fix(26): retire validate.ts/verify.cjs cooperating-sibling for W005/W006-archived/I001 via generator (stacks on #156) (#158)

* test(26): reproduce W005/W006-archived/I001 false positives in CJS validate path

Issue #26 (open-gsd/get-shit-done-redux): three validation drift items from
PR #3479 were hand-ported to verify.cjs via PR #3806 but never routed through
the generator pattern. This means they can drift again whenever validate.ts
changes.

RED tests assert that validate.generated.cjs exports four new items:
  - phaseDirNameRe (W005 regex — /^\d{2,}(?:\.\d+)*-[\w-]+$/)
  - MILESTONE_ARCHIVE_DIR_RE (W006-archived — /^v\d+.*-phases$/i)
  - PHASE_TOKEN_FROM_DIR_RE (W006-archived — phase dir token extractor)
  - canonicalPlanStem (I001 — plan/summary stem canonicalization)

Three of four new export tests are RED (exports missing from generated artifact).
Behavioral tests (W005 no-false-positive, W006-archived no-false-positive,
I001 no-false-positive) are GREEN because #3806's hand-ported fixes are present.

References: issue #26, ADR-3524, PR #154 (issue #4), PR #156 (issue #6),
PR #3479 (original fix), PR #3806 (hand-port).

* chore(26): extend gen-validate.mjs to export W005/W006-archived/I001 helpers

Issue #26 (open-gsd/get-shit-done-redux): extend gen-validate.mjs (introduced
in PR #156 / issue #6) to also extract the three drift items that PR #3806
hand-ported to verify.cjs but were never routed through the generator.

New helpers added to gen-validate.mjs:

  phaseDirNameRe (PHASE_DIR_NAME_RE) — W005 phase directory naming regex.
    /^\d{2,}(?:\.\d+)*-[\w-]+$/ accepts multi-digit prefixes (999.1-foo valid).
    Requires adding PHASE_DIR_NAME_RE as a named constant to validate.ts so
    it appears as an extractable identifier in the compiled output.

  PHASE_TOKEN_FROM_DIR_RE — W006-archived regex; extracts phase token from
    directory names like "64-auth-service" → "64". Used by
    forEachArchivedPhaseToken() and collectDiskPhases() in verify.cjs.

  MILESTONE_ARCHIVE_DIR_RE — W006-archived regex; matches milestone archive
    directory names like "v1.0-phases". Used by listMilestoneArchiveDirs().

  canonicalPlanStem() — I001 PLAN/SUMMARY stem canonicalization.
    '68-01-scaffolding' → '68-01'. Top-level named function in compiled output.

Extraction approach: PHASE_TOKEN_FROM_DIR_RE and MILESTONE_ARCHIVE_DIR_RE are
module-level const assignments, extracted via extractConstRegExp() (handles both
`const` and `export const` prefixes). PHASE_DIR_NAME_RE is the new named export
added to validate.ts in this commit. canonicalPlanStem is a top-level function,
extracted via extractTopLevelFunction() (brace-balanced).

validate.ts change: inline regex in Check 6 extracted to named constant
PHASE_DIR_NAME_RE (exported) and Check 6 updated to reference it.

References: issue #26, ADR-3524, PR #154 (issue #4), PR #156 (issue #6).

* chore(26): regenerate validate.generated.cjs with W005/W006-archived/I001 helpers

Re-run of sdk/scripts/gen-validate.mjs after extending it in the preceding
commit. The artifact now exports seven items (was three):

  New (issue #26):
    phaseDirNameRe       — /^\d{2,}(?:\.\d+)*-[\w-]+$/ (W005 check)
    PHASE_TOKEN_FROM_DIR_RE — phase token extractor regex (W006-archived)
    MILESTONE_ARCHIVE_DIR_RE — archive dir name matcher (W006-archived)
    canonicalPlanStem()  — PLAN/SUMMARY stem canonicalization (I001)

  Existing (issue #6):
    phaseVariants()
    buildRoadmapPhaseVariants()
    buildNotStartedPhaseVariants()

Freshness check: node sdk/scripts/check-validate-fresh.mjs → "fresh".

References: issue #26, ADR-3524, PR #154 (issue #4), PR #156 (issue #6).

* fix(26): migrate verify.cjs W005/W006-archived/I001 call sites to generated helpers

Issue #26 (open-gsd/get-shit-done-redux): three hand-maintained items in
verify.cjs now consumed from validate.generated.cjs (ADR-3524 §4 adapter pattern).

Changes:
  - Top-of-file require(): extend to also destructure phaseDirNameRe,
    PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE, canonicalPlanStem
    from validate.generated.cjs (issue #26 exports).

  - Remove inline PHASE_TOKEN_FROM_DIR_RE and MILESTONE_ARCHIVE_DIR_RE constants
    (lines ~403-404). Now sourced from generated artifact. listMilestoneArchiveDirs
    and forEachArchivedPhaseToken pick them up via the require() at top of file.

  - Check 6 (W005): replace inline regex /^\d{2,}(?:\.\d+)*-[\w-]+$/ with
    phaseDirNameRe from validate.generated.cjs. No behavior change.

  - Remove inline canonicalPlanStem() function (~8 lines). Now sourced from
    validate.generated.cjs. Check 7 (I001) continues to call it as before.

Public API of verify.cjs unchanged. Same migration shape as PR #156's Check 8.

References: issue #26, ADR-3524, PR #154 (issue #4), PR #156 (issue #6),
PR #3479 (original fix), PR #3806 (hand-port that #26 supersedes).

* docs(26): extend ADR-3524 2026-05-23 amendment with #26 scope

Extends the existing 2026-05-23 amendment (not a new dated section) to document
the W005/W006-archived/I001 generator migration introduced by issue #26.

Key points documented:
  - Four new exports added to validate.generated.cjs (phaseDirNameRe,
    PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE, canonicalPlanStem)
  - W006-archived coverage note: both fixes were already in verify.cjs from
    #3806; the gap was generator coverage of the regex constants
  - Extraction methods: extractConstRegExp() and extractTopLevelFunction()
  - Parity tests: tests/26-w005-w006-i001-cjs-drift-regression.test.cjs (7 tests)
  - Cross-reference: issue #26 completes the validate.ts ↔ verify.cjs migration
    scope started by issue #6

References: issue #26, ADR-3524, PR #154 (issue #4), PR #156 (issue #6),
PR #3479 (original fix), PR #3806 (hand-port).

* chore(26): add changeset fragment for W005/W006-archived/I001 generator migration

Touches get-shit-done/bin/lib/validate.generated.cjs and verify.cjs which
match USER_FACING_PREFIXES. Required by the fix-template checklist + the
changeset-lint CI workflow.

References: issue #26, ADR-3524, PR #154 (issue #4), PR #156 (issue #6).
This commit is contained in:
Tom Boucher
2026-05-23 15:57:33 -04:00
committed by GitHub
parent 5414da2ce5
commit 7f02f9090b
7 changed files with 495 additions and 30 deletions

View File

@@ -0,0 +1,250 @@
'use strict';
/**
* Regression tests for issue #26 (open-gsd/get-shit-done-redux).
* Three generator-pattern drift items: W005 phaseDirNameRe,
* W006-archived regex constants (PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE),
* I001 canonicalPlanStem.
*
* After the generator migration, all three helpers are sourced from
* validate.generated.cjs. If they diverge from validate.ts, these
* tests go RED.
*
* References:
* - Issue #26 (open-gsd/get-shit-done-redux) — three drift items + reproducer
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
* - PR #154 (issue #4) — generator pattern precedent
* - PR #156 (issue #6) — validate.ts generator scaffolding (#26 extends this)
* - Original PR #3479 — first validate.ts false-positive fix (never reached CJS)
*/
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { runGsdTools } = require('./helpers.cjs');
function mkplanning(base) {
const planningDir = path.join(base, '.planning');
const phasesDir = path.join(planningDir, 'phases');
fs.mkdirSync(phasesDir, { recursive: true });
return { planningDir, phasesDir };
}
function writeProjectMd(planningDir) {
fs.writeFileSync(
path.join(planningDir, 'PROJECT.md'),
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
);
}
function writeStateMd(planningDir, phase) {
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
`# State\n\n**Current Phase:** ${phase}\n**Status:** In progress\n`,
);
}
function writeConfigJson(planningDir) {
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ model_profile: 'balanced' }),
);
}
// ── Drift Item W005: phaseDirNameRe ──────────────────────────────────────────
//
// Issue #26 reproducer (verbatim):
// mkdir -p .planning/phases/999.1-foo
// echo "# Roadmap" > .planning/ROADMAP.md
// node .claude/get-shit-done/bin/gsd-tools.cjs validate health
// # Bug: emits W005 about 999.1-foo not following NN-name format
//
// verify.cjs must consume phaseDirNameRe from validate.generated.cjs so
// the regex /^\d{2,}(?:\.\d+)*-[\w-]+$/ is the single source of truth.
describe('Drift item W005 — phaseDirNameRe: 999.X-name dirs must not trigger W005', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-26-d1-'));
const { planningDir, phasesDir } = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeStateMd(planningDir, '999.1');
writeConfigJson(planningDir);
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
'# Roadmap\n\n- [x] **Phase 999.1:** Long Phase\n\n### Phase 999.1: Long Phase\n',
);
// Exact reproducer from issue #26
fs.mkdirSync(path.join(phasesDir, '999.1-foo'), { recursive: true });
});
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
test('no W005 for 999.1-foo (multi-digit sub-phase prefix)', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w005 = (data.warnings ?? []).filter((w) => w.code === 'W005');
assert.strictEqual(w005.length, 0,
`Expected zero W005 for 999.1-foo, got: ${JSON.stringify(w005)}`);
});
test('phaseDirNameRe is exported from validate.generated.cjs', () => {
const gen = require('../get-shit-done/bin/lib/validate.generated.cjs');
assert.ok(gen.phaseDirNameRe instanceof RegExp,
'validate.generated.cjs must export phaseDirNameRe as a RegExp');
const re = gen.phaseDirNameRe;
assert.ok(re.test('01-setup'), 'should accept 01-setup');
assert.ok(re.test('999-longphase'), 'should accept 999-longphase (3-digit prefix)');
assert.ok(re.test('999.1-foo'), 'should accept 999.1-foo (sub-phase)');
assert.ok(!re.test('1-shortname'), 'should reject single-digit prefix');
});
});
// ── Drift Item W006-archived: PHASE_TOKEN_FROM_DIR_RE / MILESTONE_ARCHIVE_DIR_RE ─
//
// forEachArchivedPhaseToken() in verify.cjs uses two inline regex constants.
// After migration both are sourced from validate.generated.cjs:
// PHASE_TOKEN_FROM_DIR_RE — extracts token from dir name like "64-auth-service"
// MILESTONE_ARCHIVE_DIR_RE — matches archive dirs like "v1.0-phases"
//
// Test: phase 64 was archived to milestones/v1.0-phases/64-auth-service/.
// Without correct archive detection, W006 fires for "Phase 64 in ROADMAP.md
// but no directory on disk".
describe('Drift item W006-archived — MILESTONE_ARCHIVE_DIR_RE and PHASE_TOKEN_FROM_DIR_RE', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-26-d2-'));
const { planningDir, phasesDir } = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeStateMd(planningDir, '65');
writeConfigJson(planningDir);
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [x] **Phase 65:** Current Work',
'',
'### Phase 65: Current Work',
'',
'<details>',
'<summary>Milestone v1.0 — Shipped</summary>',
'',
'### Phase 64: Auth Service',
'',
'</details>',
'',
].join('\n'),
);
// Active phasesDir: only phase 65
fs.mkdirSync(path.join(phasesDir, '65-current-work'), { recursive: true });
// Archive: milestones/v1.0-phases/64-auth-service
// MILESTONE_ARCHIVE_DIR_RE must match "v1.0-phases"
// PHASE_TOKEN_FROM_DIR_RE must extract "64" from "64-auth-service"
fs.mkdirSync(
path.join(planningDir, 'milestones', 'v1.0-phases', '64-auth-service'),
{ recursive: true },
);
});
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
test('no W006 for Phase 64 archived under milestones/v1.0-phases/', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w006 = (data.warnings ?? []).filter(
(w) => w.code === 'W006' && /Phase 64/i.test(w.message),
);
assert.strictEqual(w006.length, 0,
`Expected no W006 for archived Phase 64, got: ${JSON.stringify(w006)}`);
});
test('MILESTONE_ARCHIVE_DIR_RE is exported and matches vN.N-phases dirs', () => {
const gen = require('../get-shit-done/bin/lib/validate.generated.cjs');
assert.ok(gen.MILESTONE_ARCHIVE_DIR_RE instanceof RegExp,
'validate.generated.cjs must export MILESTONE_ARCHIVE_DIR_RE');
const re = gen.MILESTONE_ARCHIVE_DIR_RE;
assert.ok(re.test('v1.0-phases'), 'should match v1.0-phases');
assert.ok(re.test('v1.10-phases'), 'should match v1.10-phases');
assert.ok(!re.test('phases'), 'should NOT match plain phases');
assert.ok(!re.test('1.0-phases'), 'should NOT match missing v prefix');
});
test('PHASE_TOKEN_FROM_DIR_RE is exported and extracts phase tokens correctly', () => {
const gen = require('../get-shit-done/bin/lib/validate.generated.cjs');
assert.ok(gen.PHASE_TOKEN_FROM_DIR_RE instanceof RegExp,
'validate.generated.cjs must export PHASE_TOKEN_FROM_DIR_RE');
const re = gen.PHASE_TOKEN_FROM_DIR_RE;
assert.strictEqual(re.exec('64-auth-service')?.[1], '64');
assert.strictEqual(re.exec('03B-feature')?.[1], '03B');
assert.strictEqual(re.exec('999.1-foo')?.[1], '999.1');
assert.strictEqual(re.exec('CK-64-auth')?.[1], '64');
});
});
// ── Drift Item I001: canonicalPlanStem ────────────────────────────────────────
//
// validate.ts Check 7: canonicalPlanStem('68-01-scaffolding') → '68-01'
// verify.cjs had an inline copy. After migration, canonicalPlanStem is
// sourced from validate.generated.cjs.
//
// Test: "68-01-scaffolding-PLAN.md" + "68-01-SUMMARY.md" → no I001
// Both stems canonicalize to "68-01" → match found → I001 suppressed.
describe('Drift item I001 — canonicalPlanStem: long PLAN stem matches short SUMMARY stem', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-26-d3-'));
const { planningDir, phasesDir } = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeStateMd(planningDir, '68');
writeConfigJson(planningDir);
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
'# Roadmap\n\n- [x] **Phase 68:** Scaffolding\n\n### Phase 68: Scaffolding\n',
);
const phaseDir = path.join(phasesDir, '68-scaffolding');
fs.mkdirSync(phaseDir, { recursive: true });
// Long-stem PLAN + short-stem SUMMARY → must match via canonicalPlanStem
fs.writeFileSync(path.join(phaseDir, '68-01-scaffolding-PLAN.md'), '---\nwave: 1\n---\n# Plan\n');
fs.writeFileSync(path.join(phaseDir, '68-01-SUMMARY.md'), '# Summary\n');
});
after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
test('no I001 when 68-01-scaffolding-PLAN.md matches 68-01-SUMMARY.md via canonicalPlanStem', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const i001 = (data.info ?? []).filter((i) => i.code === 'I001');
assert.strictEqual(i001.length, 0,
`Expected zero I001, got: ${JSON.stringify(i001)}`);
});
test('canonicalPlanStem is exported from validate.generated.cjs', () => {
const gen = require('../get-shit-done/bin/lib/validate.generated.cjs');
assert.strictEqual(typeof gen.canonicalPlanStem, 'function',
'validate.generated.cjs must export canonicalPlanStem as a function');
assert.strictEqual(gen.canonicalPlanStem('68-01-scaffolding'), '68-01');
assert.strictEqual(gen.canonicalPlanStem('68-01'), '68-01');
assert.strictEqual(gen.canonicalPlanStem('3A-01-feature'), '3A-01');
assert.strictEqual(gen.canonicalPlanStem('no-match'), 'no-match');
});
});