@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,1007 +0,0 @@
1
- /**
2
- * profile.ts — the declarative type profile: lore's type vocabulary as **data**.
3
- *
4
- * Per [ADR-0006](../../docs/adr/0006-schema-types-templates.md) (amended by LORE-46),
5
- * the OKF type system is **profile-driven**: a committed, declarative
6
- * `.lore/profile.toml` is the single source of truth for the type vocabulary, each
7
- * type's frontmatter shape, its required body sections, and its template. lore
8
- * **generates** its runtime Zod validators and the editor Draft-7 JSON Schemas *from*
9
- * the profile at load — inverting the old "Zod-in-code is the source of truth" so a
10
- * project can add or retype concepts by editing data, never code (AC#4, AC#5).
11
- *
12
- * This module owns **loading and compiling** a profile; `schema.ts` consumes the
13
- * compiled {@link Profile} to validate frontmatter and emit editor schemas. The split
14
- * keeps the data/IO concern (here) apart from the validation/emission concern (there)
15
- * and means the compiler has one home with one test surface.
16
- *
17
- * Three properties define its behavior, mirroring {@link import("../config")}:
18
- *
19
- * - **Zero-config.** A missing `profile.toml` is not an error: {@link loadProfile}
20
- * returns the built-in {@link defaultProfile} — the six story-convention types
21
- * (Epic/Story/Spec/ADR/Runbook/Reference), byte-for-byte the behavior lore shipped
22
- * before the profile existed (AC#3). The file exists only to extend or replace it.
23
- * - **The declarative language is the boundary.** The grammar expresses field kinds,
24
- * enums, list items, required-ness, required sections, and a template ref — and
25
- * nothing else (AC#5). There is no code-registration escape hatch and no
26
- * cross-field/custom refinement; the [ADR-0006 §5 summary heuristic](../../docs/adr/0006-schema-types-templates.md)
27
- * stays a lore built-in in `validate.ts` precisely because it is not declaratively
28
- * expressible.
29
- * - **Deterministic + injectable.** The repo `root` is an injectable seam
30
- * (lore-design §8); compilation reads no clock and no global state, so the same
31
- * profile always compiles to the same validators and the same emitted bytes
32
- * ([ADR-0014](../../docs/adr/0014-core-has-no-llm-dependency.md)).
33
- *
34
- * Profile parsing adds **no dependency**: Bun parses TOML natively (`Bun.TOML.parse`)
35
- * and shape validation is hand-rolled, exactly as `config.ts` does. A malformed
36
- * profile is a {@link LoreError} of type `"validation"` (exit 6) — the same diagnostic
37
- * contract as the rest of lore. `profile.toml` is **separate from `config.toml`**
38
- * ([ADR-0013](../../docs/adr/0013-lore-state-directory.md)): config carries operational
39
- * knobs (reconcile/validate/Confluence), the profile carries the type system.
40
- */
41
-
42
- import { readFileSync } from "node:fs";
43
- import { isAbsolute, join, posix, win32 } from "node:path";
44
- import { z } from "zod";
45
- import { errnoCode, LoreError } from "../errors";
46
-
47
- /** Where the declarative profile lives, relative to the repo root (ADR-0013). `.toml` wins over `.json`. */
48
- export const PROFILE_REL_PATH = ".lore/profile.toml";
49
-
50
- /** The JSON form a project may use instead of TOML; identical semantics, lower precedence. */
51
- export const PROFILE_JSON_REL_PATH = ".lore/profile.json";
52
-
53
- /**
54
- * The scalar kinds a field may declare (the grammar's `kind`). Named `kind` — not
55
- * `type` — to avoid colliding with the OKF `type` frontmatter key. `string` is the
56
- * default; `datetime` is an ISO-8601 string with offset (never coerced to a `Date`,
57
- * ADR-0006 §2); `list` is a sequence whose element shape comes from `items`.
58
- */
59
- export const FIELD_KINDS = ["string", "list", "datetime", "number", "integer", "boolean"] as const;
60
-
61
- /** A declared field's scalar/sequence kind. */
62
- export type FieldKind = (typeof FIELD_KINDS)[number];
63
-
64
- /** A scalar (non-list) kind — what a list's element may be (no nested lists). */
65
- export type ScalarKind = Exclude<FieldKind, "list">;
66
-
67
- /**
68
- * The casing convention a profile's type names follow. Powers the unknown-type
69
- * "did you mean" hint and is advisory only — it never coerces an authored `type`
70
- * value. `Title` is the default (and what both the story convention and ECK use).
71
- */
72
- export const CASE_STYLES = ["Title", "lower", "UPPER", "kebab", "snake"] as const;
73
-
74
- /** A profile's declared casing convention. */
75
- export type CaseStyle = (typeof CASE_STYLES)[number];
76
-
77
- /**
78
- * One declared field's spec — the parsed form of an inline table in `[base.fields]`
79
- * or a type's `fields`. `{}` (every attribute defaulted) is an optional string field.
80
- */
81
- export interface FieldSpec {
82
- /** Whether the field must be present and non-null (default `false`). OKF's only hard requirement is `type`. */
83
- readonly required: boolean;
84
- /** The scalar/sequence kind (default `"string"`). */
85
- readonly kind: FieldKind;
86
- /** A closed set of allowed string values (implies `kind: "string"`, exact case-sensitive match). */
87
- readonly enum?: readonly string[];
88
- /** For a `list`, the element kind/enum (default: string elements; no nested lists). */
89
- readonly items?: { readonly kind: ScalarKind; readonly enum?: readonly string[] };
90
- /**
91
- * Editor-advertised default surfaced in the JSON Schema — **never** stamped onto a concept
92
- * (byte-stability). Validated at PARSE time ({@link assertDefaultMatchesShape}, LORE-242)
93
- * against this same spec's `kind`/`enum`/`items` — a `default` that contradicts its own
94
- * field's declared shape is a load-time `validation` error, not a silently-emitted lie in the
95
- * editor schema. For a `list` field the whole-list shape is checked (an array whose elements
96
- * satisfy `items`), not merely "is it an array" — the same {@link baseKindToZod} an element/
97
- * scalar field's own default is checked against, so list and scalar defaults share one rule.
98
- */
99
- readonly default?: unknown;
100
- }
101
-
102
- /** A declared type — the parsed form of one `[[types]]` table. */
103
- export interface ParsedType {
104
- /** The OKF `type` value (canonical spelling). */
105
- readonly name: string;
106
- /** Fields ADDED to the base, or a base field OVERRIDDEN by re-declaring its name (full replace). */
107
- readonly fields: Readonly<Record<string, FieldSpec>>;
108
- /** Required body section headings (matched by text, depth ≤2, order not enforced — ADR-0007 Tier 2). */
109
- readonly sections: readonly string[];
110
- /** The template filename under `.lore/templates/` `lore new` renders for this type. */
111
- readonly template?: string;
112
- }
113
-
114
- /** A fully-parsed-but-uncompiled profile — what {@link compileProfile} turns into a {@link Profile}. */
115
- export interface ParsedProfile {
116
- readonly name: string;
117
- readonly okfVersion: string;
118
- readonly case: CaseStyle;
119
- readonly resourceBase: string;
120
- /** Fields every type carries, in declaration order (insertion-ordered object). `type` must be required. */
121
- readonly baseFields: Readonly<Record<string, FieldSpec>>;
122
- /** Declared types, in declaration order. */
123
- readonly types: readonly ParsedType[];
124
- }
125
-
126
- /** A compiled type: its generated runtime validator plus the metadata the commands need. */
127
- export interface CompiledType {
128
- /** The canonical OKF `type` value. */
129
- readonly name: string;
130
- /** The LOWER-KEBAB slug — the stem of its schema file and conventional template (`QA Plan` → `qa-plan`). */
131
- readonly slug: string;
132
- /** The generated loose Zod object (extra keys pass; warned separately, OKF tolerance). */
133
- readonly schema: z.ZodType;
134
- /**
135
- * The generated Draft-7 JSON Schema for editors (ADR-0006 §3): the **lenient** tier
136
- * (`required: ["type"]`, open `additionalProperties`) with each field's editor-advertised
137
- * `default` injected. Derived once at compile, written verbatim to `.lore/schemas/<slug>.schema.json`.
138
- */
139
- readonly jsonSchema: Record<string, unknown>;
140
- /** The required body sections (ADR-0007 Tier 2). */
141
- readonly requiredSections: readonly string[];
142
- /** The template filename under `.lore/templates/`, if the type declares one. */
143
- readonly template?: string;
144
- /** The field names this type declares (base ∪ own ∪ reserved), for the extra-key warning. */
145
- readonly declaredFields: ReadonlySet<string>;
146
- /**
147
- * Whether `lore new` may auto-stamp a `resource` URL string onto a concept of **this** type
148
- * (LORE-47 / AC#4). `true` unless the type **owns** a `resource` field whose shape a URL string
149
- * cannot satisfy — i.e. a declared `resource` field with a non-`string` `kind` (a `datetime`,
150
- * `number`, … `resource` would reject the stamped URL) or an `enum` (a closed value set a free
151
- * URL is not in). A type that does **not** declare `resource`, or declares it as a plain
152
- * (optionally required) string, accepts the stamp. Computed per-type so one type declaring its
153
- * own `resource` never suppresses stamping for the others (the old global-key-order guard did),
154
- * and a `resource = { required = true }` string field is **satisfied** by the stamp rather than
155
- * failing `lore new` with a missing-required error. The single fact {@link import("./template").expectedResource}
156
- * reads to decide whether to stamp.
157
- */
158
- readonly acceptsStampedResource: boolean;
159
- }
160
-
161
- /**
162
- * A compiled, ready-to-use profile: the type vocabulary as runtime validators plus the
163
- * derived facts the serializer and scaffolder consume. Immutable; build it once
164
- * ({@link loadProfile}/{@link defaultProfile}) and thread it through the pure core.
165
- */
166
- export interface Profile {
167
- /** The profile name (`[profile].name`). */
168
- readonly name: string;
169
- /** The OKF version the profile targets, stamped on the bundle-root index. */
170
- readonly okfVersion: string;
171
- /** The declared casing convention (advisory; powers the did-you-mean hint). */
172
- readonly case: CaseStyle;
173
- /** The base a stamped `resource` value joins to a concept path (empty → no `resource` stamped). */
174
- readonly resourceBase: string;
175
- /** Compiled types keyed by canonical `type` value, in declaration order. */
176
- readonly types: ReadonlyMap<string, CompiledType>;
177
- /** Canonical `type` value keyed by its lower-cased spelling, for case-insensitive resolution. */
178
- readonly byLowerName: ReadonlyMap<string, string>;
179
- /**
180
- * The canonical frontmatter key emission order: base fields (declaration order) then each type's
181
- * own fields appended in first-seen order (ADR-0011 append-slot = profile declaration order).
182
- * `concept.ts` orders known keys by this on serialize; unknown producer keys follow verbatim.
183
- */
184
- readonly canonicalKeyOrder: readonly string[];
185
- }
186
-
187
- /** Options for {@link loadProfile}; `root` is an injectable seam for determinism in tests. */
188
- export interface LoadProfileOptions {
189
- /** Repo root containing `.lore/`; defaults to {@link process.cwd}. */
190
- root?: string;
191
- }
192
-
193
- // ── Slug ───────────────────────────────────────────────────────────────────────
194
-
195
- /**
196
- * The LOWER-KEBAB slug for a type name — the stem of its `.lore/schemas/<slug>.schema.json`
197
- * and its conventional `.lore/templates/<slug>.md` (AC#7). Lower-cased, every run of
198
- * non-alphanumerics collapsed to a single `-`, edges trimmed: `"QA Plan"` → `"qa-plan"`,
199
- * `"ADR"` → `"adr"`, `"Reference"` → `"reference"`. For the single-word story-convention
200
- * types the slug equals the lower-cased name, so their schema filenames are unchanged from
201
- * before the profile existed. A name with no alphanumeric content yields `""` (rejected at
202
- * load as an invalid type name), so a slug is never empty and never an invalid path segment.
203
- */
204
- export function slugForTypeName(name: string): string {
205
- return name
206
- .normalize("NFKD")
207
- .replace(/[\u0300-\u036f]/g, "")
208
- .toLowerCase()
209
- .replace(/[^a-z0-9]+/g, "-")
210
- .replace(/^-+|-+$/g, "");
211
- }
212
-
213
- // ── Load ─────────────────────────────────────────────────────────────────────—
214
-
215
- /**
216
- * Load and compile the active profile under `root` (default cwd). Reads
217
- * `.lore/profile.toml` (or `.lore/profile.json`, lower precedence) and compiles it to a
218
- * {@link Profile}; a **missing** — or present-but-empty (every line commented) — profile yields
219
- * the built-in {@link defaultProfile} (zero-config, AC#3), so the commented file `lore init`
220
- * scaffolds changes nothing until a team fills it in. Malformed TOML/JSON or an out-of-grammar
221
- * value throws a {@link LoreError} of type `"validation"` (exit 6).
222
- */
223
- export function loadProfile(options: LoadProfileOptions = {}): Profile {
224
- const root = options.root ?? process.cwd();
225
- // A present-but-empty `.toml` (the commented file `lore init` scaffolds) is NOT a populated
226
- // profile, so it must not shadow a real `.json` form — fall through to it, exactly as an absent
227
- // `.toml` would. Only a non-empty profile (either form) is compiled; otherwise the default.
228
- const tomlText = readProfileText(join(root, PROFILE_REL_PATH), PROFILE_REL_PATH);
229
- if (tomlText !== undefined) {
230
- const doc = parseToml(tomlText);
231
- if (!isEmptyDoc(doc)) {
232
- return compileProfile(parseProfile(doc, PROFILE_REL_PATH));
233
- }
234
- }
235
- const jsonText = readProfileText(join(root, PROFILE_JSON_REL_PATH), PROFILE_JSON_REL_PATH);
236
- if (jsonText !== undefined) {
237
- const doc = parseJson(jsonText);
238
- if (!isEmptyDoc(doc)) {
239
- return compileProfile(parseProfile(doc, PROFILE_JSON_REL_PATH));
240
- }
241
- }
242
- return defaultProfile();
243
- }
244
-
245
- /**
246
- * Read a profile file as UTF-8, returning `undefined` for an absent file (`ENOENT` — the
247
- * zero-config case, no separate `existsSync` so no time-of-check/time-of-use window) and
248
- * surfacing any other failure with its OS reason. Mirrors `config.ts`'s reader.
249
- */
250
- function readProfileText(path: string, relPath: string): string | undefined {
251
- try {
252
- return readFileSync(path, "utf8");
253
- } catch (cause) {
254
- // ENOENT (no file) and ENOTDIR (a path component — e.g. `.lore` itself — is not a directory)
255
- // both mean "no profile here": fall back to the default rather than failing the load. A
256
- // genuinely broken `.lore` directory is surfaced where it belongs (the scaffold/IO layer).
257
- const code = errnoCode(cause);
258
- if (code === "ENOENT" || code === "ENOTDIR") {
259
- return undefined;
260
- }
261
- return fail(withReason(`${relPath} could not be read`, cause), `ensure ${relPath} is a readable file`, {
262
- path: relPath,
263
- });
264
- }
265
- }
266
-
267
- /** Parse TOML via Bun's native parser, stripping a leading BOM (as `config.ts` does) and surfacing the parser's message. */
268
- function parseToml(raw: string): Record<string, unknown> {
269
- let text = raw;
270
- while (text.charCodeAt(0) === 0xfeff) {
271
- text = text.slice(1);
272
- }
273
- try {
274
- return Bun.TOML.parse(text) as Record<string, unknown>;
275
- } catch (cause) {
276
- return fail(
277
- withReason(`${PROFILE_REL_PATH} is not valid TOML`, cause),
278
- `fix the TOML syntax in ${PROFILE_REL_PATH}`,
279
- {
280
- path: PROFILE_REL_PATH,
281
- },
282
- );
283
- }
284
- }
285
-
286
- /** True for a parsed doc with no own keys — an empty or all-commented profile file (→ zero-config default). */
287
- function isEmptyDoc(doc: Record<string, unknown>): boolean {
288
- return Object.keys(doc).length === 0;
289
- }
290
-
291
- /** Parse the JSON profile form, surfacing the parser's message on failure. */
292
- function parseJson(raw: string): Record<string, unknown> {
293
- let value: unknown;
294
- try {
295
- value = JSON.parse(raw);
296
- } catch (cause) {
297
- return fail(
298
- withReason(`${PROFILE_JSON_REL_PATH} is not valid JSON`, cause),
299
- `fix the JSON syntax in ${PROFILE_JSON_REL_PATH}`,
300
- { path: PROFILE_JSON_REL_PATH },
301
- );
302
- }
303
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
304
- return fail(`${PROFILE_JSON_REL_PATH} must be a JSON object`, `make ${PROFILE_JSON_REL_PATH} a JSON object`, {
305
- path: PROFILE_JSON_REL_PATH,
306
- });
307
- }
308
- return value as Record<string, unknown>;
309
- }
310
-
311
- // ── Parse (grammar → ParsedProfile) ───────────────────────────────────────────—
312
-
313
- /**
314
- * Project a parsed TOML/JSON document onto a {@link ParsedProfile}, validating the
315
- * finalized grammar (LORE-46). Load-tier **errors** (throw): a duplicate type name, an
316
- * `enum` on a non-string kind, a malformed field/type table, a missing required `[profile]`
317
- * key, a `type` base field that is not `{ required = true }`, or an empty/invalid type name.
318
- * An **unknown** top-level/`[profile]` key is ignored (forward-compatible; a load warning is
319
- * a future nicety, not an error). `source` names the file in diagnostics.
320
- */
321
- export function parseProfile(doc: Record<string, unknown>, source: string): ParsedProfile {
322
- const profileTable = asTable(doc.profile, "profile", source) ?? {};
323
- const name = requireString(profileTable.name, "profile.name", source);
324
- const okfVersion = requireString(profileTable.okf_version, "profile.okf_version", source);
325
- const caseStyle = asEnum(profileTable.case, "profile.case", CASE_STYLES, source) ?? "Title";
326
- // Trim at the config boundary so a whitespace-only `resource_base` is treated as unset (no stamp)
327
- // and a base padded with stray whitespace can never join into an embedded-space (broken) URL.
328
- const resourceBase = (asString(profileTable.resource_base, "profile.resource_base", source) ?? "").trim();
329
-
330
- const baseTable = asTable(doc.base, "base", source) ?? {};
331
- const baseFields = parseFieldTable(asTable(baseTable.fields, "base.fields", source) ?? {}, "base.fields", source);
332
- if (!baseFields.type || baseFields.type.required !== true) {
333
- fail(
334
- `${source}: [base.fields] must declare \`type\` as { required = true } (OKF's one hard requirement)`,
335
- "add `type = { required = true }` under [base.fields]",
336
- { key: "base.fields.type" },
337
- );
338
- }
339
-
340
- const types = parseTypes(doc.types, source);
341
- if (types.length === 0) {
342
- // A profile with no types validates nothing — every concept would fall through to the
343
- // unknown-type warning tier, silently turning `lore validate` green over real defects. The
344
- // common cause is uncommenting [profile]/[base.fields] but leaving the example [[types]]
345
- // commented; fail loud so the gate is never accidentally disabled.
346
- fail(
347
- `${source}: a profile must declare at least one [[types]]`,
348
- 'add a [[types]] block (name = "…"), or remove the file to use the built-in story-convention profile',
349
- { key: "types" },
350
- );
351
- }
352
- return { name, okfVersion, case: caseStyle, resourceBase, baseFields, types };
353
- }
354
-
355
- /** The keys a `[[types]]` table may declare. */
356
- const TYPE_TABLE_KEYS = ["name", "fields", "sections", "template"] as const;
357
-
358
- /** Parse the `[[types]]` array-of-tables into {@link ParsedType}s, rejecting a duplicate type name. */
359
- function parseTypes(value: unknown, source: string): ParsedType[] {
360
- if (value === undefined) {
361
- return [];
362
- }
363
- if (!Array.isArray(value)) {
364
- fail(`${source}: \`types\` must be an array of tables ([[types]])`, "declare each type with a [[types]] table", {
365
- key: "types",
366
- });
367
- }
368
- const seen = new Set<string>();
369
- const seenSlugs = new Set<string>();
370
- const types: ParsedType[] = [];
371
- for (let i = 0; i < value.length; i++) {
372
- const table = asTable(value[i], `types[${i}]`, source);
373
- if (table === undefined) {
374
- fail(`${source}: types[${i}] must be a table`, "declare each type with a [[types]] table", {
375
- key: `types[${i}]`,
376
- });
377
- }
378
- rejectUnknownKeys(table, TYPE_TABLE_KEYS, `types[${i}]`, source);
379
- const name = requireString(table.name, `types[${i}].name`, source).trim();
380
- const slug = slugForTypeName(name);
381
- if (slug === "") {
382
- fail(
383
- `${source}: types[${i}].name "${name}" has no slug-able characters`,
384
- "give the type a name with at least one letter or digit",
385
- { key: `types[${i}].name` },
386
- );
387
- }
388
- const lower = name.toLowerCase();
389
- if (seen.has(lower)) {
390
- fail(`${source}: duplicate type "${name}"`, "each [[types]] name must be unique (case-insensitively)", {
391
- key: `types[${i}].name`,
392
- });
393
- }
394
- // Two type names that differ but reduce to the same LOWER-KEBAB slug (e.g. "Foo Bar" / "Foo-Bar")
395
- // would key the SAME `.lore/schemas/<slug>.schema.json` and `.lore/templates/<slug>.md` file, so the
396
- // second silently overwrites the first on export/scaffold (a count-lying data loss). The slug is the
397
- // file identity, so it must be unique too — not just the type name. Reject the collision at load.
398
- if (seenSlugs.has(slug)) {
399
- fail(
400
- `${source}: types[${i}].name "${name}" reduces to the schema/template slug "${slug}", already used by another type`,
401
- "two type names must not share a lower-kebab slug (it names their schema and template files); rename one",
402
- { key: `types[${i}].name` },
403
- );
404
- }
405
- seen.add(lower);
406
- seenSlugs.add(slug);
407
- const fields = parseFieldTable(
408
- asTable(table.fields, `types[${i}].fields`, source) ?? {},
409
- `types[${i}].fields`,
410
- source,
411
- );
412
- const sections = asStringArray(table.sections, `types[${i}].sections`, source) ?? [];
413
- const template = asString(table.template, `types[${i}].template`, source);
414
- if (template !== undefined) {
415
- assertTemplateConfined(template, `types[${i}].template`, source);
416
- }
417
- types.push(template === undefined ? { name, fields, sections } : { name, fields, sections, template });
418
- }
419
- return types;
420
- }
421
-
422
- /**
423
- * The way a template name/path value can fail {@link templateConfinementViolation}'s containment
424
- * check: an absolute path, or a value that resolves outside `.lore/templates/` via a `..` escape.
425
- */
426
- export type TemplateConfinementViolation = "absolute" | "escape";
427
-
428
- /**
429
- * The single containment check shared by every place lore resolves a template name/path into a
430
- * file under `.lore/templates/` — the profile-declared `[[types]].template` value (LORE-139) and
431
- * `commands/new.ts`'s `--template` CLI flag (LORE-69). Both used to run their own edge-case-
432
- * divergent copy of this arithmetic (one normalized backslashes and needed no real `root`, the
433
- * other used the host `resolve()`/`relative()` and did not); LORE-185 consolidates them onto this
434
- * one pure predicate so the invariant can never again drift between call sites. Left unchecked, a
435
- * `.lore/profile.toml` type table declaring `template = "../../../secret/leak"` — or a
436
- * `lore new --template ../../../secret/leak` — would have `lore new` read and embed an arbitrary
437
- * file's contents into the generated concept, exit `0`, no error.
438
- *
439
- * Absolute paths are rejected on the host `isAbsolute` AND both `posix.isAbsolute`/
440
- * `win32.isAbsolute` explicitly: lore ships as a compiled binary for both POSIX and win32 from the
441
- * same source, so a Windows drive-letter path must be caught even when this runs on a POSIX host
442
- * (it is otherwise inert syntax there), and a POSIX-style absolute path must be caught even when
443
- * this runs on the win32 binary. A `..`-segment escape is then caught by resolving the value
444
- * (backslash segments normalized to `/` first, so a Windows-style `..\..\secret` traversal is
445
- * caught even when parsed on a POSIX host — the cross-host drift LORE-69's own host-`resolve()`
446
- * implementation had) against a fixed anchor and confirming the result stays inside it — no real
447
- * `root` is needed: the check is pure path arithmetic, identical however the anchor is spelled, so
448
- * it holds regardless of where the caller's templates directory is ultimately loaded from.
449
- *
450
- * Returns the violation kind rather than throwing, so each call site can raise its own typed
451
- * error: a profile-declared value is a `validation` {@link LoreError} at profile PARSE time
452
- * (repo config, wrong exit code for a CLI mistake), while a `--template` flag value is a `usage`
453
- * error (a user-typed CLI argument) — those exit codes are part of each command's own observable
454
- * contract and must not collapse into one just because the underlying arithmetic is now shared.
455
- */
456
- export function templateConfinementViolation(value: string): TemplateConfinementViolation | undefined {
457
- if (isAbsolute(value) || posix.isAbsolute(value) || win32.isAbsolute(value)) {
458
- return "absolute";
459
- }
460
- const anchor = "/.lore/templates";
461
- const resolved = posix.resolve(anchor, value.replace(/\\/g, "/"));
462
- const rel = posix.relative(anchor, resolved);
463
- return rel === ".." || rel.startsWith("../") ? "escape" : undefined;
464
- }
465
-
466
- /**
467
- * Reject a `[[types]].template` value that could escape `.lore/templates/` once `commands/new.ts`
468
- * joins it into a file path (LORE-139), via the shared {@link templateConfinementViolation}
469
- * predicate. Checked here, at profile PARSE time, so every current and future consumer of a
470
- * compiled type's `template` is protected, not just `resolveTemplate`'s one call site.
471
- */
472
- function assertTemplateConfined(value: string, key: string, source: string): void {
473
- const violation = templateConfinementViolation(value);
474
- if (violation === "absolute") {
475
- fail(`${source}: ${key} "${value}" must not be an absolute path`, `declare a bare template name for ${key}`, {
476
- key,
477
- });
478
- } else if (violation === "escape") {
479
- fail(
480
- `${source}: ${key} "${value}" must not escape .lore/templates/`,
481
- `declare a bare template name for ${key}, without any ".." segment`,
482
- { key },
483
- );
484
- }
485
- }
486
-
487
- /**
488
- * True for a field name that cannot be a plain-object key without being dropped or shadowing an
489
- * inherited member — every `Object.prototype` member (`__proto__`, `constructor`, `toString`, …)
490
- * plus `prototype`. The same guard `config.ts` applies to untrusted `[reconcile.overrides]` keys;
491
- * field specs are built the same way (bracket assignment from untrusted TOML keys), so a field
492
- * literally named `__proto__` would otherwise hit the prototype setter and silently vanish — a
493
- * required field that never enters the generated Zod object, so concepts missing it validate clean.
494
- */
495
- function isUnsafeFieldName(name: string): boolean {
496
- return name === "prototype" || name in Object.prototype;
497
- }
498
-
499
- /** Parse a `fields` table (`name -> inline-table spec`) into {@link FieldSpec}s, preserving declaration order. */
500
- function parseFieldTable(table: Record<string, unknown>, where: string, source: string): Record<string, FieldSpec> {
501
- const fields: Record<string, FieldSpec> = {};
502
- for (const [fieldName, raw] of Object.entries(table)) {
503
- if (isUnsafeFieldName(fieldName)) {
504
- fail(
505
- `${source}: ${where}.${fieldName} uses a reserved object key`,
506
- `rename the "${fieldName}" field to a real frontmatter key name`,
507
- { key: `${where}.${fieldName}` },
508
- );
509
- }
510
- fields[fieldName] = parseFieldSpec(raw, `${where}.${fieldName}`, source);
511
- }
512
- return fields;
513
- }
514
-
515
- /** The attribute keys a field spec inline table (`{ required = ..., kind = ..., ... }`) may declare. */
516
- const FIELD_SPEC_KEYS = ["required", "kind", "enum", "items", "default"] as const;
517
-
518
- /** Parse one inline-table field spec, defaulting and cross-checking its attributes. */
519
- function parseFieldSpec(raw: unknown, where: string, source: string): FieldSpec {
520
- const table = asTable(raw, where, source);
521
- if (table === undefined) {
522
- fail(`${source}: ${where} must be an inline table`, `write ${where} as an inline table, e.g. { required = true }`, {
523
- key: where,
524
- });
525
- }
526
- rejectUnknownKeys(table, FIELD_SPEC_KEYS, where, source);
527
- const required = asBoolean(table.required, `${where}.required`, source) ?? false;
528
- const enumValues = asStringArray(table.enum, `${where}.enum`, source);
529
- assertNonEmptyEnum(enumValues, where, source);
530
- const declaredKind = asEnum(table.kind, `${where}.kind`, FIELD_KINDS, source);
531
- if (enumValues !== undefined && declaredKind !== undefined && declaredKind !== "string") {
532
- fail(
533
- `${source}: ${where} has an enum but kind "${declaredKind}" — enum implies kind "string"`,
534
- `drop the kind, or set kind = "string"`,
535
- { key: where },
536
- );
537
- }
538
- const kind: FieldKind = enumValues !== undefined ? "string" : (declaredKind ?? "string");
539
- const spec: {
540
- required: boolean;
541
- kind: FieldKind;
542
- enum?: readonly string[];
543
- items?: FieldSpec["items"];
544
- default?: unknown;
545
- } = { required, kind };
546
- if (enumValues !== undefined) {
547
- spec.enum = enumValues;
548
- }
549
- if (kind === "list") {
550
- spec.items = parseItems(table.items, `${where}.items`, source);
551
- }
552
- if ("default" in table) {
553
- assertDefaultMatchesShape(spec as FieldSpec, table.default, where, source);
554
- spec.default = table.default;
555
- }
556
- return spec;
557
- }
558
-
559
- /**
560
- * Reject an `enum` attribute that parsed to a zero-length array (LORE-140), shared by both
561
- * {@link parseFieldSpec} (a scalar field's own `enum`) and {@link parseItems} (a list field's
562
- * `items.enum`, LORE-193) — `baseKindToZod`/`itemToZod` pass the value straight to
563
- * `z.enum([...enumValues])`; Zod's `z.enum([])` rejects every possible value, so `enum = []`
564
- * would otherwise compile cleanly here and only surface later as a field or list element
565
- * (required or not) that can never validate, with no error pointing at the actual mistake.
566
- * Checked at parse time, right where `where` still names the offending field, so the error
567
- * lands where the author can fix it instead of at some unrelated concept's validation failure.
568
- */
569
- function assertNonEmptyEnum(enumValues: readonly string[] | undefined, where: string, source: string): void {
570
- if (enumValues !== undefined && enumValues.length === 0) {
571
- fail(
572
- `${source}: ${where}.enum must not be empty`,
573
- `declare at least one allowed value for ${where}.enum, or remove the enum attribute`,
574
- { key: `${where}.enum` },
575
- );
576
- }
577
- }
578
-
579
- /**
580
- * Reject a `default` attribute whose value contradicts the field's own declared `kind`/`enum`/
581
- * `items` shape (LORE-242) — same class of author-mistake cross-check as
582
- * {@link assertNonEmptyEnum}, the enum-implies-kind-"string" check, and
583
- * {@link assertTemplateConfined}, each raising a `validation` {@link LoreError} that names the
584
- * offending field. Without this, `{ kind = "integer", default = "x" }` or
585
- * `{ enum = ["red","green"], default = "purple" }` loaded clean and `buildJsonSchema` emitted the
586
- * bad `default` verbatim into the editor-facing JSON Schema — an internally-inconsistent schema
587
- * that misleads autocompletion.
588
- *
589
- * Judges the default with {@link baseKindToZod} — the SAME kind→Zod predicates
590
- * {@link buildJsonSchema}'s emitted `type`/`enum`/`items` ultimately derive from (via
591
- * `z.toJSONSchema`) — rather than a second, hand-rolled kind→JS-type mapping that could drift
592
- * from it. This one call also covers AC#4 for free: `baseKindToZod` returns `z.array(itemToZod(…))`
593
- * for a `kind: "list"` field, so a list's `default` is validated as a WHOLE LIST (every element
594
- * checked against `items`), not merely checked for being an array.
595
- */
596
- function assertDefaultMatchesShape(spec: FieldSpec, defaultValue: unknown, where: string, source: string): void {
597
- const result = baseKindToZod(spec).safeParse(defaultValue);
598
- if (!result.success) {
599
- const shape = spec.enum !== undefined ? "enum" : spec.kind;
600
- fail(
601
- `${source}: ${where}.default (${JSON.stringify(defaultValue)}) does not match its declared ${shape}`,
602
- `set ${where}.default to a value valid for its ${shape}, or remove the default attribute`,
603
- { key: `${where}.default` },
604
- );
605
- }
606
- }
607
-
608
- /** The attribute keys a list field's `items` inline table (`{ kind = ..., enum = ... }`) may declare. */
609
- const ITEMS_TABLE_KEYS = ["kind", "enum"] as const;
610
-
611
- /** Parse a list field's `items` element spec (default: string elements). Only `kind`/`enum` apply to an element. */
612
- function parseItems(raw: unknown, where: string, source: string): { kind: ScalarKind; enum?: readonly string[] } {
613
- if (raw === undefined) {
614
- return { kind: "string" };
615
- }
616
- const table = asTable(raw, where, source);
617
- if (table === undefined) {
618
- fail(`${source}: ${where} must be an inline table`, `write ${where} as an inline table, e.g. { kind = "string" }`, {
619
- key: where,
620
- });
621
- }
622
- rejectUnknownKeys(table, ITEMS_TABLE_KEYS, where, source);
623
- const enumValues = asStringArray(table.enum, `${where}.enum`, source);
624
- assertNonEmptyEnum(enumValues, where, source);
625
- const kind =
626
- enumValues !== undefined ? "string" : (asEnum(table.kind, `${where}.kind`, FIELD_KINDS, source) ?? "string");
627
- if (kind === "list") {
628
- fail(`${source}: ${where}.kind cannot be "list" (no nested lists)`, "use a scalar element kind", { key: where });
629
- }
630
- return enumValues !== undefined ? { kind: "string", enum: enumValues } : { kind };
631
- }
632
-
633
- // ── Compile (ParsedProfile → Profile) ─────────────────────────────────────────—
634
-
635
- /**
636
- * Compile a {@link ParsedProfile} into a ready-to-use {@link Profile}: generate each type's
637
- * loose Zod validator, compute the LOWER-KEBAB slugs, and derive the canonical key order
638
- * (base fields then each type's own fields, first-seen). Pure and deterministic.
639
- */
640
- export function compileProfile(parsed: ParsedProfile): Profile {
641
- // Each type's field order is: the declarative `[base.fields]` (declaration order), then that
642
- // type's own new fields, then lore's reserved coupling fields (see RESERVED_FIELDS) LAST. The
643
- // coupling fields carry a built-in validator (their `string | list-of-refs` shape is not
644
- // declaratively expressible — AC#5), so they live here, like the §5 summary heuristic; emitting
645
- // them last keeps the canonical key order — and thus concept serialization — byte-stable against
646
- // what lore emitted before the profile existed (ADR-0011). A profile may still override one by
647
- // declaring it in `[base.fields]`, in which case it keeps its declared position.
648
- const declaredBase = Object.keys(parsed.baseFields);
649
- const reservedBase = RESERVED_FIELD_NAMES.filter((name) => !(name in parsed.baseFields));
650
-
651
- const types = new Map<string, CompiledType>();
652
- const byLowerName = new Map<string, string>();
653
- const canonicalKeyOrder: string[] = [...declaredBase];
654
- const seenKeys = new Set<string>([...declaredBase, ...reservedBase]);
655
-
656
- for (const type of parsed.types) {
657
- // Merge base ∪ own (own overrides a base/reserved field by name = full replace); collect the
658
- // type's genuinely-new fields (neither base nor reserved) in declaration order.
659
- const merged: Record<string, FieldSpec> = { ...parsed.baseFields };
660
- const ownNew: string[] = [];
661
- for (const [fieldName, spec] of Object.entries(type.fields)) {
662
- merged[fieldName] = spec;
663
- if (!declaredBase.includes(fieldName) && !reservedBase.includes(fieldName)) {
664
- ownNew.push(fieldName);
665
- if (!seenKeys.has(fieldName)) {
666
- canonicalKeyOrder.push(fieldName);
667
- seenKeys.add(fieldName);
668
- }
669
- }
670
- }
671
- const fieldOrder = [...declaredBase, ...ownNew, ...reservedBase];
672
- const schema = buildTypeSchema(type.name, merged, fieldOrder);
673
- const compiled: CompiledType = {
674
- name: type.name,
675
- slug: slugForTypeName(type.name),
676
- schema,
677
- jsonSchema: buildJsonSchema(schema, merged, fieldOrder),
678
- requiredSections: type.sections,
679
- declaredFields: new Set(fieldOrder),
680
- acceptsStampedResource: acceptsStampedResource(merged.resource),
681
- ...(type.template === undefined ? {} : { template: type.template }),
682
- };
683
- types.set(type.name, compiled);
684
- byLowerName.set(type.name.toLowerCase(), type.name);
685
- }
686
- // Reserved coupling fields trail every authored/known key in the global canonical order.
687
- canonicalKeyOrder.push(...reservedBase);
688
-
689
- return {
690
- name: parsed.name,
691
- okfVersion: parsed.okfVersion,
692
- case: parsed.case,
693
- resourceBase: parsed.resourceBase,
694
- types,
695
- byLowerName,
696
- canonicalKeyOrder,
697
- };
698
- }
699
-
700
- /**
701
- * lore's reserved coupling fields, present on every type: the supersession links `lore supersede`
702
- * writes. Their shape is **`string | list-of-strings`** (a concept may supersede one or several) —
703
- * a union the declarative `kind` grammar cannot express — so they carry a built-in validator here
704
- * (AC#5 expressiveness limit), exactly as the ADR-0006 §5 summary heuristic stays a lore built-in.
705
- * A profile may still override one by declaring a field of the same name in `[base.fields]`.
706
- */
707
- const RESERVED_FIELDS: Readonly<Record<string, () => z.ZodType>> = Object.freeze({
708
- supersedes: () => conceptRefs(),
709
- superseded_by: () => conceptRefs(),
710
- });
711
-
712
- /** The reserved field names, appended to the base set in this fixed order when not re-declared. */
713
- const RESERVED_FIELD_NAMES = Object.keys(RESERVED_FIELDS);
714
-
715
- /** A concept reference or a list of them (the `supersedes`/`superseded_by` shape), accepting absent and null. */
716
- function conceptRefs(): z.ZodType {
717
- return z.union([z.string(), z.array(z.string())]).nullish();
718
- }
719
-
720
- /**
721
- * Whether an auto-stamped `resource` URL string is valid for a type owning the given `resource`
722
- * field spec ({@link CompiledType.acceptsStampedResource}). `undefined` (the type declares no
723
- * `resource` field) → `true`: lore stamps a recognized reserved key. A declared field accepts the
724
- * stamp only when it is a plain `string` with no `enum` — a free URL satisfies a (required or
725
- * optional) string field, but never a `datetime`/`number`/`list` field or a closed `enum`.
726
- */
727
- function acceptsStampedResource(spec: FieldSpec | undefined): boolean {
728
- return spec === undefined || (spec.kind === "string" && spec.enum === undefined);
729
- }
730
-
731
- /**
732
- * Build a type's loose Zod object from its merged fields, emitting properties in `fieldOrder`
733
- * so the generated JSON Schema's property order is stable and declaration-driven. `type` is
734
- * always a `z.literal(name)` (the OKF floor). A reserved coupling field with no declared spec
735
- * uses its built-in {@link RESERVED_FIELDS} validator; everything else comes from {@link fieldToZod}.
736
- * Loose (not strict) so extra keys pass — the extra-key warning is computed separately, preserving
737
- * OKF producer-extension tolerance.
738
- */
739
- function buildTypeSchema(name: string, merged: Record<string, FieldSpec>, fieldOrder: readonly string[]): z.ZodType {
740
- const shape: Record<string, z.ZodType> = {};
741
- for (const fieldName of fieldOrder) {
742
- const reserved = RESERVED_FIELDS[fieldName];
743
- if (fieldName === "type") {
744
- shape[fieldName] = z.literal(name);
745
- } else if (merged[fieldName] !== undefined) {
746
- shape[fieldName] = fieldToZod(merged[fieldName] as FieldSpec);
747
- } else if (reserved !== undefined) {
748
- shape[fieldName] = reserved();
749
- }
750
- }
751
- return z.looseObject(shape);
752
- }
753
-
754
- /**
755
- * Emit a type's Draft-7 JSON Schema from its generated Zod object (the **lenient** editor tier:
756
- * `required: ["type"]`, open `additionalProperties`), then inject each field's editor-advertised
757
- * `default`. The default is added only to the JSON Schema — never to the runtime Zod — so an
758
- * editor offers it while a written concept stays byte-stable (it is never stamped). Property order
759
- * follows the Zod object's field order, so the emitted bytes are deterministic (ADR-0014).
760
- */
761
- function buildJsonSchema(
762
- schema: z.ZodType,
763
- merged: Record<string, FieldSpec>,
764
- fieldOrder: readonly string[],
765
- ): Record<string, unknown> {
766
- const json = z.toJSONSchema(schema, { target: "draft-7" }) as Record<string, unknown>;
767
- const properties = json.properties as Record<string, Record<string, unknown>> | undefined;
768
- if (properties !== undefined) {
769
- for (const fieldName of fieldOrder) {
770
- const spec = merged[fieldName];
771
- if (spec?.default !== undefined && properties[fieldName] !== undefined) {
772
- properties[fieldName] = { ...properties[fieldName], default: spec.default };
773
- }
774
- }
775
- }
776
- return json;
777
- }
778
-
779
- /**
780
- * Translate one {@link FieldSpec} to a Zod schema. An optional field is `.nullish()` (accepts
781
- * both absent and a YAML `key:` null — an OKF-tolerated cleared value, never promoted to an
782
- * error); a required field must be present and non-null. `datetime` is an ISO-8601 string with
783
- * offset, never a `Date` (ADR-0006 §2). The editor-advertised `default` is **not** applied here
784
- * — `.default()` would coerce on parse and break byte-stability — it is surfaced only in the
785
- * emitted JSON Schema (`schema.ts`).
786
- */
787
- function fieldToZod(spec: FieldSpec): z.ZodType {
788
- const base = baseKindToZod(spec);
789
- return spec.required ? base : base.nullish();
790
- }
791
-
792
- /** The Zod schema for a field's kind/enum (without the optional/required wrapper). */
793
- function baseKindToZod(spec: FieldSpec): z.ZodType {
794
- if (spec.enum !== undefined) {
795
- return z.enum([...spec.enum]);
796
- }
797
- return spec.kind === "list" ? z.array(itemToZod(spec.items)) : scalarKindToZod(spec.kind);
798
- }
799
-
800
- /** The Zod element schema for a list's `items` spec (default: string elements). */
801
- function itemToZod(items: FieldSpec["items"]): z.ZodType {
802
- if (items === undefined) {
803
- return z.string();
804
- }
805
- return items.enum !== undefined ? z.enum([...items.enum]) : scalarKindToZod(items.kind);
806
- }
807
-
808
- /**
809
- * The Zod schema for a scalar (non-list) kind — the single source shared by {@link baseKindToZod}
810
- * and {@link itemToZod} so a field and a list element of the same kind can never diverge. `list`
811
- * is never passed here (a field's list wraps this for its element; nested lists are rejected at
812
- * parse). `datetime` is an ISO-8601 string with offset, never a `Date` (ADR-0006 §2).
813
- */
814
- function scalarKindToZod(kind: Exclude<FieldKind, "list">): z.ZodType {
815
- switch (kind) {
816
- case "datetime":
817
- return z.iso.datetime({ offset: true });
818
- case "number":
819
- return z.number();
820
- case "integer":
821
- return z.int();
822
- case "boolean":
823
- return z.boolean();
824
- default:
825
- return z.string();
826
- }
827
- }
828
-
829
- // ── The built-in story-convention profile (zero-config default) ────────────────—
830
-
831
- /**
832
- * An optional string field (`{}`): the common case. Helper so the default profile reads as data.
833
- */
834
- const optionalString: FieldSpec = { required: false, kind: "string" };
835
- /** An optional list-of-strings field (`{ kind = "list" }`). */
836
- const optionalStringList: FieldSpec = { required: false, kind: "list", items: { kind: "string" } };
837
-
838
- /**
839
- * The built-in **story-convention** profile (AC#3): the six types lore shipped before the
840
- * profile existed, re-expressed as data and run through the same {@link compileProfile} as a
841
- * loaded profile (so the default is exercised by the real path). Its generated validators are
842
- * byte-compatible with the old hand-authored Zod, with one documented narrowing: `supersedes` /
843
- * `superseded_by` are `list` rather than the old `string | list` union, which the declarative
844
- * `kind` grammar cannot express (AC#5 expressiveness limit) — invisible to every existing
845
- * concept (none use these lifecycle keys yet). The ADR-0006 §5 summary heuristic is NOT here;
846
- * it stays a lore built-in in `validate.ts` for the same reason.
847
- */
848
- function storyConventionProfile(): ParsedProfile {
849
- return {
850
- name: "story-convention",
851
- okfVersion: "0.1",
852
- case: "Title",
853
- resourceBase: "",
854
- baseFields: {
855
- type: { required: true, kind: "string" },
856
- title: optionalString,
857
- description: optionalString,
858
- tags: optionalStringList,
859
- summary: optionalString,
860
- timestamp: { required: false, kind: "datetime" },
861
- status: optionalString,
862
- // supersedes / superseded_by are reserved coupling fields (RESERVED_FIELDS): their
863
- // `string | list` union is added by the compiler, not declared here.
864
- },
865
- // The story-convention types declare NO `template`: their bodies come from the built-in
866
- // code templates (template.ts BUILTIN_TEMPLATES via builtinTemplateFor), not from a file
867
- // under .lore/templates/. A declared `template` is for a custom profile that ships its own
868
- // template files; setting one here would force `lore new`'s lookup to a lowercased filename
869
- // and stop honoring a canonical-case `Reference.md` on a case-sensitive filesystem.
870
- types: [
871
- { name: "Epic", fields: {}, sections: [] },
872
- {
873
- name: "Story",
874
- fields: { tasks: optionalStringList, specs: optionalStringList },
875
- sections: ["Acceptance criteria"],
876
- },
877
- { name: "Spec", fields: {}, sections: [] },
878
- { name: "ADR", fields: {}, sections: ["Status", "Context", "Decision", "Consequences"] },
879
- { name: "Runbook", fields: {}, sections: [] },
880
- { name: "Reference", fields: {}, sections: [] },
881
- ],
882
- };
883
- }
884
-
885
- /** Memoized compiled default — building its Zod schemas once, since the serializer reaches for it on every concept. */
886
- let DEFAULT_PROFILE: Profile | undefined;
887
-
888
- /**
889
- * The built-in story-convention {@link Profile} {@link loadProfile} returns when no
890
- * `.lore/profile.toml` exists. Compiled once and cached: it is pure and immutable, and the
891
- * concept serializer/validator default to it on every call, so rebuilding its Zod schemas each
892
- * time would be wasted work. This is a deterministic constant, not mutable global state.
893
- */
894
- export function defaultProfile(): Profile {
895
- DEFAULT_PROFILE ??= compileProfile(storyConventionProfile());
896
- return DEFAULT_PROFILE;
897
- }
898
-
899
- // ── Validators (mirroring config.ts's hand-rolled shape checks) ────────────────—
900
-
901
- /** Throw a `"validation"` {@link LoreError}; typed `never` so callers can `return fail(...)`. */
902
- function fail(message: string, hint: string, input: Record<string, unknown>): never {
903
- throw new LoreError("validation", message, hint, input);
904
- }
905
-
906
- /** Append `: <reason>` from a thrown cause when one can be derived (single-lined). */
907
- function withReason(base: string, cause: unknown): string {
908
- const reason =
909
- cause !== null && typeof cause === "object" && typeof (cause as { message?: unknown }).message === "string"
910
- ? (cause as { message: string }).message.replace(/\s*[\r\n]+\s*/g, " ").trim()
911
- : "";
912
- return reason ? `${base}: ${reason}` : base;
913
- }
914
-
915
- /**
916
- * Fail when `table` carries a key outside `allowed` — the closed-vocabulary gate for a field
917
- * spec, a `[[types]]` table, or an `items` table (LORE-83). Unlike the top-level/`[profile]`
918
- * table (documented forward-compatible tolerance, {@link parseProfile}'s own docstring), these
919
- * nested tables have a small, fixed attribute set with no forward-compat need — so a typo
920
- * (`require` for `required`) is a `validation` error rather than a silently-ignored no-op that
921
- * leaves the intended attribute at its default (every concept then validating clean despite
922
- * missing a field the author believed was required).
923
- */
924
- function rejectUnknownKeys(
925
- table: Record<string, unknown>,
926
- allowed: readonly string[],
927
- where: string,
928
- source: string,
929
- ): void {
930
- const unknown = Object.keys(table).filter((key) => !allowed.includes(key));
931
- if (unknown.length > 0) {
932
- fail(
933
- `${source}: ${where} has unrecognized key${unknown.length === 1 ? "" : "s"} ${unknown.map((k) => `"${k}"`).join(", ")}`,
934
- `${where} only accepts: ${allowed.join(", ")} — fix the typo or remove the key`,
935
- { key: where, unknown },
936
- );
937
- }
938
- }
939
-
940
- /** Require a table (plain object) when present; `undefined` passes through for "absent". */
941
- function asTable(value: unknown, name: string, source: string): Record<string, unknown> | undefined {
942
- if (value === undefined) {
943
- return undefined;
944
- }
945
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
946
- fail(`${source}: ${name} must be a table`, `make ${name} a table`, { key: name });
947
- }
948
- return value as Record<string, unknown>;
949
- }
950
-
951
- /** Require a string value present and non-empty. */
952
- function requireString(value: unknown, key: string, source: string): string {
953
- if (typeof value !== "string" || value.trim() === "") {
954
- fail(`${source}: ${key} must be a non-empty string`, `set ${key} to a string`, { key });
955
- }
956
- return value;
957
- }
958
-
959
- /** Require a string when present. */
960
- function asString(value: unknown, key: string, source: string): string | undefined {
961
- if (value === undefined) {
962
- return undefined;
963
- }
964
- if (typeof value !== "string") {
965
- fail(`${source}: ${key} must be a string`, `quote ${key} as a string`, { key });
966
- }
967
- return value;
968
- }
969
-
970
- /** Require a boolean when present. */
971
- function asBoolean(value: unknown, key: string, source: string): boolean | undefined {
972
- if (value === undefined) {
973
- return undefined;
974
- }
975
- if (typeof value !== "boolean") {
976
- fail(`${source}: ${key} must be a boolean`, `set ${key} to true or false`, { key });
977
- }
978
- return value;
979
- }
980
-
981
- /** Require one of `allowed` when present. */
982
- function asEnum<T extends string>(value: unknown, key: string, allowed: readonly T[], source: string): T | undefined {
983
- if (value === undefined) {
984
- return undefined;
985
- }
986
- if (typeof value !== "string" || !allowed.includes(value as T)) {
987
- fail(
988
- `${source}: ${key} must be one of ${allowed.map((a) => `"${a}"`).join(", ")}`,
989
- `set ${key} to one of: ${allowed.join(", ")}`,
990
- {
991
- key,
992
- },
993
- );
994
- }
995
- return value as T;
996
- }
997
-
998
- /** Require an array of strings when present. */
999
- function asStringArray(value: unknown, key: string, source: string): readonly string[] | undefined {
1000
- if (value === undefined) {
1001
- return undefined;
1002
- }
1003
- if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
1004
- fail(`${source}: ${key} must be an array of strings`, `write ${key} as a list of strings`, { key });
1005
- }
1006
- return value as string[];
1007
- }