diff --git a/CLAUDE.md b/CLAUDE.md index 0e48c8b20..5e94a3daa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 from ""` 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 | diff --git a/package.json b/package.json index 45b5e9e97..3f2b95925 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-test-imports.mjs b/scripts/check-test-imports.mjs new file mode 100644 index 000000000..f65cd8870 --- /dev/null +++ b/scripts/check-test-imports.mjs @@ -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, namespace: Set } 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); \ No newline at end of file diff --git a/scripts/check-test-imports.test.mjs b/scripts/check-test-imports.test.mjs new file mode 100644 index 000000000..ba785fb55 --- /dev/null +++ b/scripts/check-test-imports.test.mjs @@ -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); \ No newline at end of file