@ok-ggwp/git-gloss 0.5.0 → 0.7.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 +35 -9
- package/dist/ary/desk.js +112 -0
- package/dist/ary/jobs.js +132 -0
- package/dist/ary/page-job.js +23 -0
- package/dist/ary/picker.js +8 -0
- package/dist/ary/server.js +41 -107
- package/dist/batch.js +184 -0
- package/dist/cli.js +2 -1
- package/dist/commands/add.js +28 -2
- package/dist/commands/ary.js +3 -0
- package/dist/commands/carry.js +3 -1
- package/dist/commands/lint.js +99 -0
- package/dist/commands/review.js +10 -5
- package/dist/commands/reviews.js +64 -16
- package/dist/commands/show.js +11 -5
- package/dist/commands/sync.js +35 -12
- package/dist/commands/versions.js +52 -8
- package/dist/commands/why.js +8 -3
- package/dist/config.js +22 -11
- package/dist/findings.js +349 -0
- package/dist/floor.js +37 -10
- package/dist/git.js +151 -27
- package/dist/html/client.js +82 -11
- package/dist/html/data.js +38 -11
- package/dist/html/style.css +16 -0
- package/dist/loader.js +22 -3
- package/dist/plain.js +2 -1
- package/dist/reviews.js +35 -13
- package/dist/schema.js +2 -0
- package/dist/validate.js +4 -2
- package/package.json +2 -2
- package/skills/gloss/SKILL.md +15 -6
package/dist/git.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync, utimesSync } from 'node:fs';
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync, utimesSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { Annotation, NOTE_FORMAT } from './schema.js';
|
|
5
|
+
import { Annotation, MAX_NOTE_BYTES, NOTE_FORMAT } from './schema.js';
|
|
6
6
|
import { parsePath, unquote } from './diff.js';
|
|
7
7
|
import { plain } from './plain.js';
|
|
8
8
|
export const NOTES_REF = 'refs/notes/gloss';
|
|
@@ -21,10 +21,33 @@ export class GlossError extends Error {
|
|
|
21
21
|
export class GitError extends GlossError {
|
|
22
22
|
// git's exit code, when it ran and failed (1 is often "no", 128 a real failure).
|
|
23
23
|
status;
|
|
24
|
+
// The call was killed at GIT_GLOSS_GIT_TIMEOUT: unlike a failure, not an answer.
|
|
25
|
+
timedOut = false;
|
|
24
26
|
}
|
|
25
|
-
|
|
27
|
+
// How long one git call may run before it is killed (ms; 0 = no limit). A
|
|
28
|
+
// hung git (a stuck filesystem, a credential helper, a lock) would otherwise
|
|
29
|
+
// hang git-gloss for good. Read once at load: GIT_GLOSS_GIT_TIMEOUT overrides;
|
|
30
|
+
// a value that is not a non-negative number is ignored.
|
|
31
|
+
// A timed-out git gets SIGTERM, not SIGKILL: git removes its lock files
|
|
32
|
+
// (index.lock, a ref's .lock) on SIGTERM, while SIGKILL would leave one that
|
|
33
|
+
// fails every later command. A git that ignores SIGTERM is waited for, with
|
|
34
|
+
// no second-stage kill (execFileSync only returns when the child ends), so
|
|
35
|
+
// such a git still hangs git-gloss.
|
|
36
|
+
// Network calls (fetch, push, ls-remote) get NETWORK_TIMEOUT (600 s) instead:
|
|
37
|
+
// a slow link would otherwise fail at 120 s and every retry would fail again.
|
|
38
|
+
// GIT_GLOSS_NETWORK_TIMEOUT (ms, 0 = none) sets that limit alone; failing it,
|
|
39
|
+
// GIT_GLOSS_GIT_TIMEOUT, when set, applies to both.
|
|
40
|
+
export function parseGitTimeout(raw, fallback = 120_000) {
|
|
41
|
+
const n = raw === undefined || raw.trim() === '' ? NaN : Number(raw);
|
|
42
|
+
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
43
|
+
}
|
|
44
|
+
const GIT_TIMEOUT = parseGitTimeout(process.env.GIT_GLOSS_GIT_TIMEOUT);
|
|
45
|
+
export const NETWORK_TIMEOUT = parseGitTimeout(process.env.GIT_GLOSS_NETWORK_TIMEOUT, parseGitTimeout(process.env.GIT_GLOSS_GIT_TIMEOUT, 600_000));
|
|
46
|
+
export function git(args, input, env = {}, limit = GIT_TIMEOUT) {
|
|
26
47
|
try {
|
|
27
48
|
return execFileSync('git', args, {
|
|
49
|
+
timeout: limit,
|
|
50
|
+
killSignal: 'SIGTERM',
|
|
28
51
|
encoding: 'utf8',
|
|
29
52
|
input,
|
|
30
53
|
// Untranslated messages: readNote recognises "no note found" by its text.
|
|
@@ -34,17 +57,45 @@ export function git(args, input, env = {}) {
|
|
|
34
57
|
});
|
|
35
58
|
}
|
|
36
59
|
catch (err) {
|
|
60
|
+
if (err.code === 'ETIMEDOUT') {
|
|
61
|
+
const what = plain(args.slice(0, 3).join(' ')).slice(0, 80);
|
|
62
|
+
const t = new GitError(`git ${what} timed out after ${limit / 1000} s (set GIT_GLOSS_GIT_TIMEOUT to change the limit)`);
|
|
63
|
+
t.timedOut = true;
|
|
64
|
+
throw t;
|
|
65
|
+
}
|
|
37
66
|
const stderr = err.stderr?.trim();
|
|
38
67
|
const e = new GitError(stderr || `git ${args.join(' ')} failed`);
|
|
39
68
|
e.status = typeof err.status === 'number' ? err.status : undefined;
|
|
40
69
|
throw e;
|
|
41
70
|
}
|
|
42
71
|
}
|
|
43
|
-
|
|
72
|
+
// For a catch that turns a git failure into a value ("no gloss", "unreadable",
|
|
73
|
+
// "use the defaults"): a git killed at the time limit gave no answer, so the
|
|
74
|
+
// timeout goes on to the caller rather than become that value.
|
|
75
|
+
export function rethrowTimeout(err) {
|
|
76
|
+
if (err instanceof GitError && err.timedOut)
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
// Best-effort cleanup (a temporary ref in a `finally`, or after success): never
|
|
80
|
+
// throws, not even on a timeout, so it can't fail a finished job or replace the
|
|
81
|
+
// first error. A timeout leaves one warning line.
|
|
82
|
+
export function gitCleanup(args) {
|
|
44
83
|
try {
|
|
45
|
-
|
|
84
|
+
git(args);
|
|
46
85
|
}
|
|
47
|
-
catch {
|
|
86
|
+
catch (err) {
|
|
87
|
+
if (err instanceof GitError && err.timedOut)
|
|
88
|
+
process.stderr.write(`warning: cleanup timed out, a temporary ref may be left (git ${args.join(' ')})\n`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export function gitTry(args, input, limit = GIT_TIMEOUT) {
|
|
92
|
+
try {
|
|
93
|
+
return git(args, input, {}, limit);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
// Any failure is "no" (callers probe with this), but a git killed at the
|
|
97
|
+
// time limit gave no answer: say so rather than let it read as "no".
|
|
98
|
+
rethrowTimeout(err);
|
|
48
99
|
return null;
|
|
49
100
|
}
|
|
50
101
|
}
|
|
@@ -59,11 +110,30 @@ const looksSensitive = (path) => SENSITIVE.some((re) => re.test(path.split('/').
|
|
|
59
110
|
// untracked files except ignored ones and ones that look like secrets. Built
|
|
60
111
|
// in a temporary index so the real index is untouched. `excluded` lists the
|
|
61
112
|
// untracked files left out as possible secrets.
|
|
62
|
-
|
|
113
|
+
const READ_ONLY_MAX_UNTRACKED = 5000;
|
|
114
|
+
// One deadline for the whole read-only check, every git call in it included.
|
|
115
|
+
const READ_ONLY_TOTAL_MS = 10_000;
|
|
116
|
+
class TooBig extends Error {
|
|
117
|
+
}
|
|
118
|
+
// A git runner for the read-only check: every call gets what is left of one total deadline
|
|
119
|
+
// (none left: TooBig), and never more than the usual limit.
|
|
120
|
+
function deadlineRun(totalMs) {
|
|
121
|
+
const end = Date.now() + totalMs;
|
|
122
|
+
return (args, env = {}) => {
|
|
123
|
+
const left = end - Date.now();
|
|
124
|
+
if (left <= 0)
|
|
125
|
+
throw new TooBig();
|
|
126
|
+
return git(args, undefined, env, GIT_TIMEOUT ? Math.min(left, GIT_TIMEOUT) : left);
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
// `bounded`: the read-only check (see wipStaleness): its runner, and nothing is written to the repo.
|
|
130
|
+
function workingTree(bounded) {
|
|
63
131
|
const dir = mkdtempSync(join(tmpdir(), 'git-gloss-index-'));
|
|
132
|
+
const readOnly = !!bounded;
|
|
133
|
+
const run = bounded ?? ((args, env = {}) => git(args, undefined, env));
|
|
64
134
|
try {
|
|
65
135
|
const index = join(dir, 'index');
|
|
66
|
-
const real =
|
|
136
|
+
const real = run(['rev-parse', '--path-format=absolute', '--git-path', 'index']).trim();
|
|
67
137
|
if (existsSync(real)) {
|
|
68
138
|
copyFileSync(real, index);
|
|
69
139
|
// Keep the index's timestamps: git compares them with file mtimes to
|
|
@@ -73,12 +143,20 @@ function workingTree() {
|
|
|
73
143
|
utimesSync(index, atime, mtime);
|
|
74
144
|
}
|
|
75
145
|
const env = { GIT_INDEX_FILE: index };
|
|
76
|
-
const untracked =
|
|
146
|
+
const untracked = run(['ls-files', '--others', '--exclude-standard', '-z', '--', ':/']).split('\0').filter(Boolean);
|
|
77
147
|
const excluded = untracked.filter(looksSensitive);
|
|
78
|
-
|
|
148
|
+
if (readOnly) {
|
|
149
|
+
if (untracked.length > READ_ONLY_MAX_UNTRACKED)
|
|
150
|
+
throw new TooBig();
|
|
151
|
+
// New objects go to the temporary directory (removed below); the repo's own are only read.
|
|
152
|
+
mkdirSync(join(dir, 'objects'));
|
|
153
|
+
env.GIT_OBJECT_DIRECTORY = join(dir, 'objects');
|
|
154
|
+
env.GIT_ALTERNATE_OBJECT_DIRECTORIES = run(['rev-parse', '--path-format=absolute', '--git-path', 'objects']).trim();
|
|
155
|
+
}
|
|
156
|
+
run(['add', '-A', '--', ':/'], env);
|
|
79
157
|
if (excluded.length)
|
|
80
|
-
|
|
81
|
-
return { tree:
|
|
158
|
+
run(['rm', '-q', '--cached', '--', ...excluded.map((p) => `:/${p}`)], env);
|
|
159
|
+
return { tree: run(['write-tree'], env).trim(), excluded };
|
|
82
160
|
}
|
|
83
161
|
finally {
|
|
84
162
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -139,13 +217,35 @@ export function archiveSnapshot(sha) {
|
|
|
139
217
|
export const dropWipNote = (sha) => gitTry(['notes', '--ref', WIP_NOTES_REF, 'remove', '--ignore-missing', sha]);
|
|
140
218
|
export const isWipSnapshot = (sha) => gitTry(['log', '-1', '--format=%s', sha])?.trim() === WIP_MESSAGE;
|
|
141
219
|
// Why the latest snapshot no longer matches the working tree, or null.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
220
|
+
// `readOnly` (the ary server): writes nothing to the repository (the check's objects go to a
|
|
221
|
+
// temporary directory), and ONE total deadline covers every git call in it. If that runs out, or
|
|
222
|
+
// git fails, or the tree is too big, the answer is undefined ("could not check"): it is advisory.
|
|
223
|
+
export function wipStaleness(sha, readOnly = false) {
|
|
224
|
+
if (!readOnly) {
|
|
225
|
+
const s = treeAndParent(sha);
|
|
226
|
+
if (s.parent !== headSha())
|
|
227
|
+
return 'HEAD has moved since this snapshot was taken';
|
|
228
|
+
return workingTree().tree !== s.tree ? 'the working tree has changed since this snapshot was taken' : null;
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
const run = deadlineRun(READ_ONLY_TOTAL_MS);
|
|
232
|
+
const [tree, parent] = run(['log', '-1', '--format=%T %P', sha]).trim().split(' ');
|
|
233
|
+
let head = null; // no commits yet: no HEAD
|
|
234
|
+
try {
|
|
235
|
+
head = run(['rev-parse', '--verify', '--quiet', 'HEAD']).trim();
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
rethrowTimeout(err);
|
|
239
|
+
if (err instanceof TooBig)
|
|
240
|
+
throw err;
|
|
241
|
+
}
|
|
242
|
+
if ((parent || null) !== (head || null))
|
|
243
|
+
return 'HEAD has moved since this snapshot was taken';
|
|
244
|
+
return workingTree(run).tree !== tree ? 'the working tree has changed since this snapshot was taken' : null;
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
149
249
|
}
|
|
150
250
|
// The diff of uncommitted work against HEAD, as a snapshot would have it,
|
|
151
251
|
// without taking a snapshot: no ref is written (only objects). Hunk ids match
|
|
@@ -185,19 +285,28 @@ export function resolveCommit(rev) {
|
|
|
185
285
|
// Merges are not reviewed (their first-parent diff repeats the merged
|
|
186
286
|
// commits) but are returned so views can say they were skipped: a merge can
|
|
187
287
|
// still carry conflict resolutions or edits of its own.
|
|
188
|
-
|
|
288
|
+
// `newest`: keep only the newest that many non-merge commits (and the merges since the oldest of
|
|
289
|
+
// them); `total` is how many there were. The rev-list still walks the whole range.
|
|
290
|
+
export function resolveCommits(arg, newest = Infinity) {
|
|
189
291
|
if (!arg.includes('..'))
|
|
190
|
-
return { shas: [resolveCommit(arg)], merges: [], range: null };
|
|
292
|
+
return { shas: [resolveCommit(arg)], merges: [], range: null, total: 1 };
|
|
191
293
|
if (arg.includes('...')) {
|
|
192
294
|
throw new GitError(`${arg}: "..." includes commits from both sides; use A..B for the commits on B since A`);
|
|
193
295
|
}
|
|
194
296
|
// --end-of-options: the argument is a revision range, never an option.
|
|
195
297
|
const list = (...flags) => git(['rev-list', '--reverse', ...flags, '--end-of-options', arg, '--']).split('\n').filter(Boolean);
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
if (!
|
|
298
|
+
const plain = list('--no-merges');
|
|
299
|
+
let merges = list('--merges');
|
|
300
|
+
if (!plain.length && !merges.length)
|
|
199
301
|
throw new GitError(`no commits in ${arg}`);
|
|
200
|
-
|
|
302
|
+
const shas = plain.slice(-newest);
|
|
303
|
+
if (shas.length < plain.length) {
|
|
304
|
+
// Keep the merges after the oldest shown commit, in the order of the whole range.
|
|
305
|
+
const all = list();
|
|
306
|
+
const keep = new Set(all.slice(all.indexOf(shas[0])));
|
|
307
|
+
merges = merges.filter((c) => keep.has(c));
|
|
308
|
+
}
|
|
309
|
+
return { shas, merges, range: arg, total: plain.length };
|
|
201
310
|
}
|
|
202
311
|
export function commitSubject(sha) {
|
|
203
312
|
return git(['log', '-1', '--format=%s', sha]).trim();
|
|
@@ -238,12 +347,22 @@ function cleanStrings(v) {
|
|
|
238
347
|
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, cleanStrings(x)]));
|
|
239
348
|
return v;
|
|
240
349
|
}
|
|
350
|
+
// A note's total size, which no other cap bounds (each cost is linear in it, but
|
|
351
|
+
// 60 MB took seconds, and the hunk records are not capped by count).
|
|
352
|
+
export const noteSizeProblem = (bytes) => bytes > MAX_NOTE_BYTES ? `${(bytes / 1024 / 1024).toFixed(1)} MiB, over the size limit of ${MAX_NOTE_BYTES / 1024 / 1024} MiB (MAX_NOTE_BYTES in src/schema.ts)` : null;
|
|
241
353
|
export function readNote(sha) {
|
|
242
354
|
// A commit's gloss, or a wip snapshot's (they are never the same object).
|
|
243
355
|
const raw = rawNote(NOTES_REF, sha) ?? rawNote(WIP_NOTES_REF, sha);
|
|
244
356
|
if (raw === null)
|
|
245
357
|
return null;
|
|
246
|
-
|
|
358
|
+
return parseNote(raw, `the gloss on ${sha.slice(0, 10)}`);
|
|
359
|
+
}
|
|
360
|
+
// A stored gloss's text as a note, or a GlossError saying why it can't be one
|
|
361
|
+
// (`at` names it in the message). Also used for glosses read from the notes history.
|
|
362
|
+
export function parseNote(raw, at) {
|
|
363
|
+
const big = noteSizeProblem(Buffer.byteLength(raw));
|
|
364
|
+
if (big)
|
|
365
|
+
throw new GlossError(`${at} is ${big}; replace it with \`git gloss add --force\``);
|
|
247
366
|
let data;
|
|
248
367
|
try {
|
|
249
368
|
data = JSON.parse(raw);
|
|
@@ -268,7 +387,12 @@ export function readNote(sha) {
|
|
|
268
387
|
return { ...data, annotation: cleanStrings(annotation.data) };
|
|
269
388
|
}
|
|
270
389
|
export function writeNote(sha, note, ref = NOTES_REF) {
|
|
271
|
-
|
|
390
|
+
const text = JSON.stringify(note, null, 2) + '\n';
|
|
391
|
+
// Readers refuse a bigger note, so it is not written.
|
|
392
|
+
const big = noteSizeProblem(Buffer.byteLength(text));
|
|
393
|
+
if (big)
|
|
394
|
+
throw new GlossError(`the gloss is ${big}; write less text, or fewer or smaller groups`);
|
|
395
|
+
git(['notes', '--ref', ref, 'add', '-f', '-F', '-', sha], text);
|
|
272
396
|
}
|
|
273
397
|
// The commits that changed lines start..end of `file`, newest first, starting
|
|
274
398
|
// from `commit`, following them through edits, moves within the file and
|
package/dist/html/client.js
CHANGED
|
@@ -264,6 +264,13 @@
|
|
|
264
264
|
var f = r.state === 'seen' ? 'current' : r.state === 'code-changed' ? 'code changed since review' : 'gloss changed since review'
|
|
265
265
|
return v + ' · ' + f
|
|
266
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
|
+
}
|
|
267
274
|
// One compact row per stage; each opens the Reviews view in the main pane.
|
|
268
275
|
function reviewStatus(c) {
|
|
269
276
|
var ci = commits.indexOf(c)
|
|
@@ -274,7 +281,7 @@
|
|
|
274
281
|
var b = button('rvrow', null, function () { selectReviews(ci, r.stage) })
|
|
275
282
|
b.setAttribute('data-fid', 'rv:' + ci + ':' + r.stage)
|
|
276
283
|
b.title = r.phrase
|
|
277
|
-
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)),
|
|
278
285
|
el('span', 'rvn', r.entries.length))
|
|
279
286
|
box.appendChild(b)
|
|
280
287
|
})
|
|
@@ -342,6 +349,9 @@
|
|
|
342
349
|
if (!top || top.indent < indent || top.list.tagName.toLowerCase() !== tag) {
|
|
343
350
|
if (top && top.indent === indent) stack.pop()
|
|
344
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
|
|
345
355
|
if (stack.length && stack[stack.length - 1].li) stack[stack.length - 1].li.appendChild(list); else root.appendChild(list)
|
|
346
356
|
top = { indent: indent, list: list, li: null }
|
|
347
357
|
stack.push(top)
|
|
@@ -376,6 +386,25 @@
|
|
|
376
386
|
if (v) add(head, el('span', 'chip ' + (v === 'ISSUES' ? 'bad' : 'good'), v === 'ISSUES' ? 'issues found' : 'no issues'))
|
|
377
387
|
return head
|
|
378
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
|
+
}
|
|
379
408
|
function reviewsPane(c, stage) {
|
|
380
409
|
var pane = el('div')
|
|
381
410
|
add(pane, button('btn small back', '‹ Back', goBack))
|
|
@@ -387,10 +416,10 @@
|
|
|
387
416
|
add(sec, el('h2', null, r.stage), add(el('p', 'rvsum'), el('span', reviewClass(r), r.phrase)))
|
|
388
417
|
var entries = r.entries.slice().sort(function (a, b) { return a.at < b.at ? 1 : a.at > b.at ? -1 : 0 })
|
|
389
418
|
entries.forEach(function (e, i) {
|
|
390
|
-
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)))
|
|
391
420
|
else {
|
|
392
421
|
var d = el('details', 'entry old')
|
|
393
|
-
add(d, add(el('summary'), entryHead(e)), markdown(e.findings))
|
|
422
|
+
add(d, add(el('summary'), entryHead(e)), decisionsBlock(e), markdown(e.findings))
|
|
394
423
|
add(sec, d)
|
|
395
424
|
}
|
|
396
425
|
})
|
|
@@ -463,8 +492,10 @@
|
|
|
463
492
|
if (!multi && state.kind === 'group' && dir < 0 && !neighbor(-1)) { selectKind(0, 'overview'); return }
|
|
464
493
|
if (multi && state.kind !== 'group') {
|
|
465
494
|
var order = commitOrder()
|
|
466
|
-
|
|
467
|
-
|
|
495
|
+
// The nearest commit the filter shows, not one it hides.
|
|
496
|
+
for (var j = order.indexOf(state.ci) + dir; j >= 0 && j < order.length; j += dir) {
|
|
497
|
+
if (!commitHidden(order[j])) { selectKind(order[j], 'overview'); break }
|
|
498
|
+
}
|
|
468
499
|
return
|
|
469
500
|
}
|
|
470
501
|
var id = neighbor(dir)
|
|
@@ -624,6 +655,7 @@
|
|
|
624
655
|
function unannotatedBox(c) {
|
|
625
656
|
var box = add(el('div', 'unannotated'), el('p', null, c.glossed
|
|
626
657
|
? 'These changes are in the commit but no group explains them.'
|
|
658
|
+
: c.glossRefused ? 'This commit\'s gloss was refused, so nothing explains these changes.'
|
|
627
659
|
: 'This commit has no gloss, so nothing explains these changes.'))
|
|
628
660
|
c.unannotated.forEach(function (h) { box.appendChild(hunkView(c, h, 'un')) })
|
|
629
661
|
return box
|
|
@@ -685,17 +717,18 @@
|
|
|
685
717
|
if (multi) add(pane, el('div', 'eyebrow', 'Commit ' + (ci + 1) + ' of ' + commits.length + ' · ' + new Date(c.date).toLocaleDateString()))
|
|
686
718
|
add(pane, el('h1', 'title', c.subject), commitMeta(c))
|
|
687
719
|
if (!c.glossed) {
|
|
688
|
-
var w = add(el('section', 'warnbox'), el('div', 't', 'No gloss on this commit'),
|
|
720
|
+
var w = add(el('section', 'warnbox'), el('div', 't', c.glossRefused ? 'Gloss refused on this commit' : 'No gloss on this commit'),
|
|
721
|
+
c.glossRefused ? el('p', null, 'The gloss on this commit was refused: ' + c.glossRefused) : null,
|
|
689
722
|
el('p', null, 'Its whole diff is unannotated, so there is no attention level, no groups and no reasons to read.'),
|
|
690
|
-
el('p', 'cmd', 'git gloss add ' + sha7(c)))
|
|
723
|
+
el('p', 'cmd', 'git gloss add ' + sha7(c) + (c.glossRefused ? ' --force' : '')))
|
|
691
724
|
add(pane, w)
|
|
692
725
|
add(pane, changesHead('Changes (' + c.unannotated.length + ')'), unannotatedBox(c))
|
|
693
726
|
return pane
|
|
694
727
|
}
|
|
695
728
|
warnings(c, pane)
|
|
696
|
-
add(pane, attentionCard(c))
|
|
697
729
|
statement(pane, 'Task', c.task)
|
|
698
730
|
statement(pane, 'Summary', c.summary)
|
|
731
|
+
add(pane, attentionCard(c))
|
|
699
732
|
focusSection(c, ci, pane)
|
|
700
733
|
if (c.groups.length) {
|
|
701
734
|
var nb = necessityBar(c)
|
|
@@ -924,7 +957,7 @@
|
|
|
924
957
|
})
|
|
925
958
|
var box = add(el('div', 'sum'), el('div', 'eyebrow', 'Range at a glance'), strip, line)
|
|
926
959
|
var top = commits[order[0]]
|
|
927
|
-
if (levelOf(top).level) {
|
|
960
|
+
if (top && levelOf(top).level) {
|
|
928
961
|
var link = el('a', null, sha7(top) + ' ' + top.subject)
|
|
929
962
|
link.href = '#c-' + sha7(top)
|
|
930
963
|
link.addEventListener('click', function (ev) { ev.preventDefault(); selectKind(order[0], 'overview') })
|
|
@@ -933,13 +966,51 @@
|
|
|
933
966
|
return box
|
|
934
967
|
}
|
|
935
968
|
|
|
969
|
+
// The box above a range's commit list. Matching is plain text (lower-cased `includes` on the
|
|
970
|
+
// short sha, subject and author), never a pattern or markup. The one input is kept across
|
|
971
|
+
// re-renders so what was typed stays; it is not saved anywhere, so a reload clears it.
|
|
972
|
+
var railRows = []
|
|
973
|
+
var filterBox = null
|
|
974
|
+
var noMatch = el('div', 'emptyrows', 'No commits match.')
|
|
975
|
+
function commitHidden(ci) {
|
|
976
|
+
return railRows.some(function (r) { return r.ci === ci && r.node.hidden })
|
|
977
|
+
}
|
|
978
|
+
function applyCommitFilter() {
|
|
979
|
+
var q = filterBox.value.trim().toLowerCase()
|
|
980
|
+
var shown = 0
|
|
981
|
+
railRows.forEach(function (r) {
|
|
982
|
+
var hit = !q || r.text.indexOf(q) >= 0
|
|
983
|
+
r.node.hidden = !hit
|
|
984
|
+
if (hit) shown++
|
|
985
|
+
})
|
|
986
|
+
noMatch.hidden = !q || !!shown
|
|
987
|
+
}
|
|
988
|
+
function commitFilter() {
|
|
989
|
+
if (!filterBox) {
|
|
990
|
+
filterBox = el('input')
|
|
991
|
+
filterBox.type = 'search'
|
|
992
|
+
filterBox.spellcheck = false
|
|
993
|
+
filterBox.placeholder = 'Filter commits'
|
|
994
|
+
filterBox.setAttribute('aria-label', 'Filter commits by message, author or sha')
|
|
995
|
+
filterBox.addEventListener('input', applyCommitFilter)
|
|
996
|
+
}
|
|
997
|
+
return add(el('div', 'cfilter'), filterBox, noMatch)
|
|
998
|
+
}
|
|
999
|
+
|
|
936
1000
|
function renderRail() {
|
|
937
1001
|
railEl.textContent = ''
|
|
938
1002
|
if (multi) {
|
|
939
1003
|
add(railEl, railSummary(), add(el('div', 'railhead'), el('div', 'eyebrow', state.hot ? 'Hottest first, then newest' : 'Newest first')))
|
|
940
1004
|
var list = el('div', 'commits')
|
|
941
|
-
|
|
942
|
-
|
|
1005
|
+
railRows = []
|
|
1006
|
+
commitOrder().forEach(function (ci) {
|
|
1007
|
+
var row = commitRow(ci)
|
|
1008
|
+
var c = commits[ci]
|
|
1009
|
+
railRows.push({ ci: ci, node: row, text: (sha7(c) + ' ' + c.subject + ' ' + c.author).toLowerCase() })
|
|
1010
|
+
list.appendChild(row)
|
|
1011
|
+
})
|
|
1012
|
+
add(railEl, commitFilter(), list)
|
|
1013
|
+
applyCommitFilter()
|
|
943
1014
|
if (data.merges.length) {
|
|
944
1015
|
var ml = add(el('div', 'mergelist'), el('div', 'eyebrow', 'Merges, not reviewed'))
|
|
945
1016
|
data.merges.forEach(function (m) { ml.appendChild(add(el('div'), el('span', 'mono', m.sha.slice(0, 7)), text(' ' + m.subject))) })
|
package/dist/html/data.js
CHANGED
|
@@ -1,19 +1,42 @@
|
|
|
1
1
|
import { commitDiff, commitSubject, git } from '../git.js';
|
|
2
|
-
import {
|
|
2
|
+
import { labelFindings, orderDecisions } from '../findings.js';
|
|
3
|
+
import { decisionSummary, readDecisions, reviewStatus, statusPhrase, verdict } from '../reviews.js';
|
|
3
4
|
import { parseDiff } from '../diff.js';
|
|
4
|
-
import { loadGloss } from '../loader.js';
|
|
5
|
+
import { loadGlossInRange, loadGloss, refusedWarning } from '../loader.js';
|
|
6
|
+
import { plain } from '../plain.js';
|
|
5
7
|
import { belowFloor } from '../render.js';
|
|
6
8
|
import { showsImpact, tryAnalyze } from '../floor.js';
|
|
7
9
|
import { bodyLines, changedCount, gapText, isGap, refLabel, slice, sliceStats } from '../selection.js';
|
|
8
10
|
import { IMPACT, IMPACT_HELP, NECESSITY, NECESSITY_HELP, servesOf } from '../schema.js';
|
|
11
|
+
// The most decisions listed per review (the newest); the summary counts all of them.
|
|
12
|
+
export const MAX_PAGE_DECISIONS = 200;
|
|
13
|
+
// The page shows commit text as it shows a terminal's: without escape sequences or characters that reorder or hide text.
|
|
14
|
+
const authorAndDate = (sha) => {
|
|
15
|
+
const [author, date] = git(['log', '-1', '--format=%an%x00%aI', sha]).trim().split('\0');
|
|
16
|
+
return [plain(author), plain(date)];
|
|
17
|
+
};
|
|
9
18
|
function pageReviews(sha) {
|
|
19
|
+
const all = readDecisions(sha);
|
|
10
20
|
return reviewStatus(sha).map((s) => ({
|
|
11
21
|
stage: s.stage,
|
|
12
22
|
state: s.code === 'changed' ? 'code-changed'
|
|
13
23
|
: (s.stage === 'honesty' || s.stage === 'improve') && s.gloss === 'changed' ? 'gloss-changed' : 'seen',
|
|
14
24
|
verdict: verdict(s),
|
|
15
25
|
phrase: statusPhrase(s),
|
|
16
|
-
entries: s.reviews.map((r) =>
|
|
26
|
+
entries: s.reviews.map((r) => {
|
|
27
|
+
const entry = { id: r.id, agent: r.agent, model: r.model, mode: r.mode, at: r.at, target: r.target, findings: labelFindings(r.findings) };
|
|
28
|
+
const ds = all.filter((d) => d.review === r.id);
|
|
29
|
+
if (r.verdict === 'clean' && !ds.length)
|
|
30
|
+
return entry;
|
|
31
|
+
// Sorted here only to pick the newest MAX_PAGE_DECISIONS; orderDecisions sets the order shown.
|
|
32
|
+
const done = ds.filter((d) => d.outcome).sort((a, b) => a.at.localeCompare(b.at));
|
|
33
|
+
entry.decisions = orderDecisions(done.slice(-MAX_PAGE_DECISIONS)).map((d) => ({ outcome: d.outcome, finding: plain(d.finding), ...(d.note ? { note: plain(d.note) } : {}), by: plain(d.by), at: plain(d.at) }));
|
|
34
|
+
entry.complete = ds.some((d) => d.complete);
|
|
35
|
+
entry.summary = decisionSummary(ds);
|
|
36
|
+
entry.verdict = r.verdict;
|
|
37
|
+
entry.decisionCount = done.length;
|
|
38
|
+
return entry;
|
|
39
|
+
}),
|
|
17
40
|
}));
|
|
18
41
|
}
|
|
19
42
|
// The lines to show for a selection: the @@ header, then the slice, with
|
|
@@ -71,7 +94,7 @@ export function reviewOrder(groups, focus = []) {
|
|
|
71
94
|
}
|
|
72
95
|
const allLines = (h) => Array.from({ length: changedCount(h) }, (_, i) => i + 1);
|
|
73
96
|
export function commitData(gloss) {
|
|
74
|
-
const [author, date] =
|
|
97
|
+
const [author, date] = authorAndDate(gloss.sha);
|
|
75
98
|
const a = gloss.note.annotation;
|
|
76
99
|
const an = tryAnalyze(gloss);
|
|
77
100
|
// Same threshold as `show`: derived impact badges only where the diff puts a group above module.
|
|
@@ -120,7 +143,7 @@ export function commitData(gloss) {
|
|
|
120
143
|
.sort((x, y) => NECESSITY.indexOf(x.necessity) - NECESSITY.indexOf(y.necessity));
|
|
121
144
|
return {
|
|
122
145
|
sha: gloss.sha,
|
|
123
|
-
subject: gloss.subject,
|
|
146
|
+
subject: plain(gloss.subject),
|
|
124
147
|
author,
|
|
125
148
|
date,
|
|
126
149
|
glossed: true,
|
|
@@ -137,14 +160,15 @@ export function commitData(gloss) {
|
|
|
137
160
|
reviews: pageReviews(gloss.sha),
|
|
138
161
|
};
|
|
139
162
|
}
|
|
140
|
-
function unglossedData(sha) {
|
|
141
|
-
const [author, date] =
|
|
163
|
+
function unglossedData(sha, refused) {
|
|
164
|
+
const [author, date] = authorAndDate(sha);
|
|
142
165
|
return {
|
|
143
166
|
sha,
|
|
144
|
-
subject: commitSubject(sha),
|
|
167
|
+
subject: plain(commitSubject(sha)),
|
|
145
168
|
author,
|
|
146
169
|
date,
|
|
147
170
|
glossed: false,
|
|
171
|
+
...(refused ? { glossRefused: plain(refused).slice(0, 300) } : {}),
|
|
148
172
|
task: '',
|
|
149
173
|
attention: null,
|
|
150
174
|
computed: null,
|
|
@@ -160,12 +184,15 @@ function unglossedData(sha) {
|
|
|
160
184
|
export function buildPageData(shas, range = null, merges = []) {
|
|
161
185
|
return {
|
|
162
186
|
range,
|
|
163
|
-
merges: merges.map((sha) => ({ sha, subject: commitSubject(sha) })),
|
|
187
|
+
merges: merges.map((sha) => ({ sha, subject: plain(commitSubject(sha)) })),
|
|
164
188
|
necessity: NECESSITY.map((name) => ({ name, help: NECESSITY_HELP[name] })),
|
|
165
189
|
impact: IMPACT.map((name) => ({ name, help: IMPACT_HELP[name] })),
|
|
166
190
|
commits: shas.map((sha) => {
|
|
167
|
-
|
|
168
|
-
|
|
191
|
+
// In a range, a refused gloss is skipped (with a warning) like an unglossed commit.
|
|
192
|
+
const { gloss, refused } = range ? loadGlossInRange(sha) : { gloss: loadGloss(sha), refused: undefined };
|
|
193
|
+
if (refused)
|
|
194
|
+
console.error(refusedWarning(sha, refused));
|
|
195
|
+
return gloss ? commitData(gloss) : unglossedData(sha, refused);
|
|
169
196
|
}),
|
|
170
197
|
generated: new Date().toISOString(),
|
|
171
198
|
};
|
package/dist/html/style.css
CHANGED
|
@@ -123,6 +123,7 @@ kbd { font-family: var(--mono); font-size: 11px; border: 1px solid var(--line);
|
|
|
123
123
|
.ary-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; min-width: 0; }
|
|
124
124
|
.ary-repo { color: var(--muted); font-family: var(--mono); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
|
|
125
125
|
.ary-field { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 12px; }
|
|
126
|
+
.ary-wip { font-size: 13px; }
|
|
126
127
|
.ary-notice { flex-basis: 100%; margin: 0; padding: 6px 10px; border-radius: 8px; border: 1px dashed var(--warn); font-size: 13px; color: var(--ink); }
|
|
127
128
|
.combo { position: relative; }
|
|
128
129
|
.combo-btn, .combo-panel input, .ary-rev input { border: 1px solid var(--line); background: var(--panel); color: var(--ink); border-radius: 8px; min-height: 36px; padding: 4px 12px; font-size: 13px; }
|
|
@@ -220,6 +221,9 @@ button.chip { min-height: 32px; }
|
|
|
220
221
|
.strip i.none { background: transparent; border: 1.5px dashed var(--attention-none); }
|
|
221
222
|
.commits { display: flex; flex-direction: column; padding: 0 8px 16px; }
|
|
222
223
|
.crow { margin-bottom: 2px; }
|
|
224
|
+
.crow[hidden] { display: none; }
|
|
225
|
+
.cfilter { padding: 0 8px 8px; }
|
|
226
|
+
.cfilter input { width: 100%; box-sizing: border-box; border: 1px solid var(--line); background: var(--panel); color: var(--ink); border-radius: 8px; min-height: 36px; padding: 4px 12px; font-size: 13px; }
|
|
223
227
|
.crow > .pick { display: block; width: 100%; border: 1px solid transparent; border-radius: 8px; padding: 10px 12px; min-height: 64px; }
|
|
224
228
|
.crow.sel > .pick { background: var(--accent-soft); border-color: var(--accent); }
|
|
225
229
|
.rtop { display: flex; align-items: center; gap: 10px; }
|
|
@@ -340,6 +344,18 @@ tr.gap td { background: var(--bg); color: var(--muted); font-style: italic; font
|
|
|
340
344
|
.chip.good { color: var(--ok); border-color: var(--ok); }
|
|
341
345
|
.chip.bad { color: var(--warn); border-color: var(--warn); }
|
|
342
346
|
|
|
347
|
+
/* Decisions on a review: the outcome differs by its word and its border, not by colour alone */
|
|
348
|
+
.decisions { margin: 0 0 12px; }
|
|
349
|
+
.dhead { margin: 0 0 2px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
|
|
350
|
+
.dsum { margin: 0 0 6px; }
|
|
351
|
+
.dlist { list-style: none; margin: 0; padding: 0; }
|
|
352
|
+
.drow { padding: 4px 0; overflow-wrap: anywhere; }
|
|
353
|
+
.dchip { font-size: 12px; padding: 0 8px; }
|
|
354
|
+
.dchip.fixed { color: var(--ok); border-color: var(--ok); border-style: solid; }
|
|
355
|
+
.dchip.follow-up { border-style: dashed; }
|
|
356
|
+
.dchip.rejected { border-style: dotted; text-decoration: line-through; }
|
|
357
|
+
.dchip.accepted { border-style: double; border-width: 3px; }
|
|
358
|
+
|
|
343
359
|
/* Rendered markdown */
|
|
344
360
|
.md { max-width: 75ch; overflow-wrap: anywhere; }
|
|
345
361
|
.md p { margin: 0 0 10px; }
|
package/dist/loader.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { commitDiff, commitSubject, GlossError, readNote } from './git.js';
|
|
1
|
+
import { commitDiff, commitSubject, GitError, GlossError, readNote } from './git.js';
|
|
2
|
+
import { plain } from './plain.js';
|
|
2
3
|
import { parseDiff } from './diff.js';
|
|
3
4
|
import { changedCount, claimsProblem, selections, uncovered } from './selection.js';
|
|
4
5
|
const locationKey = (h) => [h.file, h.oldFile ?? '', h.oldStart, h.oldLines, h.newStart, h.newLines].join('\0');
|
|
@@ -37,8 +38,11 @@ export function matchHunks(stored, current) {
|
|
|
37
38
|
// the wrong code under a group without anyone noticing.
|
|
38
39
|
export function loadGloss(sha) {
|
|
39
40
|
const note = readNote(sha);
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
return note && loadGlossOf(sha, note);
|
|
42
|
+
}
|
|
43
|
+
// The same, for a note already read: what `versions --restore` checks a version
|
|
44
|
+
// with before it becomes the gloss, so it can never restore what readers refuse.
|
|
45
|
+
export function loadGlossOf(sha, note) {
|
|
42
46
|
const current = parseDiff(commitDiff(sha));
|
|
43
47
|
const resolved = matchHunks(note.hunks, current);
|
|
44
48
|
const groups = note.annotation.groups.map((group) => ({
|
|
@@ -64,3 +68,18 @@ export function loadGloss(sha) {
|
|
|
64
68
|
}
|
|
65
69
|
return { sha, subject: commitSubject(sha), note, hunks: current, groups, unannotated, stale: warnings.length > 0, warnings };
|
|
66
70
|
}
|
|
71
|
+
// For a range: a commit whose gloss is refused (too big, malformed, over the
|
|
72
|
+
// caps, claims too many lines) is skipped like an unglossed one, and the
|
|
73
|
+
// reason comes back for the caller to warn about, so one hostile note does not
|
|
74
|
+
// fail the whole range. A git failure or timeout is not a refusal and still throws.
|
|
75
|
+
export function loadGlossInRange(sha) {
|
|
76
|
+
try {
|
|
77
|
+
return { gloss: loadGloss(sha) };
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
if (!(err instanceof GlossError) || err instanceof GitError)
|
|
81
|
+
throw err;
|
|
82
|
+
return { gloss: null, refused: err.message };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export const refusedWarning = (sha, why) => `warning: skipping the gloss on ${sha.slice(0, 10)}, which ${plain(why).replace(/^the gloss on \S+ /, '')}`;
|
package/dist/plain.js
CHANGED
|
@@ -5,5 +5,6 @@ export function plain(text, multiline = false) {
|
|
|
5
5
|
// OSC (ended by BEL or ESC \) and CSI sequences, then any lone ESC and control characters (newline and tab kept when multiline).
|
|
6
6
|
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, '')
|
|
7
7
|
.replace(/\x1b\[[0-?]*[ -/]*[@-~]?/g, '')
|
|
8
|
-
|
|
8
|
+
// Also the invisible format characters that reorder or hide text: LRM, RLM, ALM (U+200E, U+200F, U+061C), zero-width space (U+200B), word joiner and invisible operators (U+2060-U+2064), BOM (U+FEFF), the Mongolian vowel separator (U+180E) and tag characters (U+E0000-U+E007F). U+200C (ZWNJ) and U+200D (ZWJ) are kept: stripping them would mangle valid text (Persian words, emoji sequences), and they reorder or hide nothing. The soft hyphen (U+00AD) is kept too: it hides no text. The html page does not use plain().
|
|
9
|
+
.replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029\u202a-\u202e\u2066-\u2069\u200b\u200e\u200f\u061c\u180e\u2060-\u2064\ufeff\u{e0000}-\u{e007f}]/gu, (c) => (multiline && (c === '\n' || c === '\t') ? c : ' '));
|
|
9
10
|
}
|