aislop 0.9.4 → 0.9.6

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