contextpruner 0.1.0 → 0.3.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 +26 -4
  2. package/dist/index.js +804 -189
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,13 +1,17 @@
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, or a path you declared
14
+ `keep:` in `.contextpruner`.
11
15
 
12
16
  It exits non-zero when it finds issues, so it drops straight into CI or a
13
17
  pre-commit hook.
@@ -19,16 +23,34 @@ export CONTEXTPRUNER_API_KEY=cp_live_... # from https://contextpruner.app/acco
19
23
  npx contextpruner lint
20
24
  ```
21
25
 
26
+ One subscription, one key: the same `cp_live_` key authorizes both this CLI and
27
+ the ContextPruner config automation (the GitHub Action / pre-commit hook), so
28
+ reuse the one you already have.
29
+
22
30
  ```
23
31
  contextpruner lint [--config <path>]... [--fix] [--json]
24
32
  ```
25
33
 
26
34
  - `--config <path>` — a config to lint (repeatable). Defaults to auto-detecting
27
- the standard files at the repo root.
35
+ the standard files at the repo root: the advisory markdown configs plus the
36
+ four enforced artifacts. An explicit path's format is inferred from its
37
+ basename (`settings.json` → Claude settings, the `*ignore` files → gitignore
38
+ syntax, anything else → markdown).
28
39
  - `--fix` — rewrite each config's managed block in place to fix the issues. Your
29
40
  own content outside the block (and the "Exceptions" section) is left alone.
41
+ Enforced ignore files regenerate only the `# contextpruner:begin/end` block;
42
+ `.claude/settings.json` is merged via its sentinel-delimited deny span and is
43
+ skipped — never clobbered — when it has no recognized span or can't be merged
44
+ safely.
30
45
  - `--json` — emit the full report as JSON.
31
46
 
47
+ If the repo root has a committed `.contextpruner` file, its
48
+ `keep: <path-or-glob>` lines (enforcement exceptions — paths that must stay
49
+ readable by agents, even untracked ones like docs inside `node_modules/`)
50
+ drop any enforced rule covering them from the expected rule set, exactly as
51
+ the generate automation does — so lint, `--fix`, and generation always agree.
52
+ Unrecognized lines are reported as a warning and ignored.
53
+
32
54
  **Exit codes:** `0` clean · `1` issues found · `2` usage/auth error.
33
55
 
34
56
  ## In CI
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;
@@ -146,6 +63,8 @@ var CONTEXT_SLICE_FACTOR = 0.05;
146
63
  var DEFAULT_CONTEXT_WINDOW_TOKENS = 1e6;
147
64
  var MONTHLY_SAVINGS_CAP_USD = 200;
148
65
  var MAX_MANIFEST_FILES = 2e4;
66
+ var MAX_OVERRIDE_DECLARATIONS = 200;
67
+ var MAX_PATH_LENGTH = 1024;
149
68
  var OVERSIZED_FILE_BYTES = 1048576;
150
69
 
151
70
  // ../../lib/engine/rules.ts
@@ -277,9 +196,18 @@ var SUMMARIZE_EXTENSIONS = /* @__PURE__ */ new Set([
277
196
  "txt",
278
197
  "adoc"
279
198
  ]);
199
+ var ENV_EXEMPT_SUFFIXES = [".example", ".sample", ".template"];
200
+ var ENFORCED_REASONS = /* @__PURE__ */ new Set([
201
+ "env-file",
202
+ "dependency-dir",
203
+ "vendored-deps",
204
+ "build-output",
205
+ "test-coverage",
206
+ "cache",
207
+ "binary-asset"
208
+ ]);
280
209
 
281
210
  // ../../lib/engine/classify.ts
282
- var ENV_EXEMPT_SUFFIXES = [".example", ".sample", ".template"];
283
211
  function classify(file) {
284
212
  const { path, sizeInBytes } = file;
285
213
  const segments = path.split("/");
@@ -322,6 +250,478 @@ function classify(file) {
322
250
  return entry("KEEP", "source", null);
323
251
  }
324
252
 
253
+ // ../../lib/engine/globMatch.ts
254
+ var ESCAPE = /[.+^${}()|[\]\\]/g;
255
+ function globToRegExp(glob) {
256
+ let re = "";
257
+ for (let i = 0; i < glob.length; i++) {
258
+ const c = glob[i];
259
+ if (c === "*") {
260
+ if (glob[i + 1] === "*") {
261
+ const atSegmentStart = i === 0 || glob[i - 1] === "/";
262
+ if (glob[i + 2] === "/" && atSegmentStart) {
263
+ re += "(?:[^/]+/)*";
264
+ i += 2;
265
+ } else {
266
+ re += ".*";
267
+ i += 1;
268
+ }
269
+ } else {
270
+ re += "[^/]*";
271
+ }
272
+ } else if (c === "?") {
273
+ re += "[^/]";
274
+ } else {
275
+ re += c.replace(ESCAPE, "\\$&");
276
+ }
277
+ }
278
+ return new RegExp(`^${re}$`);
279
+ }
280
+ function compileGlob(glob) {
281
+ const re = globToRegExp(glob);
282
+ return (path) => re.test(path);
283
+ }
284
+
285
+ // ../../lib/engine/enforce.ts
286
+ var UNENFORCED_BINARY_EXTENSIONS = /* @__PURE__ */ new Set(["svg", "pdf"]);
287
+ var ENV_ENFORCED_GLOB = "**/.env*";
288
+ function enforcedDirSegments() {
289
+ return [...PRUNE_DIR_SEGMENT_REASONS.entries()].filter(([, reason]) => ENFORCED_REASONS.has(reason)).map(([segment]) => segment).sort();
290
+ }
291
+ function demoteEnforcedGlobs(candidates, protectedPaths) {
292
+ if (protectedPaths.length === 0) {
293
+ return { globs: [...candidates], demoted: [] };
294
+ }
295
+ const globs = [];
296
+ const demoted = [];
297
+ for (const candidate of candidates) {
298
+ const matches = compileGlob(candidate);
299
+ if (protectedPaths.some(matches)) {
300
+ demoted.push(candidate);
301
+ } else {
302
+ globs.push(candidate);
303
+ }
304
+ }
305
+ return { globs, demoted };
306
+ }
307
+ function effectivePinnedPaths(classified, pins) {
308
+ const naturalVerdicts = new Map(
309
+ classified.map((file) => [file.path, file.verdict])
310
+ );
311
+ const effective = /* @__PURE__ */ new Set();
312
+ for (const [paths, pinnedVerdict] of [
313
+ [pins.keep, "KEEP"],
314
+ [pins.skim, "SUMMARIZE"]
315
+ ]) {
316
+ for (const path of paths) {
317
+ const natural = naturalVerdicts.get(path);
318
+ if (natural !== void 0 && natural !== pinnedVerdict) {
319
+ effective.add(path);
320
+ }
321
+ }
322
+ }
323
+ return [...effective];
324
+ }
325
+ function deriveEnforcedRules(classified) {
326
+ const extGlobs = /* @__PURE__ */ new Set();
327
+ const protectedPaths = [];
328
+ for (const file of classified) {
329
+ if (file.overridden && file.verdict !== "PRUNE") {
330
+ protectedPaths.push(file.path);
331
+ }
332
+ if (!file.overridden && file.verdict === "PRUNE" && file.reason === "binary-asset" && file.glob !== null) {
333
+ const ext = file.glob.slice("**/*.".length);
334
+ if (!UNENFORCED_BINARY_EXTENSIONS.has(ext)) extGlobs.add(file.glob);
335
+ }
336
+ }
337
+ const candidates = [
338
+ ENV_ENFORCED_GLOB,
339
+ ...enforcedDirSegments().map((segment) => `**/${segment}/**`),
340
+ ...[...extGlobs].sort()
341
+ ];
342
+ return demoteEnforcedGlobs(candidates, protectedPaths);
343
+ }
344
+ var DIR_GLOB = /^\*\*\/([^*/]+)\/\*\*$/;
345
+ var EXT_GLOB = /^\*\*\/\*\.([a-z0-9]+)$/;
346
+ function gitignoreLinesFrom(globs) {
347
+ const lines = [];
348
+ for (const glob of globs) {
349
+ if (glob === ENV_ENFORCED_GLOB) {
350
+ lines.push(".env*");
351
+ for (const suffix of ENV_EXEMPT_SUFFIXES) {
352
+ lines.push(`!.env*${suffix}`);
353
+ }
354
+ continue;
355
+ }
356
+ const dir = glob.match(DIR_GLOB);
357
+ if (dir) {
358
+ lines.push(`${dir[1]}/`);
359
+ continue;
360
+ }
361
+ const ext = glob.match(EXT_GLOB);
362
+ if (ext) {
363
+ lines.push(`*.${ext[1]}`);
364
+ continue;
365
+ }
366
+ lines.push(glob);
367
+ }
368
+ return lines;
369
+ }
370
+ function denyEntriesFrom(globs) {
371
+ return globs.map((glob) => `Read(/${glob})`);
372
+ }
373
+ function engineGlobFromGitignoreLine(line) {
374
+ const trimmed = line.trim();
375
+ if (trimmed === "" || trimmed.startsWith("#")) return null;
376
+ if (trimmed === ".env*") return ENV_ENFORCED_GLOB;
377
+ const dirIdiom = trimmed.match(/^([^!*/\s]+)\/$/);
378
+ if (dirIdiom) return `**/${dirIdiom[1]}/**`;
379
+ const extIdiom = trimmed.match(/^\*\.([a-z0-9]+)$/);
380
+ if (extIdiom) return `**/*.${extIdiom[1]}`;
381
+ if (trimmed === ENV_ENFORCED_GLOB || DIR_GLOB.test(trimmed) || EXT_GLOB.test(trimmed)) {
382
+ return trimmed;
383
+ }
384
+ return null;
385
+ }
386
+ function engineGlobFromDenyEntry(entry) {
387
+ const inner = entry.match(/^Read\((.+)\)$/)?.[1];
388
+ if (!inner) return null;
389
+ const glob = inner.startsWith("/") ? inner.slice(1) : inner;
390
+ if (glob === ENV_ENFORCED_GLOB || DIR_GLOB.test(glob) || EXT_GLOB.test(glob)) {
391
+ return glob;
392
+ }
393
+ return null;
394
+ }
395
+
396
+ // ../../lib/engine/generators/claudeSettings.ts
397
+ var CLAUDE_SENTINEL_BEGIN = "Read(contextpruner-managed-begin)";
398
+ var CLAUDE_SENTINEL_END = "Read(contextpruner-managed-end)";
399
+ function claudeManagedDenyBlock(input) {
400
+ return [
401
+ CLAUDE_SENTINEL_BEGIN,
402
+ ...denyEntriesFrom(input.enforcedGlobs),
403
+ CLAUDE_SENTINEL_END
404
+ ];
405
+ }
406
+ function generateClaudeSettings(input) {
407
+ const settings = {
408
+ permissions: {
409
+ deny: claudeManagedDenyBlock(input)
410
+ }
411
+ };
412
+ return `${JSON.stringify(settings, null, 2)}
413
+ `;
414
+ }
415
+ function isPlainObject(value) {
416
+ return typeof value === "object" && value !== null && !Array.isArray(value);
417
+ }
418
+ function mergeClaudeSettings(existing, input) {
419
+ const unchanged = (error) => ({
420
+ content: existing,
421
+ changed: false,
422
+ error
423
+ });
424
+ let root;
425
+ try {
426
+ root = JSON.parse(existing);
427
+ } catch {
428
+ return unchanged("existing settings file is not valid JSON");
429
+ }
430
+ if (!isPlainObject(root)) {
431
+ return unchanged("existing settings file is not a JSON object");
432
+ }
433
+ const permissions = root.permissions ?? {};
434
+ if (!isPlainObject(permissions)) {
435
+ return unchanged("existing `permissions` is not an object");
436
+ }
437
+ const deny = permissions.deny ?? [];
438
+ if (!Array.isArray(deny) || deny.some((e) => typeof e !== "string")) {
439
+ return unchanged("existing `permissions.deny` is not a string array");
440
+ }
441
+ const begin = deny.indexOf(CLAUDE_SENTINEL_BEGIN);
442
+ const end = deny.indexOf(CLAUDE_SENTINEL_END);
443
+ if (begin !== deny.lastIndexOf(CLAUDE_SENTINEL_BEGIN) || end !== deny.lastIndexOf(CLAUDE_SENTINEL_END)) {
444
+ return unchanged(
445
+ "managed sentinel appears more than once \u2014 fix .claude/settings.json by hand"
446
+ );
447
+ }
448
+ const block = claudeManagedDenyBlock(input);
449
+ let nextDeny;
450
+ if (begin === -1 && end === -1) {
451
+ nextDeny = [...deny, ...block];
452
+ } else if (begin !== -1 && end !== -1 && begin < end) {
453
+ nextDeny = [...deny.slice(0, begin), ...block, ...deny.slice(end + 1)];
454
+ } else {
455
+ return unchanged(
456
+ "managed sentinel pair is incomplete or out of order \u2014 fix .claude/settings.json by hand"
457
+ );
458
+ }
459
+ root.permissions = { ...permissions, deny: nextDeny };
460
+ const content = `${JSON.stringify(root, null, 2)}
461
+ `;
462
+ return { content, changed: content !== existing };
463
+ }
464
+
465
+ // ../../lib/engine/generators/ignoreFiles.ts
466
+ var IGNORE_BLOCK_BEGIN = "# contextpruner:begin";
467
+ var IGNORE_BLOCK_END = "# contextpruner:end";
468
+ var IGNORE_BLOCK_NOTE = "# generated block \u2014 edits inside are overwritten on regeneration";
469
+ function generateIgnoreFile(filename, input) {
470
+ const lines = [
471
+ IGNORE_BLOCK_BEGIN,
472
+ IGNORE_BLOCK_NOTE,
473
+ `# ${filename} \u2014 enforced AI context rules (generated by ContextPruner)`,
474
+ "# Hard-blocks what the advisory configs can only ask agents to skip.",
475
+ ...gitignoreLinesFrom(input.enforcedGlobs),
476
+ IGNORE_BLOCK_END,
477
+ "",
478
+ "# Yours \u2014 edit freely. A !path line below re-allows files blocked by the",
479
+ "# extension and .env* rules above, but files inside an ignored directory",
480
+ "# can't be re-included one by one \u2014 add a !dir/ line for the directory,",
481
+ "# or pin the file KEEP to drop its rules from every enforced artifact.",
482
+ ""
483
+ ];
484
+ return lines.join("\n");
485
+ }
486
+ function generateCursorIgnore(input) {
487
+ return generateIgnoreFile(".cursorignore", input);
488
+ }
489
+ function generateGeminiIgnore(input) {
490
+ return generateIgnoreFile(".geminiignore", input);
491
+ }
492
+ function generateCodeiumIgnore(input) {
493
+ return generateIgnoreFile(".codeiumignore", input);
494
+ }
495
+
496
+ // ../../lib/shared/format.ts
497
+ function formatPctReduction(ratio) {
498
+ return ratio >= 1 ? 100 : Math.min(99, Math.floor(Number((ratio * 100).toPrecision(12))));
499
+ }
500
+
501
+ // ../../lib/engine/generators/shared.ts
502
+ function formatStatsLine(stats) {
503
+ const pct = stats.bytesTotal === 0 ? 0 : formatPctReduction(stats.bytesPruned / stats.bytesTotal);
504
+ return `${stats.filesPruned} of ${stats.filesTotal} files pruned (${pct}% smaller context)`;
505
+ }
506
+ var MANAGED_BLOCK_BEGIN = "<!-- contextpruner:begin -->";
507
+ var MANAGED_BLOCK_END = "<!-- contextpruner:end -->";
508
+ var MANAGED_BLOCK_NOTE = "<!-- generated block \u2014 edits inside are overwritten on regeneration -->";
509
+ function generateMarkdownRules(filename, input) {
510
+ const lines = [
511
+ MANAGED_BLOCK_BEGIN,
512
+ MANAGED_BLOCK_NOTE,
513
+ "",
514
+ `# ${filename} \u2014 AI context rules (generated by ContextPruner)`,
515
+ "",
516
+ `Generated ${input.generatedAt}. ${formatStatsLine(input.stats)}.`,
517
+ "",
518
+ "## Ignore",
519
+ "",
520
+ "Do not read, index, or load these paths into context:",
521
+ ""
522
+ ];
523
+ for (const glob of input.excludeGlobs) {
524
+ lines.push(`- \`${glob}\``);
525
+ }
526
+ if (input.summarizeGlobs.length > 0) {
527
+ lines.push(
528
+ "",
529
+ "## Skim",
530
+ "",
531
+ "Summarize these instead of reading them in full:",
532
+ ""
533
+ );
534
+ for (const glob of input.summarizeGlobs) {
535
+ lines.push(`- \`${glob}\``);
536
+ }
537
+ }
538
+ if (input.priorityGlobs.length > 0) {
539
+ lines.push("", "## Focus", "", "Prioritize these paths when exploring:", "");
540
+ for (const glob of input.priorityGlobs) {
541
+ lines.push(`- \`${glob}\``);
542
+ }
543
+ }
544
+ if (input.keepOverridePaths.length > 0 || input.skimOverridePaths.length > 0 || input.pruneOverridePaths.length > 0) {
545
+ lines.push(
546
+ "",
547
+ "## Overrides",
548
+ "",
549
+ "The repo owner pinned these verdicts \u2014 they take precedence over every rule above:",
550
+ ""
551
+ );
552
+ for (const path of input.keepOverridePaths) {
553
+ lines.push(`- Keep (read normally): \`${path}\``);
554
+ }
555
+ for (const path of input.skimOverridePaths) {
556
+ lines.push(`- Skim (summarize only): \`${path}\``);
557
+ }
558
+ for (const path of input.pruneOverridePaths) {
559
+ lines.push(`- Prune (do not read): \`${path}\``);
560
+ }
561
+ }
562
+ lines.push(
563
+ "",
564
+ MANAGED_BLOCK_END,
565
+ "",
566
+ "## Exceptions (yours \u2014 edit freely)",
567
+ "",
568
+ "ContextPruner only sees paths and byte sizes \u2014 you know which",
569
+ "junk-shaped files actually matter in this repo. Rules you add here",
570
+ "are yours: when regenerating, replace only the marked block above",
571
+ "and keep this section.",
572
+ "",
573
+ "- _none yet \u2014 add repo-specific overrides here_",
574
+ ""
575
+ );
576
+ return lines.join("\n");
577
+ }
578
+
579
+ // ../../lib/engine/overridesFile.ts
580
+ var OVERRIDES_FILENAME = ".contextpruner";
581
+ var KEEP_LINE = /^keep:\s*(.+)$/;
582
+ function isValidDeclaration(declaration) {
583
+ return declaration.length > 0 && declaration.length <= MAX_PATH_LENGTH && !declaration.includes("\0") && !declaration.includes("\\") && !declaration.startsWith("/") && declaration.split("/").every((seg) => seg !== "..");
584
+ }
585
+ function parseOverridesFile(source) {
586
+ const keep = [];
587
+ const seen = /* @__PURE__ */ new Set();
588
+ const unknownLines = [];
589
+ for (const raw of source.split("\n")) {
590
+ const line = raw.trim();
591
+ if (line === "" || line.startsWith("#")) continue;
592
+ const match = KEEP_LINE.exec(line);
593
+ const declaration = match?.[1]?.trim();
594
+ if (declaration !== void 0 && isValidDeclaration(declaration)) {
595
+ if (!seen.has(declaration)) {
596
+ seen.add(declaration);
597
+ if (keep.length < MAX_OVERRIDE_DECLARATIONS) keep.push(declaration);
598
+ }
599
+ } else {
600
+ unknownLines.push(line);
601
+ }
602
+ }
603
+ return { keep, unknownLines };
604
+ }
605
+ function representativePathsFor(declaration) {
606
+ const literal = declaration.replace(/\*+/g, "cpx");
607
+ return declaration.includes("*") ? [literal] : [literal, `${literal}/cpx`];
608
+ }
609
+
610
+ // ../../lib/engine/parseConfig.ts
611
+ var GLOB_BULLET = /^- `(.+)`$/;
612
+ var OVERRIDE_BULLET = /^- (Keep|Skim|Prune) \([^)]*\): `(.+)`$/;
613
+ var SECTION_HEADINGS = {
614
+ "## Ignore": "ignore",
615
+ "## Skim": "skim",
616
+ "## Focus": "focus",
617
+ "## Overrides": "overrides"
618
+ };
619
+ function empty(hasManagedBlock) {
620
+ return {
621
+ hasManagedBlock,
622
+ excludeGlobs: [],
623
+ summarizeGlobs: [],
624
+ priorityGlobs: [],
625
+ overrides: { keep: [], skim: [], prune: [] }
626
+ };
627
+ }
628
+ function parseConfig(source) {
629
+ const begin = source.indexOf(MANAGED_BLOCK_BEGIN);
630
+ if (begin === -1) return empty(false);
631
+ const end = source.indexOf(MANAGED_BLOCK_END, begin + MANAGED_BLOCK_BEGIN.length);
632
+ if (end === -1) return empty(false);
633
+ const config = empty(true);
634
+ const block = source.slice(begin + MANAGED_BLOCK_BEGIN.length, end);
635
+ let section = null;
636
+ for (const raw of block.split("\n")) {
637
+ const line = raw.trimEnd();
638
+ const heading = SECTION_HEADINGS[line];
639
+ if (heading) {
640
+ section = heading;
641
+ continue;
642
+ }
643
+ if (line.startsWith("## ")) {
644
+ section = null;
645
+ continue;
646
+ }
647
+ if (section === null) continue;
648
+ if (section === "overrides") {
649
+ const match2 = OVERRIDE_BULLET.exec(line);
650
+ if (!match2) continue;
651
+ const bucket = match2[1] === "Keep" ? "keep" : match2[1] === "Skim" ? "skim" : "prune";
652
+ config.overrides[bucket].push(match2[2]);
653
+ continue;
654
+ }
655
+ const match = GLOB_BULLET.exec(line);
656
+ if (!match) continue;
657
+ const glob = match[1];
658
+ if (section === "ignore") config.excludeGlobs.push(glob);
659
+ else if (section === "skim") config.summarizeGlobs.push(glob);
660
+ else config.priorityGlobs.push(glob);
661
+ }
662
+ return config;
663
+ }
664
+
665
+ // ../../lib/engine/parseEnforced.ts
666
+ var NONE = {
667
+ recognized: false,
668
+ engineGlobs: [],
669
+ foreignLines: []
670
+ };
671
+ var ENV_NEGATION = new RegExp(
672
+ `^!\\.env\\*?(${ENV_EXEMPT_SUFFIXES.map((s) => s.replace(/\./g, "\\.")).join("|")})$`
673
+ );
674
+ function parseIgnoreFile(source) {
675
+ const begin = source.indexOf(IGNORE_BLOCK_BEGIN);
676
+ if (begin === -1) return NONE;
677
+ const end = source.indexOf(IGNORE_BLOCK_END, begin + IGNORE_BLOCK_BEGIN.length);
678
+ if (end === -1) return NONE;
679
+ const engineGlobs = [];
680
+ const foreignLines = [];
681
+ const block = source.slice(begin + IGNORE_BLOCK_BEGIN.length, end);
682
+ for (const raw of block.split("\n")) {
683
+ const line = raw.trim();
684
+ if (line === "" || line.startsWith("#")) continue;
685
+ if (ENV_NEGATION.test(line)) continue;
686
+ const glob = engineGlobFromGitignoreLine(line);
687
+ if (glob !== null) engineGlobs.push(glob);
688
+ else foreignLines.push(line);
689
+ }
690
+ return { recognized: true, engineGlobs, foreignLines };
691
+ }
692
+ function parseClaudeSettings(source) {
693
+ let root;
694
+ try {
695
+ root = JSON.parse(source);
696
+ } catch {
697
+ return NONE;
698
+ }
699
+ if (typeof root !== "object" || root === null || Array.isArray(root)) {
700
+ return NONE;
701
+ }
702
+ const deny = root.permissions?.deny;
703
+ if (!Array.isArray(deny) || deny.some((e) => typeof e !== "string")) {
704
+ return NONE;
705
+ }
706
+ const entries = deny;
707
+ const begin = entries.indexOf(CLAUDE_SENTINEL_BEGIN);
708
+ const end = entries.indexOf(CLAUDE_SENTINEL_END);
709
+ if (begin === -1 || end === -1 || begin >= end || entries.indexOf(CLAUDE_SENTINEL_BEGIN, begin + 1) !== -1 || entries.indexOf(CLAUDE_SENTINEL_END, end + 1) !== -1) {
710
+ return NONE;
711
+ }
712
+ const engineGlobs = [];
713
+ const foreignLines = [];
714
+ for (const entry of entries.slice(begin + 1, end)) {
715
+ const glob = engineGlobFromDenyEntry(entry);
716
+ if (glob !== null) engineGlobs.push(glob);
717
+ else foreignLines.push(entry);
718
+ }
719
+ return { recognized: true, engineGlobs, foreignLines };
720
+ }
721
+ function parseEnforcedConfig(source, format) {
722
+ return format === "gitignore" ? parseIgnoreFile(source) : parseClaudeSettings(source);
723
+ }
724
+
325
725
  // ../../lib/engine/generators/agentsMd.ts
326
726
  function generateAgentsMd(input) {
327
727
  return generateMarkdownRules("AGENTS.md", input);
@@ -386,7 +786,13 @@ function computeSavings(bytesTotal, bytesKept, options = {}) {
386
786
  // ../../lib/engine/prune.ts
387
787
  var MAX_PRIORITY_GLOBS = 5;
388
788
  function prune(files, options = {}) {
389
- const overrides = options.verdictOverrides ?? {};
789
+ const declarations = options.keepDeclarations ?? [];
790
+ const overrides = {
791
+ ...Object.fromEntries(
792
+ declarations.filter((declaration) => !declaration.includes("*")).map((path) => [path, "KEEP"])
793
+ ),
794
+ ...options.verdictOverrides
795
+ };
390
796
  const classified = files.map((file) => {
391
797
  const base = classify(file);
392
798
  const pinned = Object.hasOwn(overrides, file.path) ? overrides[file.path] : void 0;
@@ -449,6 +855,15 @@ function prune(files, options = {}) {
449
855
  windowClamped: pinnedSavings.windowClamped
450
856
  };
451
857
  for (const glob of options.extraExcludeGlobs ?? []) excludeGlobs.add(glob);
858
+ const pinDemoted = deriveEnforcedRules(classified);
859
+ const declDemoted = demoteEnforcedGlobs(
860
+ pinDemoted.globs,
861
+ declarations.flatMap(representativePathsFor)
862
+ );
863
+ const enforced = {
864
+ globs: declDemoted.globs,
865
+ demoted: [...pinDemoted.demoted, ...declDemoted.demoted]
866
+ };
452
867
  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
868
  const generatorInput = {
454
869
  generatedAt: (options.now ?? /* @__PURE__ */ new Date()).toISOString(),
@@ -458,7 +873,10 @@ function prune(files, options = {}) {
458
873
  priorityGlobs,
459
874
  keepOverridePaths: classified.filter((f) => f.overridden && f.verdict === "KEEP").map((f) => f.path).sort(),
460
875
  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()
876
+ pruneOverridePaths: classified.filter((f) => f.overridden && f.verdict === "PRUNE").map((f) => f.path).sort(),
877
+ // Static-baseline + derived enforce-set; independent of extraExcludeGlobs
878
+ // (the baseline already names every canonical untracked junk dir).
879
+ enforcedGlobs: enforced.globs
462
880
  };
463
881
  const verdicts = classified.map(
464
882
  ({ path, verdict, reason, sizeInBytes, overridden }) => ({
@@ -474,12 +892,17 @@ function prune(files, options = {}) {
474
892
  generatedAt: generatorInput.generatedAt,
475
893
  stats,
476
894
  verdicts,
895
+ demotedEnforcedGlobs: enforced.demoted,
477
896
  artifacts: {
478
897
  agentsMd: generateAgentsMd(generatorInput),
479
898
  claudeMd: generateClaudeMd(generatorInput),
480
899
  cursorMdc: generateCursorMdc(generatorInput),
481
900
  copilotInstructionsMd: generateCopilotInstructions(generatorInput),
482
- geminiMd: generateGeminiMd(generatorInput)
901
+ geminiMd: generateGeminiMd(generatorInput),
902
+ cursorIgnore: generateCursorIgnore(generatorInput),
903
+ geminiIgnore: generateGeminiIgnore(generatorInput),
904
+ codeiumIgnore: generateCodeiumIgnore(generatorInput),
905
+ claudeSettings: generateClaudeSettings(generatorInput)
483
906
  }
484
907
  };
485
908
  }
@@ -513,8 +936,14 @@ function buildFixedConfig(files, userConfig, now, preserveExcludeGlobs) {
513
936
  return { fixable: false, changed: false, fixed: userConfig };
514
937
  }
515
938
  const artifact = detectArtifact(userBlock);
939
+ const { overrides } = parseConfig(userConfig);
940
+ const verdictOverrides = {};
941
+ for (const path of overrides.keep) verdictOverrides[path] = "KEEP";
942
+ for (const path of overrides.skim) verdictOverrides[path] = "SUMMARIZE";
943
+ for (const path of overrides.prune) verdictOverrides[path] = "PRUNE";
516
944
  const corrected = prune(files, {
517
945
  ...now ? { now } : {},
946
+ verdictOverrides,
518
947
  extraExcludeGlobs: preserveExcludeGlobs
519
948
  }).artifacts[artifact];
520
949
  const canonicalBlock = managedBlock(corrected);
@@ -526,92 +955,47 @@ function buildFixedConfig(files, userConfig, now, preserveExcludeGlobs) {
526
955
  const fixed = userConfig.slice(0, begin) + canonicalBlock + userConfig.slice(end);
527
956
  return { fixable: true, changed: fixed !== userConfig, fixed };
528
957
  }
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"
958
+ var IGNORE_GENERATOR_BY_TITLE = {
959
+ ".cursorignore": generateCursorIgnore,
960
+ ".geminiignore": generateGeminiIgnore,
961
+ ".codeiumignore": generateCodeiumIgnore
538
962
  };
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;
963
+ var IGNORE_TITLE_RE = /^# (\.[a-z]+ignore) — enforced AI context rules/m;
964
+ function buildFixedEnforcedConfig(files, userConfig, format, pins = { keep: [], skim: [] }, keepDeclarations = []) {
965
+ const classified = files.map(classify);
966
+ const enforcedGlobs = demoteEnforcedGlobs(
967
+ deriveEnforcedRules(classified).globs,
968
+ [
969
+ ...effectivePinnedPaths(classified, pins),
970
+ ...keepDeclarations.flatMap(representativePathsFor)
971
+ ]
972
+ ).globs;
973
+ if (format === "claudeSettings") {
974
+ if (!parseEnforcedConfig(userConfig, "claudeSettings").recognized) {
975
+ return { fixable: false, changed: false, fixed: userConfig };
562
976
  }
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;
977
+ const merged = mergeClaudeSettings(userConfig, { enforcedGlobs });
978
+ if (merged.error) {
979
+ return { fixable: false, changed: false, fixed: userConfig };
574
980
  }
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);
981
+ return { fixable: true, changed: merged.changed, fixed: merged.content };
581
982
  }
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
- }
983
+ const begin = userConfig.indexOf(IGNORE_BLOCK_BEGIN);
984
+ const end = begin === -1 ? -1 : userConfig.indexOf(IGNORE_BLOCK_END, begin + IGNORE_BLOCK_BEGIN.length);
985
+ if (begin === -1 || end === -1) {
986
+ return { fixable: false, changed: false, fixed: userConfig };
609
987
  }
610
- return new RegExp(`^${re}$`);
611
- }
612
- function compileGlob(glob) {
613
- const re = globToRegExp(glob);
614
- return (path) => re.test(path);
988
+ const block = userConfig.slice(begin, end + IGNORE_BLOCK_END.length);
989
+ const generate = IGNORE_GENERATOR_BY_TITLE[IGNORE_TITLE_RE.exec(block)?.[1] ?? ""] ?? generateCursorIgnore;
990
+ const corrected = generate({ enforcedGlobs });
991
+ const cBegin = corrected.indexOf(IGNORE_BLOCK_BEGIN);
992
+ const cEnd = corrected.indexOf(IGNORE_BLOCK_END, cBegin);
993
+ const canonicalBlock = corrected.slice(
994
+ cBegin,
995
+ cEnd + IGNORE_BLOCK_END.length
996
+ );
997
+ const fixed = userConfig.slice(0, begin) + canonicalBlock + userConfig.slice(end + IGNORE_BLOCK_END.length);
998
+ return { fixable: true, changed: fixed !== userConfig, fixed };
615
999
  }
616
1000
 
617
1001
  // ../../lib/engine/reconcile.ts
@@ -643,6 +1027,116 @@ function compileConfig({ name, source }) {
643
1027
  function isAddressed(config, path) {
644
1028
  return config.keepSet.has(path) || config.pruneSet.has(path) || config.skimSet.has(path) || config.excludeMatch(path) || config.summarizeMatch(path);
645
1029
  }
1030
+ function dialectLine(glob, format) {
1031
+ return format === "claudeSettings" ? denyEntriesFrom([glob])[0] : gitignoreLinesFrom([glob])[0];
1032
+ }
1033
+ function expectedEnforcedGlobs(classified, pinnedPaths) {
1034
+ return demoteEnforcedGlobs(deriveEnforcedRules(classified).globs, pinnedPaths).globs;
1035
+ }
1036
+ function enforcedFindings(classified, paths, enforced, pinnedPaths, keepDeclarations) {
1037
+ const findings = [];
1038
+ const expected = expectedEnforcedGlobs(classified, [
1039
+ ...pinnedPaths,
1040
+ ...keepDeclarations.flatMap(representativePathsFor)
1041
+ ]);
1042
+ const expectedSet = new Set(expected);
1043
+ const declarationReps = keepDeclarations.map(
1044
+ (declaration) => [declaration, representativePathsFor(declaration)]
1045
+ );
1046
+ for (const config of enforced) {
1047
+ const have = new Set(config.engineGlobs);
1048
+ for (const glob of expected) {
1049
+ if (!have.has(glob)) {
1050
+ findings.push({
1051
+ kind: "missing",
1052
+ severity: "medium",
1053
+ glob,
1054
+ message: `Enforced rule \`${dialectLine(glob, config.format)}\` is missing from ${config.name}`
1055
+ });
1056
+ }
1057
+ }
1058
+ for (const glob of config.engineGlobs) {
1059
+ if (expectedSet.has(glob)) continue;
1060
+ if (!paths.some(compileGlob(glob))) {
1061
+ findings.push({
1062
+ kind: "dead",
1063
+ severity: "low",
1064
+ glob,
1065
+ message: `Rule \`${dialectLine(glob, config.format)}\` in ${config.name} matches no files and isn't in the enforce-set`
1066
+ });
1067
+ }
1068
+ }
1069
+ for (const glob of config.engineGlobs) {
1070
+ const matches = compileGlob(glob);
1071
+ for (const path of pinnedPaths) {
1072
+ if (matches(path)) {
1073
+ findings.push({
1074
+ kind: "conflict",
1075
+ severity: "high",
1076
+ glob,
1077
+ path,
1078
+ message: `\`${path}\` is pinned Keep/Skim but ${config.name} hard-blocks it via \`${dialectLine(glob, config.format)}\``
1079
+ });
1080
+ }
1081
+ }
1082
+ for (const [declaration, representatives] of declarationReps) {
1083
+ if (representatives.some(matches)) {
1084
+ findings.push({
1085
+ kind: "conflict",
1086
+ severity: "high",
1087
+ glob,
1088
+ path: declaration,
1089
+ message: `\`${declaration}\` is declared keep in ${OVERRIDES_FILENAME} but ${config.name} still hard-blocks it via \`${dialectLine(glob, config.format)}\``
1090
+ });
1091
+ }
1092
+ }
1093
+ }
1094
+ for (const line of config.foreignLines) {
1095
+ findings.push({
1096
+ kind: "conflict",
1097
+ severity: "low",
1098
+ message: `Hand-written line inside ${config.name}'s managed block will be overwritten on regeneration: \`${line}\``
1099
+ });
1100
+ }
1101
+ }
1102
+ const base = enforced[0];
1103
+ if (base) {
1104
+ const baseSet = new Set(base.engineGlobs);
1105
+ for (const other of enforced.slice(1)) {
1106
+ const otherSet = new Set(other.engineGlobs);
1107
+ for (const glob of base.engineGlobs) {
1108
+ if (!otherSet.has(glob)) {
1109
+ findings.push({
1110
+ kind: "drift",
1111
+ severity: "medium",
1112
+ glob,
1113
+ message: `Enforced \`${glob}\` is in ${base.name} but not ${other.name}`
1114
+ });
1115
+ }
1116
+ }
1117
+ for (const glob of other.engineGlobs) {
1118
+ if (!baseSet.has(glob)) {
1119
+ findings.push({
1120
+ kind: "drift",
1121
+ severity: "medium",
1122
+ glob,
1123
+ message: `Enforced \`${glob}\` is in ${other.name} but not ${base.name}`
1124
+ });
1125
+ }
1126
+ }
1127
+ }
1128
+ }
1129
+ return findings;
1130
+ }
1131
+ function compileEnforced(configs) {
1132
+ const entries = [];
1133
+ for (const { name, source, format } of configs) {
1134
+ if (format !== "gitignore" && format !== "claudeSettings") continue;
1135
+ const parsed = parseEnforcedConfig(source, format);
1136
+ if (parsed.recognized) entries.push({ ...parsed, name, format });
1137
+ }
1138
+ return entries;
1139
+ }
646
1140
  function reconcile(files, configs, options = {}) {
647
1141
  const primary = configs[0];
648
1142
  const empty2 = {
@@ -655,11 +1149,72 @@ function reconcile(files, configs, options = {}) {
655
1149
  findings: []
656
1150
  };
657
1151
  if (!primary) return empty2;
658
- const config = compileConfig(primary);
659
- if (!config.hasManagedBlock) return empty2;
660
1152
  const classified = files.map((file) => ({ ...classify(file) }));
661
1153
  const paths = files.map((f) => f.path);
662
1154
  const bytesTotal = files.reduce((sum, f) => sum + f.sizeInBytes, 0);
1155
+ const markdownConfigs = configs.filter(
1156
+ (c) => (c.format ?? "markdown") === "markdown"
1157
+ );
1158
+ const enforced = compileEnforced(configs);
1159
+ const keepPins = [];
1160
+ const skimPins = [];
1161
+ for (const c of markdownConfigs) {
1162
+ const parsed = parseConfig(c.source);
1163
+ if (!parsed.hasManagedBlock) continue;
1164
+ keepPins.push(...parsed.overrides.keep);
1165
+ skimPins.push(...parsed.overrides.skim);
1166
+ }
1167
+ const pinnedPaths = effectivePinnedPaths(classified, {
1168
+ keep: keepPins,
1169
+ skim: skimPins
1170
+ });
1171
+ const declarations = options.keepDeclarations ?? [];
1172
+ const demotionPaths = [
1173
+ ...pinnedPaths,
1174
+ ...declarations.flatMap(representativePathsFor)
1175
+ ];
1176
+ if ((primary.format ?? "markdown") !== "markdown") {
1177
+ const parsedPrimary = parseEnforcedConfig(
1178
+ primary.source,
1179
+ primary.format
1180
+ );
1181
+ if (!parsedPrimary.recognized) return empty2;
1182
+ const findings2 = enforcedFindings(
1183
+ classified,
1184
+ paths,
1185
+ enforced,
1186
+ pinnedPaths,
1187
+ declarations
1188
+ );
1189
+ const expected = expectedEnforcedGlobs(classified, demotionPaths);
1190
+ const have = new Set(parsedPrimary.engineGlobs);
1191
+ const present = expected.filter((glob) => have.has(glob)).length;
1192
+ const coveragePct2 = expected.length === 0 ? 100 : Math.floor(present / expected.length * 100);
1193
+ const missingMatchers = expected.filter((glob) => !have.has(glob)).map(compileGlob);
1194
+ let missingBytes = 0;
1195
+ for (const file of classified) {
1196
+ if (file.verdict === "PRUNE" && missingMatchers.some((matches) => matches(file.path))) {
1197
+ missingBytes += file.sizeInBytes;
1198
+ }
1199
+ }
1200
+ const dollarsLeaking2 = Math.floor(
1201
+ computeSavings(bytesTotal, bytesTotal - missingBytes, options).usdSavedPerMonth * 100
1202
+ ) / 100;
1203
+ findings2.sort(
1204
+ (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.kind.localeCompare(b.kind) || (a.glob ?? "").localeCompare(b.glob ?? "")
1205
+ );
1206
+ return {
1207
+ configName: primary.name,
1208
+ configRecognized: true,
1209
+ filesTotal: files.length,
1210
+ grade: gradeFor(coveragePct2),
1211
+ coveragePct: coveragePct2,
1212
+ dollarsLeaking: dollarsLeaking2,
1213
+ findings: findings2
1214
+ };
1215
+ }
1216
+ const config = compileConfig(primary);
1217
+ if (!config.hasManagedBlock) return empty2;
663
1218
  const findings = [];
664
1219
  const missingBytesByGlob = /* @__PURE__ */ new Map();
665
1220
  for (const file of classified) {
@@ -764,6 +1319,9 @@ function reconcile(files, configs, options = {}) {
764
1319
  }
765
1320
  }
766
1321
  }
1322
+ findings.push(
1323
+ ...enforcedFindings(classified, paths, enforced, pinnedPaths, declarations)
1324
+ );
767
1325
  let junkBytes = 0;
768
1326
  let prunedBytes = 0;
769
1327
  for (const file of classified) {
@@ -900,12 +1458,26 @@ async function verifyKey(opts) {
900
1458
 
901
1459
  // src/run.ts
902
1460
  var DEFAULT_CONFIG_PATHS = [
1461
+ // Advisory markdown configs first — the primary (grade/coverage) config.
903
1462
  "AGENTS.md",
904
1463
  "CLAUDE.md",
905
1464
  "GEMINI.md",
906
1465
  ".github/copilot-instructions.md",
907
- ".cursor/rules/contextpruner.mdc"
1466
+ ".cursor/rules/contextpruner.mdc",
1467
+ // Enforced artifacts — linted against the same enforce-set the generators emit.
1468
+ ".cursorignore",
1469
+ ".geminiignore",
1470
+ ".codeiumignore",
1471
+ ".claude/settings.json"
908
1472
  ];
1473
+ function formatForPath(path) {
1474
+ const base = path.split("/").pop() ?? path;
1475
+ if (base === ".cursorignore" || base === ".geminiignore" || base === ".codeiumignore") {
1476
+ return "gitignore";
1477
+ }
1478
+ if (base === "settings.json") return "claudeSettings";
1479
+ return "markdown";
1480
+ }
909
1481
  var CREATE_KEY = "Set CONTEXTPRUNER_API_KEY (create one at https://contextpruner.app/account).";
910
1482
  function dirNameOf(glob) {
911
1483
  return glob.match(/^([^*/]+)\/\*\*$/)?.[1] ?? glob.match(/^\*\*\/([^*/]+)\/\*\*$/)?.[1] ?? null;
@@ -915,7 +1487,14 @@ function isFalseDead(finding) {
915
1487
  const dir = dirNameOf(finding.glob);
916
1488
  return dir !== null && PRUNE_DIR_SEGMENT_REASONS.has(dir);
917
1489
  }
918
- function hasRuleChange(before, after) {
1490
+ function hasRuleChange(before, after, format) {
1491
+ if (format === "claudeSettings") {
1492
+ try {
1493
+ return JSON.stringify(JSON.parse(before)) !== JSON.stringify(JSON.parse(after));
1494
+ } catch {
1495
+ return before !== after;
1496
+ }
1497
+ }
919
1498
  const normalize = (s) => s.replace(/Generated \S+Z\./, "Generated <ts>.");
920
1499
  return normalize(before) !== normalize(after);
921
1500
  }
@@ -933,7 +1512,9 @@ async function runLint(deps) {
933
1512
  const configs = [];
934
1513
  for (const path of deps.configPaths) {
935
1514
  const source = deps.readConfig(path);
936
- if (source !== null) configs.push({ name: path, source });
1515
+ if (source !== null) {
1516
+ configs.push({ name: path, source, format: formatForPath(path) });
1517
+ }
937
1518
  }
938
1519
  if (configs.length === 0) {
939
1520
  return {
@@ -953,52 +1534,78 @@ async function runLint(deps) {
953
1534
  if (files.length === 0) {
954
1535
  return { text: "No tracked files found.", exitCode: 2, channel: "stderr" };
955
1536
  }
1537
+ const overridesSource = deps.readConfig(OVERRIDES_FILENAME);
1538
+ const parsedOverrides = parseOverridesFile(overridesSource ?? "");
1539
+ const overridesWarning = parsedOverrides.unknownLines.length > 0 ? `
1540
+ \u26A0 ${OVERRIDES_FILENAME}: ignored ${parsedOverrides.unknownLines.length} unrecognized line${parsedOverrides.unknownLines.length === 1 ? "" : "s"} (${parsedOverrides.unknownLines.map((l) => `"${l}"`).join(", ")}) \u2014 only \`keep: <path-or-glob>\` lines are read.
1541
+ ` : "";
956
1542
  if (deps.fix) {
1543
+ const pins = { keep: [], skim: [] };
1544
+ for (const c of configs) {
1545
+ if (c.format !== "markdown") continue;
1546
+ const parsed = parseConfig(c.source);
1547
+ if (!parsed.hasManagedBlock) continue;
1548
+ pins.keep.push(...parsed.overrides.keep);
1549
+ pins.skim.push(...parsed.overrides.skim);
1550
+ }
957
1551
  const fixed = [];
958
1552
  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);
1553
+ for (const { name, source, format } of configs) {
1554
+ let result;
1555
+ if (format === "markdown") {
1556
+ const spared = parseConfig(source).excludeGlobs.filter((glob) => {
1557
+ const dir = dirNameOf(glob);
1558
+ return dir !== null && PRUNE_DIR_SEGMENT_REASONS.has(dir);
1559
+ });
1560
+ result = buildFixedConfig(files, source, void 0, spared);
1561
+ } else {
1562
+ result = buildFixedEnforcedConfig(
1563
+ files,
1564
+ source,
1565
+ format,
1566
+ pins,
1567
+ parsedOverrides.keep
1568
+ );
1569
+ }
965
1570
  if (!result.fixable) {
966
1571
  unfixable.push(name);
967
- } else if (hasRuleChange(source, result.fixed)) {
1572
+ } else if (hasRuleChange(source, result.fixed, format)) {
968
1573
  deps.writeConfig(name, result.fixed);
969
1574
  fixed.push(name);
970
1575
  }
971
1576
  }
972
1577
  if (fixed.length > 0) {
973
1578
  return {
974
- text: `Fixed ${fixed.join(", ")}. Re-run without --fix to confirm.`,
1579
+ text: `${overridesWarning}Fixed ${fixed.join(", ")}. Re-run without --fix to confirm.`,
975
1580
  exitCode: 0,
976
1581
  channel: "stdout"
977
1582
  };
978
1583
  }
979
1584
  if (unfixable.length === configs.length) {
980
1585
  return {
981
- text: `Nothing to fix \u2014 ${unfixable.join(", ")} ${unfixable.length === 1 ? "has" : "have"} no ContextPruner managed block.`,
1586
+ text: `${overridesWarning}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
1587
  exitCode: 2,
983
1588
  channel: "stderr"
984
1589
  };
985
1590
  }
986
- const skipped = unfixable.length > 0 ? ` Skipped ${unfixable.join(", ")} (no ContextPruner managed block).` : "";
1591
+ const skipped = unfixable.length > 0 ? ` Skipped ${unfixable.join(", ")} (no ContextPruner managed block, or unmergeable).` : "";
987
1592
  return {
988
- text: `Nothing to fix \u2014 your configs already match the tree.${skipped}`,
1593
+ text: `${overridesWarning}Nothing to fix \u2014 your configs already match the tree.${skipped}`,
989
1594
  exitCode: 0,
990
1595
  channel: "stdout"
991
1596
  };
992
1597
  }
993
- const report = reconcile(files, configs);
1598
+ const report = reconcile(files, configs, {
1599
+ keepDeclarations: parsedOverrides.keep
1600
+ });
994
1601
  const kept = report.findings.filter((f) => !isFalseDead(f));
995
1602
  const filtered = { ...report, findings: kept };
996
- const text = deps.json ? JSON.stringify(filtered, null, 2) : formatReport(filtered);
1603
+ const text = deps.json ? JSON.stringify(filtered, null, 2) : `${overridesWarning}${formatReport(filtered)}`;
997
1604
  return { text, exitCode: exitCodeFor(filtered), channel: "stdout" };
998
1605
  }
999
1606
 
1000
1607
  // src/index.ts
1001
- var VERSION = "0.1.0";
1608
+ var VERSION = "0.3.0";
1002
1609
  var HELP = `contextpruner lint \u2014 check your AI-context config against your repo
1003
1610
 
1004
1611
  Usage:
@@ -1006,13 +1613,21 @@ Usage:
1006
1613
 
1007
1614
  Options:
1008
1615
  --config <path> A config to lint (repeatable). Default: auto-detect
1009
- AGENTS.md, CLAUDE.md, GEMINI.md, Copilot, and Cursor rules.
1616
+ AGENTS.md, CLAUDE.md, GEMINI.md, Copilot, and Cursor rules,
1617
+ plus the enforced .cursorignore, .geminiignore,
1618
+ .codeiumignore, and .claude/settings.json.
1010
1619
  --fix Rewrite each config's managed block in place to fix the
1011
- issues. Your own content outside the block is left alone.
1620
+ issues \u2014 enforced files too, via their managed block. Your
1621
+ own content outside the block is left alone.
1012
1622
  --json Emit the full report as JSON.
1013
1623
  -h, --help Show this help.
1014
1624
  -v, --version Show the version.
1015
1625
 
1626
+ Exceptions:
1627
+ \`keep: <path-or-glob>\` lines in a committed .contextpruner file mark paths
1628
+ that must stay readable by agents. Lint expects their covering enforced
1629
+ rules to be absent, and --fix keeps those rules out.
1630
+
1016
1631
  Auth:
1017
1632
  export CONTEXTPRUNER_API_KEY=cp_live_... (create one at
1018
1633
  https://contextpruner.app/account). Only your key is sent \u2014 the file tree
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextpruner",
3
- "version": "0.1.0",
3
+ "version": "0.3.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
  }