@kgdiff/cli 0.0.0-stage → 0.2.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 (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +211 -2
  3. package/dist/apply-command.d.ts +34 -0
  4. package/dist/apply-command.d.ts.map +1 -0
  5. package/dist/apply-command.js +137 -0
  6. package/dist/apply-command.js.map +1 -0
  7. package/dist/args.d.ts +96 -0
  8. package/dist/args.d.ts.map +1 -0
  9. package/dist/args.js +463 -0
  10. package/dist/args.js.map +1 -0
  11. package/dist/bin.d.ts +3 -0
  12. package/dist/bin.d.ts.map +1 -0
  13. package/dist/bin.js +17 -0
  14. package/dist/bin.js.map +1 -0
  15. package/dist/diff-command.d.ts +29 -0
  16. package/dist/diff-command.d.ts.map +1 -0
  17. package/dist/diff-command.js +236 -0
  18. package/dist/diff-command.js.map +1 -0
  19. package/dist/errors.d.ts +48 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +92 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/help.d.ts +11 -0
  24. package/dist/help.d.ts.map +1 -0
  25. package/dist/help.js +247 -0
  26. package/dist/help.js.map +1 -0
  27. package/dist/html.d.ts +53 -0
  28. package/dist/html.d.ts.map +1 -0
  29. package/dist/html.js +107 -0
  30. package/dist/html.js.map +1 -0
  31. package/dist/index.d.ts +25 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +24 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/inputs.d.ts +54 -0
  36. package/dist/inputs.d.ts.map +1 -0
  37. package/dist/inputs.js +209 -0
  38. package/dist/inputs.js.map +1 -0
  39. package/dist/io.d.ts +64 -0
  40. package/dist/io.d.ts.map +1 -0
  41. package/dist/io.js +2 -0
  42. package/dist/io.js.map +1 -0
  43. package/dist/locale.d.ts +58 -0
  44. package/dist/locale.d.ts.map +1 -0
  45. package/dist/locale.js +121 -0
  46. package/dist/locale.js.map +1 -0
  47. package/dist/main.d.ts +10 -0
  48. package/dist/main.d.ts.map +1 -0
  49. package/dist/main.js +51 -0
  50. package/dist/main.js.map +1 -0
  51. package/dist/markdown.d.ts +30 -0
  52. package/dist/markdown.d.ts.map +1 -0
  53. package/dist/markdown.js +93 -0
  54. package/dist/markdown.js.map +1 -0
  55. package/dist/model.d.ts +63 -0
  56. package/dist/model.d.ts.map +1 -0
  57. package/dist/model.js +151 -0
  58. package/dist/model.js.map +1 -0
  59. package/dist/node-io.d.ts +12 -0
  60. package/dist/node-io.d.ts.map +1 -0
  61. package/dist/node-io.js +135 -0
  62. package/dist/node-io.js.map +1 -0
  63. package/dist/output.d.ts +27 -0
  64. package/dist/output.d.ts.map +1 -0
  65. package/dist/output.js +50 -0
  66. package/dist/output.js.map +1 -0
  67. package/dist/overwrite.d.ts +14 -0
  68. package/dist/overwrite.d.ts.map +1 -0
  69. package/dist/overwrite.js +55 -0
  70. package/dist/overwrite.js.map +1 -0
  71. package/dist/report.d.ts +52 -0
  72. package/dist/report.d.ts.map +1 -0
  73. package/dist/report.js +64 -0
  74. package/dist/report.js.map +1 -0
  75. package/dist/style.d.ts +46 -0
  76. package/dist/style.d.ts.map +1 -0
  77. package/dist/style.js +140 -0
  78. package/dist/style.js.map +1 -0
  79. package/dist/summary.d.ts +40 -0
  80. package/dist/summary.d.ts.map +1 -0
  81. package/dist/summary.js +352 -0
  82. package/dist/summary.js.map +1 -0
  83. package/dist/version.d.ts +6 -0
  84. package/dist/version.d.ts.map +1 -0
  85. package/dist/version.js +19 -0
  86. package/dist/version.js.map +1 -0
  87. package/dist/width.d.ts +45 -0
  88. package/dist/width.d.ts.map +1 -0
  89. package/dist/width.js +232 -0
  90. package/dist/width.js.map +1 -0
  91. package/package.json +56 -3
  92. package/src/apply-command.ts +203 -0
  93. package/src/args.ts +605 -0
  94. package/src/bin.ts +22 -0
  95. package/src/diff-command.ts +299 -0
  96. package/src/errors.ts +131 -0
  97. package/src/help.ts +286 -0
  98. package/src/html.ts +145 -0
  99. package/src/index.ts +71 -0
  100. package/src/inputs.ts +269 -0
  101. package/src/io.ts +68 -0
  102. package/src/locale.ts +177 -0
  103. package/src/main.ts +56 -0
  104. package/src/markdown.ts +119 -0
  105. package/src/model.ts +181 -0
  106. package/src/node-io.ts +146 -0
  107. package/src/output.ts +81 -0
  108. package/src/overwrite.ts +75 -0
  109. package/src/report.ts +101 -0
  110. package/src/style.ts +164 -0
  111. package/src/summary.ts +440 -0
  112. package/src/version.ts +21 -0
  113. package/src/width.ts +222 -0
package/src/inputs.ts ADDED
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Reading inputs: files or stdin, gzip-compressed or not, UTF-8 / UTF-16 text, parsed with
3
+ * `@kgdiff/formats`. Failures become `CliError`s that name the file and suggest a fix.
4
+ */
5
+ import { basename, resolve } from 'node:path';
6
+ import { type FormatId, type ParseOptions, type ParseResult, parseGraph } from '@kgdiff/formats';
7
+ import { FORMATS, STDIO } from './args.js';
8
+ import { CliError, isKgdiffError, systemErrorCode, systemReason } from './errors.js';
9
+ import type { CliIO } from './io.js';
10
+ import type { Env } from './style.js';
11
+ import { sanitize } from './width.js';
12
+
13
+ export const FORMAT_NAMES: Readonly<Record<FormatId, string>> = {
14
+ json: 'JSON',
15
+ jsonld: 'JSON-LD',
16
+ turtle: 'Turtle',
17
+ ntriples: 'N-Triples',
18
+ nquads: 'N-Quads',
19
+ trig: 'TriG',
20
+ rdfxml: 'RDF/XML',
21
+ obo: 'OBO',
22
+ };
23
+
24
+ export interface RawInput {
25
+ /** How the input is called in messages: the path as given, or `stdin`. */
26
+ readonly display: string;
27
+ /** File name without directories and without `.gz` (format detection, `meta.source`). */
28
+ readonly filename?: string;
29
+ readonly text: string;
30
+ /** True for empty or whitespace-only input (only returned with `allowEmpty`). */
31
+ readonly empty: boolean;
32
+ }
33
+
34
+ const READ_HINTS: Readonly<Record<string, string>> = {
35
+ ENOENT: 'Check the path. Use - to read from stdin.',
36
+ EISDIR: 'Pass a file, not a directory.',
37
+ EACCES: 'Check the file permissions.',
38
+ };
39
+
40
+ /** Resolve a user-supplied path against the IO's working directory. */
41
+ export function resolvePath(io: CliIO, path: string): string {
42
+ return resolve(io.cwd, path);
43
+ }
44
+
45
+ /** Default input size limit (bytes, after decompression): 1 GiB. */
46
+ export const DEFAULT_MAX_INPUT = 1024 ** 3;
47
+
48
+ const SIZE_UNITS: Readonly<Record<string, number>> = { '': 1, k: 1024, m: 1024 ** 2, g: 1024 ** 3 };
49
+
50
+ /**
51
+ * Input size limit: `KGDIFF_MAX_INPUT` (bytes, or with a binary `K` / `M` / `G` suffix, e.g.
52
+ * `4G`), else `DEFAULT_MAX_INPUT`. Applies to files, stdin and decompressed gzip data.
53
+ *
54
+ * @throws CliError `usage` for an unreadable value.
55
+ */
56
+ export function maxInputBytes(env: Env): number {
57
+ const raw = env.KGDIFF_MAX_INPUT?.trim();
58
+ if (!raw) return DEFAULT_MAX_INPUT;
59
+ const match = /^(\d+(?:\.\d+)?)\s*([kmg]?)i?b?$/i.exec(raw);
60
+ const value = match ? Number(match[1]) * (SIZE_UNITS[(match[2] ?? '').toLowerCase()] ?? 1) : NaN;
61
+ if (!Number.isFinite(value) || value < 1) {
62
+ throw new CliError(
63
+ 'usage',
64
+ `Invalid KGDIFF_MAX_INPUT "${sanitize(raw)}".`,
65
+ 'Use a byte count such as 2147483648, or 512M / 2G.',
66
+ );
67
+ }
68
+ return Math.floor(value);
69
+ }
70
+
71
+ function formatBytes(bytes: number): string {
72
+ for (const [unit, size] of [
73
+ ['GiB', 1024 ** 3],
74
+ ['MiB', 1024 ** 2],
75
+ ['KiB', 1024],
76
+ ] as const) {
77
+ if (bytes >= size) return `${Number((bytes / size).toFixed(1))} ${unit}`;
78
+ }
79
+ return `${bytes} B`;
80
+ }
81
+
82
+ function tooLarge(where: string, limit: number, cause?: unknown): CliError {
83
+ return new CliError(
84
+ 'input',
85
+ `${where} is too large: the input limit is ${formatBytes(limit)}.`,
86
+ 'If the input is genuine, raise the limit with KGDIFF_MAX_INPUT (e.g. KGDIFF_MAX_INPUT=4G).',
87
+ cause === undefined ? undefined : { cause },
88
+ );
89
+ }
90
+
91
+ function isTooLarge(error: unknown): boolean {
92
+ const code = (error as { code?: unknown } | null)?.code;
93
+ return (
94
+ error instanceof RangeError ||
95
+ code === 'ERR_BUFFER_TOO_LARGE' ||
96
+ code === 'ERR_FS_FILE_TOO_LARGE' ||
97
+ code === 'ERR_STRING_TOO_LONG'
98
+ );
99
+ }
100
+
101
+ /**
102
+ * Decode input bytes: gunzip when gzip-compressed (output capped at `limit`, so a gzip bomb is
103
+ * an error, not an out-of-memory crash), UTF-16 by BOM, else strict UTF-8.
104
+ */
105
+ export async function decodeText(bytes: Uint8Array, where: string, limit: number): Promise<string> {
106
+ let data = bytes;
107
+ if (data[0] === 0x1f && data[1] === 0x8b) {
108
+ const { gunzipSync } = await import('node:zlib');
109
+ try {
110
+ data = gunzipSync(data, { maxOutputLength: limit });
111
+ } catch (error) {
112
+ if (isTooLarge(error)) throw tooLarge(`${where} (decompressed)`, limit, error);
113
+ throw new CliError(
114
+ 'input',
115
+ `Cannot decompress ${where}: ${systemReason(error)}.`,
116
+ undefined,
117
+ {
118
+ cause: error,
119
+ },
120
+ );
121
+ }
122
+ }
123
+ const encoding =
124
+ data[0] === 0xff && data[1] === 0xfe
125
+ ? 'utf-16le'
126
+ : data[0] === 0xfe && data[1] === 0xff
127
+ ? 'utf-16be'
128
+ : 'utf-8';
129
+ try {
130
+ return new TextDecoder(encoding, { fatal: true }).decode(data);
131
+ } catch (error) {
132
+ if (isTooLarge(error)) throw tooLarge(where, limit, error);
133
+ throw new CliError(
134
+ 'input',
135
+ `${where} is not valid ${encoding.toUpperCase()} text.`,
136
+ `kgdiff reads text formats (${Object.values(FORMAT_NAMES).join(', ')}). Convert binary formats first.`,
137
+ { cause: error },
138
+ );
139
+ }
140
+ }
141
+
142
+ export interface ReadOptions {
143
+ /** Accept an empty (or whitespace-only) input as an empty graph (`--allow-empty`). */
144
+ readonly allowEmpty?: boolean;
145
+ /** Hint shown when the input is empty and that is not allowed. */
146
+ readonly emptyHint?: string;
147
+ }
148
+
149
+ /**
150
+ * Read a file (or stdin for `-`) as text. `role` names the argument in messages. Inputs above
151
+ * the size limit, and empty inputs unless `allowEmpty`, are errors.
152
+ */
153
+ export async function readInput(
154
+ io: CliIO,
155
+ path: string,
156
+ role: string,
157
+ options: ReadOptions = {},
158
+ ): Promise<RawInput> {
159
+ const limit = maxInputBytes(io.env);
160
+ let raw: { display: string; where: string; filename?: string; bytes: Uint8Array };
161
+ if (path === STDIO) {
162
+ if (io.stdinIsTTY) {
163
+ io.stderr.write(`Reading ${role} from stdin… (end with Ctrl-D)\n`);
164
+ }
165
+ let bytes: Uint8Array;
166
+ try {
167
+ bytes = await io.readStdin(limit);
168
+ } catch (error) {
169
+ if (isTooLarge(error)) throw tooLarge(`${role} (stdin)`, limit, error);
170
+ throw error;
171
+ }
172
+ raw = { display: 'stdin', where: `${role} (stdin)`, bytes };
173
+ } else {
174
+ const display = sanitize(path);
175
+ const where = `${role} "${display}"`;
176
+ const absolute = resolvePath(io, path);
177
+ let bytes: Uint8Array;
178
+ try {
179
+ const size = (await io.fs.stat(absolute))?.size;
180
+ if (size !== undefined && size > limit) throw tooLarge(where, limit);
181
+ bytes = await io.fs.readFile(absolute);
182
+ } catch (error) {
183
+ if (error instanceof CliError) throw error;
184
+ if (isTooLarge(error)) throw tooLarge(where, limit, error);
185
+ const code = systemErrorCode(error);
186
+ throw new CliError(
187
+ 'input',
188
+ `Cannot read ${where}: ${systemReason(error)}.`,
189
+ (code && READ_HINTS[code]) || undefined,
190
+ { cause: error },
191
+ );
192
+ }
193
+ raw = { display, where, filename: basename(path).replace(/\.gz$/i, ''), bytes };
194
+ }
195
+ const text = await decodeText(raw.bytes, raw.where, limit);
196
+ const empty = text.trim() === '';
197
+ if (empty && !options.allowEmpty) {
198
+ throw new CliError(
199
+ 'input',
200
+ `${raw.where} is empty.`,
201
+ options.emptyHint ??
202
+ (path === STDIO
203
+ ? 'Did the command before the pipe fail? Pass --allow-empty if an empty graph is intended.'
204
+ : 'Pass --allow-empty to compare with an empty graph (e.g. before a first release).'),
205
+ );
206
+ }
207
+ return {
208
+ display: raw.display,
209
+ ...(raw.filename !== undefined ? { filename: raw.filename } : {}),
210
+ text,
211
+ empty,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Read two inputs concurrently; when both fail, the first one's error is reported (so the
217
+ * message does not depend on which read finished first).
218
+ */
219
+ export async function readPair(
220
+ first: Promise<RawInput>,
221
+ second: Promise<RawInput>,
222
+ ): Promise<[RawInput, RawInput]> {
223
+ const [a, b] = await Promise.allSettled([first, second]);
224
+ if (a.status === 'rejected') throw a.reason;
225
+ if (b.status === 'rejected') throw b.reason;
226
+ return [a.value, b.value];
227
+ }
228
+
229
+ export interface GraphParseOptions {
230
+ readonly format: FormatId | 'auto';
231
+ readonly languages?: readonly string[];
232
+ /** Flag that overrides the format of this input, for hints (`--before-format`). */
233
+ readonly formatFlag: string;
234
+ }
235
+
236
+ /** Parse a graph, turning parser errors into messages that name the input. */
237
+ export function parseInput(input: RawInput, options: GraphParseOptions): ParseResult {
238
+ const parseOptions: ParseOptions = {
239
+ format: options.format,
240
+ ...(input.filename !== undefined ? { filename: input.filename } : {}),
241
+ ...(options.languages ? { rdf: { languages: options.languages } } : {}),
242
+ };
243
+ if (input.empty) {
244
+ // --allow-empty: an empty graph, without asking a parser to guess the format of nothing.
245
+ return parseGraph('{"nodes":[],"edges":[]}', { ...parseOptions, format: 'json' });
246
+ }
247
+ try {
248
+ return parseGraph(input.text, parseOptions);
249
+ } catch (error) {
250
+ if (!isKgdiffError(error)) throw error;
251
+ const where = input.display === 'stdin' ? 'stdin' : `"${input.display}"`;
252
+ const undetected = /could not detect the format/i.test(error.message);
253
+ const message = undetected
254
+ ? `Cannot tell the format of ${where}.`
255
+ : `${where}: ${error.message.replace(/\.$/, '')}.`;
256
+ const formats = FORMATS.filter((format) => format !== 'auto').join(', ');
257
+ // A kgdiff file names its own format and version: another --format cannot help.
258
+ const marked = error.code === 'unsupported-version';
259
+ const newer = marked && typeof error.details?.version === 'number' && error.details.version > 1;
260
+ const hint = undetected
261
+ ? `Pass ${options.formatFlag} <fmt> (${formats}) or use a known file extension.`
262
+ : newer
263
+ ? 'Upgrade kgdiff: npm install -g @kgdiff/cli@latest.'
264
+ : options.format === 'auto' && !marked
265
+ ? `If the format was detected wrongly, pass ${options.formatFlag} <fmt> (${formats}).`
266
+ : undefined;
267
+ throw new CliError('input', message, hint, { cause: error });
268
+ }
269
+ }
package/src/io.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Everything the CLI needs from the outside world, injected so that `main` runs in-process in
3
+ * tests (fake streams, in-memory files, fixed clock) and for real in `bin.ts` (`nodeIO`).
4
+ */
5
+ import type { Env } from './style.js';
6
+
7
+ /** The standalone viewer bundles an HTML report can inline. */
8
+ export type ViewerBundle = 'main' | 'compute';
9
+
10
+ /** A writable text stream (stdout / stderr). */
11
+ export interface OutputStream {
12
+ write(chunk: string): void;
13
+ }
14
+
15
+ /** What overwrite checks need to know about an existing file. */
16
+ export interface FileStat {
17
+ /** Device and inode: equal for the same file under any name (case variant, link). */
18
+ readonly dev: number;
19
+ readonly ino: number;
20
+ readonly size: number;
21
+ }
22
+
23
+ export interface CliFileSystem {
24
+ /** Read a whole file. Rejects with a Node-style error (`code`: ENOENT, EISDIR, EACCES…). */
25
+ readFile(path: string): Promise<Uint8Array>;
26
+ /**
27
+ * Create or replace a file atomically: write a temporary file in the same directory, then
28
+ * rename it over `path` (readers never see a half-written file; a symlink at `path` is
29
+ * replaced, not written through).
30
+ */
31
+ writeFile(path: string, data: string | Uint8Array): Promise<void>;
32
+ /** Stat following symlinks; `undefined` when nothing exists at `path`. */
33
+ stat(path: string): Promise<FileStat | undefined>;
34
+ /** Canonical absolute path (symlinks resolved). Rejects when `path` does not exist. */
35
+ realpath(path: string): Promise<string>;
36
+ }
37
+
38
+ export interface CliIO {
39
+ readonly stdout: OutputStream;
40
+ readonly stderr: OutputStream;
41
+ /** Read all of stdin; rejects with a `RangeError` beyond `maxBytes`. */
42
+ readStdin(maxBytes: number): Promise<Uint8Array>;
43
+ readonly fs: CliFileSystem;
44
+ readonly env: Env;
45
+ /** Directory relative paths are resolved against. */
46
+ readonly cwd: string;
47
+ /** Whether stdout is a terminal. */
48
+ readonly isTTY: boolean;
49
+ /** Whether stderr is a terminal (colors of errors and notices). */
50
+ readonly stderrIsTTY: boolean;
51
+ /** Whether stdin is a terminal (nothing piped in). */
52
+ readonly stdinIsTTY: boolean;
53
+ /** Terminal width of stdout, when known. */
54
+ readonly columns: number | undefined;
55
+ /** Clock for `generatedAt` in HTML reports. */
56
+ now(): Date;
57
+ /** Create a fresh private directory (`mkdtemp`) for reports opened with `--open` and no `-o`. */
58
+ makeTempDir(): Promise<string>;
59
+ /** Open a file with the platform's default application (the browser, for HTML). */
60
+ openFile(path: string): Promise<void>;
61
+ /**
62
+ * Source of a standalone viewer bundle: `main` (`@kgdiff/viewer/standalone`, the
63
+ * `<kgdiff-viewer>` element) or `compute` (`@kgdiff/viewer/standalone/compute`, the optional
64
+ * diff / layout add-on). Rejects with a `CliError` (`viewer-missing`) when it is not built or
65
+ * not installed.
66
+ */
67
+ loadViewerBundle(part: ViewerBundle): Promise<string>;
68
+ }
package/src/locale.ts ADDED
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Locale selection and the CLI's own strings (summary, stat, Markdown). Hunk titles come from
3
+ * `@kgdiff/core`'s `formatHunk`; errors and notices on stderr stay in English so they can be
4
+ * searched for.
5
+ */
6
+ import type { Locale, ReplayPhase } from '@kgdiff/core';
7
+ import type { ChangeFamily } from './model.js';
8
+ import type { Env } from './style.js';
9
+
10
+ export const LOCALES: readonly Locale[] = ['en', 'zh-CN'];
11
+
12
+ /**
13
+ * Map a locale tag or POSIX locale (`zh_CN.UTF-8`, `zh-Hant`, `en_US`, `C`) to a supported
14
+ * locale: any Chinese tag → `zh-CN`, any English or POSIX tag → `en`, anything else `undefined`.
15
+ */
16
+ export function matchLocale(tag: string): Locale | undefined {
17
+ const t = tag
18
+ .trim()
19
+ .toLowerCase()
20
+ .replace(/[.@].*$/, '')
21
+ .replace(/_/g, '-');
22
+ if (t === 'zh' || t.startsWith('zh-') || t === 'cn') return 'zh-CN';
23
+ if (t === 'en' || t.startsWith('en-') || t === 'c' || t === 'posix') return 'en';
24
+ return undefined;
25
+ }
26
+
27
+ /**
28
+ * The locale to use: `--locale` when given, else the first non-empty of `LC_ALL`, `LC_MESSAGES`,
29
+ * `LANG` (POSIX precedence), mapped with `matchLocale`; `en` when nothing matches.
30
+ */
31
+ export function resolveLocale(flag: Locale | undefined, env: Env): Locale {
32
+ if (flag) return flag;
33
+ for (const name of ['LC_ALL', 'LC_MESSAGES', 'LANG'] as const) {
34
+ const value = env[name];
35
+ if (value) return matchLocale(value) ?? 'en';
36
+ }
37
+ return 'en';
38
+ }
39
+
40
+ /** Thousands separators without `Intl`, so output never depends on the runtime's ICU data. */
41
+ export function formatInt(n: number): string {
42
+ return String(Math.trunc(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
43
+ }
44
+
45
+ export interface Messages {
46
+ /** Group headings, by replay phase. */
47
+ readonly phase: Readonly<Record<ReplayPhase, string>>;
48
+ /** Counter labels (`+3 added`), by replay phase. */
49
+ readonly counter: Readonly<Record<ReplayPhase, string>>;
50
+ readonly family: Readonly<Record<ChangeFamily, string>>;
51
+ /** `179 → 184 nodes · 351 → 353 edges`; one number when a count did not change. */
52
+ readonly totals: (nodes: Count, edges: Count) => string;
53
+ readonly changes: (changes: number, hunks: number) => string;
54
+ readonly more: (n: number) => string;
55
+ readonly moreHint: string;
56
+ readonly identical: string;
57
+ readonly identicalDetail: (nodes: number, edges: number) => string;
58
+ readonly table: {
59
+ readonly nodes: string;
60
+ readonly edges: string;
61
+ readonly added: string;
62
+ readonly removed: string;
63
+ readonly modified: string;
64
+ readonly unchanged: string;
65
+ readonly total: string;
66
+ };
67
+ readonly byType: string;
68
+ readonly untyped: string;
69
+ readonly moreTypes: (n: number) => string;
70
+ readonly markdown: {
71
+ readonly legend: string;
72
+ readonly more: (n: number) => string;
73
+ readonly generatedBy: (generator: string) => string;
74
+ };
75
+ }
76
+
77
+ /** A count before and after the change. */
78
+ export type Count = readonly [before: number, after: number];
79
+
80
+ const plural = (n: number, one: string, many: string): string =>
81
+ `${formatInt(n)} ${n === 1 ? one : many}`;
82
+
83
+ /** `179 → 184`, or `74` when unchanged. */
84
+ export function formatCount([before, after]: Count): string {
85
+ return before === after ? formatInt(after) : `${formatInt(before)} → ${formatInt(after)}`;
86
+ }
87
+
88
+ const en: Messages = {
89
+ phase: {
90
+ restructure: 'Restructured',
91
+ modify: 'Modified',
92
+ add: 'Added',
93
+ remove: 'Removed',
94
+ },
95
+ counter: { add: 'added', remove: 'removed', modify: 'modified', restructure: 'restructured' },
96
+ family: {
97
+ added: 'added',
98
+ removed: 'removed',
99
+ content: 'content',
100
+ structure: 'structure',
101
+ identity: 'identity',
102
+ },
103
+ // The noun agrees with the number next to it (the after count): `1 node`, `2 → 1 node`.
104
+ totals: (nodes, edges) =>
105
+ `${formatCount(nodes)} ${nodes[1] === 1 ? 'node' : 'nodes'} · ` +
106
+ `${formatCount(edges)} ${edges[1] === 1 ? 'edge' : 'edges'}`,
107
+ changes: (changes, hunks) =>
108
+ `${plural(changes, 'change', 'changes')} in ${plural(hunks, 'hunk', 'hunks')}`,
109
+ more: (n) => `… and ${formatInt(n)} more`,
110
+ moreHint: '--limit 0 lists all',
111
+ identical: 'No differences',
112
+ identicalDetail: (nodes, edges) =>
113
+ `Both versions have ${plural(nodes, 'node', 'nodes')} and ${plural(edges, 'edge', 'edges')}.`,
114
+ table: {
115
+ nodes: 'nodes',
116
+ edges: 'edges',
117
+ added: 'added',
118
+ removed: 'removed',
119
+ modified: 'modified',
120
+ unchanged: 'unchanged',
121
+ total: 'before → after',
122
+ },
123
+ byType: 'By type',
124
+ untyped: '(untyped)',
125
+ moreTypes: (n) => `… and ${plural(n, 'more type', 'more types')}`,
126
+ markdown: {
127
+ legend:
128
+ '🟩 added · 🟥 removed · 🟨 content (renames, attributes, types, synonyms) · ' +
129
+ '🟦 structure (moves, relations) · 🟪 identity (merges, splits)',
130
+ more: (n) =>
131
+ `…and ${plural(n, 'more hunk', 'more hunks')}. Open the HTML report for the full list.`,
132
+ generatedBy: (generator) => `Generated by ${generator}`,
133
+ },
134
+ };
135
+
136
+ const zh: Messages = {
137
+ phase: { restructure: '结构调整', modify: '修改', add: '新增', remove: '删除' },
138
+ counter: { add: '新增', remove: '删除', modify: '修改', restructure: '结构调整' },
139
+ family: {
140
+ added: '新增',
141
+ removed: '删除',
142
+ content: '内容',
143
+ structure: '结构',
144
+ identity: '身份',
145
+ },
146
+ totals: (nodes, edges) => `节点 ${formatCount(nodes)} · 关系 ${formatCount(edges)}`,
147
+ changes: (changes, hunks) => `${formatInt(hunks)} 组共 ${formatInt(changes)} 处变化`,
148
+ more: (n) => `… 另有 ${formatInt(n)} 组`,
149
+ moreHint: '--limit 0 显示全部',
150
+ identical: '没有差异',
151
+ identicalDetail: (nodes, edges) =>
152
+ `两个版本均有 ${formatInt(nodes)} 个节点、${formatInt(edges)} 条关系。`,
153
+ table: {
154
+ nodes: '节点',
155
+ edges: '关系',
156
+ added: '新增',
157
+ removed: '删除',
158
+ modified: '修改',
159
+ unchanged: '未变',
160
+ total: '变更前 → 变更后',
161
+ },
162
+ byType: '按类型',
163
+ untyped: '(无类型)',
164
+ moreTypes: (n) => `… 另有 ${formatInt(n)} 个类型`,
165
+ markdown: {
166
+ legend:
167
+ '🟩 新增 · 🟥 删除 · 🟨 内容(改名、属性、类型、别名) · 🟦 结构(移动、关系) · 🟪 身份(合并、拆分、换 ID)',
168
+ more: (n) => `……另有 ${formatInt(n)} 组变更未列出,完整列表请查看 HTML 报告。`,
169
+ generatedBy: (generator) => `由 ${generator} 生成`,
170
+ },
171
+ };
172
+
173
+ const MESSAGES: Readonly<Record<Locale, Messages>> = { en, 'zh-CN': zh };
174
+
175
+ export function messages(locale: Locale): Messages {
176
+ return MESSAGES[locale];
177
+ }
package/src/main.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Entry point logic: parse the command line, dispatch, map every failure to a friendly message
3
+ * on stderr and an exit code. Commands are loaded on demand so `--help` and `--version` never
4
+ * load the diff engine.
5
+ */
6
+ import { parseCommandLine, peekColorMode } from './args.js';
7
+ import { debugEnabled, formatError } from './errors.js';
8
+ import { renderHelp } from './help.js';
9
+ import type { CliIO } from './io.js';
10
+ import { createPainter, isLightBackground, resolveColorLevel } from './style.js';
11
+ import { cliVersion } from './version.js';
12
+
13
+ /**
14
+ * Run the CLI with `argv` (without `node` and the script path).
15
+ *
16
+ * Exit codes: 0 success (and no differences with `--exit-code`), 1 differences with
17
+ * `--exit-code`, 2 usage or input error (message on stderr; stack trace only with
18
+ * `DEBUG=kgdiff`). Never throws.
19
+ */
20
+ export async function main(argv: readonly string[], io: CliIO): Promise<number> {
21
+ const light = isLightBackground(io.env);
22
+ try {
23
+ const command = parseCommandLine(argv);
24
+ switch (command.kind) {
25
+ case 'help': {
26
+ const paint = createPainter(resolveColorLevel(command.color, io.env, io.isTTY), light);
27
+ io.stdout.write(
28
+ renderHelp(command.topic, cliVersion(), paint, io.isTTY && io.columns ? io.columns : 80),
29
+ );
30
+ return 0;
31
+ }
32
+ case 'version':
33
+ io.stdout.write(`kgdiff ${cliVersion()}\n`);
34
+ return 0;
35
+ case 'diff': {
36
+ const { runDiff } = await import('./diff-command.js');
37
+ return await runDiff(command.options, io);
38
+ }
39
+ case 'apply': {
40
+ const { runApply } = await import('./apply-command.js');
41
+ return await runApply(command.options, io);
42
+ }
43
+ default: {
44
+ const exhaustive: never = command;
45
+ throw new Error(`Unknown command ${JSON.stringify(exhaustive)}`);
46
+ }
47
+ }
48
+ } catch (error) {
49
+ const paint = createPainter(
50
+ resolveColorLevel(peekColorMode(argv), io.env, io.stderrIsTTY),
51
+ light,
52
+ );
53
+ io.stderr.write(formatError(error, paint, debugEnabled(io.env.DEBUG)));
54
+ return 2;
55
+ }
56
+ }
@@ -0,0 +1,119 @@
1
+ /**
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>`.
5
+ */
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';
9
+ import { totalsText } from './summary.js';
10
+ import { revealInvisible } from './width.js';
11
+
12
+ /** Hunks listed before "…and N more". */
13
+ export const MARKDOWN_HUNK_LIMIT = 100;
14
+ /** Groups start expanded when the whole list is at most this long. */
15
+ const OPEN_THRESHOLD = 30;
16
+
17
+ const FAMILY_SQUARE: Readonly<Record<ChangeFamily, string>> = {
18
+ added: '🟩',
19
+ removed: '🟥',
20
+ content: '🟨',
21
+ structure: '🟦',
22
+ identity: '🟪',
23
+ };
24
+
25
+ /** Zero-width space: invisible, but enough to stop GitHub's autolinkers and filters. */
26
+ const ZWSP = '\u200B';
27
+
28
+ /**
29
+ * Escape untrusted text for inline GFM.
30
+ *
31
+ * 1. Invisible and bidirectional controls become a visible `⟨U+202E⟩`; C0 controls and line
32
+ * breaks become spaces; C1 controls are dropped.
33
+ * 2. GitHub's post-Markdown filters are defused first (they act on the rendered text, so
34
+ * escaping alone does not stop them): a zero-width space breaks `:shortcode:` emoji,
35
+ * `@mentions`, `#123` / `org/repo#123` / `GH-123` references, bare `scheme://` URLs and
36
+ * `www.` autolinks.
37
+ * 3. Markdown punctuation (backslash, backtick, `*`, `_`, brackets, `|`, `~`, `#`, `$` for math)
38
+ * is backslash-escaped; `<`, `>` and `&` become entities, so no raw HTML gets through.
39
+ */
40
+ export function escapeMarkdown(text: string): string {
41
+ return (
42
+ revealInvisible(text)
43
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are replaced on purpose.
44
+ .replace(/[\u0000-\u001f\u2028\u2029]+/g, ' ')
45
+ .replace(/[\u007f-\u009f]/g, '')
46
+ .replace(/:([a-z0-9_+-]+):/gi, `:${ZWSP}$1:`)
47
+ .replace(/\b(GH)-(?=\d)/gi, `$1${ZWSP}-`)
48
+ .replace(/([@#])(?=[\p{L}\p{N}_])/gu, `$1${ZWSP}`)
49
+ .replace(/:\/\//g, `://${ZWSP}`)
50
+ .replace(/\b(www)\./gi, `$1${ZWSP}.`)
51
+ .replace(/[\\`*_[\]|~#$]/g, '\\$&')
52
+ .replace(/&/g, '&amp;')
53
+ .replace(/</g, '&lt;')
54
+ .replace(/>/g, '&gt;')
55
+ .trim()
56
+ );
57
+ }
58
+
59
+ export interface MarkdownOptions {
60
+ readonly locale: Locale;
61
+ readonly title: string;
62
+ /** e.g. `kgdiff 0.1.0`. */
63
+ readonly generator: string;
64
+ }
65
+
66
+ /** Render the Markdown summary. Deterministic: no timestamps. */
67
+ export function renderMarkdown(diff: DiffResult, options: MarkdownOptions): string {
68
+ const m = messages(options.locale);
69
+ const ov = overview(diff);
70
+ const out: string[] = [`## kgdiff · ${escapeMarkdown(options.title)}`, ''];
71
+ const totals = escapeMarkdown(totalsText(diff, m));
72
+
73
+ if (ov.hunks === 0) {
74
+ const detail = m.identicalDetail(diff.stats.after.nodes, diff.stats.after.edges);
75
+ out.push(`✅ **${m.identical}.** ${escapeMarkdown(detail)}`, '');
76
+ } else {
77
+ out.push(`**${escapeMarkdown(m.changes(ov.changes, ov.hunks))}** · ${totals}`, '');
78
+ // Table cells are sentence-cased (a no-op for Chinese).
79
+ const t = Object.fromEntries(
80
+ Object.entries(m.table).map(([k, v]) => [k, v.charAt(0).toUpperCase() + v.slice(1)]),
81
+ ) as typeof m.table;
82
+ const { nodes, edges, before, after } = diff.stats;
83
+ const pair = (b: number, a: number): string => `${formatInt(b)} → ${formatInt(a)}`;
84
+ out.push(
85
+ `| | ${t.added} | ${t.removed} | ${t.modified} | ${t.unchanged} | ${t.total} |`,
86
+ '| :-- | --: | --: | --: | --: | --: |',
87
+ `| **${t.nodes}** | ${formatInt(nodes.added)} | ${formatInt(nodes.removed)} | ${formatInt(nodes.modified)} | ${formatInt(nodes.unchanged)} | ${pair(before.nodes, after.nodes)} |`,
88
+ `| **${t.edges}** | ${formatInt(edges.added)} | ${formatInt(edges.removed)} | ${formatInt(edges.modified)} | ${formatInt(edges.unchanged)} | ${pair(before.edges, after.edges)} |`,
89
+ '',
90
+ m.markdown.legend,
91
+ '',
92
+ );
93
+ let budget = MARKDOWN_HUNK_LIMIT;
94
+ const open = ov.hunks <= OPEN_THRESHOLD ? ' open' : '';
95
+ for (const group of groupByPhase(diff.hunks)) {
96
+ if (budget <= 0) break;
97
+ const listed = group.hunks.slice(0, budget);
98
+ budget -= listed.length;
99
+ out.push(
100
+ `<details${open}>`,
101
+ `<summary>${m.phase[group.phase]} (${formatInt(group.hunks.length)})</summary>`,
102
+ '',
103
+ );
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
+ }
111
+ out.push('', '</details>', '');
112
+ }
113
+ if (ov.hunks > MARKDOWN_HUNK_LIMIT) {
114
+ out.push(`*${escapeMarkdown(m.markdown.more(ov.hunks - MARKDOWN_HUNK_LIMIT))}*`, '');
115
+ }
116
+ }
117
+ out.push(`*${escapeMarkdown(m.markdown.generatedBy(options.generator))}*`);
118
+ return `${out.join('\n')}\n`;
119
+ }