* 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:
5
.changeset/jolly-ravens-travel.md
Normal file
5
.changeset/jolly-ravens-travel.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Fixed
|
||||||
|
pr: 3896
|
||||||
|
---
|
||||||
|
**`generate-slug` and phase/workstream slugs no longer diverge from the canonical formula.** — Some slug-producing commands and internal call sites re-implemented the ASCII slug formula by hand instead of delegating to the shared one: Cyrillic and other non-Latin titles could collapse to an empty slug where the canonical transliterates them, and slug truncation could leave a dangling trailing hyphen (regression of #2849). Every slug call site now delegates to the single canonical implementation. (#3883)
|
||||||
@@ -18,7 +18,7 @@ The evidence base is #3473's filing (a systemic root-cause review of all 30 open
|
|||||||
|
|
||||||
| Invariant | Canonical owner | Reality on `next` |
|
| Invariant | Canonical owner | Reality on `next` |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| slug rule | `generateSlugInternal`, `src/core-utils.cts` | 13 inline copies across 5 modules; copies and generator already disagree on Cyrillic transliteration and trim-vs-truncate order (#2986) |
|
| slug rule | `generateSlugInternal`, `src/core-utils.cts` | **11** inline copies across **7** files (measured in Phase 6, #3883 — the "13 across 5" written here was wrong twice over: two of the thirteen were an unrelated tokenizer regex, and the file count was never taken); copies and generator disagree on Cyrillic transliteration and trim-vs-truncate order (#2986) |
|
||||||
| `isSentinelPhaseId` | `src/phase-id.cts` | consumed by 11 modules, absent from 4 phase-enumerating commands (#3372) |
|
| `isSentinelPhaseId` | `src/phase-id.cts` | consumed by 11 modules, absent from 4 phase-enumerating commands (#3372) |
|
||||||
| verification-file discovery | *(none)* | independently implemented twice, both alphabetical-first (#3357) |
|
| verification-file discovery | *(none)* | independently implemented twice, both alphabetical-first (#3357) |
|
||||||
| runtime identity | `bin/install.js` persists it | `resolveRuntime` never reads it back (#3364) |
|
| runtime identity | `bin/install.js` persists it | `resolveRuntime` never reads it back (#3364) |
|
||||||
@@ -170,10 +170,24 @@ Both close #3349 and #3360, which are **read-side** defects a real parser fixes
|
|||||||
|
|
||||||
**Rule.** Verification-file discovery has one resolver, **canonical-filename-first, never alphabetical** (#3357).
|
**Rule.** Verification-file discovery has one resolver, **canonical-filename-first, never alphabetical** (#3357).
|
||||||
|
|
||||||
#### 8.3 One implementation per rule — *Required — phase unassigned*
|
#### 8.3 One implementation per rule — *Required — Phase 6*
|
||||||
|
|
||||||
**Rule.** Every slug call site delegates to `core-utils`. `resolveRuntime` reads the install marker in one place with one cache. The Codex sandbox derives from the role's declared tool contract rather than a maintained subset map, and `validate agents` fails on semantic drift, not just on missing files.
|
**Rule.** Every slug call site delegates to `core-utils`. `resolveRuntime` reads the install marker in one place with one cache. The Codex sandbox derives from the role's declared tool contract rather than a maintained subset map, and `validate agents` fails on semantic drift, not just on missing files.
|
||||||
|
|
||||||
|
> **Correction, 2026-08-26 (Phase 6, #3883) — I wrote this section as if it described decisions already taken. Measured against `next` @ `832dcbb75`, it describes unbuilt work, and three of its statements are wrong.** These are my errors, not inherited ones: I authored this ADR in Phase 0 (#3868/#3870) and stated these as rules without executing against the tree.
|
||||||
|
>
|
||||||
|
> | As written | Measured |
|
||||||
|
> |---|---|
|
||||||
|
> | "13 inline copies across 5 modules" | **11 copies across 7 files** (`commands.cts` ×1, `init.cts` ×4, `phase-id.cts` ×2, `phase-locator.cts` ×1, `workstream-name-policy.cts` ×1, `active-workstream-store.cts` ×1, `gsd2-import.cts` ×1). Two of the thirteen I counted were an unrelated tokenizer regex. The file count in my first correction (2026-08-26, same day) was itself wrong — I corrected 13→11 without recounting files and repeated the same class of error I was correcting. The divergence itself is real and reproduced: on Cyrillic input the canonical `generateSlugInternal` (`src/core-utils.cts:107`) yields `privet-mir` while `cmdGenerateSlug` (`src/commands.cts:200`) yields `""`; at the truncation boundary the copy leaves a trailing hyphen (#2849's regression, still live in the copy). |
|
||||||
|
> | "`resolveRuntime` reads the install marker in one place with one cache" | **It reads no marker at all.** `src/runtime-slash.cts:132` resolves `GSD_RUNTIME > config.runtime > 'claude'`, with no marker read and no cache. PR **#3382**, which I cited as prior art implementing this rung, is **CLOSED and unmerged**. |
|
||||||
|
> | "The Codex sandbox derives from the role's declared tool contract… and `validate agents` fails on semantic drift" | **Both halves false.** `generateCodexAgentToml` (`bin/install.js`) still reads `CODEX_AGENT_SANDBOX[agentName] \|\| 'read-only'` — a hand-maintained subset map with a silent fallback. `checkAgentsInstalled` (`src/agent-install-check.cts:156`) checks file presence and manifest completeness only; it has no `sandbox_mode` or tool-contract assertion. |
|
||||||
|
>
|
||||||
|
> The `shortFormToId` rule below is **accurate** — no such tier exists on `next`, and `resolveDependencyId` (`src/phase.cts:609`) remains two-tier.
|
||||||
|
>
|
||||||
|
> **The guard roster names no §8.3 casualty.** Its only §8.3-tagged row is `local/no-adhoc-regex-escape`, marked *widened*, not retired. Nothing is retired by this rule.
|
||||||
|
>
|
||||||
|
> **What this means for the phase:** unlike §8.2, this section is genuinely unbuilt — the rewrites it asserts have not happened. It is a work list, not a conformance check, and it should be read that way.
|
||||||
|
|
||||||
**Rule — consolidation carries invariants forward explicitly.** A lineage consolidation may not delete an invariant along with the surface that held it. The `shortFormToId` tier existed in the retired SDK lineage; the surviving lineage never received it, the gap was recorded only in an archived changeset and a `// KNOWN GAP:` comment, and both went away with the surface (#3427). **A parity note in an archived changeset is not a tracking mechanism.**
|
**Rule — consolidation carries invariants forward explicitly.** A lineage consolidation may not delete an invariant along with the surface that held it. The `shortFormToId` tier existed in the retired SDK lineage; the surviving lineage never received it, the gap was recorded only in an archived changeset and a `// KNOWN GAP:` comment, and both went away with the surface (#3427). **A parity note in an archived changeset is not a tracking mechanism.**
|
||||||
|
|
||||||
#### 8.4 Failure is a value — *Required — phase unassigned*
|
#### 8.4 Failure is a value — *Required — phase unassigned*
|
||||||
|
|||||||
@@ -91,6 +91,14 @@ function getControllingTtyToken(): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getWorkstreamSessionKey(): string | null {
|
function getWorkstreamSessionKey(): string | null {
|
||||||
|
// #3883 (ADR-3473 §8.3): DECLARED DIFFERENT from generateSlugInternal
|
||||||
|
// (core-utils.cts), the canonical slug formula — deliberately, not a
|
||||||
|
// consolidation gap. The text slugified here is never caller-supplied: it
|
||||||
|
// is always one of the fixed ASCII WORKSTREAM_SESSION_ENV_KEYS identifier
|
||||||
|
// names above (e.g. "GSD_SESSION_KEY"), which already agree with the
|
||||||
|
// canonical's output byte-for-byte on this restricted input domain (no
|
||||||
|
// unicode, no length anywhere near the 60-char truncation boundary) — so
|
||||||
|
// delegating would add a require() edge for zero behavioral change.
|
||||||
for (const envKey of WORKSTREAM_SESSION_ENV_KEYS) {
|
for (const envKey of WORKSTREAM_SESSION_ENV_KEYS) {
|
||||||
const raw = process.env[envKey];
|
const raw = process.env[envKey];
|
||||||
const token = sanitizeWorkstreamSessionToken(raw);
|
const token = sanitizeWorkstreamSessionToken(raw);
|
||||||
|
|||||||
@@ -206,11 +206,11 @@ function cmdGenerateSlug(text: string | undefined, raw: boolean): void {
|
|||||||
error('text required for slug generation');
|
error('text required for slug generation');
|
||||||
}
|
}
|
||||||
|
|
||||||
const slug = (text as string)
|
// #3883 (ADR-3473 §8.3): delegate to the canonical slug formula
|
||||||
.toLowerCase()
|
// (generateSlugInternal, core-utils.cts) instead of re-implementing it —
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
// this call site previously diverged from it (Cyrillic collapsed to "",
|
||||||
.replace(/^-+|-+$/g, '')
|
// and truncation could leave a trailing hyphen; #2848/#2849).
|
||||||
.substring(0, 60);
|
const slug = coreUtilsMod.generateSlugInternal(text) ?? '';
|
||||||
|
|
||||||
const result = { slug };
|
const result = { slug };
|
||||||
output(result, raw, slug);
|
output(result, raw, slug);
|
||||||
|
|||||||
@@ -14,16 +14,30 @@
|
|||||||
* - node:fs / node:path (stdlib)
|
* - node:fs / node:path (stdlib)
|
||||||
* - ./phase-id.cjs (comparePhaseNum, used by readSubdirectories)
|
* - ./phase-id.cjs (comparePhaseNum, used by readSubdirectories)
|
||||||
* - ./planning-workspace.cjs (findContextMdIn, used by getPhaseFileStats)
|
* - ./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 fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
import phaseIdModule = require('./phase-id.cjs');
|
import phaseIdModule = require('./phase-id.cjs');
|
||||||
const { comparePhaseNum, scopeToPhase } = phaseIdModule;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
import planningWorkspace = require('./planning-workspace.cjs');
|
import planningWorkspace = require('./planning-workspace.cjs');
|
||||||
const { findContextMdIn } = planningWorkspace;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
import shellCommandProjection = require('./shell-command-projection.cjs');
|
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;
|
if (!text) return null;
|
||||||
// #2849: strip leading/trailing hyphens AFTER truncation, not only before.
|
// #2849: strip leading/trailing hyphens AFTER truncation, not only before.
|
||||||
// .substring(0, 60) can land on a separator, re-introducing a trailing hyphen
|
// .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
|
// the strip step exists to prevent. Truncation cannot add a leading hyphen, so
|
||||||
// running the full ^-+|-+$ pass last is equivalent for leading hyphens and
|
// running the full ^-+|-+$ pass last is equivalent for leading hyphens and
|
||||||
// fixes the trailing-hyphen-after-truncation case.
|
// 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) ─────────────────────────────────────────────────
|
// ─── 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 {
|
return {
|
||||||
plans: scan.planFiles,
|
plans: scan.planFiles,
|
||||||
summaries: scan.summaryFiles,
|
summaries: scan.summaryFiles,
|
||||||
hasResearch: scopedFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
|
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'),
|
hasVerification: scopedFiles.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'),
|
||||||
hasReviews: scopedFiles.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'),
|
hasReviews: scopedFiles.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'),
|
||||||
scope: scan.scope,
|
scope: scan.scope,
|
||||||
@@ -256,7 +284,7 @@ function readSubdirectories(dirPath: string, sort = false): string[] {
|
|||||||
try {
|
try {
|
||||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
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 {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
31
src/init.cts
31
src/init.cts
@@ -174,9 +174,12 @@ function guardedGetRoadmapPhase(
|
|||||||
// directory exists yet) identically at every synthetic-fallback call site
|
// directory exists yet) identically at every synthetic-fallback call site
|
||||||
// below — factored out once so the slugification formula itself cannot drift.
|
// below — factored out once so the slugification formula itself cannot drift.
|
||||||
function slugifyPhaseName(phaseName: string | null): string | null {
|
function slugifyPhaseName(phaseName: string | null): string | null {
|
||||||
return phaseName
|
// #3883 (ADR-3473 §8.3): delegate to the canonical slug formula
|
||||||
? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
// (generateSlugInternal, core-utils.cts) rather than re-implementing it.
|
||||||
: null;
|
// `maxLen: null` preserves this site's pre-migration untruncated contract —
|
||||||
|
// the 60-char default would collapse two distinct >60-char phase names onto
|
||||||
|
// the same reported phase_slug.
|
||||||
|
return phaseName ? coreUtils.generateSlugInternal(phaseName, null) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1931,9 +1934,11 @@ function cmdInitPhaseOp(cwd: string, phase: string, raw: boolean): void {
|
|||||||
directory: null,
|
directory: null,
|
||||||
phase_number: roadmapPhase['phase_number'],
|
phase_number: roadmapPhase['phase_number'],
|
||||||
phase_name: phaseName,
|
phase_name: phaseName,
|
||||||
phase_slug: phaseName
|
// #3883 (ADR-3473 §8.3): delegate to the canonical slug formula
|
||||||
? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
// (generateSlugInternal, core-utils.cts) rather than re-implementing
|
||||||
: null,
|
// it. `maxLen: null` preserves this site's pre-migration untruncated
|
||||||
|
// contract.
|
||||||
|
phase_slug: phaseName ? coreUtils.generateSlugInternal(phaseName, null) : null,
|
||||||
plans: [],
|
plans: [],
|
||||||
summaries: [],
|
summaries: [],
|
||||||
incomplete_plans: [],
|
incomplete_plans: [],
|
||||||
@@ -1953,9 +1958,11 @@ function cmdInitPhaseOp(cwd: string, phase: string, raw: boolean): void {
|
|||||||
directory: null,
|
directory: null,
|
||||||
phase_number: roadmapPhase['phase_number'],
|
phase_number: roadmapPhase['phase_number'],
|
||||||
phase_name: phaseName,
|
phase_name: phaseName,
|
||||||
phase_slug: phaseName
|
// #3883 (ADR-3473 §8.3): delegate to the canonical slug formula
|
||||||
? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
// (generateSlugInternal, core-utils.cts) rather than re-implementing
|
||||||
: null,
|
// it. `maxLen: null` preserves this site's pre-migration untruncated
|
||||||
|
// contract.
|
||||||
|
phase_slug: phaseName ? coreUtils.generateSlugInternal(phaseName, null) : null,
|
||||||
plans: [],
|
plans: [],
|
||||||
summaries: [],
|
summaries: [],
|
||||||
incomplete_plans: [],
|
incomplete_plans: [],
|
||||||
@@ -3111,7 +3118,11 @@ function cmdInitProgress(cwd: string, raw: boolean, options: Record<string, unkn
|
|||||||
const status = 'not_started';
|
const status = 'not_started';
|
||||||
const phaseInfo: Record<string, unknown> = {
|
const phaseInfo: Record<string, unknown> = {
|
||||||
number: num,
|
number: num,
|
||||||
name: name.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.
|
||||||
|
name: coreUtils.generateSlugInternal(name, null) ?? '',
|
||||||
directory: null,
|
directory: null,
|
||||||
status,
|
status,
|
||||||
plan_count: 0,
|
plan_count: 0,
|
||||||
|
|||||||
@@ -10,6 +10,17 @@
|
|||||||
* Dependencies:
|
* Dependencies:
|
||||||
* - ./pattern.cjs (escapeRegex — #3212 Phase 1 seam; this module is no
|
* - ./pattern.cjs (escapeRegex — #3212 Phase 1 seam; this module is no
|
||||||
* longer the owner of pattern-escaping, only a consumer)
|
* 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';
|
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 milestone = String(parseInt(m[1], 10)).padStart(2, '0');
|
||||||
const subParts = m[2].split('-').map(p => String(parseInt(p, 10)).padStart(2, '0'));
|
const subParts = m[2].split('-').map(p => String(parseInt(p, 10)).padStart(2, '0'));
|
||||||
const sub = subParts.join('-');
|
const sub = subParts.join('-');
|
||||||
const slug = phaseName
|
// #3883 (ADR-3473 §8.3): delegate to the canonical slug formula
|
||||||
? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
// (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 parts = [milestone, sub, slug].filter(Boolean);
|
||||||
const base = parts.join('-');
|
const base = parts.join('-');
|
||||||
return projectCode ? `${projectCode}-${base}` : base;
|
return projectCode ? `${projectCode}-${base}` : base;
|
||||||
@@ -377,7 +394,22 @@ function toDir(id: PhaseId, slug: string): string {
|
|||||||
const sub = id.subphase ? `.${id.subphase}` : '';
|
const sub = id.subphase ? `.${id.subphase}` : '';
|
||||||
// Slug guard: the slug becomes an on-disk path segment, so collapse it to a
|
// Slug guard: the slug becomes an on-disk path segment, so collapse it to a
|
||||||
// safe lowercase token — never a path separator or `..` traversal.
|
// 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
|
// A slug that sanitizes to nothing (e.g. '!!!') would otherwise emit a
|
||||||
// dangling trailing hyphen.
|
// dangling trailing hyphen.
|
||||||
if (!safeSlug) {
|
if (!safeSlug) {
|
||||||
|
|||||||
@@ -266,7 +266,12 @@ function searchPhaseInDir(baseDir: string, relBase: string, normalized: string):
|
|||||||
directory: toPosixPath(path.join(relBase, match)),
|
directory: toPosixPath(path.join(relBase, match)),
|
||||||
phase_number: phaseNumber,
|
phase_number: phaseNumber,
|
||||||
phase_name: phaseName,
|
phase_name: phaseName,
|
||||||
phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
|
// #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 drop an on-disk phase slug's
|
||||||
|
// reported value out of sync with the real directory name.
|
||||||
|
phase_slug: phaseName ? coreUtilsModule.generateSlugInternal(phaseName, null) : null,
|
||||||
plans,
|
plans,
|
||||||
summaries,
|
summaries,
|
||||||
incomplete_plans: incompletePlans,
|
incomplete_plans: incompletePlans,
|
||||||
|
|||||||
@@ -5,6 +5,24 @@
|
|||||||
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
|
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
|
||||||
*
|
*
|
||||||
* Used by active-workstream-store.cjs, planning-workspace.cjs, workstream.cjs.
|
* Used by active-workstream-store.cjs, planning-workspace.cjs, workstream.cjs.
|
||||||
|
*
|
||||||
|
* #3883 (ADR-3473 §8.3): toWorkstreamSlug delegates to core-utils.cjs's
|
||||||
|
* generateSlugInternal (the canonical slug formula), passing `maxLen: null`
|
||||||
|
* to preserve this site's pre-migration untruncated contract — the 60-char
|
||||||
|
* default collided distinct >60-char workstream names onto the same slug
|
||||||
|
* (verified: `"a".repeat(60)+"alpha"` and `"a".repeat(60)+"beta"` truncated
|
||||||
|
* identically). core-utils.cjs already
|
||||||
|
* requires (transitively, at module-init time) THIS module:
|
||||||
|
* core-utils.cjs -> planning-workspace.cjs -> active-workstream-store.cjs ->
|
||||||
|
* workstream-name-policy.cjs. A top-level require of core-utils.cjs here
|
||||||
|
* would therefore close that cycle and — per this codebase's compiled-.cjs
|
||||||
|
* convention of a single `module.exports = {...}` reassignment at the bottom
|
||||||
|
* of core-utils.cjs — capture a stale, still-empty exports object forever
|
||||||
|
* (verified live: "generateSlugInternal is not a function" whichever module
|
||||||
|
* loads first). The require is deferred (lazy, inside toWorkstreamSlug's
|
||||||
|
* body) instead, mirroring the same cycle-break already used by
|
||||||
|
* core-utils.cts's own getPhaseFileStats/plan-scan.cjs seam and by
|
||||||
|
* phase-id.cts's toDir/getPhaseDirFromPhaseId.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const INVALID_ACTIVE_WORKSTREAM_NAME_MESSAGE =
|
export const INVALID_ACTIVE_WORKSTREAM_NAME_MESSAGE =
|
||||||
@@ -73,10 +91,13 @@ export function validateWorkstreamName(name: string | null | undefined): boolean
|
|||||||
* Lowercases, collapses non-alphanumeric runs to hyphens, strips leading/trailing hyphens.
|
* Lowercases, collapses non-alphanumeric runs to hyphens, strips leading/trailing hyphens.
|
||||||
*/
|
*/
|
||||||
export function toWorkstreamSlug(name: string | null | undefined): string {
|
export function toWorkstreamSlug(name: string | null | undefined): string {
|
||||||
return String(name ?? '')
|
// #3883 (ADR-3473 §8.3): delegate to the canonical slug formula
|
||||||
.toLowerCase()
|
// (generateSlugInternal, core-utils.cts) rather than re-implementing it —
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
// this call site previously diverged from it (no transliteration, no
|
||||||
.replace(/^-+|-+$/g, '');
|
// 60-char truncation). Lazy require to break the core-utils.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
|
||||||
|
return (require('./core-utils.cjs').generateSlugInternal(String(name ?? ''), null) as string | null) ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -29,7 +29,85 @@ const os = require('node:os');
|
|||||||
|
|
||||||
const coreUtils = require('../gsd-core/bin/lib/core-utils.cjs');
|
const coreUtils = require('../gsd-core/bin/lib/core-utils.cjs');
|
||||||
const { SCOPE } = require('../gsd-core/bin/lib/planning-scope.cjs');
|
const { SCOPE } = require('../gsd-core/bin/lib/planning-scope.cjs');
|
||||||
const { cleanup } = require('./helpers.cjs');
|
const {
|
||||||
|
cleanup, runGsdTools, scrubConfigLocationEnv,
|
||||||
|
saveSessionEnv, restoreSessionEnv, clearSessionEnv, TEST_HOME_SANDBOX_MARKER,
|
||||||
|
} = require('./helpers.cjs');
|
||||||
|
const phaseLocator = require('../gsd-core/bin/lib/phase-locator.cjs');
|
||||||
|
const phaseId = require('../gsd-core/bin/lib/phase-id.cjs');
|
||||||
|
const workstreamNamePolicy = require('../gsd-core/bin/lib/workstream-name-policy.cjs');
|
||||||
|
const activeWorkstreamStore = require('../gsd-core/bin/lib/active-workstream-store.cjs');
|
||||||
|
const gsd2Import = require('../gsd-core/bin/lib/gsd2-import.cjs');
|
||||||
|
const commandsMod = require('../gsd-core/bin/lib/commands.cjs');
|
||||||
|
const initMod = require('../gsd-core/bin/lib/init.cjs');
|
||||||
|
|
||||||
|
// #3883/Stryker shard budget (scripts/mutation-matrix.cjs `core-utils` entry,
|
||||||
|
// `tests/state-contract.test.cjs`'s header documents the same mechanism):
|
||||||
|
// 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. A3/A5 originally drove every CLI-reachable slug site through
|
||||||
|
// `runGsdTools` (a real child-process spawn per call, ~85-170ms each across
|
||||||
|
// ~70 calls) — ~7.5s of the file's ~7.9s wall time, which blew the 15-minute
|
||||||
|
// shard cap. `cmdGenerateSlug` / `cmdInitExecutePhase` / `cmdInitPhaseOp` /
|
||||||
|
// `cmdInitProgress` are plain functions reachable in-process from the built
|
||||||
|
// `gsd-core/bin/lib/*.cjs` — calling them directly removes the spawn
|
||||||
|
// entirely instead of moving the cost to a sibling file. `captureFd1Sync`
|
||||||
|
// intercepts the fd-level write these commands make via io.cjs's
|
||||||
|
// `writeAllSync` → `fs.writeSync(1, ...)` (bug #1008's pattern, already
|
||||||
|
// established in tests/io.test.cjs and tests/init.test.cjs's
|
||||||
|
// `captureInitVerifyWork` — NOT `process.stdout.write`, which silently
|
||||||
|
// captures nothing here). `withHermeticInProcessEnv` reproduces the isolation
|
||||||
|
// `runGsdTools(..., { HOME: tmpDir })` + `testEnvBase()` gave the child
|
||||||
|
// process (HOME/USERPROFILE sandbox + config-location env scrub + session-
|
||||||
|
// identity env clear) so an in-process call can't read the developer's real
|
||||||
|
// `~/.gsd` config or leak real session-identity env into the slug output.
|
||||||
|
|
||||||
|
function captureFd1Sync(fn) {
|
||||||
|
const chunks = [];
|
||||||
|
const origWriteSync = fs.writeSync.bind(fs);
|
||||||
|
fs.writeSync = (fd, data, offset, length) => {
|
||||||
|
if (fd === 2) return Buffer.isBuffer(data) ? data.length : String(data).length;
|
||||||
|
if (fd !== 1) return origWriteSync(fd, data, offset, length);
|
||||||
|
const chunk = Buffer.isBuffer(data)
|
||||||
|
? data.subarray(offset ?? 0, length === undefined ? data.length : (offset ?? 0) + length).toString('utf8')
|
||||||
|
: String(data);
|
||||||
|
chunks.push(chunk);
|
||||||
|
return Buffer.byteLength(chunk, 'utf8');
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} finally {
|
||||||
|
fs.writeSync = origWriteSync;
|
||||||
|
}
|
||||||
|
return chunks.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function withHermeticInProcessEnv(dir, fn) {
|
||||||
|
const savedHome = process.env.HOME;
|
||||||
|
const savedUserProfile = process.env.USERPROFILE;
|
||||||
|
const savedMarker = process.env[TEST_HOME_SANDBOX_MARKER];
|
||||||
|
const savedSession = saveSessionEnv();
|
||||||
|
const restoreConfigEnv = scrubConfigLocationEnv();
|
||||||
|
clearSessionEnv();
|
||||||
|
process.env.HOME = dir;
|
||||||
|
process.env.USERPROFILE = dir;
|
||||||
|
process.env[TEST_HOME_SANDBOX_MARKER] = dir;
|
||||||
|
try {
|
||||||
|
return fn();
|
||||||
|
} finally {
|
||||||
|
restoreConfigEnv();
|
||||||
|
restoreSessionEnv(savedSession);
|
||||||
|
if (savedHome === undefined) delete process.env.HOME; else process.env.HOME = savedHome;
|
||||||
|
if (savedUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = savedUserProfile;
|
||||||
|
if (savedMarker === undefined) delete process.env[TEST_HOME_SANDBOX_MARKER];
|
||||||
|
else process.env[TEST_HOME_SANDBOX_MARKER] = savedMarker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdGenerateSlugInProcess(text) {
|
||||||
|
const captured = captureFd1Sync(() => commandsMod.cmdGenerateSlug(text, false));
|
||||||
|
return JSON.parse(captured).slug;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── toPosixPath ─────────────────────────────────────────────────────────────
|
// ─── toPosixPath ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -764,3 +842,499 @@ describe('countMatchedSummaries — stray non-plan summaries excluded (#1988)',
|
|||||||
assert.strictEqual(countMatchedSummaries(['/abs/PLAN-01.md'], ['/abs/SUMMARY-01.md']), 1);
|
assert.strictEqual(countMatchedSummaries(['/abs/PLAN-01.md'], ['/abs/SUMMARY-01.md']), 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// #3883 (ADR-3473 §8.3) — one slug implementation per rule
|
||||||
|
//
|
||||||
|
// generateSlugInternal (this file's own subject, above) is the canonical slug
|
||||||
|
// formula. 11 independent inline re-implementations were found by grep across
|
||||||
|
// src/ (file:line evidence in the PR description). This block drives each
|
||||||
|
// reachable site the way production reaches it and proves today's code
|
||||||
|
// disagrees with the canonical — failing-first, per the phase 6 test matrix
|
||||||
|
// (.gsd/phase/feat-3883-one-impl-per-rule/50-test-matrix.md section A/B).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('#3883 one-impl-per-rule: slug re-implementation divergence', () => {
|
||||||
|
// ── A1: commands.cts cmdGenerateSlug (CLI `generate-slug`) ──────────────────
|
||||||
|
|
||||||
|
test('A1 cyrillicSlugIsNotEmpty: cmdGenerateSlug on Cyrillic yields the canonical value, not ""', () => {
|
||||||
|
const observedSlug = cmdGenerateSlugInProcess('Привет мир');
|
||||||
|
// Pinned to a concrete value (not merely "non-empty") so this cannot be
|
||||||
|
// satisfied by an unrelated fallback string — and pinned to the
|
||||||
|
// canonical's OWN live output so a future change to the transliteration
|
||||||
|
// map cannot silently desync this expectation from generateSlugInternal.
|
||||||
|
const canonical = coreUtils.generateSlugInternal('Привет мир');
|
||||||
|
assert.strictEqual(canonical, 'privet-mir', 'sanity: canonical must itself transliterate to privet-mir');
|
||||||
|
assert.notStrictEqual(observedSlug, '', 'cmdGenerateSlug must not collapse a Cyrillic title to an empty slug (#2848-class regression)');
|
||||||
|
assert.strictEqual(observedSlug, canonical, 'cmdGenerateSlug must delegate to (or agree with) generateSlugInternal');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── A2: commands.cts cmdGenerateSlug truncation-boundary trailing hyphen ────
|
||||||
|
|
||||||
|
test('A2 truncationBoundaryLeavesNoTrailingHyphen: no trailing separator at the truncation boundary', () => {
|
||||||
|
// #2849: strip-then-truncate (cmdGenerateSlug's current order) can
|
||||||
|
// reintroduce a trailing hyphen when truncation lands exactly on an
|
||||||
|
// internal separator that strip-then-truncate never revisits.
|
||||||
|
// generateSlugInternal fixed this by truncating BEFORE the final strip
|
||||||
|
// pass (see its own comment, above in this file). The boundary is
|
||||||
|
// reproduced with 59 'a's + ' b': the collapsed separator sits exactly
|
||||||
|
// at the canonical's substring(0, 60) cut point.
|
||||||
|
const input = 'a'.repeat(59) + ' b';
|
||||||
|
const observedSlug = cmdGenerateSlugInProcess(input);
|
||||||
|
const canonical = coreUtils.generateSlugInternal(input);
|
||||||
|
assert.strictEqual(canonical, 'a'.repeat(59), 'sanity: canonical must not leave a trailing hyphen at the boundary');
|
||||||
|
assert.ok(!observedSlug.endsWith('-'), `cmdGenerateSlug must not emit a trailing hyphen at the truncation boundary; got: ${JSON.stringify(observedSlug)}`);
|
||||||
|
assert.strictEqual(observedSlug, canonical, 'cmdGenerateSlug must agree with generateSlugInternal at the truncation boundary');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── A3/A4: every reachable slug call site vs. the canonical ─────────────────
|
||||||
|
|
||||||
|
// Shared input corpus: ASCII, Cyrillic, CJK, emoji, punctuation runs,
|
||||||
|
// leading/trailing separators. Boundary-length inputs are a SEPARATE
|
||||||
|
// corpus (below) because not every site truncates (A4).
|
||||||
|
const CORPUS = [
|
||||||
|
['ascii-simple', 'Hello World'],
|
||||||
|
['ascii-punctuation-run', 'Test@#$%^Special!!!'],
|
||||||
|
['ascii-leading-trailing-separators', '---Leading Trailing---'],
|
||||||
|
['ascii-numbers', 'Phase 3 Plan'],
|
||||||
|
['cyrillic', 'Привет мир'],
|
||||||
|
['cjk-non-transliterable', '中文测试'],
|
||||||
|
['emoji', 'hello 😀 world'],
|
||||||
|
['latin-diacritics', 'Café münchen'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Boundary corpus: the separator sits one-under / exactly-at / one-over the
|
||||||
|
// canonical's substring(0, 60) cut point (see A2 and B1 below).
|
||||||
|
const BOUNDARY_CORPUS = [
|
||||||
|
['boundary-under', 'a'.repeat(58) + ' b'],
|
||||||
|
['boundary-exact', 'a'.repeat(59) + ' b'],
|
||||||
|
['boundary-over', 'a'.repeat(60) + ' b'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// #3883 regression corpus: inputs well past the OLD hard-coded 60-char cap,
|
||||||
|
// for sites whose pre-migration contract never truncated (cap === null).
|
||||||
|
// Proves the untruncated tail actually survives, not merely that the
|
||||||
|
// truncation boundary is handled.
|
||||||
|
const LONG_UNCAPPED_CORPUS = [
|
||||||
|
['long-ascii-70', 'a'.repeat(70)],
|
||||||
|
['long-ascii-100-with-separators', `${'word-'.repeat(20)}tail`],
|
||||||
|
['long-ascii-90-mixed-case', 'The Quick Brown Fox Jumps Over The Lazy Dog Many Many Many Times In A Row'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// cmdInitExecutePhase / cmdInitPhaseOp emit `phase_slug: phaseInfo?.['phase_slug'] || null`
|
||||||
|
// (init.cts:1880 and neighbors) — an output-shaping `|| null` that coerces
|
||||||
|
// ANY empty-string slug to null, independent of whether the slugification
|
||||||
|
// itself was correct. Comparing the raw JSON value against the canonical's
|
||||||
|
// real string output would flag every all-non-transliterable corpus entry
|
||||||
|
// (e.g. CJK, where the canonical ALSO produces "") as a false divergence.
|
||||||
|
// Normalizing null back to "" here isolates the axis this test actually
|
||||||
|
// cares about — the slug ALGORITHM's output — from that unrelated
|
||||||
|
// presentation choice.
|
||||||
|
function phaseSlugField(json) {
|
||||||
|
return json.phase_slug === null ? '' : json.phase_slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each site: { name, cap, call(text) => observed slug value }. `cap` is the
|
||||||
|
// site's OWN pre-#3883-migration truncation contract (60, or null for
|
||||||
|
// "never truncated") — the value it must now be compared against, not a
|
||||||
|
// single global 60. #3883-remediation (this file, security-review finding):
|
||||||
|
// the original A3/A4 harness compared every migrated site to
|
||||||
|
// generateSlugInternal(text) with the DEFAULT 60 cap baked in, which could
|
||||||
|
// only ever prove "truncates like the default" — it structurally could not
|
||||||
|
// catch the two sites (phase-id.cts toDir, workstream-name-policy.cts
|
||||||
|
// toWorkstreamSlug) that the migration silently truncated for the first
|
||||||
|
// time, because a false-positive "matches the canonical" was the only
|
||||||
|
// possible outcome once BOTH sides shared the same hard-coded 60. `call`
|
||||||
|
// drives the site exactly the way production reaches it.
|
||||||
|
const SITES = [
|
||||||
|
{
|
||||||
|
name: 'commands.cts:209 cmdGenerateSlug (CLI generate-slug)',
|
||||||
|
cap: 60,
|
||||||
|
call(text) {
|
||||||
|
return cmdGenerateSlugInProcess(text);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'init.cts:176 slugifyPhaseName (CLI init execute-phase, ROADMAP-only phase)',
|
||||||
|
cap: null,
|
||||||
|
call(text) {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3883-ep-'));
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||||
|
`# Roadmap\n\n### Phase 1: ${text}\n**Goal:** test\n**Plans:** TBD\n`,
|
||||||
|
);
|
||||||
|
const json = withHermeticInProcessEnv(tmpDir, () => {
|
||||||
|
const captured = captureFd1Sync(() => initMod.cmdInitExecutePhase(tmpDir, '1', false));
|
||||||
|
return JSON.parse(captured);
|
||||||
|
});
|
||||||
|
return phaseSlugField(json);
|
||||||
|
} finally {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'init.cts:1957 cmdInitPhaseOp !phaseInfo fallback (CLI init phase-op, no directory)',
|
||||||
|
cap: null,
|
||||||
|
call(text) {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3883-po-fb-'));
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||||
|
`# Roadmap\n\n### Phase 1: ${text}\n**Goal:** test\n**Plans:** TBD\n`,
|
||||||
|
);
|
||||||
|
const json = withHermeticInProcessEnv(tmpDir, () => {
|
||||||
|
const captured = captureFd1Sync(() => initMod.cmdInitPhaseOp(tmpDir, '1', false));
|
||||||
|
return JSON.parse(captured);
|
||||||
|
});
|
||||||
|
return phaseSlugField(json);
|
||||||
|
} finally {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'init.cts:1935 cmdInitPhaseOp archived branch (CLI init phase-op, archived dir + current ROADMAP)',
|
||||||
|
cap: null,
|
||||||
|
call(text) {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3883-po-ar-'));
|
||||||
|
try {
|
||||||
|
const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '01-old');
|
||||||
|
fs.mkdirSync(archiveDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(archiveDir, '01-CONTEXT.md'), '# old');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||||
|
`# Roadmap\n\n<details>\n<summary>Shipped v1.0</summary>\n\n### Phase 1: Old\n**Goal:** old\n</details>\n\n## Current\n\n### Phase 1: ${text}\n**Goal:** test\n**Plans:** TBD\n`,
|
||||||
|
);
|
||||||
|
const json = withHermeticInProcessEnv(tmpDir, () => {
|
||||||
|
const captured = captureFd1Sync(() => initMod.cmdInitPhaseOp(tmpDir, '1', false));
|
||||||
|
return JSON.parse(captured);
|
||||||
|
});
|
||||||
|
return phaseSlugField(json);
|
||||||
|
} finally {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'init.cts:3109 cmdInitProgress unstarted ROADMAP phase (CLI init progress)',
|
||||||
|
cap: null,
|
||||||
|
call(text) {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3883-prog-'));
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||||
|
`# Roadmap\n\n### Phase 1: ${text}\n**Goal:** test\n**Plans:** TBD\n`,
|
||||||
|
);
|
||||||
|
const json = withHermeticInProcessEnv(tmpDir, () => {
|
||||||
|
const captured = captureFd1Sync(() => initMod.cmdInitProgress(tmpDir, false));
|
||||||
|
return JSON.parse(captured);
|
||||||
|
});
|
||||||
|
return json.phases[0].name;
|
||||||
|
} finally {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'phase-id.cts:229 getPhaseDirFromPhaseId (direct require, exported)',
|
||||||
|
cap: null,
|
||||||
|
call(text) {
|
||||||
|
// The rendered dir is `<milestone>-<sub>-<slug>`; strip the fixed
|
||||||
|
// numeric prefix this call always emits to isolate the slug component.
|
||||||
|
const dir = phaseId.getPhaseDirFromPhaseId('01-01', text, null);
|
||||||
|
return dir.replace(/^01-01-?/, '');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'phase-id.cts:380 toDir safeSlug guard (direct require, exported)',
|
||||||
|
cap: null,
|
||||||
|
call(text) {
|
||||||
|
try {
|
||||||
|
const dir = phaseId.toDir({ project: 'GSD', milestone: '01', phase: '01' }, text);
|
||||||
|
return dir.replace(/^GSD\.01-01-?/, '');
|
||||||
|
} catch (e) {
|
||||||
|
// #3883 declared difference (axis: empty-sanitize-guard,
|
||||||
|
// phase-id.cts:380 toDir docstring, "toDir: slug sanitizes to
|
||||||
|
// empty"): toDir intentionally THROWS rather than emit an unusable
|
||||||
|
// directory name when a slug sanitizes to "" — deliberate, not a
|
||||||
|
// bug to consolidate away, because a slug here becomes a real
|
||||||
|
// on-disk path segment (parsePhaseId's dir<->identity bijection;
|
||||||
|
// every other slug call site just accepts "" silently). Post-
|
||||||
|
// migration, toDir now shares the canonical's OWN transliteration
|
||||||
|
// + truncation, so it throws in exactly the cases the canonical
|
||||||
|
// itself would produce "" (CJK-only / punctuation-only / emoji-
|
||||||
|
// only input — see B3) and NOT in any case the canonical succeeds
|
||||||
|
// (Cyrillic — the #2848-class defect this migration fixes).
|
||||||
|
// Surfaced as the canonical's own "" here so the corpus loop still
|
||||||
|
// demands real agreement everywhere the canonical succeeds, and
|
||||||
|
// only tolerates the throw where the canonical's answer is itself
|
||||||
|
// "". Assert the SPECIFIC sentinel throw, not any exception — a
|
||||||
|
// row that accepted an unrelated crash (e.g. a TypeError from a
|
||||||
|
// regression elsewhere in toDir) as if it were the declared
|
||||||
|
// empty-sanitize guard would pass while testing nothing.
|
||||||
|
assert.ok(
|
||||||
|
e instanceof Error && e.message.startsWith('toDir: slug sanitizes to empty'),
|
||||||
|
`expected toDir's declared "toDir: slug sanitizes to empty" guard, got: ${e && e.message}`,
|
||||||
|
);
|
||||||
|
const canonical = coreUtils.generateSlugInternal(text, null);
|
||||||
|
if (canonical === '' || canonical === null) return canonical ?? '';
|
||||||
|
return `__THREW__:${e.message}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'phase-locator.cts:269 findPhaseInternal phase_slug (direct require, exported)',
|
||||||
|
cap: null,
|
||||||
|
call(text) {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3883-pl-'));
|
||||||
|
try {
|
||||||
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', `01-${text}`);
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
const result = phaseLocator.findPhaseInternal(tmpDir, '1');
|
||||||
|
return result ? result.phase_slug : undefined;
|
||||||
|
} finally {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'workstream-name-policy.cts:75 toWorkstreamSlug (direct require, exported)',
|
||||||
|
// #3883 regression (fixed): this site never truncated pre-migration.
|
||||||
|
// generateSlugInternal's `maxLen` parameter now lets it opt out of the
|
||||||
|
// 60-char default instead of colliding distinct >60-char names.
|
||||||
|
cap: null,
|
||||||
|
call(text) {
|
||||||
|
return workstreamNamePolicy.toWorkstreamSlug(text);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// A4: sites intentionally different from the canonical, with their reason.
|
||||||
|
// A3 skips these for the axis named, an UNDECLARED divergence still fails.
|
||||||
|
const DECLARED_DIFFERENT = [
|
||||||
|
{
|
||||||
|
site: 'gsd2-import.cts:97 slugify (direct require, exported)',
|
||||||
|
axis: 'truncation',
|
||||||
|
reason:
|
||||||
|
'Documented distinct contract (gsd2-import.cts:97-102, tests/gsd2-import.test.cjs '
|
||||||
|
+ '"#2848 row 11"): slugify shares the transliteration primitive with '
|
||||||
|
+ 'generateSlugInternal but deliberately does NOT truncate at 60 chars — '
|
||||||
|
+ 'GSD-2 import titles are not filesystem path segments the way phase '
|
||||||
|
+ 'directory slugs are.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
site: 'active-workstream-store.cts:97 getWorkstreamSessionKey (direct require, exported)',
|
||||||
|
axis: 'input-domain',
|
||||||
|
reason:
|
||||||
|
'The slugified text is never caller-supplied: it is always one of a '
|
||||||
|
+ 'fixed ASCII whitelist of environment-variable KEY NAMES '
|
||||||
|
+ '(WORKSTREAM_SESSION_ENV_KEYS, all 13 entries: GSD_SESSION_KEY, '
|
||||||
|
+ 'CODEX_THREAD_ID, CLAUDE_SESSION_ID, CLAUDE_CODE_SESSION_ID, '
|
||||||
|
+ 'CLAUDE_CODE_SSE_PORT, OPENCODE_SESSION_ID, GEMINI_SESSION_ID, '
|
||||||
|
+ 'CURSOR_SESSION_ID, WINDSURF_SESSION_ID, TERM_SESSION_ID, WT_SESSION, '
|
||||||
|
+ 'TMUX_PANE, ZELLIJ_SESSION_NAME). The shared unicode/CJK/emoji/'
|
||||||
|
+ 'boundary-length corpus can never reach this call site in '
|
||||||
|
+ 'production, so it is checked separately below against its own real '
|
||||||
|
+ 'input domain rather than run through the shared CORPUS loop.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
site: 'phase-id.cts:380 toDir safeSlug guard (direct require, exported)',
|
||||||
|
axis: 'empty-sanitize-guard',
|
||||||
|
reason:
|
||||||
|
'toDir (phase-id.cts:380, docstring above toDir) intentionally THROWS '
|
||||||
|
+ '"toDir: slug sanitizes to empty" instead of emitting an unusable '
|
||||||
|
+ 'on-disk directory name — this is a disk-naming call site that '
|
||||||
|
+ 'protects the parsePhaseId dir<->identity bijection (an empty slug '
|
||||||
|
+ 'would leave a dangling trailing hyphen; every other slug call site '
|
||||||
|
+ 'silently accepts ""). Migrated to share the canonical\'s own '
|
||||||
|
+ 'transliteration + truncation, so the throw now fires in EXACTLY the '
|
||||||
|
+ 'cases the canonical itself reduces to "" (CJK/punctuation/emoji-only '
|
||||||
|
+ '— see B3), and never in a case the canonical succeeds (Cyrillic — '
|
||||||
|
+ 'the #2848-class defect this migration fixes for every other site).',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test('A4 deliberatelyDifferentSitesAreDeclared: the declared-divergence list names real sites with real reasons', () => {
|
||||||
|
// This mechanism must exist even though today it is non-empty (both
|
||||||
|
// entries above are load-bearing — remove either and A3 below starts
|
||||||
|
// failing for the corresponding site/axis, which is exactly the point:
|
||||||
|
// A3 cannot be silently satisfied by exempting the hard cases).
|
||||||
|
assert.ok(Array.isArray(DECLARED_DIFFERENT));
|
||||||
|
for (const entry of DECLARED_DIFFERENT) {
|
||||||
|
assert.strictEqual(typeof entry.site, 'string');
|
||||||
|
assert.ok(entry.site.length > 0);
|
||||||
|
assert.strictEqual(typeof entry.reason, 'string');
|
||||||
|
assert.ok(entry.reason.length > 20, 'a declared-different entry needs a real reason, not a placeholder');
|
||||||
|
}
|
||||||
|
const declaredForTruncation = DECLARED_DIFFERENT.filter((e) => e.axis === 'truncation').map((e) => e.site);
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
declaredForTruncation,
|
||||||
|
['gsd2-import.cts:97 slugify (direct require, exported)'],
|
||||||
|
'exactly one site is declared to skip the truncation axis — an undeclared truncation gap must fail A3',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A3 everySlugCallSiteAgreesWithTheCanonical', () => {
|
||||||
|
for (const site of SITES) {
|
||||||
|
describe(site.name, () => {
|
||||||
|
for (const [label, text] of CORPUS) {
|
||||||
|
test(`corpus:${label} agrees with generateSlugInternal`, () => {
|
||||||
|
const canonical = coreUtils.generateSlugInternal(text);
|
||||||
|
const observed = site.call(text);
|
||||||
|
assert.strictEqual(
|
||||||
|
observed,
|
||||||
|
canonical,
|
||||||
|
`site "${site.name}" on ${JSON.stringify(text)}: expected canonical ${JSON.stringify(canonical)}, got ${JSON.stringify(observed)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (site.cap !== null) {
|
||||||
|
for (const [label, text] of BOUNDARY_CORPUS) {
|
||||||
|
test(`boundary:${label} agrees with generateSlugInternal(text, ${site.cap})`, () => {
|
||||||
|
const canonical = coreUtils.generateSlugInternal(text, site.cap);
|
||||||
|
const observed = site.call(text);
|
||||||
|
assert.strictEqual(
|
||||||
|
observed,
|
||||||
|
canonical,
|
||||||
|
`site "${site.name}" at truncation boundary ${JSON.stringify(text)}: expected canonical ${JSON.stringify(canonical)}, got ${JSON.stringify(observed)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// #3883 regression coverage: a site whose pre-migration contract
|
||||||
|
// never truncated must still never truncate post-migration — prove
|
||||||
|
// it on inputs well past the OLD hard-coded 60-char cap, not just
|
||||||
|
// at the boundary. This is the exact axis the original harness
|
||||||
|
// could not see (it always compared against a 60-capped canonical).
|
||||||
|
for (const [label, text] of LONG_UNCAPPED_CORPUS) {
|
||||||
|
test(`long:${label} agrees with generateSlugInternal(text, null) and is not truncated to 60`, () => {
|
||||||
|
const canonical = coreUtils.generateSlugInternal(text, null);
|
||||||
|
const observed = site.call(text);
|
||||||
|
assert.strictEqual(
|
||||||
|
observed,
|
||||||
|
canonical,
|
||||||
|
`site "${site.name}" on long input ${JSON.stringify(text)}: expected untruncated canonical ${JSON.stringify(canonical)}, got ${JSON.stringify(observed)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// #3883 collision regression: the two concrete collisions the security
|
||||||
|
// review proved by execution (workstream-name-policy.cts toWorkstreamSlug,
|
||||||
|
// phase-id.cts toDir) — driven through the REAL surfaces, not the
|
||||||
|
// canonical directly, and asserted RED against 01cc283da / GREEN after
|
||||||
|
// generateSlugInternal gained the maxLen parameter and these sites opted
|
||||||
|
// out of the 60-char default.
|
||||||
|
describe('A5 uncappedSitesDoNotCollideOnLongInputs (#3883 collision regression)', () => {
|
||||||
|
const COLLISION_PAIRS = [
|
||||||
|
[`${'a'.repeat(60)}alpha`, `${'a'.repeat(60)}beta`],
|
||||||
|
[
|
||||||
|
'migrate the legacy billing subsystem onto the new distributed queue system today',
|
||||||
|
'migrate the legacy billing subsystem onto the new distributed queue system tomorrow',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
for (const site of SITES.filter((s) => s.cap === null)) {
|
||||||
|
describe(site.name, () => {
|
||||||
|
for (const [textA, textB] of COLLISION_PAIRS) {
|
||||||
|
test(`"${textA.slice(0, 20)}..." vs "${textB.slice(0, 20)}..." produce distinct slugs`, () => {
|
||||||
|
const slugA = site.call(textA);
|
||||||
|
const slugB = site.call(textB);
|
||||||
|
assert.notEqual(
|
||||||
|
slugA,
|
||||||
|
slugB,
|
||||||
|
`site "${site.name}": distinct >60-char inputs collided on slug ${JSON.stringify(slugA)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// gsd2-import.cts:97 slugify — compared on the general corpus (must still
|
||||||
|
// transliterate correctly) but NOT the boundary corpus (declared, A4).
|
||||||
|
describe('gsd2-import.cts:97 slugify (direct require, exported)', () => {
|
||||||
|
for (const [label, text] of CORPUS) {
|
||||||
|
test(`corpus:${label} agrees with generateSlugInternal`, () => {
|
||||||
|
const canonical = coreUtils.generateSlugInternal(text);
|
||||||
|
const observed = gsd2Import.slugify(text);
|
||||||
|
assert.strictEqual(observed, canonical, `slugify on ${JSON.stringify(text)}: expected ${JSON.stringify(canonical)}, got ${JSON.stringify(observed)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// active-workstream-store.cts:97 getWorkstreamSessionKey — checked against
|
||||||
|
// its own real, restricted input domain (declared, A4), not the shared corpus.
|
||||||
|
describe('active-workstream-store.cts:97 getWorkstreamSessionKey (real input domain only)', () => {
|
||||||
|
const REAL_ENV_KEYS = [
|
||||||
|
'GSD_SESSION_KEY', 'CODEX_THREAD_ID', 'CLAUDE_SESSION_ID', 'CLAUDE_CODE_SESSION_ID',
|
||||||
|
'CLAUDE_CODE_SSE_PORT', 'OPENCODE_SESSION_ID', 'GEMINI_SESSION_ID', 'CURSOR_SESSION_ID',
|
||||||
|
'WINDSURF_SESSION_ID', 'TERM_SESSION_ID', 'WT_SESSION', 'TMUX_PANE', 'ZELLIJ_SESSION_NAME',
|
||||||
|
];
|
||||||
|
for (const envKey of REAL_ENV_KEYS) {
|
||||||
|
test(`${envKey} slugifies identically to generateSlugInternal(${envKey})`, () => {
|
||||||
|
const saved = {};
|
||||||
|
for (const k of REAL_ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
|
||||||
|
process.env[envKey] = 'token123';
|
||||||
|
try {
|
||||||
|
const observedKey = activeWorkstreamStore.getWorkstreamSessionKey();
|
||||||
|
const canonicalPrefix = coreUtils.generateSlugInternal(envKey);
|
||||||
|
assert.strictEqual(
|
||||||
|
observedKey,
|
||||||
|
`${canonicalPrefix}-token123`,
|
||||||
|
`getWorkstreamSessionKey(${envKey}): expected the envKey portion to equal generateSlugInternal(${envKey})`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
for (const k of REAL_ENV_KEYS) {
|
||||||
|
if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── B. Boundaries (repo rule: limit-1, limit, limit+1) ───────────────────────
|
||||||
|
|
||||||
|
describe('B1 slug truncation length: one-under / exact / one-over — no trailing separator at any', () => {
|
||||||
|
for (const [label, text] of BOUNDARY_CORPUS) {
|
||||||
|
test(`canonical: ${label}`, () => {
|
||||||
|
const slug = coreUtils.generateSlugInternal(text);
|
||||||
|
assert.ok(!slug.endsWith('-'), `generateSlugInternal(${JSON.stringify(text)}) must not end in a hyphen; got ${JSON.stringify(slug)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('B2 empty and whitespace-only input', () => {
|
||||||
|
test('generateSlugInternal("") → null', () => {
|
||||||
|
assert.strictEqual(coreUtils.generateSlugInternal(''), null);
|
||||||
|
});
|
||||||
|
test('generateSlugInternal(" ") → "" (whitespace collapses, not null — falsy check is on the input, not the output)', () => {
|
||||||
|
assert.strictEqual(coreUtils.generateSlugInternal(' '), '');
|
||||||
|
});
|
||||||
|
test('cmdGenerateSlug rejects empty input with an explicit error (not a silent empty slug)', () => {
|
||||||
|
const result = runGsdTools(['generate-slug', ''], process.cwd(), { HOME: os.tmpdir() });
|
||||||
|
assert.ok(!result.success, 'generate-slug must fail on empty text');
|
||||||
|
assert.ok(result.error.includes('text required'), `expected "text required" error, got: ${result.error}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('B3 input that is entirely non-transliterable (Cyrillic-to-empty class, generalized)', () => {
|
||||||
|
test('CJK-only title → canonical produces "" (not an error, not a crash)', () => {
|
||||||
|
assert.strictEqual(coreUtils.generateSlugInternal('中文测试'), '');
|
||||||
|
});
|
||||||
|
test('punctuation-only title → canonical produces ""', () => {
|
||||||
|
assert.strictEqual(coreUtils.generateSlugInternal('!!!@@@###'), '');
|
||||||
|
});
|
||||||
|
test('emoji-only title → canonical produces ""', () => {
|
||||||
|
assert.strictEqual(coreUtils.generateSlugInternal('😀😁😂'), '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const {
|
|||||||
validateActiveWorkstreamName,
|
validateActiveWorkstreamName,
|
||||||
assertValidActiveWorkstreamName,
|
assertValidActiveWorkstreamName,
|
||||||
isValidActiveWorkstreamName,
|
isValidActiveWorkstreamName,
|
||||||
|
toWorkstreamSlug,
|
||||||
INVALID_ACTIVE_WORKSTREAM_NAME_MESSAGE,
|
INVALID_ACTIVE_WORKSTREAM_NAME_MESSAGE,
|
||||||
} = require('../gsd-core/bin/lib/workstream-name-policy.cjs');
|
} = require('../gsd-core/bin/lib/workstream-name-policy.cjs');
|
||||||
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
|
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
|
||||||
@@ -49,4 +50,19 @@ describe('workstream-name-policy', () => {
|
|||||||
assert.equal(isValidActiveWorkstreamName('ws..traversal'), false);
|
assert.equal(isValidActiveWorkstreamName('ws..traversal'), false);
|
||||||
assert.equal(isValidActiveWorkstreamName('alpha beta'), false);
|
assert.equal(isValidActiveWorkstreamName('alpha beta'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #3883 regression: the slug consolidation (01cc283da) routed
|
||||||
|
// toWorkstreamSlug through generateSlugInternal's hard-coded 60-char cap,
|
||||||
|
// which this site never had. Two distinct >60-char names collapsed onto
|
||||||
|
// the identical slug, so `workstream create` on the second name silently
|
||||||
|
// wrote into (or reported already_exists for) the first name's directory.
|
||||||
|
test('toWorkstreamSlug does not truncate — distinct long names stay distinct', () => {
|
||||||
|
const nameA = `${'a'.repeat(60)}alpha`;
|
||||||
|
const nameB = `${'a'.repeat(60)}beta`;
|
||||||
|
const slugA = toWorkstreamSlug(nameA);
|
||||||
|
const slugB = toWorkstreamSlug(nameB);
|
||||||
|
assert.notEqual(slugA, slugB, 'distinct >60-char workstream names must not collide on slug');
|
||||||
|
assert.equal(slugA, `${'a'.repeat(60)}alpha`);
|
||||||
|
assert.equal(slugB, `${'a'.repeat(60)}beta`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user