aislop 0.6.2 → 0.8.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.
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { createRequire } from "node:module";
2
+ import { createRequire, isBuiltin } from "node:module";
3
3
  import { Command } from "commander";
4
4
  import os from "node:os";
5
5
  import fs from "node:fs";
@@ -7,7 +7,7 @@ import path from "node:path";
7
7
  import YAML from "yaml";
8
8
  import { z } from "zod/v4";
9
9
  import { performance } from "node:perf_hooks";
10
- import { spawn, spawnSync } from "node:child_process";
10
+ import { execSync, spawn, spawnSync } from "node:child_process";
11
11
  import micromatch from "micromatch";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import ts from "typescript";
@@ -34,7 +34,11 @@ var __exportAll = (all, no_symbols) => {
34
34
 
35
35
  //#endregion
36
36
  //#region src/hooks/feedback.ts
37
+ const fingerprintFinding = (f) => `${f.file}:${f.line}:${f.ruleId}`;
37
38
  const MAX_FINDINGS = 20;
39
+ const MAX_NEW_SINCE_BASELINE = 10;
40
+ const REVIEW_TOP_N = 3;
41
+ const REGRESSION_FLAG_THRESHOLD = 5;
38
42
  const toFinding = (d, rootDirectory) => {
39
43
  if (d.severity !== "error" && d.severity !== "warning") return null;
40
44
  const file = path.isAbsolute(d.filePath) ? path.relative(rootDirectory, d.filePath) : d.filePath;
@@ -64,6 +68,42 @@ const buildNextSteps = (findings) => {
64
68
  }
65
69
  return steps;
66
70
  };
71
+ const buildSuggestedActions = (diagnostics, findings, regressed, delta) => {
72
+ const actions = [];
73
+ const fixableDiags = diagnostics.filter((d) => d.fixable);
74
+ if (fixableDiags.length > 0) {
75
+ const ruleIds = Array.from(new Set(fixableDiags.map((d) => d.rule)));
76
+ actions.push({
77
+ id: "run_aislop_fix",
78
+ label: `Run aislop fix to clear ${fixableDiags.length} mechanical finding${fixableDiags.length === 1 ? "" : "s"}.`,
79
+ command: "npx aislop fix",
80
+ rationale: "These findings have deterministic fixes (formatting, unused imports, trivial comments). Running this before any manual work avoids burning agent tokens on what the CLI handles for free.",
81
+ ruleIds
82
+ });
83
+ }
84
+ const archErrors = findings.filter((f) => f.ruleId.startsWith("arch/") && f.severity === "error");
85
+ if (archErrors.length > 0) actions.push({
86
+ id: "review_finding",
87
+ label: `Review ${archErrors.length} architecture rule violation${archErrors.length === 1 ? "" : "s"} — these can't be auto-fixed.`,
88
+ rationale: "Architecture rules encode intentional project structure decisions. The fix usually means moving code, not editing it.",
89
+ ruleIds: Array.from(new Set(archErrors.map((f) => f.ruleId)))
90
+ });
91
+ if (regressed && typeof delta === "number" && delta <= -REGRESSION_FLAG_THRESHOLD && fixableDiags.length === 0) {
92
+ const top = findings.filter((f) => f.severity === "error" || f.severity === "warning").slice(0, REVIEW_TOP_N);
93
+ if (top.length > 0) actions.push({
94
+ id: "review_finding",
95
+ label: `Score dropped ${Math.abs(delta)} points — review the top ${top.length} finding${top.length === 1 ? "" : "s"} from this edit.`,
96
+ rationale: "None of these are auto-fixable. Read each one against the source and decide whether the fix is to change the code or to add a justified suppression with a reason.",
97
+ ruleIds: top.map((f) => f.ruleId)
98
+ });
99
+ }
100
+ if (actions.length === 0) actions.push({
101
+ id: "no_action",
102
+ label: typeof delta === "number" ? delta > 0 ? `Score improved by ${delta}. No action needed.` : "Score unchanged. No action needed." : "No findings. No action needed.",
103
+ rationale: "The current scan didn't reveal anything that requires the agent's attention."
104
+ });
105
+ return actions;
106
+ };
67
107
  const buildFeedback = (diagnostics, score, rootDirectory, baseline) => {
68
108
  const all = diagnostics.map((d) => toFinding(d, rootDirectory)).filter((x) => x !== null);
69
109
  const capped = all.slice(0, MAX_FINDINGS);
@@ -74,15 +114,30 @@ const buildFeedback = (diagnostics, score, rootDirectory, baseline) => {
74
114
  fixable: diagnostics.filter((d) => d.fixable).length,
75
115
  total: all.length
76
116
  };
117
+ const baselineSnapshot = typeof baseline === "number" ? {
118
+ score: baseline,
119
+ findingFingerprints: []
120
+ } : baseline;
121
+ const baselineScore = baselineSnapshot?.score;
122
+ const delta = typeof baselineScore === "number" ? score - baselineScore : void 0;
123
+ const regressed = typeof delta === "number" ? delta < 0 : false;
124
+ let newSinceBaseline;
125
+ if (baselineSnapshot && baselineSnapshot.findingFingerprints.length > 0) {
126
+ const known = new Set(baselineSnapshot.findingFingerprints);
127
+ newSinceBaseline = all.filter((f) => !known.has(fingerprintFinding(f))).slice(0, MAX_NEW_SINCE_BASELINE);
128
+ }
77
129
  return {
78
- schema: "aislop.hook.v1",
130
+ schema: "aislop.hook.v2",
79
131
  score,
80
- baseline,
81
- regressed: typeof baseline === "number" ? score < baseline : false,
132
+ baseline: baselineScore,
133
+ delta,
134
+ regressed,
82
135
  counts,
83
136
  findings: capped,
84
137
  elided,
85
- nextSteps: buildNextSteps(capped)
138
+ newSinceBaseline,
139
+ nextSteps: buildNextSteps(capped),
140
+ suggestedActions: buildSuggestedActions(diagnostics, capped, regressed, delta)
86
141
  };
87
142
  };
88
143
 
@@ -147,6 +202,7 @@ const DEFAULT_CONFIG = {
147
202
  maxNesting: 5,
148
203
  maxParams: 6
149
204
  },
205
+ lint: { typecheck: false },
150
206
  security: {
151
207
  audit: true,
152
208
  auditTimeout: 25e3
@@ -209,6 +265,48 @@ const DEFAULT_RULES_YAML = `# Architecture rules (BYO)
209
265
  # severity: error
210
266
  `;
211
267
 
268
+ //#endregion
269
+ //#region src/config/extends.ts
270
+ const MAX_DEPTH = 5;
271
+ const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
272
+ const deepMerge = (...sources) => {
273
+ const result = {};
274
+ for (const source of sources) for (const key of Object.keys(source)) {
275
+ const a = result[key];
276
+ const b = source[key];
277
+ result[key] = isPlainObject(a) && isPlainObject(b) ? deepMerge(a, b) : b;
278
+ }
279
+ return result;
280
+ };
281
+ const resolveExtendsRef = (ref, fromDir) => {
282
+ if (ref.startsWith("http://") || ref.startsWith("https://")) throw new Error(`URL-based extends not yet supported: ${ref}`);
283
+ if (ref.startsWith("./") || ref.startsWith("../") || path.isAbsolute(ref)) return path.resolve(fromDir, ref);
284
+ throw new Error(`Package-name extends not yet supported: ${ref} (use a relative path for now)`);
285
+ };
286
+ const normalizeExtends = (raw) => {
287
+ if (raw === void 0 || raw === null) return [];
288
+ if (typeof raw === "string") return [raw];
289
+ if (Array.isArray(raw) && raw.every((s) => typeof s === "string")) return raw;
290
+ throw new Error("`extends` must be a string or array of strings");
291
+ };
292
+ const loadConfigChain = (configPath, visited = /* @__PURE__ */ new Set(), depth = 0) => {
293
+ if (depth > MAX_DEPTH) throw new Error(`extends depth exceeded ${MAX_DEPTH} (cycle or runaway chain): ${configPath}`);
294
+ const absPath = path.resolve(configPath);
295
+ if (visited.has(absPath)) throw new Error(`circular extends detected: ${absPath}`);
296
+ if (!fs.existsSync(absPath)) throw new Error(`extends target not found: ${absPath}`);
297
+ const nextVisited = new Set(visited);
298
+ nextVisited.add(absPath);
299
+ const raw = fs.readFileSync(absPath, "utf-8");
300
+ const parsed = YAML.parse(raw) ?? {};
301
+ const refs = normalizeExtends(parsed.extends);
302
+ const fromDir = path.dirname(absPath);
303
+ const parents = refs.map((ref) => {
304
+ return loadConfigChain(resolveExtendsRef(ref, fromDir), nextVisited, depth + 1);
305
+ });
306
+ const { extends: _drop, ...own } = parsed;
307
+ return deepMerge(...parents, own);
308
+ };
309
+
212
310
  //#endregion
213
311
  //#region src/config/schema.ts
214
312
  const DEFAULT_WEIGHTS = {
@@ -233,6 +331,7 @@ const QualitySchema = z.object({
233
331
  maxNesting: z.number().positive().default(5),
234
332
  maxParams: z.number().positive().default(6)
235
333
  });
334
+ const LintConfigSchema = z.object({ typecheck: z.boolean().default(false) });
236
335
  const SecurityConfigSchema = z.object({
237
336
  audit: z.boolean().default(true),
238
337
  auditTimeout: z.number().positive().default(25e3)
@@ -270,6 +369,7 @@ const AislopConfigSchema = z.object({
270
369
  maxNesting: 5,
271
370
  maxParams: 6
272
371
  })),
372
+ lint: LintConfigSchema.default(() => ({ typecheck: false })),
273
373
  security: SecurityConfigSchema.default(() => ({
274
374
  audit: true,
275
375
  auditTimeout: 25e3
@@ -343,8 +443,7 @@ const loadConfig = (directory) => {
343
443
  const configPath = path.join(configDir, CONFIG_FILE);
344
444
  if (!fs.existsSync(configPath)) return DEFAULT_CONFIG;
345
445
  try {
346
- const raw = fs.readFileSync(configPath, "utf-8");
347
- return parseConfig(YAML.parse(raw));
446
+ return parseConfig(loadConfigChain(configPath));
348
447
  } catch (error) {
349
448
  const msg = error instanceof Error ? error.message : String(error);
350
449
  process.stderr.write(` ⚠ Failed to parse ${configPath}: ${msg}\n ⚠ Using default configuration.\n`);
@@ -423,7 +522,7 @@ const hasAllowedExtension = (filePath, extraExtensions) => {
423
522
  return SOURCE_EXTENSIONS.has(extension) || extraExtensions.has(extension);
424
523
  };
425
524
  const isExcludedPath = (filePath) => EXCLUDED_DIRS.some((dir) => filePath === dir || filePath.startsWith(`${dir}/`) || filePath.includes(`/${dir}/`));
426
- const isTestFile = (filePath) => TEST_FILE_PATTERNS.some((pattern) => pattern.test(filePath));
525
+ const isTestFile$2 = (filePath) => TEST_FILE_PATTERNS.some((pattern) => pattern.test(filePath));
427
526
  const getIgnoredPaths = (rootDirectory, files) => {
428
527
  if (files.length === 0) return /* @__PURE__ */ new Set();
429
528
  const result = spawnSync("git", [
@@ -497,7 +596,7 @@ const filterProjectFiles = (rootDirectory, files, extraExtensions = [], exclude
497
596
  return micromatch.isMatch(relativePath, normalizedExcludePatterns, { dot: true });
498
597
  };
499
598
  return normalizedFiles.filter(({ absolutePath, relativePath }) => {
500
- return hasAllowedExtension(relativePath, extraSet) && !isExcludedPath(relativePath) && !isTestFile(relativePath) && !ignoredPaths.has(relativePath) && !isUserExcluded(relativePath) && fs.existsSync(absolutePath);
599
+ return hasAllowedExtension(relativePath, extraSet) && !isExcludedPath(relativePath) && !isTestFile$2(relativePath) && !ignoredPaths.has(relativePath) && !isUserExcluded(relativePath) && fs.existsSync(absolutePath);
501
600
  }).map(({ absolutePath }) => absolutePath);
502
601
  };
503
602
  const filterExplicitFiles = (rootDirectory, files, extraExtensions = []) => {
@@ -633,13 +732,14 @@ const detectOverAbstraction = async (context) => {
633
732
 
634
733
  //#endregion
635
734
  //#region src/engines/ai-slop/comments.ts
735
+ const NON_PRODUCTION_DIR_PATTERN$2 = /(?:^|\/)(?:scripts|bin|examples?|demos?|bench|benches|benchmarks?|fixtures?|__fixtures__|__mocks__|__tests__|vendor|_vendor|vendored|third_party|blib2to3|lib2to3)\//i;
636
736
  const TRIVIAL_VERB_STEMS = "Import|Defin|Initializ|Setting|Set\\s+up|Setup|Return|Check|Loop|Iterat|Creat|Updat|Delet|Remov|Handl|Get|Fetch|Increment|Decrement|Writ|Runn|Run|Pars|Execut|Extract|Sav|Load|Build|Start|Stopp|Stop|Clean(?:up|\\s+up)?|Configur|Validat|Process|Queue|Fire|Emit|Dispatch|Log|Print|Render";
637
737
  const TRIVIAL_JS_COMMENT_PATTERNS = [/\/\/\s*This (?:function|method|class|variable|constant) (?:will |is used to |is responsible for )?/i, new RegExp(`\\/\\/\\s*(?:${TRIVIAL_VERB_STEMS})(?:e|es|ing|s)?\\b`, "i")];
638
738
  const TRIVIAL_PYTHON_COMMENT_PATTERNS = [/^#\s*This (?:function|method|class) (?:will |is used to )?/i, new RegExp(`^#\\s*(?:${TRIVIAL_VERB_STEMS})(?:e|es|ing|s)?\\b`, "i")];
639
739
  const EXPLANATORY_KEYWORDS = /\b(?:because|since|note|todo|fixme|hack|warn|warning|workaround|caveat|important|assumes?)\b/i;
640
740
  const COMMENTED_CODE_CHARS = /[({=;}\]>]/;
641
741
  const MAX_TRIVIAL_COMMENT_LENGTH = 60;
642
- const isJsComment = (trimmed) => trimmed.startsWith("//");
742
+ const isJsComment = (trimmed) => trimmed.startsWith("//") && !trimmed.startsWith("///") && !trimmed.startsWith("//!");
643
743
  const isPythonComment = (trimmed) => trimmed.startsWith("#") && !trimmed.startsWith("#!");
644
744
  /**
645
745
  * Extract just the comment text after the comment marker.
@@ -688,13 +788,14 @@ const detectTrivialComments = async (context) => {
688
788
  const diagnostics = [];
689
789
  for (const filePath of files) {
690
790
  if (isAutoGenerated(filePath)) continue;
791
+ const relativePath = path.relative(context.rootDirectory, filePath);
792
+ if (NON_PRODUCTION_DIR_PATTERN$2.test(relativePath)) continue;
691
793
  let content;
692
794
  try {
693
795
  content = fs.readFileSync(filePath, "utf-8");
694
796
  } catch {
695
797
  continue;
696
798
  }
697
- const relativePath = path.relative(context.rootDirectory, filePath);
698
799
  diagnostics.push(...scanFileForTrivialComments(content, relativePath));
699
800
  }
700
801
  return diagnostics;
@@ -702,7 +803,7 @@ const detectTrivialComments = async (context) => {
702
803
 
703
804
  //#endregion
704
805
  //#region src/engines/ai-slop/dead-patterns.ts
705
- const JS_EXTENSIONS$1 = new Set([
806
+ const JS_EXTENSIONS$4 = new Set([
706
807
  ".ts",
707
808
  ".tsx",
708
809
  ".js",
@@ -724,11 +825,11 @@ const slop = (filePath, line, rule, severity, message, help, fixable) => ({
724
825
  fixable
725
826
  });
726
827
  const LOGGER_FILE_PATTERN = /(?:^|\/)(?:logger|logging|log)\.[^/]+$/i;
727
- const SCRIPT_DIR_PATTERN = /(?:^|\/)(scripts|bin)\//;
828
+ const NON_PRODUCTION_DIR_PATTERN$1 = /(?:^|\/)(?:scripts|bin|examples?|demos?|bench|benches|benchmarks?|fixtures?|__fixtures__|__mocks__|__tests__|cli|cli-[\w-]+|[\w-]+-cli)\//;
728
829
  const detectConsoleLeftovers = (content, relativePath, ext) => {
729
- if (!JS_EXTENSIONS$1.has(ext)) return [];
830
+ if (!JS_EXTENSIONS$4.has(ext)) return [];
730
831
  if (LOGGER_FILE_PATTERN.test(relativePath)) return [];
731
- if (SCRIPT_DIR_PATTERN.test(relativePath)) return [];
832
+ if (NON_PRODUCTION_DIR_PATTERN$1.test(relativePath)) return [];
732
833
  const diagnostics = [];
733
834
  const lines = content.split("\n");
734
835
  for (let i = 0; i < lines.length; i++) {
@@ -768,9 +869,9 @@ const detectDeadCodePatterns = (content, relativePath, ext) => {
768
869
  for (let i = 0; i < lines.length; i++) {
769
870
  const trimmed = lines[i].trim();
770
871
  const nextLine = i + 1 < lines.length ? lines[i + 1]?.trim() : void 0;
771
- if (JS_EXTENSIONS$1.has(ext) && /^(?:return|throw)\b/.test(trimmed) && trimmed.endsWith(";") && nextLine && nextLine.length > 0 && !nextLine.startsWith("}") && !nextLine.startsWith("//") && !nextLine.startsWith("/*") && !nextLine.startsWith("case ") && !nextLine.startsWith("default:") && !nextLine.startsWith("if ") && !nextLine.startsWith("if(") && !nextLine.startsWith("else")) diagnostics.push(slop(relativePath, i + 2, "ai-slop/unreachable-code", "warning", "Code after return/throw statement is unreachable", "Remove the unreachable code or restructure the control flow", false));
872
+ if (JS_EXTENSIONS$4.has(ext) && /^(?:return|throw)\b/.test(trimmed) && trimmed.endsWith(";") && nextLine && nextLine.length > 0 && !nextLine.startsWith("}") && !nextLine.startsWith("//") && !nextLine.startsWith("/*") && !nextLine.startsWith("case ") && !nextLine.startsWith("default:") && !nextLine.startsWith("if ") && !nextLine.startsWith("if(") && !nextLine.startsWith("else")) diagnostics.push(slop(relativePath, i + 2, "ai-slop/unreachable-code", "warning", "Code after return/throw statement is unreachable", "Remove the unreachable code or restructure the control flow", false));
772
873
  if (/\bif\s*\(\s*(?:false|true|0|1)\s*\)/.test(trimmed) && !trimmed.startsWith("//") && !trimmed.startsWith("*") && !/["'`].*\bif\s*\(/.test(trimmed) && !/\/.*\bif\s*\(/.test(trimmed.replace(/\/\/.*$/, ""))) diagnostics.push(slop(relativePath, i + 1, "ai-slop/constant-condition", "warning", "Conditional with a constant value — likely debugging leftover", "Remove the constant condition or replace with proper logic", false));
773
- if (JS_EXTENSIONS$1.has(ext) && /(?:function\s+\w+|=>\s*)\s*\{\s*\}\s*;?\s*$/.test(trimmed) && !trimmed.startsWith("interface") && !trimmed.startsWith("type ")) diagnostics.push(slop(relativePath, i + 1, "ai-slop/empty-function", "info", "Empty function body — possible stub or unfinished implementation", "Implement the function body or add a comment explaining why it's empty", false));
874
+ if (JS_EXTENSIONS$4.has(ext) && /(?:function\s+\w+|=>\s*)\s*\{\s*\}\s*;?\s*$/.test(trimmed) && !trimmed.startsWith("interface") && !trimmed.startsWith("type ")) diagnostics.push(slop(relativePath, i + 1, "ai-slop/empty-function", "info", "Empty function body — possible stub or unfinished implementation", "Implement the function body or add a comment explaining why it's empty", false));
774
875
  }
775
876
  return diagnostics;
776
877
  };
@@ -778,6 +879,7 @@ const asAnyPattern = new RegExp(`\\bas\\s+any\\b`);
778
879
  const doubleAssertPattern = new RegExp(`\\bas\\s+unknown\\s+as\\s+`);
779
880
  const detectUnsafeTypePatterns = (content, relativePath, ext) => {
780
881
  if (ext !== ".ts" && ext !== ".tsx") return [];
882
+ if (NON_PRODUCTION_DIR_PATTERN$1.test(relativePath)) return [];
781
883
  const diagnostics = [];
782
884
  const lines = content.split("\n");
783
885
  for (let i = 0; i < lines.length; i++) {
@@ -814,6 +916,74 @@ const detectDeadPatterns = async (context) => {
814
916
  return diagnostics;
815
917
  };
816
918
 
919
+ //#endregion
920
+ //#region src/engines/ai-slop/duplicate-imports.ts
921
+ const JS_EXTENSIONS$3 = new Set([
922
+ ".ts",
923
+ ".tsx",
924
+ ".js",
925
+ ".jsx",
926
+ ".mjs",
927
+ ".cjs"
928
+ ]);
929
+ const IMPORT_FROM_RE$1 = /^\s*import\s+[^;]*?from\s+["']([^"']+)["']/;
930
+ const extractImportLines = (content) => {
931
+ const lines = content.split("\n");
932
+ const results = [];
933
+ for (let i = 0; i < lines.length; i++) {
934
+ const line = lines[i];
935
+ const match = IMPORT_FROM_RE$1.exec(line);
936
+ if (!match) continue;
937
+ results.push({
938
+ spec: match[1],
939
+ line: i + 1
940
+ });
941
+ }
942
+ return results;
943
+ };
944
+ const detectDuplicateImports = async (context) => {
945
+ const diagnostics = [];
946
+ const files = getSourceFiles(context);
947
+ for (const filePath of files) {
948
+ if (!JS_EXTENSIONS$3.has(path.extname(filePath))) continue;
949
+ if (isAutoGenerated(filePath)) continue;
950
+ let content;
951
+ try {
952
+ content = fs.readFileSync(filePath, "utf-8");
953
+ } catch {
954
+ continue;
955
+ }
956
+ const imports = extractImportLines(content);
957
+ if (imports.length < 2) continue;
958
+ const bySpec = /* @__PURE__ */ new Map();
959
+ for (const imp of imports) {
960
+ const list = bySpec.get(imp.spec) ?? [];
961
+ list.push(imp);
962
+ bySpec.set(imp.spec, list);
963
+ }
964
+ const relPath = path.relative(context.rootDirectory, filePath);
965
+ for (const [spec, occurrences] of bySpec) {
966
+ if (occurrences.length < 2) continue;
967
+ for (const dup of occurrences.slice(1)) {
968
+ const firstLine = occurrences[0].line;
969
+ diagnostics.push({
970
+ filePath: relPath,
971
+ engine: "ai-slop",
972
+ rule: "ai-slop/duplicate-import",
973
+ severity: "warning",
974
+ message: `"${spec}" is also imported on line ${firstLine}. Merge into a single import statement.`,
975
+ help: "Two imports from the same module split readers' attention and grow the import block. Run aislop fix to merge them automatically.",
976
+ line: dup.line,
977
+ column: 1,
978
+ category: "AI Slop",
979
+ fixable: true
980
+ });
981
+ }
982
+ }
983
+ }
984
+ return diagnostics;
985
+ };
986
+
817
987
  //#endregion
818
988
  //#region src/engines/ai-slop/exceptions.ts
819
989
  const SWALLOWED_EXCEPTION_PATTERNS = [
@@ -904,6 +1074,600 @@ const detectSwallowedExceptions = async (context) => {
904
1074
  return diagnostics;
905
1075
  };
906
1076
 
1077
+ //#endregion
1078
+ //#region src/engines/ai-slop/go-patterns.ts
1079
+ const GO_EXTENSIONS = new Set([".go"]);
1080
+ const PACKAGE_DECL_RE = /^\s*package\s+(\w+)/;
1081
+ const PANIC_CALL_RE = /\bpanic\s*\(/;
1082
+ const COMMENT_LINE_RE$1 = /^\s*\/\//;
1083
+ const NIL_GUARD_RE = /^\s*if\s+[\w.]+(?:\(\))?\s*==\s*nil\s*\{?\s*$/;
1084
+ const SHORT_STRING_PANIC_RE = /\bpanic\s*\(\s*"[^"]{1,40}"\s*\)/;
1085
+ const detectPackageName = (lines) => {
1086
+ for (const line of lines) {
1087
+ const m = PACKAGE_DECL_RE.exec(line);
1088
+ if (m) return m[1];
1089
+ }
1090
+ return null;
1091
+ };
1092
+ const PANIC_INTENT_LOOKBACK = 3;
1093
+ const hasIntentComment$1 = (lines, panicLineIdx) => {
1094
+ for (let j = panicLineIdx - 1; j >= Math.max(0, panicLineIdx - PANIC_INTENT_LOOKBACK); j--) if (COMMENT_LINE_RE$1.test(lines[j])) return true;
1095
+ return false;
1096
+ };
1097
+ const isNilGuardPanic = (lines, panicLineIdx, line) => {
1098
+ if (!SHORT_STRING_PANIC_RE.test(line)) return false;
1099
+ for (let j = panicLineIdx - 1; j >= Math.max(0, panicLineIdx - 2); j--) {
1100
+ const prev = lines[j];
1101
+ if (prev.trim() === "") continue;
1102
+ return NIL_GUARD_RE.test(prev);
1103
+ }
1104
+ return false;
1105
+ };
1106
+ const flagLibraryPanic = (lines, relPath, pkg, out) => {
1107
+ if (pkg === "main") return;
1108
+ for (let i = 0; i < lines.length; i++) {
1109
+ const line = lines[i];
1110
+ if (COMMENT_LINE_RE$1.test(line)) continue;
1111
+ PANIC_CALL_RE.lastIndex = 0;
1112
+ if (!PANIC_CALL_RE.test(line)) continue;
1113
+ if (hasIntentComment$1(lines, i)) continue;
1114
+ if (isNilGuardPanic(lines, i, line)) continue;
1115
+ out.push({
1116
+ filePath: relPath,
1117
+ engine: "ai-slop",
1118
+ rule: "ai-slop/go-library-panic",
1119
+ severity: "warning",
1120
+ message: `\`panic()\` in package \`${pkg}\` (non-main, non-test). Library code should return errors, not unwind the goroutine.`,
1121
+ help: "Convert to `return fmt.Errorf(...)` (or a wrapped error) and let the caller decide. Reserve `panic` for genuinely-impossible states (corrupt internal invariants), and mark those with a comment so future readers know it's intentional.",
1122
+ line: i + 1,
1123
+ column: 1,
1124
+ category: "AI Slop",
1125
+ fixable: false
1126
+ });
1127
+ }
1128
+ };
1129
+ const detectGoPatterns = async (context) => {
1130
+ const diagnostics = [];
1131
+ const files = getSourceFiles(context);
1132
+ for (const filePath of files) {
1133
+ if (!GO_EXTENSIONS.has(path.extname(filePath))) continue;
1134
+ if (isAutoGenerated(filePath)) continue;
1135
+ if (filePath.endsWith("_test.go")) continue;
1136
+ let content;
1137
+ try {
1138
+ content = fs.readFileSync(filePath, "utf-8");
1139
+ } catch {
1140
+ continue;
1141
+ }
1142
+ const lines = content.split("\n");
1143
+ const pkg = detectPackageName(lines);
1144
+ if (!pkg) continue;
1145
+ flagLibraryPanic(lines, path.relative(context.rootDirectory, filePath), pkg, diagnostics);
1146
+ }
1147
+ return diagnostics;
1148
+ };
1149
+
1150
+ //#endregion
1151
+ //#region src/engines/ai-slop/python-data.ts
1152
+ const PYTHON_STDLIB = new Set([
1153
+ "__future__",
1154
+ "_thread",
1155
+ "abc",
1156
+ "argparse",
1157
+ "array",
1158
+ "ast",
1159
+ "asyncio",
1160
+ "atexit",
1161
+ "base64",
1162
+ "binascii",
1163
+ "bisect",
1164
+ "builtins",
1165
+ "bz2",
1166
+ "calendar",
1167
+ "codecs",
1168
+ "collections",
1169
+ "concurrent",
1170
+ "configparser",
1171
+ "contextlib",
1172
+ "contextvars",
1173
+ "copy",
1174
+ "csv",
1175
+ "ctypes",
1176
+ "dataclasses",
1177
+ "datetime",
1178
+ "decimal",
1179
+ "difflib",
1180
+ "dis",
1181
+ "doctest",
1182
+ "email",
1183
+ "encodings",
1184
+ "enum",
1185
+ "errno",
1186
+ "faulthandler",
1187
+ "filecmp",
1188
+ "fileinput",
1189
+ "fnmatch",
1190
+ "fractions",
1191
+ "functools",
1192
+ "gc",
1193
+ "getopt",
1194
+ "getpass",
1195
+ "gettext",
1196
+ "glob",
1197
+ "graphlib",
1198
+ "gzip",
1199
+ "hashlib",
1200
+ "heapq",
1201
+ "hmac",
1202
+ "html",
1203
+ "http",
1204
+ "imaplib",
1205
+ "importlib",
1206
+ "inspect",
1207
+ "io",
1208
+ "ipaddress",
1209
+ "itertools",
1210
+ "json",
1211
+ "keyword",
1212
+ "linecache",
1213
+ "locale",
1214
+ "logging",
1215
+ "lzma",
1216
+ "mailbox",
1217
+ "math",
1218
+ "mimetypes",
1219
+ "mmap",
1220
+ "multiprocessing",
1221
+ "numbers",
1222
+ "operator",
1223
+ "os",
1224
+ "pathlib",
1225
+ "pdb",
1226
+ "pickle",
1227
+ "platform",
1228
+ "plistlib",
1229
+ "pprint",
1230
+ "profile",
1231
+ "pstats",
1232
+ "pty",
1233
+ "queue",
1234
+ "quopri",
1235
+ "random",
1236
+ "re",
1237
+ "readline",
1238
+ "reprlib",
1239
+ "resource",
1240
+ "secrets",
1241
+ "select",
1242
+ "selectors",
1243
+ "shelve",
1244
+ "shlex",
1245
+ "shutil",
1246
+ "signal",
1247
+ "site",
1248
+ "smtplib",
1249
+ "socket",
1250
+ "socketserver",
1251
+ "sqlite3",
1252
+ "ssl",
1253
+ "stat",
1254
+ "statistics",
1255
+ "string",
1256
+ "stringprep",
1257
+ "struct",
1258
+ "subprocess",
1259
+ "sunau",
1260
+ "symtable",
1261
+ "sys",
1262
+ "sysconfig",
1263
+ "syslog",
1264
+ "tarfile",
1265
+ "telnetlib",
1266
+ "tempfile",
1267
+ "termios",
1268
+ "test",
1269
+ "textwrap",
1270
+ "threading",
1271
+ "time",
1272
+ "timeit",
1273
+ "tkinter",
1274
+ "token",
1275
+ "tokenize",
1276
+ "tomllib",
1277
+ "trace",
1278
+ "traceback",
1279
+ "tracemalloc",
1280
+ "tty",
1281
+ "turtle",
1282
+ "types",
1283
+ "typing",
1284
+ "unicodedata",
1285
+ "unittest",
1286
+ "urllib",
1287
+ "uu",
1288
+ "uuid",
1289
+ "venv",
1290
+ "warnings",
1291
+ "wave",
1292
+ "weakref",
1293
+ "webbrowser",
1294
+ "winreg",
1295
+ "winsound",
1296
+ "wsgiref",
1297
+ "xml",
1298
+ "xmlrpc",
1299
+ "zipapp",
1300
+ "zipfile",
1301
+ "zipimport",
1302
+ "zlib",
1303
+ "zoneinfo"
1304
+ ]);
1305
+ const PYTHON_IMPORT_TO_PIP = {
1306
+ yaml: "pyyaml",
1307
+ PIL: "pillow",
1308
+ dateutil: "python-dateutil",
1309
+ cv2: "opencv-python",
1310
+ sklearn: "scikit-learn",
1311
+ bs4: "beautifulsoup4",
1312
+ typing_extensions: "typing-extensions",
1313
+ google: "google-api-python-client",
1314
+ jose: "python-jose",
1315
+ jwt: "pyjwt",
1316
+ OpenSSL: "pyopenssl",
1317
+ magic: "python-magic",
1318
+ docx: "python-docx",
1319
+ pptx: "python-pptx",
1320
+ git: "gitpython",
1321
+ socks: "pysocks",
1322
+ redis: "redis"
1323
+ };
1324
+
1325
+ //#endregion
1326
+ //#region src/engines/ai-slop/hallucinated-imports.ts
1327
+ const JS_EXTENSIONS$2 = new Set([
1328
+ ".ts",
1329
+ ".tsx",
1330
+ ".js",
1331
+ ".jsx",
1332
+ ".mjs",
1333
+ ".cjs"
1334
+ ]);
1335
+ const PY_EXTENSIONS$2 = new Set([".py"]);
1336
+ const readJson = (filePath) => {
1337
+ try {
1338
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
1339
+ } catch {
1340
+ return null;
1341
+ }
1342
+ };
1343
+ const PKG_DEP_SECTIONS = [
1344
+ "dependencies",
1345
+ "devDependencies",
1346
+ "peerDependencies",
1347
+ "optionalDependencies"
1348
+ ];
1349
+ const addDepsFromPkg = (pkg, jsDeps) => {
1350
+ for (const section of PKG_DEP_SECTIONS) {
1351
+ const deps = pkg[section];
1352
+ if (deps && typeof deps === "object") for (const name of Object.keys(deps)) jsDeps.add(name);
1353
+ }
1354
+ };
1355
+ const readWorkspaceGlobs = (rootDir, rootPkg) => {
1356
+ const globs = [];
1357
+ if (rootPkg && typeof rootPkg === "object") {
1358
+ const ws = rootPkg.workspaces;
1359
+ if (Array.isArray(ws)) {
1360
+ for (const g of ws) if (typeof g === "string") globs.push(g);
1361
+ } else if (ws && typeof ws === "object") {
1362
+ const pkgs = ws.packages;
1363
+ if (Array.isArray(pkgs)) {
1364
+ for (const g of pkgs) if (typeof g === "string") globs.push(g);
1365
+ }
1366
+ }
1367
+ }
1368
+ const lerna = readJson(path.join(rootDir, "lerna.json"));
1369
+ if (lerna && Array.isArray(lerna.packages)) {
1370
+ for (const g of lerna.packages) if (typeof g === "string") globs.push(g);
1371
+ }
1372
+ try {
1373
+ const pnpmWs = fs.readFileSync(path.join(rootDir, "pnpm-workspace.yaml"), "utf-8");
1374
+ let inPackages = false;
1375
+ for (const rawLine of pnpmWs.split("\n")) {
1376
+ if (/^packages\s*:\s*$/.test(rawLine)) {
1377
+ inPackages = true;
1378
+ continue;
1379
+ }
1380
+ if (!inPackages) continue;
1381
+ if (/^\S/.test(rawLine)) break;
1382
+ const m = rawLine.match(/^\s*-\s*["']?([^"'\n]+?)["']?\s*$/);
1383
+ if (m) globs.push(m[1].trim());
1384
+ }
1385
+ } catch {}
1386
+ return globs;
1387
+ };
1388
+ const expandWorkspaceDirs = (rootDir, globs) => {
1389
+ const dirs = [];
1390
+ for (const glob of globs) if (glob.endsWith("/*")) {
1391
+ const parent = path.join(rootDir, glob.slice(0, -2));
1392
+ try {
1393
+ for (const entry of fs.readdirSync(parent, { withFileTypes: true })) if (entry.isDirectory()) dirs.push(path.join(parent, entry.name));
1394
+ } catch {
1395
+ continue;
1396
+ }
1397
+ } else if (!glob.includes("*")) dirs.push(path.join(rootDir, glob));
1398
+ return dirs;
1399
+ };
1400
+ const SKIP_DIRS = new Set([
1401
+ "node_modules",
1402
+ ".git",
1403
+ "dist",
1404
+ "build",
1405
+ "out",
1406
+ "target",
1407
+ "coverage"
1408
+ ]);
1409
+ const NESTED_PKG_JSON_DEPTH = 4;
1410
+ const collectNestedManifests = (rootDir, jsDeps) => {
1411
+ const walk = (dir, depth) => {
1412
+ if (depth > NESTED_PKG_JSON_DEPTH) return;
1413
+ let entries;
1414
+ try {
1415
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1416
+ } catch {
1417
+ return;
1418
+ }
1419
+ for (const entry of entries) {
1420
+ if (entry.name.startsWith(".") && entry.name !== ".github") continue;
1421
+ if (SKIP_DIRS.has(entry.name)) continue;
1422
+ const full = path.join(dir, entry.name);
1423
+ if (entry.isDirectory()) walk(full, depth + 1);
1424
+ else if (entry.name === "package.json" && depth > 0) {
1425
+ const wsPkg = readJson(full);
1426
+ if (!wsPkg) continue;
1427
+ if (typeof wsPkg.name === "string") jsDeps.add(wsPkg.name);
1428
+ addDepsFromPkg(wsPkg, jsDeps);
1429
+ }
1430
+ }
1431
+ };
1432
+ walk(rootDir, 0);
1433
+ };
1434
+ const collectJsDeps = (rootDir, jsDeps) => {
1435
+ const pkgPath = path.join(rootDir, "package.json");
1436
+ if (!fs.existsSync(pkgPath)) return false;
1437
+ const pkg = readJson(pkgPath);
1438
+ if (!pkg || typeof pkg !== "object") return false;
1439
+ addDepsFromPkg(pkg, jsDeps);
1440
+ if (typeof pkg.name === "string") jsDeps.add(pkg.name);
1441
+ const workspaceDirs = expandWorkspaceDirs(rootDir, readWorkspaceGlobs(rootDir, pkg));
1442
+ for (const wsDir of workspaceDirs) {
1443
+ const wsPkg = readJson(path.join(wsDir, "package.json"));
1444
+ if (!wsPkg) continue;
1445
+ if (typeof wsPkg.name === "string") jsDeps.add(wsPkg.name);
1446
+ addDepsFromPkg(wsPkg, jsDeps);
1447
+ }
1448
+ collectNestedManifests(rootDir, jsDeps);
1449
+ return true;
1450
+ };
1451
+ const addPyDep = (pyDeps, name) => {
1452
+ const normalized = name.toLowerCase().replace(/_/g, "-");
1453
+ pyDeps.add(normalized);
1454
+ };
1455
+ const collectFromRequirementsTxt = (rootDir, pyDeps) => {
1456
+ const reqPath = path.join(rootDir, "requirements.txt");
1457
+ if (!fs.existsSync(reqPath)) return false;
1458
+ try {
1459
+ const content = fs.readFileSync(reqPath, "utf-8");
1460
+ for (const line of content.split("\n")) {
1461
+ const trimmed = line.trim();
1462
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("-")) continue;
1463
+ const match = trimmed.match(/^([a-zA-Z0-9_\-.]+)/);
1464
+ if (match) addPyDep(pyDeps, match[1]);
1465
+ }
1466
+ return true;
1467
+ } catch {
1468
+ return false;
1469
+ }
1470
+ };
1471
+ const collectFromPyproject = (rootDir, pyDeps) => {
1472
+ const pyprojPath = path.join(rootDir, "pyproject.toml");
1473
+ if (!fs.existsSync(pyprojPath)) return false;
1474
+ try {
1475
+ const content = fs.readFileSync(pyprojPath, "utf-8");
1476
+ const projectNameMatch = content.match(/\[project\][\s\S]*?^\s*name\s*=\s*["']([^"']+)/m);
1477
+ if (projectNameMatch) addPyDep(pyDeps, projectNameMatch[1]);
1478
+ const poetryNameMatch = content.match(/\[tool\.poetry\][\s\S]*?^\s*name\s*=\s*["']([^"']+)/m);
1479
+ if (poetryNameMatch) addPyDep(pyDeps, poetryNameMatch[1]);
1480
+ const pep621 = content.match(/^\s*dependencies\s*=\s*\[([\s\S]*?)\]/m);
1481
+ if (pep621) for (const line of pep621[1].split("\n")) {
1482
+ const m = line.match(/["']\s*([a-zA-Z0-9_\-.]+)/);
1483
+ if (m) addPyDep(pyDeps, m[1]);
1484
+ }
1485
+ const poetryRe = /\[tool\.poetry(?:\.group\.[a-z]+)?\.dependencies\]([\s\S]*?)(?=\n\[|$)/g;
1486
+ let match = poetryRe.exec(content);
1487
+ while (match !== null) {
1488
+ for (const line of match[1].split("\n")) {
1489
+ const m = line.trim().match(/^([a-zA-Z0-9_\-.]+)\s*=/);
1490
+ if (m && m[1] !== "python") addPyDep(pyDeps, m[1]);
1491
+ }
1492
+ match = poetryRe.exec(content);
1493
+ }
1494
+ return true;
1495
+ } catch {
1496
+ return false;
1497
+ }
1498
+ };
1499
+ const collectFromPipfile = (rootDir, pyDeps) => {
1500
+ const pipfilePath = path.join(rootDir, "Pipfile");
1501
+ if (!fs.existsSync(pipfilePath)) return false;
1502
+ try {
1503
+ const content = fs.readFileSync(pipfilePath, "utf-8");
1504
+ const sectionRe = /\[(packages|dev-packages)\]([\s\S]*?)(?=\n\[|$)/g;
1505
+ let match = sectionRe.exec(content);
1506
+ while (match !== null) {
1507
+ for (const line of match[2].split("\n")) {
1508
+ const m = line.trim().match(/^([a-zA-Z0-9_\-.]+)\s*=/);
1509
+ if (m) addPyDep(pyDeps, m[1]);
1510
+ }
1511
+ match = sectionRe.exec(content);
1512
+ }
1513
+ return true;
1514
+ } catch {
1515
+ return false;
1516
+ }
1517
+ };
1518
+ const loadManifest = (rootDir) => {
1519
+ const jsDeps = /* @__PURE__ */ new Set();
1520
+ const pyDeps = /* @__PURE__ */ new Set();
1521
+ const hasJsManifest = collectJsDeps(rootDir, jsDeps);
1522
+ const hasReq = collectFromRequirementsTxt(rootDir, pyDeps);
1523
+ const hasPyproject = collectFromPyproject(rootDir, pyDeps);
1524
+ const hasPipfile = collectFromPipfile(rootDir, pyDeps);
1525
+ return {
1526
+ jsDeps,
1527
+ pyDeps,
1528
+ hasJsManifest,
1529
+ hasPyManifest: hasReq || hasPyproject || hasPipfile
1530
+ };
1531
+ };
1532
+ const isJsRelativeOrAbsolute = (spec) => spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("~/");
1533
+ const isJsBuiltin = (spec) => {
1534
+ return isBuiltin(spec.startsWith("node:") ? spec.slice(5) : spec) || isBuiltin(spec);
1535
+ };
1536
+ const VIRTUAL_MODULE_PREFIXES = [
1537
+ "astro:",
1538
+ "virtual:",
1539
+ "bun:"
1540
+ ];
1541
+ const isJsVirtualModule = (spec) => VIRTUAL_MODULE_PREFIXES.some((p) => spec.startsWith(p));
1542
+ const TEMPLATE_PLACEHOLDER_RE = /\$\{/;
1543
+ const isLikelyRealImportSpec = (spec) => {
1544
+ if (spec.length === 0) return false;
1545
+ if (TEMPLATE_PLACEHOLDER_RE.test(spec)) return false;
1546
+ if (spec.includes("\\")) return false;
1547
+ if (/\s/.test(spec)) return false;
1548
+ return true;
1549
+ };
1550
+ const packageNameFromImport = (spec) => {
1551
+ if (spec.startsWith("@")) {
1552
+ const parts = spec.split("/");
1553
+ return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : spec;
1554
+ }
1555
+ return spec.split("/")[0];
1556
+ };
1557
+ const STATIC_IMPORT_RE = /^\s*import\s+(?:[\w*{},\s]+\s+from\s+)?["']([^"']+)["']/;
1558
+ const DYNAMIC_IMPORT_RE = /(?:import|require)\s*\(\s*["']([^"']+)["']/g;
1559
+ const extractJsImports = (content) => {
1560
+ const lines = content.split("\n");
1561
+ const results = [];
1562
+ for (let i = 0; i < lines.length; i++) {
1563
+ const line = lines[i];
1564
+ const trimmed = line.trim();
1565
+ if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue;
1566
+ const staticMatch = STATIC_IMPORT_RE.exec(line);
1567
+ if (staticMatch && isLikelyRealImportSpec(staticMatch[1])) results.push({
1568
+ spec: staticMatch[1],
1569
+ line: i + 1
1570
+ });
1571
+ DYNAMIC_IMPORT_RE.lastIndex = 0;
1572
+ let dyn = DYNAMIC_IMPORT_RE.exec(line);
1573
+ while (dyn !== null) {
1574
+ if (isLikelyRealImportSpec(dyn[1])) results.push({
1575
+ spec: dyn[1],
1576
+ line: i + 1
1577
+ });
1578
+ dyn = DYNAMIC_IMPORT_RE.exec(line);
1579
+ }
1580
+ }
1581
+ return results;
1582
+ };
1583
+ const extractPyImports = (content) => {
1584
+ const lines = content.split("\n");
1585
+ const results = [];
1586
+ for (let i = 0; i < lines.length; i++) {
1587
+ const line = lines[i].trim();
1588
+ if (line.startsWith("#")) continue;
1589
+ const fromMatch = line.match(/^from\s+([\w.]+)\s+import\b/);
1590
+ if (fromMatch && !fromMatch[1].startsWith(".")) {
1591
+ results.push({
1592
+ spec: fromMatch[1],
1593
+ line: i + 1
1594
+ });
1595
+ continue;
1596
+ }
1597
+ const importMatch = line.match(/^import\s+([\w.,\s]+?)(?:\s+as\s+\w+)?\s*$/);
1598
+ if (importMatch) for (const raw of importMatch[1].split(",")) {
1599
+ const cleaned = raw.trim().split(/\s+as\s+/)[0];
1600
+ if (cleaned && !cleaned.startsWith(".")) results.push({
1601
+ spec: cleaned,
1602
+ line: i + 1
1603
+ });
1604
+ }
1605
+ }
1606
+ return results;
1607
+ };
1608
+ const checkJsImport = (spec, manifest) => {
1609
+ if (isJsRelativeOrAbsolute(spec)) return null;
1610
+ if (isJsBuiltin(spec)) return null;
1611
+ if (isJsVirtualModule(spec)) return null;
1612
+ const pkg = packageNameFromImport(spec);
1613
+ if (manifest.jsDeps.has(pkg)) return null;
1614
+ if (pkg.startsWith("@types/")) {
1615
+ const realPkg = pkg.slice(7);
1616
+ if (manifest.jsDeps.has(realPkg)) return null;
1617
+ }
1618
+ return pkg;
1619
+ };
1620
+ const checkPyImport = (spec, manifest) => {
1621
+ const root = spec.split(".")[0];
1622
+ if (PYTHON_STDLIB.has(root)) return null;
1623
+ const normalized = root.toLowerCase().replace(/_/g, "-");
1624
+ if (manifest.pyDeps.has(normalized)) return null;
1625
+ const pipName = PYTHON_IMPORT_TO_PIP[root];
1626
+ if (pipName && manifest.pyDeps.has(pipName)) return null;
1627
+ return root;
1628
+ };
1629
+ const detectHallucinatedImports = async (context) => {
1630
+ const manifest = loadManifest(context.rootDirectory);
1631
+ if (!manifest.hasJsManifest && !manifest.hasPyManifest) return [];
1632
+ const diagnostics = [];
1633
+ const files = getSourceFiles(context);
1634
+ for (const filePath of files) {
1635
+ const ext = path.extname(filePath);
1636
+ const isJs = JS_EXTENSIONS$2.has(ext);
1637
+ const isPy = PY_EXTENSIONS$2.has(ext);
1638
+ if (!isJs && !isPy) continue;
1639
+ if (isJs && !manifest.hasJsManifest) continue;
1640
+ if (isPy && !manifest.hasPyManifest) continue;
1641
+ if (isAutoGenerated(filePath)) continue;
1642
+ let content;
1643
+ try {
1644
+ content = fs.readFileSync(filePath, "utf-8");
1645
+ } catch {
1646
+ continue;
1647
+ }
1648
+ const relPath = path.relative(context.rootDirectory, filePath);
1649
+ const imports = isJs ? extractJsImports(content) : extractPyImports(content);
1650
+ for (const { spec, line } of imports) {
1651
+ const hallucinated = isJs ? checkJsImport(spec, manifest) : checkPyImport(spec, manifest);
1652
+ if (!hallucinated) continue;
1653
+ const manifestLabel = isJs ? "package.json" : "requirements.txt / pyproject.toml / Pipfile";
1654
+ diagnostics.push({
1655
+ filePath: relPath,
1656
+ engine: "ai-slop",
1657
+ rule: "ai-slop/hallucinated-import",
1658
+ severity: "error",
1659
+ message: `Imports "${hallucinated}" but it's not declared in ${manifestLabel}${isPy ? " and isn't Python stdlib" : ""}`,
1660
+ help: "Most often this is an LLM hallucinating a plausible-sounding package name. Either add the package to your manifest, or correct the import.",
1661
+ line,
1662
+ column: 1,
1663
+ category: "AI Slop",
1664
+ fixable: false
1665
+ });
1666
+ }
1667
+ }
1668
+ return diagnostics;
1669
+ };
1670
+
907
1671
  //#endregion
908
1672
  //#region src/engines/ai-slop/narrative-comments-patterns.ts
909
1673
  const DECORATIVE_SEPARATOR = /^[-=─━~_*#]{6,}$/;
@@ -1020,6 +1784,7 @@ const PHP_DECL_START = /^\s*(?:public|private|protected|static|final|abstract|re
1020
1784
 
1021
1785
  //#endregion
1022
1786
  //#region src/engines/ai-slop/narrative-comments.ts
1787
+ const NON_PRODUCTION_DIR_PATTERN = /(?:^|\/)(?:scripts|bin|examples?|demos?|bench|benches|benchmarks?|fixtures?|__fixtures__|__mocks__|__tests__|vendor|_vendor|vendored|third_party|blib2to3|lib2to3)\//i;
1023
1788
  const stripJsdocLine = (line) => line.replace(/^\s*\/\*\*+\s?/, "").replace(/\s*\*+\/\s*$/, "").replace(/^\s*\*\s?/, "").trim();
1024
1789
  const stripLineComment = (line) => line.replace(/^\s*(?:(?:\/\/)|#)\s?/, "");
1025
1790
  const getCommentSyntax = (ext) => {
@@ -1048,6 +1813,10 @@ const getMatchedLinePrefix = (line, syntax) => {
1048
1813
  }
1049
1814
  return null;
1050
1815
  };
1816
+ const isRustDocCommentLine = (line) => {
1817
+ const trimmed = line.trimStart();
1818
+ return trimmed.startsWith("///") || trimmed.startsWith("//!");
1819
+ };
1051
1820
  const collectBlocks = (sourceLines, syntax) => {
1052
1821
  const blocks = [];
1053
1822
  let i = 0;
@@ -1063,6 +1832,8 @@ const collectBlocks = (sourceLines, syntax) => {
1063
1832
  }
1064
1833
  let next = i;
1065
1834
  while (next < sourceLines.length && sourceLines[next].trim() === "") next += 1;
1835
+ const docCandidates = raw.filter((l) => l.trim().length > 0);
1836
+ const isRustDoc = docCandidates.length > 0 && docCandidates.every((l) => isRustDocCommentLine(l));
1066
1837
  blocks.push({
1067
1838
  kind: "line",
1068
1839
  startLine: start + 1,
@@ -1070,6 +1841,7 @@ const collectBlocks = (sourceLines, syntax) => {
1070
1841
  rawLines: raw,
1071
1842
  prose: raw.map(stripLineComment),
1072
1843
  hasMeaningfulJsdocTag: false,
1844
+ isRustDoc,
1073
1845
  nextNonBlankLine: next < sourceLines.length ? sourceLines[next] : null
1074
1846
  });
1075
1847
  continue;
@@ -1103,6 +1875,7 @@ const collectBlocks = (sourceLines, syntax) => {
1103
1875
  rawLines: raw,
1104
1876
  prose,
1105
1877
  hasMeaningfulJsdocTag: hasMeaningful,
1878
+ isRustDoc: false,
1106
1879
  nextNonBlankLine: next < sourceLines.length ? sourceLines[next] : null
1107
1880
  });
1108
1881
  continue;
@@ -1153,6 +1926,22 @@ const nextLineLooksLikeDataEntry = (nextLine) => {
1153
1926
  return false;
1154
1927
  };
1155
1928
  const looksLikeSuppressDirective = (block) => block.rawLines.some((l) => /\b(biome-ignore|eslint-disable|ts-ignore|ts-expect-error|@ts-\w+|noqa|pylint:\s*disable|rubocop:disable|noinspection|phpcs:disable)\b/.test(l));
1929
+ const GO_DECL_NAME_RE = /^(?:func|type|var|const)\s+(?:\([^)]*\)\s*)?(\w+)/;
1930
+ const looksLikeGoDocComment = (block, ext) => {
1931
+ if (ext !== ".go" || block.kind !== "line") return false;
1932
+ const next = block.nextNonBlankLine;
1933
+ if (!next) return false;
1934
+ const declMatch = GO_DECL_NAME_RE.exec(next.trim());
1935
+ if (!declMatch) return false;
1936
+ return ((block.prose.find((l) => l.length > 0) ?? "").split(/\s+/)[0] ?? "") === declMatch[1];
1937
+ };
1938
+ const DOC_INDICATOR_RE = /`[^`]+`|\|\s*[-:]+\s*\||```|\b(?:note|warning|warn|caveat|example|caution|see):/i;
1939
+ const hasDocIndicator = (block) => {
1940
+ const joined = block.prose.join(" ");
1941
+ if (DOC_INDICATOR_RE.test(joined)) return true;
1942
+ for (const l of block.prose) if (/^[-]\s/.test(l)) return true;
1943
+ return false;
1944
+ };
1156
1945
  const detectNarrativeInBlock = (block, ext) => {
1157
1946
  if (looksLikeLicenseHeader(block)) return {
1158
1947
  matched: false,
@@ -1166,6 +1955,14 @@ const detectNarrativeInBlock = (block, ext) => {
1166
1955
  matched: false,
1167
1956
  reason: ""
1168
1957
  };
1958
+ if (block.isRustDoc) return {
1959
+ matched: false,
1960
+ reason: ""
1961
+ };
1962
+ if (looksLikeGoDocComment(block, ext)) return {
1963
+ matched: false,
1964
+ reason: ""
1965
+ };
1169
1966
  if (block.kind === "line" && block.prose.some((l) => DECORATIVE_SEPARATOR.test(l) || DECORATIVE_SECTION_HEADER.test(l))) return {
1170
1967
  matched: true,
1171
1968
  reason: "decorative separator"
@@ -1178,11 +1975,16 @@ const detectNarrativeInBlock = (block, ext) => {
1178
1975
  matched: true,
1179
1976
  reason: "bare section label"
1180
1977
  };
1978
+ const joined = block.prose.join(" ");
1979
+ const hasWhyMarker = EXPLANATORY_WHY_MARKERS.test(joined);
1980
+ if ((hasWhyMarker || hasDocIndicator(block)) && block.kind === "jsdoc") return {
1981
+ matched: false,
1982
+ reason: ""
1983
+ };
1181
1984
  if (block.prose.length >= 3 && looksLikeDeclarationPreamble(block.nextNonBlankLine, ext)) return {
1182
1985
  matched: true,
1183
1986
  reason: block.kind === "jsdoc" ? "JSDoc preamble before declaration" : "multi-line preamble before declaration"
1184
1987
  };
1185
- const joined = block.prose.join(" ");
1186
1988
  if (CROSS_REFERENCE_PHRASES.some((re) => re.test(joined))) return {
1187
1989
  matched: true,
1188
1990
  reason: "cross-reference commentary"
@@ -1198,8 +2000,6 @@ const detectNarrativeInBlock = (block, ext) => {
1198
2000
  reason: "explanatory preamble"
1199
2001
  };
1200
2002
  const nonEmptyProseCount = block.prose.filter((l) => l.length > 0).length;
1201
- const joinedProse = block.prose.join(" ");
1202
- const hasWhyMarker = EXPLANATORY_WHY_MARKERS.test(joinedProse);
1203
2003
  if (nonEmptyProseCount >= 5) return {
1204
2004
  matched: true,
1205
2005
  reason: "long narrative block"
@@ -1213,84 +2013,330 @@ const detectNarrativeInBlock = (block, ext) => {
1213
2013
  reason: ""
1214
2014
  };
1215
2015
  };
1216
- const detectNarrativeComments = async (context) => {
1217
- const files = getSourceFiles(context);
2016
+ const detectNarrativeComments = async (context) => {
2017
+ const files = getSourceFiles(context);
2018
+ const diagnostics = [];
2019
+ for (const filePath of files) {
2020
+ const ext = path.extname(filePath);
2021
+ if (!SUPPORTED_EXTS.has(ext)) continue;
2022
+ if (isAutoGenerated(filePath)) continue;
2023
+ const syntax = getCommentSyntax(ext);
2024
+ if (!syntax) continue;
2025
+ const relativePath = path.relative(context.rootDirectory, filePath);
2026
+ if (NON_PRODUCTION_DIR_PATTERN.test(relativePath)) continue;
2027
+ let content;
2028
+ try {
2029
+ content = fs.readFileSync(filePath, "utf-8");
2030
+ } catch {
2031
+ continue;
2032
+ }
2033
+ const blocks = collectBlocks(content.split("\n"), syntax);
2034
+ for (const block of blocks) {
2035
+ const { matched, reason } = detectNarrativeInBlock(block, ext);
2036
+ if (!matched) continue;
2037
+ diagnostics.push({
2038
+ filePath: relativePath,
2039
+ engine: "ai-slop",
2040
+ rule: "ai-slop/narrative-comment",
2041
+ severity: "warning",
2042
+ message: `Narrative comment block (${reason})`,
2043
+ help: "Remove — narrative/decorative comments belong in PR descriptions, not source. Code should be self-explanatory.",
2044
+ line: block.startLine,
2045
+ column: 0,
2046
+ category: "Comments",
2047
+ fixable: true
2048
+ });
2049
+ }
2050
+ }
2051
+ return diagnostics;
2052
+ };
2053
+
2054
+ //#endregion
2055
+ //#region src/engines/ai-slop/python-patterns.ts
2056
+ const PY_EXTENSIONS$1 = new Set([".py"]);
2057
+ const BARE_EXCEPT_RE = /^\s*except\s*:\s*(?:#.*)?$/;
2058
+ const BROAD_EXCEPT_RE = /^\s*except\s+(Exception|BaseException)\s*(?:as\s+\w+)?\s*:\s*(?:#.*)?$/;
2059
+ const PRINT_RE = /^\s*print\s*\(/;
2060
+ const DEF_RE = /^\s*(?:async\s+)?def\s+\w+\s*\(/;
2061
+ const MUTABLE_DEFAULT_RE = /(\w+)\s*(?::\s*[^,)=]+)?\s*=\s*(\[\s*\]|\{\s*\}|set\(\s*\))/;
2062
+ const isTestFile$1 = (relPath, basename) => basename.startsWith("test_") || basename.endsWith("_test.py") || basename === "conftest.py" || relPath.split(path.sep).some((seg) => seg === "tests" || seg === "test");
2063
+ const isScriptOrEntrypoint = (basename) => basename === "__main__.py" || basename === "manage.py" || basename === "setup.py";
2064
+ const SCRIPT_DIR_NAMES = new Set([
2065
+ "scripts",
2066
+ "bin",
2067
+ ".github",
2068
+ "action",
2069
+ "docs",
2070
+ "docs_src",
2071
+ "examples",
2072
+ "example"
2073
+ ]);
2074
+ const isInScriptDir = (relPath) => relPath.split(path.sep).some((seg) => SCRIPT_DIR_NAMES.has(seg));
2075
+ const isTutorialFile = (basename) => basename.startsWith("tutorial") && basename.endsWith(".py");
2076
+ const MAIN_GUARD_RE = /^\s*if\s+__name__\s*==\s*["']__main__["']\s*:/;
2077
+ const hasMainGuard = (lines) => lines.some((l) => MAIN_GUARD_RE.test(l));
2078
+ const buildDocstringRanges = (lines) => {
2079
+ const inside = /* @__PURE__ */ new Set();
2080
+ let openDelim = null;
2081
+ for (let i = 0; i < lines.length; i++) {
2082
+ const line = lines[i];
2083
+ if (openDelim) {
2084
+ inside.add(i);
2085
+ if (line.includes(openDelim)) openDelim = null;
2086
+ continue;
2087
+ }
2088
+ for (const delim of ["\"\"\"", "'''"]) {
2089
+ const first = line.indexOf(delim);
2090
+ if (first === -1) continue;
2091
+ if (line.indexOf(delim, first + 3) === -1) {
2092
+ openDelim = delim;
2093
+ inside.add(i);
2094
+ break;
2095
+ }
2096
+ }
2097
+ }
2098
+ return inside;
2099
+ };
2100
+ const pushFinding = (out, a) => {
2101
+ out.push({
2102
+ filePath: a.relPath,
2103
+ engine: "ai-slop",
2104
+ rule: a.rule,
2105
+ severity: a.severity,
2106
+ message: a.message,
2107
+ help: a.help,
2108
+ line: a.line,
2109
+ column: 1,
2110
+ category: "AI Slop",
2111
+ fixable: false
2112
+ });
2113
+ };
2114
+ const flagBareExcept = (lines, relPath, out) => {
2115
+ for (let i = 0; i < lines.length; i++) {
2116
+ if (!BARE_EXCEPT_RE.test(lines[i])) continue;
2117
+ pushFinding(out, {
2118
+ relPath,
2119
+ rule: "ai-slop/python-bare-except",
2120
+ severity: "warning",
2121
+ message: "Bare `except:` swallows every exception including KeyboardInterrupt and SystemExit.",
2122
+ help: "Catch the specific exception type you actually expect (`except ValueError:`, `except (KeyError, IndexError):`). If you genuinely want everything, `except BaseException:` plus a re-raise or log makes the intent explicit.",
2123
+ line: i + 1
2124
+ });
2125
+ }
2126
+ };
2127
+ const flagBroadExceptWithSilentBody = (lines, relPath, out) => {
2128
+ for (let i = 0; i < lines.length; i++) {
2129
+ const match = BROAD_EXCEPT_RE.exec(lines[i]);
2130
+ if (!match) continue;
2131
+ const trimmedNext = (lines[i + 1] ?? "").trim();
2132
+ if (!(trimmedNext === "pass" || trimmedNext.startsWith("#") && (lines[i + 2] ?? "").trim() === "pass")) continue;
2133
+ pushFinding(out, {
2134
+ relPath,
2135
+ rule: "ai-slop/python-broad-except",
2136
+ severity: "warning",
2137
+ message: `\`except ${match[1]}: pass\` silently drops every exception. Failures vanish without a trace.`,
2138
+ help: "Either narrow the exception class (`except ValueError:`), log the error, or re-raise. If you genuinely intend to swallow, add a comment naming the specific failure mode you're handling — auditors will thank you.",
2139
+ line: i + 1
2140
+ });
2141
+ }
2142
+ };
2143
+ const flagMutableDefaults = (lines, relPath, out) => {
2144
+ let i = 0;
2145
+ while (i < lines.length) {
2146
+ if (!DEF_RE.test(lines[i])) {
2147
+ i++;
2148
+ continue;
2149
+ }
2150
+ const startLine = i;
2151
+ let signature = lines[i];
2152
+ let parenDepth = 0;
2153
+ for (const ch of signature) if (ch === "(") parenDepth++;
2154
+ else if (ch === ")") parenDepth--;
2155
+ while (parenDepth > 0 && i + 1 < lines.length) {
2156
+ i++;
2157
+ signature += `\n${lines[i]}`;
2158
+ for (const ch of lines[i]) if (ch === "(") parenDepth++;
2159
+ else if (ch === ")") parenDepth--;
2160
+ }
2161
+ MUTABLE_DEFAULT_RE.lastIndex = 0;
2162
+ const found = MUTABLE_DEFAULT_RE.exec(signature);
2163
+ if (found) pushFinding(out, {
2164
+ relPath,
2165
+ rule: "ai-slop/python-mutable-default",
2166
+ severity: "warning",
2167
+ message: `Mutable default argument \`${found[1]}=${found[2]}\`. The default is shared across all calls — bugs that look like state-leakage.`,
2168
+ help: "Use `None` as the default and create the mutable value inside the body: `def f(items=None): items = items if items is not None else []`. Standard Python idiom; anything else is the AI agent shortcutting.",
2169
+ line: startLine + 1
2170
+ });
2171
+ i++;
2172
+ }
2173
+ };
2174
+ const flagPrintInProduction = (lines, relPath, basename, out) => {
2175
+ if (isTestFile$1(relPath, basename) || isScriptOrEntrypoint(basename)) return;
2176
+ if (isInScriptDir(relPath)) return;
2177
+ if (isTutorialFile(basename)) return;
2178
+ if (hasMainGuard(lines)) return;
2179
+ const docstringLines = buildDocstringRanges(lines);
2180
+ for (let i = 0; i < lines.length; i++) {
2181
+ const line = lines[i];
2182
+ if (!PRINT_RE.test(line)) continue;
2183
+ if (line.trim().startsWith("#")) continue;
2184
+ if (docstringLines.has(i)) continue;
2185
+ pushFinding(out, {
2186
+ relPath,
2187
+ rule: "ai-slop/python-print-debug",
2188
+ severity: "warning",
2189
+ message: "`print()` in production code — usually a leftover debug statement.",
2190
+ help: "Use the project's logger (`logging.getLogger(__name__).info(...)`). If this file is genuinely a CLI entry point (typer/click/argparse), it's safe to ignore — but rename to `__main__.py` or move under `scripts/` so the rule skips it next time.",
2191
+ line: i + 1
2192
+ });
2193
+ }
2194
+ };
2195
+ const detectPythonPatterns = async (context) => {
1218
2196
  const diagnostics = [];
2197
+ const files = getSourceFiles(context);
1219
2198
  for (const filePath of files) {
1220
- const ext = path.extname(filePath);
1221
- if (!SUPPORTED_EXTS.has(ext)) continue;
2199
+ if (!PY_EXTENSIONS$1.has(path.extname(filePath))) continue;
1222
2200
  if (isAutoGenerated(filePath)) continue;
1223
- const syntax = getCommentSyntax(ext);
1224
- if (!syntax) continue;
1225
2201
  let content;
1226
2202
  try {
1227
2203
  content = fs.readFileSync(filePath, "utf-8");
1228
2204
  } catch {
1229
2205
  continue;
1230
2206
  }
1231
- const blocks = collectBlocks(content.split("\n"), syntax);
1232
- const relativePath = filePath.replace(`${context.rootDirectory}/`, "");
1233
- for (const block of blocks) {
1234
- const { matched, reason } = detectNarrativeInBlock(block, ext);
1235
- if (!matched) continue;
1236
- diagnostics.push({
1237
- filePath: relativePath,
1238
- engine: "ai-slop",
1239
- rule: "ai-slop/narrative-comment",
1240
- severity: "warning",
1241
- message: `Narrative comment block (${reason})`,
1242
- help: "Remove — narrative/decorative comments belong in PR descriptions, not source. Code should be self-explanatory.",
1243
- line: block.startLine,
1244
- column: 0,
1245
- category: "Comments",
1246
- fixable: true
1247
- });
1248
- }
2207
+ const relPath = path.relative(context.rootDirectory, filePath);
2208
+ const basename = path.basename(filePath);
2209
+ const lines = content.split("\n");
2210
+ flagBareExcept(lines, relPath, diagnostics);
2211
+ flagBroadExceptWithSilentBody(lines, relPath, diagnostics);
2212
+ flagMutableDefaults(lines, relPath, diagnostics);
2213
+ flagPrintInProduction(lines, relPath, basename, diagnostics);
1249
2214
  }
1250
2215
  return diagnostics;
1251
2216
  };
1252
- const fixNarrativeComments = async (context) => {
1253
- const diagnostics = await detectNarrativeComments(context);
1254
- if (diagnostics.length === 0) return;
1255
- const byFile = /* @__PURE__ */ new Map();
1256
- for (const d of diagnostics) {
1257
- const abs = d.filePath.startsWith("/") ? d.filePath : `${context.rootDirectory}/${d.filePath}`;
1258
- const list = byFile.get(abs) ?? [];
1259
- list.push(d);
1260
- byFile.set(abs, list);
2217
+
2218
+ //#endregion
2219
+ //#region src/engines/ai-slop/rust-patterns.ts
2220
+ const RUST_EXTENSIONS = new Set([".rs"]);
2221
+ const UNWRAP_CALL_RE = /\.unwrap\s*\(\s*\)/;
2222
+ const TODO_MACRO_RE = /\b(todo|unimplemented)\s*!\s*\(/;
2223
+ const COMMENT_LINE_RE = /^\s*\/\//;
2224
+ const TEST_ATTR_RE = /^\s*#\s*\[\s*(?:cfg\s*\(\s*test\s*\)|test|tokio::test)/;
2225
+ const WRITELN_UNWRAP_RE = /\b(?:writeln|write)\s*!\s*\([^)]*\)\s*\.unwrap\s*\(\s*\)/;
2226
+ const TEST_BASENAMES = new Set([
2227
+ "tests.rs",
2228
+ "testutil.rs",
2229
+ "test_util.rs",
2230
+ "test_utils.rs",
2231
+ "build.rs"
2232
+ ]);
2233
+ const TEST_CRATE_SEGMENT_RE = /(?:^|[-_])tests?(?:$|[-_])/;
2234
+ const isTestFile = (relPath) => {
2235
+ const segments = relPath.split(path.sep);
2236
+ if (segments.some((s) => TEST_CRATE_SEGMENT_RE.test(s))) return true;
2237
+ const basename = segments[segments.length - 1] ?? "";
2238
+ if (TEST_BASENAMES.has(basename)) return true;
2239
+ return basename.endsWith("_tests.rs") || basename.endsWith("_testutil.rs");
2240
+ };
2241
+ const isExampleFile = (relPath) => relPath.split(path.sep).some((seg) => seg === "examples" || seg === "benches");
2242
+ const UNWRAP_INTENT_LOOKBACK = 2;
2243
+ const hasIntentComment = (lines, lineIdx) => {
2244
+ for (let j = lineIdx - 1; j >= Math.max(0, lineIdx - UNWRAP_INTENT_LOOKBACK); j--) if (COMMENT_LINE_RE.test(lines[j])) return true;
2245
+ return false;
2246
+ };
2247
+ const buildTestRanges = (lines) => {
2248
+ const ranges = [];
2249
+ for (let i = 0; i < lines.length; i++) {
2250
+ if (!TEST_ATTR_RE.test(lines[i])) continue;
2251
+ const openLine = i;
2252
+ let depth = 0;
2253
+ let started = false;
2254
+ for (let j = i; j < lines.length; j++) {
2255
+ const line = lines[j];
2256
+ for (const ch of line) if (ch === "{") {
2257
+ depth++;
2258
+ started = true;
2259
+ } else if (ch === "}") depth--;
2260
+ if (started && depth === 0) {
2261
+ ranges.push([openLine, j]);
2262
+ i = j;
2263
+ break;
2264
+ }
2265
+ }
1261
2266
  }
1262
- for (const [filePath, diags] of byFile) {
1263
- const syntax = getCommentSyntax(path.extname(filePath));
1264
- if (!syntax) continue;
2267
+ return ranges;
2268
+ };
2269
+ const isInRange = (ranges, lineIdx) => ranges.some(([start, end]) => lineIdx >= start && lineIdx <= end);
2270
+ const flagNonTestUnwrap = (lines, relPath, testRanges, out) => {
2271
+ for (let i = 0; i < lines.length; i++) {
2272
+ const line = lines[i];
2273
+ if (COMMENT_LINE_RE.test(line)) continue;
2274
+ if (isInRange(testRanges, i)) continue;
2275
+ if (!UNWRAP_CALL_RE.test(line)) continue;
2276
+ if (WRITELN_UNWRAP_RE.test(line)) continue;
2277
+ if (hasIntentComment(lines, i)) continue;
2278
+ out.push({
2279
+ filePath: relPath,
2280
+ engine: "ai-slop",
2281
+ rule: "ai-slop/rust-non-test-unwrap",
2282
+ severity: "warning",
2283
+ message: "`.unwrap()` in non-test code panics on None/Err. Surfaces as a hard crash for the caller.",
2284
+ help: "Use `?` to propagate, `.expect(\"context\")` if you really mean it (and the message names the invariant), or pattern-match the variant you care about. Reserve raw `.unwrap()` for tests and prototypes.",
2285
+ line: i + 1,
2286
+ column: 1,
2287
+ category: "AI Slop",
2288
+ fixable: false
2289
+ });
2290
+ }
2291
+ };
2292
+ const flagTodoMacro = (lines, relPath, out) => {
2293
+ for (let i = 0; i < lines.length; i++) {
2294
+ const line = lines[i];
2295
+ if (COMMENT_LINE_RE.test(line)) continue;
2296
+ const match = TODO_MACRO_RE.exec(line);
2297
+ if (!match) continue;
2298
+ out.push({
2299
+ filePath: relPath,
2300
+ engine: "ai-slop",
2301
+ rule: "ai-slop/rust-todo-stub",
2302
+ severity: "warning",
2303
+ message: `\`${match[1]}!()\` panics at runtime — almost certainly a stub the agent forgot to fill in.`,
2304
+ help: "Implement the missing path or remove it. If the work is genuinely deferred, file a ticket and put the number in a comment next to the macro so it doesn't ship invisibly.",
2305
+ line: i + 1,
2306
+ column: 1,
2307
+ category: "AI Slop",
2308
+ fixable: false
2309
+ });
2310
+ }
2311
+ };
2312
+ const detectRustPatterns = async (context) => {
2313
+ const diagnostics = [];
2314
+ const files = getSourceFiles(context);
2315
+ for (const filePath of files) {
2316
+ if (!RUST_EXTENSIONS.has(path.extname(filePath))) continue;
2317
+ if (isAutoGenerated(filePath)) continue;
1265
2318
  let content;
1266
2319
  try {
1267
2320
  content = fs.readFileSync(filePath, "utf-8");
1268
2321
  } catch {
1269
2322
  continue;
1270
2323
  }
2324
+ const relPath = path.relative(context.rootDirectory, filePath);
1271
2325
  const lines = content.split("\n");
1272
- const blocks = collectBlocks(lines, syntax);
1273
- const toRemove = /* @__PURE__ */ new Set();
1274
- for (const d of diags) {
1275
- const block = blocks.find((b) => b.startLine === d.line);
1276
- if (!block) continue;
1277
- for (let ln = block.startLine; ln <= block.endLine; ln += 1) toRemove.add(ln);
1278
- const prev = block.startLine - 1;
1279
- const next = block.endLine + 1;
1280
- const prevIsBlank = prev >= 1 && lines[prev - 1]?.trim() === "";
1281
- const nextIsBlank = next <= lines.length && lines[next - 1]?.trim() === "";
1282
- if (prevIsBlank && nextIsBlank) toRemove.add(prev);
2326
+ if (isExampleFile(relPath)) continue;
2327
+ if (isTestFile(relPath)) {
2328
+ flagTodoMacro(lines, relPath, diagnostics);
2329
+ continue;
1283
2330
  }
1284
- const kept = [];
1285
- for (let i = 0; i < lines.length; i += 1) if (!toRemove.has(i + 1)) kept.push(lines[i]);
1286
- const newContent = kept.join("\n");
1287
- if (newContent !== content) fs.writeFileSync(filePath, newContent);
2331
+ flagNonTestUnwrap(lines, relPath, buildTestRanges(lines), diagnostics);
2332
+ flagTodoMacro(lines, relPath, diagnostics);
1288
2333
  }
2334
+ return diagnostics;
1289
2335
  };
1290
2336
 
1291
2337
  //#endregion
1292
2338
  //#region src/engines/ai-slop/unused-imports.ts
1293
- const JS_EXTENSIONS = new Set([
2339
+ const JS_EXTENSIONS$1 = new Set([
1294
2340
  ".ts",
1295
2341
  ".tsx",
1296
2342
  ".js",
@@ -1420,7 +2466,7 @@ const analyzeFile = (filePath) => {
1420
2466
  const lines = content.split("\n");
1421
2467
  let symbols;
1422
2468
  let importLines;
1423
- if (JS_EXTENSIONS.has(ext)) {
2469
+ if (JS_EXTENSIONS$1.has(ext)) {
1424
2470
  const result = extractJsImportedSymbols(lines);
1425
2471
  symbols = result.symbols;
1426
2472
  importLines = result.importLines;
@@ -1476,7 +2522,12 @@ const aiSlopEngine = {
1476
2522
  detectOverAbstraction(context),
1477
2523
  detectDeadPatterns(context),
1478
2524
  detectUnusedImports(context),
1479
- detectNarrativeComments(context)
2525
+ detectNarrativeComments(context),
2526
+ detectDuplicateImports(context),
2527
+ detectPythonPatterns(context),
2528
+ detectGoPatterns(context),
2529
+ detectRustPatterns(context),
2530
+ detectHallucinatedImports(context)
1480
2531
  ]);
1481
2532
  for (const result of results) if (result.status === "fulfilled") diagnostics.push(...result.value);
1482
2533
  return {
@@ -1872,6 +2923,12 @@ const isDataFile = (content) => {
1872
2923
  const dataLinePattern = /^\s*[{}[\]"']/;
1873
2924
  return nonEmpty.filter((l) => dataLinePattern.test(l)).length / nonEmpty.length > .8;
1874
2925
  };
2926
+ const TEST_PATH_RE = /(?:^|\/)(?:tests?|spec|specs|__tests__|__spec__|src\/test)\//i;
2927
+ const TEST_BASENAME_RE = /(?:^|[/.])(?:test_[\w-]+\.(?:py|rb)|[\w-]+_(?:test|spec)\.(?:py|rb|go|rs)|[\w-]+\.(?:test|spec)\.(?:[jt]sx?|mjs|cjs)|conftest\.py|[A-Z]\w*Tests?\.(?:java|cs|php))$/;
2928
+ const MIGRATION_PATH_RE = /(?:^|\/)(?:migrations?|migrate|prisma\/migrations|db\/migrate)\//i;
2929
+ const FIXTURE_PATH_RE = /(?:^|\/)(?:__fixtures__|__snapshots__|__mocks__|fixtures?|snapshots?|seeds?|stubs?)\//i;
2930
+ const GENERATED_PATH_RE = /(?:^|\/)(?:generated|gen|build|dist|out|target|coverage|node_modules|vendor|\.next|\.nuxt|\.svelte-kit)\//i;
2931
+ const isExemptFromComplexity = (relativePath) => TEST_PATH_RE.test(relativePath) || TEST_BASENAME_RE.test(relativePath) || MIGRATION_PATH_RE.test(relativePath) || FIXTURE_PATH_RE.test(relativePath) || GENERATED_PATH_RE.test(relativePath);
1875
2932
  const analyzeFunctions = (content, ext) => {
1876
2933
  const lines = content.split("\n");
1877
2934
  const functions = [];
@@ -1900,13 +2957,13 @@ const checkFileDiagnostics = (relativePath, content, limits) => {
1900
2957
  const lineCount = content.split("\n").length;
1901
2958
  const ext = path.extname(relativePath).toLowerCase();
1902
2959
  if (isDataFile(content)) return results;
1903
- const effectiveMax = ext === ".jsx" || ext === ".tsx" ? Math.ceil(limits.maxFileLoc * JSX_FILE_LOC_MULTIPLIER) : limits.maxFileLoc;
1904
- if (lineCount > effectiveMax) results.push({
2960
+ const configuredMax = ext === ".jsx" || ext === ".tsx" ? Math.ceil(limits.maxFileLoc * JSX_FILE_LOC_MULTIPLIER) : limits.maxFileLoc;
2961
+ if (lineCount > Math.ceil(configuredMax * 1.1)) results.push({
1905
2962
  filePath: relativePath,
1906
2963
  engine: "code-quality",
1907
2964
  rule: "complexity/file-too-large",
1908
2965
  severity: "warning",
1909
- message: `File has ${lineCount} lines (max: ${effectiveMax})`,
2966
+ message: `File has ${lineCount} lines (max: ${configuredMax})`,
1910
2967
  help: "Consider splitting this file into smaller modules",
1911
2968
  line: 0,
1912
2969
  column: 0,
@@ -1956,13 +3013,14 @@ const checkFunctionDiagnostics = (relativePath, fn, limits) => {
1956
3013
  return results;
1957
3014
  };
1958
3015
  const checkFileComplexity = (filePath, rootDirectory, limits) => {
3016
+ const relativePath = path.relative(rootDirectory, filePath);
3017
+ if (isExemptFromComplexity(relativePath)) return [];
1959
3018
  let content;
1960
3019
  try {
1961
3020
  content = fs.readFileSync(filePath, "utf-8");
1962
3021
  } catch {
1963
3022
  return [];
1964
3023
  }
1965
- const relativePath = path.relative(rootDirectory, filePath);
1966
3024
  const ext = path.extname(filePath).toLowerCase();
1967
3025
  const diagnostics = checkFileDiagnostics(relativePath, content, limits);
1968
3026
  for (const fn of analyzeFunctions(content, ext)) diagnostics.push(...checkFunctionDiagnostics(relativePath, fn, limits));
@@ -3538,7 +4596,10 @@ const lintEngine = {
3538
4596
  const diagnostics = [];
3539
4597
  const { languages, installedTools } = context;
3540
4598
  const promises = [];
3541
- if (languages.includes("typescript") || languages.includes("javascript")) promises.push(runOxlint(context));
4599
+ if (languages.includes("typescript") || languages.includes("javascript")) {
4600
+ promises.push(runOxlint(context));
4601
+ if (context.config.lint.typecheck) promises.push(import("./typecheck-B1MXNAy-.js").then((mod) => mod.runTypecheck(context)));
4602
+ }
3542
4603
  if (context.frameworks.includes("expo")) promises.push(Promise.resolve().then(() => expo_doctor_exports).then((mod) => mod.runExpoDoctor(context)));
3543
4604
  if (languages.includes("python") && installedTools["ruff"]) promises.push(runRuffLint(context));
3544
4605
  if (languages.includes("go") && installedTools["golangci-lint"]) promises.push(runGolangciLint(context));
@@ -4556,6 +5617,7 @@ const runScopedScan = async (cwd, filePaths) => {
4556
5617
  audit: false,
4557
5618
  auditTimeout: 0
4558
5619
  },
5620
+ lint: { typecheck: false },
4559
5621
  architectureRulesPath: config.engines.architecture ? rulesPath : void 0
4560
5622
  }
4561
5623
  }, {
@@ -4594,6 +5656,9 @@ const readIfExists = (targetPath) => {
4594
5656
 
4595
5657
  //#endregion
4596
5658
  //#region src/hooks/quality-gate/baseline.ts
5659
+ const fingerprintDiagnostic = (d, rootDirectory) => {
5660
+ return `${path.isAbsolute(d.filePath) ? path.relative(rootDirectory, d.filePath) : d.filePath}:${d.line}:${d.rule}`;
5661
+ };
4597
5662
  const BASELINE_REL = path.join(".aislop", "baseline.json");
4598
5663
  const baselinePath = (cwd) => path.join(cwd, BASELINE_REL);
4599
5664
  const readBaseline = (cwd) => {
@@ -4601,8 +5666,16 @@ const readBaseline = (cwd) => {
4601
5666
  if (!raw) return null;
4602
5667
  try {
4603
5668
  const parsed = JSON.parse(raw);
4604
- if (parsed.schema !== "aislop.baseline.v1") return null;
4605
- return parsed;
5669
+ if (parsed.schema !== "aislop.baseline.v2" && parsed.schema !== "aislop.baseline.v1") return null;
5670
+ return {
5671
+ schema: "aislop.baseline.v2",
5672
+ updatedAt: parsed.updatedAt ?? "",
5673
+ score: parsed.score ?? 0,
5674
+ byEngine: parsed.byEngine ?? {},
5675
+ fileCount: parsed.fileCount ?? 0,
5676
+ commit: parsed.commit,
5677
+ findingFingerprints: parsed.findingFingerprints ?? []
5678
+ };
4606
5679
  } catch {
4607
5680
  return null;
4608
5681
  }
@@ -4626,7 +5699,8 @@ const captureBaseline = async (cwd) => {
4626
5699
  security: {
4627
5700
  audit: false,
4628
5701
  auditTimeout: 0
4629
- }
5702
+ },
5703
+ lint: { typecheck: false }
4630
5704
  }
4631
5705
  }, {
4632
5706
  format: config.engines.format,
@@ -4643,12 +5717,14 @@ const captureBaseline = async (cwd) => {
4643
5717
  const { score: engineScore } = calculateScore(diagnostics.filter((d) => r.diagnostics.includes(d)), config.scoring.weights, config.scoring.thresholds, project.sourceFileCount, config.scoring.smoothing);
4644
5718
  byEngine[r.engine] = engineScore;
4645
5719
  }
5720
+ const findingFingerprints = diagnostics.filter((d) => d.severity === "error" || d.severity === "warning").map((d) => fingerprintDiagnostic(d, project.rootDirectory));
4646
5721
  const target = writeBaseline(cwd, {
4647
- schema: "aislop.baseline.v1",
5722
+ schema: "aislop.baseline.v2",
4648
5723
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4649
5724
  score,
4650
5725
  byEngine,
4651
- fileCount: project.sourceFileCount
5726
+ fileCount: project.sourceFileCount,
5727
+ findingFingerprints
4652
5728
  });
4653
5729
  return {
4654
5730
  score,
@@ -4737,7 +5813,10 @@ const runClaudeHook = async (deps = {}) => {
4737
5813
  const { diagnostics, score, rootDirectory } = await runScopedScan(cwd, files);
4738
5814
  const baseline = readBaseline(cwd);
4739
5815
  appendSessionFiles(cwd, files);
4740
- const feedback = buildFeedback(diagnostics, score, rootDirectory, baseline?.score);
5816
+ const feedback = buildFeedback(diagnostics, score, rootDirectory, baseline ? {
5817
+ score: baseline.score,
5818
+ findingFingerprints: baseline.findingFingerprints
5819
+ } : void 0);
4741
5820
  const envelope = renderClaudeOutput(JSON.stringify(feedback));
4742
5821
  write(JSON.stringify(envelope));
4743
5822
  return 0;
@@ -4747,6 +5826,42 @@ const runClaudeHook = async (deps = {}) => {
4747
5826
  release();
4748
5827
  }
4749
5828
  };
5829
+ const parseClaudeFileChangedStdin = (raw) => {
5830
+ if (!raw.trim()) return {};
5831
+ try {
5832
+ return JSON.parse(raw);
5833
+ } catch {
5834
+ return {};
5835
+ }
5836
+ };
5837
+ const runClaudeFileChangedHook = async (deps = {}) => {
5838
+ const getStdin = deps.stdin ?? readStdin$2;
5839
+ const write = deps.write ?? ((s) => process.stdout.write(s));
5840
+ const input = parseClaudeFileChangedStdin(await getStdin());
5841
+ const cwd = input.cwd && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
5842
+ const release = acquireHookLock(cwd);
5843
+ if (!release) return 0;
5844
+ try {
5845
+ const result = await captureBaseline(cwd);
5846
+ const changed = input.file_path ? path.relative(cwd, input.file_path) || input.file_path : "<unknown>";
5847
+ const envelope = renderClaudeOutput(JSON.stringify({
5848
+ schema: "aislop.hook.v2",
5849
+ event: "file_changed",
5850
+ file: changed,
5851
+ message: `Watched file changed (${changed}). aislop refreshed the baseline — score: ${result.score}.`,
5852
+ baseline: {
5853
+ score: result.score,
5854
+ fileCount: result.fileCount
5855
+ }
5856
+ }));
5857
+ write(JSON.stringify(envelope));
5858
+ return 0;
5859
+ } catch {
5860
+ return 0;
5861
+ } finally {
5862
+ release();
5863
+ }
5864
+ };
4750
5865
  const parseClaudeStopStdin = (raw) => {
4751
5866
  if (!raw.trim()) return {};
4752
5867
  try {
@@ -4769,7 +5884,10 @@ const runClaudeStopHook = async (deps = {}) => {
4769
5884
  if (!release) return 0;
4770
5885
  try {
4771
5886
  const { diagnostics, score, rootDirectory } = await runScopedScan(cwd, sessionFiles);
4772
- const feedback = buildFeedback(diagnostics, score, rootDirectory, baseline.score);
5887
+ const feedback = buildFeedback(diagnostics, score, rootDirectory, {
5888
+ score: baseline.score,
5889
+ findingFingerprints: baseline.findingFingerprints
5890
+ });
4773
5891
  if (!feedback.regressed) {
4774
5892
  clearSessionFiles(cwd);
4775
5893
  return 0;
@@ -4897,7 +6015,7 @@ const AISLOP_MD_BODY = `# aislop — agent instructions
4897
6015
 
4898
6016
  ## On every edit
4899
6017
 
4900
- A PostToolUse hook runs \`aislop hook claude\` after every Edit, Write, or MultiEdit. It scans the touched files and returns findings as JSON \`additionalContext\` shaped like \`AislopFeedback\` (schema \`aislop.hook.v1\`). Act on them the same turn.
6018
+ A PostToolUse hook runs \`aislop hook claude\` after every Edit, Write, or MultiEdit. It scans the touched files and returns findings as JSON \`additionalContext\` shaped like \`AislopFeedback\` (schema \`aislop.hook.v2\` — score, baseline, delta, regressed, counts, findings, newSinceBaseline, suggestedActions). Act on them the same turn; the \`suggestedActions\` field tells you exactly what to run next.
4901
6019
 
4902
6020
  ## Severity ladder
4903
6021
 
@@ -5134,6 +6252,25 @@ const buildStopHookGroup = () => {
5134
6252
  }]
5135
6253
  };
5136
6254
  };
6255
+ const FILE_CHANGED_MATCHER = ".aislop/config.yml|.aislop/rules.yml|package.json";
6256
+ const buildFileChangedHookGroup = () => {
6257
+ const hashBody = JSON.stringify({
6258
+ command: "aislop hook claude --on-file-changed",
6259
+ matcher: FILE_CHANGED_MATCHER
6260
+ });
6261
+ return {
6262
+ matcher: FILE_CHANGED_MATCHER,
6263
+ hooks: [{
6264
+ type: "command",
6265
+ command: "aislop hook claude --on-file-changed",
6266
+ [AISLOP_SENTINEL_KEY]: {
6267
+ v: 1,
6268
+ managed: true,
6269
+ hash: sentinelHash(hashBody)
6270
+ }
6271
+ }]
6272
+ };
6273
+ };
5137
6274
  const renderSettings$1 = (existingRaw, qualityGate) => {
5138
6275
  let obj = {};
5139
6276
  if (existingRaw) try {
@@ -5142,6 +6279,7 @@ const renderSettings$1 = (existingRaw, qualityGate) => {
5142
6279
  obj = {};
5143
6280
  }
5144
6281
  let next = upsertHookGroup(obj, "PostToolUse", buildHookGroup$1());
6282
+ next = upsertHookGroup(next, "FileChanged", buildFileChangedHookGroup());
5145
6283
  if (qualityGate) next = upsertHookGroup(next, "Stop", buildStopHookGroup());
5146
6284
  else next = removeAislopEntries(next, "Stop").next;
5147
6285
  return `${JSON.stringify(next, null, 2)}\n`;
@@ -5150,7 +6288,7 @@ const installClaude = (opts) => {
5150
6288
  const paths = resolveClaudePaths(opts);
5151
6289
  const result = emptyResult();
5152
6290
  const nextSettings = renderSettings$1(readIfExists(paths.settings), Boolean(opts.qualityGate));
5153
- applyContent(result, opts, paths.settings, nextSettings, "register PostToolUse hook");
6291
+ applyContent(result, opts, paths.settings, nextSettings, "register PostToolUse + FileChanged hooks");
5154
6292
  const mdHash = sentinelHash(AISLOP_MD_BODY);
5155
6293
  const fenced = upsertMarkdownFence(readIfExists(paths.aislopMd), AISLOP_MD_BODY, mdHash);
5156
6294
  applyContent(result, opts, paths.aislopMd, fenced.nextContent, "write AISLOP.md rules");
@@ -5180,7 +6318,9 @@ const uninstallClaude = (opts) => {
5180
6318
  } catch {
5181
6319
  obj = {};
5182
6320
  }
5183
- const stripped = removeAislopEntries(removeAislopEntries(obj, "PostToolUse").next, "Stop").next;
6321
+ const afterPostToolUse = removeAislopEntries(obj, "PostToolUse").next;
6322
+ const afterFileChanged = removeAislopEntries(afterPostToolUse, "FileChanged").next;
6323
+ const stripped = removeAislopEntries(afterFileChanged, "Stop").next;
5184
6324
  const stillHasHooks = stripped.hooks && typeof stripped.hooks === "object" && Object.keys(stripped.hooks).length > 0;
5185
6325
  const otherKeys = Object.keys(stripped).filter((k) => k !== "hooks");
5186
6326
  if (!stillHasHooks && otherKeys.length === 0) applyRemoval(result, opts, paths.settings, null);
@@ -5189,7 +6329,7 @@ const uninstallClaude = (opts) => {
5189
6329
  if (readIfExists(paths.aislopMd) != null) applyRemoval(result, opts, paths.aislopMd, null);
5190
6330
  else result.skipped.push(paths.aislopMd);
5191
6331
  const claudeMd = readIfExists(paths.claudeMd);
5192
- if (claudeMd != null && claudeMd.includes("@AISLOP.md")) {
6332
+ if (claudeMd?.includes("@AISLOP.md")) {
5193
6333
  const stripped = claudeMd.split("\n").filter((line) => line.trim() !== "@AISLOP.md").join("\n").replace(/\n{3,}/g, "\n\n").trim();
5194
6334
  applyRemoval(result, opts, paths.claudeMd, stripped.length === 0 ? null : `${stripped}\n`);
5195
6335
  } else result.skipped.push(paths.claudeMd);
@@ -5750,7 +6890,9 @@ const hookRun = async (agent, flags) => {
5750
6890
  process.exit(0);
5751
6891
  }
5752
6892
  let exitCode = 0;
5753
- if (agent === "claude") exitCode = flags?.stop ? await runClaudeStopHook() : await runClaudeHook();
6893
+ if (agent === "claude") if (flags?.onFileChanged) exitCode = await runClaudeFileChangedHook();
6894
+ else if (flags?.stop) exitCode = await runClaudeStopHook();
6895
+ else exitCode = await runClaudeHook();
5754
6896
  else if (agent === "cursor") exitCode = await runCursorHook();
5755
6897
  else if (agent === "gemini") exitCode = await runGeminiHook();
5756
6898
  else {
@@ -5901,8 +7043,11 @@ const registerCallbacks = (hook) => {
5901
7043
  hook.command("baseline").description("Capture the current project score as the quality-gate baseline").action(async () => {
5902
7044
  await hookBaseline();
5903
7045
  });
5904
- hook.command("claude").description("Internal: Claude Code PostToolUse / Stop callback (reads stdin)").option("--stop", "run in Stop-hook mode for the quality gate").action(async (opts) => {
5905
- await hookRun("claude", { stop: Boolean(opts.stop) });
7046
+ hook.command("claude").description("Internal: Claude Code PostToolUse / Stop / FileChanged callback (reads stdin)").option("--stop", "run in Stop-hook mode for the quality gate").option("--on-file-changed", "run in FileChanged mode (refresh baseline on watched file change)").action(async (opts) => {
7047
+ await hookRun("claude", {
7048
+ stop: Boolean(opts.stop),
7049
+ onFileChanged: Boolean(opts.onFileChanged)
7050
+ });
5906
7051
  });
5907
7052
  hook.command("cursor").description("Internal: Cursor afterFileEdit callback (reads stdin)").action(async () => {
5908
7053
  await hookRun("cursor");
@@ -5918,6 +7063,83 @@ const registerHookCommand = (program) => {
5918
7063
  registerCallbacks(hook);
5919
7064
  };
5920
7065
 
7066
+ //#endregion
7067
+ //#region src/commands/badge.ts
7068
+ const GITHUB_REMOTE_RE = /^(?:git@github\.com:|https:\/\/(?:[^@]+@)?github\.com\/)([^/]+)\/([^/.\s]+?)(?:\.git)?\s*$/;
7069
+ const renderBadgeOutput = ({ owner, repo, svgUrl, pageUrl }) => {
7070
+ const slug = `${owner}/${repo}`;
7071
+ const markdown = `[![aislop](${svgUrl})](${pageUrl})`;
7072
+ return [
7073
+ ``,
7074
+ ` Repository: ${slug}`,
7075
+ ` Badge URL: ${svgUrl}`,
7076
+ ``,
7077
+ ` Markdown:`,
7078
+ ``,
7079
+ ` ${markdown}`,
7080
+ ``,
7081
+ ` Drop the line above into your README. The badge auto-updates after every public scan.`,
7082
+ ``
7083
+ ].join("\n");
7084
+ };
7085
+ const detectGithubSlugFromGit = (directory) => {
7086
+ let raw;
7087
+ try {
7088
+ raw = execSync("git remote get-url origin", {
7089
+ cwd: path.resolve(directory),
7090
+ encoding: "utf-8",
7091
+ stdio: [
7092
+ "ignore",
7093
+ "pipe",
7094
+ "ignore"
7095
+ ]
7096
+ });
7097
+ } catch {
7098
+ return null;
7099
+ }
7100
+ const match = raw.trim().match(GITHUB_REMOTE_RE);
7101
+ if (!match) return null;
7102
+ const owner = match[1];
7103
+ const repo = match[2];
7104
+ if (!owner || !repo) return null;
7105
+ return {
7106
+ owner,
7107
+ repo
7108
+ };
7109
+ };
7110
+ const badgeCommand = async (options = {}) => {
7111
+ let owner = options.owner?.trim();
7112
+ let repo = options.repo?.trim();
7113
+ if (!owner || !repo) {
7114
+ const detected = detectGithubSlugFromGit(options.directory ?? ".");
7115
+ if (!detected) throw new Error("Could not detect a GitHub remote. Run from a repo with `git remote get-url origin` set, or pass --owner and --repo.");
7116
+ owner ??= detected.owner;
7117
+ repo ??= detected.repo;
7118
+ }
7119
+ const svgUrl = `https://badges.scanaislop.com/score/${owner}/${repo}.svg`;
7120
+ const pageUrl = `https://scanaislop.com/${owner}/${repo}`;
7121
+ const output = renderBadgeOutput({
7122
+ owner,
7123
+ repo,
7124
+ svgUrl,
7125
+ pageUrl
7126
+ });
7127
+ if (options.json) process.stdout.write(JSON.stringify({
7128
+ owner,
7129
+ repo,
7130
+ svgUrl,
7131
+ pageUrl
7132
+ }) + "\n");
7133
+ else process.stdout.write(output);
7134
+ return {
7135
+ owner,
7136
+ repo,
7137
+ svgUrl,
7138
+ pageUrl,
7139
+ output
7140
+ };
7141
+ };
7142
+
5921
7143
  //#endregion
5922
7144
  //#region src/ui/symbols.ts
5923
7145
  const TTY = {
@@ -6334,7 +7556,7 @@ const renderCleanRun = (input, deps = {}) => {
6334
7556
 
6335
7557
  //#endregion
6336
7558
  //#region src/version.ts
6337
- const APP_VERSION = "0.6.2";
7559
+ const APP_VERSION = "0.8.0";
6338
7560
 
6339
7561
  //#endregion
6340
7562
  //#region src/utils/telemetry.ts
@@ -6489,6 +7711,7 @@ const scanCommand = async (directory, config, options) => {
6489
7711
  const engineConfig = {
6490
7712
  quality: config.quality,
6491
7713
  security: config.security,
7714
+ lint: config.lint,
6492
7715
  architectureRulesPath: config.engines.architecture ? rulesPath : void 0
6493
7716
  };
6494
7717
  const gridRows = ALL_ENGINE_NAMES.filter((engine) => config.engines[engine] !== false).map((engine) => ({
@@ -6556,7 +7779,7 @@ const scanCommand = async (directory, config, options) => {
6556
7779
  });
6557
7780
  }
6558
7781
  if (options.json) {
6559
- const { buildJsonOutput } = await import("./json-B51etWTw.js");
7782
+ const { buildJsonOutput } = await import("./json-BbMwrgyd.js");
6560
7783
  const jsonOut = buildJsonOutput(results, scoreResult, projectInfo.sourceFileCount, elapsedMs);
6561
7784
  console.log(JSON.stringify(jsonOut, null, 2));
6562
7785
  return { exitCode };
@@ -6767,16 +7990,29 @@ const planFormat = (ctx) => {
6767
7990
  skipReason: "no supported language"
6768
7991
  };
6769
7992
  };
6770
- const planLint = (ctx) => {
6771
- const { languages, frameworks, installedTools } = ctx.projectInfo;
6772
- if (frameworks.includes("expo")) return {
6773
- tool: "expo-doctor + oxlint (bundled)",
7993
+ const findLocalTsc = (root) => {
7994
+ const candidate = path.join(root, "node_modules", ".bin", "tsc");
7995
+ return fs.existsSync(candidate) ? candidate : null;
7996
+ };
7997
+ const withTypecheckSuffix = (baseTool, ctx) => {
7998
+ if (!ctx.config.lint?.typecheck) return {
7999
+ tool: baseTool,
6774
8000
  status: "ok"
6775
8001
  };
6776
- if (hasJsLike(languages)) return {
6777
- tool: "oxlint (bundled)",
8002
+ if (findLocalTsc(ctx.rootDirectory)) return {
8003
+ tool: `${baseTool} + tsc`,
6778
8004
  status: "ok"
6779
8005
  };
8006
+ return {
8007
+ tool: `${baseTool} + tsc not found`,
8008
+ status: "missing",
8009
+ remediation: "Install TypeScript locally (pnpm add -D typescript), or set lint.typecheck: false in .aislop/config.yml."
8010
+ };
8011
+ };
8012
+ const planLint = (ctx) => {
8013
+ const { languages, frameworks, installedTools } = ctx.projectInfo;
8014
+ if (frameworks.includes("expo")) return withTypecheckSuffix("expo-doctor + oxlint (bundled)", ctx);
8015
+ if (hasJsLike(languages)) return withTypecheckSuffix("oxlint (bundled)", ctx);
6780
8016
  return firstMatching(languages, installedTools, LINT_SPECS) ?? {
6781
8017
  tool: "no linter",
6782
8018
  status: "skipped",
@@ -7371,6 +8607,212 @@ const applyEditsAndCollapse = (lines, linesToRemove, lineReplacements) => {
7371
8607
  return collapsed.join("\n");
7372
8608
  };
7373
8609
 
8610
+ //#endregion
8611
+ //#region src/engines/ai-slop/duplicate-imports-fix.ts
8612
+ const JS_EXTENSIONS = new Set([
8613
+ ".ts",
8614
+ ".tsx",
8615
+ ".js",
8616
+ ".jsx",
8617
+ ".mjs",
8618
+ ".cjs"
8619
+ ]);
8620
+ const IMPORT_FROM_RE = /^\s*import\s+(.*?)\s+from\s+["']([^"']+)["']\s*;?\s*$/;
8621
+ const SIDE_EFFECT_RE = /^\s*import\s+["']([^"']+)["']\s*;?\s*$/;
8622
+ const parseNamedClause = (clause) => {
8623
+ const inner = clause.trim().slice(1, -1).trim();
8624
+ if (inner.length === 0) return [];
8625
+ const items = [];
8626
+ for (const part of inner.split(",")) {
8627
+ const trimmed = part.trim();
8628
+ if (!trimmed) continue;
8629
+ let isType = false;
8630
+ let working = trimmed;
8631
+ if (/^type\s+/.test(working)) {
8632
+ isType = true;
8633
+ working = working.replace(/^type\s+/, "");
8634
+ }
8635
+ const aliasMatch = working.match(/^(\w+)\s+as\s+(\w+)$/);
8636
+ if (aliasMatch) {
8637
+ items.push({
8638
+ name: aliasMatch[1],
8639
+ alias: aliasMatch[2],
8640
+ isType
8641
+ });
8642
+ continue;
8643
+ }
8644
+ if (/^\w+$/.test(working)) items.push({
8645
+ name: working,
8646
+ isType
8647
+ });
8648
+ }
8649
+ return items;
8650
+ };
8651
+ const parseImportClause = (clause) => {
8652
+ let rest = clause.trim();
8653
+ let isTypeOnly = false;
8654
+ if (/^type\s+/.test(rest)) {
8655
+ isTypeOnly = true;
8656
+ rest = rest.replace(/^type\s+/, "");
8657
+ }
8658
+ const out = {
8659
+ named: [],
8660
+ isTypeOnly
8661
+ };
8662
+ const defMatch = rest.match(/^([A-Za-z_$][\w$]*)\s*(?:,\s*(.+))?$/);
8663
+ if (defMatch && !rest.startsWith("{") && !rest.startsWith("*")) {
8664
+ out.default = defMatch[1];
8665
+ rest = defMatch[2]?.trim() ?? "";
8666
+ }
8667
+ if (rest.startsWith("*")) {
8668
+ const nsMatch = rest.match(/^\*\s+as\s+(\w+)/);
8669
+ if (nsMatch) out.namespace = nsMatch[1];
8670
+ return out;
8671
+ }
8672
+ if (rest.startsWith("{")) out.named = parseNamedClause(rest);
8673
+ return out;
8674
+ };
8675
+ const parseImportLine = (line, lineIndex) => {
8676
+ const sideEffect = line.match(SIDE_EFFECT_RE);
8677
+ if (sideEffect) return {
8678
+ lineIndex,
8679
+ module: sideEffect[1],
8680
+ named: [],
8681
+ isTypeOnly: false,
8682
+ isSideEffect: true
8683
+ };
8684
+ const m = line.match(IMPORT_FROM_RE);
8685
+ if (!m) return null;
8686
+ return {
8687
+ lineIndex,
8688
+ module: m[2],
8689
+ isSideEffect: false,
8690
+ ...parseImportClause(m[1])
8691
+ };
8692
+ };
8693
+ const formatNamed = (n, stripType) => {
8694
+ const prefix = n.isType && !stripType ? "type " : "";
8695
+ const suffix = n.alias ? ` as ${n.alias}` : "";
8696
+ return `${prefix}${n.name}${suffix}`;
8697
+ };
8698
+ const mergeImports = (group) => {
8699
+ if (group.some((s) => s.isSideEffect)) return null;
8700
+ if (group.some((s) => s.namespace !== void 0)) return null;
8701
+ if (group.some((s) => s.isTypeOnly && s.default !== void 0)) return null;
8702
+ const defaults = group.map((s) => s.default).filter((d) => d !== void 0);
8703
+ const uniqueDefaults = Array.from(new Set(defaults));
8704
+ if (uniqueDefaults.length > 1) return null;
8705
+ const defaultName = uniqueDefaults[0];
8706
+ const merged = /* @__PURE__ */ new Map();
8707
+ for (const stmt of group) for (const nm of stmt.named) {
8708
+ const key = nm.alias ?? nm.name;
8709
+ const isType = nm.isType || stmt.isTypeOnly;
8710
+ const existing = merged.get(key);
8711
+ if (!existing) merged.set(key, {
8712
+ ...nm,
8713
+ isType
8714
+ });
8715
+ else existing.isType = existing.isType && isType;
8716
+ }
8717
+ const insertionOrder = Array.from(merged.values());
8718
+ const namedList = [...insertionOrder.filter((n) => !n.isType), ...insertionOrder.filter((n) => n.isType)];
8719
+ const allTypeOnly = namedList.length > 0 && namedList.every((n) => n.isType);
8720
+ const module = group[0].module;
8721
+ if (!defaultName && namedList.length === 0) return null;
8722
+ if (!defaultName && allTypeOnly) return `import type { ${namedList.map((n) => formatNamed(n, true)).join(", ")} } from "${module}";`;
8723
+ const parts = [];
8724
+ if (defaultName) parts.push(defaultName);
8725
+ if (namedList.length > 0) {
8726
+ const items = namedList.map((n) => formatNamed(n, false)).join(", ");
8727
+ parts.push(`{ ${items} }`);
8728
+ }
8729
+ return `import ${parts.join(", ")} from "${module}";`;
8730
+ };
8731
+ const fixDuplicateImports = async (context) => {
8732
+ const files = getSourceFiles(context);
8733
+ for (const filePath of files) {
8734
+ if (!JS_EXTENSIONS.has(path.extname(filePath))) continue;
8735
+ if (isAutoGenerated(filePath)) continue;
8736
+ let content;
8737
+ try {
8738
+ content = fs.readFileSync(filePath, "utf-8");
8739
+ } catch {
8740
+ continue;
8741
+ }
8742
+ const lines = content.split("\n");
8743
+ const imports = [];
8744
+ for (let i = 0; i < lines.length; i++) {
8745
+ const stmt = parseImportLine(lines[i], i);
8746
+ if (stmt) imports.push(stmt);
8747
+ }
8748
+ if (imports.length < 2) continue;
8749
+ const groups = /* @__PURE__ */ new Map();
8750
+ for (const stmt of imports) {
8751
+ const list = groups.get(stmt.module) ?? [];
8752
+ list.push(stmt);
8753
+ groups.set(stmt.module, list);
8754
+ }
8755
+ const linesToRemove = /* @__PURE__ */ new Set();
8756
+ const replacements = /* @__PURE__ */ new Map();
8757
+ let modified = false;
8758
+ for (const group of groups.values()) {
8759
+ if (group.length < 2) continue;
8760
+ const merged = mergeImports(group);
8761
+ if (!merged) continue;
8762
+ replacements.set(group[0].lineIndex, merged);
8763
+ for (const stmt of group.slice(1)) linesToRemove.add(stmt.lineIndex);
8764
+ modified = true;
8765
+ }
8766
+ if (!modified) continue;
8767
+ const next = [...lines];
8768
+ for (const [idx, replacement] of replacements) next[idx] = replacement;
8769
+ const sortedRemove = Array.from(linesToRemove).sort((a, b) => b - a);
8770
+ for (const idx of sortedRemove) next.splice(idx, 1);
8771
+ fs.writeFileSync(filePath, next.join("\n"));
8772
+ }
8773
+ };
8774
+
8775
+ //#endregion
8776
+ //#region src/engines/ai-slop/narrative-comments-fix.ts
8777
+ const fixNarrativeComments = async (context) => {
8778
+ const diagnostics = await detectNarrativeComments(context);
8779
+ if (diagnostics.length === 0) return;
8780
+ const byFile = /* @__PURE__ */ new Map();
8781
+ for (const d of diagnostics) {
8782
+ const abs = d.filePath.startsWith("/") ? d.filePath : `${context.rootDirectory}/${d.filePath}`;
8783
+ const list = byFile.get(abs) ?? [];
8784
+ list.push(d);
8785
+ byFile.set(abs, list);
8786
+ }
8787
+ for (const [filePath, diags] of byFile) {
8788
+ const syntax = getCommentSyntax(path.extname(filePath));
8789
+ if (!syntax) continue;
8790
+ let content;
8791
+ try {
8792
+ content = fs.readFileSync(filePath, "utf-8");
8793
+ } catch {
8794
+ continue;
8795
+ }
8796
+ const lines = content.split("\n");
8797
+ const blocks = collectBlocks(lines, syntax);
8798
+ const toRemove = /* @__PURE__ */ new Set();
8799
+ for (const d of diags) {
8800
+ const block = blocks.find((b) => b.startLine === d.line);
8801
+ if (!block) continue;
8802
+ for (let ln = block.startLine; ln <= block.endLine; ln += 1) toRemove.add(ln);
8803
+ const prev = block.startLine - 1;
8804
+ const next = block.endLine + 1;
8805
+ const prevIsBlank = prev >= 1 && lines[prev - 1]?.trim() === "";
8806
+ const nextIsBlank = next <= lines.length && lines[next - 1]?.trim() === "";
8807
+ if (prevIsBlank && nextIsBlank) toRemove.add(prev);
8808
+ }
8809
+ const kept = [];
8810
+ for (let i = 0; i < lines.length; i += 1) if (!toRemove.has(i + 1)) kept.push(lines[i]);
8811
+ const newContent = kept.join("\n");
8812
+ if (newContent !== content) fs.writeFileSync(filePath, newContent);
8813
+ }
8814
+ };
8815
+
7374
8816
  //#endregion
7375
8817
  //#region src/engines/ai-slop/unused-imports-fix.ts
7376
8818
  const fixUnusedImports = async (context) => {
@@ -7392,9 +8834,9 @@ const fixUnusedImports = async (context) => {
7392
8834
  for (const [lineNo, syms] of symbolsByLine) {
7393
8835
  const lineIdx = lineNo - 1;
7394
8836
  const allUnused = syms.every((s) => unusedNames.has(s.name));
7395
- const importSpan = JS_EXTENSIONS.has(analysis.ext) ? getJsImportSpan(lines, lineIdx) : [lineIdx];
8837
+ const importSpan = JS_EXTENSIONS$1.has(analysis.ext) ? getJsImportSpan(lines, lineIdx) : [lineIdx];
7396
8838
  if (allUnused) for (const idx of importSpan) linesToRemove.add(idx);
7397
- else if (JS_EXTENSIONS.has(analysis.ext)) rewriteJsImportSpan(lines, importSpan, syms, unusedNames);
8839
+ else if (JS_EXTENSIONS$1.has(analysis.ext)) rewriteJsImportSpan(lines, importSpan, syms, unusedNames);
7398
8840
  else if (PY_EXTENSIONS.has(analysis.ext)) rewritePyImportLine(lines, lineIdx, syms, unusedNames);
7399
8841
  }
7400
8842
  if (linesToRemove.size === 0 && unused.length === 0) continue;
@@ -8127,6 +9569,7 @@ const hasJsOrTs = (projectInfo) => projectInfo.languages.includes("typescript")
8127
9569
  const runAiSlopSteps = async (deps) => {
8128
9570
  if (!deps.config.engines["ai-slop"]) return;
8129
9571
  await deps.runStep("Unused imports", () => detectUnusedImports(deps.context), () => fixUnusedImports(deps.context));
9572
+ await deps.runStep("Duplicate imports", () => detectDuplicateImports(deps.context), () => fixDuplicateImports(deps.context));
8130
9573
  const detectFixableSlop = async () => {
8131
9574
  const [comments, dead, narrative] = await Promise.all([
8132
9575
  detectTrivialComments(deps.context),
@@ -8232,7 +9675,8 @@ const createEngineContext = (rootDirectory, projectInfo, config) => ({
8232
9675
  installedTools: projectInfo.installedTools,
8233
9676
  config: {
8234
9677
  quality: config.quality,
8235
- security: config.security
9678
+ security: config.security,
9679
+ lint: config.lint
8236
9680
  }
8237
9681
  });
8238
9682
  const fixCommand = async (directory, config, options = {
@@ -8295,6 +9739,7 @@ const fixCommand = async (directory, config, options = {
8295
9739
  const engineConfig = {
8296
9740
  quality: config.quality,
8297
9741
  security: config.security,
9742
+ lint: config.lint,
8298
9743
  architectureRulesPath: config.engines.architecture ? rulesPath : void 0
8299
9744
  };
8300
9745
  rail.start("Verifying results");
@@ -8599,8 +10044,10 @@ const buildRulesRender = (input) => {
8599
10044
  const AI_SLOP_FIXABLE = new Set([
8600
10045
  "ai-slop/trivial-comment",
8601
10046
  "ai-slop/unused-import",
8602
- "ai-slop/narrative-comment"
10047
+ "ai-slop/narrative-comment",
10048
+ "ai-slop/duplicate-import"
8603
10049
  ]);
10050
+ const AI_SLOP_ERRORS = new Set(["ai-slop/hallucinated-import"]);
8604
10051
  const BUILTIN_RULES = [
8605
10052
  {
8606
10053
  engine: "format",
@@ -8621,7 +10068,8 @@ const BUILTIN_RULES = [
8621
10068
  "ruff/*",
8622
10069
  "go/*",
8623
10070
  "clippy/*",
8624
- "rubocop/*"
10071
+ "rubocop/*",
10072
+ "typescript/*"
8625
10073
  ]
8626
10074
  },
8627
10075
  {
@@ -8657,7 +10105,16 @@ const BUILTIN_RULES = [
8657
10105
  "ai-slop/unsafe-type-assertion",
8658
10106
  "ai-slop/double-type-assertion",
8659
10107
  "ai-slop/ts-directive",
8660
- "ai-slop/narrative-comment"
10108
+ "ai-slop/narrative-comment",
10109
+ "ai-slop/duplicate-import",
10110
+ "ai-slop/python-bare-except",
10111
+ "ai-slop/python-broad-except",
10112
+ "ai-slop/python-mutable-default",
10113
+ "ai-slop/python-print-debug",
10114
+ "ai-slop/go-library-panic",
10115
+ "ai-slop/rust-non-test-unwrap",
10116
+ "ai-slop/rust-todo-stub",
10117
+ "ai-slop/hallucinated-import"
8661
10118
  ]
8662
10119
  },
8663
10120
  {
@@ -8688,7 +10145,7 @@ const toRuleEntry = (engine, ruleId) => {
8688
10145
  if (engine === "ai-slop") return {
8689
10146
  id: ruleId,
8690
10147
  engine,
8691
- severity: "warning",
10148
+ severity: AI_SLOP_ERRORS.has(ruleId) ? "error" : "warning",
8692
10149
  fixable: AI_SLOP_FIXABLE.has(ruleId)
8693
10150
  };
8694
10151
  return {
@@ -8980,6 +10437,20 @@ program.command("ci [directory]").description("CI-friendly JSON output with exit
8980
10437
  program.command("rules [directory]").description("List all available rules").action(async (directory = ".") => {
8981
10438
  await rulesCommand(directory);
8982
10439
  });
10440
+ program.command("badge [directory]").description("Print the public score badge URL + README markdown for this repo").option("--owner <owner>", "GitHub owner (auto-detected from git remote if omitted)").option("--repo <repo>", "GitHub repo name (auto-detected from git remote if omitted)").option("--json", "emit machine-readable JSON instead of the rendered output").action(async (directory = ".", _flags, command) => {
10441
+ const flags = command.optsWithGlobals();
10442
+ try {
10443
+ await badgeCommand({
10444
+ directory,
10445
+ owner: flags.owner,
10446
+ repo: flags.repo,
10447
+ json: Boolean(flags.json)
10448
+ });
10449
+ } catch (err) {
10450
+ process.stderr.write(`${err?.message ?? "Failed to print badge"}\n`);
10451
+ process.exit(1);
10452
+ }
10453
+ });
8983
10454
  registerHookCommand(program);
8984
10455
  const main = async () => {
8985
10456
  await program.parseAsync();
@@ -8988,4 +10459,4 @@ const main = async () => {
8988
10459
  main();
8989
10460
 
8990
10461
  //#endregion
8991
- export { ENGINE_INFO as n, APP_VERSION as t };
10462
+ export { ENGINE_INFO as n, runSubprocess as r, APP_VERSION as t };