@yawlabs/ctxlint 0.22.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.22.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
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.21.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ctxlint",
3
- "version": "0.22.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": {
@@ -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",