diffninja 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +65 -11
  2. package/dist/executables.d.ts +18 -0
  3. package/dist/executables.js +32 -0
  4. package/dist/git.d.ts +26 -1
  5. package/dist/git.js +56 -4
  6. package/dist/languages/child-env.d.ts +11 -0
  7. package/dist/languages/child-env.js +60 -0
  8. package/dist/languages/grammar-lock.d.ts +569 -0
  9. package/dist/languages/grammar-lock.js +574 -0
  10. package/dist/languages/grammars.d.ts +69 -9
  11. package/dist/languages/grammars.js +186 -119
  12. package/dist/review/call-flow-html.js +1 -0
  13. package/dist/review/change-facts.d.ts +21 -1
  14. package/dist/review/change-facts.js +271 -49
  15. package/dist/review/cli.js +13 -1
  16. package/dist/review/connected-analysis.d.ts +4 -1
  17. package/dist/review/connected-analysis.js +4 -2
  18. package/dist/review/connected-html.d.ts +15 -4
  19. package/dist/review/connected-html.js +338 -43
  20. package/dist/review/connected.js +47 -14
  21. package/dist/review/escape-html.d.ts +5 -1
  22. package/dist/review/escape-html.js +7 -2
  23. package/dist/review/explanation.d.ts +4 -0
  24. package/dist/review/explanation.js +6 -1
  25. package/dist/review/github.d.ts +56 -0
  26. package/dist/review/github.js +234 -33
  27. package/dist/review/grammars-command.d.ts +12 -0
  28. package/dist/review/grammars-command.js +60 -0
  29. package/dist/review/hidden-characters.d.ts +31 -0
  30. package/dist/review/hidden-characters.js +113 -0
  31. package/dist/review/history.js +7 -3
  32. package/dist/review/html.js +11 -3
  33. package/dist/review/input.js +5 -2
  34. package/dist/review/intent.d.ts +7 -0
  35. package/dist/review/intent.js +25 -3
  36. package/dist/review/markdown.js +11 -0
  37. package/dist/review/mcp-cli.js +4 -1
  38. package/dist/review/mcp.d.ts +7 -1
  39. package/dist/review/mcp.js +145 -56
  40. package/dist/review/pipeline.d.ts +2 -1
  41. package/dist/review/pipeline.js +4 -3
  42. package/dist/review/pr-input.d.ts +7 -0
  43. package/dist/review/pr-input.js +25 -3
  44. package/dist/review/questions.js +11 -3
  45. package/dist/review/reference-check.d.ts +5 -1
  46. package/dist/review/reference-check.js +40 -16
  47. package/dist/review/report-pages.d.ts +24 -9
  48. package/dist/review/report-pages.js +111 -28
  49. package/dist/review/result-budget.d.ts +28 -0
  50. package/dist/review/result-budget.js +136 -0
  51. package/dist/review/service.js +26 -2
  52. package/dist/review/setup.d.ts +1 -1
  53. package/dist/review/setup.js +9 -4
  54. package/dist/review/types.d.ts +19 -9
  55. package/dist/review/types.js +2 -2
  56. package/dist/review/update-check.d.ts +35 -0
  57. package/dist/review/update-check.js +76 -0
  58. package/dist/run.js +11 -5
  59. package/npm-shrinkwrap.json +3483 -0
  60. package/package.json +3 -2
@@ -1,6 +1,7 @@
1
1
  import { BRAND_MARK, BRAND_MARK_STYLES } from "./brand.js";
2
2
  import { PALETTE_STYLES } from "./palette.js";
3
3
  import { escapeHtml } from "./escape-html.js";
4
+ import { HIDDEN_CHARACTER_FALLBACK_SOURCE, HIDDEN_CHARACTER_SOURCE } from "./hidden-characters.js";
4
5
  /**
5
6
  * The one page the connected session serves.
6
7
  *
@@ -8,17 +9,25 @@ import { escapeHtml } from "./escape-html.js";
8
9
  * draft to `/api/preview`, and posts the same payload to `/api/submit`. Every
9
10
  * string that comes from GitHub (paths, diff lines, the diff text itself, error
10
11
  * messages) reaches the DOM through `textContent`; nothing is interpolated into
11
- * markup or into the script. Style and script carry the session CSRF token as
12
- * their CSP nonce, so the server can serve a `default-src 'none'` policy with
13
- * no inline handlers and no inline styles.
12
+ * markup or into the script. Style and script carry a per-response nonce, so the
13
+ * server can serve a `default-src 'none'` policy with no inline handlers and no
14
+ * inline styles; the session's CSRF token is a different secret, and the whole
15
+ * page lives under the session's secret path prefix.
14
16
  *
15
17
  * Drafts live in `sessionStorage`, keyed per pull request and stamped with the
16
18
  * snapshot id. Restoring a draft revalidates every comment anchor against the
17
19
  * snapshot on screen: a comment whose line no longer exists in the new revision
18
20
  * is dropped, never silently carried over.
21
+ *
22
+ * A change is viewed only by the reader's click on its rail checkbox, never by
23
+ * scrolling. GitHub's Viewed mark is per file, so the page marks a file Viewed
24
+ * on GitHub (`POST api/viewed`) exactly when every hunk of it is viewed, and
25
+ * un-marks it when one is not. Progress inside a file stays in `sessionStorage`,
26
+ * keyed by snapshot id.
19
27
  */
20
- export function renderConnectedPage(csrf) {
21
- const nonce = escapeHtml(csrf);
28
+ export function renderConnectedPage(options) {
29
+ const { csrf, base } = options;
30
+ const nonce = escapeHtml(options.nonce);
22
31
  return [
23
32
  "<!doctype html>",
24
33
  '<html lang="en">',
@@ -36,6 +45,7 @@ export function renderConnectedPage(csrf) {
36
45
  `<p class="brand">${BRAND_MARK}<span>diffninja</span></p>`,
37
46
  '<h1 id="page-title">Pull request review</h1>',
38
47
  '<p id="page-meta" class="page-meta" hidden></p>',
48
+ '<p id="update-notice" class="update-notice" role="status" hidden></p>',
39
49
  '<section id="pr-goal" class="pr-goal" aria-labelledby="pr-goal-heading" hidden>',
40
50
  '<h2 id="pr-goal-heading" class="pr-goal-heading">Goal</h2>',
41
51
  '<p id="pr-goal-text" class="pr-goal-text"></p>',
@@ -83,7 +93,9 @@ export function renderConnectedPage(csrf) {
83
93
  '<header class="rail-head">',
84
94
  '<h2 id="analysis-heading">Reading order</h2>',
85
95
  '<p id="analysis-sub" class="rail-sub"></p>',
86
- '<p id="rail-progress" class="rail-progress" hidden><span id="rail-at"></span><span class="rail-keys"><kbd>j</kbd> <kbd>k</kbd> next and previous</span></p>',
96
+ '<p id="rail-progress" class="rail-progress" hidden><span id="rail-at"></span><span class="rail-keys"><kbd>j</kbd> <kbd>k</kbd> next and previous</span><span id="rail-viewed-count" class="rail-viewed-count"></span></p>',
97
+ '<p id="rail-sync-note" class="rail-note" hidden></p>',
98
+ '<p id="rail-viewed-error" class="rail-note is-error" role="alert" hidden></p>',
87
99
  '<div id="analysis-actions" class="rail-actions"></div>',
88
100
  "</header>",
89
101
  '<div id="analysis-body" class="rail-body"></div>',
@@ -177,13 +189,13 @@ export function renderConnectedPage(csrf) {
177
189
  "</div>",
178
190
  '<iframe id="flow-frame" class="flow-frame" title="Call flow"></iframe>',
179
191
  "</aside>",
180
- `<script nonce="${nonce}">${script(csrf)}</script>`,
192
+ `<script nonce="${nonce}">${script(csrf, base)}</script>`,
181
193
  "</body>",
182
194
  "</html>",
183
195
  "",
184
196
  ].join("\n");
185
197
  }
186
- function script(csrf) {
198
+ function script(csrf, base) {
187
199
  // The nonce doubles as a JS string literal; `\u003c` keeps a hostile value
188
200
  // from closing the surrounding script element.
189
201
  return `
@@ -191,6 +203,8 @@ function script(csrf) {
191
203
  'use strict';
192
204
 
193
205
  var CSRF = ${JSON.stringify(csrf).replaceAll("<", "\\u003c")};
206
+ // Every request goes under the session's secret prefix; without it the server answers 404.
207
+ var BASE = ${JSON.stringify(base).replaceAll("<", "\\u003c")};
194
208
  var DRAFT_PREFIX = 'diffninja.connected.draft.v1';
195
209
 
196
210
  var state = null;
@@ -227,21 +241,40 @@ function script(csrf) {
227
241
  var stopsNow = [];
228
242
  var currentRank = 0;
229
243
  var followedRank = 0;
230
- var seenRanks = Object.create(null);
231
- var seenFor = '';
244
+ var DRAFT_VIEWED_PREFIX = 'diffninja.connected.viewed.v1';
245
+ var viewedHunks = Object.create(null);
246
+ var viewedFor = '';
247
+ var viewedDirty = true;
248
+ var viewedError = '';
249
+ var fileSync = Object.create(null);
232
250
  var el = {};
233
251
 
234
252
  function byId(id) { return document.getElementById(id); }
235
253
 
254
+ // Bidirectional and other invisible control characters would let untrusted text reorder or hide
255
+ // what the reviewer reads; every string from the pull request is shown with visible markers instead.
256
+ var HIDDEN;
257
+ try {
258
+ HIDDEN = new RegExp(${JSON.stringify(HIDDEN_CHARACTER_SOURCE).replaceAll("<", "\\u003c")}, 'gu');
259
+ } catch (unsupported) {
260
+ // A browser that cannot compile the lookbehind marks every such character rather than lose the page.
261
+ HIDDEN = new RegExp(${JSON.stringify(HIDDEN_CHARACTER_FALLBACK_SOURCE)}, 'gu');
262
+ }
263
+ function visible(value) {
264
+ return String(value).replace(HIDDEN, function (character) {
265
+ return '\u27E6U+' + character.codePointAt(0).toString(16).toUpperCase().padStart(4, '0') + '\u27E7';
266
+ });
267
+ }
268
+
236
269
  function make(tag, className, textValue) {
237
270
  var node = document.createElement(tag);
238
271
  if (className) node.className = className;
239
- if (textValue !== undefined && textValue !== null) node.textContent = String(textValue);
272
+ if (textValue !== undefined && textValue !== null) node.textContent = visible(textValue);
240
273
  return node;
241
274
  }
242
275
 
243
276
  function setText(node, value) {
244
- node.textContent = value === undefined || value === null ? '' : String(value);
277
+ node.textContent = value === undefined || value === null ? '' : visible(value);
245
278
  }
246
279
 
247
280
  function show(node, visible) { node.hidden = !visible; }
@@ -321,6 +354,7 @@ function script(csrf) {
321
354
  return;
322
355
  }
323
356
  state = next;
357
+ viewedDirty = true;
324
358
  var snap = snapshot();
325
359
  if (snap) restoreDraft(snap);
326
360
  }
@@ -490,7 +524,7 @@ function script(csrf) {
490
524
  init.headers['X-Diffninja-CSRF'] = CSRF;
491
525
  init.body = JSON.stringify(payload === undefined ? {} : payload);
492
526
  }
493
- return fetch(path, init).then(function (response) {
527
+ return fetch(BASE + (path.charAt(0) === '/' ? path.slice(1) : path), init).then(function (response) {
494
528
  return response.text().then(function (raw) {
495
529
  var data = null;
496
530
  if (raw) {
@@ -942,7 +976,7 @@ function script(csrf) {
942
976
  if (!node || typeof node !== 'object') continue;
943
977
  var type = typeof node.t === 'string' ? node.t : '';
944
978
  if (type === 'text') {
945
- if (typeof node.v === 'string') host.appendChild(document.createTextNode(node.v));
979
+ if (typeof node.v === 'string') host.appendChild(document.createTextNode(visible(node.v)));
946
980
  continue;
947
981
  }
948
982
  if (type === 'code') {
@@ -973,7 +1007,7 @@ function script(csrf) {
973
1007
  continue;
974
1008
  }
975
1009
  // Anything this page does not know is shown as its own text.
976
- if (typeof node.v === 'string') host.appendChild(document.createTextNode(node.v));
1010
+ if (typeof node.v === 'string') host.appendChild(document.createTextNode(visible(node.v)));
977
1011
  }
978
1012
  }
979
1013
 
@@ -1104,6 +1138,19 @@ function script(csrf) {
1104
1138
  * nothing rather than a stale goal — and when none was recorded the block
1105
1139
  * says so instead of describing the pull request itself.
1106
1140
  */
1141
+ /** A newer diffninja exists: one line with the command, from the analysis the page already polls. */
1142
+ function renderUpdate() {
1143
+ var current = currentAnalysis();
1144
+ var u = current && current.update && typeof current.update === 'object' ? current.update : null;
1145
+ var valid = u && typeof u.latest === 'string' && typeof u.current === 'string' && typeof u.command === 'string';
1146
+ el.updateNotice.textContent = '';
1147
+ show(el.updateNotice, Boolean(valid));
1148
+ if (!valid) return;
1149
+ el.updateNotice.appendChild(document.createTextNode('diffninja ' + u.latest + ' is available (you have ' + u.current + '). Update by running '));
1150
+ el.updateNotice.appendChild(make('code', '', u.command));
1151
+ el.updateNotice.appendChild(document.createTextNode(' in a terminal, then restart your agent.'));
1152
+ }
1153
+
1107
1154
  function renderGoal() {
1108
1155
  var snap = snapshot();
1109
1156
  // A revision that cannot be reviewed gets no goal block: there is nothing to aim the reading at.
@@ -1146,7 +1193,7 @@ function script(csrf) {
1146
1193
  var by = typeof explanation.explainedBy === 'string' && explanation.explainedBy.trim() !== '' ? explanation.explainedBy.trim() : 'your review agent';
1147
1194
  var processes = Array.isArray(explanation.processes) ? explanation.processes.length : 0;
1148
1195
  setText(el.prHowBy, 'Explained by ' + by + ' from the code: ' + processes + (processes === 1 ? ' process' : ' processes') + ' and ' + (explanation.rules || 0) + ((explanation.rules || 0) === 1 ? ' business rule' : ' business rules') + '. Its reading, not a verdict; the diff below is the ground truth.');
1149
- var src = '/flow?snapshot=' + encodeURIComponent(current.snapshotId) + '&view=business';
1196
+ var src = BASE + 'flow?snapshot=' + encodeURIComponent(current.snapshotId) + '&view=business';
1150
1197
  if (el.prHowFrame.getAttribute('src') !== src) el.prHowFrame.setAttribute('src', src);
1151
1198
  }
1152
1199
 
@@ -1268,6 +1315,7 @@ function script(csrf) {
1268
1315
  var entry = make('li', 'order-item');
1269
1316
  entry.dataset.action = 'goto-stop';
1270
1317
  entry.dataset.rank = String(rank);
1318
+ entry.dataset.hunk = String(hunk.id);
1271
1319
  entry.dataset.path = String(hunk.file);
1272
1320
  entry.dataset.line = String(hunk.line);
1273
1321
  entry.dataset.side = hunk.side === 'LEFT' ? 'LEFT' : 'RIGHT';
@@ -1287,6 +1335,7 @@ function script(csrf) {
1287
1335
  jump.dataset.rank = entry.dataset.rank;
1288
1336
  main.appendChild(jump);
1289
1337
  entry.appendChild(main);
1338
+ entry.appendChild(viewedBox(hunk, rank));
1290
1339
  return entry;
1291
1340
  }
1292
1341
 
@@ -1382,11 +1431,13 @@ function script(csrf) {
1382
1431
  var snap = snapshot();
1383
1432
  // The goal rides on the analysis, so it re-renders wherever the analysis does — every poll included.
1384
1433
  renderGoal();
1434
+ renderUpdate();
1385
1435
  renderHow();
1386
1436
  show(el.analysisSection, Boolean(snap) && !(typeof snap.unavailableReason === 'string' && snap.unavailableReason !== ''));
1387
1437
  el.analysisBody.textContent = '';
1388
1438
  el.analysisActions.textContent = '';
1389
1439
  setText(el.analysisSub, '');
1440
+ clearViewedNotes();
1390
1441
  renderAnalysisDetails(currentAnalysis());
1391
1442
  if (!snap) return;
1392
1443
  if (!analysis || analysisFor !== snap.id) {
@@ -1416,10 +1467,12 @@ function script(csrf) {
1416
1467
  el.analysisBody.appendChild(make('p', 'inline-note', 'No call-flow diagrams: ' + analysis.scope.note.replace(/^Patch-only: /, '')));
1417
1468
  }
1418
1469
  if (!el.flowDrawer.hidden && flowSnapshot !== analysis.snapshotId) closeFlow();
1470
+ if (viewedDirty || viewedFor !== snap.id) adoptViewed();
1419
1471
  var list = make('ol', 'order-list');
1420
1472
  var hunks = Array.isArray(analysis.hunks) ? analysis.hunks : [];
1421
1473
  for (var h = 0; h < hunks.length; h += 1) list.appendChild(renderHunkEntry(hunks[h], h + 1));
1422
1474
  el.analysisBody.appendChild(list);
1475
+ paintViewed();
1423
1476
  }
1424
1477
 
1425
1478
  function gotoLine(node) {
@@ -1445,6 +1498,211 @@ function script(csrf) {
1445
1498
  if (action) action.focus({ preventScroll: true });
1446
1499
  }
1447
1500
 
1501
+ /* ------------------------------------------------------------- viewed -- */
1502
+
1503
+ function viewedStoreKey(snap) { return DRAFT_VIEWED_PREFIX + '|' + snap.id; }
1504
+
1505
+ /** The tab's own viewed flags for one snapshot, hunk id to true; none when storage is unusable. */
1506
+ function readViewedStore(snap) {
1507
+ var flags = Object.create(null);
1508
+ var store = storage();
1509
+ if (!store) return flags;
1510
+ try {
1511
+ var stored = JSON.parse(store.getItem(viewedStoreKey(snap)) || '[]');
1512
+ if (Array.isArray(stored)) {
1513
+ for (var i = 0; i < stored.length; i += 1) {
1514
+ if (typeof stored[i] === 'string') flags[stored[i]] = true;
1515
+ }
1516
+ }
1517
+ } catch (error) {
1518
+ /* Unreadable or blocked: this tab starts with none marked. */
1519
+ }
1520
+ return flags;
1521
+ }
1522
+
1523
+ function saveViewedStore() {
1524
+ var snap = snapshot();
1525
+ var store = storage();
1526
+ if (!snap || !store || viewedFor !== snap.id) return;
1527
+ try {
1528
+ store.setItem(viewedStoreKey(snap), JSON.stringify(Object.keys(viewedHunks)));
1529
+ } catch (error) {
1530
+ /* Storage disabled or full: the marks stay in memory. */
1531
+ }
1532
+ }
1533
+
1534
+ function analysisHunks() {
1535
+ var current = currentAnalysis();
1536
+ return current && Array.isArray(current.hunks) ? current.hunks : [];
1537
+ }
1538
+
1539
+ function hunksOfFile(path) {
1540
+ return analysisHunks().filter(function (hunk) { return hunk.file === path; });
1541
+ }
1542
+
1543
+ /** GitHub's Viewed is per file: a file counts as viewed exactly when it has hunks and every one of them is viewed. */
1544
+ function fileViewed(path) {
1545
+ var mine = hunksOfFile(path);
1546
+ return mine.length > 0 && mine.every(function (hunk) { return viewedHunks[hunk.id] === true; });
1547
+ }
1548
+
1549
+ /** Whether the server could read GitHub's marks, so a click may be mirrored there. */
1550
+ function viewedSynced() {
1551
+ return Boolean(state && state.viewed && state.viewed.available === true);
1552
+ }
1553
+
1554
+ /**
1555
+ * Bring the flags in line with what the server last knew of GitHub. GitHub wins
1556
+ * per file: a file it calls viewed shows every hunk viewed, and a file it does
1557
+ * not call viewed cannot show all of them. Progress inside a file is the tab's
1558
+ * own and is kept. A file with a request out is left to that request.
1559
+ */
1560
+ function adoptViewed() {
1561
+ var snap = snapshot();
1562
+ if (!snap) return;
1563
+ if (viewedFor !== snap.id) {
1564
+ viewedFor = snap.id;
1565
+ viewedHunks = readViewedStore(snap);
1566
+ fileSync = Object.create(null);
1567
+ }
1568
+ var hunks = analysisHunks();
1569
+ var known = Object.create(null);
1570
+ for (var h = 0; h < hunks.length; h += 1) known[hunks[h].id] = true;
1571
+ for (var id in viewedHunks) {
1572
+ if (known[id] !== true) delete viewedHunks[id];
1573
+ }
1574
+ var files = viewedSynced() && Array.isArray(state.viewed.files) ? state.viewed.files : [];
1575
+ var synced = Object.create(null);
1576
+ for (var f = 0; f < files.length; f += 1) {
1577
+ var entry = files[f];
1578
+ if (!entry || typeof entry.path !== 'string') continue;
1579
+ synced[entry.path] = true;
1580
+ var record = fileSync[entry.path] || (fileSync[entry.path] = { confirmed: false, sending: false, last: '' });
1581
+ if (record.sending) continue;
1582
+ record.confirmed = entry.viewed === true;
1583
+ var mine = hunksOfFile(entry.path);
1584
+ if (record.confirmed) {
1585
+ for (var a = 0; a < mine.length; a += 1) viewedHunks[mine[a].id] = true;
1586
+ } else if (fileViewed(entry.path)) {
1587
+ for (var b = 0; b < mine.length; b += 1) delete viewedHunks[mine[b].id];
1588
+ }
1589
+ }
1590
+ for (var path in fileSync) {
1591
+ if (synced[path] !== true) delete fileSync[path];
1592
+ }
1593
+ viewedDirty = false;
1594
+ saveViewedStore();
1595
+ }
1596
+
1597
+ /** The rail's checkbox for one change: a real button, so it takes focus, Space and Enter. */
1598
+ function viewedBox(hunk, rank) {
1599
+ var box = make('button', 'viewed-box');
1600
+ box.type = 'button';
1601
+ box.dataset.action = 'toggle-viewed';
1602
+ box.dataset.hunk = String(hunk.id);
1603
+ box.setAttribute('role', 'checkbox');
1604
+ box.setAttribute('aria-checked', viewedHunks[hunk.id] === true ? 'true' : 'false');
1605
+ box.setAttribute('aria-label', visible('Mark change ' + rank + ' of ' + hunk.file + ' as viewed'));
1606
+ var mirrored = viewedSynced() && fileSync[hunk.file] !== undefined;
1607
+ box.title = visible(mirrored ? 'Marks the file viewed on GitHub once every change in it is viewed' : 'Kept in this tab only');
1608
+ return box;
1609
+ }
1610
+
1611
+ /** Repaint the flags in place, so a focused checkbox keeps focus. */
1612
+ function paintViewed() {
1613
+ var stations = el.analysisBody.querySelectorAll('.order-item');
1614
+ var count = 0;
1615
+ for (var i = 0; i < stations.length; i += 1) {
1616
+ var station = stations[i];
1617
+ var on = viewedHunks[station.dataset.hunk] === true;
1618
+ if (on) count += 1;
1619
+ station.classList.toggle('is-viewed', on);
1620
+ var box = station.querySelector('.viewed-box');
1621
+ if (!box) continue;
1622
+ box.setAttribute('aria-checked', on ? 'true' : 'false');
1623
+ var record = fileSync[station.dataset.path];
1624
+ box.classList.toggle('is-pending', Boolean(record && record.sending));
1625
+ }
1626
+ setText(el.railViewedCount, stations.length > 0 ? count + ' of ' + stations.length + ' viewed' : '');
1627
+ var note = '';
1628
+ if (stations.length > 0 && !viewedSynced()) {
1629
+ var reason = state && state.viewed && typeof state.viewed.reason === 'string' && state.viewed.reason !== '' ? state.viewed.reason : 'GitHub did not offer viewed marks';
1630
+ note = 'not synced with GitHub: ' + reason;
1631
+ }
1632
+ setText(el.railSyncNote, note);
1633
+ show(el.railSyncNote, note !== '');
1634
+ setText(el.railViewedError, viewedError);
1635
+ show(el.railViewedError, viewedError !== '');
1636
+ }
1637
+
1638
+ function clearViewedNotes() {
1639
+ setText(el.railViewedCount, '');
1640
+ setText(el.railSyncNote, '');
1641
+ show(el.railSyncNote, false);
1642
+ setText(el.railViewedError, '');
1643
+ show(el.railViewedError, false);
1644
+ }
1645
+
1646
+ /** A click on a change's checkbox: flip it here, then mirror the file's new state to GitHub when the file's state changed. */
1647
+ function toggleViewed(hunkId) {
1648
+ var snap = snapshot();
1649
+ var hunk = null;
1650
+ var hunks = analysisHunks();
1651
+ for (var i = 0; i < hunks.length; i += 1) {
1652
+ if (hunks[i].id === hunkId) { hunk = hunks[i]; break; }
1653
+ }
1654
+ if (!snap || !hunk || viewedFor !== snap.id) return;
1655
+ if (viewedHunks[hunkId] === true) delete viewedHunks[hunkId];
1656
+ else viewedHunks[hunkId] = true;
1657
+ viewedError = '';
1658
+ saveViewedStore();
1659
+ var record = fileSync[hunk.file];
1660
+ if (record) record.last = hunkId;
1661
+ syncFile(hunk.file);
1662
+ paintViewed();
1663
+ }
1664
+
1665
+ /**
1666
+ * Make GitHub's mark of one file match the page. One request per file is out at
1667
+ * a time; a click that lands meanwhile is sent when the answer comes back, so
1668
+ * the file ends in the state of the last click. A request goes out only when
1669
+ * the file's state differs from what GitHub last confirmed.
1670
+ */
1671
+ function syncFile(path) {
1672
+ var snap = snapshot();
1673
+ var record = fileSync[path];
1674
+ if (!snap || !record || record.sending) return;
1675
+ var want = fileViewed(path);
1676
+ if (want === record.confirmed) return;
1677
+ var trigger = record.last;
1678
+ record.sending = true;
1679
+ api('POST', '/api/viewed', { snapshotId: snap.id, path: path, viewed: want }).then(function () {
1680
+ record.sending = false;
1681
+ if (viewedFor !== snap.id) return;
1682
+ record.confirmed = want;
1683
+ // The state the page holds must say what GitHub now says, or the next adoption would undo the click.
1684
+ var files = state && state.viewed && Array.isArray(state.viewed.files) ? state.viewed.files : [];
1685
+ for (var i = 0; i < files.length; i += 1) {
1686
+ if (files[i] && files[i].path === path) files[i].viewed = want;
1687
+ }
1688
+ }, function (error) {
1689
+ record.sending = false;
1690
+ if (viewedFor !== snap.id) return;
1691
+ // Put the click back: GitHub did not take it, so neither does the page.
1692
+ if (want) delete viewedHunks[trigger];
1693
+ else viewedHunks[trigger] = true;
1694
+ viewedError = error && error.message ? error.message : 'The Viewed mark could not be saved.';
1695
+ if (error && error.uncertain) state.viewed = { available: false, reason: 'diffninja did not answer the last Viewed mark' };
1696
+ else if (error && error.state && error.state.viewed && error.state.viewed.available === false) state.viewed = error.state.viewed;
1697
+ viewedDirty = true;
1698
+ }).then(function () {
1699
+ if (viewedDirty) adoptViewed();
1700
+ saveViewedStore();
1701
+ paintViewed();
1702
+ syncFile(path);
1703
+ });
1704
+ }
1705
+
1448
1706
  /* -------------------------------------------------------- you are here -- */
1449
1707
 
1450
1708
  var spyQueued = false;
@@ -1498,33 +1756,30 @@ function script(csrf) {
1498
1756
  }
1499
1757
 
1500
1758
  /**
1501
- * Mark where the reader is on the rail and what they have read. In the
1502
- * reading order the stops run 1, 2, 3 down the page, so the rail's list
1503
- * follows each new one; by file the stations of one file sit far apart in the
1504
- * reading order, so the list stays where the reader put it.
1759
+ * Mark where the reader is on the rail. In the reading order the stops run 1,
1760
+ * 2, 3 down the page, so the rail's list follows each new one; by file the
1761
+ * stations of one file sit far apart in the reading order, so the list stays
1762
+ * where the reader put it. Position only: nothing here says a change was
1763
+ * viewed, because only the reader's click does.
1505
1764
  */
1506
1765
  function markCurrentStation() {
1507
1766
  spyQueued = false;
1508
1767
  fitRail();
1509
- var snap = snapshot();
1510
- if (snap && seenFor !== snap.id) { seenFor = snap.id; seenRanks = Object.create(null); }
1511
1768
  var stations = el.analysisBody.querySelectorAll('.order-item');
1512
1769
  var guided = el.diffBody.querySelector('.stop') !== null;
1513
1770
  currentRank = guided ? currentStopRank() : currentFileRank(stations);
1514
- // Every change read in the current stop is current, and read.
1771
+ // Every change in the current stop is current.
1515
1772
  var here = Object.create(null);
1516
1773
  var stop = guided ? stopOf(currentRank) : null;
1517
1774
  if (stop) {
1518
1775
  for (var h = 0; h < stop.hunks.length; h += 1) here[stop.hunks[h].rank] = true;
1519
1776
  } else if (currentRank > 0) here[currentRank] = true;
1520
- for (var seen in here) seenRanks[seen] = true;
1521
1777
  var current = null;
1522
1778
  for (var j = 0; j < stations.length; j += 1) {
1523
1779
  var rank = Number(stations[j].dataset.rank);
1524
1780
  var on = here[rank] === true;
1525
1781
  if (rank === currentRank) current = stations[j];
1526
1782
  stations[j].classList.toggle('is-current', on);
1527
- stations[j].classList.toggle('is-seen', seenRanks[rank] === true);
1528
1783
  if (on) stations[j].setAttribute('aria-current', 'step');
1529
1784
  else stations[j].removeAttribute('aria-current');
1530
1785
  }
@@ -1738,6 +1993,9 @@ function script(csrf) {
1738
1993
 
1739
1994
  function codeNode(text, language, state) {
1740
1995
  var code = make('code', 'diff-code');
1996
+ var shown = visible(text);
1997
+ if (shown !== text) code.classList.add('diff-code-hidden');
1998
+ text = shown;
1741
1999
  if (!language || text.length > 2000) { code.textContent = text; return code; }
1742
2000
  var tokens = tokenize(text, language, state);
1743
2001
  for (var t = 0; t < tokens.length; t += 1) {
@@ -1769,7 +2027,7 @@ function script(csrf) {
1769
2027
  function openFlow(path, opener) {
1770
2028
  var current = currentAnalysis();
1771
2029
  if (!current) return;
1772
- var src = '/flow?snapshot=' + encodeURIComponent(current.snapshotId) + (path === '' ? '' : '&file=' + encodeURIComponent(path));
2030
+ var src = BASE + 'flow?snapshot=' + encodeURIComponent(current.snapshotId) + (path === '' ? '' : '&file=' + encodeURIComponent(path));
1773
2031
  if (el.flowFrame.getAttribute('src') !== src) el.flowFrame.setAttribute('src', src);
1774
2032
  flowSnapshot = current.snapshotId;
1775
2033
  var explained = Boolean(current.explanation);
@@ -1844,7 +2102,9 @@ function script(csrf) {
1844
2102
  var s = list[i];
1845
2103
  var shaped = s && typeof s === 'object' && typeof s.path === 'string' && typeof s.line === 'number'
1846
2104
  && (s.side === 'LEFT' || s.side === 'RIGHT') && typeof s.body === 'string' && s.body !== ''
1847
- && SEVERITY_LABELS[s.severity] !== undefined;
2105
+ && typeof s.scenario === 'string' && s.scenario !== ''
2106
+ && (s.evidence === 'ran' || s.evidence === 'traced')
2107
+ && typeof s.unlessTrue === 'string' && s.unlessTrue !== '';
1848
2108
  if (!shaped || !currentLine(s) || settled[suggestionKey(current.snapshotId, s)]) continue;
1849
2109
  out.push(s);
1850
2110
  }
@@ -1911,19 +2171,24 @@ function script(csrf) {
1911
2171
  render();
1912
2172
  }
1913
2173
 
1914
- var SEVERITY_LABELS = { critical: 'Critical', major: 'Major', minor: 'Minor' };
1915
-
2174
+ /** A suggestion is a claim that the pull request should not merge until it is fixed, so it shows its proof under the comment. */
1916
2175
  function suggestionRow(suggestion, disabled) {
1917
2176
  var wrap = make('div', 'suggestion');
1918
2177
  var head = make('p', 'suggestion-by');
1919
2178
  head.appendChild(make('span', 'agent-dot', ''));
1920
2179
  head.appendChild(make('strong', '', suggestedBy()));
1921
2180
  head.appendChild(document.createTextNode(' suggests'));
1922
- var severity = make('span', 'severity severity-' + suggestion.severity, SEVERITY_LABELS[suggestion.severity]);
1923
- severity.title = 'How much the agent thinks this matters';
1924
- head.appendChild(severity);
2181
+ var badge = make('span', 'blocks-badge', 'Blocks merge');
2182
+ badge.title = 'The agent says this must be fixed before the pull request merges';
2183
+ head.appendChild(badge);
1925
2184
  wrap.appendChild(head);
1926
2185
  wrap.appendChild(make('p', 'suggestion-body', suggestion.body));
2186
+ var proof = make('div', 'suggestion-proof');
2187
+ proof.appendChild(make('p', 'proof-title', 'Why it blocks'));
2188
+ proof.appendChild(make('p', 'proof-scenario', suggestion.scenario));
2189
+ proof.appendChild(make('p', 'proof-evidence', 'Checked: ' + (suggestion.evidence === 'ran' ? 'ran it' : 'traced the code')));
2190
+ proof.appendChild(make('p', 'proof-unless', 'Not a problem if: ' + suggestion.unlessTrue));
2191
+ wrap.appendChild(proof);
1927
2192
  var actions = make('div', 'suggestion-actions');
1928
2193
  var add = make('button', 'btn btn-primary btn-sm', 'Add to review');
1929
2194
  add.type = 'button';
@@ -1958,8 +2223,8 @@ function script(csrf) {
1958
2223
  var said = make('p', '');
1959
2224
  said.appendChild(make('span', 'agent-dot', ''));
1960
2225
  said.appendChild(make('strong', '', suggestedBy()));
1961
- said.appendChild(document.createTextNode(' suggested ' + open.length + (open.length === 1 ? ' comment' : ' comments')
1962
- + ' on the lines below. Nothing is posted until you submit.'));
2226
+ said.appendChild(document.createTextNode(' suggested ' + open.length + (open.length === 1 ? ' comment that blocks' : ' comments that block')
2227
+ + ' the merge on the lines below. Nothing is posted until you submit.'));
1963
2228
  bar.appendChild(said);
1964
2229
  var all = make('button', 'btn btn-primary btn-sm', open.length === 1 ? 'Add it to review' : 'Add all ' + open.length);
1965
2230
  all.type = 'button';
@@ -2575,6 +2840,7 @@ function script(csrf) {
2575
2840
  if (action === 'submit') { event_.preventDefault(); submitReview(); return; }
2576
2841
  if (action === 'refresh') { event_.preventDefault(); refreshState(); return; }
2577
2842
  if (action === 'goto') { event_.preventDefault(); gotoLine(node); return; }
2843
+ if (action === 'toggle-viewed') { event_.preventDefault(); toggleViewed(node.getAttribute('data-hunk') || ''); return; }
2578
2844
  if (action === 'goto-stop') { event_.preventDefault(); gotoStop(Number(node.getAttribute('data-rank'))); return; }
2579
2845
  if (action === 'view') { event_.preventDefault(); setView(node.getAttribute('data-view') || ''); return; }
2580
2846
  if (action === 'show-load') { event_.preventDefault(); showLoad = true; render(); el.prUrl.focus(); return; }
@@ -2616,6 +2882,7 @@ function script(csrf) {
2616
2882
  el.refreshButton = byId('refresh-button');
2617
2883
  el.pageTitle = byId('page-title');
2618
2884
  el.pageMeta = byId('page-meta');
2885
+ el.updateNotice = byId('update-notice');
2619
2886
  el.prGoal = byId('pr-goal');
2620
2887
  el.prGoalText = byId('pr-goal-text');
2621
2888
  el.prGoalBy = byId('pr-goal-by');
@@ -2640,6 +2907,9 @@ function script(csrf) {
2640
2907
  el.railFinish = byId('rail-finish');
2641
2908
  el.railProgress = byId('rail-progress');
2642
2909
  el.railAt = byId('rail-at');
2910
+ el.railViewedCount = byId('rail-viewed-count');
2911
+ el.railSyncNote = byId('rail-sync-note');
2912
+ el.railViewedError = byId('rail-viewed-error');
2643
2913
  el.viewSwitch = byId('view-switch');
2644
2914
  view = readView();
2645
2915
  el.analysisActions = byId('analysis-actions');
@@ -2742,6 +3012,7 @@ ${BRAND_MARK_STYLES}
2742
3012
  .page-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 13px; color: var(--ink-soft); }
2743
3013
  .page-meta a { font-weight: 600; }
2744
3014
  /* The agent's stated goal, first thing under the masthead: one sentence, with its attribution. */
3015
+ .update-notice { padding: 8px 12px; border-left: 3px solid var(--warn); background: var(--warn-soft); border-radius: 4px; font-size: 14px; }
2745
3016
  .pr-goal { margin-top: 6px; padding: 14px 16px; border: 1px solid var(--line); border-left: 3px solid var(--accent); border-radius: var(--radius); background: var(--panel); }
2746
3017
  .pr-goal.is-empty { border-left-color: var(--line-strong); background: var(--sunken); }
2747
3018
  .pr-goal-heading { font-size: 11.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-soft); }
@@ -2924,7 +3195,7 @@ button.fact:hover { border-color: var(--accent); background: var(--accent-soft);
2924
3195
  .order-list { list-style: none; margin: 0; padding: 0; }
2925
3196
  /* Stations sit on one continuous line: the route through the pull request. */
2926
3197
  .order-item {
2927
- position: relative; display: grid; grid-template-columns: 26px minmax(0, 1fr); column-gap: 10px;
3198
+ position: relative; display: grid; grid-template-columns: 26px minmax(0, 1fr) auto; column-gap: 10px;
2928
3199
  padding: 6px 14px 6px 12px; cursor: pointer;
2929
3200
  }
2930
3201
  .order-item::before {
@@ -2932,8 +3203,8 @@ button.fact:hover { border-color: var(--accent); background: var(--accent-soft);
2932
3203
  }
2933
3204
  .order-item:first-child::before { top: 16px; }
2934
3205
  .order-item:last-child::before { bottom: calc(100% - 16px); }
2935
- /* The route fills in behind the reader: a read station and the line after it turn accent. */
2936
- .order-item.is-seen::before { background: var(--accent); opacity: 0.55; }
3206
+ /* The route fills in behind what the reader marked viewed: a viewed station and the line after it turn accent. */
3207
+ .order-item.is-viewed::before { background: var(--accent); opacity: 0.55; }
2937
3208
  .order-item:hover { background: var(--hover); }
2938
3209
  .order-item:focus-within { outline: 2px solid var(--accent); outline-offset: -2px; }
2939
3210
  .order-rank {
@@ -2942,18 +3213,38 @@ button.fact:hover { border-color: var(--accent); background: var(--accent-soft);
2942
3213
  color: var(--ink-soft); background: var(--panel); border: 2px solid var(--route);
2943
3214
  transition: background 0.15s, border-color 0.15s, color 0.15s;
2944
3215
  }
2945
- .order-item.is-seen .order-rank { color: var(--accent); border-color: var(--accent); }
3216
+ .order-item.is-viewed .order-rank { color: var(--accent); border-color: var(--accent); }
2946
3217
  .order-item.is-current { background: var(--accent-soft); }
2947
3218
  .order-item.is-current .order-rank { color: var(--primary-ink); background: var(--accent); border-color: var(--accent); }
3219
+ /* A 24px target around a 16px box, drawn in CSS so it needs no glyph and follows the theme. */
3220
+ .viewed-box {
3221
+ position: relative; z-index: 1; align-self: center; width: 24px; height: 24px; margin: 0; padding: 0;
3222
+ display: grid; place-items: center; background: transparent; border: 0; border-radius: 6px; cursor: pointer;
3223
+ }
3224
+ .viewed-box::before {
3225
+ content: ""; width: 16px; height: 16px; box-sizing: border-box; border-radius: 4px;
3226
+ border: 2px solid var(--ink-faint); background: var(--panel); transition: background 0.15s, border-color 0.15s;
3227
+ }
3228
+ .viewed-box:hover::before { border-color: var(--accent); }
3229
+ .viewed-box[aria-checked="true"]::before { background: var(--accent); border-color: var(--accent); }
3230
+ .viewed-box[aria-checked="true"]::after {
3231
+ content: ""; position: absolute; width: 4px; height: 8px; margin-top: -2px;
3232
+ border: solid var(--primary-ink); border-width: 0 2px 2px 0; transform: rotate(45deg);
3233
+ }
3234
+ .viewed-box.is-pending { opacity: 0.55; }
2948
3235
  .order-main { min-width: 0; padding-top: 1px; display: flex; flex-direction: column; gap: 3px; }
2949
3236
  .order-path { display: flex; min-width: 0; font-size: 12.5px; white-space: nowrap; }
2950
3237
  .order-path .path-base { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
2951
3238
  .order-path .path-line { flex: 0 0 auto; }
2952
3239
  .order-path .path-dir { display: none; }
2953
3240
  .order-meta { display: flex; align-items: center; gap: 8px; font-size: 12px; }
2954
- .rail-progress { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; font-size: 12px; color: var(--ink); font-variant-numeric: tabular-nums; }
3241
+ .rail-progress { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 2px 8px; margin-top: 6px; font-size: 12px; color: var(--ink); font-variant-numeric: tabular-nums; }
2955
3242
  .rail-progress[hidden] { display: none; }
2956
3243
  .rail-keys { color: var(--ink-faint); white-space: nowrap; }
3244
+ .rail-viewed-count { order: 3; flex: 1 0 100%; color: var(--ink-soft); }
3245
+ .rail-viewed-count:empty { display: none; }
3246
+ .rail-note { margin-top: 4px; font-size: 12px; color: var(--ink-soft); overflow-wrap: anywhere; }
3247
+ .rail-note[hidden] { display: none; }
2957
3248
  kbd {
2958
3249
  display: inline-block; min-width: 18px; padding: 0 4px; font: 11px/16px var(--mono); text-align: center;
2959
3250
  color: var(--ink-soft); background: var(--bg); border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 4px;
@@ -3047,6 +3338,7 @@ kbd {
3047
3338
  .diff-action.has-comment { opacity: 1; transform: scale(1); background: var(--warn); }
3048
3339
  .diff-action.is-armed { opacity: 1; transform: scale(1); width: auto; padding: 0 6px; font-size: 11px; color: #fff; }
3049
3340
  .diff-mark { flex: 0 0 auto; width: 20px; text-align: center; color: var(--ink-faint); user-select: none; }
3341
+ .diff-code-hidden { outline: 1px solid var(--warn); outline-offset: -1px; background: var(--warn-soft); }
3050
3342
  .diff-code { display: block; flex: 1 1 auto; min-width: 0; padding: 0 12px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ink); }
3051
3343
  .kind-add { background: var(--add-bg); }
3052
3344
  .kind-delete { background: var(--del-bg); }
@@ -3079,13 +3371,16 @@ kbd {
3079
3371
  .editor-input { flex: 1 1 240px; min-width: 0; }
3080
3372
  .suggestion-by { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 12px; color: var(--ink-soft); }
3081
3373
  .suggestion-by strong { color: var(--ink); font-weight: 600; }
3082
- .severity {
3374
+ .blocks-badge {
3083
3375
  margin-left: 6px; padding: 1px 7px; border-radius: 4px; font-size: 11.5px; font-weight: 600; letter-spacing: 0.02em;
3084
- color: var(--ink-soft); background: var(--neutral-soft);
3376
+ color: var(--alarm); background: var(--alarm-bg);
3085
3377
  }
3086
- .severity-critical { color: var(--alarm); background: var(--alarm-bg); }
3087
- .severity-major { color: var(--warn); background: var(--warn-soft); }
3088
3378
  .suggestion-body { font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
3379
+ .suggestion-proof {
3380
+ display: flex; flex-direction: column; gap: 2px; padding: 2px 0 2px 10px; border-left: 2px solid var(--line);
3381
+ font-size: 13px; line-height: 1.45; color: var(--ink-soft); overflow-wrap: anywhere;
3382
+ }
3383
+ .proof-title { font-weight: 600; color: var(--ink); }
3089
3384
  .suggestion-actions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
3090
3385
 
3091
3386
  /* ---------------------------------------------------------------- forms */