@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.
- package/.pre-commit-hooks.yaml +1 -1
- package/README.md +15 -3
- package/bin/ctxlint.mjs +436 -244
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/.pre-commit-hooks.yaml
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
6
|
-
*
|
|
7
|
-
* runtime-agnostic -- a pre-bundled ESM entry using
|
|
8
|
-
* oam implements -- so neither path changes behavior.
|
|
9
|
-
* binary has
|
|
10
|
-
* (`ctxlint serve`
|
|
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.
|
|
16
|
-
*
|
|
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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
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.
|
|
47
|
-
*
|
|
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.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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=
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* CTXLINT_RUNTIME=
|
|
63
|
-
*
|
|
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
|
|
73
|
-
const OAM_MIN = [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
|
-
/**
|
|
84
|
-
function
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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.
|
|
127
|
-
* `process.versions.oam` ("0.15.
|
|
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"
|
|
162
|
-
* "discover"
|
|
163
|
-
*
|
|
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
|
|
166
|
-
*
|
|
167
|
-
*
|
|
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
|
-
|
|
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.
|
|
206
|
-
*
|
|
207
|
-
* there is no such shim
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
268
|
-
if (
|
|
269
|
-
await
|
|
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.
|
|
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
|
+
"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": {
|