Files
msd-core/scripts/lint-source-test-name-collision.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

242 lines
10 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* lint-source-test-name-collision.cjs — no SOURCE file may carry a basename
* that matches Node's built-in test-file collection patterns.
*
* ## Why (the incident)
*
* `src/test-home-guard.cts` was a SOURCE module (a runtime guard, not a
* test) whose filename happened to match Node's `test-*` collection
* convention. `scripts/run-tests.cjs` — what local `npm test` and GitHub CI
* use — globs only `tests/**\/*.test.cjs`, so CI never saw it. But the
* REMOTE test runner (the push gate; see CLAUDE.md's `msd-test` section)
* collects test files the way `node --test` does by default, across the
* whole tree, so it picked the file up and executed it AS a test, where it
* exited 1. Net effect: `next` was green on GitHub CI and red on the push
* gate, blocking every push repo-wide. Proven by a control run on the
* unmodified `next` tip 622f43353: `37199 passed / 1 failed`,
* `throw · src/test-home-guard.cts`. The file has since been renamed to
* `src/real-home-guard.cts` in this branch; this lint exists so a source
* file can never silently re-acquire a collectable name again.
*
* ## What "collectable" means
*
* Node's test runner (`node --test`, and by extension the remote runner
* that dispatches this repo's push-gate suite) collects any file whose path
* matches, by default:
*
* **\/*.test.?(c|m)js **\/*-test.?(c|m)js **\/*_test.?(c|m)js
* **\/test-*.?(c|m)js **\/test.?(c|m)js **\/test/**
*
* Node also resolves `.ts`/`.cts`/`.mts` through the same collector once
* type-stripping is active (unflagged since Node 23.6; this repo's
* `engines.node` floor is >=24) — which is exactly how a `.cts` file ended
* up collected in the incident above. This lint therefore checks each of
* the six extensions `js`, `cjs`, `mjs`, `ts`, `cts`, `mts` against each
* basename-shaped pattern, plus a path-based check for any file living
* inside a directory literally named `test` (not `tests` — this repo's own
* test directory is deliberately outside the scanned source dirs, see
* below).
*
* ## Scanned (source/shipped) directories
*
* src/, scripts/, hooks/, bin/, msd-core/bin/ (excluding
* msd-core/bin/lib/**, see below), eslint-rules/
*
* ## Exempted
*
* - tests/ — files there are SUPPOSED to match; that is the point.
* - node_modules/, .git/ — never source we own.
* - msd-core/bin/lib/** — build output generated from src/*.cts by
* `npm run build:lib` (tsc), and gitignored (verified: every file under
* it, including the incident's own post-fix
* `msd-core/bin/lib/real-home-guard.cjs`, is listed in .gitignore). A
* generated file inherits its source's basename 1:1, so scanning it
* would double-report the exact same defect `src/` already caught —
* noise, not signal. `msd-core/bin/shared/*.json` is data, not code,
* but is harmlessly included since it never matches a JS/TS extension.
*
* Exported pure(ish) function `checkSourceTestNameCollisions({ dirs, root })`
* so tests can drive it against synthetic fixture directories; also runnable
* as a CLI against the real tree
* (`node scripts/lint-source-test-name-collision.cjs`).
*/
const fs = require('fs');
const path = require('path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
// The six extensions Node's test runner collects, per the incident: the
// documented `?(c|m)js` set (js, cjs, mjs) plus the TypeScript-loader
// equivalents (ts, cts, mts) that the same collector resolves once
// type-stripping is active (unflagged since Node 23.6; this repo's
// engines.node floor is >=24, per package.json).
const COLLECTED_EXTENSIONS = ['js', 'cjs', 'mjs', 'ts', 'cts', 'mts'];
const EXT_ALT = COLLECTED_EXTENSIONS.join('|');
// Basename-shaped patterns, translated 1:1 from Node's documented defaults:
// **/*.test.?(c|m)js **/*-test.?(c|m)js **/*_test.?(c|m)js
// **/test-*.?(c|m)js **/test.?(c|m)js
// (the sixth default, **/test/**, is a path-shaped check — see
// isUnderLiteralTestDir below, not a basename regex.)
const BASENAME_PATTERNS = [
{ name: '*.test.EXT', re: new RegExp(`\\.test\\.(?:${EXT_ALT})$`) },
{ name: '*-test.EXT', re: new RegExp(`-test\\.(?:${EXT_ALT})$`) },
{ name: '*_test.EXT', re: new RegExp(`_test\\.(?:${EXT_ALT})$`) },
{ name: 'test-*.EXT', re: new RegExp(`^test-.*\\.(?:${EXT_ALT})$`) },
{ name: 'test.EXT', re: new RegExp(`^test\\.(?:${EXT_ALT})$`) },
];
/**
* Source/shipped directories this guard checks, relative to repo root.
* Confirmed against the repo layout: src/, scripts/, hooks/, bin/,
* msd-core/bin/, eslint-rules/ all ship first-party source or shipped
* tooling; nothing else at the top level carries executable source outside
* tests/.
*/
const DEFAULT_SCAN_DIRS = ['src', 'scripts', 'hooks', 'bin', 'msd-core/bin', 'eslint-rules'];
// Directories to never descend into anywhere in the tree.
const ALWAYS_EXCLUDE_DIR_NAMES = new Set(['node_modules', '.git']);
// Relative dir prefixes (POSIX-joined, relative to repo root) that are
// generated build output and must not be scanned — see the module doc for
// why msd-core/bin/lib is excluded (it 1:1-inherits src/*.cts basenames, so
// scanning it double-reports the same defect src/ already catches).
const GENERATED_OUTPUT_PREFIXES = ['msd-core/bin/lib'];
function toPosix(p) {
return p.split(path.sep).join('/');
}
function isGeneratedOutput(relPath) {
const posixRel = toPosix(relPath);
return GENERATED_OUTPUT_PREFIXES.some(
(prefix) => posixRel === prefix || posixRel.startsWith(`${prefix}/`) // allow-handrolled-containment: scan-exclusion membership test against a fixed generated-output prefix list, not a filesystem root-confinement gate
);
}
/**
* Node's **\/test/** default: any file living inside a directory literally
* named `test` (singular) anywhere in its path. Deliberately does NOT match
* `tests/` (plural) — this repo's real test directory is a sibling of the
* scanned source dirs, not nested inside one, and is never itself scanned.
*/
function isUnderLiteralTestDir(relPath) {
return toPosix(relPath).split('/').slice(0, -1).includes('test');
}
function matchingBasenamePatterns(basename) {
return BASENAME_PATTERNS.filter((p) => p.re.test(basename)).map((p) => p.name);
}
function walk(dir, root, out) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (err) {
// A scan dir that cannot be read must never be silently treated as
// "zero files, zero violations" — that would be a green check that
// guarded nothing (same class of bug as an empty registry elsewhere in
// this repo's lints).
out.unreadable.push({ dir: path.relative(root, dir) || dir, error: err.message });
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
const rel = path.relative(root, full);
if (entry.isDirectory()) {
if (ALWAYS_EXCLUDE_DIR_NAMES.has(entry.name)) continue;
if (isGeneratedOutput(rel)) continue;
walk(full, root, out);
} else if (entry.isFile()) {
out.scanned.push(rel);
}
}
}
/**
* @param {{ dirs?: string[], root: string }} opts
* `dirs` — scan dirs relative to `root` (defaults to DEFAULT_SCAN_DIRS).
* `root` — the directory `dirs` are resolved against (repo root for the
* real CLI run; a synthetic fixture root in tests).
* @returns {{ ok: boolean, violations: Array<{file:string, patterns:string[], reason:string}>, scanned: string[] }}
*/
function checkSourceTestNameCollisions({ dirs = DEFAULT_SCAN_DIRS, root }) {
const violations = [];
const out = { scanned: [], unreadable: [] };
for (const dir of dirs) {
const full = path.join(root, dir);
if (!fs.existsSync(full)) continue; // a configured dir that doesn't exist is not this lint's problem
walk(full, root, out);
}
if (out.unreadable.length > 0) {
return {
ok: false,
violations: out.unreadable.map((u) => ({
file: u.dir,
patterns: [],
reason: `cannot read scan directory ${u.dir}: ${u.error} — a collision guard that cannot ` +
'read its own input must fail, never silently report zero violations',
})),
scanned: out.scanned,
};
}
for (const rel of out.scanned) {
const basename = path.basename(rel);
const patterns = matchingBasenamePatterns(basename);
const underTestDir = isUnderLiteralTestDir(rel);
if (patterns.length === 0 && !underTestDir) continue;
const matched = underTestDir ? [...patterns, '**/test/**'] : patterns;
violations.push({
file: toPosix(rel),
patterns: matched,
reason:
`basename matches Node's test-collection pattern(s) [${matched.join(', ')}] — the remote ` +
'test runner (the push gate) collects files by this convention and executes them AS ' +
'tests, while GitHub CI (scripts/run-tests.cjs) globs only tests/**/*.test.cjs and never ' +
'sees it; the failure then appears ONLY at the push gate (incident: src/test-home-guard.cts, ' +
'control run on next tip 622f43353: 37199 passed / 1 failed, throw · src/test-home-guard.cts). ' +
'Remedy: rename the file out of the pattern; do NOT add a runner-side exclusion.',
});
}
violations.sort((a, b) => a.file.localeCompare(b.file));
return { ok: violations.length === 0, violations, scanned: out.scanned };
}
module.exports = {
checkSourceTestNameCollisions,
COLLECTED_EXTENSIONS,
DEFAULT_SCAN_DIRS,
GENERATED_OUTPUT_PREFIXES,
isUnderLiteralTestDir,
matchingBasenamePatterns,
};
function main() {
const ROOT = path.join(__dirname, '..');
const result = checkSourceTestNameCollisions({ root: ROOT });
if (!result.ok) {
process.stderr.write(
`lint-source-test-name-collision: ${result.violations.length} violation(s) among ${result.scanned.length} scanned file(s)\n\n`
);
for (const v of result.violations) {
process.stderr.write(` ${v.file}\n ${v.reason}\n`);
}
throw new ExitError(1);
}
console.log(`ok lint-source-test-name-collision: ${result.scanned.length} file(s) scanned, 0 violations`);
}
if (require.main === module) runMain(main);