aislop 0.6.0 → 0.6.1

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,4 +1,4 @@
1
- import { n as ENGINE_INFO, r as getEngineLabel, t as APP_VERSION } from "./version-C2lM_2fE.js";
1
+ import { n as ENGINE_INFO, r as getEngineLabel, t as APP_VERSION } from "./version-DukdnmKT.js";
2
2
  import { n as runSubprocess, t as isToolInstalled } from "./subprocess-CQUJDGgn.js";
3
3
  import { r as runGenericLinter, t as fixRubyLint } from "./generic-BrcWMW7E.js";
4
4
  import { n as runExpoDoctor } from "./expo-doctor-Bz0LZhQ6.js";
@@ -10,6 +10,7 @@ import { z } from "zod/v4";
10
10
  import pc from "picocolors";
11
11
  import wcwidth from "wcwidth";
12
12
  import { spawnSync } from "node:child_process";
13
+ import micromatch from "micromatch";
13
14
  import { fileURLToPath } from "node:url";
14
15
  import { performance } from "node:perf_hooks";
15
16
  import os from "node:os";
@@ -19,6 +20,13 @@ import { isCancel, multiselect, select, text } from "@clack/prompts";
19
20
  //#region src/config/defaults.ts
20
21
  const DEFAULT_CONFIG = {
21
22
  version: 1,
23
+ exclude: [
24
+ "node_modules",
25
+ ".git",
26
+ "dist",
27
+ "build",
28
+ "coverage"
29
+ ],
22
30
  engines: {
23
31
  format: true,
24
32
  lint: true,
@@ -172,7 +180,14 @@ const AislopConfigSchema = z.object({
172
180
  failBelow: 0,
173
181
  format: "json"
174
182
  })),
175
- telemetry: TelemetrySchema.default(() => ({ enabled: true }))
183
+ telemetry: TelemetrySchema.default(() => ({ enabled: true })),
184
+ exclude: z.array(z.string()).default(() => [
185
+ "node_modules",
186
+ ".git",
187
+ "dist",
188
+ "build",
189
+ "coverage"
190
+ ])
176
191
  });
177
192
  const defaults = AislopConfigSchema.parse({});
178
193
  /**
@@ -600,7 +615,15 @@ const listProjectFiles = (rootDirectory) => {
600
615
  if (findResult.error || findResult.status !== 0) return [];
601
616
  return findResult.stdout.split("\n").filter((file) => file.length > 0).map((file) => file.replace(/^\.\//, ""));
602
617
  };
603
- const filterProjectFiles = (rootDirectory, files, extraExtensions = []) => {
618
+ const normalizeExcludePatterns = (patterns) => {
619
+ return patterns.flatMap((pattern) => {
620
+ const p = pattern.trim();
621
+ if (p.startsWith(".")) return [`**/*${p}`];
622
+ if (!p.includes("*") && !p.includes(".")) return [`${p}/**`];
623
+ return [p];
624
+ });
625
+ };
626
+ const filterProjectFiles = (rootDirectory, files, extraExtensions = [], exclude = []) => {
604
627
  const extraSet = new Set(extraExtensions);
605
628
  const normalizedFiles = files.map((file) => {
606
629
  const absolutePath = path.isAbsolute(file) ? file : path.resolve(rootDirectory, file);
@@ -610,7 +633,14 @@ const filterProjectFiles = (rootDirectory, files, extraExtensions = []) => {
610
633
  };
611
634
  }).filter(({ relativePath }) => isWithinProject(relativePath));
612
635
  const ignoredPaths = getIgnoredPaths(rootDirectory, normalizedFiles.map(({ relativePath }) => relativePath));
613
- return normalizedFiles.filter(({ absolutePath, relativePath }) => hasAllowedExtension(relativePath, extraSet) && !isExcludedPath(relativePath) && !isTestFile(relativePath) && !ignoredPaths.has(relativePath) && fs.existsSync(absolutePath)).map(({ absolutePath }) => absolutePath);
636
+ const normalizedExcludePatterns = exclude.length ? normalizeExcludePatterns(exclude) : [];
637
+ const isUserExcluded = (relativePath) => {
638
+ if (!normalizedExcludePatterns.length) return false;
639
+ return micromatch.isMatch(relativePath, normalizedExcludePatterns, { dot: true });
640
+ };
641
+ return normalizedFiles.filter(({ absolutePath, relativePath }) => {
642
+ return hasAllowedExtension(relativePath, extraSet) && !isExcludedPath(relativePath) && !isTestFile(relativePath) && !ignoredPaths.has(relativePath) && !isUserExcluded(relativePath) && fs.existsSync(absolutePath);
643
+ }).map(({ absolutePath }) => absolutePath);
614
644
  };
615
645
  const filterExplicitFiles = (rootDirectory, files, extraExtensions = []) => {
616
646
  const extraSet = new Set(extraExtensions);
@@ -875,53 +905,44 @@ const primaryLanguage = (langs) => {
875
905
  ]) if (langs.includes(lang)) return lang;
876
906
  return null;
877
907
  };
908
+ const systemToolDecision = (installed, spec) => installed[spec.binary] ? {
909
+ tool: `${spec.toolLabel} (system)`,
910
+ status: "ok"
911
+ } : {
912
+ tool: `${spec.toolLabel} not found`,
913
+ status: "missing",
914
+ remediation: spec.remediation
915
+ };
916
+ const firstMatching = (langs, installed, specs) => {
917
+ for (const spec of specs) if (langs.includes(spec.language)) return systemToolDecision(installed, spec);
918
+ return null;
919
+ };
920
+ const spec = (language, binary, toolLabel, remediation) => ({
921
+ language,
922
+ binary,
923
+ toolLabel,
924
+ remediation
925
+ });
926
+ const FORMAT_SPECS = [
927
+ spec("python", "ruff", "ruff", "Install: pipx install ruff"),
928
+ spec("go", "gofmt", "gofmt", "Install: via go toolchain — https://go.dev/dl/"),
929
+ spec("rust", "cargo", "cargo fmt", "Install: rustup component add rustfmt"),
930
+ spec("ruby", "rubocop", "rubocop", "Install: gem install rubocop"),
931
+ spec("php", "php-cs-fixer", "php-cs-fixer", "Install: composer global require friendsofphp/php-cs-fixer")
932
+ ];
933
+ const LINT_SPECS = [
934
+ spec("python", "ruff", "ruff", "Install: pipx install ruff"),
935
+ spec("go", "golangci-lint", "golangci-lint", "Install: brew install golangci-lint"),
936
+ spec("rust", "clippy-driver", "clippy", "Install: rustup component add clippy"),
937
+ spec("ruby", "rubocop", "rubocop", "Install: gem install rubocop")
938
+ ];
878
939
  const planFormat = (ctx) => {
879
940
  const { languages, installedTools } = ctx.projectInfo;
880
941
  if (hasJsLike(languages)) return {
881
942
  tool: "biome (bundled)",
882
943
  status: "ok"
883
944
  };
884
- if (languages.includes("python")) return installedTools["ruff"] ? {
885
- tool: "ruff (system)",
886
- status: "ok"
887
- } : {
888
- tool: "ruff not found",
889
- status: "missing",
890
- remediation: "Install: pipx install ruff"
891
- };
892
- if (languages.includes("go")) return installedTools["gofmt"] ? {
893
- tool: "gofmt (system)",
894
- status: "ok"
895
- } : {
896
- tool: "gofmt not found",
897
- status: "missing",
898
- remediation: "Install: via go toolchain — https://go.dev/dl/"
899
- };
900
- if (languages.includes("rust")) return installedTools["cargo"] ? {
901
- tool: "cargo fmt (system)",
902
- status: "ok"
903
- } : {
904
- tool: "cargo fmt not found",
905
- status: "missing",
906
- remediation: "Install: rustup component add rustfmt"
907
- };
908
- if (languages.includes("ruby")) return installedTools["rubocop"] ? {
909
- tool: "rubocop (system)",
910
- status: "ok"
911
- } : {
912
- tool: "rubocop not found",
913
- status: "missing",
914
- remediation: "Install: gem install rubocop"
915
- };
916
- if (languages.includes("php")) return installedTools["php-cs-fixer"] ? {
917
- tool: "php-cs-fixer (system)",
918
- status: "ok"
919
- } : {
920
- tool: "php-cs-fixer not found",
921
- status: "missing",
922
- remediation: "Install: composer global require friendsofphp/php-cs-fixer"
923
- };
924
- return {
945
+ return firstMatching(languages, installedTools, FORMAT_SPECS) ?? {
925
946
  tool: "no formatter",
926
947
  status: "skipped",
927
948
  skipReason: "no supported language"
@@ -937,39 +958,7 @@ const planLint = (ctx) => {
937
958
  tool: "oxlint (bundled)",
938
959
  status: "ok"
939
960
  };
940
- if (languages.includes("python")) return installedTools["ruff"] ? {
941
- tool: "ruff (system)",
942
- status: "ok"
943
- } : {
944
- tool: "ruff not found",
945
- status: "missing",
946
- remediation: "Install: pipx install ruff"
947
- };
948
- if (languages.includes("go")) return installedTools["golangci-lint"] ? {
949
- tool: "golangci-lint (system)",
950
- status: "ok"
951
- } : {
952
- tool: "golangci-lint not found",
953
- status: "missing",
954
- remediation: "Install: brew install golangci-lint"
955
- };
956
- if (languages.includes("rust")) return installedTools["clippy-driver"] ? {
957
- tool: "clippy (system)",
958
- status: "ok"
959
- } : {
960
- tool: "clippy not found",
961
- status: "missing",
962
- remediation: "Install: rustup component add clippy"
963
- };
964
- if (languages.includes("ruby")) return installedTools["rubocop"] ? {
965
- tool: "rubocop (system)",
966
- status: "ok"
967
- } : {
968
- tool: "rubocop not found",
969
- status: "missing",
970
- remediation: "Install: gem install rubocop"
971
- };
972
- return {
961
+ return firstMatching(languages, installedTools, LINT_SPECS) ?? {
973
962
  tool: "no linter",
974
963
  status: "skipped",
975
964
  skipReason: "no supported language"
@@ -989,42 +978,64 @@ const planAiSlop = (_ctx) => ({
989
978
  tool: "built-in",
990
979
  status: "ok"
991
980
  });
981
+ const AUDIT_SPECS = [
982
+ {
983
+ files: ["pnpm-lock.yaml"],
984
+ bundled: "pnpm audit"
985
+ },
986
+ {
987
+ files: ["package-lock.json"],
988
+ bundled: "npm audit"
989
+ },
990
+ {
991
+ files: [
992
+ "requirements.txt",
993
+ "poetry.lock",
994
+ "Pipfile.lock"
995
+ ],
996
+ systemTool: {
997
+ binary: "pip-audit",
998
+ toolLabel: "pip-audit",
999
+ remediation: "Install: pipx install pip-audit"
1000
+ }
1001
+ },
1002
+ {
1003
+ files: ["Cargo.toml"],
1004
+ systemTool: {
1005
+ binary: "cargo-audit",
1006
+ toolLabel: "cargo audit",
1007
+ remediation: "Install: cargo install cargo-audit",
1008
+ requiresBinaries: ["cargo", "cargo-audit"]
1009
+ }
1010
+ },
1011
+ {
1012
+ files: ["go.mod"],
1013
+ systemTool: {
1014
+ binary: "govulncheck",
1015
+ toolLabel: "govulncheck",
1016
+ remediation: "Install: go install golang.org/x/vuln/cmd/govulncheck@latest"
1017
+ }
1018
+ }
1019
+ ];
992
1020
  const planSecurity = (ctx) => {
993
1021
  const { rootDirectory, projectInfo } = ctx;
994
1022
  const { installedTools } = projectInfo;
995
1023
  const hasFile = (rel) => fs.existsSync(path.join(rootDirectory, rel));
996
- if (hasFile("pnpm-lock.yaml")) return {
997
- tool: "pnpm audit",
998
- status: "ok"
999
- };
1000
- if (hasFile("package-lock.json")) return {
1001
- tool: "npm audit",
1002
- status: "ok"
1003
- };
1004
- if (hasFile("requirements.txt") || hasFile("poetry.lock") || hasFile("Pipfile.lock")) return installedTools["pip-audit"] ? {
1005
- tool: "pip-audit (system)",
1006
- status: "ok"
1007
- } : {
1008
- tool: "pip-audit not found",
1009
- status: "missing",
1010
- remediation: "Install: pipx install pip-audit"
1011
- };
1012
- if (hasFile("Cargo.toml")) return installedTools["cargo"] && installedTools["cargo-audit"] ? {
1013
- tool: "cargo audit (system)",
1014
- status: "ok"
1015
- } : {
1016
- tool: "cargo audit not found",
1017
- status: "missing",
1018
- remediation: "Install: cargo install cargo-audit"
1019
- };
1020
- if (hasFile("go.mod")) return installedTools["govulncheck"] ? {
1021
- tool: "govulncheck (system)",
1022
- status: "ok"
1023
- } : {
1024
- tool: "govulncheck not found",
1025
- status: "missing",
1026
- remediation: "Install: go install golang.org/x/vuln/cmd/govulncheck@latest"
1027
- };
1024
+ for (const spec of AUDIT_SPECS) {
1025
+ if (!spec.files.some(hasFile)) continue;
1026
+ if (spec.bundled) return {
1027
+ tool: spec.bundled,
1028
+ status: "ok"
1029
+ };
1030
+ if (spec.systemTool) return (spec.systemTool.requiresBinaries ?? [spec.systemTool.binary]).every((b) => installedTools[b]) ? {
1031
+ tool: `${spec.systemTool.toolLabel} (system)`,
1032
+ status: "ok"
1033
+ } : {
1034
+ tool: `${spec.systemTool.toolLabel} not found`,
1035
+ status: "missing",
1036
+ remediation: spec.systemTool.remediation
1037
+ };
1038
+ }
1028
1039
  return {
1029
1040
  tool: "no auditor",
1030
1041
  status: "skipped",
@@ -1206,23 +1217,9 @@ const detectOverAbstraction = async (context) => {
1206
1217
 
1207
1218
  //#endregion
1208
1219
  //#region src/engines/ai-slop/comments.ts
1209
- const TRIVIAL_JS_COMMENT_PATTERNS = [
1210
- /\/\/\s*This (?:function|method|class|variable|constant) (?:will |is used to |is responsible for )?/i,
1211
- /\/\/\s*Import(?:ing|s)?\s+/i,
1212
- /\/\/\s*Defin(?:e|ing)\s+(?:the\s+)?/i,
1213
- /\/\/\s*Initializ(?:e|ing)\s+(?:the\s+)?/i,
1214
- /\/\/\s*Set(?:ting)?\s+\w+\s+to\s+/i,
1215
- /\/\/\s*Return(?:ing|s)?\s+(?:the\s+)?/i,
1216
- /\/\/\s*Check(?:ing)?\s+(?:if|whether)\s+/i,
1217
- /\/\/\s*(?:Loop(?:ing)?\s+through|Iterat(?:e|ing)\s+over)\s+/i,
1218
- /\/\/\s*Creat(?:e|ing)\s+(?:a\s+(?:new\s+)?)?/i,
1219
- /\/\/\s*Updat(?:e|ing)\s+(?:the\s+)?/i,
1220
- /\/\/\s*(?:Delet|Remov)(?:e|ing)\s+(?:the\s+)?/i,
1221
- /\/\/\s*Handl(?:e|ing)\s+(?:the\s+)?/i,
1222
- /\/\/\s*(?:Get(?:ting)?|Fetch(?:ing)?)\s+(?:the\s+)?/i,
1223
- /\/\/\s*(?:Increment|Decrement)(?:ing)?\s+/i
1224
- ];
1225
- const TRIVIAL_PYTHON_COMMENT_PATTERNS = [/^#\s*This (?:function|method|class) (?:will |is used to )?/i, /^#\s*(?:Import|Define|Initialize|Return|Check|Create|Update|Delete|Handle|Get|Fetch)/i];
1220
+ const TRIVIAL_VERB_STEMS = "Import|Defin|Initializ|Setting|Set\\s+up|Setup|Return|Check|Loop|Iterat|Creat|Updat|Delet|Remov|Handl|Get|Fetch|Increment|Decrement|Writ|Runn|Run|Pars|Execut|Extract|Sav|Load|Build|Start|Stopp|Stop|Clean(?:up|\\s+up)?|Configur|Validat|Process|Queue|Fire|Emit|Dispatch|Log|Print|Render";
1221
+ const TRIVIAL_JS_COMMENT_PATTERNS = [/\/\/\s*This (?:function|method|class|variable|constant) (?:will |is used to |is responsible for )?/i, new RegExp(`\\/\\/\\s*(?:${TRIVIAL_VERB_STEMS})(?:e|es|ing|s)?\\b`, "i")];
1222
+ const TRIVIAL_PYTHON_COMMENT_PATTERNS = [/^#\s*This (?:function|method|class) (?:will |is used to )?/i, new RegExp(`^#\\s*(?:${TRIVIAL_VERB_STEMS})(?:e|es|ing|s)?\\b`, "i")];
1226
1223
  const EXPLANATORY_KEYWORDS = /\b(?:because|since|note|todo|fixme|hack|warn|warning|workaround|caveat|important|assumes?)\b/i;
1227
1224
  const COMMENTED_CODE_CHARS = /[({=;}\]>]/;
1228
1225
  const MAX_TRIVIAL_COMMENT_LENGTH = 60;
@@ -1298,6 +1295,18 @@ const JS_EXTENSIONS$1 = new Set([
1298
1295
  ".cjs"
1299
1296
  ]);
1300
1297
  const CONSOLE_LOG_PATTERN = /\bconsole\.(?:log|debug|info|trace|dir|table)\s*\(/;
1298
+ const slop = (filePath, line, rule, severity, message, help, fixable) => ({
1299
+ filePath,
1300
+ engine: "ai-slop",
1301
+ rule,
1302
+ severity,
1303
+ message,
1304
+ help,
1305
+ line,
1306
+ column: 0,
1307
+ category: "AI Slop",
1308
+ fixable
1309
+ });
1301
1310
  const LOGGER_FILE_PATTERN = /(?:^|\/)(?:logger|logging|log)\.[^/]+$/i;
1302
1311
  const SCRIPT_DIR_PATTERN = /(?:^|\/)(scripts|bin)\//;
1303
1312
  const detectConsoleLeftovers = (content, relativePath, ext) => {
@@ -1312,18 +1321,7 @@ const detectConsoleLeftovers = (content, relativePath, ext) => {
1312
1321
  if (CONSOLE_LOG_PATTERN.test(trimmed)) {
1313
1322
  if (/console\.(?:error|warn)\s*\(/.test(trimmed)) continue;
1314
1323
  if (/console\.log\(\s*JSON\.stringify\b/.test(trimmed)) continue;
1315
- diagnostics.push({
1316
- filePath: relativePath,
1317
- engine: "ai-slop",
1318
- rule: "ai-slop/console-leftover",
1319
- severity: "warning",
1320
- message: "console.log/debug/info statement left in production code",
1321
- help: "Remove debugging console statements or replace with a proper logger",
1322
- line: i + 1,
1323
- column: 0,
1324
- category: "AI Slop",
1325
- fixable: true
1326
- });
1324
+ 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));
1327
1325
  }
1328
1326
  }
1329
1327
  return diagnostics;
@@ -1344,18 +1342,7 @@ const detectTodoStubs = (content, relativePath) => {
1344
1342
  for (let i = 0; i < lines.length; i++) {
1345
1343
  const trimmed = lines[i].trim();
1346
1344
  if (!trimmed.startsWith("//") && !trimmed.startsWith("#") && !trimmed.startsWith("*") && !trimmed.startsWith("/*")) continue;
1347
- if (TODO_PATTERN.test(trimmed)) diagnostics.push({
1348
- filePath: relativePath,
1349
- engine: "ai-slop",
1350
- rule: "ai-slop/todo-stub",
1351
- severity: "info",
1352
- message: "Unresolved TODO/FIXME/HACK comment indicates incomplete code",
1353
- help: "Resolve the TODO or create a tracked issue for it",
1354
- line: i + 1,
1355
- column: 0,
1356
- category: "AI Slop",
1357
- fixable: false
1358
- });
1345
+ if (TODO_PATTERN.test(trimmed)) diagnostics.push(slop(relativePath, i + 1, "ai-slop/todo-stub", "info", "Unresolved TODO/FIXME/HACK comment indicates incomplete code", "Resolve the TODO or create a tracked issue for it", false));
1359
1346
  }
1360
1347
  return diagnostics;
1361
1348
  };
@@ -1365,42 +1352,9 @@ const detectDeadCodePatterns = (content, relativePath, ext) => {
1365
1352
  for (let i = 0; i < lines.length; i++) {
1366
1353
  const trimmed = lines[i].trim();
1367
1354
  const nextLine = i + 1 < lines.length ? lines[i + 1]?.trim() : void 0;
1368
- if (JS_EXTENSIONS$1.has(ext) && /^(?:return|throw)\b/.test(trimmed) && trimmed.endsWith(";") && nextLine && nextLine.length > 0 && !nextLine.startsWith("}") && !nextLine.startsWith("//") && !nextLine.startsWith("/*") && !nextLine.startsWith("case ") && !nextLine.startsWith("default:") && !nextLine.startsWith("if ") && !nextLine.startsWith("if(") && !nextLine.startsWith("else")) diagnostics.push({
1369
- filePath: relativePath,
1370
- engine: "ai-slop",
1371
- rule: "ai-slop/unreachable-code",
1372
- severity: "warning",
1373
- message: "Code after return/throw statement is unreachable",
1374
- help: "Remove the unreachable code or restructure the control flow",
1375
- line: i + 2,
1376
- column: 0,
1377
- category: "AI Slop",
1378
- fixable: false
1379
- });
1380
- 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({
1381
- filePath: relativePath,
1382
- engine: "ai-slop",
1383
- rule: "ai-slop/constant-condition",
1384
- severity: "warning",
1385
- message: "Conditional with a constant value — likely debugging leftover",
1386
- help: "Remove the constant condition or replace with proper logic",
1387
- line: i + 1,
1388
- column: 0,
1389
- category: "AI Slop",
1390
- fixable: false
1391
- });
1392
- if (JS_EXTENSIONS$1.has(ext) && /(?:function\s+\w+|=>\s*)\s*\{\s*\}\s*;?\s*$/.test(trimmed) && !trimmed.startsWith("interface") && !trimmed.startsWith("type ")) diagnostics.push({
1393
- filePath: relativePath,
1394
- engine: "ai-slop",
1395
- rule: "ai-slop/empty-function",
1396
- severity: "info",
1397
- message: "Empty function body — possible stub or unfinished implementation",
1398
- help: "Implement the function body or add a comment explaining why it's empty",
1399
- line: i + 1,
1400
- column: 0,
1401
- category: "AI Slop",
1402
- fixable: false
1403
- });
1355
+ if (JS_EXTENSIONS$1.has(ext) && /^(?:return|throw)\b/.test(trimmed) && trimmed.endsWith(";") && nextLine && nextLine.length > 0 && !nextLine.startsWith("}") && !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));
1356
+ 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));
1357
+ if (JS_EXTENSIONS$1.has(ext) && /(?:function\s+\w+|=>\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));
1404
1358
  }
1405
1359
  return diagnostics;
1406
1360
  };
@@ -1412,46 +1366,13 @@ const detectUnsafeTypePatterns = (content, relativePath, ext) => {
1412
1366
  const lines = content.split("\n");
1413
1367
  for (let i = 0; i < lines.length; i++) {
1414
1368
  const trimmed = lines[i].trim();
1415
- if (/\/\/\s*@ts-(?:ignore|expect-error)/.test(trimmed) || /\/\*\s*@ts-(?:ignore|expect-error)/.test(trimmed)) diagnostics.push({
1416
- filePath: relativePath,
1417
- engine: "ai-slop",
1418
- rule: "ai-slop/ts-directive",
1419
- severity: "info",
1420
- message: "@ts-ignore/@ts-expect-error suppresses type checking — review if still needed",
1421
- help: "Fix the underlying type issue instead of suppressing the error",
1422
- line: i + 1,
1423
- column: 0,
1424
- category: "AI Slop",
1425
- fixable: false
1426
- });
1369
+ if (/\/\/\s*@ts-(?:ignore|expect-error)/.test(trimmed) || /\/\*\s*@ts-(?:ignore|expect-error)/.test(trimmed)) diagnostics.push(slop(relativePath, i + 1, "ai-slop/ts-directive", "info", "@ts-ignore/@ts-expect-error suppresses type checking — review if still needed", "Fix the underlying type issue instead of suppressing the error", false));
1427
1370
  if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue;
1428
1371
  if (/\bRegExp\b|new\s+RegExp|\/.*\\b/.test(trimmed)) continue;
1429
1372
  if (/["'`].*\\b.*["'`]/.test(trimmed)) continue;
1430
- if (asAnyPattern.test(trimmed)) diagnostics.push({
1431
- filePath: relativePath,
1432
- engine: "ai-slop",
1433
- rule: "ai-slop/unsafe-type-assertion",
1434
- severity: "warning",
1435
- message: `'as any' bypasses type safety`,
1436
- help: "Use a proper type or a more specific assertion",
1437
- line: i + 1,
1438
- column: 0,
1439
- category: "AI Slop",
1440
- fixable: false
1441
- });
1373
+ if (asAnyPattern.test(trimmed)) diagnostics.push(slop(relativePath, i + 1, "ai-slop/unsafe-type-assertion", "warning", `'as any' bypasses type safety`, "Use a proper type or a more specific assertion", false));
1442
1374
  if (doubleAssertPattern.test(trimmed)) {
1443
- if (!(/\.query[(<]/.test(trimmed) || /result\[0\]/.test(trimmed) || /rows\s/.test(trimmed))) diagnostics.push({
1444
- filePath: relativePath,
1445
- engine: "ai-slop",
1446
- rule: "ai-slop/double-type-assertion",
1447
- severity: "warning",
1448
- message: `Double type assertion (as unknown as X) bypasses type checking`,
1449
- help: "Refactor to avoid needing a double assertion. If this is an ORM query return, consider a typed wrapper function",
1450
- line: i + 1,
1451
- column: 0,
1452
- category: "AI Slop",
1453
- fixable: false
1454
- });
1375
+ if (!(/\.query[(<]/.test(trimmed) || /result\[0\]/.test(trimmed) || /rows\s/.test(trimmed))) diagnostics.push(slop(relativePath, i + 1, "ai-slop/double-type-assertion", "warning", `Double type assertion (as unknown as X) bypasses type checking`, "Refactor to avoid needing a double assertion. If this is an ORM query return, consider a typed wrapper function", false));
1455
1376
  }
1456
1377
  }
1457
1378
  return diagnostics;
@@ -1587,6 +1508,7 @@ const CROSS_REFERENCE_PHRASES = [
1587
1508
  ];
1588
1509
  const JUSTIFICATION_OPENERS = [/^(The idea here|The trick is|This was needed|Originally,?)/i];
1589
1510
  const EXPLANATORY_OPENERS = /^(Matches|Detects|Represents|Holds|Stores|Tracks|Handles|Manages|Controls|Contains|Captures|Encapsulates|Wraps|Describes)\s+[A-Za-z`'"]/;
1511
+ const EXPLANATORY_WHY_MARKERS = /\b(?:because|since|otherwise|workaround|caveat|warning|important|assumes?|note:|bug|issue|see\s+(?:issue|above|below)|in\s+prod|in\s+production|breaks?\s+when|fails?\s+when|must\s+run|must\s+be|has\s+to\s+be|hack\s+for|fix\s+for|reason:)\b/i;
1590
1512
  const MEANINGFUL_JSDOC_TAGS = new Set([
1591
1513
  "deprecated",
1592
1514
  "see",
@@ -1797,6 +1719,23 @@ const looksLikeLicenseHeader = (block) => {
1797
1719
  const text = block.rawLines.join(" ").toLowerCase();
1798
1720
  return text.includes("copyright") || text.includes("license") || text.includes("spdx-license-identifier");
1799
1721
  };
1722
+ const BARE_LABEL_RE = /^[A-Z][A-Za-z0-9 ]{1,28}$/;
1723
+ const isBareSectionLabel = (prose) => {
1724
+ if (!BARE_LABEL_RE.test(prose)) return false;
1725
+ if (prose.endsWith(".")) return false;
1726
+ if (prose.split(/\s+/).length > 3) return false;
1727
+ return true;
1728
+ };
1729
+ const DATA_ENTRY_START = /^\s*(?:\{|\[|["'`]|\d|\w+:\s|case\s)/;
1730
+ const nextLineLooksLikeDataEntry = (nextLine) => {
1731
+ if (nextLine === null) return false;
1732
+ if (!DATA_ENTRY_START.test(nextLine)) return false;
1733
+ const trimmed = nextLine.trim();
1734
+ if (trimmed.startsWith("case ")) return true;
1735
+ if (trimmed.startsWith("{") || trimmed.startsWith("[") || trimmed.startsWith("\"") || trimmed.startsWith("'") || trimmed.startsWith("`")) return true;
1736
+ if (/^\w+\s*:/.test(trimmed)) return true;
1737
+ return false;
1738
+ };
1800
1739
  const looksLikeSuppressDirective = (block) => block.rawLines.some((l) => /\b(biome-ignore|eslint-disable|ts-ignore|ts-expect-error|@ts-\w+|noqa|pylint:\s*disable|rubocop:disable|noinspection|phpcs:disable)\b/.test(l));
1801
1740
  const detectNarrativeInBlock = (block, ext) => {
1802
1741
  if (looksLikeLicenseHeader(block)) return {
@@ -1819,6 +1758,10 @@ const detectNarrativeInBlock = (block, ext) => {
1819
1758
  matched: true,
1820
1759
  reason: "phase/section header"
1821
1760
  };
1761
+ if (block.kind === "line" && block.prose.length === 1 && isBareSectionLabel(block.prose[0]) && !nextLineLooksLikeDataEntry(block.nextNonBlankLine)) return {
1762
+ matched: true,
1763
+ reason: "bare section label"
1764
+ };
1822
1765
  if (block.prose.length >= 3 && looksLikeDeclarationPreamble(block.nextNonBlankLine, ext)) return {
1823
1766
  matched: true,
1824
1767
  reason: block.kind === "jsdoc" ? "JSDoc preamble before declaration" : "multi-line preamble before declaration"
@@ -1838,10 +1781,17 @@ const detectNarrativeInBlock = (block, ext) => {
1838
1781
  matched: true,
1839
1782
  reason: "explanatory preamble"
1840
1783
  };
1841
- if (block.prose.filter((l) => l.length > 0).length >= 5) return {
1784
+ const nonEmptyProseCount = block.prose.filter((l) => l.length > 0).length;
1785
+ const joinedProse = block.prose.join(" ");
1786
+ const hasWhyMarker = EXPLANATORY_WHY_MARKERS.test(joinedProse);
1787
+ if (nonEmptyProseCount >= 5) return {
1842
1788
  matched: true,
1843
1789
  reason: "long narrative block"
1844
1790
  };
1791
+ if (nonEmptyProseCount >= 3 && !hasWhyMarker && block.kind === "line") return {
1792
+ matched: true,
1793
+ reason: "multi-line narrative prose"
1794
+ };
1845
1795
  return {
1846
1796
  matched: false,
1847
1797
  reason: ""
@@ -2601,6 +2551,130 @@ const checkComplexity = async (context) => {
2601
2551
  return diagnostics;
2602
2552
  };
2603
2553
 
2554
+ //#endregion
2555
+ //#region src/engines/code-quality/duplicate-block.ts
2556
+ const WINDOW_SIZE = 10;
2557
+ const MIN_DISTINCT_LINES = 7;
2558
+ const SOURCE_EXTS$1 = new Set([
2559
+ ".ts",
2560
+ ".tsx",
2561
+ ".js",
2562
+ ".jsx",
2563
+ ".mjs",
2564
+ ".cjs"
2565
+ ]);
2566
+ const MEANINGFUL_LINE = /\S/;
2567
+ const normaliseLine = (line) => line.replace(/"[^"]*"|'[^']*'|`[^`]*`/g, "\"L\"").replace(/\b\d+(?:\.\d+)?\b/g, "0").replace(/\s+/g, " ").trim();
2568
+ const isTrivialLine = (line) => {
2569
+ const trimmed = line.trim();
2570
+ if (trimmed.length === 0) return true;
2571
+ if (trimmed === "{" || trimmed === "}" || trimmed === "});" || trimmed === "},") return true;
2572
+ if (trimmed.startsWith("//")) return true;
2573
+ if (trimmed.startsWith("/*") || trimmed.startsWith("*")) return true;
2574
+ return false;
2575
+ };
2576
+ const SUPPRESS_RE = /aislop[- ]ignore(?:-next-block|-file)?\b.*\b(?:duplicate-block|code-quality\/duplicate-block)\b/;
2577
+ const FILE_SUPPRESS_RE = /aislop[- ]ignore-file\b.*\b(?:duplicate-block|code-quality\/duplicate-block)\b/;
2578
+ const fileHasSuppression = (content) => FILE_SUPPRESS_RE.test(content);
2579
+ const findSuppressedLines = (lines) => {
2580
+ const suppressed = /* @__PURE__ */ new Set();
2581
+ for (let i = 0; i < lines.length; i++) {
2582
+ if (!SUPPRESS_RE.test(lines[i])) continue;
2583
+ let depth = 0;
2584
+ let started = false;
2585
+ for (let j = i + 1; j < lines.length; j++) {
2586
+ suppressed.add(j + 1);
2587
+ const opens = (lines[j].match(/\{/g) ?? []).length;
2588
+ const closes = (lines[j].match(/\}/g) ?? []).length;
2589
+ depth += opens - closes;
2590
+ if (opens > 0) started = true;
2591
+ if (started && depth <= 0) break;
2592
+ }
2593
+ }
2594
+ return suppressed;
2595
+ };
2596
+ const collectMeaningfulLines = (content) => {
2597
+ if (fileHasSuppression(content)) return [];
2598
+ const lines = content.split("\n");
2599
+ const suppressed = findSuppressedLines(lines);
2600
+ const hits = [];
2601
+ for (let i = 0; i < lines.length - WINDOW_SIZE + 1; i++) {
2602
+ if (suppressed.has(i + 1)) continue;
2603
+ const window = lines.slice(i, i + WINDOW_SIZE);
2604
+ if (window.some((l) => !MEANINGFUL_LINE.test(l))) continue;
2605
+ if (window.every(isTrivialLine)) continue;
2606
+ const normalised = window.map(normaliseLine);
2607
+ if (normalised.filter((n) => n.length > 0 && n !== "}" && n !== "{").length < WINDOW_SIZE - 1) continue;
2608
+ if (new Set(normalised).size < MIN_DISTINCT_LINES) continue;
2609
+ hits.push({
2610
+ startLine: i + 1,
2611
+ normalised
2612
+ });
2613
+ }
2614
+ return hits;
2615
+ };
2616
+ const findDuplicateBlocks = (content, relativePath) => {
2617
+ const blocks = collectMeaningfulLines(content);
2618
+ const seen = /* @__PURE__ */ new Map();
2619
+ const reports = [];
2620
+ const reportedCurrent = /* @__PURE__ */ new Set();
2621
+ for (const block of blocks) {
2622
+ const key = block.normalised.join("\n");
2623
+ const prior = seen.get(key);
2624
+ if (prior === void 0) {
2625
+ seen.set(key, block.startLine);
2626
+ continue;
2627
+ }
2628
+ if (block.startLine - prior < WINDOW_SIZE) continue;
2629
+ if (reportedCurrent.has(prior)) continue;
2630
+ const last = reports[reports.length - 1];
2631
+ if (last && block.startLine - last.currentStart < WINDOW_SIZE && prior - last.priorStart < WINDOW_SIZE) {
2632
+ last.priorEnd = Math.max(last.priorEnd, prior + WINDOW_SIZE - 1);
2633
+ last.currentEnd = Math.max(last.currentEnd, block.startLine + WINDOW_SIZE - 1);
2634
+ continue;
2635
+ }
2636
+ reportedCurrent.add(prior);
2637
+ reports.push({
2638
+ priorStart: prior,
2639
+ priorEnd: prior + WINDOW_SIZE - 1,
2640
+ currentStart: block.startLine,
2641
+ currentEnd: block.startLine + WINDOW_SIZE - 1
2642
+ });
2643
+ }
2644
+ return reports.map((r) => {
2645
+ return {
2646
+ filePath: relativePath,
2647
+ engine: "code-quality",
2648
+ rule: "code-quality/duplicate-block",
2649
+ severity: "warning",
2650
+ message: `${r.currentEnd - r.currentStart + 1}-line block at line ${r.currentStart} duplicates a block starting at line ${r.priorStart}. Extract a shared helper.`,
2651
+ help: `Pull the shared logic into a function both sites can call. Keeps one version of the truth and makes future changes one-shot instead of N-shot.`,
2652
+ line: r.currentStart,
2653
+ column: 0,
2654
+ category: "Complexity",
2655
+ fixable: false
2656
+ };
2657
+ });
2658
+ };
2659
+ const detectDuplicateBlocks = async (context) => {
2660
+ const files = getSourceFiles(context);
2661
+ const diagnostics = [];
2662
+ for (const filePath of files) {
2663
+ const ext = path.extname(filePath).toLowerCase();
2664
+ if (!SOURCE_EXTS$1.has(ext)) continue;
2665
+ if (isAutoGenerated(filePath)) continue;
2666
+ let content;
2667
+ try {
2668
+ content = fs.readFileSync(filePath, "utf-8");
2669
+ } catch {
2670
+ continue;
2671
+ }
2672
+ const relative = path.relative(context.rootDirectory, filePath);
2673
+ diagnostics.push(...findDuplicateBlocks(content, relative));
2674
+ }
2675
+ return diagnostics;
2676
+ };
2677
+
2604
2678
  //#endregion
2605
2679
  //#region src/engines/code-quality/knip.ts
2606
2680
  const KNIP_MESSAGE_MAP = {
@@ -2775,6 +2849,78 @@ const runKnip = async (rootDirectory) => {
2775
2849
  }
2776
2850
  };
2777
2851
 
2852
+ //#endregion
2853
+ //#region src/engines/code-quality/repeated-chained-call.ts
2854
+ const METHOD_CALL_RE = /^\s*\.([A-Za-z_$][\w$]*)\s*\(/;
2855
+ const CHAIN_THRESHOLD = 5;
2856
+ const hasOnlyLiteralDifferences = (lines) => {
2857
+ const normalised = lines.map((l) => l.replace(/"[^"]*"|'[^']*'|`[^`]*`/g, "\"L\"").trim().replace(/;\s*$/, ""));
2858
+ return new Set(normalised).size === 1;
2859
+ };
2860
+ const findRepeatedChains = (content, relativePath) => {
2861
+ const diagnostics = [];
2862
+ const lines = content.split("\n");
2863
+ let i = 0;
2864
+ while (i < lines.length) {
2865
+ const match = lines[i].match(METHOD_CALL_RE);
2866
+ if (!match) {
2867
+ i += 1;
2868
+ continue;
2869
+ }
2870
+ const methodName = match[1];
2871
+ const runStart = i;
2872
+ let runEnd = i;
2873
+ while (runEnd + 1 < lines.length) {
2874
+ const next = lines[runEnd + 1].match(METHOD_CALL_RE);
2875
+ if (!next || next[1] !== methodName) break;
2876
+ runEnd += 1;
2877
+ }
2878
+ const runLength = runEnd - runStart + 1;
2879
+ if (runLength >= CHAIN_THRESHOLD && hasOnlyLiteralDifferences(lines.slice(runStart, runEnd + 1))) {
2880
+ diagnostics.push({
2881
+ filePath: relativePath,
2882
+ engine: "code-quality",
2883
+ rule: "code-quality/repeated-chained-call",
2884
+ severity: "warning",
2885
+ message: `${runLength} consecutive \`.${methodName}()\` calls that differ only in string literals. Extract a data table + loop.`,
2886
+ help: `Move the per-call args into an array and call \`.${methodName}()\` in a \`for\` loop. Keeps the registration in one place and lets you document the table once.`,
2887
+ line: runStart + 1,
2888
+ column: 0,
2889
+ category: "Complexity",
2890
+ fixable: false
2891
+ });
2892
+ i = runEnd + 1;
2893
+ } else i += 1;
2894
+ }
2895
+ return diagnostics;
2896
+ };
2897
+ const SOURCE_EXTS = new Set([
2898
+ ".ts",
2899
+ ".tsx",
2900
+ ".js",
2901
+ ".jsx",
2902
+ ".mjs",
2903
+ ".cjs"
2904
+ ]);
2905
+ const detectRepeatedChainedCalls = async (context) => {
2906
+ const files = getSourceFiles(context);
2907
+ const diagnostics = [];
2908
+ for (const filePath of files) {
2909
+ const ext = path.extname(filePath).toLowerCase();
2910
+ if (!SOURCE_EXTS.has(ext)) continue;
2911
+ if (isAutoGenerated(filePath)) continue;
2912
+ let content;
2913
+ try {
2914
+ content = fs.readFileSync(filePath, "utf-8");
2915
+ } catch {
2916
+ continue;
2917
+ }
2918
+ const relative = path.relative(context.rootDirectory, filePath);
2919
+ diagnostics.push(...findRepeatedChains(content, relative));
2920
+ }
2921
+ return diagnostics;
2922
+ };
2923
+
2778
2924
  //#endregion
2779
2925
  //#region src/engines/code-quality/index.ts
2780
2926
  const codeQualityEngine = {
@@ -2784,6 +2930,8 @@ const codeQualityEngine = {
2784
2930
  const promises = [];
2785
2931
  if (context.languages.includes("typescript") || context.languages.includes("javascript")) promises.push(runKnip(context.rootDirectory));
2786
2932
  promises.push(checkComplexity(context));
2933
+ promises.push(detectRepeatedChainedCalls(context));
2934
+ promises.push(detectDuplicateBlocks(context));
2787
2935
  const results = await Promise.allSettled(promises);
2788
2936
  for (const result of results) if (result.status === "fulfilled") diagnostics.push(...result.value);
2789
2937
  return {
@@ -4032,6 +4180,54 @@ const maskStringsAndComments = (content, ext) => {
4032
4180
  if (family === "js") return maskJs(content);
4033
4181
  return maskSimple(content, family);
4034
4182
  };
4183
+ const handleQuotesAndComments = (content, i, tplStack, mask) => {
4184
+ const len = content.length;
4185
+ const c = content[i];
4186
+ const next = content[i + 1];
4187
+ if (c === "\"" || c === "'") {
4188
+ const strStart = i;
4189
+ const end = consumeQuotedString(content, i, c);
4190
+ mask(strStart + 1, end - 1);
4191
+ return {
4192
+ handled: true,
4193
+ nextI: end
4194
+ };
4195
+ }
4196
+ if (c === "`") {
4197
+ const scan = consumeTemplateString(content, i + 1);
4198
+ mask(i + 1, scan.maskEnd);
4199
+ if (scan.openedInterp) tplStack.push(0);
4200
+ return {
4201
+ handled: true,
4202
+ nextI: scan.resumeAt
4203
+ };
4204
+ }
4205
+ if (c === "/" && next === "/") {
4206
+ const strStart = i;
4207
+ let k = i;
4208
+ while (k < len && content[k] !== "\n") k++;
4209
+ mask(strStart, k);
4210
+ return {
4211
+ handled: true,
4212
+ nextI: k
4213
+ };
4214
+ }
4215
+ if (c === "/" && next === "*") {
4216
+ const strStart = i;
4217
+ let k = i + 2;
4218
+ while (k < len - 1 && !(content[k] === "*" && content[k + 1] === "/")) k++;
4219
+ if (k < len - 1) k += 2;
4220
+ mask(strStart, k);
4221
+ return {
4222
+ handled: true,
4223
+ nextI: k
4224
+ };
4225
+ }
4226
+ return {
4227
+ handled: false,
4228
+ nextI: i
4229
+ };
4230
+ };
4035
4231
  const maskJs = (content) => {
4036
4232
  const out = content.split("");
4037
4233
  const len = content.length;
@@ -4042,7 +4238,6 @@ const maskJs = (content) => {
4042
4238
  };
4043
4239
  while (i < len) {
4044
4240
  const c = content[i];
4045
- const next = content[i + 1];
4046
4241
  if (tplStack.length > 0) {
4047
4242
  if (c === "{") {
4048
4243
  tplStack[tplStack.length - 1]++;
@@ -4062,61 +4257,10 @@ const maskJs = (content) => {
4062
4257
  i++;
4063
4258
  continue;
4064
4259
  }
4065
- if (c === "\"" || c === "'") {
4066
- const strStart = i;
4067
- i = consumeQuotedString(content, i, c);
4068
- mask(strStart + 1, i - 1);
4069
- continue;
4070
- }
4071
- if (c === "`") {
4072
- const scan = consumeTemplateString(content, i + 1);
4073
- mask(i + 1, scan.maskEnd);
4074
- if (scan.openedInterp) tplStack.push(0);
4075
- i = scan.resumeAt;
4076
- continue;
4077
- }
4078
- if (c === "/" && next === "/") {
4079
- const strStart = i;
4080
- while (i < len && content[i] !== "\n") i++;
4081
- mask(strStart, i);
4082
- continue;
4083
- }
4084
- if (c === "/" && next === "*") {
4085
- const strStart = i;
4086
- i += 2;
4087
- while (i < len - 1 && !(content[i] === "*" && content[i + 1] === "/")) i++;
4088
- if (i < len - 1) i += 2;
4089
- mask(strStart, i);
4090
- continue;
4091
- }
4092
- i++;
4093
- continue;
4094
- }
4095
- if (c === "\"" || c === "'") {
4096
- const strStart = i;
4097
- i = consumeQuotedString(content, i, c);
4098
- mask(strStart + 1, i - 1);
4099
- continue;
4100
- }
4101
- if (c === "`") {
4102
- const scan = consumeTemplateString(content, i + 1);
4103
- mask(i + 1, scan.maskEnd);
4104
- if (scan.openedInterp) tplStack.push(0);
4105
- i = scan.resumeAt;
4106
- continue;
4107
- }
4108
- if (c === "/" && next === "/") {
4109
- const strStart = i;
4110
- while (i < len && content[i] !== "\n") i++;
4111
- mask(strStart, i);
4112
- continue;
4113
4260
  }
4114
- if (c === "/" && next === "*") {
4115
- const strStart = i;
4116
- i += 2;
4117
- while (i < len - 1 && !(content[i] === "*" && content[i + 1] === "/")) i++;
4118
- if (i < len - 1) i += 2;
4119
- mask(strStart, i);
4261
+ const handled = handleQuotesAndComments(content, i, tplStack, mask);
4262
+ if (handled.handled) {
4263
+ i = handled.nextI;
4120
4264
  continue;
4121
4265
  }
4122
4266
  i++;
@@ -5111,11 +5255,14 @@ const scanCommand = async (directory, config, options) => {
5111
5255
  const projectInfo = await discoverProject(resolvedDir);
5112
5256
  let files;
5113
5257
  if (options.staged) {
5114
- files = filterProjectFiles(resolvedDir, getStagedFiles(resolvedDir));
5258
+ files = filterProjectFiles(resolvedDir, getStagedFiles(resolvedDir), [], config.exclude);
5115
5259
  if (!options.json) log.muted(`Scope: ${files.length} staged file(s)`);
5116
5260
  } else if (options.changes) {
5117
- files = filterProjectFiles(resolvedDir, getChangedFiles(resolvedDir));
5261
+ files = filterProjectFiles(resolvedDir, getChangedFiles(resolvedDir), [], config.exclude);
5118
5262
  if (!options.json) log.muted(`Scope: ${files.length} changed file(s)`);
5263
+ } else {
5264
+ files = filterProjectFiles(resolvedDir, listProjectFiles(resolvedDir), [], config.exclude);
5265
+ if (!options.json) log.muted(`Scope: ${files.length} file(s) after exclusions`);
5119
5266
  }
5120
5267
  const configDir = findConfigDir(resolvedDir);
5121
5268
  const rulesPath = configDir ? path.join(configDir, RULES_FILE) : void 0;
@@ -5189,7 +5336,7 @@ const scanCommand = async (directory, config, options) => {
5189
5336
  });
5190
5337
  }
5191
5338
  if (options.json) {
5192
- const { buildJsonOutput } = await import("./json-DcE9soYJ.js");
5339
+ const { buildJsonOutput } = await import("./json-DIW4kCBS.js");
5193
5340
  const jsonOut = buildJsonOutput(results, scoreResult, projectInfo.sourceFileCount, elapsedMs);
5194
5341
  console.log(JSON.stringify(jsonOut, null, 2));
5195
5342
  return { exitCode };