@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.
- package/LICENSE +21 -0
- package/README.md +306 -0
- package/bin/lore.cjs +109 -0
- package/package.json +67 -0
- package/src/adapters/backlog.ts +1084 -0
- package/src/adapters/git.ts +221 -0
- package/src/cli.ts +667 -0
- package/src/commands/agent.ts +301 -0
- package/src/commands/agents.ts +302 -0
- package/src/commands/args.ts +209 -0
- package/src/commands/changed.ts +70 -0
- package/src/commands/check.ts +1031 -0
- package/src/commands/codex-bridge.ts +49 -0
- package/src/commands/concurrency.ts +48 -0
- package/src/commands/context.ts +292 -0
- package/src/commands/discover.ts +89 -0
- package/src/commands/explorer.ts +253 -0
- package/src/commands/export.ts +93 -0
- package/src/commands/fswrite.ts +928 -0
- package/src/commands/graph.ts +291 -0
- package/src/commands/help.ts +151 -0
- package/src/commands/impact.ts +59 -0
- package/src/commands/init.ts +583 -0
- package/src/commands/instructions.ts +91 -0
- package/src/commands/link.ts +929 -0
- package/src/commands/new.ts +476 -0
- package/src/commands/orphans.ts +457 -0
- package/src/commands/path.ts +67 -0
- package/src/commands/provenance.ts +68 -0
- package/src/commands/query.ts +312 -0
- package/src/commands/reconcile-shared.ts +280 -0
- package/src/commands/rename.ts +585 -0
- package/src/commands/replace.ts +320 -0
- package/src/commands/scaffold.ts +346 -0
- package/src/commands/schema.ts +293 -0
- package/src/commands/snapshot.ts +130 -0
- package/src/commands/supersede.ts +400 -0
- package/src/commands/sync.ts +371 -0
- package/src/commands/tasks.ts +271 -0
- package/src/commands/traversal.ts +151 -0
- package/src/commands/validate.ts +226 -0
- package/src/config.ts +598 -0
- package/src/core/agent-bridge.ts +287 -0
- package/src/core/agent-context.ts +498 -0
- package/src/core/agent-profile.ts +447 -0
- package/src/core/bundle.ts +893 -0
- package/src/core/check.ts +853 -0
- package/src/core/codex-bridge.ts +100 -0
- package/src/core/concept.ts +597 -0
- package/src/core/consumer-scaffold.ts +433 -0
- package/src/core/context.ts +271 -0
- package/src/core/explorer-contract.ts +441 -0
- package/src/core/explorer-qualification.ts +58 -0
- package/src/core/explorer.ts +518 -0
- package/src/core/finding.ts +31 -0
- package/src/core/graph.ts +201 -0
- package/src/core/indexes.ts +436 -0
- package/src/core/instructions.ts +209 -0
- package/src/core/ladybug-driver.ts +1795 -0
- package/src/core/ladybug-lifecycle.ts +1178 -0
- package/src/core/ladybug-native.ts +95 -0
- package/src/core/ladybug-source.ts +667 -0
- package/src/core/links.ts +681 -0
- package/src/core/log.ts +253 -0
- package/src/core/managed-block.ts +540 -0
- package/src/core/manifest.ts +718 -0
- package/src/core/order.ts +13 -0
- package/src/core/profile.ts +1007 -0
- package/src/core/projection.ts +195 -0
- package/src/core/query.ts +542 -0
- package/src/core/reconcile.ts +236 -0
- package/src/core/replace.ts +419 -0
- package/src/core/retrieval.ts +213 -0
- package/src/core/rewrite.ts +940 -0
- package/src/core/scaffold.ts +255 -0
- package/src/core/schema.ts +366 -0
- package/src/core/snapshot-runtime.ts +52 -0
- package/src/core/snapshot-store.ts +287 -0
- package/src/core/snapshot.ts +711 -0
- package/src/core/template.ts +429 -0
- package/src/core/traversal.ts +487 -0
- package/src/core/validate.ts +517 -0
- package/src/core/workspace-contract.ts +473 -0
- package/src/core/workspace-projection.ts +365 -0
- package/src/core/workspace-retrieval.ts +196 -0
- package/src/core/workspace-source.ts +174 -0
- package/src/errors.ts +697 -0
- package/src/meta.ts +7 -0
- package/src/output.ts +589 -0
- package/src/scripts/upstream-backlog-watch.ts +288 -0
- package/src/state.ts +390 -0
|
@@ -0,0 +1,585 @@
|
|
|
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
|
+
}
|