@yawlabs/ctxlint 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -75,11 +75,15 @@ Options:
75
75
  --strict Exit code 1 on any warning or error (for CI)
76
76
  --checks <list> Comma-separated: paths, commands, staleness, tokens, redundancy
77
77
  --ignore <list> Comma-separated checks to skip
78
+ --fix Auto-fix broken paths using git history and fuzzy matching
78
79
  --format json Output as JSON (for programmatic use)
79
80
  --tokens Show token breakdown per file
80
81
  --verbose Show passing checks too
81
82
  -V, --version Output the version number
82
83
  -h, --help Display help
84
+
85
+ Commands:
86
+ init Set up a git pre-commit hook
83
87
  ```
84
88
 
85
89
  ## Use in CI
@@ -91,6 +95,42 @@ Options:
91
95
 
92
96
  Exits with code 1 if any errors or warnings are found.
93
97
 
98
+ ## Auto-fix
99
+
100
+ ```bash
101
+ npx @yawlabs/ctxlint --fix
102
+ ```
103
+
104
+ When a broken path was renamed in git or has a close match in the project, `--fix` rewrites the context file automatically.
105
+
106
+ ## Pre-commit Hook
107
+
108
+ ```bash
109
+ npx @yawlabs/ctxlint init
110
+ ```
111
+
112
+ Sets up a git pre-commit hook that runs `ctxlint --strict` before each commit.
113
+
114
+ ## Config File
115
+
116
+ Create a `.ctxlintrc` or `.ctxlintrc.json` in your project root:
117
+
118
+ ```json
119
+ {
120
+ "checks": ["paths", "commands", "tokens"],
121
+ "ignore": ["redundancy"],
122
+ "strict": true,
123
+ "tokenThresholds": {
124
+ "info": 500,
125
+ "warning": 2000,
126
+ "error": 5000,
127
+ "aggregate": 4000
128
+ }
129
+ }
130
+ ```
131
+
132
+ CLI flags override config file settings.
133
+
94
134
  ## Use as MCP Server
95
135
 
96
136
  ctxlint ships with an MCP server that exposes three tools (`ctxlint_audit`, `ctxlint_validate_path`, `ctxlint_token_report`):
package/dist/index.js CHANGED
@@ -498,13 +498,16 @@ async function checkPaths(file, projectRoot) {
498
498
  }
499
499
  let suggestion;
500
500
  let detail;
501
+ let fixTarget;
501
502
  const rename = await findRenames(projectRoot, ref.value);
502
503
  if (rename) {
504
+ fixTarget = rename.newPath;
503
505
  suggestion = `Did you mean ${rename.newPath}?`;
504
506
  detail = `Renamed ${rename.daysAgo} days ago in commit ${rename.commitHash}`;
505
507
  } else {
506
508
  const match = findClosestMatch(normalizedRef, projectFiles);
507
509
  if (match) {
510
+ fixTarget = match;
508
511
  suggestion = `Did you mean ${match}?`;
509
512
  }
510
513
  }
@@ -514,7 +517,8 @@ async function checkPaths(file, projectRoot) {
514
517
  line: ref.line,
515
518
  message: `${ref.value} does not exist`,
516
519
  suggestion,
517
- detail
520
+ detail,
521
+ fix: fixTarget ? { file: file.filePath, line: ref.line, oldText: ref.value, newText: fixTarget } : void 0
518
522
  });
519
523
  }
520
524
  return issues;
@@ -703,13 +707,23 @@ async function checkStaleness(file, projectRoot) {
703
707
  }
704
708
 
705
709
  // src/core/checks/tokens.ts
706
- var INFO_THRESHOLD = 1e3;
707
- var WARNING_THRESHOLD = 3e3;
708
- var ERROR_THRESHOLD = 8e3;
710
+ var DEFAULT_THRESHOLDS = {
711
+ info: 1e3,
712
+ warning: 3e3,
713
+ error: 8e3,
714
+ aggregate: 5e3
715
+ };
716
+ var currentThresholds = DEFAULT_THRESHOLDS;
717
+ function setTokenThresholds(overrides) {
718
+ currentThresholds = { ...DEFAULT_THRESHOLDS, ...overrides };
719
+ }
720
+ function resetTokenThresholds() {
721
+ currentThresholds = DEFAULT_THRESHOLDS;
722
+ }
709
723
  async function checkTokens(file, _projectRoot) {
710
724
  const issues = [];
711
725
  const tokens = file.totalTokens;
712
- if (tokens >= ERROR_THRESHOLD) {
726
+ if (tokens >= currentThresholds.error) {
713
727
  issues.push({
714
728
  severity: "error",
715
729
  check: "tokens",
@@ -717,7 +731,7 @@ async function checkTokens(file, _projectRoot) {
717
731
  message: `${tokens.toLocaleString()} tokens \u2014 consumes significant context window space`,
718
732
  suggestion: "Consider splitting into focused sections or removing redundant content."
719
733
  });
720
- } else if (tokens >= WARNING_THRESHOLD) {
734
+ } else if (tokens >= currentThresholds.warning) {
721
735
  issues.push({
722
736
  severity: "warning",
723
737
  check: "tokens",
@@ -725,7 +739,7 @@ async function checkTokens(file, _projectRoot) {
725
739
  message: `${tokens.toLocaleString()} tokens \u2014 large context file`,
726
740
  suggestion: "Consider trimming \u2014 research shows diminishing returns past ~300 lines."
727
741
  });
728
- } else if (tokens >= INFO_THRESHOLD) {
742
+ } else if (tokens >= currentThresholds.info) {
729
743
  issues.push({
730
744
  severity: "info",
731
745
  check: "tokens",
@@ -737,7 +751,7 @@ async function checkTokens(file, _projectRoot) {
737
751
  }
738
752
  function checkAggregateTokens(files) {
739
753
  const total = files.reduce((sum, f) => sum + f.tokens, 0);
740
- if (total > 5e3 && files.length > 1) {
754
+ if (total > currentThresholds.aggregate && files.length > 1) {
741
755
  return {
742
756
  severity: "warning",
743
757
  check: "tokens",
@@ -1011,16 +1025,75 @@ function formatIssue(issue) {
1011
1025
  return line;
1012
1026
  }
1013
1027
 
1014
- // src/index.ts
1028
+ // src/core/fixer.ts
1029
+ import * as fs5 from "fs";
1030
+ import chalk2 from "chalk";
1031
+ function applyFixes(result) {
1032
+ const fixesByFile = /* @__PURE__ */ new Map();
1033
+ for (const file of result.files) {
1034
+ for (const issue of file.issues) {
1035
+ if (issue.fix) {
1036
+ const existing = fixesByFile.get(issue.fix.file) || [];
1037
+ existing.push(issue.fix);
1038
+ fixesByFile.set(issue.fix.file, existing);
1039
+ }
1040
+ }
1041
+ }
1042
+ let totalFixes = 0;
1043
+ const filesModified = [];
1044
+ for (const [filePath, fixes] of fixesByFile) {
1045
+ const content = fs5.readFileSync(filePath, "utf-8");
1046
+ const lines = content.split("\n");
1047
+ let modified = false;
1048
+ const sortedFixes = [...fixes].sort((a, b) => b.line - a.line);
1049
+ for (const fix of sortedFixes) {
1050
+ const lineIdx = fix.line - 1;
1051
+ if (lineIdx < 0 || lineIdx >= lines.length) continue;
1052
+ const line = lines[lineIdx];
1053
+ if (line.includes(fix.oldText)) {
1054
+ lines[lineIdx] = line.replace(fix.oldText, fix.newText);
1055
+ modified = true;
1056
+ totalFixes++;
1057
+ console.log(
1058
+ chalk2.green(" Fixed") + ` Line ${fix.line}: ${chalk2.dim(fix.oldText)} ${chalk2.dim("\u2192")} ${fix.newText}`
1059
+ );
1060
+ }
1061
+ }
1062
+ if (modified) {
1063
+ fs5.writeFileSync(filePath, lines.join("\n"), "utf-8");
1064
+ filesModified.push(filePath);
1065
+ }
1066
+ }
1067
+ return { totalFixes, filesModified };
1068
+ }
1069
+
1070
+ // src/core/config.ts
1071
+ import * as fs6 from "fs";
1015
1072
  import * as path7 from "path";
1073
+ var CONFIG_FILENAMES = [".ctxlintrc", ".ctxlintrc.json"];
1074
+ function loadConfig(projectRoot) {
1075
+ for (const filename of CONFIG_FILENAMES) {
1076
+ const filePath = path7.join(projectRoot, filename);
1077
+ try {
1078
+ const content = fs6.readFileSync(filePath, "utf-8");
1079
+ return JSON.parse(content);
1080
+ } catch {
1081
+ continue;
1082
+ }
1083
+ }
1084
+ return null;
1085
+ }
1086
+
1087
+ // src/index.ts
1088
+ import * as path8 from "path";
1016
1089
 
1017
1090
  // src/version.ts
1018
1091
  function loadVersion() {
1019
- if (true) return "0.1.1";
1020
- const fs5 = __require("fs");
1021
- const path8 = __require("path");
1022
- const pkgPath = path8.resolve(__dirname, "../package.json");
1023
- const pkg = JSON.parse(fs5.readFileSync(pkgPath, "utf-8"));
1092
+ if (true) return "0.2.1";
1093
+ const fs7 = __require("fs");
1094
+ const path9 = __require("path");
1095
+ const pkgPath = path9.resolve(__dirname, "../package.json");
1096
+ const pkg = JSON.parse(fs7.readFileSync(pkgPath, "utf-8"));
1024
1097
  return pkg.version;
1025
1098
  }
1026
1099
  var VERSION = loadVersion();
@@ -1030,18 +1103,22 @@ var ALL_CHECKS = ["paths", "commands", "staleness", "tokens", "redundancy"];
1030
1103
  var program = new Command();
1031
1104
  program.name("ctxlint").description(
1032
1105
  "Lint your AI agent context files (CLAUDE.md, AGENTS.md, etc.) against your actual codebase"
1033
- ).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("--ignore <checks>", "Comma-separated list of checks to ignore", "").action(async (projectPath, opts) => {
1034
- const resolvedPath = path7.resolve(projectPath);
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) => {
1107
+ const resolvedPath = path8.resolve(projectPath);
1108
+ const config = loadConfig(resolvedPath);
1035
1109
  const options = {
1036
1110
  projectPath: resolvedPath,
1037
- checks: opts.checks ? opts.checks.split(",").map((c) => c.trim()) : ALL_CHECKS,
1038
- strict: opts.strict,
1111
+ checks: opts.checks ? opts.checks.split(",").map((c) => c.trim()) : config?.checks || ALL_CHECKS,
1112
+ strict: opts.strict || config?.strict || false,
1039
1113
  format: opts.format,
1040
1114
  verbose: opts.verbose,
1041
- fix: false,
1042
- ignore: opts.ignore ? opts.ignore.split(",").map((c) => c.trim()) : [],
1115
+ fix: opts.fix,
1116
+ ignore: opts.ignore ? opts.ignore.split(",").map((c) => c.trim()) : config?.ignore || [],
1043
1117
  tokensOnly: opts.tokens
1044
1118
  };
1119
+ if (config?.tokenThresholds) {
1120
+ setTokenThresholds(config.tokenThresholds);
1121
+ }
1045
1122
  const activeChecks = options.checks.filter((c) => !options.ignore.includes(c));
1046
1123
  const spinner = options.format === "text" ? ora("Scanning for context files...").start() : void 0;
1047
1124
  try {
@@ -1142,6 +1219,16 @@ program.name("ctxlint").description(
1142
1219
  }
1143
1220
  };
1144
1221
  spinner?.stop();
1222
+ if (options.fix) {
1223
+ const fixSummary = applyFixes(result);
1224
+ if (fixSummary.totalFixes > 0) {
1225
+ console.log(
1226
+ `
1227
+ Fixed ${fixSummary.totalFixes} issue${fixSummary.totalFixes !== 1 ? "s" : ""} in ${fixSummary.filesModified.length} file${fixSummary.filesModified.length !== 1 ? "s" : ""}.
1228
+ `
1229
+ );
1230
+ }
1231
+ }
1145
1232
  if (options.tokensOnly) {
1146
1233
  console.log(formatTokenReport(result));
1147
1234
  } else if (options.format === "json") {
@@ -1160,6 +1247,36 @@ program.name("ctxlint").description(
1160
1247
  freeEncoder();
1161
1248
  resetGit();
1162
1249
  resetPathsCache();
1250
+ resetTokenThresholds();
1251
+ }
1252
+ });
1253
+ program.command("init").description("Set up a git pre-commit hook that runs ctxlint --strict").action(async () => {
1254
+ const fs7 = await import("fs");
1255
+ const hooksDir = path8.resolve(".git", "hooks");
1256
+ if (!fs7.existsSync(path8.resolve(".git"))) {
1257
+ console.error('Error: not a git repository. Run "git init" first.');
1258
+ process.exit(1);
1259
+ }
1260
+ if (!fs7.existsSync(hooksDir)) {
1261
+ fs7.mkdirSync(hooksDir, { recursive: true });
1262
+ }
1263
+ const hookPath = path8.join(hooksDir, "pre-commit");
1264
+ const hookContent = `#!/bin/sh
1265
+ # ctxlint pre-commit hook
1266
+ npx @yawlabs/ctxlint --strict
1267
+ `;
1268
+ if (fs7.existsSync(hookPath)) {
1269
+ const existing = fs7.readFileSync(hookPath, "utf-8");
1270
+ if (existing.includes("ctxlint")) {
1271
+ console.log("Pre-commit hook already includes ctxlint.");
1272
+ return;
1273
+ }
1274
+ fs7.appendFileSync(hookPath, "\n" + hookContent);
1275
+ console.log("Added ctxlint to existing pre-commit hook.");
1276
+ } else {
1277
+ fs7.writeFileSync(hookPath, hookContent, { mode: 493 });
1278
+ console.log("Created pre-commit hook at .git/hooks/pre-commit");
1163
1279
  }
1280
+ console.log("ctxlint will now run automatically before each commit.");
1164
1281
  });
1165
1282
  program.parse();
@@ -495,13 +495,16 @@ async function checkPaths(file, projectRoot) {
495
495
  }
496
496
  let suggestion;
497
497
  let detail;
498
+ let fixTarget;
498
499
  const rename = await findRenames(projectRoot, ref.value);
499
500
  if (rename) {
501
+ fixTarget = rename.newPath;
500
502
  suggestion = `Did you mean ${rename.newPath}?`;
501
503
  detail = `Renamed ${rename.daysAgo} days ago in commit ${rename.commitHash}`;
502
504
  } else {
503
505
  const match = findClosestMatch(normalizedRef, projectFiles);
504
506
  if (match) {
507
+ fixTarget = match;
505
508
  suggestion = `Did you mean ${match}?`;
506
509
  }
507
510
  }
@@ -511,7 +514,8 @@ async function checkPaths(file, projectRoot) {
511
514
  line: ref.line,
512
515
  message: `${ref.value} does not exist`,
513
516
  suggestion,
514
- detail
517
+ detail,
518
+ fix: fixTarget ? { file: file.filePath, line: ref.line, oldText: ref.value, newText: fixTarget } : void 0
515
519
  });
516
520
  }
517
521
  return issues;
@@ -700,13 +704,17 @@ async function checkStaleness(file, projectRoot) {
700
704
  }
701
705
 
702
706
  // src/core/checks/tokens.ts
703
- var INFO_THRESHOLD = 1e3;
704
- var WARNING_THRESHOLD = 3e3;
705
- var ERROR_THRESHOLD = 8e3;
707
+ var DEFAULT_THRESHOLDS = {
708
+ info: 1e3,
709
+ warning: 3e3,
710
+ error: 8e3,
711
+ aggregate: 5e3
712
+ };
713
+ var currentThresholds = DEFAULT_THRESHOLDS;
706
714
  async function checkTokens(file, _projectRoot) {
707
715
  const issues = [];
708
716
  const tokens = file.totalTokens;
709
- if (tokens >= ERROR_THRESHOLD) {
717
+ if (tokens >= currentThresholds.error) {
710
718
  issues.push({
711
719
  severity: "error",
712
720
  check: "tokens",
@@ -714,7 +722,7 @@ async function checkTokens(file, _projectRoot) {
714
722
  message: `${tokens.toLocaleString()} tokens \u2014 consumes significant context window space`,
715
723
  suggestion: "Consider splitting into focused sections or removing redundant content."
716
724
  });
717
- } else if (tokens >= WARNING_THRESHOLD) {
725
+ } else if (tokens >= currentThresholds.warning) {
718
726
  issues.push({
719
727
  severity: "warning",
720
728
  check: "tokens",
@@ -722,7 +730,7 @@ async function checkTokens(file, _projectRoot) {
722
730
  message: `${tokens.toLocaleString()} tokens \u2014 large context file`,
723
731
  suggestion: "Consider trimming \u2014 research shows diminishing returns past ~300 lines."
724
732
  });
725
- } else if (tokens >= INFO_THRESHOLD) {
733
+ } else if (tokens >= currentThresholds.info) {
726
734
  issues.push({
727
735
  severity: "info",
728
736
  check: "tokens",
@@ -734,7 +742,7 @@ async function checkTokens(file, _projectRoot) {
734
742
  }
735
743
  function checkAggregateTokens(files) {
736
744
  const total = files.reduce((sum, f) => sum + f.tokens, 0);
737
- if (total > 5e3 && files.length > 1) {
745
+ if (total > currentThresholds.aggregate && files.length > 1) {
738
746
  return {
739
747
  severity: "warning",
740
748
  check: "tokens",
@@ -914,7 +922,7 @@ import * as path7 from "path";
914
922
 
915
923
  // src/version.ts
916
924
  function loadVersion() {
917
- if (true) return "0.1.1";
925
+ if (true) return "0.2.1";
918
926
  const fs5 = __require("fs");
919
927
  const path8 = __require("path");
920
928
  const pkgPath = path8.resolve(__dirname, "../package.json");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ctxlint",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Lint your AI agent context files (CLAUDE.md, AGENTS.md, etc.) against your actual codebase",
5
5
  "bin": {
6
6
  "ctxlint": "dist/index.js"