* test(#4324): prove colon tokens the installer cannot convert leak Failing-first regression coverage for #4324. The install rewrite (transformContentToHyphen) is gated on an exact match against the commands/gsd stem list, so any /gsd:<token> whose token is not a registered stem survives the install and reaches the user as the deprecated colon form. The gate is load-bearing -- it is the only thing protecting the workflow DSL marker family (gsd:section, gsd:protected, gsd:loop-host, gsd:guard, gsd:dispatch, gsd:plan-revision-conflicts), which workflow-fragments parses as a literal. So this suite asserts the shipped text is convertible rather than asserting the transform is broad, and pins the marker family as explicit negative space. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#4324): stop unconvertible colon tokens reaching the user The install rewrite is gated on an exact match against the commands/gsd stem list, so a /gsd:<token> whose token is not a registered stem survives the install and reaches the user as the deprecated colon form. That gate is load-bearing -- it protects the gsd:section / gsd:protected / gsd:loop-host marker family -- so the fix is in the shipped text, and the source stays colon per CONTEXT.md's two-tier rule. - quick-batch command + skill description: close the command token at a boundary so `/gsd:quick`-shaped converts instead of being skipped. - gsd-code-fixer (both variants): execute-plan and diagnose-issues are workflows, not commands, so they never converted and rendered beside two hyphenated siblings on the same line. Name them as workflows. - help topic-mode: the extraction rule hard-coded a colon prefix that the converted full.md never ships, so --brief could never match a signature line and silently fell back on every topic. Describe the signature line without a literal prefix. - update.md: drop the prefix from prose describing a stale command. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#4324): add changeset fragment pr:0 placeholder is backfilled with the real number once the PR exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#4324): locate the help summary per reference variant Adversarial review finding. Restoring the signature-line match (the #4324 fix) activated a latent defect in the clause next to it: compact scope emitted "the single non-blank line immediately after" the signature, and that clause is only correct for full.md. full.compact.md puts the summary on the signature line itself, after an em-dash, and its next non-blank line is an unrelated "Usage:" line. Both variants ship and both are served, so before this commit the compact variant would have emitted the wrong line as the summary. It was masked until now only because the stale colon prefix meant no signature line ever matched at all. Name the two placements and pick per line, and say explicitly that a Usage: line is never a summary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#4324): de-vacuum the help parity check, narrow the marker waiver Two adversarial review findings against the #4324 coverage. The help-parity assertion went vacuous the moment the fix landed: once topic.md stops spelling a literal prefix, the matched set is empty and the assertion holds for any rewording, correct or not. It now also asserts across BOTH served reference variants that each ships signature lines under the hyphen prefix, that the two genuinely disagree about where the summary sits, and that topic.md still names both placements and the Usage: guard. The marker waiver keyed on "sits inside an HTML comment", which waves through a real broken reference that happens to be commented out -- `<!-- see /gsd:typo-cmd -->` scored clean. Enumerate the six marker families instead. Verified the narrowed rule catches that probe and still passes over the tree; it also surfaced a seventh family, write-continue, that the broad rule was hiding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#4324): normalize the namespace in skill descriptions Both hyphen-namespace skill converters ran the hyphen transform over the body but rebuilt the frontmatter description from the raw field, so a /gsd:<cmd> mention in a command description survived into the installed SKILL.md -- the exact field the host's skill picker renders, which is the surface this issue was filed about. The local flat-command path was already correct because it rewrites the whole file; only the skills path, used by a global install, was affected. Confirmed by installing into a fake HOME before and after. Fixed in both copies: bin/install.js and the src/ source of truth that compiles into gsd-core/bin/lib. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#4324): assert descriptions through the real converters The previous version of this check called transformContentToHyphen on the description line itself and passed, while a real install still shipped the colon form -- the converter never calls that transform on the description. It asserted a proxy for the behaviour instead of the behaviour. Drive convertClaudeCommandToClaudeSkill and convertClaudeCommandToClineSkill over every registered command and assert on the emitted description. Verified it fails against the pre-fix converters and passes against the fixed ones. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#4324): regenerate skills after the description change skills/<name>/SKILL.md is generated by gen-plugin-skills, not hand-maintained, and lint:generated-sync caught the hand edit. The regenerated file emits the hyphen form, which also corrects the assumption behind the scan comment in the namespace test: skills/ is runtime-emitter output, not colon source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#4324): re-sanction normalizeKimiSkillName's real end line The description-normalisation fix inserted five lines above normalizeKimiSkillName in src/runtime-artifact-conversion.cts, moving its closing brace from 635 to 640. MAJOR-1 pins that line deliberately, so the planted violation landed INSIDE the exempted body and went unflagged -- 0 !== 1. Re-sanction the value rather than derive it: the array is named sanctionedRealEndLines, and a pinned line that fails loudly on drift is the design. Deriving it would remove the human check the name asks for. Verified by executing all four MAJOR-1 rows against the real tree: each planted violation is flagged at realEndLine+1 and each unmodified file stays exempt. Emitted-Drift-Ack-Growth: gsd-code-fixer.md — names execute-plan and diagnose-issues as workflows rather than as slash commands that do not exist Emitted-Drift-Ack-Growth: gsd-code-fixer.compact.md — same rewording as its full sibling, kept byte-consistent with it Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#4324): backfill the changeset PR number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
578 lines
32 KiB
JavaScript
578 lines
32 KiB
JavaScript
'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 { QUICK_SPAWN_TIMEOUT_MS } = require('./helpers/timeouts.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: 640 },
|
||
{ 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: QUICK_SPAWN_TIMEOUT_MS,
|
||
});
|
||
|
||
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: QUICK_SPAWN_TIMEOUT_MS,
|
||
});
|
||
|
||
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');
|
||
});
|
||
});
|