@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,597 +0,0 @@
1
- /**
2
- * concept.ts — a single concept `.md` file ⇄ a typed {@link Concept}.
3
- *
4
- * This module is the **frontmatter boundary**
5
- * ([ADR-0011](../../docs/adr/0011-frontmatter-serialization-stability.md) §1): the
6
- * one place lore turns a file's raw bytes into a structured object
7
- * ({@link parseConcept}) and back again ({@link serializeConcept}). Everything
8
- * downstream — the bundle graph, validation, status reconciliation, refactoring —
9
- * reads and writes concepts through here, so the byte-stability contract has a
10
- * single home to enforce and test.
11
- *
12
- * The load-bearing guarantee is **byte-stable serialization** (ADR-0011, design
13
- * §6): re-serializing a concept whose frontmatter is already in lore-canonical form
14
- * reproduces the exact bytes, so a semantic no-op is a *byte-level* no-op and
15
- * `lore sync`/`lore check` produce clean diffs and a trustworthy drift gate. The
16
- * tested property is a **fixpoint**: for any input,
17
- * `serialize(parse(serialize(parse(raw))))` equals `serialize(parse(raw))` — the
18
- * first write normalizes to canonical form, every write after is identical
19
- * (design §9.2).
20
- *
21
- * Four mechanisms make that true:
22
- *
23
- * - **A frozen YAML config.** The YAML is parsed and dumped by js-yaml under one
24
- * pinned config ({@link YAML_DUMP_OPTIONS}) reused by every path — no
25
- * line-wrapping, no key sorting, deterministic block style, minimal-but-stable
26
- * quoting.
27
- * - **`JSON_SCHEMA` on parse.** js-yaml's `JSON_SCHEMA` never resolves a timestamp
28
- * to a `Date`, so `2026-06-21T00:00:00Z` stays the ISO **string** ADR-0006 §2
29
- * requires — which is also what keeps it byte-stable and Zod-validatable.
30
- * - **Canonical key order on serialize.** Known keys are emitted in a fixed order (the active
31
- * profile's {@link Profile.canonicalKeyOrder}, from [core/profile.ts](./profile.ts)); unknown
32
- * producer-extension keys follow in their existing order, preserved verbatim
33
- * (ADR-0011 §3, §5; OKF tolerance).
34
- * - **Input normalization on parse.** A leading BOM, CRLF/CR line endings, and
35
- * blank-line padding before the opening fence are normalized to canonical form (no
36
- * BOM, LF, fence-first) on read, so such a file is *accepted* (not rejected) and
37
- * reaches a fixpoint after one write rather than churning or erroring.
38
- *
39
- * Serialization composes the fence directly (`---` + dumped YAML + `---` + body).
40
- * This avoids object-copy helpers whose `Object.assign({}, data)` semantics **silently drop an own
41
- * `__proto__` data key** (a tolerated OKF producer extension) and reflow the body —
42
- * both of which would break the no-key-dropped (ADR-0011 §5) and byte-stability
43
- * guarantees this module exists to provide. The fence split and YAML parse therefore share this
44
- * module as one hardened boundary.
45
- *
46
- * Per the core contract (design §2.1) this module is a pure library: it returns a
47
- * {@link Concept} or throws a {@link LoreError}; it never prints, reads flags, or
48
- * calls `process.exit`. Advisory warnings flow to an optional {@link WarningCollector}.
49
- *
50
- * Scope note: a concept document is the `---`-fenced frontmatter plus its body. The
51
- * editor modeline (`# yaml-language-server: …`) is spliced in as the first line
52
- * *inside* the opening fence by {@link serializeConceptWithModeline} — the only
53
- * placement lore's own parser reads back as a concept (`parseConcept` needs `---` at
54
- * byte 0, so an above-fence comment makes the file a non-concept). The modeline text
55
- * itself is `schema.schemaModeline`'s concern (ADR-0006 §3).
56
- *
57
- * Documented limitations, all for inputs no real concept produces (frontmatter keys
58
- * are field-name identifiers and values are short scalars/dates/lists — never comments,
59
- * blank-line-terminated values, integer keys, or huge numbers). Each still reaches a
60
- * stable fixpoint; only the *first* write may normalize:
61
- *
62
- * - An in-frontmatter YAML *comment* is not preserved across a round-trip (the YAML
63
- * emitter drops comments — an accepted ADR-0011 limitation).
64
- * - A frontmatter scalar value ending in two-or-more newlines emits a `|+` keep-chomped
65
- * block that abuts the closing fence and loses one trailing newline on the first
66
- * re-parse (a value with ≤1 trailing newline round-trips exactly).
67
- * - Integer-like keys (e.g. a `9:` producer-extension key) are emitted in ascending
68
- * numeric order ahead of the named keys, not in authored order — JS object semantics
69
- * reorder integer keys at parse time (`js-yaml` builds a plain object), so the order
70
- * is unrecoverable here regardless of the profile's canonical key order.
71
- * - A large/high-precision unquoted *numeric* value loses precision (`js-yaml`'s
72
- * `JSON_SCHEMA` parses numbers to JS doubles); quote such a value to keep it exact.
73
- */
74
-
75
- import { posix } from "node:path";
76
- import type { Document, DumpOptions, LoadOptions, Node } from "js-yaml";
77
- import * as yaml from "js-yaml";
78
- import { deriveMessage, LoreError, singleLine, type WarningCollector } from "../errors";
79
- import { defaultProfile, type Profile } from "./profile";
80
- import { validateFrontmatter } from "./schema";
81
-
82
- /**
83
- * A single concept file as a typed object. `frontmatter` is the **verbatim**
84
- * parsed YAML mapping (every key preserved, never coerced or reordered on read);
85
- * `type` is a convenience mirror of `frontmatter.type` (always a non-empty string
86
- * after {@link parseConcept}); `body` is the markdown after the closing fence.
87
- *
88
- * `id`, `path`, and `type` are **`readonly`** identity/derived fields: they snapshot
89
- * what was parsed and are not the write surface. `frontmatter` is authoritative for
90
- * serialization — to retype a concept, set `frontmatter.type` (assigning the readonly
91
- * `type` mirror is a compile error precisely because serialize ignores it).
92
- */
93
- export interface Concept {
94
- /** Repo-relative path minus the `.md` suffix, e.g. `stories/bulk-archive`. */
95
- readonly id: string;
96
- /** Repo-relative path as given to {@link parseConcept}, e.g. `stories/bulk-archive.md`. */
97
- readonly path: string;
98
- /**
99
- * The resolved (trimmed, non-empty) `type` — a read-only mirror of
100
- * `frontmatter.type`. `frontmatter.type` is authoritative for serialization; to
101
- * retype a concept, set `frontmatter.type` (this mirror cannot be assigned).
102
- */
103
- readonly type: string;
104
- /**
105
- * The verbatim parsed frontmatter mapping — validated where known, passthrough
106
- * where not. Keys are never dropped or reordered on read; a value may be `null`
107
- * (an empty `key:`), so consumers must be null-safe, not merely absent-safe.
108
- */
109
- frontmatter: Record<string, unknown>;
110
- /** The markdown body after the frontmatter fence. */
111
- body: string;
112
- }
113
-
114
- /** The frontmatter fence delimiter. */
115
- const FENCE = "---\n";
116
-
117
- /**
118
- * The frozen js-yaml load config (ADR-0011 §2). `JSON_SCHEMA` is the crux: it
119
- * resolves only JSON's own types, so an ISO timestamp stays a **string** (never a
120
- * `Date`) and round-trips byte-for-byte.
121
- */
122
- const YAML_LOAD_OPTIONS: LoadOptions = Object.freeze({ schema: yaml.JSON_SCHEMA });
123
-
124
- /**
125
- * The frozen js-yaml dump config (ADR-0011 §2) — chosen for *stability over
126
- * prettiness* and reused by every write:
127
- *
128
- * - `schema: JSON_SCHEMA` — symmetric with the load schema.
129
- * - `lineWidth: -1` — never wrap/reflow long strings or lists into different bytes.
130
- * - `sortKeys: false` — we control key order ourselves (the active profile's canonical key order).
131
- * - `noRefs: true` — never emit YAML anchors/aliases for a shared object.
132
- * - `quoteStyle: "double"` + `forceQuotes: false` — when a value *must* be quoted, use
133
- * double quotes; otherwise leave it unquoted (don't gratuitously re-quote).
134
- * - `transform: quoteLeadingZeroStrings` — retain js-yaml 4's stable treatment
135
- * of digit strings such as `"007"` after js-yaml 5 stopped quoting them.
136
- */
137
- const YAML_DUMP_OPTIONS: DumpOptions = Object.freeze({
138
- schema: yaml.JSON_SCHEMA,
139
- lineWidth: -1,
140
- sortKeys: false,
141
- noRefs: true,
142
- quoteStyle: "double",
143
- forceQuotes: false,
144
- transform: quoteLeadingZeroStrings,
145
- });
146
-
147
- /**
148
- * js-yaml 5 resolves an omitted mapping value (`status:`) to an empty string,
149
- * while js-yaml 4 and YAML's null semantics resolved it to `null`. Restore that
150
- * boundary behavior without changing an explicitly quoted empty string.
151
- */
152
- function normalizeEmptyYamlScalars(input: string): string {
153
- return input.replace(
154
- /^([ \t]*[A-Za-z_][A-Za-z0-9_-]*:)[ \t]*(#.*)?$(?!\n[ \t]+(?:-|[A-Za-z_][A-Za-z0-9_-]*:))/gm,
155
- (_line, prefix, comment = "") => `${prefix} null${comment ? ` ${comment}` : ""}`,
156
- );
157
- }
158
-
159
- /** Preserve strings whose plain form is unstable or ambiguous for YAML consumers. */
160
- function quoteLeadingZeroStrings(documents: Document[]): void {
161
- const stack: Node[] = documents.flatMap((document) => (document.contents ? [document.contents] : []));
162
- while (stack.length > 0) {
163
- const node = stack.pop() as Node;
164
- if (node.kind === "scalar") {
165
- if (node.tag === "tag:yaml.org,2002:str" && (/^[-+]?0[0-9]+$/.test(node.value) || /^[-?:]/.test(node.value))) {
166
- node.style.doubleQuoted = true;
167
- }
168
- } else if (node.kind === "sequence") {
169
- stack.push(...node.items);
170
- } else if (node.kind === "mapping") {
171
- for (const item of node.items) {
172
- stack.push(item.key, item.value);
173
- }
174
- }
175
- }
176
- }
177
-
178
- /**
179
- * `assertBoundedYamlExpansion` runs on every parsed document at the single choke point
180
- * every read path (`parseConcept`/`tryParseConcept`/
181
- * `tryReadFrontmatter`, all via {@link splitFrontmatter}) shares, so a malicious file is
182
- * rejected the moment it is first read, before any downstream consumer (validation, the
183
- * bundle graph, a later `serializeConcept` dump) can ever touch the dangerous object
184
- * (LORE-85). A thrown error here is caught and path-annotated by `splitFrontmatter`'s
185
- * existing try/catch, exactly like a plain YAML syntax error.
186
- */
187
- function parseYamlFrontmatter(input: string): unknown {
188
- const parsed = yaml.load(normalizeEmptyYamlScalars(input), YAML_LOAD_OPTIONS);
189
- assertBoundedYamlExpansion(parsed);
190
- return parsed;
191
- }
192
-
193
- /**
194
- * The budget {@link assertBoundedYamlExpansion} enforces, in "expanded units" (roughly
195
- * chars — see its own doc for the exact accounting). Frontmatter is metadata, never
196
- * prose (the body carries that) — real frontmatter is at most a few KB even for a
197
- * concept with a long tags/list field, so this leaves generous headroom (hundreds of
198
- * KB) while still aborting a doubling-anchor attack within a couple dozen levels,
199
- * almost instantly (LORE-85).
200
- */
201
- const MAX_EXPANDED_YAML_UNITS = 100_000;
202
-
203
- /**
204
- * Reject a parsed YAML document whose anchor/alias structure would expand to an
205
- * unreasonable size or contains a cycle, **before** anything downstream (validation,
206
- * canonicalization, a later `noRefs: true` {@link YAML_DUMP_OPTIONS} dump) ever walks it
207
- * naively and pays the cost.
208
- *
209
- * js-yaml's `load` never expands an alias (`*a`) — it points the SAME JS object
210
- * reference back at its anchor (`&a`), so parsing a doubling-anchor chain is always
211
- * fast regardless of depth (LORE-85's own repro: an 18-level, ~400-byte chain loads in
212
- * ~1ms). The danger is downstream: any code that walks the result treating shared
213
- * references as if they were distinct subtrees — exactly what `yaml.dump({noRefs:
214
- * true})` does, by design, so a re-serialize never emits `&`/`*` — re-visits each
215
- * shared subtree once per incoming reference, and a chain of `n` doubling levels
216
- * revisits the base subtree `2^n` times: ~400 bytes of source YAML can demand
217
- * megabytes-to-gigabytes of work.
218
- *
219
- * This walker performs the SAME kind of naive, reference-blind traversal (deliberately
220
- * NOT memoizing by object identity — a memoized walk would undercount the very
221
- * blowup a real `dump` would suffer), but tracks a running total and aborts the
222
- * instant it crosses {@link MAX_EXPANDED_YAML_UNITS} — since the total at least
223
- * doubles every level in an exponential attack, the walk itself never runs longer
224
- * than a small multiple of the budget, however deep the malicious chain goes.
225
- *
226
- * A **cycle** (an anchor referencing one of its own ancestors — legal under js-yaml's
227
- * `JSON_SCHEMA`, confirmed empirically: `a: &a {b: *a}` loads with `doc.a === doc.a.b`)
228
- * is a distinct, unbounded hazard a size budget alone cannot catch (an unmemoized walk
229
- * of a true cycle never terminates) — detected via a path-scoped ancestor set (added on
230
- * entering a node, removed on leaving it), which correctly tells a real cycle apart
231
- * from harmless DAG-style sharing (the same anchor reused by two unrelated siblings,
232
- * an ordinary and safe YAML pattern that must not be rejected).
233
- *
234
- * @throws Error when the budget is exceeded or a cycle is found; caught and
235
- * path-annotated by {@link splitFrontmatter}'s surrounding `matter(...)` try/catch.
236
- */
237
- function assertBoundedYamlExpansion(value: unknown): void {
238
- const ancestors = new Set<object>();
239
- let units = 0;
240
-
241
- const walk = (node: unknown): void => {
242
- if (typeof node === "string") {
243
- units += node.length;
244
- } else if (typeof node !== "object" || node === null) {
245
- units += 1; // number, boolean, null — a fixed small cost
246
- } else {
247
- if (ancestors.has(node)) {
248
- throw new Error("frontmatter contains a cyclic YAML anchor (an anchor referencing one of its own ancestors)");
249
- }
250
- ancestors.add(node);
251
- if (Array.isArray(node)) {
252
- units += 1;
253
- for (const item of node) {
254
- walk(item);
255
- }
256
- } else {
257
- units += 1;
258
- for (const [key, val] of Object.entries(node)) {
259
- units += key.length;
260
- walk(val);
261
- }
262
- }
263
- ancestors.delete(node);
264
- }
265
- if (units > MAX_EXPANDED_YAML_UNITS) {
266
- throw new Error(
267
- `frontmatter's YAML anchors/aliases would expand to over ${MAX_EXPANDED_YAML_UNITS.toLocaleString()} characters when fully resolved — refusing rather than risk unbounded memory/CPU use`,
268
- );
269
- }
270
- };
271
- walk(value);
272
- }
273
-
274
- /** Options for {@link parseConcept}. */
275
- export interface ParseConceptOptions {
276
- /** Sink for advisory warnings (unknown type, extra keys, summary); absent → dropped. */
277
- warnings?: WarningCollector;
278
- /** The active profile to validate against; defaults to the built-in {@link defaultProfile}. */
279
- profile?: Profile;
280
- }
281
-
282
- /**
283
- * Parse a concept file's raw bytes into a typed {@link Concept}.
284
- *
285
- * Normalizes the input ({@link normalizeInput}), splits the frontmatter via
286
- * the frozen YAML engine, validates it against the lore profile
287
- * ({@link validateFrontmatter} — throws a `validation` {@link LoreError} on a missing
288
- * `type` or a mistyped known field; warns on unknown types/keys), and returns the
289
- * **verbatim** frontmatter object so a later {@link serializeConcept} can reproduce
290
- * the bytes.
291
- *
292
- * @param path repo-relative path, used to derive {@link Concept.id} and to name diagnostics.
293
- * @param raw the file's raw text (the `---`-fenced frontmatter plus body).
294
- */
295
- export function parseConcept(path: string, raw: string, options: ParseConceptOptions = {}): Concept {
296
- const split = splitFrontmatter(path, normalizeInput(raw));
297
- if (!split.present) {
298
- throw absentFrontmatterError(path, split.reason);
299
- }
300
- return conceptFromSplit(path, split, options);
301
- }
302
-
303
- /**
304
- * Like {@link parseConcept}, but returns `null` when `raw` is **not a concept
305
- * file** — it has no frontmatter, an empty fence, or a non-mapping fence (a bare
306
- * scalar/list, e.g. a hand-written doc that merely *opens* with a `---` thematic
307
- * break). It still **throws** for a real but malformed concept: unparseable YAML,
308
- * or a frontmatter *mapping* that fails the lore profile (missing/invalid `type`,
309
- * mistyped field).
310
- *
311
- * This is the distinction a bundle walk ({@link loadBundle}) needs: a non-concept
312
- * is skipped, a broken concept fails loud. A plain `hasFrontmatter`-style prefix
313
- * check cannot draw it — telling "an empty/HR `---`" from "real frontmatter"
314
- * requires actually parsing the YAML, which is exactly what this does (once), so a
315
- * caller never parses the same bytes twice.
316
- */
317
- export function tryParseConcept(path: string, raw: string, options: ParseConceptOptions = {}): Concept | null {
318
- const split = splitFrontmatter(path, normalizeInput(raw));
319
- return split.present ? conceptFromSplit(path, split, options) : null;
320
- }
321
-
322
- /**
323
- * Best-effort read of a file's frontmatter **mapping alone**, without validating it against any
324
- * profile schema — deliberately narrower than {@link tryParseConcept} (which always validates), but
325
- * NOT more tolerant of a genuine parse failure: like {@link tryParseConcept}, this returns `null` for
326
- * a file with no usable frontmatter mapping (nothing to skip), but still **throws** when the YAML
327
- * itself is unparseable — there is no mapping to safely hand back in that case, so a caller cannot
328
- * treat "unparseable" and "absent" as the same outcome (an unparseable file could easily have
329
- * declared `tasks:`; there is no way to know, so it must not be assumed innocent).
330
- *
331
- * For a caller that needs to know a FACT about a file's frontmatter (e.g. does it declare `tasks:`?)
332
- * without committing to full schema validation succeeding: `lore check`'s reconciliation-eligibility
333
- * scan (LORE-27) uses this to recognize a concept that fails the lore profile but still links a
334
- * Backlog task as reconciliation-relevant (and so re-throw the original error loud, matching what
335
- * `lore sync` would do for the identical file) — rather than silently treating it as if it never
336
- * existed, which it does for every OTHER malformed file with no such bearing. When the YAML itself
337
- * is unparseable, this function's own throw propagates the same way, which is the conservative,
338
- * correct default for that case.
339
- *
340
- * @throws LoreError `validation` — {@link splitFrontmatter}'s own contract — for unparseable YAML.
341
- */
342
- export function tryReadFrontmatter(path: string, raw: string): Record<string, unknown> | null {
343
- const split = splitFrontmatter(path, normalizeInput(raw));
344
- return split.present ? split.frontmatter : null;
345
- }
346
-
347
- /**
348
- * Validate a present frontmatter split against the lore profile and assemble the
349
- * {@link Concept}. Shared by {@link parseConcept} and {@link tryParseConcept} so
350
- * the validate-and-construct step (and the byte-stable `frontmatter`/`body`
351
- * passthrough) has one home. `validateFrontmatter` returns the resolved (trimmed,
352
- * non-empty) `type`, so the `type` mirror is correct even when `frontmatter.type`
353
- * carries stray whitespace; it throws a `validation` {@link LoreError} on a
354
- * malformed mapping.
355
- */
356
- function conceptFromSplit(path: string, split: PresentSplit, options: ParseConceptOptions): Concept {
357
- const type = validateFrontmatter(split.frontmatter, { warnings: options.warnings, path, profile: options.profile });
358
- return { id: idFromPath(path), path, type, frontmatter: split.frontmatter, body: split.body };
359
- }
360
-
361
- /**
362
- * Serialize a {@link Concept} back to bytes with the byte-stable contract:
363
- * `---` + frontmatter (canonical key order, frozen YAML config) + `---` + body.
364
- * Re-serializing an already-canonical concept reproduces the exact input bytes. The
365
- * body is emitted verbatim (line-ending/whitespace normalization happens on parse,
366
- * not here), so an empty or no-trailing-newline body is not silently padded.
367
- *
368
- * Serialization re-asserts the **same** {@link validateFrontmatter} the read path
369
- * runs (its warnings are irrelevant on write and are dropped), so lore can never
370
- * write bytes it would then refuse to parse back — a hand-built or edited Concept
371
- * with a missing/whitespace-padded/mistyped `type` or a mistyped known field throws a
372
- * `validation` {@link LoreError} here instead of producing an unreadable file. This is
373
- * exact write/read symmetry, not a weaker non-empty-`type` floor.
374
- */
375
- export function serializeConcept(concept: Concept, options: SerializeConceptOptions = {}): string {
376
- const profile = options.profile ?? defaultProfile();
377
- validateFrontmatter(concept.frontmatter, { path: concept.path, profile });
378
- const ordered = canonicalize(concept.frontmatter, profile.canonicalKeyOrder);
379
- return `${FENCE}${yaml.dump(ordered, YAML_DUMP_OPTIONS)}${FENCE}${concept.body}`;
380
- }
381
-
382
- /** Options for {@link serializeConcept}/{@link serializeConceptWithModeline}. */
383
- export interface SerializeConceptOptions {
384
- /**
385
- * The active profile, supplying the canonical key order (ADR-0011 append-slot = profile field
386
- * declaration order) and the validators re-asserted on write. Defaults to the built-in
387
- * {@link defaultProfile}, so a caller that does not opt into a custom profile is unaffected.
388
- */
389
- profile?: Profile;
390
- }
391
-
392
- /**
393
- * Serialize a {@link Concept} and splice an editor modeline (a `# yaml-language-server:`
394
- * comment, produced by `schema.schemaModeline`) in as the **first line inside** the
395
- * opening `---` fence. The single home for that placement, so every command that emits a
396
- * modeline-bearing doc (`lore init`, and `lore new` next) shares one implementation
397
- * instead of re-deriving the splice.
398
- *
399
- * Inside-fence is the only placement lore's own parser reads back as a concept
400
- * ({@link parseConcept} requires `---` at byte 0, so an above-fence comment would make
401
- * the file a non-concept). The splice slices off {@link serializeConcept}'s known `---\n`
402
- * opening fence and re-prepends it with the modeline — operating on that fixed prefix,
403
- * never a content search, so a `---\n` occurring later in a value or the body can never
404
- * be targeted.
405
- *
406
- * Round-trip caveat (ADR-0011 §2): js-yaml drops the in-fence comment if the file is
407
- * ever re-serialized, so a doc written this way is emitted once, not rewritten in place.
408
- *
409
- * `modeline` must be a single line: it is spliced in verbatim (never content-searched
410
- * or escaped), so a `modeline` containing a line break would inject arbitrary extra
411
- * lines inside/after the opening fence and corrupt the emitted document. Every caller
412
- * today passes `schema.schemaModeline` output, which is single-line by construction —
413
- * this check is fail-loud contract enforcement for a case no current caller can
414
- * reach, matching the rest of this module's throw-before-corrupt invariants.
415
- */
416
- export function serializeConceptWithModeline(
417
- concept: Concept,
418
- modeline: string,
419
- options: SerializeConceptOptions = {},
420
- ): string {
421
- if (/[\r\n\u2028\u2029]/.test(modeline)) {
422
- throw new LoreError(
423
- "validation",
424
- `modeline must be a single line, but contained a line break: ${singleLine(modeline)}`,
425
- "pass a one-line modeline (e.g. schema.schemaModeline output) with no embedded line break",
426
- { modeline },
427
- );
428
- }
429
- const serialized = serializeConcept(concept, options);
430
- return `${FENCE}${modeline}\n${serialized.slice(FENCE.length)}`;
431
- }
432
-
433
- /**
434
- * Normalize raw bytes to lore-canonical encoding before parsing: strip leading UTF-8
435
- * BOM(s), convert CRLF/CR line endings to LF, and drop any leading whitespace
436
- * (blank lines, spaces, tabs) before the opening fence. This makes a BOM-prefixed,
437
- * Windows-CRLF, or whitespace-padded concept *parse* (rather than be rejected as
438
- * "no frontmatter") and reach a fixpoint after one write.
439
- *
440
- * lore canonicalizes line endings to **LF** — the repo pins LF (`.gitattributes`), so
441
- * a CRLF/CR file is non-canonical and is rewritten to LF (frontmatter *and* body,
442
- * including line endings *inside* frontmatter values) on the first write, exactly as a
443
- * BOM is dropped; this is a deliberate one-time normalization, not incidental churn.
444
- * Multiple leading BOMs are all stripped (some Windows editors emit more than one —
445
- * the same case config.ts handles).
446
- */
447
- export function normalizeInput(raw: string): string {
448
- return raw
449
- .replace(/^\uFEFF+/, "")
450
- .replace(/\r\n?/g, "\n")
451
- .replace(/^\s+/, "");
452
- }
453
-
454
- /** A frontmatter split that yielded a usable mapping. */
455
- interface PresentSplit {
456
- readonly present: true;
457
- readonly frontmatter: Record<string, unknown>;
458
- readonly body: string;
459
- }
460
-
461
- /**
462
- * The outcome of splitting a normalized file: either a usable frontmatter mapping
463
- * ({@link PresentSplit}), or **absent** — the file is not a concept at all. `reason`
464
- * distinguishes the two absent shapes so {@link parseConcept} can raise the precise
465
- * diagnostic: `"non-mapping"` is a fence holding a bare scalar/list (e.g. a doc
466
- * that merely *opens* with a `---` thematic break), `"missing"` is no fence, an
467
- * empty fence, or a `null` fence.
468
- */
469
- type FrontmatterSplit = PresentSplit | { readonly present: false; readonly reason: "non-mapping" | "missing" };
470
-
471
- /**
472
- * Split normalized bytes into a frontmatter mapping + body.
473
- *
474
- * It **throws** a `validation` {@link LoreError} for genuinely malformed YAML
475
- * inside a fence, or for a malformed *closing* fence (LORE-141). The two
476
- * *not-a-concept* shapes — a non-mapping fence (a bare
477
- * scalar/list, which is what a leading `---` thematic break parses to) and a
478
- * missing/empty/`null` fence — are returned as `present: false`, **not** thrown, so
479
- * a caller can decide: {@link parseConcept} turns them into the matching error,
480
- * while {@link tryParseConcept} skips the file. This is what keeps a stray
481
- * thematic-break doc from aborting a whole {@link loadBundle}.
482
- */
483
- function splitFrontmatter(path: string, raw: string): FrontmatterSplit {
484
- const opening = /^---[ \t]*\n/.exec(raw);
485
- if (opening === null) return { present: false, reason: "missing" };
486
- const openEnd = opening[0].length;
487
- const closeStart = raw.indexOf("\n---", openEnd);
488
- const cleanClose = closeStart < 0 || raw.charAt(closeStart + 4) === "" || raw.charAt(closeStart + 4) === "\n";
489
- if (!cleanClose) {
490
- throw new LoreError(
491
- "validation",
492
- `frontmatter in ${path} has a malformed closing fence (extra characters after the closing ---)`,
493
- "close the frontmatter with a line containing exactly `---` and nothing else",
494
- { path },
495
- );
496
- }
497
-
498
- const yamlText = closeStart < 0 ? raw.slice(openEnd) : raw.slice(openEnd, closeStart);
499
- if (yamlText.replace(/^[ \t]*#.*$/gm, "").trim() === "") {
500
- return { present: false, reason: "missing" };
501
- }
502
- let data: unknown;
503
- try {
504
- data = parseYamlFrontmatter(yamlText);
505
- } catch (cause) {
506
- throw new LoreError(
507
- "validation",
508
- `frontmatter in ${path} is not valid YAML: ${singleLine(deriveMessage(cause))}`,
509
- "fix the YAML syntax inside the --- frontmatter fences",
510
- { path },
511
- );
512
- }
513
-
514
- if (data === null || data === undefined) return { present: false, reason: "missing" };
515
- if (typeof data !== "object" || Array.isArray(data)) {
516
- return { present: false, reason: "non-mapping" };
517
- }
518
- if (Object.getOwnPropertyNames(data).length === 0) {
519
- // A missing fence, an empty fence, and a bare `null` share one
520
- // "no usable frontmatter mapping" outcome.
521
- return { present: false, reason: "missing" };
522
- }
523
- const bodyStart = closeStart < 0 ? raw.length : closeStart + (raw.charAt(closeStart + 4) === "\n" ? 5 : 4);
524
- return { present: true, frontmatter: data as Record<string, unknown>, body: raw.slice(bodyStart) };
525
- }
526
-
527
- /**
528
- * The `validation` {@link LoreError} {@link parseConcept} raises when frontmatter
529
- * is absent — the strict-parse counterpart to {@link tryParseConcept} returning
530
- * `null`. The message matches the absent `reason` so the diagnostic points at the
531
- * real fix.
532
- */
533
- function absentFrontmatterError(path: string, reason: "non-mapping" | "missing"): LoreError {
534
- if (reason === "non-mapping") {
535
- return new LoreError(
536
- "validation",
537
- `frontmatter in ${path} must be a YAML mapping`,
538
- "write frontmatter as `key: value` lines between the --- fences",
539
- { path },
540
- );
541
- }
542
- return new LoreError(
543
- "validation",
544
- `${path} has no usable frontmatter (it is missing, empty, or null); a concept file needs at least a \`type:\``,
545
- "add a frontmatter block, e.g. `---` then `type: Reference` then `---`",
546
- { path },
547
- );
548
- }
549
-
550
- /**
551
- * Reorder a frontmatter mapping into canonical emission order — the profile's
552
- * {@link Profile.canonicalKeyOrder} first (those present, in order), then every remaining key in
553
- * its existing order. The order is the active profile's field-declaration order (ADR-0011
554
- * append-slot), so a custom profile drives where its own fields sit; unknown producer keys always
555
- * trail verbatim.
556
- *
557
- * The result is a **null-prototype** object so a literal `__proto__` data key (which
558
- * js-yaml stores as an own property, a tolerated OKF producer extension) is assigned
559
- * as an own property rather than hitting the prototype setter — no key is dropped
560
- * (ADR-0011 §5). This mirrors the `Object.create(null)` technique errors.ts uses for
561
- * the same prototype-pollution hazard; js-yaml dumps a null-prototype object the same
562
- * as a plain one. Reads are plain index access: an own `__proto__` *data* property
563
- * shadows the inherited accessor, so `frontmatter["__proto__"]` returns its value (no
564
- * descriptor read needed); the null-proto target is what makes the *write* side safe.
565
- */
566
- function canonicalize(frontmatter: Record<string, unknown>, keyOrder: readonly string[]): Record<string, unknown> {
567
- const ordered: Record<string, unknown> = Object.create(null);
568
- for (const key of keyOrder) {
569
- if (Object.hasOwn(frontmatter, key)) {
570
- ordered[key] = frontmatter[key];
571
- }
572
- }
573
- for (const key of Object.getOwnPropertyNames(frontmatter)) {
574
- if (!Object.hasOwn(ordered, key)) {
575
- ordered[key] = frontmatter[key];
576
- }
577
- }
578
- return ordered;
579
- }
580
-
581
- /**
582
- * Derive a concept id from its path: the path minus a trailing `.md`, matched
583
- * **case-insensitively** so the same on-disk file yields one id whether referenced
584
- * as `Foo.md` or `Foo.MD` (a real divergence on case-insensitive filesystems).
585
- *
586
- * Exported as the single source of the path→id rule so the bundle layer
587
- * ({@link loadBundle}) strips the `.md` suffix the exact same way when resolving a
588
- * cross-link target to a concept id — the resolution must agree byte-for-byte with
589
- * how this module derived the id, or a valid link would dangle. The path is
590
- * POSIX-normalized first (`adr/./x.md` and `a/../adr/x.md` collapse to `adr/x`), so
591
- * a stored id and a link that resolves to it agree even when one side carries a
592
- * redundant `.`/`..` segment.
593
- */
594
- export function idFromPath(path: string): string {
595
- const normalized = posix.normalize(path);
596
- return /\.md$/i.test(normalized) ? normalized.slice(0, -3) : normalized;
597
- }