@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/batch.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync } from 'node:fs';
|
|
2
|
+
import { GlossError } from './git.js';
|
|
3
|
+
import { addReviews, checkDecision, MAX_DECISION_LINE, readDecisions, resolveBy } from './reviews.js';
|
|
4
|
+
import { findingTexts } from './findings.js';
|
|
5
|
+
import { plain } from './plain.js';
|
|
6
|
+
// Limits for `reviews --decide-file`, checked before anything is parsed or written.
|
|
7
|
+
export const MAX_BATCH_ENTRIES = 500;
|
|
8
|
+
export const MAX_BATCH_BYTES = 1024 * 1024;
|
|
9
|
+
export const TEMPLATE_TEXT = 80;
|
|
10
|
+
// What one --decide-file may write to a commit (bytes of its record lines): the 1 MiB input
|
|
11
|
+
// cap alone would still let a file add about 1 MiB to every commit of a range. Honest
|
|
12
|
+
// decisions are a few hundred bytes each; this is a guard against bloat, not against unreadable data.
|
|
13
|
+
export const MAX_BATCH_WRITE_BYTES = 512 * 1024;
|
|
14
|
+
// Reads a file, or stdin for "-", never more than MAX_BATCH_BYTES: a read that
|
|
15
|
+
// goes past it is refused without holding the rest (also for a pipe or /dev/zero).
|
|
16
|
+
export function readBounded(src) {
|
|
17
|
+
const what = src === '-' ? 'stdin' : plain(src);
|
|
18
|
+
const fail = (e) => new GlossError(`--decide-file: can't read ${what}: ${e.code ?? 'error'}`);
|
|
19
|
+
const buf = Buffer.allocUnsafe(MAX_BATCH_BYTES + 1);
|
|
20
|
+
let fd = 0, total = 0;
|
|
21
|
+
try {
|
|
22
|
+
if (src !== '-')
|
|
23
|
+
fd = openSync(src, 'r');
|
|
24
|
+
// Until end of input; a full buffer means the input is over the limit.
|
|
25
|
+
while (total < buf.length) {
|
|
26
|
+
let n;
|
|
27
|
+
try {
|
|
28
|
+
n = readSync(fd, buf, total, buf.length - total, null);
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
// A non-blocking pipe with nothing yet: wait a little (no busy spin) and ask again.
|
|
32
|
+
if (e.code !== 'EAGAIN')
|
|
33
|
+
throw e;
|
|
34
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (n === 0)
|
|
38
|
+
break;
|
|
39
|
+
total += n;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
throw fail(e);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
if (fd !== 0)
|
|
47
|
+
closeSync(fd);
|
|
48
|
+
}
|
|
49
|
+
if (total > MAX_BATCH_BYTES)
|
|
50
|
+
throw new GlossError(`--decide-file: the input is over ${MAX_BATCH_BYTES / 1024 / 1024} MiB; split the decisions into several files`);
|
|
51
|
+
return buf.toString('utf8', 0, total);
|
|
52
|
+
}
|
|
53
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
54
|
+
// Accepts a bare array of {finding, outcome, note?}, or {review?, complete?: boolean,
|
|
55
|
+
// decisions: [...]}. Unknown keys are ignored. Everything is checked here; nothing is written.
|
|
56
|
+
export function parseBatch(text, reviewId) {
|
|
57
|
+
let data;
|
|
58
|
+
try {
|
|
59
|
+
data = JSON.parse(text);
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
throw new GlossError(`--decide-file: not valid JSON (${e instanceof RangeError ? 'nested too deeply' : plain(e.message).slice(0, 120)})`);
|
|
63
|
+
}
|
|
64
|
+
let list;
|
|
65
|
+
let complete = false;
|
|
66
|
+
if (Array.isArray(data))
|
|
67
|
+
list = data;
|
|
68
|
+
else if (isObject(data)) {
|
|
69
|
+
const d = data.decisions;
|
|
70
|
+
const c = data.complete;
|
|
71
|
+
if (c !== undefined && typeof c !== 'boolean')
|
|
72
|
+
throw new GlossError('--decide-file: "complete" must be true or false');
|
|
73
|
+
complete = c === true;
|
|
74
|
+
const r = data.review;
|
|
75
|
+
if (r !== undefined && r !== '' && r !== reviewId)
|
|
76
|
+
throw new GlossError(`--decide-file: the file is for review ${plain(String(r)).slice(0, 80)}, not ${plain(reviewId)}`);
|
|
77
|
+
if (!Array.isArray(d))
|
|
78
|
+
throw new GlossError('--decide-file: the object needs a "decisions" array');
|
|
79
|
+
list = d;
|
|
80
|
+
}
|
|
81
|
+
else
|
|
82
|
+
throw new GlossError('--decide-file: expected an array of {finding, outcome, note?}, or an object with a "decisions" array');
|
|
83
|
+
if (list.length > MAX_BATCH_ENTRIES)
|
|
84
|
+
throw new GlossError(`--decide-file: ${list.length} entries; at most ${MAX_BATCH_ENTRIES} per file`);
|
|
85
|
+
if (!list.length && !complete)
|
|
86
|
+
throw new GlossError('--decide-file: no decisions in the file');
|
|
87
|
+
// addReviews keeps a repeated line once, so a repeated finding would be counted but not written.
|
|
88
|
+
const firstAt = new Map();
|
|
89
|
+
const entries = list.map((e, i) => {
|
|
90
|
+
const at = `--decide-file: entry ${i + 1}`;
|
|
91
|
+
if (!isObject(e))
|
|
92
|
+
throw new GlossError(`${at} is not an object`);
|
|
93
|
+
const finding = e.finding, outcome = e.outcome, note = e.note;
|
|
94
|
+
if (typeof finding !== 'string')
|
|
95
|
+
throw new GlossError(`${at} has no "finding" text`);
|
|
96
|
+
const label = `${at} ("${plain(finding).slice(0, 40)}")`;
|
|
97
|
+
if (outcome === undefined || outcome === '')
|
|
98
|
+
throw new GlossError(`${label} has an empty outcome; fill it in or delete the row`);
|
|
99
|
+
if (typeof outcome !== 'string')
|
|
100
|
+
throw new GlossError(`${label}: "outcome" must be a string`);
|
|
101
|
+
if (note !== undefined && typeof note !== 'string')
|
|
102
|
+
throw new GlossError(`${label}: "note" must be a string`);
|
|
103
|
+
try {
|
|
104
|
+
checkDecision({ finding, outcome, note });
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
if (err instanceof GlossError)
|
|
108
|
+
throw new GlossError(`${label}: ${err.message.replace(/^--/, '')}`);
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
const seen = firstAt.get(finding.trim());
|
|
112
|
+
if (seen !== undefined)
|
|
113
|
+
throw new GlossError(`${label} repeats entry ${seen}`);
|
|
114
|
+
firstAt.set(finding.trim(), i + 1);
|
|
115
|
+
return { finding: finding.trim(), outcome: outcome, ...(note ? { note } : {}) };
|
|
116
|
+
});
|
|
117
|
+
return { entries, complete };
|
|
118
|
+
}
|
|
119
|
+
// A record that, as written, is a line readers would skip (escapes make control characters longer).
|
|
120
|
+
export function checkLength(record, what) {
|
|
121
|
+
if (JSON.stringify(record).length > MAX_DECISION_LINE)
|
|
122
|
+
throw new GlossError(`${what} is too long to record: the finding and note, as written, exceed what readers accept; shorten them`);
|
|
123
|
+
}
|
|
124
|
+
// The skeleton `--decide-template` prints: one row per ref, outcome left empty.
|
|
125
|
+
export function template(reviewId, findings) {
|
|
126
|
+
const rows = findingTexts(findings);
|
|
127
|
+
if (rows.length > MAX_BATCH_ENTRIES)
|
|
128
|
+
throw new GlossError(`review ${plain(reviewId)} has ${rows.length} findings; --decide-file takes at most ${MAX_BATCH_ENTRIES}`);
|
|
129
|
+
return {
|
|
130
|
+
review: reviewId, complete: false,
|
|
131
|
+
decisions: rows.map(([ref, text]) => ({ finding: `${ref}${ref.includes('.') ? '' : ':'} ${[...plain(text).replace(/\s+/g, ' ').trim()].slice(0, TEMPLATE_TEXT).join('')}`.trim(), outcome: '', note: '' })),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// Everything `reviews --decide-file` needs before it writes: the checked entries, the
|
|
135
|
+
// holders of the review, and the records (with the complete marker last). Nothing is written.
|
|
136
|
+
export function prepareBatch(src, id, byArg, holdersOf) {
|
|
137
|
+
const batch = parseBatch(readBounded(src), id);
|
|
138
|
+
const holders = holdersOf(id);
|
|
139
|
+
const by = resolveBy(byArg);
|
|
140
|
+
const at = new Date().toISOString();
|
|
141
|
+
const records = batch.entries.map((e, i) => {
|
|
142
|
+
const r = { decision: 1, review: id, at, by, finding: e.finding, outcome: e.outcome, ...(e.note ? { note: e.note } : {}) };
|
|
143
|
+
checkLength(r, `--decide-file: entry ${i + 1}`);
|
|
144
|
+
return r;
|
|
145
|
+
});
|
|
146
|
+
if (batch.complete)
|
|
147
|
+
records.push({ decision: 1, review: id, at, by, complete: true });
|
|
148
|
+
const bytes = records.reduce((n, r) => n + Buffer.byteLength(JSON.stringify(r)) + 1, 0);
|
|
149
|
+
if (bytes > MAX_BATCH_WRITE_BYTES) {
|
|
150
|
+
throw new GlossError(`--decide-file: the ${records.length} records come to ${Math.ceil(bytes / 1024)} KiB as written; at most ${MAX_BATCH_WRITE_BYTES / 1024} KiB per file; shorten the findings and notes or split the file`);
|
|
151
|
+
}
|
|
152
|
+
return { batch, holders, records };
|
|
153
|
+
}
|
|
154
|
+
// The same decision, whenever it was made: what a re-run must not record twice.
|
|
155
|
+
const sameDecision = (r) => JSON.stringify([r.review, r.finding ?? null, r.outcome ?? null, r.note ?? null, r.by, r.complete ?? null]);
|
|
156
|
+
// The records not yet on that commit: one equal to the LATEST decision for its finding
|
|
157
|
+
// there is left out, but not one equal to an earlier decision that a later one reversed.
|
|
158
|
+
// A re-run of a file after a partly failed write adds only what is missing (each run has
|
|
159
|
+
// its own `at`).
|
|
160
|
+
export function newRecords(sha, records) {
|
|
161
|
+
const latest = new Map();
|
|
162
|
+
for (const r of [...readDecisions(sha)].sort((a, b) => a.at.localeCompare(b.at)))
|
|
163
|
+
latest.set(JSON.stringify([r.review, r.finding ?? null]), sameDecision(r));
|
|
164
|
+
return records.filter((r) => latest.get(JSON.stringify([r.review, r.finding ?? null])) !== sameDecision(r));
|
|
165
|
+
}
|
|
166
|
+
// Write the records to each holder, one write per commit, leaving out what it has. Counts
|
|
167
|
+
// as it goes: decisions written (the most any one commit got, so a range counts a file once),
|
|
168
|
+
// whether the complete marker was written, commits written, records skipped.
|
|
169
|
+
export function applyBatch(holders, records) {
|
|
170
|
+
let decisions = 0, written = 0, skipped = 0, marked = false;
|
|
171
|
+
for (const sha of holders) {
|
|
172
|
+
const todo = newRecords(sha, records);
|
|
173
|
+
skipped += records.length - todo.length;
|
|
174
|
+
if (!todo.length)
|
|
175
|
+
continue;
|
|
176
|
+
addReviews(sha, todo);
|
|
177
|
+
written++;
|
|
178
|
+
const fresh = todo.filter((r) => !r.complete).length;
|
|
179
|
+
decisions = Math.max(decisions, fresh);
|
|
180
|
+
if (todo.some((r) => r.complete))
|
|
181
|
+
marked = true;
|
|
182
|
+
}
|
|
183
|
+
return { decisions, marked, written, skipped };
|
|
184
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import * as html from './commands/html.js';
|
|
|
7
7
|
import * as hunks from './commands/hunks.js';
|
|
8
8
|
import * as init from './commands/init.js';
|
|
9
9
|
import * as review from './commands/review.js';
|
|
10
|
+
import * as lint from './commands/lint.js';
|
|
10
11
|
import * as reviews from './commands/reviews.js';
|
|
11
12
|
import * as schema from './commands/schema.js';
|
|
12
13
|
import * as show from './commands/show.js';
|
|
@@ -15,7 +16,7 @@ import * as sync from './commands/sync.js';
|
|
|
15
16
|
import * as versions from './commands/versions.js';
|
|
16
17
|
import * as why from './commands/why.js';
|
|
17
18
|
const COMMANDS = {
|
|
18
|
-
init, hunks, signals, add, adopt, carry, show, html, ary, why, review, reviews, sync, versions, schema,
|
|
19
|
+
init, hunks, signals, lint, add, adopt, carry, show, html, ary, why, review, reviews, sync, versions, schema,
|
|
19
20
|
};
|
|
20
21
|
function help() {
|
|
21
22
|
return 'usage: git gloss <command> [options]\n\n' +
|
package/dist/commands/add.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, readSync } from 'node:fs';
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { commitDiff, isWipSnapshot, NOTES_REF, readNote, resolveCommit, WIP_NOTES_REF, writeNote } from '../git.js';
|
|
@@ -7,6 +7,32 @@ import { Annotation, noteFormat } from '../schema.js';
|
|
|
7
7
|
import { validate } from '../validate.js';
|
|
8
8
|
import { loadConfig } from '../config.js';
|
|
9
9
|
import { analyzeGroups, groupInputsFor, ratingProblems } from '../floor.js';
|
|
10
|
+
// readFileSync(0) throws EAGAIN when the pipe is non-blocking and momentarily empty
|
|
11
|
+
// (a big gloss from a slower writer): wait a moment and read on.
|
|
12
|
+
function readStdin() {
|
|
13
|
+
const chunks = [];
|
|
14
|
+
const buf = Buffer.alloc(1 << 20);
|
|
15
|
+
for (;;) {
|
|
16
|
+
let n;
|
|
17
|
+
try {
|
|
18
|
+
n = readSync(0, buf);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
const code = err.code;
|
|
22
|
+
if (code === 'EAGAIN') {
|
|
23
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (code === 'EOF')
|
|
27
|
+
break;
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
if (n === 0)
|
|
31
|
+
break;
|
|
32
|
+
chunks.push(Buffer.from(buf.subarray(0, n)));
|
|
33
|
+
}
|
|
34
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
35
|
+
}
|
|
10
36
|
export const usage = 'add [<commit>] [-f <file>] [--force] [--partial] [--allow-underrate] [--dry-run] attach an annotation (JSON from file or stdin)';
|
|
11
37
|
export function run(argv) {
|
|
12
38
|
const { values, positionals } = parseArgs({
|
|
@@ -25,7 +51,7 @@ export function run(argv) {
|
|
|
25
51
|
console.error('error: pipe annotation JSON on stdin or pass -f <file> (see `git gloss schema`)');
|
|
26
52
|
return 2;
|
|
27
53
|
}
|
|
28
|
-
const raw = readFileSync(values.file
|
|
54
|
+
const raw = values.file ? readFileSync(values.file, 'utf8') : readStdin();
|
|
29
55
|
let json;
|
|
30
56
|
try {
|
|
31
57
|
json = JSON.parse(raw);
|
package/dist/commands/ary.js
CHANGED
|
@@ -31,5 +31,8 @@ export function run(argv) {
|
|
|
31
31
|
if (!values['no-open'])
|
|
32
32
|
openInBrowser(url);
|
|
33
33
|
});
|
|
34
|
+
// The default action of a signal would skip the cleanup that kills running page builds.
|
|
35
|
+
for (const [sig, code] of [['SIGINT', 130], ['SIGTERM', 143], ['SIGHUP', 129], ['SIGQUIT', 131]])
|
|
36
|
+
process.on(sig, () => { server.close(); process.exit(code); });
|
|
34
37
|
return 0;
|
|
35
38
|
}
|
package/dist/commands/carry.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
-
import { commitDiff, git, GlossError, readNote, resolveCommit, resolveCommits, writeNote } from '../git.js';
|
|
3
|
+
import { commitDiff, git, GlossError, readNote, rethrowTimeout, resolveCommit, resolveCommits, writeNote } from '../git.js';
|
|
4
4
|
import { matchHunks } from '../loader.js';
|
|
5
5
|
import { findOrigins } from '../origins.js';
|
|
6
6
|
import { location, parseDiff } from '../diff.js';
|
|
@@ -20,6 +20,7 @@ export function targetState(sha) {
|
|
|
20
20
|
return note ? (note.sources || note.carried_from ? 'carried' : 'glossed') : 'none';
|
|
21
21
|
}
|
|
22
22
|
catch (err) {
|
|
23
|
+
rethrowTimeout(err);
|
|
23
24
|
if (err instanceof GlossError)
|
|
24
25
|
return 'broken';
|
|
25
26
|
throw err;
|
|
@@ -41,6 +42,7 @@ export function carry(sourceShas, target) {
|
|
|
41
42
|
note = readNote(sha);
|
|
42
43
|
}
|
|
43
44
|
catch (err) {
|
|
45
|
+
rethrowTimeout(err);
|
|
44
46
|
if (!(err instanceof GlossError))
|
|
45
47
|
throw err;
|
|
46
48
|
unreadable.push(`${sha.slice(0, 7)} (${err.message.replace(/^the gloss on \S+ /, '')})`);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { GlossError, resolveCommits } from '../git.js';
|
|
3
|
+
import { loadGloss } from '../loader.js';
|
|
4
|
+
import { CONFIG_EDIT_NOTE } from '../signals.js';
|
|
5
|
+
import { ratingIssues, tryAnalyze, whyText } from '../floor.js';
|
|
6
|
+
import { attentionRank, impactRank } from '../attention.js';
|
|
7
|
+
import { servesOf } from '../schema.js';
|
|
8
|
+
import { unknownServes } from '../validate.js';
|
|
9
|
+
import { plain } from '../plain.js';
|
|
10
|
+
export const usage = 'lint [<commit> | wip] [--json] advisory checks of a gloss that a reviewer would make by eye (always exits 0 for findings)';
|
|
11
|
+
// What removal_impact says when it admits the group is not needed. Matched on the
|
|
12
|
+
// lowercased text, after plain(): BARE phrases only when they are the whole text
|
|
13
|
+
// ("Nothing." but not "Nothing else would work"); the others may start it.
|
|
14
|
+
const NOTHING_BREAKS_WHOLE = ['nothing', 'none', 'n/a'];
|
|
15
|
+
const NOTHING_BREAKS_START = ['nothing breaks', 'nothing would break', 'nothing is lost', 'nothing happens', 'no effect', 'no impact'];
|
|
16
|
+
export function saysNothingBreaks(text) {
|
|
17
|
+
const t = plain(text).toLowerCase().trim().replace(/[\s.!;:,]+$/, '');
|
|
18
|
+
if (NOTHING_BREAKS_WHOLE.includes(t))
|
|
19
|
+
return true;
|
|
20
|
+
return NOTHING_BREAKS_START.some((p) => t === p || (t.startsWith(p) && /^[\s.,;:!-]/.test(t.slice(p.length))));
|
|
21
|
+
}
|
|
22
|
+
export function run(argv) {
|
|
23
|
+
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { json: { type: 'boolean' } } });
|
|
24
|
+
const arg = positionals[0] ?? 'HEAD';
|
|
25
|
+
const { shas, range } = resolveCommits(arg);
|
|
26
|
+
if (range)
|
|
27
|
+
throw new GlossError(`${arg}: lint takes one commit or wip, not a range`);
|
|
28
|
+
const sha = shas[0];
|
|
29
|
+
const gloss = loadGloss(sha);
|
|
30
|
+
if (!gloss)
|
|
31
|
+
throw new GlossError(`${sha.slice(0, 10)} has no gloss (annotate it with \`git gloss add\`)`);
|
|
32
|
+
for (const w of gloss.warnings)
|
|
33
|
+
console.error(`warning: ${plain(w)}`);
|
|
34
|
+
const a = gloss.note.annotation;
|
|
35
|
+
const findings = [];
|
|
36
|
+
// Text from a note is sanitized here, once, for both outputs.
|
|
37
|
+
const add = (code, message, at = {}) => findings.push({ code, ...(at.group !== undefined && { group: plain(at.group) }), ...(at.hunk !== undefined && { hunk: plain(at.hunk) }), message: plain(message) });
|
|
38
|
+
const ids = new Set(a.groups.map((g) => g.id));
|
|
39
|
+
const an = tryAnalyze(gloss);
|
|
40
|
+
if (an) {
|
|
41
|
+
const issues = ratingIssues(a, an);
|
|
42
|
+
if (issues.configEdit)
|
|
43
|
+
add('config-edit', CONFIG_EDIT_NOTE);
|
|
44
|
+
for (const u of issues.unrated)
|
|
45
|
+
add('no-impact', `no impact rated${u.signal ? ` (floor ${u.floor}: ${whyText(u.why)})` : ''}`, { group: u.id });
|
|
46
|
+
for (const b of issues.belowFloor)
|
|
47
|
+
add('impact-below-floor', `impact "${b.impact}" is below its floor "${b.floor}" (${whyText(b.why)})`, { group: b.id });
|
|
48
|
+
if (issues.attention)
|
|
49
|
+
add('attention-below-floor', `attention "${issues.attention.level}" is below the computed floor "${issues.attention.floor}" (${issues.attention.why}) and has no override_reason`);
|
|
50
|
+
const sigOf = new Map(an.signals.hunks.map((s) => [s.id, s]));
|
|
51
|
+
for (const g of gloss.groups) {
|
|
52
|
+
const f = an.groups.get(g.group.id);
|
|
53
|
+
// A mechanical group made only of lockfiles, docs, tests or generated files sits on a path-class floor honestly (the skill's lockfile example).
|
|
54
|
+
const real = g.hunks.some((h) => h.current && !sigOf.get(h.current.id)?.inert);
|
|
55
|
+
if (g.group.necessity === 'mechanical' && f && impactRank(f.floor) > impactRank('module') && real) {
|
|
56
|
+
add('mechanical-on-floor', `graded mechanical but its floor is "${f.floor}" (${whyText(f.why)})`, { group: g.group.id });
|
|
57
|
+
}
|
|
58
|
+
if (f?.testsOnly && !servesOf(g.group).some((t) => t !== 'task' && ids.has(t) && t !== g.group.id)) {
|
|
59
|
+
add('test-serves-nothing', 'a test group that serves no existing group (set serves to the groups it verifies)', { group: g.group.id });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const g of a.groups) {
|
|
64
|
+
const bad = unknownServes(g, ids);
|
|
65
|
+
if (bad.length)
|
|
66
|
+
add('unknown-serves', `serves names no such group: ${bad.map((t) => `"${t}"`).join(', ')}`, { group: g.id });
|
|
67
|
+
if (g.necessity === 'required' && saysNothingBreaks(g.removal_impact)) {
|
|
68
|
+
add('required-but-nothing-breaks', `graded required but removal_impact says: "${g.removal_impact}"`, { group: g.id });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const level = a.attention?.level ?? an?.derived.level;
|
|
72
|
+
if (level && attentionRank(level) >= attentionRank('careful') && !a.risks?.length) {
|
|
73
|
+
add('missing-risks', `attention is ${level} but no risks are listed`);
|
|
74
|
+
}
|
|
75
|
+
for (const u of gloss.unannotated) {
|
|
76
|
+
add('uncovered-hunks', `${u.hunk.file}: ${u.lines ? `changed lines ${[...u.lines].join(',')}` : 'the whole hunk'} not covered by any group`, { hunk: u.hunk.id });
|
|
77
|
+
}
|
|
78
|
+
if (values.json) {
|
|
79
|
+
console.log(JSON.stringify({ target: sha.slice(0, 10), findings, ...(an ? {} : { incomplete: true }) }, null, 2));
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
const subjects = new Map();
|
|
83
|
+
for (const f of findings) {
|
|
84
|
+
const key = f.group !== undefined ? `group ${f.group}` : f.hunk !== undefined ? `hunk ${f.hunk}` : 'whole gloss';
|
|
85
|
+
subjects.set(key, [...(subjects.get(key) ?? []), f]);
|
|
86
|
+
}
|
|
87
|
+
for (const [key, fs] of subjects) {
|
|
88
|
+
console.log(key);
|
|
89
|
+
for (const f of fs)
|
|
90
|
+
console.log(` ${f.code.padEnd(28)} ${f.message}`);
|
|
91
|
+
}
|
|
92
|
+
if (findings.length)
|
|
93
|
+
console.log(`${findings.length} finding${findings.length === 1 ? '' : 's'}`);
|
|
94
|
+
if (!an)
|
|
95
|
+
console.log('incomplete: the rating checks need the diff signals, which are unavailable (see the warning above)');
|
|
96
|
+
else if (!findings.length)
|
|
97
|
+
console.log('clean');
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
package/dist/commands/review.js
CHANGED
|
@@ -3,7 +3,7 @@ import { plain } from '../plain.js';
|
|
|
3
3
|
import { commitDiff, commitSubject, isWipSnapshot, resolveCommits } from '../git.js';
|
|
4
4
|
import { parseDiff } from '../diff.js';
|
|
5
5
|
import { refLabel, sliceText } from '../selection.js';
|
|
6
|
-
import { loadGloss } from '../loader.js';
|
|
6
|
+
import { loadGloss, loadGlossInRange, refusedWarning } from '../loader.js';
|
|
7
7
|
import { NECESSITY_HELP, servesOf } from '../schema.js';
|
|
8
8
|
export const usage = 'review [<commit> | <range>] [--material] print a review prompt for a commit or a branch (pipe it to an agent, e.g. `| claude -p`); --material prints only the gloss and code, for other review prompts';
|
|
9
9
|
// A fence longer than any backtick run in the content, so a markdown file in
|
|
@@ -20,11 +20,12 @@ const hunkLine = (h) => `- ${h.id} ${plain(h.oldFile && h.oldFile !== h.file ? `
|
|
|
20
20
|
`(-${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines}, +${h.added} -${h.removed})`;
|
|
21
21
|
// The material for one commit: its gloss and the code under each group, or,
|
|
22
22
|
// for an unglossed commit in a range, its whole diff.
|
|
23
|
-
function commitMaterial(sha, gloss) {
|
|
23
|
+
function commitMaterial(sha, gloss, refused) {
|
|
24
24
|
if (!gloss) {
|
|
25
25
|
return [
|
|
26
26
|
`## Commit ${sha.slice(0, 10)}: ${plain(commitSubject(sha))}`,
|
|
27
|
-
'This commit has no gloss. Review its changes as unexplained.',
|
|
27
|
+
refused ? "This commit's gloss was refused (reason below), so it is not shown. Review its changes as unexplained." : 'This commit has no gloss. Review its changes as unexplained.',
|
|
28
|
+
...(refused ? [`Reason: ${q(plain(refused).slice(0, 300))}`] : []),
|
|
28
29
|
fenced('diff', parseDiff(commitDiff(sha)).map((h) => `# ${plain(h.file)}\n${h.text}`).join('\n')),
|
|
29
30
|
];
|
|
30
31
|
}
|
|
@@ -69,7 +70,11 @@ function commitMaterial(sha, gloss) {
|
|
|
69
70
|
export function run(argv) {
|
|
70
71
|
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { material: { type: 'boolean' } } });
|
|
71
72
|
const { shas, merges, range } = resolveCommits(positionals[0] ?? 'HEAD');
|
|
72
|
-
|
|
73
|
+
// In a range a refused gloss is skipped with a warning; a single commit fails with the error.
|
|
74
|
+
const loaded = shas.map((sha) => (range ? loadGlossInRange(sha) : { gloss: loadGloss(sha), refused: undefined }));
|
|
75
|
+
loaded.forEach((l, i) => { if (l.refused)
|
|
76
|
+
console.error(refusedWarning(shas[i], l.refused)); });
|
|
77
|
+
const glosses = loaded.map((l) => l.gloss);
|
|
73
78
|
if (!range && !glosses[0]) {
|
|
74
79
|
console.error(`${shas[0].slice(0, 10)} has no gloss to review`);
|
|
75
80
|
return 1;
|
|
@@ -82,7 +87,7 @@ export function run(argv) {
|
|
|
82
87
|
? `\n\nMerge commits in the range are not included below. A merge can still carry conflict resolutions or edits of its own; check them with \`git show <sha>\`:\n\n${list(merges)}`
|
|
83
88
|
: ''));
|
|
84
89
|
}
|
|
85
|
-
shas.forEach((sha, i) => material.push(...commitMaterial(sha, glosses[i])));
|
|
90
|
+
shas.forEach((sha, i) => material.push(...commitMaterial(sha, glosses[i], loaded[i].refused)));
|
|
86
91
|
// Just the material, for a reviewer that brings its own instructions. Its
|
|
87
92
|
// author text is quoted but not framed as untrusted; the caller must say so.
|
|
88
93
|
if (values.material) {
|
package/dist/commands/reviews.js
CHANGED
|
@@ -2,19 +2,34 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
4
|
import { commitSubject, gitTry, GlossError, resolveCommits } from '../git.js';
|
|
5
|
-
import { addReviews,
|
|
5
|
+
import { addReviews, checkDecision, resolveBy, isValidDecision, MAX_FINDINGS, MAX_REVIEW_LINE, decisionSummary, readDecisions, readReviews, reviewStatus, stamp, statusPhrase, verdictOf } from '../reviews.js';
|
|
6
|
+
import { findingClashes, findingRefs, labelFindings, orderDecisions, refOf } from '../findings.js';
|
|
6
7
|
import { plain } from '../plain.js';
|
|
7
8
|
import { page, style } from '../render.js';
|
|
8
|
-
|
|
9
|
+
import { applyBatch, checkLength, prepareBatch, template } from '../batch.js';
|
|
10
|
+
export const usage = 'reviews [<commit> | <range> | wip] [--stamp | --record] the reviews recorded for commits, with their findings and what was decided about them\n reviews <target> --decide <review-id> --finding <text> --outcome <fixed|follow-up|rejected|accepted> [--note <text>] [--by <name>]\n reviews <target> --decide <review-id> --complete the arbiter is done\n reviews <target> --decide-template <review-id> print a JSON skeleton of the findings of a review to fill in\n reviews <target> --decide <review-id> --decide-file <file|-> record every decision of the filled-in skeleton at once, or none; --stamp, --record and --decide are for tools';
|
|
9
11
|
export function run(argv) {
|
|
10
12
|
const { values, positionals } = parseArgs({
|
|
11
13
|
args: argv,
|
|
12
14
|
allowPositionals: true,
|
|
13
15
|
options: {
|
|
14
16
|
stamp: { type: 'boolean' }, record: { type: 'boolean' }, decide: { type: 'string' }, finding: { type: 'string' },
|
|
15
|
-
outcome: { type: 'string' }, note: { type: 'string' }, complete: { type: 'boolean' }, by: { type: 'string' },
|
|
17
|
+
outcome: { type: 'string' }, 'decide-file': { type: 'string' }, 'decide-template': { type: 'string' }, note: { type: 'string' }, complete: { type: 'boolean' }, by: { type: 'string' },
|
|
16
18
|
},
|
|
17
19
|
});
|
|
20
|
+
const batchFlag = values['decide-file'] !== undefined ? '--decide-file' : values['decide-template'] !== undefined ? '--decide-template' : null;
|
|
21
|
+
if (batchFlag) {
|
|
22
|
+
for (const k of ['finding', 'outcome', 'note', 'complete', 'stamp', 'record']) {
|
|
23
|
+
if (values[k] !== undefined)
|
|
24
|
+
throw new GlossError(`${batchFlag} cannot be combined with --${k}`);
|
|
25
|
+
}
|
|
26
|
+
if (values['decide-file'] !== undefined && values['decide-template'] !== undefined)
|
|
27
|
+
throw new GlossError('--decide-file and --decide-template cannot be combined');
|
|
28
|
+
if (batchFlag === '--decide-template' && (values.decide !== undefined || values.by !== undefined))
|
|
29
|
+
throw new GlossError('--decide-template takes the review id itself and no --decide or --by');
|
|
30
|
+
if (batchFlag === '--decide-file' && values.decide === undefined)
|
|
31
|
+
throw new GlossError('--decide-file goes with --decide <review-id>');
|
|
32
|
+
}
|
|
18
33
|
if (values.record) {
|
|
19
34
|
const input = JSON.parse(readFileSync(0, 'utf8'));
|
|
20
35
|
for (const k of ['stage', 'agent', 'target', 'findings']) {
|
|
@@ -53,23 +68,55 @@ export function run(argv) {
|
|
|
53
68
|
return 0;
|
|
54
69
|
}
|
|
55
70
|
const { shas } = resolveCommits(positionals[0] ?? 'HEAD');
|
|
71
|
+
const target = positionals[0] ?? 'HEAD';
|
|
72
|
+
// Every commit of the target holding that review gets the decision.
|
|
73
|
+
const holdersOf = (id) => {
|
|
74
|
+
const holders = shas.filter((sha) => readReviews(sha).some((r) => r.id === id));
|
|
75
|
+
if (!holders.length) {
|
|
76
|
+
const known = [...new Set(shas.flatMap(readReviews).map((r) => r.id))];
|
|
77
|
+
throw new GlossError(`no review ${id} on ${target}` + (known.length ? ` (reviews there: ${plain(known.join(', '))})` : ' (it has no reviews)'));
|
|
78
|
+
}
|
|
79
|
+
return holders;
|
|
80
|
+
};
|
|
81
|
+
const reportOf = (id, holders) => holders.flatMap(readReviews).find((r) => r.id === id);
|
|
82
|
+
// A finding that starts with a ref names a finding of the report: say so when there is none (never refuse).
|
|
83
|
+
// The refs of a report are computed once, not once per entry.
|
|
84
|
+
const refsOfReport = new Map();
|
|
85
|
+
const warnUnknownRef = (id, report, finding, hint = '') => {
|
|
86
|
+
const ref = refOf(finding);
|
|
87
|
+
if (ref !== null && report && !(refsOfReport.get(report.findings) ?? refsOfReport.set(report.findings, findingRefs(report.findings)).get(report.findings)).has(ref))
|
|
88
|
+
console.error(`git-gloss: review ${plain(id)} has no finding ${ref} (${hint}see the refs in \`git gloss reviews ${target}\`); recording it anyway`);
|
|
89
|
+
};
|
|
90
|
+
if (values['decide-template'] !== undefined) {
|
|
91
|
+
const id = values['decide-template'];
|
|
92
|
+
const report = reportOf(id, holdersOf(id));
|
|
93
|
+
const clash = findingClashes(report.findings);
|
|
94
|
+
if (clash.length)
|
|
95
|
+
console.error(`git-gloss: review ${plain(id)}: these refs are ambiguous, so the template cannot list every finding under its own: ${clash.join(', ')}; the findings are in \`git gloss reviews ${target}\``);
|
|
96
|
+
console.log(JSON.stringify(template(id, report.findings), null, 2));
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
if (values['decide-file'] !== undefined) {
|
|
100
|
+
const id = values.decide;
|
|
101
|
+
// Everything is checked before the first write: one bad entry records nothing.
|
|
102
|
+
const { batch, holders, records } = prepareBatch(values['decide-file'], id, values.by, holdersOf);
|
|
103
|
+
const report = reportOf(id, holders);
|
|
104
|
+
batch.entries.forEach((e, i) => warnUnknownRef(id, report, e.finding, `entry ${i + 1}; `));
|
|
105
|
+
const { decisions: n, marked, written, skipped } = applyBatch(holders, records);
|
|
106
|
+
console.log(`recorded ${n} decision${n === 1 ? '' : 's'} on ${written} commit${written === 1 ? '' : 's'}${marked ? ' and marked the review complete' : ''}${skipped ? ` (${skipped} already recorded)` : ''}`);
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
56
109
|
if (values.decide !== undefined) {
|
|
57
110
|
const id = values.decide;
|
|
58
111
|
if (values.complete) {
|
|
59
|
-
if (values.finding
|
|
112
|
+
if (!isValidDecision({ complete: true, finding: values.finding, outcome: values.outcome, note: values.note })) {
|
|
60
113
|
throw new GlossError('--complete marks the review as arbitrated; it takes no --finding, --outcome or --note');
|
|
61
114
|
}
|
|
62
115
|
}
|
|
63
116
|
else
|
|
64
117
|
checkDecision(values);
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
if (!holders.length) {
|
|
68
|
-
const known = [...new Set(shas.flatMap(readReviews).map((r) => r.id))];
|
|
69
|
-
throw new GlossError(`no review ${id} on ${positionals[0] ?? 'HEAD'}` + (known.length ? ` (reviews there: ${plain(known.join(', '))})` : ' (it has no reviews)'));
|
|
70
|
-
}
|
|
71
|
-
const by = values.by !== undefined ? values.by.trim() : gitTry(['config', 'user.name'])?.trim() || 'unknown';
|
|
72
|
-
checkBy(by);
|
|
118
|
+
const holders = holdersOf(id);
|
|
119
|
+
const by = resolveBy(values.by);
|
|
73
120
|
const record = {
|
|
74
121
|
decision: 1, review: id, at: new Date().toISOString(), by,
|
|
75
122
|
...(values.complete ? { complete: true } : {
|
|
@@ -78,8 +125,9 @@ export function run(argv) {
|
|
|
78
125
|
}),
|
|
79
126
|
};
|
|
80
127
|
// Control characters are written as 6-byte escapes, so check the line itself against the reader's cap.
|
|
81
|
-
|
|
82
|
-
|
|
128
|
+
checkLength(record, 'that decision');
|
|
129
|
+
if (record.finding !== undefined)
|
|
130
|
+
warnUnknownRef(id, reportOf(id, holders), record.finding);
|
|
83
131
|
for (const sha of holders)
|
|
84
132
|
addReviews(sha, [record]);
|
|
85
133
|
console.log(`recorded ${values.complete ? 'the review as complete' : `"${record.finding}": ${record.outcome}`} on ${holders.length} commit${holders.length === 1 ? '' : 's'}`);
|
|
@@ -106,11 +154,11 @@ export function run(argv) {
|
|
|
106
154
|
out.push(` ${statusPhrase(s)}`);
|
|
107
155
|
for (const r of s.reviews) {
|
|
108
156
|
out.push('', style('dim', ` ── ${r.stage} by ${r.agent}${r.model ? `:${r.model}` : ''}${r.mode ? ` (${r.mode})` : ''}, ${r.at.slice(0, 16).replace('T', ' ')}, of ${r.target}, id ${plain(r.id)}`));
|
|
109
|
-
out.push(...r.findings.trim().split('\n').map((l) => ` ${l}`));
|
|
157
|
+
out.push(...labelFindings(r.findings).trim().split('\n').map((l) => ` ${l}`));
|
|
110
158
|
const ds = decisions.filter((d) => d.review === r.id);
|
|
111
159
|
if (r.verdict !== 'clean' || ds.length) {
|
|
112
160
|
out.push('', ` decisions on this review: ${decisionSummary(ds)}`);
|
|
113
|
-
for (const d of ds.filter((d) => d.outcome)
|
|
161
|
+
for (const d of orderDecisions(ds.filter((d) => d.outcome))) {
|
|
114
162
|
out.push(` ${d.outcome}: ${plain(d.finding)}${d.note ? ` — ${plain(d.note)}` : ''} ${style('dim', `(${plain(d.by)}, ${plain(d.at).slice(0, 10)})`)}`);
|
|
115
163
|
}
|
|
116
164
|
}
|
package/dist/commands/show.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
|
-
import { commitSubject, gitTry, readNote, resolveCommits } from '../git.js';
|
|
2
|
+
import { commitSubject, gitTry, readNote, resolveCommits, rethrowTimeout } from '../git.js';
|
|
3
3
|
import { refLabel, sliceStats, sliceText } from '../selection.js';
|
|
4
|
-
import { loadGloss } from '../loader.js';
|
|
4
|
+
import { loadGloss, loadGlossInRange, refusedWarning } from '../loader.js';
|
|
5
5
|
import { impactRank } from '../attention.js';
|
|
6
6
|
import { showsImpact, tryAnalyze } from '../floor.js';
|
|
7
7
|
import { IMPACT, NECESSITY, isImpact, isNecessity } from '../schema.js';
|
|
@@ -61,7 +61,8 @@ export function run(argv) {
|
|
|
61
61
|
const groups = readNote(c)?.annotation.groups.map((g) => g.id) ?? [];
|
|
62
62
|
return missing.every((id) => groups.includes(id));
|
|
63
63
|
}
|
|
64
|
-
catch {
|
|
64
|
+
catch (err) {
|
|
65
|
+
rethrowTimeout(err);
|
|
65
66
|
return false;
|
|
66
67
|
}
|
|
67
68
|
});
|
|
@@ -84,7 +85,11 @@ export function run(argv) {
|
|
|
84
85
|
console.error('error: -g needs a single commit; pick one from the range, e.g. git gloss show <commit> -g <group>');
|
|
85
86
|
return 2;
|
|
86
87
|
}
|
|
87
|
-
const
|
|
88
|
+
const loaded = shas.map((sha) => loadGlossInRange(sha));
|
|
89
|
+
const glosses = loaded.map((l) => l.gloss);
|
|
90
|
+
const refused = loaded.filter((l) => l.refused).length;
|
|
91
|
+
loaded.forEach((l, i) => { if (l.refused)
|
|
92
|
+
console.error(refusedWarning(shas[i], l.refused)); });
|
|
88
93
|
const glossed = glosses.filter(Boolean).length;
|
|
89
94
|
const stale = glosses.filter((g) => g?.stale).length;
|
|
90
95
|
// How many glossed commits had their gloss checked: an honesty review that
|
|
@@ -97,13 +102,14 @@ export function run(argv) {
|
|
|
97
102
|
(glossed ? style(vetted === glossed ? 'green' : 'yellow', `, ${vetted} honesty-reviewed`) : '') +
|
|
98
103
|
(flagged ? style('yellow', ` (${flagged} with issues)`) : '') +
|
|
99
104
|
(stale ? style('red', ` (${stale} stale)`) : '') +
|
|
105
|
+
(refused ? style('red', ` (${refused} refused)`) : '') +
|
|
100
106
|
(merges.length ? style('dim', ` (${merges.length} merge${merges.length === 1 ? '' : 's'} not shown)`) : ''),
|
|
101
107
|
];
|
|
102
108
|
shas.forEach((sha, i) => {
|
|
103
109
|
out.push('', style('dim', '─'.repeat(Math.min(termWidth(), 60))));
|
|
104
110
|
const gloss = glosses[i];
|
|
105
111
|
if (!gloss) {
|
|
106
|
-
out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(commitSubject(sha)))}`, style('dim', 'no gloss for this commit'));
|
|
112
|
+
out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(commitSubject(sha)))}`, style('dim', loaded[i].refused ? 'gloss refused (see the warning)' : 'no gloss for this commit'));
|
|
107
113
|
return;
|
|
108
114
|
}
|
|
109
115
|
// An --only filter can leave a commit with nothing to show; say so and move on.
|