@ok-ggwp/git-gloss 0.3.0 → 0.5.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 +72 -4
- package/dist/ary/browse.js +4 -61
- package/dist/ary/picker.js +163 -0
- package/dist/ary/server.js +87 -37
- package/dist/attention.js +34 -0
- package/dist/carry.js +4 -1
- package/dist/cli.js +2 -1
- package/dist/commands/add.js +17 -1
- package/dist/commands/ary.js +3 -3
- package/dist/commands/hunks.js +8 -1
- package/dist/commands/review.js +5 -4
- package/dist/commands/reviews.js +58 -5
- package/dist/commands/show.js +56 -20
- package/dist/commands/signals.js +89 -0
- package/dist/commands/sync.js +14 -2
- package/dist/commands/why.js +3 -2
- package/dist/config.js +248 -0
- package/dist/floor.js +148 -0
- package/dist/git.js +29 -3
- package/dist/glob.js +76 -0
- package/dist/html/client.js +1032 -296
- package/dist/html/data.js +26 -1
- package/dist/html/page.js +1 -1
- package/dist/html/style.css +310 -164
- package/dist/loader.js +7 -3
- package/dist/merge.js +5 -0
- package/dist/plain.js +9 -0
- package/dist/render.js +55 -5
- package/dist/reviews.js +98 -4
- package/dist/safe-match.js +30 -0
- package/dist/schema.js +55 -4
- package/dist/selection.js +21 -0
- package/dist/signals.js +302 -0
- package/dist/validate.js +10 -1
- package/package.json +1 -1
- package/skills/gloss/SKILL.md +42 -2
- package/dist/ary/client.js +0 -401
- package/dist/ary/index.html +0 -13
- package/dist/ary/style.css +0 -115
package/dist/loader.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { commitDiff, commitSubject, readNote } from './git.js';
|
|
1
|
+
import { commitDiff, commitSubject, GlossError, readNote } from './git.js';
|
|
2
2
|
import { parseDiff } from './diff.js';
|
|
3
|
-
import { changedCount, selections, uncovered } from './selection.js';
|
|
3
|
+
import { changedCount, claimsProblem, selections, uncovered } from './selection.js';
|
|
4
4
|
const locationKey = (h) => [h.file, h.oldFile ?? '', h.oldStart, h.oldLines, h.newStart, h.newLines].join('\0');
|
|
5
5
|
// Pair each stored hunk with a current one: first by the hash of its changes
|
|
6
6
|
// (it may have moved in a rebase), then by location (same place, different
|
|
@@ -49,6 +49,10 @@ export function loadGloss(sha) {
|
|
|
49
49
|
})),
|
|
50
50
|
}));
|
|
51
51
|
const claims = groups.flatMap((g) => g.hunks.flatMap((h) => (h.current ? [{ hunk: h.current, lines: h.lines }] : [])));
|
|
52
|
+
// Readers refuse what `add` refuses (see claimsProblem).
|
|
53
|
+
const tooMany = claimsProblem(claims, current);
|
|
54
|
+
if (tooMany)
|
|
55
|
+
throw new GlossError(`the gloss on ${sha.slice(0, 10)} is refused: ${tooMany}`);
|
|
52
56
|
const unannotated = uncovered(current, claims);
|
|
53
57
|
const bad = [...resolved.values()].filter((h) => h.status !== 'ok').length;
|
|
54
58
|
const matched = new Set([...resolved.values()].map((h) => h.current));
|
|
@@ -58,5 +62,5 @@ export function loadGloss(sha) {
|
|
|
58
62
|
warnings.push(`this gloss no longer matches the commit (${bad} glossed hunk(s) changed or gone, ${unknown} new); ` +
|
|
59
63
|
'the commit was probably amended or rebased after glossing. Re-gloss it with `git gloss add --force`.');
|
|
60
64
|
}
|
|
61
|
-
return { sha, subject: commitSubject(sha), note, groups, unannotated, stale: warnings.length > 0, warnings };
|
|
65
|
+
return { sha, subject: commitSubject(sha), note, hunks: current, groups, unannotated, stale: warnings.length > 0, warnings };
|
|
62
66
|
}
|
package/dist/merge.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { matchHunks } from './loader.js';
|
|
2
|
+
import { combineAttention } from './attention.js';
|
|
2
3
|
import { servesOf } from './schema.js';
|
|
3
4
|
import { bodyLines, changedCount, formatRanges, selections, uncovered } from './selection.js';
|
|
4
5
|
import { alsoServed } from './validate.js';
|
|
@@ -177,6 +178,9 @@ export function mergeGlosses(sources, target) {
|
|
|
177
178
|
const summaries = unique(notes.flatMap((a) => (a.summary ? [a.summary] : [])));
|
|
178
179
|
const alternatives = unique(notes.flatMap((a) => a.alternatives ?? []));
|
|
179
180
|
const risks = unique(notes.flatMap((a) => a.risks ?? []));
|
|
181
|
+
const rated = used.filter((i) => sources[i].note.annotation.attention);
|
|
182
|
+
// One unrated source makes the whole gloss unrated: its changes have no level to take.
|
|
183
|
+
const attention = rated.length < used.length ? undefined : combineAttention(rated.map((i) => sources[i].note.annotation.attention), (part, id) => newId.get(`${rated[part]}\0${id}`), groups);
|
|
180
184
|
const annotation = {
|
|
181
185
|
version: 1,
|
|
182
186
|
task: tasks.join('; '),
|
|
@@ -184,6 +188,7 @@ export function mergeGlosses(sources, target) {
|
|
|
184
188
|
groups,
|
|
185
189
|
...(alternatives.length ? { alternatives } : {}),
|
|
186
190
|
...(risks.length ? { risks } : {}),
|
|
191
|
+
...(attention ? { attention } : {}),
|
|
187
192
|
...(notes[0].author ? { author: notes[0].author } : {}),
|
|
188
193
|
};
|
|
189
194
|
const claims = kept.flatMap(({ byHunk }) => [...byHunk].map(([hunk, lines]) => ({ hunk, lines })));
|
package/dist/plain.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Text from a note is shown in a terminal: no escape sequences or other
|
|
2
|
+
// control characters get through. Newlines and tabs stay when `multiline`.
|
|
3
|
+
export function plain(text, multiline = false) {
|
|
4
|
+
return text
|
|
5
|
+
// OSC (ended by BEL or ESC \) and CSI sequences, then any lone ESC and control characters (newline and tab kept when multiline).
|
|
6
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, '')
|
|
7
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]?/g, '')
|
|
8
|
+
.replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g, (c) => (multiline && (c === '\n' || c === '\t') ? c : ' '));
|
|
9
|
+
}
|
package/dist/render.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { plain } from './plain.js';
|
|
1
2
|
import { spawnSync } from 'node:child_process';
|
|
2
3
|
import { styleText } from 'node:util';
|
|
3
4
|
import { gitTry } from './git.js';
|
|
4
5
|
import { refLabel, sliceStats } from './selection.js';
|
|
5
|
-
import {
|
|
6
|
+
import { attentionRank } from './attention.js';
|
|
7
|
+
import { IMPACT, servesOf } from './schema.js';
|
|
6
8
|
export const style = (s, text) => styleText(s, text);
|
|
7
9
|
const NECESSITY_STYLE = {
|
|
8
10
|
required: 'green',
|
|
@@ -18,12 +20,57 @@ export function necessityTag(n) {
|
|
|
18
20
|
export function necessityWord(n, pad = 0) {
|
|
19
21
|
return style(NECESSITY_STYLE[n], n.padEnd(pad));
|
|
20
22
|
}
|
|
23
|
+
const IMPACT_STYLE = {
|
|
24
|
+
local: 'gray',
|
|
25
|
+
module: 'white',
|
|
26
|
+
shared: 'yellow',
|
|
27
|
+
contract: 'magenta',
|
|
28
|
+
sensitive: 'red',
|
|
29
|
+
};
|
|
30
|
+
// "[contract]", padded to `pad` columns.
|
|
31
|
+
export function impactTag(i, pad = 0) {
|
|
32
|
+
return style(['bold', IMPACT_STYLE[i]], `[${i}]`) + ' '.repeat(Math.max(0, pad - i.length - 2));
|
|
33
|
+
}
|
|
34
|
+
// "(contract)": an impact derived from the diff rather than declared.
|
|
35
|
+
export function derivedImpactTag(i, pad = 0) {
|
|
36
|
+
return style('dim', `(${i})`) + ' '.repeat(Math.max(0, pad - i.length - 2));
|
|
37
|
+
}
|
|
38
|
+
// Width of the widest impact tag, "[sensitive]".
|
|
39
|
+
export const IMPACT_W = Math.max(...IMPACT.map((i) => i.length)) + 2;
|
|
40
|
+
export const ATTENTION_STYLE = {
|
|
41
|
+
skim: 'gray',
|
|
42
|
+
look: 'cyan',
|
|
43
|
+
careful: 'yellow',
|
|
44
|
+
'must-read': 'red',
|
|
45
|
+
};
|
|
46
|
+
// Declared below the computed floor.
|
|
47
|
+
export const belowFloor = (a, view) => !!a && !!view && attentionRank(a.level) < attentionRank(view.floor);
|
|
48
|
+
// "attention: CAREFUL (declared) · floor: careful · focus: a, b" with the
|
|
49
|
+
// reasons beneath. Without a declared level but with signals: "LOOK (derived)"
|
|
50
|
+
// and why. "unrated" where nothing is declared and no signals could be computed.
|
|
51
|
+
export function attentionBlock(a, view) {
|
|
52
|
+
const level = (l) => style(['bold', ATTENTION_STYLE[l]], l.toUpperCase());
|
|
53
|
+
if (!a) {
|
|
54
|
+
if (!view)
|
|
55
|
+
return [style('dim', 'attention: unrated')];
|
|
56
|
+
const d = view.derived;
|
|
57
|
+
return [`attention: ${level(d.level)} ${style('dim', '(derived)')}` + (d.level === view.floor ? '' : ` · floor: ${view.floor}`),
|
|
58
|
+
...d.reasons.flatMap((r) => field('•', r)), ...timedOutLines(view)];
|
|
59
|
+
}
|
|
60
|
+
const head = `attention: ${level(a.level)}${view ? ' ' + style('dim', '(declared)') + ` · floor: ${view.floor}` : ''}` +
|
|
61
|
+
(a.focus?.length ? ` · focus: ${a.focus.join(', ')}` : '');
|
|
62
|
+
const below = belowFloor(a, view);
|
|
63
|
+
return [head, ...(a.reasons ?? []).flatMap((r) => field('•', r)),
|
|
64
|
+
...(below ? field('⚠', `declared ${a.level}, below the computed floor ${view.floor}` + (a.override_reason ? '' : ' (no override_reason)'), 2).map((l) => style('yellow', l)) : []),
|
|
65
|
+
...(a.override_reason ? field('override:', a.override_reason) : []), ...timedOutLines(view)];
|
|
66
|
+
}
|
|
67
|
+
const timedOutLines = (view) => (view?.timedOut ?? []).flatMap((r) => field('⚠', r, 2).map((l) => style('yellow', l)));
|
|
21
68
|
export const termWidth = () => Math.min(process.stdout.columns || 100, 120);
|
|
22
69
|
// Word-wrap plain text to `width` columns.
|
|
23
70
|
export function wrap(text, width) {
|
|
24
71
|
const lines = [];
|
|
25
72
|
let line = '';
|
|
26
|
-
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
73
|
+
for (const word of plain(text).split(/\s+/).filter(Boolean)) {
|
|
27
74
|
if (line && line.length + 1 + word.length > width) {
|
|
28
75
|
lines.push(line);
|
|
29
76
|
line = word;
|
|
@@ -45,7 +92,7 @@ export function hunkLabel(h, lines = null) {
|
|
|
45
92
|
const range = h.newLines || h.oldLines
|
|
46
93
|
? `-${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines}`
|
|
47
94
|
: 'file header';
|
|
48
|
-
const file = h.oldFile && h.oldFile !== h.file ? `${h.oldFile} → ${h.file}` : h.file;
|
|
95
|
+
const file = plain(h.oldFile && h.oldFile !== h.file ? `${h.oldFile} → ${h.file}` : h.file);
|
|
49
96
|
const s = sliceStats(h, lines);
|
|
50
97
|
return `${style('bold', refLabel(h.id, lines))} ${file} ${style('dim', range)} ${style('green', `+${s.added}`)} ${style('red', `-${s.removed}`)}`;
|
|
51
98
|
}
|
|
@@ -63,13 +110,16 @@ export function colorDiff(text, indent = '') {
|
|
|
63
110
|
.join('\n');
|
|
64
111
|
}
|
|
65
112
|
export function groupHeader(g) {
|
|
66
|
-
const
|
|
67
|
-
const
|
|
113
|
+
const impact = g.impact ? ` [${g.impact}]` : '';
|
|
114
|
+
const prefix = `■ [${g.necessity}]${impact} `;
|
|
115
|
+
const out = wrap(g.intent, termWidth() - prefix.length).map((l, i) => (i === 0 ? `${style('bold', '■')} ${necessityTag(g.necessity)}${g.impact ? ' ' + impactTag(g.impact) : ''} ` : ' '.repeat(prefix.length)) + style('bold', l));
|
|
68
116
|
out[out.length - 1] += ' ' + style('dim', `(${g.id})`);
|
|
69
117
|
const serves = servesOf(g);
|
|
70
118
|
if (serves[0] !== 'task')
|
|
71
119
|
out.push(...field('serves:', serves.join(', ')));
|
|
72
120
|
out.push(...field('if removed:', g.removal_impact));
|
|
121
|
+
if (g.impact_reason)
|
|
122
|
+
out.push(...field('impact:', g.impact_reason));
|
|
73
123
|
if (g.evidence)
|
|
74
124
|
out.push(...field('evidence:', g.evidence));
|
|
75
125
|
if (g.notes)
|
package/dist/reviews.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { commitDiff, git, gitTry, GlossError, readNote } from './git.js';
|
|
3
3
|
import { parseDiff } from './diff.js';
|
|
4
4
|
import { bodyLines } from './selection.js';
|
|
5
|
+
import { plain } from './plain.js';
|
|
5
6
|
// Reviews of commits (by git-gud, or anything else that records them), kept
|
|
6
7
|
// beside the glosses so a reader can see whether a gloss was checked. One JSON
|
|
7
8
|
// record per line: notes from different people and machines combine by taking
|
|
@@ -56,12 +57,29 @@ export function stamp(sha) {
|
|
|
56
57
|
catch { /* an unreadable gloss: nothing to stamp */ }
|
|
57
58
|
return { lines: lineKeys(parseDiff(commitDiff(sha))), groups };
|
|
58
59
|
}
|
|
60
|
+
// What a review record may hold, for readers and for `--record`. The line
|
|
61
|
+
// carries a stamp of one 16-character key per changed line, so it can be large
|
|
62
|
+
// for a large change (16,000 changed lines make about 300 KB) without being
|
|
63
|
+
// junk: measured, JSON.parse reads 32 MB in under 0.1 s. So the line is only
|
|
64
|
+
// bounded generously (8 MiB, about 440,000 changed lines), and the report text,
|
|
65
|
+
// which is what gets rendered and printed, is bounded on its own.
|
|
66
|
+
export const MAX_REVIEW_LINE = 8 * 1024 * 1024;
|
|
67
|
+
export const MAX_FINDINGS = 256 * 1024;
|
|
68
|
+
const warnedLong = new Set();
|
|
59
69
|
export function readReviews(sha) {
|
|
60
70
|
const raw = gitTry(['notes', '--ref', REVIEWS_REF, 'show', sha]);
|
|
61
71
|
if (!raw)
|
|
62
72
|
return [];
|
|
63
73
|
// A rebase can join a note onto one with the same lines; each counts once.
|
|
64
|
-
|
|
74
|
+
const kept = [];
|
|
75
|
+
let skipped = 0;
|
|
76
|
+
for (const l of new Set(raw.split('\n'))) {
|
|
77
|
+
if (l.length <= MAX_REVIEW_LINE)
|
|
78
|
+
kept.push(l);
|
|
79
|
+
else if (!isDecisionLine(l))
|
|
80
|
+
skipped++;
|
|
81
|
+
}
|
|
82
|
+
const records = kept.flatMap((line) => {
|
|
65
83
|
try {
|
|
66
84
|
const r = JSON.parse(line);
|
|
67
85
|
// Records arrive from other machines; skip any that aren't whole.
|
|
@@ -69,6 +87,57 @@ export function readReviews(sha) {
|
|
|
69
87
|
['id', 'at', 'stage', 'agent', 'target', 'findings'].every((k) => typeof r[k] === 'string') &&
|
|
70
88
|
['model', 'mode'].every((k) => r[k] === undefined || typeof r[k] === 'string') &&
|
|
71
89
|
(r.verdict === undefined || r.verdict === 'clean' || r.verdict === 'issues');
|
|
90
|
+
// Everything shown later may reach a terminal: clean it once, here. The
|
|
91
|
+
// id stays as written, since decisions refer to it.
|
|
92
|
+
if (!ok)
|
|
93
|
+
return [];
|
|
94
|
+
if (r.findings.length > MAX_FINDINGS) {
|
|
95
|
+
skipped++;
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
const one = (v) => plain(v);
|
|
99
|
+
return [{
|
|
100
|
+
...r, at: one(r.at), stage: one(r.stage), agent: one(r.agent), target: one(r.target), findings: plain(r.findings, true),
|
|
101
|
+
...(r.model !== undefined ? { model: one(r.model) } : {}), ...(r.mode !== undefined ? { mode: one(r.mode) } : {}),
|
|
102
|
+
}];
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
// Said once per commit, though several readers ask for the same note.
|
|
109
|
+
if (skipped && !warnedLong.has(sha)) {
|
|
110
|
+
warnedLong.add(sha);
|
|
111
|
+
console.error(`warning: skipped ${skipped} review record${skipped === 1 ? '' : 's'} on ${sha.slice(0, 10)} with a report over ${MAX_FINDINGS / 1024} KiB or a line over ${MAX_REVIEW_LINE / 1024 / 1024} MiB`);
|
|
112
|
+
}
|
|
113
|
+
return records;
|
|
114
|
+
}
|
|
115
|
+
export const OUTCOMES = ['fixed', 'follow-up', 'rejected', 'accepted'];
|
|
116
|
+
const MAX_TEXT = 2000;
|
|
117
|
+
const MAX_BY = 200;
|
|
118
|
+
// A decision line is a few hundred bytes; anything this long is junk, skipped
|
|
119
|
+
// before it is parsed.
|
|
120
|
+
export const MAX_DECISION_LINE = 16 * 1024;
|
|
121
|
+
export const isDecisionLine = (line) => /^\s*\{\s*"decision"\s*:/.test(line.slice(0, 64));
|
|
122
|
+
// Reads the decisions on a commit that answer a review recorded on it.
|
|
123
|
+
export function readDecisions(sha) {
|
|
124
|
+
const raw = gitTry(['notes', '--ref', REVIEWS_REF, 'show', sha]);
|
|
125
|
+
if (!raw)
|
|
126
|
+
return [];
|
|
127
|
+
const lines = [...new Set(raw.split('\n'))].filter((l) => l.length <= MAX_DECISION_LINE && isDecisionLine(l));
|
|
128
|
+
if (!lines.length)
|
|
129
|
+
return [];
|
|
130
|
+
const ids = new Set(readReviews(sha).map((r) => r.id));
|
|
131
|
+
return lines.flatMap((line) => {
|
|
132
|
+
try {
|
|
133
|
+
const r = JSON.parse(line);
|
|
134
|
+
const str = (k) => typeof r[k] === 'string';
|
|
135
|
+
const ok = r?.decision === 1 && ids.has(r.review) && str('at') && str('by') &&
|
|
136
|
+
(r.complete === true
|
|
137
|
+
? r.finding === undefined && r.outcome === undefined
|
|
138
|
+
: str('finding') && r.finding.length > 0 && r.finding.length <= MAX_TEXT &&
|
|
139
|
+
OUTCOMES.includes(r.outcome) && (r.note === undefined || (str('note') && r.note.length <= MAX_TEXT))) &&
|
|
140
|
+
r.at.length <= 64 && r.by.length <= MAX_BY;
|
|
72
141
|
return ok ? [r] : [];
|
|
73
142
|
}
|
|
74
143
|
catch {
|
|
@@ -76,7 +145,31 @@ export function readReviews(sha) {
|
|
|
76
145
|
}
|
|
77
146
|
});
|
|
78
147
|
}
|
|
79
|
-
|
|
148
|
+
export const checkBy = (by) => {
|
|
149
|
+
if (!by)
|
|
150
|
+
throw new GlossError('--by needs a name (or set git config user.name)');
|
|
151
|
+
if (by.length > MAX_BY)
|
|
152
|
+
throw new GlossError(`--by is too long (${by.length} characters; at most ${MAX_BY})`);
|
|
153
|
+
};
|
|
154
|
+
export const checkDecision = (d) => {
|
|
155
|
+
if (!d.finding?.trim())
|
|
156
|
+
throw new GlossError('--decide needs --finding <text naming the finding>');
|
|
157
|
+
if (!OUTCOMES.includes(d.outcome))
|
|
158
|
+
throw new GlossError(`--outcome must be one of ${OUTCOMES.join(', ')}`);
|
|
159
|
+
for (const [k, v] of [['finding', d.finding], ['note', d.note]]) {
|
|
160
|
+
if (v && v.length > MAX_TEXT)
|
|
161
|
+
throw new GlossError(`--${k} is too long (${v.length} characters; at most ${MAX_TEXT})`);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
// "3 fixed, 1 rejected · complete", or "2 decided · not marked complete".
|
|
165
|
+
export function decisionSummary(ds, complete = ds.some((d) => d.complete)) {
|
|
166
|
+
const counts = OUTCOMES.map((o) => [o, ds.filter((d) => d.outcome === o).length]).filter(([, n]) => n);
|
|
167
|
+
const n = counts.reduce((a, [, c]) => a + c, 0);
|
|
168
|
+
if (!n)
|
|
169
|
+
return complete ? 'none · complete' : 'none yet';
|
|
170
|
+
return `${complete ? counts.map(([o, c]) => `${c} ${o}`).join(', ') : `${n} decided`} · ${complete ? 'complete' : 'not marked complete'}`;
|
|
171
|
+
}
|
|
172
|
+
// Adds records (reviews or decisions) to a commit's note, keeping each line once. Several
|
|
80
173
|
// reviews can finish at once, so the write is a compare-and-swap: the new
|
|
81
174
|
// notes commit is built under a private ref, and the reviews ref moves to it
|
|
82
175
|
// only if nobody else moved it meanwhile; otherwise it starts again.
|
|
@@ -109,7 +202,8 @@ export function addReviews(sha, records) {
|
|
|
109
202
|
// Reviews follow a gloss onto commits made from the reviewed ones (adopt,
|
|
110
203
|
// carry); their keys decide what they still cover there.
|
|
111
204
|
export function copyReviews(from, to) {
|
|
112
|
-
|
|
205
|
+
// Decisions come with the reviews they answer.
|
|
206
|
+
const records = [...from.flatMap(readReviews), ...from.flatMap(readDecisions)];
|
|
113
207
|
if (records.length)
|
|
114
208
|
addReviews(to, records);
|
|
115
209
|
}
|
|
@@ -199,7 +293,7 @@ export function statusPhrase(s) {
|
|
|
199
293
|
return `${s.stage}: ${never ? 'reviewed before it was glossed' : 'gloss changed since review'} (${who})`;
|
|
200
294
|
}
|
|
201
295
|
const v = verdict(s);
|
|
202
|
-
const words = WORDS[s.stage]
|
|
296
|
+
const words = Object.hasOwn(WORDS, s.stage) ? WORDS[s.stage] : WORDS.default;
|
|
203
297
|
return `${s.stage}: ${v === 'clean' ? words.clean : v === 'issues' ? words.issues : 'reviewed'} (${who}` +
|
|
204
298
|
`${earlierIssues(s) ? '; an earlier review found issues' : ''})`;
|
|
205
299
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import vm from 'node:vm';
|
|
2
|
+
// Patterns come from a repo's config, so one may backtrack without end. Each
|
|
3
|
+
// batch of lines runs in a vm with a time budget; a pattern that exceeds it is
|
|
4
|
+
// reported once on stderr, and from then on it counts as matching every line:
|
|
5
|
+
// failing open would let a change pad a line to switch a trusted pattern off. Lines are never cut: a
|
|
6
|
+
// change controls its own lines, and padding one must not hide a hit.
|
|
7
|
+
const BUDGET_MS = 250;
|
|
8
|
+
const script = new vm.Script('texts.map((t) => re.test(t))');
|
|
9
|
+
// One context for all calls: creating one per call dominates the cost.
|
|
10
|
+
const ctx = vm.createContext({ re: null, texts: [] });
|
|
11
|
+
const skipped = new Set();
|
|
12
|
+
// `timedOut` is true when the answer is the fail-closed one (this call or an
|
|
13
|
+
// earlier one in the process hit the budget), so hits can say so.
|
|
14
|
+
export function matchLines(re, texts) {
|
|
15
|
+
const key = `${re.source}/${re.flags}`;
|
|
16
|
+
if (skipped.has(key))
|
|
17
|
+
return { hit: texts.map(() => true), timedOut: true };
|
|
18
|
+
ctx.re = re;
|
|
19
|
+
ctx.texts = texts;
|
|
20
|
+
try {
|
|
21
|
+
return { hit: script.runInContext(ctx, { timeout: BUDGET_MS }), timedOut: false };
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
if (err.code !== 'ERR_SCRIPT_EXECUTION_TIMEOUT')
|
|
25
|
+
throw err;
|
|
26
|
+
skipped.add(key);
|
|
27
|
+
console.error(`warning: pattern ${JSON.stringify(re.source)} took over ${BUDGET_MS}ms and is treated as matching every line from now on (catastrophic backtracking, or a padded line?)`);
|
|
28
|
+
return { hit: texts.map(() => true), timedOut: true };
|
|
29
|
+
}
|
|
30
|
+
}
|
package/dist/schema.js
CHANGED
|
@@ -10,6 +10,7 @@ export const NECESSITY = [
|
|
|
10
10
|
'speculative',
|
|
11
11
|
'mechanical',
|
|
12
12
|
];
|
|
13
|
+
export const isNecessity = (s) => NECESSITY.includes(s);
|
|
13
14
|
export const NECESSITY_HELP = {
|
|
14
15
|
required: 'The task cannot be done without this change',
|
|
15
16
|
supporting: 'Needed only because other groups need it (name them in `serves`)',
|
|
@@ -18,6 +19,30 @@ export const NECESSITY_HELP = {
|
|
|
18
19
|
defensive: 'Guards edge cases or failures nobody asked about',
|
|
19
20
|
speculative: 'Added because it might be useful later',
|
|
20
21
|
};
|
|
22
|
+
// How far a group's effect reaches if it is wrong, lowest first.
|
|
23
|
+
export const IMPACT = ['local', 'module', 'shared', 'contract', 'sensitive'];
|
|
24
|
+
export const isImpact = (s) => IMPACT.includes(s);
|
|
25
|
+
export const IMPACT_HELP = {
|
|
26
|
+
local: 'Contained in one file or component; no outside callers (a private helper, template text, a test)',
|
|
27
|
+
module: 'One feature or project; its callers are inside it',
|
|
28
|
+
shared: 'Code used by several projects or modules, or root build, test or lint config',
|
|
29
|
+
contract: 'An interface others depend on: public APIs, routes, state shape, message formats, schemas, config keys, dependency versions, CI definitions',
|
|
30
|
+
sensitive: 'Correctness or safety critical whatever the size: auth, crypto, permissions, data writes or deletes, payments, hardware I/O, secrets',
|
|
31
|
+
};
|
|
32
|
+
// What a reviewer should do with the change, lowest first.
|
|
33
|
+
export const ATTENTION = ['skim', 'look', 'careful', 'must-read'];
|
|
34
|
+
export const ATTENTION_HELP = {
|
|
35
|
+
skim: 'Glance at the summary',
|
|
36
|
+
look: 'Read the focus groups',
|
|
37
|
+
careful: 'Read every non-mechanical group',
|
|
38
|
+
'must-read': 'Read line by line before approving',
|
|
39
|
+
};
|
|
40
|
+
// Notes come from anyone who can push to the remote, and the readers cost more
|
|
41
|
+
// than linear in the groups (the page lists, for each group, the groups it
|
|
42
|
+
// shares lines with) and spread lists into function calls. Real glosses have
|
|
43
|
+
// a few dozen groups; these are far above that.
|
|
44
|
+
export const MAX_GROUPS = 500;
|
|
45
|
+
export const MAX_ITEMS = 500;
|
|
21
46
|
export const Group = z.object({
|
|
22
47
|
id: z
|
|
23
48
|
.string()
|
|
@@ -36,22 +61,44 @@ export const Group = z.object({
|
|
|
36
61
|
.string()
|
|
37
62
|
.optional()
|
|
38
63
|
.describe('A test, requirement or task line that demonstrates the need'),
|
|
64
|
+
impact: z
|
|
65
|
+
.enum(IMPACT)
|
|
66
|
+
.optional()
|
|
67
|
+
.describe('How far the effect reaches if this group is wrong: ' + IMPACT.join(' < ') + '; the highest that applies'),
|
|
68
|
+
impact_reason: z
|
|
69
|
+
.string()
|
|
70
|
+
.max(120)
|
|
71
|
+
.optional()
|
|
72
|
+
.describe('One short sentence on why the impact applies (expected for contract and sensitive)'),
|
|
39
73
|
hunks: z
|
|
40
74
|
.array(z.string().regex(REF_PATTERN, 'use a hunk id like "h3", or changed lines of one like "h3:2-5" or "h3:1,4-6"'))
|
|
41
75
|
.min(1)
|
|
42
76
|
.describe('Hunks from `git gloss hunks` that belong to this group: "h3" for a whole hunk, "h3:2-5" for its changed lines 2 to 5'),
|
|
43
77
|
notes: z.string().optional(),
|
|
44
78
|
});
|
|
79
|
+
export const Attention = z.object({
|
|
80
|
+
level: z.enum(ATTENTION).describe('What the reviewer should do: ' + ATTENTION.map((l) => `${l} (${ATTENTION_HELP[l].toLowerCase()})`).join(', ')),
|
|
81
|
+
focus: z
|
|
82
|
+
.array(z.string())
|
|
83
|
+
.min(1)
|
|
84
|
+
.max(3)
|
|
85
|
+
.optional()
|
|
86
|
+
.describe('One to three group ids to look at first'),
|
|
87
|
+
reasons: z.array(z.string()).max(MAX_ITEMS).optional().describe('Short reasons for the level'),
|
|
88
|
+
override_reason: z.string().optional().describe('Why the level is lower than the change would suggest, if it is'),
|
|
89
|
+
});
|
|
45
90
|
export const Annotation = z.object({
|
|
46
91
|
version: z.literal(1).default(1),
|
|
47
92
|
task: z.string().min(1).describe('The task or request these changes were made for'),
|
|
48
93
|
summary: z.string().optional().describe('One or two sentences on the overall change'),
|
|
49
|
-
groups: z.array(Group).min(1),
|
|
94
|
+
groups: z.array(Group).min(1).max(MAX_GROUPS),
|
|
50
95
|
alternatives: z
|
|
51
96
|
.array(z.object({ option: z.string(), rejected_because: z.string() }))
|
|
97
|
+
.max(MAX_ITEMS)
|
|
52
98
|
.optional()
|
|
53
99
|
.describe('Approaches considered and why they were not taken'),
|
|
54
|
-
risks: z.array(z.string()).optional().describe('Known risks or open questions'),
|
|
100
|
+
risks: z.array(z.string()).max(MAX_ITEMS).optional().describe('Known risks or open questions'),
|
|
101
|
+
attention: Attention.optional().describe('How much attention a human reviewer should give this change'),
|
|
55
102
|
author: z
|
|
56
103
|
.object({
|
|
57
104
|
agent: z.string().optional(),
|
|
@@ -63,10 +110,14 @@ export const Annotation = z.object({
|
|
|
63
110
|
// A group's serves targets as a list, whichever form the gloss used.
|
|
64
111
|
export const servesOf = (g) => (Array.isArray(g.serves) ? g.serves : [g.serves]);
|
|
65
112
|
// Note formats: 1 whole-hunk references only; 2 line ranges ("h3:2-5");
|
|
66
|
-
// 3 serves lists. A note is written with the lowest format that can hold it,
|
|
113
|
+
// 3 serves lists; 4 impact, impact_reason or attention. A note is written with the lowest format that can hold it,
|
|
67
114
|
// so older readers only refuse notes that use something they can't read.
|
|
68
|
-
export const NOTE_FORMAT =
|
|
115
|
+
export const NOTE_FORMAT = 4;
|
|
69
116
|
export function noteFormat(a) {
|
|
117
|
+
// Readers of lower formats drop fields they don't know (they would lose
|
|
118
|
+
// these on a carry), so they must refuse the note instead.
|
|
119
|
+
if (a.attention || a.groups.some((g) => g.impact || g.impact_reason))
|
|
120
|
+
return 4;
|
|
70
121
|
if (a.groups.some((g) => servesOf(g).length > 1))
|
|
71
122
|
return 3;
|
|
72
123
|
if (a.groups.some((g) => g.hunks.some((ref) => ref.includes(':'))))
|
package/dist/selection.js
CHANGED
|
@@ -145,3 +145,24 @@ export function uncovered(hunks, claims) {
|
|
|
145
145
|
}
|
|
146
146
|
return out;
|
|
147
147
|
}
|
|
148
|
+
// Every group's page carries the lines it claims, and building it costs more
|
|
149
|
+
// than linear in them: measured, 200,000 claimed lines build in about half a
|
|
150
|
+
// second, 1,000,000 in 3 to 10 s, 5,000,000 in over a minute. Sharing a hunk
|
|
151
|
+
// between a few dozen groups is normal (one per dependency in a lockfile); the
|
|
152
|
+
// budget is far above that, and a gloss may always claim 5 times the diff.
|
|
153
|
+
export const MAX_CLAIMED_LINES = 500_000;
|
|
154
|
+
const MAX_CLAIM_FACTOR = 5;
|
|
155
|
+
// A message for a gloss whose groups claim too many lines, or null. Used when a
|
|
156
|
+
// gloss is written and when it is read, so `add` never writes one readers refuse.
|
|
157
|
+
export function claimsProblem(claims, hunks) {
|
|
158
|
+
let total = 0;
|
|
159
|
+
for (const h of hunks)
|
|
160
|
+
total += changedCount(h);
|
|
161
|
+
let claimed = 0;
|
|
162
|
+
for (const c of claims)
|
|
163
|
+
claimed += c.lines ? c.lines.size : changedCount(c.hunk);
|
|
164
|
+
const limit = Math.max(MAX_CLAIMED_LINES, MAX_CLAIM_FACTOR * total);
|
|
165
|
+
if (claimed <= limit)
|
|
166
|
+
return null;
|
|
167
|
+
return `the groups claim ${claimed} changed lines in all (the commit has ${total}; at most ${limit} are allowed): give each group only the lines it explains (h1:1-40) instead of the whole hunk, or use fewer groups on the same hunk`;
|
|
168
|
+
}
|