@ok-ggwp/git-gloss 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/batch.js ADDED
@@ -0,0 +1,129 @@
1
+ import { closeSync, openSync, readSync } from 'node:fs';
2
+ import { GlossError } from './git.js';
3
+ import { checkDecision, MAX_DECISION_LINE } 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
+ // Reads a file, or stdin for "-", never more than MAX_BATCH_BYTES: a read that
11
+ // goes past it is refused without holding the rest (also for a pipe or /dev/zero).
12
+ export function readBounded(src) {
13
+ const what = src === '-' ? 'stdin' : plain(src);
14
+ const fail = (e) => new GlossError(`--decide-file: can't read ${what}: ${e.code ?? 'error'}`);
15
+ const buf = Buffer.allocUnsafe(MAX_BATCH_BYTES + 1);
16
+ let fd = 0, total = 0;
17
+ try {
18
+ if (src !== '-')
19
+ fd = openSync(src, 'r');
20
+ // Until end of input; a full buffer means the input is over the limit.
21
+ while (total < buf.length) {
22
+ let n;
23
+ try {
24
+ n = readSync(fd, buf, total, buf.length - total, null);
25
+ }
26
+ catch (e) {
27
+ // A non-blocking pipe with nothing yet: wait a little (no busy spin) and ask again.
28
+ if (e.code !== 'EAGAIN')
29
+ throw e;
30
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
31
+ continue;
32
+ }
33
+ if (n === 0)
34
+ break;
35
+ total += n;
36
+ }
37
+ }
38
+ catch (e) {
39
+ throw fail(e);
40
+ }
41
+ finally {
42
+ if (fd !== 0)
43
+ closeSync(fd);
44
+ }
45
+ if (total > MAX_BATCH_BYTES)
46
+ throw new GlossError(`--decide-file: the input is over ${MAX_BATCH_BYTES / 1024 / 1024} MiB; split the decisions into several files`);
47
+ return buf.toString('utf8', 0, total);
48
+ }
49
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
50
+ // Accepts a bare array of {finding, outcome, note?}, or {review?, complete?: boolean,
51
+ // decisions: [...]}. Unknown keys are ignored. Everything is checked here; nothing is written.
52
+ export function parseBatch(text, reviewId) {
53
+ let data;
54
+ try {
55
+ data = JSON.parse(text);
56
+ }
57
+ catch (e) {
58
+ throw new GlossError(`--decide-file: not valid JSON (${e instanceof RangeError ? 'nested too deeply' : plain(e.message).slice(0, 120)})`);
59
+ }
60
+ let list;
61
+ let complete = false;
62
+ if (Array.isArray(data))
63
+ list = data;
64
+ else if (isObject(data)) {
65
+ const d = data.decisions;
66
+ const c = data.complete;
67
+ if (c !== undefined && typeof c !== 'boolean')
68
+ throw new GlossError('--decide-file: "complete" must be true or false');
69
+ complete = c === true;
70
+ const r = data.review;
71
+ if (r !== undefined && r !== '' && r !== reviewId)
72
+ throw new GlossError(`--decide-file: the file is for review ${plain(String(r)).slice(0, 80)}, not ${plain(reviewId)}`);
73
+ if (!Array.isArray(d))
74
+ throw new GlossError('--decide-file: the object needs a "decisions" array');
75
+ list = d;
76
+ }
77
+ else
78
+ throw new GlossError('--decide-file: expected an array of {finding, outcome, note?}, or an object with a "decisions" array');
79
+ if (list.length > MAX_BATCH_ENTRIES)
80
+ throw new GlossError(`--decide-file: ${list.length} entries; at most ${MAX_BATCH_ENTRIES} per file`);
81
+ if (!list.length && !complete)
82
+ throw new GlossError('--decide-file: no decisions in the file');
83
+ // addReviews keeps a repeated line once, so a repeated finding would be counted but not written.
84
+ const firstAt = new Map();
85
+ const entries = list.map((e, i) => {
86
+ const at = `--decide-file: entry ${i + 1}`;
87
+ if (!isObject(e))
88
+ throw new GlossError(`${at} is not an object`);
89
+ const finding = e.finding, outcome = e.outcome, note = e.note;
90
+ if (typeof finding !== 'string')
91
+ throw new GlossError(`${at} has no "finding" text`);
92
+ const label = `${at} ("${plain(finding).slice(0, 40)}")`;
93
+ if (outcome === undefined || outcome === '')
94
+ throw new GlossError(`${label} has an empty outcome; fill it in or delete the row`);
95
+ if (typeof outcome !== 'string')
96
+ throw new GlossError(`${label}: "outcome" must be a string`);
97
+ if (note !== undefined && typeof note !== 'string')
98
+ throw new GlossError(`${label}: "note" must be a string`);
99
+ try {
100
+ checkDecision({ finding, outcome, note });
101
+ }
102
+ catch (err) {
103
+ if (err instanceof GlossError)
104
+ throw new GlossError(`${label}: ${err.message.replace(/^--/, '')}`);
105
+ throw err;
106
+ }
107
+ const seen = firstAt.get(finding.trim());
108
+ if (seen !== undefined)
109
+ throw new GlossError(`${label} repeats entry ${seen}`);
110
+ firstAt.set(finding.trim(), i + 1);
111
+ return { finding: finding.trim(), outcome: outcome, ...(note ? { note } : {}) };
112
+ });
113
+ return { entries, complete };
114
+ }
115
+ // A record that, as written, is a line readers would skip (escapes make control characters longer).
116
+ export function checkLength(record, what) {
117
+ if (JSON.stringify(record).length > MAX_DECISION_LINE)
118
+ throw new GlossError(`${what} is too long to record: the finding and note, as written, exceed what readers accept; shorten them`);
119
+ }
120
+ // The skeleton `--decide-template` prints: one row per ref, outcome left empty.
121
+ export function template(reviewId, findings) {
122
+ const rows = findingTexts(findings);
123
+ if (rows.length > MAX_BATCH_ENTRIES)
124
+ throw new GlossError(`review ${plain(reviewId)} has ${rows.length} findings; --decide-file takes at most ${MAX_BATCH_ENTRIES}`);
125
+ return {
126
+ review: reviewId, complete: false,
127
+ decisions: rows.map(([ref, text]) => ({ finding: `${ref}${ref.includes('.') ? '' : ':'} ${[...plain(text).replace(/\s+/g, ' ').trim()].slice(0, TEMPLATE_TEXT).join('')}`.trim(), outcome: '', note: '' })),
128
+ };
129
+ }
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' +
@@ -2,10 +2,10 @@ import { parseArgs } from 'node:util';
2
2
  import { createAry } from '../ary/server.js';
3
3
  import { GlossError, git } from '../git.js';
4
4
  import { openInBrowser } from './html.js';
5
- export const usage = 'ary [--port <n>] [--no-open] browse glosses of branches, commits and uncommitted work in a local web page';
5
+ export const usage = 'ary [--port <n>] [--no-open] browse glosses of branches, commits and uncommitted work in a local web page (the html view with branch pickers)';
6
6
  // Starts a read-only server on this machine and keeps running until stopped.
7
- // The browser page lists branches and commits and shows each selection with
8
- // the same view as `git gloss html`.
7
+ // The browser page is the same view as `git gloss html`, with branch pickers
8
+ // and a revision box in its top bar; each choice loads a new page.
9
9
  export function run(argv) {
10
10
  const { values } = parseArgs({
11
11
  args: argv,
@@ -0,0 +1,96 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { GlossError, resolveCommits } from '../git.js';
3
+ import { loadGloss } from '../loader.js';
4
+ import { ratingIssues, tryAnalyze, whyText } from '../floor.js';
5
+ import { attentionRank, impactRank } from '../attention.js';
6
+ import { servesOf } from '../schema.js';
7
+ import { unknownServes } from '../validate.js';
8
+ import { plain } from '../plain.js';
9
+ export const usage = 'lint [<commit> | wip] [--json] advisory checks of a gloss that a reviewer would make by eye (always exits 0 for findings)';
10
+ // What removal_impact says when it admits the group is not needed. Matched on the
11
+ // lowercased text, after plain(): BARE phrases only when they are the whole text
12
+ // ("Nothing." but not "Nothing else would work"); the others may start it.
13
+ const NOTHING_BREAKS_WHOLE = ['nothing', 'none', 'n/a'];
14
+ const NOTHING_BREAKS_START = ['nothing breaks', 'nothing would break', 'nothing is lost', 'nothing happens', 'no effect', 'no impact'];
15
+ export function saysNothingBreaks(text) {
16
+ const t = plain(text).toLowerCase().trim().replace(/[\s.!;:,]+$/, '');
17
+ if (NOTHING_BREAKS_WHOLE.includes(t))
18
+ return true;
19
+ return NOTHING_BREAKS_START.some((p) => t === p || (t.startsWith(p) && /^[\s.,;:!-]/.test(t.slice(p.length))));
20
+ }
21
+ export function run(argv) {
22
+ const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { json: { type: 'boolean' } } });
23
+ const arg = positionals[0] ?? 'HEAD';
24
+ const { shas, range } = resolveCommits(arg);
25
+ if (range)
26
+ throw new GlossError(`${arg}: lint takes one commit or wip, not a range`);
27
+ const sha = shas[0];
28
+ const gloss = loadGloss(sha);
29
+ if (!gloss)
30
+ throw new GlossError(`${sha.slice(0, 10)} has no gloss (annotate it with \`git gloss add\`)`);
31
+ for (const w of gloss.warnings)
32
+ console.error(`warning: ${plain(w)}`);
33
+ const a = gloss.note.annotation;
34
+ const findings = [];
35
+ // Text from a note is sanitized here, once, for both outputs.
36
+ 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) });
37
+ const ids = new Set(a.groups.map((g) => g.id));
38
+ const an = tryAnalyze(gloss);
39
+ if (an) {
40
+ const issues = ratingIssues(a, an);
41
+ for (const u of issues.unrated)
42
+ add('no-impact', `no impact rated${u.signal ? ` (floor ${u.floor}: ${whyText(u.why)})` : ''}`, { group: u.id });
43
+ for (const b of issues.belowFloor)
44
+ add('impact-below-floor', `impact "${b.impact}" is below its floor "${b.floor}" (${whyText(b.why)})`, { group: b.id });
45
+ if (issues.attention)
46
+ add('attention-below-floor', `attention "${issues.attention.level}" is below the computed floor "${issues.attention.floor}" (${issues.attention.why}) and has no override_reason`);
47
+ const sigOf = new Map(an.signals.hunks.map((s) => [s.id, s]));
48
+ for (const g of gloss.groups) {
49
+ const f = an.groups.get(g.group.id);
50
+ // A mechanical group made only of lockfiles, docs, tests or generated files sits on a path-class floor honestly (the skill's lockfile example).
51
+ const real = g.hunks.some((h) => h.current && !sigOf.get(h.current.id)?.inert);
52
+ if (g.group.necessity === 'mechanical' && f && impactRank(f.floor) > impactRank('module') && real) {
53
+ add('mechanical-on-floor', `graded mechanical but its floor is "${f.floor}" (${whyText(f.why)})`, { group: g.group.id });
54
+ }
55
+ if (f?.testsOnly && !servesOf(g.group).some((t) => t !== 'task' && ids.has(t) && t !== g.group.id)) {
56
+ add('test-serves-nothing', 'a test group that serves no existing group (set serves to the groups it verifies)', { group: g.group.id });
57
+ }
58
+ }
59
+ }
60
+ for (const g of a.groups) {
61
+ const bad = unknownServes(g, ids);
62
+ if (bad.length)
63
+ add('unknown-serves', `serves names no such group: ${bad.map((t) => `"${t}"`).join(', ')}`, { group: g.id });
64
+ if (g.necessity === 'required' && saysNothingBreaks(g.removal_impact)) {
65
+ add('required-but-nothing-breaks', `graded required but removal_impact says: "${g.removal_impact}"`, { group: g.id });
66
+ }
67
+ }
68
+ const level = a.attention?.level ?? an?.derived.level;
69
+ if (level && attentionRank(level) >= attentionRank('careful') && !a.risks?.length) {
70
+ add('missing-risks', `attention is ${level} but no risks are listed`);
71
+ }
72
+ for (const u of gloss.unannotated) {
73
+ 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 });
74
+ }
75
+ if (values.json) {
76
+ console.log(JSON.stringify({ target: sha.slice(0, 10), findings, ...(an ? {} : { incomplete: true }) }, null, 2));
77
+ return 0;
78
+ }
79
+ const subjects = new Map();
80
+ for (const f of findings) {
81
+ const key = f.group !== undefined ? `group ${f.group}` : f.hunk !== undefined ? `hunk ${f.hunk}` : 'whole gloss';
82
+ subjects.set(key, [...(subjects.get(key) ?? []), f]);
83
+ }
84
+ for (const [key, fs] of subjects) {
85
+ console.log(key);
86
+ for (const f of fs)
87
+ console.log(` ${f.code.padEnd(28)} ${f.message}`);
88
+ }
89
+ if (findings.length)
90
+ console.log(`${findings.length} finding${findings.length === 1 ? '' : 's'}`);
91
+ if (!an)
92
+ console.log('incomplete: the rating checks need the diff signals, which are unavailable (see the warning above)');
93
+ else if (!findings.length)
94
+ console.log('clean');
95
+ return 0;
96
+ }
@@ -1,4 +1,5 @@
1
1
  import { parseArgs } from 'node:util';
2
+ import { plain } from '../plain.js';
2
3
  import { commitDiff, commitSubject, isWipSnapshot, resolveCommits } from '../git.js';
3
4
  import { parseDiff } from '../diff.js';
4
5
  import { refLabel, sliceText } from '../selection.js';
@@ -15,16 +16,16 @@ function fenced(lang, body) {
15
16
  // Author text is JSON-quoted so it stays on one line and can't fake a heading
16
17
  // or end the material section.
17
18
  const q = (s) => JSON.stringify(s);
18
- const hunkLine = (h) => `- ${h.id} ${h.oldFile && h.oldFile !== h.file ? `${h.oldFile} → ` : ''}${h.file} ` +
19
+ const hunkLine = (h) => `- ${h.id} ${plain(h.oldFile && h.oldFile !== h.file ? `${h.oldFile} → ${h.file}` : h.file)} ` +
19
20
  `(-${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines}, +${h.added} -${h.removed})`;
20
21
  // The material for one commit: its gloss and the code under each group, or,
21
22
  // for an unglossed commit in a range, its whole diff.
22
23
  function commitMaterial(sha, gloss) {
23
24
  if (!gloss) {
24
25
  return [
25
- `## Commit ${sha.slice(0, 10)}: ${commitSubject(sha)}`,
26
+ `## Commit ${sha.slice(0, 10)}: ${plain(commitSubject(sha))}`,
26
27
  'This commit has no gloss. Review its changes as unexplained.',
27
- fenced('diff', parseDiff(commitDiff(sha)).map((h) => `# ${h.file}\n${h.text}`).join('\n')),
28
+ fenced('diff', parseDiff(commitDiff(sha)).map((h) => `# ${plain(h.file)}\n${h.text}`).join('\n')),
28
29
  ];
29
30
  }
30
31
  const a = gloss.note.annotation;
@@ -47,7 +48,7 @@ function commitMaterial(sha, gloss) {
47
48
  const sections = [
48
49
  isWipSnapshot(sha)
49
50
  ? `## Uncommitted work (snapshot ${sha.slice(0, 10)})\n\nThese changes are not committed yet; the author glossed them before committing.`
50
- : `## Commit ${sha.slice(0, 10)}: ${gloss.subject}`,
51
+ : `## Commit ${sha.slice(0, 10)}: ${plain(gloss.subject)}`,
51
52
  ...gloss.warnings.map((w) => `> **Warning from git-gloss:** ${w}`),
52
53
  `**Task (author's statement):** ${q(a.task)}`,
53
54
  ...(a.summary ? [`**Summary (author's statement):** ${q(a.summary)}`] : []),
@@ -2,15 +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, reviewStatus, stamp, statusPhrase, verdictOf } from '../reviews.js';
5
+ import { addReviews, checkBy, checkDecision, isValidDecision, MAX_FINDINGS, MAX_REVIEW_LINE, decisionSummary, readDecisions, readReviews, reviewStatus, stamp, statusPhrase, verdictOf } from '../reviews.js';
6
+ import { findingRefs, labelFindings, orderDecisions, refOf } from '../findings.js';
7
+ import { plain } from '../plain.js';
6
8
  import { page, style } from '../render.js';
7
- export const usage = 'reviews [<commit> | <range> | wip] [--stamp | --record] the reviews recorded for commits, with their findings; --stamp and --record are for review tools';
9
+ import { checkLength, parseBatch, readBounded, 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';
8
11
  export function run(argv) {
9
12
  const { values, positionals } = parseArgs({
10
13
  args: argv,
11
14
  allowPositionals: true,
12
- options: { stamp: { type: 'boolean' }, record: { type: 'boolean' } },
15
+ options: {
16
+ stamp: { type: 'boolean' }, record: { type: 'boolean' }, decide: { type: 'string' }, finding: { type: 'string' },
17
+ outcome: { type: 'string' }, 'decide-file': { type: 'string' }, 'decide-template': { type: 'string' }, note: { type: 'string' }, complete: { type: 'boolean' }, by: { type: 'string' },
18
+ },
13
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
+ }
14
33
  if (values.record) {
15
34
  const input = JSON.parse(readFileSync(0, 'utf8'));
16
35
  for (const k of ['stage', 'agent', 'target', 'findings']) {
@@ -28,6 +47,9 @@ export function run(argv) {
28
47
  if (!Array.isArray(seen?.lines) || !Array.isArray(seen?.groups))
29
48
  throw new GlossError(`--record: the stamp for ${sha.slice(0, 12)} has no lines or groups`);
30
49
  }
50
+ // Readers skip what is over these limits, so a record that is would vanish: refuse it here instead.
51
+ if (input.findings.length > MAX_FINDINGS)
52
+ throw new GlossError(`--record: the findings are too long (${input.findings.length} characters; readers skip reports over ${MAX_FINDINGS / 1024} KiB); shorten the report`);
31
53
  const id = randomUUID();
32
54
  const at = new Date().toISOString();
33
55
  for (const [sha, seen] of Object.entries(input.stamp)) {
@@ -37,6 +59,8 @@ export function run(argv) {
37
59
  target: input.target, lines: seen.lines, groups: seen.groups, findings: input.findings,
38
60
  ...(verdictOf(input.findings) ? { verdict: verdictOf(input.findings) } : {}),
39
61
  };
62
+ if (JSON.stringify(record).length > MAX_REVIEW_LINE)
63
+ throw new GlossError(`--record: the record for ${sha.slice(0, 10)} is over ${MAX_REVIEW_LINE / 1024 / 1024} MiB with its stamp, which readers skip; review fewer changed lines at a time`);
40
64
  addReviews(sha, [record]);
41
65
  }
42
66
  const n = Object.keys(input.stamp).length;
@@ -44,6 +68,85 @@ export function run(argv) {
44
68
  return 0;
45
69
  }
46
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
+ const warnUnknownRef = (id, report, finding, hint = '') => {
84
+ const ref = refOf(finding);
85
+ if (ref !== null && report && !findingRefs(report.findings).has(ref))
86
+ console.error(`git-gloss: review ${plain(id)} has no finding ${ref} (${hint}see the refs in \`git gloss reviews ${target}\`); recording it anyway`);
87
+ };
88
+ if (values['decide-template'] !== undefined) {
89
+ const id = values['decide-template'];
90
+ const report = reportOf(id, holdersOf(id));
91
+ console.log(JSON.stringify(template(id, report.findings), null, 2));
92
+ return 0;
93
+ }
94
+ if (values['decide-file'] !== undefined) {
95
+ const id = values.decide;
96
+ // Everything is checked before the first write: one bad entry records nothing.
97
+ const batch = parseBatch(readBounded(values['decide-file']), id);
98
+ const holders = holdersOf(id);
99
+ const by = values.by !== undefined ? values.by.trim() : gitTry(['config', 'user.name'])?.trim() || 'unknown';
100
+ checkBy(by);
101
+ const at = new Date().toISOString();
102
+ const records = batch.entries.map((e, i) => {
103
+ const r = { decision: 1, review: id, at, by, finding: e.finding, outcome: e.outcome, ...(e.note ? { note: e.note } : {}) };
104
+ checkLength(r, `--decide-file: entry ${i + 1}`);
105
+ return r;
106
+ });
107
+ if (batch.complete)
108
+ records.push({ decision: 1, review: id, at, by, complete: true });
109
+ const report = reportOf(id, holders);
110
+ batch.entries.forEach((e, i) => warnUnknownRef(id, report, e.finding, `entry ${i + 1}; `));
111
+ // One compare-and-swap write per commit, whatever the number of records.
112
+ for (const sha of holders)
113
+ addReviews(sha, records);
114
+ const n = batch.entries.length;
115
+ console.log(`recorded ${n} decision${n === 1 ? '' : 's'} on ${holders.length} commit${holders.length === 1 ? '' : 's'}${batch.complete ? ' and marked the review complete' : ''}`);
116
+ return 0;
117
+ }
118
+ if (values.decide !== undefined) {
119
+ const id = values.decide;
120
+ if (values.complete) {
121
+ if (!isValidDecision({ complete: true, finding: values.finding, outcome: values.outcome, note: values.note })) {
122
+ throw new GlossError('--complete marks the review as arbitrated; it takes no --finding, --outcome or --note');
123
+ }
124
+ }
125
+ else
126
+ checkDecision(values);
127
+ const holders = holdersOf(id);
128
+ const by = values.by !== undefined ? values.by.trim() : gitTry(['config', 'user.name'])?.trim() || 'unknown';
129
+ checkBy(by);
130
+ const record = {
131
+ decision: 1, review: id, at: new Date().toISOString(), by,
132
+ ...(values.complete ? { complete: true } : {
133
+ finding: values.finding.trim(), outcome: values.outcome,
134
+ ...(values.note ? { note: values.note } : {}),
135
+ }),
136
+ };
137
+ // Control characters are written as 6-byte escapes, so check the line itself against the reader's cap.
138
+ checkLength(record, 'that decision');
139
+ if (record.finding !== undefined)
140
+ warnUnknownRef(id, reportOf(id, holders), record.finding);
141
+ for (const sha of holders)
142
+ addReviews(sha, [record]);
143
+ console.log(`recorded ${values.complete ? 'the review as complete' : `"${record.finding}": ${record.outcome}`} on ${holders.length} commit${holders.length === 1 ? '' : 's'}`);
144
+ return 0;
145
+ }
146
+ for (const k of ['finding', 'outcome', 'note', 'complete', 'by']) {
147
+ if (values[k] !== undefined)
148
+ throw new GlossError(`--${k} goes with --decide <review-id>`);
149
+ }
47
150
  if (values.stamp) {
48
151
  console.log(JSON.stringify(Object.fromEntries(shas.map((sha) => [sha, stamp(sha)]))));
49
152
  return 0;
@@ -51,7 +154,8 @@ export function run(argv) {
51
154
  const out = [];
52
155
  for (const sha of shas) {
53
156
  const statuses = reviewStatus(sha);
54
- out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', commitSubject(sha))}`);
157
+ const decisions = readDecisions(sha);
158
+ out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(commitSubject(sha)))}`);
55
159
  if (!statuses.length) {
56
160
  out.push(style('dim', ' no reviews recorded'), '');
57
161
  continue;
@@ -59,8 +163,15 @@ export function run(argv) {
59
163
  for (const s of statuses) {
60
164
  out.push(` ${statusPhrase(s)}`);
61
165
  for (const r of s.reviews) {
62
- 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}`));
63
- out.push(...r.findings.trim().split('\n').map((l) => ` ${l}`));
166
+ 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)}`));
167
+ out.push(...labelFindings(r.findings).trim().split('\n').map((l) => ` ${l}`));
168
+ const ds = decisions.filter((d) => d.review === r.id);
169
+ if (r.verdict !== 'clean' || ds.length) {
170
+ out.push('', ` decisions on this review: ${decisionSummary(ds)}`);
171
+ for (const d of orderDecisions(ds.filter((d) => d.outcome))) {
172
+ out.push(` ${d.outcome}: ${plain(d.finding)}${d.note ? ` — ${plain(d.note)}` : ''} ${style('dim', `(${plain(d.by)}, ${plain(d.at).slice(0, 10)})`)}`);
173
+ }
174
+ }
64
175
  }
65
176
  out.push('');
66
177
  }
@@ -5,7 +5,8 @@ import { loadGloss } 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';
8
- import { current, reviewStatus, statusPhrase, verdict } from '../reviews.js';
8
+ import { current, decisionSummary, readDecisions, readReviews, reviewStatus, statusPhrase, verdict } from '../reviews.js';
9
+ import { plain } from '../plain.js';
9
10
  import { attentionBlock, colorDiff, derivedImpactTag, field, groupHeader, hunkLabel, IMPACT_W, impactTag, necessityWord, noteFooter, page, style, termWidth, wrap, } from '../render.js';
10
11
  export const usage = 'show [<commit> | <range>] [-g <group>]... [--full] [--diff] [--only <necessity|impact,...> (a group must match each kind named)] [--sort impact] summarise a gloss, or each commit in a range';
11
12
  // Counts over the lines each part selects, not whole hunks, so groups that
@@ -102,12 +103,12 @@ export function run(argv) {
102
103
  out.push('', style('dim', '─'.repeat(Math.min(termWidth(), 60))));
103
104
  const gloss = glosses[i];
104
105
  if (!gloss) {
105
- out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', commitSubject(sha))}`, style('dim', 'no gloss for this commit'));
106
+ out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(commitSubject(sha)))}`, style('dim', 'no gloss for this commit'));
106
107
  return;
107
108
  }
108
109
  // An --only filter can leave a commit with nothing to show; say so and move on.
109
110
  out.push(...(renderCommit(sha, gloss, values, only, false) ??
110
- [`${style('yellow', sha.slice(0, 10))} ${style('bold', gloss.subject)}`, style('dim', `no ${only?.names.join('/')} groups`)]));
111
+ [`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(gloss.subject))}`, style('dim', `no ${only?.names.join('/')} groups`)]));
111
112
  });
112
113
  if (glossed)
113
114
  out.push('', style('dim', 'One commit in detail: git gloss show <commit> --full (or -g <group> for its code)'));
@@ -122,7 +123,14 @@ function reviewsLine(sha) {
122
123
  if (!statuses.length)
123
124
  return style('dim', 'reviews: none recorded');
124
125
  const color = (s) => (!current(s) || verdict(s) === 'issues' ? 'yellow' : verdict(s) === 'clean' ? 'green' : 'dim');
125
- return `reviews: ${statuses.map((s) => style(color(s), statusPhrase(s))).join(style('dim', ' · '))}` +
126
+ // What was decided about the reviews that found something (or didn't say);
127
+ // a commit with only clean reviews has nothing to decide.
128
+ const open = readReviews(sha).filter((r) => r.verdict !== 'clean');
129
+ const decisions = readDecisions(sha).filter((d) => open.some((r) => r.id === d.review));
130
+ const decided = open.length
131
+ ? ` ${style('dim', '·')} decisions: ${decisionSummary(decisions, open.every((r) => decisions.some((d) => d.review === r.id && d.complete)))}`
132
+ : '';
133
+ return `reviews: ${statuses.map((s) => style(color(s), statusPhrase(s))).join(style('dim', ' · '))}${decided}` +
126
134
  style('dim', ` (findings: git gloss reviews ${sha.slice(0, 7)})`);
127
135
  }
128
136
  // One commit's view, or null if no group matches -g/--only. `hints` adds the
@@ -130,14 +138,14 @@ function reviewsLine(sha) {
130
138
  function renderCommit(sha, gloss, values, only, hints) {
131
139
  const a = gloss.note.annotation;
132
140
  const short = sha.slice(0, 7);
133
- const out = [`${style('yellow', sha.slice(0, 10))} ${style('bold', gloss.subject)}`];
141
+ const out = [`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(gloss.subject))}`];
134
142
  out.push(...field('task:', a.task, 0));
135
143
  if (a.summary)
136
144
  out.push(...field('summary:', a.summary, 0));
137
145
  for (const w of gloss.warnings)
138
146
  out.push(...field('⚠', w, 0).map((l) => style('red', l)));
139
147
  const an = tryAnalyze(gloss);
140
- out.push(...attentionBlock(a.attention, an ? { floor: an.floor, derived: an.derived } : undefined), reviewsLine(sha));
148
+ out.push(...attentionBlock(a.attention, an ? { floor: an.floor, derived: an.derived, timedOut: an.timedOut } : undefined), reviewsLine(sha));
141
149
  const groups = gloss.groups
142
150
  .filter((g) => matchesOnly(g.group, only))
143
151
  .filter((g) => !values.group || values.group.includes(g.group.id));
@@ -1,3 +1,4 @@
1
+ import { plain } from '../plain.js';
1
2
  import { parseArgs } from 'node:util';
2
3
  import { loadConfig } from '../config.js';
3
4
  import { commitDiff, diffBetween, git, GitError, resolveCommit, workingDiff } from '../git.js';
@@ -17,11 +18,11 @@ export function summaryLines(sig, max = 6) {
17
18
  const files = sig.files.filter((f) => impactRank(f.floor) >= impactRank('shared'))
18
19
  .sort((a, b) => impactRank(b.floor) - impactRank(a.floor) || a.file.localeCompare(b.file));
19
20
  for (const f of files.slice(0, max))
20
- out.push(` ${(f.floor + ':').padEnd(10)} ${f.file}${f.why.length ? ` (${f.why.join('; ')})` : ''}`);
21
+ out.push(` ${(f.floor + ':').padEnd(10)} ${plain(f.file)}${f.why.length ? ` (${plain(f.why.join('; '))})` : ''}`);
21
22
  if (files.length > max)
22
23
  out.push(` ... and ${files.length - max} more at shared or above (git gloss signals)`);
23
24
  for (const r of sig.floor.reasons.filter((r) => / files changed /.test(r)))
24
- out.push(` large: ${r}`);
25
+ out.push(` large: ${plain(r)}`);
25
26
  if (sig.floor.reasons.includes(CONFIG_EDIT_NOTE))
26
27
  out.push(` note: ${CONFIG_EDIT_NOTE}`);
27
28
  out.push(` stats: ${statsLine(sig.stats)}`);
@@ -31,16 +32,16 @@ function detailLines(sig, label) {
31
32
  const out = [style('bold', `signals for ${label}:`) + ` floor attention = ${style(['bold', ATTENTION_STYLE[sig.floor.attention]], sig.floor.attention)}` +
32
33
  style('dim', ` (floor impact: ${sig.floor.impact})`)];
33
34
  for (const r of sig.floor.reasons)
34
- out.push(` why: ${r}`);
35
+ out.push(` why: ${plain(r)}`);
35
36
  out.push(` stats: ${statsLine(sig.stats)}`);
36
37
  const notable = sig.hunks.filter((h) => impactRank(h.floor) > impactRank('module') || h.hits.length);
37
38
  if (notable.length)
38
39
  out.push('', style('bold', 'hunks with signals'));
39
40
  for (const h of notable) {
40
- out.push(` ${style('bold', h.id.padEnd(5))} ${impactTag(h.floor, 11)} ${h.file}` +
41
- style('dim', ` [${h.classes.join(', ') || 'no class'}]${h.why.length ? ' ' + h.why.join('; ') : ''}`));
41
+ out.push(` ${style('bold', h.id.padEnd(5))} ${impactTag(h.floor, 11)} ${plain(h.file)}` +
42
+ style('dim', ` [${h.classes.join(', ') || 'no class'}]${h.why.length ? ' ' + plain(h.why.join('; ')) : ''}`));
42
43
  for (const x of h.hits.slice(0, 8)) {
43
- out.push(` ${h.id}:${x.line} ${x.sign} ${x.class} pattern "${x.name}"${x.damped ? style('dim', ' (damped: test, docs or lockfile path)') : ''} ${style('dim', x.text)}`);
44
+ out.push(` ${h.id}:${x.line} ${x.sign} ${x.class} pattern "${x.name}"${x.timedOut ? ' (pattern timed out: counted as matching)' : ''}${x.damped ? style('dim', ' (damped: test, docs or lockfile path)') : ''} ${style('dim', plain(x.text))}`);
44
45
  }
45
46
  if (h.hits.length > 8)
46
47
  out.push(style('dim', ` ... ${h.hits.length - 8} more hits (--json)`));
@@ -51,9 +52,9 @@ function detailLines(sig, label) {
51
52
  if (sig.dependencies.length)
52
53
  out.push('', style('bold', 'dependency changes'));
53
54
  for (const d of sig.dependencies) {
54
- out.push(` ${d.file} (${d.kind}${d.format ? `, parsed as ${d.format}` : d.kind === 'lockfile' ? ', no version detail for this format' : ''})`);
55
+ out.push(` ${plain(d.file)} (${d.kind}${d.format ? `, parsed as ${d.format}` : d.kind === 'lockfile' ? ', no version detail for this format' : ''})`);
55
56
  for (const b of d.bumps.slice(0, 20)) {
56
- const what = b.kind === 'bump' ? `${b.name} ${b.from} -> ${b.to}` : b.kind === 'added' ? `+ ${b.name} ${b.to}` : `- ${b.name} ${b.from}`;
57
+ const what = plain(b.kind === 'bump' ? `${b.name} ${b.from} -> ${b.to}` : b.kind === 'added' ? `+ ${b.name} ${b.to}` : `- ${b.name} ${b.from}`);
57
58
  out.push(` ${what}${b.unrelated ? style('yellow', ' (transitive: no manifest change names it)') : ''}`);
58
59
  }
59
60
  if (d.bumps.length > 20)
@@ -46,6 +46,18 @@ function shareable(remote, pushing) {
46
46
  return new Set();
47
47
  return new Set(git(['rev-list', ...starts, '--']).split('\n').filter(Boolean));
48
48
  }
49
+ // The fetched ref is the remote's to set: it must be a commit before notes
50
+ // code (or an update-ref onto our own notes ref) touches it.
51
+ function checkTracking(remote, ref, tracking) {
52
+ if (gitTry(['rev-parse', '-q', '--verify', `${tracking}^{commit}`]) === null) {
53
+ gitTry(['update-ref', '-d', tracking]);
54
+ throw new GitError(`${remote}'s ${ref} is not a notes commit; ignoring it (nothing was changed here)`);
55
+ }
56
+ }
57
+ function fetchTracking(remote, ref, tracking) {
58
+ git(['fetch', '-q', remote, `+${ref}:${tracking}`]);
59
+ checkTracking(remote, ref, tracking);
60
+ }
49
61
  const PUSH_REF = 'refs/notes/gloss-sync-tmp';
50
62
  // One fetch → merge → push round. Returns null if the push lost a race with
51
63
  // someone else's push and the round should be retried.
@@ -53,7 +65,7 @@ function round(remote, pushing, carried) {
53
65
  const tracking = `refs/notes/remotes/${remote}/gloss`;
54
66
  const remoteHas = gitTry(['ls-remote', '--exit-code', remote, NOTES_REF]) !== null;
55
67
  if (remoteHas)
56
- git(['fetch', '-q', remote, `+${NOTES_REF}:${tracking}`]);
68
+ fetchTracking(remote, NOTES_REF, tracking);
57
69
  const local = noteList(NOTES_REF);
58
70
  const theirs = remoteHas ? noteList(tracking) : new Map();
59
71
  // The last state both sides shared. A gloss changed on one side only is
@@ -159,7 +171,7 @@ function syncReviews(remote, pushing) {
159
171
  const tracking = `refs/notes/remotes/${remote}/gloss-reviews`;
160
172
  const remoteHas = gitTry(['ls-remote', '--exit-code', remote, REVIEWS_REF]) !== null;
161
173
  if (remoteHas)
162
- git(['fetch', '-q', remote, `+${REVIEWS_REF}:${tracking}`]);
174
+ fetchTracking(remote, REVIEWS_REF, tracking);
163
175
  const before = noteList(REVIEWS_REF);
164
176
  const theirs = remoteHas ? noteList(tracking) : new Map();
165
177
  if (remoteHas) {
@@ -1,3 +1,4 @@
1
+ import { plain } from '../plain.js';
1
2
  import { parseArgs } from 'node:util';
2
3
  import { blameRange, commitSubject, lineHistory } from '../git.js';
3
4
  import { loadGloss } from '../loader.js';
@@ -158,8 +159,8 @@ export function run(argv) {
158
159
  const renderStep = (s, label, older) => {
159
160
  // Where the lines were in an older commit: after edits or a file rename
160
161
  // that differs from the query.
161
- const then = older || s.file !== file ? style('dim', ` (${s.file}:${formatRanges(s.lines)} then)`) : '';
162
- out.push(`${label}${style('yellow', s.commit.slice(0, 10))} ${s.subject}${then}`);
162
+ const then = older || s.file !== file ? style('dim', ` (${plain(s.file)}:${formatRanges(s.lines)} then)`) : '';
163
+ out.push(`${label}${style('yellow', s.commit.slice(0, 10))} ${plain(s.subject)}${then}`);
163
164
  if (!s.gloss) {
164
165
  out.push(style('dim', ' no gloss for this commit'));
165
166
  return;