@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/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
- "copilot-instructions.md",
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
- "JULES.md",
119
+ // Cline
100
120
  ".clinerules",
101
- "CONVENTIONS.md"
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
- try {
109
- const entries = fs2.readdirSync(projectRoot, { withFileTypes: true });
110
- for (const entry of entries) {
111
- if (entry.isDirectory() && !IGNORED_DIRS2.has(entry.name) && !entry.name.startsWith(".")) {
112
- 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
+ }
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 CONTEXT_FILE_PATTERNS) {
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(/^(npm|pnpm|yarn)\s+(test|start|build|dev|lint|format)\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
+ );
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 = loadPackageJson2(projectRoot);
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 [pkg, mentions] of Object.entries(PACKAGE_TECH_MAP)) {
832
- if (!allDeps.has(pkg)) continue;
833
- for (const mention of mentions) {
834
- const patterns = [
835
- new RegExp(
836
- `\\b(?:use|using|built with|powered by|written in)\\s+${escapeRegex(mention)}\\b`,
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 fs5 from "fs";
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 = fs5.readFileSync(filePath, "utf-8");
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
- fs5.writeFileSync(filePath, lines.join("\n"), "utf-8");
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 fs6 from "fs";
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 = fs6.readFileSync(filePath, "utf-8");
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.2.1";
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 = ["paths", "commands", "staleness", "tokens", "redundancy"];
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 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) => {
1107
1701
  const resolvedPath = path8.resolve(projectPath);
1108
- const config = loadConfig(resolvedPath);
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.format === "json") {
1129
- console.log(
1130
- JSON.stringify({
1131
- version: VERSION,
1132
- scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
1133
- projectRoot: options.projectPath,
1134
- files: [],
1135
- summary: { errors: 0, warnings: 0, info: 0, totalTokens: 0, estimatedWaste: 0 }
1136
- })
1137
- );
1138
- } else {
1139
- 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
+ }
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.tokensOnly) {
1233
- console.log(formatTokenReport(result));
1234
- } else if (options.format === "json") {
1235
- console.log(formatJson(result));
1236
- } else {
1237
- 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
+ }
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 (!fs7.existsSync(path8.resolve(".git"))) {
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 (!fs7.existsSync(hooksDir)) {
1261
- fs7.mkdirSync(hooksDir, { recursive: true });
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 (fs7.existsSync(hookPath)) {
1269
- const existing = fs7.readFileSync(hookPath, "utf-8");
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
- fs7.appendFileSync(hookPath, "\n" + hookContent);
1898
+ fs6.appendFileSync(hookPath, "\n" + hookContent);
1275
1899
  console.log("Added ctxlint to existing pre-commit hook.");
1276
1900
  } else {
1277
- fs7.writeFileSync(hookPath, hookContent, { mode: 493 });
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
+ }