contextpruner 0.1.0 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +18 -4
  2. package/dist/index.js +701 -184
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,13 +1,16 @@
1
1
  # contextpruner
2
2
 
3
3
  Lint your AI-context config files against your repo. `contextpruner lint` checks
4
- your `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` / Copilot / Cursor rules against the
5
- files actually in your tree and reports:
4
+ your `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` / Copilot / Cursor rules plus the
5
+ enforced ignore artifacts (`.cursorignore` / `.geminiignore` / `.codeiumignore`
6
+ and the Claude Code deny rules in `.claude/settings.json`) — against the files
7
+ actually in your tree and reports:
6
8
 
7
9
  - **Missing** — junk the config doesn't ignore yet (with what it's costing you),
8
10
  - **Dead** — rules that match nothing,
9
11
  - **Drift** — rules in one config but not another,
10
- - **Conflict** — rules that contradict each other.
12
+ - **Conflict** — rules that contradict each other, including an enforced rule
13
+ that hard-blocks a file you pinned Keep or Skim.
11
14
 
12
15
  It exits non-zero when it finds issues, so it drops straight into CI or a
13
16
  pre-commit hook.
@@ -19,14 +22,25 @@ export CONTEXTPRUNER_API_KEY=cp_live_... # from https://contextpruner.app/acco
19
22
  npx contextpruner lint
20
23
  ```
21
24
 
25
+ One subscription, one key: the same `cp_live_` key authorizes both this CLI and
26
+ the ContextPruner config automation (the GitHub Action / pre-commit hook), so
27
+ reuse the one you already have.
28
+
22
29
  ```
23
30
  contextpruner lint [--config <path>]... [--fix] [--json]
24
31
  ```
25
32
 
26
33
  - `--config <path>` — a config to lint (repeatable). Defaults to auto-detecting
27
- the standard files at the repo root.
34
+ the standard files at the repo root: the advisory markdown configs plus the
35
+ four enforced artifacts. An explicit path's format is inferred from its
36
+ basename (`settings.json` → Claude settings, the `*ignore` files → gitignore
37
+ syntax, anything else → markdown).
28
38
  - `--fix` — rewrite each config's managed block in place to fix the issues. Your
29
39
  own content outside the block (and the "Exceptions" section) is left alone.
40
+ Enforced ignore files regenerate only the `# contextpruner:begin/end` block;
41
+ `.claude/settings.json` is merged via its sentinel-delimited deny span and is
42
+ skipped — never clobbered — when it has no recognized span or can't be merged
43
+ safely.
30
44
  - `--json` — emit the full report as JSON.
31
45
 
32
46
  **Exit codes:** `0` clean · `1` issues found · `2` usage/auth error.
package/dist/index.js CHANGED
@@ -53,89 +53,6 @@ function parseArgs(argv) {
53
53
  return { command: "lint", configs, json, fix };
54
54
  }
55
55
 
56
- // ../../lib/shared/format.ts
57
- function formatPctReduction(ratio) {
58
- return ratio >= 1 ? 100 : Math.min(99, Math.floor(Number((ratio * 100).toPrecision(12))));
59
- }
60
-
61
- // ../../lib/engine/generators/shared.ts
62
- function formatStatsLine(stats) {
63
- const pct = stats.bytesTotal === 0 ? 0 : formatPctReduction(stats.bytesPruned / stats.bytesTotal);
64
- return `${stats.filesPruned} of ${stats.filesTotal} files pruned (${pct}% smaller context)`;
65
- }
66
- var MANAGED_BLOCK_BEGIN = "<!-- contextpruner:begin -->";
67
- var MANAGED_BLOCK_END = "<!-- contextpruner:end -->";
68
- var MANAGED_BLOCK_NOTE = "<!-- generated block \u2014 edits inside are overwritten on regeneration -->";
69
- function generateMarkdownRules(filename, input) {
70
- const lines = [
71
- MANAGED_BLOCK_BEGIN,
72
- MANAGED_BLOCK_NOTE,
73
- "",
74
- `# ${filename} \u2014 AI context rules (generated by ContextPruner)`,
75
- "",
76
- `Generated ${input.generatedAt}. ${formatStatsLine(input.stats)}.`,
77
- "",
78
- "## Ignore",
79
- "",
80
- "Do not read, index, or load these paths into context:",
81
- ""
82
- ];
83
- for (const glob of input.excludeGlobs) {
84
- lines.push(`- \`${glob}\``);
85
- }
86
- if (input.summarizeGlobs.length > 0) {
87
- lines.push(
88
- "",
89
- "## Skim",
90
- "",
91
- "Summarize these instead of reading them in full:",
92
- ""
93
- );
94
- for (const glob of input.summarizeGlobs) {
95
- lines.push(`- \`${glob}\``);
96
- }
97
- }
98
- if (input.priorityGlobs.length > 0) {
99
- lines.push("", "## Focus", "", "Prioritize these paths when exploring:", "");
100
- for (const glob of input.priorityGlobs) {
101
- lines.push(`- \`${glob}\``);
102
- }
103
- }
104
- if (input.keepOverridePaths.length > 0 || input.skimOverridePaths.length > 0 || input.pruneOverridePaths.length > 0) {
105
- lines.push(
106
- "",
107
- "## Overrides",
108
- "",
109
- "The repo owner pinned these verdicts \u2014 they take precedence over every rule above:",
110
- ""
111
- );
112
- for (const path of input.keepOverridePaths) {
113
- lines.push(`- Keep (read normally): \`${path}\``);
114
- }
115
- for (const path of input.skimOverridePaths) {
116
- lines.push(`- Skim (summarize only): \`${path}\``);
117
- }
118
- for (const path of input.pruneOverridePaths) {
119
- lines.push(`- Prune (do not read): \`${path}\``);
120
- }
121
- }
122
- lines.push(
123
- "",
124
- MANAGED_BLOCK_END,
125
- "",
126
- "## Exceptions (yours \u2014 edit freely)",
127
- "",
128
- "ContextPruner only sees paths and byte sizes \u2014 you know which",
129
- "junk-shaped files actually matter in this repo. Rules you add here",
130
- "are yours: when regenerating, replace only the marked block above",
131
- "and keep this section.",
132
- "",
133
- "- _none yet \u2014 add repo-specific overrides here_",
134
- ""
135
- );
136
- return lines.join("\n");
137
- }
138
-
139
56
  // ../../lib/shared/constants.ts
140
57
  var BYTES_PER_TOKEN_RATIO = 0.25;
141
58
  var USD_PER_1M_INPUT_TOKENS = 3;
@@ -277,9 +194,18 @@ var SUMMARIZE_EXTENSIONS = /* @__PURE__ */ new Set([
277
194
  "txt",
278
195
  "adoc"
279
196
  ]);
197
+ var ENV_EXEMPT_SUFFIXES = [".example", ".sample", ".template"];
198
+ var ENFORCED_REASONS = /* @__PURE__ */ new Set([
199
+ "env-file",
200
+ "dependency-dir",
201
+ "vendored-deps",
202
+ "build-output",
203
+ "test-coverage",
204
+ "cache",
205
+ "binary-asset"
206
+ ]);
280
207
 
281
208
  // ../../lib/engine/classify.ts
282
- var ENV_EXEMPT_SUFFIXES = [".example", ".sample", ".template"];
283
209
  function classify(file) {
284
210
  const { path, sizeInBytes } = file;
285
211
  const segments = path.split("/");
@@ -322,6 +248,447 @@ function classify(file) {
322
248
  return entry("KEEP", "source", null);
323
249
  }
324
250
 
251
+ // ../../lib/engine/globMatch.ts
252
+ var ESCAPE = /[.+^${}()|[\]\\]/g;
253
+ function globToRegExp(glob) {
254
+ let re = "";
255
+ for (let i = 0; i < glob.length; i++) {
256
+ const c = glob[i];
257
+ if (c === "*") {
258
+ if (glob[i + 1] === "*") {
259
+ const atSegmentStart = i === 0 || glob[i - 1] === "/";
260
+ if (glob[i + 2] === "/" && atSegmentStart) {
261
+ re += "(?:[^/]+/)*";
262
+ i += 2;
263
+ } else {
264
+ re += ".*";
265
+ i += 1;
266
+ }
267
+ } else {
268
+ re += "[^/]*";
269
+ }
270
+ } else if (c === "?") {
271
+ re += "[^/]";
272
+ } else {
273
+ re += c.replace(ESCAPE, "\\$&");
274
+ }
275
+ }
276
+ return new RegExp(`^${re}$`);
277
+ }
278
+ function compileGlob(glob) {
279
+ const re = globToRegExp(glob);
280
+ return (path) => re.test(path);
281
+ }
282
+
283
+ // ../../lib/engine/enforce.ts
284
+ var UNENFORCED_BINARY_EXTENSIONS = /* @__PURE__ */ new Set(["svg", "pdf"]);
285
+ var ENV_ENFORCED_GLOB = "**/.env*";
286
+ function enforcedDirSegments() {
287
+ return [...PRUNE_DIR_SEGMENT_REASONS.entries()].filter(([, reason]) => ENFORCED_REASONS.has(reason)).map(([segment]) => segment).sort();
288
+ }
289
+ function demoteEnforcedGlobs(candidates, protectedPaths) {
290
+ if (protectedPaths.length === 0) {
291
+ return { globs: [...candidates], demoted: [] };
292
+ }
293
+ const globs = [];
294
+ const demoted = [];
295
+ for (const candidate of candidates) {
296
+ const matches = compileGlob(candidate);
297
+ if (protectedPaths.some(matches)) {
298
+ demoted.push(candidate);
299
+ } else {
300
+ globs.push(candidate);
301
+ }
302
+ }
303
+ return { globs, demoted };
304
+ }
305
+ function effectivePinnedPaths(classified, pins) {
306
+ const naturalVerdicts = new Map(
307
+ classified.map((file) => [file.path, file.verdict])
308
+ );
309
+ const effective = /* @__PURE__ */ new Set();
310
+ for (const [paths, pinnedVerdict] of [
311
+ [pins.keep, "KEEP"],
312
+ [pins.skim, "SUMMARIZE"]
313
+ ]) {
314
+ for (const path of paths) {
315
+ const natural = naturalVerdicts.get(path);
316
+ if (natural !== void 0 && natural !== pinnedVerdict) {
317
+ effective.add(path);
318
+ }
319
+ }
320
+ }
321
+ return [...effective];
322
+ }
323
+ function deriveEnforcedRules(classified) {
324
+ const extGlobs = /* @__PURE__ */ new Set();
325
+ const protectedPaths = [];
326
+ for (const file of classified) {
327
+ if (file.overridden && file.verdict !== "PRUNE") {
328
+ protectedPaths.push(file.path);
329
+ }
330
+ if (!file.overridden && file.verdict === "PRUNE" && file.reason === "binary-asset" && file.glob !== null) {
331
+ const ext = file.glob.slice("**/*.".length);
332
+ if (!UNENFORCED_BINARY_EXTENSIONS.has(ext)) extGlobs.add(file.glob);
333
+ }
334
+ }
335
+ const candidates = [
336
+ ENV_ENFORCED_GLOB,
337
+ ...enforcedDirSegments().map((segment) => `**/${segment}/**`),
338
+ ...[...extGlobs].sort()
339
+ ];
340
+ return demoteEnforcedGlobs(candidates, protectedPaths);
341
+ }
342
+ var DIR_GLOB = /^\*\*\/([^*/]+)\/\*\*$/;
343
+ var EXT_GLOB = /^\*\*\/\*\.([a-z0-9]+)$/;
344
+ function gitignoreLinesFrom(globs) {
345
+ const lines = [];
346
+ for (const glob of globs) {
347
+ if (glob === ENV_ENFORCED_GLOB) {
348
+ lines.push(".env*");
349
+ for (const suffix of ENV_EXEMPT_SUFFIXES) {
350
+ lines.push(`!.env*${suffix}`);
351
+ }
352
+ continue;
353
+ }
354
+ const dir = glob.match(DIR_GLOB);
355
+ if (dir) {
356
+ lines.push(`${dir[1]}/`);
357
+ continue;
358
+ }
359
+ const ext = glob.match(EXT_GLOB);
360
+ if (ext) {
361
+ lines.push(`*.${ext[1]}`);
362
+ continue;
363
+ }
364
+ lines.push(glob);
365
+ }
366
+ return lines;
367
+ }
368
+ function denyEntriesFrom(globs) {
369
+ return globs.map((glob) => `Read(/${glob})`);
370
+ }
371
+ function engineGlobFromGitignoreLine(line) {
372
+ const trimmed = line.trim();
373
+ if (trimmed === "" || trimmed.startsWith("#")) return null;
374
+ if (trimmed === ".env*") return ENV_ENFORCED_GLOB;
375
+ const dirIdiom = trimmed.match(/^([^!*/\s]+)\/$/);
376
+ if (dirIdiom) return `**/${dirIdiom[1]}/**`;
377
+ const extIdiom = trimmed.match(/^\*\.([a-z0-9]+)$/);
378
+ if (extIdiom) return `**/*.${extIdiom[1]}`;
379
+ if (trimmed === ENV_ENFORCED_GLOB || DIR_GLOB.test(trimmed) || EXT_GLOB.test(trimmed)) {
380
+ return trimmed;
381
+ }
382
+ return null;
383
+ }
384
+ function engineGlobFromDenyEntry(entry) {
385
+ const inner = entry.match(/^Read\((.+)\)$/)?.[1];
386
+ if (!inner) return null;
387
+ const glob = inner.startsWith("/") ? inner.slice(1) : inner;
388
+ if (glob === ENV_ENFORCED_GLOB || DIR_GLOB.test(glob) || EXT_GLOB.test(glob)) {
389
+ return glob;
390
+ }
391
+ return null;
392
+ }
393
+
394
+ // ../../lib/engine/generators/claudeSettings.ts
395
+ var CLAUDE_SENTINEL_BEGIN = "Read(contextpruner-managed-begin)";
396
+ var CLAUDE_SENTINEL_END = "Read(contextpruner-managed-end)";
397
+ function claudeManagedDenyBlock(input) {
398
+ return [
399
+ CLAUDE_SENTINEL_BEGIN,
400
+ ...denyEntriesFrom(input.enforcedGlobs),
401
+ CLAUDE_SENTINEL_END
402
+ ];
403
+ }
404
+ function generateClaudeSettings(input) {
405
+ const settings = {
406
+ permissions: {
407
+ deny: claudeManagedDenyBlock(input)
408
+ }
409
+ };
410
+ return `${JSON.stringify(settings, null, 2)}
411
+ `;
412
+ }
413
+ function isPlainObject(value) {
414
+ return typeof value === "object" && value !== null && !Array.isArray(value);
415
+ }
416
+ function mergeClaudeSettings(existing, input) {
417
+ const unchanged = (error) => ({
418
+ content: existing,
419
+ changed: false,
420
+ error
421
+ });
422
+ let root;
423
+ try {
424
+ root = JSON.parse(existing);
425
+ } catch {
426
+ return unchanged("existing settings file is not valid JSON");
427
+ }
428
+ if (!isPlainObject(root)) {
429
+ return unchanged("existing settings file is not a JSON object");
430
+ }
431
+ const permissions = root.permissions ?? {};
432
+ if (!isPlainObject(permissions)) {
433
+ return unchanged("existing `permissions` is not an object");
434
+ }
435
+ const deny = permissions.deny ?? [];
436
+ if (!Array.isArray(deny) || deny.some((e) => typeof e !== "string")) {
437
+ return unchanged("existing `permissions.deny` is not a string array");
438
+ }
439
+ const begin = deny.indexOf(CLAUDE_SENTINEL_BEGIN);
440
+ const end = deny.indexOf(CLAUDE_SENTINEL_END);
441
+ if (begin !== deny.lastIndexOf(CLAUDE_SENTINEL_BEGIN) || end !== deny.lastIndexOf(CLAUDE_SENTINEL_END)) {
442
+ return unchanged(
443
+ "managed sentinel appears more than once \u2014 fix .claude/settings.json by hand"
444
+ );
445
+ }
446
+ const block = claudeManagedDenyBlock(input);
447
+ let nextDeny;
448
+ if (begin === -1 && end === -1) {
449
+ nextDeny = [...deny, ...block];
450
+ } else if (begin !== -1 && end !== -1 && begin < end) {
451
+ nextDeny = [...deny.slice(0, begin), ...block, ...deny.slice(end + 1)];
452
+ } else {
453
+ return unchanged(
454
+ "managed sentinel pair is incomplete or out of order \u2014 fix .claude/settings.json by hand"
455
+ );
456
+ }
457
+ root.permissions = { ...permissions, deny: nextDeny };
458
+ const content = `${JSON.stringify(root, null, 2)}
459
+ `;
460
+ return { content, changed: content !== existing };
461
+ }
462
+
463
+ // ../../lib/engine/generators/ignoreFiles.ts
464
+ var IGNORE_BLOCK_BEGIN = "# contextpruner:begin";
465
+ var IGNORE_BLOCK_END = "# contextpruner:end";
466
+ var IGNORE_BLOCK_NOTE = "# generated block \u2014 edits inside are overwritten on regeneration";
467
+ function generateIgnoreFile(filename, input) {
468
+ const lines = [
469
+ IGNORE_BLOCK_BEGIN,
470
+ IGNORE_BLOCK_NOTE,
471
+ `# ${filename} \u2014 enforced AI context rules (generated by ContextPruner)`,
472
+ "# Hard-blocks what the advisory configs can only ask agents to skip.",
473
+ ...gitignoreLinesFrom(input.enforcedGlobs),
474
+ IGNORE_BLOCK_END,
475
+ "",
476
+ "# Yours \u2014 edit freely. A !path line below re-allows files blocked by the",
477
+ "# extension and .env* rules above, but files inside an ignored directory",
478
+ "# can't be re-included one by one \u2014 add a !dir/ line for the directory,",
479
+ "# or pin the file KEEP to drop its rules from every enforced artifact.",
480
+ ""
481
+ ];
482
+ return lines.join("\n");
483
+ }
484
+ function generateCursorIgnore(input) {
485
+ return generateIgnoreFile(".cursorignore", input);
486
+ }
487
+ function generateGeminiIgnore(input) {
488
+ return generateIgnoreFile(".geminiignore", input);
489
+ }
490
+ function generateCodeiumIgnore(input) {
491
+ return generateIgnoreFile(".codeiumignore", input);
492
+ }
493
+
494
+ // ../../lib/shared/format.ts
495
+ function formatPctReduction(ratio) {
496
+ return ratio >= 1 ? 100 : Math.min(99, Math.floor(Number((ratio * 100).toPrecision(12))));
497
+ }
498
+
499
+ // ../../lib/engine/generators/shared.ts
500
+ function formatStatsLine(stats) {
501
+ const pct = stats.bytesTotal === 0 ? 0 : formatPctReduction(stats.bytesPruned / stats.bytesTotal);
502
+ return `${stats.filesPruned} of ${stats.filesTotal} files pruned (${pct}% smaller context)`;
503
+ }
504
+ var MANAGED_BLOCK_BEGIN = "<!-- contextpruner:begin -->";
505
+ var MANAGED_BLOCK_END = "<!-- contextpruner:end -->";
506
+ var MANAGED_BLOCK_NOTE = "<!-- generated block \u2014 edits inside are overwritten on regeneration -->";
507
+ function generateMarkdownRules(filename, input) {
508
+ const lines = [
509
+ MANAGED_BLOCK_BEGIN,
510
+ MANAGED_BLOCK_NOTE,
511
+ "",
512
+ `# ${filename} \u2014 AI context rules (generated by ContextPruner)`,
513
+ "",
514
+ `Generated ${input.generatedAt}. ${formatStatsLine(input.stats)}.`,
515
+ "",
516
+ "## Ignore",
517
+ "",
518
+ "Do not read, index, or load these paths into context:",
519
+ ""
520
+ ];
521
+ for (const glob of input.excludeGlobs) {
522
+ lines.push(`- \`${glob}\``);
523
+ }
524
+ if (input.summarizeGlobs.length > 0) {
525
+ lines.push(
526
+ "",
527
+ "## Skim",
528
+ "",
529
+ "Summarize these instead of reading them in full:",
530
+ ""
531
+ );
532
+ for (const glob of input.summarizeGlobs) {
533
+ lines.push(`- \`${glob}\``);
534
+ }
535
+ }
536
+ if (input.priorityGlobs.length > 0) {
537
+ lines.push("", "## Focus", "", "Prioritize these paths when exploring:", "");
538
+ for (const glob of input.priorityGlobs) {
539
+ lines.push(`- \`${glob}\``);
540
+ }
541
+ }
542
+ if (input.keepOverridePaths.length > 0 || input.skimOverridePaths.length > 0 || input.pruneOverridePaths.length > 0) {
543
+ lines.push(
544
+ "",
545
+ "## Overrides",
546
+ "",
547
+ "The repo owner pinned these verdicts \u2014 they take precedence over every rule above:",
548
+ ""
549
+ );
550
+ for (const path of input.keepOverridePaths) {
551
+ lines.push(`- Keep (read normally): \`${path}\``);
552
+ }
553
+ for (const path of input.skimOverridePaths) {
554
+ lines.push(`- Skim (summarize only): \`${path}\``);
555
+ }
556
+ for (const path of input.pruneOverridePaths) {
557
+ lines.push(`- Prune (do not read): \`${path}\``);
558
+ }
559
+ }
560
+ lines.push(
561
+ "",
562
+ MANAGED_BLOCK_END,
563
+ "",
564
+ "## Exceptions (yours \u2014 edit freely)",
565
+ "",
566
+ "ContextPruner only sees paths and byte sizes \u2014 you know which",
567
+ "junk-shaped files actually matter in this repo. Rules you add here",
568
+ "are yours: when regenerating, replace only the marked block above",
569
+ "and keep this section.",
570
+ "",
571
+ "- _none yet \u2014 add repo-specific overrides here_",
572
+ ""
573
+ );
574
+ return lines.join("\n");
575
+ }
576
+
577
+ // ../../lib/engine/parseConfig.ts
578
+ var GLOB_BULLET = /^- `(.+)`$/;
579
+ var OVERRIDE_BULLET = /^- (Keep|Skim|Prune) \([^)]*\): `(.+)`$/;
580
+ var SECTION_HEADINGS = {
581
+ "## Ignore": "ignore",
582
+ "## Skim": "skim",
583
+ "## Focus": "focus",
584
+ "## Overrides": "overrides"
585
+ };
586
+ function empty(hasManagedBlock) {
587
+ return {
588
+ hasManagedBlock,
589
+ excludeGlobs: [],
590
+ summarizeGlobs: [],
591
+ priorityGlobs: [],
592
+ overrides: { keep: [], skim: [], prune: [] }
593
+ };
594
+ }
595
+ function parseConfig(source) {
596
+ const begin = source.indexOf(MANAGED_BLOCK_BEGIN);
597
+ if (begin === -1) return empty(false);
598
+ const end = source.indexOf(MANAGED_BLOCK_END, begin + MANAGED_BLOCK_BEGIN.length);
599
+ if (end === -1) return empty(false);
600
+ const config = empty(true);
601
+ const block = source.slice(begin + MANAGED_BLOCK_BEGIN.length, end);
602
+ let section = null;
603
+ for (const raw of block.split("\n")) {
604
+ const line = raw.trimEnd();
605
+ const heading = SECTION_HEADINGS[line];
606
+ if (heading) {
607
+ section = heading;
608
+ continue;
609
+ }
610
+ if (line.startsWith("## ")) {
611
+ section = null;
612
+ continue;
613
+ }
614
+ if (section === null) continue;
615
+ if (section === "overrides") {
616
+ const match2 = OVERRIDE_BULLET.exec(line);
617
+ if (!match2) continue;
618
+ const bucket = match2[1] === "Keep" ? "keep" : match2[1] === "Skim" ? "skim" : "prune";
619
+ config.overrides[bucket].push(match2[2]);
620
+ continue;
621
+ }
622
+ const match = GLOB_BULLET.exec(line);
623
+ if (!match) continue;
624
+ const glob = match[1];
625
+ if (section === "ignore") config.excludeGlobs.push(glob);
626
+ else if (section === "skim") config.summarizeGlobs.push(glob);
627
+ else config.priorityGlobs.push(glob);
628
+ }
629
+ return config;
630
+ }
631
+
632
+ // ../../lib/engine/parseEnforced.ts
633
+ var NONE = {
634
+ recognized: false,
635
+ engineGlobs: [],
636
+ foreignLines: []
637
+ };
638
+ var ENV_NEGATION = new RegExp(
639
+ `^!\\.env\\*?(${ENV_EXEMPT_SUFFIXES.map((s) => s.replace(/\./g, "\\.")).join("|")})$`
640
+ );
641
+ function parseIgnoreFile(source) {
642
+ const begin = source.indexOf(IGNORE_BLOCK_BEGIN);
643
+ if (begin === -1) return NONE;
644
+ const end = source.indexOf(IGNORE_BLOCK_END, begin + IGNORE_BLOCK_BEGIN.length);
645
+ if (end === -1) return NONE;
646
+ const engineGlobs = [];
647
+ const foreignLines = [];
648
+ const block = source.slice(begin + IGNORE_BLOCK_BEGIN.length, end);
649
+ for (const raw of block.split("\n")) {
650
+ const line = raw.trim();
651
+ if (line === "" || line.startsWith("#")) continue;
652
+ if (ENV_NEGATION.test(line)) continue;
653
+ const glob = engineGlobFromGitignoreLine(line);
654
+ if (glob !== null) engineGlobs.push(glob);
655
+ else foreignLines.push(line);
656
+ }
657
+ return { recognized: true, engineGlobs, foreignLines };
658
+ }
659
+ function parseClaudeSettings(source) {
660
+ let root;
661
+ try {
662
+ root = JSON.parse(source);
663
+ } catch {
664
+ return NONE;
665
+ }
666
+ if (typeof root !== "object" || root === null || Array.isArray(root)) {
667
+ return NONE;
668
+ }
669
+ const deny = root.permissions?.deny;
670
+ if (!Array.isArray(deny) || deny.some((e) => typeof e !== "string")) {
671
+ return NONE;
672
+ }
673
+ const entries = deny;
674
+ const begin = entries.indexOf(CLAUDE_SENTINEL_BEGIN);
675
+ const end = entries.indexOf(CLAUDE_SENTINEL_END);
676
+ if (begin === -1 || end === -1 || begin >= end || entries.indexOf(CLAUDE_SENTINEL_BEGIN, begin + 1) !== -1 || entries.indexOf(CLAUDE_SENTINEL_END, end + 1) !== -1) {
677
+ return NONE;
678
+ }
679
+ const engineGlobs = [];
680
+ const foreignLines = [];
681
+ for (const entry of entries.slice(begin + 1, end)) {
682
+ const glob = engineGlobFromDenyEntry(entry);
683
+ if (glob !== null) engineGlobs.push(glob);
684
+ else foreignLines.push(entry);
685
+ }
686
+ return { recognized: true, engineGlobs, foreignLines };
687
+ }
688
+ function parseEnforcedConfig(source, format) {
689
+ return format === "gitignore" ? parseIgnoreFile(source) : parseClaudeSettings(source);
690
+ }
691
+
325
692
  // ../../lib/engine/generators/agentsMd.ts
326
693
  function generateAgentsMd(input) {
327
694
  return generateMarkdownRules("AGENTS.md", input);
@@ -449,6 +816,7 @@ function prune(files, options = {}) {
449
816
  windowClamped: pinnedSavings.windowClamped
450
817
  };
451
818
  for (const glob of options.extraExcludeGlobs ?? []) excludeGlobs.add(glob);
819
+ const enforced = deriveEnforcedRules(classified);
452
820
  const priorityGlobs = [...keptBytesByDir.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, MAX_PRIORITY_GLOBS).map(([dir]) => `${dir}/**`);
453
821
  const generatorInput = {
454
822
  generatedAt: (options.now ?? /* @__PURE__ */ new Date()).toISOString(),
@@ -458,7 +826,10 @@ function prune(files, options = {}) {
458
826
  priorityGlobs,
459
827
  keepOverridePaths: classified.filter((f) => f.overridden && f.verdict === "KEEP").map((f) => f.path).sort(),
460
828
  skimOverridePaths: classified.filter((f) => f.overridden && f.verdict === "SUMMARIZE").map((f) => f.path).sort(),
461
- pruneOverridePaths: classified.filter((f) => f.overridden && f.verdict === "PRUNE").map((f) => f.path).sort()
829
+ pruneOverridePaths: classified.filter((f) => f.overridden && f.verdict === "PRUNE").map((f) => f.path).sort(),
830
+ // Static-baseline + derived enforce-set; independent of extraExcludeGlobs
831
+ // (the baseline already names every canonical untracked junk dir).
832
+ enforcedGlobs: enforced.globs
462
833
  };
463
834
  const verdicts = classified.map(
464
835
  ({ path, verdict, reason, sizeInBytes, overridden }) => ({
@@ -474,12 +845,17 @@ function prune(files, options = {}) {
474
845
  generatedAt: generatorInput.generatedAt,
475
846
  stats,
476
847
  verdicts,
848
+ demotedEnforcedGlobs: enforced.demoted,
477
849
  artifacts: {
478
850
  agentsMd: generateAgentsMd(generatorInput),
479
851
  claudeMd: generateClaudeMd(generatorInput),
480
852
  cursorMdc: generateCursorMdc(generatorInput),
481
853
  copilotInstructionsMd: generateCopilotInstructions(generatorInput),
482
- geminiMd: generateGeminiMd(generatorInput)
854
+ geminiMd: generateGeminiMd(generatorInput),
855
+ cursorIgnore: generateCursorIgnore(generatorInput),
856
+ geminiIgnore: generateGeminiIgnore(generatorInput),
857
+ codeiumIgnore: generateCodeiumIgnore(generatorInput),
858
+ claudeSettings: generateClaudeSettings(generatorInput)
483
859
  }
484
860
  };
485
861
  }
@@ -513,8 +889,14 @@ function buildFixedConfig(files, userConfig, now, preserveExcludeGlobs) {
513
889
  return { fixable: false, changed: false, fixed: userConfig };
514
890
  }
515
891
  const artifact = detectArtifact(userBlock);
892
+ const { overrides } = parseConfig(userConfig);
893
+ const verdictOverrides = {};
894
+ for (const path of overrides.keep) verdictOverrides[path] = "KEEP";
895
+ for (const path of overrides.skim) verdictOverrides[path] = "SUMMARIZE";
896
+ for (const path of overrides.prune) verdictOverrides[path] = "PRUNE";
516
897
  const corrected = prune(files, {
517
898
  ...now ? { now } : {},
899
+ verdictOverrides,
518
900
  extraExcludeGlobs: preserveExcludeGlobs
519
901
  }).artifacts[artifact];
520
902
  const canonicalBlock = managedBlock(corrected);
@@ -526,92 +908,44 @@ function buildFixedConfig(files, userConfig, now, preserveExcludeGlobs) {
526
908
  const fixed = userConfig.slice(0, begin) + canonicalBlock + userConfig.slice(end);
527
909
  return { fixable: true, changed: fixed !== userConfig, fixed };
528
910
  }
529
-
530
- // ../../lib/engine/parseConfig.ts
531
- var GLOB_BULLET = /^- `(.+)`$/;
532
- var OVERRIDE_BULLET = /^- (Keep|Skim|Prune) \([^)]*\): `(.+)`$/;
533
- var SECTION_HEADINGS = {
534
- "## Ignore": "ignore",
535
- "## Skim": "skim",
536
- "## Focus": "focus",
537
- "## Overrides": "overrides"
911
+ var IGNORE_GENERATOR_BY_TITLE = {
912
+ ".cursorignore": generateCursorIgnore,
913
+ ".geminiignore": generateGeminiIgnore,
914
+ ".codeiumignore": generateCodeiumIgnore
538
915
  };
539
- function empty(hasManagedBlock) {
540
- return {
541
- hasManagedBlock,
542
- excludeGlobs: [],
543
- summarizeGlobs: [],
544
- priorityGlobs: [],
545
- overrides: { keep: [], skim: [], prune: [] }
546
- };
547
- }
548
- function parseConfig(source) {
549
- const begin = source.indexOf(MANAGED_BLOCK_BEGIN);
550
- if (begin === -1) return empty(false);
551
- const end = source.indexOf(MANAGED_BLOCK_END, begin + MANAGED_BLOCK_BEGIN.length);
552
- if (end === -1) return empty(false);
553
- const config = empty(true);
554
- const block = source.slice(begin + MANAGED_BLOCK_BEGIN.length, end);
555
- let section = null;
556
- for (const raw of block.split("\n")) {
557
- const line = raw.trimEnd();
558
- const heading = SECTION_HEADINGS[line];
559
- if (heading) {
560
- section = heading;
561
- continue;
916
+ var IGNORE_TITLE_RE = /^# (\.[a-z]+ignore) — enforced AI context rules/m;
917
+ function buildFixedEnforcedConfig(files, userConfig, format, pins = { keep: [], skim: [] }) {
918
+ const classified = files.map(classify);
919
+ const enforcedGlobs = demoteEnforcedGlobs(
920
+ deriveEnforcedRules(classified).globs,
921
+ effectivePinnedPaths(classified, pins)
922
+ ).globs;
923
+ if (format === "claudeSettings") {
924
+ if (!parseEnforcedConfig(userConfig, "claudeSettings").recognized) {
925
+ return { fixable: false, changed: false, fixed: userConfig };
562
926
  }
563
- if (line.startsWith("## ")) {
564
- section = null;
565
- continue;
566
- }
567
- if (section === null) continue;
568
- if (section === "overrides") {
569
- const match2 = OVERRIDE_BULLET.exec(line);
570
- if (!match2) continue;
571
- const bucket = match2[1] === "Keep" ? "keep" : match2[1] === "Skim" ? "skim" : "prune";
572
- config.overrides[bucket].push(match2[2]);
573
- continue;
927
+ const merged = mergeClaudeSettings(userConfig, { enforcedGlobs });
928
+ if (merged.error) {
929
+ return { fixable: false, changed: false, fixed: userConfig };
574
930
  }
575
- const match = GLOB_BULLET.exec(line);
576
- if (!match) continue;
577
- const glob = match[1];
578
- if (section === "ignore") config.excludeGlobs.push(glob);
579
- else if (section === "skim") config.summarizeGlobs.push(glob);
580
- else config.priorityGlobs.push(glob);
931
+ return { fixable: true, changed: merged.changed, fixed: merged.content };
581
932
  }
582
- return config;
583
- }
584
-
585
- // ../../lib/engine/globMatch.ts
586
- var ESCAPE = /[.+^${}()|[\]\\]/g;
587
- function globToRegExp(glob) {
588
- let re = "";
589
- for (let i = 0; i < glob.length; i++) {
590
- const c = glob[i];
591
- if (c === "*") {
592
- if (glob[i + 1] === "*") {
593
- const atSegmentStart = i === 0 || glob[i - 1] === "/";
594
- if (glob[i + 2] === "/" && atSegmentStart) {
595
- re += "(?:[^/]+/)*";
596
- i += 2;
597
- } else {
598
- re += ".*";
599
- i += 1;
600
- }
601
- } else {
602
- re += "[^/]*";
603
- }
604
- } else if (c === "?") {
605
- re += "[^/]";
606
- } else {
607
- re += c.replace(ESCAPE, "\\$&");
608
- }
933
+ const begin = userConfig.indexOf(IGNORE_BLOCK_BEGIN);
934
+ const end = begin === -1 ? -1 : userConfig.indexOf(IGNORE_BLOCK_END, begin + IGNORE_BLOCK_BEGIN.length);
935
+ if (begin === -1 || end === -1) {
936
+ return { fixable: false, changed: false, fixed: userConfig };
609
937
  }
610
- return new RegExp(`^${re}$`);
611
- }
612
- function compileGlob(glob) {
613
- const re = globToRegExp(glob);
614
- return (path) => re.test(path);
938
+ const block = userConfig.slice(begin, end + IGNORE_BLOCK_END.length);
939
+ const generate = IGNORE_GENERATOR_BY_TITLE[IGNORE_TITLE_RE.exec(block)?.[1] ?? ""] ?? generateCursorIgnore;
940
+ const corrected = generate({ enforcedGlobs });
941
+ const cBegin = corrected.indexOf(IGNORE_BLOCK_BEGIN);
942
+ const cEnd = corrected.indexOf(IGNORE_BLOCK_END, cBegin);
943
+ const canonicalBlock = corrected.slice(
944
+ cBegin,
945
+ cEnd + IGNORE_BLOCK_END.length
946
+ );
947
+ const fixed = userConfig.slice(0, begin) + canonicalBlock + userConfig.slice(end + IGNORE_BLOCK_END.length);
948
+ return { fixable: true, changed: fixed !== userConfig, fixed };
615
949
  }
616
950
 
617
951
  // ../../lib/engine/reconcile.ts
@@ -643,6 +977,99 @@ function compileConfig({ name, source }) {
643
977
  function isAddressed(config, path) {
644
978
  return config.keepSet.has(path) || config.pruneSet.has(path) || config.skimSet.has(path) || config.excludeMatch(path) || config.summarizeMatch(path);
645
979
  }
980
+ function dialectLine(glob, format) {
981
+ return format === "claudeSettings" ? denyEntriesFrom([glob])[0] : gitignoreLinesFrom([glob])[0];
982
+ }
983
+ function expectedEnforcedGlobs(classified, pinnedPaths) {
984
+ return demoteEnforcedGlobs(deriveEnforcedRules(classified).globs, pinnedPaths).globs;
985
+ }
986
+ function enforcedFindings(classified, paths, enforced, pinnedPaths) {
987
+ const findings = [];
988
+ const expected = expectedEnforcedGlobs(classified, pinnedPaths);
989
+ const expectedSet = new Set(expected);
990
+ for (const config of enforced) {
991
+ const have = new Set(config.engineGlobs);
992
+ for (const glob of expected) {
993
+ if (!have.has(glob)) {
994
+ findings.push({
995
+ kind: "missing",
996
+ severity: "medium",
997
+ glob,
998
+ message: `Enforced rule \`${dialectLine(glob, config.format)}\` is missing from ${config.name}`
999
+ });
1000
+ }
1001
+ }
1002
+ for (const glob of config.engineGlobs) {
1003
+ if (expectedSet.has(glob)) continue;
1004
+ if (!paths.some(compileGlob(glob))) {
1005
+ findings.push({
1006
+ kind: "dead",
1007
+ severity: "low",
1008
+ glob,
1009
+ message: `Rule \`${dialectLine(glob, config.format)}\` in ${config.name} matches no files and isn't in the enforce-set`
1010
+ });
1011
+ }
1012
+ }
1013
+ for (const glob of config.engineGlobs) {
1014
+ const matches = compileGlob(glob);
1015
+ for (const path of pinnedPaths) {
1016
+ if (matches(path)) {
1017
+ findings.push({
1018
+ kind: "conflict",
1019
+ severity: "high",
1020
+ glob,
1021
+ path,
1022
+ message: `\`${path}\` is pinned Keep/Skim but ${config.name} hard-blocks it via \`${dialectLine(glob, config.format)}\``
1023
+ });
1024
+ }
1025
+ }
1026
+ }
1027
+ for (const line of config.foreignLines) {
1028
+ findings.push({
1029
+ kind: "conflict",
1030
+ severity: "low",
1031
+ message: `Hand-written line inside ${config.name}'s managed block will be overwritten on regeneration: \`${line}\``
1032
+ });
1033
+ }
1034
+ }
1035
+ const base = enforced[0];
1036
+ if (base) {
1037
+ const baseSet = new Set(base.engineGlobs);
1038
+ for (const other of enforced.slice(1)) {
1039
+ const otherSet = new Set(other.engineGlobs);
1040
+ for (const glob of base.engineGlobs) {
1041
+ if (!otherSet.has(glob)) {
1042
+ findings.push({
1043
+ kind: "drift",
1044
+ severity: "medium",
1045
+ glob,
1046
+ message: `Enforced \`${glob}\` is in ${base.name} but not ${other.name}`
1047
+ });
1048
+ }
1049
+ }
1050
+ for (const glob of other.engineGlobs) {
1051
+ if (!baseSet.has(glob)) {
1052
+ findings.push({
1053
+ kind: "drift",
1054
+ severity: "medium",
1055
+ glob,
1056
+ message: `Enforced \`${glob}\` is in ${other.name} but not ${base.name}`
1057
+ });
1058
+ }
1059
+ }
1060
+ }
1061
+ }
1062
+ return findings;
1063
+ }
1064
+ function compileEnforced(configs) {
1065
+ const entries = [];
1066
+ for (const { name, source, format } of configs) {
1067
+ if (format !== "gitignore" && format !== "claudeSettings") continue;
1068
+ const parsed = parseEnforcedConfig(source, format);
1069
+ if (parsed.recognized) entries.push({ ...parsed, name, format });
1070
+ }
1071
+ return entries;
1072
+ }
646
1073
  function reconcile(files, configs, options = {}) {
647
1074
  const primary = configs[0];
648
1075
  const empty2 = {
@@ -655,11 +1082,61 @@ function reconcile(files, configs, options = {}) {
655
1082
  findings: []
656
1083
  };
657
1084
  if (!primary) return empty2;
658
- const config = compileConfig(primary);
659
- if (!config.hasManagedBlock) return empty2;
660
1085
  const classified = files.map((file) => ({ ...classify(file) }));
661
1086
  const paths = files.map((f) => f.path);
662
1087
  const bytesTotal = files.reduce((sum, f) => sum + f.sizeInBytes, 0);
1088
+ const markdownConfigs = configs.filter(
1089
+ (c) => (c.format ?? "markdown") === "markdown"
1090
+ );
1091
+ const enforced = compileEnforced(configs);
1092
+ const keepPins = [];
1093
+ const skimPins = [];
1094
+ for (const c of markdownConfigs) {
1095
+ const parsed = parseConfig(c.source);
1096
+ if (!parsed.hasManagedBlock) continue;
1097
+ keepPins.push(...parsed.overrides.keep);
1098
+ skimPins.push(...parsed.overrides.skim);
1099
+ }
1100
+ const pinnedPaths = effectivePinnedPaths(classified, {
1101
+ keep: keepPins,
1102
+ skim: skimPins
1103
+ });
1104
+ if ((primary.format ?? "markdown") !== "markdown") {
1105
+ const parsedPrimary = parseEnforcedConfig(
1106
+ primary.source,
1107
+ primary.format
1108
+ );
1109
+ if (!parsedPrimary.recognized) return empty2;
1110
+ const findings2 = enforcedFindings(classified, paths, enforced, pinnedPaths);
1111
+ const expected = expectedEnforcedGlobs(classified, pinnedPaths);
1112
+ const have = new Set(parsedPrimary.engineGlobs);
1113
+ const present = expected.filter((glob) => have.has(glob)).length;
1114
+ const coveragePct2 = expected.length === 0 ? 100 : Math.floor(present / expected.length * 100);
1115
+ const missingMatchers = expected.filter((glob) => !have.has(glob)).map(compileGlob);
1116
+ let missingBytes = 0;
1117
+ for (const file of classified) {
1118
+ if (file.verdict === "PRUNE" && missingMatchers.some((matches) => matches(file.path))) {
1119
+ missingBytes += file.sizeInBytes;
1120
+ }
1121
+ }
1122
+ const dollarsLeaking2 = Math.floor(
1123
+ computeSavings(bytesTotal, bytesTotal - missingBytes, options).usdSavedPerMonth * 100
1124
+ ) / 100;
1125
+ findings2.sort(
1126
+ (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.kind.localeCompare(b.kind) || (a.glob ?? "").localeCompare(b.glob ?? "")
1127
+ );
1128
+ return {
1129
+ configName: primary.name,
1130
+ configRecognized: true,
1131
+ filesTotal: files.length,
1132
+ grade: gradeFor(coveragePct2),
1133
+ coveragePct: coveragePct2,
1134
+ dollarsLeaking: dollarsLeaking2,
1135
+ findings: findings2
1136
+ };
1137
+ }
1138
+ const config = compileConfig(primary);
1139
+ if (!config.hasManagedBlock) return empty2;
663
1140
  const findings = [];
664
1141
  const missingBytesByGlob = /* @__PURE__ */ new Map();
665
1142
  for (const file of classified) {
@@ -764,6 +1241,7 @@ function reconcile(files, configs, options = {}) {
764
1241
  }
765
1242
  }
766
1243
  }
1244
+ findings.push(...enforcedFindings(classified, paths, enforced, pinnedPaths));
767
1245
  let junkBytes = 0;
768
1246
  let prunedBytes = 0;
769
1247
  for (const file of classified) {
@@ -900,12 +1378,26 @@ async function verifyKey(opts) {
900
1378
 
901
1379
  // src/run.ts
902
1380
  var DEFAULT_CONFIG_PATHS = [
1381
+ // Advisory markdown configs first — the primary (grade/coverage) config.
903
1382
  "AGENTS.md",
904
1383
  "CLAUDE.md",
905
1384
  "GEMINI.md",
906
1385
  ".github/copilot-instructions.md",
907
- ".cursor/rules/contextpruner.mdc"
1386
+ ".cursor/rules/contextpruner.mdc",
1387
+ // Enforced artifacts — linted against the same enforce-set the generators emit.
1388
+ ".cursorignore",
1389
+ ".geminiignore",
1390
+ ".codeiumignore",
1391
+ ".claude/settings.json"
908
1392
  ];
1393
+ function formatForPath(path) {
1394
+ const base = path.split("/").pop() ?? path;
1395
+ if (base === ".cursorignore" || base === ".geminiignore" || base === ".codeiumignore") {
1396
+ return "gitignore";
1397
+ }
1398
+ if (base === "settings.json") return "claudeSettings";
1399
+ return "markdown";
1400
+ }
909
1401
  var CREATE_KEY = "Set CONTEXTPRUNER_API_KEY (create one at https://contextpruner.app/account).";
910
1402
  function dirNameOf(glob) {
911
1403
  return glob.match(/^([^*/]+)\/\*\*$/)?.[1] ?? glob.match(/^\*\*\/([^*/]+)\/\*\*$/)?.[1] ?? null;
@@ -915,7 +1407,14 @@ function isFalseDead(finding) {
915
1407
  const dir = dirNameOf(finding.glob);
916
1408
  return dir !== null && PRUNE_DIR_SEGMENT_REASONS.has(dir);
917
1409
  }
918
- function hasRuleChange(before, after) {
1410
+ function hasRuleChange(before, after, format) {
1411
+ if (format === "claudeSettings") {
1412
+ try {
1413
+ return JSON.stringify(JSON.parse(before)) !== JSON.stringify(JSON.parse(after));
1414
+ } catch {
1415
+ return before !== after;
1416
+ }
1417
+ }
919
1418
  const normalize = (s) => s.replace(/Generated \S+Z\./, "Generated <ts>.");
920
1419
  return normalize(before) !== normalize(after);
921
1420
  }
@@ -933,7 +1432,9 @@ async function runLint(deps) {
933
1432
  const configs = [];
934
1433
  for (const path of deps.configPaths) {
935
1434
  const source = deps.readConfig(path);
936
- if (source !== null) configs.push({ name: path, source });
1435
+ if (source !== null) {
1436
+ configs.push({ name: path, source, format: formatForPath(path) });
1437
+ }
937
1438
  }
938
1439
  if (configs.length === 0) {
939
1440
  return {
@@ -954,17 +1455,30 @@ async function runLint(deps) {
954
1455
  return { text: "No tracked files found.", exitCode: 2, channel: "stderr" };
955
1456
  }
956
1457
  if (deps.fix) {
1458
+ const pins = { keep: [], skim: [] };
1459
+ for (const c of configs) {
1460
+ if (c.format !== "markdown") continue;
1461
+ const parsed = parseConfig(c.source);
1462
+ if (!parsed.hasManagedBlock) continue;
1463
+ pins.keep.push(...parsed.overrides.keep);
1464
+ pins.skim.push(...parsed.overrides.skim);
1465
+ }
957
1466
  const fixed = [];
958
1467
  const unfixable = [];
959
- for (const { name, source } of configs) {
960
- const spared = parseConfig(source).excludeGlobs.filter((glob) => {
961
- const dir = dirNameOf(glob);
962
- return dir !== null && PRUNE_DIR_SEGMENT_REASONS.has(dir);
963
- });
964
- const result = buildFixedConfig(files, source, void 0, spared);
1468
+ for (const { name, source, format } of configs) {
1469
+ let result;
1470
+ if (format === "markdown") {
1471
+ const spared = parseConfig(source).excludeGlobs.filter((glob) => {
1472
+ const dir = dirNameOf(glob);
1473
+ return dir !== null && PRUNE_DIR_SEGMENT_REASONS.has(dir);
1474
+ });
1475
+ result = buildFixedConfig(files, source, void 0, spared);
1476
+ } else {
1477
+ result = buildFixedEnforcedConfig(files, source, format, pins);
1478
+ }
965
1479
  if (!result.fixable) {
966
1480
  unfixable.push(name);
967
- } else if (hasRuleChange(source, result.fixed)) {
1481
+ } else if (hasRuleChange(source, result.fixed, format)) {
968
1482
  deps.writeConfig(name, result.fixed);
969
1483
  fixed.push(name);
970
1484
  }
@@ -978,12 +1492,12 @@ async function runLint(deps) {
978
1492
  }
979
1493
  if (unfixable.length === configs.length) {
980
1494
  return {
981
- text: `Nothing to fix \u2014 ${unfixable.join(", ")} ${unfixable.length === 1 ? "has" : "have"} no ContextPruner managed block.`,
1495
+ text: `Nothing to fix \u2014 ${unfixable.join(", ")} ${unfixable.length === 1 ? "has" : "have"} no ContextPruner managed block (or, for .claude/settings.json, could not be merged safely).`,
982
1496
  exitCode: 2,
983
1497
  channel: "stderr"
984
1498
  };
985
1499
  }
986
- const skipped = unfixable.length > 0 ? ` Skipped ${unfixable.join(", ")} (no ContextPruner managed block).` : "";
1500
+ const skipped = unfixable.length > 0 ? ` Skipped ${unfixable.join(", ")} (no ContextPruner managed block, or unmergeable).` : "";
987
1501
  return {
988
1502
  text: `Nothing to fix \u2014 your configs already match the tree.${skipped}`,
989
1503
  exitCode: 0,
@@ -998,7 +1512,7 @@ async function runLint(deps) {
998
1512
  }
999
1513
 
1000
1514
  // src/index.ts
1001
- var VERSION = "0.1.0";
1515
+ var VERSION = "0.2.0";
1002
1516
  var HELP = `contextpruner lint \u2014 check your AI-context config against your repo
1003
1517
 
1004
1518
  Usage:
@@ -1006,9 +1520,12 @@ Usage:
1006
1520
 
1007
1521
  Options:
1008
1522
  --config <path> A config to lint (repeatable). Default: auto-detect
1009
- AGENTS.md, CLAUDE.md, GEMINI.md, Copilot, and Cursor rules.
1523
+ AGENTS.md, CLAUDE.md, GEMINI.md, Copilot, and Cursor rules,
1524
+ plus the enforced .cursorignore, .geminiignore,
1525
+ .codeiumignore, and .claude/settings.json.
1010
1526
  --fix Rewrite each config's managed block in place to fix the
1011
- issues. Your own content outside the block is left alone.
1527
+ issues \u2014 enforced files too, via their managed block. Your
1528
+ own content outside the block is left alone.
1012
1529
  --json Emit the full report as JSON.
1013
1530
  -h, --help Show this help.
1014
1531
  -v, --version Show the version.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextpruner",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Lint your AI-context config files (AGENTS.md, CLAUDE.md, GEMINI.md, Cursor rules) against your repo — find missing, dead, drifting, and conflicting rules.",
5
5
  "keywords": [
6
6
  "AGENTS.md",
@@ -27,6 +27,6 @@
27
27
  "prepublishOnly": "node build.mjs"
28
28
  },
29
29
  "devDependencies": {
30
- "esbuild": "^0.24.0"
30
+ "esbuild": "^0.25.0"
31
31
  }
32
32
  }