@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/errors.ts DELETED
@@ -1,697 +0,0 @@
1
- /**
2
- * errors.ts — lore's shared diagnostic model.
3
- *
4
- * This module is the single source of truth for how lore classifies failures
5
- * and surfaces diagnostics: the {@link LoreError} taxonomy, the centralized
6
- * exit-code mapping, the `--json` error envelope, and the warnings-not-errors
7
- * collector. Centralizing it here is what guarantees the contract — the same
8
- * logical failure maps to the same exit code and the same envelope from every
9
- * command and from the deferred MCP transport, instead of each command
10
- * inventing its own `process.exit(1)`.
11
- *
12
- * It deliberately does NOT resolve the output mode or read a TTY / `NO_COLOR`
13
- * (that is `output.ts`, LORE-12): callers pass an already-resolved `{ json,
14
- * color }` pair. It also never writes to stdout — diagnostics belong on stderr —
15
- * so the "stdout parses or stays silent" invariant holds.
16
- *
17
- * Normative contract: docs/reference/cli-contract.md §4–§5.
18
- * Rationale: docs/adr/0005-cli-contract.md.
19
- */
20
-
21
- import { readFileSync } from "node:fs";
22
-
23
- /**
24
- * The classifiable failure categories. Each maps to exactly one semantic exit
25
- * code via {@link EXIT_CODES}. `validation` and `drift` are distinct
26
- * `error_type` strings that intentionally share exit `6`, so an agent can tell
27
- * "my frontmatter is malformed" from "my managed block is stale"
28
- * (cli-contract §5.3) while shell/CI branching on the code stays simple.
29
- */
30
- export type ErrorType = "usage" | "not_found" | "denied" | "conflict" | "validation" | "drift";
31
-
32
- /** Success. */
33
- export const EXIT_OK = 0;
34
-
35
- /**
36
- * Unexpected / uncaught failure — a crash or bug, never a classifiable
37
- * condition. Reserved per cli-contract §5.1: an agent treats exit `1` as
38
- * "report this", not "handle this". {@link LoreError}s never map here.
39
- */
40
- export const EXIT_UNCAUGHT = 1;
41
-
42
- /**
43
- * The contract: {@link ErrorType} → semantic exit code (cli-contract §5.1).
44
- * Centralized so no command invents its own mapping. Changing any entry is a
45
- * breaking contract change.
46
- */
47
- export const EXIT_CODES: Readonly<Record<ErrorType, number>> = Object.freeze({
48
- usage: 2,
49
- not_found: 3,
50
- denied: 4,
51
- conflict: 5,
52
- validation: 6,
53
- drift: 6,
54
- });
55
-
56
- /**
57
- * A typed, classifiable failure. Core functions `throw` these instead of
58
- * printing or calling `process.exit`; the command layer catches one and renders
59
- * it via {@link reportError}. Errors are values, not ad-hoc strings.
60
- */
61
- export class LoreError extends Error {
62
- constructor(
63
- /** The failure category, which fixes the exit code (see {@link EXIT_CODES}). */
64
- readonly type: ErrorType,
65
- message: string,
66
- /** An actionable next step, written so an agent can often self-correct in one turn. */
67
- readonly hint?: string,
68
- /** The offending input echoed back, so a caller can diagnose without re-deriving it. */
69
- readonly input?: unknown,
70
- ) {
71
- super(message);
72
- // Set on the instance (not via an `override` field) so stack traces and
73
- // `err.name` read "LoreError" without fighting Error's prototype property.
74
- this.name = "LoreError";
75
- }
76
- }
77
-
78
- /**
79
- * The `--json` error envelope (cli-contract §5.2). Emitted on **stderr**, never
80
- * wrapped in the success `{ schemaVersion, kind, data }` envelope, so a caller
81
- * never mistakes an error for data.
82
- */
83
- export interface ErrorEnvelope {
84
- error_type: ErrorType;
85
- message: string;
86
- hint?: string;
87
- input?: unknown;
88
- }
89
-
90
- /**
91
- * The exit-`1` envelope for an uncaught failure (cli-contract §5.1) — the
92
- * catch-all emitted when a non-{@link LoreError} value reaches
93
- * {@link reportError}. `uncaught` is the only `error_type` outside the §5.3
94
- * table and carries no `hint`/`input`. Typed separately from
95
- * {@link ErrorEnvelope} (whose `error_type` is a classifiable {@link ErrorType})
96
- * so the catch-all shape is pinned to the contract at compile time.
97
- */
98
- interface UncaughtEnvelope {
99
- error_type: "uncaught";
100
- message: string;
101
- }
102
-
103
- /**
104
- * Coerce a value the contract types as a `string` (a `message` or `hint`) into an
105
- * actual string. The taxonomy types both as `string`, but a JS caller — or an
106
- * `Error.message`/`hint` reassigned at runtime — can still hand us a non-string,
107
- * while cli-contract §5.2 promises the envelope's `message`/`hint` ARE strings.
108
- * Guarded through {@link safeStringify} so coercion on the error path can never
109
- * itself throw (a hostile value cannot crash the very code reporting a failure).
110
- *
111
- * Exported alongside {@link singleLine} so the output layer applies the same
112
- * coercion before single-lining the truncation `hint` — a non-string hint from a
113
- * JS caller must degrade, not crash `String.prototype.replace`.
114
- */
115
- export function asText(value: unknown): string {
116
- if (typeof value === "string") {
117
- return value;
118
- }
119
- if (value === undefined || value === null) {
120
- return "";
121
- }
122
- return safeStringify(value);
123
- }
124
-
125
- /**
126
- * Collapse a diagnostic field to a single line: any run of line breaks (with
127
- * adjacent horizontal whitespace) becomes one space, and the ends are trimmed.
128
- * cli-contract §5.2 types `message` as single-line and §5.4 promises the text
129
- * diagnostic is one stderr line, so a multi-line `message`/`hint` can neither
130
- * spill across lines nor smuggle a second, unprefixed line into stderr. `input`
131
- * is deliberately exempt — it is echoed structured data, not a human-readable
132
- * line, and its newlines are preserved (escaped) in JSON.
133
- *
134
- * The run matches every ECMAScript line terminator — CR, LF, and the Unicode
135
- * LINE/PARAGRAPH SEPARATORs U+2028/U+2029 — so a separator that `trim()` already
136
- * treats as whitespace cannot survive here as a smuggled break.
137
- *
138
- * Exported so the output layer (output.ts) collapses its single-line fields — the
139
- * truncation `hint` (cli-contract §3.2) — through the *same* discipline rather
140
- * than letting an embedded newline smuggle a second line onto stdout.
141
- */
142
- export function singleLine(text: string): string {
143
- return text.replace(/\s*[\r\n\u2028\u2029]+\s*/g, " ").trim();
144
- }
145
-
146
- /**
147
- * Strip ANSI escape sequences and residual C0/C1 control characters from `text`. Meant to run
148
- * *after* {@link singleLine}, which only collapses line terminators (CR/LF/U+2028/U+2029) — it
149
- * leaves ESC (`\x1b`)-led sequences and other control bytes (BEL, backspace, …) untouched. A CSI
150
- * sequence (`ESC [ … final byte`) can move the cursor or erase lines, so passing one through into
151
- * rendered output would let a crafted/corrupted source field forge terminal rows even though the
152
- * text is already single-line (LORE-115).
153
- *
154
- * Two passes: first drop full ANSI escape sequences — CSI (`ESC [ … @-~`), OSC (`ESC ] …`
155
- * terminated by BEL or `ESC \`), and the general two-byte form (`ESC` + one printable byte, for
156
- * everything else) — then drop any remaining C0 (`\x00`-`\x1f`) or C1/DEL (`\x7f`-`\x9f`) control
157
- * byte that wasn't part of a recognized escape sequence (e.g. a bare BEL).
158
- *
159
- * The single shared home for this strip (LORE-181): it used to be reimplemented byte-identically
160
- * in `output.ts` (`renderTaskSummaryRows`, LORE-115), `commands/query.ts` (`sanitizeField`,
161
- * LORE-118), `core/validate.ts` (`sanitizeForMessage`, LORE-161), and `core/links.ts`
162
- * (`sanitizeForMessage`, LORE-153) — four independently-drifting copies of the same two regexes.
163
- * It lives here, layer-neutral beside {@link singleLine}, rather than in `output.ts`, so
164
- * `core/`-layer callers (which must stay filesystem/output-layer-free) can import it too. Callers
165
- * that need `singleLine` composed with the strip do so at the call site — this function is the raw
166
- * primitive only, so a caller that must NOT single-line first (none currently do) still can.
167
- */
168
- export function stripAnsiAndControls(text: string): string {
169
- const withoutAnsi = text.replace(
170
- // biome-ignore lint/suspicious/noControlCharactersInRegex: deliberately matching control bytes to strip them.
171
- /\x1b(?:\[[0-9;:<=>?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[ -~])/g,
172
- "",
173
- );
174
- // biome-ignore lint/suspicious/noControlCharactersInRegex: deliberately matching control bytes to strip them.
175
- return withoutAnsi.replace(/[\x00-\x1f\x7f-\x9f]/g, "");
176
- }
177
-
178
- /**
179
- * The maximum length {@link stderrHint} returns (truncation indicator included). A crashing or
180
- * hostile subprocess can write an unbounded amount to stderr; without a cap that entire blob
181
- * becomes a single unbounded `LoreError.hint` line (LORE-249).
182
- */
183
- const STDERR_HINT_MAX_LENGTH = 500;
184
-
185
- /** Appended to a {@link stderrHint} result cut short by {@link STDERR_HINT_MAX_LENGTH}. */
186
- const STDERR_HINT_TRUNCATION_INDICATOR = "…";
187
-
188
- /**
189
- * Collapse a failed subprocess invocation's stderr to a one-line hint, or `undefined` when it
190
- * carried no content — the shared policy behind every `LoreError` hint built from a subprocess
191
- * failure (`adapters/backlog.ts`'s Backlog spawn, `state.ts`'s git-write seam, `adapters/git.ts`'s
192
- * real `GitAdapter`), so a future change to how stderr is condensed (stripping ANSI, capping
193
- * length, …) has one home instead of three independently-drifting copies.
194
- *
195
- * Whitespace (including line breaks) is collapsed to single spaces *before* the ANSI/control-byte
196
- * strip, not after: {@link stripAnsiAndControls} deletes C0 bytes outright — including `\n`/`\t`,
197
- * which are also C0 bytes — so stripping first would glue words that were separated only by a
198
- * line break (`"foo\nbar"` → `"foobar"`) instead of the space the previous behavior preserved. A
199
- * second collapse+trim pass afterward mops up any doubled space left where an excised escape
200
- * sequence had sat between two words (LORE-181's {@link stripAnsiAndControls} was never applied
201
- * here — LORE-249). The result is then capped to {@link STDERR_HINT_MAX_LENGTH}, so an unbounded
202
- * subprocess stderr cannot produce an unbounded hint.
203
- */
204
- export function stderrHint(stderr: string): string | undefined {
205
- const collapsed = stderr.trim().replace(/\s+/g, " ");
206
- const cleaned = stripAnsiAndControls(collapsed).trim().replace(/\s+/g, " ");
207
- if (cleaned === "") {
208
- return undefined;
209
- }
210
- return cleaned.length > STDERR_HINT_MAX_LENGTH
211
- ? `${cleaned.slice(0, STDERR_HINT_MAX_LENGTH)}${STDERR_HINT_TRUNCATION_INDICATOR}`
212
- : cleaned;
213
- }
214
-
215
- /**
216
- * Project a {@link LoreError} onto its `--json` error envelope. `message`/`hint`
217
- * are coerced to single-line strings (§5.2); `hint` is omitted when absent or
218
- * empty; `input` is included only when it is a non-null, non-array object
219
- * (cli-contract §5.2 types it as an object), so a `null`/primitive/array `input`
220
- * is dropped rather than emitted as noise. Field order matches the contract example.
221
- */
222
- export function toErrorEnvelope(err: LoreError): ErrorEnvelope {
223
- // §5.2 types `message`/`hint` as single-line strings. Coerce (a reassigned or
224
- // mis-typed value need not be a string) and collapse newlines, so the envelope
225
- // honors the contract regardless of what a caller stored on the error.
226
- const envelope: ErrorEnvelope = { error_type: err.type, message: singleLine(asText(err.message)) };
227
- // A hint counts as present only when it is non-empty; an empty hint would emit
228
- // a meaningless `"hint": ""` (and a dangling `hint:` line in text).
229
- if (err.hint) {
230
- envelope.hint = singleLine(asText(err.hint));
231
- }
232
- // §5.2 types `input` as an object. Echo a non-null, non-array object only: a
233
- // `null`/primitive (`input: null` / `input: "..."`) or an array (`input: [...]`)
234
- // would break a consumer that decodes `input` as an object and reads
235
- // `envelope.input.<field>`.
236
- if (typeof err.input === "object" && err.input !== null && !Array.isArray(err.input)) {
237
- envelope.input = err.input;
238
- }
239
- return envelope;
240
- }
241
-
242
- /**
243
- * Map any thrown value to its semantic exit code. A {@link LoreError} maps via
244
- * {@link EXIT_CODES}; anything else is {@link EXIT_UNCAUGHT} (an uncaught bug).
245
- */
246
- export function exitCodeFor(err: unknown): number {
247
- return err instanceof LoreError ? EXIT_CODES[err.type] : EXIT_UNCAUGHT;
248
- }
249
-
250
- /**
251
- * The ANSI SGR sequences lore paints diagnostics and output with. Shared (exported)
252
- * so every painted surface — the error/warning heads here, the `lore init` summary in
253
- * commands/ — emits byte-identical sequences under one color policy, instead of each
254
- * module re-spelling `\x1b[…m`. Color is purely cosmetic and applied only when a caller
255
- * passes `color: true`; this module never decides that itself (output.ts owns the
256
- * TTY/`NO_COLOR` decision and threads the resolved boolean here).
257
- */
258
- export const ANSI = Object.freeze({
259
- red: "\x1b[31m",
260
- yellow: "\x1b[33m",
261
- green: "\x1b[32m",
262
- dim: "\x1b[2m",
263
- reset: "\x1b[0m",
264
- });
265
-
266
- /** Wrap `label` in an ANSI `sequence` (reset-terminated) when `color`, else return it bare. */
267
- export function paint(label: string, sequence: string, color: boolean): string {
268
- return color ? `${sequence}${label}${ANSI.reset}` : label;
269
- }
270
-
271
- /**
272
- * The single authoritative `error: <message>` head for text-mode diagnostics
273
- * (cli-contract §5.4). Both {@link formatErrorText} (classifiable errors) and
274
- * {@link reportError}'s uncaught branch render through this, so the two never
275
- * drift in prefix/color/spacing.
276
- */
277
- function errorHead(message: string, color: boolean): string {
278
- return `${paint("error:", ANSI.red, color)} ${message}`;
279
- }
280
-
281
- /**
282
- * Render a {@link LoreError} as a human diagnostic for stderr: a single
283
- * `error: <message>` line plus, when present, a `hint: <hint>` line
284
- * (cli-contract §5.4). Color is applied only when `opts.color` is true; the
285
- * caller (output.ts) owns the TTY/`NO_COLOR` decision.
286
- */
287
- export function formatErrorText(err: LoreError, opts: { color?: boolean } = {}): string {
288
- const color = opts.color ?? false;
289
- // Same single-line coercion as the envelope (§5.2/§5.4): a multi-line or
290
- // non-string message/hint must not split the stderr diagnostic across lines.
291
- const head = errorHead(singleLine(asText(err.message)), color);
292
- if (!err.hint) {
293
- return head;
294
- }
295
- return `${head}\n${paint("hint:", ANSI.dim, color)} ${singleLine(asText(err.hint))}`;
296
- }
297
-
298
- /** A minimal write sink — `process.stderr` satisfies it, and tests inject a fake. */
299
- export interface Writer {
300
- write(s: string): void;
301
- }
302
-
303
- /**
304
- * The `errno` string code (`"ENOENT"`, `"EACCES"`, …) carried by a thrown Node
305
- * filesystem error, or `undefined` for a value that is not such an error. The one
306
- * place lore reads `cause.code`, so every module that classifies a filesystem
307
- * failure (config load, bundle walk) shares one guarded extractor instead of
308
- * re-spelling the `typeof`/`in` dance.
309
- */
310
- export function errnoCode(cause: unknown): string | undefined {
311
- if (typeof cause === "object" && cause !== null && "code" in cause) {
312
- const code = (cause as { code: unknown }).code;
313
- return typeof code === "string" ? code : undefined;
314
- }
315
- return undefined;
316
- }
317
-
318
- /**
319
- * Read a file if it exists, or `undefined` if it does not — the shared "optional read" primitive
320
- * used directly by `commands/sync.ts` (a bundle's `log.md` may not exist yet before the first
321
- * `lore sync`) and `adapters/backlog.ts`'s `readStatusFlow` (a project may not have touched
322
- * `backlog/config.yml` yet). It lives here rather than in `commands/discover.ts` (which
323
- * `adapters/` must never import — adapters sit below commands in lore's layering), so both layers
324
- * share one implementation instead of two independently-drifting copies of the same
325
- * `ENOENT → undefined` / `EACCES,EPERM → denied` / else-rethrow branching. A permission failure is
326
- * `denied` (exit 4); anything else (a directory sitting at the path, …) propagates unclassified
327
- * rather than being force-fit into a misleading "not found".
328
- */
329
- export function readFileIfPresent(absPath: string, display: string): string | undefined {
330
- try {
331
- return readFileSync(absPath, "utf8");
332
- } catch (cause) {
333
- const code = errnoCode(cause);
334
- if (code === "ENOENT") {
335
- return undefined;
336
- }
337
- if (code === "EACCES" || code === "EPERM") {
338
- throw new LoreError("denied", `cannot read ${display}`, `check filesystem permissions on ${display}`, {
339
- path: display,
340
- code,
341
- });
342
- }
343
- throw cause;
344
- }
345
- }
346
-
347
- /** The per-category message + hint an {@link ioError} attaches to its {@link LoreError}. */
348
- interface IoErrorText {
349
- /** The single-line failure message. */
350
- readonly message: string;
351
- /** The actionable recovery hint. */
352
- readonly hint: string;
353
- }
354
-
355
- /** How a caught filesystem error maps onto the `denied`/`not_found` categories. */
356
- export interface IoErrorSpec {
357
- /** Text for a permission failure (`EACCES`/`EPERM` → `denied`, exit 4). */
358
- readonly denied: IoErrorText;
359
- /** Text for a missing path (`ENOENT` → `not_found`, exit 3) — and any other errno unless {@link rethrowUnknown}. */
360
- readonly notFound: IoErrorText;
361
- /** Structured context attached to the raised {@link LoreError} (for the `--json` envelope). */
362
- readonly input?: Record<string, unknown>;
363
- /**
364
- * When set, an errno that is neither `EACCES`/`EPERM` nor `ENOENT` re-throws the original
365
- * `cause` unchanged (the stat-on-a-user-named-path policy) instead of mapping to `not_found`.
366
- * Left unset, every non-permission failure is `not_found` (the read-failure policy).
367
- */
368
- readonly rethrowUnknown?: boolean;
369
- }
370
-
371
- /**
372
- * The **one** filesystem-errno → {@link LoreError} policy, shared by every command and core
373
- * module that classifies an I/O failure (`bundle.ts` reads, `commands/check.ts` /
374
- * `commands/validate.ts` path expansion, `commands/discover.ts` reads). A permission failure
375
- * (`EACCES`/`EPERM`) is `denied` (exit 4); a missing path (`ENOENT`) is `not_found` (exit 3);
376
- * any other errno is `not_found` too, unless {@link IoErrorSpec.rethrowUnknown} asks for the
377
- * original cause to propagate (so a `stat` on a path the *user* named surfaces an unexpected
378
- * fault rather than masking it as "missing"). Centralizing the mapping keeps two call sites
379
- * from ever classifying the same failure into different exit codes; each caller supplies only
380
- * its own message/hint wording. Always throws — its `never` return lets a `catch` fall through
381
- * with the surrounding binding still treated as definitely-assigned.
382
- */
383
- export function ioError(cause: unknown, spec: IoErrorSpec): never {
384
- const code = errnoCode(cause);
385
- // Attach the errno `code` to the structured input so the `--json` error envelope carries it
386
- // (preserving the field `discover.ts`'s denied read used to set by hand, now uniform across sites).
387
- const input = code !== undefined ? { ...spec.input, code } : spec.input;
388
- if (code === "EACCES" || code === "EPERM") {
389
- throw new LoreError("denied", spec.denied.message, spec.denied.hint, input);
390
- }
391
- if (code === "ENOENT" || !spec.rethrowUnknown) {
392
- throw new LoreError("not_found", spec.notFound.message, spec.notFound.hint, input);
393
- }
394
- throw cause;
395
- }
396
-
397
- /**
398
- * Project an arbitrary value onto a JSON-safe shape — primitives, arrays, and
399
- * plain objects only — that {@link JSON.stringify} can encode without throwing.
400
- * This is the degraded path {@link safeStringify} takes when a raw stringify
401
- * fails. It mirrors `JSON.stringify`'s own semantics, then tolerates exactly the
402
- * things it chokes on:
403
- *
404
- * - A custom `toJSON` is honored (a `Date` → its ISO string, a class → its
405
- * `toJSON` shape), so this fallback agrees with the fast path and respects a
406
- * `toJSON` written to hide fields.
407
- * - `BigInt` → its decimal string.
408
- * - Reference cycles → `"[Circular]"`, detected against the **ancestor chain**
409
- * (not "seen anywhere"), so a shared but acyclic node — a diamond — still
410
- * serializes in full instead of being mislabeled circular.
411
- * - A throwing `toJSON`/getter on a single field → `"[Unserializable]"` for that
412
- * field alone; the surrounding object is unaffected.
413
- *
414
- * `function`/`undefined`/`symbol` are dropped just as `JSON.stringify` drops
415
- * them. Plain-string fields are returned verbatim, which is why an envelope's
416
- * `error_type`/`message`/`hint` always survive this path. `ancestors` is the
417
- * set of objects on the current path (O(1) membership; cleared on unwind).
418
- */
419
- function toJsonSafe(value: unknown, ancestors: Set<object>, key = ""): unknown {
420
- if (value === null) {
421
- return null;
422
- }
423
- const kind = typeof value;
424
- if (kind === "bigint") {
425
- return (value as bigint).toString();
426
- }
427
- if (kind !== "object") {
428
- // string | number | boolean survive; function | undefined | symbol are
429
- // dropped by JSON.stringify, so returning undefined mirrors its semantics.
430
- return kind === "string" || kind === "number" || kind === "boolean" ? value : undefined;
431
- }
432
- if (ancestors.has(value as object)) {
433
- return "[Circular]";
434
- }
435
- ancestors.add(value as object);
436
- try {
437
- // Honor a custom `toJSON` exactly as JSON.stringify would (before the array
438
- // check, as it does). Reading or invoking it may throw — isolate that.
439
- let replacement: unknown;
440
- let replaced = false;
441
- try {
442
- const toJson = (value as { toJSON?: unknown }).toJSON;
443
- if (typeof toJson === "function") {
444
- // JSON.stringify passes the property key to toJSON (the index for an
445
- // array element, "" at the root); pass it too so a key-sensitive toJSON
446
- // serializes identically on this fallback as on the fast path.
447
- replacement = (toJson as (key: string) => unknown).call(value, key);
448
- replaced = true;
449
- }
450
- } catch {
451
- return "[Unserializable]";
452
- }
453
- if (replaced) {
454
- return toJsonSafe(replacement, ancestors, key);
455
- }
456
- if (Array.isArray(value)) {
457
- return (value as unknown[]).map((item, index) => {
458
- try {
459
- return toJsonSafe(item, ancestors, String(index));
460
- } catch {
461
- return "[Unserializable]";
462
- }
463
- });
464
- }
465
- // `Object.create(null)`, not `{}`: a data field literally named `__proto__`
466
- // assigned to a normal object hits the inherited prototype setter and is
467
- // silently dropped (diverging from the fast JSON.stringify path); a
468
- // null-prototype object has no such setter, so the key lands as an own
469
- // enumerable property and JSON.stringify emits it.
470
- const out: Record<string, unknown> = Object.create(null);
471
- for (const childKey of Object.keys(value as Record<string, unknown>)) {
472
- try {
473
- // Reading the property may itself throw (a getter); keep it isolated.
474
- const projected = toJsonSafe((value as Record<string, unknown>)[childKey], ancestors, childKey);
475
- if (projected !== undefined) {
476
- out[childKey] = projected;
477
- }
478
- } catch {
479
- out[childKey] = "[Unserializable]";
480
- }
481
- }
482
- return out;
483
- } finally {
484
- ancestors.delete(value as object);
485
- }
486
- }
487
-
488
- /**
489
- * `JSON.stringify` that never throws and always yields one parseable JSON value.
490
- * A {@link LoreError.input} is `unknown`, so a caller can hand us a value that is
491
- * **circular**, carries a `BigInt`, or has a throwing `toJSON`/getter — and the
492
- * error path is the last place we can afford a *second* throw (it would mask the
493
- * original failure with a crash). The fast path is a plain encode; only when it
494
- * throws do we re-encode through {@link toJsonSafe}, which degrades the offending
495
- * fields while leaving the envelope's classifiable string fields
496
- * (`error_type`/`message`/`hint`) intact. Callers pass an object envelope, whose
497
- * own keys are enumerable, so the walk cannot throw and the result is a string;
498
- * the inner guard is an absolute last resort for a hostile top-level value.
499
- *
500
- * `JSON.stringify` doesn't only fail by throwing — for a bare `Symbol`, a bare
501
- * function, or a value whose `toJSON` returns one of those, it silently returns
502
- * runtime `undefined` instead of a string (its documented behavior for values it
503
- * cannot encode). A caller here always expects a real string back — `asText`
504
- * exists specifically to guarantee that — so an `undefined` result for a
505
- * non-nullish `value` is treated as a failure too, routed through the same
506
- * degrade-to-`toJsonSafe` fallback as a thrown error. That fallback can itself
507
- * still bottom out at `undefined` (a *top-level* Symbol/function degrades to
508
- * `undefined` by design, mirroring `JSON.stringify`'s own semantics — see
509
- * {@link toJsonSafe}), so the final `"[unserializable]"` string is the backstop
510
- * for that case too.
511
- */
512
- function safeStringify(value: unknown): string {
513
- try {
514
- const result = JSON.stringify(value);
515
- if (result === undefined) {
516
- throw new Error("JSON.stringify produced no output for a non-nullish value");
517
- }
518
- return result;
519
- } catch {
520
- try {
521
- const degraded = JSON.stringify(toJsonSafe(value, new Set()));
522
- return degraded === undefined ? JSON.stringify("[unserializable]") : degraded;
523
- } catch {
524
- return JSON.stringify("[unserializable]");
525
- }
526
- }
527
- }
528
-
529
- /**
530
- * Best-effort single-string message for a non-{@link LoreError} thrown value
531
- * (the uncaught path). A real `Error` yields its `message` (or its `toString`
532
- * when the message is empty); a thrown POJO that carries its own diagnostics —
533
- * e.g. an `{ code, path, message }` rejection — yields its `message` field or,
534
- * failing that, a JSON projection, rather than the useless `"[object Object]"`
535
- * that `String()` would produce. All coercion is guarded: deriving the message
536
- * must never become a second throw on the crash-reporting path (a thrown value
537
- * may carry a hostile `toString`/`Symbol.toPrimitive`).
538
- *
539
- * Exported so any code that needs a safe human message from a caught value — e.g.
540
- * concept.ts turning a thrown `YAMLException` into a diagnostic — shares this one
541
- * guarded routine instead of hand-rolling a thinner, unguarded `instanceof Error`
542
- * check that a future fix here would silently bypass. The result may be multi-line;
543
- * single-line it through {@link singleLine} when the contract requires one line.
544
- */
545
- export function deriveMessage(err: unknown): string {
546
- try {
547
- if (err instanceof Error) {
548
- return typeof err.message === "string" && err.message !== "" ? err.message : String(err);
549
- }
550
- if (typeof err === "string") {
551
- return err;
552
- }
553
- if (typeof err === "object" && err !== null) {
554
- const own = (err as { message?: unknown }).message;
555
- // Honor an own string `message` even when empty: an empty string is a valid
556
- // (if unhelpful) message, whereas falling through to safeStringify(err) would
557
- // dump every other field of the thrown object — leaking internals the thrower
558
- // deliberately kept out of `message` (e.g. a token) into stderr.
559
- return typeof own === "string" ? own : safeStringify(err);
560
- }
561
- return String(err);
562
- } catch {
563
- return "[unstringifiable error]";
564
- }
565
- }
566
-
567
- /**
568
- * Report a failure on stderr and return its exit code — the one seam every
569
- * command's catch block uses.
570
- *
571
- * - In `--json` mode a {@link LoreError} is written as a one-line
572
- * {@link ErrorEnvelope}; otherwise the human diagnostic from
573
- * {@link formatErrorText}.
574
- * - A non-{@link LoreError} value is unexpected: it is reported with
575
- * `error_type: "uncaught"` (json) or a plain `error:` line and mapped to
576
- * {@link EXIT_UNCAUGHT}, so even a crash exits with a documented code and
577
- * clean stderr.
578
- *
579
- * stdout is never touched, preserving the "stdout parses or stays silent"
580
- * invariant. Mode/color are inputs, not resolved here. JSON serialization goes
581
- * through {@link safeStringify}, so a circular or otherwise non-serializable
582
- * `input` still yields one parseable envelope instead of throwing on the very
583
- * path meant to report a failure.
584
- */
585
- export function reportError(err: unknown, opts: { json: boolean; color?: boolean; stderr?: Writer }): number {
586
- const stderr = opts.stderr ?? process.stderr;
587
- if (err instanceof LoreError) {
588
- if (opts.json) {
589
- stderr.write(`${safeStringify(toErrorEnvelope(err))}\n`);
590
- } else {
591
- stderr.write(`${formatErrorText(err, { color: opts.color })}\n`);
592
- }
593
- } else {
594
- // Single-line per §5.2/§5.4: deriveMessage can yield a multi-line string (an
595
- // Error.message with embedded newlines), which would otherwise split the
596
- // uncaught diagnostic across stderr lines.
597
- const message = singleLine(deriveMessage(err));
598
- if (opts.json) {
599
- const envelope: UncaughtEnvelope = { error_type: "uncaught", message };
600
- stderr.write(`${safeStringify(envelope)}\n`);
601
- } else {
602
- stderr.write(`${errorHead(message, opts.color ?? false)}\n`);
603
- }
604
- }
605
- // Single source of truth for the exit code: exitCodeFor maps a LoreError via
606
- // EXIT_CODES and anything else to EXIT_UNCAUGHT — don't re-derive it inline.
607
- return exitCodeFor(err);
608
- }
609
-
610
- /**
611
- * Accumulates advisory warnings (unknown OKF `type`, missing `summary`,
612
- * non-portable link syntax, …). Per cli-contract §4.1 warnings go to stderr and
613
- * **do not, by themselves, change the exit code** — `count`/`list` are for
614
- * display only. A caller whose mutation depends on a specific advisory (e.g. a
615
- * complete bundle graph) tests for it with the machine-readable {@link has}
616
- * tag instead (LORE-82); as of writing, `rename`/`supersede` are the only such
617
- * callers — `validate`/`check` do not currently gate on any warning.
618
- */
619
- export class WarningCollector {
620
- private readonly messages: string[] = [];
621
- /** Machine-readable tags attached to warnings via {@link add}'s optional `kind`, for {@link has}. */
622
- private readonly kinds = new Set<string>();
623
-
624
- /**
625
- * Record an advisory warning. `kind` is an optional machine-readable tag (distinct from the
626
- * human-readable `message`) a caller can later test for with {@link has} — e.g. a bundle-load
627
- * caller that must refuse to proceed on an incomplete graph, not just display it. Most callers
628
- * only ever need the free-text `message`; `kind` is opt-in and does not change `list()`/`flush()`.
629
- */
630
- add(message: string, kind?: string): void {
631
- this.messages.push(message);
632
- if (kind !== undefined) {
633
- this.kinds.add(kind);
634
- }
635
- }
636
-
637
- /** Whether any warning was recorded with the given machine-readable `kind` tag. */
638
- has(kind: string): boolean {
639
- return this.kinds.has(kind);
640
- }
641
-
642
- /** How many warnings have been collected. */
643
- get count(): number {
644
- return this.messages.length;
645
- }
646
-
647
- /** Whether no warnings have been collected. */
648
- get isEmpty(): boolean {
649
- return this.messages.length === 0;
650
- }
651
-
652
- /** A snapshot copy of the collected warnings, in insertion order. */
653
- list(): readonly string[] {
654
- return [...this.messages];
655
- }
656
-
657
- /** Append another collector's messages and machine-readable kinds in order. */
658
- merge(other: WarningCollector): void {
659
- this.messages.push(...other.messages);
660
- for (const kind of other.kinds) this.kinds.add(kind);
661
- }
662
-
663
- /**
664
- * Write each collected warning to stderr as `warning: <message>` and return
665
- * the number flushed. Color is applied only when `opts.color` is true.
666
- *
667
- * Each message is coerced and single-lined via {@link asText}/{@link singleLine} —
668
- * the same normalization `formatErrorText`/`toErrorEnvelope` apply to a
669
- * `LoreError`'s message/hint — and then run through the shared
670
- * {@link stripAnsiAndControls}, the same ANSI/OSC/control-byte strip `output.ts`'s
671
- * `renderTaskSummaryRows` applies to table fields (LORE-115/LORE-181). So a warning
672
- * containing embedded newlines, ESC-led CSI/OSC sequences, or bare control bytes
673
- * (e.g. `\x1b[2J`, BEL) still emits as exactly one plain stderr line, preserving
674
- * the one-warning-per-line contract and closing — centrally, for every caller of
675
- * this collector (dangling task ids, Backlog titles/statuses, …) — the escape
676
- * forgery a crafted/corrupted source field could otherwise smuggle onto stderr.
677
- * Sanitization runs on the message body only: the painted `warning:` prefix is
678
- * built once below, from a fixed literal, and never passed through the strip, so
679
- * its color/escape sequence is unaffected.
680
- *
681
- * This is **non-draining**: it does not clear the collected warnings, so a
682
- * second `flush` re-emits them and {@link list}/{@link count} stay valid
683
- * afterward. Gate commands flush exactly once; report a count from
684
- * {@link count} rather than relying on `flush` to reset.
685
- */
686
- flush(opts: { color?: boolean; stderr?: Writer } = {}): number {
687
- const stderr = opts.stderr ?? process.stderr;
688
- const color = opts.color ?? false;
689
- // The painted prefix is loop-invariant — build it once, not once per warning.
690
- const prefix = paint("warning:", ANSI.yellow, color);
691
- for (const message of this.messages) {
692
- const body = stripAnsiAndControls(singleLine(asText(message)));
693
- stderr.write(`${prefix} ${body}\n`);
694
- }
695
- return this.messages.length;
696
- }
697
- }