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

@@ -14,16 +14,30 @@
* - node:fs / node:path (stdlib)
* - ./phase-id.cjs (comparePhaseNum, used by readSubdirectories)
* - ./planning-workspace.cjs (findContextMdIn, used by getPhaseFileStats)
*
* #3883 (ADR-3473 §8.3): two of this module's cyclic partners require
* generateSlugInternal, the canonical slug formula:
* - phase-id.cjs requires this module directly.
* - planning-workspace.cjs is a cyclic partner via a longer path:
* core-utils.cjs -> planning-workspace.cjs -> active-workstream-store.cjs
* -> workstream-name-policy.cjs -> core-utils.cjs.
* Both are genuine circular requires. They are safe ONLY because every side
* accesses the other's exports lazily, through the live module-namespace
* object (`phaseIdModule.foo(...)` / `planningWorkspace.foo(...)`) inside a
* function body, never via a top-level destructure — a top-level
* `const { foo } = require(...)` copies the binding at import time and would
* silently capture `undefined` whichever module loses the load-order race.
* This is an absolute rule with no exception in this file: every cyclic
* partner's export is accessed through its module-namespace object, never
* destructured at the top level.
*/
import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdModule = require('./phase-id.cjs');
const { comparePhaseNum, scopeToPhase } = phaseIdModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { findContextMdIn } = planningWorkspace;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import shellCommandProjection = require('./shell-command-projection.cjs');
@@ -104,14 +118,28 @@ function pathExistsInternal(cwd: string, targetPath: string): boolean {
}
}
function generateSlugInternal(text: string | null | undefined): string | null {
/**
* #3883 (ADR-3473 §8.3 remediation): `maxLen` lets a caller state its own
* truncation contract instead of being forced into this function's
* historical 60-char cap. Some call sites truncated at 60 before the #3883
* consolidation (commands.cts:cmdGenerateSlug) and some never truncated at
* all (phase-id.cts toDir/getPhaseDirFromPhaseId, the init.cts/phase-locator
* phase_slug sites, workstream-name-policy.cts toWorkstreamSlug) — collapsing
* every caller onto a single hard-coded 60 introduced two identity
* collisions (distinct >60-char names/phase-slugs truncating to the same
* value) that did not exist pre-migration. `maxLen: 60` remains the default
* so untouched callers keep prior behavior; pass `null` for no truncation.
*/
function generateSlugInternal(text: string | null | undefined, maxLen: number | null = 60): string | null {
if (!text) return null;
// #2849: strip leading/trailing hyphens AFTER truncation, not only before.
// .substring(0, 60) can land on a separator, re-introducing a trailing hyphen
// the strip step exists to prevent. Truncation cannot add a leading hyphen, so
// running the full ^-+|-+$ pass last is equivalent for leading hyphens and
// fixes the trailing-hyphen-after-truncation case.
return transliterateForSlug(text).replace(/[^a-z0-9]+/g, '-').substring(0, 60).replace(/^-+|-+$/g, '');
const collapsed = transliterateForSlug(text).replace(/[^a-z0-9]+/g, '-');
const truncated = maxLen === null ? collapsed : collapsed.substring(0, maxLen);
return truncated.replace(/^-+|-+$/g, '');
}
// ─── Transliteration (#2848) ─────────────────────────────────────────────────
@@ -234,13 +262,13 @@ function getPhaseFileStats(phaseDir: string): PhaseFileStats {
};
}
const scopedFiles = scopeToPhase(files, path.basename(phaseDir));
const scopedFiles = phaseIdModule.scopeToPhase(files, path.basename(phaseDir));
return {
plans: scan.planFiles,
summaries: scan.summaryFiles,
hasResearch: scopedFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
hasContext: findContextMdIn(scopedFiles) !== null,
hasContext: planningWorkspace.findContextMdIn(scopedFiles) !== null,
hasVerification: scopedFiles.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'),
hasReviews: scopedFiles.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'),
scope: scan.scope,
@@ -256,7 +284,7 @@ function readSubdirectories(dirPath: string, sort = false): string[] {
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
return sort ? dirs.sort((a, b) => comparePhaseNum(a, b)) : dirs;
return sort ? dirs.sort((a, b) => phaseIdModule.comparePhaseNum(a, b)) : dirs;
} catch {
return [];
}