@yawlabs/ctxlint 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pre-commit-hooks.yaml +1 -1
- package/bin/ctxlint.mjs +225 -0
- package/dist/index.js +1 -1
- package/package.json +3 -2
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.
|
|
7
|
+
entry: npx @yawlabs/ctxlint@0.22.0 --strict
|
|
8
8
|
language: node
|
|
9
9
|
always_run: true
|
|
10
10
|
pass_filenames: false
|
package/bin/ctxlint.mjs
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Runtime launcher for @yawlabs/ctxlint.
|
|
4
|
+
*
|
|
5
|
+
* Prefers the oam runtime (https://oamjs.org) and falls back to the Node process
|
|
6
|
+
* already running this file. The CLI itself (`dist/index.js`) is
|
|
7
|
+
* runtime-agnostic -- a pre-bundled ESM entry using only `node:` builtins that
|
|
8
|
+
* oam implements -- so neither path changes behavior. This covers both modes the
|
|
9
|
+
* binary has: the linter (`ctxlint audit ...`) and the MCP server
|
|
10
|
+
* (`ctxlint serve`), since every argument passes through untouched.
|
|
11
|
+
*
|
|
12
|
+
* WHY THE FALLBACK COSTS NOTHING
|
|
13
|
+
* npm has already started Node to run this launcher, so falling back is a plain
|
|
14
|
+
* `import()` of the CLI into THIS process: no extra spawn, no extra startup,
|
|
15
|
+
* byte-identical to invoking dist/index.js directly. Discovery is stat-only --
|
|
16
|
+
* never a subprocess -- so the miss case stays sub-millisecond.
|
|
17
|
+
*
|
|
18
|
+
* WHAT THE OAM PATH COSTS
|
|
19
|
+
* Reaching oam through an npm `bin` means Node boots first and oam boots second,
|
|
20
|
+
* so the launcher is slower than either runtime alone. It exists so `npx` users
|
|
21
|
+
* get oam automatically. To skip it -- and for `serve`, which an MCP host starts
|
|
22
|
+
* once per session, this is the better config -- point at oam directly:
|
|
23
|
+
* { "command": "oam", "args": ["run", "<abs>/dist/index.js", "--", "serve"] }
|
|
24
|
+
*
|
|
25
|
+
* NO SANDBOX HERE -- DELIBERATELY
|
|
26
|
+
* oam 0.9.0's `--permission` is real hardening, but it does not fit a linter.
|
|
27
|
+
* ctxlint's whole purpose is to read context files the caller names at run time
|
|
28
|
+
* -- CLAUDE.md, skills, agent transcripts, MCP configs, anywhere on disk -- so a
|
|
29
|
+
* filesystem-read grant would have to be `*` to keep the tool working. Narrowing
|
|
30
|
+
* it would turn "this path is not linted" into a silent clean result, which is
|
|
31
|
+
* the worst failure mode a linter has. What is left to deny (network, child
|
|
32
|
+
* process) it never uses anyway, so the sandbox would gate nothing real.
|
|
33
|
+
*
|
|
34
|
+
* MINIMUM OAM VERSION
|
|
35
|
+
* 0.9.0. Below it `child_process.execFile` ran its arguments through a SHELL,
|
|
36
|
+
* `exec` accepted `timeout` and ignored it, `spawnSync` truncated at `maxBuffer`
|
|
37
|
+
* while reporting success, and `stdio: 'inherit'`/`'ignore'` both behaved as
|
|
38
|
+
* `'pipe'`. This tool spawns nothing in shipped code, so the floor is enforced
|
|
39
|
+
* for consistency across @yawlabs/*-mcp rather than because this launcher was
|
|
40
|
+
* exposed. An older oam is not an error: the launcher falls back to Node and
|
|
41
|
+
* says so on stderr.
|
|
42
|
+
*
|
|
43
|
+
* SELECTION
|
|
44
|
+
* CTXLINT_RUNTIME=oam require oam; fail loudly if it is missing
|
|
45
|
+
* CTXLINT_RUNTIME=node never use oam
|
|
46
|
+
* CTXLINT_RUNTIME=auto prefer oam, silently fall back (default)
|
|
47
|
+
* OAM_BIN=/path/to/oam explicit binary, checked before any discovery
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
51
|
+
import { existsSync } from "node:fs";
|
|
52
|
+
import { constants, homedir } from "node:os";
|
|
53
|
+
import { delimiter, join } from "node:path";
|
|
54
|
+
import { fileURLToPath } from "node:url";
|
|
55
|
+
|
|
56
|
+
/** Oldest oam whose `child_process` matches Node. See MINIMUM OAM VERSION above. */
|
|
57
|
+
const OAM_MIN = [0, 9, 0];
|
|
58
|
+
|
|
59
|
+
// Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
|
|
60
|
+
// with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
|
|
61
|
+
// in-process fallback must use the file:// URL. spawn() needs a real path.
|
|
62
|
+
const SERVER_URL = new URL("../dist/index.js", import.meta.url);
|
|
63
|
+
const SERVER_ENTRY = fileURLToPath(SERVER_URL);
|
|
64
|
+
const isWin = process.platform === "win32";
|
|
65
|
+
const exe = isWin ? "oam.exe" : "oam";
|
|
66
|
+
|
|
67
|
+
/** Locate an oam binary, or null. Every branch is a stat, never a subprocess. */
|
|
68
|
+
function findOam() {
|
|
69
|
+
// 1. Explicit override wins and is never second-guessed.
|
|
70
|
+
const override = process.env.OAM_BIN;
|
|
71
|
+
if (override) return existsSync(override) ? override : null;
|
|
72
|
+
|
|
73
|
+
// 2. Installed locations, BEFORE PATH. Someone who develops oam itself usually
|
|
74
|
+
// has oam/target/release on PATH, and a build directory is the wrong thing
|
|
75
|
+
// for a user-facing launcher to bind to: cargo replaces the binary
|
|
76
|
+
// underneath running processes, and the dev build is not the release the
|
|
77
|
+
// user installed. OAM_BIN remains the way to point at a dev build.
|
|
78
|
+
const installed = [join(homedir(), ".oam", "bin", exe)];
|
|
79
|
+
if (isWin) {
|
|
80
|
+
installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
|
|
81
|
+
}
|
|
82
|
+
for (const candidate of installed) {
|
|
83
|
+
if (existsSync(candidate)) return candidate;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 3. PATH, resolved manually rather than by spawning `which`/`where`, which
|
|
87
|
+
// would cost a subprocess on every launch just to decide whether to spawn.
|
|
88
|
+
const pathExt = isWin ? (process.env.PATHEXT ?? ".EXE").split(";").filter(Boolean) : [""];
|
|
89
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
90
|
+
if (!dir) continue;
|
|
91
|
+
for (const ext of isWin ? pathExt : [""]) {
|
|
92
|
+
const candidate = join(dir, isWin ? `oam${ext.toLowerCase()}` : "oam");
|
|
93
|
+
if (existsSync(candidate)) return candidate;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `oam --version` -> [major, minor, patch], or null when it cannot be read.
|
|
102
|
+
* A pre-release suffix (0.9.0-rc.1) truncates to its base version.
|
|
103
|
+
*/
|
|
104
|
+
function oamVersion(cmd) {
|
|
105
|
+
try {
|
|
106
|
+
const out = execFileSync(cmd, ["--version"], {
|
|
107
|
+
encoding: "utf-8",
|
|
108
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
109
|
+
});
|
|
110
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
|
|
111
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
112
|
+
} catch {
|
|
113
|
+
// Not executable, wrong arch, or deleted since the stat. Caller degrades.
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** True when `v` is at least `min`, comparing major/minor/patch in order. */
|
|
119
|
+
function atLeast(v, min) {
|
|
120
|
+
if (!v) return false;
|
|
121
|
+
for (let i = 0; i < min.length; i++) {
|
|
122
|
+
if (v[i] > min[i]) return true;
|
|
123
|
+
if (v[i] < min[i]) return false;
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Run the CLI in THIS process. The zero-overhead fallback. */
|
|
129
|
+
async function runInProcess() {
|
|
130
|
+
// Point argv[1] at the CLI first, so the in-process path is indistinguishable
|
|
131
|
+
// from having executed the file directly -- an entry-point guard
|
|
132
|
+
// (`import.meta.url === pathToFileURL(process.argv[1]).href`) must read true.
|
|
133
|
+
process.argv[1] = SERVER_ENTRY;
|
|
134
|
+
await import(SERVER_URL.href);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const mode = (process.env.CTXLINT_RUNTIME ?? "auto").toLowerCase();
|
|
138
|
+
|
|
139
|
+
if (mode === "node") {
|
|
140
|
+
await runInProcess();
|
|
141
|
+
} else {
|
|
142
|
+
const oam = findOam();
|
|
143
|
+
|
|
144
|
+
if (!oam) {
|
|
145
|
+
if (mode === "oam") {
|
|
146
|
+
// Explicitly demanded, so this is a real misconfiguration -- do not
|
|
147
|
+
// silently do something else. writeSync because stderr is async for
|
|
148
|
+
// TTYs/pipes on Windows and process.exit truncates pending writes.
|
|
149
|
+
const { writeSync } = await import("node:fs");
|
|
150
|
+
writeSync(
|
|
151
|
+
2,
|
|
152
|
+
"ctxlint: CTXLINT_RUNTIME=oam but no oam binary was found.\n" +
|
|
153
|
+
"Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use CTXLINT_RUNTIME=node.\n",
|
|
154
|
+
);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
await runInProcess();
|
|
158
|
+
} else if (!atLeast(oamVersion(oam), OAM_MIN)) {
|
|
159
|
+
// Discovery itself stays stat-only; this is the first subprocess, and it
|
|
160
|
+
// runs only once we have already decided to spawn oam anyway. Measured 26ms
|
|
161
|
+
// median (n=12, windows-arm64), paid once per invocation.
|
|
162
|
+
const min = OAM_MIN.join(".");
|
|
163
|
+
if (mode === "oam") {
|
|
164
|
+
const { writeSync } = await import("node:fs");
|
|
165
|
+
writeSync(
|
|
166
|
+
2,
|
|
167
|
+
`ctxlint: CTXLINT_RUNTIME=oam but ${oam} is older than oam ${min}.\n` +
|
|
168
|
+
`Run \`oam self-update\`, or use CTXLINT_RUNTIME=node.\n`,
|
|
169
|
+
);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
// auto: an old oam is a reason to prefer Node, not to fail. Say so, because
|
|
173
|
+
// a silent downgrade is how someone keeps running an oam they meant to
|
|
174
|
+
// update. stderr is safe -- MCP frames travel on stdout under `serve`.
|
|
175
|
+
process.stderr.write(`ctxlint: oam at ${oam} is older than ${min}; using Node instead.\n`);
|
|
176
|
+
await runInProcess();
|
|
177
|
+
} else {
|
|
178
|
+
// `--` separates oam's own flags from the script's argv. Everything after it
|
|
179
|
+
// lands in process.argv for the CLI, so `audit`, `serve` and every flag
|
|
180
|
+
// survive the hop unchanged.
|
|
181
|
+
const child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
|
|
182
|
+
// inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
|
|
183
|
+
// stdin/stdout under `serve` is untouched, and the linter's exit-code and
|
|
184
|
+
// output behavior is identical to running it directly.
|
|
185
|
+
stdio: "inherit",
|
|
186
|
+
env: process.env,
|
|
187
|
+
windowsHide: true,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// If oam cannot be executed at all (deleted between the stat and the spawn,
|
|
191
|
+
// wrong arch, permission), fall back rather than failing outright.
|
|
192
|
+
// `spawned` guards against falling back AFTER the child has begun running.
|
|
193
|
+
let spawned = false;
|
|
194
|
+
child.on("spawn", () => {
|
|
195
|
+
spawned = true;
|
|
196
|
+
});
|
|
197
|
+
child.on("error", (err) => {
|
|
198
|
+
if (spawned) return;
|
|
199
|
+
if (mode === "oam") {
|
|
200
|
+
process.stderr.write(`ctxlint: failed to launch oam (${err.message})\n`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
void runInProcess();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Forward termination so the CLI's own shutdown path runs in the child
|
|
207
|
+
// rather than the child being orphaned. Signals are a no-op on Windows but
|
|
208
|
+
// harmless to register.
|
|
209
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
210
|
+
process.on(sig, () => {
|
|
211
|
+
if (!child.killed) child.kill(sig);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
child.on("exit", (code, signal) => {
|
|
216
|
+
// Mirror the child's fate: a signal death becomes 128+n so callers see a
|
|
217
|
+
// conventional shell exit status rather than a bare 0. ctxlint's exit code
|
|
218
|
+
// is how CI reads a lint failure, so passing it through is load-bearing.
|
|
219
|
+
if (signal) {
|
|
220
|
+
process.exit(128 + (constants.signals[signal] ?? 15));
|
|
221
|
+
}
|
|
222
|
+
process.exit(code ?? 0);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -28968,7 +28968,7 @@ import { readFileSync as readFileSync8 } from "node:fs";
|
|
|
28968
28968
|
import { resolve as resolve15, dirname as dirname7 } from "node:path";
|
|
28969
28969
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
28970
28970
|
function loadVersion() {
|
|
28971
|
-
if (true) return "0.
|
|
28971
|
+
if (true) return "0.21.0";
|
|
28972
28972
|
try {
|
|
28973
28973
|
const __dir = dirname7(fileURLToPath2(import.meta.url));
|
|
28974
28974
|
const pkgPath = resolve15(__dir, "../package.json");
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/ctxlint",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"mcpName": "io.github.YawLabs/ctxlint",
|
|
5
5
|
"description": "Lint your AI agent context files, MCP server configs, and session data against your actual codebase",
|
|
6
6
|
"bin": {
|
|
7
|
-
"ctxlint": "
|
|
7
|
+
"ctxlint": "bin/ctxlint.mjs"
|
|
8
8
|
},
|
|
9
9
|
"type": "module",
|
|
10
10
|
"exports": {
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
],
|
|
60
60
|
"files": [
|
|
61
61
|
"dist/index.js",
|
|
62
|
+
"bin/ctxlint.mjs",
|
|
62
63
|
".pre-commit-hooks.yaml",
|
|
63
64
|
"action.yml",
|
|
64
65
|
"CONTEXT_LINT_SPEC.md",
|