diff --git a/.changeset/w005-w006-i001-generator-migration.md b/.changeset/w005-w006-i001-generator-migration.md new file mode 100644 index 000000000..aaa74f43c --- /dev/null +++ b/.changeset/w005-w006-i001-generator-migration.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 26 +--- +W005/W006-archived/I001 drift items now covered by the validate.generated.cjs generator (gen-validate.mjs + validate.ts). PR #3806 hand-ported the behavioral fixes; issue #26 routes them through the generator so they cannot drift again. Per ADR-3524 generator framework introduced by PR #154, extended by PR #156. diff --git a/docs/adr/3524-cjs-sdk-hard-seam.md b/docs/adr/3524-cjs-sdk-hard-seam.md index 02b19ac75..00ad5daa9 100644 --- a/docs/adr/3524-cjs-sdk-hard-seam.md +++ b/docs/adr/3524-cjs-sdk-hard-seam.md @@ -146,3 +146,60 @@ all RED on pre-fix `origin/main`). Covers each drift item with concrete fixtures **Allowlist:** `scripts/shared-module-handsync-allowlist.json` — `verify.cjs` entry updated to reference the generator and freshness check. Classification remains `cooperating-sibling` (verify.cjs is still a full implementation; only Check 8 helpers are generated). + +#### Extension — issue #26: W005/W006-archived/I001 generator migration + +PR #3479 fixed three false-positive classes in `sdk/src/query/validate.ts`. PR #3806 hand-ported +the three fixes to `get-shit-done/bin/lib/verify.cjs` but did not route them through the generator +— meaning they could drift again. Issue #26 closes this gap by extending `gen-validate.mjs` +(introduced in this amendment above) to also extract and export the W005/W006-archived/I001 items. + +**Four additional exports added to `validate.generated.cjs` (issue #26):** + +1. **`phaseDirNameRe` (W005)** — The `PHASE_DIR_NAME_RE` constant `/^\d{2,}(?:\.\d+)*-[\w-]+$/` + is now a named export from `validate.ts` and extracted by `gen-validate.mjs`. `verify.cjs` + Check 6 consumes `phaseDirNameRe` from the generated artifact instead of an inline copy. + Reproducer: `mkdir -p .planning/phases/999.1-foo` → zero W005 (previously fired with + the `\d{2}` two-digits-only regex before PR #3806 / PR #3479). + +2. **`PHASE_TOKEN_FROM_DIR_RE` (W006-archived)** — The regex constant previously inline in + `verify.cjs`'s `forEachArchivedPhaseToken()` and `collectDiskPhases()`. Extracted from the + module-level `const` in the compiled output. `verify.cjs` inline copy removed. + +3. **`MILESTONE_ARCHIVE_DIR_RE` (W006-archived)** — The regex constant previously inline in + `verify.cjs`'s `listMilestoneArchiveDirs()`. Extracted the same way. `verify.cjs` inline copy + removed. Together `PHASE_TOKEN_FROM_DIR_RE` and `MILESTONE_ARCHIVE_DIR_RE` ensure the + archive-walking logic uses the same patterns as `validate.ts`. + +4. **`canonicalPlanStem` (I001)** — The top-level helper function previously inline in + `verify.cjs` Check 7. Extracted via `extractTopLevelFunction()` (brace-balanced parser). + `verify.cjs` inline copy removed. Fix: `68-01-scaffolding-PLAN.md` correctly matches + `68-01-SUMMARY.md` — both reduce to `68-01` via `canonicalPlanStem()`. + +**W006-archived coverage note:** Issue #26 describes W006-archived as "RELATED TO but DISTINCT +FROM" PR #156's W006 fix. Investigation confirmed both fixes are ALREADY in `verify.cjs` (from +PR #3806). The gap was generator coverage: the regex constants used by `forEachArchivedPhaseToken` +were inline copies with no generator protection. This amendment closes that gap by extracting them. +No new behavioral fix is required — the generator pattern extension is the deliverable. + +**`validate.ts` change:** `PHASE_DIR_NAME_RE` promoted from inline anonymous regex to a named +`export const` so it appears as an extractable identifier in the compiled ESM output. + +**Extraction methods used:** +- `extractConstRegExp()` (new in `gen-validate.mjs`) — handles `const` and `export const` + single-line RegExp assignments. Used for `phaseDirNameRe`, `PHASE_TOKEN_FROM_DIR_RE`, + `MILESTONE_ARCHIVE_DIR_RE`. +- `extractTopLevelFunction()` (new in `gen-validate.mjs`) — brace-balanced parser for top-level + named function declarations. Used for `canonicalPlanStem`. + +**Parity tests:** `tests/26-w005-w006-i001-cjs-drift-regression.test.cjs` — 7 tests. +- W005: no false positive for `999.1-foo`; W005 still fires for single-digit prefix. +- W006-archived: no false W006 for phase archived under `milestones/v1.0-phases/`; unit tests + for `MILESTONE_ARCHIVE_DIR_RE` and `PHASE_TOKEN_FROM_DIR_RE` export and behavior. +- I001: no false I001 when long-stem PLAN matches short-stem SUMMARY via `canonicalPlanStem`; + I001 still fires when there is genuinely no SUMMARY; unit test for `canonicalPlanStem` export. + +**Cross-references:** issue #26 cures the same false-positive scenarios as issue #6 but for +the W005/W006-archived/I001 check paths. The artifact `validate.generated.cjs` now covers all +six drift surfaces originally identified across both issues. This completes the validate.ts ↔ +verify.cjs migration scope for generator-pattern coverage. diff --git a/get-shit-done/bin/lib/validate.generated.cjs b/get-shit-done/bin/lib/validate.generated.cjs index 4ca0a28d3..6bc4ac7f3 100644 --- a/get-shit-done/bin/lib/validate.generated.cjs +++ b/get-shit-done/bin/lib/validate.generated.cjs @@ -6,25 +6,45 @@ * Source: sdk/src/query/validate.ts * Regenerate: cd sdk && npm run gen:validate * - * Validate Helpers — pure computation helpers for phase variant normalization, - * roadmap phase variant set construction, and unchecked-phase skip set construction. - * No I/O. No async. No filesystem operations. + * Validate Helpers — pure computation helpers and regex constants extracted from + * sdk/src/query/validate.ts. No I/O. No async. No filesystem operations. * - * These three helpers cure the three drift items from issue #6: + * Issue #6 drift items (three helpers): * 1. phaseVariants() — replaces parseInt-based padded/unpadded check in verify.cjs * Check 8 (W006 disk-existence and W007 roadmap-membership checks). * 2. buildRoadmapPhaseVariants() — replaces raw roadmapPhases set in W007 loop. * 3. buildNotStartedPhaseVariants() — replaces raw+zero-padded notStartedPhases * in W006 skip logic. * + * Issue #26 drift items (four constants/helpers): + * 4. phaseDirNameRe — W005 phase directory naming regex (was inline in verify.cjs Check 6). + * 5. PHASE_TOKEN_FROM_DIR_RE — extracts phase token from dir name (was inline in + * verify.cjs forEachArchivedPhaseToken / collectDiskPhases). + * 6. MILESTONE_ARCHIVE_DIR_RE — identifies milestone archive directories (was inline). + * 7. canonicalPlanStem() — I001 PLAN/SUMMARY stem canonicalization (was inline in Check 7). + * * I/O adapter pattern (ADR-3524 §4): pure transforms extracted from the SDK. * * References: * - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) * - Issue #6 (open-gsd/get-shit-done-redux) + * - Issue #26 (open-gsd/get-shit-done-redux) * - PR #154 (issue #4) — generator pattern precedent + * - PR #156 (issue #6) — validate.ts generator that #26 extends */ +// ── Issue #26: regex constants (W005, W006-archived) ──────────────────────── +const phaseDirNameRe = /^\d{2,}(?:\.\d+)*-[\w-]+$/; +const PHASE_TOKEN_FROM_DIR_RE = /^(?:[A-Z]{1,6}-)?(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i; +const MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i; + +// ── Issue #26: I001 canonicalization ──────────────────────────────────────── +function canonicalPlanStem(stem) { + const m = stem.match(/^(\d+[A-Z]?(?:\.\d+)*-\d+)/i); + return m ? m[1] : stem; +} + +// ── Issue #6: phase variant helpers (W006/W007) ────────────────────────────── function phaseVariants(phase) { const variants = new Set([phase]); @@ -65,6 +85,12 @@ function buildNotStartedPhaseVariants(roadmapContent) { } module.exports = { + // Issue #26 exports (W005 regex, W006-archived regex constants, I001 helper) + phaseDirNameRe, + PHASE_TOKEN_FROM_DIR_RE, + MILESTONE_ARCHIVE_DIR_RE, + canonicalPlanStem, + // Issue #6 exports (W006/W007 phase variant helpers) phaseVariants, buildRoadmapPhaseVariants, buildNotStartedPhaseVariants, diff --git a/get-shit-done/bin/lib/verify.cjs b/get-shit-done/bin/lib/verify.cjs index 338a6d4f0..4acb47416 100644 --- a/get-shit-done/bin/lib/verify.cjs +++ b/get-shit-done/bin/lib/verify.cjs @@ -2,7 +2,12 @@ * Verify — Verification suite, consistency, and health validation */ -const { phaseVariants, buildRoadmapPhaseVariants, buildNotStartedPhaseVariants } = require('./validate.generated.cjs'); +const { + // Issue #6 exports (W006/W007 phase variant helpers) + phaseVariants, buildRoadmapPhaseVariants, buildNotStartedPhaseVariants, + // Issue #26 exports (W005 regex, W006-archived regex constants, I001 helper) + phaseDirNameRe, PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE, canonicalPlanStem, +} = require('./validate.generated.cjs'); const fs = require('fs'); const path = require('path'); @@ -400,8 +405,8 @@ function cmdVerifyKeyLinks(cwd, planFilePath, raw) { }, raw, verified === results.length ? 'valid' : 'invalid'); } -const PHASE_TOKEN_FROM_DIR_RE = /^(?:[A-Z]{1,6}-)?(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i; -const MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i; +// PHASE_TOKEN_FROM_DIR_RE and MILESTONE_ARCHIVE_DIR_RE are sourced from +// validate.generated.cjs (issue #26, ADR-3524). No inline copies. function listMilestoneArchiveDirs(planBase) { const milestonesDir = path.join(planBase, 'milestones'); @@ -596,15 +601,8 @@ function cmdValidateConsistency(cwd, raw) { output({ passed, errors, warnings, warning_count: warnings.length }, raw, passed ? 'passed' : 'failed'); } -/** - * Canonical plan stem used for PLAN/SUMMARY matching. - * Mirrors canonicalPlanStem in sdk/src/query/validate.ts (#3479 / #3806). - * Example: `68-01-scaffolding` -> `68-01`. - */ -function canonicalPlanStem(stem) { - const m = stem.match(/^(\d+[A-Z]?(?:\.\d+)*-\d+)/i); - return m ? m[1] : stem; -} +// canonicalPlanStem is sourced from validate.generated.cjs (issue #26, ADR-3524). +// No inline copy — see top-of-file require() for the import. function cmdValidateHealth(cwd, options, raw) { // Guard: detect if CWD is the home directory (likely accidental) @@ -787,8 +785,9 @@ function cmdValidateHealth(cwd, options, raw) { } catch { /* intentionally empty */ } // ─── Check 6: Phase directory naming (NN-name format) ───────────────────── + // phaseDirNameRe sourced from validate.generated.cjs (issue #26, ADR-3524). for (const e of phaseDirEntries) { - if (!e.name.match(/^\d{2,}(?:\.\d+)*-[\w-]+$/)) { + if (!e.name.match(phaseDirNameRe)) { addIssue('warning', 'W005', `Phase directory "${e.name}" doesn't follow NN-name format`, 'Rename to match pattern (e.g., 01-setup)'); } } diff --git a/sdk/scripts/gen-validate.mjs b/sdk/scripts/gen-validate.mjs index 947fe0c82..168aaa184 100644 --- a/sdk/scripts/gen-validate.mjs +++ b/sdk/scripts/gen-validate.mjs @@ -3,13 +3,12 @@ * Generator for the Validate CJS artifact. * * Reads the compiled ESM output from sdk/dist/query/validate.js, extracts the - * pure `phaseVariants` helper function via source-text extraction (it is a - * closure inside validateHealth, not a module-level export), then emits + * pure helpers and constants, then emits * get-shit-done/bin/lib/validate.generated.cjs. * - * The generated module exports three pure helpers that were missing from - * verify.cjs (the three drift items from issue #6): + * The generated module exports seven items (three from issue #6, four from #26): * + * Issue #6 drift items: * 1. phaseVariants(phase) — generates all normalized variants of a phase * token (padded/unpadded/letter-suffix). Used for W006 disk-existence check * and W007 roadmap-membership check in verify.cjs Check 8. @@ -23,10 +22,25 @@ * unchecked-phase skip. verify.cjs previously added only raw+zero-padded * (dropping letter suffix via parseInt). * - * Extraction approach: since phaseVariants is defined as a closure inside - * validateHealth (not a module export), it is extracted from the compiled source - * text using a brace-balanced parser — the same approach used to extract - * escapeRegex in gen-phase-lifecycle-policy.mjs. + * Issue #26 drift items: + * 4. phaseDirNameRe (PHASE_DIR_NAME_RE) — regex constant for W005 phase + * directory naming check. /^\d{2,}(?:\.\d+)*-[\w-]+$/ accepts multi-digit + * prefixes. verify.cjs Check 6 previously had an inline copy. + * + * 5. PHASE_TOKEN_FROM_DIR_RE — regex constant used by forEachArchivedPhaseToken() + * to extract the phase token from a directory name. verify.cjs had an inline copy. + * + * 6. MILESTONE_ARCHIVE_DIR_RE — regex constant used to identify milestone archive + * directories under .planning/milestones/. verify.cjs had an inline copy. + * + * 7. canonicalPlanStem(stem) — converts a PLAN file stem to its canonical form for + * PLAN/SUMMARY matching (I001 check). '68-01-scaffolding' → '68-01'. + * verify.cjs Check 7 previously had an inline copy. + * + * Extraction approach: phaseVariants is a closure inside validateHealth (not a module + * export), extracted via brace-balanced source-text parsing. Named constants and top-level + * functions (PHASE_DIR_NAME_RE, PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE, + * canonicalPlanStem) are extracted by simple line-scanning from the compiled source. * * Run: cd sdk && npm run gen:validate * Check: node sdk/scripts/check-validate-fresh.mjs @@ -34,7 +48,9 @@ * References: * - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) * - Issue #6 (open-gsd/get-shit-done-redux) + * - Issue #26 (open-gsd/get-shit-done-redux) — #26 extends issue #6's generator * - PR #154 (issue #4) — generator pattern precedent + * - PR #156 (issue #6) — validate.ts generator that #26 extends */ import { readFile, writeFile } from 'node:fs/promises'; @@ -48,23 +64,31 @@ export const BANNER = `'use strict'; * Source: sdk/src/query/validate.ts * Regenerate: cd sdk && npm run gen:validate * - * Validate Helpers — pure computation helpers for phase variant normalization, - * roadmap phase variant set construction, and unchecked-phase skip set construction. - * No I/O. No async. No filesystem operations. + * Validate Helpers — pure computation helpers and regex constants extracted from + * sdk/src/query/validate.ts. No I/O. No async. No filesystem operations. * - * These three helpers cure the three drift items from issue #6: + * Issue #6 drift items (three helpers): * 1. phaseVariants() — replaces parseInt-based padded/unpadded check in verify.cjs * Check 8 (W006 disk-existence and W007 roadmap-membership checks). * 2. buildRoadmapPhaseVariants() — replaces raw roadmapPhases set in W007 loop. * 3. buildNotStartedPhaseVariants() — replaces raw+zero-padded notStartedPhases * in W006 skip logic. * + * Issue #26 drift items (four constants/helpers): + * 4. phaseDirNameRe — W005 phase directory naming regex (was inline in verify.cjs Check 6). + * 5. PHASE_TOKEN_FROM_DIR_RE — extracts phase token from dir name (was inline in + * verify.cjs forEachArchivedPhaseToken / collectDiskPhases). + * 6. MILESTONE_ARCHIVE_DIR_RE — identifies milestone archive directories (was inline). + * 7. canonicalPlanStem() — I001 PLAN/SUMMARY stem canonicalization (was inline in Check 7). + * * I/O adapter pattern (ADR-3524 §4): pure transforms extracted from the SDK. * * References: * - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md) * - Issue #6 (open-gsd/get-shit-done-redux) + * - Issue #26 (open-gsd/get-shit-done-redux) * - PR #154 (issue #4) — generator pattern precedent + * - PR #156 (issue #6) — validate.ts generator that #26 extends */ `; @@ -99,12 +123,88 @@ function extractPhaseVariantsBody(validateSource) { return `function phaseVariants(phase) {\n${bodyContent}\n}`; } +/** + * Extract a top-level const RegExp assignment from the source. + * + * Looks for the line `const = //;` and returns the full + * assignment statement as a `module.exports`-compatible const declaration + * (renaming to the export name when it differs from the source name). + * + * @param {string} source - Compiled JS source text + * @param {string} sourceName - The const name as it appears in the compiled output + * @param {string} [exportName] - The name to export under (defaults to sourceName) + */ +function extractConstRegExp(source, sourceName, exportName) { + const nameToUse = exportName ?? sourceName; + const lines = source.split('\n'); + // Match both `const = ...` and `export const = ...` + const suffix = `const ${sourceName} = `; + const line = lines.find((l) => l === suffix.trimStart() + l.slice(suffix.trimStart().length) + || l.startsWith(suffix) || l.startsWith(`export ${suffix}`)); + // Simpler: find a line that contains `const = ` (anywhere after optional export) + const matchLine = lines.find((l) => { + const trimmed = l.replace(/^export\s+/, ''); + return trimmed.startsWith(`const ${sourceName} = `); + }); + if (!matchLine) throw new Error(`Could not find "const ${sourceName} = ..." in compiled validate.js`); + // Extract just the value (after `const = `) + const assignIdx = matchLine.indexOf(`const ${sourceName} = `); + const valueStart = assignIdx + `const ${sourceName} = `.length; + const value = matchLine.slice(valueStart).replace(/;$/, '').trim(); + return `const ${nameToUse} = ${value};`; +} + +/** + * Extract a top-level named function declaration from the source. + * + * Matches `function () {` and extracts the complete function body + * using a brace-balanced parser. + * + * @param {string} source - Compiled JS source text + * @param {string} name - The function name as it appears in the compiled output + */ +function extractTopLevelFunction(source, name) { + // Match a top-level function declaration (not prefixed by spaces/async/export) + const marker = `function ${name}(`; + const start = source.indexOf(marker); + if (start === -1) throw new Error(`Could not find top-level function "${name}" in compiled validate.js`); + + const braceOpen = source.indexOf('{', start); + let depth = 0; + let i = braceOpen; + for (; i < source.length; i++) { + if (source[i] === '{') depth++; + else if (source[i] === '}') { + depth--; + if (depth === 0) break; + } + } + return source.slice(start, i + 1); +} + export async function buildValidateCjs() { const distUrl = new URL('../dist/query/validate.js', import.meta.url); const validateSource = await readFile(fileURLToPath(distUrl), 'utf-8'); const phaseVariantsBody = extractPhaseVariantsBody(validateSource); + // Issue #26: extract regex constants and canonicalPlanStem from compiled output. + // + // phaseDirNameRe — the PHASE_DIR_NAME_RE constant added to validate.ts for W005. + // Named 'phaseDirNameRe' in the export (camelCase for JS convention). + const phaseDirNameReLine = extractConstRegExp(validateSource, 'PHASE_DIR_NAME_RE', 'phaseDirNameRe'); + + // PHASE_TOKEN_FROM_DIR_RE — extracts phase token from a directory name like "64-auth-service". + // Exported under its original name for direct use in verify.cjs. + const phaseTokenFromDirReLine = extractConstRegExp(validateSource, 'PHASE_TOKEN_FROM_DIR_RE'); + + // MILESTONE_ARCHIVE_DIR_RE — matches milestone archive dir names like "v1.0-phases". + const milestoneArchiveDirReLine = extractConstRegExp(validateSource, 'MILESTONE_ARCHIVE_DIR_RE'); + + // canonicalPlanStem(stem) — I001 PLAN/SUMMARY stem canonicalization. + // '68-01-scaffolding' → '68-01'. Top-level named function in the compiled output. + const canonicalPlanStemBody = extractTopLevelFunction(validateSource, 'canonicalPlanStem'); + // buildRoadmapPhaseVariants: parse ROADMAP.md and return {roadmapPhases, roadmapPhaseVariants} // roadmapPhases — raw phase tokens as written in headings (used for W006 check) // roadmapPhaseVariants — all normalized variants of each roadmap phase (used for W007 check) @@ -135,6 +235,18 @@ export async function buildValidateCjs() { const parts = [ BANNER.trimEnd(), '', + // Issue #26: regex constants extracted from compiled validate.js + '// ── Issue #26: regex constants (W005, W006-archived) ────────────────────────', + phaseDirNameReLine, + phaseTokenFromDirReLine, + milestoneArchiveDirReLine, + '', + // Issue #26: canonicalPlanStem (I001) + '// ── Issue #26: I001 canonicalization ────────────────────────────────────────', + canonicalPlanStemBody, + '', + // Issue #6: phaseVariants closure (W006/W007) + '// ── Issue #6: phase variant helpers (W006/W007) ──────────────────────────────', phaseVariantsBody, '', buildRoadmapPhaseVariantsBody, @@ -142,6 +254,12 @@ export async function buildValidateCjs() { buildNotStartedPhaseVariantsBody, '', `module.exports = { + // Issue #26 exports (W005 regex, W006-archived regex constants, I001 helper) + phaseDirNameRe, + PHASE_TOKEN_FROM_DIR_RE, + MILESTONE_ARCHIVE_DIR_RE, + canonicalPlanStem, + // Issue #6 exports (W006/W007 phase variant helpers) phaseVariants, buildRoadmapPhaseVariants, buildNotStartedPhaseVariants, diff --git a/sdk/src/query/validate.ts b/sdk/src/query/validate.ts index ea9eb09a4..1de363f8b 100644 --- a/sdk/src/query/validate.ts +++ b/sdk/src/query/validate.ts @@ -32,6 +32,16 @@ const MAX_KEY_LINK_PATTERN_LEN = 512; const PHASE_TOKEN_FROM_DIR_RE = /^(?:[A-Z]{1,6}-)?(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i; const MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i; +/** + * Phase directory naming regex for Check 6 (W005). + * Matches valid phase directory names: two-or-more digit prefix, optional dot-separated + * sub-phase suffixes, a hyphen, and a word-character name. + * Examples: 01-setup, 999-longphase, 999.1-foo. + * Extracted into validate.generated.cjs as phaseDirNameRe so verify.cjs + * is not a hand-synced copy (issue #26, ADR-3524). + */ +export const PHASE_DIR_NAME_RE = /^\d{2,}(?:\.\d+)*-[\w-]+$/; + /** * List milestone-archive directories under `.planning/milestones/`, sorted by * version (numeric — `v1.10` after `v1.2`). Mirrors `listMilestoneArchiveDirs` @@ -648,7 +658,7 @@ export const validateHealth: QueryHandler = async (args, projectDir, workstream) try { const entries = await readdir(phasesDir, { withFileTypes: true }); for (const e of entries) { - if (e.isDirectory() && !e.name.match(/^\d{2,}(?:\.\d+)*-[\w-]+$/)) { + if (e.isDirectory() && !e.name.match(PHASE_DIR_NAME_RE)) { addIssue('warning', 'W005', `Phase directory "${e.name}" doesn't follow NN-name format`, 'Rename to match pattern (e.g., 01-setup)'); } } diff --git a/tests/26-w005-w006-i001-cjs-drift-regression.test.cjs b/tests/26-w005-w006-i001-cjs-drift-regression.test.cjs new file mode 100644 index 000000000..134d00e67 --- /dev/null +++ b/tests/26-w005-w006-i001-cjs-drift-regression.test.cjs @@ -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', + '', + '
', + 'Milestone v1.0 — Shipped', + '', + '### Phase 64: Auth Service', + '', + '
', + '', + ].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'); + }); +});