thurview 0.6.0 → 0.8.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 CHANGED
@@ -9,6 +9,12 @@ and serves it in your browser: the walkthrough, live code peeks, the diff,
9
9
  commits, and a software map. You ask questions, leave anchored comments, and
10
10
  approve or request changes. The agent answers and republishes.
11
11
 
12
+ It also explains a codebase. A **code explainer** is the same document over a
13
+ different unit: one pinned commit instead of a range, so a reader can see the
14
+ architecture well enough to spot design problems themselves. It has no diff and
15
+ nothing to approve, it surfaces structure rather than grading it, and it states
16
+ what it did not examine.
17
+
12
18
  It does not review the code for you. It helps you understand it fast enough
13
19
  to review it yourself.
14
20
 
@@ -32,10 +38,11 @@ The diff at the pinned commits, commenting on a selected line range:
32
38
  ![The Files tab, split diff, with a comment on lines 9 to 12 of
33
39
  src/auth.ts](./media/review-files.png)
34
40
 
35
- The software map, showing what the change added and what it touched:
41
+ The software map: where the change landed in the system, and what sits next to
42
+ it — the question the diff cannot answer:
36
43
 
37
- ![The Map tab: Auth and Session store changed, Audit trail added, with its
38
- files and code](./media/review-map.png)
44
+ ![The Map tab: the parts the change touched, drawn first, the links between
45
+ them, and the selected node's files, code and neighbours](./media/review-map.png)
39
46
 
40
47
  Threads: a question the agent already answered, and a comment held for the
41
48
  decision:
@@ -132,6 +139,12 @@ and open it.
132
139
  Click an identifier to see where it is defined at that commit; Ctrl-click
133
140
  jumps there.
134
141
  - **Commits**: the commits between base and head.
142
+ - **Coverage** (explainers): every file in scope at the pinned commit, in one
143
+ of three states - anchored in the document, placed on the map only, or not
144
+ examined - with the parts of the system they belong to, the references that
145
+ cross between those parts, and the names defined in more than one of them.
146
+ Derived at publish, so what the explainer skipped is a stated fact rather
147
+ than something the reader has to infer.
135
148
  - **Map**: systems, containers, components and code, with what the change
136
149
  added, removed or touched, linked to files and code.
137
150
  - **Threads**: _Ask now_ sends a question to the agent immediately and the
@@ -152,18 +165,19 @@ bar.
152
165
 
153
166
  ## CLI
154
167
 
155
- | Command | Purpose |
156
- | --------------------------------------------------------------------- | ------------------------------------------------------------ |
157
- | `thurview scaffold [--pr N \| --base R --head R]` | Create a review pinned to exact commits (`--update` re-pins) |
158
- | `thurview info [--all]` | Reviews bound to this worktree |
159
- | `thurview publish --review ID [--view T] [--open]` | Validate the document and map, seal a revision |
160
- | `thurview open --review ID [--view T]` | Start the server if needed and open the browser |
161
- | `thurview wait --review ID [--timeout S]` | Block until the reader needs the agent |
162
- | `thurview threads list\|get\|reply\|resolve` | Read and answer threads |
163
- | `thurview graph interfaces\|impact\|callers\|tests-for\|architecture` | Ask the code graph at the pinned commits |
164
- | `thurview serve` / `thurview stop` | Run the server in the foreground / stop the background one |
165
- | `thurview setup hooks\|skill\|status` | Session hooks, agent skill, install state |
166
- | `thurview update` | Self-update from npm |
168
+ | Command | Purpose |
169
+ | --------------------------------------------------------------------- | ---------------------------------------------------------------- |
170
+ | `thurview scaffold [--pr N \| --base R --head R]` | Create a review pinned to exact commits (`--update` re-pins) |
171
+ | `thurview explain [<path>] [--commit R]` | Create a code explainer of a codebase or subsystem at one commit |
172
+ | `thurview info [--all]` | Reviews bound to this worktree |
173
+ | `thurview publish --review ID [--view T] [--open]` | Validate the document and map, seal a revision |
174
+ | `thurview open --review ID [--view T]` | Start the server if needed and open the browser |
175
+ | `thurview wait --review ID [--timeout S]` | Block until the reader needs the agent |
176
+ | `thurview threads list\|get\|reply\|resolve` | Read and answer threads |
177
+ | `thurview graph interfaces\|impact\|callers\|tests-for\|architecture` | Ask the code graph at the pinned commits |
178
+ | `thurview serve` / `thurview stop` | Run the server in the foreground / stop the background one |
179
+ | `thurview setup hooks\|skill\|status` | Session hooks, agent skill, install state |
180
+ | `thurview update` | Self-update from npm |
167
181
 
168
182
  thurview is an [AXI](https://axi.md): built for agents that drive it through a
169
183
  shell. Output is [TOON](https://toonformat.dev) on stdout, errors are
@@ -185,16 +199,22 @@ The agent writes three files in `~/.thurview/reviews/<id>/`:
185
199
  - `data.yaml`: typed inputs: `actors`, `anchors` (file, from, to, graph),
186
200
  `stores`, `interfaces` (a capability line per derived entry, plus the
187
201
  interfaces the graph cannot see).
188
- - `map.yaml`: the software map at head, optionally at base.
202
+ - `map.yaml`: the software map at head, optionally at base. In an explainer it
203
+ carries the breadth the prose has no room for, and a node's `files` globs are
204
+ what let a file count as placed rather than not examined.
189
205
  - `theme.yaml`: the look, derived from the reviewed project's own design
190
206
  system (tokens, fonts, shape, code palette). Empty means the default skin.
191
207
 
208
+ An explainer writes the same files, minus `interfaces`: there is no change to
209
+ derive a delta from, and `graph: base` on an anchor is an error because there
210
+ is one commit.
211
+
192
212
  `thurview publish` rejects an anchor whose file or lines do not exist at the
193
213
  pinned commit, a call stack frame that claims an added or removed call the
194
214
  diff does not show, a storage operation on an unknown field, a map edge
195
215
  to an unknown node, an interface annotation for a symbol the change did not
196
- move, and a declared interface whose anchor holds no added or deleted line.
197
- The full format is in
216
+ move, a declared interface whose anchor holds no added or deleted line, and an
217
+ explainer that anchors nothing at all. The full format is in
198
218
  [skills/thurview/references](skills/thurview/references).
199
219
 
200
220
  Optional guidance for the agent: `~/.thurview/THURVIEW.md` for you,
package/dist/cli.js CHANGED
@@ -9,8 +9,9 @@ import { join, dirname, resolve } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import open from "open";
11
11
  import * as g from "./git.js";
12
- import { SCHEMA, home, newId, now, readReview, writeReview, listReviews, reviewsFor, reviewDir, revisionDir, readThreads, readText, writeText, writeJson, readJson, serverStateFile, deleteReview, } from "./store.js";
13
- import { compileDocument, compileMap } from "./document/compile.js";
12
+ import { SCHEMA, home, newId, now, readReview, writeReview, listReviews, reviewsFor, reviewDir, revisionDir, readThreads, readText, writeText, writeJson, readJson, serverStateFile, deleteReview, kindOf, } from "./store.js";
13
+ import { compileDocument, compileMap, globToRegExp } from "./document/compile.js";
14
+ import { computeCoverage, scopeGlob, scopeGraph, scopeTruncated, } from "./coverage.js";
14
15
  import { parseTheme, compileTheme } from "./theme.js";
15
16
  import { registerTheme } from "./highlight.js";
16
17
  import { replyThread, setThreadStatus, needsAgent } from "./threads.js";
@@ -19,7 +20,7 @@ import { parseFlags, helpFor, str, bool } from "./flags.js";
19
20
  import { VERSION } from "./version.js";
20
21
  const execFileP = promisify(execFile);
21
22
  const HERE = dirname(fileURLToPath(import.meta.url));
22
- const DESCRIPTION = "Guided, evidence-anchored reviews of agent-written code, read and answered in the browser";
23
+ const DESCRIPTION = "Guided, evidence-anchored reviews of a change and explainers of a codebase, read and answered in the browser";
23
24
  function note(msg) {
24
25
  process.stderr.write(msg + "\n");
25
26
  }
@@ -86,6 +87,7 @@ async function reviewRow(r, fields) {
86
87
  const t = await readThreads(r.id);
87
88
  const row = {
88
89
  id: short(r.id),
90
+ kind: kindOf(r),
89
91
  title: r.title,
90
92
  status: r.status,
91
93
  rev: r.revision,
@@ -95,7 +97,10 @@ async function reviewRow(r, fields) {
95
97
  if (fields.has("all") || fields.has("binding"))
96
98
  row["binding"] = r.binding.kind === "pr" ? `PR #${r.binding.name}` : r.binding.name;
97
99
  if (fields.has("all") || fields.has("pins"))
98
- row["pins"] = `${r.pins.base.slice(0, 12)}..${r.pins.head.slice(0, 12)}`;
100
+ row["pins"] =
101
+ kindOf(r) === "explainer"
102
+ ? r.pins.head.slice(0, 12)
103
+ : `${r.pins.base.slice(0, 12)}..${r.pins.head.slice(0, 12)}`;
99
104
  if (fields.has("all") || fields.has("worktree"))
100
105
  row["worktree"] = r.worktree;
101
106
  if (fields.has("all") || fields.has("inSync"))
@@ -216,7 +221,9 @@ anchors: {}
216
221
  stores: {}
217
222
  interfaces: {}
218
223
  `;
219
- const TEMPLATE_MAP = `# Software map: people, systems, containers, components, code. Empty nodes = no map.
224
+ const TEMPLATE_MAP = `# Software map: where this change landed in the system, and what sits next to it.
225
+ # People, systems, containers, components, code. Leave nodes empty when the change
226
+ # lands in one place and the Files tab already answers that.
220
227
  nodes: []
221
228
  edges: []
222
229
  `;
@@ -232,6 +239,33 @@ const TEMPLATE_THEME = `# Look of this review, derived from the reviewed project
232
239
  # shape: { radius: 8px, bevel: false, glow: false, scanlines: false, headingTransform: none }
233
240
  # code: { keyword: "#7c3aed", string: "#15803d", function: "#b45309", variable: "#0369a1", comment: "#9ca3af" }
234
241
  `;
242
+ const TEMPLATE_EXPLAIN_MD = (title) => `# ${title}
243
+
244
+ **Summary**
245
+
246
+ - The agent is still writing this explainer. The Coverage tab already states
247
+ what it has and has not examined at the pinned commit; this page offers the
248
+ new revision when the walkthrough lands.
249
+ `;
250
+ const TEMPLATE_EXPLAIN_DATA = `# Typed inputs for the explainer: actors, anchors and stores. An explainer has
251
+ # one pinned commit, so every anchor reads that commit and \`graph: base\` is an
252
+ # error. It has no interface delta: there is no change to take one from.
253
+ #
254
+ # anchors:
255
+ # dispatch:
256
+ # title: where a request picks its handler
257
+ # peek: { file: src/server/router.ts, from: 41, to: 58 }
258
+ actors: {}
259
+ anchors: {}
260
+ stores: {}
261
+ `;
262
+ const TEMPLATE_EXPLAIN_MAP = `# The structure of the code at the pinned commit: systems, containers,
263
+ # components, code. The map carries breadth so the prose can carry depth, and a
264
+ # node's \`files\` globs are what tell the Coverage tab a file was at least placed.
265
+ # Seed it from \`thurview graph architecture\`.
266
+ nodes: []
267
+ edges: []
268
+ `;
235
269
  // ---- commands ----
236
270
  const SPECS = {
237
271
  scaffold: {
@@ -252,8 +286,25 @@ const SPECS = {
252
286
  "thurview scaffold --update --review <id>",
253
287
  ],
254
288
  },
289
+ explain: {
290
+ description: "Create a code explainer pinned to one commit: a whole codebase, or one subsystem of it",
291
+ args: "[<path scope>]",
292
+ flags: {
293
+ commit: { kind: "string", help: "commit to pin (default: HEAD)" },
294
+ title: { kind: "string", help: "initial title" },
295
+ new: { kind: "boolean", help: "create another explainer even if one matches the scope" },
296
+ update: { kind: "boolean", help: "re-pin an existing explainer to a new commit" },
297
+ review: { kind: "string", help: "explainer to update (id prefix)" },
298
+ },
299
+ examples: [
300
+ "thurview explain",
301
+ "thurview explain src/server",
302
+ "thurview explain --commit v1.2.0",
303
+ "thurview explain --update --review <id>",
304
+ ],
305
+ },
255
306
  info: {
256
- description: "Reviews bound to this worktree (or every review with --all)",
307
+ description: "Reviews and explainers bound to this worktree (or all of them with --all)",
257
308
  flags: {
258
309
  all: { kind: "boolean", help: "every review, not only this worktree" },
259
310
  fields: {
@@ -393,6 +444,7 @@ async function homeView() {
393
444
  help: [
394
445
  "Run `thurview scaffold` to create a review of the current branch",
395
446
  "Run `thurview scaffold --pr <number>` for a pull request",
447
+ "Run `thurview explain [<path>]` to explain the codebase at HEAD instead",
396
448
  ],
397
449
  };
398
450
  const reviews = [];
@@ -573,6 +625,119 @@ const commands = {
573
625
  ],
574
626
  };
575
627
  },
628
+ async explain(args) {
629
+ const p = parseFlags("explain", args, spec("explain").flags, 1);
630
+ const cwd = process.cwd();
631
+ const worktree = await worktreeOf(cwd);
632
+ if (!worktree)
633
+ throw new AxiError("not inside a git repository", "VALIDATION_ERROR", [
634
+ "Run `thurview explain` inside the source worktree",
635
+ ]);
636
+ const existing = bool(p, "update") || str(p, "review") ? await resolveReview(str(p, "review")) : null;
637
+ if (existing && kindOf(existing) !== "explainer")
638
+ throw new AxiError(`${short(existing.id)} is a review, not an explainer`, "VALIDATION_ERROR", [
639
+ "Run `thurview scaffold --update --review <id>` to re-pin a review",
640
+ "Run `thurview explain` with no --review to start an explainer",
641
+ ]);
642
+ const scope = scopeGlob(p.positional[0] ?? existing?.binding.name);
643
+ let commit;
644
+ try {
645
+ commit = await g.revParse(worktree, str(p, "commit") ?? "HEAD");
646
+ }
647
+ catch (e) {
648
+ throw new AxiError(e.message, "VALIDATION_ERROR", [
649
+ "Pass a resolvable ref: `thurview explain --commit <ref>`",
650
+ ]);
651
+ }
652
+ // A scope that matches nothing is a typo, and an explainer of nothing would
653
+ // still publish and still state honest-looking coverage of zero files.
654
+ const files = await g.listFiles(worktree, commit);
655
+ const inScope = scope === "**" ? files : files.filter((f) => globToRegExp(scope).test(f));
656
+ if (!inScope.length)
657
+ throw new AxiError(`no file matches "${scope}" at ${commit.slice(0, 12)}`, "VALIDATION_ERROR", [
658
+ "Pass a path that exists at that commit: `thurview explain src/server`",
659
+ "Run `thurview explain` with no scope for the whole repository",
660
+ ]);
661
+ const binding = { kind: "codebase", name: scope };
662
+ const defaultTitle = scope === "**" ? worktree.split("/").pop() || "Codebase" : scope.replace(/\/\*\*$/, "");
663
+ let review;
664
+ let reused = false;
665
+ if (existing) {
666
+ review = existing;
667
+ review.pins = { base: commit, head: commit };
668
+ review.binding = binding;
669
+ if (str(p, "title"))
670
+ review.title = str(p, "title");
671
+ await writeReview(review);
672
+ }
673
+ else {
674
+ const match = bool(p, "new")
675
+ ? []
676
+ : (await reviewsFor(worktree)).filter((r) => kindOf(r) === "explainer" &&
677
+ r.binding.name === scope &&
678
+ r.status !== "accepted" &&
679
+ r.status !== "closed");
680
+ if (match.length) {
681
+ review = match[0];
682
+ reused = true;
683
+ review.pins = { base: commit, head: commit };
684
+ await writeReview(review);
685
+ }
686
+ else {
687
+ const id = newId();
688
+ review = {
689
+ schema: SCHEMA,
690
+ id,
691
+ kind: "explainer",
692
+ title: str(p, "title") || defaultTitle,
693
+ worktree,
694
+ repoRoot: worktree,
695
+ binding,
696
+ pins: { base: commit, head: commit },
697
+ status: "draft",
698
+ revision: 0,
699
+ dismissed: false,
700
+ createdAt: now(),
701
+ updatedAt: now(),
702
+ };
703
+ await mkdir(reviewDir(id), { recursive: true });
704
+ await writeText(join(reviewDir(id), "review.md"), TEMPLATE_EXPLAIN_MD(review.title));
705
+ await writeText(join(reviewDir(id), "data.yaml"), TEMPLATE_EXPLAIN_DATA);
706
+ await writeText(join(reviewDir(id), "map.yaml"), TEMPLATE_EXPLAIN_MAP);
707
+ await writeText(join(reviewDir(id), "theme.yaml"), TEMPLATE_THEME);
708
+ await writeReview(review);
709
+ }
710
+ }
711
+ const dir = reviewDir(review.id);
712
+ return {
713
+ explainer: {
714
+ id: short(review.id),
715
+ uuid: review.id,
716
+ kind: "explainer",
717
+ title: review.title,
718
+ status: review.status,
719
+ rev: review.revision,
720
+ scope,
721
+ commit,
722
+ worktree,
723
+ reused,
724
+ dir,
725
+ },
726
+ files: {
727
+ document: join(dir, "review.md"),
728
+ data: join(dir, "data.yaml"),
729
+ map: join(dir, "map.yaml"),
730
+ theme: join(dir, "theme.yaml"),
731
+ },
732
+ scale: { filesInScope: inScope.length },
733
+ guidance: await guidanceFiles(worktree),
734
+ help: [
735
+ `Run \`thurview graph architecture --review ${short(review.id)}\` for the clusters, their hubs and the links between them`,
736
+ `Author ${join(dir, "map.yaml")} first: it carries the breadth the prose cannot`,
737
+ `Edit ${join(dir, "review.md")} and data.yaml, then run \`thurview publish --review ${short(review.id)}\``,
738
+ ],
739
+ };
740
+ },
576
741
  async info(args) {
577
742
  const p = parseFlags("info", args, spec("info").flags);
578
743
  const fields = new Set((str(p, "fields") ?? "").split(",").filter(Boolean));
@@ -644,22 +809,28 @@ const commands = {
644
809
  }
645
810
  }
646
811
  const themeName = theme ? await registerTheme(theme.shiki) : undefined;
812
+ const kind = kindOf(review);
813
+ // An explainer has one pinned commit, so there is no delta to derive: the
814
+ // panel above its document states coverage instead.
647
815
  let interfaces = null;
648
- try {
649
- interfaces = await deltaFor(review);
650
- }
651
- catch (e) {
652
- diags.push({
653
- level: "warning",
654
- file: "review.md",
655
- message: `the interface delta is unavailable: ${e.message}`,
656
- });
816
+ if (kind === "review") {
817
+ try {
818
+ interfaces = await deltaFor(review);
819
+ }
820
+ catch (e) {
821
+ diags.push({
822
+ level: "warning",
823
+ file: "review.md",
824
+ message: `the interface delta is unavailable: ${e.message}`,
825
+ });
826
+ }
657
827
  }
658
828
  const doc = await compileDocument({
659
829
  cwd: review.worktree,
660
830
  pins: review.pins,
661
831
  reviewMd,
662
832
  dataYaml: dataYaml ?? "",
833
+ kind,
663
834
  ...(themeName ? { themeName } : {}),
664
835
  interfaces,
665
836
  });
@@ -671,6 +842,7 @@ const commands = {
671
842
  pins: review.pins,
672
843
  mapYaml,
673
844
  anchors: doc.anchors,
845
+ kind,
674
846
  });
675
847
  diags.push(...m.diagnostics);
676
848
  map = m.map;
@@ -693,7 +865,54 @@ const commands = {
693
865
  ],
694
866
  };
695
867
  }
868
+ // Coverage is derived, not claimed: every file in scope at the pinned commit
869
+ // is accounted for, so the reader is told what the prose never reached.
870
+ let coverage = null;
871
+ if (kind === "explainer") {
872
+ try {
873
+ const graph = await import("./graph.js");
874
+ const g0 = await graph.graphAt(review.worktree, review.pins.head, dir);
875
+ coverage = computeCoverage({
876
+ commit: review.pins.head,
877
+ scope: review.binding.name,
878
+ allFiles: await g.listFiles(review.worktree, review.pins.head),
879
+ graph: g0,
880
+ anchored: Object.values(doc.document.anchors)
881
+ .map((a) => a.peek?.file)
882
+ .filter((f) => !!f),
883
+ owners: (map?.head.nodes ?? [])
884
+ .filter((n) => n.files?.length)
885
+ .map((n) => ({ node: n.id, globs: n.files })),
886
+ });
887
+ }
888
+ catch (e) {
889
+ diags.push({
890
+ level: "error",
891
+ file: "review.md",
892
+ message: `coverage is unavailable, so the explainer cannot state what it skipped: ${e.message}`,
893
+ });
894
+ process.exitCode = 1;
895
+ return {
896
+ error: "publish failed: coverage could not be derived",
897
+ code: "PUBLISH_FAILED",
898
+ diagnostics: diags.map((d) => ({
899
+ level: d.level,
900
+ file: d.file,
901
+ line: d.line ?? "",
902
+ message: d.message,
903
+ })),
904
+ help: [`Run \`thurview publish --review ${short(review.id)}\` again`],
905
+ };
906
+ }
907
+ }
696
908
  const warnings = [];
909
+ if (kind === "explainer" && !map)
910
+ warnings.push("this explainer has no map, so every file it does not anchor counts as not examined; author map.yaml to place the rest");
911
+ if (review.binding.kind === "codebase") {
912
+ const tip = await g.revParse(review.worktree, "HEAD").catch(() => null);
913
+ if (tip && tip !== review.pins.head)
914
+ warnings.push(`HEAD has moved past the pinned commit; run \`thurview explain --update --review ${short(review.id)}\` to re-pin`);
915
+ }
697
916
  if (review.binding.kind === "branch") {
698
917
  const tip = await g.revParse(review.worktree, review.binding.name).catch(() => null);
699
918
  if (tip && tip !== review.pins.head)
@@ -711,6 +930,7 @@ const commands = {
711
930
  await writeJson(join(rdir, "document.json"), doc.document);
712
931
  await writeJson(join(rdir, "map.json"), map);
713
932
  await writeJson(join(rdir, "changes.json"), changes);
933
+ await writeJson(join(rdir, "coverage.json"), coverage);
714
934
  if (themeYaml !== null)
715
935
  await cp(join(dir, "theme.yaml"), join(rdir, "theme.yaml"));
716
936
  await writeJson(join(rdir, "theme.json"), theme);
@@ -719,6 +939,7 @@ const commands = {
719
939
  at: now(),
720
940
  title: doc.document.title,
721
941
  pins: review.pins,
942
+ kind,
722
943
  hasMap: !!map,
723
944
  theme: theme?.name ?? "default",
724
945
  });
@@ -738,15 +959,26 @@ const commands = {
738
959
  const out = {
739
960
  published: {
740
961
  id: short(review.id),
962
+ kind,
741
963
  rev: n,
742
964
  title: review.title,
743
965
  status: review.status,
744
966
  map: !!map,
745
- interfaces: doc.document.interfaces?.verdict ?? "(unavailable)",
967
+ ...(kind === "explainer"
968
+ ? { coverage: coverage ? coverage.verdict : "(unavailable)" }
969
+ : { interfaces: doc.document.interfaces?.verdict ?? "(unavailable)" }),
746
970
  theme: theme?.name ?? "default",
747
971
  url: url ?? "(server not running)",
748
972
  },
749
973
  };
974
+ if (coverage)
975
+ out["notExamined"] = {
976
+ files: coverage.states.uncovered,
977
+ first: coverage.uncovered.slice(0, 8),
978
+ byPart: coverage.clusters
979
+ .filter((c) => c.uncovered.length)
980
+ .map((c) => ({ part: c.label, files: c.uncovered.length })),
981
+ };
750
982
  if (rows.length)
751
983
  out["diagnostics"] = rows;
752
984
  if (warnings.length)
@@ -910,6 +1142,11 @@ const commands = {
910
1142
  throw new AxiError("--graph must be head or base", "VALIDATION_ERROR", help);
911
1143
  const graph = await import("./graph.js");
912
1144
  const review = await resolveReview(str(p, "review"));
1145
+ if (kindOf(review) === "explainer" && (sub === "interfaces" || sub === "impact"))
1146
+ throw new AxiError(`graph ${sub} compares two commits; an explainer is pinned to one`, "VALIDATION_ERROR", [
1147
+ `Run \`thurview graph architecture --review ${short(review.id)}\` for the structure at that commit`,
1148
+ `Run \`thurview graph callers <name> --review ${short(review.id)}\` to follow one symbol`,
1149
+ ]);
913
1150
  const dir = reviewDir(review.id);
914
1151
  const at = (commit) => graph.graphAt(review.worktree, commit, dir);
915
1152
  if (sub === "callers" || sub === "tests-for") {
@@ -978,6 +1215,25 @@ const commands = {
978
1215
  ],
979
1216
  };
980
1217
  }
1218
+ // An explainer is scoped to a path, so its structure is that path's, not the
1219
+ // repository's: the same bound the Coverage tab accounts for.
1220
+ if (kindOf(review) === "explainer") {
1221
+ const scope = review.binding.name;
1222
+ const g0 = scopeGraph(head, scope);
1223
+ const allFiles = await g.listFiles(review.worktree, review.pins.head);
1224
+ const { diff: _diff, truncated: _truncated, ...rest } = graph.architecture(g0, g0);
1225
+ return {
1226
+ commit: short(head.commit),
1227
+ scope,
1228
+ languages: pins.languages,
1229
+ truncated: scopeTruncated(allFiles, head, scope),
1230
+ ...rest,
1231
+ help: [
1232
+ "Seed map.yaml nodes from communities, their `files` from a community's files, and edges from edges",
1233
+ "A file in no community is outside the languages the graph reads; `thurview publish` counts those",
1234
+ ],
1235
+ };
1236
+ }
981
1237
  return {
982
1238
  ...pins,
983
1239
  ...graph.architecture(base, head),
@@ -1238,6 +1494,7 @@ function topLevelHelp() {
1238
1494
  examples: [
1239
1495
  "thurview",
1240
1496
  "thurview scaffold",
1497
+ "thurview explain src/server",
1241
1498
  "thurview publish --view files --open",
1242
1499
  "thurview wait",
1243
1500
  'thurview threads reply <threadId> --body "<answer>"',