* test(#4802): failing-first — acknowledge must refuse an unparseable-frontmatter target instead of splicing over it * fix(#4802): audit acknowledge refuses targets whose frontmatter fails to parse — never splices the marker-marked object over the file * chore(#4802): backfill changeset PR number (4895) --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 4889
|
||||
pr: 4895
|
||||
---
|
||||
**check.decision-coverage-plan stops answering an unmeasured shape** — on could-not-parse it reported covered: 0 / uncovered: [] (fields of a measurement that never happened) and a directory passed as the context path certified passed: true; the gate now answers covered: null / total: null with the unreadable decision ids (and omits uncovered), and a non-file context path fails closed naming the path. (#4794)
|
||||
|
||||
@@ -30,7 +30,7 @@ import frontmatter = require('./frontmatter.cjs');
|
||||
// does not require this module, so the edge is acyclic.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import commandsModule = require('./commands.cjs');
|
||||
const { extractFrontmatter, spliceFrontmatter } = frontmatter;
|
||||
const { extractFrontmatter, FRONTMATTER_UNPARSEABLE, spliceFrontmatter } = frontmatter;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseIdMod = require('./phase-id.cjs');
|
||||
const { PHASE_NUMBER_TOKEN_SOURCE, scopeToPhase } = phaseIdMod;
|
||||
@@ -1711,6 +1711,13 @@ function cmdAuditAcknowledge(cwd: string, args: string[], raw: boolean): void {
|
||||
|
||||
const content = fs.readFileSync(safeFilePath, 'utf-8');
|
||||
const fm = extractFrontmatter(content, safeFilePath);
|
||||
// #4802: an unparseable frontmatter block is NOT an empty one — splicing
|
||||
// the marker-marked object over the file would discard every field the
|
||||
// author actually wrote. Refuse and name the file (the write-path
|
||||
// counterpart of the read-side FRONTMATTER_UNPARSEABLE contract).
|
||||
if ((fm as unknown as Record<symbol, unknown>)[FRONTMATTER_UNPARSEABLE] === true) {
|
||||
ioError(`refusing to acknowledge — the frontmatter of "${file as string}" is not parseable YAML (splicing would discard every other frontmatter field); fix the YAML syntax error first, then re-run`);
|
||||
}
|
||||
// Mixed-frame fix (security review, 4th instance on this branch): the
|
||||
// splice above and below stays keyed to RAW `content` (raw byte offsets
|
||||
// must not shift), but `scanUatGaps`/`scanContextQuestions` now derive
|
||||
@@ -1853,6 +1860,13 @@ function cmdAuditAcknowledge(cwd: string, args: string[], raw: boolean): void {
|
||||
|
||||
const presenceOnly = category === 'todos';
|
||||
const fm = createIfMissing ? fmForCreate : extractFrontmatter(fs.readFileSync(safeFilePath, 'utf-8'), safeFilePath);
|
||||
// #4802: same unparseable-frontmatter refusal as the phase-scoped branch —
|
||||
// createIfMissing never reaches this extract (it only fires when the file is
|
||||
// absent), so an existing file with broken YAML refuses instead of splicing
|
||||
// a near-empty object over every field the author wrote.
|
||||
if (!createIfMissing && (fm as unknown as Record<symbol, unknown>)[FRONTMATTER_UNPARSEABLE] === true) {
|
||||
ioError(`refusing to acknowledge — the frontmatter of "${safeFilePath}" is not parseable YAML (splicing would discard every other frontmatter field); fix the YAML syntax error first, then re-run`);
|
||||
}
|
||||
fm.audit_acknowledged = presenceOnly ? { ...markerBase } : { ...markerBase, [snapshotKey]: currentValue };
|
||||
const newContent = createIfMissing
|
||||
? spliceFrontmatter('', fm)
|
||||
|
||||
@@ -2673,3 +2673,69 @@ describe('bug #950: quick-task SUMMARY must carry status: complete', () => {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── #4802: an unparseable frontmatter block must not be spliced over ──────
|
||||
|
||||
describe('#4802: acknowledge refuses targets whose frontmatter fails to parse', () => {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempProject('gsd-4802-'); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
function planningPath(...segs) {
|
||||
return path.join(tmpDir, '.planning', ...segs);
|
||||
}
|
||||
|
||||
// A real YAML SYNTAX error (the issue's second shape: an invalid backslash
|
||||
// escape inside a double-quoted value). Note the issue's first shape
|
||||
// (unescaped colons inside a double-quoted value) is actually LEGAL YAML —
|
||||
// double-quoted scalars may contain colons — so the fixture uses a shape
|
||||
// that genuinely fails to parse. Empirically verified against the built
|
||||
// frontmatter.cjs: this content returns an object marked
|
||||
// FRONTMATTER_UNPARSEABLE.
|
||||
const UNPARSEABLE_FM = [
|
||||
'---',
|
||||
'status: complete',
|
||||
'ref: "bad\\q escape"',
|
||||
'key-decisions:',
|
||||
' - decision one',
|
||||
'---',
|
||||
].join('\n');
|
||||
|
||||
function ack(tmpDir, args) {
|
||||
return runGsdTools(['audit-open', 'acknowledge', ...args, '--json'], tmpDir);
|
||||
}
|
||||
|
||||
test('threads: an unparseable-frontmatter target is refused, file byte-identical', () => {
|
||||
const threadsDir = planningPath('threads');
|
||||
fs.mkdirSync(threadsDir, { recursive: true });
|
||||
const filePath = path.join(threadsDir, 'broken-yaml.md');
|
||||
const before = UNPARSEABLE_FM + '\n# Thread\n';
|
||||
fs.writeFileSync(filePath, before, 'utf-8');
|
||||
|
||||
const result = ack(tmpDir, ['--category', 'threads', '--slug', 'broken-yaml', '--milestone', 'v1.0']);
|
||||
assert.ok(!result.success, `acknowledge must refuse; stdout: ${result.output}\nstderr: ${result.error}`);
|
||||
assert.ok(
|
||||
(result.error || '').includes('not parseable YAML') && (result.error || '').includes('broken-yaml.md'),
|
||||
`the refusal must name the file and the unparseable frontmatter; stderr: ${result.error}`,
|
||||
);
|
||||
assert.strictEqual(fs.readFileSync(filePath, 'utf-8'), before,
|
||||
'the file must be byte-identical — no splice may discard frontmatter');
|
||||
});
|
||||
|
||||
test('uat_gaps (phase-scoped): an unparseable-frontmatter target is refused, file byte-identical', () => {
|
||||
const ctxDir = planningPath('phases', '01-init');
|
||||
fs.mkdirSync(ctxDir, { recursive: true });
|
||||
const filePath = path.join(ctxDir, 'CONTEXT.md');
|
||||
const before = UNPARSEABLE_FM + '\n# Context\n';
|
||||
fs.writeFileSync(filePath, before, 'utf-8');
|
||||
fs.writeFileSync(path.join(ctxDir, '01-01-PLAN.md'), '# Plan\n');
|
||||
|
||||
const result = ack(tmpDir, ['--category', 'uat_gaps', '--phase', '01', '--file', 'CONTEXT.md', '--milestone', 'v1.0']);
|
||||
assert.ok(!result.success, `acknowledge must refuse; stdout: ${result.output}\nstderr: ${result.error}`);
|
||||
assert.strictEqual(fs.readFileSync(filePath, 'utf-8'), before,
|
||||
'the file must be byte-identical — no splice may discard frontmatter');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user