@opum-ai/lore 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +306 -0
- package/bin/lore.cjs +109 -0
- package/package.json +67 -0
- package/src/adapters/backlog.ts +1084 -0
- package/src/adapters/git.ts +221 -0
- package/src/cli.ts +667 -0
- package/src/commands/agent.ts +301 -0
- package/src/commands/agents.ts +302 -0
- package/src/commands/args.ts +209 -0
- package/src/commands/changed.ts +70 -0
- package/src/commands/check.ts +1031 -0
- package/src/commands/codex-bridge.ts +49 -0
- package/src/commands/concurrency.ts +48 -0
- package/src/commands/context.ts +292 -0
- package/src/commands/discover.ts +89 -0
- package/src/commands/explorer.ts +253 -0
- package/src/commands/export.ts +93 -0
- package/src/commands/fswrite.ts +928 -0
- package/src/commands/graph.ts +291 -0
- package/src/commands/help.ts +151 -0
- package/src/commands/impact.ts +59 -0
- package/src/commands/init.ts +583 -0
- package/src/commands/instructions.ts +91 -0
- package/src/commands/link.ts +929 -0
- package/src/commands/new.ts +476 -0
- package/src/commands/orphans.ts +457 -0
- package/src/commands/path.ts +67 -0
- package/src/commands/provenance.ts +68 -0
- package/src/commands/query.ts +312 -0
- package/src/commands/reconcile-shared.ts +280 -0
- package/src/commands/rename.ts +585 -0
- package/src/commands/replace.ts +320 -0
- package/src/commands/scaffold.ts +346 -0
- package/src/commands/schema.ts +293 -0
- package/src/commands/snapshot.ts +130 -0
- package/src/commands/supersede.ts +400 -0
- package/src/commands/sync.ts +371 -0
- package/src/commands/tasks.ts +271 -0
- package/src/commands/traversal.ts +151 -0
- package/src/commands/validate.ts +226 -0
- package/src/config.ts +598 -0
- package/src/core/agent-bridge.ts +287 -0
- package/src/core/agent-context.ts +498 -0
- package/src/core/agent-profile.ts +447 -0
- package/src/core/bundle.ts +893 -0
- package/src/core/check.ts +853 -0
- package/src/core/codex-bridge.ts +100 -0
- package/src/core/concept.ts +597 -0
- package/src/core/consumer-scaffold.ts +433 -0
- package/src/core/context.ts +271 -0
- package/src/core/explorer-contract.ts +441 -0
- package/src/core/explorer-qualification.ts +58 -0
- package/src/core/explorer.ts +518 -0
- package/src/core/finding.ts +31 -0
- package/src/core/graph.ts +201 -0
- package/src/core/indexes.ts +436 -0
- package/src/core/instructions.ts +209 -0
- package/src/core/ladybug-driver.ts +1795 -0
- package/src/core/ladybug-lifecycle.ts +1178 -0
- package/src/core/ladybug-native.ts +95 -0
- package/src/core/ladybug-source.ts +667 -0
- package/src/core/links.ts +681 -0
- package/src/core/log.ts +253 -0
- package/src/core/managed-block.ts +540 -0
- package/src/core/manifest.ts +718 -0
- package/src/core/order.ts +13 -0
- package/src/core/profile.ts +1007 -0
- package/src/core/projection.ts +195 -0
- package/src/core/query.ts +542 -0
- package/src/core/reconcile.ts +236 -0
- package/src/core/replace.ts +419 -0
- package/src/core/retrieval.ts +213 -0
- package/src/core/rewrite.ts +940 -0
- package/src/core/scaffold.ts +255 -0
- package/src/core/schema.ts +366 -0
- package/src/core/snapshot-runtime.ts +52 -0
- package/src/core/snapshot-store.ts +287 -0
- package/src/core/snapshot.ts +711 -0
- package/src/core/template.ts +429 -0
- package/src/core/traversal.ts +487 -0
- package/src/core/validate.ts +517 -0
- package/src/core/workspace-contract.ts +473 -0
- package/src/core/workspace-projection.ts +365 -0
- package/src/core/workspace-retrieval.ts +196 -0
- package/src/core/workspace-source.ts +174 -0
- package/src/errors.ts +697 -0
- package/src/meta.ts +7 -0
- package/src/output.ts +589 -0
- package/src/scripts/upstream-backlog-watch.ts +288 -0
- package/src/state.ts +390 -0
|
@@ -0,0 +1,893 @@
|
|
|
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 `[](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
|
+
}
|