@opum-ai/lore 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +33 -22
  2. package/bin/lore.cjs +35 -7
  3. package/package.json +17 -17
  4. package/src/adapters/backlog.ts +0 -1084
  5. package/src/adapters/git.ts +0 -221
  6. package/src/cli.ts +0 -667
  7. package/src/commands/agent.ts +0 -301
  8. package/src/commands/agents.ts +0 -302
  9. package/src/commands/args.ts +0 -209
  10. package/src/commands/changed.ts +0 -70
  11. package/src/commands/check.ts +0 -1031
  12. package/src/commands/codex-bridge.ts +0 -49
  13. package/src/commands/concurrency.ts +0 -48
  14. package/src/commands/context.ts +0 -292
  15. package/src/commands/discover.ts +0 -89
  16. package/src/commands/explorer.ts +0 -253
  17. package/src/commands/export.ts +0 -93
  18. package/src/commands/fswrite.ts +0 -928
  19. package/src/commands/graph.ts +0 -291
  20. package/src/commands/help.ts +0 -151
  21. package/src/commands/impact.ts +0 -59
  22. package/src/commands/init.ts +0 -583
  23. package/src/commands/instructions.ts +0 -91
  24. package/src/commands/link.ts +0 -929
  25. package/src/commands/new.ts +0 -476
  26. package/src/commands/orphans.ts +0 -457
  27. package/src/commands/path.ts +0 -67
  28. package/src/commands/provenance.ts +0 -68
  29. package/src/commands/query.ts +0 -312
  30. package/src/commands/reconcile-shared.ts +0 -280
  31. package/src/commands/rename.ts +0 -585
  32. package/src/commands/replace.ts +0 -320
  33. package/src/commands/scaffold.ts +0 -346
  34. package/src/commands/schema.ts +0 -293
  35. package/src/commands/snapshot.ts +0 -130
  36. package/src/commands/supersede.ts +0 -400
  37. package/src/commands/sync.ts +0 -371
  38. package/src/commands/tasks.ts +0 -271
  39. package/src/commands/traversal.ts +0 -151
  40. package/src/commands/validate.ts +0 -226
  41. package/src/config.ts +0 -598
  42. package/src/core/agent-bridge.ts +0 -287
  43. package/src/core/agent-context.ts +0 -498
  44. package/src/core/agent-profile.ts +0 -447
  45. package/src/core/bundle.ts +0 -893
  46. package/src/core/check.ts +0 -853
  47. package/src/core/codex-bridge.ts +0 -100
  48. package/src/core/concept.ts +0 -597
  49. package/src/core/consumer-scaffold.ts +0 -433
  50. package/src/core/context.ts +0 -271
  51. package/src/core/explorer-contract.ts +0 -441
  52. package/src/core/explorer-qualification.ts +0 -58
  53. package/src/core/explorer.ts +0 -518
  54. package/src/core/finding.ts +0 -31
  55. package/src/core/graph.ts +0 -201
  56. package/src/core/indexes.ts +0 -436
  57. package/src/core/instructions.ts +0 -209
  58. package/src/core/ladybug-driver.ts +0 -1795
  59. package/src/core/ladybug-lifecycle.ts +0 -1178
  60. package/src/core/ladybug-native.ts +0 -95
  61. package/src/core/ladybug-source.ts +0 -667
  62. package/src/core/links.ts +0 -681
  63. package/src/core/log.ts +0 -253
  64. package/src/core/managed-block.ts +0 -540
  65. package/src/core/manifest.ts +0 -718
  66. package/src/core/order.ts +0 -13
  67. package/src/core/profile.ts +0 -1007
  68. package/src/core/projection.ts +0 -195
  69. package/src/core/query.ts +0 -542
  70. package/src/core/reconcile.ts +0 -236
  71. package/src/core/replace.ts +0 -419
  72. package/src/core/retrieval.ts +0 -213
  73. package/src/core/rewrite.ts +0 -940
  74. package/src/core/scaffold.ts +0 -255
  75. package/src/core/schema.ts +0 -366
  76. package/src/core/snapshot-runtime.ts +0 -52
  77. package/src/core/snapshot-store.ts +0 -287
  78. package/src/core/snapshot.ts +0 -711
  79. package/src/core/template.ts +0 -429
  80. package/src/core/traversal.ts +0 -487
  81. package/src/core/validate.ts +0 -517
  82. package/src/core/workspace-contract.ts +0 -473
  83. package/src/core/workspace-projection.ts +0 -365
  84. package/src/core/workspace-retrieval.ts +0 -196
  85. package/src/core/workspace-source.ts +0 -174
  86. package/src/errors.ts +0 -697
  87. package/src/meta.ts +0 -7
  88. package/src/output.ts +0 -589
  89. package/src/scripts/upstream-backlog-watch.ts +0 -288
  90. package/src/state.ts +0 -390
@@ -1,893 +0,0 @@
1
- /**
2
- * bundle.ts — the whole `docs/` tree as one in-memory **bundle graph**.
3
- *
4
- * Where {@link parseConcept} turns a single file into a {@link Concept}, this
5
- * module turns the *directory* into a graph: it walks the bundle root, parses
6
- * every concept, and links them into nodes (concepts) and edges (concept→concept
7
- * references). That graph is the shared substrate every navigation/refactor
8
- * command reads — `query`, `context`, `graph`, `rename`, `supersede`, and the
9
- * drift computation behind `sync`/`check` (architecture §4, design §2.1).
10
- *
11
- * Two properties are load-bearing and pinned by the test suite:
12
- *
13
- * - **Deterministic** (design §8): the directory walk is sorted, symlinks are not
14
- * followed, and {@link buildGraph} sorts its input and emits edges in a fixed
15
- * order, so the same tree always produces the same `concepts`/`edges` — no
16
- * filesystem-order or input-order dependence.
17
- * - **Cycle-tolerant** (OKF §5: consumers tolerate any link shape): the graph is a
18
- * flat edge list built without traversal, so a link cycle (`A→B→A`), a
19
- * self-link, or a supersession loop loads fine; a link that resolves to no
20
- * concept is a **dangling** edge (`to: null`), never an error — surfacing broken
21
- * links is `lore check`'s job, not a parse failure.
22
- *
23
- * ### What is (and isn't) an edge
24
- *
25
- * Edges are **concept↔concept** only, from two sources:
26
- *
27
- * - **Body cross-links** — markdown links to another `.md` file in the bundle
28
- * ({@link EdgeKind} `"link"`), resolved **relative to the linking file's
29
- * directory** per the canonical lore link form (ADR-0010). Extraction defers to
30
- * a real CommonMark parser ({@link extractBodyTargets} via
31
- * `mdast-util-from-markdown`), so a link inside a code span or fenced/indented
32
- * code block is *not* an edge (it is not a link when rendered either), a
33
- * linked-image `[![alt](img)](target.md)` contributes its outer `target.md`, and
34
- * reference-style links resolve through their definition. Images, external
35
- * schemes (`http:`, `mailto:`…), bare `#anchors`, and non-`.md` targets are not
36
- * concept edges.
37
- * - **Frontmatter refs** — the `specs`, `supersedes`, and `superseded_by` fields,
38
- * which point at other concepts (design §2.1, §3.9). Each value yields an edge of
39
- * the matching {@link EdgeKind}; a value may be a bundle-relative id
40
- * (`adr/0009-x`) or a relative path (`../adr/0009-x.md`).
41
- *
42
- * The `tasks` frontmatter field is **deliberately not** an edge: it points at
43
- * Backlog.md task ids, not concepts (architecture §3, ADR-0009). It stays readable
44
- * on `concept.frontmatter.tasks`; the doc↔task coupling is reconcile/link's
45
- * concern, not the concept graph's.
46
- *
47
- * Per the core contract (design §2.1) this module is pure-ish library code: it
48
- * reads the filesystem (its one permitted side effect) and returns a typed
49
- * {@link BundleGraph} or throws a {@link LoreError}; it never prints, reads flags,
50
- * or exits. Advisory warnings (an unknown type, a skipped fence-less file, a
51
- * skipped symlink) flow to an optional {@link WarningCollector}.
52
- *
53
- * Scope note: this module builds and reads the graph. Generating the bundle's
54
- * `index.md`/`log.md` bytes is a separate concern that pairs with `lore
55
- * init`/`lore sync` and is not part of the graph layer.
56
- */
57
-
58
- import { type Dirent, readdirSync, readFileSync } from "node:fs";
59
- import { posix } from "node:path";
60
- import type { Nodes } from "mdast";
61
- import { fromMarkdown } from "mdast-util-from-markdown";
62
- import { deriveMessage, ioError, LoreError, type WarningCollector } from "../errors";
63
- import { type Concept, idFromPath, serializeConcept, tryParseConcept } from "./concept";
64
- import { decodeTarget, isExternalTarget, pathPart } from "./links";
65
- import { compareCodeUnits } from "./order";
66
- import { defaultProfile, type Profile } from "./profile";
67
- import { RESERVED_STEMS } from "./scaffold";
68
-
69
- // Bun.gc(true) is synchronous. Large projection loads retain bounded cleanup
70
- // points without pausing once per small group of authored concepts.
71
- const BOUNDED_MEMORY_GC_CONCEPT_INTERVAL = 1024;
72
-
73
- /**
74
- * The kind of a concept→concept reference. `"link"` is a body markdown
75
- * cross-link; the rest mirror the frontmatter fields that carry concept
76
- * references (the names match the frontmatter keys for an obvious round-trip).
77
- */
78
- export type EdgeKind = "link" | "specs" | "supersedes" | "superseded_by";
79
-
80
- /**
81
- * One directed reference from one concept to another. `from` is always a concept
82
- * id in the bundle (it is the source file). `to` is the resolved target concept
83
- * id, or `null` when the reference **dangles** (points at no concept in the
84
- * bundle) — a tolerated quality signal, not an error. `target` is the reference as
85
- * resolution saw it — a body link's parsed destination (angle-bracket wrapper
86
- * removed, percent-encoding intact) or a frontmatter ref's trimmed value — kept for
87
- * link diagnostics. It is the resolved form, not necessarily the byte-exact source
88
- * (a rewrite re-reads the file's bytes rather than trusting this).
89
- */
90
- export interface Edge {
91
- /** The source concept id (always a real concept in the bundle). */
92
- readonly from: string;
93
- /** The resolved target concept id, or `null` when the reference dangles. */
94
- readonly to: string | null;
95
- /** The reference as parsed (link destination / frontmatter value). */
96
- readonly target: string;
97
- /** Which kind of reference produced this edge. */
98
- readonly kind: EdgeKind;
99
- }
100
-
101
- /**
102
- * The whole bundle as a graph: concepts by id, the flat edge list, and a token
103
- * estimator. `concepts` and `edges` are read-only views — the graph is a snapshot
104
- * of what was loaded, not a mutable store (refactors recompute a fresh graph).
105
- */
106
- export interface BundleGraph {
107
- /**
108
- * Every loaded concept, keyed by {@link Concept.id}, in ascending id order
109
- * (deterministic iteration). Excludes fence-less, non-concept markdown.
110
- */
111
- readonly concepts: ReadonlyMap<string, Concept>;
112
- /**
113
- * All concept→concept references, in deterministic order: by source concept
114
- * (ascending id), then that concept's frontmatter edges (`specs`, `supersedes`,
115
- * `superseded_by`) followed by its body links in document order.
116
- */
117
- readonly edges: readonly Edge[];
118
- /**
119
- * Optional precomputed undirected neighbor lookup. Persistent retrieval backends
120
- * can provide this while materializing their edge index so bounded graph and
121
- * context traversals do not rebuild the same O(E) adjacency map per command.
122
- * The returned order must match first appearance in {@link edges}.
123
- */
124
- readonly neighbors?: (id: string) => Iterable<string>;
125
- /**
126
- * A token-count **estimate** (chars/4 heuristic, not a real tokenizer) over the
127
- * canonical serialized bytes of one concept (`id` given) or the whole bundle
128
- * (`id` omitted). Throws `not_found` if `id` names no loaded concept. Results are
129
- * memoized, so the estimate is a **snapshot** taken at first request: the graph
130
- * presumes its concepts are the valid, unmutated ones it was built from (every
131
- * loaded concept is valid), and mutating a concept's frontmatter/body in place
132
- * after load is unsupported — a stale count, or (if mutated to an invalid shape)
133
- * a `validation` error from the underlying serialize, is then on the caller.
134
- */
135
- tokenEstimate(id?: string): number;
136
- }
137
-
138
- /** Options for {@link loadBundle}. */
139
- export interface LoadBundleOptions {
140
- /** Sink for advisory warnings (unknown type, extra keys, skipped fence-less/symlink files). */
141
- warnings?: WarningCollector;
142
- /**
143
- * The active profile every parsed concept's frontmatter is validated against; defaults to the
144
- * built-in {@link defaultProfile} (mirroring {@link ParseConceptOptions.profile}'s own default)
145
- * when omitted. A project declaring a custom `.lore/profile.toml` must pass its compiled
146
- * {@link Profile} here — the caller loads it (`loadProfile({ root })`) and forwards it, since this
147
- * module reads only the filesystem tree under `root`, never `.lore/` itself (LORE-84).
148
- */
149
- profile?: Profile;
150
- /** Internal large-snapshot mode that bounds transient parser allocations. */
151
- boundedMemory?: boolean;
152
- }
153
-
154
- /**
155
- * Load a `docs/` bundle from disk into a {@link BundleGraph}.
156
- *
157
- * Walks `root` for `.md` files ({@link walkMarkdown} — sorted, recursive, and
158
- * symlink-safe so a symlinked directory can never loop the walk), reads each, and
159
- * hands it to {@link tryParseConcept}, which draws the load-bearing distinction:
160
- *
161
- * - a file that is **not a concept** (no frontmatter, an empty fence, or a fence
162
- * holding a bare scalar/list — e.g. a hand-written `index.md`/`log.md`, or a doc
163
- * that merely opens with a `---` thematic break) returns `null` and is skipped —
164
- * warned about if a collector is provided, **unless** the file's stem is a
165
- * {@link RESERVED_STEMS} entry (`index`/`log`): those are lore's own
166
- * machine-generated hubs (`indexes.ts`/`log.ts` regenerate them wholesale, always
167
- * frontmatter-free below the bundle root), so skipping one is never a surprise
168
- * worth an advisory — every `loadBundle`-backed command warning about it trains
169
- * users to ignore the warning entirely (LORE-258). A genuinely unexpected
170
- * non-concept file (any other stem) still warns; only the two known-reserved
171
- * stems are silent, matching `lore check`'s own scan (which never parses through
172
- * `loadBundle` and so never raised this noise to begin with);
173
- * - a **malformed concept** (a frontmatter *mapping* that fails the lore profile,
174
- * or unparseable YAML) throws its `validation` {@link LoreError} (path included)
175
- * rather than being silently dropped.
176
- *
177
- * Drawing that line needs the YAML parsed (a prefix check cannot tell an HR from
178
- * real frontmatter), so each file is parsed exactly once here. "The lore profile"
179
- * above is {@link LoadBundleOptions.profile} when given, else the built-in default
180
- * (LORE-84) — every concept in one `loadBundle` call validates against the same
181
- * profile, so a project's custom `.lore/profile.toml` types/fields/enums are
182
- * honored only when the caller loads and forwards it.
183
- *
184
- * Concept ids are **bundle-root-relative** (e.g. `docs/adr/0010-x.md` →
185
- * `adr/0010-x`), which is the id space the relative cross-link form resolves
186
- * within. The parsed concepts are handed to {@link buildGraph}, so the same tree
187
- * always yields the same graph.
188
- *
189
- * @param root the bundle root directory (typically `docs/`).
190
- * @throws LoreError `not_found`/`denied` if `root` cannot be read; `validation` if
191
- * a fenced concept is malformed.
192
- */
193
- export function loadBundle(root: string, options: LoadBundleOptions = {}): BundleGraph {
194
- const profile = options.profile ?? defaultProfile();
195
- const concepts: Concept[] = [];
196
- for (const rel of walkMarkdown(root, options.warnings)) {
197
- // `rel` is bundle-root-relative, so tryParseConcept derives a bundle-relative id — and so is
198
- // the reserved root index it is judged against (LORE-192): see effectiveProfileFor.
199
- const concept = tryParseConcept(rel, readConcept(root, rel), {
200
- warnings: options.warnings,
201
- profile: effectiveProfileFor(rel, BUNDLE_ROOT_INDEX_PATH, profile),
202
- });
203
- if (concept === null) {
204
- // A known-reserved stem (index/log) skips silently — see the docstring above (LORE-258).
205
- if (!RESERVED_STEMS.has(posix.basename(rel, ".md"))) {
206
- options.warnings?.add(`skipping ${rel}: no frontmatter mapping, treated as a non-concept file`);
207
- }
208
- continue;
209
- }
210
- concepts.push(concept);
211
- if (options.boundedMemory === true && concepts.length % BOUNDED_MEMORY_GC_CONCEPT_INTERVAL === 0) Bun.gc(true);
212
- }
213
- return buildGraph(concepts);
214
- }
215
-
216
- /**
217
- * {@link loadBundle}'s own spelling of the reserved bundle-root index, in **its** path space:
218
- * bundle-root-relative (`"index.md"`), not `scaffold.ts`'s repo-relative {@link
219
- * import("./scaffold").ROOT_INDEX_PATH} (`"docs/index.md"`, threaded by `core/validate.ts`
220
- * instead — LORE-144). Every `loadBundle`-backed command (`graph`/`query`/`sync`/`link`/`context`/…)
221
- * joins its `root` argument from `DOCS_DIR` before calling in (e.g. `commands/graph.ts`), so
222
- * {@link walkMarkdown}'s own relative-path space always yields this bare stem for the one file
223
- * `scaffold.ts`'s `serializeStructuralConcept` ever writes there.
224
- */
225
- const BUNDLE_ROOT_INDEX_PATH = "index.md";
226
-
227
- /**
228
- * The {@link Profile} a concept at `path` is validated against while loading: `defaultProfile()`
229
- * when `path` names `rootIndexPath` — the bundle's one reserved, always-scaffolded structural
230
- * concept — else `profile` (the caller's active one) unchanged.
231
- *
232
- * `scaffold.ts`'s `serializeStructuralConcept` always **writes** the root index against the
233
- * built-in default profile — deliberately ignoring the active one, so a custom profile can never
234
- * break `lore init` (its own docstring). Judging that same file on **read** against the active
235
- * profile with no carve-out reintroduces the write/read asymmetry LORE-144 fixed for `lore
236
- * validate`: a profile that adds a required field to `Reference` makes a freshly scaffolded
237
- * bundle fail its very first `loadBundle`-backed command (`lore graph` et al., LORE-192), because
238
- * the file lore just wrote could never satisfy a schema it was never written against. Judging the
239
- * root index under the identical profile it was serialized with restores the write/read symmetry
240
- * every other concept already has (each is both written and read against the one active profile) —
241
- * the root index is simply pinned to a fixed profile on both sides, not left inconsistent between
242
- * them.
243
- *
244
- * Exported (and generalized over `rootIndexPath`, rather than hardcoding one spelling) so this one
245
- * algorithm serves both reserved-root carve-outs without letting them drift apart: `core/
246
- * validate.ts`'s `validateConceptText` threads its own **repo-relative** constant (`"docs/
247
- * index.md"`), while this module's {@link loadBundle} threads its own **bundle-relative** one
248
- * ({@link BUNDLE_ROOT_INDEX_PATH}, `"index.md"`) — `validate.ts` cannot spell `loadBundle`'s form
249
- * itself (it already imports {@link nodeText} from here, so the reverse import would cycle), so it
250
- * imports this function instead and supplies its own path-space constant.
251
- *
252
- * Scoped to exactly one path, not the whole `RESERVED_STEMS` family (`index`/`log`): every *other*
253
- * reserved file — a sub-directory `index.md`, `log.md` — is generated frontmatter-free
254
- * (`indexes.ts`/`log.ts`), so `tryParseConcept` already treats it as a skipped non-concept and it
255
- * never reaches a profile-driven check in the first place. The bundle-root index is the only
256
- * reserved file that is itself a concept.
257
- */
258
- export function effectiveProfileFor(path: string, rootIndexPath: string, profile: Profile): Profile {
259
- return path === rootIndexPath ? defaultProfile() : profile;
260
- }
261
-
262
- /**
263
- * Build a {@link BundleGraph} from already-parsed concepts — the pure core of the
264
- * bundle layer, independent of the filesystem so the graph's determinism and
265
- * cycle-tolerance are testable in isolation.
266
- *
267
- * Concepts are sorted by id (so input order can't leak into the output), indexed,
268
- * then every concept's references are resolved against that index: a reference to
269
- * a known id becomes a resolved {@link Edge}, an unknown one a dangling edge
270
- * (`to: null`). No graph traversal happens here, so any cycle among the references
271
- * is harmless.
272
- *
273
- * `buildGraph` expects already-validated concepts — exactly what
274
- * {@link parseConcept}/{@link tryParseConcept} produce. It does not re-validate
275
- * (the frontmatter boundary already did, and re-running Zod here would double the
276
- * cost on the load path); a caller that hand-builds an invalid {@link Concept} and
277
- * inserts it is breaking the type's contract, and a later serialize-path operation
278
- * (e.g. {@link BundleGraph.tokenEstimate}) may then surface a `validation` error.
279
- *
280
- * @throws LoreError `conflict` if two concepts share an id (only reachable with
281
- * hand-built input — a single {@link loadBundle} walk yields unique ids).
282
- */
283
- export function buildGraph(concepts: readonly Concept[]): BundleGraph {
284
- const byId = new Map<string, Concept>();
285
- for (const concept of [...concepts].sort((a, b) => compareCodeUnits(a.id, b.id))) {
286
- if (byId.has(concept.id)) {
287
- throw new LoreError(
288
- "conflict",
289
- `duplicate concept id "${concept.id}" in the bundle`,
290
- "two files resolve to the same id; rename or remove one",
291
- { id: concept.id, path: concept.path },
292
- );
293
- }
294
- byId.set(concept.id, concept);
295
- }
296
-
297
- const edges: Edge[] = [];
298
- for (const concept of byId.values()) {
299
- const dir = posix.dirname(concept.path);
300
- collectFrontmatterEdges(concept, dir, byId, edges);
301
- collectBodyEdges(concept, dir, byId, edges);
302
- }
303
-
304
- return {
305
- concepts: byId,
306
- edges,
307
- tokenEstimate: makeTokenEstimate(byId),
308
- };
309
- }
310
-
311
- // ── Filesystem walk ────────────────────────────────────────────────────────────
312
-
313
- /**
314
- * The {@link WarningCollector} `kind` tag on a "skipping unreadable directory" warning
315
- * ({@link walkFiles}). A caller whose mutation depends on a **complete** view of the bundle
316
- * graph — `lore rename`/`lore supersede`'s inbound-link rewrite, which can only repoint the
317
- * links it can see — tests for this with `warnings.has(UNREADABLE_DIRECTORY_WARNING)` and
318
- * refuses to commit rather than silently reporting success over an incomplete rewrite (LORE-82).
319
- * A caller without that completeness dependency (`query`, `sync`'s per-concept reconciliation, …)
320
- * has no reason to check it — the walk itself stays tolerant either way (LORE-82 doesn't change
321
- * loading behavior, only what a caller may choose to do with the signal).
322
- */
323
- export const UNREADABLE_DIRECTORY_WARNING = "unreadable-directory";
324
-
325
- /**
326
- * Recursively collect every `.md` file under `root`, returned as
327
- * bundle-root-relative POSIX paths in ascending lexicographic order. The final
328
- * list is sorted, so the result never depends on the filesystem's enumeration
329
- * order (design §8).
330
- *
331
- * Two robustness rules keep one odd entry from corrupting or aborting the whole
332
- * walk, each leaving a visible warning rather than a silent gap:
333
- *
334
- * - **Symlinks are not followed** (file *or* directory): a symlink could loop the
335
- * walk (a link pointing back up the tree) or pull in files outside the bundle.
336
- * - **An unreadable *sub*-directory is skipped, not fatal**: a permission-denied
337
- * nested folder warns and is passed over, so the rest of the bundle still loads;
338
- * only an unreadable **root** is fatal (there is no bundle to load at all).
339
- *
340
- * The extension match is the **lowercase** `.md` (the OKF/lore canonical
341
- * extension), not case-insensitive: matching `.MD`/`.Md` too would, on a
342
- * case-sensitive filesystem, admit `Foo.md` *and* `Foo.MD` as two files that
343
- * {@link idFromPath} folds to one id — a spurious `conflict` that aborts the load
344
- * on Linux but never reproduces on a case-insensitive macOS. Restricting the walk
345
- * to `.md` keeps the id space collision-free and the result identical across
346
- * platforms; a non-`.md` file is simply not a concept.
347
- *
348
- * Exported so `lore validate`'s command layer reuses the *same* robust walk for a
349
- * directory target (sorted, symlink-safe, `.md`-only, nested-unreadable-tolerant)
350
- * instead of re-rolling a thinner one that would drift from how the bundle is loaded.
351
- */
352
- export function walkMarkdown(root: string, warnings: WarningCollector | undefined): string[] {
353
- return walkFiles(root, warnings, (name) => /\.md$/.test(name));
354
- }
355
-
356
- /**
357
- * The generic robust walk {@link walkMarkdown} is built on: a sorted, symlink-safe,
358
- * nested-unreadable-tolerant recursion that returns every regular file the `accept` predicate
359
- * keeps (matched on the file's **base name**), as bundle-root-relative POSIX paths. The same
360
- * symlink-skip / unreadable-subdir-warn / fatal-unreadable-root rules as the markdown walk apply;
361
- * only the extension policy varies. `lore check`'s command layer uses it with a `.md`-or-`.mdx`
362
- * predicate so the filename-portability lint can *see* a stray `.mdx` (which the `.md`-only bundle
363
- * walk deliberately excludes) without re-rolling the traversal.
364
- */
365
- export function walkFiles(
366
- root: string,
367
- warnings: WarningCollector | undefined,
368
- accept: (name: string) => boolean,
369
- ): string[] {
370
- const found: string[] = [];
371
-
372
- const recurse = (relDir: string): void => {
373
- const absDir = relDir === "" ? root : posix.join(root, relDir);
374
- let entries: Dirent<string>[];
375
- try {
376
- entries = readdirSync(absDir, { withFileTypes: true });
377
- } catch (cause) {
378
- if (relDir === "") {
379
- readError(cause, `cannot read directory ${absDir}`, { root, dir: absDir });
380
- }
381
- // A nested unreadable directory skips (with a warning), so one restricted
382
- // folder doesn't take the whole bundle down with it.
383
- warnings?.add(`skipping unreadable directory ${relDir}: ${deriveMessage(cause)}`, UNREADABLE_DIRECTORY_WARNING);
384
- return;
385
- }
386
- for (const entry of entries) {
387
- const rel = relDir === "" ? entry.name : `${relDir}/${entry.name}`;
388
- if (entry.isSymbolicLink()) {
389
- warnings?.add(`skipping symlink ${rel}: symlinks are not followed`);
390
- } else if (entry.isDirectory()) {
391
- recurse(rel);
392
- } else if (entry.isFile() && accept(entry.name)) {
393
- found.push(rel);
394
- }
395
- }
396
- };
397
-
398
- recurse("");
399
- return found.sort(compareCodeUnits);
400
- }
401
-
402
- /** Read one bundle file as UTF-8, mapping an I/O failure to a classified {@link LoreError}. */
403
- function readConcept(root: string, rel: string): string {
404
- try {
405
- return readFileSync(posix.join(root, rel), "utf8");
406
- } catch (cause) {
407
- readError(cause, `cannot read ${rel}`, { root, path: rel });
408
- }
409
- }
410
-
411
- /**
412
- * Raise the right {@link LoreError} for a caught read failure, deferring the errno→category
413
- * decision to the shared {@link ioError} policy (`EACCES`/`EPERM` → `denied`; anything else →
414
- * `not_found`, so an unreadable sub-directory is not misreported as a missing bundle root). Only
415
- * the wording is bundle's: the cause's message is appended to `what` for diagnosis. Always throws.
416
- */
417
- function readError(cause: unknown, what: string, input: Record<string, unknown>): never {
418
- const message = `${what}: ${deriveMessage(cause)}`;
419
- ioError(cause, {
420
- denied: { message, hint: "check filesystem permissions on that path" },
421
- notFound: { message, hint: "check the path exists and is readable" },
422
- input,
423
- });
424
- }
425
-
426
- // ── Edge collection ──────────────────────────────────────────────────────────—
427
-
428
- /**
429
- * The frontmatter fields that carry concept references, in canonical emission
430
- * order. The `satisfies` clause pins this list to the non-`"link"` {@link EdgeKind}
431
- * members, so adding an edge kind without listing it here (or vice versa) is a
432
- * compile error rather than a silent drift.
433
- *
434
- * Exported so the rewrite engine (`lore rename`/`supersede`) iterates the **same**
435
- * ref fields the graph counts as edges, rather than re-declaring the list and risking
436
- * the two drifting (e.g. a future `depends_on` ref kind silently un-rewritten).
437
- */
438
- export const REF_FIELDS = ["specs", "supersedes", "superseded_by"] as const satisfies readonly Exclude<
439
- EdgeKind,
440
- "link"
441
- >[];
442
-
443
- /**
444
- * Append the `specs`/`supersedes`/`superseded_by` frontmatter edges for one
445
- * concept. Each field is normalized to a list of non-empty strings (a field may
446
- * be a single ref or a list — {@link toRefList}); each ref is resolved against the
447
- * id index ({@link resolveRef}). Empty/blank values produce no edge.
448
- */
449
- function collectFrontmatterEdges(concept: Concept, dir: string, byId: ReadonlyMap<string, Concept>, out: Edge[]): void {
450
- for (const kind of REF_FIELDS) {
451
- for (const ref of toRefList(concept.frontmatter[kind])) {
452
- out.push({ from: concept.id, to: resolveRef(ref, dir, byId), target: ref, kind });
453
- }
454
- }
455
- }
456
-
457
- /**
458
- * Append the body cross-link edges for one concept, in document order. Targets are
459
- * extracted by a CommonMark parser ({@link extractBodyTargets}), so links in code
460
- * are already excluded; only internal `.md` targets ({@link internalTarget})
461
- * resolve to an edge, dangling tolerated.
462
- */
463
- function collectBodyEdges(concept: Concept, dir: string, byId: ReadonlyMap<string, Concept>, out: Edge[]): void {
464
- for (const target of extractBodyTargets(concept.body)) {
465
- const path = internalTarget(target);
466
- if (path === null) {
467
- continue; // external, anchor-only, or non-.md — not a concept edge
468
- }
469
- out.push({ from: concept.id, to: resolvePath(path, dir, byId), target, kind: "link" });
470
- }
471
- }
472
-
473
- /**
474
- * The canonical `not_found` {@link LoreError} (exit 3) for a concept id absent from the bundle —
475
- * the single source of its message and hint so the graph-aware refactoring commands (`lore rename`,
476
- * `lore supersede`) and the rewrite engine all surface the same wording, whichever layer detects the
477
- * absence. The hint points at `lore query`/`lore graph` (LORE-259) — both list every known concept
478
- * id when run with no arguments — never `lore check`, which only prints a pass/fail summary count
479
- * and lists no ids at all.
480
- */
481
- export function conceptNotInBundle(id: string): LoreError {
482
- return new LoreError(
483
- "not_found",
484
- `concept "${id}" is not in the bundle`,
485
- "run `lore query` or `lore graph` to see known concept ids",
486
- { id },
487
- );
488
- }
489
-
490
- /**
491
- * Resolve a frontmatter concept reference to a concept id, or `null` if it
492
- * dangles. A ref may be authored as a **bundle-relative id** (how `lore supersede`
493
- * writes it, and what `lore rename`'s rewrite engine (rewrite.ts's `remapRefItem`)
494
- * canonicalizes every moved ref to, e.g. `adr/0009-x`) or as a **relative path**
495
- * (e.g. `../adr/0009-x.md`). Which interpretation is tried first is decided by the ref's
496
- * own **shape** ({@link isPathShapedRef}), not a blanket precedence — trying one
497
- * fixed order first for every ref shape cannot be correct for both forms at once
498
- * (LORE-184): a bare id is dir-joinable (`resolvePath` will happily join it to
499
- * `dir` and `idFromPath` tolerates its missing suffix), so path-first would let a
500
- * concept that merely happens to sit at the dir-joined location shadow the bare id
501
- * `lore` itself writes; conversely a `.md`-suffixed/`./`-relative ref that
502
- * coincidentally also equals some unrelated concept's bundle-root id must not
503
- * resolve to that decoy (LORE-134). Shape removes the ambiguity: only a path-shaped
504
- * ref is dir-joined first, only a bare ref is looked up as a root id first — each
505
- * form still falls back to the other interpretation if its primary one misses, so
506
- * a legitimately dir-relative bare ref (or a `.md` ref that happens to equal a root
507
- * id with no dir-relative match) still resolves.
508
- *
509
- * Unlike a body link ({@link internalTarget}), a ref is **not** required to carry a
510
- * `.md` suffix — the bare-id form is exactly what lore writes — which is why the
511
- * two paths classify the same string differently by design: a body cross-link must
512
- * be the portable `.md`-suffixed form (ADR-0010), while a frontmatter ref is an id.
513
- *
514
- * The ref is **trimmed** before classification and resolution, so a whitespace-padded
515
- * value resolves to the same id whether reached through the edge collector (which
516
- * trims via {@link toRefList}) or a caller that passes a raw frontmatter value (the
517
- * rewrite engine). Exported as the single frontmatter-ref→id rule so `lore rename`/
518
- * `supersede` repoint exactly the refs the graph counts as edges.
519
- */
520
- export function resolveRef(ref: string, dir: string, byId: ReadonlyMap<string, Concept>): string | null {
521
- // A ref that is external (a `scheme:`/protocol-relative URL) or a bare `#anchor`
522
- // is not a concept reference — reject it the same way a body link is, so an
523
- // absolute URL is never run through path normalization (which would mangle its
524
- // `//`) and a stray `#anchor` never resolves to the referring file's own
525
- // directory.
526
- const trimmed = ref.trim();
527
- if (isExternalTarget(trimmed)) {
528
- return null;
529
- }
530
- const decoded = decodeTarget(pathPart(trimmed));
531
- if (decoded === "") {
532
- return null;
533
- }
534
- if (isPathShapedRef(decoded)) {
535
- const asPath = resolvePath(decoded, dir, byId);
536
- if (asPath !== null) {
537
- return asPath; // relative-path form, dir-joined — wins over a same-string root id (LORE-134)
538
- }
539
- const asId = idFromPath(decoded);
540
- return byId.has(asId) ? asId : null;
541
- }
542
- // Bare (suffix-less, non-`./`/`../`-prefixed) ref: this is the canonical id form
543
- // `lore` itself writes, so try it as a bundle-root id FIRST — a concept that
544
- // merely happens to live at the dir-joined location must not shadow it (LORE-184).
545
- const asId = idFromPath(decoded);
546
- if (byId.has(asId)) {
547
- return asId;
548
- }
549
- return resolvePath(decoded, dir, byId); // fallback: a dir-relative bare ref with no root-id match
550
- }
551
-
552
- /**
553
- * Whether a decoded ref string is unambiguously a **path** form — a `.md` suffix
554
- * (case-insensitive, matching {@link idFromPath}'s own suffix test) or a `./`/`../`
555
- * relative-segment prefix — as opposed to the bare bundle-root **id** form `lore`
556
- * itself writes (`adr/0009-x`, no suffix, no leading dot-segment). Every id in
557
- * {@link BundleGraph.concepts} is derived through {@link idFromPath}'s
558
- * `posix.normalize`, which collapses `..`/`.` segments, so a real id can never
559
- * itself start with `./` or `../` — classifying such a ref as path-shaped costs
560
- * nothing on the id side and correctly prioritizes the dir-relative interpretation
561
- * for the form a human author would actually write that way.
562
- */
563
- function isPathShapedRef(ref: string): boolean {
564
- return /\.md$/i.test(ref) || ref.startsWith("./") || ref.startsWith("../");
565
- }
566
-
567
- /**
568
- * Resolve an already-decoded relative `.md` path (a body link destination, or the
569
- * path form of a frontmatter ref) against `dir` to a concept id, or `null` if no
570
- * concept matches. The path is joined to the referring directory and reduced to an
571
- * id via the shared {@link idFromPath} rule (which POSIX-normalizes), so resolution
572
- * agrees byte-for-byte with how the id was derived. A target that escapes the
573
- * bundle root (a leading `../`) simply matches nothing.
574
- *
575
- * A **`/`-absolute** `path` (a bundle-root-absolute target, e.g. `/foo/bar.md`) is
576
- * resolved against the bundle root instead of `dir`: the leading `/` is stripped and
577
- * the remainder used as-is, mirroring core/check.ts's `linkFindings`, the link-check
578
- * gate's own resolver. `dir` is already a bundle-root-relative path (every caller
579
- * derives it as `posix.dirname(concept.path)` / `posix.dirname(file.path)`), so "the
580
- * bundle root" needs no separate parameter — it is simply the empty prefix a
581
- * root-relative path is already relative to. Without this special case, a
582
- * `/`-absolute ref/link would join onto `dir` like any other relative segment and
583
- * disagree with the link-check gate on the same input.
584
- *
585
- * Lookup is **case-sensitive** (a plain `Map.has`), which is deliberate: it is the
586
- * only choice that is deterministic across platforms (a case-insensitive match
587
- * would resolve differently on Linux vs macOS for the same files), and the lore
588
- * link form is exact-case (ADR-0010). A link whose case does not match its target
589
- * therefore dangles — the correct signal, since that link is already broken on a
590
- * case-sensitive filesystem.
591
- */
592
- export function resolvePath(path: string, dir: string, byId: ReadonlyMap<string, Concept>): string | null {
593
- const joined = path.startsWith("/") ? path.slice(1) : posix.join(dir, path);
594
- const id = idFromPath(joined);
595
- return byId.has(id) ? id : null;
596
- }
597
-
598
- /**
599
- * Classify a parsed link destination: return the URL-decoded `.md` path to
600
- * resolve, or `null` for a destination that is not an internal concept link — an
601
- * external scheme (`http:`/`mailto:`/…), a bare `#anchor`, or any non-`.md` target
602
- * (image, code file, directory). The `#fragment`/`?query` is dropped and the path
603
- * URL-decoded once before resolution.
604
- *
605
- * A destination with a `scheme:` prefix is treated as external per RFC-3986 /
606
- * CommonMark — including the pathological case of a relative file whose first
607
- * segment contains a colon (e.g. `weird:name.md`). lore's own links are always
608
- * relative and colon-free (ADR-0010), and the portability lint flags any that are
609
- * not, so this is a deliberate, documented edge rather than a resolution lore
610
- * needs to second-guess.
611
- *
612
- * Exported as the single body-link classifier so the rewrite engine (`lore rename`)
613
- * decides "is this an internal `.md` link, and to what decoded path" the exact same
614
- * way the graph does — trimming first — rather than re-deriving it and drifting.
615
- */
616
- export function internalTarget(target: string): string | null {
617
- const trimmed = target.trim();
618
- if (isExternalTarget(trimmed)) {
619
- return null; // empty, anchor-only, scheme-qualified, or protocol-relative (external)
620
- }
621
- const path = decodeTarget(pathPart(trimmed));
622
- return /\.md$/i.test(path) ? path : null;
623
- }
624
-
625
- // ── Token estimate ───────────────────────────────────────────────────────────—
626
-
627
- /**
628
- * The project's token-count **heuristic**: chars/4 over a string (`Math.ceil`, so a
629
- * non-empty string is never 0). This is the single home of the chars/4 rule — the
630
- * whole-concept estimator below and `lore context`'s per-neighbor budget both call
631
- * it, so the "what is a token" definition can never drift between the figures a
632
- * single command sums into one budget. It is explicitly *not* a real tokenizer
633
- * (`length` is UTF-16 code units, so non-ASCII text is mis-measured); the surfaces
634
- * that show it label it `chars/4`.
635
- */
636
- export function estimateTokens(text: string): number {
637
- return Math.ceil(text.length / 4);
638
- }
639
-
640
- /** chars/4 token estimate over one concept's canonical serialized bytes. */
641
- function estimateConcept(concept: Concept): number {
642
- return estimateTokens(serializeConcept(concept));
643
- }
644
-
645
- /**
646
- * Coerce a frontmatter scalar to a display string, or `undefined` when there is
647
- * nothing to show — the single rule every command that surfaces a concept's `title`
648
- * (or a `title`-shaped field) shares, so the same concept's `title` reads identically
649
- * from `lore graph` and `lore context`.
650
- *
651
- * A **string** is kept **verbatim** (leading/trailing and internal whitespace
652
- * preserved) unless it is empty/whitespace-only, which has no display form. A
653
- * **finite** number or a boolean (a YAML-coerced scalar on an unknown type whose
654
- * fields the schema leaves untouched — e.g. an unquoted `title: 2024`) is coerced to
655
- * its string form rather than dropped; a non-finite number (an overflowing
656
- * `1e400` → `Infinity`, or `NaN`) has no meaningful display form and is dropped
657
- * rather than rendered as the literal `Infinity`/`NaN`. Anything else (`null`, a
658
- * list, an object) yields `undefined`.
659
- *
660
- * Note this reflects the **parsed** value: js-yaml has already turned an unquoted
661
- * `1.10` into the number `1.1`, so the authored text is unrecoverable here — quote a
662
- * scalar whose exact spelling matters.
663
- */
664
- export function frontmatterScalar(value: unknown): string | undefined {
665
- if (typeof value === "string") {
666
- return value.trim() === "" ? undefined : value;
667
- }
668
- if ((typeof value === "number" && Number.isFinite(value)) || typeof value === "boolean") {
669
- return String(value);
670
- }
671
- return undefined;
672
- }
673
-
674
- /**
675
- * Build the {@link BundleGraph.tokenEstimate} closure over the loaded concepts,
676
- * memoizing each concept's estimate (and the whole-bundle total) so a repeated
677
- * call — e.g. `lore context` asking for a target plus each neighbor — never
678
- * re-serializes a concept it already measured.
679
- */
680
- function makeTokenEstimate(byId: ReadonlyMap<string, Concept>): (id?: string) => number {
681
- const cache = new Map<string, number>();
682
- let total: number | undefined;
683
-
684
- const estimateFor = (id: string, concept: Concept): number => {
685
- let value = cache.get(id);
686
- if (value === undefined) {
687
- value = estimateConcept(concept);
688
- cache.set(id, value);
689
- }
690
- return value;
691
- };
692
-
693
- return (id?: string): number => {
694
- if (id === undefined) {
695
- if (total === undefined) {
696
- let sum = 0;
697
- for (const [conceptId, concept] of byId) {
698
- sum += estimateFor(conceptId, concept);
699
- }
700
- total = sum;
701
- }
702
- return total;
703
- }
704
- const concept = byId.get(id);
705
- if (concept === undefined) {
706
- throw new LoreError(
707
- "not_found",
708
- `concept "${id}" is not in the bundle`,
709
- "run `lore query` to find the right id, or check the path",
710
- { id },
711
- );
712
- }
713
- return estimateFor(id, concept);
714
- };
715
- }
716
-
717
- // ── Markdown / path helpers ──────────────────────────────────────────────────—
718
-
719
- /**
720
- * Depth-first pre-order walk over an mdast tree, visiting every node once. It uses
721
- * an **explicit stack**, not recursion: a pathological body (e.g. tens of thousands
722
- * of nested blockquotes) produces an AST deeper than the JS call stack, and a
723
- * recursive walk would overflow with an uncaught `RangeError` that takes down the
724
- * whole bundle build — `mdast-util-from-markdown` itself parses such input
725
- * iteratively, so the walk must too. Children are pushed in reverse so they pop in
726
- * document order (a stable pre-order traversal).
727
- *
728
- * Exported so `lore validate`'s heading extraction reuses this one stack-safe
729
- * traversal instead of re-rolling the same explicit-stack walk and risking drift.
730
- */
731
- export function walkMdast(root: Nodes, visit: (node: Nodes) => void): void {
732
- const stack: Nodes[] = [root];
733
- while (stack.length > 0) {
734
- const node = stack.pop();
735
- if (node === undefined) {
736
- break;
737
- }
738
- visit(node);
739
- if ("children" in node) {
740
- for (let i = node.children.length - 1; i >= 0; i--) {
741
- const child = node.children[i];
742
- if (child !== undefined) {
743
- stack.push(child);
744
- }
745
- }
746
- }
747
- }
748
- }
749
-
750
- /**
751
- * Extract the link destinations from a concept body, in document order, using a
752
- * CommonMark parser (`mdast-util-from-markdown`). Walking the AST is what makes
753
- * extraction correct where a regex is not: a link inside a code span or
754
- * fenced/indented code block never appears as a `link` node, and a linked-image
755
- * contributes its **outer** `link` (its `url`).
756
- *
757
- * A reference-style link (`[text][id]`) is resolved through its **definition**:
758
- * only definitions that an actual `linkReference` uses become targets, so an
759
- * orphan/unused `[id]: …` definition (which renders as nothing) is *not* a phantom
760
- * edge. Image (`image`/`imageReference`) nodes are ignored — they point at assets,
761
- * not concepts.
762
- *
763
- * Two deliberate boundaries, both matching lore's canonical link form (ADR-0010):
764
- *
765
- * - **Only markdown links count.** A cross-link written as raw HTML
766
- * (`<a href="../x.md">`) parses to an opaque `html` node and is *not* an edge;
767
- * raw HTML is non-portable and the portability lint flags it. lore never emits
768
- * it, so it stays out of the graph rather than dragging an HTML parser into core.
769
- * - **Plain CommonMark, no GFM extensions.** Without the footnote extension a
770
- * `[^1]: …` line is ordinary text, so any markdown link inside it is a normal
771
- * link and an edge — which is correct: it *is* a rendered link.
772
- *
773
- * Exported so `lore check`'s link/anchor pass (LORE-30) extracts a file's link
774
- * destinations through the *same* code-span-safe, reference-resolving walk the graph
775
- * uses, instead of re-rolling one that would drift from how edges are built. The
776
- * returned destinations keep their `#fragment`/`?query` intact (the resolver strips
777
- * them; the anchor check needs them).
778
- */
779
- export function extractBodyTargets(body: string): string[] {
780
- return extractLinkTargets(fromMarkdown(compactPlainTextLines(body)));
781
- }
782
-
783
- /**
784
- * Replace exceptionally long syntax-free lines before CommonMark tokenization.
785
- * Their exact prose cannot affect link destinations, while retaining more than
786
- * CommonMark's 999-character link-label ceiling prevents an invalid oversized
787
- * label from becoming valid. Definition destinations on a following line remain
788
- * byte-exact because their text is itself the value we return.
789
- */
790
- function compactPlainTextLines(body: string): string {
791
- const lines = body.split("\n");
792
- let changed = false;
793
- for (let index = 0; index < lines.length; index++) {
794
- const line = lines[index] as string;
795
- if (line.length <= 4096 || /[^\p{L}\p{N} \t]/u.test(line)) continue;
796
- const previous = lines[index - 1]?.trimEnd();
797
- if (previous?.endsWith("]:") === true) continue;
798
- const indentation = line.match(/^[ \t]*/u)?.[0] ?? "";
799
- lines[index] = `${indentation}${"x".repeat(1000)}`;
800
- changed = true;
801
- }
802
- return changed ? lines.join("\n") : body;
803
- }
804
-
805
- /**
806
- * Like {@link extractBodyTargets}, but over an **already-parsed** mdast tree — so a caller
807
- * that also needs the tree's headings or text nodes (e.g. `lore check`) parses the body
808
- * **once** and shares the tree across every consumer, instead of re-parsing it per pass.
809
- */
810
- export function extractLinkTargets(tree: Nodes): string[] {
811
- // One walk records, in document order, each inline link's url (a `string`) and
812
- // each reference link's identifier (a `{ ref }`), and indexes every definition —
813
- // because a `linkReference` may appear before its definition, resolution waits
814
- // until the walk is done and every definition is known.
815
- const definitions = new Map<string, string>();
816
- const events: Array<string | { ref: string }> = [];
817
- walkMdast(tree, (node) => {
818
- if (node.type === "definition") {
819
- if (!definitions.has(node.identifier)) {
820
- definitions.set(node.identifier, node.url);
821
- }
822
- } else if (node.type === "link") {
823
- events.push(node.url);
824
- } else if (node.type === "linkReference") {
825
- events.push({ ref: node.identifier });
826
- }
827
- });
828
-
829
- const targets: string[] = [];
830
- for (const event of events) {
831
- if (typeof event === "string") {
832
- targets.push(event);
833
- } else {
834
- const url = definitions.get(event.ref);
835
- if (url !== undefined) {
836
- targets.push(url); // a used reference link; an orphan definition is no edge
837
- }
838
- }
839
- }
840
- return targets;
841
- }
842
-
843
- /**
844
- * The literal text content of an mdast node — every `text` and `inlineCode` value
845
- * concatenated in document order, via the stack-safe {@link walkMdast}. Exported as the one
846
- * "what text does this node render" rule so heading-anchor slugging (`lore check`) and
847
- * required-section matching (`lore validate`) cannot drift apart on what a heading *says*.
848
- *
849
- * Deliberately excludes `image`/`imageReference` `alt` text: GitHub — lore's reference
850
- * renderer (portable-markdown.md) — renders an `<img>` with empty `textContent` no matter
851
- * its `alt`, so an image contributes nothing to the *visible* heading text GitHub slugs from
852
- * (verified empirically against GitHub's rendered output; `mdast-util-to-string`'s
853
- * `includeImageAlt: true` default diverges from GitHub here and does not apply).
854
- */
855
- export function nodeText(node: Nodes): string {
856
- let text = "";
857
- walkMdast(node, (current) => {
858
- if (current.type === "text" || current.type === "inlineCode") {
859
- text += current.value;
860
- }
861
- });
862
- return text;
863
- }
864
-
865
- /**
866
- * Normalize a frontmatter ref value (a single ref or a list) to trimmed, non-empty
867
- * ref strings. A non-string **scalar** (a YAML-coerced number/boolean — reachable
868
- * on an unknown-type concept, whose fields the schema does not constrain) is
869
- * coerced to its string form rather than dropped, so a stray `supersedes: 123`
870
- * becomes a *visible* (dangling) edge instead of silently vanishing; `null` and
871
- * non-scalar items contribute nothing. Exported so `commands/link.ts` reads a
872
- * concept's `tasks:` list through the same tolerant normalization, rather than a
873
- * second implementation that silently drops what this one coerces.
874
- */
875
- export function toRefList(value: unknown): string[] {
876
- const items = Array.isArray(value) ? value : [value];
877
- const refs: string[] = [];
878
- for (const item of items) {
879
- const ref = scalarToRef(item);
880
- if (ref !== null) {
881
- refs.push(ref);
882
- }
883
- }
884
- return refs;
885
- }
886
-
887
- /** A single frontmatter ref item as a trimmed, non-empty string, or `null` if it is not one. */
888
- function scalarToRef(item: unknown): string | null {
889
- const text =
890
- typeof item === "string" ? item : typeof item === "number" || typeof item === "boolean" ? String(item) : "";
891
- const trimmed = text.trim();
892
- return trimmed === "" ? null : trimmed;
893
- }