enhance(#3883): route every slug call site through its canonical owner (#3896)

* docs(#3883): correct section 8.3 against the tree

I wrote this section in Phase 0 stating rules I had not executed against the
tree. Measured at 832dcbb75, three statements are wrong.

The slug count is 11 across 5 files, not 13; two of the thirteen were an
unrelated tokenizer regex. The divergence is real and reproduced.

resolveRuntime reads no install marker at all and has no cache; PR #3382,
cited as prior art for that rung, is closed unmerged.

The Codex sandbox is still a hand-maintained subset map with a silent
read-only fallback, and validate agents checks file presence only -- both
halves of that claim are false.

The shortFormToId rule is accurate. The guard roster names no casualty for
this rule.

Section 8.3 is therefore a work list, not a conformance check.

Refs #3883

* test(#3883): failing-first rows for slug re-implementation divergence

Refs #3883

* feat(#3883): route every slug call site through its canonical owner

Migrated commands.cts:209, init.cts:176/1935/1957/3109, phase-id.cts:229/380, phase-locator.cts:269, workstream-name-policy.cts:75 to generateSlugInternal (core-utils.cts:107).

Declared different: gsd2-import.cts:97 (no truncation), active-workstream-store.cts:97 (fixed ASCII env-key domain, already matches).

Fixed a latent circular-require bug: core-utils.cts top-level destructured comparePhaseNum/scopeToPhase from phase-id.cjs; switched both sides of the new circular require to lazy function-body requires.

Refs #3883

* fix(#3883): restore per-site truncation contracts broken by the slug consolidation

Refs #3883

* fix(#3883): close review findings — changeset, miscounts, loose rows, cyclic destructure

Refs #3883

* docs(#3883): correct the same slug miscount in the Context table

Section 8.3's rule text was corrected to 11 copies across 7 files; the Context
table above it still carried the original 13 across 5. Same wrong claim, second
location, both mine.

Refs #3883

* chore(#3883): backfill changeset PR number

Refs #3883

* test(#3883): keep the core-utils mutation shard inside its time budget

PR #3896's Stryker (core-utils) shard was cancelled at the 15-minute
shard cap. Stryker's command-runner bills one `node --test <file>`
invocation as a single unit costing whatever the file's slowest run
costs, re-run once per mutant (documented in
tests/state-contract.test.cjs's header, #2790 precedent). A3/A5 drove
every CLI-reachable slug site through runGsdTools (a real child-process
spawn per call, ~85-170ms each across ~70 calls), accounting for ~7.1s
of the file's ~7.9s wall time.

commands.cts:cmdGenerateSlug and init.cts:cmdInitExecutePhase/
cmdInitPhaseOp/cmdInitProgress are plain functions reachable in-process
from the built gsd-core/bin/lib/*.cjs, so this calls them directly
instead of spawning gsd-tools, capturing their fd-1 JSON output with the
bug #1008 fs.writeSync-mock pattern already used in tests/io.test.cjs
and tests/init.test.cjs. A hermetic-env helper reproduces the isolation
runGsdTools's { HOME: tmpDir } + testEnvBase() gave the child process.

File wall time drops from ~7.97s to ~1.1s (246/246 passing, same
coverage), well inside the 15-minute cap even at hundreds of mutants.

Refs #3883

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-26 16:03:11 -04:00
committed by GitHub
parent a638ca4332
commit 389bc86e02
11 changed files with 748 additions and 34 deletions

View File

@@ -10,6 +10,17 @@
* Dependencies:
* - ./pattern.cjs (escapeRegex — #3212 Phase 1 seam; this module is no
* longer the owner of pattern-escaping, only a consumer)
* - ./core-utils.cjs (generateSlugInternal — #3883/ADR-3473 §8.3: the
* canonical slug formula). core-utils.cjs also requires THIS module
* (comparePhaseNum, scopeToPhase), so a top-level require here would be
* circular and — per this codebase's compiled-.cjs convention of a
* single `module.exports = {...}` reassignment at the bottom of each
* file — a top-level circular require captures a stale, still-empty
* exports object forever (verified live: it throws
* "generateSlugInternal is not a function" when core-utils.cjs happens
* to load first). The require is deferred (lazy, inside each function
* body) instead, mirroring the same cycle-break already used by
* core-utils.cts's own getPhaseFileStats/plan-scan.cjs seam.
*/
import { escapeRegex } from './pattern.cjs';
@@ -226,9 +237,15 @@ function getPhaseDirFromPhaseId(phaseId: unknown, phaseName: string | null | und
const milestone = String(parseInt(m[1], 10)).padStart(2, '0');
const subParts = m[2].split('-').map(p => String(parseInt(p, 10)).padStart(2, '0'));
const sub = subParts.join('-');
const slug = phaseName
? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
: '';
// #3883 (ADR-3473 §8.3): delegate to the canonical slug formula
// (generateSlugInternal, core-utils.cts) rather than re-implementing it.
// `maxLen: null` preserves this site's pre-migration untruncated contract —
// the 60-char default would silently shadow one on-disk phase dir's
// reported phase_slug behind another distinct >60-char phase name's.
// Lazy require to break the core-utils.cjs <-> phase-id.cjs cycle (see the
// module dependency doc comment above).
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
const slug = phaseName ? ((require('./core-utils.cjs').generateSlugInternal(phaseName, null) as string | null) ?? '') : '';
const parts = [milestone, sub, slug].filter(Boolean);
const base = parts.join('-');
return projectCode ? `${projectCode}-${base}` : base;
@@ -377,7 +394,22 @@ function toDir(id: PhaseId, slug: string): string {
const sub = id.subphase ? `.${id.subphase}` : '';
// Slug guard: the slug becomes an on-disk path segment, so collapse it to a
// safe lowercase token — never a path separator or `..` traversal.
const safeSlug = slug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
// #3883 (ADR-3473 §8.3): delegate the sanitize formula itself to the
// canonical (generateSlugInternal, core-utils.cts) — this fixes the
// Cyrillic-collapses-to-empty defect (#2848-class) that toDir carried
// before (it never transliterated). The empty-sanitize and all-digit
// throw guards below stay: they are a DECLARED DIFFERENCE from every
// other slug call site, not a bug — a slug here becomes a real directory
// name, and toDir protects the parsePhaseId dir↔identity bijection
// (see the toDir docstring above) by refusing to emit an unusable name,
// where every other site silently accepts "" or a re-truncated value.
// `maxLen: null` preserves toDir's pre-migration untruncated contract — the
// 60-char default let two distinct >60-char phase names collapse onto the
// identical directory name, one silently shadowing the other on disk.
// Lazy require to break the core-utils.cjs <-> phase-id.cjs cycle (see the
// module dependency doc comment above).
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
const safeSlug = (require('./core-utils.cjs').generateSlugInternal(slug, null) as string | null) ?? '';
// A slug that sanitizes to nothing (e.g. '!!!') would otherwise emit a
// dangling trailing hyphen.
if (!safeSlug) {