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