fix(#1660): fail-closed frontmatter set of object-list fields instead of silent no-op (#1664)

* fix(#1660): fail-closed frontmatter set of object-list fields instead of silent no-op

cmdFrontmatterSet reported {updated:true} even when spliceFrontmatter returned the
content unchanged, which happened whenever the new value's extractFrontmatter projection
equalled the original's — notably for object-list fields like must_haves, whose
{path,provides} items flatten to scalar strings under the lossy parser. Detect a no-op
(newContent === content) for a dict-valued field and surface an error directing the user
to edit the file directly, instead of silently accepting a no-op set. Scalars and scalar
arrays round-trip faithfully, so idempotent sets of those are intentionally NOT flagged
(two precision regression tests lock this). Folded into frontmatter-cli.test.cjs.

* chore(#1660): backfill changeset pr ref to 1664

* refactor(#1660): extract noOpObjectListSetError as pure tested helper (Stryker coverage)

cmdFrontmatterSet is not in Stryker's property/unit test set, so the inline no-op
detection added survivors that dropped the frontmatter module below its 62% mutation
threshold. Extract the detection into a pure exported helper noOpObjectListSetError and
unit-test every branch directly (changed content, scalar, scalar-array, null, dict
no-op). cmdFrontmatterSet now calls the helper. Same pattern as the #1572 spliceFrontmatter
coverage fix.
This commit is contained in:
Tom Boucher
2026-06-24 13:39:17 -04:00
committed by GitHub
parent f615eb9ef3
commit e1d768dd78
4 changed files with 97 additions and 0 deletions

View File

@@ -503,10 +503,35 @@ function cmdFrontmatterSet(cwd: string, filePath: string, field: string | undefi
try { parsedValue = JSON.parse(value as string); } catch { parsedValue = value; }
fm[field as string] = parsedValue as FrontmatterValue;
const newContent = spliceFrontmatter(content, fm);
// #1660: a no-op set (newContent unchanged) with a dict-valued field means the lossy
// frontmatter parser made the new value's projection equal the original's — the change
// did not apply (bites object-list fields like must_haves). Detection lives in the pure
// exported helper noOpObjectListSetError so the mutation gate (property/unit set) covers
// it — the cmd path itself is not in that set.
const noOpErr = noOpObjectListSetError(content, newContent, parsedValue);
if (noOpErr) {
output({ error: noOpErr, field }, raw, undefined);
return;
}
platformWriteSync(fullPath, newContent);
output({ updated: true, field, value: parsedValue }, raw, 'true');
}
/**
* #1660: detect a frontmatter `set` that would be a silent no-op on a dict-valued field.
* Returns an error message when the splice produced no content change but the new value
* is a dict (object-list fields like must_haves, whose `{path, provides}` items flatten to
* scalar strings under extractFrontmatter so a replacement can deep-equal the original's
* projection), else null. Scalars and scalar arrays round-trip faithfully, so idempotent
* sets of those are intentionally NOT flagged. Pure and unit-tested directly (the cmd path
* is not in Stryker's property/unit set, so the detection must be testable in isolation).
*/
function noOpObjectListSetError(originalContent: string, newContent: string, parsedValue: unknown): string | null {
if (newContent !== originalContent) return null;
if (parsedValue === null || typeof parsedValue !== 'object' || Array.isArray(parsedValue)) return null;
return 'frontmatter set had no effect — the supplied value is equivalent to the existing field under the frontmatter parser, which cannot faithfully round-trip object-list fields like must_haves. Edit the file directly.';
}
function cmdFrontmatterMerge(cwd: string, filePath: string, data: string | undefined, raw: boolean): void {
if (!filePath || !data) { error('file and data required'); }
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
@@ -543,6 +568,7 @@ export = {
parseFrontmatter: extractFrontmatter,
reconstructFrontmatter,
spliceFrontmatter,
noOpObjectListSetError,
parseMustHavesBlock,
FRONTMATTER_SCHEMAS,
cmdFrontmatterGet,