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>
661 lines
16 KiB
JavaScript
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);
|