aislop 0.9.4 → 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.4";
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) => {
@@ -3140,6 +3613,143 @@ const detectRustPatterns = async (context) => {
3140
3613
  return diagnostics;
3141
3614
  };
3142
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
+
3143
3753
  //#endregion
3144
3754
  //#region src/engines/ai-slop/unused-imports.ts
3145
3755
  const JS_EXTENSIONS$1 = new Set([
@@ -3329,15 +3939,19 @@ const aiSlopEngine = {
3329
3939
  const results = await Promise.allSettled([
3330
3940
  detectTrivialComments(context),
3331
3941
  detectSwallowedExceptions(context),
3942
+ detectDefensivePatterns(context),
3332
3943
  detectOverAbstraction(context),
3333
3944
  detectDeadPatterns(context),
3334
3945
  detectUnusedImports(context),
3335
3946
  detectNarrativeComments(context),
3336
3947
  detectDuplicateImports(context),
3948
+ detectHardcodedConfigLiterals(context),
3337
3949
  detectPythonPatterns(context),
3338
3950
  detectGoPatterns(context),
3339
3951
  detectRustPatterns(context),
3340
- detectHallucinatedImports(context)
3952
+ detectHallucinatedImports(context),
3953
+ detectSilentRecovery(context),
3954
+ detectMetaComments(context)
3341
3955
  ]);
3342
3956
  for (const result of results) if (result.status === "fulfilled") diagnostics.push(...result.value);
3343
3957
  return {
@@ -6236,7 +6850,7 @@ const RISKY_PATTERNS = [
6236
6850
  help: "Use JSON, MessagePack, or other safe serialization formats for untrusted data"
6237
6851
  },
6238
6852
  {
6239
- pattern: new RegExp(`\\bexec\\s*\\(`, "g"),
6853
+ pattern: new RegExp(`(?<![\\w.>:\\\\])\\bexec\\s*\\(`, "g"),
6240
6854
  extensions: [".py"],
6241
6855
  name: "python-exec",
6242
6856
  message: "Use of exec() can execute arbitrary code",
@@ -6940,7 +7554,7 @@ const parseClaudeStdin = (raw) => {
6940
7554
  return {};
6941
7555
  }
6942
7556
  };
6943
- const readStdin$2 = async () => {
7557
+ const readStdin$3 = async () => {
6944
7558
  if (process.stdin.isTTY) return "";
6945
7559
  const chunks = [];
6946
7560
  for await (const chunk of process.stdin) chunks.push(chunk);
@@ -6958,7 +7572,7 @@ const renderClaudeOutput = (additional, block) => {
6958
7572
  return out;
6959
7573
  };
6960
7574
  const runClaudeHook = async (deps = {}) => {
6961
- const getStdin = deps.stdin ?? readStdin$2;
7575
+ const getStdin = deps.stdin ?? readStdin$3;
6962
7576
  const write = deps.write ?? ((s) => process.stdout.write(s));
6963
7577
  const input = parseClaudeStdin(await getStdin());
6964
7578
  const cwd = input.cwd && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
@@ -7005,7 +7619,7 @@ const parseClaudeFileChangedStdin = (raw) => {
7005
7619
  }
7006
7620
  };
7007
7621
  const runClaudeFileChangedHook = async (deps = {}) => {
7008
- const getStdin = deps.stdin ?? readStdin$2;
7622
+ const getStdin = deps.stdin ?? readStdin$3;
7009
7623
  const write = deps.write ?? ((s) => process.stdout.write(s));
7010
7624
  const input = parseClaudeFileChangedStdin(await getStdin());
7011
7625
  const cwd = input.cwd && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
@@ -7041,7 +7655,7 @@ const parseClaudeStopStdin = (raw) => {
7041
7655
  }
7042
7656
  };
7043
7657
  const runClaudeStopHook = async (deps = {}) => {
7044
- const getStdin = deps.stdin ?? readStdin$2;
7658
+ const getStdin = deps.stdin ?? readStdin$3;
7045
7659
  const write = deps.write ?? ((s) => process.stdout.write(s));
7046
7660
  const input = parseClaudeStopStdin(await getStdin());
7047
7661
  const cwd = input.cwd && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
@@ -7102,14 +7716,14 @@ const renderCursorOutput = (additional, event = "afterFileEdit") => ({ hookSpeci
7102
7716
  hookEventName: event,
7103
7717
  additionalContext: additional
7104
7718
  } });
7105
- const readStdin$1 = async () => {
7719
+ const readStdin$2 = async () => {
7106
7720
  if (process.stdin.isTTY) return "";
7107
7721
  const chunks = [];
7108
7722
  for await (const chunk of process.stdin) chunks.push(chunk);
7109
7723
  return Buffer.concat(chunks).toString("utf-8");
7110
7724
  };
7111
7725
  const runCursorHook = async (deps = {}) => {
7112
- const getStdin = deps.stdin ?? readStdin$1;
7726
+ const getStdin = deps.stdin ?? readStdin$2;
7113
7727
  const write = deps.write ?? ((s) => process.stdout.write(s));
7114
7728
  const writeErr = deps.writeErr ?? ((s) => process.stderr.write(s));
7115
7729
  const input = parseCursorStdin(await getStdin());
@@ -7165,14 +7779,14 @@ const renderGeminiOutput = (additional) => ({ hookSpecificOutput: {
7165
7779
  hookEventName: "AfterTool",
7166
7780
  additionalContext: additional
7167
7781
  } });
7168
- const readStdin = async () => {
7782
+ const readStdin$1 = async () => {
7169
7783
  if (process.stdin.isTTY) return "";
7170
7784
  const chunks = [];
7171
7785
  for await (const chunk of process.stdin) chunks.push(chunk);
7172
7786
  return Buffer.concat(chunks).toString("utf-8");
7173
7787
  };
7174
7788
  const runGeminiHook = async (deps = {}) => {
7175
- const getStdin = deps.stdin ?? readStdin;
7789
+ const getStdin = deps.stdin ?? readStdin$1;
7176
7790
  const write = deps.write ?? ((s) => process.stdout.write(s));
7177
7791
  const input = parseGeminiStdin(await getStdin());
7178
7792
  const cwd = input.cwd && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
@@ -7204,6 +7818,76 @@ const runGeminiHook = async (deps = {}) => {
7204
7818
  }
7205
7819
  };
7206
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
+
7207
7891
  //#endregion
7208
7892
  //#region src/hooks/assets.ts
7209
7893
  const AISLOP_MD_BODY = `# aislop — agent instructions
@@ -7774,6 +8458,67 @@ const uninstallKilocode = (opts) => {
7774
8458
  return uninstallRulesOnly(opts, resolveKilocodePaths(opts));
7775
8459
  };
7776
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
+
7777
8522
  //#endregion
7778
8523
  //#region src/hooks/install/windsurf.ts
7779
8524
  const resolveWindsurfPaths = (opts) => ({ rules: path.join(opts.cwd, ".windsurfrules") });
@@ -7802,6 +8547,7 @@ const ALL_AGENTS = [
7802
8547
  "claude",
7803
8548
  "cursor",
7804
8549
  "gemini",
8550
+ "pi",
7805
8551
  "codex",
7806
8552
  "windsurf",
7807
8553
  "cline",
@@ -7820,6 +8566,7 @@ const AGENTS_SUPPORTING_BOTH_SCOPES = [
7820
8566
  "claude",
7821
8567
  "cursor",
7822
8568
  "gemini",
8569
+ "pi",
7823
8570
  "codex"
7824
8571
  ];
7825
8572
  const paths = {
@@ -7843,6 +8590,7 @@ const paths = {
7843
8590
  p.geminiMd
7844
8591
  ];
7845
8592
  },
8593
+ pi: (opts) => [resolvePiPaths(opts).extension],
7846
8594
  codex: (opts) => [resolveCodexPaths(opts).rules],
7847
8595
  windsurf: (opts) => [resolveWindsurfPaths(opts).rules],
7848
8596
  cline: (opts) => [resolveClinePaths(opts).rules, resolveRooPaths(opts).rules],
@@ -7866,6 +8614,11 @@ const REGISTRY = {
7866
8614
  uninstall: uninstallGemini,
7867
8615
  paths: paths.gemini
7868
8616
  },
8617
+ pi: {
8618
+ install: installPi,
8619
+ uninstall: uninstallPi,
8620
+ paths: paths.pi
8621
+ },
7869
8622
  codex: {
7870
8623
  install: installCodex,
7871
8624
  uninstall: uninstallCodex,
@@ -7986,6 +8739,10 @@ const AGENT_LABELS = {
7986
8739
  label: "Gemini CLI",
7987
8740
  hint: "AfterTool, runtime"
7988
8741
  },
8742
+ pi: {
8743
+ label: "pi",
8744
+ hint: "extension, runtime"
8745
+ },
7989
8746
  codex: {
7990
8747
  label: "Codex CLI",
7991
8748
  hint: "rules-only"
@@ -8092,6 +8849,7 @@ const hookRun = async (agent, flags) => {
8092
8849
  else exitCode = await runClaudeHook();
8093
8850
  else if (agent === "cursor") exitCode = await runCursorHook();
8094
8851
  else if (agent === "gemini") exitCode = await runGeminiHook();
8852
+ else if (agent === "pi") exitCode = await runPiHook();
8095
8853
  else {
8096
8854
  process.stderr.write(`hook: agent "${agent}" has no runtime adapter (rules-file-only)\n`);
8097
8855
  process.exit(0);
@@ -8154,6 +8912,7 @@ const AGENT_NAMES = [
8154
8912
  "claude",
8155
8913
  "cursor",
8156
8914
  "gemini",
8915
+ "pi",
8157
8916
  "codex",
8158
8917
  "windsurf",
8159
8918
  "cline",
@@ -8276,6 +9035,9 @@ const registerCallbacks = (hook) => {
8276
9035
  hook.command("gemini").description("Internal: Gemini CLI AfterTool callback (reads stdin)").action(async () => {
8277
9036
  await hookRun("gemini");
8278
9037
  });
9038
+ hook.command("pi").description("Internal: pi extension tool_result callback (reads stdin)").action(async () => {
9039
+ await hookRun("pi");
9040
+ });
8279
9041
  };
8280
9042
  const registerHookCommand = (program) => {
8281
9043
  const hook = program.command("hook").description("Install or invoke AI-agent integration hooks");
@@ -8608,6 +9370,30 @@ const printEngineStatus = (result) => {
8608
9370
  }
8609
9371
  };
8610
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
+
8611
9397
  //#endregion
8612
9398
  //#region src/ui/header.ts
8613
9399
  const TAGLINE = "the quality gate for agentic coding";
@@ -8785,6 +9571,11 @@ const RULE_LABELS = {
8785
9571
  "knip/types": "Unused type",
8786
9572
  "ai-slop/trivial-comment": "Trivial restating comment",
8787
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",
8788
9579
  "ai-slop/thin-wrapper": "Thin function wrapper",
8789
9580
  "ai-slop/generic-naming": "Generic/vague identifier name",
8790
9581
  "ai-slop/unused-import": "Unused import",
@@ -8798,6 +9589,8 @@ const RULE_LABELS = {
8798
9589
  "ai-slop/ts-directive": "@ts-ignore / @ts-expect-error",
8799
9590
  "ai-slop/narrative-comment": "Narrative comment block",
8800
9591
  "ai-slop/duplicate-import": "Duplicate import statement",
9592
+ "ai-slop/hardcoded-url": "Hardcoded URL",
9593
+ "ai-slop/hardcoded-id": "Hardcoded provider ID",
8801
9594
  "ai-slop/python-bare-except": "Bare except",
8802
9595
  "ai-slop/python-broad-except": "Broad except",
8803
9596
  "ai-slop/python-mutable-default": "Mutable default argument",
@@ -8924,8 +9717,54 @@ const renderCleanRun = (input, deps = {}) => {
8924
9717
  return `\n ${parts.join(` ${sep} `)}\n`;
8925
9718
  };
8926
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
+
8927
9765
  //#endregion
8928
9766
  //#region src/commands/scan.ts
9767
+ const isMachineOutput = (options) => Boolean(options.json) || Boolean(options.sarif);
8929
9768
  const shouldUseSpinner = () => Boolean(process.stderr.isTTY) && process.env.CI !== "true" && process.env.CI !== "1";
8930
9769
  const ALL_ENGINE_NAMES = Object.keys(ENGINE_INFO);
8931
9770
  const BREAKDOWN_TOP_N = 10;
@@ -9040,17 +9879,18 @@ const scanCommand = async (directory, config, options) => {
9040
9879
  const runScanBody = async (resolvedDir, config, options, projectInfo) => {
9041
9880
  const startTime = performance.now();
9042
9881
  const showHeader = options.showHeader !== false;
9043
- const useLiveProgress = !options.json && shouldUseSpinner();
9882
+ const machineOutput = isMachineOutput(options);
9883
+ const useLiveProgress = !machineOutput && shouldUseSpinner();
9044
9884
  let files;
9045
9885
  if (options.staged) {
9046
9886
  files = filterProjectFiles(resolvedDir, getStagedFiles(resolvedDir), [], config.exclude);
9047
- if (!options.json) log.muted(`Scope: ${files.length} staged file(s)`);
9887
+ if (!machineOutput) log.muted(`Scope: ${files.length} staged file(s)`);
9048
9888
  } else if (options.changes) {
9049
9889
  files = filterProjectFiles(resolvedDir, getChangedFiles(resolvedDir), [], config.exclude);
9050
- if (!options.json) log.muted(`Scope: ${files.length} changed file(s)`);
9890
+ if (!machineOutput) log.muted(`Scope: ${files.length} changed file(s)`);
9051
9891
  } else {
9052
9892
  files = filterProjectFiles(resolvedDir, listProjectFiles(resolvedDir), [], config.exclude);
9053
- if (!options.json) log.muted(`Scope: ${files.length} file(s) after exclusions`);
9893
+ if (!machineOutput) log.muted(`Scope: ${files.length} file(s) after exclusions`);
9054
9894
  }
9055
9895
  const configDir = findConfigDir(resolvedDir);
9056
9896
  const rulesPath = configDir ? path.join(configDir, RULES_FILE) : void 0;
@@ -9067,7 +9907,7 @@ const runScanBody = async (resolvedDir, config, options, projectInfo) => {
9067
9907
  }));
9068
9908
  const progressRenderer = useLiveProgress ? new LiveGrid(gridRows) : null;
9069
9909
  progressRenderer?.start();
9070
- const results = await runEngines({
9910
+ const rawResults = await runEngines({
9071
9911
  rootDirectory: resolvedDir,
9072
9912
  languages: projectInfo.languages,
9073
9913
  frameworks: projectInfo.frameworks,
@@ -9100,9 +9940,13 @@ const runScanBody = async (resolvedDir, config, options, projectInfo) => {
9100
9940
  elapsedMs: result.elapsed
9101
9941
  });
9102
9942
  }
9103
- if (!options.json && !progressRenderer) printEngineStatus(result);
9943
+ if (!machineOutput && !progressRenderer) printEngineStatus(result);
9104
9944
  });
9105
9945
  progressRenderer?.stop();
9946
+ const results = rawResults.map((result) => ({
9947
+ ...result,
9948
+ diagnostics: applyRuleSeverities(result.diagnostics, config.rules)
9949
+ }));
9106
9950
  const allDiagnostics = results.flatMap((r) => r.diagnostics);
9107
9951
  const elapsedMs = performance.now() - startTime;
9108
9952
  const scoreResult = calculateScore(allDiagnostics, config.scoring.weights, config.scoring.thresholds, projectInfo.sourceFileCount, config.scoring.smoothing);
@@ -9123,12 +9967,24 @@ const runScanBody = async (resolvedDir, config, options, projectInfo) => {
9123
9967
  engineIssues,
9124
9968
  engineTimings
9125
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
+ }
9126
9975
  if (options.json) {
9127
9976
  const { buildJsonOutput } = await import("./json-OIzja7OM.js");
9128
9977
  const jsonOut = buildJsonOutput(results, scoreResult, projectInfo.sourceFileCount, elapsedMs);
9129
9978
  console.log(JSON.stringify(jsonOut, null, 2));
9130
9979
  return completion;
9131
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
+ });
9132
9988
  const projectName = projectInfo.projectName ?? "project";
9133
9989
  const language = projectInfo.languages[0] ?? "unknown";
9134
9990
  process.stdout.write(buildScanRender({
@@ -9155,7 +10011,8 @@ const ciCommand = async (directory, config, options = {}) => {
9155
10011
  changes: false,
9156
10012
  staged: false,
9157
10013
  verbose: false,
9158
- json: !options.human,
10014
+ json: !options.human && !options.sarif,
10015
+ sarif: options.sarif,
9159
10016
  command: "ci"
9160
10017
  });
9161
10018
  } catch (error) {
@@ -9681,6 +10538,16 @@ const AGENT_CONFIGS = {
9681
10538
  bin: "goose",
9682
10539
  args: (p) => ["run", p]
9683
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
+ },
9684
10551
  cursor: {
9685
10552
  type: "editor",
9686
10553
  bin: "cursor"
@@ -11535,6 +12402,11 @@ const BUILTIN_RULES = [
11535
12402
  rules: [
11536
12403
  "ai-slop/trivial-comment",
11537
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",
11538
12410
  "ai-slop/thin-wrapper",
11539
12411
  "ai-slop/generic-naming",
11540
12412
  "ai-slop/unused-import",
@@ -11548,6 +12420,8 @@ const BUILTIN_RULES = [
11548
12420
  "ai-slop/ts-directive",
11549
12421
  "ai-slop/narrative-comment",
11550
12422
  "ai-slop/duplicate-import",
12423
+ "ai-slop/hardcoded-url",
12424
+ "ai-slop/hardcoded-id",
11551
12425
  "ai-slop/python-bare-except",
11552
12426
  "ai-slop/python-broad-except",
11553
12427
  "ai-slop/python-mutable-default",
@@ -11714,6 +12588,73 @@ const interactiveCommand = async (directory, config) => {
11714
12588
  }
11715
12589
  };
11716
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
+
11717
12658
  //#endregion
11718
12659
  //#region src/cli.ts
11719
12660
  process.on("SIGINT", () => process.exit(0));
@@ -11729,17 +12670,22 @@ const commaSeparatedParser = (value, previous = []) => {
11729
12670
  const parts = value.split(",").map((v) => v.trim()).filter(Boolean);
11730
12671
  return [...previous, ...parts];
11731
12672
  };
12673
+ const wantsSarif = (flags) => Boolean(flags.sarif) || flags.format === "sarif";
12674
+ const wantsJson = (flags) => Boolean(flags.json) || flags.format === "json";
11732
12675
  const runScan = async (directory, flags) => {
11733
12676
  const config = loadConfig(directory);
11734
- const { exitCode } = await scanCommand(directory, {
12677
+ const finalConfig = {
11735
12678
  ...config,
11736
12679
  exclude: [...config.exclude ?? [], ...flags.exclude ?? []],
11737
12680
  include: [...config.include ?? [], ...flags.include ?? []]
11738
- }, {
12681
+ };
12682
+ const sarif = wantsSarif(flags);
12683
+ const { exitCode } = await scanCommand(directory, finalConfig, {
11739
12684
  changes: Boolean(flags.changes),
11740
12685
  staged: Boolean(flags.staged),
11741
12686
  verbose: Boolean(flags.verbose),
11742
- json: Boolean(flags.json),
12687
+ json: !sarif && wantsJson(flags),
12688
+ sarif,
11743
12689
  exclude: flags.exclude,
11744
12690
  include: flags.include
11745
12691
  });
@@ -11748,8 +12694,8 @@ const runScan = async (directory, flags) => {
11748
12694
  process.exitCode = exitCode;
11749
12695
  }
11750
12696
  };
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) => {
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) => {
11753
12699
  if (noFlagsPassed(flags) && process.stdin.isTTY) try {
11754
12700
  await interactiveCommand(directory, loadConfig(directory));
11755
12701
  return;
@@ -11767,6 +12713,7 @@ ${style(theme, "dim", "Commands:")}
11767
12713
  npx aislop doctor [dir] Check installed tools
11768
12714
  npx aislop ci [dir] CI-friendly JSON output
11769
12715
  npx aislop rules [dir] List all rules
12716
+ npx aislop trend [dir] Show score history trend
11770
12717
 
11771
12718
  ${style(theme, "dim", "Examples:")}
11772
12719
  npx aislop Interactive menu
@@ -11780,12 +12727,14 @@ ${style(theme, "dim", "Examples:")}
11780
12727
  npx aislop fix --cursor Open Cursor + copy prompt to clipboard
11781
12728
  npx aislop fix -p Print a prompt to paste into any coding agent
11782
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
11783
12732
  npx aislop scan --exclude node_modules
11784
12733
  npx aislop scan --exclude node_modules,dist,file.txt
11785
12734
  npx aislop scan --exclude node_modules --exclude dist --exclude **/*.ts
11786
12735
  ${renderHintLine("Run npx aislop scan to scan your project").trimEnd()}
11787
12736
  `);
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) => {
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) => {
11789
12738
  await runScan(directory, command.optsWithGlobals());
11790
12739
  });
11791
12740
  const FIX_AGENT_FLAGS = [
@@ -11858,6 +12807,16 @@ const FIX_AGENT_FLAGS = [
11858
12807
  flag: "goose",
11859
12808
  name: "goose",
11860
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"
11861
12820
  }
11862
12821
  ];
11863
12822
  const matchFixAgent = (flags) => {
@@ -11893,9 +12852,12 @@ program.command("doctor [directory]").description("Check installed tools and env
11893
12852
  return { exitCode: 0 };
11894
12853
  });
11895
12854
  });
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) => {
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) => {
11897
12856
  const flags = command.optsWithGlobals();
11898
- 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
+ });
11899
12861
  if (exitCode !== 0) {
11900
12862
  await flushTelemetry();
11901
12863
  process.exitCode = exitCode;
@@ -11931,6 +12893,16 @@ program.command("badge [directory]").description("Print the public score badge U
11931
12893
  process.exit(1);
11932
12894
  }
11933
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
+ });
11934
12906
  registerHookCommand(program);
11935
12907
  const main = async () => {
11936
12908
  fireInstalledOnce();