@yawlabs/ctxlint 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -17
- package/dist/index.js +730 -97
- package/dist/mcp/server.js +662 -63
- package/package.json +10 -2
package/dist/mcp/server.js
CHANGED
|
@@ -18,6 +18,14 @@ import { glob } from "glob";
|
|
|
18
18
|
// src/utils/fs.ts
|
|
19
19
|
import * as fs from "fs";
|
|
20
20
|
import * as path from "path";
|
|
21
|
+
function loadPackageJson(projectRoot) {
|
|
22
|
+
try {
|
|
23
|
+
const content = fs.readFileSync(path.join(projectRoot, "package.json"), "utf-8");
|
|
24
|
+
return JSON.parse(content);
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
21
29
|
function fileExists(filePath) {
|
|
22
30
|
try {
|
|
23
31
|
fs.accessSync(filePath);
|
|
@@ -85,37 +93,76 @@ function getAllProjectFiles(projectRoot) {
|
|
|
85
93
|
|
|
86
94
|
// src/core/scanner.ts
|
|
87
95
|
var CONTEXT_FILE_PATTERNS = [
|
|
96
|
+
// Claude Code
|
|
88
97
|
"CLAUDE.md",
|
|
89
98
|
"CLAUDE.local.md",
|
|
99
|
+
".claude/rules/*.md",
|
|
100
|
+
// AGENTS.md (AAIF / Linux Foundation standard)
|
|
90
101
|
"AGENTS.md",
|
|
102
|
+
"AGENT.md",
|
|
103
|
+
"AGENTS.override.md",
|
|
104
|
+
// Cursor
|
|
91
105
|
".cursorrules",
|
|
92
106
|
".cursor/rules/*.md",
|
|
93
107
|
".cursor/rules/*.mdc",
|
|
94
|
-
"
|
|
108
|
+
".cursor/rules/*/RULE.md",
|
|
109
|
+
// GitHub Copilot
|
|
95
110
|
".github/copilot-instructions.md",
|
|
111
|
+
".github/instructions/*.md",
|
|
112
|
+
".github/git-commit-instructions.md",
|
|
113
|
+
// Windsurf
|
|
96
114
|
".windsurfrules",
|
|
97
115
|
".windsurf/rules/*.md",
|
|
116
|
+
// Gemini CLI
|
|
98
117
|
"GEMINI.md",
|
|
99
|
-
|
|
118
|
+
// Cline
|
|
100
119
|
".clinerules",
|
|
101
|
-
|
|
120
|
+
// Aider — note: .aiderules has no file extension; this is the intended format
|
|
121
|
+
".aiderules",
|
|
122
|
+
// Aide / Codestory
|
|
123
|
+
".aide/rules/*.md",
|
|
124
|
+
// Amazon Q Developer
|
|
125
|
+
".amazonq/rules/*.md",
|
|
126
|
+
// Goose (Block)
|
|
127
|
+
".goose/instructions.md",
|
|
128
|
+
".goosehints",
|
|
129
|
+
// JetBrains Junie
|
|
130
|
+
".junie/guidelines.md",
|
|
131
|
+
".junie/AGENTS.md",
|
|
132
|
+
// JetBrains AI Assistant
|
|
133
|
+
".aiassistant/rules/*.md",
|
|
134
|
+
// Continue
|
|
135
|
+
".continuerules",
|
|
136
|
+
".continue/rules/*.md",
|
|
137
|
+
// Zed
|
|
138
|
+
".rules",
|
|
139
|
+
// Replit
|
|
140
|
+
"replit.md"
|
|
102
141
|
];
|
|
103
142
|
var IGNORED_DIRS2 = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "vendor"]);
|
|
104
|
-
async function scanForContextFiles(projectRoot) {
|
|
143
|
+
async function scanForContextFiles(projectRoot, options = {}) {
|
|
144
|
+
const maxDepth = options.depth ?? 2;
|
|
145
|
+
const patterns = [...CONTEXT_FILE_PATTERNS, ...options.extraPatterns || []];
|
|
105
146
|
const found = [];
|
|
106
147
|
const seen = /* @__PURE__ */ new Set();
|
|
107
148
|
const dirsToScan = [projectRoot];
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
149
|
+
function collectDirs(dir, currentDepth) {
|
|
150
|
+
if (currentDepth >= maxDepth) return;
|
|
151
|
+
try {
|
|
152
|
+
const entries = fs2.readdirSync(dir, { withFileTypes: true });
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
if (entry.isDirectory() && !IGNORED_DIRS2.has(entry.name) && !entry.name.startsWith(".")) {
|
|
155
|
+
const fullPath = path2.join(dir, entry.name);
|
|
156
|
+
dirsToScan.push(fullPath);
|
|
157
|
+
collectDirs(fullPath, currentDepth + 1);
|
|
158
|
+
}
|
|
113
159
|
}
|
|
160
|
+
} catch {
|
|
114
161
|
}
|
|
115
|
-
} catch {
|
|
116
162
|
}
|
|
163
|
+
collectDirs(projectRoot, 0);
|
|
117
164
|
for (const dir of dirsToScan) {
|
|
118
|
-
for (const pattern of
|
|
165
|
+
for (const pattern of patterns) {
|
|
119
166
|
const matches = await glob(pattern, {
|
|
120
167
|
cwd: dir,
|
|
121
168
|
absolute: true,
|
|
@@ -551,8 +598,9 @@ function findClosestMatch(target, files) {
|
|
|
551
598
|
// src/core/checks/commands.ts
|
|
552
599
|
import * as fs3 from "fs";
|
|
553
600
|
import * as path4 from "path";
|
|
554
|
-
var NPM_SCRIPT_PATTERN = /^(?:npm\s+run|pnpm(?:\s+run)?|yarn(?:\s+run)?)\s+(\S+)/;
|
|
601
|
+
var NPM_SCRIPT_PATTERN = /^(?:npm\s+run|pnpm(?:\s+run)?|yarn(?:\s+run)?|bun(?:\s+run)?)\s+(\S+)/;
|
|
555
602
|
var MAKE_PATTERN = /^make\s+(\S+)/;
|
|
603
|
+
var NPX_PATTERN = /^npx\s+(\S+)/;
|
|
556
604
|
async function checkCommands(file, projectRoot) {
|
|
557
605
|
const issues = [];
|
|
558
606
|
const pkgJson = loadPackageJson(projectRoot);
|
|
@@ -574,7 +622,9 @@ async function checkCommands(file, projectRoot) {
|
|
|
574
622
|
}
|
|
575
623
|
continue;
|
|
576
624
|
}
|
|
577
|
-
const shorthandMatch = cmd.match(
|
|
625
|
+
const shorthandMatch = cmd.match(
|
|
626
|
+
/^(npm|pnpm|yarn|bun)\s+(test|start|build|dev|lint|format|check|typecheck|clean|serve|preview|e2e)\b/
|
|
627
|
+
);
|
|
578
628
|
if (shorthandMatch && pkgJson) {
|
|
579
629
|
const scriptName = shorthandMatch[2];
|
|
580
630
|
if (pkgJson.scripts && !(scriptName in pkgJson.scripts)) {
|
|
@@ -587,6 +637,30 @@ async function checkCommands(file, projectRoot) {
|
|
|
587
637
|
}
|
|
588
638
|
continue;
|
|
589
639
|
}
|
|
640
|
+
const npxMatch = cmd.match(NPX_PATTERN);
|
|
641
|
+
if (npxMatch && pkgJson) {
|
|
642
|
+
const pkgName = npxMatch[1];
|
|
643
|
+
if (pkgName.startsWith("-")) continue;
|
|
644
|
+
const allDeps = {
|
|
645
|
+
...pkgJson.dependencies,
|
|
646
|
+
...pkgJson.devDependencies
|
|
647
|
+
};
|
|
648
|
+
if (!(pkgName in allDeps)) {
|
|
649
|
+
const binPath = path4.join(projectRoot, "node_modules", ".bin", pkgName);
|
|
650
|
+
try {
|
|
651
|
+
fs3.accessSync(binPath);
|
|
652
|
+
} catch {
|
|
653
|
+
issues.push({
|
|
654
|
+
severity: "warning",
|
|
655
|
+
check: "commands",
|
|
656
|
+
line: ref.line,
|
|
657
|
+
message: `"${cmd}" \u2014 "${pkgName}" not found in dependencies`,
|
|
658
|
+
suggestion: "If this is a global tool, consider adding it to devDependencies for reproducibility"
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
590
664
|
const makeMatch = cmd.match(MAKE_PATTERN);
|
|
591
665
|
if (makeMatch) {
|
|
592
666
|
const target = makeMatch[1];
|
|
@@ -631,14 +705,6 @@ async function checkCommands(file, projectRoot) {
|
|
|
631
705
|
}
|
|
632
706
|
return issues;
|
|
633
707
|
}
|
|
634
|
-
function loadPackageJson(projectRoot) {
|
|
635
|
-
try {
|
|
636
|
-
const content = fs3.readFileSync(path4.join(projectRoot, "package.json"), "utf-8");
|
|
637
|
-
return JSON.parse(content);
|
|
638
|
-
} catch {
|
|
639
|
-
return null;
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
708
|
function loadMakefile(projectRoot) {
|
|
643
709
|
try {
|
|
644
710
|
return fs3.readFileSync(path4.join(projectRoot, "Makefile"), "utf-8");
|
|
@@ -755,7 +821,6 @@ function checkAggregateTokens(files) {
|
|
|
755
821
|
}
|
|
756
822
|
|
|
757
823
|
// src/core/checks/redundancy.ts
|
|
758
|
-
import * as fs4 from "fs";
|
|
759
824
|
import * as path6 from "path";
|
|
760
825
|
var PACKAGE_TECH_MAP = {
|
|
761
826
|
react: ["React", "react"],
|
|
@@ -808,46 +873,56 @@ var PACKAGE_TECH_MAP = {
|
|
|
808
873
|
cypress: ["Cypress"],
|
|
809
874
|
puppeteer: ["Puppeteer"]
|
|
810
875
|
};
|
|
876
|
+
function compilePatterns(allDeps) {
|
|
877
|
+
const compiled = [];
|
|
878
|
+
for (const [pkg, mentions] of Object.entries(PACKAGE_TECH_MAP)) {
|
|
879
|
+
if (!allDeps.has(pkg)) continue;
|
|
880
|
+
for (const mention of mentions) {
|
|
881
|
+
const escaped = escapeRegex(mention);
|
|
882
|
+
compiled.push({
|
|
883
|
+
pkg,
|
|
884
|
+
mention,
|
|
885
|
+
patterns: [
|
|
886
|
+
new RegExp(`\\b(?:use|using|built with|powered by|written in)\\s+${escaped}\\b`, "i"),
|
|
887
|
+
new RegExp(`\\bwe\\s+use\\s+${escaped}\\b`, "i"),
|
|
888
|
+
new RegExp(`\\b${escaped}\\s+(?:project|app|application|codebase)\\b`, "i"),
|
|
889
|
+
new RegExp(`\\bThis is a\\s+${escaped}\\b`, "i")
|
|
890
|
+
]
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return compiled;
|
|
895
|
+
}
|
|
811
896
|
async function checkRedundancy(file, projectRoot) {
|
|
812
897
|
const issues = [];
|
|
813
|
-
const pkgJson =
|
|
898
|
+
const pkgJson = loadPackageJson(projectRoot);
|
|
814
899
|
if (pkgJson) {
|
|
815
900
|
const allDeps = /* @__PURE__ */ new Set([
|
|
816
901
|
...Object.keys(pkgJson.dependencies || {}),
|
|
817
902
|
...Object.keys(pkgJson.devDependencies || {})
|
|
818
903
|
]);
|
|
904
|
+
const compiledPatterns = compilePatterns(allDeps);
|
|
819
905
|
const lines2 = file.content.split("\n");
|
|
820
906
|
for (let i = 0; i < lines2.length; i++) {
|
|
821
907
|
const line = lines2[i];
|
|
822
|
-
for (const
|
|
823
|
-
|
|
824
|
-
for (const
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
"i"
|
|
829
|
-
),
|
|
830
|
-
new RegExp(`\\bwe\\s+use\\s+${escapeRegex(mention)}\\b`, "i"),
|
|
831
|
-
new RegExp(
|
|
832
|
-
`\\b${escapeRegex(mention)}\\s+(?:project|app|application|codebase)\\b`,
|
|
833
|
-
"i"
|
|
834
|
-
),
|
|
835
|
-
new RegExp(`\\bThis is a\\s+${escapeRegex(mention)}\\b`, "i")
|
|
836
|
-
];
|
|
837
|
-
for (const pattern of patterns) {
|
|
838
|
-
if (pattern.test(line)) {
|
|
839
|
-
const wastedTokens = countTokens(line.trim());
|
|
840
|
-
issues.push({
|
|
841
|
-
severity: "info",
|
|
842
|
-
check: "redundancy",
|
|
843
|
-
line: i + 1,
|
|
844
|
-
message: `"${mention}" is in package.json ${pkgJson.dependencies?.[pkg] ? "dependencies" : "devDependencies"} \u2014 agent can infer this`,
|
|
845
|
-
suggestion: `~${wastedTokens} tokens could be saved`
|
|
846
|
-
});
|
|
847
|
-
break;
|
|
848
|
-
}
|
|
908
|
+
for (const { pkg, mention, patterns } of compiledPatterns) {
|
|
909
|
+
let matched = false;
|
|
910
|
+
for (const pattern of patterns) {
|
|
911
|
+
if (pattern.test(line)) {
|
|
912
|
+
matched = true;
|
|
913
|
+
break;
|
|
849
914
|
}
|
|
850
915
|
}
|
|
916
|
+
if (matched) {
|
|
917
|
+
const wastedTokens = countTokens(line.trim());
|
|
918
|
+
issues.push({
|
|
919
|
+
severity: "info",
|
|
920
|
+
check: "redundancy",
|
|
921
|
+
line: i + 1,
|
|
922
|
+
message: `"${mention}" is in package.json ${pkgJson.dependencies?.[pkg] ? "dependencies" : "devDependencies"} \u2014 agent can infer this`,
|
|
923
|
+
suggestion: `~${wastedTokens} tokens could be saved`
|
|
924
|
+
});
|
|
925
|
+
}
|
|
851
926
|
}
|
|
852
927
|
}
|
|
853
928
|
}
|
|
@@ -905,24 +980,479 @@ function calculateLineOverlap(contentA, contentB) {
|
|
|
905
980
|
}
|
|
906
981
|
return overlap / Math.min(linesA.size, linesB.size);
|
|
907
982
|
}
|
|
908
|
-
function loadPackageJson2(projectRoot) {
|
|
909
|
-
try {
|
|
910
|
-
const content = fs4.readFileSync(path6.join(projectRoot, "package.json"), "utf-8");
|
|
911
|
-
return JSON.parse(content);
|
|
912
|
-
} catch {
|
|
913
|
-
return null;
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
983
|
function escapeRegex(str) {
|
|
917
984
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
918
985
|
}
|
|
919
986
|
|
|
987
|
+
// src/core/checks/contradictions.ts
|
|
988
|
+
var DIRECTIVE_CATEGORIES = [
|
|
989
|
+
{
|
|
990
|
+
name: "testing framework",
|
|
991
|
+
options: [
|
|
992
|
+
{
|
|
993
|
+
label: "Jest",
|
|
994
|
+
patterns: [/\buse\s+jest\b/i, /\bjest\s+for\s+test/i, /\btest.*with\s+jest\b/i]
|
|
995
|
+
},
|
|
996
|
+
{
|
|
997
|
+
label: "Vitest",
|
|
998
|
+
patterns: [/\buse\s+vitest\b/i, /\bvitest\s+for\s+test/i, /\btest.*with\s+vitest\b/i]
|
|
999
|
+
},
|
|
1000
|
+
{
|
|
1001
|
+
label: "Mocha",
|
|
1002
|
+
patterns: [/\buse\s+mocha\b/i, /\bmocha\s+for\s+test/i, /\btest.*with\s+mocha\b/i]
|
|
1003
|
+
},
|
|
1004
|
+
{
|
|
1005
|
+
label: "pytest",
|
|
1006
|
+
patterns: [/\buse\s+pytest\b/i, /\bpytest\s+for\s+test/i, /\btest.*with\s+pytest\b/i]
|
|
1007
|
+
},
|
|
1008
|
+
{
|
|
1009
|
+
label: "Playwright",
|
|
1010
|
+
patterns: [/\buse\s+playwright\b/i, /\bplaywright\s+for\s+(?:e2e|test)/i]
|
|
1011
|
+
},
|
|
1012
|
+
{ label: "Cypress", patterns: [/\buse\s+cypress\b/i, /\bcypress\s+for\s+(?:e2e|test)/i] }
|
|
1013
|
+
]
|
|
1014
|
+
},
|
|
1015
|
+
{
|
|
1016
|
+
name: "package manager",
|
|
1017
|
+
options: [
|
|
1018
|
+
{
|
|
1019
|
+
label: "npm",
|
|
1020
|
+
patterns: [
|
|
1021
|
+
/\buse\s+npm\b/i,
|
|
1022
|
+
/\bnpm\s+as\s+(?:the\s+)?package\s+manager/i,
|
|
1023
|
+
/\balways\s+use\s+npm\b/i
|
|
1024
|
+
]
|
|
1025
|
+
},
|
|
1026
|
+
{
|
|
1027
|
+
label: "pnpm",
|
|
1028
|
+
patterns: [
|
|
1029
|
+
/\buse\s+pnpm\b/i,
|
|
1030
|
+
/\bpnpm\s+as\s+(?:the\s+)?package\s+manager/i,
|
|
1031
|
+
/\balways\s+use\s+pnpm\b/i
|
|
1032
|
+
]
|
|
1033
|
+
},
|
|
1034
|
+
{
|
|
1035
|
+
label: "yarn",
|
|
1036
|
+
patterns: [
|
|
1037
|
+
/\buse\s+yarn\b/i,
|
|
1038
|
+
/\byarn\s+as\s+(?:the\s+)?package\s+manager/i,
|
|
1039
|
+
/\balways\s+use\s+yarn\b/i
|
|
1040
|
+
]
|
|
1041
|
+
},
|
|
1042
|
+
{
|
|
1043
|
+
label: "bun",
|
|
1044
|
+
patterns: [
|
|
1045
|
+
/\buse\s+bun\b/i,
|
|
1046
|
+
/\bbun\s+as\s+(?:the\s+)?package\s+manager/i,
|
|
1047
|
+
/\balways\s+use\s+bun\b/i
|
|
1048
|
+
]
|
|
1049
|
+
}
|
|
1050
|
+
]
|
|
1051
|
+
},
|
|
1052
|
+
{
|
|
1053
|
+
name: "indentation style",
|
|
1054
|
+
options: [
|
|
1055
|
+
{
|
|
1056
|
+
label: "tabs",
|
|
1057
|
+
patterns: [/\buse\s+tabs\b/i, /\btab\s+indentation\b/i, /\bindent\s+with\s+tabs\b/i]
|
|
1058
|
+
},
|
|
1059
|
+
{
|
|
1060
|
+
label: "2 spaces",
|
|
1061
|
+
patterns: [
|
|
1062
|
+
/\b2[\s-]?space\s+indent/i,
|
|
1063
|
+
/\bindent\s+with\s+2\s+spaces/i,
|
|
1064
|
+
/\b2[\s-]?space\s+tabs?\b/i
|
|
1065
|
+
]
|
|
1066
|
+
},
|
|
1067
|
+
{
|
|
1068
|
+
label: "4 spaces",
|
|
1069
|
+
patterns: [
|
|
1070
|
+
/\b4[\s-]?space\s+indent/i,
|
|
1071
|
+
/\bindent\s+with\s+4\s+spaces/i,
|
|
1072
|
+
/\b4[\s-]?space\s+tabs?\b/i
|
|
1073
|
+
]
|
|
1074
|
+
}
|
|
1075
|
+
]
|
|
1076
|
+
},
|
|
1077
|
+
{
|
|
1078
|
+
name: "semicolons",
|
|
1079
|
+
options: [
|
|
1080
|
+
{
|
|
1081
|
+
label: "semicolons",
|
|
1082
|
+
patterns: [
|
|
1083
|
+
/\buse\s+semicolons\b/i,
|
|
1084
|
+
/\balways\s+(?:use\s+)?semicolons\b/i,
|
|
1085
|
+
/\bsemicolons:\s*(?:true|yes)\b/i
|
|
1086
|
+
]
|
|
1087
|
+
},
|
|
1088
|
+
{
|
|
1089
|
+
label: "no semicolons",
|
|
1090
|
+
patterns: [
|
|
1091
|
+
/\bno\s+semicolons\b/i,
|
|
1092
|
+
/\bavoid\s+semicolons\b/i,
|
|
1093
|
+
/\bomit\s+semicolons\b/i,
|
|
1094
|
+
/\bsemicolons:\s*(?:false|no)\b/i
|
|
1095
|
+
]
|
|
1096
|
+
}
|
|
1097
|
+
]
|
|
1098
|
+
},
|
|
1099
|
+
{
|
|
1100
|
+
name: "quote style",
|
|
1101
|
+
options: [
|
|
1102
|
+
{
|
|
1103
|
+
label: "single quotes",
|
|
1104
|
+
patterns: [
|
|
1105
|
+
/\bsingle\s+quotes?\b/i,
|
|
1106
|
+
/\buse\s+(?:single\s+)?['']single['']?\s+quotes?\b/i,
|
|
1107
|
+
/\bprefer\s+single\s+quotes?\b/i
|
|
1108
|
+
]
|
|
1109
|
+
},
|
|
1110
|
+
{
|
|
1111
|
+
label: "double quotes",
|
|
1112
|
+
patterns: [
|
|
1113
|
+
/\bdouble\s+quotes?\b/i,
|
|
1114
|
+
/\buse\s+(?:double\s+)?[""]double[""]?\s+quotes?\b/i,
|
|
1115
|
+
/\bprefer\s+double\s+quotes?\b/i
|
|
1116
|
+
]
|
|
1117
|
+
}
|
|
1118
|
+
]
|
|
1119
|
+
},
|
|
1120
|
+
{
|
|
1121
|
+
name: "naming convention",
|
|
1122
|
+
options: [
|
|
1123
|
+
{
|
|
1124
|
+
label: "camelCase",
|
|
1125
|
+
patterns: [/\bcamelCase\b/, /\bcamel[\s-]?case\s+(?:for|naming|convention)/i]
|
|
1126
|
+
},
|
|
1127
|
+
{
|
|
1128
|
+
label: "snake_case",
|
|
1129
|
+
patterns: [/\bsnake_case\b/, /\bsnake[\s-]?case\s+(?:for|naming|convention)/i]
|
|
1130
|
+
},
|
|
1131
|
+
{
|
|
1132
|
+
label: "PascalCase",
|
|
1133
|
+
patterns: [/\bPascalCase\b/, /\bpascal[\s-]?case\s+(?:for|naming|convention)/i]
|
|
1134
|
+
},
|
|
1135
|
+
{
|
|
1136
|
+
label: "kebab-case",
|
|
1137
|
+
patterns: [/\bkebab-case\b/, /\bkebab[\s-]?case\s+(?:for|naming|convention)/i]
|
|
1138
|
+
}
|
|
1139
|
+
]
|
|
1140
|
+
},
|
|
1141
|
+
{
|
|
1142
|
+
name: "CSS approach",
|
|
1143
|
+
options: [
|
|
1144
|
+
{ label: "Tailwind", patterns: [/\buse\s+tailwind/i, /\btailwind\s+for\s+styl/i] },
|
|
1145
|
+
{
|
|
1146
|
+
label: "CSS Modules",
|
|
1147
|
+
patterns: [/\buse\s+css\s+modules\b/i, /\bcss\s+modules\s+for\s+styl/i]
|
|
1148
|
+
},
|
|
1149
|
+
{
|
|
1150
|
+
label: "styled-components",
|
|
1151
|
+
patterns: [/\buse\s+styled[\s-]?components\b/i, /\bstyled[\s-]?components\s+for\s+styl/i]
|
|
1152
|
+
},
|
|
1153
|
+
{ label: "CSS-in-JS", patterns: [/\buse\s+css[\s-]?in[\s-]?js\b/i] }
|
|
1154
|
+
]
|
|
1155
|
+
},
|
|
1156
|
+
{
|
|
1157
|
+
name: "state management",
|
|
1158
|
+
options: [
|
|
1159
|
+
{ label: "Redux", patterns: [/\buse\s+redux\b/i, /\bredux\s+for\s+state/i] },
|
|
1160
|
+
{ label: "Zustand", patterns: [/\buse\s+zustand\b/i, /\bzustand\s+for\s+state/i] },
|
|
1161
|
+
{ label: "MobX", patterns: [/\buse\s+mobx\b/i, /\bmobx\s+for\s+state/i] },
|
|
1162
|
+
{ label: "Jotai", patterns: [/\buse\s+jotai\b/i, /\bjotai\s+for\s+state/i] },
|
|
1163
|
+
{ label: "Recoil", patterns: [/\buse\s+recoil\b/i, /\brecoil\s+for\s+state/i] }
|
|
1164
|
+
]
|
|
1165
|
+
}
|
|
1166
|
+
];
|
|
1167
|
+
function detectDirectives(file) {
|
|
1168
|
+
const directives = [];
|
|
1169
|
+
const lines = file.content.split("\n");
|
|
1170
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1171
|
+
const line = lines[i];
|
|
1172
|
+
for (const category of DIRECTIVE_CATEGORIES) {
|
|
1173
|
+
for (const option of category.options) {
|
|
1174
|
+
for (const pattern of option.patterns) {
|
|
1175
|
+
if (pattern.test(line)) {
|
|
1176
|
+
directives.push({
|
|
1177
|
+
file: file.relativePath,
|
|
1178
|
+
category: category.name,
|
|
1179
|
+
label: option.label,
|
|
1180
|
+
line: i + 1,
|
|
1181
|
+
text: line.trim()
|
|
1182
|
+
});
|
|
1183
|
+
break;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
return directives;
|
|
1190
|
+
}
|
|
1191
|
+
function checkContradictions(files) {
|
|
1192
|
+
if (files.length < 2) return [];
|
|
1193
|
+
const issues = [];
|
|
1194
|
+
const allDirectives = [];
|
|
1195
|
+
for (const file of files) {
|
|
1196
|
+
allDirectives.push(...detectDirectives(file));
|
|
1197
|
+
}
|
|
1198
|
+
const byCategory = /* @__PURE__ */ new Map();
|
|
1199
|
+
for (const d of allDirectives) {
|
|
1200
|
+
const existing = byCategory.get(d.category) || [];
|
|
1201
|
+
existing.push(d);
|
|
1202
|
+
byCategory.set(d.category, existing);
|
|
1203
|
+
}
|
|
1204
|
+
for (const [category, directives] of byCategory) {
|
|
1205
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
1206
|
+
for (const d of directives) {
|
|
1207
|
+
const existing = byFile.get(d.file) || [];
|
|
1208
|
+
existing.push(d);
|
|
1209
|
+
byFile.set(d.file, existing);
|
|
1210
|
+
}
|
|
1211
|
+
const labels = new Set(directives.map((d) => d.label));
|
|
1212
|
+
if (labels.size <= 1) continue;
|
|
1213
|
+
const fileLabels = /* @__PURE__ */ new Map();
|
|
1214
|
+
for (const d of directives) {
|
|
1215
|
+
const existing = fileLabels.get(d.file) || /* @__PURE__ */ new Set();
|
|
1216
|
+
existing.add(d.label);
|
|
1217
|
+
fileLabels.set(d.file, existing);
|
|
1218
|
+
}
|
|
1219
|
+
const fileEntries = [...fileLabels.entries()];
|
|
1220
|
+
for (let i = 0; i < fileEntries.length; i++) {
|
|
1221
|
+
for (let j = i + 1; j < fileEntries.length; j++) {
|
|
1222
|
+
const [fileA, labelsA] = fileEntries[i];
|
|
1223
|
+
const [fileB, labelsB] = fileEntries[j];
|
|
1224
|
+
for (const labelA of labelsA) {
|
|
1225
|
+
for (const labelB of labelsB) {
|
|
1226
|
+
if (labelA !== labelB) {
|
|
1227
|
+
const directiveA = directives.find((d) => d.file === fileA && d.label === labelA);
|
|
1228
|
+
const directiveB = directives.find((d) => d.file === fileB && d.label === labelB);
|
|
1229
|
+
issues.push({
|
|
1230
|
+
severity: "warning",
|
|
1231
|
+
check: "contradictions",
|
|
1232
|
+
line: directiveA.line,
|
|
1233
|
+
message: `${category} conflict: "${directiveA.label}" in ${fileA} vs "${directiveB.label}" in ${fileB}`,
|
|
1234
|
+
suggestion: `Align on one ${category} across all context files`,
|
|
1235
|
+
detail: `${fileA}:${directiveA.line} says "${directiveA.text}" but ${fileB}:${directiveB.line} says "${directiveB.text}"`
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return issues;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// src/core/checks/frontmatter.ts
|
|
1247
|
+
function parseFrontmatter(content) {
|
|
1248
|
+
const lines = content.split("\n");
|
|
1249
|
+
if (lines[0]?.trim() !== "---") {
|
|
1250
|
+
return { found: false, fields: {}, endLine: 0 };
|
|
1251
|
+
}
|
|
1252
|
+
const fields = {};
|
|
1253
|
+
let endLine = 0;
|
|
1254
|
+
for (let i = 1; i < lines.length; i++) {
|
|
1255
|
+
const line = lines[i].trim();
|
|
1256
|
+
if (line === "---") {
|
|
1257
|
+
endLine = i + 1;
|
|
1258
|
+
break;
|
|
1259
|
+
}
|
|
1260
|
+
const match = line.match(/^(\w+)\s*:\s*(.*)$/);
|
|
1261
|
+
if (match) {
|
|
1262
|
+
fields[match[1]] = match[2].trim();
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
if (endLine === 0) {
|
|
1266
|
+
return { found: true, fields, endLine: lines.length };
|
|
1267
|
+
}
|
|
1268
|
+
return { found: true, fields, endLine };
|
|
1269
|
+
}
|
|
1270
|
+
function isCursorMdc(file) {
|
|
1271
|
+
return file.relativePath.endsWith(".mdc");
|
|
1272
|
+
}
|
|
1273
|
+
function isCopilotInstructions(file) {
|
|
1274
|
+
return file.relativePath.includes(".github/instructions/") && file.relativePath.endsWith(".md");
|
|
1275
|
+
}
|
|
1276
|
+
function isWindsurfRule(file) {
|
|
1277
|
+
return file.relativePath.includes(".windsurf/rules/") && file.relativePath.endsWith(".md");
|
|
1278
|
+
}
|
|
1279
|
+
var VALID_WINDSURF_TRIGGERS = ["always_on", "glob", "manual", "model"];
|
|
1280
|
+
async function checkFrontmatter(file, _projectRoot) {
|
|
1281
|
+
const issues = [];
|
|
1282
|
+
if (isCursorMdc(file)) {
|
|
1283
|
+
issues.push(...validateCursorMdc(file));
|
|
1284
|
+
} else if (isCopilotInstructions(file)) {
|
|
1285
|
+
issues.push(...validateCopilotInstructions(file));
|
|
1286
|
+
} else if (isWindsurfRule(file)) {
|
|
1287
|
+
issues.push(...validateWindsurfRule(file));
|
|
1288
|
+
}
|
|
1289
|
+
return issues;
|
|
1290
|
+
}
|
|
1291
|
+
function validateCursorMdc(file) {
|
|
1292
|
+
const issues = [];
|
|
1293
|
+
const fm = parseFrontmatter(file.content);
|
|
1294
|
+
if (!fm.found) {
|
|
1295
|
+
issues.push({
|
|
1296
|
+
severity: "warning",
|
|
1297
|
+
check: "frontmatter",
|
|
1298
|
+
line: 1,
|
|
1299
|
+
message: "Cursor .mdc file is missing frontmatter",
|
|
1300
|
+
suggestion: "Add YAML frontmatter with description, globs, and alwaysApply fields"
|
|
1301
|
+
});
|
|
1302
|
+
return issues;
|
|
1303
|
+
}
|
|
1304
|
+
if (!fm.fields["description"]) {
|
|
1305
|
+
issues.push({
|
|
1306
|
+
severity: "warning",
|
|
1307
|
+
check: "frontmatter",
|
|
1308
|
+
line: 1,
|
|
1309
|
+
message: 'Missing "description" field in Cursor .mdc frontmatter',
|
|
1310
|
+
suggestion: "Add a description so Cursor knows when to apply this rule"
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
if (!("alwaysApply" in fm.fields) && !("globs" in fm.fields)) {
|
|
1314
|
+
issues.push({
|
|
1315
|
+
severity: "info",
|
|
1316
|
+
check: "frontmatter",
|
|
1317
|
+
line: 1,
|
|
1318
|
+
message: 'No "alwaysApply" or "globs" field \u2014 rule may not be applied automatically',
|
|
1319
|
+
suggestion: "Set alwaysApply: true or specify globs for targeted activation"
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
if ("alwaysApply" in fm.fields) {
|
|
1323
|
+
const val = fm.fields["alwaysApply"].toLowerCase();
|
|
1324
|
+
if (!["true", "false"].includes(val)) {
|
|
1325
|
+
issues.push({
|
|
1326
|
+
severity: "error",
|
|
1327
|
+
check: "frontmatter",
|
|
1328
|
+
line: 1,
|
|
1329
|
+
message: `Invalid alwaysApply value: "${fm.fields["alwaysApply"]}"`,
|
|
1330
|
+
suggestion: "alwaysApply must be true or false"
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
if ("globs" in fm.fields) {
|
|
1335
|
+
const val = fm.fields["globs"];
|
|
1336
|
+
if (val && !val.startsWith("[") && !val.startsWith('"') && !val.includes("*") && !val.includes("/")) {
|
|
1337
|
+
issues.push({
|
|
1338
|
+
severity: "warning",
|
|
1339
|
+
check: "frontmatter",
|
|
1340
|
+
line: 1,
|
|
1341
|
+
message: `Possibly invalid globs value: "${val}"`,
|
|
1342
|
+
suggestion: 'globs should be a glob pattern like "src/**/*.ts" or an array like ["*.ts", "*.tsx"]'
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
return issues;
|
|
1347
|
+
}
|
|
1348
|
+
function validateCopilotInstructions(file) {
|
|
1349
|
+
const issues = [];
|
|
1350
|
+
const fm = parseFrontmatter(file.content);
|
|
1351
|
+
if (!fm.found) {
|
|
1352
|
+
issues.push({
|
|
1353
|
+
severity: "info",
|
|
1354
|
+
check: "frontmatter",
|
|
1355
|
+
line: 1,
|
|
1356
|
+
message: "Copilot instructions file has no frontmatter",
|
|
1357
|
+
suggestion: "Add applyTo frontmatter to target specific file patterns"
|
|
1358
|
+
});
|
|
1359
|
+
return issues;
|
|
1360
|
+
}
|
|
1361
|
+
if (!fm.fields["applyTo"]) {
|
|
1362
|
+
issues.push({
|
|
1363
|
+
severity: "warning",
|
|
1364
|
+
check: "frontmatter",
|
|
1365
|
+
line: 1,
|
|
1366
|
+
message: 'Missing "applyTo" field in Copilot instructions frontmatter',
|
|
1367
|
+
suggestion: 'Add applyTo to specify which files this instruction applies to (e.g., applyTo: "**/*.ts")'
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
return issues;
|
|
1371
|
+
}
|
|
1372
|
+
function validateWindsurfRule(file) {
|
|
1373
|
+
const issues = [];
|
|
1374
|
+
const fm = parseFrontmatter(file.content);
|
|
1375
|
+
if (!fm.found) {
|
|
1376
|
+
issues.push({
|
|
1377
|
+
severity: "info",
|
|
1378
|
+
check: "frontmatter",
|
|
1379
|
+
line: 1,
|
|
1380
|
+
message: "Windsurf rule file has no frontmatter",
|
|
1381
|
+
suggestion: "Add YAML frontmatter with a trigger field (always_on, glob, manual, model)"
|
|
1382
|
+
});
|
|
1383
|
+
return issues;
|
|
1384
|
+
}
|
|
1385
|
+
if (!fm.fields["trigger"]) {
|
|
1386
|
+
issues.push({
|
|
1387
|
+
severity: "warning",
|
|
1388
|
+
check: "frontmatter",
|
|
1389
|
+
line: 1,
|
|
1390
|
+
message: 'Missing "trigger" field in Windsurf rule frontmatter',
|
|
1391
|
+
suggestion: `Set trigger to one of: ${VALID_WINDSURF_TRIGGERS.join(", ")}`
|
|
1392
|
+
});
|
|
1393
|
+
} else {
|
|
1394
|
+
const trigger = fm.fields["trigger"].replace(/['"]/g, "");
|
|
1395
|
+
if (!VALID_WINDSURF_TRIGGERS.includes(trigger)) {
|
|
1396
|
+
issues.push({
|
|
1397
|
+
severity: "error",
|
|
1398
|
+
check: "frontmatter",
|
|
1399
|
+
line: 1,
|
|
1400
|
+
message: `Invalid trigger value: "${trigger}"`,
|
|
1401
|
+
suggestion: `Valid triggers: ${VALID_WINDSURF_TRIGGERS.join(", ")}`
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
return issues;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// src/core/fixer.ts
|
|
1409
|
+
import * as fs4 from "fs";
|
|
1410
|
+
import chalk from "chalk";
|
|
1411
|
+
function applyFixes(result) {
|
|
1412
|
+
const fixesByFile = /* @__PURE__ */ new Map();
|
|
1413
|
+
for (const file of result.files) {
|
|
1414
|
+
for (const issue of file.issues) {
|
|
1415
|
+
if (issue.fix) {
|
|
1416
|
+
const existing = fixesByFile.get(issue.fix.file) || [];
|
|
1417
|
+
existing.push(issue.fix);
|
|
1418
|
+
fixesByFile.set(issue.fix.file, existing);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
let totalFixes = 0;
|
|
1423
|
+
const filesModified = [];
|
|
1424
|
+
for (const [filePath, fixes] of fixesByFile) {
|
|
1425
|
+
const content = fs4.readFileSync(filePath, "utf-8");
|
|
1426
|
+
const lines = content.split("\n");
|
|
1427
|
+
let modified = false;
|
|
1428
|
+
const sortedFixes = [...fixes].sort((a, b) => b.line - a.line);
|
|
1429
|
+
for (const fix of sortedFixes) {
|
|
1430
|
+
const lineIdx = fix.line - 1;
|
|
1431
|
+
if (lineIdx < 0 || lineIdx >= lines.length) continue;
|
|
1432
|
+
const line = lines[lineIdx];
|
|
1433
|
+
if (line.includes(fix.oldText)) {
|
|
1434
|
+
lines[lineIdx] = line.replace(fix.oldText, fix.newText);
|
|
1435
|
+
modified = true;
|
|
1436
|
+
totalFixes++;
|
|
1437
|
+
console.log(
|
|
1438
|
+
chalk.green(" Fixed") + ` Line ${fix.line}: ${chalk.dim(fix.oldText)} ${chalk.dim("\u2192")} ${fix.newText}`
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
if (modified) {
|
|
1443
|
+
fs4.writeFileSync(filePath, lines.join("\n"), "utf-8");
|
|
1444
|
+
filesModified.push(filePath);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
return { totalFixes, filesModified };
|
|
1448
|
+
}
|
|
1449
|
+
|
|
920
1450
|
// src/mcp/server.ts
|
|
921
1451
|
import * as path7 from "path";
|
|
922
1452
|
|
|
923
1453
|
// src/version.ts
|
|
924
1454
|
function loadVersion() {
|
|
925
|
-
if (true) return "0.
|
|
1455
|
+
if (true) return "0.3.0";
|
|
926
1456
|
const fs5 = __require("fs");
|
|
927
1457
|
const path8 = __require("path");
|
|
928
1458
|
const pkgPath = path8.resolve(__dirname, "../package.json");
|
|
@@ -932,17 +1462,34 @@ function loadVersion() {
|
|
|
932
1462
|
var VERSION = loadVersion();
|
|
933
1463
|
|
|
934
1464
|
// src/mcp/server.ts
|
|
935
|
-
var ALL_CHECKS = [
|
|
1465
|
+
var ALL_CHECKS = [
|
|
1466
|
+
"paths",
|
|
1467
|
+
"commands",
|
|
1468
|
+
"staleness",
|
|
1469
|
+
"tokens",
|
|
1470
|
+
"redundancy",
|
|
1471
|
+
"contradictions",
|
|
1472
|
+
"frontmatter"
|
|
1473
|
+
];
|
|
1474
|
+
var checkEnum = z.enum([
|
|
1475
|
+
"paths",
|
|
1476
|
+
"commands",
|
|
1477
|
+
"staleness",
|
|
1478
|
+
"tokens",
|
|
1479
|
+
"redundancy",
|
|
1480
|
+
"contradictions",
|
|
1481
|
+
"frontmatter"
|
|
1482
|
+
]);
|
|
936
1483
|
var server = new McpServer({
|
|
937
1484
|
name: "ctxlint",
|
|
938
1485
|
version: VERSION
|
|
939
1486
|
});
|
|
940
1487
|
server.tool(
|
|
941
1488
|
"ctxlint_audit",
|
|
942
|
-
"Audit all AI agent context files (CLAUDE.md, AGENTS.md, etc.) in the project for stale references, invalid commands, redundant content, and token waste.",
|
|
1489
|
+
"Audit all AI agent context files (CLAUDE.md, AGENTS.md, etc.) in the project for stale references, invalid commands, redundant content, contradictions, frontmatter issues, and token waste.",
|
|
943
1490
|
{
|
|
944
1491
|
projectPath: z.string().optional().describe("Path to the project root. Defaults to current working directory."),
|
|
945
|
-
checks: z.array(
|
|
1492
|
+
checks: z.array(checkEnum).optional().describe("Which checks to run. Defaults to all.")
|
|
946
1493
|
},
|
|
947
1494
|
async ({ projectPath, checks }) => {
|
|
948
1495
|
const root = path7.resolve(projectPath || process.cwd());
|
|
@@ -1020,7 +1567,11 @@ server.tool(
|
|
|
1020
1567
|
content: [
|
|
1021
1568
|
{
|
|
1022
1569
|
type: "text",
|
|
1023
|
-
text: JSON.stringify(
|
|
1570
|
+
text: JSON.stringify(
|
|
1571
|
+
{ files, totalTokens, note: "Token counts use GPT-4 cl100k_base tokenizer" },
|
|
1572
|
+
null,
|
|
1573
|
+
2
|
|
1574
|
+
)
|
|
1024
1575
|
}
|
|
1025
1576
|
]
|
|
1026
1577
|
};
|
|
@@ -1035,6 +1586,47 @@ server.tool(
|
|
|
1035
1586
|
}
|
|
1036
1587
|
}
|
|
1037
1588
|
);
|
|
1589
|
+
server.tool(
|
|
1590
|
+
"ctxlint_fix",
|
|
1591
|
+
"Run the linter with --fix mode to auto-correct broken file paths in context files using git history and fuzzy matching. Returns a summary of what was fixed.",
|
|
1592
|
+
{
|
|
1593
|
+
projectPath: z.string().optional().describe("Path to the project root. Defaults to current working directory."),
|
|
1594
|
+
checks: z.array(checkEnum).optional().describe("Which checks to run before fixing. Defaults to all.")
|
|
1595
|
+
},
|
|
1596
|
+
async ({ projectPath, checks }) => {
|
|
1597
|
+
const root = path7.resolve(projectPath || process.cwd());
|
|
1598
|
+
const activeChecks = checks || ALL_CHECKS;
|
|
1599
|
+
try {
|
|
1600
|
+
const result = await runAudit(root, activeChecks);
|
|
1601
|
+
const fixSummary = applyFixes(result);
|
|
1602
|
+
return {
|
|
1603
|
+
content: [
|
|
1604
|
+
{
|
|
1605
|
+
type: "text",
|
|
1606
|
+
text: JSON.stringify(
|
|
1607
|
+
{
|
|
1608
|
+
totalFixes: fixSummary.totalFixes,
|
|
1609
|
+
filesModified: fixSummary.filesModified,
|
|
1610
|
+
remainingIssues: result.summary
|
|
1611
|
+
},
|
|
1612
|
+
null,
|
|
1613
|
+
2
|
|
1614
|
+
)
|
|
1615
|
+
}
|
|
1616
|
+
]
|
|
1617
|
+
};
|
|
1618
|
+
} catch (err) {
|
|
1619
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1620
|
+
return {
|
|
1621
|
+
content: [{ type: "text", text: JSON.stringify({ error: msg }) }],
|
|
1622
|
+
isError: true
|
|
1623
|
+
};
|
|
1624
|
+
} finally {
|
|
1625
|
+
freeEncoder();
|
|
1626
|
+
resetGit();
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
);
|
|
1038
1630
|
async function runAudit(projectRoot, activeChecks) {
|
|
1039
1631
|
const discovered = await scanForContextFiles(projectRoot);
|
|
1040
1632
|
const parsed = discovered.map((f) => parseContextFile(f));
|
|
@@ -1048,6 +1640,8 @@ async function runAudit(projectRoot, activeChecks) {
|
|
|
1048
1640
|
if (activeChecks.includes("tokens")) issues.push(...await checkTokens(file, projectRoot));
|
|
1049
1641
|
if (activeChecks.includes("redundancy"))
|
|
1050
1642
|
issues.push(...await checkRedundancy(file, projectRoot));
|
|
1643
|
+
if (activeChecks.includes("frontmatter"))
|
|
1644
|
+
issues.push(...await checkFrontmatter(file, projectRoot));
|
|
1051
1645
|
fileResults.push({
|
|
1052
1646
|
path: file.relativePath,
|
|
1053
1647
|
isSymlink: file.isSymlink,
|
|
@@ -1067,6 +1661,11 @@ async function runAudit(projectRoot, activeChecks) {
|
|
|
1067
1661
|
const dupIssues = checkDuplicateContent(parsed);
|
|
1068
1662
|
if (dupIssues.length > 0 && fileResults.length > 0) fileResults[0].issues.push(...dupIssues);
|
|
1069
1663
|
}
|
|
1664
|
+
if (activeChecks.includes("contradictions")) {
|
|
1665
|
+
const contradictionIssues = checkContradictions(parsed);
|
|
1666
|
+
if (contradictionIssues.length > 0 && fileResults.length > 0)
|
|
1667
|
+
fileResults[0].issues.push(...contradictionIssues);
|
|
1668
|
+
}
|
|
1070
1669
|
let estimatedWaste = 0;
|
|
1071
1670
|
for (const fr of fileResults) {
|
|
1072
1671
|
for (const issue of fr.issues) {
|