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/README.md +58 -5
- package/dist/cli.js +4337 -2982
- package/dist/index.d.ts +2 -0
- package/dist/index.js +516 -294
- package/dist/{json-D_i2_5_-.js → json-DIW4kCBS.js} +1 -1
- package/dist/{version-CIlgPf8Q.js → version-DukdnmKT.js} +1 -5
- package/package.json +7 -4
- package/scripts/postinstall-tools.mjs +2 -2
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as ENGINE_INFO, r as getEngineLabel, t as APP_VERSION } from "./version-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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
|
|
1210
|
-
|
|
1211
|
-
|
|
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
|
-
|
|
1370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4054
|
-
|
|
4055
|
-
i =
|
|
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
|
|
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 (
|
|
4965
|
-
|
|
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-
|
|
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
|
|
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,
|
|
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
|
|
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)
|
|
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");
|