@yawlabs/ctxlint 0.22.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.22.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,6 +24050,80 @@ var init_cli_subcommands = __esm({
24050
24050
  }
24051
24051
  });
24052
24052
 
24053
+ // src/utils/markdown.ts
24054
+ function inlineCodeSpans(line) {
24055
+ const spans = [];
24056
+ const re2 = /`([^`]+)`/g;
24057
+ let m;
24058
+ while ((m = re2.exec(line)) !== null) {
24059
+ spans.push({ start: m.index, end: m.index + m[0].length, content: m[1] });
24060
+ }
24061
+ return spans;
24062
+ }
24063
+ function maskCodeSpans(line, spans) {
24064
+ let out = line;
24065
+ for (const s of spans) {
24066
+ out = out.slice(0, s.start) + "#".repeat(s.end - s.start) + out.slice(s.end);
24067
+ }
24068
+ return out;
24069
+ }
24070
+ function nonProseLineMask(lines) {
24071
+ return classifyLines(lines).map((c3) => c3.fence || c3.comment);
24072
+ }
24073
+ function htmlCommentLineMask(lines) {
24074
+ return classifyLines(lines).map((c3) => c3.comment);
24075
+ }
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;
24086
+ continue;
24087
+ }
24088
+ if (inFence) {
24089
+ out[i2].fence = true;
24090
+ continue;
24091
+ }
24092
+ const probe = maskCodeSpans(trimmed2, inlineCodeSpans(trimmed2));
24093
+ if (inHtmlComment) {
24094
+ out[i2].comment = true;
24095
+ if (probe.includes("-->")) inHtmlComment = false;
24096
+ continue;
24097
+ }
24098
+ if (HTML_COMMENT.test(probe.replace(HTML_COMMENT_PAIR, ""))) {
24099
+ out[i2].comment = true;
24100
+ inHtmlComment = true;
24101
+ }
24102
+ }
24103
+ return out;
24104
+ }
24105
+ function stripInlineHtmlComments(line) {
24106
+ return line.replace(HTML_COMMENT_PAIR, (m) => "#".repeat(m.length));
24107
+ }
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 });
24114
+ }
24115
+ return spans;
24116
+ }
24117
+ var HTML_COMMENT_PAIR, HTML_COMMENT;
24118
+ var init_markdown = __esm({
24119
+ "src/utils/markdown.ts"() {
24120
+ "use strict";
24121
+ init_define_WEB_FIRST_SEGMENTS();
24122
+ HTML_COMMENT_PAIR = /<!--[\s\S]*?-->/g;
24123
+ HTML_COMMENT = /<!--/;
24124
+ }
24125
+ });
24126
+
24053
24127
  // src/core/checks/commands.ts
24054
24128
  import * as fs6 from "node:fs";
24055
24129
  import * as path7 from "node:path";
@@ -24112,13 +24186,48 @@ function loadDeniedCommandPrefixes(projectRoot) {
24112
24186
  function isDeniedCommand(cmd, deniedPrefixes) {
24113
24187
  return deniedPrefixes.some((p2) => cmd === p2 || cmd.startsWith(`${p2} `));
24114
24188
  }
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);
24197
+ const masked = maskCodeSpans(line, inlineCodeSpans(line));
24198
+ const prefix = masked.slice(0, Math.max(0, offset));
24199
+ const clause = narrowAtContrastComma(prefix.split(CLAUSE_TERMINATOR).pop() ?? "");
24200
+ return PROHIBITION_TOKEN.test(clause);
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
+ }
24115
24213
  async function checkCommands(file2, projectRoot) {
24116
24214
  const issues = [];
24117
24215
  const pkgJson = loadPackageJson(projectRoot);
24118
24216
  const makefile = loadMakefile(projectRoot);
24119
24217
  const deniedPrefixes = loadDeniedCommandPrefixes(projectRoot);
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
+ };
24120
24227
  if (!pkgJson) {
24121
- 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
+ );
24122
24231
  if (skipped) {
24123
24232
  issues.push({
24124
24233
  severity: "info",
@@ -24130,8 +24239,11 @@ async function checkCommands(file2, projectRoot) {
24130
24239
  });
24131
24240
  }
24132
24241
  }
24133
- issues.push(...checkUnknownSubcommand(file2, projectRoot, pkgJson));
24242
+ issues.push(
24243
+ ...checkUnknownSubcommand(file2, projectRoot, pkgJson, deniedPrefixes, contentLines)
24244
+ );
24134
24245
  for (const ref of file2.references.commands) {
24246
+ if (inHtmlComment(ref.line, ref.column)) continue;
24135
24247
  const cmd = ref.value;
24136
24248
  const masked = analyzeMaskedExitStatus(cmd, pkgJson?.scripts);
24137
24249
  if (masked && !pipefailInScope(file2.content, ref.line)) {
@@ -24145,6 +24257,7 @@ async function checkCommands(file2, projectRoot) {
24145
24257
  suggestion: "Add `set -o pipefail` before the pipeline, drop the filter, or read `${PIPESTATUS[0]}` instead of `$?`."
24146
24258
  });
24147
24259
  }
24260
+ if (isDeniedCommand(cmd, deniedPrefixes) || isProhibitedRef(contentLines, ref)) continue;
24148
24261
  const scriptMatch = cmd.match(NPM_SCRIPT_PATTERN);
24149
24262
  if (scriptMatch && pkgJson) {
24150
24263
  const scriptName = scriptNameFromMatch(scriptMatch);
@@ -24187,7 +24300,6 @@ async function checkCommands(file2, projectRoot) {
24187
24300
  ...pkgJson.optionalDependencies
24188
24301
  };
24189
24302
  if (!(pkgName in allDeps)) {
24190
- if (isDeniedCommand(cmd, deniedPrefixes)) continue;
24191
24303
  const binPath = path7.join(projectRoot, "node_modules", ".bin", pkgName);
24192
24304
  try {
24193
24305
  fs6.accessSync(binPath);
@@ -24255,7 +24367,7 @@ async function checkCommands(file2, projectRoot) {
24255
24367
  }
24256
24368
  return issues;
24257
24369
  }
24258
- function checkUnknownSubcommand(file2, projectRoot, pkgJson) {
24370
+ function checkUnknownSubcommand(file2, projectRoot, pkgJson, deniedPrefixes, contentLines) {
24259
24371
  const bins = ownedBins(pkgJson);
24260
24372
  if (bins.length === 0) return [];
24261
24373
  const invocations = findBinInvocations(file2.content, new Set(bins.map((b2) => b2.name)));
@@ -24263,6 +24375,8 @@ function checkUnknownSubcommand(file2, projectRoot, pkgJson) {
24263
24375
  const resolved = /* @__PURE__ */ new Map();
24264
24376
  const issues = [];
24265
24377
  for (const inv of invocations) {
24378
+ if (isDeniedCommand(inv.cmd, deniedPrefixes)) continue;
24379
+ if (isProhibitedInvocation(contentLines, inv)) continue;
24266
24380
  if (!resolved.has(inv.bin)) {
24267
24381
  const bin = bins.find((b2) => b2.name === inv.bin);
24268
24382
  resolved.set(inv.bin, bin ? knownSubcommands(projectRoot, bin.entry) : null);
@@ -24302,7 +24416,7 @@ function hasMakeTarget(makefile, target) {
24302
24416
  const pattern = new RegExp(`^${escaped}\\s*:(?!:?=)`, "m");
24303
24417
  return pattern.test(makefile);
24304
24418
  }
24305
- var NPM_SCRIPT_PATTERN, MAKE_PATTERN, PM_BUILTIN_SUBCOMMANDS, PM_MANAGER_BUILTINS, PKG_DEPENDENT_TOOL_PATTERN, BIN_TO_PACKAGE, PKG_SHORTHAND_PATTERN;
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;
24306
24420
  var init_commands = __esm({
24307
24421
  "src/core/checks/commands.ts"() {
24308
24422
  "use strict";
@@ -24311,6 +24425,7 @@ var init_commands = __esm({
24311
24425
  init_fs();
24312
24426
  init_exit_status();
24313
24427
  init_cli_subcommands();
24428
+ init_markdown();
24314
24429
  NPM_SCRIPT_PATTERN = /^(?:npm\s+run|(pnpm|yarn|bun)(?:\s+(run))?)\s+(\S+)/;
24315
24430
  MAKE_PATTERN = /^make\s+\S/;
24316
24431
  PM_BUILTIN_SUBCOMMANDS = /* @__PURE__ */ new Set([
@@ -24380,6 +24495,9 @@ var init_commands = __esm({
24380
24495
  tsc: "typescript"
24381
24496
  };
24382
24497
  PKG_SHORTHAND_PATTERN = /^(npm|pnpm|yarn|bun)\s+(test|start|build|dev|lint|format|check|typecheck|clean|serve|preview|e2e)\b/;
24498
+ PROHIBITION_TOKEN = /\b(?:never|don['’]?t|do\s+not|must\s+not|avoid)\b/i;
24499
+ CLAUSE_TERMINATOR = /[.!?;—]|\s--(?=\s|$)/;
24500
+ RECOMMENDATION_VERB = /\b(?:run|use|call|invoke|execute|prefer|deploy|install)\b/i;
24383
24501
  }
24384
24502
  });
24385
24503
 
@@ -24650,6 +24768,24 @@ function computeSectionCosts(file2) {
24650
24768
  return { title: s.title, line: s.startLine, tokens: countTokens(body) };
24651
24769
  }).sort((a, b2) => b2.tokens - a.tokens);
24652
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
+ }
24653
24789
  function fingerprintFiles(paths) {
24654
24790
  return paths.map((p2) => {
24655
24791
  try {
@@ -24725,14 +24861,16 @@ function commandIsEnforced(cmd, settings) {
24725
24861
  function checkHardEnforcement(file2, settings) {
24726
24862
  const issues = [];
24727
24863
  const lines = file2.content.split("\n");
24864
+ const nonProse = nonProseLineMask(lines);
24728
24865
  for (let i2 = 0; i2 < lines.length; i2++) {
24729
- const line = lines[i2];
24730
- const match = line.match(INVIOLABLE_WITH_COMMAND);
24866
+ if (nonProse[i2]) continue;
24867
+ const line = stripInlineHtmlComments(lines[i2]);
24868
+ const match = findInviolableCommand(line);
24731
24869
  if (!match) continue;
24732
- const cmd = canonicalizeCommand(match[2]);
24870
+ const cmd = canonicalizeCommand(match.command);
24733
24871
  if (!cmd) continue;
24734
24872
  if (commandIsEnforced(cmd, settings)) continue;
24735
- const suggestion = match[1].toUpperCase() === "ALWAYS" ? `Rules in always-loaded files are advisory. For \`${cmd}\`, add a hook in .claude/settings.json (e.g. a PreToolUse or Stop hook that runs or verifies \`${cmd}\`) so the requirement doesn't depend on the agent remembering.` : `Rules in always-loaded files are advisory. For \`${cmd}\`, add a PreToolUse hook (or permissions.deny entry) in .claude/settings.json so the command is physically blocked.`;
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.`;
24736
24874
  issues.push({
24737
24875
  severity: "info",
24738
24876
  check: "tier-tokens",
@@ -24787,7 +24925,7 @@ function checkAggregateTierTokens(files, thresholds = DEFAULT_TOKEN_THRESHOLDS)
24787
24925
  suggestion: "Consider moving the largest files or their heaviest sections to on-demand tiers (skills, subagents, memory)."
24788
24926
  };
24789
24927
  }
24790
- var ALWAYS_LOADED_NAMES, TOP_SECTIONS_TO_REPORT, INVIOLABLE_WITH_COMMAND, settingsCache;
24928
+ var ALWAYS_LOADED_NAMES, TOP_SECTIONS_TO_REPORT, FRAMING_TOKEN, FRAMING_COMMAND_GAP, settingsCache;
24791
24929
  var init_tier_tokens = __esm({
24792
24930
  "src/core/checks/tier-tokens.ts"() {
24793
24931
  "use strict";
@@ -24795,6 +24933,7 @@ var init_tier_tokens = __esm({
24795
24933
  init_main3();
24796
24934
  init_tokens();
24797
24935
  init_fs();
24936
+ init_markdown();
24798
24937
  init_tokens2();
24799
24938
  ALWAYS_LOADED_NAMES = [
24800
24939
  "CLAUDE.md",
@@ -24817,7 +24956,8 @@ var init_tier_tokens = __esm({
24817
24956
  ".goose/instructions.md"
24818
24957
  ];
24819
24958
  TOP_SECTIONS_TO_REPORT = 3;
24820
- INVIOLABLE_WITH_COMMAND = /\b(NEVER|ALWAYS|DON'?T|DO NOT|MUST NOT)\b[^.!?`]{0,80}`([^`]+)`/i;
24959
+ FRAMING_TOKEN = /(?<![\w'.-])(never|always|don'?t|do\s+not|must\s+not)(?![\w'.-])/gi;
24960
+ FRAMING_COMMAND_GAP = 80;
24821
24961
  settingsCache = null;
24822
24962
  }
24823
24963
  });
@@ -28968,7 +29108,7 @@ import { readFileSync as readFileSync8 } from "node:fs";
28968
29108
  import { resolve as resolve15, dirname as dirname7 } from "node:path";
28969
29109
  import { fileURLToPath as fileURLToPath2 } from "node:url";
28970
29110
  function loadVersion() {
28971
- if (true) return "0.21.0";
29111
+ if (true) return "0.24.0";
28972
29112
  try {
28973
29113
  const __dir = dirname7(fileURLToPath2(import.meta.url));
28974
29114
  const pkgPath = resolve15(__dir, "../package.json");
@@ -70060,6 +70200,13 @@ __export(cli_exports, {
70060
70200
  });
70061
70201
  import * as fs15 from "node:fs";
70062
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
+ }
70063
70210
  function validateCheckNames(names, source) {
70064
70211
  const invalid = names.filter((n7) => n7 && !VALID_CHECKS.has(n7));
70065
70212
  if (invalid.length > 0) {
@@ -70083,6 +70230,12 @@ async function runCli() {
70083
70230
  false
70084
70231
  ).option("--no-ignore-file", "Disable .ctxlintignore suppression (see all findings)").option("--watch", "Re-lint on context file changes", false).action(async (projectPath, opts) => {
70085
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
+ }
70086
70239
  const { config: config2, options, activeChecks } = resolveSession(resolvedPath, opts);
70087
70240
  const spinner = options.format === "text" && !options.quiet ? ora("Scanning for context files...").start() : void 0;
70088
70241
  try {
@@ -70401,7 +70554,7 @@ function resolveDepth(raw) {
70401
70554
  }
70402
70555
  function resolveSession(resolvedPath, opts, throwOnConfigError = false) {
70403
70556
  const configPath = opts.config ? path20.resolve(opts.config) : void 0;
70404
- const config2 = configPath ? loadConfigFromPath(configPath, throwOnConfigError) : loadConfig(resolvedPath);
70557
+ const config2 = configPath ? loadConfigFromPath(configPath, throwOnConfigError) : loadDiscoveredConfig(resolvedPath, throwOnConfigError);
70405
70558
  const mcpGlobal = opts.mcpGlobal || config2?.mcpGlobal || false;
70406
70559
  const mcpOnly = opts.mcpOnly || config2?.mcpOnly || false;
70407
70560
  const mcpFlag = opts.mcp || mcpGlobal || mcpOnly || config2?.mcp || false;
@@ -70469,6 +70622,16 @@ function resolveSession(resolvedPath, opts, throwOnConfigError = false) {
70469
70622
  const activeChecks = options.checks.filter((c3) => !options.ignore.includes(c3));
70470
70623
  return { config: config2, options, activeChecks };
70471
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
+ }
70472
70635
  function loadConfigFromPath(configPath, throwOnError = false) {
70473
70636
  try {
70474
70637
  return loadConfigFromExplicitPath(configPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ctxlint",
3
- "version": "0.22.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": {
@@ -13,6 +13,7 @@
13
13
  "scripts": {
14
14
  "build": "node build.mjs",
15
15
  "dev": "node build.mjs",
16
+ "prepublishOnly": "node build.mjs",
16
17
  "generate": "node scripts/generate-catalog-prose.mjs",
17
18
  "generate:check": "node scripts/generate-catalog-prose.mjs --check",
18
19
  "pretest": "node build.mjs",