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;