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