@kgdiff/cli 0.4.0 → 0.6.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.
Files changed (64) hide show
  1. package/README.md +21 -4
  2. package/dist/args.d.ts +8 -1
  3. package/dist/args.d.ts.map +1 -1
  4. package/dist/args.js +37 -0
  5. package/dist/args.js.map +1 -1
  6. package/dist/diff-command.d.ts.map +1 -1
  7. package/dist/diff-command.js +8 -1
  8. package/dist/diff-command.js.map +1 -1
  9. package/dist/errors.d.ts +8 -2
  10. package/dist/errors.d.ts.map +1 -1
  11. package/dist/errors.js +21 -4
  12. package/dist/errors.js.map +1 -1
  13. package/dist/help.d.ts.map +1 -1
  14. package/dist/help.js +9 -0
  15. package/dist/help.js.map +1 -1
  16. package/dist/html.js +2 -2
  17. package/dist/html.js.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/io.d.ts +5 -0
  23. package/dist/io.d.ts.map +1 -1
  24. package/dist/locale.d.ts +34 -1
  25. package/dist/locale.d.ts.map +1 -1
  26. package/dist/locale.js +65 -0
  27. package/dist/locale.js.map +1 -1
  28. package/dist/main.d.ts.map +1 -1
  29. package/dist/main.js +5 -2
  30. package/dist/main.js.map +1 -1
  31. package/dist/markdown.d.ts +12 -2
  32. package/dist/markdown.d.ts.map +1 -1
  33. package/dist/markdown.js +94 -11
  34. package/dist/markdown.js.map +1 -1
  35. package/dist/model.d.ts +42 -2
  36. package/dist/model.d.ts.map +1 -1
  37. package/dist/model.js +82 -0
  38. package/dist/model.js.map +1 -1
  39. package/dist/node-io.d.ts.map +1 -1
  40. package/dist/node-io.js +1 -0
  41. package/dist/node-io.js.map +1 -1
  42. package/dist/report.d.ts +3 -2
  43. package/dist/report.d.ts.map +1 -1
  44. package/dist/report.js +5 -4
  45. package/dist/report.js.map +1 -1
  46. package/dist/summary.d.ts +19 -4
  47. package/dist/summary.d.ts.map +1 -1
  48. package/dist/summary.js +105 -19
  49. package/dist/summary.js.map +1 -1
  50. package/package.json +6 -6
  51. package/src/args.ts +58 -2
  52. package/src/diff-command.ts +8 -1
  53. package/src/errors.ts +30 -4
  54. package/src/help.ts +9 -0
  55. package/src/html.ts +2 -2
  56. package/src/index.ts +2 -0
  57. package/src/io.ts +5 -0
  58. package/src/locale.ts +123 -1
  59. package/src/main.ts +5 -2
  60. package/src/markdown.ts +128 -12
  61. package/src/model.ts +115 -2
  62. package/src/node-io.ts +1 -0
  63. package/src/report.ts +13 -5
  64. package/src/summary.ts +145 -22
package/src/markdown.ts CHANGED
@@ -1,11 +1,22 @@
1
1
  /**
2
2
  * GitHub-flavoured Markdown summary for PR comments and `$GITHUB_STEP_SUMMARY`
3
- * (docs/cli.md §Markdown summary): title, diffstat table, family legend and the hunk list
4
- * grouped by phase in `<details>` blocks. No HTML beyond `<details>` / `<summary>`.
3
+ * (docs/cli.md §Markdown summary): title, diffstat table, family legend, the hunks that change
4
+ * a watched attribute (review first), the hunk list grouped by phase and the possible matches,
5
+ * in `<details>` blocks. No HTML beyond `<details>` / `<summary>`.
5
6
  */
6
- import { type DiffResult, formatHunk, type Locale } from '@kgdiff/core';
7
- import { formatInt, messages } from './locale.js';
8
- import { type ChangeFamily, groupByPhase, hunkFamily, hunkGlyph, overview } from './model.js';
7
+ import { type DiffResult, formatHunk, type Hunk, type Locale } from '@kgdiff/core';
8
+ import { formatInt, messages, watchLabels } from './locale.js';
9
+ import {
10
+ type ChangeFamily,
11
+ DEFAULT_WATCH_ATTRS,
12
+ groupByPhase,
13
+ hunkFamily,
14
+ hunkGlyph,
15
+ overview,
16
+ percent,
17
+ possibleMatches,
18
+ watchedChanges,
19
+ } from './model.js';
9
20
  import { totalsText } from './summary.js';
10
21
  import { revealInvisible } from './width.js';
11
22
 
@@ -13,6 +24,12 @@ import { revealInvisible } from './width.js';
13
24
  export const MARKDOWN_HUNK_LIMIT = 100;
14
25
  /** Groups start expanded when the whole list is at most this long. */
15
26
  const OPEN_THRESHOLD = 30;
27
+ /** Hunks listed under "Review first" before "…and N more". */
28
+ export const MARKDOWN_REVIEW_LIMIT = 20;
29
+ /** Possible matches listed before "…and N more". */
30
+ export const MARKDOWN_POSSIBLE_LIMIT = 20;
31
+ /** The possible matches start expanded when there are at most this many. */
32
+ const POSSIBLE_OPEN_THRESHOLD = 10;
16
33
 
17
34
  const FAMILY_SQUARE: Readonly<Record<ChangeFamily, string>> = {
18
35
  added: '🟩',
@@ -61,6 +78,11 @@ export interface MarkdownOptions {
61
78
  readonly title: string;
62
79
  /** e.g. `kgdiff 0.1.0`. */
63
80
  readonly generator: string;
81
+ /**
82
+ * Attribute keys whose changes are flagged ⚑ for review first (`--watch-attr`). Default
83
+ * `DEFAULT_WATCH_ATTRS` (definitions); `[]` flags nothing.
84
+ */
85
+ readonly watch?: readonly string[];
64
86
  }
65
87
 
66
88
  /** Render the Markdown summary. Deterministic: no timestamps. */
@@ -90,6 +112,10 @@ export function renderMarkdown(diff: DiffResult, options: MarkdownOptions): stri
90
112
  m.markdown.legend,
91
113
  '',
92
114
  );
115
+ const watch = options.watch ?? DEFAULT_WATCH_ATTRS;
116
+ const watched = watchedChanges(diff, watch);
117
+ const item = (hunk: Hunk): string => hunkItem(hunk, diff, options.locale, watched.get(hunk.id));
118
+ out.push(...reviewFirstSection(diff, watched, watch, options.locale, item));
93
119
  let budget = MARKDOWN_HUNK_LIMIT;
94
120
  const open = ov.hunks <= OPEN_THRESHOLD ? ' open' : '';
95
121
  for (const group of groupByPhase(diff.hunks)) {
@@ -101,19 +127,109 @@ export function renderMarkdown(diff: DiffResult, options: MarkdownOptions): stri
101
127
  `<summary>${m.phase[group.phase]} (${formatInt(group.hunks.length)})</summary>`,
102
128
  '',
103
129
  );
104
- for (const hunk of listed) {
105
- const { title, subtitle } = formatHunk(hunk, diff, options.locale);
106
- const family = hunkFamily(hunk);
107
- const glyph = hunkGlyph(hunk) === '~' ? '\\~' : hunkGlyph(hunk);
108
- const note = subtitle ? ` — *${escapeMarkdown(subtitle)}*` : '';
109
- out.push(`- ${FAMILY_SQUARE[family]} ${glyph} ${escapeMarkdown(title)}${note}`);
110
- }
130
+ for (const hunk of listed) out.push(item(hunk));
111
131
  out.push('', '</details>', '');
112
132
  }
113
133
  if (ov.hunks > MARKDOWN_HUNK_LIMIT) {
114
134
  out.push(`*${escapeMarkdown(m.markdown.more(ov.hunks - MARKDOWN_HUNK_LIMIT))}*`, '');
115
135
  }
136
+ out.push(...possibleSection(diff, options.locale));
116
137
  }
117
138
  out.push(`*${escapeMarkdown(m.markdown.generatedBy(options.generator))}*`);
118
139
  return `${out.join('\n')}\n`;
119
140
  }
141
+
142
+ /**
143
+ * A hunk as a list item: family square, glyph, title, then its summary in italics, after
144
+ * `⚑ definition` when it changes a watched attribute.
145
+ */
146
+ function hunkItem(
147
+ hunk: Hunk,
148
+ diff: DiffResult,
149
+ locale: Locale,
150
+ watchedKeys: readonly string[] | undefined,
151
+ ): string {
152
+ const m = messages(locale);
153
+ const { title, subtitle } = formatHunk(hunk, diff, locale);
154
+ const glyph = hunkGlyph(hunk) === '~' ? '\\~' : hunkGlyph(hunk);
155
+ const flag = watchedKeys ? `⚑ ${m.watch.marker(watchLabels(watchedKeys, m))}` : '';
156
+ const note = [flag, subtitle].filter(Boolean).join(' · ');
157
+ return `- ${FAMILY_SQUARE[hunkFamily(hunk)]} ${glyph} ${escapeMarkdown(title)}${note ? ` — *${escapeMarkdown(note)}*` : ''}`;
158
+ }
159
+
160
+ /**
161
+ * The hunks that change a watched attribute, before the phase groups (which may start
162
+ * collapsed), in list order: a reworded definition can change what existing data means while
163
+ * the diff looks small. The same items in the groups carry the ⚑ marker. Empty when none.
164
+ */
165
+ function reviewFirstSection(
166
+ diff: DiffResult,
167
+ watched: ReadonlyMap<string, readonly string[]>,
168
+ watch: readonly string[],
169
+ locale: Locale,
170
+ item: (hunk: Hunk) => string,
171
+ ): string[] {
172
+ if (watched.size === 0) return [];
173
+ const m = messages(locale);
174
+ const keys = new Set<string>();
175
+ for (const list of watched.values()) for (const key of list) keys.add(key);
176
+ const labels = watchLabels(
177
+ watch.filter((k) => keys.has(k)),
178
+ m,
179
+ );
180
+ const flagged = groupByPhase(diff.hunks).flatMap((g) => g.hunks.filter((h) => watched.has(h.id)));
181
+ const out = [
182
+ '<details open>',
183
+ `<summary>⚑ ${m.markdown.reviewFirst} (${formatInt(flagged.length)})</summary>`,
184
+ '',
185
+ `*${escapeMarkdown(m.markdown.reviewFirstHint(labels))}*`,
186
+ '',
187
+ ];
188
+ for (const hunk of flagged.slice(0, MARKDOWN_REVIEW_LIMIT)) out.push(item(hunk));
189
+ if (flagged.length > MARKDOWN_REVIEW_LIMIT) {
190
+ out.push(
191
+ '',
192
+ `*${escapeMarkdown(m.markdown.reviewFirstMore(flagged.length - MARKDOWN_REVIEW_LIMIT))}*`,
193
+ );
194
+ }
195
+ out.push('', '</details>', '');
196
+ return out;
197
+ }
198
+
199
+ /**
200
+ * The possible matches (`possibleMatches`): pairings kgdiff did not apply between a node listed
201
+ * as removed and one listed as added. In a pull request comment they are what tells a reviewer
202
+ * that "removed X" and "added Y" may be one entity. Empty when there are none.
203
+ */
204
+ function possibleSection(diff: DiffResult, locale: Locale): string[] {
205
+ const possible = possibleMatches(diff);
206
+ if (possible.length === 0) return [];
207
+ const m = messages(locale);
208
+ const threshold = percent(diff.options.match.threshold);
209
+ const open = possible.length <= POSSIBLE_OPEN_THRESHOLD ? ' open' : '';
210
+ const out = [
211
+ `<details${open}>`,
212
+ `<summary>${m.possible.heading} (${formatInt(possible.length)})</summary>`,
213
+ '',
214
+ `*${m.markdown.possibleHint}*`,
215
+ '',
216
+ ];
217
+ for (const p of possible.slice(0, MARKDOWN_POSSIBLE_LIMIT)) {
218
+ const reason =
219
+ p.reason === 'ambiguous' ? m.possible.ambiguous : m.possible.belowThreshold(threshold);
220
+ const note = [
221
+ percent(p.score),
222
+ reason,
223
+ m.possible.evidence(percent(p.label), percent(p.neighbors)),
224
+ ].join(' · ');
225
+ out.push(`- ≈ ${escapeMarkdown(m.possible.title(p.before, p.after))} — *${note}*`);
226
+ }
227
+ if (possible.length > MARKDOWN_POSSIBLE_LIMIT) {
228
+ out.push(
229
+ '',
230
+ `*${escapeMarkdown(m.markdown.possibleMore(possible.length - MARKDOWN_POSSIBLE_LIMIT))}*`,
231
+ );
232
+ }
233
+ out.push('', '</details>', '');
234
+ return out;
235
+ }
package/src/model.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Presentation model shared by every text output: hunk families, glyphs, name-status letters,
3
- * phase grouping and the per-phase counts behind the counters and the diffstat bar.
3
+ * phase grouping, the per-phase counts behind the counters and the diffstat bar, the watched
4
+ * attributes flagged for review first, and the possible matches listed after the hunks.
4
5
  */
5
- import type { DiffResult, Hunk, HunkKind, ReplayPhase } from '@kgdiff/core';
6
+ import type { DiffResult, Hunk, HunkKind, MatchSuggestion, ReplayPhase } from '@kgdiff/core';
6
7
 
7
8
  /** The five meaning families of docs/visual-language.md (mirrors `@kgdiff/scene`). */
8
9
  export type ChangeFamily = 'added' | 'removed' | 'content' | 'structure' | 'identity';
@@ -176,6 +177,118 @@ export function apportion(weights: readonly number[], total: number): number[] {
176
177
  return cells;
177
178
  }
178
179
 
180
+ /**
181
+ * Attributes watched by default: definitions, under the keys the formats give them (OBO `def`,
182
+ * kgdiff JSON `definition`, `IAO:0000115` for OBO ontologies in RDF, `skos:definition`). A
183
+ * reworded definition can change what existing data means while the diff looks small.
184
+ */
185
+ export const DEFAULT_WATCH_ATTRS: readonly string[] = [
186
+ 'def',
187
+ 'definition',
188
+ 'IAO:0000115',
189
+ 'skos:definition',
190
+ ];
191
+
192
+ const DEFINITION_KEYS: ReadonlySet<string> = new Set(DEFAULT_WATCH_ATTRS);
193
+
194
+ /** Whether `key` is one of the default definition keys, shown as “definition”. */
195
+ export function isDefinitionKey(key: string): boolean {
196
+ return DEFINITION_KEYS.has(key);
197
+ }
198
+
199
+ /**
200
+ * The watched attributes each hunk changes, by hunk id, in `watch` order; hunks that change
201
+ * none are absent. Only attribute changes of matched nodes count: an added or removed node's
202
+ * definition is part of that addition or removal, not a change to a definition. Ignored
203
+ * attributes (`ignoreAttrs`) never show up here, since the diff does not report them. O(changes).
204
+ */
205
+ export function watchedChanges(
206
+ diff: DiffResult,
207
+ watch: readonly string[],
208
+ ): ReadonlyMap<string, readonly string[]> {
209
+ const watched = new Set(watch);
210
+ const byHunk = new Map<string, readonly string[]>();
211
+ if (watched.size === 0) return byHunk;
212
+ for (const hunk of diff.hunks) {
213
+ const keys = new Set<string>();
214
+ for (const id of hunk.nodes) {
215
+ for (const facet of diff.index.node(id)?.facets ?? []) {
216
+ if (facet.kind !== 'attrs') continue;
217
+ for (const change of facet.changes) if (watched.has(change.key)) keys.add(change.key);
218
+ }
219
+ }
220
+ if (keys.size > 0)
221
+ byHunk.set(
222
+ hunk.id,
223
+ [...watched].filter((k) => keys.has(k)),
224
+ );
225
+ }
226
+ return byHunk;
227
+ }
228
+
229
+ /** One side of a possible match: the node's id in its version and the name shown for it. */
230
+ export interface PossibleMatchSide {
231
+ readonly id: string;
232
+ readonly name: string;
233
+ }
234
+
235
+ /** A pairing kgdiff considered but did not apply (a `MatchSuggestion`), ready to list. */
236
+ export interface PossibleMatch {
237
+ readonly before: PossibleMatchSide;
238
+ readonly after: PossibleMatchSide;
239
+ readonly score: number;
240
+ readonly reason: MatchSuggestion['reason'];
241
+ /** Label and neighbourhood evidence, 0..1. */
242
+ readonly label: number;
243
+ readonly neighbors: number;
244
+ }
245
+
246
+ /** Longest name shown for either side (as hunk titles shorten names, @kgdiff/core i18n). */
247
+ const MAX_POSSIBLE_NAME = 40;
248
+
249
+ function shortName(label: string): string {
250
+ const chars = Array.from(label);
251
+ return chars.length > MAX_POSSIBLE_NAME
252
+ ? `${chars.slice(0, MAX_POSSIBLE_NAME - 1).join('')}…`
253
+ : label;
254
+ }
255
+
256
+ const byCodePoint = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
257
+
258
+ /**
259
+ * The suggestions a reviewer of the text outputs needs to see: pairings kgdiff did not apply
260
+ * where the before node is reported removed or the after node added, so that "removed X, added
261
+ * Y" can be read as "maybe X became Y". The viewer's detail panel lists the same ones. Names
262
+ * are the label each node had in its own version. Highest score first, then by ids.
263
+ */
264
+ export function possibleMatches(diff: DiffResult): PossibleMatch[] {
265
+ const list: PossibleMatch[] = [];
266
+ for (const s of diff.suggestions) {
267
+ const before = diff.index.nodeByBefore(s.before);
268
+ const after = diff.index.nodeByAfter(s.after);
269
+ if (before?.status !== 'removed' && after?.status !== 'added') continue;
270
+ list.push({
271
+ before: { id: s.before, name: shortName(before?.before?.label ?? before?.label ?? s.before) },
272
+ after: { id: s.after, name: shortName(after?.after?.label ?? after?.label ?? s.after) },
273
+ score: s.score,
274
+ reason: s.reason,
275
+ label: s.evidence.label,
276
+ neighbors: s.evidence.neighbors,
277
+ });
278
+ }
279
+ return list.sort(
280
+ (x, y) =>
281
+ y.score - x.score ||
282
+ byCodePoint(x.before.id, y.before.id) ||
283
+ byCodePoint(x.after.id, y.after.id),
284
+ );
285
+ }
286
+
287
+ /** A score or evidence value as a whole percentage: `0.796` → `80%`. */
288
+ export function percent(value: number): string {
289
+ return `${Math.round(value * 100)}%`;
290
+ }
291
+
179
292
  export function assertNever(value: never): never {
180
293
  throw new Error(`Unexpected value: ${String(value)}`);
181
294
  }
package/src/node-io.ts CHANGED
@@ -138,6 +138,7 @@ export function nodeIO(): CliIO {
138
138
  stderrIsTTY: process.stderr.isTTY === true,
139
139
  stdinIsTTY: process.stdin.isTTY === true,
140
140
  columns: process.stdout.isTTY ? process.stdout.columns : undefined,
141
+ runtime: `${process.platform} ${process.arch}, Node.js ${process.version}`,
141
142
  now: () => new Date(),
142
143
  makeTempDir: () => mkdtemp(join(tmpdir(), 'kgdiff-')),
143
144
  openFile,
package/src/report.ts CHANGED
@@ -1,10 +1,16 @@
1
1
  /**
2
- * Build the `ReportPayload` embedded in HTML reports: diff (`diffToJSON`) + layout
2
+ * Build the `ReportPayload` embedded in HTML reports: packed diff (`packDiffJSON`) + layout
3
3
  * (`layoutToJSON`) + meta + initial view preferences. `@kgdiff/layout` and `node:zlib` are
4
4
  * loaded on demand, so text outputs never pay for them.
5
5
  */
6
- import { type DiffResult, diffToJSON, type Locale } from '@kgdiff/core';
7
- import type { ReportPayload, ThemePreference, ViewerMode, ViewerScope } from '@kgdiff/viewer';
6
+ import { type DiffResult, diffToJSON, type Locale, packDiffJSON } from '@kgdiff/core';
7
+ import type {
8
+ ReportPayload,
9
+ ThemePreference,
10
+ ViewerMode,
11
+ ViewerPanels,
12
+ ViewerScope,
13
+ } from '@kgdiff/viewer';
8
14
  import { renderReportHtml } from './html.js';
9
15
 
10
16
  export interface ReportInfo {
@@ -20,6 +26,7 @@ export interface ReportInfo {
20
26
  readonly scope?: ViewerScope;
21
27
  readonly theme?: ThemePreference;
22
28
  readonly locale: Locale;
29
+ readonly panels?: ViewerPanels;
23
30
  };
24
31
  }
25
32
 
@@ -30,11 +37,11 @@ export async function buildReportPayload(
30
37
  ): Promise<ReportPayload> {
31
38
  const { layoutDiff, layoutToJSON } = await import('@kgdiff/layout');
32
39
  const layout = layoutToJSON(layoutDiff(diff));
33
- const { mode, scope, theme, locale } = info.view;
40
+ const { mode, scope, theme, locale, panels } = info.view;
34
41
  return {
35
42
  format: 'kgdiff.report',
36
43
  version: 1,
37
- diff: diffToJSON(diff),
44
+ diff: packDiffJSON(diffToJSON(diff)),
38
45
  layout,
39
46
  meta: {
40
47
  title: info.title,
@@ -48,6 +55,7 @@ export async function buildReportPayload(
48
55
  ...(scope !== undefined ? { scope } : {}),
49
56
  ...(theme !== undefined ? { theme } : {}),
50
57
  locale,
58
+ ...(panels !== undefined ? { panels } : {}),
51
59
  },
52
60
  };
53
61
  }
package/src/summary.ts CHANGED
@@ -1,15 +1,17 @@
1
1
  /**
2
- * Terminal outputs: the default summary (header, counters, diffstat bar, hunk list), `--stat`
3
- * and `--name-status` (docs/cli.md §Terminal summary).
2
+ * Terminal outputs: the default summary (header, counters, diffstat bar, hunk list, possible
3
+ * matches), `--stat` and `--name-status` (docs/cli.md §Terminal summary).
4
4
  *
5
5
  * Every line is laid out on plain text first (measured with `stringWidth`, truncated with an
6
6
  * ellipsis) and painted last, so alignment holds with and without color and with CJK labels.
7
7
  */
8
8
  import { type DiffResult, formatHunk, type Hunk, type Locale } from '@kgdiff/core';
9
- import { formatInt, type Messages, messages } from './locale.js';
9
+ import { formatInt, type Messages, messages, watchLabels } from './locale.js';
10
10
  import {
11
11
  apportion,
12
+ type ChangeFamily,
12
13
  COUNTER_PHASES,
14
+ DEFAULT_WATCH_ATTRS,
13
15
  groupByPhase,
14
16
  hunkFamily,
15
17
  hunkGlyph,
@@ -18,6 +20,10 @@ import {
18
20
  overview,
19
21
  PHASE_FAMILY,
20
22
  PHASE_GLYPH,
23
+ type PossibleMatch,
24
+ percent,
25
+ possibleMatches,
26
+ watchedChanges,
21
27
  } from './model.js';
22
28
  import type { Painter } from './style.js';
23
29
  import { padEnd, padStart, sanitize, stringWidth, truncate, wrap } from './width.js';
@@ -29,6 +35,35 @@ export interface TextContext {
29
35
  readonly width: number;
30
36
  /** Maximum hunks listed in the summary; 0 lists all. */
31
37
  readonly limit: number;
38
+ /**
39
+ * Attribute keys whose changes are flagged ⚑ for review first (`--watch-attr`). Default
40
+ * `DEFAULT_WATCH_ATTRS` (definitions); `[]` flags nothing.
41
+ */
42
+ readonly watch?: readonly string[];
43
+ }
44
+
45
+ /** Flag glyph of hunks that change a watched attribute. */
46
+ const FLAG = '⚑';
47
+
48
+ /** The watched attributes each hunk changes, for `ctx.watch` or the default definitions. */
49
+ function watchedOf(diff: DiffResult, ctx: TextContext): ReadonlyMap<string, readonly string[]> {
50
+ return watchedChanges(diff, ctx.watch ?? DEFAULT_WATCH_ATTRS);
51
+ }
52
+
53
+ /** `⚑ Review first: definition changed in 10 hunks`, under the diffstat bar; none if no flags. */
54
+ function reviewFirstLines(
55
+ watched: ReadonlyMap<string, readonly string[]>,
56
+ ctx: TextContext,
57
+ width: number,
58
+ ): string[] {
59
+ if (watched.size === 0) return [];
60
+ const m = messages(ctx.locale);
61
+ const keys = new Set<string>();
62
+ for (const list of watched.values()) for (const key of list) keys.add(key);
63
+ const watchOrder = (ctx.watch ?? DEFAULT_WATCH_ATTRS).filter((k) => keys.has(k));
64
+ const text = m.watch.line(watchLabels(watchOrder, m), watched.size);
65
+ const room = width - INDENT.length - stringWidth(FLAG) - 1;
66
+ return [`${INDENT}${ctx.paint.tone('accent', FLAG)} ${truncate(sanitize(text), room)}`];
32
67
  }
33
68
 
34
69
  const INDENT = ' ';
@@ -135,7 +170,13 @@ export function diffstatBar(ov: Overview, width: number, paint: Painter): string
135
170
  }).join('');
136
171
  }
137
172
 
138
- function statLines(ov: Overview, ctx: TextContext, width: number): string[] {
173
+ /** Counters, the diffstat bar, and the review-first line when a watched attribute changed. */
174
+ function statLines(
175
+ ov: Overview,
176
+ ctx: TextContext,
177
+ width: number,
178
+ watched: ReadonlyMap<string, readonly string[]>,
179
+ ): string[] {
139
180
  const m = messages(ctx.locale);
140
181
  const right = m.changes(ov.changes, ov.hunks);
141
182
  const rows = counterLines(counterItems(ov, m, ctx.paint), width - INDENT.length);
@@ -149,6 +190,7 @@ function statLines(ov: Overview, ctx: TextContext, width: number): string[] {
149
190
  ),
150
191
  );
151
192
  lines.push(INDENT + diffstatBar(ov, width, ctx.paint));
193
+ lines.push(...reviewFirstLines(watched, ctx, width));
152
194
  return lines;
153
195
  }
154
196
 
@@ -169,30 +211,67 @@ function identicalLines(diff: DiffResult, ctx: TextContext, width: number): stri
169
211
  ];
170
212
  }
171
213
 
214
+ /** A line of the hunk list (or of the possible matches): glyph, title, family and summary. */
172
215
  interface Row {
173
- readonly hunk: Hunk;
216
+ /** Colour of the glyph and the family label. */
217
+ readonly tone: ChangeFamily;
174
218
  readonly glyph: string;
175
219
  readonly title: string;
176
220
  readonly family: string;
221
+ /** Watched attributes the hunk changes (`definition`), shown as `⚑ definition`. */
222
+ readonly flag?: string;
177
223
  readonly subtitle: string;
178
224
  }
179
225
 
180
- function rowsOf(diff: DiffResult, hunks: readonly Hunk[], ctx: TextContext): Row[] {
226
+ function rowsOf(
227
+ diff: DiffResult,
228
+ hunks: readonly Hunk[],
229
+ ctx: TextContext,
230
+ watched: ReadonlyMap<string, readonly string[]>,
231
+ ): Row[] {
181
232
  const m = messages(ctx.locale);
182
233
  return hunks.map((hunk) => {
183
234
  const { title, subtitle } = formatHunk(hunk, diff, ctx.locale);
235
+ const keys = watched.get(hunk.id);
184
236
  return {
185
- hunk,
237
+ tone: hunkFamily(hunk),
186
238
  glyph: hunkGlyph(hunk),
187
239
  title: sanitize(title),
188
240
  family: m.family[hunkFamily(hunk)],
241
+ ...(keys ? { flag: sanitize(m.watch.marker(watchLabels(keys, m))) } : {}),
189
242
  subtitle: sanitize(subtitle),
190
243
  };
191
244
  });
192
245
  }
193
246
 
194
- const rightText = (row: Row): string =>
195
- row.subtitle ? `${row.family} · ${row.subtitle}` : row.family;
247
+ /** What follows the family label: ` · ⚑ definition · <subtitle>`, as plain text. */
248
+ const restText = (row: Row): string =>
249
+ (row.flag ? ` · ${FLAG} ${row.flag}` : '') + (row.subtitle ? ` · ${row.subtitle}` : '');
250
+
251
+ /** Glyph of a possible match: “maybe the same”. */
252
+ const POSSIBLE_GLYPH = '≈';
253
+ /** Possible matches listed before "… and N more" (all with `--limit 0`). */
254
+ export const POSSIBLE_LIMIT = 10;
255
+
256
+ /** Possible match rows: both names, then the score, why it was not applied and the evidence. */
257
+ function possibleRows(diff: DiffResult, list: readonly PossibleMatch[], ctx: TextContext): Row[] {
258
+ const m = messages(ctx.locale);
259
+ const threshold = percent(diff.options.match.threshold);
260
+ return list.map((p) => ({
261
+ tone: 'identity',
262
+ glyph: POSSIBLE_GLYPH,
263
+ title: sanitize(m.possible.title(p.before, p.after)),
264
+ family: percent(p.score),
265
+ subtitle: sanitize(
266
+ [
267
+ p.reason === 'ambiguous' ? m.possible.ambiguous : m.possible.belowThreshold(threshold),
268
+ m.possible.evidence(percent(p.label), percent(p.neighbors)),
269
+ ].join(' · '),
270
+ ),
271
+ }));
272
+ }
273
+
274
+ const rightText = (row: Row): string => row.family + restText(row);
196
275
 
197
276
  /** Natural width of the hunk list (before any truncation). */
198
277
  function listWidth(rows: readonly Row[]): number {
@@ -229,16 +308,24 @@ function listColumns(rows: readonly Row[], width: number): { title: number; righ
229
308
 
230
309
  function renderRow(row: Row, cols: { title: number; right: number }, ctx: TextContext): string {
231
310
  const { paint } = ctx;
232
- const tone = hunkFamily(row.hunk);
311
+ const tone = row.tone;
233
312
  const glyph = paint.tone(tone, row.glyph);
234
313
  if (cols.right === 0) return `${INDENT}${glyph} ${truncate(row.title, cols.title)}`;
235
314
  const title = padEnd(truncate(row.title, cols.title), cols.title);
236
315
  const familyWidth = stringWidth(row.family);
237
316
  const restRoom = cols.right - familyWidth;
238
- const rest = row.subtitle && restRoom >= 6 ? truncate(` · ${row.subtitle}`, restRoom) : '';
317
+ const full = restText(row);
318
+ const rest = full && restRoom >= 6 ? truncate(full, restRoom) : '';
319
+ // The flag comes first, so it survives truncation, and keeps the accent colour; the summary
320
+ // after it is dimmed.
321
+ const flag = row.flag ? ` · ${FLAG} ${row.flag}` : '';
322
+ const flagShown = flag ? rest.slice(0, Math.min(rest.length, flag.length)) : '';
323
+ const painted = flagShown
324
+ ? paint.dim(flagShown.slice(0, 3)) + paint.tone('accent', flagShown.slice(3))
325
+ : '';
239
326
  return (
240
327
  `${INDENT}${glyph} ${title}${' '.repeat(COLUMN_GAP)}` +
241
- `${paint.tone(tone, row.family)}${paint.dim(rest)}`
328
+ `${paint.tone(tone, row.family)}${painted}${paint.dim(rest.slice(flagShown.length))}`
242
329
  );
243
330
  }
244
331
 
@@ -268,6 +355,13 @@ function headerNatural(diff: DiffResult, title: string, ctx: TextContext, ov: Ov
268
355
  * The default terminal output: header (title, node and edge totals), per-phase counters, a
269
356
  * proportional diffstat bar, then the hunks grouped by phase with glyph, title and a family /
270
357
  * summary column. At most `ctx.limit` hunks are listed (0 = all), then "… and N more".
358
+ * Hunks that change a watched attribute (`ctx.watch`, definitions by default) are marked
359
+ * `⚑ definition` before their summary, and a line under the bar counts them.
360
+ *
361
+ * Last come the possible matches: pairings kgdiff considered but did not apply, between a node
362
+ * listed as removed and one listed as added (`possibleMatches`), with their score, why they were
363
+ * not applied, the evidence, and how to pair one. At most `POSSIBLE_LIMIT` (all with
364
+ * `--limit 0`).
271
365
  */
272
366
  export function renderSummary(diff: DiffResult, title: string, ctx: TextContext): string {
273
367
  const ov = overview(diff);
@@ -275,33 +369,61 @@ export function renderSummary(diff: DiffResult, title: string, ctx: TextContext)
275
369
  const width = layoutWidth(headerNatural(diff, title, ctx, ov), ctx.width);
276
370
  return `${[...headerLines(diff, title, ctx, width), '', ...identicalLines(diff, ctx, width)].join('\n')}\n`;
277
371
  }
372
+ const watched = watchedOf(diff, ctx);
278
373
  const m = messages(ctx.locale);
374
+ const { paint } = ctx;
279
375
  const groups = groupByPhase(diff.hunks);
280
376
  let shown = 0;
281
377
  const budget = ctx.limit > 0 ? ctx.limit : Number.POSITIVE_INFINITY;
282
378
  const listed = groups.map((g) => {
283
379
  const take = g.hunks.slice(0, Math.max(0, budget - shown));
284
380
  shown += take.length;
285
- return { phase: g.phase, total: g.hunks.length, rows: rowsOf(diff, take, ctx) };
381
+ return { phase: g.phase, total: g.hunks.length, rows: rowsOf(diff, take, ctx, watched) };
286
382
  });
383
+ const possible = possibleMatches(diff);
384
+ const possibleShown = possible.slice(
385
+ 0,
386
+ ctx.limit > 0 ? Math.min(ctx.limit, POSSIBLE_LIMIT) : possible.length,
387
+ );
388
+ const possibleList = possibleRows(diff, possibleShown, ctx);
287
389
  const allRows = listed.flatMap((g) => g.rows);
288
390
  const width = layoutWidth(
289
- Math.max(headerNatural(diff, title, ctx, ov), listWidth(allRows)),
391
+ Math.max(headerNatural(diff, title, ctx, ov), listWidth(allRows), listWidth(possibleList)),
290
392
  ctx.width,
291
393
  );
292
394
  const cols = listColumns(allRows, width);
293
- const lines = [...headerLines(diff, title, ctx, width), '', ...statLines(ov, ctx, width), ''];
395
+ // Columns of their own: hunk titles are often long, and the reason and evidence matter more
396
+ // here than lining up with the hunks above (the section has its own heading).
397
+ const possibleCols = listColumns(possibleList, width);
398
+ const lines = [
399
+ ...headerLines(diff, title, ctx, width),
400
+ '',
401
+ ...statLines(ov, ctx, width, watched),
402
+ '',
403
+ ];
294
404
  for (const group of listed) {
295
405
  if (group.rows.length === 0) continue;
296
- lines.push(
297
- `${INDENT}${ctx.paint.bold(m.phase[group.phase])} ${ctx.paint.dim(formatInt(group.total))}`,
298
- );
406
+ lines.push(`${INDENT}${paint.bold(m.phase[group.phase])} ${paint.dim(formatInt(group.total))}`);
299
407
  for (const row of group.rows) lines.push(renderRow(row, cols, ctx));
300
408
  }
301
409
  const hidden = ov.hunks - shown;
302
410
  if (hidden > 0) {
303
411
  const more = `${m.more(hidden)} · ${m.moreHint}`;
304
- lines.push(INDENT + ctx.paint.dim(truncate(more, width - INDENT.length)));
412
+ lines.push(INDENT + paint.dim(truncate(more, width - INDENT.length)));
413
+ }
414
+ if (possible.length > 0) {
415
+ lines.push(
416
+ '',
417
+ `${INDENT}${paint.bold(m.possible.heading)} ${paint.dim(formatInt(possible.length))}`,
418
+ );
419
+ for (const row of possibleList) lines.push(renderRow(row, possibleCols, ctx));
420
+ const notListed = possible.length - possibleShown.length;
421
+ if (notListed > 0) {
422
+ lines.push(INDENT + paint.dim(truncate(m.possible.more(notListed), width - INDENT.length)));
423
+ }
424
+ for (const line of wrap(m.possible.hint, width - INDENT.length)) {
425
+ lines.push(INDENT + paint.dim(line));
426
+ }
305
427
  }
306
428
  return `${lines.join('\n')}\n`;
307
429
  }
@@ -310,15 +432,16 @@ const MAX_TYPE_ROWS = 12;
310
432
  const MAX_TYPE_NAME = 28;
311
433
 
312
434
  /**
313
- * `--stat`: header, counters and bar, a nodes / edges × added / removed / modified / unchanged
314
- * table, and a git-style per-type breakdown (`+` added, `−` removed, `~` modified nodes).
435
+ * `--stat`: header, counters and bar (with the review-first line, as in the summary), a
436
+ * nodes / edges × added / removed / modified / unchanged table, and a git-style per-type
437
+ * breakdown (`+` added, `−` removed, `~` modified nodes).
315
438
  */
316
439
  export function renderStat(diff: DiffResult, title: string, ctx: TextContext): string {
317
440
  const ov = overview(diff);
318
441
  const width = layoutWidth(headerNatural(diff, title, ctx, ov), ctx.width);
319
442
  const lines = [...headerLines(diff, title, ctx, width), ''];
320
443
  if (ov.hunks === 0) return `${[...lines, ...identicalLines(diff, ctx, width)].join('\n')}\n`;
321
- lines.push(...statLines(ov, ctx, width), '');
444
+ lines.push(...statLines(ov, ctx, width, watchedOf(diff, ctx)), '');
322
445
  lines.push(...countsTable(diff, ctx, width), '');
323
446
  lines.push(...typeLines(diff, ctx, width));
324
447
  return `${lines.join('\n')}\n`;