fix(#2202): preserve unknown frontmatter keys in syncStateFrontmatter (#2233)

* fix(#2202): preserve unknown frontmatter keys in syncStateFrontmatter

syncStateFrontmatter rebuilds frontmatter from a fixed schema, dropping any
custom/unknown key on every mutating verb. Before reconstruction, merge any
existing frontmatter key the schema does not own. Schema keys still win.

Closes #2202

* docs(#2202): add changeset fragment

* docs(#2202): backfill PR number

* fix(#2202): add regression test + remove redundant type assertion

- tests/state.test.cjs: behavioral regression test asserting custom/unknown
  STATE.md frontmatter keys survive a mutating verb (they were silently dropped
  before the syncStateFrontmatter carry-forward).
- src/state.cts: drop the unnecessary `as Record<string, unknown>` assertion
  that tripped @typescript-eslint/no-unnecessary-type-assertion (the lint-tests
  gate failure).

Refs #2202

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-13 12:40:52 -04:00
committed by GitHub
parent ffd353f080
commit 7ccf57200d
3 changed files with 46 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 2233
---
**Custom STATE.md frontmatter keys are no longer dropped on every mutating verb** — syncStateFrontmatter rebuilt the frontmatter from a fixed schema, silently dropping any custom key. It now carries forward existing keys the schema does not own. (#2202)

View File

@@ -1650,6 +1650,16 @@ function syncStateFrontmatter(content: string, cwd: string | undefined): string
derivedFm['progress'] = normalizeProgressNumbers(existingFm['progress']);
}
// #2202: carry forward any existing frontmatter key that the schema does not
// own, so custom/unknown keys are not silently dropped on every mutating verb.
// Schema-owned keys (already in derivedFm from buildStateFrontmatter + the
// preserve guards above) still win.
for (const key of Object.keys(existingFm)) {
if (!(key in derivedFm) && existingFm[key] !== undefined) {
derivedFm[key] = existingFm[key];
}
}
const yamlStr = reconstructFrontmatter(derivedFm as unknown as Frontmatter);
return `---\n${yamlStr}\n---\n\n${body}`;
}

View File

@@ -611,6 +611,37 @@ milestone: v1.0
assert.ok(!content.includes('status: unknown'), 'should not contain unknown status');
});
test('#2202: preserves unknown frontmatter keys the schema does not own', () => {
// Regression: a mutating verb rewrites STATE.md via syncStateFrontmatter,
// which rebuilds frontmatter from the body + schema. Before #2202 it dropped
// any frontmatter key the schema does not own; custom/tooling keys must
// survive every write.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`---
status: executing
milestone: v1.0
custom_tracking_id: ABC-123
team: platform
---
# Project State
**Current Phase:** 03
**Current Plan:** 03-02
`
);
// Any writeStateMd triggers syncStateFrontmatter.
runGsdTools('state update "Current Plan" "03-03"', tmpDir);
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.match(content, /custom_tracking_id: ABC-123/, 'unknown key custom_tracking_id must be preserved');
assert.match(content, /team: platform/, 'unknown key team must be preserved');
// Schema-owned keys still win / survive alongside the carried-forward keys.
assert.ok(content.includes('status: executing'), 'schema-owned status still preserved');
});
test('round-trip: write then read via state json', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),