@yawlabs/ctxlint 0.9.18 → 0.9.19

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.9.18 --strict
7
+ entry: npx @yawlabs/ctxlint@0.9.19 --strict
8
8
  language: node
9
9
  always_run: true
10
10
  pass_filenames: false
package/README.md CHANGED
@@ -28,7 +28,7 @@ ctxlint is a linter purpose-built for this. It reads your context files, cross-r
28
28
  - **Token-aware** — shows how much context window your files consume and flags redundant content
29
29
  - **Every AI tool** — supports Claude Code, Cursor, Copilot, Windsurf, Gemini, Cline, Aider, and 14 more
30
30
  - **Multiple outputs** — text, JSON, and SARIF (GitHub Code Scanning)
31
- - **MCP server** — 6 tools for IDE/agent integration with tool annotations for auto-approval
31
+ - **MCP server** — 7 tools for IDE/agent integration with tool annotations for auto-approval
32
32
  - **Watch mode** — `--watch` re-lints automatically when context files change
33
33
 
34
34
  ## Install
@@ -404,7 +404,7 @@ CLI flags override config file settings. Use `--config <path>` to load a config
404
404
 
405
405
  ## Use as MCP Server
406
406
 
407
- ctxlint ships with an MCP server that exposes six tools (`ctxlint_audit`, `ctxlint_mcp_audit`, `ctxlint_session_audit`, `ctxlint_validate_path`, `ctxlint_token_report`, `ctxlint_fix`). All read-only tools declare annotations so MCP clients can skip confirmation dialogs.
407
+ ctxlint ships with an MCP server that exposes seven tools (`ctxlint_audit`, `ctxlint_mcp_audit`, `ctxlint_mcph_audit`, `ctxlint_session_audit`, `ctxlint_validate_path`, `ctxlint_token_report`, `ctxlint_fix`). All read-only tools declare annotations so MCP clients can skip confirmation dialogs.
408
408
 
409
409
  Launch it with the `serve` subcommand (or the equivalent `--mcp-server` flag, kept for back-compat):
410
410
 
package/dist/index.js CHANGED
@@ -41316,6 +41316,28 @@ function parseTree(text, errors = [], options = ParseOptions.DEFAULT) {
41316
41316
  }
41317
41317
  return result;
41318
41318
  }
41319
+ function getNodeValue(node) {
41320
+ switch (node.type) {
41321
+ case "array":
41322
+ return node.children.map(getNodeValue);
41323
+ case "object":
41324
+ const obj = /* @__PURE__ */ Object.create(null);
41325
+ for (let prop of node.children) {
41326
+ const valueNode = prop.children[1];
41327
+ if (valueNode) {
41328
+ obj[prop.children[0].value] = getNodeValue(valueNode);
41329
+ }
41330
+ }
41331
+ return obj;
41332
+ case "null":
41333
+ case "string":
41334
+ case "number":
41335
+ case "boolean":
41336
+ return node.value;
41337
+ default:
41338
+ return void 0;
41339
+ }
41340
+ }
41319
41341
  function visit(text, visitor, options = ParseOptions.DEFAULT) {
41320
41342
  const _scanner = createScanner(text, false);
41321
41343
  const _jsonPath = [];
@@ -41664,7 +41686,7 @@ var init_edit = __esm({
41664
41686
  });
41665
41687
 
41666
41688
  // node_modules/.pnpm/jsonc-parser@3.3.1/node_modules/jsonc-parser/lib/esm/main.js
41667
- var ScanError, SyntaxKind, parseTree2, ParseErrorCode;
41689
+ var ScanError, SyntaxKind, parseTree2, getNodeValue2, ParseErrorCode;
41668
41690
  var init_main = __esm({
41669
41691
  "node_modules/.pnpm/jsonc-parser@3.3.1/node_modules/jsonc-parser/lib/esm/main.js"() {
41670
41692
  "use strict";
@@ -41701,6 +41723,7 @@ var init_main = __esm({
41701
41723
  SyntaxKind2[SyntaxKind2["EOF"] = 17] = "EOF";
41702
41724
  })(SyntaxKind || (SyntaxKind = {}));
41703
41725
  parseTree2 = parseTree;
41726
+ getNodeValue2 = getNodeValue;
41704
41727
  (function(ParseErrorCode2) {
41705
41728
  ParseErrorCode2[ParseErrorCode2["InvalidSymbol"] = 1] = "InvalidSymbol";
41706
41729
  ParseErrorCode2[ParseErrorCode2["InvalidNumberFormat"] = 2] = "InvalidNumberFormat";
@@ -41754,11 +41777,7 @@ async function parseMchpConfig(file2, projectRoot, scopeOverride) {
41754
41777
  result.parseErrors.push(".mcph.json must be a JSON object at the root");
41755
41778
  return result;
41756
41779
  }
41757
- try {
41758
- result.raw = JSON.parse(stripComments2(content));
41759
- } catch {
41760
- return result;
41761
- }
41780
+ result.raw = getNodeValue2(tree);
41762
41781
  const rootProps = tree.children ?? [];
41763
41782
  for (const prop of rootProps) {
41764
41783
  if (prop.type !== "property" || !prop.children || prop.children.length < 2) continue;
@@ -41816,46 +41835,6 @@ function offsetToPosition(content, offset) {
41816
41835
  }
41817
41836
  return { line, column };
41818
41837
  }
41819
- function stripComments2(src) {
41820
- let out = "";
41821
- let i2 = 0;
41822
- let inString = false;
41823
- let escape2 = false;
41824
- while (i2 < src.length) {
41825
- const ch = src[i2];
41826
- if (inString) {
41827
- out += ch;
41828
- if (escape2) {
41829
- escape2 = false;
41830
- } else if (ch === "\\") {
41831
- escape2 = true;
41832
- } else if (ch === '"') {
41833
- inString = false;
41834
- }
41835
- i2++;
41836
- continue;
41837
- }
41838
- if (ch === '"') {
41839
- inString = true;
41840
- out += ch;
41841
- i2++;
41842
- continue;
41843
- }
41844
- if (ch === "/" && src[i2 + 1] === "/") {
41845
- while (i2 < src.length && src[i2] !== "\n") i2++;
41846
- continue;
41847
- }
41848
- if (ch === "/" && src[i2 + 1] === "*") {
41849
- i2 += 2;
41850
- while (i2 < src.length && !(src[i2] === "*" && src[i2 + 1] === "/")) i2++;
41851
- i2 += 2;
41852
- continue;
41853
- }
41854
- out += ch;
41855
- i2++;
41856
- }
41857
- return out;
41858
- }
41859
41838
  async function checkGitTracked2(filePath, projectRoot) {
41860
41839
  try {
41861
41840
  const git = getGit(projectRoot);
@@ -45242,7 +45221,7 @@ import { readFileSync as readFileSync4 } from "node:fs";
45242
45221
  import { resolve as resolve8, dirname as dirname3 } from "node:path";
45243
45222
  import { fileURLToPath } from "node:url";
45244
45223
  function loadVersion() {
45245
- if (true) return "0.9.18";
45224
+ if (true) return "0.9.19";
45246
45225
  const __dir = dirname3(fileURLToPath(import.meta.url));
45247
45226
  const pkgPath = resolve8(__dir, "../package.json");
45248
45227
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf-8"));
@@ -46190,6 +46169,8 @@ function applyFixes(result, options = {}) {
46190
46169
  existing.push(fix);
46191
46170
  fixesByLine.set(fix.line, existing);
46192
46171
  }
46172
+ let perFileFixCount = 0;
46173
+ const perFileLogs = [];
46193
46174
  for (const [lineNum, lineFixes] of fixesByLine) {
46194
46175
  const lineIdx = lineNum - 1;
46195
46176
  if (lineIdx < 0 || lineIdx >= lines.length) continue;
@@ -46197,9 +46178,9 @@ function applyFixes(result, options = {}) {
46197
46178
  for (const fix of lineFixes) {
46198
46179
  if (line.includes(fix.oldText)) {
46199
46180
  line = line.replaceAll(fix.oldText, fix.newText);
46200
- totalFixes++;
46181
+ perFileFixCount++;
46201
46182
  const prefix = dryRun ? source_default.cyan(" Would fix") : source_default.green(" Fixed");
46202
- log(
46183
+ perFileLogs.push(
46203
46184
  prefix + ` Line ${fix.line}: ${source_default.dim(fix.oldText)} ${source_default.dim("->")} ${fix.newText}`
46204
46185
  );
46205
46186
  }
@@ -46223,6 +46204,8 @@ function applyFixes(result, options = {}) {
46223
46204
  fs7.writeFileSync(filePath, newContent, "utf-8");
46224
46205
  }
46225
46206
  filesModified.push(filePath);
46207
+ totalFixes += perFileFixCount;
46208
+ for (const m of perFileLogs) log(m);
46226
46209
  }
46227
46210
  }
46228
46211
  return { totalFixes, filesModified };
@@ -46253,7 +46236,7 @@ function validateFilePathInput(rawPath) {
46253
46236
  throw new Error("path contains disallowed characters");
46254
46237
  }
46255
46238
  }
46256
- var contextCheckEnum, mcpCheckEnum, sessionCheckEnum, PATH_DISALLOWED, server, transport;
46239
+ var contextCheckEnum, mcpCheckEnum, mcphCheckEnum, sessionCheckEnum, PATH_DISALLOWED, server, transport;
46257
46240
  var init_server3 = __esm({
46258
46241
  async "src/mcp/server.ts"() {
46259
46242
  "use strict";
@@ -46272,6 +46255,7 @@ var init_server3 = __esm({
46272
46255
  init_version2();
46273
46256
  contextCheckEnum = external_exports3.enum(ALL_CHECKS);
46274
46257
  mcpCheckEnum = external_exports3.enum(ALL_MCP_CHECKS);
46258
+ mcphCheckEnum = external_exports3.enum(ALL_MCPH_CHECKS);
46275
46259
  sessionCheckEnum = external_exports3.enum(ALL_SESSION_CHECKS);
46276
46260
  PATH_DISALLOWED = /[\n\r\t;`|]|\$\(|\$\{/;
46277
46261
  server = new McpServer({
@@ -46493,6 +46477,48 @@ var init_server3 = __esm({
46493
46477
  }
46494
46478
  }
46495
46479
  );
46480
+ server.tool(
46481
+ "ctxlint_mcph_audit",
46482
+ "Lint .mcph.json (the @yawlabs/mcph CLI config) files. Checks for PAT (mcp_pat_*) leakage in git-tracked project-scope files, environment-variable posture, plaintext HTTP apiBase to public hosts, schema drift, allow/deny list conflicts and duplicates, and machine-local override files not covered by .gitignore. Distinct from ctxlint_mcp_audit, which lints client-side .mcp.json server lists.",
46483
+ {
46484
+ projectPath: external_exports3.string().optional().describe("Path to the project root. Defaults to current working directory."),
46485
+ checks: external_exports3.array(mcphCheckEnum).optional().describe("Specific mcph checks to run (default: all mcph-* checks)."),
46486
+ includeGlobal: external_exports3.boolean().optional().describe("Also scan ~/.mcph.json (user-global config)."),
46487
+ strictEnvToken: external_exports3.boolean().optional().describe(
46488
+ "Upgrade mcph-config/prefer-env-token from warning to error (env-var-only posture)."
46489
+ )
46490
+ },
46491
+ {
46492
+ readOnlyHint: true,
46493
+ destructiveHint: false,
46494
+ idempotentHint: true,
46495
+ openWorldHint: false
46496
+ },
46497
+ async ({ projectPath, checks, includeGlobal, strictEnvToken }) => {
46498
+ try {
46499
+ const root = validateProjectPath(projectPath);
46500
+ const activeChecks = checks?.length ? checks : ALL_MCPH_CHECKS;
46501
+ const result = await runAudit(root, activeChecks, {
46502
+ mcph: true,
46503
+ mcphOnly: true,
46504
+ mcphGlobal: includeGlobal || false,
46505
+ mcphStrictEnvToken: strictEnvToken || false
46506
+ });
46507
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
46508
+ } catch (err) {
46509
+ const msg = err instanceof Error ? err.message : String(err);
46510
+ return {
46511
+ content: [{ type: "text", text: JSON.stringify({ error: msg }) }],
46512
+ isError: true
46513
+ };
46514
+ } finally {
46515
+ freeEncoder();
46516
+ resetGit();
46517
+ resetPathsCache();
46518
+ resetPackageJsonCache();
46519
+ }
46520
+ }
46521
+ );
46496
46522
  server.tool(
46497
46523
  "ctxlint_session_audit",
46498
46524
  "Audit AI agent session data for cross-project consistency. Checks for missing GitHub secrets, diverged config files, missing workflows, stale memory entries, and duplicate memories across sibling repositories.",
@@ -53008,6 +53034,24 @@ var init_ora = __esm({
53008
53034
  });
53009
53035
 
53010
53036
  // src/core/reporter.ts
53037
+ function classifyFile(f) {
53038
+ if (f.path === "(mcp)") return "mcp";
53039
+ if (f.path.includes("session audit")) return "session";
53040
+ for (const issue2 of f.issues) {
53041
+ if (issue2.check.startsWith("session-")) return "session";
53042
+ if (issue2.check.startsWith("mcph-")) return "mcph";
53043
+ if (issue2.check.startsWith("mcp-")) return "mcp";
53044
+ }
53045
+ const norm = f.path.replace(/\\/g, "/");
53046
+ if (norm.endsWith(".mcph.json") || norm.endsWith(".mcph.local.json")) return "mcph";
53047
+ if (norm === ".mcp.json" || norm.endsWith("/.mcp.json") || norm.endsWith("/mcp.json") || norm.includes("/mcpServers/") || norm.endsWith("/.claude.json") || norm.endsWith(".claude/settings.json") || norm.endsWith("claude_desktop_config.json")) {
53048
+ return "mcp";
53049
+ }
53050
+ return "context";
53051
+ }
53052
+ function isSyntheticBucket(p2) {
53053
+ return p2.startsWith("(") || p2.includes("session audit");
53054
+ }
53011
53055
  function formatText(result, verbose = false) {
53012
53056
  const lines = [];
53013
53057
  lines.push("");
@@ -53015,30 +53059,45 @@ function formatText(result, verbose = false) {
53015
53059
  lines.push("");
53016
53060
  lines.push(`Scanning ${result.projectRoot}...`);
53017
53061
  lines.push("");
53018
- const isMcpFile = (f) => f.issues.some((i2) => i2.check.startsWith("mcp-"));
53019
- const contextFiles = result.files.filter((f) => !isMcpFile(f));
53020
- const mcpFiles = result.files.filter((f) => isMcpFile(f));
53062
+ const groups = {
53063
+ context: [],
53064
+ mcp: [],
53065
+ mcph: [],
53066
+ session: []
53067
+ };
53068
+ for (const f of result.files) {
53069
+ groups[classifyFile(f)].push(f);
53070
+ }
53021
53071
  const totalTokens = result.summary.totalTokens;
53022
- if (contextFiles.length > 0) {
53072
+ let renderedAnySummary = false;
53073
+ const contextReal = groups.context.filter((f) => !isSyntheticBucket(f.path));
53074
+ if (contextReal.length > 0) {
53023
53075
  lines.push(
53024
- `Found ${contextFiles.length} context file${contextFiles.length !== 1 ? "s" : ""} (${totalTokens.toLocaleString()} tokens total)`
53076
+ `Found ${contextReal.length} context file${contextReal.length !== 1 ? "s" : ""} (${totalTokens.toLocaleString()} tokens total)`
53025
53077
  );
53026
- for (const file2 of contextFiles) {
53078
+ for (const file2 of contextReal) {
53027
53079
  let desc = ` ${file2.path} (${file2.tokens.toLocaleString()} tokens, ${file2.lines} lines)`;
53028
53080
  if (file2.isSymlink && file2.symlinkTarget) {
53029
53081
  desc = ` ${file2.path} ${source_default.dim(`-> ${file2.symlinkTarget} (symlink)`)}`;
53030
53082
  }
53031
53083
  lines.push(desc);
53032
53084
  }
53085
+ renderedAnySummary = true;
53033
53086
  }
53034
- if (mcpFiles.length > 0) {
53035
- if (contextFiles.length > 0) lines.push("");
53036
- lines.push(`Found ${mcpFiles.length} MCP config${mcpFiles.length !== 1 ? "s" : ""}`);
53037
- for (const file2 of mcpFiles) {
53038
- lines.push(` ${file2.path}`);
53039
- }
53087
+ for (const g of ["mcp", "mcph"]) {
53088
+ const real = groups[g].filter((f) => !isSyntheticBucket(f.path));
53089
+ if (real.length === 0) continue;
53090
+ if (renderedAnySummary) lines.push("");
53091
+ lines.push(`Found ${real.length} ${GROUP_SUMMARY_NOUNS[g]}${real.length !== 1 ? "s" : ""}`);
53092
+ for (const file2 of real) lines.push(` ${file2.path}`);
53093
+ renderedAnySummary = true;
53094
+ }
53095
+ if (groups.session.length > 0) {
53096
+ if (renderedAnySummary) lines.push("");
53097
+ lines.push("Session audit scanned");
53098
+ renderedAnySummary = true;
53040
53099
  }
53041
- if (contextFiles.length === 0 && mcpFiles.length === 0) {
53100
+ if (!renderedAnySummary) {
53042
53101
  lines.push(`Found ${result.files.length} file${result.files.length !== 1 ? "s" : ""}`);
53043
53102
  }
53044
53103
  lines.push("");
@@ -53057,16 +53116,13 @@ function formatText(result, verbose = false) {
53057
53116
  lines.push("");
53058
53117
  }
53059
53118
  };
53060
- if (contextFiles.length > 0 && mcpFiles.length > 0) {
53061
- const contextWithIssues = contextFiles.filter((f) => f.issues.length > 0 || verbose);
53062
- const mcpWithIssues = mcpFiles.filter((f) => f.issues.length > 0 || verbose);
53063
- if (contextWithIssues.length > 0) {
53064
- lines.push(source_default.bold("Context Files"));
53065
- renderFileGroup(contextFiles);
53066
- }
53067
- if (mcpWithIssues.length > 0) {
53068
- lines.push(source_default.bold("MCP Configs"));
53069
- renderFileGroup(mcpFiles);
53119
+ const groupsToRender = GROUP_ORDER.filter(
53120
+ (g) => groups[g].some((f) => f.issues.length > 0 || verbose)
53121
+ );
53122
+ if (groupsToRender.length > 1) {
53123
+ for (const g of groupsToRender) {
53124
+ lines.push(source_default.bold(GROUP_LABELS[g]));
53125
+ renderFileGroup(groups[g]);
53070
53126
  }
53071
53127
  } else {
53072
53128
  renderFileGroup(result.files);
@@ -53282,6 +53338,31 @@ function buildRuleDescriptors() {
53282
53338
  shortDescription: { text: "Redundant MCP config entry" },
53283
53339
  helpUri: "https://github.com/yawlabs/ctxlint#mcp-config-linting"
53284
53340
  },
53341
+ {
53342
+ id: "ctxlint/mcph-token-security",
53343
+ shortDescription: { text: "mcp.hosting PAT leakage or env-var posture" },
53344
+ helpUri: "https://github.com/yawlabs/ctxlint#mcph-config-linting"
53345
+ },
53346
+ {
53347
+ id: "ctxlint/mcph-apibase",
53348
+ shortDescription: { text: "mcph apiBase URL validation" },
53349
+ helpUri: "https://github.com/yawlabs/ctxlint#mcph-config-linting"
53350
+ },
53351
+ {
53352
+ id: "ctxlint/mcph-schema-conformance",
53353
+ shortDescription: { text: "mcph config unknown field or stale schema version" },
53354
+ helpUri: "https://github.com/yawlabs/ctxlint#mcph-config-linting"
53355
+ },
53356
+ {
53357
+ id: "ctxlint/mcph-lists",
53358
+ shortDescription: { text: "mcph allow/deny list conflict or duplicate entry" },
53359
+ helpUri: "https://github.com/yawlabs/ctxlint#mcph-config-linting"
53360
+ },
53361
+ {
53362
+ id: "ctxlint/mcph-gitignore",
53363
+ shortDescription: { text: "mcph machine-local file not covered by .gitignore" },
53364
+ helpUri: "https://github.com/yawlabs/ctxlint#mcph-config-linting"
53365
+ },
53285
53366
  {
53286
53367
  id: "ctxlint/session-missing-secret",
53287
53368
  shortDescription: { text: "GitHub secret set on sibling repos but missing here" },
@@ -53335,10 +53416,24 @@ function formatIssue(issue2) {
53335
53416
  }
53336
53417
  return line;
53337
53418
  }
53419
+ var GROUP_ORDER, GROUP_LABELS, GROUP_SUMMARY_NOUNS;
53338
53420
  var init_reporter = __esm({
53339
53421
  "src/core/reporter.ts"() {
53340
53422
  "use strict";
53341
53423
  init_source();
53424
+ GROUP_ORDER = ["context", "mcp", "mcph", "session"];
53425
+ GROUP_LABELS = {
53426
+ context: "Context Files",
53427
+ mcp: "MCP Configs",
53428
+ mcph: "mcph Configs",
53429
+ session: "Session Audit"
53430
+ };
53431
+ GROUP_SUMMARY_NOUNS = {
53432
+ context: "context file",
53433
+ mcp: "MCP config",
53434
+ mcph: "mcph config",
53435
+ session: "session audit"
53436
+ };
53342
53437
  }
53343
53438
  });
53344
53439
 
@@ -53511,74 +53606,7 @@ async function runCli() {
53511
53606
  false
53512
53607
  ).option("--session", "Run session audit checks (cross-project consistency)", false).option("--session-only", "Run only session checks, skip context and MCP checks", false).option("--watch", "Re-lint on context file changes", false).action(async (projectPath, opts) => {
53513
53608
  const resolvedPath = path12.resolve(projectPath);
53514
- const configPath = opts.config ? path12.resolve(opts.config) : void 0;
53515
- const config2 = configPath ? loadConfigFromPath(configPath) : loadConfig(resolvedPath);
53516
- const mcpGlobal = opts.mcpGlobal || config2?.mcpGlobal || false;
53517
- const mcpOnly = opts.mcpOnly || config2?.mcpOnly || false;
53518
- const mcpFlag = opts.mcp || mcpGlobal || mcpOnly || config2?.mcp || false;
53519
- const mcphGlobal = opts.mcphGlobal || config2?.mcphGlobal || false;
53520
- const mcphOnly = opts.mcphOnly || config2?.mcphOnly || false;
53521
- const mcphFlag = opts.mcph || mcphGlobal || mcphOnly || config2?.mcph || false;
53522
- const mcphStrictEnvToken = opts.mcphStrictEnvToken || config2?.mcphStrictEnvToken || false;
53523
- const sessionOnly = opts.sessionOnly || config2?.sessionOnly || false;
53524
- const sessionFlag = opts.session || sessionOnly || config2?.session || false;
53525
- let explicitChecks = opts.checks ? validateCheckNames(
53526
- opts.checks.split(",").map((c3) => c3.trim()),
53527
- "--checks"
53528
- ) : null;
53529
- if (explicitChecks?.length === 0) explicitChecks = null;
53530
- const hasMcpInChecks = explicitChecks?.some((c3) => c3.startsWith("mcp-")) || false;
53531
- const hasMcphInChecks = explicitChecks?.some((c3) => c3.startsWith("mcph-")) || false;
53532
- const hasSessionInChecks = explicitChecks?.some((c3) => c3.startsWith("session-")) || false;
53533
- const effectiveMcp = mcpFlag || hasMcpInChecks;
53534
- const effectiveMcph = mcphFlag || hasMcphInChecks;
53535
- const effectiveSession = sessionFlag || sessionOnly || hasSessionInChecks;
53536
- let checks;
53537
- if (explicitChecks) {
53538
- checks = explicitChecks;
53539
- } else if (sessionOnly) {
53540
- checks = ALL_SESSION_CHECKS;
53541
- } else if (mcpOnly) {
53542
- checks = ALL_MCP_CHECKS;
53543
- } else if (mcphOnly) {
53544
- checks = ALL_MCPH_CHECKS;
53545
- } else {
53546
- const base = config2?.checks || ALL_CHECKS;
53547
- checks = [
53548
- ...base,
53549
- ...effectiveMcp ? ALL_MCP_CHECKS : [],
53550
- ...effectiveMcph ? ALL_MCPH_CHECKS : [],
53551
- ...effectiveSession ? ALL_SESSION_CHECKS : []
53552
- ];
53553
- }
53554
- const options = {
53555
- projectPath: resolvedPath,
53556
- checks,
53557
- strict: opts.strict || config2?.strict || false,
53558
- format: opts.format,
53559
- verbose: opts.verbose,
53560
- fix: opts.fix,
53561
- ignore: opts.ignore ? validateCheckNames(
53562
- opts.ignore.split(",").map((c3) => c3.trim()),
53563
- "--ignore"
53564
- ) : config2?.ignore || [],
53565
- tokensOnly: opts.tokens,
53566
- quiet: opts.quiet,
53567
- depth: Math.max(0, Math.min(parseInt(opts.depth, 10) || 2, 10)),
53568
- mcp: effectiveMcp,
53569
- mcpOnly,
53570
- mcpGlobal,
53571
- mcph: effectiveMcph,
53572
- mcphOnly,
53573
- mcphGlobal,
53574
- mcphStrictEnvToken,
53575
- session: effectiveSession,
53576
- sessionOnly
53577
- };
53578
- if (config2?.tokenThresholds) {
53579
- setTokenThresholds(config2.tokenThresholds);
53580
- }
53581
- const activeChecks = options.checks.filter((c3) => !options.ignore.includes(c3));
53609
+ const { config: config2, options, activeChecks } = resolveSession(resolvedPath, opts);
53582
53610
  const spinner = options.format === "text" && !options.quiet ? ora("Scanning for context files...").start() : void 0;
53583
53611
  try {
53584
53612
  if (spinner) spinner.text = "Running checks...";
@@ -53706,7 +53734,16 @@ Fixed ${applied.totalFixes} issue${applied.totalFixes !== 1 ? "s" : ""} in ${app
53706
53734
  path12.join(resolvedPath, "GEMINI.md"),
53707
53735
  path12.join(resolvedPath, "replit.md"),
53708
53736
  path12.join(resolvedPath, "package.json"),
53709
- path12.join(resolvedPath, ".mcp.json")
53737
+ path12.join(resolvedPath, ".mcp.json"),
53738
+ // mcph configs — without these, edits to the .mcph.json the user is
53739
+ // actively iterating on don't trigger a re-lint.
53740
+ path12.join(resolvedPath, ".mcph.json"),
53741
+ path12.join(resolvedPath, ".mcph.local.json"),
53742
+ // ctxlint config — re-resolve on edit so threshold changes,
53743
+ // ignore-list edits, and check-list overrides take effect without
53744
+ // restarting the watcher.
53745
+ path12.join(resolvedPath, ".ctxlintrc"),
53746
+ path12.join(resolvedPath, ".ctxlintrc.json")
53710
53747
  ];
53711
53748
  const watchDirs = [
53712
53749
  path12.join(resolvedPath, ".claude"),
@@ -53727,30 +53764,46 @@ Fixed ${applied.totalFixes} issue${applied.totalFixes !== 1 ? "s" : ""} in ${app
53727
53764
  if (debounceTimer) clearTimeout(debounceTimer);
53728
53765
  debounceTimer = setTimeout(async () => {
53729
53766
  if (process.stdout.isTTY) console.clear();
53767
+ let liveConfig = config2;
53768
+ let liveOptions = options;
53769
+ let liveActiveChecks = activeChecks;
53770
+ try {
53771
+ const resolved = resolveSession(resolvedPath, opts);
53772
+ liveConfig = resolved.config;
53773
+ liveOptions = resolved.options;
53774
+ liveActiveChecks = resolved.activeChecks;
53775
+ } catch (err) {
53776
+ console.error("Error reloading config:", err instanceof Error ? err.message : err);
53777
+ }
53730
53778
  try {
53731
- const result = await runAudit(resolvedPath, activeChecks, {
53732
- depth: options.depth,
53733
- extraPatterns: config2?.contextFiles,
53734
- mcp: options.mcp,
53735
- mcpGlobal: options.mcpGlobal,
53736
- mcpOnly: options.mcpOnly,
53737
- mcph: options.mcph,
53738
- mcphGlobal: options.mcphGlobal,
53739
- mcphOnly: options.mcphOnly,
53740
- mcphStrictEnvToken: options.mcphStrictEnvToken,
53741
- session: options.session,
53742
- sessionOnly: options.sessionOnly
53779
+ if (liveConfig?.tokenThresholds) {
53780
+ setTokenThresholds(liveConfig.tokenThresholds);
53781
+ } else {
53782
+ resetTokenThresholds();
53783
+ }
53784
+ const result = await runAudit(resolvedPath, liveActiveChecks, {
53785
+ depth: liveOptions.depth,
53786
+ extraPatterns: liveConfig?.contextFiles,
53787
+ mcp: liveOptions.mcp,
53788
+ mcpGlobal: liveOptions.mcpGlobal,
53789
+ mcpOnly: liveOptions.mcpOnly,
53790
+ mcph: liveOptions.mcph,
53791
+ mcphGlobal: liveOptions.mcphGlobal,
53792
+ mcphOnly: liveOptions.mcphOnly,
53793
+ mcphStrictEnvToken: liveOptions.mcphStrictEnvToken,
53794
+ session: liveOptions.session,
53795
+ sessionOnly: liveOptions.sessionOnly
53743
53796
  });
53744
53797
  if (result.files.length === 0) {
53745
53798
  console.log("\nNo context files found.\n");
53746
- } else if (options.tokensOnly) {
53799
+ } else if (liveOptions.tokensOnly) {
53747
53800
  console.log(formatTokenReport(result));
53748
- } else if (options.format === "json") {
53801
+ } else if (liveOptions.format === "json") {
53749
53802
  console.log(formatJson(result));
53750
- } else if (options.format === "sarif") {
53803
+ } else if (liveOptions.format === "sarif") {
53751
53804
  console.log(formatSarif(result));
53752
53805
  } else {
53753
- console.log(formatText(result, options.verbose));
53806
+ console.log(formatText(result, liveOptions.verbose));
53754
53807
  }
53755
53808
  } catch (err) {
53756
53809
  console.error("Error:", err instanceof Error ? err.message : err);
@@ -53760,7 +53813,6 @@ Fixed ${applied.totalFixes} issue${applied.totalFixes !== 1 ? "s" : ""} in ${app
53760
53813
  resetPathsCache();
53761
53814
  resetPackageJsonCache();
53762
53815
  resetTokenThresholds();
53763
- if (config2?.tokenThresholds) setTokenThresholds(config2.tokenThresholds);
53764
53816
  }
53765
53817
  console.log(chalk2.dim("\nWatching for changes... (Ctrl+C to stop)\n"));
53766
53818
  }, 300);
@@ -53839,6 +53891,77 @@ async function promptYesNo(question) {
53839
53891
  rl.close();
53840
53892
  }
53841
53893
  }
53894
+ function resolveSession(resolvedPath, opts) {
53895
+ const configPath = opts.config ? path12.resolve(opts.config) : void 0;
53896
+ const config2 = configPath ? loadConfigFromPath(configPath) : loadConfig(resolvedPath);
53897
+ const mcpGlobal = opts.mcpGlobal || config2?.mcpGlobal || false;
53898
+ const mcpOnly = opts.mcpOnly || config2?.mcpOnly || false;
53899
+ const mcpFlag = opts.mcp || mcpGlobal || mcpOnly || config2?.mcp || false;
53900
+ const mcphGlobal = opts.mcphGlobal || config2?.mcphGlobal || false;
53901
+ const mcphOnly = opts.mcphOnly || config2?.mcphOnly || false;
53902
+ const mcphFlag = opts.mcph || mcphGlobal || mcphOnly || config2?.mcph || false;
53903
+ const mcphStrictEnvToken = opts.mcphStrictEnvToken || config2?.mcphStrictEnvToken || false;
53904
+ const sessionOnly = opts.sessionOnly || config2?.sessionOnly || false;
53905
+ const sessionFlag = opts.session || sessionOnly || config2?.session || false;
53906
+ let explicitChecks = opts.checks ? validateCheckNames(
53907
+ opts.checks.split(",").map((c3) => c3.trim()),
53908
+ "--checks"
53909
+ ) : null;
53910
+ if (explicitChecks?.length === 0) explicitChecks = null;
53911
+ const hasMcpInChecks = explicitChecks?.some((c3) => c3.startsWith("mcp-") && !c3.startsWith("mcph-")) || false;
53912
+ const hasMcphInChecks = explicitChecks?.some((c3) => c3.startsWith("mcph-")) || false;
53913
+ const hasSessionInChecks = explicitChecks?.some((c3) => c3.startsWith("session-")) || false;
53914
+ const effectiveMcp = mcpFlag || hasMcpInChecks;
53915
+ const effectiveMcph = mcphFlag || hasMcphInChecks;
53916
+ const effectiveSession = sessionFlag || sessionOnly || hasSessionInChecks;
53917
+ let checks;
53918
+ if (explicitChecks) {
53919
+ checks = explicitChecks;
53920
+ } else if (sessionOnly) {
53921
+ checks = ALL_SESSION_CHECKS;
53922
+ } else if (mcpOnly) {
53923
+ checks = ALL_MCP_CHECKS;
53924
+ } else if (mcphOnly) {
53925
+ checks = ALL_MCPH_CHECKS;
53926
+ } else {
53927
+ const base = config2?.checks || ALL_CHECKS;
53928
+ checks = [
53929
+ ...base,
53930
+ ...effectiveMcp ? ALL_MCP_CHECKS : [],
53931
+ ...effectiveMcph ? ALL_MCPH_CHECKS : [],
53932
+ ...effectiveSession ? ALL_SESSION_CHECKS : []
53933
+ ];
53934
+ }
53935
+ const options = {
53936
+ projectPath: resolvedPath,
53937
+ checks,
53938
+ strict: opts.strict || config2?.strict || false,
53939
+ format: opts.format,
53940
+ verbose: opts.verbose,
53941
+ fix: opts.fix,
53942
+ ignore: opts.ignore ? validateCheckNames(
53943
+ opts.ignore.split(",").map((c3) => c3.trim()),
53944
+ "--ignore"
53945
+ ) : config2?.ignore || [],
53946
+ tokensOnly: opts.tokens,
53947
+ quiet: opts.quiet,
53948
+ depth: Math.max(0, Math.min(parseInt(opts.depth, 10) || 2, 10)),
53949
+ mcp: effectiveMcp,
53950
+ mcpOnly,
53951
+ mcpGlobal,
53952
+ mcph: effectiveMcph,
53953
+ mcphOnly,
53954
+ mcphGlobal,
53955
+ mcphStrictEnvToken,
53956
+ session: effectiveSession,
53957
+ sessionOnly
53958
+ };
53959
+ if (config2?.tokenThresholds) {
53960
+ setTokenThresholds(config2.tokenThresholds);
53961
+ }
53962
+ const activeChecks = options.checks.filter((c3) => !options.ignore.includes(c3));
53963
+ return { config: config2, options, activeChecks };
53964
+ }
53842
53965
  function loadConfigFromPath(configPath) {
53843
53966
  try {
53844
53967
  return loadConfigFromExplicitPath(configPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ctxlint",
3
- "version": "0.9.18",
3
+ "version": "0.9.19",
4
4
  "description": "Lint your AI agent context files, MCP server configs, and session data against your actual codebase",
5
5
  "bin": {
6
6
  "ctxlint": "dist/index.js"