@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/README.md +49 -3
- package/dist/ary/browse.js +4 -61
- package/dist/ary/picker.js +163 -0
- package/dist/ary/server.js +87 -37
- package/dist/batch.js +129 -0
- package/dist/cli.js +2 -1
- package/dist/commands/ary.js +3 -3
- package/dist/commands/lint.js +96 -0
- package/dist/commands/review.js +5 -4
- package/dist/commands/reviews.js +117 -6
- package/dist/commands/show.js +14 -6
- package/dist/commands/signals.js +9 -8
- package/dist/commands/sync.js +14 -2
- package/dist/commands/why.js +3 -2
- package/dist/config.js +61 -5
- package/dist/findings.js +254 -0
- package/dist/floor.js +39 -12
- package/dist/git.js +18 -2
- package/dist/html/client.js +56 -11
- package/dist/html/data.js +20 -3
- package/dist/html/page.js +1 -1
- package/dist/html/style.css +39 -1
- package/dist/loader.js +6 -2
- package/dist/plain.js +10 -0
- package/dist/render.js +6 -4
- package/dist/reviews.js +112 -4
- package/dist/safe-match.js +5 -3
- package/dist/schema.js +10 -3
- package/dist/selection.js +21 -0
- package/dist/signals.js +13 -3
- package/dist/validate.js +8 -3
- package/package.json +2 -2
- package/skills/gloss/SKILL.md +26 -3
- package/dist/ary/client.js +0 -401
- package/dist/ary/index.html +0 -13
- package/dist/ary/style.css +0 -115
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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 ?
|
|
215
|
+
const text = ref ? configAt(ref) : null;
|
|
160
216
|
if (text === null)
|
|
161
217
|
return undefined;
|
|
162
218
|
let json;
|
package/dist/findings.js
ADDED
|
@@ -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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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');
|
package/dist/html/client.js
CHANGED
|
@@ -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
|
-
|
|
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 = /^\[([^\]]
|
|
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
|