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.
- package/README.md +9 -1
- package/dist/index.js +114 -16
- 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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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(
|
|
1111
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
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",
|