aislop 0.9.4 → 0.9.6
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 +50 -3
- package/dist/cli.js +1046 -62
- package/dist/{version-C45P3Q1N.js → engine-info-DCvIfZ0f.js} +1 -5
- package/dist/index.d.ts +6 -0
- package/dist/index.js +770 -51
- package/dist/{json-CXiEvR_M.js → json-CxiErSgX.js} +2 -1
- package/dist/mcp.js +665 -39
- package/dist/sarif-CLVijBAO.js +60 -0
- package/dist/sarif-CZVuavf_.js +61 -0
- package/dist/version-CPpO6jbj.js +5 -0
- package/package.json +3 -2
- package/scripts/gen-config-schema.mjs +35 -0
- /package/dist/{expo-doctor-Bz0LZhQ6.js → expo-doctor-BcIkOte5.js} +0 -0
- /package/dist/{generic-BrcWMW7E.js → generic-D_T4cUaC.js} +0 -0
- /package/dist/{typecheck-XJMuCczG.js → typecheck-DQSzG8fX.js} +0 -0
package/dist/cli.js
CHANGED
|
@@ -10,8 +10,8 @@ import YAML from "yaml";
|
|
|
10
10
|
import { z } from "zod/v4";
|
|
11
11
|
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
12
12
|
import micromatch from "micromatch";
|
|
13
|
-
import { fileURLToPath } from "node:url";
|
|
14
13
|
import ts from "typescript";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { isCancel, multiselect, select, text } from "@clack/prompts";
|
|
16
16
|
import pc from "picocolors";
|
|
17
17
|
import wcwidth from "wcwidth";
|
|
@@ -34,7 +34,7 @@ var __exportAll = (all, no_symbols) => {
|
|
|
34
34
|
|
|
35
35
|
//#endregion
|
|
36
36
|
//#region src/version.ts
|
|
37
|
-
const APP_VERSION = "0.9.
|
|
37
|
+
const APP_VERSION = "0.9.6";
|
|
38
38
|
|
|
39
39
|
//#endregion
|
|
40
40
|
//#region src/telemetry/env.ts
|
|
@@ -180,8 +180,8 @@ const redactProperties = (props) => {
|
|
|
180
180
|
|
|
181
181
|
//#endregion
|
|
182
182
|
//#region src/telemetry/client.ts
|
|
183
|
-
const POSTHOG_HOST = "https://eu.i.posthog.com";
|
|
184
|
-
const POSTHOG_KEY = "phc_eY2cOMFva9q24GrWeOuvuVIOhCIdjOALxeAR3ItrqbJ";
|
|
183
|
+
const POSTHOG_HOST = process.env.AISLOP_POSTHOG_HOST ?? "https://eu.i.posthog.com";
|
|
184
|
+
const POSTHOG_KEY = process.env.AISLOP_POSTHOG_KEY ?? "phc_eY2cOMFva9q24GrWeOuvuVIOhCIdjOALxeAR3ItrqbJ";
|
|
185
185
|
const SCHEMA_VERSION = "v2";
|
|
186
186
|
const REQUEST_TIMEOUT_MS = 3e3;
|
|
187
187
|
const isTelemetryDisabled = (config) => {
|
|
@@ -602,7 +602,8 @@ const DEFAULT_CONFIG = {
|
|
|
602
602
|
failBelow: 70,
|
|
603
603
|
format: "json"
|
|
604
604
|
},
|
|
605
|
-
telemetry: { enabled: true }
|
|
605
|
+
telemetry: { enabled: true },
|
|
606
|
+
rules: {}
|
|
606
607
|
};
|
|
607
608
|
const GITHUB_WORKFLOW_DIR = ".github/workflows";
|
|
608
609
|
const GITHUB_WORKFLOW_FILE = "aislop.yml";
|
|
@@ -729,6 +730,12 @@ const CiSchema = z.object({
|
|
|
729
730
|
format: z.enum(["json"]).default("json")
|
|
730
731
|
});
|
|
731
732
|
const TelemetrySchema = z.object({ enabled: z.boolean().default(true) });
|
|
733
|
+
const RuleSeverityOverride = z.enum([
|
|
734
|
+
"error",
|
|
735
|
+
"warning",
|
|
736
|
+
"off"
|
|
737
|
+
]);
|
|
738
|
+
const RulesSchema = z.record(z.string(), RuleSeverityOverride).default(() => ({}));
|
|
732
739
|
const AislopConfigSchema = z.object({
|
|
733
740
|
version: z.number().default(1),
|
|
734
741
|
engines: EnginesSchema.default(() => ({
|
|
@@ -763,6 +770,7 @@ const AislopConfigSchema = z.object({
|
|
|
763
770
|
format: "json"
|
|
764
771
|
})),
|
|
765
772
|
telemetry: TelemetrySchema.default(() => ({ enabled: true })),
|
|
773
|
+
rules: RulesSchema,
|
|
766
774
|
exclude: z.array(z.string()).default(() => [
|
|
767
775
|
"node_modules",
|
|
768
776
|
".git",
|
|
@@ -831,7 +839,7 @@ const loadConfig = (directory) => {
|
|
|
831
839
|
//#endregion
|
|
832
840
|
//#region src/utils/source-files.ts
|
|
833
841
|
const MAX_BUFFER$1 = 50 * 1024 * 1024;
|
|
834
|
-
const SOURCE_EXTENSIONS = new Set([
|
|
842
|
+
const SOURCE_EXTENSIONS$1 = new Set([
|
|
835
843
|
".ts",
|
|
836
844
|
".tsx",
|
|
837
845
|
".js",
|
|
@@ -939,7 +947,7 @@ const toProjectPath = (rootDirectory, filePath) => {
|
|
|
939
947
|
const isWithinProject = (relativePath) => relativePath.length > 0 && !relativePath.startsWith("..");
|
|
940
948
|
const hasAllowedExtension = (filePath, extraExtensions) => {
|
|
941
949
|
const extension = path.extname(filePath);
|
|
942
|
-
return SOURCE_EXTENSIONS.has(extension) || extraExtensions.has(extension);
|
|
950
|
+
return SOURCE_EXTENSIONS$1.has(extension) || extraExtensions.has(extension);
|
|
943
951
|
};
|
|
944
952
|
const isExcludedPath = (filePath) => EXCLUDED_DIRS.some((dir) => filePath === dir || filePath.startsWith(`${dir}/`) || filePath.includes(`/${dir}/`));
|
|
945
953
|
const isExcludedFromScan = (relativePath) => isExcludedPath(relativePath) || isBuildCacheFile(relativePath);
|
|
@@ -1078,7 +1086,7 @@ const getSourceFilesWithExtras = (context, extraExtensions) => {
|
|
|
1078
1086
|
|
|
1079
1087
|
//#endregion
|
|
1080
1088
|
//#region src/engines/ai-slop/abstractions.ts
|
|
1081
|
-
const JS_EXTS$
|
|
1089
|
+
const JS_EXTS$2 = new Set([
|
|
1082
1090
|
".ts",
|
|
1083
1091
|
".tsx",
|
|
1084
1092
|
".js",
|
|
@@ -1089,11 +1097,11 @@ const JS_EXTS$1 = new Set([
|
|
|
1089
1097
|
const THIN_WRAPPER_PATTERNS = [
|
|
1090
1098
|
{
|
|
1091
1099
|
pattern: /(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\([^)]*\)\s*(?::\s*\w[^{]*)?\{\s*\n?\s*return\s+\w+\([^)]*\);\s*\n?\s*\}/g,
|
|
1092
|
-
extensions: JS_EXTS$
|
|
1100
|
+
extensions: JS_EXTS$2
|
|
1093
1101
|
},
|
|
1094
1102
|
{
|
|
1095
1103
|
pattern: /(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*(?::\s*\w[^=]*)?\s*=>\s*\w+\([^)]*\);/g,
|
|
1096
|
-
extensions: JS_EXTS$
|
|
1104
|
+
extensions: JS_EXTS$2
|
|
1097
1105
|
},
|
|
1098
1106
|
{
|
|
1099
1107
|
pattern: /def\s+(\w+)\s*\([^)]*\)(?:\s*->[^:]*)?:\s*\n\s+return\s+\w+\([^)]*\)\s*$/gm,
|
|
@@ -1326,6 +1334,12 @@ const COMMENTED_CODE_CHARS = /[({=;}\]>]/;
|
|
|
1326
1334
|
const MAX_TRIVIAL_COMMENT_LENGTH = 60;
|
|
1327
1335
|
const isJsComment = (trimmed) => trimmed.startsWith("//") && !trimmed.startsWith("///") && !trimmed.startsWith("//!");
|
|
1328
1336
|
const isPythonComment = (trimmed) => trimmed.startsWith("#") && !trimmed.startsWith("#!");
|
|
1337
|
+
const isLineComment = (trimmed) => isJsComment(trimmed) || isPythonComment(trimmed);
|
|
1338
|
+
const isInMultiLineCommentRun = (lines, index) => {
|
|
1339
|
+
const prev = index > 0 ? lines[index - 1].trim() : "";
|
|
1340
|
+
const next = index + 1 < lines.length ? lines[index + 1].trim() : "";
|
|
1341
|
+
return isLineComment(prev) || isLineComment(next);
|
|
1342
|
+
};
|
|
1329
1343
|
/**
|
|
1330
1344
|
* Extract just the comment text after the comment marker.
|
|
1331
1345
|
*/
|
|
@@ -1378,6 +1392,7 @@ const scanFileForTrivialComments = (content, relativePath, ext) => {
|
|
|
1378
1392
|
const lines = content.split("\n");
|
|
1379
1393
|
for (let i = 0; i < lines.length; i++) {
|
|
1380
1394
|
if (!isTrivialComment(lines[i].trim(), i + 1 < lines.length ? lines[i + 1] : void 0)) continue;
|
|
1395
|
+
if (isInMultiLineCommentRun(lines, i)) continue;
|
|
1381
1396
|
if (isDocCommentForDeclaration(lines, i, ext)) continue;
|
|
1382
1397
|
diagnostics.push({
|
|
1383
1398
|
filePath: relativePath,
|
|
@@ -1539,6 +1554,177 @@ const detectDeadPatterns = async (context) => {
|
|
|
1539
1554
|
return diagnostics;
|
|
1540
1555
|
};
|
|
1541
1556
|
|
|
1557
|
+
//#endregion
|
|
1558
|
+
//#region src/engines/ai-slop/defensive-patterns.ts
|
|
1559
|
+
const JS_TS_EXTENSIONS = new Set([
|
|
1560
|
+
".ts",
|
|
1561
|
+
".tsx",
|
|
1562
|
+
".js",
|
|
1563
|
+
".jsx",
|
|
1564
|
+
".mjs",
|
|
1565
|
+
".cjs"
|
|
1566
|
+
]);
|
|
1567
|
+
const TS_EXTENSIONS = new Set([".ts", ".tsx"]);
|
|
1568
|
+
const COERCION_CTORS = {
|
|
1569
|
+
string: "String",
|
|
1570
|
+
number: "Number",
|
|
1571
|
+
boolean: "Boolean"
|
|
1572
|
+
};
|
|
1573
|
+
const scriptKindFor = (ext) => {
|
|
1574
|
+
switch (ext) {
|
|
1575
|
+
case ".tsx": return ts.ScriptKind.TSX;
|
|
1576
|
+
case ".jsx": return ts.ScriptKind.JSX;
|
|
1577
|
+
case ".js":
|
|
1578
|
+
case ".mjs":
|
|
1579
|
+
case ".cjs": return ts.ScriptKind.JS;
|
|
1580
|
+
default: return ts.ScriptKind.TS;
|
|
1581
|
+
}
|
|
1582
|
+
};
|
|
1583
|
+
const lineFor = (sourceFile, node) => sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
1584
|
+
const makeDiagnostic = (filePath, line, rule, message, help) => ({
|
|
1585
|
+
filePath,
|
|
1586
|
+
engine: "ai-slop",
|
|
1587
|
+
rule,
|
|
1588
|
+
severity: "warning",
|
|
1589
|
+
message,
|
|
1590
|
+
help,
|
|
1591
|
+
line,
|
|
1592
|
+
column: 0,
|
|
1593
|
+
category: "AI Slop",
|
|
1594
|
+
fixable: false
|
|
1595
|
+
});
|
|
1596
|
+
const isFunctionNode = (node) => ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node) || ts.isConstructorDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node);
|
|
1597
|
+
const primitiveKindOf = (node) => {
|
|
1598
|
+
if (!node) return null;
|
|
1599
|
+
switch (node.kind) {
|
|
1600
|
+
case ts.SyntaxKind.StringKeyword: return "string";
|
|
1601
|
+
case ts.SyntaxKind.NumberKeyword: return "number";
|
|
1602
|
+
case ts.SyntaxKind.BooleanKeyword: return "boolean";
|
|
1603
|
+
default: return null;
|
|
1604
|
+
}
|
|
1605
|
+
};
|
|
1606
|
+
const hasExportModifier = (node) => node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
|
|
1607
|
+
const primitiveParamsOf = (node) => {
|
|
1608
|
+
const params = /* @__PURE__ */ new Map();
|
|
1609
|
+
for (const param of node.parameters) {
|
|
1610
|
+
if (!ts.isIdentifier(param.name)) continue;
|
|
1611
|
+
const kind = primitiveKindOf(param.type);
|
|
1612
|
+
if (!kind) continue;
|
|
1613
|
+
params.set(param.name.text, kind);
|
|
1614
|
+
}
|
|
1615
|
+
return params;
|
|
1616
|
+
};
|
|
1617
|
+
const isRethrowStatement = (statement, errorName) => ts.isThrowStatement(statement) && statement.expression !== void 0 && ts.isIdentifier(statement.expression) && statement.expression.text === errorName;
|
|
1618
|
+
const isPromiseRejectRethrow = (statement, errorName) => {
|
|
1619
|
+
if (!ts.isReturnStatement(statement) || !statement.expression) return false;
|
|
1620
|
+
const expression = statement.expression;
|
|
1621
|
+
if (!ts.isCallExpression(expression) || expression.arguments.length !== 1) return false;
|
|
1622
|
+
const [arg] = expression.arguments;
|
|
1623
|
+
if (!ts.isIdentifier(arg) || arg.text !== errorName) return false;
|
|
1624
|
+
if (!ts.isPropertyAccessExpression(expression.expression)) return false;
|
|
1625
|
+
const target = expression.expression;
|
|
1626
|
+
return ts.isIdentifier(target.expression) && target.expression.text === "Promise" && target.name.text === "reject";
|
|
1627
|
+
};
|
|
1628
|
+
const detectRedundantTryCatch = (sourceFile, relativePath) => {
|
|
1629
|
+
const diagnostics = [];
|
|
1630
|
+
const visit = (node) => {
|
|
1631
|
+
if (ts.isTryStatement(node) && node.catchClause && !node.finallyBlock) {
|
|
1632
|
+
const catchNameNode = node.catchClause.variableDeclaration?.name;
|
|
1633
|
+
const [onlyStatement] = node.catchClause.block.statements;
|
|
1634
|
+
if (catchNameNode && ts.isIdentifier(catchNameNode) && node.catchClause.block.statements.length === 1 && onlyStatement && (isRethrowStatement(onlyStatement, catchNameNode.text) || isPromiseRejectRethrow(onlyStatement, catchNameNode.text))) diagnostics.push(makeDiagnostic(relativePath, lineFor(sourceFile, node.catchClause), "ai-slop/redundant-try-catch", "Catch block only rethrows the same error", "Remove the try/catch or add useful context, cleanup, or recovery. Rethrowing unchanged errors is usually defensive agent noise."));
|
|
1635
|
+
}
|
|
1636
|
+
ts.forEachChild(node, visit);
|
|
1637
|
+
};
|
|
1638
|
+
visit(sourceFile);
|
|
1639
|
+
return diagnostics;
|
|
1640
|
+
};
|
|
1641
|
+
const detectPrimitiveCoercions = (sourceFile, relativePath) => {
|
|
1642
|
+
const diagnostics = [];
|
|
1643
|
+
const scanFunctionBody = (node, params) => {
|
|
1644
|
+
const body = node.body;
|
|
1645
|
+
if (!body || params.size === 0) return;
|
|
1646
|
+
const visitBody = (child) => {
|
|
1647
|
+
if (child !== body && isFunctionNode(child)) return;
|
|
1648
|
+
if (ts.isCallExpression(child) && ts.isIdentifier(child.expression)) {
|
|
1649
|
+
const [arg] = child.arguments;
|
|
1650
|
+
if (arg && ts.isIdentifier(arg)) {
|
|
1651
|
+
const primitive = params.get(arg.text);
|
|
1652
|
+
if (primitive && child.expression.text === COERCION_CTORS[primitive]) diagnostics.push(makeDiagnostic(relativePath, lineFor(sourceFile, child), "ai-slop/redundant-type-coercion", `Parameter '${arg.text}' is already typed as ${primitive} but is coerced again`, "Trust the typed boundary or validate unknown input before this function. Re-coercing already typed parameters is usually defensive agent noise."));
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
ts.forEachChild(child, visitBody);
|
|
1656
|
+
};
|
|
1657
|
+
visitBody(body);
|
|
1658
|
+
};
|
|
1659
|
+
const visit = (node) => {
|
|
1660
|
+
if (isFunctionNode(node)) scanFunctionBody(node, primitiveParamsOf(node));
|
|
1661
|
+
ts.forEachChild(node, visit);
|
|
1662
|
+
};
|
|
1663
|
+
visit(sourceFile);
|
|
1664
|
+
return diagnostics;
|
|
1665
|
+
};
|
|
1666
|
+
const normalizedTypeDeclaration = (sourceFile, node) => sourceFile.text.slice(node.getStart(sourceFile), node.getEnd()).replace(/\bexport\b/g, "").replace(/\bdeclare\b/g, "").replace(/\s+/g, " ").trim();
|
|
1667
|
+
const exportedTypesOf = (parsed) => {
|
|
1668
|
+
const declarations = [];
|
|
1669
|
+
const visit = (node) => {
|
|
1670
|
+
if ((ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) && hasExportModifier(node)) declarations.push({
|
|
1671
|
+
name: node.name.text,
|
|
1672
|
+
signature: normalizedTypeDeclaration(parsed.sourceFile, node),
|
|
1673
|
+
filePath: parsed.relativePath,
|
|
1674
|
+
line: lineFor(parsed.sourceFile, node)
|
|
1675
|
+
});
|
|
1676
|
+
ts.forEachChild(node, visit);
|
|
1677
|
+
};
|
|
1678
|
+
visit(parsed.sourceFile);
|
|
1679
|
+
return declarations;
|
|
1680
|
+
};
|
|
1681
|
+
const duplicateTypeKeyOf = (declaration) => `${declaration.name}\0${declaration.signature}`;
|
|
1682
|
+
const detectDuplicateExportedTypes = (parsedSources) => {
|
|
1683
|
+
const diagnostics = [];
|
|
1684
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1685
|
+
for (const parsed of parsedSources) {
|
|
1686
|
+
if (!TS_EXTENSIONS.has(parsed.ext)) continue;
|
|
1687
|
+
for (const declaration of exportedTypesOf(parsed)) {
|
|
1688
|
+
const key = duplicateTypeKeyOf(declaration);
|
|
1689
|
+
const previous = seen.get(key);
|
|
1690
|
+
if (!previous) {
|
|
1691
|
+
seen.set(key, declaration);
|
|
1692
|
+
continue;
|
|
1693
|
+
}
|
|
1694
|
+
if (previous.filePath === declaration.filePath) continue;
|
|
1695
|
+
diagnostics.push(makeDiagnostic(declaration.filePath, declaration.line, "ai-slop/duplicate-type-declaration", `Exported type '${declaration.name}' duplicates an existing declaration`, `Reuse or import the existing type from ${previous.filePath} instead of re-declaring the same shape in another file.`));
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
return diagnostics;
|
|
1699
|
+
};
|
|
1700
|
+
const detectDefensivePatterns = async (context) => {
|
|
1701
|
+
const diagnostics = [];
|
|
1702
|
+
const parsedSources = [];
|
|
1703
|
+
for (const filePath of getSourceFiles(context)) {
|
|
1704
|
+
if (isAutoGenerated(filePath)) continue;
|
|
1705
|
+
let content;
|
|
1706
|
+
try {
|
|
1707
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
1708
|
+
} catch {
|
|
1709
|
+
continue;
|
|
1710
|
+
}
|
|
1711
|
+
const relativePath = path.relative(context.rootDirectory, filePath);
|
|
1712
|
+
if (isNonProductionPath(relativePath)) continue;
|
|
1713
|
+
const ext = path.extname(filePath);
|
|
1714
|
+
if (!JS_TS_EXTENSIONS.has(ext)) continue;
|
|
1715
|
+
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, scriptKindFor(ext));
|
|
1716
|
+
parsedSources.push({
|
|
1717
|
+
sourceFile,
|
|
1718
|
+
relativePath,
|
|
1719
|
+
ext
|
|
1720
|
+
});
|
|
1721
|
+
diagnostics.push(...detectRedundantTryCatch(sourceFile, relativePath));
|
|
1722
|
+
if (TS_EXTENSIONS.has(ext)) diagnostics.push(...detectPrimitiveCoercions(sourceFile, relativePath));
|
|
1723
|
+
}
|
|
1724
|
+
diagnostics.push(...detectDuplicateExportedTypes(parsedSources));
|
|
1725
|
+
return diagnostics;
|
|
1726
|
+
};
|
|
1727
|
+
|
|
1542
1728
|
//#endregion
|
|
1543
1729
|
//#region src/engines/ai-slop/duplicate-imports.ts
|
|
1544
1730
|
const JS_EXTENSIONS$3 = new Set([
|
|
@@ -1549,7 +1735,16 @@ const JS_EXTENSIONS$3 = new Set([
|
|
|
1549
1735
|
".mjs",
|
|
1550
1736
|
".cjs"
|
|
1551
1737
|
]);
|
|
1552
|
-
const IMPORT_FROM_RE$1 = /^\s*import\s+[^;]*?from\s+["']([^"']+)["']/;
|
|
1738
|
+
const IMPORT_FROM_RE$1 = /^\s*import\s+([^;]*?)\s+from\s+["']([^"']+)["']/;
|
|
1739
|
+
const TYPE_ONLY_RE = /^\s*type\b/;
|
|
1740
|
+
const VALUE_BINDING_RE = /\{([^}]*)\}/;
|
|
1741
|
+
const isTypeOnly = (clause) => {
|
|
1742
|
+
if (TYPE_ONLY_RE.test(clause)) return true;
|
|
1743
|
+
const braces = VALUE_BINDING_RE.exec(clause);
|
|
1744
|
+
if (!braces) return false;
|
|
1745
|
+
const members = braces[1].split(",").map((member) => member.trim()).filter((member) => member.length > 0);
|
|
1746
|
+
return members.length > 0 && members.every((member) => /^type\b/.test(member));
|
|
1747
|
+
};
|
|
1553
1748
|
const extractImportLines = (content) => {
|
|
1554
1749
|
const lines = content.split("\n");
|
|
1555
1750
|
const results = [];
|
|
@@ -1558,8 +1753,9 @@ const extractImportLines = (content) => {
|
|
|
1558
1753
|
const match = IMPORT_FROM_RE$1.exec(line);
|
|
1559
1754
|
if (!match) continue;
|
|
1560
1755
|
results.push({
|
|
1561
|
-
spec: match[
|
|
1562
|
-
line: i + 1
|
|
1756
|
+
spec: match[2],
|
|
1757
|
+
line: i + 1,
|
|
1758
|
+
typeOnly: isTypeOnly(match[1])
|
|
1563
1759
|
});
|
|
1564
1760
|
}
|
|
1565
1761
|
return results;
|
|
@@ -1578,14 +1774,16 @@ const detectDuplicateImports = async (context) => {
|
|
|
1578
1774
|
}
|
|
1579
1775
|
const imports = extractImportLines(content);
|
|
1580
1776
|
if (imports.length < 2) continue;
|
|
1581
|
-
const
|
|
1777
|
+
const byBucket = /* @__PURE__ */ new Map();
|
|
1582
1778
|
for (const imp of imports) {
|
|
1583
|
-
const
|
|
1779
|
+
const key = `${imp.typeOnly ? "type" : "value"}\0${imp.spec}`;
|
|
1780
|
+
const list = byBucket.get(key) ?? [];
|
|
1584
1781
|
list.push(imp);
|
|
1585
|
-
|
|
1782
|
+
byBucket.set(key, list);
|
|
1586
1783
|
}
|
|
1587
1784
|
const relPath = path.relative(context.rootDirectory, filePath);
|
|
1588
|
-
for (const
|
|
1785
|
+
for (const occurrences of byBucket.values()) {
|
|
1786
|
+
const { spec } = occurrences[0];
|
|
1589
1787
|
if (occurrences.length < 2) continue;
|
|
1590
1788
|
for (const dup of occurrences.slice(1)) {
|
|
1591
1789
|
const firstLine = occurrences[0].line;
|
|
@@ -1790,6 +1988,141 @@ const detectGoPatterns = async (context) => {
|
|
|
1790
1988
|
return diagnostics;
|
|
1791
1989
|
};
|
|
1792
1990
|
|
|
1991
|
+
//#endregion
|
|
1992
|
+
//#region src/engines/ai-slop/hardcoded-config.ts
|
|
1993
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
1994
|
+
".ts",
|
|
1995
|
+
".tsx",
|
|
1996
|
+
".js",
|
|
1997
|
+
".jsx",
|
|
1998
|
+
".mjs",
|
|
1999
|
+
".cjs",
|
|
2000
|
+
".py",
|
|
2001
|
+
".go",
|
|
2002
|
+
".rs",
|
|
2003
|
+
".rb",
|
|
2004
|
+
".java",
|
|
2005
|
+
".php"
|
|
2006
|
+
]);
|
|
2007
|
+
const URL_LITERAL_RE = /(["'`])(https?:\/\/[^"'`\s<>]+)\1/g;
|
|
2008
|
+
const ID_LITERAL_RE = /(["'])([A-Za-z][A-Za-z0-9_-]{15,})\1/g;
|
|
2009
|
+
const ENV_REFERENCE_RE = /\b(?:process\.env|import\.meta\.env|Deno\.env|os\.environ|getenv|env\()\b/i;
|
|
2010
|
+
const DOC_URL_CONTEXT_RE = /\b(?:docs?|documentation|homepage|repository|bugs|license|readme|source|svgUrl|pageUrl|href|link|install)\b/i;
|
|
2011
|
+
const URL_CONFIG_CONTEXT_RE = /\b(?:api|base[_-]?url|baseUrl|endpoint|host|origin|webhook|callback|redirect|server|service|domain|url)\b/i;
|
|
2012
|
+
const ENVIRONMENT_HOST_RE = /(?:^|[.-])(?:api|app|admin|auth|staging|stage|prod|dev|sandbox|webhook|internal)(?:[.-]|$)|^(?:localhost|127\.0\.0\.1|0\.0\.0\.0)$/i;
|
|
2013
|
+
const ID_CONTEXT_RE = /(?:^|[^A-Za-z0-9])(?:api[_-]?key|client[_-]?id|project[_-]?id|org(?:anization)?[_-]?id|workspace[_-]?id|tenant[_-]?id|price[_-]?id|product[_-]?id|customer[_-]?id|subscription[_-]?id|account[_-]?id|app[_-]?id|key|token|secret)(?:$|[^A-Za-z0-9])/i;
|
|
2014
|
+
const MIGRATION_PATH_RE$1 = /(?:^|[\\/])(?:migrations?|db[\\/]migrate)[\\/]/i;
|
|
2015
|
+
const PLACEHOLDER_HOSTS = new Set([
|
|
2016
|
+
"example.com",
|
|
2017
|
+
"example.org",
|
|
2018
|
+
"example.net"
|
|
2019
|
+
]);
|
|
2020
|
+
const LOOPBACK_HOSTS = new Set([
|
|
2021
|
+
"localhost",
|
|
2022
|
+
"127.0.0.1",
|
|
2023
|
+
"0.0.0.0",
|
|
2024
|
+
"::1"
|
|
2025
|
+
]);
|
|
2026
|
+
const PLACEHOLDER_ID_RE = /^(?:changeme|replace[_-]?me|your[_-]|example|placeholder|todo)/i;
|
|
2027
|
+
const HARDCODED_URL_FINDING = {
|
|
2028
|
+
rule: "ai-slop/hardcoded-url",
|
|
2029
|
+
message: "Hardcoded environment URL in production code",
|
|
2030
|
+
help: "Move deployment-specific URLs to environment variables or a typed config module. Keep only stable documentation/public links inline."
|
|
2031
|
+
};
|
|
2032
|
+
const HARDCODED_ID_FINDING = {
|
|
2033
|
+
rule: "ai-slop/hardcoded-id",
|
|
2034
|
+
message: "Hardcoded provider/project ID in production code",
|
|
2035
|
+
help: "Move provider IDs, tenant IDs, price IDs, and similar deployment-specific identifiers to env/config so agents do not bake one environment into source."
|
|
2036
|
+
};
|
|
2037
|
+
const makeFinding = (filePath, line, spec) => ({
|
|
2038
|
+
filePath,
|
|
2039
|
+
engine: "ai-slop",
|
|
2040
|
+
rule: spec.rule,
|
|
2041
|
+
severity: "warning",
|
|
2042
|
+
message: spec.message,
|
|
2043
|
+
help: spec.help,
|
|
2044
|
+
line,
|
|
2045
|
+
column: 0,
|
|
2046
|
+
category: "AI Slop",
|
|
2047
|
+
fixable: false
|
|
2048
|
+
});
|
|
2049
|
+
const isCommentOnlyLine = (trimmed) => trimmed.startsWith("//") || trimmed.startsWith("#") || trimmed.startsWith("*") || trimmed.startsWith("/*");
|
|
2050
|
+
const commentStartsBefore = (line, index, ext) => {
|
|
2051
|
+
const prefix = line.slice(0, index);
|
|
2052
|
+
if (ext === ".py" || ext === ".rb") return prefix.includes("#");
|
|
2053
|
+
if (ext === ".php") return prefix.includes("//") || prefix.includes("#");
|
|
2054
|
+
return prefix.includes("//") || prefix.includes("/*");
|
|
2055
|
+
};
|
|
2056
|
+
const safeUrlHost = (urlText) => {
|
|
2057
|
+
try {
|
|
2058
|
+
return new URL(urlText).hostname.toLowerCase();
|
|
2059
|
+
} catch {
|
|
2060
|
+
return null;
|
|
2061
|
+
}
|
|
2062
|
+
};
|
|
2063
|
+
const isEnvBackedLine = (line) => ENV_REFERENCE_RE.test(line);
|
|
2064
|
+
const shouldFlagUrlLiteral = (line, urlText) => {
|
|
2065
|
+
if (isEnvBackedLine(line)) return false;
|
|
2066
|
+
const host = safeUrlHost(urlText);
|
|
2067
|
+
if (!host) return false;
|
|
2068
|
+
if (PLACEHOLDER_HOSTS.has(host)) return false;
|
|
2069
|
+
if (LOOPBACK_HOSTS.has(host)) return false;
|
|
2070
|
+
if (DOC_URL_CONTEXT_RE.test(line) && !ENVIRONMENT_HOST_RE.test(host)) return false;
|
|
2071
|
+
return URL_CONFIG_CONTEXT_RE.test(line) || ENVIRONMENT_HOST_RE.test(host);
|
|
2072
|
+
};
|
|
2073
|
+
const ENV_VAR_NAME_RE = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/;
|
|
2074
|
+
const hasUsefulIdShape = (value) => {
|
|
2075
|
+
if (PLACEHOLDER_ID_RE.test(value)) return false;
|
|
2076
|
+
if (ENV_VAR_NAME_RE.test(value)) return false;
|
|
2077
|
+
if (/^https?:\/\//i.test(value)) return false;
|
|
2078
|
+
if (/^[A-Za-z]+$/.test(value)) return false;
|
|
2079
|
+
return /[0-9]/.test(value);
|
|
2080
|
+
};
|
|
2081
|
+
const scanLineForConfigLiterals = (line, relativePath, ext, lineNumber) => {
|
|
2082
|
+
const diagnostics = [];
|
|
2083
|
+
if (isCommentOnlyLine(line.trim())) return diagnostics;
|
|
2084
|
+
URL_LITERAL_RE.lastIndex = 0;
|
|
2085
|
+
let urlMatch;
|
|
2086
|
+
while ((urlMatch = URL_LITERAL_RE.exec(line)) !== null) {
|
|
2087
|
+
const urlText = urlMatch[2];
|
|
2088
|
+
if (commentStartsBefore(line, urlMatch.index, ext)) continue;
|
|
2089
|
+
if (!shouldFlagUrlLiteral(line, urlText)) continue;
|
|
2090
|
+
diagnostics.push(makeFinding(relativePath, lineNumber, HARDCODED_URL_FINDING));
|
|
2091
|
+
}
|
|
2092
|
+
if (!ID_CONTEXT_RE.test(line) || isEnvBackedLine(line) || DOC_URL_CONTEXT_RE.test(line)) return diagnostics;
|
|
2093
|
+
ID_LITERAL_RE.lastIndex = 0;
|
|
2094
|
+
let idMatch;
|
|
2095
|
+
while ((idMatch = ID_LITERAL_RE.exec(line)) !== null) {
|
|
2096
|
+
const value = idMatch[2];
|
|
2097
|
+
if (commentStartsBefore(line, idMatch.index, ext)) continue;
|
|
2098
|
+
if (!hasUsefulIdShape(value)) continue;
|
|
2099
|
+
diagnostics.push(makeFinding(relativePath, lineNumber, HARDCODED_ID_FINDING));
|
|
2100
|
+
}
|
|
2101
|
+
return diagnostics;
|
|
2102
|
+
};
|
|
2103
|
+
const scanFileForConfigLiterals = (content, relativePath, ext) => {
|
|
2104
|
+
if (!SOURCE_EXTENSIONS.has(ext)) return [];
|
|
2105
|
+
if (isNonProductionPath(relativePath)) return [];
|
|
2106
|
+
if (MIGRATION_PATH_RE$1.test(relativePath)) return [];
|
|
2107
|
+
return content.split("\n").flatMap((line, index) => scanLineForConfigLiterals(line, relativePath, ext, index + 1));
|
|
2108
|
+
};
|
|
2109
|
+
const detectHardcodedConfigLiterals = async (context) => {
|
|
2110
|
+
const diagnostics = [];
|
|
2111
|
+
for (const filePath of getSourceFiles(context)) {
|
|
2112
|
+
if (isAutoGenerated(filePath)) continue;
|
|
2113
|
+
let content;
|
|
2114
|
+
try {
|
|
2115
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
2116
|
+
} catch {
|
|
2117
|
+
continue;
|
|
2118
|
+
}
|
|
2119
|
+
const relativePath = path.relative(context.rootDirectory, filePath);
|
|
2120
|
+
const ext = path.extname(filePath);
|
|
2121
|
+
diagnostics.push(...scanFileForConfigLiterals(content, relativePath, ext));
|
|
2122
|
+
}
|
|
2123
|
+
return diagnostics;
|
|
2124
|
+
};
|
|
2125
|
+
|
|
1793
2126
|
//#endregion
|
|
1794
2127
|
//#region src/engines/ai-slop/js-import-aliases.ts
|
|
1795
2128
|
const TS_CONFIG_FILES = ["tsconfig.json", "jsconfig.json"];
|
|
@@ -2057,23 +2390,88 @@ const PYTHON_STDLIB = new Set([
|
|
|
2057
2390
|
"zoneinfo"
|
|
2058
2391
|
]);
|
|
2059
2392
|
const PYTHON_IMPORT_TO_PIP = {
|
|
2060
|
-
yaml: "pyyaml",
|
|
2061
|
-
PIL: "pillow",
|
|
2062
|
-
dateutil: "python-dateutil",
|
|
2063
|
-
cv2:
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2393
|
+
yaml: ["pyyaml"],
|
|
2394
|
+
PIL: ["pillow"],
|
|
2395
|
+
dateutil: ["python-dateutil"],
|
|
2396
|
+
cv2: [
|
|
2397
|
+
"opencv-python",
|
|
2398
|
+
"opencv-python-headless",
|
|
2399
|
+
"opencv-contrib-python"
|
|
2400
|
+
],
|
|
2401
|
+
sklearn: ["scikit-learn"],
|
|
2402
|
+
bs4: ["beautifulsoup4"],
|
|
2403
|
+
typing_extensions: ["typing-extensions"],
|
|
2404
|
+
dotenv: ["python-dotenv"],
|
|
2405
|
+
genai: ["google-genai"],
|
|
2406
|
+
google: [
|
|
2407
|
+
"google-genai",
|
|
2408
|
+
"google-generativeai",
|
|
2409
|
+
"google-api-python-client",
|
|
2410
|
+
"google-cloud-storage",
|
|
2411
|
+
"google-cloud-aiplatform",
|
|
2412
|
+
"google-auth",
|
|
2413
|
+
"protobuf"
|
|
2414
|
+
],
|
|
2415
|
+
jose: ["python-jose"],
|
|
2416
|
+
jwt: ["pyjwt"],
|
|
2417
|
+
OpenSSL: ["pyopenssl"],
|
|
2418
|
+
Crypto: ["pycryptodome", "pycryptodomex"],
|
|
2419
|
+
Cryptodome: ["pycryptodomex", "pycryptodome"],
|
|
2420
|
+
magic: ["python-magic"],
|
|
2421
|
+
docx: ["python-docx"],
|
|
2422
|
+
pptx: ["python-pptx"],
|
|
2423
|
+
git: ["gitpython"],
|
|
2424
|
+
socks: ["pysocks"],
|
|
2425
|
+
psycopg2: ["psycopg2-binary", "psycopg2"],
|
|
2426
|
+
redis: ["redis"],
|
|
2427
|
+
cairo: ["pycairo"],
|
|
2428
|
+
serial: ["pyserial"],
|
|
2429
|
+
usb: ["pyusb"],
|
|
2430
|
+
gi: ["pygobject"],
|
|
2431
|
+
Xlib: ["python-xlib"],
|
|
2432
|
+
ldap: ["python-ldap"],
|
|
2433
|
+
slugify: ["python-slugify"],
|
|
2434
|
+
memcache: ["python-memcached"],
|
|
2435
|
+
dns: ["dnspython"],
|
|
2436
|
+
attr: ["attrs"],
|
|
2437
|
+
attrs: ["attrs"],
|
|
2438
|
+
zoneinfo_data: ["tzdata"],
|
|
2439
|
+
pkg_resources: ["setuptools"],
|
|
2440
|
+
setuptools: ["setuptools"],
|
|
2441
|
+
wx: ["wxpython"],
|
|
2442
|
+
skimage: ["scikit-image"],
|
|
2443
|
+
OpenGL: ["pyopengl"],
|
|
2444
|
+
win32api: ["pywin32"],
|
|
2445
|
+
win32con: ["pywin32"],
|
|
2446
|
+
win32com: ["pywin32"],
|
|
2447
|
+
pythoncom: ["pywin32"],
|
|
2448
|
+
pywintypes: ["pywin32"],
|
|
2449
|
+
rest_framework: ["djangorestframework"],
|
|
2450
|
+
allauth: ["django-allauth"],
|
|
2451
|
+
corsheaders: ["django-cors-headers"],
|
|
2452
|
+
debug_toolbar: ["django-debug-toolbar"],
|
|
2453
|
+
environ: ["django-environ"],
|
|
2454
|
+
flask_cors: ["flask-cors"],
|
|
2455
|
+
flask_sqlalchemy: ["flask-sqlalchemy"],
|
|
2456
|
+
flask_migrate: ["flask-migrate"],
|
|
2457
|
+
flask_login: ["flask-login"],
|
|
2458
|
+
jwt_extended: ["flask-jwt-extended"],
|
|
2459
|
+
dateparser: ["dateparser"],
|
|
2460
|
+
yaml_include: ["pyyaml-include"],
|
|
2461
|
+
lxml_html_clean: ["lxml-html-clean"],
|
|
2462
|
+
grpc: ["grpcio"],
|
|
2463
|
+
grpc_status: ["grpcio-status"],
|
|
2464
|
+
google_crc32c: ["google-crc32c"],
|
|
2465
|
+
pkg_about: ["pkg-about"],
|
|
2466
|
+
mpl_toolkits: ["matplotlib"],
|
|
2467
|
+
dotmap: ["dotmap"],
|
|
2468
|
+
pydantic_settings: ["pydantic-settings"],
|
|
2469
|
+
telegram: ["python-telegram-bot"],
|
|
2470
|
+
discord: ["discord-py"],
|
|
2471
|
+
nacl: ["pynacl"],
|
|
2472
|
+
jwcrypto: ["jwcrypto"],
|
|
2473
|
+
humanfriendly: ["humanfriendly"],
|
|
2474
|
+
multipart: ["python-multipart"]
|
|
2077
2475
|
};
|
|
2078
2476
|
|
|
2079
2477
|
//#endregion
|
|
@@ -2112,6 +2510,8 @@ const collectFromPyproject = (rootDir, pyDeps) => {
|
|
|
2112
2510
|
const m = line.match(/["']\s*([a-zA-Z0-9_\-.]+)/);
|
|
2113
2511
|
if (m) addPyDep(pyDeps, m[1]);
|
|
2114
2512
|
}
|
|
2513
|
+
const extras = content.match(/\[project\.optional-dependencies\]([\s\S]*?)(?=\n\[|$)/);
|
|
2514
|
+
if (extras) for (const m of extras[1].matchAll(/["']\s*([a-zA-Z][a-zA-Z0-9_\-.]+)/g)) addPyDep(pyDeps, m[1]);
|
|
2115
2515
|
const poetryRe = /\[tool\.poetry(?:\.group\.[a-z]+)?\.dependencies\]([\s\S]*?)(?=\n\[|$)/g;
|
|
2116
2516
|
let match = poetryRe.exec(content);
|
|
2117
2517
|
while (match !== null) {
|
|
@@ -2310,6 +2710,11 @@ const packageNameFromImport = (spec) => {
|
|
|
2310
2710
|
}
|
|
2311
2711
|
return spec.split("/")[0];
|
|
2312
2712
|
};
|
|
2713
|
+
const typesPackageName = (pkg) => {
|
|
2714
|
+
if (pkg.startsWith("@types/")) return pkg;
|
|
2715
|
+
if (pkg.startsWith("@")) return `@types/${pkg.slice(1).replace("/", "__")}`;
|
|
2716
|
+
return `@types/${pkg}`;
|
|
2717
|
+
};
|
|
2313
2718
|
const STATIC_IMPORT_RE = /^\s*import\s+(?:[\w*{},\s]+\s+from\s+)?["']([^"']+)["']/;
|
|
2314
2719
|
const DYNAMIC_IMPORT_RE = /(?:import|require)\s*\(\s*["']([^"']+)["']/g;
|
|
2315
2720
|
const extractJsImports = (content) => {
|
|
@@ -2374,15 +2779,16 @@ const checkJsImport = (rawSpec, manifest, tsAliasMatchers) => {
|
|
|
2374
2779
|
const realPkg = pkg.slice(7);
|
|
2375
2780
|
if (manifest.jsDeps.has(realPkg)) return null;
|
|
2376
2781
|
}
|
|
2782
|
+
if (manifest.jsDeps.has(typesPackageName(pkg))) return null;
|
|
2377
2783
|
return pkg;
|
|
2378
2784
|
};
|
|
2785
|
+
const normalizePyName = (name) => name.toLowerCase().replace(/_/g, "-");
|
|
2379
2786
|
const checkPyImport = (spec, manifest) => {
|
|
2380
2787
|
const root = spec.split(".")[0];
|
|
2381
2788
|
if (PYTHON_STDLIB.has(root)) return null;
|
|
2382
|
-
const normalized = root
|
|
2789
|
+
const normalized = normalizePyName(root);
|
|
2383
2790
|
if (manifest.pyDeps.has(normalized)) return null;
|
|
2384
|
-
|
|
2385
|
-
if (pipName && manifest.pyDeps.has(pipName)) return null;
|
|
2791
|
+
if ((PYTHON_IMPORT_TO_PIP[root] ?? PYTHON_IMPORT_TO_PIP[normalized])?.some((dist) => manifest.pyDeps.has(normalizePyName(dist)))) return null;
|
|
2386
2792
|
return root;
|
|
2387
2793
|
};
|
|
2388
2794
|
const detectHallucinatedImports = async (context) => {
|
|
@@ -2532,6 +2938,85 @@ const collectBlocks = (sourceLines, syntax) => {
|
|
|
2532
2938
|
return blocks;
|
|
2533
2939
|
};
|
|
2534
2940
|
|
|
2941
|
+
//#endregion
|
|
2942
|
+
//#region src/engines/ai-slop/meta-comment.ts
|
|
2943
|
+
const PLAN_REFERENCE_RES = [
|
|
2944
|
+
/\b(?:stage|step|phase)\s+\d+\b(?!\s*[:.]?\s*(?:bytes|ms|seconds|of\s+\d))/i,
|
|
2945
|
+
/\bstep\s+\d+\s+of\s+the\s+plan\b/i,
|
|
2946
|
+
/\bas\s+(?:per|requested)\s+(?:the\s+)?(?:requirements?|spec|task|ticket|prompt|instructions?)\b/i,
|
|
2947
|
+
/\bper\s+the\s+(?:spec|requirements?|task|ticket|plan|prompt|instructions?)\b/i,
|
|
2948
|
+
/\bfrom\s+the\s+(?:task|todo|plan|spec|ticket|prompt|requirements?)\b/i,
|
|
2949
|
+
/\bimplement(?:ing|s|ed)?\s+use\s*case\s+\d*/i,
|
|
2950
|
+
/\b(?:requirements?\s+doc|requirement\s+\d+)\b/i,
|
|
2951
|
+
/\bas\s+(?:instructed|specified|outlined)\s+(?:above|below|in\s+the)\b/i
|
|
2952
|
+
];
|
|
2953
|
+
const BEFORE_AFTER_RES = [
|
|
2954
|
+
/\bpreviously[,:]?\s+(?:this|we|it|the)\b/i,
|
|
2955
|
+
/\bused\s+to\s+(?:be|use|call|return|do|have|rely)\b/i,
|
|
2956
|
+
/\bchanged\s+(?:\w+\s+){0,3}from\s+.+\bto\b/i,
|
|
2957
|
+
/\bno\s+longer\s+(?:needed|used|required|necessary|calls?|returns?|does)\b/i,
|
|
2958
|
+
/\bthis\s+was\s+.+\bbut\s+now\b/i,
|
|
2959
|
+
/\bwe\s+(?:now|used\s+to)\s+(?:no\s+longer\s+)?(?:use|call|return|do|have)\b/i,
|
|
2960
|
+
/\breplaced\s+the\s+(?:old|previous|former)\b/i,
|
|
2961
|
+
/\b(?:was|were)\s+(?:renamed|moved|removed|refactored|extracted)\s+(?:from|to|out\s+of)\b/i
|
|
2962
|
+
];
|
|
2963
|
+
const WHY_OR_TODO_RE = /\b(?:because|since|otherwise|todo|fixme|hack|note:|reason:|workaround|see\s+(?:issue|#))\b/i;
|
|
2964
|
+
const looksLikeLicenseHeader$1 = (block) => {
|
|
2965
|
+
if (block.startLine !== 1) return false;
|
|
2966
|
+
const text = block.rawLines.join(" ").toLowerCase();
|
|
2967
|
+
return text.includes("copyright") || text.includes("license") || text.includes("spdx-license-identifier");
|
|
2968
|
+
};
|
|
2969
|
+
const looksLikeSuppressDirective$1 = (block) => block.rawLines.some((line) => /\b(?:biome-ignore|eslint-disable|ts-ignore|ts-expect-error|@ts-\w+|noqa|pylint:\s*disable|rubocop:disable|noinspection|phpcs:disable)\b/.test(line));
|
|
2970
|
+
const matchMetaSignal = (block) => {
|
|
2971
|
+
if (looksLikeLicenseHeader$1(block)) return null;
|
|
2972
|
+
if (looksLikeSuppressDirective$1(block)) return null;
|
|
2973
|
+
if (block.kind === "jsdoc" && block.hasMeaningfulJsdocTag) return null;
|
|
2974
|
+
if (block.isRustDoc) return null;
|
|
2975
|
+
const joined = block.prose.join(" ");
|
|
2976
|
+
if (joined.trim().length === 0) return null;
|
|
2977
|
+
if (WHY_OR_TODO_RE.test(joined)) return null;
|
|
2978
|
+
if (PLAN_REFERENCE_RES.some((re) => re.test(joined))) return "plan/process reference";
|
|
2979
|
+
if (BEFORE_AFTER_RES.some((re) => re.test(joined))) return "before/after state narration";
|
|
2980
|
+
return null;
|
|
2981
|
+
};
|
|
2982
|
+
const detectMetaComments = async (context) => {
|
|
2983
|
+
const files = getSourceFiles(context);
|
|
2984
|
+
const diagnostics = [];
|
|
2985
|
+
for (const filePath of files) {
|
|
2986
|
+
const ext = path.extname(filePath);
|
|
2987
|
+
if (!SUPPORTED_EXTS.has(ext)) continue;
|
|
2988
|
+
if (isAutoGenerated(filePath)) continue;
|
|
2989
|
+
const syntax = getCommentSyntax(ext);
|
|
2990
|
+
if (!syntax) continue;
|
|
2991
|
+
const relativePath = path.relative(context.rootDirectory, filePath);
|
|
2992
|
+
if (isNonProductionPath(relativePath)) continue;
|
|
2993
|
+
let content;
|
|
2994
|
+
try {
|
|
2995
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
2996
|
+
} catch {
|
|
2997
|
+
continue;
|
|
2998
|
+
}
|
|
2999
|
+
const blocks = collectBlocks(content.split("\n"), syntax);
|
|
3000
|
+
for (const block of blocks) {
|
|
3001
|
+
const reason = matchMetaSignal(block);
|
|
3002
|
+
if (!reason) continue;
|
|
3003
|
+
diagnostics.push({
|
|
3004
|
+
filePath: relativePath,
|
|
3005
|
+
engine: "ai-slop",
|
|
3006
|
+
rule: "ai-slop/meta-comment",
|
|
3007
|
+
severity: "warning",
|
|
3008
|
+
message: `Meta/plan comment (${reason})`,
|
|
3009
|
+
help: "Remove — references to the build plan or before/after code state belong in PR descriptions and commit messages, not source.",
|
|
3010
|
+
line: block.startLine,
|
|
3011
|
+
column: 0,
|
|
3012
|
+
category: "Comments",
|
|
3013
|
+
fixable: false
|
|
3014
|
+
});
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
return diagnostics;
|
|
3018
|
+
};
|
|
3019
|
+
|
|
2535
3020
|
//#endregion
|
|
2536
3021
|
//#region src/engines/ai-slop/narrative-comments.ts
|
|
2537
3022
|
const looksLikeDeclarationPreamble = (nextLine, ext) => {
|
|
@@ -3140,6 +3625,143 @@ const detectRustPatterns = async (context) => {
|
|
|
3140
3625
|
return diagnostics;
|
|
3141
3626
|
};
|
|
3142
3627
|
|
|
3628
|
+
//#endregion
|
|
3629
|
+
//#region src/engines/ai-slop/silent-recovery.ts
|
|
3630
|
+
const JS_EXTS$1 = new Set([
|
|
3631
|
+
".ts",
|
|
3632
|
+
".tsx",
|
|
3633
|
+
".js",
|
|
3634
|
+
".jsx",
|
|
3635
|
+
".mjs",
|
|
3636
|
+
".cjs"
|
|
3637
|
+
]);
|
|
3638
|
+
const CATCH_HEAD_RE = /\bcatch\s*(?:\([^)]*\))?\s*\{/g;
|
|
3639
|
+
const LOG_STATEMENT_RE = /^(?:console|[\w$]+(?:\.[\w$]+)*)\.(?:log|info|warn|warning|error|debug|trace)\s*\(/;
|
|
3640
|
+
const HANDLING_TOKEN_RE = /\b(?:throw|return|reject|next|process\.exit|continue|break)\b/;
|
|
3641
|
+
const stripBlockComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
3642
|
+
const extractCatchBody = (content, openBraceIndex) => {
|
|
3643
|
+
let depth = 0;
|
|
3644
|
+
let inString = null;
|
|
3645
|
+
for (let i = openBraceIndex; i < content.length; i += 1) {
|
|
3646
|
+
const ch = content[i];
|
|
3647
|
+
const prev = content[i - 1];
|
|
3648
|
+
if (inString) {
|
|
3649
|
+
if (ch === inString && prev !== "\\") inString = null;
|
|
3650
|
+
continue;
|
|
3651
|
+
}
|
|
3652
|
+
if (ch === "\"" || ch === "'" || ch === "`") {
|
|
3653
|
+
inString = ch;
|
|
3654
|
+
continue;
|
|
3655
|
+
}
|
|
3656
|
+
if (ch === "{") depth += 1;
|
|
3657
|
+
else if (ch === "}") {
|
|
3658
|
+
depth -= 1;
|
|
3659
|
+
if (depth === 0) return content.slice(openBraceIndex + 1, i);
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3662
|
+
return null;
|
|
3663
|
+
};
|
|
3664
|
+
const isLogOnlyBody = (body) => {
|
|
3665
|
+
const statements = stripBlockComments(body).split("\n").map((line) => line.replace(/\/\/.*$/, "").trim()).filter((line) => line.length > 0 && line !== ";");
|
|
3666
|
+
if (statements.length === 0) return false;
|
|
3667
|
+
if (statements.some((line) => HANDLING_TOKEN_RE.test(line))) return false;
|
|
3668
|
+
let sawLog = false;
|
|
3669
|
+
for (const statement of statements) {
|
|
3670
|
+
const normalized = statement.replace(/;+$/, "");
|
|
3671
|
+
if (LOG_STATEMENT_RE.test(normalized)) {
|
|
3672
|
+
sawLog = true;
|
|
3673
|
+
continue;
|
|
3674
|
+
}
|
|
3675
|
+
if (/^[\w$"'`{[(),.\s+:-]+$/.test(normalized) && !/[=(]\s*(?:async\s+)?\(/.test(normalized)) continue;
|
|
3676
|
+
return false;
|
|
3677
|
+
}
|
|
3678
|
+
return sawLog;
|
|
3679
|
+
};
|
|
3680
|
+
const detectJsSilentRecovery = (content, relPath) => {
|
|
3681
|
+
const out = [];
|
|
3682
|
+
CATCH_HEAD_RE.lastIndex = 0;
|
|
3683
|
+
let match;
|
|
3684
|
+
while ((match = CATCH_HEAD_RE.exec(content)) !== null) {
|
|
3685
|
+
const body = extractCatchBody(content, match.index + match[0].length - 1);
|
|
3686
|
+
if (body === null) continue;
|
|
3687
|
+
if (!isLogOnlyBody(body)) continue;
|
|
3688
|
+
const line = content.slice(0, match.index).split("\n").length;
|
|
3689
|
+
out.push({
|
|
3690
|
+
filePath: relPath,
|
|
3691
|
+
engine: "ai-slop",
|
|
3692
|
+
rule: "ai-slop/silent-recovery",
|
|
3693
|
+
severity: "warning",
|
|
3694
|
+
message: "Catch only logs then continues, leaving execution in a possibly broken state",
|
|
3695
|
+
help: "Handle the error: rethrow, return an error value, or recover explicitly. Logging alone lets the program proceed as if nothing failed.",
|
|
3696
|
+
line,
|
|
3697
|
+
column: 0,
|
|
3698
|
+
category: "AI Slop",
|
|
3699
|
+
fixable: false
|
|
3700
|
+
});
|
|
3701
|
+
}
|
|
3702
|
+
return out;
|
|
3703
|
+
};
|
|
3704
|
+
const PY_EXCEPT_RE = /^(\s*)except\b[^\n]*:\s*(?:#.*)?$/;
|
|
3705
|
+
const PY_LOG_STATEMENT_RE = /^(?:logging|logger|log|self\.log|self\.logger|print)(?:\.(?:debug|info|warning|warn|error|exception|critical))?\s*\(/;
|
|
3706
|
+
const PY_HANDLING_TOKEN_RE = /^(?:raise\b|return\b|continue\b|break\b|self\.|[\w.]+\s*=)/;
|
|
3707
|
+
const detectPySilentRecovery = (content, relPath) => {
|
|
3708
|
+
const out = [];
|
|
3709
|
+
const lines = content.split("\n");
|
|
3710
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
3711
|
+
const exceptMatch = PY_EXCEPT_RE.exec(lines[i]);
|
|
3712
|
+
if (!exceptMatch) continue;
|
|
3713
|
+
const indent = exceptMatch[1].length;
|
|
3714
|
+
const bodyLines = [];
|
|
3715
|
+
let j = i + 1;
|
|
3716
|
+
for (; j < lines.length; j += 1) {
|
|
3717
|
+
const raw = lines[j];
|
|
3718
|
+
if (raw.trim() === "") continue;
|
|
3719
|
+
if (raw.length - raw.trimStart().length <= indent) break;
|
|
3720
|
+
bodyLines.push(raw.trim());
|
|
3721
|
+
}
|
|
3722
|
+
if (bodyLines.length === 0) continue;
|
|
3723
|
+
if (bodyLines.some((line) => PY_HANDLING_TOKEN_RE.test(line))) continue;
|
|
3724
|
+
if (bodyLines.some((line) => line === "pass")) continue;
|
|
3725
|
+
const allLogs = bodyLines.every((line) => PY_LOG_STATEMENT_RE.test(line) || /^[\w"'(),.\s+:%{}[\]-]+$/.test(line));
|
|
3726
|
+
const sawLog = bodyLines.some((line) => PY_LOG_STATEMENT_RE.test(line));
|
|
3727
|
+
if (!allLogs || !sawLog) continue;
|
|
3728
|
+
out.push({
|
|
3729
|
+
filePath: relPath,
|
|
3730
|
+
engine: "ai-slop",
|
|
3731
|
+
rule: "ai-slop/silent-recovery",
|
|
3732
|
+
severity: "warning",
|
|
3733
|
+
message: "except only logs then continues, leaving execution in a possibly broken state",
|
|
3734
|
+
help: "Handle the error: re-raise, return an error value, or recover explicitly. Logging alone lets the program proceed as if nothing failed.",
|
|
3735
|
+
line: i + 1,
|
|
3736
|
+
column: 0,
|
|
3737
|
+
category: "AI Slop",
|
|
3738
|
+
fixable: false
|
|
3739
|
+
});
|
|
3740
|
+
}
|
|
3741
|
+
return out;
|
|
3742
|
+
};
|
|
3743
|
+
const detectSilentRecovery = async (context) => {
|
|
3744
|
+
const files = getSourceFiles(context);
|
|
3745
|
+
const diagnostics = [];
|
|
3746
|
+
for (const filePath of files) {
|
|
3747
|
+
if (isAutoGenerated(filePath)) continue;
|
|
3748
|
+
const ext = path.extname(filePath);
|
|
3749
|
+
const isJs = JS_EXTS$1.has(ext);
|
|
3750
|
+
if (!isJs && !(ext === ".py")) continue;
|
|
3751
|
+
const relPath = path.relative(context.rootDirectory, filePath);
|
|
3752
|
+
if (isNonProductionPath(relPath)) continue;
|
|
3753
|
+
let content;
|
|
3754
|
+
try {
|
|
3755
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
3756
|
+
} catch {
|
|
3757
|
+
continue;
|
|
3758
|
+
}
|
|
3759
|
+
if (isJs) diagnostics.push(...detectJsSilentRecovery(content, relPath));
|
|
3760
|
+
else diagnostics.push(...detectPySilentRecovery(content, relPath));
|
|
3761
|
+
}
|
|
3762
|
+
return diagnostics;
|
|
3763
|
+
};
|
|
3764
|
+
|
|
3143
3765
|
//#endregion
|
|
3144
3766
|
//#region src/engines/ai-slop/unused-imports.ts
|
|
3145
3767
|
const JS_EXTENSIONS$1 = new Set([
|
|
@@ -3329,15 +3951,19 @@ const aiSlopEngine = {
|
|
|
3329
3951
|
const results = await Promise.allSettled([
|
|
3330
3952
|
detectTrivialComments(context),
|
|
3331
3953
|
detectSwallowedExceptions(context),
|
|
3954
|
+
detectDefensivePatterns(context),
|
|
3332
3955
|
detectOverAbstraction(context),
|
|
3333
3956
|
detectDeadPatterns(context),
|
|
3334
3957
|
detectUnusedImports(context),
|
|
3335
3958
|
detectNarrativeComments(context),
|
|
3336
3959
|
detectDuplicateImports(context),
|
|
3960
|
+
detectHardcodedConfigLiterals(context),
|
|
3337
3961
|
detectPythonPatterns(context),
|
|
3338
3962
|
detectGoPatterns(context),
|
|
3339
3963
|
detectRustPatterns(context),
|
|
3340
|
-
detectHallucinatedImports(context)
|
|
3964
|
+
detectHallucinatedImports(context),
|
|
3965
|
+
detectSilentRecovery(context),
|
|
3966
|
+
detectMetaComments(context)
|
|
3341
3967
|
]);
|
|
3342
3968
|
for (const result of results) if (result.status === "fulfilled") diagnostics.push(...result.value);
|
|
3343
3969
|
return {
|
|
@@ -6236,7 +6862,7 @@ const RISKY_PATTERNS = [
|
|
|
6236
6862
|
help: "Use JSON, MessagePack, or other safe serialization formats for untrusted data"
|
|
6237
6863
|
},
|
|
6238
6864
|
{
|
|
6239
|
-
pattern: new RegExp(
|
|
6865
|
+
pattern: new RegExp(`(?<![\\w.>:\\\\])\\bexec\\s*\\(`, "g"),
|
|
6240
6866
|
extensions: [".py"],
|
|
6241
6867
|
name: "python-exec",
|
|
6242
6868
|
message: "Use of exec() can execute arbitrary code",
|
|
@@ -6940,7 +7566,7 @@ const parseClaudeStdin = (raw) => {
|
|
|
6940
7566
|
return {};
|
|
6941
7567
|
}
|
|
6942
7568
|
};
|
|
6943
|
-
const readStdin$
|
|
7569
|
+
const readStdin$3 = async () => {
|
|
6944
7570
|
if (process.stdin.isTTY) return "";
|
|
6945
7571
|
const chunks = [];
|
|
6946
7572
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
@@ -6958,7 +7584,7 @@ const renderClaudeOutput = (additional, block) => {
|
|
|
6958
7584
|
return out;
|
|
6959
7585
|
};
|
|
6960
7586
|
const runClaudeHook = async (deps = {}) => {
|
|
6961
|
-
const getStdin = deps.stdin ?? readStdin$
|
|
7587
|
+
const getStdin = deps.stdin ?? readStdin$3;
|
|
6962
7588
|
const write = deps.write ?? ((s) => process.stdout.write(s));
|
|
6963
7589
|
const input = parseClaudeStdin(await getStdin());
|
|
6964
7590
|
const cwd = input.cwd && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
|
|
@@ -7005,7 +7631,7 @@ const parseClaudeFileChangedStdin = (raw) => {
|
|
|
7005
7631
|
}
|
|
7006
7632
|
};
|
|
7007
7633
|
const runClaudeFileChangedHook = async (deps = {}) => {
|
|
7008
|
-
const getStdin = deps.stdin ?? readStdin$
|
|
7634
|
+
const getStdin = deps.stdin ?? readStdin$3;
|
|
7009
7635
|
const write = deps.write ?? ((s) => process.stdout.write(s));
|
|
7010
7636
|
const input = parseClaudeFileChangedStdin(await getStdin());
|
|
7011
7637
|
const cwd = input.cwd && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
|
|
@@ -7041,7 +7667,7 @@ const parseClaudeStopStdin = (raw) => {
|
|
|
7041
7667
|
}
|
|
7042
7668
|
};
|
|
7043
7669
|
const runClaudeStopHook = async (deps = {}) => {
|
|
7044
|
-
const getStdin = deps.stdin ?? readStdin$
|
|
7670
|
+
const getStdin = deps.stdin ?? readStdin$3;
|
|
7045
7671
|
const write = deps.write ?? ((s) => process.stdout.write(s));
|
|
7046
7672
|
const input = parseClaudeStopStdin(await getStdin());
|
|
7047
7673
|
const cwd = input.cwd && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
|
|
@@ -7102,14 +7728,14 @@ const renderCursorOutput = (additional, event = "afterFileEdit") => ({ hookSpeci
|
|
|
7102
7728
|
hookEventName: event,
|
|
7103
7729
|
additionalContext: additional
|
|
7104
7730
|
} });
|
|
7105
|
-
const readStdin$
|
|
7731
|
+
const readStdin$2 = async () => {
|
|
7106
7732
|
if (process.stdin.isTTY) return "";
|
|
7107
7733
|
const chunks = [];
|
|
7108
7734
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
7109
7735
|
return Buffer.concat(chunks).toString("utf-8");
|
|
7110
7736
|
};
|
|
7111
7737
|
const runCursorHook = async (deps = {}) => {
|
|
7112
|
-
const getStdin = deps.stdin ?? readStdin$
|
|
7738
|
+
const getStdin = deps.stdin ?? readStdin$2;
|
|
7113
7739
|
const write = deps.write ?? ((s) => process.stdout.write(s));
|
|
7114
7740
|
const writeErr = deps.writeErr ?? ((s) => process.stderr.write(s));
|
|
7115
7741
|
const input = parseCursorStdin(await getStdin());
|
|
@@ -7165,14 +7791,14 @@ const renderGeminiOutput = (additional) => ({ hookSpecificOutput: {
|
|
|
7165
7791
|
hookEventName: "AfterTool",
|
|
7166
7792
|
additionalContext: additional
|
|
7167
7793
|
} });
|
|
7168
|
-
const readStdin = async () => {
|
|
7794
|
+
const readStdin$1 = async () => {
|
|
7169
7795
|
if (process.stdin.isTTY) return "";
|
|
7170
7796
|
const chunks = [];
|
|
7171
7797
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
7172
7798
|
return Buffer.concat(chunks).toString("utf-8");
|
|
7173
7799
|
};
|
|
7174
7800
|
const runGeminiHook = async (deps = {}) => {
|
|
7175
|
-
const getStdin = deps.stdin ?? readStdin;
|
|
7801
|
+
const getStdin = deps.stdin ?? readStdin$1;
|
|
7176
7802
|
const write = deps.write ?? ((s) => process.stdout.write(s));
|
|
7177
7803
|
const input = parseGeminiStdin(await getStdin());
|
|
7178
7804
|
const cwd = input.cwd && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
|
|
@@ -7204,6 +7830,76 @@ const runGeminiHook = async (deps = {}) => {
|
|
|
7204
7830
|
}
|
|
7205
7831
|
};
|
|
7206
7832
|
|
|
7833
|
+
//#endregion
|
|
7834
|
+
//#region src/hooks/adapters/pi.ts
|
|
7835
|
+
const parsePiStdin = (raw) => {
|
|
7836
|
+
if (!raw.trim()) return {};
|
|
7837
|
+
try {
|
|
7838
|
+
return JSON.parse(raw);
|
|
7839
|
+
} catch {
|
|
7840
|
+
return {};
|
|
7841
|
+
}
|
|
7842
|
+
};
|
|
7843
|
+
const readStdin = async () => {
|
|
7844
|
+
if (process.stdin.isTTY) return "";
|
|
7845
|
+
const chunks = [];
|
|
7846
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
7847
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
7848
|
+
};
|
|
7849
|
+
const formatPiMessage = (feedback) => {
|
|
7850
|
+
if (feedback.counts.total === 0 && !feedback.regressed) return "";
|
|
7851
|
+
const { error, warning } = feedback.counts;
|
|
7852
|
+
const header = `aislop: score ${feedback.score}/100${feedback.baseline != null ? ` (baseline ${feedback.baseline})` : ""}, ${error} error${error === 1 ? "" : "s"}, ${warning} warning${warning === 1 ? "" : "s"}.`;
|
|
7853
|
+
const lines = feedback.findings.map((f) => ` - ${f.file}:${f.line} [${f.severity}] ${f.ruleId}: ${f.message}`);
|
|
7854
|
+
if (feedback.elided && feedback.elided > 0) lines.push(` ...and ${feedback.elided} more.`);
|
|
7855
|
+
const tail = feedback.nextSteps.length > 0 ? `\n${feedback.nextSteps.join("\n")}` : "";
|
|
7856
|
+
return `${header}\n${lines.join("\n")}${tail}`;
|
|
7857
|
+
};
|
|
7858
|
+
const runPiHook = async (deps = {}) => {
|
|
7859
|
+
const getStdin = deps.stdin ?? readStdin;
|
|
7860
|
+
const write = deps.write ?? ((s) => process.stdout.write(s));
|
|
7861
|
+
const input = parsePiStdin(await getStdin());
|
|
7862
|
+
const cwd = input.cwd && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
|
|
7863
|
+
const files = resolveHookFiles(cwd, input.file_path ? [input.file_path] : []);
|
|
7864
|
+
if (files.length === 0) return 0;
|
|
7865
|
+
const release = acquireHookLock(cwd);
|
|
7866
|
+
if (!release) return 0;
|
|
7867
|
+
try {
|
|
7868
|
+
const { diagnostics, score, rootDirectory } = await runScopedScan(cwd, files);
|
|
7869
|
+
const baseline = readBaseline(cwd);
|
|
7870
|
+
appendSessionFiles(cwd, files);
|
|
7871
|
+
const feedback = buildFeedback(diagnostics, score, rootDirectory, baseline ? {
|
|
7872
|
+
score: baseline.score,
|
|
7873
|
+
findingFingerprints: baseline.findingFingerprints
|
|
7874
|
+
} : void 0, {
|
|
7875
|
+
agent: "pi",
|
|
7876
|
+
touchedFiles: files
|
|
7877
|
+
});
|
|
7878
|
+
track({
|
|
7879
|
+
event: "hook_scan_completed",
|
|
7880
|
+
properties: buildHookScanCompletedProps({
|
|
7881
|
+
agent: "pi",
|
|
7882
|
+
score,
|
|
7883
|
+
scoreDelta: baseline ? score - baseline.score : null,
|
|
7884
|
+
findingCount: diagnostics.length,
|
|
7885
|
+
fileCount: files.length
|
|
7886
|
+
})
|
|
7887
|
+
});
|
|
7888
|
+
const output = {
|
|
7889
|
+
schema: "aislop.hook.v2",
|
|
7890
|
+
block: feedback.counts.error > 0 || feedback.regressed,
|
|
7891
|
+
message: formatPiMessage(feedback),
|
|
7892
|
+
feedback
|
|
7893
|
+
};
|
|
7894
|
+
write(JSON.stringify(output));
|
|
7895
|
+
return 0;
|
|
7896
|
+
} catch {
|
|
7897
|
+
return 0;
|
|
7898
|
+
} finally {
|
|
7899
|
+
release();
|
|
7900
|
+
}
|
|
7901
|
+
};
|
|
7902
|
+
|
|
7207
7903
|
//#endregion
|
|
7208
7904
|
//#region src/hooks/assets.ts
|
|
7209
7905
|
const AISLOP_MD_BODY = `# aislop — agent instructions
|
|
@@ -7774,6 +8470,67 @@ const uninstallKilocode = (opts) => {
|
|
|
7774
8470
|
return uninstallRulesOnly(opts, resolveKilocodePaths(opts));
|
|
7775
8471
|
};
|
|
7776
8472
|
|
|
8473
|
+
//#endregion
|
|
8474
|
+
//#region src/hooks/install/pi.ts
|
|
8475
|
+
const resolvePiPaths = (opts) => {
|
|
8476
|
+
return { extension: opts.scope === "project" ? path.join(opts.cwd, ".pi", "extensions", "aislop.js") : path.join(opts.home, ".pi", "agent", "extensions", "aislop.js") };
|
|
8477
|
+
};
|
|
8478
|
+
const PI_EXTENSION_SOURCE = `// aislop — auto-generated pi extension. Do not edit by hand.
|
|
8479
|
+
// Reinstall with: aislop hook install --pi
|
|
8480
|
+
import { spawnSync } from "node:child_process";
|
|
8481
|
+
|
|
8482
|
+
export default function (pi) {
|
|
8483
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
8484
|
+
if (event.toolName !== "edit" && event.toolName !== "write") return;
|
|
8485
|
+
if (event.isError) return;
|
|
8486
|
+
const filePath = event.input && event.input.path;
|
|
8487
|
+
if (typeof filePath !== "string" || filePath.length === 0) return;
|
|
8488
|
+
|
|
8489
|
+
const bin = process.env.AISLOP_BIN || "aislop";
|
|
8490
|
+
const payload = JSON.stringify({
|
|
8491
|
+
cwd: ctx.cwd,
|
|
8492
|
+
file_path: filePath,
|
|
8493
|
+
tool_name: event.toolName,
|
|
8494
|
+
});
|
|
8495
|
+
|
|
8496
|
+
let out;
|
|
8497
|
+
try {
|
|
8498
|
+
const res = spawnSync(bin, ["hook", "pi"], {
|
|
8499
|
+
input: payload,
|
|
8500
|
+
encoding: "utf-8",
|
|
8501
|
+
timeout: 15000,
|
|
8502
|
+
});
|
|
8503
|
+
if (res.status !== 0 || !res.stdout) return;
|
|
8504
|
+
out = JSON.parse(res.stdout);
|
|
8505
|
+
} catch {
|
|
8506
|
+
return;
|
|
8507
|
+
}
|
|
8508
|
+
if (!out || !out.message) return;
|
|
8509
|
+
|
|
8510
|
+
return {
|
|
8511
|
+
content: [...event.content, { type: "text", text: out.message }],
|
|
8512
|
+
isError: event.isError,
|
|
8513
|
+
};
|
|
8514
|
+
});
|
|
8515
|
+
}
|
|
8516
|
+
`;
|
|
8517
|
+
const installPi = (opts) => {
|
|
8518
|
+
const paths = resolvePiPaths(opts);
|
|
8519
|
+
const result = emptyResult();
|
|
8520
|
+
applyContent(result, opts, paths.extension, PI_EXTENSION_SOURCE, "write pi aislop extension");
|
|
8521
|
+
return result;
|
|
8522
|
+
};
|
|
8523
|
+
const uninstallPi = (opts) => {
|
|
8524
|
+
const paths = resolvePiPaths(opts);
|
|
8525
|
+
const result = {
|
|
8526
|
+
removed: [],
|
|
8527
|
+
skipped: []
|
|
8528
|
+
};
|
|
8529
|
+
if (readIfExists(paths.extension) != null) applyRemoval(result, opts, paths.extension, null);
|
|
8530
|
+
else result.skipped.push(paths.extension);
|
|
8531
|
+
return result;
|
|
8532
|
+
};
|
|
8533
|
+
|
|
7777
8534
|
//#endregion
|
|
7778
8535
|
//#region src/hooks/install/windsurf.ts
|
|
7779
8536
|
const resolveWindsurfPaths = (opts) => ({ rules: path.join(opts.cwd, ".windsurfrules") });
|
|
@@ -7802,6 +8559,7 @@ const ALL_AGENTS = [
|
|
|
7802
8559
|
"claude",
|
|
7803
8560
|
"cursor",
|
|
7804
8561
|
"gemini",
|
|
8562
|
+
"pi",
|
|
7805
8563
|
"codex",
|
|
7806
8564
|
"windsurf",
|
|
7807
8565
|
"cline",
|
|
@@ -7820,6 +8578,7 @@ const AGENTS_SUPPORTING_BOTH_SCOPES = [
|
|
|
7820
8578
|
"claude",
|
|
7821
8579
|
"cursor",
|
|
7822
8580
|
"gemini",
|
|
8581
|
+
"pi",
|
|
7823
8582
|
"codex"
|
|
7824
8583
|
];
|
|
7825
8584
|
const paths = {
|
|
@@ -7843,6 +8602,7 @@ const paths = {
|
|
|
7843
8602
|
p.geminiMd
|
|
7844
8603
|
];
|
|
7845
8604
|
},
|
|
8605
|
+
pi: (opts) => [resolvePiPaths(opts).extension],
|
|
7846
8606
|
codex: (opts) => [resolveCodexPaths(opts).rules],
|
|
7847
8607
|
windsurf: (opts) => [resolveWindsurfPaths(opts).rules],
|
|
7848
8608
|
cline: (opts) => [resolveClinePaths(opts).rules, resolveRooPaths(opts).rules],
|
|
@@ -7866,6 +8626,11 @@ const REGISTRY = {
|
|
|
7866
8626
|
uninstall: uninstallGemini,
|
|
7867
8627
|
paths: paths.gemini
|
|
7868
8628
|
},
|
|
8629
|
+
pi: {
|
|
8630
|
+
install: installPi,
|
|
8631
|
+
uninstall: uninstallPi,
|
|
8632
|
+
paths: paths.pi
|
|
8633
|
+
},
|
|
7869
8634
|
codex: {
|
|
7870
8635
|
install: installCodex,
|
|
7871
8636
|
uninstall: uninstallCodex,
|
|
@@ -7986,6 +8751,10 @@ const AGENT_LABELS = {
|
|
|
7986
8751
|
label: "Gemini CLI",
|
|
7987
8752
|
hint: "AfterTool, runtime"
|
|
7988
8753
|
},
|
|
8754
|
+
pi: {
|
|
8755
|
+
label: "pi",
|
|
8756
|
+
hint: "extension, runtime"
|
|
8757
|
+
},
|
|
7989
8758
|
codex: {
|
|
7990
8759
|
label: "Codex CLI",
|
|
7991
8760
|
hint: "rules-only"
|
|
@@ -8092,6 +8861,7 @@ const hookRun = async (agent, flags) => {
|
|
|
8092
8861
|
else exitCode = await runClaudeHook();
|
|
8093
8862
|
else if (agent === "cursor") exitCode = await runCursorHook();
|
|
8094
8863
|
else if (agent === "gemini") exitCode = await runGeminiHook();
|
|
8864
|
+
else if (agent === "pi") exitCode = await runPiHook();
|
|
8095
8865
|
else {
|
|
8096
8866
|
process.stderr.write(`hook: agent "${agent}" has no runtime adapter (rules-file-only)\n`);
|
|
8097
8867
|
process.exit(0);
|
|
@@ -8154,6 +8924,7 @@ const AGENT_NAMES = [
|
|
|
8154
8924
|
"claude",
|
|
8155
8925
|
"cursor",
|
|
8156
8926
|
"gemini",
|
|
8927
|
+
"pi",
|
|
8157
8928
|
"codex",
|
|
8158
8929
|
"windsurf",
|
|
8159
8930
|
"cline",
|
|
@@ -8276,6 +9047,9 @@ const registerCallbacks = (hook) => {
|
|
|
8276
9047
|
hook.command("gemini").description("Internal: Gemini CLI AfterTool callback (reads stdin)").action(async () => {
|
|
8277
9048
|
await hookRun("gemini");
|
|
8278
9049
|
});
|
|
9050
|
+
hook.command("pi").description("Internal: pi extension tool_result callback (reads stdin)").action(async () => {
|
|
9051
|
+
await hookRun("pi");
|
|
9052
|
+
});
|
|
8279
9053
|
};
|
|
8280
9054
|
const registerHookCommand = (program) => {
|
|
8281
9055
|
const hook = program.command("hook").description("Install or invoke AI-agent integration hooks");
|
|
@@ -8608,6 +9382,30 @@ const printEngineStatus = (result) => {
|
|
|
8608
9382
|
}
|
|
8609
9383
|
};
|
|
8610
9384
|
|
|
9385
|
+
//#endregion
|
|
9386
|
+
//#region src/scoring/rule-severity.ts
|
|
9387
|
+
/**
|
|
9388
|
+
* Apply per-rule severity overrides from config: "off" drops the diagnostic,
|
|
9389
|
+
* "error"/"warning" rewrite its severity before scoring and rendering.
|
|
9390
|
+
*/
|
|
9391
|
+
const applyRuleSeverities = (diagnostics, overrides) => {
|
|
9392
|
+
if (Object.keys(overrides).length === 0) return diagnostics;
|
|
9393
|
+
const result = [];
|
|
9394
|
+
for (const diagnostic of diagnostics) {
|
|
9395
|
+
const override = overrides[diagnostic.rule];
|
|
9396
|
+
if (!override) {
|
|
9397
|
+
result.push(diagnostic);
|
|
9398
|
+
continue;
|
|
9399
|
+
}
|
|
9400
|
+
if (override === "off") continue;
|
|
9401
|
+
result.push(override === diagnostic.severity ? diagnostic : {
|
|
9402
|
+
...diagnostic,
|
|
9403
|
+
severity: override
|
|
9404
|
+
});
|
|
9405
|
+
}
|
|
9406
|
+
return result;
|
|
9407
|
+
};
|
|
9408
|
+
|
|
8611
9409
|
//#endregion
|
|
8612
9410
|
//#region src/ui/header.ts
|
|
8613
9411
|
const TAGLINE = "the quality gate for agentic coding";
|
|
@@ -8785,6 +9583,11 @@ const RULE_LABELS = {
|
|
|
8785
9583
|
"knip/types": "Unused type",
|
|
8786
9584
|
"ai-slop/trivial-comment": "Trivial restating comment",
|
|
8787
9585
|
"ai-slop/swallowed-exception": "Empty catch (swallowed error)",
|
|
9586
|
+
"ai-slop/silent-recovery": "Catch logs then continues",
|
|
9587
|
+
"ai-slop/meta-comment": "Meta/plan comment",
|
|
9588
|
+
"ai-slop/redundant-try-catch": "Redundant try/catch",
|
|
9589
|
+
"ai-slop/redundant-type-coercion": "Redundant type coercion",
|
|
9590
|
+
"ai-slop/duplicate-type-declaration": "Duplicate exported type",
|
|
8788
9591
|
"ai-slop/thin-wrapper": "Thin function wrapper",
|
|
8789
9592
|
"ai-slop/generic-naming": "Generic/vague identifier name",
|
|
8790
9593
|
"ai-slop/unused-import": "Unused import",
|
|
@@ -8798,6 +9601,8 @@ const RULE_LABELS = {
|
|
|
8798
9601
|
"ai-slop/ts-directive": "@ts-ignore / @ts-expect-error",
|
|
8799
9602
|
"ai-slop/narrative-comment": "Narrative comment block",
|
|
8800
9603
|
"ai-slop/duplicate-import": "Duplicate import statement",
|
|
9604
|
+
"ai-slop/hardcoded-url": "Hardcoded URL",
|
|
9605
|
+
"ai-slop/hardcoded-id": "Hardcoded provider ID",
|
|
8801
9606
|
"ai-slop/python-bare-except": "Bare except",
|
|
8802
9607
|
"ai-slop/python-broad-except": "Broad except",
|
|
8803
9608
|
"ai-slop/python-mutable-default": "Mutable default argument",
|
|
@@ -8924,8 +9729,54 @@ const renderCleanRun = (input, deps = {}) => {
|
|
|
8924
9729
|
return `\n ${parts.join(` ${sep} `)}\n`;
|
|
8925
9730
|
};
|
|
8926
9731
|
|
|
9732
|
+
//#endregion
|
|
9733
|
+
//#region src/utils/history.ts
|
|
9734
|
+
const HISTORY_FILE = "history.jsonl";
|
|
9735
|
+
const isHistoryDisabled = (env = process.env) => env.AISLOP_NO_HISTORY === "1";
|
|
9736
|
+
const historyPath = (directory) => path.join(path.resolve(directory), CONFIG_DIR, HISTORY_FILE);
|
|
9737
|
+
/**
|
|
9738
|
+
* Append a compact scan record to .aislop/history.jsonl. Best-effort: never
|
|
9739
|
+
* throws, so a read-only checkout or missing config dir can't break a scan.
|
|
9740
|
+
*/
|
|
9741
|
+
const appendHistory = (input) => {
|
|
9742
|
+
if (isHistoryDisabled()) return;
|
|
9743
|
+
const configDir = path.join(path.resolve(input.directory), CONFIG_DIR);
|
|
9744
|
+
if (!fs.existsSync(configDir)) return;
|
|
9745
|
+
const record = {
|
|
9746
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
9747
|
+
score: input.score,
|
|
9748
|
+
errors: input.errors,
|
|
9749
|
+
warnings: input.warnings,
|
|
9750
|
+
files: input.files,
|
|
9751
|
+
cliVersion: APP_VERSION
|
|
9752
|
+
};
|
|
9753
|
+
try {
|
|
9754
|
+
fs.appendFileSync(historyPath(input.directory), `${JSON.stringify(record)}\n`);
|
|
9755
|
+
} catch {}
|
|
9756
|
+
};
|
|
9757
|
+
const isHistoryRecord = (value) => {
|
|
9758
|
+
if (!value || typeof value !== "object") return false;
|
|
9759
|
+
const record = value;
|
|
9760
|
+
return typeof record.timestamp === "string" && typeof record.score === "number" && typeof record.errors === "number" && typeof record.warnings === "number" && typeof record.files === "number" && typeof record.cliVersion === "string";
|
|
9761
|
+
};
|
|
9762
|
+
const readHistory = (directory) => {
|
|
9763
|
+
const file = historyPath(directory);
|
|
9764
|
+
if (!fs.existsSync(file)) return [];
|
|
9765
|
+
const records = [];
|
|
9766
|
+
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
|
|
9767
|
+
const trimmed = line.trim();
|
|
9768
|
+
if (!trimmed) continue;
|
|
9769
|
+
try {
|
|
9770
|
+
const parsed = JSON.parse(trimmed);
|
|
9771
|
+
if (isHistoryRecord(parsed)) records.push(parsed);
|
|
9772
|
+
} catch {}
|
|
9773
|
+
}
|
|
9774
|
+
return records;
|
|
9775
|
+
};
|
|
9776
|
+
|
|
8927
9777
|
//#endregion
|
|
8928
9778
|
//#region src/commands/scan.ts
|
|
9779
|
+
const isMachineOutput = (options) => Boolean(options.json) || Boolean(options.sarif);
|
|
8929
9780
|
const shouldUseSpinner = () => Boolean(process.stderr.isTTY) && process.env.CI !== "true" && process.env.CI !== "1";
|
|
8930
9781
|
const ALL_ENGINE_NAMES = Object.keys(ENGINE_INFO);
|
|
8931
9782
|
const BREAKDOWN_TOP_N = 10;
|
|
@@ -9040,17 +9891,18 @@ const scanCommand = async (directory, config, options) => {
|
|
|
9040
9891
|
const runScanBody = async (resolvedDir, config, options, projectInfo) => {
|
|
9041
9892
|
const startTime = performance.now();
|
|
9042
9893
|
const showHeader = options.showHeader !== false;
|
|
9043
|
-
const
|
|
9894
|
+
const machineOutput = isMachineOutput(options);
|
|
9895
|
+
const useLiveProgress = !machineOutput && shouldUseSpinner();
|
|
9044
9896
|
let files;
|
|
9045
9897
|
if (options.staged) {
|
|
9046
9898
|
files = filterProjectFiles(resolvedDir, getStagedFiles(resolvedDir), [], config.exclude);
|
|
9047
|
-
if (!
|
|
9899
|
+
if (!machineOutput) log.muted(`Scope: ${files.length} staged file(s)`);
|
|
9048
9900
|
} else if (options.changes) {
|
|
9049
9901
|
files = filterProjectFiles(resolvedDir, getChangedFiles(resolvedDir), [], config.exclude);
|
|
9050
|
-
if (!
|
|
9902
|
+
if (!machineOutput) log.muted(`Scope: ${files.length} changed file(s)`);
|
|
9051
9903
|
} else {
|
|
9052
9904
|
files = filterProjectFiles(resolvedDir, listProjectFiles(resolvedDir), [], config.exclude);
|
|
9053
|
-
if (!
|
|
9905
|
+
if (!machineOutput) log.muted(`Scope: ${files.length} file(s) after exclusions`);
|
|
9054
9906
|
}
|
|
9055
9907
|
const configDir = findConfigDir(resolvedDir);
|
|
9056
9908
|
const rulesPath = configDir ? path.join(configDir, RULES_FILE) : void 0;
|
|
@@ -9067,7 +9919,7 @@ const runScanBody = async (resolvedDir, config, options, projectInfo) => {
|
|
|
9067
9919
|
}));
|
|
9068
9920
|
const progressRenderer = useLiveProgress ? new LiveGrid(gridRows) : null;
|
|
9069
9921
|
progressRenderer?.start();
|
|
9070
|
-
const
|
|
9922
|
+
const rawResults = await runEngines({
|
|
9071
9923
|
rootDirectory: resolvedDir,
|
|
9072
9924
|
languages: projectInfo.languages,
|
|
9073
9925
|
frameworks: projectInfo.frameworks,
|
|
@@ -9100,9 +9952,13 @@ const runScanBody = async (resolvedDir, config, options, projectInfo) => {
|
|
|
9100
9952
|
elapsedMs: result.elapsed
|
|
9101
9953
|
});
|
|
9102
9954
|
}
|
|
9103
|
-
if (!
|
|
9955
|
+
if (!machineOutput && !progressRenderer) printEngineStatus(result);
|
|
9104
9956
|
});
|
|
9105
9957
|
progressRenderer?.stop();
|
|
9958
|
+
const results = rawResults.map((result) => ({
|
|
9959
|
+
...result,
|
|
9960
|
+
diagnostics: applyRuleSeverities(result.diagnostics, config.rules)
|
|
9961
|
+
}));
|
|
9106
9962
|
const allDiagnostics = results.flatMap((r) => r.diagnostics);
|
|
9107
9963
|
const elapsedMs = performance.now() - startTime;
|
|
9108
9964
|
const scoreResult = calculateScore(allDiagnostics, config.scoring.weights, config.scoring.thresholds, projectInfo.sourceFileCount, config.scoring.smoothing);
|
|
@@ -9123,12 +9979,24 @@ const runScanBody = async (resolvedDir, config, options, projectInfo) => {
|
|
|
9123
9979
|
engineIssues,
|
|
9124
9980
|
engineTimings
|
|
9125
9981
|
};
|
|
9982
|
+
if (options.sarif) {
|
|
9983
|
+
const { buildSarifLog } = await import("./sarif-CZVuavf_.js");
|
|
9984
|
+
console.log(JSON.stringify(buildSarifLog(results), null, 2));
|
|
9985
|
+
return completion;
|
|
9986
|
+
}
|
|
9126
9987
|
if (options.json) {
|
|
9127
9988
|
const { buildJsonOutput } = await import("./json-OIzja7OM.js");
|
|
9128
9989
|
const jsonOut = buildJsonOutput(results, scoreResult, projectInfo.sourceFileCount, elapsedMs);
|
|
9129
9990
|
console.log(JSON.stringify(jsonOut, null, 2));
|
|
9130
9991
|
return completion;
|
|
9131
9992
|
}
|
|
9993
|
+
if (!options.staged && !options.changes && options.command !== "ci" && !isCiEnv()) appendHistory({
|
|
9994
|
+
directory: resolvedDir,
|
|
9995
|
+
score: scoreResult.score,
|
|
9996
|
+
errors: completion.errorCount,
|
|
9997
|
+
warnings: completion.warningCount,
|
|
9998
|
+
files: projectInfo.sourceFileCount
|
|
9999
|
+
});
|
|
9132
10000
|
const projectName = projectInfo.projectName ?? "project";
|
|
9133
10001
|
const language = projectInfo.languages[0] ?? "unknown";
|
|
9134
10002
|
process.stdout.write(buildScanRender({
|
|
@@ -9155,7 +10023,8 @@ const ciCommand = async (directory, config, options = {}) => {
|
|
|
9155
10023
|
changes: false,
|
|
9156
10024
|
staged: false,
|
|
9157
10025
|
verbose: false,
|
|
9158
|
-
json: !options.human,
|
|
10026
|
+
json: !options.human && !options.sarif,
|
|
10027
|
+
sarif: options.sarif,
|
|
9159
10028
|
command: "ci"
|
|
9160
10029
|
});
|
|
9161
10030
|
} catch (error) {
|
|
@@ -9681,6 +10550,16 @@ const AGENT_CONFIGS = {
|
|
|
9681
10550
|
bin: "goose",
|
|
9682
10551
|
args: (p) => ["run", p]
|
|
9683
10552
|
},
|
|
10553
|
+
pi: {
|
|
10554
|
+
type: "cli",
|
|
10555
|
+
bin: "pi",
|
|
10556
|
+
args: (p) => ["-p", p]
|
|
10557
|
+
},
|
|
10558
|
+
crush: {
|
|
10559
|
+
type: "cli",
|
|
10560
|
+
bin: "crush",
|
|
10561
|
+
args: (p) => ["run", p]
|
|
10562
|
+
},
|
|
9684
10563
|
cursor: {
|
|
9685
10564
|
type: "editor",
|
|
9686
10565
|
bin: "cursor"
|
|
@@ -11535,6 +12414,11 @@ const BUILTIN_RULES = [
|
|
|
11535
12414
|
rules: [
|
|
11536
12415
|
"ai-slop/trivial-comment",
|
|
11537
12416
|
"ai-slop/swallowed-exception",
|
|
12417
|
+
"ai-slop/silent-recovery",
|
|
12418
|
+
"ai-slop/meta-comment",
|
|
12419
|
+
"ai-slop/redundant-try-catch",
|
|
12420
|
+
"ai-slop/redundant-type-coercion",
|
|
12421
|
+
"ai-slop/duplicate-type-declaration",
|
|
11538
12422
|
"ai-slop/thin-wrapper",
|
|
11539
12423
|
"ai-slop/generic-naming",
|
|
11540
12424
|
"ai-slop/unused-import",
|
|
@@ -11548,6 +12432,8 @@ const BUILTIN_RULES = [
|
|
|
11548
12432
|
"ai-slop/ts-directive",
|
|
11549
12433
|
"ai-slop/narrative-comment",
|
|
11550
12434
|
"ai-slop/duplicate-import",
|
|
12435
|
+
"ai-slop/hardcoded-url",
|
|
12436
|
+
"ai-slop/hardcoded-id",
|
|
11551
12437
|
"ai-slop/python-bare-except",
|
|
11552
12438
|
"ai-slop/python-broad-except",
|
|
11553
12439
|
"ai-slop/python-mutable-default",
|
|
@@ -11714,6 +12600,73 @@ const interactiveCommand = async (directory, config) => {
|
|
|
11714
12600
|
}
|
|
11715
12601
|
};
|
|
11716
12602
|
|
|
12603
|
+
//#endregion
|
|
12604
|
+
//#region src/commands/trend.ts
|
|
12605
|
+
const SPARK_TICKS = [
|
|
12606
|
+
"▁",
|
|
12607
|
+
"▂",
|
|
12608
|
+
"▃",
|
|
12609
|
+
"▄",
|
|
12610
|
+
"▅",
|
|
12611
|
+
"▆",
|
|
12612
|
+
"▇",
|
|
12613
|
+
"█"
|
|
12614
|
+
];
|
|
12615
|
+
const DEFAULT_LIMIT = 20;
|
|
12616
|
+
const renderSparkline = (scores) => {
|
|
12617
|
+
if (scores.length === 0) return "";
|
|
12618
|
+
const min = Math.min(...scores);
|
|
12619
|
+
const span = Math.max(...scores) - min;
|
|
12620
|
+
return scores.map((score) => {
|
|
12621
|
+
if (span === 0) return SPARK_TICKS[SPARK_TICKS.length - 1];
|
|
12622
|
+
const ratio = (score - min) / span;
|
|
12623
|
+
return SPARK_TICKS[Math.round(ratio * (SPARK_TICKS.length - 1))];
|
|
12624
|
+
}).join("");
|
|
12625
|
+
};
|
|
12626
|
+
const formatDate = (timestamp) => {
|
|
12627
|
+
const date = new Date(timestamp);
|
|
12628
|
+
if (Number.isNaN(date.getTime())) return timestamp;
|
|
12629
|
+
return date.toISOString().slice(0, 16).replace("T", " ");
|
|
12630
|
+
};
|
|
12631
|
+
const delta = (current, previous) => {
|
|
12632
|
+
if (previous === void 0) return "";
|
|
12633
|
+
const diff = current - previous;
|
|
12634
|
+
if (diff > 0) return style(theme, "success", `+${diff}`);
|
|
12635
|
+
if (diff < 0) return style(theme, "danger", `${diff}`);
|
|
12636
|
+
return style(theme, "muted", "0");
|
|
12637
|
+
};
|
|
12638
|
+
const buildTrendRender = (input) => {
|
|
12639
|
+
const header = renderHeader({
|
|
12640
|
+
version: APP_VERSION,
|
|
12641
|
+
command: "trend",
|
|
12642
|
+
context: [],
|
|
12643
|
+
brand: input.printBrand !== false
|
|
12644
|
+
});
|
|
12645
|
+
if (input.records.length === 0) return `${header}\n ${style(theme, "muted", "No score history yet. Run a scan to start tracking trends.")}\n`;
|
|
12646
|
+
const limit = input.limit ?? DEFAULT_LIMIT;
|
|
12647
|
+
const recent = input.records.slice(-limit);
|
|
12648
|
+
const scores = recent.map((r) => r.score);
|
|
12649
|
+
const lines = [header];
|
|
12650
|
+
lines.push(` ${style(theme, "dim", padEnd("Date", 18))}${style(theme, "dim", padEnd("Score", 8))}${style(theme, "dim", padEnd("Δ", 6))}${style(theme, "dim", padEnd("Err", 6))}${style(theme, "dim", "Warn")}`);
|
|
12651
|
+
recent.forEach((record, index) => {
|
|
12652
|
+
const previous = index > 0 ? recent[index - 1]?.score : void 0;
|
|
12653
|
+
lines.push(` ${padEnd(formatDate(record.timestamp), 18)}${padEnd(String(record.score), 8)}${padEnd(delta(record.score, previous), 6)}${padEnd(String(record.errors), 6)}${record.warnings}`);
|
|
12654
|
+
});
|
|
12655
|
+
const latest = recent[recent.length - 1];
|
|
12656
|
+
lines.push("");
|
|
12657
|
+
lines.push(` ${style(theme, "accent", renderSparkline(scores))}`);
|
|
12658
|
+
lines.push(` ${style(theme, "muted", `${recent.length} run(s), latest score ${latest?.score}`)}`);
|
|
12659
|
+
lines.push(renderHintLine("Run aislop scan to add a new data point").trimEnd());
|
|
12660
|
+
return `${lines.join("\n")}\n`;
|
|
12661
|
+
};
|
|
12662
|
+
const trendCommand = (directory, limit) => {
|
|
12663
|
+
const records = readHistory(directory);
|
|
12664
|
+
process.stdout.write(buildTrendRender({
|
|
12665
|
+
records,
|
|
12666
|
+
limit
|
|
12667
|
+
}));
|
|
12668
|
+
};
|
|
12669
|
+
|
|
11717
12670
|
//#endregion
|
|
11718
12671
|
//#region src/cli.ts
|
|
11719
12672
|
process.on("SIGINT", () => process.exit(0));
|
|
@@ -11729,17 +12682,22 @@ const commaSeparatedParser = (value, previous = []) => {
|
|
|
11729
12682
|
const parts = value.split(",").map((v) => v.trim()).filter(Boolean);
|
|
11730
12683
|
return [...previous, ...parts];
|
|
11731
12684
|
};
|
|
12685
|
+
const wantsSarif = (flags) => Boolean(flags.sarif) || flags.format === "sarif";
|
|
12686
|
+
const wantsJson = (flags) => Boolean(flags.json) || flags.format === "json";
|
|
11732
12687
|
const runScan = async (directory, flags) => {
|
|
11733
12688
|
const config = loadConfig(directory);
|
|
11734
|
-
const
|
|
12689
|
+
const finalConfig = {
|
|
11735
12690
|
...config,
|
|
11736
12691
|
exclude: [...config.exclude ?? [], ...flags.exclude ?? []],
|
|
11737
12692
|
include: [...config.include ?? [], ...flags.include ?? []]
|
|
11738
|
-
}
|
|
12693
|
+
};
|
|
12694
|
+
const sarif = wantsSarif(flags);
|
|
12695
|
+
const { exitCode } = await scanCommand(directory, finalConfig, {
|
|
11739
12696
|
changes: Boolean(flags.changes),
|
|
11740
12697
|
staged: Boolean(flags.staged),
|
|
11741
12698
|
verbose: Boolean(flags.verbose),
|
|
11742
|
-
json:
|
|
12699
|
+
json: !sarif && wantsJson(flags),
|
|
12700
|
+
sarif,
|
|
11743
12701
|
exclude: flags.exclude,
|
|
11744
12702
|
include: flags.include
|
|
11745
12703
|
});
|
|
@@ -11748,8 +12706,8 @@ const runScan = async (directory, flags) => {
|
|
|
11748
12706
|
process.exitCode = exitCode;
|
|
11749
12707
|
}
|
|
11750
12708
|
};
|
|
11751
|
-
const noFlagsPassed = (flags) => !flags.changes && !flags.staged && !flags.verbose && !flags.json && !(flags.exclude && flags.exclude.length > 0) && !(flags.include && flags.include.length > 0);
|
|
11752
|
-
const program = new Command().name("aislop").description("The unified code quality CLI").version(APP_VERSION, "-v, --version").argument("[directory]", "project directory to scan", ".").option("--changes", "only scan changed files (git diff)").option("--staged", "only scan staged files").option("-d, --verbose", "show file details per rule").option("--json", "output JSON instead of terminal UI").option("--exclude <patterns>", "comma-separated or repeatable list of paths and files to exclude", commaSeparatedParser, []).option("--include <patterns>", "comma-separated or repeatable list of paths and files to include", commaSeparatedParser, []).action(async (directory, flags) => {
|
|
12709
|
+
const noFlagsPassed = (flags) => !flags.changes && !flags.staged && !flags.verbose && !flags.json && !flags.sarif && !flags.format && !(flags.exclude && flags.exclude.length > 0) && !(flags.include && flags.include.length > 0);
|
|
12710
|
+
const program = new Command().name("aislop").description("The unified code quality CLI").version(APP_VERSION, "-v, --version").argument("[directory]", "project directory to scan", ".").option("--changes", "only scan changed files (git diff)").option("--staged", "only scan staged files").option("-d, --verbose", "show file details per rule").option("--json", "output JSON instead of terminal UI").option("--sarif", "output SARIF 2.1.0 (for GitHub code scanning)").option("--format <format>", "output format: json or sarif").option("--exclude <patterns>", "comma-separated or repeatable list of paths and files to exclude", commaSeparatedParser, []).option("--include <patterns>", "comma-separated or repeatable list of paths and files to include", commaSeparatedParser, []).action(async (directory, flags) => {
|
|
11753
12711
|
if (noFlagsPassed(flags) && process.stdin.isTTY) try {
|
|
11754
12712
|
await interactiveCommand(directory, loadConfig(directory));
|
|
11755
12713
|
return;
|
|
@@ -11767,6 +12725,7 @@ ${style(theme, "dim", "Commands:")}
|
|
|
11767
12725
|
npx aislop doctor [dir] Check installed tools
|
|
11768
12726
|
npx aislop ci [dir] CI-friendly JSON output
|
|
11769
12727
|
npx aislop rules [dir] List all rules
|
|
12728
|
+
npx aislop trend [dir] Show score history trend
|
|
11770
12729
|
|
|
11771
12730
|
${style(theme, "dim", "Examples:")}
|
|
11772
12731
|
npx aislop Interactive menu
|
|
@@ -11780,12 +12739,14 @@ ${style(theme, "dim", "Examples:")}
|
|
|
11780
12739
|
npx aislop fix --cursor Open Cursor + copy prompt to clipboard
|
|
11781
12740
|
npx aislop fix -p Print a prompt to paste into any coding agent
|
|
11782
12741
|
npx aislop ci JSON output for CI pipelines
|
|
12742
|
+
npx aislop scan --sarif SARIF 2.1.0 for GitHub code scanning
|
|
12743
|
+
npx aislop trend Show score history over time
|
|
11783
12744
|
npx aislop scan --exclude node_modules
|
|
11784
12745
|
npx aislop scan --exclude node_modules,dist,file.txt
|
|
11785
12746
|
npx aislop scan --exclude node_modules --exclude dist --exclude **/*.ts
|
|
11786
12747
|
${renderHintLine("Run npx aislop scan to scan your project").trimEnd()}
|
|
11787
12748
|
`);
|
|
11788
|
-
program.command("scan [directory]").description("Run full code quality scan").option("--changes", "only scan changed files").option("--staged", "only scan staged files").option("-d, --verbose", "show file details per rule").option("--json", "output JSON").option("--exclude <patterns>", "comma-separated or repeatable list of paths and files to exclude", commaSeparatedParser, []).option("--include <patterns>", "comma-separated or repeatable list of paths and files to include", commaSeparatedParser, []).action(async (directory = ".", _flags, command) => {
|
|
12749
|
+
program.command("scan [directory]").description("Run full code quality scan").option("--changes", "only scan changed files").option("--staged", "only scan staged files").option("-d, --verbose", "show file details per rule").option("--json", "output JSON").option("--sarif", "output SARIF 2.1.0 (for GitHub code scanning)").option("--format <format>", "output format: json or sarif").option("--exclude <patterns>", "comma-separated or repeatable list of paths and files to exclude", commaSeparatedParser, []).option("--include <patterns>", "comma-separated or repeatable list of paths and files to include", commaSeparatedParser, []).action(async (directory = ".", _flags, command) => {
|
|
11789
12750
|
await runScan(directory, command.optsWithGlobals());
|
|
11790
12751
|
});
|
|
11791
12752
|
const FIX_AGENT_FLAGS = [
|
|
@@ -11858,6 +12819,16 @@ const FIX_AGENT_FLAGS = [
|
|
|
11858
12819
|
flag: "goose",
|
|
11859
12820
|
name: "goose",
|
|
11860
12821
|
help: "open Goose to fix remaining issues"
|
|
12822
|
+
},
|
|
12823
|
+
{
|
|
12824
|
+
flag: "pi",
|
|
12825
|
+
name: "pi",
|
|
12826
|
+
help: "open pi to fix remaining issues"
|
|
12827
|
+
},
|
|
12828
|
+
{
|
|
12829
|
+
flag: "crush",
|
|
12830
|
+
name: "crush",
|
|
12831
|
+
help: "open Crush to fix remaining issues"
|
|
11861
12832
|
}
|
|
11862
12833
|
];
|
|
11863
12834
|
const matchFixAgent = (flags) => {
|
|
@@ -11893,9 +12864,12 @@ program.command("doctor [directory]").description("Check installed tools and env
|
|
|
11893
12864
|
return { exitCode: 0 };
|
|
11894
12865
|
});
|
|
11895
12866
|
});
|
|
11896
|
-
program.command("ci [directory]").description("CI-friendly JSON output with exit codes").option("--human", "render the human-friendly scan design instead of JSON").action(async (directory = ".", _flags, command) => {
|
|
12867
|
+
program.command("ci [directory]").description("CI-friendly JSON output with exit codes").option("--human", "render the human-friendly scan design instead of JSON").option("--sarif", "output SARIF 2.1.0 (for GitHub code scanning)").option("--format <format>", "output format: json or sarif").action(async (directory = ".", _flags, command) => {
|
|
11897
12868
|
const flags = command.optsWithGlobals();
|
|
11898
|
-
const { exitCode } = await ciCommand(directory, loadConfig(directory), {
|
|
12869
|
+
const { exitCode } = await ciCommand(directory, loadConfig(directory), {
|
|
12870
|
+
human: Boolean(flags.human),
|
|
12871
|
+
sarif: Boolean(flags.sarif) || flags.format === "sarif"
|
|
12872
|
+
});
|
|
11899
12873
|
if (exitCode !== 0) {
|
|
11900
12874
|
await flushTelemetry();
|
|
11901
12875
|
process.exitCode = exitCode;
|
|
@@ -11931,6 +12905,16 @@ program.command("badge [directory]").description("Print the public score badge U
|
|
|
11931
12905
|
process.exit(1);
|
|
11932
12906
|
}
|
|
11933
12907
|
});
|
|
12908
|
+
program.command("trend [directory]").description("Show score history trend from .aislop/history.jsonl").option("--limit <n>", "number of recent runs to show", (v) => Number.parseInt(v, 10)).action(async (directory = ".", _flags, command) => {
|
|
12909
|
+
const flags = command.optsWithGlobals();
|
|
12910
|
+
await withCommandLifecycle({
|
|
12911
|
+
command: "trend",
|
|
12912
|
+
config: loadConfig(directory).telemetry
|
|
12913
|
+
}, async () => {
|
|
12914
|
+
trendCommand(directory, flags.limit);
|
|
12915
|
+
return { exitCode: 0 };
|
|
12916
|
+
});
|
|
12917
|
+
});
|
|
11934
12918
|
registerHookCommand(program);
|
|
11935
12919
|
const main = async () => {
|
|
11936
12920
|
fireInstalledOnce();
|