aislop 0.9.3 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -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.3";
37
+ const APP_VERSION = "0.9.5";
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$1 = new Set([
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$1
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$1
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[1],
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 bySpec = /* @__PURE__ */ new Map();
1777
+ const byBucket = /* @__PURE__ */ new Map();
1582
1778
  for (const imp of imports) {
1583
- const list = bySpec.get(imp.spec) ?? [];
1779
+ const key = `${imp.typeOnly ? "type" : "value"}\0${imp.spec}`;
1780
+ const list = byBucket.get(key) ?? [];
1584
1781
  list.push(imp);
1585
- bySpec.set(imp.spec, list);
1782
+ byBucket.set(key, list);
1586
1783
  }
1587
1784
  const relPath = path.relative(context.rootDirectory, filePath);
1588
- for (const [spec, occurrences] of bySpec) {
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,130 @@ 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 PLACEHOLDER_HOSTS = new Set([
2015
+ "example.com",
2016
+ "example.org",
2017
+ "example.net"
2018
+ ]);
2019
+ const PLACEHOLDER_ID_RE = /^(?:changeme|replace[_-]?me|your[_-]|example|placeholder|todo)/i;
2020
+ const HARDCODED_URL_FINDING = {
2021
+ rule: "ai-slop/hardcoded-url",
2022
+ message: "Hardcoded environment URL in production code",
2023
+ help: "Move deployment-specific URLs to environment variables or a typed config module. Keep only stable documentation/public links inline."
2024
+ };
2025
+ const HARDCODED_ID_FINDING = {
2026
+ rule: "ai-slop/hardcoded-id",
2027
+ message: "Hardcoded provider/project ID in production code",
2028
+ 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."
2029
+ };
2030
+ const makeFinding = (filePath, line, spec) => ({
2031
+ filePath,
2032
+ engine: "ai-slop",
2033
+ rule: spec.rule,
2034
+ severity: "warning",
2035
+ message: spec.message,
2036
+ help: spec.help,
2037
+ line,
2038
+ column: 0,
2039
+ category: "AI Slop",
2040
+ fixable: false
2041
+ });
2042
+ const isCommentOnlyLine = (trimmed) => trimmed.startsWith("//") || trimmed.startsWith("#") || trimmed.startsWith("*") || trimmed.startsWith("/*");
2043
+ const commentStartsBefore = (line, index, ext) => {
2044
+ const prefix = line.slice(0, index);
2045
+ if (ext === ".py" || ext === ".rb") return prefix.includes("#");
2046
+ if (ext === ".php") return prefix.includes("//") || prefix.includes("#");
2047
+ return prefix.includes("//") || prefix.includes("/*");
2048
+ };
2049
+ const safeUrlHost = (urlText) => {
2050
+ try {
2051
+ return new URL(urlText).hostname.toLowerCase();
2052
+ } catch {
2053
+ return null;
2054
+ }
2055
+ };
2056
+ const isEnvBackedLine = (line) => ENV_REFERENCE_RE.test(line);
2057
+ const shouldFlagUrlLiteral = (line, urlText) => {
2058
+ if (isEnvBackedLine(line)) return false;
2059
+ const host = safeUrlHost(urlText);
2060
+ if (!host) return false;
2061
+ if (PLACEHOLDER_HOSTS.has(host)) return false;
2062
+ if (DOC_URL_CONTEXT_RE.test(line) && !ENVIRONMENT_HOST_RE.test(host)) return false;
2063
+ return URL_CONFIG_CONTEXT_RE.test(line) || ENVIRONMENT_HOST_RE.test(host);
2064
+ };
2065
+ const hasUsefulIdShape = (value) => {
2066
+ if (PLACEHOLDER_ID_RE.test(value)) return false;
2067
+ if (/^https?:\/\//i.test(value)) return false;
2068
+ if (/^[A-Za-z]+$/.test(value)) return false;
2069
+ return /[0-9_-]/.test(value);
2070
+ };
2071
+ const scanLineForConfigLiterals = (line, relativePath, ext, lineNumber) => {
2072
+ const diagnostics = [];
2073
+ if (isCommentOnlyLine(line.trim())) return diagnostics;
2074
+ URL_LITERAL_RE.lastIndex = 0;
2075
+ let urlMatch;
2076
+ while ((urlMatch = URL_LITERAL_RE.exec(line)) !== null) {
2077
+ const urlText = urlMatch[2];
2078
+ if (commentStartsBefore(line, urlMatch.index, ext)) continue;
2079
+ if (!shouldFlagUrlLiteral(line, urlText)) continue;
2080
+ diagnostics.push(makeFinding(relativePath, lineNumber, HARDCODED_URL_FINDING));
2081
+ }
2082
+ if (!ID_CONTEXT_RE.test(line) || isEnvBackedLine(line) || DOC_URL_CONTEXT_RE.test(line)) return diagnostics;
2083
+ ID_LITERAL_RE.lastIndex = 0;
2084
+ let idMatch;
2085
+ while ((idMatch = ID_LITERAL_RE.exec(line)) !== null) {
2086
+ const value = idMatch[2];
2087
+ if (commentStartsBefore(line, idMatch.index, ext)) continue;
2088
+ if (!hasUsefulIdShape(value)) continue;
2089
+ diagnostics.push(makeFinding(relativePath, lineNumber, HARDCODED_ID_FINDING));
2090
+ }
2091
+ return diagnostics;
2092
+ };
2093
+ const scanFileForConfigLiterals = (content, relativePath, ext) => {
2094
+ if (!SOURCE_EXTENSIONS.has(ext)) return [];
2095
+ if (isNonProductionPath(relativePath)) return [];
2096
+ return content.split("\n").flatMap((line, index) => scanLineForConfigLiterals(line, relativePath, ext, index + 1));
2097
+ };
2098
+ const detectHardcodedConfigLiterals = async (context) => {
2099
+ const diagnostics = [];
2100
+ for (const filePath of getSourceFiles(context)) {
2101
+ if (isAutoGenerated(filePath)) continue;
2102
+ let content;
2103
+ try {
2104
+ content = fs.readFileSync(filePath, "utf-8");
2105
+ } catch {
2106
+ continue;
2107
+ }
2108
+ const relativePath = path.relative(context.rootDirectory, filePath);
2109
+ const ext = path.extname(filePath);
2110
+ diagnostics.push(...scanFileForConfigLiterals(content, relativePath, ext));
2111
+ }
2112
+ return diagnostics;
2113
+ };
2114
+
1793
2115
  //#endregion
1794
2116
  //#region src/engines/ai-slop/js-import-aliases.ts
1795
2117
  const TS_CONFIG_FILES = ["tsconfig.json", "jsconfig.json"];
@@ -2057,23 +2379,87 @@ const PYTHON_STDLIB = new Set([
2057
2379
  "zoneinfo"
2058
2380
  ]);
2059
2381
  const PYTHON_IMPORT_TO_PIP = {
2060
- yaml: "pyyaml",
2061
- PIL: "pillow",
2062
- dateutil: "python-dateutil",
2063
- cv2: "opencv-python",
2064
- sklearn: "scikit-learn",
2065
- bs4: "beautifulsoup4",
2066
- typing_extensions: "typing-extensions",
2067
- google: "google-api-python-client",
2068
- jose: "python-jose",
2069
- jwt: "pyjwt",
2070
- OpenSSL: "pyopenssl",
2071
- magic: "python-magic",
2072
- docx: "python-docx",
2073
- pptx: "python-pptx",
2074
- git: "gitpython",
2075
- socks: "pysocks",
2076
- redis: "redis"
2382
+ yaml: ["pyyaml"],
2383
+ PIL: ["pillow"],
2384
+ dateutil: ["python-dateutil"],
2385
+ cv2: [
2386
+ "opencv-python",
2387
+ "opencv-python-headless",
2388
+ "opencv-contrib-python"
2389
+ ],
2390
+ sklearn: ["scikit-learn"],
2391
+ bs4: ["beautifulsoup4"],
2392
+ typing_extensions: ["typing-extensions"],
2393
+ dotenv: ["python-dotenv"],
2394
+ genai: ["google-genai"],
2395
+ google: [
2396
+ "google-genai",
2397
+ "google-generativeai",
2398
+ "google-api-python-client",
2399
+ "google-cloud-storage",
2400
+ "google-cloud-aiplatform",
2401
+ "google-auth",
2402
+ "protobuf"
2403
+ ],
2404
+ jose: ["python-jose"],
2405
+ jwt: ["pyjwt"],
2406
+ OpenSSL: ["pyopenssl"],
2407
+ Crypto: ["pycryptodome", "pycryptodomex"],
2408
+ Cryptodome: ["pycryptodomex", "pycryptodome"],
2409
+ magic: ["python-magic"],
2410
+ docx: ["python-docx"],
2411
+ pptx: ["python-pptx"],
2412
+ git: ["gitpython"],
2413
+ socks: ["pysocks"],
2414
+ redis: ["redis"],
2415
+ cairo: ["pycairo"],
2416
+ serial: ["pyserial"],
2417
+ usb: ["pyusb"],
2418
+ gi: ["pygobject"],
2419
+ Xlib: ["python-xlib"],
2420
+ ldap: ["python-ldap"],
2421
+ slugify: ["python-slugify"],
2422
+ memcache: ["python-memcached"],
2423
+ dns: ["dnspython"],
2424
+ attr: ["attrs"],
2425
+ attrs: ["attrs"],
2426
+ zoneinfo_data: ["tzdata"],
2427
+ pkg_resources: ["setuptools"],
2428
+ setuptools: ["setuptools"],
2429
+ wx: ["wxpython"],
2430
+ skimage: ["scikit-image"],
2431
+ OpenGL: ["pyopengl"],
2432
+ win32api: ["pywin32"],
2433
+ win32con: ["pywin32"],
2434
+ win32com: ["pywin32"],
2435
+ pythoncom: ["pywin32"],
2436
+ pywintypes: ["pywin32"],
2437
+ rest_framework: ["djangorestframework"],
2438
+ allauth: ["django-allauth"],
2439
+ corsheaders: ["django-cors-headers"],
2440
+ debug_toolbar: ["django-debug-toolbar"],
2441
+ environ: ["django-environ"],
2442
+ flask_cors: ["flask-cors"],
2443
+ flask_sqlalchemy: ["flask-sqlalchemy"],
2444
+ flask_migrate: ["flask-migrate"],
2445
+ flask_login: ["flask-login"],
2446
+ jwt_extended: ["flask-jwt-extended"],
2447
+ dateparser: ["dateparser"],
2448
+ yaml_include: ["pyyaml-include"],
2449
+ lxml_html_clean: ["lxml-html-clean"],
2450
+ grpc: ["grpcio"],
2451
+ grpc_status: ["grpcio-status"],
2452
+ google_crc32c: ["google-crc32c"],
2453
+ pkg_about: ["pkg-about"],
2454
+ mpl_toolkits: ["matplotlib"],
2455
+ dotmap: ["dotmap"],
2456
+ pydantic_settings: ["pydantic-settings"],
2457
+ telegram: ["python-telegram-bot"],
2458
+ discord: ["discord-py"],
2459
+ nacl: ["pynacl"],
2460
+ jwcrypto: ["jwcrypto"],
2461
+ humanfriendly: ["humanfriendly"],
2462
+ multipart: ["python-multipart"]
2077
2463
  };
2078
2464
 
2079
2465
  //#endregion
@@ -2112,6 +2498,8 @@ const collectFromPyproject = (rootDir, pyDeps) => {
2112
2498
  const m = line.match(/["']\s*([a-zA-Z0-9_\-.]+)/);
2113
2499
  if (m) addPyDep(pyDeps, m[1]);
2114
2500
  }
2501
+ const extras = content.match(/\[project\.optional-dependencies\]([\s\S]*?)(?=\n\[|$)/);
2502
+ if (extras) for (const m of extras[1].matchAll(/["']\s*([a-zA-Z][a-zA-Z0-9_\-.]+)/g)) addPyDep(pyDeps, m[1]);
2115
2503
  const poetryRe = /\[tool\.poetry(?:\.group\.[a-z]+)?\.dependencies\]([\s\S]*?)(?=\n\[|$)/g;
2116
2504
  let match = poetryRe.exec(content);
2117
2505
  while (match !== null) {
@@ -2310,6 +2698,11 @@ const packageNameFromImport = (spec) => {
2310
2698
  }
2311
2699
  return spec.split("/")[0];
2312
2700
  };
2701
+ const typesPackageName = (pkg) => {
2702
+ if (pkg.startsWith("@types/")) return pkg;
2703
+ if (pkg.startsWith("@")) return `@types/${pkg.slice(1).replace("/", "__")}`;
2704
+ return `@types/${pkg}`;
2705
+ };
2313
2706
  const STATIC_IMPORT_RE = /^\s*import\s+(?:[\w*{},\s]+\s+from\s+)?["']([^"']+)["']/;
2314
2707
  const DYNAMIC_IMPORT_RE = /(?:import|require)\s*\(\s*["']([^"']+)["']/g;
2315
2708
  const extractJsImports = (content) => {
@@ -2374,15 +2767,16 @@ const checkJsImport = (rawSpec, manifest, tsAliasMatchers) => {
2374
2767
  const realPkg = pkg.slice(7);
2375
2768
  if (manifest.jsDeps.has(realPkg)) return null;
2376
2769
  }
2770
+ if (manifest.jsDeps.has(typesPackageName(pkg))) return null;
2377
2771
  return pkg;
2378
2772
  };
2773
+ const normalizePyName = (name) => name.toLowerCase().replace(/_/g, "-");
2379
2774
  const checkPyImport = (spec, manifest) => {
2380
2775
  const root = spec.split(".")[0];
2381
2776
  if (PYTHON_STDLIB.has(root)) return null;
2382
- const normalized = root.toLowerCase().replace(/_/g, "-");
2777
+ const normalized = normalizePyName(root);
2383
2778
  if (manifest.pyDeps.has(normalized)) return null;
2384
- const pipName = PYTHON_IMPORT_TO_PIP[root];
2385
- if (pipName && manifest.pyDeps.has(pipName)) return null;
2779
+ if ((PYTHON_IMPORT_TO_PIP[root] ?? PYTHON_IMPORT_TO_PIP[normalized])?.some((dist) => manifest.pyDeps.has(normalizePyName(dist)))) return null;
2386
2780
  return root;
2387
2781
  };
2388
2782
  const detectHallucinatedImports = async (context) => {
@@ -2532,6 +2926,85 @@ const collectBlocks = (sourceLines, syntax) => {
2532
2926
  return blocks;
2533
2927
  };
2534
2928
 
2929
+ //#endregion
2930
+ //#region src/engines/ai-slop/meta-comment.ts
2931
+ const PLAN_REFERENCE_RES = [
2932
+ /\b(?:stage|step|phase)\s+\d+\b(?!\s*[:.]?\s*(?:bytes|ms|seconds|of\s+\d))/i,
2933
+ /\bstep\s+\d+\s+of\s+the\s+plan\b/i,
2934
+ /\bas\s+(?:per|requested)\s+(?:the\s+)?(?:requirements?|spec|task|ticket|prompt|instructions?)\b/i,
2935
+ /\bper\s+the\s+(?:spec|requirements?|task|ticket|plan|prompt|instructions?)\b/i,
2936
+ /\bfrom\s+the\s+(?:task|todo|plan|spec|ticket|prompt|requirements?)\b/i,
2937
+ /\bimplement(?:ing|s|ed)?\s+use\s*case\s+\d*/i,
2938
+ /\b(?:requirements?\s+doc|requirement\s+\d+)\b/i,
2939
+ /\bas\s+(?:instructed|specified|outlined)\s+(?:above|below|in\s+the)\b/i
2940
+ ];
2941
+ const BEFORE_AFTER_RES = [
2942
+ /\bpreviously[,:]?\s+(?:this|we|it|the)\b/i,
2943
+ /\bused\s+to\s+(?:be|use|call|return|do|have|rely)\b/i,
2944
+ /\bchanged\s+(?:\w+\s+){0,3}from\s+.+\bto\b/i,
2945
+ /\bno\s+longer\s+(?:needed|used|required|necessary|calls?|returns?|does)\b/i,
2946
+ /\bthis\s+was\s+.+\bbut\s+now\b/i,
2947
+ /\bwe\s+(?:now|used\s+to)\s+(?:no\s+longer\s+)?(?:use|call|return|do|have)\b/i,
2948
+ /\breplaced\s+the\s+(?:old|previous|former)\b/i,
2949
+ /\b(?:was|were)\s+(?:renamed|moved|removed|refactored|extracted)\s+(?:from|to|out\s+of)\b/i
2950
+ ];
2951
+ const WHY_OR_TODO_RE = /\b(?:because|since|otherwise|todo|fixme|hack|note:|reason:|workaround|see\s+(?:issue|#))\b/i;
2952
+ const looksLikeLicenseHeader$1 = (block) => {
2953
+ if (block.startLine !== 1) return false;
2954
+ const text = block.rawLines.join(" ").toLowerCase();
2955
+ return text.includes("copyright") || text.includes("license") || text.includes("spdx-license-identifier");
2956
+ };
2957
+ 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));
2958
+ const matchMetaSignal = (block) => {
2959
+ if (looksLikeLicenseHeader$1(block)) return null;
2960
+ if (looksLikeSuppressDirective$1(block)) return null;
2961
+ if (block.kind === "jsdoc" && block.hasMeaningfulJsdocTag) return null;
2962
+ if (block.isRustDoc) return null;
2963
+ const joined = block.prose.join(" ");
2964
+ if (joined.trim().length === 0) return null;
2965
+ if (WHY_OR_TODO_RE.test(joined)) return null;
2966
+ if (PLAN_REFERENCE_RES.some((re) => re.test(joined))) return "plan/process reference";
2967
+ if (BEFORE_AFTER_RES.some((re) => re.test(joined))) return "before/after state narration";
2968
+ return null;
2969
+ };
2970
+ const detectMetaComments = async (context) => {
2971
+ const files = getSourceFiles(context);
2972
+ const diagnostics = [];
2973
+ for (const filePath of files) {
2974
+ const ext = path.extname(filePath);
2975
+ if (!SUPPORTED_EXTS.has(ext)) continue;
2976
+ if (isAutoGenerated(filePath)) continue;
2977
+ const syntax = getCommentSyntax(ext);
2978
+ if (!syntax) continue;
2979
+ const relativePath = path.relative(context.rootDirectory, filePath);
2980
+ if (isNonProductionPath(relativePath)) continue;
2981
+ let content;
2982
+ try {
2983
+ content = fs.readFileSync(filePath, "utf-8");
2984
+ } catch {
2985
+ continue;
2986
+ }
2987
+ const blocks = collectBlocks(content.split("\n"), syntax);
2988
+ for (const block of blocks) {
2989
+ const reason = matchMetaSignal(block);
2990
+ if (!reason) continue;
2991
+ diagnostics.push({
2992
+ filePath: relativePath,
2993
+ engine: "ai-slop",
2994
+ rule: "ai-slop/meta-comment",
2995
+ severity: "warning",
2996
+ message: `Meta/plan comment (${reason})`,
2997
+ help: "Remove — references to the build plan or before/after code state belong in PR descriptions and commit messages, not source.",
2998
+ line: block.startLine,
2999
+ column: 0,
3000
+ category: "Comments",
3001
+ fixable: false
3002
+ });
3003
+ }
3004
+ }
3005
+ return diagnostics;
3006
+ };
3007
+
2535
3008
  //#endregion
2536
3009
  //#region src/engines/ai-slop/narrative-comments.ts
2537
3010
  const looksLikeDeclarationPreamble = (nextLine, ext) => {
@@ -2763,6 +3236,11 @@ const BROAD_EXCEPT_RE = /^\s*except\s+(Exception|BaseException)\s*(?:as\s+\w+)?\
2763
3236
  const PRINT_RE = /^\s*print\s*\(/;
2764
3237
  const DEF_RE = /^\s*(?:async\s+)?def\s+\w+\s*\(/;
2765
3238
  const MUTABLE_DEFAULT_RE = /(\w+)\s*(?::\s*[^,)=]+)?\s*=\s*(\[\s*\]|\{\s*\}|set\(\s*\))/;
3239
+ const RANGE_LEN_LOOP_RE = /^\s*for\s+([A-Za-z_]\w*)\s+in\s+range\s*\(\s*len\s*\(\s*([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\s*\)\s*\)\s*:\s*(?:#.*)?$/;
3240
+ const CHAINED_DICT_GET_RE = /\.get\s*\([^)]*,\s*\{\s*\}\s*\)\s*\.get\s*\(/;
3241
+ const SAME_VALUE_BRANCH_RE = /^(\s*)(?:if|elif)\s+([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\s*==\s*["'][^"']+["']\s*:/;
3242
+ const INSTANCE_BRANCH_RE = /^(\s*)(?:if|elif)\s+isinstance\s*\(\s*([A-Za-z_]\w*)\s*,\s*[^)]+\)\s*:/;
3243
+ const BRANCH_LADDER_THRESHOLD = 4;
2766
3244
  const isTestFile$1 = (relPath, basename) => basename.startsWith("test_") || basename.endsWith("_test.py") || basename === "conftest.py" || relPath.split(path.sep).some((seg) => seg === "tests" || seg === "test");
2767
3245
  const isScriptOrEntrypoint = (basename) => basename === "__main__.py" || basename === "manage.py" || basename === "setup.py";
2768
3246
  const SCRIPT_DIR_NAMES = new Set([
@@ -2815,6 +3293,13 @@ const pushFinding = (out, a) => {
2815
3293
  fixable: false
2816
3294
  });
2817
3295
  };
3296
+ const pushLineFinding = (out, relPath, line, finding) => {
3297
+ pushFinding(out, {
3298
+ relPath,
3299
+ line,
3300
+ ...finding
3301
+ });
3302
+ };
2818
3303
  const flagBareExcept = (lines, relPath, out) => {
2819
3304
  for (let i = 0; i < lines.length; i++) {
2820
3305
  if (!BARE_EXCEPT_RE.test(lines[i])) continue;
@@ -2896,6 +3381,76 @@ const flagPrintInProduction = (lines, relPath, basename, out) => {
2896
3381
  });
2897
3382
  }
2898
3383
  };
3384
+ const flagRangeLenLoops = (lines, relPath, out) => {
3385
+ for (let i = 0; i < lines.length; i++) {
3386
+ const match = RANGE_LEN_LOOP_RE.exec(lines[i]);
3387
+ if (!match) continue;
3388
+ pushLineFinding(out, relPath, i + 1, {
3389
+ rule: "ai-slop/python-range-len-loop",
3390
+ severity: "info",
3391
+ message: `\`range(len(${match[2]}))\` loop — usually a hand-rolled iteration pattern.`,
3392
+ help: "Prefer direct iteration (`for item in items`) or `enumerate(items)` when the index is needed. Keeping index plumbing out of the loop reduces checkpoint-to-checkpoint bloat."
3393
+ });
3394
+ }
3395
+ };
3396
+ const flagChainedDictGets = (lines, relPath, out) => {
3397
+ for (let i = 0; i < lines.length; i++) {
3398
+ if (!CHAINED_DICT_GET_RE.test(lines[i])) continue;
3399
+ pushLineFinding(out, relPath, i + 1, {
3400
+ rule: "ai-slop/python-chained-dict-get",
3401
+ severity: "warning",
3402
+ message: "Chained `.get(..., {})` defaults hide missing-data cases.",
3403
+ help: "Normalize the input at the boundary, use a typed object, or split the lookup into explicit steps. Empty-dict fallback chains are a common agent shortcut that becomes brittle as schemas evolve."
3404
+ });
3405
+ }
3406
+ };
3407
+ const countBranchLadder = (lines, start, pattern, selector, indent) => {
3408
+ let count = 1;
3409
+ for (let i = start + 1; i < lines.length; i++) {
3410
+ const line = lines[i];
3411
+ const trimmed = line.trim();
3412
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
3413
+ const match = pattern.exec(line);
3414
+ if (match?.[1] === indent && match[2] === selector) {
3415
+ count++;
3416
+ continue;
3417
+ }
3418
+ if (line.startsWith(`${indent}elif `)) break;
3419
+ if (line.length - line.trimStart().length <= indent.length && !line.startsWith(`${indent}else`)) break;
3420
+ }
3421
+ return count;
3422
+ };
3423
+ const flagBranchLadders = (lines, relPath, out) => {
3424
+ const reported = /* @__PURE__ */ new Set();
3425
+ for (let i = 0; i < lines.length; i++) {
3426
+ if (reported.has(i)) continue;
3427
+ const valueMatch = SAME_VALUE_BRANCH_RE.exec(lines[i]);
3428
+ if (valueMatch) {
3429
+ const count = countBranchLadder(lines, i, SAME_VALUE_BRANCH_RE, valueMatch[2], valueMatch[1]);
3430
+ if (count >= BRANCH_LADDER_THRESHOLD) {
3431
+ reported.add(i);
3432
+ pushLineFinding(out, relPath, i + 1, {
3433
+ rule: "ai-slop/python-repetitive-dispatch",
3434
+ severity: "warning",
3435
+ message: `${count} repeated branches dispatch on \`${valueMatch[2]}\`.`,
3436
+ help: "Use a table, set membership, or handler map when branches share the same shape. SlopCodeBench highlights these selector ladders as code that keeps growing instead of absorbing new cases cleanly."
3437
+ });
3438
+ }
3439
+ continue;
3440
+ }
3441
+ const instanceMatch = INSTANCE_BRANCH_RE.exec(lines[i]);
3442
+ if (!instanceMatch) continue;
3443
+ const count = countBranchLadder(lines, i, INSTANCE_BRANCH_RE, instanceMatch[2], instanceMatch[1]);
3444
+ if (count < BRANCH_LADDER_THRESHOLD) continue;
3445
+ reported.add(i);
3446
+ pushLineFinding(out, relPath, i + 1, {
3447
+ rule: "ai-slop/python-isinstance-ladder",
3448
+ severity: "warning",
3449
+ message: `${count} repeated \`isinstance(${instanceMatch[2]}, ...)\` branches.`,
3450
+ help: "Prefer a handler map, protocol, or normalized intermediate representation when each type branch has the same role. Repeated type ladders are one of the maintainability smells SCBench-style checks look for."
3451
+ });
3452
+ }
3453
+ };
2899
3454
  const detectPythonPatterns = async (context) => {
2900
3455
  const diagnostics = [];
2901
3456
  const files = getSourceFiles(context);
@@ -2915,6 +3470,9 @@ const detectPythonPatterns = async (context) => {
2915
3470
  flagBroadExceptWithSilentBody(lines, relPath, diagnostics);
2916
3471
  flagMutableDefaults(lines, relPath, diagnostics);
2917
3472
  flagPrintInProduction(lines, relPath, basename, diagnostics);
3473
+ flagRangeLenLoops(lines, relPath, diagnostics);
3474
+ flagChainedDictGets(lines, relPath, diagnostics);
3475
+ flagBranchLadders(lines, relPath, diagnostics);
2918
3476
  }
2919
3477
  return diagnostics;
2920
3478
  };
@@ -3055,6 +3613,143 @@ const detectRustPatterns = async (context) => {
3055
3613
  return diagnostics;
3056
3614
  };
3057
3615
 
3616
+ //#endregion
3617
+ //#region src/engines/ai-slop/silent-recovery.ts
3618
+ const JS_EXTS$1 = new Set([
3619
+ ".ts",
3620
+ ".tsx",
3621
+ ".js",
3622
+ ".jsx",
3623
+ ".mjs",
3624
+ ".cjs"
3625
+ ]);
3626
+ const CATCH_HEAD_RE = /\bcatch\s*(?:\([^)]*\))?\s*\{/g;
3627
+ const LOG_STATEMENT_RE = /^(?:console|[\w$]+(?:\.[\w$]+)*)\.(?:log|info|warn|warning|error|debug|trace)\s*\(/;
3628
+ const HANDLING_TOKEN_RE = /\b(?:throw|return|reject|next|process\.exit|continue|break)\b/;
3629
+ const stripBlockComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, "");
3630
+ const extractCatchBody = (content, openBraceIndex) => {
3631
+ let depth = 0;
3632
+ let inString = null;
3633
+ for (let i = openBraceIndex; i < content.length; i += 1) {
3634
+ const ch = content[i];
3635
+ const prev = content[i - 1];
3636
+ if (inString) {
3637
+ if (ch === inString && prev !== "\\") inString = null;
3638
+ continue;
3639
+ }
3640
+ if (ch === "\"" || ch === "'" || ch === "`") {
3641
+ inString = ch;
3642
+ continue;
3643
+ }
3644
+ if (ch === "{") depth += 1;
3645
+ else if (ch === "}") {
3646
+ depth -= 1;
3647
+ if (depth === 0) return content.slice(openBraceIndex + 1, i);
3648
+ }
3649
+ }
3650
+ return null;
3651
+ };
3652
+ const isLogOnlyBody = (body) => {
3653
+ const statements = stripBlockComments(body).split("\n").map((line) => line.replace(/\/\/.*$/, "").trim()).filter((line) => line.length > 0 && line !== ";");
3654
+ if (statements.length === 0) return false;
3655
+ if (statements.some((line) => HANDLING_TOKEN_RE.test(line))) return false;
3656
+ let sawLog = false;
3657
+ for (const statement of statements) {
3658
+ const normalized = statement.replace(/;+$/, "");
3659
+ if (LOG_STATEMENT_RE.test(normalized)) {
3660
+ sawLog = true;
3661
+ continue;
3662
+ }
3663
+ if (/^[\w$"'`{[(),.\s+:-]+$/.test(normalized) && !/[=(]\s*(?:async\s+)?\(/.test(normalized)) continue;
3664
+ return false;
3665
+ }
3666
+ return sawLog;
3667
+ };
3668
+ const detectJsSilentRecovery = (content, relPath) => {
3669
+ const out = [];
3670
+ CATCH_HEAD_RE.lastIndex = 0;
3671
+ let match;
3672
+ while ((match = CATCH_HEAD_RE.exec(content)) !== null) {
3673
+ const body = extractCatchBody(content, match.index + match[0].length - 1);
3674
+ if (body === null) continue;
3675
+ if (!isLogOnlyBody(body)) continue;
3676
+ const line = content.slice(0, match.index).split("\n").length;
3677
+ out.push({
3678
+ filePath: relPath,
3679
+ engine: "ai-slop",
3680
+ rule: "ai-slop/silent-recovery",
3681
+ severity: "warning",
3682
+ message: "Catch only logs then continues, leaving execution in a possibly broken state",
3683
+ help: "Handle the error: rethrow, return an error value, or recover explicitly. Logging alone lets the program proceed as if nothing failed.",
3684
+ line,
3685
+ column: 0,
3686
+ category: "AI Slop",
3687
+ fixable: false
3688
+ });
3689
+ }
3690
+ return out;
3691
+ };
3692
+ const PY_EXCEPT_RE = /^(\s*)except\b[^\n]*:\s*(?:#.*)?$/;
3693
+ const PY_LOG_STATEMENT_RE = /^(?:logging|logger|log|self\.log|self\.logger|print)(?:\.(?:debug|info|warning|warn|error|exception|critical))?\s*\(/;
3694
+ const PY_HANDLING_TOKEN_RE = /^(?:raise\b|return\b|continue\b|break\b|self\.|[\w.]+\s*=)/;
3695
+ const detectPySilentRecovery = (content, relPath) => {
3696
+ const out = [];
3697
+ const lines = content.split("\n");
3698
+ for (let i = 0; i < lines.length; i += 1) {
3699
+ const exceptMatch = PY_EXCEPT_RE.exec(lines[i]);
3700
+ if (!exceptMatch) continue;
3701
+ const indent = exceptMatch[1].length;
3702
+ const bodyLines = [];
3703
+ let j = i + 1;
3704
+ for (; j < lines.length; j += 1) {
3705
+ const raw = lines[j];
3706
+ if (raw.trim() === "") continue;
3707
+ if (raw.length - raw.trimStart().length <= indent) break;
3708
+ bodyLines.push(raw.trim());
3709
+ }
3710
+ if (bodyLines.length === 0) continue;
3711
+ if (bodyLines.some((line) => PY_HANDLING_TOKEN_RE.test(line))) continue;
3712
+ if (bodyLines.some((line) => line === "pass")) continue;
3713
+ const allLogs = bodyLines.every((line) => PY_LOG_STATEMENT_RE.test(line) || /^[\w"'(),.\s+:%{}[\]-]+$/.test(line));
3714
+ const sawLog = bodyLines.some((line) => PY_LOG_STATEMENT_RE.test(line));
3715
+ if (!allLogs || !sawLog) continue;
3716
+ out.push({
3717
+ filePath: relPath,
3718
+ engine: "ai-slop",
3719
+ rule: "ai-slop/silent-recovery",
3720
+ severity: "warning",
3721
+ message: "except only logs then continues, leaving execution in a possibly broken state",
3722
+ help: "Handle the error: re-raise, return an error value, or recover explicitly. Logging alone lets the program proceed as if nothing failed.",
3723
+ line: i + 1,
3724
+ column: 0,
3725
+ category: "AI Slop",
3726
+ fixable: false
3727
+ });
3728
+ }
3729
+ return out;
3730
+ };
3731
+ const detectSilentRecovery = async (context) => {
3732
+ const files = getSourceFiles(context);
3733
+ const diagnostics = [];
3734
+ for (const filePath of files) {
3735
+ if (isAutoGenerated(filePath)) continue;
3736
+ const ext = path.extname(filePath);
3737
+ const isJs = JS_EXTS$1.has(ext);
3738
+ if (!isJs && !(ext === ".py")) continue;
3739
+ const relPath = path.relative(context.rootDirectory, filePath);
3740
+ if (isNonProductionPath(relPath)) continue;
3741
+ let content;
3742
+ try {
3743
+ content = fs.readFileSync(filePath, "utf-8");
3744
+ } catch {
3745
+ continue;
3746
+ }
3747
+ if (isJs) diagnostics.push(...detectJsSilentRecovery(content, relPath));
3748
+ else diagnostics.push(...detectPySilentRecovery(content, relPath));
3749
+ }
3750
+ return diagnostics;
3751
+ };
3752
+
3058
3753
  //#endregion
3059
3754
  //#region src/engines/ai-slop/unused-imports.ts
3060
3755
  const JS_EXTENSIONS$1 = new Set([
@@ -3244,15 +3939,19 @@ const aiSlopEngine = {
3244
3939
  const results = await Promise.allSettled([
3245
3940
  detectTrivialComments(context),
3246
3941
  detectSwallowedExceptions(context),
3942
+ detectDefensivePatterns(context),
3247
3943
  detectOverAbstraction(context),
3248
3944
  detectDeadPatterns(context),
3249
3945
  detectUnusedImports(context),
3250
3946
  detectNarrativeComments(context),
3251
3947
  detectDuplicateImports(context),
3948
+ detectHardcodedConfigLiterals(context),
3252
3949
  detectPythonPatterns(context),
3253
3950
  detectGoPatterns(context),
3254
3951
  detectRustPatterns(context),
3255
- detectHallucinatedImports(context)
3952
+ detectHallucinatedImports(context),
3953
+ detectSilentRecovery(context),
3954
+ detectMetaComments(context)
3256
3955
  ]);
3257
3956
  for (const result of results) if (result.status === "fulfilled") diagnostics.push(...result.value);
3258
3957
  return {
@@ -6151,7 +6850,7 @@ const RISKY_PATTERNS = [
6151
6850
  help: "Use JSON, MessagePack, or other safe serialization formats for untrusted data"
6152
6851
  },
6153
6852
  {
6154
- pattern: new RegExp(`\\bexec\\s*\\(`, "g"),
6853
+ pattern: new RegExp(`(?<![\\w.>:\\\\])\\bexec\\s*\\(`, "g"),
6155
6854
  extensions: [".py"],
6156
6855
  name: "python-exec",
6157
6856
  message: "Use of exec() can execute arbitrary code",
@@ -6855,7 +7554,7 @@ const parseClaudeStdin = (raw) => {
6855
7554
  return {};
6856
7555
  }
6857
7556
  };
6858
- const readStdin$2 = async () => {
7557
+ const readStdin$3 = async () => {
6859
7558
  if (process.stdin.isTTY) return "";
6860
7559
  const chunks = [];
6861
7560
  for await (const chunk of process.stdin) chunks.push(chunk);
@@ -6873,7 +7572,7 @@ const renderClaudeOutput = (additional, block) => {
6873
7572
  return out;
6874
7573
  };
6875
7574
  const runClaudeHook = async (deps = {}) => {
6876
- const getStdin = deps.stdin ?? readStdin$2;
7575
+ const getStdin = deps.stdin ?? readStdin$3;
6877
7576
  const write = deps.write ?? ((s) => process.stdout.write(s));
6878
7577
  const input = parseClaudeStdin(await getStdin());
6879
7578
  const cwd = input.cwd && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
@@ -6920,7 +7619,7 @@ const parseClaudeFileChangedStdin = (raw) => {
6920
7619
  }
6921
7620
  };
6922
7621
  const runClaudeFileChangedHook = async (deps = {}) => {
6923
- const getStdin = deps.stdin ?? readStdin$2;
7622
+ const getStdin = deps.stdin ?? readStdin$3;
6924
7623
  const write = deps.write ?? ((s) => process.stdout.write(s));
6925
7624
  const input = parseClaudeFileChangedStdin(await getStdin());
6926
7625
  const cwd = input.cwd && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
@@ -6956,7 +7655,7 @@ const parseClaudeStopStdin = (raw) => {
6956
7655
  }
6957
7656
  };
6958
7657
  const runClaudeStopHook = async (deps = {}) => {
6959
- const getStdin = deps.stdin ?? readStdin$2;
7658
+ const getStdin = deps.stdin ?? readStdin$3;
6960
7659
  const write = deps.write ?? ((s) => process.stdout.write(s));
6961
7660
  const input = parseClaudeStopStdin(await getStdin());
6962
7661
  const cwd = input.cwd && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
@@ -7017,14 +7716,14 @@ const renderCursorOutput = (additional, event = "afterFileEdit") => ({ hookSpeci
7017
7716
  hookEventName: event,
7018
7717
  additionalContext: additional
7019
7718
  } });
7020
- const readStdin$1 = async () => {
7719
+ const readStdin$2 = async () => {
7021
7720
  if (process.stdin.isTTY) return "";
7022
7721
  const chunks = [];
7023
7722
  for await (const chunk of process.stdin) chunks.push(chunk);
7024
7723
  return Buffer.concat(chunks).toString("utf-8");
7025
7724
  };
7026
7725
  const runCursorHook = async (deps = {}) => {
7027
- const getStdin = deps.stdin ?? readStdin$1;
7726
+ const getStdin = deps.stdin ?? readStdin$2;
7028
7727
  const write = deps.write ?? ((s) => process.stdout.write(s));
7029
7728
  const writeErr = deps.writeErr ?? ((s) => process.stderr.write(s));
7030
7729
  const input = parseCursorStdin(await getStdin());
@@ -7080,14 +7779,14 @@ const renderGeminiOutput = (additional) => ({ hookSpecificOutput: {
7080
7779
  hookEventName: "AfterTool",
7081
7780
  additionalContext: additional
7082
7781
  } });
7083
- const readStdin = async () => {
7782
+ const readStdin$1 = async () => {
7084
7783
  if (process.stdin.isTTY) return "";
7085
7784
  const chunks = [];
7086
7785
  for await (const chunk of process.stdin) chunks.push(chunk);
7087
7786
  return Buffer.concat(chunks).toString("utf-8");
7088
7787
  };
7089
7788
  const runGeminiHook = async (deps = {}) => {
7090
- const getStdin = deps.stdin ?? readStdin;
7789
+ const getStdin = deps.stdin ?? readStdin$1;
7091
7790
  const write = deps.write ?? ((s) => process.stdout.write(s));
7092
7791
  const input = parseGeminiStdin(await getStdin());
7093
7792
  const cwd = input.cwd && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
@@ -7119,6 +7818,76 @@ const runGeminiHook = async (deps = {}) => {
7119
7818
  }
7120
7819
  };
7121
7820
 
7821
+ //#endregion
7822
+ //#region src/hooks/adapters/pi.ts
7823
+ const parsePiStdin = (raw) => {
7824
+ if (!raw.trim()) return {};
7825
+ try {
7826
+ return JSON.parse(raw);
7827
+ } catch {
7828
+ return {};
7829
+ }
7830
+ };
7831
+ const readStdin = async () => {
7832
+ if (process.stdin.isTTY) return "";
7833
+ const chunks = [];
7834
+ for await (const chunk of process.stdin) chunks.push(chunk);
7835
+ return Buffer.concat(chunks).toString("utf-8");
7836
+ };
7837
+ const formatPiMessage = (feedback) => {
7838
+ if (feedback.counts.total === 0 && !feedback.regressed) return "";
7839
+ const { error, warning } = feedback.counts;
7840
+ const header = `aislop: score ${feedback.score}/100${feedback.baseline != null ? ` (baseline ${feedback.baseline})` : ""}, ${error} error${error === 1 ? "" : "s"}, ${warning} warning${warning === 1 ? "" : "s"}.`;
7841
+ const lines = feedback.findings.map((f) => ` - ${f.file}:${f.line} [${f.severity}] ${f.ruleId}: ${f.message}`);
7842
+ if (feedback.elided && feedback.elided > 0) lines.push(` ...and ${feedback.elided} more.`);
7843
+ const tail = feedback.nextSteps.length > 0 ? `\n${feedback.nextSteps.join("\n")}` : "";
7844
+ return `${header}\n${lines.join("\n")}${tail}`;
7845
+ };
7846
+ const runPiHook = async (deps = {}) => {
7847
+ const getStdin = deps.stdin ?? readStdin;
7848
+ const write = deps.write ?? ((s) => process.stdout.write(s));
7849
+ const input = parsePiStdin(await getStdin());
7850
+ const cwd = input.cwd && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
7851
+ const files = resolveHookFiles(cwd, input.file_path ? [input.file_path] : []);
7852
+ if (files.length === 0) return 0;
7853
+ const release = acquireHookLock(cwd);
7854
+ if (!release) return 0;
7855
+ try {
7856
+ const { diagnostics, score, rootDirectory } = await runScopedScan(cwd, files);
7857
+ const baseline = readBaseline(cwd);
7858
+ appendSessionFiles(cwd, files);
7859
+ const feedback = buildFeedback(diagnostics, score, rootDirectory, baseline ? {
7860
+ score: baseline.score,
7861
+ findingFingerprints: baseline.findingFingerprints
7862
+ } : void 0, {
7863
+ agent: "pi",
7864
+ touchedFiles: files
7865
+ });
7866
+ track({
7867
+ event: "hook_scan_completed",
7868
+ properties: buildHookScanCompletedProps({
7869
+ agent: "pi",
7870
+ score,
7871
+ scoreDelta: baseline ? score - baseline.score : null,
7872
+ findingCount: diagnostics.length,
7873
+ fileCount: files.length
7874
+ })
7875
+ });
7876
+ const output = {
7877
+ schema: "aislop.hook.v2",
7878
+ block: feedback.counts.error > 0 || feedback.regressed,
7879
+ message: formatPiMessage(feedback),
7880
+ feedback
7881
+ };
7882
+ write(JSON.stringify(output));
7883
+ return 0;
7884
+ } catch {
7885
+ return 0;
7886
+ } finally {
7887
+ release();
7888
+ }
7889
+ };
7890
+
7122
7891
  //#endregion
7123
7892
  //#region src/hooks/assets.ts
7124
7893
  const AISLOP_MD_BODY = `# aislop — agent instructions
@@ -7689,6 +8458,67 @@ const uninstallKilocode = (opts) => {
7689
8458
  return uninstallRulesOnly(opts, resolveKilocodePaths(opts));
7690
8459
  };
7691
8460
 
8461
+ //#endregion
8462
+ //#region src/hooks/install/pi.ts
8463
+ const resolvePiPaths = (opts) => {
8464
+ return { extension: opts.scope === "project" ? path.join(opts.cwd, ".pi", "extensions", "aislop.js") : path.join(opts.home, ".pi", "agent", "extensions", "aislop.js") };
8465
+ };
8466
+ const PI_EXTENSION_SOURCE = `// aislop — auto-generated pi extension. Do not edit by hand.
8467
+ // Reinstall with: aislop hook install --pi
8468
+ import { spawnSync } from "node:child_process";
8469
+
8470
+ export default function (pi) {
8471
+ pi.on("tool_result", async (event, ctx) => {
8472
+ if (event.toolName !== "edit" && event.toolName !== "write") return;
8473
+ if (event.isError) return;
8474
+ const filePath = event.input && event.input.path;
8475
+ if (typeof filePath !== "string" || filePath.length === 0) return;
8476
+
8477
+ const bin = process.env.AISLOP_BIN || "aislop";
8478
+ const payload = JSON.stringify({
8479
+ cwd: ctx.cwd,
8480
+ file_path: filePath,
8481
+ tool_name: event.toolName,
8482
+ });
8483
+
8484
+ let out;
8485
+ try {
8486
+ const res = spawnSync(bin, ["hook", "pi"], {
8487
+ input: payload,
8488
+ encoding: "utf-8",
8489
+ timeout: 15000,
8490
+ });
8491
+ if (res.status !== 0 || !res.stdout) return;
8492
+ out = JSON.parse(res.stdout);
8493
+ } catch {
8494
+ return;
8495
+ }
8496
+ if (!out || !out.message) return;
8497
+
8498
+ return {
8499
+ content: [...event.content, { type: "text", text: out.message }],
8500
+ isError: event.isError,
8501
+ };
8502
+ });
8503
+ }
8504
+ `;
8505
+ const installPi = (opts) => {
8506
+ const paths = resolvePiPaths(opts);
8507
+ const result = emptyResult();
8508
+ applyContent(result, opts, paths.extension, PI_EXTENSION_SOURCE, "write pi aislop extension");
8509
+ return result;
8510
+ };
8511
+ const uninstallPi = (opts) => {
8512
+ const paths = resolvePiPaths(opts);
8513
+ const result = {
8514
+ removed: [],
8515
+ skipped: []
8516
+ };
8517
+ if (readIfExists(paths.extension) != null) applyRemoval(result, opts, paths.extension, null);
8518
+ else result.skipped.push(paths.extension);
8519
+ return result;
8520
+ };
8521
+
7692
8522
  //#endregion
7693
8523
  //#region src/hooks/install/windsurf.ts
7694
8524
  const resolveWindsurfPaths = (opts) => ({ rules: path.join(opts.cwd, ".windsurfrules") });
@@ -7717,6 +8547,7 @@ const ALL_AGENTS = [
7717
8547
  "claude",
7718
8548
  "cursor",
7719
8549
  "gemini",
8550
+ "pi",
7720
8551
  "codex",
7721
8552
  "windsurf",
7722
8553
  "cline",
@@ -7735,6 +8566,7 @@ const AGENTS_SUPPORTING_BOTH_SCOPES = [
7735
8566
  "claude",
7736
8567
  "cursor",
7737
8568
  "gemini",
8569
+ "pi",
7738
8570
  "codex"
7739
8571
  ];
7740
8572
  const paths = {
@@ -7758,6 +8590,7 @@ const paths = {
7758
8590
  p.geminiMd
7759
8591
  ];
7760
8592
  },
8593
+ pi: (opts) => [resolvePiPaths(opts).extension],
7761
8594
  codex: (opts) => [resolveCodexPaths(opts).rules],
7762
8595
  windsurf: (opts) => [resolveWindsurfPaths(opts).rules],
7763
8596
  cline: (opts) => [resolveClinePaths(opts).rules, resolveRooPaths(opts).rules],
@@ -7781,6 +8614,11 @@ const REGISTRY = {
7781
8614
  uninstall: uninstallGemini,
7782
8615
  paths: paths.gemini
7783
8616
  },
8617
+ pi: {
8618
+ install: installPi,
8619
+ uninstall: uninstallPi,
8620
+ paths: paths.pi
8621
+ },
7784
8622
  codex: {
7785
8623
  install: installCodex,
7786
8624
  uninstall: uninstallCodex,
@@ -7901,6 +8739,10 @@ const AGENT_LABELS = {
7901
8739
  label: "Gemini CLI",
7902
8740
  hint: "AfterTool, runtime"
7903
8741
  },
8742
+ pi: {
8743
+ label: "pi",
8744
+ hint: "extension, runtime"
8745
+ },
7904
8746
  codex: {
7905
8747
  label: "Codex CLI",
7906
8748
  hint: "rules-only"
@@ -8007,6 +8849,7 @@ const hookRun = async (agent, flags) => {
8007
8849
  else exitCode = await runClaudeHook();
8008
8850
  else if (agent === "cursor") exitCode = await runCursorHook();
8009
8851
  else if (agent === "gemini") exitCode = await runGeminiHook();
8852
+ else if (agent === "pi") exitCode = await runPiHook();
8010
8853
  else {
8011
8854
  process.stderr.write(`hook: agent "${agent}" has no runtime adapter (rules-file-only)\n`);
8012
8855
  process.exit(0);
@@ -8069,6 +8912,7 @@ const AGENT_NAMES = [
8069
8912
  "claude",
8070
8913
  "cursor",
8071
8914
  "gemini",
8915
+ "pi",
8072
8916
  "codex",
8073
8917
  "windsurf",
8074
8918
  "cline",
@@ -8191,6 +9035,9 @@ const registerCallbacks = (hook) => {
8191
9035
  hook.command("gemini").description("Internal: Gemini CLI AfterTool callback (reads stdin)").action(async () => {
8192
9036
  await hookRun("gemini");
8193
9037
  });
9038
+ hook.command("pi").description("Internal: pi extension tool_result callback (reads stdin)").action(async () => {
9039
+ await hookRun("pi");
9040
+ });
8194
9041
  };
8195
9042
  const registerHookCommand = (program) => {
8196
9043
  const hook = program.command("hook").description("Install or invoke AI-agent integration hooks");
@@ -8523,6 +9370,30 @@ const printEngineStatus = (result) => {
8523
9370
  }
8524
9371
  };
8525
9372
 
9373
+ //#endregion
9374
+ //#region src/scoring/rule-severity.ts
9375
+ /**
9376
+ * Apply per-rule severity overrides from config: "off" drops the diagnostic,
9377
+ * "error"/"warning" rewrite its severity before scoring and rendering.
9378
+ */
9379
+ const applyRuleSeverities = (diagnostics, overrides) => {
9380
+ if (Object.keys(overrides).length === 0) return diagnostics;
9381
+ const result = [];
9382
+ for (const diagnostic of diagnostics) {
9383
+ const override = overrides[diagnostic.rule];
9384
+ if (!override) {
9385
+ result.push(diagnostic);
9386
+ continue;
9387
+ }
9388
+ if (override === "off") continue;
9389
+ result.push(override === diagnostic.severity ? diagnostic : {
9390
+ ...diagnostic,
9391
+ severity: override
9392
+ });
9393
+ }
9394
+ return result;
9395
+ };
9396
+
8526
9397
  //#endregion
8527
9398
  //#region src/ui/header.ts
8528
9399
  const TAGLINE = "the quality gate for agentic coding";
@@ -8700,6 +9571,11 @@ const RULE_LABELS = {
8700
9571
  "knip/types": "Unused type",
8701
9572
  "ai-slop/trivial-comment": "Trivial restating comment",
8702
9573
  "ai-slop/swallowed-exception": "Empty catch (swallowed error)",
9574
+ "ai-slop/silent-recovery": "Catch logs then continues",
9575
+ "ai-slop/meta-comment": "Meta/plan comment",
9576
+ "ai-slop/redundant-try-catch": "Redundant try/catch",
9577
+ "ai-slop/redundant-type-coercion": "Redundant type coercion",
9578
+ "ai-slop/duplicate-type-declaration": "Duplicate exported type",
8703
9579
  "ai-slop/thin-wrapper": "Thin function wrapper",
8704
9580
  "ai-slop/generic-naming": "Generic/vague identifier name",
8705
9581
  "ai-slop/unused-import": "Unused import",
@@ -8713,10 +9589,16 @@ const RULE_LABELS = {
8713
9589
  "ai-slop/ts-directive": "@ts-ignore / @ts-expect-error",
8714
9590
  "ai-slop/narrative-comment": "Narrative comment block",
8715
9591
  "ai-slop/duplicate-import": "Duplicate import statement",
9592
+ "ai-slop/hardcoded-url": "Hardcoded URL",
9593
+ "ai-slop/hardcoded-id": "Hardcoded provider ID",
8716
9594
  "ai-slop/python-bare-except": "Bare except",
8717
9595
  "ai-slop/python-broad-except": "Broad except",
8718
9596
  "ai-slop/python-mutable-default": "Mutable default argument",
8719
9597
  "ai-slop/python-print-debug": "print() left in code",
9598
+ "ai-slop/python-range-len-loop": "range(len(...)) loop",
9599
+ "ai-slop/python-chained-dict-get": "Chained dict get",
9600
+ "ai-slop/python-repetitive-dispatch": "Repetitive dispatch ladder",
9601
+ "ai-slop/python-isinstance-ladder": "isinstance ladder",
8720
9602
  "ai-slop/go-library-panic": "panic() in Go library code",
8721
9603
  "ai-slop/rust-non-test-unwrap": "Rust .unwrap() in production code",
8722
9604
  "ai-slop/rust-todo-stub": "Rust todo!() stub",
@@ -8820,6 +9702,9 @@ const renderSummary = (input, deps = {}) => {
8820
9702
  }
8821
9703
  return lines.join("\n");
8822
9704
  };
9705
+ const renderStarCta = (deps = {}) => {
9706
+ return `\n ${style(deps.theme ?? theme, "muted", "★ Found this useful? Star us at github.com/scanaislop/aislop")}\n`;
9707
+ };
8823
9708
  const renderCleanRun = (input, deps = {}) => {
8824
9709
  const t = deps.theme ?? theme;
8825
9710
  const s = deps.symbols ?? symbols;
@@ -8832,8 +9717,54 @@ const renderCleanRun = (input, deps = {}) => {
8832
9717
  return `\n ${parts.join(` ${sep} `)}\n`;
8833
9718
  };
8834
9719
 
9720
+ //#endregion
9721
+ //#region src/utils/history.ts
9722
+ const HISTORY_FILE = "history.jsonl";
9723
+ const isHistoryDisabled = (env = process.env) => env.AISLOP_NO_HISTORY === "1";
9724
+ const historyPath = (directory) => path.join(path.resolve(directory), CONFIG_DIR, HISTORY_FILE);
9725
+ /**
9726
+ * Append a compact scan record to .aislop/history.jsonl. Best-effort: never
9727
+ * throws, so a read-only checkout or missing config dir can't break a scan.
9728
+ */
9729
+ const appendHistory = (input) => {
9730
+ if (isHistoryDisabled()) return;
9731
+ const configDir = path.join(path.resolve(input.directory), CONFIG_DIR);
9732
+ if (!fs.existsSync(configDir)) return;
9733
+ const record = {
9734
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
9735
+ score: input.score,
9736
+ errors: input.errors,
9737
+ warnings: input.warnings,
9738
+ files: input.files,
9739
+ cliVersion: APP_VERSION
9740
+ };
9741
+ try {
9742
+ fs.appendFileSync(historyPath(input.directory), `${JSON.stringify(record)}\n`);
9743
+ } catch {}
9744
+ };
9745
+ const isHistoryRecord = (value) => {
9746
+ if (!value || typeof value !== "object") return false;
9747
+ const record = value;
9748
+ 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";
9749
+ };
9750
+ const readHistory = (directory) => {
9751
+ const file = historyPath(directory);
9752
+ if (!fs.existsSync(file)) return [];
9753
+ const records = [];
9754
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
9755
+ const trimmed = line.trim();
9756
+ if (!trimmed) continue;
9757
+ try {
9758
+ const parsed = JSON.parse(trimmed);
9759
+ if (isHistoryRecord(parsed)) records.push(parsed);
9760
+ } catch {}
9761
+ }
9762
+ return records;
9763
+ };
9764
+
8835
9765
  //#endregion
8836
9766
  //#region src/commands/scan.ts
9767
+ const isMachineOutput = (options) => Boolean(options.json) || Boolean(options.sarif);
8837
9768
  const shouldUseSpinner = () => Boolean(process.stderr.isTTY) && process.env.CI !== "true" && process.env.CI !== "1";
8838
9769
  const ALL_ENGINE_NAMES = Object.keys(ENGINE_INFO);
8839
9770
  const BREAKDOWN_TOP_N = 10;
@@ -8889,11 +9820,12 @@ const buildScanRender = (input) => {
8889
9820
  const warnings = input.diagnostics.filter((d) => d.severity === "warning").length;
8890
9821
  const fixable = input.diagnostics.filter((d) => d.fixable).length;
8891
9822
  const hasVulnerableDeps = input.diagnostics.some((d) => d.rule === "security/vulnerable-dependency");
9823
+ const starCta = input.printBrand !== false ? renderStarCta(deps) : "";
8892
9824
  if (input.diagnostics.length === 0 && input.score.score === 100) return `${header}${renderCleanRun({
8893
9825
  score: input.score.score,
8894
9826
  label: input.score.label,
8895
9827
  elapsedMs: input.elapsedMs
8896
- }, deps)}`;
9828
+ }, deps)}${starCta}`;
8897
9829
  const diagBlock = input.diagnostics.length === 0 ? "" : renderDiagnostics(input.diagnostics, input.verbose);
8898
9830
  const nextSteps = [];
8899
9831
  if (fixable > 0) nextSteps.push({
@@ -8920,7 +9852,7 @@ const buildScanRender = (input) => {
8920
9852
  nextSteps,
8921
9853
  breakdown: computeBreakdown(input.diagnostics),
8922
9854
  thresholds: input.thresholds
8923
- }, deps)}`;
9855
+ }, deps)}${starCta}`;
8924
9856
  };
8925
9857
  const scanCommand = async (directory, config, options) => {
8926
9858
  const resolvedDir = path.resolve(directory);
@@ -8947,17 +9879,18 @@ const scanCommand = async (directory, config, options) => {
8947
9879
  const runScanBody = async (resolvedDir, config, options, projectInfo) => {
8948
9880
  const startTime = performance.now();
8949
9881
  const showHeader = options.showHeader !== false;
8950
- const useLiveProgress = !options.json && shouldUseSpinner();
9882
+ const machineOutput = isMachineOutput(options);
9883
+ const useLiveProgress = !machineOutput && shouldUseSpinner();
8951
9884
  let files;
8952
9885
  if (options.staged) {
8953
9886
  files = filterProjectFiles(resolvedDir, getStagedFiles(resolvedDir), [], config.exclude);
8954
- if (!options.json) log.muted(`Scope: ${files.length} staged file(s)`);
9887
+ if (!machineOutput) log.muted(`Scope: ${files.length} staged file(s)`);
8955
9888
  } else if (options.changes) {
8956
9889
  files = filterProjectFiles(resolvedDir, getChangedFiles(resolvedDir), [], config.exclude);
8957
- if (!options.json) log.muted(`Scope: ${files.length} changed file(s)`);
9890
+ if (!machineOutput) log.muted(`Scope: ${files.length} changed file(s)`);
8958
9891
  } else {
8959
9892
  files = filterProjectFiles(resolvedDir, listProjectFiles(resolvedDir), [], config.exclude);
8960
- if (!options.json) log.muted(`Scope: ${files.length} file(s) after exclusions`);
9893
+ if (!machineOutput) log.muted(`Scope: ${files.length} file(s) after exclusions`);
8961
9894
  }
8962
9895
  const configDir = findConfigDir(resolvedDir);
8963
9896
  const rulesPath = configDir ? path.join(configDir, RULES_FILE) : void 0;
@@ -8974,7 +9907,7 @@ const runScanBody = async (resolvedDir, config, options, projectInfo) => {
8974
9907
  }));
8975
9908
  const progressRenderer = useLiveProgress ? new LiveGrid(gridRows) : null;
8976
9909
  progressRenderer?.start();
8977
- const results = await runEngines({
9910
+ const rawResults = await runEngines({
8978
9911
  rootDirectory: resolvedDir,
8979
9912
  languages: projectInfo.languages,
8980
9913
  frameworks: projectInfo.frameworks,
@@ -9007,9 +9940,13 @@ const runScanBody = async (resolvedDir, config, options, projectInfo) => {
9007
9940
  elapsedMs: result.elapsed
9008
9941
  });
9009
9942
  }
9010
- if (!options.json && !progressRenderer) printEngineStatus(result);
9943
+ if (!machineOutput && !progressRenderer) printEngineStatus(result);
9011
9944
  });
9012
9945
  progressRenderer?.stop();
9946
+ const results = rawResults.map((result) => ({
9947
+ ...result,
9948
+ diagnostics: applyRuleSeverities(result.diagnostics, config.rules)
9949
+ }));
9013
9950
  const allDiagnostics = results.flatMap((r) => r.diagnostics);
9014
9951
  const elapsedMs = performance.now() - startTime;
9015
9952
  const scoreResult = calculateScore(allDiagnostics, config.scoring.weights, config.scoring.thresholds, projectInfo.sourceFileCount, config.scoring.smoothing);
@@ -9030,12 +9967,24 @@ const runScanBody = async (resolvedDir, config, options, projectInfo) => {
9030
9967
  engineIssues,
9031
9968
  engineTimings
9032
9969
  };
9970
+ if (options.sarif) {
9971
+ const { buildSarifLog } = await import("./sarif-CZVuavf_.js");
9972
+ console.log(JSON.stringify(buildSarifLog(results), null, 2));
9973
+ return completion;
9974
+ }
9033
9975
  if (options.json) {
9034
9976
  const { buildJsonOutput } = await import("./json-OIzja7OM.js");
9035
9977
  const jsonOut = buildJsonOutput(results, scoreResult, projectInfo.sourceFileCount, elapsedMs);
9036
9978
  console.log(JSON.stringify(jsonOut, null, 2));
9037
9979
  return completion;
9038
9980
  }
9981
+ if (!options.staged && !options.changes && options.command !== "ci" && !isCiEnv()) appendHistory({
9982
+ directory: resolvedDir,
9983
+ score: scoreResult.score,
9984
+ errors: completion.errorCount,
9985
+ warnings: completion.warningCount,
9986
+ files: projectInfo.sourceFileCount
9987
+ });
9039
9988
  const projectName = projectInfo.projectName ?? "project";
9040
9989
  const language = projectInfo.languages[0] ?? "unknown";
9041
9990
  process.stdout.write(buildScanRender({
@@ -9062,7 +10011,8 @@ const ciCommand = async (directory, config, options = {}) => {
9062
10011
  changes: false,
9063
10012
  staged: false,
9064
10013
  verbose: false,
9065
- json: !options.human,
10014
+ json: !options.human && !options.sarif,
10015
+ sarif: options.sarif,
9066
10016
  command: "ci"
9067
10017
  });
9068
10018
  } catch (error) {
@@ -9588,6 +10538,16 @@ const AGENT_CONFIGS = {
9588
10538
  bin: "goose",
9589
10539
  args: (p) => ["run", p]
9590
10540
  },
10541
+ pi: {
10542
+ type: "cli",
10543
+ bin: "pi",
10544
+ args: (p) => ["-p", p]
10545
+ },
10546
+ crush: {
10547
+ type: "cli",
10548
+ bin: "crush",
10549
+ args: (p) => ["run", p]
10550
+ },
9591
10551
  cursor: {
9592
10552
  type: "editor",
9593
10553
  bin: "cursor"
@@ -11442,6 +12402,11 @@ const BUILTIN_RULES = [
11442
12402
  rules: [
11443
12403
  "ai-slop/trivial-comment",
11444
12404
  "ai-slop/swallowed-exception",
12405
+ "ai-slop/silent-recovery",
12406
+ "ai-slop/meta-comment",
12407
+ "ai-slop/redundant-try-catch",
12408
+ "ai-slop/redundant-type-coercion",
12409
+ "ai-slop/duplicate-type-declaration",
11445
12410
  "ai-slop/thin-wrapper",
11446
12411
  "ai-slop/generic-naming",
11447
12412
  "ai-slop/unused-import",
@@ -11455,10 +12420,16 @@ const BUILTIN_RULES = [
11455
12420
  "ai-slop/ts-directive",
11456
12421
  "ai-slop/narrative-comment",
11457
12422
  "ai-slop/duplicate-import",
12423
+ "ai-slop/hardcoded-url",
12424
+ "ai-slop/hardcoded-id",
11458
12425
  "ai-slop/python-bare-except",
11459
12426
  "ai-slop/python-broad-except",
11460
12427
  "ai-slop/python-mutable-default",
11461
12428
  "ai-slop/python-print-debug",
12429
+ "ai-slop/python-range-len-loop",
12430
+ "ai-slop/python-chained-dict-get",
12431
+ "ai-slop/python-repetitive-dispatch",
12432
+ "ai-slop/python-isinstance-ladder",
11462
12433
  "ai-slop/go-library-panic",
11463
12434
  "ai-slop/rust-non-test-unwrap",
11464
12435
  "ai-slop/rust-todo-stub",
@@ -11617,6 +12588,73 @@ const interactiveCommand = async (directory, config) => {
11617
12588
  }
11618
12589
  };
11619
12590
 
12591
+ //#endregion
12592
+ //#region src/commands/trend.ts
12593
+ const SPARK_TICKS = [
12594
+ "▁",
12595
+ "▂",
12596
+ "▃",
12597
+ "▄",
12598
+ "▅",
12599
+ "▆",
12600
+ "▇",
12601
+ "█"
12602
+ ];
12603
+ const DEFAULT_LIMIT = 20;
12604
+ const renderSparkline = (scores) => {
12605
+ if (scores.length === 0) return "";
12606
+ const min = Math.min(...scores);
12607
+ const span = Math.max(...scores) - min;
12608
+ return scores.map((score) => {
12609
+ if (span === 0) return SPARK_TICKS[SPARK_TICKS.length - 1];
12610
+ const ratio = (score - min) / span;
12611
+ return SPARK_TICKS[Math.round(ratio * (SPARK_TICKS.length - 1))];
12612
+ }).join("");
12613
+ };
12614
+ const formatDate = (timestamp) => {
12615
+ const date = new Date(timestamp);
12616
+ if (Number.isNaN(date.getTime())) return timestamp;
12617
+ return date.toISOString().slice(0, 16).replace("T", " ");
12618
+ };
12619
+ const delta = (current, previous) => {
12620
+ if (previous === void 0) return "";
12621
+ const diff = current - previous;
12622
+ if (diff > 0) return style(theme, "success", `+${diff}`);
12623
+ if (diff < 0) return style(theme, "danger", `${diff}`);
12624
+ return style(theme, "muted", "0");
12625
+ };
12626
+ const buildTrendRender = (input) => {
12627
+ const header = renderHeader({
12628
+ version: APP_VERSION,
12629
+ command: "trend",
12630
+ context: [],
12631
+ brand: input.printBrand !== false
12632
+ });
12633
+ if (input.records.length === 0) return `${header}\n ${style(theme, "muted", "No score history yet. Run a scan to start tracking trends.")}\n`;
12634
+ const limit = input.limit ?? DEFAULT_LIMIT;
12635
+ const recent = input.records.slice(-limit);
12636
+ const scores = recent.map((r) => r.score);
12637
+ const lines = [header];
12638
+ 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")}`);
12639
+ recent.forEach((record, index) => {
12640
+ const previous = index > 0 ? recent[index - 1]?.score : void 0;
12641
+ 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}`);
12642
+ });
12643
+ const latest = recent[recent.length - 1];
12644
+ lines.push("");
12645
+ lines.push(` ${style(theme, "accent", renderSparkline(scores))}`);
12646
+ lines.push(` ${style(theme, "muted", `${recent.length} run(s), latest score ${latest?.score}`)}`);
12647
+ lines.push(renderHintLine("Run aislop scan to add a new data point").trimEnd());
12648
+ return `${lines.join("\n")}\n`;
12649
+ };
12650
+ const trendCommand = (directory, limit) => {
12651
+ const records = readHistory(directory);
12652
+ process.stdout.write(buildTrendRender({
12653
+ records,
12654
+ limit
12655
+ }));
12656
+ };
12657
+
11620
12658
  //#endregion
11621
12659
  //#region src/cli.ts
11622
12660
  process.on("SIGINT", () => process.exit(0));
@@ -11632,17 +12670,22 @@ const commaSeparatedParser = (value, previous = []) => {
11632
12670
  const parts = value.split(",").map((v) => v.trim()).filter(Boolean);
11633
12671
  return [...previous, ...parts];
11634
12672
  };
12673
+ const wantsSarif = (flags) => Boolean(flags.sarif) || flags.format === "sarif";
12674
+ const wantsJson = (flags) => Boolean(flags.json) || flags.format === "json";
11635
12675
  const runScan = async (directory, flags) => {
11636
12676
  const config = loadConfig(directory);
11637
- const { exitCode } = await scanCommand(directory, {
12677
+ const finalConfig = {
11638
12678
  ...config,
11639
12679
  exclude: [...config.exclude ?? [], ...flags.exclude ?? []],
11640
12680
  include: [...config.include ?? [], ...flags.include ?? []]
11641
- }, {
12681
+ };
12682
+ const sarif = wantsSarif(flags);
12683
+ const { exitCode } = await scanCommand(directory, finalConfig, {
11642
12684
  changes: Boolean(flags.changes),
11643
12685
  staged: Boolean(flags.staged),
11644
12686
  verbose: Boolean(flags.verbose),
11645
- json: Boolean(flags.json),
12687
+ json: !sarif && wantsJson(flags),
12688
+ sarif,
11646
12689
  exclude: flags.exclude,
11647
12690
  include: flags.include
11648
12691
  });
@@ -11651,8 +12694,8 @@ const runScan = async (directory, flags) => {
11651
12694
  process.exitCode = exitCode;
11652
12695
  }
11653
12696
  };
11654
- const noFlagsPassed = (flags) => !flags.changes && !flags.staged && !flags.verbose && !flags.json && !(flags.exclude && flags.exclude.length > 0) && !(flags.include && flags.include.length > 0);
11655
- 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) => {
12697
+ 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);
12698
+ 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) => {
11656
12699
  if (noFlagsPassed(flags) && process.stdin.isTTY) try {
11657
12700
  await interactiveCommand(directory, loadConfig(directory));
11658
12701
  return;
@@ -11670,6 +12713,7 @@ ${style(theme, "dim", "Commands:")}
11670
12713
  npx aislop doctor [dir] Check installed tools
11671
12714
  npx aislop ci [dir] CI-friendly JSON output
11672
12715
  npx aislop rules [dir] List all rules
12716
+ npx aislop trend [dir] Show score history trend
11673
12717
 
11674
12718
  ${style(theme, "dim", "Examples:")}
11675
12719
  npx aislop Interactive menu
@@ -11683,12 +12727,14 @@ ${style(theme, "dim", "Examples:")}
11683
12727
  npx aislop fix --cursor Open Cursor + copy prompt to clipboard
11684
12728
  npx aislop fix -p Print a prompt to paste into any coding agent
11685
12729
  npx aislop ci JSON output for CI pipelines
12730
+ npx aislop scan --sarif SARIF 2.1.0 for GitHub code scanning
12731
+ npx aislop trend Show score history over time
11686
12732
  npx aislop scan --exclude node_modules
11687
12733
  npx aislop scan --exclude node_modules,dist,file.txt
11688
12734
  npx aislop scan --exclude node_modules --exclude dist --exclude **/*.ts
11689
12735
  ${renderHintLine("Run npx aislop scan to scan your project").trimEnd()}
11690
12736
  `);
11691
- 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) => {
12737
+ 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) => {
11692
12738
  await runScan(directory, command.optsWithGlobals());
11693
12739
  });
11694
12740
  const FIX_AGENT_FLAGS = [
@@ -11761,6 +12807,16 @@ const FIX_AGENT_FLAGS = [
11761
12807
  flag: "goose",
11762
12808
  name: "goose",
11763
12809
  help: "open Goose to fix remaining issues"
12810
+ },
12811
+ {
12812
+ flag: "pi",
12813
+ name: "pi",
12814
+ help: "open pi to fix remaining issues"
12815
+ },
12816
+ {
12817
+ flag: "crush",
12818
+ name: "crush",
12819
+ help: "open Crush to fix remaining issues"
11764
12820
  }
11765
12821
  ];
11766
12822
  const matchFixAgent = (flags) => {
@@ -11796,9 +12852,12 @@ program.command("doctor [directory]").description("Check installed tools and env
11796
12852
  return { exitCode: 0 };
11797
12853
  });
11798
12854
  });
11799
- 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) => {
12855
+ 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) => {
11800
12856
  const flags = command.optsWithGlobals();
11801
- const { exitCode } = await ciCommand(directory, loadConfig(directory), { human: Boolean(flags.human) });
12857
+ const { exitCode } = await ciCommand(directory, loadConfig(directory), {
12858
+ human: Boolean(flags.human),
12859
+ sarif: Boolean(flags.sarif) || flags.format === "sarif"
12860
+ });
11802
12861
  if (exitCode !== 0) {
11803
12862
  await flushTelemetry();
11804
12863
  process.exitCode = exitCode;
@@ -11834,6 +12893,16 @@ program.command("badge [directory]").description("Print the public score badge U
11834
12893
  process.exit(1);
11835
12894
  }
11836
12895
  });
12896
+ 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) => {
12897
+ const flags = command.optsWithGlobals();
12898
+ await withCommandLifecycle({
12899
+ command: "trend",
12900
+ config: loadConfig(directory).telemetry
12901
+ }, async () => {
12902
+ trendCommand(directory, flags.limit);
12903
+ return { exitCode: 0 };
12904
+ });
12905
+ });
11837
12906
  registerHookCommand(program);
11838
12907
  const main = async () => {
11839
12908
  fireInstalledOnce();