* 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>
123 lines
4.8 KiB
TypeScript
123 lines
4.8 KiB
TypeScript
/**
|
|
* Canonical workstream name validation and slug normalization
|
|
* (ADR-457 build-at-publish: the hand-written bin/lib/workstream-name-policy.cjs
|
|
* collapsed to a TypeScript source of truth). Behaviour is preserved
|
|
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
|
|
*
|
|
* 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 =
|
|
'Invalid workstream name: must be alphanumeric, hyphens, underscores, or dots';
|
|
|
|
const ACTIVE_WORKSTREAM_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
|
|
/** Result of validateActiveWorkstreamName. */
|
|
export interface WorkstreamValidationResult {
|
|
ok: boolean;
|
|
reason: 'empty' | 'invalid' | null;
|
|
value: string | null;
|
|
}
|
|
|
|
export function normalizeWorkstreamNameInput(name: string | null | undefined): string | null {
|
|
const value = String(name ?? '').trim();
|
|
return value || null;
|
|
}
|
|
|
|
/**
|
|
* Returns true when `name` contains a path separator, a bare dot, or a
|
|
* dot-dot sequence — any of which would make the name unsafe for use as a
|
|
* filesystem path segment.
|
|
*/
|
|
export function hasInvalidPathSegment(name: string | null | undefined): boolean {
|
|
const value = String(name ?? '');
|
|
return /[/\\]/.test(value) || value === '.' || value === '..' || value.includes('..');
|
|
}
|
|
|
|
export function validateActiveWorkstreamName(name: string | null | undefined): WorkstreamValidationResult {
|
|
const value = normalizeWorkstreamNameInput(name);
|
|
if (!value) {
|
|
return {
|
|
ok: false,
|
|
reason: 'empty',
|
|
value: null,
|
|
};
|
|
}
|
|
if (hasInvalidPathSegment(value) || !ACTIVE_WORKSTREAM_RE.test(value)) {
|
|
return {
|
|
ok: false,
|
|
reason: 'invalid',
|
|
value,
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
reason: null,
|
|
value,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate a workstream name.
|
|
* Allowed: alphanumeric, hyphens, underscores, dots.
|
|
* Disallowed: empty, spaces, slashes, special chars, path traversal.
|
|
*
|
|
* Alias for isValidActiveWorkstreamName; provided for SDK-layer callers.
|
|
*/
|
|
export function validateWorkstreamName(name: string | null | undefined): boolean {
|
|
return isValidActiveWorkstreamName(name);
|
|
}
|
|
|
|
/**
|
|
* Convert a display name to a URL/filesystem-safe workstream slug.
|
|
* Lowercases, collapses non-alphanumeric runs to hyphens, strips leading/trailing hyphens.
|
|
*/
|
|
export function toWorkstreamSlug(name: string | null | undefined): string {
|
|
// #3883 (ADR-3473 §8.3): delegate to the canonical slug formula
|
|
// (generateSlugInternal, core-utils.cts) rather than re-implementing it —
|
|
// this call site previously diverged from it (no transliteration, no
|
|
// 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) ?? '';
|
|
}
|
|
|
|
/**
|
|
* Returns true when `name` is a valid active workstream name:
|
|
* - Must start with alphanumeric
|
|
* - May contain alphanumeric, dots, underscores, hyphens
|
|
* - Must not contain path traversal sequences (..)
|
|
*/
|
|
export function isValidActiveWorkstreamName(name: string | null | undefined): boolean {
|
|
return validateActiveWorkstreamName(name).ok;
|
|
}
|
|
|
|
export function assertValidActiveWorkstreamName(
|
|
name: string | null | undefined,
|
|
errorMessage: string = INVALID_ACTIVE_WORKSTREAM_NAME_MESSAGE,
|
|
): string {
|
|
const validation = validateActiveWorkstreamName(name);
|
|
if (!validation.ok) {
|
|
throw new Error(errorMessage);
|
|
}
|
|
return validation.value!;
|
|
}
|