@yawlabs/ctxlint 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  # Version-pinned so a checkout at `rev: vX.Y.Z` runs exactly that release
5
5
  # of ctxlint — matches the pinning done by `ctxlint init`. release.sh keeps
6
6
  # this in sync with package.json on each bump.
7
- entry: npx @yawlabs/ctxlint@0.21.0 --strict
7
+ entry: npx @yawlabs/ctxlint@0.23.0 --strict
8
8
  language: node
9
9
  always_run: true
10
10
  pass_filenames: false
@@ -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
@@ -24050,84 +24050,443 @@ var init_cli_subcommands = __esm({
24050
24050
  }
24051
24051
  });
24052
24052
 
24053
- // src/core/checks/commands.ts
24053
+ // src/core/checks/tokens.ts
24054
+ function resolveTokenThresholds(overrides) {
24055
+ if (!overrides) return DEFAULT_TOKEN_THRESHOLDS;
24056
+ const merged = { ...DEFAULT_TOKEN_THRESHOLDS, ...overrides };
24057
+ if (merged.info >= merged.warning || merged.warning >= merged.error) {
24058
+ console.error(
24059
+ `Warning: token thresholds should satisfy info < warning < error (got ${merged.info}, ${merged.warning}, ${merged.error}) \u2014 using defaults`
24060
+ );
24061
+ return DEFAULT_TOKEN_THRESHOLDS;
24062
+ }
24063
+ return merged;
24064
+ }
24065
+ async function checkTokens(file2, _projectRoot, thresholds = DEFAULT_TOKEN_THRESHOLDS) {
24066
+ const issues = [];
24067
+ const tokens = file2.totalTokens;
24068
+ if (tokens >= thresholds.error) {
24069
+ issues.push({
24070
+ severity: "error",
24071
+ check: "tokens",
24072
+ ruleId: "tokens/excessive",
24073
+ line: 1,
24074
+ message: `${tokens.toLocaleString()} tokens \u2014 consumes significant context window space`,
24075
+ suggestion: "Consider splitting into focused sections or removing redundant content."
24076
+ });
24077
+ } else if (tokens >= thresholds.warning) {
24078
+ issues.push({
24079
+ severity: "warning",
24080
+ check: "tokens",
24081
+ ruleId: "tokens/large",
24082
+ line: 1,
24083
+ message: `${tokens.toLocaleString()} tokens \u2014 large context file`,
24084
+ suggestion: "Consider trimming \u2014 research shows diminishing returns past ~300 lines."
24085
+ });
24086
+ } else if (tokens >= thresholds.info) {
24087
+ issues.push({
24088
+ severity: "info",
24089
+ check: "tokens",
24090
+ ruleId: "tokens/info",
24091
+ line: 1,
24092
+ message: `Uses ~${tokens.toLocaleString()} tokens per session`
24093
+ });
24094
+ }
24095
+ return issues;
24096
+ }
24097
+ function checkAggregateTokens(files, thresholds = DEFAULT_TOKEN_THRESHOLDS) {
24098
+ const total = files.reduce((sum, f) => sum + f.tokens, 0);
24099
+ if (total >= thresholds.aggregate && files.length > 1) {
24100
+ return {
24101
+ severity: "warning",
24102
+ check: "tokens",
24103
+ ruleId: "tokens/aggregate",
24104
+ line: 0,
24105
+ message: `${files.length} context files consume ${total.toLocaleString()} tokens combined`,
24106
+ suggestion: "Consider consolidating or trimming to reduce per-session context cost."
24107
+ };
24108
+ }
24109
+ return null;
24110
+ }
24111
+ var DEFAULT_TOKEN_THRESHOLDS;
24112
+ var init_tokens2 = __esm({
24113
+ "src/core/checks/tokens.ts"() {
24114
+ "use strict";
24115
+ init_define_WEB_FIRST_SEGMENTS();
24116
+ DEFAULT_TOKEN_THRESHOLDS = {
24117
+ info: 1e3,
24118
+ warning: 3e3,
24119
+ error: 8e3,
24120
+ aggregate: 5e3,
24121
+ tierBreakdown: 1e3,
24122
+ tierAggregate: 4e3
24123
+ };
24124
+ }
24125
+ });
24126
+
24127
+ // src/core/checks/tier-tokens.ts
24054
24128
  import * as fs6 from "node:fs";
24129
+ import * as os2 from "node:os";
24055
24130
  import * as path7 from "node:path";
24056
- function isManagerBuiltin(manager, sub) {
24057
- return PM_MANAGER_BUILTINS[manager]?.has(sub) ?? false;
24131
+ function hasFrontmatterList(content, field) {
24132
+ const lines = content.split("\n");
24133
+ if (lines[0]?.trim() !== "---") return false;
24134
+ const fieldPattern = new RegExp(`^${field}\\s*:\\s*(.*)$`);
24135
+ for (let i2 = 1; i2 < lines.length; i2++) {
24136
+ const line = lines[i2];
24137
+ if (line.trim() === "---") return false;
24138
+ const match = line.match(fieldPattern);
24139
+ if (match) {
24140
+ const val = match[1].trim();
24141
+ if (val && val !== "[]") return true;
24142
+ for (let j3 = i2 + 1; j3 < lines.length; j3++) {
24143
+ const next = lines[j3];
24144
+ if (next.trim() === "---") return false;
24145
+ if (next.trim() === "") continue;
24146
+ if (next.trim().startsWith("- ")) return true;
24147
+ break;
24148
+ }
24149
+ }
24150
+ }
24151
+ return false;
24058
24152
  }
24059
- function scriptNameFromMatch(match) {
24060
- const [, manager, explicitRun, name] = match;
24061
- if (name.startsWith("-")) return null;
24062
- if (manager && !explicitRun && PM_BUILTIN_SUBCOMMANDS.has(name)) return null;
24063
- if (manager && !explicitRun && isManagerBuiltin(manager, name)) return null;
24064
- return name;
24153
+ function frontmatterScalar(content, field) {
24154
+ const lines = content.split("\n");
24155
+ if (lines[0]?.trim() !== "---") return null;
24156
+ const fieldPattern = new RegExp(`^${field}\\s*:\\s*(.*)$`);
24157
+ for (let i2 = 1; i2 < lines.length; i2++) {
24158
+ if (lines[i2].trim() === "---") return null;
24159
+ const match = lines[i2].match(fieldPattern);
24160
+ if (match) return match[1].trim().replace(/^['"]|['"]$/g, "") || null;
24161
+ }
24162
+ return null;
24065
24163
  }
24066
- function extractNpxPackage(cmd) {
24067
- if (!/^npx\b/.test(cmd)) return null;
24068
- const tokens = cmd.split(/\s+/).slice(1);
24069
- for (let i2 = 0; i2 < tokens.length; i2++) {
24070
- const t2 = tokens[i2];
24071
- if (t2 === "-p" || t2 === "--package") {
24072
- const v2 = tokens[i2 + 1];
24073
- if (v2 && !v2.startsWith("-")) return v2;
24074
- continue;
24164
+ function isAlwaysLoaded(file2) {
24165
+ const rel = file2.relativePath.replace(/\\/g, "/");
24166
+ if (rel.endsWith(".mdc")) return false;
24167
+ if (rel.startsWith(".github/instructions/")) return false;
24168
+ if (rel.includes("/rules/")) {
24169
+ if (hasFrontmatterList(file2.content, "paths")) return false;
24170
+ if (hasFrontmatterList(file2.content, "globs")) return false;
24171
+ if (rel.includes(".windsurf/rules/")) {
24172
+ const trigger = frontmatterScalar(file2.content, "trigger");
24173
+ if (trigger && trigger.toLowerCase() !== "always_on") return false;
24075
24174
  }
24076
- if (t2.startsWith("-p=") || t2.startsWith("--package=")) {
24077
- return t2.slice(t2.indexOf("=") + 1) || null;
24175
+ return true;
24176
+ }
24177
+ const basename4 = rel.split("/").pop() ?? "";
24178
+ for (const name of ALWAYS_LOADED_NAMES) {
24179
+ if (name.includes("/")) {
24180
+ if (rel === name || rel.endsWith("/" + name)) return true;
24181
+ } else if (basename4 === name) {
24182
+ return true;
24078
24183
  }
24079
- if (t2.startsWith("-")) continue;
24080
- return t2;
24184
+ }
24185
+ return false;
24186
+ }
24187
+ function computeSectionCosts(file2) {
24188
+ if (file2.sections.length === 0) return [];
24189
+ const hasH2 = file2.sections.some((s) => s.level === 2);
24190
+ const topLevel = hasH2 ? 2 : 1;
24191
+ const lines = file2.content.split("\n");
24192
+ return file2.sections.filter((s) => s.level === topLevel).map((s) => {
24193
+ const body = lines.slice(s.startLine - 1, s.endLine).join("\n");
24194
+ return { title: s.title, line: s.startLine, tokens: countTokens(body) };
24195
+ }).sort((a, b2) => b2.tokens - a.tokens);
24196
+ }
24197
+ function inlineCodeSpans(line) {
24198
+ const spans = [];
24199
+ const re2 = /`([^`]+)`/g;
24200
+ let m;
24201
+ while ((m = re2.exec(line)) !== null) {
24202
+ spans.push({ start: m.index, end: m.index + m[0].length, content: m[1] });
24203
+ }
24204
+ return spans;
24205
+ }
24206
+ function maskCodeSpans(line, spans) {
24207
+ let out = line;
24208
+ for (const s of spans) {
24209
+ out = out.slice(0, s.start) + "#".repeat(s.end - s.start) + out.slice(s.end);
24210
+ }
24211
+ return out;
24212
+ }
24213
+ function findInviolableCommand(line) {
24214
+ const spans = inlineCodeSpans(line);
24215
+ if (spans.length === 0) return null;
24216
+ const masked = maskCodeSpans(line, spans);
24217
+ FRAMING_TOKEN.lastIndex = 0;
24218
+ let m;
24219
+ while ((m = FRAMING_TOKEN.exec(masked)) !== null) {
24220
+ const token = m[1];
24221
+ if (token === token.toLowerCase()) continue;
24222
+ const afterIdx = m.index + m[0].length;
24223
+ const span = spans.find((s) => s.start >= afterIdx);
24224
+ if (!span) continue;
24225
+ const gap = masked.slice(afterIdx, span.start);
24226
+ if (gap.length > FRAMING_COMMAND_GAP || /[.!?]/.test(gap)) continue;
24227
+ return { framing: token, command: span.content };
24081
24228
  }
24082
24229
  return null;
24083
24230
  }
24084
- function wouldNeedPackageJson(cmd) {
24085
- const scriptMatch = cmd.match(NPM_SCRIPT_PATTERN);
24086
- if (scriptMatch && scriptNameFromMatch(scriptMatch) !== null) return true;
24087
- const shorthandMatch = cmd.match(PKG_SHORTHAND_PATTERN);
24088
- if (shorthandMatch && !isManagerBuiltin(shorthandMatch[1], shorthandMatch[2])) return true;
24089
- return /^npx\b/.test(cmd) || PKG_DEPENDENT_TOOL_PATTERN.test(cmd);
24231
+ function fingerprintFiles(paths) {
24232
+ return paths.map((p2) => {
24233
+ try {
24234
+ const st2 = fs6.statSync(p2);
24235
+ return `${st2.mtimeMs}:${st2.size}`;
24236
+ } catch {
24237
+ return "absent";
24238
+ }
24239
+ }).join("|");
24090
24240
  }
24091
- function loadDeniedCommandPrefixes(projectRoot) {
24092
- const prefixes = [];
24093
- for (const rel of ["settings.json", "settings.local.json"]) {
24241
+ function loadSettingsSources(projectRoot, includeGlobal) {
24242
+ const candidates = [
24243
+ path7.join(projectRoot, ".claude", "settings.json"),
24244
+ path7.join(projectRoot, ".claude", "settings.local.json")
24245
+ ];
24246
+ if (includeGlobal) {
24247
+ candidates.push(path7.join(os2.homedir(), ".claude", "settings.json"));
24248
+ }
24249
+ const fingerprint = fingerprintFiles(candidates);
24250
+ if (settingsCache?.root === projectRoot && settingsCache.includeGlobal === includeGlobal && settingsCache.fingerprint === fingerprint) {
24251
+ return settingsCache.data;
24252
+ }
24253
+ const sources = [];
24254
+ for (const p2 of candidates) {
24094
24255
  let content;
24095
24256
  try {
24096
- content = stripBom(fs6.readFileSync(path7.join(projectRoot, ".claude", rel), "utf-8"));
24257
+ content = stripBom(fs6.readFileSync(p2, "utf-8"));
24097
24258
  } catch {
24098
24259
  continue;
24099
24260
  }
24100
- const data = parse2(content, [], { allowTrailingComma: true });
24101
- const deny = data?.permissions?.deny;
24102
- if (!Array.isArray(deny)) continue;
24103
- for (const entry of deny) {
24104
- if (typeof entry !== "string") continue;
24105
- const inner = entry.replace(/^[A-Za-z]+\((.*)\)$/, "$1");
24106
- const prefix = inner.replace(/:?\*+$/, "").trim();
24107
- if (prefix) prefixes.push(prefix);
24261
+ const errors = [];
24262
+ const data = parse2(content, errors, { allowTrailingComma: true });
24263
+ if (errors.length > 0) {
24264
+ console.warn(
24265
+ `ctxlint: could not parse ${p2}: ${printParseErrorCode(errors[0].error)} at offset ${errors[0].offset}`
24266
+ );
24267
+ continue;
24108
24268
  }
24269
+ if (!data || typeof data !== "object") continue;
24270
+ sources.push(data);
24109
24271
  }
24110
- return prefixes;
24272
+ settingsCache = { root: projectRoot, includeGlobal, fingerprint, data: sources };
24273
+ return sources;
24111
24274
  }
24112
- function isDeniedCommand(cmd, deniedPrefixes) {
24113
- return deniedPrefixes.some((p2) => cmd === p2 || cmd.startsWith(`${p2} `));
24275
+ function canonicalizeCommand(backticked) {
24276
+ const beforeFlags = backticked.trim().split(/\s+--?/, 1)[0];
24277
+ return beforeFlags.replace(/\s+/g, " ");
24114
24278
  }
24115
- async function checkCommands(file2, projectRoot) {
24116
- const issues = [];
24117
- const pkgJson = loadPackageJson(projectRoot);
24118
- const makefile = loadMakefile(projectRoot);
24119
- const deniedPrefixes = loadDeniedCommandPrefixes(projectRoot);
24120
- if (!pkgJson) {
24121
- const skipped = file2.references.commands.find((ref) => wouldNeedPackageJson(ref.value));
24122
- if (skipped) {
24123
- issues.push({
24124
- severity: "info",
24125
- check: "commands",
24126
- ruleId: "commands/package-json-missing",
24127
- line: skipped.line,
24128
- message: "package.json missing or unparseable \u2014 command checks skipped",
24129
- suggestion: "Add a parseable package.json at the project root so script, npx, and tool references can be validated."
24130
- });
24279
+ function buildCommandPattern(cmd) {
24280
+ const tokens = cmd.split(/\s+/).filter(Boolean);
24281
+ const escaped = tokens.map((t2) => t2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
24282
+ if (tokens.length === 1) {
24283
+ return new RegExp(`(?<![A-Za-z0-9_\\-])${escaped[0]}(?![A-Za-z0-9_\\-])`, "i");
24284
+ }
24285
+ const body = escaped.join("[\\s\\-_]+");
24286
+ return new RegExp(`(?<![A-Za-z0-9])${body}(?![A-Za-z0-9])`, "i");
24287
+ }
24288
+ function commandIsEnforced(cmd, settings) {
24289
+ const pattern = buildCommandPattern(cmd);
24290
+ for (const s of settings) {
24291
+ for (const entry of [...s.permissions?.deny ?? [], ...s.permissions?.ask ?? []]) {
24292
+ if (pattern.test(entry)) return true;
24293
+ }
24294
+ for (const h2 of [...s.hooks?.PreToolUse ?? [], ...s.hooks?.Stop ?? []]) {
24295
+ if (pattern.test(h2.matcher || "")) return true;
24296
+ for (const sub of h2.hooks ?? []) {
24297
+ if (pattern.test(sub.command || "")) return true;
24298
+ }
24299
+ }
24300
+ }
24301
+ return false;
24302
+ }
24303
+ function checkHardEnforcement(file2, settings) {
24304
+ const issues = [];
24305
+ const lines = file2.content.split("\n");
24306
+ for (let i2 = 0; i2 < lines.length; i2++) {
24307
+ const line = lines[i2];
24308
+ const match = findInviolableCommand(line);
24309
+ if (!match) continue;
24310
+ const cmd = canonicalizeCommand(match.command);
24311
+ if (!cmd) continue;
24312
+ if (commandIsEnforced(cmd, settings)) continue;
24313
+ const suggestion = match.framing.toUpperCase() === "ALWAYS" ? `Rules in always-loaded files are advisory. For \`${cmd}\`, add a hook in .claude/settings.json (e.g. a PreToolUse or Stop hook that runs or verifies \`${cmd}\`) so the requirement doesn't depend on the agent remembering.` : `Rules in always-loaded files are advisory. For \`${cmd}\`, add a PreToolUse hook (or permissions.deny entry) in .claude/settings.json so the command is physically blocked.`;
24314
+ issues.push({
24315
+ severity: "info",
24316
+ check: "tier-tokens",
24317
+ ruleId: "tier-tokens/hard-enforcement-missing",
24318
+ line: i2 + 1,
24319
+ message: `Inviolable framing ("${line.trim().slice(0, 80)}") without a hook to back it up`,
24320
+ suggestion
24321
+ });
24322
+ }
24323
+ return issues;
24324
+ }
24325
+ async function checkTierTokens(file2, projectRoot, thresholds = DEFAULT_TOKEN_THRESHOLDS, includeGlobal = false) {
24326
+ if (!isAlwaysLoaded(file2)) return [];
24327
+ const issues = [];
24328
+ const threshold = thresholds.tierBreakdown;
24329
+ if (file2.totalTokens >= threshold) {
24330
+ const sectionCosts = computeSectionCosts(file2);
24331
+ if (sectionCosts.length > 0) {
24332
+ const top = sectionCosts.slice(0, TOP_SECTIONS_TO_REPORT);
24333
+ const heaviest = top[0];
24334
+ const pct = Math.round(heaviest.tokens / file2.totalTokens * 100);
24335
+ const detail = top.map((s) => ` - "${s.title}" (L${s.line}): ~${s.tokens.toLocaleString()} tokens`).join("\n");
24336
+ issues.push({
24337
+ severity: "info",
24338
+ check: "tier-tokens",
24339
+ ruleId: "tier-tokens/section-breakdown",
24340
+ line: heaviest.line,
24341
+ message: `${file2.totalTokens.toLocaleString()} tokens loaded every session \u2014 heaviest top-level section${top.length === 1 ? "" : "s"}:`,
24342
+ detail,
24343
+ suggestion: `"${heaviest.title}" is ~${heaviest.tokens.toLocaleString()} tokens (${pct}% of file). Consider demoting to an on-demand tier (skill, subagent, or memory) so it loads only when relevant.`
24344
+ });
24345
+ }
24346
+ }
24347
+ const settings = loadSettingsSources(projectRoot, includeGlobal);
24348
+ issues.push(...checkHardEnforcement(file2, settings));
24349
+ return issues;
24350
+ }
24351
+ function checkAggregateTierTokens(files, thresholds = DEFAULT_TOKEN_THRESHOLDS) {
24352
+ const alwaysLoaded = files.filter(isAlwaysLoaded);
24353
+ if (alwaysLoaded.length < 2) return null;
24354
+ const total = alwaysLoaded.reduce((sum, f) => sum + f.totalTokens, 0);
24355
+ const threshold = thresholds.tierAggregate;
24356
+ if (total < threshold) return null;
24357
+ const breakdown = alwaysLoaded.slice().sort((a, b2) => b2.totalTokens - a.totalTokens).slice(0, 5).map((f) => ` - ${f.relativePath}: ~${f.totalTokens.toLocaleString()} tokens`).join("\n");
24358
+ return {
24359
+ severity: "warning",
24360
+ check: "tier-tokens",
24361
+ ruleId: "tier-tokens/aggregate",
24362
+ line: 0,
24363
+ message: `${alwaysLoaded.length} always-loaded files total ${total.toLocaleString()} tokens \u2014 loaded every session`,
24364
+ detail: breakdown,
24365
+ suggestion: "Consider moving the largest files or their heaviest sections to on-demand tiers (skills, subagents, memory)."
24366
+ };
24367
+ }
24368
+ var ALWAYS_LOADED_NAMES, TOP_SECTIONS_TO_REPORT, FRAMING_TOKEN, FRAMING_COMMAND_GAP, settingsCache;
24369
+ var init_tier_tokens = __esm({
24370
+ "src/core/checks/tier-tokens.ts"() {
24371
+ "use strict";
24372
+ init_define_WEB_FIRST_SEGMENTS();
24373
+ init_main3();
24374
+ init_tokens();
24375
+ init_fs();
24376
+ init_tokens2();
24377
+ ALWAYS_LOADED_NAMES = [
24378
+ "CLAUDE.md",
24379
+ "CLAUDE.local.md",
24380
+ "AGENTS.md",
24381
+ "AGENTS.override.md",
24382
+ "AGENT.md",
24383
+ "GEMINI.md",
24384
+ ".cursorrules",
24385
+ ".windsurfrules",
24386
+ ".clinerules",
24387
+ ".aiderules",
24388
+ ".continuerules",
24389
+ ".rules",
24390
+ ".goosehints",
24391
+ "replit.md",
24392
+ ".github/copilot-instructions.md",
24393
+ ".junie/guidelines.md",
24394
+ ".junie/AGENTS.md",
24395
+ ".goose/instructions.md"
24396
+ ];
24397
+ TOP_SECTIONS_TO_REPORT = 3;
24398
+ FRAMING_TOKEN = /(?<![\w'.-])(never|always|don'?t|do\s+not|must\s+not)(?![\w'.-])/gi;
24399
+ FRAMING_COMMAND_GAP = 80;
24400
+ settingsCache = null;
24401
+ }
24402
+ });
24403
+
24404
+ // src/core/checks/commands.ts
24405
+ import * as fs7 from "node:fs";
24406
+ import * as path8 from "node:path";
24407
+ function isManagerBuiltin(manager, sub) {
24408
+ return PM_MANAGER_BUILTINS[manager]?.has(sub) ?? false;
24409
+ }
24410
+ function scriptNameFromMatch(match) {
24411
+ const [, manager, explicitRun, name] = match;
24412
+ if (name.startsWith("-")) return null;
24413
+ if (manager && !explicitRun && PM_BUILTIN_SUBCOMMANDS.has(name)) return null;
24414
+ if (manager && !explicitRun && isManagerBuiltin(manager, name)) return null;
24415
+ return name;
24416
+ }
24417
+ function extractNpxPackage(cmd) {
24418
+ if (!/^npx\b/.test(cmd)) return null;
24419
+ const tokens = cmd.split(/\s+/).slice(1);
24420
+ for (let i2 = 0; i2 < tokens.length; i2++) {
24421
+ const t2 = tokens[i2];
24422
+ if (t2 === "-p" || t2 === "--package") {
24423
+ const v2 = tokens[i2 + 1];
24424
+ if (v2 && !v2.startsWith("-")) return v2;
24425
+ continue;
24426
+ }
24427
+ if (t2.startsWith("-p=") || t2.startsWith("--package=")) {
24428
+ return t2.slice(t2.indexOf("=") + 1) || null;
24429
+ }
24430
+ if (t2.startsWith("-")) continue;
24431
+ return t2;
24432
+ }
24433
+ return null;
24434
+ }
24435
+ function wouldNeedPackageJson(cmd) {
24436
+ const scriptMatch = cmd.match(NPM_SCRIPT_PATTERN);
24437
+ if (scriptMatch && scriptNameFromMatch(scriptMatch) !== null) return true;
24438
+ const shorthandMatch = cmd.match(PKG_SHORTHAND_PATTERN);
24439
+ if (shorthandMatch && !isManagerBuiltin(shorthandMatch[1], shorthandMatch[2])) return true;
24440
+ return /^npx\b/.test(cmd) || PKG_DEPENDENT_TOOL_PATTERN.test(cmd);
24441
+ }
24442
+ function loadDeniedCommandPrefixes(projectRoot) {
24443
+ const prefixes = [];
24444
+ for (const rel of ["settings.json", "settings.local.json"]) {
24445
+ let content;
24446
+ try {
24447
+ content = stripBom(fs7.readFileSync(path8.join(projectRoot, ".claude", rel), "utf-8"));
24448
+ } catch {
24449
+ continue;
24450
+ }
24451
+ const data = parse2(content, [], { allowTrailingComma: true });
24452
+ const deny = data?.permissions?.deny;
24453
+ if (!Array.isArray(deny)) continue;
24454
+ for (const entry of deny) {
24455
+ if (typeof entry !== "string") continue;
24456
+ const inner = entry.replace(/^[A-Za-z]+\((.*)\)$/, "$1");
24457
+ const prefix = inner.replace(/:?\*+$/, "").trim();
24458
+ if (prefix) prefixes.push(prefix);
24459
+ }
24460
+ }
24461
+ return prefixes;
24462
+ }
24463
+ function isDeniedCommand(cmd, deniedPrefixes) {
24464
+ return deniedPrefixes.some((p2) => cmd === p2 || cmd.startsWith(`${p2} `));
24465
+ }
24466
+ function isProhibitedMention(lines, ref) {
24467
+ const line = lines[ref.line - 1] ?? "";
24468
+ const masked = maskCodeSpans(line, inlineCodeSpans(line));
24469
+ const prefix = masked.slice(0, Math.max(0, ref.column - 1));
24470
+ const clause = prefix.split(CLAUSE_TERMINATOR).pop() ?? "";
24471
+ return PROHIBITION_TOKEN.test(clause);
24472
+ }
24473
+ async function checkCommands(file2, projectRoot) {
24474
+ const issues = [];
24475
+ const pkgJson = loadPackageJson(projectRoot);
24476
+ const makefile = loadMakefile(projectRoot);
24477
+ const deniedPrefixes = loadDeniedCommandPrefixes(projectRoot);
24478
+ const contentLines = file2.content.split("\n");
24479
+ if (!pkgJson) {
24480
+ const skipped = file2.references.commands.find((ref) => wouldNeedPackageJson(ref.value));
24481
+ if (skipped) {
24482
+ issues.push({
24483
+ severity: "info",
24484
+ check: "commands",
24485
+ ruleId: "commands/package-json-missing",
24486
+ line: skipped.line,
24487
+ message: "package.json missing or unparseable \u2014 command checks skipped",
24488
+ suggestion: "Add a parseable package.json at the project root so script, npx, and tool references can be validated."
24489
+ });
24131
24490
  }
24132
24491
  }
24133
24492
  issues.push(...checkUnknownSubcommand(file2, projectRoot, pkgJson));
@@ -24188,9 +24547,10 @@ async function checkCommands(file2, projectRoot) {
24188
24547
  };
24189
24548
  if (!(pkgName in allDeps)) {
24190
24549
  if (isDeniedCommand(cmd, deniedPrefixes)) continue;
24191
- const binPath = path7.join(projectRoot, "node_modules", ".bin", pkgName);
24550
+ if (isProhibitedMention(contentLines, ref)) continue;
24551
+ const binPath = path8.join(projectRoot, "node_modules", ".bin", pkgName);
24192
24552
  try {
24193
- fs6.accessSync(binPath);
24553
+ fs7.accessSync(binPath);
24194
24554
  } catch {
24195
24555
  issues.push({
24196
24556
  severity: "warning",
@@ -24238,9 +24598,9 @@ async function checkCommands(file2, projectRoot) {
24238
24598
  ...pkgJson.optionalDependencies
24239
24599
  };
24240
24600
  if (!(pkgName in allDeps)) {
24241
- const binPath = path7.join(projectRoot, "node_modules", ".bin", tool);
24601
+ const binPath = path8.join(projectRoot, "node_modules", ".bin", tool);
24242
24602
  try {
24243
- fs6.accessSync(binPath);
24603
+ fs7.accessSync(binPath);
24244
24604
  } catch {
24245
24605
  issues.push({
24246
24606
  severity: "warning",
@@ -24284,7 +24644,7 @@ function checkUnknownSubcommand(file2, projectRoot, pkgJson) {
24284
24644
  }
24285
24645
  function loadMakefile(projectRoot) {
24286
24646
  try {
24287
- return stripBom(fs6.readFileSync(path7.join(projectRoot, "Makefile"), "utf-8"));
24647
+ return stripBom(fs7.readFileSync(path8.join(projectRoot, "Makefile"), "utf-8"));
24288
24648
  } catch {
24289
24649
  return null;
24290
24650
  }
@@ -24302,7 +24662,7 @@ function hasMakeTarget(makefile, target) {
24302
24662
  const pattern = new RegExp(`^${escaped}\\s*:(?!:?=)`, "m");
24303
24663
  return pattern.test(makefile);
24304
24664
  }
24305
- var NPM_SCRIPT_PATTERN, MAKE_PATTERN, PM_BUILTIN_SUBCOMMANDS, PM_MANAGER_BUILTINS, PKG_DEPENDENT_TOOL_PATTERN, BIN_TO_PACKAGE, PKG_SHORTHAND_PATTERN;
24665
+ var NPM_SCRIPT_PATTERN, MAKE_PATTERN, PM_BUILTIN_SUBCOMMANDS, PM_MANAGER_BUILTINS, PKG_DEPENDENT_TOOL_PATTERN, BIN_TO_PACKAGE, PKG_SHORTHAND_PATTERN, PROHIBITION_TOKEN, CLAUSE_TERMINATOR;
24306
24666
  var init_commands = __esm({
24307
24667
  "src/core/checks/commands.ts"() {
24308
24668
  "use strict";
@@ -24311,6 +24671,7 @@ var init_commands = __esm({
24311
24671
  init_fs();
24312
24672
  init_exit_status();
24313
24673
  init_cli_subcommands();
24674
+ init_tier_tokens();
24314
24675
  NPM_SCRIPT_PATTERN = /^(?:npm\s+run|(pnpm|yarn|bun)(?:\s+(run))?)\s+(\S+)/;
24315
24676
  MAKE_PATTERN = /^make\s+\S/;
24316
24677
  PM_BUILTIN_SUBCOMMANDS = /* @__PURE__ */ new Set([
@@ -24380,17 +24741,19 @@ var init_commands = __esm({
24380
24741
  tsc: "typescript"
24381
24742
  };
24382
24743
  PKG_SHORTHAND_PATTERN = /^(npm|pnpm|yarn|bun)\s+(test|start|build|dev|lint|format|check|typecheck|clean|serve|preview|e2e)\b/;
24744
+ PROHIBITION_TOKEN = /\b(?:never|don['’]?t|do\s+not|must\s+not|avoid)\b/i;
24745
+ CLAUSE_TERMINATOR = /[.!?;—]|\s--(?=\s|$)/;
24383
24746
  }
24384
24747
  });
24385
24748
 
24386
24749
  // src/core/checks/staleness.ts
24387
- import * as path8 from "node:path";
24750
+ import * as path9 from "node:path";
24388
24751
  async function checkStaleness(file2, projectRoot) {
24389
24752
  const issues = [];
24390
24753
  if (!await isGitRepo(projectRoot)) {
24391
24754
  return issues;
24392
24755
  }
24393
- const relativePath = path8.relative(projectRoot, file2.filePath).replace(/\\/g, "/");
24756
+ const relativePath = path9.relative(projectRoot, file2.filePath).replace(/\\/g, "/");
24394
24757
  const lastModified = await getFileLastModified(projectRoot, relativePath);
24395
24758
  if (!lastModified || isNaN(lastModified.getTime())) {
24396
24759
  return issues;
@@ -24506,322 +24869,6 @@ var init_suppressions = __esm({
24506
24869
  }
24507
24870
  });
24508
24871
 
24509
- // src/core/checks/tokens.ts
24510
- function resolveTokenThresholds(overrides) {
24511
- if (!overrides) return DEFAULT_TOKEN_THRESHOLDS;
24512
- const merged = { ...DEFAULT_TOKEN_THRESHOLDS, ...overrides };
24513
- if (merged.info >= merged.warning || merged.warning >= merged.error) {
24514
- console.error(
24515
- `Warning: token thresholds should satisfy info < warning < error (got ${merged.info}, ${merged.warning}, ${merged.error}) \u2014 using defaults`
24516
- );
24517
- return DEFAULT_TOKEN_THRESHOLDS;
24518
- }
24519
- return merged;
24520
- }
24521
- async function checkTokens(file2, _projectRoot, thresholds = DEFAULT_TOKEN_THRESHOLDS) {
24522
- const issues = [];
24523
- const tokens = file2.totalTokens;
24524
- if (tokens >= thresholds.error) {
24525
- issues.push({
24526
- severity: "error",
24527
- check: "tokens",
24528
- ruleId: "tokens/excessive",
24529
- line: 1,
24530
- message: `${tokens.toLocaleString()} tokens \u2014 consumes significant context window space`,
24531
- suggestion: "Consider splitting into focused sections or removing redundant content."
24532
- });
24533
- } else if (tokens >= thresholds.warning) {
24534
- issues.push({
24535
- severity: "warning",
24536
- check: "tokens",
24537
- ruleId: "tokens/large",
24538
- line: 1,
24539
- message: `${tokens.toLocaleString()} tokens \u2014 large context file`,
24540
- suggestion: "Consider trimming \u2014 research shows diminishing returns past ~300 lines."
24541
- });
24542
- } else if (tokens >= thresholds.info) {
24543
- issues.push({
24544
- severity: "info",
24545
- check: "tokens",
24546
- ruleId: "tokens/info",
24547
- line: 1,
24548
- message: `Uses ~${tokens.toLocaleString()} tokens per session`
24549
- });
24550
- }
24551
- return issues;
24552
- }
24553
- function checkAggregateTokens(files, thresholds = DEFAULT_TOKEN_THRESHOLDS) {
24554
- const total = files.reduce((sum, f) => sum + f.tokens, 0);
24555
- if (total >= thresholds.aggregate && files.length > 1) {
24556
- return {
24557
- severity: "warning",
24558
- check: "tokens",
24559
- ruleId: "tokens/aggregate",
24560
- line: 0,
24561
- message: `${files.length} context files consume ${total.toLocaleString()} tokens combined`,
24562
- suggestion: "Consider consolidating or trimming to reduce per-session context cost."
24563
- };
24564
- }
24565
- return null;
24566
- }
24567
- var DEFAULT_TOKEN_THRESHOLDS;
24568
- var init_tokens2 = __esm({
24569
- "src/core/checks/tokens.ts"() {
24570
- "use strict";
24571
- init_define_WEB_FIRST_SEGMENTS();
24572
- DEFAULT_TOKEN_THRESHOLDS = {
24573
- info: 1e3,
24574
- warning: 3e3,
24575
- error: 8e3,
24576
- aggregate: 5e3,
24577
- tierBreakdown: 1e3,
24578
- tierAggregate: 4e3
24579
- };
24580
- }
24581
- });
24582
-
24583
- // src/core/checks/tier-tokens.ts
24584
- import * as fs7 from "node:fs";
24585
- import * as os2 from "node:os";
24586
- import * as path9 from "node:path";
24587
- function hasFrontmatterList(content, field) {
24588
- const lines = content.split("\n");
24589
- if (lines[0]?.trim() !== "---") return false;
24590
- const fieldPattern = new RegExp(`^${field}\\s*:\\s*(.*)$`);
24591
- for (let i2 = 1; i2 < lines.length; i2++) {
24592
- const line = lines[i2];
24593
- if (line.trim() === "---") return false;
24594
- const match = line.match(fieldPattern);
24595
- if (match) {
24596
- const val = match[1].trim();
24597
- if (val && val !== "[]") return true;
24598
- for (let j3 = i2 + 1; j3 < lines.length; j3++) {
24599
- const next = lines[j3];
24600
- if (next.trim() === "---") return false;
24601
- if (next.trim() === "") continue;
24602
- if (next.trim().startsWith("- ")) return true;
24603
- break;
24604
- }
24605
- }
24606
- }
24607
- return false;
24608
- }
24609
- function frontmatterScalar(content, field) {
24610
- const lines = content.split("\n");
24611
- if (lines[0]?.trim() !== "---") return null;
24612
- const fieldPattern = new RegExp(`^${field}\\s*:\\s*(.*)$`);
24613
- for (let i2 = 1; i2 < lines.length; i2++) {
24614
- if (lines[i2].trim() === "---") return null;
24615
- const match = lines[i2].match(fieldPattern);
24616
- if (match) return match[1].trim().replace(/^['"]|['"]$/g, "") || null;
24617
- }
24618
- return null;
24619
- }
24620
- function isAlwaysLoaded(file2) {
24621
- const rel = file2.relativePath.replace(/\\/g, "/");
24622
- if (rel.endsWith(".mdc")) return false;
24623
- if (rel.startsWith(".github/instructions/")) return false;
24624
- if (rel.includes("/rules/")) {
24625
- if (hasFrontmatterList(file2.content, "paths")) return false;
24626
- if (hasFrontmatterList(file2.content, "globs")) return false;
24627
- if (rel.includes(".windsurf/rules/")) {
24628
- const trigger = frontmatterScalar(file2.content, "trigger");
24629
- if (trigger && trigger.toLowerCase() !== "always_on") return false;
24630
- }
24631
- return true;
24632
- }
24633
- const basename4 = rel.split("/").pop() ?? "";
24634
- for (const name of ALWAYS_LOADED_NAMES) {
24635
- if (name.includes("/")) {
24636
- if (rel === name || rel.endsWith("/" + name)) return true;
24637
- } else if (basename4 === name) {
24638
- return true;
24639
- }
24640
- }
24641
- return false;
24642
- }
24643
- function computeSectionCosts(file2) {
24644
- if (file2.sections.length === 0) return [];
24645
- const hasH2 = file2.sections.some((s) => s.level === 2);
24646
- const topLevel = hasH2 ? 2 : 1;
24647
- const lines = file2.content.split("\n");
24648
- return file2.sections.filter((s) => s.level === topLevel).map((s) => {
24649
- const body = lines.slice(s.startLine - 1, s.endLine).join("\n");
24650
- return { title: s.title, line: s.startLine, tokens: countTokens(body) };
24651
- }).sort((a, b2) => b2.tokens - a.tokens);
24652
- }
24653
- function fingerprintFiles(paths) {
24654
- return paths.map((p2) => {
24655
- try {
24656
- const st2 = fs7.statSync(p2);
24657
- return `${st2.mtimeMs}:${st2.size}`;
24658
- } catch {
24659
- return "absent";
24660
- }
24661
- }).join("|");
24662
- }
24663
- function loadSettingsSources(projectRoot, includeGlobal) {
24664
- const candidates = [
24665
- path9.join(projectRoot, ".claude", "settings.json"),
24666
- path9.join(projectRoot, ".claude", "settings.local.json")
24667
- ];
24668
- if (includeGlobal) {
24669
- candidates.push(path9.join(os2.homedir(), ".claude", "settings.json"));
24670
- }
24671
- const fingerprint = fingerprintFiles(candidates);
24672
- if (settingsCache?.root === projectRoot && settingsCache.includeGlobal === includeGlobal && settingsCache.fingerprint === fingerprint) {
24673
- return settingsCache.data;
24674
- }
24675
- const sources = [];
24676
- for (const p2 of candidates) {
24677
- let content;
24678
- try {
24679
- content = stripBom(fs7.readFileSync(p2, "utf-8"));
24680
- } catch {
24681
- continue;
24682
- }
24683
- const errors = [];
24684
- const data = parse2(content, errors, { allowTrailingComma: true });
24685
- if (errors.length > 0) {
24686
- console.warn(
24687
- `ctxlint: could not parse ${p2}: ${printParseErrorCode(errors[0].error)} at offset ${errors[0].offset}`
24688
- );
24689
- continue;
24690
- }
24691
- if (!data || typeof data !== "object") continue;
24692
- sources.push(data);
24693
- }
24694
- settingsCache = { root: projectRoot, includeGlobal, fingerprint, data: sources };
24695
- return sources;
24696
- }
24697
- function canonicalizeCommand(backticked) {
24698
- const beforeFlags = backticked.trim().split(/\s+--?/, 1)[0];
24699
- return beforeFlags.replace(/\s+/g, " ");
24700
- }
24701
- function buildCommandPattern(cmd) {
24702
- const tokens = cmd.split(/\s+/).filter(Boolean);
24703
- const escaped = tokens.map((t2) => t2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
24704
- if (tokens.length === 1) {
24705
- return new RegExp(`(?<![A-Za-z0-9_\\-])${escaped[0]}(?![A-Za-z0-9_\\-])`, "i");
24706
- }
24707
- const body = escaped.join("[\\s\\-_]+");
24708
- return new RegExp(`(?<![A-Za-z0-9])${body}(?![A-Za-z0-9])`, "i");
24709
- }
24710
- function commandIsEnforced(cmd, settings) {
24711
- const pattern = buildCommandPattern(cmd);
24712
- for (const s of settings) {
24713
- for (const entry of [...s.permissions?.deny ?? [], ...s.permissions?.ask ?? []]) {
24714
- if (pattern.test(entry)) return true;
24715
- }
24716
- for (const h2 of [...s.hooks?.PreToolUse ?? [], ...s.hooks?.Stop ?? []]) {
24717
- if (pattern.test(h2.matcher || "")) return true;
24718
- for (const sub of h2.hooks ?? []) {
24719
- if (pattern.test(sub.command || "")) return true;
24720
- }
24721
- }
24722
- }
24723
- return false;
24724
- }
24725
- function checkHardEnforcement(file2, settings) {
24726
- const issues = [];
24727
- const lines = file2.content.split("\n");
24728
- for (let i2 = 0; i2 < lines.length; i2++) {
24729
- const line = lines[i2];
24730
- const match = line.match(INVIOLABLE_WITH_COMMAND);
24731
- if (!match) continue;
24732
- const cmd = canonicalizeCommand(match[2]);
24733
- if (!cmd) continue;
24734
- if (commandIsEnforced(cmd, settings)) continue;
24735
- const suggestion = match[1].toUpperCase() === "ALWAYS" ? `Rules in always-loaded files are advisory. For \`${cmd}\`, add a hook in .claude/settings.json (e.g. a PreToolUse or Stop hook that runs or verifies \`${cmd}\`) so the requirement doesn't depend on the agent remembering.` : `Rules in always-loaded files are advisory. For \`${cmd}\`, add a PreToolUse hook (or permissions.deny entry) in .claude/settings.json so the command is physically blocked.`;
24736
- issues.push({
24737
- severity: "info",
24738
- check: "tier-tokens",
24739
- ruleId: "tier-tokens/hard-enforcement-missing",
24740
- line: i2 + 1,
24741
- message: `Inviolable framing ("${line.trim().slice(0, 80)}") without a hook to back it up`,
24742
- suggestion
24743
- });
24744
- }
24745
- return issues;
24746
- }
24747
- async function checkTierTokens(file2, projectRoot, thresholds = DEFAULT_TOKEN_THRESHOLDS, includeGlobal = false) {
24748
- if (!isAlwaysLoaded(file2)) return [];
24749
- const issues = [];
24750
- const threshold = thresholds.tierBreakdown;
24751
- if (file2.totalTokens >= threshold) {
24752
- const sectionCosts = computeSectionCosts(file2);
24753
- if (sectionCosts.length > 0) {
24754
- const top = sectionCosts.slice(0, TOP_SECTIONS_TO_REPORT);
24755
- const heaviest = top[0];
24756
- const pct = Math.round(heaviest.tokens / file2.totalTokens * 100);
24757
- const detail = top.map((s) => ` - "${s.title}" (L${s.line}): ~${s.tokens.toLocaleString()} tokens`).join("\n");
24758
- issues.push({
24759
- severity: "info",
24760
- check: "tier-tokens",
24761
- ruleId: "tier-tokens/section-breakdown",
24762
- line: heaviest.line,
24763
- message: `${file2.totalTokens.toLocaleString()} tokens loaded every session \u2014 heaviest top-level section${top.length === 1 ? "" : "s"}:`,
24764
- detail,
24765
- suggestion: `"${heaviest.title}" is ~${heaviest.tokens.toLocaleString()} tokens (${pct}% of file). Consider demoting to an on-demand tier (skill, subagent, or memory) so it loads only when relevant.`
24766
- });
24767
- }
24768
- }
24769
- const settings = loadSettingsSources(projectRoot, includeGlobal);
24770
- issues.push(...checkHardEnforcement(file2, settings));
24771
- return issues;
24772
- }
24773
- function checkAggregateTierTokens(files, thresholds = DEFAULT_TOKEN_THRESHOLDS) {
24774
- const alwaysLoaded = files.filter(isAlwaysLoaded);
24775
- if (alwaysLoaded.length < 2) return null;
24776
- const total = alwaysLoaded.reduce((sum, f) => sum + f.totalTokens, 0);
24777
- const threshold = thresholds.tierAggregate;
24778
- if (total < threshold) return null;
24779
- const breakdown = alwaysLoaded.slice().sort((a, b2) => b2.totalTokens - a.totalTokens).slice(0, 5).map((f) => ` - ${f.relativePath}: ~${f.totalTokens.toLocaleString()} tokens`).join("\n");
24780
- return {
24781
- severity: "warning",
24782
- check: "tier-tokens",
24783
- ruleId: "tier-tokens/aggregate",
24784
- line: 0,
24785
- message: `${alwaysLoaded.length} always-loaded files total ${total.toLocaleString()} tokens \u2014 loaded every session`,
24786
- detail: breakdown,
24787
- suggestion: "Consider moving the largest files or their heaviest sections to on-demand tiers (skills, subagents, memory)."
24788
- };
24789
- }
24790
- var ALWAYS_LOADED_NAMES, TOP_SECTIONS_TO_REPORT, INVIOLABLE_WITH_COMMAND, settingsCache;
24791
- var init_tier_tokens = __esm({
24792
- "src/core/checks/tier-tokens.ts"() {
24793
- "use strict";
24794
- init_define_WEB_FIRST_SEGMENTS();
24795
- init_main3();
24796
- init_tokens();
24797
- init_fs();
24798
- init_tokens2();
24799
- ALWAYS_LOADED_NAMES = [
24800
- "CLAUDE.md",
24801
- "CLAUDE.local.md",
24802
- "AGENTS.md",
24803
- "AGENTS.override.md",
24804
- "AGENT.md",
24805
- "GEMINI.md",
24806
- ".cursorrules",
24807
- ".windsurfrules",
24808
- ".clinerules",
24809
- ".aiderules",
24810
- ".continuerules",
24811
- ".rules",
24812
- ".goosehints",
24813
- "replit.md",
24814
- ".github/copilot-instructions.md",
24815
- ".junie/guidelines.md",
24816
- ".junie/AGENTS.md",
24817
- ".goose/instructions.md"
24818
- ];
24819
- TOP_SECTIONS_TO_REPORT = 3;
24820
- INVIOLABLE_WITH_COMMAND = /\b(NEVER|ALWAYS|DON'?T|DO NOT|MUST NOT)\b[^.!?`]{0,80}`([^`]+)`/i;
24821
- settingsCache = null;
24822
- }
24823
- });
24824
-
24825
24872
  // src/utils/similarity.ts
24826
24873
  function jaccardSimilarityFromSets(a, b2, opts = {}) {
24827
24874
  const bothEmptyIsIdentical = opts.bothEmptyIsIdentical ?? false;
@@ -28968,7 +29015,7 @@ import { readFileSync as readFileSync8 } from "node:fs";
28968
29015
  import { resolve as resolve15, dirname as dirname7 } from "node:path";
28969
29016
  import { fileURLToPath as fileURLToPath2 } from "node:url";
28970
29017
  function loadVersion() {
28971
- if (true) return "0.20.0";
29018
+ if (true) return "0.23.0";
28972
29019
  try {
28973
29020
  const __dir = dirname7(fileURLToPath2(import.meta.url));
28974
29021
  const pkgPath = resolve15(__dir, "../package.json");
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@yawlabs/ctxlint",
3
- "version": "0.21.0",
3
+ "version": "0.23.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": "dist/index.js"
7
+ "ctxlint": "bin/ctxlint.mjs"
8
8
  },
9
9
  "type": "module",
10
10
  "exports": {
@@ -13,6 +13,7 @@
13
13
  "scripts": {
14
14
  "build": "node build.mjs",
15
15
  "dev": "node build.mjs",
16
+ "prepublishOnly": "node build.mjs",
16
17
  "generate": "node scripts/generate-catalog-prose.mjs",
17
18
  "generate:check": "node scripts/generate-catalog-prose.mjs --check",
18
19
  "pretest": "node build.mjs",
@@ -59,6 +60,7 @@
59
60
  ],
60
61
  "files": [
61
62
  "dist/index.js",
63
+ "bin/ctxlint.mjs",
62
64
  ".pre-commit-hooks.yaml",
63
65
  "action.yml",
64
66
  "CONTEXT_LINT_SPEC.md",