aislop 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@ import { Command } from "commander";
4
4
  import os from "node:os";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
7
8
  import crypto, { randomUUID } from "node:crypto";
8
9
  import { performance } from "node:perf_hooks";
9
10
  import YAML from "yaml";
@@ -11,10 +12,11 @@ import { z } from "zod/v4";
11
12
  import { execSync, spawn, spawnSync } from "node:child_process";
12
13
  import micromatch from "micromatch";
13
14
  import ts from "typescript";
14
- import { fileURLToPath } from "node:url";
15
- import { isCancel, multiselect, select, text } from "@clack/prompts";
15
+ import * as readline from "node:readline";
16
+ import { Writable } from "node:stream";
16
17
  import pc from "picocolors";
17
18
  import wcwidth from "wcwidth";
19
+ import { isCancel, multiselect, select, text } from "@clack/prompts";
18
20
 
19
21
  //#region \0rolldown/runtime.js
20
22
  var __defProp = Object.defineProperty;
@@ -34,7 +36,7 @@ var __exportAll = (all, no_symbols) => {
34
36
 
35
37
  //#endregion
36
38
  //#region src/version.ts
37
- const APP_VERSION = "0.10.2";
39
+ const APP_VERSION = "0.11.0";
38
40
 
39
41
  //#endregion
40
42
  //#region src/telemetry/env.ts
@@ -441,7 +443,7 @@ const buildSuggestedActions = (diagnostics, findings, regressed, delta) => {
441
443
  actions.push({
442
444
  id: "run_aislop_fix",
443
445
  label: `Run aislop fix to clear ${fixableDiags.length} mechanical finding${fixableDiags.length === 1 ? "" : "s"}.`,
444
- command: "npx aislop fix",
446
+ command: "aislop fix",
445
447
  rationale: "These findings have deterministic fixes (formatting, unused imports, trivial comments). Running this before any manual work avoids burning agent tokens on what the CLI handles for free.",
446
448
  ruleIds
447
449
  });
@@ -601,7 +603,8 @@ const DEFAULT_CONFIG = {
601
603
  good: 75,
602
604
  ok: 50
603
605
  },
604
- smoothing: 20
606
+ smoothing: 20,
607
+ maxPerRule: 40
605
608
  },
606
609
  ci: {
607
610
  failBelow: 70,
@@ -624,9 +627,9 @@ jobs:
624
627
  runs-on: ubuntu-latest
625
628
  steps:
626
629
  - uses: actions/checkout@v4
627
- - uses: scanaislop/aislop@v${APP_VERSION}
630
+ - uses: scanaislop/aislop@v1
628
631
  with:
629
- version: ${APP_VERSION}
632
+ version: latest
630
633
  `;
631
634
  const DEFAULT_RULES_YAML = `# Architecture rules (BYO)
632
635
  # Uncomment and customize to enforce your project's conventions.
@@ -725,7 +728,8 @@ const ScoringSchema = z.object({
725
728
  good: 75,
726
729
  ok: 50
727
730
  })),
728
- smoothing: z.number().nonnegative().default(20)
731
+ smoothing: z.number().nonnegative().default(20),
732
+ maxPerRule: z.number().positive().default(40)
729
733
  });
730
734
  const CiSchema = z.object({
731
735
  failBelow: z.number().default(70),
@@ -765,7 +769,8 @@ const AislopConfigSchema = z.object({
765
769
  good: 75,
766
770
  ok: 50
767
771
  },
768
- smoothing: 20
772
+ smoothing: 20,
773
+ maxPerRule: 40
769
774
  })),
770
775
  ci: CiSchema.default(() => ({
771
776
  failBelow: 70,
@@ -1543,8 +1548,8 @@ const PHP_DECL_START = /^\s*(?:(?:public|private|protected|static|final|abstract
1543
1548
 
1544
1549
  //#endregion
1545
1550
  //#region src/engines/ai-slop/non-production-paths.ts
1546
- const DIR_PATTERN = /(?:^|\/)(?:scripts|bin|examples?|demos?|docs?|bench|benches|benchmarks?|fixtures?|__fixtures__|__mocks__|__tests__|vendor|_vendor|vendored|third_party|blib2to3|lib2to3|cli|cli-[\w-]+|[\w-]+-cli)\//i;
1547
- const BASENAME_PATTERN = /(?:^|\/)(?:benchmark|bench|demo|example|script|seed|migrate|profile|smoke|stress|load|debug|repro)[-_.][^/]*\.[mc]?[jt]sx?$|(?:^|\/)[^/]+[-_](?:benchmark|bench|demo|example)\.[mc]?[jt]sx?$/i;
1551
+ const DIR_PATTERN = /(?:^|\/)(?:scripts|bin|examples?|demos?|docs?|bench|benches|benchmarks?|fixtures?|__fixtures__|__mocks__|__tests__|prototypes?|experiments?|vendor|_vendor|vendored|third_party|blib2to3|lib2to3|cli|cli-[\w-]+|[\w-]+-cli)\//i;
1552
+ const BASENAME_PATTERN = /(?:^|\/)(?:(?:prototype|experiment)(?:[-_.][^/]*)?|(?:benchmark|bench|demo|example|script|seed|migrate|profile|smoke|stress|load|debug|repro)[-_.][^/]*)\.[mc]?[jt]sx?$|(?:^|\/)[^/]+[-_](?:benchmark|bench|demo|example|prototype|experiment)\.[mc]?[jt]sx?$/i;
1548
1553
  const isNonProductionPath = (relativePath) => DIR_PATTERN.test(relativePath) || BASENAME_PATTERN.test(relativePath);
1549
1554
 
1550
1555
  //#endregion
@@ -1660,7 +1665,7 @@ const JS_EXTENSIONS$4 = new Set([
1660
1665
  ".mjs",
1661
1666
  ".cjs"
1662
1667
  ]);
1663
- const CONSOLE_LOG_PATTERN = /\bconsole\.(?:log|debug|info|trace|dir|table)\s*\(/;
1668
+ const CONSOLE_CALL_PATTERN = /\bconsole\.(log|debug|info|trace|dir|table)\s*\(/;
1664
1669
  const slop = (filePath, line, rule, severity, message, help, fixable) => ({
1665
1670
  filePath,
1666
1671
  engine: "ai-slop",
@@ -1674,20 +1679,35 @@ const slop = (filePath, line, rule, severity, message, help, fixable) => ({
1674
1679
  fixable
1675
1680
  });
1676
1681
  const LOGGER_FILE_PATTERN = /(?:^|\/)(?:logger|logging|log)\.[^/]+$/i;
1682
+ const CLI_ENTRYPOINT_PATTERN = /(?:^|\/)(?:cli|cli[-_.][^/]*|[^/]+[-_]cli)\.[mc]?[jt]sx?$/i;
1683
+ const ENTRYPOINT_GUARD_PATTERN = /\b(?:import\.meta\.main|require\.main\s*===\s*module)\b/;
1684
+ const OPERATIONAL_LOG_PATTERN = /\bconsole\.(?:log|info)\s*\(\s*(?:`|["'])\s*\[[^\]\n]{1,48}\]/;
1685
+ const DEBUG_SIGNAL_PATTERN = /\b(?:debug|dbg|trace|dump|inspect|todo|tmp|temp|remove\s+me|leftover|here|checkpoint)\b/i;
1686
+ const shouldFlagConsoleCall = (trimmed) => {
1687
+ const match = CONSOLE_CALL_PATTERN.exec(trimmed);
1688
+ if (!match) return false;
1689
+ const method = match[1];
1690
+ if (method === "trace" || method === "dir" || method === "table") return true;
1691
+ if (method === "debug") return DEBUG_SIGNAL_PATTERN.test(trimmed) || !OPERATIONAL_LOG_PATTERN.test(trimmed);
1692
+ if (method === "info" || method === "log") {
1693
+ if (/console\.log\(\s*JSON\.stringify\b/.test(trimmed)) return false;
1694
+ if (OPERATIONAL_LOG_PATTERN.test(trimmed)) return false;
1695
+ return true;
1696
+ }
1697
+ return false;
1698
+ };
1677
1699
  const detectConsoleLeftovers = (content, relativePath, ext) => {
1678
1700
  if (!JS_EXTENSIONS$4.has(ext)) return [];
1679
1701
  if (LOGGER_FILE_PATTERN.test(relativePath)) return [];
1680
- if (isNonProductionPath(relativePath)) return [];
1702
+ if (isNonProductionPath(relativePath) || CLI_ENTRYPOINT_PATTERN.test(relativePath)) return [];
1703
+ if (content.startsWith("#!")) return [];
1704
+ if (ENTRYPOINT_GUARD_PATTERN.test(content)) return [];
1681
1705
  const diagnostics = [];
1682
1706
  const lines = content.split("\n");
1683
1707
  for (let i = 0; i < lines.length; i++) {
1684
1708
  const trimmed = lines[i].trim();
1685
1709
  if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue;
1686
- if (CONSOLE_LOG_PATTERN.test(trimmed)) {
1687
- if (/console\.(?:error|warn)\s*\(/.test(trimmed)) continue;
1688
- if (/console\.log\(\s*JSON\.stringify\b/.test(trimmed)) continue;
1689
- diagnostics.push(slop(relativePath, i + 1, "ai-slop/console-leftover", "warning", "console.log/debug/info statement left in production code", "Remove debugging console statements or replace with a proper logger", true));
1690
- }
1710
+ if (shouldFlagConsoleCall(trimmed)) diagnostics.push(slop(relativePath, i + 1, "ai-slop/console-leftover", "warning", "console.log/debug/info statement left in production code", "Remove debugging console statements or replace with a proper logger", true));
1691
1711
  }
1692
1712
  return diagnostics;
1693
1713
  };
@@ -1715,6 +1735,7 @@ const isGuardedSingleLineExit = (lines, lineIndex) => {
1715
1735
  const control = contextLines.join(" ");
1716
1736
  return /(?:^|[}\s])(?:if|else\s+if|for|while)\s*\(/.test(control) && !/{\s*$/.test(control);
1717
1737
  };
1738
+ const isPropertyNoopAssignment = (trimmed) => /^(?:[\w$]+\.)+[\w$]+\s*=\s*(?:function\s*)?\([^)]*\)\s*(?:=>)?\s*\{\s*\}\s*;?$/.test(trimmed);
1718
1739
  const detectTodoStubs = (content, relativePath) => {
1719
1740
  const diagnostics = [];
1720
1741
  const lines = content.split("\n");
@@ -1736,7 +1757,7 @@ const detectDeadCodePatterns = (content, relativePath, ext) => {
1736
1757
  const nextLine = i + 1 < lines.length ? lines[i + 1]?.trim() : void 0;
1737
1758
  if (JS_EXTENSIONS$4.has(ext) && /^(?:return|throw)\b/.test(trimmed) && trimmed.endsWith(";") && nextLine && nextLine.length > 0 && !isGuardedSingleLineExit(lines, i) && !isBlockCloserAfterReturn(nextLine) && !nextLine.startsWith("//") && !nextLine.startsWith("/*") && !nextLine.startsWith("case ") && !nextLine.startsWith("default:") && !nextLine.startsWith("if ") && !nextLine.startsWith("if(") && !nextLine.startsWith("else")) diagnostics.push(slop(relativePath, i + 2, "ai-slop/unreachable-code", "warning", "Code after return/throw statement is unreachable", "Remove the unreachable code or restructure the control flow", false));
1738
1759
  if (/\bif\s*\(\s*(?:false|true|0|1)\s*\)/.test(trimmed) && !trimmed.startsWith("//") && !trimmed.startsWith("*") && !/["'`].*\bif\s*\(/.test(trimmed) && !/\/.*\bif\s*\(/.test(trimmed.replace(/\/\/.*$/, ""))) diagnostics.push(slop(relativePath, i + 1, "ai-slop/constant-condition", "warning", "Conditional with a constant value — likely debugging leftover", "Remove the constant condition or replace with proper logic", false));
1739
- if (JS_EXTENSIONS$4.has(ext) && /(?:function\s+\w+\s*\([^)]*\)|=>\s*)\s*\{\s*\}\s*;?\s*$/.test(trimmed) && !trimmed.startsWith("interface") && !trimmed.startsWith("type ")) diagnostics.push(slop(relativePath, i + 1, "ai-slop/empty-function", "info", "Empty function body — possible stub or unfinished implementation", "Implement the function body or add a comment explaining why it's empty", false));
1760
+ if (JS_EXTENSIONS$4.has(ext) && /(?:function\s+\w+\s*\([^)]*\)|=>\s*)\s*\{\s*\}\s*;?\s*$/.test(trimmed) && !trimmed.startsWith("interface") && !trimmed.startsWith("type ") && !isPropertyNoopAssignment(trimmed)) diagnostics.push(slop(relativePath, i + 1, "ai-slop/empty-function", "info", "Empty function body — possible stub or unfinished implementation", "Implement the function body or add a comment explaining why it's empty", false));
1740
1761
  }
1741
1762
  return diagnostics;
1742
1763
  };
@@ -2234,7 +2255,7 @@ const JS_RESOLUTION_EXTENSIONS = [
2234
2255
  "/index.js",
2235
2256
  "/index.jsx"
2236
2257
  ];
2237
- const readJson$2 = (filePath) => {
2258
+ const readJson$3 = (filePath) => {
2238
2259
  try {
2239
2260
  return JSON.parse(fs.readFileSync(filePath, "utf-8"));
2240
2261
  } catch {
@@ -2249,7 +2270,7 @@ const buildAliasMatcher = (key) => {
2249
2270
  return (spec) => spec.length >= before.length + after.length && spec.startsWith(before) && spec.endsWith(after);
2250
2271
  };
2251
2272
  const collectAliasMatchersFromConfig = (configPath, matchers) => {
2252
- const opts = readJson$2(configPath)?.compilerOptions;
2273
+ const opts = readJson$3(configPath)?.compilerOptions;
2253
2274
  if (!opts || typeof opts !== "object") return;
2254
2275
  const configDir = path.dirname(configPath);
2255
2276
  const paths = opts.paths;
@@ -2272,7 +2293,7 @@ const collectTsPathAliases = (rootDir, workspaceDirs) => {
2272
2293
 
2273
2294
  //#endregion
2274
2295
  //#region src/engines/ai-slop/js-workspaces.ts
2275
- const readJson$1 = (filePath) => {
2296
+ const readJson$2 = (filePath) => {
2276
2297
  try {
2277
2298
  return JSON.parse(fs.readFileSync(filePath, "utf-8"));
2278
2299
  } catch {
@@ -2292,7 +2313,7 @@ const readWorkspaceGlobs = (rootDir, rootPkg) => {
2292
2313
  }
2293
2314
  }
2294
2315
  }
2295
- const lerna = readJson$1(path.join(rootDir, "lerna.json"));
2316
+ const lerna = readJson$2(path.join(rootDir, "lerna.json"));
2296
2317
  if (lerna && Array.isArray(lerna.packages)) {
2297
2318
  for (const g of lerna.packages) if (typeof g === "string") globs.push(g);
2298
2319
  }
@@ -2693,7 +2714,7 @@ const JS_EXTENSIONS$2 = new Set([
2693
2714
  ".cjs"
2694
2715
  ]);
2695
2716
  const PY_EXTENSIONS$2 = new Set([".py"]);
2696
- const readJson = (filePath) => {
2717
+ const readJson$1 = (filePath) => {
2697
2718
  try {
2698
2719
  return JSON.parse(fs.readFileSync(filePath, "utf-8"));
2699
2720
  } catch {
@@ -2737,7 +2758,7 @@ const collectNestedManifests = (rootDir, jsDeps) => {
2737
2758
  const full = path.join(dir, entry.name);
2738
2759
  if (entry.isDirectory()) walk(full, depth + 1);
2739
2760
  else if (entry.name === "package.json" && depth > 0) {
2740
- const wsPkg = readJson(full);
2761
+ const wsPkg = readJson$1(full);
2741
2762
  if (!wsPkg) continue;
2742
2763
  if (typeof wsPkg.name === "string") jsDeps.add(wsPkg.name);
2743
2764
  addDepsFromPkg(wsPkg, jsDeps);
@@ -2749,13 +2770,13 @@ const collectNestedManifests = (rootDir, jsDeps) => {
2749
2770
  const collectJsDeps = (rootDir, jsDeps) => {
2750
2771
  const pkgPath = path.join(rootDir, "package.json");
2751
2772
  if (!fs.existsSync(pkgPath)) return false;
2752
- const pkg = readJson(pkgPath);
2773
+ const pkg = readJson$1(pkgPath);
2753
2774
  if (!pkg || typeof pkg !== "object") return false;
2754
2775
  addDepsFromPkg(pkg, jsDeps);
2755
2776
  if (typeof pkg.name === "string") jsDeps.add(pkg.name);
2756
2777
  const workspaceDirs = collectWorkspaceDirs(rootDir, pkg);
2757
2778
  for (const wsDir of workspaceDirs) {
2758
- const wsPkg = readJson(path.join(wsDir, "package.json"));
2779
+ const wsPkg = readJson$1(path.join(wsDir, "package.json"));
2759
2780
  if (!wsPkg) continue;
2760
2781
  if (typeof wsPkg.name === "string") jsDeps.add(wsPkg.name);
2761
2782
  addDepsFromPkg(wsPkg, jsDeps);
@@ -2784,7 +2805,11 @@ const VIRTUAL_MODULE_PREFIXES = [
2784
2805
  "astro:",
2785
2806
  "virtual:",
2786
2807
  "bun:",
2787
- "file:"
2808
+ "file:",
2809
+ "http:",
2810
+ "https:",
2811
+ "jsr:",
2812
+ "npm:"
2788
2813
  ];
2789
2814
  const isJsVirtualModule = (spec, manifest) => {
2790
2815
  if (VIRTUAL_MODULE_PREFIXES.some((p) => spec.startsWith(p))) return true;
@@ -2913,7 +2938,7 @@ const checkPyImport = (spec, manifest) => {
2913
2938
  return root;
2914
2939
  };
2915
2940
  const detectHallucinatedImports = async (context) => {
2916
- const rootPkg = readJson(path.join(context.rootDirectory, "package.json"));
2941
+ const rootPkg = readJson$1(path.join(context.rootDirectory, "package.json"));
2917
2942
  const workspaceDirs = collectWorkspaceDirs(context.rootDirectory, rootPkg);
2918
2943
  const manifest = loadManifest(context.rootDirectory);
2919
2944
  if (!manifest.hasJsManifest && !manifest.hasPyManifest) return [];
@@ -3018,6 +3043,9 @@ const VENDOR_API_DOMAINS = [
3018
3043
  ];
3019
3044
  const isVendorApiHost = (host) => VENDOR_API_DOMAINS.some((d) => host === d || host.endsWith(`.${d}`));
3020
3045
  const PLACEHOLDER_ID_RE = /^(?:changeme|replace[_-]?me|your[_-]|example|placeholder|todo)/i;
3046
+ const PROVIDER_ID_RE = /^(?:price|prod|cus|sub|acct|org|app|tenant|workspace|project|client|key|tok|token|sk|pk)_[A-Za-z0-9][A-Za-z0-9_-]{7,}$/i;
3047
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
3048
+ const READABLE_KEY_RE = /^[a-z][a-z0-9]*(?:[_-][a-z0-9]+){2,}$/;
3021
3049
  const HARDCODED_URL_FINDING = {
3022
3050
  rule: "ai-slop/hardcoded-url",
3023
3051
  message: "Hardcoded environment URL in production code",
@@ -3055,8 +3083,10 @@ const safeUrlHost = (urlText) => {
3055
3083
  }
3056
3084
  };
3057
3085
  const isEnvBackedLine = (line) => ENV_REFERENCE_RE.test(line);
3086
+ const TEMPLATE_INTERPOLATION_START = "${";
3058
3087
  const shouldFlagUrlLiteral = (line, urlText) => {
3059
3088
  if (isEnvBackedLine(line)) return false;
3089
+ if (urlText.includes(TEMPLATE_INTERPOLATION_START) && /\bnew\s+URL\s*\(/.test(line)) return false;
3060
3090
  const host = safeUrlHost(urlText);
3061
3091
  if (!host) return false;
3062
3092
  if (PLACEHOLDER_HOSTS.has(host)) return false;
@@ -3071,7 +3101,11 @@ const hasUsefulIdShape = (value) => {
3071
3101
  if (ENV_VAR_NAME_RE.test(value)) return false;
3072
3102
  if (/^https?:\/\//i.test(value)) return false;
3073
3103
  if (/^[A-Za-z]+$/.test(value)) return false;
3074
- return /[0-9]/.test(value);
3104
+ if (READABLE_KEY_RE.test(value) && !PROVIDER_ID_RE.test(value)) return false;
3105
+ if (PROVIDER_ID_RE.test(value)) return true;
3106
+ if (UUID_RE.test(value)) return true;
3107
+ if (!/[0-9]/.test(value)) return false;
3108
+ return value.length >= 24 && !/[_-]/.test(value) && /[a-z]/.test(value) && /[A-Z]/.test(value);
3075
3109
  };
3076
3110
  const scanLineForConfigLiterals = (line, relativePath, ext, lineNumber) => {
3077
3111
  const diagnostics = [];
@@ -5153,8 +5187,8 @@ const shouldIncludeIssue = (issueType, filePath) => {
5153
5187
  return !filePath.replace(/\\/g, "/").includes(".github/workflows/");
5154
5188
  };
5155
5189
  const DEPENDENCY_HELP = {
5156
- dependencies: "This package is listed in package.json but not imported anywhere. Remove it with `npm uninstall` or `npx aislop fix`.",
5157
- devDependencies: "This package is listed in package.json but not imported anywhere. Remove it with `npm uninstall` or `npx aislop fix`.",
5190
+ dependencies: "This package is listed in package.json but not imported anywhere. Remove it with `npm uninstall` or `aislop fix`.",
5191
+ devDependencies: "This package is listed in package.json but not imported anywhere. Remove it with `npm uninstall` or `aislop fix`.",
5158
5192
  unlisted: "This package is imported in code but not declared in package.json. Run `npm install` to add it.",
5159
5193
  unresolved: "This import cannot be resolved. Check for typos or missing packages.",
5160
5194
  binaries: "This binary is used but its package is not in package.json."
@@ -5503,7 +5537,7 @@ const parseBiomeJsonOutput = (output, rootDir) => {
5503
5537
  rule: "formatting",
5504
5538
  severity,
5505
5539
  message,
5506
- help: "Run `npx aislop fix` to auto-format",
5540
+ help: "Run `aislop fix` to auto-format",
5507
5541
  line: entry.location?.start?.line ?? 0,
5508
5542
  column: entry.location?.start?.column ?? 0,
5509
5543
  category: "Format",
@@ -5542,7 +5576,7 @@ const FORMATTERS = {
5542
5576
  rule: "rust-formatting",
5543
5577
  severity: "warning",
5544
5578
  message: "Rust file is not formatted correctly",
5545
- help: "Run `npx aislop fix` to auto-format with rustfmt",
5579
+ help: "Run `aislop fix` to auto-format with rustfmt",
5546
5580
  line: parseInt(match[2], 10),
5547
5581
  column: 0,
5548
5582
  category: "Format",
@@ -5575,7 +5609,7 @@ const FORMATTERS = {
5575
5609
  rule: offense.cop_name ?? "ruby-formatting",
5576
5610
  severity: "warning",
5577
5611
  message: offense.message ?? "Ruby formatting issue",
5578
- help: "Run `npx aislop fix` to auto-format",
5612
+ help: "Run `aislop fix` to auto-format",
5579
5613
  line: offense.location?.start_line ?? 0,
5580
5614
  column: offense.location?.start_column ?? 0,
5581
5615
  category: "Format",
@@ -5606,7 +5640,7 @@ const FORMATTERS = {
5606
5640
  rule: "php-formatting",
5607
5641
  severity: "warning",
5608
5642
  message: "PHP file is not formatted correctly",
5609
- help: "Run `npx aislop fix` to auto-format",
5643
+ help: "Run `aislop fix` to auto-format",
5610
5644
  line: 0,
5611
5645
  column: 0,
5612
5646
  category: "Format",
@@ -5659,7 +5693,7 @@ const runGofmt = async (context) => {
5659
5693
  rule: "go-formatting",
5660
5694
  severity: "warning",
5661
5695
  message: "Go file is not formatted correctly",
5662
- help: "Run `npx aislop fix` to auto-format with gofmt",
5696
+ help: "Run `aislop fix` to auto-format with gofmt",
5663
5697
  line: 0,
5664
5698
  column: 0,
5665
5699
  category: "Format",
@@ -5758,7 +5792,7 @@ const parseRuffFormatOutput = (output, rootDir) => {
5758
5792
  rule: "python-formatting",
5759
5793
  severity: "warning",
5760
5794
  message: "Python file is not formatted correctly",
5761
- help: "Run `npx aislop fix` to auto-format with ruff",
5795
+ help: "Run `aislop fix` to auto-format with ruff",
5762
5796
  line: 0,
5763
5797
  column: 0,
5764
5798
  category: "Format",
@@ -6275,6 +6309,7 @@ const AMBIENT_GLOBAL_DEPS = [
6275
6309
  const SST_PLATFORM_REF_RE = /\/\/\/\s*<reference\s+path=["'][^"']*sst[\\/]+platform[\\/]+config\.d\.ts["']/;
6276
6310
  const ICON_AUTOIMPORT_RE = /^Icon[A-Z]/;
6277
6311
  const NO_UNDEF_IDENT_RE = /^['‘"`]([^'’"`]+)['’"`]/;
6312
+ const SUPABASE_FUNCTION_PATH_RE = /(?:^|\/)supabase\/functions\/[^/]+\/.+\.[cm]?[jt]sx?$/;
6278
6313
  const detectAmbientSources = (rootDir) => {
6279
6314
  const found = /* @__PURE__ */ new Set();
6280
6315
  const skipDirs = new Set([
@@ -6319,6 +6354,36 @@ const detectAmbientSources = (rootDir) => {
6319
6354
  const extractNoUndefIdentifier = (message) => {
6320
6355
  return NO_UNDEF_IDENT_RE.exec(message)?.[1] ?? null;
6321
6356
  };
6357
+ const looksLikeChromeExtensionManifest = (filePath) => {
6358
+ try {
6359
+ const manifest = JSON.parse(fs.readFileSync(filePath, "utf-8"));
6360
+ return typeof manifest.manifest_version === "number" && ("background" in manifest || "content_scripts" in manifest || "permissions" in manifest);
6361
+ } catch {
6362
+ return false;
6363
+ }
6364
+ };
6365
+ const chromeExtensionFileCache = /* @__PURE__ */ new Map();
6366
+ const isChromeExtensionFile = (rootDir, relativeFilePath) => {
6367
+ const cacheKey = `${rootDir}:${relativeFilePath.split(path.sep).join("/")}`;
6368
+ const cached = chromeExtensionFileCache.get(cacheKey);
6369
+ if (cached !== void 0) return cached;
6370
+ const absolute = path.isAbsolute(relativeFilePath) ? relativeFilePath : path.join(rootDir, relativeFilePath);
6371
+ const root = path.resolve(rootDir);
6372
+ let dir = path.dirname(path.resolve(absolute));
6373
+ let matched = false;
6374
+ while (true) {
6375
+ const relativeToRoot = path.relative(root, dir);
6376
+ if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) break;
6377
+ if (looksLikeChromeExtensionManifest(path.join(dir, "manifest.json"))) {
6378
+ matched = true;
6379
+ break;
6380
+ }
6381
+ if (dir === root) break;
6382
+ dir = path.dirname(dir);
6383
+ }
6384
+ chromeExtensionFileCache.set(cacheKey, matched);
6385
+ return matched;
6386
+ };
6322
6387
  const isAmbientFalsePositive = (rule, message, sources) => {
6323
6388
  if (rule !== "eslint/no-undef") return false;
6324
6389
  const ident = extractNoUndefIdentifier(message);
@@ -6327,9 +6392,19 @@ const isAmbientFalsePositive = (rule, message, sources) => {
6327
6392
  if ((sources.has("@types/bun") || sources.has("bun-types")) && ident === "Bun") return true;
6328
6393
  return false;
6329
6394
  };
6395
+ const isRuntimeGlobalFalsePositive = (rule, message, rootDir, relativeFilePath) => {
6396
+ if (rule !== "eslint/no-undef") return false;
6397
+ const ident = extractNoUndefIdentifier(message);
6398
+ if (!ident) return false;
6399
+ const normalized = relativeFilePath.split(path.sep).join("/");
6400
+ if (ident === "Deno" && SUPABASE_FUNCTION_PATH_RE.test(normalized)) return true;
6401
+ if (ident === "chrome" && isChromeExtensionFile(rootDir, relativeFilePath)) return true;
6402
+ return false;
6403
+ };
6330
6404
  const sstReferencedFiles = /* @__PURE__ */ new Map();
6331
6405
  const clearSstReferenceCache = () => {
6332
6406
  sstReferencedFiles.clear();
6407
+ chromeExtensionFileCache.clear();
6333
6408
  };
6334
6409
  const fileReferencesSstPlatform = (rootDir, relativeFilePath) => {
6335
6410
  const cached = sstReferencedFiles.get(relativeFilePath);
@@ -6381,6 +6456,32 @@ const collectPackageNames = (dir) => {
6381
6456
  }
6382
6457
  return names;
6383
6458
  };
6459
+ const readJson = (filePath) => {
6460
+ const raw = readTextFile$1(filePath);
6461
+ if (!raw) return null;
6462
+ try {
6463
+ const parsed = JSON.parse(raw);
6464
+ return parsed && typeof parsed === "object" ? parsed : null;
6465
+ } catch {
6466
+ return null;
6467
+ }
6468
+ };
6469
+ const hasBunRuntime = (rootDir, projectFiles) => {
6470
+ if (fs.existsSync(path.join(rootDir, "bun.lock")) || fs.existsSync(path.join(rootDir, "bun.lockb")) || fs.existsSync(path.join(rootDir, "bunfig.toml"))) return true;
6471
+ const hasBunFiles = projectFiles.some((filePath) => /(?:^|\/)bunfig\.toml$|(?:^|\/)bun\.lockb?$/.test(filePath));
6472
+ const pkg = readJson(path.join(rootDir, "package.json"));
6473
+ if (!pkg) return hasBunFiles;
6474
+ if (typeof pkg.packageManager === "string" && /^bun@/i.test(pkg.packageManager)) return true;
6475
+ const scripts = pkg.scripts;
6476
+ if (scripts && typeof scripts === "object") {
6477
+ for (const command of Object.values(scripts)) if (typeof command === "string" && /(?:^|[;&|()\s])bunx?\s/.test(command)) return true;
6478
+ }
6479
+ return hasBunFiles;
6480
+ };
6481
+ const hasDenoRuntime = (rootDir, projectFiles) => {
6482
+ if (fs.existsSync(path.join(rootDir, "deno.json")) || fs.existsSync(path.join(rootDir, "deno.jsonc"))) return true;
6483
+ return projectFiles.some((filePath) => /(?:^|\/)deno\.jsonc?$/.test(filePath));
6484
+ };
6384
6485
  const AMBIENT_GLOBAL_RE = /^\s*(?:declare\s+)?(?:const|let|var|function|class)\s+([A-Za-z_$][\w$]*)/gm;
6385
6486
  const collectAmbientGlobals = (rootDir) => {
6386
6487
  const globals = /* @__PURE__ */ new Set();
@@ -6392,7 +6493,8 @@ const collectAmbientGlobals = (rootDir) => {
6392
6493
  for (const match of content.matchAll(AMBIENT_GLOBAL_RE)) globals.add(match[1]);
6393
6494
  }
6394
6495
  const deps = collectPackageNames(rootDir);
6395
- if (deps.has("@types/bun") || deps.has("bun-types")) globals.add("Bun");
6496
+ if (deps.has("@types/bun") || deps.has("bun-types") || hasBunRuntime(rootDir, projectFiles)) globals.add("Bun");
6497
+ if (hasDenoRuntime(rootDir, projectFiles)) globals.add("Deno");
6396
6498
  if (projectFiles.some((filePath) => /(?:^|\/)sst\.config\.ts$/.test(filePath))) for (const name of [
6397
6499
  "$app",
6398
6500
  "$config",
@@ -6548,6 +6650,37 @@ const removeDuplicateKeyLines = (rootDirectory, diagnostics) => {
6548
6650
  fs.writeFileSync(filePath, filtered.join("\n"));
6549
6651
  }
6550
6652
  };
6653
+ const toDiagnostic = (d) => {
6654
+ const { plugin, rule } = parseRuleCode(d.code);
6655
+ const label = d.labels[0];
6656
+ return {
6657
+ filePath: d.filename,
6658
+ engine: "lint",
6659
+ rule: `${plugin}/${rule}`,
6660
+ severity: d.severity,
6661
+ message: d.message.replace(/\S+\.\w+:\d+:\d+[\s\S]*$/, "").trim() || d.message,
6662
+ help: d.help || "",
6663
+ line: label?.span.line ?? 0,
6664
+ column: label?.span.column ?? 0,
6665
+ category: plugin === "react" ? "React" : plugin === "import" ? "Imports" : "Lint",
6666
+ fixable: false
6667
+ };
6668
+ };
6669
+ const shouldKeepOxlintDiagnostic = (context, ambientSources, seen, d) => {
6670
+ const relativePath = path.isAbsolute(d.filePath) ? path.relative(context.rootDirectory, d.filePath) : d.filePath;
6671
+ if (isExcludedFromScan(relativePath)) return false;
6672
+ if (isViteVirtualImportFalsePositive(d.rule, d.message)) return false;
6673
+ if (isAmbientFalsePositive(d.rule, d.message, ambientSources)) return false;
6674
+ if (isRuntimeGlobalFalsePositive(d.rule, d.message, context.rootDirectory, relativePath)) return false;
6675
+ if (isSolidRefFalsePositive(context, d)) return false;
6676
+ if (isContextualTypeScriptFalsePositive(d)) return false;
6677
+ if (isUnderscoreUnusedVar(d.rule, d.message)) return false;
6678
+ if (d.rule === "eslint/no-undef" && fileReferencesSstPlatform(context.rootDirectory, d.filePath)) return false;
6679
+ const key = `${d.filePath}:${d.line}:${d.rule}:${d.message}`;
6680
+ if (seen.has(key)) return false;
6681
+ seen.add(key);
6682
+ return true;
6683
+ };
6551
6684
  const runOxlint = async (context) => {
6552
6685
  const configPath = path.join(os.tmpdir(), `aislop-oxlintrc-${process.pid}.json`);
6553
6686
  const framework = context.frameworks.find((f) => f !== "none");
@@ -6584,34 +6717,7 @@ const runOxlint = async (context) => {
6584
6717
  return [];
6585
6718
  }
6586
6719
  const seen = /* @__PURE__ */ new Set();
6587
- return output.diagnostics.map((d) => {
6588
- const { plugin, rule } = parseRuleCode(d.code);
6589
- const label = d.labels[0];
6590
- return {
6591
- filePath: d.filename,
6592
- engine: "lint",
6593
- rule: `${plugin}/${rule}`,
6594
- severity: d.severity,
6595
- message: d.message.replace(/\S+\.\w+:\d+:\d+[\s\S]*$/, "").trim() || d.message,
6596
- help: d.help || "",
6597
- line: label?.span.line ?? 0,
6598
- column: label?.span.column ?? 0,
6599
- category: plugin === "react" ? "React" : plugin === "import" ? "Imports" : "Lint",
6600
- fixable: false
6601
- };
6602
- }).filter((d) => {
6603
- if (isExcludedFromScan(path.isAbsolute(d.filePath) ? path.relative(context.rootDirectory, d.filePath) : d.filePath)) return false;
6604
- if (isViteVirtualImportFalsePositive(d.rule, d.message)) return false;
6605
- if (isAmbientFalsePositive(d.rule, d.message, ambientSources)) return false;
6606
- if (isSolidRefFalsePositive(context, d)) return false;
6607
- if (isContextualTypeScriptFalsePositive(d)) return false;
6608
- if (isUnderscoreUnusedVar(d.rule, d.message)) return false;
6609
- if (d.rule === "eslint/no-undef" && fileReferencesSstPlatform(context.rootDirectory, d.filePath)) return false;
6610
- const key = `${d.filePath}:${d.line}:${d.rule}:${d.message}`;
6611
- if (seen.has(key)) return false;
6612
- seen.add(key);
6613
- return true;
6614
- });
6720
+ return output.diagnostics.map(toDiagnostic).filter((d) => shouldKeepOxlintDiagnostic(context, ambientSources, seen, d));
6615
6721
  } finally {
6616
6722
  if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
6617
6723
  }
@@ -6737,7 +6843,7 @@ const lintEngine = {
6737
6843
  const promises = [];
6738
6844
  if (languages.includes("typescript") || languages.includes("javascript")) {
6739
6845
  promises.push(runOxlint(context));
6740
- if (context.config.lint.typecheck) promises.push(import("./typecheck-wVSohmOX.js").then((mod) => mod.runTypecheck(context)));
6846
+ if (context.config.lint.typecheck) promises.push(import("./typecheck-yOGXIIGU.js").then((mod) => mod.runTypecheck(context)));
6741
6847
  }
6742
6848
  if (context.frameworks.includes("expo")) promises.push(Promise.resolve().then(() => expo_doctor_exports).then((mod) => mod.runExpoDoctor(context)));
6743
6849
  if (languages.includes("python") && installedTools.ruff) promises.push(runRuffLint(context));
@@ -6757,7 +6863,7 @@ const lintEngine = {
6757
6863
 
6758
6864
  //#endregion
6759
6865
  //#region src/ui/invocation.ts
6760
- const detectInvocation = () => "npx aislop";
6866
+ const detectInvocation = () => "aislop";
6761
6867
 
6762
6868
  //#endregion
6763
6869
  //#region src/engines/security/audit.ts
@@ -6909,7 +7015,7 @@ const parseJsAudit = (output, source) => {
6909
7015
  rule: "security/dependency-audit-skipped",
6910
7016
  severity: "info",
6911
7017
  message: `Dependency audit skipped (${source}): lockfile is missing`,
6912
- help: error.detail ?? "Generate a lockfile, then re-run `npx aislop scan` for dependency vulnerability checks.",
7018
+ help: error.detail ?? "Generate a lockfile, then re-run `aislop scan` for dependency vulnerability checks.",
6913
7019
  line: 0,
6914
7020
  column: 0,
6915
7021
  category: "Security",
@@ -7026,6 +7132,194 @@ const runCargoAudit = async (rootDir, timeout) => {
7026
7132
  }
7027
7133
  };
7028
7134
 
7135
+ //#endregion
7136
+ //#region src/engines/security/html-safety.ts
7137
+ const SAFE_EMPTY_INNER_HTML_RE = /^\.innerHTML\s*=\s*(?:""|''|``)\s*;?/;
7138
+ const SAFE_SANITIZED_INNER_HTML_RE = /^\.innerHTML\s*=\s*(?:escapeHtml|sanitizeHtml|sanitizeHTML|DOMPurify\.sanitize)\s*\([^;\n]*\)\s*;?(?:\n|$)/;
7139
+ const SANITIZER_EXPR_RE = /^(?:escapeHtml|escapeHTML|sanitizeHtml|sanitizeHTML|DOMPurify\.sanitize)\s*\([^;\n]*\)$/;
7140
+ const IDENT_RE = /^[A-Za-z_$][\w$]*$/;
7141
+ const STATIC_STRING_RE = /^(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\$])*`)$/;
7142
+ const NUMERICISH_EXPR_RE = /^(?:[-+]?\d+(?:\.\d+)?|[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*(?:\s*\|\|\s*[-+]?\d+(?:\.\d+)?)?)$/;
7143
+ const NUMERICISH_NAME_RE = /(?:^|\.)(?:count|length|size|width|height|top|right|bottom|left|duration|elapsed|timestamp|time|ms|port|pid|attempt|attempts|index|total|x|y)$|(?:count|length|size|width|height|duration|elapsed|timestamp|time|port|pid|attempt|index|total)$/i;
7144
+ const SAFE_FORMAT_CALL_RE = /^(?:format[A-Z]\w*|fmt[A-Z]?\w*)\s*\((.*)\)$/;
7145
+ const consumeQuotedLiteral = (content, startIndex, quote) => {
7146
+ let i = startIndex + 1;
7147
+ while (i < content.length) {
7148
+ const char = content[i];
7149
+ if (char === "\\") {
7150
+ i += 2;
7151
+ continue;
7152
+ }
7153
+ if (char === quote) return { endIndex: i };
7154
+ if (char === "\n") return null;
7155
+ i++;
7156
+ }
7157
+ return null;
7158
+ };
7159
+ const consumeTemplateLiteral = (content, startIndex) => {
7160
+ const openIndex = content.indexOf("`", startIndex);
7161
+ if (openIndex === -1) return null;
7162
+ let i = openIndex + 1;
7163
+ while (i < content.length) {
7164
+ const char = content[i];
7165
+ if (char === "\\") {
7166
+ i += 2;
7167
+ continue;
7168
+ }
7169
+ if (char === "`") return {
7170
+ body: content.slice(openIndex + 1, i),
7171
+ endIndex: i
7172
+ };
7173
+ i++;
7174
+ }
7175
+ return null;
7176
+ };
7177
+ const assignmentTailIsClosed = (content, endIndex) => /^\s*(?:;[^\n]*)?(?:\n|$)/.test(content.slice(endIndex + 1));
7178
+ const assignmentRhsStart = (content, matchIndex) => {
7179
+ const match = /^\.innerHTML\s*=\s*/.exec(content.slice(matchIndex));
7180
+ return match ? matchIndex + match[0].length : null;
7181
+ };
7182
+ const templateExpressions = (templateBody) => [...templateBody.matchAll(/\$\{\s*([^}]+?)\s*\}/g)].map((match) => match[1].trim());
7183
+ const staticTernaryRe = /^\s*[^?]+\?\s*(?:"[^"]*"|'[^']*'|`[^`$]*`)\s*:\s*(?:"[^"]*"|'[^']*'|`[^`$]*`)\s*$/;
7184
+ const splitTopLevelTernary = (expr) => {
7185
+ let quote = null;
7186
+ let depth = 0;
7187
+ let question = -1;
7188
+ let colon = -1;
7189
+ for (let i = 0; i < expr.length; i++) {
7190
+ const char = expr[i];
7191
+ if (char === "\\") {
7192
+ i++;
7193
+ continue;
7194
+ }
7195
+ if ((char === "'" || char === "\"" || char === "`") && quote === null) {
7196
+ quote = char;
7197
+ continue;
7198
+ }
7199
+ if (char === quote) {
7200
+ quote = null;
7201
+ continue;
7202
+ }
7203
+ if (quote) continue;
7204
+ if (char === "(" || char === "[" || char === "{") depth++;
7205
+ else if (char === ")" || char === "]" || char === "}") depth = Math.max(0, depth - 1);
7206
+ else if (char === "?" && depth === 0 && question === -1) question = i;
7207
+ else if (char === ":" && depth === 0 && question !== -1) {
7208
+ colon = i;
7209
+ break;
7210
+ }
7211
+ }
7212
+ if (question === -1 || colon === -1) return null;
7213
+ return {
7214
+ whenTrue: expr.slice(question + 1, colon).trim(),
7215
+ whenFalse: expr.slice(colon + 1).trim()
7216
+ };
7217
+ };
7218
+ const isNumericishExpression = (expr) => {
7219
+ const normalized = expr.trim();
7220
+ if (/^(?:Math\.\w+|Number|parseInt|parseFloat)\s*\(/.test(normalized)) return true;
7221
+ if (!NUMERICISH_EXPR_RE.test(normalized)) return false;
7222
+ return /\d/.test(normalized) || NUMERICISH_NAME_RE.test(normalized);
7223
+ };
7224
+ const isSafeTemplateLiteralExpression = (expr, safeNames) => {
7225
+ if (!expr.startsWith("`") || !expr.endsWith("`")) return false;
7226
+ return templateExpressions(expr.slice(1, -1)).every((part) => isSafeHtmlExpression(part, safeNames));
7227
+ };
7228
+ const collectSafeHtmlNames = (content, matchIndex) => {
7229
+ const safeNames = /* @__PURE__ */ new Set();
7230
+ const prefix = content.slice(Math.max(0, matchIndex - 8e3), matchIndex);
7231
+ for (const rawLine of prefix.split("\n")) {
7232
+ const line = rawLine.trim();
7233
+ let match = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)\s*;?$/.exec(line);
7234
+ if (match) {
7235
+ const [, name, expr] = match;
7236
+ if (isSafeHtmlExpression(expr.trim(), safeNames)) safeNames.add(name);
7237
+ else safeNames.delete(name);
7238
+ continue;
7239
+ }
7240
+ match = /^([A-Za-z_$][\w$]*)\s*\+=\s*(.+?)\s*;?$/.exec(line);
7241
+ if (match) {
7242
+ const [, name, expr] = match;
7243
+ if (safeNames.has(name) && isSafeHtmlExpression(expr.trim(), safeNames)) safeNames.add(name);
7244
+ else safeNames.delete(name);
7245
+ continue;
7246
+ }
7247
+ match = /^([A-Za-z_$][\w$]*)\s*=\s*(.+?)\s*;?$/.exec(line);
7248
+ if (match) {
7249
+ const [, name, expr] = match;
7250
+ if (isSafeHtmlExpression(expr.trim(), safeNames)) safeNames.add(name);
7251
+ else safeNames.delete(name);
7252
+ }
7253
+ }
7254
+ return safeNames;
7255
+ };
7256
+ const isSafeHtmlExpression = (expr, safeNames) => {
7257
+ const normalized = expr.trim();
7258
+ if (SANITIZER_EXPR_RE.test(normalized)) return true;
7259
+ if (STATIC_STRING_RE.test(normalized)) return true;
7260
+ if (staticTernaryRe.test(expr)) return true;
7261
+ if (isNumericishExpression(normalized)) return true;
7262
+ if (IDENT_RE.test(normalized) && safeNames.has(normalized)) return true;
7263
+ if (isSafeTemplateLiteralExpression(normalized, safeNames)) return true;
7264
+ const ternary = splitTopLevelTernary(normalized);
7265
+ if (ternary && isSafeHtmlExpression(ternary.whenTrue, safeNames) && isSafeHtmlExpression(ternary.whenFalse, safeNames)) return true;
7266
+ const formatCall = SAFE_FORMAT_CALL_RE.exec(normalized);
7267
+ if (formatCall) return formatCall[1].split(",").map((arg) => arg.trim()).filter((arg) => arg.length > 0).every((arg) => isNumericishExpression(arg) || IDENT_RE.test(arg) && safeNames.has(arg));
7268
+ return false;
7269
+ };
7270
+ const readSingleLineRhs = (content, rhsStart) => {
7271
+ const lineEnd = content.indexOf("\n", rhsStart);
7272
+ const line = content.slice(rhsStart, lineEnd === -1 ? content.length : lineEnd);
7273
+ let quote = null;
7274
+ for (let i = 0; i < line.length; i++) {
7275
+ const char = line[i];
7276
+ if (char === "\\") {
7277
+ i++;
7278
+ continue;
7279
+ }
7280
+ if ((char === "'" || char === "\"" || char === "`") && quote === null) {
7281
+ quote = char;
7282
+ continue;
7283
+ }
7284
+ if (char === quote) {
7285
+ quote = null;
7286
+ continue;
7287
+ }
7288
+ if (char === ";" && quote === null) return line.slice(0, i).trim();
7289
+ }
7290
+ return line.trim();
7291
+ };
7292
+ const isSafeMapJoinHtmlAssignment = (content, rhsStart) => {
7293
+ const head = content.slice(rhsStart);
7294
+ const mapMatch = /^[A-Za-z_$][\w$.]*\.map\(\s*[A-Za-z_$][\w$]*\s*=>\s*`/.exec(head);
7295
+ if (!mapMatch) return false;
7296
+ const template = consumeTemplateLiteral(content, rhsStart + mapMatch[0].length - 1);
7297
+ if (!template) return false;
7298
+ if (!/^\s*\)\.join\(\s*(?:""|'')\s*\)/.test(content.slice(template.endIndex + 1))) return false;
7299
+ const safeNames = collectSafeHtmlNames(content, rhsStart);
7300
+ return templateExpressions(template.body).every((expr) => isSafeHtmlExpression(expr, safeNames));
7301
+ };
7302
+ const isSafeInnerHtmlAssignment = (content, matchIndex) => {
7303
+ const tail = content.slice(matchIndex);
7304
+ if (SAFE_EMPTY_INNER_HTML_RE.test(tail) || SAFE_SANITIZED_INNER_HTML_RE.test(tail)) return true;
7305
+ const rhsStart = assignmentRhsStart(content, matchIndex);
7306
+ if (rhsStart === null) return false;
7307
+ const first = content[rhsStart];
7308
+ const safeNames = collectSafeHtmlNames(content, matchIndex);
7309
+ if (isSafeHtmlExpression(readSingleLineRhs(content, rhsStart), safeNames)) return true;
7310
+ if (isSafeMapJoinHtmlAssignment(content, rhsStart)) return true;
7311
+ if (first === "'" || first === "\"") {
7312
+ const quoted = consumeQuotedLiteral(content, rhsStart, first);
7313
+ return Boolean(quoted && assignmentTailIsClosed(content, quoted.endIndex));
7314
+ }
7315
+ if (first !== "`") return false;
7316
+ const template = consumeTemplateLiteral(content, rhsStart);
7317
+ if (!template || !assignmentTailIsClosed(content, template.endIndex)) return false;
7318
+ const expressions = templateExpressions(template.body);
7319
+ if (expressions.length === 0) return true;
7320
+ return expressions.every((expr) => isSafeHtmlExpression(expr, safeNames));
7321
+ };
7322
+
7029
7323
  //#endregion
7030
7324
  //#region src/engines/security/risky.ts
7031
7325
  const ev = "eval";
@@ -7151,6 +7445,30 @@ const isStructuredDataScript = (content, matchIndex) => {
7151
7445
  const after = content.slice(matchIndex, Math.min(content.length, matchIndex + 180));
7152
7446
  return /__html\s*:\s*JSON\.stringify\s*\(/.test(after);
7153
7447
  };
7448
+ const isSafeShellSpawnArray = (content, matchIndex) => /^spawn\s*\(\s*\[/.test(content.slice(matchIndex)) && !/^\s*spawn\s*\(\s*\[\s*["'](?:sh|bash|zsh|cmd|cmd\.exe|powershell|pwsh)["']\s*,\s*["'](?:-c|\/c|\/C)["']/i.test(content.slice(matchIndex)) && !/shell\s*:\s*true\b/.test(content.slice(matchIndex, matchIndex + 500));
7449
+ const PLACEHOLDER_EXPR_RE = /^(?:placeholders?|placeholderList|bindMarkers?|bindingMarkers?|bindPlaceholders?|bindingPlaceholders?|parameterPlaceholders?|sqlPlaceholders?)(?:\.\w+\([^)]*\))?$/i;
7450
+ const SQL_PLACEHOLDER_LITERAL_RE = /["'](?:\?|\$\d+|\$\{[^}]+\})["']/;
7451
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7452
+ const isGeneratedPlaceholderList = (content, matchIndex, placeholderExpr) => {
7453
+ const name = placeholderExpr.match(/^([A-Za-z_$][\w$]*)/)?.[1];
7454
+ if (!name) return false;
7455
+ const prefix = content.slice(Math.max(0, matchIndex - 4e3), matchIndex);
7456
+ const declarationRe = new RegExp(`\\b(?:const|let|var)\\s+${escapeRegExp(name)}\\s*=\\s*([^;\\n]+)`, "g");
7457
+ const declaration = [...prefix.matchAll(declarationRe)].at(-1);
7458
+ if (!declaration) return false;
7459
+ const expr = declaration[1];
7460
+ if (!/\.join\s*\(/.test(expr)) return false;
7461
+ return /\.map\s*\(/.test(expr) && /=>/.test(expr) && SQL_PLACEHOLDER_LITERAL_RE.test(expr) || /\.fill\s*\(/.test(expr) && SQL_PLACEHOLDER_LITERAL_RE.test(expr);
7462
+ };
7463
+ const isSafeSqlPlaceholderTemplate = (content, matchIndex) => {
7464
+ const template = consumeTemplateLiteral(content, matchIndex);
7465
+ if (!template) return false;
7466
+ const afterTemplate = content.slice(template.endIndex + 1);
7467
+ if (!(/^\s*,/.test(afterTemplate) || /^\s*\)\s*\.(?:all|get|run|values)\s*\(/.test(afterTemplate))) return false;
7468
+ const expressions = [...template.body.matchAll(/\$\{\s*([^}]+?)\s*\}/g)].map((match) => match[1].trim());
7469
+ if (expressions.length === 0) return false;
7470
+ return expressions.every((expr) => PLACEHOLDER_EXPR_RE.test(expr) && isGeneratedPlaceholderList(content, matchIndex, expr));
7471
+ };
7154
7472
  const detectRiskyConstructs = async (context) => {
7155
7473
  const files = getSourceFiles(context);
7156
7474
  const diagnostics = [];
@@ -7175,8 +7493,11 @@ const detectRiskyConstructs = async (context) => {
7175
7493
  const line = content.slice(0, match.index).split("\n").length;
7176
7494
  if (name === "innerhtml") {
7177
7495
  const beforeMatch = content.slice(Math.max(0, match.index - 200), match.index);
7496
+ if (isSafeInnerHtmlAssignment(content, match.index)) continue;
7178
7497
  if (/(?:template|tmpl|tpl)$/i.test(beforeMatch.trimEnd()) || /createElement\s*\(\s*['"]template['"]\s*\)$/.test(beforeMatch.trimEnd())) continue;
7179
7498
  }
7499
+ if (name === "sql-injection" && isSafeSqlPlaceholderTemplate(content, match.index)) continue;
7500
+ if (name === "shell-injection" && isSafeShellSpawnArray(content, match.index)) continue;
7180
7501
  if (name === "dangerously-set-innerhtml") {
7181
7502
  if (hasDangerouslySetInnerHtmlIgnore(lines, line - 1)) continue;
7182
7503
  if (isStructuredDataScript(content, match.index)) continue;
@@ -7273,7 +7594,28 @@ const PLACEHOLDER_EXACT = new Set([
7273
7594
  "todo",
7274
7595
  "replace_me"
7275
7596
  ]);
7597
+ const PLACEHOLDER_URL_PARTS = new Set([
7598
+ "example",
7599
+ "host",
7600
+ "localhost",
7601
+ "pass",
7602
+ "password",
7603
+ "pw",
7604
+ "user",
7605
+ "username"
7606
+ ]);
7607
+ const isPlaceholderCredentialUrl = (matchedText) => {
7608
+ const credentialMatch = matchedText.match(/^[a-z]+:\/\/([^:@/\s]+):([^@/\s]+)@/i);
7609
+ if (credentialMatch) return PLACEHOLDER_URL_PARTS.has(credentialMatch[1].toLowerCase()) && PLACEHOLDER_URL_PARTS.has(credentialMatch[2].toLowerCase());
7610
+ try {
7611
+ const parsed = new URL(matchedText);
7612
+ return PLACEHOLDER_URL_PARTS.has(parsed.username.toLowerCase()) && PLACEHOLDER_URL_PARTS.has(parsed.password.toLowerCase()) && PLACEHOLDER_URL_PARTS.has(parsed.hostname.toLowerCase());
7613
+ } catch {
7614
+ return false;
7615
+ }
7616
+ };
7276
7617
  const isPlaceholderValue = (matchedText) => {
7618
+ if (isPlaceholderCredentialUrl(matchedText)) return true;
7277
7619
  if (/env\(/i.test(matchedText)) return true;
7278
7620
  if (matchedText.includes("process.env")) return true;
7279
7621
  if (matchedText.includes("os.environ")) return true;
@@ -7394,23 +7736,36 @@ const STYLE_RULES = new Set([
7394
7736
  "complexity/function-too-long"
7395
7737
  ]);
7396
7738
  const STYLE_WEIGHT = .5;
7739
+ const COMMENT_STYLE_RULE_CAP = 12;
7740
+ const COMMENT_STYLE_RULES = new Set(["ai-slop/trivial-comment", "ai-slop/narrative-comment"]);
7397
7741
  const getEffectiveFileCount = (diagnostics, sourceFileCount) => {
7398
7742
  if (typeof sourceFileCount === "number" && sourceFileCount > 0) return sourceFileCount;
7399
7743
  const filesWithDiagnostics = new Set(diagnostics.map((d) => d.filePath)).size;
7400
7744
  return Math.max(1, filesWithDiagnostics);
7401
7745
  };
7402
- const calculateScore = (diagnostics, weights, thresholds, sourceFileCount, smoothing) => {
7746
+ const calculateScore = (diagnostics, weights, thresholds, sourceFileCount, smoothing, maxPerRule) => {
7403
7747
  if (diagnostics.length === 0) return {
7404
7748
  score: PERFECT_SCORE,
7405
7749
  label: "Healthy"
7406
7750
  };
7407
- let deductions = 0;
7751
+ const deductionsByRule = /* @__PURE__ */ new Map();
7408
7752
  for (const d of diagnostics) {
7409
7753
  const engineWeight = weights[d.engine] ?? 1;
7410
7754
  const severityPenalty = d.severity === "error" ? 3 : d.severity === "warning" ? 1 : .25;
7411
7755
  const styleFactor = STYLE_RULES.has(d.rule) ? STYLE_WEIGHT : 1;
7412
- deductions += severityPenalty * engineWeight * styleFactor;
7413
- }
7756
+ const key = `${d.engine}:${d.rule}`;
7757
+ deductionsByRule.set(key, (deductionsByRule.get(key) ?? 0) + severityPenalty * engineWeight * styleFactor);
7758
+ }
7759
+ const defaultRuleCap = typeof maxPerRule === "number" && maxPerRule > 0 ? maxPerRule : null;
7760
+ const capForRule = (key) => {
7761
+ const rule = key.slice(key.indexOf(":") + 1);
7762
+ if (COMMENT_STYLE_RULES.has(rule)) return defaultRuleCap ? Math.min(defaultRuleCap, COMMENT_STYLE_RULE_CAP) : COMMENT_STYLE_RULE_CAP;
7763
+ return defaultRuleCap;
7764
+ };
7765
+ const deductions = [...deductionsByRule.entries()].reduce((total, [key, value]) => {
7766
+ const cap = capForRule(key);
7767
+ return total + (cap ? Math.min(value, cap) : value);
7768
+ }, 0);
7414
7769
  const effectiveFileCount = getEffectiveFileCount(diagnostics, sourceFileCount);
7415
7770
  const smoothingConstant = typeof smoothing === "number" ? smoothing : 10;
7416
7771
  const issueDensity = Math.min(1, diagnostics.length / (effectiveFileCount + smoothingConstant));
@@ -7622,6 +7977,19 @@ const discoverProject = async (directory, excludePatterns = []) => {
7622
7977
  //#endregion
7623
7978
  //#region src/utils/git.ts
7624
7979
  const MAX_BUFFER = 50 * 1024 * 1024;
7980
+ const baseRefExists = (cwd, ref) => {
7981
+ const result = spawnSync("git", [
7982
+ "rev-parse",
7983
+ "--verify",
7984
+ "--quiet",
7985
+ `${ref}^{commit}`
7986
+ ], {
7987
+ cwd,
7988
+ encoding: "utf-8",
7989
+ maxBuffer: MAX_BUFFER
7990
+ });
7991
+ return !result.error && result.status === 0;
7992
+ };
7625
7993
  const getChangedFiles = (cwd, base) => {
7626
7994
  const diff = spawnSync("git", [
7627
7995
  "diff",
@@ -7707,7 +8075,7 @@ const runScopedScan = async (cwd, filePaths) => {
7707
8075
  architecture: config.engines.architecture,
7708
8076
  security: false
7709
8077
  })).flatMap((r) => r.diagnostics);
7710
- const { score } = calculateScore(diagnostics, config.scoring.weights, config.scoring.thresholds, project.sourceFileCount, config.scoring.smoothing);
8078
+ const { score } = calculateScore(diagnostics, config.scoring.weights, config.scoring.thresholds, project.sourceFileCount, config.scoring.smoothing, config.scoring.maxPerRule);
7711
8079
  return {
7712
8080
  diagnostics,
7713
8081
  score,
@@ -7790,10 +8158,10 @@ const captureBaseline = async (cwd) => {
7790
8158
  security: false
7791
8159
  });
7792
8160
  const diagnostics = results.flatMap((r) => r.diagnostics);
7793
- const { score } = calculateScore(diagnostics, config.scoring.weights, config.scoring.thresholds, project.sourceFileCount, config.scoring.smoothing);
8161
+ const { score } = calculateScore(diagnostics, config.scoring.weights, config.scoring.thresholds, project.sourceFileCount, config.scoring.smoothing, config.scoring.maxPerRule);
7794
8162
  const byEngine = {};
7795
8163
  for (const r of results) {
7796
- const { score: engineScore } = calculateScore(diagnostics.filter((d) => r.diagnostics.includes(d)), config.scoring.weights, config.scoring.thresholds, project.sourceFileCount, config.scoring.smoothing);
8164
+ const { score: engineScore } = calculateScore(diagnostics.filter((d) => r.diagnostics.includes(d)), config.scoring.weights, config.scoring.thresholds, project.sourceFileCount, config.scoring.smoothing, config.scoring.maxPerRule);
7797
8165
  byEngine[r.engine] = engineScore;
7798
8166
  }
7799
8167
  const findingFingerprints = diagnostics.filter((d) => d.severity === "error" || d.severity === "warning").map((d) => fingerprintDiagnostic(d, project.rootDirectory));
@@ -8972,6 +9340,40 @@ const detectInstalledAgents = (opts) => {
8972
9340
  return hits;
8973
9341
  };
8974
9342
 
9343
+ //#endregion
9344
+ //#region src/ui/symbols.ts
9345
+ const TTY = {
9346
+ stepActive: "◇",
9347
+ stepDone: "◆",
9348
+ rail: "│",
9349
+ railEnd: "└",
9350
+ bullet: "●",
9351
+ hint: "→",
9352
+ pass: "✓",
9353
+ fail: "✗",
9354
+ warn: "!",
9355
+ pending: "•",
9356
+ engineActive: "⏵",
9357
+ neutral: "─"
9358
+ };
9359
+ const PLAIN = {
9360
+ stepActive: "*",
9361
+ stepDone: "*",
9362
+ rail: "|",
9363
+ railEnd: "+",
9364
+ bullet: "-",
9365
+ hint: "->",
9366
+ pass: "[ok]",
9367
+ fail: "[x]",
9368
+ warn: "[!]",
9369
+ pending: "-",
9370
+ engineActive: ">",
9371
+ neutral: "-"
9372
+ };
9373
+ const createSymbols = (opts = {}) => opts.plain ? PLAIN : TTY;
9374
+ const isPlain = () => process.env.THEME === "plain" || Boolean(process.env.NO_COLOR) || !process.stdout.isTTY;
9375
+ const symbols = createSymbols({ plain: isPlain() });
9376
+
8975
9377
  //#endregion
8976
9378
  //#region src/ui/theme.ts
8977
9379
  const TRUECOLOR = {
@@ -9032,6 +9434,250 @@ const createTheme = (opts = {}) => {
9032
9434
  const style = (theme, token, text) => theme.paint[token](text);
9033
9435
  const theme = createTheme();
9034
9436
 
9437
+ //#endregion
9438
+ //#region src/ui/width.ts
9439
+ const ANSI_RE = new RegExp(`\\[[0-9;]*m`, "g");
9440
+ const stripAnsi = (s) => s.replace(ANSI_RE, "");
9441
+ const stringWidth = (s) => {
9442
+ const bare = stripAnsi(s);
9443
+ let total = 0;
9444
+ for (const ch of bare) {
9445
+ const w = wcwidth(ch.codePointAt(0) ?? 0);
9446
+ total += w > 0 ? w : 1;
9447
+ }
9448
+ return total;
9449
+ };
9450
+ const padEnd = (s, target, fill = " ") => {
9451
+ const w = stringWidth(s);
9452
+ if (w >= target) return s;
9453
+ return s + fill.repeat(target - w);
9454
+ };
9455
+ const padStart = (s, target, fill = " ") => {
9456
+ const w = stringWidth(s);
9457
+ if (w >= target) return s;
9458
+ return fill.repeat(target - w) + s;
9459
+ };
9460
+ const truncate = (s, max, ellipsis = "…") => {
9461
+ if (stringWidth(s) <= max) return s;
9462
+ const limit = Math.max(0, max - stringWidth(ellipsis));
9463
+ let out = "";
9464
+ let w = 0;
9465
+ for (const ch of s) {
9466
+ const cw = wcwidth(ch.codePointAt(0) ?? 0);
9467
+ if (w + cw > limit) break;
9468
+ out += ch;
9469
+ w += cw;
9470
+ }
9471
+ return out + ellipsis;
9472
+ };
9473
+
9474
+ //#endregion
9475
+ //#region src/ui/search-select.ts
9476
+ const silentOutput = new Writable({ write(_chunk, _encoding, callback) {
9477
+ callback();
9478
+ } });
9479
+ const filterSearchItems = (items, query) => {
9480
+ const q = query.trim().toLowerCase();
9481
+ if (q.length === 0) return items;
9482
+ return items.map((item, index) => {
9483
+ const label = item.label.toLowerCase();
9484
+ const value = String(item.value).toLowerCase();
9485
+ const hint = item.hint?.toLowerCase() ?? "";
9486
+ const keywords = (item.keywords ?? []).join(" ").toLowerCase();
9487
+ const haystack = [
9488
+ label,
9489
+ value,
9490
+ hint,
9491
+ keywords
9492
+ ].filter((v) => v.length > 0).join(" ");
9493
+ if (!q.split(/\s+/).every((part) => haystack.includes(part))) return null;
9494
+ let rank = 80;
9495
+ if (label === q || value === q) rank = 0;
9496
+ else if (label.startsWith(q) || value.startsWith(q)) rank = 10;
9497
+ else if (label.includes(q) || value.includes(q)) rank = 20;
9498
+ else if (keywords.includes(q)) rank = 40;
9499
+ else if (hint.includes(q)) rank = 60;
9500
+ return {
9501
+ item,
9502
+ index,
9503
+ rank
9504
+ };
9505
+ }).filter((entry) => entry !== null).sort((a, b) => {
9506
+ if (a.rank !== b.rank) return a.rank - b.rank;
9507
+ return a.index - b.index;
9508
+ }).map((entry) => entry.item);
9509
+ };
9510
+ const countRows = (lines, columns) => {
9511
+ const width = columns && columns > 0 ? columns : 80;
9512
+ return lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(stringWidth(line) / width)), 0);
9513
+ };
9514
+ const renderSearchLines = (options) => {
9515
+ const maxVisible = options.maxVisible ?? 8;
9516
+ const filtered = filterSearchItems(options.items, options.query);
9517
+ const cursor = Math.max(0, Math.min(options.cursor, Math.max(0, filtered.length - 1)));
9518
+ const start = Math.max(0, Math.min(cursor - Math.floor(maxVisible / 2), filtered.length - maxVisible));
9519
+ const visible = filtered.slice(start, start + maxVisible);
9520
+ const lines = [];
9521
+ const marker = options.state === "cancel" ? style(theme, "danger", symbols.fail) : options.state === "submit" ? style(theme, "success", symbols.stepDone) : style(theme, "accent", symbols.stepActive);
9522
+ lines.push(` ${marker} ${style(theme, "bold", options.message)}`);
9523
+ if (options.state === "cancel") {
9524
+ lines.push(` ${style(theme, "muted", symbols.rail)} ${style(theme, "muted", "Cancelled")}`);
9525
+ return lines;
9526
+ }
9527
+ if (options.state === "submit") {
9528
+ const selected = options.items.filter((item) => options.selected.has(item.value));
9529
+ const label = selected.length > 0 ? selected.map((item) => item.label).join(", ") : "No selection";
9530
+ lines.push(` ${style(theme, "muted", symbols.rail)} ${style(theme, "muted", label)}`);
9531
+ return lines;
9532
+ }
9533
+ lines.push(` ${style(theme, "muted", symbols.rail)} ${style(theme, "muted", "Search:")} ${options.query}${style(theme, "dim", "_")}`);
9534
+ lines.push(` ${style(theme, "muted", symbols.rail)} ${style(theme, "muted", options.mode === "multi" ? "type to filter, arrows move, space toggles, enter confirms" : "type to filter, arrows move, enter selects")}`);
9535
+ lines.push(` ${style(theme, "muted", symbols.rail)}`);
9536
+ if (visible.length === 0) lines.push(` ${style(theme, "muted", symbols.rail)} ${style(theme, "muted", "No matches")}`);
9537
+ else for (const [offset, item] of visible.entries()) {
9538
+ const active = start + offset === cursor;
9539
+ const selected = options.selected.has(item.value);
9540
+ const pointer = active ? style(theme, "info", symbols.engineActive) : " ";
9541
+ const radio = options.mode === "multi" ? selected ? style(theme, "success", symbols.pass) : style(theme, "muted", symbols.pending) : active ? style(theme, "accent", symbols.bullet) : style(theme, "muted", symbols.pending);
9542
+ const label = active ? style(theme, "bold", item.label) : item.label;
9543
+ const hint = item.hint ? ` ${style(theme, "muted", truncate(item.hint, 72))}` : "";
9544
+ lines.push(` ${style(theme, "muted", symbols.rail)} ${pointer} ${radio} ${label}${hint}`);
9545
+ }
9546
+ const hiddenBefore = start;
9547
+ const hiddenAfter = Math.max(0, filtered.length - (start + visible.length));
9548
+ if (hiddenBefore > 0 || hiddenAfter > 0) {
9549
+ const parts = [];
9550
+ if (hiddenBefore > 0) parts.push(`up ${hiddenBefore} more`);
9551
+ if (hiddenAfter > 0) parts.push(`down ${hiddenAfter} more`);
9552
+ lines.push(` ${style(theme, "muted", symbols.rail)} ${style(theme, "muted", parts.join(" · "))}`);
9553
+ }
9554
+ if (options.mode === "multi") {
9555
+ const picked = options.items.filter((item) => options.selected.has(item.value));
9556
+ const summary = picked.length === 0 ? "Selected: none" : picked.length <= 3 ? `Selected: ${picked.map((item) => item.label).join(", ")}` : `Selected: ${picked.slice(0, 3).map((item) => item.label).join(", ")} +${picked.length - 3} more`;
9557
+ lines.push(` ${style(theme, "muted", symbols.rail)}`);
9558
+ lines.push(` ${style(theme, "muted", symbols.rail)} ${style(theme, "success", summary)}`);
9559
+ }
9560
+ lines.push(` ${style(theme, "muted", symbols.railEnd)}`);
9561
+ return lines;
9562
+ };
9563
+ const runSearchPrompt = async (options) => {
9564
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return options.mode === "multi" ? options.initialSelected ?? [] : null;
9565
+ return new Promise((resolve) => {
9566
+ const rl = readline.createInterface({
9567
+ input: process.stdin,
9568
+ output: silentOutput,
9569
+ terminal: false
9570
+ });
9571
+ readline.emitKeypressEvents(process.stdin, rl);
9572
+ process.stdin.setRawMode(true);
9573
+ let query = "";
9574
+ let cursor = 0;
9575
+ let lastRows = 0;
9576
+ const selected = new Set(options.initialSelected ?? []);
9577
+ const clear = () => {
9578
+ if (lastRows === 0) return;
9579
+ process.stdout.write(`\x1b[${lastRows}A`);
9580
+ for (let i = 0; i < lastRows; i++) process.stdout.write("\x1B[2K\x1B[1B");
9581
+ process.stdout.write(`\x1b[${lastRows}A`);
9582
+ };
9583
+ const render = (state = "active") => {
9584
+ clear();
9585
+ const lines = renderSearchLines({
9586
+ ...options,
9587
+ query,
9588
+ cursor,
9589
+ selected,
9590
+ state
9591
+ });
9592
+ process.stdout.write(`${lines.join("\n")}\n`);
9593
+ lastRows = countRows(lines, process.stdout.columns);
9594
+ };
9595
+ const cleanup = () => {
9596
+ process.stdin.removeListener("keypress", onKeypress);
9597
+ process.stdin.setRawMode(false);
9598
+ rl.close();
9599
+ };
9600
+ const submit = () => {
9601
+ const item = filterSearchItems(options.items, query)[cursor];
9602
+ if (options.mode === "single") {
9603
+ if (!item) {
9604
+ if (options.required) return;
9605
+ render("cancel");
9606
+ cleanup();
9607
+ resolve(null);
9608
+ return;
9609
+ }
9610
+ selected.clear();
9611
+ selected.add(item.value);
9612
+ render("submit");
9613
+ cleanup();
9614
+ resolve(item.value);
9615
+ return;
9616
+ }
9617
+ if (options.required && selected.size === 0) return;
9618
+ render("submit");
9619
+ cleanup();
9620
+ resolve([...selected]);
9621
+ };
9622
+ const cancel = () => {
9623
+ render("cancel");
9624
+ cleanup();
9625
+ resolve(null);
9626
+ };
9627
+ const onKeypress = (_str, key) => {
9628
+ if (!key) return;
9629
+ const filtered = filterSearchItems(options.items, query);
9630
+ if (key.name === "return") {
9631
+ submit();
9632
+ return;
9633
+ }
9634
+ if (key.name === "escape" || key.ctrl && key.name === "c") {
9635
+ cancel();
9636
+ return;
9637
+ }
9638
+ if (key.name === "up") {
9639
+ cursor = Math.max(0, cursor - 1);
9640
+ render();
9641
+ return;
9642
+ }
9643
+ if (key.name === "down") {
9644
+ cursor = Math.min(Math.max(0, filtered.length - 1), cursor + 1);
9645
+ render();
9646
+ return;
9647
+ }
9648
+ if (key.name === "space" && options.mode === "multi") {
9649
+ const item = filtered[cursor];
9650
+ if (item) if (selected.has(item.value)) selected.delete(item.value);
9651
+ else selected.add(item.value);
9652
+ render();
9653
+ return;
9654
+ }
9655
+ if (key.name === "backspace") {
9656
+ query = query.slice(0, -1);
9657
+ cursor = 0;
9658
+ render();
9659
+ return;
9660
+ }
9661
+ if (key.sequence && !key.ctrl && !key.meta && key.sequence.length === 1) {
9662
+ query += key.sequence;
9663
+ cursor = 0;
9664
+ render();
9665
+ }
9666
+ };
9667
+ process.stdin.on("keypress", onKeypress);
9668
+ render();
9669
+ });
9670
+ };
9671
+ const searchSelect = async (options) => await runSearchPrompt({
9672
+ ...options,
9673
+ mode: "single"
9674
+ });
9675
+ const searchMultiselect = async (options) => await runSearchPrompt({
9676
+ ...options,
9677
+ mode: "multi",
9678
+ initialSelected: options.initialSelected
9679
+ });
9680
+
9035
9681
  //#endregion
9036
9682
  //#region src/commands/hook.ts
9037
9683
  const HOOK_FLUSH_TIMEOUT_MS = 1500;
@@ -9202,18 +9848,17 @@ const promptAgentSelection = async (mode, deps = {}) => {
9202
9848
  const pool = mode === "uninstall" ? installed : ALL_AGENTS;
9203
9849
  if (pool.length === 0) return [];
9204
9850
  const preChecked = mode === "uninstall" ? installed : AGENTS_SUPPORTING_BOTH_SCOPES;
9205
- const choice = await multiselect({
9851
+ return await searchMultiselect({
9206
9852
  message: mode === "install" ? "Which agents should get aislop hooks?" : "Which agent hooks should be removed?",
9207
- options: pool.map((a) => ({
9853
+ items: pool.map((a) => ({
9208
9854
  value: a,
9209
9855
  label: AGENT_LABELS[a].label,
9210
- hint: AGENT_LABELS[a].hint
9856
+ hint: AGENT_LABELS[a].hint,
9857
+ keywords: [a]
9211
9858
  })),
9212
- initialValues: preChecked.filter((a) => pool.includes(a)),
9859
+ initialSelected: preChecked.filter((a) => pool.includes(a)),
9213
9860
  required: false
9214
9861
  });
9215
- if (isCancel(choice)) return null;
9216
- return choice;
9217
9862
  };
9218
9863
 
9219
9864
  //#endregion
@@ -9272,50 +9917,58 @@ const pickAgents = async (mode, opts, positional) => {
9272
9917
  if (!process.stdin.isTTY) return defaultInstallTargets();
9273
9918
  return mode === "uninstall" ? promptForUninstall() : promptForInstall();
9274
9919
  };
9275
- const registerInstall = (hook) => {
9276
- const install = hook.command("install [agents...]").description("Install aislop hooks for one or more coding agents. Agents can be passed as positional args (aislop hook install claude cursor), per-agent flags (--claude), or via --agent. Default: every supported agent.").option("--agent <names>", "comma-separated agent list (claude,cursor,gemini,codex,windsurf,cline,kilocode,antigravity,copilot)").option("-g, --global", "install to the user-scope config (default for agents that support it)").option("--project", "install to the project-scope config").option("--dry-run", "print the planned diff without writing").option("--yes", "skip the confirmation prompt (reserved)").option("--quality-gate", "add a Stop hook that blocks when score regresses below baseline (Claude only)");
9277
- for (const a of AGENT_NAMES) install.option(`--${a}`, `shortcut for --agent ${a}`);
9278
- install.action(async (positional, opts) => {
9279
- const agents = await pickAgents("install", opts, positional);
9280
- if (agents === null || agents.length === 0) return;
9281
- await withCommandLifecycle({
9282
- command: "hook_install",
9283
- config: loadConfig(process.cwd()).telemetry
9284
- }, async () => {
9285
- await hookInstall({
9286
- agents,
9287
- scope: resolveScope(opts),
9288
- dryRun: Boolean(opts.dryRun),
9289
- yes: Boolean(opts.yes),
9290
- qualityGate: Boolean(opts.qualityGate)
9291
- });
9292
- return { exitCode: 0 };
9920
+ const addAgentShortcutOptions = (command) => {
9921
+ for (const a of AGENT_NAMES) command.option(`--${a}`, `shortcut for --agent ${a}`);
9922
+ return command;
9923
+ };
9924
+ const addInstallOptions = (command) => addAgentShortcutOptions(command.option("--agent <names>", "comma-separated agent list (claude,cursor,gemini,codex,windsurf,cline,kilocode,antigravity,copilot)").option("-g, --global", "install to the user-scope config (default)").option("--project", "install to the project-scope config").option("--dry-run", "print the planned diff without writing").option("--yes", "skip the confirmation prompt (reserved)").option("--quality-gate", "add a Stop hook that blocks when score regresses below baseline (Claude only)"));
9925
+ const addUninstallOptions = (command) => addAgentShortcutOptions(command.option("--agent <names>", "comma-separated agent list").option("-g, --global", "uninstall from user-scope config").option("--project", "uninstall from project-scope config").option("--dry-run", "print the planned removal without writing"));
9926
+ const runInstallAction = async (positional, opts) => {
9927
+ const agents = await pickAgents("install", opts, positional);
9928
+ if (agents === null || agents.length === 0) return;
9929
+ await withCommandLifecycle({
9930
+ command: "hook_install",
9931
+ config: loadConfig(process.cwd()).telemetry
9932
+ }, async () => {
9933
+ await hookInstall({
9934
+ agents,
9935
+ scope: resolveScope(opts),
9936
+ dryRun: Boolean(opts.dryRun),
9937
+ yes: Boolean(opts.yes),
9938
+ qualityGate: Boolean(opts.qualityGate)
9293
9939
  });
9940
+ return { exitCode: 0 };
9294
9941
  });
9295
9942
  };
9296
- const registerUninstall = (hook) => {
9297
- const uninstall = hook.command("uninstall [agents...]").description("Uninstall aislop hooks for one or more coding agents. Accepts positional args, per-agent flags (--claude), or --agent. Default: every supported agent.").option("--agent <names>", "comma-separated agent list").option("-g, --global", "uninstall from user-scope config").option("--project", "uninstall from project-scope config").option("--dry-run", "print the planned removal without writing");
9298
- for (const a of AGENT_NAMES) uninstall.option(`--${a}`, `shortcut for --agent ${a}`);
9299
- uninstall.action(async (positional, opts) => {
9300
- const agents = await pickAgents("uninstall", opts, positional);
9301
- if (agents === null || agents.length === 0) return;
9302
- await withCommandLifecycle({
9303
- command: "hook_uninstall",
9304
- config: loadConfig(process.cwd()).telemetry
9305
- }, async () => {
9306
- await hookUninstall({
9307
- agents,
9308
- scope: resolveScope(opts),
9309
- dryRun: Boolean(opts.dryRun),
9310
- yes: true,
9311
- qualityGate: false
9312
- });
9313
- return { exitCode: 0 };
9943
+ const runUninstallAction = async (positional, opts) => {
9944
+ const agents = await pickAgents("uninstall", opts, positional);
9945
+ if (agents === null || agents.length === 0) return;
9946
+ await withCommandLifecycle({
9947
+ command: "hook_uninstall",
9948
+ config: loadConfig(process.cwd()).telemetry
9949
+ }, async () => {
9950
+ await hookUninstall({
9951
+ agents,
9952
+ scope: resolveScope(opts),
9953
+ dryRun: Boolean(opts.dryRun),
9954
+ yes: true,
9955
+ qualityGate: false
9314
9956
  });
9957
+ return { exitCode: 0 };
9315
9958
  });
9316
9959
  };
9960
+ const normalizeHookAliasAgents = (agents) => {
9961
+ const [first, ...rest] = agents;
9962
+ return first === "hook" || first === "hooks" ? rest : agents;
9963
+ };
9964
+ const registerInstall = (hook) => {
9965
+ addInstallOptions(hook.command("install [agents...]").description("Install hooks for one or more coding agents. Use positional agents, per-agent flags, or --agent.")).action(runInstallAction);
9966
+ };
9967
+ const registerUninstall = (hook) => {
9968
+ addUninstallOptions(hook.command("uninstall [agents...]").description("Remove hooks for one or more coding agents. Use positional agents, per-agent flags, or --agent.")).action(runUninstallAction);
9969
+ };
9317
9970
  const registerCallbacks = (hook) => {
9318
- hook.command("status").description("Show which agent hooks are installed").action(async () => {
9971
+ hook.command("status").description("Show installed agent hooks").action(async () => {
9319
9972
  await withCommandLifecycle({
9320
9973
  command: "hook_status",
9321
9974
  config: loadConfig(process.cwd()).telemetry
@@ -9324,7 +9977,7 @@ const registerCallbacks = (hook) => {
9324
9977
  return { exitCode: 0 };
9325
9978
  });
9326
9979
  });
9327
- hook.command("baseline").description("Capture the current project score as the quality-gate baseline").action(async () => {
9980
+ hook.command("baseline").description("Capture the current score as the hook baseline").action(async () => {
9328
9981
  await withCommandLifecycle({
9329
9982
  command: "hook_baseline",
9330
9983
  config: loadConfig(process.cwd()).telemetry
@@ -9333,28 +9986,36 @@ const registerCallbacks = (hook) => {
9333
9986
  return { exitCode: 0 };
9334
9987
  });
9335
9988
  });
9336
- hook.command("claude").description("Internal: Claude Code PostToolUse / Stop / FileChanged callback (reads stdin)").option("--stop", "run in Stop-hook mode for the quality gate").option("--on-file-changed", "run in FileChanged mode (refresh baseline on watched file change)").action(async (opts) => {
9989
+ hook.command("claude", { hidden: true }).description("Internal: Claude Code PostToolUse / Stop / FileChanged callback (reads stdin)").option("--stop", "run in Stop-hook mode for the quality gate").option("--on-file-changed", "run in FileChanged mode (refresh baseline on watched file change)").action(async (opts) => {
9337
9990
  await hookRun("claude", {
9338
9991
  stop: Boolean(opts.stop),
9339
9992
  onFileChanged: Boolean(opts.onFileChanged)
9340
9993
  });
9341
9994
  });
9342
- hook.command("cursor").description("Internal: Cursor afterFileEdit callback (reads stdin)").action(async () => {
9995
+ hook.command("cursor", { hidden: true }).description("Internal: Cursor afterFileEdit callback (reads stdin)").action(async () => {
9343
9996
  await hookRun("cursor");
9344
9997
  });
9345
- hook.command("gemini").description("Internal: Gemini CLI AfterTool callback (reads stdin)").action(async () => {
9998
+ hook.command("gemini", { hidden: true }).description("Internal: Gemini CLI AfterTool callback (reads stdin)").action(async () => {
9346
9999
  await hookRun("gemini");
9347
10000
  });
9348
- hook.command("pi").description("Internal: pi extension tool_result callback (reads stdin)").action(async () => {
10001
+ hook.command("pi", { hidden: true }).description("Internal: pi extension tool_result callback (reads stdin)").action(async () => {
9349
10002
  await hookRun("pi");
9350
10003
  });
9351
10004
  };
9352
10005
  const registerHookCommand = (program) => {
9353
- const hook = program.command("hook").description("Install or invoke AI-agent integration hooks");
10006
+ const hook = program.command("hook").alias("hooks").description("Manage per-edit coding-agent hooks");
9354
10007
  registerInstall(hook);
9355
10008
  registerUninstall(hook);
9356
10009
  registerCallbacks(hook);
9357
10010
  };
10011
+ const registerHookAliases = (program) => {
10012
+ addInstallOptions(program.command("install [agents...]").description("Install coding-agent hooks (alias: hook install)")).action(async (agents, opts) => {
10013
+ await runInstallAction(normalizeHookAliasAgents(agents), opts);
10014
+ });
10015
+ addUninstallOptions(program.command("uninstall [agents...]").description("Remove coding-agent hooks (alias: hook uninstall)")).action(async (agents, opts) => {
10016
+ await runUninstallAction(normalizeHookAliasAgents(agents), opts);
10017
+ });
10018
+ };
9358
10019
 
9359
10020
  //#endregion
9360
10021
  //#region src/commands/badge.ts
@@ -9433,40 +10094,6 @@ const badgeCommand = async (options = {}) => {
9433
10094
  };
9434
10095
  };
9435
10096
 
9436
- //#endregion
9437
- //#region src/ui/symbols.ts
9438
- const TTY = {
9439
- stepActive: "◇",
9440
- stepDone: "◆",
9441
- rail: "│",
9442
- railEnd: "└",
9443
- bullet: "●",
9444
- hint: "→",
9445
- pass: "✓",
9446
- fail: "✗",
9447
- warn: "!",
9448
- pending: "•",
9449
- engineActive: "⏵",
9450
- neutral: "─"
9451
- };
9452
- const PLAIN = {
9453
- stepActive: "*",
9454
- stepDone: "*",
9455
- rail: "|",
9456
- railEnd: "+",
9457
- bullet: "-",
9458
- hint: "->",
9459
- pass: "[ok]",
9460
- fail: "[x]",
9461
- warn: "[!]",
9462
- pending: "-",
9463
- engineActive: ">",
9464
- neutral: "-"
9465
- };
9466
- const createSymbols = (opts = {}) => opts.plain ? PLAIN : TTY;
9467
- const isPlain = () => process.env.THEME === "plain" || Boolean(process.env.NO_COLOR) || !process.stdout.isTTY;
9468
- const symbols = createSymbols({ plain: isPlain() });
9469
-
9470
10097
  //#endregion
9471
10098
  //#region src/ui/error.ts
9472
10099
  const renderError = (input, deps = {}) => {
@@ -9721,30 +10348,6 @@ const renderHeader = (input, _deps = {}) => {
9721
10348
  return subLine ? `${brandLine}\n\n${subLine}\n\n` : `${brandLine}\n\n`;
9722
10349
  };
9723
10350
 
9724
- //#endregion
9725
- //#region src/ui/width.ts
9726
- const ANSI_RE = new RegExp(`\\[[0-9;]*m`, "g");
9727
- const stripAnsi = (s) => s.replace(ANSI_RE, "");
9728
- const stringWidth = (s) => {
9729
- const bare = stripAnsi(s);
9730
- let total = 0;
9731
- for (const ch of bare) {
9732
- const w = wcwidth(ch.codePointAt(0) ?? 0);
9733
- total += w > 0 ? w : 1;
9734
- }
9735
- return total;
9736
- };
9737
- const padEnd = (s, target, fill = " ") => {
9738
- const w = stringWidth(s);
9739
- if (w >= target) return s;
9740
- return s + fill.repeat(target - w);
9741
- };
9742
- const padStart = (s, target, fill = " ") => {
9743
- const w = stringWidth(s);
9744
- if (w >= target) return s;
9745
- return fill.repeat(target - w) + s;
9746
- };
9747
-
9748
10351
  //#endregion
9749
10352
  //#region src/ui/live-grid.ts
9750
10353
  const SPINNER = [
@@ -9862,6 +10465,199 @@ var LiveGrid = class {
9862
10465
  }
9863
10466
  };
9864
10467
 
10468
+ //#endregion
10469
+ //#region src/utils/history.ts
10470
+ const HISTORY_FILE = "history.jsonl";
10471
+ const isHistoryDisabled = (env = process.env) => env.AISLOP_NO_HISTORY === "1";
10472
+ const historyPath = (directory) => path.join(path.resolve(directory), CONFIG_DIR, HISTORY_FILE);
10473
+ /**
10474
+ * Append a compact scan record to .aislop/history.jsonl. Best-effort: never
10475
+ * throws, so a read-only checkout or missing config dir can't break a scan.
10476
+ */
10477
+ const appendHistory = (input) => {
10478
+ if (isHistoryDisabled()) return;
10479
+ const configDir = path.join(path.resolve(input.directory), CONFIG_DIR);
10480
+ if (!fs.existsSync(configDir)) return;
10481
+ const record = {
10482
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
10483
+ score: input.score,
10484
+ errors: input.errors,
10485
+ warnings: input.warnings,
10486
+ files: input.files,
10487
+ cliVersion: APP_VERSION
10488
+ };
10489
+ try {
10490
+ fs.appendFileSync(historyPath(input.directory), `${JSON.stringify(record)}\n`);
10491
+ } catch {}
10492
+ };
10493
+ const isHistoryRecord = (value) => {
10494
+ if (!value || typeof value !== "object") return false;
10495
+ const record = value;
10496
+ 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";
10497
+ };
10498
+ const readHistory = (directory) => {
10499
+ const file = historyPath(directory);
10500
+ if (!fs.existsSync(file)) return [];
10501
+ const records = [];
10502
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
10503
+ const trimmed = line.trim();
10504
+ if (!trimmed) continue;
10505
+ try {
10506
+ const parsed = JSON.parse(trimmed);
10507
+ if (isHistoryRecord(parsed)) records.push(parsed);
10508
+ } catch {}
10509
+ }
10510
+ return records;
10511
+ };
10512
+
10513
+ //#endregion
10514
+ //#region src/commands/scan-coverage.ts
10515
+ const coverageReason = (c) => {
10516
+ if (c.supportedFiles === 0 && c.dominantUnsupported) return `This repository is ${c.dominantUnsupported} (${c.unsupportedFiles} files), which aislop does not analyze. No score — it would not reflect this code.`;
10517
+ if (c.supportedFiles === 0) return "No files in a language aislop analyzes (TypeScript, JavaScript, Python, Go, Rust, Ruby, PHP, Java). Nothing to score.";
10518
+ const lang = c.dominantUnsupported ?? "an unsupported language";
10519
+ const files = `${c.supportedFiles} supported file${c.supportedFiles === 1 ? "" : "s"}`;
10520
+ return `This repository is mostly ${lang} (${c.unsupportedFiles} files); aislop analyzed only ${files}. Score withheld — it would represent a sliver of the codebase.`;
10521
+ };
10522
+ const renderCoverageNotice = (projectInfo, includeHeader) => {
10523
+ const deps = {
10524
+ theme: createTheme(),
10525
+ symbols: createSymbols({ plain: false })
10526
+ };
10527
+ return `${includeHeader === false ? "" : renderHeader({
10528
+ version: APP_VERSION,
10529
+ command: "Scan result",
10530
+ context: [
10531
+ projectInfo.projectName,
10532
+ projectInfo.languages[0] ?? "unknown",
10533
+ `${projectInfo.sourceFileCount} files`
10534
+ ],
10535
+ brand: true
10536
+ }, deps)} ${coverageReason(projectInfo.coverage)}\n\n`;
10537
+ };
10538
+
10539
+ //#endregion
10540
+ //#region src/commands/scan-exit-code.ts
10541
+ const computeScanExitCode = (opts) => opts.hasErrors || opts.scoreable && opts.score < opts.failBelow ? 1 : 0;
10542
+
10543
+ //#endregion
10544
+ //#region src/output/finding-assessment.ts
10545
+ const KNIP_FORCE_RULES = new Set([
10546
+ "knip/files",
10547
+ "knip/dependencies",
10548
+ "knip/devDependencies"
10549
+ ]);
10550
+ const isForceFixable = (diagnostic) => {
10551
+ if (diagnostic.fixable) return false;
10552
+ if (KNIP_FORCE_RULES.has(diagnostic.rule)) return true;
10553
+ if (diagnostic.rule === "security/vulnerable-dependency") return diagnostic.detail === "npm" || diagnostic.detail === "pnpm";
10554
+ if (diagnostic.rule.startsWith("expo-doctor/")) return diagnostic.rule !== "expo-doctor/config-error";
10555
+ return false;
10556
+ };
10557
+ const FINDING_KIND_LABELS = {
10558
+ "confirmed-defect": "confirmed defects",
10559
+ "conservative-security": "conservative security",
10560
+ "style-policy": "style/policy",
10561
+ "ai-slop-indicator": "AI-slop indicators"
10562
+ };
10563
+ const STYLE_POLICY_RULES = new Set([
10564
+ "ai-slop/trivial-comment",
10565
+ "ai-slop/narrative-comment",
10566
+ "ai-slop/meta-comment",
10567
+ "ai-slop/console-leftover",
10568
+ "ai-slop/ts-directive",
10569
+ "complexity/file-too-large",
10570
+ "complexity/function-too-long",
10571
+ "complexity/deep-nesting",
10572
+ "complexity/too-many-params",
10573
+ "code-quality/duplicate-block",
10574
+ "eslint/no-empty",
10575
+ "eslint/no-unused-vars",
10576
+ "eslint/no-useless-escape",
10577
+ "eslint/no-unused-expressions",
10578
+ "unicorn/no-useless-fallback-in-spread",
10579
+ "unicorn/prefer-string-starts-ends-with",
10580
+ "unicorn/no-new-array",
10581
+ "unicorn/no-useless-spread"
10582
+ ]);
10583
+ const CONFIRMED_DEFECT_RULES = new Set([
10584
+ "ai-slop/hallucinated-import",
10585
+ "eslint/no-undef",
10586
+ "eslint/no-unreachable",
10587
+ "security/vulnerable-dependency"
10588
+ ]);
10589
+ const LOW_CONFIDENCE_SECURITY_RULES = new Set(["security/innerhtml", "security/dangerously-set-innerhtml"]);
10590
+ const confidenceFor = (diagnostic, kind) => {
10591
+ if (kind === "confirmed-defect") return "high";
10592
+ if (kind === "style-policy") return "medium";
10593
+ if (kind === "conservative-security") {
10594
+ if (LOW_CONFIDENCE_SECURITY_RULES.has(diagnostic.rule)) return "medium";
10595
+ return diagnostic.severity === "error" ? "high" : "medium";
10596
+ }
10597
+ return diagnostic.severity === "error" ? "high" : "medium";
10598
+ };
10599
+ const classifyKind = (diagnostic) => {
10600
+ if (CONFIRMED_DEFECT_RULES.has(diagnostic.rule)) return "confirmed-defect";
10601
+ if (diagnostic.engine === "security") return "conservative-security";
10602
+ if (STYLE_POLICY_RULES.has(diagnostic.rule)) return "style-policy";
10603
+ if (diagnostic.engine === "format" || diagnostic.engine === "code-quality") return "style-policy";
10604
+ if (diagnostic.engine === "ai-slop") return "ai-slop-indicator";
10605
+ if (diagnostic.severity === "error") return "confirmed-defect";
10606
+ return "style-policy";
10607
+ };
10608
+ const assessDiagnostic = (diagnostic) => {
10609
+ const kind = classifyKind(diagnostic);
10610
+ return {
10611
+ kind,
10612
+ confidence: confidenceFor(diagnostic, kind),
10613
+ label: FINDING_KIND_LABELS[kind]
10614
+ };
10615
+ };
10616
+ const withFindingAssessments = (diagnostics) => diagnostics.map((diagnostic) => ({
10617
+ ...diagnostic,
10618
+ assessment: assessDiagnostic(diagnostic),
10619
+ forceFixable: isForceFixable(diagnostic)
10620
+ }));
10621
+ const summarizeFindingAssessments = (diagnostics) => {
10622
+ const byKind = {
10623
+ "confirmed-defect": 0,
10624
+ "conservative-security": 0,
10625
+ "style-policy": 0,
10626
+ "ai-slop-indicator": 0
10627
+ };
10628
+ const byConfidence = {
10629
+ high: 0,
10630
+ medium: 0,
10631
+ low: 0
10632
+ };
10633
+ const rows = /* @__PURE__ */ new Map();
10634
+ for (const diagnostic of diagnostics) {
10635
+ const assessment = assessDiagnostic(diagnostic);
10636
+ byKind[assessment.kind]++;
10637
+ byConfidence[assessment.confidence]++;
10638
+ const row = rows.get(assessment.kind) ?? {
10639
+ kind: assessment.kind,
10640
+ label: assessment.label,
10641
+ count: 0,
10642
+ errors: 0,
10643
+ warnings: 0,
10644
+ info: 0,
10645
+ fixable: 0
10646
+ };
10647
+ row.count++;
10648
+ if (diagnostic.severity === "error") row.errors++;
10649
+ else if (diagnostic.severity === "warning") row.warnings++;
10650
+ else row.info++;
10651
+ if (diagnostic.fixable) row.fixable++;
10652
+ rows.set(assessment.kind, row);
10653
+ }
10654
+ return {
10655
+ rows: [...rows.values()].sort((a, b) => b.count - a.count),
10656
+ byKind,
10657
+ byConfidence
10658
+ };
10659
+ };
10660
+
9865
10661
  //#endregion
9866
10662
  //#region src/output/rule-labels.ts
9867
10663
  const RULE_LABELS = {
@@ -9946,11 +10742,85 @@ const RULE_LABELS = {
9946
10742
  "unicorn/no-useless-spread": "Useless spread",
9947
10743
  "unicorn/no-single-promise-in-promise-methods": "Single-element Promise.all"
9948
10744
  };
10745
+ const RULE_DESCRIPTIONS = {
10746
+ formatting: "File needs standard formatter output.",
10747
+ "code-quality/duplicate-block": "Large repeated code block should be shared or simplified.",
10748
+ "code-quality/repeated-chained-call": "Same chained call is repeated instead of stored once.",
10749
+ "code-quality/unused-declaration": "Declared symbol is not referenced.",
10750
+ "complexity/file-too-large": "File is large enough to be hard to review safely.",
10751
+ "complexity/function-too-long": "Function is doing too much in one body.",
10752
+ "complexity/deep-nesting": "Nested branches make the path hard to follow.",
10753
+ "complexity/too-many-params": "Function takes more arguments than readers can track.",
10754
+ "knip/files": "Source file is not imported or referenced.",
10755
+ "knip/dependencies": "Production dependency is listed but unused.",
10756
+ "knip/devDependencies": "Dev dependency is listed but unused.",
10757
+ "knip/unlisted": "Code imports a package missing from package.json.",
10758
+ "knip/unresolved": "Import cannot be resolved from the project.",
10759
+ "knip/binaries": "Package binary is listed but unused.",
10760
+ "knip/exports": "Exported value is not imported anywhere.",
10761
+ "knip/types": "Exported type is not imported anywhere.",
10762
+ "knip/duplicates": "Same export is declared more than once.",
10763
+ "ai-slop/trivial-comment": "Comment repeats obvious code instead of explaining intent.",
10764
+ "ai-slop/swallowed-exception": "Catch block hides an error without handling it.",
10765
+ "ai-slop/silent-recovery": "Error path logs or defaults, then continues as if safe.",
10766
+ "ai-slop/meta-comment": "Comment describes editing steps, plans, or generated-code process.",
10767
+ "ai-slop/redundant-try-catch": "try/catch only rethrows or adds no useful handling.",
10768
+ "ai-slop/redundant-type-coercion": "Conversion does not change the value meaningfully.",
10769
+ "ai-slop/duplicate-type-declaration": "Same exported type shape appears more than once.",
10770
+ "ai-slop/thin-wrapper": "Wrapper function adds no behavior or clearer contract.",
10771
+ "ai-slop/generic-naming": "Name is too vague to explain its role.",
10772
+ "ai-slop/unused-import": "Imported symbol is never used.",
10773
+ "ai-slop/console-leftover": "console/debug output was left in application code.",
10774
+ "ai-slop/todo-stub": "TODO/FIXME/stub marks unfinished behavior.",
10775
+ "ai-slop/unreachable-code": "Code path cannot execute.",
10776
+ "ai-slop/constant-condition": "Condition is always true or always false.",
10777
+ "ai-slop/empty-function": "Function body is empty or placeholder-only.",
10778
+ "ai-slop/unsafe-type-assertion": "Type assertion bypasses useful checking.",
10779
+ "ai-slop/double-type-assertion": "Value is cast through unknown/any to force a type.",
10780
+ "ai-slop/ts-directive": "TypeScript error is suppressed with a directive.",
10781
+ "ai-slop/narrative-comment": "Comment narrates implementation instead of adding context.",
10782
+ "ai-slop/duplicate-import": "Same module is imported more than once.",
10783
+ "ai-slop/hardcoded-url": "URL-like value is embedded directly in code.",
10784
+ "ai-slop/hardcoded-id": "Provider/account/test ID is embedded directly in code.",
10785
+ "ai-slop/python-bare-except": "Bare except catches everything, including system exits.",
10786
+ "ai-slop/python-broad-except": "Broad exception catch hides specific failure modes.",
10787
+ "ai-slop/python-mutable-default": "Mutable default argument is shared across calls.",
10788
+ "ai-slop/python-print-debug": "print/debug output was left in Python source.",
10789
+ "ai-slop/python-range-len-loop": "Loop uses indexes where direct iteration is clearer.",
10790
+ "ai-slop/python-chained-dict-get": "Nested get chain hides shape assumptions.",
10791
+ "ai-slop/python-repetitive-dispatch": "Repeated if/elif dispatch should be table-driven.",
10792
+ "ai-slop/python-isinstance-ladder": "Long isinstance ladder is brittle polymorphism.",
10793
+ "ai-slop/go-library-panic": "Library code panics instead of returning an error.",
10794
+ "ai-slop/rust-non-test-unwrap": "Production Rust uses unwrap instead of handling failure.",
10795
+ "ai-slop/rust-todo-stub": "Rust todo!/unimplemented! leaves behavior unfinished.",
10796
+ "ai-slop/hallucinated-import": "Import names a package not declared by the project.",
10797
+ "security/hardcoded-secret": "Secret-looking token is embedded in source.",
10798
+ "security/vulnerable-dependency": "Dependency audit reported a known vulnerability.",
10799
+ "security/dependency-audit-skipped": "Audit could not run because inputs/tools are missing.",
10800
+ "security/eval": "Dynamic code execution can run attacker-controlled input.",
10801
+ "security/innerhtml": "Raw HTML assignment can introduce XSS.",
10802
+ "security/dangerously-set-innerhtml": "React raw HTML escape hatch can introduce XSS.",
10803
+ "security/sql-injection": "SQL is built from interpolated or concatenated input.",
10804
+ "security/shell-injection": "Shell command is built from unsanitized input.",
10805
+ "oxlint/*": "JavaScript/TypeScript lint finding from oxlint.",
10806
+ "ruff/*": "Python lint finding from ruff.",
10807
+ "go/*": "Go lint finding from bundled checks.",
10808
+ "clippy/*": "Rust lint finding from clippy.",
10809
+ "rubocop/*": "Ruby lint finding from rubocop.",
10810
+ "typescript/*": "TypeScript compiler finding.",
10811
+ "import-order": "Imports need deterministic ordering.",
10812
+ "python-formatting": "Python file needs ruff formatting.",
10813
+ "go-formatting": "Go file needs gofmt.",
10814
+ "rust-formatting": "Rust file needs rustfmt.",
10815
+ "ruby-formatting": "Ruby file needs rubocop formatting.",
10816
+ "php-formatting": "PHP file needs php-cs-fixer formatting."
10817
+ };
9949
10818
  const prettifyFallback = (ruleId) => {
9950
10819
  const spaced = (ruleId.includes("/") ? ruleId.slice(ruleId.indexOf("/") + 1) : ruleId).replace(/[-_]/g, " ").replace(/\//g, " · ");
9951
10820
  return spaced.charAt(0).toUpperCase() + spaced.slice(1);
9952
10821
  };
9953
10822
  const labelForRule = (ruleId) => RULE_LABELS[ruleId] ?? prettifyFallback(ruleId);
10823
+ const descriptionForRule = (ruleId) => RULE_DESCRIPTIONS[ruleId] ?? labelForRule(ruleId);
9954
10824
 
9955
10825
  //#endregion
9956
10826
  //#region src/ui/summary.ts
@@ -9960,6 +10830,18 @@ const scoreToken = (score, thresholds) => {
9960
10830
  if (score >= thresholds.ok) return "warn";
9961
10831
  return "danger";
9962
10832
  };
10833
+ const renderFindingAssessment = (assessment, t, sep) => {
10834
+ if (assessment.rows.length === 0) return [];
10835
+ const parts = assessment.rows.filter((row) => row.count > 0).map((row) => `${row.count} ${row.label}`);
10836
+ if (parts.length === 0) return [];
10837
+ const high = assessment.byConfidence.high;
10838
+ const medium = assessment.byConfidence.medium;
10839
+ const confidenceParts = [];
10840
+ if (high > 0) confidenceParts.push(`${high} high-confidence`);
10841
+ if (medium > 0) confidenceParts.push(`${medium} medium-confidence`);
10842
+ const confidence = confidenceParts.length > 0 ? ` ${sep} ${style(t, "muted", confidenceParts.join(", "))}` : "";
10843
+ return [` ${style(t, "muted", "Verdict mix:")} ${parts.join(` ${sep} `)}${confidence}`];
10844
+ };
9963
10845
  const renderSummary = (input, deps = {}) => {
9964
10846
  const t = deps.theme ?? theme;
9965
10847
  const s = deps.symbols ?? symbols;
@@ -9978,6 +10860,10 @@ const renderSummary = (input, deps = {}) => {
9978
10860
  ` ${style(t, "muted", `${input.files} files`)} ${sep} ${style(t, "muted", `${input.engines} engines`)} ${sep} ${style(t, "muted", elapsed(input.elapsedMs))}`,
9979
10861
  ""
9980
10862
  ];
10863
+ if (input.findingAssessment) {
10864
+ lines.push(...renderFindingAssessment(input.findingAssessment, t, sep));
10865
+ lines.push("");
10866
+ }
9981
10867
  if (input.breakdown && input.breakdown.rows.length > 0) {
9982
10868
  lines.push(` ${style(t, "bold", "Top findings")}`);
9983
10869
  const maxCountWidth = input.breakdown.rows.reduce((w, r) => Math.max(w, String(r.errors + r.warnings + r.info).length), 0);
@@ -10032,85 +10918,7 @@ const renderCleanRun = (input, deps = {}) => {
10032
10918
  };
10033
10919
 
10034
10920
  //#endregion
10035
- //#region src/utils/history.ts
10036
- const HISTORY_FILE = "history.jsonl";
10037
- const isHistoryDisabled = (env = process.env) => env.AISLOP_NO_HISTORY === "1";
10038
- const historyPath = (directory) => path.join(path.resolve(directory), CONFIG_DIR, HISTORY_FILE);
10039
- /**
10040
- * Append a compact scan record to .aislop/history.jsonl. Best-effort: never
10041
- * throws, so a read-only checkout or missing config dir can't break a scan.
10042
- */
10043
- const appendHistory = (input) => {
10044
- if (isHistoryDisabled()) return;
10045
- const configDir = path.join(path.resolve(input.directory), CONFIG_DIR);
10046
- if (!fs.existsSync(configDir)) return;
10047
- const record = {
10048
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
10049
- score: input.score,
10050
- errors: input.errors,
10051
- warnings: input.warnings,
10052
- files: input.files,
10053
- cliVersion: APP_VERSION
10054
- };
10055
- try {
10056
- fs.appendFileSync(historyPath(input.directory), `${JSON.stringify(record)}\n`);
10057
- } catch {}
10058
- };
10059
- const isHistoryRecord = (value) => {
10060
- if (!value || typeof value !== "object") return false;
10061
- const record = value;
10062
- 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";
10063
- };
10064
- const readHistory = (directory) => {
10065
- const file = historyPath(directory);
10066
- if (!fs.existsSync(file)) return [];
10067
- const records = [];
10068
- for (const line of fs.readFileSync(file, "utf8").split("\n")) {
10069
- const trimmed = line.trim();
10070
- if (!trimmed) continue;
10071
- try {
10072
- const parsed = JSON.parse(trimmed);
10073
- if (isHistoryRecord(parsed)) records.push(parsed);
10074
- } catch {}
10075
- }
10076
- return records;
10077
- };
10078
-
10079
- //#endregion
10080
- //#region src/commands/scan-coverage.ts
10081
- const coverageReason = (c) => {
10082
- if (c.supportedFiles === 0 && c.dominantUnsupported) return `This repository is ${c.dominantUnsupported} (${c.unsupportedFiles} files), which aislop does not analyze. No score — it would not reflect this code.`;
10083
- if (c.supportedFiles === 0) return "No files in a language aislop analyzes (TypeScript, JavaScript, Python, Go, Rust, Ruby, PHP, Java). Nothing to score.";
10084
- const lang = c.dominantUnsupported ?? "an unsupported language";
10085
- const files = `${c.supportedFiles} supported file${c.supportedFiles === 1 ? "" : "s"}`;
10086
- return `This repository is mostly ${lang} (${c.unsupportedFiles} files); aislop analyzed only ${files}. Score withheld — it would represent a sliver of the codebase.`;
10087
- };
10088
- const renderCoverageNotice = (projectInfo, includeHeader) => {
10089
- const deps = {
10090
- theme: createTheme(),
10091
- symbols: createSymbols({ plain: false })
10092
- };
10093
- return `${includeHeader === false ? "" : renderHeader({
10094
- version: APP_VERSION,
10095
- command: "scan",
10096
- context: [
10097
- projectInfo.projectName,
10098
- projectInfo.languages[0] ?? "unknown",
10099
- `${projectInfo.sourceFileCount} files`
10100
- ],
10101
- brand: true
10102
- }, deps)} ${coverageReason(projectInfo.coverage)}\n\n`;
10103
- };
10104
-
10105
- //#endregion
10106
- //#region src/commands/scan-exit-code.ts
10107
- const computeScanExitCode = (opts) => opts.hasErrors || opts.scoreable && opts.score < opts.failBelow ? 1 : 0;
10108
-
10109
- //#endregion
10110
- //#region src/commands/scan.ts
10111
- const isMachineOutput = (options) => Boolean(options.json) || Boolean(options.sarif);
10112
- const shouldUseSpinner = () => Boolean(process.stderr.isTTY) && process.env.CI !== "true" && process.env.CI !== "1";
10113
- const ALL_ENGINE_NAMES = Object.keys(ENGINE_INFO);
10921
+ //#region src/commands/scan-render.ts
10114
10922
  const BREAKDOWN_TOP_N = 10;
10115
10923
  const computeBreakdown = (diagnostics) => {
10116
10924
  const byRule = /* @__PURE__ */ new Map();
@@ -10152,7 +10960,7 @@ const buildScanRender = (input) => {
10152
10960
  const invocation = detectInvocation();
10153
10961
  const header = input.includeHeader === false ? "" : renderHeader({
10154
10962
  version: APP_VERSION,
10155
- command: "scan",
10963
+ command: "Scan result",
10156
10964
  context: [
10157
10965
  input.projectName,
10158
10966
  input.language,
@@ -10195,9 +11003,16 @@ const buildScanRender = (input) => {
10195
11003
  elapsedMs: input.elapsedMs,
10196
11004
  nextSteps,
10197
11005
  breakdown: computeBreakdown(input.diagnostics),
11006
+ findingAssessment: summarizeFindingAssessments(input.diagnostics),
10198
11007
  thresholds: input.thresholds
10199
11008
  }, deps)}${starCta}`;
10200
11009
  };
11010
+
11011
+ //#endregion
11012
+ //#region src/commands/scan.ts
11013
+ const isMachineOutput = (options) => Boolean(options.json) || Boolean(options.sarif);
11014
+ const shouldUseSpinner = () => Boolean(process.stderr.isTTY) && process.env.CI !== "true" && process.env.CI !== "1";
11015
+ const ALL_ENGINE_NAMES = Object.keys(ENGINE_INFO);
10201
11016
  const scanCommand = async (directory, config, options) => {
10202
11017
  const resolvedDir = path.resolve(directory);
10203
11018
  if (!fs.existsSync(resolvedDir)) {
@@ -10212,6 +11027,12 @@ const scanCommand = async (directory, config, options) => {
10212
11027
  else log.error(msg);
10213
11028
  return { exitCode: 1 };
10214
11029
  }
11030
+ if (options.changes && options.base && !baseRefExists(resolvedDir, options.base)) {
11031
+ const msg = `Could not resolve base ref "${options.base}". Make sure it exists and was fetched (e.g. \`git fetch origin ${options.base}\`).`;
11032
+ if (options.json) console.log(JSON.stringify({ error: msg }, null, 2));
11033
+ else log.error(msg);
11034
+ return { exitCode: 1 };
11035
+ }
10215
11036
  const projectInfo = await discoverProject(resolvedDir, [...config.exclude, ...readAislopIgnorePatterns(resolvedDir)]);
10216
11037
  return withCommandLifecycle({
10217
11038
  command: options.command ?? "scan",
@@ -10225,14 +11046,30 @@ const runScanBody = async (resolvedDir, config, options, projectInfo) => {
10225
11046
  const showHeader = options.showHeader !== false;
10226
11047
  const machineOutput = isMachineOutput(options);
10227
11048
  const useLiveProgress = !machineOutput && shouldUseSpinner();
11049
+ const projectName = projectInfo.projectName ?? "project";
11050
+ const language = projectInfo.languages[0] ?? "unknown";
11051
+ const printedHumanHeader = !machineOutput && showHeader;
11052
+ if (printedHumanHeader) process.stdout.write(renderHeader({
11053
+ version: APP_VERSION,
11054
+ command: "Scan result",
11055
+ context: [
11056
+ projectName,
11057
+ language,
11058
+ `${projectInfo.sourceFileCount} files`
11059
+ ],
11060
+ brand: options.printBrand !== false
11061
+ }));
10228
11062
  const excludePatterns = [...config.exclude, ...readAislopIgnorePatterns(resolvedDir)];
10229
11063
  let files;
10230
11064
  if (options.staged) {
10231
11065
  files = filterProjectFiles(resolvedDir, getStagedFiles(resolvedDir), [], excludePatterns);
10232
11066
  if (!machineOutput) log.muted(`Scope: ${files.length} staged file(s)`);
10233
11067
  } else if (options.changes) {
10234
- files = filterProjectFiles(resolvedDir, getChangedFiles(resolvedDir), [], excludePatterns);
10235
- if (!machineOutput) log.muted(`Scope: ${files.length} changed file(s)`);
11068
+ files = filterProjectFiles(resolvedDir, getChangedFiles(resolvedDir, options.base), [], excludePatterns);
11069
+ if (!machineOutput) {
11070
+ const scope = options.base ? `changed vs ${options.base}` : "changed";
11071
+ log.muted(`Scope: ${files.length} ${scope} file(s)`);
11072
+ }
10236
11073
  } else {
10237
11074
  files = filterProjectFiles(resolvedDir, listProjectFiles(resolvedDir), [], excludePatterns);
10238
11075
  if (!machineOutput) log.muted(`Scope: ${files.length} file(s) after exclusions`);
@@ -10295,7 +11132,7 @@ const runScanBody = async (resolvedDir, config, options, projectInfo) => {
10295
11132
  if (suppressedCount > 0 && !machineOutput) log.muted(`Suppressed ${suppressedCount} finding(s) via aislop-ignore directives`);
10296
11133
  const allDiagnostics = results.flatMap((r) => r.diagnostics);
10297
11134
  const elapsedMs = performance.now() - startTime;
10298
- const scoreResult = calculateScore(allDiagnostics, config.scoring.weights, config.scoring.thresholds, projectInfo.sourceFileCount, config.scoring.smoothing);
11135
+ const scoreResult = calculateScore(allDiagnostics, config.scoring.weights, config.scoring.thresholds, projectInfo.sourceFileCount, config.scoring.smoothing, config.scoring.maxPerRule);
10299
11136
  const scoreable = projectInfo.coverage.scoreable;
10300
11137
  const exitCode = computeScanExitCode({
10301
11138
  hasErrors: allDiagnostics.some((d) => d.severity === "error"),
@@ -10321,19 +11158,19 @@ const runScanBody = async (resolvedDir, config, options, projectInfo) => {
10321
11158
  engineTimings
10322
11159
  };
10323
11160
  if (options.sarif) {
10324
- const { buildSarifLog } = await import("./sarif-CZVuavf_.js");
11161
+ const { buildSarifLog } = await import("./sarif-CjxSBcqx.js");
10325
11162
  console.log(JSON.stringify(buildSarifLog(results), null, 2));
10326
11163
  return completion;
10327
11164
  }
10328
11165
  if (options.json) {
10329
- const { buildJsonOutput } = await import("./json-CXV4D0Ib.js");
11166
+ const { buildJsonOutput } = await import("./json-pHsqtKkz.js");
10330
11167
  const jsonOut = buildJsonOutput(results, scoreResult, projectInfo.sourceFileCount, elapsedMs, projectInfo.coverage);
10331
11168
  console.log(JSON.stringify(jsonOut, null, 2));
10332
11169
  return completion;
10333
11170
  }
10334
11171
  if (!scoreable) {
10335
11172
  if (!machineOutput) {
10336
- process.stdout.write(renderCoverageNotice(projectInfo, showHeader));
11173
+ process.stdout.write(renderCoverageNotice(projectInfo, !printedHumanHeader && showHeader));
10337
11174
  if (allDiagnostics.length > 0) process.stdout.write(renderDiagnostics(allDiagnostics, options.verbose ?? false));
10338
11175
  }
10339
11176
  return completion;
@@ -10345,8 +11182,6 @@ const runScanBody = async (resolvedDir, config, options, projectInfo) => {
10345
11182
  warnings: completion.warningCount,
10346
11183
  files: projectInfo.sourceFileCount
10347
11184
  });
10348
- const projectName = projectInfo.projectName ?? "project";
10349
- const language = projectInfo.languages[0] ?? "unknown";
10350
11185
  process.stdout.write(buildScanRender({
10351
11186
  projectName,
10352
11187
  language,
@@ -10357,7 +11192,7 @@ const runScanBody = async (resolvedDir, config, options, projectInfo) => {
10357
11192
  elapsedMs,
10358
11193
  thresholds: config.scoring.thresholds,
10359
11194
  verbose: options.verbose,
10360
- includeHeader: showHeader,
11195
+ includeHeader: !printedHumanHeader && showHeader,
10361
11196
  printBrand: options.printBrand
10362
11197
  }));
10363
11198
  return completion;
@@ -10368,8 +11203,9 @@ const runScanBody = async (resolvedDir, config, options, projectInfo) => {
10368
11203
  const ciCommand = async (directory, config, options = {}) => {
10369
11204
  try {
10370
11205
  return await scanCommand(directory, config, {
10371
- changes: false,
10372
- staged: false,
11206
+ changes: Boolean(options.changes),
11207
+ staged: Boolean(options.staged),
11208
+ base: options.base,
10373
11209
  verbose: false,
10374
11210
  json: !options.human && !options.sarif,
10375
11211
  sarif: options.sarif,
@@ -10467,7 +11303,7 @@ const buildDoctorRender = (input) => {
10467
11303
  };
10468
11304
  const header = renderHeader({
10469
11305
  version: APP_VERSION,
10470
- command: "doctor",
11306
+ command: "Doctor report",
10471
11307
  context: [input.projectName, input.languageLabel].filter((s) => s.length > 0),
10472
11308
  brand: input.printBrand !== false
10473
11309
  }, deps);
@@ -11018,7 +11854,7 @@ const buildAgentPrompt = (rootDirectory, diagnostics, score) => {
11018
11854
  }
11019
11855
  lines.push("---");
11020
11856
  lines.push("Fix each issue following the guidance above. Prioritize errors over warnings.");
11021
- lines.push("After making changes, run `npx aislop scan` to verify all issues are resolved and the score improves.");
11857
+ lines.push("After making changes, run `aislop scan` to verify all issues are resolved and the score improves.");
11022
11858
  return lines.join("\n");
11023
11859
  };
11024
11860
  const SUPPORTED_AGENT_NAMES = Object.keys(AGENT_CONFIGS);
@@ -11958,7 +12794,7 @@ const runExpoDoctor = async (context) => {
11958
12794
  rule: "expo-doctor/config-error",
11959
12795
  severity: "warning",
11960
12796
  message: configError,
11961
- help: "Install project dependencies, then re-run `npx aislop scan`.",
12797
+ help: "Install project dependencies, then re-run `aislop scan`.",
11962
12798
  line: 0,
11963
12799
  column: 0,
11964
12800
  category: "Expo",
@@ -12458,7 +13294,7 @@ const runFixBody = async (resolvedDir, config, options, projectInfo) => {
12458
13294
  const projectName = projectInfo.projectName ?? "project";
12459
13295
  if (showHeader) process.stdout.write(renderHeader({
12460
13296
  version: APP_VERSION,
12461
- command: "fix",
13297
+ command: "Fix run",
12462
13298
  context: [projectName],
12463
13299
  brand: options.printBrand !== false
12464
13300
  }));
@@ -12516,10 +13352,11 @@ const runFixBody = async (resolvedDir, config, options, projectInfo) => {
12516
13352
  label: "Verification complete"
12517
13353
  });
12518
13354
  const allDiagnostics = scanResults.flatMap((r) => r.diagnostics);
12519
- const scoreResult = calculateScore(allDiagnostics, config.scoring.weights, config.scoring.thresholds, projectInfo.sourceFileCount, config.scoring.smoothing);
13355
+ const scoreResult = calculateScore(allDiagnostics, config.scoring.weights, config.scoring.thresholds, projectInfo.sourceFileCount, config.scoring.smoothing, config.scoring.maxPerRule);
12520
13356
  const errors = allDiagnostics.filter((d) => d.severity === "error").length;
12521
13357
  const warnings = allDiagnostics.filter((d) => d.severity === "warning").length;
12522
13358
  const remaining = errors + warnings;
13359
+ const actionableDiagnostics = allDiagnostics.filter((d) => d.severity !== "info");
12523
13360
  if (steps.length === 0) rail.complete({
12524
13361
  status: "skipped",
12525
13362
  label: "No applicable auto-fixers found"
@@ -12537,7 +13374,7 @@ const runFixBody = async (resolvedDir, config, options, projectInfo) => {
12537
13374
  language,
12538
13375
  fileCount: projectInfo.sourceFileCount,
12539
13376
  results: scanResults,
12540
- diagnostics: allDiagnostics,
13377
+ diagnostics: actionableDiagnostics,
12541
13378
  score: scoreResult,
12542
13379
  elapsedMs: performance.now() - startTime,
12543
13380
  thresholds: config.scoring.thresholds,
@@ -12547,7 +13384,7 @@ const runFixBody = async (resolvedDir, config, options, projectInfo) => {
12547
13384
  }));
12548
13385
  }
12549
13386
  if (options.agent) {
12550
- launchAgent(options.agent, resolvedDir, allDiagnostics, scoreResult.score);
13387
+ launchAgent(options.agent, resolvedDir, actionableDiagnostics, scoreResult.score);
12551
13388
  return {
12552
13389
  exitCode: 0,
12553
13390
  score: scoreResult.score,
@@ -12556,7 +13393,7 @@ const runFixBody = async (resolvedDir, config, options, projectInfo) => {
12556
13393
  };
12557
13394
  }
12558
13395
  if (options.prompt) {
12559
- printPrompt(resolvedDir, allDiagnostics, scoreResult.score);
13396
+ printPrompt(resolvedDir, actionableDiagnostics, scoreResult.score);
12560
13397
  return {
12561
13398
  exitCode: 0,
12562
13399
  score: scoreResult.score,
@@ -12584,7 +13421,7 @@ const buildInitSuccessRender = (input) => {
12584
13421
  };
12585
13422
  const header = input.includeHeader === false ? "" : renderHeader({
12586
13423
  version: APP_VERSION,
12587
- command: "init",
13424
+ command: "Setup",
12588
13425
  context: [],
12589
13426
  brand: input.printBrand !== false
12590
13427
  }, deps);
@@ -12719,7 +13556,8 @@ const writeAislopConfig = (configDir, configPath, choices) => {
12719
13556
  scoring: {
12720
13557
  weights: { ...DEFAULT_CONFIG.scoring.weights },
12721
13558
  thresholds: { ...DEFAULT_CONFIG.scoring.thresholds },
12722
- smoothing: DEFAULT_CONFIG.scoring.smoothing
13559
+ smoothing: DEFAULT_CONFIG.scoring.smoothing,
13560
+ maxPerRule: DEFAULT_CONFIG.scoring.maxPerRule
12723
13561
  },
12724
13562
  ci: {
12725
13563
  failBelow: choices.failBelow,
@@ -12735,7 +13573,7 @@ const initCommand = async (directory, options = {}) => {
12735
13573
  const printBrand = options.printBrand !== false;
12736
13574
  process.stdout.write(renderHeader({
12737
13575
  version: APP_VERSION,
12738
- command: "init",
13576
+ command: "Setup",
12739
13577
  context: [],
12740
13578
  brand: printBrand
12741
13579
  }));
@@ -12800,13 +13638,437 @@ const initCommand = async (directory, options = {}) => {
12800
13638
  }));
12801
13639
  };
12802
13640
 
13641
+ //#endregion
13642
+ //#region src/ui/action-frame.ts
13643
+ const renderActionStart = (input) => {
13644
+ const hint = input.hint ? ` ${style(theme, "muted", `· ${input.hint}`)}` : "";
13645
+ return `\n ${style(theme, "muted", "┌")} ${style(theme, "accent", input.label)}${hint}\n\n`;
13646
+ };
13647
+ const renderActionEnd = (input) => {
13648
+ const status = input.status ?? "complete";
13649
+ const token = status === "complete" ? "success" : "muted";
13650
+ const text = status === "complete" ? `${input.label} complete` : `${input.label} skipped`;
13651
+ return `\n ${style(theme, "muted", "└")} ${style(theme, token, text)}\n`;
13652
+ };
13653
+
13654
+ //#endregion
13655
+ //#region src/ui/home.ts
13656
+ const HOME_COMMANDS = [
13657
+ {
13658
+ command: "aislop scan",
13659
+ summary: "Score this project and show findings",
13660
+ group: "Run"
13661
+ },
13662
+ {
13663
+ command: "aislop fix",
13664
+ summary: "Auto-fix safe issues or hand off to an agent",
13665
+ group: "Run"
13666
+ },
13667
+ {
13668
+ command: "aislop ci",
13669
+ summary: "Run the quality gate for CI",
13670
+ group: "Run"
13671
+ },
13672
+ {
13673
+ command: "aislop doctor",
13674
+ summary: "Check which engines can run here",
13675
+ group: "Run"
13676
+ },
13677
+ {
13678
+ command: "aislop init",
13679
+ summary: "Create config and optional CI workflow",
13680
+ group: "Setup"
13681
+ },
13682
+ {
13683
+ command: "aislop hook install",
13684
+ summary: "Run aislop after coding-agent edits",
13685
+ group: "Setup"
13686
+ },
13687
+ {
13688
+ command: "aislop rules",
13689
+ summary: "Explain every rule and fix mode",
13690
+ group: "Learn"
13691
+ },
13692
+ {
13693
+ command: "aislop trend",
13694
+ summary: "Show local score history",
13695
+ group: "Learn"
13696
+ },
13697
+ {
13698
+ command: "aislop badge",
13699
+ summary: "Print a score badge URL and README markdown",
13700
+ group: "Learn"
13701
+ },
13702
+ {
13703
+ command: "aislop commands",
13704
+ summary: "List all commands and major flags",
13705
+ group: "Utility"
13706
+ },
13707
+ {
13708
+ command: "aislop update",
13709
+ summary: "Check the latest npm version",
13710
+ group: "Learn"
13711
+ },
13712
+ {
13713
+ command: "aislop version",
13714
+ summary: "Print the installed version",
13715
+ group: "Utility"
13716
+ }
13717
+ ];
13718
+ const GROUPS = [
13719
+ "Run",
13720
+ "Setup",
13721
+ "Learn",
13722
+ "Utility"
13723
+ ];
13724
+ const COMMAND_REFERENCE = [
13725
+ {
13726
+ command: "aislop",
13727
+ summary: "Open the interactive menu, or scan the current directory in non-TTY shells"
13728
+ },
13729
+ {
13730
+ command: "aislop scan [directory]",
13731
+ summary: "Score code quality and show findings",
13732
+ flags: [
13733
+ "--changes",
13734
+ "--staged",
13735
+ "-d, --verbose",
13736
+ "--json",
13737
+ "--sarif",
13738
+ "--format <format>",
13739
+ "--include <patterns>",
13740
+ "--exclude <patterns>"
13741
+ ]
13742
+ },
13743
+ {
13744
+ command: "aislop fix [directory]",
13745
+ summary: "Apply safe auto-fixes or hand remaining findings to an agent",
13746
+ flags: [
13747
+ "-d, --verbose",
13748
+ "-f, --force",
13749
+ "--safe",
13750
+ "-p, --prompt",
13751
+ "--claude",
13752
+ "--codex",
13753
+ "--cursor",
13754
+ "--windsurf",
13755
+ "--vscode",
13756
+ "--amp",
13757
+ "--antigravity",
13758
+ "--deep-agents",
13759
+ "--gemini",
13760
+ "--kimi",
13761
+ "--opencode",
13762
+ "--warp",
13763
+ "--aider",
13764
+ "--goose",
13765
+ "--pi",
13766
+ "--crush"
13767
+ ]
13768
+ },
13769
+ {
13770
+ command: "aislop ci [directory]",
13771
+ summary: "Run the CI quality gate with thresholded exit codes",
13772
+ flags: [
13773
+ "--human",
13774
+ "--sarif",
13775
+ "--format <format>"
13776
+ ]
13777
+ },
13778
+ {
13779
+ command: "aislop init [directory]",
13780
+ summary: "Create .aislop/config.yml, .aislop/rules.yml, and optional GitHub Actions workflow",
13781
+ flags: ["--strict"]
13782
+ },
13783
+ {
13784
+ command: "aislop doctor [directory]",
13785
+ summary: "Check installed engines and project coverage"
13786
+ },
13787
+ {
13788
+ command: "aislop rules [directory]",
13789
+ summary: "Explain rule IDs, severity, fixability, and meaning",
13790
+ flags: ["--search"]
13791
+ },
13792
+ {
13793
+ command: "aislop hook install [agents...]",
13794
+ summary: "Install coding-agent hooks",
13795
+ flags: [
13796
+ "--agent <names>",
13797
+ "-g, --global",
13798
+ "--project",
13799
+ "--dry-run",
13800
+ "--yes",
13801
+ "--quality-gate",
13802
+ "--claude",
13803
+ "--cursor",
13804
+ "--gemini",
13805
+ "--pi",
13806
+ "--codex",
13807
+ "--windsurf",
13808
+ "--cline",
13809
+ "--kilocode",
13810
+ "--antigravity",
13811
+ "--copilot"
13812
+ ]
13813
+ },
13814
+ {
13815
+ command: "aislop hook uninstall [agents...]",
13816
+ summary: "Remove installed coding-agent hooks",
13817
+ flags: [
13818
+ "--agent <names>",
13819
+ "-g, --global",
13820
+ "--project",
13821
+ "--dry-run",
13822
+ "--claude",
13823
+ "--cursor",
13824
+ "--gemini",
13825
+ "--pi",
13826
+ "--codex",
13827
+ "--windsurf",
13828
+ "--cline",
13829
+ "--kilocode",
13830
+ "--antigravity",
13831
+ "--copilot"
13832
+ ]
13833
+ },
13834
+ {
13835
+ command: "aislop hooks",
13836
+ summary: "Alias for hook"
13837
+ },
13838
+ {
13839
+ command: "aislop hook status",
13840
+ summary: "Show installed hook status"
13841
+ },
13842
+ {
13843
+ command: "aislop hook baseline",
13844
+ summary: "Capture the current score as the hook baseline"
13845
+ },
13846
+ {
13847
+ command: "aislop install [agents...]",
13848
+ summary: "Alias for hook install",
13849
+ flags: [
13850
+ "--agent <names>",
13851
+ "-g, --global",
13852
+ "--project",
13853
+ "--dry-run",
13854
+ "--yes",
13855
+ "--quality-gate",
13856
+ "--claude",
13857
+ "--cursor",
13858
+ "--gemini",
13859
+ "--pi",
13860
+ "--codex",
13861
+ "--windsurf",
13862
+ "--cline",
13863
+ "--kilocode",
13864
+ "--antigravity",
13865
+ "--copilot"
13866
+ ]
13867
+ },
13868
+ {
13869
+ command: "aislop install hooks [agents...]",
13870
+ summary: "Natural alias for install; same flags"
13871
+ },
13872
+ {
13873
+ command: "aislop uninstall [agents...]",
13874
+ summary: "Alias for hook uninstall",
13875
+ flags: [
13876
+ "--agent <names>",
13877
+ "-g, --global",
13878
+ "--project",
13879
+ "--dry-run",
13880
+ "--claude",
13881
+ "--cursor",
13882
+ "--gemini",
13883
+ "--pi",
13884
+ "--codex",
13885
+ "--windsurf",
13886
+ "--cline",
13887
+ "--kilocode",
13888
+ "--antigravity",
13889
+ "--copilot"
13890
+ ]
13891
+ },
13892
+ {
13893
+ command: "aislop uninstall hooks [agents...]",
13894
+ summary: "Natural alias for uninstall; same flags"
13895
+ },
13896
+ {
13897
+ command: "aislop badge [directory]",
13898
+ summary: "Print score badge URL and README markdown",
13899
+ flags: [
13900
+ "--owner <owner>",
13901
+ "--repo <repo>",
13902
+ "--json"
13903
+ ]
13904
+ },
13905
+ {
13906
+ command: "aislop trend [directory]",
13907
+ summary: "Show recent local scores from .aislop/history.jsonl",
13908
+ flags: ["--limit <n>"]
13909
+ },
13910
+ {
13911
+ command: "aislop update",
13912
+ summary: "Show current and latest npm versions"
13913
+ },
13914
+ {
13915
+ command: "aislop upgrade",
13916
+ summary: "Alias for update"
13917
+ },
13918
+ {
13919
+ command: "aislop version",
13920
+ summary: "Print the installed version"
13921
+ },
13922
+ {
13923
+ command: "aislop commands",
13924
+ summary: "Show this command reference"
13925
+ }
13926
+ ];
13927
+ const renderCommandGroups = () => {
13928
+ const commandWidth = Math.max(...HOME_COMMANDS.map((c) => c.command.length));
13929
+ const lines = [];
13930
+ for (const group of GROUPS) {
13931
+ lines.push(` ${style(theme, "dim", group)}`);
13932
+ for (const item of HOME_COMMANDS.filter((c) => c.group === group)) lines.push(` ${style(theme, "muted", "$")} ${style(theme, "fg", padEnd(item.command, commandWidth))} ${style(theme, "muted", item.summary)}`);
13933
+ lines.push("");
13934
+ }
13935
+ return lines.join("\n");
13936
+ };
13937
+ const renderHelpDetails = () => [
13938
+ ` ${style(theme, "dim", "Usage")}`,
13939
+ " aislop Open interactive menu",
13940
+ " aislop scan [options] [directory]",
13941
+ " aislop fix [options] [directory]",
13942
+ " aislop ci [options] [directory]",
13943
+ " aislop init [options] [directory]",
13944
+ " aislop doctor [directory]",
13945
+ " aislop rules [directory]",
13946
+ " aislop badge [options] [directory]",
13947
+ " aislop trend [options] [directory]",
13948
+ " aislop hook install [agents...]",
13949
+ " aislop install hooks [agents...]",
13950
+ " aislop update",
13951
+ " aislop version",
13952
+ "",
13953
+ ` ${style(theme, "dim", "Scan flags")}`,
13954
+ " --changes scan changed files from HEAD",
13955
+ " --staged scan staged files",
13956
+ " --json emit machine-readable JSON",
13957
+ " --sarif emit SARIF 2.1.0",
13958
+ " --format choose json or sarif",
13959
+ " --exclude exclude comma-separated or repeated paths",
13960
+ " --include include comma-separated or repeated paths",
13961
+ "",
13962
+ ` ${style(theme, "dim", "Fix flags")}`,
13963
+ " --safe only reversible fixes",
13964
+ " --force aggressive dependency and framework fixes",
13965
+ " --prompt print an agent handoff prompt",
13966
+ " --codex open Codex to fix remaining findings",
13967
+ " --claude open Claude Code to fix remaining findings",
13968
+ "",
13969
+ ` ${style(theme, "dim", "Ignore and scope")}`,
13970
+ " .aislopignore skip generated, vendored, or noisy paths",
13971
+ " .gitignore respected for untracked files",
13972
+ " --exclude skip extra paths for this run",
13973
+ " --include scan only matching paths for this run",
13974
+ "",
13975
+ ` ${style(theme, "dim", "More")}`,
13976
+ " aislop commands show every command and major flag",
13977
+ " aislop <cmd> --help show detailed help for one command",
13978
+ " -h, --help show help",
13979
+ " -v, -V, --version show version",
13980
+ "",
13981
+ ` ${style(theme, "dim", "One-off latest run")}`,
13982
+ " npx aislop@latest scan",
13983
+ "",
13984
+ ` ${style(theme, "dim", "Examples")}`,
13985
+ " aislop scan --changes",
13986
+ " aislop fix --codex",
13987
+ " aislop hook install --claude",
13988
+ " aislop install hooks",
13989
+ " aislop rules --search",
13990
+ ""
13991
+ ].join("\n");
13992
+ const renderHome = (input = {}) => {
13993
+ let out = renderHeader({
13994
+ version: input.version ?? APP_VERSION,
13995
+ command: "--bare",
13996
+ context: []
13997
+ });
13998
+ out += `${renderCommandGroups().trimEnd()}\n`;
13999
+ if (input.includeHelpDetails) {
14000
+ out += `\n${renderHelpDetails().trimEnd()}\n`;
14001
+ out += renderHintLine("Run aislop scan to scan your project");
14002
+ }
14003
+ return out;
14004
+ };
14005
+ const renderRootHelp = (input = {}) => `${renderHome({
14006
+ version: input.version,
14007
+ includeHelpDetails: true
14008
+ })}\n`;
14009
+ const renderCommandReference = (input = {}) => {
14010
+ const version = input.version ?? APP_VERSION;
14011
+ const commandWidth = Math.max(...COMMAND_REFERENCE.map((c) => c.command.length));
14012
+ const lines = [renderHeader({
14013
+ version,
14014
+ command: "Commands",
14015
+ context: ["full list"]
14016
+ }).trimEnd(), ""];
14017
+ for (const item of COMMAND_REFERENCE) {
14018
+ lines.push(` ${style(theme, "fg", padEnd(item.command, commandWidth))} ${style(theme, "muted", item.summary)}`);
14019
+ if (item.flags?.length) lines.push(` ${style(theme, "dim", item.flags.join(" "))}`);
14020
+ }
14021
+ lines.push("", ` ${style(theme, "dim", "Scope files")}`, " .aislopignore Skip generated, vendored, or noisy paths", " .gitignore Respected for untracked files");
14022
+ lines.push("", renderHintLine("Run aislop <command> --help for complete command-specific options").trimEnd());
14023
+ return `${lines.join("\n")}\n`;
14024
+ };
14025
+
12803
14026
  //#endregion
12804
14027
  //#region src/commands/rules.ts
14028
+ const ENGINE_PRESENTATION = {
14029
+ "ai-slop": {
14030
+ label: "AI Slop",
14031
+ summary: "Generated-code leftovers: vague comments, unsafe casts, stubs, swallowed errors.",
14032
+ order: 10
14033
+ },
14034
+ security: {
14035
+ label: "Security",
14036
+ summary: "Secrets, injection, XSS, shell execution, and vulnerable dependencies.",
14037
+ order: 20
14038
+ },
14039
+ "code-quality": {
14040
+ label: "Code Quality",
14041
+ summary: "Dead code, duplicate code, complexity, and dependency hygiene.",
14042
+ order: 30
14043
+ },
14044
+ format: {
14045
+ label: "Format",
14046
+ summary: "Formatter and import-order checks that aislop can usually fix.",
14047
+ order: 40
14048
+ },
14049
+ lint: {
14050
+ label: "Lint",
14051
+ summary: "Language linter and compiler findings from bundled or system tools.",
14052
+ order: 50
14053
+ },
14054
+ architecture: {
14055
+ label: "Architecture",
14056
+ summary: "Project-specific import and layering rules from .aislop/rules.yml.",
14057
+ order: 60
14058
+ }
14059
+ };
14060
+ const presentationFor = (engine) => ENGINE_PRESENTATION[engine] ?? {
14061
+ label: engine,
14062
+ summary: "Project-specific rules.",
14063
+ order: 100
14064
+ };
14065
+ const severityLabel = (severity) => severity === "warning" ? "warn" : severity;
14066
+ const fixModeLabel = (fixable) => fixable ? "auto" : "review";
12805
14067
  const buildRulesRender = (input) => {
12806
- const header = renderHeader({
14068
+ const header = input.includeHeader === false ? "" : renderHeader({
12807
14069
  version: APP_VERSION,
12808
- command: "rules",
12809
- context: [],
14070
+ command: "Rules catalog",
14071
+ context: [`${input.rules.length} checks`],
12810
14072
  brand: input.printBrand !== false
12811
14073
  });
12812
14074
  const byEngine = /* @__PURE__ */ new Map();
@@ -12815,23 +14077,50 @@ const buildRulesRender = (input) => {
12815
14077
  list.push(r);
12816
14078
  byEngine.set(r.engine, list);
12817
14079
  }
12818
- const engines = [...byEngine.keys()].sort();
14080
+ const engines = [...byEngine.keys()].sort((a, b) => {
14081
+ const pa = presentationFor(a);
14082
+ const pb = presentationFor(b);
14083
+ if (pa.order !== pb.order) return pa.order - pb.order;
14084
+ return pa.label.localeCompare(pb.label);
14085
+ });
12819
14086
  const idWidth = Math.max(20, ...input.rules.map((r) => r.id.length));
12820
- const lines = [];
14087
+ const lines = [` ${style(theme, "muted", "auto = aislop fix can change it; review = inspect and fix with a developer or agent.")}`, ""];
12821
14088
  for (const engine of engines) {
12822
- lines.push(` ${style(theme, "accent", engine)}`);
14089
+ const presentation = presentationFor(engine);
14090
+ lines.push(` ${style(theme, "accent", presentation.label)}`);
14091
+ lines.push(` ${style(theme, "muted", presentation.summary)}`);
14092
+ lines.push(` ${style(theme, "dim", padEnd("Rule ID", idWidth))} ${style(theme, "dim", "Sev")} ${style(theme, "dim", "Fix")} ${style(theme, "dim", "Meaning")}`);
12823
14093
  const rules = (byEngine.get(engine) ?? []).sort((a, b) => a.id.localeCompare(b.id));
12824
14094
  for (const r of rules) {
12825
- const severity = style(theme, r.severity === "error" ? "danger" : "warn", padEnd(r.severity, 8));
12826
- const fixable = r.fixable ? style(theme, "accent", "fixable") : style(theme, "muted", "manual");
12827
- lines.push(` ${padEnd(r.id, idWidth)} ${severity} ${fixable}`);
14095
+ const severityText = severityLabel(r.severity);
14096
+ const severity = style(theme, r.severity === "error" ? "danger" : "warn", padEnd(severityText, 5));
14097
+ const fixable = r.fixable ? style(theme, "accent", padEnd("auto", 6)) : style(theme, "muted", padEnd("review", 6));
14098
+ lines.push(` ${padEnd(r.id, idWidth)} ${severity} ${fixable} ${descriptionForRule(r.id)}`);
12828
14099
  }
12829
14100
  lines.push("");
12830
14101
  }
12831
14102
  const invocation = input.invocation ?? detectInvocation();
12832
- const tail = renderHintLine(`Run ${invocation} scan to apply these rules`) + renderHintLine(`Run ${invocation} init to customize which engines are enabled`);
14103
+ const tail = renderHintLine(`Run ${invocation} scan to check your project against these rules`) + renderHintLine(`Run ${invocation} init to choose engines and CI settings`);
12833
14104
  return `${header}${lines.join("\n")}\n${tail}`;
12834
14105
  };
14106
+ const buildRuleDetailRender = (rule, input = {}) => {
14107
+ const presentation = presentationFor(rule.engine);
14108
+ const header = input.includeHeader === false ? "" : renderHeader({
14109
+ version: APP_VERSION,
14110
+ command: "Rule detail",
14111
+ context: [presentation.label],
14112
+ brand: input.printBrand !== false
14113
+ });
14114
+ const rows = [
14115
+ ["Rule", rule.id],
14116
+ ["Engine", `${presentation.label} — ${presentation.summary}`],
14117
+ ["Severity", severityLabel(rule.severity)],
14118
+ ["Fix", `${fixModeLabel(rule.fixable)}${rule.fixable ? " (aislop fix can change it)" : " (review and fix intentionally)"}`],
14119
+ ["Meaning", descriptionForRule(rule.id)]
14120
+ ];
14121
+ const labelWidth = Math.max(...rows.map(([label]) => label.length));
14122
+ return `${header}${rows.map(([label, value]) => ` ${style(theme, "muted", padEnd(label, labelWidth))} ${style(theme, label === "Severity" && rule.severity === "error" ? "danger" : "fg", value)}`).join("\n")}\n\n${renderHintLine(rule.fixable ? "Run aislop fix to apply the automatic fix" : "Use the meaning above to fix or review the finding")}`;
14123
+ };
12835
14124
  const AI_SLOP_FIXABLE = new Set([
12836
14125
  "ai-slop/trivial-comment",
12837
14126
  "ai-slop/unused-import",
@@ -12964,7 +14253,7 @@ const toRuleEntry = (engine, ruleId) => {
12964
14253
  fixable: false
12965
14254
  };
12966
14255
  };
12967
- const rulesCommand = async (directory, options = {}) => {
14256
+ const collectRuleEntries = (directory) => {
12968
14257
  const resolvedDir = path.resolve(directory);
12969
14258
  const entries = [];
12970
14259
  for (const { engine, rules } of BUILTIN_RULES) for (const rule of rules) entries.push(toRuleEntry(engine, rule));
@@ -12978,6 +14267,41 @@ const rulesCommand = async (directory, options = {}) => {
12978
14267
  fixable: false
12979
14268
  });
12980
14269
  }
14270
+ return entries;
14271
+ };
14272
+ const runRulesExplorer = async (entries, options) => {
14273
+ const selected = await searchSelect({
14274
+ message: "Search rules",
14275
+ items: entries.map((rule) => {
14276
+ const presentation = presentationFor(rule.engine);
14277
+ return {
14278
+ value: rule,
14279
+ label: rule.id,
14280
+ hint: `${presentation.label} · ${severityLabel(rule.severity)} · ${descriptionForRule(rule.id)}`,
14281
+ keywords: [
14282
+ presentation.label,
14283
+ rule.engine,
14284
+ rule.severity,
14285
+ fixModeLabel(rule.fixable),
14286
+ descriptionForRule(rule.id)
14287
+ ]
14288
+ };
14289
+ }),
14290
+ maxVisible: 10,
14291
+ required: true
14292
+ });
14293
+ if (selected === null) return;
14294
+ process.stdout.write(`${buildRuleDetailRender(selected, {
14295
+ printBrand: options.printBrand,
14296
+ includeHeader: true
14297
+ })}\n`);
14298
+ };
14299
+ const rulesCommand = async (directory, options = {}) => {
14300
+ const entries = collectRuleEntries(directory);
14301
+ if (options.interactive && process.stdin.isTTY && process.stdout.isTTY) {
14302
+ await runRulesExplorer(entries, options);
14303
+ return;
14304
+ }
12981
14305
  process.stdout.write(`${buildRulesRender({
12982
14306
  rules: entries,
12983
14307
  invocation: detectInvocation(),
@@ -12991,27 +14315,37 @@ const INTERACTIVE_OPTIONS = [
12991
14315
  {
12992
14316
  value: "scan",
12993
14317
  label: "Scan",
12994
- hint: "Analyze code quality and risk"
14318
+ hint: "Score project and show findings"
12995
14319
  },
12996
14320
  {
12997
14321
  value: "fix",
12998
14322
  label: "Fix",
12999
- hint: "Apply safe auto-fixes"
13000
- },
13001
- {
13002
- value: "init",
13003
- label: "Init",
13004
- hint: "Create aislop config"
14323
+ hint: "Auto-fix or hand off remaining findings"
13005
14324
  },
13006
14325
  {
13007
14326
  value: "doctor",
13008
14327
  label: "Doctor",
13009
- hint: "Check toolchain"
14328
+ hint: "Check required tools"
14329
+ },
14330
+ {
14331
+ value: "init",
14332
+ label: "Setup",
14333
+ hint: "Create config and CI workflow"
13010
14334
  },
13011
14335
  {
13012
14336
  value: "rules",
13013
14337
  label: "Rules",
13014
- hint: "List all rules"
14338
+ hint: "Explain every check"
14339
+ },
14340
+ {
14341
+ value: "hook-install",
14342
+ label: "Install hooks",
14343
+ hint: "Run aislop after agent edits"
14344
+ },
14345
+ {
14346
+ value: "hook-status",
14347
+ label: "Hook status",
14348
+ hint: "Show installed hooks"
13015
14349
  },
13016
14350
  {
13017
14351
  value: "quit",
@@ -13019,6 +14353,7 @@ const INTERACTIVE_OPTIONS = [
13019
14353
  hint: "Exit"
13020
14354
  }
13021
14355
  ];
14356
+ const optionFor = (action) => INTERACTIVE_OPTIONS.find((option) => option.value === action);
13022
14357
  const run = async (action, directory, config) => {
13023
14358
  switch (action) {
13024
14359
  case "scan":
@@ -13029,52 +14364,79 @@ const run = async (action, directory, config) => {
13029
14364
  json: false,
13030
14365
  printBrand: false
13031
14366
  });
13032
- return;
14367
+ return "complete";
13033
14368
  case "fix":
13034
14369
  await fixCommand(directory, config, {
13035
14370
  verbose: false,
13036
14371
  printBrand: false
13037
14372
  });
13038
- return;
14373
+ return "complete";
14374
+ case "hook-install": {
14375
+ const agents = await promptAgentSelection("install");
14376
+ if (agents === null || agents.length === 0) return "skipped";
14377
+ await hookInstall({
14378
+ agents,
14379
+ scope: "global",
14380
+ dryRun: false,
14381
+ yes: false,
14382
+ qualityGate: false
14383
+ });
14384
+ return "complete";
14385
+ }
14386
+ case "hook-status":
14387
+ await hookStatus();
14388
+ return "complete";
13039
14389
  case "init":
13040
14390
  await initCommand(directory, { printBrand: false });
13041
- return;
14391
+ return "complete";
13042
14392
  case "doctor":
13043
14393
  await doctorCommand(directory, { printBrand: false });
13044
- return;
14394
+ return "complete";
13045
14395
  case "rules":
13046
- await rulesCommand(directory, { printBrand: false });
13047
- return;
13048
- case "quit": return;
14396
+ await rulesCommand(directory, {
14397
+ printBrand: false,
14398
+ interactive: true
14399
+ });
14400
+ return "complete";
14401
+ case "quit": return "skipped";
13049
14402
  }
13050
14403
  };
13051
- const interactiveCommand = async (directory, config) => {
13052
- process.stdout.write(renderHeader({
13053
- version: APP_VERSION,
13054
- command: "--bare",
13055
- context: []
14404
+ const runFramed = async (action, directory, config) => {
14405
+ const option = optionFor(action);
14406
+ const label = option?.label ?? action;
14407
+ process.stdout.write(renderActionStart({
14408
+ label,
14409
+ hint: option?.hint
14410
+ }));
14411
+ const status = await run(action, directory, config);
14412
+ process.stdout.write(renderActionEnd({
14413
+ label,
14414
+ status
13056
14415
  }));
13057
- const picked = await select({
14416
+ };
14417
+ const interactiveCommand = async (directory, config) => {
14418
+ process.stdout.write(`${renderHome({ version: APP_VERSION })}\n`);
14419
+ const picked = await searchSelect({
13058
14420
  message: "What would you like to do?",
13059
- options: INTERACTIVE_OPTIONS.map((o) => ({
14421
+ items: INTERACTIVE_OPTIONS.map((o) => ({
13060
14422
  value: o.value,
13061
14423
  label: o.label,
13062
14424
  hint: o.hint
13063
14425
  }))
13064
14426
  });
13065
- if (isCancel(picked) || picked === "quit") return;
13066
- await run(picked, directory, config);
14427
+ if (picked === null || picked === "quit") return;
14428
+ await runFramed(picked, directory, config);
13067
14429
  while (true) {
13068
- const again = await select({
13069
- message: "Next?",
13070
- options: INTERACTIVE_OPTIONS.map((o) => ({
14430
+ const again = await searchSelect({
14431
+ message: "Next action?",
14432
+ items: INTERACTIVE_OPTIONS.map((o) => ({
13071
14433
  value: o.value,
13072
14434
  label: o.label,
13073
14435
  hint: o.hint
13074
14436
  }))
13075
14437
  });
13076
- if (isCancel(again) || again === "quit") return;
13077
- await run(again, directory, config);
14438
+ if (again === null || again === "quit") return;
14439
+ await runFramed(again, directory, config);
13078
14440
  }
13079
14441
  };
13080
14442
 
@@ -13116,7 +14478,7 @@ const delta = (current, previous) => {
13116
14478
  const buildTrendRender = (input) => {
13117
14479
  const header = renderHeader({
13118
14480
  version: APP_VERSION,
13119
- command: "trend",
14481
+ command: "Score history",
13120
14482
  context: [],
13121
14483
  brand: input.printBrand !== false
13122
14484
  });
@@ -13178,7 +14540,13 @@ const isOutdated = (current, latest) => {
13178
14540
  if (l.minor !== c.minor) return l.minor > c.minor;
13179
14541
  return l.patch > c.patch;
13180
14542
  };
13181
- const formatUpdateNotice = (current, latest) => `\nUpdate available: ${current} -> ${latest}. Run npx aislop@latest to upgrade.\n`;
14543
+ const formatUpdateNotice = (current, latest) => [
14544
+ "",
14545
+ `Update available: ${current} -> ${latest}.`,
14546
+ "Upgrade: npm i -g aislop@latest",
14547
+ "One-off: npx aislop@latest",
14548
+ ""
14549
+ ].join("\n");
13182
14550
  const readCache = (cachePath) => {
13183
14551
  try {
13184
14552
  const parsed = JSON.parse(fs.readFileSync(cachePath, "utf-8"));
@@ -13225,6 +14593,49 @@ const maybeNotifyUpdate = async (now = Date.now()) => {
13225
14593
  }
13226
14594
  };
13227
14595
 
14596
+ //#endregion
14597
+ //#region src/commands/update.ts
14598
+ const renderUpgradeHelp = (label = "Upgrade:") => [
14599
+ `${style(theme, "dim", label)}`,
14600
+ " npm i -g aislop@latest",
14601
+ "",
14602
+ `${style(theme, "dim", "One-off latest run:")}`,
14603
+ " npx aislop@latest",
14604
+ ""
14605
+ ].join("\n");
14606
+ const buildUpdateStatusRender = (input) => {
14607
+ const lines = [
14608
+ `Current: ${input.current}`,
14609
+ `Latest: ${input.latest ?? "unavailable"}`,
14610
+ ""
14611
+ ];
14612
+ if (!input.latest) {
14613
+ lines.push("Status: could not reach the npm registry right now.", "");
14614
+ lines.push(renderUpgradeHelp("Use latest when npm is reachable:").trimEnd());
14615
+ return `${lines.join("\n")}\n`;
14616
+ }
14617
+ if (isOutdated(input.current, input.latest)) {
14618
+ lines.push(`Status: update available (${input.current} -> ${input.latest}).`, "");
14619
+ lines.push(renderUpgradeHelp("Upgrade:").trimEnd());
14620
+ return `${lines.join("\n")}\n`;
14621
+ }
14622
+ lines.push("Status: aislop is up to date.", "");
14623
+ lines.push(renderUpgradeHelp("Latest commands:").trimEnd());
14624
+ return `${lines.join("\n")}\n`;
14625
+ };
14626
+ const updateCommand = async (options = {}) => {
14627
+ if (options.printBrand !== false) process.stdout.write(renderHeader({
14628
+ version: APP_VERSION,
14629
+ command: "Update check",
14630
+ context: ["npm"]
14631
+ }));
14632
+ const latest = await fetchLatestVersion();
14633
+ process.stdout.write(buildUpdateStatusRender({
14634
+ current: APP_VERSION,
14635
+ latest
14636
+ }));
14637
+ };
14638
+
13228
14639
  //#endregion
13229
14640
  //#region src/cli.ts
13230
14641
  process.on("SIGINT", () => process.exit(0));
@@ -13253,6 +14664,7 @@ const runScan = async (directory, flags) => {
13253
14664
  const { exitCode } = await scanCommand(directory, finalConfig, {
13254
14665
  changes: Boolean(flags.changes),
13255
14666
  staged: Boolean(flags.staged),
14667
+ base: flags.base,
13256
14668
  verbose: Boolean(flags.verbose),
13257
14669
  json: !sarif && wantsJson(flags),
13258
14670
  sarif,
@@ -13265,46 +14677,32 @@ const runScan = async (directory, flags) => {
13265
14677
  }
13266
14678
  };
13267
14679
  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);
13268
- 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) => {
13269
- if (noFlagsPassed(flags) && process.stdin.isTTY) try {
14680
+ const hasNoUserArgs = () => process.argv.slice(2).length === 0;
14681
+ const shouldRenderRootHelp = () => {
14682
+ const args = process.argv.slice(2);
14683
+ return args.length === 1 && [
14684
+ "--help",
14685
+ "-h",
14686
+ "help"
14687
+ ].includes(args[0] ?? "");
14688
+ };
14689
+ const shouldRenderPlainVersion = () => {
14690
+ const args = process.argv.slice(2);
14691
+ return args.length === 1 && [
14692
+ "-V",
14693
+ "-v",
14694
+ "--version",
14695
+ "version"
14696
+ ].includes(args[0] ?? "");
14697
+ };
14698
+ const program = new Command().name("aislop").description("The quality gate for agentic coding.").version(APP_VERSION, "-v, --version").argument("[directory]", "directory to scan when no command is passed", ".").option("--changes", "only scan changed files (git diff)").option("--staged", "only scan staged files").option("--base <ref>", "diff base for --changes, e.g. origin/main (default HEAD)").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) => {
14699
+ if (hasNoUserArgs() && noFlagsPassed(flags) && process.stdin.isTTY) try {
13270
14700
  await interactiveCommand(directory, loadConfig(directory));
13271
14701
  return;
13272
14702
  } catch {}
13273
14703
  await runScan(directory, flags);
13274
- }).addHelpText("beforeAll", renderHeader({
13275
- version: APP_VERSION,
13276
- command: "--bare",
13277
- context: []
13278
- })).addHelpText("after", `
13279
- ${style(theme, "dim", "Commands:")}
13280
- npx aislop scan [dir] Full code quality scan
13281
- npx aislop fix [dir] Auto-fix ai slop in codebase
13282
- npx aislop init [dir] Initialize aislop config
13283
- npx aislop doctor [dir] Check installed tools
13284
- npx aislop ci [dir] CI-friendly JSON output
13285
- npx aislop rules [dir] List all rules
13286
- npx aislop trend [dir] Show score history trend
13287
-
13288
- ${style(theme, "dim", "Examples:")}
13289
- npx aislop Interactive menu
13290
- npx aislop scan Scan entire project
13291
- npx aislop scan -d Scan with file/line details
13292
- npx aislop scan --changes Scan only changed files
13293
- npx aislop scan --staged Scan only staged files (for hooks)
13294
- npx aislop fix Auto-fix ai slop in codebase
13295
- npx aislop fix -f Run aggressive fixes (includes audit and dependency alignment)
13296
- npx aislop fix --claude Open Claude Code to fix remaining issues
13297
- npx aislop fix --cursor Open Cursor + copy prompt to clipboard
13298
- npx aislop fix -p Print a prompt to paste into any coding agent
13299
- npx aislop ci JSON output for CI pipelines
13300
- npx aislop scan --sarif SARIF 2.1.0 for GitHub code scanning
13301
- npx aislop trend Show score history over time
13302
- npx aislop scan --exclude node_modules
13303
- npx aislop scan --exclude node_modules,dist,file.txt
13304
- npx aislop scan --exclude node_modules --exclude dist --exclude **/*.ts
13305
- ${renderHintLine("Run npx aislop scan to scan your project").trimEnd()}
13306
- `);
13307
- 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) => {
14704
+ });
14705
+ program.command("scan [directory]").description("Score a project and print findings").option("--changes", "only scan changed files").option("--staged", "only scan staged files").option("--base <ref>", "diff base for --changes, e.g. origin/main (default HEAD)").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) => {
13308
14706
  await runScan(directory, command.optsWithGlobals());
13309
14707
  });
13310
14708
  const FIX_AGENT_FLAGS = [
@@ -13392,7 +14790,7 @@ const FIX_AGENT_FLAGS = [
13392
14790
  const matchFixAgent = (flags) => {
13393
14791
  return FIX_AGENT_FLAGS.find((a) => flags[a.name])?.flag;
13394
14792
  };
13395
- const fixProgram = program.command("fix [directory]").description("Auto-fix ai slop in codebase").option("-d, --verbose", "show detailed fix progress").option("-f, --force", "run aggressive fixes (audit and framework dependency alignment)").option("--safe", "only apply reversible fixes (imports, comment removal, formatting); skip anything that deletes code or rewrites behaviour").option("-p, --prompt", "print a prompt for your coding agent to fix remaining issues");
14793
+ const fixProgram = program.command("fix [directory]").description("Auto-fix findings or hand off to a coding agent").option("-d, --verbose", "show detailed fix progress").option("-f, --force", "run aggressive fixes (audit and framework dependency alignment)").option("--safe", "only apply reversible fixes (imports, comment removal, formatting); skip anything that deletes code or rewrites behaviour").option("-p, --prompt", "print a prompt for your coding agent to fix remaining issues");
13396
14794
  for (const a of FIX_AGENT_FLAGS) fixProgram.option(`--${a.flag}`, a.help);
13397
14795
  fixProgram.action(async (directory = ".", _flags, command) => {
13398
14796
  const flags = command.optsWithGlobals();
@@ -13404,7 +14802,7 @@ fixProgram.action(async (directory = ".", _flags, command) => {
13404
14802
  agent: matchFixAgent(flags)
13405
14803
  });
13406
14804
  });
13407
- program.command("init [directory]").description("Initialize aislop config in project").option("--strict", "write an enterprise-grade default config: all engines, typecheck on, CI failBelow 85, workflow included").action(async (directory = ".", _flags, command) => {
14805
+ program.command("init [directory]").description("Create aislop config and optional CI workflow").option("--strict", "write an enterprise-grade default config: all engines, typecheck on, CI failBelow 85, workflow included").action(async (directory = ".", _flags, command) => {
13408
14806
  const flags = command.optsWithGlobals();
13409
14807
  await withCommandLifecycle({
13410
14808
  command: "init",
@@ -13414,7 +14812,7 @@ program.command("init [directory]").description("Initialize aislop config in pro
13414
14812
  return { exitCode: 0 };
13415
14813
  });
13416
14814
  });
13417
- program.command("doctor [directory]").description("Check installed tools and environment").action(async (directory = ".") => {
14815
+ program.command("doctor [directory]").description("Check toolchain coverage for this project").action(async (directory = ".") => {
13418
14816
  await withCommandLifecycle({
13419
14817
  command: "doctor",
13420
14818
  config: loadConfig(directory).telemetry
@@ -13423,9 +14821,21 @@ program.command("doctor [directory]").description("Check installed tools and env
13423
14821
  return { exitCode: 0 };
13424
14822
  });
13425
14823
  });
13426
- 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) => {
14824
+ const ciProgram = program.command("ci [directory]").description("Run the quality gate for CI");
14825
+ for (const [flag, description] of [
14826
+ ["--changes", "only gate files changed vs --base (or HEAD)"],
14827
+ ["--staged", "only gate staged files"],
14828
+ ["--base <ref>", "diff base for --changes, e.g. origin/main (default HEAD)"],
14829
+ ["--human", "render the human-friendly scan design instead of JSON"],
14830
+ ["--sarif", "output SARIF 2.1.0 (for GitHub code scanning)"],
14831
+ ["--format <format>", "output format: json or sarif"]
14832
+ ]) ciProgram.option(flag, description);
14833
+ ciProgram.action(async (directory = ".", _flags, command) => {
13427
14834
  const flags = command.optsWithGlobals();
13428
14835
  const { exitCode } = await ciCommand(directory, loadConfig(directory), {
14836
+ changes: Boolean(flags.changes),
14837
+ staged: Boolean(flags.staged),
14838
+ base: flags.base,
13429
14839
  human: Boolean(flags.human),
13430
14840
  sarif: Boolean(flags.sarif) || flags.format === "sarif"
13431
14841
  });
@@ -13434,16 +14844,17 @@ program.command("ci [directory]").description("CI-friendly JSON output with exit
13434
14844
  process.exitCode = exitCode;
13435
14845
  }
13436
14846
  });
13437
- program.command("rules [directory]").description("List all available rules").action(async (directory = ".") => {
14847
+ program.command("rules [directory]").description("Explain rules, severity, and fix mode").option("-s, --search", "open an interactive searchable rule explorer").action(async (directory = ".", _flags, command) => {
14848
+ const flags = command.optsWithGlobals();
13438
14849
  await withCommandLifecycle({
13439
14850
  command: "rules",
13440
14851
  config: loadConfig(directory).telemetry
13441
14852
  }, async () => {
13442
- await rulesCommand(directory);
14853
+ await rulesCommand(directory, { interactive: Boolean(flags.search) });
13443
14854
  return { exitCode: 0 };
13444
14855
  });
13445
14856
  });
13446
- program.command("badge [directory]").description("Print the public score badge URL + README markdown for this repo").option("--owner <owner>", "GitHub owner (auto-detected from git remote if omitted)").option("--repo <repo>", "GitHub repo name (auto-detected from git remote if omitted)").option("--json", "emit machine-readable JSON instead of the rendered output").action(async (directory = ".", _flags, command) => {
14857
+ program.command("badge [directory]").description("Print score badge URL and README markdown").option("--owner <owner>", "GitHub owner (auto-detected from git remote if omitted)").option("--repo <repo>", "GitHub repo name (auto-detected from git remote if omitted)").option("--json", "emit machine-readable JSON instead of the rendered output").action(async (directory = ".", _flags, command) => {
13447
14858
  const flags = command.optsWithGlobals();
13448
14859
  try {
13449
14860
  await withCommandLifecycle({
@@ -13464,7 +14875,7 @@ program.command("badge [directory]").description("Print the public score badge U
13464
14875
  process.exit(1);
13465
14876
  }
13466
14877
  });
13467
- 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) => {
14878
+ program.command("trend [directory]").description("Show local score history").option("--limit <n>", "number of recent runs to show", (v) => Number.parseInt(v, 10)).action(async (directory = ".", _flags, command) => {
13468
14879
  const flags = command.optsWithGlobals();
13469
14880
  await withCommandLifecycle({
13470
14881
  command: "trend",
@@ -13474,9 +14885,27 @@ program.command("trend [directory]").description("Show score history trend from
13474
14885
  return { exitCode: 0 };
13475
14886
  });
13476
14887
  });
14888
+ program.command("update").alias("upgrade").description("Check npm for the latest aislop version").action(async () => {
14889
+ await updateCommand();
14890
+ });
14891
+ program.command("version").description("Print the installed aislop version").action(() => {
14892
+ process.stdout.write(`${APP_VERSION}\n`);
14893
+ });
14894
+ program.command("commands").description("List all commands and major flags").action(() => {
14895
+ process.stdout.write(renderCommandReference({ version: APP_VERSION }));
14896
+ });
13477
14897
  registerHookCommand(program);
14898
+ registerHookAliases(program);
13478
14899
  const main = async () => {
13479
14900
  fireInstalledOnce();
14901
+ if (shouldRenderPlainVersion()) {
14902
+ process.stdout.write(`${APP_VERSION}\n`);
14903
+ return;
14904
+ }
14905
+ if (shouldRenderRootHelp()) {
14906
+ process.stdout.write(renderRootHelp({ version: APP_VERSION }));
14907
+ return;
14908
+ }
13480
14909
  await program.parseAsync();
13481
14910
  await flushTelemetry();
13482
14911
  await maybeNotifyUpdate();
@@ -13484,4 +14913,4 @@ const main = async () => {
13484
14913
  main();
13485
14914
 
13486
14915
  //#endregion
13487
- export { runSubprocess as n, APP_VERSION as r, ENGINE_INFO as t };
14916
+ export { APP_VERSION as a, runSubprocess as i, withFindingAssessments as n, ENGINE_INFO as r, summarizeFindingAssessments as t };