pr-complexity 1.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.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +84 -0
  3. package/cli.js +73 -0
  4. package/lib.mjs +137 -0
  5. package/package.json +29 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sebastian Mellen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # pr-complexity
2
+
3
+ A GitHub Action that scores pull requests by **information content** instead of lines of code. Language-agnostic: JS/TS, Python, Go, Rust, Ruby, Java, C/C++, C#, PHP, Swift, Kotlin, SQL, shell, Dockerfiles — anything text-based.
4
+
5
+ It compresses the diff against the rest of your codebase (brotli). Code that repeats existing patterns — boilerplate, renames, house style — compresses to almost nothing. Genuinely new logic doesn't. Deletions earn symmetric credit. The raw byte count is then mapped onto a log-scale points score approximating the Kolmogorov complexity of the change given the codebase: `K(diff | repo)`.
6
+
7
+ Lockfiles, generated code, and build output are ignored.
8
+
9
+ ## Usage
10
+
11
+ ```yaml
12
+ on:
13
+ pull_request:
14
+
15
+ permissions:
16
+ contents: read
17
+ pull-requests: write
18
+
19
+ jobs:
20
+ complexity:
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+ with:
25
+ fetch-depth: 0
26
+ - uses: sebMellen/pr-complexity@v1
27
+ ```
28
+
29
+ The action posts one comment per PR and updates it on every push.
30
+
31
+ ## CLI (for humans and agents)
32
+
33
+ Zero dependencies, runs anywhere with Node 20+ and git:
34
+
35
+ ```bash
36
+ npx pr-complexity # score uncommitted changes vs HEAD (incl. untracked files)
37
+ npx pr-complexity --base main # score HEAD vs merge-base of main (PR mode)
38
+ npx pr-complexity --json # machine-readable, per-file attribution
39
+ npx pr-complexity --budget 40 # exit 1 if over budget
40
+ ```
41
+
42
+ Because the score is cheap, deterministic, and signed, an agent can optimize against it — implement, score, simplify, repeat. The cheapest way to lower the score is to reuse existing patterns and delete dead code, so gaming it mostly means doing the right thing:
43
+
44
+ ```bash
45
+ while ! pr-complexity --budget 40 --json > score.json; do
46
+ agent "Reduce complexity. Biggest contributors: $(jq -c '.files[:5]' score.json)"
47
+ done
48
+ ```
49
+
50
+ Always constrain on behavior (tests must pass) — the score is a simplicity prior, not proof of correctness.
51
+
52
+ ## Reading the score
53
+
54
+ Points are a **Fahrenheit-style 0–100 scale**: 0 means the PR adds no new information, and 100 is the limit — roughly 16 KB of novel compressed logic, the largest change that should ever land in one PR. Scores are clamped to ±100.
55
+
56
+ **Points = `min(100, 5 · log2(1 + bytes/100)^1.5)`**, signed — deletions push negative. The 1.5 exponent compresses the everyday range (most PRs land under 50) and stretches the top end, so genuinely huge changes still shoot toward the ceiling. On top of that, a **file-scatter penalty** charges for review context-switching: 0 for ≤10 files, `6·log2(files/10)` beyond, capped at +30. Net-negative PRs are exempt — simplification is never taxed.
57
+
58
+ | Points | Band | Meaning |
59
+ |---|---|---|
60
+ | `≤ 0` | net simplifier | Removes more information than it adds. Reward these. |
61
+ | `< 10` | trivial | Typos, config tweaks, one-line fixes. |
62
+ | `< 30` | small | A focused fix or small feature slice. |
63
+ | `< 50` | moderate | Real new logic; review carefully. |
64
+ | `< 70` | large | Heavy new complexity — should earn its keep. |
65
+ | `70–99` | split-worthy | Strongly consider splitting. |
66
+ | `100` | the limit | Too big to review. Split it. |
67
+
68
+ Calibration data from a large production TypeScript API: focused bug fixes score 5–30, feature slices 42–58, major upgrades (Prisma v7, CI reworks) 70–82, and a whole new platform API prototype (~3.3K lines, dozens of files) scores 88. Because the scale is anchored to information content rather than repo size, a point means roughly the same thing across codebases.
69
+
70
+ ## Outputs
71
+
72
+ The action sets `points` and `net_bytes` as step outputs, so you can gate on them (e.g. require extra review above a threshold).
73
+
74
+ ## How it works
75
+
76
+ 1. Diff the PR against its merge base (`git diff base...head`).
77
+ 2. Drop lockfiles (`*.lock`, `*.sum`), generated code (`*.gen.*`, `*.pb.*`), minified files, and build output (`dist/`, `node_modules/`, `target/`, `__pycache__/`, …).
78
+ 3. Strip comments (`//`, `#`, `--`, `/* */` — heuristic) and whitespace from the added/removed lines.
79
+ 4. Concatenate the repo's source files (capped at 256 KB, excluding the files being changed so new code can't compress against itself) as a compression context.
80
+ 5. Score = `compress(context + added) − compress(context)` minus the same for removed lines, **pooled across all files** — a pattern repeated in 20 files is one idea, not twenty. The file-scatter penalty separately captures the per-file review burden, and per-file byte estimates keep the score attributable.
81
+
82
+ Because compression is a novelty detector, unusual identifiers, magic numbers, and new abstractions cost full entropy, while the 50th `return` statement costs ~zero.
83
+
84
+ Zero dependencies — just Node's built-in `zlib`, `git`, and the GitHub REST API.
package/cli.js ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { parseArgs } from 'node:util';
4
+ import { parseDiff, score, scoreable, sh } from './lib.mjs';
5
+
6
+ const USAGE = `pr-complexity — score code changes by information content, K(diff | codebase)
7
+
8
+ Usage:
9
+ pr-complexity score uncommitted changes vs HEAD (incl. untracked files)
10
+ pr-complexity --base main score HEAD vs merge-base of <ref> (PR mode)
11
+ pr-complexity --budget 40 exit 1 if the score exceeds <pts> (gate an agent loop)
12
+ pr-complexity --json machine-readable output with per-file attribution
13
+ `;
14
+
15
+ const { values } = parseArgs({
16
+ options: {
17
+ base: { type: 'string' },
18
+ budget: { type: 'string' },
19
+ json: { type: 'boolean', default: false },
20
+ help: { type: 'boolean', default: false },
21
+ },
22
+ });
23
+
24
+ if (values.help) {
25
+ process.stdout.write(USAGE);
26
+ process.exit(0);
27
+ }
28
+
29
+ let diff;
30
+ try {
31
+ diff = values.base
32
+ ? sh(`git diff --unified=0 --no-color ${values.base}...HEAD`)
33
+ : sh('git diff --unified=0 --no-color HEAD');
34
+ } catch {
35
+ console.error('error: not a git repo, or bad --base ref');
36
+ process.exit(2);
37
+ }
38
+
39
+ const files = parseDiff(diff);
40
+ if (!values.base) {
41
+ for (const p of sh('git ls-files --others --exclude-standard').split('\n')) {
42
+ if (p && scoreable(p)) {
43
+ try {
44
+ files.push({ path: p, added: readFileSync(p, 'utf8').split('\n'), removed: [] });
45
+ } catch {}
46
+ }
47
+ }
48
+ }
49
+
50
+ const result = score(files);
51
+ const budget = values.budget !== undefined ? Number(values.budget) : null;
52
+ const over = budget !== null && result.points > budget;
53
+ const scope = values.base ? `HEAD vs ${values.base}` : 'working tree vs HEAD';
54
+ const hint =
55
+ 'lower the score by reusing existing utilities, matching local patterns, and deleting dead code';
56
+
57
+ if (values.json) {
58
+ console.log(JSON.stringify({ scope, ...result, budget, over, hint }));
59
+ } else {
60
+ const sign = result.net > 0 ? '+' : '';
61
+ console.log(
62
+ `${result.points} pts (${result.band}) — net ${sign}${result.net} bytes, ${scope}`
63
+ );
64
+ if (result.scatter > 0)
65
+ console.log(` scatter: +${result.scatter} pts (${result.filesChanged} files changed)`);
66
+ for (const f of result.files.filter((f) => f.netBytes > 0).slice(0, 5))
67
+ console.log(` +${f.netBytes}B ${f.path}`);
68
+ if (budget !== null)
69
+ console.log(over ? `budget ${budget}: OVER by ${result.points - budget} pts` : `budget ${budget}: ok`);
70
+ if (result.points > 0) console.log(`hint: ${hint}`);
71
+ }
72
+
73
+ process.exit(over ? 1 : 0);
package/lib.mjs ADDED
@@ -0,0 +1,137 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { brotliCompressSync, constants } from 'node:zlib';
4
+
5
+ export const INCLUDE =
6
+ /\.(ts|tsx|js|jsx|mts|cts|mjs|cjs|py|go|rs|rb|java|kt|kts|scala|sc|c|h|cc|cpp|cxx|hpp|cs|php|swift|m|mm|sql|sh|bash|zsh|lua|pl|pm|r|jl|dart|vue|svelte|hs|ml|fs|fsx|ex|exs|erl|clj|cljs|groovy|tf|hcl|graphql|proto|json|ya?ml|toml|xml|html?|css|scss|less|md|mdx)$/;
7
+ export const INCLUDE_FILES = /(^|\/)(Dockerfile|Makefile|Justfile)$/i;
8
+ export const EXCLUDE = [
9
+ /(^|\/)node_modules\//,
10
+ /(^|\/)(dist|build|out|coverage|vendor|fixtures?|target|__pycache__)(\/|$)/,
11
+ /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb)$/,
12
+ /\.(min\.(js|css)|map|snap|lock|sum|gen\.\w+|pb\.\w+)$/i,
13
+ ];
14
+ export const CTX_LIMIT = 256 * 1024;
15
+
16
+ export const scoreable = (p) =>
17
+ (INCLUDE.test(p) || INCLUDE_FILES.test(p)) && !EXCLUDE.some((re) => re.test(p));
18
+
19
+ export const sh = (cmd) => execSync(cmd, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
20
+
21
+ const CEILING_BYTES = 16 * 1024;
22
+ const SCALE = 100 / Math.pow(Math.log2(1 + CEILING_BYTES / 100), 1.5);
23
+
24
+ export const toPoints = (b) => {
25
+ const p = Math.round(SCALE * Math.pow(Math.log2(1 + Math.abs(b) / 100), 1.5));
26
+ return (b < 0 ? -1 : 1) * Math.min(100, p);
27
+ };
28
+
29
+ // File-scatter penalty: every file is a fresh review context even when its
30
+ // diff is informationally cheap (renames, repeated patterns). Free up to 10
31
+ // files, +6 pts per doubling beyond, capped at +30 so bytes stay dominant.
32
+ // Net-negative PRs are exempt — don't tax simplification.
33
+ export const scatterOf = (n) =>
34
+ n <= 10 ? 0 : Math.min(30, Math.round(6 * Math.log2(n / 10)));
35
+
36
+ export const bandOf = (points) =>
37
+ points <= 0
38
+ ? 'net simplifier'
39
+ : points < 10
40
+ ? 'trivial'
41
+ : points < 30
42
+ ? 'small'
43
+ : points < 50
44
+ ? 'moderate'
45
+ : points < 70
46
+ ? 'large'
47
+ : 'split-worthy';
48
+
49
+ export function parseDiff(diff) {
50
+ const files = [];
51
+ let cur = null;
52
+ for (const line of diff.split('\n')) {
53
+ if (line.startsWith('diff --git')) {
54
+ cur = null;
55
+ continue;
56
+ }
57
+ if (line.startsWith('+++ ')) {
58
+ if (line.startsWith('+++ b/') && scoreable(line.slice(6))) {
59
+ cur = { path: line.slice(6), added: [], removed: [] };
60
+ files.push(cur);
61
+ }
62
+ continue;
63
+ }
64
+ if (!cur) continue;
65
+ if (line.startsWith('+')) cur.added.push(line.slice(1));
66
+ else if (line.startsWith('-')) cur.removed.push(line.slice(1));
67
+ }
68
+ return files;
69
+ }
70
+
71
+ // Heuristic comment stripping across languages. `#` and `--` require a
72
+ // following space so JS private fields (`#count`) and decrements (`x--`)
73
+ // survive; `https://` in strings is still mangled. Good enough.
74
+ const norm = (s) =>
75
+ s
76
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
77
+ .replace(/(^|\s)\/\/[^\n]*/g, '$1')
78
+ .replace(/(^|\s)#(\s|$)[^\n]*/g, '$1')
79
+ .replace(/(^|\s)--(\s|$)[^\n]*/g, '$1')
80
+ .replace(/\s+/g, ' ')
81
+ .trim();
82
+
83
+ const compress = (s) =>
84
+ brotliCompressSync(Buffer.from(s), {
85
+ params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
86
+ }).length;
87
+
88
+ // Repo content as compression context, excluding files present in the diff
89
+ // so new code can't compress against itself.
90
+ export function buildContext(excludePaths = new Set()) {
91
+ let ctx = '';
92
+ for (const p of sh('git ls-files').split('\n')) {
93
+ if (ctx.length >= CTX_LIMIT) break;
94
+ if (!p || !scoreable(p) || excludePaths.has(p)) continue;
95
+ try {
96
+ ctx += readFileSync(p, 'utf8') + '\n';
97
+ } catch {}
98
+ }
99
+ return ctx.slice(0, CTX_LIMIT);
100
+ }
101
+
102
+ // Totals are pooled across files: a pattern repeated in 20 files is one
103
+ // idea, not twenty — the file-scatter penalty separately captures the
104
+ // per-file review burden. Per-file bytes are for attribution only.
105
+ export function score(files) {
106
+ const ctx = buildContext(new Set(files.map((f) => f.path)));
107
+ const cCtx = compress(ctx);
108
+ const marginal = (lines) => {
109
+ const t = norm(lines.join('\n'));
110
+ return t ? Math.max(0, compress(ctx + t) - cCtx) : 0;
111
+ };
112
+ const added = marginal(files.flatMap((f) => f.added));
113
+ const removed = marginal(files.flatMap((f) => f.removed));
114
+ for (const f of files) {
115
+ f.addedBytes = marginal(f.added);
116
+ f.removedBytes = marginal(f.removed);
117
+ f.netBytes = f.addedBytes - f.removedBytes;
118
+ delete f.added;
119
+ delete f.removed;
120
+ }
121
+ files.sort((a, b) => b.netBytes - a.netBytes);
122
+ const net = added - removed;
123
+ const bytePoints = toPoints(net);
124
+ const scatter = net > 0 ? scatterOf(files.length) : 0;
125
+ const points = net > 0 ? Math.min(100, bytePoints + scatter) : bytePoints;
126
+ return {
127
+ added,
128
+ removed,
129
+ net,
130
+ points,
131
+ bytePoints,
132
+ scatter,
133
+ filesChanged: files.length,
134
+ band: bandOf(points),
135
+ files,
136
+ };
137
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "pr-complexity",
3
+ "version": "1.2.0",
4
+ "description": "Score code changes by information content — K(diff | codebase)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "pr-complexity": "./cli.js"
9
+ },
10
+ "files": [
11
+ "cli.js",
12
+ "lib.mjs"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/sebmellen/pr-complexity.git"
20
+ },
21
+ "keywords": [
22
+ "code-complexity",
23
+ "pull-request",
24
+ "code-review",
25
+ "kolmogorov",
26
+ "compression",
27
+ "github-action"
28
+ ]
29
+ }