@kgdiff/cli 0.0.0-stage → 0.1.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 +198 -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 +135 -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 +204 -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 +201 -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 +264 -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/model.ts ADDED
@@ -0,0 +1,181 @@
1
+ /**
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.
4
+ */
5
+ import type { DiffResult, Hunk, HunkKind, ReplayPhase } from '@kgdiff/core';
6
+
7
+ /** The five meaning families of docs/visual-language.md (mirrors `@kgdiff/scene`). */
8
+ export type ChangeFamily = 'added' | 'removed' | 'content' | 'structure' | 'identity';
9
+
10
+ /**
11
+ * Family of each hunk kind. Identical to `hunkFamily` in `@kgdiff/scene` (a test enforces it);
12
+ * duplicated so text outputs do not load the scene and layout modules.
13
+ */
14
+ export const KIND_FAMILY: Readonly<Record<HunkKind, ChangeFamily>> = {
15
+ add: 'added',
16
+ remove: 'removed',
17
+ merge: 'identity',
18
+ split: 'identity',
19
+ relations: 'structure',
20
+ modify: 'content',
21
+ };
22
+
23
+ /**
24
+ * Family a hunk is shown with: its kind's family, except that a `modify` hunk made only of
25
+ * hierarchy moves (phase `restructure`) is `structure`, as its node is drawn
26
+ * (docs/visual-language.md: move → structure).
27
+ */
28
+ export function hunkFamily(hunk: Hunk): ChangeFamily {
29
+ if (hunk.kind === 'modify' && hunk.phase === 'restructure') return 'structure';
30
+ return KIND_FAMILY[hunk.kind];
31
+ }
32
+
33
+ /** One-character glyph of a hunk: `+ − ~ ⇄ ⊕ ⑂`. */
34
+ export function hunkGlyph(hunk: Hunk): string {
35
+ switch (hunk.kind) {
36
+ case 'add':
37
+ return '+';
38
+ case 'remove':
39
+ return '−';
40
+ case 'merge':
41
+ return '⊕';
42
+ case 'split':
43
+ return '⑂';
44
+ case 'relations':
45
+ return '⇄';
46
+ case 'modify':
47
+ return hunk.phase === 'restructure' ? '⇄' : '~';
48
+ default:
49
+ return assertNever(hunk.kind);
50
+ }
51
+ }
52
+
53
+ export type NameStatus = 'A' | 'D' | 'M' | 'R' | 'V' | 'G' | 'S' | 'E';
54
+
55
+ /**
56
+ * `--name-status` letter: `A` added, `D` removed, `M` modified, `R` renamed, `V` moved,
57
+ * `G` merged, `S` split, `E` relations. A modify hunk is `R` when its node was renamed, else `V`
58
+ * when it moved in the hierarchy, else `M`.
59
+ */
60
+ export function nameStatus(hunk: Hunk, diff: DiffResult): NameStatus {
61
+ switch (hunk.kind) {
62
+ case 'add':
63
+ return 'A';
64
+ case 'remove':
65
+ return 'D';
66
+ case 'merge':
67
+ return 'G';
68
+ case 'split':
69
+ return 'S';
70
+ case 'relations':
71
+ return 'E';
72
+ case 'modify': {
73
+ const facets = diff.index.node(hunk.anchor)?.facets ?? [];
74
+ if (facets.some((f) => f.kind === 'rename')) return 'R';
75
+ if (facets.some((f) => f.kind === 'move')) return 'V';
76
+ return 'M';
77
+ }
78
+ default:
79
+ return assertNever(hunk.kind);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Order of the phase groups in hunk lists: the replay order ("clean up, restructure, refine,
85
+ * grow"), shared with the viewer's change list and the Markdown summary.
86
+ */
87
+ export const LIST_PHASES: readonly ReplayPhase[] = ['remove', 'restructure', 'modify', 'add'];
88
+
89
+ /** Order of the counters and the diffstat bar segments. */
90
+ export const COUNTER_PHASES: readonly ReplayPhase[] = ['add', 'remove', 'modify', 'restructure'];
91
+
92
+ /** Counter glyph and family of each phase. */
93
+ export const PHASE_GLYPH: Readonly<Record<ReplayPhase, string>> = {
94
+ add: '+',
95
+ remove: '−',
96
+ modify: '~',
97
+ restructure: '⇄',
98
+ };
99
+
100
+ export const PHASE_FAMILY: Readonly<Record<ReplayPhase, ChangeFamily>> = {
101
+ add: 'added',
102
+ remove: 'removed',
103
+ modify: 'content',
104
+ restructure: 'structure',
105
+ };
106
+
107
+ export interface PhaseCount {
108
+ /** Hunks in the phase. */
109
+ readonly hunks: number;
110
+ /** Changed elements (nodes + edges) owned by those hunks. */
111
+ readonly changes: number;
112
+ }
113
+
114
+ export interface Overview {
115
+ readonly phases: Readonly<Record<ReplayPhase, PhaseCount>>;
116
+ /** Changed elements (every changed node and edge belongs to exactly one hunk). */
117
+ readonly changes: number;
118
+ readonly hunks: number;
119
+ }
120
+
121
+ /** Per-phase hunk and change counts. O(hunks). */
122
+ export function overview(diff: DiffResult): Overview {
123
+ const phases: Record<ReplayPhase, { hunks: number; changes: number }> = {
124
+ add: { hunks: 0, changes: 0 },
125
+ remove: { hunks: 0, changes: 0 },
126
+ modify: { hunks: 0, changes: 0 },
127
+ restructure: { hunks: 0, changes: 0 },
128
+ };
129
+ let changes = 0;
130
+ for (const h of diff.hunks) {
131
+ const n = h.nodes.length + h.edges.length;
132
+ phases[h.phase].hunks++;
133
+ phases[h.phase].changes += n;
134
+ changes += n;
135
+ }
136
+ return { phases, changes, hunks: diff.hunks.length };
137
+ }
138
+
139
+ /** Hunks in list order: grouped by `LIST_PHASES`, each group in review order. */
140
+ export function groupByPhase(hunks: readonly Hunk[]): { phase: ReplayPhase; hunks: Hunk[] }[] {
141
+ return LIST_PHASES.map((phase) => ({
142
+ phase,
143
+ hunks: hunks.filter((h) => h.phase === phase).sort((a, b) => a.order - b.order),
144
+ })).filter((g) => g.hunks.length > 0);
145
+ }
146
+
147
+ /**
148
+ * Split `total` cells among `weights` proportionally (largest remainder); every positive weight
149
+ * gets at least one cell when `total` allows. Deterministic; ties go to the earlier entry.
150
+ */
151
+ export function apportion(weights: readonly number[], total: number): number[] {
152
+ const sum = weights.reduce((a, b) => a + b, 0);
153
+ if (sum <= 0 || total <= 0) return weights.map(() => 0);
154
+ const exact = weights.map((w) => (w / sum) * total);
155
+ const cells = exact.map((x, i) => ((weights[i] as number) > 0 ? Math.max(1, Math.floor(x)) : 0));
156
+ let used = cells.reduce((a, b) => a + b, 0);
157
+ const byRemainder = exact
158
+ .map((x, i) => ({ i, r: x - Math.floor(x) }))
159
+ .sort((a, b) => b.r - a.r || a.i - b.i);
160
+ for (let k = 0; used < total && k < byRemainder.length * 2; k++) {
161
+ const { i } = byRemainder[k % byRemainder.length] as { i: number };
162
+ if ((weights[i] as number) > 0) {
163
+ cells[i] = (cells[i] as number) + 1;
164
+ used++;
165
+ }
166
+ }
167
+ // Minimum cells may overshoot a tiny total: take back from the largest segments.
168
+ while (used > total) {
169
+ let largest = 0;
170
+ for (let i = 1; i < cells.length; i++) {
171
+ if ((cells[i] as number) > (cells[largest] as number)) largest = i;
172
+ }
173
+ cells[largest] = (cells[largest] as number) - 1;
174
+ used--;
175
+ }
176
+ return cells;
177
+ }
178
+
179
+ export function assertNever(value: never): never {
180
+ throw new Error(`Unexpected value: ${String(value)}`);
181
+ }
package/src/node-io.ts ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The real `CliIO`: process streams, the file system, the platform opener and the viewer
3
+ * bundles resolved from `@kgdiff/viewer/standalone` and `@kgdiff/viewer/standalone/compute`.
4
+ */
5
+ import { spawn } from 'node:child_process';
6
+ import { randomBytes } from 'node:crypto';
7
+ import { chmod, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
8
+ import { createRequire } from 'node:module';
9
+ import { tmpdir } from 'node:os';
10
+ import { basename, dirname, join } from 'node:path';
11
+ import { CliError, systemErrorCode } from './errors.js';
12
+ import type { CliFileSystem, CliIO, ViewerBundle } from './io.js';
13
+
14
+ async function readStdin(maxBytes: number): Promise<Uint8Array> {
15
+ const chunks: Buffer[] = [];
16
+ let size = 0;
17
+ for await (const chunk of process.stdin) {
18
+ const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : (chunk as Buffer);
19
+ size += buffer.byteLength;
20
+ if (size > maxBytes) {
21
+ process.stdin.destroy();
22
+ throw new RangeError(`stdin is larger than ${maxBytes} bytes`);
23
+ }
24
+ chunks.push(buffer);
25
+ }
26
+ return Buffer.concat(chunks);
27
+ }
28
+
29
+ /**
30
+ * Atomic replace: write `.<name>.<random>.tmp` next to `path` (exclusive create), copy the
31
+ * permissions of an existing file, then rename over `path`. The temporary file is removed on
32
+ * failure.
33
+ */
34
+ async function writeFileAtomic(path: string, data: string | Uint8Array): Promise<void> {
35
+ const temp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString('hex')}.tmp`);
36
+ try {
37
+ await writeFile(temp, data, { flag: 'wx', mode: 0o666 });
38
+ const existing = await stat(path).catch(() => undefined);
39
+ if (existing) await chmod(temp, existing.mode & 0o7777);
40
+ await rename(temp, path);
41
+ } catch (error) {
42
+ await rm(temp, { force: true }).catch(() => undefined);
43
+ throw error;
44
+ }
45
+ }
46
+
47
+ /** The real file system (exported for tests that exercise links and case variants). */
48
+ export function nodeFileSystem(): CliFileSystem {
49
+ return {
50
+ readFile: (path) => readFile(path),
51
+ writeFile: writeFileAtomic,
52
+ stat: async (path) => {
53
+ try {
54
+ const s = await stat(path);
55
+ return { dev: s.dev, ino: s.ino, size: s.size };
56
+ } catch (error) {
57
+ if (systemErrorCode(error) === 'ENOENT' || systemErrorCode(error) === 'ENOTDIR') {
58
+ return undefined;
59
+ }
60
+ throw error;
61
+ }
62
+ },
63
+ realpath: (path) => realpath(path),
64
+ };
65
+ }
66
+
67
+ /** Command that opens a file with the default application on this platform. */
68
+ export function openCommand(
69
+ platform: NodeJS.Platform,
70
+ path: string,
71
+ ): { command: string; args: string[]; verbatim: boolean } {
72
+ if (platform === 'darwin') return { command: 'open', args: [path], verbatim: false };
73
+ if (platform === 'win32') {
74
+ // `start` takes its first quoted argument as a window title, hence the empty "". With /s,
75
+ // cmd strips the outer quotes and runs the rest verbatim.
76
+ return { command: 'cmd', args: ['/d', '/s', '/c', `"start "" "${path}""`], verbatim: true };
77
+ }
78
+ return { command: 'xdg-open', args: [path], verbatim: false };
79
+ }
80
+
81
+ function openFile(path: string): Promise<void> {
82
+ const { command, args, verbatim } = openCommand(process.platform, path);
83
+ return new Promise((resolve, reject) => {
84
+ const child = spawn(command, args, {
85
+ stdio: 'ignore',
86
+ detached: true,
87
+ windowsHide: true,
88
+ windowsVerbatimArguments: verbatim,
89
+ });
90
+ child.once('error', reject);
91
+ child.once('spawn', () => {
92
+ child.unref();
93
+ resolve();
94
+ });
95
+ });
96
+ }
97
+
98
+ const VIEWER_SPECIFIERS: Readonly<Record<ViewerBundle, string>> = {
99
+ main: '@kgdiff/viewer/standalone',
100
+ compute: '@kgdiff/viewer/standalone/compute',
101
+ };
102
+
103
+ async function loadViewerBundle(part: ViewerBundle): Promise<string> {
104
+ const specifier = VIEWER_SPECIFIERS[part];
105
+ const what = part === 'main' ? 'standalone viewer bundle' : 'viewer compute add-on';
106
+ const hint =
107
+ 'Build it with `pnpm --filter @kgdiff/viewer build` (development) or reinstall kgdiff.';
108
+ let path: string;
109
+ try {
110
+ path = createRequire(import.meta.url).resolve(specifier);
111
+ } catch (error) {
112
+ throw new CliError(
113
+ 'viewer-missing',
114
+ `The HTML report needs the ${what} (${specifier}), which was not found.`,
115
+ hint,
116
+ { cause: error },
117
+ );
118
+ }
119
+ try {
120
+ return await readFile(path, 'utf8');
121
+ } catch (error) {
122
+ throw new CliError('viewer-missing', `Cannot read the ${what} at ${path}.`, hint, {
123
+ cause: error,
124
+ });
125
+ }
126
+ }
127
+
128
+ /** `CliIO` bound to the current process. */
129
+ export function nodeIO(): CliIO {
130
+ return {
131
+ stdout: process.stdout,
132
+ stderr: process.stderr,
133
+ readStdin,
134
+ fs: nodeFileSystem(),
135
+ env: process.env,
136
+ cwd: process.cwd(),
137
+ isTTY: process.stdout.isTTY === true,
138
+ stderrIsTTY: process.stderr.isTTY === true,
139
+ stdinIsTTY: process.stdin.isTTY === true,
140
+ columns: process.stdout.isTTY ? process.stdout.columns : undefined,
141
+ now: () => new Date(),
142
+ makeTempDir: () => mkdtemp(join(tmpdir(), 'kgdiff-')),
143
+ openFile,
144
+ loadViewerBundle,
145
+ };
146
+ }
package/src/output.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Shared output plumbing of the commands: painters for stdout / stderr, the resolved locale,
3
+ * `--quiet`, and writing output files with a short notice on stderr.
4
+ */
5
+ import type { Locale } from '@kgdiff/core';
6
+ import type { CommonOptions } from './args.js';
7
+ import { CliError, systemErrorCode, systemReason } from './errors.js';
8
+ import { resolvePath } from './inputs.js';
9
+ import type { CliIO } from './io.js';
10
+ import { resolveLocale } from './locale.js';
11
+ import { createPainter, isLightBackground, type Painter, resolveColorLevel } from './style.js';
12
+ import { sanitize } from './width.js';
13
+
14
+ export interface OutputContext {
15
+ readonly io: CliIO;
16
+ readonly locale: Locale;
17
+ /** Painter for stdout. */
18
+ readonly paint: Painter;
19
+ /** Painter for stderr. */
20
+ readonly errPaint: Painter;
21
+ readonly quiet: boolean;
22
+ /** Write to stdout unless `--quiet`. */
23
+ stdout(text: string): void;
24
+ /** Write a notice or warning to stderr unless `--quiet`. */
25
+ notice(text: string): void;
26
+ }
27
+
28
+ export function outputContext(io: CliIO, options: CommonOptions): OutputContext {
29
+ const light = isLightBackground(io.env);
30
+ return {
31
+ io,
32
+ locale: resolveLocale(options.locale, io.env),
33
+ paint: createPainter(resolveColorLevel(options.color, io.env, io.isTTY), light),
34
+ errPaint: createPainter(resolveColorLevel(options.color, io.env, io.stderrIsTTY), light),
35
+ quiet: options.quiet,
36
+ stdout: (text) => {
37
+ if (!options.quiet && text) io.stdout.write(text);
38
+ },
39
+ notice: (text) => {
40
+ if (!options.quiet) io.stderr.write(text);
41
+ },
42
+ };
43
+ }
44
+
45
+ /** `1.2 MB`, `512 kB`, `90 B` (decimal units, like most file managers). */
46
+ export function formatSize(bytes: number): string {
47
+ if (bytes < 1000) return `${bytes} B`;
48
+ if (bytes < 1_000_000) return `${(bytes / 1000).toFixed(bytes < 10_000 ? 1 : 0)} kB`;
49
+ return `${(bytes / 1_000_000).toFixed(1)} MB`;
50
+ }
51
+
52
+ const WRITE_HINTS: Readonly<Record<string, string>> = {
53
+ ENOENT: 'Create the directory first.',
54
+ EISDIR: 'Pass a file name, not a directory.',
55
+ EACCES: 'Check the permissions of the directory.',
56
+ };
57
+
58
+ /** Write an output file and report it on stderr (`✓ Wrote report.html (1.2 MB)`). */
59
+ export async function writeOutputFile(
60
+ ctx: OutputContext,
61
+ path: string,
62
+ content: string,
63
+ label: string,
64
+ ): Promise<void> {
65
+ const data = new TextEncoder().encode(content);
66
+ try {
67
+ await ctx.io.fs.writeFile(resolvePath(ctx.io, path), data);
68
+ } catch (error) {
69
+ const code = systemErrorCode(error);
70
+ throw new CliError(
71
+ 'output',
72
+ `Cannot write ${label} to "${sanitize(path)}": ${systemReason(error)}.`,
73
+ (code && WRITE_HINTS[code]) || undefined,
74
+ { cause: error },
75
+ );
76
+ }
77
+ const p = ctx.errPaint;
78
+ ctx.notice(
79
+ `${p.tone('added', '✓')} Wrote ${label} ${p.bold(sanitize(path))} ${p.dim(`(${formatSize(data.byteLength)})`)}\n`,
80
+ );
81
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Outputs never overwrite inputs, and two outputs never share a file — under any spelling:
3
+ * case variants on case-insensitive file systems, symlinks and hard links all resolve to the
4
+ * same (device, inode) pair; files that do not exist yet compare by canonical directory and
5
+ * case-folded name.
6
+ */
7
+ import { basename, dirname, join } from 'node:path';
8
+ import { STDIO } from './args.js';
9
+ import { CliError } from './errors.js';
10
+ import { resolvePath } from './inputs.js';
11
+ import type { CliIO } from './io.js';
12
+ import { sanitize } from './width.js';
13
+
14
+ export interface PathRole {
15
+ /** How the path was given, for messages (`<before>`, `--json`, `-o`). */
16
+ readonly role: string;
17
+ /** Path as given; `-` (stdin / stdout) and `undefined` are ignored. */
18
+ readonly path: string | undefined;
19
+ }
20
+
21
+ /** Identity of the file at `path`: `inode:dev:ino` when it exists, else a canonical path. */
22
+ export async function fileIdentity(io: CliIO, path: string): Promise<string> {
23
+ const absolute = resolvePath(io, path);
24
+ const stat = await io.fs.stat(absolute);
25
+ if (stat) return `inode:${stat.dev}:${stat.ino}`;
26
+ let directory = dirname(absolute);
27
+ try {
28
+ directory = await io.fs.realpath(directory);
29
+ } catch {
30
+ // A missing directory makes the write fail later with its own message.
31
+ }
32
+ // Case-folded: on case-insensitive file systems `A.json` and `a.json` are one file. On
33
+ // case-sensitive ones this can only refuse two outputs that differ in case alone.
34
+ return `path:${join(directory, basename(absolute)).toLowerCase()}`;
35
+ }
36
+
37
+ /**
38
+ * @throws CliError `usage` when an output is one of the inputs or two outputs are one file.
39
+ */
40
+ export async function checkNoOverwrite(
41
+ io: CliIO,
42
+ inputs: readonly PathRole[],
43
+ outputs: readonly PathRole[],
44
+ ): Promise<void> {
45
+ const used = (p: PathRole): p is PathRole & { path: string } =>
46
+ p.path !== undefined && p.path !== STDIO;
47
+ const inputIds = new Map<string, PathRole>();
48
+ for (const input of inputs.filter(used)) {
49
+ // Missing inputs are reported when they are read.
50
+ if (await io.fs.stat(resolvePath(io, input.path))) {
51
+ inputIds.set(await fileIdentity(io, input.path), input);
52
+ }
53
+ }
54
+ const outputIds = new Map<string, PathRole>();
55
+ for (const output of outputs.filter(used)) {
56
+ const id = await fileIdentity(io, output.path);
57
+ const input = inputIds.get(id);
58
+ if (input) {
59
+ throw new CliError(
60
+ 'usage',
61
+ `${output.role} "${sanitize(output.path)}" is the input ${input.role} "${sanitize(input.path as string)}"; kgdiff never overwrites its inputs.`,
62
+ 'Write to another file, then move it into place if you want to replace the input.',
63
+ );
64
+ }
65
+ const other = outputIds.get(id);
66
+ if (other) {
67
+ throw new CliError(
68
+ 'usage',
69
+ `${other.role} and ${output.role} both write to "${sanitize(output.path)}".`,
70
+ 'Give each output its own file.',
71
+ );
72
+ }
73
+ outputIds.set(id, output);
74
+ }
75
+ }
package/src/report.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Build the `ReportPayload` embedded in HTML reports: diff (`diffToJSON`) + layout
3
+ * (`layoutToJSON`) + meta + initial view preferences. `@kgdiff/layout` and `node:zlib` are
4
+ * loaded on demand, so text outputs never pay for them.
5
+ */
6
+ import { type DiffResult, diffToJSON, type Locale } from '@kgdiff/core';
7
+ import type { ReportPayload, ThemePreference, ViewerMode } from '@kgdiff/viewer';
8
+ import { renderReportHtml } from './html.js';
9
+
10
+ export interface ReportInfo {
11
+ readonly title: string;
12
+ /** e.g. `kgdiff 0.1.0`. */
13
+ readonly generator: string;
14
+ /** ISO timestamp; omitted for reproducible reports. */
15
+ readonly generatedAt?: string;
16
+ readonly before: { readonly name: string; readonly format?: string };
17
+ readonly after: { readonly name: string; readonly format?: string };
18
+ readonly view: {
19
+ readonly mode?: ViewerMode;
20
+ readonly theme?: ThemePreference;
21
+ readonly locale: Locale;
22
+ };
23
+ }
24
+
25
+ /** Lay out the diff and assemble the payload. Deterministic apart from `generatedAt`. */
26
+ export async function buildReportPayload(
27
+ diff: DiffResult,
28
+ info: ReportInfo,
29
+ ): Promise<ReportPayload> {
30
+ const { layoutDiff, layoutToJSON } = await import('@kgdiff/layout');
31
+ const layout = layoutToJSON(layoutDiff(diff));
32
+ const { mode, theme, locale } = info.view;
33
+ return {
34
+ format: 'kgdiff.report',
35
+ version: 1,
36
+ diff: diffToJSON(diff),
37
+ layout,
38
+ meta: {
39
+ title: info.title,
40
+ generator: info.generator,
41
+ ...(info.generatedAt !== undefined ? { generatedAt: info.generatedAt } : {}),
42
+ before: info.before,
43
+ after: info.after,
44
+ },
45
+ view: {
46
+ ...(mode !== undefined ? { mode } : {}),
47
+ ...(theme !== undefined ? { theme } : {}),
48
+ locale,
49
+ },
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Deterministic gzip (level 9). The header's OS byte is normalized to 255 ("unknown") so the
55
+ * bytes do not depend on the platform the report was generated on.
56
+ */
57
+ export async function createGzip(): Promise<(data: Uint8Array) => Uint8Array> {
58
+ const { gzipSync, constants } = await import('node:zlib');
59
+ return (data) => {
60
+ const out = gzipSync(data, { level: constants.Z_BEST_COMPRESSION });
61
+ out[9] = 0xff;
62
+ return out;
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Whether a report should inline the compute add-on: only when the layout covers just the
68
+ * changes (large graphs), where the viewer may need to lay out collapsed groups the user
69
+ * expands. Full layouts need no browser-side computation, so small reports stay small.
70
+ */
71
+ export function needsCompute(payload: ReportPayload): boolean {
72
+ return payload.layout.scope === 'changes';
73
+ }
74
+
75
+ export interface ReportBundles {
76
+ /** The standalone viewer bundle. */
77
+ readonly main: string;
78
+ /**
79
+ * Loads the compute add-on; called only when `needsCompute`. Returning `undefined` (e.g.
80
+ * `--no-compute`) leaves it out.
81
+ */
82
+ readonly compute: () => Promise<string | undefined>;
83
+ }
84
+
85
+ /** Full HTML document for a diff. */
86
+ export async function buildReportHtml(
87
+ diff: DiffResult,
88
+ info: ReportInfo,
89
+ bundles: ReportBundles,
90
+ description: string,
91
+ ): Promise<string> {
92
+ const payload = await buildReportPayload(diff, info);
93
+ const compute = needsCompute(payload) ? await bundles.compute() : undefined;
94
+ return renderReportHtml({
95
+ payload,
96
+ bundle: bundles.main,
97
+ ...(compute !== undefined ? { compute } : {}),
98
+ description,
99
+ gzip: await createGzip(),
100
+ });
101
+ }