* test(#3338): fold the verify/validate & workflow-text issue-* cluster — Wave 6 Folds 10 legacy issue-*.test.cjs regression files (75 test() blocks) into their module's main suite, per H3 (#3315) of the test-hygiene epic (#3053). Third of 4 issue-* waves. - issue-2701-nul-corrupted-validators.test.cjs (9) + issue-429-comment- text-gate.test.cjs (31, incl. fast-check property tests): both target verify.cjs/validate.cjs via different calling styles (CLI vs direct- require) — merged jointly into verify.test.cjs, 0 dropped. - issue-2762-plan-reviews-chunked.test.cjs (3) merged into plan-phase-drift-guard.test.cjs. - issue-2771-advisor-subagent-type.test.cjs (1) + issue-2772-discuss- phase-text-inconsistencies.test.cjs (6) merged jointly into discuss-phase-power.test.cjs. - issue-498-update-backup-runtime-dir.test.cjs (3, rename basis) + issue-815-update-next-channel.test.cjs (7, merged in): both concern update.md workflow-text contracts, now update-workflow.test.cjs. - issue-498-update-context.test.cjs (13): pure rename to update-context.test.cjs, sole comprehensive suite for its module. - issue-2765-brace-expansion-lockfile.test.cjs (1, rename basis) + issue-3238-js-yaml-lockfile.test.cjs (1, merged in): two distinct CVE regression pins against package-lock.json, now lockfile-cve-audit.test.cjs, shared ROOT/npmLs helpers deduped instead of double-declared. Ratchet upkeep to keep this wave's own gates green: pruned 3 stale allow-test-rule allowlist entries, cited 2 previously-uncited comments that surfaced in the folded content (#3338), tightened the exemption-file ceiling 305 -> 297. Fixed one stale filename reference in production code (src/init.cts) plus one in docs/reference/workflow-fragments.md. Zero net test-coverage loss. No production code BEHAVIOR changed. * test(#3338): fix orthogonal-review findings — Wave 6 fold Standards-axis review found a real structural defect in two files, both the same root cause and both fixed here: - tests/update-workflow.test.cjs: the folded:issue-815-update-next-channel wrapper's closing brace was placed after the file's pre-existing tail instead of before it, making the already-established folded:bug-2470 and folded:bug-3130 wrappers CHILDREN of issue-815's block in the test hierarchy instead of independent siblings — confirmed via an actual node --test run showing the mislabeled TAP nesting. Moved the closing brace to the correct position; all three fold wrappers are now top-level siblings again (verified via node --test, TAP hierarchy correct, 10/10 tests, 5 suites, identical count before and after). - tests/plan-phase-drift-guard.test.cjs: same mistake in the other direction — folded:issue-2762-plan-reviews-chunked was spliced inside the pre-existing folded:bug-2492-context-coverage-gate wrapper instead of after it. Fixed the same way (227/227 tests, 38 suites, identical count before and after). - Reverted unnecessary 815-suffixed local renames (assert815/fs815/etc.) introduced by the fold — the wrapper is genuinely block-scoped once correctly closed, so no collision existed (same class as Wave 4's __foldSetNested finding). No test() count changed in either file. No production code touched. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -610,7 +610,7 @@ steps (`backup_custom_files`, `restore_custom_files`) still depend on.
|
|||||||
`--rc`) is resolved in PARALLEL with, not in place of, `update.md`'s own
|
`--rc`) is resolved in PARALLEL with, not in place of, `update.md`'s own
|
||||||
`TAG="next"`/`TAG="latest"` case-statement in `parse_update_channel`, which
|
`TAG="next"`/`TAG="latest"` case-statement in `parse_update_channel`, which
|
||||||
stays byte-identical — issue #815's regression test
|
stays byte-identical — issue #815's regression test
|
||||||
(`tests/issue-815-update-next-channel.test.cjs`) asserts that literal
|
(`tests/update-workflow.test.cjs`) asserts that literal
|
||||||
case-statement text remains in the workflow, since the npm dist-tag
|
case-statement text remains in the workflow, since the npm dist-tag
|
||||||
selection has to run in the workflow's own shell before any `gsd_run`
|
selection has to run in the workflow's own shell before any `gsd_run`
|
||||||
round-trip.
|
round-trip.
|
||||||
|
|||||||
@@ -77,9 +77,6 @@
|
|||||||
"tests/intel.test.cjs :: source-text-is-the-product",
|
"tests/intel.test.cjs :: source-text-is-the-product",
|
||||||
"tests/inventory-headings-countfree.test.cjs :: source-text-is-the-product",
|
"tests/inventory-headings-countfree.test.cjs :: source-text-is-the-product",
|
||||||
"tests/ios-scaffold-safety.test.cjs :: source-text-is-the-product",
|
"tests/ios-scaffold-safety.test.cjs :: source-text-is-the-product",
|
||||||
"tests/issue-429-comment-text-gate.test.cjs :: source-text-is-the-product",
|
|
||||||
"tests/issue-498-update-backup-runtime-dir.test.cjs :: source-text-is-the-product",
|
|
||||||
"tests/issue-815-update-next-channel.test.cjs :: source-text-is-the-product",
|
|
||||||
"tests/legacy-cleanup.test.cjs :: integration-test-input",
|
"tests/legacy-cleanup.test.cjs :: integration-test-input",
|
||||||
"tests/locking-bugs-1909-1916-1925-1927.test.cjs :: architectural-invariant",
|
"tests/locking-bugs-1909-1916-1925-1927.test.cjs :: architectural-invariant",
|
||||||
"tests/mcp-tool-inheritance.test.cjs :: source-text-is-the-product",
|
"tests/mcp-tool-inheritance.test.cjs :: source-text-is-the-product",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"maxFiles": 305,
|
"maxFiles": 297,
|
||||||
"grace": 3
|
"grace": 3
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2659,7 +2659,7 @@ function cmdInitDocsUpdate(cwd: string, raw: boolean, options: Record<string, un
|
|||||||
* `state:chunked-mode`/`state:plan-strategy-converge`). This DELIBERATELY
|
* `state:chunked-mode`/`state:plan-strategy-converge`). This DELIBERATELY
|
||||||
* does not replace `update.md`'s own `parse_update_channel` case-statement
|
* does not replace `update.md`'s own `parse_update_channel` case-statement
|
||||||
* (`TAG="next"`/`TAG="latest"`) — issue #815's regression test
|
* (`TAG="next"`/`TAG="latest"`) — issue #815's regression test
|
||||||
* (`tests/issue-815-update-next-channel.test.cjs`) asserts that literal
|
* (`tests/update-workflow.test.cjs`) asserts that literal
|
||||||
* case-statement text stays in `update.md` verbatim (the npm dist-tag
|
* case-statement text stays in `update.md` verbatim (the npm dist-tag
|
||||||
* selection has to run in the workflow's own shell before any `gsd_run`
|
* selection has to run in the workflow's own shell before any `gsd_run`
|
||||||
* round-trip), so `next_channel` exists purely to gate the `channel-banner`
|
* round-trip), so `next_channel` exists purely to gate the `channel-banner`
|
||||||
|
|||||||
@@ -152,3 +152,113 @@ describe('discuss-phase power user mode (#1513)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/issue-2771-advisor-subagent-type.test.cjs — H3 Wave 6 (#3338)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe('folded:issue-2771-advisor-subagent-type', () => {
|
||||||
|
// allow-test-rule: structural-implementation-guard (#2771)
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Regression guard for #2771: the discuss-phase advisor mode must spawn the REGISTERED
|
||||||
|
// `gsd-advisor-researcher` subagent (auto-loads the agent def), not `general-purpose` —
|
||||||
|
// which contradicts universal-anti-patterns rule 10 (injected into discuss-phase via
|
||||||
|
// <required_reading>): "NEVER use non-GSD agent types — ALWAYS use gsd-{agent}".
|
||||||
|
// Spawning general-purpose + a manual "read the agent def" prompt re-specifies what the
|
||||||
|
// def already owns (a drift risk; the same shape assumptions's answer_validation hit).
|
||||||
|
|
||||||
|
const ADVISOR_MD = path.join(
|
||||||
|
__dirname, '..', 'gsd-core', 'workflows', 'discuss-phase', 'modes', 'advisor.md'
|
||||||
|
);
|
||||||
|
|
||||||
|
test('advisor mode spawns the registered gsd-advisor-researcher subagent, not general-purpose (#2771)', () => {
|
||||||
|
const src = fs.readFileSync(ADVISOR_MD, 'utf8');
|
||||||
|
|
||||||
|
// Locate the Agent() block that researches gray areas.
|
||||||
|
const agentIdx = src.indexOf('subagent_type=');
|
||||||
|
assert.ok(agentIdx !== -1, 'advisor.md must contain an Agent() subagent_type declaration');
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
src.includes('subagent_type="gsd-advisor-researcher"'),
|
||||||
|
'advisor mode must spawn subagent_type="gsd-advisor-researcher" (the registered agent def auto-loads) — not general-purpose (#2771, universal-anti-patterns rule 10)'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!src.includes('subagent_type="general-purpose"'),
|
||||||
|
'advisor mode must NOT spawn subagent_type="general-purpose" (contradicts universal-anti-patterns rule 10, injected into the same context) (#2771)'
|
||||||
|
);
|
||||||
|
// The manual "read @.../gsd-advisor-researcher.md" prompt line must be gone —
|
||||||
|
// spawning by type auto-loads the def; re-specifying it is a drift risk. Deny the
|
||||||
|
// full class (any "read @" lead-in, case-insensitive) so a phrasing variant can't
|
||||||
|
// sneak the drift back in.
|
||||||
|
assert.ok(
|
||||||
|
!/read\s+@.*gsd-advisor-researcher\.md/i.test(src),
|
||||||
|
'advisor mode must not manually instruct reading the agent def — spawning by type auto-loads it (#2771)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/issue-2772-discuss-phase-text-inconsistencies.test.cjs — H3 Wave 6 (#3338)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe('folded:issue-2772-discuss-phase-text-inconsistencies', () => {
|
||||||
|
// allow-test-rule: structural-implementation-guard (#2772)
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Regression guard for #2772: four self-contained text inconsistencies in the
|
||||||
|
// discuss-phase surface, each a literal-instruction hazard. The shipped markdown IS
|
||||||
|
// the runtime contract, so structural inspection is the correct guard.
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
||||||
|
|
||||||
|
test('auto.md does not read the dead MAX_PASSES / max_discuss_passes config (#2772.1)', () => {
|
||||||
|
const src = read('gsd-core/workflows/discuss-phase/modes/auto.md');
|
||||||
|
assert.ok(/single pass/i.test(src), 'auto.md must still mandate the single-pass rule');
|
||||||
|
assert.ok(!/MAX_PASSES=/.test(src), 'auto.md must not read MAX_PASSES (dead config — single-pass rule governs) (#2772)');
|
||||||
|
assert.ok(!/max_discuss_passes/.test(src), 'auto.md must not reference max_discuss_passes (contradicts the single-pass rule) (#2772)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gate-prompts context-handling matches the actual check_existing options (#2772.2)', () => {
|
||||||
|
const src = read('gsd-core/references/gate-prompts.md');
|
||||||
|
const ctx = src.slice(src.indexOf('## Pattern: context-handling'), src.indexOf('## Pattern: gray-area-option'));
|
||||||
|
assert.ok(/Update it \| View it \| Skip/.test(ctx), 'context-handling options must be "Update it | View it | Skip" (the actual check_existing flow) (#2772)');
|
||||||
|
assert.ok(!/Overwrite \| Append \| Cancel/.test(ctx), 'context-handling must NOT document the obsolete "Overwrite | Append | Cancel" (#2772)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gate-prompts gray-area-option does not mandate "Let Claude decide" (#2772.2)', () => {
|
||||||
|
const src = read('gsd-core/references/gate-prompts.md');
|
||||||
|
const gray = src.slice(src.indexOf('## Pattern: gray-area-option'));
|
||||||
|
assert.ok(!/Always include "Let Claude decide"/i.test(gray), 'gray-area-option must NOT mandate "Let Claude decide" — it contradicts discuss-phase.md:353 ("Do NOT include a skip or you decide option") (#2772)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discuss-phase auto_advance fallback ends the workflow, not routes back to confirm_creation (#2772.3)', () => {
|
||||||
|
const src = read('gsd-core/workflows/discuss-phase.md');
|
||||||
|
const step = src.slice(src.indexOf('<step name="auto_advance">'), src.indexOf('</step>', src.indexOf('<step name="auto_advance">')));
|
||||||
|
assert.ok(!/route to `confirm_creation`/.test(step), 'auto_advance fallback must not route back to confirm_creation (it already ran earlier in the step order — circular) (#2772)');
|
||||||
|
assert.ok(/end here|workflow is complete/i.test(step), 'auto_advance fallback must explicitly END the workflow (positive anchor — a re-phrased regression should not slip past) (#2772)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discuss-phase-assumptions auto_advance fallback also ends the workflow (sibling of #2772.3)', () => {
|
||||||
|
const src = read('gsd-core/workflows/discuss-phase-assumptions.md');
|
||||||
|
const step = src.slice(src.indexOf('<step name="auto_advance">'), src.indexOf('</step>', src.indexOf('<step name="auto_advance">')));
|
||||||
|
assert.ok(!/Route to confirm_creation step/.test(step), 'assumptions auto_advance fallback must not route back to confirm_creation (same circularity as the parent) (#2772)');
|
||||||
|
assert.ok(/end here|workflow is complete/i.test(step), 'assumptions auto_advance fallback must explicitly END the workflow (#2772)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discuss-phase-assumptions answer_validation matches the parent canonical content (#2772.4)', () => {
|
||||||
|
const parent = read('gsd-core/workflows/discuss-phase.md');
|
||||||
|
const assumptions = read('gsd-core/workflows/discuss-phase-assumptions.md');
|
||||||
|
// The parent's canonical answer_validation includes the "Other" empty-text branch.
|
||||||
|
const parentBlock = parent.slice(parent.indexOf('<answer_validation>'), parent.indexOf('</answer_validation>') + '</answer_validation>'.length);
|
||||||
|
const assumptionsBlock = assumptions.slice(assumptions.indexOf('<answer_validation>'), assumptions.indexOf('</answer_validation>') + '</answer_validation>'.length);
|
||||||
|
assert.ok(/"Other" with empty text/.test(assumptionsBlock), 'assumptions answer_validation must include the "Other" empty-text branch (was drifted) (#2772)');
|
||||||
|
// The two blocks must now agree on the empty-response handling.
|
||||||
|
assert.strictEqual(assumptionsBlock, parentBlock, 'discuss-phase-assumptions answer_validation must match the parent canonical block exactly (single source of truth) (#2772)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
// Regression tests for #2701 — plan/summary/verification/state validators silently
|
|
||||||
// accept NUL-corrupted files and report valid:true.
|
|
||||||
//
|
|
||||||
// A NUL-corrupted text artifact is binary-classified by file(1) and silently
|
|
||||||
// OMITTED from recursive / binary-skipping search results (rg -l, grep -rI,
|
|
||||||
// exit 0), so the corruption reads downstream as "file absent" rather than
|
|
||||||
// "file corrupt." The validators must fail loud, naming the encoding problem and
|
|
||||||
// its consequence, before any schema/structure check. The fix is at the
|
|
||||||
// validator entry points (a shared textEncodingError helper in validate.cjs),
|
|
||||||
// NOT inside the broadly-shared platformReadSync read primitive.
|
|
||||||
//
|
|
||||||
// NUL bytes are written via Buffer so they survive onto disk (a string write
|
|
||||||
// would not). Cleanup via t.after(() => cleanup(tmpDir)).
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { test, describe } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
||||||
const { writeState } = require('./fixtures/index.cjs');
|
|
||||||
|
|
||||||
// A structurally-complete PLAN.md that passes both validators when clean.
|
|
||||||
function validPlanBody() {
|
|
||||||
return [
|
|
||||||
'---',
|
|
||||||
'phase: 01-test',
|
|
||||||
'plan: 01',
|
|
||||||
'type: execute',
|
|
||||||
'wave: 1',
|
|
||||||
'depends_on: []',
|
|
||||||
'files_modified: [some/file.ts]',
|
|
||||||
'autonomous: true',
|
|
||||||
'must_haves:',
|
|
||||||
' truths:',
|
|
||||||
' - "something is true"',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'<tasks>',
|
|
||||||
'',
|
|
||||||
'<task type="auto">',
|
|
||||||
' <name>Task 1: Do something</name>',
|
|
||||||
' <files>some/file.ts</files>',
|
|
||||||
' <action>Do the thing</action>',
|
|
||||||
' <verify><automated>npx vitest run</automated></verify>',
|
|
||||||
' <done>Thing is done</done>',
|
|
||||||
'</task>',
|
|
||||||
'',
|
|
||||||
'</tasks>',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Write `body` to a fresh phase plan path, optionally injecting a NUL at `nulAt`. */
|
|
||||||
function writePlan(tmpDir, name, body, nulAt) {
|
|
||||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-test'), { recursive: true });
|
|
||||||
const p = path.join(tmpDir, '.planning', 'phases', '01-test', name);
|
|
||||||
let buf = Buffer.from(body, 'utf8');
|
|
||||||
if (nulAt !== undefined) {
|
|
||||||
buf = Buffer.concat([buf.subarray(0, nulAt), Buffer.from([0x00]), buf.subarray(nulAt)]);
|
|
||||||
}
|
|
||||||
fs.writeFileSync(p, buf);
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseResult(t, argv, tmpDir) {
|
|
||||||
const r = runGsdTools(argv, tmpDir);
|
|
||||||
assert.ok(r.success, `command failed: ${r.error}`);
|
|
||||||
return JSON.parse(r.output);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── frontmatter validate --schema plan|summary|verification ────────────────
|
|
||||||
|
|
||||||
describe('#2701: frontmatter validate rejects NUL-corrupted artifacts', () => {
|
|
||||||
test('PLAN.md with an embedded NUL byte → valid:false, error names encoding + consequence', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
const rel = '.planning/phases/01-test/01-01-PLAN.md';
|
|
||||||
writePlan(tmpDir, '01-01-PLAN.md', validPlanBody(), 200);
|
|
||||||
|
|
||||||
const out = parseResult(t, ['frontmatter', 'validate', rel, '--schema', 'plan'], tmpDir);
|
|
||||||
assert.strictEqual(out.valid, false, `expected valid:false; got ${JSON.stringify(out)}`);
|
|
||||||
assert.ok(Array.isArray(out.errors) && out.errors.length > 0, 'must report errors');
|
|
||||||
const msg = out.errors.join(' ');
|
|
||||||
assert.ok(/NUL/i.test(msg), `error must name NUL/encoding: ${msg}`);
|
|
||||||
assert.ok(/skip|search|absent|missing/i.test(msg), `error must name the downstream consequence: ${msg}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('SUMMARY.md with an embedded NUL byte → valid:false', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
const dir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
const body = ['---', 'phase: 01-test', 'plan: 01', 'status: in_progress', '---', '', '# Summary', 'did the work'].join('\n');
|
|
||||||
const buf = Buffer.concat([Buffer.from(body, 'utf8').subarray(0, 30), Buffer.from([0x00]), Buffer.from(body, 'utf8').subarray(30)]);
|
|
||||||
fs.writeFileSync(path.join(dir, '01-01-SUMMARY.md'), buf);
|
|
||||||
|
|
||||||
const out = parseResult(t, ['frontmatter', 'validate', '.planning/phases/01-test/01-01-SUMMARY.md', '--schema', 'summary'], tmpDir);
|
|
||||||
assert.strictEqual(out.valid, false);
|
|
||||||
assert.ok(out.errors.some((e) => /NUL/i.test(e)));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('VERIFICATION.md with an embedded NUL byte → valid:false', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
const dir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
const body = ['---', 'phase: 01-test', 'plan: 01', 'status: passed', '---', '', '# Verification', 'all green'].join('\n');
|
|
||||||
const buf = Buffer.concat([Buffer.from(body, 'utf8').subarray(0, 40), Buffer.from([0x00]), Buffer.from(body, 'utf8').subarray(40)]);
|
|
||||||
fs.writeFileSync(path.join(dir, '01-01-VERIFICATION.md'), buf);
|
|
||||||
|
|
||||||
const out = parseResult(t, ['frontmatter', 'validate', '.planning/phases/01-test/01-01-VERIFICATION.md', '--schema', 'verification'], tmpDir);
|
|
||||||
assert.strictEqual(out.valid, false);
|
|
||||||
assert.ok(out.errors.some((e) => /NUL/i.test(e)));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── verify plan-structure ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('#2701: verify plan-structure rejects NUL-corrupted PLAN.md', () => {
|
|
||||||
test('PLAN.md with an embedded NUL byte → valid:false, error names encoding', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
const rel = '.planning/phases/01-test/01-01-PLAN.md';
|
|
||||||
writePlan(tmpDir, '01-01-PLAN.md', validPlanBody(), 200);
|
|
||||||
|
|
||||||
const out = parseResult(t, ['verify', 'plan-structure', rel], tmpDir);
|
|
||||||
assert.strictEqual(out.valid, false, `expected valid:false; got ${JSON.stringify(out)}`);
|
|
||||||
assert.ok(out.errors.some((e) => /NUL/i.test(e)), `error must name NUL: ${JSON.stringify(out.errors)}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── state validate ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('#2701: state validate rejects NUL-corrupted STATE.md', () => {
|
|
||||||
test('STATE.md with an embedded NUL byte → valid:false', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
// createTempProject() does NOT seed STATE.md; use writeState to create one,
|
|
||||||
// then corrupt it in place with a NUL byte (Buffer write so it survives).
|
|
||||||
const seed = [
|
|
||||||
'# Project',
|
|
||||||
'',
|
|
||||||
'## Status',
|
|
||||||
'executing',
|
|
||||||
'## Current Phase',
|
|
||||||
'01 of 01',
|
|
||||||
'## Total Plans in Phase',
|
|
||||||
'1',
|
|
||||||
].join('\n');
|
|
||||||
const statePath = writeState(tmpDir, seed);
|
|
||||||
const body = Buffer.from(seed, 'utf8');
|
|
||||||
const buf = Buffer.concat([body.subarray(0, 50), Buffer.from([0x00]), body.subarray(50)]);
|
|
||||||
fs.writeFileSync(statePath, buf);
|
|
||||||
|
|
||||||
const out = parseResult(t, ['state', 'validate'], tmpDir);
|
|
||||||
assert.strictEqual(out.valid, false, `expected valid:false; got ${JSON.stringify(out)}`);
|
|
||||||
assert.ok(out.warnings.some((w) => /NUL/i.test(w)), `warning must name NUL: ${JSON.stringify(out.warnings)}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── negative space: clean files still pass; non-ASCII UTF-8 not over-rejected ─
|
|
||||||
|
|
||||||
describe('#2701: clean and valid-UTF-8 files are not over-rejected', () => {
|
|
||||||
test('clean PLAN.md (no NUL) → frontmatter validate valid:true', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
const rel = '.planning/phases/01-test/01-01-PLAN.md';
|
|
||||||
writePlan(tmpDir, '01-01-PLAN.md', validPlanBody());
|
|
||||||
|
|
||||||
const out = parseResult(t, ['frontmatter', 'validate', rel, '--schema', 'plan'], tmpDir);
|
|
||||||
assert.strictEqual(out.valid, true, `clean plan must pass; got ${JSON.stringify(out)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clean PLAN.md (no NUL) → verify plan-structure valid:true', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
const rel = '.planning/phases/01-test/01-01-PLAN.md';
|
|
||||||
writePlan(tmpDir, '01-01-PLAN.md', validPlanBody());
|
|
||||||
|
|
||||||
const out = parseResult(t, ['verify', 'plan-structure', rel], tmpDir);
|
|
||||||
assert.strictEqual(out.valid, true, `clean plan must pass; got ${JSON.stringify(out)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-ASCII UTF-8 (é, emoji) without NUL is NOT rejected', (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
const rel = '.planning/phases/01-test/01-01-PLAN.md';
|
|
||||||
// High bytes are valid UTF-8; only a NUL (0x00) is the corruption signal.
|
|
||||||
const body = validPlanBody().replace('Do the thing', 'Do the thing — café ☕ naïve');
|
|
||||||
writePlan(tmpDir, '01-01-PLAN.md', body);
|
|
||||||
|
|
||||||
const out = parseResult(t, ['frontmatter', 'validate', rel, '--schema', 'plan'], tmpDir);
|
|
||||||
assert.strictEqual(out.valid, true, `valid UTF-8 high bytes must not be rejected; got ${JSON.stringify(out)}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── boundary: NUL at offset 0 and mid-file both rejected ───────────────────
|
|
||||||
|
|
||||||
describe('#2701: NUL position does not matter (start and middle both rejected)', () => {
|
|
||||||
for (const nulAt of [0, 5, 250]) {
|
|
||||||
test(`NUL at offset ${nulAt} → frontmatter validate valid:false`, (t) => {
|
|
||||||
const tmpDir = createTempProject();
|
|
||||||
t.after(() => cleanup(tmpDir));
|
|
||||||
const rel = '.planning/phases/01-test/01-01-PLAN.md';
|
|
||||||
writePlan(tmpDir, '01-01-PLAN.md', validPlanBody(), nulAt);
|
|
||||||
|
|
||||||
const out = parseResult(t, ['frontmatter', 'validate', rel, '--schema', 'plan'], tmpDir);
|
|
||||||
assert.strictEqual(out.valid, false, `NUL at offset ${nulAt} must be rejected; got ${JSON.stringify(out)}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
// allow-test-rule: structural-implementation-guard (#2762)
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// Regression guard for #2762: /gsd-plan-phase --reviews was a silent no-op in chunked
|
|
||||||
// mode. Two defects in plan-phase.md §8.5:
|
|
||||||
// A. §8.5.1 outline resume-check greps for a marker the agent only RETURNED (never
|
|
||||||
// wrote to the file) → outline always re-ran/overwrote (broke crash-resume).
|
|
||||||
// B. §8.5.2 per-plan resume-check skipped any plan with frontmatter, with no
|
|
||||||
// --reviews exception → --reviews skipped 100% of plans (contradicted §6's
|
|
||||||
// "go straight to replanning" contract).
|
|
||||||
|
|
||||||
const { test } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// #2993 fragmentization moved §8.5 (chunked planning mode, including §8.5.1 /
|
|
||||||
// §8.5.2) out of plan-phase.md into gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md.
|
|
||||||
// Read that step file directly — it is the sole remaining source of the §8.5.1/§8.5.2
|
|
||||||
// content these regression guards assert on.
|
|
||||||
const MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase', 'steps', 'chunked-planning-mode.md');
|
|
||||||
const read = () => fs.readFileSync(MD, 'utf8');
|
|
||||||
|
|
||||||
test('§8.5.1 outline agent writes the resume marker into the file (#2762 defect A)', () => {
|
|
||||||
const src = read();
|
|
||||||
// The outline prompt must instruct writing ## OUTLINE COMPLETE into PLAN-OUTLINE.md
|
|
||||||
// (the §8.5.1 resume-check greps for it in the file).
|
|
||||||
const outlineSection = src.slice(src.indexOf('### 8.5.1'), src.indexOf('### 8.5.2'));
|
|
||||||
assert.ok(
|
|
||||||
/outline|PLAN-OUTLINE/i.test(outlineSection) && /End the file.*## OUTLINE COMPLETE|write.*## OUTLINE COMPLETE.*file/i.test(outlineSection.replace(/\s+/g, ' ')),
|
|
||||||
'the outline agent prompt must instruct writing ## OUTLINE COMPLETE into the file (the resume-check greps the file for it) (#2762)'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('§8.5.2 per-plan resume-check does NOT skip under --reviews (#2762 defect B)', () => {
|
|
||||||
const src = read();
|
|
||||||
const perPlanSection = src.slice(src.indexOf('### 8.5.2'));
|
|
||||||
// The resume-check bash must gate the skip on --reviews being ABSENT.
|
|
||||||
const bashMatch = perPlanSection.match(/PLAN_FILE=[\s\S]*?fi\s*\n/);
|
|
||||||
assert.ok(bashMatch, '§8.5.2 must contain the per-plan resume-check bash block');
|
|
||||||
const bash = bashMatch[0];
|
|
||||||
assert.ok(
|
|
||||||
/--reviews/.test(bash),
|
|
||||||
'the per-plan resume-check must reference --reviews so it does NOT skip when replanning with review feedback (#2762)'
|
|
||||||
);
|
|
||||||
// The skip must be conditional on --reviews being ABSENT (e.g. ARGUMENTS != *"--reviews"*).
|
|
||||||
assert.ok(
|
|
||||||
/!=\s*\*"--reviews"\*|!~.*--reviews|--reviews.*absent|not.*--reviews/i.test(bash),
|
|
||||||
'the resume-check skip must be gated on --reviews being ABSENT (so --reviews overwrites/replans) (#2762)'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('§8.5.2 crash-resume (non-reviews) still skips written plans (#2762 negative space)', () => {
|
|
||||||
const src = read();
|
|
||||||
const perPlanSection = src.slice(src.indexOf('### 8.5.2'));
|
|
||||||
const bashMatch = perPlanSection.match(/PLAN_FILE=[\s\S]*?fi\s*\n/);
|
|
||||||
const bash = bashMatch ? bashMatch[0] : '';
|
|
||||||
assert.ok(
|
|
||||||
/head -1.*grep.*\^---|frontmatter/i.test(bash + perPlanSection.slice(0, 400)),
|
|
||||||
'crash-resume (non-reviews) must still skip plans with valid frontmatter (resume safety preserved) (#2762)'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
// allow-test-rule: structural-implementation-guard (#2765)
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// Regression guard for #2765: the lockfile must pin the patched brace-expansion
|
|
||||||
// versions (>=1.1.18 for the 1.x line, >=5.0.9 for the 5.x line) published 2026-07-30
|
|
||||||
// to resolve the high-severity DoS/OOM advisories (GHSA-3jxr-9vmj-r5cp /
|
|
||||||
// GHSA-mh99-v99m-4gvg, range <=5.0.7). This is a lockfile-only devDependency bump
|
|
||||||
// (eslint/stryker → minimatch → brace-expansion); production (npm audit --omit=dev) is
|
|
||||||
// unaffected. The test pins the installed versions so the bump can't silently regress.
|
|
||||||
|
|
||||||
const { test } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const { execFileSync } = require('node:child_process');
|
|
||||||
const path = require('node:path');
|
|
||||||
|
|
||||||
const ROOT = path.join(__dirname, '..');
|
|
||||||
|
|
||||||
// `npm` is not process.execPath, git, or a bash script/hook, so this does not
|
|
||||||
// route through tests/helpers/process-seam.cjs (whose runNode/runGit/runHook
|
|
||||||
// primitives cover exactly those three shapes and forward no `shell` option)
|
|
||||||
// — `npm` needs `shell: true` on Windows (npm.cmd), which the seam has no
|
|
||||||
// surface for. Bounding this directly with an explicit `timeout` is the
|
|
||||||
// documented alternative in eslint-rules/no-unbounded-spawn.cjs.
|
|
||||||
const NPM_LS_TIMEOUT_MS = 30000;
|
|
||||||
|
|
||||||
function npmLs(pkg) {
|
|
||||||
// `npm ls <pkg> --json --all` lists every installed copy with its version. Collect
|
|
||||||
// the version of every node whose key is `pkg` (not the parent packages).
|
|
||||||
const out = execFileSync('npm', ['ls', pkg, '--json', '--all'], {
|
|
||||||
cwd: ROOT, encoding: 'utf8', shell: true, stdio: ['ignore', 'pipe', 'ignore'],
|
|
||||||
timeout: NPM_LS_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
const versions = [];
|
|
||||||
const walk = (node) => {
|
|
||||||
if (!node || !node.dependencies) return;
|
|
||||||
for (const [k, v] of Object.entries(node.dependencies)) {
|
|
||||||
if (k === pkg && v && v.version) versions.push(v.version);
|
|
||||||
walk(v);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
walk(JSON.parse(out));
|
|
||||||
return versions;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('all installed brace-expansion copies are patched (>=1.1.18 / >=5.0.9) — #2765', () => {
|
|
||||||
const versions = npmLs('brace-expansion');
|
|
||||||
assert.ok(versions.length > 0, 'brace-expansion must be installed (devDependency) to guard');
|
|
||||||
for (const v of versions) {
|
|
||||||
const [maj, min, pat] = v.split('.').map(Number);
|
|
||||||
const ok = (maj === 1 && (min > 1 || (min === 1 && pat >= 18))) // 1.x >= 1.1.18
|
|
||||||
|| (maj === 5 && (min > 0 || pat >= 9)) // 5.x >= 5.0.9
|
|
||||||
|| (maj > 5); // >5.x
|
|
||||||
assert.ok(ok,
|
|
||||||
`brace-expansion@${v} is within the vulnerable range (<=5.0.7) — lockfile regressed the #2765 patch bump. ` +
|
|
||||||
'Re-apply: npm audit fix (non-breaking) to bump to 1.1.18 / 5.0.9.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// allow-test-rule: structural-implementation-guard (#2771)
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// Regression guard for #2771: the discuss-phase advisor mode must spawn the REGISTERED
|
|
||||||
// `gsd-advisor-researcher` subagent (auto-loads the agent def), not `general-purpose` —
|
|
||||||
// which contradicts universal-anti-patterns rule 10 (injected into discuss-phase via
|
|
||||||
// <required_reading>): "NEVER use non-GSD agent types — ALWAYS use gsd-{agent}".
|
|
||||||
// Spawning general-purpose + a manual "read the agent def" prompt re-specifies what the
|
|
||||||
// def already owns (a drift risk; the same shape assumptions's answer_validation hit).
|
|
||||||
|
|
||||||
const { test } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const ADVISOR_MD = path.join(
|
|
||||||
__dirname, '..', 'gsd-core', 'workflows', 'discuss-phase', 'modes', 'advisor.md'
|
|
||||||
);
|
|
||||||
|
|
||||||
test('advisor mode spawns the registered gsd-advisor-researcher subagent, not general-purpose (#2771)', () => {
|
|
||||||
const src = fs.readFileSync(ADVISOR_MD, 'utf8');
|
|
||||||
|
|
||||||
// Locate the Agent() block that researches gray areas.
|
|
||||||
const agentIdx = src.indexOf('subagent_type=');
|
|
||||||
assert.ok(agentIdx !== -1, 'advisor.md must contain an Agent() subagent_type declaration');
|
|
||||||
|
|
||||||
assert.ok(
|
|
||||||
src.includes('subagent_type="gsd-advisor-researcher"'),
|
|
||||||
'advisor mode must spawn subagent_type="gsd-advisor-researcher" (the registered agent def auto-loads) — not general-purpose (#2771, universal-anti-patterns rule 10)'
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
!src.includes('subagent_type="general-purpose"'),
|
|
||||||
'advisor mode must NOT spawn subagent_type="general-purpose" (contradicts universal-anti-patterns rule 10, injected into the same context) (#2771)'
|
|
||||||
);
|
|
||||||
// The manual "read @.../gsd-advisor-researcher.md" prompt line must be gone —
|
|
||||||
// spawning by type auto-loads the def; re-specifying it is a drift risk. Deny the
|
|
||||||
// full class (any "read @" lead-in, case-insensitive) so a phrasing variant can't
|
|
||||||
// sneak the drift back in.
|
|
||||||
assert.ok(
|
|
||||||
!/read\s+@.*gsd-advisor-researcher\.md/i.test(src),
|
|
||||||
'advisor mode must not manually instruct reading the agent def — spawning by type auto-loads it (#2771)'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
// allow-test-rule: structural-implementation-guard (#2772)
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// Regression guard for #2772: four self-contained text inconsistencies in the
|
|
||||||
// discuss-phase surface, each a literal-instruction hazard. The shipped markdown IS
|
|
||||||
// the runtime contract, so structural inspection is the correct guard.
|
|
||||||
|
|
||||||
const { test } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const ROOT = path.join(__dirname, '..');
|
|
||||||
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
||||||
|
|
||||||
test('auto.md does not read the dead MAX_PASSES / max_discuss_passes config (#2772.1)', () => {
|
|
||||||
const src = read('gsd-core/workflows/discuss-phase/modes/auto.md');
|
|
||||||
assert.ok(/single pass/i.test(src), 'auto.md must still mandate the single-pass rule');
|
|
||||||
assert.ok(!/MAX_PASSES=/.test(src), 'auto.md must not read MAX_PASSES (dead config — single-pass rule governs) (#2772)');
|
|
||||||
assert.ok(!/max_discuss_passes/.test(src), 'auto.md must not reference max_discuss_passes (contradicts the single-pass rule) (#2772)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gate-prompts context-handling matches the actual check_existing options (#2772.2)', () => {
|
|
||||||
const src = read('gsd-core/references/gate-prompts.md');
|
|
||||||
const ctx = src.slice(src.indexOf('## Pattern: context-handling'), src.indexOf('## Pattern: gray-area-option'));
|
|
||||||
assert.ok(/Update it \| View it \| Skip/.test(ctx), 'context-handling options must be "Update it | View it | Skip" (the actual check_existing flow) (#2772)');
|
|
||||||
assert.ok(!/Overwrite \| Append \| Cancel/.test(ctx), 'context-handling must NOT document the obsolete "Overwrite | Append | Cancel" (#2772)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gate-prompts gray-area-option does not mandate "Let Claude decide" (#2772.2)', () => {
|
|
||||||
const src = read('gsd-core/references/gate-prompts.md');
|
|
||||||
const gray = src.slice(src.indexOf('## Pattern: gray-area-option'));
|
|
||||||
assert.ok(!/Always include "Let Claude decide"/i.test(gray), 'gray-area-option must NOT mandate "Let Claude decide" — it contradicts discuss-phase.md:353 ("Do NOT include a skip or you decide option") (#2772)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('discuss-phase auto_advance fallback ends the workflow, not routes back to confirm_creation (#2772.3)', () => {
|
|
||||||
const src = read('gsd-core/workflows/discuss-phase.md');
|
|
||||||
const step = src.slice(src.indexOf('<step name="auto_advance">'), src.indexOf('</step>', src.indexOf('<step name="auto_advance">')));
|
|
||||||
assert.ok(!/route to `confirm_creation`/.test(step), 'auto_advance fallback must not route back to confirm_creation (it already ran earlier in the step order — circular) (#2772)');
|
|
||||||
assert.ok(/end here|workflow is complete/i.test(step), 'auto_advance fallback must explicitly END the workflow (positive anchor — a re-phrased regression should not slip past) (#2772)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('discuss-phase-assumptions auto_advance fallback also ends the workflow (sibling of #2772.3)', () => {
|
|
||||||
const src = read('gsd-core/workflows/discuss-phase-assumptions.md');
|
|
||||||
const step = src.slice(src.indexOf('<step name="auto_advance">'), src.indexOf('</step>', src.indexOf('<step name="auto_advance">')));
|
|
||||||
assert.ok(!/Route to confirm_creation step/.test(step), 'assumptions auto_advance fallback must not route back to confirm_creation (same circularity as the parent) (#2772)');
|
|
||||||
assert.ok(/end here|workflow is complete/i.test(step), 'assumptions auto_advance fallback must explicitly END the workflow (#2772)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('discuss-phase-assumptions answer_validation matches the parent canonical content (#2772.4)', () => {
|
|
||||||
const parent = read('gsd-core/workflows/discuss-phase.md');
|
|
||||||
const assumptions = read('gsd-core/workflows/discuss-phase-assumptions.md');
|
|
||||||
// The parent's canonical answer_validation includes the "Other" empty-text branch.
|
|
||||||
const parentBlock = parent.slice(parent.indexOf('<answer_validation>'), parent.indexOf('</answer_validation>') + '</answer_validation>'.length);
|
|
||||||
const assumptionsBlock = assumptions.slice(assumptions.indexOf('<answer_validation>'), assumptions.indexOf('</answer_validation>') + '</answer_validation>'.length);
|
|
||||||
assert.ok(/"Other" with empty text/.test(assumptionsBlock), 'assumptions answer_validation must include the "Other" empty-text branch (was drifted) (#2772)');
|
|
||||||
// The two blocks must now agree on the empty-response handling.
|
|
||||||
assert.strictEqual(assumptionsBlock, parentBlock, 'discuss-phase-assumptions answer_validation must match the parent canonical block exactly (single source of truth) (#2772)');
|
|
||||||
});
|
|
||||||
@@ -1,711 +0,0 @@
|
|||||||
// allow-test-rule: source-text-is-the-product
|
|
||||||
// Issue #429: the gate logic is tested behaviorally via the exported pure
|
|
||||||
// function + runGsdTools; the discipline rule + allowlist escape hatch are
|
|
||||||
// asserted against the agent/reference .md whose text IS the deployed contract.
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { test, describe, before, beforeEach, afterEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
|
||||||
|
|
||||||
// Build path to built verify.cjs
|
|
||||||
const VERIFY_CJS = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'verify.cjs');
|
|
||||||
|
|
||||||
// fast-check: loaded at top level so skip flags evaluate correctly
|
|
||||||
let fc;
|
|
||||||
try { fc = require('fast-check'); } catch { fc = null; }
|
|
||||||
// Build path to agent/reference files
|
|
||||||
const PLANNER_MD = path.join(__dirname, '..', 'agents', 'gsd-planner.md');
|
|
||||||
const ANTIPATTERNS_MD = path.join(__dirname, '..', 'gsd-core', 'references', 'planner-antipatterns.md');
|
|
||||||
|
|
||||||
// ─── Fixtures ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function makePlan({ negativeGrep, actionEcho, allowlistMarker, positiveGrep } = {}) {
|
|
||||||
const lines = [
|
|
||||||
'---',
|
|
||||||
'phase: 01-test',
|
|
||||||
'plan: 01',
|
|
||||||
'type: execute',
|
|
||||||
'wave: 1',
|
|
||||||
'depends_on: []',
|
|
||||||
'files_modified: [src/animal-detail.tsx]',
|
|
||||||
'autonomous: true',
|
|
||||||
'must_haves:',
|
|
||||||
' - AC1',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'# Test Plan',
|
|
||||||
'',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (allowlistMarker) {
|
|
||||||
lines.push(allowlistMarker, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('<task>');
|
|
||||||
lines.push('<name>Test task</name>');
|
|
||||||
lines.push('<action>');
|
|
||||||
if (actionEcho) {
|
|
||||||
lines.push(actionEcho);
|
|
||||||
} else {
|
|
||||||
lines.push('Do the work.');
|
|
||||||
}
|
|
||||||
lines.push('</action>');
|
|
||||||
|
|
||||||
if (positiveGrep) {
|
|
||||||
lines.push(`<verify><automated>${positiveGrep}</automated></verify>`);
|
|
||||||
} else if (negativeGrep) {
|
|
||||||
lines.push(`<verify><automated>${negativeGrep}</automated></verify>`);
|
|
||||||
} else {
|
|
||||||
lines.push('<verify><automated>npm test</automated></verify>');
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('<done>Task complete</done>');
|
|
||||||
lines.push('</task>');
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Group 1: pure-function unit tests ────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('scanNegativeGrepCommentEcho — pure unit tests', () => {
|
|
||||||
let scanNegativeGrepCommentEcho;
|
|
||||||
|
|
||||||
before(() => {
|
|
||||||
const verify = require(VERIFY_CJS);
|
|
||||||
scanNegativeGrepCommentEcho = verify.scanNegativeGrepCommentEcho;
|
|
||||||
});
|
|
||||||
|
|
||||||
test('case 1 — regression Plan 12-04: action echoes the forbidden literal', () => {
|
|
||||||
const content = makePlan({
|
|
||||||
negativeGrep: "grep -c '?from=' src/animal-detail.tsx == 0",
|
|
||||||
actionEcho: 'Do NOT reintroduce the old ?from= referrer hack.',
|
|
||||||
});
|
|
||||||
const result = scanNegativeGrepCommentEcho(content);
|
|
||||||
assert.strictEqual(result.errors.length, 1, `expected 1 error, got: ${JSON.stringify(result.errors)}`);
|
|
||||||
assert.ok(result.errors[0].includes('?from='), `error should mention ?from=, got: ${result.errors[0]}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('case 2 — regression Plan 11-04: JSDoc head-comment echoes CardModalHost', () => {
|
|
||||||
const content = makePlan({
|
|
||||||
negativeGrep: "grep -c 'CardModalHost' file == 0",
|
|
||||||
actionEcho: '* @see CardModalHost for the deprecated pattern.',
|
|
||||||
});
|
|
||||||
const result = scanNegativeGrepCommentEcho(content);
|
|
||||||
assert.strictEqual(result.errors.length, 1, `expected 1 error, got: ${JSON.stringify(result.errors)}`);
|
|
||||||
assert.ok(result.errors[0].includes('CardModalHost'), `error should mention CardModalHost, got: ${result.errors[0]}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('case 3 — regression Plan 12-02: head-comment echoes .catch(() => null) (regex-special chars)', () => {
|
|
||||||
const content = makePlan({
|
|
||||||
negativeGrep: "grep -c '.catch(() => null)' file == 0",
|
|
||||||
actionEcho: '// Old pattern: .catch(() => null)',
|
|
||||||
});
|
|
||||||
const result = scanNegativeGrepCommentEcho(content);
|
|
||||||
assert.strictEqual(result.errors.length, 1, `expected 1 error, got: ${JSON.stringify(result.errors)}`);
|
|
||||||
assert.ok(result.errors[0].includes('.catch(() => null)'), `error should mention the literal, got: ${result.errors[0]}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('case 4 — boundary: positive count gate (== 60) must NOT be flagged (AC#2)', () => {
|
|
||||||
const content = makePlan({
|
|
||||||
positiveGrep: "grep -c '= makeParallel(' file == 60",
|
|
||||||
actionEcho: 'Use makeParallel() for concurrent processing.',
|
|
||||||
});
|
|
||||||
const result = scanNegativeGrepCommentEcho(content);
|
|
||||||
assert.strictEqual(result.errors.length, 0, `positive count gate must not flag, errors: ${JSON.stringify(result.errors)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('case 5 — no echo: literal only in verify, not in action', () => {
|
|
||||||
const content = makePlan({
|
|
||||||
negativeGrep: "grep -c 'LEGACY_TOKEN' file == 0",
|
|
||||||
actionEcho: 'Remove the old token handling.',
|
|
||||||
});
|
|
||||||
const result = scanNegativeGrepCommentEcho(content);
|
|
||||||
assert.strictEqual(result.errors.length, 0, 'should be no errors');
|
|
||||||
assert.strictEqual(result.warnings.length, 0, 'should be no warnings');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('case 6 — allowlist marker suppresses the error', () => {
|
|
||||||
const content = makePlan({
|
|
||||||
negativeGrep: "grep -c '?from=' src/animal-detail.tsx == 0",
|
|
||||||
actionEcho: 'Do NOT reintroduce the old ?from= referrer hack.',
|
|
||||||
allowlistMarker: '<!-- planner-discipline-allow: ?from= -->',
|
|
||||||
});
|
|
||||||
const result = scanNegativeGrepCommentEcho(content);
|
|
||||||
assert.strictEqual(result.errors.length, 0, `allowlist should suppress error, got: ${JSON.stringify(result.errors)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('case 7 — ambiguous unquoted bareword echo: warning not error', () => {
|
|
||||||
const content = makePlan({
|
|
||||||
negativeGrep: 'grep -c badToken file == 0',
|
|
||||||
actionEcho: 'Remove badToken from codebase.',
|
|
||||||
});
|
|
||||||
const result = scanNegativeGrepCommentEcho(content);
|
|
||||||
assert.strictEqual(result.errors.length, 0, `ambiguous token must not error, got: ${JSON.stringify(result.errors)}`);
|
|
||||||
assert.strictEqual(result.warnings.length, 1, `ambiguous token should warn once, got: ${JSON.stringify(result.warnings)}`);
|
|
||||||
assert.ok(result.warnings[0].includes('badToken'), `warning should mention badToken, got: ${result.warnings[0]}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('case 8 — negative-grep command inside an <action> does NOT self-flag', () => {
|
|
||||||
// action tells executor to ADD the verify command — the grep itself is in the action
|
|
||||||
// but there is no echo of selfToken outside the grep command
|
|
||||||
const lines = [
|
|
||||||
'---',
|
|
||||||
'phase: 01-test',
|
|
||||||
'plan: 01',
|
|
||||||
'type: execute',
|
|
||||||
'wave: 1',
|
|
||||||
'depends_on: []',
|
|
||||||
'files_modified: [file.ts]',
|
|
||||||
'autonomous: true',
|
|
||||||
'must_haves:',
|
|
||||||
' - AC1',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'<task>',
|
|
||||||
'<name>Add verify command</name>',
|
|
||||||
'<action>',
|
|
||||||
"Add this to the CI script: grep -c 'selfToken' file == 0",
|
|
||||||
'</action>',
|
|
||||||
'<verify><automated>npm test</automated></verify>',
|
|
||||||
'<done>Done</done>',
|
|
||||||
'</task>',
|
|
||||||
].join('\n');
|
|
||||||
const verify = require(VERIFY_CJS);
|
|
||||||
const r = verify.scanNegativeGrepCommentEcho(lines);
|
|
||||||
assert.strictEqual(r.errors.length, 0, `grep command in action must not self-flag, errors: ${JSON.stringify(r.errors)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('case 9 — CRLF newlines are normalized', () => {
|
|
||||||
const content = makePlan({
|
|
||||||
negativeGrep: "grep -c '?from=' src/animal-detail.tsx == 0",
|
|
||||||
actionEcho: 'Do NOT reintroduce the old ?from= referrer hack.',
|
|
||||||
});
|
|
||||||
const crlfContent = content.split('\n').join('\r\n');
|
|
||||||
const result = scanNegativeGrepCommentEcho(crlfContent);
|
|
||||||
assert.strictEqual(result.errors.length, 1, `CRLF content should still find error, got: ${JSON.stringify(result.errors)}`);
|
|
||||||
assert.ok(result.errors[0].includes('?from='));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('case 10 — multiple distinct echoed literals each produce their own error', () => {
|
|
||||||
const lines = [
|
|
||||||
'---',
|
|
||||||
'phase: 01-test',
|
|
||||||
'plan: 01',
|
|
||||||
'type: execute',
|
|
||||||
'wave: 1',
|
|
||||||
'depends_on: []',
|
|
||||||
'files_modified: [file.ts]',
|
|
||||||
'autonomous: true',
|
|
||||||
'must_haves:',
|
|
||||||
' - AC1',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'<task>',
|
|
||||||
'<name>Multi literal task</name>',
|
|
||||||
'<action>',
|
|
||||||
"Remove tokA and tokB from the codebase.",
|
|
||||||
'</action>',
|
|
||||||
"<verify><automated>grep -c 'tokA' file == 0 && grep -c 'tokB' file == 0</automated></verify>",
|
|
||||||
'<done>Done</done>',
|
|
||||||
'</task>',
|
|
||||||
].join('\n');
|
|
||||||
const verify = require(VERIFY_CJS);
|
|
||||||
const result = verify.scanNegativeGrepCommentEcho(lines);
|
|
||||||
assert.strictEqual(result.errors.length, 2, `expected 2 errors (one per literal), got: ${JSON.stringify(result.errors)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('case 11 — != 0 and >= 0 are NOT negative gates', () => {
|
|
||||||
const verify = require(VERIFY_CJS);
|
|
||||||
const content1 = makePlan({
|
|
||||||
negativeGrep: "grep -c 'nz' file != 0",
|
|
||||||
actionEcho: 'Ensure nz is present.',
|
|
||||||
});
|
|
||||||
const r1 = verify.scanNegativeGrepCommentEcho(content1);
|
|
||||||
assert.strictEqual(r1.errors.length, 0, `!= 0 must not trigger, errors: ${JSON.stringify(r1.errors)}`);
|
|
||||||
|
|
||||||
const content2 = makePlan({
|
|
||||||
negativeGrep: "grep -c 'nz' file >= 0",
|
|
||||||
actionEcho: 'Ensure nz is present.',
|
|
||||||
});
|
|
||||||
const r2 = verify.scanNegativeGrepCommentEcho(content2);
|
|
||||||
assert.strictEqual(r2.errors.length, 0, `>= 0 must not trigger, errors: ${JSON.stringify(r2.errors)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Bug-fix regression tests (adversarial-review findings) ───────────────────
|
|
||||||
|
|
||||||
test('case 12 — mixed positive+negative on one line: no false positive for positive gate token', () => {
|
|
||||||
// Bug 1: mixed positive+negative greps on one physical line — presentTok is a
|
|
||||||
// *positive* gate (== 1) and absentTok is a *negative* gate (== 0). Only absentTok
|
|
||||||
// should be flagged; presentTok must not produce a spurious error.
|
|
||||||
const lines = [
|
|
||||||
'---',
|
|
||||||
'phase: 01-test',
|
|
||||||
'plan: 01',
|
|
||||||
'type: execute',
|
|
||||||
'wave: 1',
|
|
||||||
'depends_on: []',
|
|
||||||
'files_modified: [file.ts]',
|
|
||||||
'autonomous: true',
|
|
||||||
'must_haves:',
|
|
||||||
' - AC1',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'<task>',
|
|
||||||
'<name>Mixed gate task</name>',
|
|
||||||
'<action>',
|
|
||||||
'Use presentTok for the new pattern.',
|
|
||||||
'Do not use absentTok any more.',
|
|
||||||
'</action>',
|
|
||||||
"<verify><automated>grep -c 'presentTok' f == 1 && grep -c 'absentTok' f == 0</automated></verify>",
|
|
||||||
'<done>Done</done>',
|
|
||||||
'</task>',
|
|
||||||
].join('\n');
|
|
||||||
const verify = require(VERIFY_CJS);
|
|
||||||
const result = verify.scanNegativeGrepCommentEcho(lines);
|
|
||||||
assert.strictEqual(result.errors.length, 1, `expected exactly 1 error (absentTok only), got: ${JSON.stringify(result.errors)}`);
|
|
||||||
assert.ok(result.errors[0].includes('absentTok'), `error must name absentTok, got: ${result.errors[0]}`);
|
|
||||||
assert.ok(!result.errors[0].includes('presentTok'), `error must NOT name presentTok, got: ${result.errors[0]}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('case 13 — grep -c -F (separate count+fixed flags) extracts literal', () => {
|
|
||||||
// Bug 2: grep -c -F 'LIT' was not extracted by the old regex that required -c
|
|
||||||
// immediately before the pattern without intervening flags.
|
|
||||||
const verify = require(VERIFY_CJS);
|
|
||||||
const content = makePlan({
|
|
||||||
negativeGrep: "grep -c -F '.catch(() => null)' f == 0",
|
|
||||||
actionEcho: '// Old pattern: .catch(() => null)',
|
|
||||||
});
|
|
||||||
const result = verify.scanNegativeGrepCommentEcho(content);
|
|
||||||
assert.strictEqual(result.errors.length, 1, `grep -c -F must extract literal, got: ${JSON.stringify(result.errors)}`);
|
|
||||||
assert.ok(result.errors[0].includes('.catch(() => null)'), `error must name the literal, got: ${result.errors[0]}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('case 14 — grep -F -c (reversed flag order) extracts literal', () => {
|
|
||||||
// Bug 2: grep -F -c 'LIT' — count flag not in the first position after grep.
|
|
||||||
const verify = require(VERIFY_CJS);
|
|
||||||
const content = makePlan({
|
|
||||||
negativeGrep: "grep -F -c 'CardModalHost' f == 0",
|
|
||||||
actionEcho: '* @see CardModalHost for the deprecated pattern.',
|
|
||||||
});
|
|
||||||
const result = verify.scanNegativeGrepCommentEcho(content);
|
|
||||||
assert.strictEqual(result.errors.length, 1, `grep -F -c must extract literal, got: ${JSON.stringify(result.errors)}`);
|
|
||||||
assert.ok(result.errors[0].includes('CardModalHost'), `error must name CardModalHost, got: ${result.errors[0]}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('case 15 — grep --count (long option) extracts literal', () => {
|
|
||||||
// Bug 2: grep --count 'LIT' was not matched by the old -c pattern.
|
|
||||||
const verify = require(VERIFY_CJS);
|
|
||||||
const content = makePlan({
|
|
||||||
negativeGrep: "grep --count 'longCountTok' f == 0",
|
|
||||||
actionEcho: 'Remove longCountTok from the codebase.',
|
|
||||||
});
|
|
||||||
const result = verify.scanNegativeGrepCommentEcho(content);
|
|
||||||
assert.strictEqual(result.errors.length, 1, `grep --count must extract literal, got: ${JSON.stringify(result.errors)}`);
|
|
||||||
assert.ok(result.errors[0].includes('longCountTok'), `error must name longCountTok, got: ${result.errors[0]}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('case 16 — same-line command span stripped but prose echo on same line is still caught', () => {
|
|
||||||
// Bug 3: the old code filtered entire lines; a line with a pasted grep command AND
|
|
||||||
// a prose echo would be dropped, silencing the error. Only the command SPAN should
|
|
||||||
// be stripped; prose on the same line that echoes the token must still be detected.
|
|
||||||
const lines = [
|
|
||||||
'---',
|
|
||||||
'phase: 01-test',
|
|
||||||
'plan: 01',
|
|
||||||
'type: execute',
|
|
||||||
'wave: 1',
|
|
||||||
'depends_on: []',
|
|
||||||
'files_modified: [file.ts]',
|
|
||||||
'autonomous: true',
|
|
||||||
'must_haves:',
|
|
||||||
' - AC1',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'<task>',
|
|
||||||
'<name>Span strip task</name>',
|
|
||||||
'<action>',
|
|
||||||
// Single line: pasted command PLUS a prose mention of spanTok outside the command
|
|
||||||
"Run grep -c 'spanTok' f == 0 to confirm; note spanTok must be gone.",
|
|
||||||
'</action>',
|
|
||||||
"<verify><automated>grep -c 'spanTok' f == 0</automated></verify>",
|
|
||||||
'<done>Done</done>',
|
|
||||||
'</task>',
|
|
||||||
].join('\n');
|
|
||||||
const verify = require(VERIFY_CJS);
|
|
||||||
const result = verify.scanNegativeGrepCommentEcho(lines);
|
|
||||||
assert.strictEqual(result.errors.length, 1, `prose echo outside command span must still be caught, got: ${JSON.stringify(result.errors)}`);
|
|
||||||
assert.ok(result.errors[0].includes('spanTok'), `error must name spanTok, got: ${result.errors[0]}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('case 17 — command-only action (no prose echo) still does NOT self-flag', () => {
|
|
||||||
// Bug 3 regression guard: when the ONLY occurrence of the token in an action is
|
|
||||||
// inside the grep command span itself, no error should fire.
|
|
||||||
const lines = [
|
|
||||||
'---',
|
|
||||||
'phase: 01-test',
|
|
||||||
'plan: 01',
|
|
||||||
'type: execute',
|
|
||||||
'wave: 1',
|
|
||||||
'depends_on: []',
|
|
||||||
'files_modified: [file.ts]',
|
|
||||||
'autonomous: true',
|
|
||||||
'must_haves:',
|
|
||||||
' - AC1',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'<task>',
|
|
||||||
'<name>Solo command task</name>',
|
|
||||||
'<action>',
|
|
||||||
"grep -c 'soloTok' file == 0",
|
|
||||||
'</action>',
|
|
||||||
"<verify><automated>grep -c 'soloTok' file == 0</automated></verify>",
|
|
||||||
'<done>Done</done>',
|
|
||||||
'</task>',
|
|
||||||
].join('\n');
|
|
||||||
const verify = require(VERIFY_CJS);
|
|
||||||
const result = verify.scanNegativeGrepCommentEcho(lines);
|
|
||||||
assert.strictEqual(result.errors.length, 0, `command-only action must not self-flag, errors: ${JSON.stringify(result.errors)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('case 18 — multi-line backslash continuation in verify command is joined and detected', () => {
|
|
||||||
// Bug 4: a verify command split with trailing backslash was not joined, so the
|
|
||||||
// == 0 appeared on a continuation line without the grep prefix → missed.
|
|
||||||
const lines = [
|
|
||||||
'---',
|
|
||||||
'phase: 01-test',
|
|
||||||
'plan: 01',
|
|
||||||
'type: execute',
|
|
||||||
'wave: 1',
|
|
||||||
'depends_on: []',
|
|
||||||
'files_modified: [file.ts]',
|
|
||||||
'autonomous: true',
|
|
||||||
'must_haves:',
|
|
||||||
' - AC1',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'<task>',
|
|
||||||
'<name>Multi-line verify task</name>',
|
|
||||||
'<action>',
|
|
||||||
'Remove mlTok from all modules.',
|
|
||||||
'</action>',
|
|
||||||
'<verify><automated>grep -c \'mlTok\' file \\\n == 0</automated></verify>',
|
|
||||||
'<done>Done</done>',
|
|
||||||
'</task>',
|
|
||||||
].join('\n');
|
|
||||||
const verify = require(VERIFY_CJS);
|
|
||||||
const result = verify.scanNegativeGrepCommentEcho(lines);
|
|
||||||
assert.strictEqual(result.errors.length, 1, `backslash-continued verify must be detected, got: ${JSON.stringify(result.errors)}`);
|
|
||||||
assert.ok(result.errors[0].includes('mlTok'), `error must name mlTok, got: ${result.errors[0]}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── (A) assignment is not a gate ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
test('case 19 — bare STATUS=0 assignment after semicolon is not a negative gate', () => {
|
|
||||||
// grep -c '...' f > /dev/null; STATUS=0 is an assignment, not a == 0 gate.
|
|
||||||
// deprecatedTok is echoed in the action but the verify line has no == 0 gate,
|
|
||||||
// so no error should fire.
|
|
||||||
const content = makePlan({
|
|
||||||
negativeGrep: "grep -c 'deprecatedTok' src/m.ts > /dev/null; STATUS=0",
|
|
||||||
actionEcho: 'Remove deprecatedTok from the module.',
|
|
||||||
});
|
|
||||||
const verify = require(VERIFY_CJS);
|
|
||||||
const result = verify.scanNegativeGrepCommentEcho(content);
|
|
||||||
assert.strictEqual(result.errors.length, 0, [
|
|
||||||
'assignment after semicolon must not be treated as a negative gate,',
|
|
||||||
`errors: ${JSON.stringify(result.errors)}`,
|
|
||||||
].join(' '));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('case 19b — positive control: spaced == 0 IS a gate and fires when token is echoed', () => {
|
|
||||||
// Same plan as case 19 but the verify line now uses the real == 0 gate form.
|
|
||||||
// deprecatedTok is echoed in the action → expect exactly 1 error.
|
|
||||||
const content = makePlan({
|
|
||||||
negativeGrep: "grep -c 'deprecatedTok' src/m.ts == 0",
|
|
||||||
actionEcho: 'Remove deprecatedTok from the module.',
|
|
||||||
});
|
|
||||||
const verify = require(VERIFY_CJS);
|
|
||||||
const result = verify.scanNegativeGrepCommentEcho(content);
|
|
||||||
assert.strictEqual(result.errors.length, 1, [
|
|
||||||
'spaced == 0 gate with echoed token must produce exactly 1 error,',
|
|
||||||
`errors: ${JSON.stringify(result.errors)}`,
|
|
||||||
].join(' '));
|
|
||||||
assert.ok(result.errors[0].includes('deprecatedTok'), `error must name deprecatedTok, got: ${result.errors[0]}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── (B) inverted count is not a negative gate ─────────────────────────────────
|
|
||||||
|
|
||||||
test('case 20 — grep -cv with == 0 is NOT a negative gate', () => {
|
|
||||||
// -cv counts non-matching lines; "== 0" on a -cv result is a positive assertion
|
|
||||||
// (all lines match), which is out of scope for the negative-grep gate rule.
|
|
||||||
// invTok is echoed in the action but no error should fire.
|
|
||||||
const content = makePlan({
|
|
||||||
negativeGrep: "grep -cv 'invTok' file == 0",
|
|
||||||
actionEcho: 'Ensure every line contains invTok.',
|
|
||||||
});
|
|
||||||
const verify = require(VERIFY_CJS);
|
|
||||||
const result = verify.scanNegativeGrepCommentEcho(content);
|
|
||||||
assert.strictEqual(result.errors.length, 0, [
|
|
||||||
'grep -cv counts non-matching lines; == 0 is a positive assertion — must not flag,',
|
|
||||||
`errors: ${JSON.stringify(result.errors)}`,
|
|
||||||
].join(' '));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Group 2: end-to-end via runGsdTools ──────────────────────────────────────
|
|
||||||
|
|
||||||
describe('scanNegativeGrepCommentEcho — end-to-end via verify plan-structure', () => {
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = createTempProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup(tmpDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('e2e case 1 — echoed literal causes valid:false', () => {
|
|
||||||
const planContent = makePlan({
|
|
||||||
negativeGrep: "grep -c '?from=' src/animal-detail.tsx == 0",
|
|
||||||
actionEcho: 'Do NOT reintroduce the old ?from= referrer hack.',
|
|
||||||
});
|
|
||||||
const planDir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
|
||||||
fs.mkdirSync(planDir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(planDir, '01-01-PLAN.md'), planContent);
|
|
||||||
|
|
||||||
const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir);
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(output.valid, false, `expected valid:false, got: ${JSON.stringify(output)}`);
|
|
||||||
assert.ok(
|
|
||||||
output.errors.some(e => e.includes('?from=')),
|
|
||||||
`expected an error mentioning ?from=, got: ${JSON.stringify(output.errors)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('e2e case 2 — allowlist marker causes valid:true', () => {
|
|
||||||
const planContent = makePlan({
|
|
||||||
negativeGrep: "grep -c '?from=' src/animal-detail.tsx == 0",
|
|
||||||
actionEcho: 'Do NOT reintroduce the old ?from= referrer hack.',
|
|
||||||
allowlistMarker: '<!-- planner-discipline-allow: ?from= -->',
|
|
||||||
});
|
|
||||||
const planDir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
|
||||||
fs.mkdirSync(planDir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(planDir, '01-01-PLAN.md'), planContent);
|
|
||||||
|
|
||||||
const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir);
|
|
||||||
const output = JSON.parse(result.output);
|
|
||||||
assert.strictEqual(output.valid, true, `expected valid:true with allowlist, got: ${JSON.stringify(output)}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Group 3: doc-contract (source-text-is-the-product) ───────────────────────
|
|
||||||
|
|
||||||
describe('doc-contract: agent/reference .md files carry the deployed contract text', () => {
|
|
||||||
test('gsd-planner.md contains <comment_text_discipline> block', () => {
|
|
||||||
const content = fs.readFileSync(PLANNER_MD, 'utf8');
|
|
||||||
assert.ok(content.includes('<comment_text_discipline>'), 'gsd-planner.md must contain <comment_text_discipline>');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gsd-planner.md contains a usage example (<!-- planner-discipline-allow: ...)', () => {
|
|
||||||
const content = fs.readFileSync(PLANNER_MD, 'utf8');
|
|
||||||
assert.ok(
|
|
||||||
content.includes('<!-- planner-discipline-allow:'),
|
|
||||||
'gsd-planner.md must contain an HTML comment example of the allowlist syntax',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('planner-antipatterns.md contains Comment-Text Discipline section heading', () => {
|
|
||||||
const content = fs.readFileSync(ANTIPATTERNS_MD, 'utf8');
|
|
||||||
assert.ok(
|
|
||||||
content.includes('Comment-Text Discipline'),
|
|
||||||
'planner-antipatterns.md must contain a Comment-Text Discipline section',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('planner-antipatterns.md contains planner-discipline-allow: syntax', () => {
|
|
||||||
const content = fs.readFileSync(ANTIPATTERNS_MD, 'utf8');
|
|
||||||
assert.ok(
|
|
||||||
content.includes('planner-discipline-allow:'),
|
|
||||||
'planner-antipatterns.md must contain planner-discipline-allow: syntax',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Group 4: property-based (fast-check) ────────────────────────────────────
|
|
||||||
|
|
||||||
describe('property-based: scanNegativeGrepCommentEcho — fast-check', () => {
|
|
||||||
let scanNegativeGrepCommentEcho;
|
|
||||||
|
|
||||||
before(() => {
|
|
||||||
const verify = require(VERIFY_CJS);
|
|
||||||
scanNegativeGrepCommentEcho = verify.scanNegativeGrepCommentEcho;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Two-arm property (alphanumeric literals — DEFECT.GENERATIVE-FIX parity guard):
|
|
||||||
// both arms in one property so no stub can pass.
|
|
||||||
// arm1: lit only in gate (no echo) → 0 errors
|
|
||||||
// arm2: lit in gate AND echoed in action → exactly 1 error naming lit
|
|
||||||
test('property (two-arm): gate-only → 0 errors; gate+echo → 1 error', { skip: !fc }, () => {
|
|
||||||
if (!fc) return;
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.stringMatching(/^[A-Za-z_][A-Za-z0-9_]{2,12}$/),
|
|
||||||
(lit) => {
|
|
||||||
const base = [
|
|
||||||
'---',
|
|
||||||
'phase: 01-test',
|
|
||||||
'plan: 01',
|
|
||||||
'type: execute',
|
|
||||||
'wave: 1',
|
|
||||||
'depends_on: []',
|
|
||||||
'files_modified: [f.ts]',
|
|
||||||
'autonomous: true',
|
|
||||||
'must_haves:',
|
|
||||||
' - AC1',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'<task>',
|
|
||||||
'<name>T</name>',
|
|
||||||
];
|
|
||||||
const verifyLine = `<verify><automated>grep -c '${lit}' f.ts == 0</automated></verify>`;
|
|
||||||
|
|
||||||
// arm1: no echo in action
|
|
||||||
const arm1 = base.concat([
|
|
||||||
'<action>Do work, not the forbidden thing.</action>',
|
|
||||||
verifyLine,
|
|
||||||
'<done>Done</done>',
|
|
||||||
'</task>',
|
|
||||||
]).join('\n');
|
|
||||||
const r1 = scanNegativeGrepCommentEcho(arm1);
|
|
||||||
if (r1.errors.length !== 0) return false;
|
|
||||||
|
|
||||||
// arm2: echo in action
|
|
||||||
const arm2 = base.concat([
|
|
||||||
`<action>Remove ${lit} from codebase.</action>`,
|
|
||||||
verifyLine,
|
|
||||||
'<done>Done</done>',
|
|
||||||
'</task>',
|
|
||||||
]).join('\n');
|
|
||||||
const r2 = scanNegativeGrepCommentEcho(arm2);
|
|
||||||
return r2.errors.length === 1 && r2.errors[0].includes(lit);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
{ numRuns: 100, seed: 42 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Two-arm property (regex-special literal alphabet): proves substring matching, not regex.
|
|
||||||
// Generates literals from safe chars that include regex-special characters.
|
|
||||||
// Same two-arm structure: gate-only → 0 errors; gate+echo → 1 error naming lit.
|
|
||||||
test('property (two-arm, regex-special chars): gate-only → 0 errors; gate+echo → 1 error', { skip: !fc }, () => {
|
|
||||||
if (!fc) return;
|
|
||||||
const safeChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.()?=*+[]{}-.'.split('');
|
|
||||||
fc.assert(
|
|
||||||
fc.property(
|
|
||||||
fc.array(fc.constantFrom(...safeChars), { minLength: 3, maxLength: 15 }).map(a => a.join('')),
|
|
||||||
(lit) => {
|
|
||||||
// skip if lit contains single-quote (would break fixture shell quoting)
|
|
||||||
if (lit.includes("'")) return true;
|
|
||||||
const base = [
|
|
||||||
'---',
|
|
||||||
'phase: 01-test',
|
|
||||||
'plan: 01',
|
|
||||||
'type: execute',
|
|
||||||
'wave: 1',
|
|
||||||
'depends_on: []',
|
|
||||||
'files_modified: [f.ts]',
|
|
||||||
'autonomous: true',
|
|
||||||
'must_haves:',
|
|
||||||
' - AC1',
|
|
||||||
'---',
|
|
||||||
'',
|
|
||||||
'<task>',
|
|
||||||
'<name>T</name>',
|
|
||||||
];
|
|
||||||
const verifyLine = `<verify><automated>grep -c '${lit}' f.ts == 0</automated></verify>`;
|
|
||||||
|
|
||||||
// arm1: no echo in action
|
|
||||||
const arm1 = base.concat([
|
|
||||||
'<action>Do work, not the forbidden thing.</action>',
|
|
||||||
verifyLine,
|
|
||||||
'<done>Done</done>',
|
|
||||||
'</task>',
|
|
||||||
]).join('\n');
|
|
||||||
const r1 = scanNegativeGrepCommentEcho(arm1);
|
|
||||||
if (r1.errors.length !== 0) return false;
|
|
||||||
|
|
||||||
// arm2: echo in action (wrap in prose so it is unambiguously a prose echo)
|
|
||||||
const arm2 = base.concat([
|
|
||||||
`<action>Remove the token ${lit} from codebase.</action>`,
|
|
||||||
verifyLine,
|
|
||||||
'<done>Done</done>',
|
|
||||||
'</task>',
|
|
||||||
]).join('\n');
|
|
||||||
const r2 = scanNegativeGrepCommentEcho(arm2);
|
|
||||||
return r2.errors.length === 1 && r2.errors[0].includes(lit);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
{ numRuns: 100, seed: 42 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Group 5: allowlist-syntax parity (DEFECT.GENERATIVE-FIX) ─────────────────
|
|
||||||
// Couples the documented marker syntax to runtime behaviour.
|
|
||||||
// If the marker prefix is renamed in code without updating docs (or vice versa), this
|
|
||||||
// test breaks — preventing silent drift between the two surfaces.
|
|
||||||
|
|
||||||
describe('allowlist-syntax parity: doc marker == runtime marker', () => {
|
|
||||||
let scanNegativeGrepCommentEcho;
|
|
||||||
|
|
||||||
before(() => {
|
|
||||||
const verify = require(VERIFY_CJS);
|
|
||||||
scanNegativeGrepCommentEcho = verify.scanNegativeGrepCommentEcho;
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ALLOW_PREFIX appears in both gsd-planner.md and planner-antipatterns.md', () => {
|
|
||||||
// allow-test-rule: source-text-is-the-product
|
|
||||||
const ALLOW_PREFIX = '<!-- planner-discipline-allow:';
|
|
||||||
const plannerContent = fs.readFileSync(PLANNER_MD, 'utf8');
|
|
||||||
const antipatternContent = fs.readFileSync(ANTIPATTERNS_MD, 'utf8');
|
|
||||||
assert.ok(
|
|
||||||
plannerContent.includes(ALLOW_PREFIX),
|
|
||||||
`gsd-planner.md must contain "${ALLOW_PREFIX}"`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
antipatternContent.includes(ALLOW_PREFIX),
|
|
||||||
`planner-antipatterns.md must contain "${ALLOW_PREFIX}"`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ALLOW_PREFIX gates runtime: without marker → error; with marker → 0 errors', () => {
|
|
||||||
const ALLOW_PREFIX = '<!-- planner-discipline-allow:';
|
|
||||||
|
|
||||||
// Without marker: parityTok is echoed in action and gated in verify → must error
|
|
||||||
const withoutMarker = makePlan({
|
|
||||||
negativeGrep: "grep -c 'parityTok' src/m.ts == 0",
|
|
||||||
actionEcho: 'Remove parityTok from the module.',
|
|
||||||
});
|
|
||||||
const r1 = scanNegativeGrepCommentEcho(withoutMarker);
|
|
||||||
assert.ok(r1.errors.length >= 1, [
|
|
||||||
'expected at least 1 error without allowlist marker,',
|
|
||||||
`got: ${JSON.stringify(r1.errors)}`,
|
|
||||||
].join(' '));
|
|
||||||
|
|
||||||
// With marker: same plan but allowlist marker suppresses the error
|
|
||||||
const withMarker = makePlan({
|
|
||||||
negativeGrep: "grep -c 'parityTok' src/m.ts == 0",
|
|
||||||
actionEcho: 'Remove parityTok from the module.',
|
|
||||||
allowlistMarker: `${ALLOW_PREFIX} parityTok -->`,
|
|
||||||
});
|
|
||||||
const r2 = scanNegativeGrepCommentEcho(withMarker);
|
|
||||||
assert.strictEqual(r2.errors.length, 0, [
|
|
||||||
`allowlist marker "${ALLOW_PREFIX} parityTok -->" must suppress error,`,
|
|
||||||
`got: ${JSON.stringify(r2.errors)}`,
|
|
||||||
].join(' '));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
/**
|
|
||||||
* Regression (#498, adversarial-review finding): the custom-file backup step in
|
|
||||||
* update.md must derive RUNTIME_DIR from GSD_DIR.
|
|
||||||
*
|
|
||||||
* The get_installed_version step was rewritten to call `gsd-tools update-context`
|
|
||||||
* and now emits GSD_DIR (the resolved config dir) instead of the old probe-loop
|
|
||||||
* variables LOCAL_DIR / GLOBAL_DIR. The backup_custom_files step still read
|
|
||||||
* LOCAL_DIR / GLOBAL_DIR, which are no longer assigned anywhere — so RUNTIME_DIR
|
|
||||||
* went empty for every LOCAL/GLOBAL install and detect-custom-files was skipped.
|
|
||||||
* Because the update then runs a clean install that wipes managed dirs
|
|
||||||
* (commands/gsd, gsd-core), user-added files inside those dirs could be
|
|
||||||
* deleted without the intended backup.
|
|
||||||
*
|
|
||||||
* This locks the fix: RUNTIME_DIR comes from GSD_DIR, and the dead LOCAL_DIR /
|
|
||||||
* GLOBAL_DIR references are gone.
|
|
||||||
*
|
|
||||||
* Source-text-is-the-product: update.md's bash blocks ARE the deployed /gsd:update
|
|
||||||
* program; asserting their shape is asserting on the deployed contract.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// allow-test-rule: source-text-is-the-product
|
|
||||||
// update.md's bash blocks ARE the deployed /gsd:update program; asserting
|
|
||||||
// their shape is asserting on the deployed contract. The data-loss behavior
|
|
||||||
// only manifests against a real install during a clean reinstall, which CI
|
|
||||||
// does not perform.
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { describe, test } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
|
|
||||||
const UPDATE_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md');
|
|
||||||
|
|
||||||
function codeOnly(file) {
|
|
||||||
// Strip fenced-block prose is unnecessary here; we assert on the whole doc
|
|
||||||
// but ignore markdown comment prose by only matching shell-assignment forms.
|
|
||||||
return fs.readFileSync(file, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('#498 regression: update.md backup uses GSD_DIR, not the removed LOCAL_DIR/GLOBAL_DIR', () => {
|
|
||||||
const src = codeOnly(UPDATE_MD);
|
|
||||||
|
|
||||||
test('RUNTIME_DIR is assigned from GSD_DIR', () => {
|
|
||||||
assert.match(
|
|
||||||
src,
|
|
||||||
/RUNTIME_DIR="\$GSD_DIR"/,
|
|
||||||
'backup_custom_files must set RUNTIME_DIR="$GSD_DIR" (the resolved config dir from update-context)',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no shell assignment reads the removed LOCAL_DIR/GLOBAL_DIR probe variables', () => {
|
|
||||||
// The get_installed_version rewrite no longer assigns LOCAL_DIR/GLOBAL_DIR.
|
|
||||||
// Any RUNTIME_DIR="$LOCAL_DIR" / "$GLOBAL_DIR" would silently resolve to empty.
|
|
||||||
assert.doesNotMatch(
|
|
||||||
src,
|
|
||||||
/="\$(LOCAL_DIR|GLOBAL_DIR)"/,
|
|
||||||
'update.md still reads LOCAL_DIR/GLOBAL_DIR, which get_installed_version no longer sets — backup will be skipped',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('detect-custom-files stays gated on a non-empty RUNTIME_DIR', () => {
|
|
||||||
assert.match(
|
|
||||||
src,
|
|
||||||
/\[ -n "\$RUNTIME_DIR" \][\s\S]*?detect-custom-files --config-dir "\$RUNTIME_DIR"/,
|
|
||||||
'backup must still skip when RUNTIME_DIR is empty (UNKNOWN scope)',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
// allow-test-rule: structural-implementation-guard (#3238)
|
// allow-test-rule: structural-implementation-guard (#2765, #3238)
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// Regression guard for #3238: the lockfile must pin a patched js-yaml (>=4.3.1 on the
|
// Regression guard for #2765: the lockfile must pin the patched brace-expansion
|
||||||
// 4.x line, >=3.15.1 on the 3.x line) to resolve GHSA-5p4m-2wfm-xmqj — a high-severity
|
// versions (>=1.1.18 for the 1.x line, >=5.0.9 for the 5.x line) published 2026-07-30
|
||||||
// (CVSS 7.5, CWE-407) quadratic-CPU DoS in `!!omap` resolution, vulnerable range
|
// to resolve the high-severity DoS/OOM advisories (GHSA-3jxr-9vmj-r5cp /
|
||||||
// `>=4.0.0 <4.3.1`. `!!omap` is in the DEFAULT schema, so a plain yaml.load() is
|
// GHSA-mh99-v99m-4gvg, range <=5.0.7). This is a lockfile-only devDependency bump
|
||||||
// affected. This is a lockfile-only devDependency bump (direct, plus an
|
// (eslint/stryker → minimatch → brace-expansion); production (npm audit --omit=dev) is
|
||||||
// @eslint/eslintrc dedupe); production (npm audit --omit=dev) was already clean.
|
// unaffected. The test pins the installed versions so the bump can't silently regress.
|
||||||
// The test pins every installed copy so the bump can't silently regress.
|
//
|
||||||
|
// Regression guard for #3238: the lockfile must also pin a patched js-yaml (>=4.3.1 on
|
||||||
|
// the 4.x line, >=3.15.1 on the 3.x line) to resolve GHSA-5p4m-2wfm-xmqj — a
|
||||||
|
// high-severity (CVSS 7.5, CWE-407) quadratic-CPU DoS in `!!omap` resolution,
|
||||||
|
// vulnerable range `>=4.0.0 <4.3.1`. `!!omap` is in the DEFAULT schema, so a plain
|
||||||
|
// yaml.load() is affected. This is a lockfile-only devDependency bump (direct, plus
|
||||||
|
// an @eslint/eslintrc dedupe); production (npm audit --omit=dev) was already clean.
|
||||||
|
// The test pins every installed copy so the bump can't silently regress. Folded into
|
||||||
|
// this file (originally tests/issue-3238-js-yaml-lockfile.test.cjs) because it is the
|
||||||
|
// same shape of lockfile CVE-pin regression test for a different package/CVE; it
|
||||||
|
// shares the ROOT/npmLs/NPM_LS_TIMEOUT_MS helpers below rather than duplicating them.
|
||||||
|
|
||||||
const { test } = require('node:test');
|
const { test } = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
@@ -43,12 +53,26 @@ function npmLs(pkg) {
|
|||||||
return versions;
|
return versions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('all installed brace-expansion copies are patched (>=1.1.18 / >=5.0.9) — #2765', () => {
|
||||||
|
const versions = npmLs('brace-expansion');
|
||||||
|
assert.ok(versions.length > 0, 'brace-expansion must be installed (devDependency) to guard');
|
||||||
|
for (const v of versions) {
|
||||||
|
const [maj, min, pat] = v.split('.').map(Number);
|
||||||
|
const ok = (maj === 1 && (min > 1 || (min === 1 && pat >= 18))) // 1.x >= 1.1.18
|
||||||
|
|| (maj === 5 && (min > 0 || pat >= 9)) // 5.x >= 5.0.9
|
||||||
|
|| (maj > 5); // >5.x
|
||||||
|
assert.ok(ok,
|
||||||
|
`brace-expansion@${v} is within the vulnerable range (<=5.0.7) — lockfile regressed the #2765 patch bump. ` +
|
||||||
|
'Re-apply: npm audit fix (non-breaking) to bump to 1.1.18 / 5.0.9.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GHSA-5p4m-2wfm-xmqj names only the 3.x (<3.15.1) and 4.x (<4.3.1) lines. The SAME
|
// GHSA-5p4m-2wfm-xmqj names only the 3.x (<3.15.1) and 4.x (<4.3.1) lines. The SAME
|
||||||
// weakness in the 5.x line is CVE-2026-59870 / GHSA-724g-mxrg-4qvm, fixed in 5.2.1 —
|
// weakness in the 5.x line is CVE-2026-59870 / GHSA-724g-mxrg-4qvm, fixed in 5.2.1 —
|
||||||
// so a guard against this bug CLASS must require 5.2.1 there too rather than waving
|
// so a guard against this bug CLASS must require 5.2.1 there too rather than waving
|
||||||
// every 5.x through, or an accidental major bump to 5.0.0 would reintroduce the exact
|
// every 5.x through, or an accidental major bump to 5.0.0 would reintroduce the exact
|
||||||
// quadratic `!!omap` resolution this test exists to prevent.
|
// quadratic `!!omap` resolution this test exists to prevent.
|
||||||
function isPatched(version) {
|
function isPatchedJsYaml(version) {
|
||||||
const core = String(version).split('+')[0]; // drop build metadata
|
const core = String(version).split('+')[0]; // drop build metadata
|
||||||
// A prerelease of the patched version (e.g. 4.3.1-beta.1) sorts BELOW it in semver
|
// A prerelease of the patched version (e.g. 4.3.1-beta.1) sorts BELOW it in semver
|
||||||
// and may predate the fix — fail closed rather than guess.
|
// and may predate the fix — fail closed rather than guess.
|
||||||
@@ -67,7 +91,7 @@ test('all installed js-yaml copies are patched (>=4.3.1 / >=3.15.1 / >=5.2.1)
|
|||||||
// Vacuity guard: an empty list would make every assertion below trivially true.
|
// Vacuity guard: an empty list would make every assertion below trivially true.
|
||||||
assert.ok(versions.length > 0, 'js-yaml must be installed (devDependency) to guard');
|
assert.ok(versions.length > 0, 'js-yaml must be installed (devDependency) to guard');
|
||||||
for (const v of versions) {
|
for (const v of versions) {
|
||||||
assert.ok(isPatched(v),
|
assert.ok(isPatchedJsYaml(v),
|
||||||
`js-yaml@${v} is not a patched version — the quadratic \`!!omap\` resolution bug is ` +
|
`js-yaml@${v} is not a patched version — the quadratic \`!!omap\` resolution bug is ` +
|
||||||
'present in 3.x <3.15.1 (GHSA-5p4m-2wfm-xmqj), 4.x <4.3.1 (same), and 5.x <5.2.1 ' +
|
'present in 3.x <3.15.1 (GHSA-5p4m-2wfm-xmqj), 4.x <4.3.1 (same), and 5.x <5.2.1 ' +
|
||||||
'(CVE-2026-59870). Re-apply: npm install js-yaml@^4.3.1');
|
'(CVE-2026-59870). Re-apply: npm install js-yaml@^4.3.1');
|
||||||
@@ -1744,3 +1744,72 @@ describe('runtime wiring for #2492 gates', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/issue-2762-plan-reviews-chunked.test.cjs — H3 Wave 6 (#3338)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe('folded:issue-2762-plan-reviews-chunked', () => {
|
||||||
|
// allow-test-rule: structural-implementation-guard (#2762)
|
||||||
|
// Regression guard for #2762: /gsd-plan-phase --reviews was a silent no-op in chunked
|
||||||
|
// mode. Two defects in plan-phase.md §8.5:
|
||||||
|
// A. §8.5.1 outline resume-check greps for a marker the agent only RETURNED (never
|
||||||
|
// wrote to the file) → outline always re-ran/overwrote (broke crash-resume).
|
||||||
|
// B. §8.5.2 per-plan resume-check skipped any plan with frontmatter, with no
|
||||||
|
// --reviews exception → --reviews skipped 100% of plans (contradicted §6's
|
||||||
|
// "go straight to replanning" contract).
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// #2993 fragmentization moved §8.5 (chunked planning mode, including §8.5.1 /
|
||||||
|
// §8.5.2) out of plan-phase.md into gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md.
|
||||||
|
// Read that step file directly — it is the sole remaining source of the §8.5.1/§8.5.2
|
||||||
|
// content these regression guards assert on.
|
||||||
|
const MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase', 'steps', 'chunked-planning-mode.md');
|
||||||
|
const read = () => fs.readFileSync(MD, 'utf8');
|
||||||
|
|
||||||
|
test('§8.5.1 outline agent writes the resume marker into the file (#2762 defect A)', () => {
|
||||||
|
const src = read();
|
||||||
|
// The outline prompt must instruct writing ## OUTLINE COMPLETE into PLAN-OUTLINE.md
|
||||||
|
// (the §8.5.1 resume-check greps for it in the file).
|
||||||
|
const outlineSection = src.slice(src.indexOf('### 8.5.1'), src.indexOf('### 8.5.2'));
|
||||||
|
assert.ok(
|
||||||
|
/outline|PLAN-OUTLINE/i.test(outlineSection) && /End the file.*## OUTLINE COMPLETE|write.*## OUTLINE COMPLETE.*file/i.test(outlineSection.replace(/\s+/g, ' ')),
|
||||||
|
'the outline agent prompt must instruct writing ## OUTLINE COMPLETE into the file (the resume-check greps the file for it) (#2762)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('§8.5.2 per-plan resume-check does NOT skip under --reviews (#2762 defect B)', () => {
|
||||||
|
const src = read();
|
||||||
|
const perPlanSection = src.slice(src.indexOf('### 8.5.2'));
|
||||||
|
// The resume-check bash must gate the skip on --reviews being ABSENT.
|
||||||
|
const bashMatch = perPlanSection.match(/PLAN_FILE=[\s\S]*?fi\s*\n/);
|
||||||
|
assert.ok(bashMatch, '§8.5.2 must contain the per-plan resume-check bash block');
|
||||||
|
const bash = bashMatch[0];
|
||||||
|
assert.ok(
|
||||||
|
/--reviews/.test(bash),
|
||||||
|
'the per-plan resume-check must reference --reviews so it does NOT skip when replanning with review feedback (#2762)'
|
||||||
|
);
|
||||||
|
// The skip must be conditional on --reviews being ABSENT (e.g. ARGUMENTS != *"--reviews"*).
|
||||||
|
assert.ok(
|
||||||
|
/!=\s*\*"--reviews"\*|!~.*--reviews|--reviews.*absent|not.*--reviews/i.test(bash),
|
||||||
|
'the resume-check skip must be gated on --reviews being ABSENT (so --reviews overwrites/replans) (#2762)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('§8.5.2 crash-resume (non-reviews) still skips written plans (#2762 negative space)', () => {
|
||||||
|
const src = read();
|
||||||
|
const perPlanSection = src.slice(src.indexOf('### 8.5.2'));
|
||||||
|
const bashMatch = perPlanSection.match(/PLAN_FILE=[\s\S]*?fi\s*\n/);
|
||||||
|
const bash = bashMatch ? bashMatch[0] : '';
|
||||||
|
assert.ok(
|
||||||
|
/head -1.*grep.*\^---|frontmatter/i.test(bash + perPlanSection.slice(0, 400)),
|
||||||
|
'crash-resume (non-reviews) must still skip plans with valid frontmatter (resume safety preserved) (#2762)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Regression (#498, adversarial-review finding): the custom-file backup step in
|
||||||
|
* update.md must derive RUNTIME_DIR from GSD_DIR.
|
||||||
|
*
|
||||||
|
* The get_installed_version step was rewritten to call `gsd-tools update-context`
|
||||||
|
* and now emits GSD_DIR (the resolved config dir) instead of the old probe-loop
|
||||||
|
* variables LOCAL_DIR / GLOBAL_DIR. The backup_custom_files step still read
|
||||||
|
* LOCAL_DIR / GLOBAL_DIR, which are no longer assigned anywhere — so RUNTIME_DIR
|
||||||
|
* went empty for every LOCAL/GLOBAL install and detect-custom-files was skipped.
|
||||||
|
* Because the update then runs a clean install that wipes managed dirs
|
||||||
|
* (commands/gsd, gsd-core), user-added files inside those dirs could be
|
||||||
|
* deleted without the intended backup.
|
||||||
|
*
|
||||||
|
* This locks the fix: RUNTIME_DIR comes from GSD_DIR, and the dead LOCAL_DIR /
|
||||||
|
* GLOBAL_DIR references are gone.
|
||||||
|
*
|
||||||
|
* Source-text-is-the-product: update.md's bash blocks ARE the deployed /gsd:update
|
||||||
|
* program; asserting their shape is asserting on the deployed contract.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// allow-test-rule: source-text-is-the-product (#3338)
|
||||||
|
// update.md's bash blocks ARE the deployed /gsd:update program; asserting
|
||||||
|
// their shape is asserting on the deployed contract. The data-loss behavior
|
||||||
|
// only manifests against a real install during a clean reinstall, which CI
|
||||||
|
// does not perform.
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
// allow-test-rule: source-text-is-the-product
|
|
||||||
|
const { describe, test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const UPDATE_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md');
|
||||||
|
|
||||||
|
function codeOnly(file) {
|
||||||
|
// Strip fenced-block prose is unnecessary here; we assert on the whole doc
|
||||||
|
// but ignore markdown comment prose by only matching shell-assignment forms.
|
||||||
|
return fs.readFileSync(file, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('#498 regression: update.md backup uses GSD_DIR, not the removed LOCAL_DIR/GLOBAL_DIR', () => {
|
||||||
|
const src = codeOnly(UPDATE_MD);
|
||||||
|
|
||||||
|
test('RUNTIME_DIR is assigned from GSD_DIR', () => {
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/RUNTIME_DIR="\$GSD_DIR"/,
|
||||||
|
'backup_custom_files must set RUNTIME_DIR="$GSD_DIR" (the resolved config dir from update-context)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no shell assignment reads the removed LOCAL_DIR/GLOBAL_DIR probe variables', () => {
|
||||||
|
// The get_installed_version rewrite no longer assigns LOCAL_DIR/GLOBAL_DIR.
|
||||||
|
// Any RUNTIME_DIR="$LOCAL_DIR" / "$GLOBAL_DIR" would silently resolve to empty.
|
||||||
|
assert.doesNotMatch(
|
||||||
|
src,
|
||||||
|
/="\$(LOCAL_DIR|GLOBAL_DIR)"/,
|
||||||
|
'update.md still reads LOCAL_DIR/GLOBAL_DIR, which get_installed_version no longer sets — backup will be skipped',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detect-custom-files stays gated on a non-empty RUNTIME_DIR', () => {
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/\[ -n "\$RUNTIME_DIR" \][\s\S]*?detect-custom-files --config-dir "\$RUNTIME_DIR"/,
|
||||||
|
'backup must still skip when RUNTIME_DIR is empty (UNKNOWN scope)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/issue-815-update-next-channel.test.cjs (#3338 H3 Wave 6)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe('folded:issue-815-update-next-channel', () => {
|
||||||
|
// allow-test-rule: source-text-is-the-product (#3338)
|
||||||
// Reads product workflow/command markdown to verify the --next RC channel
|
// Reads product workflow/command markdown to verify the --next RC channel
|
||||||
// contract.
|
// contract.
|
||||||
|
|
||||||
@@ -12,9 +88,9 @@ const assert = require('node:assert/strict');
|
|||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
const ROOT = path.join(__dirname, '..');
|
const ROOT815 = path.join(__dirname, '..');
|
||||||
const WF = fs.readFileSync(path.join(ROOT, 'gsd-core', 'workflows', 'update.md'), 'utf8');
|
const WF = fs.readFileSync(path.join(ROOT815, 'gsd-core', 'workflows', 'update.md'), 'utf8');
|
||||||
const CMD = fs.readFileSync(path.join(ROOT, 'commands', 'gsd', 'update.md'), 'utf8');
|
const CMD = fs.readFileSync(path.join(ROOT815, 'commands', 'gsd', 'update.md'), 'utf8');
|
||||||
|
|
||||||
test('issue #815: workflow parses --next/--rc into a TAG channel', () => {
|
test('issue #815: workflow parses --next/--rc into a TAG channel', () => {
|
||||||
assert.match(WF, /--next/);
|
assert.match(WF, /--next/);
|
||||||
@@ -44,7 +120,8 @@ test('issue #815: command documents --next/--rc and routes it to the update work
|
|||||||
assert.match(CMD, /--rc/);
|
assert.match(CMD, /--rc/);
|
||||||
assert.match(CMD, /argument-hint:.*--next/);
|
assert.match(CMD, /argument-hint:.*--next/);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
// Folded from tests/bug-2470-update-md-claude-path.test.cjs — consolidation epic #1969 (B4 #1973)
|
// Folded from tests/bug-2470-update-md-claude-path.test.cjs — consolidation epic #1969 (B4 #1973)
|
||||||
@@ -56,7 +133,6 @@ test('issue #815: command documents --next/--rc and routes it to the update work
|
|||||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||||
// Per CONTRIBUTING.md exception matrix.
|
// Per CONTRIBUTING.md exception matrix.
|
||||||
'use strict';
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,23 +146,23 @@ test('issue #815: command documents --next/--rc and routes it to the update work
|
|||||||
* update.md would slip through and trigger the installer warning for non-Claude runtimes.
|
* update.md would slip through and trigger the installer warning for non-Claude runtimes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { test, describe } = require('node:test');
|
const { test: __t2470, describe: __d2470 } = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert2470 = require('node:assert/strict');
|
||||||
const fs = require('fs');
|
const fs2470 = require('fs');
|
||||||
const path = require('path');
|
const path2470 = require('path');
|
||||||
|
|
||||||
const UPDATE_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md');
|
const UPDATE_MD_2470 = path2470.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md');
|
||||||
|
|
||||||
describe('update.md — no bare ~.claude path references (#2470)', () => {
|
__d2470('update.md — no bare ~.claude path references (#2470)', () => {
|
||||||
const content = fs.readFileSync(UPDATE_MD, 'utf-8');
|
const content = fs2470.readFileSync(UPDATE_MD_2470, 'utf-8');
|
||||||
|
|
||||||
test('update.md does not contain bare ~/\\.claude (without trailing slash)', () => {
|
__t2470('update.md does not contain bare ~/\\.claude (without trailing slash)', () => {
|
||||||
// This is the exact pattern from the installer's scanForLeakedPaths():
|
// This is the exact pattern from the installer's scanForLeakedPaths():
|
||||||
// /(?:~|\$HOME)\/\.claude\b/g
|
// /(?:~|\$HOME)\/\.claude\b/g
|
||||||
// The replacer handles ~/\.claude\/ (with trailing slash) but misses bare ~/\.claude
|
// The replacer handles ~/\.claude\/ (with trailing slash) but misses bare ~/\.claude
|
||||||
// so we must not have bare references in the source file.
|
// so we must not have bare references in the source file.
|
||||||
const matches = content.match(/(?:~|\$HOME)\/\.claude(?!\/)/g);
|
const matches = content.match(/(?:~|\$HOME)\/\.claude(?!\/)/g);
|
||||||
assert.strictEqual(
|
assert2470.strictEqual(
|
||||||
matches,
|
matches,
|
||||||
null,
|
null,
|
||||||
`update.md must not contain bare ~/.claude (without trailing slash) — installer scanner flags these as unresolved path refs: ${JSON.stringify(matches)}`
|
`update.md must not contain bare ~/.claude (without trailing slash) — installer scanner flags these as unresolved path refs: ${JSON.stringify(matches)}`
|
||||||
@@ -103,7 +179,6 @@ describe('update.md — no bare ~.claude path references (#2470)', () => {
|
|||||||
{
|
{
|
||||||
const { describe: __foldDescribe } = require('node:test');
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
__foldDescribe("folded:bug-3130-update-npx-robust-invocation (consolidation epic #1969 B4 #1973)", () => {
|
__foldDescribe("folded:bug-3130-update-npx-robust-invocation (consolidation epic #1969 B4 #1973)", () => {
|
||||||
'use strict';
|
|
||||||
// allow-test-rule: source-text-is-the-product (see #3130)
|
// allow-test-rule: source-text-is-the-product (see #3130)
|
||||||
// Reads product workflow markdown (update.md) to verify structural
|
// Reads product workflow markdown (update.md) to verify structural
|
||||||
// invocation contract.
|
// invocation contract.
|
||||||
@@ -126,32 +201,32 @@ describe('update.md — no bare ~.claude path references (#2470)', () => {
|
|||||||
// `$TAG` is a shell variable (latest by default, next under --next/--rc),
|
// `$TAG` is a shell variable (latest by default, next under --next/--rc),
|
||||||
// set by the parse_update_channel step (#815).
|
// set by the parse_update_channel step (#815).
|
||||||
|
|
||||||
const { test } = require('node:test');
|
const { test: __t3130 } = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert3130 = require('node:assert/strict');
|
||||||
const fs = require('node:fs');
|
const fs3130 = require('node:fs');
|
||||||
const path = require('node:path');
|
const path3130 = require('node:path');
|
||||||
|
|
||||||
const ROOT = path.join(__dirname, '..');
|
const ROOT_3130 = path3130.join(__dirname, '..');
|
||||||
const UPDATE_WF = path.join(ROOT, 'gsd-core', 'workflows', 'update.md');
|
const UPDATE_WF_3130 = path3130.join(ROOT_3130, 'gsd-core', 'workflows', 'update.md');
|
||||||
|
|
||||||
const src = fs.readFileSync(UPDATE_WF, 'utf8');
|
const src3130 = fs3130.readFileSync(UPDATE_WF_3130, 'utf8');
|
||||||
|
|
||||||
test('bug #3130: update.md contains no bare npx invocations (cache-stale form)', () => {
|
__t3130('bug #3130: update.md contains no bare npx invocations (cache-stale form)', () => {
|
||||||
// Any occurrence of `npx -y @opengsd/gsd-core@<something>` without `--package=`
|
// Any occurrence of `npx -y @opengsd/gsd-core@<something>` without `--package=`
|
||||||
// is the stale form that triggers the two failure modes.
|
// is the stale form that triggers the two failure modes.
|
||||||
const stale = (src.match(/npx -y @opengsd\/gsd-core@\S+[^\r\n]*/g) || []);
|
const stale = (src3130.match(/npx -y @opengsd\/gsd-core@\S+[^\r\n]*/g) || []);
|
||||||
assert.deepEqual(
|
assert3130.deepEqual(
|
||||||
stale,
|
stale,
|
||||||
[],
|
[],
|
||||||
`Stale npx forms found in update.md (must use --package= form): ${stale.join('; ')}`,
|
`Stale npx forms found in update.md (must use --package= form): ${stale.join('; ')}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('bug #3130: update.md has >=3 robust npx invocations (--package= + -- separator)', () => {
|
__t3130('bug #3130: update.md has >=3 robust npx invocations (--package= + -- separator)', () => {
|
||||||
// Three sibling invocations: local, global, and unknown/fallback.
|
// Three sibling invocations: local, global, and unknown/fallback.
|
||||||
// The tag is now a $TAG variable (latest by default, next under --next/--rc).
|
// The tag is now a $TAG variable (latest by default, next under --next/--rc).
|
||||||
const robust = (src.match(/npx -y --package=@opengsd\/gsd-core@\S+ -- gsd-core/g) || []);
|
const robust = (src3130.match(/npx -y --package=@opengsd\/gsd-core@\S+ -- gsd-core/g) || []);
|
||||||
assert.ok(
|
assert3130.ok(
|
||||||
robust.length >= 3,
|
robust.length >= 3,
|
||||||
`Expected >=3 robust npx invocations in update.md, found ${robust.length}`,
|
`Expected >=3 robust npx invocations in update.md, found ${robust.length}`,
|
||||||
);
|
);
|
||||||
@@ -3064,3 +3064,944 @@ describe('bug #1883 — listMilestoneArchiveDirs distinguishes a permission erro
|
|||||||
'an absent milestones/ dir (ENOENT) must still return [] — Hyrum: empty path unchanged');
|
'an absent milestones/ dir (ENOENT) must still return [] — Hyrum: empty path unchanged');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/issue-2701-nul-corrupted-validators.test.cjs — test-hygiene sweep #3338 (H3 wave 6)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:issue-2701-nul-corrupted-validators", () => {
|
||||||
|
// Regression tests for #2701 — plan/summary/verification/state validators silently
|
||||||
|
// accept NUL-corrupted files and report valid:true.
|
||||||
|
//
|
||||||
|
// A NUL-corrupted text artifact is binary-classified by file(1) and silently
|
||||||
|
// OMITTED from recursive / binary-skipping search results (rg -l, grep -rI,
|
||||||
|
// exit 0), so the corruption reads downstream as "file absent" rather than
|
||||||
|
// "file corrupt." The validators must fail loud, naming the encoding problem and
|
||||||
|
// its consequence, before any schema/structure check. The fix is at the
|
||||||
|
// validator entry points (a shared textEncodingError helper in validate.cjs),
|
||||||
|
// NOT inside the broadly-shared platformReadSync read primitive.
|
||||||
|
//
|
||||||
|
// NUL bytes are written via Buffer so they survive onto disk (a string write
|
||||||
|
// would not). Cleanup via t.after(() => cleanup(tmpDir)).
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { test, describe } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||||
|
const { writeState } = require('./fixtures/index.cjs');
|
||||||
|
|
||||||
|
// A structurally-complete PLAN.md that passes both validators when clean.
|
||||||
|
function validPlanBody() {
|
||||||
|
return [
|
||||||
|
'---',
|
||||||
|
'phase: 01-test',
|
||||||
|
'plan: 01',
|
||||||
|
'type: execute',
|
||||||
|
'wave: 1',
|
||||||
|
'depends_on: []',
|
||||||
|
'files_modified: [some/file.ts]',
|
||||||
|
'autonomous: true',
|
||||||
|
'must_haves:',
|
||||||
|
' truths:',
|
||||||
|
' - "something is true"',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'<tasks>',
|
||||||
|
'',
|
||||||
|
'<task type="auto">',
|
||||||
|
' <name>Task 1: Do something</name>',
|
||||||
|
' <files>some/file.ts</files>',
|
||||||
|
' <action>Do the thing</action>',
|
||||||
|
' <verify><automated>npx vitest run</automated></verify>',
|
||||||
|
' <done>Thing is done</done>',
|
||||||
|
'</task>',
|
||||||
|
'',
|
||||||
|
'</tasks>',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write `body` to a fresh phase plan path, optionally injecting a NUL at `nulAt`. */
|
||||||
|
function writePlan(tmpDir, name, body, nulAt) {
|
||||||
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-test'), { recursive: true });
|
||||||
|
const p = path.join(tmpDir, '.planning', 'phases', '01-test', name);
|
||||||
|
let buf = Buffer.from(body, 'utf8');
|
||||||
|
if (nulAt !== undefined) {
|
||||||
|
buf = Buffer.concat([buf.subarray(0, nulAt), Buffer.from([0x00]), buf.subarray(nulAt)]);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(p, buf);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseResult(t, argv, tmpDir) {
|
||||||
|
const r = runGsdTools(argv, tmpDir);
|
||||||
|
assert.ok(r.success, `command failed: ${r.error}`);
|
||||||
|
return JSON.parse(r.output);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── frontmatter validate --schema plan|summary|verification ────────────────
|
||||||
|
|
||||||
|
describe('#2701: frontmatter validate rejects NUL-corrupted artifacts', () => {
|
||||||
|
test('PLAN.md with an embedded NUL byte → valid:false, error names encoding + consequence', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
const rel = '.planning/phases/01-test/01-01-PLAN.md';
|
||||||
|
writePlan(tmpDir, '01-01-PLAN.md', validPlanBody(), 200);
|
||||||
|
|
||||||
|
const out = parseResult(t, ['frontmatter', 'validate', rel, '--schema', 'plan'], tmpDir);
|
||||||
|
assert.strictEqual(out.valid, false, `expected valid:false; got ${JSON.stringify(out)}`);
|
||||||
|
assert.ok(Array.isArray(out.errors) && out.errors.length > 0, 'must report errors');
|
||||||
|
const msg = out.errors.join(' ');
|
||||||
|
assert.ok(/NUL/i.test(msg), `error must name NUL/encoding: ${msg}`);
|
||||||
|
assert.ok(/skip|search|absent|missing/i.test(msg), `error must name the downstream consequence: ${msg}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SUMMARY.md with an embedded NUL byte → valid:false', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
const dir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const body = ['---', 'phase: 01-test', 'plan: 01', 'status: in_progress', '---', '', '# Summary', 'did the work'].join('\n');
|
||||||
|
const buf = Buffer.concat([Buffer.from(body, 'utf8').subarray(0, 30), Buffer.from([0x00]), Buffer.from(body, 'utf8').subarray(30)]);
|
||||||
|
fs.writeFileSync(path.join(dir, '01-01-SUMMARY.md'), buf);
|
||||||
|
|
||||||
|
const out = parseResult(t, ['frontmatter', 'validate', '.planning/phases/01-test/01-01-SUMMARY.md', '--schema', 'summary'], tmpDir);
|
||||||
|
assert.strictEqual(out.valid, false);
|
||||||
|
assert.ok(out.errors.some((e) => /NUL/i.test(e)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('VERIFICATION.md with an embedded NUL byte → valid:false', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
const dir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const body = ['---', 'phase: 01-test', 'plan: 01', 'status: passed', '---', '', '# Verification', 'all green'].join('\n');
|
||||||
|
const buf = Buffer.concat([Buffer.from(body, 'utf8').subarray(0, 40), Buffer.from([0x00]), Buffer.from(body, 'utf8').subarray(40)]);
|
||||||
|
fs.writeFileSync(path.join(dir, '01-01-VERIFICATION.md'), buf);
|
||||||
|
|
||||||
|
const out = parseResult(t, ['frontmatter', 'validate', '.planning/phases/01-test/01-01-VERIFICATION.md', '--schema', 'verification'], tmpDir);
|
||||||
|
assert.strictEqual(out.valid, false);
|
||||||
|
assert.ok(out.errors.some((e) => /NUL/i.test(e)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── verify plan-structure ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('#2701: verify plan-structure rejects NUL-corrupted PLAN.md', () => {
|
||||||
|
test('PLAN.md with an embedded NUL byte → valid:false, error names encoding', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
const rel = '.planning/phases/01-test/01-01-PLAN.md';
|
||||||
|
writePlan(tmpDir, '01-01-PLAN.md', validPlanBody(), 200);
|
||||||
|
|
||||||
|
const out = parseResult(t, ['verify', 'plan-structure', rel], tmpDir);
|
||||||
|
assert.strictEqual(out.valid, false, `expected valid:false; got ${JSON.stringify(out)}`);
|
||||||
|
assert.ok(out.errors.some((e) => /NUL/i.test(e)), `error must name NUL: ${JSON.stringify(out.errors)}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── state validate ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('#2701: state validate rejects NUL-corrupted STATE.md', () => {
|
||||||
|
test('STATE.md with an embedded NUL byte → valid:false', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
// createTempProject() does NOT seed STATE.md; use writeState to create one,
|
||||||
|
// then corrupt it in place with a NUL byte (Buffer write so it survives).
|
||||||
|
const seed = [
|
||||||
|
'# Project',
|
||||||
|
'',
|
||||||
|
'## Status',
|
||||||
|
'executing',
|
||||||
|
'## Current Phase',
|
||||||
|
'01 of 01',
|
||||||
|
'## Total Plans in Phase',
|
||||||
|
'1',
|
||||||
|
].join('\n');
|
||||||
|
const statePath = writeState(tmpDir, seed);
|
||||||
|
const body = Buffer.from(seed, 'utf8');
|
||||||
|
const buf = Buffer.concat([body.subarray(0, 50), Buffer.from([0x00]), body.subarray(50)]);
|
||||||
|
fs.writeFileSync(statePath, buf);
|
||||||
|
|
||||||
|
const out = parseResult(t, ['state', 'validate'], tmpDir);
|
||||||
|
assert.strictEqual(out.valid, false, `expected valid:false; got ${JSON.stringify(out)}`);
|
||||||
|
assert.ok(out.warnings.some((w) => /NUL/i.test(w)), `warning must name NUL: ${JSON.stringify(out.warnings)}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── negative space: clean files still pass; non-ASCII UTF-8 not over-rejected ─
|
||||||
|
|
||||||
|
describe('#2701: clean and valid-UTF-8 files are not over-rejected', () => {
|
||||||
|
test('clean PLAN.md (no NUL) → frontmatter validate valid:true', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
const rel = '.planning/phases/01-test/01-01-PLAN.md';
|
||||||
|
writePlan(tmpDir, '01-01-PLAN.md', validPlanBody());
|
||||||
|
|
||||||
|
const out = parseResult(t, ['frontmatter', 'validate', rel, '--schema', 'plan'], tmpDir);
|
||||||
|
assert.strictEqual(out.valid, true, `clean plan must pass; got ${JSON.stringify(out)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clean PLAN.md (no NUL) → verify plan-structure valid:true', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
const rel = '.planning/phases/01-test/01-01-PLAN.md';
|
||||||
|
writePlan(tmpDir, '01-01-PLAN.md', validPlanBody());
|
||||||
|
|
||||||
|
const out = parseResult(t, ['verify', 'plan-structure', rel], tmpDir);
|
||||||
|
assert.strictEqual(out.valid, true, `clean plan must pass; got ${JSON.stringify(out)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-ASCII UTF-8 (é, emoji) without NUL is NOT rejected', (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
const rel = '.planning/phases/01-test/01-01-PLAN.md';
|
||||||
|
// High bytes are valid UTF-8; only a NUL (0x00) is the corruption signal.
|
||||||
|
const body = validPlanBody().replace('Do the thing', 'Do the thing — café ☕ naïve');
|
||||||
|
writePlan(tmpDir, '01-01-PLAN.md', body);
|
||||||
|
|
||||||
|
const out = parseResult(t, ['frontmatter', 'validate', rel, '--schema', 'plan'], tmpDir);
|
||||||
|
assert.strictEqual(out.valid, true, `valid UTF-8 high bytes must not be rejected; got ${JSON.stringify(out)}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── boundary: NUL at offset 0 and mid-file both rejected ───────────────────
|
||||||
|
|
||||||
|
describe('#2701: NUL position does not matter (start and middle both rejected)', () => {
|
||||||
|
for (const nulAt of [0, 5, 250]) {
|
||||||
|
test(`NUL at offset ${nulAt} → frontmatter validate valid:false`, (t) => {
|
||||||
|
const tmpDir = createTempProject();
|
||||||
|
t.after(() => cleanup(tmpDir));
|
||||||
|
const rel = '.planning/phases/01-test/01-01-PLAN.md';
|
||||||
|
writePlan(tmpDir, '01-01-PLAN.md', validPlanBody(), nulAt);
|
||||||
|
|
||||||
|
const out = parseResult(t, ['frontmatter', 'validate', rel, '--schema', 'plan'], tmpDir);
|
||||||
|
assert.strictEqual(out.valid, false, `NUL at offset ${nulAt} must be rejected; got ${JSON.stringify(out)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Folded from tests/issue-429-comment-text-gate.test.cjs — test-hygiene sweep #3338 (H3 wave 6)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const { describe: __foldDescribe } = require('node:test');
|
||||||
|
__foldDescribe("folded:issue-429-comment-text-gate", () => {
|
||||||
|
// allow-test-rule: source-text-is-the-product (#3338)
|
||||||
|
// Issue #429: the gate logic is tested behaviorally via the exported pure
|
||||||
|
// function + runGsdTools; the discipline rule + allowlist escape hatch are
|
||||||
|
// asserted against the agent/reference .md whose text IS the deployed contract.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { test, describe, before, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
// Build path to built verify.cjs
|
||||||
|
const VERIFY_CJS = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'verify.cjs');
|
||||||
|
|
||||||
|
// fast-check: loaded at top level so skip flags evaluate correctly
|
||||||
|
let fc;
|
||||||
|
try { fc = require('fast-check'); } catch { fc = null; }
|
||||||
|
// Build path to agent/reference files
|
||||||
|
const PLANNER_MD = path.join(__dirname, '..', 'agents', 'gsd-planner.md');
|
||||||
|
const ANTIPATTERNS_MD = path.join(__dirname, '..', 'gsd-core', 'references', 'planner-antipatterns.md');
|
||||||
|
|
||||||
|
// ─── Fixtures ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makePlan({ negativeGrep, actionEcho, allowlistMarker, positiveGrep } = {}) {
|
||||||
|
const lines = [
|
||||||
|
'---',
|
||||||
|
'phase: 01-test',
|
||||||
|
'plan: 01',
|
||||||
|
'type: execute',
|
||||||
|
'wave: 1',
|
||||||
|
'depends_on: []',
|
||||||
|
'files_modified: [src/animal-detail.tsx]',
|
||||||
|
'autonomous: true',
|
||||||
|
'must_haves:',
|
||||||
|
' - AC1',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'# Test Plan',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (allowlistMarker) {
|
||||||
|
lines.push(allowlistMarker, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('<task>');
|
||||||
|
lines.push('<name>Test task</name>');
|
||||||
|
lines.push('<action>');
|
||||||
|
if (actionEcho) {
|
||||||
|
lines.push(actionEcho);
|
||||||
|
} else {
|
||||||
|
lines.push('Do the work.');
|
||||||
|
}
|
||||||
|
lines.push('</action>');
|
||||||
|
|
||||||
|
if (positiveGrep) {
|
||||||
|
lines.push(`<verify><automated>${positiveGrep}</automated></verify>`);
|
||||||
|
} else if (negativeGrep) {
|
||||||
|
lines.push(`<verify><automated>${negativeGrep}</automated></verify>`);
|
||||||
|
} else {
|
||||||
|
lines.push('<verify><automated>npm test</automated></verify>');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('<done>Task complete</done>');
|
||||||
|
lines.push('</task>');
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Group 1: pure-function unit tests ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('scanNegativeGrepCommentEcho — pure unit tests', () => {
|
||||||
|
let scanNegativeGrepCommentEcho;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
const verify = require(VERIFY_CJS);
|
||||||
|
scanNegativeGrepCommentEcho = verify.scanNegativeGrepCommentEcho;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case 1 — regression Plan 12-04: action echoes the forbidden literal', () => {
|
||||||
|
const content = makePlan({
|
||||||
|
negativeGrep: "grep -c '?from=' src/animal-detail.tsx == 0",
|
||||||
|
actionEcho: 'Do NOT reintroduce the old ?from= referrer hack.',
|
||||||
|
});
|
||||||
|
const result = scanNegativeGrepCommentEcho(content);
|
||||||
|
assert.strictEqual(result.errors.length, 1, `expected 1 error, got: ${JSON.stringify(result.errors)}`);
|
||||||
|
assert.ok(result.errors[0].includes('?from='), `error should mention ?from=, got: ${result.errors[0]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case 2 — regression Plan 11-04: JSDoc head-comment echoes CardModalHost', () => {
|
||||||
|
const content = makePlan({
|
||||||
|
negativeGrep: "grep -c 'CardModalHost' file == 0",
|
||||||
|
actionEcho: '* @see CardModalHost for the deprecated pattern.',
|
||||||
|
});
|
||||||
|
const result = scanNegativeGrepCommentEcho(content);
|
||||||
|
assert.strictEqual(result.errors.length, 1, `expected 1 error, got: ${JSON.stringify(result.errors)}`);
|
||||||
|
assert.ok(result.errors[0].includes('CardModalHost'), `error should mention CardModalHost, got: ${result.errors[0]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case 3 — regression Plan 12-02: head-comment echoes .catch(() => null) (regex-special chars)', () => {
|
||||||
|
const content = makePlan({
|
||||||
|
negativeGrep: "grep -c '.catch(() => null)' file == 0",
|
||||||
|
actionEcho: '// Old pattern: .catch(() => null)',
|
||||||
|
});
|
||||||
|
const result = scanNegativeGrepCommentEcho(content);
|
||||||
|
assert.strictEqual(result.errors.length, 1, `expected 1 error, got: ${JSON.stringify(result.errors)}`);
|
||||||
|
assert.ok(result.errors[0].includes('.catch(() => null)'), `error should mention the literal, got: ${result.errors[0]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case 4 — boundary: positive count gate (== 60) must NOT be flagged (AC#2)', () => {
|
||||||
|
const content = makePlan({
|
||||||
|
positiveGrep: "grep -c '= makeParallel(' file == 60",
|
||||||
|
actionEcho: 'Use makeParallel() for concurrent processing.',
|
||||||
|
});
|
||||||
|
const result = scanNegativeGrepCommentEcho(content);
|
||||||
|
assert.strictEqual(result.errors.length, 0, `positive count gate must not flag, errors: ${JSON.stringify(result.errors)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case 5 — no echo: literal only in verify, not in action', () => {
|
||||||
|
const content = makePlan({
|
||||||
|
negativeGrep: "grep -c 'LEGACY_TOKEN' file == 0",
|
||||||
|
actionEcho: 'Remove the old token handling.',
|
||||||
|
});
|
||||||
|
const result = scanNegativeGrepCommentEcho(content);
|
||||||
|
assert.strictEqual(result.errors.length, 0, 'should be no errors');
|
||||||
|
assert.strictEqual(result.warnings.length, 0, 'should be no warnings');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case 6 — allowlist marker suppresses the error', () => {
|
||||||
|
const content = makePlan({
|
||||||
|
negativeGrep: "grep -c '?from=' src/animal-detail.tsx == 0",
|
||||||
|
actionEcho: 'Do NOT reintroduce the old ?from= referrer hack.',
|
||||||
|
allowlistMarker: '<!-- planner-discipline-allow: ?from= -->',
|
||||||
|
});
|
||||||
|
const result = scanNegativeGrepCommentEcho(content);
|
||||||
|
assert.strictEqual(result.errors.length, 0, `allowlist should suppress error, got: ${JSON.stringify(result.errors)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case 7 — ambiguous unquoted bareword echo: warning not error', () => {
|
||||||
|
const content = makePlan({
|
||||||
|
negativeGrep: 'grep -c badToken file == 0',
|
||||||
|
actionEcho: 'Remove badToken from codebase.',
|
||||||
|
});
|
||||||
|
const result = scanNegativeGrepCommentEcho(content);
|
||||||
|
assert.strictEqual(result.errors.length, 0, `ambiguous token must not error, got: ${JSON.stringify(result.errors)}`);
|
||||||
|
assert.strictEqual(result.warnings.length, 1, `ambiguous token should warn once, got: ${JSON.stringify(result.warnings)}`);
|
||||||
|
assert.ok(result.warnings[0].includes('badToken'), `warning should mention badToken, got: ${result.warnings[0]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case 8 — negative-grep command inside an <action> does NOT self-flag', () => {
|
||||||
|
// action tells executor to ADD the verify command — the grep itself is in the action
|
||||||
|
// but there is no echo of selfToken outside the grep command
|
||||||
|
const lines = [
|
||||||
|
'---',
|
||||||
|
'phase: 01-test',
|
||||||
|
'plan: 01',
|
||||||
|
'type: execute',
|
||||||
|
'wave: 1',
|
||||||
|
'depends_on: []',
|
||||||
|
'files_modified: [file.ts]',
|
||||||
|
'autonomous: true',
|
||||||
|
'must_haves:',
|
||||||
|
' - AC1',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'<task>',
|
||||||
|
'<name>Add verify command</name>',
|
||||||
|
'<action>',
|
||||||
|
"Add this to the CI script: grep -c 'selfToken' file == 0",
|
||||||
|
'</action>',
|
||||||
|
'<verify><automated>npm test</automated></verify>',
|
||||||
|
'<done>Done</done>',
|
||||||
|
'</task>',
|
||||||
|
].join('\n');
|
||||||
|
const verify = require(VERIFY_CJS);
|
||||||
|
const r = verify.scanNegativeGrepCommentEcho(lines);
|
||||||
|
assert.strictEqual(r.errors.length, 0, `grep command in action must not self-flag, errors: ${JSON.stringify(r.errors)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case 9 — CRLF newlines are normalized', () => {
|
||||||
|
const content = makePlan({
|
||||||
|
negativeGrep: "grep -c '?from=' src/animal-detail.tsx == 0",
|
||||||
|
actionEcho: 'Do NOT reintroduce the old ?from= referrer hack.',
|
||||||
|
});
|
||||||
|
const crlfContent = content.split('\n').join('\r\n');
|
||||||
|
const result = scanNegativeGrepCommentEcho(crlfContent);
|
||||||
|
assert.strictEqual(result.errors.length, 1, `CRLF content should still find error, got: ${JSON.stringify(result.errors)}`);
|
||||||
|
assert.ok(result.errors[0].includes('?from='));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case 10 — multiple distinct echoed literals each produce their own error', () => {
|
||||||
|
const lines = [
|
||||||
|
'---',
|
||||||
|
'phase: 01-test',
|
||||||
|
'plan: 01',
|
||||||
|
'type: execute',
|
||||||
|
'wave: 1',
|
||||||
|
'depends_on: []',
|
||||||
|
'files_modified: [file.ts]',
|
||||||
|
'autonomous: true',
|
||||||
|
'must_haves:',
|
||||||
|
' - AC1',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'<task>',
|
||||||
|
'<name>Multi literal task</name>',
|
||||||
|
'<action>',
|
||||||
|
"Remove tokA and tokB from the codebase.",
|
||||||
|
'</action>',
|
||||||
|
"<verify><automated>grep -c 'tokA' file == 0 && grep -c 'tokB' file == 0</automated></verify>",
|
||||||
|
'<done>Done</done>',
|
||||||
|
'</task>',
|
||||||
|
].join('\n');
|
||||||
|
const verify = require(VERIFY_CJS);
|
||||||
|
const result = verify.scanNegativeGrepCommentEcho(lines);
|
||||||
|
assert.strictEqual(result.errors.length, 2, `expected 2 errors (one per literal), got: ${JSON.stringify(result.errors)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case 11 — != 0 and >= 0 are NOT negative gates', () => {
|
||||||
|
const verify = require(VERIFY_CJS);
|
||||||
|
const content1 = makePlan({
|
||||||
|
negativeGrep: "grep -c 'nz' file != 0",
|
||||||
|
actionEcho: 'Ensure nz is present.',
|
||||||
|
});
|
||||||
|
const r1 = verify.scanNegativeGrepCommentEcho(content1);
|
||||||
|
assert.strictEqual(r1.errors.length, 0, `!= 0 must not trigger, errors: ${JSON.stringify(r1.errors)}`);
|
||||||
|
|
||||||
|
const content2 = makePlan({
|
||||||
|
negativeGrep: "grep -c 'nz' file >= 0",
|
||||||
|
actionEcho: 'Ensure nz is present.',
|
||||||
|
});
|
||||||
|
const r2 = verify.scanNegativeGrepCommentEcho(content2);
|
||||||
|
assert.strictEqual(r2.errors.length, 0, `>= 0 must not trigger, errors: ${JSON.stringify(r2.errors)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Bug-fix regression tests (adversarial-review findings) ───────────────────
|
||||||
|
|
||||||
|
test('case 12 — mixed positive+negative on one line: no false positive for positive gate token', () => {
|
||||||
|
// Bug 1: mixed positive+negative greps on one physical line — presentTok is a
|
||||||
|
// *positive* gate (== 1) and absentTok is a *negative* gate (== 0). Only absentTok
|
||||||
|
// should be flagged; presentTok must not produce a spurious error.
|
||||||
|
const lines = [
|
||||||
|
'---',
|
||||||
|
'phase: 01-test',
|
||||||
|
'plan: 01',
|
||||||
|
'type: execute',
|
||||||
|
'wave: 1',
|
||||||
|
'depends_on: []',
|
||||||
|
'files_modified: [file.ts]',
|
||||||
|
'autonomous: true',
|
||||||
|
'must_haves:',
|
||||||
|
' - AC1',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'<task>',
|
||||||
|
'<name>Mixed gate task</name>',
|
||||||
|
'<action>',
|
||||||
|
'Use presentTok for the new pattern.',
|
||||||
|
'Do not use absentTok any more.',
|
||||||
|
'</action>',
|
||||||
|
"<verify><automated>grep -c 'presentTok' f == 1 && grep -c 'absentTok' f == 0</automated></verify>",
|
||||||
|
'<done>Done</done>',
|
||||||
|
'</task>',
|
||||||
|
].join('\n');
|
||||||
|
const verify = require(VERIFY_CJS);
|
||||||
|
const result = verify.scanNegativeGrepCommentEcho(lines);
|
||||||
|
assert.strictEqual(result.errors.length, 1, `expected exactly 1 error (absentTok only), got: ${JSON.stringify(result.errors)}`);
|
||||||
|
assert.ok(result.errors[0].includes('absentTok'), `error must name absentTok, got: ${result.errors[0]}`);
|
||||||
|
assert.ok(!result.errors[0].includes('presentTok'), `error must NOT name presentTok, got: ${result.errors[0]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case 13 — grep -c -F (separate count+fixed flags) extracts literal', () => {
|
||||||
|
// Bug 2: grep -c -F 'LIT' was not extracted by the old regex that required -c
|
||||||
|
// immediately before the pattern without intervening flags.
|
||||||
|
const verify = require(VERIFY_CJS);
|
||||||
|
const content = makePlan({
|
||||||
|
negativeGrep: "grep -c -F '.catch(() => null)' f == 0",
|
||||||
|
actionEcho: '// Old pattern: .catch(() => null)',
|
||||||
|
});
|
||||||
|
const result = verify.scanNegativeGrepCommentEcho(content);
|
||||||
|
assert.strictEqual(result.errors.length, 1, `grep -c -F must extract literal, got: ${JSON.stringify(result.errors)}`);
|
||||||
|
assert.ok(result.errors[0].includes('.catch(() => null)'), `error must name the literal, got: ${result.errors[0]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case 14 — grep -F -c (reversed flag order) extracts literal', () => {
|
||||||
|
// Bug 2: grep -F -c 'LIT' — count flag not in the first position after grep.
|
||||||
|
const verify = require(VERIFY_CJS);
|
||||||
|
const content = makePlan({
|
||||||
|
negativeGrep: "grep -F -c 'CardModalHost' f == 0",
|
||||||
|
actionEcho: '* @see CardModalHost for the deprecated pattern.',
|
||||||
|
});
|
||||||
|
const result = verify.scanNegativeGrepCommentEcho(content);
|
||||||
|
assert.strictEqual(result.errors.length, 1, `grep -F -c must extract literal, got: ${JSON.stringify(result.errors)}`);
|
||||||
|
assert.ok(result.errors[0].includes('CardModalHost'), `error must name CardModalHost, got: ${result.errors[0]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case 15 — grep --count (long option) extracts literal', () => {
|
||||||
|
// Bug 2: grep --count 'LIT' was not matched by the old -c pattern.
|
||||||
|
const verify = require(VERIFY_CJS);
|
||||||
|
const content = makePlan({
|
||||||
|
negativeGrep: "grep --count 'longCountTok' f == 0",
|
||||||
|
actionEcho: 'Remove longCountTok from the codebase.',
|
||||||
|
});
|
||||||
|
const result = verify.scanNegativeGrepCommentEcho(content);
|
||||||
|
assert.strictEqual(result.errors.length, 1, `grep --count must extract literal, got: ${JSON.stringify(result.errors)}`);
|
||||||
|
assert.ok(result.errors[0].includes('longCountTok'), `error must name longCountTok, got: ${result.errors[0]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case 16 — same-line command span stripped but prose echo on same line is still caught', () => {
|
||||||
|
// Bug 3: the old code filtered entire lines; a line with a pasted grep command AND
|
||||||
|
// a prose echo would be dropped, silencing the error. Only the command SPAN should
|
||||||
|
// be stripped; prose on the same line that echoes the token must still be detected.
|
||||||
|
const lines = [
|
||||||
|
'---',
|
||||||
|
'phase: 01-test',
|
||||||
|
'plan: 01',
|
||||||
|
'type: execute',
|
||||||
|
'wave: 1',
|
||||||
|
'depends_on: []',
|
||||||
|
'files_modified: [file.ts]',
|
||||||
|
'autonomous: true',
|
||||||
|
'must_haves:',
|
||||||
|
' - AC1',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'<task>',
|
||||||
|
'<name>Span strip task</name>',
|
||||||
|
'<action>',
|
||||||
|
// Single line: pasted command PLUS a prose mention of spanTok outside the command
|
||||||
|
"Run grep -c 'spanTok' f == 0 to confirm; note spanTok must be gone.",
|
||||||
|
'</action>',
|
||||||
|
"<verify><automated>grep -c 'spanTok' f == 0</automated></verify>",
|
||||||
|
'<done>Done</done>',
|
||||||
|
'</task>',
|
||||||
|
].join('\n');
|
||||||
|
const verify = require(VERIFY_CJS);
|
||||||
|
const result = verify.scanNegativeGrepCommentEcho(lines);
|
||||||
|
assert.strictEqual(result.errors.length, 1, `prose echo outside command span must still be caught, got: ${JSON.stringify(result.errors)}`);
|
||||||
|
assert.ok(result.errors[0].includes('spanTok'), `error must name spanTok, got: ${result.errors[0]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case 17 — command-only action (no prose echo) still does NOT self-flag', () => {
|
||||||
|
// Bug 3 regression guard: when the ONLY occurrence of the token in an action is
|
||||||
|
// inside the grep command span itself, no error should fire.
|
||||||
|
const lines = [
|
||||||
|
'---',
|
||||||
|
'phase: 01-test',
|
||||||
|
'plan: 01',
|
||||||
|
'type: execute',
|
||||||
|
'wave: 1',
|
||||||
|
'depends_on: []',
|
||||||
|
'files_modified: [file.ts]',
|
||||||
|
'autonomous: true',
|
||||||
|
'must_haves:',
|
||||||
|
' - AC1',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'<task>',
|
||||||
|
'<name>Solo command task</name>',
|
||||||
|
'<action>',
|
||||||
|
"grep -c 'soloTok' file == 0",
|
||||||
|
'</action>',
|
||||||
|
"<verify><automated>grep -c 'soloTok' file == 0</automated></verify>",
|
||||||
|
'<done>Done</done>',
|
||||||
|
'</task>',
|
||||||
|
].join('\n');
|
||||||
|
const verify = require(VERIFY_CJS);
|
||||||
|
const result = verify.scanNegativeGrepCommentEcho(lines);
|
||||||
|
assert.strictEqual(result.errors.length, 0, `command-only action must not self-flag, errors: ${JSON.stringify(result.errors)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case 18 — multi-line backslash continuation in verify command is joined and detected', () => {
|
||||||
|
// Bug 4: a verify command split with trailing backslash was not joined, so the
|
||||||
|
// == 0 appeared on a continuation line without the grep prefix → missed.
|
||||||
|
const lines = [
|
||||||
|
'---',
|
||||||
|
'phase: 01-test',
|
||||||
|
'plan: 01',
|
||||||
|
'type: execute',
|
||||||
|
'wave: 1',
|
||||||
|
'depends_on: []',
|
||||||
|
'files_modified: [file.ts]',
|
||||||
|
'autonomous: true',
|
||||||
|
'must_haves:',
|
||||||
|
' - AC1',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'<task>',
|
||||||
|
'<name>Multi-line verify task</name>',
|
||||||
|
'<action>',
|
||||||
|
'Remove mlTok from all modules.',
|
||||||
|
'</action>',
|
||||||
|
'<verify><automated>grep -c \'mlTok\' file \\\n == 0</automated></verify>',
|
||||||
|
'<done>Done</done>',
|
||||||
|
'</task>',
|
||||||
|
].join('\n');
|
||||||
|
const verify = require(VERIFY_CJS);
|
||||||
|
const result = verify.scanNegativeGrepCommentEcho(lines);
|
||||||
|
assert.strictEqual(result.errors.length, 1, `backslash-continued verify must be detected, got: ${JSON.stringify(result.errors)}`);
|
||||||
|
assert.ok(result.errors[0].includes('mlTok'), `error must name mlTok, got: ${result.errors[0]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── (A) assignment is not a gate ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('case 19 — bare STATUS=0 assignment after semicolon is not a negative gate', () => {
|
||||||
|
// grep -c '...' f > /dev/null; STATUS=0 is an assignment, not a == 0 gate.
|
||||||
|
// deprecatedTok is echoed in the action but the verify line has no == 0 gate,
|
||||||
|
// so no error should fire.
|
||||||
|
const content = makePlan({
|
||||||
|
negativeGrep: "grep -c 'deprecatedTok' src/m.ts > /dev/null; STATUS=0",
|
||||||
|
actionEcho: 'Remove deprecatedTok from the module.',
|
||||||
|
});
|
||||||
|
const verify = require(VERIFY_CJS);
|
||||||
|
const result = verify.scanNegativeGrepCommentEcho(content);
|
||||||
|
assert.strictEqual(result.errors.length, 0, [
|
||||||
|
'assignment after semicolon must not be treated as a negative gate,',
|
||||||
|
`errors: ${JSON.stringify(result.errors)}`,
|
||||||
|
].join(' '));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case 19b — positive control: spaced == 0 IS a gate and fires when token is echoed', () => {
|
||||||
|
// Same plan as case 19 but the verify line now uses the real == 0 gate form.
|
||||||
|
// deprecatedTok is echoed in the action → expect exactly 1 error.
|
||||||
|
const content = makePlan({
|
||||||
|
negativeGrep: "grep -c 'deprecatedTok' src/m.ts == 0",
|
||||||
|
actionEcho: 'Remove deprecatedTok from the module.',
|
||||||
|
});
|
||||||
|
const verify = require(VERIFY_CJS);
|
||||||
|
const result = verify.scanNegativeGrepCommentEcho(content);
|
||||||
|
assert.strictEqual(result.errors.length, 1, [
|
||||||
|
'spaced == 0 gate with echoed token must produce exactly 1 error,',
|
||||||
|
`errors: ${JSON.stringify(result.errors)}`,
|
||||||
|
].join(' '));
|
||||||
|
assert.ok(result.errors[0].includes('deprecatedTok'), `error must name deprecatedTok, got: ${result.errors[0]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── (B) inverted count is not a negative gate ─────────────────────────────────
|
||||||
|
|
||||||
|
test('case 20 — grep -cv with == 0 is NOT a negative gate', () => {
|
||||||
|
// -cv counts non-matching lines; "== 0" on a -cv result is a positive assertion
|
||||||
|
// (all lines match), which is out of scope for the negative-grep gate rule.
|
||||||
|
// invTok is echoed in the action but no error should fire.
|
||||||
|
const content = makePlan({
|
||||||
|
negativeGrep: "grep -cv 'invTok' file == 0",
|
||||||
|
actionEcho: 'Ensure every line contains invTok.',
|
||||||
|
});
|
||||||
|
const verify = require(VERIFY_CJS);
|
||||||
|
const result = verify.scanNegativeGrepCommentEcho(content);
|
||||||
|
assert.strictEqual(result.errors.length, 0, [
|
||||||
|
'grep -cv counts non-matching lines; == 0 is a positive assertion — must not flag,',
|
||||||
|
`errors: ${JSON.stringify(result.errors)}`,
|
||||||
|
].join(' '));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Group 2: end-to-end via runGsdTools ──────────────────────────────────────
|
||||||
|
|
||||||
|
describe('scanNegativeGrepCommentEcho — end-to-end via verify plan-structure', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('e2e case 1 — echoed literal causes valid:false', () => {
|
||||||
|
const planContent = makePlan({
|
||||||
|
negativeGrep: "grep -c '?from=' src/animal-detail.tsx == 0",
|
||||||
|
actionEcho: 'Do NOT reintroduce the old ?from= referrer hack.',
|
||||||
|
});
|
||||||
|
const planDir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
||||||
|
fs.mkdirSync(planDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(planDir, '01-01-PLAN.md'), planContent);
|
||||||
|
|
||||||
|
const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(output.valid, false, `expected valid:false, got: ${JSON.stringify(output)}`);
|
||||||
|
assert.ok(
|
||||||
|
output.errors.some(e => e.includes('?from=')),
|
||||||
|
`expected an error mentioning ?from=, got: ${JSON.stringify(output.errors)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('e2e case 2 — allowlist marker causes valid:true', () => {
|
||||||
|
const planContent = makePlan({
|
||||||
|
negativeGrep: "grep -c '?from=' src/animal-detail.tsx == 0",
|
||||||
|
actionEcho: 'Do NOT reintroduce the old ?from= referrer hack.',
|
||||||
|
allowlistMarker: '<!-- planner-discipline-allow: ?from= -->',
|
||||||
|
});
|
||||||
|
const planDir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
||||||
|
fs.mkdirSync(planDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(planDir, '01-01-PLAN.md'), planContent);
|
||||||
|
|
||||||
|
const result = runGsdTools('verify plan-structure .planning/phases/01-test/01-01-PLAN.md', tmpDir);
|
||||||
|
const output = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(output.valid, true, `expected valid:true with allowlist, got: ${JSON.stringify(output)}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Group 3: doc-contract (source-text-is-the-product) ───────────────────────
|
||||||
|
|
||||||
|
describe('doc-contract: agent/reference .md files carry the deployed contract text', () => {
|
||||||
|
test('gsd-planner.md contains <comment_text_discipline> block', () => {
|
||||||
|
const content = fs.readFileSync(PLANNER_MD, 'utf8');
|
||||||
|
assert.ok(content.includes('<comment_text_discipline>'), 'gsd-planner.md must contain <comment_text_discipline>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gsd-planner.md contains a usage example (<!-- planner-discipline-allow: ...)', () => {
|
||||||
|
const content = fs.readFileSync(PLANNER_MD, 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
content.includes('<!-- planner-discipline-allow:'),
|
||||||
|
'gsd-planner.md must contain an HTML comment example of the allowlist syntax',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('planner-antipatterns.md contains Comment-Text Discipline section heading', () => {
|
||||||
|
const content = fs.readFileSync(ANTIPATTERNS_MD, 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
content.includes('Comment-Text Discipline'),
|
||||||
|
'planner-antipatterns.md must contain a Comment-Text Discipline section',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('planner-antipatterns.md contains planner-discipline-allow: syntax', () => {
|
||||||
|
const content = fs.readFileSync(ANTIPATTERNS_MD, 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
content.includes('planner-discipline-allow:'),
|
||||||
|
'planner-antipatterns.md must contain planner-discipline-allow: syntax',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Group 4: property-based (fast-check) ────────────────────────────────────
|
||||||
|
|
||||||
|
describe('property-based: scanNegativeGrepCommentEcho — fast-check', () => {
|
||||||
|
let scanNegativeGrepCommentEcho;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
const verify = require(VERIFY_CJS);
|
||||||
|
scanNegativeGrepCommentEcho = verify.scanNegativeGrepCommentEcho;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two-arm property (alphanumeric literals — DEFECT.GENERATIVE-FIX parity guard):
|
||||||
|
// both arms in one property so no stub can pass.
|
||||||
|
// arm1: lit only in gate (no echo) → 0 errors
|
||||||
|
// arm2: lit in gate AND echoed in action → exactly 1 error naming lit
|
||||||
|
test('property (two-arm): gate-only → 0 errors; gate+echo → 1 error', { skip: !fc }, () => {
|
||||||
|
if (!fc) return;
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.stringMatching(/^[A-Za-z_][A-Za-z0-9_]{2,12}$/),
|
||||||
|
(lit) => {
|
||||||
|
const base = [
|
||||||
|
'---',
|
||||||
|
'phase: 01-test',
|
||||||
|
'plan: 01',
|
||||||
|
'type: execute',
|
||||||
|
'wave: 1',
|
||||||
|
'depends_on: []',
|
||||||
|
'files_modified: [f.ts]',
|
||||||
|
'autonomous: true',
|
||||||
|
'must_haves:',
|
||||||
|
' - AC1',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'<task>',
|
||||||
|
'<name>T</name>',
|
||||||
|
];
|
||||||
|
const verifyLine = `<verify><automated>grep -c '${lit}' f.ts == 0</automated></verify>`;
|
||||||
|
|
||||||
|
// arm1: no echo in action
|
||||||
|
const arm1 = base.concat([
|
||||||
|
'<action>Do work, not the forbidden thing.</action>',
|
||||||
|
verifyLine,
|
||||||
|
'<done>Done</done>',
|
||||||
|
'</task>',
|
||||||
|
]).join('\n');
|
||||||
|
const r1 = scanNegativeGrepCommentEcho(arm1);
|
||||||
|
if (r1.errors.length !== 0) return false;
|
||||||
|
|
||||||
|
// arm2: echo in action
|
||||||
|
const arm2 = base.concat([
|
||||||
|
`<action>Remove ${lit} from codebase.</action>`,
|
||||||
|
verifyLine,
|
||||||
|
'<done>Done</done>',
|
||||||
|
'</task>',
|
||||||
|
]).join('\n');
|
||||||
|
const r2 = scanNegativeGrepCommentEcho(arm2);
|
||||||
|
return r2.errors.length === 1 && r2.errors[0].includes(lit);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
{ numRuns: 100, seed: 42 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two-arm property (regex-special literal alphabet): proves substring matching, not regex.
|
||||||
|
// Generates literals from safe chars that include regex-special characters.
|
||||||
|
// Same two-arm structure: gate-only → 0 errors; gate+echo → 1 error naming lit.
|
||||||
|
test('property (two-arm, regex-special chars): gate-only → 0 errors; gate+echo → 1 error', { skip: !fc }, () => {
|
||||||
|
if (!fc) return;
|
||||||
|
const safeChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.()?=*+[]{}-.'.split('');
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.array(fc.constantFrom(...safeChars), { minLength: 3, maxLength: 15 }).map(a => a.join('')),
|
||||||
|
(lit) => {
|
||||||
|
// skip if lit contains single-quote (would break fixture shell quoting)
|
||||||
|
if (lit.includes("'")) return true;
|
||||||
|
const base = [
|
||||||
|
'---',
|
||||||
|
'phase: 01-test',
|
||||||
|
'plan: 01',
|
||||||
|
'type: execute',
|
||||||
|
'wave: 1',
|
||||||
|
'depends_on: []',
|
||||||
|
'files_modified: [f.ts]',
|
||||||
|
'autonomous: true',
|
||||||
|
'must_haves:',
|
||||||
|
' - AC1',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'<task>',
|
||||||
|
'<name>T</name>',
|
||||||
|
];
|
||||||
|
const verifyLine = `<verify><automated>grep -c '${lit}' f.ts == 0</automated></verify>`;
|
||||||
|
|
||||||
|
// arm1: no echo in action
|
||||||
|
const arm1 = base.concat([
|
||||||
|
'<action>Do work, not the forbidden thing.</action>',
|
||||||
|
verifyLine,
|
||||||
|
'<done>Done</done>',
|
||||||
|
'</task>',
|
||||||
|
]).join('\n');
|
||||||
|
const r1 = scanNegativeGrepCommentEcho(arm1);
|
||||||
|
if (r1.errors.length !== 0) return false;
|
||||||
|
|
||||||
|
// arm2: echo in action (wrap in prose so it is unambiguously a prose echo)
|
||||||
|
const arm2 = base.concat([
|
||||||
|
`<action>Remove the token ${lit} from codebase.</action>`,
|
||||||
|
verifyLine,
|
||||||
|
'<done>Done</done>',
|
||||||
|
'</task>',
|
||||||
|
]).join('\n');
|
||||||
|
const r2 = scanNegativeGrepCommentEcho(arm2);
|
||||||
|
return r2.errors.length === 1 && r2.errors[0].includes(lit);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
{ numRuns: 100, seed: 42 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Group 5: allowlist-syntax parity (DEFECT.GENERATIVE-FIX) ─────────────────
|
||||||
|
// Couples the documented marker syntax to runtime behaviour.
|
||||||
|
// If the marker prefix is renamed in code without updating docs (or vice versa), this
|
||||||
|
// test breaks — preventing silent drift between the two surfaces.
|
||||||
|
|
||||||
|
describe('allowlist-syntax parity: doc marker == runtime marker', () => {
|
||||||
|
let scanNegativeGrepCommentEcho;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
const verify = require(VERIFY_CJS);
|
||||||
|
scanNegativeGrepCommentEcho = verify.scanNegativeGrepCommentEcho;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ALLOW_PREFIX appears in both gsd-planner.md and planner-antipatterns.md', () => {
|
||||||
|
// allow-test-rule: source-text-is-the-product (#3338)
|
||||||
|
const ALLOW_PREFIX = '<!-- planner-discipline-allow:';
|
||||||
|
const plannerContent = fs.readFileSync(PLANNER_MD, 'utf8');
|
||||||
|
const antipatternContent = fs.readFileSync(ANTIPATTERNS_MD, 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
plannerContent.includes(ALLOW_PREFIX),
|
||||||
|
`gsd-planner.md must contain "${ALLOW_PREFIX}"`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
antipatternContent.includes(ALLOW_PREFIX),
|
||||||
|
`planner-antipatterns.md must contain "${ALLOW_PREFIX}"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ALLOW_PREFIX gates runtime: without marker → error; with marker → 0 errors', () => {
|
||||||
|
const ALLOW_PREFIX = '<!-- planner-discipline-allow:';
|
||||||
|
|
||||||
|
// Without marker: parityTok is echoed in action and gated in verify → must error
|
||||||
|
const withoutMarker = makePlan({
|
||||||
|
negativeGrep: "grep -c 'parityTok' src/m.ts == 0",
|
||||||
|
actionEcho: 'Remove parityTok from the module.',
|
||||||
|
});
|
||||||
|
const r1 = scanNegativeGrepCommentEcho(withoutMarker);
|
||||||
|
assert.ok(r1.errors.length >= 1, [
|
||||||
|
'expected at least 1 error without allowlist marker,',
|
||||||
|
`got: ${JSON.stringify(r1.errors)}`,
|
||||||
|
].join(' '));
|
||||||
|
|
||||||
|
// With marker: same plan but allowlist marker suppresses the error
|
||||||
|
const withMarker = makePlan({
|
||||||
|
negativeGrep: "grep -c 'parityTok' src/m.ts == 0",
|
||||||
|
actionEcho: 'Remove parityTok from the module.',
|
||||||
|
allowlistMarker: `${ALLOW_PREFIX} parityTok -->`,
|
||||||
|
});
|
||||||
|
const r2 = scanNegativeGrepCommentEcho(withMarker);
|
||||||
|
assert.strictEqual(r2.errors.length, 0, [
|
||||||
|
`allowlist marker "${ALLOW_PREFIX} parityTok -->" must suppress error,`,
|
||||||
|
`got: ${JSON.stringify(r2.errors)}`,
|
||||||
|
].join(' '));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user