@ok-ggwp/git-gloss 0.3.0 → 0.5.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 +72 -4
- package/dist/ary/browse.js +4 -61
- package/dist/ary/picker.js +163 -0
- package/dist/ary/server.js +87 -37
- 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/ary.js +3 -3
- package/dist/commands/hunks.js +8 -1
- package/dist/commands/review.js +5 -4
- package/dist/commands/reviews.js +58 -5
- package/dist/commands/show.js +56 -20
- package/dist/commands/signals.js +89 -0
- package/dist/commands/sync.js +14 -2
- package/dist/commands/why.js +3 -2
- package/dist/config.js +248 -0
- package/dist/floor.js +148 -0
- package/dist/git.js +29 -3
- package/dist/glob.js +76 -0
- package/dist/html/client.js +1032 -296
- package/dist/html/data.js +26 -1
- package/dist/html/page.js +1 -1
- package/dist/html/style.css +310 -164
- package/dist/loader.js +7 -3
- package/dist/merge.js +5 -0
- package/dist/plain.js +9 -0
- package/dist/render.js +55 -5
- package/dist/reviews.js +98 -4
- package/dist/safe-match.js +30 -0
- package/dist/schema.js +55 -4
- package/dist/selection.js +21 -0
- package/dist/signals.js +302 -0
- package/dist/validate.js +10 -1
- package/package.json +1 -1
- package/skills/gloss/SKILL.md +42 -2
- package/dist/ary/client.js +0 -401
- package/dist/ary/index.html +0 -13
- package/dist/ary/style.css +0 -115
package/dist/commands/add.js
CHANGED
|
@@ -5,7 +5,9 @@ import { commitDiff, isWipSnapshot, NOTES_REF, readNote, resolveCommit, WIP_NOTE
|
|
|
5
5
|
import { location, parseDiff } from '../diff.js';
|
|
6
6
|
import { Annotation, noteFormat } from '../schema.js';
|
|
7
7
|
import { validate } from '../validate.js';
|
|
8
|
-
|
|
8
|
+
import { loadConfig } from '../config.js';
|
|
9
|
+
import { analyzeGroups, groupInputsFor, ratingProblems } from '../floor.js';
|
|
10
|
+
export const usage = 'add [<commit>] [-f <file>] [--force] [--partial] [--allow-underrate] [--dry-run] attach an annotation (JSON from file or stdin)';
|
|
9
11
|
export function run(argv) {
|
|
10
12
|
const { values, positionals } = parseArgs({
|
|
11
13
|
args: argv,
|
|
@@ -14,6 +16,7 @@ export function run(argv) {
|
|
|
14
16
|
file: { type: 'string', short: 'f' },
|
|
15
17
|
force: { type: 'boolean' },
|
|
16
18
|
partial: { type: 'boolean' },
|
|
19
|
+
'allow-underrate': { type: 'boolean' },
|
|
17
20
|
'dry-run': { type: 'boolean' },
|
|
18
21
|
},
|
|
19
22
|
});
|
|
@@ -41,10 +44,23 @@ export function run(argv) {
|
|
|
41
44
|
if (uncovered.length && !values.partial) {
|
|
42
45
|
errors.push(`hunks not assigned to any group: ${uncovered.join(', ')} (every hunk needs a group, or pass --partial)`);
|
|
43
46
|
}
|
|
47
|
+
// Computed signals: group impact below its floor is a warning; attention
|
|
48
|
+
// below the floor is an error unless explained (override_reason) or allowed.
|
|
49
|
+
// A malformed config stops here with its own message.
|
|
50
|
+
const warnings = [];
|
|
51
|
+
if (!errors.length) {
|
|
52
|
+
const analysis = analyzeGroups(hunks, groupInputsFor(parsed.data, hunks), loadConfig());
|
|
53
|
+
const problems = ratingProblems(parsed.data, analysis);
|
|
54
|
+
warnings.push(...problems.warnings);
|
|
55
|
+
if (problems.underrated)
|
|
56
|
+
(values['allow-underrate'] ? warnings : errors).push(problems.underrated);
|
|
57
|
+
}
|
|
44
58
|
if (errors.length) {
|
|
45
59
|
console.error('error: annotation is inconsistent with the commit:\n' + errors.map((e) => ` ✖ ${e}`).join('\n'));
|
|
46
60
|
return 1;
|
|
47
61
|
}
|
|
62
|
+
for (const w of warnings)
|
|
63
|
+
console.error(`warning: ${w}`);
|
|
48
64
|
// Not an error: long intents still work, but they make the summary rows tall.
|
|
49
65
|
for (const g of parsed.data.groups.filter((g) => g.intent.length > 100)) {
|
|
50
66
|
console.error(`note: group "${g.id}" has a ${g.intent.length}-character intent; keep it under ~80 and move detail to "notes"`);
|
package/dist/commands/ary.js
CHANGED
|
@@ -2,10 +2,10 @@ import { parseArgs } from 'node:util';
|
|
|
2
2
|
import { createAry } from '../ary/server.js';
|
|
3
3
|
import { GlossError, git } from '../git.js';
|
|
4
4
|
import { openInBrowser } from './html.js';
|
|
5
|
-
export const usage = 'ary [--port <n>] [--no-open] browse glosses of branches, commits and uncommitted work in a local web page';
|
|
5
|
+
export const usage = 'ary [--port <n>] [--no-open] browse glosses of branches, commits and uncommitted work in a local web page (the html view with branch pickers)';
|
|
6
6
|
// Starts a read-only server on this machine and keeps running until stopped.
|
|
7
|
-
// The browser page
|
|
8
|
-
//
|
|
7
|
+
// The browser page is the same view as `git gloss html`, with branch pickers
|
|
8
|
+
// and a revision box in its top bar; each choice loads a new page.
|
|
9
9
|
export function run(argv) {
|
|
10
10
|
const { values } = parseArgs({
|
|
11
11
|
args: argv,
|
package/dist/commands/hunks.js
CHANGED
|
@@ -6,6 +6,10 @@ import { location, parseDiff } from '../diff.js';
|
|
|
6
6
|
import { noteFormat } from '../schema.js';
|
|
7
7
|
import { colorDiff, hunkLabel, page, style } from '../render.js';
|
|
8
8
|
import { bodyLines, changedCount } from '../selection.js';
|
|
9
|
+
import { loadConfig } from '../config.js';
|
|
10
|
+
import { withSignals } from '../floor.js';
|
|
11
|
+
import { analyze } from '../signals.js';
|
|
12
|
+
import { summaryLines } from './signals.js';
|
|
9
13
|
export const usage = 'hunks [<commit> | wip] [--stat] [--json] list a commit\'s hunks (or snapshot uncommitted work with wip), numbering changed lines';
|
|
10
14
|
export function run(argv) {
|
|
11
15
|
const { values, positionals } = parseArgs({
|
|
@@ -59,6 +63,9 @@ export function run(argv) {
|
|
|
59
63
|
return colorDiff(h.text, ' ');
|
|
60
64
|
return [colorDiff(header, ' '), ...body.map((l) => style('dim', (l.idx === null ? '' : String(l.idx)).padStart(5)) + ' ' + colorDiff(l.raw))].join('\n');
|
|
61
65
|
};
|
|
62
|
-
|
|
66
|
+
// What the diff itself says about attention, for the author to read before glossing.
|
|
67
|
+
const summary = withSignals(() => summaryLines(analyze(hunks, loadConfig())));
|
|
68
|
+
const signals = summary ? ['', ...summary] : [];
|
|
69
|
+
page([hunks.map((h) => hunkLabel(h) + style('dim', ` (${changedCount(h)} changed)`) + (values.stat ? '' : '\n' + numbered(h) + '\n')).join('\n'), ...signals].join('\n'));
|
|
63
70
|
return 0;
|
|
64
71
|
}
|
package/dist/commands/review.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
|
+
import { plain } from '../plain.js';
|
|
2
3
|
import { commitDiff, commitSubject, isWipSnapshot, resolveCommits } from '../git.js';
|
|
3
4
|
import { parseDiff } from '../diff.js';
|
|
4
5
|
import { refLabel, sliceText } from '../selection.js';
|
|
@@ -15,16 +16,16 @@ function fenced(lang, body) {
|
|
|
15
16
|
// Author text is JSON-quoted so it stays on one line and can't fake a heading
|
|
16
17
|
// or end the material section.
|
|
17
18
|
const q = (s) => JSON.stringify(s);
|
|
18
|
-
const hunkLine = (h) => `- ${h.id} ${h.oldFile && h.oldFile !== h.file ? `${h.oldFile} → ` :
|
|
19
|
+
const hunkLine = (h) => `- ${h.id} ${plain(h.oldFile && h.oldFile !== h.file ? `${h.oldFile} → ${h.file}` : h.file)} ` +
|
|
19
20
|
`(-${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines}, +${h.added} -${h.removed})`;
|
|
20
21
|
// The material for one commit: its gloss and the code under each group, or,
|
|
21
22
|
// for an unglossed commit in a range, its whole diff.
|
|
22
23
|
function commitMaterial(sha, gloss) {
|
|
23
24
|
if (!gloss) {
|
|
24
25
|
return [
|
|
25
|
-
`## Commit ${sha.slice(0, 10)}: ${commitSubject(sha)}`,
|
|
26
|
+
`## Commit ${sha.slice(0, 10)}: ${plain(commitSubject(sha))}`,
|
|
26
27
|
'This commit has no gloss. Review its changes as unexplained.',
|
|
27
|
-
fenced('diff', parseDiff(commitDiff(sha)).map((h) => `# ${h.file}\n${h.text}`).join('\n')),
|
|
28
|
+
fenced('diff', parseDiff(commitDiff(sha)).map((h) => `# ${plain(h.file)}\n${h.text}`).join('\n')),
|
|
28
29
|
];
|
|
29
30
|
}
|
|
30
31
|
const a = gloss.note.annotation;
|
|
@@ -47,7 +48,7 @@ function commitMaterial(sha, gloss) {
|
|
|
47
48
|
const sections = [
|
|
48
49
|
isWipSnapshot(sha)
|
|
49
50
|
? `## Uncommitted work (snapshot ${sha.slice(0, 10)})\n\nThese changes are not committed yet; the author glossed them before committing.`
|
|
50
|
-
: `## Commit ${sha.slice(0, 10)}: ${gloss.subject}`,
|
|
51
|
+
: `## Commit ${sha.slice(0, 10)}: ${plain(gloss.subject)}`,
|
|
51
52
|
...gloss.warnings.map((w) => `> **Warning from git-gloss:** ${w}`),
|
|
52
53
|
`**Task (author's statement):** ${q(a.task)}`,
|
|
53
54
|
...(a.summary ? [`**Summary (author's statement):** ${q(a.summary)}`] : []),
|
package/dist/commands/reviews.js
CHANGED
|
@@ -2,14 +2,18 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
4
|
import { commitSubject, gitTry, GlossError, resolveCommits } from '../git.js';
|
|
5
|
-
import { addReviews, reviewStatus, stamp, statusPhrase, verdictOf } from '../reviews.js';
|
|
5
|
+
import { addReviews, checkBy, checkDecision, MAX_DECISION_LINE, MAX_FINDINGS, MAX_REVIEW_LINE, decisionSummary, readDecisions, readReviews, reviewStatus, stamp, statusPhrase, verdictOf } from '../reviews.js';
|
|
6
|
+
import { plain } from '../plain.js';
|
|
6
7
|
import { page, style } from '../render.js';
|
|
7
|
-
export const usage = 'reviews [<commit> | <range> | wip] [--stamp | --record] the reviews recorded for commits, with their findings; --stamp and --
|
|
8
|
+
export const usage = 'reviews [<commit> | <range> | wip] [--stamp | --record] the reviews recorded for commits, with their findings and what was decided about them\n reviews <target> --decide <review-id> --finding <text> --outcome <fixed|follow-up|rejected|accepted> [--note <text>] [--by <name>]\n reviews <target> --decide <review-id> --complete the arbiter is done; --stamp, --record and --decide are for tools';
|
|
8
9
|
export function run(argv) {
|
|
9
10
|
const { values, positionals } = parseArgs({
|
|
10
11
|
args: argv,
|
|
11
12
|
allowPositionals: true,
|
|
12
|
-
options: {
|
|
13
|
+
options: {
|
|
14
|
+
stamp: { type: 'boolean' }, record: { type: 'boolean' }, decide: { type: 'string' }, finding: { type: 'string' },
|
|
15
|
+
outcome: { type: 'string' }, note: { type: 'string' }, complete: { type: 'boolean' }, by: { type: 'string' },
|
|
16
|
+
},
|
|
13
17
|
});
|
|
14
18
|
if (values.record) {
|
|
15
19
|
const input = JSON.parse(readFileSync(0, 'utf8'));
|
|
@@ -28,6 +32,9 @@ export function run(argv) {
|
|
|
28
32
|
if (!Array.isArray(seen?.lines) || !Array.isArray(seen?.groups))
|
|
29
33
|
throw new GlossError(`--record: the stamp for ${sha.slice(0, 12)} has no lines or groups`);
|
|
30
34
|
}
|
|
35
|
+
// Readers skip what is over these limits, so a record that is would vanish: refuse it here instead.
|
|
36
|
+
if (input.findings.length > MAX_FINDINGS)
|
|
37
|
+
throw new GlossError(`--record: the findings are too long (${input.findings.length} characters; readers skip reports over ${MAX_FINDINGS / 1024} KiB); shorten the report`);
|
|
31
38
|
const id = randomUUID();
|
|
32
39
|
const at = new Date().toISOString();
|
|
33
40
|
for (const [sha, seen] of Object.entries(input.stamp)) {
|
|
@@ -37,6 +44,8 @@ export function run(argv) {
|
|
|
37
44
|
target: input.target, lines: seen.lines, groups: seen.groups, findings: input.findings,
|
|
38
45
|
...(verdictOf(input.findings) ? { verdict: verdictOf(input.findings) } : {}),
|
|
39
46
|
};
|
|
47
|
+
if (JSON.stringify(record).length > MAX_REVIEW_LINE)
|
|
48
|
+
throw new GlossError(`--record: the record for ${sha.slice(0, 10)} is over ${MAX_REVIEW_LINE / 1024 / 1024} MiB with its stamp, which readers skip; review fewer changed lines at a time`);
|
|
40
49
|
addReviews(sha, [record]);
|
|
41
50
|
}
|
|
42
51
|
const n = Object.keys(input.stamp).length;
|
|
@@ -44,6 +53,42 @@ export function run(argv) {
|
|
|
44
53
|
return 0;
|
|
45
54
|
}
|
|
46
55
|
const { shas } = resolveCommits(positionals[0] ?? 'HEAD');
|
|
56
|
+
if (values.decide !== undefined) {
|
|
57
|
+
const id = values.decide;
|
|
58
|
+
if (values.complete) {
|
|
59
|
+
if (values.finding !== undefined || values.outcome !== undefined || values.note !== undefined) {
|
|
60
|
+
throw new GlossError('--complete marks the review as arbitrated; it takes no --finding, --outcome or --note');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else
|
|
64
|
+
checkDecision(values);
|
|
65
|
+
// Every commit of the target holding that review gets the decision.
|
|
66
|
+
const holders = shas.filter((sha) => readReviews(sha).some((r) => r.id === id));
|
|
67
|
+
if (!holders.length) {
|
|
68
|
+
const known = [...new Set(shas.flatMap(readReviews).map((r) => r.id))];
|
|
69
|
+
throw new GlossError(`no review ${id} on ${positionals[0] ?? 'HEAD'}` + (known.length ? ` (reviews there: ${plain(known.join(', '))})` : ' (it has no reviews)'));
|
|
70
|
+
}
|
|
71
|
+
const by = values.by !== undefined ? values.by.trim() : gitTry(['config', 'user.name'])?.trim() || 'unknown';
|
|
72
|
+
checkBy(by);
|
|
73
|
+
const record = {
|
|
74
|
+
decision: 1, review: id, at: new Date().toISOString(), by,
|
|
75
|
+
...(values.complete ? { complete: true } : {
|
|
76
|
+
finding: values.finding.trim(), outcome: values.outcome,
|
|
77
|
+
...(values.note ? { note: values.note } : {}),
|
|
78
|
+
}),
|
|
79
|
+
};
|
|
80
|
+
// Control characters are written as 6-byte escapes, so check the line itself against the reader's cap.
|
|
81
|
+
if (JSON.stringify(record).length > MAX_DECISION_LINE)
|
|
82
|
+
throw new GlossError('that decision is too long to record: the finding and note, as written, exceed what readers accept; shorten them');
|
|
83
|
+
for (const sha of holders)
|
|
84
|
+
addReviews(sha, [record]);
|
|
85
|
+
console.log(`recorded ${values.complete ? 'the review as complete' : `"${record.finding}": ${record.outcome}`} on ${holders.length} commit${holders.length === 1 ? '' : 's'}`);
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
for (const k of ['finding', 'outcome', 'note', 'complete', 'by']) {
|
|
89
|
+
if (values[k] !== undefined)
|
|
90
|
+
throw new GlossError(`--${k} goes with --decide <review-id>`);
|
|
91
|
+
}
|
|
47
92
|
if (values.stamp) {
|
|
48
93
|
console.log(JSON.stringify(Object.fromEntries(shas.map((sha) => [sha, stamp(sha)]))));
|
|
49
94
|
return 0;
|
|
@@ -51,7 +96,8 @@ export function run(argv) {
|
|
|
51
96
|
const out = [];
|
|
52
97
|
for (const sha of shas) {
|
|
53
98
|
const statuses = reviewStatus(sha);
|
|
54
|
-
|
|
99
|
+
const decisions = readDecisions(sha);
|
|
100
|
+
out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(commitSubject(sha)))}`);
|
|
55
101
|
if (!statuses.length) {
|
|
56
102
|
out.push(style('dim', ' no reviews recorded'), '');
|
|
57
103
|
continue;
|
|
@@ -59,8 +105,15 @@ export function run(argv) {
|
|
|
59
105
|
for (const s of statuses) {
|
|
60
106
|
out.push(` ${statusPhrase(s)}`);
|
|
61
107
|
for (const r of s.reviews) {
|
|
62
|
-
out.push('', style('dim', ` ── ${r.stage} by ${r.agent}${r.model ? `:${r.model}` : ''}${r.mode ? ` (${r.mode})` : ''}, ${r.at.slice(0, 16).replace('T', ' ')}, of ${r.target}`));
|
|
108
|
+
out.push('', style('dim', ` ── ${r.stage} by ${r.agent}${r.model ? `:${r.model}` : ''}${r.mode ? ` (${r.mode})` : ''}, ${r.at.slice(0, 16).replace('T', ' ')}, of ${r.target}, id ${plain(r.id)}`));
|
|
63
109
|
out.push(...r.findings.trim().split('\n').map((l) => ` ${l}`));
|
|
110
|
+
const ds = decisions.filter((d) => d.review === r.id);
|
|
111
|
+
if (r.verdict !== 'clean' || ds.length) {
|
|
112
|
+
out.push('', ` decisions on this review: ${decisionSummary(ds)}`);
|
|
113
|
+
for (const d of ds.filter((d) => d.outcome).sort((a, b) => a.at.localeCompare(b.at))) {
|
|
114
|
+
out.push(` ${d.outcome}: ${plain(d.finding)}${d.note ? ` — ${plain(d.note)}` : ''} ${style('dim', `(${plain(d.by)}, ${plain(d.at).slice(0, 10)})`)}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
64
117
|
}
|
|
65
118
|
out.push('');
|
|
66
119
|
}
|
package/dist/commands/show.js
CHANGED
|
@@ -2,10 +2,13 @@ import { parseArgs } from 'node:util';
|
|
|
2
2
|
import { commitSubject, gitTry, readNote, resolveCommits } from '../git.js';
|
|
3
3
|
import { refLabel, sliceStats, sliceText } from '../selection.js';
|
|
4
4
|
import { loadGloss } from '../loader.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
5
|
+
import { impactRank } from '../attention.js';
|
|
6
|
+
import { showsImpact, tryAnalyze } from '../floor.js';
|
|
7
|
+
import { IMPACT, NECESSITY, isImpact, isNecessity } from '../schema.js';
|
|
8
|
+
import { current, decisionSummary, readDecisions, readReviews, reviewStatus, statusPhrase, verdict } from '../reviews.js';
|
|
9
|
+
import { plain } from '../plain.js';
|
|
10
|
+
import { attentionBlock, colorDiff, derivedImpactTag, field, groupHeader, hunkLabel, IMPACT_W, impactTag, necessityWord, noteFooter, page, style, termWidth, wrap, } from '../render.js';
|
|
11
|
+
export const usage = 'show [<commit> | <range>] [-g <group>]... [--full] [--diff] [--only <necessity|impact,...> (a group must match each kind named)] [--sort impact] summarise a gloss, or each commit in a range';
|
|
9
12
|
// Counts over the lines each part selects, not whole hunks, so groups that
|
|
10
13
|
// split a hunk don't both count all of it.
|
|
11
14
|
function stats(parts) {
|
|
@@ -26,12 +29,18 @@ export function run(argv) {
|
|
|
26
29
|
full: { type: 'boolean' },
|
|
27
30
|
diff: { type: 'boolean' },
|
|
28
31
|
only: { type: 'string' },
|
|
32
|
+
sort: { type: 'string' },
|
|
29
33
|
},
|
|
30
34
|
});
|
|
31
|
-
const
|
|
32
|
-
const unknown =
|
|
35
|
+
const names = values.only?.split(',').map((s) => s.trim());
|
|
36
|
+
const unknown = names?.filter((n) => !isNecessity(n) && !isImpact(n));
|
|
33
37
|
if (unknown?.length) {
|
|
34
|
-
console.error(`error: unknown necessity ${unknown.join(', ')}; expected ${NECESSITY.join(', ')}`);
|
|
38
|
+
console.error(`error: unknown necessity or impact ${unknown.join(', ')}; expected ${NECESSITY.join(', ')} or ${IMPACT.join(', ')}`);
|
|
39
|
+
return 2;
|
|
40
|
+
}
|
|
41
|
+
const only = names && { names, necessities: names.filter(isNecessity), impacts: names.filter(isImpact) };
|
|
42
|
+
if (values.sort !== undefined && values.sort !== 'impact') {
|
|
43
|
+
console.error(`error: unknown sort ${values.sort}; expected impact`);
|
|
35
44
|
return 2;
|
|
36
45
|
}
|
|
37
46
|
const { shas, merges, range } = resolveCommits(positionals[0] ?? 'HEAD');
|
|
@@ -63,7 +72,7 @@ export function run(argv) {
|
|
|
63
72
|
}
|
|
64
73
|
const out = renderCommit(sha, gloss, values, only, true);
|
|
65
74
|
if (!out) {
|
|
66
|
-
console.error(`no groups match ${[values.group && `-g ${values.group.join(',')}`, only && `--only ${only.join(',')}`].filter(Boolean).join(' and ')}`);
|
|
75
|
+
console.error(`no groups match ${[values.group && `-g ${values.group.join(',')}`, only && `--only ${only.names.join(',')}`].filter(Boolean).join(' and ')}`);
|
|
67
76
|
return 1;
|
|
68
77
|
}
|
|
69
78
|
page(out.join('\n'));
|
|
@@ -94,25 +103,34 @@ export function run(argv) {
|
|
|
94
103
|
out.push('', style('dim', '─'.repeat(Math.min(termWidth(), 60))));
|
|
95
104
|
const gloss = glosses[i];
|
|
96
105
|
if (!gloss) {
|
|
97
|
-
out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', commitSubject(sha))}`, style('dim', 'no gloss for this commit'));
|
|
106
|
+
out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(commitSubject(sha)))}`, style('dim', 'no gloss for this commit'));
|
|
98
107
|
return;
|
|
99
108
|
}
|
|
100
109
|
// An --only filter can leave a commit with nothing to show; say so and move on.
|
|
101
110
|
out.push(...(renderCommit(sha, gloss, values, only, false) ??
|
|
102
|
-
[`${style('yellow', sha.slice(0, 10))} ${style('bold', gloss.subject)}`, style('dim', `no ${only?.join('/')} groups`)]));
|
|
111
|
+
[`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(gloss.subject))}`, style('dim', `no ${only?.names.join('/')} groups`)]));
|
|
103
112
|
});
|
|
104
113
|
if (glossed)
|
|
105
114
|
out.push('', style('dim', 'One commit in detail: git gloss show <commit> --full (or -g <group> for its code)'));
|
|
106
115
|
page(out.join('\n'));
|
|
107
116
|
return 0;
|
|
108
117
|
}
|
|
118
|
+
const matchesOnly = (g, only) => !only ||
|
|
119
|
+
((!only.necessities.length || only.necessities.includes(g.necessity)) && (!only.impacts.length || (!!g.impact && only.impacts.includes(g.impact))));
|
|
109
120
|
// One line on whether the commit as it is now was reviewed, per stage.
|
|
110
121
|
function reviewsLine(sha) {
|
|
111
122
|
const statuses = reviewStatus(sha);
|
|
112
123
|
if (!statuses.length)
|
|
113
124
|
return style('dim', 'reviews: none recorded');
|
|
114
125
|
const color = (s) => (!current(s) || verdict(s) === 'issues' ? 'yellow' : verdict(s) === 'clean' ? 'green' : 'dim');
|
|
115
|
-
|
|
126
|
+
// What was decided about the reviews that found something (or didn't say);
|
|
127
|
+
// a commit with only clean reviews has nothing to decide.
|
|
128
|
+
const open = readReviews(sha).filter((r) => r.verdict !== 'clean');
|
|
129
|
+
const decisions = readDecisions(sha).filter((d) => open.some((r) => r.id === d.review));
|
|
130
|
+
const decided = open.length
|
|
131
|
+
? ` ${style('dim', '·')} decisions: ${decisionSummary(decisions, open.every((r) => decisions.some((d) => d.review === r.id && d.complete)))}`
|
|
132
|
+
: '';
|
|
133
|
+
return `reviews: ${statuses.map((s) => style(color(s), statusPhrase(s))).join(style('dim', ' · '))}${decided}` +
|
|
116
134
|
style('dim', ` (findings: git gloss reviews ${sha.slice(0, 7)})`);
|
|
117
135
|
}
|
|
118
136
|
// One commit's view, or null if no group matches -g/--only. `hints` adds the
|
|
@@ -120,21 +138,31 @@ function reviewsLine(sha) {
|
|
|
120
138
|
function renderCommit(sha, gloss, values, only, hints) {
|
|
121
139
|
const a = gloss.note.annotation;
|
|
122
140
|
const short = sha.slice(0, 7);
|
|
123
|
-
const out = [`${style('yellow', sha.slice(0, 10))} ${style('bold', gloss.subject)}`];
|
|
141
|
+
const out = [`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(gloss.subject))}`];
|
|
124
142
|
out.push(...field('task:', a.task, 0));
|
|
125
143
|
if (a.summary)
|
|
126
144
|
out.push(...field('summary:', a.summary, 0));
|
|
127
145
|
for (const w of gloss.warnings)
|
|
128
146
|
out.push(...field('⚠', w, 0).map((l) => style('red', l)));
|
|
129
|
-
|
|
147
|
+
const an = tryAnalyze(gloss);
|
|
148
|
+
out.push(...attentionBlock(a.attention, an ? { floor: an.floor, derived: an.derived, timedOut: an.timedOut } : undefined), reviewsLine(sha));
|
|
130
149
|
const groups = gloss.groups
|
|
131
|
-
.filter((g) =>
|
|
150
|
+
.filter((g) => matchesOnly(g.group, only))
|
|
132
151
|
.filter((g) => !values.group || values.group.includes(g.group.id));
|
|
133
152
|
if (!groups.length)
|
|
134
153
|
return null;
|
|
154
|
+
// --sort impact: the most consequential first, then by necessity as usual.
|
|
155
|
+
const byNecessity = (x, y) => NECESSITY.indexOf(x.group.necessity) - NECESSITY.indexOf(y.group.necessity);
|
|
156
|
+
const byImpact = (x, y) => impactRank(y.group.impact) - impactRank(x.group.impact) || byNecessity(x, y);
|
|
157
|
+
const detail = values.group || values.full || values.diff;
|
|
158
|
+
// The summary lists required first; detail views keep the gloss's order unless asked.
|
|
159
|
+
if (values.sort === 'impact')
|
|
160
|
+
groups.sort(byImpact);
|
|
161
|
+
else if (!detail)
|
|
162
|
+
groups.sort(byNecessity);
|
|
135
163
|
// -g: chosen groups with their code. --full: every group's details, plus
|
|
136
164
|
// alternatives and risks. --diff: --full plus code.
|
|
137
|
-
if (
|
|
165
|
+
if (detail) {
|
|
138
166
|
const withCode = values.diff || !!values.group;
|
|
139
167
|
for (const g of groups) {
|
|
140
168
|
out.push('', ...groupHeader(g.group));
|
|
@@ -163,13 +191,12 @@ function renderCommit(sha, gloss, values, only, hints) {
|
|
|
163
191
|
return out;
|
|
164
192
|
}
|
|
165
193
|
// Summary: one line per group.
|
|
166
|
-
const sorted = [...groups].sort((x, y) => NECESSITY.indexOf(x.group.necessity) - NECESSITY.indexOf(y.group.necessity));
|
|
167
194
|
const tally = NECESSITY.map((n) => [n, gloss.groups.filter((g) => g.group.necessity === n).length])
|
|
168
195
|
.filter(([, c]) => c > 0)
|
|
169
196
|
.map(([n, c]) => `${c} ${necessityWord(n)}`);
|
|
170
|
-
const showing = groups.length < gloss.groups.length ? style('dim', ` (showing ${groups.length}: --only ${only?.join(',')})`) : '';
|
|
197
|
+
const showing = groups.length < gloss.groups.length ? style('dim', ` (showing ${groups.length}: --only ${only?.names.join(',')})`) : '';
|
|
171
198
|
out.push('', `${style('bold', `${gloss.groups.length} groups:`)} ${tally.join(style('dim', ' · '))}${showing}`, '');
|
|
172
|
-
const rows =
|
|
199
|
+
const rows = groups.map((g) => {
|
|
173
200
|
const s = stats(currentParts(g));
|
|
174
201
|
const stale = g.hunks.filter((h) => h.status !== 'ok').length;
|
|
175
202
|
return {
|
|
@@ -182,14 +209,21 @@ function renderCommit(sha, gloss, values, only, hints) {
|
|
|
182
209
|
const statW = Math.max(...rows.map((r) => r.stat.length), un.count ? 8 : 0);
|
|
183
210
|
const addW = Math.max(...rows.map((r) => r.added.length));
|
|
184
211
|
const remW = Math.max(...rows.map((r) => r.removed.length));
|
|
185
|
-
|
|
212
|
+
// An impact column only when some group declares one or the diff puts one
|
|
213
|
+
// above the baseline (module), so plain glosses look as before. Undeclared
|
|
214
|
+
// ones show the group's floor in parentheses.
|
|
215
|
+
const floorOf = (g) => an?.groups.get(g.group.id)?.floor;
|
|
216
|
+
const showImpact = showsImpact(gloss.groups.map((g) => g.group), an);
|
|
217
|
+
const impactW = showImpact ? IMPACT_W + 2 : 0;
|
|
218
|
+
const anyDerived = showImpact && gloss.groups.some((g) => !g.group.impact && floorOf(g));
|
|
219
|
+
const intentCol = 2 + 11 + 2 + impactW + idW + 2;
|
|
186
220
|
const statsW = 2 + statW + 1 + addW + 1 + remW;
|
|
187
221
|
const intentW = Math.max(20, termWidth() - intentCol - statsW);
|
|
188
222
|
for (const r of rows) {
|
|
189
223
|
// Intents wrap within their column rather than being cut off; the stats
|
|
190
224
|
// stay on the first line so the numbers still line up.
|
|
191
225
|
const [first, ...rest] = wrap(r.g.group.intent, intentW);
|
|
192
|
-
out.push(` ${necessityWord(r.g.group.necessity, 11)} ${style('bold', r.g.group.id.padEnd(idW))} ` +
|
|
226
|
+
out.push(` ${necessityWord(r.g.group.necessity, 11)} ${impactW ? (r.g.group.impact ? impactTag(r.g.group.impact, IMPACT_W) : floorOf(r.g) ? derivedImpactTag(floorOf(r.g), IMPACT_W) : ' '.repeat(IMPACT_W)) + ' ' : ''}${style('bold', r.g.group.id.padEnd(idW))} ` +
|
|
193
227
|
`${first.padEnd(intentW)} ` +
|
|
194
228
|
style('dim', r.stat.padStart(statW)) + ' ' + style('green', r.added.padStart(addW)) + ' ' + style('red', r.removed.padStart(remW)), ...rest.map((l) => ' '.repeat(intentCol) + l));
|
|
195
229
|
if (r.stale) {
|
|
@@ -200,6 +234,8 @@ function renderCommit(sha, gloss, values, only, hints) {
|
|
|
200
234
|
const where = gloss.unannotated.map((u) => refLabel(u.hunk.id, u.lines)).join(', ');
|
|
201
235
|
out.push(` ${style(['bold', 'red'], 'unannotated'.padEnd(11))} ${style('red', `+${un.added} -${un.removed} not in any group: ${where}`)}`);
|
|
202
236
|
}
|
|
237
|
+
if (anyDerived)
|
|
238
|
+
out.push('', style('dim', '(impact in parentheses is derived from the diff, not declared)'));
|
|
203
239
|
// Rejected alternatives and risks: short, and often what a reader most
|
|
204
240
|
// lacks, so they're in the summary rather than only behind --full.
|
|
205
241
|
out.push(...noteFooter(gloss.note));
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { plain } from '../plain.js';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { loadConfig } from '../config.js';
|
|
4
|
+
import { commitDiff, diffBetween, git, GitError, resolveCommit, workingDiff } from '../git.js';
|
|
5
|
+
import { parseDiff } from '../diff.js';
|
|
6
|
+
import { analyze, CONFIG_EDIT_NOTE } from '../signals.js';
|
|
7
|
+
import { impactRank } from '../attention.js';
|
|
8
|
+
import { ATTENTION_STYLE, impactTag, page, style } from '../render.js';
|
|
9
|
+
export const usage = 'signals [<commit> | <range> | wip] [--json] what the diff itself says about attention: path classes, pattern hits, dependency bumps and the computed floor';
|
|
10
|
+
const pct = (x) => `${Math.round(x * 100)}%`;
|
|
11
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
|
12
|
+
const statsLine = (s) => `${plural(s.files, 'file')}, ${plural(s.hunks, 'hunk')}, +${s.added} -${s.removed}, ${plural(s.testFiles, 'test file')}` +
|
|
13
|
+
(s.hunks ? ` (${pct(s.testHunkShare)} of hunks in tests)` : '');
|
|
14
|
+
// The short form `hunks` prints at its end: the floor, the files that set it,
|
|
15
|
+
// and the stats.
|
|
16
|
+
export function summaryLines(sig, max = 6) {
|
|
17
|
+
const out = [style('bold', 'signals:') + ` floor attention = ${style(['bold', ATTENTION_STYLE[sig.floor.attention]], sig.floor.attention)}`];
|
|
18
|
+
const files = sig.files.filter((f) => impactRank(f.floor) >= impactRank('shared'))
|
|
19
|
+
.sort((a, b) => impactRank(b.floor) - impactRank(a.floor) || a.file.localeCompare(b.file));
|
|
20
|
+
for (const f of files.slice(0, max))
|
|
21
|
+
out.push(` ${(f.floor + ':').padEnd(10)} ${plain(f.file)}${f.why.length ? ` (${plain(f.why.join('; '))})` : ''}`);
|
|
22
|
+
if (files.length > max)
|
|
23
|
+
out.push(` ... and ${files.length - max} more at shared or above (git gloss signals)`);
|
|
24
|
+
for (const r of sig.floor.reasons.filter((r) => / files changed /.test(r)))
|
|
25
|
+
out.push(` large: ${plain(r)}`);
|
|
26
|
+
if (sig.floor.reasons.includes(CONFIG_EDIT_NOTE))
|
|
27
|
+
out.push(` note: ${CONFIG_EDIT_NOTE}`);
|
|
28
|
+
out.push(` stats: ${statsLine(sig.stats)}`);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
function detailLines(sig, label) {
|
|
32
|
+
const out = [style('bold', `signals for ${label}:`) + ` floor attention = ${style(['bold', ATTENTION_STYLE[sig.floor.attention]], sig.floor.attention)}` +
|
|
33
|
+
style('dim', ` (floor impact: ${sig.floor.impact})`)];
|
|
34
|
+
for (const r of sig.floor.reasons)
|
|
35
|
+
out.push(` why: ${plain(r)}`);
|
|
36
|
+
out.push(` stats: ${statsLine(sig.stats)}`);
|
|
37
|
+
const notable = sig.hunks.filter((h) => impactRank(h.floor) > impactRank('module') || h.hits.length);
|
|
38
|
+
if (notable.length)
|
|
39
|
+
out.push('', style('bold', 'hunks with signals'));
|
|
40
|
+
for (const h of notable) {
|
|
41
|
+
out.push(` ${style('bold', h.id.padEnd(5))} ${impactTag(h.floor, 11)} ${plain(h.file)}` +
|
|
42
|
+
style('dim', ` [${h.classes.join(', ') || 'no class'}]${h.why.length ? ' ' + plain(h.why.join('; ')) : ''}`));
|
|
43
|
+
for (const x of h.hits.slice(0, 8)) {
|
|
44
|
+
out.push(` ${h.id}:${x.line} ${x.sign} ${x.class} pattern "${x.name}"${x.timedOut ? ' (pattern timed out: counted as matching)' : ''}${x.damped ? style('dim', ' (damped: test, docs or lockfile path)') : ''} ${style('dim', plain(x.text))}`);
|
|
45
|
+
}
|
|
46
|
+
if (h.hits.length > 8)
|
|
47
|
+
out.push(style('dim', ` ... ${h.hits.length - 8} more hits (--json)`));
|
|
48
|
+
}
|
|
49
|
+
const quiet = sig.hunks.length - notable.length;
|
|
50
|
+
if (quiet)
|
|
51
|
+
out.push(style('dim', ` (${plural(quiet, 'other hunk')} at module or below, no pattern hits)`));
|
|
52
|
+
if (sig.dependencies.length)
|
|
53
|
+
out.push('', style('bold', 'dependency changes'));
|
|
54
|
+
for (const d of sig.dependencies) {
|
|
55
|
+
out.push(` ${plain(d.file)} (${d.kind}${d.format ? `, parsed as ${d.format}` : d.kind === 'lockfile' ? ', no version detail for this format' : ''})`);
|
|
56
|
+
for (const b of d.bumps.slice(0, 20)) {
|
|
57
|
+
const what = plain(b.kind === 'bump' ? `${b.name} ${b.from} -> ${b.to}` : b.kind === 'added' ? `+ ${b.name} ${b.to}` : `- ${b.name} ${b.from}`);
|
|
58
|
+
out.push(` ${what}${b.unrelated ? style('yellow', ' (transitive: no manifest change names it)') : ''}`);
|
|
59
|
+
}
|
|
60
|
+
if (d.bumps.length > 20)
|
|
61
|
+
out.push(style('dim', ` ... ${d.bumps.length - 20} more (--json)`));
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
// The diff to analyse: wip is the working tree as it is now (no snapshot is taken).
|
|
66
|
+
function target(arg) {
|
|
67
|
+
if (arg === 'wip')
|
|
68
|
+
return { diff: workingDiff().diff, label: 'uncommitted changes' };
|
|
69
|
+
if (arg.includes('...'))
|
|
70
|
+
throw new GitError(`${arg}: use A..B for the commits on B since A`);
|
|
71
|
+
if (arg.includes('..')) {
|
|
72
|
+
const [a, b] = arg.split('..');
|
|
73
|
+
const base = git(['merge-base', '--end-of-options', a || 'HEAD', b || 'HEAD']).trim();
|
|
74
|
+
return { diff: diffBetween(base, resolveCommit(b || 'HEAD')), label: arg };
|
|
75
|
+
}
|
|
76
|
+
const sha = resolveCommit(arg);
|
|
77
|
+
return { diff: commitDiff(sha), label: sha.slice(0, 10) };
|
|
78
|
+
}
|
|
79
|
+
export function run(argv) {
|
|
80
|
+
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { json: { type: 'boolean' } } });
|
|
81
|
+
const { diff, label } = target(positionals[0] ?? 'HEAD');
|
|
82
|
+
const sig = analyze(parseDiff(diff), loadConfig());
|
|
83
|
+
if (values.json) {
|
|
84
|
+
console.log(JSON.stringify({ target: label, ...sig }, null, 2));
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
page(detailLines(sig, label).join('\n'));
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
package/dist/commands/sync.js
CHANGED
|
@@ -46,6 +46,18 @@ function shareable(remote, pushing) {
|
|
|
46
46
|
return new Set();
|
|
47
47
|
return new Set(git(['rev-list', ...starts, '--']).split('\n').filter(Boolean));
|
|
48
48
|
}
|
|
49
|
+
// The fetched ref is the remote's to set: it must be a commit before notes
|
|
50
|
+
// code (or an update-ref onto our own notes ref) touches it.
|
|
51
|
+
function checkTracking(remote, ref, tracking) {
|
|
52
|
+
if (gitTry(['rev-parse', '-q', '--verify', `${tracking}^{commit}`]) === null) {
|
|
53
|
+
gitTry(['update-ref', '-d', tracking]);
|
|
54
|
+
throw new GitError(`${remote}'s ${ref} is not a notes commit; ignoring it (nothing was changed here)`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function fetchTracking(remote, ref, tracking) {
|
|
58
|
+
git(['fetch', '-q', remote, `+${ref}:${tracking}`]);
|
|
59
|
+
checkTracking(remote, ref, tracking);
|
|
60
|
+
}
|
|
49
61
|
const PUSH_REF = 'refs/notes/gloss-sync-tmp';
|
|
50
62
|
// One fetch → merge → push round. Returns null if the push lost a race with
|
|
51
63
|
// someone else's push and the round should be retried.
|
|
@@ -53,7 +65,7 @@ function round(remote, pushing, carried) {
|
|
|
53
65
|
const tracking = `refs/notes/remotes/${remote}/gloss`;
|
|
54
66
|
const remoteHas = gitTry(['ls-remote', '--exit-code', remote, NOTES_REF]) !== null;
|
|
55
67
|
if (remoteHas)
|
|
56
|
-
|
|
68
|
+
fetchTracking(remote, NOTES_REF, tracking);
|
|
57
69
|
const local = noteList(NOTES_REF);
|
|
58
70
|
const theirs = remoteHas ? noteList(tracking) : new Map();
|
|
59
71
|
// The last state both sides shared. A gloss changed on one side only is
|
|
@@ -159,7 +171,7 @@ function syncReviews(remote, pushing) {
|
|
|
159
171
|
const tracking = `refs/notes/remotes/${remote}/gloss-reviews`;
|
|
160
172
|
const remoteHas = gitTry(['ls-remote', '--exit-code', remote, REVIEWS_REF]) !== null;
|
|
161
173
|
if (remoteHas)
|
|
162
|
-
|
|
174
|
+
fetchTracking(remote, REVIEWS_REF, tracking);
|
|
163
175
|
const before = noteList(REVIEWS_REF);
|
|
164
176
|
const theirs = remoteHas ? noteList(tracking) : new Map();
|
|
165
177
|
if (remoteHas) {
|
package/dist/commands/why.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { plain } from '../plain.js';
|
|
1
2
|
import { parseArgs } from 'node:util';
|
|
2
3
|
import { blameRange, commitSubject, lineHistory } from '../git.js';
|
|
3
4
|
import { loadGloss } from '../loader.js';
|
|
@@ -158,8 +159,8 @@ export function run(argv) {
|
|
|
158
159
|
const renderStep = (s, label, older) => {
|
|
159
160
|
// Where the lines were in an older commit: after edits or a file rename
|
|
160
161
|
// that differs from the query.
|
|
161
|
-
const then = older || s.file !== file ? style('dim', ` (${s.file}:${formatRanges(s.lines)} then)`) : '';
|
|
162
|
-
out.push(`${label}${style('yellow', s.commit.slice(0, 10))} ${s.subject}${then}`);
|
|
162
|
+
const then = older || s.file !== file ? style('dim', ` (${plain(s.file)}:${formatRanges(s.lines)} then)`) : '';
|
|
163
|
+
out.push(`${label}${style('yellow', s.commit.slice(0, 10))} ${plain(s.subject)}${then}`);
|
|
163
164
|
if (!s.gloss) {
|
|
164
165
|
out.push(style('dim', ' no gloss for this commit'));
|
|
165
166
|
return;
|