@opum-ai/lore 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +33 -22
  2. package/bin/lore.cjs +35 -7
  3. package/package.json +17 -17
  4. package/src/adapters/backlog.ts +0 -1084
  5. package/src/adapters/git.ts +0 -221
  6. package/src/cli.ts +0 -667
  7. package/src/commands/agent.ts +0 -301
  8. package/src/commands/agents.ts +0 -302
  9. package/src/commands/args.ts +0 -209
  10. package/src/commands/changed.ts +0 -70
  11. package/src/commands/check.ts +0 -1031
  12. package/src/commands/codex-bridge.ts +0 -49
  13. package/src/commands/concurrency.ts +0 -48
  14. package/src/commands/context.ts +0 -292
  15. package/src/commands/discover.ts +0 -89
  16. package/src/commands/explorer.ts +0 -253
  17. package/src/commands/export.ts +0 -93
  18. package/src/commands/fswrite.ts +0 -928
  19. package/src/commands/graph.ts +0 -291
  20. package/src/commands/help.ts +0 -151
  21. package/src/commands/impact.ts +0 -59
  22. package/src/commands/init.ts +0 -583
  23. package/src/commands/instructions.ts +0 -91
  24. package/src/commands/link.ts +0 -929
  25. package/src/commands/new.ts +0 -476
  26. package/src/commands/orphans.ts +0 -457
  27. package/src/commands/path.ts +0 -67
  28. package/src/commands/provenance.ts +0 -68
  29. package/src/commands/query.ts +0 -312
  30. package/src/commands/reconcile-shared.ts +0 -280
  31. package/src/commands/rename.ts +0 -585
  32. package/src/commands/replace.ts +0 -320
  33. package/src/commands/scaffold.ts +0 -346
  34. package/src/commands/schema.ts +0 -293
  35. package/src/commands/snapshot.ts +0 -130
  36. package/src/commands/supersede.ts +0 -400
  37. package/src/commands/sync.ts +0 -371
  38. package/src/commands/tasks.ts +0 -271
  39. package/src/commands/traversal.ts +0 -151
  40. package/src/commands/validate.ts +0 -226
  41. package/src/config.ts +0 -598
  42. package/src/core/agent-bridge.ts +0 -287
  43. package/src/core/agent-context.ts +0 -498
  44. package/src/core/agent-profile.ts +0 -447
  45. package/src/core/bundle.ts +0 -893
  46. package/src/core/check.ts +0 -853
  47. package/src/core/codex-bridge.ts +0 -100
  48. package/src/core/concept.ts +0 -597
  49. package/src/core/consumer-scaffold.ts +0 -433
  50. package/src/core/context.ts +0 -271
  51. package/src/core/explorer-contract.ts +0 -441
  52. package/src/core/explorer-qualification.ts +0 -58
  53. package/src/core/explorer.ts +0 -518
  54. package/src/core/finding.ts +0 -31
  55. package/src/core/graph.ts +0 -201
  56. package/src/core/indexes.ts +0 -436
  57. package/src/core/instructions.ts +0 -209
  58. package/src/core/ladybug-driver.ts +0 -1795
  59. package/src/core/ladybug-lifecycle.ts +0 -1178
  60. package/src/core/ladybug-native.ts +0 -95
  61. package/src/core/ladybug-source.ts +0 -667
  62. package/src/core/links.ts +0 -681
  63. package/src/core/log.ts +0 -253
  64. package/src/core/managed-block.ts +0 -540
  65. package/src/core/manifest.ts +0 -718
  66. package/src/core/order.ts +0 -13
  67. package/src/core/profile.ts +0 -1007
  68. package/src/core/projection.ts +0 -195
  69. package/src/core/query.ts +0 -542
  70. package/src/core/reconcile.ts +0 -236
  71. package/src/core/replace.ts +0 -419
  72. package/src/core/retrieval.ts +0 -213
  73. package/src/core/rewrite.ts +0 -940
  74. package/src/core/scaffold.ts +0 -255
  75. package/src/core/schema.ts +0 -366
  76. package/src/core/snapshot-runtime.ts +0 -52
  77. package/src/core/snapshot-store.ts +0 -287
  78. package/src/core/snapshot.ts +0 -711
  79. package/src/core/template.ts +0 -429
  80. package/src/core/traversal.ts +0 -487
  81. package/src/core/validate.ts +0 -517
  82. package/src/core/workspace-contract.ts +0 -473
  83. package/src/core/workspace-projection.ts +0 -365
  84. package/src/core/workspace-retrieval.ts +0 -196
  85. package/src/core/workspace-source.ts +0 -174
  86. package/src/errors.ts +0 -697
  87. package/src/meta.ts +0 -7
  88. package/src/output.ts +0 -589
  89. package/src/scripts/upstream-backlog-watch.ts +0 -288
  90. package/src/state.ts +0 -390
package/src/core/check.ts DELETED
@@ -1,853 +0,0 @@
1
- /**
2
- * check.ts — the **pure** engine behind `lore check`'s link/anchor + portability passes.
3
- *
4
- * `lore check` is lore's read-only CI **drift gate** (ADR-0007). Of its four specified
5
- * passes, this module owns the two that are deterministic and dependency-free today:
6
- *
7
- * - **Internal cross-link + heading-anchor validation** (the gate, exit `6`): every
8
- * relative `.md` link target in the bundle must resolve to a real file, and every
9
- * `#fragment` must resolve to a real heading slug in the target file. A broken link or
10
- * a rotted anchor is an **error** {@link CheckFinding}.
11
- * - **Portability lint** (advisory, warn-only): non-portable link *form* (via the shared
12
- * {@link validateLink} classifier) plus a body-text scan for Obsidian-isms (wikilinks,
13
- * embeds, callouts, highlights, `%%`-comments). Every such finding is a **warning** that
14
- * never fails the gate on its own (ADR-0007, portable-markdown.md).
15
- * - **Status reconciliation + managed-block drift** (LORE-27, the gate, exit `6`): a `Story`
16
- * whose persisted `status` no longer matches its live Backlog rollup, or whose
17
- * `<!-- lore:tasks -->` region no longer matches what {@link regenerateTaskBlock} would
18
- * produce from current task data, is drift — the same condition `lore sync` would fix by
19
- * writing. {@link reconcileDriftFindings} is the pure judgment; resolving each linked task's
20
- * live status (the Backlog JSON adapter) and reading each concept's on-disk bytes are
21
- * command-layer IO (`commands/check.ts`, via the shared `commands/reconcile-shared.ts`
22
- * gather it also feeds to `lore sync`) — ADR-0014 keeps that IO out of core. Both findings
23
- * are **errors**: unlike the portability lint, drift always gates (ADR-0007), never merely
24
- * advisory under `--strict`.
25
- *
26
- * External-URL liveness is opt-in and deferred (no networking in core).
27
- *
28
- * Per the core contract (lore-design §2.1) everything here is pure: `{ path, raw }` files
29
- * in, typed findings out — no filesystem, no printing, no flags, no `process.exit`. The
30
- * command layer (`commands/check.ts`) discovers and reads the bundle's files and hands
31
- * their bytes here; this module owns the judgement.
32
- *
33
- * ### Resolution is against the whole bundle, not just concepts
34
- *
35
- * Link existence is checked against **every `.md` file** in the bundle — concepts *and*
36
- * the frontmatter-free `index.md`/`log.md` hubs — not just the concept graph. This is
37
- * exactly what `indexes.ts` documents as required: a generated hub links to sub-indexes
38
- * that are *not* concepts, so resolving only against the concept map would flag every
39
- * correct hub link as broken. Resolving against file membership makes those reserved
40
- * targets resolve with no special case.
41
- *
42
- * A link whose target resolves **above the bundle root** (a `../`-escaping path, e.g. a
43
- * managed `lore:tasks` block's link to a `backlog/tasks/*.md` file) is **out of scope**
44
- * here — the bundle membership cannot see outside `docs/` — and is skipped rather than
45
- * reported broken, the same way an external URL is. (Those cross-bundle links arrive with
46
- * `lore sync`/LORE-26; validating them is that pass's concern.)
47
- */
48
-
49
- import { posix } from "node:path";
50
- import GithubSlugger, { slug as githubSlug } from "github-slugger";
51
- import * as ipaddr from "ipaddr.js";
52
- import * as yaml from "js-yaml";
53
- import type { Nodes } from "mdast";
54
- import { fromMarkdown } from "mdast-util-from-markdown";
55
- import { extractLinkTargets, nodeText, walkMdast } from "./bundle";
56
- import { idFromPath, normalizeInput } from "./concept";
57
- import type { Finding, Severity } from "./finding";
58
- import { decodeTarget, isExternalTarget, pathPart, validateLink } from "./links";
59
- import { type ManagedTaskRow, regenerateTaskBlock } from "./managed-block";
60
- import type { ReconciledStatus } from "./reconcile";
61
-
62
- /** `error` fails the gate (exit `6`); `warning` is advisory (fails only under `--strict`). The shared {@link Severity}. */
63
- export type CheckSeverity = Severity;
64
-
65
- /**
66
- * Which check produced a {@link CheckFinding}. `broken-link`/`broken-anchor` and
67
- * `status-drift`/`managed-block-drift` are all error-tier gate findings; `portability` is the
68
- * warn-tier lint; `external-link` is the opt-in, non-deterministic liveness advisory
69
- * (`--external`) that never fails the gate (ADR-0007).
70
- */
71
- export type CheckRule =
72
- | "broken-link"
73
- | "broken-anchor"
74
- | "status-drift"
75
- | "managed-block-drift"
76
- | "portability"
77
- | "external-link";
78
-
79
- /**
80
- * One problem found in the bundle, attributed to the file that carries it: the shared
81
- * {@link Finding} (narrowed to `check`'s rules) plus that per-file attribution.
82
- */
83
- export type CheckFinding = Finding<CheckRule> & {
84
- /** The bundle-root-relative POSIX path of the file the finding is in. */
85
- readonly file: string;
86
- };
87
-
88
- /** One bundle file handed to {@link checkBundle}: its **bundle-root-relative** path and raw bytes. */
89
- export interface CheckInputFile {
90
- /** Bundle-root-relative POSIX path (e.g. `adr/0007-x.md`) — the id space links resolve within. */
91
- readonly path: string;
92
- /** The file's raw UTF-8 content (frontmatter fence included; stripped here). */
93
- readonly raw: string;
94
- }
95
-
96
- /** The aggregate result of a `lore check` run over a bundle. */
97
- export interface CheckReport {
98
- /** Every deterministic finding, in file-then-document order (the gate + portability lint). */
99
- readonly findings: readonly CheckFinding[];
100
- /**
101
- * Total error-severity findings — the gate count. This module's own passes (`checkBundle`)
102
- * contribute `broken-link`/`broken-anchor`; `commands/check.ts` folds in `status-drift`/
103
- * `managed-block-drift` (LORE-27) on top via the same {@link tallySeverity}, so a consumer of the
104
- * final `check.report` should not assume this counts only link/anchor problems.
105
- */
106
- readonly errorCount: number;
107
- /** Total warning-severity findings (portability) — advisory. */
108
- readonly warningCount: number;
109
- /** Number of files examined. */
110
- readonly fileCount: number;
111
- /**
112
- * Whether every pass that was supposed to run for this report actually finished. Always `true`
113
- * from this module (`checkBundle`/`summarize` are fully synchronous and never partial) — it only
114
- * ever goes `false` in `commands/check.ts`'s emitted report, when the async status/managed-block
115
- * reconciliation pass (LORE-27) errors mid-run for some bundle root (a missing linked task, a bad
116
- * status-flow config, a malformed managed block). That failure is a real gate error and is always
117
- * re-thrown after emitting, but the findings collected up to that point can still have
118
- * `errorCount === 0` (the failure short-circuited before any finding was produced) — indistinguishable
119
- * from a genuinely clean, complete run without this field (LORE-112). A JSON consumer reading only
120
- * stdout should treat `complete: false` the same as a non-zero exit code / a caught rejection.
121
- */
122
- readonly complete: boolean;
123
- /**
124
- * Opt-in external-URL **liveness** results (`--external`), each an `external-link` warning. These
125
- * are **non-deterministic** (they depend on the network), so they are kept out of the gate
126
- * entirely: never folded into {@link errorCount}/{@link warningCount}, never affecting the exit
127
- * code — not even under `--strict` (ADR-0007). Absent (undefined) unless `--external` ran. Core
128
- * leaves this empty; the command layer fills it (the probing is network IO, ADR-0014).
129
- */
130
- readonly externalFindings?: readonly CheckFinding[];
131
- }
132
-
133
- /** One external URL discovered in the bundle, attributed to its file — the `--external` probe's worklist item. */
134
- export interface ExternalLink {
135
- /** The bundle-root-relative POSIX path of the file the link is in. */
136
- readonly file: string;
137
- /** The external `http(s)` URL as authored. */
138
- readonly url: string;
139
- }
140
-
141
- /**
142
- * Collect the distinct `http(s)` link targets per file — the worklist the opt-in `--external`
143
- * liveness probe (command layer) fetches. Pure: it parses the same bundle bodies and returns URLs,
144
- * touching no network (ADR-0014). Only `http`/`https` are returned; other external schemes
145
- * (`mailto:`, `tel:`, protocol-relative `//host`) are not liveness-checkable and are skipped, as
146
- * are duplicates **within a file** (one finding per file per URL). Parsing here, rather than
147
- * reusing {@link checkBundle}'s internal parse, keeps that gate function's signature unpolluted by
148
- * the optional, non-deterministic feature.
149
- */
150
- export function collectExternalLinks(files: readonly CheckInputFile[]): ExternalLink[] {
151
- const links: ExternalLink[] = [];
152
- for (const file of files) {
153
- const tree = fromMarkdown(bodyText(file.raw));
154
- const seen = new Set<string>();
155
- for (const target of extractLinkTargets(tree)) {
156
- const url = target.trim();
157
- if (HTTP_URL.test(url) && !seen.has(url)) {
158
- seen.add(url);
159
- links.push({ file: file.path, url });
160
- }
161
- }
162
- }
163
- return links;
164
- }
165
-
166
- /** An `http`/`https` URL — the only externally-probeable link scheme. */
167
- const HTTP_URL = /^https?:\/\//i;
168
-
169
- /**
170
- * Whether `ip` (a resolved IPv4 or IPv6 address, never a hostname) falls inside a loopback,
171
- * link-local, or private/reserved range — the destination-classification half of the
172
- * `--external` liveness probe's SSRF guard (LORE-71: the probe otherwise fetches any URL a
173
- * bundle author writes, including one pointed at a loopback/private/cloud-metadata address).
174
- * Pure and deterministic (no DNS, no network): the command layer resolves a URL's hostname to
175
- * its actual IP address(es) first (`commands/check.ts`'s injectable DNS seam — resolution
176
- * itself is IO, ADR-0014) and classifies each one here, BEFORE ever issuing a request for it.
177
- *
178
- * IPv4-mapped IPv6 is normalized to IPv4 through ipaddr.js before the explicit IPv4 policy ranges
179
- * are matched, so an attacker cannot dodge an IPv4-only blocklist by requesting the exact same
180
- * address in its mapped form — a well-known SSRF-filter bypass technique.
181
- *
182
- * Not an exhaustive IANA special-purpose-registry sweep (documentation ranges like
183
- * `192.0.2.0/24` are omitted as low real-world SSRF risk) — scoped to the ranges an attacker
184
- * can actually reach something interesting through: loopback, link-local (where the canonical
185
- * cloud-metadata address `169.254.169.254` lives), RFC1918 private space, and carrier-grade NAT.
186
- */
187
- export function classifyAddress(ip: string): { readonly blocked: boolean; readonly reason?: string } {
188
- const parsed = parseAddressLiteral(ip);
189
- if (parsed === null) {
190
- return { blocked: true, reason: `"${ip}" is not a valid IP address literal` };
191
- }
192
- if (isDottedIpv4CompatibleSpelling(ip, parsed)) {
193
- return { blocked: true, reason: IPV4_COMPATIBLE_LABEL };
194
- }
195
- const address = parsed instanceof ipaddr.IPv6 && parsed.isIPv4MappedAddress() ? parsed.toIPv4Address() : parsed;
196
- for (const range of BLOCKED_ADDRESS_RANGES) {
197
- if (address.kind() === range.network.kind() && address.match(range.network, range.prefixBits)) {
198
- return { blocked: true, reason: range.label };
199
- }
200
- }
201
- return { blocked: false };
202
- }
203
-
204
- /**
205
- * Whether a value is a strict address literal under the same package-backed parser used by
206
- * {@link classifyAddress}. IPv4 is deliberately limited to four-part decimal: ipaddr.js also
207
- * supports legacy inet_aton hex, octal, short-part, and integer spellings, but accepting those
208
- * would widen Lore's resolver boundary and create ambiguous SSRF inputs. IPv6 retains the
209
- * package's supported compressed, embedded-IPv4, case, and zone-id forms.
210
- */
211
- export function isAddressLiteral(ip: string): boolean {
212
- return parseAddressLiteral(ip) !== null;
213
- }
214
-
215
- /** Parse and normalize the accepted address-literal grammar without performing any IO. */
216
- function parseAddressLiteral(ip: string): ipaddr.IPv4 | ipaddr.IPv6 | null {
217
- if (ipaddr.IPv4.isValidFourPartDecimal(ip)) {
218
- return ipaddr.IPv4.parse(ip);
219
- }
220
- if (ipaddr.IPv6.isValid(ip)) {
221
- return ipaddr.IPv6.parse(ip);
222
- }
223
- return null;
224
- }
225
-
226
- const IPV4_COMPATIBLE_LABEL = "deprecated IPv4-compatible form (::/96)";
227
-
228
- /**
229
- * ipaddr.js normalizes the historical dotted spelling `::127.0.0.1` to the mapped address
230
- * `::ffff:127.0.0.1`. Lore has always treated that authored spelling as the deprecated `::/96`
231
- * policy range instead. Preserve that policy distinction before mapped-address normalization;
232
- * parsing and validation still belong to ipaddr.js.
233
- */
234
- function isDottedIpv4CompatibleSpelling(ip: string, parsed: ipaddr.IPv4 | ipaddr.IPv6): boolean {
235
- if (!(parsed instanceof ipaddr.IPv6) || !parsed.isIPv4MappedAddress()) {
236
- return false;
237
- }
238
- const withoutZone = ip.split("%", 1)[0] ?? ip;
239
- if (!withoutZone.includes(".")) {
240
- return false;
241
- }
242
- const prefix = withoutZone.slice(0, withoutZone.lastIndexOf(":")).toLowerCase();
243
- return !prefix.endsWith(":ffff");
244
- }
245
-
246
- /** One explicit Lore-owned policy CIDR, parsed and matched by ipaddr.js. */
247
- interface AddressRange {
248
- readonly network: ipaddr.IPv4 | ipaddr.IPv6;
249
- readonly prefixBits: number;
250
- readonly label: string;
251
- }
252
-
253
- /** Parse one hard-coded policy CIDR once at module initialization. */
254
- function blockedRange(cidr: string, label: string): AddressRange {
255
- const [network, prefixBits] = ipaddr.parseCIDR(cidr);
256
- return { network, prefixBits, label };
257
- }
258
-
259
- /**
260
- * Loopback, link-local, and private/reserved ranges refused by default (LORE-71 AC1): IPv4's
261
- * "this network" (`0.0.0.0/8`), RFC1918 private space, carrier-grade NAT (`100.64.0.0/10`, used
262
- * by some cloud metadata reachability paths), loopback, and link-local — plus their IPv6
263
- * counterparts (loopback, unspecified, link-local, unique-local).
264
- *
265
- * The last two entries block two LEGACY IPv6 forms wholesale, not by re-deriving whether their
266
- * embedded IPv4 address happens to be blocked: the deprecated "IPv4-compatible" form (`::/96`,
267
- * e.g. `::169.254.169.254` — textually similar to but numerically DISTINCT from the IPv4-MAPPED
268
- * form `::ffff:a.b.c.d` this file already unifies into the IPv4 table) and the NAT64 well-known
269
- * prefix (`64:ff9b::/96`, RFC 6052 — a NAT64 gateway on some IPv6-only networks translates any
270
- * address in this range to its embedded IPv4 destination). An independent adversarial review
271
- * confirmed neither form is honored as "reach the embedded IPv4 address" by a plain `fetch()` on
272
- * an ordinary (non-NAT64) network — so this is defense-in-depth for an IPv6-only/NAT64-configured
273
- * runner, not a fix for a demonstrated bypass on typical CI. Blocking the WHOLE `::/96` block
274
- * (rather than trying to re-classify its embedded 32 bits) is deliberate: both mechanisms are
275
- * deprecated/translation-only address spaces with no legitimate use for checking a documentation
276
- * link, and `::/96` numerically also contains `::`/`::1` themselves (same values, different
277
- * spellings) — re-deriving "is the embedded IPv4 blocked" for those two would incorrectly treat
278
- * the IPv6 loopback/unspecified addresses as if they meant IPv4 `0.0.0.1`/`0.0.0.0`, which is not
279
- * how either address is actually used in practice.
280
- */
281
- const BLOCKED_ADDRESS_RANGES: readonly AddressRange[] = [
282
- blockedRange("0.0.0.0/8", "this-network (0.0.0.0/8)"),
283
- blockedRange("10.0.0.0/8", "private (10.0.0.0/8)"),
284
- blockedRange("100.64.0.0/10", "carrier-grade NAT (100.64.0.0/10)"),
285
- blockedRange("127.0.0.0/8", "loopback (127.0.0.0/8)"),
286
- blockedRange("169.254.0.0/16", "link-local (169.254.0.0/16)"),
287
- blockedRange("172.16.0.0/12", "private (172.16.0.0/12)"),
288
- blockedRange("192.168.0.0/16", "private (192.168.0.0/16)"),
289
- blockedRange("::1/128", "loopback (::1)"),
290
- blockedRange("::/128", "unspecified (::)"),
291
- blockedRange("fe80::/10", "link-local (fe80::/10)"),
292
- blockedRange("fc00::/7", "unique-local (fc00::/7)"),
293
- blockedRange("::/96", IPV4_COMPATIBLE_LABEL),
294
- blockedRange("64:ff9b::/96", "NAT64 well-known prefix (64:ff9b::/96)"),
295
- ];
296
-
297
- /**
298
- * Run the link/anchor + portability passes over a whole bundle and aggregate the findings.
299
- * Pure over its inputs (the command layer does the reading), so the resolution and slug
300
- * logic are testable without the filesystem.
301
- *
302
- * Two passes over the files: the first parses every file **once** and indexes its
303
- * heading-slug set under its bundle-relative id; the second resolves each file's
304
- * links/anchors against that index (the id set is the bundle membership the link gate
305
- * resolves against) and runs the portability lint over the same parsed tree. The split is
306
- * what lets a link resolve to a file walked *after* the linking file (a forward reference)
307
- * — every member is known before any link is checked — and the single parse per file is
308
- * shared across the heading, link, and portability consumers.
309
- */
310
- export function checkBundle(files: readonly CheckInputFile[]): CheckReport {
311
- // Pass 1 — index: parse each file's body once into an mdast tree, keyed by bundle-relative
312
- // id, and record its heading-slug set. The id key set is the membership; every file is
313
- // indexed before any link is checked, so a forward reference resolves.
314
- const prepared: { file: CheckInputFile; tree: Nodes; id: string }[] = [];
315
- const slugsById = new Map<string, ReadonlySet<string>>();
316
- for (const file of files) {
317
- const tree = fromMarkdown(bodyText(file.raw));
318
- const id = idFromPath(file.path);
319
- prepared.push({ file, tree, id });
320
- slugsById.set(id, extractHeadingSlugs(tree));
321
- }
322
-
323
- // Pass 2 — judge: per file, the link/anchor gate then the portability lint, over the one
324
- // shared parse. Membership is `slugsById` itself — every file id is a key.
325
- const findings: CheckFinding[] = [];
326
- for (const { file, tree, id } of prepared) {
327
- const dir = posix.dirname(file.path);
328
- const targets = extractLinkTargets(tree);
329
- for (const target of targets) {
330
- findings.push(...linkFindings(target, file.path, dir, id, slugsById));
331
- }
332
- for (const target of targets) {
333
- for (const finding of validateLink(target)) {
334
- findings.push({ severity: "warning", rule: "portability", file: file.path, message: finding.message });
335
- }
336
- }
337
- findings.push(...portabilityScan(tree, file.path));
338
- }
339
-
340
- return summarize(findings, files.length);
341
- }
342
-
343
- // ── Status + managed-block drift (the gate) ──────────────────────────────────────
344
-
345
- /** One `tasks:`-linked concept's already-resolved reconciliation data, for {@link reconcileDriftFindings}. */
346
- export interface ReconcileDriftInput {
347
- /** The bundle-relative POSIX path of the concept, attributed on any finding (bundle-label-prefixed by the caller in multi-root mode). */
348
- readonly path: string;
349
- /** The concept's persisted `frontmatter.status`, as currently on disk. */
350
- readonly currentStatus: unknown;
351
- /** The recomputed status (`core/reconcile.ts`'s `reconcileStatus`); `null` only if the caller passes a concept with no linked tasks (never drift either way). */
352
- readonly newStatus: ReconciledStatus | null;
353
- /** The concept's full raw file bytes, as currently on disk (LF-normalized). */
354
- readonly original: string;
355
- /** The linked tasks' live data, in the concept's `tasks:` order — {@link regenerateTaskBlock}'s `rows`. */
356
- readonly rows: readonly ManagedTaskRow[];
357
- /** The concept's repo-relative path, for {@link regenerateTaskBlock}'s link computation. */
358
- readonly docPath: string;
359
- /**
360
- * Whether `lore sync` would actually reconcile this concept if run — `sync` only ever operates on
361
- * the default `docs/` bundle (no concept of an alternate root today), while `check` also supports
362
- * checking any named bundle root (LORE-30's multi-root discovery). Decided by the command layer
363
- * (which knows the bundle root as the user actually named it, canonicalized) rather than guessed
364
- * here from `docPath`'s string shape — a non-canonical but equivalent spelling of the default root
365
- * (`./docs`, a trailing slash) must not silently omit the hint.
366
- */
367
- readonly fixable: boolean;
368
- }
369
-
370
- /**
371
- * The drift findings for one already-reconciled concept: a **status-drift** error when the
372
- * recomputed status differs from what is persisted, and a **managed-block-drift** error when
373
- * re-rendering the `<!-- lore:tasks -->` region from `rows` would change the file's bytes. Both
374
- * are independent — a concept can have one, both, or neither — and both are `error` severity
375
- * (ADR-0007: this gate is not a `--strict`-only advisory, unlike the portability lint).
376
- *
377
- * Reuses the exact pure engines `lore sync` writes with ({@link reconcileStatus},
378
- * {@link regenerateTaskBlock}) so drift can never differ from what a `sync` run would fix.
379
- *
380
- * @throws LoreError `validation` (exit 6) — {@link regenerateTaskBlock}'s own contract — when the
381
- * concept's managed-block markers are missing, duplicated, crossed, or a collapsed same-line
382
- * begin/end pair; `check` refuses to guess at a corrupted region rather than reporting a soft
383
- * finding for it, the read-time mirror of `sync`'s "never writes a partial block."
384
- */
385
- export function reconcileDriftFindings(input: ReconcileDriftInput): CheckFinding[] {
386
- if (input.newStatus === null) {
387
- // No linked tasks to roll up or render a managed block from — the documented "never drift
388
- // either way" contract (this interface's own `newStatus` doc comment). Neither drift check
389
- // below is meaningful without a recomputed status, so both are skipped together rather than
390
- // letting the managed-block regeneration run unconditionally on a concept this function has
391
- // nothing to reconcile.
392
- return [];
393
- }
394
- const fixable = input.fixable;
395
- const findings: CheckFinding[] = [];
396
- if (input.newStatus !== input.currentStatus) {
397
- // `status:` is schema-nullish (profile.ts's optional fields accept both an OMITTED key --
398
- // `undefined` -- and an explicit empty/`null` scalar), and `JSON.stringify` renders each
399
- // inconsistently: `undefined` becomes the bare, unquoted word "undefined" (not a string at all),
400
- // while `null` at least stringifies correctly but reads as the literal word "null" rather than
401
- // "no status set yet". Both are normalized to the same friendly placeholder, distinctly from
402
- // `newStatus`'s always-quoted form.
403
- const currentDisplay =
404
- input.currentStatus === undefined || input.currentStatus === null
405
- ? "(unset)"
406
- : JSON.stringify(input.currentStatus);
407
- const hint = fixable ? " — run `lore sync` to reconcile" : "";
408
- findings.push({
409
- severity: "error",
410
- rule: "status-drift",
411
- file: input.path,
412
- message: `status is ${currentDisplay} but the linked tasks recompute to ${JSON.stringify(input.newStatus)}${hint}`,
413
- });
414
- }
415
- const regenerated = regenerateTaskBlock(input.original, input.rows, { docPath: input.docPath });
416
- if (regenerated !== input.original) {
417
- const hint = fixable ? " — run `lore sync` to regenerate it from live task data" : "";
418
- findings.push({
419
- severity: "error",
420
- rule: "managed-block-drift",
421
- file: input.path,
422
- message: `the <!-- lore:tasks --> block is stale${hint}`,
423
- });
424
- }
425
- return findings;
426
- }
427
-
428
- // ── Link / anchor resolution (the gate) ──────────────────────────────────────────
429
-
430
- /**
431
- * The error findings for one body-link **destination**: a broken internal cross-link
432
- * (target file not in the bundle) or a rotted anchor (`#fragment` resolving to no heading
433
- * in the target). Returns `[]` for anything out of the gate's scope — external URLs,
434
- * non-`.md` assets, and cross-bundle (`../`-escaping) targets.
435
- *
436
- * A **same-file** anchor (`[jump](#section)`, a destination that is *only* a fragment) is
437
- * validated against the linking file's own headings, so in-page anchor rot is caught too.
438
- */
439
- function linkFindings(
440
- target: string,
441
- file: string,
442
- dir: string,
443
- fromId: string,
444
- slugsById: ReadonlyMap<string, ReadonlySet<string>>,
445
- ): CheckFinding[] {
446
- const trimmed = target.trim();
447
- const fragment = fragmentOf(trimmed);
448
- const path = pathPart(trimmed);
449
-
450
- if (path === "") {
451
- // A pure `#fragment` — an in-page anchor; resolve it against this file's own headings.
452
- return anchorFindings(target, file, fromId, fragment, slugsById);
453
- }
454
- if (isExternalTarget(path)) {
455
- return []; // external scheme / protocol-relative — not an internal cross-link
456
- }
457
- const decoded = decodeTarget(path);
458
- if (!/\.md$/i.test(decoded)) {
459
- return []; // a non-`.md` asset link — not a concept edge (matches the bundle resolver)
460
- }
461
- // A `/`-absolute destination resolves against the bundle root, not the linking file's
462
- // directory (it is non-portable — `validateLink` warns — but its existence is judged from
463
- // the root so the broken-link message names the path the author meant). A relative path
464
- // joins to the linking dir as usual.
465
- const resolved = posix.normalize(decoded.startsWith("/") ? decoded.slice(1) : posix.join(dir, decoded));
466
- if (resolved === ".." || resolved.startsWith("../")) {
467
- return []; // escapes the bundle root — a cross-bundle link, out of scope for this pass
468
- }
469
- const targetId = idFromPath(resolved);
470
- if (!slugsById.has(targetId)) {
471
- return [
472
- {
473
- severity: "error",
474
- rule: "broken-link",
475
- file,
476
- message: `link "${target}" points at "${targetId}.md", which is not in the bundle`,
477
- },
478
- ];
479
- }
480
- return anchorFindings(target, file, targetId, fragment, slugsById);
481
- }
482
-
483
- /**
484
- * The broken-anchor finding (if any) for a resolved target: an **error** when a non-empty
485
- * `fragment` resolves to no heading slug in the target file (`targetId`). An empty fragment
486
- * (a plain file link) is clean. The fragment is only percent-decoded before the compare — it
487
- * is **not** lower-cased. {@link slugify} already produces lower-case GitHub-style slugs, so a
488
- * fragment that differs from the real anchor only in case (`#My-Section` vs. slug
489
- * `my-section`) must still miss: GitHub (and every other case-sensitive anchor consumer) never
490
- * normalizes the href fragment, so a case mismatch is a real broken anchor, not a cosmetic one.
491
- */
492
- function anchorFindings(
493
- target: string,
494
- file: string,
495
- targetId: string,
496
- fragment: string,
497
- slugsById: ReadonlyMap<string, ReadonlySet<string>>,
498
- ): CheckFinding[] {
499
- const anchor = decodeTarget(fragment);
500
- if (anchor === "") {
501
- return [];
502
- }
503
- const slugs = slugsById.get(targetId);
504
- if (slugs?.has(anchor)) {
505
- return [];
506
- }
507
- return [
508
- {
509
- severity: "error",
510
- rule: "broken-anchor",
511
- file,
512
- message: `link "${target}" has anchor "#${fragment}", which is not a heading in "${targetId}.md"`,
513
- },
514
- ];
515
- }
516
-
517
- /** The fragment of a destination — the text after its first `#`, or `""` when it has none. */
518
- function fragmentOf(target: string): string {
519
- const hash = target.indexOf("#");
520
- return hash === -1 ? "" : target.slice(hash + 1);
521
- }
522
-
523
- // ── Heading anchors (GitHub-style slugs) ─────────────────────────────────────────
524
-
525
- /**
526
- * The set of heading-anchor slugs a markdown body exposes, computed the way **GitHub** —
527
- * the reference renderer (portable-markdown.md) — derives them: each heading's text is
528
- * {@link slugify}d, and a repeated slug gets a `-1`/`-2`/… disambiguator in document
529
- * order. Other renderers (MkDocs, Docusaurus) may slug differently; `lore check` validates
530
- * against the GitHub slug because that is the form lore writes and the matrix targets.
531
- *
532
- * Headings are read from an already-parsed mdast tree (shared with the link and portability
533
- * passes) and walked with the stack-safe {@link walkMdast}, so a `#` inside a fenced/indented
534
- * code block is not mistaken for a heading. A string overload is offered for tests.
535
- */
536
- export function extractHeadingSlugs(source: Nodes | string): ReadonlySet<string> {
537
- const tree = typeof source === "string" ? fromMarkdown(source) : source;
538
- const slugger = new GithubSlugger();
539
- const slugs = new Set<string>();
540
- walkMdast(tree, (node) => {
541
- if (node.type === "heading") {
542
- slugs.add(slugger.slug(nodeText(node)));
543
- }
544
- });
545
- return slugs;
546
- }
547
-
548
- /**
549
- * Slugify already-extracted heading text with the same package primitive used by
550
- * {@link extractHeadingSlugs}, without retaining duplicate state. The package lower-cases,
551
- * removes GitHub-excluded punctuation and Unicode code points, and maps literal spaces to
552
- * hyphens. It deliberately does not trim or normalize Unicode: those details are part of
553
- * GitHub-compatible anchor behavior rather than Lore policy.
554
- */
555
- export function slugify(text: string): string {
556
- return githubSlug(text);
557
- }
558
-
559
- // ── Portability lint (warn-only body-text scan) ──────────────────────────────────
560
-
561
- /** One non-portable-syntax detector: a global regex over body text, and the warning it raises. */
562
- interface Detector {
563
- readonly re: RegExp;
564
- readonly describe: (match: RegExpExecArray) => string;
565
- }
566
-
567
- /**
568
- * The Obsidian-ism detectors (portable-markdown.md). Each renders as literal characters (or
569
- * not at all) outside Obsidian, so lore detects and **warns** — it never rewrites. Wikilinks,
570
- * embeds, and callouts are LORE-30 AC#2; highlights and `%%`-comments round out the
571
- * low-false-positive subset of the portable-markdown.md table.
572
- *
573
- * The patterns are tuned to flag the real syntax without crying wolf on ordinary prose:
574
- * wikilinks/embeds (`[[…]]`/`![[…]]`), highlights (`==…==`), and `%%`-comments are
575
- * distinctive enough to match anywhere in a text node. A **callout** (`[!type]`) is *not*
576
- * in this list — unlike those, it is only a callout at the structural **start of a
577
- * blockquote**, not merely at the start of some text node (inline formatting earlier in an
578
- * ordinary paragraph — `ordinary **bold** [!note] prose` — splits the paragraph so `[!note]`
579
- * would start a later text node and a per-text-node regex would wrongly flag it, LORE-239) —
580
- * so it is judged structurally by {@link calloutFinding} instead, the same way the Obsidian
581
- * **block-reference** `^id`, the MDX raw-`<`/`{` hazard, and the `_`-prefix/`.mdx` filename
582
- * rules are handled separately — {@link blockReferenceFinding}, {@link mdxHazardFindings}, and
583
- * the command layer — not as body-text regexes, because each needs structural context a
584
- * per-text-node regex cannot see.
585
- */
586
- const DETECTORS: readonly Detector[] = [
587
- {
588
- // `![[embed]]` before `[[wikilink]]` so the leading `!` is attributed to the embed.
589
- re: /(!?)\[\[[^\]\n]*\]\]/g,
590
- describe: (m) =>
591
- m[1] === "!"
592
- ? `non-portable embed "${m[0]}"; use a normal markdown link or image (renders literally off Obsidian)`
593
- : `non-portable wikilink "${m[0]}"; use the relative .md link form (renders literally off Obsidian)`,
594
- },
595
- {
596
- re: /==[^=\n]+==/g,
597
- describe: (m) => `non-portable highlight "${m[0]}"; renders literally off Obsidian — use bold/italic`,
598
- },
599
- {
600
- re: /%%[^\n]*?%%/g,
601
- describe: () => `non-portable Obsidian comment "%% … %%"; use an HTML comment <!-- … -->, which hides everywhere`,
602
- },
603
- ];
604
-
605
- /**
606
- * An Obsidian block-reference marker `^id`, matched only at the very **end of its block**: the
607
- * caret must be preceded by whitespace or the block start (so `x^2`, a GFM footnote `[^1]`, and any
608
- * mid-prose caret are spared) and the id — which may be digit-leading, e.g. `^3f9a2b` — runs to the
609
- * end. This is judged against a **block's last text child** (see {@link blockReferenceFinding}), not
610
- * an arbitrary text node, so `see note ^id **bold**` (where the caret is mid-paragraph, before the
611
- * bold) is *not* flagged. The `[[note#^id]]` reference form is already caught by the wikilink detector.
612
- */
613
- const BLOCK_REFERENCE = /(?:^|\s)\^([A-Za-z0-9][A-Za-z0-9-]*)$/;
614
-
615
- /**
616
- * An Obsidian callout marker `[!type]`, matched only when it **leads a blockquote paragraph**
617
- * (see {@link calloutFinding}) — the structural position Obsidian requires for `> [!type]` to
618
- * render as a callout rather than a plain blockquote. Optional leading whitespace tolerates a
619
- * blockquote line like `> [!note]` (extra space after the `>` marker).
620
- */
621
- const CALLOUT = /^\s*\[!([A-Za-z][\w-]*)\]/;
622
-
623
- /**
624
- * The portability warnings for a body's prose: the {@link DETECTORS Obsidian-ism detectors} plus
625
- * the {@link calloutFinding callout} and {@link mdxHazardFindings MDX-safety} scans, over the
626
- * parsed tree. The Obsidian detectors run over **text nodes only** — scanning text (never
627
- * `inlineCode`/`code`) excludes fenced and inline code for free, so a `[[x]]` inside a code span
628
- * is correctly left alone — while the MDX scan also inspects raw-`html` nodes (the form
629
- * CommonMark pulls a `<tag>` out of the text as), skipping HTML comments.
630
- */
631
- function portabilityScan(tree: Nodes, file: string): CheckFinding[] {
632
- const findings: CheckFinding[] = [];
633
- walkMdast(tree, (node) => {
634
- if (node.type === "text") {
635
- for (const detector of DETECTORS) {
636
- detector.re.lastIndex = 0;
637
- let match: RegExpExecArray | null = detector.re.exec(node.value);
638
- while (match !== null) {
639
- findings.push({ severity: "warning", rule: "portability", file, message: detector.describe(match) });
640
- match = detector.re.exec(node.value);
641
- }
642
- }
643
- }
644
- findings.push(...blockReferenceFinding(node, file));
645
- findings.push(...calloutFinding(node, file));
646
- findings.push(...mdxHazardFindings(node, file));
647
- });
648
- return findings;
649
- }
650
-
651
- /**
652
- * The Obsidian block-reference warning for a block, judged from its **last inline child** so the
653
- * `^id` marker is only flagged when it truly ends the block ({@link BLOCK_REFERENCE}). Only a
654
- * `paragraph` is inspected — the block kind an Obsidian `^id` attaches to (a list item's text lives
655
- * in a nested paragraph, so it is covered too) — and only when that paragraph's last child is a text
656
- * node ending in `^id`. A caret followed by inline formatting (`^id **bold**`, where the last child
657
- * is the `strong`, not text) or sitting mid-prose is therefore not a false positive.
658
- */
659
- function blockReferenceFinding(node: Nodes, file: string): CheckFinding[] {
660
- if (node.type !== "paragraph") {
661
- return [];
662
- }
663
- const last = node.children.at(-1);
664
- if (last?.type !== "text") {
665
- return [];
666
- }
667
- const match = BLOCK_REFERENCE.exec(last.value.trimEnd());
668
- if (match === null) {
669
- return [];
670
- }
671
- return [
672
- {
673
- severity: "warning",
674
- rule: "portability",
675
- file,
676
- message: `non-portable Obsidian block reference "^${match[1]}"; renders literally off Obsidian — link to a heading anchor instead`,
677
- },
678
- ];
679
- }
680
-
681
- /**
682
- * The Obsidian callout warning for a block, judged from a **blockquote's first child**
683
- * ({@link CALLOUT}) — mirroring {@link blockReferenceFinding}'s structural approach — so
684
- * `[!type]` is only flagged when it genuinely **leads a blockquote**, the position Obsidian
685
- * requires for `> [!type]` to render as a callout, never merely because it starts some
686
- * arbitrary text node. Only a `blockquote` is inspected, and only when its first child is a
687
- * `paragraph` whose own first child is a text node starting with `[!type]`. This is why inline
688
- * formatting earlier in an *ordinary* (non-blockquote) paragraph — `ordinary **bold** [!note]
689
- * prose` — is not a false positive even though mdast splits that paragraph into a `strong` node
690
- * followed by a text node starting with `[!note]` (LORE-239): the paragraph is never a
691
- * blockquote's first child, so it's never inspected here at all. A blockquote-leading `[!type]`
692
- * followed by more content on the same line (`> [!note] more text`) is still flagged, since only
693
- * the paragraph's *first* child needs to start with the marker.
694
- */
695
- function calloutFinding(node: Nodes, file: string): CheckFinding[] {
696
- if (node.type !== "blockquote") {
697
- return [];
698
- }
699
- const firstBlock = node.children[0];
700
- if (firstBlock?.type !== "paragraph") {
701
- return [];
702
- }
703
- const firstInline = firstBlock.children[0];
704
- if (firstInline?.type !== "text") {
705
- return [];
706
- }
707
- const match = CALLOUT.exec(firstInline.value);
708
- if (match === null) {
709
- return [];
710
- }
711
- return [
712
- {
713
- severity: "warning",
714
- rule: "portability",
715
- file,
716
- message: `non-portable callout "[!${match[1]}]"; GitHub shows it as a plain blockquote with literal text`,
717
- },
718
- ];
719
- }
720
-
721
- /**
722
- * MDX-safety warnings (portable-markdown.md §MDX): Docusaurus parses Markdown as **MDX**, where a
723
- * raw `<` starts a JSX element and a raw `{` a JSX expression — so un-escaped, non-code `<`/`{` in
724
- * prose make its build throw even though the same source renders fine on GitHub/Obsidian/MkDocs.
725
- * lore detects (never escapes) two shapes, one finding apiece to keep the report readable:
726
- *
727
- * - a `<` or `{` CommonMark left literal in a **text** node (`temperature < 0`, `{ "k": 1 }`);
728
- * - a raw-`html` node that carries any non-comment markup (`<T>`, `<div>`, `<Component>` — what
729
- * CommonMark pulled out of the text as inline/block HTML). HTML **comments** (`<!-- … -->`) are
730
- * portable and are what lore writes for its managed regions, so they are stripped first; a node
731
- * that is *only* comments is left alone, but `<!-- … --><div>` still flags the `<div>`.
732
- *
733
- * Code spans/blocks are `inlineCode`/`code` nodes, never `text`/`html`, so they never reach here.
734
- */
735
- function mdxHazardFindings(node: Nodes, file: string): CheckFinding[] {
736
- const findings: CheckFinding[] = [];
737
- if (node.type === "text") {
738
- if (node.value.includes("<")) {
739
- findings.push(mdxHazard(file, "<"));
740
- }
741
- if (node.value.includes("{")) {
742
- findings.push(mdxHazard(file, "{"));
743
- }
744
- } else if (node.type === "html" && node.value.replace(HTML_COMMENT, "").trim() !== "") {
745
- findings.push({
746
- severity: "warning",
747
- rule: "portability",
748
- file,
749
- message: `non-portable raw HTML "${clip(node.value)}"; the portable subset has no raw HTML outside comments, and MDX (Docusaurus) parses it as JSX`,
750
- });
751
- }
752
- return findings;
753
- }
754
-
755
- /** An HTML comment span — stripped before judging whether an `html` node carries real (non-comment) markup. */
756
- const HTML_COMMENT = /<!--[\s\S]*?-->/g;
757
-
758
- /** One MDX raw-character warning, naming the hazard and the portable escape. */
759
- function mdxHazard(file: string, ch: "<" | "{"): CheckFinding {
760
- const role = ch === "<" ? "a JSX/HTML element" : "a JSX expression";
761
- const entity = ch === "<" ? "&lt;" : "&#123;";
762
- return {
763
- severity: "warning",
764
- rule: "portability",
765
- file,
766
- message: `non-portable raw "${ch}" in prose; MDX (Docusaurus) reads it as the start of ${role} — escape it (${entity}) or wrap the text in backticks`,
767
- };
768
- }
769
-
770
- /** Collapse whitespace and clip a raw-HTML snippet to a single readable line for a finding message. */
771
- function clip(value: string): string {
772
- const flat = value.replace(/\s+/g, " ").trim();
773
- return flat.length > 50 ? `${flat.slice(0, 50)}…` : flat;
774
- }
775
-
776
- // ── Helpers ──────────────────────────────────────────────────────────────────────
777
-
778
- /** Tally findings into the aggregate {@link CheckReport} counts. `checkBundle` is fully synchronous and never partial, so `complete` is always `true` here — only `commands/check.ts` ever sets it `false` (LORE-112). */
779
- function summarize(findings: readonly CheckFinding[], fileCount: number): CheckReport {
780
- const { errorCount, warningCount } = tallySeverity(findings);
781
- return { findings, errorCount, warningCount, fileCount, complete: true };
782
- }
783
-
784
- /**
785
- * Count `error`/`warning` findings — the one shared tally every aggregation reuses (this module's
786
- * own {@link summarize}, and `commands/check.ts`'s multi-bundle and drift-merge aggregations), so a
787
- * future severity-tier change is one edit, not a hunt across three near-identical loops.
788
- */
789
- export function tallySeverity(findings: readonly CheckFinding[]): { errorCount: number; warningCount: number } {
790
- let errorCount = 0;
791
- let warningCount = 0;
792
- for (const finding of findings) {
793
- if (finding.severity === "error") {
794
- errorCount++;
795
- } else {
796
- warningCount++;
797
- }
798
- }
799
- return { errorCount, warningCount };
800
- }
801
-
802
- /**
803
- * The markdown **body** of a file — its content with a leading YAML frontmatter fence
804
- * removed — so heading and link extraction see the same body for a concept (frontmatter
805
- * stripped) and a frontmatter-free `index.md`/`log.md` (returned unchanged). Stripping the
806
- * fence matters: a `#`-prefixed YAML comment inside it would otherwise parse as a phantom
807
- * heading.
808
- *
809
- * Reuses the concept parser's normalization and js-yaml schema so `lore check` and concept
810
- * parsing agree on the body boundary. Malformed YAML degrades to scanning the whole normalized
811
- * file; `lore validate` remains responsible for reporting the frontmatter error. A tagged,
812
- * unsupported fence language still fails loud (LORE-138).
813
- *
814
- * A file with **no** frontmatter fence takes a separate path that skips normalizeInput's leading-`\s+`
815
- * strip: that strip exists only so a *whitespace-padded fence* (blank lines before `---`) still
816
- * parses, but applied to a body that never has a fence at all it deletes the body's own first-line
817
- * indentation — an indented (4-space/tab) code block opening a frontmatter-free file would lose its
818
- * indentation and get reparsed as a lazy-continuation prose paragraph, exposing any `{`/`[[…]]`/etc.
819
- * inside it to the portability scan as if it were prose (LORE-240). BOM-strip and CRLF/CR
820
- * normalization still apply on this path — only the leading-whitespace strip is skipped. A file
821
- * that *does* open with the fence delimiter (including a malformed, empty, or non-mapping one) is
822
- * unaffected by this branch and keeps the exact behavior above.
823
- *
824
- * Exported (alongside this module's other internals such as {@link slugify} and
825
- * {@link extractHeadingSlugs}) so the normalization contract itself — leading indentation
826
- * preserved, BOM/CRLF still stripped, frontmatter path untouched — has a direct unit-level
827
- * regression test, not only an indirect one through {@link checkBundle}'s findings.
828
- */
829
- export function bodyText(raw: string): string {
830
- const normalized = normalizeInput(raw);
831
- if (!normalized.startsWith("---")) {
832
- // No frontmatter fence attempted anywhere in this file: normalize BOM and line endings only
833
- // (normalizeInput's other two steps), and deliberately skip its leading-whitespace strip so a
834
- // leading indented code block keeps the indentation that makes it parse as code, not prose.
835
- return raw.replace(/^\uFEFF+/, "").replace(/\r\n?/g, "\n");
836
- }
837
- if (!normalized.startsWith("---\n")) {
838
- const engine = normalized.slice(3, normalized.indexOf("\n"));
839
- throw new Error(`gray-matter engine "${engine}" is not registered`);
840
- }
841
- const closeStart = normalized.indexOf("\n---", 4);
842
- if (closeStart < 0) return normalized;
843
- try {
844
- yaml.load(normalized.slice(4, closeStart), { schema: yaml.JSON_SCHEMA });
845
- const bodyStart = closeStart + (normalized.charAt(closeStart + 4) === "\n" ? 5 : 4);
846
- return normalized.slice(bodyStart);
847
- } catch (error) {
848
- if (error instanceof Error) {
849
- return normalized;
850
- }
851
- throw error;
852
- }
853
- }