aislop 0.3.1 → 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/README.md +48 -19
- package/dist/cli.js +607 -161
- package/dist/{engine-info-D19chfzD.js → engine-info-s7Gy2StX.js} +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +596 -175
- package/dist/{json-DD60WkDS.js → json-CdzBXUbz.js} +1 -1
- package/package.json +2 -2
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-
|
|
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
|
-
|
|
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("|")}
|
|
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))
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
-
*
|
|
1036
|
-
* - ai-slop/
|
|
1037
|
-
*
|
|
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
|
|
1046
|
-
|
|
1047
|
-
|
|
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,
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
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 =
|
|
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:
|
|
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 =
|
|
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: "
|
|
1788
|
-
rule: "
|
|
1897
|
+
plugin: "eslint",
|
|
1898
|
+
rule: "syntax-error"
|
|
1789
1899
|
};
|
|
1790
1900
|
const match = code.match(/^(.+)\((.+)\)$/);
|
|
1791
1901
|
if (!match) return {
|
|
1792
|
-
plugin: "
|
|
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(
|
|
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
|
|
3213
|
+
const runPnpmAuditWithFallback = async (rootDir, timeout) => {
|
|
3214
|
+
const canFallbackToNpm = fs.existsSync(path.join(rootDir, "package-lock.json"));
|
|
3175
3215
|
try {
|
|
3176
|
-
|
|
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
|
|
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("
|
|
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
|
|
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
|
|
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, () => {}, () => {})
|
|
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
|
-
|
|
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
|
-
]
|
|
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-
|
|
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 };
|