@ok-ggwp/git-gloss 0.0.0-stage → 0.1.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.
@@ -0,0 +1,156 @@
1
+ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
2
+ import { commitDiff, diffBetween, EMPTY_TREE, git, gitTry, NOTES_REF } from './git.js';
3
+ import { parseDiff } from './diff.js';
4
+ import { bodyLines } from './selection.js';
5
+ // Commits that have a gloss and are in this repository (a teammate's gloss
6
+ // can arrive before, or without, the commit it describes).
7
+ export function glossedCommits() {
8
+ const shas = (gitTry(['notes', '--ref', NOTES_REF, 'list']) ?? '').split('\n').filter(Boolean).map((l) => l.split(' ')[1]);
9
+ if (!shas.length)
10
+ return [];
11
+ return git(['cat-file', '--batch-check=%(objectname) %(objecttype)'], shas.join('\n') + '\n')
12
+ .split('\n').filter((l) => l.endsWith(' commit')).map((l) => l.split(' ')[0]);
13
+ }
14
+ const diffs = new Map();
15
+ const diffOf = (sha) => {
16
+ let d = diffs.get(sha);
17
+ if (!d)
18
+ diffs.set(sha, (d = parseDiff(commitDiff(sha))));
19
+ return d;
20
+ };
21
+ // git's patch-id: the same for two commits that make the same changes,
22
+ // wherever they sit in history.
23
+ function patchId(sha) {
24
+ const diff = gitTry(['show', '--format=', '--no-color', '--no-ext-diff', sha]);
25
+ return diff ? git(['patch-id', '--stable'], diff).split(' ')[0] || null : null;
26
+ }
27
+ // Where a commit's changes came from, among commits with a gloss:
28
+ // 1. its message: "(cherry picked from commit X)" (cherry-pick -x), or the
29
+ // "Squashed commit of the following:" list git merge --squash writes;
30
+ // 2. a commit with the same author, author date and patch-id: a cherry-pick
31
+ // or a rebase (both keep the author date), including GitHub's
32
+ // "Rebase and merge";
33
+ // 3. with `squash`, a branch not merged into the commit whose combined diff is
34
+ // at least 80% the same as the commit's: a squash merge, including
35
+ // GitHub's "Squash and merge", whatever its message says.
36
+ // Returns the source commits, oldest first, or null.
37
+ export function findOrigins(target, opts = {}) {
38
+ const glossed = new Set(opts.glossed ?? glossedCommits());
39
+ glossed.delete(target);
40
+ if (!glossed.size)
41
+ return null;
42
+ const message = git(['log', '-1', '--format=%B', target]);
43
+ const named = [
44
+ ...[...message.matchAll(/\(cherry picked from commit ([0-9a-f]{7,64})\)/g)].map((m) => m[1]),
45
+ ...(/^Squashed commit of the following:/m.test(message) ? [...message.matchAll(/^commit ([0-9a-f]{40,64})$/gm)].map((m) => m[1]) : []),
46
+ ].map((s) => gitTry(['rev-parse', '-q', '--verify', `${s}^{commit}`])?.trim()).filter((s) => !!s && glossed.has(s));
47
+ // git lists squashed commits newest first.
48
+ if (named.length)
49
+ return [...new Set(named)].reverse();
50
+ const ident = (sha) => git(['log', '-1', '--format=%an%x00%ae%x00%ad', '--date=raw', sha]).trim();
51
+ const mine = ident(target);
52
+ const same = git(['log', '--no-walk=unsorted', '--stdin', '--format=%H%x00%an%x00%ae%x00%ad', '--date=raw'], [...glossed].join('\n'))
53
+ .split('\n').filter(Boolean).filter((l) => l.slice(l.indexOf('\0') + 1) === mine).map((l) => l.slice(0, l.indexOf('\0')));
54
+ if (same.length) {
55
+ const id = patchId(target);
56
+ const match = id && same.find((s) => patchId(s) === id);
57
+ if (match)
58
+ return [match];
59
+ }
60
+ if (!opts.squash)
61
+ return null;
62
+ return squashSources(target, [...glossed]);
63
+ }
64
+ // Changed lines as a multiset of "file, +/- text".
65
+ function changedLines(hunks) {
66
+ const out = new Map();
67
+ for (const h of hunks)
68
+ for (const l of bodyLines(h)) {
69
+ if (l.idx === null)
70
+ continue;
71
+ const key = `${h.file}\0${l.raw}`;
72
+ out.set(key, (out.get(key) ?? 0) + 1);
73
+ }
74
+ return out;
75
+ }
76
+ // How alike two diffs are: the changed lines they share, as a share of the
77
+ // larger one. A squash's diff is (nearly) its branch's combined diff, so this
78
+ // is near 1; an unrelated commit that happens to repeat a few of a branch's
79
+ // lines scores low, however small it is.
80
+ function similarity(a, b) {
81
+ const [x, y] = [changedLines(a), changedLines(b)];
82
+ let shared = 0, nx = 0, ny = 0;
83
+ for (const n of x.values())
84
+ nx += n;
85
+ for (const n of y.values())
86
+ ny += n;
87
+ for (const [k, n] of x)
88
+ shared += Math.min(n, y.get(k) ?? 0);
89
+ return Math.max(nx, ny) ? shared / Math.max(nx, ny) : 0;
90
+ }
91
+ // The unmerged branch the target is a squash of: the one whose combined
92
+ // diff (from where it left the target's parent to its tip) is at least 80%
93
+ // the same as the target's, best first.
94
+ function squashSources(target, glossed) {
95
+ const parent = gitTry(['rev-parse', '-q', '--verify', `${target}^1`])?.trim();
96
+ if (!parent)
97
+ return null;
98
+ // Branch history the target doesn't contain, from glossed commits and
99
+ // branch heads; its tips are the candidate squashed branches.
100
+ const heads = (gitTry(['for-each-ref', '--format=%(objectname)', 'refs/heads/', 'refs/remotes/']) ?? '').split('\n').filter(Boolean);
101
+ const when = Number(git(['log', '-1', '--format=%ct', target]).trim());
102
+ const rows = git(['rev-list', '--parents', ...new Set([...glossed, ...heads]), '--not', target, '--'])
103
+ .split('\n').filter(Boolean).map((l) => l.split(' '));
104
+ const hasChild = new Set(rows.flatMap(([, ...parents]) => parents));
105
+ const tips = rows.map(([c]) => c).filter((c) => !hasChild.has(c))
106
+ .filter((c) => Number(git(['log', '-1', '--format=%ct', c]).trim()) <= when);
107
+ const glossedSet = new Set(glossed);
108
+ const mine = diffOf(target);
109
+ let best = null;
110
+ for (const tip of tips) {
111
+ const base = gitTry(['merge-base', tip, parent])?.trim();
112
+ const shas = git(['rev-list', '--reverse', '--no-merges', base ? `${base}..${tip}` : tip, '--']).split('\n').filter((s) => glossedSet.has(s));
113
+ if (!shas.length)
114
+ continue;
115
+ const score = similarity(mine, parseDiff(diffBetween(base || EMPTY_TREE, tip)));
116
+ if (score >= 0.8 && (!best || score > best.score))
117
+ best = { sources: shas, score };
118
+ }
119
+ return best?.sources ?? null;
120
+ }
121
+ // Unglossed commits on `starts` (a remote's branches, commits being pushed)
122
+ // that may have been made from glossed ones by a squash or rebase merge, a
123
+ // cherry-pick: each is glossed from its origins when they are found. Checks
124
+ // are remembered in .git/gloss-checked with how many glossed commits there
125
+ // were, so a commit is checked again only once more glosses have arrived (its
126
+ // origins' glosses may come later, from a teammate). Returns what was carried.
127
+ export function carryNew(starts, carry) {
128
+ if (!starts.length)
129
+ return [];
130
+ const glossed = glossedCommits();
131
+ if (!glossed.length)
132
+ return [];
133
+ const memo = git(['rev-parse', '--git-path', 'gloss-checked']).trim();
134
+ const checked = new Map((existsSync(memo) ? readFileSync(memo, 'utf8').split('\n').filter(Boolean) : [])
135
+ .map((l) => { const [sha, n] = l.split(' '); return [sha, Number(n) || 0]; }));
136
+ // A commit made from glossed ones is newer than the oldest of them.
137
+ const oldest = Math.min(...git(['log', '--no-walk=unsorted', '--stdin', '--format=%ct'], glossed.join('\n'))
138
+ .split('\n').filter(Boolean).map(Number));
139
+ const glossedSet = new Set(glossed);
140
+ const targets = git(['rev-list', '--no-merges', `--since=${oldest}`, ...starts, '--']).split('\n')
141
+ .filter((s) => s && !glossedSet.has(s) && (checked.get(s) ?? -1) < glossed.length);
142
+ const out = [];
143
+ for (const target of targets.reverse()) {
144
+ const origins = findOrigins(target, { squash: true, glossed });
145
+ if (origins) {
146
+ const res = carry(origins, target);
147
+ if (res.written) {
148
+ out.push(res.message);
149
+ glossed.push(target);
150
+ glossedSet.add(target);
151
+ }
152
+ }
153
+ appendFileSync(memo, `${target} ${glossed.length}\n`);
154
+ }
155
+ return out;
156
+ }
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/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
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@ok-ggwp/git-gloss",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Annotate agent-written diffs with intent and necessity, stored in git notes",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "git",
8
+ "notes",
9
+ "code-review",
10
+ "ai",
11
+ "agents",
12
+ "annotations"
13
+ ],
14
+ "homepage": "https://github.com/ok-ggwp/git-gloss#readme",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/ok-ggwp/git-gloss.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/ok-ggwp/git-gloss/issues"
21
+ },
22
+ "type": "module",
23
+ "bin": {
24
+ "git-gloss": "bin/git-gloss.js"
25
+ },
26
+ "files": [
27
+ "bin",
28
+ "dist",
29
+ "skills",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "engines": {
34
+ "node": ">=20.12"
35
+ },
36
+ "scripts": {
37
+ "build": "node scripts/build.mjs",
38
+ "prepare": "npm run build",
39
+ "test": "node --test 'test/**/*.test.ts'",
40
+ "typecheck": "tsc"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "dependencies": {
46
+ "zod": "^4.6.7"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^26.6.5",
50
+ "typescript": "^7.0.2"
51
+ }
52
+ }