chore(#3212): src/pattern.cts is the sole owner of runtime-value regex construction — Phase 1 (#3416)

* test(#3412): failing-first suite for the pattern-construction seam

Phase 1 of epic #3212 (ADR-3212 §1/§2/§7). Tests only — src/pattern.cts
and eslint-rules/no-adhoc-regex-escape.cjs do not exist yet, so both
suites fail with MODULE_NOT_FOUND, which is the intended RED.

Locks the measured behavior rather than the assumed behavior:
RegExp.escape hex-escapes the leading character of nearly every string
("abc" -> "\x61bc"), so the suite asserts match-equivalence against an
inlined historical oracle (the implementation being deleted) rather
than byte-equivalence of pattern text — 200 seeded fast-check runs plus
a fixed corpus, 0 mismatches. Also locks the latent character-class
range bug this phase fixes as a side effect: a hyphen-bearing value
interpolated into [...] currently forms a real range and matches an
unintended character; post-migration it must not.

* chore(#3412): src/pattern.cts owns runtime-value regex construction

Phase 1 of epic #3212 (ADR-3212 §1/§2/§6/§7). Adds the pattern seam
delegating to the built-in RegExp.escape, deletes every hand-rolled
copy, and raises the Node floor to the Active LTS line.

The census was low, three times over. ADR-3212 counted 10 copies; a
graph query found 12; the new lint rule — once live — found 27 more.
The difference is that the census counted named helper FUNCTIONS while
the rule counts the escape SHAPE, so inline .replace(<class>, '\$&')
copies were never in scope. ADR §1's actual requirement is that no
module outside the seam escapes a value for regex use, so all of them
are, and CLAUDE.md's no-defer rule makes them this change's work.
Fourth consecutive epic here whose copy count was low — the argument
for ADR-3180 Amendment 3's "state N found by the guard" rule.

Also corrected mid-implementation: the survey reported phase-id.cts's
escapeRegex had 0 external importers. It had 8 production importers,
making its removal a public-surface change to an ADR-2121-owned module
and requiring an update to that ADR's locked-surface test. Blast
radius revised Medium-High -> High.

RegExp.escape is match-equivalent but NOT text-equivalent: it
hex-escapes the leading char of nearly every string ("abc" ->
"\x61bc"). Equivalence is proven by a seeded fast-check property test
against the deleted implementation as oracle. It also fixes a latent
bug: a hyphen-bearing value interpolated into a character class
previously formed a real range and matched an unintended character.

Node floor 22 -> 24 (RegExp.escape is Node 24+), across engines,
.nvmrc, package-lock, 9 CI matrix entries, and 5 docs. The aggregate
`required-tests` context is unchanged and no job was added or removed,
so branch protection cannot be orphaned by the dropped lanes.

Enforced by eslint-rules/no-adhoc-regex-escape.cjs (shape-matched, with
structural provenance for reviewed pattern-fragment constants rather
than a name heuristic) plus a whole-tree companion guard covering the
directories ESLint's globs miss.

* fix(#3412): close the _SOURCE guard evasion, correct two false claims

Three findings from the orthogonal review pass, all fixed.

1. The ESLint rule's `_SOURCE` provenance fallback was pure identifier-
   name matching with no binding check, so `new RegExp(userInput_SOURCE)`
   — a function parameter — sailed past the guard. That is the same
   rename-evasion class issue #3410 documents, reopened by the very
   fallback meant to complement the structural check. Now bound to the
   identifier's actual binding kind: import, require-derived const, or
   module-scope const; parameters, `let`/`var`, and unresolvable
   bindings fail closed. Four RuleTester cases cover the evasion and
   prove the legitimate cross-module case still passes.

2. src/pattern.cts's own header carried the stale pre-correction counts
   (12 copies / 17 call sites) while CONTEXT.md and the design doc
   carried the corrected ones (~39 / ~44) — a self-contradiction inside
   the PR whose entire purpose is deleting divergent copies. Rewritten,
   preserving the durable lesson: a named-function census cannot see
   inline copies; only a shape-matching guard can.

3. The claim that all deleted copies threw TypeError on non-string was
   false. phase-id.cts's copy — the one with 8 external importers — did
   String(value).replace(...) and never threw. The seam's locked
   signature does not coerce, so this is a real, now-disclosed behavior
   change rather than the pure preservation the tests asserted. Audited
   all 32 invocations across the 8 importers and 6 in-file callers:
   every one is safe by construction (upstream truthy guard or a
   string-producing derivation), verified by runtime probe against the
   compiled modules rather than by TS compilation, which cannot see a
   runtime undefined. Corrected the false claim in both the test comment
   and the design doc, and added it to Known limits.

* docs(#3412): add Changed changeset for the Node 24 floor

The only user-visible break in this phase. The escape-behavior change
is internal and match-equivalent, so it carries no user-facing note.

* fix(#3412): resolve the seam's require graph in script fixtures and packaging

Checkpoint 2 came back red with 90 failures on the node24 lane. Three
distinct defects, all introduced by routing scripts/ through the new
pattern seam, none reproducible by any local gate:

1. ~82 failures — tests/adr-index-gate.test.cjs and
   tests/removed-but-needed-lint.test.cjs copy a scripts/*.cjs into an
   mkdtemp fixture and spawn it there (necessary: those scripts resolve
   their scan root from __dirname/.., so running the real script would
   scan the real repo). Each harness hand-listed the dependencies to
   copy alongside. Adding require('../gsd-core/bin/lib/pattern.cjs') to
   gen-adr-index.cjs made both lists silently incomplete ->
   MODULE_NOT_FOUND, plus 17 downstream 'did not emit parseable JSON'
   failures from the same crash.

   Fixed as a class, not an instance: new tests/helpers/copy-script-
   fixture.cjs walks a script's transitive static relative-require graph
   and copies it, so dependencies are derived and never re-declared. It
   throws (naming the unbuilt artifact) instead of letting the child die
   with a bare MODULE_NOT_FOUND. Verified for all four seam-consuming
   scripts: gen-adr-index, lint-removed-but-needed, gen-loop-host-
   contract, sync-runtime-launcher.

2. 2 failures — scripts/ ships wholesale but eslint-rules/ does not, so
   the new scripts/lint-no-adhoc-regex-escape.cjs would be
   MODULE_NOT_FOUND in a published install (#2858 guard). Excluded from
   the tarball, matching the existing precedent for gen-emitted-
   baseline.cjs, which is excluded for the identical reason, and locked
   with a test modeled on that one. Confirmed against a real npm pack:
   890 files, 0 from eslint-rules/, and gsd-core/bin/lib/pattern.cjs
   present (so the other four scripts' requires are legitimate).

3. 6 failures — tests/phase-id.test.cjs asserted the literal escaped
   source text ('0*29', 'PROJ-42'). RegExp.escape is match-equivalent to
   the retired hand-rolled escaper but NOT text-equivalent: it hex-
   escapes the leading character and all hyphens ('0*\x329',
   '\x50ROJ\x2d42'). Verified NOT a behavior change — 576 match
   decisions across all three real interpolation prefixes, zero
   divergence. Those tests now compile each source into the same heading
   regex src/roadmap.cts's searchPhaseInContent builds and assert what
   matches and what does not, including the 'i'-flag canonicalization
   the hex escape has to preserve. Re-pinning the new literals would
   have rebuilt the same brittleness one layer down. Adds a test for the
   property the escape exists for: a dot in '1.2' must not act as a
   wildcard.

Also shares one definition of 'a require' between the packaging guard
and the fixture copier, so the two cannot disagree about what they scan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3412): refuse to copy a fixture dependency outside the fixture root

copyScriptWithDeps resolved each relative require and joined the
repo-relative result onto fixtureRoot. A require resolving OUTSIDE the
repo yields a '../'-prefixed relative path, so path.join climbed out of
the fixture and wrote into the surrounding temp dir (verified:
repoRoot=/repo + depAbs=/etc/passwd wrote /tmp/etc/passwd).

No script in the tree does this today, so this closes an available
escape rather than an active one. Refuses via the existing unresolved-
require path so the failure names the offending specifier. Covered by a
negative proof that the guard fires and that nothing lands outside the
fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3412): parse requires instead of pattern-matching them; restore the foreign-prefix contract

Applies all findings from the second orthogonal review round, re-run
because real code changed after round 1.

HIGH (security) — extractRequires stripped BLOCK comments before LINE
comments, so a '//' comment containing '/*' opened a phantom block
comment, and a '//' inside a string literal truncated the line. Both
hid real requires: 'const u="http://x"; require("./real.cjs")'
returned [], and four real requires in gsd-core/bin/gsd-tools.cjs were
invisible. Replaced with a real AST parse via espree.

This is ADR-3212's own Decision 4 — tokenizer-first for stateful
grammars — applied to the case it describes; comment/string/regex
nesting is exactly such a grammar, which is why the regex version was
wrong. The function was moved byte-identical out of the #2858 packaging
guard, so the bug PRE-DATES this branch and has been a live blind spot
there: a shipped script could have required an unshipped path
undetected. Fixing it makes that guard strictly stronger than on next.

espree is promoted from a transitive eslint dependency to an explicit
devDependency rather than relying on hoisting. The script parse attempt
sets ecmaFeatures.globalReturn because Node wraps CommonJS bodies in a
function, making a top-level return legal — scripts/check-coverage-gate
.cjs relies on it, and without the flag the guard throws on a file it
is supposed to scan. Verified 0 unparseable across all 324 .cjs/.js
under scripts/, bin/, and gsd-core/bin/, and 0 new violations against a
real npm pack, so the exact extractor does not newly fail the guard.

MEDIUM (security) — the repo-containment check guarded dependencies but
not the entry path. One escapesContainment predicate now guards both.

LOW (security) — containment was lexical while fs follows symlinks, and
a directory symlink could mint a fresh dedupe key per level. realpath
now resolves both repoRoot and each dependency before the decision, and
the realpath-derived path is the dedupe key. Destination layout still
uses the original repo-relative path, so copied trees are unchanged.

MAJOR (standards) — the round-1 behavioral rewrite of phase-id tests
lost the foreign-prefix contract: every assertion was satisfied by an
impl returning [A-Z]+\x2d42, i.e. ANY project code — the exact #3599
bug class the exact-source prevents. The literal assertions it replaced
were catching this. Now asserts the compiled regex REJECTS a different
prefix with the same number.

MAJOR (standards) — the test hand-duplicated production's heading regex
with no parity guard (CLAUDE.md's 'Generative Fix Divergence'). Removed
the parallel surface instead of policing it: src/roadmap.cts exports
buildPhaseHeadingRegex, searchPhaseInContent calls it, the test imports
it. Byte-identical .source and .flags verified for both escaped forms.

MINOR — '..foo' no longer false-flagged as an escape; the inverted
spurious-vs-missing doc claim corrected; the dead allow-test-rule
header removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3412): backfill changeset pr number to 3416

* fix(#3412): make the escape guard's own regex linear, reword an injection-scan collision

Two CI failures on PR #3416, both in code this branch added.

CodeQL js/redos (high) — REPLACE_CALL_RE's outer alternation let a
bracket run be consumed EITHER by the character-class branch OR one
character at a time by the trailing catch-all, so a failing match
explored both parses of every pair. Measured on the real regex:
n=26 -> 204ms, n=28 -> 791ms, n=30 -> 3475ms, a clean 2^n. This script
scans repo source, so a file with a long bracket run after '.replace(/'
would hang CI outright — a guard against undisciplined pattern
construction was itself the worst pattern in the diff.

Fixed the way ADR-3212 already prescribes: the catch-all branch now
excludes '[' and ']' so a bracket can only be consumed by the class
branch (this is what makes it linear), and every quantifier is bounded
(the locked bounded-quantifiers decision) as a second line of defense.
Now 0ms at n=2000. Disclosed coverage tradeoff, recorded at the
constant: a regex literal with a BARE unescaped ']' outside a class is
no longer matched by this backstop. No census shape has that form, and
the AST rule remains the primary detector.

Verified the guard did not go blind doing it: a real census-shape
violation is still reported, and an allow-adhoc-regex-escape
suppression comment is still honored.

Regression test drives the exported findViolations on a
2000-repetition adversarial input and asserts the RESULT. It makes no
wall-clock assertion — elapsed-time tests are forbidden — so a
regression surfaces as a harness timeout, which is the correct signal.

Prompt injection scan — 'must not act as a regex wildcard' in a test
comment matched the scanner's jailbreak pattern act\s+as\s+(a|an|if|
my). Reworded to 'behave as'. Deliberately NOT allowlisted: silencing a
whole test file over one phrase would blunt the scanner permanently,
and the comment has nothing to do with injection.

Neither failure was reachable from the remote runner — CodeQL and the
injection scan are not in that matrix, so the sha it passed was green
and still wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-13 16:19:57 -04:00
committed by GitHub
parent 622c10b2c6
commit dc3c81e93d
71 changed files with 1839 additions and 286 deletions

View File

@@ -16,6 +16,7 @@ const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { copyScriptWithDeps } = require('./helpers/copy-script-fixture.cjs');
const REPO_ROOT = path.resolve(__dirname, '..');
const SCRIPT_REL = path.join('scripts', 'gen-adr-index.cjs');
@@ -25,22 +26,18 @@ const END = '<!-- ADR-INDEX:END -->';
/**
* Build a throwaway repo whose docs/adr/ contains exactly `files`, and whose
* scripts/ holds a copy of the generator + its cli-exit dependency. A unique
* mkdtemp per call keeps parallel tests from colliding, and the dir is removed
* via `t.after()` so a failing assertion cannot leak it.
* scripts/ holds a copy of the generator together with its transitive
* relative-require graph. A unique mkdtemp per call keeps parallel tests from
* colliding, and the dir is removed via `t.after()` so a failing assertion
* cannot leak it.
*/
function makeRepo(t, files) {
// helpers.cleanup (not raw fs.rmSync) carries the Windows-EBUSY retry budget.
const root = createTempDir('gsd-adr-index-');
t.after(() => cleanup(root));
fs.mkdirSync(path.join(root, 'docs', 'adr'), { recursive: true });
fs.mkdirSync(path.join(root, 'scripts', 'lib'), { recursive: true });
fs.copyFileSync(path.join(REPO_ROOT, SCRIPT_REL), path.join(root, SCRIPT_REL));
fs.copyFileSync(
path.join(REPO_ROOT, 'scripts', 'lib', 'cli-exit.cjs'),
path.join(root, 'scripts', 'lib', 'cli-exit.cjs'),
);
copyScriptWithDeps(REPO_ROOT, root, SCRIPT_REL);
for (const [name, body] of Object.entries(files)) {
fs.writeFileSync(path.join(root, 'docs', 'adr', name), body);

View File

@@ -26,6 +26,7 @@ const { runNode, OUTCOME } = require('./helpers/process-seam.cjs');
// file (#2365 review): readPhaseScope is the pure phase-scope reader behind the
// gate. Those tests monkeypatch fs rather than drive a subprocess.
const { readPhaseScope } = require('../gsd-core/bin/lib/check-command-router.cjs');
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
@@ -342,7 +343,7 @@ describe('readPhaseScope — fail-closed on a real read failure (#2365 review)',
tmpDir = makeProject({ api_coverage_gate: true });
const phaseDir = makePhaseDir(tmpDir, '01-pay');
writePlan(phaseDir, '01-PLAN.md', '# Plan\nIntegrate the Stripe API.');
const res = withFsThrow('readdirSync', new RegExp(phaseDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$'), 'EACCES', () =>
const res = withFsThrow('readdirSync', new RegExp(escapeRegex(phaseDir) + '$'), 'EACCES', () =>
readPhaseScope(tmpDir, phaseDir, '01'));
assert.ok(res.readError, 'an unreadable phase directory must set readError, not read as empty');
});

View File

@@ -20,6 +20,7 @@ const path = require('node:path');
const { cleanup } = require('./helpers.cjs');
const lifecycle = require('../gsd-core/bin/lib/capability-lifecycle.cjs');
const ledgerMod = require('../gsd-core/bin/lib/capability-ledger.cjs');
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
const { CAP_MARKER } = lifecycle;
// ---------------------------------------------------------------------------
@@ -3218,7 +3219,7 @@ test('IC-05/WIN-2: a consent-store write failure leaves the install status:insta
assert.ok(fs.existsSync(path.join(dir, '.gsd', 'capabilities', 'rofs-cap', 'capability.json')), 'the bundle is committed on disk');
assert.match(buf, /capability consent:/i, 'a consent diagnostic was written to stderr');
assert.match(buf, /could not write the consent record/i, 'the warning explains the write failure');
assert.match(buf, new RegExp(home.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 'the warning names the consent store path');
assert.match(buf, new RegExp(escapeRegex(home)), 'the warning names the consent store path');
});
// ---------------------------------------------------------------------------

View File

@@ -222,7 +222,8 @@ describe('check-env.cjs', () => {
}
assert.equal(typeof parsed.pass, 'boolean', 'Live repo JSON must have boolean pass');
assert.ok(Array.isArray(parsed.checks), 'Live repo JSON must have checks array');
// Node version check must be present and pass (Node >=22 is installed)
// Node version check must be present and pass (Node >=24 is installed,
// per package.json engines.node)
const nodeCheck = parsed.checks.find((c) => c.name === 'node-version');
assert.ok(nodeCheck, 'node-version check must be present in live repo output');
assert.equal(nodeCheck.status, 'pass', `node-version should pass on live repo, got: ${nodeCheck.status} — ${nodeCheck.message}`);

View File

@@ -16,6 +16,8 @@ const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { escapeRegex: escapeRe } = require('../gsd-core/bin/lib/pattern.cjs');
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
// Workflow file -> EVERY subagent type it spawns and must inject skills for.
@@ -29,10 +31,6 @@ const REVIEW_FAMILY = [
{ file: 'eval-review.md', agentTypes: ['gsd-eval-auditor'] },
];
function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const cases = REVIEW_FAMILY.flatMap(({ file, agentTypes }) =>
agentTypes.map((agentType) => ({ file, agentType })),
);

View File

@@ -26,6 +26,7 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
// --- Test Environment Setup ---
@@ -747,7 +748,7 @@ describe('bug-2839: /gsd-code-review-fix cleanup is transactional', () => {
// (`rm -f .../.review-fix-recovery-pending.json`) or a shell-variable form
// referring to the previously-declared `sentinel` variable
// (`rm -f "$sentinel"` / `rm -f "${sentinel}"`).
const escapedName = SENTINEL_NAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedName = escapeRegex(SENTINEL_NAME);
const sentinelRemovalRe = new RegExp(
`(rm\\s+(?:-f\\s+)?[^\\n]*(?:${escapedName}|\\$\\{?sentinel\\}?)|unlink[^\\n]*(?:${escapedName}|\\$\\{?sentinel\\}?))`
);

View File

@@ -23,6 +23,7 @@ const { throwIfFailed } = require('./helpers/git-fixture.cjs');
const { cleanup } = require('./helpers.cjs');
const fc = require('fast-check');
const { CLAUDE_AGENT_ALIASES } = require('../gsd-core/bin/lib/model-resolver.cjs');
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
// #3241 — the intended new home for CLAUDE_AGENT_ALIASES + isAnthropicFlavoredModel
// (see .gsd/phase/feat-3241-codex-omit-model-by-default/40-design.md "The seam
// decision"). Neither export exists on model-catalog.cjs yet; requiring the
@@ -1721,7 +1722,7 @@ describe('mergeCodexConfig', () => {
assert.ok(!content.includes('Updated description'), 'no per-agent description in config.toml');
assert.ok(!content.includes('[agents.gsd-planner]'), 'no agent role table (canonical source is the standalone TOML)');
// Verify no duplicate markers
const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
const markerCount = (content.match(new RegExp(escapeRegex(GSD_CODEX_MARKER), 'g')) || []).length;
assert.strictEqual(markerCount, 1, 'exactly one marker');
});
@@ -1775,7 +1776,7 @@ describe('mergeCodexConfig', () => {
// GSD section (stripLeakedGsdCodexSections) and the fresh block never
// re-adds one (#2406) — zero role tables should remain anywhere.
const gsdStructCount = (content.match(/^\[agents\.gsd-executor\]\s*$/gm) || []).length;
const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
const markerCount = (content.match(new RegExp(escapeRegex(GSD_CODEX_MARKER), 'g')) || []).length;
assert.ok(content.includes('[model]'), 'preserves user content');
assert.ok(content.includes('[agents.custom-agent]'), 'preserves non-GSD agent section');
@@ -1808,7 +1809,7 @@ describe('mergeCodexConfig', () => {
assert.ok(content.includes('other_feature = true'), 'preserves user feature keys');
assert.ok(!content.includes('[agents.gsd-executor]'), 'no agent role table (canonical source is the standalone TOML)');
// Verify no duplicate markers
const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
const markerCount = (content.match(new RegExp(escapeRegex(GSD_CODEX_MARKER), 'g')) || []).length;
assert.strictEqual(markerCount, 1, 'exactly one marker');
});
@@ -2128,7 +2129,7 @@ describe('mergeCodexConfig trailing-content preservation (#2940)', () => {
assert.ok(content.includes('[model]'), 'user [model] section preserved after re-merge');
assert.ok(content.includes('name = "gpt-5.4"'), 'user model value preserved verbatim');
assert.ok(content.includes(GSD_CODEX_MARKER), 'GSD marker still present');
const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
const markerCount = (content.match(new RegExp(escapeRegex(GSD_CODEX_MARKER), 'g')) || []).length;
assert.strictEqual(markerCount, 1, 'exactly one marker (no duplication)');
assert.ok(content.includes('max_depth ='), 'GSD-managed [agents] block regenerated');
});
@@ -2237,7 +2238,7 @@ describe('mergeCodexConfig trailing-content preservation (#2940)', () => {
const content = fs.readFileSync(configPath, 'utf8');
assert.ok(content.includes('[profiles.work]'), 'content before marker preserved');
assert.ok(content.includes('[mcp_servers.github]'), 'content after marker preserved');
const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
const markerCount = (content.match(new RegExp(escapeRegex(GSD_CODEX_MARKER), 'g')) || []).length;
assert.strictEqual(markerCount, 1, 'exactly one marker');
});
@@ -4712,7 +4713,7 @@ describe('#2760 fix 4 — Write-failure rollback (atomic write + snapshot restor
const preInstallBytes = fs.readFileSync(path.join(codexHome, 'config.toml'));
const configPath = path.join(codexHome, 'config.toml');
const tempPattern = new RegExp('^' + configPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\.tmp-');
const tempPattern = new RegExp('^' + escapeRegex(configPath) + '\\.tmp-');
// Stub: allow writes to atomic temp files (which renameSync overwrites
// the target, never truncating it directly) but throw on any direct
@@ -4778,7 +4779,7 @@ describe('#2760 fix 4 — Write-failure rollback (atomic write + snapshot restor
const preInstallBytes = fs.readFileSync(path.join(codexHome, 'config.toml'));
const configPath = path.join(codexHome, 'config.toml');
const tempPattern = new RegExp('^' + configPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\.tmp-');
const tempPattern = new RegExp('^' + escapeRegex(configPath) + '\\.tmp-');
// Stub: fault writes targeting the atomic temp file (the pre-rename branch
// of atomicWriteFileSync). Other writes (agent .toml files in CODEX_HOME)

View File

@@ -31,6 +31,7 @@ const {
VALID_CONFIG_KEYS,
RUNTIME_STATE_KEYS,
} = require('../gsd-core/bin/lib/config-schema.cjs');
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
describe('config-schema: isValidConfigKey properties', () => {
// (a) Never throws on any input
@@ -487,7 +488,7 @@ const SECTION_HEADERS = ['Planning', 'Execution', 'Docs & Output', 'Features', '
function hasPathLike(block, field) {
const parts = field.split('.');
if (parts.length === 1) return block.includes(parts[0]);
const escaped = parts.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const escaped = parts.map((p) => escapeRegex(p));
const pattern = new RegExp(escaped.join('[\\s\\S]{0,600}'), 'i');
return pattern.test(block);
}

View File

@@ -32,6 +32,7 @@ const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
const HOOKS = path.join(__dirname, '..', 'hooks');
const SESSION_START = path.join(HOOKS, 'gsd-cursor-session-start.js');
@@ -85,7 +86,7 @@ describe('#2587: cursor hooks resolve the workspace from workspace_roots, not cw
});
assert.match(
out.additional_context || '',
new RegExp(MSG_PRESENT_FRAGMENT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
new RegExp(escapeRegex(MSG_PRESENT_FRAGMENT)),
'must report STATE.md present when workspace_roots points at the project',
);
} finally {

View File

@@ -231,9 +231,12 @@ function extractCommandTokens(content) {
/**
* Walk a directory and return all .md files recursively.
* Uses hand-rolled DFS for Node 20 compat (Node 22+ recursive readdirSync is
* not available in all CI matrix entries). Surfaces permission-denied errors
* as structured warnings (PRED.k302) rather than silently skipping.
* Uses hand-rolled DFS rather than `fs.readdirSync(dir, { recursive: true })`
* — the recursive option is available on every CI matrix entry now that the
* Node floor is >=24.0.0 (it landed in Node 20.1), but this walker predates
* that bump and was not rewritten as part of it; unchanged, not stale.
* Surfaces permission-denied errors as structured warnings (PRED.k302) rather
* than silently skipping.
*/
function listMdFiles(dir) {
if (!fs.existsSync(dir)) return [];

View File

@@ -41,6 +41,7 @@ const fc = require('fast-check');
const { cleanup, createTempDir } = require('./helpers.cjs');
const { BUILD_SCRIPT, buildParityManifest, buildInstallTree, PKG_VERSION } = require('./helpers/install-shared.cjs');
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
const {
resolveChangedPaths,
resolveBase,
@@ -891,7 +892,7 @@ test('listFragmentFiles: exactly MAX_ACK_FRAGMENTS entries passes, one over fail
assert.throws(
() => listFragmentFiles(dir),
(err) => {
assert.match(err.message, new RegExp(dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
assert.match(err.message, new RegExp(escapeRegex(dir)));
assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS + 1)));
assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS)));
return true;
@@ -1233,7 +1234,7 @@ test('listAckFragmentFiles: exactly MAX_ACK_FRAGMENTS entries passes, one over f
assert.throws(
() => listAckFragmentFiles(dir),
(err) => {
assert.match(err.message, new RegExp(dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
assert.match(err.message, new RegExp(escapeRegex(dir)));
assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS + 1)));
assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS)));
return true;
@@ -1262,7 +1263,7 @@ test('listAckFragmentFilesAtRef: exactly MAX_ACK_FRAGMENTS entries passes, one o
assert.throws(
() => listAckFragmentFilesAtRef(SHA_A, { run: overCap }),
(err) => {
assert.match(err.message, new RegExp(`${ACK_DIR_REPO_PATH}/ at "${SHA_A}"`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
assert.match(err.message, new RegExp(escapeRegex(`${ACK_DIR_REPO_PATH}/ at "${SHA_A}"`)));
assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS + 1)));
assert.match(err.message, new RegExp(String(MAX_ACK_FRAGMENTS)));
return true;

View File

@@ -0,0 +1,306 @@
'use strict';
/**
* RuleTester unit tests for `local/no-adhoc-regex-escape`
* (#3212 Phase 1, #3412).
*
* Design: .gsd/phase/chore-3412-pattern-seam/40-design.md (Negative space)
* Test matrix: .gsd/phase/chore-3412-pattern-seam/50-test-matrix.md — section 5, rows 24-29
* ADR: docs/adr/3212-lexical-seam-consolidation.md §7
*
* TDD RED: `eslint-rules/no-adhoc-regex-escape.cjs` does not exist yet —
* this file's require() throws MODULE_NOT_FOUND until the implementing phase
* adds it. That is the intended starting state.
*
* RuleTester setup (languageOptions, sourceType, filename conventions) mirrors
* tests/eslint-rules.test.cjs's `no-adhoc-markdown-parsing` suite, which is
* this repo's other src/*.cts-scoped structural-shape rule.
*
* Contract this test file locks for the not-yet-written rule (per ADR §7 and
* the design doc's Negative space section):
* - FIRES on an inline `.replace(<escape-all-metachars class>, '\$&')`
* shape outside src/pattern.cts, matching the SHAPE (character-class
* membership set) not exact byte order (row 25).
* - Does NOT fire inside src/pattern.cts itself — the seam is the owner
* (row 26).
* - Does NOT fire on `new RegExp(<identifier>)` when the identifier is a
* `*_SOURCE`-suffixed constant — the design's named provenance marker for
* "deliberate, reviewed pattern fragments" (row 27).
* - Does NOT fire on a regex literal that merely contains the escape class
* as DATA, with no `.replace(..., '\$&')` shape present (row 28).
* - FIRES on `new RegExp(<identifier>)` when the identifier is a plain
* runtime value with no `_SOURCE` provenance marker and no routing
* through escapeRegex/literalPattern — ADR §7's "new RegExp() built from
* a non-literal without routing through the seam" (row 29).
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const { RuleTester } = require('eslint');
const noAdhocRegexEscape = require('../eslint-rules/no-adhoc-regex-escape.cjs');
const { findViolations } = require('../scripts/lint-no-adhoc-regex-escape.cjs');
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
},
});
// Separate instance for the ES-module `import` binding case (row 27b) — the
// default instance above uses sourceType 'commonjs', which cannot parse
// `import` statements.
const moduleRuleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
});
describe('no-adhoc-regex-escape rule', () => {
test('rule module exports a create function', () => {
assert.strictEqual(typeof noAdhocRegexEscape.create, 'function');
});
// ── row 24: invalid — a new inline escape-all-metachars .replace() ────────
test('row 24 invalid: a new inline .replace(/[.*+?^${}()|[\\]\\\\]/g, \'\\\\$&\') outside the seam', () => {
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
valid: [],
invalid: [
{
// NOT String.raw: the code-under-test contains a literal `${` (part
// of the escape-all-metachars character class), which String.raw
// would still interpret as a template-literal substitution marker.
// A normal (non-raw) template literal is used instead, with `\$`
// and doubled backslashes to produce the exact literal source text.
code: `
function esc(s) {
return s.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&');
}
`,
filename: 'src/some-module.cts',
errors: [{ messageId: 'adhocRegexEscape' }],
},
],
});
});
// ── row 25: invalid — reordered/differently-escaped char class, same shape ─
test('row 25 invalid: same escape shape with a REORDERED character class still fires (matches shape, not exact bytes)', () => {
// Same 14-member set { . * + ? ^ $ { } ( ) | [ ] \ } as row 24, but written
// in a different order and with a different (still valid) escaping style.
// If the rule only string-matched the exact literal bytes of the census
// copies, this would slip through — it must not.
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
valid: [],
invalid: [
{
code: String.raw`
function escapeForRegex(value) {
return value.replace(/[\^$.|?*+()[\]{}\\]/g, '\\$&');
}
`,
filename: 'src/another-module.cts',
errors: [{ messageId: 'adhocRegexEscape' }],
},
],
});
});
// ── row 26: valid — the seam file itself is exempt ─────────────────────────
test('row 26 valid: src/pattern.cts itself is exempt (the seam owns this shape)', () => {
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
valid: [
{
// Not String.raw — see the row-24 comment above (`${` needs escaping
// in a non-raw template literal).
code: `
function escapeRegex(value) {
return value.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&');
}
`,
filename: 'src/pattern.cts',
},
],
invalid: [],
});
});
// ── row 27: valid (negative space) — new RegExp from an exported *_SOURCE ──
test('row 27 valid: new RegExp built from an exported *_SOURCE constant is NOT flagged (critical negative-space case)', () => {
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
valid: [
{
// A deliberate, reviewed pattern fragment (design doc Negative space:
// PHASE_NUMBER_TOKEN_SOURCE, MILESTONE_HEADING_LINE_SOURCE, etc.) —
// its metacharacters are load-bearing and must not be treated as an
// unescaped runtime value.
code: String.raw`
const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?';
const re = new RegExp(PHASE_NUMBER_TOKEN_SOURCE);
`,
filename: 'src/phase-id.cts',
},
{
code: String.raw`
const MILESTONE_HEADING_LINE_SOURCE = '^##\\s+Milestone\\s';
const re = new RegExp(MILESTONE_HEADING_LINE_SOURCE, 'm');
`,
filename: 'src/roadmap-parser.cts',
},
],
invalid: [],
});
});
// ── row 28: valid (negative space) — regex literal containing the class as data ─
test('row 28 valid: a regex literal that merely CONTAINS [.*+?] as data is NOT flagged', () => {
// A legitimate validator matching one of the metacharacters as literal
// DATA — no .replace(..., '\$&') shape present, so this must not fire.
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
valid: [
{
code: String.raw`const isMetachar = /^[.*+?]$/.test(char);`,
filename: 'src/some-validator.cts',
},
],
invalid: [],
});
});
// ── #3412 Standards-review Finding 1: _SOURCE naming-only evasion closed ──
// A rule that trusts the `_SOURCE` suffix on spelling alone (no
// scope/binding check) is exactly #3410's guard-evasion class: naming a
// genuinely dynamic value with a matching suffix defeats it. These
// cases lock that the fallback is now bound to the identifier's ACTUAL
// BINDING KIND (import / require()-derived const), not its name.
test('Finding 1 invalid: a function-parameter identifier named *_SOURCE is NOT exempted by naming alone', () => {
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
valid: [],
invalid: [
{
// The evasion: naming a plain, unescaped function parameter with
// the `_SOURCE` suffix used to sail past condition (b) because
// that check was pure identifier-name regex matching with no
// scope/binding verification. It must fire now.
code: String.raw`
function f(userInput_SOURCE) {
return new RegExp(userInput_SOURCE);
}
`,
filename: 'src/some-module.cts',
errors: [{ messageId: 'unsafeNewRegExp' }],
},
],
});
});
test('Finding 1 invalid: a reassigned let identifier named *_SOURCE is NOT exempted by naming alone', () => {
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
valid: [],
invalid: [
{
// A `let` binding holding genuinely dynamic content (not a
// static const, not an import, not a require()-derived const) —
// the suffix alone must not exempt it.
code: String.raw`
let mutable_SOURCE = getUserInput();
const re = new RegExp(mutable_SOURCE);
`,
filename: 'src/some-module.cts',
errors: [{ messageId: 'unsafeNewRegExp' }],
},
],
});
});
test('Finding 1 valid: a require()-derived *_SOURCE const (destructured) still passes', () => {
// The legitimate cross-module case condition (b) exists to cover:
// `const { X_SOURCE } = require('./mod.cjs')`. Proves the tightened
// check did not regress this repo's dominant CJS import shape.
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
valid: [
{
code: String.raw`
const { PHASE_NUMBER_TOKEN_SOURCE } = require('./phase-id.cjs');
const re = new RegExp(PHASE_NUMBER_TOKEN_SOURCE);
`,
filename: 'src/roadmap-parser.cts',
},
{
// The other require()-derived shape condition (b) covers:
// `const X_SOURCE = require('./mod.cjs').X_SOURCE`.
code: String.raw`
const PHASE_NUMBER_TOKEN_SOURCE = require('./phase-id.cjs').PHASE_NUMBER_TOKEN_SOURCE;
const re = new RegExp(PHASE_NUMBER_TOKEN_SOURCE);
`,
filename: 'src/roadmap-parser.cts',
},
],
invalid: [],
});
});
test('Finding 1 valid: an ES `import`-derived *_SOURCE binding still passes', () => {
moduleRuleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
valid: [
{
code: String.raw`
import { PHASE_NUMBER_TOKEN_SOURCE } from './phase-id.cts';
const re = new RegExp(PHASE_NUMBER_TOKEN_SOURCE);
`,
filename: 'src/roadmap-parser.cts',
},
],
invalid: [],
});
});
// ── row 29: invalid — new RegExp from a genuinely unescaped runtime value ──
test('row 29 invalid: new RegExp from a genuinely unescaped runtime value fires', () => {
// A plain, non-`_SOURCE`-suffixed parameter interpolated straight into
// new RegExp() with no seam routing (no escapeRegex/literalPattern call
// anywhere on it) — ADR §7: "flags new RegExp() built from a non-literal
// without routing through the seam." This is the minimal-pair inverse of
// row 27: same `new RegExp(<identifier>)` shape, but the identifier
// carries no `_SOURCE` provenance marker.
ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, {
valid: [],
invalid: [
{
code: String.raw`
function buildLiteralMatcher(userSuppliedValue) {
return new RegExp(userSuppliedValue);
}
`,
filename: 'src/some-module.cts',
errors: [{ messageId: 'unsafeNewRegExp' }],
},
],
});
});
// ── ReDoS regression — scripts/lint-no-adhoc-regex-escape.cjs's own regex ─
test('an adversarial [] run after .replace(/ terminates instead of backtracking exponentially (#3412)', () => {
// Pre-fix, REPLACE_CALL_RE's outer alternation let a `[...]` run be
// consumed either by the character-class branch or one char at a time
// by the catch-all branch, so a failing match explored both parses of
// every bracket pair: measured n=30 -> 3475ms (~2^n growth per +2). This
// adversarial input never closes the `.replace(/` call (no trailing
// `, '...')`), forcing the engine all the way through backtracking on a
// pre-fix regex. n=2000 is comfortably past where the pre-fix regex
// would already be unusable; a linear-time regex resolves it instantly.
const adversarial = '.replace(/' + '[]'.repeat(2000) + 'X';
const result = findViolations(adversarial);
assert.deepEqual(result, []);
});
});

View File

@@ -0,0 +1,265 @@
'use strict';
/**
* Copy a repo script into a throwaway fixture tree ALONG WITH its transitive
* relative-require dependencies, preserving repo-relative layout.
*
* Several suites drive a `scripts/*.cjs` end-to-end by copying it into an
* mkdtemp fixture and spawning it there — necessary because those scripts
* resolve their scan root from `path.join(__dirname, '..')`, so running the
* REAL script would scan the real repo instead of the fixture (see
* tests/removed-but-needed-lint.test.cjs's copyScriptInto for the original
* statement of that constraint).
*
* Each such harness used to hand-list the script's dependencies
* (`fs.copyFileSync(... 'scripts/lib/cli-exit.cjs' ...)`). That made every new
* require in a covered script a silent, duplicated edit across N harnesses,
* and the failure mode was a MODULE_NOT_FOUND that only ever appeared in CI.
* #3412 collected the bill: adding one require of the new pattern seam to
* gen-adr-index.cjs broke 82 tests across two suites that had each hand-copied
* a now-incomplete dependency list.
*
* Walking the require graph instead means a script's dependencies are derived,
* never re-declared, so this class cannot recur.
*/
const fs = require('node:fs');
const path = require('node:path');
const espree = require('espree');
/** Relative specifiers — the only kind that resolves inside the fixture tree. */
const RELATIVE_SPEC_RE = /^\.{1,2}[\\/]/;
/**
* Parse attempts tried in order by `extractRequires`. `globalReturn: true` is
* required on the `script` attempt because Node wraps every CommonJS module
* body in an implicit function, which makes a top-level `return` legal there
* even though it is not legal in a bare ECMAScript Program — `espree` without
* the flag rejects it with "'return' outside of function". A real shipped
* script relies on exactly this (`scripts/check-coverage-gate.cjs` has a
* top-level `return`), so dropping the flag silently breaks the #2858
* packaging guard that scans it. Do not remove this without re-verifying
* every shipped `scripts/**`, `bin/**`, and `gsd-core/bin/**` .cjs/.js file
* still parses.
*/
const PARSE_ATTEMPTS = [
{ ecmaVersion: 'latest', sourceType: 'script', ecmaFeatures: { globalReturn: true } },
{ ecmaVersion: 'latest', sourceType: 'module' },
];
/**
* Extract every static `require('...')` string-literal call from a CJS or ESM
* source, via a real AST parse rather than pattern-matching. Commented-out,
* string-embedded, and template-literal occurrences are correctly ignored;
* `foo.require('x')` (a method call, not a bare identifier callee) is
* correctly ignored; dynamic `require(variable)` remains out of scope — it
* cannot be resolved statically, and in a script that ships it would itself
* be a red flag (see tests/packaging-shipped-scripts-require-only-shipped.
* test.cjs, which consumes this same extractor so the two guards cannot
* disagree about what "a require" is).
*
* Currently a spurious hit (a require-shaped call this function reports that
* the source does not actually reach) is not merely harmless: an unresolvable
* specifier makes `copyScriptWithDeps` throw. With a real parser this is
* largely moot — there is no pattern-matching left to produce a false
* positive from comment/string/regex content.
*
* @param {string} source
* @returns {string[]} specifiers, in source order, duplicates included
*/
function extractRequires(source) {
let ast = null;
for (const options of PARSE_ATTEMPTS) {
try {
ast = espree.parse(source, options);
break;
} catch {
/* try next */
}
}
if (ast === null) {
throw new Error('extractRequires: source did not parse as script or module');
}
const found = [];
(function walk(node) {
if (node === null || typeof node !== 'object') return;
if (Array.isArray(node)) {
node.forEach(walk);
return;
}
if (
node.type === 'CallExpression' &&
node.callee &&
node.callee.type === 'Identifier' &&
node.callee.name === 'require' &&
node.arguments.length === 1 &&
node.arguments[0].type === 'Literal' &&
typeof node.arguments[0].value === 'string'
) {
found.push(node.arguments[0].value);
}
for (const key of Object.keys(node)) walk(node[key]);
})(ast);
return found;
}
/**
* Resolve a relative require specifier to a real file, applying Node's CJS
* extension/index candidates.
*
* @param {string} fromAbsFile absolute path of the requiring file
* @param {string} spec the relative specifier
* @returns {string|null} absolute path of the resolved file, or null
*/
function resolveRelativeRequire(fromAbsFile, spec) {
const base = path.resolve(path.dirname(fromAbsFile), spec);
const candidates = [
base,
`${base}.cjs`,
`${base}.js`,
`${base}.json`,
path.join(base, 'index.cjs'),
path.join(base, 'index.js'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
}
return null;
}
/**
* True when `rel` (a `path.relative(base, target)` result) climbs outside
* `base` — i.e. `target` is not contained within `base`. Guards against the
* `'..foo'.startsWith('..')` false positive (a real sibling directory named
* `..foo` is NOT an escape) by requiring either an exact `..` or a
* `..<sep>`-prefixed relative path, using the platform separator since
* `path.relative` returns platform-native separators.
*
* @param {string} rel
* @returns {boolean}
*/
function escapesContainment(rel) {
return rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
}
/**
* Copy `scriptRel` (repo-relative, e.g. `scripts/gen-adr-index.cjs`) and every
* file reachable from it through static relative requires into `fixtureRoot`,
* at the same repo-relative paths. Bare specifiers (`node:fs`, npm packages)
* are left alone — they resolve from the real installation.
*
* Both the entry script and every discovered dependency are validated against
* the repo boundary before being copied (symlinks resolved via
* `fs.realpathSync` first, since the containment check is otherwise lexical
* while `copyFileSync`/`readFileSync` follow links — a repo-committed symlink
* pointing outside the repo must not smuggle its target's contents in). The
* ORIGINAL repo-relative path (not the realpath-derived one) is preserved for
* the destination location, so the copied layout is unchanged; the
* realpath-derived repo-relative path is used as the dedupe key so a
* directory-symlink cycle cannot mint a new key per level.
*
* Throws when a relative require does not resolve on disk, or when the entry
* or a dependency resolves outside the repo. Unresolved-require failures are
* nearly always an unbuilt artifact (`gsd-core/bin/lib/*.cjs` requires
* `npm run build:lib`), and failing here names the cause instead of letting
* the spawned subprocess die with a bare MODULE_NOT_FOUND.
*
* @param {string} repoRoot absolute path to the repo root
* @param {string} fixtureRoot absolute path to the temp fixture root
* @param {string} scriptRel repo-relative path of the script to copy
* @returns {string} absolute path of the copied script inside `fixtureRoot`
*/
function copyScriptWithDeps(repoRoot, fixtureRoot, scriptRel) {
const toPosix = (p) => p.split(path.sep).join('/');
const entry = toPosix(scriptRel);
const copied = new Set();
const unresolved = [];
let realRepoRoot;
try {
realRepoRoot = fs.realpathSync(repoRoot);
} catch (err) {
throw new Error(`copyScriptWithDeps: could not resolve repoRoot ${repoRoot}: ${err.message}`);
}
/**
* @param {string} abs absolute path (not yet realpath-resolved)
* @param {string} label human-readable label for error messages
* @returns {{ dedupeKey: string } | null} null when unresolvable or escaping
*/
function checkContainment(abs, label) {
let real;
try {
real = fs.realpathSync(abs);
} catch (err) {
unresolved.push(`${label} could not be resolved on disk: ${err.message}`);
return null;
}
const dedupeKey = toPosix(path.relative(realRepoRoot, real));
if (escapesContainment(dedupeKey)) {
unresolved.push(`${label} resolves outside the repo (${real}) — refusing to copy outside the fixture`);
return null;
}
return { dedupeKey };
}
/** @param {string} rel repo-relative posix path (ORIGINAL, pre-realpath) */
function copyOne(rel) {
const abs = path.join(repoRoot, rel);
if (!fs.existsSync(abs)) {
if (!copied.has(rel)) {
copied.add(rel);
unresolved.push(`${rel} (does not exist in the repo)`);
}
return;
}
const containment = checkContainment(abs, rel);
if (!containment) return;
if (copied.has(containment.dedupeKey)) return;
copied.add(containment.dedupeKey);
const dest = path.join(fixtureRoot, rel);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(abs, dest);
for (const spec of extractRequires(fs.readFileSync(abs, 'utf-8'))) {
if (!RELATIVE_SPEC_RE.test(spec)) continue;
const depAbs = resolveRelativeRequire(abs, spec);
if (!depAbs) {
unresolved.push(`require('${spec}') from ${rel}`);
continue;
}
const depRel = toPosix(path.relative(repoRoot, depAbs));
copyOne(depRel);
}
}
// F2: the entry path bypasses the sandbox guard the same way a dependency
// could — validate it against the same containment rule before copying.
const entryAbs = path.join(repoRoot, entry);
if (!fs.existsSync(entryAbs)) {
throw new Error(`copyScriptWithDeps: entry script does not exist in the repo: ${entry}`);
}
const entryContainment = checkContainment(entryAbs, `entry script ${entry}`);
if (!entryContainment) {
throw new Error(
`copyScriptWithDeps: entry script ${entry} resolves outside the repo — refusing to copy outside the fixture`,
);
}
copyOne(entry);
if (unresolved.length > 0) {
throw new Error(
`copyScriptWithDeps(${entry}) could not resolve ${unresolved.length} ` +
`relative require(s); the fixture would fail with MODULE_NOT_FOUND:\n` +
unresolved.map((u) => ` - ${u}`).join('\n') +
`\nIf these are compiled artifacts under gsd-core/bin/lib/, run \`npm run build:lib\`.`,
);
}
return path.join(fixtureRoot, scriptRel);
}
module.exports = { extractRequires, resolveRelativeRequire, copyScriptWithDeps };

View File

@@ -24,6 +24,7 @@ const { runNode } = require('./process-seam.cjs');
const {
resolveRuntimeArtifactLayout,
} = require('../../gsd-core/bin/lib/runtime-artifact-layout.cjs');
const { escapeRegex: escapeRegExp } = require('../../gsd-core/bin/lib/pattern.cjs');
const INSTALL_SCRIPT = path.join(__dirname, '..', '..', 'bin', 'install.js');
const MANIFEST_NAME = 'gsd-file-manifest.json';
@@ -227,9 +228,6 @@ function stripAnsi(str) {
// A version string can itself contain regex metacharacters (`.`, and — via
// prerelease/build metadata — `-`/`+`), so it must be escaped before being spliced
// into a RegExp source, or e.g. the `.` in "1.9.0" would match ANY character.
function escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Loosely semver-shaped: leading `MAJOR.MINOR.PATCH`, optional `-prerelease` and/or
// `+build` metadata (e.g. `1.9.0`, `1.9.0-rc.1`, `1.9.0+abc`). Deliberately loose

View File

@@ -42,6 +42,7 @@ const {
} = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
const { applySurface } = require('../gsd-core/bin/lib/surface.cjs');
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
const {
loadSkillsManifest,
@@ -433,7 +434,7 @@ describe('installOpencodeFamilySkills — emits skills/<name>/SKILL.md (#784)',
);
// Regression guard for the prefix-overlap double-rewrite (e.g. kilo-alt-alt).
assert.ok(
!new RegExp(`${defaultBase.replace(/[\\.*+?^${}()|[\]]/g, '\\$&')}-[^/\\s]*-`).test(body),
!new RegExp(`${escapeRegex(defaultBase)}-[^/\\s]*-`).test(body),
`${skillName}: must not contain a doubled config-dir suffix`,
);
}

View File

@@ -49,7 +49,7 @@ const REPO_ROOT = path.join(__dirname, '..');
* Recursively list every `.cjs` file under `tests/`, including subdirectories
* (`tests/helpers/`, `tests/qa/`, `tests/observability/`, `tests/fixtures/`,
* `tests/dispatch/`, etc.). `fs.readdirSync(dir, { recursive: true,
* withFileTypes: true })` is available on the repo's Node floor (>=22.0.0 per
* withFileTypes: true })` is available on the repo's Node floor (>=24.0.0 per
* package.json `engines`; the option landed in Node 20.1). Each returned
* `Dirent` carries `parentPath` — its containing directory, which for a
* nested entry is the subdirectory, not `TESTS_DIR` — so the joined path is

View File

@@ -21,6 +21,8 @@ const { execFileSync } = require('node:child_process');
const REPO_ROOT = path.join(__dirname, '..');
const { extractRequires } = require('./helpers/copy-script-fixture.cjs');
/**
* Resolve the tarball file list via `npm pack --dry-run --json`.
* This is the ACTUAL set of files that ship — not a hardcoded list — so adding
@@ -47,27 +49,6 @@ function resolveTarballFiles() {
return new Set(parsed[0].files.map((f) => f.path.replace(/\\/g, '/')));
}
/**
* Extract all require('...') string-literal calls from a .cjs source.
* Only static string-literal requires are checked — dynamic require(variable)
* is out of scope (and would itself be a red flag in a shipped script).
*/
function extractRequires(source) {
const requires = [];
// Strip block comments (/* ... */) and inline line comments (// ...) before
// matching, so a require() appearing in a doc comment or fenced code block
// inside a /* */ does not produce a false positive.
const stripped = source
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '');
const requireRe = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
let m;
while ((m = requireRe.exec(stripped)) !== null) {
requires.push(m[1]);
}
return requires;
}
/**
* Classify a require specifier from a given file path:
* - 'builtin' — node: prefix or a Node builtin (fs, path, etc.)
@@ -198,6 +179,15 @@ describe('#2858 — shipped scripts require only shipped paths', () => {
);
});
test('lint-no-adhoc-regex-escape.cjs does NOT ship (repo-only CI tooling)', () => {
// This script requires ../eslint-rules/ which does not ship. It is
// repo-only CI tooling. It must be excluded from the npm tarball (#3412).
assert.ok(
!shippedFiles.has('scripts/lint-no-adhoc-regex-escape.cjs'),
'scripts/lint-no-adhoc-regex-escape.cjs must NOT ship — it requires ../eslint-rules/ which does not ship (#3412)',
);
});
test('a script requiring a sibling in the same shipped dir resolves as shipped (positive case)', () => {
// Sanity: scripts/lib/cli-exit.cjs ships and is required by shipped scripts.
// This confirms the guard's positive path works — a valid intra-shipped require

204
tests/pattern.test.cjs Normal file
View File

@@ -0,0 +1,204 @@
'use strict';
/**
* Tests for `src/pattern.cts` — the pattern-construction seam (#3212 Phase 1, #3412).
*
* Design: .gsd/phase/chore-3412-pattern-seam/40-design.md
* Test matrix: .gsd/phase/chore-3412-pattern-seam/50-test-matrix.md
* ADR: docs/adr/3212-lexical-seam-consolidation.md §1, §2, §7
*
* TDD RED: `src/pattern.cts` does not exist yet — this file's
* `require('../gsd-core/bin/lib/pattern.cjs')` throws MODULE_NOT_FOUND until
* the implementing phase adds it. That is the intended starting state (mirrors
* tests/planning-snapshot.test.cjs's RED convention).
*
* Covers test-matrix sections 1 (escapeRegex, rows 1-10), 2 (literalPattern,
* rows 11-14), and 3 (migration equivalence, rows 15-17).
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
// Seeded fast-check convention: require the shared setup helper (NOT
// 'fast-check' directly) so numRuns/seed are configured globally before any
// fc.assert() call — mirrors tests/frontmatter.property.test.cjs and every
// other *.property.test.cjs file. seed: 42, overridable via GSD_FC_SEED.
const fc = require('./helpers/fast-check-setup.cjs');
const { escapeRegex, literalPattern } = require('../gsd-core/bin/lib/pattern.cjs');
// ─── Section 1: escapeRegex — rows 1-10 ───────────────────────────────────
describe('escapeRegex', () => {
test('row 1: "" returns ""', () => {
assert.strictEqual(escapeRegex(''), '');
});
test('row 2: ordinary chars only ("_x") are unchanged', () => {
assert.strictEqual(escapeRegex('_x'), '_x');
});
test('row 3: each metacharacter is escaped and the result matches its own literal form', () => {
const metachars = ['.', '*', '+', '?', '^', '$', '{', '}', '(', ')', '|', '[', ']', '\\'];
for (const ch of metachars) {
const escaped = escapeRegex(ch);
const re = new RegExp(escaped);
assert.ok(re.test(ch), `escapeRegex(${JSON.stringify(ch)}) -> ${JSON.stringify(escaped)} must match its own literal char`);
}
});
test('row 4: leading ASCII letter locks the MEASURED hex-escape ("abc" -> "\\x61bc", Node v26.5.1)', () => {
assert.strictEqual(escapeRegex('abc'), '\\x61bc');
});
test('row 5: leading digit locks the MEASURED hex-escape ("5abc" -> "\\x35abc", Node v26.5.1)', () => {
assert.strictEqual(escapeRegex('5abc'), '\\x35abc');
});
test('row 6: hyphen anywhere locks the MEASURED hex-escape ("a-b" -> "\\x61\\x2db", Node v26.5.1)', () => {
assert.strictEqual(escapeRegex('a-b'), '\\x61\\x2db');
});
test('row 7: whitespace / newline / control chars are escaped and the result still constructs', () => {
const values = [' ', '\n', '\t', '\r', 'a\nb', 'a\tb\rc'];
for (const v of values) {
const escaped = escapeRegex(v);
assert.doesNotThrow(() => new RegExp(escaped));
assert.ok(new RegExp(escaped).test(v));
}
});
// #3212 Spec-axis review correction: this throw behavior matches MOST of the
// deleted copies' `.replace` throw, but NOT ALL of them — `src/phase-id.cts`'s
// original `escapeRegex(value: unknown)` did `String(value).replace(...)` and
// never threw (`escapeRegex(42) === '42'`, `escapeRegex(null) === 'null'`, per
// the deleted `tests/phase-id.test.cjs` assertions). That is a deliberate,
// disclosed behavior change for phase-id's former callers: the seam's locked
// signature (`escapeRegex(value: string): string`, ADR §1) does not coerce, so
// a non-string reaching it now throws instead of silently stringifying.
// Mitigated by an explicit `String(...)` wrap at the one former phase-id call
// site that genuinely needed it (phaseMarkdownRegexSource's fallback branch,
// src/phase-id.cts) — the seam's throw itself is intentional and stays.
test('row 8: non-string input throws TypeError (matches 11 of the 12 deleted copies\' .replace throw — phase-id.cts\'s copy coerced instead, see comment above)', () => {
for (const bad of [null, undefined, 42, {}, [], true]) {
assert.throws(() => escapeRegex(bad), TypeError, `escapeRegex(${JSON.stringify(bad)}) must throw TypeError`);
}
});
test('row 9: a 10,000-char value does not throw and completes without catastrophic time', () => {
const big = 'a-b.c*d'.repeat(1429); // ~10,001 chars
let escaped;
assert.doesNotThrow(() => {
escaped = escapeRegex(big);
});
assert.doesNotThrow(() => new RegExp(escaped));
});
test('row 10: RegExp.escape availability — the ADR §2 floor-vs-capability assertion', () => {
assert.strictEqual(typeof RegExp.escape, 'function');
});
});
// ─── Section 2: literalPattern — rows 11-14 ───────────────────────────────
describe('literalPattern', () => {
test('row 11: value + no flags produces a RegExp matching the value literally', () => {
const re = literalPattern('abc');
assert.ok(re instanceof RegExp);
assert.ok(re.test('abc'));
});
test('row 12: value + valid flags ("gi") applies the flags', () => {
const re = literalPattern('abc', 'gi');
assert.strictEqual(re.flags, 'gi');
assert.ok(re.test('ABC'));
});
test('row 13: invalid flags ("qq") throws SyntaxError from RegExp', () => {
assert.throws(() => literalPattern('abc', 'qq'), SyntaxError);
});
test('row 14: metacharacter-heavy value matches literally, not by its metacharacter interpretation', () => {
const re = literalPattern('a.b*c');
assert.ok(re.test('a.b*c'), 'must match the literal string');
// If '.' and '*' were left live, this would ALSO match (any-char + zero-or-more)
assert.ok(!re.test('aXbYYYc'), 'must NOT match the metacharacter interpretation');
});
});
// ─── Section 3: migration equivalence — rows 15-17 (load-bearing) ─────────
describe('migration equivalence (row-9 sweep)', () => {
test('row 15: property — hand oracle and escapeRegex are match-equivalent for all (s, probe) pairs (seeded)', () => {
// `hand` is the historical oracle: the deleted pre-migration implementation,
// byte-identical across all twelve copies before this seam existed (design
// doc "Ground truth" #1; ADR §7 Class 1 census). It is inlined here ONLY as
// a test oracle for this equivalence sweep — this is NOT a 13th production
// copy of the escape helper (design doc Notes: "not a 13th production copy").
// eslint-disable-next-line local/no-adhoc-regex-escape -- historical oracle for the row-15 equivalence sweep, not a production copy (#3412)
const hand = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
fc.assert(
fc.property(
fc.string({ maxLength: 200 }),
fc.string({ maxLength: 50 }),
(s, probe) => {
const handResult = new RegExp(hand(s)).test(probe);
const seamResult = new RegExp(escapeRegex(s)).test(probe);
assert.strictEqual(
seamResult,
handResult,
`match divergence for s=${JSON.stringify(s)} probe=${JSON.stringify(probe)}`
);
}
)
);
});
test('row 16: the real corpus — phase tokens, milestone versions, STATE field names, runtime ids are match-equivalent', () => {
// eslint-disable-next-line local/no-adhoc-regex-escape -- historical oracle for the row-16 equivalence sweep, not a production copy (#3412)
const hand = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const corpus = [
'1A',
'PROJ-05',
'999.1',
'v1.2',
'**Current Phase:**',
'claude-code',
'gemini-cli',
];
const probes = ['1A', 'PROJ-05', '999.1', 'v1.2', '**Current Phase:**', 'claude-code', 'gemini-cli', 'no-match-here'];
for (const s of corpus) {
for (const probe of probes) {
const handResult = new RegExp(hand(s)).test(probe);
const seamResult = new RegExp(escapeRegex(s)).test(probe);
assert.strictEqual(
seamResult,
handResult,
`corpus divergence for s=${JSON.stringify(s)} probe=${JSON.stringify(probe)}`
);
}
}
});
test('row 17: latent-bug fix — a hyphen interpolated into a character class no longer forms a range', () => {
// Pre-migration (hand-rolled, verified): new RegExp('[' + hand('a-z') + ']').test('m') === true
// — the unescaped '-' formed an unintended a-through-z RANGE that happened
// to match 'm'. This is design row 10's confirmed latent bug.
// eslint-disable-next-line local/no-adhoc-regex-escape -- historical oracle for the row-17 latent-bug check, not a production copy (#3412)
const hand = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
assert.strictEqual(
new RegExp('[' + hand('a-z') + ']').test('m'),
true,
'sanity check: pre-migration hand-rolled behavior was true (verified)'
);
// Post-migration (the seam): the fix. 'a', '-', 'z' are all escaped as
// discrete literal characters, so '[...]' no longer forms a range and 'm'
// (interior to the would-be range) must NOT match.
assert.strictEqual(
new RegExp('[' + escapeRegex('a-z') + ']').test('m'),
false,
'deliberate fix, not an accident: the seam must not let a value form a character-class range'
);
});
});

View File

@@ -32,8 +32,15 @@ const phaseId = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phase-id.cjs'
// The locked canonical surface (ADR-2121 Decision 1/2; PHASE_NUMBER_TOKEN_SOURCE
// added in Phase 4). Every name is exported by phase-id.cjs; the identity guard
// forbids any other module from re-exporting a divergent copy of one.
// #3212 Phase 1: `escapeRegex` moved off this locked surface entirely — it is
// no longer owned (or re-exported) by phase-id.cjs, it is owned by the
// pattern-construction seam (src/pattern.cts / gsd-core/bin/lib/pattern.cjs).
// Dropped from CANONICAL rather than kept: phase-id.cjs no longer exports the
// name at all, so `name in phaseId` below would fail if it stayed listed, and
// the identity-guard test's job (no consumer re-exports a DIVERGENT copy) is
// now the pattern seam's own single-owner property, not phase-id's.
const CANONICAL = [
'escapeRegex', 'OPTIONAL_PROJECT_CODE_PREFIX_SOURCE', 'OPTIONAL_PHASE_TAG_SOURCE',
'OPTIONAL_PROJECT_CODE_PREFIX_SOURCE', 'OPTIONAL_PHASE_TAG_SOURCE',
'PHASE_NUMBER_TOKEN_SOURCE', 'stripProjectCodePrefix', 'normalizePhaseName',
'getMilestoneFromPhaseId', 'getPhaseDirFromPhaseId', 'phaseMarkdownRegexSource',
'phaseMarkdownRegexSourceExact', 'comparePhaseNum', 'extractPhaseToken',

View File

@@ -2,7 +2,6 @@
* Tests for src/phase-id.cts (compiled to gsd-core/bin/lib/phase-id.cjs).
*
* Verifies behavioural contracts of the extracted pure phase-id helpers:
* - escapeRegex
* - normalizePhaseName
* - comparePhaseNum
* - extractPhaseToken
@@ -22,53 +21,27 @@ const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const phaseId = require('../gsd-core/bin/lib/phase-id.cjs');
const { buildPhaseHeadingRegex: headingRegex } = require('../gsd-core/bin/lib/roadmap.cjs');
const fc = require('fast-check');
// ─── escapeRegex ─────────────────────────────────────────────────────────────
// escapeRegex moved off phase-id.cjs entirely in #3212 Phase 1 (#3412): it is
// now owned by the pattern-construction seam (src/pattern.cts, tests in
// tests/pattern.test.cjs) and phase-id.cjs no longer exports it — see
// tests/phase-id-drift-guard.test.cjs's CANONICAL list, which drops it for
// the same reason.
describe('escapeRegex', () => {
test('escapes all regex special characters', () => {
assert.strictEqual(phaseId.escapeRegex('.'), '\\.');
assert.strictEqual(phaseId.escapeRegex('*'), '\\*');
assert.strictEqual(phaseId.escapeRegex('+'), '\\+');
assert.strictEqual(phaseId.escapeRegex('?'), '\\?');
assert.strictEqual(phaseId.escapeRegex('^'), '\\^');
assert.strictEqual(phaseId.escapeRegex('$'), '\\$');
assert.strictEqual(phaseId.escapeRegex('{'), '\\{');
assert.strictEqual(phaseId.escapeRegex('}'), '\\}');
assert.strictEqual(phaseId.escapeRegex('('), '\\(');
assert.strictEqual(phaseId.escapeRegex(')'), '\\)');
assert.strictEqual(phaseId.escapeRegex('|'), '\\|');
assert.strictEqual(phaseId.escapeRegex('['), '\\[');
assert.strictEqual(phaseId.escapeRegex(']'), '\\]');
assert.strictEqual(phaseId.escapeRegex('\\'), '\\\\');
});
test('leaves alphanumeric and hyphen characters unescaped', () => {
assert.strictEqual(phaseId.escapeRegex('abc'), 'abc');
assert.strictEqual(phaseId.escapeRegex('01-02'), '01-02');
assert.strictEqual(phaseId.escapeRegex('v1.0'), 'v1\\.0');
});
test('coerces non-string values via String()', () => {
assert.strictEqual(phaseId.escapeRegex(42), '42');
assert.strictEqual(phaseId.escapeRegex(null), 'null');
assert.strictEqual(phaseId.escapeRegex(undefined), 'undefined');
});
test('adversarial: path-traversal-like inputs are treated as literals', () => {
const result = phaseId.escapeRegex('../../../etc/passwd');
// The dots get escaped; slashes and alphanumeric pass through unchanged
assert.strictEqual(result, '\\.\\./\\.\\./\\.\\./etc/passwd');
// The result forms a valid regex (no throws)
assert.doesNotThrow(() => new RegExp(result));
});
test('unicode passthrough', () => {
assert.strictEqual(phaseId.escapeRegex('Phase Name'), 'Phase Name');
assert.strictEqual(phaseId.escapeRegex('中文'), '中文');
});
});
// ─── #3412 shared test helper ─────────────────────────────────────────────────
//
// phase-id.cts now delegates regex escaping to RegExp.escape via src/pattern.cts.
// RegExp.escape is MATCH-equivalent to the retired hand-rolled escaper but not
// TEXT-equivalent (it hex-escapes the first character and all hyphens), so any
// test that pinned the literal source text (e.g. `'0*29'`, `'PROJ-42'`) is
// brittle to that internal encoding, not to actual behavior. `headingRegex`
// (imported above as `buildPhaseHeadingRegex` from gsd-core/bin/lib/roadmap.cjs)
// is the SAME production function src/roadmap.cts's searchPhaseInContent uses to
// build its heading regex — not a hand-duplicated copy — so tests assert what
// matches and what doesn't — the real contract — rather than the escaper's
// spelling, with no parity gap against the production pattern.
// ─── normalizePhaseName ───────────────────────────────────────────────────────
@@ -387,20 +360,50 @@ describe('phaseMarkdownRegexSource', () => {
const re = new RegExp(src);
assert.ok(!re.test('3X1'), 'unescaped dot would match any char — must be escaped');
});
test('regex metacharacters in a phase id are neutralized, not interpreted (#3412)', () => {
// The property RegExp.escape exists for: a literal metacharacter in the
// phase id (the dot in "1.2") must not behave as a regex wildcard once the
// source is compiled into the same heading regex production uses.
const src = phaseId.phaseMarkdownRegexSource('1.2');
const re = headingRegex(src);
assert.ok(re.test('Phase 1.2: Title'));
assert.ok(!re.test('Phase 1X2: Title'));
});
});
// ─── phaseMarkdownRegexSourceExact ────────────────────────────────────────────
describe('phaseMarkdownRegexSourceExact', () => {
test('returns escaped form for project-code-prefixed IDs', () => {
const result = phaseId.phaseMarkdownRegexSourceExact('PROJ-42');
// hyphen is not a regex special char so it passes through unescaped
assert.strictEqual(result, 'PROJ-42');
// The result is a valid regex source
assert.doesNotThrow(() => new RegExp(result));
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('MANIFOLD-117'), 'MANIFOLD-117');
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('APP1-117'), 'APP1-117');
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('APP_1-117'), 'APP_1-117');
// Source text is RegExp.escape's business (#3412) — the actual contract
// is a non-null, compilable source that matches its own prefixed heading
// and rejects the bare-numeric heading.
for (const [id, bareHeadingNum, foreignPrefix] of [
['PROJ-42', '42', 'OTHER'],
['AB-29', '29', 'CK'],
['MANIFOLD-117', '117', 'OTHER'],
['APP1-117', '117', 'OTHER'],
['APP_1-117', '117', 'OTHER'],
]) {
const result = phaseId.phaseMarkdownRegexSourceExact(id);
assert.ok(result !== null, id);
assert.doesNotThrow(() => new RegExp(result));
const re = headingRegex(result);
assert.ok(re.test(`Phase ${id}: Title`), `${id} must match its own heading`);
assert.ok(!re.test(`Phase ${bareHeadingNum}: Title`), `${id} must not match the bare-numeric heading`);
// #3599 regression: the exact source must be tied to the FULL prefixed
// id, not just its trailing number — a different prefix with the same
// number must not match (an impl returning `[A-Z]+-42` would pass every
// assertion above but fail this one).
assert.ok(
!re.test(`Phase ${foreignPrefix}-${bareHeadingNum}: Title`),
`${id} must not match a foreign-prefixed heading with the same number`,
);
// Case-insensitivity (the 'i' flag) — canonicalizes the same way a
// literal would, despite the hex escape (#3412).
assert.ok(re.test(`phase ${id.toLowerCase()}: title`));
}
});
test('returns null for non-prefixed IDs', () => {
@@ -657,24 +660,53 @@ describe('isForeignPrefixedPhaseQuery', () => {
// ─── roadmapPhaseLookupSources (#2121, owned here after the move) ─────────────
describe('roadmapPhaseLookupSources', () => {
const PREFIX_TOLERANT = `${phaseId.OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}0*29`;
test('a bare numeric query yields the numeric then prefix-tolerant sources', () => {
const sources = phaseId.roadmapPhaseLookupSources('29');
assert.deepEqual(sources, ['0*29', PREFIX_TOLERANT]);
assert.equal(sources.length, 2);
// Source text is RegExp.escape's business (#3412) — pin matching
// behavior instead: source[0] matches the bare and zero-padded heading
// but not a project-code-prefixed one; source[1] additionally tolerates
// the prefix.
const re0 = headingRegex(sources[0]);
const re1 = headingRegex(sources[1]);
assert.ok(re0.test('Phase 29: Title'));
assert.ok(re0.test('Phase 029: Title'));
assert.ok(!re0.test('Phase CK-29: Title'));
assert.ok(re1.test('Phase CK-29: Title'));
assert.ok(re1.test('Phase 29: Title'));
});
test('the bare numeric source precedes the prefix-tolerant fallback', () => {
const sources = phaseId.roadmapPhaseLookupSources('29');
assert.ok(sources.indexOf('0*29') < sources.indexOf(PREFIX_TOLERANT));
// Behavioral ordering (#3412): the source that rejects a project-code
// prefix must appear before the source that accepts one.
const bareIdx = sources.findIndex((s) => !headingRegex(s).test('Phase CK-29: Title'));
const prefixTolerantIdx = sources.findIndex((s) => headingRegex(s).test('Phase CK-29: Title'));
assert.notEqual(bareIdx, -1);
assert.notEqual(prefixTolerantIdx, -1);
assert.ok(bareIdx < prefixTolerantIdx);
});
test('a project-code-prefixed query adds the exact source first (3 sources)', () => {
const sources = phaseId.roadmapPhaseLookupSources('AB-29');
assert.equal(sources.length, 3);
assert.equal(sources[0], 'AB-29');
assert.ok(sources.includes('0*29'));
assert.ok(sources.includes(PREFIX_TOLERANT));
// source[0] is the EXACT source (#3599): matches its own prefixed
// heading and must NOT match the bare numeric heading — that ordering
// is the whole point of #3599. Source text itself is RegExp.escape's
// business (#3412).
const reExact = headingRegex(sources[0]);
assert.ok(reExact.test('Phase AB-29: Title'));
assert.ok(!reExact.test('Phase 29: Title'));
// #3599 regression: the exact source is tied to the FULL prefix, not just
// the trailing number — a foreign prefix with the same number must not match.
assert.ok(!reExact.test('Phase CK-29: Title'));
// Case-insensitivity (the 'i' flag) — canonicalizes the same way a
// literal would, despite the hex escape (#3412).
assert.ok(reExact.test('phase ab-29: title'));
// The remaining two sources behave as the numeric/prefix-tolerant pair.
const rest = sources.slice(1).map(headingRegex);
assert.ok(rest.some((re) => re.test('Phase 29: Title') && !re.test('Phase CK-29: Title')));
assert.ok(rest.some((re) => re.test('Phase CK-29: Title')));
});
test('zero-padding is tolerated: 029 resolves the same sources as 29', () => {

View File

@@ -8558,6 +8558,7 @@ const { cleanup, runGsdTools } = require('./helpers.cjs');
// phase-command-router.cjs delegates to SDK when available; we must test the
// CJS implementation directly since that is where the bug lives.
const phaseModule = require('../gsd-core/bin/lib/phase.cjs');
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
const { cmdPhaseComplete } = phaseModule;
function writePassedVerificationFile(phaseDir, phase = '01') {
@@ -8698,7 +8699,7 @@ function roadmapCompletionSnapshot(roadmapContent) {
}
function extractField(stateContent, fieldName) {
const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escaped = escapeRegex(fieldName);
const boldMatch = stateContent.match(new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i'));
if (boldMatch) return boldMatch[1].trim();
const plainMatch = stateContent.match(new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im'));

View File

@@ -21,15 +21,12 @@ const CORE_SUBSTRATE_TERMS = [
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
const { isCentralConfigKey } = require('../gsd-core/bin/lib/config-schema.cjs');
const { escapeRegex: escapeRegExp } = require('../gsd-core/bin/lib/pattern.cjs');
function readRepoFile(relativePath) {
return fs.readFileSync(path.join(ROOT, relativePath), 'utf8');
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function activeWhenKeys() {
const keys = new Set();
for (const cap of Object.values(registry.capabilities)) {

View File

@@ -15,6 +15,7 @@ const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
const ROOT = path.join(__dirname, '..');
@@ -48,7 +49,7 @@ function findProductParentheticals(content) {
for (const product of PRODUCTS) {
// Match "ProductName (something)" but not "ProductName (v1.2.3)" (version refs are ok)
const pattern = new RegExp(
product.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
escapeRegex(product) +
'\\s*\\([^)]*(?!v?\\d+\\.\\d)[^)]*\\)',
'g'
);

View File

@@ -23,6 +23,7 @@ const { referencesBasename, referencesNpmLockfileDependency, findSurvivingRefere
const { cleanup } = require('./helpers.cjs');
const { runNode } = require('./helpers/process-seam.cjs');
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
const { copyScriptWithDeps } = require('./helpers/copy-script-fixture.cjs');
describe('removed-but-needed lint: referencesBasename (pure)', () => {
test('a plain filename reference in prose is found', () => {
@@ -158,13 +159,7 @@ function buildTempRepo(tmpDir, baseFiles, prFiles) {
* @returns {string} path to the copied script inside tmpDir/scripts/
*/
function copyScriptInto(tmpDir) {
const scriptsDir = path.join(tmpDir, 'scripts');
const libDir = path.join(scriptsDir, 'lib');
fs.mkdirSync(libDir, { recursive: true });
const scriptCopy = path.join(scriptsDir, 'lint-removed-but-needed.cjs');
fs.copyFileSync(LINT_SCRIPT, scriptCopy);
fs.copyFileSync(path.join(ROOT, 'scripts', 'lib', 'cli-exit.cjs'), path.join(libDir, 'cli-exit.cjs'));
return scriptCopy;
return copyScriptWithDeps(ROOT, tmpDir, path.relative(ROOT, LINT_SCRIPT));
}
describe('removed-but-needed lint: main() end-to-end wiring', () => {

View File

@@ -13,6 +13,7 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
const ROOT = path.resolve(__dirname, '..');
const PLANNER = path.join(ROOT, 'agents', 'gsd-planner.md');
@@ -47,7 +48,7 @@ function namesRating(text, rating) {
// js/incomplete-sanitization (CodeQL, high). `-` needs no escaping outside a
// character class, so the previous `-`-only replace was both incomplete and
// unnecessary.
const escaped = rating.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escaped = escapeRegex(rating);
return new RegExp(`\\b${escaped}\\b`).test(text);
}

View File

@@ -32,6 +32,7 @@ const os = require('node:os');
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
const { cleanup } = require('./helpers.cjs');
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
const AGENTS_DIR = path.join(__dirname, '..', 'agents');
@@ -87,7 +88,7 @@ function extractShellBlocks(content) {
blockLang = (fenceOpen[2] || '').toLowerCase();
blockLines = [];
// Closing pattern: same indent prefix + ```
closingPattern = new RegExp('^' + blockIndent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '```\\s*$');
closingPattern = new RegExp('^' + escapeRegex(blockIndent) + '```\\s*$');
continue;
}
} else {
@@ -1027,6 +1028,7 @@ const os = require('node:os');
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
const { cleanup } = require('./helpers.cjs');
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
const SNIPPET_FILE = path.join(WORKFLOWS_DIR, '_runtime-launcher.snippet.sh');
@@ -1104,7 +1106,7 @@ function extractShellBlocks(content) {
blockIndent = fenceOpen[1];
blockLang = (fenceOpen[2] || '').toLowerCase();
blockLines = [];
closingPattern = new RegExp('^' + blockIndent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '```\\s*$');
closingPattern = new RegExp('^' + escapeRegex(blockIndent) + '```\\s*$');
continue;
}
} else {

View File

@@ -8,6 +8,7 @@ const {
isValidActiveWorkstreamName,
INVALID_ACTIVE_WORKSTREAM_NAME_MESSAGE,
} = require('../gsd-core/bin/lib/workstream-name-policy.cjs');
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
describe('workstream-name-policy', () => {
test('normalizeWorkstreamNameInput trims and nulls empty input', () => {
@@ -39,7 +40,7 @@ describe('workstream-name-policy', () => {
assert.equal(assertValidActiveWorkstreamName(' alpha '), 'alpha');
assert.throws(
() => assertValidActiveWorkstreamName('alpha/beta'),
new RegExp(INVALID_ACTIVE_WORKSTREAM_NAME_MESSAGE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
new RegExp(escapeRegex(INVALID_ACTIVE_WORKSTREAM_NAME_MESSAGE))
);
});

View File

@@ -23,6 +23,7 @@ const fc = require('fast-check');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { createFixture } = require('./fixtures/index.cjs');
const { makeFaultyGit } = require('./helpers/faulty-deps.cjs');
const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs');
// 30000ms: this file's single named bound for every migrated subprocess call
// below (git plumbing on small mkdtemp fixtures, gsd-tools.cjs/hook CLI runs,
@@ -974,7 +975,7 @@ describe('planWorktreeRecordAgent', () => {
});
assert.equal(plan.reason, 'missing_field');
for (const flag of ['--agent-id', '--path', '--branch', '--base']) {
assert.match(plan.hint, new RegExp(flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
assert.match(plan.hint, new RegExp(escapeRegex(flag)));
}
});