@kgdiff/cli 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.
Files changed (65) hide show
  1. package/README.md +21 -4
  2. package/dist/args.d.ts +7 -1
  3. package/dist/args.d.ts.map +1 -1
  4. package/dist/args.js +19 -2
  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 +11 -1
  15. package/dist/help.js.map +1 -1
  16. package/dist/html.d.ts.map +1 -1
  17. package/dist/html.js +1 -0
  18. package/dist/html.js.map +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/io.d.ts +5 -0
  24. package/dist/io.d.ts.map +1 -1
  25. package/dist/locale.d.ts +34 -1
  26. package/dist/locale.d.ts.map +1 -1
  27. package/dist/locale.js +65 -0
  28. package/dist/locale.js.map +1 -1
  29. package/dist/main.d.ts.map +1 -1
  30. package/dist/main.js +5 -2
  31. package/dist/main.js.map +1 -1
  32. package/dist/markdown.d.ts +12 -2
  33. package/dist/markdown.d.ts.map +1 -1
  34. package/dist/markdown.js +94 -11
  35. package/dist/markdown.js.map +1 -1
  36. package/dist/model.d.ts +42 -2
  37. package/dist/model.d.ts.map +1 -1
  38. package/dist/model.js +82 -0
  39. package/dist/model.js.map +1 -1
  40. package/dist/node-io.d.ts.map +1 -1
  41. package/dist/node-io.js +1 -0
  42. package/dist/node-io.js.map +1 -1
  43. package/dist/report.d.ts +3 -2
  44. package/dist/report.d.ts.map +1 -1
  45. package/dist/report.js +5 -4
  46. package/dist/report.js.map +1 -1
  47. package/dist/summary.d.ts +19 -4
  48. package/dist/summary.d.ts.map +1 -1
  49. package/dist/summary.js +105 -19
  50. package/dist/summary.js.map +1 -1
  51. package/package.json +6 -6
  52. package/src/args.ts +31 -4
  53. package/src/diff-command.ts +8 -1
  54. package/src/errors.ts +30 -4
  55. package/src/help.ts +11 -1
  56. package/src/html.ts +1 -0
  57. package/src/index.ts +2 -0
  58. package/src/io.ts +5 -0
  59. package/src/locale.ts +123 -1
  60. package/src/main.ts +5 -2
  61. package/src/markdown.ts +128 -12
  62. package/src/model.ts +115 -2
  63. package/src/node-io.ts +1 -0
  64. package/src/report.ts +7 -5
  65. package/src/summary.ts +145 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kgdiff/cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "git diff for knowledge graphs. Compare two graph files from the terminal, CI, or as a shareable HTML report.",
5
5
  "keywords": [
6
6
  "kgdiff",
@@ -45,11 +45,11 @@
45
45
  "registry": "https://registry.npmjs.org/"
46
46
  },
47
47
  "dependencies": {
48
- "@kgdiff/core": "0.3.0",
49
- "@kgdiff/formats": "0.3.0",
50
- "@kgdiff/layout": "0.3.0",
51
- "@kgdiff/scene": "0.3.0",
52
- "@kgdiff/viewer": "0.3.0"
48
+ "@kgdiff/core": "0.5.0",
49
+ "@kgdiff/formats": "0.5.0",
50
+ "@kgdiff/layout": "0.5.0",
51
+ "@kgdiff/scene": "0.5.0",
52
+ "@kgdiff/viewer": "0.5.0"
53
53
  },
54
54
  "scripts": {
55
55
  "build": "tsc -p tsconfig.build.json",
package/src/args.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  import { parseArgs } from 'node:util';
13
13
  import type { DiffOptions, EdgeIdentity, Locale, MatchOptions } from '@kgdiff/core';
14
14
  import type { FormatId } from '@kgdiff/formats';
15
- import type { ThemePreference, ViewerMode } from '@kgdiff/viewer';
15
+ import type { ThemePreference, ViewerMode, ViewerScope } from '@kgdiff/viewer';
16
16
  import { CliError } from './errors.js';
17
17
  import { matchLocale } from './locale.js';
18
18
  import type { ColorMode } from './style.js';
@@ -34,7 +34,9 @@ export const FORMATS: readonly (FormatId | 'auto')[] = [
34
34
  const MATCH_STRATEGIES = ['id', 'auto', 'similarity'] as const;
35
35
  const EDGE_IDENTITIES: readonly EdgeIdentity[] = ['structure', 'id'];
36
36
  const THEMES: readonly ThemePreference[] = ['auto', 'light', 'dark'];
37
- const MODES: readonly ViewerMode[] = ['overlay', 'swipe', 'replay', 'changes'];
37
+ /** `changes` is the viewer's legacy mode: overlay + `--scope changes`. */
38
+ const MODES: readonly (ViewerMode | 'changes')[] = ['overlay', 'swipe', 'replay', 'changes'];
39
+ const SCOPES: readonly ViewerScope[] = ['all', 'changes'];
38
40
  const COLOR_MODES: readonly ColorMode[] = ['auto', 'always', 'never'];
39
41
 
40
42
  /** Default `--limit`. */
@@ -67,6 +69,7 @@ export interface DiffCommandOptions extends CommonOptions {
67
69
  readonly title?: string;
68
70
  readonly theme?: ThemePreference;
69
71
  readonly mode?: ViewerMode;
72
+ readonly scope?: ViewerScope;
70
73
  readonly reproducible: boolean;
71
74
  /** `--allow-empty`: an empty input is an empty graph instead of an error. */
72
75
  readonly allowEmpty: boolean;
@@ -75,6 +78,11 @@ export interface DiffCommandOptions extends CommonOptions {
75
78
  readonly exitCode: boolean;
76
79
  /** Max hunks in the summary; 0 = all. */
77
80
  readonly limit: number;
81
+ /**
82
+ * `--watch-attr`: attributes whose changes are flagged ⚑ for review first, replacing the
83
+ * default definitions. Absent when not given.
84
+ */
85
+ readonly watchAttrs?: readonly string[];
78
86
  }
79
87
 
80
88
  export interface ApplyCommandOptions extends CommonOptions {
@@ -139,11 +147,13 @@ export const DIFF_OPTIONS: Readonly<Record<string, OptionSpec>> = {
139
147
  title: { type: 'string' },
140
148
  theme: { type: 'string' },
141
149
  mode: { type: 'string' },
150
+ scope: { type: 'string' },
142
151
  reproducible: { type: 'boolean' },
143
152
  'no-compute': { type: 'boolean' },
144
153
  'allow-empty': { type: 'boolean' },
145
154
  'exit-code': { type: 'boolean' },
146
155
  limit: { type: 'string' },
156
+ 'watch-attr': { type: 'string', multiple: true },
147
157
  };
148
158
 
149
159
  /** Options of `kgdiff apply <graph> <delta>`. */
@@ -479,7 +489,15 @@ function parseDiff(argv: readonly string[]): Command {
479
489
  };
480
490
 
481
491
  const theme = v.string('theme');
482
- const mode = v.string('mode');
492
+ const modeArg = v.string('mode');
493
+ const mode = modeArg === undefined ? undefined : choice('mode', modeArg, MODES);
494
+ const scopeArg = v.string('scope');
495
+ const scope =
496
+ scopeArg !== undefined
497
+ ? choice('scope', scopeArg, SCOPES)
498
+ : mode === 'changes'
499
+ ? 'changes'
500
+ : undefined;
483
501
  const limit = v.string('limit');
484
502
  const languages = parseLanguages(v);
485
503
  const title = v.string('title');
@@ -487,6 +505,13 @@ function parseDiff(argv: readonly string[]): Command {
487
505
  const delta = v.target('delta');
488
506
  const markdown = v.target('markdown');
489
507
  const html = v.string('html');
508
+ const watchAttrs = v.strings('watch-attr');
509
+ if (watchAttrs.some((key) => key.trim() === '')) {
510
+ throw usageError(
511
+ '--watch-attr needs an attribute key.',
512
+ 'e.g. --watch-attr rdfs:comment. Attribute keys are listed in the JSON output (--json).',
513
+ );
514
+ }
490
515
  const options: DiffCommandOptions = {
491
516
  ...common,
492
517
  before,
@@ -505,7 +530,8 @@ function parseDiff(argv: readonly string[]): Command {
505
530
  pretty: v.flag('pretty'),
506
531
  ...(title !== undefined ? { title } : {}),
507
532
  ...(theme !== undefined ? { theme: choice('theme', theme, THEMES) } : {}),
508
- ...(mode !== undefined ? { mode: choice('mode', mode, MODES) } : {}),
533
+ ...(mode !== undefined ? { mode: mode === 'changes' ? 'overlay' : mode } : {}),
534
+ ...(scope !== undefined ? { scope } : {}),
509
535
  reproducible: v.flag('reproducible'),
510
536
  compute: !v.flag('no-compute'),
511
537
  allowEmpty: v.flag('allow-empty'),
@@ -514,6 +540,7 @@ function parseDiff(argv: readonly string[]): Command {
514
540
  limit === undefined
515
541
  ? DEFAULT_LIMIT
516
542
  : parseNumber('limit', limit, 0, Number.MAX_SAFE_INTEGER, true),
543
+ ...(watchAttrs.length > 0 ? { watchAttrs } : {}),
517
544
  };
518
545
  checkOutputs(options);
519
546
  return { kind: 'diff', options };
@@ -235,7 +235,12 @@ export async function runDiff(o: DiffCommandOptions, io: CliIO): Promise<number>
235
235
  }
236
236
  if (o.markdown !== undefined) {
237
237
  const { renderMarkdown } = await import('./markdown.js');
238
- const md = renderMarkdown(result, { locale: ctx.locale, title, generator });
238
+ const md = renderMarkdown(result, {
239
+ locale: ctx.locale,
240
+ title,
241
+ generator,
242
+ ...(o.watchAttrs ? { watch: o.watchAttrs } : {}),
243
+ });
239
244
  if (o.markdown !== STDIO)
240
245
  files.push({ path: o.markdown, content: md, label: 'Markdown summary' });
241
246
  else stdoutText = md;
@@ -252,6 +257,7 @@ export async function runDiff(o: DiffCommandOptions, io: CliIO): Promise<number>
252
257
  after: { name: graphLabel(after.graph.meta, afterInput), format: after.format },
253
258
  view: {
254
259
  ...(o.mode ? { mode: o.mode } : {}),
260
+ ...(o.scope ? { scope: o.scope } : {}),
255
261
  ...(o.theme ? { theme: o.theme } : {}),
256
262
  locale: ctx.locale,
257
263
  },
@@ -283,6 +289,7 @@ export async function runDiff(o: DiffCommandOptions, io: CliIO): Promise<number>
283
289
  locale: ctx.locale,
284
290
  width: outputWidth(io),
285
291
  limit: o.limit,
292
+ ...(o.watchAttrs ? { watch: o.watchAttrs } : {}),
286
293
  };
287
294
  if (o.json === STDIO) stdoutText = json(diffToJSON(result), o.pretty);
288
295
  else if (o.delta === STDIO) stdoutText = json(result.delta, o.pretty);
package/src/errors.ts CHANGED
@@ -78,8 +78,27 @@ const KGDIFF_HINTS: Readonly<Record<string, string>> = {
78
78
  'dangling-edge': 'An edge refers to a node the graph does not define.',
79
79
  };
80
80
 
81
+ /** The bug report form an unexpected error links to. */
82
+ const BUG_REPORT_FORM = 'https://github.com/iiwish/kgdiff/issues/new';
83
+
84
+ /**
85
+ * Link to the bug report form, prefilled through the form's field ids (`version`,
86
+ * `environment`). Only public metadata goes into it: error messages and input paths can contain
87
+ * private data, and the person reporting decides what to paste into the form.
88
+ */
89
+ export function bugReportUrl(version: string, runtime?: string): string {
90
+ const url = new URL(BUG_REPORT_FORM);
91
+ url.searchParams.set('template', 'bug_report.yml');
92
+ url.searchParams.set('version', `kgdiff ${version}`);
93
+ if (runtime) url.searchParams.set('environment', runtime);
94
+ return url.href;
95
+ }
96
+
81
97
  /** User-facing message and hint for any thrown value. */
82
- export function describeError(error: unknown): { message: string; hint?: string } {
98
+ export function describeError(
99
+ error: unknown,
100
+ bugReportUrl?: string,
101
+ ): { message: string; hint?: string } {
83
102
  if (error instanceof CliError) {
84
103
  return error.hint ? { message: error.message, hint: error.hint } : { message: error.message };
85
104
  }
@@ -91,7 +110,9 @@ export function describeError(error: unknown): { message: string; hint?: string
91
110
  if (code) return { message: systemReason(error) };
92
111
  return {
93
112
  message: `Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
94
- hint: 'This is a bug in kgdiff. Run again with DEBUG=kgdiff and report the output.',
113
+ hint: bugReportUrl
114
+ ? `This is a bug in kgdiff. Run again with DEBUG=kgdiff and report it: ${bugReportUrl}`
115
+ : 'This is a bug in kgdiff. Run again with DEBUG=kgdiff and report the output.',
95
116
  };
96
117
  }
97
118
 
@@ -103,10 +124,15 @@ export function debugEnabled(debug: string | undefined): boolean {
103
124
  }
104
125
 
105
126
  /** The text written to stderr for an error (ends with a newline). */
106
- export function formatError(error: unknown, paint: Painter, debug: boolean): string {
127
+ export function formatError(
128
+ error: unknown,
129
+ paint: Painter,
130
+ debug: boolean,
131
+ bugReportUrl?: string,
132
+ ): string {
107
133
  // Messages quote user input (paths, parser snippets, JSON.parse excerpts): never let it
108
134
  // carry escape sequences or bidi controls to the terminal.
109
- const { message, hint } = describeError(error);
135
+ const { message, hint } = describeError(error, bugReportUrl);
110
136
  const lines = [`${paint.bold(paint.tone('removed', 'error:'))} ${sanitize(message)}`];
111
137
  if (hint) lines.push(`${paint.tone('accent', 'hint:')} ${sanitize(hint)}`);
112
138
  if (debug) {
package/src/help.ts CHANGED
@@ -104,7 +104,12 @@ const MAIN: readonly Section[] = [
104
104
  text: 'Language of hunk titles and the report: en, zh-CN',
105
105
  default: 'from LANG, else en',
106
106
  },
107
- { flags: '--mode <mode>', text: 'Initial viewer mode: overlay, swipe, replay, changes' },
107
+ { flags: '--mode <mode>', text: 'Initial viewer mode: overlay, swipe, replay' },
108
+ {
109
+ flags: '--scope <scope>',
110
+ text: 'Show the whole graph or only the changes (in any mode): all, changes',
111
+ default: 'changes for large graphs, else all',
112
+ },
108
113
  {
109
114
  flags: '--reproducible',
110
115
  text: 'Omit the timestamp: byte-identical reports for identical input',
@@ -133,6 +138,11 @@ const MAIN: readonly Section[] = [
133
138
  default: 'auto',
134
139
  },
135
140
  { flags: '--limit <n>', text: 'Hunks listed in the summary; 0 lists all', default: '50' },
141
+ {
142
+ flags: '--watch-attr <key>',
143
+ text: 'Flag changes to this attribute ⚑ for review first (repeatable); replaces the defaults',
144
+ default: 'definitions',
145
+ },
136
146
  { flags: '-h, --help', text: 'Show this help' },
137
147
  { flags: '-v, --version', text: 'Show the version' },
138
148
  ],
package/src/html.ts CHANGED
@@ -110,6 +110,7 @@ export function renderReportHtml(options: ReportHtmlOptions): string {
110
110
  `theme="${escapeHtml(theme)}"`,
111
111
  `locale="${escapeHtml(locale)}"`,
112
112
  ...(view.mode ? [`mode="${escapeHtml(view.mode)}"`] : []),
113
+ ...(view.scope ? [`scope="${escapeHtml(view.scope)}"`] : []),
113
114
  ].join(' ');
114
115
  const encodingAttr = encoded.encoding ? ` data-encoding="${encoded.encoding}"` : '';
115
116
  const style =
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ export {
40
40
  } from './markdown.js';
41
41
  export {
42
42
  type ChangeFamily,
43
+ DEFAULT_WATCH_ATTRS,
43
44
  hunkFamily,
44
45
  hunkGlyph,
45
46
  KIND_FAMILY,
@@ -47,6 +48,7 @@ export {
47
48
  nameStatus,
48
49
  type Overview,
49
50
  overview,
51
+ watchedChanges,
50
52
  } from './model.js';
51
53
  export { nodeIO, openCommand } from './node-io.js';
52
54
  export { buildReportHtml, buildReportPayload, createGzip, type ReportInfo } from './report.js';
package/src/io.ts CHANGED
@@ -52,6 +52,11 @@ export interface CliIO {
52
52
  readonly stdinIsTTY: boolean;
53
53
  /** Terminal width of stdout, when known. */
54
54
  readonly columns: number | undefined;
55
+ /**
56
+ * The platform and runtime, for bug reports: `darwin arm64, Node.js v24.11.0`. Optional:
57
+ * without it the bug report link of an unexpected error names only the CLI version.
58
+ */
59
+ readonly runtime?: string;
55
60
  /** Clock for `generatedAt` in HTML reports. */
56
61
  now(): Date;
57
62
  /** Create a fresh private directory (`mkdtemp`) for reports opened with `--open` and no `-o`. */
package/src/locale.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * searched for.
5
5
  */
6
6
  import type { Locale, ReplayPhase } from '@kgdiff/core';
7
- import type { ChangeFamily } from './model.js';
7
+ import { type ChangeFamily, isDefinitionKey, type PossibleMatchSide } from './model.js';
8
8
  import type { Env } from './style.js';
9
9
 
10
10
  export const LOCALES: readonly Locale[] = ['en', 'zh-CN'];
@@ -67,13 +67,71 @@ export interface Messages {
67
67
  readonly byType: string;
68
68
  readonly untyped: string;
69
69
  readonly moreTypes: (n: number) => string;
70
+ /** Watched attributes (`--watch-attr`): changes flagged ⚑ for review first. */
71
+ readonly watch: {
72
+ /** How the default definition keys (`def`, `IAO:0000115`, …) are named. */
73
+ readonly definition: string;
74
+ /** After ⚑ under the diffstat bar: `Review first: definition changed in 10 hunks`. */
75
+ readonly line: (labels: readonly string[], hunks: number) => string;
76
+ /** After ⚑ in a hunk's summary: `definition`. */
77
+ readonly marker: (labels: readonly string[]) => string;
78
+ };
79
+ /** Pairings kgdiff considered but did not apply, listed after the hunks. */
80
+ readonly possible: {
81
+ readonly heading: string;
82
+ /** `“Saudi Aramco” → “Aramco”`; each side's id is added when both have the same name. */
83
+ readonly title: (before: PossibleMatchSide, after: PossibleMatchSide) => string;
84
+ readonly belowThreshold: (threshold: string) => string;
85
+ readonly ambiguous: string;
86
+ readonly evidence: (label: string, neighbors: string) => string;
87
+ /** Terminal: what "not applied" means and how to pair one. */
88
+ readonly hint: string;
89
+ readonly more: (n: number) => string;
90
+ };
70
91
  readonly markdown: {
71
92
  readonly legend: string;
72
93
  readonly more: (n: number) => string;
94
+ /** Heading of the block of hunks that change a watched attribute (after ⚑). */
95
+ readonly reviewFirst: string;
96
+ readonly reviewFirstHint: (labels: readonly string[]) => string;
97
+ readonly reviewFirstMore: (n: number) => string;
98
+ /** Like `possible.hint`, as Markdown. */
99
+ readonly possibleHint: string;
100
+ readonly possibleMore: (n: number) => string;
73
101
  readonly generatedBy: (generator: string) => string;
74
102
  };
75
103
  }
76
104
 
105
+ /**
106
+ * Display names of watched attribute keys, in order: the default definition keys are all
107
+ * “definition” (once), other keys are shown as they are.
108
+ */
109
+ export function watchLabels(keys: readonly string[], m: Messages): string[] {
110
+ const labels: string[] = [];
111
+ for (const key of keys) {
112
+ const label = isDefinitionKey(key) ? m.watch.definition : key;
113
+ if (!labels.includes(label)) labels.push(label);
114
+ }
115
+ return labels;
116
+ }
117
+
118
+ /**
119
+ * Both sides of a possible match, quoted and joined by `arrow`. When they share a name (two
120
+ * “Apple”s), `withId` adds each side's id, as hunk titles do for equally named nodes.
121
+ */
122
+ function possibleTitle(
123
+ before: PossibleMatchSide,
124
+ after: PossibleMatchSide,
125
+ quote: (name: string) => string,
126
+ withId: (quoted: string, id: string) => string,
127
+ arrow: string,
128
+ ): string {
129
+ const same = before.name === after.name;
130
+ const side = (s: PossibleMatchSide): string =>
131
+ same ? withId(quote(s.name), s.id) : quote(s.name);
132
+ return `${side(before)}${arrow}${side(after)}`;
133
+ }
134
+
77
135
  /** A count before and after the change. */
78
136
  export type Count = readonly [before: number, after: number];
79
137
 
@@ -123,12 +181,47 @@ const en: Messages = {
123
181
  byType: 'By type',
124
182
  untyped: '(untyped)',
125
183
  moreTypes: (n) => `… and ${plural(n, 'more type', 'more types')}`,
184
+ watch: {
185
+ definition: 'definition',
186
+ line: (labels, hunks) =>
187
+ `Review first: ${labels.join(', ')} changed in ${plural(hunks, 'hunk', 'hunks')}`,
188
+ marker: (labels) => labels.join(', '),
189
+ },
190
+ possible: {
191
+ heading: 'Possible matches',
192
+ title: (before, after) =>
193
+ possibleTitle(
194
+ before,
195
+ after,
196
+ (name) => `“${name}”`,
197
+ (quoted, id) => `${quoted} (${id})`,
198
+ ' → ',
199
+ ),
200
+ belowThreshold: (threshold) => `below the ${threshold} threshold`,
201
+ ambiguous: 'too close to another candidate',
202
+ evidence: (label, neighbors) => `name ${label} · neighbours ${neighbors}`,
203
+ hint:
204
+ 'Not paired, so listed as removed or added. To pair one, add a synonym or a ' +
205
+ 'replacement link, or lower --threshold.',
206
+ more: (n) => `… and ${formatInt(n)} more · --json lists all`,
207
+ },
126
208
  markdown: {
127
209
  legend:
128
210
  '🟩 added · 🟥 removed · 🟨 content (renames, attributes, types, synonyms) · ' +
129
211
  '🟦 structure (moves, relations) · 🟪 identity (merges, splits)',
130
212
  more: (n) =>
131
213
  `…and ${plural(n, 'more hunk', 'more hunks')}. Open the HTML report for the full list.`,
214
+ reviewFirst: 'Review first',
215
+ reviewFirstHint: (labels) =>
216
+ `These hunks change a watched attribute (${labels.join(', ')}). Such an edit can change ` +
217
+ 'what existing data means even when the diff looks small.',
218
+ reviewFirstMore: (n) => `…and ${formatInt(n)} more.`,
219
+ possibleHint:
220
+ 'kgdiff did not pair these nodes, so they are listed above as removed or added. If a ' +
221
+ 'pair is the same thing, add a synonym or a replacement link, or lower `--threshold`.',
222
+ possibleMore: (n) =>
223
+ `…and ${plural(n, 'more possible match', 'more possible matches')}. The HTML report ` +
224
+ "shows them in each node's details.",
132
225
  generatedBy: (generator) => `Generated by ${generator}`,
133
226
  },
134
227
  };
@@ -162,10 +255,39 @@ const zh: Messages = {
162
255
  byType: '按类型',
163
256
  untyped: '(无类型)',
164
257
  moreTypes: (n) => `… 另有 ${formatInt(n)} 个类型`,
258
+ watch: {
259
+ definition: '定义',
260
+ line: (labels, hunks) => `优先审查:${formatInt(hunks)} 组变更修改了${labels.join('、')}`,
261
+ marker: (labels) => labels.join('、'),
262
+ },
263
+ possible: {
264
+ heading: '可能的匹配',
265
+ title: (before, after) =>
266
+ possibleTitle(
267
+ before,
268
+ after,
269
+ (name) => `「${name}」`,
270
+ (quoted, id) => `${quoted}(${id})`,
271
+ '→',
272
+ ),
273
+ belowThreshold: (threshold) => `低于阈值 ${threshold}`,
274
+ ambiguous: '与其他候选过于接近',
275
+ evidence: (label, neighbors) => `名称 ${label} · 邻居 ${neighbors}`,
276
+ hint: '这些节点未配对,因此按删除或新增列出。如是同一节点,可补充别名或替换链接,或调低 --threshold。',
277
+ more: (n) => `… 另有 ${formatInt(n)} 对 · --json 列出全部`,
278
+ },
165
279
  markdown: {
166
280
  legend:
167
281
  '🟩 新增 · 🟥 删除 · 🟨 内容(改名、属性、类型、别名) · 🟦 结构(移动、关系) · 🟪 身份(合并、拆分、换 ID)',
168
282
  more: (n) => `……另有 ${formatInt(n)} 组变更未列出,完整列表请查看 HTML 报告。`,
283
+ reviewFirst: '优先审查',
284
+ reviewFirstHint: (labels) =>
285
+ `这些变更修改了受关注的属性(${labels.join('、')})。这类修改在差异里看起来很小,却可能改变已有数据的含义。`,
286
+ reviewFirstMore: (n) => `……另有 ${formatInt(n)} 组。`,
287
+ possibleHint:
288
+ 'kgdiff 没有配对这些节点,因此它们在上方按删除或新增列出。如果某一对其实是同一节点,可补充别名或替换链接,或调低 `--threshold`。',
289
+ possibleMore: (n) =>
290
+ `……另有 ${formatInt(n)} 对可能的匹配未列出,HTML 报告的节点详情中可以看到全部。`,
169
291
  generatedBy: (generator) => `由 ${generator} 生成`,
170
292
  },
171
293
  };
package/src/main.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * load the diff engine.
5
5
  */
6
6
  import { parseCommandLine, peekColorMode } from './args.js';
7
- import { debugEnabled, formatError } from './errors.js';
7
+ import { bugReportUrl, debugEnabled, formatError } from './errors.js';
8
8
  import { renderHelp } from './help.js';
9
9
  import type { CliIO } from './io.js';
10
10
  import { createPainter, isLightBackground, resolveColorLevel } from './style.js';
@@ -50,7 +50,10 @@ export async function main(argv: readonly string[], io: CliIO): Promise<number>
50
50
  resolveColorLevel(peekColorMode(argv), io.env, io.stderrIsTTY),
51
51
  light,
52
52
  );
53
- io.stderr.write(formatError(error, paint, debugEnabled(io.env.DEBUG)));
53
+ // The platform comes from `io` like every other side effect, so `main` stays runnable
54
+ // in-process and its output deterministic in tests.
55
+ const report = bugReportUrl(cliVersion(), io.runtime);
56
+ io.stderr.write(formatError(error, paint, debugEnabled(io.env.DEBUG), report));
54
57
  return 2;
55
58
  }
56
59
  }
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
+ }