aislop 0.5.1 → 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-CIlgPf8Q.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",
@@ -1611,7 +1533,49 @@ const MEANINGFUL_JSDOC_TAGS = new Set([
1611
1533
  "todo",
1612
1534
  "link",
1613
1535
  "license",
1614
- "preserve"
1536
+ "preserve",
1537
+ "swagger",
1538
+ "openapi",
1539
+ "route",
1540
+ "group",
1541
+ "summary",
1542
+ "description",
1543
+ "operationid",
1544
+ "response",
1545
+ "responses",
1546
+ "request",
1547
+ "requestbody",
1548
+ "security",
1549
+ "tag",
1550
+ "tags",
1551
+ "path",
1552
+ "body",
1553
+ "query",
1554
+ "queryparam",
1555
+ "header",
1556
+ "headers",
1557
+ "produces",
1558
+ "accept",
1559
+ "middleware",
1560
+ "api",
1561
+ "apiname",
1562
+ "apidefine",
1563
+ "apigroup",
1564
+ "apiparam",
1565
+ "apiquery",
1566
+ "apibody",
1567
+ "apiheader",
1568
+ "apisuccess",
1569
+ "apierror",
1570
+ "apiexample",
1571
+ "apiversion",
1572
+ "apidescription",
1573
+ "apipermission",
1574
+ "apiuse",
1575
+ "apiignore",
1576
+ "apiprivate",
1577
+ "namespace",
1578
+ "category"
1615
1579
  ]);
1616
1580
  const SUPPORTED_EXTS = new Set([
1617
1581
  ".ts",
@@ -1755,6 +1719,23 @@ const looksLikeLicenseHeader = (block) => {
1755
1719
  const text = block.rawLines.join(" ").toLowerCase();
1756
1720
  return text.includes("copyright") || text.includes("license") || text.includes("spdx-license-identifier");
1757
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
+ };
1758
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));
1759
1740
  const detectNarrativeInBlock = (block, ext) => {
1760
1741
  if (looksLikeLicenseHeader(block)) return {
@@ -1777,6 +1758,10 @@ const detectNarrativeInBlock = (block, ext) => {
1777
1758
  matched: true,
1778
1759
  reason: "phase/section header"
1779
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
+ };
1780
1765
  if (block.prose.length >= 3 && looksLikeDeclarationPreamble(block.nextNonBlankLine, ext)) return {
1781
1766
  matched: true,
1782
1767
  reason: block.kind === "jsdoc" ? "JSDoc preamble before declaration" : "multi-line preamble before declaration"
@@ -1796,10 +1781,17 @@ const detectNarrativeInBlock = (block, ext) => {
1796
1781
  matched: true,
1797
1782
  reason: "explanatory preamble"
1798
1783
  };
1799
- 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 {
1800
1788
  matched: true,
1801
1789
  reason: "long narrative block"
1802
1790
  };
1791
+ if (nonEmptyProseCount >= 3 && !hasWhyMarker && block.kind === "line") return {
1792
+ matched: true,
1793
+ reason: "multi-line narrative prose"
1794
+ };
1803
1795
  return {
1804
1796
  matched: false,
1805
1797
  reason: ""
@@ -2559,6 +2551,130 @@ const checkComplexity = async (context) => {
2559
2551
  return diagnostics;
2560
2552
  };
2561
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
+
2562
2678
  //#endregion
2563
2679
  //#region src/engines/code-quality/knip.ts
2564
2680
  const KNIP_MESSAGE_MAP = {
@@ -2733,6 +2849,78 @@ const runKnip = async (rootDirectory) => {
2733
2849
  }
2734
2850
  };
2735
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
+
2736
2924
  //#endregion
2737
2925
  //#region src/engines/code-quality/index.ts
2738
2926
  const codeQualityEngine = {
@@ -2742,6 +2930,8 @@ const codeQualityEngine = {
2742
2930
  const promises = [];
2743
2931
  if (context.languages.includes("typescript") || context.languages.includes("javascript")) promises.push(runKnip(context.rootDirectory));
2744
2932
  promises.push(checkComplexity(context));
2933
+ promises.push(detectRepeatedChainedCalls(context));
2934
+ promises.push(detectDuplicateBlocks(context));
2745
2935
  const results = await Promise.allSettled(promises);
2746
2936
  for (const result of results) if (result.status === "fulfilled") diagnostics.push(...result.value);
2747
2937
  return {
@@ -3990,6 +4180,54 @@ const maskStringsAndComments = (content, ext) => {
3990
4180
  if (family === "js") return maskJs(content);
3991
4181
  return maskSimple(content, family);
3992
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
+ };
3993
4231
  const maskJs = (content) => {
3994
4232
  const out = content.split("");
3995
4233
  const len = content.length;
@@ -4000,7 +4238,6 @@ const maskJs = (content) => {
4000
4238
  };
4001
4239
  while (i < len) {
4002
4240
  const c = content[i];
4003
- const next = content[i + 1];
4004
4241
  if (tplStack.length > 0) {
4005
4242
  if (c === "{") {
4006
4243
  tplStack[tplStack.length - 1]++;
@@ -4020,61 +4257,10 @@ const maskJs = (content) => {
4020
4257
  i++;
4021
4258
  continue;
4022
4259
  }
4023
- if (c === "\"" || c === "'") {
4024
- const strStart = i;
4025
- i = consumeQuotedString(content, i, c);
4026
- mask(strStart + 1, i - 1);
4027
- continue;
4028
- }
4029
- if (c === "`") {
4030
- const scan = consumeTemplateString(content, i + 1);
4031
- mask(i + 1, scan.maskEnd);
4032
- if (scan.openedInterp) tplStack.push(0);
4033
- i = scan.resumeAt;
4034
- continue;
4035
- }
4036
- if (c === "/" && next === "/") {
4037
- const strStart = i;
4038
- while (i < len && content[i] !== "\n") i++;
4039
- mask(strStart, i);
4040
- continue;
4041
- }
4042
- if (c === "/" && next === "*") {
4043
- const strStart = i;
4044
- i += 2;
4045
- while (i < len - 1 && !(content[i] === "*" && content[i + 1] === "/")) i++;
4046
- if (i < len - 1) i += 2;
4047
- mask(strStart, i);
4048
- continue;
4049
- }
4050
- i++;
4051
- continue;
4052
4260
  }
4053
- if (c === "\"" || c === "'") {
4054
- const strStart = i;
4055
- i = consumeQuotedString(content, i, c);
4056
- mask(strStart + 1, i - 1);
4057
- continue;
4058
- }
4059
- if (c === "`") {
4060
- const scan = consumeTemplateString(content, i + 1);
4061
- mask(i + 1, scan.maskEnd);
4062
- if (scan.openedInterp) tplStack.push(0);
4063
- i = scan.resumeAt;
4064
- continue;
4065
- }
4066
- if (c === "/" && next === "/") {
4067
- const strStart = i;
4068
- while (i < len && content[i] !== "\n") i++;
4069
- mask(strStart, i);
4070
- continue;
4071
- }
4072
- if (c === "/" && next === "*") {
4073
- const strStart = i;
4074
- i += 2;
4075
- while (i < len - 1 && !(content[i] === "*" && content[i + 1] === "/")) i++;
4076
- if (i < len - 1) i += 2;
4077
- mask(strStart, i);
4261
+ const handled = handleQuotesAndComments(content, i, tplStack, mask);
4262
+ if (handled.handled) {
4263
+ i = handled.nextI;
4078
4264
  continue;
4079
4265
  }
4080
4266
  i++;
@@ -4951,7 +5137,7 @@ const renderCleanRun = (input, deps = {}) => {
4951
5137
  //#region src/utils/git.ts
4952
5138
  const MAX_BUFFER = 50 * 1024 * 1024;
4953
5139
  const getChangedFiles = (cwd, base) => {
4954
- const result = spawnSync("git", [
5140
+ const diff = spawnSync("git", [
4955
5141
  "diff",
4956
5142
  "--name-only",
4957
5143
  "--diff-filter=ACMR",
@@ -4961,8 +5147,22 @@ const getChangedFiles = (cwd, base) => {
4961
5147
  encoding: "utf-8",
4962
5148
  maxBuffer: MAX_BUFFER
4963
5149
  });
4964
- if (result.error || result.status !== 0) return [];
4965
- return result.stdout.split("\n").filter((f) => f.length > 0).map((f) => path.resolve(cwd, f));
5150
+ if (diff.error || diff.status !== 0) return [];
5151
+ const untracked = spawnSync("git", [
5152
+ "ls-files",
5153
+ "--others",
5154
+ "--exclude-standard"
5155
+ ], {
5156
+ cwd,
5157
+ encoding: "utf-8",
5158
+ maxBuffer: MAX_BUFFER
5159
+ });
5160
+ const names = /* @__PURE__ */ new Set();
5161
+ for (const line of diff.stdout.split("\n")) if (line.length > 0) names.add(line);
5162
+ if (!untracked.error && untracked.status === 0) {
5163
+ for (const line of untracked.stdout.split("\n")) if (line.length > 0) names.add(line);
5164
+ }
5165
+ return Array.from(names).map((f) => path.resolve(cwd, f));
4966
5166
  };
4967
5167
  const getStagedFiles = (cwd) => {
4968
5168
  const result = spawnSync("git", [
@@ -5055,11 +5255,14 @@ const scanCommand = async (directory, config, options) => {
5055
5255
  const projectInfo = await discoverProject(resolvedDir);
5056
5256
  let files;
5057
5257
  if (options.staged) {
5058
- files = filterProjectFiles(resolvedDir, getStagedFiles(resolvedDir));
5258
+ files = filterProjectFiles(resolvedDir, getStagedFiles(resolvedDir), [], config.exclude);
5059
5259
  if (!options.json) log.muted(`Scope: ${files.length} staged file(s)`);
5060
5260
  } else if (options.changes) {
5061
- files = filterProjectFiles(resolvedDir, getChangedFiles(resolvedDir));
5261
+ files = filterProjectFiles(resolvedDir, getChangedFiles(resolvedDir), [], config.exclude);
5062
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`);
5063
5266
  }
5064
5267
  const configDir = findConfigDir(resolvedDir);
5065
5268
  const rulesPath = configDir ? path.join(configDir, RULES_FILE) : void 0;
@@ -5133,7 +5336,7 @@ const scanCommand = async (directory, config, options) => {
5133
5336
  });
5134
5337
  }
5135
5338
  if (options.json) {
5136
- const { buildJsonOutput } = await import("./json-D_i2_5_-.js");
5339
+ const { buildJsonOutput } = await import("./json-DIW4kCBS.js");
5137
5340
  const jsonOut = buildJsonOutput(results, scoreResult, projectInfo.sourceFileCount, elapsedMs);
5138
5341
  console.log(JSON.stringify(jsonOut, null, 2));
5139
5342
  return { exitCode };
@@ -5919,7 +6122,13 @@ const fixDependencyAudit = async (context, onProgress) => {
5919
6122
  await tryNpmOverrides(context.rootDirectory, onProgress);
5920
6123
  return;
5921
6124
  }
5922
- await tryPnpmOverrides(context.rootDirectory, onProgress);
6125
+ if (await tryPnpmOverrides(context.rootDirectory, onProgress)) return;
6126
+ if (fs.existsSync(path.join(context.rootDirectory, "package-lock.json"))) {
6127
+ await runNpmAuditFix(context.rootDirectory, onProgress);
6128
+ await tryNpmOverrides(context.rootDirectory, onProgress);
6129
+ return;
6130
+ }
6131
+ onProgress?.("Dependency audit fixes · skipping (pnpm audit unavailable and no package-lock.json for npm fallback)");
5923
6132
  };
5924
6133
  const runNpmAuditFix = async (rootDir, onProgress) => {
5925
6134
  onProgress?.("Dependency audit fixes · running npm audit fix (can take a few minutes)");
@@ -5951,11 +6160,11 @@ const fetchLatestVersion = async (rootDir, pkgName, pm) => {
5951
6160
  return null;
5952
6161
  }
5953
6162
  };
5954
- const collectNpmOverrides = async (rootDir, vulnerabilities) => {
6163
+ const collectOverrides = async (rootDir, vulnerabilities, pm) => {
5955
6164
  const overrides = {};
5956
6165
  for (const [pkgName, vuln] of Object.entries(vulnerabilities)) {
5957
6166
  if (vuln.fixAvailable !== false || !vuln.range) continue;
5958
- const latest = await fetchLatestVersion(rootDir, pkgName, "npm");
6167
+ const latest = await fetchLatestVersion(rootDir, pkgName, pm);
5959
6168
  if (latest) overrides[pkgName] = latest;
5960
6169
  }
5961
6170
  return overrides;
@@ -5969,7 +6178,7 @@ const tryNpmOverrides = async (rootDir, onProgress) => {
5969
6178
  if (!auditResult.stdout) return;
5970
6179
  const vulnerabilities = JSON.parse(auditResult.stdout).vulnerabilities;
5971
6180
  if (!vulnerabilities) return;
5972
- const overrides = await collectNpmOverrides(rootDir, vulnerabilities);
6181
+ const overrides = await collectOverrides(rootDir, vulnerabilities, "npm");
5973
6182
  if (Object.keys(overrides).length === 0) return;
5974
6183
  const pkgPath = path.join(rootDir, "package.json");
5975
6184
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
@@ -6005,23 +6214,31 @@ const collectPnpmOverrides = (advisories) => {
6005
6214
  }
6006
6215
  return overrides;
6007
6216
  };
6217
+ const isPnpmAuditRetired = (stdout, stderr) => {
6218
+ const haystack = `${stdout}\n${stderr}`.toLowerCase();
6219
+ return haystack.includes("410") || haystack.includes("gone") || haystack.includes("retired") || haystack.includes("endpoint") || haystack.includes("err_pnpm_audit") || haystack.includes("audit endpoint");
6220
+ };
6008
6221
  const tryPnpmOverrides = async (rootDir, onProgress) => {
6009
6222
  onProgress?.("Dependency audit fixes · running pnpm audit");
6010
6223
  const auditResult = await runSubprocess("pnpm", ["audit", "--json"], {
6011
6224
  cwd: rootDir,
6012
6225
  timeout: AUDIT_TIMEOUT
6013
6226
  });
6014
- if (!auditResult.stdout) return;
6227
+ if (!auditResult.stdout) {
6228
+ if (isPnpmAuditRetired(auditResult.stdout ?? "", auditResult.stderr ?? "")) return false;
6229
+ return auditResult.exitCode === 0;
6230
+ }
6015
6231
  let parsed;
6016
6232
  try {
6017
6233
  parsed = JSON.parse(auditResult.stdout);
6018
6234
  } catch {
6019
- return;
6235
+ if (auditResult.exitCode !== 0 || isPnpmAuditRetired(auditResult.stdout, auditResult.stderr ?? "")) return false;
6236
+ return true;
6020
6237
  }
6021
6238
  const advisories = parsed.advisories;
6022
- if (!advisories || Object.keys(advisories).length === 0) return;
6239
+ if (!advisories || Object.keys(advisories).length === 0) return true;
6023
6240
  const overrides = collectPnpmOverrides(advisories);
6024
- if (Object.keys(overrides).length === 0) return;
6241
+ if (Object.keys(overrides).length === 0) return true;
6025
6242
  const pkgPath = path.join(rootDir, "package.json");
6026
6243
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
6027
6244
  const pnpmBlock = pkg.pnpm ?? {};
@@ -6039,6 +6256,7 @@ const tryPnpmOverrides = async (rootDir, onProgress) => {
6039
6256
  cwd: rootDir,
6040
6257
  timeout: INSTALL_TIMEOUT
6041
6258
  });
6259
+ return true;
6042
6260
  };
6043
6261
  const fixExpoDependencies = async (context, onProgress) => {
6044
6262
  await removeDisallowedExpoPackages(context.rootDirectory, onProgress);
@@ -6064,6 +6282,10 @@ const fixExpoDependencies = async (context, onProgress) => {
6064
6282
  });
6065
6283
  if (checkResult.exitCode !== 0) throw new Error(checkResult.stderr || checkResult.stdout || "expo dependency check failed");
6066
6284
  };
6285
+ /**
6286
+ * Run expo-doctor to detect packages that should not be installed directly,
6287
+ * then uninstall them. No hardcoded list — expo-doctor is the source of truth.
6288
+ */
6067
6289
  const removeDisallowedExpoPackages = async (rootDir, onProgress) => {
6068
6290
  try {
6069
6291
  onProgress?.("Expo dependency alignment · running expo-doctor");