@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,940 @@
|
|
|
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
|
+
}
|