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>
This commit is contained in:
Mikael Hugo 2026-05-17 00:46:10 +02:00
parent 56e8ec6c53
commit e8bbb477e6

View file

@ -185,6 +185,55 @@ const JS_KEYWORDS = new 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
@ -289,6 +338,104 @@ function parseImports(source) {
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
@ -338,14 +485,16 @@ function collectReferences(source) {
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 (same filtering as before)
// 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;