codeblast 0.3.2 → 0.3.3

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 CHANGED
@@ -1,4 +1,4 @@
1
- **codeblast** is a deterministic code-graph CLI for TypeScript and Python repositories that tells developers and AI agents what breaks before a change is merged.
1
+ **codeblast** is the pre-merge blast-radius check for TypeScript monorepos and AI coding agents. It tells you what to review, which tests to run, and when the graph is incomplete — with a source line for every reported edge.
2
2
 
3
3
  <p align="center">
4
4
  <img src="assets/readme/hero.svg" width="100%" alt="codeblast — deterministic code graph: know what breaks before you merge"/>
@@ -15,6 +15,29 @@
15
15
  <img src="https://img.shields.io/badge/license-MIT-8b949e?style=flat-square" alt="MIT"/>
16
16
  </p>
17
17
 
18
+ ## Get value in one pull request
19
+
20
+ Copy this workflow into `.github/workflows/codeblast.yml`:
21
+
22
+ ```yaml
23
+ name: codeblast
24
+ on: pull_request
25
+ permissions:
26
+ contents: read
27
+ pull-requests: write
28
+ jobs:
29
+ codeblast:
30
+ runs-on: ubuntu-latest
31
+ steps:
32
+ - uses: actions/checkout@v4
33
+ with: { fetch-depth: 0 }
34
+ - uses: alloevil/codeblast@v0.3.3
35
+ ```
36
+
37
+ On a structural PR, codeblast posts a bounded review decision, affected tests, `file:line` evidence,
38
+ and explicit blind-spot warnings. On a docs-only or otherwise irrelevant PR, it stays silent. It runs
39
+ locally in the runner; source is not uploaded to a codeblast service.
40
+
18
41
  ## What it is
19
42
 
20
43
  **codeblast parses your repository into a deterministic code graph and answers the three most expensive questions around any code change:**
@@ -49,7 +72,7 @@ Built for humans (CLI / interactive HTML / PR comments) and for AI agents ([SKIL
49
72
  ```bash
50
73
  npx codeblast demo # build a graph of the current repo, run one impact query, emit the map
51
74
  npm i -g codeblast # or install globally; needs Node ≥ 22.13 (built-in sqlite) or Bun
52
- # npm serves 0.3.1
75
+ # npm serves 0.3.3
53
76
 
54
77
  # Install as an agent skill (Claude Code, Codex, Cursor, and 14 more harnesses)
55
78
  npx skills add alloevil/codeblast
@@ -57,28 +80,28 @@ npx skills add alloevil/codeblast
57
80
 
58
81
  ### As a GitHub Action (one line)
59
82
 
83
+ The smallest useful installation is a PR workflow. Pin the release tag, or pin the commit when your
84
+ repository requires immutable third-party actions:
85
+
60
86
  ```yaml
61
- # .github/workflows/codeblast.yml
62
87
  name: codeblast
63
88
  on: pull_request
64
89
  permissions:
65
90
  contents: read
66
91
  pull-requests: write
67
92
  jobs:
68
- analyze:
93
+ codeblast:
69
94
  runs-on: ubuntu-latest
70
95
  steps:
71
96
  - uses: actions/checkout@v4
72
- with: { fetch-depth: 0 } # the analyzer compares base and head commits
73
- - uses: alloevil/codeblast@v0.3.1
97
+ with: { fetch-depth: 0 }
98
+ - uses: alloevil/codeblast@v0.3.3
74
99
  ```
75
100
 
76
- The action builds the analyzer from the ref you pinned (not from npm, which can lag),
77
- posts one sticky comment per PR and updates it in place, and stays silent when the diff
78
- has no structural change. Inputs: `base`, `head`, `repo-url`, `comment` (set to `false`
79
- to only produce the file); outputs: `has_comment`, `comment_path`. If you prefer to own
80
- the commenting step, copy [`.github/workflows-template/codeblast.yml`](.github/workflows-template/codeblast.yml)
81
- instead — it runs the same command with `npx`.
101
+ The action builds the analyzer from the ref you pinned, posts one sticky comment per PR, and stays
102
+ silent when the diff has no structural change. For the full input/output contract, use the
103
+ [workflow template](.github/workflows-template/codeblast.yml).
104
+
82
105
 
83
106
  ## Why not yet another LLM diagram tool
84
107
 
@@ -122,6 +145,19 @@ codeblast cochange <repo> graph.db
122
145
  ```
123
146
 
124
147
  ### PR bot (runs in CI, stays quiet by default)
148
+ ### Reproducible PR cases
149
+
150
+ These are not synthetic diagrams; each case is a committed replay or pilot artifact:
151
+
152
+ | Case | Run it | Reviewer takeaway |
153
+ |---|---|---|
154
+ | Function-body behavior change | `codeblast pr-comment <repo> <base> <head>` | A symbol can keep the same shape while its callers still need review. |
155
+ | Exported signature change | `codeblast check-change <repo> <base> <head> --json` | API contraction or signature changes route to targeted review or review. |
156
+ | Incomplete static graph | `codeblast impact <db> <symbol> --json` | `warnings` and `blind_spot_count` prevent an apparently complete answer. |
157
+
158
+ The self-pilot record is [`eval/pilot-2026-09-24.json`](eval/pilot-2026-09-24.json): the published
159
+ package indexed this repository with 0 extraction failures and returned separate review-first and test
160
+ guidance. It is evidence that the workflow runs, not a claim of universal accuracy.
125
161
 
126
162
  Copy [`.github/workflows-template/codeblast.yml`](.github/workflows-template/codeblast.yml) into your repo (it runs `npx codeblast pr-comment`, no other setup):
127
163
  every PR gets an automatic comment with structural changes + blast radius + new symbols with no test coverage; **PRs with no structural change get zero comments**.
@@ -129,6 +165,48 @@ Replayed against 50 real commits: 42 correctly stayed silent. Comment usefulness
129
165
  four review rounds — rounds 1–3 independent blind review, round 4 by the current model — scored 25% / 75% / 57% / 20% useful, against 7/8 = 87.5% when the
130
166
  authoring agent rated its own comments; both numbers and the fixes that followed each round are logged in
131
167
  [intent.md](intent.md).
168
+ ## Evidence you can rerun
169
+
170
+ The headline promise is bounded: TypeScript, within the statically analyzable scope, and measured by
171
+ mutation testing against the real test suite. The committed runs are inspectable under [`eval/`](eval/)
172
+ and every published figure has a machine-readable receipt in [`docs/claims.json`](docs/claims.json).
173
+
174
+ | Scenario | Evidence | What it proves |
175
+ |---|---|---|
176
+ | tRPC, 30 injected mutations | [`mutation-2026-08-28-trpc-n30.json`](eval/mutation-2026-08-28-trpc-n30.json) | 28/28 killed mutants recalled; 2 were not killed by the suite |
177
+ | graphql-tools, Jest | [`mutation-2026-09-07-graphql-tools-n10.json`](eval/mutation-2026-09-07-graphql-tools-n10.json) | 10/10 killed mutants recalled across a second workspace layout |
178
+ | Real package pilot | [`pilot-2026-09-24.json`](eval/pilot-2026-09-24.json) | 32 files indexed, 0 extraction failures, guidance separated from repository-wide blind spots |
179
+
180
+ The pilot is not a benchmark and does not establish a universal accuracy rate. It is a reproducible
181
+ smoke run of the published package against this repository.
182
+
183
+ ### A reviewer's decision, not a diagram
184
+
185
+ For agents and CI, `check-change --json` returns a routing decision plus evidence:
186
+
187
+ ```json
188
+ {
189
+ "decision": "targeted-review",
190
+ "risk": "medium",
191
+ "affected_test_files": 3,
192
+ "blind_spot_count": 0,
193
+ "graph_health": {"warnings": []}
194
+ }
195
+ ```
196
+
197
+ ### What the numbers do and do not mean
198
+
199
+ The 28/28 and 10/10 figures are mutation-testing recall on two pinned repositories. They mean every
200
+ test file that failed for each killed mutation was present in the predicted set **within the measured
201
+ static-analysis boundary**. They do not mean codeblast catches every production regression, understands
202
+ dynamic runtime behavior, or provides function-level guarantees for Python.
203
+
204
+ The conservative import/file channel is deliberately retained: a controlled call-only ablation reached
205
+ better precision but recalled only 2/14 killed mutations. Treat `call` items as the first reading list,
206
+ the complete result as the test safety net, and every blind spot as an explicit limit.
207
+ `review` means inspect before merge; it does not mean the tool has proven the change unsafe. A nonzero
208
+ graph failure or an incomplete impact result is a reason to stop and inspect, not a reason to hide the
209
+ uncertainty.
132
210
 
133
211
  ## The precision promise (bounded, and evidence-backed)
134
212
 
@@ -174,13 +252,22 @@ safe to merge. `review` is required when API surface contracts, removed symbols,
174
252
  results are detected.
175
253
  ## For AI agents
176
254
 
177
- ```
255
+ ```text
178
256
  before editing: impact "symbol" --json → callsite list into context, so nothing gets missed
179
- after editing: change HEAD~1 HEAD --json → self-check for scope creep and accidental deletions
257
+ after editing: check-change repo base head --json → risk, graph health, affected tests and warnings
180
258
  ```
181
259
 
182
- The full contract and interpretation discipline (including "never pretend the blind-spot list is complete") is in [SKILL.md](SKILL.md).
183
- Agent conventions: [AGENTS.md](AGENTS.md).
260
+ The full contract and interpretation discipline (including “never pretend the impact list is complete”)
261
+ is in [SKILL.md](SKILL.md). Agent conventions: [AGENTS.md](AGENTS.md).
262
+
263
+ ## Help improve the reviewer
264
+
265
+ Found a false positive, missed impact, noisy comment, or wrong silence decision? Open a
266
+ [privacy-safe bot feedback issue](https://github.com/alloevil/codeblast/issues/new?template=bot-feedback.yml).
267
+ Share the public PR URL and a redacted explanation; never paste source code, secrets, private diffs,
268
+ or full repository contents. Reproducible benchmark results and pilot evidence belong in [`eval/`](eval/),
269
+ not in issue comments.
270
+
184
271
 
185
272
  ## FAQ
186
273
 
package/README.zh-CN.md CHANGED
@@ -33,7 +33,7 @@
33
33
  ```bash
34
34
  npx codeblast demo # 给当前仓库建图、跑一次 impact 查询、导出架构图
35
35
  npm i -g codeblast # 或全局安装;需要 Node ≥ 22.13(内置 sqlite)或 Bun
36
- # npm 上的版本是 0.3.1,与本仓库一致
36
+ # npm 上的版本是 0.3.3,与本仓库一致
37
37
 
38
38
  # 作为 agent skill 安装(Claude Code、Codex、Cursor 等)
39
39
  npx skills add alloevil/codeblast
package/SKILL.md CHANGED
@@ -9,18 +9,79 @@ Three deterministic queries over a graph built by `tsc` (TypeScript, function-le
9
9
  Python AST (file-level with typed-call upgrades). Every result carries the `file:line` where the
10
10
  dependency actually occurs. The graph comes from the code, not from a model's reading of it.
11
11
 
12
- ## When to run it
13
-
14
12
  | Situation | Command | What you get back |
15
13
  |---|---|---|
16
- | About to edit an exported symbol | `codeblast impact <db> "<symbol>" --json` | The callsites you must review and the tests you must run |
14
+ | About to edit an exported symbol | `codeblast impact <db> "<symbol>" --json` | `guidance.review_first`, `guidance.run_tests`, conservative items and warnings |
17
15
  | Finished a multi-file change; verifying scope | `codeblast change <repo> HEAD~1 HEAD --json` | Symbols and dependency edges added / removed / renamed |
16
+ | Need one merge-safety decision | `codeblast check-change <repo> <base> <head> --json` | Risk, decision, graph health, affected tests and recommended actions |
18
17
  | Need to understand an unfamiliar repo | `codeblast archmap <db> --out arch.html` | Module → file → symbol map with cycle detection |
19
18
  | Reviewing a PR | `codeblast pr-comment <repo> <base> <head>` | Markdown review comment; empty output when nothing structural changed |
20
19
 
21
20
  Prerequisites: Node ≥ 22.13 or Bun ≥ 1.0 (`npx codeblast` works with no install); `python3` for Python
22
21
  repos; the target repo's dependencies installed (missing `node_modules` turns external calls into blind spots).
23
22
 
23
+
24
+ Recommended agent loop:
25
+
26
+ ```text
27
+ 1. index the repository and check `failures == 0`
28
+ 2. before editing: read `impact --json` and start with `guidance.review_first`
29
+ 3. make the change
30
+ 4. run `check-change --json` against base and head
31
+ 5. run distinct files from `guidance.run_tests` / `affected_test_files`
32
+ 6. report `warnings`, blind spots, and every `via_file:via_line` evidence location
33
+ ```
34
+
35
+ Never turn a `safe-to-review` routing result into “safe to merge”; the command routes attention and
36
+
37
+ `check-change --json` has `schema_version: "1"` and `engine_version`. Its exit codes are stable:
38
+
39
+ - `0`: analysis completed with no high-risk or graph-health warning;
40
+ - `1`: analysis completed but routes the change to review (`high` risk or incomplete graph);
41
+ - `2`: command, repository, worktree, or graph construction error.
42
+
43
+ `impact --json` also has `schema_version: "1"`; every guidance array is de-duplicated and sorted for
44
+ stable agent diffs. Treat the schema version as a compatibility boundary, not as a prose suggestion.
45
+
46
+ Machine-readable compatibility contract:
47
+
48
+ ```text
49
+ impact --json: schema_version=1, guidance arrays sorted and de-duplicated
50
+ check-change --json: schema_version=1, engine_version, decision, risk, graph_health
51
+ exit 0: result complete and no high-risk routing
52
+ exit 1: result complete but review routing is required
53
+ exit 2: analysis or repository error; do not consume the result as evidence
54
+ ```
55
+
56
+ When a future schema version appears, stop and read its contract before making decisions from fields
57
+ that are not explicitly understood. Do not silently fall back to prose parsing.
58
+
59
+ The formal schemas are committed at [`eval/check-change.schema.json`](eval/check-change.schema.json)
60
+ and [`eval/impact.schema.json`](eval/impact.schema.json). Version policy: within schema version `1`,
61
+ new optional fields may be added, but existing field meanings, enum values, array ordering, and exit
62
+ codes remain stable. A breaking field removal, type change, or enum change requires a new schema
63
+ version and an explicit migration note. Consumers must reject unknown schema versions rather than
64
+ guessing.
65
+
66
+ The supported agent-facing artifacts are the two version-one JSON contracts and the smoke command:
67
+ `bun run agent-smoke` (or `node eval/agent-workflow-smoke.mjs` after building). A consumer integration
68
+ should fail closed when the schema version is unknown, and should preserve the complete JSON artifact
69
+ alongside its human summary for later review.
70
+
71
+ Continuous evolution outputs:
72
+
73
+ ```bash
74
+ bun run release-smoke # package install and executable surface
75
+ bun run agent-smoke # end-to-end safety JSON contract
76
+ bun run pilot-summary # committed pilot graph/impact summary
77
+ bun run guidance-stability # deterministic guidance sample
78
+ bun run validate-compatibility # compatibility sample boundary check
79
+ ```
80
+
81
+ Each command should either produce a small machine-readable result or fail. Keep the result with the
82
+ release or compatibility evidence; a green test suite without a current evidence sample is not a
83
+ complete evolution cycle.
84
+ tests. The graph health and warning fields are part of the contract.
24
85
  ## Interpretation rules — read before running
25
86
 
26
87
  These are the mistakes an agent makes with this tool. Each one has produced a wrong answer in practice.
package/dist/bin.js CHANGED
@@ -238,15 +238,30 @@ class Extractor {
238
238
  rel(fileName) {
239
239
  return path.relative(this.rootDir, fileName);
240
240
  }
241
+ safeSymbolAt(node) {
242
+ try {
243
+ return this.checker.getSymbolAtLocation(node);
244
+ } catch {
245
+ return;
246
+ }
247
+ }
248
+ safeAliased(symbol) {
249
+ if (!(symbol.flags & ts.SymbolFlags.Alias))
250
+ return symbol;
251
+ try {
252
+ return this.checker.getAliasedSymbol(symbol);
253
+ } catch {
254
+ return symbol;
255
+ }
256
+ }
241
257
  collectImplementers() {
242
258
  for (const sf of this.sourceFiles()) {
243
259
  const visit = (node) => {
244
260
  if (ts.isClassDeclaration(node) && node.heritageClauses) {
245
261
  for (const clause of node.heritageClauses) {
246
262
  for (const typeNode of clause.types) {
247
- let sym = this.checker.getSymbolAtLocation(typeNode.expression);
248
- if (sym && sym.flags & ts.SymbolFlags.Alias)
249
- sym = this.checker.getAliasedSymbol(sym);
263
+ const raw = this.safeSymbolAt(typeNode.expression);
264
+ const sym = raw ? this.safeAliased(raw) : undefined;
250
265
  const decl = sym?.declarations?.[0];
251
266
  if (!decl)
252
267
  continue;
@@ -289,18 +304,27 @@ class Extractor {
289
304
  if (ts.isImportDeclaration(stmt) || ts.isExportDeclaration(stmt)) {
290
305
  const spec = stmt.moduleSpecifier;
291
306
  if (spec && ts.isStringLiteral(spec)) {
292
- const resolved = this.resolveModule(spec.text, sf.fileName);
307
+ let resolved;
308
+ try {
309
+ resolved = this.resolveModule(spec.text, sf.fileName);
310
+ } catch {
311
+ blindSpots.push({ file: relPath, line: lineOf(stmt), reason: `module resolution failed: ${spec.text}`, src_file: relPath });
312
+ }
293
313
  if (!resolved) {
294
- for (const entry of this.externalReentry(spec.text, sf.fileName)) {
295
- edges.push({
296
- src: relPath,
297
- dst: this.rel(entry),
298
- kind: "imports",
299
- file: relPath,
300
- line: lineOf(stmt),
301
- confidence: "conservative",
302
- src_file: relPath
303
- });
314
+ try {
315
+ for (const entry of this.externalReentry(spec.text, sf.fileName)) {
316
+ edges.push({
317
+ src: relPath,
318
+ dst: this.rel(entry),
319
+ kind: "imports",
320
+ file: relPath,
321
+ line: lineOf(stmt),
322
+ confidence: "conservative",
323
+ src_file: relPath
324
+ });
325
+ }
326
+ } catch {
327
+ blindSpots.push({ file: relPath, line: lineOf(stmt), reason: `external reentry resolution failed: ${spec.text}`, src_file: relPath });
304
328
  }
305
329
  } else {
306
330
  edges.push({
@@ -429,9 +453,8 @@ class Extractor {
429
453
  for (const clause of node.heritageClauses) {
430
454
  const ek = clause.token === ts.SyntaxKind.ImplementsKeyword ? "implements" : "extends";
431
455
  for (const t of clause.types) {
432
- let sym = this.checker.getSymbolAtLocation(t.expression);
433
- if (sym && sym.flags & ts.SymbolFlags.Alias)
434
- sym = this.checker.getAliasedSymbol(sym);
456
+ const raw = this.safeSymbolAt(t.expression);
457
+ const sym = raw ? this.safeAliased(raw) : undefined;
435
458
  const decl = sym?.declarations?.[0];
436
459
  const dst = decl ? this.nodeIdOfDecl(decl) : undefined;
437
460
  if (dst)
@@ -489,9 +512,8 @@ class Extractor {
489
512
  }
490
513
  resolveCall(call, caller, relPath, line, edges, blindSpots) {
491
514
  const expr = call.expression;
492
- let sym = this.checker.getSymbolAtLocation(expr);
493
- if (sym && sym.flags & ts.SymbolFlags.Alias)
494
- sym = this.checker.getAliasedSymbol(sym);
515
+ const raw = this.safeSymbolAt(expr);
516
+ const sym = raw ? this.safeAliased(raw) : undefined;
495
517
  const decl = sym?.valueDeclaration ?? sym?.declarations?.[0];
496
518
  if (!decl) {
497
519
  const structural = ts.isElementAccessExpression(expr) || ts.isPropertyAccessExpression(expr) && ["call", "apply", "bind"].includes(expr.name.text);
@@ -709,7 +731,11 @@ function indexProgram(extractor) {
709
731
  } catch (err) {
710
732
  failures++;
711
733
  seenFiles.delete(relPath);
712
- console.error(`EXTRACT FAILED ${relPath}: ${err instanceof Error ? err.message : err}`);
734
+ const detail = err instanceof Error ? `${err.name}: ${err.message}${err.stack ? `
735
+ ${err.stack.split(`
736
+ `).slice(1, 12).join(`
737
+ `)}` : ""}` : String(err);
738
+ console.error(`EXTRACT FAILED ${relPath}: ${detail}`);
713
739
  }
714
740
  }
715
741
  }
@@ -1131,16 +1157,17 @@ var init_impact_cli = __esm(() => {
1131
1157
  result = impact(db2, targetId, maxNodes);
1132
1158
  ms = (performance.now() - t02).toFixed(0);
1133
1159
  if (process.argv.includes("--json")) {
1160
+ const uniqueSorted = (values) => [...new Set(values)].sort((a, b) => a.localeCompare(b));
1134
1161
  const guidance = {
1135
- review_first: result.items.filter((it) => it.channel === "call" && it.level !== "tests").map((it) => it.id),
1136
- run_tests: result.items.filter((it) => it.level === "tests").map((it) => it.file),
1137
- conservative: result.items.filter((it) => it.channel === "file" && it.level !== "tests").map((it) => it.id),
1162
+ review_first: uniqueSorted(result.items.filter((it) => it.channel === "call" && it.level !== "tests").map((it) => it.id)),
1163
+ run_tests: uniqueSorted(result.items.filter((it) => it.level === "tests").map((it) => it.file)),
1164
+ conservative: uniqueSorted(result.items.filter((it) => it.channel === "file" && it.level !== "tests").map((it) => it.id)),
1138
1165
  warnings: [
1139
1166
  ...result.truncated ? ["impact_truncated_run_full_test_suite"] : [],
1140
1167
  ...result.blind_spot_count > 0 ? ["blind_spots_may_underestimate_impact"] : []
1141
1168
  ]
1142
1169
  };
1143
- process.stdout.write(JSON.stringify({ ...result, guidance }) + `
1170
+ process.stdout.write(JSON.stringify({ schema_version: "1", ...result, guidance }) + `
1144
1171
  `);
1145
1172
  db2.close();
1146
1173
  process.exitCode = 0;
@@ -2006,7 +2033,7 @@ function bodySignalCount(bodyChanged, diffLineCount, hasCallImpact) {
2006
2033
  return n;
2007
2034
  }
2008
2035
  function coreNamedCount(diff, prodNodesAdded) {
2009
- return diff.edgesAdded.filter((e) => !AUX_RE.test(e.file)).length + prodNodesAdded.filter((n) => !AUX_RE.test(n.file)).length + diff.renamed.filter((r) => !AUX_RE.test(r.file)).length + diff.visibilityChanged.filter((v) => !AUX_RE.test(v.file)).length + diff.signatureChanged.filter((s) => !AUX_RE.test(s.file)).length;
2036
+ return diff.edgesAdded.filter((e) => !AUX_RE.test(e.file)).length + diff.edgesRemoved.filter((e) => !AUX_RE.test(e.file)).length + prodNodesAdded.filter((n) => !AUX_RE.test(n.file)).length + diff.nodesRemoved.filter((n) => !AUX_RE.test(n.file) && !TEST_RE3.test(n.file)).length + diff.renamed.filter((r) => !AUX_RE.test(r.file)).length + diff.visibilityChanged.filter((v) => !AUX_RE.test(v.file)).length + diff.signatureChanged.filter((s) => !AUX_RE.test(s.file)).length;
2010
2037
  }
2011
2038
  var TEST_RE3, AUX_RE, BIG_DIFF_LINES = 40;
2012
2039
  var init_pr_silence = __esm(() => {
@@ -2100,7 +2127,7 @@ var args3, repo2, baseSha, headSha, urlFlag, repoUrl2, dbPathA = "/tmp/codeblast
2100
2127
  const wrap = s.kind === "interface" || s.kind === "const" ? (t) => t : (t) => `(${t})`;
2101
2128
  const what = s.kind === "interface" ? "成员变化" : s.kind === "const" ? "类型变化" : "";
2102
2129
  return `- \`${s.name}\`${what ? ` ${what}` : ""}: \`${wrap(clip(s.from))}\` → \`${wrap(clip(s.to))}\` (${link(s.file, s.line)})`;
2103
- }, apiSig, testSig, uncovered, impactRows, affectedTestCount = 0, anyImpactTruncated = false, totalBlindSpots = 0, prodNodesAdded, diffLineCount, bodySignal, coreNamed, decision;
2130
+ }, apiSig, testSig, uncovered, impactRows, affectedTestCount = 0, anyImpactTruncated = false, totalBlindSpots = 0, prodNodesAdded, diffLineCount, bodySignal, coreNamed, changedFiles, changedTests, testedBodySignal, decision;
2104
2131
  var init_pr_comment = __esm(async () => {
2105
2132
  init_db();
2106
2133
  init_proc();
@@ -2242,7 +2269,11 @@ var init_pr_comment = __esm(async () => {
2242
2269
  }
2243
2270
  });
2244
2271
  coreNamed = coreNamedCount(diff2, prodNodesAdded);
2245
- if (coreNamed + bodySignal === 0)
2272
+ changedFiles = spawnSync(["git", "diff", "--name-only", baseSha, headSha], { cwd: repo2 }).stdout.split(`
2273
+ `).filter(Boolean);
2274
+ changedTests = changedFiles.some((file) => TEST_RE3.test(file));
2275
+ testedBodySignal = bodyChanged.length > 0 && changedTests ? 1 : 0;
2276
+ if (coreNamed + bodySignal + testedBodySignal === 0)
2246
2277
  process.exit(0);
2247
2278
  if (bodyChanged.length > 0) {
2248
2279
  const rows = [];
@@ -2280,6 +2311,7 @@ var init_pr_comment = __esm(async () => {
2280
2311
  // src/check-change.ts
2281
2312
  var exports_check_change = {};
2282
2313
  import fs8 from "node:fs";
2314
+ import path3 from "node:path";
2283
2315
  function buildGraphAt3(ref, db) {
2284
2316
  const wt = `/tmp/codeblast-check-${ref.replace(/[^\w]/g, "_")}`;
2285
2317
  spawnSync(["git", "worktree", "remove", "--force", wt], { cwd: repo3 });
@@ -2294,12 +2326,33 @@ function buildGraphAt3(ref, db) {
2294
2326
  spawnSync(["git", "worktree", "remove", "--force", wt], { cwd: repo3 });
2295
2327
  }
2296
2328
  }
2297
- var repo3, baseSha2, headSha2, dbAPath = "/tmp/codeblast-check-base.db", dbBPath = "/tmp/codeblast-check-head.db", dbA3, dbB3, diff3, prodNodesAdded2, bodyChanged2, total3, affectedTests = 0, truncated = false, blindSpots = 0, diffLineCount2, decision2, output;
2329
+ var SCHEMA_VERSION = "1", EXIT_OK = 0, EXIT_REVIEW = 1, EXIT_ERROR = 2, readVersion = () => {
2330
+ try {
2331
+ const parsed = JSON.parse(fs8.readFileSync(path3.join(import.meta.dirname, "..", "package.json"), "utf8"));
2332
+ if (parsed && typeof parsed === "object" && "version" in parsed && typeof parsed.version === "string")
2333
+ return parsed.version;
2334
+ } catch {}
2335
+ return "unknown";
2336
+ }, ENGINE_VERSION, repo3, baseSha2, headSha2, dbAPath = "/tmp/codeblast-check-base.db", dbBPath = "/tmp/codeblast-check-head.db", dbA3, dbB3, graphHealth = (db) => ({
2337
+ files: Number(db.prepare("SELECT COUNT(*) c FROM files").get().c),
2338
+ nodes: Number(db.prepare("SELECT COUNT(*) c FROM nodes").get().c),
2339
+ edges: Number(db.prepare("SELECT COUNT(*) c FROM edges").get().c),
2340
+ blind_spots: Number(db.prepare("SELECT COUNT(*) c FROM blind_spots").get().c)
2341
+ }), healthBase, healthHead, diff3, healthWarnings, prodNodesAdded2, total3, affectedTests = 0, truncated = false, blindSpots = 0, bodyChanged2, diffText, currentFile = "", diffLineCount2, decision2, output;
2298
2342
  var init_check_change = __esm(() => {
2299
2343
  init_db();
2300
2344
  init_proc();
2301
2345
  init_impact();
2302
2346
  init_pr_silence();
2347
+ ENGINE_VERSION = readVersion();
2348
+ process.on("uncaughtException", (error) => {
2349
+ console.error(`check-change analysis error: ${error instanceof Error ? error.message : error}`);
2350
+ process.exitCode = EXIT_ERROR;
2351
+ });
2352
+ process.on("unhandledRejection", (reason) => {
2353
+ console.error(`check-change analysis error: ${reason instanceof Error ? reason.message : reason}`);
2354
+ process.exitCode = EXIT_ERROR;
2355
+ });
2303
2356
  [repo3, baseSha2, headSha2] = process.argv.slice(2);
2304
2357
  if (!repo3 || !baseSha2 || !headSha2) {
2305
2358
  console.error("usage: codeblast check-change <repo> <base-sha> <head-sha> --json");
@@ -2312,9 +2365,14 @@ var init_check_change = __esm(() => {
2312
2365
  buildGraphAt3(headSha2, dbBPath);
2313
2366
  dbA3 = openDatabase(dbAPath, { readonly: true });
2314
2367
  dbB3 = openDatabase(dbBPath, { readonly: true });
2368
+ healthBase = graphHealth(dbA3);
2369
+ healthHead = graphHealth(dbB3);
2315
2370
  diff3 = graphDiff(dbA3, dbB3);
2371
+ healthWarnings = [
2372
+ ...healthHead.files === 0 || healthHead.nodes === 0 ? ["graph_empty"] : [],
2373
+ ...healthHead.nodes < healthBase.nodes / 2 ? ["graph_node_count_dropped_sharply"] : []
2374
+ ];
2316
2375
  prodNodesAdded2 = diff3.nodesAdded.filter((n) => !TEST_RE3.test(n.file));
2317
- bodyChanged2 = [];
2318
2376
  total3 = structuralTotal(diff3);
2319
2377
  for (const node of [...diff3.nodesAdded, ...diff3.renamed.map((r) => ({ id: `${r.file}#${r.to}`, kind: r.kind, name: r.to, file: r.file, line: 0 }))].slice(0, 15)) {
2320
2378
  try {
@@ -2324,13 +2382,36 @@ var init_check_change = __esm(() => {
2324
2382
  blindSpots += result.blind_spot_count;
2325
2383
  } catch {}
2326
2384
  }
2385
+ bodyChanged2 = [];
2386
+ diffText = spawnSync(["git", "diff", "--unified=0", baseSha2, headSha2, "--", "*.ts", "*.tsx"], { cwd: repo3 }).stdout;
2387
+ for (const line of diffText.split(`
2388
+ `)) {
2389
+ const file = line.match(/^\+\+\+ b\/(.+)$/);
2390
+ if (file) {
2391
+ currentFile = file[1];
2392
+ continue;
2393
+ }
2394
+ const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)/);
2395
+ if (!hunk || !currentFile || TEST_RE3.test(currentFile))
2396
+ continue;
2397
+ const row = dbB3.prepare("SELECT id, name, kind, file, line FROM nodes WHERE file = ? AND kind IN ('function','method') AND line <= ? AND end_line >= ? ORDER BY (end_line - line) ASC LIMIT 1").get(currentFile, Number(hunk[1]), Number(hunk[1]));
2398
+ if (row && !bodyChanged2.some((item) => item.id === row.id))
2399
+ bodyChanged2.push(row);
2400
+ }
2327
2401
  diffLineCount2 = spawnSync(["git", "diff", "--numstat", baseSha2, headSha2], { cwd: repo3 }).stdout.split(`
2328
2402
  `).reduce((sum, line) => {
2329
2403
  const match = line.match(/^(\d+)\t(\d+)\t/);
2330
2404
  return sum + (match ? Number(match[1]) + Number(match[2]) : 0);
2331
2405
  }, 0);
2332
2406
  decision2 = reviewDecision({ diff: diff3, prodNodesAdded: prodNodesAdded2, bodyChanged: bodyChanged2, affectedTests, truncated, blindSpotCount: blindSpots });
2407
+ if (healthWarnings.length > 0) {
2408
+ decision2.risk = "high";
2409
+ decision2.reasons.push(...healthWarnings);
2410
+ decision2.recommendedActions.unshift("Rebuild or inspect the graph before relying on this decision.");
2411
+ }
2333
2412
  output = {
2413
+ schema_version: SCHEMA_VERSION,
2414
+ engine_version: ENGINE_VERSION,
2334
2415
  range: `${baseSha2}..${headSha2}`,
2335
2416
  decision: decision2.risk === "high" ? "review" : decision2.risk === "medium" ? "targeted-review" : "safe-to-review",
2336
2417
  risk: decision2.risk,
@@ -2340,6 +2421,7 @@ var init_check_change = __esm(() => {
2340
2421
  structural_changes: total3,
2341
2422
  affected_test_files: affectedTests,
2342
2423
  blind_spot_count: blindSpots,
2424
+ graph_health: { base: healthBase, head: healthHead, warnings: healthWarnings },
2343
2425
  truncated,
2344
2426
  signals: {
2345
2427
  core_named: coreNamedCount(diff3, prodNodesAdded2),
@@ -2351,12 +2433,13 @@ var init_check_change = __esm(() => {
2351
2433
  console.log(JSON.stringify(output));
2352
2434
  dbA3.close();
2353
2435
  dbB3.close();
2436
+ process.exitCode = healthWarnings.length > 0 || decision2.risk === "high" ? EXIT_REVIEW : EXIT_OK;
2354
2437
  });
2355
2438
 
2356
2439
  // src/demo.ts
2357
2440
  var exports_demo = {};
2358
2441
  import fs9 from "node:fs";
2359
- import path3 from "node:path";
2442
+ import path4 from "node:path";
2360
2443
  var repo4, db6 = "/tmp/codeblast-demo.db", out = "/tmp/codeblast-demo-arch.html", run = (label, args) => {
2361
2444
  console.log(`
2362
2445
  \x1B[36m▸ ${label}\x1B[0m`);
@@ -2377,7 +2460,7 @@ var repo4, db6 = "/tmp/codeblast-demo.db", out = "/tmp/codeblast-demo-arch.html"
2377
2460
  var init_demo = __esm(() => {
2378
2461
  init_db();
2379
2462
  init_proc();
2380
- repo4 = path3.resolve(process.argv[2] ?? path3.join(import.meta.dirname, ".."));
2463
+ repo4 = path4.resolve(process.argv[2] ?? path4.join(import.meta.dirname, ".."));
2381
2464
  console.log(`codeblast demo — target: ${repo4}`);
2382
2465
  for (const s of ["", "-wal", "-shm"])
2383
2466
  fs9.rmSync(db6 + s, { force: true });
@@ -2501,15 +2584,12 @@ usage: codeblast <command> [args]
2501
2584
  archmap <graph.db> --out arch.html interactive architecture map
2502
2585
  [--impact <sym>] [--diff <base.db>] ...with impact / change overlay
2503
2586
  [--repo-url <url>] names the page and its JSON-LD (already used above)
2504
- [--site-url <base>] [--og-image <u>] canonical/og for the published page (optional)
2505
- mermaid <graph.db> module map as mermaid
2506
- cochange <repo> <graph.db> mine git history coupling
2507
2587
  pr-comment <repo> <base-sha> <head-sha> PR review comment (silent if no change)
2508
2588
  check-change <repo> <base-sha> <head-sha> machine-readable merge safety decision
2509
2589
  demo [repo] build + query + map in one shot
2510
2590
 
2511
2591
  docs: https://github.com/alloevil/codeblast · demos: https://alloevil.github.io/codeblast/`);
2512
- process.exit(cmd2 && !ROUTES[cmd2] ? 1 : 0);
2592
+ process.exit(cmd2 === "--help" || cmd2 === "-h" || !cmd2 ? 0 : 1);
2513
2593
  }
2514
2594
  process.argv.splice(2, 1);
2515
2595
  await ROUTES[cmd2]();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeblast",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Know what breaks before you merge — mutation-tested code graph with architecture, change & impact maps. Evidence on every edge. For humans and AI agents.",
5
5
  "keywords": [
6
6
  "impact-analysis",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "type": "module",
28
28
  "bin": {
29
- "codeblast": "./dist/bin.js"
29
+ "codeblast": "dist/bin.js"
30
30
  },
31
31
  "files": [
32
32
  "dist",
@@ -38,8 +38,13 @@
38
38
  "build": "bun build src/bin.ts --target=node --outdir=dist --entry-naming=bin.js --external typescript --external @dagrejs/dagre && cp src/archmap-client.js src/py_extract.py dist/",
39
39
  "prepack": "bun run build",
40
40
  "typecheck": "tsc -p .",
41
- "test": "bun test",
42
- "demo": "bun src/bin.ts demo",
41
+ "agent-smoke": "bun run build && node eval/agent-workflow-smoke.mjs",
42
+ "guidance-stability": "node eval/guidance-stability.mjs",
43
+ "offline-replay": "bun run build && node eval/offline-replay.mjs",
44
+ "pilot-summary": "node eval/pilot-summary.mjs",
45
+ "release-smoke": "node eval/release-smoke.mjs",
46
+ "validate-compatibility": "node eval/validate-compatibility.mjs",
47
+ "validate-safety": "node eval/validate-safety.mjs",
43
48
  "verify": "python3 eval/mutation_check.py"
44
49
  },
45
50
  "engines": {
@@ -52,5 +57,11 @@
52
57
  "devDependencies": {
53
58
  "@types/node": "^26.4.0",
54
59
  "bun-types": "^1.4.0"
55
- }
60
+ },
61
+ "main": "index.js",
62
+ "directories": {
63
+ "doc": "docs",
64
+ "test": "test"
65
+ },
66
+ "author": ""
56
67
  }