@yawlabs/ctxlint 0.20.0 → 0.22.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.
@@ -4,7 +4,7 @@
4
4
  # Version-pinned so a checkout at `rev: vX.Y.Z` runs exactly that release
5
5
  # of ctxlint — matches the pinning done by `ctxlint init`. release.sh keeps
6
6
  # this in sync with package.json on each bump.
7
- entry: npx @yawlabs/ctxlint@0.20.0 --strict
7
+ entry: npx @yawlabs/ctxlint@0.22.0 --strict
8
8
  language: node
9
9
  always_run: true
10
10
  pass_filenames: false
package/README.md CHANGED
@@ -386,12 +386,17 @@ Create a `.ctxlintrc` or `.ctxlintrc.json` in your project root:
386
386
  "tierBreakdown": 1000,
387
387
  "tierAggregate": 4000
388
388
  },
389
- "contextFiles": ["CONVENTIONS.md", "docs/ai-rules.md"]
389
+ "contextFiles": ["CONVENTIONS.md", "docs/ai-rules.md"],
390
+ "exclude": ["fixtures/**", "examples/**"]
390
391
  }
391
392
  ```
392
393
 
393
394
  The `contextFiles` array adds custom file patterns to scan alongside the built-in list. Useful for project-specific context files like `CONVENTIONS.md`.
394
395
 
396
+ The `exclude` array is its counterpart: globs of context files to drop from the scan entirely. Use it for files that are not this project's agent context at all — test fixtures, example projects, vendored templates.
397
+
398
+ `exclude` removes **files**, where `ignore` / `ignoreRules` / `.ctxlintignore` suppress **findings**. That distinction matters for the cross-file checks: `contradictions` and `redundancy/duplicate-content` compare the discovered set and report against the literal `(project)` path, which no per-file glob can match. Excluding the files is the only way to silence a conflict between two files you never wanted linted. Reach for `.ctxlintignore` when a real context file has one noisy finding; reach for `exclude` when the file shouldn't be in the corpus.
399
+
395
400
  ### Config Reference
396
401
 
397
402
  | Field | Type | Default | Meaning |
@@ -407,6 +412,7 @@ The `contextFiles` array adds custom file patterns to scan alongside the built-i
407
412
  | `tokenThresholds.tierBreakdown` | `number` | `1000` | Always-loaded file threshold for `tier-tokens/section-breakdown`. |
408
413
  | `tokenThresholds.tierAggregate` | `number` | `4000` | Combined always-loaded threshold for `tier-tokens/aggregate`. |
409
414
  | `contextFiles` | `string[]` | `[]` | Extra glob patterns to scan alongside the built-in list. |
415
+ | `exclude` | `string[]` | `[]` | Globs of context files to drop from the scan entirely, matched against each file's project-relative path. Removes files rather than suppressing findings, so excluded files are also invisible to the cross-file checks. |
410
416
  | `mcp` | `boolean` | `false` | Enable MCP config checks by default (same as `--mcp`). |
411
417
  | `mcpOnly` | `boolean` | `false` | Run only MCP config checks, skip context-file checks (same as `--mcp-only`). |
412
418
  | `mcpGlobal` | `boolean` | `false` | Also scan user/global MCP configs (same as `--mcp-global`). |
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runtime launcher for @yawlabs/ctxlint.
4
+ *
5
+ * Prefers the oam runtime (https://oamjs.org) and falls back to the Node process
6
+ * already running this file. The CLI itself (`dist/index.js`) is
7
+ * runtime-agnostic -- a pre-bundled ESM entry using only `node:` builtins that
8
+ * oam implements -- so neither path changes behavior. This covers both modes the
9
+ * binary has: the linter (`ctxlint audit ...`) and the MCP server
10
+ * (`ctxlint serve`), since every argument passes through untouched.
11
+ *
12
+ * WHY THE FALLBACK COSTS NOTHING
13
+ * npm has already started Node to run this launcher, so falling back is a plain
14
+ * `import()` of the CLI into THIS process: no extra spawn, no extra startup,
15
+ * byte-identical to invoking dist/index.js directly. Discovery is stat-only --
16
+ * never a subprocess -- so the miss case stays sub-millisecond.
17
+ *
18
+ * WHAT THE OAM PATH COSTS
19
+ * Reaching oam through an npm `bin` means Node boots first and oam boots second,
20
+ * so the launcher is slower than either runtime alone. It exists so `npx` users
21
+ * get oam automatically. To skip it -- and for `serve`, which an MCP host starts
22
+ * once per session, this is the better config -- point at oam directly:
23
+ * { "command": "oam", "args": ["run", "<abs>/dist/index.js", "--", "serve"] }
24
+ *
25
+ * NO SANDBOX HERE -- DELIBERATELY
26
+ * oam 0.9.0's `--permission` is real hardening, but it does not fit a linter.
27
+ * ctxlint's whole purpose is to read context files the caller names at run time
28
+ * -- CLAUDE.md, skills, agent transcripts, MCP configs, anywhere on disk -- so a
29
+ * filesystem-read grant would have to be `*` to keep the tool working. Narrowing
30
+ * it would turn "this path is not linted" into a silent clean result, which is
31
+ * the worst failure mode a linter has. What is left to deny (network, child
32
+ * process) it never uses anyway, so the sandbox would gate nothing real.
33
+ *
34
+ * MINIMUM OAM VERSION
35
+ * 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
36
+ * `exec` accepted `timeout` and ignored it, `spawnSync` truncated at `maxBuffer`
37
+ * while reporting success, and `stdio: 'inherit'`/`'ignore'` both behaved as
38
+ * `'pipe'`. This tool spawns nothing in shipped code, so the floor is enforced
39
+ * for consistency across @yawlabs/*-mcp rather than because this launcher was
40
+ * exposed. An older oam is not an error: the launcher falls back to Node and
41
+ * says so on stderr.
42
+ *
43
+ * SELECTION
44
+ * CTXLINT_RUNTIME=oam require oam; fail loudly if it is missing
45
+ * CTXLINT_RUNTIME=node never use oam
46
+ * CTXLINT_RUNTIME=auto prefer oam, silently fall back (default)
47
+ * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
48
+ */
49
+
50
+ import { execFileSync, spawn } from "node:child_process";
51
+ import { existsSync } from "node:fs";
52
+ import { constants, homedir } from "node:os";
53
+ import { delimiter, join } from "node:path";
54
+ import { fileURLToPath } from "node:url";
55
+
56
+ /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
57
+ const OAM_MIN = [0, 9, 0];
58
+
59
+ // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
60
+ // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
61
+ // in-process fallback must use the file:// URL. spawn() needs a real path.
62
+ const SERVER_URL = new URL("../dist/index.js", import.meta.url);
63
+ const SERVER_ENTRY = fileURLToPath(SERVER_URL);
64
+ const isWin = process.platform === "win32";
65
+ const exe = isWin ? "oam.exe" : "oam";
66
+
67
+ /** Locate an oam binary, or null. Every branch is a stat, never a subprocess. */
68
+ function findOam() {
69
+ // 1. Explicit override wins and is never second-guessed.
70
+ const override = process.env.OAM_BIN;
71
+ if (override) return existsSync(override) ? override : null;
72
+
73
+ // 2. Installed locations, BEFORE PATH. Someone who develops oam itself usually
74
+ // has oam/target/release on PATH, and a build directory is the wrong thing
75
+ // for a user-facing launcher to bind to: cargo replaces the binary
76
+ // underneath running processes, and the dev build is not the release the
77
+ // user installed. OAM_BIN remains the way to point at a dev build.
78
+ const installed = [join(homedir(), ".oam", "bin", exe)];
79
+ if (isWin) {
80
+ installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
81
+ }
82
+ for (const candidate of installed) {
83
+ if (existsSync(candidate)) return candidate;
84
+ }
85
+
86
+ // 3. PATH, resolved manually rather than by spawning `which`/`where`, which
87
+ // would cost a subprocess on every launch just to decide whether to spawn.
88
+ const pathExt = isWin ? (process.env.PATHEXT ?? ".EXE").split(";").filter(Boolean) : [""];
89
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
90
+ if (!dir) continue;
91
+ for (const ext of isWin ? pathExt : [""]) {
92
+ const candidate = join(dir, isWin ? `oam${ext.toLowerCase()}` : "oam");
93
+ if (existsSync(candidate)) return candidate;
94
+ }
95
+ }
96
+
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * `oam --version` -> [major, minor, patch], or null when it cannot be read.
102
+ * A pre-release suffix (0.9.0-rc.1) truncates to its base version.
103
+ */
104
+ function oamVersion(cmd) {
105
+ try {
106
+ const out = execFileSync(cmd, ["--version"], {
107
+ encoding: "utf-8",
108
+ stdio: ["ignore", "pipe", "ignore"],
109
+ });
110
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
111
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
112
+ } catch {
113
+ // Not executable, wrong arch, or deleted since the stat. Caller degrades.
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /** True when `v` is at least `min`, comparing major/minor/patch in order. */
119
+ function atLeast(v, min) {
120
+ if (!v) return false;
121
+ for (let i = 0; i < min.length; i++) {
122
+ if (v[i] > min[i]) return true;
123
+ if (v[i] < min[i]) return false;
124
+ }
125
+ return true;
126
+ }
127
+
128
+ /** Run the CLI in THIS process. The zero-overhead fallback. */
129
+ async function runInProcess() {
130
+ // Point argv[1] at the CLI first, so the in-process path is indistinguishable
131
+ // from having executed the file directly -- an entry-point guard
132
+ // (`import.meta.url === pathToFileURL(process.argv[1]).href`) must read true.
133
+ process.argv[1] = SERVER_ENTRY;
134
+ await import(SERVER_URL.href);
135
+ }
136
+
137
+ const mode = (process.env.CTXLINT_RUNTIME ?? "auto").toLowerCase();
138
+
139
+ if (mode === "node") {
140
+ await runInProcess();
141
+ } else {
142
+ const oam = findOam();
143
+
144
+ if (!oam) {
145
+ if (mode === "oam") {
146
+ // Explicitly demanded, so this is a real misconfiguration -- do not
147
+ // silently do something else. writeSync because stderr is async for
148
+ // TTYs/pipes on Windows and process.exit truncates pending writes.
149
+ const { writeSync } = await import("node:fs");
150
+ writeSync(
151
+ 2,
152
+ "ctxlint: CTXLINT_RUNTIME=oam but no oam binary was found.\n" +
153
+ "Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use CTXLINT_RUNTIME=node.\n",
154
+ );
155
+ process.exit(1);
156
+ }
157
+ await runInProcess();
158
+ } else if (!atLeast(oamVersion(oam), OAM_MIN)) {
159
+ // Discovery itself stays stat-only; this is the first subprocess, and it
160
+ // runs only once we have already decided to spawn oam anyway. Measured 26ms
161
+ // median (n=12, windows-arm64), paid once per invocation.
162
+ const min = OAM_MIN.join(".");
163
+ if (mode === "oam") {
164
+ const { writeSync } = await import("node:fs");
165
+ writeSync(
166
+ 2,
167
+ `ctxlint: CTXLINT_RUNTIME=oam but ${oam} is older than oam ${min}.\n` +
168
+ `Run \`oam self-update\`, or use CTXLINT_RUNTIME=node.\n`,
169
+ );
170
+ process.exit(1);
171
+ }
172
+ // auto: an old oam is a reason to prefer Node, not to fail. Say so, because
173
+ // a silent downgrade is how someone keeps running an oam they meant to
174
+ // update. stderr is safe -- MCP frames travel on stdout under `serve`.
175
+ process.stderr.write(`ctxlint: oam at ${oam} is older than ${min}; using Node instead.\n`);
176
+ await runInProcess();
177
+ } else {
178
+ // `--` separates oam's own flags from the script's argv. Everything after it
179
+ // lands in process.argv for the CLI, so `audit`, `serve` and every flag
180
+ // survive the hop unchanged.
181
+ const child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
182
+ // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
183
+ // stdin/stdout under `serve` is untouched, and the linter's exit-code and
184
+ // output behavior is identical to running it directly.
185
+ stdio: "inherit",
186
+ env: process.env,
187
+ windowsHide: true,
188
+ });
189
+
190
+ // If oam cannot be executed at all (deleted between the stat and the spawn,
191
+ // wrong arch, permission), fall back rather than failing outright.
192
+ // `spawned` guards against falling back AFTER the child has begun running.
193
+ let spawned = false;
194
+ child.on("spawn", () => {
195
+ spawned = true;
196
+ });
197
+ child.on("error", (err) => {
198
+ if (spawned) return;
199
+ if (mode === "oam") {
200
+ process.stderr.write(`ctxlint: failed to launch oam (${err.message})\n`);
201
+ process.exit(1);
202
+ }
203
+ void runInProcess();
204
+ });
205
+
206
+ // Forward termination so the CLI's own shutdown path runs in the child
207
+ // rather than the child being orphaned. Signals are a no-op on Windows but
208
+ // harmless to register.
209
+ for (const sig of ["SIGINT", "SIGTERM"]) {
210
+ process.on(sig, () => {
211
+ if (!child.killed) child.kill(sig);
212
+ });
213
+ }
214
+
215
+ child.on("exit", (code, signal) => {
216
+ // Mirror the child's fate: a signal death becomes 128+n so callers see a
217
+ // conventional shell exit status rather than a bare 0. ctxlint's exit code
218
+ // is how CI reads a lint failure, so passing it through is load-bearing.
219
+ if (signal) {
220
+ process.exit(128 + (constants.signals[signal] ?? 15));
221
+ }
222
+ process.exit(code ?? 0);
223
+ });
224
+ }
225
+ }