@opum-ai/lore 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,940 +0,0 @@
1
- /**
2
- * rewrite.ts — the **pure** graph-aware inbound-rewrite engine behind `lore rename` (and,
3
- * next, `lore supersede`): when a concept's id/path changes, repoint **every** reference to
4
- * it — body cross-links and `specs`/`supersedes`/`superseded_by` frontmatter refs — to the new
5
- * location, without touching one byte of authored prose (cli-surface §rename, LORE-35 AC#1–3).
6
- *
7
- * ### Why a surgical string splice, not parse→stringify
8
- *
9
- * lore deliberately ships **no markdown serializer** (deps are only `mdast-util-from-markdown`
10
- * for parsing, plus js-yaml/zod for frontmatter — ADR-0001 packaging constraint).
11
- * Re-emitting a body through a stringifier would reflow prose — reindenting lists, normalizing
12
- * emphasis, rewrapping — which ADR-0008 §7 forbids and AC#3 pins against. So a link's
13
- * destination is rewritten by **splicing the new bytes over the old destination's byte range**,
14
- * located structurally inside the parsed link node; every other byte of the body is copied
15
- * verbatim.
16
- *
17
- * Locating that range cannot use a text search for the destination, because a parsed
18
- * `node.url` is **not** byte-equal to the source it came from: `mdast-util-from-markdown`
19
- * strips an angle-bracket `<…>` wrapper, decodes backslash escapes (`a\(b\).md` → `a(b).md`),
20
- * and drops an optional `"title"` — while *keeping* percent-encoding. An `indexOf(node.url)`
21
- * would miss (or, worse, mis-hit) the destination. Instead {@link destRangeForLink} /
22
- * {@link destRangeForDefinition} walk the source from the link node's known start offset to find
23
- * the exact `[start, end)` of the destination token as authored (see each for the grammar).
24
- *
25
- * ### What gets rewritten
26
- *
27
- * The affected files are computed from the graph, not a directory scan: the moved concept itself
28
- * (when `move`), plus every concept that is the `from` of an edge whose `to` is the renamed
29
- * concept (any {@link EdgeKind}). For each affected concept:
30
- *
31
- * - **Inbound files** (point *at* the renamed concept): each body link or used reference
32
- * definition that resolves to `fromId` is repointed to the new path via {@link normalizeLink}
33
- * (preserving any `#fragment`/`?query`); each `specs`/`supersedes`/`superseded_by` ref that
34
- * resolves to `fromId` is rewritten to the bare-id `toId` (the canonical frontmatter ref form).
35
- * - **The moved file** (only when `move`): *all* of its outbound internal links are recomputed
36
- * against its new directory — they move with the file — so a sibling link becomes a `../`
37
- * link (or vice versa) and a self-link retargets to the new name. This is pure path
38
- * arithmetic, so it corrects even links that dangle (resolve to no concept).
39
- *
40
- * Resolution mirrors the bundle graph's own rules byte-for-byte ({@link bundle}'s
41
- * `internalTarget` + {@link resolvePath} for a body link, `resolveRef` for frontmatter), so this
42
- * engine rewrites exactly the edges the graph counts — case-sensitively, and including
43
- * `resolvePath`'s `/`-absolute special case (a leading `/` resolves against the bundle root,
44
- * stripped, instead of joining onto the referring file's directory — the same rule `check.ts`'s
45
- * link gate applies, LORE-180).
46
- *
47
- * The rewritten bytes are produced by {@link serializeConcept} (frontmatter in canonical,
48
- * byte-stable form; body verbatim with the splices applied), so an already-canonical concept's
49
- * frontmatter round-trips unchanged and only its changed destinations/refs differ.
50
- *
51
- * ### Boundaries (deliberate, documented)
52
- *
53
- * - Only **concept** files are rewritten — a graph edge always originates at a concept. A link
54
- * to the renamed concept from a *non-concept* markdown file (a frontmatter-free `index.md`
55
- * listing's authored prose, a README) is not a graph edge and is left for `lore check` to
56
- * surface; the machine-owned `index.md` listing blocks are regenerated by the command layer
57
- * via {@link generateIndexes}, not here.
58
- * - The moved file's own outbound references are recomputed for its new location — body links by
59
- * path arithmetic, and resolving frontmatter refs canonicalized to their bare (location-
60
- * independent) id. A frontmatter ref that *dangles* (resolves to no concept) is left as-is, the
61
- * same broken signal `lore check` already reports.
62
- *
63
- * ### Text/target mismatch reporting (LORE-262)
64
- *
65
- * An inbound link is always retargeted exactly as described above — the engine never *skips* a
66
- * retarget, because for `lore rename` the old file is deleted, and a skipped link would become a
67
- * genuinely dangling one (worse than a stale-reading text). But a link whose *visible display
68
- * text* still names the OLD id (e.g. a supersession doc's own `[ADR-0005](…)` citation, now
69
- * pointing at ADR-0006) is easy to leave silently misleading, so every such retarget is *also*
70
- * surfaced as a {@link LinkTextMismatch} in {@link RewritePlan.textMismatches} — advisory data
71
- * only, the same shape as everything else this module returns (pure, no I/O). The command layer
72
- * renders each one as a stderr warning via {@link renderLinkTextMismatchWarning}. Scoped to
73
- * **inbound** files only (not the moved file's own self-link retarget in `move` mode) — see
74
- * {@link computeBodyEdits}'s doc comment for why.
75
- *
76
- * Per the core contract (lore-design §2.1) everything here is pure: a {@link BundleGraph} in, a
77
- * {@link RewritePlan} out, or a `not_found`/`conflict` {@link LoreError}. No filesystem, no
78
- * printing, no flags, no `process.exit` — the command layer reads the bundle, writes the plan's
79
- * bytes, and performs the move/delete.
80
- */
81
-
82
- import { posix, win32 } from "node:path";
83
- import type { Nodes } from "mdast";
84
- import { fromMarkdown } from "mdast-util-from-markdown";
85
- import { LoreError } from "../errors";
86
- import {
87
- type BundleGraph,
88
- conceptNotInBundle,
89
- internalTarget,
90
- nodeText,
91
- REF_FIELDS,
92
- resolvePath,
93
- resolveRef,
94
- walkMdast,
95
- } from "./bundle";
96
- import { type Concept, idFromPath, serializeConcept } from "./concept";
97
- import { normalizeLink } from "./links";
98
- import { compareCodeUnits } from "./order";
99
- import type { Profile } from "./profile";
100
- import { DOCS_DIR } from "./scaffold";
101
-
102
- /** A half-open `[start, end)` byte range within a body, locating a link destination to splice. */
103
- interface ByteRange {
104
- readonly start: number;
105
- readonly end: number;
106
- }
107
-
108
- /** One file the plan writes: its bundle-relative path and the new bytes (full file contents). */
109
- export interface RewriteWrite {
110
- /** Bundle-relative path of the file to write (the moved file carries its **new** path). */
111
- readonly path: string;
112
- /** The complete new file contents. */
113
- readonly bytes: string;
114
- }
115
-
116
- /**
117
- * One retargeted **inbound** body link whose visible display text still names the OLD id — a
118
- * text/target mismatch a naive retarget would otherwise leave silently in place (LORE-262). The
119
- * link's destination is always still repointed to `to` (skipping it would leave a genuinely
120
- * *dangling* link once `lore rename` deletes the old file — worse than a stale-reading text), so
121
- * this is advisory data only: the caller (`commands/supersede.ts`/`commands/rename.ts`) surfaces
122
- * it as a stderr warning via {@link renderLinkTextMismatchWarning} so the author can review the
123
- * prose, not something the engine acts on itself.
124
- */
125
- export interface LinkTextMismatch {
126
- /** Bundle-relative path of the concept file containing the mismatched link. */
127
- readonly path: string;
128
- /** The link's visible display text, verbatim as authored (e.g. `"ADR-0005"`). */
129
- readonly text: string;
130
- /** The old id the text names (bare id, no `.md`). */
131
- readonly from: string;
132
- /** The new id the link's destination now points to (bare id, no `.md`). */
133
- readonly to: string;
134
- }
135
-
136
- /**
137
- * The stderr warning line for one {@link LinkTextMismatch} (LORE-262). Exported so
138
- * `commands/supersede.ts` and `commands/rename.ts` — the two callers that can produce one — render
139
- * byte-identical wording through a single shared function, rather than two independently-drifting
140
- * copies (mirrors `state.ts`'s `renderBacklogCommitLine`, reused the same way by `rename.ts`).
141
- */
142
- export function renderLinkTextMismatchWarning(m: LinkTextMismatch): string {
143
- return `link text "${m.text}" in ${DOCS_DIR}/${m.path} still names "${m.from}", but its link now points to "${m.to}" — review the prose`;
144
- }
145
-
146
- /**
147
- * The result of {@link rewriteInbound}: the optional file move and every file whose bytes change.
148
- * `rename` is non-null only in `move` mode — it tells the command which file to write at the new
149
- * path and delete at the old; the moved file's new bytes are in `writes` under its new path.
150
- * `writes` is in ascending-path order for a stable, reviewable plan.
151
- */
152
- export interface RewritePlan {
153
- /** The file relocation (`move` mode only): write `to`, delete `from`. `null` in place-only mode. */
154
- readonly rename: { readonly from: string; readonly to: string } | null;
155
- /** Each changed file's new bytes, ascending by path. */
156
- readonly writes: readonly RewriteWrite[];
157
- /**
158
- * Every retargeted inbound link whose text names the old id, ascending by path (each file's own
159
- * entries keep document order — LORE-262). Empty when no such link was found — the overwhelmingly
160
- * common case, so an ordinary rewrite carries no advisory data at all.
161
- */
162
- readonly textMismatches: readonly LinkTextMismatch[];
163
- }
164
-
165
- /** Options for {@link rewriteInbound}. */
166
- export interface RewriteInboundOptions {
167
- /**
168
- * Whether the renamed concept **relocates** (`lore rename`): its file moves `fromId`→`toId`,
169
- * its own outbound links are recomputed, and `toId` must not already exist. When `false`
170
- * (`lore supersede`'s `--rewrite-links`), nothing moves — only inbound references are
171
- * repointed — and `toId` is expected to already exist (the command validates that).
172
- */
173
- readonly move?: boolean;
174
- /**
175
- * Whether to repoint `specs`/`supersedes`/`superseded_by` **frontmatter** refs as well as body
176
- * links. Defaults to `true` (`lore rename`: the old path vanishes, so every ref to it — structured
177
- * or prose — must move or dangle). `lore supersede` passes `false`: it **preserves** the old file,
178
- * so a third party's `supersedes`/`superseded_by`/`specs` ref to it is still a valid historical
179
- * record, not a dead pointer — repointing it would fabricate a relationship that never happened.
180
- * Only body cross-links (the navigational "go read this" edges) are redirected to the successor.
181
- */
182
- readonly rewriteFrontmatterRefs?: boolean;
183
- /**
184
- * Concept ids the caller will rewrite itself and the engine must therefore leave untouched (never
185
- * parsed, serialized, or emitted in the plan). `lore supersede` excludes the two **principals**
186
- * (it wires their frontmatter directly — repointing the successor's own link to its predecessor,
187
- * or the preserved old doc's self-link, would be wrong) and the machine-owned `index.md`/`log.md`
188
- * hubs (regenerated by `lore sync`, never hand-rewritten). Empty by default (`lore rename` rewrites
189
- * everything its graph reaches). If this set contains `fromId` itself in `move` mode, the returned
190
- * plan's `rename` is `null` too (LORE-164) — the caller owns that file entirely, including whether
191
- * it moves, so the engine cannot claim a move it never wrote the destination bytes for.
192
- */
193
- readonly exclude?: ReadonlySet<string>;
194
- /**
195
- * The active profile, forwarded to every {@link serializeConcept} call this engine makes so a
196
- * rewritten concept's re-emitted frontmatter validates against the SAME profile the caller's own
197
- * `loadBundle` used to read the bundle in the first place — never silently falling back to the
198
- * built-in default when the caller has a project-specific `.lore/profile.toml` (LORE-88). Also the
199
- * profile a caller should re-parse these bytes with afterward (e.g. `rename.ts`'s
200
- * `buildPostRenameGraph`), so a concept is never written under one profile and re-read under
201
- * another. Defaults to {@link serializeConcept}'s own default (the built-in profile), so a caller
202
- * that does not opt in is unaffected (LORE-88 AC#5).
203
- */
204
- readonly profile?: Profile;
205
- }
206
-
207
- /** The shared empty exclude set, so the common (no-exclude) path allocates nothing. */
208
- const NO_EXCLUDE: ReadonlySet<string> = new Set();
209
-
210
- /**
211
- * Compute the {@link RewritePlan} to repoint every reference to `fromId` onto `toId`.
212
- *
213
- * In `move` mode (`lore rename`) the concept relocates: `fromId` must exist, `toId` must not,
214
- * inbound references are repointed, and the moved file's own outbound links are recomputed for
215
- * its new directory. Without `move` (`lore supersede`) the concept stays put and only inbound
216
- * references are repointed.
217
- *
218
- * @throws LoreError `not_found` when `fromId` names no concept; `conflict` (move mode) when
219
- * `toId` already names a concept.
220
- */
221
- export function rewriteInbound(
222
- graph: BundleGraph,
223
- fromId: string,
224
- toId: string,
225
- options: RewriteInboundOptions = {},
226
- ): RewritePlan {
227
- const move = options.move ?? false;
228
- const rewriteRefs = options.rewriteFrontmatterRefs ?? true;
229
- const exclude = options.exclude ?? NO_EXCLUDE;
230
- const profile = options.profile;
231
- assertConfinedToBundle(fromId, "fromId");
232
- assertConfinedToBundle(toId, "toId");
233
- const from = idFromPath(fromId);
234
- const to = idFromPath(toId);
235
-
236
- const fromConcept = graph.concepts.get(from);
237
- if (fromConcept === undefined) {
238
- throw conceptNotInBundle(from);
239
- }
240
- if (move && graph.concepts.has(to)) {
241
- throw new LoreError(
242
- "conflict",
243
- `cannot rename to "${to}": a concept already exists there`,
244
- "choose an id/path that is not already taken, or supersede instead of rename",
245
- { id: to },
246
- );
247
- }
248
-
249
- const fromPath = fromConcept.path;
250
- const toPath = `${to}.md`;
251
-
252
- // Affected files: the moved concept (always, in move mode) plus every concept with an edge
253
- // pointing at it. A Set folds a file that both moves and self-references into one rewrite.
254
- const affected = new Set<string>();
255
- if (move) {
256
- affected.add(from);
257
- }
258
- for (const edge of graph.edges) {
259
- if (edge.to === from) {
260
- affected.add(edge.from);
261
- }
262
- }
263
-
264
- const writes: RewriteWrite[] = [];
265
- const textMismatches: LinkTextMismatch[] = [];
266
- for (const id of affected) {
267
- if (exclude.has(id)) {
268
- continue; // the caller rewrites this file itself — never parse, serialize, or plan it here
269
- }
270
- const concept = graph.concepts.get(id);
271
- if (concept === undefined) {
272
- continue; // an edge's `from` is always a real concept; this is unreachable belt-and-braces
273
- }
274
- const isMoved = move && id === from;
275
- const rewritten = rewriteConcept(concept, graph, { from, to, fromPath, toPath, isMoved, rewriteRefs }, profile);
276
- if (rewritten.bytes === null && !isMoved) {
277
- continue; // nothing changed in this file and it does not move — leave it untouched
278
- }
279
- const bytes = rewritten.bytes ?? serializeConcept(concept, { profile });
280
- writes.push({ path: isMoved ? toPath : concept.path, bytes });
281
- textMismatches.push(...rewritten.textMismatches);
282
- }
283
-
284
- writes.sort((a, b) => compareCodeUnits(a.path, b.path));
285
- // Stable sort: each file's own mismatches keep the document order they were collected in.
286
- textMismatches.sort((a, b) => compareCodeUnits(a.path, b.path));
287
- // `rename` must only claim the move when the source's own rewrite actually ran: if `exclude`
288
- // contains `from`, the loop above `continue`d past it before ever pushing its destination write
289
- // (the exclude contract — never parsed, serialized, or planned here), so a `rename` announcing the
290
- // move would leave `writes` with no entry at `toPath` for a caller to act on (LORE-164).
291
- const rename = move && !exclude.has(from) ? { from: fromPath, to: toPath } : null;
292
- return { rename, writes, textMismatches };
293
- }
294
-
295
- /**
296
- * Reject a `fromId`/`toId` that would resolve outside the `docs/` bundle root — closes the
297
- * traversal gap for every {@link rewriteInbound} caller (`lore rename`, `lore supersede`) at the
298
- * one shared layer both funnel through (LORE-80). Checked on the RAW caller-supplied value,
299
- * before {@link idFromPath} ever runs.
300
- *
301
- * An absolute path is checked against BOTH `posix.isAbsolute` and `win32.isAbsolute` — this ships
302
- * as a compiled binary for both platforms from the same source, so a Windows drive-letter id
303
- * (inert POSIX-side, genuinely absolute on a win32 run) must be rejected regardless of which
304
- * platform is running (the LORE-69 cross-platform-normalize convention).
305
- *
306
- * A relative escape is caught by {@link escapesRoot}, which walks segments split on EITHER `/` or
307
- * `\` — not just `posix.normalize`'s own forward-slash-only splitting. `idFromPath`'s
308
- * `posix.normalize` treats a backslash as an ordinary filename character, not a separator, so a
309
- * relative traversal spelled `..\pwned` survives it completely unchanged and would trip neither
310
- * `posix.isAbsolute` (it isn't) nor `win32.isAbsolute` (it's relative, not absolute — that check
311
- * only matches a drive-letter/UNC/leading-separator *absolute* form). Yet the command layer's
312
- * eventual write (`commands/rename.ts`, via the platform-native `path.join`) treats `\` as a
313
- * separator on an actual Windows run, so `..\pwned` is exactly as real an escape there as
314
- * `../pwned` is everywhere else — this check must catch it on every platform it runs on, not only
315
- * the one it happens to be compiled for.
316
- */
317
- function assertConfinedToBundle(id: string, label: "fromId" | "toId"): void {
318
- if (posix.isAbsolute(id) || win32.isAbsolute(id) || escapesRoot(id) || isDriveRelative(id) || resolvesToRoot(id)) {
319
- throw new LoreError(
320
- "validation",
321
- `${label} "${id}" resolves outside the docs/ bundle root`,
322
- "pass an id that stays inside the docs/ bundle",
323
- { id },
324
- );
325
- }
326
- }
327
-
328
- /**
329
- * Whether `id` is a Windows drive-relative reference — a drive letter and colon with **no**
330
- * following separator (e.g. `"C:foo"`, real Windows syntax meaning "relative to that drive's
331
- * current directory"). Distinct from an absolute `"C:\\foo"`/`"C:/foo"` form, which
332
- * `win32.isAbsolute` already rejects: `win32.isAbsolute("C:foo")` is `false` (Node correctly
333
- * implements this Windows quirk), and `posix.isAbsolute("C:foo")` is also `false`, so nothing
334
- * else {@link assertConfinedToBundle} already checks catches this shape (LORE-95). Exported so
335
- * `commands/rename.ts`'s own argument-parsing-layer guard can reuse this exact check, mirroring
336
- * how {@link escapesRoot} itself is already shared rather than re-derived per call site.
337
- */
338
- export function isDriveRelative(id: string): boolean {
339
- return /^[A-Za-z]:(?![\\/])/.test(id);
340
- }
341
-
342
- /**
343
- * Whether `id` normalizes to the bundle root itself — an empty string, `"."`, or a self-cancelling
344
- * relative path like `"sub/.."` whose segments net out to zero remaining depth. {@link escapesRoot}
345
- * alone doesn't catch this: none of these ever climb *above* where they started (the property
346
- * `escapesRoot` checks) — they simply cancel out to nothing. Left uncaught, `idFromPath`'s
347
- * `posix.normalize` folds any of these to `"."`, and a caller building `` `${to}.md` `` from that
348
- * gets the literal string `"..md"` — a hidden dotfile silently created at the bundle root instead
349
- * of a rejection (LORE-95). Shares `escapesRoot`'s own segment-walk convention (split on either
350
- * `/` or `\`) for the identical cross-platform reason documented there; a segment that merely
351
- * *starts with* `".."` (e.g. `"..foo"`) is a real, non-cancelling segment and does not count
352
- * towards depth going down, matching `escapesRoot`'s own exact-match care. Exported for reuse by
353
- * `commands/rename.ts`'s own argument-parsing-layer guard, mirroring how `escapesRoot` itself is
354
- * already shared.
355
- */
356
- export function resolvesToRoot(id: string): boolean {
357
- let depth = 0;
358
- for (const segment of id.split(/[\\/]+/)) {
359
- if (segment === "" || segment === ".") {
360
- continue;
361
- }
362
- if (segment === "..") {
363
- // A genuine climb-above-start is escapesRoot's own concern, already checked separately;
364
- // clamping at 0 here keeps this function correct standalone regardless of call order.
365
- depth = Math.max(0, depth - 1);
366
- } else {
367
- depth++;
368
- }
369
- }
370
- return depth === 0;
371
- }
372
-
373
- /**
374
- * Whether walking `id`'s segments — split on a run of either `/` or `\`, since either can act as
375
- * a separator depending on which platform's binary eventually resolves it (see
376
- * {@link assertConfinedToBundle}) — climbs above the directory `id` starts in. A literal `..`
377
- * segment always means "parent directory": no real filesystem, on either platform, permits a
378
- * file or directory literally named `..`, so this can never misfire on a genuine bundle id — nor
379
- * reject a real segment that merely *starts* with `..` (e.g. `..foo/bar`), since that segment
380
- * does not equal `..` exactly (mirrors `new.ts`'s `resolveOutPath`'s own documented care).
381
- *
382
- * Exported so a command layer can reuse this exact, already-review-tested traversal check for its
383
- * own defense-in-depth confinement guard (e.g. `commands/rename.ts`'s `newId`, LORE-79) instead of
384
- * re-deriving the same security-sensitive segment walk a second time.
385
- */
386
- export function escapesRoot(id: string): boolean {
387
- let depth = 0;
388
- for (const segment of id.split(/[\\/]+/)) {
389
- if (segment === "" || segment === ".") {
390
- continue;
391
- }
392
- if (segment === "..") {
393
- if (depth === 0) {
394
- return true;
395
- }
396
- depth--;
397
- } else {
398
- depth++;
399
- }
400
- }
401
- return false;
402
- }
403
-
404
- /** The resolved coordinates one concept's rewrite needs (`from`/`to` are normalized ids). */
405
- interface RewriteContext {
406
- readonly from: string;
407
- readonly to: string;
408
- readonly fromPath: string;
409
- readonly toPath: string;
410
- /** Whether this concept is the relocating one (recompute all its outbound links). */
411
- readonly isMoved: boolean;
412
- /** Whether to repoint `specs`/`supersedes`/`superseded_by` frontmatter refs (false for `lore supersede`). */
413
- readonly rewriteRefs: boolean;
414
- }
415
-
416
- /** {@link rewriteConcept}'s result: the new bytes (`null` when nothing changed) plus any {@link LinkTextMismatch}es found. */
417
- interface ConceptRewrite {
418
- readonly bytes: string | null;
419
- readonly textMismatches: readonly LinkTextMismatch[];
420
- }
421
-
422
- /**
423
- * Rewrite one affected concept, returning its new serialized bytes (`null` when nothing in it
424
- * changed — no body destination and no frontmatter ref needed rewriting) plus every
425
- * {@link LinkTextMismatch} its body edits surfaced. The moved file always yields bytes from its
426
- * caller even on a `null` here, because it still relocates.
427
- */
428
- function rewriteConcept(
429
- concept: Concept,
430
- graph: BundleGraph,
431
- ctx: RewriteContext,
432
- profile: Profile | undefined,
433
- ): ConceptRewrite {
434
- const dir = posix.dirname(concept.path);
435
- const { edits, textMismatches } = computeBodyEdits(concept.body, concept.path, ctx, graph.concepts);
436
- const newBody = applyBodyEdits(concept.body, edits);
437
-
438
- // `lore supersede` (rewriteRefs=false) preserves the old file, so a third party's frontmatter ref
439
- // to it stays a valid historical record — only body links are redirected to the successor.
440
- const newFrontmatter = ctx.rewriteRefs ? remapFrontmatterRefs(concept.frontmatter, dir, graph, ctx) : null;
441
- const frontmatterChanged = newFrontmatter !== null;
442
-
443
- if (newBody === concept.body && !frontmatterChanged) {
444
- return { bytes: null, textMismatches: [] };
445
- }
446
- const next: Concept = {
447
- ...concept,
448
- frontmatter: frontmatterChanged ? newFrontmatter : concept.frontmatter,
449
- body: newBody,
450
- };
451
- return { bytes: serializeConcept(next, { profile }), textMismatches };
452
- }
453
-
454
- // ── Body link rewriting ──────────────────────────────────────────────────────────
455
-
456
- /** A planned destination splice: the source byte range to replace and its replacement text. */
457
- interface BodyEdit extends ByteRange {
458
- readonly dest: string;
459
- }
460
-
461
- /** {@link computeBodyEdits}'s result: the destination splices plus any {@link LinkTextMismatch}es they surfaced. */
462
- interface BodyEditsResult {
463
- readonly edits: BodyEdit[];
464
- readonly textMismatches: LinkTextMismatch[];
465
- }
466
-
467
- /**
468
- * Compute every destination splice for one concept body. The body is parsed once; each inline
469
- * link and each *used* reference definition (a definition an actual `linkReference` points at,
470
- * mirroring how the bundle graph counts edges — an orphan definition is not an edge) is a
471
- * candidate. For the moved file, every internal link is recomputed against the new directory; for
472
- * an inbound file, only links that resolve to `fromId` are repointed. An edit is emitted only when
473
- * the new destination differs from the authored bytes, so a canonical link the move leaves in
474
- * place produces no churn.
475
- *
476
- * For an **inbound** file (`!ctx.isMoved`) — the code path both `lore supersede --rewrite-links`
477
- * and `lore rename` funnel every *other* affected concept through — each emitted edit is also
478
- * checked against {@link textNamesOldId}: if the link's visible display text still names the OLD
479
- * id, a {@link LinkTextMismatch} is recorded alongside it. The destination is retargeted exactly
480
- * as before either way (LORE-262 AC#2: no regression) — this only adds advisory data the caller
481
- * surfaces, never changes what gets rewritten. The moved file's own outbound links are exempt: a
482
- * self-link's destination follows the file to its new location by construction, and most of the
483
- * candidates recomputed there aren't edges to `fromId` at all (see {@link newDestPathFor}), so
484
- * "does this text name the old id" isn't a meaningful question to ask there.
485
- */
486
- function computeBodyEdits(
487
- body: string,
488
- conceptPath: string,
489
- ctx: RewriteContext,
490
- byId: ReadonlyMap<string, Concept>,
491
- ): BodyEditsResult {
492
- const tree = fromMarkdown(body);
493
- const links: Nodes[] = [];
494
- const usedIdentifiers = new Set<string>();
495
- const firstDefinition = new Map<string, Nodes>();
496
- const allDefinitions: Nodes[] = [];
497
- // The visible text of a reference-style link lives on its `linkReference` node, not on the
498
- // `definition` it resolves to — collected here, first occurrence wins, while the tree is walked
499
- // anyway for `usedIdentifiers` (LORE-262: needed only to check a definition-candidate's text
500
- // below, but cheap to always collect).
501
- const linkRefText = new Map<string, string>();
502
- walkMdast(tree, (node) => {
503
- if (node.type === "link") {
504
- links.push(node);
505
- } else if (node.type === "linkReference") {
506
- usedIdentifiers.add(node.identifier);
507
- if (!linkRefText.has(node.identifier)) {
508
- linkRefText.set(node.identifier, nodeText(node));
509
- }
510
- } else if (node.type === "definition") {
511
- allDefinitions.push(node);
512
- if (!firstDefinition.has(node.identifier)) {
513
- firstDefinition.set(node.identifier, node); // first definition wins, as CommonMark resolves
514
- }
515
- }
516
- });
517
-
518
- // Candidate destinations in document order: inline links, then reference definitions. For the
519
- // moved file *every* definition is recomputed (it moves with the file, even an orphan/unused one
520
- // that renders as nothing); for an inbound file only the **used** definitions are edges, so an
521
- // orphan definition is left alone — mirroring how the bundle graph counts edges.
522
- const candidates: Array<{ node: Nodes; isDefinition: boolean }> = links.map((node) => ({
523
- node,
524
- isDefinition: false,
525
- }));
526
- if (ctx.isMoved) {
527
- for (const def of allDefinitions) {
528
- candidates.push({ node: def, isDefinition: true });
529
- }
530
- } else {
531
- for (const identifier of usedIdentifiers) {
532
- const def = firstDefinition.get(identifier);
533
- if (def !== undefined) {
534
- candidates.push({ node: def, isDefinition: true });
535
- }
536
- }
537
- }
538
-
539
- // Only an inbound file's retargets are checked for a stale text citation (see the doc comment
540
- // above) — computed once, not per-candidate.
541
- const oldIdCandidates = ctx.isMoved ? null : oldIdNameCandidates(ctx.from);
542
-
543
- const edits: BodyEdit[] = [];
544
- const textMismatches: LinkTextMismatch[] = [];
545
- for (const { node, isDefinition } of candidates) {
546
- const newPath = newDestPathFor((node as { url: string }).url, conceptPath, ctx, byId);
547
- if (newPath === null) {
548
- continue; // external/non-.md, or (inbound) not a link to the renamed concept
549
- }
550
- const range = isDefinition ? destRangeForDefinition(body, node) : destRangeForLink(body, node);
551
- if (range === null) {
552
- continue; // defensive: a well-formed link/definition always locates, but never corrupt on a miss
553
- }
554
- // Append the destination's #fragment/?query taken from the **source** bytes (not the decoded
555
- // node.url), so an authored fragment — even one carrying escapes — is preserved byte-for-byte
556
- // (AC#3); only the path portion is replaced with the canonical form.
557
- const src = body.slice(range.start, range.end);
558
- const newDest = newPath + sourceSuffix(src);
559
- if (src === newDest) {
560
- continue; // already the canonical bytes — no edit (the move left this link in place)
561
- }
562
- edits.push({ start: range.start, end: range.end, dest: newDest });
563
-
564
- if (oldIdCandidates !== null) {
565
- // Reference-style: the visible text is the linkReference's, looked up by identifier — a
566
- // definition candidate here always has one (only *used* definitions are candidates when
567
- // `!ctx.isMoved`), so this lookup cannot miss. Inline: the text lives on the link node itself.
568
- const text = isDefinition ? (linkRefText.get((node as { identifier: string }).identifier) ?? "") : nodeText(node);
569
- if (textNamesOldId(text, oldIdCandidates)) {
570
- textMismatches.push({ path: conceptPath, text, from: ctx.from, to: ctx.to });
571
- }
572
- }
573
- }
574
- return { edits, textMismatches };
575
- }
576
-
577
- /**
578
- * Candidate substrings a link's visible text might contain to be "naming" `fromId` (LORE-262): the
579
- * bare id itself, its basename, and — for an `NNNN-slug` id (lore's ADR/RFC-style numbering
580
- * convention, e.g. `adr/0005-cli-contract`) — the bare digits and `<dirname>-<digits>` (so a prose
581
- * citation like `"ADR-0005"` is caught even though the id's own basename is `0005-cli-contract`,
582
- * not `adr-0005`). This is a deliberately pragmatic heuristic, not a full NLP match: it cannot
583
- * catch every phrasing (a citation with no digits at all, an id with no numeric prefix referenced
584
- * only by an unrelated-looking title) — see {@link textNamesOldId} for how each candidate is
585
- * matched against the text.
586
- */
587
- function oldIdNameCandidates(fromId: string): string[] {
588
- const candidates = new Set<string>([fromId]);
589
- const base = posix.basename(fromId);
590
- candidates.add(base);
591
- const numeric = base.match(/^(\d+)-/);
592
- if (numeric?.[1] !== undefined) {
593
- const digits = numeric[1];
594
- candidates.add(digits);
595
- const dir = posix.basename(posix.dirname(fromId));
596
- if (dir !== "" && dir !== ".") {
597
- candidates.add(`${dir}-${digits}`);
598
- }
599
- }
600
- // A short candidate (e.g. a one/two-character id or digit run) is dropped: matching it against
601
- // arbitrary prose would produce far more noise than signal.
602
- return [...candidates].filter((c) => c.length >= 3);
603
- }
604
-
605
- /**
606
- * Whether `text` names one of `candidates` — a case-insensitive check, applied as a plain
607
- * substring for a candidate containing `/` (a full bundle id, which essentially never collides
608
- * with unrelated prose) and as a **word-boundary** regex match otherwise (a bare id/digits/slug
609
- * candidate, which — unlike a full id — could otherwise false-positive inside a longer unrelated
610
- * word or number).
611
- */
612
- function textNamesOldId(text: string, candidates: readonly string[]): boolean {
613
- const lower = text.toLowerCase();
614
- for (const candidate of candidates) {
615
- if (candidate.includes("/")) {
616
- if (lower.includes(candidate.toLowerCase())) {
617
- return true;
618
- }
619
- continue;
620
- }
621
- if (new RegExp(`\\b${escapeRegExp(candidate)}\\b`, "i").test(text)) {
622
- return true;
623
- }
624
- }
625
- return false;
626
- }
627
-
628
- /** Escape every regex metacharacter in `s`, so a candidate built from arbitrary id text is safe to interpolate into a `RegExp`. */
629
- function escapeRegExp(s: string): string {
630
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
631
- }
632
-
633
- /**
634
- * The canonical **path** (no `#fragment`/`?query`) one link should point at after the rename, or
635
- * `null` when the link is not a target: external, non-`.md`, or — for an inbound file — not a link
636
- * to the renamed concept. Classification and id derivation reuse the bundle graph's own
637
- * {@link internalTarget}/{@link resolvePath} — including `resolvePath`'s `/`-absolute special
638
- * case (leading `/` strips and resolves against the bundle root, rather than joining onto `dir`
639
- * like a relative segment) — so this rewrites exactly the edges the graph counts, and a `/`-absolute
640
- * link is never mis-classified against a same-string decoy that merely happens to sit at the
641
- * dir-joined path (LORE-180).
642
- */
643
- function newDestPathFor(
644
- url: string,
645
- conceptPath: string,
646
- ctx: RewriteContext,
647
- byId: ReadonlyMap<string, Concept>,
648
- ): string | null {
649
- const decoded = internalTarget(url);
650
- if (decoded === null) {
651
- return null; // external, anchor-only, or non-.md — not an internal concept link
652
- }
653
- const dir = posix.dirname(conceptPath);
654
- const targetId = resolvePath(decoded, dir, byId);
655
-
656
- // normalizeLink's precondition is the repo-relative coordinate space (both operands rooted at
657
- // the repo, `docs/…`), not the bundle-relative one `conceptPath`/`ctx.to` use as graph ids. The
658
- // two coincide for a link that stays inside the bundle (a constant `docs/` prefix cancels out of
659
- // a relative-path computation), which is why this only ever surfaced for a link that escapes the
660
- // bundle root — e.g. a Story's managed task block linking `backlog/tasks/…` (LORE-68): resolving
661
- // `decoded`/`ctx.toPath` in bundle-relative space silently drops the `docs/` hop, truncating the
662
- // rewritten link by one `../` segment.
663
- const repoDir = `${DOCS_DIR}/${dir}`;
664
- const repoToPath = `${DOCS_DIR}/${ctx.toPath}`;
665
-
666
- if (ctx.isMoved) {
667
- // The file moves: recompute the link against its new directory. A self-link follows the file
668
- // to its new path; any other target keeps its location (pure path arithmetic, so a dangling
669
- // link is corrected too) — joined with the same leading-`/`-aware rule as `targetId` above, so
670
- // a `/`-absolute sibling link isn't mis-resolved into the file's old directory (LORE-180: plain
671
- // `posix.join(repoDir, decoded)` does NOT treat an absolute second segment specially — it just
672
- // concatenates — so this must special-case it explicitly rather than lean on `posix.join`).
673
- const targetPath =
674
- targetId === ctx.from
675
- ? repoToPath
676
- : posix.normalize(decoded.startsWith("/") ? `${DOCS_DIR}/${decoded.slice(1)}` : posix.join(repoDir, decoded));
677
- // A link authored with more `../` segments than the file's depth resolves ABOVE the repo
678
- // root: `targetPath` (already normalized, repo-relative) equals `..` or starts with `../`.
679
- // normalizeLink roots both operands at a fixed virtual `/` (links.ts) and would silently
680
- // clamp that surplus `..`, quietly retargeting an already-non-portable link rather than
681
- // preserving it. Bail out with no edit instead, so the original authored bytes survive the
682
- // move verbatim (the `decoded.startsWith("/")` sub-branch above always yields a `docs/…`
683
- // path and can never trigger this).
684
- if (targetPath === ".." || targetPath.startsWith("../")) {
685
- return null;
686
- }
687
- return normalizeLink(repoToPath, targetPath);
688
- }
689
- if (targetId !== ctx.from) {
690
- return null; // an inbound file only repoints links to the renamed concept
691
- }
692
- // An inbound link is recomputed from *this* file's location to the moved concept's new path.
693
- return normalizeLink(`${DOCS_DIR}/${conceptPath}`, repoToPath);
694
- }
695
-
696
- /**
697
- * The `#fragment`/`?query` suffix of a source destination token, preserved verbatim: the
698
- * angle-bracket `<…>` wrapper (which the canonical form drops) is stripped, then everything from
699
- * the first `#`/`?` onward is returned — the authored bytes, never the decoded `node.url`.
700
- */
701
- function sourceSuffix(src: string): string {
702
- const inner = src.startsWith("<") && src.endsWith(">") ? src.slice(1, -1) : src;
703
- const hash = inner.indexOf("#");
704
- const question = inner.indexOf("?");
705
- // The first of the two delimiters that is present (a `?` living inside a `#fragment` is not a query).
706
- const cut = hash < 0 ? question : question < 0 ? hash : Math.min(hash, question);
707
- return cut < 0 ? "" : inner.slice(cut);
708
- }
709
-
710
- /** Apply destination splices right-to-left (descending start) so earlier edits don't shift later offsets. */
711
- function applyBodyEdits(body: string, edits: BodyEdit[]): string {
712
- if (edits.length === 0) {
713
- return body;
714
- }
715
- const ordered = [...edits].sort((a, b) => b.start - a.start);
716
- let result = body;
717
- for (const edit of ordered) {
718
- result = result.slice(0, edit.start) + edit.dest + result.slice(edit.end);
719
- }
720
- return result;
721
- }
722
-
723
- // ── Destination range location (structural, not a text search) ─────────────────────
724
-
725
- /**
726
- * The `[start, end)` byte range of an **inline** link's destination token (`[label](DEST)` or
727
- * `[label](<DEST>)`, with an optional `"title"` after `DEST`), located structurally from the
728
- * link node's offsets — because `node.url` is not byte-equal to the source (see the module
729
- * header). Returns the full token including a `<…>` wrapper (so it is replaced by the canonical,
730
- * wrapper-free form) and any `#fragment`/`?query`, but excludes a following `"title"`. `null` if
731
- * the structure cannot be located (never expected for a parsed link).
732
- */
733
- function destRangeForLink(body: string, node: Nodes): ByteRange | null {
734
- const span = positionOf(node);
735
- if (span === null) {
736
- return null;
737
- }
738
- // End of the label content: the last child's end offset, or just past `[` for an empty label.
739
- const contentEnd = maxChildEnd(node) ?? span.start + 1;
740
- const rb = body.indexOf("]", contentEnd); // the label's closing `]`
741
- if (rb === -1 || body[rb + 1] !== "(") {
742
- return null;
743
- }
744
- return scanDestination(body, rb + 2, span.end, true);
745
- }
746
-
747
- /**
748
- * The `[start, end)` byte range of a **reference definition**'s destination (`[label]: DEST
749
- * "title"`), located structurally from the node's offsets. Unlike an inline link, the raw form
750
- * has no enclosing `()`, so the destination ends at whitespace (a following title) or the node's
751
- * end — not at a closing paren. `null` if the structure cannot be located.
752
- *
753
- * Unlike {@link destRangeForLink}, a `definition` node carries no parsed `children` to derive the
754
- * label's end from structurally (mdast gives only its decoded `identifier`/`label` strings, whose
755
- * lengths are not byte-equal to the raw source once escapes are involved — the same reason
756
- * `node.url` can't drive a text search, see the module header) — so the closing `]` is located by
757
- * an escape-aware raw scan ({@link findLabelClose}) instead of a plain `indexOf`, which would
758
- * match an escaped `\]` *inside* the label rather than the real closing bracket (LORE-87).
759
- */
760
- function destRangeForDefinition(body: string, node: Nodes): ByteRange | null {
761
- const span = positionOf(node);
762
- if (span === null) {
763
- return null;
764
- }
765
- const rb = findLabelClose(body, span.start + 1, span.end); // the label's closing `]`
766
- if (rb === -1) {
767
- return null;
768
- }
769
- const colon = body.indexOf(":", rb);
770
- if (colon === -1) {
771
- return null;
772
- }
773
- return scanDestination(body, colon + 1, span.end, false);
774
- }
775
-
776
- /**
777
- * The index of a reference definition's label-closing `]`, scanned from just after the opening
778
- * `[` (`from`) and honoring `\` escapes — the same escape convention {@link scanDestination}
779
- * applies to a destination, so `\]` inside the label is skipped rather than mistaken for the real
780
- * closing bracket (LORE-87). `-1` if none is found before `end`.
781
- */
782
- function findLabelClose(body: string, from: number, end: number): number {
783
- let j = from;
784
- while (j < end) {
785
- if (body[j] === "\\") {
786
- j += 2;
787
- continue;
788
- }
789
- if (body[j] === "]") {
790
- return j;
791
- }
792
- j++;
793
- }
794
- return -1;
795
- }
796
-
797
- /**
798
- * Scan a link destination starting at `from` (just after `(` for an inline link, or after `:`
799
- * for a definition), bounded by `end` (the node's end offset). Leading whitespace is skipped;
800
- * then either an angle-bracket `<…>` form (scanned to its closing `>`, honoring `\` escapes) or
801
- * a raw form (honoring `\` escapes and — for an inline link — balanced `()`, stopping at
802
- * unescaped whitespace before a title, or at the inline link's closing `)`). `inline` selects
803
- * whether an unescaped depth-0 `)` ends the destination.
804
- */
805
- function scanDestination(body: string, from: number, end: number, inline: boolean): ByteRange | null {
806
- let i = from;
807
- while (i < end && isWhitespace(body[i])) {
808
- i++;
809
- }
810
- if (i >= end) {
811
- return null;
812
- }
813
- const start = i;
814
- if (body[i] === "<") {
815
- let j = i + 1;
816
- while (j < end && body[j] !== ">") {
817
- j += body[j] === "\\" ? 2 : 1;
818
- }
819
- return { start, end: Math.min(j + 1, end) }; // include the closing `>`
820
- }
821
- let depth = 0;
822
- let j = i;
823
- while (j < end) {
824
- const c = body[j];
825
- if (c === "\\") {
826
- j += 2;
827
- continue;
828
- }
829
- if (isWhitespace(c)) {
830
- break; // a title (or nothing) follows
831
- }
832
- if (c === "(") {
833
- depth++;
834
- } else if (c === ")") {
835
- if (inline && depth === 0) {
836
- break; // the inline link's closing `)`
837
- }
838
- if (depth > 0) {
839
- depth--;
840
- }
841
- }
842
- j++;
843
- }
844
- return { start, end: Math.min(j, end) };
845
- }
846
-
847
- /** Whether `c` is ASCII whitespace that bounds a link destination (space, tab, newline, CR, FF). */
848
- function isWhitespace(c: string | undefined): boolean {
849
- return c === " " || c === "\t" || c === "\n" || c === "\r" || c === "\f";
850
- }
851
-
852
- /** The max end offset among a node's direct children, or `null` when it has none (an empty label). */
853
- function maxChildEnd(node: Nodes): number | null {
854
- if (!("children" in node) || node.children.length === 0) {
855
- return null;
856
- }
857
- let max = 0;
858
- let seen = false;
859
- for (const child of node.children) {
860
- const span = positionOf(child);
861
- if (span !== null) {
862
- max = Math.max(max, span.end);
863
- seen = true;
864
- }
865
- }
866
- return seen ? max : null;
867
- }
868
-
869
- /** A node's `[start, end)` source offsets, or `null` when position info is absent (defensive). */
870
- function positionOf(node: Nodes): ByteRange | null {
871
- const position = node.position;
872
- if (position?.start.offset === undefined || position.end.offset === undefined) {
873
- return null;
874
- }
875
- return { start: position.start.offset, end: position.end.offset };
876
- }
877
-
878
- // ── Frontmatter ref rewriting ──────────────────────────────────────────────────────
879
-
880
- /**
881
- * Return a new frontmatter object with the `specs`/`supersedes`/`superseded_by` refs rewritten, or
882
- * `null` when none changed. A field may be a single ref or a list — both shapes are preserved, and
883
- * a non-matching item (including a non-string producer value) is copied verbatim. The original
884
- * object is never mutated (the graph snapshot stays intact). The ref fields iterated are the
885
- * graph's own {@link REF_FIELDS}, so a future ref kind cannot be added as an edge without also
886
- * being rewritten here.
887
- */
888
- function remapFrontmatterRefs(
889
- frontmatter: Record<string, unknown>,
890
- dir: string,
891
- graph: BundleGraph,
892
- ctx: RewriteContext,
893
- ): Record<string, unknown> | null {
894
- let changed = false;
895
- const next: Record<string, unknown> = { ...frontmatter };
896
- for (const field of REF_FIELDS) {
897
- const value = frontmatter[field];
898
- if (value === undefined) {
899
- continue;
900
- }
901
- if (Array.isArray(value)) {
902
- const mapped = value.map((item) => remapRefItem(item, dir, graph, ctx));
903
- if (mapped.some((item, i) => item !== value[i])) {
904
- next[field] = mapped;
905
- changed = true;
906
- }
907
- } else {
908
- const mapped = remapRefItem(value, dir, graph, ctx);
909
- if (mapped !== value) {
910
- next[field] = mapped;
911
- changed = true;
912
- }
913
- }
914
- }
915
- return changed ? next : null;
916
- }
917
-
918
- /**
919
- * Rewrite one frontmatter ref item, reusing the bundle graph's own {@link resolveRef} (which trims
920
- * and classifies exactly as the edge collector does, so this repoints exactly the refs the graph
921
- * counts). A non-string or a dangling ref is returned unchanged. For an **inbound** file only a ref
922
- * that resolves to the renamed concept is rewritten (to the bare-id `to`). For the **moved** file
923
- * every resolving ref is canonicalized to its bare id — which both repoints a self-ref to `to` and
924
- * corrects a relative-path ref to *another* concept that the move would otherwise leave pointing at
925
- * the wrong location (the body-link recompute does the analogous fix); an already-bare-id ref maps
926
- * to itself, so it is left byte-identical.
927
- */
928
- function remapRefItem(item: unknown, dir: string, graph: BundleGraph, ctx: RewriteContext): unknown {
929
- if (typeof item !== "string") {
930
- return item;
931
- }
932
- const resolved = resolveRef(item, dir, graph.concepts);
933
- if (resolved === null) {
934
- return item; // external or dangling — not a concept ref to repoint
935
- }
936
- if (!ctx.isMoved) {
937
- return resolved === ctx.from ? ctx.to : item;
938
- }
939
- return resolved === ctx.from ? ctx.to : resolved; // moved file: canonicalize to bare id
940
- }