Files
msd-core/tests/slug-derivation-drift-guard.test.cjs
Dennis Alexis Valin Dittrich 925a363879 enhance(#4032): apply configured agent tool grants (#4238)
* test(4032): add failing installed-agent grants contract

Cover global and project agent_tools precedence at the real Claude installer seam before adding implementation.

* feat(4032): apply configured agent tool grants during staging

Resolve selector-level global and project config once per staging call, then append validated grants before runtime conversion.

* test(4032): cover host grant and quoted MCP contracts

Exercise installed host artifacts and prove ZCode must treat quoted MCP scalars like plain MCP grants.

* feat(4032): apply configured agent tool grants across runtimes

Move augmentation and scalar identity into the converter seam so every staged artifact preserves host policy.

* fix(4032): register agent tool grants in configuration

Accept documented agent_tools config without unknown-key warnings.\n\nKeep installer fixtures on the shared temporary-directory helper.

* fix(4032): translate configured MCP grants for Kilo

Reuse the converter-owned scalar decoder so quoted canonical grants reach Kilo's native permission keys without altering other host policies.

* fix(4032): decode YAML-escaped tool grants

* fix(4032): emit valid inline agent tool grants

* fix(4032): reject invalid trailing-colon grants

* test(#4032): cover cross-review remediation gaps

* fix(#4032): close cross-runtime grant gaps

* test(#4032): expose Kimi global project context

* fix(#4032): preserve Kimi project config context

* chore(#4032): add release note

* test(#4032): expose fork review regressions

* fix(#4032): address fork review findings

* test(#4032): make byte-stability assertion portable

Compare repeat installs at one root so platform-specific path rendering cannot
masquerade as an agent_tools behavior change.

* chore(#4032): bind changeset to upstream PR 4238

* fix(#4032): address trek-e review findings (2,3,4,5,6,7,8)

Fixes fail-closed decode-failure handling in ZCode's mcp__ stripper,
a comment-only `tools:` header mis-parse that silently dropped
configured grants, and a naive comma-split that could tear a quoted
scalar containing a literal comma. Documents Kilo's inherent
`{server}_{tool}` MCP-permission-key collision (external, fixed
format — not ours to widen) and locks the existing first-seen-wins
resolution in with a regression test.

Opts kimi/kimi-code out of the ADR-1235 pre-converter path-rewrite
step: routing Kimi through that pipeline (needed so project-scoped
agent_tools selectors reach it) was short-circuiting Kimi's own
neutralizeKimiAgentPrompt, which expects the original ~/.claude/gsd-core
text rather than a pre-rewritten Kimi path.

Extends the fast-check token pool and per-runtime install coverage
with the missing comment/comma/broad-runtime cases the prior review
flagged as untested.

* docs(#4032): add CONTEXT.md glossary entries for agent_tools resolver + pre-converter step

Documents readGsdEffectiveAgentTools (Install Model Override Resolver
Module) and the appendAgentTools pre-converter pipeline step (Runtime
Artifact Conversion Module), per contributor-standards.md's
new-seam glossary requirement (finding 1).

* fix(#4032): address agy adversarial review findings

An agy (gemini-3.8-flash-high) adversarial pass over the prior review-fix
commit found the fixes for findings 3, 4, 6 and 8 had unfixed sibling gaps,
plus a genuine new regression and two CONTEXT.md inaccuracies:

- ZCode's comment-only `tools: # note` header matched the inline-value
  branch instead of falling through to the block-list scan, so a following
  mcp__* item leaked through unstripped — the exact defect finding 4 fixed
  in appendAgentTools, unfixed in this sibling function.
- Reverted capabilities/kimi-code/capability.json's noPathRewrite: true.
  kimi-code uses the standard 'agents' kind with converter: null (not
  kimi-agents — confirmed by reading the descriptor, not its prose
  description), so it never went through the pipeline change finding 5
  fixed, and disabling its path rewrite broke every ~/.claude/ embed in
  its shipped agents instead.
- decodeToolScalar never stripped a trailing ` # comment` from a bare
  (unquoted) scalar, so a comment after a block-list item, or after an
  appended grant on an inline line, became part of the "tool name" —
  fixed at the source (one call site fixes every consumer).
- appendAgentTools's comment-index scan wasn't quote-aware, so a `#`
  inside a quoted scalar (`"mcp__server #1"`) was mistaken for a comment
  start and corrupted the quote.
- parseFrontmatterTools (Kimi/Qwen's tool-list reader, downstream of
  appendAgentTools's own output) had the same naive comma-split and
  comment-only-header gaps as findings 4 and 6, unpatched.
- The all-runtime smoke test's presence assertion was built on a guessed
  omit-list; empirically only 7 of 17 runtimes keep an arbitrary mcp__
  grant recognizable, replaced with a verified allowlist.
- CONTEXT.md claimed a `project:<agent>` selector prefix that does not
  exist (project override is a same-key merge across two config files)
  and mislabeled stageAgentsForRuntimeWithConverter's module.

* fix(#4032): address full-PR review (Opus critical/ponytail + agy)

A whole-PR pass (critical-code-reviewer + ponytail-review on Opus, plus a
second agy full-source adversarial pass) surfaced defects the earlier
finding-scoped passes couldn't reach:

- appendAgentTools corrupted a `tools:` line whose ENTIRE value is a
  leading quoted scalar (`tools: "Read"` -> `tools: "Read", Write`,
  invalid YAML) — there is no safe line-surgical rewrite here, so it now
  refuses to touch that shape instead of emitting broken frontmatter.
- decodeToolScalar's malformed-trailing-quote check ran BEFORE comment
  stripping, so a bare tool name with a quote inside its own trailing
  comment (`Bash # note: "internal"`) was wrongly rejected. Reordered.
- findUnquotedCommentIndex (added in the prior remediation commit) was
  built on a wrong model of YAML: a `#` after whitespace starts a real
  comment in a plain scalar regardless of nearby quote characters —
  verified against the actual parser. The one case that DOES need
  protection (a leading quoted scalar) is now refused outright above, so
  the quote-tracking scan was dead weight solving a problem that no
  longer reaches it. Removed; reverted to the plain `[ \t]#` scan.
- Kilo has a SEPARATE agent-frontmatter parser (convertClaudeToKiloFrontmatter,
  distinct from the buildKiloAgentPermissionBlock fixed earlier) with the
  same comment-only-header and naive-comma-split gaps as findings 4 and 6
  — unfixed in both its src/ and bin/install.js copies. Fixed in both,
  exporting splitToolScalars for bin/install.js to reuse rather than
  reimplementing it.
- Pipeline docstring in stageAgentsForRuntimeWithConverter still listed 5
  steps, omitting appendAgentTools (now step 3 of 6).
- docs/CONFIGURATION.md didn't state that a --global install still
  discovers agent_tools from the cwd's .planning/config.json (confirmed
  intentional and already covered by a dedicated test, not a bug).
- Removed install-engine.cts's deps.cwd injection seam: zero callers or
  tests ever populated it.

Two claims from this round were verified and rejected, not fixed:
prototype pollution via a `__proto__` selector key (empirically confirmed
`Object.prototype` is never touched — only reassigns the resolver's own
local object's prototype, with no observable effect), and a `*` grant
value crashing YAML parsing as an alias reference (empirically confirmed
it parses as plain scalar text, no crash). A pre-existing, unrelated
defect (extractFrontmatterField returns null for block-list `tools:` on
Copilot/Antigravity/Cursor/Codex/Qwen, affecting two shipped agents
today) was filed as a follow-up rather than fixed here — it predates
#4032 and isn't caused or worsened by this PR.

* fix(#4032): update stale slug-derivation-drift-guard fixture line

normalizeKimiSkillName's real closing brace moved from line 616 to 635 as a
side effect of this PR's edits to runtime-artifact-conversion.cts; the
MAJOR-1 fixture's hardcoded realEndLine had gone stale.

* fix(#4032): address CodeRabbit findings on projectDir threading and flow-sequence tools

bin/install.js's installAgentsKindStandalone call site omitted the projectDir
argument the function already supports, so a global install through this
legacy branch silently fell back to the runtime config dir instead of
process.cwd() when resolving project-scoped agent_tools grants — inconsistent
with the sibling installOpencodeFamilyArtifacts call site, which already
threads it correctly.

appendAgentTools' leading-quoted-scalar bailout did not cover a YAML flow
sequence (`tools: [Bash, Read]`): splitToolScalars tore it apart on the
in-sequence commas and appended past its closing bracket, producing invalid
frontmatter. Extended the bailout regex to also refuse a value starting with
`[`, matching the same "whole node, nothing may follow" reasoning already
applied to quoted scalars.

---------

Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Test <test@test.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-05 04:52:45 -04:00

577 lines
32 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Unit coverage for the SLUG-DERIVATION drift guard
* (scripts/lint-slug-derivation-drift.cjs, issue #3987, closing epic #3473's
* last two residuals).
*
* Modelled on tests/enumeration-drift-guard.test.cjs /
* tests/completion-ratio-single-owner.test.cjs: exercises the guard's pure
* functions directly (no `readFileSync().includes()` in a test body), plus
* a `scanRepo` PROVE-IT-CAN-FAIL row on a fresh synthetic tree — this
* repo's rule that a drift guard must be shown capable of failing, not just
* shown to pass on an already-clean tree.
*
* .gsd/phase/feat-3987-guard-slug-and-swallow/50-test-matrix.md rows T1-T12
* map onto the describe blocks below.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const {
findSlugDerivationDrift,
scanRepo,
buildLogicalStatements,
stripComments,
SCAN_EXT,
} = require(path.join(ROOT, 'scripts', 'lint-slug-derivation-drift.cjs'));
const { generateSlugInternal } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'core-utils.cjs'));
const { getPhaseDirFromPhaseId } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phase-id.cjs'));
const { slugify: qaSmellRatchetSlugify } = require(path.join(ROOT, 'scripts', 'qa-smell-ratchet.cjs'));
const { createTempDir, cleanup } = require('./helpers.cjs');
const { splitLines } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'text-lines.cjs'));
const { MAX_REGEX_LITERAL_LEN, resetRegexScanStats, getRegexScanStats } = require(path.join(ROOT, 'scripts', 'lib', 'drift-scan.cjs'));
// ─── T1: the real deleted #3883 shape (POSITIVE) ──────────────────────────
describe('findSlugDerivationDrift — T1: the real historical inline-copy shape', () => {
test('single-line chained copy (matches src/gsd2-import.cts slugify\'s own shape) is flagged', () => {
const line = "function slugify(title) { return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); }";
const v = findSlugDerivationDrift(line, 'src/unrelated.cts');
assert.equal(v.length, 1);
assert.equal(v[0].line, 1);
});
test('multi-line chained copy (matches the real deleted #3883 shape, and the pre-fix scripts/qa-smell-ratchet.cjs slugify) is flagged as ONE statement', () => {
const text = [
'function slugify(value) {',
' return value',
' .toLowerCase()',
" .replace(/[^a-z0-9]+/g, '-')",
" .replace(/^-+|-+$/g, '')",
' .slice(0, 60);',
'}',
].join('\n');
const v = findSlugDerivationDrift(text, 'src/unrelated.cts');
assert.equal(v.length, 1);
assert.equal(v[0].line, 2, 'reports at the statement\'s OPENING line, not the line either .replace() sits on');
});
test('the exact pre-fix tests/planning-inspect.test.cjs helper shape is flagged', () => {
const line = "function slugify(name) { return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); }";
const v = findSlugDerivationDrift(line, 'tests/unrelated.test.cjs');
assert.equal(v.length, 1);
});
});
// ─── T2: the canonical owner is NOT flagged ───────────────────────────────
describe('findSlugDerivationDrift — T2: the canonical owner (src/core-utils.cts generateSlugInternal)', () => {
test('the real generateSlugInternal body is not flagged, EVEN UNEXEMPTED — its two clauses sit in different statements by construction', () => {
const text = fs.readFileSync(path.join(ROOT, 'src', 'core-utils.cts'), 'utf8');
const unexempt = findSlugDerivationDrift(text, 'ZZZ-not-the-real-owner-path.cts');
assert.deepEqual(unexempt, []);
});
test('the real owner file at its real repo-relative path is not flagged (allowlist entry present as a defensive backstop)', () => {
const text = fs.readFileSync(path.join(ROOT, 'src', 'core-utils.cts'), 'utf8');
const v = findSlugDerivationDrift(text, path.join('src', 'core-utils.cts'));
assert.deepEqual(v, []);
});
test('a synthetic refactor that DID fold generateSlugInternal into one statement would be flagged if NOT for the explicit allowlist entry — proving the entry is load-bearing, not decorative', () => {
const folded = [
'function generateSlugInternal(text, maxLen) {',
" return transliterateForSlug(text).replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');",
'}',
].join('\n');
const unexempt = findSlugDerivationDrift(folded, 'ZZZ-not-core-utils.cts');
assert.equal(unexempt.length, 1, 'the folded shape IS detectable — proves the real file escapes only by construction, not because the detector cannot see this shape');
const exempt = findSlugDerivationDrift(folded, path.join('src', 'core-utils.cts'));
assert.deepEqual(exempt, [], 'the allowlist entry suppresses it at the real owner path');
});
});
// ─── T3-T5: the 3 sanctioned sites are exempted BY the allowlist ──────────
describe('findSlugDerivationDrift — T3-T5: sanctioned sites are exempted BY the allowlist, not by accident', () => {
const sanctioned = [
{ file: path.join('src', 'gsd2-import.cts'), fn: 'slugify' },
{ file: path.join('src', 'runtime-artifact-conversion.cts'), fn: 'normalizeKimiSkillName' },
{ file: path.join('scripts', 'generate-package-identity.cjs'), fn: 'slugifyPackageName' },
];
for (const { file, fn } of sanctioned) {
test(`${file} (${fn}) is exempted at its real path`, () => {
const text = fs.readFileSync(path.join(ROOT, file), 'utf8');
const v = findSlugDerivationDrift(text, file);
assert.deepEqual(v, []);
});
test(`${file} (${fn}) IS flagged when the SAME text is attributed to a non-exempt path — proves the allowlist, not the shape, suppresses it`, () => {
const text = fs.readFileSync(path.join(ROOT, file), 'utf8');
const v = findSlugDerivationDrift(text, `ZZZ-not-exempt-${path.basename(file)}`);
assert.ok(v.length >= 1, `expected ${file}'s re-derivation to be independently detectable outside its allowlist entry`);
});
}
});
// ─── MAJOR-1 (security review, #3987): exemption scoping does not bleed past
// the exempted function's own closing brace ────────────────────────────────
describe('findSlugDerivationDrift — MAJOR-1: allowlist exemption is scoped to the REAL function body, not "until the next top-level function"', () => {
const sanctionedRealEndLines = [
{ file: path.join('src', 'core-utils.cts'), fn: 'generateSlugInternal', realEndLine: 199 },
{ file: path.join('src', 'gsd2-import.cts'), fn: 'slugify', realEndLine: 103 },
{ file: path.join('src', 'runtime-artifact-conversion.cts'), fn: 'normalizeKimiSkillName', realEndLine: 635 },
{ file: path.join('scripts', 'generate-package-identity.cjs'), fn: 'slugifyPackageName', realEndLine: 42 },
];
for (const { file, fn, realEndLine } of sanctionedRealEndLines) {
test(`a re-derivation planted immediately AFTER ${fn}'s (${file}) real closing brace IS flagged — the pre-fix bug exempted up to 50 lines past the function's own 11-line body`, () => {
const lines = splitLines(fs.readFileSync(path.join(ROOT, file), 'utf8'));
const evilSlug = "const evilSlug = (t) => t.replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'');";
lines.splice(realEndLine, 0, evilSlug); // insert right after the function's REAL closing brace
const text = lines.join('\n');
const v = findSlugDerivationDrift(text, file);
assert.equal(v.length, 1, `expected the planted violation right after ${fn}'s real body to be flagged`);
assert.equal(v[0].line, realEndLine + 1);
});
test(`a re-derivation planted INSIDE ${fn}'s (${file}) own real body remains exempt`, () => {
const text = fs.readFileSync(path.join(ROOT, file), 'utf8');
// The real bodies here are single-collapse-clause shapes (never both
// clauses in one statement) by construction, so this only re-confirms
// the existing "exempted at its real path" T3-T5 assertion holds with
// the new extent-based exemption mechanism, not the old bleed-through one.
assert.deepEqual(findSlugDerivationDrift(text, file), []);
});
}
});
// ─── T6: the rejected loose [^A-Za-z0-9._-] line-level shape is NOT flagged ─
describe('findSlugDerivationDrift — T6: the rejected loose line-level false-positive shape', () => {
test('a negated-class collapse to a DIFFERENT character ("_") sharing a statement with a hyphen-trim is NOT flagged — clause (a) requires collapsing specifically to \'-\'', () => {
const line = "const p = raw.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^-+|-+$/g, '');";
assert.deepEqual(findSlugDerivationDrift(line, 'src/unrelated.cts'), []);
});
test('two UNRELATED statements sharing one physical line (separated by \';\') are NOT merged into one false-positive statement', () => {
const line = "a.replace(/[^A-Za-z0-9._-]+/g, '-'); b.replace(/^-+|-+$/g, '');";
assert.deepEqual(findSlugDerivationDrift(line, 'src/unrelated.cts'), []);
});
test('clause (a) alone (no trim-replace anywhere) is not flagged', () => {
const line = "const p = raw.replace(/[^A-Za-z0-9._-]+/g, '-');";
assert.deepEqual(findSlugDerivationDrift(line, 'src/unrelated.cts'), []);
});
test('clause (b) alone (no charclass-replace anywhere) is not flagged', () => {
const line = "const p = raw.replace(/^-+|-+$/g, '');";
assert.deepEqual(findSlugDerivationDrift(line, 'src/unrelated.cts'), []);
});
});
// ─── MAJOR-3 (security review, #3987): widened detector shapes ───────────
describe('findSlugDerivationDrift — MAJOR-3: widened detection (each of these evaded the pre-fix detector)', () => {
const positives = [
['replaceAll alongside replace', "function f(t){return t.toLowerCase().replaceAll(/[^a-z0-9]+/g,'-').replaceAll(/^-+|-+$/g,'');}"],
['{1,} as an equivalent of +', "function f(t){return t.toLowerCase().replace(/[^a-z0-9]{1,}/g,'-').replace(/^-+|-+$/g,'');}"],
['\\s* prefixed into the collapse class', "function f(t){return t.toLowerCase().replace(/\\s*[^a-z0-9]+\\s*/g,'-').replace(/^-+|-+$/g,'');}"],
['escaped ] inside the negated class', "function f(t){return t.toLowerCase().replace(/[^a-z0-9\\]]+/g,'-').replace(/^-+|-+$/g,'');}"],
['literal new RegExp(...) form', "function f(t){return t.toLowerCase().replace(new RegExp('[^a-z0-9]+','g'),'-').replace(/^-+|-+$/g,'');}"],
['trim spelled /^[-]+|[-]+$/', "function f(t){return t.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^[-]+|[-]+$/g,'');}"],
['trim spelled /(^-+)|(-+$)/', "function f(t){return t.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/(^-+)|(-+$)/g,'');}"],
['trim spelled /^-*|-*$/', "function f(t){return t.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-*|-*$/g,'');}"],
['trim spelled /^\\-+|\\-+$/', "function f(t){return t.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^\\-+|\\-+$/g,'');}"],
['trim spelled /-+$|^-+/ (swapped order)', "function f(t){return t.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/-+$|^-+/g,'');}"],
['.split(<negated class>).join(\'-\') as a collapse form', "function f(t){return t.toLowerCase().split(/[^a-z0-9]+/g).join('-').replace(/^-+|-+$/g,'');}"],
[
'arguments to .replace( spanning multiple physical lines (no leading "." continuation)',
['function f(t){', ' return t.toLowerCase().replace(', ' /[^a-z0-9]+/g,', " '-'", " ).replace(/^-+|-+$/g, '');", '}'].join('\n'),
],
];
for (const [name, src] of positives) {
test(`${name} is flagged`, () => {
const v = findSlugDerivationDrift(src, 'ZZZ-not-exempt.cts');
assert.ok(v.length >= 1, `expected "${name}" to be detected after the MAJOR-3 widening`);
});
}
test('the two-statement/temp-var form is a DOCUMENTED, deliberate gap (needs data flow, not textual matching) — still evades', () => {
const src = ['function f(t){', " const a = t.toLowerCase().replace(/[^a-z0-9]+/g,'-');", " return a.replace(/^-+|-+$/g,'');", '}'].join('\n');
assert.deepEqual(findSlugDerivationDrift(src, 'ZZZ-not-exempt.cts'), []);
});
test('new RegExp built from a variable is a DOCUMENTED, deliberate gap — still evades', () => {
const src = "function f(t,p){return t.toLowerCase().replace(new RegExp(p,'g'),'-').replace(/^-+|-+$/g,'');}";
assert.deepEqual(findSlugDerivationDrift(src, 'ZZZ-not-exempt.cts'), []);
});
});
// ─── MAJOR-2 (security review, #3987): bounded regex-literal extraction ──
describe('findSlugDerivationDrift — MAJOR-2: no quadratic blowup on a pathological regex-literal-shaped line', () => {
test('a 1.28MB line built from many unterminated "[^"-shaped fragments is scanned in bounded, LINEAR work — not the pre-fix quadratic blowup (54.3s at this size)', () => {
// Deterministic replacement for a wall-clock assertion (CLAUDE.md "Clock
// Seams: Do not assert on wall-clock time" — the original elapsedMs<5000
// row flaked on a slow shared CI runner at 7368ms while passing locally
// at ~200ms). Instead of timing, this pins the actual MAJOR-2 invariant:
// every attempt to read a regex literal is capped at MAX_REGEX_LITERAL_LEN
// characters (`readRegexLiteralAt`'s own `limit`), so total scan work
// across all `k` unterminated `.replace(/[^` fragments is bounded by
// `calls * MAX_REGEX_LITERAL_LEN` — a small linear multiple of the input,
// never a multiple of the input's OWN LENGTH (which is what made the
// pre-fix unbounded scan quadratic: each of the k attempts re-scanned
// however much of the remaining 1.28MB line was left).
const k = 40000; // '.replace(/[^'.repeat(k) + 'x'.repeat(20k) ~= 1.28MB, matching the review's measured repro
const line = '.replace(/[^'.repeat(k) + 'x'.repeat(20 * k);
assert.equal(Buffer.byteLength(line, 'utf8'), 1_280_000);
resetRegexScanStats();
const v = findSlugDerivationDrift(line, 'ZZZ-not-exempt.cts');
const { calls, charsExamined } = getRegexScanStats();
assert.deepEqual(v, [], 'a giant unterminated fragment run is not a real re-derivation');
// Every attempt is capped at MAX_REGEX_LITERAL_LEN by construction — this
// holds even under the (hypothetical) unbounded pre-fix shape only if the
// cap itself is honored; a bound expressed against `calls`, not against
// `line.length`, is what makes this assertion mean something.
assert.ok(calls > 0, 'expected at least one regex-literal-read attempt on this fragment run');
assert.ok(
charsExamined <= calls * MAX_REGEX_LITERAL_LEN,
`expected charsExamined (${charsExamined}) to never exceed calls (${calls}) * MAX_REGEX_LITERAL_LEN (${MAX_REGEX_LITERAL_LEN})`,
);
// The real discriminator: an ABSOLUTE ceiling, independent of whatever
// MAX_REGEX_LITERAL_LEN happens to be configured to (the prior assertion
// is tautological w.r.t. that constant and would not catch the constant
// itself being blown out). Measured on the current bounded implementation
// this line drives ~120k bounded attempts (`calls`) totalling
// ~4.8e7 examined characters — comfortably under 1e8. An unbounded scan
// (each attempt re-reading however much of the 1.28MB line remains, the
// exact pre-fix shape) is quadratic: ~line.length^2/2 ≈ 8e11 characters —
// over four orders of magnitude past this ceiling. Confirmed live: a
// reverted "no cap" simulation of this same fixture did not finish within
// 120s, versus ~0.3s bounded.
assert.ok(
charsExamined < 1e8,
`expected charsExamined (${charsExamined}) to stay under a fixed absolute ceiling, not scale toward line.length^2 (~8e11) as the pre-fix unbounded scan would`,
);
});
});
// ─── MINOR fixes (security review, #3987) ────────────────────────────────
describe('MINOR fixes', () => {
test('stripComments does not cut at a "//" inside a string literal (e.g. a URL)', () => {
const line = "const u = 'http://x'; return t.replace(/[^a-z0-9]+/g, '-');";
const stripped = stripComments(line);
assert.ok(stripped.includes("'http://x'"), 'the URL string must survive comment-stripping intact');
assert.ok(stripped.includes(".replace(/[^a-z0-9]+/g, '-')"), 'code after the string must survive too');
});
test('a top-level statement split on ";" does not fire on a ";" embedded inside a regex character class', () => {
const line = "a.replace(/[^a-z0-9;]+/g, '-'); b.replace(/^-+|-+$/g, '');";
const stmts = buildLogicalStatements([line]);
assert.equal(stmts.length, 2, 'the embedded ";" inside the class must not split the first statement in two');
assert.equal(stmts[0].text, "a.replace(/[^a-z0-9;]+/g, '-')");
});
test('SCAN_EXT includes .mjs, .tsx, .jsx alongside the original extensions', () => {
for (const ext of ['.mjs', '.tsx', '.jsx', '.cts', '.ts', '.mts', '.cjs', '.js']) {
assert.ok(SCAN_EXT.has(ext), `expected SCAN_EXT to include ${ext}`);
}
});
});
// ─── buildLogicalStatements — the statement-scoping mechanism itself ──────
describe('buildLogicalStatements — statement scoping mechanics', () => {
test('a chain\'s continuation lines (leading ".") merge into the opening line\'s statement', () => {
const text = ['const x = a', ' .b()', ' .c();'].join('\n');
const stmts = buildLogicalStatements(text.split('\n'));
assert.equal(stmts.length, 1);
assert.equal(stmts[0].startLine, 1);
// The trailing ';' is stripped by the fragment splitter (it is the
// fragment TERMINATOR, not part of the statement text) — matches the
// ';'-terminated statements test below.
assert.equal(stmts[0].text, 'const x = a .b() .c()');
});
test('a line NOT starting with "." never merges into the previous statement, even with no ";" boundary', () => {
const text = ['const a = 1', 'const b = 2'].join('\n');
const stmts = buildLogicalStatements(text.split('\n'));
assert.equal(stmts.length, 2);
assert.equal(stmts[0].startLine, 1);
assert.equal(stmts[1].startLine, 2);
});
test('multiple ";"-terminated statements on one physical line become separate statements', () => {
const stmts = buildLogicalStatements(['const a = 1; const b = 2; const c = 3;']);
assert.equal(stmts.length, 3);
assert.deepEqual(stmts.map((s) => s.text), ['const a = 1', 'const b = 2', 'const c = 3']);
});
test('blank and comment-only lines are skipped without breaking a chain across them', () => {
const text = ['const x = a', ' // a comment line in the middle of the chain', '', ' .b();'].join('\n');
const stmts = buildLogicalStatements(text.split('\n'));
assert.equal(stmts.length, 1);
assert.equal(stmts[0].text, 'const x = a .b()');
});
});
// ─── T7 (PROVE-IT-CAN-FAIL) + T8: scanRepo mechanics ──────────────────────
describe('scanRepo — PROVE-IT-CAN-FAIL: the guard reds on a fresh synthetic violation', () => {
test('a freshly written violation in a temp tree is reported with its file and line', (t) => {
const root = createTempDir('gsd-slug-derivation-drift-');
t.after(() => cleanup(root));
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
fs.writeFileSync(
path.join(root, 'src', 'fake.cts'),
"function slugify(name) { return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); }\n",
);
const violations = scanRepo(root);
assert.equal(violations.length, 1, 'the guard must be able to FAIL on a real violation, not merely pass on a clean tree');
assert.equal(violations[0].file, path.join('src', 'fake.cts'));
assert.equal(violations[0].line, 1);
});
test('a clean temp tree with no re-derivations reports zero violations', (t) => {
const root = createTempDir('gsd-slug-derivation-drift-');
t.after(() => cleanup(root));
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
fs.writeFileSync(path.join(root, 'src', 'clean.cts'), 'const x = 1;\n');
assert.deepEqual(scanRepo(root), []);
});
test('gsd-core/bin/lib and bin/install.js are never visited — a scan-dir outside src/scripts/tests/eslint-rules is not scanned', (t) => {
const root = createTempDir('gsd-slug-derivation-drift-');
t.after(() => cleanup(root));
fs.mkdirSync(path.join(root, 'gsd-core', 'bin', 'lib'), { recursive: true });
fs.writeFileSync(
path.join(root, 'gsd-core', 'bin', 'lib', 'core-utils.cjs'),
"function slugify(name) { return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); }\n",
);
fs.mkdirSync(path.join(root, 'bin'), { recursive: true });
fs.writeFileSync(
path.join(root, 'bin', 'install.js'),
"function slugify(name) { return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); }\n",
);
assert.deepEqual(scanRepo(root), []);
});
test('SELF_TEST_FILE exemption is scoped to its exact path — a DIFFERENT tests/ file with the same fixture text IS still flagged', (t) => {
const { SELF_TEST_FILE } = require(path.join(ROOT, 'scripts', 'lint-slug-derivation-drift.cjs'));
const root = createTempDir('gsd-slug-derivation-drift-');
t.after(() => cleanup(root));
const fixtureLine = "function slugify(name) { return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); }\n";
fs.mkdirSync(path.join(root, path.dirname(SELF_TEST_FILE)), { recursive: true });
fs.writeFileSync(path.join(root, SELF_TEST_FILE), fixtureLine);
const otherTestsFile = path.join('tests', 'some-other-file.test.cjs');
fs.writeFileSync(path.join(root, otherTestsFile), fixtureLine);
const violations = scanRepo(root);
assert.equal(violations.length, 1, 'exactly one violation: SELF_TEST_FILE is skipped, the other tests/ file is not');
assert.equal(violations[0].file, otherTestsFile);
});
});
test('T8: scanRepo(repoRoot) against the real repo returns EMPTY after the Task-2 fixes (was 2 TRUE positives)', () => {
const violations = scanRepo(ROOT);
assert.deepEqual(violations, []);
});
// ─── PROVE-IT-CAN-FAIL, the CLI (main()) surface ──────────────────────────
//
// The `scanRepo` PROVE-IT-CAN-FAIL row above only exercises the pure
// function; `main()`'s `process.exitCode = 1`, its stderr banner text, and
// both `sanitizeForReport` call sites (on `d.file` AND `d.found`) are a
// SEPARATE, uncovered surface — dropping the `process.exitCode = 1` line
// entirely would leave every row above green. `main()` is not exported and
// hardcodes its scan root to `path.join(__dirname, '..')` (the real repo,
// which is clean — see T8), so the only way to drive the exit-1 branch is to
// run the CLI as a REAL subprocess against a throwaway copy of the script
// (plus its `scripts/lib/drift-scan.cjs` dependency, which has no other
// requires) rooted at a synthetic tree carrying a real violation.
describe('CLI (main()) — the process.exitCode/stderr surface scanRepo alone does not cover', () => {
test('a violation drives exit code 1, a stderr banner, and a sanitized file:line report line', (t) => {
const { spawnSync } = require('node:child_process');
const tmpRoot = createTempDir('gsd-slug-derivation-drift-cli-');
t.after(() => cleanup(tmpRoot));
fs.mkdirSync(path.join(tmpRoot, 'scripts', 'lib'), { recursive: true });
fs.mkdirSync(path.join(tmpRoot, 'src'), { recursive: true });
fs.copyFileSync(
path.join(ROOT, 'scripts', 'lint-slug-derivation-drift.cjs'),
path.join(tmpRoot, 'scripts', 'lint-slug-derivation-drift.cjs'),
);
fs.copyFileSync(
path.join(ROOT, 'scripts', 'lib', 'drift-scan.cjs'),
path.join(tmpRoot, 'scripts', 'lib', 'drift-scan.cjs'),
);
// `d.file` gets a zero-width space (a valid filename character on every
// OS — a raw control byte in a filename is REJECTED as ENOENT on
// Windows, so it cannot be used here without breaking that lane); `d.found`
// gets an actual control byte (\x07) embedded in the flagged statement's
// own text, which is disk file CONTENT, not a path, so it is safe on every
// platform. Both are exactly what sanitizeForReport exists to neutralize.
const evilFileName = `fa${'​'}ke.cts`;
fs.writeFileSync(
path.join(tmpRoot, 'src', evilFileName),
`function slugify(name${'\x07'}) { return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); }\n`,
);
const res = spawnSync(process.execPath, [path.join(tmpRoot, 'scripts', 'lint-slug-derivation-drift.cjs')], {
encoding: 'utf8',
timeout: 10000,
});
assert.equal(res.status, 1, 'main() must set a non-zero process.exitCode when a violation is found');
assert.match(res.stderr, /slug-derivation-drift: independent re-derivation\(s\)/, 'expected the stderr banner text');
assert.match(res.stderr, /generateSlugInternal\(text, maxLen\)/, 'expected the fix-forward guidance line');
assert.match(res.stderr, /fa\\u200bke\.cts/, 'sanitizeForReport must have escaped the zero-width space in d.file');
assert.match(res.stderr, /name\\x07/, 'sanitizeForReport must have escaped the control byte in d.found');
assert.ok(!res.stderr.includes('​'), 'the raw zero-width space must not reach stderr unescaped');
assert.ok(!res.stderr.includes('\x07'), 'the raw control byte must not reach stderr unescaped');
});
test('a clean tree exits 0 with the "ok" stdout line, not the violation banner', (t) => {
const { spawnSync } = require('node:child_process');
const tmpRoot = createTempDir('gsd-slug-derivation-drift-cli-clean-');
t.after(() => cleanup(tmpRoot));
fs.mkdirSync(path.join(tmpRoot, 'scripts', 'lib'), { recursive: true });
fs.mkdirSync(path.join(tmpRoot, 'src'), { recursive: true });
fs.copyFileSync(
path.join(ROOT, 'scripts', 'lint-slug-derivation-drift.cjs'),
path.join(tmpRoot, 'scripts', 'lint-slug-derivation-drift.cjs'),
);
fs.copyFileSync(
path.join(ROOT, 'scripts', 'lib', 'drift-scan.cjs'),
path.join(tmpRoot, 'scripts', 'lib', 'drift-scan.cjs'),
);
fs.writeFileSync(path.join(tmpRoot, 'src', 'clean.cts'), 'const x = 1;\n');
const res = spawnSync(process.execPath, [path.join(tmpRoot, 'scripts', 'lint-slug-derivation-drift.cjs')], {
encoding: 'utf8',
timeout: 10000,
});
assert.equal(res.status, 0);
assert.match(res.stdout, /^ok slug-derivation-drift:/);
assert.equal(res.stderr, '');
});
});
// ─── T9-T11: scripts/qa-smell-ratchet.cjs slugify, routed through the seam ─
describe('scripts/qa-smell-ratchet.cjs slugify — routed through generateSlugInternal (#2849/#2848 fixes)', () => {
// Re-require the fixed module's own slugify indirectly isn't exported, so
// these rows assert the SEAM behaves as the routed call site now expects
// (parity is guaranteed by construction: the call site is `generateSlugInternal(value, 60) ?? ''`).
test('T9: a >60-char input whose 60th char is the separator hyphen itself does not leave a trailing hyphen (the live #2849 bug)', () => {
// 59 'a's + "-bcd": char 60 (1-based) is EXACTLY the separator hyphen at
// index 59. The pre-#2849 formula trimmed leading/trailing hyphens BEFORE
// truncating to 60 — a no-op here, since the hyphen sits in the middle,
// not at either end — then truncated with substring(0, 60), which lands
// exactly ON that hyphen and leaves it as the new trailing character:
// 59 'a's + trailing '-'. The fixed formula truncates FIRST (same 60-char
// cut), THEN trims trailing hyphens, removing it. This input is
// discriminating (old -> trailing '-', new -> none); the previous fixture
// ('a'.repeat(58) + '-bcdef') truncated to "...a-b", which both the old
// and new formulas produce identically — it never reached #2849's bug at
// all (verified: both formulas agree on that input).
const input = 'a'.repeat(59) + '-bcd';
const slug = generateSlugInternal(input, 60) ?? '';
assert.ok(!slug.endsWith('-'), `expected no trailing hyphen after truncation, got ${JSON.stringify(slug)}`);
assert.equal(slug.length <= 60, true);
assert.equal(slug, 'a'.repeat(59), 'the separator hyphen itself must be trimmed after truncation');
});
test('T9b (call-site): scripts/qa-smell-ratchet.cjs\'s slugify(), routed through generateSlugInternal, does not leave a trailing hyphen either', () => {
// Asserts through the CALL SITE, not generateSlugInternal directly — if
// qa-smell-ratchet.cjs's slugify() were reverted to its pre-#3987
// hand-rolled (trim-before-truncate) copy, this reds even though T9
// above (which only calls generateSlugInternal) would not notice.
const input = 'a'.repeat(59) + '-bcd';
const slug = qaSmellRatchetSlugify(input);
assert.ok(!slug.endsWith('-'), `expected no trailing hyphen from the routed call site, got ${JSON.stringify(slug)}`);
assert.equal(slug, 'a'.repeat(59));
});
test('T10: non-Latin (Cyrillic) input transliterates to a non-empty slug', () => {
const slug = generateSlugInternal('Привет мир', 60) ?? '';
assert.notEqual(slug, '');
assert.match(slug, /^[a-z0-9-]+$/);
});
test('T11: null/empty input preserves the never-null contract via "?? \'\'"', () => {
assert.equal(generateSlugInternal(null, 60) ?? '', '');
assert.equal(generateSlugInternal('', 60) ?? '', '');
assert.equal(generateSlugInternal(undefined, 60) ?? '', '');
});
test('T11b: an entirely non-alphanumeric input collapses to the EMPTY STRING, not null', () => {
// Distinguishes "the input produced nothing after slugification" (a
// non-null, empty string — the collapse/trim clauses ran and consumed
// every character) from "no input was supplied at all" (T11's null/undefined
// -> null case). Previously untested.
assert.equal(generateSlugInternal('!!!', 60), '');
assert.notEqual(generateSlugInternal('!!!', 60), null);
});
test('T9c: maxLen boundary at 59/60/61 (limit-1, limit, limit+1) truncates to exactly maxLen characters', () => {
const input = 'a'.repeat(65);
assert.equal(generateSlugInternal(input, 59), 'a'.repeat(59));
assert.equal(generateSlugInternal(input, 60), 'a'.repeat(60));
assert.equal(generateSlugInternal(input, 61), 'a'.repeat(61));
});
});
// ─── T12: tests/planning-inspect.test.cjs slugify helper parity ──────────
describe('tests/planning-inspect.test.cjs slugify helper — parity with getPhaseDirFromPhaseId (#3987)', () => {
// A name long enough that its slug EXCEEDS 60 chars, so maxLen: null and
// maxLen: 60 genuinely disagree — the previous 18-char fixture never
// exercised truncation at all, so the two arguments trivially matched
// regardless of which one getPhaseDirFromPhaseId actually passes.
const LONG_NAME = 'This Is A Genuinely Long Phase Name That Exceeds Sixty Characters For Sure';
test('T12: maxLen: null and maxLen: 60 genuinely DISAGREE on a >60-char slug (fixture is discriminating)', () => {
const untruncated = generateSlugInternal(LONG_NAME, null) ?? '';
const truncated = generateSlugInternal(LONG_NAME, 60) ?? '';
assert.ok(untruncated.length > 60, `fixture must produce a >60-char slug to be discriminating, got length ${untruncated.length}`);
assert.equal(truncated.length, 60);
assert.notEqual(untruncated, truncated, 'maxLen: null and maxLen: 60 must produce DIFFERENT slugs for this fixture');
});
test('T12b (call site): getPhaseDirFromPhaseId embeds the UNTRUNCATED (maxLen: null) slug, never the 60-char-truncated one', () => {
// Asserts through the CALL SITE (src/phase-id.cts's getPhaseDirFromPhaseId),
// not generateSlugInternal directly — if that call site were reverted to
// pass the 60-char default instead of maxLen: null, this reds even
// though a generateSlugInternal-only assertion would not notice.
const dir = getPhaseDirFromPhaseId('01-01', LONG_NAME, null);
const untruncated = generateSlugInternal(LONG_NAME, null) ?? '';
const truncated = generateSlugInternal(LONG_NAME, 60) ?? '';
assert.ok(dir.endsWith(untruncated), `expected the phase dir to embed the untruncated slug, got ${JSON.stringify(dir)}`);
assert.ok(!dir.endsWith(truncated) || truncated === untruncated, 'the phase dir must not embed the 60-char-truncated slug');
});
});