aislop 0.3.2 → 0.4.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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { i as runSubprocess, n as runExpoDoctor, r as isToolInstalled } from "./expo-doctor-Cm5892Y8.js";
2
- import { n as getEngineLabel, r as APP_VERSION, t as ENGINE_INFO } from "./engine-info-N9t3U_CZ.js";
2
+ import { n as getEngineLabel, r as APP_VERSION, t as ENGINE_INFO } from "./engine-info-s7Gy2StX.js";
3
3
  import { createRequire } from "node:module";
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
@@ -207,15 +207,19 @@ const filterExplicitFiles = (rootDirectory, files, extraExtensions = []) => {
207
207
  }).filter(({ relativePath }) => isWithinProject(relativePath) && hasAllowedExtension(relativePath, extraSet)).map(({ absolutePath }) => absolutePath);
208
208
  };
209
209
  const isAutoGenerated = (filePath) => {
210
+ let fd;
210
211
  try {
211
- const fd = fs.openSync(filePath, "r");
212
+ fd = fs.openSync(filePath, "r");
212
213
  const buf = Buffer.alloc(512);
213
214
  const bytesRead = fs.readSync(fd, buf, 0, 512, 0);
214
- fs.closeSync(fd);
215
215
  const header = buf.toString("utf-8", 0, bytesRead);
216
216
  return AUTO_GENERATED_PATTERNS.some((pattern) => pattern.test(header));
217
217
  } catch {
218
218
  return false;
219
+ } finally {
220
+ if (fd !== void 0) try {
221
+ fs.closeSync(fd);
222
+ } catch {}
219
223
  }
220
224
  };
221
225
  const getSourceFilesForRoot = (rootDirectory) => filterProjectFiles(rootDirectory, listProjectFiles(rootDirectory));
@@ -756,7 +760,9 @@ const loadConfig = (directory) => {
756
760
  try {
757
761
  const raw = fs.readFileSync(configPath, "utf-8");
758
762
  return parseConfig(YAML.parse(raw));
759
- } catch {
763
+ } catch (error) {
764
+ const msg = error instanceof Error ? error.message : String(error);
765
+ process.stderr.write(` ⚠ Failed to parse ${configPath}: ${msg}\n ⚠ Using default configuration.\n`);
760
766
  return DEFAULT_CONFIG;
761
767
  }
762
768
  };
@@ -890,7 +896,11 @@ const TODO_PATTERN = new RegExp(`\\b(?:${[
890
896
  "FIXME",
891
897
  "HACK",
892
898
  "XXX"
893
- ].join("|")}|TEMP|PLACEHOLDER|STUB)\\b[:\\s]`, "i");
899
+ ].join("|")})\\b[:\\s]|\\b(?:${[
900
+ "TEMP",
901
+ "PLACEHOLDER",
902
+ "STUB"
903
+ ].join("|")})[:\\s]`);
894
904
  const detectTodoStubs = (content, relativePath) => {
895
905
  const diagnostics = [];
896
906
  const lines = content.split("\n");
@@ -992,18 +1002,20 @@ const detectUnsafeTypePatterns = (content, relativePath, ext) => {
992
1002
  category: "AI Slop",
993
1003
  fixable: false
994
1004
  });
995
- if (doubleAssertPattern.test(trimmed)) diagnostics.push({
996
- filePath: relativePath,
997
- engine: "ai-slop",
998
- rule: "ai-slop/double-type-assertion",
999
- severity: "warning",
1000
- message: `Double type assertion (as unknown as X) bypasses type checking`,
1001
- help: "Refactor to avoid needing a double assertion it usually indicates a design issue",
1002
- line: i + 1,
1003
- column: 0,
1004
- category: "AI Slop",
1005
- fixable: false
1006
- });
1005
+ if (doubleAssertPattern.test(trimmed)) {
1006
+ if (!(/\.query[(<]/.test(trimmed) || /result\[0\]/.test(trimmed) || /rows\s/.test(trimmed))) diagnostics.push({
1007
+ filePath: relativePath,
1008
+ engine: "ai-slop",
1009
+ rule: "ai-slop/double-type-assertion",
1010
+ severity: "warning",
1011
+ message: `Double type assertion (as unknown as X) bypasses type checking`,
1012
+ help: "Refactor to avoid needing a double assertion. If this is an ORM query return, consider a typed wrapper function",
1013
+ line: i + 1,
1014
+ column: 0,
1015
+ category: "AI Slop",
1016
+ fixable: false
1017
+ });
1018
+ }
1007
1019
  }
1008
1020
  return diagnostics;
1009
1021
  };
@@ -1031,10 +1043,55 @@ const detectDeadPatterns = async (context) => {
1031
1043
  //#endregion
1032
1044
  //#region src/engines/ai-slop/dead-patterns-fix.ts
1033
1045
  /**
1046
+ * Given a starting line that contains an opening `(`, find all lines
1047
+ * through the matching `)`. Returns the set of 1-based line numbers.
1048
+ */
1049
+ const findStatementSpan = (lines, startIndex) => {
1050
+ const span = /* @__PURE__ */ new Set();
1051
+ let depth = 0;
1052
+ let started = false;
1053
+ for (let i = startIndex; i < lines.length; i++) {
1054
+ const line = lines[i];
1055
+ span.add(i + 1);
1056
+ for (const ch of line) if (ch === "(") {
1057
+ depth++;
1058
+ started = true;
1059
+ } else if (ch === ")") depth--;
1060
+ if (started && depth <= 0) break;
1061
+ }
1062
+ return span;
1063
+ };
1064
+ /**
1065
+ * Patterns that indicate a console.log is communicating an error or important
1066
+ * status to the user — should be upgraded to console.error, not removed.
1067
+ */
1068
+ const ERROR_MESSAGE_PATTERNS = [
1069
+ /\b(?:error|err|fail|failed|failure|fatal|crash|exception)\b/i,
1070
+ /\b(?:not found|missing|invalid|unable|cannot|couldn'?t|won'?t)\b/i,
1071
+ /\b(?:denied|unauthorized|forbidden|refused|rejected|timeout|timed?\s*out)\b/i,
1072
+ /\bno\s+(?:\w+\s+)*found\b/i,
1073
+ /\bprocess\.exit\b/
1074
+ ];
1075
+ /**
1076
+ * Extracts the full text of a console statement spanning multiple lines.
1077
+ */
1078
+ const getStatementText = (lines, startIndex, span) => {
1079
+ const spanLines = [];
1080
+ for (const lineNo of span) spanLines.push(lines[lineNo - 1]);
1081
+ return spanLines.join("\n");
1082
+ };
1083
+ /**
1084
+ * Determine if a console.log should be replaced with console.error
1085
+ * rather than removed entirely.
1086
+ */
1087
+ const shouldUpgradeToError = (statementText) => {
1088
+ return ERROR_MESSAGE_PATTERNS.some((pattern) => pattern.test(statementText));
1089
+ };
1090
+ /**
1034
1091
  * Removes lines flagged as fixable by the trivial-comment and dead-pattern detectors.
1035
- * Specifically handles:
1036
- * - ai-slop/trivial-comment (trivial comments that restate the code)
1037
- * - ai-slop/console-leftover (console.log/debug/info left in production)
1092
+ * - ai-slop/trivial-comment → remove the line
1093
+ * - ai-slop/console-leftover remove the entire statement (multi-line safe),
1094
+ * OR replace with console.error if the message indicates an error/failure
1038
1095
  */
1039
1096
  const fixDeadPatterns = async (context) => {
1040
1097
  const fixable = [...await detectTrivialComments(context), ...await detectDeadPatterns(context)].filter((d) => d.fixable);
@@ -1042,26 +1099,48 @@ const fixDeadPatterns = async (context) => {
1042
1099
  const byFile = /* @__PURE__ */ new Map();
1043
1100
  for (const d of fixable) {
1044
1101
  const absolute = path.isAbsolute(d.filePath) ? d.filePath : path.join(context.rootDirectory, d.filePath);
1045
- const lines = byFile.get(absolute) ?? /* @__PURE__ */ new Set();
1046
- lines.add(d.line);
1047
- byFile.set(absolute, lines);
1102
+ const entries = byFile.get(absolute) ?? [];
1103
+ entries.push({
1104
+ line: d.line,
1105
+ rule: d.rule
1106
+ });
1107
+ byFile.set(absolute, entries);
1048
1108
  }
1049
- for (const [filePath, lineNumbers] of byFile) {
1050
- if (!fs.existsSync(filePath)) continue;
1051
- const lines = fs.readFileSync(filePath, "utf-8").split("\n");
1052
- const filtered = [];
1053
- for (let i = 0; i < lines.length; i++) {
1054
- const lineNo = i + 1;
1055
- if (lineNumbers.has(lineNo)) continue;
1056
- filtered.push(lines[i]);
1057
- }
1058
- const collapsed = [];
1059
- for (const line of filtered) {
1060
- if (line.trim() === "" && collapsed.length > 0 && collapsed[collapsed.length - 1].trim() === "") continue;
1061
- collapsed.push(line);
1062
- }
1063
- fs.writeFileSync(filePath, collapsed.join("\n"));
1109
+ for (const [filePath, entries] of byFile) fixFileDeadPatterns(filePath, entries);
1110
+ };
1111
+ const fixFileDeadPatterns = (filePath, entries) => {
1112
+ if (!fs.existsSync(filePath)) return;
1113
+ const lines = fs.readFileSync(filePath, "utf-8").split("\n");
1114
+ const linesToRemove = /* @__PURE__ */ new Set();
1115
+ const lineReplacements = /* @__PURE__ */ new Map();
1116
+ for (const entry of entries) {
1117
+ const index = entry.line - 1;
1118
+ if (index < 0 || index >= lines.length) continue;
1119
+ if (entry.rule === "ai-slop/console-leftover") {
1120
+ const span = findStatementSpan(lines, index);
1121
+ if (shouldUpgradeToError(getStatementText(lines, index, span))) {
1122
+ const replaced = lines[index].replace(/console\.(?:log|debug|info|trace|dir|table)\s*\(/, "console.error(");
1123
+ lineReplacements.set(entry.line, replaced);
1124
+ } else for (const lineNo of span) linesToRemove.add(lineNo);
1125
+ } else linesToRemove.add(entry.line);
1126
+ }
1127
+ const result = applyEditsAndCollapse(lines, linesToRemove, lineReplacements);
1128
+ fs.writeFileSync(filePath, result);
1129
+ };
1130
+ const applyEditsAndCollapse = (lines, linesToRemove, lineReplacements) => {
1131
+ const result = [];
1132
+ for (let i = 0; i < lines.length; i++) {
1133
+ const lineNo = i + 1;
1134
+ if (linesToRemove.has(lineNo)) continue;
1135
+ result.push(lineReplacements.get(lineNo) ?? lines[i]);
1136
+ }
1137
+ const collapsed = [];
1138
+ for (const line of result) {
1139
+ const prevEmpty = collapsed.length > 0 && collapsed[collapsed.length - 1].trim() === "";
1140
+ if (line.trim() === "" && prevEmpty) continue;
1141
+ collapsed.push(line);
1064
1142
  }
1143
+ return collapsed.join("\n");
1065
1144
  };
1066
1145
 
1067
1146
  //#endregion
@@ -1402,7 +1481,7 @@ const collectIssues = (fileIssue, issueType, rootDir, knipCwd) => {
1402
1481
  return diagnostics;
1403
1482
  };
1404
1483
  const findMonorepoRoot = (directory) => {
1405
- let current = path.dirname(directory);
1484
+ let current = directory;
1406
1485
  while (current !== path.dirname(current)) {
1407
1486
  if (fs.existsSync(path.join(current, "pnpm-workspace.yaml")) || (() => {
1408
1487
  const pkgPath = path.join(current, "package.json");
@@ -1462,6 +1541,16 @@ const fixUnusedDependencies = async (rootDirectory) => {
1462
1541
  }
1463
1542
  if (changed) fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, " ")}\n`);
1464
1543
  };
1544
+ const runKnipUnusedFiles = async (rootDirectory) => {
1545
+ return (await runKnip(rootDirectory)).filter((d) => d.rule === "knip/files");
1546
+ };
1547
+ const fixUnusedFiles = async (rootDirectory) => {
1548
+ const diagnostics = await runKnipUnusedFiles(rootDirectory);
1549
+ for (const d of diagnostics) {
1550
+ const absolutePath = path.resolve(rootDirectory, d.filePath);
1551
+ if (fs.existsSync(absolutePath)) fs.unlinkSync(absolutePath);
1552
+ }
1553
+ };
1465
1554
  const runKnip = async (rootDirectory) => {
1466
1555
  const knipRuntime = findKnipBin(rootDirectory, findMonorepoRoot(rootDirectory));
1467
1556
  if (!knipRuntime) return [];
@@ -1475,7 +1564,7 @@ const runKnip = async (rootDirectory) => {
1475
1564
  ];
1476
1565
  const result = await runSubprocess(process.execPath, args, {
1477
1566
  cwd: knipRuntime.cwd,
1478
- timeout: 2e4,
1567
+ timeout: 6e4,
1479
1568
  env: { FORCE_COLOR: "0" }
1480
1569
  });
1481
1570
  if (!result.stdout) return [];
@@ -1538,6 +1627,24 @@ const BIOME_EXTENSIONS = new Set([
1538
1627
  ".mjs",
1539
1628
  ".cjs"
1540
1629
  ]);
1630
+ const projectHasBiomeConfig = (rootDir) => {
1631
+ try {
1632
+ const biomePath = path.join(rootDir, "biome.json");
1633
+ return fs.existsSync(biomePath);
1634
+ } catch {
1635
+ return false;
1636
+ }
1637
+ };
1638
+ const getBiomeLineWidth = (rootDir) => {
1639
+ try {
1640
+ const biomePath = path.join(rootDir, "biome.json");
1641
+ if (!fs.existsSync(biomePath)) return 120;
1642
+ const content = fs.readFileSync(biomePath, "utf-8");
1643
+ return JSON.parse(content).formatter?.lineWidth ?? 120;
1644
+ } catch {
1645
+ return 120;
1646
+ }
1647
+ };
1541
1648
  const getBiomeTargets = (context) => getSourceFiles(context).filter((filePath) => BIOME_EXTENSIONS.has(path.extname(filePath))).map((filePath) => path.relative(context.rootDirectory, filePath));
1542
1649
  const projectUsesDecorators = (rootDir) => {
1543
1650
  try {
@@ -1552,9 +1659,11 @@ const projectUsesDecorators = (rootDir) => {
1552
1659
  const runBiomeFormat = async (context) => {
1553
1660
  const targets = getBiomeTargets(context);
1554
1661
  if (targets.length === 0) return [];
1662
+ if (!projectHasBiomeConfig(context.rootDirectory)) return [];
1555
1663
  const args = [
1556
1664
  "format",
1557
1665
  "--reporter=json",
1666
+ `--line-width=${getBiomeLineWidth(context.rootDirectory)}`,
1558
1667
  ...targets
1559
1668
  ];
1560
1669
  try {
@@ -1586,7 +1695,7 @@ const parseBiomeJsonOutput = (output, rootDir) => {
1586
1695
  for (const entry of parsed.diagnostics) {
1587
1696
  const rawPath = entry.location?.path;
1588
1697
  if (!rawPath) continue;
1589
- const severity = entry.severity === "error" ? "error" : "warning";
1698
+ const severity = "warning";
1590
1699
  const rawMessage = entry.message ?? "";
1591
1700
  const message = !rawMessage || rawMessage.toLowerCase().includes("would have printed") ? "File is not formatted correctly" : rawMessage;
1592
1701
  diagnostics.push({
@@ -1611,6 +1720,7 @@ const fixBiomeFormat = async (context) => {
1611
1720
  await runBiome([
1612
1721
  "format",
1613
1722
  "--write",
1723
+ `--line-width=${getBiomeLineWidth(context.rootDirectory)}`,
1614
1724
  ...targets
1615
1725
  ], context.rootDirectory, 6e4);
1616
1726
  };
@@ -1784,12 +1894,12 @@ const resolveOxlintBinary = () => {
1784
1894
  };
1785
1895
  const parseRuleCode = (code) => {
1786
1896
  if (!code) return {
1787
- plugin: "unknown",
1788
- rule: "unknown"
1897
+ plugin: "eslint",
1898
+ rule: "syntax-error"
1789
1899
  };
1790
1900
  const match = code.match(/^(.+)\((.+)\)$/);
1791
1901
  if (!match) return {
1792
- plugin: "unknown",
1902
+ plugin: "eslint",
1793
1903
  rule: code
1794
1904
  };
1795
1905
  return {
@@ -1979,6 +2089,7 @@ const runOxlint = async (context) => {
1979
2089
  } catch {
1980
2090
  return [];
1981
2091
  }
2092
+ const seen = /* @__PURE__ */ new Set();
1982
2093
  return output.diagnostics.map((d) => {
1983
2094
  const { plugin, rule } = parseRuleCode(d.code);
1984
2095
  const label = d.labels[0];
@@ -1994,6 +2105,11 @@ const runOxlint = async (context) => {
1994
2105
  category: plugin === "react" ? "React" : plugin === "import" ? "Imports" : "Lint",
1995
2106
  fixable: false
1996
2107
  };
2108
+ }).filter((d) => {
2109
+ const key = `${d.filePath}:${d.line}:${d.rule}:${d.message}`;
2110
+ if (seen.has(key)) return false;
2111
+ seen.add(key);
2112
+ return true;
1997
2113
  });
1998
2114
  } finally {
1999
2115
  if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
@@ -2759,82 +2875,6 @@ const checkComplexity = async (context) => {
2759
2875
  return diagnostics;
2760
2876
  };
2761
2877
 
2762
- //#endregion
2763
- //#region src/engines/code-quality/duplication.ts
2764
- const MIN_DUPLICATE_LINES = 12;
2765
- const MIN_DUPLICATE_CHARS = 240;
2766
- const MAX_DUPLICATE_REPORTS = 50;
2767
- const isIgnorableLine = (line) => {
2768
- const trimmed = line.trim();
2769
- return trimmed.length === 0 || trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*") || trimmed.startsWith("#");
2770
- };
2771
- const normalizeLine = (line) => line.trim().replace(/\s+/g, " ");
2772
- const extractDuplicateBlocks = (content) => {
2773
- const blocks = [];
2774
- const lines = content.split("\n");
2775
- for (let i = 0; i <= lines.length - MIN_DUPLICATE_LINES; i++) {
2776
- const segment = lines.slice(i, i + MIN_DUPLICATE_LINES);
2777
- if (segment.some(isIgnorableLine)) continue;
2778
- const key = segment.map(normalizeLine).join("\n");
2779
- if (key.length < MIN_DUPLICATE_CHARS) continue;
2780
- blocks.push({
2781
- key,
2782
- startLine: i + 1
2783
- });
2784
- }
2785
- return blocks;
2786
- };
2787
- const checkDuplication = async (context) => {
2788
- const files = getSourceFiles(context);
2789
- const duplicates = /* @__PURE__ */ new Map();
2790
- for (const absoluteFilePath of files) {
2791
- if (isAutoGenerated(absoluteFilePath)) continue;
2792
- let content = "";
2793
- try {
2794
- content = fs.readFileSync(absoluteFilePath, "utf-8");
2795
- } catch {
2796
- continue;
2797
- }
2798
- const relativeFilePath = path.relative(context.rootDirectory, absoluteFilePath);
2799
- for (const block of extractDuplicateBlocks(content)) {
2800
- const occurrence = {
2801
- filePath: relativeFilePath,
2802
- startLine: block.startLine
2803
- };
2804
- const list = duplicates.get(block.key) ?? [];
2805
- list.push(occurrence);
2806
- duplicates.set(block.key, list);
2807
- }
2808
- }
2809
- const diagnostics = [];
2810
- const reportedPairs = /* @__PURE__ */ new Set();
2811
- for (const occurrences of duplicates.values()) {
2812
- if (occurrences.length < 2) continue;
2813
- const source = occurrences[0];
2814
- for (const occurrence of occurrences.slice(1)) {
2815
- if (diagnostics.length >= MAX_DUPLICATE_REPORTS) return diagnostics;
2816
- if (occurrence.filePath === source.filePath && occurrence.startLine === source.startLine) continue;
2817
- if (occurrence.filePath === source.filePath) continue;
2818
- const pairKey = `${source.filePath}->${occurrence.filePath}`;
2819
- if (reportedPairs.has(pairKey)) continue;
2820
- reportedPairs.add(pairKey);
2821
- diagnostics.push({
2822
- filePath: occurrence.filePath,
2823
- engine: "code-quality",
2824
- rule: "duplication/block",
2825
- severity: "warning",
2826
- message: `Possible duplicated code block (${MIN_DUPLICATE_LINES}+ lines) also found at ${source.filePath}:${source.startLine}`,
2827
- help: "Extract shared logic into a reusable function or module",
2828
- line: occurrence.startLine,
2829
- column: 0,
2830
- category: "Duplication",
2831
- fixable: false
2832
- });
2833
- }
2834
- }
2835
- return diagnostics;
2836
- };
2837
-
2838
2878
  //#endregion
2839
2879
  //#region src/engines/code-quality/index.ts
2840
2880
  const codeQualityEngine = {
@@ -2844,7 +2884,6 @@ const codeQualityEngine = {
2844
2884
  const promises = [];
2845
2885
  if (context.languages.includes("typescript") || context.languages.includes("javascript")) promises.push(runKnip(context.rootDirectory));
2846
2886
  promises.push(checkComplexity(context));
2847
- promises.push(checkDuplication(context));
2848
2887
  const results = await Promise.allSettled(promises);
2849
2888
  for (const result of results) if (result.status === "fulfilled") diagnostics.push(...result.value);
2850
2889
  return {
@@ -3151,7 +3190,7 @@ const runDependencyAudit = async (context) => {
3151
3190
  const timeout = context.config.security.auditTimeout;
3152
3191
  const promises = [];
3153
3192
  if (context.languages.includes("typescript") || context.languages.includes("javascript")) {
3154
- if (fs.existsSync(path.join(context.rootDirectory, "pnpm-lock.yaml"))) promises.push(runPnpmAudit(context.rootDirectory, timeout));
3193
+ if (fs.existsSync(path.join(context.rootDirectory, "pnpm-lock.yaml"))) promises.push(runPnpmAuditWithFallback(context.rootDirectory, timeout));
3155
3194
  else if (fs.existsSync(path.join(context.rootDirectory, "package-lock.json")) || fs.existsSync(path.join(context.rootDirectory, "package.json"))) promises.push(runNpmAudit(context.rootDirectory, timeout));
3156
3195
  }
3157
3196
  if (context.languages.includes("python") && context.installedTools["pip-audit"]) promises.push(runPipAudit(context.rootDirectory, timeout));
@@ -3171,13 +3210,20 @@ const runNpmAudit = async (rootDir, timeout) => {
3171
3210
  return [];
3172
3211
  }
3173
3212
  };
3174
- const runPnpmAudit = async (rootDir, timeout) => {
3213
+ const runPnpmAuditWithFallback = async (rootDir, timeout) => {
3214
+ const canFallbackToNpm = fs.existsSync(path.join(rootDir, "package-lock.json"));
3175
3215
  try {
3176
- return parseJsAudit((await runSubprocess("pnpm", ["audit", "--json"], {
3216
+ const diagnostics = parseJsAudit((await runSubprocess("pnpm", ["audit", "--json"], {
3177
3217
  cwd: rootDir,
3178
3218
  timeout
3179
3219
  })).stdout, "pnpm audit");
3220
+ if (diagnostics.some((d) => d.rule === "security/dependency-audit-skipped")) {
3221
+ if (canFallbackToNpm) return runNpmAudit(rootDir, timeout);
3222
+ return [];
3223
+ }
3224
+ return diagnostics;
3180
3225
  } catch {
3226
+ if (canFallbackToNpm) return runNpmAudit(rootDir, timeout);
3181
3227
  return [];
3182
3228
  }
3183
3229
  };
@@ -3209,8 +3255,10 @@ const parseModernVulnerabilities = (vulnerabilities, source) => {
3209
3255
  for (const [packageName, vulnerability] of Object.entries(vulnerabilities)) {
3210
3256
  const severity = (vulnerability.severity ?? "moderate").toLowerCase();
3211
3257
  const fixAvailable = vulnerability.fixAvailable;
3258
+ const isDirect = vulnerability.isDirect === true;
3212
3259
  let recommendation = `Run \`${defaultAuditFixCommand(source)}\` to resolve`;
3213
- if (fixAvailable === false) recommendation = "No automatic fix available.";
3260
+ if (fixAvailable === false) recommendation = isDirect ? "No automatic fix available — check for a newer major version or an alternative package." : "Transitive vulnerability with no fix. Add an override in package.json or upgrade the parent dependency.";
3261
+ else if (!isDirect && fixAvailable === true) recommendation = `Transitive dep — \`${defaultAuditFixCommand(source)}\` may not resolve this. If it persists, add an override in package.json or upgrade the parent package that depends on ${packageName}.`;
3214
3262
  else if (fixAvailable && typeof fixAvailable === "object" && "name" in fixAvailable && "version" in fixAvailable) {
3215
3263
  const target = fixAvailable;
3216
3264
  if (target.name && target.version) recommendation = `Upgrade to ${target.name}@${target.version}.`;
@@ -3701,7 +3749,7 @@ const getStepSummary = (result) => {
3701
3749
  if (result.beforeIssues === 0) return `0 issues, ${formatElapsed$1(result.elapsedMs)}`;
3702
3750
  if (result.afterIssues === 0) return `${result.resolvedIssues} resolved, ${formatElapsed$1(result.elapsedMs)}`;
3703
3751
  if (result.resolvedIssues > 0) return `${result.resolvedIssues} resolved, ${result.afterIssues} remaining, ${formatElapsed$1(result.elapsedMs)}`;
3704
- return `no changes, ${result.afterIssues} issue${result.afterIssues === 1 ? "" : "s"}, ${formatElapsed$1(result.elapsedMs)}`;
3752
+ return `no changes, ${result.afterIssues} issue${result.afterIssues === 1 ? "" : "s"} remain, ${formatElapsed$1(result.elapsedMs)}`;
3705
3753
  };
3706
3754
  const getStatusParts$1 = (state, frameIndex) => {
3707
3755
  if (state.status === "running") return {
@@ -3832,6 +3880,24 @@ const getScoreColor = (score, thresholds) => {
3832
3880
  return "error";
3833
3881
  };
3834
3882
 
3883
+ //#endregion
3884
+ //#region src/utils/spinner.ts
3885
+ const createNoopHandle = () => ({
3886
+ succeed: () => void 0,
3887
+ fail: () => void 0,
3888
+ stop: () => void 0
3889
+ });
3890
+ const shouldRenderSpinner = () => Boolean(process.stderr.isTTY) && process.env.CI !== "true" && process.env.CI !== "1";
3891
+ const spinner = (text) => ({ start() {
3892
+ if (!shouldRenderSpinner()) return createNoopHandle();
3893
+ const instance = ora({ text }).start();
3894
+ return {
3895
+ succeed: (displayText) => instance.succeed(displayText),
3896
+ fail: (displayText) => instance.fail(displayText),
3897
+ stop: () => instance.stop()
3898
+ };
3899
+ } });
3900
+
3835
3901
  //#endregion
3836
3902
  //#region src/utils/telemetry.ts
3837
3903
  /**
@@ -3925,6 +3991,241 @@ const trackEvent = (event) => {
3925
3991
  }).then(() => {}).catch(() => {});
3926
3992
  };
3927
3993
 
3994
+ //#endregion
3995
+ //#region src/commands/fix-code.ts
3996
+ const CONTEXT_LINES = 3;
3997
+ const MAX_DIAGNOSTICS_PER_FILE = 10;
3998
+ const MAX_FILES = 20;
3999
+ const AGENT_CONFIGS = {
4000
+ claude: {
4001
+ type: "cli",
4002
+ bin: "claude",
4003
+ args: (p) => [p]
4004
+ },
4005
+ codex: {
4006
+ type: "cli",
4007
+ bin: "codex",
4008
+ args: (p) => [p]
4009
+ },
4010
+ amp: {
4011
+ type: "cli",
4012
+ bin: "amp",
4013
+ args: (p) => [p]
4014
+ },
4015
+ antigravity: {
4016
+ type: "cli",
4017
+ bin: "antigravity",
4018
+ args: (p) => [p]
4019
+ },
4020
+ "deep-agents": {
4021
+ type: "cli",
4022
+ bin: "deep-agents",
4023
+ args: (p) => [p]
4024
+ },
4025
+ gemini: {
4026
+ type: "cli",
4027
+ bin: "gemini",
4028
+ args: (p) => [p]
4029
+ },
4030
+ kimi: {
4031
+ type: "cli",
4032
+ bin: "kimi",
4033
+ args: (p) => [p]
4034
+ },
4035
+ opencode: {
4036
+ type: "cli",
4037
+ bin: "opencode",
4038
+ args: (p) => ["run", p]
4039
+ },
4040
+ warp: {
4041
+ type: "cli",
4042
+ bin: "warp",
4043
+ args: (p) => [p]
4044
+ },
4045
+ aider: {
4046
+ type: "cli",
4047
+ bin: "aider",
4048
+ args: (p) => ["--message", p]
4049
+ },
4050
+ goose: {
4051
+ type: "cli",
4052
+ bin: "goose",
4053
+ args: (p) => ["run", p]
4054
+ },
4055
+ cursor: {
4056
+ type: "editor",
4057
+ bin: "cursor"
4058
+ },
4059
+ windsurf: {
4060
+ type: "editor",
4061
+ bin: "windsurf"
4062
+ },
4063
+ vscode: {
4064
+ type: "editor",
4065
+ bin: "code"
4066
+ }
4067
+ };
4068
+ const getCodeSnippet = (rootDirectory, diagnostic) => {
4069
+ if (diagnostic.line <= 0) return null;
4070
+ const absolutePath = path.resolve(rootDirectory, diagnostic.filePath);
4071
+ let content;
4072
+ try {
4073
+ content = fs.readFileSync(absolutePath, "utf-8");
4074
+ } catch {
4075
+ return null;
4076
+ }
4077
+ const lines = content.split("\n");
4078
+ const startLine = Math.max(0, diagnostic.line - 1 - CONTEXT_LINES);
4079
+ const endLine = Math.min(lines.length, diagnostic.line + CONTEXT_LINES);
4080
+ const snippet = [];
4081
+ for (let i = startLine; i < endLine; i++) {
4082
+ const lineNum = i + 1;
4083
+ const marker = lineNum === diagnostic.line ? "→" : " ";
4084
+ snippet.push(`${marker} ${String(lineNum).padStart(4)} │ ${lines[i]}`);
4085
+ }
4086
+ return snippet.join("\n");
4087
+ };
4088
+ const groupByFile = (diagnostics) => {
4089
+ const map = /* @__PURE__ */ new Map();
4090
+ for (const d of diagnostics) {
4091
+ const list = map.get(d.filePath) ?? [];
4092
+ list.push(d);
4093
+ map.set(d.filePath, list);
4094
+ }
4095
+ return [...map.entries()].map(([filePath, diags]) => ({
4096
+ filePath,
4097
+ diagnostics: diags
4098
+ })).sort((a, b) => {
4099
+ const aErrors = a.diagnostics.filter((d) => d.severity === "error").length;
4100
+ const bErrors = b.diagnostics.filter((d) => d.severity === "error").length;
4101
+ if (aErrors !== bErrors) return bErrors - aErrors;
4102
+ return b.diagnostics.length - a.diagnostics.length;
4103
+ });
4104
+ };
4105
+ const isInstalled = (bin) => {
4106
+ return spawnSync(process.platform === "win32" ? "where" : "which", [bin], { encoding: "utf-8" }).status === 0;
4107
+ };
4108
+ const copyToClipboard = (text) => {
4109
+ const args = {
4110
+ darwin: ["pbcopy"],
4111
+ linux: [
4112
+ "xclip",
4113
+ "-selection",
4114
+ "clipboard"
4115
+ ],
4116
+ win32: ["clip"]
4117
+ }[process.platform];
4118
+ if (!args) return false;
4119
+ const [bin, ...rest] = args;
4120
+ return spawnSync(bin, rest, {
4121
+ input: text,
4122
+ encoding: "utf-8"
4123
+ }).status === 0;
4124
+ };
4125
+ const buildAgentPrompt = (rootDirectory, diagnostics, score) => {
4126
+ const groups = groupByFile(diagnostics).slice(0, MAX_FILES);
4127
+ const errorCount = diagnostics.filter((d) => d.severity === "error").length;
4128
+ const warningCount = diagnostics.filter((d) => d.severity === "warning").length;
4129
+ const lines = [
4130
+ `Fix the following ${diagnostics.length} code quality issue${diagnostics.length === 1 ? "" : "s"} found by aislop (current score: ${score}/100).`,
4131
+ "",
4132
+ `Summary: ${errorCount} error${errorCount === 1 ? "" : "s"}, ${warningCount} warning${warningCount === 1 ? "" : "s"} across ${groups.length} file${groups.length === 1 ? "" : "s"}.`,
4133
+ ""
4134
+ ];
4135
+ for (const group of groups) {
4136
+ lines.push(`## ${group.filePath}`);
4137
+ lines.push("");
4138
+ const fileDiags = group.diagnostics.slice(0, MAX_DIAGNOSTICS_PER_FILE);
4139
+ for (const d of fileDiags) {
4140
+ const severity = d.severity === "error" ? "ERROR" : d.severity === "warning" ? "WARN" : "INFO";
4141
+ const location = d.line > 0 ? ` (line ${d.line})` : "";
4142
+ lines.push(`**[${severity}]** \`${d.rule}\`${location}: ${d.message}`);
4143
+ if (d.help) lines.push(`> ${d.help}`);
4144
+ const snippet = getCodeSnippet(rootDirectory, d);
4145
+ if (snippet) {
4146
+ lines.push("```");
4147
+ lines.push(snippet);
4148
+ lines.push("```");
4149
+ }
4150
+ lines.push("");
4151
+ }
4152
+ if (group.diagnostics.length > MAX_DIAGNOSTICS_PER_FILE) {
4153
+ lines.push(`_...and ${group.diagnostics.length - MAX_DIAGNOSTICS_PER_FILE} more issue${group.diagnostics.length - MAX_DIAGNOSTICS_PER_FILE === 1 ? "" : "s"} in this file._`);
4154
+ lines.push("");
4155
+ }
4156
+ }
4157
+ const totalGroups = groupByFile(diagnostics).length;
4158
+ if (totalGroups > MAX_FILES) {
4159
+ const remaining = totalGroups - MAX_FILES;
4160
+ lines.push(`_...and ${remaining} more file${remaining === 1 ? "" : "s"} with issues._`);
4161
+ lines.push("");
4162
+ }
4163
+ lines.push("---");
4164
+ lines.push("Fix each issue following the guidance above. Prioritize errors over warnings.");
4165
+ lines.push("After making changes, run `npx aislop scan` to verify all issues are resolved and the score improves.");
4166
+ return lines.join("\n");
4167
+ };
4168
+ const SUPPORTED_AGENT_NAMES = Object.keys(AGENT_CONFIGS);
4169
+ const launchAgent = (agent, rootDirectory, diagnostics, score) => {
4170
+ if (diagnostics.length === 0) {
4171
+ logger.success(" No remaining issues — nothing to hand off.");
4172
+ return;
4173
+ }
4174
+ const config = AGENT_CONFIGS[agent];
4175
+ if (!config) {
4176
+ logger.error(` Unknown agent: ${agent}`);
4177
+ logger.dim(` Supported: ${SUPPORTED_AGENT_NAMES.join(", ")}`);
4178
+ return;
4179
+ }
4180
+ if (!isInstalled(config.bin)) {
4181
+ logger.error(` ${agent} is not installed or not in PATH.`);
4182
+ logger.dim(` Install it first, or use ${highlighter.info("fix -p")} to print the prompt manually.`);
4183
+ return;
4184
+ }
4185
+ const prompt = buildAgentPrompt(rootDirectory, diagnostics, score);
4186
+ if (config.type === "editor") {
4187
+ const copied = copyToClipboard(prompt);
4188
+ logger.break();
4189
+ if (copied) logger.log(` ${highlighter.success("✓")} Prompt copied to clipboard (${diagnostics.length} issue${diagnostics.length === 1 ? "" : "s"})`);
4190
+ else logger.warn(" Could not copy to clipboard. Use fix --prompt to print it instead.");
4191
+ logger.log(` ${highlighter.info("→")} Opening ${highlighter.bold(agent)}... paste the prompt into the agent chat.`);
4192
+ logger.break();
4193
+ spawnSync(config.bin, ["."], {
4194
+ cwd: rootDirectory,
4195
+ stdio: "inherit"
4196
+ });
4197
+ return;
4198
+ }
4199
+ logger.break();
4200
+ logger.log(` ${highlighter.info("→")} Opening ${highlighter.bold(agent)} with ${diagnostics.length} issue${diagnostics.length === 1 ? "" : "s"}...`);
4201
+ logger.break();
4202
+ spawnSync(config.bin, config.args(prompt), {
4203
+ cwd: rootDirectory,
4204
+ stdio: "inherit"
4205
+ });
4206
+ };
4207
+ const printPrompt = (rootDirectory, diagnostics, score) => {
4208
+ if (diagnostics.length === 0) {
4209
+ logger.success(" No remaining issues — nothing to generate.");
4210
+ return;
4211
+ }
4212
+ const prompt = buildAgentPrompt(rootDirectory, diagnostics, score);
4213
+ if (!process.stdout.isTTY) {
4214
+ process.stdout.write(prompt);
4215
+ return;
4216
+ }
4217
+ logger.break();
4218
+ logger.log(highlighter.bold("Agent prompt"));
4219
+ logger.log(highlighter.dim(" Copy the prompt below, or pipe it: fix -p | pbcopy"));
4220
+ logger.log(highlighter.dim(" Or launch directly: fix --claude, fix --cursor, fix --codex, etc."));
4221
+ logger.log(highlighter.dim(" Editor agents (--cursor, --windsurf, --vscode) auto-copy to clipboard."));
4222
+ logger.break();
4223
+ logger.log(highlighter.dim("╭─────────────────────────────────────────────────────────╮"));
4224
+ for (const line of prompt.split("\n")) logger.log(` ${line}`);
4225
+ logger.log(highlighter.dim("╰─────────────────────────────────────────────────────────╯"));
4226
+ logger.break();
4227
+ };
4228
+
3928
4229
  //#endregion
3929
4230
  //#region src/commands/fix-force.ts
3930
4231
  const getJsAuditFixCommand = (rootDirectory) => {
@@ -3945,9 +4246,69 @@ const fixDependencyAudit = async (context) => {
3945
4246
  cwd: context.rootDirectory,
3946
4247
  timeout: 18e4
3947
4248
  });
3948
- if (result.exitCode !== 0) throw new Error(result.stderr || result.stdout || `${auditFix.command} audit fix failed`);
4249
+ if (result.exitCode !== 0 && !result.stdout && !result.stderr) throw new Error(`${auditFix.command} audit fix failed`);
4250
+ const installResult = await runSubprocess(auditFix.command, ["install"], {
4251
+ cwd: context.rootDirectory,
4252
+ timeout: 18e4
4253
+ });
4254
+ if (installResult.exitCode !== 0) throw new Error(installResult.stderr || installResult.stdout || `${auditFix.command} install failed after audit fix`);
4255
+ if (auditFix.command === "npm") await tryNpmOverrides(context.rootDirectory);
4256
+ };
4257
+ /**
4258
+ * For unresolvable transitive vulnerabilities, attempt to add npm overrides
4259
+ * in package.json. This forces a newer version of the vulnerable transitive dep.
4260
+ */
4261
+ const fetchLatestVersion = async (rootDir, pkgName) => {
4262
+ try {
4263
+ const result = await runSubprocess("npm", [
4264
+ "view",
4265
+ pkgName,
4266
+ "version",
4267
+ "--json"
4268
+ ], {
4269
+ cwd: rootDir,
4270
+ timeout: 1e4
4271
+ });
4272
+ return result.stdout ? JSON.parse(result.stdout) : null;
4273
+ } catch {
4274
+ return null;
4275
+ }
4276
+ };
4277
+ const collectOverrides = async (rootDir, vulnerabilities) => {
4278
+ const overrides = {};
4279
+ for (const [pkgName, vuln] of Object.entries(vulnerabilities)) {
4280
+ if (vuln.fixAvailable !== false || !vuln.range) continue;
4281
+ const latest = await fetchLatestVersion(rootDir, pkgName);
4282
+ if (latest) overrides[pkgName] = latest;
4283
+ }
4284
+ return overrides;
4285
+ };
4286
+ const tryNpmOverrides = async (rootDir) => {
4287
+ try {
4288
+ const auditResult = await runSubprocess("npm", ["audit", "--json"], {
4289
+ cwd: rootDir,
4290
+ timeout: 3e4
4291
+ });
4292
+ if (!auditResult.stdout) return;
4293
+ const vulnerabilities = JSON.parse(auditResult.stdout).vulnerabilities;
4294
+ if (!vulnerabilities) return;
4295
+ const overrides = await collectOverrides(rootDir, vulnerabilities);
4296
+ if (Object.keys(overrides).length === 0) return;
4297
+ const pkgPath = path.join(rootDir, "package.json");
4298
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
4299
+ pkg.overrides = {
4300
+ ...pkg.overrides || {},
4301
+ ...overrides
4302
+ };
4303
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
4304
+ await runSubprocess("npm", ["install"], {
4305
+ cwd: rootDir,
4306
+ timeout: 18e4
4307
+ });
4308
+ } catch {}
3949
4309
  };
3950
4310
  const fixExpoDependencies = async (context) => {
4311
+ await removeDisallowedExpoPackages(context.rootDirectory);
3951
4312
  if ((await runSubprocess("npx", [
3952
4313
  "--yes",
3953
4314
  "expo",
@@ -3968,6 +4329,56 @@ const fixExpoDependencies = async (context) => {
3968
4329
  });
3969
4330
  if (checkResult.exitCode !== 0) throw new Error(checkResult.stderr || checkResult.stdout || "expo dependency check failed");
3970
4331
  };
4332
+ /**
4333
+ * Run expo-doctor to detect packages that should not be installed directly,
4334
+ * then uninstall them. No hardcoded list — expo-doctor is the source of truth.
4335
+ */
4336
+ const removeDisallowedExpoPackages = async (rootDir) => {
4337
+ try {
4338
+ const result = await runSubprocess("npx", [
4339
+ "--yes",
4340
+ "expo-doctor",
4341
+ rootDir
4342
+ ], {
4343
+ cwd: rootDir,
4344
+ timeout: 12e4
4345
+ });
4346
+ const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
4347
+ const packagePattern = /The package "([^"]+)" should not be installed directly/g;
4348
+ const toRemove = [];
4349
+ let match;
4350
+ while ((match = packagePattern.exec(output)) !== null) toRemove.push(match[1]);
4351
+ if (toRemove.length === 0) return;
4352
+ await runSubprocess("npm", ["uninstall", ...toRemove], {
4353
+ cwd: rootDir,
4354
+ timeout: 6e4
4355
+ });
4356
+ } catch {}
4357
+ };
4358
+
4359
+ //#endregion
4360
+ //#region src/commands/fix-plan.ts
4361
+ const hasJsTs = (projectInfo) => projectInfo.languages.includes("typescript") || projectInfo.languages.includes("javascript");
4362
+ const buildFixStepNames = (projectInfo, config, options) => {
4363
+ const stepNames = [];
4364
+ if (config.engines["ai-slop"]) stepNames.push("Unused imports", "Dead code & comments");
4365
+ if (config.engines.lint) {
4366
+ if (hasJsTs(projectInfo)) stepNames.push("JS/TS lint fixes");
4367
+ if (projectInfo.languages.includes("python") && projectInfo.installedTools.ruff) stepNames.push("Python lint fixes");
4368
+ }
4369
+ if (config.engines["code-quality"] && hasJsTs(projectInfo)) stepNames.push("Unused dependencies");
4370
+ if (config.engines.format) {
4371
+ if (hasJsTs(projectInfo)) stepNames.push("JS/TS formatting");
4372
+ if (projectInfo.languages.includes("python") && projectInfo.installedTools.ruff) stepNames.push("Python formatting");
4373
+ if (projectInfo.languages.includes("go") && projectInfo.installedTools.gofmt) stepNames.push("Go formatting");
4374
+ }
4375
+ if (options.force) {
4376
+ if (config.engines["code-quality"] && hasJsTs(projectInfo)) stepNames.push("Remove unused files");
4377
+ if (config.engines.security) stepNames.push("Dependency audit fixes");
4378
+ if (projectInfo.frameworks.includes("expo")) stepNames.push("Expo dependency alignment");
4379
+ }
4380
+ return stepNames;
4381
+ };
3971
4382
 
3972
4383
  //#endregion
3973
4384
  //#region src/commands/fix-step.ts
@@ -3999,12 +4410,12 @@ const runFixStep = async (name, detect, applyFix, options, progress) => {
3999
4410
  const stepStart = performance.now();
4000
4411
  const before = await detect();
4001
4412
  let applyError = null;
4002
- try {
4413
+ if (before.length > 0) try {
4003
4414
  await applyFix();
4004
4415
  } catch (error) {
4005
4416
  applyError = error;
4006
4417
  }
4007
- const after = await detect();
4418
+ const after = before.length > 0 ? await detect() : before;
4008
4419
  const elapsedMs = performance.now() - stepStart;
4009
4420
  const result = {
4010
4421
  name,
@@ -4047,7 +4458,7 @@ const summarizeFixRun = (steps) => {
4047
4458
  logger.log(` Fix summary: checked ${steps.length} step(s), resolved ${totals.resolvedIssues} issue(s).`);
4048
4459
  logger.warn(` ${totals.failedSteps} step(s) reported tool errors; unresolved issue count is unknown for failed steps.`);
4049
4460
  } else logger.log(` Fix summary: checked ${steps.length} step(s), resolved ${totals.resolvedIssues} issue(s), remaining ${totals.afterIssues}.`);
4050
- if (totals.failedSteps === 0 && totals.beforeIssues > 0 && totals.resolvedIssues === 0) logger.dim(" No auto-fixable changes were applied. Current findings are likely manual-fix categories.");
4461
+ if (totals.failedSteps === 0 && totals.beforeIssues > 0 && totals.resolvedIssues === 0) logger.dim(" Remaining issues require manual fixes or agent assistance. Run `scan` for details.");
4051
4462
  };
4052
4463
 
4053
4464
  //#endregion
@@ -4067,34 +4478,18 @@ const fixCommand = async (directory, config, options = {
4067
4478
  showHeader: true
4068
4479
  }) => {
4069
4480
  const resolvedDir = path.resolve(directory);
4481
+ if (!fs.existsSync(resolvedDir) || !fs.statSync(resolvedDir).isDirectory()) {
4482
+ const msg = !fs.existsSync(resolvedDir) ? `Path does not exist: ${resolvedDir}` : `Not a directory: ${resolvedDir}`;
4483
+ logger.error(` ${msg}`);
4484
+ return;
4485
+ }
4070
4486
  if (options.showHeader !== false) printCommandHeader("Fix");
4071
4487
  const projectInfo = await discoverProject(resolvedDir);
4072
4488
  logger.success(` ✓ ${formatProjectSummary(projectInfo)}`);
4073
4489
  printProjectMetadata(projectInfo);
4074
4490
  const context = createEngineContext(resolvedDir, projectInfo, config);
4075
4491
  const steps = [];
4076
- const stepNames = [];
4077
- if (config.engines["ai-slop"]) {
4078
- stepNames.push("Unused imports");
4079
- stepNames.push("Dead code & comments");
4080
- }
4081
- if (config.engines.lint) {
4082
- if (projectInfo.languages.includes("typescript") || projectInfo.languages.includes("javascript")) stepNames.push("JS/TS lint fixes");
4083
- if (projectInfo.languages.includes("python") && projectInfo.installedTools.ruff) stepNames.push("Python lint fixes");
4084
- }
4085
- if (config.engines["code-quality"]) {
4086
- if (projectInfo.languages.includes("typescript") || projectInfo.languages.includes("javascript")) stepNames.push("Unused dependencies");
4087
- }
4088
- if (config.engines.format) {
4089
- if (projectInfo.languages.includes("typescript") || projectInfo.languages.includes("javascript")) stepNames.push("JS/TS formatting");
4090
- if (projectInfo.languages.includes("python") && projectInfo.installedTools.ruff) stepNames.push("Python formatting");
4091
- if (projectInfo.languages.includes("go") && projectInfo.installedTools.gofmt) stepNames.push("Go formatting");
4092
- }
4093
- if (options.force) {
4094
- if (config.engines.security) stepNames.push("Dependency audit fixes");
4095
- if (projectInfo.frameworks.includes("expo")) stepNames.push("Expo dependency alignment");
4096
- }
4097
- const progress = new FixProgressRenderer(stepNames);
4492
+ const progress = new FixProgressRenderer(buildFixStepNames(projectInfo, config, options));
4098
4493
  progress.start();
4099
4494
  if (config.engines["ai-slop"]) {
4100
4495
  steps.push(await runFixStep("Unused imports", () => detectUnusedImports(context), () => fixUnusedImports(context), options, progress));
@@ -4120,6 +4515,7 @@ const fixCommand = async (directory, config, options = {
4120
4515
  else if (projectInfo.languages.includes("go")) logger.warn(" Go detected but gofmt is not installed; skipping Go formatting fixes.");
4121
4516
  }
4122
4517
  if (options.force) {
4518
+ if (config.engines["code-quality"] && (projectInfo.languages.includes("typescript") || projectInfo.languages.includes("javascript"))) steps.push(await runFixStep("Remove unused files", () => runKnipUnusedFiles(resolvedDir), () => fixUnusedFiles(resolvedDir), options, progress));
4123
4519
  if (config.engines.security) steps.push(await runFixStep("Dependency audit fixes", () => runDependencyAudit(context), () => fixDependencyAudit(context), options, progress));
4124
4520
  if (projectInfo.frameworks.includes("expo")) steps.push(await runFixStep("Expo dependency alignment", () => runExpoDoctor(context), () => fixExpoDependencies(context), options, progress));
4125
4521
  }
@@ -4137,6 +4533,7 @@ const fixCommand = async (directory, config, options = {
4137
4533
  fixResolved: totalResolved
4138
4534
  });
4139
4535
  logger.break();
4536
+ const verifySpinner = spinner(" Verifying results...").start();
4140
4537
  const configDir = findConfigDir(resolvedDir);
4141
4538
  const rulesPath = configDir ? path.join(configDir, RULES_FILE) : void 0;
4142
4539
  const engineConfig = {
@@ -4144,13 +4541,15 @@ const fixCommand = async (directory, config, options = {
4144
4541
  security: config.security,
4145
4542
  architectureRulesPath: config.engines.architecture ? rulesPath : void 0
4146
4543
  };
4147
- const allDiagnostics = (await runEngines({
4544
+ const scanResults = await runEngines({
4148
4545
  rootDirectory: resolvedDir,
4149
4546
  languages: projectInfo.languages,
4150
4547
  frameworks: projectInfo.frameworks,
4151
4548
  installedTools: projectInfo.installedTools,
4152
4549
  config: engineConfig
4153
- }, config.engines, () => {}, () => {})).flatMap((r) => r.diagnostics);
4550
+ }, config.engines, () => {}, () => {});
4551
+ verifySpinner.stop();
4552
+ const allDiagnostics = scanResults.flatMap((r) => r.diagnostics);
4154
4553
  const scoreResult = calculateScore(allDiagnostics, config.scoring.weights, config.scoring.thresholds, projectInfo.sourceFileCount, config.scoring.smoothing);
4155
4554
  const errors = allDiagnostics.filter((d) => d.severity === "error").length;
4156
4555
  const warnings = allDiagnostics.filter((d) => d.severity === "warning").length;
@@ -4165,27 +4564,29 @@ const fixCommand = async (directory, config, options = {
4165
4564
  if (fixable > 0) logger.log(` Auto-fixable: ${highlighter.info(String(fixable))}`);
4166
4565
  if (manual > 0) logger.log(` Manual effort: ${highlighter.dim(String(manual))}`);
4167
4566
  logger.log(highlighter.dim("------------------------------------------------------------"));
4567
+ if (options.agent) {
4568
+ launchAgent(options.agent, resolvedDir, allDiagnostics, scoreResult.score);
4569
+ return;
4570
+ }
4571
+ if (options.prompt) {
4572
+ printPrompt(resolvedDir, allDiagnostics, scoreResult.score);
4573
+ return;
4574
+ }
4575
+ const nextSteps = [];
4576
+ if (!options.force && errors + warnings > 0) nextSteps.push(`Run ${highlighter.info("fix -f")} to try aggressive fixes (dependency audit, unused files, unsafe lint)`);
4577
+ if (errors + warnings > 0 && !options.agent && !options.prompt) {
4578
+ nextSteps.push(`Run ${highlighter.info("fix --<agent>")} to hand off to a coding agent, or ${highlighter.info("fix --prompt")} to copy the prompt`);
4579
+ nextSteps.push(highlighter.dim("Agents: --claude, --codex, --gemini, --opencode, --cursor, --amp, --aider, --goose and more"));
4580
+ }
4581
+ if (errors + warnings > 0) nextSteps.push(`Run ${highlighter.info("scan")} to see remaining issues with full details`);
4582
+ if (nextSteps.length > 0) {
4583
+ logger.break();
4584
+ logger.log(highlighter.bold("Next steps"));
4585
+ for (let i = 0; i < nextSteps.length; i++) logger.log(` ${i + 1}. ${nextSteps[i]}`);
4586
+ }
4168
4587
  logger.break();
4169
4588
  };
4170
4589
 
4171
- //#endregion
4172
- //#region src/utils/spinner.ts
4173
- const createNoopHandle = () => ({
4174
- succeed: () => void 0,
4175
- fail: () => void 0,
4176
- stop: () => void 0
4177
- });
4178
- const shouldRenderSpinner = () => Boolean(process.stderr.isTTY) && process.env.CI !== "true" && process.env.CI !== "1";
4179
- const spinner = (text) => ({ start() {
4180
- if (!shouldRenderSpinner()) return createNoopHandle();
4181
- const instance = ora({ text }).start();
4182
- return {
4183
- succeed: (displayText) => instance.succeed(displayText),
4184
- fail: (displayText) => instance.fail(displayText),
4185
- stop: () => instance.stop()
4186
- };
4187
- } });
4188
-
4189
4590
  //#endregion
4190
4591
  //#region src/commands/init.ts
4191
4592
  const initCommand = async (directory) => {
@@ -4406,7 +4807,7 @@ const renderSummary = (diagnostics, scoreResult, elapsedMs, fileCount, threshold
4406
4807
  const warningCount = diagnostics.filter((d) => d.severity === "warning").length;
4407
4808
  const fixableCount = diagnostics.filter((d) => d.fixable).length;
4408
4809
  const elapsed = toElapsedLabel(elapsedMs);
4409
- return `${[
4810
+ const lines = [
4410
4811
  highlighter.dim("------------------------------------------------------------"),
4411
4812
  highlighter.bold("Summary"),
4412
4813
  ` Score: ${colorByScore(`${scoreResult.score}/${PERFECT_SCORE}`, scoreResult.score, thresholds)} ${colorByScore(`(${scoreResult.label})`, scoreResult.score, thresholds)}`,
@@ -4415,7 +4816,15 @@ const renderSummary = (diagnostics, scoreResult, elapsedMs, fileCount, threshold
4415
4816
  ` Files: ${highlighter.info(String(fileCount))}`,
4416
4817
  ` Time: ${highlighter.info(elapsed)}`,
4417
4818
  highlighter.dim("------------------------------------------------------------")
4418
- ].join("\n")}\n`;
4819
+ ];
4820
+ if (errorCount + warningCount > 0) {
4821
+ lines.push("");
4822
+ lines.push(highlighter.bold("Next steps"));
4823
+ if (fixableCount > 0) lines.push(` ${highlighter.info("→")} Run ${highlighter.info("fix")} to auto-fix ${fixableCount} issue${fixableCount === 1 ? "" : "s"}`);
4824
+ lines.push(` ${highlighter.info("→")} Run ${highlighter.info("fix -f")} to apply all available fixes (includes dependency audit)`);
4825
+ lines.push(` ${highlighter.dim("→")} Run ${highlighter.dim("fix --<agent>")} to hand off to a coding agent (see ${highlighter.dim("fix --help")} for supported agents)`);
4826
+ }
4827
+ return `${lines.join("\n")}\n`;
4419
4828
  };
4420
4829
  const printEngineStatus = (result) => {
4421
4830
  const label = getEngineLabel(result.engine);
@@ -4473,6 +4882,18 @@ const ALL_ENGINE_NAMES = Object.keys(ENGINE_INFO);
4473
4882
  const scanCommand = async (directory, config, options) => {
4474
4883
  const startTime = performance.now();
4475
4884
  const resolvedDir = path.resolve(directory);
4885
+ if (!fs.existsSync(resolvedDir)) {
4886
+ const msg = `Path does not exist: ${resolvedDir}`;
4887
+ if (options.json) console.log(JSON.stringify({ error: msg }, null, 2));
4888
+ else logger.error(` ${msg}`);
4889
+ return { exitCode: 1 };
4890
+ }
4891
+ if (!fs.statSync(resolvedDir).isDirectory()) {
4892
+ const msg = `Not a directory: ${resolvedDir}`;
4893
+ if (options.json) console.log(JSON.stringify({ error: msg }, null, 2));
4894
+ else logger.error(` ${msg}`);
4895
+ return { exitCode: 1 };
4896
+ }
4476
4897
  const showHeader = options.showHeader !== false;
4477
4898
  const useLiveProgress = !options.json && shouldUseSpinner();
4478
4899
  if (!options.json && showHeader) printCommandHeader("Scan");
@@ -4541,7 +4962,7 @@ const scanCommand = async (directory, config, options) => {
4541
4962
  });
4542
4963
  }
4543
4964
  if (options.json) {
4544
- const { buildJsonOutput } = await import("./json-gLNX92Bu.js");
4965
+ const { buildJsonOutput } = await import("./json-CdzBXUbz.js");
4545
4966
  const jsonOut = buildJsonOutput(results, scoreResult, projectInfo.sourceFileCount, elapsedMs);
4546
4967
  console.log(JSON.stringify(jsonOut, null, 2));
4547
4968
  return { exitCode };