Files
msd-core/scripts/lint-no-adhoc-regex-escape.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

237 lines
11 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* lint-no-adhoc-regex-escape.cjs — the whole-tree parity backstop for
* `local/no-adhoc-regex-escape` (ADR-3212 §7 / #3412 Phase 1; test-matrix
* row 30: "a 13th copy of the escape body anywhere in the tree" must fail
* CI, not review).
*
* ## Why this exists alongside the ESLint rule
*
* `eslint.config.mjs` covers `src/**\/*.cts`, `scripts/**\/*.cjs`,
* `tests/**\/*.cjs`, `hooks/**\/*.js`+`*.cjs`, and a short explicit list of
* top-level `.js` files — but NOT every extension in every directory. A
* whole-tree file-inventory check found `tests/fixtures/brand-typing/*.cts`
* (6 files): `.cts` under `tests/` matches neither the `src/**\/*.cts` glob
* (wrong directory) nor the `tests/**\/*.cjs` glob (wrong extension), so the
* AST rule never sees them. A hand-rolled escape helper planted in a
* directory/extension combination outside every `eslint.config.mjs` glob
* would pass `npx eslint .` cleanly. This script closes that gap with a
* plain-text, whole-repo-tree scan (mirrors scripts/lint-removed-but-needed.cjs's
* SCAN_ROOTS + walk() + ExitError structure) instead of another eslint.config.mjs
* glob entry, so growing the covered surface never depends on remembering to
* add a new files: [...] block for the next unanticipated extension.
*
* ## What this checks
*
* Walks the ENTIRE repository tree (skipping node_modules/.git/dist/coverage/
* .worktrees/.claude, mirroring eslint.config.mjs's global ignores), and for
* every source-like file (.cjs/.js/.mjs/.cts/.ts), text-scans for a
* `<expr>.replace(<regex-literal>, <string-literal>)` call whose regex
* literal's character class matches the escape-all-metachars member set
* (reusing `eslint-rules/no-adhoc-regex-escape.cjs`'s
* `isEscapeAllMetacharsClassSource` — single source of truth for the shape,
* per CLAUDE.md's Generative Fix Divergence guidance) and whose replacement
* string is `'\$&'`. `src/pattern.cts` (the seam) and any line carrying a
* trailing/preceding `// allow-adhoc-regex-escape: <reason>` comment are
* exempt, mirroring the ESLint rule's own suppression convention.
*
* A plain regex/text scan (not a full parser) is a deliberate, disclosed
* trade-off: it only needs to close the residual gap outside the AST rule's
* glob coverage, not replace it — the AST rule remains the primary,
* precision enforcement for everything it does cover.
*/
const fs = require('node:fs');
const path = require('node:path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const { isEscapeAllMetacharsClassSource, SELF_REFERENCE_REPLACEMENT } = require('../eslint-rules/no-adhoc-regex-escape.cjs');
const ROOT = path.join(__dirname, '..');
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage', '.worktrees', '.claude']);
const SOURCE_EXT = new Set(['.cjs', '.js', '.mjs', '.cts', '.ts']);
// The seam owns this shape — not a violation of itself.
const SEAM_FILE_RE = /(?:^|\/)src\/pattern\.cts$/;
// NOTE: bin/install.js was considered for a blanket "generated bundle,
// downstream mirror" exemption (the same reasoning that excuses
// msd-core/bin/lib/**/*.cjs below) but REJECTED after inspection —
// bin/install.js:1974 carries a genuine, hand-written `function
// escapeRegExp(value) { return value.replace(...); }` of its own (real
// executable code, not an embedded string copy of another file's content).
// It is therefore a real 13th-plus copy and deliberately left un-exempted;
// see this script's own findings for it.
const GENERATED_BUNDLE_FILES = new Set();
// The RuleTester fixture corpus for the ESLint rule itself. Row 24/25's
// `code:` fixtures deliberately contain the flagged shape as STRING DATA
// (inside String.raw / template literals) to prove the rule fires — this is
// not runnable production code, and the AST-based ESLint rule correctly does
// not fire on it either (it never becomes a real `.replace()` CallExpression
// in this file's own AST). This text-only scanner cannot make that
// distinction, so the file is exempted explicitly. Per the dispatch brief,
// this test file is a fixed contract and is not edited to route around this.
const RULE_FIXTURE_FILES = new Set(['tests/eslint-no-adhoc-regex-escape.test.cjs']);
// Two suppression conventions are honored, matching what actually appears in
// this repo: the rule's own per-finding `// allow-adhoc-regex-escape: <reason>`
// marker (mirrors no-adhoc-markdown-parsing's convention), and a native
// `// eslint-disable-next-line local/no-adhoc-regex-escape -- <reason>`
// directive (what tests/pattern.test.cjs's historical-oracle exemptions use —
// real ESLint already honors this natively; this text-only scanner has to
// recognize it explicitly since it does not run through ESLint's engine).
const ALLOW_COMMENT_RE = /allow-adhoc-regex-escape:\s*\S/;
const ESLINT_DISABLE_RE = /eslint-disable(?:-next-line|-line)?\b[^\n]*\bno-adhoc-regex-escape\b/;
function isSuppressedByComment(line) {
return ALLOW_COMMENT_RE.test(line) || ESLINT_DISABLE_RE.test(line);
}
/**
* Is `rel` (repo-relative, POSIX-slash) a tsc-generated `.cjs` mirror of a
* `src/**\/*.cts` source (ADR-457)? Dynamic (checks the filesystem for the
* sibling `.cts`) rather than a hardcoded list, so it tracks whichever `.cts`
* modules exist without needing its own upkeep as the seam migration lands.
* @param {string} rel
* @param {string} root
*/
function isGeneratedLibMirror(rel, root) {
if (!rel.startsWith('msd-core/bin/lib/') || !rel.endsWith('.cjs')) return false;
const candidateSrc = `src/${rel.slice('msd-core/bin/lib/'.length, -'.cjs'.length)}.cts`;
return fs.existsSync(path.join(root, candidateSrc));
}
// A permissive, single-pass extraction of `.replace(<regex-literal>,
// <string-literal>)` call text. Deliberately simple (no nested-nesting /
// multi-line-argument support) — this is a coverage backstop for the AST
// rule, not a replacement for it; every real census copy is a single-line
// `.replace(/[...]/g, '\$&')` call.
//
// ReDoS fix (#3412, CodeQL js/redos): the original outer alternation let a
// `[...]` run be consumed EITHER by the character-class branch OR one
// character at a time by the trailing catch-all branch, so on a failing
// match the engine explored both parses of every bracket pair — exponential
// (measured: n=26 -> 204ms, n=28 -> 791ms, n=30 -> 3475ms, ~2^n). Two
// independent fixes, both required, neither alone sufficient long-term:
// (a) the trailing branch is now `[^/\\\n[\]]` — it excludes `[` and `]`,
// so a bracket can only ever be consumed by the character-class
// branch. This removes the ambiguity and makes matching linear.
// Consequence: a regex literal containing a BARE unescaped `]`
// outside a character class is no longer matched by this backstop —
// acceptable, since this is a coverage backstop for the AST rule, not
// a replacement for it, and no census shape has that form.
// (b) every `*` is now a bounded quantifier (ADR-3212's locked
// bounded-quantifiers decision) — a second line of defense that caps
// worst-case work even if the grammar above is later loosened.
const REPLACE_CALL_RE = /\.replace\(\s{0,20}\/((?:\\.|\[(?:\\.|[^\]\\]){0,200}\]|[^/\\\n[\]]){1,400})\/([a-z]{0,10})\s{0,20},\s{0,20}(['"`])((?:\\.|(?!\3)[^\\]){0,400})\3\s{0,20}\)/g;
function unescapeSimpleStringLiteral(raw) {
// Handles the two-char escapes this specific replacement string ever uses
// (\\ and \$); good enough for the '\$&' shape this script looks for.
return raw.replace(/\\(.)/g, '$1');
}
function walk(dir) {
const out = [];
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
}
catch {
return out;
}
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(full));
else if (entry.isFile() && SOURCE_EXT.has(path.extname(entry.name))) out.push(full);
}
return out;
}
/**
* Pure: does `content` (the text of a single file) contain the
* escape-all-metachars `.replace(<class>, '\$&')` shape on a line that is
* NOT exempted by an `allow-adhoc-regex-escape:` comment?
* @param {string} content
* @returns {{ line: number }[]}
*/
function findViolations(content) {
const violations = [];
const lines = content.split('\n');
let match;
REPLACE_CALL_RE.lastIndex = 0;
while ((match = REPLACE_CALL_RE.exec(content)) !== null) {
const [, classSource, , , replacementRaw] = match;
if (!isEscapeAllMetacharsClassSource(`[${classSource}]`)) continue;
if (unescapeSimpleStringLiteral(replacementRaw) !== SELF_REFERENCE_REPLACEMENT) continue;
const upToMatch = content.slice(0, match.index);
const line = upToMatch.split('\n').length;
const sameLine = lines[line - 1] || '';
const prevLine = lines[line - 2] || '';
if (isSuppressedByComment(sameLine) || isSuppressedByComment(prevLine)) continue;
violations.push({ line });
}
return violations;
}
/**
* Pure: scan the whole tree rooted at `root` and return every violation.
* @param {string} root
* @returns {{ file: string, line: number }[]}
*/
function scan(root) {
const violations = [];
for (const abs of walk(root)) {
const rel = path.relative(root, abs).replace(/\\/g, '/');
if (SEAM_FILE_RE.test(rel)) continue;
if (GENERATED_BUNDLE_FILES.has(rel)) continue;
if (RULE_FIXTURE_FILES.has(rel)) continue;
if (isGeneratedLibMirror(rel, root)) continue;
let content;
try {
content = fs.readFileSync(abs, 'utf8');
}
catch {
continue; // unreadable (broken symlink, binary) — skip
}
for (const v of findViolations(content)) {
violations.push({ file: rel, line: v.line });
}
}
return violations;
}
function main() {
const violations = scan(ROOT);
if (violations.length > 0) {
const detail = violations.map((v) => ` ${v.file}:${v.line}`).join('\n');
throw new ExitError(
1,
'lint-no-adhoc-regex-escape: a hand-rolled regex-metacharacter-escape\n'
+ '.replace(<escape-all-metachars class>, \'\\$&\') copy was found outside\n'
+ 'src/pattern.cts (ADR-3212 §7 / #3412, test-matrix row 30 — parity\n'
+ 'assertion). Import escapeRegex() from src/pattern.cts instead, or\n'
+ 'suppress a genuine non-production exception with a trailing\n'
+ '// allow-adhoc-regex-escape: <reason> comment:\n'
+ detail,
);
}
console.log('ok lint-no-adhoc-regex-escape: no hand-rolled escape-metachars copy found outside src/pattern.cts');
}
module.exports = {
findViolations,
scan,
walk,
SOURCE_EXT,
SKIP_DIRS,
};
if (require.main === module) runMain(main);