fix(#3639): dir-aware sentinel recognition for the disk-side guards (#3698)

* test(#3639): pin bracket sentinel recognition in disk-side guards

* fix(#3639): dir-aware sentinel recognition for the disk-side guards

* chore(#3639): add changeset

* fix(#3639): disclose the digit-continuation residual, join phases-clear, load-bearing over-suppression guard

* test(#3639): match the token form W007 reports

* chore(#3639): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-19 21:55:18 -04:00
committed by GitHub
parent 66228a89cf
commit 71e00d426e
7 changed files with 153 additions and 8 deletions

View File

@@ -43,7 +43,7 @@ type Rule = healthDiagnosticMod.Rule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('../phase-id.cjs');
const { isSentinelPhaseId } = phaseIdMod;
const { isSentinelPhaseDir } = phaseIdMod;
// ─── C001 — gap in disk phase numbering (integer sequence) ────────────────
// (verify.cts:1504-1519)
@@ -57,8 +57,12 @@ function checkC001(snapshot: PlanningSnapshot): Diagnostic[] {
const integerPhases = snapshot.allPhaseDirNames.value
// #3225: exclude sentinel phase ids (999.x/0.x) — never part of the
// sequential numbering, mirrors verify.cts:1510 verbatim.
.filter((p) => !p.includes('.') && !isSentinelPhaseId(p))
// sequential numbering, mirrors verify.cts:1510 verbatim. The dot filter
// already drops every code-prefixed (bracket) name before the sentinel
// test, so the dir-aware recognizer below is defense-in-depth for the
// dotless legacy forms — kept so this guard cannot regress if the dot
// filter is ever loosened (#3639).
.filter((p) => !p.includes('.') && !isSentinelPhaseDir(p))
.map((p) => parseInt(p, 10))
.filter((n) => !Number.isNaN(n))
.sort((a, b) => a - b);

View File

@@ -113,7 +113,7 @@ const { SCOPE } = planningScopeMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('../phase-id.cjs');
const { matchPhaseDirs, normalizePhaseName, extractPhaseToken, isSentinelPhaseId } = phaseIdMod;
const { matchPhaseDirs, normalizePhaseName, extractPhaseToken, isSentinelPhaseId, isSentinelPhaseDir } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import validateMod = require('../validate.cjs');
@@ -248,8 +248,11 @@ function checkW007(snapshot: PlanningSnapshot): Diagnostic[] {
// `verify.cts:1373-1397`) — same token, relocated read, not reinvented.
const token = extractPhaseToken(dirName);
// #3225: a sentinel dir on disk (999-interim, 0-drafts) is defined as
// never-on-roadmap; it must not trigger W007.
if (isSentinelPhaseId(token)) continue;
// never-on-roadmap; it must not trigger W007. #3639: judged on the DIR
// NAME via the dir-aware recognizer — the extracted token is
// milestone-stripped, so a bracket sentinel (GSD.999-07-icebox) was
// invisible to the id predicate and false-fired as an orphan.
if (isSentinelPhaseDir(dirName)) continue;
if (claimedDirs.has(dirName)) continue;
diagnostics.push({
code: 'W007',

View File

@@ -30,7 +30,7 @@ import ioMod = require('./io.cjs');
const { output, error } = ioMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { normalizePhaseName, matchPhaseDirs, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId } = phaseIdMod;
const { normalizePhaseName, matchPhaseDirs, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId, isSentinelPhaseDir } = phaseIdMod;
import { escapeRegex } from './pattern.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserMod = require('./roadmap-parser.cjs');
@@ -1198,7 +1198,11 @@ function cmdPhasesClear(cwd: string, raw: boolean, args: string[]): void {
// divergence meant a `0-*` directory `roadmap analyze` preserves as a
// sentinel was DELETED here. Routed through the canonical predicate so
// every reader of "is this a sentinel phase" agrees by construction.
const dirs = entries.filter((e) => e.isDirectory() && !isSentinelPhaseId(e.name));
// #3639: the DIR-AWARE recognizer — the convention-less id predicate
// never saw bracket sentinel dirs (GSD.999-07-icebox), so they were
// counted for deletion here while the disk guards (post-#3639) preserve
// them; the destructive path must not be the one blind reader left.
const dirs = entries.filter((e) => e.isDirectory() && !isSentinelPhaseDir(e.name));
if (dirs.length > 0 && !confirm) {
error(

View File

@@ -414,6 +414,39 @@ function isSentinelPhaseId(phaseId: unknown, convention?: string): boolean {
return SENTINEL_RANGES.includes(parseInt(legacy[1], 10));
}
/**
* Disk-side sentinel recognizer (#3639): is this on-disk PHASE DIRECTORY a
* sentinel (never-on-roadmap by convention)?
*
* The disk-side guards (C001 gap numbering, W007 orphan dirs) see raw
* directory names and do not know the repo's naming convention — and neither
* convention-blind route could recognize a bracket sentinel: `isSentinelPhaseId`
* without the convention argument reads only the legacy leading int, while
* `extractPhaseToken(dirName)` (convention-aware or not) strips the MILESTONE
* and returns the bare phase token — bracket sentinel-ness lives in the
* milestone portion (`GSD.999-07-icebox` is icebox because of the 999, not
* the 07). This helper reads the milestone directly off the dir name.
*
* The bracket branch requires the FULL bracket dir shape — code prefix, dot,
* milestone digits, hyphen, PHASE DIGITS — so a #1324 letter-prefixed real
* dir with a LETTER slug (`P0.0-foundation`) never matches it (ADR-2121
* indistinguishability, same gate as extractPhaseToken below). DISCLOSED
* RESIDUAL (#3639 review): the #1324 family also has digit continuations
* (`P0.0-1-foundation`, `P0.3-2` are real shapes per derivePhaseTokenSegments),
* and `{code}.{0|999}-{digit}...` is string-indistinguishable from a bracket
* sentinel dir — no convention-free discriminator exists (ADR-2121). Such a
* dir reads as sentinel here, which at the disk-guard call sites suppresses
* a warning (conservative for a linter) rather than deleting data. The
* digit-continuation family with NON-sentinel first decimals (`P0.3-2`)
* reads milestone 3 — ordinary — exactly as the convention-gated id
* predicate does. Everything else falls to the legacy leading-int rule.
*/
function isSentinelPhaseDir(dirName: string): boolean {
const bracketDir = dirName.match(/^[A-Z][A-Z0-9_]*\.(\d+)-\d/); // milestone digits + hyphen + phase DIGITS
if (bracketDir) return SENTINEL_RANGES.includes(parseInt(bracketDir[1], 10));
return isSentinelPhaseId(dirName);
}
/**
* Render a regex source fragment matching a phase number against ROADMAP/STATE
* prose regardless of zero-padding on either side.
@@ -1198,6 +1231,7 @@ export = {
toDir,
SENTINEL_RANGES,
isSentinelPhaseId,
isSentinelPhaseDir,
phaseMarkdownRegexSource,
phaseMarkdownRegexSourceExact,
comparePhaseNum,