@yawlabs/ctxlint 0.25.3 → 0.25.5

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.25.3 --strict
7
+ entry: npx @yawlabs/ctxlint@0.25.5 --strict
8
8
  language: node
9
9
  always_run: true
10
10
  pass_filenames: false
package/README.md CHANGED
@@ -234,7 +234,7 @@ Checks whose signal is what the agent did (commands run, files written or read)
234
234
  ## Example Output
235
235
 
236
236
  ```
237
- ctxlint v0.25.0
237
+ ctxlint v0.25.5
238
238
 
239
239
  Scanning /Users/you/my-app...
240
240
 
@@ -258,7 +258,7 @@ Summary: 2 errors, 2 warnings, 1 info
258
258
  ## Options
259
259
 
260
260
  ```
261
- Usage: ctxlint [options] [path]
261
+ Usage: ctxlint [options] [command] [path]
262
262
 
263
263
  Arguments:
264
264
  path Project directory to scan (default: ".")
@@ -287,12 +287,15 @@ Options:
287
287
  --hooks-global Also scan the user-global ~/.claude/settings.json in the
288
288
  dead-hook check (default scans project .claude/ only)
289
289
  --mcp-server Start the MCP server (alias: `serve` subcommand)
290
+ --lsp Start in LSP server mode (JSON-RPC over stdio for editor integration)
291
+ --no-ignore-file Disable .ctxlintignore suppression (see all findings)
290
292
  --watch Re-lint on context file changes
291
293
  -V, --version Output the version number
292
294
  -h, --help Display help
293
295
 
294
296
  Commands:
295
297
  init Set up a git pre-commit hook
298
+ serve Start the MCP server (same as --mcp-server)
296
299
  ```
297
300
 
298
301
  **Available checks:** `paths`, `commands`, `staleness`, `tokens`, `tier-tokens`, `redundancy`, `contradictions`, `frontmatter`, `ci-coverage`, `ci-secrets`, `content-secrets`, `hook-coverage`, `mcp-schema`, `mcp-security`, `mcp-commands`, `mcp-deprecated`, `mcp-env`, `mcp-urls`, `mcp-consistency`, `mcp-redundancy`, `session-missing-secret`, `session-diverged-file`, `session-missing-workflow`, `session-stale-memory`, `session-duplicate-memory`, `session-loop-detection`, `session-memory-index-overflow`, `session-shared-temp-path`, `session-unverified-gate-claimed-clean`, `session-default-branch-accumulation`, `session-unresolvable-sha`, `session-large-read`, `skill-frontmatter`, `skill-broken-ref`, `skill-trigger-collision`, `skill-orphaned`, `skill-dead-tool-restriction`
@@ -377,7 +380,7 @@ Add to your `.pre-commit-config.yaml`:
377
380
  ```yaml
378
381
  repos:
379
382
  - repo: https://github.com/yawlabs/ctxlint
380
- rev: v0.25.0
383
+ rev: v0.25.5
381
384
  hooks:
382
385
  - id: ctxlint
383
386
  ```
@@ -390,6 +393,9 @@ Create a `.ctxlintrc` or `.ctxlintrc.json` in your project root:
390
393
  {
391
394
  "checks": ["paths", "commands", "tokens", "contradictions", "frontmatter"],
392
395
  "ignore": ["redundancy"],
396
+ "ignoreRules": [
397
+ { "check": "paths", "match": "^docs/archive/", "reason": "archived docs cite removed files" }
398
+ ],
393
399
  "strict": true,
394
400
  "tokenThresholds": {
395
401
  "info": 500,
@@ -416,6 +422,11 @@ The `exclude` array is its counterpart: globs of context files to drop from the
416
422
  | ------------------------------- | ---------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
417
423
  | `checks` | `string[]` | all checks | Checks to run. Check names include `paths`, `commands`, `tokens`, `tier-tokens`, `redundancy`, `contradictions`, `frontmatter`, `staleness`, `ci-coverage`, `ci-secrets`, `content-secrets`, `hook-coverage`, plus any `mcp-*` / `session-*` / `skill-*`. |
418
424
  | `ignore` | `string[]` | `[]` | Checks to skip, evaluated after `checks`. |
425
+ | `ignoreRules` | `object[]` | `[]` | Per-finding suppression, finer than `ignore`: each rule drops only the findings it matches, and the first matching rule wins. Rules that never fire, and rules missing a `reason`, are listed in the text report and in JSON `_meta.ignoreReport`. |
426
+ | `ignoreRules[].check` | `string` | required | The check the rule applies to, by exact name (`paths`, `session-stale-memory`, ...). |
427
+ | `ignoreRules[].match` | `string` | none | Regex tested against the finding's message. A rule with neither `match` nor `pathPattern` drops every finding of its check; with both, both must match. |
428
+ | `ignoreRules[].pathPattern` | `string` | none | Regex tested against each path a finding names; the rule fires only when every path matches. Honored only for `session-stale-memory`: on any other check the rule never fires, and loading the config prints a warning. |
429
+ | `ignoreRules[].reason` | `string` | none | Why the finding is suppressed. Optional, but rules without one are listed in the report for review. |
419
430
  | `strict` | `boolean` | `false` | Exit non-zero on any warning or error. |
420
431
  | `tokenThresholds` | `object` | see below | Per-file and cross-file token thresholds. |
421
432
  | `tokenThresholds.info` | `number` | `1000` | Per-file info threshold for `tokens/info`. |
@@ -433,6 +444,7 @@ The `exclude` array is its counterpart: globs of context files to drop from the
433
444
  | `sessionOnly` | `boolean` | `false` | Run only session checks, skip context and MCP checks (same as `--session-only`). |
434
445
  | `skills` | `boolean` | `false` | Enable agent-skill checks (`~/.claude/skills` + `~/.claude/agents`); same as `--skills`. |
435
446
  | `skillsOnly` | `boolean` | `false` | Run only agent-skill checks, skip everything else (same as `--skills-only`). |
447
+ | `hooksGlobal` | `boolean` | `false` | Also scan the user-global `~/.claude/settings.json` in the dead-hook check (same as `--hooks-global`). |
436
448
 
437
449
  Config file resolution order: `.ctxlintrc` → `.ctxlintrc.json` in the project root. Use `--config <path>` to point elsewhere. CLI flags override config fields.
438
450
 
package/bin/ctxlint.mjs CHANGED
@@ -2,75 +2,117 @@
2
2
  /**
3
3
  * Runtime launcher for @yawlabs/ctxlint.
4
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.
5
+ * Prefers the newest usable oam runtime (https://oamjs.org) and falls back to
6
+ * Node. It never runs the CLI on an oam older than the floor below. The CLI
7
+ * itself (`dist/index.js`) is runtime-agnostic -- a pre-bundled ESM entry using
8
+ * only `node:` builtins that oam implements -- so neither path changes behavior.
9
+ * This covers every mode the binary has -- the linter (`ctxlint [path] ...`),
10
+ * `ctxlint init`, the MCP server (`ctxlint serve` or `--mcp-server`) and the
11
+ * language server (`ctxlint --lsp`) -- since every argument passes through
12
+ * untouched.
11
13
  *
12
14
  * WHY THE FALLBACK COSTS NOTHING
13
15
  * npm has already started Node to run this launcher, so falling back is a plain
14
16
  * `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
+ * byte-identical to invoking dist/index.js directly. Finding the candidates is
18
+ * stat-only, so a machine without oam never pays for a subprocess.
17
19
  *
18
20
  * 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
21
+ * Reaching oam through an npm `bin` means Node boots first, every oam binary
22
+ * found is asked for its version, and then oam boots to run the CLI -- so the
23
+ * launcher is slower than either runtime alone. It exists so `npx` users get
24
+ * oam automatically. To skip it -- and for `serve`, which an MCP host starts
22
25
  * once per session, this is the better config -- point at oam directly:
23
26
  * { "command": "oam", "args": ["run", "<abs>/dist/index.js", "--", "serve"] }
24
27
  *
28
+ * WHICH OAM
29
+ * OAM_BIN, when set and usable, is used as given. Otherwise every oam binary
30
+ * discovery can see -- the installed locations, then PATH -- is asked for its
31
+ * version, and the NEWEST one at or above the floor wins; a tie keeps search
32
+ * order. Taking the first binary found instead let a stale copy early in the
33
+ * search order hide a current one later: with oam 0.9.0 installed in ~/.oam/bin
34
+ * and 0.15.2 on PATH, the launcher bound to 0.9.0 because installed locations
35
+ * are searched first.
36
+ *
37
+ * An OAM_BIN that does not exist, is below the floor, or will not run is always
38
+ * named on stderr, and discovery carries on. It used to end discovery: a
39
+ * missing OAM_BIN meant Node with no hint why, and an old or unrunnable one
40
+ * meant Node even when a usable oam was installed. Discovered binaries that
41
+ * were passed over, and an oam.cmd/oam.bat shim on PATH, are named only when NO
42
+ * usable oam is found; when one is, the others go unmentioned.
43
+ *
25
44
  * ALREADY RUNNING ON OAM
26
45
  * A host can resolve this package's `bin` and launch `oam run <this file>`
27
46
  * instead of `node <this file>` -- Yaw MCP does, and so does oam's sidecar
28
- * regression matrix. This launcher used to discover oam and spawn it anyway,
29
- * so one server cost two runtime boots: measured on Windows, oam.exe with a
30
- * NESTED oam.exe + conhost.exe underneath it. Now, when `process.versions.oam`
31
- * clears the same MINIMUM OAM VERSION a discovered binary has to, the CLI is
47
+ * regression matrix. When `process.versions.oam` clears the floor, the CLI is
32
48
  * imported into THIS process exactly as the Node fallback is -- no discovery,
33
49
  * no `oam --version` probe, no second oam. OAM_BIN is a discovery input, so it
34
50
  * is not consulted on that path: the host has already chosen which oam runs.
51
+ * With no sandbox (below), nothing here needs a FRESH oam.
35
52
  *
36
- * With no sandbox (below), nothing here needs a FRESH oam, so the only case
37
- * that still spawns is a host oam below the floor, which takes the discovery
38
- * path exactly as it always did.
53
+ * A host oam BELOW the floor never runs the CLI. It used to, whenever discovery
54
+ * came up empty or found only an old binary. It now hands the CLI off to the
55
+ * newest usable oam, or to Node found on PATH, or exits with an error when
56
+ * there is neither.
57
+ *
58
+ * That handoff PIPES stdio rather than inheriting it. Before 0.9.0 oam treated
59
+ * `stdio: 'inherit'` as `'pipe'`, so an inherited handoff from such a host
60
+ * connected the child to pipes nobody reads: measured on @yawlabs/aws-mcp's
61
+ * identical launcher with a real oam 0.8.2 host, the MCP handshake never
62
+ * answered. Piping the streams explicitly completes it, to both oam and Node.
63
+ * A Node host keeps `inherit`, which hands over the same fds untouched.
39
64
  *
40
65
  * NO SANDBOX HERE -- DELIBERATELY
41
- * oam 0.9.0's `--permission` is real hardening, but it does not fit a linter.
66
+ * oam's `--permission` is real hardening, but it does not fit a linter.
42
67
  * ctxlint's whole purpose is to read context files the caller names at run time
43
68
  * -- CLAUDE.md, skills, agent transcripts, MCP configs, anywhere on disk -- so a
44
69
  * filesystem-read grant would have to be `*` to keep the tool working. Narrowing
45
70
  * it would turn "this path is not linted" into a silent clean result, which is
46
- * the worst failure mode a linter has. What is left to deny (network, child
47
- * process) it never uses anyway, so the sandbox would gate nothing real.
71
+ * the worst failure mode a linter has. Of what is left, its git-backed checks
72
+ * run `git` through simple-git, so the child-process grant would have to stay
73
+ * open too, and it never touches the network. The sandbox would gate nothing
74
+ * real.
48
75
  *
49
76
  * MINIMUM OAM VERSION
50
- * 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
51
- * `exec` accepted `timeout` and ignored it, `spawnSync` truncated at `maxBuffer`
52
- * while reporting success, and `stdio: 'inherit'`/`'ignore'` both behaved as
53
- * `'pipe'`. This tool spawns nothing in shipped code, so the floor is enforced
54
- * for consistency across @yawlabs/*-mcp rather than because this launcher was
55
- * exposed. An older oam is not an error: the launcher falls back to Node and
56
- * says so on stderr.
77
+ * The latest oam release, 0.15.2 -- bump OAM_MIN when oam ships a newer one.
78
+ * Only the current oam is used and verified; an older one hands off or falls
79
+ * back to Node. The floor is that support policy, not a fix for something the
80
+ * CLI was exposed to. Below 0.9.0 `child_process.execFile` ran its arguments
81
+ * through a SHELL, `exec` accepted `timeout` and ignored it, `spawnSync`
82
+ * truncated at `maxBuffer` while reporting success, and
83
+ * `stdio: 'inherit'`/`'ignore'` both behaved as `'pipe'`. The CLI reaches none
84
+ * of those: its only child process is `git`, started by simple-git through
85
+ * `spawn` with the default piped stdio, and on a real oam 0.8.2 `spawn` passed
86
+ * `git`'s arguments through no shell while `execFile` did (measured). The
87
+ * bundle's other `child_process` call sites -- vscode-languageserver's
88
+ * global-module lookup helpers and commander's executable subcommands -- are
89
+ * never called. This launcher's own handoff from an old oam host does meet
90
+ * the `inherit` bug, which is why that handoff pipes.
57
91
  *
58
92
  * SELECTION
59
- * CTXLINT_RUNTIME=oam require oam; fail loudly if it is missing
60
- * (already running on oam satisfies it)
61
- * CTXLINT_RUNTIME=node never use oam
62
- * CTXLINT_RUNTIME=auto prefer oam, silently fall back (default)
63
- * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
93
+ * CTXLINT_RUNTIME=auto newest usable oam, else Node (default)
94
+ * CTXLINT_RUNTIME=oam newest usable oam, else exit with an error
95
+ * (already running on oam at the floor satisfies it)
96
+ * CTXLINT_RUNTIME=node Node: in THIS process on Node, handed off to Node
97
+ * on PATH when THIS process is oam
98
+ * OAM_BIN=/path/to/oam use this oam when it is usable, before discovery
99
+ * The value is case-insensitive; anything else behaves like `auto`.
64
100
  */
65
101
 
66
102
  import { execFileSync, spawn } from 'node:child_process';
67
- import { existsSync } from 'node:fs';
103
+ import { existsSync, realpathSync } from 'node:fs';
68
104
  import { constants, homedir } from 'node:os';
69
105
  import { delimiter, join } from 'node:path';
70
106
  import { fileURLToPath } from 'node:url';
71
107
 
72
- /** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
73
- const OAM_MIN = [0, 9, 0];
108
+ /** Oldest oam this launcher will use. See MINIMUM OAM VERSION above. */
109
+ const OAM_MIN = [0, 15, 2];
110
+
111
+ /**
112
+ * Bound on each `oam --version` probe. A healthy oam answers in milliseconds;
113
+ * the bound only exists so a wedged binary on PATH cannot hang the launch.
114
+ */
115
+ const VERSION_PROBE_TIMEOUT_MS = 5_000;
74
116
 
75
117
  // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
76
118
  // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
@@ -80,42 +122,58 @@ const SERVER_ENTRY = fileURLToPath(SERVER_URL);
80
122
  const isWin = process.platform === 'win32';
81
123
  const exe = isWin ? 'oam.exe' : 'oam';
82
124
 
83
- /** Locate an oam binary, or null. Every branch is a stat, never a subprocess. */
84
- function findOam() {
85
- // 1. Explicit override wins and is never second-guessed.
86
- const override = process.env.OAM_BIN;
87
- if (override) return existsSync(override) ? override : null;
125
+ /** Identity for de-duplicating paths: resolved, and case-folded on Windows. */
126
+ function pathKey(p) {
127
+ let key = p;
128
+ try {
129
+ key = realpathSync(p);
130
+ } catch {
131
+ // Unresolvable: fall back to the literal path.
132
+ }
133
+ return isWin ? key.toLowerCase() : key;
134
+ }
88
135
 
89
- // 2. Installed locations, BEFORE PATH. Someone who develops oam itself usually
90
- // has oam/target/release on PATH, and a build directory is the wrong thing
91
- // for a user-facing launcher to bind to: cargo replaces the binary
92
- // underneath running processes, and the dev build is not the release the
93
- // user installed. OAM_BIN remains the way to point at a dev build.
136
+ /**
137
+ * Every oam binary discovery can see, in search order, de-duplicated. Stat-only,
138
+ * never a subprocess.
139
+ *
140
+ * Installed locations come BEFORE PATH, so when two binaries report the same
141
+ * version the installed copy wins the tie. Someone who develops oam itself
142
+ * usually has oam/target/release on PATH, and cargo replaces that binary
143
+ * underneath running processes. Both forms are checked on Windows: the
144
+ * installer defaults to %LOCALAPPDATA%\oam\bin there, but oam's docs name
145
+ * ~/.oam/bin first and OAM_INSTALL_DIR can pick either.
146
+ *
147
+ * PATH is resolved manually rather than by spawning `which`/`where`, which would
148
+ * cost a subprocess on every launch just to decide whether to spawn.
149
+ *
150
+ * Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
151
+ * run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and for
152
+ * spawn it throws SYNCHRONOUSLY rather than emitting 'error'), so walking the
153
+ * full PATHEXT list would hand back a path this launcher cannot execute. A
154
+ * skipped shim is still reported when no usable oam is found -- see findOamShim.
155
+ */
156
+ function discoverOamPaths() {
94
157
  const installed = [join(homedir(), '.oam', 'bin', exe)];
95
158
  if (isWin) {
96
159
  installed.unshift(
97
160
  join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'oam', 'bin', exe),
98
161
  );
99
162
  }
100
- for (const candidate of installed) {
101
- if (existsSync(candidate)) return candidate;
163
+ const onPath = (process.env.PATH ?? '')
164
+ .split(delimiter)
165
+ .filter(Boolean)
166
+ .map((dir) => join(dir, exe));
167
+ const seen = new Set();
168
+ const found = [];
169
+ for (const candidate of [...installed, ...onPath]) {
170
+ if (!existsSync(candidate)) continue;
171
+ const key = pathKey(candidate);
172
+ if (seen.has(key)) continue;
173
+ seen.add(key);
174
+ found.push(candidate);
102
175
  }
103
-
104
- // 3. PATH, resolved manually rather than by spawning `which`/`where`, which
105
- // would cost a subprocess on every launch just to decide whether to spawn.
106
- // Windows: `.exe` ONLY -- deliberately narrower than PATHEXT. Node refuses to
107
- // run a .cmd/.bat through execFile/spawn without `shell: true` (EINVAL, and
108
- // for spawn it throws SYNCHRONOUSLY rather than emitting 'error'), so walking
109
- // the full PATHEXT list would hand back a path this launcher cannot execute.
110
- // Discovery has to agree with execution. A skipped shim is still reported --
111
- // see findOamShim.
112
- for (const dir of (process.env.PATH ?? '').split(delimiter)) {
113
- if (!dir) continue;
114
- const candidate = join(dir, exe);
115
- if (existsSync(candidate)) return candidate;
116
- }
117
-
118
- return null;
176
+ return found;
119
177
  }
120
178
 
121
179
  /**
@@ -123,8 +181,8 @@ function findOam() {
123
181
  * A pre-release suffix (0.9.0-rc.1) truncates to its base version.
124
182
  *
125
183
  * Shared by the two places a version is read -- a discovered binary's
126
- * `oam --version` output ("oam 0.15.1") and the host's own
127
- * `process.versions.oam` ("0.15.1") -- so they cannot disagree about what a
184
+ * `oam --version` output ("oam 0.15.2") and the host's own
185
+ * `process.versions.oam` ("0.15.2") -- so they cannot disagree about what a
128
186
  * version string means, or which floor it has to clear.
129
187
  */
130
188
  function parseVersion(text) {
@@ -138,10 +196,12 @@ function oamVersion(cmd) {
138
196
  const out = execFileSync(cmd, ['--version'], {
139
197
  encoding: 'utf-8',
140
198
  stdio: ['ignore', 'pipe', 'ignore'],
199
+ timeout: VERSION_PROBE_TIMEOUT_MS,
200
+ windowsHide: true,
141
201
  });
142
202
  return parseVersion(out);
143
203
  } catch {
144
- // Not executable, wrong arch, or deleted since the stat. Caller degrades.
204
+ // Not executable, wrong arch, wedged, or deleted since the stat. Caller degrades.
145
205
  return null;
146
206
  }
147
207
  }
@@ -156,15 +216,34 @@ function atLeast(v, min) {
156
216
  return true;
157
217
  }
158
218
 
219
+ /**
220
+ * The newest candidate at or above the floor, or null. `candidates` is
221
+ * `{ path, version }[]` in search order, `version` null when unreadable.
222
+ * Strictly-greater replaces, so a tie keeps the earlier candidate.
223
+ *
224
+ * Pure on purpose, like runtimePlan: the choice is testable without binaries.
225
+ */
226
+ function pickNewest(candidates) {
227
+ let best = null;
228
+ for (const candidate of candidates) {
229
+ if (!atLeast(candidate.version, OAM_MIN)) continue;
230
+ if (!best || !atLeast(best.version, candidate.version)) best = candidate;
231
+ }
232
+ return best;
233
+ }
234
+
159
235
  /**
160
236
  * Where the CLI runs, decided BEFORE any discovery:
161
- * "in-process" import it into THIS process
162
- * "discover" find an oam binary, gate its version, spawn it -- or fall
163
- * back to Node in-process when that fails
237
+ * "in-process" import it into THIS process
238
+ * "discover" choose an oam and spawn it, or fall back to Node
239
+ * "handoff-node" hand it off to Node on PATH: THIS process is an oam, and
240
+ * Node was asked for
164
241
  *
165
- * `hostOam` is `process.versions.oam`: oam's own key, absent on Node, so on
166
- * Node every mode but `node` is the discovery path it always was. There is no
167
- * sandbox input because this launcher has no sandbox; see NO SANDBOX HERE
242
+ * `hostOam` is `process.versions.oam`: oam's own key, absent on Node. An oam
243
+ * host whose version cannot be read is treated as below the floor -- it never
244
+ * proved it is a supported oam -- and a below-floor host discovers, so it hands
245
+ * off to a usable oam or to Node rather than running the CLI itself. There is
246
+ * no sandbox input because this launcher has no sandbox; see NO SANDBOX HERE
168
247
  * above. The floor is OAM_MIN itself, not a parameter, so a host oam and a
169
248
  * discovered one can never be held to different minimums.
170
249
  *
@@ -172,7 +251,8 @@ function atLeast(v, min) {
172
251
  * without booting a runtime.
173
252
  */
174
253
  function runtimePlan({ mode, hostOam }) {
175
- if (mode === 'node') return 'in-process';
254
+ const onOam = hostOam !== undefined;
255
+ if (mode === 'node') return onOam ? 'handoff-node' : 'in-process';
176
256
  return atLeast(parseVersion(hostOam ?? ''), OAM_MIN) ? 'in-process' : 'discover';
177
257
  }
178
258
 
@@ -202,9 +282,10 @@ async function errSync(message) {
202
282
 
203
283
  /**
204
284
  * An oam-named .cmd/.bat on PATH: a real install in a shape this launcher
205
- * cannot spawn. Reported rather than ignored, because "no oam binary was found"
206
- * reads as "install oam" -- the one thing that will not help. Windows only;
207
- * there is no such shim concept on POSIX.
285
+ * cannot spawn. Looked for only when no usable oam was found, and then reported
286
+ * rather than ignored, because "no oam binary was found" reads as "install oam"
287
+ * -- the one thing that will not help. Windows only; there is no such shim
288
+ * concept on POSIX.
208
289
  */
209
290
  function findOamShim() {
210
291
  if (!isWin) return null;
@@ -218,193 +299,304 @@ function findOamShim() {
218
299
  return null;
219
300
  }
220
301
 
302
+ /** A Node binary on PATH, or null. Stat-only; used only when THIS process is oam. */
303
+ function findNodeOnPath() {
304
+ const name = isWin ? 'node.exe' : 'node';
305
+ for (const dir of (process.env.PATH ?? '').split(delimiter)) {
306
+ if (!dir) continue;
307
+ const candidate = join(dir, name);
308
+ if (existsSync(candidate)) return candidate;
309
+ }
310
+ return null;
311
+ }
312
+
313
+ /** Why a candidate was passed over, for stderr. */
314
+ function unusableReason(path, version, label = path) {
315
+ const min = OAM_MIN.join('.');
316
+ return version
317
+ ? `${label} is oam ${version.join('.')}, older than ${min}`
318
+ : `${label} could not be run, or did not report a version this launcher understands`;
319
+ }
320
+
321
+ /**
322
+ * Choose the oam to spawn: a usable OAM_BIN, else the newest usable discovered
323
+ * binary. Returns the choice (or null) plus stderr notes: `overrideNote` about
324
+ * an unusable OAM_BIN, and `skipped` describing what was found and rejected
325
+ * when nothing was usable.
326
+ */
327
+ function chooseOam() {
328
+ const override = process.env.OAM_BIN;
329
+ let overrideNote = null;
330
+ if (override) {
331
+ if (!existsSync(override)) {
332
+ overrideNote = `OAM_BIN=${override} does not exist`;
333
+ } else {
334
+ const version = oamVersion(override);
335
+ if (atLeast(version, OAM_MIN)) {
336
+ return { chosen: { path: override, version }, overrideNote, skipped: [] };
337
+ }
338
+ overrideNote = unusableReason(override, version, `OAM_BIN=${override}`);
339
+ }
340
+ }
341
+ const overrideKey = override ? pathKey(override) : null;
342
+ const candidates = discoverOamPaths()
343
+ .filter((path) => pathKey(path) !== overrideKey)
344
+ .map((path) => ({ path, version: oamVersion(path) }));
345
+ const chosen = pickNewest(candidates);
346
+ const skipped = chosen ? [] : candidates.map((c) => unusableReason(c.path, c.version));
347
+ return { chosen, overrideNote, skipped };
348
+ }
349
+
221
350
  /** Run the CLI in THIS process. The zero-overhead fallback. */
222
351
  async function runInProcess() {
223
352
  // Point argv[1] at the CLI first, so the in-process path is indistinguishable
224
353
  // from having executed the file directly -- an entry-point guard
225
354
  // (`import.meta.url === pathToFileURL(process.argv[1]).href`) must read true.
355
+ // The spawn path needs no equivalent -- there argv[1] is already the CLI.
226
356
  process.argv[1] = SERVER_ENTRY;
227
357
  await import(SERVER_URL.href);
228
358
  }
229
359
 
230
- const mode = (process.env.CTXLINT_RUNTIME ?? 'auto').toLowerCase();
360
+ // ONE reporter for every failed in-process fallback. runInProcess() is a bare
361
+ // import() that rejects when dist/index.js is missing, and at ESM top level an
362
+ // unhandled rejection is an uncaught exception -- replacing this launcher's
363
+ // diagnostic with a raw stack trace.
364
+ const fallbackFailed = (e) => {
365
+ process.stderr.write(`ctxlint: fallback to Node failed (${e?.message ?? e})\n`);
366
+ process.exitCode = 1;
367
+ };
368
+
369
+ /**
370
+ * Spawn the CLI in a child runtime and mirror its lifetime.
371
+ *
372
+ * `onLaunchFailed(err)` runs when the child could not be started at all; it is
373
+ * never called once the child is running, which would double-start the CLI on
374
+ * the same stdio.
375
+ */
376
+ async function launchChild(cmd, args, onLaunchFailed) {
377
+ // THIS process being an oam means one below the floor (a supported oam host
378
+ // runs the CLI in-process) or one handing CTXLINT_RUNTIME=node off, and an old
379
+ // oam's `stdio: 'inherit'` does not hand over the fds. Pipe explicitly there;
380
+ // see ALREADY RUNNING ON OAM.
381
+ const piped = process.versions.oam !== undefined;
382
+ let child = null;
383
+ try {
384
+ child = spawn(cmd, args, {
385
+ // inherit keeps the SAME fds, so MCP's and LSP's framing on stdin/stdout
386
+ // under `serve`/`--lsp` is untouched, and the linter's output behavior is
387
+ // identical to running it directly. Piping preserves both as well: bytes
388
+ // are copied unchanged, and stdin's end propagates to the child.
389
+ stdio: piped ? ['pipe', 'pipe', 'pipe'] : 'inherit',
390
+ env: process.env,
391
+ windowsHide: true,
392
+ });
393
+ } catch (err) {
394
+ // spawn() THROWS for some failures instead of emitting 'error', and the
395
+ // 'error' listener is registered AFTER this call, so it can never observe
396
+ // one -- an uncaught throw here kills the launcher with a raw stack trace
397
+ // instead of falling back.
398
+ await onLaunchFailed(err).catch(fallbackFailed);
399
+ return;
400
+ }
401
+
402
+ // If the runtime cannot be executed at all (deleted between the version probe
403
+ // and the spawn, wrong arch, permission), fall back rather than failing
404
+ // outright. `spawned` guards against falling back AFTER the child has begun
405
+ // running.
406
+ //
407
+ // Everything that assumes a live child waits for 'spawn'. A failed spawn
408
+ // still emits 'close' (after 'error', with the negative errno as its code), so
409
+ // an unguarded close handler would process.exit() out from under the fallback
410
+ // onLaunchFailed has just started -- and stdin piped into a child that never
411
+ // ran would swallow the caller's first bytes before the fallback could read
412
+ // them. Until 'spawn', process.stdin has no reader and simply stays paused.
413
+ let spawned = false;
414
+ child.on('spawn', () => {
415
+ spawned = true;
416
+ if (piped) {
417
+ process.stdin.pipe(child.stdin);
418
+ child.stdout.pipe(process.stdout);
419
+ child.stderr.pipe(process.stderr);
420
+ }
421
+ forwardSignals();
422
+ });
423
+ child.on('error', (err) => {
424
+ if (spawned) return;
425
+ // Handle the rejection instead of discarding it: a failing in-process
426
+ // fallback would otherwise escape as an unhandled rejection, replacing
427
+ // this launcher's diagnostic with a raw stack trace.
428
+ onLaunchFailed(err).catch(fallbackFailed);
429
+ });
430
+ // A child that exits before reading everything closes its stdin; the
431
+ // resulting EPIPE is not worth crashing over. Null when stdio is inherited.
432
+ child.stdin?.on('error', () => {});
433
+
434
+ // Forward termination so the server's own shutdown path runs in the child
435
+ // rather than the child being orphaned.
436
+ //
437
+ // Registering ANY handler for these suppresses Node's default
438
+ // terminate-on-signal, so the parent's exit has to be arranged explicitly.
439
+ // `child.killed` only records that kill() was CALLED, never that the child
440
+ // is gone, so gating on it swallows every signal after the first and wedges
441
+ // the launcher with no escape hatch.
442
+ //
443
+ // Escalation is driven by a TIMER, not by counting signals. Counting is
444
+ // ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
445
+ // apart, and a terminal Ctrl-C reaches the whole process group, so reading
446
+ // "a second signal" as impatience hard-kills a child that is already
447
+ // shutting down cleanly. A timer makes the count irrelevant -- ONE press is
448
+ // enough, and a wedged child dies on schedule. setTimeout is monotonic, so
449
+ // a wall-clock step cannot mis-gate the window either.
450
+ //
451
+ // POSIX vs Windows, and why we do NOT forward on Windows.
452
+ // On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
453
+ // is what lets the child run its shutdown. On Windows there are no POSIX
454
+ // signals: child.kill IGNORES the name and calls TerminateProcess -- an
455
+ // immediate hard kill (verified: a child with a SIGTERM handler never runs
456
+ // it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
457
+ // graceful shutdown the console's own Ctrl-C just started, skipping the
458
+ // child's process.on("exit") cleanup. The console has already notified the
459
+ // child, so on Windows the timer below is the only kill we issue.
460
+ const ESCALATE_AFTER_MS = 2000;
461
+ let escalation = null;
462
+ function forwardSignals() {
463
+ for (const sig of ['SIGINT', 'SIGTERM']) {
464
+ process.on(sig, () => {
465
+ // No try/catch: kill() on an already-exited child returns false, it does
466
+ // not throw. It throws only for a signal the platform does not know,
467
+ // which SIGINT/SIGTERM/SIGKILL never are.
468
+ if (!isWin) child.kill(sig);
469
+ if (escalation) return; // already counting down; further signals are noise
470
+ escalation = setTimeout(() => {
471
+ // Still here after its grace window. Stop waiting on it.
472
+ child.kill('SIGKILL');
473
+ process.exit(128 + (constants.signals[sig] ?? 15));
474
+ }, ESCALATE_AFTER_MS);
475
+ });
476
+ }
477
+ }
231
478
 
232
- const plan = runtimePlan({ mode, hostOam: process.versions.oam });
479
+ // Piped: wait for 'close', so the child's last stdout bytes are copied out
480
+ // before this process exits. Inherited: 'exit' is enough, the fds were never
481
+ // ours to drain. Either way, only for a child that actually ran -- see the
482
+ // 'spawn' handler above.
483
+ child.on(piped ? 'close' : 'exit', (code, signal) => {
484
+ if (!spawned) return;
485
+ if (escalation) clearTimeout(escalation);
486
+ // Mirror the child's fate: a signal death becomes 128+n so callers see a
487
+ // conventional shell exit status rather than a bare 0. ctxlint's exit code
488
+ // is how CI reads a lint failure, so passing it through is load-bearing.
489
+ if (signal) {
490
+ process.exit(128 + (constants.signals[signal] ?? 15));
491
+ }
492
+ process.exit(code ?? 0);
493
+ });
494
+ }
495
+
496
+ /**
497
+ * Hand the CLI to Node on PATH. Only reachable when THIS process is oam -- one
498
+ * below the floor, or any oam under CTXLINT_RUNTIME=node -- so there is no
499
+ * in-process option left.
500
+ */
501
+ async function handOffToNode(reason) {
502
+ const node = findNodeOnPath();
503
+ if (!node) {
504
+ // An empty reason means CTXLINT_RUNTIME=node on a supported oam, where
505
+ // updating oam is not the remedy.
506
+ await errSync(
507
+ reason
508
+ ? `ctxlint: ${reason}, and no Node was found on PATH to run the CLI instead.\n` +
509
+ `Run \`oam self-update\` to get oam ${OAM_MIN.join('.')} or newer, or launch this command with node.\n`
510
+ : 'ctxlint: CTXLINT_RUNTIME=node, but no Node was found on PATH to run the CLI.\n' +
511
+ 'Put node on PATH, or unset CTXLINT_RUNTIME to run on this oam.\n',
512
+ );
513
+ process.exit(1);
514
+ }
515
+ if (reason) await errSync(`ctxlint: ${reason}; running on ${node} instead.\n`);
516
+ await launchChild(node, [SERVER_ENTRY, ...process.argv.slice(2)], async (err) => {
517
+ await errSync(`ctxlint: failed to launch Node at ${node} (${err?.message ?? err})\n`);
518
+ process.exit(1);
519
+ });
520
+ }
521
+
522
+ /**
523
+ * No usable oam, or the chosen one would not start, under a mode that allows
524
+ * Node. `why` finishes the handoff note on an oam host, so it can say which of
525
+ * the two happened.
526
+ */
527
+ async function fallBackToNode(hostOam, why) {
528
+ if (hostOam === undefined) {
529
+ await runInProcess();
530
+ return;
531
+ }
532
+ await handOffToNode(
533
+ `this process is oam ${hostOam}, older than ${OAM_MIN.join('.')}, and ${why}`,
534
+ );
535
+ }
536
+
537
+ const mode = (process.env.CTXLINT_RUNTIME ?? 'auto').toLowerCase();
538
+ const hostOam = process.versions.oam;
539
+ const plan = runtimePlan({ mode, hostOam });
233
540
 
234
541
  if (plan === 'in-process') {
235
542
  await runInProcess();
543
+ } else if (plan === 'handoff-node') {
544
+ const belowFloor = !atLeast(parseVersion(hostOam), OAM_MIN);
545
+ await handOffToNode(
546
+ belowFloor ? `this process is oam ${hostOam}, older than ${OAM_MIN.join('.')}` : '',
547
+ );
236
548
  } else {
237
- const oam = findOam();
238
- // Read the version ONCE, and only when discovery found something: the
239
- // gate below has to tell "too old" apart from "could not be read at all",
240
- // and re-probing inside the branch would cost a second subprocess.
241
- const found = oam ? oamVersion(oam) : null;
549
+ const { chosen, overrideNote, skipped } = chooseOam();
242
550
 
243
- if (!oam) {
244
- // An oam-named .cmd/.bat on PATH is a real install in a shape this
245
- // launcher cannot spawn. Naming it turns "no oam binary was found" --
246
- // which reads as "install oam", the one thing that will not help --
247
- // into something the user can act on.
248
- const oamShim = findOamShim();
249
- const shimNote = oamShim
250
- ? `Found ${oamShim}, but Node cannot execute a .cmd/.bat directly.\n` +
251
- 'Install the native oam binary, or point OAM_BIN at one.\n'
252
- : '';
551
+ if (chosen) {
552
+ if (overrideNote) {
553
+ await errSync(
554
+ `ctxlint: ${overrideNote}; using ${chosen.path} (oam ${chosen.version.join('.')}).\n`,
555
+ );
556
+ }
557
+ // `--` separates oam's own flags from the script's argv. Everything after it
558
+ // lands in process.argv for the CLI, so the lint path, `init`, `serve` and
559
+ // every flag survive the hop unchanged.
560
+ await launchChild(
561
+ chosen.path,
562
+ ['run', SERVER_ENTRY, '--', ...process.argv.slice(2)],
563
+ async (err) => {
564
+ if (mode === 'oam') {
565
+ await errSync(
566
+ `ctxlint: failed to launch oam at ${chosen.path} (${err?.message ?? err})\n`,
567
+ );
568
+ process.exit(1);
569
+ }
570
+ await errSync(
571
+ `ctxlint: failed to launch oam at ${chosen.path} (${err?.message ?? err}); using Node instead.\n`,
572
+ );
573
+ await fallBackToNode(hostOam, 'the newer oam would not start');
574
+ },
575
+ );
576
+ } else {
577
+ const shim = findOamShim();
578
+ const notes = [
579
+ ...(overrideNote ? [overrideNote] : []),
580
+ ...skipped,
581
+ ...(shim
582
+ ? [
583
+ `found ${shim}, but Node cannot execute a .cmd/.bat directly -- install the native oam binary, or point OAM_BIN at one`,
584
+ ]
585
+ : []),
586
+ ];
253
587
  if (mode === 'oam') {
254
588
  // Explicitly demanded, so this is a real misconfiguration -- do not
255
- // silently do something else. writeSync because stderr is async for
256
- // TTYs/pipes on Windows and process.exit truncates pending writes.
257
- const { writeSync } = await import('node:fs');
258
- writeSync(
259
- 2,
260
- 'ctxlint: CTXLINT_RUNTIME=oam but no runnable oam binary was found.\n' +
261
- shimNote +
262
- 'Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use CTXLINT_RUNTIME=node.\n',
589
+ // silently do something else.
590
+ await errSync(
591
+ `ctxlint: CTXLINT_RUNTIME=oam but no usable oam (${OAM_MIN.join('.')} or newer) was found.\n` +
592
+ notes.map((note) => ` ${note}\n`).join('') +
593
+ 'Install or update from https://oamjs.org, set OAM_BIN=/path/to/oam, or use CTXLINT_RUNTIME=node.\n',
263
594
  );
264
595
  process.exit(1);
265
596
  }
266
597
  // auto: falling back is correct, but silence is how someone never learns
267
- // their oam install is a shape this launcher skips.
268
- if (oamShim) await errSync(`ctxlint: ${shimNote}Using Node instead.\n`);
269
- await runInProcess();
270
- } else if (!atLeast(found, OAM_MIN)) {
271
- const min = OAM_MIN.join('.');
272
- // Two different causes reach this branch and they need different
273
- // remedies. `found === null` is NOT "old": oamVersion returns null when
274
- // the binary could not be run at all (not executable, wrong arch, a
275
- // .cmd/.bat Node refuses, deleted between the stat and the probe) or
276
- // when its --version output did not parse. Telling that user to
277
- // `oam self-update` sends them after the one cause it definitely is not.
278
- const detail = found
279
- ? `${oam} is oam ${found.join('.')}, older than ${min}`
280
- : `${oam} could not be run, or did not report a version this launcher understands`;
281
- const remedy = found
282
- ? 'Run \`oam self-update\`, or use CTXLINT_RUNTIME=node.\n'
283
- : 'Check that it is an executable oam binary for this platform, or use CTXLINT_RUNTIME=node.\n';
284
- if (mode === 'oam') {
285
- await errSync(`ctxlint: CTXLINT_RUNTIME=oam but ${detail}.\n${remedy}`);
286
- process.exit(1);
287
- }
288
- // auto: neither cause is worth failing over -- prefer Node. Say so,
289
- // because a silent downgrade is how someone keeps running an oam they
290
- // meant to update, or never learns their oam is unexecutable.
291
- await errSync(`ctxlint: ${detail}; using Node instead.\n`);
292
- await runInProcess();
293
- } else {
294
- // `--` separates oam's own flags from the script's argv. Everything after it
295
- // lands in process.argv for the CLI, so `audit`, `serve` and every flag
296
- // survive the hop unchanged.
297
- // Every "oam could not be executed" outcome lands here: the synchronous
298
- // throw from spawn() and the async 'error' event mean the same thing and
299
- // must degrade the same way, so the handling lives in one place.
300
- // errSync rather than process.stderr.write because stderr is async for
301
- // TTYs and pipes on Windows and the process.exit below truncates pending
302
- // writes.
303
- const launchFailed = async (err) => {
304
- if (mode === 'oam') {
305
- await errSync(`ctxlint: failed to launch oam (${err?.message ?? err})\n`);
306
- process.exit(1);
307
- }
308
- await runInProcess();
309
- };
310
-
311
- // ONE reporter shared by both launchFailed call sites, so the sync-throw
312
- // path and the 'error'-event path cannot drift apart. Either can reject:
313
- // runInProcess() is a bare import() that rejects when dist/index.js is
314
- // missing, and at ESM top level an unhandled rejection is an uncaught
315
- // exception -- the exact failure this handling exists to prevent.
316
- const fallbackFailed = (e) => {
317
- process.stderr.write(`ctxlint: fallback to Node failed (${e?.message ?? e})\n`);
318
- process.exitCode = 1;
319
- };
320
-
321
- let child = null;
322
- try {
323
- child = spawn(oam, ['run', SERVER_ENTRY, '--', ...process.argv.slice(2)], {
324
- // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
325
- // stdin/stdout under `serve` is untouched, and the linter's exit-code and
326
- // output behavior is identical to running it directly.
327
- stdio: 'inherit',
328
- env: process.env,
329
- windowsHide: true,
330
- });
331
- } catch (err) {
332
- // spawn() THROWS for some failures instead of emitting 'error', and the
333
- // 'error' listener is registered AFTER this call, so it can never observe
334
- // one -- an uncaught throw here kills the launcher with a raw stack trace
335
- // instead of falling back to Node.
336
- await launchFailed(err).catch(fallbackFailed);
337
- }
338
-
339
- if (child) {
340
- // If oam cannot be executed at all (deleted between the stat and the spawn,
341
- // wrong arch, permission), fall back rather than failing outright.
342
- // `spawned` guards against falling back AFTER the child has begun running.
343
- let spawned = false;
344
- child.on('spawn', () => {
345
- spawned = true;
346
- });
347
- child.on('error', (err) => {
348
- if (spawned) return;
349
- // Handle the rejection instead of discarding it: a failing in-process
350
- // fallback would otherwise escape as an unhandled rejection, replacing
351
- // this launcher's diagnostic with a raw stack trace.
352
- launchFailed(err).catch(fallbackFailed);
353
- });
354
-
355
- // Forward termination so the server's own shutdown path runs in the child
356
- // rather than the child being orphaned.
357
- //
358
- // Registering ANY handler for these suppresses Node's default
359
- // terminate-on-signal, so the parent's exit has to be arranged explicitly.
360
- // `child.killed` only records that kill() was CALLED, never that the child
361
- // is gone, so gating on it swallows every signal after the first and wedges
362
- // the launcher with no escape hatch.
363
- //
364
- // Escalation is driven by a TIMER, not by counting signals. Counting is
365
- // ambiguous: a supervisor routinely sends SIGINT then SIGTERM milliseconds
366
- // apart, and a terminal Ctrl-C reaches the whole process group, so reading
367
- // "a second signal" as impatience hard-kills a child that is already
368
- // shutting down cleanly. A timer makes the count irrelevant -- ONE press is
369
- // enough, and a wedged child dies on schedule. setTimeout is monotonic, so
370
- // a wall-clock step cannot mis-gate the window either.
371
- //
372
- // POSIX vs Windows, and why we do NOT forward on Windows.
373
- // On POSIX child.kill(sig) delivers a real, catchable signal, so forwarding
374
- // is what lets the child run its shutdown. On Windows there are no POSIX
375
- // signals: child.kill IGNORES the name and calls TerminateProcess -- an
376
- // immediate hard kill (verified: a child with a SIGTERM handler never runs
377
- // it and dies with code=null, signal=SIGTERM). Forwarding there ABORTS the
378
- // graceful shutdown the console's own Ctrl-C just started, skipping the
379
- // child's process.on("exit") cleanup. The console has already notified the
380
- // child, so on Windows the timer below is the only kill we issue.
381
- const ESCALATE_AFTER_MS = 2000;
382
- let escalation = null;
383
- for (const sig of ['SIGINT', 'SIGTERM']) {
384
- process.on(sig, () => {
385
- // No try/catch: kill() on an already-exited child returns false, it does
386
- // not throw. It throws only for a signal the platform does not know,
387
- // which SIGINT/SIGTERM/SIGKILL never are.
388
- if (!isWin) child.kill(sig);
389
- if (escalation) return; // already counting down; further signals are noise
390
- escalation = setTimeout(() => {
391
- // Still here after its grace window. Stop waiting on it.
392
- child.kill('SIGKILL');
393
- process.exit(128 + (constants.signals[sig] ?? 15));
394
- }, ESCALATE_AFTER_MS);
395
- });
396
- }
397
-
398
- child.on('exit', (code, signal) => {
399
- if (escalation) clearTimeout(escalation);
400
- // Mirror the child's fate: a signal death becomes 128+n so callers see a
401
- // conventional shell exit status rather than a bare 0. ctxlint's exit code
402
- // is how CI reads a lint failure, so passing it through is load-bearing.
403
- if (signal) {
404
- process.exit(128 + (constants.signals[signal] ?? 15));
405
- }
406
- process.exit(code ?? 0);
407
- });
408
- }
598
+ // their OAM_BIN is wrong or their oam is too old to use.
599
+ if (notes.length > 0) await errSync(`ctxlint: ${notes.join('; ')}; using Node instead.\n`);
600
+ await fallBackToNode(hostOam, 'no newer oam was found').catch(fallbackFailed);
409
601
  }
410
602
  }
package/dist/index.js CHANGED
@@ -30420,7 +30420,7 @@ import { readFileSync as readFileSync10 } from "node:fs";
30420
30420
  import { resolve as resolve18, dirname as dirname10 } from "node:path";
30421
30421
  import { fileURLToPath as fileURLToPath2 } from "node:url";
30422
30422
  function loadVersion() {
30423
- if (true) return "0.25.3";
30423
+ if (true) return "0.25.5";
30424
30424
  try {
30425
30425
  const __dir = dirname10(fileURLToPath2(import.meta.url));
30426
30426
  const pkgPath = resolve18(__dir, "../package.json");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ctxlint",
3
- "version": "0.25.3",
3
+ "version": "0.25.5",
4
4
  "mcpName": "io.github.YawLabs/ctxlint",
5
5
  "description": "Linter for AI agent context files and MCP configs: CLAUDE.md, AGENTS.md, .cursorrules, .mcp.json - catches broken paths, wrong commands, leaked secrets",
6
6
  "bin": {