@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/index.js
CHANGED
|
@@ -7,6 +7,7 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
7
7
|
});
|
|
8
8
|
|
|
9
9
|
// src/index.ts
|
|
10
|
+
import * as fs6 from "fs";
|
|
10
11
|
import { Command } from "commander";
|
|
11
12
|
import ora from "ora";
|
|
12
13
|
|
|
@@ -18,6 +19,14 @@ import { glob } from "glob";
|
|
|
18
19
|
// src/utils/fs.ts
|
|
19
20
|
import * as fs from "fs";
|
|
20
21
|
import * as path from "path";
|
|
22
|
+
function loadPackageJson(projectRoot) {
|
|
23
|
+
try {
|
|
24
|
+
const content = fs.readFileSync(path.join(projectRoot, "package.json"), "utf-8");
|
|
25
|
+
return JSON.parse(content);
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
21
30
|
function fileExists(filePath) {
|
|
22
31
|
try {
|
|
23
32
|
fs.accessSync(filePath);
|
|
@@ -85,37 +94,76 @@ function getAllProjectFiles(projectRoot) {
|
|
|
85
94
|
|
|
86
95
|
// src/core/scanner.ts
|
|
87
96
|
var CONTEXT_FILE_PATTERNS = [
|
|
97
|
+
// Claude Code
|
|
88
98
|
"CLAUDE.md",
|
|
89
99
|
"CLAUDE.local.md",
|
|
100
|
+
".claude/rules/*.md",
|
|
101
|
+
// AGENTS.md (AAIF / Linux Foundation standard)
|
|
90
102
|
"AGENTS.md",
|
|
103
|
+
"AGENT.md",
|
|
104
|
+
"AGENTS.override.md",
|
|
105
|
+
// Cursor
|
|
91
106
|
".cursorrules",
|
|
92
107
|
".cursor/rules/*.md",
|
|
93
108
|
".cursor/rules/*.mdc",
|
|
94
|
-
"
|
|
109
|
+
".cursor/rules/*/RULE.md",
|
|
110
|
+
// GitHub Copilot
|
|
95
111
|
".github/copilot-instructions.md",
|
|
112
|
+
".github/instructions/*.md",
|
|
113
|
+
".github/git-commit-instructions.md",
|
|
114
|
+
// Windsurf
|
|
96
115
|
".windsurfrules",
|
|
97
116
|
".windsurf/rules/*.md",
|
|
117
|
+
// Gemini CLI
|
|
98
118
|
"GEMINI.md",
|
|
99
|
-
|
|
119
|
+
// Cline
|
|
100
120
|
".clinerules",
|
|
101
|
-
|
|
121
|
+
// Aider — note: .aiderules has no file extension; this is the intended format
|
|
122
|
+
".aiderules",
|
|
123
|
+
// Aide / Codestory
|
|
124
|
+
".aide/rules/*.md",
|
|
125
|
+
// Amazon Q Developer
|
|
126
|
+
".amazonq/rules/*.md",
|
|
127
|
+
// Goose (Block)
|
|
128
|
+
".goose/instructions.md",
|
|
129
|
+
".goosehints",
|
|
130
|
+
// JetBrains Junie
|
|
131
|
+
".junie/guidelines.md",
|
|
132
|
+
".junie/AGENTS.md",
|
|
133
|
+
// JetBrains AI Assistant
|
|
134
|
+
".aiassistant/rules/*.md",
|
|
135
|
+
// Continue
|
|
136
|
+
".continuerules",
|
|
137
|
+
".continue/rules/*.md",
|
|
138
|
+
// Zed
|
|
139
|
+
".rules",
|
|
140
|
+
// Replit
|
|
141
|
+
"replit.md"
|
|
102
142
|
];
|
|
103
143
|
var IGNORED_DIRS2 = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "vendor"]);
|
|
104
|
-
async function scanForContextFiles(projectRoot) {
|
|
144
|
+
async function scanForContextFiles(projectRoot, options = {}) {
|
|
145
|
+
const maxDepth = options.depth ?? 2;
|
|
146
|
+
const patterns = [...CONTEXT_FILE_PATTERNS, ...options.extraPatterns || []];
|
|
105
147
|
const found = [];
|
|
106
148
|
const seen = /* @__PURE__ */ new Set();
|
|
107
149
|
const dirsToScan = [projectRoot];
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
150
|
+
function collectDirs(dir, currentDepth) {
|
|
151
|
+
if (currentDepth >= maxDepth) return;
|
|
152
|
+
try {
|
|
153
|
+
const entries = fs2.readdirSync(dir, { withFileTypes: true });
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
if (entry.isDirectory() && !IGNORED_DIRS2.has(entry.name) && !entry.name.startsWith(".")) {
|
|
156
|
+
const fullPath = path2.join(dir, entry.name);
|
|
157
|
+
dirsToScan.push(fullPath);
|
|
158
|
+
collectDirs(fullPath, currentDepth + 1);
|
|
159
|
+
}
|
|
113
160
|
}
|
|
161
|
+
} catch {
|
|
114
162
|
}
|
|
115
|
-
} catch {
|
|
116
163
|
}
|
|
164
|
+
collectDirs(projectRoot, 0);
|
|
117
165
|
for (const dir of dirsToScan) {
|
|
118
|
-
for (const pattern of
|
|
166
|
+
for (const pattern of patterns) {
|
|
119
167
|
const matches = await glob(pattern, {
|
|
120
168
|
cwd: dir,
|
|
121
169
|
absolute: true,
|
|
@@ -554,8 +602,9 @@ function findClosestMatch(target, files) {
|
|
|
554
602
|
// src/core/checks/commands.ts
|
|
555
603
|
import * as fs3 from "fs";
|
|
556
604
|
import * as path4 from "path";
|
|
557
|
-
var NPM_SCRIPT_PATTERN = /^(?:npm\s+run|pnpm(?:\s+run)?|yarn(?:\s+run)?)\s+(\S+)/;
|
|
605
|
+
var NPM_SCRIPT_PATTERN = /^(?:npm\s+run|pnpm(?:\s+run)?|yarn(?:\s+run)?|bun(?:\s+run)?)\s+(\S+)/;
|
|
558
606
|
var MAKE_PATTERN = /^make\s+(\S+)/;
|
|
607
|
+
var NPX_PATTERN = /^npx\s+(\S+)/;
|
|
559
608
|
async function checkCommands(file, projectRoot) {
|
|
560
609
|
const issues = [];
|
|
561
610
|
const pkgJson = loadPackageJson(projectRoot);
|
|
@@ -577,7 +626,9 @@ async function checkCommands(file, projectRoot) {
|
|
|
577
626
|
}
|
|
578
627
|
continue;
|
|
579
628
|
}
|
|
580
|
-
const shorthandMatch = cmd.match(
|
|
629
|
+
const shorthandMatch = cmd.match(
|
|
630
|
+
/^(npm|pnpm|yarn|bun)\s+(test|start|build|dev|lint|format|check|typecheck|clean|serve|preview|e2e)\b/
|
|
631
|
+
);
|
|
581
632
|
if (shorthandMatch && pkgJson) {
|
|
582
633
|
const scriptName = shorthandMatch[2];
|
|
583
634
|
if (pkgJson.scripts && !(scriptName in pkgJson.scripts)) {
|
|
@@ -590,6 +641,30 @@ async function checkCommands(file, projectRoot) {
|
|
|
590
641
|
}
|
|
591
642
|
continue;
|
|
592
643
|
}
|
|
644
|
+
const npxMatch = cmd.match(NPX_PATTERN);
|
|
645
|
+
if (npxMatch && pkgJson) {
|
|
646
|
+
const pkgName = npxMatch[1];
|
|
647
|
+
if (pkgName.startsWith("-")) continue;
|
|
648
|
+
const allDeps = {
|
|
649
|
+
...pkgJson.dependencies,
|
|
650
|
+
...pkgJson.devDependencies
|
|
651
|
+
};
|
|
652
|
+
if (!(pkgName in allDeps)) {
|
|
653
|
+
const binPath = path4.join(projectRoot, "node_modules", ".bin", pkgName);
|
|
654
|
+
try {
|
|
655
|
+
fs3.accessSync(binPath);
|
|
656
|
+
} catch {
|
|
657
|
+
issues.push({
|
|
658
|
+
severity: "warning",
|
|
659
|
+
check: "commands",
|
|
660
|
+
line: ref.line,
|
|
661
|
+
message: `"${cmd}" \u2014 "${pkgName}" not found in dependencies`,
|
|
662
|
+
suggestion: "If this is a global tool, consider adding it to devDependencies for reproducibility"
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
593
668
|
const makeMatch = cmd.match(MAKE_PATTERN);
|
|
594
669
|
if (makeMatch) {
|
|
595
670
|
const target = makeMatch[1];
|
|
@@ -634,14 +709,6 @@ async function checkCommands(file, projectRoot) {
|
|
|
634
709
|
}
|
|
635
710
|
return issues;
|
|
636
711
|
}
|
|
637
|
-
function loadPackageJson(projectRoot) {
|
|
638
|
-
try {
|
|
639
|
-
const content = fs3.readFileSync(path4.join(projectRoot, "package.json"), "utf-8");
|
|
640
|
-
return JSON.parse(content);
|
|
641
|
-
} catch {
|
|
642
|
-
return null;
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
712
|
function loadMakefile(projectRoot) {
|
|
646
713
|
try {
|
|
647
714
|
return fs3.readFileSync(path4.join(projectRoot, "Makefile"), "utf-8");
|
|
@@ -764,7 +831,6 @@ function checkAggregateTokens(files) {
|
|
|
764
831
|
}
|
|
765
832
|
|
|
766
833
|
// src/core/checks/redundancy.ts
|
|
767
|
-
import * as fs4 from "fs";
|
|
768
834
|
import * as path6 from "path";
|
|
769
835
|
var PACKAGE_TECH_MAP = {
|
|
770
836
|
react: ["React", "react"],
|
|
@@ -817,46 +883,56 @@ var PACKAGE_TECH_MAP = {
|
|
|
817
883
|
cypress: ["Cypress"],
|
|
818
884
|
puppeteer: ["Puppeteer"]
|
|
819
885
|
};
|
|
886
|
+
function compilePatterns(allDeps) {
|
|
887
|
+
const compiled = [];
|
|
888
|
+
for (const [pkg, mentions] of Object.entries(PACKAGE_TECH_MAP)) {
|
|
889
|
+
if (!allDeps.has(pkg)) continue;
|
|
890
|
+
for (const mention of mentions) {
|
|
891
|
+
const escaped = escapeRegex(mention);
|
|
892
|
+
compiled.push({
|
|
893
|
+
pkg,
|
|
894
|
+
mention,
|
|
895
|
+
patterns: [
|
|
896
|
+
new RegExp(`\\b(?:use|using|built with|powered by|written in)\\s+${escaped}\\b`, "i"),
|
|
897
|
+
new RegExp(`\\bwe\\s+use\\s+${escaped}\\b`, "i"),
|
|
898
|
+
new RegExp(`\\b${escaped}\\s+(?:project|app|application|codebase)\\b`, "i"),
|
|
899
|
+
new RegExp(`\\bThis is a\\s+${escaped}\\b`, "i")
|
|
900
|
+
]
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return compiled;
|
|
905
|
+
}
|
|
820
906
|
async function checkRedundancy(file, projectRoot) {
|
|
821
907
|
const issues = [];
|
|
822
|
-
const pkgJson =
|
|
908
|
+
const pkgJson = loadPackageJson(projectRoot);
|
|
823
909
|
if (pkgJson) {
|
|
824
910
|
const allDeps = /* @__PURE__ */ new Set([
|
|
825
911
|
...Object.keys(pkgJson.dependencies || {}),
|
|
826
912
|
...Object.keys(pkgJson.devDependencies || {})
|
|
827
913
|
]);
|
|
914
|
+
const compiledPatterns = compilePatterns(allDeps);
|
|
828
915
|
const lines2 = file.content.split("\n");
|
|
829
916
|
for (let i = 0; i < lines2.length; i++) {
|
|
830
917
|
const line = lines2[i];
|
|
831
|
-
for (const
|
|
832
|
-
|
|
833
|
-
for (const
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
"i"
|
|
838
|
-
),
|
|
839
|
-
new RegExp(`\\bwe\\s+use\\s+${escapeRegex(mention)}\\b`, "i"),
|
|
840
|
-
new RegExp(
|
|
841
|
-
`\\b${escapeRegex(mention)}\\s+(?:project|app|application|codebase)\\b`,
|
|
842
|
-
"i"
|
|
843
|
-
),
|
|
844
|
-
new RegExp(`\\bThis is a\\s+${escapeRegex(mention)}\\b`, "i")
|
|
845
|
-
];
|
|
846
|
-
for (const pattern of patterns) {
|
|
847
|
-
if (pattern.test(line)) {
|
|
848
|
-
const wastedTokens = countTokens(line.trim());
|
|
849
|
-
issues.push({
|
|
850
|
-
severity: "info",
|
|
851
|
-
check: "redundancy",
|
|
852
|
-
line: i + 1,
|
|
853
|
-
message: `"${mention}" is in package.json ${pkgJson.dependencies?.[pkg] ? "dependencies" : "devDependencies"} \u2014 agent can infer this`,
|
|
854
|
-
suggestion: `~${wastedTokens} tokens could be saved`
|
|
855
|
-
});
|
|
856
|
-
break;
|
|
857
|
-
}
|
|
918
|
+
for (const { pkg, mention, patterns } of compiledPatterns) {
|
|
919
|
+
let matched = false;
|
|
920
|
+
for (const pattern of patterns) {
|
|
921
|
+
if (pattern.test(line)) {
|
|
922
|
+
matched = true;
|
|
923
|
+
break;
|
|
858
924
|
}
|
|
859
925
|
}
|
|
926
|
+
if (matched) {
|
|
927
|
+
const wastedTokens = countTokens(line.trim());
|
|
928
|
+
issues.push({
|
|
929
|
+
severity: "info",
|
|
930
|
+
check: "redundancy",
|
|
931
|
+
line: i + 1,
|
|
932
|
+
message: `"${mention}" is in package.json ${pkgJson.dependencies?.[pkg] ? "dependencies" : "devDependencies"} \u2014 agent can infer this`,
|
|
933
|
+
suggestion: `~${wastedTokens} tokens could be saved`
|
|
934
|
+
});
|
|
935
|
+
}
|
|
860
936
|
}
|
|
861
937
|
}
|
|
862
938
|
}
|
|
@@ -914,18 +990,431 @@ function calculateLineOverlap(contentA, contentB) {
|
|
|
914
990
|
}
|
|
915
991
|
return overlap / Math.min(linesA.size, linesB.size);
|
|
916
992
|
}
|
|
917
|
-
function loadPackageJson2(projectRoot) {
|
|
918
|
-
try {
|
|
919
|
-
const content = fs4.readFileSync(path6.join(projectRoot, "package.json"), "utf-8");
|
|
920
|
-
return JSON.parse(content);
|
|
921
|
-
} catch {
|
|
922
|
-
return null;
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
993
|
function escapeRegex(str) {
|
|
926
994
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
927
995
|
}
|
|
928
996
|
|
|
997
|
+
// src/core/checks/contradictions.ts
|
|
998
|
+
var DIRECTIVE_CATEGORIES = [
|
|
999
|
+
{
|
|
1000
|
+
name: "testing framework",
|
|
1001
|
+
options: [
|
|
1002
|
+
{
|
|
1003
|
+
label: "Jest",
|
|
1004
|
+
patterns: [/\buse\s+jest\b/i, /\bjest\s+for\s+test/i, /\btest.*with\s+jest\b/i]
|
|
1005
|
+
},
|
|
1006
|
+
{
|
|
1007
|
+
label: "Vitest",
|
|
1008
|
+
patterns: [/\buse\s+vitest\b/i, /\bvitest\s+for\s+test/i, /\btest.*with\s+vitest\b/i]
|
|
1009
|
+
},
|
|
1010
|
+
{
|
|
1011
|
+
label: "Mocha",
|
|
1012
|
+
patterns: [/\buse\s+mocha\b/i, /\bmocha\s+for\s+test/i, /\btest.*with\s+mocha\b/i]
|
|
1013
|
+
},
|
|
1014
|
+
{
|
|
1015
|
+
label: "pytest",
|
|
1016
|
+
patterns: [/\buse\s+pytest\b/i, /\bpytest\s+for\s+test/i, /\btest.*with\s+pytest\b/i]
|
|
1017
|
+
},
|
|
1018
|
+
{
|
|
1019
|
+
label: "Playwright",
|
|
1020
|
+
patterns: [/\buse\s+playwright\b/i, /\bplaywright\s+for\s+(?:e2e|test)/i]
|
|
1021
|
+
},
|
|
1022
|
+
{ label: "Cypress", patterns: [/\buse\s+cypress\b/i, /\bcypress\s+for\s+(?:e2e|test)/i] }
|
|
1023
|
+
]
|
|
1024
|
+
},
|
|
1025
|
+
{
|
|
1026
|
+
name: "package manager",
|
|
1027
|
+
options: [
|
|
1028
|
+
{
|
|
1029
|
+
label: "npm",
|
|
1030
|
+
patterns: [
|
|
1031
|
+
/\buse\s+npm\b/i,
|
|
1032
|
+
/\bnpm\s+as\s+(?:the\s+)?package\s+manager/i,
|
|
1033
|
+
/\balways\s+use\s+npm\b/i
|
|
1034
|
+
]
|
|
1035
|
+
},
|
|
1036
|
+
{
|
|
1037
|
+
label: "pnpm",
|
|
1038
|
+
patterns: [
|
|
1039
|
+
/\buse\s+pnpm\b/i,
|
|
1040
|
+
/\bpnpm\s+as\s+(?:the\s+)?package\s+manager/i,
|
|
1041
|
+
/\balways\s+use\s+pnpm\b/i
|
|
1042
|
+
]
|
|
1043
|
+
},
|
|
1044
|
+
{
|
|
1045
|
+
label: "yarn",
|
|
1046
|
+
patterns: [
|
|
1047
|
+
/\buse\s+yarn\b/i,
|
|
1048
|
+
/\byarn\s+as\s+(?:the\s+)?package\s+manager/i,
|
|
1049
|
+
/\balways\s+use\s+yarn\b/i
|
|
1050
|
+
]
|
|
1051
|
+
},
|
|
1052
|
+
{
|
|
1053
|
+
label: "bun",
|
|
1054
|
+
patterns: [
|
|
1055
|
+
/\buse\s+bun\b/i,
|
|
1056
|
+
/\bbun\s+as\s+(?:the\s+)?package\s+manager/i,
|
|
1057
|
+
/\balways\s+use\s+bun\b/i
|
|
1058
|
+
]
|
|
1059
|
+
}
|
|
1060
|
+
]
|
|
1061
|
+
},
|
|
1062
|
+
{
|
|
1063
|
+
name: "indentation style",
|
|
1064
|
+
options: [
|
|
1065
|
+
{
|
|
1066
|
+
label: "tabs",
|
|
1067
|
+
patterns: [/\buse\s+tabs\b/i, /\btab\s+indentation\b/i, /\bindent\s+with\s+tabs\b/i]
|
|
1068
|
+
},
|
|
1069
|
+
{
|
|
1070
|
+
label: "2 spaces",
|
|
1071
|
+
patterns: [
|
|
1072
|
+
/\b2[\s-]?space\s+indent/i,
|
|
1073
|
+
/\bindent\s+with\s+2\s+spaces/i,
|
|
1074
|
+
/\b2[\s-]?space\s+tabs?\b/i
|
|
1075
|
+
]
|
|
1076
|
+
},
|
|
1077
|
+
{
|
|
1078
|
+
label: "4 spaces",
|
|
1079
|
+
patterns: [
|
|
1080
|
+
/\b4[\s-]?space\s+indent/i,
|
|
1081
|
+
/\bindent\s+with\s+4\s+spaces/i,
|
|
1082
|
+
/\b4[\s-]?space\s+tabs?\b/i
|
|
1083
|
+
]
|
|
1084
|
+
}
|
|
1085
|
+
]
|
|
1086
|
+
},
|
|
1087
|
+
{
|
|
1088
|
+
name: "semicolons",
|
|
1089
|
+
options: [
|
|
1090
|
+
{
|
|
1091
|
+
label: "semicolons",
|
|
1092
|
+
patterns: [
|
|
1093
|
+
/\buse\s+semicolons\b/i,
|
|
1094
|
+
/\balways\s+(?:use\s+)?semicolons\b/i,
|
|
1095
|
+
/\bsemicolons:\s*(?:true|yes)\b/i
|
|
1096
|
+
]
|
|
1097
|
+
},
|
|
1098
|
+
{
|
|
1099
|
+
label: "no semicolons",
|
|
1100
|
+
patterns: [
|
|
1101
|
+
/\bno\s+semicolons\b/i,
|
|
1102
|
+
/\bavoid\s+semicolons\b/i,
|
|
1103
|
+
/\bomit\s+semicolons\b/i,
|
|
1104
|
+
/\bsemicolons:\s*(?:false|no)\b/i
|
|
1105
|
+
]
|
|
1106
|
+
}
|
|
1107
|
+
]
|
|
1108
|
+
},
|
|
1109
|
+
{
|
|
1110
|
+
name: "quote style",
|
|
1111
|
+
options: [
|
|
1112
|
+
{
|
|
1113
|
+
label: "single quotes",
|
|
1114
|
+
patterns: [
|
|
1115
|
+
/\bsingle\s+quotes?\b/i,
|
|
1116
|
+
/\buse\s+(?:single\s+)?['']single['']?\s+quotes?\b/i,
|
|
1117
|
+
/\bprefer\s+single\s+quotes?\b/i
|
|
1118
|
+
]
|
|
1119
|
+
},
|
|
1120
|
+
{
|
|
1121
|
+
label: "double quotes",
|
|
1122
|
+
patterns: [
|
|
1123
|
+
/\bdouble\s+quotes?\b/i,
|
|
1124
|
+
/\buse\s+(?:double\s+)?[""]double[""]?\s+quotes?\b/i,
|
|
1125
|
+
/\bprefer\s+double\s+quotes?\b/i
|
|
1126
|
+
]
|
|
1127
|
+
}
|
|
1128
|
+
]
|
|
1129
|
+
},
|
|
1130
|
+
{
|
|
1131
|
+
name: "naming convention",
|
|
1132
|
+
options: [
|
|
1133
|
+
{
|
|
1134
|
+
label: "camelCase",
|
|
1135
|
+
patterns: [/\bcamelCase\b/, /\bcamel[\s-]?case\s+(?:for|naming|convention)/i]
|
|
1136
|
+
},
|
|
1137
|
+
{
|
|
1138
|
+
label: "snake_case",
|
|
1139
|
+
patterns: [/\bsnake_case\b/, /\bsnake[\s-]?case\s+(?:for|naming|convention)/i]
|
|
1140
|
+
},
|
|
1141
|
+
{
|
|
1142
|
+
label: "PascalCase",
|
|
1143
|
+
patterns: [/\bPascalCase\b/, /\bpascal[\s-]?case\s+(?:for|naming|convention)/i]
|
|
1144
|
+
},
|
|
1145
|
+
{
|
|
1146
|
+
label: "kebab-case",
|
|
1147
|
+
patterns: [/\bkebab-case\b/, /\bkebab[\s-]?case\s+(?:for|naming|convention)/i]
|
|
1148
|
+
}
|
|
1149
|
+
]
|
|
1150
|
+
},
|
|
1151
|
+
{
|
|
1152
|
+
name: "CSS approach",
|
|
1153
|
+
options: [
|
|
1154
|
+
{ label: "Tailwind", patterns: [/\buse\s+tailwind/i, /\btailwind\s+for\s+styl/i] },
|
|
1155
|
+
{
|
|
1156
|
+
label: "CSS Modules",
|
|
1157
|
+
patterns: [/\buse\s+css\s+modules\b/i, /\bcss\s+modules\s+for\s+styl/i]
|
|
1158
|
+
},
|
|
1159
|
+
{
|
|
1160
|
+
label: "styled-components",
|
|
1161
|
+
patterns: [/\buse\s+styled[\s-]?components\b/i, /\bstyled[\s-]?components\s+for\s+styl/i]
|
|
1162
|
+
},
|
|
1163
|
+
{ label: "CSS-in-JS", patterns: [/\buse\s+css[\s-]?in[\s-]?js\b/i] }
|
|
1164
|
+
]
|
|
1165
|
+
},
|
|
1166
|
+
{
|
|
1167
|
+
name: "state management",
|
|
1168
|
+
options: [
|
|
1169
|
+
{ label: "Redux", patterns: [/\buse\s+redux\b/i, /\bredux\s+for\s+state/i] },
|
|
1170
|
+
{ label: "Zustand", patterns: [/\buse\s+zustand\b/i, /\bzustand\s+for\s+state/i] },
|
|
1171
|
+
{ label: "MobX", patterns: [/\buse\s+mobx\b/i, /\bmobx\s+for\s+state/i] },
|
|
1172
|
+
{ label: "Jotai", patterns: [/\buse\s+jotai\b/i, /\bjotai\s+for\s+state/i] },
|
|
1173
|
+
{ label: "Recoil", patterns: [/\buse\s+recoil\b/i, /\brecoil\s+for\s+state/i] }
|
|
1174
|
+
]
|
|
1175
|
+
}
|
|
1176
|
+
];
|
|
1177
|
+
function detectDirectives(file) {
|
|
1178
|
+
const directives = [];
|
|
1179
|
+
const lines = file.content.split("\n");
|
|
1180
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1181
|
+
const line = lines[i];
|
|
1182
|
+
for (const category of DIRECTIVE_CATEGORIES) {
|
|
1183
|
+
for (const option of category.options) {
|
|
1184
|
+
for (const pattern of option.patterns) {
|
|
1185
|
+
if (pattern.test(line)) {
|
|
1186
|
+
directives.push({
|
|
1187
|
+
file: file.relativePath,
|
|
1188
|
+
category: category.name,
|
|
1189
|
+
label: option.label,
|
|
1190
|
+
line: i + 1,
|
|
1191
|
+
text: line.trim()
|
|
1192
|
+
});
|
|
1193
|
+
break;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
return directives;
|
|
1200
|
+
}
|
|
1201
|
+
function checkContradictions(files) {
|
|
1202
|
+
if (files.length < 2) return [];
|
|
1203
|
+
const issues = [];
|
|
1204
|
+
const allDirectives = [];
|
|
1205
|
+
for (const file of files) {
|
|
1206
|
+
allDirectives.push(...detectDirectives(file));
|
|
1207
|
+
}
|
|
1208
|
+
const byCategory = /* @__PURE__ */ new Map();
|
|
1209
|
+
for (const d of allDirectives) {
|
|
1210
|
+
const existing = byCategory.get(d.category) || [];
|
|
1211
|
+
existing.push(d);
|
|
1212
|
+
byCategory.set(d.category, existing);
|
|
1213
|
+
}
|
|
1214
|
+
for (const [category, directives] of byCategory) {
|
|
1215
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
1216
|
+
for (const d of directives) {
|
|
1217
|
+
const existing = byFile.get(d.file) || [];
|
|
1218
|
+
existing.push(d);
|
|
1219
|
+
byFile.set(d.file, existing);
|
|
1220
|
+
}
|
|
1221
|
+
const labels = new Set(directives.map((d) => d.label));
|
|
1222
|
+
if (labels.size <= 1) continue;
|
|
1223
|
+
const fileLabels = /* @__PURE__ */ new Map();
|
|
1224
|
+
for (const d of directives) {
|
|
1225
|
+
const existing = fileLabels.get(d.file) || /* @__PURE__ */ new Set();
|
|
1226
|
+
existing.add(d.label);
|
|
1227
|
+
fileLabels.set(d.file, existing);
|
|
1228
|
+
}
|
|
1229
|
+
const fileEntries = [...fileLabels.entries()];
|
|
1230
|
+
for (let i = 0; i < fileEntries.length; i++) {
|
|
1231
|
+
for (let j = i + 1; j < fileEntries.length; j++) {
|
|
1232
|
+
const [fileA, labelsA] = fileEntries[i];
|
|
1233
|
+
const [fileB, labelsB] = fileEntries[j];
|
|
1234
|
+
for (const labelA of labelsA) {
|
|
1235
|
+
for (const labelB of labelsB) {
|
|
1236
|
+
if (labelA !== labelB) {
|
|
1237
|
+
const directiveA = directives.find((d) => d.file === fileA && d.label === labelA);
|
|
1238
|
+
const directiveB = directives.find((d) => d.file === fileB && d.label === labelB);
|
|
1239
|
+
issues.push({
|
|
1240
|
+
severity: "warning",
|
|
1241
|
+
check: "contradictions",
|
|
1242
|
+
line: directiveA.line,
|
|
1243
|
+
message: `${category} conflict: "${directiveA.label}" in ${fileA} vs "${directiveB.label}" in ${fileB}`,
|
|
1244
|
+
suggestion: `Align on one ${category} across all context files`,
|
|
1245
|
+
detail: `${fileA}:${directiveA.line} says "${directiveA.text}" but ${fileB}:${directiveB.line} says "${directiveB.text}"`
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
return issues;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// src/core/checks/frontmatter.ts
|
|
1257
|
+
function parseFrontmatter(content) {
|
|
1258
|
+
const lines = content.split("\n");
|
|
1259
|
+
if (lines[0]?.trim() !== "---") {
|
|
1260
|
+
return { found: false, fields: {}, endLine: 0 };
|
|
1261
|
+
}
|
|
1262
|
+
const fields = {};
|
|
1263
|
+
let endLine = 0;
|
|
1264
|
+
for (let i = 1; i < lines.length; i++) {
|
|
1265
|
+
const line = lines[i].trim();
|
|
1266
|
+
if (line === "---") {
|
|
1267
|
+
endLine = i + 1;
|
|
1268
|
+
break;
|
|
1269
|
+
}
|
|
1270
|
+
const match = line.match(/^(\w+)\s*:\s*(.*)$/);
|
|
1271
|
+
if (match) {
|
|
1272
|
+
fields[match[1]] = match[2].trim();
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
if (endLine === 0) {
|
|
1276
|
+
return { found: true, fields, endLine: lines.length };
|
|
1277
|
+
}
|
|
1278
|
+
return { found: true, fields, endLine };
|
|
1279
|
+
}
|
|
1280
|
+
function isCursorMdc(file) {
|
|
1281
|
+
return file.relativePath.endsWith(".mdc");
|
|
1282
|
+
}
|
|
1283
|
+
function isCopilotInstructions(file) {
|
|
1284
|
+
return file.relativePath.includes(".github/instructions/") && file.relativePath.endsWith(".md");
|
|
1285
|
+
}
|
|
1286
|
+
function isWindsurfRule(file) {
|
|
1287
|
+
return file.relativePath.includes(".windsurf/rules/") && file.relativePath.endsWith(".md");
|
|
1288
|
+
}
|
|
1289
|
+
var VALID_WINDSURF_TRIGGERS = ["always_on", "glob", "manual", "model"];
|
|
1290
|
+
async function checkFrontmatter(file, _projectRoot) {
|
|
1291
|
+
const issues = [];
|
|
1292
|
+
if (isCursorMdc(file)) {
|
|
1293
|
+
issues.push(...validateCursorMdc(file));
|
|
1294
|
+
} else if (isCopilotInstructions(file)) {
|
|
1295
|
+
issues.push(...validateCopilotInstructions(file));
|
|
1296
|
+
} else if (isWindsurfRule(file)) {
|
|
1297
|
+
issues.push(...validateWindsurfRule(file));
|
|
1298
|
+
}
|
|
1299
|
+
return issues;
|
|
1300
|
+
}
|
|
1301
|
+
function validateCursorMdc(file) {
|
|
1302
|
+
const issues = [];
|
|
1303
|
+
const fm = parseFrontmatter(file.content);
|
|
1304
|
+
if (!fm.found) {
|
|
1305
|
+
issues.push({
|
|
1306
|
+
severity: "warning",
|
|
1307
|
+
check: "frontmatter",
|
|
1308
|
+
line: 1,
|
|
1309
|
+
message: "Cursor .mdc file is missing frontmatter",
|
|
1310
|
+
suggestion: "Add YAML frontmatter with description, globs, and alwaysApply fields"
|
|
1311
|
+
});
|
|
1312
|
+
return issues;
|
|
1313
|
+
}
|
|
1314
|
+
if (!fm.fields["description"]) {
|
|
1315
|
+
issues.push({
|
|
1316
|
+
severity: "warning",
|
|
1317
|
+
check: "frontmatter",
|
|
1318
|
+
line: 1,
|
|
1319
|
+
message: 'Missing "description" field in Cursor .mdc frontmatter',
|
|
1320
|
+
suggestion: "Add a description so Cursor knows when to apply this rule"
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
if (!("alwaysApply" in fm.fields) && !("globs" in fm.fields)) {
|
|
1324
|
+
issues.push({
|
|
1325
|
+
severity: "info",
|
|
1326
|
+
check: "frontmatter",
|
|
1327
|
+
line: 1,
|
|
1328
|
+
message: 'No "alwaysApply" or "globs" field \u2014 rule may not be applied automatically',
|
|
1329
|
+
suggestion: "Set alwaysApply: true or specify globs for targeted activation"
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
if ("alwaysApply" in fm.fields) {
|
|
1333
|
+
const val = fm.fields["alwaysApply"].toLowerCase();
|
|
1334
|
+
if (!["true", "false"].includes(val)) {
|
|
1335
|
+
issues.push({
|
|
1336
|
+
severity: "error",
|
|
1337
|
+
check: "frontmatter",
|
|
1338
|
+
line: 1,
|
|
1339
|
+
message: `Invalid alwaysApply value: "${fm.fields["alwaysApply"]}"`,
|
|
1340
|
+
suggestion: "alwaysApply must be true or false"
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
if ("globs" in fm.fields) {
|
|
1345
|
+
const val = fm.fields["globs"];
|
|
1346
|
+
if (val && !val.startsWith("[") && !val.startsWith('"') && !val.includes("*") && !val.includes("/")) {
|
|
1347
|
+
issues.push({
|
|
1348
|
+
severity: "warning",
|
|
1349
|
+
check: "frontmatter",
|
|
1350
|
+
line: 1,
|
|
1351
|
+
message: `Possibly invalid globs value: "${val}"`,
|
|
1352
|
+
suggestion: 'globs should be a glob pattern like "src/**/*.ts" or an array like ["*.ts", "*.tsx"]'
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
return issues;
|
|
1357
|
+
}
|
|
1358
|
+
function validateCopilotInstructions(file) {
|
|
1359
|
+
const issues = [];
|
|
1360
|
+
const fm = parseFrontmatter(file.content);
|
|
1361
|
+
if (!fm.found) {
|
|
1362
|
+
issues.push({
|
|
1363
|
+
severity: "info",
|
|
1364
|
+
check: "frontmatter",
|
|
1365
|
+
line: 1,
|
|
1366
|
+
message: "Copilot instructions file has no frontmatter",
|
|
1367
|
+
suggestion: "Add applyTo frontmatter to target specific file patterns"
|
|
1368
|
+
});
|
|
1369
|
+
return issues;
|
|
1370
|
+
}
|
|
1371
|
+
if (!fm.fields["applyTo"]) {
|
|
1372
|
+
issues.push({
|
|
1373
|
+
severity: "warning",
|
|
1374
|
+
check: "frontmatter",
|
|
1375
|
+
line: 1,
|
|
1376
|
+
message: 'Missing "applyTo" field in Copilot instructions frontmatter',
|
|
1377
|
+
suggestion: 'Add applyTo to specify which files this instruction applies to (e.g., applyTo: "**/*.ts")'
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
return issues;
|
|
1381
|
+
}
|
|
1382
|
+
function validateWindsurfRule(file) {
|
|
1383
|
+
const issues = [];
|
|
1384
|
+
const fm = parseFrontmatter(file.content);
|
|
1385
|
+
if (!fm.found) {
|
|
1386
|
+
issues.push({
|
|
1387
|
+
severity: "info",
|
|
1388
|
+
check: "frontmatter",
|
|
1389
|
+
line: 1,
|
|
1390
|
+
message: "Windsurf rule file has no frontmatter",
|
|
1391
|
+
suggestion: "Add YAML frontmatter with a trigger field (always_on, glob, manual, model)"
|
|
1392
|
+
});
|
|
1393
|
+
return issues;
|
|
1394
|
+
}
|
|
1395
|
+
if (!fm.fields["trigger"]) {
|
|
1396
|
+
issues.push({
|
|
1397
|
+
severity: "warning",
|
|
1398
|
+
check: "frontmatter",
|
|
1399
|
+
line: 1,
|
|
1400
|
+
message: 'Missing "trigger" field in Windsurf rule frontmatter',
|
|
1401
|
+
suggestion: `Set trigger to one of: ${VALID_WINDSURF_TRIGGERS.join(", ")}`
|
|
1402
|
+
});
|
|
1403
|
+
} else {
|
|
1404
|
+
const trigger = fm.fields["trigger"].replace(/['"]/g, "");
|
|
1405
|
+
if (!VALID_WINDSURF_TRIGGERS.includes(trigger)) {
|
|
1406
|
+
issues.push({
|
|
1407
|
+
severity: "error",
|
|
1408
|
+
check: "frontmatter",
|
|
1409
|
+
line: 1,
|
|
1410
|
+
message: `Invalid trigger value: "${trigger}"`,
|
|
1411
|
+
suggestion: `Valid triggers: ${VALID_WINDSURF_TRIGGERS.join(", ")}`
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
return issues;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
929
1418
|
// src/core/reporter.ts
|
|
930
1419
|
import chalk from "chalk";
|
|
931
1420
|
function formatText(result, verbose = false) {
|
|
@@ -984,6 +1473,9 @@ function formatTokenReport(result) {
|
|
|
984
1473
|
const lines = [];
|
|
985
1474
|
lines.push("");
|
|
986
1475
|
lines.push(chalk.bold("Token Usage Report"));
|
|
1476
|
+
lines.push(
|
|
1477
|
+
chalk.dim(" (counts use GPT-4 cl100k_base tokenizer \u2014 Claude counts may vary slightly)")
|
|
1478
|
+
);
|
|
987
1479
|
lines.push("");
|
|
988
1480
|
const maxPathLen = Math.max(...result.files.map((f) => f.path.length), 4);
|
|
989
1481
|
lines.push(
|
|
@@ -1010,6 +1502,100 @@ function formatTokenReport(result) {
|
|
|
1010
1502
|
lines.push("");
|
|
1011
1503
|
return lines.join("\n");
|
|
1012
1504
|
}
|
|
1505
|
+
function formatSarif(result) {
|
|
1506
|
+
const severityToLevel = {
|
|
1507
|
+
error: "error",
|
|
1508
|
+
warning: "warning",
|
|
1509
|
+
info: "note"
|
|
1510
|
+
};
|
|
1511
|
+
const results = [];
|
|
1512
|
+
for (const file of result.files) {
|
|
1513
|
+
for (const issue of file.issues) {
|
|
1514
|
+
const sarifResult = {
|
|
1515
|
+
ruleId: `ctxlint/${issue.check}`,
|
|
1516
|
+
level: severityToLevel[issue.severity] || "note",
|
|
1517
|
+
message: {
|
|
1518
|
+
text: issue.message + (issue.suggestion ? ` (${issue.suggestion})` : "")
|
|
1519
|
+
},
|
|
1520
|
+
locations: [
|
|
1521
|
+
{
|
|
1522
|
+
physicalLocation: {
|
|
1523
|
+
artifactLocation: {
|
|
1524
|
+
uri: file.path,
|
|
1525
|
+
uriBaseId: "%SRCROOT%"
|
|
1526
|
+
},
|
|
1527
|
+
region: {
|
|
1528
|
+
startLine: Math.max(issue.line, 1)
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
]
|
|
1533
|
+
};
|
|
1534
|
+
if (issue.detail) {
|
|
1535
|
+
sarifResult.message.text += `
|
|
1536
|
+
${issue.detail}`;
|
|
1537
|
+
}
|
|
1538
|
+
results.push(sarifResult);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
const sarif = {
|
|
1542
|
+
version: "2.1.0",
|
|
1543
|
+
$schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
|
|
1544
|
+
runs: [
|
|
1545
|
+
{
|
|
1546
|
+
tool: {
|
|
1547
|
+
driver: {
|
|
1548
|
+
name: "ctxlint",
|
|
1549
|
+
version: result.version,
|
|
1550
|
+
informationUri: "https://github.com/yawlabs/ctxlint",
|
|
1551
|
+
rules: buildRuleDescriptors()
|
|
1552
|
+
}
|
|
1553
|
+
},
|
|
1554
|
+
results
|
|
1555
|
+
}
|
|
1556
|
+
]
|
|
1557
|
+
};
|
|
1558
|
+
return JSON.stringify(sarif, null, 2);
|
|
1559
|
+
}
|
|
1560
|
+
function buildRuleDescriptors() {
|
|
1561
|
+
return [
|
|
1562
|
+
{
|
|
1563
|
+
id: "ctxlint/paths",
|
|
1564
|
+
shortDescription: { text: "File path does not exist in project" },
|
|
1565
|
+
helpUri: "https://github.com/yawlabs/ctxlint#what-it-checks"
|
|
1566
|
+
},
|
|
1567
|
+
{
|
|
1568
|
+
id: "ctxlint/commands",
|
|
1569
|
+
shortDescription: { text: "Command does not match project scripts" },
|
|
1570
|
+
helpUri: "https://github.com/yawlabs/ctxlint#what-it-checks"
|
|
1571
|
+
},
|
|
1572
|
+
{
|
|
1573
|
+
id: "ctxlint/staleness",
|
|
1574
|
+
shortDescription: { text: "Context file is stale relative to referenced code" },
|
|
1575
|
+
helpUri: "https://github.com/yawlabs/ctxlint#what-it-checks"
|
|
1576
|
+
},
|
|
1577
|
+
{
|
|
1578
|
+
id: "ctxlint/tokens",
|
|
1579
|
+
shortDescription: { text: "Context file token usage" },
|
|
1580
|
+
helpUri: "https://github.com/yawlabs/ctxlint#what-it-checks"
|
|
1581
|
+
},
|
|
1582
|
+
{
|
|
1583
|
+
id: "ctxlint/redundancy",
|
|
1584
|
+
shortDescription: { text: "Content is redundant or inferable" },
|
|
1585
|
+
helpUri: "https://github.com/yawlabs/ctxlint#what-it-checks"
|
|
1586
|
+
},
|
|
1587
|
+
{
|
|
1588
|
+
id: "ctxlint/contradictions",
|
|
1589
|
+
shortDescription: { text: "Conflicting directives across context files" },
|
|
1590
|
+
helpUri: "https://github.com/yawlabs/ctxlint#what-it-checks"
|
|
1591
|
+
},
|
|
1592
|
+
{
|
|
1593
|
+
id: "ctxlint/frontmatter",
|
|
1594
|
+
shortDescription: { text: "Invalid or missing frontmatter" },
|
|
1595
|
+
helpUri: "https://github.com/yawlabs/ctxlint#what-it-checks"
|
|
1596
|
+
}
|
|
1597
|
+
];
|
|
1598
|
+
}
|
|
1013
1599
|
function formatIssue(issue) {
|
|
1014
1600
|
const icon = issue.severity === "error" ? chalk.red("\u2717") : issue.severity === "warning" ? chalk.yellow("\u26A0") : chalk.blue("\u2139");
|
|
1015
1601
|
const lineRef = issue.line > 0 ? `Line ${issue.line}: ` : "";
|
|
@@ -1026,7 +1612,7 @@ function formatIssue(issue) {
|
|
|
1026
1612
|
}
|
|
1027
1613
|
|
|
1028
1614
|
// src/core/fixer.ts
|
|
1029
|
-
import * as
|
|
1615
|
+
import * as fs4 from "fs";
|
|
1030
1616
|
import chalk2 from "chalk";
|
|
1031
1617
|
function applyFixes(result) {
|
|
1032
1618
|
const fixesByFile = /* @__PURE__ */ new Map();
|
|
@@ -1042,7 +1628,7 @@ function applyFixes(result) {
|
|
|
1042
1628
|
let totalFixes = 0;
|
|
1043
1629
|
const filesModified = [];
|
|
1044
1630
|
for (const [filePath, fixes] of fixesByFile) {
|
|
1045
|
-
const content =
|
|
1631
|
+
const content = fs4.readFileSync(filePath, "utf-8");
|
|
1046
1632
|
const lines = content.split("\n");
|
|
1047
1633
|
let modified = false;
|
|
1048
1634
|
const sortedFixes = [...fixes].sort((a, b) => b.line - a.line);
|
|
@@ -1060,7 +1646,7 @@ function applyFixes(result) {
|
|
|
1060
1646
|
}
|
|
1061
1647
|
}
|
|
1062
1648
|
if (modified) {
|
|
1063
|
-
|
|
1649
|
+
fs4.writeFileSync(filePath, lines.join("\n"), "utf-8");
|
|
1064
1650
|
filesModified.push(filePath);
|
|
1065
1651
|
}
|
|
1066
1652
|
}
|
|
@@ -1068,14 +1654,14 @@ function applyFixes(result) {
|
|
|
1068
1654
|
}
|
|
1069
1655
|
|
|
1070
1656
|
// src/core/config.ts
|
|
1071
|
-
import * as
|
|
1657
|
+
import * as fs5 from "fs";
|
|
1072
1658
|
import * as path7 from "path";
|
|
1073
1659
|
var CONFIG_FILENAMES = [".ctxlintrc", ".ctxlintrc.json"];
|
|
1074
1660
|
function loadConfig(projectRoot) {
|
|
1075
1661
|
for (const filename of CONFIG_FILENAMES) {
|
|
1076
1662
|
const filePath = path7.join(projectRoot, filename);
|
|
1077
1663
|
try {
|
|
1078
|
-
const content =
|
|
1664
|
+
const content = fs5.readFileSync(filePath, "utf-8");
|
|
1079
1665
|
return JSON.parse(content);
|
|
1080
1666
|
} catch {
|
|
1081
1667
|
continue;
|
|
@@ -1089,7 +1675,7 @@ import * as path8 from "path";
|
|
|
1089
1675
|
|
|
1090
1676
|
// src/version.ts
|
|
1091
1677
|
function loadVersion() {
|
|
1092
|
-
if (true) return "0.
|
|
1678
|
+
if (true) return "0.3.0";
|
|
1093
1679
|
const fs7 = __require("fs");
|
|
1094
1680
|
const path9 = __require("path");
|
|
1095
1681
|
const pkgPath = path9.resolve(__dirname, "../package.json");
|
|
@@ -1099,13 +1685,22 @@ function loadVersion() {
|
|
|
1099
1685
|
var VERSION = loadVersion();
|
|
1100
1686
|
|
|
1101
1687
|
// src/index.ts
|
|
1102
|
-
var ALL_CHECKS = [
|
|
1688
|
+
var ALL_CHECKS = [
|
|
1689
|
+
"paths",
|
|
1690
|
+
"commands",
|
|
1691
|
+
"staleness",
|
|
1692
|
+
"tokens",
|
|
1693
|
+
"redundancy",
|
|
1694
|
+
"contradictions",
|
|
1695
|
+
"frontmatter"
|
|
1696
|
+
];
|
|
1103
1697
|
var program = new Command();
|
|
1104
1698
|
program.name("ctxlint").description(
|
|
1105
1699
|
"Lint your AI agent context files (CLAUDE.md, AGENTS.md, etc.) against your actual codebase"
|
|
1106
|
-
).version(VERSION).argument("[path]", "Project directory to scan", ".").option("--strict", "Exit code 1 on any warning or error (for CI)", false).option("--checks <checks>", "Comma-separated list of checks to run", "").option("--format <format>", "Output format: text or
|
|
1700
|
+
).version(VERSION).argument("[path]", "Project directory to scan", ".").option("--strict", "Exit code 1 on any warning or error (for CI)", false).option("--checks <checks>", "Comma-separated list of checks to run", "").option("--format <format>", "Output format: text, json, or sarif", "text").option("--tokens", "Show token breakdown per file", false).option("--verbose", "Show passing checks too", false).option("--fix", "Auto-fix broken paths using git history and fuzzy matching", false).option("--ignore <checks>", "Comma-separated list of checks to ignore", "").option("--quiet", "Suppress all output except errors (exit code only)", false).option("--config <path>", "Path to config file (default: .ctxlintrc in project root)").option("--depth <n>", "Max subdirectory depth to scan (default: 2)", "2").action(async (projectPath, opts) => {
|
|
1107
1701
|
const resolvedPath = path8.resolve(projectPath);
|
|
1108
|
-
const
|
|
1702
|
+
const configPath = opts.config ? path8.resolve(opts.config) : void 0;
|
|
1703
|
+
const config = configPath ? loadConfigFromPath(configPath) : loadConfig(resolvedPath);
|
|
1109
1704
|
const options = {
|
|
1110
1705
|
projectPath: resolvedPath,
|
|
1111
1706
|
checks: opts.checks ? opts.checks.split(",").map((c) => c.trim()) : config?.checks || ALL_CHECKS,
|
|
@@ -1114,29 +1709,46 @@ program.name("ctxlint").description(
|
|
|
1114
1709
|
verbose: opts.verbose,
|
|
1115
1710
|
fix: opts.fix,
|
|
1116
1711
|
ignore: opts.ignore ? opts.ignore.split(",").map((c) => c.trim()) : config?.ignore || [],
|
|
1117
|
-
tokensOnly: opts.tokens
|
|
1712
|
+
tokensOnly: opts.tokens,
|
|
1713
|
+
quiet: opts.quiet,
|
|
1714
|
+
depth: parseInt(opts.depth, 10) || 2
|
|
1118
1715
|
};
|
|
1119
1716
|
if (config?.tokenThresholds) {
|
|
1120
1717
|
setTokenThresholds(config.tokenThresholds);
|
|
1121
1718
|
}
|
|
1122
1719
|
const activeChecks = options.checks.filter((c) => !options.ignore.includes(c));
|
|
1123
|
-
const spinner = options.format === "text" ? ora("Scanning for context files...").start() : void 0;
|
|
1720
|
+
const spinner = options.format === "text" && !options.quiet ? ora("Scanning for context files...").start() : void 0;
|
|
1124
1721
|
try {
|
|
1125
|
-
const discovered = await scanForContextFiles(options.projectPath
|
|
1722
|
+
const discovered = await scanForContextFiles(options.projectPath, {
|
|
1723
|
+
depth: options.depth,
|
|
1724
|
+
extraPatterns: config?.contextFiles
|
|
1725
|
+
});
|
|
1126
1726
|
if (discovered.length === 0) {
|
|
1127
1727
|
spinner?.stop();
|
|
1128
|
-
if (options.
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1728
|
+
if (!options.quiet) {
|
|
1729
|
+
if (options.format === "json") {
|
|
1730
|
+
console.log(
|
|
1731
|
+
JSON.stringify({
|
|
1732
|
+
version: VERSION,
|
|
1733
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1734
|
+
projectRoot: options.projectPath,
|
|
1735
|
+
files: [],
|
|
1736
|
+
summary: { errors: 0, warnings: 0, info: 0, totalTokens: 0, estimatedWaste: 0 }
|
|
1737
|
+
})
|
|
1738
|
+
);
|
|
1739
|
+
} else if (options.format === "sarif") {
|
|
1740
|
+
console.log(
|
|
1741
|
+
formatSarif({
|
|
1742
|
+
version: VERSION,
|
|
1743
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1744
|
+
projectRoot: options.projectPath,
|
|
1745
|
+
files: [],
|
|
1746
|
+
summary: { errors: 0, warnings: 0, info: 0, totalTokens: 0, estimatedWaste: 0 }
|
|
1747
|
+
})
|
|
1748
|
+
);
|
|
1749
|
+
} else {
|
|
1750
|
+
console.log("\nNo context files found.\n");
|
|
1751
|
+
}
|
|
1140
1752
|
}
|
|
1141
1753
|
process.exit(0);
|
|
1142
1754
|
}
|
|
@@ -1162,6 +1774,9 @@ program.name("ctxlint").description(
|
|
|
1162
1774
|
if (activeChecks.includes("redundancy")) {
|
|
1163
1775
|
issues.push(...await checkRedundancy(file, options.projectPath));
|
|
1164
1776
|
}
|
|
1777
|
+
if (activeChecks.includes("frontmatter")) {
|
|
1778
|
+
issues.push(...await checkFrontmatter(file, options.projectPath));
|
|
1779
|
+
}
|
|
1165
1780
|
fileResults.push({
|
|
1166
1781
|
path: file.relativePath,
|
|
1167
1782
|
isSymlink: file.isSymlink,
|
|
@@ -1185,6 +1800,12 @@ program.name("ctxlint").description(
|
|
|
1185
1800
|
fileResults[0].issues.push(...dupIssues);
|
|
1186
1801
|
}
|
|
1187
1802
|
}
|
|
1803
|
+
if (activeChecks.includes("contradictions")) {
|
|
1804
|
+
const contradictionIssues = checkContradictions(parsed);
|
|
1805
|
+
if (contradictionIssues.length > 0 && fileResults.length > 0) {
|
|
1806
|
+
fileResults[0].issues.push(...contradictionIssues);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1188
1809
|
let estimatedWaste = 0;
|
|
1189
1810
|
for (const fr of fileResults) {
|
|
1190
1811
|
for (const issue of fr.issues) {
|
|
@@ -1221,7 +1842,7 @@ program.name("ctxlint").description(
|
|
|
1221
1842
|
spinner?.stop();
|
|
1222
1843
|
if (options.fix) {
|
|
1223
1844
|
const fixSummary = applyFixes(result);
|
|
1224
|
-
if (fixSummary.totalFixes > 0) {
|
|
1845
|
+
if (fixSummary.totalFixes > 0 && !options.quiet) {
|
|
1225
1846
|
console.log(
|
|
1226
1847
|
`
|
|
1227
1848
|
Fixed ${fixSummary.totalFixes} issue${fixSummary.totalFixes !== 1 ? "s" : ""} in ${fixSummary.filesModified.length} file${fixSummary.filesModified.length !== 1 ? "s" : ""}.
|
|
@@ -1229,12 +1850,16 @@ Fixed ${fixSummary.totalFixes} issue${fixSummary.totalFixes !== 1 ? "s" : ""} in
|
|
|
1229
1850
|
);
|
|
1230
1851
|
}
|
|
1231
1852
|
}
|
|
1232
|
-
if (options.
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1853
|
+
if (!options.quiet) {
|
|
1854
|
+
if (options.tokensOnly) {
|
|
1855
|
+
console.log(formatTokenReport(result));
|
|
1856
|
+
} else if (options.format === "json") {
|
|
1857
|
+
console.log(formatJson(result));
|
|
1858
|
+
} else if (options.format === "sarif") {
|
|
1859
|
+
console.log(formatSarif(result));
|
|
1860
|
+
} else {
|
|
1861
|
+
console.log(formatText(result, options.verbose));
|
|
1862
|
+
}
|
|
1238
1863
|
}
|
|
1239
1864
|
if (options.strict && (result.summary.errors > 0 || result.summary.warnings > 0)) {
|
|
1240
1865
|
process.exit(1);
|
|
@@ -1251,32 +1876,40 @@ Fixed ${fixSummary.totalFixes} issue${fixSummary.totalFixes !== 1 ? "s" : ""} in
|
|
|
1251
1876
|
}
|
|
1252
1877
|
});
|
|
1253
1878
|
program.command("init").description("Set up a git pre-commit hook that runs ctxlint --strict").action(async () => {
|
|
1254
|
-
const fs7 = await import("fs");
|
|
1255
1879
|
const hooksDir = path8.resolve(".git", "hooks");
|
|
1256
|
-
if (!
|
|
1880
|
+
if (!fs6.existsSync(path8.resolve(".git"))) {
|
|
1257
1881
|
console.error('Error: not a git repository. Run "git init" first.');
|
|
1258
1882
|
process.exit(1);
|
|
1259
1883
|
}
|
|
1260
|
-
if (!
|
|
1261
|
-
|
|
1884
|
+
if (!fs6.existsSync(hooksDir)) {
|
|
1885
|
+
fs6.mkdirSync(hooksDir, { recursive: true });
|
|
1262
1886
|
}
|
|
1263
1887
|
const hookPath = path8.join(hooksDir, "pre-commit");
|
|
1264
1888
|
const hookContent = `#!/bin/sh
|
|
1265
1889
|
# ctxlint pre-commit hook
|
|
1266
1890
|
npx @yawlabs/ctxlint --strict
|
|
1267
1891
|
`;
|
|
1268
|
-
if (
|
|
1269
|
-
const existing =
|
|
1892
|
+
if (fs6.existsSync(hookPath)) {
|
|
1893
|
+
const existing = fs6.readFileSync(hookPath, "utf-8");
|
|
1270
1894
|
if (existing.includes("ctxlint")) {
|
|
1271
1895
|
console.log("Pre-commit hook already includes ctxlint.");
|
|
1272
1896
|
return;
|
|
1273
1897
|
}
|
|
1274
|
-
|
|
1898
|
+
fs6.appendFileSync(hookPath, "\n" + hookContent);
|
|
1275
1899
|
console.log("Added ctxlint to existing pre-commit hook.");
|
|
1276
1900
|
} else {
|
|
1277
|
-
|
|
1901
|
+
fs6.writeFileSync(hookPath, hookContent, { mode: 493 });
|
|
1278
1902
|
console.log("Created pre-commit hook at .git/hooks/pre-commit");
|
|
1279
1903
|
}
|
|
1280
1904
|
console.log("ctxlint will now run automatically before each commit.");
|
|
1281
1905
|
});
|
|
1282
1906
|
program.parse();
|
|
1907
|
+
function loadConfigFromPath(configPath) {
|
|
1908
|
+
try {
|
|
1909
|
+
const content = fs6.readFileSync(configPath, "utf-8");
|
|
1910
|
+
return JSON.parse(content);
|
|
1911
|
+
} catch {
|
|
1912
|
+
console.error(`Error: could not load config from ${configPath}`);
|
|
1913
|
+
process.exit(2);
|
|
1914
|
+
}
|
|
1915
|
+
}
|