@yawlabs/ctxlint 0.13.2 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -42489,7 +42489,7 @@ async function checkPaths(file2, projectRoot) {
42489
42489
  const resolvedPath = path4.resolve(baseDir, ref.value);
42490
42490
  const normalizedRef = ref.value.replace(/\\/g, "/");
42491
42491
  if (normalizedRef.includes("*")) {
42492
- const matches = await Ze(normalizedRef, { cwd: baseDir, nodir: false });
42492
+ const matches = path4.isAbsolute(normalizedRef) ? await Ze(normalizedRef, { absolute: true, nodir: false }) : await Ze(normalizedRef, { cwd: baseDir, nodir: false });
42493
42493
  if (matches.length === 0) {
42494
42494
  issues.push({
42495
42495
  severity: "error",
@@ -42871,7 +42871,7 @@ async function checkTokens(file2, _projectRoot, thresholds = DEFAULT_TOKEN_THRES
42871
42871
  }
42872
42872
  function checkAggregateTokens(files, thresholds = DEFAULT_TOKEN_THRESHOLDS) {
42873
42873
  const total = files.reduce((sum, f) => sum + f.tokens, 0);
42874
- if (total > thresholds.aggregate && files.length > 1) {
42874
+ if (total >= thresholds.aggregate && files.length > 1) {
42875
42875
  return {
42876
42876
  severity: "warning",
42877
42877
  check: "tokens",
@@ -42950,14 +42950,20 @@ function computeSectionCosts(file2) {
42950
42950
  return { title: s.title, line: s.startLine, tokens: countTokens(body) };
42951
42951
  }).sort((a, b2) => b2.tokens - a.tokens);
42952
42952
  }
42953
- function loadSettingsSources(projectRoot) {
42954
- if (settingsCache?.root === projectRoot) return settingsCache.data;
42953
+ function loadSettingsSources(projectRoot, includeGlobal) {
42954
+ if (settingsCache?.root === projectRoot && settingsCache.includeGlobal === includeGlobal) {
42955
+ return settingsCache.data;
42956
+ }
42955
42957
  const sources = [];
42956
42958
  const candidates = [
42957
42959
  path7.join(projectRoot, ".claude", "settings.json"),
42958
- path7.join(projectRoot, ".claude", "settings.local.json"),
42959
- path7.join(process.env.HOME || process.env.USERPROFILE || "", ".claude", "settings.json")
42960
+ path7.join(projectRoot, ".claude", "settings.local.json")
42960
42961
  ];
42962
+ if (includeGlobal) {
42963
+ candidates.push(
42964
+ path7.join(process.env.HOME || process.env.USERPROFILE || "", ".claude", "settings.json")
42965
+ );
42966
+ }
42961
42967
  for (const p2 of candidates) {
42962
42968
  let content;
42963
42969
  try {
@@ -42971,7 +42977,7 @@ function loadSettingsSources(projectRoot) {
42971
42977
  console.warn(`ctxlint: could not parse ${p2}: ${err.message}`);
42972
42978
  }
42973
42979
  }
42974
- settingsCache = { root: projectRoot, data: sources };
42980
+ settingsCache = { root: projectRoot, includeGlobal, data: sources };
42975
42981
  return sources;
42976
42982
  }
42977
42983
  function canonicalizeCommand(backticked) {
@@ -43023,7 +43029,7 @@ function checkHardEnforcement(file2, settings) {
43023
43029
  }
43024
43030
  return issues;
43025
43031
  }
43026
- async function checkTierTokens(file2, projectRoot, thresholds = DEFAULT_TOKEN_THRESHOLDS) {
43032
+ async function checkTierTokens(file2, projectRoot, thresholds = DEFAULT_TOKEN_THRESHOLDS, includeGlobal = false) {
43027
43033
  if (!isAlwaysLoaded(file2)) return [];
43028
43034
  const issues = [];
43029
43035
  const threshold = thresholds.tierBreakdown;
@@ -43045,7 +43051,7 @@ async function checkTierTokens(file2, projectRoot, thresholds = DEFAULT_TOKEN_TH
43045
43051
  });
43046
43052
  }
43047
43053
  }
43048
- const settings = loadSettingsSources(projectRoot);
43054
+ const settings = loadSettingsSources(projectRoot, includeGlobal);
43049
43055
  issues.push(...checkHardEnforcement(file2, settings));
43050
43056
  return issues;
43051
43057
  }
@@ -44067,7 +44073,9 @@ async function checkMcpSecurity(config2, _projectRoot) {
44067
44073
  try {
44068
44074
  if (!isEnvVarRef(server2.url)) {
44069
44075
  const parsed = new URL(server2.url);
44070
- if (parsed.protocol === "http:" && parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1" && parsed.hostname !== "::1") {
44076
+ const host = parsed.hostname.replace(/^\[|\]$/g, "");
44077
+ const isLoopback = host === "localhost" || host === "::1" || host.startsWith("127.");
44078
+ if (parsed.protocol === "http:" && !isLoopback) {
44071
44079
  issues.push({
44072
44080
  severity: "warning",
44073
44081
  check: "mcp-security",
@@ -44523,6 +44531,7 @@ function checkMissingFromClient(configs) {
44523
44531
  function collectServerNameKeys(content, rootKey) {
44524
44532
  const names = [];
44525
44533
  let i2 = 0;
44534
+ let keyStart = -1;
44526
44535
  let depth = 0;
44527
44536
  let inString = false;
44528
44537
  let escape2 = false;
@@ -44548,6 +44557,7 @@ function collectServerNameKeys(content, rootKey) {
44548
44557
  inString = true;
44549
44558
  collectingKey = true;
44550
44559
  pendingKey = "";
44560
+ keyStart = i2;
44551
44561
  } else {
44552
44562
  inString = false;
44553
44563
  let j3 = i2 + 1;
@@ -44556,7 +44566,11 @@ function collectServerNameKeys(content, rootKey) {
44556
44566
  if (pendingKey === rootKey && rootKeyDepth === -1) {
44557
44567
  rootKeyDepth = depth;
44558
44568
  } else if (rootKeyDepth !== -1 && depth === rootKeyDepth + 1) {
44559
- names.push(pendingKey);
44569
+ let line = 1;
44570
+ for (let k3 = 0; k3 < keyStart; k3++) {
44571
+ if (content[k3] === "\n") line++;
44572
+ }
44573
+ names.push({ name: pendingKey, line });
44560
44574
  }
44561
44575
  }
44562
44576
  collectingKey = false;
@@ -44582,14 +44596,19 @@ function checkSingleFileIssues(configs) {
44582
44596
  if (!config2.actualRootKey) continue;
44583
44597
  const serverKeys = collectServerNameKeys(config2.content, config2.actualRootKey);
44584
44598
  const counts = /* @__PURE__ */ new Map();
44585
- for (const k3 of serverKeys) counts.set(k3, (counts.get(k3) ?? 0) + 1);
44599
+ const secondLines = /* @__PURE__ */ new Map();
44600
+ for (const { name, line } of serverKeys) {
44601
+ const next = (counts.get(name) ?? 0) + 1;
44602
+ counts.set(name, next);
44603
+ if (next === 2) secondLines.set(name, line);
44604
+ }
44586
44605
  for (const [name, count] of counts) {
44587
44606
  if (count > 1) {
44588
44607
  issues.push({
44589
44608
  severity: "warning",
44590
44609
  check: "mcp-consistency",
44591
44610
  ruleId: "mcp-consistency/duplicate-server-name",
44592
- line: 1,
44611
+ line: secondLines.get(name) ?? 1,
44593
44612
  message: `Duplicate server name "${name}" in ${config2.relativePath} \u2014 only the last definition is used`
44594
44613
  });
44595
44614
  }
@@ -44748,6 +44767,17 @@ async function checkMcphApibase(config2, _projectRoot) {
44748
44767
  });
44749
44768
  return issues;
44750
44769
  }
44770
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
44771
+ issues.push({
44772
+ severity: "error",
44773
+ check: "mcph-apibase",
44774
+ ruleId: "mcph-apibase/invalid-apibase",
44775
+ line: pos.line,
44776
+ message: `"apiBase" must be an http(s) URL, got ${parsed.protocol.replace(/:$/, "")}`,
44777
+ suggestion: `Use an absolute http(s) URL, e.g. "https://mcp.hosting".`
44778
+ });
44779
+ return issues;
44780
+ }
44751
44781
  if (parsed.protocol === "http:" && !isPrivateHost(parsed.hostname)) {
44752
44782
  issues.push({
44753
44783
  severity: "warning",
@@ -44941,6 +44971,12 @@ function extractPathsClassified(content) {
44941
44971
  candidates.push(p2);
44942
44972
  }
44943
44973
  }
44974
+ for (const match of content.matchAll(DRIVE_ABS_PATH)) {
44975
+ const p2 = match[1].replace(/[)}\]]+$/, "");
44976
+ if (p2.length > 2) {
44977
+ candidates.push(p2);
44978
+ }
44979
+ }
44944
44980
  const seen = /* @__PURE__ */ new Set();
44945
44981
  const out = [];
44946
44982
  for (const value of candidates) {
@@ -44993,7 +45029,7 @@ async function parseMemoryFile(filePath, projectDir) {
44993
45029
  referencedPaths
44994
45030
  };
44995
45031
  }
44996
- var WEB_FIRST_SEGMENTS, PATH_PATTERN2, BARE_FILE_PATH;
45032
+ var WEB_FIRST_SEGMENTS, PATH_PATTERN2, BARE_FILE_PATH, DRIVE_ABS_PATH;
44997
45033
  var init_session_parser = __esm({
44998
45034
  "src/core/session-parser.ts"() {
44999
45035
  "use strict";
@@ -45002,6 +45038,7 @@ var init_session_parser = __esm({
45002
45038
  WEB_FIRST_SEGMENTS = loadWebFirstSegments();
45003
45039
  PATH_PATTERN2 = /(?:^|\s|['"`(])([.~/][^\s'"`),;:!?]+)/g;
45004
45040
  BARE_FILE_PATH = /(?:^|[\s`"'(])([\w][\w-]*(?:\/[\w.-]+)+\.[a-zA-Z0-9]{1,8})\b/g;
45041
+ DRIVE_ABS_PATH = /(?:^|[\s`"'(])([A-Za-z]:[\\/][^\s'"`),;!?]+)/g;
45005
45042
  }
45006
45043
  });
45007
45044
 
@@ -45201,7 +45238,12 @@ var init_session_scanner = __esm({
45201
45238
  },
45202
45239
  {
45203
45240
  provider: "goose",
45204
- dir: process.platform === "win32" ? join5(process.env.APPDATA || "", "Block", "goose") : join5(home, ".config", "goose")
45241
+ // On win32 the goose dir lives under %APPDATA%. If APPDATA is unset,
45242
+ // `join('', 'Block', 'goose')` yields the cwd-relative `Block/goose`,
45243
+ // which the HOME guard in `detectProviders` does NOT catch (HOME/USERPROFILE
45244
+ // may still be set). Leave the dir empty so `existsSync('')` is always false
45245
+ // and we never match a cwd-relative `Block/goose`, mirroring the HOME-guard intent.
45246
+ dir: process.platform === "win32" ? process.env.APPDATA ? join5(process.env.APPDATA, "Block", "goose") : "" : join5(home, ".config", "goose")
45205
45247
  },
45206
45248
  { provider: "continue", dir: join5(home, ".continue") },
45207
45249
  {
@@ -45217,15 +45259,45 @@ import { resolve as resolve6, basename as basename3 } from "node:path";
45217
45259
  function normalizePath2(p2) {
45218
45260
  return resolve6(p2).replace(/\\/g, "/").toLowerCase();
45219
45261
  }
45262
+ function extractSecretName(display) {
45263
+ const prefixMatch = display.match(SECRET_SET_PREFIX);
45264
+ if (!prefixMatch) return void 0;
45265
+ const rest = display.slice(prefixMatch.index + prefixMatch[0].length);
45266
+ const tokens = rest.split(/\s+/).filter((t2) => t2.length > 0);
45267
+ for (let i2 = 0; i2 < tokens.length; i2++) {
45268
+ const tok = tokens[i2];
45269
+ if (!tok.startsWith("-")) return tok;
45270
+ if (VALUE_FLAGS.has(tok) && !tok.includes("=")) i2++;
45271
+ }
45272
+ return void 0;
45273
+ }
45274
+ function repoBasename(repoSpec) {
45275
+ return (repoSpec.split("/").pop() || repoSpec).toLowerCase();
45276
+ }
45277
+ function repoMatchesSibling(repoSpec, sib) {
45278
+ const slash = repoSpec.indexOf("/");
45279
+ const sibBase = basename3(normalizePath2(sib.path));
45280
+ if (slash === -1) {
45281
+ return repoBasename(repoSpec) === sibBase;
45282
+ }
45283
+ const owner = repoSpec.slice(0, slash).toLowerCase();
45284
+ const repoName = repoSpec.slice(slash + 1).toLowerCase();
45285
+ if (repoName !== sibBase) return false;
45286
+ if (sib.gitOrg) return sib.gitOrg.toLowerCase() === owner;
45287
+ if (sib.gitRemoteUrl) {
45288
+ return sib.gitRemoteUrl.toLowerCase().includes(`${owner}/${repoName}`);
45289
+ }
45290
+ return true;
45291
+ }
45220
45292
  async function checkMissingSecret(ctx) {
45221
45293
  const issues = [];
45222
45294
  const secrets = [];
45223
45295
  for (const entry of ctx.history) {
45224
- const match = entry.display.match(SECRET_SET_SIMPLE);
45225
- if (!match) continue;
45296
+ const name = extractSecretName(entry.display);
45297
+ if (!name) continue;
45226
45298
  const repoMatch = entry.display.match(REPO_FLAG_PATTERN);
45227
45299
  secrets.push({
45228
- name: match[1],
45300
+ name,
45229
45301
  repo: repoMatch ? repoMatch[1] : void 0,
45230
45302
  project: entry.project
45231
45303
  });
@@ -45233,28 +45305,22 @@ async function checkMissingSecret(ctx) {
45233
45305
  if (secrets.length === 0) return issues;
45234
45306
  const byName = /* @__PURE__ */ new Map();
45235
45307
  for (const s of secrets) {
45236
- if (!byName.has(s.name)) byName.set(s.name, /* @__PURE__ */ new Set());
45237
- byName.get(s.name).add(s.project);
45238
- if (s.repo) byName.get(s.name).add(s.repo);
45308
+ if (!byName.has(s.name)) byName.set(s.name, { projects: /* @__PURE__ */ new Set(), repos: /* @__PURE__ */ new Set() });
45309
+ const bucket = byName.get(s.name);
45310
+ bucket.projects.add(s.project);
45311
+ if (s.repo) bucket.repos.add(s.repo);
45239
45312
  }
45240
45313
  const currentNorm = normalizePath2(ctx.currentProject);
45241
45314
  const currentBase = basename3(currentNorm);
45242
- for (const [secretName, projects] of byName) {
45315
+ for (const [secretName, { projects, repos }] of byName) {
45243
45316
  const normalizedProjects = [...projects].map(normalizePath2);
45244
- const currentHas = normalizedProjects.some((p2) => {
45245
- if (p2 === currentNorm) return true;
45246
- const lastSegment = p2.split("/").pop() || "";
45247
- return lastSegment === currentBase;
45248
- });
45317
+ const repoSpecs = [...repos];
45318
+ const currentHas = normalizedProjects.some((p2) => p2 === currentNorm) || repoSpecs.some((r2) => repoBasename(r2) === currentBase);
45249
45319
  if (currentHas) continue;
45250
45320
  const siblingMatches = ctx.siblings.filter((sib) => {
45251
45321
  const sibNorm = normalizePath2(sib.path);
45252
- const sibBase = basename3(sibNorm);
45253
- return normalizedProjects.some((p2) => {
45254
- if (p2 === sibNorm) return true;
45255
- const lastSegment = p2.split("/").pop() || "";
45256
- return lastSegment === sibBase;
45257
- });
45322
+ if (normalizedProjects.some((p2) => p2 === sibNorm)) return true;
45323
+ return repoSpecs.some((r2) => repoMatchesSibling(r2, sib));
45258
45324
  });
45259
45325
  if (siblingMatches.length >= 2) {
45260
45326
  const sibNames = siblingMatches.map((s) => s.name).join(", ");
@@ -45271,13 +45337,14 @@ async function checkMissingSecret(ctx) {
45271
45337
  }
45272
45338
  return issues;
45273
45339
  }
45274
- var SECRET_SET_SIMPLE, REPO_FLAG_PATTERN;
45340
+ var SECRET_SET_PREFIX, REPO_FLAG_PATTERN, VALUE_FLAGS;
45275
45341
  var init_missing_secret = __esm({
45276
45342
  "src/core/checks/session/missing-secret.ts"() {
45277
45343
  "use strict";
45278
45344
  init_define_WEB_FIRST_SEGMENTS();
45279
- SECRET_SET_SIMPLE = /gh\s+secret\s+set\s+(\S+)/;
45345
+ SECRET_SET_PREFIX = /gh\s+secret\s+set\s+/;
45280
45346
  REPO_FLAG_PATTERN = /--repo\s+(\S+)/;
45347
+ VALUE_FLAGS = /* @__PURE__ */ new Set(["--repo", "-R", "-b", "--body", "--app", "--env"]);
45281
45348
  }
45282
45349
  });
45283
45350
 
@@ -45438,14 +45505,14 @@ var init_missing_workflow = __esm({
45438
45505
 
45439
45506
  // src/core/checks/session/stale-memory.ts
45440
45507
  import { existsSync as existsSync4 } from "node:fs";
45441
- import { resolve as resolve8, isAbsolute as isAbsolute2 } from "node:path";
45508
+ import { resolve as resolve8, isAbsolute as isAbsolute3 } from "node:path";
45442
45509
  function resolveRef2(ref, projectRoot) {
45443
45510
  if (ref.startsWith("~/") || ref === "~") {
45444
45511
  const home2 = process.env.HOME || process.env.USERPROFILE;
45445
45512
  if (!home2) return null;
45446
45513
  return ref === "~" ? home2 : resolve8(home2, ref.slice(2));
45447
45514
  }
45448
- if (isAbsolute2(ref)) return ref;
45515
+ if (isAbsolute3(ref)) return ref;
45449
45516
  return resolve8(projectRoot, ref);
45450
45517
  }
45451
45518
  async function checkStaleMemory(ctx) {
@@ -45470,6 +45537,7 @@ async function checkStaleMemory(ctx) {
45470
45537
  ruleId: "session-stale-memory/stale-memory",
45471
45538
  line: 0,
45472
45539
  message: `Memory "${name}" references ${brokenPaths.length} path(s) that no longer exist: ${brokenPaths.join(", ")}`,
45540
+ affectedPaths: brokenPaths,
45473
45541
  suggestion: `Update or remove the memory file: ${mem.filePath}`,
45474
45542
  detail: `Memory files with broken path references may cause the AI agent to follow stale instructions`
45475
45543
  });
@@ -45663,7 +45731,7 @@ async function checkMemoryIndexOverflow(ctx) {
45663
45731
  issues.push({
45664
45732
  severity: "warning",
45665
45733
  check: "session-memory-index-overflow",
45666
- ruleId: "session-memory-index-overflow/memory-index-overflow",
45734
+ ruleId: "session-memory-index-overflow/line-overflow",
45667
45735
  line: MAX_LINES + 1,
45668
45736
  message: `MEMORY.md has ${lineCount.toLocaleString()} lines \u2014 only the first ${MAX_LINES} are loaded. ${excess.toLocaleString()} line(s) are effectively invisible to the agent.`,
45669
45737
  detail: `File: ${memoryFile}`,
@@ -45675,7 +45743,7 @@ async function checkMemoryIndexOverflow(ctx) {
45675
45743
  issues.push({
45676
45744
  severity: "warning",
45677
45745
  check: "session-memory-index-overflow",
45678
- ruleId: "session-memory-index-overflow/memory-index-overflow",
45746
+ ruleId: "session-memory-index-overflow/byte-overflow",
45679
45747
  line: 0,
45680
45748
  message: `MEMORY.md is ${byteSize.toLocaleString()} bytes \u2014 only the first ${MAX_BYTES.toLocaleString()} bytes are loaded. ~${excess.toLocaleString()} bytes are effectively invisible.`,
45681
45749
  detail: `File: ${memoryFile}`,
@@ -46585,7 +46653,7 @@ function applyIgnoreRules(issues, rules) {
46585
46653
  if (rule.check !== issue2.check) continue;
46586
46654
  if (rule.pathPattern) {
46587
46655
  if (issue2.check !== "session-stale-memory") continue;
46588
- const paths = extractPathsFromMessage(issue2.message);
46656
+ const paths = issue2.affectedPaths ?? extractPathsFromMessage(issue2.message);
46589
46657
  if (paths.length === 0) continue;
46590
46658
  const allMatch = paths.every((p2) => rule.pathPattern.test(p2));
46591
46659
  if (!allMatch) continue;
@@ -46643,7 +46711,7 @@ import { readFileSync as readFileSync7 } from "node:fs";
46643
46711
  import { resolve as resolve12, dirname as dirname6 } from "node:path";
46644
46712
  import { fileURLToPath as fileURLToPath2 } from "node:url";
46645
46713
  function loadVersion() {
46646
- if (true) return "0.13.1";
46714
+ if (true) return "0.13.2";
46647
46715
  const __dir = dirname6(fileURLToPath2(import.meta.url));
46648
46716
  const pkgPath = resolve12(__dir, "../package.json");
46649
46717
  const pkg = JSON.parse(readFileSync7(pkgPath, "utf-8"));
@@ -46698,7 +46766,9 @@ async function runAudit(projectRoot, activeChecks, options = {}) {
46698
46766
  if (activeChecks.includes("tokens"))
46699
46767
  checkPromises.push(checkTokens(file2, projectRoot, thresholds));
46700
46768
  if (activeChecks.includes("tier-tokens"))
46701
- checkPromises.push(checkTierTokens(file2, projectRoot, thresholds));
46769
+ checkPromises.push(
46770
+ checkTierTokens(file2, projectRoot, thresholds, Boolean(options.hooksGlobal))
46771
+ );
46702
46772
  if (activeChecks.includes("redundancy"))
46703
46773
  checkPromises.push(checkRedundancy(file2, projectRoot));
46704
46774
  if (activeChecks.includes("frontmatter"))
@@ -46973,8 +47043,9 @@ async function runAudit(projectRoot, activeChecks, options = {}) {
46973
47043
  if (typeof issue2.wastedTokens === "number") {
46974
47044
  estimatedWaste += issue2.wastedTokens;
46975
47045
  } else if (issue2.suggestion) {
46976
- const tokenMatch = issue2.suggestion.match(/~(\d+)\s+tokens/);
46977
- if (tokenMatch) estimatedWaste += parseInt(tokenMatch[1], 10);
47046
+ for (const m of issue2.suggestion.matchAll(/~(\d+)\s+tokens/g)) {
47047
+ estimatedWaste += parseInt(m[1], 10);
47048
+ }
46978
47049
  }
46979
47050
  }
46980
47051
  }
@@ -47652,12 +47723,6 @@ function applyFixes(result, options = {}) {
47652
47723
  } : console.log.bind(console);
47653
47724
  const dryRun = options.dryRun ?? false;
47654
47725
  const skipSymlinks = options.skipSymlinks ?? true;
47655
- const symlinkFiles = /* @__PURE__ */ new Set();
47656
- if (skipSymlinks) {
47657
- for (const f of result.files) {
47658
- if (f.isSymlink) symlinkFiles.add(f.path);
47659
- }
47660
- }
47661
47726
  const fixesByFile = /* @__PURE__ */ new Map();
47662
47727
  const dedupeKeys = /* @__PURE__ */ new Map();
47663
47728
  const skippedSymlinks = /* @__PURE__ */ new Set();
@@ -48213,7 +48278,7 @@ var init_server3 = __esm({
48213
48278
  checks: external_exports3.array(mcphCheckEnum).optional().describe("Specific mcph checks to run (default: all mcph-* checks)."),
48214
48279
  includeGlobal: external_exports3.boolean().optional().describe("Also scan ~/.mcph.json (user-global config)."),
48215
48280
  strictEnvToken: external_exports3.boolean().optional().describe(
48216
- "Upgrade mcph-config/prefer-env-token from warning to error (env-var-only posture)."
48281
+ "Upgrade mcph-token-security/prefer-env-token from warning to error (env-var-only posture)."
48217
48282
  )
48218
48283
  },
48219
48284
  {
@@ -54960,12 +55025,13 @@ function formatTokenReport(result) {
54960
55025
  source_default.dim(" (counts use GPT-4 cl100k_base tokenizer \u2014 Claude counts may vary slightly)")
54961
55026
  );
54962
55027
  lines.push("");
54963
- const maxPathLen = Math.max(...result.files.map((f) => f.path.length), 4);
55028
+ const realFiles = result.files.filter((f) => !isSyntheticBucket(f.path));
55029
+ const maxPathLen = Math.max(...realFiles.map((f) => f.path.length), 4);
54964
55030
  lines.push(
54965
55031
  ` ${source_default.dim("File".padEnd(maxPathLen))} ${source_default.dim("Tokens".padStart(8))} ${source_default.dim("Lines".padStart(6))}`
54966
55032
  );
54967
55033
  lines.push(` ${"-".repeat(maxPathLen)} ${"-".repeat(8)} ${"-".repeat(6)}`);
54968
- for (const file2 of result.files) {
55034
+ for (const file2 of realFiles) {
54969
55035
  const tokenStr = file2.tokens.toLocaleString().padStart(8);
54970
55036
  const lineStr = file2.lines.toString().padStart(6);
54971
55037
  lines.push(` ${file2.path.padEnd(maxPathLen)} ${tokenStr} ${lineStr}`);
@@ -54986,7 +55052,7 @@ function formatTokenReport(result) {
54986
55052
  return lines.join("\n");
54987
55053
  }
54988
55054
  function isSyntheticPath(p2) {
54989
- return p2.startsWith("(") || p2.startsWith("~");
55055
+ return isSyntheticBucket(p2) || p2.startsWith("~");
54990
55056
  }
54991
55057
  function formatSarif(result) {
54992
55058
  const severityToLevel = {
@@ -55308,7 +55374,7 @@ async function runCli() {
55308
55374
  new Option("--format <format>", "Output format: text, json, or sarif").choices(["text", "json", "sarif"]).default("text")
55309
55375
  ).option("--tokens", "Show token breakdown per file", false).option("--verbose", "Show passing checks too", false).option("--fix", "Auto-fix broken paths using git history and fuzzy matching", false).option("--fix-dry-run", "Preview --fix changes without writing", false).option("--yes", "Skip interactive confirmation prompts (required for --fix in TTY)", false).option("--follow-symlinks", "Allow --fix to write through symlinks (default: skip)", false).option("--ignore <checks>", "Comma-separated list of checks to ignore", "").option("--quiet", "Suppress all output except errors (exit code only)", false).option("--config <path>", "Path to config file (default: .ctxlintrc in project root)").option("--depth <n>", "Max subdirectory depth to scan (default: 2)", "2").option("--mcp", "Enable MCP config linting alongside context file checks", false).option("--mcp-only", "Run only MCP config checks, skip context file checks", false).option("--mcp-global", "Also scan user/global MCP config files (implies --mcp)", false).option("--mcp-server", "Start the MCP server (alias: `ctxlint serve`)").option("--mcph", "Enable .mcph.json (mcp.hosting CLI config) linting", false).option("--mcph-only", "Run only mcph config checks", false).option("--mcph-global", "Also scan ~/.mcph.json (implies --mcph)", false).option(
55310
55376
  "--mcph-strict-env-token",
55311
- "Upgrade mcph-config/prefer-env-token from warning to error (strict env-var-only posture)",
55377
+ "Upgrade mcph-token-security/prefer-env-token from warning to error (strict env-var-only posture)",
55312
55378
  false
55313
55379
  ).option("--session", "Run session audit checks (cross-project consistency)", false).option("--session-only", "Run only session checks, skip context and MCP checks", false).option("--skills", "Run agent-skill checks (~/.claude/skills + ~/.claude/agents)", false).option("--skills-only", "Run only agent-skill checks, skip everything else", false).option(
55314
55380
  "--hooks-global",
@@ -55342,7 +55408,7 @@ async function runCli() {
55342
55408
  if (result.files.length === 0) {
55343
55409
  if (!options.quiet) {
55344
55410
  if (options.format === "json") {
55345
- console.log(JSON.stringify(result));
55411
+ console.log(formatJson(result));
55346
55412
  } else if (options.format === "sarif") {
55347
55413
  console.log(formatSarif(result));
55348
55414
  } else {
@@ -55482,7 +55548,7 @@ Fixed ${applied.totalFixes} issue${applied.totalFixes !== 1 ? "s" : ""} in ${app
55482
55548
  let liveOptions = options;
55483
55549
  let liveActiveChecks = activeChecks;
55484
55550
  try {
55485
- const resolved = resolveSession(resolvedPath, opts);
55551
+ const resolved = resolveSession(resolvedPath, opts, true);
55486
55552
  liveConfig = resolved.config;
55487
55553
  liveOptions = resolved.options;
55488
55554
  liveActiveChecks = resolved.activeChecks;
@@ -55604,9 +55670,9 @@ async function promptYesNo(question) {
55604
55670
  rl.close();
55605
55671
  }
55606
55672
  }
55607
- function resolveSession(resolvedPath, opts) {
55673
+ function resolveSession(resolvedPath, opts, throwOnConfigError = false) {
55608
55674
  const configPath = opts.config ? path16.resolve(opts.config) : void 0;
55609
- const config2 = configPath ? loadConfigFromPath(configPath) : loadConfig(resolvedPath);
55675
+ const config2 = configPath ? loadConfigFromPath(configPath, throwOnConfigError) : loadConfig(resolvedPath);
55610
55676
  const mcpGlobal = opts.mcpGlobal || config2?.mcpGlobal || false;
55611
55677
  const mcpOnly = opts.mcpOnly || config2?.mcpOnly || false;
55612
55678
  const mcpFlag = opts.mcp || mcpGlobal || mcpOnly || config2?.mcp || false;
@@ -55683,10 +55749,11 @@ function resolveSession(resolvedPath, opts) {
55683
55749
  const activeChecks = options.checks.filter((c3) => !options.ignore.includes(c3));
55684
55750
  return { config: config2, options, activeChecks };
55685
55751
  }
55686
- function loadConfigFromPath(configPath) {
55752
+ function loadConfigFromPath(configPath, throwOnError = false) {
55687
55753
  try {
55688
55754
  return loadConfigFromExplicitPath(configPath);
55689
55755
  } catch (err) {
55756
+ if (throwOnError) throw err;
55690
55757
  const detail = err instanceof Error ? err.message : String(err);
55691
55758
  console.error(`Error: ${detail}`);
55692
55759
  process.exit(2);
@@ -35,7 +35,7 @@
35
35
  ],
36
36
  "rules": [
37
37
  {
38
- "id": "mcph-config/token-in-project-scope",
38
+ "id": "mcph-token-security/token-in-project-scope",
39
39
  "category": "mcph-token-security",
40
40
  "severity": "error",
41
41
  "description": "A project-scope .mcph.json that is git-tracked contains a \"token\" field. The PAT will leak via git history.",
@@ -46,18 +46,18 @@
46
46
  "stability": "stable"
47
47
  },
48
48
  {
49
- "id": "mcph-config/local-file-not-gitignored",
49
+ "id": "mcph-gitignore/local-file-not-gitignored",
50
50
  "category": "mcph-gitignore",
51
51
  "severity": "error",
52
52
  "description": "A .mcph.local.json file exists but is not covered by .gitignore. The file exists precisely so machine-local overrides stay machine-local.",
53
53
  "trigger": "scope === 'project-local' && !isGitignored",
54
54
  "message": "{file} is not covered by .gitignore — machine-local overrides can leak via git",
55
- "fixable": true,
55
+ "fixable": false,
56
56
  "fixDescription": "Append \".mcph.local.json\" to .gitignore in the project root.",
57
57
  "stability": "stable"
58
58
  },
59
59
  {
60
- "id": "mcph-config/invalid-token-format",
60
+ "id": "mcph-token-security/invalid-token-format",
61
61
  "category": "mcph-token-security",
62
62
  "severity": "error",
63
63
  "description": "The \"token\" value doesn't match the mcp.hosting PAT format (^mcp_pat_[A-Za-z0-9_-]+$).",
@@ -68,7 +68,7 @@
68
68
  "stability": "stable"
69
69
  },
70
70
  {
71
- "id": "mcph-config/insecure-apibase",
71
+ "id": "mcph-apibase/insecure-apibase",
72
72
  "category": "mcph-apibase",
73
73
  "severity": "warning",
74
74
  "description": "The \"apiBase\" URL uses plaintext HTTP to a public (non-localhost, non-private) host. The MCPH_TOKEN would travel in the clear.",
@@ -79,7 +79,7 @@
79
79
  "stability": "stable"
80
80
  },
81
81
  {
82
- "id": "mcph-config/invalid-apibase",
82
+ "id": "mcph-apibase/invalid-apibase",
83
83
  "category": "mcph-apibase",
84
84
  "severity": "error",
85
85
  "description": "The \"apiBase\" field is not a valid absolute URL.",
@@ -90,7 +90,7 @@
90
90
  "stability": "stable"
91
91
  },
92
92
  {
93
- "id": "mcph-config/unknown-field",
93
+ "id": "mcph-schema-conformance/unknown-field",
94
94
  "category": "mcph-schema-conformance",
95
95
  "severity": "warning",
96
96
  "description": "A top-level field appears that is not in the mcph config schema. Usually a typo (tokens vs token, blockList vs blocked).",
@@ -101,7 +101,7 @@
101
101
  "stability": "stable"
102
102
  },
103
103
  {
104
- "id": "mcph-config/stale-version",
104
+ "id": "mcph-schema-conformance/stale-version",
105
105
  "category": "mcph-schema-conformance",
106
106
  "severity": "info",
107
107
  "description": "The \"version\" field is older than the current schema version.",
@@ -112,7 +112,7 @@
112
112
  "stability": "stable"
113
113
  },
114
114
  {
115
- "id": "mcph-config/allowlist-denylist-conflict",
115
+ "id": "mcph-lists/allowlist-denylist-conflict",
116
116
  "category": "mcph-lists",
117
117
  "severity": "warning",
118
118
  "description": "A server namespace appears in both the allow-list (\"servers\") and deny-list (\"blocked\"). Deny wins in practice, so the allow-list entry is dead weight.",
@@ -123,7 +123,7 @@
123
123
  "stability": "stable"
124
124
  },
125
125
  {
126
- "id": "mcph-config/duplicate-entries",
126
+ "id": "mcph-lists/duplicate-entries",
127
127
  "category": "mcph-lists",
128
128
  "severity": "info",
129
129
  "description": "The same value appears more than once in a servers or blocked array.",
@@ -134,7 +134,7 @@
134
134
  "stability": "stable"
135
135
  },
136
136
  {
137
- "id": "mcph-config/prefer-env-token",
137
+ "id": "mcph-token-security/prefer-env-token",
138
138
  "category": "mcph-token-security",
139
139
  "severity": "warning",
140
140
  "configurable": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ctxlint",
3
- "version": "0.13.2",
3
+ "version": "0.13.3",
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": {