@opum-ai/lore 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 (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +306 -0
  3. package/bin/lore.cjs +109 -0
  4. package/package.json +67 -0
  5. package/src/adapters/backlog.ts +1084 -0
  6. package/src/adapters/git.ts +221 -0
  7. package/src/cli.ts +667 -0
  8. package/src/commands/agent.ts +301 -0
  9. package/src/commands/agents.ts +302 -0
  10. package/src/commands/args.ts +209 -0
  11. package/src/commands/changed.ts +70 -0
  12. package/src/commands/check.ts +1031 -0
  13. package/src/commands/codex-bridge.ts +49 -0
  14. package/src/commands/concurrency.ts +48 -0
  15. package/src/commands/context.ts +292 -0
  16. package/src/commands/discover.ts +89 -0
  17. package/src/commands/explorer.ts +253 -0
  18. package/src/commands/export.ts +93 -0
  19. package/src/commands/fswrite.ts +928 -0
  20. package/src/commands/graph.ts +291 -0
  21. package/src/commands/help.ts +151 -0
  22. package/src/commands/impact.ts +59 -0
  23. package/src/commands/init.ts +583 -0
  24. package/src/commands/instructions.ts +91 -0
  25. package/src/commands/link.ts +929 -0
  26. package/src/commands/new.ts +476 -0
  27. package/src/commands/orphans.ts +457 -0
  28. package/src/commands/path.ts +67 -0
  29. package/src/commands/provenance.ts +68 -0
  30. package/src/commands/query.ts +312 -0
  31. package/src/commands/reconcile-shared.ts +280 -0
  32. package/src/commands/rename.ts +585 -0
  33. package/src/commands/replace.ts +320 -0
  34. package/src/commands/scaffold.ts +346 -0
  35. package/src/commands/schema.ts +293 -0
  36. package/src/commands/snapshot.ts +130 -0
  37. package/src/commands/supersede.ts +400 -0
  38. package/src/commands/sync.ts +371 -0
  39. package/src/commands/tasks.ts +271 -0
  40. package/src/commands/traversal.ts +151 -0
  41. package/src/commands/validate.ts +226 -0
  42. package/src/config.ts +598 -0
  43. package/src/core/agent-bridge.ts +287 -0
  44. package/src/core/agent-context.ts +498 -0
  45. package/src/core/agent-profile.ts +447 -0
  46. package/src/core/bundle.ts +893 -0
  47. package/src/core/check.ts +853 -0
  48. package/src/core/codex-bridge.ts +100 -0
  49. package/src/core/concept.ts +597 -0
  50. package/src/core/consumer-scaffold.ts +433 -0
  51. package/src/core/context.ts +271 -0
  52. package/src/core/explorer-contract.ts +441 -0
  53. package/src/core/explorer-qualification.ts +58 -0
  54. package/src/core/explorer.ts +518 -0
  55. package/src/core/finding.ts +31 -0
  56. package/src/core/graph.ts +201 -0
  57. package/src/core/indexes.ts +436 -0
  58. package/src/core/instructions.ts +209 -0
  59. package/src/core/ladybug-driver.ts +1795 -0
  60. package/src/core/ladybug-lifecycle.ts +1178 -0
  61. package/src/core/ladybug-native.ts +95 -0
  62. package/src/core/ladybug-source.ts +667 -0
  63. package/src/core/links.ts +681 -0
  64. package/src/core/log.ts +253 -0
  65. package/src/core/managed-block.ts +540 -0
  66. package/src/core/manifest.ts +718 -0
  67. package/src/core/order.ts +13 -0
  68. package/src/core/profile.ts +1007 -0
  69. package/src/core/projection.ts +195 -0
  70. package/src/core/query.ts +542 -0
  71. package/src/core/reconcile.ts +236 -0
  72. package/src/core/replace.ts +419 -0
  73. package/src/core/retrieval.ts +213 -0
  74. package/src/core/rewrite.ts +940 -0
  75. package/src/core/scaffold.ts +255 -0
  76. package/src/core/schema.ts +366 -0
  77. package/src/core/snapshot-runtime.ts +52 -0
  78. package/src/core/snapshot-store.ts +287 -0
  79. package/src/core/snapshot.ts +711 -0
  80. package/src/core/template.ts +429 -0
  81. package/src/core/traversal.ts +487 -0
  82. package/src/core/validate.ts +517 -0
  83. package/src/core/workspace-contract.ts +473 -0
  84. package/src/core/workspace-projection.ts +365 -0
  85. package/src/core/workspace-retrieval.ts +196 -0
  86. package/src/core/workspace-source.ts +174 -0
  87. package/src/errors.ts +697 -0
  88. package/src/meta.ts +7 -0
  89. package/src/output.ts +589 -0
  90. package/src/scripts/upstream-backlog-watch.ts +288 -0
  91. package/src/state.ts +390 -0
@@ -0,0 +1,400 @@
1
+ /**
2
+ * commands/supersede.ts — `lore supersede <oldId> <newId> [--rewrite-links] [--dry-run]`.
3
+ *
4
+ * The thin, side-effecting layer that records a supersession relationship between two existing
5
+ * concepts (cli-surface §supersede, the third of LORE-35's refactoring commands; LORE-35 AC: the
6
+ * supersede half). Unlike {@link runRename}, it **preserves the old file** as history — nothing
7
+ * moves or is deleted — and only edits frontmatter:
8
+ *
9
+ * - on the **old** concept: `status: superseded` + `superseded_by: <newId>` (the successor, bare id);
10
+ * - on the **new** concept: `supersedes: <oldId>`, **appended** to any existing entry (a concept may
11
+ * supersede several) rather than clobbering it.
12
+ *
13
+ * Both writes go through {@link serializeConcept} under the **active profile** (so the `status` value
14
+ * is validated against the project's own profile, not just the default — a custom `status` enum that
15
+ * forbids `superseded` fails fast here rather than slipping through to break the next `lore validate`),
16
+ * in canonical key order with the frozen YAML config, so an already-canonical concept's other
17
+ * frontmatter and its whole body round-trip byte-for-byte and the wiring is the only diff (ADR-0011).
18
+ *
19
+ * With `--rewrite-links` it additionally repoints **inbound body links** to the successor via the
20
+ * shared pure {@link rewriteInbound} engine in place-only (`move:false`) mode, with two restrictions
21
+ * that distinguish supersede from rename (whose machinery it reuses):
22
+ *
23
+ * - `rewriteFrontmatterRefs:false` — because the old file is **preserved**, a third party's
24
+ * `supersedes`/`superseded_by`/`specs` ref to it remains a true historical record; repointing it
25
+ * would fabricate a relationship that never happened. Only navigational body links are redirected.
26
+ * - `exclude` the two **principals** and the machine-owned `index.md`/`log.md` hubs — the old doc's
27
+ * own (historical) body links and the new doc's legitimate links *to* its predecessor must stay
28
+ * intact (else the successor links to itself), and a generated hub is never hand-rewritten (its
29
+ * file listing is unchanged, since supersede moves nothing).
30
+ *
31
+ * Every retargeted link is still repointed to the successor — but when its visible text still names
32
+ * the OLD id (e.g. `[ADR-0005](…)`, now pointing at ADR-0006 — a supersession doc frequently cites
33
+ * its predecessor by name to explain the change), that would silently leave the prose and the link
34
+ * disagreeing. The engine flags each such {@link LinkTextMismatch} in the plan; this command renders
35
+ * one stderr `warning:` line per mismatch via {@link renderLinkTextMismatchWarning} (LORE-262) — the
36
+ * retarget and the exit code are unaffected, so this is purely advisory.
37
+ *
38
+ * Validation lives here, because the engine's `move:false` path checks only that `oldId` exists (its
39
+ * conflict guard is move-only): both ids must name concepts (`not_found`, exit 3); neither may be a
40
+ * reserved hub name (`usage`, exit 2); and the old concept must not already be superseded —
41
+ * `status: superseded` (any case) **or** an already-recorded `superseded_by` — which would otherwise
42
+ * be silently overwritten (`conflict`, exit 5). All file I/O is here
43
+ * ({@link writeFileOverwriting}, overwrite in place); every link/ref judgement stays pure in
44
+ * `core/rewrite.ts`.
45
+ */
46
+
47
+ import { join, posix } from "node:path";
48
+ import { conceptNotInBundle, loadBundle, resolveRef, UNREADABLE_DIRECTORY_WARNING } from "../core/bundle";
49
+ import { type Concept, idFromPath, serializeConcept } from "../core/concept";
50
+ import { loadProfile } from "../core/profile";
51
+ import { renderLinkTextMismatchWarning, rewriteInbound } from "../core/rewrite";
52
+ import { DOCS_DIR, RESERVED_STEMS } from "../core/scaffold";
53
+ import { EXIT_OK, LoreError, WarningCollector, type Writer } from "../errors";
54
+ import { emit, type OutputContext, type Renderable } from "../output";
55
+ import { assertNotReservedStem, parseCommandArgs, usage } from "./args";
56
+ import { writeFileOverwriting } from "./fswrite";
57
+
58
+ /** The frontmatter `status` value that marks a concept superseded — the lifecycle signal we set and detect. */
59
+ const SUPERSEDED_STATUS = "superseded";
60
+
61
+ /** Options for {@link runSupersede}; `root` and the streams are injectable for tests. */
62
+ export interface SupersedeOptions {
63
+ /** The repo root the `docs/` bundle resolves against. */
64
+ root: string;
65
+ /** The resolved output mode/color (from `output.ts`). */
66
+ output: OutputContext;
67
+ /** The command's normalized positional + flag tokens from Commander. */
68
+ args: readonly string[];
69
+ /** stdout sink; defaults to `process.stdout`. */
70
+ stdout?: Writer;
71
+ /** stderr sink for bundle-load advisories; defaults to `process.stderr`. */
72
+ stderr?: Writer;
73
+ }
74
+
75
+ /** The parsed form of `lore supersede`'s arguments. */
76
+ interface SupersedeArgs {
77
+ /** The concept id (or path) being superseded. */
78
+ oldId: string;
79
+ /** The successor concept id (or path). */
80
+ newId: string;
81
+ /** `--rewrite-links`: also repoint inbound body links to the successor. */
82
+ rewriteLinks: boolean;
83
+ /** `--dry-run`: report what would change, write nothing. */
84
+ dryRun: boolean;
85
+ }
86
+
87
+ /** One written file, for the report. */
88
+ interface ChangedFile {
89
+ /** Repo-relative POSIX path of the written file. */
90
+ readonly path: string;
91
+ }
92
+
93
+ /** The `supersede.result` payload: the wired relationship and every file written. */
94
+ export interface SupersedeReport {
95
+ /** The superseded concept's repo-relative path. */
96
+ readonly old: string;
97
+ /** The successor concept's repo-relative path. */
98
+ readonly new: string;
99
+ /** Every file written (the principals that changed, plus any repointed inbound files), ascending. */
100
+ readonly files: readonly ChangedFile[];
101
+ /** How many files changed (== `files.length`). */
102
+ readonly filesChanged: number;
103
+ /** Whether any inbound body link was actually repointed (not merely whether `--rewrite-links` was passed). */
104
+ readonly rewroteLinks: boolean;
105
+ /** Whether this was a `--dry-run` (nothing was written). */
106
+ readonly dryRun: boolean;
107
+ }
108
+
109
+ /**
110
+ * Run `lore supersede`: parse the arguments, load the bundle and active profile, validate both
111
+ * concepts exist and the old one is not already superseded, wire the supersession frontmatter both
112
+ * ways, optionally repoint inbound body links to the successor, write the changed files (unless
113
+ * `--dry-run`), emit the `supersede.result`, and return `0`. A bad flag / self-supersede / reserved
114
+ * id throws a `usage` {@link LoreError} (exit `2`); a missing id a `not_found` (exit `3`); an
115
+ * already-superseded old id a `conflict` (exit `5`).
116
+ */
117
+ export function runSupersede(options: SupersedeOptions): number {
118
+ const parsed = parseSupersedeArgs(options.args);
119
+ const oldId = idFromPath(parsed.oldId);
120
+ const newId = idFromPath(parsed.newId);
121
+ if (oldId === newId) {
122
+ throw new LoreError("usage", "a concept cannot supersede itself", "pass a different successor id", {
123
+ id: oldId,
124
+ });
125
+ }
126
+ assertNotReservedStem(oldId, "supersede");
127
+ assertNotReservedStem(newId, "supersede");
128
+
129
+ const docsRoot = join(options.root, DOCS_DIR);
130
+ const advisories = new WarningCollector();
131
+ const profile = loadProfile({ root: options.root });
132
+ const graph = loadBundle(docsRoot, { warnings: advisories, profile });
133
+ // Flushed immediately (not at the end, as this command previously did) so a skipped-directory
134
+ // warning naming the exact path/reason survives on the fail-loud `--rewrite-links` path below it
135
+ // feeds (LORE-82), mirroring how `context.ts`/`graph.ts` flush before a load-warning-explained
136
+ // not_found throw.
137
+ advisories.flush({ color: options.output.color, stderr: options.stderr });
138
+
139
+ // The command owns all validation: the engine's `move:false` path checks only that `oldId` exists.
140
+ const oldConcept = graph.concepts.get(oldId);
141
+ if (oldConcept === undefined) {
142
+ throw conceptNotInBundle(oldId);
143
+ }
144
+ const newConcept = graph.concepts.get(newId);
145
+ if (newConcept === undefined) {
146
+ throw conceptNotInBundle(newId);
147
+ }
148
+ assertNotAlreadySuperseded(oldConcept);
149
+
150
+ // Optionally repoint inbound body links to the successor. The principals and the machine-owned
151
+ // hubs are excluded (the engine never even parses them), and frontmatter refs are left intact —
152
+ // the old file is preserved, so a ref to it is valid history, not a dead pointer.
153
+ const writes = new Map<string, string>();
154
+ let rewroteLinks = false;
155
+ if (parsed.rewriteLinks) {
156
+ // rewriteInbound can only repoint the inbound links it can SEE — a directory `loadBundle` had
157
+ // to skip (unreadable) may hide a concept that links to `oldId`, so committing this rewrite
158
+ // would silently report success while leaving that concept's link stale/broken. Refuse rather
159
+ // than guess: the graph is not the complete bundle, so no rewrite over it is safe to commit
160
+ // (LORE-82). Gated on `--rewrite-links` specifically — without it, supersede only edits the two
161
+ // principals' own frontmatter, which has no dependency on the rest of the bundle being visible.
162
+ if (advisories.has(UNREADABLE_DIRECTORY_WARNING)) {
163
+ throw new LoreError(
164
+ "validation",
165
+ "the bundle graph is incomplete: an unreadable directory was skipped while loading it",
166
+ "fix filesystem permissions on the directory named in the warning above and retry — --rewrite-links cannot safely repoint inbound links without a complete view of the bundle",
167
+ { docsRoot },
168
+ );
169
+ }
170
+ const plan = rewriteInbound(graph, oldId, newId, {
171
+ move: false,
172
+ rewriteFrontmatterRefs: false,
173
+ exclude: excludedFromRewrite(graph, oldConcept.path, newConcept.path),
174
+ profile,
175
+ });
176
+ for (const w of plan.writes) {
177
+ writes.set(w.path, w.bytes);
178
+ }
179
+ rewroteLinks = writes.size > 0;
180
+
181
+ // Every retargeted inbound link is still repointed exactly as before (LORE-262 AC#2 — no
182
+ // regression); a link whose visible text still names the OLD id is additionally called out as a
183
+ // stderr warning so the author can review the prose, rather than the mismatch shipping silently
184
+ // (LORE-262 AC#1). A FRESH collector, not `advisories` — that one was already flushed above
185
+ // (LORE-82's ordering), and `flush()` is non-draining, so reusing it here would re-print the
186
+ // earlier bundle-load warnings a second time.
187
+ if (plan.textMismatches.length > 0) {
188
+ const mismatchWarnings = new WarningCollector();
189
+ for (const mismatch of plan.textMismatches) {
190
+ mismatchWarnings.add(renderLinkTextMismatchWarning(mismatch));
191
+ }
192
+ mismatchWarnings.flush({ color: options.output.color, stderr: options.stderr });
193
+ }
194
+ }
195
+
196
+ // Wire the principals' frontmatter (cloned, never mutating the graph snapshot) and serialize under
197
+ // the active profile. The old doc always changes (it gains the lifecycle keys); the new doc is
198
+ // written only when its `supersedes` actually changes, so a no-op append is not reported as a write.
199
+ writes.set(oldConcept.path, serializeConcept(wireOld(oldConcept, newId), { profile }));
200
+ const wiredNew = wireNew(newConcept, oldId, graph);
201
+ if (wiredNew !== null) {
202
+ writes.set(newConcept.path, serializeConcept(wiredNew, { profile }));
203
+ }
204
+
205
+ const sorted = new Map([...writes].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)));
206
+
207
+ if (!parsed.dryRun) {
208
+ for (const [path, bytes] of sorted) {
209
+ writeFileOverwriting(join(docsRoot, path), bytes, `${DOCS_DIR}/${path}`);
210
+ }
211
+ }
212
+
213
+ const report = buildReport(oldConcept, newConcept, sorted, { rewroteLinks, dryRun: parsed.dryRun });
214
+ emit(reportRenderable(report), options.output, options.stdout);
215
+ // Load advisories were already flushed right after loadBundle (LORE-82), not repeated here
216
+ // (flush is non-draining — a second call would re-print the same warnings).
217
+ return EXIT_OK;
218
+ }
219
+
220
+ /**
221
+ * The concept ids `--rewrite-links` must never touch: the two principals (the command wires their
222
+ * frontmatter itself) and every machine-owned `index.md`/`log.md` hub in the bundle (regenerated by
223
+ * `lore sync`, not hand-rewritten — and since supersede moves nothing, their listings are unchanged).
224
+ */
225
+ function excludedFromRewrite(
226
+ graph: { concepts: ReadonlyMap<string, Concept> },
227
+ oldPath: string,
228
+ newPath: string,
229
+ ): ReadonlySet<string> {
230
+ const ids = new Set<string>([idFromPath(oldPath), idFromPath(newPath)]);
231
+ for (const id of graph.concepts.keys()) {
232
+ if (RESERVED_STEMS.has(posix.basename(id))) {
233
+ ids.add(id);
234
+ }
235
+ }
236
+ return ids;
237
+ }
238
+
239
+ // ── Frontmatter wiring ─────────────────────────────────────────────────────────
240
+
241
+ /**
242
+ * The old concept with its supersession frontmatter set: `status: superseded` and
243
+ * `superseded_by: <newId>` (the bare-id successor, lore's canonical ref form). Returns a clone — the
244
+ * graph snapshot is never mutated. The body is carried over verbatim, so re-serializing the
245
+ * already-canonical concept changes only these two keys. Safe to overwrite `superseded_by`
246
+ * unconditionally because {@link assertNotAlreadySuperseded} has already rejected a concept that
247
+ * carries one.
248
+ */
249
+ function wireOld(concept: Concept, newId: string): Concept {
250
+ return {
251
+ ...concept,
252
+ frontmatter: { ...concept.frontmatter, status: SUPERSEDED_STATUS, superseded_by: newId },
253
+ };
254
+ }
255
+
256
+ /**
257
+ * The new concept with `oldId` appended to its `supersedes`, or `null` when that is a no-op (the
258
+ * concept already references the old one) — so an unchanged successor is neither rewritten nor
259
+ * counted. A concept may supersede several, so an existing entry is **preserved**: an absent
260
+ * `supersedes` becomes the single bare id `oldId`; an existing scalar or list gains `oldId`
261
+ * (normalized to a list when adding a second). Membership is decided by resolving each existing entry
262
+ * to a concept id via the bundle's own {@link resolveRef}, so a path-form entry that already names
263
+ * the old concept is not duplicated as a bare id.
264
+ */
265
+ function wireNew(concept: Concept, oldId: string, graph: { concepts: ReadonlyMap<string, Concept> }): Concept | null {
266
+ const dir = posix.dirname(concept.path);
267
+ const existing = concept.frontmatter.supersedes;
268
+ const next = appendSupersedes(existing, oldId, dir, graph.concepts);
269
+ if (next === existing) {
270
+ return null; // already references the old concept — nothing to write
271
+ }
272
+ return { ...concept, frontmatter: { ...concept.frontmatter, supersedes: next } };
273
+ }
274
+
275
+ /** Compute the new `supersedes` value, appending `oldId` (bare id) unless it is already referenced. */
276
+ function appendSupersedes(
277
+ existing: unknown,
278
+ oldId: string,
279
+ dir: string,
280
+ byId: ReadonlyMap<string, Concept>,
281
+ ): string | string[] {
282
+ if (existing === undefined || existing === null) {
283
+ return oldId;
284
+ }
285
+ const list = Array.isArray(existing) ? existing : [existing];
286
+ if (list.some((item) => typeof item === "string" && resolveRef(item, dir, byId) === oldId)) {
287
+ return existing as string | string[]; // already references the old concept — preserve as-is
288
+ }
289
+ return [...list, oldId];
290
+ }
291
+
292
+ // ── Validation ─────────────────────────────────────────────────────────────────
293
+
294
+ /**
295
+ * Reject superseding a concept that is already superseded (`conflict`, exit 5). "Already superseded"
296
+ * is either the `status: superseded` lifecycle signal (matched case-insensitively, since `status` is
297
+ * a free-form string) **or** an already-recorded `superseded_by` — the structured field
298
+ * {@link wireOld} would otherwise silently overwrite, discarding the concept's real recorded
299
+ * successor.
300
+ */
301
+ function assertNotAlreadySuperseded(oldConcept: Concept): void {
302
+ if (hasRecordedSuccessor(oldConcept) || statusIsSuperseded(oldConcept)) {
303
+ throw new LoreError(
304
+ "conflict",
305
+ `concept "${oldConcept.id}" is already superseded`,
306
+ "a superseded concept cannot be superseded again; clear its `status`/`superseded_by` first if this is intentional",
307
+ { id: oldConcept.id },
308
+ );
309
+ }
310
+ }
311
+
312
+ /** Whether the concept already records a successor (`superseded_by` set to a non-empty value). */
313
+ function hasRecordedSuccessor(concept: Concept): boolean {
314
+ const value = concept.frontmatter.superseded_by;
315
+ if (value === undefined || value === null) {
316
+ return false;
317
+ }
318
+ return !(Array.isArray(value) && value.length === 0);
319
+ }
320
+
321
+ /** Whether the concept's `status` is `superseded`, matched case-insensitively (status is free-form text). */
322
+ function statusIsSuperseded(concept: Concept): boolean {
323
+ const status = concept.frontmatter.status;
324
+ return typeof status === "string" && status.trim().toLowerCase() === SUPERSEDED_STATUS;
325
+ }
326
+
327
+ // ── Argument parsing ───────────────────────────────────────────────────────────
328
+
329
+ /**
330
+ * Parse `supersede`'s tokens into its two positionals (`<oldId> <newId>`), `--rewrite-links`, and
331
+ * `--dry-run`, via the shared {@link parseCommandArgs} parser (mirrors
332
+ * `commands/rename.ts`/`commands/link.ts`'s parsers). Positional arity is validated here since it
333
+ * differs per command.
334
+ */
335
+ function parseSupersedeArgs(args: readonly string[]): SupersedeArgs {
336
+ const { positionals, flags } = parseCommandArgs(args, "supersede");
337
+
338
+ const oldId = positionals[0];
339
+ if (oldId === undefined) {
340
+ throw usage("`lore supersede` needs an old and a new id", "run `lore supersede <oldId> <newId>`");
341
+ }
342
+ const newId = positionals[1];
343
+ if (newId === undefined) {
344
+ throw usage(
345
+ "`lore supersede` needs a successor id",
346
+ "pass the successor id, e.g. `lore supersede adr/0007-old adr/0012-new`",
347
+ );
348
+ }
349
+ if (positionals.length > 2) {
350
+ throw usage(
351
+ `unexpected argument "${positionals[2]}"`,
352
+ "pass exactly an old and a new id; scope nothing else (supersede wires the whole bundle)",
353
+ );
354
+ }
355
+ return { oldId, newId, rewriteLinks: flags.has("rewrite-links"), dryRun: flags.has("dry-run") };
356
+ }
357
+
358
+ // ── Output ─────────────────────────────────────────────────────────────────────
359
+
360
+ /** Assemble the {@link SupersedeReport} from the principals and the merged writes (repo-relative display paths). */
361
+ function buildReport(
362
+ oldConcept: Concept,
363
+ newConcept: Concept,
364
+ writes: Map<string, string>,
365
+ flags: { rewroteLinks: boolean; dryRun: boolean },
366
+ ): SupersedeReport {
367
+ const files = [...writes.keys()].map((path) => ({ path: `${DOCS_DIR}/${path}` }));
368
+ return {
369
+ old: `${DOCS_DIR}/${oldConcept.path}`,
370
+ new: `${DOCS_DIR}/${newConcept.path}`,
371
+ files,
372
+ filesChanged: files.length,
373
+ rewroteLinks: flags.rewroteLinks,
374
+ dryRun: flags.dryRun,
375
+ };
376
+ }
377
+
378
+ /** The per-result-type rendering bundle for `supersede` (output.ts dispatches on the mode). */
379
+ function reportRenderable(data: SupersedeReport): Renderable<SupersedeReport> {
380
+ return {
381
+ kind: "supersede.result",
382
+ data,
383
+ pretty: (report) => render(report),
384
+ plain: (report) => render(report),
385
+ };
386
+ }
387
+
388
+ /** The supersession line, one line per other changed file, then a summary. (No color: no severities.) */
389
+ function render(data: SupersedeReport): string {
390
+ const verb = data.dryRun ? "would supersede" : "superseded";
391
+ const lines = [`${verb} ${data.old} -> ${data.new}`];
392
+ for (const file of data.files) {
393
+ if (file.path !== data.old && file.path !== data.new) {
394
+ lines.push(`${data.dryRun ? "would update" : "updated"} ${file.path}`);
395
+ }
396
+ }
397
+ const noun = data.filesChanged === 1 ? "file" : "files";
398
+ lines.push(`${data.filesChanged} ${noun} changed${data.dryRun ? " (dry-run)" : ""}`);
399
+ return lines.join("\n");
400
+ }