* 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:
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