@opum-ai/lore 0.1.0 → 0.1.1

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 (90) hide show
  1. package/README.md +33 -22
  2. package/bin/lore.cjs +35 -7
  3. package/package.json +17 -17
  4. package/src/adapters/backlog.ts +0 -1084
  5. package/src/adapters/git.ts +0 -221
  6. package/src/cli.ts +0 -667
  7. package/src/commands/agent.ts +0 -301
  8. package/src/commands/agents.ts +0 -302
  9. package/src/commands/args.ts +0 -209
  10. package/src/commands/changed.ts +0 -70
  11. package/src/commands/check.ts +0 -1031
  12. package/src/commands/codex-bridge.ts +0 -49
  13. package/src/commands/concurrency.ts +0 -48
  14. package/src/commands/context.ts +0 -292
  15. package/src/commands/discover.ts +0 -89
  16. package/src/commands/explorer.ts +0 -253
  17. package/src/commands/export.ts +0 -93
  18. package/src/commands/fswrite.ts +0 -928
  19. package/src/commands/graph.ts +0 -291
  20. package/src/commands/help.ts +0 -151
  21. package/src/commands/impact.ts +0 -59
  22. package/src/commands/init.ts +0 -583
  23. package/src/commands/instructions.ts +0 -91
  24. package/src/commands/link.ts +0 -929
  25. package/src/commands/new.ts +0 -476
  26. package/src/commands/orphans.ts +0 -457
  27. package/src/commands/path.ts +0 -67
  28. package/src/commands/provenance.ts +0 -68
  29. package/src/commands/query.ts +0 -312
  30. package/src/commands/reconcile-shared.ts +0 -280
  31. package/src/commands/rename.ts +0 -585
  32. package/src/commands/replace.ts +0 -320
  33. package/src/commands/scaffold.ts +0 -346
  34. package/src/commands/schema.ts +0 -293
  35. package/src/commands/snapshot.ts +0 -130
  36. package/src/commands/supersede.ts +0 -400
  37. package/src/commands/sync.ts +0 -371
  38. package/src/commands/tasks.ts +0 -271
  39. package/src/commands/traversal.ts +0 -151
  40. package/src/commands/validate.ts +0 -226
  41. package/src/config.ts +0 -598
  42. package/src/core/agent-bridge.ts +0 -287
  43. package/src/core/agent-context.ts +0 -498
  44. package/src/core/agent-profile.ts +0 -447
  45. package/src/core/bundle.ts +0 -893
  46. package/src/core/check.ts +0 -853
  47. package/src/core/codex-bridge.ts +0 -100
  48. package/src/core/concept.ts +0 -597
  49. package/src/core/consumer-scaffold.ts +0 -433
  50. package/src/core/context.ts +0 -271
  51. package/src/core/explorer-contract.ts +0 -441
  52. package/src/core/explorer-qualification.ts +0 -58
  53. package/src/core/explorer.ts +0 -518
  54. package/src/core/finding.ts +0 -31
  55. package/src/core/graph.ts +0 -201
  56. package/src/core/indexes.ts +0 -436
  57. package/src/core/instructions.ts +0 -209
  58. package/src/core/ladybug-driver.ts +0 -1795
  59. package/src/core/ladybug-lifecycle.ts +0 -1178
  60. package/src/core/ladybug-native.ts +0 -95
  61. package/src/core/ladybug-source.ts +0 -667
  62. package/src/core/links.ts +0 -681
  63. package/src/core/log.ts +0 -253
  64. package/src/core/managed-block.ts +0 -540
  65. package/src/core/manifest.ts +0 -718
  66. package/src/core/order.ts +0 -13
  67. package/src/core/profile.ts +0 -1007
  68. package/src/core/projection.ts +0 -195
  69. package/src/core/query.ts +0 -542
  70. package/src/core/reconcile.ts +0 -236
  71. package/src/core/replace.ts +0 -419
  72. package/src/core/retrieval.ts +0 -213
  73. package/src/core/rewrite.ts +0 -940
  74. package/src/core/scaffold.ts +0 -255
  75. package/src/core/schema.ts +0 -366
  76. package/src/core/snapshot-runtime.ts +0 -52
  77. package/src/core/snapshot-store.ts +0 -287
  78. package/src/core/snapshot.ts +0 -711
  79. package/src/core/template.ts +0 -429
  80. package/src/core/traversal.ts +0 -487
  81. package/src/core/validate.ts +0 -517
  82. package/src/core/workspace-contract.ts +0 -473
  83. package/src/core/workspace-projection.ts +0 -365
  84. package/src/core/workspace-retrieval.ts +0 -196
  85. package/src/core/workspace-source.ts +0 -174
  86. package/src/errors.ts +0 -697
  87. package/src/meta.ts +0 -7
  88. package/src/output.ts +0 -589
  89. package/src/scripts/upstream-backlog-watch.ts +0 -288
  90. package/src/state.ts +0 -390
@@ -1,585 +0,0 @@
1
- /**
2
- * commands/rename.ts — `lore rename <oldId> <newId> [--dry-run]`.
3
- *
4
- * The thin, side-effecting layer over the pure inbound-rewrite engine (cli-surface §rename,
5
- * LORE-35 AC#2): it parses the command's own arguments, loads the `docs/` bundle graph, asks
6
- * {@link rewriteInbound} for the move plan — relocate the concept and repoint every inbound
7
- * cross-link and frontmatter ref — regenerates the affected `index.md` listing blocks against the
8
- * post-rename graph, writes the changed files, moves the renamed file and deletes its old path
9
- * (unless `--dry-run`), renders the report, and returns the exit code. All file I/O lives here;
10
- * every link/ref judgement lives in `core/rewrite.ts`, and the byte-stable concept serialization
11
- * in `core/concept.ts`.
12
- *
13
- * Index regeneration composes on top of the rewrite: the renamed concept's old directory loses a
14
- * listing entry and its new directory gains one, so the bundle's `index.md` hubs are regenerated
15
- * from the **post-rename** graph ({@link generateIndexes}) and spliced into the already-rewritten
16
- * index bytes — only index files whose bytes actually change are written, so an unrelated, already
17
- * canonical hub is never churned.
18
- *
19
- * **Renaming a concept linked to Backlog tasks (LORE-24, ADR-0009 §2) also moves each linked
20
- * task's `doc:<conceptId>` label and `--doc` path** to the new id/path, via `commands/link.ts`'s
21
- * {@link moveBackRefs} — the file move commits first (it needs no Backlog round-trip and never
22
- * depends on the back-ref move's outcome), then the per-task Backlog edits run, so a Backlog
23
- * failure can never strand an already-renamed file. A concept with no `tasks:` entries never
24
- * constructs a `BacklogAdapter` at all — renaming an unlinked doc has exactly the same
25
- * zero-Backlog-dependency behavior it always did. `--dry-run` skips the Backlog move entirely
26
- * (it previews the file-level plan only, not a Backlog-side preview).
27
- *
28
- * A bad flag, a missing/duplicate id, or a `newId` that escapes the `docs/` bundle root (checked
29
- * at argument-parsing time, before any bundle load, as defense-in-depth alongside
30
- * `rewriteInbound`'s own identical engine-layer guard — LORE-78/LORE-79/LORE-80) is a `usage`
31
- * error (exit 2); an absent `oldId` a `not_found` (exit 3, from the engine); an already-taken
32
- * `newId` a `conflict` (exit 5); a failed back-ref move is `drift` (exit 6, same as
33
- * `link`/`unlink`) — all funnel through the router's one error seam like every command.
34
- *
35
- * Every retargeted inbound link is still repointed to the new location — `rename` DELETES the old
36
- * file, so skipping a retarget would leave a genuinely dangling link, never an option. But when the
37
- * link's visible text still names the OLD id (a citation left over from before the rename), that
38
- * would silently leave the prose and the link disagreeing. The engine flags each such
39
- * {@link LinkTextMismatch} in the plan; this command renders one stderr `warning:` line per mismatch
40
- * via {@link renderLinkTextMismatchWarning} (LORE-262) — purely advisory, no effect on the retarget
41
- * or the exit code.
42
- */
43
-
44
- import { existsSync } from "node:fs";
45
- import { dirname, join, posix, win32 } from "node:path";
46
- import type { BacklogAdapter } from "../adapters/backlog";
47
- import { type BundleGraph, buildGraph, loadBundle, toRefList, UNREADABLE_DIRECTORY_WARNING } from "../core/bundle";
48
- import { type Concept, idFromPath, parseConcept } from "../core/concept";
49
- import { generateIndexes, INDEX_BLOCK_BEGIN, INDEX_BLOCK_END, locateManagedBlock } from "../core/indexes";
50
- import { loadProfile, type Profile } from "../core/profile";
51
- import {
52
- escapesRoot,
53
- isDriveRelative,
54
- type RewritePlan,
55
- renderLinkTextMismatchWarning,
56
- resolvesToRoot,
57
- rewriteInbound,
58
- } from "../core/rewrite";
59
- import { DOCS_DIR } from "../core/scaffold";
60
- import { EXIT_CODES, EXIT_OK, LoreError, WarningCollector, type Writer } from "../errors";
61
- import { emit, type OutputContext, type Renderable } from "../output";
62
- import { type BacklogCommitResult, commitBacklogFiles, type GitSpawn, renderBacklogCommitLine } from "../state";
63
- import { assertNotReservedStem, parseCommandArgs, usage } from "./args";
64
- import { canonicalIdentity, readIndexBytes } from "./discover";
65
- import { assertNoSymlinkInAnyPath, ensureDir, moveFile, writeFileOverwriting } from "./fswrite";
66
- import {
67
- assertNoCommaInId,
68
- assertNoLabelCaseCollision,
69
- dedupeTaskIds,
70
- defaultAdapter,
71
- type MovedBackRef,
72
- moveBackRefs,
73
- } from "./link";
74
-
75
- /** The reserved index file name, regenerated from the post-rename graph rather than spliced as a link. */
76
- const INDEX_FILE = "index.md";
77
-
78
- /** Options for {@link runRename}; `root` and the streams are injectable for tests. */
79
- export interface RenameOptions {
80
- /** The repo root the `docs/` bundle resolves against. */
81
- root: string;
82
- /** The resolved output mode/color (from `output.ts`). */
83
- output: OutputContext;
84
- /** The command's normalized positional + flag tokens from Commander. */
85
- args: readonly string[];
86
- /** stdout sink; defaults to `process.stdout`. */
87
- stdout?: Writer;
88
- /** stderr sink for bundle-load advisories; defaults to `process.stderr`. */
89
- stderr?: Writer;
90
- /** The Backlog adapter; defaults to the real `backlog` binary on PATH. Only ever constructed (and only ever injected in tests) when the renamed concept has `tasks:` entries. */
91
- adapter?: BacklogAdapter;
92
- /** The git-write seam (`state.ts`) for committing `backlog/` after the back-reference move; defaults to the real `git` binary. Only used when the concept is linked and not a `--dry-run`. Injected in tests. */
93
- gitSpawn?: GitSpawn;
94
- }
95
-
96
- /** The parsed form of `lore rename`'s arguments. */
97
- interface RenameArgs {
98
- /** The concept id (or path) to rename from. */
99
- oldId: string;
100
- /** The concept id (or path) to rename to. */
101
- newId: string;
102
- /** `--dry-run`: report what would change, write nothing. */
103
- dryRun: boolean;
104
- }
105
-
106
- /** One written file, for the report. */
107
- interface ChangedFile {
108
- /** Repo-relative POSIX path of the written file. */
109
- readonly path: string;
110
- }
111
-
112
- /** The `rename.result` payload: the relocation and the files written. */
113
- export interface RenameReport {
114
- /** The renamed file's old repo-relative path. */
115
- readonly from: string;
116
- /** The renamed file's new repo-relative path. */
117
- readonly to: string;
118
- /** Every file written (the moved file, repointed inbound files, regenerated indexes), ascending. */
119
- readonly files: readonly ChangedFile[];
120
- /** How many files changed (== `files.length`). */
121
- readonly filesChanged: number;
122
- /** Every linked task's back-reference move outcome (empty when the concept had no `tasks:`, or under `--dry-run`, which never attempts the Backlog move). */
123
- readonly backRefs: readonly MovedBackRef[];
124
- /** The `backlog/` commit outcome — `{committed: false, files: []}` when no back-reference was moved (unlinked concept, `--dry-run`, or every task already current). */
125
- readonly backlogCommit: BacklogCommitResult;
126
- /** Whether this was a `--dry-run` (nothing was written). */
127
- readonly dryRun: boolean;
128
- }
129
-
130
- /**
131
- * Run `lore rename`: parse the arguments, load the bundle, plan the move + inbound rewrite,
132
- * regenerate the affected indexes, write the changed files and relocate the renamed file (unless
133
- * `--dry-run`), move every linked task's Backlog back-reference to the new id/path, emit the
134
- * `rename.result`, and return the exit code. A bad flag or duplicate id throws a `usage`
135
- * {@link LoreError} (exit `2`); an absent `oldId` a `not_found` (exit `3`); a taken `newId` a
136
- * `conflict` (exit `5`); a failed back-reference move `drift` (exit `6`).
137
- */
138
- export async function runRename(options: RenameOptions): Promise<number> {
139
- const parsed = parseRenameArgs(options.args);
140
- const oldId = idFromPath(parsed.oldId);
141
- const newId = idFromPath(parsed.newId);
142
- if (oldId === newId) {
143
- throw new LoreError("usage", "the old and new id are the same", "pass a different target id to rename to", {
144
- id: oldId,
145
- });
146
- }
147
- // A concept may not be renamed FROM or ONTO a reserved, machine-owned file name (index.md/log.md):
148
- // those are regenerated wholesale, so renaming away from one would silently delete it (LORE-81) and
149
- // renaming onto one would silently clobber the relocated content. Mirrors supersede.ts's identical
150
- // two-sided check.
151
- assertNotReservedStem(oldId, "rename from");
152
- assertNotReservedStem(newId, "rename to");
153
-
154
- const docsRoot = join(options.root, DOCS_DIR);
155
- const advisories = new WarningCollector();
156
- // Loaded once and threaded through every downstream serialize/re-parse (rewriteInbound below,
157
- // buildPostRenameGraph's own re-parse) so a rewritten concept is never written under one profile
158
- // and re-read under another (LORE-88) — before this fix, only this initial load honored a
159
- // project's custom `.lore/profile.toml` (LORE-84); rewriteInbound's internal serialize/re-parse
160
- // silently fell back to the built-in default, which could reject an inbound concept whose custom-
161
- // profile-shaped frontmatter (e.g. a scalar `tasks:` field) is perfectly valid under the project's
162
- // own schema.
163
- const profile = loadProfile({ root: options.root });
164
- const graph = loadBundle(docsRoot, { warnings: advisories, profile });
165
- // Flushed immediately (not at the end, as this command previously did) so a skipped-directory
166
- // warning naming the exact path/reason survives on the fail-loud path below it feeds (LORE-82),
167
- // mirroring how `context.ts`/`graph.ts` flush before a load-warning-explained not_found throw.
168
- advisories.flush({ color: options.output.color, stderr: options.stderr });
169
- // rewriteInbound can only repoint the inbound links it can SEE — a directory `loadBundle` had to
170
- // skip (unreadable) may hide a concept that links to `oldId`, so committing this rewrite would
171
- // silently report success while leaving that concept's link stale/broken. Refuse rather than
172
- // guess: the graph is not the complete bundle, so no rewrite over it is safe to commit (LORE-82).
173
- if (advisories.has(UNREADABLE_DIRECTORY_WARNING)) {
174
- throw new LoreError(
175
- "validation",
176
- "the bundle graph is incomplete: an unreadable directory was skipped while loading it",
177
- "fix filesystem permissions on the directory named in the warning above and retry — rename cannot safely rewrite inbound links without a complete view of the bundle",
178
- { docsRoot },
179
- );
180
- }
181
-
182
- // Plan the move + inbound rewrite (throws not_found if oldId is absent / conflict if newId is a
183
- // concept), then regenerate the index hubs against the post-rename graph and merge them over the
184
- // plan's writes (index regen wins).
185
- const plan = rewriteInbound(graph, oldId, newId, { move: true, profile });
186
- // The engine's conflict check is graph-only; guard the filesystem too, so a target that collides
187
- // with a NON-concept file (a hand-written index, a doc with no/invalid frontmatter) or with a
188
- // concept differing only in case (which a case-sensitive `Map.has` misses) is never overwritten.
189
- // A target resolving to the *same* inode as the source is a legitimate case-only rename, allowed.
190
- assertTargetFree(plan, docsRoot);
191
-
192
- // Every retargeted inbound link is still repointed exactly as before (LORE-262 AC#2 — no
193
- // regression); a link whose visible text still names the OLD id is additionally called out as a
194
- // stderr warning so the author can review the prose, rather than the mismatch shipping silently
195
- // (LORE-262 AC#1). Skipping the retarget instead is not an option here: `lore rename` DELETES the
196
- // old file, so a skipped link would become a genuinely dangling one — worse than a stale-reading
197
- // text. A FRESH collector, not `advisories` — that one was already flushed above (LORE-82's
198
- // ordering), and `flush()` is non-draining, so reusing it here would re-print the earlier
199
- // bundle-load warnings a second time.
200
- if (plan.textMismatches.length > 0) {
201
- const mismatchWarnings = new WarningCollector();
202
- for (const mismatch of plan.textMismatches) {
203
- mismatchWarnings.add(renderLinkTextMismatchWarning(mismatch));
204
- }
205
- mismatchWarnings.flush({ color: options.output.color, stderr: options.stderr });
206
- }
207
-
208
- // The Backlog back-ref move's own preconditions, checked up front (before any write) — but only
209
- // when the move will actually be attempted: a linked concept (an unlinked rename never touches
210
- // Backlog) that isn't a `--dry-run` (which previews the file-level plan only and never attempts
211
- // the Backlog-side move either — see below). Mirrors link.ts's `!noBackRef` scoping.
212
- const oldConcept = graph.concepts.get(oldId) as Concept;
213
- const linkedTasks = dedupeTaskIds(toRefList(oldConcept.frontmatter.tasks));
214
- if (linkedTasks.length > 0 && !parsed.dryRun) {
215
- assertNoCommaInId(newId, "rename to");
216
- assertNoLabelCaseCollision(graph, newId, oldId, "rename to");
217
- }
218
-
219
- const writes = mergeIndexWrites(plan, graph, docsRoot, profile);
220
-
221
- if (!parsed.dryRun) {
222
- commitWrites(writes, plan, docsRoot, options.root);
223
- }
224
-
225
- // Move every linked task's Backlog back-reference LAST — mirrors link.ts's write-order fix: the
226
- // file rename needs no Backlog round-trip and never depends on the back-ref move's outcome, so
227
- // committing it first means a Backlog failure can never strand an already-renamed file. Skipped
228
- // entirely (no BacklogAdapter even constructed) when the concept has no `tasks:` — renaming an
229
- // unlinked doc keeps its historical zero-Backlog-dependency behavior — and under `--dry-run`,
230
- // which previews the file-level plan only, not a Backlog-side one.
231
- let backRefs: readonly MovedBackRef[] = [];
232
- let editedTaskFiles: readonly string[] = [];
233
- // `plan.rename` is never actually `null` here — `rewriteInbound` above is always called with
234
- // `move: true` — but the check is kept (mirrors `assertTargetFree`'s identical guard) so this
235
- // stays correct by construction rather than by the caller's current behavior, should a future
236
- // change ever make `move` conditional in this function.
237
- if (plan.rename !== null && !parsed.dryRun && linkedTasks.length > 0) {
238
- const adapter = options.adapter ?? defaultAdapter(options.root);
239
- const moved = await moveBackRefs(
240
- adapter,
241
- linkedTasks,
242
- oldId,
243
- newId,
244
- `${DOCS_DIR}/${plan.rename.from}`,
245
- `${DOCS_DIR}/${plan.rename.to}`,
246
- );
247
- backRefs = moved.outcomes;
248
- editedTaskFiles = moved.editedFiles;
249
- }
250
-
251
- // Commit exactly the task files the back-reference move edited — lore is the sole committer of
252
- // `backlog/` (ADR-0012, design §3.6), so a `rename` no longer leaves them uncommitted until the
253
- // next `lore sync`. Scoped to `editedTaskFiles`, so an unlinked/`--dry-run` rename or an
254
- // all-`already-current` move (empty) commits nothing and an unrelated dirty `backlog/` edit is
255
- // never swept in (ADR-0012 §1).
256
- const backlogCommit = await commitBacklogFiles(editedTaskFiles, options, RENAME_COMMIT_MESSAGE);
257
-
258
- // commitBacklogFiles captures a commit failure into backlogCommit.error rather than throwing, so
259
- // the report emit below always runs on the write path — a git failure no longer skips it
260
- // (previously it threw here, dropping the report). Load advisories were already flushed right
261
- // after loadBundle (LORE-82), not repeated here (flush is non-draining — a second call would
262
- // re-print the same warnings).
263
- const report = buildReport(plan, writes, backRefs, backlogCommit, parsed.dryRun);
264
- emit(reportRenderable(report), options.output, options.stdout);
265
- return backRefs.some((b) => b.backRef === "failed") || backlogCommit.error !== undefined ? EXIT_CODES.drift : EXIT_OK;
266
- }
267
-
268
- /** The `git`-authored commit message for `lore rename`'s `backlog/` writes (moving each linked task's `doc:` label/`--doc` path). */
269
- const RENAME_COMMIT_MESSAGE = "chore(backlog): move doc back-references (lore rename)";
270
-
271
- // ── Filesystem commit ──────────────────────────────────────────────────────────
272
-
273
- /**
274
- * Reject a rename whose destination is already occupied on disk by anything other than the source
275
- * file itself. The engine's `graph.concepts.has(to)` guard is case-sensitive and concept-only; this
276
- * closes the two gaps it leaves on a case-insensitive filesystem and against non-concept files. A
277
- * destination that resolves to the **same physical file** as the source is a case-only rename and
278
- * is permitted (the relocation renames the inode rather than clobbering it).
279
- *
280
- * This is a PLAN-TIME check only — it runs once, well before `commitWrites` below actually moves
281
- * anything, so it cannot see a destination created during that (potentially I/O-heavy) window
282
- * (LORE-132). It still fires early for the common case (fail fast, before any write), but the
283
- * guarantee that a raced-in destination is never silently clobbered comes from `moveFile`
284
- * (`fswrite.ts`)'s own immediately-before-the-syscall re-check, not from this function.
285
- */
286
- function assertTargetFree(plan: RewritePlan, docsRoot: string): void {
287
- if (plan.rename === null) {
288
- return;
289
- }
290
- const absTo = join(docsRoot, plan.rename.to);
291
- const absFrom = join(docsRoot, plan.rename.from);
292
- if (existsSync(absTo) && canonicalIdentity(absTo) !== canonicalIdentity(absFrom)) {
293
- throw new LoreError(
294
- "conflict",
295
- `cannot rename to "${plan.rename.to}": a file already exists at that path`,
296
- "choose a target path that is not already taken (concept or not), or remove the conflicting file",
297
- { path: `${DOCS_DIR}/${plan.rename.to}` },
298
- );
299
- }
300
- }
301
-
302
- /**
303
- * Commit the planned writes to disk. Every target is swept for a symlinked ancestor (or, for the
304
- * in-place rewrites, a symlinked final component too — {@link writeFileOverwriting} is a plain
305
- * `writeFileSync`, which follows a symlink at the final path segment, unlike {@link moveFile}'s
306
- * `renameSync`, which atomically replaces whatever is at the destination without ever dereferencing
307
- * it) BEFORE any single write begins (LORE-93 AC#5) — `ensureDir`'s own per-call guard alone would
308
- * only refuse once the loop below REACHES a bad target, by which point earlier targets in the same
309
- * plan may already be on disk. Parent directories are then created (so a target in a brand-new
310
- * category directory does not fail with ENOENT), the in-place rewrites are written, and the renamed
311
- * file is relocated **last** by {@link moveFile} — its new bytes are written into the source path
312
- * and then the inode is renamed, which is atomic and safe even for a case-only rename on a
313
- * case-insensitive filesystem. {@link moveFile} itself re-verifies the destination immediately
314
- * before that rename (LORE-132), so a destination that appeared after {@link assertTargetFree}'s
315
- * earlier plan-time check runs is refused with the same `conflict` rather than silently replaced.
316
- * (A mid-commit IO failure UNRELATED to a symlink can still leave the bundle partially rewritten —
317
- * cross-file transactional rollback for that case is a shared concern with `lore replace`, deferred;
318
- * the preflight sweep above only closes the symlink-specific gap.)
319
- */
320
- function commitWrites(writes: Map<string, string>, plan: RewritePlan, docsRoot: string, root: string): void {
321
- const movedTo = plan.rename?.to;
322
- // `writes` already carries the moved file's new bytes keyed at `plan.rename.to` (RewritePlan's
323
- // own contract) — only `plan.rename.from` (the source, written-then-renamed) isn't a `writes` key.
324
- const targets = [...writes.keys()].map((path) => `${DOCS_DIR}/${path}`);
325
- if (plan.rename !== null) {
326
- targets.push(`${DOCS_DIR}/${plan.rename.from}`);
327
- }
328
- assertNoSymlinkInAnyPath(root, targets);
329
-
330
- for (const [path, bytes] of writes) {
331
- if (path === movedTo) {
332
- continue; // the moved file is relocated below, not written at its new path here
333
- }
334
- const abs = join(docsRoot, path);
335
- ensureDir(root, dirname(`${DOCS_DIR}/${path}`));
336
- writeFileOverwriting(abs, bytes, `${DOCS_DIR}/${path}`);
337
- }
338
- if (plan.rename !== null) {
339
- const absFrom = join(docsRoot, plan.rename.from);
340
- const absTo = join(docsRoot, plan.rename.to);
341
- ensureDir(root, dirname(`${DOCS_DIR}/${plan.rename.to}`));
342
- // Write the new bytes into the source file, then rename the source to its destination — never
343
- // write-new-then-delete-old, which would destroy a case-only rename's single inode.
344
- writeFileOverwriting(absFrom, writes.get(plan.rename.to) ?? "", `${DOCS_DIR}/${plan.rename.from}`);
345
- moveFile(absFrom, absTo, `${DOCS_DIR}/${plan.rename.to}`);
346
- }
347
- }
348
-
349
- // ── Index regeneration ───────────────────────────────────────────────────────────
350
-
351
- /**
352
- * Merge the plan's concept rewrites with the regenerated `index.md` hubs, returning the final
353
- * `path → bytes` writes (bundle-relative paths, ascending). Index regeneration runs over the
354
- * **post-rename** graph and splices into the already-rewritten index bytes, so a hub whose listing
355
- * gains/loses the renamed concept is updated while its frontmatter and prose are preserved; a hub
356
- * whose regenerated bytes equal the on-disk bytes is dropped, so an unrelated canonical index is
357
- * never written.
358
- */
359
- function mergeIndexWrites(
360
- plan: RewritePlan,
361
- graph: BundleGraph,
362
- docsRoot: string,
363
- profile: Profile,
364
- ): Map<string, string> {
365
- const planByPath = new Map(plan.writes.map((w) => [w.path, w.bytes] as const));
366
-
367
- // Current on-disk bytes of every index file, the determinism seam generateIndexes splices into —
368
- // overridden by the plan's rewritten bytes where a (concept) index was already repointed.
369
- const diskIndexBytes = readIndexBytes(docsRoot);
370
- const existing = new Map(diskIndexBytes);
371
- for (const [path, bytes] of planByPath) {
372
- if (posix.basename(path) === INDEX_FILE) {
373
- existing.set(path, bytes);
374
- }
375
- }
376
-
377
- const postRename = buildPostRenameGraph(graph, plan, profile);
378
- const regenerated = generateIndexes(postRename, { existing });
379
-
380
- // Start from the plan's writes, then let index regeneration win for any index path. An index
381
- // whose regenerated bytes equal what is already on disk is a no-op and is skipped.
382
- const writes = new Map(planByPath);
383
- for (const [path, bytes] of regenerated) {
384
- if (bytes === diskIndexBytes.get(path)) {
385
- writes.delete(path); // unchanged hub — drop a plan entry too (none expected) and don't write
386
- } else {
387
- writes.set(path, bytes);
388
- }
389
- }
390
-
391
- // A cross-directory rename can empty a directory of its last concept; generateIndexes only emits
392
- // an index for a directory that still holds a concept, so that directory's on-disk index.md keeps
393
- // a now-dead link to the moved file in its machine-owned block. Regenerate those to an empty
394
- // listing so no stale link survives in a managed region.
395
- if (plan.rename !== null) {
396
- for (const dir of emptiedDirs(plan.rename.from, postRename)) {
397
- const indexPath = `${dir}/${INDEX_FILE}`;
398
- const disk = diskIndexBytes.get(indexPath);
399
- if (disk === undefined || regenerated.has(indexPath)) {
400
- continue; // no such index, or it was already regenerated (the dir still has concepts)
401
- }
402
- const emptied = spliceEmptyListing(disk);
403
- if (emptied !== disk) {
404
- writes.set(indexPath, emptied);
405
- }
406
- }
407
- }
408
- return new Map([...writes].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)));
409
- }
410
-
411
- /**
412
- * The ancestor directories of `fromPath` (its directory up to, but excluding, the bundle root) that
413
- * hold no concept after the rename — the directories a cross-directory move may have emptied. The
414
- * root is excluded because generateIndexes always regenerates the root index.
415
- */
416
- function emptiedDirs(fromPath: string, postRename: BundleGraph): string[] {
417
- const concepts = [...postRename.concepts.values()];
418
- const dirs: string[] = [];
419
- let dir = posix.dirname(fromPath);
420
- while (dir !== "" && dir !== "." && dir !== "/") {
421
- const prefix = `${dir}/`;
422
- if (!concepts.some((c) => c.path.startsWith(prefix))) {
423
- dirs.push(dir);
424
- }
425
- dir = posix.dirname(dir);
426
- }
427
- return dirs;
428
- }
429
-
430
- /**
431
- * Replace an index file's managed listing block with an **empty** listing (the same bytes
432
- * generateIndexes emits for a concept-less directory), preserving every other byte. Returns the
433
- * input unchanged when it carries no managed block (nothing lore owns to regenerate).
434
- */
435
- function spliceEmptyListing(content: string): string {
436
- const bounds = locateManagedBlock(content, INDEX_BLOCK_BEGIN, INDEX_BLOCK_END);
437
- if (bounds === null) {
438
- return content;
439
- }
440
- const block = `${INDEX_BLOCK_BEGIN}\n\n${INDEX_BLOCK_END}`;
441
- const tail = content.slice(bounds.end);
442
- const spliced = content.slice(0, bounds.start) + block + tail;
443
- return tail === "" ? `${spliced}\n` : spliced;
444
- }
445
-
446
- /**
447
- * Rebuild the bundle graph as it will be **after** the rename, so index regeneration lists the
448
- * renamed concept at its new path. Every concept the plan rewrote is re-parsed from its new bytes
449
- * (the moved concept from its new path, so it re-ids to `newId`); every untouched concept is
450
- * carried over verbatim. The plan's bytes came from a validating serialize against `profile` — the
451
- * SAME profile passed here (LORE-88) — so re-parse cannot fail.
452
- */
453
- function buildPostRenameGraph(graph: BundleGraph, plan: RewritePlan, profile: Profile): BundleGraph {
454
- const newBytesByPath = new Map(plan.writes.map((w) => [w.path, w.bytes] as const));
455
- const movedFrom = plan.rename?.from;
456
- const movedTo = plan.rename?.to;
457
-
458
- const concepts: Concept[] = [];
459
- for (const concept of graph.concepts.values()) {
460
- if (concept.path === movedFrom && movedTo !== undefined) {
461
- concepts.push(parseConcept(movedTo, newBytesByPath.get(movedTo) ?? "", { profile })); // moved → new path/id
462
- } else {
463
- const rewritten = newBytesByPath.get(concept.path);
464
- concepts.push(rewritten === undefined ? concept : parseConcept(concept.path, rewritten, { profile }));
465
- }
466
- }
467
- return buildGraph(concepts);
468
- }
469
-
470
- // ── Argument parsing ───────────────────────────────────────────────────────────
471
-
472
- /**
473
- * Reject a `newId` that would resolve outside the `docs/` bundle root, confining the destination
474
- * at the ARGUMENT-PARSING layer — before {@link runRename}'s body runs at all — as defense-in-depth
475
- * alongside {@link rewriteInbound}'s own identical engine-layer guard (LORE-80), and with a clearer
476
- * `usage` error (exit 2) here versus its `validation` (exit 6), mirroring `new.ts`'s
477
- * `resolveOutPath` in spirit (fail fast at the earliest possible point, don't rely on a downstream
478
- * engine's own guard). The algorithm itself mirrors LORE-80's `escapesRoot`/`assertConfinedToBundle`,
479
- * not `resolveOutPath`: `rename` operates on bundle-relative concept ids, not real filesystem paths,
480
- * so there is no `resolve`+`relative`-against-a-real-directory step that fits here — `escapesRoot`
481
- * is reused (not re-derived) from `core/rewrite.ts` for that reason, keeping the one
482
- * security-sensitive segment walk in a single place. Called from {@link parseRenameArgs} itself
483
- * (LORE-78) rather than by its caller, so a confined `newId` is `parseRenameArgs`'s own guarantee,
484
- * not a follow-up check `runRename` happens to make (LORE-79's original call site).
485
- *
486
- * Checked on the RAW `newId` (before {@link idFromPath} runs), mirroring `assertConfinedToBundle`'s
487
- * own documented reasoning for checking pre-normalize.
488
- */
489
- function assertDestinationConfined(newId: string): void {
490
- if (
491
- posix.isAbsolute(newId) ||
492
- win32.isAbsolute(newId) ||
493
- escapesRoot(newId) ||
494
- isDriveRelative(newId) ||
495
- resolvesToRoot(newId)
496
- ) {
497
- throw usage(
498
- `newId "${newId}" resolves outside the docs/ bundle root`,
499
- "pass a destination id that stays inside docs/ (no absolute path, no `..` segments, not empty or self-cancelling)",
500
- { id: newId },
501
- );
502
- }
503
- }
504
-
505
- /**
506
- * Parse `rename`'s tokens into its two positionals (`<oldId> <newId>`) and `--dry-run`, via the
507
- * shared {@link parseCommandArgs} parser (mirrors `commands/supersede.ts`/`commands/link.ts`'s
508
- * parsers). Positional arity is validated here since it differs per command. `newId` is also
509
- * confined to the `docs/` bundle root here (LORE-78) — before this function returns, so a
510
- * traversal/absolute destination never reaches `runRename`'s body as a "parsed" value.
511
- */
512
- function parseRenameArgs(args: readonly string[]): RenameArgs {
513
- const { positionals, flags } = parseCommandArgs(args, "rename");
514
-
515
- const oldId = positionals[0];
516
- if (oldId === undefined) {
517
- throw usage("`lore rename` needs an old and a new id", "run `lore rename <oldId> <newId>`");
518
- }
519
- const newId = positionals[1];
520
- if (newId === undefined) {
521
- throw usage("`lore rename` needs a new id", "pass the target id, e.g. `lore rename stories/old stories/new`");
522
- }
523
- if (positionals.length > 2) {
524
- throw usage(
525
- `unexpected argument "${positionals[2]}"`,
526
- "pass exactly an old and a new id; scope nothing else (rename rewrites the whole bundle)",
527
- );
528
- }
529
- assertDestinationConfined(newId);
530
- return { oldId, newId, dryRun: flags.has("dry-run") };
531
- }
532
-
533
- // ── Output ─────────────────────────────────────────────────────────────────────
534
-
535
- /** Assemble the {@link RenameReport} from the plan, the merged writes, the back-ref move outcomes, and the `backlog/` commit outcome (repo-relative display paths). */
536
- function buildReport(
537
- plan: RewritePlan,
538
- writes: Map<string, string>,
539
- backRefs: readonly MovedBackRef[],
540
- backlogCommit: BacklogCommitResult,
541
- dryRun: boolean,
542
- ): RenameReport {
543
- const files = [...writes.keys()].map((path) => ({ path: `${DOCS_DIR}/${path}` }));
544
- return {
545
- from: plan.rename ? `${DOCS_DIR}/${plan.rename.from}` : "",
546
- to: plan.rename ? `${DOCS_DIR}/${plan.rename.to}` : "",
547
- files,
548
- filesChanged: files.length,
549
- backRefs,
550
- backlogCommit,
551
- dryRun,
552
- };
553
- }
554
-
555
- /** The per-result-type rendering bundle for `rename` (output.ts dispatches on the mode). */
556
- function reportRenderable(data: RenameReport): Renderable<RenameReport> {
557
- return {
558
- kind: "rename.result",
559
- data,
560
- pretty: (report) => render(report),
561
- plain: (report) => render(report),
562
- };
563
- }
564
-
565
- /** The relocation line, one line per other changed file, one per moved back-reference, then a summary. (No color: no severities.) */
566
- function render(data: RenameReport): string {
567
- const verb = data.dryRun ? "would rename" : "renamed";
568
- const lines = [`${verb} ${data.from} -> ${data.to}`];
569
- for (const file of data.files) {
570
- if (file.path !== data.to) {
571
- lines.push(`${data.dryRun ? "would update" : "updated"} ${file.path}`);
572
- }
573
- }
574
- for (const b of data.backRefs) {
575
- const suffix = b.error !== undefined ? ` (${b.error})` : "";
576
- lines.push(`back-ref ${b.task}: ${b.backRef}${suffix}`);
577
- }
578
- const commitLine = renderBacklogCommitLine(data.backlogCommit);
579
- if (commitLine !== undefined) {
580
- lines.push(commitLine);
581
- }
582
- const noun = data.filesChanged === 1 ? "file" : "files";
583
- lines.push(`${data.filesChanged} ${noun} changed${data.dryRun ? " (dry-run)" : ""}`);
584
- return lines.join("\n");
585
- }