* refactor(#3183): route live-plan counting through scanPhasePlans scanPhasePlans becomes the sole owner of the live-plan derivation. Twenty-one independent re-derivations across seven modules now route through it, and scripts/lint-plan-count-drift.cjs reports zero, scanning the whole repo rather than an allowlist (ADR-3180 Decision 4a). The epic scoped this at three copies. A whole-repo guard found twenty-six sites across nine files, so Phase 1 absorbs every live-plan re-derivation and Phase 3 narrows to window plus sentinel enumeration. Two sites are exempt with a documented reason rather than a bare allowlist: audit.cts scans one quick task's own directory for a single completion record, and gsd2-import.cts reads a foreign GSD-2 tasks/ layout during a one-time import. Neither is a phase directory. scanPhasePlans gains allPlanFiles (pre-supersession) alongside planFiles so one owner answers both questions: verify.cts's numbering-gap check wants every plan on disk, its pairing check wants the live set. Both fields are additive. Highest-severity fix: cmdPhasePlanIndex, which feeds execute-phase wave scheduling, was scheduling status:superseded plans into waves and reporting zero plans for the post-#3139 nested layout. filterPlanFiles and filterSummaryFiles are deleted; getPhaseFileStats orphaned them and only their own tests still called them. New leaf module src/planning-scope.cts carries the frozen SCOPE discriminator, with its six-gate ripple closed: gitignore, inventory manifest, INVENTORY.md and the CONTEXT.md glossary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma * docs(#3183): amend ADR-3180 for the Phase 1/3 boundary re-slice The contract held; the phase boundary did not. The whole-repo drift guard found 26 re-derivations across 9 files against the epic's estimate of 3, and cmdProgressRender re-derives both enumeration and plan counting on adjacent lines, so DW4 was unsatisfiable within Phase 1's original file scope. Records the amended scope, scanPhasePlans's new allPlanFiles field, findOrphanSummaries, the two documented exemptions, the re-derived Tier-2 table, and the describeNonCanonicalPlans trap for later phases. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma * fix(#3183): complete the canonical pairing rule and gate the naming diagnostic The remote runner went red with 13 deterministic failures on both lanes, and they were right: replacing verify.cts's canonicalPlanStem pairing with summaryCandidates dropped a case the bespoke rule covered. A plan carrying a descriptive slug after its id (68-01-scaffolding-PLAN.md) pairs with its canonical-stem summary (68-01-SUMMARY.md), and summaryCandidates generated no such candidate, so the plan read unsummarized. The fix is to complete the one rule rather than restore a second: summaryCandidates gains a canonical-id candidate, narrowed to fire only when an id pair was actually extracted. countMatchedSummaries, findUnsummarizedPlans and findOrphanSummaries all inherit it. The two-plans-one-summary collision behaviour of the original rule is preserved deliberately and documented in place. Second defect, independently root-caused while verifying: routing the #2893 naming diagnostic through scanPhasePlans exposed it to the loose /PLAN/i fallback, which is correct for counting and wrong for a naming check — a non-canonically-named file was accepted as a valid plan and the diagnostic went silent. cmdPhasesList, cmdFindPhase and cmdPhasePlanIndex now intersect with a strict isCanonicalPlanFile predicate before reporting names. Same class as the describeNonCanonicalPlans trap already recorded in ADR-3180: a question about file naming wants the physical, strictly-matched set; only a question about outstanding work wants the live set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma * chore(#3183): register planning-scope.cjs in the eslint migration list tests/repo-invariants.test.cjs asserts every bin/lib/*.cjs is linted xor ignored per its ADR-457 migration state. The new planning-scope module closed five of the six .cts ripple gates - gitignore, inventory manifest, INVENTORY.md and the CONTEXT.md glossary - but not eslint, because that one is enforced by a test rather than by lint:ci, so the local pipeline stayed green while it was missing. Generated from src/planning-scope.cts, so the .cjs is ignored and the .cts is linted, matching every other migrated module. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma * fix(#3183): replace the plan-count drift detector with a literal tokenizer CodeQL reported 4 high-severity js/redos alerts on REGEX_LITERAL_MD_RE, the backtracking regex that finds "a regex literal mentioning PLAN/SUMMARY and an escaped \.md". Five review rounds found it had two defects, not one: - EXPONENTIAL, then CUBIC. Its "any char" atom `(?:\\.|[^/\r\n])` let a `\.` pair be consumed either as one escape or as two class characters, which is exponential backtracking: 27,464ms on `"/\.mdplan" + "\.".repeat(28) + "X"`. Excluding `\` from the class killed that but left a cubic path — 23ms at N=200, 172ms at N=400, 1362ms at N=800 on `"/" + "PLAN\.md".repeat(N)` with no closing `/`. This guard is the last stage of `npm run lint:ci`, which CI runs on fork pull requests, so a crafted src/*.cts could stall the job. - A DETECTION HOLE. A character class holding a bare, unescaped `/` — e.g. `/SUMMARY[^/]*\.md$/`, an ordinary path-excluding filter — terminated the literal at that `/`, so the scan never reached `\.md` and the guard missed it entirely. (Classes holding an ESCAPED `\/` were already matched; the tests cover those separately as parity, not as regressions.) Both defects have one root cause: regex-literal grammar — `\x` escapes, and `/` inside `[...]` not terminating — is not expressible in a backtracking regex. So the detector is now a tokenizer, not a regex. readRegexLiteralAt reads the literal at a given `/` in a single left-to-right pass with no backtracking, treating escapes as two-character units and suppressing the `/` terminator inside a character class. findRegexLiteralMdMatch restarts it at every `/` on the line, preserving the old "find anywhere" behaviour; MAX_REGEX_LITERAL_LEN (400) bounds each read — including the trailing-flag scan — which keeps the whole-line cost linear. Results: cubic shape flat at 0.06-0.39ms out to N=3200 (25KB), exponential shape 0.01ms at 28 reps and 0.00ms at 64, and the bare-`/` class shapes are now caught. Differential against the old regex over 28,474 lines (those matching FILENAME_TEST_RE but not PLAN_SUMMARY_LITERAL_RE, across src/tests/scripts/ gsd-core/bin/eslint-rules, excluding 265 lines with >6 backslashes on which the old regex hangs): 6 differences, all the tokenizer returning the fuller or newly-correct literal, 0 old-only misses. The `\.md` token stays case-insensitive, matching the `/i` the old regex carried. Also closes three holes in the same new file: - walk() tested entry.isFile(), false for a symlink, so a symlinked src/*.cts was silently unscanned — an evasion of a guard whose stated principle (ADR-3180 Decision 4a) is whole-repo discovery with no allowlist. It now resolves symlinks, but confined: file links must resolve inside the repo root, directory links inside the scanned dir itself. Every sibling drift guard in scripts/ uses the Dirent classification and never follows links, so following them unconfined would have made this the only linter able to read outside the tree — on fork PRs an arbitrary out-of-repo read whose matched fragments reach a public CI log. The narrower directory rule additionally stops `src/up -> ..` from sweeping the whole repo, and the skip list is now checked against resolved paths so `src/g -> ../.git` cannot reach .git/** or node_modules/**. Real paths are de-duplicated and files reported canonically, so a symlink alias cannot shift which FUNCTION_SCOPED_EXEMPTIONS key applies. - Both the reported fragment and the reported FILE PATH are attacker- controlled source text written straight to a CI log, and git permits control bytes in a filename. Both are now escaped — C0/C1/DEL plus the bidi and zero-width controls — so a crafted literal or filename cannot recolour the log, overwrite a line with CR, or fabricate a line that looks like this guard's own success output. Regression coverage in tests/plan-count-single-owner.test.cjs: a child-process probe over both pathological shapes (catastrophic backtracking is synchronous and would freeze the suite rather than fail one test), the bare-`/` class shapes verified to fail against the parent-commit blob, root-confinement tests covering the outside-file, outside-directory, cycle, broken-link and duplicate cases, direct isInsideRoot coverage including the sibling-prefix case that a bare startsWith would let through, sanitizeForReport coverage, and limit-1/limit/limit+1 coverage of MAX_REGEX_LITERAL_LEN derived from the exported constant. The earlier structural assertion was dropped — it checked for the substring `[^/`, which respelling the class as `[^\r\n/]` defeats while staying exponential. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma * chore(#3183): backfill changeset PR number Restores b77931869, which a force-push during the ReDoS remediation dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -239,6 +239,14 @@ function scanQuickTasks(planDir: string): QuickTaskItem[] {
|
||||
|
||||
// workflows/quick.md mandates `${quick_id}-SUMMARY.md`; older flows used
|
||||
// bare `SUMMARY.md`. Accept either to avoid false-positive "missing".
|
||||
//
|
||||
// #3183 (ADR-3180 Decision 4(a) — bucket B, out of scope for the
|
||||
// scanPhasePlans migration): this scans a quick task's OWN directory
|
||||
// (`.planning/quick/<task>/`) for THAT task's single completion record —
|
||||
// "does this one quick task have a SUMMARY.md" — not a phase directory's
|
||||
// live-plan/summary counting question. scanPhasePlans is the wrong tool
|
||||
// here; there is no plan/summary PAIRING to derive, only a single
|
||||
// filename presence check local to a non-phase directory.
|
||||
let summaryPath: string | null = null;
|
||||
try {
|
||||
const summaryFiles = fs.readdirSync(safeTaskDir, { withFileTypes: true })
|
||||
|
||||
@@ -43,6 +43,12 @@ const { evaluatePredicate } = gatePredicateEval;
|
||||
import apiCoverageMod = require('./api-coverage.cjs');
|
||||
const { detectApiIntegration, validateCoverageMatrix } = apiCoverageMod;
|
||||
import { execTool, platformReadSync, posixNormalize } from './shell-command-projection.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planScanMod = require('./plan-scan.cjs');
|
||||
const { scanPhasePlans } = planScanMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningScopeMod = require('./planning-scope.cjs');
|
||||
const { SCOPE } = planningScopeMod;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -133,13 +139,13 @@ function gateEnabled(projectDir: string): boolean {
|
||||
|
||||
function loadPlanContents(phaseDir: string): string[] {
|
||||
if (!fs.existsSync(phaseDir)) return [];
|
||||
try {
|
||||
return fs.readdirSync(phaseDir)
|
||||
.filter((entry) => /-PLAN\.md$/.test(entry))
|
||||
.map((entry) => readIfExists(path.join(phaseDir, entry)));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
// #3183 (lint-plan-count-drift): source live plan files from the single
|
||||
// owner (scanPhasePlans) instead of a local `-PLAN.md` readdirSync filter
|
||||
// — picks up bare PLAN.md and nested plans/, and excludes plans marked
|
||||
// `status: superseded`, which the prior root-only exact-suffix filter did
|
||||
// neither for.
|
||||
return scanPhasePlans(phaseDir).planFiles
|
||||
.map((entry) => readIfExists(path.join(phaseDir, entry)));
|
||||
}
|
||||
|
||||
const DESIGNATED_HEADINGS_RE = /^#{1,6}\s+(?:must[_ ]haves?|truths?|tasks?|objective)\b/i;
|
||||
@@ -434,8 +440,11 @@ function cmdDecisionCoverageVerify(projectDir: string, args: string[], raw: bool
|
||||
}
|
||||
|
||||
const planContents = loadPlanContents(phaseDir);
|
||||
// #3183 (lint-plan-count-drift): same single-owner sourcing as
|
||||
// loadPlanContents above — scanPhasePlans's summaryFiles instead of a
|
||||
// local `-SUMMARY.md` readdirSync filter.
|
||||
const summaryParts = fs.existsSync(phaseDir)
|
||||
? fs.readdirSync(phaseDir).filter((entry) => /-SUMMARY\.md$/.test(entry)).map((entry) => readIfExists(path.join(phaseDir, entry)))
|
||||
? scanPhasePlans(phaseDir).summaryFiles.map((entry) => readIfExists(path.join(phaseDir, entry)))
|
||||
: [];
|
||||
const haystack = [
|
||||
planContents.join('\n\n'),
|
||||
@@ -757,7 +766,9 @@ function cmdTddReviewCheckpoint(projectDir: string, args: string[], raw: boolean
|
||||
const tddPlanFiles: string[] = [];
|
||||
if (phaseDir) {
|
||||
try {
|
||||
const files = fs.readdirSync(phaseDir).filter(f => f.endsWith('-PLAN.md'));
|
||||
// #3183: canonical plan set (root+nested, superseded-excluded) from the
|
||||
// single owner, rather than a root-only hand-rolled readdirSync filter.
|
||||
const files = scanPhasePlans(phaseDir).planFiles;
|
||||
for (const file of files) {
|
||||
const planPath = path.join(phaseDir, file);
|
||||
const content = readIfExists(planPath);
|
||||
@@ -1394,12 +1405,26 @@ function isRealReadFailure(err: unknown): boolean {
|
||||
function readPhaseScope(projectDir: string, phaseDir: string, phaseNumber: string): PhaseScopeRead {
|
||||
const chunks: string[] = [];
|
||||
let readError: string | null = null;
|
||||
try {
|
||||
const entries = fs.readdirSync(phaseDir, { withFileTypes: true });
|
||||
const plans = entries
|
||||
.filter((e) => e.isFile() && /-PLAN\.md$/i.test(e.name))
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
// A MISSING phase directory is fine (no plans yet → fall through to the
|
||||
// roadmap). Checked up front (rather than via a readdirSync catch) because
|
||||
// #3183 (lint-plan-count-drift) now sources the plan-file list from the
|
||||
// single owner (scanPhasePlans) instead of a local `-PLAN\.md$` readdirSync
|
||||
// filter — picks up bare PLAN.md and nested plans/, and excludes
|
||||
// superseded plans, none of which the prior root-only exact-suffix filter
|
||||
// did.
|
||||
if (fs.existsSync(phaseDir)) {
|
||||
const scan = scanPhasePlans(phaseDir);
|
||||
if (scan.scope === SCOPE.UNREADABLE) {
|
||||
// Directory exists but scanPhasePlans's own readdirSync(phaseDir) call
|
||||
// failed (EACCES/EIO race) — a real read failure the gate must not
|
||||
// silently pass (#2365 review), mirroring the prior isRealReadFailure
|
||||
// branch below for the readdirSync-throws case.
|
||||
return {
|
||||
text: '',
|
||||
readError: 'could not read the phase directory: scanPhasePlans reported scope UNREADABLE',
|
||||
};
|
||||
}
|
||||
const plans = [...scan.planFiles].sort();
|
||||
for (const p of plans) {
|
||||
try {
|
||||
chunks.push(fs.readFileSync(path.join(phaseDir, p), 'utf8'));
|
||||
@@ -1411,16 +1436,6 @@ function readPhaseScope(projectDir: string, phaseDir: string, phaseNumber: strin
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// A MISSING phase directory is fine (no plans yet → fall through to the
|
||||
// roadmap). A directory that exists but cannot be enumerated (EACCES/EIO)
|
||||
// is a real read failure the gate must not silently pass (#2365 review).
|
||||
if (isRealReadFailure(err)) {
|
||||
return {
|
||||
text: '',
|
||||
readError: `could not read the phase directory: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (readError) return { text: chunks.join('\n\n'), readError };
|
||||
if (chunks.join('').trim().length > 0) return { text: chunks.join('\n\n'), readError: null };
|
||||
|
||||
@@ -48,6 +48,9 @@ import modelProfiles = require('./model-profiles.cjs');
|
||||
const { MODEL_PROFILES, VALID_PHASE_TYPES } = modelProfiles;
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
import { realClock } from './clock.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planScanMod = require('./plan-scan.cjs');
|
||||
const { scanPhasePlans } = planScanMod;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -421,7 +424,14 @@ function cmdHistoryDigest(cwd: string, raw: boolean): void {
|
||||
|
||||
try {
|
||||
for (const { name: dir, fullPath: dirPath } of allPhaseDirs) {
|
||||
const summaries = fs.readdirSync(dirPath).filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
||||
// #3183: canonical summary set (root+nested) from the single owner.
|
||||
// This call also opens every plan file's frontmatter to check
|
||||
// superseded status even though cmdHistoryDigest never uses planFiles
|
||||
// or the superseded distinction — that per-phase-dir cost is accepted
|
||||
// deliberately (correctness/single-ownership over micro-optimization;
|
||||
// summaryFiles itself is not superseded-filtered either way). Do not
|
||||
// "optimize" this back into a second hand-rolled summary derivation.
|
||||
const summaries = scanPhasePlans(dirPath).summaryFiles;
|
||||
|
||||
for (const summary of summaries) {
|
||||
const summaryFilePath = path.join(dirPath, summary);
|
||||
@@ -1564,9 +1574,11 @@ function cmdProgressRender(cwd: string, format: string | undefined, raw: boolean
|
||||
const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
|
||||
const phaseNum = dm ? dm[1] : dir;
|
||||
const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
|
||||
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
|
||||
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
|
||||
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
|
||||
// #3183: canonical plan/summary counts (root+nested, superseded-excluded,
|
||||
// canonical pairing) from the single owner.
|
||||
const phaseScan = scanPhasePlans(path.join(phasesDir, dir));
|
||||
const plans = phaseScan.planCount;
|
||||
const summaries = phaseScan.summaryCount;
|
||||
|
||||
totalPlans += plans;
|
||||
totalSummaries += summaries;
|
||||
@@ -1675,7 +1687,9 @@ function cmdTodoMatchPhase(cwd: string, phase: string | undefined, raw: boolean)
|
||||
if (phaseInfoDisk && phaseInfoDisk['found']) {
|
||||
try {
|
||||
const phaseDir = path.join(cwd, phaseInfoDisk['directory'] as string);
|
||||
const planFiles = fs.readdirSync(phaseDir).filter(f => f.endsWith('-PLAN.md'));
|
||||
// #3183: canonical plan set (root+nested, superseded-excluded) from the
|
||||
// single owner, rather than a root-only hand-rolled readdirSync filter.
|
||||
const planFiles = scanPhasePlans(phaseDir).planFiles;
|
||||
for (const pf of planFiles) {
|
||||
const planContent = platformReadSync(path.join(phaseDir, pf));
|
||||
if (planContent === null) continue;
|
||||
@@ -1891,9 +1905,11 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
|
||||
// phaseName is everything after the token (strip leading '-')
|
||||
const afterToken = dir.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
|
||||
const phaseName = afterToken ? afterToken.replace(/-/g, ' ') : '';
|
||||
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
|
||||
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
|
||||
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
|
||||
// #3183: canonical plan/summary counts (root+nested, superseded-excluded,
|
||||
// canonical pairing) from the single owner.
|
||||
const phaseScan = scanPhasePlans(path.join(phasesDir, dir));
|
||||
const plans = phaseScan.planCount;
|
||||
const summaries = phaseScan.summaryCount;
|
||||
|
||||
totalPlans += plans;
|
||||
totalSummaries += summaries;
|
||||
|
||||
@@ -154,16 +154,6 @@ function transliterateForSlug(text: string): string {
|
||||
|
||||
// ─── Phase file helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** Filter a file list to just PLAN.md / *-PLAN.md entries. */
|
||||
function filterPlanFiles(files: string[]): string[] {
|
||||
return files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
||||
}
|
||||
|
||||
/** Filter a file list to just SUMMARY.md / *-SUMMARY.md entries. */
|
||||
function filterSummaryFiles(files: string[]): string[] {
|
||||
return files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
||||
}
|
||||
|
||||
interface PhaseFileStats {
|
||||
plans: string[];
|
||||
summaries: string[];
|
||||
@@ -171,20 +161,65 @@ interface PhaseFileStats {
|
||||
hasContext: boolean;
|
||||
hasVerification: boolean;
|
||||
hasReviews: boolean;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
// Minimal shape this module needs from plan-scan.cjs's scanPhasePlans result.
|
||||
interface PlanScanResultShape {
|
||||
planFiles: string[];
|
||||
summaryFiles: string[];
|
||||
scope: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a phase directory and return counts/flags for common file types.
|
||||
*
|
||||
* #3183 (ADR-3180 Decision 2): `plans`/`summaries` are derived from the
|
||||
* canonical `scanPhasePlans` rather than a local re-derivation, so this
|
||||
* primitive can no longer diverge from the single owner of live-plan
|
||||
* counting. `scanPhasePlans`
|
||||
* lives in plan-scan.cjs, which itself imports `countMatchedSummaries` from
|
||||
* THIS module — a top-level import here would be circular, so the require
|
||||
* is deferred (lazy, inside the function body) to break the cycle at load
|
||||
* time. This mirrors the lazy-require seam already used elsewhere in this
|
||||
* repo (see src/audit-command-router.cts) for the same "module A needs
|
||||
* module B which needs module A" shape.
|
||||
*
|
||||
* `hasResearch`/`hasContext`/`hasVerification`/`hasReviews` stay on the raw
|
||||
* `readdirSync` listing — they are not plan-scan concerns.
|
||||
*
|
||||
* Degrades on an unreadable directory instead of throwing: empty arrays,
|
||||
* every flag false, scope UNREADABLE (mirroring scanPhasePlans's own
|
||||
* degrade path).
|
||||
*/
|
||||
function getPhaseFileStats(phaseDir: string): PhaseFileStats {
|
||||
const files = fs.readdirSync(phaseDir);
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
||||
const scanPhasePlans: (dir: string) => PlanScanResultShape = require('./plan-scan.cjs');
|
||||
const scan = scanPhasePlans(phaseDir);
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = fs.readdirSync(phaseDir);
|
||||
} catch {
|
||||
return {
|
||||
plans: scan.planFiles,
|
||||
summaries: scan.summaryFiles,
|
||||
hasResearch: false,
|
||||
hasContext: false,
|
||||
hasVerification: false,
|
||||
hasReviews: false,
|
||||
scope: scan.scope,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
plans: filterPlanFiles(files),
|
||||
summaries: filterSummaryFiles(files),
|
||||
plans: scan.planFiles,
|
||||
summaries: scan.summaryFiles,
|
||||
hasResearch: files.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
|
||||
hasContext: findContextMdIn(files) !== null,
|
||||
hasVerification: files.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'),
|
||||
hasReviews: files.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'),
|
||||
scope: scan.scope,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -305,6 +340,34 @@ function summaryCandidates(plan: string): string[] {
|
||||
];
|
||||
const extended = base.match(/^(\d+)-PLAN-(\d+)/i);
|
||||
if (extended) candidates.push(dir + extended[1] + '-' + extended[2] + '-SUMMARY.md');
|
||||
// #3183: canonical-id form. Restores the coverage of the pre-migration
|
||||
// bespoke I001 rule (verify.cts, pre-#3183, via validate.cjs's now-unused
|
||||
// `canonicalPlanStem` — behaviourally identical to `extractCanonicalPlanId`,
|
||||
// confirmed empirically), which matched a plan carrying a descriptive slug
|
||||
// after its <phase>-<plan> id — e.g. `68-01-scaffolding-PLAN.md` — against
|
||||
// a summary named only by the bare id — `68-01-SUMMARY.md`. None of the
|
||||
// three candidates above produce that filename.
|
||||
//
|
||||
// Narrowed to the case `extractCanonicalPlanId` actually extracted an
|
||||
// <id>-<id> pair (its result differs from the plan's own PLAN-stripped
|
||||
// base). When no pair is found it falls back to returning that same base
|
||||
// unchanged, which would otherwise push a redundant candidate identical to
|
||||
// the `<stem>-SUMMARY.md` form above (e.g. `setup-PLAN.md` -> canonical
|
||||
// 'setup' -> 'setup-SUMMARY.md', already candidate #2) rather than the
|
||||
// original rule's actual behavior of matching only real id pairs.
|
||||
//
|
||||
// Collision, matching the original rule byte-for-behaviour: two plans that
|
||||
// share the same <phase>-<plan> id but differ only in their descriptive
|
||||
// slug (`68-01-alpha-PLAN.md` + `68-01-beta-PLAN.md`) both generate the
|
||||
// SAME candidate `68-01-SUMMARY.md` and therefore BOTH read as summarized
|
||||
// off one shared summary file. This is not a new regression: the
|
||||
// pre-migration bespoke rule collapsed the same way (it populated one
|
||||
// `summaryBases` Set keyed by canonical stem, so any plan whose canonical
|
||||
// stem hit the set counted as matched, with no cardinality check against
|
||||
// how many plans shared that stem).
|
||||
const planStem = base.replace(/-PLAN$/i, '');
|
||||
const canonicalId = extractCanonicalPlanId(base + '.md');
|
||||
if (canonicalId !== planStem) candidates.push(dir + canonicalId + '-SUMMARY.md');
|
||||
return candidates;
|
||||
}
|
||||
|
||||
@@ -324,6 +387,24 @@ function findUnsummarizedPlans(planFiles: string[], summaryFiles: string[]): str
|
||||
return planFiles.filter((plan) => !summaryCandidates(plan).some((c) => summarySet.has(c)));
|
||||
}
|
||||
|
||||
/**
|
||||
* #3183: the mirror image of `findUnsummarizedPlans` — the summary files in
|
||||
* `summaryFiles` that do NOT pair with ANY plan in `planFiles`, using the
|
||||
* identical `summaryCandidates` matching rule as `countMatchedSummaries` /
|
||||
* `findUnsummarizedPlans`. Callers that must name orphaned summaries (a
|
||||
* stray non-plan summary, or a summary whose plan was renamed/removed) need
|
||||
* this instead of a bespoke exact-suffix Set-diff, which cannot recognize
|
||||
* the nested or extended naming forms `summaryCandidates` already handles —
|
||||
* a divergence that produced false "orphan summary" warnings.
|
||||
*/
|
||||
function findOrphanSummaries(planFiles: string[], summaryFiles: string[]): string[] {
|
||||
const claimed = new Set<string>();
|
||||
for (const plan of planFiles) {
|
||||
for (const candidate of summaryCandidates(plan)) claimed.add(candidate);
|
||||
}
|
||||
return summaryFiles.filter((s) => !claimed.has(s));
|
||||
}
|
||||
|
||||
export = {
|
||||
toPosixPath,
|
||||
detectSubRepos,
|
||||
@@ -331,12 +412,11 @@ export = {
|
||||
pathExistsInternal,
|
||||
generateSlugInternal,
|
||||
transliterateForSlug,
|
||||
filterPlanFiles,
|
||||
filterSummaryFiles,
|
||||
getPhaseFileStats,
|
||||
readSubdirectories,
|
||||
timeAgo,
|
||||
extractCanonicalPlanId,
|
||||
countMatchedSummaries,
|
||||
findUnsummarizedPlans,
|
||||
findOrphanSummaries,
|
||||
};
|
||||
|
||||
@@ -29,6 +29,9 @@ import planningWorkspace = require('./planning-workspace.cjs');
|
||||
const { planningPaths, planningDir, findContextMdIn } = planningWorkspace;
|
||||
import { parseDecisions, extractDecisions } from './decisions.cjs';
|
||||
import { iterateBullets } from './markdown-sectionizer.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planScanMod = require('./plan-scan.cjs');
|
||||
const { scanPhasePlans } = planScanMod;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -318,7 +321,12 @@ function runGapAnalysis(cwd: string, phaseDir: string, options: RunGapAnalysisOp
|
||||
let planText = '';
|
||||
try {
|
||||
if (phaseDirFiles.length > 0) {
|
||||
const files = phaseDirFiles.filter(f => /-PLAN\.md$/.test(f));
|
||||
// #3183 (lint-plan-count-drift): source the live plan-file list from
|
||||
// the single owner (scanPhasePlans) instead of a local `-PLAN\.md$`
|
||||
// filter on the already-read listing — picks up bare PLAN.md, nested
|
||||
// plans/, and excludes superseded plans, none of which the prior
|
||||
// root-only exact-suffix filter did.
|
||||
const files = scanPhasePlans(absPhaseDir).planFiles;
|
||||
planText = files.map(f => {
|
||||
try { return fs.readFileSync(path.join(absPhaseDir, f), 'utf-8'); }
|
||||
catch { return ''; }
|
||||
|
||||
@@ -177,6 +177,12 @@ function parseTaskMustHaves(content: string): string[] {
|
||||
/**
|
||||
* Read all task plan files from a GSD-2 tasks/ directory.
|
||||
*/
|
||||
// #3183 (ADR-3180 Decision 4(a) — bucket B, out of scope for the
|
||||
// scanPhasePlans migration): this reads a FOREIGN GSD-2 legacy project's own
|
||||
// `tasks/` directory convention (`T##-PLAN.md`) during a one-time import —
|
||||
// it is not this project's `.planning/phases/<phase>/` layout at all, has no
|
||||
// nested-plans/superseded-status concept, and scanPhasePlans's grammar
|
||||
// (which is scoped to GSD's OWN phase directories) does not apply here.
|
||||
function readTasksDir(tasksDir: string): TaskInfo[] {
|
||||
if (!fs.existsSync(tasksDir)) return [];
|
||||
|
||||
|
||||
@@ -32,7 +32,10 @@ import roadmapParserMod = require('./roadmap-parser.cjs');
|
||||
const { getMilestonePhaseFilter, extractCurrentMilestone, getMilestoneInfo } = roadmapParserMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import coreUtilsMod = require('./core-utils.cjs');
|
||||
const { extractOneLinerFromBody } = coreUtilsMod;
|
||||
const { extractOneLinerFromBody, countMatchedSummaries } = coreUtilsMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
|
||||
import planScanMod = require('./plan-scan.cjs');
|
||||
const { scanPhasePlans } = planScanMod;
|
||||
const { planningPaths } = planningWorkspace;
|
||||
const { extractFrontmatter } = frontmatterMod;
|
||||
const { writeStateMd } = stateMod;
|
||||
@@ -333,17 +336,21 @@ function cmdRequirementsReadyIds(cwd: string, args: string[], raw: boolean): voi
|
||||
}
|
||||
|
||||
const planAbsPath = path.resolve(cwd, planPathArg);
|
||||
const phaseDir = path.dirname(planAbsPath);
|
||||
const currentBasename = path.basename(planAbsPath);
|
||||
// #3183: `planPathArg` may point at a root plan (`<phaseDir>/<n>-PLAN.md`)
|
||||
// or a nested plan (`<phaseDir>/plans/PLAN-<n>.md`, #3139 layout) —
|
||||
// scanPhasePlans always operates on the PHASE dir, so a nested plan needs
|
||||
// one extra `dirname` to reach it, and its planFiles-relative identity
|
||||
// carries the `plans/` prefix scanPhasePlans itself applies.
|
||||
const isNestedPlanPath = path.basename(path.dirname(planAbsPath)) === 'plans';
|
||||
const phaseDir = isNestedPlanPath ? path.dirname(path.dirname(planAbsPath)) : path.dirname(planAbsPath);
|
||||
const currentRelative = isNestedPlanPath ? `plans/${path.basename(planAbsPath)}` : path.basename(planAbsPath);
|
||||
|
||||
let siblingPlanFiles: string[] = [];
|
||||
try {
|
||||
siblingPlanFiles = fs
|
||||
.readdirSync(phaseDir)
|
||||
.filter((f) => f.endsWith('-PLAN.md') && f !== currentBasename);
|
||||
} catch {
|
||||
siblingPlanFiles = [];
|
||||
}
|
||||
// #3183: canonical plan/summary sets (root+nested, superseded-excluded)
|
||||
// from the single owner, rather than a root-only hand-rolled readdirSync
|
||||
// filter — a superseded sibling that still declares reqId with no SUMMARY
|
||||
// used to block the ID forever (false-block); it is now excluded upstream.
|
||||
const phaseScan = scanPhasePlans(phaseDir);
|
||||
const siblingPlanFiles = phaseScan.planFiles.filter((f) => f !== currentRelative);
|
||||
|
||||
const parseFrontmatterReqIds = (content: string, sourcePath?: string): string[] => {
|
||||
const fm = extractFrontmatter(content, sourcePath);
|
||||
@@ -379,9 +386,11 @@ function cmdRequirementsReadyIds(cwd: string, args: string[], raw: boolean): voi
|
||||
if (!siblingDeclaresId) continue;
|
||||
|
||||
// Sibling declares the SAME ID — it must have finished (produced a
|
||||
// SUMMARY) before this ID is ready to mark Complete.
|
||||
const siblingSummaryPath = siblingPath.replace(/-PLAN\.md$/, '-SUMMARY.md');
|
||||
if (!fs.existsSync(siblingSummaryPath)) {
|
||||
// SUMMARY) before this ID is ready to mark Complete. Canonical pairing
|
||||
// via countMatchedSummaries (root+nested, all three naming forms)
|
||||
// instead of a bespoke -PLAN.md→-SUMMARY.md regex swap.
|
||||
const siblingHasSummary = countMatchedSummaries([siblingFile], phaseScan.summaryFiles) > 0;
|
||||
if (!siblingHasSummary) {
|
||||
blockedBySibling = true;
|
||||
break;
|
||||
}
|
||||
@@ -643,10 +652,12 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
|
||||
if (!isDirInMilestone(dir)) continue;
|
||||
|
||||
phaseCount++;
|
||||
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
|
||||
const plans = phaseFiles.filter((f) => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
||||
const summaries = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
||||
totalPlans += plans.length;
|
||||
// #3183: canonical plan/summary sets (root+nested, superseded-excluded)
|
||||
// from the single owner, rather than a root-only hand-rolled readdirSync
|
||||
// filter.
|
||||
const phaseScan = scanPhasePlans(path.join(phasesDir, dir));
|
||||
const summaries = phaseScan.summaryFiles;
|
||||
totalPlans += phaseScan.planCount;
|
||||
|
||||
// Extract one-liners from summaries
|
||||
for (const s of summaries) {
|
||||
|
||||
@@ -23,7 +23,7 @@ import phaseIdModule = require('./phase-id.cjs');
|
||||
const { normalizePhaseName, phaseTokenMatches, extractPhaseToken } = phaseIdModule;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import coreUtilsModule = require('./core-utils.cjs');
|
||||
const { readSubdirectories, getPhaseFileStats, extractCanonicalPlanId, toPosixPath } = coreUtilsModule;
|
||||
const { readSubdirectories, getPhaseFileStats, extractCanonicalPlanId, toPosixPath, findUnsummarizedPlans } = coreUtilsModule;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
const { planningDir } = planningWorkspace;
|
||||
@@ -178,18 +178,13 @@ function searchPhaseInDir(baseDir: string, relBase: string, normalized: string):
|
||||
const plans = unsortedPlans.sort();
|
||||
const summaries = unsortedSummaries.sort();
|
||||
|
||||
const completedPlanIds = new Set(
|
||||
summaries.flatMap(s => {
|
||||
const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
||||
const canonical = extractCanonicalPlanId(s);
|
||||
return canonical === exact ? [exact] : [exact, canonical];
|
||||
})
|
||||
);
|
||||
const incompletePlans = plans.filter(p => {
|
||||
const planId = p.replace('-PLAN.md', '').replace('PLAN.md', '');
|
||||
const canonical = extractCanonicalPlanId(p);
|
||||
return !completedPlanIds.has(planId) && !completedPlanIds.has(canonical);
|
||||
});
|
||||
// #3183 (ADR-3180 Decision 2): the summary→plan pairing used to be a
|
||||
// bespoke rule local to this function (a third pairing rule alongside
|
||||
// scanPhasePlans's completion check and countMatchedSummaries). Routed
|
||||
// through the canonical core-utils.findUnsummarizedPlans instead, which
|
||||
// shares its `summaryCandidates` matching rule with countMatchedSummaries
|
||||
// so the count and this named list can never disagree.
|
||||
const incompletePlans = findUnsummarizedPlans(plans, summaries);
|
||||
|
||||
// #2830: reverse lookup from a completed plan's id (exact or canonical) to
|
||||
// its actual summary filename. Shared builder (also used by phase.cts's
|
||||
|
||||
120
src/phase.cts
120
src/phase.cts
@@ -85,9 +85,6 @@ const {
|
||||
updatePerformanceMetricsSection,
|
||||
} = stateMod;
|
||||
|
||||
// #2893 — strict canonical filter: `{padded_phase}-{NN}-PLAN.md` or `PLAN.md`.
|
||||
const isCanonicalPlanFile = (f: string): boolean => f.endsWith('-PLAN.md') || f === 'PLAN.md';
|
||||
|
||||
// Any .md file with PLAN anywhere in the basename — diagnostic net
|
||||
const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i;
|
||||
const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i;
|
||||
@@ -243,11 +240,29 @@ function cmdPhasesList(cwd: string, options: PhaseListOptions, raw: boolean): vo
|
||||
|
||||
let filtered: string[];
|
||||
if (type === 'plans') {
|
||||
filtered = dirFiles.filter(isCanonicalPlanFile);
|
||||
// #3183: this is a "what plan files physically exist" query (this
|
||||
// IS the file-listing command), not a live-completion question, so
|
||||
// it uses the single owner's allPlanFiles (root+nested, INCLUDING
|
||||
// status: superseded) rather than a root-only readdirSync filter
|
||||
// that also missed nested plans.
|
||||
//
|
||||
// #2893 (regression fix): `allPlanFiles` also carries
|
||||
// `isRootPlanFile`'s loose `/PLAN/i` fallback (deliberately
|
||||
// permissive for live-plan COUNTING elsewhere — see
|
||||
// plan-count-single-owner.test.cjs). That fallback silently
|
||||
// recognized a non-canonically-named file (e.g.
|
||||
// `01-PLAN-01-foundation.md`) as "matched", which defeated this
|
||||
// command's #2893 naming-convention diagnostic entirely (no
|
||||
// warning, file listed as if valid). Intersect with the STRICT
|
||||
// `isCanonicalPlanFile` predicate so this diagnostic — and the
|
||||
// `files` list this command actually returns — only ever
|
||||
// recognizes the canonical root/nested forms, exactly like the
|
||||
// pre-#3183 behavior this feature was built and tested against.
|
||||
filtered = scanPhasePlans(dirPath).allPlanFiles.filter(isCanonicalPlanFile);
|
||||
const w = describeNonCanonicalPlans(dirFiles, filtered);
|
||||
if (w) warnings.push(`${dir}: ${w}`);
|
||||
} else if (type === 'summaries') {
|
||||
filtered = dirFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
||||
filtered = scanPhasePlans(dirPath).summaryFiles;
|
||||
} else {
|
||||
filtered = dirFiles;
|
||||
}
|
||||
@@ -471,9 +486,31 @@ function cmdFindPhase(cwd: string, phase: string, raw: boolean): void {
|
||||
|
||||
const phaseDir = path.join(searchDir, match);
|
||||
const phaseFiles = fs.readdirSync(phaseDir);
|
||||
const plans = phaseFiles.filter(isCanonicalPlanFile).sort();
|
||||
const summaries = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort();
|
||||
const planNamingWarning = describeNonCanonicalPlans(phaseFiles, plans);
|
||||
// #3183: canonical, live (superseded-excluded) plan/summary sets
|
||||
// (root+nested) from the single owner, rather than a root-only
|
||||
// isCanonicalPlanFile filter + hand-rolled summary filter.
|
||||
//
|
||||
// #2893 (regression fix): both `plans` and the naming-diagnostic
|
||||
// "matched" set are further intersected with the STRICT
|
||||
// `isCanonicalPlanFile` predicate — scanPhasePlans's own
|
||||
// planFiles/allPlanFiles carry `isRootPlanFile`'s loose `/PLAN/i`
|
||||
// fallback (deliberately permissive for live-plan COUNTING elsewhere),
|
||||
// which silently recognized a non-canonically-named file (e.g.
|
||||
// `01-PLAN-01-foundation.md`) as a valid plan here and defeated this
|
||||
// command's #2893 naming-convention diagnostic (no warning, offender
|
||||
// listed in `plans` as if valid).
|
||||
const phaseScan = scanPhasePlans(phaseDir);
|
||||
const plans = phaseScan.planFiles.filter(isCanonicalPlanFile).sort();
|
||||
const summaries = phaseScan.summaryFiles.slice().sort();
|
||||
// describeNonCanonicalPlans is a NAMING-CONVENTION diagnostic, unrelated
|
||||
// to supersession — compare against allPlanFiles (every plan-shaped file
|
||||
// the owner recognizes, canonical or not) rather than the live-only
|
||||
// `plans`, so a superseded-but-canonically-named plan is not misreported
|
||||
// as a naming violation.
|
||||
const planNamingWarning = describeNonCanonicalPlans(
|
||||
phaseFiles,
|
||||
phaseScan.allPlanFiles.filter(isCanonicalPlanFile),
|
||||
);
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
found: true,
|
||||
@@ -630,23 +667,52 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
|
||||
}
|
||||
void phaseDirName; // used only to set phaseDir above
|
||||
|
||||
// phaseFiles stays root-only readdirSync — it feeds only
|
||||
// describeNonCanonicalPlans's near-miss naming diagnostic below, which is
|
||||
// advisory text, not a counted/scheduled file set.
|
||||
const phaseFiles = fs.readdirSync(phaseDir);
|
||||
const planFiles = phaseFiles.filter(isCanonicalPlanFile).sort();
|
||||
const summaryFiles = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
||||
const planNamingWarning = describeNonCanonicalPlans(phaseFiles, planFiles);
|
||||
|
||||
const completedPlanIds = new Set(
|
||||
summaryFiles.flatMap((s) => {
|
||||
const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
||||
const canonical = extractCanonicalPlanId(s);
|
||||
return canonical === exact ? [exact] : [exact, canonical];
|
||||
}),
|
||||
// #3183 (highest-severity site, ADR-3180 Decision 2): canonical LIVE
|
||||
// plan/summary sets (root+nested, status: superseded EXCLUDED) from the
|
||||
// single owner. This fixes two real bugs in the wave/dependency index this
|
||||
// function builds: (1) a superseded plan used to still get scheduled into
|
||||
// an execution wave, and (2) a phase using the #3139 nested `plans/`
|
||||
// layout used to report ZERO plans (root-only readdirSync, no `plans/`
|
||||
// join).
|
||||
// #2893 (regression fix): intersected with the STRICT `isCanonicalPlanFile`
|
||||
// predicate — scanPhasePlans's own planFiles/allPlanFiles carry
|
||||
// `isRootPlanFile`'s loose `/PLAN/i` fallback (deliberately permissive for
|
||||
// live-plan COUNTING elsewhere), which silently scheduled a
|
||||
// non-canonically-named file (e.g. `01-PLAN-01-foundation.md`) into a wave
|
||||
// here and defeated this command's #2893 naming-convention diagnostic (no
|
||||
// warning). Restores the pre-#3183, tested behavior: only canonical
|
||||
// root/nested filenames are ever counted or scheduled by this command.
|
||||
const phaseScan = scanPhasePlans(phaseDir);
|
||||
const planFiles = phaseScan.planFiles.filter(isCanonicalPlanFile).sort();
|
||||
const summaryFiles = phaseScan.summaryFiles;
|
||||
// describeNonCanonicalPlans is a NAMING-CONVENTION diagnostic, unrelated to
|
||||
// supersession — compare against allPlanFiles (every plan-shaped file the
|
||||
// owner recognizes, canonical or not) rather than the live-only planFiles,
|
||||
// so a superseded-but-canonically-named plan is not misreported as a
|
||||
// naming violation.
|
||||
const planNamingWarning = describeNonCanonicalPlans(
|
||||
phaseFiles,
|
||||
phaseScan.allPlanFiles.filter(isCanonicalPlanFile),
|
||||
);
|
||||
|
||||
// #3183: completion pairing via the canonical findUnsummarizedPlans
|
||||
// (shares its `summaryCandidates` matching rule with countMatchedSummaries,
|
||||
// and is layout-agnostic — it pairs a nested `plans/PLAN-01.md` with
|
||||
// `plans/SUMMARY-01.md` correctly) instead of a bespoke ID-Set built from
|
||||
// extractCanonicalPlanId, which only ever handled the root-canonical
|
||||
// `-PLAN.md`/`-SUMMARY.md` naming form.
|
||||
const unsummarizedPlanFiles = new Set(findUnsummarizedPlans(planFiles, summaryFiles));
|
||||
// #2830: reverse lookup from a completed plan's id (exact or canonical) to
|
||||
// the actual summary filename, so a plan's own SUMMARY frontmatter can be
|
||||
// read for its `status`. Shared builder (also used by phase-locator.cts's
|
||||
// searchPhaseInDir) so the two can never disagree about which summary
|
||||
// belongs to which plan.
|
||||
// belongs to which plan. This is a FILE resolution for reading halted
|
||||
// status, not a completion-count pairing rule, so it is unaffected by the
|
||||
// #3183 pairing migration above.
|
||||
const summaryFileByPlanId = buildSummaryFileIndex(summaryFiles, extractCanonicalPlanId);
|
||||
|
||||
// ── Pass 1: parse each plan file ─────────────────────────────────────────
|
||||
@@ -688,8 +754,7 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void {
|
||||
filesModified = Array.isArray(fmFiles) ? fmFiles.map(String) : [String(fmFiles)];
|
||||
}
|
||||
|
||||
const hasSummary =
|
||||
completedPlanIds.has(planId) || completedPlanIds.has(extractCanonicalPlanId(planFile));
|
||||
const hasSummary = !unsummarizedPlanFiles.has(planFile);
|
||||
|
||||
// #2830: a plan can have a SUMMARY (hasSummary=true) and still be halted —
|
||||
// a designed stop still writes a completion record, just one whose status
|
||||
@@ -1629,11 +1694,14 @@ function cmdPhaseRemove(
|
||||
const targetDir = subdirs.find((d) => phaseTokenMatches(d, normalized)) || null;
|
||||
|
||||
if (targetDir && !force) {
|
||||
const files = fs.readdirSync(path.join(phasesDir, targetDir));
|
||||
const summaries = files.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
||||
if (summaries.length > 0) {
|
||||
// #3183: canonical summary set (root+nested) from the single owner —
|
||||
// a root-only readdirSync filter left nested (#3139 layout) summaries
|
||||
// invisible, letting a phase with completed nested work be deleted
|
||||
// without --force.
|
||||
const summaryCount = scanPhasePlans(path.join(phasesDir, targetDir)).summaryFiles.length;
|
||||
if (summaryCount > 0) {
|
||||
error(
|
||||
`Phase ${targetPhase} has ${summaries.length} executed plan(s). Use --force to remove anyway.`,
|
||||
`Phase ${targetPhase} has ${summaryCount} executed plan(s). Use --force to remove anyway.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2810,7 +2878,7 @@ function cmdPhaseUatPassed(
|
||||
// paths without re-discovering the phase directory themselves.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
|
||||
import planScanMod = require('./plan-scan.cjs');
|
||||
const { scanPhasePlans } = planScanMod;
|
||||
const { scanPhasePlans, isCanonicalPlanFile } = planScanMod;
|
||||
|
||||
function cmdPhaseListPlans(cwd: string, phaseNum: string | undefined, raw: boolean): void {
|
||||
if (!phaseNum) {
|
||||
|
||||
@@ -15,6 +15,9 @@ const { countMatchedSummaries } = coreUtils;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import frontmatterMod = require('./frontmatter.cjs');
|
||||
const { extractFrontmatter } = frontmatterMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningScopeMod = require('./planning-scope.cjs');
|
||||
const { SCOPE } = planningScopeMod;
|
||||
|
||||
// Excluded derivative files
|
||||
const PLAN_OUTLINE_RE = /-OUTLINE\.md$/i;
|
||||
@@ -94,13 +97,58 @@ function isNestedSummaryFile(fileName: string): boolean {
|
||||
return /^SUMMARY-\d+.*\.md$/i.test(fileName) || /-SUMMARY-\d+.*\.md$/i.test(fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict canonical-naming predicate over a `scanPhasePlans` `planFiles`/
|
||||
* `allPlanFiles` ENTRY (root form bare, nested form `plans/`-prefixed, exactly
|
||||
* as those arrays store them) — root `<phase>-<NN>-PLAN.md`/bare `PLAN.md`,
|
||||
* or nested `plans/PLAN-<NN>....md`/`plans/<x>-PLAN-<NN>....md` — WITHOUT
|
||||
* `isRootPlanFile`'s loose `/\.md$/i && /PLAN/i` fallback.
|
||||
*
|
||||
* The `plans/` prefix check is load-bearing, not cosmetic: `isNestedPlanFile`
|
||||
* matches ANY basename containing `-PLAN-<digits>...md` with no anchor
|
||||
* requiring an actual `plans/` directory — that shape is exactly the #2893
|
||||
* reporter's non-canonical example, `01-PLAN-01-foundation.md`. Applying
|
||||
* `isNestedPlanFile` directly to a bare root-level name would therefore
|
||||
* misclassify that exact offender as canonical. Only entries scanPhasePlans
|
||||
* itself produced with the `plans/` prefix (i.e. read from the real nested
|
||||
* subdirectory) are eligible for the nested check.
|
||||
*
|
||||
* #2893/#3183: `isRootPlanFile`'s loose fallback is deliberately permissive
|
||||
* for live-plan COUNTING (a lowercase `plan.md` still counts toward
|
||||
* completion — see plan-count-single-owner.test.cjs's pinned case-sensitivity
|
||||
* asymmetry). But the #2893 "non-canonical filename" diagnostic (phase.cts's
|
||||
* `describeNonCanonicalPlans`, used by find-phase/phase-plan-index/phases
|
||||
* list --type plans) exists specifically to CATCH a plan-shaped file that
|
||||
* does NOT match the canonical contract and warn instead of silently
|
||||
* scheduling it. Feeding that diagnostic (and the `plans`/`files` lists those
|
||||
* commands return) the loose `allPlanFiles`/`planFiles` set defeats the
|
||||
* diagnostic entirely, since the loose fallback already recognizes the
|
||||
* non-canonical file as "matched". This predicate is the STRICT filter those
|
||||
* three call sites intersect against so the diagnostic (and what counts as a
|
||||
* schedulable plan for those commands specifically) stays canonical-only,
|
||||
* while scanPhasePlans's own planCount/summaryCount/completed stay on the
|
||||
* loose, permissive rule.
|
||||
*/
|
||||
function isCanonicalPlanFile(fileEntry: string): boolean {
|
||||
if (fileEntry.startsWith('plans/')) return isNestedPlanFile(fileEntry.slice('plans/'.length));
|
||||
return fileEntry.endsWith('-PLAN.md') || fileEntry === 'PLAN.md';
|
||||
}
|
||||
|
||||
interface PhaseScanResult {
|
||||
planCount: number;
|
||||
summaryCount: number;
|
||||
completed: boolean;
|
||||
hasNestedPlans: boolean;
|
||||
// Callers asking "which plans are OUTSTANDING" (live-completion tracking,
|
||||
// pairing, wave scheduling) use planFiles — it is post status:superseded
|
||||
// exclusion. Callers asking "what plan files physically exist on disk"
|
||||
// (e.g. numbering-gap detection) use allPlanFiles — it is EVERY plan file
|
||||
// found, root + nested, BEFORE the superseded exclusion. One owner, two
|
||||
// questions.
|
||||
planFiles: string[];
|
||||
allPlanFiles: string[];
|
||||
summaryFiles: string[];
|
||||
scope: planningScopeMod.Scope;
|
||||
}
|
||||
|
||||
function scanPhasePlans(phaseDir: string): PhaseScanResult {
|
||||
@@ -114,7 +162,9 @@ function scanPhasePlans(phaseDir: string): PhaseScanResult {
|
||||
completed: false,
|
||||
hasNestedPlans: false,
|
||||
planFiles: [],
|
||||
allPlanFiles: [],
|
||||
summaryFiles: [],
|
||||
scope: SCOPE.UNREADABLE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,6 +173,7 @@ function scanPhasePlans(phaseDir: string): PhaseScanResult {
|
||||
let nestedPlanFiles: string[] = [];
|
||||
let nestedSummaryFiles: string[] = [];
|
||||
let hasNestedPlans = false;
|
||||
let scope: planningScopeMod.Scope = SCOPE.COMPLETE;
|
||||
|
||||
const nestedDir = join(phaseDir, 'plans');
|
||||
if (existsSync(nestedDir)) {
|
||||
@@ -131,7 +182,12 @@ function scanPhasePlans(phaseDir: string): PhaseScanResult {
|
||||
nestedPlanFiles = nestedFiles.filter(isNestedPlanFile).map((file) => `plans/${file}`);
|
||||
nestedSummaryFiles = nestedFiles.filter(isNestedSummaryFile).map((file) => `plans/${file}`);
|
||||
hasNestedPlans = nestedPlanFiles.length > 0;
|
||||
} catch { /* ignore unreadable nested layout */ }
|
||||
} catch {
|
||||
// #3183 (ADR-3180 Decision 2): the nested plans/ dir exists but could not
|
||||
// be read — this scan cannot see plans it knows are there, so zero is
|
||||
// NOT a reliable answer; mark TRUNCATED rather than COMPLETE.
|
||||
scope = SCOPE.TRUNCATED;
|
||||
}
|
||||
}
|
||||
|
||||
const allPlanFiles = rootPlanFiles.concat(nestedPlanFiles);
|
||||
@@ -166,7 +222,9 @@ function scanPhasePlans(phaseDir: string): PhaseScanResult {
|
||||
completed: allPlanFiles.length > 0 && summaryCount >= planCount,
|
||||
hasNestedPlans,
|
||||
planFiles,
|
||||
allPlanFiles,
|
||||
summaryFiles,
|
||||
scope,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,4 +237,5 @@ export = Object.assign(scanPhasePlans, {
|
||||
isNestedPlanFile,
|
||||
isRootSummaryFile,
|
||||
isNestedSummaryFile,
|
||||
isCanonicalPlanFile,
|
||||
});
|
||||
|
||||
49
src/planning-scope.cts
Normal file
49
src/planning-scope.cts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Planning Scope — shared result discriminator for live-plan scanning.
|
||||
*
|
||||
* ADR-3180 Decision 2 (docs/adr/3180-planning-semantic-model-single-owner.md):
|
||||
* `scanPhasePlans` is the single owner of live-plan counting, and every
|
||||
* consumer of that count needs to distinguish a REAL answer from a
|
||||
* NON-answer. `SCOPE.COMPLETE` with zero items is a real answer — a phase
|
||||
* genuinely has no plans yet. `SCOPE.TRUNCATED`, `SCOPE.UNSCOPED`, and
|
||||
* `SCOPE.UNREADABLE` with zero items are NOT — they mean the scan could not
|
||||
* see (part of) the phase directory, so a caller must not treat that zero as
|
||||
* "this phase has no plans."
|
||||
*
|
||||
* This is a frozen enum, not a message string: CONTRIBUTING.md bans raw-text
|
||||
* matching on outputs and requires a typed IR, so callers branch on the
|
||||
* `SCOPE` value rather than pattern-matching prose.
|
||||
*
|
||||
* PROVISIONAL: this contract is pending this phase's validation and may be
|
||||
* revised before the epic (#3180) ships.
|
||||
*
|
||||
* Dependencies: none — this is a leaf module (mirrors src/phase-id.cts). It
|
||||
* imports nothing, so any consumer can depend on it without risking a cycle.
|
||||
*/
|
||||
|
||||
const SCOPE = Object.freeze({
|
||||
COMPLETE: 'complete',
|
||||
TRUNCATED: 'truncated',
|
||||
UNSCOPED: 'unscoped',
|
||||
UNREADABLE: 'unreadable',
|
||||
});
|
||||
|
||||
type Scope = (typeof SCOPE)[keyof typeof SCOPE];
|
||||
|
||||
const planningScope = { SCOPE };
|
||||
// Namespace merge (same binding name as the value above) is how a CommonJS
|
||||
// `export =` module exposes a type alongside its runtime export — `export
|
||||
// type` is rejected by TS2309 ("An export assignment cannot be used in a
|
||||
// module with other exported elements") when combined with `export =`, so
|
||||
// the `Scope` type rides along on the exported object via declaration
|
||||
// merging instead. Consumers doing `import x = require('./planning-scope.cjs')`
|
||||
// can reference the type as `x.Scope`.
|
||||
// Required to merge a compile-time-only type onto the `export =` runtime
|
||||
// value; there is no ES-module-syntax way to export a type alongside a CJS
|
||||
// `export =`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
declare namespace planningScope {
|
||||
export { Scope };
|
||||
}
|
||||
|
||||
export = planningScope;
|
||||
@@ -31,6 +31,9 @@ const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter } = frontma
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import scanPhasePlans = require('./plan-scan.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningScopeMod = require('./planning-scope.cjs');
|
||||
const { SCOPE } = planningScopeMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import stateTransitionMod = require('./state-transition.cjs');
|
||||
const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } = stateTransitionMod;
|
||||
type StateTransitionIntent = stateTransitionMod.StateTransitionIntent;
|
||||
@@ -3179,9 +3182,21 @@ function cmdStateRebuild(cwd: string, options: StateRebuildOptions, raw: boolean
|
||||
// Directory-name convention: `<NN>-<slug>` (e.g. `03-test-phase`).
|
||||
const m = entry.match(/^(\d+)-(.+)$/);
|
||||
if (!m) continue;
|
||||
const files = fs.readdirSync(full);
|
||||
const planCount = files.filter(f => /-PLAN\.md$/i.test(f)).length;
|
||||
const summaryCount = files.filter(f => /-SUMMARY\.md$/i.test(f)).length;
|
||||
// #3183 (lint-plan-count-drift / ADR-3180 Decision 2): source
|
||||
// planCount/summaryCount from the single owner (scanPhasePlans)
|
||||
// instead of a local root-only `-PLAN.md`/`-SUMMARY.md` readdirSync
|
||||
// filter — picks up bare PLAN.md/SUMMARY.md and nested plans/. A
|
||||
// non-COMPLETE scope (TRUNCATED: nested plans/ unreadable;
|
||||
// UNREADABLE: `full` itself unreadable) is not a trustworthy count —
|
||||
// throw so it surfaces via the outer catch as a real scan failure
|
||||
// (`ok:false`), mirroring the #3057 B1 contract documented above for
|
||||
// the sibling `fs.readdirSync(phasesDir)` failure mode, rather than
|
||||
// silently reporting an undercount.
|
||||
const scan = scanPhasePlans(full);
|
||||
if (scan.scope !== SCOPE.COMPLETE) {
|
||||
throw new Error(`could not fully scan plan directory (scope ${scan.scope}): ${full}`);
|
||||
}
|
||||
const { planCount, summaryCount } = scan;
|
||||
records.push({ number: m[1], name: m[2], planCount, summaryCount });
|
||||
}
|
||||
return { ok: true, phases: records };
|
||||
|
||||
155
src/verify.cts
155
src/verify.cts
@@ -11,7 +11,7 @@ import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { phaseVariants, buildRoadmapPhaseVariants, buildNotStartedPhaseVariants } from './validate.cjs';
|
||||
import { realClock } from './clock.cjs';
|
||||
import { phaseDirNameRe, PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE, canonicalPlanStem, textEncodingError } from './validate.cjs';
|
||||
import { phaseDirNameRe, PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE, textEncodingError } from './validate.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
||||
@@ -22,6 +22,12 @@ import stateMod = require('./state.cjs');
|
||||
import modelProfilesMod = require('./model-profiles.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
|
||||
import planScanMod = require('./plan-scan.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
|
||||
import coreUtilsMod = require('./core-utils.cjs');
|
||||
const { findOrphanSummaries, findUnsummarizedPlans } = coreUtilsMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-scope.cjs is an export= CommonJS module
|
||||
import planningScopeMod = require('./planning-scope.cjs');
|
||||
const { SCOPE } = planningScopeMod;
|
||||
import { execGit, platformReadSync as safeReadFile, platformWriteSync, posixNormalize } from './shell-command-projection.cjs';
|
||||
import { PACKAGE_NAME } from './package-identity.cjs';
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
@@ -940,26 +946,28 @@ function cmdVerifyPhaseCompleteness(cwd: string, phase: string, raw: boolean): v
|
||||
const warnings: string[] = [];
|
||||
const phaseDir = path.join(cwd, phaseInfo['directory'] as string);
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = fs.readdirSync(phaseDir);
|
||||
} catch {
|
||||
// #3183 (lint-plan-count-drift / ADR-3180 Decision 2): source plans/
|
||||
// summaries and their pairing from the single owner (scanPhasePlans +
|
||||
// findUnsummarizedPlans/findOrphanSummaries) instead of a bespoke
|
||||
// root-only `-PLAN.md`/`-SUMMARY.md` filter and exact-suffix-stem
|
||||
// Set-diff. The prior pairing missed bare PLAN.md/SUMMARY.md, nested
|
||||
// (#3139 layout) plans, and any of the canonical pairing's other
|
||||
// recognized naming forms — producing false "Plans without summaries" /
|
||||
// "Summaries without plans" for names it could not recognize as paired
|
||||
// (the same failure class #1988/#2648 fixed for the owner's own callers).
|
||||
const scan = planScanMod.scanPhasePlans(phaseDir);
|
||||
if (scan.scope === SCOPE.UNREADABLE) {
|
||||
output({ error: 'Cannot read phase directory' }, raw);
|
||||
return;
|
||||
}
|
||||
const { planFiles, summaryFiles } = scan;
|
||||
|
||||
const plans = files.filter((f) => f.match(/-PLAN\.md$/i));
|
||||
const summaries = files.filter((f) => f.match(/-SUMMARY\.md$/i));
|
||||
|
||||
const planIds = new Set(plans.map((p) => p.replace(/-PLAN\.md$/i, '')));
|
||||
const summaryIds = new Set(summaries.map((s) => s.replace(/-SUMMARY\.md$/i, '')));
|
||||
|
||||
const incompletePlans = [...planIds].filter((id) => !summaryIds.has(id));
|
||||
const incompletePlans = findUnsummarizedPlans(planFiles, summaryFiles);
|
||||
if (incompletePlans.length > 0) {
|
||||
errors.push(`Plans without summaries: ${incompletePlans.join(', ')}`);
|
||||
}
|
||||
|
||||
const orphanSummaries = [...summaryIds].filter((id) => !planIds.has(id));
|
||||
const orphanSummaries = findOrphanSummaries(planFiles, summaryFiles);
|
||||
if (orphanSummaries.length > 0) {
|
||||
warnings.push(`Summaries without plans: ${orphanSummaries.join(', ')}`);
|
||||
}
|
||||
@@ -968,8 +976,8 @@ function cmdVerifyPhaseCompleteness(cwd: string, phase: string, raw: boolean): v
|
||||
{
|
||||
complete: errors.length === 0,
|
||||
phase: phaseInfo['phase_number'],
|
||||
plan_count: plans.length,
|
||||
summary_count: summaries.length,
|
||||
plan_count: planFiles.length,
|
||||
summary_count: summaryFiles.length,
|
||||
incomplete_plans: incompletePlans,
|
||||
orphan_summaries: orphanSummaries,
|
||||
errors,
|
||||
@@ -1461,15 +1469,42 @@ function cmdValidateConsistency(cwd: string, raw: boolean): void {
|
||||
for (const dir of dirs) {
|
||||
const phasePath = path.join(phaseRoot, dir);
|
||||
const phaseLabel = posixNormalize(path.relative(planBase, phasePath));
|
||||
const phaseFiles = fs.readdirSync(phasePath);
|
||||
const plans = phaseFiles.filter((f) => f.endsWith('-PLAN.md')).sort();
|
||||
|
||||
const planNums = plans
|
||||
// #3183: this loop mixes two DIFFERENT questions — split explicitly
|
||||
// rather than migrating it as one blind swap-in.
|
||||
|
||||
// QUESTION 1 — physical numbering-gap detection: wants EVERY plan
|
||||
// file that physically exists, superseded or not (a retired plan
|
||||
// still occupied a number in the sequence), root+nested. Uses the
|
||||
// single owner's allPlanFiles rather than a root-only readdirSync
|
||||
// filter. The strict `-NN-PLAN.md` suffix regex below already
|
||||
// ignores any entry (nested, loose-named, bare PLAN.md) that isn't
|
||||
// in the root canonical numbered form, so widening the input set is
|
||||
// a pure visibility fix with no change to which files feed a number.
|
||||
//
|
||||
// One scan serves both questions below: `allPlanFiles` answers
|
||||
// Question 1 (numbering-gap), `planFiles`/`summaryFiles` answer
|
||||
// Question 2 (pairing) a few lines down.
|
||||
const { allPlanFiles, planFiles, summaryFiles } = planScanMod.scanPhasePlans(phasePath);
|
||||
|
||||
// Root-canonical numbered plans only (`<phase>-NN-PLAN.md`) — the
|
||||
// shape the numbering-gap sequence check operates on. Matched via
|
||||
// the numbering regex itself rather than a separate suffix filter,
|
||||
// so this stays a single derivation from the owner's output, not a
|
||||
// second independent re-derivation of its filename grammar.
|
||||
const numberedPlans = allPlanFiles
|
||||
.map((p) => {
|
||||
const pm = p.match(/-(\d{2})-PLAN\.md$/);
|
||||
return pm ? parseInt(pm[1], 10) : null;
|
||||
return pm ? { file: p, num: parseInt(pm[1], 10) } : null;
|
||||
})
|
||||
.filter((n): n is number => n !== null);
|
||||
.filter((e): e is { file: string; num: number } => e !== null)
|
||||
.sort((a, b) => a.num - b.num);
|
||||
// numberedPlans (and planNums below) answers Question 1 ONLY — the
|
||||
// numbering-gap sequence check. It is a strict `-NN-PLAN.md` subset
|
||||
// and must NOT be reused as a general "all live plans" set: a plan
|
||||
// whose filename isn't in that canonical 2-digit form (a 3-digit
|
||||
// continuation, a bare PLAN.md, etc.) is silently absent from it.
|
||||
const planNums = numberedPlans.map((e) => e.num);
|
||||
|
||||
for (let i = 1; i < planNums.length; i++) {
|
||||
if (planNums[i] !== planNums[i - 1] + 1) {
|
||||
@@ -1479,17 +1514,25 @@ function cmdValidateConsistency(cwd: string, raw: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
const summaries = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md'));
|
||||
const planIds = new Set(plans.map((p) => p.replace('-PLAN.md', '')));
|
||||
const summaryIds = new Set(summaries.map((s) => s.replace('-SUMMARY.md', '')));
|
||||
|
||||
for (const sid of summaryIds) {
|
||||
if (!planIds.has(sid)) {
|
||||
warnings.push(`Summary ${sid}-SUMMARY.md in ${phaseLabel} has no matching PLAN.md`);
|
||||
}
|
||||
// QUESTION 2 — plan↔summary pairing: "does this summary have a
|
||||
// matching LIVE plan" wants the single owner's superseded-excluded
|
||||
// planFiles and the canonical summaryCandidates-based pairing
|
||||
// (findOrphanSummaries) instead of an exact-suffix Set-diff, which
|
||||
// produced false "orphan summary" warnings for legacy/nested naming
|
||||
// forms it could not recognize as paired.
|
||||
const orphanSummaries = findOrphanSummaries(planFiles, summaryFiles);
|
||||
for (const orphan of orphanSummaries) {
|
||||
warnings.push(`Summary ${orphan} in ${phaseLabel} has no matching PLAN.md`);
|
||||
}
|
||||
|
||||
for (const plan of plans) {
|
||||
// QUESTION 3 — wave-frontmatter presence: "does every LIVE plan
|
||||
// declare a wave" wants the same live (superseded-excluded) set as
|
||||
// Question 2's pairing check — planFiles, NOT numberedPlans/Question
|
||||
// 1's strict 2-digit subset. A superseded plan legitimately carries
|
||||
// no wave, and a live plan whose filename isn't in canonical 2-digit
|
||||
// form (a 3-digit continuation, a bare PLAN.md, etc.) must still be
|
||||
// checked here even though it is invisible to the numbering-gap scan.
|
||||
for (const plan of planFiles) {
|
||||
const planFilePath = path.join(phasePath, plan);
|
||||
const content = fs.readFileSync(planFilePath, 'utf-8');
|
||||
const fmData = extractFrontmatter(content, planFilePath);
|
||||
@@ -1737,6 +1780,14 @@ function cmdValidateHealth(
|
||||
|
||||
let phaseDirEntries: fs.Dirent[] = [];
|
||||
const phaseDirFiles = new Map<string, string[]>();
|
||||
// #3183: companion map of the single owner's scan per phase dir
|
||||
// (root+nested, superseded-excluded plan/summary sets + canonical
|
||||
// pairing), computed alongside the raw readdirSync listing above. The
|
||||
// W023 duplicate-dir describer and the I001 unsummarized-plan detector
|
||||
// below use THIS map for plan/summary counts and pairing; phaseDirFiles
|
||||
// stays raw for the RESEARCH/VALIDATION and phase-dir-naming checks that
|
||||
// are not plan-count questions.
|
||||
const phaseDirScans = new Map<string, ReturnType<typeof planScanMod.scanPhasePlans>>();
|
||||
try {
|
||||
phaseDirEntries = fs
|
||||
.readdirSync(phasesDir, { withFileTypes: true })
|
||||
@@ -1747,6 +1798,7 @@ function cmdValidateHealth(
|
||||
} catch {
|
||||
phaseDirFiles.set(e.name, []);
|
||||
}
|
||||
phaseDirScans.set(e.name, planScanMod.scanPhasePlans(path.join(phasesDir, e.name)));
|
||||
}
|
||||
} catch {
|
||||
/* intentionally empty */
|
||||
@@ -1793,9 +1845,11 @@ function cmdValidateHealth(
|
||||
.slice()
|
||||
.sort((a, b) => comparePhaseNum(a, b) || String(a).localeCompare(String(b)))
|
||||
.map((d) => {
|
||||
const files = phaseDirFiles.get(d) || [];
|
||||
const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
|
||||
const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
|
||||
// #3183: canonical plan/summary counts (root+nested,
|
||||
// superseded-excluded, canonical pairing) from the single owner.
|
||||
const scan = phaseDirScans.get(d);
|
||||
const plans = scan ? scan.planCount : 0;
|
||||
const summaries = scan ? scan.summaryCount : 0;
|
||||
const status = determinePhaseStatus(plans, summaries, path.join(phasesDir, d), 'Not Started');
|
||||
return `${d} (${status})`;
|
||||
})
|
||||
@@ -1809,23 +1863,18 @@ function cmdValidateHealth(
|
||||
}
|
||||
}
|
||||
|
||||
// I001 (#3183): this IS findUnsummarizedPlans's exact question — routed
|
||||
// through the single owner's scan (root+nested, superseded-excluded plan
|
||||
// set) and the canonical summaryCandidates-based pairing, instead of a
|
||||
// bespoke canonicalPlanStem reimplementation. Fixes: a superseded plan is
|
||||
// no longer permanently flagged "may be in progress" (false noise
|
||||
// forever), and nested (#3139 layout) plans are no longer invisible.
|
||||
for (const e of phaseDirEntries) {
|
||||
const phaseFiles = phaseDirFiles.get(e.name) || [];
|
||||
const plans = phaseFiles.filter((f) => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
||||
const summaries = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
||||
const summaryBases = new Set<string>();
|
||||
for (const s of summaries) {
|
||||
const summaryBase = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
||||
summaryBases.add(summaryBase);
|
||||
summaryBases.add(canonicalPlanStem(summaryBase));
|
||||
}
|
||||
|
||||
for (const plan of plans) {
|
||||
const planBase = plan.replace('-PLAN.md', '').replace('PLAN.md', '');
|
||||
const canonicalBase = canonicalPlanStem(planBase);
|
||||
if (!summaryBases.has(planBase) && !summaryBases.has(canonicalBase)) {
|
||||
addIssue('info', 'I001', `${e.name}/${plan} has no SUMMARY.md`, 'May be in progress');
|
||||
}
|
||||
const scan = phaseDirScans.get(e.name);
|
||||
const planFiles = scan ? scan.planFiles : [];
|
||||
const summaryFiles = scan ? scan.summaryFiles : [];
|
||||
for (const plan of findUnsummarizedPlans(planFiles, summaryFiles)) {
|
||||
addIssue('info', 'I001', `${e.name}/${plan} has no SUMMARY.md`, 'May be in progress');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2473,8 +2522,15 @@ function cmdVerifySchemaDrift(
|
||||
return;
|
||||
}
|
||||
|
||||
// #3183: canonical LIVE plan/summary sets (root+nested,
|
||||
// status: superseded EXCLUDED) from the single owner, rather than a
|
||||
// root-only readdirSync filter — a superseded plan's claimed
|
||||
// files_modified is no longer treated as an expected drift target, and
|
||||
// nested (#3139 layout) plans/summaries are no longer invisible to the
|
||||
// drift check.
|
||||
const { planFiles, summaryFiles } = planScanMod.scanPhasePlans(phaseDir);
|
||||
|
||||
const allFiles: string[] = [];
|
||||
const planFiles = fs.readdirSync(phaseDir).filter((f) => f.endsWith('-PLAN.md'));
|
||||
for (const pf of planFiles) {
|
||||
const content = fs.readFileSync(path.join(phaseDir, pf), 'utf-8');
|
||||
const fmMatch = content.match(/files_modified:\s*\[([^\]]{0,8000})\]/);
|
||||
@@ -2485,7 +2541,6 @@ function cmdVerifySchemaDrift(
|
||||
}
|
||||
|
||||
let executionLog = '';
|
||||
const summaryFiles = fs.readdirSync(phaseDir).filter((f) => f.endsWith('-SUMMARY.md'));
|
||||
for (const sf of summaryFiles) {
|
||||
executionLog += fs.readFileSync(path.join(phaseDir, sf), 'utf-8') + '\n';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user