@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/floor.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { ConfigError, loadConfig } from './config.js';
|
|
2
|
+
import { attentionForImpact, attentionRank, impactRank, maxAttention, maxImpact, raise } from './attention.js';
|
|
3
|
+
import { servesOf } from './schema.js';
|
|
4
|
+
import { analyze, CONFIG_EDIT_NOTE, partFloor } from './signals.js';
|
|
5
|
+
import { changedCount, selections } from './selection.js';
|
|
6
|
+
// The groups of a loaded gloss with their current hunks (a hunk that is gone is skipped).
|
|
7
|
+
export function groupInputs(gloss) {
|
|
8
|
+
return gloss.groups.map((g) => ({
|
|
9
|
+
group: g.group,
|
|
10
|
+
parts: g.hunks.flatMap((h) => (h.current ? [{ hunk: h.current, lines: h.lines }] : [])),
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
13
|
+
// The groups of an annotation over hunks of the diff (as `add` has them).
|
|
14
|
+
export function groupInputsFor(a, hunks) {
|
|
15
|
+
const byId = new Map(hunks.map((h) => [h.id, h]));
|
|
16
|
+
return a.groups.map((group) => ({
|
|
17
|
+
group,
|
|
18
|
+
parts: [...selections(group.hunks, (id) => { const h = byId.get(id); return h && changedCount(h); })]
|
|
19
|
+
.flatMap(([id, lines]) => { const hunk = byId.get(id); return hunk ? [{ hunk, lines }] : []; }),
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
// Group floors, and the derivation of the attention for when the author declares none:
|
|
23
|
+
//
|
|
24
|
+
// start from the highest group impact (declared, else the group's floor):
|
|
25
|
+
// local skim, module look, shared and contract careful, sensitive must-read;
|
|
26
|
+
// raise it ONE step (capped at must-read), however many of these apply:
|
|
27
|
+
// - a behaviour-changing group no test-only group serves (see GroupFloor);
|
|
28
|
+
// - a speculative or defensive group that is not made only of test hunks;
|
|
29
|
+
// - more than attention.largeChangeFiles files changed;
|
|
30
|
+
// never below the change's floor (which is the highest hunk floor, and at
|
|
31
|
+
// least careful for a large change).
|
|
32
|
+
export function analyzeGroups(hunks, groups, cfg) {
|
|
33
|
+
const signals = analyze(hunks, cfg);
|
|
34
|
+
const sigOf = new Map(signals.hunks.map((s) => [s.id, s]));
|
|
35
|
+
const floors = new Map();
|
|
36
|
+
for (const { group, parts } of groups) {
|
|
37
|
+
let floor = 'local';
|
|
38
|
+
const found = [];
|
|
39
|
+
for (const { hunk, lines } of parts) {
|
|
40
|
+
const s = sigOf.get(hunk.id);
|
|
41
|
+
if (!s)
|
|
42
|
+
continue;
|
|
43
|
+
const p = partFloor(s, lines);
|
|
44
|
+
floor = maxImpact(floor, p.floor);
|
|
45
|
+
found.push({ floor: p.floor, why: p.why.map((w) => `${hunk.file}: ${w}`) });
|
|
46
|
+
}
|
|
47
|
+
const sigs = parts.map((p) => sigOf.get(p.hunk.id)).filter((s) => !!s);
|
|
48
|
+
floors.set(group.id, {
|
|
49
|
+
floor,
|
|
50
|
+
why: [...new Set(found.filter((f) => f.floor === floor).flatMap((f) => f.why))].slice(0, 3),
|
|
51
|
+
behaviour: group.necessity !== 'mechanical' && sigs.some((s) => !s.inert),
|
|
52
|
+
testsOnly: sigs.length > 0 && sigs.every((s) => s.test),
|
|
53
|
+
tested: false,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
// A test-only group serves what it names, and what those groups serve in turn.
|
|
57
|
+
const byId = new Map(groups.map((g) => [g.group.id, g.group]));
|
|
58
|
+
for (const { group } of groups) {
|
|
59
|
+
if (!floors.get(group.id).testsOnly)
|
|
60
|
+
continue;
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
const queue = servesOf(group).filter((t) => t !== 'task');
|
|
63
|
+
while (queue.length) {
|
|
64
|
+
const id = queue.shift();
|
|
65
|
+
if (seen.has(id))
|
|
66
|
+
continue;
|
|
67
|
+
seen.add(id);
|
|
68
|
+
const f = floors.get(id);
|
|
69
|
+
if (f)
|
|
70
|
+
f.tested = true;
|
|
71
|
+
const g = byId.get(id);
|
|
72
|
+
if (g)
|
|
73
|
+
queue.push(...servesOf(g).filter((t) => t !== 'task'));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Derivation.
|
|
77
|
+
let top = { impact: undefined, id: '', declared: false };
|
|
78
|
+
for (const { group } of groups) {
|
|
79
|
+
const impact = group.impact ?? floors.get(group.id).floor;
|
|
80
|
+
if (impactRank(impact) > impactRank(top.impact))
|
|
81
|
+
top = { impact, id: group.id, declared: !!group.impact };
|
|
82
|
+
}
|
|
83
|
+
const reasons = [];
|
|
84
|
+
let level = top.impact ? attentionForImpact(top.impact) : signals.floor.attention;
|
|
85
|
+
if (top.impact)
|
|
86
|
+
reasons.push(`highest group impact: ${top.impact} (${top.id}, ${top.declared ? 'declared' : 'from the diff'})`);
|
|
87
|
+
const raises = [];
|
|
88
|
+
const untested = groups.filter((g) => floors.get(g.group.id).behaviour && !floors.get(g.group.id).tested).map((g) => g.group.id);
|
|
89
|
+
if (untested.length)
|
|
90
|
+
raises.push(`behaviour change without a test serving it: ${untested.join(', ')}`);
|
|
91
|
+
const hedged = groups.filter((g) => (g.group.necessity === 'speculative' || g.group.necessity === 'defensive') && !floors.get(g.group.id).testsOnly);
|
|
92
|
+
if (hedged.length)
|
|
93
|
+
raises.push(`speculative or defensive code: ${hedged.map((g) => g.group.id).join(', ')}`);
|
|
94
|
+
if (signals.stats.files > cfg.attention.largeChangeFiles)
|
|
95
|
+
raises.push(`${signals.stats.files} files changed (more than ${cfg.attention.largeChangeFiles})`);
|
|
96
|
+
if (raises.length) {
|
|
97
|
+
level = raise(level);
|
|
98
|
+
reasons.push(...raises.map((r) => `raised one step: ${r}`));
|
|
99
|
+
}
|
|
100
|
+
if (attentionRank(signals.floor.attention) > attentionRank(level)) {
|
|
101
|
+
level = maxAttention(level, signals.floor.attention);
|
|
102
|
+
reasons.push(`floor ${signals.floor.attention}: ${signals.floor.reasons[0] ?? signals.floor.impact}`);
|
|
103
|
+
}
|
|
104
|
+
return { signals, groups: floors, floor: signals.floor.attention, derived: { level, reasons } };
|
|
105
|
+
}
|
|
106
|
+
export const analyzeGloss = (gloss, cfg) => analyzeGroups(gloss.hunks, groupInputs(gloss), cfg);
|
|
107
|
+
export function ratingProblems(a, an) {
|
|
108
|
+
const warnings = [];
|
|
109
|
+
if (an.signals.floor.reasons.includes(CONFIG_EDIT_NOTE))
|
|
110
|
+
warnings.push(CONFIG_EDIT_NOTE);
|
|
111
|
+
for (const g of a.groups) {
|
|
112
|
+
const f = an.groups.get(g.id);
|
|
113
|
+
// A floor of plain "module" is the baseline for source files, not a signal
|
|
114
|
+
// to warn about: a private helper may honestly be `local`.
|
|
115
|
+
if (!g.impact || !f || impactRank(f.floor) <= impactRank('module') || impactRank(g.impact) >= impactRank(f.floor))
|
|
116
|
+
continue;
|
|
117
|
+
warnings.push(`group "${g.id}" is rated impact "${g.impact}" but its floor is "${f.floor}" (${f.why.join('; ') || 'computed from the diff'}); raise it, or say why in impact_reason`);
|
|
118
|
+
}
|
|
119
|
+
let underrated = null;
|
|
120
|
+
const att = a.attention;
|
|
121
|
+
if (att && attentionRank(att.level) < attentionRank(an.floor) && !att.override_reason) {
|
|
122
|
+
const why = an.signals.floor.reasons.length ? an.signals.floor.reasons.join('; ') : `${an.signals.floor.impact}: ordinary source changes`;
|
|
123
|
+
underrated = `attention.level "${att.level}" is below the computed floor "${an.floor}" (${why}); raise it, or explain with attention.override_reason`;
|
|
124
|
+
}
|
|
125
|
+
return { warnings, underrated };
|
|
126
|
+
}
|
|
127
|
+
let warned = false;
|
|
128
|
+
// Run something that needs the signals' config; null when the config is
|
|
129
|
+
// malformed (say why once on stderr), so callers fall back to what is declared.
|
|
130
|
+
export function withSignals(fn) {
|
|
131
|
+
try {
|
|
132
|
+
return fn();
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
if (!(err instanceof ConfigError))
|
|
136
|
+
throw err;
|
|
137
|
+
if (!warned)
|
|
138
|
+
console.error(`warning: signals unavailable: ${err.message}`);
|
|
139
|
+
warned = true;
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// The analysis of a loaded gloss, or null when the signals cannot be computed.
|
|
144
|
+
export const tryAnalyze = (gloss) => withSignals(() => analyzeGloss(gloss, loadConfig()));
|
|
145
|
+
// Whether a gloss's groups get an impact column (show) or badges (html): some
|
|
146
|
+
// group declares an impact, or the diff puts a group's floor above the module
|
|
147
|
+
// baseline. Then undeclared groups show their floor as a derived impact.
|
|
148
|
+
export const showsImpact = (groups, an) => groups.some((g) => g.impact || impactRank(an?.groups.get(g.id)?.floor) > impactRank('module'));
|
package/dist/git.js
CHANGED
|
@@ -142,6 +142,16 @@ export function wipStaleness(sha) {
|
|
|
142
142
|
return 'the working tree has changed since this snapshot was taken';
|
|
143
143
|
return null;
|
|
144
144
|
}
|
|
145
|
+
// The diff of uncommitted work against HEAD, as a snapshot would have it,
|
|
146
|
+
// without taking a snapshot: no ref is written (only objects). Hunk ids match
|
|
147
|
+
// those of `hunks wip` for the same working tree.
|
|
148
|
+
export function workingDiff() {
|
|
149
|
+
const { tree, excluded } = workingTree();
|
|
150
|
+
const head = headSha();
|
|
151
|
+
if (head && git(['rev-parse', `${head}^{tree}`]).trim() === tree)
|
|
152
|
+
throw new GlossError('there are no uncommitted changes');
|
|
153
|
+
return { diff: diffBetween(head ?? EMPTY_TREE, tree), excluded };
|
|
154
|
+
}
|
|
145
155
|
export function resolveCommit(rev) {
|
|
146
156
|
// "wip": the latest snapshot of uncommitted work (made by `hunks wip`). A
|
|
147
157
|
// branch or tag named wip still works when there is no snapshot, and can be
|
|
@@ -236,7 +246,7 @@ export function readNote(sha) {
|
|
|
236
246
|
throw new GlossError(`${at} was written by a newer git-gloss (format ${data.gloss}); upgrade to read it`);
|
|
237
247
|
}
|
|
238
248
|
const annotation = Annotation.safeParse(data?.annotation);
|
|
239
|
-
if (![1, 2, 3].includes(data?.gloss) || !Array.isArray(data.hunks) || !annotation.success) {
|
|
249
|
+
if (![1, 2, 3, 4].includes(data?.gloss) || !Array.isArray(data.hunks) || !annotation.success) {
|
|
240
250
|
throw new GlossError(`${at} is malformed; replace it with \`git gloss add --force\``);
|
|
241
251
|
}
|
|
242
252
|
return { ...data, annotation: annotation.data };
|
package/dist/glob.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// A small glob matcher for repo paths (always "/"-separated, relative to the
|
|
2
|
+
// repo root). Supports `**` (any number of directories, including none when
|
|
3
|
+
// written as `**/` or `/**`), `*` (anything but "/"), `?` (one character but
|
|
4
|
+
// "/"), `{a,b}` alternatives (nestable) and `\` to escape. A pattern without a
|
|
5
|
+
// "/" matches the file name at any depth, as in .gitignore ("Jenkinsfile",
|
|
6
|
+
// "*.lock"). Node's path.matchesGlob is not used: it is missing before Node 22.
|
|
7
|
+
const SPECIAL = /[.+^$()|[\]\\]/;
|
|
8
|
+
export class GlobError extends Error {
|
|
9
|
+
}
|
|
10
|
+
function translate(glob) {
|
|
11
|
+
let out = '';
|
|
12
|
+
let depth = 0;
|
|
13
|
+
for (let i = 0; i < glob.length; i++) {
|
|
14
|
+
const c = glob[i];
|
|
15
|
+
if (c === '\\') {
|
|
16
|
+
if (i + 1 >= glob.length)
|
|
17
|
+
throw new GlobError('trailing backslash');
|
|
18
|
+
out += glob[++i].replace(/[^a-zA-Z0-9]/, '\\$&');
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (c === '*') {
|
|
22
|
+
if (glob[i + 1] === '*') {
|
|
23
|
+
let j = i + 1;
|
|
24
|
+
while (glob[j + 1] === '*')
|
|
25
|
+
j++;
|
|
26
|
+
const atStart = i === 0 || glob[i - 1] === '/';
|
|
27
|
+
if (glob[j + 1] === '/' && atStart) {
|
|
28
|
+
out += '(?:.*/)?';
|
|
29
|
+
i = j + 1;
|
|
30
|
+
} // "**/": any directories, or none
|
|
31
|
+
else {
|
|
32
|
+
out += '.*';
|
|
33
|
+
i = j;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else
|
|
37
|
+
out += '[^/]*';
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (c === '?') {
|
|
41
|
+
out += '[^/]';
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (c === '{') {
|
|
45
|
+
depth++;
|
|
46
|
+
out += '(?:';
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (c === ',' && depth > 0) {
|
|
50
|
+
out += '|';
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (c === '}' && depth > 0) {
|
|
54
|
+
depth--;
|
|
55
|
+
out += ')';
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
out += SPECIAL.test(c) ? '\\' + c : c;
|
|
59
|
+
}
|
|
60
|
+
if (depth > 0)
|
|
61
|
+
throw new GlobError('unclosed "{"');
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
const cache = new Map();
|
|
65
|
+
// The regular expression for a glob; throws GlobError if it is malformed.
|
|
66
|
+
export function globToRegExp(glob) {
|
|
67
|
+
let re = cache.get(glob);
|
|
68
|
+
if (!re) {
|
|
69
|
+
const body = translate(glob);
|
|
70
|
+
// Without a "/", match the last path component at any depth.
|
|
71
|
+
re = new RegExp('^' + (glob.includes('/') ? '' : '(?:.*/)?') + body + '$');
|
|
72
|
+
cache.set(glob, re);
|
|
73
|
+
}
|
|
74
|
+
return re;
|
|
75
|
+
}
|
|
76
|
+
export const matchGlob = (glob, path) => globToRegExp(glob).test(path);
|