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.
- package/README.md +26 -4
- package/dist/index.js +804 -189
- 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
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
const
|
|
614
|
-
|
|
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)
|
|
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
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
30
|
+
"esbuild": "^0.25.0"
|
|
31
31
|
}
|
|
32
32
|
}
|