@ok-ggwp/git-gloss 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +160 -2
- package/bin/git-gloss.js +15 -0
- package/dist/carry.js +56 -0
- package/dist/cli.js +48 -0
- package/dist/commands/add.js +78 -0
- package/dist/commands/adopt.js +93 -0
- package/dist/commands/carry.js +175 -0
- package/dist/commands/html.js +45 -0
- package/dist/commands/hunks.js +64 -0
- package/dist/commands/init.js +142 -0
- package/dist/commands/review.js +118 -0
- package/dist/commands/reviews.js +70 -0
- package/dist/commands/schema.js +7 -0
- package/dist/commands/show.js +210 -0
- package/dist/commands/sync.js +245 -0
- package/dist/commands/versions.js +132 -0
- package/dist/commands/why.js +209 -0
- package/dist/diff.js +144 -0
- package/dist/git.js +315 -0
- package/dist/html/client.js +383 -0
- package/dist/html/data.js +147 -0
- package/dist/html/page.js +35 -0
- package/dist/html/style.css +229 -0
- package/dist/loader.js +62 -0
- package/dist/merge.js +191 -0
- package/dist/origins.js +156 -0
- package/dist/render.js +131 -0
- package/dist/reviews.js +205 -0
- package/dist/schema.js +75 -0
- package/dist/selection.js +147 -0
- package/dist/validate.js +85 -0
- package/package.json +50 -4
- package/skills/gloss/SKILL.md +106 -0
package/dist/render.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { styleText } from 'node:util';
|
|
3
|
+
import { gitTry } from './git.js';
|
|
4
|
+
import { refLabel, sliceStats } from './selection.js';
|
|
5
|
+
import { servesOf } from './schema.js';
|
|
6
|
+
export const style = (s, text) => styleText(s, text);
|
|
7
|
+
const NECESSITY_STYLE = {
|
|
8
|
+
required: 'green',
|
|
9
|
+
supporting: 'cyan',
|
|
10
|
+
mechanical: 'gray',
|
|
11
|
+
incidental: 'yellow',
|
|
12
|
+
defensive: 'blue',
|
|
13
|
+
speculative: 'magenta',
|
|
14
|
+
};
|
|
15
|
+
export function necessityTag(n) {
|
|
16
|
+
return style(['bold', NECESSITY_STYLE[n]], `[${n}]`);
|
|
17
|
+
}
|
|
18
|
+
export function necessityWord(n, pad = 0) {
|
|
19
|
+
return style(NECESSITY_STYLE[n], n.padEnd(pad));
|
|
20
|
+
}
|
|
21
|
+
export const termWidth = () => Math.min(process.stdout.columns || 100, 120);
|
|
22
|
+
// Word-wrap plain text to `width` columns.
|
|
23
|
+
export function wrap(text, width) {
|
|
24
|
+
const lines = [];
|
|
25
|
+
let line = '';
|
|
26
|
+
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
27
|
+
if (line && line.length + 1 + word.length > width) {
|
|
28
|
+
lines.push(line);
|
|
29
|
+
line = word;
|
|
30
|
+
}
|
|
31
|
+
else
|
|
32
|
+
line = line ? `${line} ${word}` : word;
|
|
33
|
+
}
|
|
34
|
+
lines.push(line);
|
|
35
|
+
return lines;
|
|
36
|
+
}
|
|
37
|
+
// "label text" with continuation lines hanging under the text.
|
|
38
|
+
export function field(label, text, indent = 2) {
|
|
39
|
+
const hang = indent + label.length + 1;
|
|
40
|
+
const [first, ...rest] = wrap(text, Math.max(20, termWidth() - hang));
|
|
41
|
+
return [' '.repeat(indent) + style('dim', label) + ' ' + first, ...rest.map((l) => ' '.repeat(hang) + l)];
|
|
42
|
+
}
|
|
43
|
+
// "h6:5-12 src/x.ts -10,20 +10,25 +6 -2", with counts for the selected lines.
|
|
44
|
+
export function hunkLabel(h, lines = null) {
|
|
45
|
+
const range = h.newLines || h.oldLines
|
|
46
|
+
? `-${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines}`
|
|
47
|
+
: 'file header';
|
|
48
|
+
const file = h.oldFile && h.oldFile !== h.file ? `${h.oldFile} → ${h.file}` : h.file;
|
|
49
|
+
const s = sliceStats(h, lines);
|
|
50
|
+
return `${style('bold', refLabel(h.id, lines))} ${file} ${style('dim', range)} ${style('green', `+${s.added}`)} ${style('red', `-${s.removed}`)}`;
|
|
51
|
+
}
|
|
52
|
+
export function colorDiff(text, indent = '') {
|
|
53
|
+
return text
|
|
54
|
+
.split('\n')
|
|
55
|
+
.map((l) => {
|
|
56
|
+
const s = l.startsWith('@@') ? 'cyan'
|
|
57
|
+
: l.startsWith('+') ? 'green'
|
|
58
|
+
: l.startsWith('-') ? 'red'
|
|
59
|
+
: l.startsWith('diff ') || l.startsWith('index ') || l.startsWith('⋯') ? 'dim'
|
|
60
|
+
: null;
|
|
61
|
+
return indent + (s ? style(s, l) : l);
|
|
62
|
+
})
|
|
63
|
+
.join('\n');
|
|
64
|
+
}
|
|
65
|
+
export function groupHeader(g) {
|
|
66
|
+
const prefix = `■ [${g.necessity}] `;
|
|
67
|
+
const out = wrap(g.intent, termWidth() - prefix.length).map((l, i) => (i === 0 ? `${style('bold', '■')} ${necessityTag(g.necessity)} ` : ' '.repeat(prefix.length)) + style('bold', l));
|
|
68
|
+
out[out.length - 1] += ' ' + style('dim', `(${g.id})`);
|
|
69
|
+
const serves = servesOf(g);
|
|
70
|
+
if (serves[0] !== 'task')
|
|
71
|
+
out.push(...field('serves:', serves.join(', ')));
|
|
72
|
+
out.push(...field('if removed:', g.removal_impact));
|
|
73
|
+
if (g.evidence)
|
|
74
|
+
out.push(...field('evidence:', g.evidence));
|
|
75
|
+
if (g.notes)
|
|
76
|
+
out.push(...field('notes:', g.notes));
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
export function risksBlock(note) {
|
|
80
|
+
const risks = note.annotation.risks ?? [];
|
|
81
|
+
return risks.length ? ['', style('bold', 'Risks'), ...risks.flatMap((r) => field('•', r))] : [];
|
|
82
|
+
}
|
|
83
|
+
export function noteFooter(note) {
|
|
84
|
+
const out = [];
|
|
85
|
+
const a = note.annotation;
|
|
86
|
+
if (a.alternatives?.length) {
|
|
87
|
+
out.push('', style('bold', 'Alternatives considered'));
|
|
88
|
+
for (const alt of a.alternatives)
|
|
89
|
+
out.push(...field('•', `${alt.option}: ${alt.rejected_because}`));
|
|
90
|
+
}
|
|
91
|
+
return [...out, ...risksBlock(note)];
|
|
92
|
+
}
|
|
93
|
+
// The pager git would use for `git gloss`: pager.gloss if set (false turns it
|
|
94
|
+
// off), else GIT_PAGER, core.pager, PAGER, less. `git --no-pager` reaches us
|
|
95
|
+
// as GIT_PAGER=cat.
|
|
96
|
+
export function pagerCommand() {
|
|
97
|
+
if (process.env.GIT_PAGER_IN_USE)
|
|
98
|
+
return null;
|
|
99
|
+
const own = gitTry(['config', 'pager.gloss'])?.trim();
|
|
100
|
+
if (own !== undefined && own !== '') {
|
|
101
|
+
const off = gitTry(['config', '--type=bool', 'pager.gloss'])?.trim();
|
|
102
|
+
if (off === 'false')
|
|
103
|
+
return null;
|
|
104
|
+
if (off !== 'true')
|
|
105
|
+
return own;
|
|
106
|
+
}
|
|
107
|
+
const pager = gitTry(['var', 'GIT_PAGER'])?.trim();
|
|
108
|
+
return !pager || pager === 'cat' ? null : pager;
|
|
109
|
+
}
|
|
110
|
+
// Run the pager; print directly only if it could not run at all. Quitting the
|
|
111
|
+
// pager early makes our write fail with EPIPE, which is not a reason to dump
|
|
112
|
+
// the rest of the output on the terminal.
|
|
113
|
+
export function runPager(pager, text) {
|
|
114
|
+
const r = spawnSync('sh', ['-c', pager], {
|
|
115
|
+
input: text + '\n',
|
|
116
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
117
|
+
env: { LESS: 'FRX', LV: '-c', ...process.env, GIT_PAGER_IN_USE: 'true' },
|
|
118
|
+
});
|
|
119
|
+
const code = r.error?.code;
|
|
120
|
+
if ((r.error && code !== 'EPIPE') || r.status === 127)
|
|
121
|
+
console.log(text);
|
|
122
|
+
}
|
|
123
|
+
// Page output that won't fit on screen, as git does.
|
|
124
|
+
export function page(text) {
|
|
125
|
+
const rows = process.stdout.rows;
|
|
126
|
+
const pager = process.stdout.isTTY && rows && text.split('\n').length >= rows ? pagerCommand() : null;
|
|
127
|
+
if (pager)
|
|
128
|
+
runPager(pager, text);
|
|
129
|
+
else
|
|
130
|
+
console.log(text);
|
|
131
|
+
}
|
package/dist/reviews.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { commitDiff, git, gitTry, GlossError, readNote } from './git.js';
|
|
3
|
+
import { parseDiff } from './diff.js';
|
|
4
|
+
import { bodyLines } from './selection.js';
|
|
5
|
+
// Reviews of commits (by git-gud, or anything else that records them), kept
|
|
6
|
+
// beside the glosses so a reader can see whether a gloss was checked. One JSON
|
|
7
|
+
// record per line: notes from different people and machines combine by taking
|
|
8
|
+
// every line (git's cat_sort_uniq), with no conflicts.
|
|
9
|
+
export const REVIEWS_REF = 'refs/notes/gloss-reviews';
|
|
10
|
+
// The report's VERDICT line, which has to be a line of its own among the
|
|
11
|
+
// last three, so a verdict quoted in the body can't stand in for it.
|
|
12
|
+
export function verdictOf(findings) {
|
|
13
|
+
const tail = findings.trim().split('\n').filter((l) => l.trim()).slice(-3).reverse();
|
|
14
|
+
for (const line of tail) {
|
|
15
|
+
// The whole line, give or take markdown: not a sentence that starts so.
|
|
16
|
+
const m = /^\W*VERDICT:\W*(CLEAN|ISSUES)\W*$/i.exec(line.trim());
|
|
17
|
+
if (m)
|
|
18
|
+
return m[1].toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const key = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
23
|
+
// One key per changed line, repeated for repeated lines (a second "}" is a
|
|
24
|
+
// second key), so adding another copy of a reviewed line isn't "seen". A
|
|
25
|
+
// change with no lines (a binary file, a mode change, a pure rename) is keyed
|
|
26
|
+
// by its file and content hash.
|
|
27
|
+
export const lineKeys = (hunks) => hunks.flatMap((h) => {
|
|
28
|
+
const lines = bodyLines(h).filter((l) => l.idx !== null);
|
|
29
|
+
return lines.length ? lines.map((l) => key(`${h.file}\0${l.raw}`)) : [key(`meta\0${h.file}\0${h.hash}`)];
|
|
30
|
+
}).sort();
|
|
31
|
+
// A group's meaning, without its id or hunk references (which change when a
|
|
32
|
+
// gloss is carried or adopted) and without the notes carrying adds.
|
|
33
|
+
export function groupKey(g) {
|
|
34
|
+
const notes = (g.notes ?? '').replace(/\s*\((?:from [0-9a-f]{7,}|also served [^)]*)\)/g, '').trim();
|
|
35
|
+
const serves = (Array.isArray(g.serves) ? g.serves : [g.serves]).slice().sort();
|
|
36
|
+
return key(JSON.stringify([g.intent, g.necessity, serves, g.removal_impact, g.evidence ?? '', notes]));
|
|
37
|
+
}
|
|
38
|
+
// The gloss's parts a reviewer checks: each group, each risk and each
|
|
39
|
+
// rejected alternative (keyed one by one, so a squash that combines several
|
|
40
|
+
// glosses' risks still matches the reviews of each). The task and summary
|
|
41
|
+
// are left out: carrying joins them.
|
|
42
|
+
export function glossKeys(a) {
|
|
43
|
+
return [
|
|
44
|
+
...a.groups.map(groupKey),
|
|
45
|
+
...(a.risks ?? []).map((r) => key(`risk\0${r}`)),
|
|
46
|
+
...(a.alternatives ?? []).map((x) => key(`alternative\0${x.option}\0${x.rejected_because}`)),
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
// What a reviewer of `sha` sees now: its changed lines and its gloss's groups.
|
|
50
|
+
export function stamp(sha) {
|
|
51
|
+
let groups = [];
|
|
52
|
+
try {
|
|
53
|
+
const note = readNote(sha);
|
|
54
|
+
groups = note ? glossKeys(note.annotation) : [];
|
|
55
|
+
}
|
|
56
|
+
catch { /* an unreadable gloss: nothing to stamp */ }
|
|
57
|
+
return { lines: lineKeys(parseDiff(commitDiff(sha))), groups };
|
|
58
|
+
}
|
|
59
|
+
export function readReviews(sha) {
|
|
60
|
+
const raw = gitTry(['notes', '--ref', REVIEWS_REF, 'show', sha]);
|
|
61
|
+
if (!raw)
|
|
62
|
+
return [];
|
|
63
|
+
// A rebase can join a note onto one with the same lines; each counts once.
|
|
64
|
+
return [...new Set(raw.split('\n'))].flatMap((line) => {
|
|
65
|
+
try {
|
|
66
|
+
const r = JSON.parse(line);
|
|
67
|
+
// Records arrive from other machines; skip any that aren't whole.
|
|
68
|
+
const ok = r?.review === 1 && Array.isArray(r.lines) && Array.isArray(r.groups) &&
|
|
69
|
+
['id', 'at', 'stage', 'agent', 'target', 'findings'].every((k) => typeof r[k] === 'string') &&
|
|
70
|
+
['model', 'mode'].every((k) => r[k] === undefined || typeof r[k] === 'string') &&
|
|
71
|
+
(r.verdict === undefined || r.verdict === 'clean' || r.verdict === 'issues');
|
|
72
|
+
return ok ? [r] : [];
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
// Adds records to a commit's reviews, keeping each line once. Several
|
|
80
|
+
// reviews can finish at once, so the write is a compare-and-swap: the new
|
|
81
|
+
// notes commit is built under a private ref, and the reviews ref moves to it
|
|
82
|
+
// only if nobody else moved it meanwhile; otherwise it starts again.
|
|
83
|
+
export function addReviews(sha, records) {
|
|
84
|
+
const mine = records.map((r) => JSON.stringify(r));
|
|
85
|
+
// git notes needs it under refs/notes; named so the refs/notes/gloss*
|
|
86
|
+
// fetch glob never matches it.
|
|
87
|
+
const tmp = `refs/notes/tmp-gloss-reviews-${process.pid}-${randomUUID().slice(0, 8)}`;
|
|
88
|
+
try {
|
|
89
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
90
|
+
const old = gitTry(['rev-parse', '-q', '--verify', REVIEWS_REF])?.trim() ?? null;
|
|
91
|
+
if (old)
|
|
92
|
+
git(['update-ref', tmp, old]);
|
|
93
|
+
else
|
|
94
|
+
gitTry(['update-ref', '-d', tmp]);
|
|
95
|
+
const existing = (gitTry(['notes', '--ref', tmp, 'show', sha]) ?? '').split('\n').filter(Boolean);
|
|
96
|
+
const lines = [...new Set([...existing, ...mine])].sort();
|
|
97
|
+
git(['notes', '--ref', tmp, 'add', '-f', '-F', '-', sha], lines.join('\n') + '\n');
|
|
98
|
+
const next = git(['rev-parse', tmp]).trim();
|
|
99
|
+
// An empty old value means "must not exist yet".
|
|
100
|
+
if (gitTry(['update-ref', REVIEWS_REF, next, old ?? '']) !== null)
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
throw new GlossError(`couldn't record the review on ${sha.slice(0, 10)}: other processes kept writing reviews at the same time`);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
gitTry(['update-ref', '-d', tmp]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Reviews follow a gloss onto commits made from the reviewed ones (adopt,
|
|
110
|
+
// carry); their keys decide what they still cover there.
|
|
111
|
+
export function copyReviews(from, to) {
|
|
112
|
+
const records = from.flatMap(readReviews);
|
|
113
|
+
if (records.length)
|
|
114
|
+
addReviews(to, records);
|
|
115
|
+
}
|
|
116
|
+
// Whether reviews saw every changed line, counting repeats: each distinct
|
|
117
|
+
// stamp (reviews of different commits, as for a squash) contributes its
|
|
118
|
+
// lines; reviews of the same stamp count once.
|
|
119
|
+
function covers(reviews, lines) {
|
|
120
|
+
const seen = new Map();
|
|
121
|
+
const stamps = new Set();
|
|
122
|
+
for (const r of reviews) {
|
|
123
|
+
const id = r.lines.join(',');
|
|
124
|
+
if (stamps.has(id))
|
|
125
|
+
continue;
|
|
126
|
+
stamps.add(id);
|
|
127
|
+
const counts = new Map();
|
|
128
|
+
for (const l of r.lines)
|
|
129
|
+
counts.set(l, (counts.get(l) ?? 0) + 1);
|
|
130
|
+
for (const [l, n] of counts)
|
|
131
|
+
seen.set(l, (seen.get(l) ?? 0) + n);
|
|
132
|
+
}
|
|
133
|
+
const need = new Map();
|
|
134
|
+
for (const l of lines)
|
|
135
|
+
need.set(l, (need.get(l) ?? 0) + 1);
|
|
136
|
+
return [...need].every(([l, n]) => (seen.get(l) ?? 0) >= n);
|
|
137
|
+
}
|
|
138
|
+
// Per stage: whether the commit as it is now was reviewed. A commit squashed
|
|
139
|
+
// from several reviewed ones is covered when their reviews together saw all
|
|
140
|
+
// of it.
|
|
141
|
+
export function reviewStatus(sha, current = stamp(sha)) {
|
|
142
|
+
const byStage = new Map();
|
|
143
|
+
for (const r of readReviews(sha))
|
|
144
|
+
byStage.set(r.stage, [...(byStage.get(r.stage) ?? []), r]);
|
|
145
|
+
return [...byStage].map(([stage, reviews]) => {
|
|
146
|
+
const groups = new Set(reviews.flatMap((r) => r.groups));
|
|
147
|
+
return {
|
|
148
|
+
stage,
|
|
149
|
+
now: current,
|
|
150
|
+
reviews: reviews.sort((a, b) => b.at.localeCompare(a.at)),
|
|
151
|
+
code: covers(reviews, current.lines) ? 'seen' : 'changed',
|
|
152
|
+
gloss: current.groups.length ? (current.groups.every((g) => groups.has(g)) ? 'seen' : 'changed') : null,
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
// Whether this stage's reviews still apply to the commit as it is now.
|
|
157
|
+
export const current = (s) => s.code === 'seen' && !((s.stage === 'honesty' || s.stage === 'improve') && s.gloss === 'changed');
|
|
158
|
+
// What the reviews concluded about the commit as it is now. Each part of it
|
|
159
|
+
// (a gloss group for honesty and improve, else a changed line) takes the
|
|
160
|
+
// verdict of the latest review that saw it, so fixing a gloss and having it
|
|
161
|
+
// reviewed again replaces the earlier verdict for the parts it saw. Issues
|
|
162
|
+
// if any part's latest review found some; clean if every part's said clean.
|
|
163
|
+
export function verdict(s) {
|
|
164
|
+
const v = latestVerdicts(s);
|
|
165
|
+
return v.includes('issues') ? 'issues' : v.length && v.every((x) => x === 'clean') ? 'clean' : 'unknown';
|
|
166
|
+
}
|
|
167
|
+
function latestVerdicts(s) {
|
|
168
|
+
const byGroups = (s.stage === 'honesty' || s.stage === 'improve') && s.now.groups.length;
|
|
169
|
+
const parts = byGroups ? s.now.groups : s.now.lines;
|
|
170
|
+
// s.reviews is newest first.
|
|
171
|
+
const latest = new Set();
|
|
172
|
+
for (const part of parts) {
|
|
173
|
+
// A review without a verdict line says nothing either way, so it doesn't
|
|
174
|
+
// replace an earlier one's.
|
|
175
|
+
const r = s.reviews.find((r) => r.verdict && (byGroups ? r.groups : r.lines).includes(part));
|
|
176
|
+
if (r)
|
|
177
|
+
latest.add(r);
|
|
178
|
+
}
|
|
179
|
+
return [...latest].map((r) => r.verdict);
|
|
180
|
+
}
|
|
181
|
+
// Whether a review older than the ones deciding the verdict found issues:
|
|
182
|
+
// a clean result is then shown as following an earlier one with issues.
|
|
183
|
+
export const earlierIssues = (s) => verdict(s) !== 'issues' && s.reviews.some((r) => r.verdict === 'issues');
|
|
184
|
+
// What a verdict means for each stage: improve's "issues" are suggestions.
|
|
185
|
+
const WORDS = {
|
|
186
|
+
bugs: { clean: 'no defects found', issues: 'defects found' },
|
|
187
|
+
improve: { clean: 'nothing to improve', issues: 'suggestions' },
|
|
188
|
+
default: { clean: 'no issues', issues: 'issues found' },
|
|
189
|
+
};
|
|
190
|
+
// One short phrase per stage, for summaries: "honesty: issues found (claude,
|
|
191
|
+
// 2026-10-18)". A review says the commit was checked, not that it passed.
|
|
192
|
+
export function statusPhrase(s) {
|
|
193
|
+
const latest = s.reviews[0];
|
|
194
|
+
const who = `${latest.agent}${latest.model ? `:${latest.model}` : ''}, ${latest.at.slice(0, 10)}${s.reviews.length > 1 ? `, ${s.reviews.length} reviews` : ''}`;
|
|
195
|
+
if (s.code === 'changed')
|
|
196
|
+
return `${s.stage}: code changed since review (${who})`;
|
|
197
|
+
if (!current(s)) {
|
|
198
|
+
const never = s.reviews.every((r) => !r.groups.length);
|
|
199
|
+
return `${s.stage}: ${never ? 'reviewed before it was glossed' : 'gloss changed since review'} (${who})`;
|
|
200
|
+
}
|
|
201
|
+
const v = verdict(s);
|
|
202
|
+
const words = WORDS[s.stage] ?? WORDS.default;
|
|
203
|
+
return `${s.stage}: ${v === 'clean' ? words.clean : v === 'issues' ? words.issues : 'reviewed'} (${who}` +
|
|
204
|
+
`${earlierIssues(s) ? '; an earlier review found issues' : ''})`;
|
|
205
|
+
}
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { REF_PATTERN } from './selection.js';
|
|
3
|
+
// In the order `show` lists groups: what the task needs, then what a reviewer
|
|
4
|
+
// might cut, then edits with no design content.
|
|
5
|
+
export const NECESSITY = [
|
|
6
|
+
'required',
|
|
7
|
+
'supporting',
|
|
8
|
+
'defensive',
|
|
9
|
+
'incidental',
|
|
10
|
+
'speculative',
|
|
11
|
+
'mechanical',
|
|
12
|
+
];
|
|
13
|
+
export const NECESSITY_HELP = {
|
|
14
|
+
required: 'The task cannot be done without this change',
|
|
15
|
+
supporting: 'Needed only because other groups need it (name them in `serves`)',
|
|
16
|
+
mechanical: 'Follow-on edits with no design content: renames, imports, formatting, lockfiles',
|
|
17
|
+
incidental: 'Drive-by cleanup or refactor the task did not ask for',
|
|
18
|
+
defensive: 'Guards edge cases or failures nobody asked about',
|
|
19
|
+
speculative: 'Added because it might be useful later',
|
|
20
|
+
};
|
|
21
|
+
export const Group = z.object({
|
|
22
|
+
id: z
|
|
23
|
+
.string()
|
|
24
|
+
.regex(/^[a-z0-9][a-z0-9-]*$/, 'use lowercase kebab-case')
|
|
25
|
+
.describe('Short unique id for this group, e.g. "retry-core"'),
|
|
26
|
+
intent: z.string().min(1).describe('What this group of changes does, in one short sentence (under ~80 characters)'),
|
|
27
|
+
necessity: z.enum(NECESSITY).describe('How necessary the change is to the task'),
|
|
28
|
+
serves: z
|
|
29
|
+
.union([z.string(), z.array(z.string()).min(1)])
|
|
30
|
+
.describe('"task" if it serves the task directly, otherwise the id of the group it supports, or a list of ids when it supports several (e.g. tests)'),
|
|
31
|
+
removal_impact: z
|
|
32
|
+
.string()
|
|
33
|
+
.min(1)
|
|
34
|
+
.describe('What concretely breaks or is lost if this group is removed'),
|
|
35
|
+
evidence: z
|
|
36
|
+
.string()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe('A test, requirement or task line that demonstrates the need'),
|
|
39
|
+
hunks: z
|
|
40
|
+
.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
|
+
.min(1)
|
|
42
|
+
.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
|
+
notes: z.string().optional(),
|
|
44
|
+
});
|
|
45
|
+
export const Annotation = z.object({
|
|
46
|
+
version: z.literal(1).default(1),
|
|
47
|
+
task: z.string().min(1).describe('The task or request these changes were made for'),
|
|
48
|
+
summary: z.string().optional().describe('One or two sentences on the overall change'),
|
|
49
|
+
groups: z.array(Group).min(1),
|
|
50
|
+
alternatives: z
|
|
51
|
+
.array(z.object({ option: z.string(), rejected_because: z.string() }))
|
|
52
|
+
.optional()
|
|
53
|
+
.describe('Approaches considered and why they were not taken'),
|
|
54
|
+
risks: z.array(z.string()).optional().describe('Known risks or open questions'),
|
|
55
|
+
author: z
|
|
56
|
+
.object({
|
|
57
|
+
agent: z.string().optional(),
|
|
58
|
+
model: z.string().optional(),
|
|
59
|
+
session: z.string().optional(),
|
|
60
|
+
})
|
|
61
|
+
.optional(),
|
|
62
|
+
});
|
|
63
|
+
// A group's serves targets as a list, whichever form the gloss used.
|
|
64
|
+
export const servesOf = (g) => (Array.isArray(g.serves) ? g.serves : [g.serves]);
|
|
65
|
+
// 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,
|
|
67
|
+
// so older readers only refuse notes that use something they can't read.
|
|
68
|
+
export const NOTE_FORMAT = 3;
|
|
69
|
+
export function noteFormat(a) {
|
|
70
|
+
if (a.groups.some((g) => servesOf(g).length > 1))
|
|
71
|
+
return 3;
|
|
72
|
+
if (a.groups.some((g) => g.hunks.some((ref) => ref.includes(':'))))
|
|
73
|
+
return 2;
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
export const REF_PATTERN = /^(h\d+)(?::(\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*))?$/;
|
|
2
|
+
export function parseRef(ref) {
|
|
3
|
+
const m = REF_PATTERN.exec(ref);
|
|
4
|
+
if (!m)
|
|
5
|
+
return null;
|
|
6
|
+
if (!m[2])
|
|
7
|
+
return { hunk: m[1], ranges: null };
|
|
8
|
+
const ranges = m[2].split(',').map((part) => {
|
|
9
|
+
const [a, b] = part.split('-').map(Number);
|
|
10
|
+
return [a, b ?? a];
|
|
11
|
+
});
|
|
12
|
+
return { hunk: m[1], ranges };
|
|
13
|
+
}
|
|
14
|
+
export function formatRanges(lines) {
|
|
15
|
+
const sorted = [...new Set(lines)].sort((x, y) => x - y);
|
|
16
|
+
const parts = [];
|
|
17
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
18
|
+
let j = i;
|
|
19
|
+
while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1)
|
|
20
|
+
j++;
|
|
21
|
+
parts.push(i === j ? `${sorted[i]}` : `${sorted[i]}-${sorted[j]}`);
|
|
22
|
+
i = j;
|
|
23
|
+
}
|
|
24
|
+
return parts.join(',');
|
|
25
|
+
}
|
|
26
|
+
export const refLabel = (id, lines) => (lines ? `${id}:${formatRanges(lines)}` : id);
|
|
27
|
+
const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
|
|
28
|
+
// Parsed bodies are cached per hunk: callers ask for the same hunk's lines
|
|
29
|
+
// many times (counts, slices, coverage), and re-parsing made splitting a large
|
|
30
|
+
// hunk quadratic.
|
|
31
|
+
const parsed = new WeakMap();
|
|
32
|
+
// The hunk's lines after its @@ header, with changed-line numbers and file
|
|
33
|
+
// line numbers. A header-only change (binary, rename) is all 'meta'.
|
|
34
|
+
export function bodyLines(h) {
|
|
35
|
+
let lines = parsed.get(h);
|
|
36
|
+
if (!lines)
|
|
37
|
+
parsed.set(h, (lines = parseBody(h)));
|
|
38
|
+
return lines;
|
|
39
|
+
}
|
|
40
|
+
function parseBody(h) {
|
|
41
|
+
const [header, ...body] = h.text.split('\n');
|
|
42
|
+
const m = HUNK_HEADER.exec(header);
|
|
43
|
+
if (!m)
|
|
44
|
+
return h.text.split('\n').map((raw) => ({ raw, kind: 'meta', idx: null, oldNo: null, newNo: null }));
|
|
45
|
+
let oldNo = Number(m[1]), newNo = Number(m[2]), idx = 0;
|
|
46
|
+
return body.map((raw) => {
|
|
47
|
+
if (raw[0] === '+')
|
|
48
|
+
return { raw, kind: 'add', idx: ++idx, oldNo: null, newNo: newNo++ };
|
|
49
|
+
if (raw[0] === '-')
|
|
50
|
+
return { raw, kind: 'del', idx: ++idx, oldNo: oldNo++, newNo: null };
|
|
51
|
+
if (raw[0] === '\\')
|
|
52
|
+
return { raw, kind: 'meta', idx: null, oldNo: null, newNo: null };
|
|
53
|
+
return { raw, kind: 'ctx', idx: null, oldNo: oldNo++, newNo: newNo++ };
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export const changedCount = (h) => bodyLines(h).filter((l) => l.idx !== null).length;
|
|
57
|
+
export const isGap = (l) => 'gap' in l;
|
|
58
|
+
// The part of a hunk a selection covers: selected changed lines, context
|
|
59
|
+
// within `context` lines of them, and a gap marker for each run of changed
|
|
60
|
+
// lines that belong elsewhere. A null selection is the whole hunk.
|
|
61
|
+
export function slice(lines, selected, context = 3) {
|
|
62
|
+
if (!selected)
|
|
63
|
+
return lines;
|
|
64
|
+
const keep = lines.map((l) => l.idx !== null && selected.has(l.idx));
|
|
65
|
+
const near = lines.map((_, i) => keep.slice(Math.max(0, i - context), i + context + 1).some(Boolean));
|
|
66
|
+
const out = [];
|
|
67
|
+
lines.forEach((l, i) => {
|
|
68
|
+
if (keep[i] || (l.kind === 'meta' && near[i]))
|
|
69
|
+
out.push(l);
|
|
70
|
+
else if (l.idx !== null) {
|
|
71
|
+
const last = out[out.length - 1];
|
|
72
|
+
if (last && isGap(last))
|
|
73
|
+
last.gap++;
|
|
74
|
+
else
|
|
75
|
+
out.push({ gap: 1 });
|
|
76
|
+
}
|
|
77
|
+
else if (l.kind === 'ctx' && near[i])
|
|
78
|
+
out.push(l);
|
|
79
|
+
});
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
// Lines a group's view leaves out: claimed by other groups, or (under --partial) by none.
|
|
83
|
+
export const gapText = (n) => `⋯ ${n} changed line${n === 1 ? '' : 's'} not in this group`;
|
|
84
|
+
// Text form of a slice for terminal and prompt output: the @@ header, kept
|
|
85
|
+
// lines verbatim, gaps as marker lines that can't be read as diff lines.
|
|
86
|
+
export function sliceText(h, selected) {
|
|
87
|
+
if (!selected)
|
|
88
|
+
return h.text;
|
|
89
|
+
const header = h.text.split('\n')[0];
|
|
90
|
+
return [header, ...slice(bodyLines(h), selected).map((l) => (isGap(l) ? gapText(l.gap) : l.raw))].join('\n');
|
|
91
|
+
}
|
|
92
|
+
export function sliceStats(h, selected) {
|
|
93
|
+
if (!selected)
|
|
94
|
+
return { added: h.added, removed: h.removed };
|
|
95
|
+
const lines = bodyLines(h).filter((l) => l.idx !== null && selected.has(l.idx));
|
|
96
|
+
return { added: lines.filter((l) => l.kind === 'add').length, removed: lines.filter((l) => l.kind === 'del').length };
|
|
97
|
+
}
|
|
98
|
+
// One selection per hunk for a group's refs, in order of first mention. A
|
|
99
|
+
// whole-hunk ref wins over ranges for the same hunk. Ranges are clamped to the
|
|
100
|
+
// hunk's changed-line count, so an oversized range (from a typo, a stale or a
|
|
101
|
+
// hand-edited note) can't build an enormous set; refs to unknown hunks keep
|
|
102
|
+
// an empty selection.
|
|
103
|
+
export function selections(refs, changedOf) {
|
|
104
|
+
const out = new Map();
|
|
105
|
+
for (const ref of refs) {
|
|
106
|
+
const r = parseRef(ref);
|
|
107
|
+
if (!r)
|
|
108
|
+
continue;
|
|
109
|
+
const prev = out.get(r.hunk);
|
|
110
|
+
if (prev === null)
|
|
111
|
+
continue;
|
|
112
|
+
if (!r.ranges) {
|
|
113
|
+
out.set(r.hunk, null);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const set = prev ?? new Set();
|
|
117
|
+
const n = changedOf(r.hunk) ?? 0;
|
|
118
|
+
for (const [a, b] of r.ranges)
|
|
119
|
+
for (let i = Math.max(1, a); i <= Math.min(b, n); i++)
|
|
120
|
+
set.add(i);
|
|
121
|
+
out.set(r.hunk, set);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
// Changed lines that no claim covers. A hunk with no claims at all is
|
|
126
|
+
// uncovered whole (lines: null); a header-only hunk is covered by any claim.
|
|
127
|
+
export function uncovered(hunks, claims) {
|
|
128
|
+
const out = [];
|
|
129
|
+
for (const h of hunks) {
|
|
130
|
+
const mine = claims.filter((c) => c.hunk === h);
|
|
131
|
+
if (!mine.length) {
|
|
132
|
+
out.push({ hunk: h, lines: null });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (mine.some((c) => c.lines === null))
|
|
136
|
+
continue;
|
|
137
|
+
const covered = new Set(mine.flatMap((c) => [...c.lines]));
|
|
138
|
+
const missing = new Set();
|
|
139
|
+
const n = changedCount(h);
|
|
140
|
+
for (let i = 1; i <= n; i++)
|
|
141
|
+
if (!covered.has(i))
|
|
142
|
+
missing.add(i);
|
|
143
|
+
if (missing.size)
|
|
144
|
+
out.push({ hunk: h, lines: missing });
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { servesOf } from './schema.js';
|
|
2
|
+
import { changedCount, parseRef, refLabel, selections, uncovered } from './selection.js';
|
|
3
|
+
export function validate(annotation, hunks) {
|
|
4
|
+
const errors = [];
|
|
5
|
+
const groupIds = new Set();
|
|
6
|
+
for (const g of annotation.groups) {
|
|
7
|
+
if (groupIds.has(g.id))
|
|
8
|
+
errors.push(`duplicate group id "${g.id}"`);
|
|
9
|
+
groupIds.add(g.id);
|
|
10
|
+
}
|
|
11
|
+
const byHunkId = new Map(hunks.map((h) => [h.id, h]));
|
|
12
|
+
const claims = [];
|
|
13
|
+
for (const g of annotation.groups) {
|
|
14
|
+
for (const ref of g.hunks) {
|
|
15
|
+
const r = parseRef(ref);
|
|
16
|
+
const h = byHunkId.get(r.hunk);
|
|
17
|
+
if (!h) {
|
|
18
|
+
errors.push(`group "${g.id}" references unknown hunk "${r.hunk}"`);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const n = changedCount(h);
|
|
22
|
+
if (r.ranges && n === 0)
|
|
23
|
+
errors.push(`group "${g.id}": ${r.hunk} has no changed lines to select; reference it whole`);
|
|
24
|
+
else if (r.ranges?.some(([a, b]) => b < a)) {
|
|
25
|
+
errors.push(`group "${g.id}": "${ref}" runs backwards; write ranges low to high, e.g. ${r.hunk}:3-5`);
|
|
26
|
+
}
|
|
27
|
+
else if (r.ranges?.some(([a, b]) => a < 1 || b > n)) {
|
|
28
|
+
errors.push(`group "${g.id}": "${ref}" is out of range; ${r.hunk} has changed lines 1-${n}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
for (const [id, lines] of selections(g.hunks, (id) => { const h = byHunkId.get(id); return h && changedCount(h); })) {
|
|
32
|
+
const h = byHunkId.get(id);
|
|
33
|
+
if (h)
|
|
34
|
+
claims.push({ hunk: h, lines });
|
|
35
|
+
}
|
|
36
|
+
const targets = servesOf(g);
|
|
37
|
+
for (const t of targets) {
|
|
38
|
+
if (t !== 'task' && !groupIds.has(t))
|
|
39
|
+
errors.push(`group "${g.id}" serves unknown group "${t}" (use "task" or group ids)`);
|
|
40
|
+
if (t === g.id)
|
|
41
|
+
errors.push(`group "${g.id}" cannot serve itself`);
|
|
42
|
+
}
|
|
43
|
+
if (targets.length > 1 && targets.includes('task')) {
|
|
44
|
+
errors.push(`group "${g.id}" serves "task" and groups; use "task" alone or list only groups`);
|
|
45
|
+
}
|
|
46
|
+
if (new Set(targets).size < targets.length)
|
|
47
|
+
errors.push(`group "${g.id}" lists a serves target twice`);
|
|
48
|
+
if (g.necessity === 'supporting' && targets.includes('task')) {
|
|
49
|
+
errors.push(`group "${g.id}" is "supporting" but serves "task"; name the group(s) it supports`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// A group is in a cycle if it can reach itself by following serves links.
|
|
53
|
+
const byId = new Map(annotation.groups.map((g) => [g.id, g]));
|
|
54
|
+
for (const g of annotation.groups) {
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
const queue = servesOf(g).filter((t) => t !== 'task');
|
|
57
|
+
while (queue.length) {
|
|
58
|
+
const id = queue.shift();
|
|
59
|
+
if (id === g.id) {
|
|
60
|
+
errors.push(`group "${g.id}" is in a serves cycle; every chain must end at "task"`);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
if (seen.has(id))
|
|
64
|
+
continue;
|
|
65
|
+
seen.add(id);
|
|
66
|
+
queue.push(...servesOf(byId.get(id) ?? { serves: 'task' }).filter((t) => t !== 'task'));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { errors, uncovered: uncovered(hunks, claims).map((u) => refLabel(u.hunk.id, u.lines)) };
|
|
70
|
+
}
|
|
71
|
+
// The note a carried group gets when groups it served didn't come along, so
|
|
72
|
+
// it serves the task instead; carriedErrors recognises it by this prefix.
|
|
73
|
+
const ALSO_SERVED = '(also served ';
|
|
74
|
+
export const alsoServed = (lost, why) => `${ALSO_SERVED}${lost.join(', ')}, ${why})`;
|
|
75
|
+
// Errors in a gloss moved onto another commit (by adopt or carry). A group
|
|
76
|
+
// whose supported groups didn't come along serves the task instead, with
|
|
77
|
+
// "(also served …)" in its notes; that isn't an error for such a group, but
|
|
78
|
+
// is for any other.
|
|
79
|
+
export function carriedErrors(annotation, hunks) {
|
|
80
|
+
const repointed = new Set(annotation.groups.filter((g) => g.notes?.includes(ALSO_SERVED)).map((g) => g.id));
|
|
81
|
+
return validate(annotation, hunks).errors.filter((e) => {
|
|
82
|
+
const m = /^group "([^"]+)" is "supporting" but serves "task"/.exec(e);
|
|
83
|
+
return !(m && repointed.has(m[1]));
|
|
84
|
+
});
|
|
85
|
+
}
|