@ok-ggwp/git-gloss 0.4.0 → 0.6.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/dist/config.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { GlossError, gitTry } from './git.js';
2
+ import { git, GitError, GlossError, gitTry } from './git.js';
3
3
  import { globToRegExp } from './glob.js';
4
4
  // Settings for the computed signals, in three layers (later wins for scalars,
5
5
  // repo config extends the lists of the one before):
@@ -135,6 +135,31 @@ function gitInt(key) {
135
135
  throw new ConfigError(`git config gloss.attention.${key} must be a positive integer, got "${v}"`);
136
136
  return Number(v);
137
137
  }
138
+ // Git failing (a broken repo, an unreadable object, no permission) is not the
139
+ // same as "no base" or "no file": the repo's own, perhaps stricter, config
140
+ // would silently not apply. It is not fatal either (the built-in defaults
141
+ // still give a floor, and refusing to rate anything would make every command
142
+ // fail in a damaged repo), so it is warned about on stderr, once per message,
143
+ // and the defaults are used.
144
+ const warned = new Set();
145
+ function warnOnce(msg) {
146
+ if (warned.has(msg))
147
+ return;
148
+ warned.add(msg);
149
+ console.error(`warning: ${msg}`);
150
+ }
151
+ // git's output, or null when it exits 1 with nothing to say (its way of
152
+ // answering "no"); any other failure is thrown.
153
+ function gitAnswer(args) {
154
+ try {
155
+ return git(args);
156
+ }
157
+ catch (err) {
158
+ if (err instanceof GitError && err.status === 1 && /failed$/.test(err.message))
159
+ return null;
160
+ throw err;
161
+ }
162
+ }
138
163
  // The config is read from the base branch, not the working tree: a change under
139
164
  // review must not be able to reclassify its own paths or add patterns that
140
165
  // lower its own floor. The base is the merge base of the checked-out HEAD (not
@@ -145,18 +170,49 @@ function gitInt(key) {
145
170
  // the change's own config. Editing the file in a change is itself flagged: it
146
171
  // is always a `sensitive` path.
147
172
  function trustedRef() {
173
+ // No commits yet: no base, and nothing failed.
174
+ if (gitAnswer(['rev-parse', '--verify', '-q', 'HEAD']) === null)
175
+ return noBase();
176
+ let failed = false;
148
177
  // Full ref names, so a tag called "main" cannot stand in for the branch.
149
178
  for (const b of ['refs/remotes/origin/main', 'refs/heads/main', 'refs/remotes/origin/master', 'refs/heads/master']) {
150
- const mb = gitTry(['merge-base', 'HEAD', b])?.trim();
151
- if (mb)
152
- return mb;
179
+ try {
180
+ if (gitAnswer(['rev-parse', '--verify', '-q', b]) === null)
181
+ continue;
182
+ const mb = gitAnswer(['merge-base', 'HEAD', b])?.trim();
183
+ if (mb)
184
+ return mb;
185
+ }
186
+ catch (err) {
187
+ // A broken candidate does not hide the ones after it.
188
+ failed = true;
189
+ warnOnce(`git failed finding the base branch with ${b} (${err.message}); trying the next candidate`);
190
+ }
153
191
  }
192
+ // Some candidate failed: git's warning above is the reason, not an absent branch.
193
+ return failed ? (console.error(`warning: no working base branch, so ${CONFIG_FILE} is not read; using the built-in defaults`), null) : noBase();
194
+ }
195
+ function noBase() {
154
196
  console.error('warning: no base branch (origin/main, main, origin/master, master) found, so .gloss/config.json is not read; using the built-in defaults');
155
197
  return null;
156
198
  }
199
+ // The config file at `ref`: null when the file is not there (the usual case),
200
+ // a warning and null when git could not tell or could not read it. Presence
201
+ // is asked of ls-tree (empty output: absent), not read off an error message.
202
+ function configAt(ref) {
203
+ try {
204
+ if (!git(['ls-tree', '--full-tree', '--name-only', ref, '--', CONFIG_FILE]).trim())
205
+ return null;
206
+ return git(['show', `${ref}:${CONFIG_FILE}`]);
207
+ }
208
+ catch (err) {
209
+ warnOnce(`${CONFIG_FILE} could not be read from the base branch (${err.message}); using the built-in defaults, so the repo's own patterns do not apply`);
210
+ return null;
211
+ }
212
+ }
157
213
  function readRepoConfig() {
158
214
  const ref = trustedRef();
159
- const text = ref ? gitTry(['show', `${ref}:${CONFIG_FILE}`]) : null;
215
+ const text = ref ? configAt(ref) : null;
160
216
  if (text === null)
161
217
  return undefined;
162
218
  let json;
@@ -0,0 +1,254 @@
1
+ // Stable references for the findings of a review report. Reports are free
2
+ // markdown and every reviewer formats them differently, so for DISPLAY each
3
+ // top-level list item is rewritten as `- **[S.N]** text`, S the section number
4
+ // and N the item's position in that section. Nothing stored is changed: the
5
+ // report stays raw in the notes, and verdicts and review keys use the raw text.
6
+ //
7
+ // The input is untrusted and up to 256 KiB, so this is one pass over the lines
8
+ // with no backtracking patterns: every line is looked at a bounded number of
9
+ // characters from its start (a 200 KB line costs no more than a short one,
10
+ // apart from the copy into the output).
11
+ //
12
+ // Section numbers:
13
+ // - a heading (# to ######) that starts with a number (`1.`, `1)` or `1`) sets
14
+ // the section to that number;
15
+ // - any other heading is the previous section plus one;
16
+ // - EXCEPT titles: unnumbered headings before the first list item or numbered
17
+ // heading take no number (the first item then falls in section 1). So the
18
+ // headings `honesty`, `Review of X`, `1. Mismatches`, `Misgraded necessity`,
19
+ // `3. Removal` give sections 1, 2, 3, and `## A`, `## B` with items under
20
+ // both give 1 and 2.
21
+ // - a heading is a `#` line, or a line that is ONLY bold text (`**1. Title**`,
22
+ // `__Title__`, optional trailing colon), or, conservatively, a plain
23
+ // `1. Title` / `1) Title` line that is short, has no sentence punctuation
24
+ // at its end, follows a blank line (or the start), and whose next non-empty
25
+ // line (within two blank lines) is a bullet or another heading: an ordinary
26
+ // numbered sentence, or a numbered list, is an item, not a heading.
27
+ // - list items before any heading are labelled `[N]` (no section).
28
+ // Items are indent-0 `-`, `*`, `+`, `1.` or `1)` lines; indented sub-items and
29
+ // wrapped lines are left alone, as are fenced code blocks, rules and empty
30
+ // items. A numbered heading with no list under it keeps its own number as its
31
+ // ref (`1`) and gets no label. Running it on its own output changes nothing.
32
+ const isDigit = (c) => c >= '0' && c <= '9';
33
+ const MAXDIG = 6;
34
+ // The section number at the start of a heading's text: up to 3 digits, then `.` or
35
+ // `)`, then a space or the end. `2024 results` and `10 files changed` are text.
36
+ function leadingNumber(s) {
37
+ let i = 0;
38
+ while (i < s.length && i < 4 && isDigit(s[i]))
39
+ i++;
40
+ if (i === 0 || i > 3)
41
+ return null;
42
+ if (s[i] !== '.' && s[i] !== ')')
43
+ return null;
44
+ i++;
45
+ if (i < s.length && s[i] !== ' ' && s[i] !== '\t')
46
+ return null;
47
+ return Number(s.slice(0, i - 1));
48
+ }
49
+ function headingText(line) {
50
+ let i = 0;
51
+ while (i < 3 && line[i] === ' ')
52
+ i++;
53
+ let h = 0;
54
+ while (line[i + h] === '#' && h < 7)
55
+ h++;
56
+ if (h < 1 || h > 6)
57
+ return null;
58
+ const c = line[i + h];
59
+ if (c !== undefined && c !== ' ' && c !== '\t')
60
+ return null;
61
+ let j = i + h;
62
+ while (line[j] === ' ' || line[j] === '\t')
63
+ j++;
64
+ return line.slice(j, j + 16);
65
+ }
66
+ // The text of a line made only of bold (`**x**`, `__x__`, optional colon), else null.
67
+ function boldOnly(line) {
68
+ const mark = line.startsWith('**') ? '**' : line.startsWith('__') ? '__' : null;
69
+ if (mark === null)
70
+ return null;
71
+ let end = line.length;
72
+ while (end > 0 && (line[end - 1] === ' ' || line[end - 1] === '\t' || line[end - 1] === '\r'))
73
+ end--;
74
+ if (line[end - 1] === ':')
75
+ end--;
76
+ if (end < 2 * mark.length + 1 || !line.startsWith(mark, end - mark.length))
77
+ return null;
78
+ const inner = line.slice(mark.length, end - mark.length);
79
+ if (inner.indexOf(mark) !== -1 || inner.trim() === '')
80
+ return null;
81
+ return inner.trimStart().slice(0, 16);
82
+ }
83
+ const isBullet = (l) => (l[0] === '-' || l[0] === '*' || l[0] === '+') && (l[1] === ' ' || l[1] === '\t');
84
+ // A plain `1. Title` line that is a section title rather than a numbered finding.
85
+ function plainTitle(lines, n) {
86
+ const line = lines[n];
87
+ if (leadingNumber(line.slice(0, 12)) === null || itemText(line) === null)
88
+ return false;
89
+ if (line.length > 70 || (n > 0 && lines[n - 1].trim() !== ''))
90
+ return false;
91
+ const last = line.trimEnd().slice(-1);
92
+ if ('.!?,;:'.includes(last) && last !== ':')
93
+ return false;
94
+ // A real title has a blank line after it too: `1. Race` straight above bullets is an item with sub-items.
95
+ if (n + 1 >= lines.length || lines[n + 1].trim() !== '')
96
+ return false;
97
+ let k = n + 1;
98
+ while (k < lines.length && k <= n + 3 && lines[k].trim() === '')
99
+ k++;
100
+ if (k >= lines.length || k > n + 3)
101
+ return false;
102
+ const next = lines[k];
103
+ return isBullet(next) || headingText(next) !== null || boldOnly(next) !== null;
104
+ }
105
+ // The text after a top-level list marker, or null when the line is not an item.
106
+ function itemText(line) {
107
+ const c = line[0];
108
+ let i = 0;
109
+ if (c === '-' || c === '*' || c === '+')
110
+ i = 1;
111
+ else if (c !== undefined && isDigit(c)) {
112
+ while (i < MAXDIG + 1 && isDigit(line[i] ?? ''))
113
+ i++;
114
+ if (i > MAXDIG || (line[i] !== '.' && line[i] !== ')'))
115
+ return null;
116
+ i++;
117
+ }
118
+ else
119
+ return null;
120
+ if (line[i] !== ' ' && line[i] !== '\t')
121
+ return null;
122
+ // A rule (`* * *`, `- - -`, `___`): at most 64 characters, so the pattern is bounded.
123
+ if (line.length <= 64 && /^([-*_])( *\1){2,} *$/.test(line))
124
+ return null;
125
+ let j = i;
126
+ while (line[j] === ' ' || line[j] === '\t')
127
+ j++;
128
+ if (j >= line.length)
129
+ return null;
130
+ return line.slice(j);
131
+ }
132
+ // Drop a label this function wrote before: `**[1.2]** `.
133
+ function stripLabel(t) {
134
+ if (!t.startsWith('**['))
135
+ return t;
136
+ let i = 3;
137
+ while (i < 20 && (isDigit(t[i] ?? '') || t[i] === '.'))
138
+ i++;
139
+ if (i > 3 && t.startsWith(']**', i)) {
140
+ let j = i + 3;
141
+ while (t[j] === ' ')
142
+ j++;
143
+ return t.slice(j);
144
+ }
145
+ return t;
146
+ }
147
+ function scan(text) {
148
+ const lines = text.split('\n');
149
+ // Each ref with its text (an item's, or a numbered heading's line); the first wins.
150
+ const found = new Map();
151
+ const add = (ref, t) => { if (!found.has(ref))
152
+ found.set(ref, t); };
153
+ let section = 0, count = 0;
154
+ let sawHeading = false, sawNumbered = false, sawItem = false;
155
+ let fence = null;
156
+ for (let n = 0; n < lines.length; n++) {
157
+ const line = lines[n];
158
+ const t = line.trimStart();
159
+ if (t.startsWith('```') || t.startsWith('~~~')) {
160
+ if (fence === null)
161
+ fence = t.slice(0, 3);
162
+ else if (t.startsWith(fence))
163
+ fence = null;
164
+ continue;
165
+ }
166
+ if (fence !== null)
167
+ continue;
168
+ const h = headingText(line) ?? boldOnly(line) ?? (plainTitle(lines, n) ? line : null);
169
+ if (h !== null) {
170
+ const num = leadingNumber(h);
171
+ if (num !== null) {
172
+ section = num;
173
+ sawNumbered = true;
174
+ add(String(num), line.replace(/^[\s#*_]+/, '').replace(/^\d{1,3}[.)]?\s*/, '').replace(/[\s*_:]+$/, ''));
175
+ }
176
+ else if (!sawItem && !sawNumbered)
177
+ section = 0;
178
+ else
179
+ section += 1;
180
+ sawHeading = true;
181
+ count = 0;
182
+ continue;
183
+ }
184
+ const item = itemText(line);
185
+ if (item === null)
186
+ continue;
187
+ if (section === 0 && sawHeading)
188
+ section = 1;
189
+ sawItem = true;
190
+ count++;
191
+ const ref = sawHeading ? `${section}.${count}` : String(count);
192
+ add(ref, stripLabel(item));
193
+ lines[n] = `- **[${ref}]** ${stripLabel(item)}`;
194
+ }
195
+ return { out: lines.join('\n'), found };
196
+ }
197
+ export function labelFindings(text) {
198
+ return scan(text).out;
199
+ }
200
+ // The refs a report's findings carry (and its numbered headings'), as a set.
201
+ export function findingRefs(text) {
202
+ return new Set(scan(text).found.keys());
203
+ }
204
+ // Each ref a report carries, once, with the text of its finding (raw, for the caller to clean).
205
+ export function findingTexts(text) {
206
+ return [...scan(text).found];
207
+ }
208
+ // A decision's finding names a finding by starting with its ref: `1.2 wording nit`,
209
+ // `1.2: ...`, or a bare section number only with a delimiter (`3: ...`, `3) ...`,
210
+ // `3. ...`). Numbers are up to 3 digits, so `2024 migration` and `3 files` are text.
211
+ export function refOf(finding) {
212
+ let i = 0;
213
+ while (i < 4 && isDigit(finding[i] ?? ''))
214
+ i++;
215
+ if (i === 0 || i > 3)
216
+ return null;
217
+ if (finding[i] === '.') {
218
+ let j = i + 1;
219
+ while (j < i + 5 && isDigit(finding[j] ?? ''))
220
+ j++;
221
+ if (j > i + 1 && j <= i + 4) {
222
+ const c = finding[j];
223
+ if (c === undefined || c === ' ' || c === '\t' || c === ':')
224
+ return finding.slice(0, j);
225
+ }
226
+ }
227
+ const c = finding[i];
228
+ if (c === ':' || c === ')')
229
+ return finding.slice(0, i);
230
+ if (c === '.' && (finding[i + 1] === undefined || finding[i + 1] === ' '))
231
+ return finding.slice(0, i);
232
+ return null;
233
+ }
234
+ function compareRefs(a, b) {
235
+ const [a1, a2 = '-1'] = a.split('.');
236
+ const [b1, b2 = '-1'] = b.split('.');
237
+ return Number(a1) - Number(b1) || Number(a2) - Number(b2);
238
+ }
239
+ // Finding order: decisions whose finding starts with a ref, by ref (1.2 before
240
+ // 1.10, ties by time); the rest after them in time order.
241
+ export function orderDecisions(input) {
242
+ const ds = [...input].sort((a, b) => a.at.localeCompare(b.at));
243
+ const withRef = [];
244
+ const rest = [];
245
+ for (const d of ds) {
246
+ const r = refOf(d.finding ?? '');
247
+ if (r === null)
248
+ rest.push(d);
249
+ else
250
+ withRef.push([r, d]);
251
+ }
252
+ withRef.sort((a, b) => compareRefs(a[0], b[0]));
253
+ return [...withRef.map((x) => x[1]), ...rest];
254
+ }
package/dist/floor.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ConfigError, loadConfig } from './config.js';
2
2
  import { attentionForImpact, attentionRank, impactRank, maxAttention, maxImpact, raise } from './attention.js';
3
3
  import { servesOf } from './schema.js';
4
- import { analyze, CONFIG_EDIT_NOTE, partFloor } from './signals.js';
4
+ import { analyze, CONFIG_EDIT_NOTE, partFloor, timedOutNotes } from './signals.js';
5
5
  import { changedCount, selections } from './selection.js';
6
6
  // The groups of a loaded gloss with their current hunks (a hunk that is gone is skipped).
7
7
  export function groupInputs(gloss) {
@@ -101,27 +101,54 @@ export function analyzeGroups(hunks, groups, cfg) {
101
101
  level = maxAttention(level, signals.floor.attention);
102
102
  reasons.push(`floor ${signals.floor.attention}: ${signals.floor.reasons[0] ?? signals.floor.impact}`);
103
103
  }
104
- return { signals, groups: floors, floor: signals.floor.attention, derived: { level, reasons } };
104
+ return { signals, groups: floors, floor: signals.floor.attention, derived: { level, reasons }, timedOut: timedOutNotes(signals.hunks) };
105
105
  }
106
106
  export const analyzeGloss = (gloss, cfg) => analyzeGroups(gloss.hunks, groupInputs(gloss), cfg);
107
- export function ratingProblems(a, an) {
108
- const warnings = [];
109
- if (an.signals.floor.reasons.includes(CONFIG_EDIT_NOTE))
110
- warnings.push(CONFIG_EDIT_NOTE);
107
+ // Where a floor comes from, for messages.
108
+ export const whyText = (why) => why.join('; ') || 'computed from the diff';
109
+ export function ratingIssues(a, an) {
110
+ const belowFloor = [];
111
+ const unrated = [];
111
112
  for (const g of a.groups) {
112
113
  const f = an.groups.get(g.id);
113
- // A floor of plain "module" is the baseline for source files, not a signal
114
- // to warn about: a private helper may honestly be `local`.
115
- if (!g.impact || !f || impactRank(f.floor) <= impactRank('module') || impactRank(g.impact) >= impactRank(f.floor))
114
+ if (g.impact) {
115
+ // A floor of plain "module" is the baseline for source files, not a signal
116
+ // to warn about: a private helper may honestly be `local`.
117
+ if (f && impactRank(f.floor) > impactRank('module') && impactRank(g.impact) < impactRank(f.floor))
118
+ belowFloor.push({ id: g.id, impact: g.impact, floor: f.floor, why: f.why });
116
119
  continue;
117
- warnings.push(`group "${g.id}" is rated impact "${g.impact}" but its floor is "${f.floor}" (${f.why.join('; ') || 'computed from the diff'}); raise it, or say why in impact_reason`);
120
+ }
121
+ // A mechanical group on a floor signal (lockfile, config, CI, sensitive path)
122
+ // is not pure; plain module is the baseline for source files.
123
+ const signal = !!f && impactRank(f.floor) > impactRank('module');
124
+ if (g.necessity === 'mechanical' && !signal)
125
+ continue;
126
+ unrated.push({ id: g.id, floor: f?.floor ?? 'local', why: f?.why ?? [], signal });
118
127
  }
119
- let underrated = null;
128
+ let attention = null;
120
129
  const att = a.attention;
121
130
  if (att && attentionRank(att.level) < attentionRank(an.floor) && !att.override_reason) {
122
131
  const why = an.signals.floor.reasons.length ? an.signals.floor.reasons.join('; ') : `${an.signals.floor.impact}: ordinary source changes`;
123
- underrated = `attention.level "${att.level}" is below the computed floor "${an.floor}" (${why}); raise it, or explain with attention.override_reason`;
132
+ attention = { level: att.level, floor: an.floor, why };
133
+ }
134
+ return { configEdit: an.signals.floor.reasons.includes(CONFIG_EDIT_NOTE), belowFloor, unrated, attention };
135
+ }
136
+ export function ratingProblems(a, an) {
137
+ const issues = ratingIssues(a, an);
138
+ const warnings = [];
139
+ if (issues.configEdit)
140
+ warnings.push(CONFIG_EDIT_NOTE);
141
+ for (const b of issues.belowFloor) {
142
+ warnings.push(`group "${b.id}" is rated impact "${b.impact}" but its floor is "${b.floor}" (${whyText(b.why)}); raise it, or say why in impact_reason`);
143
+ }
144
+ const unrated = issues.unrated.map((u) => `"${u.id}"`);
145
+ const floored = issues.unrated.filter((u) => u.signal).map((u) => `${u.id}: floor ${u.floor}: ${whyText(u.why)}`);
146
+ if (unrated.length) {
147
+ const names = unrated.slice(0, 10).join(', ') + (unrated.length > 10 ? ` and ${unrated.length - 10} more` : '');
148
+ warnings.push(`no impact rated on group ${names}${floored.length ? ` (${floored.join('; ')})` : ''}; rate impact on every group except a mechanical one on plain source (floor module or below), see the gloss skill`);
124
149
  }
150
+ const att = issues.attention;
151
+ const underrated = att && `attention.level "${att.level}" is below the computed floor "${att.floor}" (${att.why}); raise it, or explain with attention.override_reason`;
125
152
  return { warnings, underrated };
126
153
  }
127
154
  let warned = false;
package/dist/git.js CHANGED
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { Annotation, NOTE_FORMAT } from './schema.js';
6
6
  import { parsePath, unquote } from './diff.js';
7
+ import { plain } from './plain.js';
7
8
  export const NOTES_REF = 'refs/notes/gloss';
8
9
  // Glosses of uncommitted work (wip snapshots) live in their own notes ref,
9
10
  // which sync never pushes, and the latest snapshot in a private ref that
@@ -18,6 +19,8 @@ export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
18
19
  export class GlossError extends Error {
19
20
  }
20
21
  export class GitError extends GlossError {
22
+ // git's exit code, when it ran and failed (1 is often "no", 128 a real failure).
23
+ status;
21
24
  }
22
25
  export function git(args, input, env = {}) {
23
26
  try {
@@ -32,7 +35,9 @@ export function git(args, input, env = {}) {
32
35
  }
33
36
  catch (err) {
34
37
  const stderr = err.stderr?.trim();
35
- throw new GitError(stderr || `git ${args.join(' ')} failed`);
38
+ const e = new GitError(stderr || `git ${args.join(' ')} failed`);
39
+ e.status = typeof err.status === 'number' ? err.status : undefined;
40
+ throw e;
36
41
  }
37
42
  }
38
43
  export function gitTry(args, input) {
@@ -224,6 +229,15 @@ function rawNote(ref, sha) {
224
229
  throw err;
225
230
  }
226
231
  }
232
+ function cleanStrings(v) {
233
+ if (typeof v === 'string')
234
+ return plain(v, true);
235
+ if (Array.isArray(v))
236
+ return v.map(cleanStrings);
237
+ if (v && typeof v === 'object')
238
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, cleanStrings(x)]));
239
+ return v;
240
+ }
227
241
  export function readNote(sha) {
228
242
  // A commit's gloss, or a wip snapshot's (they are never the same object).
229
243
  const raw = rawNote(NOTES_REF, sha) ?? rawNote(WIP_NOTES_REF, sha);
@@ -249,7 +263,9 @@ export function readNote(sha) {
249
263
  if (![1, 2, 3, 4].includes(data?.gloss) || !Array.isArray(data.hunks) || !annotation.success) {
250
264
  throw new GlossError(`${at} is malformed; replace it with \`git gloss add --force\``);
251
265
  }
252
- return { ...data, annotation: annotation.data };
266
+ // The gloss's text is shown in a terminal, and a note can come from anyone
267
+ // who can push to the remote: no escape sequences or control characters get through.
268
+ return { ...data, annotation: cleanStrings(annotation.data) };
253
269
  }
254
270
  export function writeNote(sha, note, ref = NOTES_REF) {
255
271
  git(['notes', '--ref', ref, 'add', '-f', '-F', '-', sha], JSON.stringify(note, null, 2) + '\n');
@@ -62,7 +62,7 @@
62
62
  return { level: null, source: 'unrated' }
63
63
  }
64
64
  function fileCount(c) {
65
- var seen = {}
65
+ var seen = Object.create(null)
66
66
  c.groups.forEach(function (g) { g.hunks.forEach(function (h) { seen[h.file] = true }) })
67
67
  c.unannotated.forEach(function (h) { seen[h.file] = true })
68
68
  return Object.keys(seen).length
@@ -72,7 +72,7 @@
72
72
  var readCache = {}
73
73
  function readSet(c) {
74
74
  if (!readCache[c.sha]) {
75
- var s = {}
75
+ var s = Object.create(null) // no prototype: a group id like "constructor" must not read as already read
76
76
  try {
77
77
  var raw = window.sessionStorage.getItem('git-gloss:read:' + c.sha)
78
78
  if (raw) JSON.parse(raw).forEach(function (id) { s[id] = true })
@@ -240,6 +240,11 @@
240
240
  reasons.forEach(function (r) { ul.appendChild(el('li', null, r)) })
241
241
  add(box, ul)
242
242
  }
243
+ if (k && k.timedOut && k.timedOut.length) {
244
+ var tl = el('ul', 'timedout')
245
+ k.timedOut.forEach(function (r) { tl.appendChild(el('li', null, r)) })
246
+ add(box, tl)
247
+ }
243
248
  if (c.risks.length) {
244
249
  var rb = button('linkbtn', plural(c.risks.length, 'risk') + ' flagged', function () { selectRisks(commits.indexOf(c)) })
245
250
  add(box, add(el('p', 'look'), rb))
@@ -259,6 +264,13 @@
259
264
  var f = r.state === 'seen' ? 'current' : r.state === 'code-changed' ? 'code changed since review' : 'gloss changed since review'
260
265
  return v + ' · ' + f
261
266
  }
267
+ // How far the arbiter got with a stage's latest review, for a rail row: nothing unless it found issues.
268
+ function decidedShort(r) {
269
+ // reviewStatus() in src/reviews.ts sorts each stage newest-first, and the page data keeps that order.
270
+ var latest = r.entries[0]
271
+ if (!latest || !latest.decisions || latest.verdict === 'clean') return ''
272
+ return latest.complete ? ' \u00b7 complete' : latest.decisionCount ? ' \u00b7 ' + latest.decisionCount + ' decided' : ' \u00b7 undecided'
273
+ }
262
274
  // One compact row per stage; each opens the Reviews view in the main pane.
263
275
  function reviewStatus(c) {
264
276
  var ci = commits.indexOf(c)
@@ -269,7 +281,7 @@
269
281
  var b = button('rvrow', null, function () { selectReviews(ci, r.stage) })
270
282
  b.setAttribute('data-fid', 'rv:' + ci + ':' + r.stage)
271
283
  b.title = r.phrase
272
- add(b, el('span', 'rvdot ' + (cls === 'review-ok' ? 'ok' : cls === 'stale' ? 'warn' : 'none')), el('span', 'rvstage', r.stage), el('span', 'rvtxt', reviewShort(r)),
284
+ add(b, el('span', 'rvdot ' + (cls === 'review-ok' ? 'ok' : cls === 'stale' ? 'warn' : 'none')), el('span', 'rvstage', r.stage), el('span', 'rvtxt', reviewShort(r) + decidedShort(r)),
273
285
  el('span', 'rvn', r.entries.length))
274
286
  box.appendChild(b)
275
287
  })
@@ -278,6 +290,8 @@
278
290
 
279
291
  // ---- markdown: a small subset, built as DOM nodes (never parsed as HTML) ----
280
292
  var ALNUM = /[A-Za-z0-9]/
293
+ // Review text is untrusted and unbounded: no emphasis or link may reach further than this.
294
+ var MAX_SPAN = 500
281
295
  function inline(parent, s) {
282
296
  var i = 0, buf = ''
283
297
  function flush() { if (buf) { parent.appendChild(text(buf)); buf = '' } }
@@ -290,12 +304,13 @@
290
304
  j = s.indexOf('**', i + 2)
291
305
  if (j > i + 2) { flush(); parent.appendChild(inline(el('strong'), s.slice(i + 2, j))); i = j + 2; continue }
292
306
  } else if (ch === '*' && s.charAt(i + 1) && !/[\s*]/.test(s.charAt(i + 1)) && (i === 0 || !ALNUM.test(s.charAt(i - 1)))) {
293
- for (j = i + 2; j < s.length; j++) {
307
+ // Looks no further than MAX_SPAN: an unclosed marker would otherwise re-read the rest of the text each time (quadratic).
308
+ for (j = i + 2; j < s.length && j < i + MAX_SPAN; j++) {
294
309
  if (s.charAt(j) === '*' && !/\s/.test(s.charAt(j - 1)) && !ALNUM.test(s.charAt(j + 1)) && s.charAt(j + 1) !== '*') break
295
310
  }
296
- if (j < s.length) { flush(); parent.appendChild(inline(el('em'), s.slice(i + 1, j))); i = j + 1; continue }
311
+ if (j < s.length && j < i + MAX_SPAN) { flush(); parent.appendChild(inline(el('em'), s.slice(i + 1, j))); i = j + 1; continue }
297
312
  } else if (ch === '[') {
298
- m = /^\[([^\]]+)\]\(([^)\s]+)\)/.exec(s.slice(i))
313
+ m = /^\[([^\]]{1,200})\]\(([^)\s]{1,400})\)/.exec(s.slice(i, i + MAX_SPAN))
299
314
  if (m) { flush(); inline(parent, m[1]); parent.appendChild(el('span', 'muted', ' (' + m[2] + ')')); i += m[0].length; continue }
300
315
  }
301
316
  buf += ch
@@ -334,6 +349,9 @@
334
349
  if (!top || top.indent < indent || top.list.tagName.toLowerCase() !== tag) {
335
350
  if (top && top.indent === indent) stack.pop()
336
351
  var list = el(tag)
352
+ // A numbered list starts at its first item's number, so "5. 6." is not shown as "1. 2.".
353
+ var first = tag === 'ol' ? parseInt(m[2], 10) : 0
354
+ if (first > 0 && first < 100000 && first !== 1) list.start = first
337
355
  if (stack.length && stack[stack.length - 1].li) stack[stack.length - 1].li.appendChild(list); else root.appendChild(list)
338
356
  top = { indent: indent, list: list, li: null }
339
357
  stack.push(top)
@@ -368,6 +386,25 @@
368
386
  if (v) add(head, el('span', 'chip ' + (v === 'ISSUES' ? 'bad' : 'good'), v === 'ISSUES' ? 'issues found' : 'no issues'))
369
387
  return head
370
388
  }
389
+ // What was decided about one review: the summary, then a row per decision. Text only, never markup.
390
+ function decisionsBlock(e) {
391
+ if (!e.decisions) return null
392
+ var box = el('div', 'decisions')
393
+ add(box, el('h3', 'dhead', 'Decisions'), el('p', 'dsum', e.summary))
394
+ if (e.decisionCount > e.decisions.length) add(box, el('p', 'muted dhidden', 'showing the latest ' + e.decisions.length + ' of ' + e.decisionCount))
395
+ if (e.decisions.length) {
396
+ var ul = el('ul', 'dlist')
397
+ e.decisions.forEach(function (d) {
398
+ var row = el('li', 'drow')
399
+ add(row, el('span', 'chip dchip ' + d.outcome, d.outcome), text(' '), el('span', 'dfinding', d.finding))
400
+ if (d.note) add(row, text(' \u2014 '), el('span', 'dnote', d.note))
401
+ add(row, text(' '), el('span', 'muted dby', d.by + ', ' + d.at.slice(0, 10)))
402
+ ul.appendChild(row)
403
+ })
404
+ add(box, ul)
405
+ }
406
+ return box
407
+ }
371
408
  function reviewsPane(c, stage) {
372
409
  var pane = el('div')
373
410
  add(pane, button('btn small back', '‹ Back', goBack))
@@ -379,10 +416,10 @@
379
416
  add(sec, el('h2', null, r.stage), add(el('p', 'rvsum'), el('span', reviewClass(r), r.phrase)))
380
417
  var entries = r.entries.slice().sort(function (a, b) { return a.at < b.at ? 1 : a.at > b.at ? -1 : 0 })
381
418
  entries.forEach(function (e, i) {
382
- if (i === 0) add(sec, add(el('div', 'entry'), entryHead(e), markdown(e.findings)))
419
+ if (i === 0) add(sec, add(el('div', 'entry'), entryHead(e), decisionsBlock(e), markdown(e.findings)))
383
420
  else {
384
421
  var d = el('details', 'entry old')
385
- add(d, add(el('summary'), entryHead(e)), markdown(e.findings))
422
+ add(d, add(el('summary'), entryHead(e)), decisionsBlock(e), markdown(e.findings))
386
423
  add(sec, d)
387
424
  }
388
425
  })
@@ -466,6 +503,10 @@
466
503
 
467
504
  // ---- the detail pane ----
468
505
  var topEl = el('header', 'top')
506
+ // Served by `git gloss ary`: the branch pickers (src/ary/picker.js, included
507
+ // ahead of this script only then) live in the top bar. Built once, since
508
+ // the bar is rebuilt on every change of filter.
509
+ var aryEl = data.ary ? aryBar(data.ary, el, add) : null
469
510
  var railEl = el('aside', 'rail')
470
511
  railEl.setAttribute('aria-label', multi ? 'Commits in the range' : 'Groups in review order')
471
512
  var mainEl = el('main', 'main')
@@ -562,7 +603,7 @@
562
603
  var order = c.order
563
604
  var hunkN = g.hunks.length
564
605
  var files = []
565
- var perFile = {}
606
+ var perFile = Object.create(null)
566
607
  g.hunks.forEach(function (h) {
567
608
  if (!perFile[h.file]) { perFile[h.file] = 0; files.push(h.file) }
568
609
  perFile[h.file]++
@@ -681,9 +722,9 @@
681
722
  return pane
682
723
  }
683
724
  warnings(c, pane)
684
- add(pane, attentionCard(c))
685
725
  statement(pane, 'Task', c.task)
686
726
  statement(pane, 'Summary', c.summary)
727
+ add(pane, attentionCard(c))
687
728
  focusSection(c, ci, pane)
688
729
  if (c.groups.length) {
689
730
  var nb = necessityBar(c)
@@ -753,7 +794,7 @@
753
794
  function renderTop() {
754
795
  var c = commits[state.ci]
755
796
  topEl.textContent = ''
756
- add(topEl, el('span', 'brand', 'git gloss'))
797
+ add(topEl, el('span', 'brand', 'git gloss'), aryEl)
757
798
  if (multi) {
758
799
  var glossed = commits.filter(function (x) { return x.glossed }).length
759
800
  var vetted = commits.filter(function (x) {
@@ -1091,8 +1132,12 @@
1091
1132
 
1092
1133
  // ---- start ----
1093
1134
  var desk = add(el('div', 'desk'), topEl, body)
1135
+ // The rail sticks below the top bar, whose height varies (the pickers wrap).
1136
+ function measureTop() { desk.style.setProperty('--top-h', topEl.offsetHeight + 'px') }
1094
1137
  app.textContent = ''
1095
1138
  app.appendChild(desk)
1139
+ measureTop()
1140
+ if (typeof ResizeObserver !== 'undefined') new ResizeObserver(measureTop).observe(topEl)
1096
1141
  if (multi) {
1097
1142
  var first = commitOrder()[0]
1098
1143
  state.ci = first