@opum-ai/lore 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +33 -22
  2. package/bin/lore.cjs +35 -7
  3. package/package.json +17 -17
  4. package/src/adapters/backlog.ts +0 -1084
  5. package/src/adapters/git.ts +0 -221
  6. package/src/cli.ts +0 -667
  7. package/src/commands/agent.ts +0 -301
  8. package/src/commands/agents.ts +0 -302
  9. package/src/commands/args.ts +0 -209
  10. package/src/commands/changed.ts +0 -70
  11. package/src/commands/check.ts +0 -1031
  12. package/src/commands/codex-bridge.ts +0 -49
  13. package/src/commands/concurrency.ts +0 -48
  14. package/src/commands/context.ts +0 -292
  15. package/src/commands/discover.ts +0 -89
  16. package/src/commands/explorer.ts +0 -253
  17. package/src/commands/export.ts +0 -93
  18. package/src/commands/fswrite.ts +0 -928
  19. package/src/commands/graph.ts +0 -291
  20. package/src/commands/help.ts +0 -151
  21. package/src/commands/impact.ts +0 -59
  22. package/src/commands/init.ts +0 -583
  23. package/src/commands/instructions.ts +0 -91
  24. package/src/commands/link.ts +0 -929
  25. package/src/commands/new.ts +0 -476
  26. package/src/commands/orphans.ts +0 -457
  27. package/src/commands/path.ts +0 -67
  28. package/src/commands/provenance.ts +0 -68
  29. package/src/commands/query.ts +0 -312
  30. package/src/commands/reconcile-shared.ts +0 -280
  31. package/src/commands/rename.ts +0 -585
  32. package/src/commands/replace.ts +0 -320
  33. package/src/commands/scaffold.ts +0 -346
  34. package/src/commands/schema.ts +0 -293
  35. package/src/commands/snapshot.ts +0 -130
  36. package/src/commands/supersede.ts +0 -400
  37. package/src/commands/sync.ts +0 -371
  38. package/src/commands/tasks.ts +0 -271
  39. package/src/commands/traversal.ts +0 -151
  40. package/src/commands/validate.ts +0 -226
  41. package/src/config.ts +0 -598
  42. package/src/core/agent-bridge.ts +0 -287
  43. package/src/core/agent-context.ts +0 -498
  44. package/src/core/agent-profile.ts +0 -447
  45. package/src/core/bundle.ts +0 -893
  46. package/src/core/check.ts +0 -853
  47. package/src/core/codex-bridge.ts +0 -100
  48. package/src/core/concept.ts +0 -597
  49. package/src/core/consumer-scaffold.ts +0 -433
  50. package/src/core/context.ts +0 -271
  51. package/src/core/explorer-contract.ts +0 -441
  52. package/src/core/explorer-qualification.ts +0 -58
  53. package/src/core/explorer.ts +0 -518
  54. package/src/core/finding.ts +0 -31
  55. package/src/core/graph.ts +0 -201
  56. package/src/core/indexes.ts +0 -436
  57. package/src/core/instructions.ts +0 -209
  58. package/src/core/ladybug-driver.ts +0 -1795
  59. package/src/core/ladybug-lifecycle.ts +0 -1178
  60. package/src/core/ladybug-native.ts +0 -95
  61. package/src/core/ladybug-source.ts +0 -667
  62. package/src/core/links.ts +0 -681
  63. package/src/core/log.ts +0 -253
  64. package/src/core/managed-block.ts +0 -540
  65. package/src/core/manifest.ts +0 -718
  66. package/src/core/order.ts +0 -13
  67. package/src/core/profile.ts +0 -1007
  68. package/src/core/projection.ts +0 -195
  69. package/src/core/query.ts +0 -542
  70. package/src/core/reconcile.ts +0 -236
  71. package/src/core/replace.ts +0 -419
  72. package/src/core/retrieval.ts +0 -213
  73. package/src/core/rewrite.ts +0 -940
  74. package/src/core/scaffold.ts +0 -255
  75. package/src/core/schema.ts +0 -366
  76. package/src/core/snapshot-runtime.ts +0 -52
  77. package/src/core/snapshot-store.ts +0 -287
  78. package/src/core/snapshot.ts +0 -711
  79. package/src/core/template.ts +0 -429
  80. package/src/core/traversal.ts +0 -487
  81. package/src/core/validate.ts +0 -517
  82. package/src/core/workspace-contract.ts +0 -473
  83. package/src/core/workspace-projection.ts +0 -365
  84. package/src/core/workspace-retrieval.ts +0 -196
  85. package/src/core/workspace-source.ts +0 -174
  86. package/src/errors.ts +0 -697
  87. package/src/meta.ts +0 -7
  88. package/src/output.ts +0 -589
  89. package/src/scripts/upstream-backlog-watch.ts +0 -288
  90. package/src/state.ts +0 -390
@@ -1,255 +0,0 @@
1
- /**
2
- * scaffold.ts — the **pure** description of an empty, conformant OKF bundle.
3
- *
4
- * This module is the byte-source for `lore init` (lore-design §3.1): it computes,
5
- * with no filesystem access whatsoever, the exact directories and file contents a
6
- * fresh bundle needs — the `.lore/` state directory ([ADR-0013](../../docs/adr/0013-lore-state-directory.md)),
7
- * the Draft-7 JSON Schemas exported from the Zod source of truth
8
- * ([ADR-0006](../../docs/adr/0006-schema-types-templates.md)), and the reserved
9
- * root `docs/index.md` that carries `okf_version`
10
- * ([okf-conformance](../../docs/reference/okf-conformance.md)).
11
- *
12
- * Keeping it pure is what makes init testable and deterministic: {@link buildScaffold}
13
- * returns a {@link ScaffoldPlan} of intended bytes that golden tests pin exactly, and
14
- * `commands/init.ts` is the thin layer that applies the plan **idempotently** (writing
15
- * only absent files, never clobbering) — the side effects live there, the bytes live
16
- * here. The single non-deterministic input, the root index's `timestamp`, enters
17
- * through an injected option (lore-design §8), so the same options always yield the
18
- * same plan.
19
- *
20
- * Scope (confirmed for LORE-17): an empty bundle only. Full index/log *generation*
21
- * (`indexes.generateIndexes`, `log.ts` → `log.md`) is M3 / `lore sync` and is **not** done here —
22
- * init writes a *minimal* root index. Per-type template *content* is `lore new`'s
23
- * concern (LORE-18, which carries built-in fallbacks); init only ensures the
24
- * `.lore/templates/` directory exists.
25
- */
26
-
27
- import { CONFIG_REL_PATH } from "../config";
28
- import { type Concept, idFromPath, serializeConcept, serializeConceptWithModeline } from "./concept";
29
- import { defaultProfile, PROFILE_REL_PATH, type Profile } from "./profile";
30
- import { emitSchemaFiles, schemaModeline } from "./schema";
31
-
32
- /**
33
- * The bundle root — the directory every concept lives under, relative to the repo root.
34
- * The single source of truth for the `docs/` convention, shared by the scaffolder, the
35
- * bundle walk, and `lore new`'s output-path computation so they never spell it differently.
36
- */
37
- export const DOCS_DIR = "docs";
38
-
39
- /**
40
- * The reserved bundle-root index — the only file that carries `okf_version`. Exported so
41
- * `core/validate.ts` can recognize it as the one lore-owned reserved file whose frontmatter is
42
- * always a concept (every other `RESERVED_STEMS` file — sub-directory `index.md`s, `log.md` — is
43
- * frontmatter-free and so never reaches the profile-driven validator at all): `lore validate`
44
- * must judge it under the same {@link defaultProfile} `serializeStructuralConcept` wrote it
45
- * against, never the active project profile (LORE-144).
46
- */
47
- export const ROOT_INDEX_PATH = `${DOCS_DIR}/index.md`;
48
-
49
- /**
50
- * Reserved file stems that name machine-generated hubs (`index.md`, `log.md`): regenerated
51
- * wholesale by `lore sync`, never a rename/supersede/link/unlink principal. Shared by
52
- * `commands/rename.ts`, `commands/supersede.ts`, and `commands/link.ts` — each guards its own
53
- * concept-id argument(s) against it.
54
- */
55
- export const RESERVED_STEMS: ReadonlySet<string> = new Set(["index", "log"]);
56
-
57
- /** A single file the scaffold wants to exist, with the exact bytes to write when it is absent. */
58
- export interface ScaffoldFile {
59
- /** Repo-relative POSIX path. */
60
- readonly path: string;
61
- /** The exact bytes to write — pinned by golden tests, applied verbatim by the command. */
62
- readonly contents: string;
63
- }
64
-
65
- /**
66
- * The complete, filesystem-free description of an empty bundle. `dirs` are ensured to
67
- * exist (idempotent `mkdir -p`, in parent-first order); `files` are created only when
68
- * absent so a re-run never clobbers user edits (the AC#2 idempotency contract).
69
- */
70
- export interface ScaffoldPlan {
71
- /** Directories to ensure exist, parents before children. */
72
- readonly dirs: readonly string[];
73
- /** Files to create if absent, in a stable order. */
74
- readonly files: readonly ScaffoldFile[];
75
- }
76
-
77
- /** Options for {@link buildScaffold}; the timestamp is the one injected determinism seam. */
78
- export interface ScaffoldOptions {
79
- /**
80
- * ISO-8601 datetime (e.g. `2026-06-25T12:00:00Z`) stamped on the generated root
81
- * index. Injected by the command from its clock so the plan is deterministic and
82
- * golden-testable (lore-design §8).
83
- */
84
- readonly timestamp: string;
85
- /**
86
- * The active profile whose types drive the emitted JSON Schemas and whose `okfVersion` stamps
87
- * the root index. Defaults to the built-in {@link defaultProfile}, so a zero-config `lore init`
88
- * scaffolds the six story-convention schemas exactly as before.
89
- */
90
- readonly profile?: Profile;
91
- }
92
-
93
- /**
94
- * Build the {@link ScaffoldPlan} for an empty OKF bundle: the `.lore/` state tree, one exported
95
- * JSON Schema per profile type, and the reserved root `docs/index.md`. Pure — it touches no
96
- * filesystem and reads no clock; identical `options` always produce identical bytes.
97
- */
98
- export function buildScaffold(options: ScaffoldOptions): ScaffoldPlan {
99
- const profile = options.profile ?? defaultProfile();
100
- return {
101
- dirs: [".lore", ".lore/schemas", ".lore/templates", ".lore/cache", DOCS_DIR],
102
- files: [
103
- { path: CONFIG_REL_PATH, contents: DEFAULT_CONFIG_TOML },
104
- { path: PROFILE_REL_PATH, contents: DEFAULT_PROFILE_TOML },
105
- { path: ".lore/.gitignore", contents: LORE_GITIGNORE },
106
- ...schemaFiles(profile),
107
- // Materialize the templates directory without committing to per-type content:
108
- // `lore new` (LORE-18) owns the template bodies and their override-if-present
109
- // logic, so init only keeps the directory tracked.
110
- { path: ".lore/templates/.gitkeep", contents: "" },
111
- { path: ROOT_INDEX_PATH, contents: rootIndexDocument(options.timestamp, profile) },
112
- ],
113
- };
114
- }
115
-
116
- /**
117
- * One {@link ScaffoldFile} per profile type, each the generated Draft-7 JSON Schema pretty-printed
118
- * with a trailing newline, in the profile's type-declaration order so the plan (and its golden) is
119
- * stable. Delegates to the shared {@link emitSchemaFiles} (the byte contract `lore schema export`
120
- * reuses), so a scaffolded schema and a re-exported one can never diverge.
121
- */
122
- function schemaFiles(profile: Profile): ScaffoldFile[] {
123
- return emitSchemaFiles(profile);
124
- }
125
-
126
- /**
127
- * The minimal reserved root index: byte-stable frontmatter (a `Reference` carrying
128
- * `okf_version: "0.1"` — the sole carrier in the bundle), with the editor modeline
129
- * spliced in as the **first line inside** the `---` fence via
130
- * {@link serializeConceptWithModeline} (the shared placement seam in concept.ts).
131
- *
132
- * The modeline sits *inside* the frontmatter, not above it, because that is the only
133
- * placement that both (a) lets lore read the file back as a concept — `parseConcept`
134
- * needs `---` at byte 0, so an above-fence comment would make `loadBundle` skip the
135
- * index as a non-concept — and (b) matches every modeline-bearing doc already in this
136
- * bundle. (Trade-off: js-yaml drops the in-fence comment if the file is ever
137
- * re-serialized — a documented round-trip limitation in concept.ts that applies to all
138
- * such docs equally; `init` writes the index once and never rewrites it.)
139
- */
140
- function rootIndexDocument(timestamp: string, profile: Profile): string {
141
- const concept: Concept = {
142
- id: idFromPath(ROOT_INDEX_PATH),
143
- path: ROOT_INDEX_PATH,
144
- type: ROOT_INDEX_TYPE,
145
- frontmatter: {
146
- type: ROOT_INDEX_TYPE,
147
- title: "Documentation",
148
- summary: "Root index of this OKF documentation bundle, created by `lore init`.",
149
- timestamp,
150
- okf_version: profile.okfVersion,
151
- },
152
- body: ROOT_INDEX_BODY,
153
- };
154
- // Only `okf_version` above is profile-derived; the serialization choice itself (structural
155
- // profile, conditional modeline) is shared with every other reserved structural file — see
156
- // {@link serializeStructuralConcept}.
157
- return serializeStructuralConcept(concept, profile);
158
- }
159
-
160
- /**
161
- * Serialize a lore-owned **reserved structural file** — one with a fixed shape that lore itself
162
- * generates (the root `docs/index.md` here, `docs/tags.md` in `core/consumer-scaffold.ts`) — never
163
- * a user concept. Always validated/serialized against the built-in {@link defaultProfile}, never the
164
- * *active* one, so a custom profile that retypes the concept's `type` (e.g. adds a required field to
165
- * `Reference`) can never make a scaffold command fail to write its own reserved file. The `$schema`
166
- * editor modeline is carried only when the *active* `profile` defines `concept.type` — so the schema
167
- * it points at was actually emitted under `.lore/schemas/` — via {@link serializeConceptWithModeline};
168
- * otherwise a plain {@link serializeConcept} against the structural profile.
169
- */
170
- export function serializeStructuralConcept(concept: Concept, profile: Profile): string {
171
- const structural = defaultProfile();
172
- return profile.types.has(concept.type)
173
- ? serializeConceptWithModeline(concept, schemaModeline(concept.path, concept.type), { profile: structural })
174
- : serializeConcept(concept, { profile: structural });
175
- }
176
-
177
- /** The reserved root index's `type` — lore's bundle entry point is conventionally a `Reference`. */
178
- const ROOT_INDEX_TYPE = "Reference";
179
-
180
- /** The body of the scaffolded root index (after the frontmatter fence). */
181
- const ROOT_INDEX_BODY = `
182
- # Documentation
183
-
184
- This is the root index of an OKF documentation bundle, created by \`lore init\`.
185
- Add concepts under \`docs/\` and link them from here. This file is the bundle's
186
- entry point and the only one that carries \`okf_version\`.
187
- `;
188
-
189
- /** The committed `.lore/.gitignore`: keep the transient cache out of git (ADR-0013). */
190
- const LORE_GITIGNORE = `# lore transient cache — recomputable, machine-local, never committed (ADR-0013).
191
- cache/
192
- `;
193
-
194
- /**
195
- * The default, fully-commented `.lore/config.toml`. Every setting is shown commented
196
- * because lore is zero-config: a missing file — or one with every line commented —
197
- * yields the documented defaults (ADR-0013), so a fresh `init` produces a file that
198
- * changes nothing until a team uncomments a knob. The committed-token guard in
199
- * config.ts still applies; the Confluence token is environment-only.
200
- */
201
- const DEFAULT_CONFIG_TOML = `# lore configuration — committed, team-shared knobs for \`lore sync\` / \`lore check\`.
202
- # Every setting is OPTIONAL: with this file absent, or every line below commented,
203
- # lore uses the documented defaults. See docs/adr/0013-lore-state-directory.md.
204
-
205
- # [reconcile]
206
- # Status roll-up policy applied by \`lore sync\` / \`lore check\`.
207
- # mode = "task-rollup" # all tasks Done -> done; any In Progress -> in-progress; else todo
208
-
209
- # [validate]
210
- # external_links = false # external-link liveness is opt-in only
211
- # promote_portability = false # keep the portability lint a warning, not an error
212
-
213
- # [confluence]
214
- # Non-secret publish target settings (the one-way publish adapter is deferred to v2).
215
- # The API token is environment-only: set $LORE_CONFLUENCE_TOKEN, never store it here.
216
- # base_url = "https://yourorg.atlassian.net/wiki"
217
- # space = "ENG"
218
- # parent_page_id = "98765"
219
- # format = "storage" # or "adf"
220
- `;
221
-
222
- /**
223
- * The default, fully-commented `.lore/profile.toml`. The profile is the declarative source of
224
- * truth for the type vocabulary (ADR-0006): with this file absent — or every line below
225
- * commented — lore uses the built-in story-convention profile (Epic/Story/Spec/ADR/Runbook/
226
- * Reference), so a fresh `init` produces a file that changes nothing until a team defines its own
227
- * types. It is separate from `config.toml`: config carries operational knobs, the profile carries
228
- * the type system. See docs/adr/0006-schema-types-templates.md.
229
- */
230
- const DEFAULT_PROFILE_TOML = `# lore profile — committed, declarative type vocabulary for this bundle.
231
- # OPTIONAL: with this file absent, or every line below commented, lore uses the built-in
232
- # story-convention profile (Epic/Story/Spec/ADR/Runbook/Reference). Fill it in to define your
233
- # own types. lore generates its runtime validators + editor JSON Schemas from this file at load.
234
-
235
- # [profile]
236
- # name = "my-project" # required once any line below is uncommented
237
- # okf_version = "0.1" # required; asserted against the bundle-root index.md
238
- # case = "Title" # type-name casing convention (advisory; powers the did-you-mean hint)
239
- # resource_base = "" # prefix for the stamped \`resource\` link (empty = none)
240
-
241
- # [base.fields]
242
- # Fields every type carries. \`type\` MUST be required (OKF's one hard requirement).
243
- # type = { required = true }
244
- # title = {}
245
- # description = {}
246
- # tags = { kind = "list" }
247
- # summary = {}
248
- # timestamp = { kind = "datetime" }
249
-
250
- # [[types]]
251
- # name = "Spec" # the OKF \`type\` value
252
- # sections = ["Summary", "Design"] # required body headings (## …)
253
- # template = "spec.md" # template under .lore/templates/
254
- # fields = { feature = { required = true }, status = { enum = ["draft", "approved"] } }
255
- `;
@@ -1,366 +0,0 @@
1
- /**
2
- * schema.ts — validate concept frontmatter against the **active profile**.
3
- *
4
- * Per [ADR-0006](../../docs/adr/0006-schema-types-templates.md) (inverted by LORE-46),
5
- * the type vocabulary is no longer hand-authored Zod here: it is **data**, loaded and
6
- * compiled by [profile.ts](./profile.ts) into a {@link Profile} whose per-type Zod
7
- * validators and editor JSON Schemas are *generated* from a declarative
8
- * `.lore/profile.toml`. This module is the **consumer** of that compiled profile: it runs
9
- * the per-type validator, classifies findings into OKF's tiers, and owns the editor
10
- * schema-file naming + modeline conventions. With no profile passed, every function
11
- * defaults to the built-in story-convention {@link defaultProfile} — so a caller that
12
- * does not opt into a custom profile sees exactly the behavior lore shipped before the
13
- * profile existed.
14
- *
15
- * The validation tiers mirror OKF §9 + the active profile
16
- * ([okf-conformance](../../docs/reference/okf-conformance.md) "How lore checks conformance"):
17
- *
18
- * - **ERROR** (throws a `validation` {@link LoreError}, exit 6): unparseable frontmatter
19
- * (handled upstream in concept.ts), a missing/empty `type`, or a *known* field carrying
20
- * the wrong type (e.g. `tags` that is not a list).
21
- * - **WARNING** (recorded on a {@link WarningCollector}, never fatal): an unknown `type`
22
- * (validated on `type` alone), an extra key on a *known* type, or a missing/over-long
23
- * `summary`.
24
- *
25
- * Two deliberate departures from a naive reading of ADR-0006 are preserved by the
26
- * generated profile (see [profile.ts](./profile.ts)): known schemas are **loose** (extra
27
- * keys pass validation and are warned separately, preserving OKF tolerance), and
28
- * validation **never rewrites** the data — {@link validateFrontmatter} only inspects the
29
- * frontmatter and returns the resolved `type`, so byte-stable round-tripping
30
- * ([ADR-0011](../../docs/adr/0011-frontmatter-serialization-stability.md)) is never
31
- * compromised by coercion, key-stripping, or reordering. Dates stay **ISO strings**, never
32
- * coerced to `Date` (ADR-0006 §2).
33
- */
34
-
35
- import { posix } from "node:path";
36
- import type { z } from "zod";
37
- import { LoreError, type WarningCollector } from "../errors";
38
- import { type CompiledType, defaultProfile, type Profile, slugForTypeName } from "./profile";
39
-
40
- /**
41
- * The bundle-root index's path, in the two conventions a caller of {@link validateFrontmatter} may
42
- * pass for the identical physical file: **bundle-root-relative** (`"index.md"` — every
43
- * {@link import("./bundle").loadBundle}-backed command (`sync`/`query`/`graph`/`link`/…) reaches
44
- * this module through {@link import("./concept").parseConcept}/`tryParseConcept`, fed
45
- * bundle-root-relative paths by `loadBundle`'s own walk) and **repo-relative**
46
- * (`"docs/index.md"` — `core/validate.ts`'s `validateConceptText` threads this form instead; see
47
- * its own `ROOT_INDEX_PATH`, LORE-144). Both name the one file `lore init` ever stamps
48
- * `okf_version` onto, so both must be recognized as "the root" here. This module cannot import
49
- * `scaffold.ts`'s `ROOT_INDEX_PATH` directly — `scaffold.ts` already imports from this module, and
50
- * doing so would be a circular import — so the two literal spellings are pinned locally instead.
51
- */
52
- const ROOT_INDEX_PATHS: ReadonlySet<string> = new Set(["index.md", "docs/index.md"]);
53
-
54
- /** Whether `path` names the bundle-root index, under either convention {@link ROOT_INDEX_PATHS} pins. */
55
- function isRootIndexPath(path: string | undefined): boolean {
56
- return path !== undefined && ROOT_INDEX_PATHS.has(path);
57
- }
58
-
59
- /**
60
- * Whether `key` is an OKF-reserved key that passes the extra-key warning on a known type. Both
61
- * `resource` and `okf_version` are legitimate, lore-recognized fields — not stray producer
62
- * extensions — so flagging either as unknown (as the generic extra-key check otherwise would)
63
- * would be a false positive on lore's own conformant output, but each is reserved only in the
64
- * ONE position lore itself ever writes it:
65
- *
66
- * - `resource` is the OKF-recommended canonical link `lore new` stamps from the profile's
67
- * `resource_base` (LORE-47) onto an ordinary concept. **Index files are the exception**: lore
68
- * never stamps `resource` on an `index.md` (it is a structure page, not a cited concept —
69
- * LORE-47 AC#4/#5), so a `resource:` hand-authored onto one (`isIndex`) is not lore's
70
- * recognized output and is warned like any other extra key.
71
- * - `okf_version` is the bundle-root index's conformance marker (OKF §4) — a whole-bundle
72
- * conformance property, not a per-concept one. Only the bundle-ROOT index
73
- * ({@link isRootIndexPath}) — the one file `lore init`'s `serializeStructuralConcept` ever
74
- * stamps it onto — is exempt. A hand-authored `okf_version` anywhere else (an ordinary
75
- * concept, or a *sub*-index like `docs/adr/index.md`) is not lore's own output and is warned
76
- * like any other extra key (LORE-168; previously this was unconditionally exempt everywhere,
77
- * contradicting the conformance check `docs/reference/okf-conformance.md` documents).
78
- */
79
- function isReservedKey(key: string, isIndex: boolean, isRootIndex: boolean): boolean {
80
- if (key === "resource") {
81
- return !isIndex;
82
- }
83
- if (key === "okf_version") {
84
- return isRootIndex;
85
- }
86
- return false;
87
- }
88
-
89
- /** The longest a `summary` should be before lore warns it is no longer a one-liner (ADR-0006 §5). */
90
- const SUMMARY_SOFT_LIMIT = 200;
91
-
92
- /** Options for {@link validateFrontmatter}. */
93
- export interface ValidateOptions {
94
- /** Sink for advisory warnings (unknown type, extra keys, summary). Absent → warnings are dropped. */
95
- warnings?: WarningCollector;
96
- /** The concept's repo-relative path, woven into diagnostics so a finding names its file. */
97
- path?: string;
98
- /** The active profile to validate against; defaults to the built-in {@link defaultProfile}. */
99
- profile?: Profile;
100
- }
101
-
102
- /** Narrow an arbitrary string to a type the `profile` (default: built-in) validates strictly-by-field. */
103
- export function isKnownType(type: string, profile: Profile = defaultProfile()): boolean {
104
- return profile.types.has(type);
105
- }
106
-
107
- /**
108
- * Resolve a user-supplied `<type>` token to its canonical spelling in `profile`. `lore new`
109
- * accepts a type case-insensitively (`story`, `ADR`, `reference`), so a token whose lower-case
110
- * form names a profile type returns that type's canonical casing (`Story`, `ADR`, `Reference`) —
111
- * the value lore writes to `type:` and keys its schema by. An **unknown** type is a tolerated OKF
112
- * producer extension: it is returned **trimmed but otherwise verbatim** (the author's own casing
113
- * preserved), never folded or rejected.
114
- */
115
- export function canonicalType(input: string, profile: Profile = defaultProfile()): string {
116
- const trimmed = input.trim();
117
- return profile.byLowerName.get(trimmed.toLowerCase()) ?? trimmed;
118
- }
119
-
120
- /**
121
- * The **required body sections** (`##` headings) a concept of `type` must carry under `profile` —
122
- * the per-type tier-2 section contract `lore validate` enforces as an **error** (ADR-0007). An
123
- * unknown (producer-extension) type yields `[]` (OKF tolerance: lore never imposes a section shape
124
- * on a type it does not own). The single source the validator reads.
125
- */
126
- export function requiredSectionsFor(type: string, profile: Profile = defaultProfile()): readonly string[] {
127
- return profile.types.get(type)?.requiredSections ?? [];
128
- }
129
-
130
- /**
131
- * The bundle sub-directory each story-convention type's concepts conventionally live under,
132
- * relative to the bundle root (`docs/`). These plural/acronym directory names are **not derivable**
133
- * from the type by any single rule (the bundle uses the acronym `adr`, the singular `reference`, and
134
- * the plurals `runbooks`/`specs`), so they are a lore built-in convenience map — independent of the
135
- * profile, which (by its finalized grammar) carries no per-type directory. A type outside this map
136
- * (a producer extension, or a custom-profile type) falls back to its LOWER-KEBAB slug.
137
- */
138
- const TYPE_DIRECTORIES: Readonly<Record<string, string>> = Object.freeze({
139
- Epic: "epics",
140
- Story: "stories",
141
- Spec: "specs",
142
- ADR: "adr",
143
- Runbook: "runbooks",
144
- Reference: "reference",
145
- });
146
-
147
- /**
148
- * The bundle sub-directory a concept of `type` is scaffolded into: the {@link TYPE_DIRECTORIES}
149
- * convention for a story-convention type, else the type's {@link slugForTypeName LOWER-KEBAB slug}
150
- * (so `lore new "QA Plan" …` lands under `docs/qa-plan/`, never `docs/qa plan/`). Using the slug —
151
- * not a bare lower-case — keeps a multi-word/space-containing profile type from yielding an invalid
152
- * path segment. Returns a path segment relative to the bundle root, never including `docs/` itself.
153
- * A caller may always override the computed path (`lore new … --out`).
154
- */
155
- export function typeDirectory(type: string): string {
156
- return TYPE_DIRECTORIES[type] ?? slugForTypeName(type);
157
- }
158
-
159
- /**
160
- * Validate a frontmatter object against `options.profile` (default: built-in), **throwing** on an
161
- * error-tier problem and **recording** warning-tier ones on `options.warnings`. It never mutates
162
- * `fm` — the caller keeps the verbatim object so byte-stable round-tripping is preserved (ADR-0011).
163
- * It **returns the resolved type** (the `type` value, trimmed). Behavior by tier:
164
- *
165
- * - Missing/empty `type` → throw (`validation`). This is the OKF §9 floor.
166
- * - Unknown `type` → warn; the type-only floor already passed, so nothing else is checked and
167
- * every key is preserved (OKF tolerance).
168
- * - Known `type` with a mistyped field → throw (`validation`) citing the field(s). A `type`
169
- * carrying surrounding whitespace, or spelled in a different casing than the profile's
170
- * canonical form (`story` for `Story`), classifies via {@link canonicalType} — so it is
171
- * looked up and validated against that type's *real* schema — and then fails the schema's
172
- * `type` literal check loudly here, rather than being silently demoted to an unvalidated
173
- * unknown type. Only the lookup key is folded; `fm` itself is never rewritten (ADR-0011).
174
- * - Known `type` with extra keys → one warning per extra key.
175
- * - Missing or over-long (~{@link SUMMARY_SOFT_LIMIT}-char) `summary` → warn.
176
- */
177
- export function validateFrontmatter(fm: Record<string, unknown>, options: ValidateOptions = {}): string {
178
- const profile = options.profile ?? defaultProfile();
179
- const where = options.path ? ` in ${options.path}` : "";
180
- const type = requireType(fm, where, options.path);
181
-
182
- const compiled = profile.types.get(canonicalType(type, profile));
183
- if (compiled === undefined) {
184
- // Unknown type: the non-empty-`type` floor (OKF §9) is already satisfied, so this is a
185
- // tolerated producer extension — warn, validate nothing further, leave every key untouched.
186
- options.warnings?.add(`unknown type "${type}"${where}; validated on \`type\` only`);
187
- return type;
188
- }
189
-
190
- const result = compiled.schema.safeParse(fm);
191
- if (!result.success) {
192
- throw new LoreError(
193
- "validation",
194
- `invalid ${type} frontmatter${where}: ${describeIssues(result.error)}`,
195
- "fix the field(s) named above to match the type's schema, or run `lore validate`",
196
- { path: options.path, type, issues: issueList(result.error) },
197
- );
198
- }
199
-
200
- const isIndex = options.path !== undefined && posix.basename(options.path) === "index.md";
201
- const isRootIndex = isRootIndexPath(options.path);
202
- warnExtraKeys(fm, compiled, where, options.warnings, isIndex, isRootIndex);
203
- warnSummary(fm.summary, where, options.warnings);
204
- return type;
205
- }
206
-
207
- /**
208
- * Read a non-empty string `type` and return it **trimmed**, or throw the OKF §9 floor error.
209
- * Trimming the return means a `type` with accidental surrounding whitespace classifies on its real
210
- * value (so a known type is type-checked, not silently demoted to "unknown"); the frontmatter object
211
- * keeps the verbatim value. `path` is echoed on the error's `input.path` so a consumer gets a usable path.
212
- */
213
- function requireType(fm: Record<string, unknown>, where: string, path: string | undefined): string {
214
- const type = fm.type;
215
- if (type === undefined || type === null) {
216
- throw new LoreError(
217
- "validation",
218
- `frontmatter${where} is missing a \`type\``,
219
- "every concept needs a `type:` field (OKF §9); add one, e.g. `type: Reference`",
220
- { path },
221
- );
222
- }
223
- if (typeof type !== "string" || type.trim() === "") {
224
- throw new LoreError(
225
- "validation",
226
- `frontmatter${where} has an invalid \`type\` (must be a non-empty string)`,
227
- 'set `type` to a non-empty string; quote it if it looks like a number or date, e.g. `type: "2026"`',
228
- { path },
229
- );
230
- }
231
- return type.trim();
232
- }
233
-
234
- /**
235
- * Warn for each key on a *known*-type concept that its profile type does not declare. Diffs the
236
- * frontmatter's own keys against the compiled type's {@link CompiledType.declaredFields} (the
237
- * generated schema is loose, so these extras validated fine) — the tier-3 OKF-tolerance warning,
238
- * not an error.
239
- */
240
- function warnExtraKeys(
241
- fm: Record<string, unknown>,
242
- compiled: CompiledType,
243
- where: string,
244
- warnings: WarningCollector | undefined,
245
- isIndex: boolean,
246
- isRootIndex: boolean,
247
- ): void {
248
- if (warnings === undefined) {
249
- return;
250
- }
251
- for (const key of Object.getOwnPropertyNames(fm)) {
252
- if (!compiled.declaredFields.has(key) && !isReservedKey(key, isIndex, isRootIndex)) {
253
- warnings.add(`unknown key "${key}"${where}; preserved but not validated`);
254
- }
255
- }
256
- }
257
-
258
- /** Warn when `summary` is absent or runs well past the one-liner soft limit (ADR-0006 §5). */
259
- function warnSummary(summary: unknown, where: string, warnings: WarningCollector | undefined): void {
260
- if (warnings === undefined) {
261
- return;
262
- }
263
- if (summary === undefined || summary === null) {
264
- warnings.add(`missing \`summary\`${where}; add a one-line summary for indexes and query snippets`);
265
- return;
266
- }
267
- if (typeof summary === "string") {
268
- const codePointLength = [...summary].length;
269
- if (codePointLength > SUMMARY_SOFT_LIMIT) {
270
- warnings.add(
271
- `\`summary\`${where} is ${codePointLength} chars; keep it under ~${SUMMARY_SOFT_LIMIT} (one sentence)`,
272
- );
273
- }
274
- }
275
- }
276
-
277
- /**
278
- * Project one Zod issue onto its `{ path, message }` pair — the single source of the dotted-path
279
- * rendering, so the human message and the JSON envelope's `input.issues` can never spell a nested
280
- * path two different ways if Zod's issue shape changes.
281
- */
282
- function projectIssue(issue: z.core.$ZodIssue): { path: string; message: string } {
283
- return { path: issue.path.join("."), message: issue.message };
284
- }
285
-
286
- /** Flatten Zod issues to a single-line, human "field: reason; field: reason" string. */
287
- function describeIssues(error: z.ZodError): string {
288
- return error.issues
289
- .map((issue) => {
290
- const { path, message } = projectIssue(issue);
291
- return `${path || "(root)"}: ${message}`;
292
- })
293
- .join("; ");
294
- }
295
-
296
- /** Project Zod issues onto a plain, JSON-safe array for the error envelope's `input`. */
297
- function issueList(error: z.ZodError): Array<{ path: string; message: string }> {
298
- return error.issues.map(projectIssue);
299
- }
300
-
301
- // ── JSON Schema emission + editor modeline (ADR-0006 §3) ───────────────────────
302
- //
303
- // The generated profile already carries each type's Draft-7 JSON Schema
304
- // ({@link CompiledType.jsonSchema}); `lore init` writes one per type under `.lore/schemas/`.
305
- // This module owns the **filename + modeline conventions** that bind a doc to its schema, kept
306
- // here beside the validator so the emitter, the modeline writer, and the runtime check can never
307
- // disagree about what a type's schema is called or where it lives.
308
-
309
- /** Where lore writes the emitted JSON Schemas, relative to the repo root (ADR-0013). */
310
- export const SCHEMAS_DIR = ".lore/schemas";
311
-
312
- /**
313
- * The on-disk filename for a type's JSON Schema: its LOWER-KEBAB slug plus `.schema.json`
314
- * (`Reference` → `reference.schema.json`, `QA Plan` → `qa-plan.schema.json` — AC#7). For the
315
- * single-word story-convention types the slug equals the lower-cased name, so the filenames are
316
- * unchanged from before the profile existed (the committed modelines in this bundle still resolve).
317
- * The single source of the convention, shared by the scaffolder and {@link schemaModeline}.
318
- */
319
- export function schemaFileName(type: string): string {
320
- return `${slugForTypeName(type)}.schema.json`;
321
- }
322
-
323
- /**
324
- * The editor modeline for a concept at `docPath` of `type` — the comment
325
- * `# yaml-language-server: $schema=<relative path to .lore/schemas/<slug>.schema.json>` (ADR-0006 §3).
326
- * The `$schema` path is computed **relative to the document's own directory** with POSIX separators,
327
- * so it resolves identically on every consumer regardless of how deep the doc sits. Pure and
328
- * filesystem-free.
329
- */
330
- export function schemaModeline(docPath: string, type: string): string {
331
- const relDir = posix.relative(posix.dirname(docPath), SCHEMAS_DIR);
332
- return `# yaml-language-server: $schema=${posix.join(relDir, schemaFileName(type))}`;
333
- }
334
-
335
- /** A single emitted JSON Schema file: its `<dir>`-relative path and the exact bytes to write. */
336
- export interface SchemaFile {
337
- /** `<dir>/<slug>.schema.json`, POSIX-joined (`.lore/schemas/reference.schema.json`). */
338
- readonly path: string;
339
- /** The type's Draft-7 JSON Schema, two-space pretty-printed with one trailing newline (the byte contract). */
340
- readonly contents: string;
341
- }
342
-
343
- /** Options for {@link emitSchemaFiles}. */
344
- export interface EmitSchemaFilesOptions {
345
- /** Directory the files are placed under (default {@link SCHEMAS_DIR}). */
346
- readonly dir?: string;
347
- /** Emit only this one compiled type; default: every type in the profile, in declaration order. */
348
- readonly only?: CompiledType;
349
- }
350
-
351
- /**
352
- * The emitted JSON Schema files for a `profile` — one {@link SchemaFile} per type (or just
353
- * `options.only`), each the type's generated Draft-7 JSON Schema pretty-printed with a two-space
354
- * indent and a single trailing newline (the committed byte contract), placed under `options.dir`.
355
- * Pure and deterministic: the same profile always yields the same files in the same order. This is
356
- * the **single source** `lore init` ({@link import("./scaffold").buildScaffold}) and
357
- * `lore schema export` share, so a scaffolded schema file and a re-exported one are byte-identical.
358
- */
359
- export function emitSchemaFiles(profile: Profile, options: EmitSchemaFilesOptions = {}): SchemaFile[] {
360
- const dir = options.dir ?? SCHEMAS_DIR;
361
- const types = options.only ? [options.only] : [...profile.types.values()];
362
- return types.map((type) => ({
363
- path: posix.join(dir, schemaFileName(type.name)),
364
- contents: `${JSON.stringify(type.jsonSchema, null, 2)}\n`,
365
- }));
366
- }
@@ -1,52 +0,0 @@
1
- /** Source-loading boundary shared by snapshot/change/provenance commands. */
2
-
3
- import type { BacklogAdapter } from "../adapters/backlog";
4
- import type { WarningCollector } from "../errors";
5
- import { EXPECTED_LADYBUG_STORAGE_VERSION, EXPECTED_LADYBUG_VERSION } from "./ladybug-native";
6
- import { loadLadybugProjectionSource } from "./ladybug-source";
7
- import { buildRepositoryRetainedSnapshot, buildWorkspaceRetainedSnapshot, type RetainedSnapshot } from "./snapshot";
8
- import type { SnapshotScopeSelection } from "./snapshot-store";
9
- import { workspaceKeyForId } from "./workspace-contract";
10
- import { loadWorkspaceProjection } from "./workspace-source";
11
-
12
- export interface SnapshotRuntimeSelection {
13
- readonly workspace?: string;
14
- readonly workspaceId?: string;
15
- }
16
-
17
- export async function loadCurrentRetainedSnapshot(options: {
18
- readonly root: string;
19
- readonly selection: SnapshotRuntimeSelection;
20
- readonly warnings?: WarningCollector;
21
- readonly adapter?: BacklogAdapter;
22
- }): Promise<RetainedSnapshot> {
23
- if (options.selection.workspace !== undefined) {
24
- const loaded = await loadWorkspaceProjection({
25
- root: options.root,
26
- manifestPath: options.selection.workspace,
27
- warnings: options.warnings,
28
- });
29
- return buildWorkspaceRetainedSnapshot(loaded.projection);
30
- }
31
- const source = await loadLadybugProjectionSource({
32
- root: options.root,
33
- ladybugVersion: EXPECTED_LADYBUG_VERSION,
34
- ladybugStorageVersion: EXPECTED_LADYBUG_STORAGE_VERSION,
35
- adapter: options.adapter,
36
- warnings: options.warnings,
37
- });
38
- return buildRepositoryRetainedSnapshot(source);
39
- }
40
-
41
- export async function resolveSnapshotScope(options: {
42
- readonly root: string;
43
- readonly selection: SnapshotRuntimeSelection;
44
- readonly warnings?: WarningCollector;
45
- readonly adapter?: BacklogAdapter;
46
- }): Promise<SnapshotScopeSelection> {
47
- if (options.selection.workspaceId !== undefined) {
48
- return { kind: "workspace", scopeKey: workspaceKeyForId(options.selection.workspaceId) };
49
- }
50
- const current = await loadCurrentRetainedSnapshot(options);
51
- return { kind: current.scopeKind, scopeKey: current.scopeKey };
52
- }