@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.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +306 -0
  3. package/bin/lore.cjs +109 -0
  4. package/package.json +67 -0
  5. package/src/adapters/backlog.ts +1084 -0
  6. package/src/adapters/git.ts +221 -0
  7. package/src/cli.ts +667 -0
  8. package/src/commands/agent.ts +301 -0
  9. package/src/commands/agents.ts +302 -0
  10. package/src/commands/args.ts +209 -0
  11. package/src/commands/changed.ts +70 -0
  12. package/src/commands/check.ts +1031 -0
  13. package/src/commands/codex-bridge.ts +49 -0
  14. package/src/commands/concurrency.ts +48 -0
  15. package/src/commands/context.ts +292 -0
  16. package/src/commands/discover.ts +89 -0
  17. package/src/commands/explorer.ts +253 -0
  18. package/src/commands/export.ts +93 -0
  19. package/src/commands/fswrite.ts +928 -0
  20. package/src/commands/graph.ts +291 -0
  21. package/src/commands/help.ts +151 -0
  22. package/src/commands/impact.ts +59 -0
  23. package/src/commands/init.ts +583 -0
  24. package/src/commands/instructions.ts +91 -0
  25. package/src/commands/link.ts +929 -0
  26. package/src/commands/new.ts +476 -0
  27. package/src/commands/orphans.ts +457 -0
  28. package/src/commands/path.ts +67 -0
  29. package/src/commands/provenance.ts +68 -0
  30. package/src/commands/query.ts +312 -0
  31. package/src/commands/reconcile-shared.ts +280 -0
  32. package/src/commands/rename.ts +585 -0
  33. package/src/commands/replace.ts +320 -0
  34. package/src/commands/scaffold.ts +346 -0
  35. package/src/commands/schema.ts +293 -0
  36. package/src/commands/snapshot.ts +130 -0
  37. package/src/commands/supersede.ts +400 -0
  38. package/src/commands/sync.ts +371 -0
  39. package/src/commands/tasks.ts +271 -0
  40. package/src/commands/traversal.ts +151 -0
  41. package/src/commands/validate.ts +226 -0
  42. package/src/config.ts +598 -0
  43. package/src/core/agent-bridge.ts +287 -0
  44. package/src/core/agent-context.ts +498 -0
  45. package/src/core/agent-profile.ts +447 -0
  46. package/src/core/bundle.ts +893 -0
  47. package/src/core/check.ts +853 -0
  48. package/src/core/codex-bridge.ts +100 -0
  49. package/src/core/concept.ts +597 -0
  50. package/src/core/consumer-scaffold.ts +433 -0
  51. package/src/core/context.ts +271 -0
  52. package/src/core/explorer-contract.ts +441 -0
  53. package/src/core/explorer-qualification.ts +58 -0
  54. package/src/core/explorer.ts +518 -0
  55. package/src/core/finding.ts +31 -0
  56. package/src/core/graph.ts +201 -0
  57. package/src/core/indexes.ts +436 -0
  58. package/src/core/instructions.ts +209 -0
  59. package/src/core/ladybug-driver.ts +1795 -0
  60. package/src/core/ladybug-lifecycle.ts +1178 -0
  61. package/src/core/ladybug-native.ts +95 -0
  62. package/src/core/ladybug-source.ts +667 -0
  63. package/src/core/links.ts +681 -0
  64. package/src/core/log.ts +253 -0
  65. package/src/core/managed-block.ts +540 -0
  66. package/src/core/manifest.ts +718 -0
  67. package/src/core/order.ts +13 -0
  68. package/src/core/profile.ts +1007 -0
  69. package/src/core/projection.ts +195 -0
  70. package/src/core/query.ts +542 -0
  71. package/src/core/reconcile.ts +236 -0
  72. package/src/core/replace.ts +419 -0
  73. package/src/core/retrieval.ts +213 -0
  74. package/src/core/rewrite.ts +940 -0
  75. package/src/core/scaffold.ts +255 -0
  76. package/src/core/schema.ts +366 -0
  77. package/src/core/snapshot-runtime.ts +52 -0
  78. package/src/core/snapshot-store.ts +287 -0
  79. package/src/core/snapshot.ts +711 -0
  80. package/src/core/template.ts +429 -0
  81. package/src/core/traversal.ts +487 -0
  82. package/src/core/validate.ts +517 -0
  83. package/src/core/workspace-contract.ts +473 -0
  84. package/src/core/workspace-projection.ts +365 -0
  85. package/src/core/workspace-retrieval.ts +196 -0
  86. package/src/core/workspace-source.ts +174 -0
  87. package/src/errors.ts +697 -0
  88. package/src/meta.ts +7 -0
  89. package/src/output.ts +589 -0
  90. package/src/scripts/upstream-backlog-watch.ts +288 -0
  91. package/src/state.ts +390 -0
@@ -0,0 +1,517 @@
1
+ /**
2
+ * validate.ts — the **pure**, aggregating engine behind `lore validate`.
3
+ *
4
+ * Where {@link tryParseConcept}/{@link validateFrontmatter} are *fail-fast* (they throw the
5
+ * first error-tier problem they hit, which is right for the write path — lore must never
6
+ * emit bytes it would refuse to read back), `lore validate` is a **reporter**: it surfaces
7
+ * *every file's* findings in one pass — fail-fast aborts the whole run on the first bad file;
8
+ * this never does — so an author or a pre-commit hook sees the whole bundle's picture at once
9
+ * (cli-surface §validate, ADR-0007). Within a single file the frontmatter tier still
10
+ * short-circuits: a parse failure yields the one frontmatter error (plus the raw-text
11
+ * quote-safety scan), and the per-type section checks — which presuppose a parsed type/body —
12
+ * run only once the frontmatter parses. This module turns the fail-fast machinery into an
13
+ * aggregator: it runs the existing frontmatter engine per file, collects what it throws or
14
+ * warns as {@link Finding}s, and layers the two checks ADR-0007 adds on top — **per-type
15
+ * required sections** and **frontmatter quote-safety**.
16
+ *
17
+ * It stays within the core contract (lore-design §2.1): pure, filesystem-free, no printing
18
+ * or `process.exit`. The command layer (`commands/validate.ts`) owns file discovery and I/O
19
+ * and hands raw text here; this module owns the *judgement* and returns structured data.
20
+ *
21
+ * The tiers (ADR-0007 "How lore checks conformance"):
22
+ *
23
+ * - **Tier 1 — OKF §9 (error):** frontmatter parses and `type` is present/non-empty. Reuses
24
+ * {@link tryParseConcept}, whose thrown `validation` {@link LoreError} becomes one finding.
25
+ * - **Tier 2 — per-type shape (error):** a *known* type's strict Zod schema (surfaced through
26
+ * the same {@link tryParseConcept} throw) **plus** its {@link requiredSectionsFor required
27
+ * body sections} (this module).
28
+ * - **Tier 3 — extensions (warning):** an unknown `type`, an extra key on a known type, or a
29
+ * missing/over-long `summary` — collected from the {@link WarningCollector}.
30
+ * - **Cross-cutting — quote-safety:** unquoted frontmatter scalars that a YAML-1.1 consumer
31
+ * would coerce to a non-string (or that carry a YAML indicator), so the value is
32
+ * parser-dependent across the bundle's target renderers ({@link quoteSafetyFindings}).
33
+ *
34
+ * A file that is **not a concept** (no frontmatter, an empty/`null` fence, or a bare
35
+ * scalar/list — e.g. a hand-written `index.md`/`log.md`) is **skipped**, not failed: exactly
36
+ * the {@link tryParseConcept} distinction {@link loadBundle} draws, so a pre-commit hook that
37
+ * globs `*.md` does not trip over a non-concept file.
38
+ */
39
+
40
+ import { fromMarkdown } from "mdast-util-from-markdown";
41
+ import { LoreError, singleLine, stripAnsiAndControls, WarningCollector } from "../errors";
42
+ import { effectiveProfileFor, nodeText } from "./bundle";
43
+ import { type Concept, tryParseConcept } from "./concept";
44
+ import type { Finding as BaseFinding, Severity } from "./finding";
45
+ import { decodeTarget } from "./links";
46
+ import { defaultProfile, type Profile } from "./profile";
47
+ import { ROOT_INDEX_PATH } from "./scaffold";
48
+ import { requiredSectionsFor } from "./schema";
49
+ import { expectedResource } from "./template";
50
+
51
+ export type { Severity };
52
+
53
+ /** Which check produced a {@link Finding}, for machine consumers and grouped display. */
54
+ export type FindingRule = "frontmatter" | "required-section" | "quote-safety" | "resource";
55
+
56
+ /** One tiered problem found in a single file — the shared {@link BaseFinding} narrowed to `validate`'s rules. */
57
+ export type Finding = BaseFinding<FindingRule>;
58
+
59
+ /** The validation outcome for one file. */
60
+ export interface FileReport {
61
+ /** The file's repo-relative POSIX path, as given to {@link validateConceptText}. */
62
+ readonly path: string;
63
+ /** The resolved concept `type`, or `undefined` for a skipped non-concept or an unparseable file. */
64
+ readonly type?: string;
65
+ /** Every finding for this file, in tier order (frontmatter → sections → quote-safety). */
66
+ readonly findings: readonly Finding[];
67
+ /** `true` when the file is not a concept and was not validated (no findings contributed). */
68
+ readonly skipped: boolean;
69
+ /** `true` when the file carries no error-severity finding (a warning-only or clean/skipped file). */
70
+ readonly ok: boolean;
71
+ }
72
+
73
+ /** The aggregate report across every validated file — the `validate.report` payload. */
74
+ export interface ValidateReport {
75
+ /** Per-file reports, in the order the files were supplied. */
76
+ readonly files: readonly FileReport[];
77
+ /** Total error-severity findings across all files. */
78
+ readonly errorCount: number;
79
+ /** Total warning-severity findings across all files. */
80
+ readonly warningCount: number;
81
+ /** Files skipped as non-concepts. */
82
+ readonly skippedCount: number;
83
+ }
84
+
85
+ /**
86
+ * Validate one file's raw bytes into a {@link FileReport}, never throwing for a *content*
87
+ * problem — every tier is collected as a {@link Finding}.
88
+ *
89
+ * The frontmatter tiers reuse {@link parseConcept}: a thrown `validation` {@link LoreError}
90
+ * (missing/invalid `type`, a mistyped known field, unparseable YAML) becomes one error
91
+ * finding; the {@link WarningCollector} it fills (unknown type/key, summary) becomes warning
92
+ * findings. A non-concept (no usable frontmatter) is **skipped** via {@link tryParseConcept}.
93
+ * On a clean parse the two ADR-0007 additions run: {@link requiredSectionFindings} and
94
+ * {@link quoteSafetyFindings}.
95
+ *
96
+ * `path` is resolved to its **judging profile** via {@link import("./bundle").effectiveProfileFor}
97
+ * before any of that runs: the bundle-root {@link ROOT_INDEX_PATH} (this module's repo-relative
98
+ * spelling) is always judged against the built-in {@link defaultProfile}, never `profile`
99
+ * (LORE-144) — see that helper for why, and for why it lives in `bundle.ts` rather than here
100
+ * (LORE-192: this module already imports {@link nodeText} from `bundle.ts`, so the reverse import
101
+ * would cycle).
102
+ *
103
+ * A non-{@link LoreError} (a genuine bug) is *not* swallowed — it propagates, so a crash is
104
+ * never silently dressed up as a validation finding.
105
+ */
106
+ export function validateConceptText(path: string, raw: string, profile: Profile = defaultProfile()): FileReport {
107
+ const effective = effectiveProfileFor(path, ROOT_INDEX_PATH, profile);
108
+ // Parse exactly once. tryParseConcept fills the collector with tier-3 warnings, returns null for
109
+ // a non-concept (skip), and throws a `validation` LoreError for a real-but-malformed concept —
110
+ // so a single call draws every distinction the reporter needs without re-parsing the same bytes.
111
+ const warnings = new WarningCollector();
112
+ let concept: Concept | null;
113
+ try {
114
+ concept = tryParseConcept(path, raw, { warnings, profile: effective });
115
+ } catch (err) {
116
+ // A genuine bug (a non-LoreError) must never be dressed up as a validation finding — propagate
117
+ // it (the invariant this module states). A malformed concept becomes one error finding; its
118
+ // `type` is recovered best-effort from the raw frontmatter so a `--type` run still attributes
119
+ // (and so never silently drops) a known-type-but-invalid file — the gate it exists to enforce.
120
+ if (!(err instanceof LoreError)) {
121
+ throw err;
122
+ }
123
+ const findings: Finding[] = [{ severity: "error", rule: "frontmatter", message: err.message }];
124
+ // Quote-safety is a raw-text scan needing no parsed concept, so it still runs and the author
125
+ // sees YAML hazards in the same pass; per-type section checks presuppose a parsed type/body
126
+ // and are deferred until the frontmatter parses.
127
+ findings.push(...quoteSafetyFindings(raw));
128
+ return { path, type: recoverType(raw), findings, skipped: false, ok: false };
129
+ }
130
+ if (concept === null) {
131
+ return { path, findings: [], skipped: true, ok: true };
132
+ }
133
+
134
+ const findings: Finding[] = [];
135
+ for (const message of warnings.list()) {
136
+ findings.push({ severity: "warning", rule: "frontmatter", message });
137
+ }
138
+ findings.push(...requiredSectionFindings(concept.type, concept.body, effective));
139
+ findings.push(...resourceDriftFindings(path, concept, effective));
140
+ findings.push(...quoteSafetyFindings(raw));
141
+
142
+ return finalize(path, concept.type, findings);
143
+ }
144
+
145
+ /**
146
+ * Validate a list of `{ path, raw }` files into one aggregate {@link ValidateReport}, optionally
147
+ * narrowed to a single `type`. Pure over its inputs (the command layer does the reading), so the
148
+ * aggregation and the `--type` filter are testable without the filesystem.
149
+ *
150
+ * `type` (the `--type <T>` flag, already canonicalized by the caller) narrows the report to one
151
+ * concept type — but **never** at the cost of hiding a broken file from the gate: see
152
+ * {@link keepForType}.
153
+ */
154
+ export function validateFiles(
155
+ files: readonly { path: string; raw: string }[],
156
+ type?: string,
157
+ profile: Profile = defaultProfile(),
158
+ ): ValidateReport {
159
+ const wanted = type?.toLowerCase();
160
+ const reports: FileReport[] = [];
161
+ for (const file of files) {
162
+ const report = validateConceptText(file.path, file.raw, profile);
163
+ if (wanted !== undefined && !keepForType(report, wanted)) {
164
+ continue;
165
+ }
166
+ reports.push(report);
167
+ }
168
+ return summarize(reports);
169
+ }
170
+
171
+ /**
172
+ * Whether a per-file report belongs in a `--type <wanted>` run. An **error** file is **always
173
+ * kept**: silently dropping a broken file would turn the very gate `--type` scopes green over a
174
+ * malformed concept, and a malformed file's true type can never be trusted to be *not* `wanted`
175
+ * (its frontmatter did not parse). A clean concept is kept only when its type matches; a skipped
176
+ * non-concept is dropped (it is genuinely not `wanted`). The error file's {@link recoverType}d
177
+ * type is for display only — never a reason to filter it out.
178
+ */
179
+ function keepForType(report: FileReport, wanted: string): boolean {
180
+ if (!report.ok && !report.skipped) {
181
+ return true;
182
+ }
183
+ return report.type?.toLowerCase() === wanted;
184
+ }
185
+
186
+ /** Tally per-file reports into the aggregate counts (errors, warnings, skips). */
187
+ export function summarize(files: readonly FileReport[]): ValidateReport {
188
+ let errorCount = 0;
189
+ let warningCount = 0;
190
+ let skippedCount = 0;
191
+ for (const file of files) {
192
+ if (file.skipped) {
193
+ skippedCount++;
194
+ }
195
+ for (const finding of file.findings) {
196
+ if (finding.severity === "error") {
197
+ errorCount++;
198
+ } else {
199
+ warningCount++;
200
+ }
201
+ }
202
+ }
203
+ return { files, errorCount, warningCount, skippedCount };
204
+ }
205
+
206
+ /**
207
+ * Assemble a non-skipped {@link FileReport}, deriving `ok` from the absence of any error-severity
208
+ * finding (a warning-only file is still `ok` — warnings never fail a file, cli-contract §4.1).
209
+ */
210
+ function finalize(path: string, type: string, findings: readonly Finding[]): FileReport {
211
+ const ok = !findings.some((finding) => finding.severity === "error");
212
+ return { path, type, findings, skipped: false, ok };
213
+ }
214
+
215
+ /**
216
+ * A best-effort `type` read from the raw frontmatter of a file that failed to parse, so a `--type`
217
+ * run can attribute (and therefore never silently drop) a known-type-but-invalid concept. Scans the
218
+ * fenced block for a top-level `type:` line, stripping a trailing comment and surrounding quotes;
219
+ * returns `undefined` when no `type` is recoverable (unparseable YAML, or a missing `type`). The
220
+ * value is raw (not canonicalized) — it is matched case-insensitively, only for filtering/display.
221
+ */
222
+ function recoverType(raw: string): string | undefined {
223
+ const block = frontmatterBlock(raw);
224
+ if (block === null) {
225
+ return undefined;
226
+ }
227
+ for (const line of block.split("\n")) {
228
+ const match = /^type:[ \t]+(.*)$/.exec(line);
229
+ if (match === null) {
230
+ continue;
231
+ }
232
+ const value = unquoteScalar(stripInlineComment((match[1] ?? "").trim()));
233
+ return value === "" ? undefined : value;
234
+ }
235
+ return undefined;
236
+ }
237
+
238
+ /** Strip a trailing YAML comment (` #…`, the `#` preceded by whitespace) from a raw scalar value. */
239
+ function stripInlineComment(value: string): string {
240
+ return value.replace(/\s+#.*$/, "").trimEnd();
241
+ }
242
+
243
+ /** Remove a matching pair of surrounding quotes from a scalar value, else return it unchanged. */
244
+ function unquoteScalar(value: string): string {
245
+ const quote = value[0];
246
+ if (value.length >= 2 && (quote === '"' || quote === "'") && value.endsWith(quote)) {
247
+ return value.slice(1, -1);
248
+ }
249
+ return value;
250
+ }
251
+
252
+ // ── Tier 2: required body sections ─────────────────────────────────────────────—
253
+
254
+ /**
255
+ * The required-section findings for a concept body: one **error** per
256
+ * {@link requiredSectionsFor required `##` heading} the body does not carry. Matching is on
257
+ * {@link normalizeHeading normalized} heading text (trimmed, interior whitespace collapsed,
258
+ * lower-cased), so `## status` and `## Acceptance criteria` (a double space that renders
259
+ * identically) both satisfy their requirement. A type with no required sections (every unknown
260
+ * type, and Epic/Spec/Runbook/Reference under the minimal policy) yields nothing.
261
+ */
262
+ function requiredSectionFindings(type: string, body: string, profile: Profile): Finding[] {
263
+ const required = requiredSectionsFor(type, profile);
264
+ if (required.length === 0) {
265
+ return [];
266
+ }
267
+ const present = new Set(h2Headings(body).map(normalizeHeading));
268
+ const findings: Finding[] = [];
269
+ for (const section of required) {
270
+ if (!present.has(normalizeHeading(section))) {
271
+ findings.push({
272
+ severity: "error",
273
+ rule: "required-section",
274
+ message: `${type} is missing the required "## ${section}" section`,
275
+ });
276
+ }
277
+ }
278
+ return findings;
279
+ }
280
+
281
+ // ── Cross-cutting: resource drift ──────────────────────────────────────────────—
282
+
283
+ /**
284
+ * The resource-drift finding for a concept whose stamped `resource` no longer matches what its
285
+ * path + the profile's `resource_base` would produce (LORE-47 / AC#4) — one **warning** when a
286
+ * **present** string `resource` differs from {@link expectedResource}. Unlike `index.md`/`log.md`,
287
+ * a stamped `resource` is not regenerated, so a later rename or `resource_base` change silently
288
+ * leaves a stale URL; this surfaces that drift in the same pass.
289
+ *
290
+ * It judges only what lore itself would stamp: a file with no `resource`, a non-string `resource`,
291
+ * or one where lore would stamp nothing here ({@link expectedResource} `undefined` — no
292
+ * `resource_base`, an index file, or a type that owns its own `resource` field) yields nothing, so
293
+ * an author-owned `resource` is never second-guessed. Advisory tier (`resource` is advisory
294
+ * metadata, not a shape constraint), so it reports the staleness without failing the file.
295
+ */
296
+ function resourceDriftFindings(path: string, concept: Concept, profile: Profile): Finding[] {
297
+ const actual = concept.frontmatter.resource;
298
+ if (typeof actual !== "string") {
299
+ return [];
300
+ }
301
+ const expected = expectedResource(concept.type, path, profile);
302
+ if (expected === undefined || actual === expected) {
303
+ return [];
304
+ }
305
+ // Compare decode-tolerantly: the shared segment encoder (links.ts `encodePathSegment`) escapes the
306
+ // markdown-significant `! ' ( ) *` that `encodeURIComponent` leaves raw, so a `resource` stamped
307
+ // before LORE-28 (or hand-authored with those chars literal) differs from the freshly-encoded
308
+ // `expected` only in percent-encoding — an equivalent URL, not drift. Decoding both collapses that
309
+ // difference while a real path/`resource_base` change still decodes to a different string.
310
+ if (decodeTarget(actual) === decodeTarget(expected)) {
311
+ return [];
312
+ }
313
+ return [
314
+ {
315
+ severity: "warning",
316
+ rule: "resource",
317
+ // `actual` is sanitized here, not above: the drift comparisons must judge the *raw*
318
+ // bundle-authored value (sanitizing first could mask real drift or falsely collapse two
319
+ // distinct URLs into one), but `Finding.message` is documented as "a single-line, actionable
320
+ // description" and every other finding builder only ever interpolates lore-computed values
321
+ // (`type`, `expected`) — `resource` is the one message that embeds an author-controlled raw
322
+ // string straight from frontmatter. Sanitizing once here, at the only place that string
323
+ // reaches a message, keeps that contract for every consumer (CLI text, `--json`, a future
324
+ // renderer) instead of trusting each print site to re-derive the same defense (LORE-161).
325
+ message: `resource "${sanitizeForMessage(actual)}" is stale; this path under the profile's resource_base is "${expected}" — update it or remove the \`resource\` key`,
326
+ },
327
+ ];
328
+ }
329
+
330
+ /**
331
+ * Collapse an author-controlled frontmatter string to a single line with no ANSI escape sequences
332
+ * or other control bytes, for safe embedding into a {@link Finding} `message`. {@link singleLine}
333
+ * (cli-contract §5.2's own single-line discipline) only folds line *terminators* (CR/LF/U+2028/
334
+ * U+2029); a YAML double-quoted scalar can also smuggle an ESC-led ANSI sequence or another C0/C1/
335
+ * DEL control byte (e.g. `resource: "…\x1b[31m…"`), which `singleLine` leaves untouched. Runs
336
+ * `singleLine` first, then strips what it leaves via the shared {@link stripAnsiAndControls}
337
+ * (LORE-181) — the single home for that two-pass strip, also used by `output.ts`,
338
+ * `commands/query.ts`, and `core/links.ts` — imported from `errors.ts` rather than `output.ts`:
339
+ * this module must stay filesystem/output-layer-free (module doc above), and `errors.ts` is
340
+ * layer-neutral.
341
+ */
342
+ function sanitizeForMessage(text: string): string {
343
+ return stripAnsiAndControls(singleLine(text));
344
+ }
345
+
346
+ /** Normalize a heading or section name for comparison: trim, collapse interior whitespace, lower-case. */
347
+ function normalizeHeading(text: string): string {
348
+ return text.trim().replace(/\s+/g, " ").toLowerCase();
349
+ }
350
+
351
+ /**
352
+ * The text of every depth-2 (`##`) heading that is a **direct, top-level child of the document
353
+ * root** — i.e. a genuine section heading, not one nested inside a blockquote or list item —
354
+ * in document order. Extraction defers to a CommonMark parser (`mdast-util-from-markdown`) so a
355
+ * `## ` that appears inside a fenced/indented code block is **not** mistaken for a heading —
356
+ * matching how {@link loadBundle} extracts links. Unlike {@link nodeText}'s own traversal (which
357
+ * still walks a matched heading's inline children to assemble its text), this only iterates the
358
+ * root's immediate `children` — it does not recurse into container nodes — so `> ## Status`
359
+ * (inside a blockquote) or a `## `-looking line inside a list item never counts as a top-level
360
+ * section, even though both nest a real `heading` node somewhere in the tree.
361
+ */
362
+ function h2Headings(body: string): string[] {
363
+ const headings: string[] = [];
364
+ for (const node of fromMarkdown(body).children) {
365
+ if (node.type === "heading" && node.depth === 2) {
366
+ headings.push(nodeText(node));
367
+ }
368
+ }
369
+ return headings;
370
+ }
371
+
372
+ // ── Cross-cutting: frontmatter quote-safety ────────────────────────────────────—
373
+
374
+ /** YAML indicator characters that make an unquoted scalar parser-dependent or reserved. */
375
+ const INDICATOR_CHARS: ReadonlySet<string> = new Set(["@", "`", "!", "&", "*", "|", ">", ":"]);
376
+
377
+ /** A bare YAML-1.1 boolean alias a non-1.2 consumer coerces away from a string. */
378
+ const YAML11_BOOLEAN = /^(yes|no|on|off|y|n)$/i;
379
+
380
+ /** A `|`/`>` block-scalar **header** (optional indent digit + chomping indicator), authored on purpose. */
381
+ const BLOCK_SCALAR_HEADER = /^[|>][1-9]?[+-]?$/;
382
+
383
+ /** A bare `YYYY-MM-DD` date a YAML-1.1 consumer parses as a timestamp. */
384
+ const BARE_DATE = /^\d{4}-\d{2}-\d{2}$/;
385
+
386
+ /**
387
+ * The quote-safety findings for a file's raw frontmatter (ADR-0007 "frontmatter quote-safety").
388
+ * lore's *own* output is already safe — it serializes structurally and js-yaml quotes whatever
389
+ * needs it ([ADR-0011](../../docs/adr/0011-frontmatter-serialization-stability.md)) — so this
390
+ * check exists for **author-written** frontmatter, whose ambiguous scalars round-trip
391
+ * differently across the bundle's target consumers (GitHub, Obsidian, MkDocs, Docusaurus), some
392
+ * of which still parse YAML 1.1.
393
+ *
394
+ * It is a deliberately **best-effort line scan** over the raw fenced block, not a re-parse: by
395
+ * the time js-yaml has parsed the value, the ambiguity is already resolved, so the raw token is
396
+ * the only place the hazard is visible. To stay false-positive-free on real bundles it inspects
397
+ * only **simple top-level `key: value` lines** with a single-line unquoted scalar value;
398
+ * quoted values, block scalars (`|`/`>`), flow collections (`[`/`{`), list items, nested
399
+ * (indented) lines, and empty values are left alone (a documented limitation — nested/list
400
+ * scalars are not analyzed). Returns `[]` for a file with no frontmatter fence.
401
+ */
402
+ export function quoteSafetyFindings(raw: string): Finding[] {
403
+ const findings: Finding[] = [];
404
+ for (const value of topLevelScalarValues(raw)) {
405
+ const finding = quoteSafetyForValue(value);
406
+ if (finding !== null) {
407
+ findings.push(finding);
408
+ }
409
+ }
410
+ return findings;
411
+ }
412
+
413
+ /** Judge one unquoted scalar value, or `null` when it is quote-safe. */
414
+ function quoteSafetyForValue(value: string): Finding | null {
415
+ const first = value[0] ?? "";
416
+ // An intentional block-scalar header (`|`, `>-`, `|2`, …) is safe; only `>foo`/`|foo` with
417
+ // content abutting the indicator is suspect (it is not a valid header), so it falls through.
418
+ if ((first === "|" || first === ">") && BLOCK_SCALAR_HEADER.test(value)) {
419
+ return null;
420
+ }
421
+ if (INDICATOR_CHARS.has(first)) {
422
+ return error(
423
+ `unquoted value starts with the YAML indicator "${first}" ("${value}"); quote it so every consumer reads a string`,
424
+ );
425
+ }
426
+ if (YAML11_BOOLEAN.test(value)) {
427
+ return error(`unquoted "${value}" is a boolean to YAML 1.1 consumers; quote it to keep the string "${value}"`);
428
+ }
429
+ if (value.includes(": ")) {
430
+ return error(
431
+ `unquoted value contains a colon ("${value}"); YAML reads "key: a: b" as a nested mapping — quote the value`,
432
+ );
433
+ }
434
+ if (BARE_DATE.test(value)) {
435
+ return warning(`unquoted "${value}" parses as a date to YAML 1.1 consumers; quote it to keep it a string`);
436
+ }
437
+ return null;
438
+ }
439
+
440
+ /** Build an error-severity quote-safety {@link Finding}. */
441
+ function error(message: string): Finding {
442
+ return { severity: "error", rule: "quote-safety", message };
443
+ }
444
+
445
+ /** Build a warning-severity quote-safety {@link Finding}. */
446
+ function warning(message: string): Finding {
447
+ return { severity: "warning", rule: "quote-safety", message };
448
+ }
449
+
450
+ /**
451
+ * The unquoted single-line scalar values of the top-level `key: value` lines in a file's raw
452
+ * frontmatter fence, in order. Returns `[]` when the file has no leading `---` fence. Only the
453
+ * lines this scan can judge safely are returned; everything else (see
454
+ * {@link quoteSafetyFindings}) is skipped.
455
+ */
456
+ function topLevelScalarValues(raw: string): string[] {
457
+ const block = frontmatterBlock(raw);
458
+ if (block === null) {
459
+ return [];
460
+ }
461
+ const values: string[] = [];
462
+ for (const line of block.split("\n")) {
463
+ // Top-level only: a leading space/tab means a nested or list value, which this scan does
464
+ // not analyze. A blank line or a YAML comment carries no scalar.
465
+ if (line === "" || /^\s/.test(line) || line.startsWith("#")) {
466
+ continue;
467
+ }
468
+ const match = /^[A-Za-z0-9_][\w.-]*:(?:[ \t]+(.*))?$/.exec(line);
469
+ if (match === null) {
470
+ continue; // not a simple `key:`/`key: value` line (e.g. a `- item`, or `?`-complex key)
471
+ }
472
+ let value = (match[1] ?? "").trim();
473
+ // An empty value (`key:`) or a comment-only value (`key: # note`, which YAML reads as null)
474
+ // carries no scalar to judge.
475
+ if (value === "" || value.startsWith("#")) {
476
+ continue;
477
+ }
478
+ const quote = value[0];
479
+ if (quote === '"' || quote === "'") {
480
+ continue; // already quoted — explicitly a string, safe
481
+ }
482
+ if (value.startsWith("[") || value.startsWith("{")) {
483
+ continue; // flow collection — a structured list/map, not a scalar string
484
+ }
485
+ // Strip a trailing YAML comment only now, on a known-unquoted scalar: a `#` with no leading
486
+ // space (a URL fragment `a#b`) is part of the value and must survive, while ` # note` is a
487
+ // comment YAML discards — analyzing it would be a false positive (a colon there is not a hazard).
488
+ value = stripInlineComment(value);
489
+ if (value !== "") {
490
+ values.push(value);
491
+ }
492
+ }
493
+ return values;
494
+ }
495
+
496
+ /**
497
+ * Extract the raw text **between** the opening and closing `---` fences, or `null` when the file
498
+ * does not open with a frontmatter fence. Applies the same leading normalization
499
+ * {@link parseConcept} does (strip BOM(s), CRLF/CR → LF, drop leading whitespace) so a
500
+ * BOM/Windows/whitespace-padded concept is analyzed identically to how it is parsed, then takes
501
+ * the lines up to the next line that is exactly `---`.
502
+ */
503
+ function frontmatterBlock(raw: string): string | null {
504
+ const normalized = raw
505
+ .replace(/^\uFEFF+/, "")
506
+ .replace(/\r\n?/g, "\n")
507
+ .replace(/^\s+/, "");
508
+ if (!normalized.startsWith("---\n")) {
509
+ return null;
510
+ }
511
+ const rest = normalized.slice(4);
512
+ const end = rest.indexOf("\n---");
513
+ if (end === -1) {
514
+ return null; // no closing fence — not a well-formed concept; parse-tier handles it
515
+ }
516
+ return rest.slice(0, end);
517
+ }