Skip to content

fix(surface): default missing optional fields in readSurface, normalize writeSurface input#3666

Open
cristianuibar wants to merge 6 commits into
gsd-build:mainfrom
cristianuibar:fix/3662-readsurface-partial-fields-silent-null
Open

fix(surface): default missing optional fields in readSurface, normalize writeSurface input#3666
cristianuibar wants to merge 6 commits into
gsd-build:mainfrom
cristianuibar:fix/3662-readsurface-partial-fields-silent-null

Conversation

@cristianuibar
Copy link
Copy Markdown
Contributor

@cristianuibar cristianuibar commented May 17, 2026

Fix PR

Linked Issue

Fixes #3662

The linked issue carries the confirmed-bug label (priority: low, area: installer, runtime: claude-code).


What was broken

readSurface() in get-shit-done/bin/lib/surface.cjs rejected any .gsd-surface.json whose four expected fields were not all present-and-typed, returning null. Every caller treated null as "no surface state," so a partial or hand-edited .gsd-surface.json silently degraded the active profile to full with no log line and no error — debugging "my standard profile reverted" was effectively blind.

What this fix does

  • Lenient on optional fields. Missing or wrong-typed optional array fields (disabledClusters, explicitAdds, explicitRemoves) now default to []. They are meaningfully empty, so a hand-edited surface state with only some fields keeps working.
  • Loud on real failures. Hard validation failures still return null, but now emit a console.warn naming the file and the reason. The four diagnostic paths: unreadable file (other than ENOENT), malformed JSON, non-object root, missing/non-string baseProfile. File-absent stays silent because it's the expected "no surface configured" path.
  • Symmetric writer. writeSurface() normalizes its input to the full four-field shape before writing, and throws TypeError if baseProfile is missing or empty. The writer/reader asymmetry that produced the original bug cannot recur — you can't write a partial state from inside the SDK any more.

Root cause

writeSurface did no shape validation and just stringified whatever object the caller handed it, while readSurface enforced strict structural validation and silently swallowed any mismatch as null. Asymmetric writer/reader with a silent failure mode.

Testing

How I verified the fix

  • node --test tests/surface-state.test.cjs — 17/17 pass (was 11).
  • node --test tests/surface-state.test.cjs tests/surface-resolve.test.cjs tests/surface-apply.test.cjs tests/surface-list.test.cjs tests/surface-clusters.test.cjs tests/bug-3562-codex-install-skill-surface.test.cjs — 43/43 pass.
  • npm test — 9975/9978 pass. The two failures (tests/bug-3491-nested-git-worktree.test.cjs and tests/enh-3170-graphify-commit-staleness.test.cjs) are pre-existing on main baseline; they fail identically without this change and are unrelated to surface-state IO.
  • Verified the reporter's reproduction: dropping explicitRemoves from .gsd-surface.json now produces a populated SurfaceState (with explicitRemoves: []) and resolveSurface returns surface:standard instead of falling back to profile:full.

Regression test added?

  • Yes — added six tests in tests/surface-state.test.cjs:
    • JSON missing baseProfile field returns null and warns (#3662)
    • JSON with non-string baseProfile returns null and warns
    • JSON with non-object root returns null and warns
    • missing optional array field defaults to [] (#3662)
    • all optional arrays missing default to [] (#3662)
    • non-array optional field is coerced to [] (#3662)
    • writeSurface normalizes partial input — all four fields land on disk (#3662)
    • writeSurface rejects missing baseProfile (#3662 writer guard)
    • Existing corrupt JSON returns null extended to also assert the warn message.
  • A small captureWarn(fn) helper silences/captures console.warn so test output stays clean.

Platforms tested

  • Linux (WSL2)
  • macOS
  • Windows
  • N/A

The fix is filesystem-agnostic; it operates on path.join + fs.readFileSync + JSON parsing, no platform-specific code paths.

Runtimes tested

  • Claude Code (the runtime that writes ~/.claude/skills/.gsd-surface.json)
  • Gemini CLI
  • OpenCode
  • N/A

Checklist

  • Issue linked above with Fixes #3662
  • Linked issue has the confirmed-bug label
  • Fix is scoped to the reported bug — no unrelated changes
  • Regression tests added
  • All existing tests pass (npm test — pre-existing unrelated failures noted above)
  • .changeset/ fragment added: .changeset/witty-birds-gather.md (type: Fixed, pr: 3662)
  • No unnecessary dependencies added

Breaking changes

Minor behavioral change, no API break:

  • writeSurface() now throws TypeError if baseProfile is missing or empty. All in-tree callers (tests + production) already pass a non-empty string, so no production caller is affected. External callers passing complete SurfaceState are unaffected. This is the symmetric writer guard the linked issue explicitly requested.
  • readSurface() now emits console.warn on hard validation failures. Previous behavior was silent. Callers depending on absolute silence on bad input will see new stderr output, but the returned value (null) is unchanged.
  • .gsd-surface.json files missing optional array fields used to return null; they now return a fully-populated record. This is the user-visible bug fix.

No CHANGELOG.md breaking-change entry needed — fields that defaulted to null now default to a usable record.

Summary by CodeRabbit

  • Bug Fixes

    • Reader/writer now validate surface files consistently: hard validation failures return null and emit warnings; unknown profile names warn; comma-only or blank base profiles are rejected. Missing or mistyped optional arrays default to [] and are normalized on save to avoid degraded files.
  • Tests

    • Expanded tests for warning behavior, normalization defaults, stricter save validation, and improved temp-dir cleanup.
  • Documentation

    • Added a changeset documenting the revised reader/writer behavior.

Review Change Stack

…iteSurface input

readSurface used to reject any .gsd-surface.json missing one of its four fields
and return null with no diagnostic, so the active surface silently degraded to
the 'full' profile. Optional array fields (disabledClusters, explicitAdds,
explicitRemoves) now default to [] when missing or wrong-typed; hard failures
(malformed JSON, non-object root, missing/non-string baseProfile) still return
null but emit a console.warn naming the file + reason.

writeSurface now normalizes its input to the full SurfaceState shape and
throws on missing baseProfile, so partial writes can no longer land on disk
and trip readSurface later. Tests extended to cover the new lenient and
warn-on-hard-fail behavior plus the writer guard.

Fixes gsd-build#3662
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 17, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f5882280-f78a-45d4-a2b8-7761e76a7afa

📥 Commits

Reviewing files that changed from the base of the PR and between 9706ef3 and 93816c9.

📒 Files selected for processing (3)
  • .changeset/witty-birds-gather.md
  • get-shit-done/bin/lib/surface.cjs
  • tests/surface-state.test.cjs
✅ Files skipped from review due to trivial changes (1)
  • .changeset/witty-birds-gather.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • get-shit-done/bin/lib/surface.cjs

📝 Walkthrough

Walkthrough

readSurface now validates JSON root and baseProfile, warns on hard failures and returns null, and normalizes missing/mistyped optional arrays to []. writeSurface enforces non-blank baseProfile, warns on unknown/mistyped fields, normalizes partial input, and atomically writes the normalized four-field payload. Tests and a changeset were updated.

Changes

Surface State Validation and Normalization

Layer / File(s) Summary
Profile mode parsing and unknown detection
get-shit-done/bin/lib/surface.cjs
Adds KNOWN_PROFILE_NAMES, effectiveProfileModes() and unknownProfileModes() plus mistypedOptionalFields() to detect unknown/composed profile modes and non-array optional fields.
SurfaceState normalization helper
get-shit-done/bin/lib/surface.cjs
Adds normalizeSurfaceState(input) which clones baseProfile and defaults missing/non-array optional fields (disabledClusters, explicitAdds, explicitRemoves) to [].
Read validation and normalization
get-shit-done/bin/lib/surface.cjs
readSurface() treats ENOENT as absent, emits console.warn and returns null for unreadable/malformed/non-object/invalid baseProfile cases, warns on unknown profile modes and mistyped optional fields, and returns a normalized four-field SurfaceState for valid JSON.
Write validation, normalization, and persist
get-shit-done/bin/lib/surface.cjs
writeSurface() throws on missing/blank/non-string baseProfile, warns on unknown/mistyped optional fields, normalizes partial input to the complete four-field payload, and writes the normalized JSON atomically.
Test helpers, cleanup, and expanded matrix
tests/surface-state.test.cjs
Adds tmpDir()/captureWarn() helpers, centralizes cleanup via cleanup(), expands readSurface tests (malformed JSON, non-object root, missing/invalid/whitespace/comma-only baseProfile, unknown-mode warnings), tests optional-array defaulting/coercion with warnings, and extends writeSurface tests for validation, warning capture, normalization, and persistence.
Changeset documentation
.changeset/witty-birds-gather.md
Documents updated behavior: missing optional arrays default to [], hard validation failures emit console.warn and return null, and writeSurface() validates and normalizes to eliminate reader/writer asymmetry.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant readSurface
  participant Filesystem
  participant JSONparse as JSON.parse
  participant normalizeSurfaceState

  Caller->>readSurface: readSurface(runtimeConfigDir)
  readSurface->>Filesystem: platformReadSync(".gsd-surface.json")
  Filesystem-->>readSurface: file contents / ENOENT / read error
  readSurface->>JSONparse: JSON.parse(contents)
  JSONparse-->>readSurface: parsed value / parse error
  readSurface->>normalizeSurfaceState: normalizeSurfaceState(parsedObject)
  normalizeSurfaceState-->>readSurface: normalized SurfaceState
  readSurface-->>Caller: return normalized SurfaceState or null (with console.warn)
Loading
sequenceDiagram
  participant Caller
  participant writeSurface
  participant normalizeSurfaceState
  participant Filesystem

  Caller->>writeSurface: writeSurface(runtimeConfigDir, surfaceState)
  writeSurface->>normalizeSurfaceState: normalizeSurfaceState(surfaceState)
  normalizeSurfaceState-->>writeSurface: normalized SurfaceState
  writeSurface->>Filesystem: platformWriteSync(JSON.stringify(normalized))
  Filesystem-->>writeSurface: write result
  writeSurface-->>Caller: return / throw on invalid baseProfile
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~40 minutes

Possibly related issues

  • #3659 — Adjacent observation about readSurface rejecting partial surface files and causing silent fallback; these changes address that reader/writer asymmetry and warning behavior.

Suggested labels

size/XL

Suggested reviewers

  • glittercowboy

Poem

🐰 I nibbled lines where surfaces were torn,

Missing arrays made profiles forlorn,
Now reads will warn and gently fill the space,
Writes validate and keep the state in place,
Hopping home content — no silent fallback scorn.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and specifically describes the main change: defaulting missing optional fields in readSurface and normalizing writeSurface input.
Description check ✅ Passed The description fully follows the fix template with all required sections: What was broken, What this fix does, Root cause, Testing (with regression tests documented), Platforms tested, Runtimes tested, and a comprehensive Checklist.
Linked Issues check ✅ Passed The PR fully addresses all coding requirements from issue #3662: lenient optional field handling with [] defaults, diagnostic warnings on hard failures while preserving silent ENOENT, symmetric writer validation rejecting missing/blank baseProfile, unknown profile mode detection, and comprehensive regression tests.
Out of Scope Changes check ✅ Passed All changes are scoped to surface-state IO: readSurface/writeSurface normalization, validation helpers, and corresponding test coverage. No unrelated modifications to other modules or functionality.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@get-shit-done/bin/lib/surface.cjs`:
- Around line 103-104: The current guard allows whitespace-only baseProfile
values (e.g., "  ") which later normalize to empty modes; update the validation
in readSurface() and writeSurface() to reject such values by checking typeof
parsed.baseProfile === 'string' && parsed.baseProfile.trim().length > 0 (and the
symmetric check in the writer) instead of just non-empty string, and use the
same trimmed check where baseProfile is written to ensure whitespace-only
profiles are not accepted or emitted.

In `@tests/surface-state.test.cjs`:
- Around line 95-106: The test is creating and cleaning a temp dir manually;
replace the local tmpDir() and fs.rmSync cleanup in the 'corrupt JSON returns
null and warns' test (and the other listed tests) with the shared temp/cleanup
helpers exported from tests/helpers.cjs: import and use the helper that creates
a temporary project and ensures teardown (instead of tmpDir() and manual
fs.rmSync), write the '.gsd-surface.json' into the helper-provided path, and
keep the assertions around readSurface and captureWarn unchanged so cleanup is
automatic via the helpers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 82f717e7-322d-4ed9-bcec-9d1dd607d507

📥 Commits

Reviewing files that changed from the base of the PR and between 835dd6a and 4558aa1.

📒 Files selected for processing (3)
  • .changeset/witty-birds-gather.md
  • get-shit-done/bin/lib/surface.cjs
  • tests/surface-state.test.cjs
Comment thread get-shit-done/bin/lib/surface.cjs Outdated
Comment thread tests/surface-state.test.cjs
…lpers.cjs

Applies CodeRabbit findings on PR gsd-build#3666:

1. readSurface and writeSurface now reject baseProfile values that are blank
   after trim() (e.g. "   "), not only the empty string. Whitespace-only
   strings would split-by-comma to [''] downstream and silently produce an
   unresolvable profile mode. Both guards updated symmetrically; warn/error
   messages reworded to "missing, non-string, or blank".

2. tests/surface-state.test.cjs now uses createTempDir + cleanup from
   tests/helpers.cjs instead of local mkdtempSync + fs.rmSync, aligning with
   the repo coding guideline for root-level tests. The local tmpDir() helper
   delegates to createTempDir for backward-compat with the existing test
   bodies. Per-test cleanup calls swapped to cleanup(dir).

3. Added regression tests:
   - readSurface rejects whitespace-only baseProfile and warns
   - writeSurface rejects whitespace-only baseProfile, non-string baseProfile,
     and null surfaceState

Refs gsd-build#3662
@cristianuibar
Copy link
Copy Markdown
Contributor Author

Addressed in 88d1ebd

@coderabbitai both findings applied:

1. Whitespace-only baseProfile — both readSurface and writeSurface now reject on trim() === ''. Warn/error messages updated to "missing, non-string, or blank". New regression test JSON with whitespace-only baseProfile returns null and warns (#3662 CodeRabbit) locks the reader path; writeSurface rejects missing, empty, or blank baseProfile extended to also cover whitespace, non-string, and null inputs.

2. Migrate new tests to tests/helpers.cjssurface-state.test.cjs now imports createTempDir + cleanup from ./helpers.cjs. The local tmpDir() helper delegates to createTempDir('gsd-surface-state-') to keep the existing test bodies unchanged; all 18 fs.rmSync cleanup calls swapped to cleanup(dir). Other surface-*.test.cjs peers in this directory still use the local pattern — out of scope for this PR. Happy to follow up with a dedicated cleanup PR if maintainers want full alignment across the file family.

Re-running review pass please.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 17, 2026

@cristianuibar Thanks for the thorough follow-up on commit 88d1ebd! Both items look well-handled:

  1. Whitespace-only baseProfile — the trim() === '' guard on both paths, updated diagnostic messages, and the dedicated regression tests covering reader and writer edge cases (whitespace, non-string, null) are exactly the right approach.
  2. tests/helpers.cjs migration — delegating via the local tmpDir() shim is a clean way to keep test bodies untouched while centralising the teardown. The note about sibling files being out of scope for this PR is well-scoped; happy to track that as a follow-up if you want to open an issue for it.

Kicking off a fresh review pass now.

🐇 ✨

✅ Actions performed

Full review triggered.

Applies a Codex review finding on PR gsd-build#3666:

readSurface and writeSurface used to accept any non-blank string as
baseProfile. A typo like {"baseProfile":"standrad"} would pass validation,
then resolveProfile() in install-profiles.cjs would silently fall back to
'full' with no diagnostic — the same silent-degradation symptom that gsd-build#3662
was filed to fix, just through a different code path.

Both functions now split baseProfile by comma, validate each mode against
the registered PROFILES set ('core', 'standard', 'full'), and emit a single
[gsd] console.warn line that names the unknown modes and lists the valid
ones. The state is still parsed/written — resolveProfile() decides the
actual resolution fallback. Composed profiles where some modes are valid
and some are not warn only about the unknown subset.

Side note: the pre-existing 'round-trips composed base profile' test used
'core,audit' as a stand-in composed string. 'audit' is not a registered
profile (the three known profiles are 'core', 'standard', 'full'), so the
test was relying on the old lack of validation. Switched to 'core,standard'
to preserve the round-trip intent without producing diagnostic noise.

Refs gsd-build#3662
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/surface-state.test.cjs (1)

126-140: 💤 Low value

Missing warnings.length assertion for consistency.

The non-string baseProfile test (lines 126-140) asserts warnings[0] matches /baseProfile/ but doesn't verify exactly one warning was emitted, unlike the adjacent tests. Consider adding assert.strictEqual(warnings.length, 1) for consistency with other validation tests.

Suggested fix
       const { result, warnings } = captureWarn(() => readSurface(dir));
       assert.strictEqual(result, null);
+      assert.strictEqual(warnings.length, 1);
       assert.match(warnings[0], /baseProfile/);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/surface-state.test.cjs` around lines 126 - 140, Add an assertion that
exactly one warning was emitted in the "JSON with non-string baseProfile returns
null and warns" test: after calling captureWarn(() => readSurface(dir)) and
before cleanup, assert.strictEqual(warnings.length, 1). This ensures consistency
with the adjacent validation tests and refers to the existing test block that
calls readSurface and checks warnings[0] for /baseProfile/.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.changeset/witty-birds-gather.md:
- Line 5: Update the changelog sentence describing readSurface() failures to
include blank/whitespace-only baseProfile cases: change the phrase
“missing/non-string `baseProfile`” to “missing, non-string, or blank (including
whitespace-only) `baseProfile`” so it accurately matches the validation behavior
of readSurface() and writeSurface() regarding `.gsd-surface.json`.

---

Nitpick comments:
In `@tests/surface-state.test.cjs`:
- Around line 126-140: Add an assertion that exactly one warning was emitted in
the "JSON with non-string baseProfile returns null and warns" test: after
calling captureWarn(() => readSurface(dir)) and before cleanup,
assert.strictEqual(warnings.length, 1). This ensures consistency with the
adjacent validation tests and refers to the existing test block that calls
readSurface and checks warnings[0] for /baseProfile/.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 391ba657-decf-467f-9cd4-d46846dd996c

📥 Commits

Reviewing files that changed from the base of the PR and between 835dd6a and 88d1ebd.

📒 Files selected for processing (3)
  • .changeset/witty-birds-gather.md
  • get-shit-done/bin/lib/surface.cjs
  • tests/surface-state.test.cjs
Comment thread .changeset/witty-birds-gather.md Outdated
@cristianuibar
Copy link
Copy Markdown
Contributor Author

Codex review finding addressed in c6aef48

Codex flagged a second silent-fall-back path adjacent to the original bug:

baseProfile only rejects non-string/blank values, not unknown profile names. A typo like "standrad" silently enables the full skill surface because resolveProfile() falls back to full when no modes match.

Same symptom shape as #3662 (silent profile reversion), different cause. Added a warn-on-unknown layer in both readSurface and writeSurface:

  • Splits baseProfile by comma, trims each mode, validates against the registered PROFILES set (core, standard, full).
  • Emits a single [gsd] console.warn line naming the unknown modes and listing the valid ones.
  • State is still parsed/written; resolveProfile() keeps ownership of the actual resolution fallback.
  • Composed profiles where some modes are valid and some are not warn only about the unknown subset.

Four new tests added:

  1. typo in baseProfile warns about unknown mode but still returns state
  2. composed baseProfile warns only about unknown member
  3. known composed baseProfile does not warn
  4. writeSurface warns on unknown profile mode but still writes

Side note: the pre-existing round-trips composed base profile test used 'core,audit' as a stand-in composed string. 'audit' is not a registered profile, so it was incidentally relying on the lack of validation. Switched to 'core,standard' to preserve the round-trip intent without producing diagnostic noise. Pure test housekeeping — no behavior change.

44/44 surface tests pass. CI is green on the previous push; awaiting CI on c6aef48.

…cases

CodeRabbit minor finding on PR gsd-build#3666 — the changeset wording only mentioned
"missing/non-string baseProfile" but the implementation also rejects blank
(including whitespace-only) baseProfile values, and warns on typo'd /
unknown profile mode names. Updated to match actual behavior.
@cristianuibar
Copy link
Copy Markdown
Contributor Author

CodeRabbit minor finding addressed in 5813ed2

Changeset wording updated to include blank/whitespace-only and unknown-name cases:

  • "missing/non-string" → "missing, non-string, or blank (including whitespace-only)"
  • Added: "Unknown profile names in baseProfile (e.g. a typo like 'standrad') also warn — they were previously swallowed by resolveProfile()'s fallback-to-full."
  • Added: writer behavior mention of TypeError on blank/non-string and warn on unknown.

Now matches the implementation across all three commits in this PR.

Codex review finding on PR gsd-build#3666. The changeset's `pr: 3662` was the linked
issue (gsd-build#3662), but the convention across other .changeset/*.md files is that
`pr:` carries the PR number. Verified by spot-checking other changesets
(2937 → pr: 3515, 3298 → pr: 3306, 3541 → pr: 3547 — all PR numbers).
Updated to `pr: 3666`. The body text still references the issue.
@cristianuibar
Copy link
Copy Markdown
Contributor Author

Codex caught a third finding: changeset pr field — 'pr: 3662' was the linked issue number; verified convention across other changesets is that the field holds the PR number. Fixed to 'pr: 3666' in 9706ef3.

… fields

Three Gemini review findings on PR gsd-build#3666 — all spirit-of-gsd-build#3662 edge cases:

1. Comma-only baseProfile bypasses validation. readSurface used to accept
   baseProfile: ", ," because trim() returned "," (non-empty). Downstream
   resolveProfile() would split-and-filter to [] and silently fall back to
   'full'. Added effectiveProfileModes() helper that splits, trims, filters
   empty — both readSurface and writeSurface now reject when the result is
   empty. Same silent-degradation symptom as the original bug.

2. Wrong-typed optional fields silently coerced. readSurface used to coerce
   {disabledClusters: 42} to {disabledClusters: []} with no diagnostic.
   Now warns via mistypedOptionalFields() before normalizeSurfaceState()
   does the coercion, in both reader and writer.

3. Missing test coverage for the EACCES branch in readSurface. Added a
   chmod-000 unreadable-file test, skipped on Windows and root accounts
   (mode bits are ignored on those platforms).

48/48 surface tests pass. Final codex pass returned LGTM on the prior
state; these three additions strengthen the same lenient/loud contract.

Refs gsd-build#3662
@cristianuibar
Copy link
Copy Markdown
Contributor Author

Gemini review findings addressed in 93816c9

Gemini caught three additional spirit-of-#3662 edge cases:

  1. Comma-only baseProfile bypasses validation. ", ,".trim() returns "," which passed the blank check; downstream resolveProfile() split-and-filtered to [] and silently fell back to full. Same silent-degradation symptom as the original bug, different input shape. Fixed via a new effectiveProfileModes() helper used in both readSurface and writeSurface blank-check; reader warn message reworded to "missing, non-string, blank, or comma-only".

  2. Wrong-typed optional field silently coerced. {disabledClusters: 42} used to coerce to [] with no diagnostic. Now both readSurface and writeSurface warn before normalizeSurfaceState() does the coercion. New mistypedOptionalFields() helper enumerates which fields had the wrong type.

  3. EACCES branch untested. Added a chmod-000 unreadable-file test for readSurface's non-ENOENT path. Skipped on Windows and root accounts (mode bits are ignored there).

48/48 surface tests pass. Codex's previous LGTM pass covered the same surface; these three additions strengthen the same lenient/loud contract.

Total state of this PR:

@cristianuibar
Copy link
Copy Markdown
Contributor Author

Convergence reached

  • CI: 13/13 green on HEAD 93816c9 (changeset-lint, check-branch, docs-lint, Issue link required, lint-tests, PR template format, security, size-check, test (ubuntu-22), test (ubuntu-24), test (macos-24), CodeRabbit, dismiss-unauthorized-approval).
  • CodeRabbit: no new comments on the final push. All 3 prior findings (whitespace baseProfile, helpers.cjs migration, changeset wording) addressed in earlier commits.
  • Codex final pass: LGTM — "No remaining real bug or meaningful test gap in the requested readSurface() silently returns null on partial-field .gsd-surface.json, degrading profile to 'full' with no diagnostic #3662 surface-state changes."
  • Gemini final pass: LGTM — "The state management for the skill surface is now robust, defensive, and provides clear feedback for configuration errors."

48/48 surface tests pass locally. Ready for maintainer review.

@trek-e
Copy link
Copy Markdown
Collaborator

trek-e commented May 17, 2026

This was generated by AI during review.

Review: NEEDS-CHANGES

Fix is correct in intent and scope is reasonable, but the test file violates two CONTRIBUTING.md rules that the project enforces specifically because they produce passing-but-useless tests.

Critical — must fix before merge

1. tests/surface-state.test.cjs — pervasive try/finally in test bodies (banned by CONTRIBUTING.md).

"Never use try/finally inside test bodies. It is verbose, masks test failures, and is not an approved pattern in this project."

The PR adds ~15 new try { … } finally { cleanup(dir); } blocks. Pattern 1 (beforeEach/afterEach) or Pattern 2 (t.after(() => cleanup(dir))) are the two approved forms. The chmod-restore sites are also fragile: if readSurface throws unexpectedly, the chmod-back is skipped and cleanup fails. t.after() eliminates both failure modes.

2. assert.match on rendered console.warn prose violates the "Prohibited: Raw Text Matching" rule.

17 occurrences in the new tests: assert.match(warnings[0], /unreadable/), /malformed JSON/, /baseProfile/, /unknown profile mode/, /object root/, /wrong type/, /comma-only/, etc. CONTRIBUTING.md's canonical bad example is assert.match on a free-form reason string from a JSON report.

Fix: surface a frozen reason enum from surface.cjs:

const READ_FAIL = Object.freeze({ MALFORMED_JSON: 'malformed_json', NON_OBJECT_ROOT: 'non_object_root', BAD_BASE_PROFILE: 'bad_base_profile', UNREADABLE: 'unreadable' });
const WARN = Object.freeze({ UNKNOWN_PROFILE_MODE: 'unknown_profile_mode', WRONG_TYPE_OPTIONAL: 'wrong_type_optional' });

Then return / console.warn a structured payload ({code, fields}), and tests assert warnings[0].code === WARN.UNKNOWN_PROFILE_MODE. The current tests will silently rot the moment anyone rewords a warning string.

Important

3. Scope creep on writeSurface. Persisting a typo'd baseProfile (e.g., 'standrad') with a warning is a worse default than rejecting it — the on-disk file is now wrong but valid-shaped, and subsequent reads will warn forever. The reporter's bug (#3662) was the reader silently dropping state; the writer changes feel beyond scope. Recommend: throw TypeError on unknown modes from the writer. If soft tolerance is wanted, file as its own issue.

4. [...KNOWN_PROFILE_NAMES].join(', ') iteration order depends on PROFILES declaration order. Test at L196 splits on (valid: then asserts bogus is in the unknown half and core is not. Brittle to PROFILES key reordering — falls out for free once you switch to the structured-warning approach above.

Suggestions

  • Move warn diagnostics behind a single surfaceWarn(code, payload) seam so callers can mock it cleanly (no console.warn monkey-patch dance in captureWarn).
  • Add tests/fixtures/adversarial/surface/ per CONTRIBUTING.md parser-input matrix; duplicate keys and mixed CRLF/LF on .gsd-surface.json are not covered.

CodeRabbit status

  • "Reject whitespace-only baseProfile" — applied (88d1ebd).
  • "Migrate tests to tests/helpers.cjs" — partially applied (88d1ebd); helpers imported but the structural try/finally violation (Critical not working after npx install #1) is unaddressed.
  • "Include blank/whitespace-only in documented read failures" — applied (c6aef485813ed2).

CodeRabbit did not flag either of the two CONTRIBUTING.md antipatterns above.

Verdict

Convert test cleanup to beforeEach/afterEach or t.after(), swap warning regexes for an enum-backed structured payload, decide whether writer should reject typo'd modes. Tests should then come out shorter, not longer.

@trek-e trek-e added area: core PROJECT.md, REQUIREMENTS.md, templates needs changes Review requested changes that must be addressed review: changes requested PR reviewed — changes required before merge size/M and removed review: changes requested PR reviewed — changes required before merge needs changes Review requested changes that must be addressed labels May 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: core PROJECT.md, REQUIREMENTS.md, templates size/M

2 participants