@yawlabs/ctxlint 0.1.1 → 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 +40 -0
- package/dist/index.js +137 -20
- package/dist/mcp/server.js +17 -9
- package/package.json +1 -1
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
|
|
707
|
-
|
|
708
|
-
|
|
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 >=
|
|
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 >=
|
|
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 >=
|
|
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 >
|
|
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/
|
|
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.
|
|
1020
|
-
const
|
|
1021
|
-
const
|
|
1022
|
-
const pkgPath =
|
|
1023
|
-
const pkg = JSON.parse(
|
|
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"));
|
|
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 =
|
|
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:
|
|
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();
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
|
704
|
-
|
|
705
|
-
|
|
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 >=
|
|
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 >=
|
|
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 >=
|
|
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 >
|
|
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.
|
|
925
|
+
if (true) return "0.2.0";
|
|
918
926
|
const fs5 = __require("fs");
|
|
919
927
|
const path8 = __require("path");
|
|
920
928
|
const pkgPath = path8.resolve(__dirname, "../package.json");
|