chore(#3484): adr-0174 behavior carry-forward amendment and merge gate (#3507)

* chore(#3484): adr-0174 behavior carry-forward amendment and merge gate

* chore(#3484): regen example context index for new ruleset predicates

* chore(#3484): review fixes - amendment heading per contributor-standards, helper-based fixtures

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-14 19:31:48 -04:00
committed by GitHub
parent ddf852873c
commit fba7c90327
9 changed files with 599 additions and 268 deletions

View File

@@ -29,7 +29,7 @@ const fs = require('fs');
const path = require('path');
const { parseDecisions, extractDecisions } = require('../gsd-core/bin/lib/decisions.cjs');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const { runGsdTools, createTempProject, createTempDir, cleanup } = require('./helpers.cjs');
// ─── Regression #1364: markdown-header fallback ───────────────────────────────
@@ -1300,3 +1300,119 @@ describe('check.decision-coverage-plan — empty contextPath argument fails clos
`Missing contextPath argument must fail closed. Got: ${JSON.stringify(parsed)}`);
});
});
// ─── #3484: modified-file truncation bound (MAX_MODIFIED_FILE_BYTES) ─────────
//
// readModifiedFilesContent harvests files_modified entries from phase summaries into
// the decision-coverage-verify haystack, capping each file's content. The bound
// survived the ADR-0174 SDK collapse as a bare `256 * 1024` literal duplicated in one
// expression; #3484 restored it as MAX_MODIFIED_FILE_BYTES. These rows pin the bound
// behaviorally — honored/not_honored flips at exactly 262,144 chars — so the value
// cannot drift silently again. The constant counts String#length (UTF-16 code units),
// not bytes; semantics preserved from the retired lineage.
//
// Fixture facts these rows depend on (verified against the compiled CLI):
// - files_modified entries resolve against the PROJECT root (cwd), not phaseDir;
// - decisionMentioned matches \bD-99\b, so the id needs a non-word char before it.
describe('check.decision-coverage-verify — modified-file truncation bound (#3484)', () => {
const CAP = 256 * 1024;
let tmpDir;
let phaseDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-3484-');
phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init');
fs.mkdirSync(phaseDir, { recursive: true });
});
afterEach(() => cleanup(tmpDir));
function writeVerifyFixture(modifiedFileContent) {
writeGateFiles(['# Summary', '', 'files_modified:', '- big-modified.txt', '', 'Wrapped up the widget work.']);
fs.writeFileSync(path.join(tmpDir, 'big-modified.txt'), modifiedFileContent);
return path.join(phaseDir, 'CONTEXT.md');
}
function writeGateFiles(summaryLines) {
writeContextFile(phaseDir, [
'# Phase Context',
'',
'<decisions>',
'- **D-99:** keep the marker token unique to this fixture',
'</decisions>',
].join('\n'));
writePlanFile(phaseDir, '01', '# Plan\n\n## Must Haves\n\n- deliver the widget\n');
fs.writeFileSync(path.join(phaseDir, '01-SUMMARY.md'), summaryLines.join('\n') + '\n');
}
function runVerify(contextPath) {
const result = runGsdTools(
['query', 'check.decision-coverage-verify', phaseDir, contextPath],
tmpDir
);
return JSON.parse(result.output || '{}');
}
test('id ending at the last included char is kept (limit)', () => {
// Length exactly CAP: `raw.length > CAP` is false → content passes through whole,
// so a D-99 whose last char sits at index CAP-1 is honored. Pins > vs >=.
const contextPath = writeVerifyFixture('x'.repeat(CAP - 5) + ' D-99');
const parsed = runVerify(contextPath);
assert.strictEqual(parsed.honored, 1,
`D-99 ending at index CAP-1 must be honored. Got: ${JSON.stringify(parsed)}`);
});
test('id beyond the cap is truncated away (limit+1)', () => {
// Length CAP+4 with D-99 starting AT index CAP: slice(0, CAP) drops it entirely.
const contextPath = writeVerifyFixture('x'.repeat(CAP - 1) + ' D-99');
const parsed = runVerify(contextPath);
assert.strictEqual(parsed.honored, 0,
`D-99 starting at index CAP must be truncated away. Got: ${JSON.stringify(parsed)}`);
assert.ok(
(parsed.not_honored || []).some((item) => item.id === 'D-99'),
`D-99 must land in not_honored. Got: ${JSON.stringify(parsed)}`
);
});
test('id within a small modified file is honored (happy)', () => {
const contextPath = writeVerifyFixture('D-99 ' + 'y'.repeat(64));
const parsed = runVerify(contextPath);
assert.strictEqual(parsed.honored, 1,
`D-99 in a small modified file must be honored. Got: ${JSON.stringify(parsed)}`);
});
test('files_modified entry outside the root is skipped', (t) => {
// A sibling dir (outside the project root) holding a file that WOULD satisfy the
// decision — proving isInsideRoot skipped it, as distinct from a missing file.
const sibling = createTempDir('gsd-3484-escape-');
t.after(() => cleanup(sibling));
fs.writeFileSync(path.join(sibling, 'escape.txt'), 'D-99 '.repeat(10));
writeGateFiles(['# Summary', '', 'files_modified:', `- ../${path.basename(sibling)}/escape.txt`, '']);
const parsed = runVerify(path.join(phaseDir, 'CONTEXT.md'));
assert.strictEqual(parsed.honored, 0,
`An out-of-root files_modified entry must be skipped, not harvested. Got: ${JSON.stringify(parsed)}`);
assert.ok(
(parsed.not_honored || []).some((item) => item.id === 'D-99'),
'D-99 must be reported not honored — the only D-99 lives outside the root'
);
});
test('unreadable files_modified entry does not crash the gate', () => {
writeGateFiles(['# Summary', '', 'files_modified:', '- does-not-exist.txt', '']);
const parsed = runVerify(path.join(phaseDir, 'CONTEXT.md'));
assert.strictEqual(parsed.honored, 0,
`A missing file must harvest empty content, not crash. Got: ${JSON.stringify(parsed)}`);
});
test('summary without files_modified yields no haystack content', () => {
writeGateFiles(['# Summary', '', 'No file list here.']);
const parsed = runVerify(path.join(phaseDir, 'CONTEXT.md'));
assert.strictEqual(parsed.honored, 0,
`No files_modified block means D-99 cannot be honored. Got: ${JSON.stringify(parsed)}`);
});
});