This commit is contained in:
346
tests/health-diagnostic-rules/consistency.test.cjs
Normal file
346
tests/health-diagnostic-rules/consistency.test.cjs
Normal file
@@ -0,0 +1,346 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Tests for `src/health-diagnostic-rules/consistency.cts` (Phase 12, #3310,
|
||||
* ADR-3180 §8.4) — the "consistency" (`C0NN`) rule group: C001-C004, plus
|
||||
* `health-diagnostic.cts`'s `CONSISTENCY_RULES`/`evaluateConsistencyRules`
|
||||
* composition (W006/W007 reuse proof).
|
||||
*
|
||||
* Design: .gsd/phase/feat-3310-enhance-3180-the-sibling-validators-shar/40-design.md
|
||||
* Test matrix: .gsd/phase/feat-3310-enhance-3180-the-sibling-validators-shar/50-test-matrix.md
|
||||
* section 2 (rows 9-11), section 3 (C001-C004)
|
||||
*
|
||||
* Fixture provenance (§8.5 + CONTRIBUTING "Fixture provenance (#2371)"):
|
||||
* - C001/C002/C004 are MECHANICAL MUTATION — a numbering gap / a missing
|
||||
* `wave:` line, mirroring the exact fixture shapes
|
||||
* `tests/planning-snapshot.test.cjs`'s Phase-12 field tests already use
|
||||
* for the same underlying `PlanningSnapshot` fields.
|
||||
* - C003 REUSES `tests/health-diagnostic-rules/phase-structure.test.cjs`'s
|
||||
* I001 fixture family (a live PLAN.md with no matching SUMMARY.md),
|
||||
* inverted: a SUMMARY.md with no matching live PLAN.md.
|
||||
*
|
||||
* Every case calls the REAL `buildPlanningSnapshot(cwd)` against a real
|
||||
* temp `.planning/` tree, then the REAL rule `check` functions from the
|
||||
* compiled module under test — no hand-built in-memory snapshot mocks,
|
||||
* mirroring `tests/health-diagnostic-rules/roadmap-disk-consistency.test.cjs`.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { createTempDir, cleanup } = require('../helpers.cjs');
|
||||
|
||||
const { buildPlanningSnapshot } = require('../../gsd-core/bin/lib/planning-snapshot.cjs');
|
||||
const consistencyMod = require('../../gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs');
|
||||
const { RULES } = consistencyMod;
|
||||
const {
|
||||
SEVERITY,
|
||||
REMEDY_ACTION,
|
||||
REMEDY_RISK,
|
||||
CONSISTENCY_RULES,
|
||||
evaluateConsistencyRules,
|
||||
evaluateRules,
|
||||
} = require('../../gsd-core/bin/lib/health-diagnostic.cjs');
|
||||
|
||||
function ruleFor(code) {
|
||||
const rule = RULES.find((r) => r.code === code);
|
||||
assert.ok(rule, `rule ${code} not found in RULES`);
|
||||
return rule;
|
||||
}
|
||||
|
||||
// ─── Fixture helpers (mirrors tests/planning-snapshot.test.cjs) ────────────
|
||||
|
||||
function planningDirOf(cwd) {
|
||||
return path.join(cwd, '.planning');
|
||||
}
|
||||
|
||||
function writeRoadmap(cwd, content) {
|
||||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||||
fs.writeFileSync(path.join(planningDirOf(cwd), 'ROADMAP.md'), content);
|
||||
}
|
||||
|
||||
function writeFile(cwd, relPath, content) {
|
||||
const full = path.join(cwd, relPath);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
|
||||
function makePhaseDir(cwd, dirName) {
|
||||
fs.mkdirSync(path.join(planningDirOf(cwd), 'phases', dirName), { recursive: true });
|
||||
}
|
||||
|
||||
function writePlan(cwd, relPhasePath, planName, frontmatterLines) {
|
||||
const lines = frontmatterLines ? ['---', ...frontmatterLines, '---', '', '# Plan', ''] : ['# Plan', ''];
|
||||
writeFile(cwd, `${relPhasePath}/${planName}`, lines.join('\n'));
|
||||
}
|
||||
|
||||
// ─── RULES shape ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('RULES (consistency group)', () => {
|
||||
test('exports exactly 4 rules: C001, C002, C003, C004', () => {
|
||||
assert.deepEqual(RULES.map((r) => r.code).sort(), ['C001', 'C002', 'C003', 'C004']);
|
||||
});
|
||||
|
||||
test('all four rules are severity WARNING with an ADVISE remedy shape', () => {
|
||||
for (const code of ['C001', 'C002', 'C003', 'C004']) {
|
||||
assert.equal(ruleFor(code).severity, SEVERITY.WARNING);
|
||||
assert.equal(ruleFor(code).repairable, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── C001 — gap in disk phase numbering ────────────────────────────────────
|
||||
|
||||
describe('C001 — gap in disk phase numbering', () => {
|
||||
test('MECHANICAL MUTATION: phases 1, 2, 4 on disk (3 missing) fires C001', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-c001-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeRoadmap(cwd, ['## v1.0 Current 🚧', ''].join('\n'));
|
||||
makePhaseDir(cwd, '01-foo');
|
||||
makePhaseDir(cwd, '02-bar');
|
||||
makePhaseDir(cwd, '04-baz');
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
const diagnostics = ruleFor('C001').check(snapshot);
|
||||
|
||||
assert.equal(diagnostics.length, 1);
|
||||
assert.deepEqual(diagnostics[0], {
|
||||
code: 'C001',
|
||||
severity: SEVERITY.WARNING,
|
||||
message: 'Gap in phase numbering: 2 → 4',
|
||||
remedy: {
|
||||
action: REMEDY_ACTION.ADVISE,
|
||||
risk: REMEDY_RISK.NONE,
|
||||
args: { command: 'Create the missing phase directory or renumber to close the gap' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('baseline: a sequential integer run produces no diagnostics', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-c001-neg-');
|
||||
t.after(() => cleanup(cwd));
|
||||
makePhaseDir(cwd, '01-foo');
|
||||
makePhaseDir(cwd, '02-bar');
|
||||
makePhaseDir(cwd, '03-baz');
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
assert.deepEqual(ruleFor('C001').check(snapshot), []);
|
||||
});
|
||||
|
||||
test('boundary: phase_naming "custom" skips the integer-sequence gap check entirely', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-c001-custom-');
|
||||
t.after(() => cleanup(cwd));
|
||||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(planningDirOf(cwd), 'config.json'),
|
||||
JSON.stringify({ phase_naming: 'custom' }, null, 2),
|
||||
);
|
||||
makePhaseDir(cwd, '01-foo');
|
||||
makePhaseDir(cwd, '04-baz');
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
assert.equal(snapshot.config.value?.['phase_naming'], 'custom');
|
||||
assert.deepEqual(ruleFor('C001').check(snapshot), []);
|
||||
});
|
||||
|
||||
test('a sentinel phase dir (999-interim) does not produce a spurious gap', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-c001-sentinel-');
|
||||
t.after(() => cleanup(cwd));
|
||||
makePhaseDir(cwd, '01-foo');
|
||||
makePhaseDir(cwd, '02-bar');
|
||||
makePhaseDir(cwd, '999-interim');
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
assert.deepEqual(ruleFor('C001').check(snapshot), []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── C002 — gap in plan numbering within a phase ───────────────────────────
|
||||
|
||||
describe('C002 — gap in plan numbering within a phase', () => {
|
||||
test('MECHANICAL MUTATION: 01-PLAN.md, 03-PLAN.md in one phase dir fires C002', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-c002-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-01-PLAN.md');
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-03-PLAN.md');
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
const diagnostics = ruleFor('C002').check(snapshot);
|
||||
|
||||
assert.equal(diagnostics.length, 1);
|
||||
assert.deepEqual(diagnostics[0], {
|
||||
code: 'C002',
|
||||
severity: SEVERITY.WARNING,
|
||||
message: 'Gap in plan numbering in 01-foo: plan 1 → 3',
|
||||
remedy: {
|
||||
action: REMEDY_ACTION.ADVISE,
|
||||
risk: REMEDY_RISK.NONE,
|
||||
args: { command: 'Create the missing plan or renumber to close the gap' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('boundary: a gap of exactly one (01, 02) still reports the same C002 subject', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-c002-gap1-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-01-PLAN.md');
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-02-PLAN.md');
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
// Sequential (01, 02) is NOT a gap — sanity baseline for the boundary
|
||||
// pairing below (a real gap must be > 1, not merely non-empty).
|
||||
assert.deepEqual(ruleFor('C002').check(snapshot), []);
|
||||
});
|
||||
|
||||
test('baseline: sequential plans (01, 02, 03) produce no diagnostics', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-c002-neg-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-01-PLAN.md');
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-02-PLAN.md');
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-03-PLAN.md');
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
assert.deepEqual(ruleFor('C002').check(snapshot), []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── C003 — orphan SUMMARY with no matching live PLAN ──────────────────────
|
||||
// Reuses phase-structure.test.cjs's I001 fixture family, inverted.
|
||||
|
||||
describe('C003 — orphan SUMMARY with no matching live PLAN', () => {
|
||||
test('REUSED (I001 family, inverted): a SUMMARY.md with no matching PLAN.md fires C003', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-c003-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-SUMMARY.md', '# Summary\n');
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
const diagnostics = ruleFor('C003').check(snapshot);
|
||||
|
||||
assert.equal(diagnostics.length, 1);
|
||||
assert.deepEqual(diagnostics[0], {
|
||||
code: 'C003',
|
||||
severity: SEVERITY.WARNING,
|
||||
message: 'Summary 01-01-SUMMARY.md in 01-foo has no matching PLAN.md',
|
||||
remedy: {
|
||||
action: REMEDY_ACTION.ADVISE,
|
||||
risk: REMEDY_RISK.NONE,
|
||||
args: { command: 'Create the matching PLAN.md or remove the orphan summary' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('boundary: a summary paired only to a superseded plan is still an orphan', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-c003-superseded-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-01-PLAN.md', ['status: superseded']);
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-SUMMARY.md', '# Summary\n');
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
const diagnostics = ruleFor('C003').check(snapshot);
|
||||
assert.equal(diagnostics.length, 1);
|
||||
assert.equal(diagnostics[0].code, 'C003', 'a summary paired only to a superseded plan must still fire C003');
|
||||
});
|
||||
|
||||
test('baseline: a paired plan+summary produces no diagnostics', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-c003-neg-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-01-PLAN.md');
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-SUMMARY.md', '# Summary\n');
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
assert.deepEqual(ruleFor('C003').check(snapshot), []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── C004 — PLAN missing 'wave' in frontmatter ─────────────────────────────
|
||||
|
||||
describe("C004 — PLAN missing 'wave' in frontmatter", () => {
|
||||
test('MECHANICAL MUTATION: a shipped PLAN template with wave: stripped fires C004', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-c004-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-01-PLAN.md');
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
const diagnostics = ruleFor('C004').check(snapshot);
|
||||
|
||||
assert.equal(diagnostics.length, 1);
|
||||
assert.deepEqual(diagnostics[0], {
|
||||
code: 'C004',
|
||||
severity: SEVERITY.WARNING,
|
||||
message: "01-foo/01-01-PLAN.md: missing 'wave' in frontmatter",
|
||||
remedy: {
|
||||
action: REMEDY_ACTION.ADVISE,
|
||||
risk: REMEDY_RISK.NONE,
|
||||
args: { command: "Add a 'wave' key to the plan's frontmatter" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('baseline: a plan with wave: in frontmatter produces no diagnostics', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-c004-neg-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-01-PLAN.md', ['wave: 1']);
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
assert.deepEqual(ruleFor('C004').check(snapshot), []);
|
||||
});
|
||||
|
||||
test('a superseded plan with no wave is not flagged (superseded plans are excluded from the live set)', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-c004-superseded-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-01-PLAN.md', ['status: superseded']);
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
assert.deepEqual(ruleFor('C004').check(snapshot), []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CONSISTENCY_RULES composition (health-diagnostic.cts) ────────────────
|
||||
|
||||
describe('CONSISTENCY_RULES composition (matrix row 9)', () => {
|
||||
test('exactly W006, W007, C001-C004 — 6 entries, no duplicates', () => {
|
||||
const codes = CONSISTENCY_RULES.map((r) => r.code).sort();
|
||||
assert.deepEqual(codes, ['C001', 'C002', 'C003', 'C004', 'W006', 'W007']);
|
||||
assert.equal(new Set(codes).size, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateConsistencyRules', () => {
|
||||
test('matrix row 10: an all-clean snapshot produces zero diagnostics', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-ecr-clean-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n'));
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-01-PLAN.md', ['wave: 1']);
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-SUMMARY.md', '# Summary\n');
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
assert.deepEqual(evaluateConsistencyRules(snapshot), []);
|
||||
});
|
||||
|
||||
test('matrix row 11 (W006 reuse proof): evaluateConsistencyRules produces the IDENTICAL Diagnostic evaluateRules would for the same W006 fixture', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-ecr-w006-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeRoadmap(
|
||||
cwd,
|
||||
['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar'].join('\n'),
|
||||
);
|
||||
makePhaseDir(cwd, '01-foo');
|
||||
// Phase 2 deliberately has no matching directory -> W006.
|
||||
|
||||
const snapshot = buildPlanningSnapshot(cwd);
|
||||
|
||||
const fromConsistency = evaluateConsistencyRules(snapshot).filter((d) => d.code === 'W006');
|
||||
const fromHealth = evaluateRules(snapshot).filter((d) => d.code === 'W006');
|
||||
|
||||
assert.equal(fromConsistency.length, 1);
|
||||
assert.equal(fromHealth.length, 1);
|
||||
assert.deepEqual(fromConsistency[0], fromHealth[0]);
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(fromConsistency)),
|
||||
JSON.parse(JSON.stringify(fromHealth)),
|
||||
'evaluateConsistencyRules must produce a byte-identical Diagnostic to evaluateRules for the same fixture — proof of reuse, not reimplementation',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,8 @@ const {
|
||||
checkFixtureProofInvariant,
|
||||
findHealthDiagnosticTestFiles,
|
||||
PERMANENTLY_INERT_CODES,
|
||||
STATE_VALIDATE_TEST_FILE,
|
||||
STATE_VALIDATE_CODES,
|
||||
} = guard;
|
||||
|
||||
const FAKE_SEVERITY = Object.freeze({ ERROR: 'error', WARNING: 'warning', INFO: 'info' });
|
||||
@@ -221,6 +223,57 @@ describe('checkFixtureProofInvariant — PERMANENTLY_INERT_CODES exemption (§8.
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Check 3 — S0NN pass (Phase 12, #3310): checkFixtureProofInvariant
|
||||
// against the hardcoded STATE_VALIDATE_CODES list and tests/state.test.cjs.
|
||||
// These codes are not Rule-table entries (cmdStateValidate builds
|
||||
// Diagnostic[] inline), so this check exercises the SAME
|
||||
// checkFixtureProofInvariant helper the C0NN pass uses, just fed a
|
||||
// hardcoded code list instead of a Rule[] array — mirroring the original
|
||||
// W/E/I test structure above (no code-source-specific behavior to test
|
||||
// beyond that, since checkFixtureProofInvariant itself is already covered).
|
||||
|
||||
describe('checkFixtureProofInvariant (S0NN pass, #3310)', () => {
|
||||
test('flags a code with zero mentions anywhere in the scanned test files', (t) => {
|
||||
const dir = createTempDir('gsd-lint-hd-rt-s0nn-nomention-');
|
||||
t.after(() => cleanup(dir));
|
||||
const file = writeTempTestFile(
|
||||
dir,
|
||||
'fake-state.test.cjs',
|
||||
"describe('S001: something', () => { test('fires', () => {}); });\n",
|
||||
);
|
||||
|
||||
const rules = [{ code: 'S001' }, { code: 'S999' }];
|
||||
const { uncovered } = checkFixtureProofInvariant(rules, [file]);
|
||||
|
||||
assert.deepEqual(uncovered, ['S999']);
|
||||
});
|
||||
|
||||
test('passes a code named in a test() block title', (t) => {
|
||||
const dir = createTempDir('gsd-lint-hd-rt-s0nn-titled-');
|
||||
t.after(() => cleanup(dir));
|
||||
const file = writeTempTestFile(
|
||||
dir,
|
||||
'fake-state.test.cjs',
|
||||
"test('S001: STATE.md corrupt (NUL byte) fires with the verbatim textEncodingError message', () => {});\n",
|
||||
);
|
||||
|
||||
const rules = [{ code: 'S001' }];
|
||||
const { uncovered } = checkFixtureProofInvariant(rules, [file]);
|
||||
|
||||
assert.deepEqual(uncovered, []);
|
||||
});
|
||||
|
||||
test('all 7 real STATE_VALIDATE_CODES (S001-S007) are fixture-covered against the real tests/state.test.cjs', () => {
|
||||
assert.ok(fs.existsSync(STATE_VALIDATE_TEST_FILE), 'tests/state.test.cjs must exist');
|
||||
assert.deepEqual(STATE_VALIDATE_CODES, ['S001', 'S002', 'S003', 'S004', 'S005', 'S006', 'S007']);
|
||||
|
||||
const rules = STATE_VALIDATE_CODES.map((code) => ({ code }));
|
||||
const { uncovered } = checkFixtureProofInvariant(rules, [STATE_VALIDATE_TEST_FILE], new Map());
|
||||
|
||||
assert.deepEqual(uncovered, []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── findHealthDiagnosticTestFiles ─────────────────────────────────────────
|
||||
|
||||
describe('findHealthDiagnosticTestFiles', () => {
|
||||
|
||||
228
tests/lint-planning-artifact-writer-drift.test.cjs
Normal file
228
tests/lint-planning-artifact-writer-drift.test.cjs
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Tests for the `.planning/`-root artifact writer-registry completeness
|
||||
* guard (epic #3180, ADR-3180 §8.4 deliverable C, Phase 12 #3310) —
|
||||
* `scripts/lint-planning-artifact-writer-drift.cjs`.
|
||||
*
|
||||
* Unlike its `lint-*-drift.cjs` siblings, this guard is a bare pass/fail
|
||||
* completeness check (no ratchet baseline) — see the guard's own module
|
||||
* docblock. Tests exercise the guard's PURE functions directly with
|
||||
* in-memory strings, mirroring `tests/planning-snapshot-bypass-drift.test.cjs`'s
|
||||
* structure — no shelling out to the CLI, except the one real-tree
|
||||
* regression test which imports and calls `scanRepo` in-process.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const drift = require('../scripts/lint-planning-artifact-writer-drift.cjs');
|
||||
const {
|
||||
scanFileForCandidates,
|
||||
findArtifactWriterDrift,
|
||||
scanRepo,
|
||||
stripLineComment,
|
||||
} = drift;
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const FAKE_FILE = path.join('src', 'fake-writer.cts');
|
||||
|
||||
// ─── Case 1: the guard passes clean against the REAL src/ tree ───────────
|
||||
// This is the main regression-proof test: the design doc's ground-truth
|
||||
// sweep found zero current violations (every real root-artifact writer is
|
||||
// already registered), so a red run here means either a genuine new/newly
|
||||
// discovered registry gap or a detector regression — never "expected".
|
||||
|
||||
describe('scanRepo — real src/ tree', () => {
|
||||
test('reports zero violations against the actual src/*.cts tree', () => {
|
||||
const violations = scanRepo(REPO_ROOT);
|
||||
assert.deepStrictEqual(
|
||||
violations,
|
||||
[],
|
||||
`unexpected .planning/-root writer-registry violation(s): ${JSON.stringify(violations, null, 2)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('the real tree also has REAL statically-resolvable candidates (the detector is not silently inert)', () => {
|
||||
// A guard that never matches anything would also report zero
|
||||
// violations — this proves the detector actually found real writers
|
||||
// (ROADMAP.md/STATE.md/config.json/MILESTONES.md/REQUIREMENTS.md) and
|
||||
// they were individually checked, not just skipped wholesale.
|
||||
const driftScanLib = require('../scripts/lib/drift-scan.cjs');
|
||||
const candidates = driftScanLib.scanTree({
|
||||
root: REPO_ROOT,
|
||||
scanDirs: drift.SCAN_DIRS,
|
||||
scanExt: drift.SCAN_EXT,
|
||||
onFile(rel, text) {
|
||||
return scanFileForCandidates(text, rel);
|
||||
},
|
||||
});
|
||||
assert.ok(candidates.length > 0, 'expected at least one statically-resolvable .planning/-root write in src/*.cts');
|
||||
const filenames = new Set(candidates.map((c) => c.filename));
|
||||
assert.ok(filenames.has('ROADMAP.md'));
|
||||
assert.ok(filenames.has('STATE.md'));
|
||||
assert.ok(filenames.has('config.json'));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Case 2: an unregistered literal filename is flagged ─────────────────
|
||||
|
||||
describe('findArtifactWriterDrift — unregistered filename is a violation', () => {
|
||||
test('a write to an unregistered .planning/-root file is flagged, naming the file', () => {
|
||||
const source = [
|
||||
'function cmdWriteFoo(cwd) {',
|
||||
" const fooPath = path.join(planningRoot(cwd), 'FOO.md');",
|
||||
' platformWriteSync(fooPath, content);',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
// Injected predicate (no build:lib dependency for this unit test):
|
||||
// everything is canonical EXCEPT 'FOO.md'.
|
||||
const isCanonical = (name) => name !== 'FOO.md';
|
||||
|
||||
const out = findArtifactWriterDrift(source, FAKE_FILE, isCanonical);
|
||||
assert.strictEqual(out.length, 1);
|
||||
assert.strictEqual(out[0].filename, 'FOO.md');
|
||||
assert.strictEqual(out[0].file, FAKE_FILE.replace(/\\/g, '/'));
|
||||
assert.strictEqual(out[0].line, 3);
|
||||
assert.strictEqual(out[0].text, 'platformWriteSync(fooPath, content);');
|
||||
});
|
||||
|
||||
test('a registered filename resolved through the identical path is NOT flagged', () => {
|
||||
const source = [
|
||||
'function cmdWriteFoo(cwd) {',
|
||||
" const fooPath = path.join(planningRoot(cwd), 'FOO.md');",
|
||||
' platformWriteSync(fooPath, content);',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
const isCanonical = () => true; // everything canonical
|
||||
const out = findArtifactWriterDrift(source, FAKE_FILE, isCanonical);
|
||||
assert.deepStrictEqual(out, []);
|
||||
});
|
||||
|
||||
test('an inline path.join (no intermediate variable) is also detected and checked', () => {
|
||||
const source = [
|
||||
'function cmdWriteBar(cwd) {',
|
||||
" platformWriteSync(path.join(planningRoot(cwd), 'BAR.json'), content);",
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
const out = findArtifactWriterDrift(source, FAKE_FILE, (name) => name !== 'BAR.json');
|
||||
assert.strictEqual(out.length, 1);
|
||||
assert.strictEqual(out[0].filename, 'BAR.json');
|
||||
});
|
||||
|
||||
test('an object-literal-property binding (RepairPaths-style) resolves the same as a local const', () => {
|
||||
const source = [
|
||||
'function repairPaths(cwd) {',
|
||||
' const rootBase = planningRoot(cwd);',
|
||||
' return {',
|
||||
" fooPath: path.join(rootBase, 'FOO.md'),",
|
||||
' };',
|
||||
'}',
|
||||
'function repair(cwd) {',
|
||||
' const { fooPath } = repairPaths(cwd);',
|
||||
' platformWriteSync(fooPath, content);',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
const out = findArtifactWriterDrift(source, FAKE_FILE, (name) => name !== 'FOO.md');
|
||||
assert.strictEqual(out.length, 1);
|
||||
assert.strictEqual(out[0].filename, 'FOO.md');
|
||||
assert.strictEqual(out[0].line, 9);
|
||||
});
|
||||
|
||||
test('planningPaths(cwd).<prop> (a full-path property, not a directory) resolves to its known file name', () => {
|
||||
const source = [
|
||||
'function cmdWriteState(cwd) {',
|
||||
' const p = planningPaths(cwd).state;',
|
||||
' platformWriteSync(p, content);',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
const out = findArtifactWriterDrift(source, FAKE_FILE, () => false); // everything fails -> must see STATE.md
|
||||
assert.strictEqual(out.length, 1);
|
||||
assert.strictEqual(out[0].filename, 'STATE.md');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Case 3: a runtime-computed target is silently skipped ───────────────
|
||||
|
||||
describe('scanFileForCandidates — dynamic targets are never candidates (not pass, not fail)', () => {
|
||||
test('a template-literal file name (inline) produces NO candidate at all', () => {
|
||||
const source = [
|
||||
'function cmdWriteDynamic(cwd, name) {',
|
||||
' platformWriteSync(path.join(planningRoot(cwd), `${name}.md`), content);',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
const candidates = scanFileForCandidates(source, FAKE_FILE);
|
||||
assert.deepStrictEqual(candidates, [], 'a runtime-computed filename must never be reported as a candidate, checked or otherwise');
|
||||
});
|
||||
|
||||
test('a template-literal file name (via an intermediate variable) also produces no candidate', () => {
|
||||
const source = [
|
||||
'function cmdWriteDynamic(cwd, name) {',
|
||||
' const dynPath = path.join(planningRoot(cwd), `${name}.md`);',
|
||||
' platformWriteSync(dynPath, content);',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
const candidates = scanFileForCandidates(source, FAKE_FILE);
|
||||
assert.deepStrictEqual(candidates, []);
|
||||
});
|
||||
|
||||
test('findArtifactWriterDrift over the same dynamic-target source reports no violation either (never a false pass or false fail)', () => {
|
||||
const source = [
|
||||
'function cmdWriteDynamic(cwd, name) {',
|
||||
' platformWriteSync(path.join(planningRoot(cwd), `${name}.md`), content);',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
// Even an isCanonical that rejects EVERYTHING must not surface a
|
||||
// violation here, because the write was never a candidate to begin with.
|
||||
const out = findArtifactWriterDrift(source, FAKE_FILE, () => false);
|
||||
assert.deepStrictEqual(out, []);
|
||||
});
|
||||
|
||||
test('a workstream-scoped planningDir(cwd, ws) call is ambiguous and is never treated as an unambiguous root', () => {
|
||||
const source = [
|
||||
'function cmdWriteScoped(cwd, ws) {',
|
||||
" const p = path.join(planningDir(cwd, ws), 'config.json');",
|
||||
' platformWriteSync(p, content);',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
const candidates = scanFileForCandidates(source, FAKE_FILE);
|
||||
assert.deepStrictEqual(candidates, [], 'planningDir(cwd, ws) can resolve under .planning/workstreams/<ws>/ — must be skipped, not treated as root');
|
||||
});
|
||||
|
||||
test('a nested write (under milestones/) is never treated as a root-level candidate', () => {
|
||||
const source = [
|
||||
'function cmdArchive(cwd, version) {',
|
||||
" const archiveDir = path.join(planningRoot(cwd), 'milestones');",
|
||||
' platformWriteSync(path.join(archiveDir, `${version}-ROADMAP.md`), content);',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
const candidates = scanFileForCandidates(source, FAKE_FILE);
|
||||
assert.deepStrictEqual(candidates, []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── stripLineComment — comment/string safety ─────────────────────────────
|
||||
|
||||
describe('stripLineComment', () => {
|
||||
test('strips a trailing // comment but preserves a quoted string containing //', () => {
|
||||
const line = " const x = 'http://example.com'; // not real";
|
||||
assert.strictEqual(stripLineComment(line), " const x = 'http://example.com'; ");
|
||||
});
|
||||
|
||||
test('a // inside a string does not truncate the line early', () => {
|
||||
const line = "platformWriteSync(path.join(planningRoot(cwd), '//weird.md'), c);";
|
||||
// The string content is preserved verbatim even though it contains //.
|
||||
assert.ok(stripLineComment(line).includes("'//weird.md'"));
|
||||
});
|
||||
});
|
||||
@@ -277,8 +277,8 @@ describe('#3164 — validate consistency: milestone-archive layout', () => {
|
||||
const result = runGsdTools('validate consistency', tmpDir);
|
||||
assert.ok(result.success);
|
||||
|
||||
const w006 = (JSON.parse(result.output).warnings || []).filter(w => w.includes('Phase 64') && w.includes('no directory'));
|
||||
assert.deepStrictEqual(w006, [], `Got spurious W006: ${w006.join(', ')}`);
|
||||
const w006 = (JSON.parse(result.output).warnings || []).filter(w => w.message.includes('Phase 64') && w.message.includes('no directory'));
|
||||
assert.deepStrictEqual(w006, [], `Got spurious W006: ${JSON.stringify(w006)}`);
|
||||
});
|
||||
|
||||
test('no W006 when multiple phases exist in milestone-archive layout', () => {
|
||||
@@ -287,8 +287,8 @@ describe('#3164 — validate consistency: milestone-archive layout', () => {
|
||||
const result = runGsdTools('validate consistency', tmpDir);
|
||||
assert.ok(result.success);
|
||||
|
||||
const w006 = (JSON.parse(result.output).warnings || []).filter(w => w.includes('no directory'));
|
||||
assert.deepStrictEqual(w006, [], `Got spurious W006: ${w006.join(', ')}`);
|
||||
const w006 = (JSON.parse(result.output).warnings || []).filter(w => w.message.includes('no directory'));
|
||||
assert.deepStrictEqual(w006, [], `Got spurious W006: ${JSON.stringify(w006)}`);
|
||||
});
|
||||
|
||||
test('prefixed archive dir names (CK-64-...) are recognized as phase 64', () => {
|
||||
@@ -297,7 +297,7 @@ describe('#3164 — validate consistency: milestone-archive layout', () => {
|
||||
const result = runGsdTools('validate consistency', tmpDir);
|
||||
assert.ok(result.success);
|
||||
|
||||
const w006 = (JSON.parse(result.output).warnings || []).filter(w => w.includes('Phase 64') && w.includes('no directory'));
|
||||
const w006 = (JSON.parse(result.output).warnings || []).filter(w => w.message.includes('Phase 64') && w.message.includes('no directory'));
|
||||
assert.deepStrictEqual(w006, [], `Prefixed phase dir should count as phase 64`);
|
||||
});
|
||||
|
||||
@@ -328,18 +328,31 @@ describe('#3164 — validate consistency: milestone-archive layout', () => {
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
const warnings = out.warnings || [];
|
||||
const warningsPosix = warnings.map(w => toPosixPath(w));
|
||||
const warningsPosix = warnings.map(w => toPosixPath(w.message));
|
||||
|
||||
const phase64Warnings = warnings.filter(w => w.includes('Phase 64 exists on disk but not in ROADMAP.md'));
|
||||
const phase64Warnings = warningsPosix.filter(w => w.includes('Phase 64 exists on disk but not in ROADMAP.md'));
|
||||
assert.deepStrictEqual(phase64Warnings, [], 'Old archived milestone phase 64 should not be treated as active');
|
||||
// Phase 12 (#3310) migration note: `validate consistency`'s C002
|
||||
// (plan-numbering-gap)/C004 (missing-wave) rules now read
|
||||
// `PlanningSnapshot`'s `perPhasePlanNumbering`/`perPhaseWaveMissingPlans`
|
||||
// fields, which enumerate ONLY the flat `.planning/phases/` root — a
|
||||
// disclosed, accepted scope reduction from the pre-migration inline scan
|
||||
// (which also walked the active milestone-archive phase root via
|
||||
// `collectPhaseRoots`). See `src/health-diagnostic-rules/consistency.cts`'s
|
||||
// fidelity-note comment on `checkC002` and
|
||||
// `src/planning-snapshot.cts`'s `buildPerPhasePlanScanFields` (called with
|
||||
// `paths.phases` only). With `.planning/phases/` removed by this fixture
|
||||
// (milestone-archive-only layout), NEITHER C002 nor C004 can fire for the
|
||||
// active-archive phase `65-current` anymore — this locks that known,
|
||||
// disclosed gap rather than asserting behavior the migration no longer
|
||||
// provides.
|
||||
assert.ok(
|
||||
warningsPosix.some(w => /Gap in plan numbering in .*milestones\/v1\.7-phases\/65-current/.test(w)),
|
||||
`Expected plan numbering warning, got: ${warnings.join(', ')}`,
|
||||
!warningsPosix.some(w => /Gap in plan numbering in .*milestones\/v1\.7-phases\/65-current/.test(w)),
|
||||
`plan-numbering gap is out of scope for milestone-archive phases post-migration; got: ${JSON.stringify(warningsPosix)}`,
|
||||
);
|
||||
assert.ok(
|
||||
warningsPosix.some(w => /milestones\/v1\.7-phases\/65-current\/65-01-PLAN\.md: missing 'wave'/.test(w))
|
||||
|| warningsPosix.some(w => /milestones\/v1\.7-phases\/65-current\/65-03-PLAN\.md: missing 'wave'/.test(w)),
|
||||
`Expected frontmatter warning from active archive plans`,
|
||||
!warningsPosix.some(w => /milestones\/v1\.7-phases\/65-current\/65-0[13]-PLAN\.md: missing 'wave'/.test(w)),
|
||||
`missing-wave is out of scope for milestone-archive phases post-migration; got: ${JSON.stringify(warningsPosix)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,8 +102,12 @@ function makeDirUnreadableAsFile(fullPath) {
|
||||
// A matched plan/summary pair plus a passing `*-VERIFICATION.md` —
|
||||
// `isPhaseComplete` requires `verification.status === 'passed'` for
|
||||
// `complete: true`, which plan/summary pairing alone does not establish.
|
||||
// The plan carries `wave: 1` frontmatter so this fixture is also
|
||||
// wave-complete — callers that assert `perPhaseWaveMissingPlans` is empty
|
||||
// on a "healthy" phase (Phase 12, #3310) get a genuinely clean baseline
|
||||
// rather than a false positive from a plan that predates the `wave:` field.
|
||||
function makeCompletePhaseDir(cwd, relPhaseDir) {
|
||||
writeFile(cwd, `${relPhaseDir}/01-01-PLAN.md`, '# Plan\n');
|
||||
writeFile(cwd, `${relPhaseDir}/01-01-PLAN.md`, '---\nwave: 1\n---\n\n# Plan\n');
|
||||
writeFile(cwd, `${relPhaseDir}/01-01-SUMMARY.md`, '# Summary\n');
|
||||
writeFile(cwd, `${relPhaseDir}/01-VERIFICATION.md`, '---\nstatus: passed\n---\n');
|
||||
}
|
||||
@@ -1090,3 +1094,180 @@ describe('allPhaseDirNames field (Phase 11, #3309 — health-diagnostic-rules/ro
|
||||
assert.deepStrictEqual(snap.allPhaseDirNames, { value: [], scope: SCOPE.UNREADABLE });
|
||||
});
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// Phase 12 (#3310, ADR-3180 §8.4) additions — perPhasePlanNumbering /
|
||||
// perPhaseOrphanSummaries / perPhaseWaveMissingPlans
|
||||
//
|
||||
// Design: .gsd/phase/feat-3310-enhance-3180-the-sibling-validators-shar/40-design.md
|
||||
// ("New PlanningSnapshot fields")
|
||||
// Test matrix: .gsd/phase/feat-3310-enhance-3180-the-sibling-validators-shar/50-test-matrix.md
|
||||
// section 1, rows 1-8
|
||||
//
|
||||
// Each relocates (not reinvents) `verify.cts:1556-1603`'s per-phase-directory
|
||||
// plan scan — see `buildPerPhasePlanScanFields`'s doc comment in
|
||||
// src/planning-snapshot.cts for the exact source lines. Fixture helpers
|
||||
// mirror the existing writeRoadmap/writeState/writeFile idiom.
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function writePlan(cwd, relPhasePath, planName, frontmatterLines) {
|
||||
const lines = frontmatterLines ? ['---', ...frontmatterLines, '---', '', '# Plan', ''] : ['# Plan', ''];
|
||||
writeFile(cwd, `${relPhasePath}/${planName}`, lines.join('\n'));
|
||||
}
|
||||
|
||||
describe('perPhasePlanNumbering field (Phase 12, #3310, matrix rows 1-2)', () => {
|
||||
test('row 1: sequential plans (01, 02, 03) report the full sorted sequence, no gap', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-ppn1-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-01-PLAN.md');
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-02-PLAN.md');
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-03-PLAN.md');
|
||||
|
||||
const snap = buildPlanningSnapshot(cwd);
|
||||
const entry = snap.perPhasePlanNumbering.value.find((e) => e.phaseDir === '01-foo');
|
||||
assert.deepStrictEqual(entry, { phaseDir: '01-foo', planNums: [1, 2, 3] });
|
||||
assert.strictEqual(snap.perPhasePlanNumbering.scope, SCOPE.COMPLETE);
|
||||
});
|
||||
|
||||
test('row 2: a real gap (01, 03) is surfaced in the raw per-phase number list', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-ppn2-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-01-PLAN.md');
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-03-PLAN.md');
|
||||
|
||||
const snap = buildPlanningSnapshot(cwd);
|
||||
const entry = snap.perPhasePlanNumbering.value.find((e) => e.phaseDir === '01-foo');
|
||||
assert.deepStrictEqual(entry, { phaseDir: '01-foo', planNums: [1, 3] });
|
||||
});
|
||||
|
||||
test('boundary: zero phase directories yields an empty array, not a non-answer', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-ppn3-');
|
||||
t.after(() => cleanup(cwd));
|
||||
fs.mkdirSync(path.join(planningDirOf(cwd), 'phases'), { recursive: true });
|
||||
|
||||
const snap = buildPlanningSnapshot(cwd);
|
||||
assert.deepStrictEqual(snap.perPhasePlanNumbering, { value: [], scope: SCOPE.COMPLETE });
|
||||
});
|
||||
|
||||
test('boundary: a phase with zero plans reports an empty planNums list for that phase, not an absent entry', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-ppn4-');
|
||||
t.after(() => cleanup(cwd));
|
||||
fs.mkdirSync(path.join(planningDirOf(cwd), 'phases', '01-foo'), { recursive: true });
|
||||
|
||||
const snap = buildPlanningSnapshot(cwd);
|
||||
const entry = snap.perPhasePlanNumbering.value.find((e) => e.phaseDir === '01-foo');
|
||||
assert.deepStrictEqual(entry, { phaseDir: '01-foo', planNums: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('perPhaseOrphanSummaries field (Phase 12, #3310, matrix rows 3-5)', () => {
|
||||
test('row 3: a paired plan+summary produces no orphan entries', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-pos1-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-01-PLAN.md');
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-SUMMARY.md', '# Summary\n');
|
||||
|
||||
const snap = buildPlanningSnapshot(cwd);
|
||||
assert.deepStrictEqual(
|
||||
snap.perPhaseOrphanSummaries.value.filter((e) => e.phaseDir === '01-foo'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('row 4: a summary with no live plan at all is named as an orphan', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-pos2-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-SUMMARY.md', '# Summary\n');
|
||||
|
||||
const snap = buildPlanningSnapshot(cwd);
|
||||
const entries = snap.perPhaseOrphanSummaries.value.filter((e) => e.phaseDir === '01-foo');
|
||||
assert.deepStrictEqual(entries, [{ phaseDir: '01-foo', orphanSummary: '01-01-SUMMARY.md' }]);
|
||||
});
|
||||
|
||||
test('row 5: a summary paired only to a superseded plan is still orphan — superseded plans are not live', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-pos3-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-01-PLAN.md', ['status: superseded']);
|
||||
writeFile(cwd, '.planning/phases/01-foo/01-01-SUMMARY.md', '# Summary\n');
|
||||
|
||||
const snap = buildPlanningSnapshot(cwd);
|
||||
const entries = snap.perPhaseOrphanSummaries.value.filter((e) => e.phaseDir === '01-foo');
|
||||
assert.deepStrictEqual(entries, [{ phaseDir: '01-foo', orphanSummary: '01-01-SUMMARY.md' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('perPhaseWaveMissingPlans field (Phase 12, #3310, matrix rows 6-7)', () => {
|
||||
test('row 6: a plan with wave: in frontmatter is not flagged', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-pwm1-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-01-PLAN.md', ['wave: 1']);
|
||||
|
||||
const snap = buildPlanningSnapshot(cwd);
|
||||
assert.deepStrictEqual(
|
||||
snap.perPhaseWaveMissingPlans.value.filter((e) => e.phaseDir === '01-foo'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('row 7: a plan without wave: in frontmatter is flagged', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-pwm2-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-01-PLAN.md');
|
||||
|
||||
const snap = buildPlanningSnapshot(cwd);
|
||||
const entries = snap.perPhaseWaveMissingPlans.value.filter((e) => e.phaseDir === '01-foo');
|
||||
assert.deepStrictEqual(entries, [{ phaseDir: '01-foo', plan: '01-01-PLAN.md' }]);
|
||||
});
|
||||
|
||||
test('boundary: a phase where every plan lacks wave: reports every one, none silently dropped', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-pwm3-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-01-PLAN.md');
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-02-PLAN.md');
|
||||
|
||||
const snap = buildPlanningSnapshot(cwd);
|
||||
const entries = snap.perPhaseWaveMissingPlans.value.filter((e) => e.phaseDir === '01-foo');
|
||||
assert.deepStrictEqual(
|
||||
entries.map((e) => e.plan).sort(),
|
||||
['01-01-PLAN.md', '01-02-PLAN.md'],
|
||||
);
|
||||
});
|
||||
|
||||
test('boundary: a phase where every plan carries wave: reports none', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-pwm4-');
|
||||
t.after(() => cleanup(cwd));
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-01-PLAN.md', ['wave: 1']);
|
||||
writePlan(cwd, '.planning/phases/01-foo', '01-02-PLAN.md', ['wave: 2']);
|
||||
|
||||
const snap = buildPlanningSnapshot(cwd);
|
||||
const entries = snap.perPhaseWaveMissingPlans.value.filter((e) => e.phaseDir === '01-foo');
|
||||
assert.deepStrictEqual(entries, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phase-10/11 fields unchanged by the Phase-12 extension (matrix row 8)', () => {
|
||||
test('the new fields are additive alongside every prior field on the same fixture', (t) => {
|
||||
const cwd = createTempDir('gsd-3310-reg8-');
|
||||
t.after(() => cleanup(cwd));
|
||||
buildHealthyTwoPhaseFixture(cwd);
|
||||
|
||||
const snap = buildPlanningSnapshot(cwd);
|
||||
|
||||
assert.strictEqual(snap.milestone.scope, SCOPE.COMPLETE);
|
||||
assert.strictEqual(snap.phases.value.length, 2);
|
||||
assert.ok('config' in snap);
|
||||
assert.ok('agentInstall' in snap);
|
||||
assert.ok('worktreeHealth' in snap);
|
||||
// The extension is additive — the three new Phase 12 fields sit
|
||||
// alongside every prior field, not in place of them.
|
||||
assert.ok('perPhasePlanNumbering' in snap);
|
||||
assert.ok('perPhaseOrphanSummaries' in snap);
|
||||
assert.ok('perPhaseWaveMissingPlans' in snap);
|
||||
assert.strictEqual(snap.perPhasePlanNumbering.value.length, 2);
|
||||
for (const entry of snap.perPhasePlanNumbering.value) {
|
||||
assert.deepStrictEqual(entry.planNums, [1]);
|
||||
}
|
||||
assert.deepStrictEqual(snap.perPhaseOrphanSummaries.value, []);
|
||||
assert.deepStrictEqual(snap.perPhaseWaveMissingPlans.value, []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,19 @@ const stateDocument = require('../gsd-core/bin/lib/state-document.cjs');
|
||||
const frontmatterLib = require('../gsd-core/bin/lib/frontmatter.cjs');
|
||||
const { SCOPE } = require('../gsd-core/bin/lib/planning-scope.cjs');
|
||||
const workstreamInventory = require('../gsd-core/bin/lib/workstream-inventory.cjs');
|
||||
// Phase 12 (#3310, ADR-3180 §8.4 rule 3): `cmdStateValidate`'s `warnings` are
|
||||
// now `Diagnostic[]` (S0NN codes), not bare strings, and `drift` is gone.
|
||||
const { SEVERITY } = require('../gsd-core/bin/lib/health-diagnostic-types.cjs');
|
||||
|
||||
/** First `Diagnostic` in `output.warnings` carrying the given S0NN code, or `undefined`. */
|
||||
function findWarning(output, code) {
|
||||
return (output.warnings || []).find((w) => w.code === code);
|
||||
}
|
||||
|
||||
/** Phase 12 breaking-change proof (§8.4 rule 3, test matrix row 18): `drift` never appears. */
|
||||
function assertNoDriftKey(output) {
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(output, 'drift'), 'output must not contain a drift key');
|
||||
}
|
||||
|
||||
function writePassedVerification(tmpDir, phaseDirName, paddedPhase) {
|
||||
fs.writeFileSync(
|
||||
@@ -3296,11 +3309,10 @@ describe('state validate command', () => {
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.valid, false, 'passed verification must invalidate executing state');
|
||||
assert.ok(output.warnings.length > 0, 'passed verification drift must emit a warning');
|
||||
assert.deepStrictEqual(
|
||||
output.drift.verification_status,
|
||||
{ state_status: 'executing', verification: 'passed' },
|
||||
'template frontmatter phase must reach the existing disk-backed verification drift check',
|
||||
);
|
||||
const s006 = findWarning(output, 'S006');
|
||||
assert.ok(s006, 'S006 must fire for passed verification against executing status');
|
||||
assert.strictEqual(s006.severity, SEVERITY.WARNING);
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('template-equivalent phase identities remain clean without disk drift', () => {
|
||||
@@ -3320,7 +3332,7 @@ describe('state validate command', () => {
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.valid, true, 'equivalent phase identities without disk drift must stay valid');
|
||||
assert.deepStrictEqual(output.warnings, [], 'clean control must not emit warnings');
|
||||
assert.deepStrictEqual(output.drift, {}, 'clean control must not report drift');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('legacy Current Phase fallback reaches passed-verification drift on disk', () => {
|
||||
@@ -3341,10 +3353,9 @@ describe('state validate command', () => {
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.valid, false, 'legacy phase fallback must expose verification drift');
|
||||
assert.deepStrictEqual(
|
||||
output.drift.verification_status,
|
||||
{ state_status: 'Executing Phase 2', verification: 'passed' },
|
||||
);
|
||||
const s006 = findWarning(output, 'S006');
|
||||
assert.ok(s006, 'S006 must fire for legacy Current Phase fallback');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('Current Position Phase fallback reaches passed-verification drift on disk', () => {
|
||||
@@ -3367,10 +3378,9 @@ describe('state validate command', () => {
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.valid, false, 'canonical phase fallback must expose verification drift');
|
||||
assert.deepStrictEqual(
|
||||
output.drift.verification_status,
|
||||
{ state_status: 'Executing Phase 2', verification: 'passed' },
|
||||
);
|
||||
const s006 = findWarning(output, 'S006');
|
||||
assert.ok(s006, 'S006 must fire for Current Position Phase fallback');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('frontmatter phase wins conflicts and scans its selected directory', () => {
|
||||
@@ -3399,15 +3409,14 @@ describe('state validate command', () => {
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.valid, false, 'conflicting sources must invalidate the result');
|
||||
assert.strictEqual(output.drift.phase_reference.reason, 'conflict');
|
||||
assert.strictEqual(output.drift.phase_reference.selected, '2');
|
||||
assert.strictEqual(output.drift.phase_reference.sources.frontmatter, '2');
|
||||
assert.strictEqual(output.drift.phase_reference.sources.current_position_phase, '1');
|
||||
assert.deepStrictEqual(
|
||||
output.drift.verification_status,
|
||||
{ state_status: 'executing', verification: 'passed' },
|
||||
'disk evidence must come from the authoritative frontmatter phase',
|
||||
);
|
||||
const s003 = findWarning(output, 'S003');
|
||||
assert.ok(s003, 'S003 must fire for conflicting phase sources');
|
||||
// S003 names only the selected (authoritative) phase, not the individual
|
||||
// disagreeing sources; the old `drift.phase_reference.sources` structured
|
||||
// detail has no S0NN equivalent (disclosed breaking change, §8.4 rule 3).
|
||||
const s006 = findWarning(output, 'S006');
|
||||
assert.ok(s006, 'disk evidence must come from the authoritative frontmatter phase');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('missing phase sources fail closed with phase-reference drift', () => {
|
||||
@@ -3420,9 +3429,9 @@ describe('state validate command', () => {
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.valid, false, 'missing phase source must not validate cleanly');
|
||||
assert.strictEqual(output.drift.phase_reference.reason, 'unresolved');
|
||||
assert.strictEqual(output.drift.phase_reference.selected, null);
|
||||
assert.ok(output.warnings.some(warning => /phase/i.test(warning)), 'warning must identify phase resolution');
|
||||
const s002 = findWarning(output, 'S002');
|
||||
assert.ok(s002, 'S002 must fire when no phase source resolves');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('non-scalar frontmatter phase fails closed without a body fallback', () => {
|
||||
@@ -3444,8 +3453,9 @@ describe('state validate command', () => {
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.valid, false, 'non-scalar phase source must not validate cleanly');
|
||||
assert.strictEqual(output.drift.phase_reference.reason, 'unresolved');
|
||||
assert.strictEqual(output.drift.phase_reference.sources.frontmatter, null);
|
||||
const s002 = findWarning(output, 'S002');
|
||||
assert.ok(s002, 'S002 must fire when the frontmatter phase source is non-scalar');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('missing phases root fails closed with phase-directory drift', () => {
|
||||
@@ -3460,8 +3470,9 @@ describe('state validate command', () => {
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.valid, false, 'missing phases root must not validate cleanly');
|
||||
assert.strictEqual(output.drift.phase_directory.reason, 'missing_root');
|
||||
assert.ok(output.warnings.some(warning => /director/i.test(warning)), 'warning must identify the missing directory');
|
||||
const s004 = findWarning(output, 'S004');
|
||||
assert.ok(s004, 'S004 must fire when the phases directory is missing');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('missing canonical phase-directory match fails closed', () => {
|
||||
@@ -3474,8 +3485,9 @@ describe('state validate command', () => {
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.valid, false, 'missing phase-directory match must not validate cleanly');
|
||||
assert.strictEqual(output.drift.phase_directory.reason, 'not_found');
|
||||
assert.strictEqual(output.drift.phase_directory.selected, '2');
|
||||
const s004 = findWarning(output, 'S004');
|
||||
assert.ok(s004, 'S004 must fire when no phase directory matches');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('crafted path-like phase cannot scan verification evidence outside phases root', () => {
|
||||
@@ -3502,8 +3514,10 @@ describe('state validate command', () => {
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.valid, false, 'crafted phase reference must fail closed');
|
||||
assert.strictEqual(output.drift.phase_reference.reason, 'unresolved');
|
||||
assert.ok(!output.drift.verification_status, 'outside-root verification evidence must not be scanned');
|
||||
const s002 = findWarning(output, 'S002');
|
||||
assert.ok(s002, 'S002 must fire when the crafted phase value fails to resolve');
|
||||
assert.ok(!findWarning(output, 'S006'), 'outside-root verification evidence must not be scanned');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('STATE says executing + VERIFICATION.md shows passed emits warning', () => {
|
||||
@@ -3524,7 +3538,9 @@ describe('state validate command', () => {
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(output.warnings.length > 0, 'Should have warnings when executing but verification passed');
|
||||
assert.ok(output.warnings.some(w => /verif/i.test(w)), 'Warning should mention verification');
|
||||
const s006 = findWarning(output, 'S006');
|
||||
assert.ok(s006, 'S006 must fire when executing but verification passed');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('STATE plan count 3 but 12 SUMMARY.md on disk emits mismatch warning', () => {
|
||||
@@ -3546,7 +3562,15 @@ describe('state validate command', () => {
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(output.warnings.length > 0, 'Should have warnings for plan count mismatch');
|
||||
assert.ok(output.warnings.some(w => /plan.*count|count.*mismatch/i.test(w)), 'Warning should mention plan count mismatch');
|
||||
const s005 = findWarning(output, 'S005');
|
||||
assert.ok(s005, 'S005 must fire for a plan count mismatch');
|
||||
// The specific counts are the thing under test (STATE.md-authored count
|
||||
// vs disk-scanned count); `.includes()` on the interpolated values, not
|
||||
// full-string message equality (CONTRIBUTING.md's "Prohibited: Raw Text
|
||||
// Matching on Test Outputs").
|
||||
assert.ok(s005.message.includes('STATE.md says 3 plans'), 'message must report the STATE.md count');
|
||||
assert.ok(s005.message.includes('disk has 12'), 'message must report the disk-scanned count');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('perfect state returns valid: true, no warnings', () => {
|
||||
@@ -3591,6 +3615,218 @@ describe('state validate command', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Phase 12 (#3310, ADR-3180 §8.4 rule 3) — S0NN coded-diagnostic fixtures for
|
||||
// `cmdStateValidate`. `warnings` is now `Diagnostic[]` (not bare strings) and
|
||||
// `drift` is gone from every output shape. One test per code, title naming
|
||||
// the code (test-matrix §4 / §8.5's "known-bad fixture proves it can fire"
|
||||
// discipline extended to the S0NN codes, which are NOT `Rule`-table entries —
|
||||
// `cmdStateValidate` builds `Diagnostic[]` directly, per the design doc).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#3310 state validate — S0NN coded diagnostics', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createFixture();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('S001: STATE.md corrupt (NUL byte) fires with the verbatim textEncodingError message', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), Buffer.from('# Project State\0corrupt'));
|
||||
|
||||
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
assert.strictEqual(output.valid, false);
|
||||
assert.strictEqual(output.warnings.length, 1);
|
||||
const [s001] = output.warnings;
|
||||
assert.strictEqual(s001.code, 'S001');
|
||||
assert.strictEqual(s001.severity, SEVERITY.ERROR);
|
||||
assert.strictEqual(s001.remedy.action, 'advise');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('S002: no usable current-phase value fires the verbatim pre-migration message', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
['# Project State', '', '**Status:** Planning', ''].join('\n'),
|
||||
);
|
||||
|
||||
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
assert.strictEqual(output.valid, false);
|
||||
const s002 = findWarning(output, 'S002');
|
||||
assert.ok(s002);
|
||||
assert.strictEqual(s002.severity, SEVERITY.WARNING);
|
||||
assert.strictEqual(s002.remedy.action, 'advise');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('S003: conflicting phase-reference sources fires the verbatim template with interpolated phase', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
[
|
||||
'---',
|
||||
'current_phase: 2',
|
||||
'status: executing',
|
||||
'---',
|
||||
'',
|
||||
'# Project State',
|
||||
'',
|
||||
'**Current Phase:** 1',
|
||||
'**Status:** Executing Phase 2',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-core'), { recursive: true });
|
||||
|
||||
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
assert.strictEqual(output.valid, false);
|
||||
const s003 = findWarning(output, 'S003');
|
||||
assert.ok(s003);
|
||||
assert.strictEqual(s003.severity, SEVERITY.WARNING);
|
||||
assert.strictEqual(s003.remedy.action, 'advise');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('S004: phases directory question collapses three sub-conditions to one code with distinct messages', () => {
|
||||
// (a) phases/ root missing entirely.
|
||||
cleanup(tmpDir);
|
||||
tmpDir = createFixture({ planning: false, projectDoc: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
['---', 'current_phase: 2', 'status: planning', '---', '', '# Project State', ''].join('\n'),
|
||||
);
|
||||
const missingRootOutput = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
const missingRootS004 = findWarning(missingRootOutput, 'S004');
|
||||
assert.ok(missingRootS004, 'S004 must fire when phases/ root is missing');
|
||||
assert.strictEqual(missingRootS004.severity, SEVERITY.WARNING);
|
||||
assert.strictEqual(missingRootS004.remedy.action, 'advise');
|
||||
assertNoDriftKey(missingRootOutput);
|
||||
|
||||
// (b) phases/ exists, no matching subdir for the current phase.
|
||||
cleanup(tmpDir);
|
||||
tmpDir = createFixture();
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
['---', 'current_phase: 2', 'status: planning', '---', '', '# Project State', ''].join('\n'),
|
||||
);
|
||||
const notFoundOutput = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
const notFoundS004 = findWarning(notFoundOutput, 'S004');
|
||||
assert.ok(notFoundS004, 'S004 must fire when no phase directory matches');
|
||||
assertNoDriftKey(notFoundOutput);
|
||||
|
||||
// Same code, distinct message text per sub-condition — §8.2 rule 1. This
|
||||
// is a relative comparison (message A !== message B), not a literal
|
||||
// string match, so it stays within CONTRIBUTING.md's rule.
|
||||
assert.strictEqual(missingRootS004.code, notFoundS004.code);
|
||||
assert.notStrictEqual(missingRootS004.message, notFoundS004.message);
|
||||
});
|
||||
|
||||
test('S005: plan-count mismatch fires the verbatim template with both counts interpolated', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Status:** Executing Phase 1\n**Current Phase:** 1\n**Total Plans in Phase:** 3\n**Current Plan:** 1\n`,
|
||||
);
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n');
|
||||
|
||||
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
assert.strictEqual(output.valid, false);
|
||||
const s005 = findWarning(output, 'S005');
|
||||
assert.ok(s005);
|
||||
assert.strictEqual(s005.severity, SEVERITY.WARNING);
|
||||
assert.strictEqual(s005.remedy.action, 'advise');
|
||||
// The interpolated counts are what this test is proving; `.includes()`
|
||||
// on the specific values, not full-string message equality
|
||||
// (CONTRIBUTING.md's "Prohibited: Raw Text Matching on Test Outputs").
|
||||
assert.ok(s005.message.includes('STATE.md says 3 plans'), 'message must report the STATE.md count');
|
||||
assert.ok(s005.message.includes('disk has 2'), 'message must report the disk-scanned count');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('S006: verification passed but status still executing fires the verbatim template', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Status:** Executing Phase 1\n**Current Phase:** 1\n**Total Plans in Phase:** 1\n**Current Plan:** 1\n`,
|
||||
);
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '01-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
|
||||
|
||||
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
assert.strictEqual(output.valid, false);
|
||||
const s006 = findWarning(output, 'S006');
|
||||
assert.ok(s006);
|
||||
assert.strictEqual(s006.severity, SEVERITY.WARNING);
|
||||
assert.strictEqual(s006.remedy.action, 'advise');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('S007: all plans have summaries but status still executing fires the verbatim template (never a drift entry pre-migration either)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Status:** Executing Phase 1\n**Current Phase:** 1\n**Total Plans in Phase:** 2\n**Current Plan:** 1\n`,
|
||||
);
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
|
||||
fs.writeFileSync(path.join(phaseDir, '01-02-SUMMARY.md'), '# Summary\n');
|
||||
// No VERIFICATION.md — otherwise S006 would cover it instead (see the
|
||||
// production code's own "Only warn if no verification exists" guard).
|
||||
|
||||
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
assert.strictEqual(output.valid, false);
|
||||
const s007 = findWarning(output, 'S007');
|
||||
assert.ok(s007);
|
||||
assert.strictEqual(s007.severity, SEVERITY.WARNING);
|
||||
assert.strictEqual(s007.remedy.action, 'advise');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('output never carries a drift key, across clean/warning/error shapes (breaking-change proof, test matrix row 18)', () => {
|
||||
// Clean shape.
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Status:** Executing Phase 1\n**Current Phase:** 1\n**Total Plans in Phase:** 1\n`,
|
||||
);
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
||||
const clean = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
assert.ok(!('drift' in clean));
|
||||
assert.ok(!Object.keys(clean).includes('drift'));
|
||||
|
||||
// Warning shape (S005).
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Status:** Executing Phase 1\n**Current Phase:** 1\n**Total Plans in Phase:** 5\n`,
|
||||
);
|
||||
const warned = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
assert.ok(!('drift' in warned));
|
||||
assert.ok(!Object.keys(warned).includes('drift'));
|
||||
|
||||
// Error shape (S001, corrupt STATE.md).
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), Buffer.from('# Project State\0bad'));
|
||||
const corrupt = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
assert.ok(!('drift' in corrupt));
|
||||
assert.ok(!Object.keys(corrupt).includes('drift'));
|
||||
|
||||
// `{error: 'STATE.md not found'}` pre-check shape.
|
||||
cleanup(tmpDir);
|
||||
tmpDir = createFixture();
|
||||
const missing = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
assert.ok(!('drift' in missing));
|
||||
assert.ok(!Object.keys(missing).includes('drift'));
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #3187 (ADR-3180 §7.7) — matrix section B: `state validate`'s scope field,
|
||||
// including the #3162 headline regression and #1255 frontmatter shadowing.
|
||||
@@ -3612,7 +3848,8 @@ describe('#3187 state validate — scope field (matrix section B)', () => {
|
||||
// at all. Pre-#3187, cmdStateValidate read `Current Phase` off the body
|
||||
// only, resolved null, and the ENTIRE drift block was skipped —
|
||||
// "could not look" was output-identical to "looked, all clean"
|
||||
// ({valid:true, warnings:[], drift:{}}).
|
||||
// (originally {valid:true, warnings:[], drift:{}}; post-#3310 the
|
||||
// equivalent clean shape is {valid:true, warnings:[]}, `drift` removed).
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
[
|
||||
@@ -3639,9 +3876,12 @@ describe('#3187 state validate — scope field (matrix section B)', () => {
|
||||
// The RIGHT reason to fail before the fix: valid was true and no
|
||||
// plan-count warning was ever generated, not a crash.
|
||||
assert.strictEqual(output.valid, false, 'STATE.md says 5 plans, disk has 2 — drift must be reported');
|
||||
assert.ok(output.warnings.some((w) => /plan.*count|count.*mismatch/i.test(w)));
|
||||
assert.deepEqual(output.drift.plan_count, { state: 5, disk: 2 });
|
||||
const s005 = findWarning(output, 'S005');
|
||||
assert.ok(s005, 'S005 must fire for the frontmatter-only phase drift');
|
||||
assert.ok(s005.message.includes('STATE.md says 5 plans'), 'message must report the STATE.md count');
|
||||
assert.ok(s005.message.includes('disk has 2'), 'message must report the disk-scanned count');
|
||||
assert.strictEqual(output.scope, SCOPE.COMPLETE);
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('B2: validate passes cleanly when it actually looked (frontmatter-only phase, counts match)', () => {
|
||||
@@ -3670,6 +3910,7 @@ describe('#3187 state validate — scope field (matrix section B)', () => {
|
||||
assert.strictEqual(output.valid, true);
|
||||
assert.strictEqual(output.warnings.length, 0);
|
||||
assert.strictEqual(output.scope, SCOPE.COMPLETE);
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('B3: validate still detects body-resolved drift (regression guard, unchanged today)', () => {
|
||||
@@ -3691,8 +3932,12 @@ describe('#3187 state validate — scope field (matrix section B)', () => {
|
||||
|
||||
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
assert.strictEqual(output.valid, false);
|
||||
assert.ok(output.warnings.some((w) => /plan.*count|count.*mismatch/i.test(w)));
|
||||
const s005 = findWarning(output, 'S005');
|
||||
assert.ok(s005, 'S005 must fire for the body-resolved plan-count drift');
|
||||
assert.ok(s005.message.includes('STATE.md says 3 plans'), 'message must report the STATE.md count');
|
||||
assert.ok(s005.message.includes('disk has 1'), 'message must report the disk-scanned count');
|
||||
assert.strictEqual(output.scope, SCOPE.COMPLETE);
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('B4: unresolvable phase is not reported as clean (distinguishable from B2)', () => {
|
||||
@@ -3704,7 +3949,9 @@ describe('#3187 state validate — scope field (matrix section B)', () => {
|
||||
|
||||
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
assert.strictEqual(output.valid, false);
|
||||
assert.strictEqual(output.drift.phase_reference.reason, 'unresolved');
|
||||
const s002 = findWarning(output, 'S002');
|
||||
assert.ok(s002, 'S002 must fire when the phase is unresolvable');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('B5: missing phase dir differs from could-not-look (distinguishable from B4)', () => {
|
||||
@@ -3723,7 +3970,9 @@ describe('#3187 state validate — scope field (matrix section B)', () => {
|
||||
|
||||
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
assert.strictEqual(output.valid, false);
|
||||
assert.strictEqual(output.drift.phase_directory.reason, 'not_found');
|
||||
const s004 = findWarning(output, 'S004');
|
||||
assert.ok(s004, 'S004 must fire when no phase directory matches');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('B6: unreadable phases dir is surfaced, not swallowed', (t) => {
|
||||
@@ -3757,7 +4006,9 @@ describe('#3187 state validate — scope field (matrix section B)', () => {
|
||||
const raw = captureStdout(() => stateLib.cmdStateValidate(tmpDir, false));
|
||||
const output = JSON.parse(raw);
|
||||
assert.strictEqual(output.valid, false, 'an unreadable directory must not validate cleanly');
|
||||
assert.strictEqual(output.drift.phase_directory.reason, 'unreadable');
|
||||
const s004 = findWarning(output, 'S004');
|
||||
assert.ok(s004, 'S004 must fire when the phases directory itself is unreadable');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('B6b: unreadable selected phase directory fails closed', (t) => {
|
||||
@@ -3777,7 +4028,9 @@ describe('#3187 state validate — scope field (matrix section B)', () => {
|
||||
|
||||
const output = JSON.parse(captureStdout(() => stateLib.cmdStateValidate(tmpDir, false)));
|
||||
assert.strictEqual(output.valid, false, 'an unreadable selected phase must not validate cleanly');
|
||||
assert.strictEqual(output.drift.phase_directory.reason, 'unreadable');
|
||||
const s004 = findWarning(output, 'S004');
|
||||
assert.ok(s004, 'S004 must fire when the selected phase directory itself is unreadable');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('B7: one unreadable verification file does not abort the scan', (t) => {
|
||||
@@ -3814,13 +4067,16 @@ describe('#3187 state validate — scope field (matrix section B)', () => {
|
||||
const raw = captureStdout(() => stateLib.cmdStateValidate(tmpDir, false));
|
||||
const output = JSON.parse(raw);
|
||||
// Per-file swallow (#2245 audit) is unchanged: the other verification
|
||||
// file is still consulted, so its drift still fires, and the whole
|
||||
// scan is NOT degraded to UNREADABLE just because one file 404s.
|
||||
// Asserted on the structured `drift` field (CONTRIBUTING.md's "Prohibited:
|
||||
// Raw Text Matching on Test Outputs"), not a `warnings` prose regex —
|
||||
// `drift.verification_status` is a typed record, not free-form text.
|
||||
assert.deepStrictEqual(output.drift.verification_status, { state_status: 'Executing Phase 1', verification: 'passed' });
|
||||
// file is still consulted, so its S006 diagnostic still fires, and the
|
||||
// whole scan is NOT degraded to UNREADABLE just because one file 404s.
|
||||
// Asserted on the S006 `Diagnostic`'s `code` alone, not its `message`
|
||||
// prose (CONTRIBUTING.md's "Prohibited: Raw Text Matching on Test
|
||||
// Outputs") — the vf filename isn't pinned since readdirSync order
|
||||
// across the two verification files isn't guaranteed.
|
||||
const s006 = findWarning(output, 'S006');
|
||||
assert.ok(s006, 'S006 must fire for the readable verification file despite the unreadable sibling');
|
||||
assert.strictEqual(output.scope, SCOPE.COMPLETE);
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('B8: absent STATE.md unchanged', () => {
|
||||
@@ -3834,7 +4090,11 @@ describe('#3187 state validate — scope field (matrix section B)', () => {
|
||||
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
assert.strictEqual(output.valid, false);
|
||||
assert.strictEqual(output.warnings.length, 1);
|
||||
const [s001] = output.warnings;
|
||||
assert.strictEqual(s001.code, 'S001');
|
||||
assert.strictEqual(s001.severity, SEVERITY.ERROR, 'S001 is error-class severity, not a mere warning');
|
||||
assert.strictEqual(output.scope, undefined, 'the #2701 early return is explicitly unchanged — no scope key');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('B10: json and default output agree (validate has no distinct raw-text mode; --raw is a no-op for it)', () => {
|
||||
@@ -3904,10 +4164,15 @@ describe('#3187 state validate — scope field (matrix section B)', () => {
|
||||
}
|
||||
const output = JSON.parse(runGsdTools('state validate', dir).output);
|
||||
assert.strictEqual(output.valid, false, `n=${n} vs disk n+1 must be flagged`);
|
||||
// Asserted on the structured `drift.plan_count` record rather than a
|
||||
// `warnings` prose regex (CONTRIBUTING.md's "Prohibited: Raw Text
|
||||
// Matching on Test Outputs") — `drift` is typed, `warnings` is free-form.
|
||||
assert.deepStrictEqual(output.drift.plan_count, { state: n, disk: n + 1 });
|
||||
// The boundary values (n, n+1) are what this loop proves flow through
|
||||
// correctly; `.includes()` on the interpolated counts, not full-string
|
||||
// message equality (CONTRIBUTING.md's "Prohibited: Raw Text Matching
|
||||
// on Test Outputs") — `code` establishes S005 fired.
|
||||
const s005 = findWarning(output, 'S005');
|
||||
assert.ok(s005, `n=${n}: S005 must fire for the plan-count mismatch`);
|
||||
assert.ok(s005.message.includes(`STATE.md says ${n} plans`), `n=${n}: message must report the STATE.md count`);
|
||||
assert.ok(s005.message.includes(`disk has ${n + 1}`), `n=${n}: message must report the disk-scanned count`);
|
||||
assertNoDriftKey(output);
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
@@ -4039,9 +4304,10 @@ describe('#3187 chain-owner identity — every consumer agrees with stateFieldVa
|
||||
|
||||
const output = JSON.parse(runGsdTools('state validate', tmpDir).output);
|
||||
assert.strictEqual(output.valid, false, 'conflicting phase sources must not validate cleanly');
|
||||
assert.strictEqual(output.drift.phase_reference.reason, 'conflict');
|
||||
assert.strictEqual(output.drift.phase_reference.selected, '2');
|
||||
assert.ok(!output.drift.plan_count, 'validate must scan phase 2, not the shadowed phase 1');
|
||||
const s003 = findWarning(output, 'S003');
|
||||
assert.ok(s003, 'S003 must fire for conflicting phase sources');
|
||||
assert.ok(!findWarning(output, 'S005'), 'validate must scan phase 2, not the shadowed phase 1 (no plan-count drift)');
|
||||
assertNoDriftKey(output);
|
||||
});
|
||||
|
||||
test('C3: prune resolves the same phase as the owner', () => {
|
||||
@@ -10637,7 +10903,8 @@ describe('cmdStateValidate nested plans/ layout (#3257)', () => {
|
||||
const parsed = JSON.parse(result.output);
|
||||
assert.ok(parsed.valid, `state validate should be valid; warnings: ${JSON.stringify(parsed.warnings)}`);
|
||||
assert.deepStrictEqual(parsed.warnings, [], 'no drift warnings for nested-layout phase with correct plan count');
|
||||
assert.ok(!parsed.drift.plan_count, 'no plan_count drift when nested scan matches STATE.md');
|
||||
assert.ok(!findWarning(parsed, 'S005'), 'no S005 (plan-count drift) when nested scan matches STATE.md');
|
||||
assertNoDriftKey(parsed);
|
||||
});
|
||||
|
||||
test('emits drift warning when STATE.md plan count does not match nested disk count', () => {
|
||||
@@ -10667,9 +10934,15 @@ describe('cmdStateValidate nested plans/ layout (#3257)', () => {
|
||||
const parsed = JSON.parse(result.output);
|
||||
assert.ok(!parsed.valid, 'state validate should report invalid when plan counts differ');
|
||||
assert.ok(parsed.warnings.length > 0, 'at least one drift warning expected');
|
||||
assert.ok(parsed.drift.plan_count, 'plan_count drift object must be present');
|
||||
assert.strictEqual(parsed.drift.plan_count.disk, 2, 'disk count must reflect nested scan (2 nested plans)');
|
||||
assert.strictEqual(parsed.drift.plan_count.state, 5, 'state count from STATE.md must be 5');
|
||||
const s005 = findWarning(parsed, 'S005');
|
||||
assert.ok(s005, 'S005 diagnostic must be present');
|
||||
// `.includes()` on the interpolated counts (not full-string message
|
||||
// equality — CONTRIBUTING.md's "Prohibited: Raw Text Matching on Test
|
||||
// Outputs"): the disk count must reflect the nested scan (2 nested
|
||||
// plans), not the pre-fix flat-scan under-count.
|
||||
assert.ok(s005.message.includes('STATE.md says 5 plans'), 'message must report the STATE.md count');
|
||||
assert.ok(s005.message.includes('disk has 2'), 'disk count must reflect nested scan (2 nested plans)');
|
||||
assertNoDriftKey(parsed);
|
||||
});
|
||||
|
||||
test('PLAN-OUTLINE.md excluded from nested count in validate', () => {
|
||||
@@ -10697,7 +10970,8 @@ describe('cmdStateValidate nested plans/ layout (#3257)', () => {
|
||||
|
||||
const parsed = JSON.parse(result.output);
|
||||
assert.ok(parsed.valid, `should be valid (outline excluded); warnings: ${JSON.stringify(parsed.warnings)}`);
|
||||
assert.ok(!parsed.drift.plan_count, 'no plan_count drift when outline excluded from nested count');
|
||||
assert.ok(!findWarning(parsed, 'S005'), 'no S005 (plan-count drift) when outline excluded from nested count');
|
||||
assertNoDriftKey(parsed);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10994,12 +11268,21 @@ describe('flat "## Phase Details" milestone leak (#501)', () => {
|
||||
const result = runGsdTools(['validate', 'consistency'], tmpDir);
|
||||
const payload = JSON.parse(result.output);
|
||||
const warnings = payload.warnings || [];
|
||||
const orphanWarnings = warnings.filter((w) => /exists on disk but not in ROADMAP/i.test(w));
|
||||
const orphanWarnings = warnings.filter((w) => /exists on disk but not in ROADMAP/i.test(w.message));
|
||||
assert.deepEqual(
|
||||
orphanWarnings,
|
||||
[],
|
||||
`shipped phase dirs (1-3) are in the full ROADMAP and must not be flagged as orphans. Got: ${JSON.stringify(orphanWarnings)}`
|
||||
);
|
||||
// W007 is REUSED verbatim from `validate.health`'s rule table (design doc,
|
||||
// "Which rules run where") — `validate consistency`'s own findings carry
|
||||
// the SAME code space for this subject, not a re-derived private label.
|
||||
const w007Orphans = warnings.filter((w) => w.code === 'W007');
|
||||
assert.deepEqual(
|
||||
w007Orphans,
|
||||
[],
|
||||
`shipped phase dirs (1-3) must not produce W007 via validate consistency either. Got: ${JSON.stringify(w007Orphans)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('validate health (W007) does not flag shipped phase dirs as not-in-ROADMAP', () => {
|
||||
|
||||
@@ -97,9 +97,15 @@ describe('validate consistency command', () => {
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(output.warning_count > 0, 'should have warnings');
|
||||
assert.ok(
|
||||
output.warnings.some(w => w.includes('disk but not in ROADMAP')),
|
||||
output.warnings.some(w => w.message.includes('disk but not in ROADMAP')),
|
||||
'should warn about orphan directory'
|
||||
);
|
||||
// W007 is REUSED verbatim from `validate.health`'s rule table (design doc,
|
||||
// "Which rules run where") — the code is proof of reuse, not a mistake.
|
||||
assert.ok(
|
||||
output.warnings.some(w => w.code === 'W007'),
|
||||
`expected code W007 for the orphan-on-disk warning; got: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('#3225: sentinel phase dirs (999/0) do not warn; real orphans still do', () => {
|
||||
@@ -119,7 +125,7 @@ describe('validate consistency command', () => {
|
||||
const output = JSON.parse(result.output);
|
||||
|
||||
const sentinelWarnings = output.warnings.filter(
|
||||
w => w.includes('disk but not in ROADMAP') && /\b(0|999)\b/.test(w)
|
||||
w => w.message.includes('disk but not in ROADMAP') && /\b(0|999)\b/.test(w.message)
|
||||
);
|
||||
assert.strictEqual(
|
||||
sentinelWarnings.length, 0,
|
||||
@@ -127,14 +133,14 @@ describe('validate consistency command', () => {
|
||||
);
|
||||
// Negative space: the real orphan must still warn.
|
||||
assert.ok(
|
||||
output.warnings.some(w => w.includes('disk but not in ROADMAP') && /02\b/.test(w)),
|
||||
output.warnings.some(w => w.message.includes('disk but not in ROADMAP') && /02\b/.test(w.message)),
|
||||
`expected a warning for the real orphan 02; got: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
// #3225 (review finding): a sentinel dir must NOT produce a spurious
|
||||
// "Gap in phase numbering: N → 999" either (the gap check builds its integer
|
||||
// sequence from diskPhases and would otherwise include 999).
|
||||
const sentinelGaps = output.warnings.filter(
|
||||
w => w.includes('Gap in phase numbering') && /999\b/.test(w)
|
||||
w => w.message.includes('Gap in phase numbering') && /999\b/.test(w.message)
|
||||
);
|
||||
assert.strictEqual(
|
||||
sentinelGaps.length, 0,
|
||||
@@ -155,9 +161,15 @@ describe('validate consistency command', () => {
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
output.warnings.some(w => w.includes('Gap in phase numbering')),
|
||||
output.warnings.some(w => w.message.includes('Gap in phase numbering')),
|
||||
'should warn about gap'
|
||||
);
|
||||
// C001 — new code namespace (design doc, "Code namespace"): not a W0NN,
|
||||
// since this subject has no `validate.health` equivalent.
|
||||
assert.ok(
|
||||
output.warnings.some(w => w.code === 'C001'),
|
||||
`expected code C001 for the phase-numbering gap; got: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3298,7 +3310,18 @@ describe('#2701: state validate rejects NUL-corrupted STATE.md', () => {
|
||||
|
||||
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)}`);
|
||||
// `state validate` (Phase 12 migration) emits coded warning objects
|
||||
// ({code, severity, message, remedy}), not bare strings — assert on the
|
||||
// code as the primary check, with a message substring as a secondary,
|
||||
// human-readable confirmation.
|
||||
assert.ok(
|
||||
out.warnings.some((w) => w.code === 'S001'),
|
||||
`warning must carry code S001: ${JSON.stringify(out.warnings)}`,
|
||||
);
|
||||
assert.ok(
|
||||
out.warnings.some((w) => /NUL/i.test(w.message)),
|
||||
`warning must name NUL: ${JSON.stringify(out.warnings)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user