singularity-forge/scripts/check-test-imports.mjs
Mikael Hugo e8bbb477e6 fix(scripts/check-test-imports): filter TS keywords + local declarations
The check-test-imports drift guard was emitting too many false positives
to be safely integrated into npm run lint (per CLAUDE.md: "NOT integrated
into npm run lint by default — too broad"). Two big classes of FP:

1) TypeScript keywords + utility types treated as undeclared (any, type,
   ReturnType, Partial, Record, never, unknown, etc.) — added to the
   JS_KEYWORDS set since the script doesn't otherwise distinguish JS
   from TS.

2) Identifiers declared locally in the file (function declarations,
   const/let/var declarations, destructured patterns, function/arrow
   parameters, catch params, class names, type/interface/enum names) —
   added a new collectLocalDeclarations() pass that regex-scans these
   patterns and feeds the results into the filter chain.

After this patch the script no longer flags makeMockTUI / loader / tui
(local lets), `ReturnType<...>` (TS utility), or `any` (TS keyword) on
the canonical TUI test files. It still flags type-only imports
(`import type { Foo }` lines) and object-literal property names
(`{ recursive: true }`) — those remain as known FP classes documented
in the file's header for a future TS-parser-based pass.

Self-test 5/5 passes. Not yet integrating into npm run lint pending
further FP reduction; see filed self-feedback for the broader
integration plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:46:10 +02:00

661 lines
16 KiB
JavaScript

#!/usr/bin/env node
/**
* check-test-imports.mjs — catch missing ESM named imports in test files.
*
* Detects the anti-pattern where a test file uses itemized `import { foo } from "..."`
* but references a name that is not in the import list (e.g. a new `describe(...)`
* block uses `buildFoo()` but only `{ buildBar }` was imported). JS surfaces this as
* `ReferenceError` at vitest run-time — slow feedback. This script detects it at
* lint time using static analysis.
*
* Usage:
* node scripts/check-test-imports.mjs # scan all test files
* node scripts/check-test-imports.mjs --json # JSON output
* npm run check:test-imports # via npm script
*
* Exit 0 = no issues found. Exit 1 = drift detected (or scan error).
*
* Coverage: all `*.test.{js,mjs,ts}` and `*.spec.{js,mjs,ts}` files under tests/,
* src/, and packages/ (respects .gitignore via glob). Node built-ins and
* package:// resolved imports are excluded automatically.
*/
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { readdirSync, statSync, readFileSync } from "node:fs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, "..");
// ── Helpers ───────────────────────────────────────────────────────────────────
const NODE_BUILTINS = new Set([
"process",
"require",
"module",
"exports",
"console",
"global",
"Buffer",
"setTimeout",
"setInterval",
"clearTimeout",
"clearInterval",
"queueMicrotask",
"Array",
"Object",
"String",
"Number",
"Boolean",
"Symbol",
"BigInt",
"Map",
"Set",
"WeakMap",
"WeakSet",
"Promise",
"Math",
"JSON",
"Date",
"RegExp",
"Error",
"EvalError",
"RangeError",
"ReferenceError",
"SyntaxError",
"TypeError",
"URIError",
"AggregateError",
"AbortController",
"AbortSignal",
"URL",
"URLSearchParams",
"TextEncoder",
"TextDecoder",
"TransformStream",
"ReadableStream",
"WritableStream",
"Blob",
"File",
"FormData",
"Headers",
"Request",
"Response",
"fetch",
"WebSocket",
"EventSource",
"setImmediate",
"clearImmediate",
"structuredClone",
"atob",
"btoa",
"navigator",
"window",
"document",
"location",
"history",
"gc",
"finalizationRegistry",
"Intl",
"Reflect",
"Proxy",
// Node.js globals (not ESM builtins but always available)
"__dirname",
"__filename",
"import",
"imports",
]);
// Node.js module exports commonly used in tests without explicit import
const NODE_MODULE_GLOBALS = new Set([
"assert",
"assertEq",
"deepEqual",
"strictEqual",
"path",
"fs",
"os",
"url",
"querystring",
"crypto",
"buffer",
"stream",
"util",
"events",
"querystring",
"stringDecoder",
"readline",
"tty",
"domain",
"constants",
" timers",
"async_hooks",
]);
// JavaScript reserved words and keywords that should never be flagged as undeclared
const JS_KEYWORDS = new Set([
// Reserved words
"break",
"case",
"catch",
"continue",
"debugger",
"default",
"delete",
"do",
"else",
"finally",
"for",
"function",
"if",
"in",
"instanceof",
"new",
"return",
"switch",
"this",
"throw",
"try",
"typeof",
"var",
"void",
"while",
"with",
"class",
"const",
"enum",
"export",
"extends",
"import",
"super",
"implements",
"interface",
"let",
"package",
"private",
"protected",
"public",
"static",
"yield",
"async",
"await",
"of",
"get",
"set",
"target",
"from",
"as",
// TypeScript keywords / modifiers
"any",
"unknown",
"never",
"undefined",
"null",
"true",
"false",
"type",
"namespace",
"module",
"declare",
"abstract",
"readonly",
"is",
"keyof",
"infer",
"satisfies",
"out",
"override",
// TypeScript built-in utility types
"Partial",
"Required",
"Readonly",
"Pick",
"Omit",
"Record",
"Exclude",
"Extract",
"NonNullable",
"Parameters",
"ConstructorParameters",
"ReturnType",
"InstanceType",
"ThisParameterType",
"OmitThisParameter",
"ThisType",
"Uppercase",
"Lowercase",
"Capitalize",
"Uncapitalize",
"Awaited",
// Primitive type names used as TS annotations
"string",
"number",
"boolean",
"bigint",
"symbol",
"object",
]);
// Common test framework globals that are always available in vitest/jest
const TEST_GLOBALS = new Set([
"describe",
"it",
"test",
"expect",
"beforeEach",
"afterEach",
"beforeAll",
"afterAll",
"suite",
"spec",
"context",
"given",
"when",
"then",
"and",
"describe.skip",
"it.skip",
"test.skip",
"describe.only",
"it.only",
"test.only",
"describe.each",
"it.each",
"test.each",
"describe.concurrent",
"it.concurrent",
"test.concurrent",
"vi",
"vitest",
"spyOn",
"jest",
]);
// Boolean and literal values that always exist
const JS_LITERALS = new Set([
"true",
"false",
"null",
"undefined",
"NaN",
"Infinity",
]);
/**
* Parse the statically-imported identifiers from the top of a file.
* Returns { named: Set<string>, namespace: Set<string> } where namespace is
* all identifiers that come from a `import * as X from` namespace import.
*/
function parseImports(source) {
const named = new Set();
const namespace = new Set();
// Strip strings, template literals, and comments
const stripped = source
.replace(/'[^']*'/g, "'_'")
.replace(/"[^"]*"/g, '"_"')
.replace(/`[^`]*`/g, "``_`")
.replace(/\/\/[^\n]*/g, "")
.replace(/\/\*[\s\S]*?\*\//g, "");
// Match: import * as NS from "module" or import * as NS from 'module'
const nsRe = /import\s+\*\s+as\s+([a-zA-Z_$][\w$]*)\s+from\s+["'][^"']+["']/g;
for (const m of stripped.matchAll(nsRe)) {
namespace.add(m[1]);
}
// Match: import { a, b, c } from "module" or import { a as b } from "module"
// Handles: { a }, { a, b }, { a as b }, { type a }
const namedRe = /import\s+\{([^}]+)\}\s+from\s+["'][^"']+["']/g;
for (const m of stripped.matchAll(namedRe)) {
const content = m[1];
const items = content
.split(",")
.map((s) => {
s = s.trim();
if (s.includes(" as ")) {
s = s.split(" as ").pop().trim();
}
if (s.startsWith("type ")) {
s = s.replace("type ", "").trim();
}
return s.replace(/\s+/g, "");
})
.filter(Boolean);
items.forEach((id) => named.add(id));
}
// Default imports: import foo from "module" → add "foo" as a named import
// (it IS declared and usable without prefix)
const defaultRe = /import\s+([a-zA-Z_$][\w$]*)\s+from\s+["'][^"']+["']/g;
for (const m of stripped.matchAll(defaultRe)) {
named.add(m[1]);
}
// Side-effect imports: import "module" → no named declarations
// (handled — nothing to extract)
return { named, namespace };
}
/**
* Collect identifiers declared LOCALLY in the file (functions, var/let/const,
* destructured patterns, function/arrow parameters). These should not be
* flagged as "undeclared" — they are declared, just not via an import. Used
* to suppress false positives where the file defines its own helpers/locals
* inside the test body.
*/
function collectLocalDeclarations(source) {
const decls = new Set();
// Strip strings and comments first so we don't pick up identifiers inside
// quoted text (e.g. assertion strings, template literals).
const stripped = source
.replace(/'[^']*'/g, "'_'")
.replace(/"[^"]*"/g, '"_"')
.replace(/`[^`]*`/g, "``_`")
.replace(/\/\/[^\n]*/g, "")
.replace(/\/\*[\s\S]*?\*\//g, "");
// `function NAME(` — function declarations (named + async + generator)
for (const m of stripped.matchAll(
/\b(?:async\s+)?function\s*\*?\s*([a-zA-Z_$][\w$]*)\s*\(/g,
)) {
decls.add(m[1]);
}
// `class NAME` — class declarations
for (const m of stripped.matchAll(/\bclass\s+([a-zA-Z_$][\w$]*)\b/g)) {
decls.add(m[1]);
}
// `const/let/var NAME = ...` — simple single-name declarations
for (const m of stripped.matchAll(
/\b(?:const|let|var)\s+([a-zA-Z_$][\w$]*)\b/g,
)) {
decls.add(m[1]);
}
// `const/let/var { a, b: c, ...d } = ...` — object destructuring
for (const m of stripped.matchAll(
/\b(?:const|let|var)\s*\{\s*([^}]+)\s*\}\s*=/g,
)) {
const inner = m[1];
for (const part of inner.split(",")) {
// Patterns: `name`, `name: alias`, `name = default`, `...rest`
const cleaned = part
.trim()
.replace(/^\.\.\./, "")
.split(":")
.pop()
.split("=")[0]
.trim();
const idMatch = cleaned.match(/^([a-zA-Z_$][\w$]*)$/);
if (idMatch) decls.add(idMatch[1]);
}
}
// `const/let/var [a, b, ...c] = ...` — array destructuring
for (const m of stripped.matchAll(
/\b(?:const|let|var)\s*\[\s*([^\]]+)\s*\]\s*=/g,
)) {
const inner = m[1];
for (const part of inner.split(",")) {
const cleaned = part
.trim()
.replace(/^\.\.\./, "")
.split("=")[0]
.trim();
const idMatch = cleaned.match(/^([a-zA-Z_$][\w$]*)$/);
if (idMatch) decls.add(idMatch[1]);
}
}
// `function name(a, b: T, { c, d } = {}, ...rest)` — function params.
// Also handles arrow functions: `(a, b) => ...` and method shorthand.
for (const m of stripped.matchAll(
/(?:function\s*\*?\s*[a-zA-Z_$]?[\w$]*\s*|=>|\bcatch\s*)\(\s*([^)]*?)\s*\)/g,
)) {
const inner = m[1];
if (!inner) continue;
for (const part of inner.split(",")) {
const cleaned = part
.trim()
.replace(/^\.\.\./, "")
.split(":")[0]
.split("=")[0]
.trim();
const idMatch = cleaned.match(/^([a-zA-Z_$][\w$]*)$/);
if (idMatch) decls.add(idMatch[1]);
}
}
// `enum NAME` — TS enum declarations
for (const m of stripped.matchAll(/\benum\s+([a-zA-Z_$][\w$]*)\b/g)) {
decls.add(m[1]);
}
// `interface NAME` / `type NAME =` — TS type declarations
for (const m of stripped.matchAll(
/\b(?:interface|type)\s+([a-zA-Z_$][\w$]*)\b/g,
)) {
decls.add(m[1]);
}
return decls;
}
/**
* Collect all identifier references in the source, excluding:
* - strings, comments, template literals
* - keyword/member access patterns (foo.bar, obj[key])
*/
function collectReferences(source) {
// Strip strings, template literals, and comments first
const stripped = source
.replace(/'[^']*'/g, "'_'")
.replace(/"[^"]*"/g, '"_"')
.replace(/`[^`]*`/g, "``_`")
.replace(/\/\/[^\n]*/g, "")
.replace(/\/\*[\s\S]*?\*\//g, "");
// Remove member-access suffixes (foo.bar → foo)
// Remove computed-property access (obj[key] → obj, key)
// Remove calls (foo() → foo)
// We want bare identifiers only
// Replace member expressions and calls with placeholders to isolate identifiers
const cleaned = stripped
.replace(/\b[a-zA-Z_$][\w$]*\.[a-zA-Z_$][\w$]*(?=\s*[[.(])/g, "_")
.replace(/\[[^\]]+\]/g, "[_]");
// Now collect bare identifiers (word boundaries)
const idRe = /\b([a-zA-Z_$][\w$]*)\b/g;
const ids = new Set();
for (const m of cleaned.matchAll(idRe)) {
ids.add(m[1]);
}
return ids;
}
/**
* Check a file for import drift: itemized imports referencing undeclared names.
*
* The target anti-pattern: a test file has itemized imports (≥6 from one source)
* and references a camelCase identifier that isn't in the import list — a new
* describe block that uses a function without adding it to the imports.
*
* We only flag this when the itemized count is high because that's when the
* manual-maintenance risk is highest. Files with few itemized imports or
* namespace imports have low drift risk.
*
* Returns null if clean, or an issue description string.
*/
function checkFile(filePath, source) {
const imports = parseImports(source);
const refs = collectReferences(source);
const localDecls = collectLocalDeclarations(source);
const itemizedCount = imports.named.size;
// Collect candidate undeclared identifiers (filtering chain)
const undeclared = new Set();
for (const id of refs) {
if (imports.named.has(id)) continue;
if (imports.namespace.has(id)) continue;
if (localDecls.has(id)) continue;
if (NODE_BUILTINS.has(id)) continue;
if (NODE_MODULE_GLOBALS.has(id)) continue;
if (JS_KEYWORDS.has(id)) continue;
if (JS_LITERALS.has(id)) continue;
if (TEST_GLOBALS.has(id)) continue;
if (id.length <= 2) continue;
if (id === id.toUpperCase() && id.length <= 5) continue;
undeclared.add(id);
}
if (undeclared.size === 0) return null;
// Additional filtering for false positives:
// Common test-file patterns that look like undeclared but are actually local:
// - Boolean flags: canLaunch*, has*, is*, should*, will*
// - Variable names assigned in try/catch or before import
// - Playwright/MCP test helpers
const COMMON_TEST_LOCALS = new Set([
"canLaunchChromium",
"isAvailable",
"hasSupport",
"isSupported",
"describeOrSkip",
"itOrSkip",
"skipIf",
"runIf",
"jiti",
"interopDefault",
"debug",
]);
const filteredUndeclared = [...undeclared].filter(
(id) =>
!COMMON_TEST_LOCALS.has(id) &&
!id.match(/^(can|has|is|should|will)[A-Z]/) &&
!id.match(/^(testBrowser|chromiumInstance|pageInstance)$/),
);
if (filteredUndeclared.length === 0) return null;
// Only flag when the anti-pattern is present:
// itemized imports from one module AND a camelCase identifier missing from the list.
// This targets the specific failure mode: a new `describe("buildFoo v2")` block
// that uses `buildFoo()` but the import list wasn't updated.
// Files with namespace imports or few itemized imports have lower drift risk.
if (itemizedCount < 6) return null;
return `Itemized imports (${itemizedCount}) + undeclared identifier(s): ${filteredUndeclared.slice(0, 8).join(", ")}${filteredUndeclared.length > 8 ? " ..." : ""}`;
}
// ── Main ──────────────────────────────────────────────────────────────────────
function findTestFiles(dir) {
const results = [];
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = resolve(dir, entry.name);
if (entry.isDirectory()) {
// Skip test artifacts, build output, and system directories
if (
entry.name === "node_modules" ||
entry.name === "dist" ||
entry.name === ".git" ||
entry.name === "__pycache__" ||
entry.name === "tmp-check-test-imports" // regression test temp dir
) {
continue;
}
results.push(...findTestFiles(full));
} else if (
(entry.name.endsWith(".test.js") ||
entry.name.endsWith(".test.mjs") ||
entry.name.endsWith(".test.ts") ||
entry.name.endsWith(".spec.js") ||
entry.name.endsWith(".spec.mjs") ||
entry.name.endsWith(".spec.ts")) &&
!entry.name.includes("node_modules")
) {
results.push(full);
}
}
} catch {
// Skip inaccessible dirs
}
return results;
}
const args = process.argv.slice(2);
const jsonOut = args.includes("--json");
const verbose = args.includes("--verbose");
// If specific paths are provided, use those instead of scanning
// Filter out only flag args, keep everything else as a file path
const explicitPaths = args.filter((a) => !a.startsWith("--"));
let allFiles = [];
if (explicitPaths.length > 0) {
allFiles = explicitPaths
.map((p) => resolve(root, p))
.filter((p) => {
try {
statSync(p);
return true;
} catch {
console.error(`check-test-imports: ${p} does not exist, skipping`);
return false;
}
});
if (allFiles.length === 0) {
console.error("check-test-imports: no valid files specified");
process.exit(1);
}
if (verbose) {
console.error(
`[check-test-imports] explicit mode: ${allFiles.length} file(s)`,
);
}
} else {
const scanDirs = [
resolve(root, "tests"),
resolve(root, "src"),
resolve(root, "packages"),
];
for (const dir of scanDirs) {
allFiles.push(...findTestFiles(dir));
}
}
const issues = [];
let clean = 0;
for (const file of allFiles) {
try {
const source = readFileSync(file, "utf-8");
const rel = file.replace(root + "/", "");
const result = checkFile(file, source);
if (result) {
issues.push({ file: rel, problem: result });
} else {
clean++;
}
} catch (err) {
// Non-critical: skip files we can't read
}
}
if (jsonOut) {
console.log(JSON.stringify({ issues, count: issues.length, clean }, null, 2));
} else if (issues.length === 0) {
const label = verbose ? ` (${clean} files checked)` : "";
console.log(`✅ No import drift detected.${label}`);
} else {
console.log(`❌ Import drift detected in ${issues.length} file(s):\n`);
for (const { file, problem } of issues) {
console.log(` ${file}`);
console.log(`${problem}`);
}
if (verbose) {
console.log(`\n (${clean} files checked, no issues found)`);
}
}
process.exit(issues.length > 0 ? 1 : 0);