@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.
@@ -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
- export const usage = 'add [<commit>] [-f <file>] [--force] [--partial] [--dry-run] attach an annotation (JSON from file or stdin)';
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"`);
@@ -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 lists branches and commits and shows each selection with
8
- // the same view as `git gloss html`.
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,
@@ -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
- page(hunks.map((h) => hunkLabel(h) + style('dim', ` (${changedCount(h)} changed)`) + (values.stat ? '' : '\n' + numbered(h) + '\n')).join('\n'));
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
  }
@@ -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} → ` : ''}${h.file} ` +
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)}`] : []),
@@ -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 --record are for review tools';
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: { stamp: { type: 'boolean' }, record: { type: 'boolean' } },
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
- out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', commitSubject(sha))}`);
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
  }
@@ -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 { NECESSITY } from '../schema.js';
6
- import { current, reviewStatus, statusPhrase, verdict } from '../reviews.js';
7
- import { colorDiff, field, groupHeader, hunkLabel, necessityWord, noteFooter, page, style, termWidth, wrap, } from '../render.js';
8
- export const usage = 'show [<commit> | <range>] [-g <group>]... [--full] [--diff] [--only <necessity,...>] summarise a gloss, or each commit in a range';
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 only = values.only?.split(',').map((s) => s.trim());
32
- const unknown = only?.filter((n) => !NECESSITY.includes(n));
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
- return `reviews: ${statuses.map((s) => style(color(s), statusPhrase(s))).join(style('dim', ' · '))}` +
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
- out.push(reviewsLine(sha));
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) => !only || only.includes(g.group.necessity))
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 (values.group || values.full || values.diff) {
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 = sorted.map((g) => {
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
- const intentCol = 2 + 11 + 2 + idW + 2;
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
+ }
@@ -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
- git(['fetch', '-q', remote, `+${NOTES_REF}:${tracking}`]);
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
- git(['fetch', '-q', remote, `+${REVIEWS_REF}:${tracking}`]);
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) {
@@ -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;