diffninja 0.1.1 → 0.3.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.
@@ -1,6 +1,7 @@
1
1
  import { CALL_FLOW_MAX_CHILDREN, CALL_FLOW_MAX_CROSS_FILE_CHILDREN, CALL_FLOW_MAX_DEPTH, CALL_FLOW_MAX_NODES, CALL_FLOW_MAX_ROOTS, } from "./call-flow.js";
2
2
  import { CALL_FLOW_DEFAULT_DEPTH, CALL_FLOW_DEPTHS, CALL_FLOW_MODES, CALL_FLOW_NAV_SOURCE, } from "./call-flow-nav.js";
3
3
  import { escapeHtml } from "./escape-html.js";
4
+ import { nodeFunctionId, purposesOf, wrapPurpose } from "./explanation.js";
4
5
  /**
5
6
  * Call-flow navigation for a review report: Tree, Graph and Sequence views of
6
7
  * the syntactic call trees that touch each changed file.
@@ -24,13 +25,16 @@ import { escapeHtml } from "./escape-html.js";
24
25
  * removed, or contains a change, and plain dimmed text for an unchanged call.
25
26
  */
26
27
  export function renderCallFlows(report) {
27
- const files = collectFiles(report.callFlows, report.items);
28
+ const purposes = purposesOf(report);
29
+ const files = collectFiles(report.callFlows, report.items, purposes);
28
30
  if (files.length === 0) {
29
31
  return `<p class="cf-note cf-absence">${escapeHtml(AVAILABILITY_NOTE[report.callFlowAvailability])}</p>`;
30
32
  }
33
+ const plumbing = purposes.size === 0 ? 0 : files.reduce((total, view) => total + countPlumbing(view.trees, purposes), 0);
31
34
  return [
32
35
  '<div class="cf">',
33
36
  renderSummary(files, new Set(report.items.map(item => item.file)).size),
37
+ renderPlumbingToggle(plumbing, report.agentExplanation?.explainedBy),
34
38
  renderControls(),
35
39
  renderJump(files),
36
40
  '<nav class="cf-crumbs" id="cf-crumbs" aria-label="Visited call trail" hidden></nav>',
@@ -100,6 +104,47 @@ const GRAPH_CHAR = 7.4;
100
104
  const GRAPH_LOC_CHAR = 6.2;
101
105
  const GRAPH_MIN_WIDTH = 132;
102
106
  const GRAPH_MAX_WIDTH = 300;
107
+ /** The agent's purpose leads an explained box: up to three lines of 12px sans text. */
108
+ const GRAPH_PURPOSE_LINES = 3;
109
+ const GRAPH_PURPOSE_HEIGHT = 16;
110
+ const GRAPH_PURPOSE_CHAR = 7.2;
111
+ /** The agent's purpose for the function a node resolved to, if it wrote one. */
112
+ function purposeOf(node, purposes) {
113
+ const id = nodeFunctionId(node);
114
+ return id === undefined ? undefined : purposes.get(id);
115
+ }
116
+ /**
117
+ * Whether a node and everything below it is plumbing: no call in the subtree
118
+ * resolved to a function the agent explained. Library and framework calls are
119
+ * the usual case; hiding them leaves the calls the product itself makes.
120
+ */
121
+ function isPlumbing(node, purposes) {
122
+ return purposeOf(node, purposes) === undefined && node.children.every((child) => isPlumbing(child, purposes));
123
+ }
124
+ /** Plumbing subtrees that the toggle hides, counted at their topmost node. */
125
+ function countPlumbing(trees, purposes) {
126
+ let total = 0;
127
+ for (const tree of trees)
128
+ total += isPlumbing(tree, purposes) ? 1 : countPlumbing(tree.children, purposes);
129
+ return total;
130
+ }
131
+ /**
132
+ * A native checkbox, so hiding plumbing needs no script: while it is unchecked
133
+ * the stylesheet hides every plumbing call in Tree and Sequence. The Diagram
134
+ * keeps every box, because hiding one would leave its edges pointing at nothing.
135
+ */
136
+ function renderPlumbingToggle(plumbing, explainedBy) {
137
+ if (explainedBy === undefined)
138
+ return "";
139
+ return [
140
+ '<div class="cf-business">',
141
+ `<p class="cf-business-note">Each call shows what it does for the product first, as ${escapeHtml(explainedBy)} explained it, and its code name under it.</p>`,
142
+ plumbing === 0
143
+ ? ""
144
+ : `<label class="cf-plumbing-toggle"><input type="checkbox" class="cf-show-plumbing"> Show ${escapeHtml(plural(plumbing, "library or framework call"))} with no product code below</label>`,
145
+ "</div>",
146
+ ].join("");
147
+ }
103
148
  /** Most severe hunk per file, and the earliest hunk of that severity. */
104
149
  function hunkRanks(items) {
105
150
  const ranks = new Map();
@@ -113,7 +158,7 @@ function hunkRanks(items) {
113
158
  return ranks;
114
159
  }
115
160
  /** One diagram section per changed file with structured call paths. */
116
- function collectFiles(callFlows, items) {
161
+ function collectFiles(callFlows, items, purposes) {
117
162
  const ranks = hunkRanks(items);
118
163
  const views = [];
119
164
  const seen = new Set();
@@ -129,6 +174,7 @@ function collectFiles(callFlows, items) {
129
174
  rank: hunk.rank,
130
175
  trees,
131
176
  truncated: entry.truncated === true,
177
+ purposes,
132
178
  });
133
179
  }
134
180
  // Most severe file first; ties keep report order.
@@ -289,7 +335,7 @@ function lazyMode(mode, at, section) {
289
335
  }
290
336
  function renderTreeMode(view, at) {
291
337
  const trees = view.trees
292
- .map((tree, root) => renderTreeNode(tree, [root], at, view.file))
338
+ .map((tree, root) => renderTreeNode(tree, [root], at, view.file, view.purposes))
293
339
  .join("\n");
294
340
  return modeSection("tree", at, `<ul class="cf-tree">${trees}</ul>`);
295
341
  }
@@ -341,11 +387,12 @@ function nodeAttrs(node, path, at) {
341
387
  node.file === undefined ? "" : ` data-cf-nodefile="${escapeHtml(node.file)}"`,
342
388
  ].join("");
343
389
  }
344
- function renderTreeNode(node, path, at, changedFile) {
390
+ function renderTreeNode(node, path, at, changedFile, purposes) {
345
391
  const id = path.join("-");
346
392
  const status = node.status;
347
393
  const inFile = node.file === changedFile;
348
394
  const loc = locationText(node);
395
+ const purpose = purposeOf(node, purposes);
349
396
  // The label is the zoom target. It stays a plain span without JavaScript (a
350
397
  // dead button would be worse) and the script upgrades it into a real button.
351
398
  const zoom = `<span class="cf-label mono" data-cf-zoom data-cf-file="${at}" data-cf-path="${id}"` +
@@ -356,7 +403,9 @@ function renderTreeNode(node, path, at, changedFile) {
356
403
  loc === ""
357
404
  ? '<span class="cf-loc cf-loc-none">no source location</span>'
358
405
  : `<span class="cf-loc mono">${escapeHtml(loc)}</span>`,
359
- node.description ? `<span class="cf-desc">${escapeHtml(node.description)}</span>` : "",
406
+ purpose !== undefined
407
+ ? `<span class="cf-purpose">${escapeHtml(purpose)}</span>`
408
+ : node.description ? `<span class="cf-desc">${escapeHtml(node.description)}</span>` : "",
360
409
  // An anchor, not a button: it reaches the disclosure below without the script.
361
410
  `<a class="cf-src-link" href="#cf-f${at}-src-${id}" data-cf-source` +
362
411
  ` data-cf-file="${at}" data-cf-path="${id}">${node.source ? "source" : "details"}</a>`,
@@ -366,13 +415,17 @@ function renderTreeNode(node, path, at, changedFile) {
366
415
  const classes = ["cf-node", `cf-st-${status}`];
367
416
  if (inFile)
368
417
  classes.push("cf-infile");
418
+ if (purpose !== undefined)
419
+ classes.push("cf-explained");
420
+ else if (purposes.size > 0 && isPlumbing(node, purposes))
421
+ classes.push("cf-plumbing");
369
422
  const kids = node.children;
370
423
  const source = renderSource(node, id, at);
371
424
  if (kids.length === 0) {
372
425
  return `<li class="${classes.join(" ")} cf-leaf"${nodeAttrs(node, id, at)}><span class="cf-row">${row}</span>${source}</li>`;
373
426
  }
374
427
  const children = kids
375
- .map((child, childAt) => renderTreeNode(child, path.concat([childAt]), at, changedFile))
428
+ .map((child, childAt) => renderTreeNode(child, path.concat([childAt]), at, changedFile, purposes))
376
429
  .join("\n");
377
430
  return [
378
431
  `<li class="${classes.join(" ")}"${nodeAttrs(node, id, at)}>`,
@@ -412,7 +465,7 @@ function locationText(node) {
412
465
  function renderGraphMode(view, at) {
413
466
  const figures = view.trees
414
467
  .map((tree, root) => {
415
- const layout = layoutTree(tree, [root]);
468
+ const layout = layoutTree(tree, [root], view.purposes);
416
469
  const edges = layout.edges
417
470
  .map((edge, index) => renderGraphEdge(edge, index + 1, layout.edges.length, at))
418
471
  .join("");
@@ -446,26 +499,29 @@ function renderGraphMode(view, at) {
446
499
  * The enhanced viewport frames these coordinates without shrinking text.
447
500
  * Without JavaScript the native-size diagram scrolls inside its container.
448
501
  */
449
- function layoutTree(root, rootPath) {
502
+ function layoutTree(root, rootPath, purposes) {
450
503
  const levels = [];
451
504
  const boxes = [];
452
505
  const edges = [];
453
506
  const walk = (node, path, parent) => {
454
507
  const loc = locationText(node);
455
- const description = node.description ?? "";
508
+ const purpose = purposeOf(node, purposes);
509
+ const description = purpose === undefined ? node.description ?? "" : "";
456
510
  const labelChars = Math.max(1, node.label.length + (node.status === "same" ? 0 : 2));
457
- const width = Math.ceil(Math.min(GRAPH_MAX_WIDTH, Math.max(GRAPH_MIN_WIDTH, 48 +
511
+ const width = purpose !== undefined ? GRAPH_MAX_WIDTH : Math.ceil(Math.min(GRAPH_MAX_WIDTH, Math.max(GRAPH_MIN_WIDTH, 48 +
458
512
  Math.max(labelChars * GRAPH_CHAR, loc.length * GRAPH_LOC_CHAR, description.length * GRAPH_LOC_CHAR))));
513
+ const purposeLines = purpose === undefined ? [] : wrapPurpose(purpose, Math.floor((width - 48) / GRAPH_PURPOSE_CHAR), GRAPH_PURPOSE_LINES);
459
514
  const box = {
460
515
  node,
461
516
  path,
462
517
  x: 0,
463
518
  y: 0,
464
519
  width,
465
- height: description === "" ? GRAPH_NODE_HEIGHT : GRAPH_NODE_HEIGHT + GRAPH_DESC_HEIGHT,
520
+ height: GRAPH_NODE_HEIGHT + (description === "" ? 0 : GRAPH_DESC_HEIGHT) + purposeLines.length * GRAPH_PURPOSE_HEIGHT,
466
521
  labelChars: Math.floor((width - 48) / GRAPH_CHAR),
467
522
  locChars: Math.floor((width - 24) / GRAPH_LOC_CHAR),
468
523
  descChars: Math.floor((width - 24) / GRAPH_LOC_CHAR),
524
+ purposeLines,
469
525
  };
470
526
  const depth = path.length - 1;
471
527
  const level = levels[depth];
@@ -526,12 +582,14 @@ function renderGraphNode(box, at, changedFile) {
526
582
  const status = node.status;
527
583
  const loc = locationText(node);
528
584
  const source = node.source;
529
- const description = node.description ?? "";
585
+ // The agent's purpose replaces the docstring line: one explanation per box.
586
+ const description = box.purposeLines.length > 0 ? "" : node.description ?? "";
530
587
  const desc = description === "" ? "" : clip(description, box.descChars);
531
588
  const inFile = node.file === changedFile;
532
589
  const action = `href="#cf-f${at}-src-${path}" data-cf-source`;
533
590
  const title = [
534
591
  node.label,
592
+ box.purposeLines.join(" "),
535
593
  loc === "" ? "no source location" : loc,
536
594
  STATUS_WORD[status],
537
595
  description,
@@ -541,17 +599,23 @@ function renderGraphNode(box, at, changedFile) {
541
599
  .join(" · ");
542
600
  const mark = STATUS_MARK[status];
543
601
  const label = clip(mark ? `${mark} ${node.label}` : node.label, box.labelChars);
602
+ // An explained box leads with what the call does; its code name and place follow.
603
+ const lead = box.purposeLines.length * GRAPH_PURPOSE_HEIGHT;
604
+ const purposeLines = box.purposeLines
605
+ .map((line, index) => `<text class="cf-gpurpose" x="${box.x + 12}" y="${box.y + 19 + index * GRAPH_PURPOSE_HEIGHT}">${escapeHtml(line)}</text>`)
606
+ .join("");
544
607
  return [
545
- `<a class="cf-gnode cf-st-${status}${inFile ? " cf-infile" : ""}" ${action} data-cf-file="${at}" data-cf-path="${path}">`,
608
+ `<a class="cf-gnode cf-st-${status}${inFile ? " cf-infile" : ""}${lead > 0 ? " cf-explained" : ""}" ${action} data-cf-file="${at}" data-cf-path="${path}">`,
546
609
  `<title>${escapeHtml(title)}</title>`,
547
610
  `<rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="7"></rect>`,
548
- `<text class="cf-glabel" x="${box.x + 12}" y="${loc === "" && desc === "" ? box.y + 25 : box.y + 17}">${escapeHtml(label)}</text>`,
611
+ purposeLines,
612
+ `<text class="cf-glabel" x="${box.x + 12}" y="${lead + (loc === "" && desc === "" ? box.y + 25 : box.y + 17)}">${escapeHtml(label)}</text>`,
549
613
  loc === ""
550
614
  ? ""
551
- : `<text class="cf-gloc" x="${box.x + 12}" y="${box.y + 31}">${escapeHtml(clip(loc, box.locChars))}</text>`,
615
+ : `<text class="cf-gloc" x="${box.x + 12}" y="${lead + box.y + 31}">${escapeHtml(clip(loc, box.locChars))}</text>`,
552
616
  desc === ""
553
617
  ? ""
554
- : `<text class="cf-gdesc" x="${box.x + 12}" y="${loc === "" ? box.y + 31 : box.y + 44}">${escapeHtml(desc)}</text>`,
618
+ : `<text class="cf-gdesc" x="${box.x + 12}" y="${lead + (loc === "" ? box.y + 31 : box.y + 44)}">${escapeHtml(desc)}</text>`,
555
619
  "</a>",
556
620
  `<a class="cf-gzoom" href="#cf-f${at}-t-${path}" data-cf-zoom data-cf-file="${at}" data-cf-path="${path}" aria-label="Zoom into ${escapeHtml(node.label)}">`,
557
621
  `<circle cx="${box.x + box.width - 13}" cy="${box.y + 13}" r="10"></circle>`,
@@ -570,9 +634,15 @@ function clip(text, maxChars) {
570
634
  function renderSequenceMode(view, at) {
571
635
  const paths = [];
572
636
  view.trees.forEach((tree, root) => collectPaths(tree, [root], [], paths));
637
+ // With an explanation, a path of plumbing alone says nothing about the product.
638
+ if (view.purposes.size > 0) {
639
+ const explained = paths.filter((chain) => chain.some((step) => purposeOf(step.node, view.purposes) !== undefined));
640
+ if (explained.length > 0)
641
+ paths.splice(0, paths.length, ...explained);
642
+ }
573
643
  const shown = Math.min(paths.length, SEQUENCE_LIMIT);
574
644
  const note = `<p class="cf-note cf-omitted"${paths.length <= SEQUENCE_LIMIT ? " hidden" : ""}>Showing ${shown} of ${paths.length} call paths; focus a branch to narrow.</p>`;
575
- return modeSection("sequence", at, `<p class="cf-note">Static call paths, not execution order.</p><ol class="cf-paths" data-cf-path-limit="${SEQUENCE_LIMIT}">${paths.map((chain) => renderPath(chain, at)).join("\n")}</ol>${note}`);
645
+ return modeSection("sequence", at, `<p class="cf-note">Static call paths, not execution order.</p><ol class="cf-paths" data-cf-path-limit="${SEQUENCE_LIMIT}">${paths.map((chain) => renderPath(chain, at, view.purposes)).join("\n")}</ol>${note}`);
576
646
  }
577
647
  function collectPaths(node, path, chain, out) {
578
648
  const next = chain.concat([{ node, path }]);
@@ -583,18 +653,19 @@ function collectPaths(node, path, chain, out) {
583
653
  }
584
654
  kids.forEach((child, childAt) => collectPaths(child, path.concat([childAt]), next, out));
585
655
  }
586
- function renderPath(chain, at) {
656
+ function renderPath(chain, at, purposes) {
587
657
  const leaf = chain[chain.length - 1];
588
658
  const chips = chain
589
- .map((step) => renderChip(step, at))
659
+ .map((step) => renderChip(step, at, purposes))
590
660
  .join('<span class="cf-arrow" aria-hidden="true">→</span>');
591
661
  return `<li class="cf-path" data-cf-file="${at}" data-cf-path="${leaf.path.join("-")}">${chips}</li>`;
592
662
  }
593
663
  /** A chip is the step's zoom target plus, when the backend resolved it, its own
594
664
  * source link: without the script both are plain anchors that reach the tree. */
595
- function renderChip(step, at) {
665
+ function renderChip(step, at, purposes) {
596
666
  const path = step.path.join("-");
597
667
  const node = step.node;
668
+ const purpose = purposeOf(node, purposes);
598
669
  const status = node.status;
599
670
  const loc = locationText(node);
600
671
  const title = loc === "" ? "" : ` title="${escapeHtml(loc)}"`;
@@ -605,7 +676,9 @@ function renderChip(step, at) {
605
676
  `<span class="cf-chip-label mono">${escapeHtml(node.label)}</span>`,
606
677
  loc === "" ? "" : `<span class="cf-chip-loc mono">${escapeHtml(loc)}</span>`,
607
678
  "</a>",
608
- node.description ? `<span class="cf-desc">${escapeHtml(node.description)}</span>` : "",
679
+ purpose !== undefined
680
+ ? `<span class="cf-purpose">${escapeHtml(purpose)}</span>`
681
+ : node.description ? `<span class="cf-desc">${escapeHtml(node.description)}</span>` : "",
609
682
  `<a class="cf-src-link" href="#cf-f${at}-src-${path}" data-cf-source` +
610
683
  ` data-cf-file="${at}" data-cf-path="${path}">${node.source ? "source" : "details"}</a>`,
611
684
  "</span>",
@@ -957,6 +1030,17 @@ export const CALL_FLOW_STYLES = `
957
1030
  .cf-arrow { color: var(--ink-soft); font-size: 12px; }
958
1031
  /* One description line per call, only when the backend attached one. */
959
1032
  .cf-desc { font-size: 11.5px; color: var(--ink-soft); font-style: italic; overflow-wrap: anywhere; }
1033
+ .cf-business { display: flex; flex-direction: column; gap: 6px; margin: 4px 0 10px; }
1034
+ .cf-business-note { font-size: 13px; color: var(--ink-soft); }
1035
+ .cf-plumbing-toggle { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--ink-soft); cursor: pointer; width: fit-content; }
1036
+ .cf:has(.cf-show-plumbing:not(:checked)) .cf-node.cf-plumbing { display: none; }
1037
+ .cf-purpose { font-family: var(--sans); font-size: 13.5px; font-weight: 550; color: var(--ink); overflow-wrap: anywhere; }
1038
+ .cf-row > .cf-purpose { flex: 1 0 100%; order: -1; padding-left: 26px; }
1039
+ .cf-fold > .cf-row > .cf-purpose { padding-left: 16px; }
1040
+ .cf-node.cf-explained > .cf-row .cf-label, .cf-node.cf-explained > .cf-fold > .cf-row .cf-label { font-size: 11.5px; }
1041
+ .cf-chip .cf-purpose { font-size: 12.5px; max-width: 34ch; }
1042
+ .cf-gpurpose { font-family: var(--sans); font-size: 12px; font-weight: 600; fill: var(--ink); }
1043
+ .cf-diagram-nav-name.cf-diagram-nav-purpose { font-family: var(--sans); white-space: normal; }
960
1044
  .cf-row > .cf-desc { flex: 1 0 100%; order: 5; padding-left: 26px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
961
1045
  .cf-row > .cf-src-link { order: 4; }
962
1046
  .cf-depth { align-items: center; gap: 4px; }
@@ -1610,6 +1694,16 @@ export const CALL_FLOW_SCRIPT = `
1610
1694
  count.className = 'cf-diagram-nav-count';
1611
1695
  var calls = frames[i].querySelectorAll('a.cf-gnode').length;
1612
1696
  count.textContent = (dot > 0 && label.slice(0, 4) !== 'new ' ? label.slice(0, dot) + ', ' : '') + calls + (calls === 1 ? ' call' : ' calls');
1697
+ // An explained entry point leads with what it does for the product; its name moves under it.
1698
+ var lead = frames[i].querySelector('a.cf-gnode');
1699
+ var said = lead ? lead.querySelectorAll('.cf-gpurpose') : [];
1700
+ if (said.length > 0) {
1701
+ var words = [];
1702
+ for (var w = 0; w < said.length; w++) words.push(said[w].textContent);
1703
+ count.textContent = name.textContent + ' \u00b7 ' + count.textContent;
1704
+ name.textContent = words.join(' ');
1705
+ name.classList.add('cf-diagram-nav-purpose');
1706
+ }
1613
1707
  button.appendChild(name);
1614
1708
  button.appendChild(count);
1615
1709
  item.appendChild(button);
@@ -11,7 +11,8 @@
11
11
  * MCP client that recorded it.
12
12
  */
13
13
  import { type QuestionKind, type Verdict } from "./questions.js";
14
- import type { ReviewReport, ReviewStatus, SuggestedComment } from "./types.js";
14
+ import type { AgentSummary, ReviewReport, ReviewStatus, SuggestedComment } from "./types.js";
15
+ import type { ExplanationChange } from "./explanation.js";
15
16
  /** Most agenda entries the page lists; the full report has the rest. */
16
17
  export declare const CONNECTED_AGENDA_LIMIT = 5;
17
18
  export interface ConnectedFact {
@@ -45,6 +46,22 @@ export interface ConnectedHunk {
45
46
  /** One fixed sentence when the hunk was not read: a metadata unit or an unread file type. */
46
47
  note?: string;
47
48
  readonly questions: readonly ConnectedQuestion[];
49
+ /** The process steps and rules the reviewing agent tied to this hunk, in its words. */
50
+ business?: readonly ConnectedBusinessLink[];
51
+ }
52
+ /** One step or rule of the agent's explanation that names a hunk. */
53
+ export interface ConnectedBusinessLink {
54
+ /** The process title for a step; absent for a business rule. */
55
+ readonly process?: string;
56
+ readonly text: string;
57
+ readonly change: ExplanationChange;
58
+ }
59
+ /** What the page needs to show the business view under the goal; the drawing itself is served by `/flow?view=business`. */
60
+ export interface ConnectedExplanation {
61
+ readonly explainedBy: string;
62
+ readonly processes: readonly string[];
63
+ readonly rules: number;
64
+ readonly functions: number;
48
65
  }
49
66
  /** Whether the analysis had a local clone's definitions and call flows. */
50
67
  export interface ConnectedScope {
@@ -70,13 +87,23 @@ export interface ConnectedAnalysis {
70
87
  readonly total: number;
71
88
  readonly answered: number;
72
89
  };
73
- /** Line comments the reviewing agent suggested, for the human to add to their review or not. */
74
90
  /** Changed files that have call-flow diagrams, for the page's "Call flow" buttons; empty for a patch-only analysis. */
75
91
  readonly callFlowFiles: readonly string[];
92
+ /** Line comments the reviewing agent suggested, for the human to add to their review or not. */
76
93
  suggestions?: {
77
94
  readonly suggestedBy: string;
78
95
  readonly comments: readonly SuggestedComment[];
79
96
  };
97
+ /**
98
+ * The reviewing agent's own short paragraph on what the pull request does and
99
+ * why, attributed to the client that wrote it. Absent when no finish_review
100
+ * accepted one, which the page must say instead of showing a goal of its own:
101
+ * diffninja generates no summary, and this one is the agent's reading of the
102
+ * author's stated intent, not a claim that the changes achieve it.
103
+ */
104
+ summary?: AgentSummary;
105
+ /** Present once the reviewing agent's business explanation was accepted for this report. */
106
+ explanation?: ConnectedExplanation;
80
107
  }
81
108
  export type ConnectedOrder = {
82
109
  readonly source: "agent";
@@ -106,6 +106,17 @@ export function connectedAnalysisOf(report, snapshotId, reviewId, reportUrl, sco
106
106
  list.push(view);
107
107
  questionsByUnit.set(owner, list);
108
108
  }
109
+ const business = new Map();
110
+ const link = (hunks, entry) => {
111
+ for (const id of new Set(hunks ?? []))
112
+ business.set(id, [...(business.get(id) ?? []), entry]);
113
+ };
114
+ for (const process of report.agentExplanation?.processes ?? []) {
115
+ for (const step of process.steps)
116
+ link(step.hunks, { process: process.title, text: step.text, change: step.change });
117
+ }
118
+ for (const rule of report.agentExplanation?.rules ?? [])
119
+ link(rule.hunks, { text: rule.text, change: rule.change });
109
120
  const counts = { attention: 0, uncertain: 0, low: 0, passed: 0 };
110
121
  const hunks = report.items.map((item) => {
111
122
  counts[item.status] += 1;
@@ -136,6 +147,9 @@ export function connectedAnalysisOf(report, snapshotId, reviewId, reportUrl, sco
136
147
  const note = noteOf(item);
137
148
  if (note !== undefined)
138
149
  hunk.note = note;
150
+ const links = business.get(item.id);
151
+ if (links !== undefined)
152
+ hunk.business = links;
139
153
  return hunk;
140
154
  });
141
155
  const answered = report.questions.filter((question) => question.answer !== undefined).length;
@@ -159,5 +173,19 @@ export function connectedAnalysisOf(report, snapshotId, reviewId, reportUrl, sco
159
173
  if (report.agentComments !== undefined) {
160
174
  analysis.suggestions = { suggestedBy: report.agentComments.suggestedBy, comments: report.agentComments.comments };
161
175
  }
176
+ // Only finish_review stores a summary, so this is present exactly when the
177
+ // agent's whole reading was accepted for this snapshot's report; it is copied
178
+ // verbatim, attributed, and never synthesized here.
179
+ if (report.agentSummary !== undefined)
180
+ analysis.summary = report.agentSummary;
181
+ const explanation = report.agentExplanation;
182
+ if (explanation !== undefined) {
183
+ analysis.explanation = {
184
+ explainedBy: explanation.explainedBy,
185
+ processes: explanation.processes.map((process) => process.title),
186
+ rules: explanation.rules.length,
187
+ functions: explanation.functions.length,
188
+ };
189
+ }
162
190
  return analysis;
163
191
  }