* refactor(#3183): route live-plan counting through scanPhasePlans scanPhasePlans becomes the sole owner of the live-plan derivation. Twenty-one independent re-derivations across seven modules now route through it, and scripts/lint-plan-count-drift.cjs reports zero, scanning the whole repo rather than an allowlist (ADR-3180 Decision 4a). The epic scoped this at three copies. A whole-repo guard found twenty-six sites across nine files, so Phase 1 absorbs every live-plan re-derivation and Phase 3 narrows to window plus sentinel enumeration. Two sites are exempt with a documented reason rather than a bare allowlist: audit.cts scans one quick task's own directory for a single completion record, and gsd2-import.cts reads a foreign GSD-2 tasks/ layout during a one-time import. Neither is a phase directory. scanPhasePlans gains allPlanFiles (pre-supersession) alongside planFiles so one owner answers both questions: verify.cts's numbering-gap check wants every plan on disk, its pairing check wants the live set. Both fields are additive. Highest-severity fix: cmdPhasePlanIndex, which feeds execute-phase wave scheduling, was scheduling status:superseded plans into waves and reporting zero plans for the post-#3139 nested layout. filterPlanFiles and filterSummaryFiles are deleted; getPhaseFileStats orphaned them and only their own tests still called them. New leaf module src/planning-scope.cts carries the frozen SCOPE discriminator, with its six-gate ripple closed: gitignore, inventory manifest, INVENTORY.md and the CONTEXT.md glossary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma * docs(#3183): amend ADR-3180 for the Phase 1/3 boundary re-slice The contract held; the phase boundary did not. The whole-repo drift guard found 26 re-derivations across 9 files against the epic's estimate of 3, and cmdProgressRender re-derives both enumeration and plan counting on adjacent lines, so DW4 was unsatisfiable within Phase 1's original file scope. Records the amended scope, scanPhasePlans's new allPlanFiles field, findOrphanSummaries, the two documented exemptions, the re-derived Tier-2 table, and the describeNonCanonicalPlans trap for later phases. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma * fix(#3183): complete the canonical pairing rule and gate the naming diagnostic The remote runner went red with 13 deterministic failures on both lanes, and they were right: replacing verify.cts's canonicalPlanStem pairing with summaryCandidates dropped a case the bespoke rule covered. A plan carrying a descriptive slug after its id (68-01-scaffolding-PLAN.md) pairs with its canonical-stem summary (68-01-SUMMARY.md), and summaryCandidates generated no such candidate, so the plan read unsummarized. The fix is to complete the one rule rather than restore a second: summaryCandidates gains a canonical-id candidate, narrowed to fire only when an id pair was actually extracted. countMatchedSummaries, findUnsummarizedPlans and findOrphanSummaries all inherit it. The two-plans-one-summary collision behaviour of the original rule is preserved deliberately and documented in place. Second defect, independently root-caused while verifying: routing the #2893 naming diagnostic through scanPhasePlans exposed it to the loose /PLAN/i fallback, which is correct for counting and wrong for a naming check — a non-canonically-named file was accepted as a valid plan and the diagnostic went silent. cmdPhasesList, cmdFindPhase and cmdPhasePlanIndex now intersect with a strict isCanonicalPlanFile predicate before reporting names. Same class as the describeNonCanonicalPlans trap already recorded in ADR-3180: a question about file naming wants the physical, strictly-matched set; only a question about outstanding work wants the live set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma * chore(#3183): register planning-scope.cjs in the eslint migration list tests/repo-invariants.test.cjs asserts every bin/lib/*.cjs is linted xor ignored per its ADR-457 migration state. The new planning-scope module closed five of the six .cts ripple gates - gitignore, inventory manifest, INVENTORY.md and the CONTEXT.md glossary - but not eslint, because that one is enforced by a test rather than by lint:ci, so the local pipeline stayed green while it was missing. Generated from src/planning-scope.cts, so the .cjs is ignored and the .cts is linted, matching every other migrated module. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma * fix(#3183): replace the plan-count drift detector with a literal tokenizer CodeQL reported 4 high-severity js/redos alerts on REGEX_LITERAL_MD_RE, the backtracking regex that finds "a regex literal mentioning PLAN/SUMMARY and an escaped \.md". Five review rounds found it had two defects, not one: - EXPONENTIAL, then CUBIC. Its "any char" atom `(?:\\.|[^/\r\n])` let a `\.` pair be consumed either as one escape or as two class characters, which is exponential backtracking: 27,464ms on `"/\.mdplan" + "\.".repeat(28) + "X"`. Excluding `\` from the class killed that but left a cubic path — 23ms at N=200, 172ms at N=400, 1362ms at N=800 on `"/" + "PLAN\.md".repeat(N)` with no closing `/`. This guard is the last stage of `npm run lint:ci`, which CI runs on fork pull requests, so a crafted src/*.cts could stall the job. - A DETECTION HOLE. A character class holding a bare, unescaped `/` — e.g. `/SUMMARY[^/]*\.md$/`, an ordinary path-excluding filter — terminated the literal at that `/`, so the scan never reached `\.md` and the guard missed it entirely. (Classes holding an ESCAPED `\/` were already matched; the tests cover those separately as parity, not as regressions.) Both defects have one root cause: regex-literal grammar — `\x` escapes, and `/` inside `[...]` not terminating — is not expressible in a backtracking regex. So the detector is now a tokenizer, not a regex. readRegexLiteralAt reads the literal at a given `/` in a single left-to-right pass with no backtracking, treating escapes as two-character units and suppressing the `/` terminator inside a character class. findRegexLiteralMdMatch restarts it at every `/` on the line, preserving the old "find anywhere" behaviour; MAX_REGEX_LITERAL_LEN (400) bounds each read — including the trailing-flag scan — which keeps the whole-line cost linear. Results: cubic shape flat at 0.06-0.39ms out to N=3200 (25KB), exponential shape 0.01ms at 28 reps and 0.00ms at 64, and the bare-`/` class shapes are now caught. Differential against the old regex over 28,474 lines (those matching FILENAME_TEST_RE but not PLAN_SUMMARY_LITERAL_RE, across src/tests/scripts/ gsd-core/bin/eslint-rules, excluding 265 lines with >6 backslashes on which the old regex hangs): 6 differences, all the tokenizer returning the fuller or newly-correct literal, 0 old-only misses. The `\.md` token stays case-insensitive, matching the `/i` the old regex carried. Also closes three holes in the same new file: - walk() tested entry.isFile(), false for a symlink, so a symlinked src/*.cts was silently unscanned — an evasion of a guard whose stated principle (ADR-3180 Decision 4a) is whole-repo discovery with no allowlist. It now resolves symlinks, but confined: file links must resolve inside the repo root, directory links inside the scanned dir itself. Every sibling drift guard in scripts/ uses the Dirent classification and never follows links, so following them unconfined would have made this the only linter able to read outside the tree — on fork PRs an arbitrary out-of-repo read whose matched fragments reach a public CI log. The narrower directory rule additionally stops `src/up -> ..` from sweeping the whole repo, and the skip list is now checked against resolved paths so `src/g -> ../.git` cannot reach .git/** or node_modules/**. Real paths are de-duplicated and files reported canonically, so a symlink alias cannot shift which FUNCTION_SCOPED_EXEMPTIONS key applies. - Both the reported fragment and the reported FILE PATH are attacker- controlled source text written straight to a CI log, and git permits control bytes in a filename. Both are now escaped — C0/C1/DEL plus the bidi and zero-width controls — so a crafted literal or filename cannot recolour the log, overwrite a line with CR, or fabricate a line that looks like this guard's own success output. Regression coverage in tests/plan-count-single-owner.test.cjs: a child-process probe over both pathological shapes (catastrophic backtracking is synchronous and would freeze the suite rather than fail one test), the bare-`/` class shapes verified to fail against the parent-commit blob, root-confinement tests covering the outside-file, outside-directory, cycle, broken-link and duplicate cases, direct isInsideRoot coverage including the sibling-prefix case that a bare startsWith would let through, sanitizeForReport coverage, and limit-1/limit/limit+1 coverage of MAX_REGEX_LITERAL_LEN derived from the exported constant. The earlier structural assertion was dropped — it checked for the substring `[^/`, which respelling the class as `[^\r\n/]` defeats while staying exponential. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma * chore(#3183): backfill changeset PR number Restores b77931869, which a force-push during the ReDoS remediation dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
945 lines
41 KiB
JavaScript
945 lines
41 KiB
JavaScript
/**
|
|
* Tests for the live-plan-counting single-owner contract (#3183, ADR-3180).
|
|
*
|
|
* Covers:
|
|
* - src/plan-scan.cts `scanPhasePlans` — plan/summary counting matrix,
|
|
* superseded-plan exclusion (#2349), root+nested layouts, exclusion
|
|
* filters (-OUTLINE.md, .pre-bounce.md, -PLAN-REVIEW.md), the additive
|
|
* `scope` field (COMPLETE/TRUNCATED/UNREADABLE).
|
|
* - src/planning-scope.cts `SCOPE` — frozen enum contract.
|
|
* - IDENTITY GUARD (ADR-3180 Decision 4c): core-utils.cts's
|
|
* `getPhaseFileStats` must return the EXACT `planFiles`/`summaryFiles`
|
|
* scanPhasePlans produced — asserted at the consumer's output, not the
|
|
* owner's return value, so a future local post-filter at the call site
|
|
* fails it. Also asserts `findUnsummarizedPlans` never disagrees with
|
|
* `summaryCount` for the same inputs.
|
|
*
|
|
* Uses helpers.cjs createTempDir/cleanup per CONTRIBUTING.md — never inline
|
|
* mkdtemp. IO failure injection uses mock.method(fs, 'readdirSync', ...)
|
|
* restored via t.after(), never fs.chmodSync (root bypasses 000 in Docker/CI).
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { test, describe, mock } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { execFileSync } = require('node:child_process');
|
|
|
|
const planScan = require('../gsd-core/bin/lib/plan-scan.cjs');
|
|
const { SCOPE } = require('../gsd-core/bin/lib/planning-scope.cjs');
|
|
const coreUtils = require('../gsd-core/bin/lib/core-utils.cjs');
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
const drift = require('../scripts/lint-plan-count-drift.cjs');
|
|
|
|
function writeFile(dir, relName, content) {
|
|
const full = path.join(dir, relName);
|
|
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
fs.writeFileSync(full, content);
|
|
}
|
|
|
|
function planBody() {
|
|
return ['# Plan', ''].join('\n');
|
|
}
|
|
|
|
function summaryBody() {
|
|
return ['# Summary', ''].join('\n');
|
|
}
|
|
|
|
function frontmatterBlock(fields) {
|
|
const lines = ['---'];
|
|
for (const [k, v] of Object.entries(fields)) lines.push(`${k}: ${v}`);
|
|
lines.push('---', '', '# Plan', '');
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// ─── Scenario matrix (rows 1-14) ──────────────────────────────────────────
|
|
// Reused by both the scanPhasePlans matrix tests below AND the identity-guard
|
|
// tests (rows 22-23), so the two suites can never see different fixtures.
|
|
|
|
const SCENARIOS = [
|
|
{
|
|
id: 'row1',
|
|
label: 'root layout: 3 plans/3 summaries, none superseded',
|
|
build(dir) {
|
|
for (const n of ['01', '02', '03']) {
|
|
writeFile(dir, `${n}-PLAN.md`, planBody());
|
|
writeFile(dir, `${n}-SUMMARY.md`, summaryBody());
|
|
}
|
|
},
|
|
check(scan) {
|
|
assert.strictEqual(scan.planCount, 3);
|
|
assert.strictEqual(scan.summaryCount, 3);
|
|
assert.strictEqual(scan.scope, SCOPE.COMPLETE);
|
|
},
|
|
},
|
|
{
|
|
id: 'row2',
|
|
label: '1 of 3 plans has frontmatter status: superseded -> planCount 2',
|
|
build(dir) {
|
|
writeFile(dir, '01-PLAN.md', frontmatterBlock({ status: 'superseded' }));
|
|
writeFile(dir, '02-PLAN.md', planBody());
|
|
writeFile(dir, '03-PLAN.md', planBody());
|
|
},
|
|
check(scan) {
|
|
assert.strictEqual(scan.planCount, 2);
|
|
},
|
|
},
|
|
{
|
|
id: 'row3',
|
|
label: 'ALL plans superseded -> planCount 0 AND completed TRUE (#2349 invariant)',
|
|
build(dir) {
|
|
for (const n of ['01', '02', '03']) {
|
|
writeFile(dir, `${n}-PLAN.md`, frontmatterBlock({ status: 'superseded' }));
|
|
}
|
|
},
|
|
check(scan) {
|
|
assert.strictEqual(scan.planCount, 0);
|
|
assert.strictEqual(scan.completed, true);
|
|
},
|
|
},
|
|
{
|
|
id: 'row4',
|
|
label: 'zero plans authored -> planCount 0, completed FALSE, scope COMPLETE',
|
|
build() { /* empty phase dir */ },
|
|
check(scan) {
|
|
assert.strictEqual(scan.planCount, 0);
|
|
assert.strictEqual(scan.completed, false);
|
|
assert.strictEqual(scan.scope, SCOPE.COMPLETE);
|
|
},
|
|
},
|
|
{
|
|
id: 'row5',
|
|
label: 'exactly 1 plan (boundary limit)',
|
|
build(dir) { writeFile(dir, '01-PLAN.md', planBody()); },
|
|
check(scan) { assert.strictEqual(scan.planCount, 1); },
|
|
},
|
|
{
|
|
id: 'row6',
|
|
label: 'exactly 2 plans (boundary limit+1)',
|
|
build(dir) {
|
|
writeFile(dir, '01-PLAN.md', planBody());
|
|
writeFile(dir, '02-PLAN.md', planBody());
|
|
},
|
|
check(scan) { assert.strictEqual(scan.planCount, 2); },
|
|
},
|
|
{
|
|
id: 'row7',
|
|
label: 'nested plans/PLAN-01.md ONLY -> planCount 1 (regression: used to report 0)',
|
|
build(dir) { writeFile(dir, 'plans/PLAN-01.md', planBody()); },
|
|
check(scan) {
|
|
assert.strictEqual(scan.planCount, 1);
|
|
assert.ok(scan.planFiles.includes('plans/PLAN-01.md'));
|
|
},
|
|
},
|
|
{
|
|
id: 'row8',
|
|
label: 'mixed root + nested plans -> both counted, no double count',
|
|
build(dir) {
|
|
writeFile(dir, '01-PLAN.md', planBody());
|
|
writeFile(dir, 'plans/PLAN-02.md', planBody());
|
|
},
|
|
check(scan) {
|
|
assert.strictEqual(scan.planCount, 2);
|
|
assert.deepEqual([...scan.planFiles].sort(), ['01-PLAN.md', 'plans/PLAN-02.md']);
|
|
},
|
|
},
|
|
{
|
|
id: 'row9',
|
|
label: '-OUTLINE.md present -> NOT counted',
|
|
build(dir) {
|
|
writeFile(dir, '01-PLAN.md', planBody());
|
|
writeFile(dir, '01-OUTLINE.md', planBody());
|
|
},
|
|
check(scan) {
|
|
assert.strictEqual(scan.planCount, 1);
|
|
assert.ok(!scan.planFiles.includes('01-OUTLINE.md'));
|
|
},
|
|
},
|
|
{
|
|
id: 'row10',
|
|
label: '.pre-bounce.md present -> NOT counted',
|
|
build(dir) {
|
|
writeFile(dir, '01-PLAN.md', planBody());
|
|
writeFile(dir, '01-PLAN.pre-bounce.md', planBody());
|
|
},
|
|
check(scan) {
|
|
assert.strictEqual(scan.planCount, 1);
|
|
assert.ok(!scan.planFiles.includes('01-PLAN.pre-bounce.md'));
|
|
},
|
|
},
|
|
{
|
|
id: 'row11',
|
|
label: '-PLAN-REVIEW.md present -> NOT counted',
|
|
build(dir) {
|
|
writeFile(dir, '01-PLAN.md', planBody());
|
|
writeFile(dir, '01-PLAN-REVIEW.md', planBody());
|
|
},
|
|
check(scan) {
|
|
assert.strictEqual(scan.planCount, 1);
|
|
assert.ok(!scan.planFiles.includes('01-PLAN-REVIEW.md'));
|
|
},
|
|
},
|
|
{
|
|
id: 'row12',
|
|
label: 'stray summary with no matching plan -> summaryCount excludes it',
|
|
build(dir) {
|
|
writeFile(dir, '01-PLAN.md', planBody());
|
|
writeFile(dir, '01-SUMMARY.md', summaryBody());
|
|
writeFile(dir, '99-GAPCLOSURE-SUMMARY.md', summaryBody());
|
|
},
|
|
check(scan) {
|
|
assert.strictEqual(scan.planCount, 1);
|
|
assert.strictEqual(scan.summaryCount, 1);
|
|
},
|
|
},
|
|
{
|
|
id: 'row13',
|
|
label: 'bare PLAN.md <-> SUMMARY.md pairing',
|
|
build(dir) {
|
|
writeFile(dir, 'PLAN.md', planBody());
|
|
writeFile(dir, 'SUMMARY.md', summaryBody());
|
|
},
|
|
check(scan) {
|
|
assert.strictEqual(scan.planCount, 1);
|
|
assert.strictEqual(scan.summaryCount, 1);
|
|
},
|
|
},
|
|
{
|
|
id: 'row14',
|
|
label: 'nested PLAN-01.md <-> SUMMARY-01.md pairing',
|
|
build(dir) {
|
|
writeFile(dir, 'plans/PLAN-01.md', planBody());
|
|
writeFile(dir, 'plans/SUMMARY-01.md', summaryBody());
|
|
},
|
|
check(scan) {
|
|
assert.strictEqual(scan.planCount, 1);
|
|
assert.strictEqual(scan.summaryCount, 1);
|
|
},
|
|
},
|
|
];
|
|
|
|
// ─── scanPhasePlans matrix (rows 1-14) ────────────────────────────────────
|
|
|
|
describe('scanPhasePlans — counting matrix (#3183 rows 1-14)', () => {
|
|
for (const scenario of SCENARIOS) {
|
|
test(`${scenario.id}: ${scenario.label}`, (t) => {
|
|
const dir = createTempDir('gsd-plan-scan-');
|
|
t.after(() => cleanup(dir));
|
|
scenario.build(dir);
|
|
const scan = planScan(dir);
|
|
scenario.check(scan);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ─── IDENTITY GUARD (rows 22-23, ADR-3180 Decision 4c) ────────────────────
|
|
|
|
describe('identity guard: getPhaseFileStats output === scanPhasePlans output (row 22)', () => {
|
|
for (const scenario of SCENARIOS) {
|
|
test(`${scenario.id}: getPhaseFileStats.plans/.summaries deep-equal scanPhasePlans.planFiles/.summaryFiles`, (t) => {
|
|
const dir = createTempDir('gsd-plan-scan-identity-');
|
|
t.after(() => cleanup(dir));
|
|
scenario.build(dir);
|
|
const scan = planScan(dir);
|
|
const stats = coreUtils.getPhaseFileStats(dir);
|
|
assert.deepEqual(stats.plans, scan.planFiles);
|
|
assert.deepEqual(stats.summaries, scan.summaryFiles);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('identity guard: findUnsummarizedPlans length never disagrees with summaryCount (row 23)', () => {
|
|
for (const scenario of SCENARIOS) {
|
|
test(`${scenario.id}: findUnsummarizedPlans(...).length === planFiles.length - summaryCount`, (t) => {
|
|
const dir = createTempDir('gsd-plan-scan-unsummarized-');
|
|
t.after(() => cleanup(dir));
|
|
scenario.build(dir);
|
|
const scan = planScan(dir);
|
|
const unsummarized = coreUtils.findUnsummarizedPlans(scan.planFiles, scan.summaryFiles);
|
|
assert.strictEqual(unsummarized.length, scan.planFiles.length - scan.summaryCount);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ─── Superseded frontmatter detection (rows 18-20) ────────────────────────
|
|
|
|
describe('isPlanSuperseded — frontmatter detection edge cases', () => {
|
|
test('row18: uppercase SUPERSEDED with surrounding whitespace still detected', (t) => {
|
|
const dir = createTempDir('gsd-plan-scan-');
|
|
t.after(() => cleanup(dir));
|
|
writeFile(dir, '01-PLAN.md', ['---', 'status: SUPERSEDED ', '---', '', '# Plan', ''].join('\n'));
|
|
writeFile(dir, '02-PLAN.md', planBody());
|
|
const scan = planScan(dir);
|
|
assert.strictEqual(scan.planCount, 1);
|
|
assert.ok(!scan.planFiles.includes('01-PLAN.md'));
|
|
});
|
|
|
|
test('row19: status: supersededX is NOT superseded (no prefix matching)', (t) => {
|
|
const dir = createTempDir('gsd-plan-scan-');
|
|
t.after(() => cleanup(dir));
|
|
writeFile(dir, '01-PLAN.md', ['---', 'status: supersededX', '---', '', '# Plan', ''].join('\n'));
|
|
writeFile(dir, '02-PLAN.md', planBody());
|
|
const scan = planScan(dir);
|
|
assert.strictEqual(scan.planCount, 2);
|
|
assert.ok(scan.planFiles.includes('01-PLAN.md'));
|
|
});
|
|
|
|
test('row20: CRLF line endings in a superseded plan frontmatter still detected', (t) => {
|
|
const dir = createTempDir('gsd-plan-scan-');
|
|
t.after(() => cleanup(dir));
|
|
writeFile(dir, '01-PLAN.md', ['---', 'status: superseded', '---', '', '# Plan', ''].join('\r\n'));
|
|
writeFile(dir, '02-PLAN.md', planBody());
|
|
const scan = planScan(dir);
|
|
assert.strictEqual(scan.planCount, 1);
|
|
assert.ok(!scan.planFiles.includes('01-PLAN.md'));
|
|
});
|
|
});
|
|
|
|
// ─── scope field: UNREADABLE / TRUNCATED / COMPLETE independence (rows 15-17) ──
|
|
|
|
describe('scope field — UNREADABLE / TRUNCATED / COMPLETE independence', () => {
|
|
test('row15: nonexistent phase dir -> scope UNREADABLE, planCount 0, getPhaseFileStats does not throw', () => {
|
|
const base = createTempDir('gsd-plan-scan-');
|
|
const missing = path.join(base, 'does-not-exist');
|
|
try {
|
|
const scan = planScan(missing);
|
|
assert.strictEqual(scan.scope, SCOPE.UNREADABLE);
|
|
assert.strictEqual(scan.planFiles.length, 0);
|
|
assert.strictEqual(scan.planCount, 0);
|
|
|
|
assert.doesNotThrow(() => coreUtils.getPhaseFileStats(missing));
|
|
const stats = coreUtils.getPhaseFileStats(missing);
|
|
assert.strictEqual(stats.scope, SCOPE.UNREADABLE);
|
|
assert.deepEqual(stats.plans, []);
|
|
} finally {
|
|
cleanup(base);
|
|
}
|
|
});
|
|
|
|
test('row16: plans/ exists but readdirSync on it throws -> scope TRUNCATED, root plans still returned', (t) => {
|
|
const dir = createTempDir('gsd-plan-scan-');
|
|
writeFile(dir, '01-PLAN.md', planBody());
|
|
const nestedDir = path.join(dir, 'plans');
|
|
fs.mkdirSync(nestedDir);
|
|
const originalReaddirSync = fs.readdirSync;
|
|
mock.method(fs, 'readdirSync', (p, ...rest) => {
|
|
if (p === nestedDir) throw new Error('EACCES: permission denied, scandir plans/');
|
|
return originalReaddirSync.call(fs, p, ...rest);
|
|
});
|
|
t.after(() => {
|
|
mock.restoreAll();
|
|
cleanup(dir);
|
|
});
|
|
|
|
const scan = planScan(dir);
|
|
assert.strictEqual(scan.scope, SCOPE.TRUNCATED);
|
|
assert.deepEqual(scan.planFiles, ['01-PLAN.md']);
|
|
});
|
|
|
|
test('row17: zero plans AND dir readable -> scope COMPLETE, assertably different from row16 TRUNCATED', (t) => {
|
|
// Same zero planFiles.length as row16, but readable — must diverge in scope.
|
|
const readableEmptyDir = createTempDir('gsd-plan-scan-');
|
|
const truncatedDir = createTempDir('gsd-plan-scan-');
|
|
const nestedDir = path.join(truncatedDir, 'plans');
|
|
fs.mkdirSync(nestedDir);
|
|
const originalReaddirSync = fs.readdirSync;
|
|
mock.method(fs, 'readdirSync', (p, ...rest) => {
|
|
if (p === nestedDir) throw new Error('EACCES: permission denied, scandir plans/');
|
|
return originalReaddirSync.call(fs, p, ...rest);
|
|
});
|
|
t.after(() => {
|
|
mock.restoreAll();
|
|
cleanup(readableEmptyDir);
|
|
cleanup(truncatedDir);
|
|
});
|
|
|
|
const completeScan = planScan(readableEmptyDir);
|
|
const truncatedScan = planScan(truncatedDir);
|
|
|
|
assert.strictEqual(completeScan.planFiles.length, 0);
|
|
assert.strictEqual(truncatedScan.planFiles.length, 0);
|
|
assert.strictEqual(completeScan.scope, SCOPE.COMPLETE);
|
|
assert.strictEqual(truncatedScan.scope, SCOPE.TRUNCATED);
|
|
assert.notStrictEqual(completeScan.scope, truncatedScan.scope);
|
|
});
|
|
});
|
|
|
|
// ─── Case sensitivity: plan.md/Plan.md vs summary.md/Summary.md (#3183) ───
|
|
//
|
|
// isRootPlanFile (src/plan-scan.cts) has a loose fallback —
|
|
// `/\.md$/i.test(f) && /PLAN/i.test(f)` — that is case-INSENSITIVE, so
|
|
// `plan.md`/`Plan.md` count as plans even though neither matches the
|
|
// canonical `-PLAN.md`/`PLAN.md` suffix exactly. isRootSummaryFile has no
|
|
// such fallback — `f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'` is
|
|
// case-SENSITIVE — so `summary.md`/`Summary.md` do NOT count as summaries.
|
|
// This asymmetry is INTENTIONAL (the loose plan fallback is the point of
|
|
// consolidating onto the single owner; summary detection was never given
|
|
// the same fallback) — these tests pin the current behavior at both
|
|
// altitudes (scanPhasePlans and getPhaseFileStats) so a future change to
|
|
// either rule is caught rather than silently drifting.
|
|
describe('case sensitivity: plan.md/Plan.md counted, summary.md/Summary.md NOT (#3183 asymmetry)', () => {
|
|
test('lowercase plan.md is counted as a plan (loose /PLAN/i fallback is case-insensitive)', (t) => {
|
|
const dir = createTempDir('gsd-plan-scan-case-');
|
|
t.after(() => cleanup(dir));
|
|
writeFile(dir, 'plan.md', planBody());
|
|
const scan = planScan(dir);
|
|
assert.strictEqual(scan.planCount, 1);
|
|
assert.ok(scan.planFiles.includes('plan.md'));
|
|
|
|
const stats = coreUtils.getPhaseFileStats(dir);
|
|
assert.ok(stats.plans.includes('plan.md'));
|
|
});
|
|
|
|
test('mixed-case Plan.md is counted as a plan (loose /PLAN/i fallback is case-insensitive)', (t) => {
|
|
const dir = createTempDir('gsd-plan-scan-case-');
|
|
t.after(() => cleanup(dir));
|
|
writeFile(dir, 'Plan.md', planBody());
|
|
const scan = planScan(dir);
|
|
assert.strictEqual(scan.planCount, 1);
|
|
assert.ok(scan.planFiles.includes('Plan.md'));
|
|
|
|
const stats = coreUtils.getPhaseFileStats(dir);
|
|
assert.ok(stats.plans.includes('Plan.md'));
|
|
});
|
|
|
|
test('lowercase summary.md is NOT counted as a summary (isRootSummaryFile is case-sensitive)', (t) => {
|
|
const dir = createTempDir('gsd-plan-scan-case-');
|
|
t.after(() => cleanup(dir));
|
|
writeFile(dir, '01-PLAN.md', planBody());
|
|
writeFile(dir, 'summary.md', summaryBody());
|
|
const scan = planScan(dir);
|
|
assert.ok(!scan.summaryFiles.includes('summary.md'));
|
|
// Not swept in as a plan either — no "PLAN" substring.
|
|
assert.ok(!scan.planFiles.includes('summary.md'));
|
|
|
|
const stats = coreUtils.getPhaseFileStats(dir);
|
|
assert.ok(!stats.summaries.includes('summary.md'));
|
|
});
|
|
|
|
test('mixed-case Summary.md is NOT counted as a summary (isRootSummaryFile is case-sensitive)', (t) => {
|
|
const dir = createTempDir('gsd-plan-scan-case-');
|
|
t.after(() => cleanup(dir));
|
|
writeFile(dir, '01-PLAN.md', planBody());
|
|
writeFile(dir, 'Summary.md', summaryBody());
|
|
const scan = planScan(dir);
|
|
assert.ok(!scan.summaryFiles.includes('Summary.md'));
|
|
assert.ok(!scan.planFiles.includes('Summary.md'));
|
|
|
|
const stats = coreUtils.getPhaseFileStats(dir);
|
|
assert.ok(!stats.summaries.includes('Summary.md'));
|
|
});
|
|
});
|
|
|
|
// ─── SCOPE frozen enum contract (row 21) ──────────────────────────────────
|
|
|
|
describe('SCOPE — frozen enum contract', () => {
|
|
test('row21: Object.isFrozen(SCOPE) is true, and assigning to a member does not change it', () => {
|
|
assert.strictEqual(Object.isFrozen(SCOPE), true);
|
|
const before = SCOPE.COMPLETE;
|
|
const assigned = Reflect.set(SCOPE, 'COMPLETE', 'mutated-value');
|
|
assert.strictEqual(assigned, false);
|
|
assert.strictEqual(SCOPE.COMPLETE, before);
|
|
});
|
|
});
|
|
|
|
// ─── summaryCandidates canonical-id coverage (#3183 I001 regression) ──────
|
|
//
|
|
// The pre-migration bespoke I001 rule (verify.cts, pre-#3183) matched a plan
|
|
// carrying a descriptive slug after its <phase>-<plan> id — e.g.
|
|
// `68-01-scaffolding-PLAN.md` — against a summary named only by the bare id
|
|
// — `68-01-SUMMARY.md` — via `canonicalPlanStem` (validate.cjs). The
|
|
// consolidation onto the single `summaryCandidates` rule in core-utils.cts
|
|
// (used by countMatchedSummaries / findUnsummarizedPlans / findOrphanSummaries)
|
|
// dropped that candidate, causing 13 remote-runner failures (health-validation
|
|
// and phase test suites) — a live-scaffolding-style plan with a matching
|
|
// bare-id SUMMARY was misreported as unsummarized. These tests pin the
|
|
// restored candidate at both altitudes: the pure core-utils functions AND
|
|
// scanPhasePlans's real-directory integration.
|
|
describe('summaryCandidates canonical-id form — long PLAN stem matches short SUMMARY stem (#3183)', () => {
|
|
test('68-01-scaffolding-PLAN.md pairs with 68-01-SUMMARY.md: countMatchedSummaries/findUnsummarizedPlans agree', () => {
|
|
const plans = ['68-01-scaffolding-PLAN.md'];
|
|
const summaries = ['68-01-SUMMARY.md'];
|
|
assert.strictEqual(coreUtils.countMatchedSummaries(plans, summaries), 1);
|
|
assert.deepEqual(coreUtils.findUnsummarizedPlans(plans, summaries), []);
|
|
assert.deepEqual(coreUtils.findOrphanSummaries(plans, summaries), []);
|
|
});
|
|
|
|
test('same pairing, real directory: scanPhasePlans + validate health emit zero I001', (t) => {
|
|
const dir = createTempDir('gsd-plan-count-i001-');
|
|
t.after(() => cleanup(dir));
|
|
writeFile(dir, '68-01-scaffolding-PLAN.md', frontmatterBlock({ wave: 1 }));
|
|
writeFile(dir, '68-01-SUMMARY.md', summaryBody());
|
|
const scan = planScan(dir);
|
|
assert.strictEqual(scan.planCount, 1);
|
|
assert.strictEqual(scan.summaryCount, 1);
|
|
assert.strictEqual(scan.completed, true);
|
|
assert.deepEqual(coreUtils.findUnsummarizedPlans(scan.planFiles, scan.summaryFiles), []);
|
|
});
|
|
|
|
test('COLLISION: two plans sharing one canonical id (differing only by slug) both pair to the ' +
|
|
'SAME single summary — reproduces the pre-migration bespoke rule\'s own collapsing behavior, ' +
|
|
'not a new regression (the old rule populated one Set keyed by canonical stem with no ' +
|
|
'cardinality check)', () => {
|
|
const plans = ['68-01-alpha-PLAN.md', '68-01-beta-PLAN.md'];
|
|
const summaries = ['68-01-SUMMARY.md'];
|
|
// Both plans read as summarized off the one shared summary.
|
|
assert.deepEqual(coreUtils.findUnsummarizedPlans(plans, summaries), []);
|
|
// countMatchedSummaries counts per-plan matches, so it double-counts the
|
|
// single summary here (2), not the number of distinct summary files (1) —
|
|
// same modeling limit as the pre-migration rule, preserved intentionally.
|
|
assert.strictEqual(coreUtils.countMatchedSummaries(plans, summaries), 2);
|
|
assert.deepEqual(coreUtils.findOrphanSummaries(plans, summaries), []);
|
|
});
|
|
|
|
test('NEGATIVE: a plan whose canonical stem has no matching summary is still reported unsummarized', () => {
|
|
const plans = ['68-02-other-PLAN.md'];
|
|
const summaries = ['68-01-SUMMARY.md'];
|
|
assert.deepEqual(coreUtils.findUnsummarizedPlans(plans, summaries), ['68-02-other-PLAN.md']);
|
|
assert.strictEqual(coreUtils.countMatchedSummaries(plans, summaries), 0);
|
|
});
|
|
|
|
test('narrowing: a plan whose base has no extractable <id>-<id> pair does not gain a redundant ' +
|
|
'candidate (canonicalId falls back to the plain base, already covered by the <stem>-SUMMARY.md ' +
|
|
'candidate)', () => {
|
|
const plans = ['setup-PLAN.md'];
|
|
const summaries = ['setup-SUMMARY.md'];
|
|
assert.strictEqual(coreUtils.countMatchedSummaries(plans, summaries), 1);
|
|
assert.deepEqual(coreUtils.findUnsummarizedPlans(plans, summaries), []);
|
|
});
|
|
});
|
|
|
|
// ─── #2893 regression: non-canonical plan filenames must stay non-canonical
|
|
// when routed through scanPhasePlans's live-plan set (find-phase /
|
|
// phase-plan-index / phases list --type plans naming diagnostic) ──────
|
|
//
|
|
// scanPhasePlans's `isRootPlanFile` loose `/PLAN/i` fallback is deliberately
|
|
// permissive for live-plan COUNTING (see the case-sensitivity describe block
|
|
// above). Routing phase.cts's #2893 naming-convention diagnostic through
|
|
// `allPlanFiles`/`planFiles` directly (the #3183 migration's first pass) let
|
|
// that loose fallback silently recognize a non-canonically-named file (e.g.
|
|
// the reporter's own `01-PLAN-01-foundation.md`) as a valid, already-matched
|
|
// plan — defeating the diagnostic (no warning, offender listed as if valid).
|
|
// `isCanonicalPlanFile` is the strict predicate those three call sites now
|
|
// intersect against. Pinned here at the predicate level; the CLI-level
|
|
// behavior is covered by tests/phase.test.cjs's `(#2893 parity)` suite.
|
|
describe('isCanonicalPlanFile — strict predicate excludes the loose /PLAN/i fallback (#2893 regression)', () => {
|
|
test('root canonical forms match', () => {
|
|
assert.strictEqual(planScan.isCanonicalPlanFile('03-01-PLAN.md'), true);
|
|
assert.strictEqual(planScan.isCanonicalPlanFile('PLAN.md'), true);
|
|
});
|
|
|
|
test('nested canonical forms match only when plans/-prefixed', () => {
|
|
assert.strictEqual(planScan.isCanonicalPlanFile('plans/PLAN-01.md'), true);
|
|
assert.strictEqual(planScan.isCanonicalPlanFile('plans/03-PLAN-01-foo.md'), true);
|
|
});
|
|
|
|
test('the #2893 reporter\'s exact non-canonical example does NOT match at root level, even though ' +
|
|
'its basename shape collides with the nested-form regex', () => {
|
|
assert.strictEqual(planScan.isCanonicalPlanFile('01-PLAN-01-foundation.md'), false);
|
|
assert.strictEqual(planScan.isCanonicalPlanFile('01-PLAN-02-api.md'), false);
|
|
});
|
|
|
|
test('loose-fallback-only root matches (lowercase plan.md) do NOT satisfy the strict predicate', () => {
|
|
assert.strictEqual(planScan.isCanonicalPlanFile('plan.md'), false);
|
|
assert.strictEqual(planScan.isCanonicalPlanFile('Plan.md'), false);
|
|
});
|
|
});
|
|
|
|
// ─── findRegexLiteralMdMatch — literal tokenizer regression
|
|
// (scripts/lint-plan-count-drift.cjs)
|
|
//
|
|
// The scanner's "unquoted regex literal that mentions PLAN/SUMMARY and \.md"
|
|
// detector used to be a single backtracking regex (REGEX_LITERAL_MD_RE). An
|
|
// independent security review found it was STILL defective after a prior
|
|
// backslash-exclusion fix: it was cubic (not just exponential) on
|
|
// `"/" + "PLAN\\.md".repeat(N)` with no closing `/`, and it structurally
|
|
// could not see a character class containing a BARE, unescaped `/` between
|
|
// the PLAN/SUMMARY token and `\.md` (e.g. `/SUMMARY[^/]*\.md$/`) — the old
|
|
// regex treated that `/` as the literal's terminator and stopped scanning
|
|
// before ever reaching `\.md`, silently missing real re-derivation shapes.
|
|
// A class holding an ESCAPED `\/` (e.g. `/PLAN[\\/].*\.md$/`) was already
|
|
// matched by the old regex's `\\.` alternative, so those shapes are parity
|
|
// coverage, not regressions. Both genuine defects share one root cause:
|
|
// regex-literal grammar (escapes, and `/` inside `[...]` not terminating) is
|
|
// not expressible in a backtracking regex. The fix replaces it with
|
|
// `readRegexLiteralAt`/`findRegexLiteralMdMatch`, a deterministic
|
|
// single-pass tokenizer with no backtracking at all.
|
|
describe('findRegexLiteralMdMatch — literal tokenizer (ReDoS + character-class regression)', () => {
|
|
test('findPlanCountDrift completes on backslash-dense and repetition-dense lines (ReDoS regression)', (t) => {
|
|
// Catastrophic backtracking is synchronous and cannot be interrupted
|
|
// in-process — a hang here would freeze the whole suite instead of
|
|
// failing this one test. Spawn a child process with a hard timeout.
|
|
// Exercises BOTH pathological shapes the old regex blew up on:
|
|
// - exponential: `/\.mdplan` + `\.`.repeat(reps) + `X` (no closing `/`)
|
|
// at reps 24 / 28 / 32;
|
|
// - cubic: `/` + `PLAN\.md`.repeat(reps) + ` ` (no closing `/`) at reps
|
|
// 400 / 800 / 1600.
|
|
// These are GROWTH-RATE samples, deliberately doubling, not limit-1/limit/
|
|
// limit+1 boundary coverage — there is no limit here to sit either side
|
|
// of, and under the old regex each step multiplied the runtime (~2x per
|
|
// rep exponential, ~8x per doubling cubic) so a tokenizer that had
|
|
// silently regressed to backtracking blows the child's timeout at the top
|
|
// of either ladder. The one real numeric limit in this module,
|
|
// MAX_REGEX_LITERAL_LEN, gets true limit-1/limit/limit+1 coverage in the
|
|
// readRegexLiteralAt test below.
|
|
const dir = createTempDir('gsd-plan-count-drift-redos-');
|
|
t.after(() => cleanup(dir));
|
|
|
|
const guardPath = path.join(__dirname, '..', 'scripts', 'lint-plan-count-drift.cjs');
|
|
const childPath = path.join(dir, 'probe.cjs');
|
|
const childSource = [
|
|
"'use strict';",
|
|
"const { findPlanCountDrift } = require(process.argv[2]);",
|
|
"for (const reps of [24, 28, 32]) {",
|
|
" const attack = '\\\\.'.repeat(reps);",
|
|
" const line = `const RE = /\\\\.mdplan${attack}X; names.filter(n => RE.test(n));`;",
|
|
" findPlanCountDrift(line, 'src/probe.cts');",
|
|
"}",
|
|
"for (const reps of [400, 800, 1600]) {",
|
|
" const attack = 'PLAN\\\\.md'.repeat(reps);",
|
|
" const line = `const RE = /${attack} ; names.filter(n => RE.test(n));`;",
|
|
" findPlanCountDrift(line, 'src/probe.cts');",
|
|
"}",
|
|
"process.stdout.write('done');",
|
|
].join('\n');
|
|
fs.writeFileSync(childPath, childSource);
|
|
|
|
try {
|
|
const output = execFileSync(process.execPath, [childPath, guardPath], {
|
|
encoding: 'utf8',
|
|
timeout: PROBE_TIMEOUT_MS,
|
|
});
|
|
assert.strictEqual(output, 'done');
|
|
} catch (err) {
|
|
// `code === 'ETIMEDOUT'` is the canonical execFileSync timeout signal;
|
|
// on darwin the kill yields signal 'SIGTERM' with `killed` undefined,
|
|
// so all three are checked rather than relying on any one platform's
|
|
// spelling.
|
|
if (err.code === 'ETIMEDOUT' || err.signal === 'SIGTERM' || err.killed) {
|
|
assert.fail(
|
|
'findPlanCountDrift did not complete within PROBE_TIMEOUT_MS: the child probe ' +
|
|
'process was killed instead of exiting. Catastrophic backtracking (ReDoS) in ' +
|
|
'the literal tokenizer is the expected cause; an externally killed child would ' +
|
|
'also land here.',
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
test('findRegexLiteralMdMatch semantics: matches genuine plan/summary literals, ' +
|
|
'catches path-separator character-class shapes, rejects near-misses and attack lines', () => {
|
|
const matchOf = (line) => drift.findRegexLiteralMdMatch(line);
|
|
|
|
// Positive: genuine plan/summary regex literals must still match.
|
|
assert.strictEqual(
|
|
matchOf("if (/-PLAN\\.md$/.test(name)) return true;"),
|
|
'/-PLAN\\.md$/',
|
|
);
|
|
assert.strictEqual(
|
|
matchOf('names.filter((n) => /^PLAN-\\d+.*\\.md$/i.test(n));'),
|
|
'/^PLAN-\\d+.*\\.md$/i',
|
|
);
|
|
assert.strictEqual(
|
|
matchOf('names.filter((n) => /-SUMMARY-\\d+.*\\.md$/i.test(n));'),
|
|
'/-SUMMARY-\\d+.*\\.md$/i',
|
|
);
|
|
assert.strictEqual(
|
|
matchOf('names.filter((n) => /\\.md.*SUMMARY/.test(n));'),
|
|
'/\\.md.*SUMMARY/',
|
|
);
|
|
|
|
// Character classes containing an ESCAPED `\/` pair. The old backtracking
|
|
// regex also matched these (its `\\.` alternative consumed the `\/`), so
|
|
// they are NOT regression cases — they are parity coverage proving the
|
|
// tokenizer did not LOSE behaviour when it replaced the regex.
|
|
assert.strictEqual(
|
|
matchOf(String.raw`files.filter(f => /PLAN[\\/].*\.md$/.test(f));`),
|
|
String.raw`/PLAN[\\/].*\.md$/`,
|
|
);
|
|
assert.strictEqual(
|
|
matchOf(String.raw`files.filter(f => /PLAN[\\/]\d+\.md$/.test(f));`),
|
|
String.raw`/PLAN[\\/]\d+\.md$/`,
|
|
);
|
|
assert.strictEqual(
|
|
matchOf(String.raw`files.filter(f => /SUMMARY[\\/][^x]*\.md$/.test(f));`),
|
|
String.raw`/SUMMARY[\\/][^x]*\.md$/`,
|
|
);
|
|
assert.strictEqual(
|
|
matchOf(String.raw`files.filter(f => /\.md[\\/]SUMMARY/.test(f));`),
|
|
String.raw`/\.md[\\/]SUMMARY/`,
|
|
);
|
|
|
|
// Character classes containing a BARE, UNESCAPED `/`. These are the real
|
|
// regression cases: the old regex treated that `/` as the literal's
|
|
// terminator, so it stopped scanning before reaching `\.md` and MISSED
|
|
// every one of them — verified against the parent-commit blob, where all
|
|
// four return null. A guard that cannot see `/SUMMARY[^/]*\.md$/` is
|
|
// blind to an ordinary path-excluding filter.
|
|
assert.strictEqual(
|
|
matchOf(String.raw`files.filter(f => /PLAN[/\\].*\.md$/.test(f));`),
|
|
String.raw`/PLAN[/\\].*\.md$/`,
|
|
);
|
|
assert.strictEqual(
|
|
matchOf(String.raw`files.filter(f => /PLAN[a/b]\.md$/.test(f));`),
|
|
String.raw`/PLAN[a/b]\.md$/`,
|
|
);
|
|
assert.strictEqual(
|
|
matchOf(String.raw`files.filter(f => /SUMMARY[^/]*\.md$/.test(f));`),
|
|
String.raw`/SUMMARY[^/]*\.md$/`,
|
|
);
|
|
assert.strictEqual(
|
|
matchOf(String.raw`files.filter(f => /PLAN[/]\.md$/.test(f));`),
|
|
String.raw`/PLAN[/]\.md$/`,
|
|
);
|
|
|
|
// Positive: an escaped slash (`\/`) inside the literal must not
|
|
// terminate it early — the whole literal is returned, not a truncated
|
|
// fragment up to the escaped `/`.
|
|
assert.strictEqual(
|
|
matchOf(String.raw`names.filter(n => /PLAN\/\d+\.md$/.test(n));`),
|
|
String.raw`/PLAN\/\d+\.md$/`,
|
|
);
|
|
|
|
// Negative: \.md with no PLAN/SUMMARY token in the literal.
|
|
assert.strictEqual(matchOf('names.filter((n) => /\\.md$/.test(n));'), null);
|
|
// Negative: PLAN with no escaped \.md token in the literal.
|
|
assert.strictEqual(matchOf('names.filter((n) => /PLAN/.test(n));'), null);
|
|
// Negative: the backslash-dense ReDoS attack construction (no closing
|
|
// `/`, so the literal never resolves).
|
|
const attack = '\\.'.repeat(8);
|
|
const attackLine = `const RE = /\\.mdplan${attack}X; names.filter(n => RE.test(n));`;
|
|
assert.strictEqual(matchOf(attackLine), null);
|
|
});
|
|
|
|
// Direct coverage of readRegexLiteralAt — the tokenizer's single-pass
|
|
// scan primitive. Replaces the deleted `!source.includes('[^/')`
|
|
// structural check, which was a gameable substring test (respelling the
|
|
// class as `[^\r\n/]` would still pass it while remaining exponential)
|
|
// inspecting a constant (REGEX_LITERAL_MD_RE) that no longer exists.
|
|
describe('readRegexLiteralAt', () => {
|
|
test('an unterminated literal (no closing `/`) returns null', () => {
|
|
assert.strictEqual(
|
|
drift.readRegexLiteralAt('/PLAN and no closing slash at all', 0),
|
|
null,
|
|
);
|
|
});
|
|
|
|
test('MAX_REGEX_LITERAL_LEN boundary: limit-1 reads, limit reads, limit+1 returns null', () => {
|
|
// Derived from the exported constant so the boundary cannot silently
|
|
// drift from it (a missing/undefined export must fail loudly, not
|
|
// vacuously pass a boundary computed from `undefined`).
|
|
const MAX = drift.MAX_REGEX_LITERAL_LEN;
|
|
assert.ok(Number.isInteger(MAX) && MAX > 10, `MAX_REGEX_LITERAL_LEN must be exported as an integer > 10, got ${MAX}`);
|
|
|
|
// Total literal length (both delimiters included) = contentLen + 2.
|
|
const build = (contentLen) => '/' + 'a'.repeat(contentLen) + '/';
|
|
const underBound = build(MAX - 3); // total length MAX-1 — limit-1
|
|
const atBound = build(MAX - 2); // total length MAX — limit (MAX_REGEX_LITERAL_LEN)
|
|
const overBound = build(MAX - 1); // total length MAX+1 — limit+1
|
|
|
|
assert.strictEqual(drift.readRegexLiteralAt(underBound, 0)?.text, underBound);
|
|
assert.strictEqual(drift.readRegexLiteralAt(atBound, 0)?.text, atBound);
|
|
assert.strictEqual(drift.readRegexLiteralAt(overBound, 0), null);
|
|
});
|
|
|
|
test('a `/` inside a `[...]` character class does not terminate the literal', () => {
|
|
const line = '/a[/]b/';
|
|
assert.strictEqual(drift.readRegexLiteralAt(line, 0)?.text, line);
|
|
});
|
|
|
|
test('an escaped `\\/` does not terminate the literal', () => {
|
|
const line = String.raw`/a\/b/`;
|
|
assert.strictEqual(drift.readRegexLiteralAt(line, 0)?.text, line);
|
|
});
|
|
|
|
test('trailing flags are included in the returned literal text', () => {
|
|
const line = '/foo/gi';
|
|
assert.strictEqual(drift.readRegexLiteralAt(line, 0)?.text, line);
|
|
});
|
|
});
|
|
|
|
// scanRepo's `walk` follows symlinks (via fs.statSync) to close the
|
|
// Dirent-classification evasion documented at `walk`'s definition, which
|
|
// means it must also confine itself to the repo root — an unconfined
|
|
// symlink follow would let a fork PR read and leak arbitrary files outside
|
|
// the repo into the CI-log report. `{ skip }` mirrors the win32 symlink
|
|
// guard used elsewhere in this repo (tests/phase.test.cjs) — symlink
|
|
// creation needs elevated privilege on Windows CI runners.
|
|
describe('walk — symlink handling and root confinement', () => {
|
|
const skip = process.platform === 'win32' ? 'symlink creation needs privilege on Windows' : false;
|
|
|
|
function violatingLine() {
|
|
return "module.exports.isPlan = (f) => f.endsWith('-PLAN.md');\n";
|
|
}
|
|
|
|
test('(a) a symlinked .cts INSIDE the root IS scanned, reported under its canonical real path', { skip }, (t) => {
|
|
const root = createTempDir('gsd-plan-count-drift-root-');
|
|
t.after(() => cleanup(root));
|
|
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
fs.mkdirSync(path.join(root, 'vendor'), { recursive: true });
|
|
const realFile = path.join(root, 'vendor', 'real-target.cts');
|
|
fs.writeFileSync(realFile, violatingLine());
|
|
fs.symlinkSync(realFile, path.join(root, 'src', 'linked.cts'));
|
|
|
|
const violations = drift.scanRepo(root);
|
|
assert.strictEqual(violations.length, 1);
|
|
assert.strictEqual(violations[0].file, path.join('vendor', 'real-target.cts'));
|
|
});
|
|
|
|
test('(b) a symlinked .cts pointing OUTSIDE the root is NOT scanned: zero violations reported', { skip }, (t) => {
|
|
const root = createTempDir('gsd-plan-count-drift-root-');
|
|
const outside = createTempDir('gsd-plan-count-drift-outside-');
|
|
t.after(() => cleanup(root));
|
|
t.after(() => cleanup(outside));
|
|
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
const outsideFile = path.join(outside, 'evil.cts');
|
|
fs.writeFileSync(outsideFile, violatingLine());
|
|
fs.symlinkSync(outsideFile, path.join(root, 'src', 'evil.cts'));
|
|
|
|
const violations = drift.scanRepo(root);
|
|
assert.strictEqual(violations.length, 0);
|
|
});
|
|
|
|
test('(c) a directory symlink pointing OUTSIDE the root is not descended into', { skip }, (t) => {
|
|
const root = createTempDir('gsd-plan-count-drift-root-');
|
|
const outside = createTempDir('gsd-plan-count-drift-outside-');
|
|
t.after(() => cleanup(root));
|
|
t.after(() => cleanup(outside));
|
|
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
const outsideDir = path.join(outside, 'dir');
|
|
fs.mkdirSync(outsideDir, { recursive: true });
|
|
fs.writeFileSync(path.join(outsideDir, 'evil.cts'), violatingLine());
|
|
fs.symlinkSync(outsideDir, path.join(root, 'src', 'outdir'), 'dir');
|
|
|
|
const violations = drift.scanRepo(root);
|
|
assert.strictEqual(violations.length, 0);
|
|
});
|
|
|
|
test('(d) a symlink CYCLE terminates rather than recursing forever, and dedupes the real file', { skip }, (t) => {
|
|
const root = createTempDir('gsd-plan-count-drift-root-');
|
|
t.after(() => cleanup(root));
|
|
const srcDir = path.join(root, 'src');
|
|
fs.mkdirSync(srcDir, { recursive: true });
|
|
fs.writeFileSync(path.join(srcDir, 'real.cts'), violatingLine());
|
|
fs.symlinkSync(srcDir, path.join(srcDir, 'loop'), 'dir');
|
|
|
|
const violations = drift.scanRepo(root);
|
|
assert.strictEqual(violations.filter((v) => v.file === path.join('src', 'real.cts')).length, 1);
|
|
});
|
|
|
|
test('(e) a BROKEN symlink is skipped without throwing', { skip }, (t) => {
|
|
const root = createTempDir('gsd-plan-count-drift-root-');
|
|
t.after(() => cleanup(root));
|
|
const srcDir = path.join(root, 'src');
|
|
fs.mkdirSync(srcDir, { recursive: true });
|
|
fs.symlinkSync(path.join(srcDir, '.does-not-exist.cts'), path.join(srcDir, 'broken.cts'));
|
|
|
|
assert.doesNotThrow(() => drift.scanRepo(root));
|
|
assert.strictEqual(drift.scanRepo(root).length, 0);
|
|
});
|
|
|
|
test('(f) two symlinks inside the root to the SAME real file yield ONE entry, not two', { skip }, (t) => {
|
|
const root = createTempDir('gsd-plan-count-drift-root-');
|
|
t.after(() => cleanup(root));
|
|
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
fs.mkdirSync(path.join(root, 'vendor'), { recursive: true });
|
|
const realFile = path.join(root, 'vendor', 'shared-target.cts');
|
|
fs.writeFileSync(realFile, violatingLine());
|
|
fs.symlinkSync(realFile, path.join(root, 'src', 'link1.cts'));
|
|
fs.symlinkSync(realFile, path.join(root, 'src', 'link2.cts'));
|
|
|
|
const violations = drift.scanRepo(root);
|
|
assert.strictEqual(violations.length, 1);
|
|
assert.strictEqual(violations[0].file, path.join('vendor', 'shared-target.cts'));
|
|
});
|
|
});
|
|
});
|
|
|
|
// Direct coverage of isInsideRoot — a reviewer mutating its body to a bare
|
|
// `realPath.startsWith(realRoot)` left every existing test above still
|
|
// passing, because none of those temp fixtures ever produce a SIBLING
|
|
// directory sharing the root's name as a strict prefix. This describe closes
|
|
// that gap so the mutant is actually killed (relevant to the repo's 80%
|
|
// Stryker mutation gate).
|
|
describe('isInsideRoot', () => {
|
|
test('the root itself is inside', () => {
|
|
const root = path.join(path.sep, 'tmp', 'repo');
|
|
assert.strictEqual(drift.isInsideRoot(root, root), true);
|
|
});
|
|
|
|
test('a child path is inside', () => {
|
|
const root = path.join(path.sep, 'tmp', 'repo');
|
|
const child = path.join(root, 'src', 'file.cts');
|
|
assert.strictEqual(drift.isInsideRoot(child, root), true);
|
|
});
|
|
|
|
test('a SIBLING whose name is the root plus a suffix is NOT inside', () => {
|
|
// This is the case that kills the `startsWith(realRoot)` mutant: a bare
|
|
// prefix check wrongly admits `/tmp/repo-evil/x.cts` as "inside"
|
|
// `/tmp/repo`, because the string "/tmp/repo-evil/x.cts" does start with
|
|
// the string "/tmp/repo". The real implementation requires an exact
|
|
// match or a root+separator prefix, so this must be rejected.
|
|
const root = path.join(path.sep, 'tmp', 'repo');
|
|
const sibling = path.join(path.sep, 'tmp', 'repo-evil', 'x.cts');
|
|
assert.strictEqual(drift.isInsideRoot(sibling, root), false);
|
|
});
|
|
|
|
test('a parent path is not inside', () => {
|
|
const root = path.join(path.sep, 'tmp', 'repo', 'src');
|
|
const parent = path.join(path.sep, 'tmp', 'repo');
|
|
assert.strictEqual(drift.isInsideRoot(parent, root), false);
|
|
});
|
|
|
|
test('an unrelated absolute path is not inside', () => {
|
|
const root = path.join(path.sep, 'tmp', 'repo');
|
|
const unrelated = path.join(path.sep, 'var', 'other', 'x.cts');
|
|
assert.strictEqual(drift.isInsideRoot(unrelated, root), false);
|
|
});
|
|
});
|
|
|
|
// Direct coverage of sanitizeForReport — both a matched `found` fragment and
|
|
// a reported file path are attacker-controlled source text on a fork PR, and
|
|
// both are written straight to a CI log; this asserts the escaping contract
|
|
// documented at the function's definition (C0/C1 control bytes plus the
|
|
// listed bidi/line-separator/zero-width codepoints), and that ordinary
|
|
// printable text — including the regex-literal punctuation this module
|
|
// itself tokenizes — passes through completely unchanged.
|
|
describe('sanitizeForReport', () => {
|
|
test('escapes a C0 control byte (ESC 0x1b)', () => {
|
|
assert.strictEqual(drift.sanitizeForReport(String.fromCharCode(0x1b)), '\\x1b');
|
|
});
|
|
|
|
test('escapes DEL (0x7f)', () => {
|
|
assert.strictEqual(drift.sanitizeForReport(String.fromCharCode(0x7f)), '\\x7f');
|
|
});
|
|
|
|
test('escapes a C1 byte (0x9b)', () => {
|
|
assert.strictEqual(drift.sanitizeForReport(String.fromCharCode(0x9b)), '\\x9b');
|
|
});
|
|
|
|
test('escapes ZERO WIDTH SPACE (U+200B)', () => {
|
|
assert.strictEqual(drift.sanitizeForReport('\u200B'), '\\u200b');
|
|
});
|
|
|
|
test('escapes LINE SEPARATOR (U+2028)', () => {
|
|
assert.strictEqual(drift.sanitizeForReport('\u2028'), '\\u2028');
|
|
});
|
|
|
|
test('escapes PARAGRAPH SEPARATOR (U+2029)', () => {
|
|
assert.strictEqual(drift.sanitizeForReport('\u2029'), '\\u2029');
|
|
});
|
|
|
|
test('escapes RIGHT-TO-LEFT OVERRIDE (U+202E)', () => {
|
|
assert.strictEqual(drift.sanitizeForReport('\u202E'), '\\u202e');
|
|
});
|
|
|
|
test('ordinary printable text, including regex-literal punctuation, is unchanged', () => {
|
|
const text = String.raw`/-PLAN\.md$/i names.filter([].$^*+ )`;
|
|
assert.strictEqual(drift.sanitizeForReport(text), text);
|
|
});
|
|
});
|