@yawlabs/ctxlint 0.23.0 → 0.24.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.23.0 --strict
7
+ entry: npx @yawlabs/ctxlint@0.24.0 --strict
8
8
  language: node
9
9
  always_run: true
10
10
  pass_filenames: false
package/dist/index.js CHANGED
@@ -24050,150 +24050,7 @@ var init_cli_subcommands = __esm({
24050
24050
  }
24051
24051
  });
24052
24052
 
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
24128
- import * as fs6 from "node:fs";
24129
- import * as os2 from "node:os";
24130
- import * as path7 from "node:path";
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;
24152
- }
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;
24163
- }
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;
24174
- }
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;
24183
- }
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
- }
24053
+ // src/utils/markdown.ts
24197
24054
  function inlineCodeSpans(line) {
24198
24055
  const spans = [];
24199
24056
  const re2 = /`([^`]+)`/g;
@@ -24210,200 +24067,66 @@ function maskCodeSpans(line, spans) {
24210
24067
  }
24211
24068
  return out;
24212
24069
  }
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 };
24228
- }
24229
- return null;
24070
+ function nonProseLineMask(lines) {
24071
+ return classifyLines(lines).map((c3) => c3.fence || c3.comment);
24230
24072
  }
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("|");
24073
+ function htmlCommentLineMask(lines) {
24074
+ return classifyLines(lines).map((c3) => c3.comment);
24240
24075
  }
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) {
24255
- let content;
24256
- try {
24257
- content = stripBom(fs6.readFileSync(p2, "utf-8"));
24258
- } catch {
24076
+ function classifyLines(lines) {
24077
+ const out = lines.map(() => ({ fence: false, comment: false }));
24078
+ let inFence = false;
24079
+ let inHtmlComment = false;
24080
+ for (let i2 = 0; i2 < lines.length; i2++) {
24081
+ const raw = lines[i2];
24082
+ const trimmed2 = raw.trimStart();
24083
+ if (trimmed2.startsWith("```")) {
24084
+ out[i2].fence = true;
24085
+ inFence = !inFence;
24259
24086
  continue;
24260
24087
  }
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
- );
24088
+ if (inFence) {
24089
+ out[i2].fence = true;
24267
24090
  continue;
24268
24091
  }
24269
- if (!data || typeof data !== "object") continue;
24270
- sources.push(data);
24271
- }
24272
- settingsCache = { root: projectRoot, includeGlobal, fingerprint, data: sources };
24273
- return sources;
24274
- }
24275
- function canonicalizeCommand(backticked) {
24276
- const beforeFlags = backticked.trim().split(/\s+--?/, 1)[0];
24277
- return beforeFlags.replace(/\s+/g, " ");
24278
- }
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;
24092
+ const probe = maskCodeSpans(trimmed2, inlineCodeSpans(trimmed2));
24093
+ if (inHtmlComment) {
24094
+ out[i2].comment = true;
24095
+ if (probe.includes("-->")) inHtmlComment = false;
24096
+ continue;
24293
24097
  }
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
- }
24098
+ if (HTML_COMMENT.test(probe.replace(HTML_COMMENT_PAIR, ""))) {
24099
+ out[i2].comment = true;
24100
+ inHtmlComment = true;
24299
24101
  }
24300
24102
  }
24301
- return false;
24103
+ return out;
24302
24104
  }
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;
24105
+ function stripInlineHtmlComments(line) {
24106
+ return line.replace(HTML_COMMENT_PAIR, (m) => "#".repeat(m.length));
24324
24107
  }
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
- }
24108
+ function htmlCommentSpans(line) {
24109
+ const re2 = /<!--[\s\S]*?-->/g;
24110
+ const spans = [];
24111
+ let m;
24112
+ while ((m = re2.exec(line)) !== null) {
24113
+ spans.push({ start: m.index, end: m.index + m[0].length });
24346
24114
  }
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
- };
24115
+ return spans;
24367
24116
  }
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"() {
24117
+ var HTML_COMMENT_PAIR, HTML_COMMENT;
24118
+ var init_markdown = __esm({
24119
+ "src/utils/markdown.ts"() {
24371
24120
  "use strict";
24372
24121
  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;
24122
+ HTML_COMMENT_PAIR = /<!--[\s\S]*?-->/g;
24123
+ HTML_COMMENT = /<!--/;
24401
24124
  }
24402
24125
  });
24403
24126
 
24404
24127
  // src/core/checks/commands.ts
24405
- import * as fs7 from "node:fs";
24406
- import * as path8 from "node:path";
24128
+ import * as fs6 from "node:fs";
24129
+ import * as path7 from "node:path";
24407
24130
  function isManagerBuiltin(manager, sub) {
24408
24131
  return PM_MANAGER_BUILTINS[manager]?.has(sub) ?? false;
24409
24132
  }
@@ -24444,7 +24167,7 @@ function loadDeniedCommandPrefixes(projectRoot) {
24444
24167
  for (const rel of ["settings.json", "settings.local.json"]) {
24445
24168
  let content;
24446
24169
  try {
24447
- content = stripBom(fs7.readFileSync(path8.join(projectRoot, ".claude", rel), "utf-8"));
24170
+ content = stripBom(fs6.readFileSync(path7.join(projectRoot, ".claude", rel), "utf-8"));
24448
24171
  } catch {
24449
24172
  continue;
24450
24173
  }
@@ -24463,21 +24186,48 @@ function loadDeniedCommandPrefixes(projectRoot) {
24463
24186
  function isDeniedCommand(cmd, deniedPrefixes) {
24464
24187
  return deniedPrefixes.some((p2) => cmd === p2 || cmd.startsWith(`${p2} `));
24465
24188
  }
24466
- function isProhibitedMention(lines, ref) {
24467
- const line = lines[ref.line - 1] ?? "";
24189
+ function narrowAtContrastComma(clause) {
24190
+ const idx = clause.lastIndexOf(",");
24191
+ if (idx === -1) return clause;
24192
+ const tail = clause.slice(idx + 1);
24193
+ return RECOMMENDATION_VERB.test(tail) ? tail : clause;
24194
+ }
24195
+ function isProhibitedAt(rawLine, offset) {
24196
+ const line = stripInlineHtmlComments(rawLine);
24468
24197
  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() ?? "";
24198
+ const prefix = masked.slice(0, Math.max(0, offset));
24199
+ const clause = narrowAtContrastComma(prefix.split(CLAUSE_TERMINATOR).pop() ?? "");
24471
24200
  return PROHIBITION_TOKEN.test(clause);
24472
24201
  }
24202
+ function isProhibitedRef(lines, ref) {
24203
+ return isProhibitedAt(lines[ref.line - 1] ?? "", ref.column - 1);
24204
+ }
24205
+ function isProhibitedInvocation(lines, inv) {
24206
+ const line = lines[inv.line - 1] ?? "";
24207
+ for (const span of inlineCodeSpans(line)) {
24208
+ if (span.content.trim() === inv.cmd) return isProhibitedAt(line, span.start);
24209
+ }
24210
+ const idx = line.indexOf(inv.cmd);
24211
+ return idx < 0 ? false : isProhibitedAt(line, idx);
24212
+ }
24473
24213
  async function checkCommands(file2, projectRoot) {
24474
24214
  const issues = [];
24475
24215
  const pkgJson = loadPackageJson(projectRoot);
24476
24216
  const makefile = loadMakefile(projectRoot);
24477
24217
  const deniedPrefixes = loadDeniedCommandPrefixes(projectRoot);
24478
24218
  const contentLines = file2.content.split("\n");
24219
+ const commentLines = htmlCommentLineMask(contentLines);
24220
+ const inHtmlComment = (line, column) => {
24221
+ if (commentLines[line - 1] === true) return true;
24222
+ const offset = column - 1;
24223
+ return htmlCommentSpans(contentLines[line - 1] ?? "").some(
24224
+ (s) => offset >= s.start && offset < s.end
24225
+ );
24226
+ };
24479
24227
  if (!pkgJson) {
24480
- const skipped = file2.references.commands.find((ref) => wouldNeedPackageJson(ref.value));
24228
+ const skipped = file2.references.commands.find(
24229
+ (ref) => wouldNeedPackageJson(ref.value) && !inHtmlComment(ref.line, ref.column) && !isDeniedCommand(ref.value, deniedPrefixes) && !isProhibitedRef(contentLines, ref)
24230
+ );
24481
24231
  if (skipped) {
24482
24232
  issues.push({
24483
24233
  severity: "info",
@@ -24489,8 +24239,11 @@ async function checkCommands(file2, projectRoot) {
24489
24239
  });
24490
24240
  }
24491
24241
  }
24492
- issues.push(...checkUnknownSubcommand(file2, projectRoot, pkgJson));
24242
+ issues.push(
24243
+ ...checkUnknownSubcommand(file2, projectRoot, pkgJson, deniedPrefixes, contentLines)
24244
+ );
24493
24245
  for (const ref of file2.references.commands) {
24246
+ if (inHtmlComment(ref.line, ref.column)) continue;
24494
24247
  const cmd = ref.value;
24495
24248
  const masked = analyzeMaskedExitStatus(cmd, pkgJson?.scripts);
24496
24249
  if (masked && !pipefailInScope(file2.content, ref.line)) {
@@ -24504,6 +24257,7 @@ async function checkCommands(file2, projectRoot) {
24504
24257
  suggestion: "Add `set -o pipefail` before the pipeline, drop the filter, or read `${PIPESTATUS[0]}` instead of `$?`."
24505
24258
  });
24506
24259
  }
24260
+ if (isDeniedCommand(cmd, deniedPrefixes) || isProhibitedRef(contentLines, ref)) continue;
24507
24261
  const scriptMatch = cmd.match(NPM_SCRIPT_PATTERN);
24508
24262
  if (scriptMatch && pkgJson) {
24509
24263
  const scriptName = scriptNameFromMatch(scriptMatch);
@@ -24546,11 +24300,9 @@ async function checkCommands(file2, projectRoot) {
24546
24300
  ...pkgJson.optionalDependencies
24547
24301
  };
24548
24302
  if (!(pkgName in allDeps)) {
24549
- if (isDeniedCommand(cmd, deniedPrefixes)) continue;
24550
- if (isProhibitedMention(contentLines, ref)) continue;
24551
- const binPath = path8.join(projectRoot, "node_modules", ".bin", pkgName);
24303
+ const binPath = path7.join(projectRoot, "node_modules", ".bin", pkgName);
24552
24304
  try {
24553
- fs7.accessSync(binPath);
24305
+ fs6.accessSync(binPath);
24554
24306
  } catch {
24555
24307
  issues.push({
24556
24308
  severity: "warning",
@@ -24598,9 +24350,9 @@ async function checkCommands(file2, projectRoot) {
24598
24350
  ...pkgJson.optionalDependencies
24599
24351
  };
24600
24352
  if (!(pkgName in allDeps)) {
24601
- const binPath = path8.join(projectRoot, "node_modules", ".bin", tool);
24353
+ const binPath = path7.join(projectRoot, "node_modules", ".bin", tool);
24602
24354
  try {
24603
- fs7.accessSync(binPath);
24355
+ fs6.accessSync(binPath);
24604
24356
  } catch {
24605
24357
  issues.push({
24606
24358
  severity: "warning",
@@ -24615,7 +24367,7 @@ async function checkCommands(file2, projectRoot) {
24615
24367
  }
24616
24368
  return issues;
24617
24369
  }
24618
- function checkUnknownSubcommand(file2, projectRoot, pkgJson) {
24370
+ function checkUnknownSubcommand(file2, projectRoot, pkgJson, deniedPrefixes, contentLines) {
24619
24371
  const bins = ownedBins(pkgJson);
24620
24372
  if (bins.length === 0) return [];
24621
24373
  const invocations = findBinInvocations(file2.content, new Set(bins.map((b2) => b2.name)));
@@ -24623,6 +24375,8 @@ function checkUnknownSubcommand(file2, projectRoot, pkgJson) {
24623
24375
  const resolved = /* @__PURE__ */ new Map();
24624
24376
  const issues = [];
24625
24377
  for (const inv of invocations) {
24378
+ if (isDeniedCommand(inv.cmd, deniedPrefixes)) continue;
24379
+ if (isProhibitedInvocation(contentLines, inv)) continue;
24626
24380
  if (!resolved.has(inv.bin)) {
24627
24381
  const bin = bins.find((b2) => b2.name === inv.bin);
24628
24382
  resolved.set(inv.bin, bin ? knownSubcommands(projectRoot, bin.entry) : null);
@@ -24644,7 +24398,7 @@ function checkUnknownSubcommand(file2, projectRoot, pkgJson) {
24644
24398
  }
24645
24399
  function loadMakefile(projectRoot) {
24646
24400
  try {
24647
- return stripBom(fs7.readFileSync(path8.join(projectRoot, "Makefile"), "utf-8"));
24401
+ return stripBom(fs6.readFileSync(path7.join(projectRoot, "Makefile"), "utf-8"));
24648
24402
  } catch {
24649
24403
  return null;
24650
24404
  }
@@ -24662,7 +24416,7 @@ function hasMakeTarget(makefile, target) {
24662
24416
  const pattern = new RegExp(`^${escaped}\\s*:(?!:?=)`, "m");
24663
24417
  return pattern.test(makefile);
24664
24418
  }
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;
24419
+ 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, RECOMMENDATION_VERB;
24666
24420
  var init_commands = __esm({
24667
24421
  "src/core/checks/commands.ts"() {
24668
24422
  "use strict";
@@ -24671,7 +24425,7 @@ var init_commands = __esm({
24671
24425
  init_fs();
24672
24426
  init_exit_status();
24673
24427
  init_cli_subcommands();
24674
- init_tier_tokens();
24428
+ init_markdown();
24675
24429
  NPM_SCRIPT_PATTERN = /^(?:npm\s+run|(pnpm|yarn|bun)(?:\s+(run))?)\s+(\S+)/;
24676
24430
  MAKE_PATTERN = /^make\s+\S/;
24677
24431
  PM_BUILTIN_SUBCOMMANDS = /* @__PURE__ */ new Set([
@@ -24743,17 +24497,18 @@ var init_commands = __esm({
24743
24497
  PKG_SHORTHAND_PATTERN = /^(npm|pnpm|yarn|bun)\s+(test|start|build|dev|lint|format|check|typecheck|clean|serve|preview|e2e)\b/;
24744
24498
  PROHIBITION_TOKEN = /\b(?:never|don['’]?t|do\s+not|must\s+not|avoid)\b/i;
24745
24499
  CLAUSE_TERMINATOR = /[.!?;—]|\s--(?=\s|$)/;
24500
+ RECOMMENDATION_VERB = /\b(?:run|use|call|invoke|execute|prefer|deploy|install)\b/i;
24746
24501
  }
24747
24502
  });
24748
24503
 
24749
24504
  // src/core/checks/staleness.ts
24750
- import * as path9 from "node:path";
24505
+ import * as path8 from "node:path";
24751
24506
  async function checkStaleness(file2, projectRoot) {
24752
24507
  const issues = [];
24753
24508
  if (!await isGitRepo(projectRoot)) {
24754
24509
  return issues;
24755
24510
  }
24756
- const relativePath = path9.relative(projectRoot, file2.filePath).replace(/\\/g, "/");
24511
+ const relativePath = path8.relative(projectRoot, file2.filePath).replace(/\\/g, "/");
24757
24512
  const lastModified = await getFileLastModified(projectRoot, relativePath);
24758
24513
  if (!lastModified || isNaN(lastModified.getTime())) {
24759
24514
  return issues;
@@ -24869,6 +24624,344 @@ var init_suppressions = __esm({
24869
24624
  }
24870
24625
  });
24871
24626
 
24627
+ // src/core/checks/tokens.ts
24628
+ function resolveTokenThresholds(overrides) {
24629
+ if (!overrides) return DEFAULT_TOKEN_THRESHOLDS;
24630
+ const merged = { ...DEFAULT_TOKEN_THRESHOLDS, ...overrides };
24631
+ if (merged.info >= merged.warning || merged.warning >= merged.error) {
24632
+ console.error(
24633
+ `Warning: token thresholds should satisfy info < warning < error (got ${merged.info}, ${merged.warning}, ${merged.error}) \u2014 using defaults`
24634
+ );
24635
+ return DEFAULT_TOKEN_THRESHOLDS;
24636
+ }
24637
+ return merged;
24638
+ }
24639
+ async function checkTokens(file2, _projectRoot, thresholds = DEFAULT_TOKEN_THRESHOLDS) {
24640
+ const issues = [];
24641
+ const tokens = file2.totalTokens;
24642
+ if (tokens >= thresholds.error) {
24643
+ issues.push({
24644
+ severity: "error",
24645
+ check: "tokens",
24646
+ ruleId: "tokens/excessive",
24647
+ line: 1,
24648
+ message: `${tokens.toLocaleString()} tokens \u2014 consumes significant context window space`,
24649
+ suggestion: "Consider splitting into focused sections or removing redundant content."
24650
+ });
24651
+ } else if (tokens >= thresholds.warning) {
24652
+ issues.push({
24653
+ severity: "warning",
24654
+ check: "tokens",
24655
+ ruleId: "tokens/large",
24656
+ line: 1,
24657
+ message: `${tokens.toLocaleString()} tokens \u2014 large context file`,
24658
+ suggestion: "Consider trimming \u2014 research shows diminishing returns past ~300 lines."
24659
+ });
24660
+ } else if (tokens >= thresholds.info) {
24661
+ issues.push({
24662
+ severity: "info",
24663
+ check: "tokens",
24664
+ ruleId: "tokens/info",
24665
+ line: 1,
24666
+ message: `Uses ~${tokens.toLocaleString()} tokens per session`
24667
+ });
24668
+ }
24669
+ return issues;
24670
+ }
24671
+ function checkAggregateTokens(files, thresholds = DEFAULT_TOKEN_THRESHOLDS) {
24672
+ const total = files.reduce((sum, f) => sum + f.tokens, 0);
24673
+ if (total >= thresholds.aggregate && files.length > 1) {
24674
+ return {
24675
+ severity: "warning",
24676
+ check: "tokens",
24677
+ ruleId: "tokens/aggregate",
24678
+ line: 0,
24679
+ message: `${files.length} context files consume ${total.toLocaleString()} tokens combined`,
24680
+ suggestion: "Consider consolidating or trimming to reduce per-session context cost."
24681
+ };
24682
+ }
24683
+ return null;
24684
+ }
24685
+ var DEFAULT_TOKEN_THRESHOLDS;
24686
+ var init_tokens2 = __esm({
24687
+ "src/core/checks/tokens.ts"() {
24688
+ "use strict";
24689
+ init_define_WEB_FIRST_SEGMENTS();
24690
+ DEFAULT_TOKEN_THRESHOLDS = {
24691
+ info: 1e3,
24692
+ warning: 3e3,
24693
+ error: 8e3,
24694
+ aggregate: 5e3,
24695
+ tierBreakdown: 1e3,
24696
+ tierAggregate: 4e3
24697
+ };
24698
+ }
24699
+ });
24700
+
24701
+ // src/core/checks/tier-tokens.ts
24702
+ import * as fs7 from "node:fs";
24703
+ import * as os2 from "node:os";
24704
+ import * as path9 from "node:path";
24705
+ function hasFrontmatterList(content, field) {
24706
+ const lines = content.split("\n");
24707
+ if (lines[0]?.trim() !== "---") return false;
24708
+ const fieldPattern = new RegExp(`^${field}\\s*:\\s*(.*)$`);
24709
+ for (let i2 = 1; i2 < lines.length; i2++) {
24710
+ const line = lines[i2];
24711
+ if (line.trim() === "---") return false;
24712
+ const match = line.match(fieldPattern);
24713
+ if (match) {
24714
+ const val = match[1].trim();
24715
+ if (val && val !== "[]") return true;
24716
+ for (let j3 = i2 + 1; j3 < lines.length; j3++) {
24717
+ const next = lines[j3];
24718
+ if (next.trim() === "---") return false;
24719
+ if (next.trim() === "") continue;
24720
+ if (next.trim().startsWith("- ")) return true;
24721
+ break;
24722
+ }
24723
+ }
24724
+ }
24725
+ return false;
24726
+ }
24727
+ function frontmatterScalar(content, field) {
24728
+ const lines = content.split("\n");
24729
+ if (lines[0]?.trim() !== "---") return null;
24730
+ const fieldPattern = new RegExp(`^${field}\\s*:\\s*(.*)$`);
24731
+ for (let i2 = 1; i2 < lines.length; i2++) {
24732
+ if (lines[i2].trim() === "---") return null;
24733
+ const match = lines[i2].match(fieldPattern);
24734
+ if (match) return match[1].trim().replace(/^['"]|['"]$/g, "") || null;
24735
+ }
24736
+ return null;
24737
+ }
24738
+ function isAlwaysLoaded(file2) {
24739
+ const rel = file2.relativePath.replace(/\\/g, "/");
24740
+ if (rel.endsWith(".mdc")) return false;
24741
+ if (rel.startsWith(".github/instructions/")) return false;
24742
+ if (rel.includes("/rules/")) {
24743
+ if (hasFrontmatterList(file2.content, "paths")) return false;
24744
+ if (hasFrontmatterList(file2.content, "globs")) return false;
24745
+ if (rel.includes(".windsurf/rules/")) {
24746
+ const trigger = frontmatterScalar(file2.content, "trigger");
24747
+ if (trigger && trigger.toLowerCase() !== "always_on") return false;
24748
+ }
24749
+ return true;
24750
+ }
24751
+ const basename4 = rel.split("/").pop() ?? "";
24752
+ for (const name of ALWAYS_LOADED_NAMES) {
24753
+ if (name.includes("/")) {
24754
+ if (rel === name || rel.endsWith("/" + name)) return true;
24755
+ } else if (basename4 === name) {
24756
+ return true;
24757
+ }
24758
+ }
24759
+ return false;
24760
+ }
24761
+ function computeSectionCosts(file2) {
24762
+ if (file2.sections.length === 0) return [];
24763
+ const hasH2 = file2.sections.some((s) => s.level === 2);
24764
+ const topLevel = hasH2 ? 2 : 1;
24765
+ const lines = file2.content.split("\n");
24766
+ return file2.sections.filter((s) => s.level === topLevel).map((s) => {
24767
+ const body = lines.slice(s.startLine - 1, s.endLine).join("\n");
24768
+ return { title: s.title, line: s.startLine, tokens: countTokens(body) };
24769
+ }).sort((a, b2) => b2.tokens - a.tokens);
24770
+ }
24771
+ function findInviolableCommand(line) {
24772
+ const spans = inlineCodeSpans(line);
24773
+ if (spans.length === 0) return null;
24774
+ const masked = maskCodeSpans(line, spans);
24775
+ FRAMING_TOKEN.lastIndex = 0;
24776
+ let m;
24777
+ while ((m = FRAMING_TOKEN.exec(masked)) !== null) {
24778
+ const token = m[1];
24779
+ if (token === token.toLowerCase()) continue;
24780
+ const afterIdx = m.index + m[0].length;
24781
+ const span = spans.find((s) => s.start >= afterIdx);
24782
+ if (!span) continue;
24783
+ const gap = masked.slice(afterIdx, span.start);
24784
+ if (gap.length > FRAMING_COMMAND_GAP || /[.!?]/.test(gap)) continue;
24785
+ return { framing: token, command: span.content };
24786
+ }
24787
+ return null;
24788
+ }
24789
+ function fingerprintFiles(paths) {
24790
+ return paths.map((p2) => {
24791
+ try {
24792
+ const st2 = fs7.statSync(p2);
24793
+ return `${st2.mtimeMs}:${st2.size}`;
24794
+ } catch {
24795
+ return "absent";
24796
+ }
24797
+ }).join("|");
24798
+ }
24799
+ function loadSettingsSources(projectRoot, includeGlobal) {
24800
+ const candidates = [
24801
+ path9.join(projectRoot, ".claude", "settings.json"),
24802
+ path9.join(projectRoot, ".claude", "settings.local.json")
24803
+ ];
24804
+ if (includeGlobal) {
24805
+ candidates.push(path9.join(os2.homedir(), ".claude", "settings.json"));
24806
+ }
24807
+ const fingerprint = fingerprintFiles(candidates);
24808
+ if (settingsCache?.root === projectRoot && settingsCache.includeGlobal === includeGlobal && settingsCache.fingerprint === fingerprint) {
24809
+ return settingsCache.data;
24810
+ }
24811
+ const sources = [];
24812
+ for (const p2 of candidates) {
24813
+ let content;
24814
+ try {
24815
+ content = stripBom(fs7.readFileSync(p2, "utf-8"));
24816
+ } catch {
24817
+ continue;
24818
+ }
24819
+ const errors = [];
24820
+ const data = parse2(content, errors, { allowTrailingComma: true });
24821
+ if (errors.length > 0) {
24822
+ console.warn(
24823
+ `ctxlint: could not parse ${p2}: ${printParseErrorCode(errors[0].error)} at offset ${errors[0].offset}`
24824
+ );
24825
+ continue;
24826
+ }
24827
+ if (!data || typeof data !== "object") continue;
24828
+ sources.push(data);
24829
+ }
24830
+ settingsCache = { root: projectRoot, includeGlobal, fingerprint, data: sources };
24831
+ return sources;
24832
+ }
24833
+ function canonicalizeCommand(backticked) {
24834
+ const beforeFlags = backticked.trim().split(/\s+--?/, 1)[0];
24835
+ return beforeFlags.replace(/\s+/g, " ");
24836
+ }
24837
+ function buildCommandPattern(cmd) {
24838
+ const tokens = cmd.split(/\s+/).filter(Boolean);
24839
+ const escaped = tokens.map((t2) => t2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
24840
+ if (tokens.length === 1) {
24841
+ return new RegExp(`(?<![A-Za-z0-9_\\-])${escaped[0]}(?![A-Za-z0-9_\\-])`, "i");
24842
+ }
24843
+ const body = escaped.join("[\\s\\-_]+");
24844
+ return new RegExp(`(?<![A-Za-z0-9])${body}(?![A-Za-z0-9])`, "i");
24845
+ }
24846
+ function commandIsEnforced(cmd, settings) {
24847
+ const pattern = buildCommandPattern(cmd);
24848
+ for (const s of settings) {
24849
+ for (const entry of [...s.permissions?.deny ?? [], ...s.permissions?.ask ?? []]) {
24850
+ if (pattern.test(entry)) return true;
24851
+ }
24852
+ for (const h2 of [...s.hooks?.PreToolUse ?? [], ...s.hooks?.Stop ?? []]) {
24853
+ if (pattern.test(h2.matcher || "")) return true;
24854
+ for (const sub of h2.hooks ?? []) {
24855
+ if (pattern.test(sub.command || "")) return true;
24856
+ }
24857
+ }
24858
+ }
24859
+ return false;
24860
+ }
24861
+ function checkHardEnforcement(file2, settings) {
24862
+ const issues = [];
24863
+ const lines = file2.content.split("\n");
24864
+ const nonProse = nonProseLineMask(lines);
24865
+ for (let i2 = 0; i2 < lines.length; i2++) {
24866
+ if (nonProse[i2]) continue;
24867
+ const line = stripInlineHtmlComments(lines[i2]);
24868
+ const match = findInviolableCommand(line);
24869
+ if (!match) continue;
24870
+ const cmd = canonicalizeCommand(match.command);
24871
+ if (!cmd) continue;
24872
+ if (commandIsEnforced(cmd, settings)) continue;
24873
+ 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.`;
24874
+ issues.push({
24875
+ severity: "info",
24876
+ check: "tier-tokens",
24877
+ ruleId: "tier-tokens/hard-enforcement-missing",
24878
+ line: i2 + 1,
24879
+ message: `Inviolable framing ("${line.trim().slice(0, 80)}") without a hook to back it up`,
24880
+ suggestion
24881
+ });
24882
+ }
24883
+ return issues;
24884
+ }
24885
+ async function checkTierTokens(file2, projectRoot, thresholds = DEFAULT_TOKEN_THRESHOLDS, includeGlobal = false) {
24886
+ if (!isAlwaysLoaded(file2)) return [];
24887
+ const issues = [];
24888
+ const threshold = thresholds.tierBreakdown;
24889
+ if (file2.totalTokens >= threshold) {
24890
+ const sectionCosts = computeSectionCosts(file2);
24891
+ if (sectionCosts.length > 0) {
24892
+ const top = sectionCosts.slice(0, TOP_SECTIONS_TO_REPORT);
24893
+ const heaviest = top[0];
24894
+ const pct = Math.round(heaviest.tokens / file2.totalTokens * 100);
24895
+ const detail = top.map((s) => ` - "${s.title}" (L${s.line}): ~${s.tokens.toLocaleString()} tokens`).join("\n");
24896
+ issues.push({
24897
+ severity: "info",
24898
+ check: "tier-tokens",
24899
+ ruleId: "tier-tokens/section-breakdown",
24900
+ line: heaviest.line,
24901
+ message: `${file2.totalTokens.toLocaleString()} tokens loaded every session \u2014 heaviest top-level section${top.length === 1 ? "" : "s"}:`,
24902
+ detail,
24903
+ 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.`
24904
+ });
24905
+ }
24906
+ }
24907
+ const settings = loadSettingsSources(projectRoot, includeGlobal);
24908
+ issues.push(...checkHardEnforcement(file2, settings));
24909
+ return issues;
24910
+ }
24911
+ function checkAggregateTierTokens(files, thresholds = DEFAULT_TOKEN_THRESHOLDS) {
24912
+ const alwaysLoaded = files.filter(isAlwaysLoaded);
24913
+ if (alwaysLoaded.length < 2) return null;
24914
+ const total = alwaysLoaded.reduce((sum, f) => sum + f.totalTokens, 0);
24915
+ const threshold = thresholds.tierAggregate;
24916
+ if (total < threshold) return null;
24917
+ const breakdown = alwaysLoaded.slice().sort((a, b2) => b2.totalTokens - a.totalTokens).slice(0, 5).map((f) => ` - ${f.relativePath}: ~${f.totalTokens.toLocaleString()} tokens`).join("\n");
24918
+ return {
24919
+ severity: "warning",
24920
+ check: "tier-tokens",
24921
+ ruleId: "tier-tokens/aggregate",
24922
+ line: 0,
24923
+ message: `${alwaysLoaded.length} always-loaded files total ${total.toLocaleString()} tokens \u2014 loaded every session`,
24924
+ detail: breakdown,
24925
+ suggestion: "Consider moving the largest files or their heaviest sections to on-demand tiers (skills, subagents, memory)."
24926
+ };
24927
+ }
24928
+ var ALWAYS_LOADED_NAMES, TOP_SECTIONS_TO_REPORT, FRAMING_TOKEN, FRAMING_COMMAND_GAP, settingsCache;
24929
+ var init_tier_tokens = __esm({
24930
+ "src/core/checks/tier-tokens.ts"() {
24931
+ "use strict";
24932
+ init_define_WEB_FIRST_SEGMENTS();
24933
+ init_main3();
24934
+ init_tokens();
24935
+ init_fs();
24936
+ init_markdown();
24937
+ init_tokens2();
24938
+ ALWAYS_LOADED_NAMES = [
24939
+ "CLAUDE.md",
24940
+ "CLAUDE.local.md",
24941
+ "AGENTS.md",
24942
+ "AGENTS.override.md",
24943
+ "AGENT.md",
24944
+ "GEMINI.md",
24945
+ ".cursorrules",
24946
+ ".windsurfrules",
24947
+ ".clinerules",
24948
+ ".aiderules",
24949
+ ".continuerules",
24950
+ ".rules",
24951
+ ".goosehints",
24952
+ "replit.md",
24953
+ ".github/copilot-instructions.md",
24954
+ ".junie/guidelines.md",
24955
+ ".junie/AGENTS.md",
24956
+ ".goose/instructions.md"
24957
+ ];
24958
+ TOP_SECTIONS_TO_REPORT = 3;
24959
+ FRAMING_TOKEN = /(?<![\w'.-])(never|always|don'?t|do\s+not|must\s+not)(?![\w'.-])/gi;
24960
+ FRAMING_COMMAND_GAP = 80;
24961
+ settingsCache = null;
24962
+ }
24963
+ });
24964
+
24872
24965
  // src/utils/similarity.ts
24873
24966
  function jaccardSimilarityFromSets(a, b2, opts = {}) {
24874
24967
  const bothEmptyIsIdentical = opts.bothEmptyIsIdentical ?? false;
@@ -29015,7 +29108,7 @@ import { readFileSync as readFileSync8 } from "node:fs";
29015
29108
  import { resolve as resolve15, dirname as dirname7 } from "node:path";
29016
29109
  import { fileURLToPath as fileURLToPath2 } from "node:url";
29017
29110
  function loadVersion() {
29018
- if (true) return "0.23.0";
29111
+ if (true) return "0.24.0";
29019
29112
  try {
29020
29113
  const __dir = dirname7(fileURLToPath2(import.meta.url));
29021
29114
  const pkgPath = resolve15(__dir, "../package.json");
@@ -70107,6 +70200,13 @@ __export(cli_exports, {
70107
70200
  });
70108
70201
  import * as fs15 from "node:fs";
70109
70202
  import * as path20 from "node:path";
70203
+ function existsAsDirectory(p2) {
70204
+ try {
70205
+ return fs15.statSync(p2).isDirectory();
70206
+ } catch {
70207
+ return false;
70208
+ }
70209
+ }
70110
70210
  function validateCheckNames(names, source) {
70111
70211
  const invalid = names.filter((n7) => n7 && !VALID_CHECKS.has(n7));
70112
70212
  if (invalid.length > 0) {
@@ -70130,6 +70230,12 @@ async function runCli() {
70130
70230
  false
70131
70231
  ).option("--no-ignore-file", "Disable .ctxlintignore suppression (see all findings)").option("--watch", "Re-lint on context file changes", false).action(async (projectPath, opts) => {
70132
70232
  const resolvedPath = path20.resolve(projectPath);
70233
+ if (!existsAsDirectory(resolvedPath)) {
70234
+ console.error(
70235
+ `Error: ${resolvedPath} is not an existing directory. Check the path argument.`
70236
+ );
70237
+ process.exit(2);
70238
+ }
70133
70239
  const { config: config2, options, activeChecks } = resolveSession(resolvedPath, opts);
70134
70240
  const spinner = options.format === "text" && !options.quiet ? ora("Scanning for context files...").start() : void 0;
70135
70241
  try {
@@ -70448,7 +70554,7 @@ function resolveDepth(raw) {
70448
70554
  }
70449
70555
  function resolveSession(resolvedPath, opts, throwOnConfigError = false) {
70450
70556
  const configPath = opts.config ? path20.resolve(opts.config) : void 0;
70451
- const config2 = configPath ? loadConfigFromPath(configPath, throwOnConfigError) : loadConfig(resolvedPath);
70557
+ const config2 = configPath ? loadConfigFromPath(configPath, throwOnConfigError) : loadDiscoveredConfig(resolvedPath, throwOnConfigError);
70452
70558
  const mcpGlobal = opts.mcpGlobal || config2?.mcpGlobal || false;
70453
70559
  const mcpOnly = opts.mcpOnly || config2?.mcpOnly || false;
70454
70560
  const mcpFlag = opts.mcp || mcpGlobal || mcpOnly || config2?.mcp || false;
@@ -70516,6 +70622,16 @@ function resolveSession(resolvedPath, opts, throwOnConfigError = false) {
70516
70622
  const activeChecks = options.checks.filter((c3) => !options.ignore.includes(c3));
70517
70623
  return { config: config2, options, activeChecks };
70518
70624
  }
70625
+ function loadDiscoveredConfig(resolvedPath, throwOnError = false) {
70626
+ try {
70627
+ return loadConfig(resolvedPath);
70628
+ } catch (err) {
70629
+ if (throwOnError) throw err;
70630
+ const detail = err instanceof Error ? err.message : String(err);
70631
+ console.error(`Error: ${detail}`);
70632
+ process.exit(2);
70633
+ }
70634
+ }
70519
70635
  function loadConfigFromPath(configPath, throwOnError = false) {
70520
70636
  try {
70521
70637
  return loadConfigFromExplicitPath(configPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ctxlint",
3
- "version": "0.23.0",
3
+ "version": "0.24.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": {