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/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import { t as APP_VERSION } from "./version-BfJVwhN2.js";
2
- import { n as getEngineLabel, t as ENGINE_INFO } from "./engine-info-Cpt36DqZ.js";
3
- import { n as runSubprocess, t as isToolInstalled } from "./subprocess-0uXz8HdE.js";
4
- import { r as runGenericLinter, t as fixRubyLint } from "./generic-BsQa13CS.js";
5
- import { n as runExpoDoctor } from "./expo-doctor-c-jE6pR2.js";
1
+ import { i as getEngineLabel, r as ENGINE_INFO, t as summarizeFindingAssessments } from "./finding-assessment-PCl1fnok.js";
2
+ import { n as runSubprocess, t as isToolInstalled } from "./subprocess-CQUJDGgn.js";
3
+ import { t as APP_VERSION } from "./version-BJA3AcRM.js";
4
+ import { r as runGenericLinter, t as fixRubyLint } from "./generic-D_T4cUaC.js";
5
+ import { n as runExpoDoctor } from "./expo-doctor-BwLKXF__.js";
6
6
  import { createRequire, isBuiltin } from "node:module";
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
@@ -18,6 +18,8 @@ import ts from "typescript";
18
18
  import os from "node:os";
19
19
  import { randomUUID } from "node:crypto";
20
20
  import { isCancel, multiselect, select, text } from "@clack/prompts";
21
+ import * as readline from "node:readline";
22
+ import { Writable } from "node:stream";
21
23
 
22
24
  //#region src/config/defaults.ts
23
25
  const DEFAULT_CONFIG = {
@@ -62,7 +64,8 @@ const DEFAULT_CONFIG = {
62
64
  good: 75,
63
65
  ok: 50
64
66
  },
65
- smoothing: 20
67
+ smoothing: 20,
68
+ maxPerRule: 40
66
69
  },
67
70
  ci: {
68
71
  failBelow: 70,
@@ -85,9 +88,9 @@ jobs:
85
88
  runs-on: ubuntu-latest
86
89
  steps:
87
90
  - uses: actions/checkout@v4
88
- - uses: scanaislop/aislop@v${APP_VERSION}
91
+ - uses: scanaislop/aislop@v1
89
92
  with:
90
- version: ${APP_VERSION}
93
+ version: latest
91
94
  `;
92
95
  const DEFAULT_RULES_YAML = `# Architecture rules (BYO)
93
96
  # Uncomment and customize to enforce your project's conventions.
@@ -186,7 +189,8 @@ const ScoringSchema = z.object({
186
189
  good: 75,
187
190
  ok: 50
188
191
  })),
189
- smoothing: z.number().nonnegative().default(20)
192
+ smoothing: z.number().nonnegative().default(20),
193
+ maxPerRule: z.number().positive().default(40)
190
194
  });
191
195
  const CiSchema = z.object({
192
196
  failBelow: z.number().default(70),
@@ -226,7 +230,8 @@ const AislopConfigSchema = z.object({
226
230
  good: 75,
227
231
  ok: 50
228
232
  },
229
- smoothing: 20
233
+ smoothing: 20,
234
+ maxPerRule: 40
230
235
  })),
231
236
  ci: CiSchema.default(() => ({
232
237
  failBelow: 70,
@@ -390,7 +395,7 @@ const renderHeader = (input, _deps = {}) => {
390
395
 
391
396
  //#endregion
392
397
  //#region src/ui/invocation.ts
393
- const detectInvocation = () => "npx aislop";
398
+ const detectInvocation = () => "aislop";
394
399
 
395
400
  //#endregion
396
401
  //#region src/ui/symbols.ts
@@ -546,6 +551,19 @@ const padStart = (s, target, fill = " ") => {
546
551
  if (w >= target) return s;
547
552
  return fill.repeat(target - w) + s;
548
553
  };
554
+ const truncate = (s, max, ellipsis = "…") => {
555
+ if (stringWidth(s) <= max) return s;
556
+ const limit = Math.max(0, max - stringWidth(ellipsis));
557
+ let out = "";
558
+ let w = 0;
559
+ for (const ch of s) {
560
+ const cw = wcwidth(ch.codePointAt(0) ?? 0);
561
+ if (w + cw > limit) break;
562
+ out += ch;
563
+ w += cw;
564
+ }
565
+ return out + ellipsis;
566
+ };
549
567
 
550
568
  //#endregion
551
569
  //#region src/utils/source-files.ts
@@ -1049,7 +1067,7 @@ const buildDoctorRender = (input) => {
1049
1067
  };
1050
1068
  const header = renderHeader({
1051
1069
  version: APP_VERSION,
1052
- command: "doctor",
1070
+ command: "Doctor report",
1053
1071
  context: [input.projectName, input.languageLabel].filter((s) => s.length > 0),
1054
1072
  brand: input.printBrand !== false
1055
1073
  }, deps);
@@ -1767,8 +1785,8 @@ const PHP_DECL_START = /^\s*(?:(?:public|private|protected|static|final|abstract
1767
1785
 
1768
1786
  //#endregion
1769
1787
  //#region src/engines/ai-slop/non-production-paths.ts
1770
- 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;
1771
- 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;
1788
+ 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;
1789
+ 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;
1772
1790
  const isNonProductionPath = (relativePath) => DIR_PATTERN.test(relativePath) || BASENAME_PATTERN.test(relativePath);
1773
1791
 
1774
1792
  //#endregion
@@ -1884,7 +1902,7 @@ const JS_EXTENSIONS$4 = new Set([
1884
1902
  ".mjs",
1885
1903
  ".cjs"
1886
1904
  ]);
1887
- const CONSOLE_LOG_PATTERN = /\bconsole\.(?:log|debug|info|trace|dir|table)\s*\(/;
1905
+ const CONSOLE_CALL_PATTERN = /\bconsole\.(log|debug|info|trace|dir|table)\s*\(/;
1888
1906
  const slop = (filePath, line, rule, severity, message, help, fixable) => ({
1889
1907
  filePath,
1890
1908
  engine: "ai-slop",
@@ -1898,20 +1916,35 @@ const slop = (filePath, line, rule, severity, message, help, fixable) => ({
1898
1916
  fixable
1899
1917
  });
1900
1918
  const LOGGER_FILE_PATTERN = /(?:^|\/)(?:logger|logging|log)\.[^/]+$/i;
1919
+ const CLI_ENTRYPOINT_PATTERN = /(?:^|\/)(?:cli|cli[-_.][^/]*|[^/]+[-_]cli)\.[mc]?[jt]sx?$/i;
1920
+ const ENTRYPOINT_GUARD_PATTERN = /\b(?:import\.meta\.main|require\.main\s*===\s*module)\b/;
1921
+ const OPERATIONAL_LOG_PATTERN = /\bconsole\.(?:log|info)\s*\(\s*(?:`|["'])\s*\[[^\]\n]{1,48}\]/;
1922
+ const DEBUG_SIGNAL_PATTERN = /\b(?:debug|dbg|trace|dump|inspect|todo|tmp|temp|remove\s+me|leftover|here|checkpoint)\b/i;
1923
+ const shouldFlagConsoleCall = (trimmed) => {
1924
+ const match = CONSOLE_CALL_PATTERN.exec(trimmed);
1925
+ if (!match) return false;
1926
+ const method = match[1];
1927
+ if (method === "trace" || method === "dir" || method === "table") return true;
1928
+ if (method === "debug") return DEBUG_SIGNAL_PATTERN.test(trimmed) || !OPERATIONAL_LOG_PATTERN.test(trimmed);
1929
+ if (method === "info" || method === "log") {
1930
+ if (/console\.log\(\s*JSON\.stringify\b/.test(trimmed)) return false;
1931
+ if (OPERATIONAL_LOG_PATTERN.test(trimmed)) return false;
1932
+ return true;
1933
+ }
1934
+ return false;
1935
+ };
1901
1936
  const detectConsoleLeftovers = (content, relativePath, ext) => {
1902
1937
  if (!JS_EXTENSIONS$4.has(ext)) return [];
1903
1938
  if (LOGGER_FILE_PATTERN.test(relativePath)) return [];
1904
- if (isNonProductionPath(relativePath)) return [];
1939
+ if (isNonProductionPath(relativePath) || CLI_ENTRYPOINT_PATTERN.test(relativePath)) return [];
1940
+ if (content.startsWith("#!")) return [];
1941
+ if (ENTRYPOINT_GUARD_PATTERN.test(content)) return [];
1905
1942
  const diagnostics = [];
1906
1943
  const lines = content.split("\n");
1907
1944
  for (let i = 0; i < lines.length; i++) {
1908
1945
  const trimmed = lines[i].trim();
1909
1946
  if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue;
1910
- if (CONSOLE_LOG_PATTERN.test(trimmed)) {
1911
- if (/console\.(?:error|warn)\s*\(/.test(trimmed)) continue;
1912
- if (/console\.log\(\s*JSON\.stringify\b/.test(trimmed)) continue;
1913
- 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));
1914
- }
1947
+ 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));
1915
1948
  }
1916
1949
  return diagnostics;
1917
1950
  };
@@ -1939,6 +1972,7 @@ const isGuardedSingleLineExit = (lines, lineIndex) => {
1939
1972
  const control = contextLines.join(" ");
1940
1973
  return /(?:^|[}\s])(?:if|else\s+if|for|while)\s*\(/.test(control) && !/{\s*$/.test(control);
1941
1974
  };
1975
+ const isPropertyNoopAssignment = (trimmed) => /^(?:[\w$]+\.)+[\w$]+\s*=\s*(?:function\s*)?\([^)]*\)\s*(?:=>)?\s*\{\s*\}\s*;?$/.test(trimmed);
1942
1976
  const detectTodoStubs = (content, relativePath) => {
1943
1977
  const diagnostics = [];
1944
1978
  const lines = content.split("\n");
@@ -1960,7 +1994,7 @@ const detectDeadCodePatterns = (content, relativePath, ext) => {
1960
1994
  const nextLine = i + 1 < lines.length ? lines[i + 1]?.trim() : void 0;
1961
1995
  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));
1962
1996
  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));
1963
- 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));
1997
+ 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));
1964
1998
  }
1965
1999
  return diagnostics;
1966
2000
  };
@@ -2458,7 +2492,7 @@ const JS_RESOLUTION_EXTENSIONS = [
2458
2492
  "/index.js",
2459
2493
  "/index.jsx"
2460
2494
  ];
2461
- const readJson$2 = (filePath) => {
2495
+ const readJson$3 = (filePath) => {
2462
2496
  try {
2463
2497
  return JSON.parse(fs.readFileSync(filePath, "utf-8"));
2464
2498
  } catch {
@@ -2473,7 +2507,7 @@ const buildAliasMatcher = (key) => {
2473
2507
  return (spec) => spec.length >= before.length + after.length && spec.startsWith(before) && spec.endsWith(after);
2474
2508
  };
2475
2509
  const collectAliasMatchersFromConfig = (configPath, matchers) => {
2476
- const opts = readJson$2(configPath)?.compilerOptions;
2510
+ const opts = readJson$3(configPath)?.compilerOptions;
2477
2511
  if (!opts || typeof opts !== "object") return;
2478
2512
  const configDir = path.dirname(configPath);
2479
2513
  const paths = opts.paths;
@@ -2496,7 +2530,7 @@ const collectTsPathAliases = (rootDir, workspaceDirs) => {
2496
2530
 
2497
2531
  //#endregion
2498
2532
  //#region src/engines/ai-slop/js-workspaces.ts
2499
- const readJson$1 = (filePath) => {
2533
+ const readJson$2 = (filePath) => {
2500
2534
  try {
2501
2535
  return JSON.parse(fs.readFileSync(filePath, "utf-8"));
2502
2536
  } catch {
@@ -2516,7 +2550,7 @@ const readWorkspaceGlobs = (rootDir, rootPkg) => {
2516
2550
  }
2517
2551
  }
2518
2552
  }
2519
- const lerna = readJson$1(path.join(rootDir, "lerna.json"));
2553
+ const lerna = readJson$2(path.join(rootDir, "lerna.json"));
2520
2554
  if (lerna && Array.isArray(lerna.packages)) {
2521
2555
  for (const g of lerna.packages) if (typeof g === "string") globs.push(g);
2522
2556
  }
@@ -2917,7 +2951,7 @@ const JS_EXTENSIONS$2 = new Set([
2917
2951
  ".cjs"
2918
2952
  ]);
2919
2953
  const PY_EXTENSIONS$2 = new Set([".py"]);
2920
- const readJson = (filePath) => {
2954
+ const readJson$1 = (filePath) => {
2921
2955
  try {
2922
2956
  return JSON.parse(fs.readFileSync(filePath, "utf-8"));
2923
2957
  } catch {
@@ -2961,7 +2995,7 @@ const collectNestedManifests = (rootDir, jsDeps) => {
2961
2995
  const full = path.join(dir, entry.name);
2962
2996
  if (entry.isDirectory()) walk(full, depth + 1);
2963
2997
  else if (entry.name === "package.json" && depth > 0) {
2964
- const wsPkg = readJson(full);
2998
+ const wsPkg = readJson$1(full);
2965
2999
  if (!wsPkg) continue;
2966
3000
  if (typeof wsPkg.name === "string") jsDeps.add(wsPkg.name);
2967
3001
  addDepsFromPkg(wsPkg, jsDeps);
@@ -2973,13 +3007,13 @@ const collectNestedManifests = (rootDir, jsDeps) => {
2973
3007
  const collectJsDeps = (rootDir, jsDeps) => {
2974
3008
  const pkgPath = path.join(rootDir, "package.json");
2975
3009
  if (!fs.existsSync(pkgPath)) return false;
2976
- const pkg = readJson(pkgPath);
3010
+ const pkg = readJson$1(pkgPath);
2977
3011
  if (!pkg || typeof pkg !== "object") return false;
2978
3012
  addDepsFromPkg(pkg, jsDeps);
2979
3013
  if (typeof pkg.name === "string") jsDeps.add(pkg.name);
2980
3014
  const workspaceDirs = collectWorkspaceDirs(rootDir, pkg);
2981
3015
  for (const wsDir of workspaceDirs) {
2982
- const wsPkg = readJson(path.join(wsDir, "package.json"));
3016
+ const wsPkg = readJson$1(path.join(wsDir, "package.json"));
2983
3017
  if (!wsPkg) continue;
2984
3018
  if (typeof wsPkg.name === "string") jsDeps.add(wsPkg.name);
2985
3019
  addDepsFromPkg(wsPkg, jsDeps);
@@ -3008,7 +3042,11 @@ const VIRTUAL_MODULE_PREFIXES = [
3008
3042
  "astro:",
3009
3043
  "virtual:",
3010
3044
  "bun:",
3011
- "file:"
3045
+ "file:",
3046
+ "http:",
3047
+ "https:",
3048
+ "jsr:",
3049
+ "npm:"
3012
3050
  ];
3013
3051
  const isJsVirtualModule = (spec, manifest) => {
3014
3052
  if (VIRTUAL_MODULE_PREFIXES.some((p) => spec.startsWith(p))) return true;
@@ -3137,7 +3175,7 @@ const checkPyImport = (spec, manifest) => {
3137
3175
  return root;
3138
3176
  };
3139
3177
  const detectHallucinatedImports = async (context) => {
3140
- const rootPkg = readJson(path.join(context.rootDirectory, "package.json"));
3178
+ const rootPkg = readJson$1(path.join(context.rootDirectory, "package.json"));
3141
3179
  const workspaceDirs = collectWorkspaceDirs(context.rootDirectory, rootPkg);
3142
3180
  const manifest = loadManifest(context.rootDirectory);
3143
3181
  if (!manifest.hasJsManifest && !manifest.hasPyManifest) return [];
@@ -3242,6 +3280,9 @@ const VENDOR_API_DOMAINS = [
3242
3280
  ];
3243
3281
  const isVendorApiHost = (host) => VENDOR_API_DOMAINS.some((d) => host === d || host.endsWith(`.${d}`));
3244
3282
  const PLACEHOLDER_ID_RE = /^(?:changeme|replace[_-]?me|your[_-]|example|placeholder|todo)/i;
3283
+ 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;
3284
+ 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;
3285
+ const READABLE_KEY_RE = /^[a-z][a-z0-9]*(?:[_-][a-z0-9]+){2,}$/;
3245
3286
  const HARDCODED_URL_FINDING = {
3246
3287
  rule: "ai-slop/hardcoded-url",
3247
3288
  message: "Hardcoded environment URL in production code",
@@ -3279,8 +3320,10 @@ const safeUrlHost = (urlText) => {
3279
3320
  }
3280
3321
  };
3281
3322
  const isEnvBackedLine = (line) => ENV_REFERENCE_RE.test(line);
3323
+ const TEMPLATE_INTERPOLATION_START = "${";
3282
3324
  const shouldFlagUrlLiteral = (line, urlText) => {
3283
3325
  if (isEnvBackedLine(line)) return false;
3326
+ if (urlText.includes(TEMPLATE_INTERPOLATION_START) && /\bnew\s+URL\s*\(/.test(line)) return false;
3284
3327
  const host = safeUrlHost(urlText);
3285
3328
  if (!host) return false;
3286
3329
  if (PLACEHOLDER_HOSTS.has(host)) return false;
@@ -3295,7 +3338,11 @@ const hasUsefulIdShape = (value) => {
3295
3338
  if (ENV_VAR_NAME_RE.test(value)) return false;
3296
3339
  if (/^https?:\/\//i.test(value)) return false;
3297
3340
  if (/^[A-Za-z]+$/.test(value)) return false;
3298
- return /[0-9]/.test(value);
3341
+ if (READABLE_KEY_RE.test(value) && !PROVIDER_ID_RE.test(value)) return false;
3342
+ if (PROVIDER_ID_RE.test(value)) return true;
3343
+ if (UUID_RE.test(value)) return true;
3344
+ if (!/[0-9]/.test(value)) return false;
3345
+ return value.length >= 24 && !/[_-]/.test(value) && /[a-z]/.test(value) && /[A-Z]/.test(value);
3299
3346
  };
3300
3347
  const scanLineForConfigLiterals = (line, relativePath, ext, lineNumber) => {
3301
3348
  const diagnostics = [];
@@ -5309,8 +5356,8 @@ const shouldIncludeIssue = (issueType, filePath) => {
5309
5356
  return !filePath.replace(/\\/g, "/").includes(".github/workflows/");
5310
5357
  };
5311
5358
  const DEPENDENCY_HELP = {
5312
- dependencies: "This package is listed in package.json but not imported anywhere. Remove it with `npm uninstall` or `npx aislop fix`.",
5313
- devDependencies: "This package is listed in package.json but not imported anywhere. Remove it with `npm uninstall` or `npx aislop fix`.",
5359
+ dependencies: "This package is listed in package.json but not imported anywhere. Remove it with `npm uninstall` or `aislop fix`.",
5360
+ devDependencies: "This package is listed in package.json but not imported anywhere. Remove it with `npm uninstall` or `aislop fix`.",
5314
5361
  unlisted: "This package is imported in code but not declared in package.json. Run `npm install` to add it.",
5315
5362
  unresolved: "This import cannot be resolved. Check for typos or missing packages.",
5316
5363
  binaries: "This binary is used but its package is not in package.json."
@@ -5659,7 +5706,7 @@ const parseBiomeJsonOutput = (output, rootDir) => {
5659
5706
  rule: "formatting",
5660
5707
  severity,
5661
5708
  message,
5662
- help: "Run `npx aislop fix` to auto-format",
5709
+ help: "Run `aislop fix` to auto-format",
5663
5710
  line: entry.location?.start?.line ?? 0,
5664
5711
  column: entry.location?.start?.column ?? 0,
5665
5712
  category: "Format",
@@ -5698,7 +5745,7 @@ const FORMATTERS = {
5698
5745
  rule: "rust-formatting",
5699
5746
  severity: "warning",
5700
5747
  message: "Rust file is not formatted correctly",
5701
- help: "Run `npx aislop fix` to auto-format with rustfmt",
5748
+ help: "Run `aislop fix` to auto-format with rustfmt",
5702
5749
  line: parseInt(match[2], 10),
5703
5750
  column: 0,
5704
5751
  category: "Format",
@@ -5731,7 +5778,7 @@ const FORMATTERS = {
5731
5778
  rule: offense.cop_name ?? "ruby-formatting",
5732
5779
  severity: "warning",
5733
5780
  message: offense.message ?? "Ruby formatting issue",
5734
- help: "Run `npx aislop fix` to auto-format",
5781
+ help: "Run `aislop fix` to auto-format",
5735
5782
  line: offense.location?.start_line ?? 0,
5736
5783
  column: offense.location?.start_column ?? 0,
5737
5784
  category: "Format",
@@ -5762,7 +5809,7 @@ const FORMATTERS = {
5762
5809
  rule: "php-formatting",
5763
5810
  severity: "warning",
5764
5811
  message: "PHP file is not formatted correctly",
5765
- help: "Run `npx aislop fix` to auto-format",
5812
+ help: "Run `aislop fix` to auto-format",
5766
5813
  line: 0,
5767
5814
  column: 0,
5768
5815
  category: "Format",
@@ -5815,7 +5862,7 @@ const runGofmt = async (context) => {
5815
5862
  rule: "go-formatting",
5816
5863
  severity: "warning",
5817
5864
  message: "Go file is not formatted correctly",
5818
- help: "Run `npx aislop fix` to auto-format with gofmt",
5865
+ help: "Run `aislop fix` to auto-format with gofmt",
5819
5866
  line: 0,
5820
5867
  column: 0,
5821
5868
  category: "Format",
@@ -5881,7 +5928,7 @@ const parseRuffFormatOutput = (output, rootDir) => {
5881
5928
  rule: "python-formatting",
5882
5929
  severity: "warning",
5883
5930
  message: "Python file is not formatted correctly",
5884
- help: "Run `npx aislop fix` to auto-format with ruff",
5931
+ help: "Run `aislop fix` to auto-format with ruff",
5885
5932
  line: 0,
5886
5933
  column: 0,
5887
5934
  category: "Format",
@@ -6293,6 +6340,7 @@ const AMBIENT_GLOBAL_DEPS = [
6293
6340
  const SST_PLATFORM_REF_RE = /\/\/\/\s*<reference\s+path=["'][^"']*sst[\\/]+platform[\\/]+config\.d\.ts["']/;
6294
6341
  const ICON_AUTOIMPORT_RE = /^Icon[A-Z]/;
6295
6342
  const NO_UNDEF_IDENT_RE = /^['‘"`]([^'’"`]+)['’"`]/;
6343
+ const SUPABASE_FUNCTION_PATH_RE = /(?:^|\/)supabase\/functions\/[^/]+\/.+\.[cm]?[jt]sx?$/;
6296
6344
  const detectAmbientSources = (rootDir) => {
6297
6345
  const found = /* @__PURE__ */ new Set();
6298
6346
  const skipDirs = new Set([
@@ -6337,6 +6385,36 @@ const detectAmbientSources = (rootDir) => {
6337
6385
  const extractNoUndefIdentifier = (message) => {
6338
6386
  return NO_UNDEF_IDENT_RE.exec(message)?.[1] ?? null;
6339
6387
  };
6388
+ const looksLikeChromeExtensionManifest = (filePath) => {
6389
+ try {
6390
+ const manifest = JSON.parse(fs.readFileSync(filePath, "utf-8"));
6391
+ return typeof manifest.manifest_version === "number" && ("background" in manifest || "content_scripts" in manifest || "permissions" in manifest);
6392
+ } catch {
6393
+ return false;
6394
+ }
6395
+ };
6396
+ const chromeExtensionFileCache = /* @__PURE__ */ new Map();
6397
+ const isChromeExtensionFile = (rootDir, relativeFilePath) => {
6398
+ const cacheKey = `${rootDir}:${relativeFilePath.split(path.sep).join("/")}`;
6399
+ const cached = chromeExtensionFileCache.get(cacheKey);
6400
+ if (cached !== void 0) return cached;
6401
+ const absolute = path.isAbsolute(relativeFilePath) ? relativeFilePath : path.join(rootDir, relativeFilePath);
6402
+ const root = path.resolve(rootDir);
6403
+ let dir = path.dirname(path.resolve(absolute));
6404
+ let matched = false;
6405
+ while (true) {
6406
+ const relativeToRoot = path.relative(root, dir);
6407
+ if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) break;
6408
+ if (looksLikeChromeExtensionManifest(path.join(dir, "manifest.json"))) {
6409
+ matched = true;
6410
+ break;
6411
+ }
6412
+ if (dir === root) break;
6413
+ dir = path.dirname(dir);
6414
+ }
6415
+ chromeExtensionFileCache.set(cacheKey, matched);
6416
+ return matched;
6417
+ };
6340
6418
  const isAmbientFalsePositive = (rule, message, sources) => {
6341
6419
  if (rule !== "eslint/no-undef") return false;
6342
6420
  const ident = extractNoUndefIdentifier(message);
@@ -6345,9 +6423,19 @@ const isAmbientFalsePositive = (rule, message, sources) => {
6345
6423
  if ((sources.has("@types/bun") || sources.has("bun-types")) && ident === "Bun") return true;
6346
6424
  return false;
6347
6425
  };
6426
+ const isRuntimeGlobalFalsePositive = (rule, message, rootDir, relativeFilePath) => {
6427
+ if (rule !== "eslint/no-undef") return false;
6428
+ const ident = extractNoUndefIdentifier(message);
6429
+ if (!ident) return false;
6430
+ const normalized = relativeFilePath.split(path.sep).join("/");
6431
+ if (ident === "Deno" && SUPABASE_FUNCTION_PATH_RE.test(normalized)) return true;
6432
+ if (ident === "chrome" && isChromeExtensionFile(rootDir, relativeFilePath)) return true;
6433
+ return false;
6434
+ };
6348
6435
  const sstReferencedFiles = /* @__PURE__ */ new Map();
6349
6436
  const clearSstReferenceCache = () => {
6350
6437
  sstReferencedFiles.clear();
6438
+ chromeExtensionFileCache.clear();
6351
6439
  };
6352
6440
  const fileReferencesSstPlatform = (rootDir, relativeFilePath) => {
6353
6441
  const cached = sstReferencedFiles.get(relativeFilePath);
@@ -6399,6 +6487,32 @@ const collectPackageNames = (dir) => {
6399
6487
  }
6400
6488
  return names;
6401
6489
  };
6490
+ const readJson = (filePath) => {
6491
+ const raw = readTextFile$1(filePath);
6492
+ if (!raw) return null;
6493
+ try {
6494
+ const parsed = JSON.parse(raw);
6495
+ return parsed && typeof parsed === "object" ? parsed : null;
6496
+ } catch {
6497
+ return null;
6498
+ }
6499
+ };
6500
+ const hasBunRuntime = (rootDir, projectFiles) => {
6501
+ if (fs.existsSync(path.join(rootDir, "bun.lock")) || fs.existsSync(path.join(rootDir, "bun.lockb")) || fs.existsSync(path.join(rootDir, "bunfig.toml"))) return true;
6502
+ const hasBunFiles = projectFiles.some((filePath) => /(?:^|\/)bunfig\.toml$|(?:^|\/)bun\.lockb?$/.test(filePath));
6503
+ const pkg = readJson(path.join(rootDir, "package.json"));
6504
+ if (!pkg) return hasBunFiles;
6505
+ if (typeof pkg.packageManager === "string" && /^bun@/i.test(pkg.packageManager)) return true;
6506
+ const scripts = pkg.scripts;
6507
+ if (scripts && typeof scripts === "object") {
6508
+ for (const command of Object.values(scripts)) if (typeof command === "string" && /(?:^|[;&|()\s])bunx?\s/.test(command)) return true;
6509
+ }
6510
+ return hasBunFiles;
6511
+ };
6512
+ const hasDenoRuntime = (rootDir, projectFiles) => {
6513
+ if (fs.existsSync(path.join(rootDir, "deno.json")) || fs.existsSync(path.join(rootDir, "deno.jsonc"))) return true;
6514
+ return projectFiles.some((filePath) => /(?:^|\/)deno\.jsonc?$/.test(filePath));
6515
+ };
6402
6516
  const AMBIENT_GLOBAL_RE = /^\s*(?:declare\s+)?(?:const|let|var|function|class)\s+([A-Za-z_$][\w$]*)/gm;
6403
6517
  const collectAmbientGlobals = (rootDir) => {
6404
6518
  const globals = /* @__PURE__ */ new Set();
@@ -6410,7 +6524,8 @@ const collectAmbientGlobals = (rootDir) => {
6410
6524
  for (const match of content.matchAll(AMBIENT_GLOBAL_RE)) globals.add(match[1]);
6411
6525
  }
6412
6526
  const deps = collectPackageNames(rootDir);
6413
- if (deps.has("@types/bun") || deps.has("bun-types")) globals.add("Bun");
6527
+ if (deps.has("@types/bun") || deps.has("bun-types") || hasBunRuntime(rootDir, projectFiles)) globals.add("Bun");
6528
+ if (hasDenoRuntime(rootDir, projectFiles)) globals.add("Deno");
6414
6529
  if (projectFiles.some((filePath) => /(?:^|\/)sst\.config\.ts$/.test(filePath))) for (const name of [
6415
6530
  "$app",
6416
6531
  "$config",
@@ -6566,6 +6681,37 @@ const removeDuplicateKeyLines = (rootDirectory, diagnostics) => {
6566
6681
  fs.writeFileSync(filePath, filtered.join("\n"));
6567
6682
  }
6568
6683
  };
6684
+ const toDiagnostic = (d) => {
6685
+ const { plugin, rule } = parseRuleCode(d.code);
6686
+ const label = d.labels[0];
6687
+ return {
6688
+ filePath: d.filename,
6689
+ engine: "lint",
6690
+ rule: `${plugin}/${rule}`,
6691
+ severity: d.severity,
6692
+ message: d.message.replace(/\S+\.\w+:\d+:\d+[\s\S]*$/, "").trim() || d.message,
6693
+ help: d.help || "",
6694
+ line: label?.span.line ?? 0,
6695
+ column: label?.span.column ?? 0,
6696
+ category: plugin === "react" ? "React" : plugin === "import" ? "Imports" : "Lint",
6697
+ fixable: false
6698
+ };
6699
+ };
6700
+ const shouldKeepOxlintDiagnostic = (context, ambientSources, seen, d) => {
6701
+ const relativePath = path.isAbsolute(d.filePath) ? path.relative(context.rootDirectory, d.filePath) : d.filePath;
6702
+ if (isExcludedFromScan(relativePath)) return false;
6703
+ if (isViteVirtualImportFalsePositive(d.rule, d.message)) return false;
6704
+ if (isAmbientFalsePositive(d.rule, d.message, ambientSources)) return false;
6705
+ if (isRuntimeGlobalFalsePositive(d.rule, d.message, context.rootDirectory, relativePath)) return false;
6706
+ if (isSolidRefFalsePositive(context, d)) return false;
6707
+ if (isContextualTypeScriptFalsePositive(d)) return false;
6708
+ if (isUnderscoreUnusedVar(d.rule, d.message)) return false;
6709
+ if (d.rule === "eslint/no-undef" && fileReferencesSstPlatform(context.rootDirectory, d.filePath)) return false;
6710
+ const key = `${d.filePath}:${d.line}:${d.rule}:${d.message}`;
6711
+ if (seen.has(key)) return false;
6712
+ seen.add(key);
6713
+ return true;
6714
+ };
6569
6715
  const runOxlint = async (context) => {
6570
6716
  const configPath = path.join(os.tmpdir(), `aislop-oxlintrc-${process.pid}.json`);
6571
6717
  const framework = context.frameworks.find((f) => f !== "none");
@@ -6602,34 +6748,7 @@ const runOxlint = async (context) => {
6602
6748
  return [];
6603
6749
  }
6604
6750
  const seen = /* @__PURE__ */ new Set();
6605
- return output.diagnostics.map((d) => {
6606
- const { plugin, rule } = parseRuleCode(d.code);
6607
- const label = d.labels[0];
6608
- return {
6609
- filePath: d.filename,
6610
- engine: "lint",
6611
- rule: `${plugin}/${rule}`,
6612
- severity: d.severity,
6613
- message: d.message.replace(/\S+\.\w+:\d+:\d+[\s\S]*$/, "").trim() || d.message,
6614
- help: d.help || "",
6615
- line: label?.span.line ?? 0,
6616
- column: label?.span.column ?? 0,
6617
- category: plugin === "react" ? "React" : plugin === "import" ? "Imports" : "Lint",
6618
- fixable: false
6619
- };
6620
- }).filter((d) => {
6621
- if (isExcludedFromScan(path.isAbsolute(d.filePath) ? path.relative(context.rootDirectory, d.filePath) : d.filePath)) return false;
6622
- if (isViteVirtualImportFalsePositive(d.rule, d.message)) return false;
6623
- if (isAmbientFalsePositive(d.rule, d.message, ambientSources)) return false;
6624
- if (isSolidRefFalsePositive(context, d)) return false;
6625
- if (isContextualTypeScriptFalsePositive(d)) return false;
6626
- if (isUnderscoreUnusedVar(d.rule, d.message)) return false;
6627
- if (d.rule === "eslint/no-undef" && fileReferencesSstPlatform(context.rootDirectory, d.filePath)) return false;
6628
- const key = `${d.filePath}:${d.line}:${d.rule}:${d.message}`;
6629
- if (seen.has(key)) return false;
6630
- seen.add(key);
6631
- return true;
6632
- });
6751
+ return output.diagnostics.map(toDiagnostic).filter((d) => shouldKeepOxlintDiagnostic(context, ambientSources, seen, d));
6633
6752
  } finally {
6634
6753
  if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
6635
6754
  }
@@ -6755,9 +6874,9 @@ const lintEngine = {
6755
6874
  const promises = [];
6756
6875
  if (languages.includes("typescript") || languages.includes("javascript")) {
6757
6876
  promises.push(runOxlint(context));
6758
- if (context.config.lint.typecheck) promises.push(import("./typecheck-BdQ7uFyK.js").then((mod) => mod.runTypecheck(context)));
6877
+ if (context.config.lint.typecheck) promises.push(import("./typecheck-DQSzG8fX.js").then((mod) => mod.runTypecheck(context)));
6759
6878
  }
6760
- if (context.frameworks.includes("expo")) promises.push(import("./expo-doctor-c-jE6pR2.js").then((n) => n.t).then((mod) => mod.runExpoDoctor(context)));
6879
+ if (context.frameworks.includes("expo")) promises.push(import("./expo-doctor-BwLKXF__.js").then((n) => n.t).then((mod) => mod.runExpoDoctor(context)));
6761
6880
  if (languages.includes("python") && installedTools.ruff) promises.push(runRuffLint(context));
6762
6881
  if (languages.includes("go") && installedTools["golangci-lint"]) promises.push(runGolangciLint(context));
6763
6882
  if (languages.includes("rust") && installedTools.cargo) promises.push(runGenericLinter(context, "rust"));
@@ -6923,7 +7042,7 @@ const parseJsAudit = (output, source) => {
6923
7042
  rule: "security/dependency-audit-skipped",
6924
7043
  severity: "info",
6925
7044
  message: `Dependency audit skipped (${source}): lockfile is missing`,
6926
- help: error.detail ?? "Generate a lockfile, then re-run `npx aislop scan` for dependency vulnerability checks.",
7045
+ help: error.detail ?? "Generate a lockfile, then re-run `aislop scan` for dependency vulnerability checks.",
6927
7046
  line: 0,
6928
7047
  column: 0,
6929
7048
  category: "Security",
@@ -7040,6 +7159,194 @@ const runCargoAudit = async (rootDir, timeout) => {
7040
7159
  }
7041
7160
  };
7042
7161
 
7162
+ //#endregion
7163
+ //#region src/engines/security/html-safety.ts
7164
+ const SAFE_EMPTY_INNER_HTML_RE = /^\.innerHTML\s*=\s*(?:""|''|``)\s*;?/;
7165
+ const SAFE_SANITIZED_INNER_HTML_RE = /^\.innerHTML\s*=\s*(?:escapeHtml|sanitizeHtml|sanitizeHTML|DOMPurify\.sanitize)\s*\([^;\n]*\)\s*;?(?:\n|$)/;
7166
+ const SANITIZER_EXPR_RE = /^(?:escapeHtml|escapeHTML|sanitizeHtml|sanitizeHTML|DOMPurify\.sanitize)\s*\([^;\n]*\)$/;
7167
+ const IDENT_RE = /^[A-Za-z_$][\w$]*$/;
7168
+ const STATIC_STRING_RE = /^(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\$])*`)$/;
7169
+ const NUMERICISH_EXPR_RE = /^(?:[-+]?\d+(?:\.\d+)?|[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*(?:\s*\|\|\s*[-+]?\d+(?:\.\d+)?)?)$/;
7170
+ 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;
7171
+ const SAFE_FORMAT_CALL_RE = /^(?:format[A-Z]\w*|fmt[A-Z]?\w*)\s*\((.*)\)$/;
7172
+ const consumeQuotedLiteral = (content, startIndex, quote) => {
7173
+ let i = startIndex + 1;
7174
+ while (i < content.length) {
7175
+ const char = content[i];
7176
+ if (char === "\\") {
7177
+ i += 2;
7178
+ continue;
7179
+ }
7180
+ if (char === quote) return { endIndex: i };
7181
+ if (char === "\n") return null;
7182
+ i++;
7183
+ }
7184
+ return null;
7185
+ };
7186
+ const consumeTemplateLiteral = (content, startIndex) => {
7187
+ const openIndex = content.indexOf("`", startIndex);
7188
+ if (openIndex === -1) return null;
7189
+ let i = openIndex + 1;
7190
+ while (i < content.length) {
7191
+ const char = content[i];
7192
+ if (char === "\\") {
7193
+ i += 2;
7194
+ continue;
7195
+ }
7196
+ if (char === "`") return {
7197
+ body: content.slice(openIndex + 1, i),
7198
+ endIndex: i
7199
+ };
7200
+ i++;
7201
+ }
7202
+ return null;
7203
+ };
7204
+ const assignmentTailIsClosed = (content, endIndex) => /^\s*(?:;[^\n]*)?(?:\n|$)/.test(content.slice(endIndex + 1));
7205
+ const assignmentRhsStart = (content, matchIndex) => {
7206
+ const match = /^\.innerHTML\s*=\s*/.exec(content.slice(matchIndex));
7207
+ return match ? matchIndex + match[0].length : null;
7208
+ };
7209
+ const templateExpressions = (templateBody) => [...templateBody.matchAll(/\$\{\s*([^}]+?)\s*\}/g)].map((match) => match[1].trim());
7210
+ const staticTernaryRe = /^\s*[^?]+\?\s*(?:"[^"]*"|'[^']*'|`[^`$]*`)\s*:\s*(?:"[^"]*"|'[^']*'|`[^`$]*`)\s*$/;
7211
+ const splitTopLevelTernary = (expr) => {
7212
+ let quote = null;
7213
+ let depth = 0;
7214
+ let question = -1;
7215
+ let colon = -1;
7216
+ for (let i = 0; i < expr.length; i++) {
7217
+ const char = expr[i];
7218
+ if (char === "\\") {
7219
+ i++;
7220
+ continue;
7221
+ }
7222
+ if ((char === "'" || char === "\"" || char === "`") && quote === null) {
7223
+ quote = char;
7224
+ continue;
7225
+ }
7226
+ if (char === quote) {
7227
+ quote = null;
7228
+ continue;
7229
+ }
7230
+ if (quote) continue;
7231
+ if (char === "(" || char === "[" || char === "{") depth++;
7232
+ else if (char === ")" || char === "]" || char === "}") depth = Math.max(0, depth - 1);
7233
+ else if (char === "?" && depth === 0 && question === -1) question = i;
7234
+ else if (char === ":" && depth === 0 && question !== -1) {
7235
+ colon = i;
7236
+ break;
7237
+ }
7238
+ }
7239
+ if (question === -1 || colon === -1) return null;
7240
+ return {
7241
+ whenTrue: expr.slice(question + 1, colon).trim(),
7242
+ whenFalse: expr.slice(colon + 1).trim()
7243
+ };
7244
+ };
7245
+ const isNumericishExpression = (expr) => {
7246
+ const normalized = expr.trim();
7247
+ if (/^(?:Math\.\w+|Number|parseInt|parseFloat)\s*\(/.test(normalized)) return true;
7248
+ if (!NUMERICISH_EXPR_RE.test(normalized)) return false;
7249
+ return /\d/.test(normalized) || NUMERICISH_NAME_RE.test(normalized);
7250
+ };
7251
+ const isSafeTemplateLiteralExpression = (expr, safeNames) => {
7252
+ if (!expr.startsWith("`") || !expr.endsWith("`")) return false;
7253
+ return templateExpressions(expr.slice(1, -1)).every((part) => isSafeHtmlExpression(part, safeNames));
7254
+ };
7255
+ const collectSafeHtmlNames = (content, matchIndex) => {
7256
+ const safeNames = /* @__PURE__ */ new Set();
7257
+ const prefix = content.slice(Math.max(0, matchIndex - 8e3), matchIndex);
7258
+ for (const rawLine of prefix.split("\n")) {
7259
+ const line = rawLine.trim();
7260
+ let match = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)\s*;?$/.exec(line);
7261
+ if (match) {
7262
+ const [, name, expr] = match;
7263
+ if (isSafeHtmlExpression(expr.trim(), safeNames)) safeNames.add(name);
7264
+ else safeNames.delete(name);
7265
+ continue;
7266
+ }
7267
+ match = /^([A-Za-z_$][\w$]*)\s*\+=\s*(.+?)\s*;?$/.exec(line);
7268
+ if (match) {
7269
+ const [, name, expr] = match;
7270
+ if (safeNames.has(name) && isSafeHtmlExpression(expr.trim(), safeNames)) safeNames.add(name);
7271
+ else safeNames.delete(name);
7272
+ continue;
7273
+ }
7274
+ match = /^([A-Za-z_$][\w$]*)\s*=\s*(.+?)\s*;?$/.exec(line);
7275
+ if (match) {
7276
+ const [, name, expr] = match;
7277
+ if (isSafeHtmlExpression(expr.trim(), safeNames)) safeNames.add(name);
7278
+ else safeNames.delete(name);
7279
+ }
7280
+ }
7281
+ return safeNames;
7282
+ };
7283
+ const isSafeHtmlExpression = (expr, safeNames) => {
7284
+ const normalized = expr.trim();
7285
+ if (SANITIZER_EXPR_RE.test(normalized)) return true;
7286
+ if (STATIC_STRING_RE.test(normalized)) return true;
7287
+ if (staticTernaryRe.test(expr)) return true;
7288
+ if (isNumericishExpression(normalized)) return true;
7289
+ if (IDENT_RE.test(normalized) && safeNames.has(normalized)) return true;
7290
+ if (isSafeTemplateLiteralExpression(normalized, safeNames)) return true;
7291
+ const ternary = splitTopLevelTernary(normalized);
7292
+ if (ternary && isSafeHtmlExpression(ternary.whenTrue, safeNames) && isSafeHtmlExpression(ternary.whenFalse, safeNames)) return true;
7293
+ const formatCall = SAFE_FORMAT_CALL_RE.exec(normalized);
7294
+ 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));
7295
+ return false;
7296
+ };
7297
+ const readSingleLineRhs = (content, rhsStart) => {
7298
+ const lineEnd = content.indexOf("\n", rhsStart);
7299
+ const line = content.slice(rhsStart, lineEnd === -1 ? content.length : lineEnd);
7300
+ let quote = null;
7301
+ for (let i = 0; i < line.length; i++) {
7302
+ const char = line[i];
7303
+ if (char === "\\") {
7304
+ i++;
7305
+ continue;
7306
+ }
7307
+ if ((char === "'" || char === "\"" || char === "`") && quote === null) {
7308
+ quote = char;
7309
+ continue;
7310
+ }
7311
+ if (char === quote) {
7312
+ quote = null;
7313
+ continue;
7314
+ }
7315
+ if (char === ";" && quote === null) return line.slice(0, i).trim();
7316
+ }
7317
+ return line.trim();
7318
+ };
7319
+ const isSafeMapJoinHtmlAssignment = (content, rhsStart) => {
7320
+ const head = content.slice(rhsStart);
7321
+ const mapMatch = /^[A-Za-z_$][\w$.]*\.map\(\s*[A-Za-z_$][\w$]*\s*=>\s*`/.exec(head);
7322
+ if (!mapMatch) return false;
7323
+ const template = consumeTemplateLiteral(content, rhsStart + mapMatch[0].length - 1);
7324
+ if (!template) return false;
7325
+ if (!/^\s*\)\.join\(\s*(?:""|'')\s*\)/.test(content.slice(template.endIndex + 1))) return false;
7326
+ const safeNames = collectSafeHtmlNames(content, rhsStart);
7327
+ return templateExpressions(template.body).every((expr) => isSafeHtmlExpression(expr, safeNames));
7328
+ };
7329
+ const isSafeInnerHtmlAssignment = (content, matchIndex) => {
7330
+ const tail = content.slice(matchIndex);
7331
+ if (SAFE_EMPTY_INNER_HTML_RE.test(tail) || SAFE_SANITIZED_INNER_HTML_RE.test(tail)) return true;
7332
+ const rhsStart = assignmentRhsStart(content, matchIndex);
7333
+ if (rhsStart === null) return false;
7334
+ const first = content[rhsStart];
7335
+ const safeNames = collectSafeHtmlNames(content, matchIndex);
7336
+ if (isSafeHtmlExpression(readSingleLineRhs(content, rhsStart), safeNames)) return true;
7337
+ if (isSafeMapJoinHtmlAssignment(content, rhsStart)) return true;
7338
+ if (first === "'" || first === "\"") {
7339
+ const quoted = consumeQuotedLiteral(content, rhsStart, first);
7340
+ return Boolean(quoted && assignmentTailIsClosed(content, quoted.endIndex));
7341
+ }
7342
+ if (first !== "`") return false;
7343
+ const template = consumeTemplateLiteral(content, rhsStart);
7344
+ if (!template || !assignmentTailIsClosed(content, template.endIndex)) return false;
7345
+ const expressions = templateExpressions(template.body);
7346
+ if (expressions.length === 0) return true;
7347
+ return expressions.every((expr) => isSafeHtmlExpression(expr, safeNames));
7348
+ };
7349
+
7043
7350
  //#endregion
7044
7351
  //#region src/engines/security/risky.ts
7045
7352
  const ev = "eval";
@@ -7165,6 +7472,30 @@ const isStructuredDataScript = (content, matchIndex) => {
7165
7472
  const after = content.slice(matchIndex, Math.min(content.length, matchIndex + 180));
7166
7473
  return /__html\s*:\s*JSON\.stringify\s*\(/.test(after);
7167
7474
  };
7475
+ 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));
7476
+ const PLACEHOLDER_EXPR_RE = /^(?:placeholders?|placeholderList|bindMarkers?|bindingMarkers?|bindPlaceholders?|bindingPlaceholders?|parameterPlaceholders?|sqlPlaceholders?)(?:\.\w+\([^)]*\))?$/i;
7477
+ const SQL_PLACEHOLDER_LITERAL_RE = /["'](?:\?|\$\d+|\$\{[^}]+\})["']/;
7478
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7479
+ const isGeneratedPlaceholderList = (content, matchIndex, placeholderExpr) => {
7480
+ const name = placeholderExpr.match(/^([A-Za-z_$][\w$]*)/)?.[1];
7481
+ if (!name) return false;
7482
+ const prefix = content.slice(Math.max(0, matchIndex - 4e3), matchIndex);
7483
+ const declarationRe = new RegExp(`\\b(?:const|let|var)\\s+${escapeRegExp(name)}\\s*=\\s*([^;\\n]+)`, "g");
7484
+ const declaration = [...prefix.matchAll(declarationRe)].at(-1);
7485
+ if (!declaration) return false;
7486
+ const expr = declaration[1];
7487
+ if (!/\.join\s*\(/.test(expr)) return false;
7488
+ return /\.map\s*\(/.test(expr) && /=>/.test(expr) && SQL_PLACEHOLDER_LITERAL_RE.test(expr) || /\.fill\s*\(/.test(expr) && SQL_PLACEHOLDER_LITERAL_RE.test(expr);
7489
+ };
7490
+ const isSafeSqlPlaceholderTemplate = (content, matchIndex) => {
7491
+ const template = consumeTemplateLiteral(content, matchIndex);
7492
+ if (!template) return false;
7493
+ const afterTemplate = content.slice(template.endIndex + 1);
7494
+ if (!(/^\s*,/.test(afterTemplate) || /^\s*\)\s*\.(?:all|get|run|values)\s*\(/.test(afterTemplate))) return false;
7495
+ const expressions = [...template.body.matchAll(/\$\{\s*([^}]+?)\s*\}/g)].map((match) => match[1].trim());
7496
+ if (expressions.length === 0) return false;
7497
+ return expressions.every((expr) => PLACEHOLDER_EXPR_RE.test(expr) && isGeneratedPlaceholderList(content, matchIndex, expr));
7498
+ };
7168
7499
  const detectRiskyConstructs = async (context) => {
7169
7500
  const files = getSourceFiles(context);
7170
7501
  const diagnostics = [];
@@ -7189,8 +7520,11 @@ const detectRiskyConstructs = async (context) => {
7189
7520
  const line = content.slice(0, match.index).split("\n").length;
7190
7521
  if (name === "innerhtml") {
7191
7522
  const beforeMatch = content.slice(Math.max(0, match.index - 200), match.index);
7523
+ if (isSafeInnerHtmlAssignment(content, match.index)) continue;
7192
7524
  if (/(?:template|tmpl|tpl)$/i.test(beforeMatch.trimEnd()) || /createElement\s*\(\s*['"]template['"]\s*\)$/.test(beforeMatch.trimEnd())) continue;
7193
7525
  }
7526
+ if (name === "sql-injection" && isSafeSqlPlaceholderTemplate(content, match.index)) continue;
7527
+ if (name === "shell-injection" && isSafeShellSpawnArray(content, match.index)) continue;
7194
7528
  if (name === "dangerously-set-innerhtml") {
7195
7529
  if (hasDangerouslySetInnerHtmlIgnore(lines, line - 1)) continue;
7196
7530
  if (isStructuredDataScript(content, match.index)) continue;
@@ -7287,7 +7621,28 @@ const PLACEHOLDER_EXACT = new Set([
7287
7621
  "todo",
7288
7622
  "replace_me"
7289
7623
  ]);
7624
+ const PLACEHOLDER_URL_PARTS = new Set([
7625
+ "example",
7626
+ "host",
7627
+ "localhost",
7628
+ "pass",
7629
+ "password",
7630
+ "pw",
7631
+ "user",
7632
+ "username"
7633
+ ]);
7634
+ const isPlaceholderCredentialUrl = (matchedText) => {
7635
+ const credentialMatch = matchedText.match(/^[a-z]+:\/\/([^:@/\s]+):([^@/\s]+)@/i);
7636
+ if (credentialMatch) return PLACEHOLDER_URL_PARTS.has(credentialMatch[1].toLowerCase()) && PLACEHOLDER_URL_PARTS.has(credentialMatch[2].toLowerCase());
7637
+ try {
7638
+ const parsed = new URL(matchedText);
7639
+ return PLACEHOLDER_URL_PARTS.has(parsed.username.toLowerCase()) && PLACEHOLDER_URL_PARTS.has(parsed.password.toLowerCase()) && PLACEHOLDER_URL_PARTS.has(parsed.hostname.toLowerCase());
7640
+ } catch {
7641
+ return false;
7642
+ }
7643
+ };
7290
7644
  const isPlaceholderValue = (matchedText) => {
7645
+ if (isPlaceholderCredentialUrl(matchedText)) return true;
7291
7646
  if (/env\(/i.test(matchedText)) return true;
7292
7647
  if (matchedText.includes("process.env")) return true;
7293
7648
  if (matchedText.includes("os.environ")) return true;
@@ -7408,23 +7763,36 @@ const STYLE_RULES = new Set([
7408
7763
  "complexity/function-too-long"
7409
7764
  ]);
7410
7765
  const STYLE_WEIGHT = .5;
7766
+ const COMMENT_STYLE_RULE_CAP = 12;
7767
+ const COMMENT_STYLE_RULES = new Set(["ai-slop/trivial-comment", "ai-slop/narrative-comment"]);
7411
7768
  const getEffectiveFileCount = (diagnostics, sourceFileCount) => {
7412
7769
  if (typeof sourceFileCount === "number" && sourceFileCount > 0) return sourceFileCount;
7413
7770
  const filesWithDiagnostics = new Set(diagnostics.map((d) => d.filePath)).size;
7414
7771
  return Math.max(1, filesWithDiagnostics);
7415
7772
  };
7416
- const calculateScore = (diagnostics, weights, thresholds, sourceFileCount, smoothing) => {
7773
+ const calculateScore = (diagnostics, weights, thresholds, sourceFileCount, smoothing, maxPerRule) => {
7417
7774
  if (diagnostics.length === 0) return {
7418
7775
  score: PERFECT_SCORE,
7419
7776
  label: "Healthy"
7420
7777
  };
7421
- let deductions = 0;
7778
+ const deductionsByRule = /* @__PURE__ */ new Map();
7422
7779
  for (const d of diagnostics) {
7423
7780
  const engineWeight = weights[d.engine] ?? 1;
7424
7781
  const severityPenalty = d.severity === "error" ? 3 : d.severity === "warning" ? 1 : .25;
7425
7782
  const styleFactor = STYLE_RULES.has(d.rule) ? STYLE_WEIGHT : 1;
7426
- deductions += severityPenalty * engineWeight * styleFactor;
7427
- }
7783
+ const key = `${d.engine}:${d.rule}`;
7784
+ deductionsByRule.set(key, (deductionsByRule.get(key) ?? 0) + severityPenalty * engineWeight * styleFactor);
7785
+ }
7786
+ const defaultRuleCap = typeof maxPerRule === "number" && maxPerRule > 0 ? maxPerRule : null;
7787
+ const capForRule = (key) => {
7788
+ const rule = key.slice(key.indexOf(":") + 1);
7789
+ if (COMMENT_STYLE_RULES.has(rule)) return defaultRuleCap ? Math.min(defaultRuleCap, COMMENT_STYLE_RULE_CAP) : COMMENT_STYLE_RULE_CAP;
7790
+ return defaultRuleCap;
7791
+ };
7792
+ const deductions = [...deductionsByRule.entries()].reduce((total, [key, value]) => {
7793
+ const cap = capForRule(key);
7794
+ return total + (cap ? Math.min(value, cap) : value);
7795
+ }, 0);
7428
7796
  const effectiveFileCount = getEffectiveFileCount(diagnostics, sourceFileCount);
7429
7797
  const smoothingConstant = typeof smoothing === "number" ? smoothing : 10;
7430
7798
  const issueDensity = Math.min(1, diagnostics.length / (effectiveFileCount + smoothingConstant));
@@ -8067,7 +8435,7 @@ const buildAgentPrompt = (rootDirectory, diagnostics, score) => {
8067
8435
  }
8068
8436
  lines.push("---");
8069
8437
  lines.push("Fix each issue following the guidance above. Prioritize errors over warnings.");
8070
- lines.push("After making changes, run `npx aislop scan` to verify all issues are resolved and the score improves.");
8438
+ lines.push("After making changes, run `aislop scan` to verify all issues are resolved and the score improves.");
8071
8439
  return lines.join("\n");
8072
8440
  };
8073
8441
  const SUPPORTED_AGENT_NAMES = Object.keys(AGENT_CONFIGS);
@@ -9274,7 +9642,7 @@ const runLintSteps = async (deps) => {
9274
9642
  if (hasJsOrTs(deps.projectInfo)) await deps.runStep("Lint fixes (js/ts)", () => runOxlint(deps.context), () => fixOxlint(deps.context, { force: deps.force }));
9275
9643
  if (deps.projectInfo.languages.includes("python") && deps.projectInfo.installedTools.ruff) await deps.runStep("Lint fixes (python)", () => runRuffLint(deps.context), () => deps.force ? fixRuffLintForce(deps.resolvedDir) : fixRuffLint(deps.resolvedDir));
9276
9644
  else if (deps.projectInfo.languages.includes("python")) log.warn("Python detected but ruff is not installed; skipping Python lint fixes.");
9277
- if (deps.projectInfo.languages.includes("ruby") && deps.projectInfo.installedTools.rubocop) await deps.runStep("Lint fixes (ruby)", () => import("./generic-BsQa13CS.js").then((n) => n.n).then((mod) => mod.runGenericLinter(deps.context, "ruby")), () => fixRubyLint(deps.resolvedDir));
9645
+ if (deps.projectInfo.languages.includes("ruby") && deps.projectInfo.installedTools.rubocop) await deps.runStep("Lint fixes (ruby)", () => import("./generic-D_T4cUaC.js").then((n) => n.n).then((mod) => mod.runGenericLinter(deps.context, "ruby")), () => fixRubyLint(deps.resolvedDir));
9278
9646
  else if (deps.projectInfo.languages.includes("ruby")) log.warn("Ruby detected but rubocop is not installed; skipping Ruby lint fixes.");
9279
9647
  };
9280
9648
  const runDependencyStep = async (deps) => {
@@ -9620,6 +9988,121 @@ var LiveGrid = class {
9620
9988
  }
9621
9989
  };
9622
9990
 
9991
+ //#endregion
9992
+ //#region src/utils/git.ts
9993
+ const MAX_BUFFER = 50 * 1024 * 1024;
9994
+ const baseRefExists = (cwd, ref) => {
9995
+ const result = spawnSync("git", [
9996
+ "rev-parse",
9997
+ "--verify",
9998
+ "--quiet",
9999
+ `${ref}^{commit}`
10000
+ ], {
10001
+ cwd,
10002
+ encoding: "utf-8",
10003
+ maxBuffer: MAX_BUFFER
10004
+ });
10005
+ return !result.error && result.status === 0;
10006
+ };
10007
+ const getChangedFiles = (cwd, base) => {
10008
+ const diff = spawnSync("git", [
10009
+ "diff",
10010
+ "--name-only",
10011
+ "--diff-filter=ACMR",
10012
+ base ?? "HEAD"
10013
+ ], {
10014
+ cwd,
10015
+ encoding: "utf-8",
10016
+ maxBuffer: MAX_BUFFER
10017
+ });
10018
+ if (diff.error || diff.status !== 0) return [];
10019
+ const untracked = spawnSync("git", [
10020
+ "ls-files",
10021
+ "--others",
10022
+ "--exclude-standard"
10023
+ ], {
10024
+ cwd,
10025
+ encoding: "utf-8",
10026
+ maxBuffer: MAX_BUFFER
10027
+ });
10028
+ const names = /* @__PURE__ */ new Set();
10029
+ for (const line of diff.stdout.split("\n")) if (line.length > 0) names.add(line);
10030
+ if (!untracked.error && untracked.status === 0) {
10031
+ for (const line of untracked.stdout.split("\n")) if (line.length > 0) names.add(line);
10032
+ }
10033
+ return Array.from(names).map((f) => path.resolve(cwd, f));
10034
+ };
10035
+ const getStagedFiles = (cwd) => {
10036
+ const result = spawnSync("git", [
10037
+ "diff",
10038
+ "--cached",
10039
+ "--name-only",
10040
+ "--diff-filter=ACMR"
10041
+ ], {
10042
+ cwd,
10043
+ encoding: "utf-8",
10044
+ maxBuffer: MAX_BUFFER
10045
+ });
10046
+ if (result.error || result.status !== 0) return [];
10047
+ return result.stdout.split("\n").filter((f) => f.length > 0).map((f) => path.resolve(cwd, f));
10048
+ };
10049
+
10050
+ //#endregion
10051
+ //#region src/utils/history.ts
10052
+ const HISTORY_FILE = "history.jsonl";
10053
+ const isHistoryDisabled = (env = process.env) => env.AISLOP_NO_HISTORY === "1";
10054
+ const historyPath = (directory) => path.join(path.resolve(directory), CONFIG_DIR, HISTORY_FILE);
10055
+ /**
10056
+ * Append a compact scan record to .aislop/history.jsonl. Best-effort: never
10057
+ * throws, so a read-only checkout or missing config dir can't break a scan.
10058
+ */
10059
+ const appendHistory = (input) => {
10060
+ if (isHistoryDisabled()) return;
10061
+ const configDir = path.join(path.resolve(input.directory), CONFIG_DIR);
10062
+ if (!fs.existsSync(configDir)) return;
10063
+ const record = {
10064
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
10065
+ score: input.score,
10066
+ errors: input.errors,
10067
+ warnings: input.warnings,
10068
+ files: input.files,
10069
+ cliVersion: APP_VERSION
10070
+ };
10071
+ try {
10072
+ fs.appendFileSync(historyPath(input.directory), `${JSON.stringify(record)}\n`);
10073
+ } catch {}
10074
+ };
10075
+
10076
+ //#endregion
10077
+ //#region src/commands/scan-coverage.ts
10078
+ const coverageReason = (c) => {
10079
+ 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.`;
10080
+ if (c.supportedFiles === 0) return "No files in a language aislop analyzes (TypeScript, JavaScript, Python, Go, Rust, Ruby, PHP, Java). Nothing to score.";
10081
+ const lang = c.dominantUnsupported ?? "an unsupported language";
10082
+ const files = `${c.supportedFiles} supported file${c.supportedFiles === 1 ? "" : "s"}`;
10083
+ return `This repository is mostly ${lang} (${c.unsupportedFiles} files); aislop analyzed only ${files}. Score withheld — it would represent a sliver of the codebase.`;
10084
+ };
10085
+ const renderCoverageNotice = (projectInfo, includeHeader) => {
10086
+ const deps = {
10087
+ theme: createTheme(),
10088
+ symbols: createSymbols({ plain: false })
10089
+ };
10090
+ return `${includeHeader === false ? "" : renderHeader({
10091
+ version: APP_VERSION,
10092
+ command: "Scan result",
10093
+ context: [
10094
+ projectInfo.projectName,
10095
+ projectInfo.languages[0] ?? "unknown",
10096
+ `${projectInfo.sourceFileCount} files`
10097
+ ],
10098
+ brand: true
10099
+ }, deps)} ${coverageReason(projectInfo.coverage)}\n\n`;
10100
+ };
10101
+
10102
+ //#endregion
10103
+ //#region src/commands/scan-exit-code.ts
10104
+ const computeScanExitCode = (opts) => opts.hasErrors || opts.scoreable && opts.score < opts.failBelow ? 1 : 0;
10105
+
9623
10106
  //#endregion
9624
10107
  //#region src/output/rule-labels.ts
9625
10108
  const RULE_LABELS = {
@@ -9704,11 +10187,85 @@ const RULE_LABELS = {
9704
10187
  "unicorn/no-useless-spread": "Useless spread",
9705
10188
  "unicorn/no-single-promise-in-promise-methods": "Single-element Promise.all"
9706
10189
  };
10190
+ const RULE_DESCRIPTIONS = {
10191
+ formatting: "File needs standard formatter output.",
10192
+ "code-quality/duplicate-block": "Large repeated code block should be shared or simplified.",
10193
+ "code-quality/repeated-chained-call": "Same chained call is repeated instead of stored once.",
10194
+ "code-quality/unused-declaration": "Declared symbol is not referenced.",
10195
+ "complexity/file-too-large": "File is large enough to be hard to review safely.",
10196
+ "complexity/function-too-long": "Function is doing too much in one body.",
10197
+ "complexity/deep-nesting": "Nested branches make the path hard to follow.",
10198
+ "complexity/too-many-params": "Function takes more arguments than readers can track.",
10199
+ "knip/files": "Source file is not imported or referenced.",
10200
+ "knip/dependencies": "Production dependency is listed but unused.",
10201
+ "knip/devDependencies": "Dev dependency is listed but unused.",
10202
+ "knip/unlisted": "Code imports a package missing from package.json.",
10203
+ "knip/unresolved": "Import cannot be resolved from the project.",
10204
+ "knip/binaries": "Package binary is listed but unused.",
10205
+ "knip/exports": "Exported value is not imported anywhere.",
10206
+ "knip/types": "Exported type is not imported anywhere.",
10207
+ "knip/duplicates": "Same export is declared more than once.",
10208
+ "ai-slop/trivial-comment": "Comment repeats obvious code instead of explaining intent.",
10209
+ "ai-slop/swallowed-exception": "Catch block hides an error without handling it.",
10210
+ "ai-slop/silent-recovery": "Error path logs or defaults, then continues as if safe.",
10211
+ "ai-slop/meta-comment": "Comment describes editing steps, plans, or generated-code process.",
10212
+ "ai-slop/redundant-try-catch": "try/catch only rethrows or adds no useful handling.",
10213
+ "ai-slop/redundant-type-coercion": "Conversion does not change the value meaningfully.",
10214
+ "ai-slop/duplicate-type-declaration": "Same exported type shape appears more than once.",
10215
+ "ai-slop/thin-wrapper": "Wrapper function adds no behavior or clearer contract.",
10216
+ "ai-slop/generic-naming": "Name is too vague to explain its role.",
10217
+ "ai-slop/unused-import": "Imported symbol is never used.",
10218
+ "ai-slop/console-leftover": "console/debug output was left in application code.",
10219
+ "ai-slop/todo-stub": "TODO/FIXME/stub marks unfinished behavior.",
10220
+ "ai-slop/unreachable-code": "Code path cannot execute.",
10221
+ "ai-slop/constant-condition": "Condition is always true or always false.",
10222
+ "ai-slop/empty-function": "Function body is empty or placeholder-only.",
10223
+ "ai-slop/unsafe-type-assertion": "Type assertion bypasses useful checking.",
10224
+ "ai-slop/double-type-assertion": "Value is cast through unknown/any to force a type.",
10225
+ "ai-slop/ts-directive": "TypeScript error is suppressed with a directive.",
10226
+ "ai-slop/narrative-comment": "Comment narrates implementation instead of adding context.",
10227
+ "ai-slop/duplicate-import": "Same module is imported more than once.",
10228
+ "ai-slop/hardcoded-url": "URL-like value is embedded directly in code.",
10229
+ "ai-slop/hardcoded-id": "Provider/account/test ID is embedded directly in code.",
10230
+ "ai-slop/python-bare-except": "Bare except catches everything, including system exits.",
10231
+ "ai-slop/python-broad-except": "Broad exception catch hides specific failure modes.",
10232
+ "ai-slop/python-mutable-default": "Mutable default argument is shared across calls.",
10233
+ "ai-slop/python-print-debug": "print/debug output was left in Python source.",
10234
+ "ai-slop/python-range-len-loop": "Loop uses indexes where direct iteration is clearer.",
10235
+ "ai-slop/python-chained-dict-get": "Nested get chain hides shape assumptions.",
10236
+ "ai-slop/python-repetitive-dispatch": "Repeated if/elif dispatch should be table-driven.",
10237
+ "ai-slop/python-isinstance-ladder": "Long isinstance ladder is brittle polymorphism.",
10238
+ "ai-slop/go-library-panic": "Library code panics instead of returning an error.",
10239
+ "ai-slop/rust-non-test-unwrap": "Production Rust uses unwrap instead of handling failure.",
10240
+ "ai-slop/rust-todo-stub": "Rust todo!/unimplemented! leaves behavior unfinished.",
10241
+ "ai-slop/hallucinated-import": "Import names a package not declared by the project.",
10242
+ "security/hardcoded-secret": "Secret-looking token is embedded in source.",
10243
+ "security/vulnerable-dependency": "Dependency audit reported a known vulnerability.",
10244
+ "security/dependency-audit-skipped": "Audit could not run because inputs/tools are missing.",
10245
+ "security/eval": "Dynamic code execution can run attacker-controlled input.",
10246
+ "security/innerhtml": "Raw HTML assignment can introduce XSS.",
10247
+ "security/dangerously-set-innerhtml": "React raw HTML escape hatch can introduce XSS.",
10248
+ "security/sql-injection": "SQL is built from interpolated or concatenated input.",
10249
+ "security/shell-injection": "Shell command is built from unsanitized input.",
10250
+ "oxlint/*": "JavaScript/TypeScript lint finding from oxlint.",
10251
+ "ruff/*": "Python lint finding from ruff.",
10252
+ "go/*": "Go lint finding from bundled checks.",
10253
+ "clippy/*": "Rust lint finding from clippy.",
10254
+ "rubocop/*": "Ruby lint finding from rubocop.",
10255
+ "typescript/*": "TypeScript compiler finding.",
10256
+ "import-order": "Imports need deterministic ordering.",
10257
+ "python-formatting": "Python file needs ruff formatting.",
10258
+ "go-formatting": "Go file needs gofmt.",
10259
+ "rust-formatting": "Rust file needs rustfmt.",
10260
+ "ruby-formatting": "Ruby file needs rubocop formatting.",
10261
+ "php-formatting": "PHP file needs php-cs-fixer formatting."
10262
+ };
9707
10263
  const prettifyFallback = (ruleId) => {
9708
10264
  const spaced = (ruleId.includes("/") ? ruleId.slice(ruleId.indexOf("/") + 1) : ruleId).replace(/[-_]/g, " ").replace(/\//g, " · ");
9709
10265
  return spaced.charAt(0).toUpperCase() + spaced.slice(1);
9710
10266
  };
9711
10267
  const labelForRule = (ruleId) => RULE_LABELS[ruleId] ?? prettifyFallback(ruleId);
10268
+ const descriptionForRule = (ruleId) => RULE_DESCRIPTIONS[ruleId] ?? labelForRule(ruleId);
9712
10269
 
9713
10270
  //#endregion
9714
10271
  //#region src/ui/summary.ts
@@ -9718,6 +10275,18 @@ const scoreToken = (score, thresholds) => {
9718
10275
  if (score >= thresholds.ok) return "warn";
9719
10276
  return "danger";
9720
10277
  };
10278
+ const renderFindingAssessment = (assessment, t, sep) => {
10279
+ if (assessment.rows.length === 0) return [];
10280
+ const parts = assessment.rows.filter((row) => row.count > 0).map((row) => `${row.count} ${row.label}`);
10281
+ if (parts.length === 0) return [];
10282
+ const high = assessment.byConfidence.high;
10283
+ const medium = assessment.byConfidence.medium;
10284
+ const confidenceParts = [];
10285
+ if (high > 0) confidenceParts.push(`${high} high-confidence`);
10286
+ if (medium > 0) confidenceParts.push(`${medium} medium-confidence`);
10287
+ const confidence = confidenceParts.length > 0 ? ` ${sep} ${style(t, "muted", confidenceParts.join(", "))}` : "";
10288
+ return [` ${style(t, "muted", "Verdict mix:")} ${parts.join(` ${sep} `)}${confidence}`];
10289
+ };
9721
10290
  const renderSummary = (input, deps = {}) => {
9722
10291
  const t = deps.theme ?? theme;
9723
10292
  const s = deps.symbols ?? symbols;
@@ -9736,6 +10305,10 @@ const renderSummary = (input, deps = {}) => {
9736
10305
  ` ${style(t, "muted", `${input.files} files`)} ${sep} ${style(t, "muted", `${input.engines} engines`)} ${sep} ${style(t, "muted", elapsed(input.elapsedMs))}`,
9737
10306
  ""
9738
10307
  ];
10308
+ if (input.findingAssessment) {
10309
+ lines.push(...renderFindingAssessment(input.findingAssessment, t, sep));
10310
+ lines.push("");
10311
+ }
9739
10312
  if (input.breakdown && input.breakdown.rows.length > 0) {
9740
10313
  lines.push(` ${style(t, "bold", "Top findings")}`);
9741
10314
  const maxCountWidth = input.breakdown.rows.reduce((w, r) => Math.max(w, String(r.errors + r.warnings + r.info).length), 0);
@@ -9790,112 +10363,7 @@ const renderCleanRun = (input, deps = {}) => {
9790
10363
  };
9791
10364
 
9792
10365
  //#endregion
9793
- //#region src/utils/git.ts
9794
- const MAX_BUFFER = 50 * 1024 * 1024;
9795
- const getChangedFiles = (cwd, base) => {
9796
- const diff = spawnSync("git", [
9797
- "diff",
9798
- "--name-only",
9799
- "--diff-filter=ACMR",
9800
- base ?? "HEAD"
9801
- ], {
9802
- cwd,
9803
- encoding: "utf-8",
9804
- maxBuffer: MAX_BUFFER
9805
- });
9806
- if (diff.error || diff.status !== 0) return [];
9807
- const untracked = spawnSync("git", [
9808
- "ls-files",
9809
- "--others",
9810
- "--exclude-standard"
9811
- ], {
9812
- cwd,
9813
- encoding: "utf-8",
9814
- maxBuffer: MAX_BUFFER
9815
- });
9816
- const names = /* @__PURE__ */ new Set();
9817
- for (const line of diff.stdout.split("\n")) if (line.length > 0) names.add(line);
9818
- if (!untracked.error && untracked.status === 0) {
9819
- for (const line of untracked.stdout.split("\n")) if (line.length > 0) names.add(line);
9820
- }
9821
- return Array.from(names).map((f) => path.resolve(cwd, f));
9822
- };
9823
- const getStagedFiles = (cwd) => {
9824
- const result = spawnSync("git", [
9825
- "diff",
9826
- "--cached",
9827
- "--name-only",
9828
- "--diff-filter=ACMR"
9829
- ], {
9830
- cwd,
9831
- encoding: "utf-8",
9832
- maxBuffer: MAX_BUFFER
9833
- });
9834
- if (result.error || result.status !== 0) return [];
9835
- return result.stdout.split("\n").filter((f) => f.length > 0).map((f) => path.resolve(cwd, f));
9836
- };
9837
-
9838
- //#endregion
9839
- //#region src/utils/history.ts
9840
- const HISTORY_FILE = "history.jsonl";
9841
- const isHistoryDisabled = (env = process.env) => env.AISLOP_NO_HISTORY === "1";
9842
- const historyPath = (directory) => path.join(path.resolve(directory), CONFIG_DIR, HISTORY_FILE);
9843
- /**
9844
- * Append a compact scan record to .aislop/history.jsonl. Best-effort: never
9845
- * throws, so a read-only checkout or missing config dir can't break a scan.
9846
- */
9847
- const appendHistory = (input) => {
9848
- if (isHistoryDisabled()) return;
9849
- const configDir = path.join(path.resolve(input.directory), CONFIG_DIR);
9850
- if (!fs.existsSync(configDir)) return;
9851
- const record = {
9852
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
9853
- score: input.score,
9854
- errors: input.errors,
9855
- warnings: input.warnings,
9856
- files: input.files,
9857
- cliVersion: APP_VERSION
9858
- };
9859
- try {
9860
- fs.appendFileSync(historyPath(input.directory), `${JSON.stringify(record)}\n`);
9861
- } catch {}
9862
- };
9863
-
9864
- //#endregion
9865
- //#region src/commands/scan-coverage.ts
9866
- const coverageReason = (c) => {
9867
- 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.`;
9868
- if (c.supportedFiles === 0) return "No files in a language aislop analyzes (TypeScript, JavaScript, Python, Go, Rust, Ruby, PHP, Java). Nothing to score.";
9869
- const lang = c.dominantUnsupported ?? "an unsupported language";
9870
- const files = `${c.supportedFiles} supported file${c.supportedFiles === 1 ? "" : "s"}`;
9871
- return `This repository is mostly ${lang} (${c.unsupportedFiles} files); aislop analyzed only ${files}. Score withheld — it would represent a sliver of the codebase.`;
9872
- };
9873
- const renderCoverageNotice = (projectInfo, includeHeader) => {
9874
- const deps = {
9875
- theme: createTheme(),
9876
- symbols: createSymbols({ plain: false })
9877
- };
9878
- return `${includeHeader === false ? "" : renderHeader({
9879
- version: APP_VERSION,
9880
- command: "scan",
9881
- context: [
9882
- projectInfo.projectName,
9883
- projectInfo.languages[0] ?? "unknown",
9884
- `${projectInfo.sourceFileCount} files`
9885
- ],
9886
- brand: true
9887
- }, deps)} ${coverageReason(projectInfo.coverage)}\n\n`;
9888
- };
9889
-
9890
- //#endregion
9891
- //#region src/commands/scan-exit-code.ts
9892
- const computeScanExitCode = (opts) => opts.hasErrors || opts.scoreable && opts.score < opts.failBelow ? 1 : 0;
9893
-
9894
- //#endregion
9895
- //#region src/commands/scan.ts
9896
- const isMachineOutput = (options) => Boolean(options.json) || Boolean(options.sarif);
9897
- const shouldUseSpinner = () => Boolean(process.stderr.isTTY) && process.env.CI !== "true" && process.env.CI !== "1";
9898
- const ALL_ENGINE_NAMES = Object.keys(ENGINE_INFO);
10366
+ //#region src/commands/scan-render.ts
9899
10367
  const BREAKDOWN_TOP_N = 10;
9900
10368
  const computeBreakdown = (diagnostics) => {
9901
10369
  const byRule = /* @__PURE__ */ new Map();
@@ -9937,7 +10405,7 @@ const buildScanRender = (input) => {
9937
10405
  const invocation = detectInvocation();
9938
10406
  const header = input.includeHeader === false ? "" : renderHeader({
9939
10407
  version: APP_VERSION,
9940
- command: "scan",
10408
+ command: "Scan result",
9941
10409
  context: [
9942
10410
  input.projectName,
9943
10411
  input.language,
@@ -9980,9 +10448,16 @@ const buildScanRender = (input) => {
9980
10448
  elapsedMs: input.elapsedMs,
9981
10449
  nextSteps,
9982
10450
  breakdown: computeBreakdown(input.diagnostics),
10451
+ findingAssessment: summarizeFindingAssessments(input.diagnostics),
9983
10452
  thresholds: input.thresholds
9984
10453
  }, deps)}${starCta}`;
9985
10454
  };
10455
+
10456
+ //#endregion
10457
+ //#region src/commands/scan.ts
10458
+ const isMachineOutput = (options) => Boolean(options.json) || Boolean(options.sarif);
10459
+ const shouldUseSpinner = () => Boolean(process.stderr.isTTY) && process.env.CI !== "true" && process.env.CI !== "1";
10460
+ const ALL_ENGINE_NAMES = Object.keys(ENGINE_INFO);
9986
10461
  const scanCommand = async (directory, config, options) => {
9987
10462
  const resolvedDir = path.resolve(directory);
9988
10463
  if (!fs.existsSync(resolvedDir)) {
@@ -9997,6 +10472,12 @@ const scanCommand = async (directory, config, options) => {
9997
10472
  else log.error(msg);
9998
10473
  return { exitCode: 1 };
9999
10474
  }
10475
+ if (options.changes && options.base && !baseRefExists(resolvedDir, options.base)) {
10476
+ const msg = `Could not resolve base ref "${options.base}". Make sure it exists and was fetched (e.g. \`git fetch origin ${options.base}\`).`;
10477
+ if (options.json) console.log(JSON.stringify({ error: msg }, null, 2));
10478
+ else log.error(msg);
10479
+ return { exitCode: 1 };
10480
+ }
10000
10481
  const projectInfo = await discoverProject(resolvedDir, [...config.exclude, ...readAislopIgnorePatterns(resolvedDir)]);
10001
10482
  return withCommandLifecycle({
10002
10483
  command: options.command ?? "scan",
@@ -10010,14 +10491,30 @@ const runScanBody = async (resolvedDir, config, options, projectInfo) => {
10010
10491
  const showHeader = options.showHeader !== false;
10011
10492
  const machineOutput = isMachineOutput(options);
10012
10493
  const useLiveProgress = !machineOutput && shouldUseSpinner();
10494
+ const projectName = projectInfo.projectName ?? "project";
10495
+ const language = projectInfo.languages[0] ?? "unknown";
10496
+ const printedHumanHeader = !machineOutput && showHeader;
10497
+ if (printedHumanHeader) process.stdout.write(renderHeader({
10498
+ version: APP_VERSION,
10499
+ command: "Scan result",
10500
+ context: [
10501
+ projectName,
10502
+ language,
10503
+ `${projectInfo.sourceFileCount} files`
10504
+ ],
10505
+ brand: options.printBrand !== false
10506
+ }));
10013
10507
  const excludePatterns = [...config.exclude, ...readAislopIgnorePatterns(resolvedDir)];
10014
10508
  let files;
10015
10509
  if (options.staged) {
10016
10510
  files = filterProjectFiles(resolvedDir, getStagedFiles(resolvedDir), [], excludePatterns);
10017
10511
  if (!machineOutput) log.muted(`Scope: ${files.length} staged file(s)`);
10018
10512
  } else if (options.changes) {
10019
- files = filterProjectFiles(resolvedDir, getChangedFiles(resolvedDir), [], excludePatterns);
10020
- if (!machineOutput) log.muted(`Scope: ${files.length} changed file(s)`);
10513
+ files = filterProjectFiles(resolvedDir, getChangedFiles(resolvedDir, options.base), [], excludePatterns);
10514
+ if (!machineOutput) {
10515
+ const scope = options.base ? `changed vs ${options.base}` : "changed";
10516
+ log.muted(`Scope: ${files.length} ${scope} file(s)`);
10517
+ }
10021
10518
  } else {
10022
10519
  files = filterProjectFiles(resolvedDir, listProjectFiles(resolvedDir), [], excludePatterns);
10023
10520
  if (!machineOutput) log.muted(`Scope: ${files.length} file(s) after exclusions`);
@@ -10080,7 +10577,7 @@ const runScanBody = async (resolvedDir, config, options, projectInfo) => {
10080
10577
  if (suppressedCount > 0 && !machineOutput) log.muted(`Suppressed ${suppressedCount} finding(s) via aislop-ignore directives`);
10081
10578
  const allDiagnostics = results.flatMap((r) => r.diagnostics);
10082
10579
  const elapsedMs = performance.now() - startTime;
10083
- const scoreResult = calculateScore(allDiagnostics, config.scoring.weights, config.scoring.thresholds, projectInfo.sourceFileCount, config.scoring.smoothing);
10580
+ const scoreResult = calculateScore(allDiagnostics, config.scoring.weights, config.scoring.thresholds, projectInfo.sourceFileCount, config.scoring.smoothing, config.scoring.maxPerRule);
10084
10581
  const scoreable = projectInfo.coverage.scoreable;
10085
10582
  const exitCode = computeScanExitCode({
10086
10583
  hasErrors: allDiagnostics.some((d) => d.severity === "error"),
@@ -10106,19 +10603,19 @@ const runScanBody = async (resolvedDir, config, options, projectInfo) => {
10106
10603
  engineTimings
10107
10604
  };
10108
10605
  if (options.sarif) {
10109
- const { buildSarifLog } = await import("./sarif-cy5SiDDq.js");
10606
+ const { buildSarifLog } = await import("./sarif-BXUicqQU.js");
10110
10607
  console.log(JSON.stringify(buildSarifLog(results), null, 2));
10111
10608
  return completion;
10112
10609
  }
10113
10610
  if (options.json) {
10114
- const { buildJsonOutput } = await import("./json-B01i-GOz.js");
10611
+ const { buildJsonOutput } = await import("./json-0lJPTrwO.js");
10115
10612
  const jsonOut = buildJsonOutput(results, scoreResult, projectInfo.sourceFileCount, elapsedMs, projectInfo.coverage);
10116
10613
  console.log(JSON.stringify(jsonOut, null, 2));
10117
10614
  return completion;
10118
10615
  }
10119
10616
  if (!scoreable) {
10120
10617
  if (!machineOutput) {
10121
- process.stdout.write(renderCoverageNotice(projectInfo, showHeader));
10618
+ process.stdout.write(renderCoverageNotice(projectInfo, !printedHumanHeader && showHeader));
10122
10619
  if (allDiagnostics.length > 0) process.stdout.write(renderDiagnostics(allDiagnostics, options.verbose ?? false));
10123
10620
  }
10124
10621
  return completion;
@@ -10130,8 +10627,6 @@ const runScanBody = async (resolvedDir, config, options, projectInfo) => {
10130
10627
  warnings: completion.warningCount,
10131
10628
  files: projectInfo.sourceFileCount
10132
10629
  });
10133
- const projectName = projectInfo.projectName ?? "project";
10134
- const language = projectInfo.languages[0] ?? "unknown";
10135
10630
  process.stdout.write(buildScanRender({
10136
10631
  projectName,
10137
10632
  language,
@@ -10142,7 +10637,7 @@ const runScanBody = async (resolvedDir, config, options, projectInfo) => {
10142
10637
  elapsedMs,
10143
10638
  thresholds: config.scoring.thresholds,
10144
10639
  verbose: options.verbose,
10145
- includeHeader: showHeader,
10640
+ includeHeader: !printedHumanHeader && showHeader,
10146
10641
  printBrand: options.printBrand
10147
10642
  }));
10148
10643
  return completion;
@@ -10185,7 +10680,7 @@ const runFixBody = async (resolvedDir, config, options, projectInfo) => {
10185
10680
  const projectName = projectInfo.projectName ?? "project";
10186
10681
  if (showHeader) process.stdout.write(renderHeader({
10187
10682
  version: APP_VERSION,
10188
- command: "fix",
10683
+ command: "Fix run",
10189
10684
  context: [projectName],
10190
10685
  brand: options.printBrand !== false
10191
10686
  }));
@@ -10243,10 +10738,11 @@ const runFixBody = async (resolvedDir, config, options, projectInfo) => {
10243
10738
  label: "Verification complete"
10244
10739
  });
10245
10740
  const allDiagnostics = scanResults.flatMap((r) => r.diagnostics);
10246
- const scoreResult = calculateScore(allDiagnostics, config.scoring.weights, config.scoring.thresholds, projectInfo.sourceFileCount, config.scoring.smoothing);
10741
+ const scoreResult = calculateScore(allDiagnostics, config.scoring.weights, config.scoring.thresholds, projectInfo.sourceFileCount, config.scoring.smoothing, config.scoring.maxPerRule);
10247
10742
  const errors = allDiagnostics.filter((d) => d.severity === "error").length;
10248
10743
  const warnings = allDiagnostics.filter((d) => d.severity === "warning").length;
10249
10744
  const remaining = errors + warnings;
10745
+ const actionableDiagnostics = allDiagnostics.filter((d) => d.severity !== "info");
10250
10746
  if (steps.length === 0) rail.complete({
10251
10747
  status: "skipped",
10252
10748
  label: "No applicable auto-fixers found"
@@ -10264,7 +10760,7 @@ const runFixBody = async (resolvedDir, config, options, projectInfo) => {
10264
10760
  language,
10265
10761
  fileCount: projectInfo.sourceFileCount,
10266
10762
  results: scanResults,
10267
- diagnostics: allDiagnostics,
10763
+ diagnostics: actionableDiagnostics,
10268
10764
  score: scoreResult,
10269
10765
  elapsedMs: performance.now() - startTime,
10270
10766
  thresholds: config.scoring.thresholds,
@@ -10274,7 +10770,7 @@ const runFixBody = async (resolvedDir, config, options, projectInfo) => {
10274
10770
  }));
10275
10771
  }
10276
10772
  if (options.agent) {
10277
- launchAgent(options.agent, resolvedDir, allDiagnostics, scoreResult.score);
10773
+ launchAgent(options.agent, resolvedDir, actionableDiagnostics, scoreResult.score);
10278
10774
  return {
10279
10775
  exitCode: 0,
10280
10776
  score: scoreResult.score,
@@ -10283,7 +10779,7 @@ const runFixBody = async (resolvedDir, config, options, projectInfo) => {
10283
10779
  };
10284
10780
  }
10285
10781
  if (options.prompt) {
10286
- printPrompt(resolvedDir, allDiagnostics, scoreResult.score);
10782
+ printPrompt(resolvedDir, actionableDiagnostics, scoreResult.score);
10287
10783
  return {
10288
10784
  exitCode: 0,
10289
10785
  score: scoreResult.score,
@@ -10311,7 +10807,7 @@ const buildInitSuccessRender = (input) => {
10311
10807
  };
10312
10808
  const header = input.includeHeader === false ? "" : renderHeader({
10313
10809
  version: APP_VERSION,
10314
- command: "init",
10810
+ command: "Setup",
10315
10811
  context: [],
10316
10812
  brand: input.printBrand !== false
10317
10813
  }, deps);
@@ -10446,7 +10942,8 @@ const writeAislopConfig = (configDir, configPath, choices) => {
10446
10942
  scoring: {
10447
10943
  weights: { ...DEFAULT_CONFIG.scoring.weights },
10448
10944
  thresholds: { ...DEFAULT_CONFIG.scoring.thresholds },
10449
- smoothing: DEFAULT_CONFIG.scoring.smoothing
10945
+ smoothing: DEFAULT_CONFIG.scoring.smoothing,
10946
+ maxPerRule: DEFAULT_CONFIG.scoring.maxPerRule
10450
10947
  },
10451
10948
  ci: {
10452
10949
  failBelow: choices.failBelow,
@@ -10462,7 +10959,7 @@ const initCommand = async (directory, options = {}) => {
10462
10959
  const printBrand = options.printBrand !== false;
10463
10960
  process.stdout.write(renderHeader({
10464
10961
  version: APP_VERSION,
10465
- command: "init",
10962
+ command: "Setup",
10466
10963
  context: [],
10467
10964
  brand: printBrand
10468
10965
  }));
@@ -10527,13 +11024,254 @@ const initCommand = async (directory, options = {}) => {
10527
11024
  }));
10528
11025
  };
10529
11026
 
11027
+ //#endregion
11028
+ //#region src/ui/search-select.ts
11029
+ const silentOutput = new Writable({ write(_chunk, _encoding, callback) {
11030
+ callback();
11031
+ } });
11032
+ const filterSearchItems = (items, query) => {
11033
+ const q = query.trim().toLowerCase();
11034
+ if (q.length === 0) return items;
11035
+ return items.map((item, index) => {
11036
+ const label = item.label.toLowerCase();
11037
+ const value = String(item.value).toLowerCase();
11038
+ const hint = item.hint?.toLowerCase() ?? "";
11039
+ const keywords = (item.keywords ?? []).join(" ").toLowerCase();
11040
+ const haystack = [
11041
+ label,
11042
+ value,
11043
+ hint,
11044
+ keywords
11045
+ ].filter((v) => v.length > 0).join(" ");
11046
+ if (!q.split(/\s+/).every((part) => haystack.includes(part))) return null;
11047
+ let rank = 80;
11048
+ if (label === q || value === q) rank = 0;
11049
+ else if (label.startsWith(q) || value.startsWith(q)) rank = 10;
11050
+ else if (label.includes(q) || value.includes(q)) rank = 20;
11051
+ else if (keywords.includes(q)) rank = 40;
11052
+ else if (hint.includes(q)) rank = 60;
11053
+ return {
11054
+ item,
11055
+ index,
11056
+ rank
11057
+ };
11058
+ }).filter((entry) => entry !== null).sort((a, b) => {
11059
+ if (a.rank !== b.rank) return a.rank - b.rank;
11060
+ return a.index - b.index;
11061
+ }).map((entry) => entry.item);
11062
+ };
11063
+ const countRows = (lines, columns) => {
11064
+ const width = columns && columns > 0 ? columns : 80;
11065
+ return lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(stringWidth(line) / width)), 0);
11066
+ };
11067
+ const renderSearchLines = (options) => {
11068
+ const maxVisible = options.maxVisible ?? 8;
11069
+ const filtered = filterSearchItems(options.items, options.query);
11070
+ const cursor = Math.max(0, Math.min(options.cursor, Math.max(0, filtered.length - 1)));
11071
+ const start = Math.max(0, Math.min(cursor - Math.floor(maxVisible / 2), filtered.length - maxVisible));
11072
+ const visible = filtered.slice(start, start + maxVisible);
11073
+ const lines = [];
11074
+ const marker = options.state === "cancel" ? style(theme, "danger", symbols.fail) : options.state === "submit" ? style(theme, "success", symbols.stepDone) : style(theme, "accent", symbols.stepActive);
11075
+ lines.push(` ${marker} ${style(theme, "bold", options.message)}`);
11076
+ if (options.state === "cancel") {
11077
+ lines.push(` ${style(theme, "muted", symbols.rail)} ${style(theme, "muted", "Cancelled")}`);
11078
+ return lines;
11079
+ }
11080
+ if (options.state === "submit") {
11081
+ const selected = options.items.filter((item) => options.selected.has(item.value));
11082
+ const label = selected.length > 0 ? selected.map((item) => item.label).join(", ") : "No selection";
11083
+ lines.push(` ${style(theme, "muted", symbols.rail)} ${style(theme, "muted", label)}`);
11084
+ return lines;
11085
+ }
11086
+ lines.push(` ${style(theme, "muted", symbols.rail)} ${style(theme, "muted", "Search:")} ${options.query}${style(theme, "dim", "_")}`);
11087
+ 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")}`);
11088
+ lines.push(` ${style(theme, "muted", symbols.rail)}`);
11089
+ if (visible.length === 0) lines.push(` ${style(theme, "muted", symbols.rail)} ${style(theme, "muted", "No matches")}`);
11090
+ else for (const [offset, item] of visible.entries()) {
11091
+ const active = start + offset === cursor;
11092
+ const selected = options.selected.has(item.value);
11093
+ const pointer = active ? style(theme, "info", symbols.engineActive) : " ";
11094
+ 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);
11095
+ const label = active ? style(theme, "bold", item.label) : item.label;
11096
+ const hint = item.hint ? ` ${style(theme, "muted", truncate(item.hint, 72))}` : "";
11097
+ lines.push(` ${style(theme, "muted", symbols.rail)} ${pointer} ${radio} ${label}${hint}`);
11098
+ }
11099
+ const hiddenBefore = start;
11100
+ const hiddenAfter = Math.max(0, filtered.length - (start + visible.length));
11101
+ if (hiddenBefore > 0 || hiddenAfter > 0) {
11102
+ const parts = [];
11103
+ if (hiddenBefore > 0) parts.push(`up ${hiddenBefore} more`);
11104
+ if (hiddenAfter > 0) parts.push(`down ${hiddenAfter} more`);
11105
+ lines.push(` ${style(theme, "muted", symbols.rail)} ${style(theme, "muted", parts.join(" · "))}`);
11106
+ }
11107
+ if (options.mode === "multi") {
11108
+ const picked = options.items.filter((item) => options.selected.has(item.value));
11109
+ 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`;
11110
+ lines.push(` ${style(theme, "muted", symbols.rail)}`);
11111
+ lines.push(` ${style(theme, "muted", symbols.rail)} ${style(theme, "success", summary)}`);
11112
+ }
11113
+ lines.push(` ${style(theme, "muted", symbols.railEnd)}`);
11114
+ return lines;
11115
+ };
11116
+ const runSearchPrompt = async (options) => {
11117
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return options.mode === "multi" ? options.initialSelected ?? [] : null;
11118
+ return new Promise((resolve) => {
11119
+ const rl = readline.createInterface({
11120
+ input: process.stdin,
11121
+ output: silentOutput,
11122
+ terminal: false
11123
+ });
11124
+ readline.emitKeypressEvents(process.stdin, rl);
11125
+ process.stdin.setRawMode(true);
11126
+ let query = "";
11127
+ let cursor = 0;
11128
+ let lastRows = 0;
11129
+ const selected = new Set(options.initialSelected ?? []);
11130
+ const clear = () => {
11131
+ if (lastRows === 0) return;
11132
+ process.stdout.write(`\x1b[${lastRows}A`);
11133
+ for (let i = 0; i < lastRows; i++) process.stdout.write("\x1B[2K\x1B[1B");
11134
+ process.stdout.write(`\x1b[${lastRows}A`);
11135
+ };
11136
+ const render = (state = "active") => {
11137
+ clear();
11138
+ const lines = renderSearchLines({
11139
+ ...options,
11140
+ query,
11141
+ cursor,
11142
+ selected,
11143
+ state
11144
+ });
11145
+ process.stdout.write(`${lines.join("\n")}\n`);
11146
+ lastRows = countRows(lines, process.stdout.columns);
11147
+ };
11148
+ const cleanup = () => {
11149
+ process.stdin.removeListener("keypress", onKeypress);
11150
+ process.stdin.setRawMode(false);
11151
+ rl.close();
11152
+ };
11153
+ const submit = () => {
11154
+ const item = filterSearchItems(options.items, query)[cursor];
11155
+ if (options.mode === "single") {
11156
+ if (!item) {
11157
+ if (options.required) return;
11158
+ render("cancel");
11159
+ cleanup();
11160
+ resolve(null);
11161
+ return;
11162
+ }
11163
+ selected.clear();
11164
+ selected.add(item.value);
11165
+ render("submit");
11166
+ cleanup();
11167
+ resolve(item.value);
11168
+ return;
11169
+ }
11170
+ if (options.required && selected.size === 0) return;
11171
+ render("submit");
11172
+ cleanup();
11173
+ resolve([...selected]);
11174
+ };
11175
+ const cancel = () => {
11176
+ render("cancel");
11177
+ cleanup();
11178
+ resolve(null);
11179
+ };
11180
+ const onKeypress = (_str, key) => {
11181
+ if (!key) return;
11182
+ const filtered = filterSearchItems(options.items, query);
11183
+ if (key.name === "return") {
11184
+ submit();
11185
+ return;
11186
+ }
11187
+ if (key.name === "escape" || key.ctrl && key.name === "c") {
11188
+ cancel();
11189
+ return;
11190
+ }
11191
+ if (key.name === "up") {
11192
+ cursor = Math.max(0, cursor - 1);
11193
+ render();
11194
+ return;
11195
+ }
11196
+ if (key.name === "down") {
11197
+ cursor = Math.min(Math.max(0, filtered.length - 1), cursor + 1);
11198
+ render();
11199
+ return;
11200
+ }
11201
+ if (key.name === "space" && options.mode === "multi") {
11202
+ const item = filtered[cursor];
11203
+ if (item) if (selected.has(item.value)) selected.delete(item.value);
11204
+ else selected.add(item.value);
11205
+ render();
11206
+ return;
11207
+ }
11208
+ if (key.name === "backspace") {
11209
+ query = query.slice(0, -1);
11210
+ cursor = 0;
11211
+ render();
11212
+ return;
11213
+ }
11214
+ if (key.sequence && !key.ctrl && !key.meta && key.sequence.length === 1) {
11215
+ query += key.sequence;
11216
+ cursor = 0;
11217
+ render();
11218
+ }
11219
+ };
11220
+ process.stdin.on("keypress", onKeypress);
11221
+ render();
11222
+ });
11223
+ };
11224
+ const searchSelect = async (options) => await runSearchPrompt({
11225
+ ...options,
11226
+ mode: "single"
11227
+ });
11228
+
10530
11229
  //#endregion
10531
11230
  //#region src/commands/rules.ts
11231
+ const ENGINE_PRESENTATION = {
11232
+ "ai-slop": {
11233
+ label: "AI Slop",
11234
+ summary: "Generated-code leftovers: vague comments, unsafe casts, stubs, swallowed errors.",
11235
+ order: 10
11236
+ },
11237
+ security: {
11238
+ label: "Security",
11239
+ summary: "Secrets, injection, XSS, shell execution, and vulnerable dependencies.",
11240
+ order: 20
11241
+ },
11242
+ "code-quality": {
11243
+ label: "Code Quality",
11244
+ summary: "Dead code, duplicate code, complexity, and dependency hygiene.",
11245
+ order: 30
11246
+ },
11247
+ format: {
11248
+ label: "Format",
11249
+ summary: "Formatter and import-order checks that aislop can usually fix.",
11250
+ order: 40
11251
+ },
11252
+ lint: {
11253
+ label: "Lint",
11254
+ summary: "Language linter and compiler findings from bundled or system tools.",
11255
+ order: 50
11256
+ },
11257
+ architecture: {
11258
+ label: "Architecture",
11259
+ summary: "Project-specific import and layering rules from .aislop/rules.yml.",
11260
+ order: 60
11261
+ }
11262
+ };
11263
+ const presentationFor = (engine) => ENGINE_PRESENTATION[engine] ?? {
11264
+ label: engine,
11265
+ summary: "Project-specific rules.",
11266
+ order: 100
11267
+ };
11268
+ const severityLabel = (severity) => severity === "warning" ? "warn" : severity;
11269
+ const fixModeLabel = (fixable) => fixable ? "auto" : "review";
10532
11270
  const buildRulesRender = (input) => {
10533
- const header = renderHeader({
11271
+ const header = input.includeHeader === false ? "" : renderHeader({
10534
11272
  version: APP_VERSION,
10535
- command: "rules",
10536
- context: [],
11273
+ command: "Rules catalog",
11274
+ context: [`${input.rules.length} checks`],
10537
11275
  brand: input.printBrand !== false
10538
11276
  });
10539
11277
  const byEngine = /* @__PURE__ */ new Map();
@@ -10542,23 +11280,50 @@ const buildRulesRender = (input) => {
10542
11280
  list.push(r);
10543
11281
  byEngine.set(r.engine, list);
10544
11282
  }
10545
- const engines = [...byEngine.keys()].sort();
11283
+ const engines = [...byEngine.keys()].sort((a, b) => {
11284
+ const pa = presentationFor(a);
11285
+ const pb = presentationFor(b);
11286
+ if (pa.order !== pb.order) return pa.order - pb.order;
11287
+ return pa.label.localeCompare(pb.label);
11288
+ });
10546
11289
  const idWidth = Math.max(20, ...input.rules.map((r) => r.id.length));
10547
- const lines = [];
11290
+ const lines = [` ${style(theme, "muted", "auto = aislop fix can change it; review = inspect and fix with a developer or agent.")}`, ""];
10548
11291
  for (const engine of engines) {
10549
- lines.push(` ${style(theme, "accent", engine)}`);
11292
+ const presentation = presentationFor(engine);
11293
+ lines.push(` ${style(theme, "accent", presentation.label)}`);
11294
+ lines.push(` ${style(theme, "muted", presentation.summary)}`);
11295
+ lines.push(` ${style(theme, "dim", padEnd("Rule ID", idWidth))} ${style(theme, "dim", "Sev")} ${style(theme, "dim", "Fix")} ${style(theme, "dim", "Meaning")}`);
10550
11296
  const rules = (byEngine.get(engine) ?? []).sort((a, b) => a.id.localeCompare(b.id));
10551
11297
  for (const r of rules) {
10552
- const severity = style(theme, r.severity === "error" ? "danger" : "warn", padEnd(r.severity, 8));
10553
- const fixable = r.fixable ? style(theme, "accent", "fixable") : style(theme, "muted", "manual");
10554
- lines.push(` ${padEnd(r.id, idWidth)} ${severity} ${fixable}`);
11298
+ const severityText = severityLabel(r.severity);
11299
+ const severity = style(theme, r.severity === "error" ? "danger" : "warn", padEnd(severityText, 5));
11300
+ const fixable = r.fixable ? style(theme, "accent", padEnd("auto", 6)) : style(theme, "muted", padEnd("review", 6));
11301
+ lines.push(` ${padEnd(r.id, idWidth)} ${severity} ${fixable} ${descriptionForRule(r.id)}`);
10555
11302
  }
10556
11303
  lines.push("");
10557
11304
  }
10558
11305
  const invocation = input.invocation ?? detectInvocation();
10559
- const tail = renderHintLine(`Run ${invocation} scan to apply these rules`) + renderHintLine(`Run ${invocation} init to customize which engines are enabled`);
11306
+ const tail = renderHintLine(`Run ${invocation} scan to check your project against these rules`) + renderHintLine(`Run ${invocation} init to choose engines and CI settings`);
10560
11307
  return `${header}${lines.join("\n")}\n${tail}`;
10561
11308
  };
11309
+ const buildRuleDetailRender = (rule, input = {}) => {
11310
+ const presentation = presentationFor(rule.engine);
11311
+ const header = input.includeHeader === false ? "" : renderHeader({
11312
+ version: APP_VERSION,
11313
+ command: "Rule detail",
11314
+ context: [presentation.label],
11315
+ brand: input.printBrand !== false
11316
+ });
11317
+ const rows = [
11318
+ ["Rule", rule.id],
11319
+ ["Engine", `${presentation.label} — ${presentation.summary}`],
11320
+ ["Severity", severityLabel(rule.severity)],
11321
+ ["Fix", `${fixModeLabel(rule.fixable)}${rule.fixable ? " (aislop fix can change it)" : " (review and fix intentionally)"}`],
11322
+ ["Meaning", descriptionForRule(rule.id)]
11323
+ ];
11324
+ const labelWidth = Math.max(...rows.map(([label]) => label.length));
11325
+ 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")}`;
11326
+ };
10562
11327
  const AI_SLOP_FIXABLE = new Set([
10563
11328
  "ai-slop/trivial-comment",
10564
11329
  "ai-slop/unused-import",
@@ -10691,7 +11456,7 @@ const toRuleEntry = (engine, ruleId) => {
10691
11456
  fixable: false
10692
11457
  };
10693
11458
  };
10694
- const rulesCommand = async (directory, options = {}) => {
11459
+ const collectRuleEntries = (directory) => {
10695
11460
  const resolvedDir = path.resolve(directory);
10696
11461
  const entries = [];
10697
11462
  for (const { engine, rules } of BUILTIN_RULES) for (const rule of rules) entries.push(toRuleEntry(engine, rule));
@@ -10705,6 +11470,41 @@ const rulesCommand = async (directory, options = {}) => {
10705
11470
  fixable: false
10706
11471
  });
10707
11472
  }
11473
+ return entries;
11474
+ };
11475
+ const runRulesExplorer = async (entries, options) => {
11476
+ const selected = await searchSelect({
11477
+ message: "Search rules",
11478
+ items: entries.map((rule) => {
11479
+ const presentation = presentationFor(rule.engine);
11480
+ return {
11481
+ value: rule,
11482
+ label: rule.id,
11483
+ hint: `${presentation.label} · ${severityLabel(rule.severity)} · ${descriptionForRule(rule.id)}`,
11484
+ keywords: [
11485
+ presentation.label,
11486
+ rule.engine,
11487
+ rule.severity,
11488
+ fixModeLabel(rule.fixable),
11489
+ descriptionForRule(rule.id)
11490
+ ]
11491
+ };
11492
+ }),
11493
+ maxVisible: 10,
11494
+ required: true
11495
+ });
11496
+ if (selected === null) return;
11497
+ process.stdout.write(`${buildRuleDetailRender(selected, {
11498
+ printBrand: options.printBrand,
11499
+ includeHeader: true
11500
+ })}\n`);
11501
+ };
11502
+ const rulesCommand = async (directory, options = {}) => {
11503
+ const entries = collectRuleEntries(directory);
11504
+ if (options.interactive && process.stdin.isTTY && process.stdout.isTTY) {
11505
+ await runRulesExplorer(entries, options);
11506
+ return;
11507
+ }
10708
11508
  process.stdout.write(`${buildRulesRender({
10709
11509
  rules: entries,
10710
11510
  invocation: detectInvocation(),
@@ -10713,4 +11513,4 @@ const rulesCommand = async (directory, options = {}) => {
10713
11513
  };
10714
11514
 
10715
11515
  //#endregion
10716
- export { buildDoctorRender, buildInitSuccessRender, buildRulesRender, calculateScore, discoverProject, doctorCommand, fixCommand, initCommand, loadConfig, rulesCommand, scanCommand };
11516
+ export { buildDoctorRender, buildInitSuccessRender, buildRuleDetailRender, buildRulesRender, calculateScore, discoverProject, doctorCommand, fixCommand, initCommand, loadConfig, rulesCommand, scanCommand };