@sdsrs/code-graph 0.51.1 → 0.53.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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.51.1",
7
+ "version": "0.53.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // PR impact review (v0.53.0). Renders a code-graph `affected` analysis of a
4
+ // pull request's changed files into a sticky PR comment: which test files to
5
+ // re-run, the blast radius, and changed production files that have NO covering
6
+ // test ("test gaps").
7
+ //
8
+ // Posture: this is a productization of the already-shipped `affected` command
9
+ // (reverse-dependency closure over imports∪calls∪references∪implements∪inherits).
10
+ // It introduces no new graph logic — it only shells out to the built binary and
11
+ // formats the result. The render path is pure (unit-tested with fixtures); the
12
+ // gh upsert + binary calls live in `computeReview` / `upsertComment` / `main`.
13
+ //
14
+ // Funnel-safe: every binary invocation carries CODE_GRAPH_INTERNAL=1 so CI
15
+ // analysis runs never inflate the deny→use conversion metrics (mirrors
16
+ // cg-answer.js).
17
+
18
+ const { spawnSync } = require('child_process');
19
+
20
+ const MARKER = '<!-- code-graph-impact-review -->';
21
+ const SPAWN_TIMEOUT_MS = 60_000;
22
+ const TOP_AFFECTED = 15;
23
+
24
+ /// File-level test classifier — mirrors `domain::is_test_path` (Rust). Kept in
25
+ /// sync deliberately; see feedback_test_classifier_dual_sources.md.
26
+ function isTestPath(p) {
27
+ return (
28
+ p.startsWith('tests/') || p.startsWith('test/') ||
29
+ p.startsWith('benches/') || p.startsWith('bench/') ||
30
+ p.includes('__tests__/') ||
31
+ p.endsWith('/tests.rs') ||
32
+ p.endsWith('_test.go') || p.endsWith('_test.rs') ||
33
+ p.endsWith('.test.ts') || p.endsWith('.test.js') ||
34
+ p.endsWith('.test.tsx') || p.endsWith('.test.jsx') ||
35
+ p.endsWith('.spec.ts') || p.endsWith('.spec.js') ||
36
+ p.endsWith('.spec.tsx') || p.endsWith('.spec.jsx')
37
+ );
38
+ }
39
+
40
+ function resolveBinary() {
41
+ if (process.env._CG_REVIEW_BINARY) return process.env._CG_REVIEW_BINARY;
42
+ try {
43
+ return require('./find-binary').findBinary();
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ function runAffected(binary, args, cwd, stdin) {
50
+ const res = spawnSync(binary, args, {
51
+ cwd,
52
+ input: stdin,
53
+ timeout: SPAWN_TIMEOUT_MS,
54
+ encoding: 'utf8',
55
+ maxBuffer: 16 * 1024 * 1024,
56
+ stdio: ['pipe', 'pipe', 'ignore'],
57
+ env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
58
+ });
59
+ if (res.error || res.signal || res.status !== 0) return null;
60
+ try {
61
+ return JSON.parse((res.stdout || '').trim());
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /// Build the review object by running `affected` over the changed files.
68
+ /// `changedFiles` is the raw list (newline-split, pre-filtered to non-empty).
69
+ /// Returns null only when the binary itself is unavailable — an empty diff still
70
+ /// yields a valid (empty) review so the comment path always has data.
71
+ function computeReview(binary, changedFiles, cwd) {
72
+ const aggregate = runAffected(
73
+ binary, ['affected', '--stdin', '--json'], cwd, changedFiles.join('\n') + '\n'
74
+ );
75
+ if (!aggregate) return null;
76
+
77
+ const changed = aggregate.changed || [];
78
+ const tests = aggregate.tests || [];
79
+ const affectedFiles = aggregate.affected_files || [];
80
+ const notIndexed = aggregate.not_indexed || [];
81
+
82
+ // Per-file test-gap: a changed PRODUCTION (non-test) file is "uncovered" when
83
+ // running `affected` on it alone surfaces zero test files. Run per-file so the
84
+ // signal is attributable (the aggregate union can't be split back per file).
85
+ const uncovered = [];
86
+ for (const f of changed) {
87
+ if (isTestPath(f)) continue;
88
+ const single = runAffected(binary, ['affected', f, '--json'], cwd, '');
89
+ if (single && (single.tests || []).length === 0) {
90
+ uncovered.push(f);
91
+ }
92
+ }
93
+
94
+ const topAffected = affectedFiles
95
+ .slice()
96
+ .sort((a, b) => (a.depth - b.depth) || a.path.localeCompare(b.path))
97
+ .slice(0, TOP_AFFECTED);
98
+
99
+ return {
100
+ changed,
101
+ not_indexed: notIndexed,
102
+ tests: tests.slice().sort(),
103
+ blast_radius: affectedFiles.length,
104
+ top_affected: topAffected,
105
+ uncovered: uncovered.sort(),
106
+ };
107
+ }
108
+
109
+ /// Pure: render a review object to sticky-comment markdown. Always begins with
110
+ /// MARKER so the upsert can find and replace it.
111
+ function renderMarkdown(review) {
112
+ const lines = [MARKER, '## 🔎 Code Graph impact review', ''];
113
+
114
+ if (review.changed.length === 0) {
115
+ lines.push(
116
+ review.not_indexed.length > 0
117
+ ? `No **indexed** code changed (${review.not_indexed.length} changed file(s) not in the graph — new/non-code files).`
118
+ : 'No code changes detected in this PR.'
119
+ );
120
+ lines.push('', '<sub>code-graph-mcp `affected`</sub>');
121
+ return lines.join('\n');
122
+ }
123
+
124
+ lines.push(
125
+ `**${review.changed.length}** changed indexed file(s) · ` +
126
+ `blast radius **${review.blast_radius}** file(s) · ` +
127
+ `**${review.tests.length}** test file(s) to re-run`,
128
+ ''
129
+ );
130
+
131
+ if (review.uncovered.length > 0) {
132
+ lines.push(`### ⚠️ Test gaps (${review.uncovered.length})`);
133
+ lines.push('Changed production files with no test in their reverse-dependency closure:');
134
+ for (const p of review.uncovered) lines.push(`- \`${p}\``);
135
+ lines.push('');
136
+ }
137
+
138
+ if (review.tests.length > 0) {
139
+ lines.push('<details><summary>Tests to re-run</summary>', '');
140
+ for (const t of review.tests) lines.push(`- \`${t}\``);
141
+ lines.push('', '</details>', '');
142
+ }
143
+
144
+ if (review.top_affected.length > 0) {
145
+ const more = review.blast_radius - review.top_affected.length;
146
+ const cap = more > 0 ? ` (top ${review.top_affected.length} of ${review.blast_radius})` : '';
147
+ lines.push(`<details><summary>Blast radius${cap}</summary>`, '');
148
+ for (const a of review.top_affected) lines.push(`- \`${a.path}\` (depth ${a.depth})`);
149
+ if (more > 0) lines.push(`- …and ${more} more`);
150
+ lines.push('', '</details>', '');
151
+ }
152
+
153
+ if (review.not_indexed.length > 0) {
154
+ lines.push(`<sub>${review.not_indexed.length} changed file(s) not in index (new/non-code) — not analyzed.</sub>`);
155
+ }
156
+ lines.push('<sub>code-graph-mcp `affected` · reverse-dependency closure over imports∪calls∪references∪implements∪inherits</sub>');
157
+ return lines.join('\n');
158
+ }
159
+
160
+ /// Upsert a sticky comment: find an existing comment containing MARKER and PATCH
161
+ /// it, else POST a new one. Uses `gh api` (preinstalled on GitHub runners).
162
+ function upsertComment(repo, prNumber, body) {
163
+ const gh = (args, input) => spawnSync('gh', args, {
164
+ encoding: 'utf8', input, timeout: SPAWN_TIMEOUT_MS,
165
+ env: { ...process.env },
166
+ });
167
+
168
+ const list = gh(['api', '--paginate', `repos/${repo}/issues/${prNumber}/comments`]);
169
+ let existingId = null;
170
+ if (list.status === 0) {
171
+ try {
172
+ const comments = JSON.parse(list.stdout || '[]');
173
+ const hit = comments.find((c) => (c.body || '').includes(MARKER));
174
+ if (hit) existingId = hit.id;
175
+ } catch { /* fall through to create */ }
176
+ }
177
+
178
+ if (existingId) {
179
+ const res = gh(
180
+ ['api', '--method', 'PATCH', `repos/${repo}/issues/comments/${existingId}`, '-F', 'body=@-'],
181
+ body
182
+ );
183
+ return res.status === 0;
184
+ }
185
+ const res = gh(
186
+ ['api', '--method', 'POST', `repos/${repo}/issues/${prNumber}/comments`, '-F', 'body=@-'],
187
+ body
188
+ );
189
+ return res.status === 0;
190
+ }
191
+
192
+ function main(argv) {
193
+ // argv: [changedFilesPath]. Env: GH_REPO, PR_NUMBER, CODE_GRAPH_FAIL_ON_RISK.
194
+ const fs = require('fs');
195
+ const changedPath = argv[0];
196
+ if (!changedPath) {
197
+ console.error('usage: pr-impact-comment.js <changed-files.txt>');
198
+ process.exit(2);
199
+ }
200
+ const binary = resolveBinary();
201
+ if (!binary) {
202
+ console.error('[pr-impact] code-graph-mcp binary not found; skipping review.');
203
+ return; // best-effort: never fail the PR over a missing analyzer
204
+ }
205
+
206
+ const changedFiles = fs.readFileSync(changedPath, 'utf8')
207
+ .split('\n').map((s) => s.trim()).filter(Boolean);
208
+
209
+ const review = computeReview(binary, changedFiles, process.cwd());
210
+ if (!review) {
211
+ console.error('[pr-impact] affected analysis failed; skipping comment.');
212
+ return;
213
+ }
214
+
215
+ const body = renderMarkdown(review);
216
+ const repo = process.env.GH_REPO;
217
+ const prNumber = process.env.PR_NUMBER;
218
+ if (repo && prNumber) {
219
+ const ok = upsertComment(repo, prNumber, body);
220
+ if (!ok) console.error('[pr-impact] failed to upsert PR comment.');
221
+ } else {
222
+ // No PR context (e.g. local run) — print to stdout for inspection.
223
+ process.stdout.write(body + '\n');
224
+ }
225
+
226
+ const failOnRisk = /^(1|true|yes)$/i.test(process.env.CODE_GRAPH_FAIL_ON_RISK || '');
227
+ if (failOnRisk && review.uncovered.length > 0) {
228
+ console.error(`[pr-impact] fail-on-risk: ${review.uncovered.length} changed file(s) have no covering test.`);
229
+ process.exit(1);
230
+ }
231
+ }
232
+
233
+ if (require.main === module) {
234
+ main(process.argv.slice(2));
235
+ }
236
+
237
+ module.exports = { isTestPath, renderMarkdown, computeReview, upsertComment, MARKER };
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+ const { test } = require('node:test');
3
+ const assert = require('node:assert');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+
8
+ const { isTestPath, renderMarkdown, computeReview, MARKER } = require('./pr-impact-comment');
9
+
10
+ test('isTestPath mirrors domain::is_test_path patterns', () => {
11
+ for (const p of [
12
+ 'tests/integration.rs', 'test/foo.js', 'benches/indexing.rs', 'bench/x.rs',
13
+ 'src/__tests__/a.ts', 'src/foo/tests.rs', 'pkg/x_test.go', 'src/y_test.rs',
14
+ 'a.test.ts', 'a.test.js', 'a.test.tsx', 'a.test.jsx',
15
+ 'a.spec.ts', 'a.spec.js', 'a.spec.tsx', 'a.spec.jsx',
16
+ ]) {
17
+ assert.ok(isTestPath(p), `${p} should be a test path`);
18
+ }
19
+ for (const p of ['src/lib.rs', 'src/graph/centrality.rs', 'README.md', 'src/testing.rs']) {
20
+ assert.ok(!isTestPath(p), `${p} should NOT be a test path`);
21
+ }
22
+ });
23
+
24
+ test('renderMarkdown: empty diff', () => {
25
+ const md = renderMarkdown({ changed: [], not_indexed: [], tests: [], blast_radius: 0, top_affected: [], uncovered: [] });
26
+ assert.ok(md.startsWith(MARKER), 'must start with marker');
27
+ assert.match(md, /No code changes detected/);
28
+ });
29
+
30
+ test('renderMarkdown: only non-indexed changes', () => {
31
+ const md = renderMarkdown({ changed: [], not_indexed: ['docs/x.md', 'new.rs'], tests: [], blast_radius: 0, top_affected: [], uncovered: [] });
32
+ assert.match(md, /No \*\*indexed\*\* code changed \(2 changed file/);
33
+ });
34
+
35
+ test('renderMarkdown: full review with test gaps', () => {
36
+ const md = renderMarkdown({
37
+ changed: ['src/a.rs', 'src/b.rs'],
38
+ not_indexed: ['NEW.md'],
39
+ tests: ['tests/a_test.rs'],
40
+ blast_radius: 20,
41
+ top_affected: [{ path: 'src/c.rs', depth: 1 }, { path: 'src/d.rs', depth: 2 }],
42
+ uncovered: ['src/b.rs'],
43
+ });
44
+ assert.ok(md.startsWith(MARKER));
45
+ assert.match(md, /2\*\* changed indexed file/);
46
+ assert.match(md, /blast radius \*\*20\*\*/);
47
+ assert.match(md, /Test gaps \(1\)/);
48
+ assert.match(md, /- `src\/b\.rs`/);
49
+ assert.match(md, /Tests to re-run/);
50
+ assert.match(md, /- `tests\/a_test\.rs`/);
51
+ // 20 blast radius but only 2 shown → "top 2 of 20" + "…and 18 more"
52
+ assert.match(md, /top 2 of 20/);
53
+ assert.match(md, /…and 18 more/);
54
+ assert.match(md, /1 changed file\(s\) not in index/);
55
+ });
56
+
57
+ // Stub binary: a node script that emulates `code-graph-mcp affected`.
58
+ // `affected --stdin --json` → aggregate; `affected <file> --json` → per-file.
59
+ function writeStubBinary(dir) {
60
+ const stub = path.join(dir, 'stub-cg.js');
61
+ fs.writeFileSync(stub, `#!/usr/bin/env node
62
+ 'use strict';
63
+ const args = process.argv.slice(2);
64
+ // args[0] === 'affected'
65
+ if (args.includes('--stdin')) {
66
+ process.stdout.write(JSON.stringify({
67
+ changed: ['src/a.rs', 'src/b.rs', 'tests/a_test.rs'],
68
+ tests: ['tests/a_test.rs'],
69
+ affected_files: [{path:'tests/a_test.rs',depth:1,is_test:true},{path:'src/x.rs',depth:1,is_test:false}],
70
+ not_indexed: ['NEW.md'],
71
+ }));
72
+ process.exit(0);
73
+ }
74
+ const file = args[1];
75
+ // src/a.rs is covered (has a test); src/b.rs is uncovered (no tests).
76
+ if (file === 'src/a.rs') {
77
+ process.stdout.write(JSON.stringify({ changed:[file], tests:['tests/a_test.rs'], affected_files:[], not_indexed:[] }));
78
+ } else {
79
+ process.stdout.write(JSON.stringify({ changed:[file], tests:[], affected_files:[], not_indexed:[] }));
80
+ }
81
+ process.exit(0);
82
+ `);
83
+ fs.chmodSync(stub, 0o755);
84
+ // Wrap so it's executable as a single binary path: use `node stub.js` via a shell shim.
85
+ const shim = path.join(dir, 'cg');
86
+ fs.writeFileSync(shim, `#!/usr/bin/env bash\nexec node "${stub}" "$@"\n`);
87
+ fs.chmodSync(shim, 0o755);
88
+ return shim;
89
+ }
90
+
91
+ test('computeReview: aggregate + per-file test-gap detection', () => {
92
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-prreview-'));
93
+ try {
94
+ const binary = writeStubBinary(dir);
95
+ const review = computeReview(binary, ['src/a.rs', 'src/b.rs', 'tests/a_test.rs', 'NEW.md'], dir);
96
+ assert.ok(review, 'review computed');
97
+ assert.deepStrictEqual(review.tests, ['tests/a_test.rs']);
98
+ assert.strictEqual(review.blast_radius, 2);
99
+ assert.deepStrictEqual(review.not_indexed, ['NEW.md']);
100
+ // src/b.rs has no covering test → uncovered; src/a.rs covered; test file skipped.
101
+ assert.deepStrictEqual(review.uncovered, ['src/b.rs']);
102
+ } finally {
103
+ fs.rmSync(dir, { recursive: true, force: true });
104
+ }
105
+ });
106
+
107
+ test('computeReview: returns null when binary unavailable', () => {
108
+ const review = computeReview('/nonexistent/cg-binary-xyz', ['src/a.rs'], os.tmpdir());
109
+ assert.strictEqual(review, null);
110
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.51.1",
3
+ "version": "0.53.0",
4
4
  "description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,10 +35,10 @@
35
35
  "node": ">=16"
36
36
  },
37
37
  "optionalDependencies": {
38
- "@sdsrs/code-graph-linux-x64": "0.51.1",
39
- "@sdsrs/code-graph-linux-arm64": "0.51.1",
40
- "@sdsrs/code-graph-darwin-x64": "0.51.1",
41
- "@sdsrs/code-graph-darwin-arm64": "0.51.1",
42
- "@sdsrs/code-graph-win32-x64": "0.51.1"
38
+ "@sdsrs/code-graph-linux-x64": "0.53.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.53.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.53.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.53.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.53.0"
43
43
  }
44
44
  }