@yawlabs/ctxlint 0.2.2 → 0.4.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.
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ ALL_CHECKS,
4
+ VERSION,
5
+ applyFixes,
6
+ freeEncoder,
7
+ resetGit,
8
+ resetPathsCache,
9
+ resetTokenThresholds,
10
+ runAudit,
11
+ setTokenThresholds
12
+ } from "./chunk-WEYNMCAH.js";
13
+
14
+ // src/cli.ts
15
+ import * as fs2 from "fs";
16
+ import { Command } from "commander";
17
+ import ora from "ora";
18
+
19
+ // src/core/reporter.ts
20
+ import chalk from "chalk";
21
+ function formatText(result, verbose = false) {
22
+ const lines = [];
23
+ lines.push("");
24
+ lines.push(chalk.bold(`ctxlint v${result.version}`));
25
+ lines.push("");
26
+ lines.push(`Scanning ${result.projectRoot}...`);
27
+ lines.push("");
28
+ const totalTokens = result.summary.totalTokens;
29
+ lines.push(
30
+ `Found ${result.files.length} context file${result.files.length !== 1 ? "s" : ""} (${totalTokens.toLocaleString()} tokens total)`
31
+ );
32
+ for (const file of result.files) {
33
+ let desc = ` ${file.path} (${file.tokens.toLocaleString()} tokens, ${file.lines} lines)`;
34
+ if (file.isSymlink && file.symlinkTarget) {
35
+ desc = ` ${file.path} ${chalk.dim(`\u2192 ${file.symlinkTarget} (symlink)`)}`;
36
+ }
37
+ lines.push(desc);
38
+ }
39
+ lines.push("");
40
+ for (const file of result.files) {
41
+ const fileIssues = file.issues;
42
+ if (fileIssues.length === 0 && !verbose) continue;
43
+ lines.push(chalk.underline(file.path));
44
+ if (fileIssues.length === 0) {
45
+ lines.push(chalk.green(" \u2713 All checks passed"));
46
+ } else {
47
+ for (const issue of fileIssues) {
48
+ lines.push(formatIssue(issue));
49
+ }
50
+ }
51
+ lines.push("");
52
+ }
53
+ const { errors, warnings, info } = result.summary;
54
+ const parts = [];
55
+ if (errors > 0) parts.push(chalk.red(`${errors} error${errors !== 1 ? "s" : ""}`));
56
+ if (warnings > 0) parts.push(chalk.yellow(`${warnings} warning${warnings !== 1 ? "s" : ""}`));
57
+ if (info > 0) parts.push(chalk.blue(`${info} info`));
58
+ if (parts.length > 0) {
59
+ lines.push(`Summary: ${parts.join(", ")}`);
60
+ } else {
61
+ lines.push(chalk.green("No issues found!"));
62
+ }
63
+ lines.push(` Token usage: ${totalTokens.toLocaleString()} tokens per agent session`);
64
+ if (result.summary.estimatedWaste > 0) {
65
+ lines.push(` Estimated waste: ~${result.summary.estimatedWaste} tokens (redundant content)`);
66
+ }
67
+ lines.push("");
68
+ return lines.join("\n");
69
+ }
70
+ function formatJson(result) {
71
+ return JSON.stringify(result, null, 2);
72
+ }
73
+ function formatTokenReport(result) {
74
+ const lines = [];
75
+ lines.push("");
76
+ lines.push(chalk.bold("Token Usage Report"));
77
+ lines.push(
78
+ chalk.dim(" (counts use GPT-4 cl100k_base tokenizer \u2014 Claude counts may vary slightly)")
79
+ );
80
+ lines.push("");
81
+ const maxPathLen = Math.max(...result.files.map((f) => f.path.length), 4);
82
+ lines.push(
83
+ ` ${chalk.dim("File".padEnd(maxPathLen))} ${chalk.dim("Tokens".padStart(8))} ${chalk.dim("Lines".padStart(6))}`
84
+ );
85
+ lines.push(` ${"\u2500".repeat(maxPathLen)} ${"\u2500".repeat(8)} ${"\u2500".repeat(6)}`);
86
+ for (const file of result.files) {
87
+ const tokenStr = file.tokens.toLocaleString().padStart(8);
88
+ const lineStr = file.lines.toString().padStart(6);
89
+ lines.push(` ${file.path.padEnd(maxPathLen)} ${tokenStr} ${lineStr}`);
90
+ }
91
+ lines.push(` ${"\u2500".repeat(maxPathLen)} ${"\u2500".repeat(8)} ${"\u2500".repeat(6)}`);
92
+ lines.push(
93
+ ` ${"Total".padEnd(maxPathLen)} ${result.summary.totalTokens.toLocaleString().padStart(8)}`
94
+ );
95
+ if (result.summary.estimatedWaste > 0) {
96
+ lines.push("");
97
+ lines.push(
98
+ chalk.yellow(
99
+ ` ~${result.summary.estimatedWaste} tokens estimated waste from redundant content`
100
+ )
101
+ );
102
+ }
103
+ lines.push("");
104
+ return lines.join("\n");
105
+ }
106
+ function formatSarif(result) {
107
+ const severityToLevel = {
108
+ error: "error",
109
+ warning: "warning",
110
+ info: "note"
111
+ };
112
+ const results = [];
113
+ for (const file of result.files) {
114
+ for (const issue of file.issues) {
115
+ const sarifResult = {
116
+ ruleId: `ctxlint/${issue.check}`,
117
+ level: severityToLevel[issue.severity] || "note",
118
+ message: {
119
+ text: issue.message + (issue.suggestion ? ` (${issue.suggestion})` : "")
120
+ },
121
+ locations: [
122
+ {
123
+ physicalLocation: {
124
+ artifactLocation: {
125
+ uri: file.path,
126
+ uriBaseId: "%SRCROOT%"
127
+ },
128
+ region: {
129
+ startLine: Math.max(issue.line, 1)
130
+ }
131
+ }
132
+ }
133
+ ]
134
+ };
135
+ if (issue.detail) {
136
+ sarifResult.message.text += `
137
+ ${issue.detail}`;
138
+ }
139
+ results.push(sarifResult);
140
+ }
141
+ }
142
+ const sarif = {
143
+ version: "2.1.0",
144
+ $schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
145
+ runs: [
146
+ {
147
+ tool: {
148
+ driver: {
149
+ name: "ctxlint",
150
+ version: result.version,
151
+ informationUri: "https://github.com/yawlabs/ctxlint",
152
+ rules: buildRuleDescriptors()
153
+ }
154
+ },
155
+ results
156
+ }
157
+ ]
158
+ };
159
+ return JSON.stringify(sarif, null, 2);
160
+ }
161
+ function buildRuleDescriptors() {
162
+ return [
163
+ {
164
+ id: "ctxlint/paths",
165
+ shortDescription: { text: "File path does not exist in project" },
166
+ helpUri: "https://github.com/yawlabs/ctxlint#what-it-checks"
167
+ },
168
+ {
169
+ id: "ctxlint/commands",
170
+ shortDescription: { text: "Command does not match project scripts" },
171
+ helpUri: "https://github.com/yawlabs/ctxlint#what-it-checks"
172
+ },
173
+ {
174
+ id: "ctxlint/staleness",
175
+ shortDescription: { text: "Context file is stale relative to referenced code" },
176
+ helpUri: "https://github.com/yawlabs/ctxlint#what-it-checks"
177
+ },
178
+ {
179
+ id: "ctxlint/tokens",
180
+ shortDescription: { text: "Context file token usage" },
181
+ helpUri: "https://github.com/yawlabs/ctxlint#what-it-checks"
182
+ },
183
+ {
184
+ id: "ctxlint/redundancy",
185
+ shortDescription: { text: "Content is redundant or inferable" },
186
+ helpUri: "https://github.com/yawlabs/ctxlint#what-it-checks"
187
+ },
188
+ {
189
+ id: "ctxlint/contradictions",
190
+ shortDescription: { text: "Conflicting directives across context files" },
191
+ helpUri: "https://github.com/yawlabs/ctxlint#what-it-checks"
192
+ },
193
+ {
194
+ id: "ctxlint/frontmatter",
195
+ shortDescription: { text: "Invalid or missing frontmatter" },
196
+ helpUri: "https://github.com/yawlabs/ctxlint#what-it-checks"
197
+ }
198
+ ];
199
+ }
200
+ function formatIssue(issue) {
201
+ const icon = issue.severity === "error" ? chalk.red("\u2717") : issue.severity === "warning" ? chalk.yellow("\u26A0") : chalk.blue("\u2139");
202
+ const lineRef = issue.line > 0 ? `Line ${issue.line}: ` : "";
203
+ let line = ` ${icon} ${lineRef}${issue.message}`;
204
+ if (issue.suggestion) {
205
+ line += `
206
+ ${chalk.dim("\u2192")} ${chalk.dim(issue.suggestion)}`;
207
+ }
208
+ if (issue.detail) {
209
+ line += `
210
+ ${chalk.dim(issue.detail)}`;
211
+ }
212
+ return line;
213
+ }
214
+
215
+ // src/core/config.ts
216
+ import * as fs from "fs";
217
+ import * as path from "path";
218
+ var CONFIG_FILENAMES = [".ctxlintrc", ".ctxlintrc.json"];
219
+ function loadConfig(projectRoot) {
220
+ for (const filename of CONFIG_FILENAMES) {
221
+ const filePath = path.join(projectRoot, filename);
222
+ try {
223
+ const content = fs.readFileSync(filePath, "utf-8");
224
+ return JSON.parse(content);
225
+ } catch {
226
+ continue;
227
+ }
228
+ }
229
+ return null;
230
+ }
231
+
232
+ // src/cli.ts
233
+ import * as path2 from "path";
234
+ async function runCli() {
235
+ const program = new Command();
236
+ program.name("ctxlint").description(
237
+ "Lint your AI agent context files (CLAUDE.md, AGENTS.md, etc.) against your actual codebase"
238
+ ).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) => {
239
+ const resolvedPath = path2.resolve(projectPath);
240
+ const configPath = opts.config ? path2.resolve(opts.config) : void 0;
241
+ const config = configPath ? loadConfigFromPath(configPath) : loadConfig(resolvedPath);
242
+ const options = {
243
+ projectPath: resolvedPath,
244
+ checks: opts.checks ? opts.checks.split(",").map((c) => c.trim()) : config?.checks || ALL_CHECKS,
245
+ strict: opts.strict || config?.strict || false,
246
+ format: opts.format,
247
+ verbose: opts.verbose,
248
+ fix: opts.fix,
249
+ ignore: opts.ignore ? opts.ignore.split(",").map((c) => c.trim()) : config?.ignore || [],
250
+ tokensOnly: opts.tokens,
251
+ quiet: opts.quiet,
252
+ depth: parseInt(opts.depth, 10) || 2
253
+ };
254
+ if (config?.tokenThresholds) {
255
+ setTokenThresholds(config.tokenThresholds);
256
+ }
257
+ const activeChecks = options.checks.filter((c) => !options.ignore.includes(c));
258
+ const spinner = options.format === "text" && !options.quiet ? ora("Scanning for context files...").start() : void 0;
259
+ try {
260
+ if (spinner) spinner.text = "Running checks...";
261
+ const result = await runAudit(resolvedPath, activeChecks, {
262
+ depth: options.depth,
263
+ extraPatterns: config?.contextFiles
264
+ });
265
+ spinner?.stop();
266
+ if (result.files.length === 0) {
267
+ if (!options.quiet) {
268
+ if (options.format === "json") {
269
+ console.log(JSON.stringify(result));
270
+ } else if (options.format === "sarif") {
271
+ console.log(formatSarif(result));
272
+ } else {
273
+ console.log("\nNo context files found.\n");
274
+ }
275
+ }
276
+ process.exit(0);
277
+ }
278
+ if (options.fix) {
279
+ const fixSummary = applyFixes(result);
280
+ if (fixSummary.totalFixes > 0 && !options.quiet) {
281
+ console.log(
282
+ `
283
+ Fixed ${fixSummary.totalFixes} issue${fixSummary.totalFixes !== 1 ? "s" : ""} in ${fixSummary.filesModified.length} file${fixSummary.filesModified.length !== 1 ? "s" : ""}.
284
+ `
285
+ );
286
+ }
287
+ }
288
+ if (!options.quiet) {
289
+ if (options.tokensOnly) {
290
+ console.log(formatTokenReport(result));
291
+ } else if (options.format === "json") {
292
+ console.log(formatJson(result));
293
+ } else if (options.format === "sarif") {
294
+ console.log(formatSarif(result));
295
+ } else {
296
+ console.log(formatText(result, options.verbose));
297
+ }
298
+ }
299
+ if (options.strict && (result.summary.errors > 0 || result.summary.warnings > 0)) {
300
+ process.exit(1);
301
+ }
302
+ } catch (err) {
303
+ spinner?.stop();
304
+ console.error("Error:", err instanceof Error ? err.message : err);
305
+ process.exit(2);
306
+ } finally {
307
+ freeEncoder();
308
+ resetGit();
309
+ resetPathsCache();
310
+ resetTokenThresholds();
311
+ }
312
+ });
313
+ program.command("init").description("Set up a git pre-commit hook that runs ctxlint --strict").action(async () => {
314
+ const hooksDir = path2.resolve(".git", "hooks");
315
+ if (!fs2.existsSync(path2.resolve(".git"))) {
316
+ console.error('Error: not a git repository. Run "git init" first.');
317
+ process.exit(1);
318
+ }
319
+ if (!fs2.existsSync(hooksDir)) {
320
+ fs2.mkdirSync(hooksDir, { recursive: true });
321
+ }
322
+ const hookPath = path2.join(hooksDir, "pre-commit");
323
+ const hookContent = `#!/bin/sh
324
+ # ctxlint pre-commit hook
325
+ npx @yawlabs/ctxlint --strict
326
+ `;
327
+ if (fs2.existsSync(hookPath)) {
328
+ const existing = fs2.readFileSync(hookPath, "utf-8");
329
+ if (existing.includes("ctxlint")) {
330
+ console.log("Pre-commit hook already includes ctxlint.");
331
+ return;
332
+ }
333
+ fs2.appendFileSync(hookPath, "\n" + hookContent);
334
+ console.log("Added ctxlint to existing pre-commit hook.");
335
+ } else {
336
+ fs2.writeFileSync(hookPath, hookContent, { mode: 493 });
337
+ console.log("Created pre-commit hook at .git/hooks/pre-commit");
338
+ }
339
+ console.log("ctxlint will now run automatically before each commit.");
340
+ });
341
+ program.parse();
342
+ }
343
+ function loadConfigFromPath(configPath) {
344
+ try {
345
+ const content = fs2.readFileSync(configPath, "utf-8");
346
+ return JSON.parse(content);
347
+ } catch {
348
+ console.error(`Error: could not load config from ${configPath}`);
349
+ process.exit(2);
350
+ }
351
+ }
352
+ export {
353
+ runCli
354
+ };