@yawlabs/ctxlint 0.2.2 → 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/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
 
@@ -93,43 +94,76 @@ function getAllProjectFiles(projectRoot) {
93
94
 
94
95
  // src/core/scanner.ts
95
96
  var CONTEXT_FILE_PATTERNS = [
97
+ // Claude Code
96
98
  "CLAUDE.md",
97
99
  "CLAUDE.local.md",
100
+ ".claude/rules/*.md",
101
+ // AGENTS.md (AAIF / Linux Foundation standard)
98
102
  "AGENTS.md",
103
+ "AGENT.md",
104
+ "AGENTS.override.md",
105
+ // Cursor
99
106
  ".cursorrules",
100
107
  ".cursor/rules/*.md",
101
108
  ".cursor/rules/*.mdc",
102
- "copilot-instructions.md",
109
+ ".cursor/rules/*/RULE.md",
110
+ // GitHub Copilot
103
111
  ".github/copilot-instructions.md",
104
112
  ".github/instructions/*.md",
113
+ ".github/git-commit-instructions.md",
114
+ // Windsurf
105
115
  ".windsurfrules",
106
116
  ".windsurf/rules/*.md",
117
+ // Gemini CLI
107
118
  "GEMINI.md",
108
- "JULES.md",
119
+ // Cline
109
120
  ".clinerules",
110
- "CONVENTIONS.md",
111
- "CODEX.md",
121
+ // Aider — note: .aiderules has no file extension; this is the intended format
112
122
  ".aiderules",
123
+ // Aide / Codestory
113
124
  ".aide/rules/*.md",
125
+ // Amazon Q Developer
114
126
  ".amazonq/rules/*.md",
115
- ".goose/instructions.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"
116
142
  ];
117
143
  var IGNORED_DIRS2 = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "vendor"]);
118
- async function scanForContextFiles(projectRoot) {
144
+ async function scanForContextFiles(projectRoot, options = {}) {
145
+ const maxDepth = options.depth ?? 2;
146
+ const patterns = [...CONTEXT_FILE_PATTERNS, ...options.extraPatterns || []];
119
147
  const found = [];
120
148
  const seen = /* @__PURE__ */ new Set();
121
149
  const dirsToScan = [projectRoot];
122
- try {
123
- const entries = fs2.readdirSync(projectRoot, { withFileTypes: true });
124
- for (const entry of entries) {
125
- if (entry.isDirectory() && !IGNORED_DIRS2.has(entry.name) && !entry.name.startsWith(".")) {
126
- dirsToScan.push(path2.join(projectRoot, entry.name));
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
+ }
127
160
  }
161
+ } catch {
128
162
  }
129
- } catch {
130
163
  }
164
+ collectDirs(projectRoot, 0);
131
165
  for (const dir of dirsToScan) {
132
- for (const pattern of CONTEXT_FILE_PATTERNS) {
166
+ for (const pattern of patterns) {
133
167
  const matches = await glob(pattern, {
134
168
  cwd: dir,
135
169
  absolute: true,
@@ -570,6 +604,7 @@ import * as fs3 from "fs";
570
604
  import * as path4 from "path";
571
605
  var NPM_SCRIPT_PATTERN = /^(?:npm\s+run|pnpm(?:\s+run)?|yarn(?:\s+run)?|bun(?:\s+run)?)\s+(\S+)/;
572
606
  var MAKE_PATTERN = /^make\s+(\S+)/;
607
+ var NPX_PATTERN = /^npx\s+(\S+)/;
573
608
  async function checkCommands(file, projectRoot) {
574
609
  const issues = [];
575
610
  const pkgJson = loadPackageJson(projectRoot);
@@ -591,7 +626,9 @@ async function checkCommands(file, projectRoot) {
591
626
  }
592
627
  continue;
593
628
  }
594
- const shorthandMatch = cmd.match(/^(npm|pnpm|yarn|bun)\s+(test|start|build|dev|lint|format|check|typecheck|clean|serve|preview|e2e)\b/);
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
+ );
595
632
  if (shorthandMatch && pkgJson) {
596
633
  const scriptName = shorthandMatch[2];
597
634
  if (pkgJson.scripts && !(scriptName in pkgJson.scripts)) {
@@ -604,6 +641,30 @@ async function checkCommands(file, projectRoot) {
604
641
  }
605
642
  continue;
606
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
+ }
607
668
  const makeMatch = cmd.match(MAKE_PATTERN);
608
669
  if (makeMatch) {
609
670
  const target = makeMatch[1];
@@ -822,6 +883,26 @@ var PACKAGE_TECH_MAP = {
822
883
  cypress: ["Cypress"],
823
884
  puppeteer: ["Puppeteer"]
824
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
+ }
825
906
  async function checkRedundancy(file, projectRoot) {
826
907
  const issues = [];
827
908
  const pkgJson = loadPackageJson(projectRoot);
@@ -830,38 +911,28 @@ async function checkRedundancy(file, projectRoot) {
830
911
  ...Object.keys(pkgJson.dependencies || {}),
831
912
  ...Object.keys(pkgJson.devDependencies || {})
832
913
  ]);
914
+ const compiledPatterns = compilePatterns(allDeps);
833
915
  const lines2 = file.content.split("\n");
834
916
  for (let i = 0; i < lines2.length; i++) {
835
917
  const line = lines2[i];
836
- for (const [pkg, mentions] of Object.entries(PACKAGE_TECH_MAP)) {
837
- if (!allDeps.has(pkg)) continue;
838
- for (const mention of mentions) {
839
- const patterns = [
840
- new RegExp(
841
- `\\b(?:use|using|built with|powered by|written in)\\s+${escapeRegex(mention)}\\b`,
842
- "i"
843
- ),
844
- new RegExp(`\\bwe\\s+use\\s+${escapeRegex(mention)}\\b`, "i"),
845
- new RegExp(
846
- `\\b${escapeRegex(mention)}\\s+(?:project|app|application|codebase)\\b`,
847
- "i"
848
- ),
849
- new RegExp(`\\bThis is a\\s+${escapeRegex(mention)}\\b`, "i")
850
- ];
851
- for (const pattern of patterns) {
852
- if (pattern.test(line)) {
853
- const wastedTokens = countTokens(line.trim());
854
- issues.push({
855
- severity: "info",
856
- check: "redundancy",
857
- line: i + 1,
858
- message: `"${mention}" is in package.json ${pkgJson.dependencies?.[pkg] ? "dependencies" : "devDependencies"} \u2014 agent can infer this`,
859
- suggestion: `~${wastedTokens} tokens could be saved`
860
- });
861
- break;
862
- }
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;
863
924
  }
864
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
+ }
865
936
  }
866
937
  }
867
938
  }
@@ -923,6 +994,427 @@ function escapeRegex(str) {
923
994
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
924
995
  }
925
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
+
926
1418
  // src/core/reporter.ts
927
1419
  import chalk from "chalk";
928
1420
  function formatText(result, verbose = false) {
@@ -981,6 +1473,9 @@ function formatTokenReport(result) {
981
1473
  const lines = [];
982
1474
  lines.push("");
983
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
+ );
984
1479
  lines.push("");
985
1480
  const maxPathLen = Math.max(...result.files.map((f) => f.path.length), 4);
986
1481
  lines.push(
@@ -1007,6 +1502,100 @@ function formatTokenReport(result) {
1007
1502
  lines.push("");
1008
1503
  return lines.join("\n");
1009
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
+ }
1010
1599
  function formatIssue(issue) {
1011
1600
  const icon = issue.severity === "error" ? chalk.red("\u2717") : issue.severity === "warning" ? chalk.yellow("\u26A0") : chalk.blue("\u2139");
1012
1601
  const lineRef = issue.line > 0 ? `Line ${issue.line}: ` : "";
@@ -1086,23 +1675,32 @@ import * as path8 from "path";
1086
1675
 
1087
1676
  // src/version.ts
1088
1677
  function loadVersion() {
1089
- if (true) return "0.2.2";
1090
- const fs6 = __require("fs");
1678
+ if (true) return "0.3.0";
1679
+ const fs7 = __require("fs");
1091
1680
  const path9 = __require("path");
1092
1681
  const pkgPath = path9.resolve(__dirname, "../package.json");
1093
- const pkg = JSON.parse(fs6.readFileSync(pkgPath, "utf-8"));
1682
+ const pkg = JSON.parse(fs7.readFileSync(pkgPath, "utf-8"));
1094
1683
  return pkg.version;
1095
1684
  }
1096
1685
  var VERSION = loadVersion();
1097
1686
 
1098
1687
  // src/index.ts
1099
- var ALL_CHECKS = ["paths", "commands", "staleness", "tokens", "redundancy"];
1688
+ var ALL_CHECKS = [
1689
+ "paths",
1690
+ "commands",
1691
+ "staleness",
1692
+ "tokens",
1693
+ "redundancy",
1694
+ "contradictions",
1695
+ "frontmatter"
1696
+ ];
1100
1697
  var program = new Command();
1101
1698
  program.name("ctxlint").description(
1102
1699
  "Lint your AI agent context files (CLAUDE.md, AGENTS.md, etc.) against your actual codebase"
1103
- ).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 json", "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", "").action(async (projectPath, opts) => {
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) => {
1104
1701
  const resolvedPath = path8.resolve(projectPath);
1105
- const config = loadConfig(resolvedPath);
1702
+ const configPath = opts.config ? path8.resolve(opts.config) : void 0;
1703
+ const config = configPath ? loadConfigFromPath(configPath) : loadConfig(resolvedPath);
1106
1704
  const options = {
1107
1705
  projectPath: resolvedPath,
1108
1706
  checks: opts.checks ? opts.checks.split(",").map((c) => c.trim()) : config?.checks || ALL_CHECKS,
@@ -1111,29 +1709,46 @@ program.name("ctxlint").description(
1111
1709
  verbose: opts.verbose,
1112
1710
  fix: opts.fix,
1113
1711
  ignore: opts.ignore ? opts.ignore.split(",").map((c) => c.trim()) : config?.ignore || [],
1114
- tokensOnly: opts.tokens
1712
+ tokensOnly: opts.tokens,
1713
+ quiet: opts.quiet,
1714
+ depth: parseInt(opts.depth, 10) || 2
1115
1715
  };
1116
1716
  if (config?.tokenThresholds) {
1117
1717
  setTokenThresholds(config.tokenThresholds);
1118
1718
  }
1119
1719
  const activeChecks = options.checks.filter((c) => !options.ignore.includes(c));
1120
- 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;
1121
1721
  try {
1122
- const discovered = await scanForContextFiles(options.projectPath);
1722
+ const discovered = await scanForContextFiles(options.projectPath, {
1723
+ depth: options.depth,
1724
+ extraPatterns: config?.contextFiles
1725
+ });
1123
1726
  if (discovered.length === 0) {
1124
1727
  spinner?.stop();
1125
- if (options.format === "json") {
1126
- console.log(
1127
- JSON.stringify({
1128
- version: VERSION,
1129
- scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
1130
- projectRoot: options.projectPath,
1131
- files: [],
1132
- summary: { errors: 0, warnings: 0, info: 0, totalTokens: 0, estimatedWaste: 0 }
1133
- })
1134
- );
1135
- } else {
1136
- console.log("\nNo context files found.\n");
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
+ }
1137
1752
  }
1138
1753
  process.exit(0);
1139
1754
  }
@@ -1159,6 +1774,9 @@ program.name("ctxlint").description(
1159
1774
  if (activeChecks.includes("redundancy")) {
1160
1775
  issues.push(...await checkRedundancy(file, options.projectPath));
1161
1776
  }
1777
+ if (activeChecks.includes("frontmatter")) {
1778
+ issues.push(...await checkFrontmatter(file, options.projectPath));
1779
+ }
1162
1780
  fileResults.push({
1163
1781
  path: file.relativePath,
1164
1782
  isSymlink: file.isSymlink,
@@ -1182,6 +1800,12 @@ program.name("ctxlint").description(
1182
1800
  fileResults[0].issues.push(...dupIssues);
1183
1801
  }
1184
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
+ }
1185
1809
  let estimatedWaste = 0;
1186
1810
  for (const fr of fileResults) {
1187
1811
  for (const issue of fr.issues) {
@@ -1218,7 +1842,7 @@ program.name("ctxlint").description(
1218
1842
  spinner?.stop();
1219
1843
  if (options.fix) {
1220
1844
  const fixSummary = applyFixes(result);
1221
- if (fixSummary.totalFixes > 0) {
1845
+ if (fixSummary.totalFixes > 0 && !options.quiet) {
1222
1846
  console.log(
1223
1847
  `
1224
1848
  Fixed ${fixSummary.totalFixes} issue${fixSummary.totalFixes !== 1 ? "s" : ""} in ${fixSummary.filesModified.length} file${fixSummary.filesModified.length !== 1 ? "s" : ""}.
@@ -1226,12 +1850,16 @@ Fixed ${fixSummary.totalFixes} issue${fixSummary.totalFixes !== 1 ? "s" : ""} in
1226
1850
  );
1227
1851
  }
1228
1852
  }
1229
- if (options.tokensOnly) {
1230
- console.log(formatTokenReport(result));
1231
- } else if (options.format === "json") {
1232
- console.log(formatJson(result));
1233
- } else {
1234
- console.log(formatText(result, options.verbose));
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
+ }
1235
1863
  }
1236
1864
  if (options.strict && (result.summary.errors > 0 || result.summary.warnings > 0)) {
1237
1865
  process.exit(1);
@@ -1248,7 +1876,6 @@ Fixed ${fixSummary.totalFixes} issue${fixSummary.totalFixes !== 1 ? "s" : ""} in
1248
1876
  }
1249
1877
  });
1250
1878
  program.command("init").description("Set up a git pre-commit hook that runs ctxlint --strict").action(async () => {
1251
- const fs6 = await import("fs");
1252
1879
  const hooksDir = path8.resolve(".git", "hooks");
1253
1880
  if (!fs6.existsSync(path8.resolve(".git"))) {
1254
1881
  console.error('Error: not a git repository. Run "git init" first.');
@@ -1277,3 +1904,12 @@ npx @yawlabs/ctxlint --strict
1277
1904
  console.log("ctxlint will now run automatically before each commit.");
1278
1905
  });
1279
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
+ }