aislop 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -1
- package/dist/cli.js +4189 -3838
- package/dist/index.d.ts +2 -0
- package/dist/index.js +429 -282
- package/dist/{json-DcE9soYJ.js → json-DIW4kCBS.js} +1 -1
- package/dist/{version-C2lM_2fE.js → version-DukdnmKT.js} +1 -5
- package/package.json +4 -1
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",
|
|
@@ -1797,6 +1719,23 @@ const looksLikeLicenseHeader = (block) => {
|
|
|
1797
1719
|
const text = block.rawLines.join(" ").toLowerCase();
|
|
1798
1720
|
return text.includes("copyright") || text.includes("license") || text.includes("spdx-license-identifier");
|
|
1799
1721
|
};
|
|
1722
|
+
const BARE_LABEL_RE = /^[A-Z][A-Za-z0-9 ]{1,28}$/;
|
|
1723
|
+
const isBareSectionLabel = (prose) => {
|
|
1724
|
+
if (!BARE_LABEL_RE.test(prose)) return false;
|
|
1725
|
+
if (prose.endsWith(".")) return false;
|
|
1726
|
+
if (prose.split(/\s+/).length > 3) return false;
|
|
1727
|
+
return true;
|
|
1728
|
+
};
|
|
1729
|
+
const DATA_ENTRY_START = /^\s*(?:\{|\[|["'`]|\d|\w+:\s|case\s)/;
|
|
1730
|
+
const nextLineLooksLikeDataEntry = (nextLine) => {
|
|
1731
|
+
if (nextLine === null) return false;
|
|
1732
|
+
if (!DATA_ENTRY_START.test(nextLine)) return false;
|
|
1733
|
+
const trimmed = nextLine.trim();
|
|
1734
|
+
if (trimmed.startsWith("case ")) return true;
|
|
1735
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[") || trimmed.startsWith("\"") || trimmed.startsWith("'") || trimmed.startsWith("`")) return true;
|
|
1736
|
+
if (/^\w+\s*:/.test(trimmed)) return true;
|
|
1737
|
+
return false;
|
|
1738
|
+
};
|
|
1800
1739
|
const looksLikeSuppressDirective = (block) => block.rawLines.some((l) => /\b(biome-ignore|eslint-disable|ts-ignore|ts-expect-error|@ts-\w+|noqa|pylint:\s*disable|rubocop:disable|noinspection|phpcs:disable)\b/.test(l));
|
|
1801
1740
|
const detectNarrativeInBlock = (block, ext) => {
|
|
1802
1741
|
if (looksLikeLicenseHeader(block)) return {
|
|
@@ -1819,6 +1758,10 @@ const detectNarrativeInBlock = (block, ext) => {
|
|
|
1819
1758
|
matched: true,
|
|
1820
1759
|
reason: "phase/section header"
|
|
1821
1760
|
};
|
|
1761
|
+
if (block.kind === "line" && block.prose.length === 1 && isBareSectionLabel(block.prose[0]) && !nextLineLooksLikeDataEntry(block.nextNonBlankLine)) return {
|
|
1762
|
+
matched: true,
|
|
1763
|
+
reason: "bare section label"
|
|
1764
|
+
};
|
|
1822
1765
|
if (block.prose.length >= 3 && looksLikeDeclarationPreamble(block.nextNonBlankLine, ext)) return {
|
|
1823
1766
|
matched: true,
|
|
1824
1767
|
reason: block.kind === "jsdoc" ? "JSDoc preamble before declaration" : "multi-line preamble before declaration"
|
|
@@ -1838,10 +1781,17 @@ const detectNarrativeInBlock = (block, ext) => {
|
|
|
1838
1781
|
matched: true,
|
|
1839
1782
|
reason: "explanatory preamble"
|
|
1840
1783
|
};
|
|
1841
|
-
|
|
1784
|
+
const nonEmptyProseCount = block.prose.filter((l) => l.length > 0).length;
|
|
1785
|
+
const joinedProse = block.prose.join(" ");
|
|
1786
|
+
const hasWhyMarker = EXPLANATORY_WHY_MARKERS.test(joinedProse);
|
|
1787
|
+
if (nonEmptyProseCount >= 5) return {
|
|
1842
1788
|
matched: true,
|
|
1843
1789
|
reason: "long narrative block"
|
|
1844
1790
|
};
|
|
1791
|
+
if (nonEmptyProseCount >= 3 && !hasWhyMarker && block.kind === "line") return {
|
|
1792
|
+
matched: true,
|
|
1793
|
+
reason: "multi-line narrative prose"
|
|
1794
|
+
};
|
|
1845
1795
|
return {
|
|
1846
1796
|
matched: false,
|
|
1847
1797
|
reason: ""
|
|
@@ -2601,6 +2551,130 @@ const checkComplexity = async (context) => {
|
|
|
2601
2551
|
return diagnostics;
|
|
2602
2552
|
};
|
|
2603
2553
|
|
|
2554
|
+
//#endregion
|
|
2555
|
+
//#region src/engines/code-quality/duplicate-block.ts
|
|
2556
|
+
const WINDOW_SIZE = 10;
|
|
2557
|
+
const MIN_DISTINCT_LINES = 7;
|
|
2558
|
+
const SOURCE_EXTS$1 = new Set([
|
|
2559
|
+
".ts",
|
|
2560
|
+
".tsx",
|
|
2561
|
+
".js",
|
|
2562
|
+
".jsx",
|
|
2563
|
+
".mjs",
|
|
2564
|
+
".cjs"
|
|
2565
|
+
]);
|
|
2566
|
+
const MEANINGFUL_LINE = /\S/;
|
|
2567
|
+
const normaliseLine = (line) => line.replace(/"[^"]*"|'[^']*'|`[^`]*`/g, "\"L\"").replace(/\b\d+(?:\.\d+)?\b/g, "0").replace(/\s+/g, " ").trim();
|
|
2568
|
+
const isTrivialLine = (line) => {
|
|
2569
|
+
const trimmed = line.trim();
|
|
2570
|
+
if (trimmed.length === 0) return true;
|
|
2571
|
+
if (trimmed === "{" || trimmed === "}" || trimmed === "});" || trimmed === "},") return true;
|
|
2572
|
+
if (trimmed.startsWith("//")) return true;
|
|
2573
|
+
if (trimmed.startsWith("/*") || trimmed.startsWith("*")) return true;
|
|
2574
|
+
return false;
|
|
2575
|
+
};
|
|
2576
|
+
const SUPPRESS_RE = /aislop[- ]ignore(?:-next-block|-file)?\b.*\b(?:duplicate-block|code-quality\/duplicate-block)\b/;
|
|
2577
|
+
const FILE_SUPPRESS_RE = /aislop[- ]ignore-file\b.*\b(?:duplicate-block|code-quality\/duplicate-block)\b/;
|
|
2578
|
+
const fileHasSuppression = (content) => FILE_SUPPRESS_RE.test(content);
|
|
2579
|
+
const findSuppressedLines = (lines) => {
|
|
2580
|
+
const suppressed = /* @__PURE__ */ new Set();
|
|
2581
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2582
|
+
if (!SUPPRESS_RE.test(lines[i])) continue;
|
|
2583
|
+
let depth = 0;
|
|
2584
|
+
let started = false;
|
|
2585
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
2586
|
+
suppressed.add(j + 1);
|
|
2587
|
+
const opens = (lines[j].match(/\{/g) ?? []).length;
|
|
2588
|
+
const closes = (lines[j].match(/\}/g) ?? []).length;
|
|
2589
|
+
depth += opens - closes;
|
|
2590
|
+
if (opens > 0) started = true;
|
|
2591
|
+
if (started && depth <= 0) break;
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
return suppressed;
|
|
2595
|
+
};
|
|
2596
|
+
const collectMeaningfulLines = (content) => {
|
|
2597
|
+
if (fileHasSuppression(content)) return [];
|
|
2598
|
+
const lines = content.split("\n");
|
|
2599
|
+
const suppressed = findSuppressedLines(lines);
|
|
2600
|
+
const hits = [];
|
|
2601
|
+
for (let i = 0; i < lines.length - WINDOW_SIZE + 1; i++) {
|
|
2602
|
+
if (suppressed.has(i + 1)) continue;
|
|
2603
|
+
const window = lines.slice(i, i + WINDOW_SIZE);
|
|
2604
|
+
if (window.some((l) => !MEANINGFUL_LINE.test(l))) continue;
|
|
2605
|
+
if (window.every(isTrivialLine)) continue;
|
|
2606
|
+
const normalised = window.map(normaliseLine);
|
|
2607
|
+
if (normalised.filter((n) => n.length > 0 && n !== "}" && n !== "{").length < WINDOW_SIZE - 1) continue;
|
|
2608
|
+
if (new Set(normalised).size < MIN_DISTINCT_LINES) continue;
|
|
2609
|
+
hits.push({
|
|
2610
|
+
startLine: i + 1,
|
|
2611
|
+
normalised
|
|
2612
|
+
});
|
|
2613
|
+
}
|
|
2614
|
+
return hits;
|
|
2615
|
+
};
|
|
2616
|
+
const findDuplicateBlocks = (content, relativePath) => {
|
|
2617
|
+
const blocks = collectMeaningfulLines(content);
|
|
2618
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2619
|
+
const reports = [];
|
|
2620
|
+
const reportedCurrent = /* @__PURE__ */ new Set();
|
|
2621
|
+
for (const block of blocks) {
|
|
2622
|
+
const key = block.normalised.join("\n");
|
|
2623
|
+
const prior = seen.get(key);
|
|
2624
|
+
if (prior === void 0) {
|
|
2625
|
+
seen.set(key, block.startLine);
|
|
2626
|
+
continue;
|
|
2627
|
+
}
|
|
2628
|
+
if (block.startLine - prior < WINDOW_SIZE) continue;
|
|
2629
|
+
if (reportedCurrent.has(prior)) continue;
|
|
2630
|
+
const last = reports[reports.length - 1];
|
|
2631
|
+
if (last && block.startLine - last.currentStart < WINDOW_SIZE && prior - last.priorStart < WINDOW_SIZE) {
|
|
2632
|
+
last.priorEnd = Math.max(last.priorEnd, prior + WINDOW_SIZE - 1);
|
|
2633
|
+
last.currentEnd = Math.max(last.currentEnd, block.startLine + WINDOW_SIZE - 1);
|
|
2634
|
+
continue;
|
|
2635
|
+
}
|
|
2636
|
+
reportedCurrent.add(prior);
|
|
2637
|
+
reports.push({
|
|
2638
|
+
priorStart: prior,
|
|
2639
|
+
priorEnd: prior + WINDOW_SIZE - 1,
|
|
2640
|
+
currentStart: block.startLine,
|
|
2641
|
+
currentEnd: block.startLine + WINDOW_SIZE - 1
|
|
2642
|
+
});
|
|
2643
|
+
}
|
|
2644
|
+
return reports.map((r) => {
|
|
2645
|
+
return {
|
|
2646
|
+
filePath: relativePath,
|
|
2647
|
+
engine: "code-quality",
|
|
2648
|
+
rule: "code-quality/duplicate-block",
|
|
2649
|
+
severity: "warning",
|
|
2650
|
+
message: `${r.currentEnd - r.currentStart + 1}-line block at line ${r.currentStart} duplicates a block starting at line ${r.priorStart}. Extract a shared helper.`,
|
|
2651
|
+
help: `Pull the shared logic into a function both sites can call. Keeps one version of the truth and makes future changes one-shot instead of N-shot.`,
|
|
2652
|
+
line: r.currentStart,
|
|
2653
|
+
column: 0,
|
|
2654
|
+
category: "Complexity",
|
|
2655
|
+
fixable: false
|
|
2656
|
+
};
|
|
2657
|
+
});
|
|
2658
|
+
};
|
|
2659
|
+
const detectDuplicateBlocks = async (context) => {
|
|
2660
|
+
const files = getSourceFiles(context);
|
|
2661
|
+
const diagnostics = [];
|
|
2662
|
+
for (const filePath of files) {
|
|
2663
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
2664
|
+
if (!SOURCE_EXTS$1.has(ext)) continue;
|
|
2665
|
+
if (isAutoGenerated(filePath)) continue;
|
|
2666
|
+
let content;
|
|
2667
|
+
try {
|
|
2668
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
2669
|
+
} catch {
|
|
2670
|
+
continue;
|
|
2671
|
+
}
|
|
2672
|
+
const relative = path.relative(context.rootDirectory, filePath);
|
|
2673
|
+
diagnostics.push(...findDuplicateBlocks(content, relative));
|
|
2674
|
+
}
|
|
2675
|
+
return diagnostics;
|
|
2676
|
+
};
|
|
2677
|
+
|
|
2604
2678
|
//#endregion
|
|
2605
2679
|
//#region src/engines/code-quality/knip.ts
|
|
2606
2680
|
const KNIP_MESSAGE_MAP = {
|
|
@@ -2775,6 +2849,78 @@ const runKnip = async (rootDirectory) => {
|
|
|
2775
2849
|
}
|
|
2776
2850
|
};
|
|
2777
2851
|
|
|
2852
|
+
//#endregion
|
|
2853
|
+
//#region src/engines/code-quality/repeated-chained-call.ts
|
|
2854
|
+
const METHOD_CALL_RE = /^\s*\.([A-Za-z_$][\w$]*)\s*\(/;
|
|
2855
|
+
const CHAIN_THRESHOLD = 5;
|
|
2856
|
+
const hasOnlyLiteralDifferences = (lines) => {
|
|
2857
|
+
const normalised = lines.map((l) => l.replace(/"[^"]*"|'[^']*'|`[^`]*`/g, "\"L\"").trim().replace(/;\s*$/, ""));
|
|
2858
|
+
return new Set(normalised).size === 1;
|
|
2859
|
+
};
|
|
2860
|
+
const findRepeatedChains = (content, relativePath) => {
|
|
2861
|
+
const diagnostics = [];
|
|
2862
|
+
const lines = content.split("\n");
|
|
2863
|
+
let i = 0;
|
|
2864
|
+
while (i < lines.length) {
|
|
2865
|
+
const match = lines[i].match(METHOD_CALL_RE);
|
|
2866
|
+
if (!match) {
|
|
2867
|
+
i += 1;
|
|
2868
|
+
continue;
|
|
2869
|
+
}
|
|
2870
|
+
const methodName = match[1];
|
|
2871
|
+
const runStart = i;
|
|
2872
|
+
let runEnd = i;
|
|
2873
|
+
while (runEnd + 1 < lines.length) {
|
|
2874
|
+
const next = lines[runEnd + 1].match(METHOD_CALL_RE);
|
|
2875
|
+
if (!next || next[1] !== methodName) break;
|
|
2876
|
+
runEnd += 1;
|
|
2877
|
+
}
|
|
2878
|
+
const runLength = runEnd - runStart + 1;
|
|
2879
|
+
if (runLength >= CHAIN_THRESHOLD && hasOnlyLiteralDifferences(lines.slice(runStart, runEnd + 1))) {
|
|
2880
|
+
diagnostics.push({
|
|
2881
|
+
filePath: relativePath,
|
|
2882
|
+
engine: "code-quality",
|
|
2883
|
+
rule: "code-quality/repeated-chained-call",
|
|
2884
|
+
severity: "warning",
|
|
2885
|
+
message: `${runLength} consecutive \`.${methodName}()\` calls that differ only in string literals. Extract a data table + loop.`,
|
|
2886
|
+
help: `Move the per-call args into an array and call \`.${methodName}()\` in a \`for\` loop. Keeps the registration in one place and lets you document the table once.`,
|
|
2887
|
+
line: runStart + 1,
|
|
2888
|
+
column: 0,
|
|
2889
|
+
category: "Complexity",
|
|
2890
|
+
fixable: false
|
|
2891
|
+
});
|
|
2892
|
+
i = runEnd + 1;
|
|
2893
|
+
} else i += 1;
|
|
2894
|
+
}
|
|
2895
|
+
return diagnostics;
|
|
2896
|
+
};
|
|
2897
|
+
const SOURCE_EXTS = new Set([
|
|
2898
|
+
".ts",
|
|
2899
|
+
".tsx",
|
|
2900
|
+
".js",
|
|
2901
|
+
".jsx",
|
|
2902
|
+
".mjs",
|
|
2903
|
+
".cjs"
|
|
2904
|
+
]);
|
|
2905
|
+
const detectRepeatedChainedCalls = async (context) => {
|
|
2906
|
+
const files = getSourceFiles(context);
|
|
2907
|
+
const diagnostics = [];
|
|
2908
|
+
for (const filePath of files) {
|
|
2909
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
2910
|
+
if (!SOURCE_EXTS.has(ext)) continue;
|
|
2911
|
+
if (isAutoGenerated(filePath)) continue;
|
|
2912
|
+
let content;
|
|
2913
|
+
try {
|
|
2914
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
2915
|
+
} catch {
|
|
2916
|
+
continue;
|
|
2917
|
+
}
|
|
2918
|
+
const relative = path.relative(context.rootDirectory, filePath);
|
|
2919
|
+
diagnostics.push(...findRepeatedChains(content, relative));
|
|
2920
|
+
}
|
|
2921
|
+
return diagnostics;
|
|
2922
|
+
};
|
|
2923
|
+
|
|
2778
2924
|
//#endregion
|
|
2779
2925
|
//#region src/engines/code-quality/index.ts
|
|
2780
2926
|
const codeQualityEngine = {
|
|
@@ -2784,6 +2930,8 @@ const codeQualityEngine = {
|
|
|
2784
2930
|
const promises = [];
|
|
2785
2931
|
if (context.languages.includes("typescript") || context.languages.includes("javascript")) promises.push(runKnip(context.rootDirectory));
|
|
2786
2932
|
promises.push(checkComplexity(context));
|
|
2933
|
+
promises.push(detectRepeatedChainedCalls(context));
|
|
2934
|
+
promises.push(detectDuplicateBlocks(context));
|
|
2787
2935
|
const results = await Promise.allSettled(promises);
|
|
2788
2936
|
for (const result of results) if (result.status === "fulfilled") diagnostics.push(...result.value);
|
|
2789
2937
|
return {
|
|
@@ -4032,6 +4180,54 @@ const maskStringsAndComments = (content, ext) => {
|
|
|
4032
4180
|
if (family === "js") return maskJs(content);
|
|
4033
4181
|
return maskSimple(content, family);
|
|
4034
4182
|
};
|
|
4183
|
+
const handleQuotesAndComments = (content, i, tplStack, mask) => {
|
|
4184
|
+
const len = content.length;
|
|
4185
|
+
const c = content[i];
|
|
4186
|
+
const next = content[i + 1];
|
|
4187
|
+
if (c === "\"" || c === "'") {
|
|
4188
|
+
const strStart = i;
|
|
4189
|
+
const end = consumeQuotedString(content, i, c);
|
|
4190
|
+
mask(strStart + 1, end - 1);
|
|
4191
|
+
return {
|
|
4192
|
+
handled: true,
|
|
4193
|
+
nextI: end
|
|
4194
|
+
};
|
|
4195
|
+
}
|
|
4196
|
+
if (c === "`") {
|
|
4197
|
+
const scan = consumeTemplateString(content, i + 1);
|
|
4198
|
+
mask(i + 1, scan.maskEnd);
|
|
4199
|
+
if (scan.openedInterp) tplStack.push(0);
|
|
4200
|
+
return {
|
|
4201
|
+
handled: true,
|
|
4202
|
+
nextI: scan.resumeAt
|
|
4203
|
+
};
|
|
4204
|
+
}
|
|
4205
|
+
if (c === "/" && next === "/") {
|
|
4206
|
+
const strStart = i;
|
|
4207
|
+
let k = i;
|
|
4208
|
+
while (k < len && content[k] !== "\n") k++;
|
|
4209
|
+
mask(strStart, k);
|
|
4210
|
+
return {
|
|
4211
|
+
handled: true,
|
|
4212
|
+
nextI: k
|
|
4213
|
+
};
|
|
4214
|
+
}
|
|
4215
|
+
if (c === "/" && next === "*") {
|
|
4216
|
+
const strStart = i;
|
|
4217
|
+
let k = i + 2;
|
|
4218
|
+
while (k < len - 1 && !(content[k] === "*" && content[k + 1] === "/")) k++;
|
|
4219
|
+
if (k < len - 1) k += 2;
|
|
4220
|
+
mask(strStart, k);
|
|
4221
|
+
return {
|
|
4222
|
+
handled: true,
|
|
4223
|
+
nextI: k
|
|
4224
|
+
};
|
|
4225
|
+
}
|
|
4226
|
+
return {
|
|
4227
|
+
handled: false,
|
|
4228
|
+
nextI: i
|
|
4229
|
+
};
|
|
4230
|
+
};
|
|
4035
4231
|
const maskJs = (content) => {
|
|
4036
4232
|
const out = content.split("");
|
|
4037
4233
|
const len = content.length;
|
|
@@ -4042,7 +4238,6 @@ const maskJs = (content) => {
|
|
|
4042
4238
|
};
|
|
4043
4239
|
while (i < len) {
|
|
4044
4240
|
const c = content[i];
|
|
4045
|
-
const next = content[i + 1];
|
|
4046
4241
|
if (tplStack.length > 0) {
|
|
4047
4242
|
if (c === "{") {
|
|
4048
4243
|
tplStack[tplStack.length - 1]++;
|
|
@@ -4062,61 +4257,10 @@ const maskJs = (content) => {
|
|
|
4062
4257
|
i++;
|
|
4063
4258
|
continue;
|
|
4064
4259
|
}
|
|
4065
|
-
if (c === "\"" || c === "'") {
|
|
4066
|
-
const strStart = i;
|
|
4067
|
-
i = consumeQuotedString(content, i, c);
|
|
4068
|
-
mask(strStart + 1, i - 1);
|
|
4069
|
-
continue;
|
|
4070
|
-
}
|
|
4071
|
-
if (c === "`") {
|
|
4072
|
-
const scan = consumeTemplateString(content, i + 1);
|
|
4073
|
-
mask(i + 1, scan.maskEnd);
|
|
4074
|
-
if (scan.openedInterp) tplStack.push(0);
|
|
4075
|
-
i = scan.resumeAt;
|
|
4076
|
-
continue;
|
|
4077
|
-
}
|
|
4078
|
-
if (c === "/" && next === "/") {
|
|
4079
|
-
const strStart = i;
|
|
4080
|
-
while (i < len && content[i] !== "\n") i++;
|
|
4081
|
-
mask(strStart, i);
|
|
4082
|
-
continue;
|
|
4083
|
-
}
|
|
4084
|
-
if (c === "/" && next === "*") {
|
|
4085
|
-
const strStart = i;
|
|
4086
|
-
i += 2;
|
|
4087
|
-
while (i < len - 1 && !(content[i] === "*" && content[i + 1] === "/")) i++;
|
|
4088
|
-
if (i < len - 1) i += 2;
|
|
4089
|
-
mask(strStart, i);
|
|
4090
|
-
continue;
|
|
4091
|
-
}
|
|
4092
|
-
i++;
|
|
4093
|
-
continue;
|
|
4094
|
-
}
|
|
4095
|
-
if (c === "\"" || c === "'") {
|
|
4096
|
-
const strStart = i;
|
|
4097
|
-
i = consumeQuotedString(content, i, c);
|
|
4098
|
-
mask(strStart + 1, i - 1);
|
|
4099
|
-
continue;
|
|
4100
|
-
}
|
|
4101
|
-
if (c === "`") {
|
|
4102
|
-
const scan = consumeTemplateString(content, i + 1);
|
|
4103
|
-
mask(i + 1, scan.maskEnd);
|
|
4104
|
-
if (scan.openedInterp) tplStack.push(0);
|
|
4105
|
-
i = scan.resumeAt;
|
|
4106
|
-
continue;
|
|
4107
|
-
}
|
|
4108
|
-
if (c === "/" && next === "/") {
|
|
4109
|
-
const strStart = i;
|
|
4110
|
-
while (i < len && content[i] !== "\n") i++;
|
|
4111
|
-
mask(strStart, i);
|
|
4112
|
-
continue;
|
|
4113
4260
|
}
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
i
|
|
4117
|
-
while (i < len - 1 && !(content[i] === "*" && content[i + 1] === "/")) i++;
|
|
4118
|
-
if (i < len - 1) i += 2;
|
|
4119
|
-
mask(strStart, i);
|
|
4261
|
+
const handled = handleQuotesAndComments(content, i, tplStack, mask);
|
|
4262
|
+
if (handled.handled) {
|
|
4263
|
+
i = handled.nextI;
|
|
4120
4264
|
continue;
|
|
4121
4265
|
}
|
|
4122
4266
|
i++;
|
|
@@ -5111,11 +5255,14 @@ const scanCommand = async (directory, config, options) => {
|
|
|
5111
5255
|
const projectInfo = await discoverProject(resolvedDir);
|
|
5112
5256
|
let files;
|
|
5113
5257
|
if (options.staged) {
|
|
5114
|
-
files = filterProjectFiles(resolvedDir, getStagedFiles(resolvedDir));
|
|
5258
|
+
files = filterProjectFiles(resolvedDir, getStagedFiles(resolvedDir), [], config.exclude);
|
|
5115
5259
|
if (!options.json) log.muted(`Scope: ${files.length} staged file(s)`);
|
|
5116
5260
|
} else if (options.changes) {
|
|
5117
|
-
files = filterProjectFiles(resolvedDir, getChangedFiles(resolvedDir));
|
|
5261
|
+
files = filterProjectFiles(resolvedDir, getChangedFiles(resolvedDir), [], config.exclude);
|
|
5118
5262
|
if (!options.json) log.muted(`Scope: ${files.length} changed file(s)`);
|
|
5263
|
+
} else {
|
|
5264
|
+
files = filterProjectFiles(resolvedDir, listProjectFiles(resolvedDir), [], config.exclude);
|
|
5265
|
+
if (!options.json) log.muted(`Scope: ${files.length} file(s) after exclusions`);
|
|
5119
5266
|
}
|
|
5120
5267
|
const configDir = findConfigDir(resolvedDir);
|
|
5121
5268
|
const rulesPath = configDir ? path.join(configDir, RULES_FILE) : void 0;
|
|
@@ -5189,7 +5336,7 @@ const scanCommand = async (directory, config, options) => {
|
|
|
5189
5336
|
});
|
|
5190
5337
|
}
|
|
5191
5338
|
if (options.json) {
|
|
5192
|
-
const { buildJsonOutput } = await import("./json-
|
|
5339
|
+
const { buildJsonOutput } = await import("./json-DIW4kCBS.js");
|
|
5193
5340
|
const jsonOut = buildJsonOutput(results, scoreResult, projectInfo.sourceFileCount, elapsedMs);
|
|
5194
5341
|
console.log(JSON.stringify(jsonOut, null, 2));
|
|
5195
5342
|
return { exitCode };
|