ai-diff-check 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1525 @@
1
+ // src/git/diff.ts
2
+ import { execFileSync } from "child_process";
3
+ var NotAGitRepoError = class extends Error {
4
+ constructor() {
5
+ super("Not a git repository \u2014 run ai-diff-check inside a repo.");
6
+ }
7
+ };
8
+ function git(args, cwd) {
9
+ return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
10
+ }
11
+ function findRepoRoot(cwd) {
12
+ try {
13
+ return git(["rev-parse", "--show-toplevel"], cwd).trim();
14
+ } catch {
15
+ throw new NotAGitRepoError();
16
+ }
17
+ }
18
+ function resolveBaseRef(repoRoot, explicit) {
19
+ if (explicit) return explicit;
20
+ for (const ref of ["main", "master", "origin/main", "origin/master"]) {
21
+ try {
22
+ git(["rev-parse", "--verify", "--quiet", ref], repoRoot);
23
+ return ref;
24
+ } catch {
25
+ }
26
+ }
27
+ const root = git(["rev-list", "--max-parents=0", "HEAD"], repoRoot).trim().split("\n")[0];
28
+ if (!root) throw new Error("Could not resolve a base ref \u2014 pass one with --base.");
29
+ return root;
30
+ }
31
+ function getChangedFiles(repoRoot, baseRef) {
32
+ const raw = git(["diff", "--no-color", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/", baseRef, "--"], repoRoot);
33
+ return parseUnifiedDiff(raw);
34
+ }
35
+ function parseUnifiedDiff(raw) {
36
+ const files = [];
37
+ let current = null;
38
+ let oldLine = 0;
39
+ let newLine = 0;
40
+ for (const line of raw.split("\n")) {
41
+ if (line.startsWith("diff --git ")) {
42
+ current = { path: "", added: /* @__PURE__ */ new Map(), removed: /* @__PURE__ */ new Map(), status: "modified" };
43
+ files.push(current);
44
+ } else if (current && line.startsWith("--- ")) {
45
+ if (line.includes("/dev/null")) current.status = "added";
46
+ else if (line.startsWith("--- a/")) current.path = line.slice("--- a/".length);
47
+ } else if (current && line.startsWith("+++ ")) {
48
+ if (line.includes("/dev/null")) current.status = "deleted";
49
+ else current.path = line.slice("+++ b/".length);
50
+ } else if (current && line.startsWith("@@")) {
51
+ const m = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
52
+ if (m) {
53
+ oldLine = Number(m[1]);
54
+ newLine = Number(m[2]);
55
+ }
56
+ } else if (current && line.startsWith("+")) {
57
+ current.added.set(newLine++, line.slice(1));
58
+ } else if (current && line.startsWith("-")) {
59
+ current.removed.set(oldLine++, line.slice(1));
60
+ }
61
+ }
62
+ return files.filter((f) => f.path !== "");
63
+ }
64
+ function diffStats(files) {
65
+ return {
66
+ files: files.length,
67
+ added: files.reduce((n, f) => n + f.added.size, 0),
68
+ removed: files.reduce((n, f) => n + f.removed.size, 0)
69
+ };
70
+ }
71
+
72
+ // src/report/terminal.ts
73
+ import pc from "picocolors";
74
+ var SEVERITY_ORDER = { error: 0, warn: 1, info: 2 };
75
+ function sortFindings(findings) {
76
+ return [...findings].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
77
+ }
78
+ function renderHeader(version, baseRef) {
79
+ return `
80
+ ${pc.bold(pc.yellow("ai-diff-check"))} ${pc.dim(`v${version} \u2014 reviewing working tree vs ${baseRef}`)}
81
+ `;
82
+ }
83
+ function renderDiffLine(stats, elapsedMs) {
84
+ const files = `${stats.files} file${stats.files === 1 ? "" : "s"}`;
85
+ return ` ${pc.green("\u2713")} ${pc.dim("parsed diff")} ${files} \xB7 +${stats.added.toLocaleString()} \u2212${stats.removed.toLocaleString()} ${pc.dim(`(${(elapsedMs / 1e3).toFixed(1)}s)`)}`;
86
+ }
87
+ function renderFindings(findings) {
88
+ const blocks = sortFindings(findings).map((f) => {
89
+ const mark = f.severity === "error" ? pc.red("\u2716") : f.severity === "warn" ? pc.yellow("\u26A0") : pc.cyan("\u2139");
90
+ const color = f.severity === "error" ? pc.red : f.severity === "warn" ? pc.yellow : pc.cyan;
91
+ const badge = color(pc.bold(f.check.toUpperCase()));
92
+ const loc = pc.dim(f.line ? `${f.file}:${f.line}` : f.file);
93
+ const fix = f.fix ? `
94
+ ${pc.dim("\u2192")} ${f.fix}` : "";
95
+ return ` ${mark} ${badge} ${pc.dim(`[${f.id}]`)}
96
+ ${loc} ${f.message}${fix}`;
97
+ });
98
+ return blocks.join("\n\n");
99
+ }
100
+ function renderSummary(findings, stats, elapsedMs) {
101
+ const errors = findings.filter((f) => f.severity === "error").length;
102
+ const warns = findings.filter((f) => f.severity === "warn").length;
103
+ const infos = findings.filter((f) => f.severity === "info").length;
104
+ const score = trustScore(findings, stats);
105
+ const scoreColor = score >= 80 ? pc.green : score >= 50 ? pc.yellow : pc.red;
106
+ const parts = [
107
+ `Diff trust score: ${scoreColor(pc.bold(`${score}/100`))}`,
108
+ errors ? pc.red(pc.bold(`\u2716 ${errors} error${errors === 1 ? "" : "s"}`)) : pc.green("no errors"),
109
+ warns ? pc.yellow(`\u26A0 ${warns} warning${warns === 1 ? "" : "s"}`) : "",
110
+ infos ? pc.cyan(`\u2139 ${infos} note${infos === 1 ? "" : "s"}`) : "",
111
+ pc.dim(`${stats.files} files \xB7 ${(elapsedMs / 1e3).toFixed(1)}s`)
112
+ ].filter(Boolean);
113
+ return `
114
+ ${pc.dim("\u2500".repeat(56))}
115
+ ${parts.join(" ")}
116
+ `;
117
+ }
118
+ function trustScore(findings, _stats) {
119
+ const errors = findings.filter((f) => f.severity === "error").length;
120
+ const warns = findings.filter((f) => f.severity === "warn").length;
121
+ return Math.max(0, 100 - errors * 18 - warns * 6);
122
+ }
123
+
124
+ // src/report/ci.ts
125
+ var escapeData = (s) => s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
126
+ var escapeProp = (s) => escapeData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
127
+ var COMMAND = { error: "error", warn: "warning", info: "notice" };
128
+ function githubAnnotations(findings) {
129
+ return findings.map((f) => {
130
+ const props = [`file=${escapeProp(f.file)}`];
131
+ if (f.line !== void 0) props.push(`line=${f.line}`);
132
+ props.push(`title=${escapeProp(`${f.check.toUpperCase()} [${f.id}]`)}`);
133
+ const message = f.fix !== void 0 ? `${f.message} \u2192 ${f.fix}` : f.message;
134
+ return `::${COMMAND[f.severity]} ${props.join(",")}::${escapeData(message)}`;
135
+ });
136
+ }
137
+
138
+ // src/report/fix-prompt.ts
139
+ function renderFixPrompt(findings) {
140
+ if (findings.length === 0) return "The diff is clean \u2014 nothing to fix.";
141
+ const lines = [
142
+ "Your last change failed its automated review (ai-diff-check). Fix ONLY the issues below.",
143
+ "Do not refactor unrelated code, do not add dependencies, do not delete tests to make checks pass.",
144
+ ""
145
+ ];
146
+ sortFindings(findings).forEach((f, i) => {
147
+ const loc = f.line !== void 0 ? `${f.file}:${f.line}` : f.file;
148
+ lines.push(`${i + 1}. [${f.severity}] ${loc} \u2014 ${f.message}`);
149
+ if (f.fix !== void 0) lines.push(` Required fix: ${f.fix}`);
150
+ });
151
+ lines.push("");
152
+ lines.push("When done, run `ai-diff-check` \u2014 it must report no errors.");
153
+ return lines.join("\n");
154
+ }
155
+
156
+ // src/checks/util.ts
157
+ import { readdirSync } from "fs";
158
+ import { join, relative, sep } from "path";
159
+ var CODE_FILE = /\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs)$/;
160
+ var TEST_FILE = /(?:[._](?:test|spec)\.[cm]?[jt]sx?$|__tests__\/)/;
161
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "out", "coverage", "vendor"]);
162
+ function walkCodeFiles(repoRoot) {
163
+ const found = [];
164
+ const stack = [repoRoot];
165
+ while (stack.length > 0) {
166
+ const dir = stack.pop();
167
+ let entries;
168
+ try {
169
+ entries = readdirSync(dir, { withFileTypes: true });
170
+ } catch {
171
+ continue;
172
+ }
173
+ for (const entry of entries) {
174
+ if (entry.isDirectory()) {
175
+ if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) stack.push(join(dir, entry.name));
176
+ } else if (entry.isFile() && CODE_FILE.test(entry.name)) {
177
+ found.push(relative(repoRoot, join(dir, entry.name)).split(sep).join("/"));
178
+ }
179
+ }
180
+ }
181
+ return found;
182
+ }
183
+ var DECL_RES = [
184
+ /^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)/,
185
+ /^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/,
186
+ /^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)[^=]*=\s*(?:async\s+)?(?:\(|function\b|[A-Za-z_$][\w$]*\s*=>)/
187
+ ];
188
+ function scanTopLevelDecls(lines) {
189
+ const decls = [];
190
+ lines.forEach((text, i) => {
191
+ for (let p = 0; p < DECL_RES.length; p++) {
192
+ const name = DECL_RES[p].exec(text)?.[1];
193
+ if (name) {
194
+ decls.push({ name, line: i + 1, isClass: p === 1 });
195
+ break;
196
+ }
197
+ }
198
+ });
199
+ return decls;
200
+ }
201
+ function declSpan(lines, decl, decls) {
202
+ const next = decls.find((d) => d.line > decl.line)?.line ?? lines.length + 1;
203
+ for (let i = decl.line; i < lines.length && i + 1 < next; i++) {
204
+ if (/^[}\])]/.test(lines[i])) return { start: decl.line, end: i + 1 };
205
+ }
206
+ return { start: decl.line, end: next - 1 };
207
+ }
208
+ function shingles(tokens, k) {
209
+ const set = /* @__PURE__ */ new Set();
210
+ for (let i = 0; i + k <= tokens.length; i++) {
211
+ set.add(tokens.slice(i, i + k).join("\0"));
212
+ }
213
+ return set;
214
+ }
215
+ function jaccard(a, b) {
216
+ const [small, large] = a.size <= b.size ? [a, b] : [b, a];
217
+ let intersection = 0;
218
+ for (const s of small) if (large.has(s)) intersection++;
219
+ const union = a.size + b.size - intersection;
220
+ return union === 0 ? 0 : intersection / union;
221
+ }
222
+ function stableId(prefix, key) {
223
+ let h = 5381;
224
+ for (let i = 0; i < key.length; i++) {
225
+ h = (h << 5) + h + key.charCodeAt(i) >>> 0;
226
+ }
227
+ return `${prefix}-${100 + h % 900}`;
228
+ }
229
+
230
+ // src/checks/stub.ts
231
+ var MARK_A = "TODO";
232
+ var MARK_B = "FIXME";
233
+ var MARKER = new RegExp(`(?://|/\\*|\\*|#).*\\b(?:${MARK_A}|${MARK_B})\\b`);
234
+ var MARKER_TOKEN = new RegExp(`\\b(${MARK_A}|${MARK_B})\\b`);
235
+ var LINE_RULES = [
236
+ {
237
+ kind: "console",
238
+ match: /\bconsole\.(?:log|debug)\s*\(/,
239
+ message: () => "Leftover debug logging in new code",
240
+ fix: "remove the log or route it through your logger"
241
+ },
242
+ {
243
+ kind: "marker",
244
+ match: MARKER,
245
+ message: (line) => `Unresolved ${MARKER_TOKEN.exec(line)?.[1] ?? "task"} marker left in new code`,
246
+ fix: "resolve it, or track it in an issue before merging"
247
+ },
248
+ {
249
+ kind: "not-implemented",
250
+ match: /throw\s+new\s+\w*Error\s*\(\s*['"`][^'"`]*not[\s_-]?implemented/i,
251
+ message: () => 'Stubbed "not implemented" throw shipped in the diff',
252
+ fix: "implement the path or remove the dead branch"
253
+ },
254
+ {
255
+ // Single-line empty catch: `catch (e) {}`.
256
+ kind: "empty-catch",
257
+ match: /catch\s*(?:\([^)]*\))?\s*\{\s*\}/,
258
+ message: () => "Empty catch block silently swallows the error",
259
+ fix: "handle the error or at least log it \u2014 don't swallow it"
260
+ }
261
+ ];
262
+ var EMPTY_CATCH_RULE = LINE_RULES[LINE_RULES.length - 1];
263
+ var CATCH_OPEN = /catch\s*(?:\([^)]*\))?\s*\{\s*$/;
264
+ var BARE_CLOSE = /^\s*\}\s*;?\s*$/;
265
+ var stubCheck = {
266
+ name: "stub",
267
+ async run(ctx) {
268
+ const findings = [];
269
+ const seen = /* @__PURE__ */ new Map();
270
+ const push = (path, line, rule, text) => {
271
+ const snippet2 = text.trim();
272
+ const key = `${path}:${rule.kind}:${snippet2}`;
273
+ const occ = seen.get(key) ?? 0;
274
+ seen.set(key, occ + 1);
275
+ findings.push({
276
+ id: stableId("STUB", occ === 0 ? key : `${key}#${occ}`),
277
+ check: "stub",
278
+ severity: "warn",
279
+ file: path,
280
+ line,
281
+ message: rule.message(text),
282
+ fix: rule.fix
283
+ });
284
+ };
285
+ for (const file of ctx.files) {
286
+ const added = [...file.added.entries()].sort((a, b) => a[0] - b[0]);
287
+ for (const [lineNo, text] of added) {
288
+ if (CATCH_OPEN.test(text)) {
289
+ const next = file.added.get(lineNo + 1);
290
+ if (next !== void 0 && BARE_CLOSE.test(next)) {
291
+ push(file.path, lineNo, EMPTY_CATCH_RULE, text);
292
+ continue;
293
+ }
294
+ }
295
+ for (const rule of LINE_RULES) {
296
+ if (rule.match.test(text)) {
297
+ push(file.path, lineNo, rule, text);
298
+ break;
299
+ }
300
+ }
301
+ }
302
+ }
303
+ return findings;
304
+ }
305
+ };
306
+
307
+ // src/checks/oversize.ts
308
+ import { readFileSync } from "fs";
309
+ import { join as join2 } from "path";
310
+ var JSX_FILE = /\.(?:tsx|jsx)$/;
311
+ var REGROWTH_LINES = 50;
312
+ var MIN_ADDED_IN_COMPONENT = 10;
313
+ function countLines(content) {
314
+ const parts = content.split("\n");
315
+ return parts[parts.length - 1] === "" ? parts.length - 1 : parts.length;
316
+ }
317
+ function declLabel(d, isJsx) {
318
+ if (d.isClass) return d.name;
319
+ if (/^use[A-Z]/.test(d.name)) return `${d.name} (hook)`;
320
+ if (isJsx && /^[A-Z]/.test(d.name)) return `<${d.name}>`;
321
+ return d.name;
322
+ }
323
+ function proposeFileSplit(decls, isJsx) {
324
+ if (decls.length < 2) return "split components, hooks, and utils into their own modules";
325
+ const names = decls.slice(0, 4).map((d) => declLabel(d, isJsx));
326
+ const more = decls.length - 4;
327
+ return `split it up \u2014 ${names.join(", ")}${more > 0 ? ` (+${more} more)` : ""} can each live in their own module`;
328
+ }
329
+ var INNER_DECL = /^\s+(?:const|function)\s+([a-z_$][\w$]*)\s*[=(]/;
330
+ function proposeComponentSplit(name, lines, span) {
331
+ const inner = [];
332
+ for (let i = span.start; i < span.end && inner.length < 3; i++) {
333
+ const m = INNER_DECL.exec(lines[i]);
334
+ if (m?.[1]) inner.push(m[1]);
335
+ }
336
+ return inner.length > 0 ? `extract pieces of <${name}> into hooks/subcomponents \u2014 e.g. ${inner.join(", ")}` : `break <${name}> into smaller subcomponents or hooks`;
337
+ }
338
+ function fileFinding(file, total, before, limit, fix) {
339
+ const message = file.status === "added" ? `New file is ${total} lines (limit ${limit})` : `File grew from ${before} to ${total} lines (limit ${limit})`;
340
+ return {
341
+ id: stableId("OVERSIZE", `${file.path}:file`),
342
+ check: "oversize",
343
+ severity: "warn",
344
+ file: file.path,
345
+ message,
346
+ fix
347
+ };
348
+ }
349
+ var oversizeCheck = {
350
+ name: "oversize",
351
+ async run(ctx) {
352
+ const findings = [];
353
+ for (const file of ctx.files) {
354
+ if (file.status === "deleted" || !CODE_FILE.test(file.path)) continue;
355
+ let content;
356
+ try {
357
+ content = readFileSync(join2(ctx.repoRoot, file.path), "utf8");
358
+ } catch {
359
+ continue;
360
+ }
361
+ const lines = content.split("\n");
362
+ const total = countLines(content);
363
+ const netGrowth = file.added.size - file.removed.size;
364
+ const before = total - netGrowth;
365
+ const isJsx = JSX_FILE.test(file.path);
366
+ const decls = scanTopLevelDecls(lines);
367
+ const fileLimit = ctx.config.maxFileLines;
368
+ const crossedLimit = before <= fileLimit && total > fileLimit;
369
+ const keptGrowing = before > fileLimit && netGrowth >= REGROWTH_LINES;
370
+ if (total > fileLimit && (crossedLimit || keptGrowing)) {
371
+ findings.push(fileFinding(file, total, before, fileLimit, proposeFileSplit(decls, isJsx)));
372
+ continue;
373
+ }
374
+ if (!isJsx) continue;
375
+ const compLimit = ctx.config.maxComponentLines;
376
+ for (const decl of decls) {
377
+ if (decl.isClass || !/^[A-Z]/.test(decl.name)) continue;
378
+ const span = declSpan(lines, decl, decls);
379
+ const size = span.end - span.start + 1;
380
+ if (size <= compLimit) continue;
381
+ let addedInside = 0;
382
+ for (const n of file.added.keys()) {
383
+ if (n >= span.start && n <= span.end) addedInside++;
384
+ }
385
+ if (addedInside < MIN_ADDED_IN_COMPONENT) continue;
386
+ findings.push({
387
+ id: stableId("OVERSIZE", `${file.path}:${decl.name}`),
388
+ check: "oversize",
389
+ severity: "warn",
390
+ file: file.path,
391
+ line: decl.line,
392
+ message: `<${decl.name}> is ${size} lines (limit ${compLimit})`,
393
+ fix: proposeComponentSplit(decl.name, lines, span)
394
+ });
395
+ }
396
+ }
397
+ return findings;
398
+ }
399
+ };
400
+
401
+ // src/checks/phantom-dep.ts
402
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
403
+ import { dirname, join as join3 } from "path";
404
+ import { builtinModules } from "module";
405
+ var BUILTINS = new Set(builtinModules);
406
+ var MAX_REGISTRY_LOOKUPS = 15;
407
+ var REGISTRY_TIMEOUT_MS = 3e3;
408
+ var IMPORT_PATTERNS = [
409
+ /^\s*(?:import|export)\b[^'"]*\bfrom\s+['"]([^'"]+)['"]/,
410
+ // import x from 'p' / export { x } from 'p'
411
+ /^\s*[}\])][^'"]*\bfrom\s+['"]([^'"]+)['"]/,
412
+ // closing line of a multi-line import
413
+ /^\s*import\s+['"]([^'"]+)['"]/,
414
+ // side-effect import
415
+ /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/,
416
+ // CJS
417
+ /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/
418
+ // dynamic import
419
+ ];
420
+ function extractSpecifier(line) {
421
+ for (const re of IMPORT_PATTERNS) {
422
+ const name = re.exec(line)?.[1];
423
+ if (name) return name;
424
+ }
425
+ return null;
426
+ }
427
+ function packageName(spec) {
428
+ if (/^[./#~]/.test(spec)) return null;
429
+ if (spec.startsWith("node:")) return null;
430
+ const parts = spec.split("/");
431
+ const name = spec.startsWith("@") ? parts.length >= 2 ? `${parts[0]}/${parts[1]}` : null : parts[0];
432
+ if (!name || BUILTINS.has(name)) return null;
433
+ if (!/^(@[a-z0-9~][\w.~-]*\/)?[a-z0-9~][a-z0-9._~-]*$/.test(name)) return null;
434
+ return name;
435
+ }
436
+ function typesPackage(name) {
437
+ return name.startsWith("@") ? `@types/${name.slice(1).replace("/", "__")}` : `@types/${name}`;
438
+ }
439
+ function loadTsconfigAliases(repoRoot) {
440
+ const aliases = { exact: /* @__PURE__ */ new Set(), prefixes: [] };
441
+ let raw;
442
+ try {
443
+ raw = readFileSync2(join3(repoRoot, "tsconfig.json"), "utf8");
444
+ } catch {
445
+ return aliases;
446
+ }
447
+ try {
448
+ const jsonc = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1");
449
+ const paths = JSON.parse(jsonc)?.compilerOptions?.paths;
450
+ if (paths && typeof paths === "object") {
451
+ for (const key of Object.keys(paths)) {
452
+ if (key.endsWith("*")) aliases.prefixes.push(key.slice(0, -1));
453
+ else aliases.exact.add(key);
454
+ }
455
+ }
456
+ } catch {
457
+ }
458
+ return aliases;
459
+ }
460
+ function isAliased(spec, aliases) {
461
+ return aliases.exact.has(spec) || aliases.prefixes.some((p) => spec.startsWith(p));
462
+ }
463
+ function readManifest(dir, cache) {
464
+ const cached = cache.get(dir);
465
+ if (cached !== void 0) return cached;
466
+ let result = null;
467
+ try {
468
+ const pkg = JSON.parse(readFileSync2(join3(dir, "package.json"), "utf8"));
469
+ result = /* @__PURE__ */ new Set();
470
+ if (typeof pkg["name"] === "string") result.add(pkg["name"]);
471
+ for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
472
+ const deps = pkg[field];
473
+ if (deps && typeof deps === "object") for (const key of Object.keys(deps)) result.add(key);
474
+ }
475
+ } catch {
476
+ result = null;
477
+ }
478
+ cache.set(dir, result);
479
+ return result;
480
+ }
481
+ function collectDeclared(repoRoot, relPath, cache) {
482
+ const declared = /* @__PURE__ */ new Set();
483
+ let manifests = 0;
484
+ let dir = dirname(join3(repoRoot, relPath));
485
+ for (; ; ) {
486
+ const manifest = readManifest(dir, cache);
487
+ if (manifest) {
488
+ manifests++;
489
+ for (const name of manifest) declared.add(name);
490
+ }
491
+ if (dir === repoRoot) break;
492
+ const parent = dirname(dir);
493
+ if (parent === dir) break;
494
+ dir = parent;
495
+ }
496
+ return { declared, manifests };
497
+ }
498
+ async function checkRegistry(name) {
499
+ try {
500
+ const res = await fetch(`https://registry.npmjs.org/${name.replace("/", "%2F")}`, {
501
+ method: "HEAD",
502
+ signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS)
503
+ });
504
+ if (res.status === 404) return "missing";
505
+ if (res.ok) return "exists";
506
+ return "unknown";
507
+ } catch {
508
+ return "unknown";
509
+ }
510
+ }
511
+ var phantomDepCheck = {
512
+ name: "phantom-dep",
513
+ async run(ctx) {
514
+ const manifestCache = /* @__PURE__ */ new Map();
515
+ const aliases = loadTsconfigAliases(ctx.repoRoot);
516
+ const undeclared = /* @__PURE__ */ new Map();
517
+ for (const file of ctx.files) {
518
+ if (file.status === "deleted" || !CODE_FILE.test(file.path)) continue;
519
+ const { declared, manifests } = collectDeclared(ctx.repoRoot, file.path, manifestCache);
520
+ if (manifests === 0) continue;
521
+ for (const [lineNo, text] of [...file.added.entries()].sort((a, b) => a[0] - b[0])) {
522
+ const trimmed = text.trim();
523
+ if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) continue;
524
+ const spec = extractSpecifier(text);
525
+ if (!spec || isAliased(spec, aliases)) continue;
526
+ const name = packageName(spec);
527
+ if (!name || declared.has(name) || declared.has(typesPackage(name))) continue;
528
+ if (existsSync(join3(ctx.repoRoot, spec.split("/")[0]))) continue;
529
+ const seen = undeclared.get(name);
530
+ if (seen) seen.count++;
531
+ else undeclared.set(name, { file: file.path, line: lineNo, count: 1 });
532
+ }
533
+ }
534
+ const verdicts = /* @__PURE__ */ new Map();
535
+ await Promise.all(
536
+ [...undeclared.keys()].slice(0, MAX_REGISTRY_LOOKUPS).map(async (name) => {
537
+ verdicts.set(name, await checkRegistry(name));
538
+ })
539
+ );
540
+ const findings = [];
541
+ for (const [name, occ] of undeclared) {
542
+ const verdict = verdicts.get(name) ?? "unknown";
543
+ const more = occ.count > 1 ? ` (and ${occ.count - 1} more place${occ.count > 2 ? "s" : ""})` : "";
544
+ const finding = {
545
+ id: stableId("PHANTOM", name),
546
+ check: "phantom-dep",
547
+ severity: "error",
548
+ file: occ.file,
549
+ line: occ.line,
550
+ message: "",
551
+ fix: ""
552
+ };
553
+ if (verdict === "missing") {
554
+ finding.message = `'${name}' does not exist on npm \u2014 likely a hallucinated import${more}`;
555
+ finding.fix = `remove it or find the real package \u2014 installing a lookalike name risks slopsquatting`;
556
+ } else if (verdict === "exists") {
557
+ finding.message = `'${name}' is imported but not declared in package.json${more}`;
558
+ finding.fix = `npm install ${name} \u2014 undeclared deps break on fresh installs`;
559
+ } else {
560
+ finding.message = `'${name}' is imported but not declared in package.json${more}`;
561
+ finding.fix = `npm install ${name} if it's real \u2014 could not reach the npm registry to verify`;
562
+ }
563
+ findings.push(finding);
564
+ }
565
+ return findings;
566
+ }
567
+ };
568
+
569
+ // src/checks/deletion.ts
570
+ var ERROR_HANDLING = [/\btry\s*\{/, /\bcatch\s*[({]/, /\.catch\s*\(/];
571
+ var GUARD_IF = [/\bif\s*\(\s*!(?!=)/, /\bif\b[^\n]*[=!]==?\s*(?:null|undefined)\b/, /\bif\b[^\n]*\btypeof\s+[\w.$]+\s*[=!]==?/];
572
+ var GUARD_DIRECT = [
573
+ /\bthrow\s+new\s+\w*(?:Error|Exception)\w*\s*\(/,
574
+ /\b(?:validate|invariant|assert)\w*\s*\(/,
575
+ /\.safeParse\s*\(/,
576
+ /[sS]chema\.parse\s*\(/
577
+ ];
578
+ var CONSEQUENCE = /\b(?:return|throw|continue|break)\b|process\.exit/;
579
+ var TEST_LINE = /^\s*(?:it|test)(?:\.\w+)?\s*\(\s*(?:(['"`])(.*?)\1)?/;
580
+ var norm = (s) => s.replace(/\s+/g, " ").trim();
581
+ var snippet = (s) => {
582
+ const t = norm(s);
583
+ return t.length > 64 ? `${t.slice(0, 63)}\u2026` : t;
584
+ };
585
+ function isGuard(text, nextRemoved) {
586
+ if (GUARD_DIRECT.some((re) => re.test(text))) return true;
587
+ if (!GUARD_IF.some((re) => re.test(text))) return false;
588
+ return CONSEQUENCE.test(text) || nextRemoved !== void 0 && CONSEQUENCE.test(nextRemoved);
589
+ }
590
+ function sameCategoryAdded(category, text) {
591
+ if (category === "error-handling") return ERROR_HANDLING.some((re) => re.test(text));
592
+ return GUARD_DIRECT.some((re) => re.test(text)) || GUARD_IF.some((re) => re.test(text));
593
+ }
594
+ var deletionCheck = {
595
+ name: "deletion",
596
+ async run(ctx) {
597
+ const findings = [];
598
+ const seen = /* @__PURE__ */ new Map();
599
+ const push = (file, line, key, message, fix) => {
600
+ const occ = seen.get(key) ?? 0;
601
+ seen.set(key, occ + 1);
602
+ findings.push({
603
+ id: stableId("DEL", occ === 0 ? key : `${key}#${occ}`),
604
+ check: "deletion",
605
+ severity: "error",
606
+ file,
607
+ line,
608
+ message,
609
+ fix
610
+ });
611
+ };
612
+ const addedEverywhere = /* @__PURE__ */ new Set();
613
+ const addedTestNames = /* @__PURE__ */ new Set();
614
+ for (const f of ctx.files) {
615
+ for (const text of f.added.values()) {
616
+ addedEverywhere.add(norm(text));
617
+ const name = TEST_LINE.exec(text)?.[2];
618
+ if (name) addedTestNames.add(name);
619
+ }
620
+ }
621
+ const moved = (text) => addedEverywhere.has(norm(text));
622
+ for (const file of ctx.files) {
623
+ if (!CODE_FILE.test(file.path) || file.removed.size === 0) continue;
624
+ const removed = [...file.removed.entries()].sort((a, b) => a[0] - b[0]);
625
+ const addedTexts = [...file.added.values()];
626
+ if (TEST_FILE.test(file.path)) {
627
+ const removedTests = [];
628
+ for (const [lineNo, text] of removed) {
629
+ const m = TEST_LINE.exec(text);
630
+ if (!m || moved(text)) continue;
631
+ const name = m[2];
632
+ if (name !== void 0 && addedTestNames.has(name)) continue;
633
+ removedTests.push({ line: lineNo, name });
634
+ }
635
+ if (removedTests.length === 0) continue;
636
+ const first = removedTests[0];
637
+ const fix = "restore it \u2014 deleting tests to make a change pass hides regressions";
638
+ if (file.status === "deleted") {
639
+ const count = removedTests.length;
640
+ push(
641
+ file.path,
642
+ first.line,
643
+ `${file.path}:test-file`,
644
+ `Test file deleted (${count} test case${count === 1 ? "" : "s"})`,
645
+ fix
646
+ );
647
+ continue;
648
+ }
649
+ const addedCount = addedTexts.filter((t) => TEST_LINE.test(t)).length;
650
+ if (removedTests.length <= addedCount) continue;
651
+ const named = first.name !== void 0 ? `: "${first.name}"` : "";
652
+ const message = removedTests.length === 1 ? `Test case removed${named}` : `${removedTests.length} test cases removed, only ${addedCount} added back${first.name !== void 0 ? ` \u2014 including "${first.name}"` : ""}`;
653
+ push(file.path, first.line, `${file.path}:test`, message, fix);
654
+ continue;
655
+ }
656
+ if (file.status === "deleted") continue;
657
+ const ehSuppressed = addedTexts.some((t) => sameCategoryAdded("error-handling", t));
658
+ const guardSuppressed = addedTexts.some((t) => sameCategoryAdded("guard", t));
659
+ let block = [];
660
+ let prevLine = Number.MIN_SAFE_INTEGER;
661
+ const blocks = [];
662
+ for (const entry of removed) {
663
+ if (entry[0] !== prevLine + 1) {
664
+ block = [];
665
+ blocks.push(block);
666
+ }
667
+ block.push(entry);
668
+ prevLine = entry[0];
669
+ }
670
+ for (const blk of blocks) {
671
+ if (!ehSuppressed) {
672
+ const hit = blk.find(([, text]) => !moved(text) && ERROR_HANDLING.some((re) => re.test(text)));
673
+ if (hit) {
674
+ const promise = /\.catch\s*\(/.test(hit[1]);
675
+ push(
676
+ file.path,
677
+ hit[0],
678
+ `${file.path}:eh:${norm(hit[1])}`,
679
+ promise ? "promise .catch() removed \u2014 rejections now go unhandled" : "try/catch removed \u2014 errors here now propagate unhandled",
680
+ "restore the handling, or make the caller handle the error explicitly"
681
+ );
682
+ }
683
+ }
684
+ if (!guardSuppressed) {
685
+ const hit = blk.find(([lineNo, text]) => !moved(text) && isGuard(text, file.removed.get(lineNo + 1)));
686
+ if (hit) {
687
+ push(
688
+ file.path,
689
+ hit[0],
690
+ `${file.path}:guard:${norm(hit[1])}`,
691
+ `Guard removed: "${snippet(hit[1])}"`,
692
+ "restore it \u2014 or confirm the input can no longer be invalid"
693
+ );
694
+ }
695
+ }
696
+ }
697
+ }
698
+ return findings;
699
+ }
700
+ };
701
+
702
+ // src/checks/no-test.ts
703
+ import { readFileSync as readFileSync3 } from "fs";
704
+ import { join as join4 } from "path";
705
+ var MIN_LOGIC_TOUCHED = 5;
706
+ var MIN_LOGIC_NEW = 10;
707
+ var NOT_LOGIC = [
708
+ /^\s*$/,
709
+ /^\s*(?:\/\/|\/?\*)/,
710
+ // comments
711
+ /^\s*import\b/,
712
+ /^\s*export\s+(?:\{|\*|type\b|interface\b)/,
713
+ // re-exports and type exports
714
+ /^\s*(?:interface|type|declare)\b/,
715
+ /^[\s{}()[\];,]*$/
716
+ // punctuation-only
717
+ ];
718
+ var LOGIC = /\breturn\b|\bthrow\b|\bawait\b|\bif\b|\belse\b|\bfor\b|\bwhile\b|\bswitch\b|=>|[\w$]\s*\(|=[^=>]/;
719
+ var DECL_SIGNAL = /\bfunction\b|\bclass\b|=>/;
720
+ function countLogicLines(texts) {
721
+ let count = 0;
722
+ for (const text of texts) {
723
+ if (NOT_LOGIC.some((re) => re.test(text))) continue;
724
+ if (LOGIC.test(text)) count++;
725
+ }
726
+ return count;
727
+ }
728
+ function moduleBase(path) {
729
+ const name = path.split("/").pop();
730
+ return name.replace(/\.[cm]?[jt]sx?$/, "").replace(/[._](?:test|spec)$/, "");
731
+ }
732
+ var RUNNERS = ["vitest", "jest", "mocha", "ava", "tap", "jasmine", "uvu"];
733
+ function hasTestRunner(repoRoot) {
734
+ try {
735
+ const pkg = JSON.parse(readFileSync3(join4(repoRoot, "package.json"), "utf8"));
736
+ for (const field of ["dependencies", "devDependencies"]) {
737
+ const deps = pkg[field];
738
+ if (deps && typeof deps === "object" && RUNNERS.some((r) => r in deps)) return true;
739
+ }
740
+ } catch {
741
+ }
742
+ return false;
743
+ }
744
+ function suggestTestPath(sourcePath, repoTests) {
745
+ const base = moduleBase(sourcePath);
746
+ const testDir = repoTests.find((t) => /^tests?\//.test(t))?.split("/")[0];
747
+ if (testDir) return `${testDir}/${base}.test.ts`;
748
+ const dir = sourcePath.split("/").slice(0, -1).join("/");
749
+ return `${dir === "" ? "" : `${dir}/`}${base}.test.ts`;
750
+ }
751
+ var noTestCheck = {
752
+ name: "no-test",
753
+ async run(ctx) {
754
+ const findings = [];
755
+ const sourceChanged = ctx.files.filter(
756
+ (f) => f.status !== "deleted" && CODE_FILE.test(f.path) && !TEST_FILE.test(f.path)
757
+ );
758
+ if (sourceChanged.length === 0) return findings;
759
+ const repoTests = walkCodeFiles(ctx.repoRoot).filter((p) => TEST_FILE.test(p));
760
+ if (repoTests.length === 0 && !hasTestRunner(ctx.repoRoot)) return findings;
761
+ const touchedTestBases = new Set(
762
+ ctx.files.filter((f) => TEST_FILE.test(f.path) && f.status !== "deleted").map((f) => moduleBase(f.path))
763
+ );
764
+ const repoTestByBase = /* @__PURE__ */ new Map();
765
+ for (const t of repoTests) {
766
+ const base = moduleBase(t);
767
+ if (!repoTestByBase.has(base)) repoTestByBase.set(base, t);
768
+ }
769
+ for (const file of sourceChanged) {
770
+ const base = moduleBase(file.path);
771
+ if (touchedTestBases.has(base)) continue;
772
+ const logic = countLogicLines(file.added.values());
773
+ const testPath = repoTestByBase.get(base);
774
+ if (testPath !== void 0) {
775
+ if (logic < MIN_LOGIC_TOUCHED) continue;
776
+ findings.push({
777
+ id: stableId("NOTEST", `${file.path}:stale`),
778
+ check: "no-test",
779
+ severity: "warn",
780
+ file: file.path,
781
+ message: `Logic changed (${logic} lines) but ${testPath} wasn't touched`,
782
+ fix: `update ${testPath} to cover the changed behavior`
783
+ });
784
+ } else {
785
+ if (logic < MIN_LOGIC_NEW) continue;
786
+ const texts = [...file.added.values()];
787
+ if (!texts.some((t) => DECL_SIGNAL.test(t))) continue;
788
+ findings.push({
789
+ id: stableId("NOTEST", `${file.path}:missing`),
790
+ check: "no-test",
791
+ severity: "warn",
792
+ file: file.path,
793
+ message: `${logic} lines of logic changed with no test file for this module`,
794
+ fix: `add ${suggestTestPath(file.path, repoTests)} to lock the behavior in`
795
+ });
796
+ }
797
+ }
798
+ return findings;
799
+ }
800
+ };
801
+
802
+ // src/checks/unreferenced.ts
803
+ import { readFileSync as readFileSync4 } from "fs";
804
+ import { join as join5 } from "path";
805
+ var ENTRY_FILE = /(?:^|\/)(?:index|main|cli)\.[cm]?[jt]sx?$/;
806
+ var FRAMEWORK_FILE = /(?:^|\/)(?:pages|app|routes)\/|\.(?:config|stories)\.[cm]?[jt]sx?$|\.d\.ts$/;
807
+ var MAGIC_NAMES = /* @__PURE__ */ new Set([
808
+ "metadata",
809
+ "revalidate",
810
+ "dynamic",
811
+ "config",
812
+ "runtime",
813
+ "viewport",
814
+ "getServerSideProps",
815
+ "getStaticProps",
816
+ "getStaticPaths",
817
+ "generateMetadata",
818
+ "generateStaticParams",
819
+ "loader",
820
+ "action",
821
+ "links",
822
+ "meta",
823
+ "headers",
824
+ "handle",
825
+ "ErrorBoundary",
826
+ "middleware"
827
+ ]);
828
+ var EXPORT_DECL = /^\s*export\s+(?:async\s+)?(?:function\*?|class|abstract\s+class|const|let|var)\s+([A-Za-z_$][\w$]*)/;
829
+ var escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
830
+ var unreferencedCheck = {
831
+ name: "unreferenced",
832
+ async run(ctx) {
833
+ const findings = [];
834
+ const candidates = [];
835
+ for (const file of ctx.files) {
836
+ if (file.status === "deleted") continue;
837
+ if (!/\.[cm]?[jt]sx?$/.test(file.path)) continue;
838
+ if (ENTRY_FILE.test(file.path) || FRAMEWORK_FILE.test(file.path) || TEST_FILE.test(file.path)) continue;
839
+ for (const [lineNo, text] of file.added) {
840
+ const name = EXPORT_DECL.exec(text)?.[1];
841
+ if (name === void 0 || MAGIC_NAMES.has(name)) continue;
842
+ candidates.push({ file: file.path, line: lineNo, name });
843
+ }
844
+ }
845
+ if (candidates.length === 0) return findings;
846
+ const repoFiles = walkCodeFiles(ctx.repoRoot);
847
+ const onDisk = new Set(repoFiles);
848
+ const contentCache = /* @__PURE__ */ new Map();
849
+ const read = (path) => {
850
+ let content = contentCache.get(path);
851
+ if (content === void 0) {
852
+ try {
853
+ content = readFileSync4(join5(ctx.repoRoot, path), "utf8");
854
+ } catch {
855
+ content = "";
856
+ }
857
+ contentCache.set(path, content);
858
+ }
859
+ return content;
860
+ };
861
+ for (const { file, line, name } of candidates) {
862
+ if (!onDisk.has(file)) continue;
863
+ const word = new RegExp(`\\b${escapeRegex(name)}\\b`);
864
+ const referencedElsewhere = repoFiles.some((other) => other !== file && word.test(read(other)));
865
+ if (referencedElsewhere) continue;
866
+ const ownMatches = read(file).match(new RegExp(`\\b${escapeRegex(name)}\\b`, "g"))?.length ?? 0;
867
+ const internalOnly = ownMatches >= 2;
868
+ findings.push({
869
+ id: stableId("UNREF", `${file}:${name}`),
870
+ check: "unreferenced",
871
+ severity: "warn",
872
+ file,
873
+ line,
874
+ message: internalOnly ? `export ${name} is only used inside this file` : `export ${name} is never referenced`,
875
+ fix: internalOnly ? "drop the export keyword \u2014 keep the module surface honest" : "remove it, or wire it up \u2014 dead exports invite drift"
876
+ });
877
+ }
878
+ return findings;
879
+ }
880
+ };
881
+
882
+ // src/checks/duplicate.ts
883
+ import { readFileSync as readFileSync5 } from "fs";
884
+ import { join as join6 } from "path";
885
+ var SIMILARITY_THRESHOLD = 0.8;
886
+ var MIN_TOKENS = 40;
887
+ var BODY_MIN_TOKENS = 30;
888
+ var SHINGLE_K = 6;
889
+ var KEYWORDS = /* @__PURE__ */ new Set([
890
+ "if",
891
+ "else",
892
+ "for",
893
+ "while",
894
+ "do",
895
+ "switch",
896
+ "case",
897
+ "return",
898
+ "const",
899
+ "let",
900
+ "var",
901
+ "function",
902
+ "class",
903
+ "new",
904
+ "try",
905
+ "catch",
906
+ "finally",
907
+ "throw",
908
+ "await",
909
+ "async",
910
+ "yield",
911
+ "typeof",
912
+ "instanceof",
913
+ "in",
914
+ "of",
915
+ "break",
916
+ "continue",
917
+ "default",
918
+ "import",
919
+ "export",
920
+ "from",
921
+ "extends",
922
+ "this",
923
+ "super",
924
+ "null",
925
+ "undefined",
926
+ "true",
927
+ "false",
928
+ "void",
929
+ "delete",
930
+ "static",
931
+ "get",
932
+ "set"
933
+ ]);
934
+ var TOKEN_RE = /(['"`])(?:\\.|(?!\1).)*\1|\d+(?:\.\d+)?|[A-Za-z_$][\w$]*|=>|===|!==|==|!=|<=|>=|&&|\|\||\?\?|\?\.|\.\.\.|[+\-*/%<>=!&|^~?:;,.(){}[\]]/g;
935
+ function tokenize(code) {
936
+ const stripped = code.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
937
+ const tokens = [];
938
+ for (const m of stripped.matchAll(TOKEN_RE)) {
939
+ const t = m[0];
940
+ if (t[0] === '"' || t[0] === "'" || t[0] === "`") tokens.push("S");
941
+ else if (/^\d/.test(t)) tokens.push("N");
942
+ else if (/^[A-Za-z_$]/.test(t)) tokens.push(KEYWORDS.has(t) ? t : "ID");
943
+ else tokens.push(t);
944
+ }
945
+ return tokens;
946
+ }
947
+ function extractFunctions(file, content) {
948
+ const lines = content.split("\n");
949
+ const decls = scanTopLevelDecls(lines);
950
+ const fns = [];
951
+ for (const decl of decls) {
952
+ const span = declSpan(lines, decl, decls);
953
+ const tokens = tokenize(lines.slice(span.start - 1, span.end).join("\n"));
954
+ if (tokens.length < MIN_TOKENS) continue;
955
+ const bodyTokens = tokenize(lines.slice(span.start, span.end).join("\n"));
956
+ fns.push({
957
+ file,
958
+ name: decl.name,
959
+ start: span.start,
960
+ end: span.end,
961
+ shingles: shingles(tokens, SHINGLE_K),
962
+ bodyShingles: bodyTokens.length >= BODY_MIN_TOKENS ? shingles(bodyTokens, SHINGLE_K) : null
963
+ });
964
+ }
965
+ return fns;
966
+ }
967
+ function similarity(a, b) {
968
+ const full = jaccard(a.shingles, b.shingles);
969
+ const body = a.bodyShingles !== null && b.bodyShingles !== null ? jaccard(a.bodyShingles, b.bodyShingles) : 0;
970
+ return Math.max(full, body);
971
+ }
972
+ var duplicateCheck = {
973
+ name: "duplicate",
974
+ async run(ctx) {
975
+ const findings = [];
976
+ const contentCache = /* @__PURE__ */ new Map();
977
+ const read = (path) => {
978
+ let content = contentCache.get(path);
979
+ if (content === void 0) {
980
+ try {
981
+ content = readFileSync5(join6(ctx.repoRoot, path), "utf8");
982
+ } catch {
983
+ content = null;
984
+ }
985
+ contentCache.set(path, content);
986
+ }
987
+ return content;
988
+ };
989
+ const candidates = [];
990
+ for (const file of ctx.files) {
991
+ if (file.status === "deleted" || !CODE_FILE.test(file.path) || TEST_FILE.test(file.path)) continue;
992
+ if (file.added.size === 0) continue;
993
+ const content = read(file.path);
994
+ if (content === null) continue;
995
+ for (const fn of extractFunctions(file.path, content)) {
996
+ if (file.added.has(fn.start)) candidates.push(fn);
997
+ }
998
+ }
999
+ if (candidates.length === 0) return findings;
1000
+ const index = [];
1001
+ for (const path of walkCodeFiles(ctx.repoRoot)) {
1002
+ if (TEST_FILE.test(path)) continue;
1003
+ const content = read(path);
1004
+ if (content !== null) index.push(...extractFunctions(path, content));
1005
+ }
1006
+ const reportedPairs = /* @__PURE__ */ new Set();
1007
+ for (const candidate of candidates) {
1008
+ let best = null;
1009
+ let bestScore = 0;
1010
+ for (const existing of index) {
1011
+ if (existing.file === candidate.file && existing.start === candidate.start) continue;
1012
+ const score = similarity(candidate, existing);
1013
+ if (score > bestScore) {
1014
+ bestScore = score;
1015
+ best = existing;
1016
+ }
1017
+ }
1018
+ if (best === null || bestScore < SIMILARITY_THRESHOLD) continue;
1019
+ const pairKey = [`${candidate.file}:${candidate.start}`, `${best.file}:${best.start}`].sort().join("|");
1020
+ if (reportedPairs.has(pairKey)) continue;
1021
+ reportedPairs.add(pairKey);
1022
+ const pct = Math.round(bestScore * 100);
1023
+ findings.push({
1024
+ id: stableId("DUP", `${candidate.file}:${candidate.name}`),
1025
+ check: "duplicate",
1026
+ severity: "error",
1027
+ file: candidate.file,
1028
+ line: candidate.start,
1029
+ message: `${candidate.name}() is ${pct}% similar to ${best.name}() in ${best.file}:${best.start}`,
1030
+ fix: `reuse ${best.name} from ${best.file} \u2014 extend it there if the behavior differs`
1031
+ });
1032
+ }
1033
+ return findings;
1034
+ }
1035
+ };
1036
+
1037
+ // src/checks/standard.ts
1038
+ function globToRegex(glob) {
1039
+ const pattern = glob.includes("/") ? glob : `**/${glob}`;
1040
+ const segments = pattern.split("/");
1041
+ let out = "^";
1042
+ segments.forEach((segment, i) => {
1043
+ const last = i === segments.length - 1;
1044
+ if (segment === "**") {
1045
+ out += last ? ".*" : "(?:[^/]+/)*";
1046
+ } else {
1047
+ out += segment.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]") + (last ? "" : "/");
1048
+ }
1049
+ });
1050
+ return new RegExp(`${out}$`);
1051
+ }
1052
+ function compile(rule) {
1053
+ const pattern = rule.pattern !== void 0 ? new RegExp(rule.pattern) : null;
1054
+ const ban = rule.ban ?? null;
1055
+ return {
1056
+ rule,
1057
+ key: ban ?? rule.pattern ?? "",
1058
+ matches: (line) => ban !== null && line.includes(ban) || pattern !== null && pattern.test(line),
1059
+ scope: rule.in !== void 0 ? globToRegex(rule.in) : null
1060
+ };
1061
+ }
1062
+ function ruleFix(rule) {
1063
+ if (rule.use !== void 0 && rule.docs !== void 0) return `use ${rule.use} (see ${rule.docs})`;
1064
+ if (rule.use !== void 0) return `use ${rule.use}`;
1065
+ if (rule.docs !== void 0) return `see ${rule.docs}`;
1066
+ return void 0;
1067
+ }
1068
+ var standardCheck = {
1069
+ name: "standard",
1070
+ async run(ctx) {
1071
+ const rules = ctx.config.standards;
1072
+ if (rules === void 0 || rules.length === 0) return [];
1073
+ const compiled = rules.map(compile);
1074
+ const findings = [];
1075
+ for (const file of ctx.files) {
1076
+ if (file.status === "deleted" || file.added.size === 0) continue;
1077
+ for (const { rule, key, matches, scope } of compiled) {
1078
+ if (scope !== null ? !scope.test(file.path) : !CODE_FILE.test(file.path)) continue;
1079
+ let firstLine = null;
1080
+ let count = 0;
1081
+ for (const [lineNo, text] of file.added) {
1082
+ const trimmed = text.trim();
1083
+ if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) continue;
1084
+ if (!matches(text)) continue;
1085
+ count++;
1086
+ if (firstLine === null || lineNo < firstLine) firstLine = lineNo;
1087
+ }
1088
+ if (firstLine === null) continue;
1089
+ findings.push({
1090
+ id: stableId("STD", `${key}:${file.path}`),
1091
+ check: "standard",
1092
+ severity: rule.severity ?? "warn",
1093
+ file: file.path,
1094
+ line: firstLine,
1095
+ message: `"${key}" is banned in new code${count > 1 ? ` (${count}\xD7 in this file)` : ""}`,
1096
+ fix: ruleFix(rule)
1097
+ });
1098
+ }
1099
+ }
1100
+ return findings;
1101
+ }
1102
+ };
1103
+
1104
+ // src/checks/reuse.ts
1105
+ import { readFileSync as readFileSync6 } from "fs";
1106
+ import { join as join7 } from "path";
1107
+ var JSX_FILE2 = /\.(?:tsx|jsx)$/;
1108
+ var SIMILARITY_THRESHOLD2 = 0.8;
1109
+ var MIN_JSX_TOKENS = 25;
1110
+ var SHINGLE_K2 = 4;
1111
+ var JSX_BITS = /<\/?[A-Za-z][\w.]*|\/>|([A-Za-z][\w-]*)=(?=["'{])/g;
1112
+ function jsxTokens(code) {
1113
+ const out = [];
1114
+ for (const m of code.matchAll(JSX_BITS)) {
1115
+ out.push(m[1] !== void 0 ? `${m[1]}=` : m[0]);
1116
+ }
1117
+ return out;
1118
+ }
1119
+ function extractComponents(file, content) {
1120
+ const lines = content.split("\n");
1121
+ const decls = scanTopLevelDecls(lines);
1122
+ const comps = [];
1123
+ for (const decl of decls) {
1124
+ if (decl.isClass || !/^[A-Z]/.test(decl.name)) continue;
1125
+ const span = declSpan(lines, decl, decls);
1126
+ const tokens = jsxTokens(lines.slice(span.start - 1, span.end).join("\n"));
1127
+ if (tokens.length < MIN_JSX_TOKENS) continue;
1128
+ comps.push({ file, name: decl.name, start: span.start, shingles: shingles(tokens, SHINGLE_K2) });
1129
+ }
1130
+ return comps;
1131
+ }
1132
+ var reuseCheck = {
1133
+ name: "reuse",
1134
+ async run(ctx) {
1135
+ const findings = [];
1136
+ const contentCache = /* @__PURE__ */ new Map();
1137
+ const read = (path) => {
1138
+ let content = contentCache.get(path);
1139
+ if (content === void 0) {
1140
+ try {
1141
+ content = readFileSync6(join7(ctx.repoRoot, path), "utf8");
1142
+ } catch {
1143
+ content = null;
1144
+ }
1145
+ contentCache.set(path, content);
1146
+ }
1147
+ return content;
1148
+ };
1149
+ const candidates = [];
1150
+ for (const file of ctx.files) {
1151
+ if (file.status === "deleted" || !JSX_FILE2.test(file.path) || TEST_FILE.test(file.path)) continue;
1152
+ if (file.added.size === 0) continue;
1153
+ const content = read(file.path);
1154
+ if (content === null) continue;
1155
+ for (const comp of extractComponents(file.path, content)) {
1156
+ if (file.added.has(comp.start)) candidates.push(comp);
1157
+ }
1158
+ }
1159
+ if (candidates.length === 0) return findings;
1160
+ const index = [];
1161
+ for (const path of walkCodeFiles(ctx.repoRoot)) {
1162
+ if (!JSX_FILE2.test(path) || TEST_FILE.test(path)) continue;
1163
+ const content = read(path);
1164
+ if (content !== null) index.push(...extractComponents(path, content));
1165
+ }
1166
+ const reportedPairs = /* @__PURE__ */ new Set();
1167
+ for (const candidate of candidates) {
1168
+ let best = null;
1169
+ let bestScore = 0;
1170
+ for (const existing of index) {
1171
+ if (existing.file === candidate.file && existing.start === candidate.start) continue;
1172
+ const score = jaccard(candidate.shingles, existing.shingles);
1173
+ if (score > bestScore) {
1174
+ bestScore = score;
1175
+ best = existing;
1176
+ }
1177
+ }
1178
+ if (best === null || bestScore < SIMILARITY_THRESHOLD2) continue;
1179
+ const pairKey = [`${candidate.file}:${candidate.start}`, `${best.file}:${best.start}`].sort().join("|");
1180
+ if (reportedPairs.has(pairKey)) continue;
1181
+ reportedPairs.add(pairKey);
1182
+ findings.push({
1183
+ id: stableId("REUSE", `${candidate.file}:${candidate.name}`),
1184
+ check: "reuse",
1185
+ severity: "warn",
1186
+ file: candidate.file,
1187
+ line: candidate.start,
1188
+ message: `<${candidate.name}> markup is ${Math.round(bestScore * 100)}% similar to <${best.name}> (${best.file}:${best.start})`,
1189
+ fix: `reuse <${best.name}> from ${best.file} \u2014 extend it with props instead of forking the markup`
1190
+ });
1191
+ }
1192
+ return findings;
1193
+ }
1194
+ };
1195
+
1196
+ // src/checks/index.ts
1197
+ var checks = [
1198
+ phantomDepCheck,
1199
+ deletionCheck,
1200
+ duplicateCheck,
1201
+ reuseCheck,
1202
+ stubCheck,
1203
+ oversizeCheck,
1204
+ noTestCheck,
1205
+ unreferencedCheck,
1206
+ standardCheck
1207
+ ];
1208
+ var SEVERITY_RANK = { error: 0, warn: 1, info: 2 };
1209
+ async function runChecks(ctx) {
1210
+ const ignored = new Set(ctx.config.ignore);
1211
+ const findings = [];
1212
+ for (const check of checks) {
1213
+ const results = await check.run(ctx);
1214
+ for (const f of results) {
1215
+ if (!ignored.has(f.id)) findings.push(f);
1216
+ }
1217
+ }
1218
+ const byLocation = /* @__PURE__ */ new Map();
1219
+ const kept = [];
1220
+ for (const f of findings) {
1221
+ if (f.line === void 0) {
1222
+ kept.push(f);
1223
+ continue;
1224
+ }
1225
+ const key = `${f.file}:${f.line}`;
1226
+ const prev = byLocation.get(key);
1227
+ if (prev === void 0) {
1228
+ byLocation.set(key, f);
1229
+ kept.push(f);
1230
+ } else if (SEVERITY_RANK[f.severity] < SEVERITY_RANK[prev.severity]) {
1231
+ kept[kept.indexOf(prev)] = f;
1232
+ byLocation.set(key, f);
1233
+ }
1234
+ }
1235
+ return kept;
1236
+ }
1237
+
1238
+ // src/types.ts
1239
+ var DEFAULT_CONFIG = {
1240
+ maxFileLines: 400,
1241
+ maxComponentLines: 200,
1242
+ strict: false,
1243
+ standards: void 0,
1244
+ ignore: []
1245
+ };
1246
+
1247
+ // src/config.ts
1248
+ import { readFileSync as readFileSync7 } from "fs";
1249
+ import { join as join8 } from "path";
1250
+ var CONFIG_FILE = "aidiff.config.json";
1251
+ var ConfigError = class extends Error {
1252
+ };
1253
+ function fail(message) {
1254
+ throw new ConfigError(`${CONFIG_FILE}: ${message}`);
1255
+ }
1256
+ var SEVERITIES = ["error", "warn", "info"];
1257
+ function isPlainObject(v) {
1258
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1259
+ }
1260
+ function readPositiveInt(o, key) {
1261
+ const v = o[key];
1262
+ if (v === void 0) return void 0;
1263
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 1) fail(`"${key}" must be a positive integer`);
1264
+ return v;
1265
+ }
1266
+ function readStringArray(o, key) {
1267
+ const v = o[key];
1268
+ if (v === void 0) return void 0;
1269
+ if (!Array.isArray(v) || v.some((s) => typeof s !== "string")) fail(`"${key}" must be an array of strings`);
1270
+ return v;
1271
+ }
1272
+ function readStandards(o) {
1273
+ const v = o["standards"];
1274
+ if (v === void 0) return void 0;
1275
+ if (!Array.isArray(v)) fail('"standards" must be an array of rules');
1276
+ return v.map((raw, i) => {
1277
+ const at = `standards[${i}]`;
1278
+ if (!isPlainObject(raw)) fail(`${at} must be an object`);
1279
+ const rule = {};
1280
+ for (const key of ["ban", "pattern", "in", "use", "docs"]) {
1281
+ const field = raw[key];
1282
+ if (field === void 0) continue;
1283
+ if (typeof field !== "string" || field === "") fail(`${at}.${key} must be a non-empty string`);
1284
+ rule[key] = field;
1285
+ }
1286
+ if (rule.ban === void 0 && rule.pattern === void 0) fail(`${at} needs a "ban" or a "pattern"`);
1287
+ if (rule.pattern !== void 0) {
1288
+ try {
1289
+ new RegExp(rule.pattern);
1290
+ } catch {
1291
+ fail(`${at}.pattern is not a valid regex: ${rule.pattern}`);
1292
+ }
1293
+ }
1294
+ const severity = raw["severity"];
1295
+ if (severity !== void 0) {
1296
+ if (typeof severity !== "string" || !SEVERITIES.includes(severity)) {
1297
+ fail(`${at}.severity must be one of ${SEVERITIES.join(" | ")}`);
1298
+ }
1299
+ rule.severity = severity;
1300
+ }
1301
+ return rule;
1302
+ });
1303
+ }
1304
+ function loadConfig(repoRoot) {
1305
+ let raw;
1306
+ try {
1307
+ raw = readFileSync7(join8(repoRoot, CONFIG_FILE), "utf8");
1308
+ } catch (err) {
1309
+ if (err.code === "ENOENT") return { ...DEFAULT_CONFIG };
1310
+ fail(`could not be read: ${err.message}`);
1311
+ }
1312
+ let parsed;
1313
+ try {
1314
+ parsed = JSON.parse(raw);
1315
+ } catch (err) {
1316
+ fail(`is not valid JSON \u2014 ${err.message}`);
1317
+ }
1318
+ if (!isPlainObject(parsed)) fail("must be a JSON object");
1319
+ const strict = parsed["strict"];
1320
+ if (strict !== void 0 && typeof strict !== "boolean") fail('"strict" must be a boolean');
1321
+ const user = {
1322
+ maxFileLines: readPositiveInt(parsed, "maxFileLines"),
1323
+ maxComponentLines: readPositiveInt(parsed, "maxComponentLines"),
1324
+ strict,
1325
+ ignore: readStringArray(parsed, "ignore"),
1326
+ standards: readStandards(parsed)
1327
+ };
1328
+ return {
1329
+ maxFileLines: user.maxFileLines ?? DEFAULT_CONFIG.maxFileLines,
1330
+ maxComponentLines: user.maxComponentLines ?? DEFAULT_CONFIG.maxComponentLines,
1331
+ strict: user.strict ?? DEFAULT_CONFIG.strict,
1332
+ standards: user.standards,
1333
+ ignore: user.ignore ?? []
1334
+ };
1335
+ }
1336
+
1337
+ // src/commands.ts
1338
+ import { existsSync as existsSync2, readFileSync as readFileSync8, writeFileSync, appendFileSync, chmodSync } from "fs";
1339
+ import { join as join9 } from "path";
1340
+ var STARTER_CONFIG = `{
1341
+ "maxFileLines": 400,
1342
+ "maxComponentLines": 200,
1343
+ "strict": false,
1344
+ "ignore": [],
1345
+ "standards": []
1346
+ }
1347
+ `;
1348
+ var HOOK_LINE = "npx ai-diff-check";
1349
+ function runInit(repoRoot) {
1350
+ const out = [];
1351
+ const configPath = join9(repoRoot, CONFIG_FILE);
1352
+ if (existsSync2(configPath)) {
1353
+ out.push(`${CONFIG_FILE} already exists \u2014 left untouched`);
1354
+ } else {
1355
+ writeFileSync(configPath, STARTER_CONFIG);
1356
+ out.push(`wrote ${CONFIG_FILE}`);
1357
+ }
1358
+ const usesHusky = existsSync2(join9(repoRoot, ".husky"));
1359
+ const hooksDir = usesHusky ? join9(repoRoot, ".husky") : join9(repoRoot, ".git", "hooks");
1360
+ const label = usesHusky ? ".husky/pre-commit" : ".git/hooks/pre-commit";
1361
+ if (!existsSync2(hooksDir)) {
1362
+ out.push(`no .husky/ or .git/hooks/ found \u2014 add "${HOOK_LINE}" to your pre-commit hook manually`);
1363
+ return out;
1364
+ }
1365
+ const hookPath = join9(hooksDir, "pre-commit");
1366
+ if (!existsSync2(hookPath)) {
1367
+ writeFileSync(hookPath, `#!/bin/sh
1368
+ ${HOOK_LINE}
1369
+ `);
1370
+ chmodSync(hookPath, 493);
1371
+ out.push(`created ${label} \u2014 every commit now runs the vibe check`);
1372
+ } else if (readFileSync8(hookPath, "utf8").includes("ai-diff-check")) {
1373
+ out.push(`${label} already runs ai-diff-check \u2014 left untouched`);
1374
+ } else {
1375
+ appendFileSync(hookPath, `
1376
+ ${HOOK_LINE}
1377
+ `);
1378
+ out.push(`added "${HOOK_LINE}" to your existing ${label}`);
1379
+ }
1380
+ return out;
1381
+ }
1382
+ function addIgnore(repoRoot, id) {
1383
+ const configPath = join9(repoRoot, CONFIG_FILE);
1384
+ let config = {};
1385
+ if (existsSync2(configPath)) {
1386
+ try {
1387
+ config = JSON.parse(readFileSync8(configPath, "utf8"));
1388
+ } catch {
1389
+ throw new ConfigError(`${CONFIG_FILE} is not valid JSON \u2014 fix it before adding ignores`);
1390
+ }
1391
+ }
1392
+ const ignore = Array.isArray(config["ignore"]) ? config["ignore"] : [];
1393
+ if (ignore.includes(id)) return `${id} is already on the ignore list`;
1394
+ config["ignore"] = [...ignore, id];
1395
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}
1396
+ `);
1397
+ return `${id} added to the ignore list in ${CONFIG_FILE}`;
1398
+ }
1399
+
1400
+ // src/mcp.ts
1401
+ import { createInterface } from "readline";
1402
+ var PROTOCOL_FALLBACK = "2025-03-26";
1403
+ var VIBE_CHECK_TOOL = {
1404
+ name: "vibe_check",
1405
+ description: "Review the current git diff (uncommitted/branch changes only \u2014 never the whole repo) for AI-generated-code issues: hallucinated or undeclared imports, functions duplicating existing code, deleted error handling/guards/tests, leftover stubs (console.log, TODO, empty catch), oversized files/components, logic changes with untouched tests, unreferenced new exports, and violations of the project's own house rules. Call this after making code changes and fix everything it reports before declaring the task done.",
1406
+ inputSchema: {
1407
+ type: "object",
1408
+ properties: {
1409
+ base: { type: "string", description: "git ref to diff against (default: main/master, then origin/*)" },
1410
+ repo: { type: "string", description: "path inside the repository to check (default: server working directory)" }
1411
+ },
1412
+ additionalProperties: false
1413
+ }
1414
+ };
1415
+ async function vibeCheck(args) {
1416
+ const repoRoot = findRepoRoot(args.repo ?? process.cwd());
1417
+ const config = loadConfig(repoRoot);
1418
+ const baseRef = resolveBaseRef(repoRoot, args.base);
1419
+ const files = getChangedFiles(repoRoot, baseRef);
1420
+ const findings = sortFindings(await runChecks({ repoRoot, baseRef, files, config }));
1421
+ const stats = diffStats(files);
1422
+ const errors = findings.filter((f) => f.severity === "error").length;
1423
+ const warns = findings.filter((f) => f.severity === "warn").length;
1424
+ const header = `Trust score ${trustScore(findings, stats)}/100 \u2014 ${errors} error(s), ${warns} warning(s) across ${stats.files} changed file(s) (diff vs ${baseRef}).`;
1425
+ return `${header}
1426
+
1427
+ ${renderFixPrompt(findings)}`;
1428
+ }
1429
+ function runMcpServer(version) {
1430
+ const write = (msg) => {
1431
+ process.stdout.write(`${JSON.stringify(msg)}
1432
+ `);
1433
+ };
1434
+ const reply = (id, result) => write({ jsonrpc: "2.0", id, result });
1435
+ const replyError = (id, code, message) => write({ jsonrpc: "2.0", id, error: { code, message } });
1436
+ const handle = async (msg) => {
1437
+ const { id, method, params } = msg;
1438
+ if (method === void 0) return;
1439
+ if (id === void 0) return;
1440
+ switch (method) {
1441
+ case "initialize": {
1442
+ const requested = params?.["protocolVersion"];
1443
+ reply(id, {
1444
+ protocolVersion: typeof requested === "string" ? requested : PROTOCOL_FALLBACK,
1445
+ capabilities: { tools: {} },
1446
+ serverInfo: { name: "ai-diff-check", version }
1447
+ });
1448
+ return;
1449
+ }
1450
+ case "ping":
1451
+ reply(id, {});
1452
+ return;
1453
+ case "tools/list":
1454
+ reply(id, { tools: [VIBE_CHECK_TOOL] });
1455
+ return;
1456
+ case "tools/call": {
1457
+ if (params?.["name"] !== VIBE_CHECK_TOOL.name) {
1458
+ replyError(id, -32602, `Unknown tool: ${String(params?.["name"])}`);
1459
+ return;
1460
+ }
1461
+ try {
1462
+ const text = await vibeCheck(params?.["arguments"] ?? {});
1463
+ reply(id, { content: [{ type: "text", text }], isError: false });
1464
+ } catch (err) {
1465
+ const known = err instanceof NotAGitRepoError || err instanceof ConfigError;
1466
+ const text = known ? err.message : `vibe_check failed: ${err.message}`;
1467
+ reply(id, { content: [{ type: "text", text }], isError: true });
1468
+ }
1469
+ return;
1470
+ }
1471
+ default:
1472
+ replyError(id, -32601, `Method not found: ${method}`);
1473
+ }
1474
+ };
1475
+ const rl = createInterface({ input: process.stdin });
1476
+ rl.on("line", (line) => {
1477
+ const trimmed = line.trim();
1478
+ if (trimmed === "") return;
1479
+ let msg;
1480
+ try {
1481
+ msg = JSON.parse(trimmed);
1482
+ } catch {
1483
+ replyError(null, -32700, "Parse error");
1484
+ return;
1485
+ }
1486
+ void handle(msg);
1487
+ });
1488
+ rl.on("close", () => process.exit(0));
1489
+ }
1490
+
1491
+ export {
1492
+ NotAGitRepoError,
1493
+ findRepoRoot,
1494
+ resolveBaseRef,
1495
+ getChangedFiles,
1496
+ parseUnifiedDiff,
1497
+ diffStats,
1498
+ sortFindings,
1499
+ renderHeader,
1500
+ renderDiffLine,
1501
+ renderFindings,
1502
+ renderSummary,
1503
+ trustScore,
1504
+ githubAnnotations,
1505
+ renderFixPrompt,
1506
+ stubCheck,
1507
+ oversizeCheck,
1508
+ phantomDepCheck,
1509
+ deletionCheck,
1510
+ noTestCheck,
1511
+ unreferencedCheck,
1512
+ duplicateCheck,
1513
+ standardCheck,
1514
+ reuseCheck,
1515
+ checks,
1516
+ runChecks,
1517
+ DEFAULT_CONFIG,
1518
+ CONFIG_FILE,
1519
+ ConfigError,
1520
+ loadConfig,
1521
+ runInit,
1522
+ addIgnore,
1523
+ runMcpServer
1524
+ };
1525
+ //# sourceMappingURL=chunk-7JKSFHII.js.map