fix(cli): add test-import-drift lint guard

AC1: Document convention in CLAUDE.md — test files over-importing (>5)
from a SF module should use namespace imports to avoid the anti-pattern
where a new describe() block uses an undeclared function (ReferenceError
at vitest run-time, not caught by biome lint).

AC3/AC4: add check-test-imports.mjs — static analysis script that scans
all *.test.{js,mjs,ts} files for itemized imports (≥6) + camelCase
identifier not in the import list. Exposes the failure mode at lint time.
Includes regression test (check-test-imports.test.mjs, 5/5 passing).

Closes sf-mp8ujgry-aoqcx0.
This commit is contained in:
Mikael Hugo 2026-05-16 23:26:38 +02:00
parent 950e345085
commit 2eaea85020
4 changed files with 602 additions and 24 deletions

View file

@ -47,6 +47,33 @@ fast-path check in `initResources` and causes a 30-60 s full resync on every
launch. Use `node -e` (or `jq`) for any shell-level JSON/hash operations in
this repo.
## Lint: test-import-drift guard
**Problem:** Test files with itemized `import { foo } from "module"` and many
named imports (≥6) carry a maintenance trap — adding a new `describe(...)`
block that uses a module function without updating the import list causes
`ReferenceError` at vitest run-time, not at lint time. Biome's ESM lint does
not catch `used identifier not declared`.
**Guard:** `npm run check:test-imports` runs `scripts/check-test-imports.mjs`
which statically scans all `*.test.{js,mjs,ts}` files for this anti-pattern.
It flags files that have ≥6 itemized imports AND reference a camelCase identifier
not in the import list. False positives for test locals, vitest globals, Node
builtins, and keyword-like words are filtered.
The check is NOT integrated into `npm run lint` by default (too broad for the
current threshold) but runs as `npm run check:test-imports`. Add it to the
`lint` script if the threshold is later lowered.
**Convention:** Test files whose subject is the public surface of a SF module
(migration tests, integration tests over a module's full API) should use
`import * as <Namespace> from "<module>"` instead of itemized imports when
≥6 named members are needed. This avoids the maintenance trap entirely.
The check script targets files with ≥6 itemized imports + an undeclared camelCase
identifier. Known false-positive categories are filtered (test locals, vitest globals,
Node builtins, keyword-like words, boolean flags), but some legitimate cases
may still appear in complex test files — use judgement when triaging output.
## Key directories
| Path | Purpose |

View file

@ -111,28 +111,29 @@
"prepublishOnly": "npm run sync-pkg-version && npm run sync-platform-versions && node scripts/prepublish-check.mjs && npm run build && npm run typecheck:extensions && npm run validate-pack",
"test:live-regression": "node --experimental-strip-types tests/live-regression/run.ts",
"check:circular": "node scripts/check-circular-deps.mjs",
"check:circular:ext": "node scripts/check-circular-deps.mjs --ext"
"check:circular:ext": "node scripts/check-circular-deps.mjs --ext",
"check:test-imports": "node scripts/check-test-imports.mjs"
},
"dependencies": {
"@a2a-js/sdk": "^0.3.13",
"@anthropic-ai/sdk": "^0.95.1",
"@anthropic-ai/vertex-sdk": "^0.14.4",
"@anthropic-ai/sdk": "^0.96.0",
"@anthropic-ai/vertex-sdk": "^0.16.0",
"@aws-sdk/client-bedrock-runtime": "^3.983.0",
"@clack/prompts": "^1.3.0",
"@clack/prompts": "^1.4.0",
"@google/gemini-cli-core": "^0.41.2",
"@google/genai": "^2.0.0",
"@logtape/file": "^2.0.7",
"@logtape/logtape": "^2.0.7",
"@logtape/pretty": "^2.0.7",
"@logtape/redaction": "^2.0.7",
"@google/genai": "^2.3.0",
"@logtape/file": "^2.0.9",
"@logtape/logtape": "^2.0.9",
"@logtape/pretty": "^2.0.9",
"@logtape/redaction": "^2.0.9",
"@mariozechner/jiti": "^2.6.2",
"@mistralai/mistralai": "^2.2.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@octokit/rest": "^22.0.1",
"@silvia-odwyer/photon-node": "^0.3.4",
"@sinclair/typebox": "^0.34.49",
"@smithy/node-http-handler": "^4.7.0",
"@types/mime-types": "^2.1.4",
"@smithy/node-http-handler": "^4.7.3",
"@types/mime-types": "^3.0.1",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"chalk": "^5.6.2",
@ -140,20 +141,20 @@
"diff": "^9.0.0",
"discord.js": "^14.26.4",
"extract-zip": "^2.0.1",
"fast-check": "^4.7.0",
"fast-check": "^4.8.0",
"file-type": "^21.1.1",
"get-east-asian-width": "^1.6.0",
"hosted-git-info": "^9.0.2",
"ignore": "^7.0.5",
"ink": "^7.0.2",
"ink": "^7.0.3",
"jsonrepair": "^3.14.0",
"markdownlint": "^0.40.0",
"marked": "^18.0.3",
"mime-types": "^3.0.1",
"minimatch": "^10.2.5",
"openai": "^6.37.0",
"openai": "^6.38.0",
"picomatch": "^4.0.3",
"playwright": "^1.59.1",
"playwright": "^1.60.0",
"proper-lockfile": "^4.1.2",
"proxy-agent": "^8.0.1",
"react": "^19.2.6",
@ -161,28 +162,28 @@
"sharp": "^0.34.5",
"shell-quote": "^1.8.3",
"strip-ansi": "^7.1.0",
"undici": "^8.2.0",
"undici": "^8.3.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"yaml": "^2.8.4",
"yaml": "^2.9.0",
"zod": "^4.4.3",
"zod-to-json-schema": "^3.25.2"
},
"devDependencies": {
"@biomejs/biome": "^2.4.14",
"@types/node": "^25.6.2",
"@biomejs/biome": "^2.4.15",
"@types/node": "^25.8.0",
"@types/picomatch": "^4.0.3",
"@types/react": "^19.2.14",
"@types/shell-quote": "^1.7.5",
"@typescript/native-preview": "^7.0.0-dev.20260510.1",
"@vitest/coverage-v8": "^4.1.5",
"esbuild": "^0.27.7",
"@vitest/coverage-v8": "^4.1.6",
"esbuild": "^0.28.0",
"jiti": "^2.7.0",
"jscpd": "^4.0.9",
"jscpd": "^4.2.2",
"madge": "^8.0.0",
"typescript": "^6.0.3",
"typescript-language-server": "^5.1.3",
"vitest": "^4.1.5"
"typescript-language-server": "^5.2.0",
"vitest": "^4.1.6"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.137",

View file

@ -0,0 +1,344 @@
#!/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",
]);
// 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 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 itemizedCount = imports.named.size;
// Collect candidate undeclared identifiers (same filtering as before)
const undeclared = new Set();
for (const id of refs) {
if (imports.named.has(id)) continue;
if (imports.namespace.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);

View file

@ -0,0 +1,206 @@
#!/usr/bin/env node
/**
* check-test-imports.test.mjs regression test for the test-import-drift guard.
*
* Verifies that check-test-imports.mjs correctly detects the anti-pattern:
* a test file uses itemized `import { foo }` but references `bar` (not imported).
*
* All file paths are absolute to avoid resolution ambiguity when running from
* different working directories.
*/
import { execSync } from "node:child_process";
import { writeFileSync, rmdirSync, mkdirSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = fileURLToPath(new URL(".", import.meta.url)); // scripts/
const repoRoot = resolve(__dirname, ".."); // parent = repo root
const script = resolve(__dirname, "check-test-imports.mjs");
const tmpDir = resolve(__dirname, "tmp-check-test-imports");
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Run the check script from the repo root, return {exitCode, stdout, stderr}. */
function run(args) {
const cmd = ["node", "--", script, ...args].join(" ");
try {
const out = execSync(cmd, {
cwd: repoRoot,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
return { exitCode: 0, stdout: out, stderr: "" };
} catch (err) {
return {
exitCode: err.status ?? 1,
stdout: (err.stdout || "").toString(),
stderr: (err.stderr || "").toString(),
};
}
}
/** Create a temp test file, return absolute path. */
function makeTempFile(id, content) {
try { mkdirSync(tmpDir, { recursive: true }); } catch { /* */ }
const file = resolve(tmpDir, `regression-${id}.test.mjs`);
writeFileSync(file, content);
return file;
}
function cleanupTempDir() {
try { rmdirSync(tmpDir); } catch { /* ignore */ }
}
// ── Tests ─────────────────────────────────────────────────────────────────────
let passed = 0;
let failed = 0;
function assert(condition, message) {
if (condition) {
console.log(`${message}`);
passed++;
} else {
console.error(`${message}`);
failed++;
}
}
console.log("check-test-imports regression tests\n");
// ── Test 1: Script is loadable and produces valid JSON ───────────────────────
// Use a real test file that exists under the repo root
const realFile = resolve(repoRoot, "tests/autonomous-solver.test.mjs");
if (!existsSync(realFile)) {
console.error(` ⚠️ Test file not found: ${realFile}`);
} else {
const result1 = run([realFile, "--json"]);
try {
const parsed = JSON.parse(result1.stdout);
assert(
typeof parsed.count === "number",
`Valid JSON with count: ${parsed.count}`,
);
} catch {
assert(false, `Script should produce valid JSON output`);
}
}
// ── Test 2: Detects undeclared identifier in anti-pattern file ───────────────
console.log("\n[2] Detects undeclared identifier in anti-pattern file");
const file2 = makeTempFile("regression-02", `
// Regression test — generated by check-test-imports.test.mjs
// DO NOT COMMIT
import { describe, it, expect } from "vitest";
import { fn1, fn2, fn3, fn4, fn5, fn6 } from "./fixture.mjs";
describe("test", () => {
it("uses undeclaredFn", () => {
undeclaredFn();
});
});
`);
const result2 = run([file2, "--json"]);
try {
const parsed = JSON.parse(result2.stdout);
assert(
parsed.count > 0,
`Drift detected in anti-pattern file (count: ${parsed.count})`,
);
assert(
result2.exitCode === 1,
`Exit code 1 when drift present (got: ${result2.exitCode})`,
);
} catch {
assert(false, `Should not throw when drift is detected`);
}
// ── Test 3: Clean file with namespace import passes ─────────────────────────
console.log("\n[3] Clean file with namespace import + local vars passes");
const file3 = makeTempFile("regression-03", `
// Clean: namespace import + only local variables
// DO NOT COMMIT
import { describe, it, expect } from "vitest";
import * as Fixtures from "./fixture.mjs";
const myLocalVar = Fixtures.fn1();
describe("test", () => {
it("uses Fixtures methods", () => {
expect(myLocalVar).toBeDefined();
});
});
`);
const result3 = run([file3, "--json"]);
try {
const parsed = JSON.parse(result3.stdout);
assert(
parsed.count === 0,
`Clean file: count ${parsed.count}`,
);
} catch {
assert(false, `Script should not throw on clean file`);
}
// ── Test 4: Local const/let declarations not flagged ───────────────────────
console.log("\n[4] Local const/let declarations not flagged");
const file4 = makeTempFile("regression-04", `
// Local variable declarations should not be flagged
// DO NOT COMMIT
import { describe, it, expect } from "vitest";
import { foo } from "./fixture.mjs";
const myLocalVar = foo();
const anotherLocal = foo();
describe("test", () => {
it("uses local variables", () => {
expect(myLocalVar).toBeDefined();
});
});
`);
const result4 = run([file4, "--json"]);
try {
const parsed = JSON.parse(result4.stdout);
assert(
parsed.count === 0,
`Local variables not flagged (count: ${parsed.count})`,
);
} catch {
assert(false, `Should not throw on clean file`);
}
// ── Test 5: Short lowercase variables not flagged ───────────────────────────
console.log("\n[5] Short lowercase variables not flagged");
const file5 = makeTempFile("regression-05", `
// Short lowercase vars like i, fn are common test locals
// DO NOT COMMIT
import { describe, it, expect } from "vitest";
import { fn } from "./fixture.mjs";
describe("test", () => {
it("works with short vars", () => {
const i = fn();
const j = i + 1;
expect(j).toBeGreaterThan(0);
});
});
`);
const result5 = run([file5, "--json"]);
try {
const parsed = JSON.parse(result5.stdout);
assert(
parsed.count === 0,
`Short lowercase vars not flagged (count: ${parsed.count})`,
);
} catch {
assert(false, `Should not throw on clean file`);
}
// ── Summary ─────────────────────────────────────────────────────────────────
console.log(`\n${"-".repeat(50)}`);
console.log(`Results: ${passed} passed, ${failed} failed`);
cleanupTempDir();
process.exit(failed > 0 ? 1 : 0);