@ok-ggwp/git-gloss 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -2
- package/dist/attention.js +34 -0
- package/dist/carry.js +4 -1
- package/dist/cli.js +2 -1
- package/dist/commands/add.js +17 -1
- package/dist/commands/hunks.js +8 -1
- package/dist/commands/show.js +44 -16
- package/dist/commands/signals.js +88 -0
- package/dist/config.js +192 -0
- package/dist/floor.js +148 -0
- package/dist/git.js +11 -1
- package/dist/glob.js +76 -0
- package/dist/html/client.js +1016 -296
- package/dist/html/data.js +26 -1
- package/dist/html/style.css +284 -164
- package/dist/loader.js +1 -1
- package/dist/merge.js +5 -0
- package/dist/render.js +51 -3
- package/dist/safe-match.js +28 -0
- package/dist/schema.js +46 -2
- package/dist/signals.js +292 -0
- package/dist/validate.js +6 -0
- package/package.json +1 -1
- package/skills/gloss/SKILL.md +27 -1
package/dist/render.js
CHANGED
|
@@ -2,7 +2,8 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { styleText } from 'node:util';
|
|
3
3
|
import { gitTry } from './git.js';
|
|
4
4
|
import { refLabel, sliceStats } from './selection.js';
|
|
5
|
-
import {
|
|
5
|
+
import { attentionRank } from './attention.js';
|
|
6
|
+
import { IMPACT, servesOf } from './schema.js';
|
|
6
7
|
export const style = (s, text) => styleText(s, text);
|
|
7
8
|
const NECESSITY_STYLE = {
|
|
8
9
|
required: 'green',
|
|
@@ -18,6 +19,50 @@ export function necessityTag(n) {
|
|
|
18
19
|
export function necessityWord(n, pad = 0) {
|
|
19
20
|
return style(NECESSITY_STYLE[n], n.padEnd(pad));
|
|
20
21
|
}
|
|
22
|
+
const IMPACT_STYLE = {
|
|
23
|
+
local: 'gray',
|
|
24
|
+
module: 'white',
|
|
25
|
+
shared: 'yellow',
|
|
26
|
+
contract: 'magenta',
|
|
27
|
+
sensitive: 'red',
|
|
28
|
+
};
|
|
29
|
+
// "[contract]", padded to `pad` columns.
|
|
30
|
+
export function impactTag(i, pad = 0) {
|
|
31
|
+
return style(['bold', IMPACT_STYLE[i]], `[${i}]`) + ' '.repeat(Math.max(0, pad - i.length - 2));
|
|
32
|
+
}
|
|
33
|
+
// "(contract)": an impact derived from the diff rather than declared.
|
|
34
|
+
export function derivedImpactTag(i, pad = 0) {
|
|
35
|
+
return style('dim', `(${i})`) + ' '.repeat(Math.max(0, pad - i.length - 2));
|
|
36
|
+
}
|
|
37
|
+
// Width of the widest impact tag, "[sensitive]".
|
|
38
|
+
export const IMPACT_W = Math.max(...IMPACT.map((i) => i.length)) + 2;
|
|
39
|
+
export const ATTENTION_STYLE = {
|
|
40
|
+
skim: 'gray',
|
|
41
|
+
look: 'cyan',
|
|
42
|
+
careful: 'yellow',
|
|
43
|
+
'must-read': 'red',
|
|
44
|
+
};
|
|
45
|
+
// Declared below the computed floor.
|
|
46
|
+
export const belowFloor = (a, view) => !!a && !!view && attentionRank(a.level) < attentionRank(view.floor);
|
|
47
|
+
// "attention: CAREFUL (declared) · floor: careful · focus: a, b" with the
|
|
48
|
+
// reasons beneath. Without a declared level but with signals: "LOOK (derived)"
|
|
49
|
+
// and why. "unrated" where nothing is declared and no signals could be computed.
|
|
50
|
+
export function attentionBlock(a, view) {
|
|
51
|
+
const level = (l) => style(['bold', ATTENTION_STYLE[l]], l.toUpperCase());
|
|
52
|
+
if (!a) {
|
|
53
|
+
if (!view)
|
|
54
|
+
return [style('dim', 'attention: unrated')];
|
|
55
|
+
const d = view.derived;
|
|
56
|
+
return [`attention: ${level(d.level)} ${style('dim', '(derived)')}` + (d.level === view.floor ? '' : ` · floor: ${view.floor}`),
|
|
57
|
+
...d.reasons.flatMap((r) => field('•', r))];
|
|
58
|
+
}
|
|
59
|
+
const head = `attention: ${level(a.level)}${view ? ' ' + style('dim', '(declared)') + ` · floor: ${view.floor}` : ''}` +
|
|
60
|
+
(a.focus?.length ? ` · focus: ${a.focus.join(', ')}` : '');
|
|
61
|
+
const below = belowFloor(a, view);
|
|
62
|
+
return [head, ...(a.reasons ?? []).flatMap((r) => field('•', r)),
|
|
63
|
+
...(below ? field('⚠', `declared ${a.level}, below the computed floor ${view.floor}` + (a.override_reason ? '' : ' (no override_reason)'), 2).map((l) => style('yellow', l)) : []),
|
|
64
|
+
...(a.override_reason ? field('override:', a.override_reason) : [])];
|
|
65
|
+
}
|
|
21
66
|
export const termWidth = () => Math.min(process.stdout.columns || 100, 120);
|
|
22
67
|
// Word-wrap plain text to `width` columns.
|
|
23
68
|
export function wrap(text, width) {
|
|
@@ -63,13 +108,16 @@ export function colorDiff(text, indent = '') {
|
|
|
63
108
|
.join('\n');
|
|
64
109
|
}
|
|
65
110
|
export function groupHeader(g) {
|
|
66
|
-
const
|
|
67
|
-
const
|
|
111
|
+
const impact = g.impact ? ` [${g.impact}]` : '';
|
|
112
|
+
const prefix = `■ [${g.necessity}]${impact} `;
|
|
113
|
+
const out = wrap(g.intent, termWidth() - prefix.length).map((l, i) => (i === 0 ? `${style('bold', '■')} ${necessityTag(g.necessity)}${g.impact ? ' ' + impactTag(g.impact) : ''} ` : ' '.repeat(prefix.length)) + style('bold', l));
|
|
68
114
|
out[out.length - 1] += ' ' + style('dim', `(${g.id})`);
|
|
69
115
|
const serves = servesOf(g);
|
|
70
116
|
if (serves[0] !== 'task')
|
|
71
117
|
out.push(...field('serves:', serves.join(', ')));
|
|
72
118
|
out.push(...field('if removed:', g.removal_impact));
|
|
119
|
+
if (g.impact_reason)
|
|
120
|
+
out.push(...field('impact:', g.impact_reason));
|
|
73
121
|
if (g.evidence)
|
|
74
122
|
out.push(...field('evidence:', g.evidence));
|
|
75
123
|
if (g.notes)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import vm from 'node:vm';
|
|
2
|
+
// Patterns come from a repo's config, so one may backtrack without end. Each
|
|
3
|
+
// batch of lines runs in a vm with a time budget; a pattern that exceeds it is
|
|
4
|
+
// reported once on stderr, and from then on it counts as matching every line:
|
|
5
|
+
// failing open would let a change pad a line to switch a trusted pattern off. Lines are never cut: a
|
|
6
|
+
// change controls its own lines, and padding one must not hide a hit.
|
|
7
|
+
const BUDGET_MS = 250;
|
|
8
|
+
const script = new vm.Script('texts.map((t) => re.test(t))');
|
|
9
|
+
// One context for all calls: creating one per call dominates the cost.
|
|
10
|
+
const ctx = vm.createContext({ re: null, texts: [] });
|
|
11
|
+
const skipped = new Set();
|
|
12
|
+
export function matchLines(re, texts) {
|
|
13
|
+
const key = `${re.source}/${re.flags}`;
|
|
14
|
+
if (skipped.has(key))
|
|
15
|
+
return texts.map(() => true);
|
|
16
|
+
ctx.re = re;
|
|
17
|
+
ctx.texts = texts;
|
|
18
|
+
try {
|
|
19
|
+
return script.runInContext(ctx, { timeout: BUDGET_MS });
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
if (err.code !== 'ERR_SCRIPT_EXECUTION_TIMEOUT')
|
|
23
|
+
throw err;
|
|
24
|
+
skipped.add(key);
|
|
25
|
+
console.error(`warning: pattern ${JSON.stringify(re.source)} took over ${BUDGET_MS}ms and is treated as matching every line from now on (catastrophic backtracking, or a padded line?)`);
|
|
26
|
+
return texts.map(() => true);
|
|
27
|
+
}
|
|
28
|
+
}
|
package/dist/schema.js
CHANGED
|
@@ -10,6 +10,7 @@ export const NECESSITY = [
|
|
|
10
10
|
'speculative',
|
|
11
11
|
'mechanical',
|
|
12
12
|
];
|
|
13
|
+
export const isNecessity = (s) => NECESSITY.includes(s);
|
|
13
14
|
export const NECESSITY_HELP = {
|
|
14
15
|
required: 'The task cannot be done without this change',
|
|
15
16
|
supporting: 'Needed only because other groups need it (name them in `serves`)',
|
|
@@ -18,6 +19,24 @@ export const NECESSITY_HELP = {
|
|
|
18
19
|
defensive: 'Guards edge cases or failures nobody asked about',
|
|
19
20
|
speculative: 'Added because it might be useful later',
|
|
20
21
|
};
|
|
22
|
+
// How far a group's effect reaches if it is wrong, lowest first.
|
|
23
|
+
export const IMPACT = ['local', 'module', 'shared', 'contract', 'sensitive'];
|
|
24
|
+
export const isImpact = (s) => IMPACT.includes(s);
|
|
25
|
+
export const IMPACT_HELP = {
|
|
26
|
+
local: 'Contained in one file or component; no outside callers (a private helper, template text, a test)',
|
|
27
|
+
module: 'One feature or project; its callers are inside it',
|
|
28
|
+
shared: 'Code used by several projects or modules, or root build, test or lint config',
|
|
29
|
+
contract: 'An interface others depend on: public APIs, routes, state shape, message formats, schemas, config keys, dependency versions, CI definitions',
|
|
30
|
+
sensitive: 'Correctness or safety critical whatever the size: auth, crypto, permissions, data writes or deletes, payments, hardware I/O, secrets',
|
|
31
|
+
};
|
|
32
|
+
// What a reviewer should do with the change, lowest first.
|
|
33
|
+
export const ATTENTION = ['skim', 'look', 'careful', 'must-read'];
|
|
34
|
+
export const ATTENTION_HELP = {
|
|
35
|
+
skim: 'Glance at the summary',
|
|
36
|
+
look: 'Read the focus groups',
|
|
37
|
+
careful: 'Read every non-mechanical group',
|
|
38
|
+
'must-read': 'Read line by line before approving',
|
|
39
|
+
};
|
|
21
40
|
export const Group = z.object({
|
|
22
41
|
id: z
|
|
23
42
|
.string()
|
|
@@ -36,12 +55,32 @@ export const Group = z.object({
|
|
|
36
55
|
.string()
|
|
37
56
|
.optional()
|
|
38
57
|
.describe('A test, requirement or task line that demonstrates the need'),
|
|
58
|
+
impact: z
|
|
59
|
+
.enum(IMPACT)
|
|
60
|
+
.optional()
|
|
61
|
+
.describe('How far the effect reaches if this group is wrong: ' + IMPACT.join(' < ') + '; the highest that applies'),
|
|
62
|
+
impact_reason: z
|
|
63
|
+
.string()
|
|
64
|
+
.max(120)
|
|
65
|
+
.optional()
|
|
66
|
+
.describe('One short sentence on why the impact applies (expected for contract and sensitive)'),
|
|
39
67
|
hunks: z
|
|
40
68
|
.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
69
|
.min(1)
|
|
42
70
|
.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
71
|
notes: z.string().optional(),
|
|
44
72
|
});
|
|
73
|
+
export const Attention = z.object({
|
|
74
|
+
level: z.enum(ATTENTION).describe('What the reviewer should do: ' + ATTENTION.map((l) => `${l} (${ATTENTION_HELP[l].toLowerCase()})`).join(', ')),
|
|
75
|
+
focus: z
|
|
76
|
+
.array(z.string())
|
|
77
|
+
.min(1)
|
|
78
|
+
.max(3)
|
|
79
|
+
.optional()
|
|
80
|
+
.describe('One to three group ids to look at first'),
|
|
81
|
+
reasons: z.array(z.string()).optional().describe('Short reasons for the level'),
|
|
82
|
+
override_reason: z.string().optional().describe('Why the level is lower than the change would suggest, if it is'),
|
|
83
|
+
});
|
|
45
84
|
export const Annotation = z.object({
|
|
46
85
|
version: z.literal(1).default(1),
|
|
47
86
|
task: z.string().min(1).describe('The task or request these changes were made for'),
|
|
@@ -52,6 +91,7 @@ export const Annotation = z.object({
|
|
|
52
91
|
.optional()
|
|
53
92
|
.describe('Approaches considered and why they were not taken'),
|
|
54
93
|
risks: z.array(z.string()).optional().describe('Known risks or open questions'),
|
|
94
|
+
attention: Attention.optional().describe('How much attention a human reviewer should give this change'),
|
|
55
95
|
author: z
|
|
56
96
|
.object({
|
|
57
97
|
agent: z.string().optional(),
|
|
@@ -63,10 +103,14 @@ export const Annotation = z.object({
|
|
|
63
103
|
// A group's serves targets as a list, whichever form the gloss used.
|
|
64
104
|
export const servesOf = (g) => (Array.isArray(g.serves) ? g.serves : [g.serves]);
|
|
65
105
|
// 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,
|
|
106
|
+
// 3 serves lists; 4 impact, impact_reason or attention. A note is written with the lowest format that can hold it,
|
|
67
107
|
// so older readers only refuse notes that use something they can't read.
|
|
68
|
-
export const NOTE_FORMAT =
|
|
108
|
+
export const NOTE_FORMAT = 4;
|
|
69
109
|
export function noteFormat(a) {
|
|
110
|
+
// Readers of lower formats drop fields they don't know (they would lose
|
|
111
|
+
// these on a carry), so they must refuse the note instead.
|
|
112
|
+
if (a.attention || a.groups.some((g) => g.impact || g.impact_reason))
|
|
113
|
+
return 4;
|
|
70
114
|
if (a.groups.some((g) => servesOf(g).length > 1))
|
|
71
115
|
return 3;
|
|
72
116
|
if (a.groups.some((g) => g.hunks.some((ref) => ref.includes(':'))))
|
package/dist/signals.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { bodyLines } from './selection.js';
|
|
3
|
+
import { attentionForImpact, impactRank, maxAttention, maxImpact } from './attention.js';
|
|
4
|
+
import { CONFIG_FILE, PATH_CLASSES, PATTERN_CLASSES } from './config.js';
|
|
5
|
+
import { globToRegExp } from './glob.js';
|
|
6
|
+
import { matchLines } from './safe-match.js';
|
|
7
|
+
const compiled = new WeakMap();
|
|
8
|
+
function pathMatchers(cfg) {
|
|
9
|
+
let m = compiled.get(cfg);
|
|
10
|
+
if (!m) {
|
|
11
|
+
m = {};
|
|
12
|
+
for (const c of PATH_CLASSES)
|
|
13
|
+
m[c] = cfg.paths[c].map(globToRegExp);
|
|
14
|
+
compiled.set(cfg, m);
|
|
15
|
+
}
|
|
16
|
+
return m;
|
|
17
|
+
}
|
|
18
|
+
export function pathClasses(cfg, ...files) {
|
|
19
|
+
const m = pathMatchers(cfg);
|
|
20
|
+
const out = new Set();
|
|
21
|
+
for (const f of files) {
|
|
22
|
+
if (!f)
|
|
23
|
+
continue;
|
|
24
|
+
for (const c of PATH_CLASSES)
|
|
25
|
+
if (m[c].some((re) => re.test(f)))
|
|
26
|
+
out.add(c);
|
|
27
|
+
}
|
|
28
|
+
// Dependency files are interfaces whatever the globs say.
|
|
29
|
+
if ((out.has('lockfile') || out.has('manifest')) && !out.has('sensitive'))
|
|
30
|
+
out.add('contract');
|
|
31
|
+
return PATH_CLASSES.filter((c) => out.has(c));
|
|
32
|
+
}
|
|
33
|
+
// The floor from the path alone, and what set it.
|
|
34
|
+
// - lockfile: at least contract, even if generated.
|
|
35
|
+
// - test, docs, generated or manifest-in-test: capped at local (the cap wins over path classes like sensitive).
|
|
36
|
+
// - otherwise the highest of sensitive > contract > shared, else module.
|
|
37
|
+
function pathFloor(classes) {
|
|
38
|
+
const has = (c) => classes.includes(c);
|
|
39
|
+
const top = ['sensitive', 'contract', 'shared'].find(has);
|
|
40
|
+
if (has('lockfile'))
|
|
41
|
+
return has('sensitive') ? { floor: 'sensitive', why: ['path: sensitive', 'lockfile'] } : { floor: 'contract', why: ['lockfile'] };
|
|
42
|
+
if (has('test') || has('docs') || has('generated'))
|
|
43
|
+
return { floor: 'local', why: [] };
|
|
44
|
+
if (top)
|
|
45
|
+
return { floor: top, why: [`path: ${top}${has('manifest') ? ' (manifest)' : ''}`] };
|
|
46
|
+
return { floor: 'module', why: [] };
|
|
47
|
+
}
|
|
48
|
+
const clip = (s) => (s.length > 120 ? s.slice(0, 117) + '...' : s.trim());
|
|
49
|
+
export function hunkSignals(h, cfg) {
|
|
50
|
+
const classes = pathClasses(cfg, h.file, h.oldFile);
|
|
51
|
+
const { floor: pFloor, why: pWhy } = pathFloor(classes);
|
|
52
|
+
const damped = classes.includes('test') || classes.includes('docs') || classes.includes('lockfile');
|
|
53
|
+
const hits = [];
|
|
54
|
+
// Only added and removed lines: never context, never the @@ header.
|
|
55
|
+
if (h.text.startsWith('@@') && (h.added || h.removed)) {
|
|
56
|
+
const lines = bodyLines(h).filter((l) => l.idx !== null && l.kind !== 'ctx' && l.kind !== 'meta');
|
|
57
|
+
const texts = lines.map((l) => l.raw.slice(1));
|
|
58
|
+
for (const c of PATTERN_CLASSES) {
|
|
59
|
+
for (const p of cfg.patterns[c]) {
|
|
60
|
+
const found = matchLines(p.re, texts);
|
|
61
|
+
lines.forEach((l, i) => {
|
|
62
|
+
if (found[i])
|
|
63
|
+
hits.push({ class: c, name: p.name, line: l.idx, sign: l.kind === 'add' ? '+' : '-', text: clip(texts[i]), damped });
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const { floor, why } = floorFrom(pFloor, pWhy, hits, null);
|
|
69
|
+
const inert = classes.includes('test') || classes.includes('docs') || classes.includes('generated') || classes.includes('lockfile');
|
|
70
|
+
return { id: h.id, file: h.file, oldFile: h.oldFile, classes, hits, pathFloor: pFloor, pathWhy: pWhy, floor, why, inert, test: classes.includes('test') };
|
|
71
|
+
}
|
|
72
|
+
// The floor from a path floor and the pattern hits: damped hits never count,
|
|
73
|
+
// and with `lines` only hits on those changed lines do. `why` names what set
|
|
74
|
+
// it; the path's reasons stand only while the path itself sets the floor.
|
|
75
|
+
function floorFrom(pFloor, pWhy, hits, lines) {
|
|
76
|
+
let floor = pFloor;
|
|
77
|
+
const live = hits.filter((x) => !x.damped && (!lines || lines.has(x.line)));
|
|
78
|
+
for (const x of live)
|
|
79
|
+
floor = maxImpact(floor, x.class);
|
|
80
|
+
const names = [...new Set(live.filter((x) => x.class === floor).map((x) => `pattern: ${x.name}`))];
|
|
81
|
+
return { floor, why: floor === pFloor ? [...pWhy, ...names.filter((n) => !pWhy.includes(n))] : names };
|
|
82
|
+
}
|
|
83
|
+
// The floor of a part of a hunk (a group's lines of it); all of it when null.
|
|
84
|
+
export function partFloor(s, lines) {
|
|
85
|
+
return lines ? floorFrom(s.pathFloor, s.pathWhy, s.hits, lines) : { floor: s.floor, why: s.why };
|
|
86
|
+
}
|
|
87
|
+
// ---- dependency changes ----
|
|
88
|
+
const NPM_KEY = /^\s*"(node_modules\/[^"]+)":\s*\{\s*$/;
|
|
89
|
+
const NPM_VERSION = /^\s*"version":\s*"([^"]+)"/;
|
|
90
|
+
const NPM_RESOLVED = /^\s*"resolved":\s*"[^"]*?\/((?:@[^/"]+\/)?[^/"]+)\/-\/[^"]*"/;
|
|
91
|
+
function parsePackageLock(hunks) {
|
|
92
|
+
const old = new Map(), now = new Map();
|
|
93
|
+
for (const h of hunks) {
|
|
94
|
+
const lines = bodyLines(h);
|
|
95
|
+
let key = null;
|
|
96
|
+
lines.forEach((l, i) => {
|
|
97
|
+
if (l.kind === 'meta')
|
|
98
|
+
return;
|
|
99
|
+
const body = l.raw.slice(1);
|
|
100
|
+
const k = NPM_KEY.exec(body);
|
|
101
|
+
if (k) {
|
|
102
|
+
key = k[1];
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const v = NPM_VERSION.exec(body);
|
|
106
|
+
if (!v || l.kind === 'ctx')
|
|
107
|
+
return;
|
|
108
|
+
// The entry's key is usually the line above (context or changed); else
|
|
109
|
+
// the package's name is in the `resolved` URL a few lines below.
|
|
110
|
+
let id = key;
|
|
111
|
+
let name = id ? id.slice(id.lastIndexOf('node_modules/') + 'node_modules/'.length) : null;
|
|
112
|
+
if (!id) {
|
|
113
|
+
for (const n of lines.slice(i + 1, i + 5)) {
|
|
114
|
+
const r = n.kind === 'meta' ? null : NPM_RESOLVED.exec(n.raw.slice(1));
|
|
115
|
+
if (r) {
|
|
116
|
+
name = r[1];
|
|
117
|
+
id = `?${r[1]}`;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!id || !name)
|
|
123
|
+
return;
|
|
124
|
+
(l.kind === 'del' ? old : now).set(id, { name, version: v[1] });
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return { old, now };
|
|
128
|
+
}
|
|
129
|
+
const PNPM_KEY = [
|
|
130
|
+
// packages and snapshots: "/name@1.2.3:", "name@1.2.3(peer@1):", "'@scope/name@1.2.3':"
|
|
131
|
+
/^\s+['"]?\/?(@[^/@\s'"]+\/[^@\s'"]+|[^@\s'"/]+)@(\d[^\s'"(:]*)[^:]*?['"]?:\s*$/,
|
|
132
|
+
// older format: "/name/1.2.3:", "/@scope/name/1.2.3_peer:"
|
|
133
|
+
/^\s+\/(@[^/\s]+\/[^/\s]+|[^/\s@]+)\/(\d[^\s:_]*)[^:]*:\s*$/,
|
|
134
|
+
];
|
|
135
|
+
function parsePnpmLock(hunks) {
|
|
136
|
+
const old = new Map(), now = new Map();
|
|
137
|
+
for (const h of hunks) {
|
|
138
|
+
for (const l of bodyLines(h)) {
|
|
139
|
+
if (l.kind !== 'add' && l.kind !== 'del')
|
|
140
|
+
continue;
|
|
141
|
+
const body = l.raw.slice(1);
|
|
142
|
+
for (const re of PNPM_KEY) {
|
|
143
|
+
const m = re.exec(body);
|
|
144
|
+
if (m) {
|
|
145
|
+
(l.kind === 'del' ? old : now).set(`${m[1]}@${m[2]}`, { name: m[1], version: m[2] });
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { old, now };
|
|
152
|
+
}
|
|
153
|
+
// Pair removed and added entries into bumps. By identity (npm: the entry's
|
|
154
|
+
// path) when both sides have it; for pnpm, entries are name@version, so a
|
|
155
|
+
// name with removed and added versions is a bump of those.
|
|
156
|
+
function diffSeen(old, now, byName) {
|
|
157
|
+
const out = [];
|
|
158
|
+
if (!byName) {
|
|
159
|
+
for (const [id, n] of now) {
|
|
160
|
+
const o = old.get(id);
|
|
161
|
+
if (!o)
|
|
162
|
+
out.push({ name: n.name, from: null, to: n.version, kind: 'added' });
|
|
163
|
+
else if (o.version !== n.version)
|
|
164
|
+
out.push({ name: n.name, from: o.version, to: n.version, kind: 'bump' });
|
|
165
|
+
}
|
|
166
|
+
for (const [id, o] of old)
|
|
167
|
+
if (!now.has(id))
|
|
168
|
+
out.push({ name: o.name, from: o.version, to: null, kind: 'removed' });
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
const group = (m) => {
|
|
172
|
+
const g = new Map();
|
|
173
|
+
for (const e of m.values())
|
|
174
|
+
g.set(e.name, [...(g.get(e.name) ?? []), e.version]);
|
|
175
|
+
return g;
|
|
176
|
+
};
|
|
177
|
+
const o = group(old), n = group(now);
|
|
178
|
+
for (const [name, to] of n) {
|
|
179
|
+
const from = (o.get(name) ?? []).filter((v) => !to.includes(v));
|
|
180
|
+
const added = to.filter((v) => !(o.get(name) ?? []).includes(v));
|
|
181
|
+
if (!o.has(name))
|
|
182
|
+
out.push({ name, from: null, to: to.join(', '), kind: 'added' });
|
|
183
|
+
else if (added.length)
|
|
184
|
+
out.push(from.length ? { name, from: from.join(', '), to: added.join(', '), kind: 'bump' } : { name, from: null, to: added.join(', '), kind: 'added' });
|
|
185
|
+
}
|
|
186
|
+
for (const [name, from] of o)
|
|
187
|
+
if (!n.has(name))
|
|
188
|
+
out.push({ name, from: from.join(', '), to: null, kind: 'removed' });
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
const MANIFEST_DEP = /^\s*"([^"]+)"\s*:\s*"(?:[~^<>=*]|\d|npm:|workspace:|file:|link:|git|https?:|latest|catalog:)[^"]*"/;
|
|
192
|
+
// Names a package.json change adds, removes or edits (by its changed lines).
|
|
193
|
+
function manifestNames(hunks) {
|
|
194
|
+
const names = new Set();
|
|
195
|
+
for (const h of hunks) {
|
|
196
|
+
for (const l of bodyLines(h)) {
|
|
197
|
+
if (l.kind !== 'add' && l.kind !== 'del')
|
|
198
|
+
continue;
|
|
199
|
+
const m = MANIFEST_DEP.exec(l.raw.slice(1));
|
|
200
|
+
if (m)
|
|
201
|
+
names.add(m[1]);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return names;
|
|
205
|
+
}
|
|
206
|
+
function dependencyChanges(hs, sigs) {
|
|
207
|
+
const byFile = new Map();
|
|
208
|
+
hs.forEach((h, i) => {
|
|
209
|
+
const c = sigs[i].classes;
|
|
210
|
+
const kind = c.includes('lockfile') ? 'lockfile' : c.includes('manifest') && !sigs[i].inert ? 'manifest' : null;
|
|
211
|
+
if (!kind)
|
|
212
|
+
return;
|
|
213
|
+
const e = byFile.get(h.file) ?? { kind, hunks: [] };
|
|
214
|
+
e.hunks.push(h);
|
|
215
|
+
byFile.set(h.file, e);
|
|
216
|
+
});
|
|
217
|
+
const names = new Set();
|
|
218
|
+
for (const [file, e] of byFile)
|
|
219
|
+
if (e.kind === 'manifest' && basename(file) === 'package.json')
|
|
220
|
+
for (const n of manifestNames(e.hunks))
|
|
221
|
+
names.add(n);
|
|
222
|
+
return [...byFile].map(([file, e]) => {
|
|
223
|
+
const base = basename(file);
|
|
224
|
+
const format = e.kind === 'lockfile' && base === 'package-lock.json' ? 'package-lock.json'
|
|
225
|
+
: e.kind === 'lockfile' && base === 'pnpm-lock.yaml' ? 'pnpm-lock.yaml' : null;
|
|
226
|
+
if (!format)
|
|
227
|
+
return { file, kind: e.kind, format: null, bumps: [] };
|
|
228
|
+
const { old, now } = format === 'package-lock.json' ? parsePackageLock(e.hunks) : parsePnpmLock(e.hunks);
|
|
229
|
+
const bumps = diffSeen(old, now, format === 'pnpm-lock.yaml').map((b) => ({ ...b, unrelated: b.kind === 'bump' && !names.has(b.name) }));
|
|
230
|
+
return { file, kind: e.kind, format, bumps };
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
// ---- the whole change ----
|
|
234
|
+
// Said wherever the floor reasons are: the change's own edit is not what rated it.
|
|
235
|
+
export const CONFIG_EDIT_NOTE = `${CONFIG_FILE} is edited here; this change is rated with the base branch's config, not this edit`;
|
|
236
|
+
export function analyze(hunks, cfg) {
|
|
237
|
+
const sigs = hunks.map((h) => hunkSignals(h, cfg));
|
|
238
|
+
const deps = dependencyChanges(hunks, sigs);
|
|
239
|
+
const files = new Map();
|
|
240
|
+
hunks.forEach((h, i) => {
|
|
241
|
+
const s = sigs[i];
|
|
242
|
+
const f = files.get(h.file) ?? { file: h.file, classes: [], hunks: [], added: 0, removed: 0, floor: s.floor, why: [] };
|
|
243
|
+
const first = !f.hunks.length;
|
|
244
|
+
f.classes = PATH_CLASSES.filter((c) => f.classes.includes(c) || s.classes.includes(c));
|
|
245
|
+
f.hunks.push(h.id);
|
|
246
|
+
f.added += h.added;
|
|
247
|
+
f.removed += h.removed;
|
|
248
|
+
if (first || s.floor === f.floor)
|
|
249
|
+
f.why = [...new Set([...f.why, ...s.why])];
|
|
250
|
+
else if (impactRank(s.floor) > impactRank(f.floor)) {
|
|
251
|
+
f.floor = s.floor;
|
|
252
|
+
f.why = [...s.why];
|
|
253
|
+
}
|
|
254
|
+
files.set(h.file, f);
|
|
255
|
+
});
|
|
256
|
+
const fileList = [...files.values()];
|
|
257
|
+
const testHunks = sigs.filter((s) => s.test).length;
|
|
258
|
+
const stats = {
|
|
259
|
+
files: fileList.length,
|
|
260
|
+
hunks: hunks.length,
|
|
261
|
+
added: hunks.reduce((n, h) => n + h.added, 0),
|
|
262
|
+
removed: hunks.reduce((n, h) => n + h.removed, 0),
|
|
263
|
+
testFiles: fileList.filter((f) => f.classes.includes('test')).length,
|
|
264
|
+
testHunks,
|
|
265
|
+
testHunkShare: hunks.length ? Math.round((testHunks / hunks.length) * 100) / 100 : 0,
|
|
266
|
+
};
|
|
267
|
+
// Dependency bumps that no manifest change explains are worth naming where
|
|
268
|
+
// the lockfile is reported.
|
|
269
|
+
for (const d of deps) {
|
|
270
|
+
const f = files.get(d.file);
|
|
271
|
+
if (!f)
|
|
272
|
+
continue;
|
|
273
|
+
for (const b of d.bumps.filter((x) => x.unrelated).slice(0, 3))
|
|
274
|
+
f.why.push(`transitive bump: ${b.name} ${b.from} -> ${b.to}`);
|
|
275
|
+
}
|
|
276
|
+
// The floor: the highest hunk floor decides the attention floor (local skim,
|
|
277
|
+
// module look, shared and contract careful, sensitive must-read). A change
|
|
278
|
+
// of more than largeChangeFiles files is at least careful. The "raise one
|
|
279
|
+
// step" rules of the derivation are not part of the floor: they depend on
|
|
280
|
+
// the gloss, and the floor must be the same whoever writes it.
|
|
281
|
+
const impact = sigs.reduce((m, s) => maxImpact(m, s.floor), 'local');
|
|
282
|
+
let attention = attentionForImpact(impact);
|
|
283
|
+
const reasons = fileList.filter((f) => f.floor === impact && impact !== 'module' && impact !== 'local')
|
|
284
|
+
.slice(0, 5).map((f) => `${impact}: ${f.file}${f.why.length ? ` (${f.why.join('; ')})` : ''}`);
|
|
285
|
+
if (stats.files > cfg.attention.largeChangeFiles) {
|
|
286
|
+
attention = maxAttention(attention, 'careful');
|
|
287
|
+
reasons.push(`${stats.files} files changed (more than ${cfg.attention.largeChangeFiles})`);
|
|
288
|
+
}
|
|
289
|
+
if (files.has(CONFIG_FILE))
|
|
290
|
+
reasons.push(CONFIG_EDIT_NOTE);
|
|
291
|
+
return { stats, hunks: sigs, files: fileList, dependencies: deps, floor: { impact, attention, reasons } };
|
|
292
|
+
}
|
package/dist/validate.js
CHANGED
|
@@ -49,6 +49,12 @@ export function validate(annotation, hunks) {
|
|
|
49
49
|
errors.push(`group "${g.id}" is "supporting" but serves "task"; name the group(s) it supports`);
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
+
const focus = annotation.attention?.focus ?? [];
|
|
53
|
+
for (const id of focus)
|
|
54
|
+
if (!groupIds.has(id))
|
|
55
|
+
errors.push(`attention.focus names unknown group "${id}"`);
|
|
56
|
+
if (new Set(focus).size < focus.length)
|
|
57
|
+
errors.push('attention.focus lists a group twice');
|
|
52
58
|
// A group is in a cycle if it can reach itself by following serves links.
|
|
53
59
|
const byId = new Map(annotation.groups.map((g) => [g.id, g]));
|
|
54
60
|
for (const g of annotation.groups) {
|
package/package.json
CHANGED
package/skills/gloss/SKILL.md
CHANGED
|
@@ -31,7 +31,7 @@ The commands below are written as `git gloss …`. That works only where git-glo
|
|
|
31
31
|
|
|
32
32
|
## Steps
|
|
33
33
|
|
|
34
|
-
1. Run `git gloss hunks wip` to snapshot the uncommitted work and see it split into hunks with ids `h1`, `h2`, …, with each hunk's changed (`+`/`-`) lines numbered from 1. If you change files afterwards, run it again: that takes a new snapshot, and a gloss must match the snapshot it was written for.
|
|
34
|
+
1. Run `git gloss hunks wip` to snapshot the uncommitted work and see it split into hunks with ids `h1`, `h2`, …, with each hunk's changed (`+`/`-`) lines numbered from 1, and a `signals:` summary of what in it deserves attention (see "Impact and attention"). If you change files afterwards, run it again: that takes a new snapshot, and a gloss must match the snapshot it was written for.
|
|
35
35
|
2. Group changes **by intent**, not by file. One intent often spans several files. When one hunk mixes intents (common in new files, which are a single hunk), give each group only its lines: `"h6:1-40"`, `"h6:41-55,60"`. The numbers count changed lines only, as `hunks` prints them. Every changed line must belong to at least one group; `add` lists any that don't.
|
|
36
36
|
Take hunk ids and line numbers from the `git gloss hunks` output for this snapshot or commit, never from memory of the diff or from an earlier commit. Ids depend on the order git lists hunks, and line numbers count only changed lines, so guesses are easily wrong. `add` rejects ranges past the end of a hunk, but a wrong range that stays in bounds is accepted and silently attaches an intent to the wrong code.
|
|
37
37
|
3. Write the annotation JSON (shape below; `git gloss schema` prints the full JSON Schema) to a file in your scratchpad, then run:
|
|
@@ -99,6 +99,32 @@ Where you can, set `evidence` to a test that exercises the change or the line of
|
|
|
99
99
|
|
|
100
100
|
`alternatives` and `risks` are optional, but fill them in when you weighed real options. They are what later readers most often lack.
|
|
101
101
|
|
|
102
|
+
## Impact and attention
|
|
103
|
+
|
|
104
|
+
Necessity says whether a change is needed; `impact` says how far it reaches if it is wrong. Read the `signals:` summary at the end of `git gloss hunks wip` (or run `git gloss signals wip` for hunks, pattern hits and dependency bumps) **before** writing the gloss: it is the floor the gloss is checked against.
|
|
105
|
+
|
|
106
|
+
- `impact` on each non-mechanical group, the highest level that applies: `local` (one file or component, no outside callers), `module` (one feature or project), `shared` (used by several projects, or root build, test or lint config), `contract` (an interface others depend on: public APIs, props and events, routes, state shape, message formats, schemas, config keys, dependency versions, CI definitions), `sensitive` (auth, crypto, permissions, data writes or deletes, payments, hardware I/O, secrets, whatever the size). Give `impact_reason` (one short sentence, up to 120 characters) at `contract` and above. `add` warns when a group is rated below its floor.
|
|
107
|
+
- `attention` for the reviewer: `level` is `skim` (glance at the summary), `look` (read the focus groups), `careful` (read every non-mechanical group) or `must-read` (line by line); `focus` is one to three group ids to read first; `reasons` tie the level to signals.
|
|
108
|
+
- Never declare `attention.level` below the floor without `override_reason`: `add` refuses it. Don't reach for `--allow-underrate`; raise the level, or say why the floor is wrong. A large "behaviour-neutral" refactor deserves `careful`, not `skim`, however many hunks are mechanical.
|
|
109
|
+
- Name the groups that test others in `serves` (`"serves": ["gauge-fix"]`, not `"task"`): a behaviour change that no test group serves raises the derived attention, and say so in `reasons` when it is untested.
|
|
110
|
+
- Both fields are optional. Without them `show` derives a value from the diff and marks it `(derived)`.
|
|
111
|
+
|
|
112
|
+
Example: a change adds a dev dependency, fixes a device write, and tweaks a label with no test. `signals` says `sensitive: src/devices/gauge.ts (pattern: device write)` and `contract: package-lock.json (transitive bump: some-pkg 7.1.0 -> 7.1.2)`, so the floor is must-read.
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
"groups": [
|
|
116
|
+
{"id": "gauge-fix", "intent": "Send the brightness value, not the signal", "necessity": "required", "serves": "task",
|
|
117
|
+
"impact": "sensitive", "impact_reason": "writes a value to the device", "removal_impact": "The gauge shows a constant", "hunks": ["h2"]},
|
|
118
|
+
{"id": "gauge-test", "intent": "Cover the written value", "necessity": "supporting", "serves": ["gauge-fix"], "removal_impact": "The fix is untested", "hunks": ["h3"]},
|
|
119
|
+
{"id": "label-fix", "intent": "Fix the Save label", "necessity": "required", "serves": "task",
|
|
120
|
+
"impact": "module", "removal_impact": "The button says Sav", "hunks": ["h4"]},
|
|
121
|
+
{"id": "report-dep", "intent": "Add the report tool; lockfile also bumps some-pkg 7.1.0 to 7.1.2", "necessity": "mechanical", "serves": "task",
|
|
122
|
+
"impact": "contract", "impact_reason": "dependency versions; the some-pkg bump is not ours", "removal_impact": "No test reports", "hunks": ["h1"]}
|
|
123
|
+
],
|
|
124
|
+
"attention": {"level": "must-read", "focus": ["gauge-fix", "report-dep"],
|
|
125
|
+
"reasons": ["gauge-fix writes to the device", "lockfile bumps some-pkg beyond the new dependency", "label-fix has no test"]}
|
|
126
|
+
```
|
|
127
|
+
|
|
102
128
|
## Looking up intent
|
|
103
129
|
|
|
104
130
|
You don't need to look up intent before ordinary edits. Use `git gloss why` when you are genuinely unsure why existing code is the way it is, especially before removing or rewriting something that looks unnecessary, defensive or wrong. That is when an unexplained check gets deleted and a fixed bug comes back.
|