@opum-ai/lore 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +33 -22
  2. package/bin/lore.cjs +35 -7
  3. package/package.json +17 -17
  4. package/src/adapters/backlog.ts +0 -1084
  5. package/src/adapters/git.ts +0 -221
  6. package/src/cli.ts +0 -667
  7. package/src/commands/agent.ts +0 -301
  8. package/src/commands/agents.ts +0 -302
  9. package/src/commands/args.ts +0 -209
  10. package/src/commands/changed.ts +0 -70
  11. package/src/commands/check.ts +0 -1031
  12. package/src/commands/codex-bridge.ts +0 -49
  13. package/src/commands/concurrency.ts +0 -48
  14. package/src/commands/context.ts +0 -292
  15. package/src/commands/discover.ts +0 -89
  16. package/src/commands/explorer.ts +0 -253
  17. package/src/commands/export.ts +0 -93
  18. package/src/commands/fswrite.ts +0 -928
  19. package/src/commands/graph.ts +0 -291
  20. package/src/commands/help.ts +0 -151
  21. package/src/commands/impact.ts +0 -59
  22. package/src/commands/init.ts +0 -583
  23. package/src/commands/instructions.ts +0 -91
  24. package/src/commands/link.ts +0 -929
  25. package/src/commands/new.ts +0 -476
  26. package/src/commands/orphans.ts +0 -457
  27. package/src/commands/path.ts +0 -67
  28. package/src/commands/provenance.ts +0 -68
  29. package/src/commands/query.ts +0 -312
  30. package/src/commands/reconcile-shared.ts +0 -280
  31. package/src/commands/rename.ts +0 -585
  32. package/src/commands/replace.ts +0 -320
  33. package/src/commands/scaffold.ts +0 -346
  34. package/src/commands/schema.ts +0 -293
  35. package/src/commands/snapshot.ts +0 -130
  36. package/src/commands/supersede.ts +0 -400
  37. package/src/commands/sync.ts +0 -371
  38. package/src/commands/tasks.ts +0 -271
  39. package/src/commands/traversal.ts +0 -151
  40. package/src/commands/validate.ts +0 -226
  41. package/src/config.ts +0 -598
  42. package/src/core/agent-bridge.ts +0 -287
  43. package/src/core/agent-context.ts +0 -498
  44. package/src/core/agent-profile.ts +0 -447
  45. package/src/core/bundle.ts +0 -893
  46. package/src/core/check.ts +0 -853
  47. package/src/core/codex-bridge.ts +0 -100
  48. package/src/core/concept.ts +0 -597
  49. package/src/core/consumer-scaffold.ts +0 -433
  50. package/src/core/context.ts +0 -271
  51. package/src/core/explorer-contract.ts +0 -441
  52. package/src/core/explorer-qualification.ts +0 -58
  53. package/src/core/explorer.ts +0 -518
  54. package/src/core/finding.ts +0 -31
  55. package/src/core/graph.ts +0 -201
  56. package/src/core/indexes.ts +0 -436
  57. package/src/core/instructions.ts +0 -209
  58. package/src/core/ladybug-driver.ts +0 -1795
  59. package/src/core/ladybug-lifecycle.ts +0 -1178
  60. package/src/core/ladybug-native.ts +0 -95
  61. package/src/core/ladybug-source.ts +0 -667
  62. package/src/core/links.ts +0 -681
  63. package/src/core/log.ts +0 -253
  64. package/src/core/managed-block.ts +0 -540
  65. package/src/core/manifest.ts +0 -718
  66. package/src/core/order.ts +0 -13
  67. package/src/core/profile.ts +0 -1007
  68. package/src/core/projection.ts +0 -195
  69. package/src/core/query.ts +0 -542
  70. package/src/core/reconcile.ts +0 -236
  71. package/src/core/replace.ts +0 -419
  72. package/src/core/retrieval.ts +0 -213
  73. package/src/core/rewrite.ts +0 -940
  74. package/src/core/scaffold.ts +0 -255
  75. package/src/core/schema.ts +0 -366
  76. package/src/core/snapshot-runtime.ts +0 -52
  77. package/src/core/snapshot-store.ts +0 -287
  78. package/src/core/snapshot.ts +0 -711
  79. package/src/core/template.ts +0 -429
  80. package/src/core/traversal.ts +0 -487
  81. package/src/core/validate.ts +0 -517
  82. package/src/core/workspace-contract.ts +0 -473
  83. package/src/core/workspace-projection.ts +0 -365
  84. package/src/core/workspace-retrieval.ts +0 -196
  85. package/src/core/workspace-source.ts +0 -174
  86. package/src/errors.ts +0 -697
  87. package/src/meta.ts +0 -7
  88. package/src/output.ts +0 -589
  89. package/src/scripts/upstream-backlog-watch.ts +0 -288
  90. package/src/state.ts +0 -390
package/src/config.ts DELETED
@@ -1,598 +0,0 @@
1
- /**
2
- * config.ts — the `.lore/config.toml` loader.
3
- *
4
- * lore keeps its team-chosen knobs in a committed `.lore/config.toml`: the
5
- * status-reconciliation policy, link/validate toggles, and (non-secret)
6
- * Confluence target settings ([ADR-0013](../docs/adr/0013-lore-state-directory.md)).
7
- * This module turns that file into one validated, typed {@link LoreConfig} the
8
- * rest of lore consumes. The design's `state.ts` (the broader `.lore/` + git
9
- * owner) imports `loadConfig` rather than re-parsing the file.
10
- *
11
- * Three properties define its behavior:
12
- *
13
- * - **Zero-config.** A missing `config.toml` is not an error — {@link loadConfig}
14
- * returns the documented defaults. The file exists only to override them.
15
- * - **Secrets are environment-only.** The Confluence API token is read solely
16
- * from `$LORE_CONFLUENCE_TOKEN`, never from the file and never written back. A
17
- * `token` committed under `[confluence]` is a leak, so it fails loud
18
- * (ADR-0013).
19
- * - **Deterministic + injectable.** The two impure inputs — the repo `root` and
20
- * the `env` — are injectable seams (lore-design §8), so tests drive the loader
21
- * without touching the real working directory or `process.env`.
22
- *
23
- * Bun parses TOML natively (`Bun.TOML.parse`), then Zod validates only the
24
- * generic parsed shape. Lore retains defaults/projection, committed-secret
25
- * scanning, reserved override-key policy, page-id precision, environment
26
- * overlay, and the stable {@link LoreError} mapping. Bad config is a
27
- * `"validation"` error (exit 6), keeping the diagnostic contract identical to
28
- * the rest of lore.
29
- */
30
-
31
- import { readFileSync } from "node:fs";
32
- import { join } from "node:path";
33
- import { z } from "zod";
34
- import { errnoCode, LoreError } from "./errors";
35
-
36
- /** Where lore's config lives, relative to the repo root. Exported so the `lore init` scaffolder writes to the exact path the loader reads. */
37
- export const CONFIG_REL_PATH = ".lore/config.toml";
38
-
39
- /** The single environment variable that may carry the Confluence API token. */
40
- const TOKEN_ENV = "LORE_CONFLUENCE_TOKEN";
41
-
42
- /** The status roll-up policies lore understands; only `"task-rollup"` exists in v1 (ADR-0013). */
43
- const RECONCILE_MODES = ["task-rollup"] as const;
44
-
45
- /** The Confluence wire formats the (deferred) publish adapter understands (ADR-0013). */
46
- const CONFLUENCE_FORMATS = ["storage", "adf"] as const;
47
-
48
- /** Generic parsed-TOML shape for `[reconcile]`; unknown future keys remain tolerated. */
49
- const ReconcileTableSchema = z.looseObject({
50
- mode: z.enum(RECONCILE_MODES).optional(),
51
- overrides: z.record(z.string(), z.string()).optional(),
52
- });
53
-
54
- /** Generic parsed-TOML shape for `[validate]`; projection/default policy stays below. */
55
- const ValidateTableSchema = z.looseObject({
56
- external_links: z.boolean().optional(),
57
- promote_portability: z.boolean().optional(),
58
- });
59
-
60
- /** Generic parsed-TOML shape for `[confluence]`; secret and page-id policy stay outside Zod. */
61
- const ConfluenceTableSchema = z.looseObject({
62
- format: z.enum(CONFLUENCE_FORMATS).optional(),
63
- base_url: z.string().optional(),
64
- space: z.string().optional(),
65
- parent_page_id: z.union([z.string(), z.number()]).optional(),
66
- });
67
-
68
- /**
69
- * The single declarative shape boundary for Bun's parsed TOML value. Loose
70
- * objects preserve Lore's additive unknown-key tolerance at every known table.
71
- */
72
- const ParsedConfigSchema = z.looseObject({
73
- reconcile: ReconcileTableSchema.optional(),
74
- validate: ValidateTableSchema.optional(),
75
- confluence: ConfluenceTableSchema.optional(),
76
- });
77
-
78
- type ParsedConfig = z.infer<typeof ParsedConfigSchema>;
79
- type ParsedConfluenceTable = z.infer<typeof ConfluenceTableSchema>;
80
-
81
- /** The status roll-up policy applied by `lore sync` / `lore check`. */
82
- export type ReconcileMode = (typeof RECONCILE_MODES)[number];
83
-
84
- /** The Confluence storage format for the one-way publish adapter. */
85
- export type ConfluenceFormat = (typeof CONFLUENCE_FORMATS)[number];
86
-
87
- /** Status-reconciliation configuration (the `[reconcile]` table). */
88
- export interface ReconcileConfig {
89
- /** The roll-up policy: all-tasks-Done → done, any In Progress → in-progress, else todo. */
90
- mode: ReconcileMode;
91
- /**
92
- * Per-repo status overrides: a Backlog status name → a rollup status string.
93
- * Values are carried through verbatim (validated only as strings here);
94
- * reconcile.ts (LORE-23) owns the rollup-status vocabulary and its semantics.
95
- */
96
- overrides: Readonly<Record<string, string>>;
97
- }
98
-
99
- /** Validation / coherence-gate configuration (the `[validate]` table). */
100
- export interface ValidateConfig {
101
- /** Opt-in external-link liveness checking in `lore check` (default `false`; ADR-0007). */
102
- externalLinks: boolean;
103
- /** Promote the portability lint from warning to error in `lore check` (default `false`). */
104
- promotePortability: boolean;
105
- }
106
-
107
- /**
108
- * Confluence publish configuration (the `[confluence]` table). All fields are
109
- * non-secret; the API {@link ConfluenceConfig.token} is overlaid from the
110
- * environment and never read from the committed file.
111
- */
112
- export interface ConfluenceConfig {
113
- /** Wiki base URL, e.g. `https://yourorg.atlassian.net/wiki`. */
114
- baseUrl?: string;
115
- /** Target space key, e.g. `ENG`. */
116
- space?: string;
117
- /** Parent page id the published tree hangs under. */
118
- parentPageId?: string;
119
- /** Wire format; defaults to `"storage"`. */
120
- format: ConfluenceFormat;
121
- /** API token — sourced ONLY from `$LORE_CONFLUENCE_TOKEN`, never the file. Absent when unset. */
122
- token?: string;
123
- }
124
-
125
- /** The fully-resolved lore configuration: file values merged over defaults, with the env token overlaid. */
126
- export interface LoreConfig {
127
- reconcile: ReconcileConfig;
128
- validate: ValidateConfig;
129
- confluence: ConfluenceConfig;
130
- }
131
-
132
- /** Options for {@link loadConfig}; both fields are injectable seams for determinism in tests. */
133
- export interface LoadConfigOptions {
134
- /** Repo root containing `.lore/`; defaults to {@link process.cwd}. */
135
- root?: string;
136
- /** Environment source for the secret overlay; defaults to {@link process.env}. */
137
- env?: Record<string, string | undefined>;
138
- }
139
-
140
- /**
141
- * Load and validate `.lore/config.toml` under `root` (default cwd), overlaying
142
- * the Confluence token from `env` (default `process.env`). A missing file yields
143
- * the zero-config {@link defaultConfig}; malformed TOML or an out-of-contract
144
- * value throws a {@link LoreError} of type `"validation"`; a file that exists
145
- * but cannot be read (`EACCES`/`EPERM`) throws a `"denied"` {@link LoreError}
146
- * instead (see {@link readConfigText}).
147
- */
148
- export function loadConfig(options: LoadConfigOptions = {}): LoreConfig {
149
- const root = options.root ?? process.cwd();
150
- const env = options.env ?? process.env;
151
- const fromFile = parseConfigFile(join(root, CONFIG_REL_PATH));
152
- return overlayEnv(fromFile, env);
153
- }
154
-
155
- /** The zero-config defaults — what `loadConfig` returns when no `config.toml` exists. */
156
- export function defaultConfig(): LoreConfig {
157
- return {
158
- reconcile: { mode: "task-rollup", overrides: {} },
159
- validate: { externalLinks: false, promotePortability: false },
160
- confluence: { format: "storage" },
161
- };
162
- }
163
-
164
- /** Read → parse → validate the file at `path`, or return defaults when it is absent. */
165
- function parseConfigFile(path: string): LoreConfig {
166
- const raw = readConfigText(path);
167
- if (raw === undefined) {
168
- return defaultConfig();
169
- }
170
- return validateConfig(parseToml(raw));
171
- }
172
-
173
- /**
174
- * Read the config file as UTF-8. One read is the sole source of truth for
175
- * absent-vs-unreadable: `ENOENT` → `undefined` (the zero-config case — no
176
- * separate `existsSync`, so no time-of-check/time-of-use window); `EACCES`/`EPERM`
177
- * → a `denied` {@link LoreError} (the codebase-wide permissions contract — see
178
- * `errors.ts`'s `ioError`/`readFileIfPresent`); any other failure (a directory
179
- * at the path, …) is a `validation` error surfaced with its OS reason instead
180
- * of a blanket "check file permissions".
181
- */
182
- function readConfigText(path: string): string | undefined {
183
- try {
184
- return readFileSync(path, "utf8");
185
- } catch (cause) {
186
- if (isErrnoCode(cause, "ENOENT")) {
187
- return undefined;
188
- }
189
- if (isErrnoCode(cause, "EACCES") || isErrnoCode(cause, "EPERM")) {
190
- throw new LoreError(
191
- "denied",
192
- withReason(`${CONFIG_REL_PATH} could not be read`, cause),
193
- `check filesystem permissions on ${CONFIG_REL_PATH}`,
194
- // Attach the errno `code` (matching errors.ts's ioError/readFileIfPresent),
195
- // so a --json consumer reading envelope.input.code gets it here too.
196
- { path: CONFIG_REL_PATH, code: errnoCode(cause) },
197
- );
198
- }
199
- return fail(
200
- withReason(`${CONFIG_REL_PATH} could not be read`, cause),
201
- `ensure ${CONFIG_REL_PATH} is a readable file`,
202
- { path: CONFIG_REL_PATH },
203
- );
204
- }
205
- }
206
-
207
- /** Parse TOML via Bun's native parser, surfacing the parser's own message on failure. */
208
- function parseToml(raw: string): Record<string, unknown> {
209
- // Strip leading UTF-8 BOM(s): Windows editors (Notepad, some PowerShell paths)
210
- // prepend U+FEFF — and some prepend more than one — which Bun.TOML.parse
211
- // otherwise swallows, parsing the file as an empty document `{}`: every
212
- // committed setting is silently dropped AND the committed-token guard (ADR-0013)
213
- // is bypassed. Strip every leading BOM, not just the first.
214
- let text = raw;
215
- while (text.charCodeAt(0) === 0xfeff) {
216
- text = text.slice(1);
217
- }
218
- try {
219
- return Bun.TOML.parse(text) as Record<string, unknown>;
220
- } catch (cause) {
221
- return fail(
222
- withReason(`${CONFIG_REL_PATH} is not valid TOML`, cause),
223
- `fix the TOML syntax in ${CONFIG_REL_PATH}`,
224
- { path: CONFIG_REL_PATH },
225
- );
226
- }
227
- }
228
-
229
- /** Project the parsed TOML table onto a {@link LoreConfig}, validating known keys and merging over defaults. */
230
- function validateConfig(root: Record<string, unknown>): LoreConfig {
231
- const parsed = parseConfigShape(root);
232
- const defaults = defaultConfig();
233
-
234
- const reconcile: ReconcileConfig = {
235
- mode: parsed.reconcile?.mode ?? defaults.reconcile.mode,
236
- overrides: copyOverrideMap(parsed.reconcile?.overrides) ?? defaults.reconcile.overrides,
237
- };
238
-
239
- const validate: ValidateConfig = {
240
- externalLinks: parsed.validate?.external_links ?? defaults.validate.externalLinks,
241
- promotePortability: parsed.validate?.promote_portability ?? defaults.validate.promotePortability,
242
- };
243
-
244
- return {
245
- reconcile,
246
- validate,
247
- confluence: validateConfluence(parsed.confluence, defaults.confluence),
248
- };
249
- }
250
-
251
- /**
252
- * Validate the generic parsed-TOML shape once. Zod owns recognition; Lore owns
253
- * the stable public diagnostic contract through {@link failConfigShape}.
254
- */
255
- function parseConfigShape(root: Record<string, unknown>): ParsedConfig {
256
- const result = ParsedConfigSchema.safeParse(root);
257
- if (result.success) {
258
- // Inspect the raw parsed map before Zod copies it: assigning `__proto__` to
259
- // a normal object can erase that entry, which must not bypass Lore's policy.
260
- assertNoReservedOverrideKey(root.reconcile, "reconcile.overrides");
261
- assertNoCommittedToken(root.confluence, "confluence");
262
- return result.data;
263
- }
264
-
265
- // Preserve the hand-written validator's public failure precedence:
266
- // reconcile shape → reserved override policy → validate shape → committed
267
- // token policy → confluence shape. Zod recognizes every generic issue in one
268
- // pass; Lore chooses which established diagnostic to expose.
269
- const reconcileIssue = result.error.issues.find((issue) => issue.path[0] === "reconcile");
270
- if (reconcileIssue !== undefined) {
271
- if (reconcileIssue.path[1] === "overrides" && reconcileIssue.path.length >= 3) {
272
- assertNoReservedOverrideKey(root.reconcile, "reconcile.overrides", String(reconcileIssue.path[2]));
273
- }
274
- return failConfigShape(root, reconcileIssue);
275
- }
276
- assertNoReservedOverrideKey(root.reconcile, "reconcile.overrides");
277
-
278
- const validateIssue = result.error.issues.find((issue) => issue.path[0] === "validate");
279
- if (validateIssue !== undefined) {
280
- return failConfigShape(root, validateIssue);
281
- }
282
-
283
- // Scan whatever shape `confluence` takes — including an array on a
284
- // `[[confluence]]` typo — before exposing its generic table/type failure.
285
- assertNoCommittedToken(root.confluence, "confluence");
286
- const confluenceIssue = result.error.issues.find((issue) => issue.path[0] === "confluence");
287
- if (confluenceIssue !== undefined) {
288
- return failConfigShape(root, confluenceIssue);
289
- }
290
-
291
- if (result.error.issues.length === 0) {
292
- return fail(`${CONFIG_REL_PATH}: config has an invalid value`, `fix ${CONFIG_REL_PATH}`, { path: CONFIG_REL_PATH });
293
- }
294
- return failConfigShape(root, result.error.issues[0] as z.core.$ZodIssue);
295
- }
296
-
297
- /** Project the `[confluence]` table over defaults. The committed-token guard runs in {@link validateConfig}. */
298
- function validateConfluence(table: ParsedConfluenceTable | undefined, defaults: ConfluenceConfig): ConfluenceConfig {
299
- const confluence: ConfluenceConfig = {
300
- format: table?.format ?? defaults.format,
301
- };
302
- const baseUrl = table?.base_url;
303
- if (baseUrl !== undefined) {
304
- confluence.baseUrl = baseUrl;
305
- }
306
- const space = table?.space;
307
- if (space !== undefined) {
308
- confluence.space = space;
309
- }
310
- const parentPageId = asPageId(table?.parent_page_id, "confluence.parent_page_id");
311
- if (parentPageId !== undefined) {
312
- confluence.parentPageId = parentPageId;
313
- }
314
- return confluence;
315
- }
316
-
317
- /**
318
- * Reject a committed Confluence token anywhere under `value` (ADR-0013),
319
- * recursing through tables and arrays of tables so a token at any depth — a
320
- * nested `[confluence.auth]` subtable or a `[[confluence]]` array-of-tables typo
321
- * — fails loud with a pointer to the env var, rather than slipping through or
322
- * surfacing a generic shape error. The token value is never echoed.
323
- */
324
- function assertNoCommittedToken(value: unknown, path: string): void {
325
- if (Array.isArray(value)) {
326
- for (const item of value) {
327
- assertNoCommittedToken(item, path);
328
- }
329
- return;
330
- }
331
- if (typeof value !== "object" || value === null) {
332
- return;
333
- }
334
- const table = value as Record<string, unknown>;
335
- if ("token" in table) {
336
- fail(
337
- `${CONFIG_REL_PATH}: a Confluence token must not be committed`,
338
- `remove \`token\` from [${path}] and set $${TOKEN_ENV} instead`,
339
- { key: `${path}.token` },
340
- );
341
- }
342
- for (const [childKey, childValue] of Object.entries(table)) {
343
- assertNoCommittedToken(childValue, `${path}.${childKey}`);
344
- }
345
- }
346
-
347
- /**
348
- * Overlay the Confluence token from the environment. Only a value with
349
- * non-whitespace content counts: an unset, empty, or whitespace-only
350
- * `$LORE_CONFLUENCE_TOKEN` leaves the token absent so the (deferred) publish
351
- * adapter fails loud rather than sending a blank credential (ADR-0013). Stray
352
- * surrounding whitespace (a shell or CI secret can introduce it) is trimmed, and
353
- * the token is never written back to the file.
354
- */
355
- function overlayEnv(config: LoreConfig, env: Record<string, string | undefined>): LoreConfig {
356
- const token = env[TOKEN_ENV]?.trim();
357
- if (!token) {
358
- return config;
359
- }
360
- return { ...config, confluence: { ...config.confluence, token } };
361
- }
362
-
363
- /** Throw a `"validation"` {@link LoreError}; typed `never` so callers can `return fail(...)`. */
364
- function fail(message: string, hint: string, input: Record<string, unknown>): never {
365
- throw new LoreError("validation", message, hint, input);
366
- }
367
-
368
- /** True when `cause` is a Node fs error carrying the given errno `code` (e.g. `"ENOENT"`). */
369
- function isErrnoCode(cause: unknown, code: string): boolean {
370
- return errnoCode(cause) === code;
371
- }
372
-
373
- /** Append `: <reason>` to `base` only when a non-empty reason can be derived from `cause`. */
374
- function withReason(base: string, cause: unknown): string {
375
- const reason = describeCause(cause);
376
- return reason ? `${base}: ${reason}` : base;
377
- }
378
-
379
- /**
380
- * A single-line human reason from a thrown cause, for embedding in a diagnostic.
381
- * An aggregate parse error (Bun throws one for some malformed TOML) is flattened
382
- * to its sub-messages; otherwise the cause's own `message` is used.
383
- */
384
- function describeCause(cause: unknown): string {
385
- const parts: string[] = [];
386
- if (cause !== null && typeof cause === "object" && Array.isArray((cause as { errors?: unknown }).errors)) {
387
- for (const sub of (cause as { errors: unknown[] }).errors) {
388
- pushIfNonEmpty(parts, causeMessage(sub));
389
- }
390
- }
391
- // Fall back to the top-level message when the aggregate yielded nothing usable
392
- // (an `errors` array whose sub-messages are all empty), so the diagnostic never
393
- // degrades to a bare base with no reason.
394
- if (parts.length === 0) {
395
- pushIfNonEmpty(parts, causeMessage(cause));
396
- }
397
- return parts
398
- .join("; ")
399
- .replace(/\s*[\r\n]+\s*/g, " ")
400
- .trim();
401
- }
402
-
403
- /** Push `message` onto `parts` only when it carries content. */
404
- function pushIfNonEmpty(parts: string[], message: string): void {
405
- if (message !== "") {
406
- parts.push(message);
407
- }
408
- }
409
-
410
- /**
411
- * Best-effort human message from a thrown value. Prefers a string `message`
412
- * property over `String(value)` so a non-`Error` carrier (e.g. Bun's
413
- * `BuildMessage` TOML error) yields its reason — "Unexpected =" — rather than
414
- * leaking the runtime class-name prefix "BuildMessage: …".
415
- */
416
- function causeMessage(cause: unknown): string {
417
- if (cause !== null && typeof cause === "object") {
418
- const message = (cause as { message?: unknown }).message;
419
- if (typeof message === "string") {
420
- return message;
421
- }
422
- }
423
- return typeof cause === "string" ? cause : String(cause);
424
- }
425
-
426
- /**
427
- * Translate Zod's first deterministic shape issue into Lore's established
428
- * credential-safe config diagnostic. This is error-policy mapping, not a
429
- * second validator: recognition occurs only in {@link ParsedConfigSchema}.
430
- */
431
- function failConfigShape(root: Record<string, unknown>, issue: z.core.$ZodIssue): never {
432
- const path = issue.path.map(String);
433
- const key = path.join(".");
434
- const value = configValueAtPath(root, issue.path);
435
-
436
- if (path.length === 1 || key === "reconcile.overrides") {
437
- fail(`${CONFIG_REL_PATH}: ${key} must be a table`, `make ${key} a TOML table ([${key}] with key = value lines)`, {
438
- key,
439
- });
440
- }
441
-
442
- if (key === "validate.external_links" || key === "validate.promote_portability") {
443
- fail(`${CONFIG_REL_PATH}: ${key} must be a boolean`, `set ${key} to true or false`, { key, value });
444
- }
445
-
446
- if (
447
- key === "confluence.base_url" ||
448
- key === "confluence.space" ||
449
- (path[0] === "reconcile" && path[1] === "overrides")
450
- ) {
451
- fail(`${CONFIG_REL_PATH}: ${key} must be a string`, `quote ${key} as a string`, { key, value });
452
- }
453
-
454
- if (key === "reconcile.mode") {
455
- fail(
456
- `${CONFIG_REL_PATH}: ${key} must be one of ${RECONCILE_MODES.map((allowed) => `"${allowed}"`).join(", ")}`,
457
- `set ${key} to one of: ${RECONCILE_MODES.join(", ")}`,
458
- { key, value },
459
- );
460
- }
461
-
462
- if (key === "confluence.format") {
463
- fail(
464
- `${CONFIG_REL_PATH}: ${key} must be one of ${CONFLUENCE_FORMATS.map((allowed) => `"${allowed}"`).join(", ")}`,
465
- `set ${key} to one of: ${CONFLUENCE_FORMATS.join(", ")}`,
466
- { key, value },
467
- );
468
- }
469
-
470
- if (key === "confluence.parent_page_id") {
471
- fail(
472
- `${CONFIG_REL_PATH}: ${key} must be a positive integer page id`,
473
- `set ${key} to a positive integer page id (a quoted string or an unquoted integer)`,
474
- { key, value },
475
- );
476
- }
477
-
478
- return fail(
479
- `${CONFIG_REL_PATH}: ${key || "config"} has an invalid value`,
480
- `fix ${key || CONFIG_REL_PATH}`,
481
- key ? { key, value } : { path: CONFIG_REL_PATH },
482
- );
483
- }
484
-
485
- /** Read a parsed value for structured error input without changing or coercing it. */
486
- function configValueAtPath(root: Record<string, unknown>, path: PropertyKey[]): unknown {
487
- let value: unknown = root;
488
- for (const part of path) {
489
- if (value === null || typeof value !== "object") {
490
- return undefined;
491
- }
492
- value = (value as Record<PropertyKey, unknown>)[part];
493
- }
494
- return value;
495
- }
496
-
497
- /**
498
- * True for a key that cannot be stored on a plain object without being dropped or
499
- * shadowing an inherited member: every `Object.prototype` member (`__proto__`,
500
- * `constructor`, `toString`, `hasOwnProperty`, …) plus `prototype`. (TOML keys are
501
- * always strings, so this covers every key the loader can see.)
502
- */
503
- function isUnsafeMapKey(key: string): boolean {
504
- return key === "prototype" || key in Object.prototype;
505
- }
506
-
507
- /**
508
- * Enforce Lore's reserved-key policy against the raw parsed table before Zod
509
- * copies it. Non-table shapes and non-string values are left to the generic
510
- * schema so their existing type diagnostics retain precedence.
511
- */
512
- function assertNoReservedOverrideKey(reconcile: unknown, key: string, stopAtEntry?: string): void {
513
- if (reconcile === null || typeof reconcile !== "object" || Array.isArray(reconcile)) {
514
- return;
515
- }
516
- const value = (reconcile as Record<string, unknown>).overrides;
517
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
518
- return;
519
- }
520
- for (const [entryKey, entryValue] of Object.entries(value)) {
521
- // The old validator checked entries in source order, validating each value
522
- // before its key policy. Stop where Zod found the first bad value so a
523
- // preceding reserved key still wins, while a later one cannot jump ahead.
524
- if (entryKey === stopAtEntry || typeof entryValue !== "string") {
525
- return;
526
- }
527
- if (!isUnsafeMapKey(entryKey)) {
528
- continue;
529
- }
530
- fail(
531
- `${CONFIG_REL_PATH}: ${key}.${entryKey} uses a reserved object key`,
532
- `rename the "${entryKey}" entry under [${key}] to a real status name`,
533
- { key: `${key}.${entryKey}`, value: entryValue },
534
- );
535
- }
536
- }
537
-
538
- /** Return Zod's validated string map on a normal plain object. */
539
- function copyOverrideMap(value: Record<string, string> | undefined): Record<string, string> | undefined {
540
- if (value === undefined) {
541
- return undefined;
542
- }
543
- const out: Record<string, string> = {};
544
- for (const [entryKey, entryValue] of Object.entries(value)) {
545
- out[entryKey] = entryValue;
546
- }
547
- return out;
548
- }
549
-
550
- /**
551
- * Accept a Confluence page id as a positive-integer string (any length, so an id
552
- * beyond `Number.MAX_SAFE_INTEGER` keeps full precision) or an unquoted positive
553
- * integer (its most natural form, e.g. `parent_page_id = 98765`). Both forms must
554
- * resolve to a positive integer — a `0`/negative, non-integer, empty, or
555
- * non-numeric id is rejected at load time rather than surfacing later as a publish
556
- * 404. The number form is constrained by its *value* (Bun has already normalized
557
- * `0x10`/`1_000`/`1e3` to a decimal integer), the string form by its literal text
558
- * (`/^[1-9][0-9]*$/`); the two are not byte-identical checks, but neither admits an
559
- * invalid id. The sign is checked before magnitude so a negative id reports "must
560
- * be positive", not "too large".
561
- */
562
- function asPageId(value: unknown, key: string): string | undefined {
563
- if (value === undefined) {
564
- return undefined;
565
- }
566
- if (typeof value === "string") {
567
- if (!/^[1-9][0-9]*$/.test(value)) {
568
- fail(
569
- `${CONFIG_REL_PATH}: ${key} must be a positive integer page id`,
570
- `set ${key} to a positive integer page id, e.g. ${key} = "98765"`,
571
- { key, value },
572
- );
573
- }
574
- return value;
575
- }
576
- if (typeof value === "number") {
577
- if (!Number.isInteger(value) || value <= 0) {
578
- fail(
579
- `${CONFIG_REL_PATH}: ${key} must be a positive integer page id`,
580
- `set ${key} to a positive integer page id`,
581
- { key, value },
582
- );
583
- }
584
- if (!Number.isSafeInteger(value)) {
585
- fail(
586
- `${CONFIG_REL_PATH}: ${key} is too large to represent exactly as a number`,
587
- `quote ${key} as a string to preserve its precision`,
588
- { key, value },
589
- );
590
- }
591
- return String(value);
592
- }
593
- fail(
594
- `${CONFIG_REL_PATH}: ${key} must be a positive integer page id`,
595
- `set ${key} to a positive integer page id (a quoted string or an unquoted integer)`,
596
- { key, value },
597
- );
598
- }