From e8bbb477e6de83d89e3f6c0cd340196733888a28 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sun, 17 May 2026 00:46:10 +0200 Subject: [PATCH] fix(scripts/check-test-imports): filter TS keywords + local declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/check-test-imports.mjs | 151 ++++++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 1 deletion(-) diff --git a/scripts/check-test-imports.mjs b/scripts/check-test-imports.mjs index c8ea932c9..138d4ff77 100644 --- a/scripts/check-test-imports.mjs +++ b/scripts/check-test-imports.mjs @@ -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;