@yawlabs/ctxlint 0.1.0 → 0.2.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 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
@@ -1,4 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
3
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
4
+ }) : x)(function(x) {
5
+ if (typeof require !== "undefined") return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
2
8
 
3
9
  // src/index.ts
4
10
  import { Command } from "commander";
@@ -492,13 +498,16 @@ async function checkPaths(file, projectRoot) {
492
498
  }
493
499
  let suggestion;
494
500
  let detail;
501
+ let fixTarget;
495
502
  const rename = await findRenames(projectRoot, ref.value);
496
503
  if (rename) {
504
+ fixTarget = rename.newPath;
497
505
  suggestion = `Did you mean ${rename.newPath}?`;
498
506
  detail = `Renamed ${rename.daysAgo} days ago in commit ${rename.commitHash}`;
499
507
  } else {
500
508
  const match = findClosestMatch(normalizedRef, projectFiles);
501
509
  if (match) {
510
+ fixTarget = match;
502
511
  suggestion = `Did you mean ${match}?`;
503
512
  }
504
513
  }
@@ -508,7 +517,8 @@ async function checkPaths(file, projectRoot) {
508
517
  line: ref.line,
509
518
  message: `${ref.value} does not exist`,
510
519
  suggestion,
511
- detail
520
+ detail,
521
+ fix: fixTarget ? { file: file.filePath, line: ref.line, oldText: ref.value, newText: fixTarget } : void 0
512
522
  });
513
523
  }
514
524
  return issues;
@@ -697,13 +707,23 @@ async function checkStaleness(file, projectRoot) {
697
707
  }
698
708
 
699
709
  // src/core/checks/tokens.ts
700
- var INFO_THRESHOLD = 1e3;
701
- var WARNING_THRESHOLD = 3e3;
702
- 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
+ }
703
723
  async function checkTokens(file, _projectRoot) {
704
724
  const issues = [];
705
725
  const tokens = file.totalTokens;
706
- if (tokens >= ERROR_THRESHOLD) {
726
+ if (tokens >= currentThresholds.error) {
707
727
  issues.push({
708
728
  severity: "error",
709
729
  check: "tokens",
@@ -711,7 +731,7 @@ async function checkTokens(file, _projectRoot) {
711
731
  message: `${tokens.toLocaleString()} tokens \u2014 consumes significant context window space`,
712
732
  suggestion: "Consider splitting into focused sections or removing redundant content."
713
733
  });
714
- } else if (tokens >= WARNING_THRESHOLD) {
734
+ } else if (tokens >= currentThresholds.warning) {
715
735
  issues.push({
716
736
  severity: "warning",
717
737
  check: "tokens",
@@ -719,7 +739,7 @@ async function checkTokens(file, _projectRoot) {
719
739
  message: `${tokens.toLocaleString()} tokens \u2014 large context file`,
720
740
  suggestion: "Consider trimming \u2014 research shows diminishing returns past ~300 lines."
721
741
  });
722
- } else if (tokens >= INFO_THRESHOLD) {
742
+ } else if (tokens >= currentThresholds.info) {
723
743
  issues.push({
724
744
  severity: "info",
725
745
  check: "tokens",
@@ -731,7 +751,7 @@ async function checkTokens(file, _projectRoot) {
731
751
  }
732
752
  function checkAggregateTokens(files) {
733
753
  const total = files.reduce((sum, f) => sum + f.tokens, 0);
734
- if (total > 5e3 && files.length > 1) {
754
+ if (total > currentThresholds.aggregate && files.length > 1) {
735
755
  return {
736
756
  severity: "warning",
737
757
  check: "tokens",
@@ -1005,25 +1025,100 @@ function formatIssue(issue) {
1005
1025
  return line;
1006
1026
  }
1007
1027
 
1008
- // 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";
1009
1072
  import * as path7 from "path";
1010
- var VERSION = "0.1.0";
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";
1089
+
1090
+ // src/version.ts
1091
+ function loadVersion() {
1092
+ if (true) return "0.2.0";
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"));
1097
+ return pkg.version;
1098
+ }
1099
+ var VERSION = loadVersion();
1100
+
1101
+ // src/index.ts
1011
1102
  var ALL_CHECKS = ["paths", "commands", "staleness", "tokens", "redundancy"];
1012
1103
  var program = new Command();
1013
1104
  program.name("ctxlint").description(
1014
1105
  "Lint your AI agent context files (CLAUDE.md, AGENTS.md, etc.) against your actual codebase"
1015
- ).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) => {
1016
- 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);
1017
1109
  const options = {
1018
1110
  projectPath: resolvedPath,
1019
- checks: opts.checks ? opts.checks.split(",").map((c) => c.trim()) : ALL_CHECKS,
1020
- 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,
1021
1113
  format: opts.format,
1022
1114
  verbose: opts.verbose,
1023
- fix: false,
1024
- 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 || [],
1025
1117
  tokensOnly: opts.tokens
1026
1118
  };
1119
+ if (config?.tokenThresholds) {
1120
+ setTokenThresholds(config.tokenThresholds);
1121
+ }
1027
1122
  const activeChecks = options.checks.filter((c) => !options.ignore.includes(c));
1028
1123
  const spinner = options.format === "text" ? ora("Scanning for context files...").start() : void 0;
1029
1124
  try {
@@ -1124,6 +1219,16 @@ program.name("ctxlint").description(
1124
1219
  }
1125
1220
  };
1126
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
+ }
1127
1232
  if (options.tokensOnly) {
1128
1233
  console.log(formatTokenReport(result));
1129
1234
  } else if (options.format === "json") {
@@ -1142,6 +1247,36 @@ program.name("ctxlint").description(
1142
1247
  freeEncoder();
1143
1248
  resetGit();
1144
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");
1145
1279
  }
1280
+ console.log("ctxlint will now run automatically before each commit.");
1146
1281
  });
1147
1282
  program.parse();
@@ -1,3 +1,10 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
1
8
  // src/mcp/server.ts
2
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -488,13 +495,16 @@ async function checkPaths(file, projectRoot) {
488
495
  }
489
496
  let suggestion;
490
497
  let detail;
498
+ let fixTarget;
491
499
  const rename = await findRenames(projectRoot, ref.value);
492
500
  if (rename) {
501
+ fixTarget = rename.newPath;
493
502
  suggestion = `Did you mean ${rename.newPath}?`;
494
503
  detail = `Renamed ${rename.daysAgo} days ago in commit ${rename.commitHash}`;
495
504
  } else {
496
505
  const match = findClosestMatch(normalizedRef, projectFiles);
497
506
  if (match) {
507
+ fixTarget = match;
498
508
  suggestion = `Did you mean ${match}?`;
499
509
  }
500
510
  }
@@ -504,7 +514,8 @@ async function checkPaths(file, projectRoot) {
504
514
  line: ref.line,
505
515
  message: `${ref.value} does not exist`,
506
516
  suggestion,
507
- detail
517
+ detail,
518
+ fix: fixTarget ? { file: file.filePath, line: ref.line, oldText: ref.value, newText: fixTarget } : void 0
508
519
  });
509
520
  }
510
521
  return issues;
@@ -693,13 +704,17 @@ async function checkStaleness(file, projectRoot) {
693
704
  }
694
705
 
695
706
  // src/core/checks/tokens.ts
696
- var INFO_THRESHOLD = 1e3;
697
- var WARNING_THRESHOLD = 3e3;
698
- 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;
699
714
  async function checkTokens(file, _projectRoot) {
700
715
  const issues = [];
701
716
  const tokens = file.totalTokens;
702
- if (tokens >= ERROR_THRESHOLD) {
717
+ if (tokens >= currentThresholds.error) {
703
718
  issues.push({
704
719
  severity: "error",
705
720
  check: "tokens",
@@ -707,7 +722,7 @@ async function checkTokens(file, _projectRoot) {
707
722
  message: `${tokens.toLocaleString()} tokens \u2014 consumes significant context window space`,
708
723
  suggestion: "Consider splitting into focused sections or removing redundant content."
709
724
  });
710
- } else if (tokens >= WARNING_THRESHOLD) {
725
+ } else if (tokens >= currentThresholds.warning) {
711
726
  issues.push({
712
727
  severity: "warning",
713
728
  check: "tokens",
@@ -715,7 +730,7 @@ async function checkTokens(file, _projectRoot) {
715
730
  message: `${tokens.toLocaleString()} tokens \u2014 large context file`,
716
731
  suggestion: "Consider trimming \u2014 research shows diminishing returns past ~300 lines."
717
732
  });
718
- } else if (tokens >= INFO_THRESHOLD) {
733
+ } else if (tokens >= currentThresholds.info) {
719
734
  issues.push({
720
735
  severity: "info",
721
736
  check: "tokens",
@@ -727,7 +742,7 @@ async function checkTokens(file, _projectRoot) {
727
742
  }
728
743
  function checkAggregateTokens(files) {
729
744
  const total = files.reduce((sum, f) => sum + f.tokens, 0);
730
- if (total > 5e3 && files.length > 1) {
745
+ if (total > currentThresholds.aggregate && files.length > 1) {
731
746
  return {
732
747
  severity: "warning",
733
748
  check: "tokens",
@@ -904,7 +919,19 @@ function escapeRegex(str) {
904
919
 
905
920
  // src/mcp/server.ts
906
921
  import * as path7 from "path";
907
- var VERSION = "0.1.0";
922
+
923
+ // src/version.ts
924
+ function loadVersion() {
925
+ if (true) return "0.2.0";
926
+ const fs5 = __require("fs");
927
+ const path8 = __require("path");
928
+ const pkgPath = path8.resolve(__dirname, "../package.json");
929
+ const pkg = JSON.parse(fs5.readFileSync(pkgPath, "utf-8"));
930
+ return pkg.version;
931
+ }
932
+ var VERSION = loadVersion();
933
+
934
+ // src/mcp/server.ts
908
935
  var ALL_CHECKS = ["paths", "commands", "staleness", "tokens", "redundancy"];
909
936
  var server = new McpServer({
910
937
  name: "ctxlint",
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@yawlabs/ctxlint",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Lint your AI agent context files (CLAUDE.md, AGENTS.md, etc.) against your actual codebase",
5
5
  "bin": {
6
- "ctxlint": "./dist/index.js"
6
+ "ctxlint": "dist/index.js"
7
7
  },
8
8
  "type": "module",
9
9
  "scripts": {
@@ -34,7 +34,7 @@
34
34
  "license": "MIT",
35
35
  "repository": {
36
36
  "type": "git",
37
- "url": "https://github.com/yawlabs/ctxlint"
37
+ "url": "git+https://github.com/yawlabs/ctxlint.git"
38
38
  },
39
39
  "homepage": "https://github.com/yawlabs/ctxlint",
40
40
  "author": "Yaw Labs <contact@yaw.sh>",
@@ -69,4 +69,4 @@
69
69
  "esbuild"
70
70
  ]
71
71
  }
72
- }
72
+ }