contextpruner 0.2.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 +9 -1
  2. package/dist/index.js +114 -16
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -10,7 +10,8 @@ actually in your tree and reports:
10
10
  - **Dead** — rules that match nothing,
11
11
  - **Drift** — rules in one config but not another,
12
12
  - **Conflict** — rules that contradict each other, including an enforced rule
13
- that hard-blocks a file you pinned Keep or Skim.
13
+ that hard-blocks a file you pinned Keep or Skim, or a path you declared
14
+ `keep:` in `.contextpruner`.
14
15
 
15
16
  It exits non-zero when it finds issues, so it drops straight into CI or a
16
17
  pre-commit hook.
@@ -43,6 +44,13 @@ contextpruner lint [--config <path>]... [--fix] [--json]
43
44
  safely.
44
45
  - `--json` — emit the full report as JSON.
45
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
+
46
54
  **Exit codes:** `0` clean · `1` issues found · `2` usage/auth error.
47
55
 
48
56
  ## In CI
package/dist/index.js CHANGED
@@ -63,6 +63,8 @@ var CONTEXT_SLICE_FACTOR = 0.05;
63
63
  var DEFAULT_CONTEXT_WINDOW_TOKENS = 1e6;
64
64
  var MONTHLY_SAVINGS_CAP_USD = 200;
65
65
  var MAX_MANIFEST_FILES = 2e4;
66
+ var MAX_OVERRIDE_DECLARATIONS = 200;
67
+ var MAX_PATH_LENGTH = 1024;
66
68
  var OVERSIZED_FILE_BYTES = 1048576;
67
69
 
68
70
  // ../../lib/engine/rules.ts
@@ -574,6 +576,37 @@ function generateMarkdownRules(filename, input) {
574
576
  return lines.join("\n");
575
577
  }
576
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
+
577
610
  // ../../lib/engine/parseConfig.ts
578
611
  var GLOB_BULLET = /^- `(.+)`$/;
579
612
  var OVERRIDE_BULLET = /^- (Keep|Skim|Prune) \([^)]*\): `(.+)`$/;
@@ -753,7 +786,13 @@ function computeSavings(bytesTotal, bytesKept, options = {}) {
753
786
  // ../../lib/engine/prune.ts
754
787
  var MAX_PRIORITY_GLOBS = 5;
755
788
  function prune(files, options = {}) {
756
- 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
+ };
757
796
  const classified = files.map((file) => {
758
797
  const base = classify(file);
759
798
  const pinned = Object.hasOwn(overrides, file.path) ? overrides[file.path] : void 0;
@@ -816,7 +855,15 @@ function prune(files, options = {}) {
816
855
  windowClamped: pinnedSavings.windowClamped
817
856
  };
818
857
  for (const glob of options.extraExcludeGlobs ?? []) excludeGlobs.add(glob);
819
- const enforced = deriveEnforcedRules(classified);
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
+ };
820
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}/**`);
821
868
  const generatorInput = {
822
869
  generatedAt: (options.now ?? /* @__PURE__ */ new Date()).toISOString(),
@@ -914,11 +961,14 @@ var IGNORE_GENERATOR_BY_TITLE = {
914
961
  ".codeiumignore": generateCodeiumIgnore
915
962
  };
916
963
  var IGNORE_TITLE_RE = /^# (\.[a-z]+ignore) — enforced AI context rules/m;
917
- function buildFixedEnforcedConfig(files, userConfig, format, pins = { keep: [], skim: [] }) {
964
+ function buildFixedEnforcedConfig(files, userConfig, format, pins = { keep: [], skim: [] }, keepDeclarations = []) {
918
965
  const classified = files.map(classify);
919
966
  const enforcedGlobs = demoteEnforcedGlobs(
920
967
  deriveEnforcedRules(classified).globs,
921
- effectivePinnedPaths(classified, pins)
968
+ [
969
+ ...effectivePinnedPaths(classified, pins),
970
+ ...keepDeclarations.flatMap(representativePathsFor)
971
+ ]
922
972
  ).globs;
923
973
  if (format === "claudeSettings") {
924
974
  if (!parseEnforcedConfig(userConfig, "claudeSettings").recognized) {
@@ -983,10 +1033,16 @@ function dialectLine(glob, format) {
983
1033
  function expectedEnforcedGlobs(classified, pinnedPaths) {
984
1034
  return demoteEnforcedGlobs(deriveEnforcedRules(classified).globs, pinnedPaths).globs;
985
1035
  }
986
- function enforcedFindings(classified, paths, enforced, pinnedPaths) {
1036
+ function enforcedFindings(classified, paths, enforced, pinnedPaths, keepDeclarations) {
987
1037
  const findings = [];
988
- const expected = expectedEnforcedGlobs(classified, pinnedPaths);
1038
+ const expected = expectedEnforcedGlobs(classified, [
1039
+ ...pinnedPaths,
1040
+ ...keepDeclarations.flatMap(representativePathsFor)
1041
+ ]);
989
1042
  const expectedSet = new Set(expected);
1043
+ const declarationReps = keepDeclarations.map(
1044
+ (declaration) => [declaration, representativePathsFor(declaration)]
1045
+ );
990
1046
  for (const config of enforced) {
991
1047
  const have = new Set(config.engineGlobs);
992
1048
  for (const glob of expected) {
@@ -1023,6 +1079,17 @@ function enforcedFindings(classified, paths, enforced, pinnedPaths) {
1023
1079
  });
1024
1080
  }
1025
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
+ }
1026
1093
  }
1027
1094
  for (const line of config.foreignLines) {
1028
1095
  findings.push({
@@ -1101,14 +1168,25 @@ function reconcile(files, configs, options = {}) {
1101
1168
  keep: keepPins,
1102
1169
  skim: skimPins
1103
1170
  });
1171
+ const declarations = options.keepDeclarations ?? [];
1172
+ const demotionPaths = [
1173
+ ...pinnedPaths,
1174
+ ...declarations.flatMap(representativePathsFor)
1175
+ ];
1104
1176
  if ((primary.format ?? "markdown") !== "markdown") {
1105
1177
  const parsedPrimary = parseEnforcedConfig(
1106
1178
  primary.source,
1107
1179
  primary.format
1108
1180
  );
1109
1181
  if (!parsedPrimary.recognized) return empty2;
1110
- const findings2 = enforcedFindings(classified, paths, enforced, pinnedPaths);
1111
- const expected = expectedEnforcedGlobs(classified, pinnedPaths);
1182
+ const findings2 = enforcedFindings(
1183
+ classified,
1184
+ paths,
1185
+ enforced,
1186
+ pinnedPaths,
1187
+ declarations
1188
+ );
1189
+ const expected = expectedEnforcedGlobs(classified, demotionPaths);
1112
1190
  const have = new Set(parsedPrimary.engineGlobs);
1113
1191
  const present = expected.filter((glob) => have.has(glob)).length;
1114
1192
  const coveragePct2 = expected.length === 0 ? 100 : Math.floor(present / expected.length * 100);
@@ -1241,7 +1319,9 @@ function reconcile(files, configs, options = {}) {
1241
1319
  }
1242
1320
  }
1243
1321
  }
1244
- findings.push(...enforcedFindings(classified, paths, enforced, pinnedPaths));
1322
+ findings.push(
1323
+ ...enforcedFindings(classified, paths, enforced, pinnedPaths, declarations)
1324
+ );
1245
1325
  let junkBytes = 0;
1246
1326
  let prunedBytes = 0;
1247
1327
  for (const file of classified) {
@@ -1454,6 +1534,11 @@ async function runLint(deps) {
1454
1534
  if (files.length === 0) {
1455
1535
  return { text: "No tracked files found.", exitCode: 2, channel: "stderr" };
1456
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
+ ` : "";
1457
1542
  if (deps.fix) {
1458
1543
  const pins = { keep: [], skim: [] };
1459
1544
  for (const c of configs) {
@@ -1474,7 +1559,13 @@ async function runLint(deps) {
1474
1559
  });
1475
1560
  result = buildFixedConfig(files, source, void 0, spared);
1476
1561
  } else {
1477
- result = buildFixedEnforcedConfig(files, source, format, pins);
1562
+ result = buildFixedEnforcedConfig(
1563
+ files,
1564
+ source,
1565
+ format,
1566
+ pins,
1567
+ parsedOverrides.keep
1568
+ );
1478
1569
  }
1479
1570
  if (!result.fixable) {
1480
1571
  unfixable.push(name);
@@ -1485,34 +1576,36 @@ async function runLint(deps) {
1485
1576
  }
1486
1577
  if (fixed.length > 0) {
1487
1578
  return {
1488
- text: `Fixed ${fixed.join(", ")}. Re-run without --fix to confirm.`,
1579
+ text: `${overridesWarning}Fixed ${fixed.join(", ")}. Re-run without --fix to confirm.`,
1489
1580
  exitCode: 0,
1490
1581
  channel: "stdout"
1491
1582
  };
1492
1583
  }
1493
1584
  if (unfixable.length === configs.length) {
1494
1585
  return {
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).`,
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).`,
1496
1587
  exitCode: 2,
1497
1588
  channel: "stderr"
1498
1589
  };
1499
1590
  }
1500
1591
  const skipped = unfixable.length > 0 ? ` Skipped ${unfixable.join(", ")} (no ContextPruner managed block, or unmergeable).` : "";
1501
1592
  return {
1502
- 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}`,
1503
1594
  exitCode: 0,
1504
1595
  channel: "stdout"
1505
1596
  };
1506
1597
  }
1507
- const report = reconcile(files, configs);
1598
+ const report = reconcile(files, configs, {
1599
+ keepDeclarations: parsedOverrides.keep
1600
+ });
1508
1601
  const kept = report.findings.filter((f) => !isFalseDead(f));
1509
1602
  const filtered = { ...report, findings: kept };
1510
- const text = deps.json ? JSON.stringify(filtered, null, 2) : formatReport(filtered);
1603
+ const text = deps.json ? JSON.stringify(filtered, null, 2) : `${overridesWarning}${formatReport(filtered)}`;
1511
1604
  return { text, exitCode: exitCodeFor(filtered), channel: "stdout" };
1512
1605
  }
1513
1606
 
1514
1607
  // src/index.ts
1515
- var VERSION = "0.2.0";
1608
+ var VERSION = "0.3.0";
1516
1609
  var HELP = `contextpruner lint \u2014 check your AI-context config against your repo
1517
1610
 
1518
1611
  Usage:
@@ -1530,6 +1623,11 @@ Options:
1530
1623
  -h, --help Show this help.
1531
1624
  -v, --version Show the version.
1532
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
+
1533
1631
  Auth:
1534
1632
  export CONTEXTPRUNER_API_KEY=cp_live_... (create one at
1535
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.2.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",