contextpruner 0.1.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 +72 -0
- package/dist/index.js +1066 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# contextpruner
|
|
2
|
+
|
|
3
|
+
Lint your AI-context config files against your repo. `contextpruner lint` checks
|
|
4
|
+
your `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` / Copilot / Cursor rules against the
|
|
5
|
+
files actually in your tree and reports:
|
|
6
|
+
|
|
7
|
+
- **Missing** — junk the config doesn't ignore yet (with what it's costing you),
|
|
8
|
+
- **Dead** — rules that match nothing,
|
|
9
|
+
- **Drift** — rules in one config but not another,
|
|
10
|
+
- **Conflict** — rules that contradict each other.
|
|
11
|
+
|
|
12
|
+
It exits non-zero when it finds issues, so it drops straight into CI or a
|
|
13
|
+
pre-commit hook.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
export CONTEXTPRUNER_API_KEY=cp_live_... # from https://contextpruner.app/account
|
|
19
|
+
npx contextpruner lint
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
contextpruner lint [--config <path>]... [--fix] [--json]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- `--config <path>` — a config to lint (repeatable). Defaults to auto-detecting
|
|
27
|
+
the standard files at the repo root.
|
|
28
|
+
- `--fix` — rewrite each config's managed block in place to fix the issues. Your
|
|
29
|
+
own content outside the block (and the "Exceptions" section) is left alone.
|
|
30
|
+
- `--json` — emit the full report as JSON.
|
|
31
|
+
|
|
32
|
+
**Exit codes:** `0` clean · `1` issues found · `2` usage/auth error.
|
|
33
|
+
|
|
34
|
+
## In CI
|
|
35
|
+
|
|
36
|
+
Fail the build when a config drifts. Save this as
|
|
37
|
+
`.github/workflows/contextpruner-lint.yml` and add your key as the repo secret
|
|
38
|
+
`CONTEXTPRUNER_API_KEY`:
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
name: ContextPruner Lint
|
|
42
|
+
on: [push, pull_request]
|
|
43
|
+
jobs:
|
|
44
|
+
lint:
|
|
45
|
+
runs-on: ubuntu-latest
|
|
46
|
+
steps:
|
|
47
|
+
- uses: actions/checkout@v4
|
|
48
|
+
- uses: actions/setup-node@v4
|
|
49
|
+
with:
|
|
50
|
+
node-version: 20
|
|
51
|
+
- run: npx --yes contextpruner lint
|
|
52
|
+
env:
|
|
53
|
+
CONTEXTPRUNER_API_KEY: ${{ secrets.CONTEXTPRUNER_API_KEY }}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Privacy
|
|
57
|
+
|
|
58
|
+
The pruning engine runs **entirely on your machine** — your file tree never
|
|
59
|
+
leaves it. The only network call is to `/api/license/verify`, which sends just
|
|
60
|
+
your API key to confirm an active subscription.
|
|
61
|
+
|
|
62
|
+
## Development
|
|
63
|
+
|
|
64
|
+
The CLI bundles the app's pure engine at build time:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
npm install # in packages/cli
|
|
68
|
+
npm run build # → dist/index.js
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The published package name is `contextpruner`; the repository's root
|
|
72
|
+
`package.json` is a separate private app that happens to share the name.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1066 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
// src/args.ts
|
|
9
|
+
function parseArgs(argv) {
|
|
10
|
+
const [command, ...rest] = argv;
|
|
11
|
+
if (command === void 0 || command === "--help" || command === "-h") {
|
|
12
|
+
return { command: "help", configs: [], json: false, fix: false };
|
|
13
|
+
}
|
|
14
|
+
if (command === "--version" || command === "-v") {
|
|
15
|
+
return { command: "version", configs: [], json: false, fix: false };
|
|
16
|
+
}
|
|
17
|
+
if (command !== "lint") {
|
|
18
|
+
return {
|
|
19
|
+
command: "unknown",
|
|
20
|
+
configs: [],
|
|
21
|
+
json: false,
|
|
22
|
+
fix: false,
|
|
23
|
+
problem: `unknown command "${command}"`
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const configs = [];
|
|
27
|
+
let json = false;
|
|
28
|
+
let fix = false;
|
|
29
|
+
for (let i = 0; i < rest.length; i++) {
|
|
30
|
+
const arg = rest[i];
|
|
31
|
+
if (arg === "--json") {
|
|
32
|
+
json = true;
|
|
33
|
+
} else if (arg === "--fix") {
|
|
34
|
+
fix = true;
|
|
35
|
+
} else if (arg === "--config") {
|
|
36
|
+
const value = rest[++i];
|
|
37
|
+
if (value === void 0) {
|
|
38
|
+
return { command: "lint", configs, json, fix, problem: "--config needs a path" };
|
|
39
|
+
}
|
|
40
|
+
configs.push(value);
|
|
41
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
42
|
+
return { command: "help", configs: [], json: false, fix: false };
|
|
43
|
+
} else {
|
|
44
|
+
return {
|
|
45
|
+
command: "lint",
|
|
46
|
+
configs,
|
|
47
|
+
json,
|
|
48
|
+
fix,
|
|
49
|
+
problem: `unknown option "${arg}"`
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { command: "lint", configs, json, fix };
|
|
54
|
+
}
|
|
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
|
+
// ../../lib/shared/constants.ts
|
|
140
|
+
var BYTES_PER_TOKEN_RATIO = 0.25;
|
|
141
|
+
var USD_PER_1M_INPUT_TOKENS = 3;
|
|
142
|
+
var PROMPTS_PER_DAY = 50;
|
|
143
|
+
var WORKING_DAYS_PER_MONTH = 20;
|
|
144
|
+
var PROMPTS_PER_MONTH = PROMPTS_PER_DAY * WORKING_DAYS_PER_MONTH;
|
|
145
|
+
var CONTEXT_SLICE_FACTOR = 0.05;
|
|
146
|
+
var DEFAULT_CONTEXT_WINDOW_TOKENS = 1e6;
|
|
147
|
+
var MONTHLY_SAVINGS_CAP_USD = 200;
|
|
148
|
+
var MAX_MANIFEST_FILES = 2e4;
|
|
149
|
+
var OVERSIZED_FILE_BYTES = 1048576;
|
|
150
|
+
|
|
151
|
+
// ../../lib/engine/rules.ts
|
|
152
|
+
var LOCKFILE_BASENAMES = /* @__PURE__ */ new Set([
|
|
153
|
+
"package-lock.json",
|
|
154
|
+
"npm-shrinkwrap.json",
|
|
155
|
+
"pnpm-lock.yaml",
|
|
156
|
+
"yarn.lock",
|
|
157
|
+
"bun.lockb",
|
|
158
|
+
"bun.lock",
|
|
159
|
+
"deno.lock",
|
|
160
|
+
"composer.lock",
|
|
161
|
+
"Gemfile.lock",
|
|
162
|
+
"poetry.lock",
|
|
163
|
+
"uv.lock",
|
|
164
|
+
"pdm.lock",
|
|
165
|
+
"Pipfile.lock",
|
|
166
|
+
"Cargo.lock",
|
|
167
|
+
"go.sum",
|
|
168
|
+
"flake.lock",
|
|
169
|
+
"packages.lock.json",
|
|
170
|
+
"gradle.lockfile"
|
|
171
|
+
]);
|
|
172
|
+
var PRUNE_DIR_SEGMENT_REASONS = /* @__PURE__ */ new Map([
|
|
173
|
+
["node_modules", "dependency-dir"],
|
|
174
|
+
["vendor", "vendored-deps"],
|
|
175
|
+
[".venv", "dependency-dir"],
|
|
176
|
+
["venv", "dependency-dir"],
|
|
177
|
+
["dist", "build-output"],
|
|
178
|
+
["build", "build-output"],
|
|
179
|
+
["out", "build-output"],
|
|
180
|
+
[".next", "build-output"],
|
|
181
|
+
[".nuxt", "build-output"],
|
|
182
|
+
[".output", "build-output"],
|
|
183
|
+
["target", "build-output"],
|
|
184
|
+
["coverage", "test-coverage"],
|
|
185
|
+
[".nyc_output", "test-coverage"],
|
|
186
|
+
["__snapshots__", "test-snapshot"],
|
|
187
|
+
[".git", "vcs-metadata"],
|
|
188
|
+
[".idea", "editor-metadata"],
|
|
189
|
+
[".vscode", "editor-metadata"],
|
|
190
|
+
["__pycache__", "cache"],
|
|
191
|
+
[".pytest_cache", "cache"],
|
|
192
|
+
[".cache", "cache"],
|
|
193
|
+
[".turbo", "cache"],
|
|
194
|
+
["fixtures", "test-fixture"],
|
|
195
|
+
["__fixtures__", "test-fixture"],
|
|
196
|
+
["testdata", "test-fixture"]
|
|
197
|
+
]);
|
|
198
|
+
var PRUNE_BASENAME_REASONS = /* @__PURE__ */ new Map([
|
|
199
|
+
[".DS_Store", "os-metadata"],
|
|
200
|
+
["Thumbs.db", "os-metadata"],
|
|
201
|
+
["LICENSE", "license"],
|
|
202
|
+
["LICENSE.txt", "license"],
|
|
203
|
+
["LICENSE.md", "license"],
|
|
204
|
+
["NOTICE", "license"]
|
|
205
|
+
]);
|
|
206
|
+
var PRUNE_SUFFIX_REASONS = {
|
|
207
|
+
".min.js": "minified",
|
|
208
|
+
".min.css": "minified",
|
|
209
|
+
".map": "source-map",
|
|
210
|
+
".snap": "test-snapshot",
|
|
211
|
+
".log": "log-file",
|
|
212
|
+
".pyc": "compiled-artifact",
|
|
213
|
+
".class": "compiled-artifact",
|
|
214
|
+
".o": "compiled-artifact",
|
|
215
|
+
".obj": "compiled-artifact"
|
|
216
|
+
};
|
|
217
|
+
var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
218
|
+
// images
|
|
219
|
+
"png",
|
|
220
|
+
"jpg",
|
|
221
|
+
"jpeg",
|
|
222
|
+
"gif",
|
|
223
|
+
"webp",
|
|
224
|
+
"avif",
|
|
225
|
+
"ico",
|
|
226
|
+
"bmp",
|
|
227
|
+
"tiff",
|
|
228
|
+
"svg",
|
|
229
|
+
// fonts
|
|
230
|
+
"woff",
|
|
231
|
+
"woff2",
|
|
232
|
+
"ttf",
|
|
233
|
+
"otf",
|
|
234
|
+
"eot",
|
|
235
|
+
// audio / video
|
|
236
|
+
"mp3",
|
|
237
|
+
"wav",
|
|
238
|
+
"ogg",
|
|
239
|
+
"flac",
|
|
240
|
+
"mp4",
|
|
241
|
+
"mov",
|
|
242
|
+
"avi",
|
|
243
|
+
"mkv",
|
|
244
|
+
"webm",
|
|
245
|
+
// archives
|
|
246
|
+
"zip",
|
|
247
|
+
"gz",
|
|
248
|
+
"tgz",
|
|
249
|
+
"bz2",
|
|
250
|
+
"xz",
|
|
251
|
+
"tar",
|
|
252
|
+
"rar",
|
|
253
|
+
"7z",
|
|
254
|
+
"jar",
|
|
255
|
+
"war",
|
|
256
|
+
// binaries / data blobs
|
|
257
|
+
"exe",
|
|
258
|
+
"dll",
|
|
259
|
+
"so",
|
|
260
|
+
"dylib",
|
|
261
|
+
"a",
|
|
262
|
+
"wasm",
|
|
263
|
+
"bin",
|
|
264
|
+
"dat",
|
|
265
|
+
"pdf",
|
|
266
|
+
"psd",
|
|
267
|
+
"sketch",
|
|
268
|
+
"fig",
|
|
269
|
+
"sqlite",
|
|
270
|
+
"db"
|
|
271
|
+
]);
|
|
272
|
+
var SUMMARIZE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
273
|
+
"md",
|
|
274
|
+
"mdx",
|
|
275
|
+
"markdown",
|
|
276
|
+
"rst",
|
|
277
|
+
"txt",
|
|
278
|
+
"adoc"
|
|
279
|
+
]);
|
|
280
|
+
|
|
281
|
+
// ../../lib/engine/classify.ts
|
|
282
|
+
var ENV_EXEMPT_SUFFIXES = [".example", ".sample", ".template"];
|
|
283
|
+
function classify(file) {
|
|
284
|
+
const { path, sizeInBytes } = file;
|
|
285
|
+
const segments = path.split("/");
|
|
286
|
+
const basename = segments[segments.length - 1];
|
|
287
|
+
const lowerBasename = basename.toLowerCase();
|
|
288
|
+
const dotIndex = lowerBasename.lastIndexOf(".");
|
|
289
|
+
const ext = dotIndex > 0 ? lowerBasename.slice(dotIndex + 1) : "";
|
|
290
|
+
const entry = (verdict, reason, glob) => ({ path, verdict, reason, sizeInBytes, glob });
|
|
291
|
+
if (basename.startsWith(".env") && !ENV_EXEMPT_SUFFIXES.some((suffix) => lowerBasename.endsWith(suffix))) {
|
|
292
|
+
return entry("PRUNE", "env-file", "**/.env*");
|
|
293
|
+
}
|
|
294
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
295
|
+
const reason = PRUNE_DIR_SEGMENT_REASONS.get(segments[i]);
|
|
296
|
+
if (reason) {
|
|
297
|
+
const glob = i === 0 ? `${segments[0]}/**` : `**/${segments[i]}/**`;
|
|
298
|
+
return entry("PRUNE", reason, glob);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (LOCKFILE_BASENAMES.has(basename)) {
|
|
302
|
+
return entry("PRUNE", "lockfile", segments.length === 1 ? path : `**/${basename}`);
|
|
303
|
+
}
|
|
304
|
+
const basenameReason = PRUNE_BASENAME_REASONS.get(basename);
|
|
305
|
+
if (basenameReason) {
|
|
306
|
+
return entry("PRUNE", basenameReason, `**/${basename}`);
|
|
307
|
+
}
|
|
308
|
+
for (const [suffix, reason] of Object.entries(PRUNE_SUFFIX_REASONS)) {
|
|
309
|
+
if (lowerBasename.endsWith(suffix) && lowerBasename !== suffix) {
|
|
310
|
+
return entry("PRUNE", reason, `**/*${suffix}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (BINARY_EXTENSIONS.has(ext)) {
|
|
314
|
+
return entry("PRUNE", "binary-asset", `**/*.${ext}`);
|
|
315
|
+
}
|
|
316
|
+
if (sizeInBytes > OVERSIZED_FILE_BYTES) {
|
|
317
|
+
return entry("PRUNE", "oversized", path);
|
|
318
|
+
}
|
|
319
|
+
if (SUMMARIZE_EXTENSIONS.has(ext)) {
|
|
320
|
+
return entry("SUMMARIZE", "docs", `**/*.${ext}`);
|
|
321
|
+
}
|
|
322
|
+
return entry("KEEP", "source", null);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ../../lib/engine/generators/agentsMd.ts
|
|
326
|
+
function generateAgentsMd(input) {
|
|
327
|
+
return generateMarkdownRules("AGENTS.md", input);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ../../lib/engine/generators/claudeMd.ts
|
|
331
|
+
function generateClaudeMd(input) {
|
|
332
|
+
return generateMarkdownRules("CLAUDE.md", input);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ../../lib/engine/generators/copilotInstructions.ts
|
|
336
|
+
function generateCopilotInstructions(input) {
|
|
337
|
+
return generateMarkdownRules(".github/copilot-instructions.md", input);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ../../lib/engine/generators/cursorMdc.ts
|
|
341
|
+
function generateCursorMdc(input) {
|
|
342
|
+
const frontmatter = [
|
|
343
|
+
"---",
|
|
344
|
+
"description: Repo context-pruning rules generated by ContextPruner",
|
|
345
|
+
"alwaysApply: true",
|
|
346
|
+
"---",
|
|
347
|
+
""
|
|
348
|
+
].join("\n");
|
|
349
|
+
return frontmatter + generateMarkdownRules("Cursor rules", input);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ../../lib/engine/generators/geminiMd.ts
|
|
353
|
+
function generateGeminiMd(input) {
|
|
354
|
+
return generateMarkdownRules("GEMINI.md", input);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ../../lib/engine/savings.ts
|
|
358
|
+
function computeSavings(bytesTotal, bytesKept, options = {}) {
|
|
359
|
+
const price = options.usdPer1MInputTokens ?? USD_PER_1M_INPUT_TOKENS;
|
|
360
|
+
const promptsPerMonth = options.promptsPerMonth ?? PROMPTS_PER_MONTH;
|
|
361
|
+
const sliceFactor = options.contextSliceFactor ?? CONTEXT_SLICE_FACTOR;
|
|
362
|
+
const cap = options.monthlySpendCapUsd === void 0 ? MONTHLY_SAVINGS_CAP_USD : options.monthlySpendCapUsd;
|
|
363
|
+
const windowTokens = options.contextWindowTokens ?? DEFAULT_CONTEXT_WINDOW_TOKENS;
|
|
364
|
+
const bytesPruned = Math.max(0, bytesTotal - bytesKept);
|
|
365
|
+
const tokensSaved = Math.round(bytesPruned * BYTES_PER_TOKEN_RATIO);
|
|
366
|
+
const windowClamped = tokensSaved > windowTokens;
|
|
367
|
+
const tokensWastedPerPrompt = Math.min(tokensSaved, windowTokens) * sliceFactor;
|
|
368
|
+
const usdPerPrompt = tokensWastedPerPrompt / 1e6 * price;
|
|
369
|
+
const usdSavedPerMonthUncapped = usdPerPrompt * promptsPerMonth;
|
|
370
|
+
const capApplied = cap !== null && usdSavedPerMonthUncapped > cap;
|
|
371
|
+
const usdSavedPerMonth = Math.min(usdSavedPerMonthUncapped, cap ?? Infinity);
|
|
372
|
+
const pctReduction = bytesTotal === 0 ? 0 : bytesPruned / bytesTotal;
|
|
373
|
+
return {
|
|
374
|
+
bytesPruned,
|
|
375
|
+
tokensSaved,
|
|
376
|
+
tokensWastedPerPrompt,
|
|
377
|
+
usdPerPrompt,
|
|
378
|
+
usdSavedPerMonth,
|
|
379
|
+
usdSavedPerMonthUncapped,
|
|
380
|
+
capApplied,
|
|
381
|
+
windowClamped,
|
|
382
|
+
pctReduction
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ../../lib/engine/prune.ts
|
|
387
|
+
var MAX_PRIORITY_GLOBS = 5;
|
|
388
|
+
function prune(files, options = {}) {
|
|
389
|
+
const overrides = options.verdictOverrides ?? {};
|
|
390
|
+
const classified = files.map((file) => {
|
|
391
|
+
const base = classify(file);
|
|
392
|
+
const pinned = Object.hasOwn(overrides, file.path) ? overrides[file.path] : void 0;
|
|
393
|
+
if (pinned === void 0 || pinned === base.verdict) {
|
|
394
|
+
return base;
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
...base,
|
|
398
|
+
verdict: pinned,
|
|
399
|
+
glob: null,
|
|
400
|
+
overridden: true
|
|
401
|
+
};
|
|
402
|
+
});
|
|
403
|
+
let bytesTotal = 0;
|
|
404
|
+
let bytesKept = 0;
|
|
405
|
+
let filesPruned = 0;
|
|
406
|
+
const excludeGlobs = /* @__PURE__ */ new Set();
|
|
407
|
+
const summarizeGlobs = /* @__PURE__ */ new Set();
|
|
408
|
+
const keptBytesByDir = /* @__PURE__ */ new Map();
|
|
409
|
+
for (const file of classified) {
|
|
410
|
+
bytesTotal += file.sizeInBytes;
|
|
411
|
+
if (file.verdict === "PRUNE") {
|
|
412
|
+
filesPruned++;
|
|
413
|
+
if (file.glob) excludeGlobs.add(file.glob);
|
|
414
|
+
} else {
|
|
415
|
+
bytesKept += file.sizeInBytes;
|
|
416
|
+
if (file.verdict === "SUMMARIZE") {
|
|
417
|
+
if (file.glob) summarizeGlobs.add(file.glob);
|
|
418
|
+
} else if (!file.overridden) {
|
|
419
|
+
const slash = file.path.indexOf("/");
|
|
420
|
+
if (slash > 0) {
|
|
421
|
+
const dir = file.path.slice(0, slash);
|
|
422
|
+
keptBytesByDir.set(
|
|
423
|
+
dir,
|
|
424
|
+
(keptBytesByDir.get(dir) ?? 0) + file.sizeInBytes
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const savings = computeSavings(bytesTotal, bytesKept, options);
|
|
431
|
+
const pinnedSavings = computeSavings(bytesTotal, bytesKept, {
|
|
432
|
+
usdPer1MInputTokens: options.usdPer1MInputTokens,
|
|
433
|
+
monthlySpendCapUsd: options.monthlySpendCapUsd,
|
|
434
|
+
contextWindowTokens: options.contextWindowTokens
|
|
435
|
+
});
|
|
436
|
+
const stats = {
|
|
437
|
+
filesTotal: classified.length,
|
|
438
|
+
filesKept: classified.length - filesPruned,
|
|
439
|
+
filesPruned,
|
|
440
|
+
bytesTotal,
|
|
441
|
+
bytesKept,
|
|
442
|
+
bytesPruned: savings.bytesPruned,
|
|
443
|
+
tokensSaved: savings.tokensSaved,
|
|
444
|
+
junkTokensPerPrompt: Math.round(pinnedSavings.tokensWastedPerPrompt),
|
|
445
|
+
dollarsPerPrompt: Math.round(pinnedSavings.usdPerPrompt * 1e4) / 1e4,
|
|
446
|
+
dollarsSavedPerMonth: Math.round(pinnedSavings.usdSavedPerMonth * 100) / 100,
|
|
447
|
+
dollarsSavedPerMonthUncapped: Math.round(pinnedSavings.usdSavedPerMonthUncapped * 100) / 100,
|
|
448
|
+
savingsCapped: pinnedSavings.capApplied,
|
|
449
|
+
windowClamped: pinnedSavings.windowClamped
|
|
450
|
+
};
|
|
451
|
+
for (const glob of options.extraExcludeGlobs ?? []) excludeGlobs.add(glob);
|
|
452
|
+
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
|
+
const generatorInput = {
|
|
454
|
+
generatedAt: (options.now ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
455
|
+
stats,
|
|
456
|
+
excludeGlobs: [...excludeGlobs].sort(),
|
|
457
|
+
summarizeGlobs: [...summarizeGlobs].sort(),
|
|
458
|
+
priorityGlobs,
|
|
459
|
+
keepOverridePaths: classified.filter((f) => f.overridden && f.verdict === "KEEP").map((f) => f.path).sort(),
|
|
460
|
+
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()
|
|
462
|
+
};
|
|
463
|
+
const verdicts = classified.map(
|
|
464
|
+
({ path, verdict, reason, sizeInBytes, overridden }) => ({
|
|
465
|
+
path,
|
|
466
|
+
verdict,
|
|
467
|
+
reason,
|
|
468
|
+
sizeInBytes,
|
|
469
|
+
...overridden ? { overridden: true } : {}
|
|
470
|
+
})
|
|
471
|
+
);
|
|
472
|
+
return {
|
|
473
|
+
version: 2,
|
|
474
|
+
generatedAt: generatorInput.generatedAt,
|
|
475
|
+
stats,
|
|
476
|
+
verdicts,
|
|
477
|
+
artifacts: {
|
|
478
|
+
agentsMd: generateAgentsMd(generatorInput),
|
|
479
|
+
claudeMd: generateClaudeMd(generatorInput),
|
|
480
|
+
cursorMdc: generateCursorMdc(generatorInput),
|
|
481
|
+
copilotInstructionsMd: generateCopilotInstructions(generatorInput),
|
|
482
|
+
geminiMd: generateGeminiMd(generatorInput)
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ../../lib/engine/lintFix.ts
|
|
488
|
+
var ARTIFACT_BY_TITLE = {
|
|
489
|
+
"AGENTS.md": "agentsMd",
|
|
490
|
+
"CLAUDE.md": "claudeMd",
|
|
491
|
+
"GEMINI.md": "geminiMd",
|
|
492
|
+
".github/copilot-instructions.md": "copilotInstructionsMd",
|
|
493
|
+
"Cursor rules": "cursorMdc"
|
|
494
|
+
};
|
|
495
|
+
var TITLE_RE = /^# (.+?) — AI context rules/m;
|
|
496
|
+
function detectArtifact(block) {
|
|
497
|
+
const match = TITLE_RE.exec(block);
|
|
498
|
+
return match && ARTIFACT_BY_TITLE[match[1]] || "agentsMd";
|
|
499
|
+
}
|
|
500
|
+
function managedBlock(source) {
|
|
501
|
+
const begin = source.indexOf(MANAGED_BLOCK_BEGIN);
|
|
502
|
+
if (begin === -1) return null;
|
|
503
|
+
const endMarker = source.indexOf(
|
|
504
|
+
MANAGED_BLOCK_END,
|
|
505
|
+
begin + MANAGED_BLOCK_BEGIN.length
|
|
506
|
+
);
|
|
507
|
+
if (endMarker === -1) return null;
|
|
508
|
+
return source.slice(begin, endMarker + MANAGED_BLOCK_END.length);
|
|
509
|
+
}
|
|
510
|
+
function buildFixedConfig(files, userConfig, now, preserveExcludeGlobs) {
|
|
511
|
+
const userBlock = managedBlock(userConfig);
|
|
512
|
+
if (userBlock === null) {
|
|
513
|
+
return { fixable: false, changed: false, fixed: userConfig };
|
|
514
|
+
}
|
|
515
|
+
const artifact = detectArtifact(userBlock);
|
|
516
|
+
const corrected = prune(files, {
|
|
517
|
+
...now ? { now } : {},
|
|
518
|
+
extraExcludeGlobs: preserveExcludeGlobs
|
|
519
|
+
}).artifacts[artifact];
|
|
520
|
+
const canonicalBlock = managedBlock(corrected);
|
|
521
|
+
if (canonicalBlock === null) {
|
|
522
|
+
return { fixable: false, changed: false, fixed: userConfig };
|
|
523
|
+
}
|
|
524
|
+
const begin = userConfig.indexOf(MANAGED_BLOCK_BEGIN);
|
|
525
|
+
const end = userConfig.indexOf(MANAGED_BLOCK_END, begin + MANAGED_BLOCK_BEGIN.length) + MANAGED_BLOCK_END.length;
|
|
526
|
+
const fixed = userConfig.slice(0, begin) + canonicalBlock + userConfig.slice(end);
|
|
527
|
+
return { fixable: true, changed: fixed !== userConfig, fixed };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ../../lib/engine/parseConfig.ts
|
|
531
|
+
var GLOB_BULLET = /^- `(.+)`$/;
|
|
532
|
+
var OVERRIDE_BULLET = /^- (Keep|Skim|Prune) \([^)]*\): `(.+)`$/;
|
|
533
|
+
var SECTION_HEADINGS = {
|
|
534
|
+
"## Ignore": "ignore",
|
|
535
|
+
"## Skim": "skim",
|
|
536
|
+
"## Focus": "focus",
|
|
537
|
+
"## Overrides": "overrides"
|
|
538
|
+
};
|
|
539
|
+
function empty(hasManagedBlock) {
|
|
540
|
+
return {
|
|
541
|
+
hasManagedBlock,
|
|
542
|
+
excludeGlobs: [],
|
|
543
|
+
summarizeGlobs: [],
|
|
544
|
+
priorityGlobs: [],
|
|
545
|
+
overrides: { keep: [], skim: [], prune: [] }
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
function parseConfig(source) {
|
|
549
|
+
const begin = source.indexOf(MANAGED_BLOCK_BEGIN);
|
|
550
|
+
if (begin === -1) return empty(false);
|
|
551
|
+
const end = source.indexOf(MANAGED_BLOCK_END, begin + MANAGED_BLOCK_BEGIN.length);
|
|
552
|
+
if (end === -1) return empty(false);
|
|
553
|
+
const config = empty(true);
|
|
554
|
+
const block = source.slice(begin + MANAGED_BLOCK_BEGIN.length, end);
|
|
555
|
+
let section = null;
|
|
556
|
+
for (const raw of block.split("\n")) {
|
|
557
|
+
const line = raw.trimEnd();
|
|
558
|
+
const heading = SECTION_HEADINGS[line];
|
|
559
|
+
if (heading) {
|
|
560
|
+
section = heading;
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
if (line.startsWith("## ")) {
|
|
564
|
+
section = null;
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
if (section === null) continue;
|
|
568
|
+
if (section === "overrides") {
|
|
569
|
+
const match2 = OVERRIDE_BULLET.exec(line);
|
|
570
|
+
if (!match2) continue;
|
|
571
|
+
const bucket = match2[1] === "Keep" ? "keep" : match2[1] === "Skim" ? "skim" : "prune";
|
|
572
|
+
config.overrides[bucket].push(match2[2]);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
const match = GLOB_BULLET.exec(line);
|
|
576
|
+
if (!match) continue;
|
|
577
|
+
const glob = match[1];
|
|
578
|
+
if (section === "ignore") config.excludeGlobs.push(glob);
|
|
579
|
+
else if (section === "skim") config.summarizeGlobs.push(glob);
|
|
580
|
+
else config.priorityGlobs.push(glob);
|
|
581
|
+
}
|
|
582
|
+
return config;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ../../lib/engine/globMatch.ts
|
|
586
|
+
var ESCAPE = /[.+^${}()|[\]\\]/g;
|
|
587
|
+
function globToRegExp(glob) {
|
|
588
|
+
let re = "";
|
|
589
|
+
for (let i = 0; i < glob.length; i++) {
|
|
590
|
+
const c = glob[i];
|
|
591
|
+
if (c === "*") {
|
|
592
|
+
if (glob[i + 1] === "*") {
|
|
593
|
+
const atSegmentStart = i === 0 || glob[i - 1] === "/";
|
|
594
|
+
if (glob[i + 2] === "/" && atSegmentStart) {
|
|
595
|
+
re += "(?:[^/]+/)*";
|
|
596
|
+
i += 2;
|
|
597
|
+
} else {
|
|
598
|
+
re += ".*";
|
|
599
|
+
i += 1;
|
|
600
|
+
}
|
|
601
|
+
} else {
|
|
602
|
+
re += "[^/]*";
|
|
603
|
+
}
|
|
604
|
+
} else if (c === "?") {
|
|
605
|
+
re += "[^/]";
|
|
606
|
+
} else {
|
|
607
|
+
re += c.replace(ESCAPE, "\\$&");
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return new RegExp(`^${re}$`);
|
|
611
|
+
}
|
|
612
|
+
function compileGlob(glob) {
|
|
613
|
+
const re = globToRegExp(glob);
|
|
614
|
+
return (path) => re.test(path);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ../../lib/engine/reconcile.ts
|
|
618
|
+
var SEVERITY_RANK = { high: 0, medium: 1, low: 2 };
|
|
619
|
+
function gradeFor(coveragePct) {
|
|
620
|
+
if (coveragePct >= 95) return "A";
|
|
621
|
+
if (coveragePct >= 85) return "B";
|
|
622
|
+
if (coveragePct >= 70) return "C";
|
|
623
|
+
if (coveragePct >= 50) return "D";
|
|
624
|
+
return "F";
|
|
625
|
+
}
|
|
626
|
+
function tokensFor(bytes) {
|
|
627
|
+
return Math.round(bytes * BYTES_PER_TOKEN_RATIO);
|
|
628
|
+
}
|
|
629
|
+
function compileConfig({ name, source }) {
|
|
630
|
+
const parsed = parseConfig(source);
|
|
631
|
+
const excludeMatchers = parsed.excludeGlobs.map(compileGlob);
|
|
632
|
+
const summarizeMatchers = parsed.summarizeGlobs.map(compileGlob);
|
|
633
|
+
return {
|
|
634
|
+
...parsed,
|
|
635
|
+
name,
|
|
636
|
+
excludeMatch: (path) => excludeMatchers.some((m) => m(path)),
|
|
637
|
+
summarizeMatch: (path) => summarizeMatchers.some((m) => m(path)),
|
|
638
|
+
keepSet: new Set(parsed.overrides.keep),
|
|
639
|
+
pruneSet: new Set(parsed.overrides.prune),
|
|
640
|
+
skimSet: new Set(parsed.overrides.skim)
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
function isAddressed(config, path) {
|
|
644
|
+
return config.keepSet.has(path) || config.pruneSet.has(path) || config.skimSet.has(path) || config.excludeMatch(path) || config.summarizeMatch(path);
|
|
645
|
+
}
|
|
646
|
+
function reconcile(files, configs, options = {}) {
|
|
647
|
+
const primary = configs[0];
|
|
648
|
+
const empty2 = {
|
|
649
|
+
configName: primary?.name ?? "",
|
|
650
|
+
configRecognized: false,
|
|
651
|
+
filesTotal: files.length,
|
|
652
|
+
grade: "\u2014",
|
|
653
|
+
coveragePct: 0,
|
|
654
|
+
dollarsLeaking: 0,
|
|
655
|
+
findings: []
|
|
656
|
+
};
|
|
657
|
+
if (!primary) return empty2;
|
|
658
|
+
const config = compileConfig(primary);
|
|
659
|
+
if (!config.hasManagedBlock) return empty2;
|
|
660
|
+
const classified = files.map((file) => ({ ...classify(file) }));
|
|
661
|
+
const paths = files.map((f) => f.path);
|
|
662
|
+
const bytesTotal = files.reduce((sum, f) => sum + f.sizeInBytes, 0);
|
|
663
|
+
const findings = [];
|
|
664
|
+
const missingBytesByGlob = /* @__PURE__ */ new Map();
|
|
665
|
+
for (const file of classified) {
|
|
666
|
+
if (file.verdict === "KEEP") continue;
|
|
667
|
+
if (isAddressed(config, file.path)) continue;
|
|
668
|
+
const glob = file.glob ?? file.path;
|
|
669
|
+
const entry = missingBytesByGlob.get(glob) ?? {
|
|
670
|
+
verdict: file.verdict,
|
|
671
|
+
bytes: 0
|
|
672
|
+
};
|
|
673
|
+
entry.bytes += file.sizeInBytes;
|
|
674
|
+
missingBytesByGlob.set(glob, entry);
|
|
675
|
+
}
|
|
676
|
+
for (const [glob, { verdict, bytes }] of missingBytesByGlob) {
|
|
677
|
+
const tokens2 = tokensFor(bytes);
|
|
678
|
+
const severity = verdict === "SUMMARIZE" ? "low" : tokens2 >= 1e4 ? "high" : "medium";
|
|
679
|
+
findings.push({
|
|
680
|
+
kind: "missing",
|
|
681
|
+
severity,
|
|
682
|
+
glob,
|
|
683
|
+
tokens: tokens2,
|
|
684
|
+
message: verdict === "SUMMARIZE" ? `Docs matching \`${glob}\` aren't marked to skim` : `Junk matching \`${glob}\` isn't ignored`
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
const allConfigGlobs = [
|
|
688
|
+
...config.excludeGlobs,
|
|
689
|
+
...config.summarizeGlobs,
|
|
690
|
+
...config.priorityGlobs
|
|
691
|
+
];
|
|
692
|
+
for (const glob of allConfigGlobs) {
|
|
693
|
+
if (!paths.some(compileGlob(glob))) {
|
|
694
|
+
findings.push({
|
|
695
|
+
kind: "dead",
|
|
696
|
+
severity: "low",
|
|
697
|
+
glob,
|
|
698
|
+
message: `Rule \`${glob}\` matches no files`
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
const bucketsByPath = /* @__PURE__ */ new Map();
|
|
703
|
+
for (const [bucket, list] of [
|
|
704
|
+
["Keep", config.overrides.keep],
|
|
705
|
+
["Skim", config.overrides.skim],
|
|
706
|
+
["Prune", config.overrides.prune]
|
|
707
|
+
]) {
|
|
708
|
+
for (const path of list) {
|
|
709
|
+
const seen = bucketsByPath.get(path) ?? [];
|
|
710
|
+
seen.push(bucket);
|
|
711
|
+
bucketsByPath.set(path, seen);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
for (const [path, buckets] of bucketsByPath) {
|
|
715
|
+
if (buckets.length > 1) {
|
|
716
|
+
findings.push({
|
|
717
|
+
kind: "conflict",
|
|
718
|
+
severity: "medium",
|
|
719
|
+
path,
|
|
720
|
+
message: `\`${path}\` is pinned to conflicting verdicts (${buckets.join(" + ")})`
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
const excludeSet = new Set(config.excludeGlobs);
|
|
725
|
+
for (const glob of config.priorityGlobs) {
|
|
726
|
+
if (excludeSet.has(glob)) {
|
|
727
|
+
findings.push({
|
|
728
|
+
kind: "conflict",
|
|
729
|
+
severity: "medium",
|
|
730
|
+
glob,
|
|
731
|
+
message: `\`${glob}\` is both a Focus and an Ignore rule`
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
for (let i = 1; i < configs.length; i++) {
|
|
736
|
+
const other = compileConfig(configs[i]);
|
|
737
|
+
if (!other.hasManagedBlock) continue;
|
|
738
|
+
for (const [label, a, b] of [
|
|
739
|
+
["Ignore", config.excludeGlobs, other.excludeGlobs],
|
|
740
|
+
["Skim", config.summarizeGlobs, other.summarizeGlobs],
|
|
741
|
+
["Focus", config.priorityGlobs, other.priorityGlobs]
|
|
742
|
+
]) {
|
|
743
|
+
const bSet = new Set(b);
|
|
744
|
+
const aSet = new Set(a);
|
|
745
|
+
for (const glob of a) {
|
|
746
|
+
if (!bSet.has(glob)) {
|
|
747
|
+
findings.push({
|
|
748
|
+
kind: "drift",
|
|
749
|
+
severity: "medium",
|
|
750
|
+
glob,
|
|
751
|
+
message: `${label} \`${glob}\` is in ${config.name} but not ${other.name}`
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
for (const glob of b) {
|
|
756
|
+
if (!aSet.has(glob)) {
|
|
757
|
+
findings.push({
|
|
758
|
+
kind: "drift",
|
|
759
|
+
severity: "medium",
|
|
760
|
+
glob,
|
|
761
|
+
message: `${label} \`${glob}\` is in ${other.name} but not ${config.name}`
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
let junkBytes = 0;
|
|
768
|
+
let prunedBytes = 0;
|
|
769
|
+
for (const file of classified) {
|
|
770
|
+
if (file.verdict !== "PRUNE") continue;
|
|
771
|
+
if (config.keepSet.has(file.path)) continue;
|
|
772
|
+
junkBytes += file.sizeInBytes;
|
|
773
|
+
if (config.pruneSet.has(file.path) || config.excludeMatch(file.path)) {
|
|
774
|
+
prunedBytes += file.sizeInBytes;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const coveragePct = junkBytes === 0 ? 100 : Math.floor(prunedBytes / junkBytes * 100);
|
|
778
|
+
const full = computeSavings(bytesTotal, bytesTotal - junkBytes, options).usdSavedPerMonth;
|
|
779
|
+
const achieved = computeSavings(bytesTotal, bytesTotal - prunedBytes, options).usdSavedPerMonth;
|
|
780
|
+
const dollarsLeaking = Math.max(0, Math.floor((full - achieved) * 100) / 100);
|
|
781
|
+
findings.sort(
|
|
782
|
+
(a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.kind.localeCompare(b.kind) || (a.glob ?? "").localeCompare(b.glob ?? "")
|
|
783
|
+
);
|
|
784
|
+
return {
|
|
785
|
+
configName: config.name,
|
|
786
|
+
configRecognized: true,
|
|
787
|
+
filesTotal: files.length,
|
|
788
|
+
grade: gradeFor(coveragePct),
|
|
789
|
+
coveragePct,
|
|
790
|
+
dollarsLeaking,
|
|
791
|
+
findings
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// src/format.ts
|
|
796
|
+
var KIND = {
|
|
797
|
+
missing: { symbol: "\u2717", label: "MISSING" },
|
|
798
|
+
conflict: { symbol: "\u2717", label: "CONFLICT" },
|
|
799
|
+
dead: { symbol: "\u26A0", label: "DEAD" },
|
|
800
|
+
drift: { symbol: "~", label: "DRIFT" }
|
|
801
|
+
};
|
|
802
|
+
function tokens(n) {
|
|
803
|
+
return n >= 1e3 ? `~${Math.round(n / 1e3)}k tokens` : `~${n} tokens`;
|
|
804
|
+
}
|
|
805
|
+
function exitCodeFor(report) {
|
|
806
|
+
if (!report.configRecognized) return 2;
|
|
807
|
+
return report.findings.length > 0 ? 1 : 0;
|
|
808
|
+
}
|
|
809
|
+
function formatReport(report) {
|
|
810
|
+
const lines = ["", `ContextPruner \u2014 ${report.configName}`, ""];
|
|
811
|
+
if (!report.configRecognized) {
|
|
812
|
+
lines.push(
|
|
813
|
+
" This isn't a ContextPruner-generated config (no managed block).",
|
|
814
|
+
" Generate one at https://contextpruner.app.",
|
|
815
|
+
""
|
|
816
|
+
);
|
|
817
|
+
return lines.join("\n");
|
|
818
|
+
}
|
|
819
|
+
if (report.findings.length === 0) {
|
|
820
|
+
lines.push(" \u2713 No issues \u2014 this config matches the tree.");
|
|
821
|
+
} else {
|
|
822
|
+
for (const f of report.findings) {
|
|
823
|
+
const { symbol, label } = KIND[f.kind];
|
|
824
|
+
const suffix = f.tokens ? ` (${tokens(f.tokens)})` : "";
|
|
825
|
+
lines.push(` ${symbol} ${label.padEnd(9)}${f.message}${suffix}`);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
const dollars = report.dollarsLeaking > 0 ? ` \xB7 ~$${report.dollarsLeaking.toFixed(2)}/mo leaking` : "";
|
|
829
|
+
lines.push(
|
|
830
|
+
"",
|
|
831
|
+
` Grade ${report.grade} \xB7 ${report.coveragePct}% of junk covered${dollars} \xB7 ${report.filesTotal.toLocaleString("en-US")} files`,
|
|
832
|
+
""
|
|
833
|
+
);
|
|
834
|
+
return lines.join("\n");
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// src/manifest.ts
|
|
838
|
+
function buildManifest(deps) {
|
|
839
|
+
const paths = deps.listTrackedFiles();
|
|
840
|
+
const truncated = paths.length > MAX_MANIFEST_FILES;
|
|
841
|
+
const files = [];
|
|
842
|
+
for (const path of paths.slice(0, MAX_MANIFEST_FILES)) {
|
|
843
|
+
let size;
|
|
844
|
+
try {
|
|
845
|
+
size = deps.sizeOf(path);
|
|
846
|
+
} catch {
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
if (Number.isFinite(size)) files.push({ path, sizeInBytes: size });
|
|
850
|
+
}
|
|
851
|
+
return { files, truncated };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// src/verify.ts
|
|
855
|
+
var ACCOUNT = "https://contextpruner.app/account";
|
|
856
|
+
async function verifyKey(opts) {
|
|
857
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
858
|
+
let res;
|
|
859
|
+
try {
|
|
860
|
+
res = await doFetch(`${opts.baseUrl}/api/license/verify`, {
|
|
861
|
+
method: "POST",
|
|
862
|
+
headers: { authorization: `Bearer ${opts.key}` }
|
|
863
|
+
});
|
|
864
|
+
} catch {
|
|
865
|
+
return {
|
|
866
|
+
ok: false,
|
|
867
|
+
kind: "error",
|
|
868
|
+
message: "Couldn't reach ContextPruner to verify your key (network error)."
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
if (res.status === 200) return { ok: true };
|
|
872
|
+
if (res.status === 401) {
|
|
873
|
+
return {
|
|
874
|
+
ok: false,
|
|
875
|
+
kind: "invalid_key",
|
|
876
|
+
message: `Invalid API key. Create or rotate one at ${ACCOUNT}.`
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
if (res.status === 402) {
|
|
880
|
+
return {
|
|
881
|
+
ok: false,
|
|
882
|
+
kind: "inactive",
|
|
883
|
+
message: `Subscription inactive. Manage billing at ${ACCOUNT}.`
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
if (res.status === 429) {
|
|
887
|
+
const retryAfter = res.headers.get("retry-after") ?? await res.json().then((b) => b.retryAfter).catch(() => void 0) ?? 60;
|
|
888
|
+
return {
|
|
889
|
+
ok: false,
|
|
890
|
+
kind: "rate_limited",
|
|
891
|
+
message: `Rate limited \u2014 retry in ${retryAfter}s.`
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
return {
|
|
895
|
+
ok: false,
|
|
896
|
+
kind: "error",
|
|
897
|
+
message: `Key verification failed (HTTP ${res.status}).`
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// src/run.ts
|
|
902
|
+
var DEFAULT_CONFIG_PATHS = [
|
|
903
|
+
"AGENTS.md",
|
|
904
|
+
"CLAUDE.md",
|
|
905
|
+
"GEMINI.md",
|
|
906
|
+
".github/copilot-instructions.md",
|
|
907
|
+
".cursor/rules/contextpruner.mdc"
|
|
908
|
+
];
|
|
909
|
+
var CREATE_KEY = "Set CONTEXTPRUNER_API_KEY (create one at https://contextpruner.app/account).";
|
|
910
|
+
function dirNameOf(glob) {
|
|
911
|
+
return glob.match(/^([^*/]+)\/\*\*$/)?.[1] ?? glob.match(/^\*\*\/([^*/]+)\/\*\*$/)?.[1] ?? null;
|
|
912
|
+
}
|
|
913
|
+
function isFalseDead(finding) {
|
|
914
|
+
if (finding.kind !== "dead" || !finding.glob) return false;
|
|
915
|
+
const dir = dirNameOf(finding.glob);
|
|
916
|
+
return dir !== null && PRUNE_DIR_SEGMENT_REASONS.has(dir);
|
|
917
|
+
}
|
|
918
|
+
function hasRuleChange(before, after) {
|
|
919
|
+
const normalize = (s) => s.replace(/Generated \S+Z\./, "Generated <ts>.");
|
|
920
|
+
return normalize(before) !== normalize(after);
|
|
921
|
+
}
|
|
922
|
+
async function runLint(deps) {
|
|
923
|
+
if (!deps.key) {
|
|
924
|
+
return { text: `No API key. ${CREATE_KEY}`, exitCode: 2, channel: "stderr" };
|
|
925
|
+
}
|
|
926
|
+
const verified = await verifyKey({
|
|
927
|
+
key: deps.key,
|
|
928
|
+
baseUrl: deps.baseUrl,
|
|
929
|
+
fetchImpl: deps.fetchImpl
|
|
930
|
+
});
|
|
931
|
+
if (!verified.ok)
|
|
932
|
+
return { text: verified.message, exitCode: 2, channel: "stderr" };
|
|
933
|
+
const configs = [];
|
|
934
|
+
for (const path of deps.configPaths) {
|
|
935
|
+
const source = deps.readConfig(path);
|
|
936
|
+
if (source !== null) configs.push({ name: path, source });
|
|
937
|
+
}
|
|
938
|
+
if (configs.length === 0) {
|
|
939
|
+
return {
|
|
940
|
+
text: `No config files found (${deps.configPaths.join(", ")}). Generate one at https://contextpruner.app.`,
|
|
941
|
+
exitCode: 2,
|
|
942
|
+
channel: "stderr"
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
const { files, truncated } = buildManifest(deps);
|
|
946
|
+
if (truncated) {
|
|
947
|
+
return {
|
|
948
|
+
text: `This repo has more than ${MAX_MANIFEST_FILES.toLocaleString("en-US")} tracked files. Lint needs the whole tree to be accurate \u2014 check out a narrower path.`,
|
|
949
|
+
exitCode: 2,
|
|
950
|
+
channel: "stderr"
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
if (files.length === 0) {
|
|
954
|
+
return { text: "No tracked files found.", exitCode: 2, channel: "stderr" };
|
|
955
|
+
}
|
|
956
|
+
if (deps.fix) {
|
|
957
|
+
const fixed = [];
|
|
958
|
+
const unfixable = [];
|
|
959
|
+
for (const { name, source } of configs) {
|
|
960
|
+
const spared = parseConfig(source).excludeGlobs.filter((glob) => {
|
|
961
|
+
const dir = dirNameOf(glob);
|
|
962
|
+
return dir !== null && PRUNE_DIR_SEGMENT_REASONS.has(dir);
|
|
963
|
+
});
|
|
964
|
+
const result = buildFixedConfig(files, source, void 0, spared);
|
|
965
|
+
if (!result.fixable) {
|
|
966
|
+
unfixable.push(name);
|
|
967
|
+
} else if (hasRuleChange(source, result.fixed)) {
|
|
968
|
+
deps.writeConfig(name, result.fixed);
|
|
969
|
+
fixed.push(name);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
if (fixed.length > 0) {
|
|
973
|
+
return {
|
|
974
|
+
text: `Fixed ${fixed.join(", ")}. Re-run without --fix to confirm.`,
|
|
975
|
+
exitCode: 0,
|
|
976
|
+
channel: "stdout"
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
if (unfixable.length === configs.length) {
|
|
980
|
+
return {
|
|
981
|
+
text: `Nothing to fix \u2014 ${unfixable.join(", ")} ${unfixable.length === 1 ? "has" : "have"} no ContextPruner managed block.`,
|
|
982
|
+
exitCode: 2,
|
|
983
|
+
channel: "stderr"
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
const skipped = unfixable.length > 0 ? ` Skipped ${unfixable.join(", ")} (no ContextPruner managed block).` : "";
|
|
987
|
+
return {
|
|
988
|
+
text: `Nothing to fix \u2014 your configs already match the tree.${skipped}`,
|
|
989
|
+
exitCode: 0,
|
|
990
|
+
channel: "stdout"
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
const report = reconcile(files, configs);
|
|
994
|
+
const kept = report.findings.filter((f) => !isFalseDead(f));
|
|
995
|
+
const filtered = { ...report, findings: kept };
|
|
996
|
+
const text = deps.json ? JSON.stringify(filtered, null, 2) : formatReport(filtered);
|
|
997
|
+
return { text, exitCode: exitCodeFor(filtered), channel: "stdout" };
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// src/index.ts
|
|
1001
|
+
var VERSION = "0.1.0";
|
|
1002
|
+
var HELP = `contextpruner lint \u2014 check your AI-context config against your repo
|
|
1003
|
+
|
|
1004
|
+
Usage:
|
|
1005
|
+
contextpruner lint [--config <path>]... [--json]
|
|
1006
|
+
|
|
1007
|
+
Options:
|
|
1008
|
+
--config <path> A config to lint (repeatable). Default: auto-detect
|
|
1009
|
+
AGENTS.md, CLAUDE.md, GEMINI.md, Copilot, and Cursor rules.
|
|
1010
|
+
--fix Rewrite each config's managed block in place to fix the
|
|
1011
|
+
issues. Your own content outside the block is left alone.
|
|
1012
|
+
--json Emit the full report as JSON.
|
|
1013
|
+
-h, --help Show this help.
|
|
1014
|
+
-v, --version Show the version.
|
|
1015
|
+
|
|
1016
|
+
Auth:
|
|
1017
|
+
export CONTEXTPRUNER_API_KEY=cp_live_... (create one at
|
|
1018
|
+
https://contextpruner.app/account). Only your key is sent \u2014 the file tree
|
|
1019
|
+
never leaves your machine.
|
|
1020
|
+
|
|
1021
|
+
Exit codes: 0 clean \xB7 1 issues found \xB7 2 usage/auth error.`;
|
|
1022
|
+
async function main() {
|
|
1023
|
+
const args = parseArgs(process.argv.slice(2));
|
|
1024
|
+
if (args.command === "help") {
|
|
1025
|
+
console.log(HELP);
|
|
1026
|
+
return 0;
|
|
1027
|
+
}
|
|
1028
|
+
if (args.command === "version") {
|
|
1029
|
+
console.log(VERSION);
|
|
1030
|
+
return 0;
|
|
1031
|
+
}
|
|
1032
|
+
if (args.command !== "lint" || args.problem) {
|
|
1033
|
+
console.error(`contextpruner: ${args.problem ?? "invalid usage"}
|
|
1034
|
+
|
|
1035
|
+
${HELP}`);
|
|
1036
|
+
return 2;
|
|
1037
|
+
}
|
|
1038
|
+
const cwd = process.cwd();
|
|
1039
|
+
const { text, exitCode, channel } = await runLint({
|
|
1040
|
+
key: process.env.CONTEXTPRUNER_API_KEY,
|
|
1041
|
+
baseUrl: process.env.CONTEXTPRUNER_API_URL ?? "https://contextpruner.app",
|
|
1042
|
+
configPaths: args.configs.length > 0 ? args.configs : DEFAULT_CONFIG_PATHS,
|
|
1043
|
+
json: args.json,
|
|
1044
|
+
fix: args.fix,
|
|
1045
|
+
listTrackedFiles: () => execFileSync("git", ["ls-files", "-z"], {
|
|
1046
|
+
cwd,
|
|
1047
|
+
maxBuffer: 256 * 1024 * 1024
|
|
1048
|
+
}).toString("utf8").split("\0").filter(Boolean),
|
|
1049
|
+
sizeOf: (path) => statSync(join(cwd, path)).size,
|
|
1050
|
+
readConfig: (path) => existsSync(join(cwd, path)) ? readFileSync(join(cwd, path), "utf8") : null,
|
|
1051
|
+
writeConfig: (path, content) => writeFileSync(join(cwd, path), content, "utf8")
|
|
1052
|
+
});
|
|
1053
|
+
if (channel === "stderr") {
|
|
1054
|
+
console.error(text);
|
|
1055
|
+
} else {
|
|
1056
|
+
console.log(text);
|
|
1057
|
+
}
|
|
1058
|
+
return exitCode;
|
|
1059
|
+
}
|
|
1060
|
+
main().then((code) => {
|
|
1061
|
+
process.exitCode = code;
|
|
1062
|
+
}).catch((error) => {
|
|
1063
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1064
|
+
console.error(`contextpruner: ${message}`);
|
|
1065
|
+
process.exitCode = 2;
|
|
1066
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "contextpruner",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"AGENTS.md",
|
|
7
|
+
"CLAUDE.md",
|
|
8
|
+
"cursor rules",
|
|
9
|
+
"ai",
|
|
10
|
+
"context",
|
|
11
|
+
"lint"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://contextpruner.app",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"bin": {
|
|
16
|
+
"contextpruner": "dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "node build.mjs",
|
|
27
|
+
"prepublishOnly": "node build.mjs"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"esbuild": "^0.24.0"
|
|
31
|
+
}
|
|
32
|
+
}
|