@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/meta.ts DELETED
@@ -1,7 +0,0 @@
1
- import pkg from "../package.json" with { type: "json" };
2
-
3
- /** The package name as presented by the CLI. */
4
- export const NAME = "lore";
5
-
6
- /** The CLI version, sourced from package.json so there is a single source of truth. */
7
- export const VERSION: string = pkg.version;
package/src/output.ts DELETED
@@ -1,589 +0,0 @@
1
- /**
2
- * output.ts — lore's output-mode layer (the single rendering seam).
3
- *
4
- * Every command produces a typed result object; this module renders it in
5
- * exactly one of three modes — **pretty**, **`--plain`**, or **`--json`** — and
6
- * is the one place that decides which. Centralizing the decision here is what
7
- * makes the machine contract hold for the *whole* stream rather than line by
8
- * line: the mode is resolved once, up front, so a single invocation never emits
9
- * a partially-styled or mixed stdout (cli-contract §1).
10
- *
11
- * Responsibilities (cli-contract §1–§6, lore-design §5):
12
- *
13
- * - **Resolve the mode** from the `--json`/`--plain` flags and the stdout TTY
14
- * state, with the locked precedence `--json > --plain > pretty`; plain is
15
- * auto-selected when stdout is not a TTY, so piped/redirected output is
16
- * deterministic without a flag (§1.1).
17
- * - **Own the color decision.** Color is emitted only in pretty mode and only
18
- * when `NO_COLOR` is unset (§6). This module reads the TTY and `NO_COLOR`;
19
- * {@link errors} deliberately does not — it takes an already-resolved
20
- * `{ json, color }`, which {@link errorRenderOpts} derives from an
21
- * {@link OutputContext} for `reportError`/`WarningCollector.flush`. Because
22
- * that pair writes to **stderr**, a redirected `2>` must never receive ANSI
23
- * just because stdout happens to be a terminal (LORE-250, cli-contract §6):
24
- * `resolveOutput` takes stderr's own TTY state (independent of the stdout TTY
25
- * state `mode` is resolved from) and folds it into {@link OutputContext.color}
26
- * at the source, so every consumer that reads `ctx.color` — including the
27
- * `WarningCollector.flush({ color: options.output.color, ... })` call sites in
28
- * `commands/*.ts`, which never round-trip through `errorRenderOpts` — gets the
29
- * already-stderr-safe value with no call-site change of their own.
30
- * {@link OutputContext.stdoutColor} carries the un-gated sibling so stdout's
31
- * own pretty rendering ({@link emit}) is provably unaffected by stderr's TTY
32
- * state (§6, AC#2). `errorRenderOpts` still exists for a caller that holds a
33
- * hand-built (not `resolveOutput`-derived) context and needs the gate applied
34
- * explicitly. Both `stderrIsTTY` inputs default an *absent* value to `true`
35
- * (a no-op, for a caller that never passes the field at all) — a caller that
36
- * *does* source it from a real stream must hand in an already-coerced
37
- * boolean (cli.ts's `coerceRealTTY`), never a bare `.isTTY` read: Node/Bun
38
- * leave that property `undefined` (not `false`) on a non-TTY stream, and an
39
- * uncoerced `undefined` is indistinguishable from "omitted", silently
40
- * re-triggering the `?? true` default and reopening the leak (LORE-250,
41
- * round 3).
42
- * - **Emit the success envelope** `{ schemaVersion, kind, data }` on stdout in
43
- * `--json` mode (§2), and the pretty/plain text otherwise.
44
- * - **Keep the streams disciplined.** Only the payload goes to stdout; all
45
- * diagnostics belong on stderr (§4). On a non-serializable payload {@link emit}
46
- * throws *before* writing, so the "stdout parses or stays silent" invariant
47
- * holds even on that bug path.
48
- *
49
- * The {@link OutputMode} type is defined here, not in {@link errors}: the error
50
- * model is intentionally mode-agnostic, and the mode is an output-layer concept.
51
- *
52
- * Normative contract: docs/reference/cli-contract.md §1–§6.
53
- * Design: docs/specs/lore-design.md §5. Rationale: docs/adr/0005-cli-contract.md.
54
- */
55
-
56
- import stringWidth from "string-width";
57
-
58
- import { asText, singleLine, stripAnsiAndControls, type Writer } from "./errors";
59
-
60
- // Re-exported so a command author gets the write sink type from the rendering
61
- // seam itself, without a second import from errors.ts.
62
- export type { Writer };
63
-
64
- /**
65
- * The three mutually exclusive output modes. Defined here (not in errors.ts,
66
- * which is mode-agnostic by design) because the mode is purely an output-layer
67
- * concern (cli-contract §1).
68
- */
69
- export type OutputMode = "json" | "plain" | "pretty";
70
-
71
- /** The version of the `--json` success-envelope contract (cli-contract §2/§7). */
72
- export const SCHEMA_VERSION = 1;
73
-
74
- /**
75
- * The two flags and the TTY state that select the mode. `isTTY` is
76
- * `process.stdout.isTTY`: `true` at a terminal, and `undefined`/`false` when
77
- * stdout is piped or redirected — both of which must auto-select plain, which is
78
- * why the resolver tests it for *falsiness*, not strict `=== false`.
79
- */
80
- export interface ModeInputs {
81
- /** The `--json` flag. Wins over everything when set (cli-contract §1.1). */
82
- json?: boolean;
83
- /** The `--plain` flag. Selects plain unless `--json` is also set. */
84
- plain?: boolean;
85
- /** Whether stdout is a TTY. Falsy (piped/redirected) auto-selects plain. */
86
- isTTY?: boolean;
87
- }
88
-
89
- /**
90
- * Resolve the output mode from flags + TTY with the locked precedence
91
- * `--json > --plain > pretty` (cli-contract §1.1):
92
- *
93
- * - `--json` ⇒ `json`, regardless of `--plain`, TTY state, or `NO_COLOR`.
94
- * - else `--plain` **or a non-TTY stdout** ⇒ `plain` (the auto-selection that
95
- * makes a piped/captured call stable and ANSI-free without a flag).
96
- * - else ⇒ `pretty`.
97
- *
98
- * Pure and env-free, so the precedence matrix is exhaustively testable on its
99
- * own; {@link resolveOutput} layers the color policy on top.
100
- */
101
- export function resolveMode(inputs: ModeInputs): OutputMode {
102
- if (inputs.json) {
103
- return "json";
104
- }
105
- if (inputs.plain || !inputs.isTTY) {
106
- return "plain";
107
- }
108
- return "pretty";
109
- }
110
-
111
- /**
112
- * The resolved output decision, threaded through a command after a single
113
- * up-front {@link resolveOutput} call.
114
- *
115
- * `mode` is the **one** routing key: {@link emit} switches on it for the success
116
- * path, and {@link errorRenderOpts} derives the `{ json, color }` the error path
117
- * needs from it. There is deliberately no separate `json` field — a derivable,
118
- * independently-settable boolean could be hand-built to disagree with `mode`
119
- * (JSON envelope on stdout but a text error on stderr, or vice versa), so it is
120
- * computed on demand instead. `color` *is* carried: it is not derivable from
121
- * `mode` alone (it also depends on `NO_COLOR`).
122
- */
123
- export interface OutputContext {
124
- readonly mode: OutputMode;
125
- /**
126
- * Whether ANSI color may be emitted, for the single color signal a caller that
127
- * only ever reads one field gets. Only ever `true` in pretty mode with
128
- * `NO_COLOR` unset.
129
- *
130
- * For a context built by {@link resolveOutput} (the real dispatch path in
131
- * cli.ts), this is the stdout color decision further AND-gated by **stderr's
132
- * own TTY state** (LORE-250 AC#1) — the value that is safe to hand to a
133
- * stderr writer without checking anything else. That is exactly what every
134
- * `WarningCollector.flush({ color: options.output.color, stderr })` call site
135
- * across `commands/*.ts` already reads (unchanged since before LORE-250), and
136
- * what {@link errorRenderOpts} multiplies again (a harmless no-op — see
137
- * there) for `reportError`. A hand-built context — most command-level unit
138
- * tests construct `{ mode, color }` directly and never route through
139
- * `resolveOutput` — has no independent stderr TTY state to gate on, so
140
- * `color` there is simply the one signal the test set, used as-is; this is
141
- * unchanged, pre-LORE-250 behavior for every such test.
142
- *
143
- * A caller that specifically needs stdout's own decision, un-narrowed by
144
- * stderr's TTY state, wants {@link OutputContext.stdoutColor} instead —
145
- * {@link emit} is the only one.
146
- */
147
- readonly color: boolean;
148
- /**
149
- * Stdout's own color decision — `mode === "pretty"` with `NO_COLOR` unset —
150
- * **never** further gated by stderr's TTY state. Set by {@link resolveOutput}
151
- * on every context it builds; {@link emit}'s pretty branch reads this (falling
152
- * back to {@link OutputContext.color} when absent) specifically so a
153
- * still-colored TTY stdout is provably unaffected by stderr being redirected
154
- * (LORE-250 AC#2) even though `color` above may have been narrowed for
155
- * stderr's sake on the very same context.
156
- *
157
- * Optional, not required: dozens of existing tests hand-build `{ mode, color }`
158
- * literals to unit-test one command's rendering in isolation from
159
- * `resolveOutput`'s stderr gate entirely: they never set this field, and
160
- * `emit`'s fallback to `color` reproduces their exact pre-existing behavior.
161
- */
162
- readonly stdoutColor?: boolean;
163
- }
164
-
165
- /** {@link ModeInputs} plus the environment, for the full color-aware resolution. */
166
- export interface ResolveInputs extends ModeInputs {
167
- /** The environment to read `NO_COLOR` from. Defaults to `process.env`. */
168
- env?: Record<string, string | undefined>;
169
- /**
170
- * Whether **stderr** (not stdout) is a TTY — independent of `isTTY`, which is
171
- * stdout's own state and the only one {@link resolveMode}'s `mode` consults.
172
- * Defaults to `true`, a no-op that makes the returned `color` equal
173
- * `stdoutColor` exactly — every caller that predates this field (and any test
174
- * that only ever inspects `.color`) keeps its exact prior behavior. Only
175
- * cli.ts's real dispatch passes the actual stderr TTY state (LORE-250 AC#1).
176
- *
177
- * Danger for any future caller: the `true` default is a "field omitted"
178
- * no-op, not a safety fallback — it is the **unsafe** direction (assume TTY,
179
- * allow color) precisely because omitting the field is the common case. A
180
- * caller that *does* source this from a real stream must pass an
181
- * already-coerced boolean (cli.ts's `coerceRealTTY`), never a bare
182
- * `process.stderr.isTTY` read: Node/Bun leave that property `undefined` —
183
- * not `false` — on a non-TTY stream, and an uncoerced `undefined` is
184
- * indistinguishable from "the field was never passed", silently
185
- * re-triggering this default and reopening the leak this field exists to
186
- * close (LORE-250, round 3 — confirmed live under a real pty; see
187
- * `coerceRealTTY`'s doc in cli.ts).
188
- */
189
- stderrIsTTY?: boolean;
190
- }
191
-
192
- /**
193
- * Resolve the full {@link OutputContext}: the mode (via {@link resolveMode}) plus
194
- * the color policy (cli-contract §6).
195
- *
196
- * Color is allowed **only** in pretty mode and **only** when `NO_COLOR` is
197
- * unset. Because pretty is reachable only on a TTY (a non-TTY resolves to
198
- * plain), the TTY requirement of §6 is already satisfied by `mode === "pretty"`;
199
- * no separate TTY check is needed here. Per §6 — which overrides the upstream
200
- * NO_COLOR convention — `NO_COLOR` set to *any* value, **including the empty
201
- * string**, suppresses color, so presence is tested with `=== undefined` (unset)
202
- * rather than truthiness (which would let `NO_COLOR=` slip through).
203
- * `--plain`/`--json` are always ANSI-free, which falls out for free since
204
- * neither yields the pretty mode.
205
- *
206
- * `stdoutColor` is exactly that mode/NO_COLOR decision, untouched by anything
207
- * stream-specific. `color` is the same decision additionally AND-gated by
208
- * `stderrIsTTY` (LORE-250 AC#1): a `lore <cmd> 2>err.log` invocation run from a
209
- * terminal (stdout a TTY, stderr redirected) resolves `stdoutColor: true` (so
210
- * stdout keeps its color, AC#2) but `color: false` (so nothing that reads the
211
- * shared `color` field — every `WarningCollector.flush`/`reportError` call site
212
- * — can paint the redirected stderr). `stderrIsTTY` defaults to `true`, making
213
- * `color === stdoutColor`, matching every pre-LORE-250 caller exactly.
214
- */
215
- export function resolveOutput(inputs: ResolveInputs): OutputContext {
216
- const mode = resolveMode(inputs);
217
- const env = inputs.env ?? process.env;
218
- const stdoutColor = mode === "pretty" && env.NO_COLOR === undefined;
219
- const stderrIsTTY = inputs.stderrIsTTY ?? true;
220
- return { mode, color: stdoutColor && stderrIsTTY, stdoutColor };
221
- }
222
-
223
- /**
224
- * Derive the `{ json, color }` pair {@link errors.reportError} and
225
- * `WarningCollector.flush` consume, for a caller holding an {@link OutputContext}
226
- * it needs to apply an *explicit* stderr-TTY gate to. `json` is computed from
227
- * `mode` here rather than stored on the context, so the success path (routed by
228
- * `mode`) and the error path (routed by `json`) cannot disagree.
229
- *
230
- * For a context {@link resolveOutput} built, `ctx.color` is **already** gated by
231
- * the real stderr TTY state it was constructed with (LORE-250 AC#1, see
232
- * {@link resolveOutput}) — cli.ts's two `reportError` call sites pass that same
233
- * real `stderrIsTTY` here too, which is a harmless no-op re-application (ANDing
234
- * a boolean with the value it was already ANDed with changes nothing). This
235
- * function's load-bearing case is a **hand-built** `ctx` — e.g. a unit test that
236
- * constructs `{ mode, color }` directly rather than via `resolveOutput` — where
237
- * `ctx.color` is a bare, ungated boolean and `stderrIsTTY` is the *only* place
238
- * the gate is applied.
239
- *
240
- * `stderrIsTTY` defaults to `true` — a no-op gate — so every call site that
241
- * predates this parameter (and any test that only cares about `ctx.color`)
242
- * keeps its exact prior behavior. As with {@link ResolveInputs.stderrIsTTY},
243
- * that default is a "parameter omitted" no-op, not a safe fallback: a caller
244
- * passing a real stream's `.isTTY` must coerce it first (cli.ts's
245
- * `coerceRealTTY`) — an uncoerced `undefined` (Node/Bun's non-TTY reading) is
246
- * indistinguishable from an omitted argument and silently re-enables color
247
- * (LORE-250, round 3).
248
- *
249
- * Usage: `reportError(err, { ...errorRenderOpts(ctx, stderrIsTTY), stderr })`
250
- * (or `flush` likewise).
251
- */
252
- export function errorRenderOpts(ctx: OutputContext, stderrIsTTY = true): { json: boolean; color: boolean } {
253
- return { json: ctx.mode === "json", color: ctx.color && stderrIsTTY };
254
- }
255
-
256
- /**
257
- * The canonical `--json` success envelope (cli-contract §2). Every successful
258
- * `--json` payload on stdout is exactly this shape: a stable `kind` naming the
259
- * result type so a consumer dispatches without inferring structure, and the
260
- * typed `data` body for that kind. Versioned additively (§7) — consumers
261
- * tolerate unknown keys and unknown `kind` values.
262
- */
263
- export interface SuccessEnvelope<T> {
264
- schemaVersion: number;
265
- kind: string;
266
- data: T;
267
- }
268
-
269
- /**
270
- * Wrap a command's typed result in the {@link SuccessEnvelope} at the current
271
- * {@link SCHEMA_VERSION}. The single constructor for the envelope, so the
272
- * version and field order are fixed in one place.
273
- */
274
- export function successEnvelope<T>(kind: string, data: T): SuccessEnvelope<T> {
275
- return { schemaVersion: SCHEMA_VERSION, kind, data };
276
- }
277
-
278
- /**
279
- * Explicit truncation signal for read-heavy output (cli-contract §3). A bounded
280
- * command never silently returns a partial result: in `--json` these fields sit
281
- * on `data`; in pretty/plain they render as the trailing line from
282
- * {@link renderTruncationLine}.
283
- */
284
- export interface Truncation {
285
- /** The full count that matched. */
286
- total: number;
287
- /** The number actually returned. */
288
- shown: number;
289
- /** `true` when `shown < total`. */
290
- truncated: boolean;
291
- /** Actionable narrowing advice, identical across modes. Omitted when absent. */
292
- hint?: string;
293
- }
294
-
295
- /**
296
- * Build a {@link Truncation} from the full and shown counts, deriving
297
- * `truncated = shown < total`.
298
- *
299
- * Counts must be item tallies (see {@link assertCounts}) — a `NaN`/`Infinity`/
300
- * fractional/negative/transposed count throws rather than silently mis-deriving
301
- * `truncated` or serializing `NaN`→`null` in `--json` (§3.1).
302
- *
303
- * The `hint` is coerced ({@link asText}, so a non-string from a JS caller degrades
304
- * instead of crashing `String.prototype.replace`) and collapsed to one line
305
- * ({@link singleLine}, so an embedded newline cannot split the §3.2 line or appear
306
- * multi-line in `--json`); a hint empty or whitespace-only after collapsing is
307
- * dropped rather than emitted as a meaningless `"hint": ""`.
308
- */
309
- export function truncation(total: number, shown: number, hint?: string): Truncation {
310
- assertCounts(total, shown);
311
- // `!== undefined`, not a truthy check: a provided-but-falsy hint (a JS caller's
312
- // `0`/`false`) must still be coerced via asText, not silently dropped — only an
313
- // omitted hint is skipped. (An empty/whitespace-only hint collapses to "" below
314
- // and is then dropped by the `if (cleanHint)` guard.)
315
- const cleanHint = hint !== undefined ? singleLine(asText(hint)) : "";
316
- const result: Truncation = { total, shown, truncated: shown < total };
317
- if (cleanHint) {
318
- result.hint = cleanHint;
319
- }
320
- return result;
321
- }
322
-
323
- /**
324
- * Render the pretty/plain truncation line (cli-contract §3.2), e.g.
325
- * `showing 30 of 120 — narrow with --type story`. Returns `""` when nothing was
326
- * truncated, so a caller can unconditionally append it and add no line when the
327
- * full result fit.
328
- *
329
- * The parameter is the exported {@link Truncation}, so a caller may hand-build one
330
- * (bypassing the builder), and every field is therefore re-derived or re-validated
331
- * here rather than trusted:
332
- *
333
- * - **`truncated` is derived** from `shown < total`, never read from the object — a
334
- * hand-built `{ shown: 30, total: 120, truncated: false }` would otherwise drop
335
- * the footer and present a partial result as complete (the §3 guarantee it must
336
- * not), and the inverse would print a misleading `showing 30 of 30`.
337
- * - **counts** go through the same {@link assertCounts} (a corrupt count would
338
- * render `showing 30 of NaN`).
339
- * - the **`hint`** is coerced + single-lined exactly as in the builder, so a
340
- * multi-line/non-string hint cannot smuggle a second, unprefixed line onto stdout
341
- * (§3.2/§4).
342
- *
343
- * Mirrors errors.ts, which guards at both build and render.
344
- */
345
- export function renderTruncationLine(t: Truncation): string {
346
- assertCounts(t.total, t.shown);
347
- if (t.shown >= t.total) {
348
- return "";
349
- }
350
- const head = `showing ${t.shown} of ${t.total}`;
351
- const hint = t.hint !== undefined ? singleLine(asText(t.hint)) : "";
352
- return hint ? `${head} — ${hint}` : head;
353
- }
354
-
355
- /**
356
- * A command's per-result-type rendering bundle. A command supplies one of these
357
- * (the `kind`, the typed `data`, and the two text renderers) and never branches
358
- * on the mode itself — {@link emit} dispatches. `data` is what the `--json`
359
- * envelope carries; `pretty`/`plain` produce the human and pipe-stable text.
360
- * Renderers return the body **without** a trailing newline; {@link emit} adds
361
- * exactly one.
362
- */
363
- export interface Renderable<T> {
364
- kind: string;
365
- data: T;
366
- /** Human view; emit ANSI only when `opts.color` is true. */
367
- pretty(data: T, opts: { color: boolean }): string;
368
- /** ANSI-free, diff-stable view for pipes and snapshot tests. */
369
- plain(data: T): string;
370
- }
371
-
372
- /**
373
- * Render a {@link Renderable} to stdout in the resolved mode — the seam every
374
- * command's success path goes through.
375
- *
376
- * - **json:** the {@link SuccessEnvelope} as one compact line. It is serialized
377
- * *first*, then those exact bytes are validated against §2 (see
378
- * {@link assertSerializedEnvelope}) before the write, so a malformed or
379
- * non-serializable payload (a bug — core returns plain data) throws with
380
- * **nothing** written, keeping "stdout parses or stays silent" (§4) intact.
381
- * Unlike the error path, a bad success payload is deliberately *not* degraded:
382
- * it must surface as an uncaught failure on stderr, never be dressed up as a
383
- * success.
384
- * - **plain / pretty:** the renderer's text via {@link writeBody}, normalized to
385
- * exactly one trailing newline (an empty/whitespace-only body writes nothing,
386
- * so stdout stays clean). Pretty receives {@link OutputContext.stdoutColor} —
387
- * stdout's own, never-stderr-gated decision — falling back to
388
- * {@link OutputContext.color} only for a hand-built context that predates the
389
- * `stdoutColor` field, so a still-TTY stdout is never dimmed by a redirected
390
- * stderr (LORE-250 AC#2).
391
- *
392
- * The `switch` is exhaustive over {@link OutputMode}: the `never` default makes
393
- * adding a mode without handling it here a compile error. stdout-only by
394
- * contract; diagnostics are the caller's job via errors.ts on stderr. `out`
395
- * defaults to `process.stdout` and is injectable for tests.
396
- */
397
- export function emit<T>(renderable: Renderable<T>, ctx: OutputContext, out: Writer = process.stdout): void {
398
- switch (ctx.mode) {
399
- case "json": {
400
- // Serialize ONCE, then validate the exact bytes (not the live object): this
401
- // closes a TOCTOU where a non-idempotent toJSON could ship a value different
402
- // from the one checked. The write happens only after both succeed.
403
- const text = JSON.stringify(successEnvelope(renderable.kind, renderable.data));
404
- assertSerializedEnvelope(text);
405
- out.write(`${text}\n`);
406
- return;
407
- }
408
- case "plain": {
409
- writeBody(renderable.plain(renderable.data), out);
410
- return;
411
- }
412
- case "pretty": {
413
- writeBody(renderable.pretty(renderable.data, { color: ctx.stdoutColor ?? ctx.color }), out);
414
- return;
415
- }
416
- default: {
417
- // Exhaustiveness guard: if OutputMode grows a member, this stops compiling.
418
- const unreachable: never = ctx.mode;
419
- throw new TypeError(`emit: unhandled output mode ${String(unreachable)}`);
420
- }
421
- }
422
- }
423
-
424
- /**
425
- * Write a pretty/plain body with exactly one trailing newline. Renderers return
426
- * the logical text without one (as errors.ts's `formatErrorText` does), so the
427
- * single source of truth for the line terminator is here.
428
- *
429
- * A body with no non-whitespace character is treated as "no content" and writes
430
- * **nothing**, so a command with no rows leaves stdout silent rather than emitting
431
- * a blank line. The test is `!/\S/` — `\S` is the complement of `\s`, which covers
432
- * spaces, tabs, CR/LF, the Unicode LINE/PARAGRAPH separators (U+2028/U+2029), and
433
- * the BOM — and allocates nothing, unlike `trim()`. A content-bearing body has
434
- * only its trailing **line terminators** stripped (LF, the CR of a CRLF, and
435
- * U+2028/U+2029 — the line-terminator subset of what the emptiness test counts as
436
- * whitespace, so a terminator-only body is silenced *and* a trailing terminator on
437
- * a content line is normalized), then exactly one `\n` is appended. All other
438
- * whitespace is the renderer's payload and is preserved verbatim — including
439
- * trailing *horizontal* whitespace on the last line, which a plain renderer may
440
- * treat as a significant field (e.g. an empty trailing TSV column, §1.3), and all
441
- * leading/interior formatting (pretty may format freely, §1.2).
442
- */
443
- function writeBody(body: string, out: Writer): void {
444
- if (!/\S/.test(body)) {
445
- return;
446
- }
447
- const trimmed = body.replace(/[\r\n\u2028\u2029]+$/, "");
448
- out.write(`${trimmed}\n`);
449
- }
450
-
451
- /**
452
- * Validate the **serialized** `--json` envelope (cli-contract §2) — `kind` a
453
- * non-empty string, `data` an object or array — by parsing the exact bytes
454
- * {@link emit} is about to write.
455
- *
456
- * Checking the serialized form rather than the live object is what closes the
457
- * validate-then-reserialize TOCTOU: whatever a non-idempotent `toJSON`/getter
458
- * produced is already baked into `serialized`, so the value validated *is* the
459
- * value written. A `typeof` check on the live object cannot see this — a `Date`
460
- * (or any primitive-returning `toJSON`) is `typeof "object"` yet serializes to a
461
- * bare string; here that string, a `null`, or a dropped `data`/`kind` key (from an
462
- * `undefined`/function/symbol value, or a non-string `kind`) all fail. The
463
- * `JSON.parse` cannot throw — `serialized` came straight from `JSON.stringify`.
464
- * Throwing keeps the §4 invariant: the caller has not written yet, so stdout stays
465
- * silent and the bug surfaces on stderr (exit 1), never as a lie at exit 0.
466
- */
467
- function assertSerializedEnvelope(serialized: string): void {
468
- const parsed = JSON.parse(serialized) as { kind?: unknown; data?: unknown };
469
- if (typeof parsed.kind !== "string" || parsed.kind === "") {
470
- throw new TypeError("emit: --json envelope kind must be a non-empty string (cli-contract §2)");
471
- }
472
- if (typeof parsed.data !== "object" || parsed.data === null) {
473
- throw new TypeError(
474
- `emit: --json envelope data for kind "${parsed.kind}" must be an object or array (cli-contract §2)`,
475
- );
476
- }
477
- }
478
-
479
- /**
480
- * One row for the shared 3-column id/status/title alignment: `lore tasks`'s rollup rows and `lore
481
- * orphans`' orphan-task block render the identical `{id, title, status}` shape through the identical
482
- * ` <id> <status> <title>` layout, so both commands share this type and {@link renderTaskSummaryRows}
483
- * rather than keeping two byte-identical redeclarations that could silently diverge (LORE-51).
484
- */
485
- export interface TaskSummaryRow {
486
- /** Display-cased task id (`"LORE-21"`). */
487
- readonly id: string;
488
- readonly title: string;
489
- /** The raw configured Backlog status string (no presentation icon). */
490
- readonly status: string;
491
- }
492
-
493
- /**
494
- * The longest `length(item)` across `items` for column alignment — a loop rather than
495
- * `Math.max(...items.map(...))`, whose argument spread throws `RangeError` once the list is large
496
- * enough (a six-figure Backlog snapshot is possible). `0` for an empty list. Shared by every
497
- * aligned-column renderer (task-summary rows here, `lore orphans`' dangling-link concept column).
498
- */
499
- export function maxLen<T>(items: readonly T[], length: (item: T) => number): number {
500
- let max = 0;
501
- for (const item of items) {
502
- const n = length(item);
503
- if (n > max) {
504
- max = n;
505
- }
506
- }
507
- return max;
508
- }
509
-
510
- /**
511
- * Terminal display width of `text` — the number of terminal columns it occupies when printed, not
512
- * `text.length` (UTF-16 code units). `string-width` owns Unicode grapheme segmentation,
513
- * East_Asian_Width data, combining/default-ignorable handling, and emoji-sequence width. Keeping
514
- * the dependency behind this wrapper preserves the output layer's single measurement seam and
515
- * makes ASCII-only strings byte-compatible (`displayWidth(text) === text.length`) while correctly
516
- * treating emoji variation, regional-indicator, keycap, and ZWJ sequences as terminal graphemes.
517
- * Callers sanitize fields before measurement, so Lore's stricter ANSI/control policy remains owned
518
- * by {@link renderTaskSummaryRows} rather than delegated to the dependency (LORE-221, LCLI-285).
519
- */
520
- export function displayWidth(text: string): number {
521
- return stringWidth(text);
522
- }
523
-
524
- /**
525
- * Pad `text` with trailing spaces until it reaches `width` terminal columns
526
- * ({@link displayWidth}), not `width` UTF-16 code units the way `String.prototype.padEnd` does —
527
- * `padEnd` over/under-shoots once `text` contains a East Asian wide or zero-width/combining
528
- * character. Padding characters are plain ASCII spaces (always display width 1), so the width
529
- * deficit maps directly to a character count. Returns `text` unchanged once it is already at or
530
- * past `width`.
531
- */
532
- function padEndDisplay(text: string, width: number): string {
533
- const deficit = width - displayWidth(text);
534
- return deficit > 0 ? text + " ".repeat(deficit) : text;
535
- }
536
-
537
- /**
538
- * Render `rows` as aligned ` <id> <status> <title>` lines, one per row, with the id/status
539
- * columns padded to the widest cell in each (via the spread-free {@link maxLen}). Shared by `lore
540
- * tasks`'s rollup table and `lore orphans`' orphan-task block, so a column-layout change (an extra
541
- * column, truncation, a width cap) is a one-place edit instead of two independently-drifting copies.
542
- * Returns `[]` for an empty `rows` — the caller decides what an empty section renders as.
543
- *
544
- * Each field is coerced ({@link asText}), collapsed to one line ({@link singleLine}), and stripped
545
- * of ANSI escape sequences and residual control characters ({@link stripAnsiAndControls}) before
546
- * padding/joining. `id`/`status`/`title` come from a Backlog task file, which a crafted or
547
- * corrupted file could load with an embedded newline, CR, ANSI escape sequence, or other control
548
- * character — without this, that would split a plain-mode row across lines, inject cursor-moving
549
- * escape sequences, or otherwise break the single-line, ANSI-free-per-row output guarantee
550
- * (cli-contract.md §6). Sanitizing before {@link maxLen} also keeps the column widths measuring the
551
- * same text that is actually printed, so a stripped character cannot desync the padding from the
552
- * rendered length.
553
- *
554
- * Column width is measured and padded by terminal *display* width ({@link displayWidth} /
555
- * {@link padEndDisplay}), not UTF-16 code-unit count — the realistic vector is `status` (the raw
556
- * configured Backlog status), which a full-width CJK status or a combining-mark status could
557
- * otherwise under- or over-pad relative to what a terminal actually renders (LORE-221). `id` is
558
- * always ASCII in practice, and `title` is the unpadded last column either way.
559
- */
560
- export function renderTaskSummaryRows(rows: readonly TaskSummaryRow[]): string[] {
561
- const clean = rows.map((row) => ({
562
- id: stripAnsiAndControls(singleLine(asText(row.id))),
563
- status: stripAnsiAndControls(singleLine(asText(row.status))),
564
- title: stripAnsiAndControls(singleLine(asText(row.title))),
565
- }));
566
- const idWidth = maxLen(clean, (row) => displayWidth(row.id));
567
- const statusWidth = maxLen(clean, (row) => displayWidth(row.status));
568
- return clean.map(
569
- (row) => ` ${padEndDisplay(row.id, idWidth)} ${padEndDisplay(row.status, statusWidth)} ${row.title}`,
570
- );
571
- }
572
-
573
- /**
574
- * Assert truncation counts are item tallies — non-negative integers with
575
- * `shown <= total`. Shared by {@link truncation} (build) and
576
- * {@link renderTruncationLine} (render) so a hand-built {@link Truncation} cannot
577
- * bypass the check at either seam. A `NaN`/`Infinity`/fractional/negative/
578
- * transposed count is an upstream arithmetic slip: at build it makes
579
- * `shown < total` mis-derive `truncated` and serialize `NaN`→`null` in `--json`
580
- * (§3.1); at render it prints `showing 30 of NaN` / `showing 30 of 120.5` (§3.2).
581
- * Fail loud either way.
582
- */
583
- function assertCounts(total: number, shown: number): void {
584
- if (!Number.isInteger(total) || !Number.isInteger(shown) || shown < 0 || shown > total) {
585
- throw new RangeError(
586
- `truncation: counts must be integers with 0 <= shown <= total, received total=${String(total)}, shown=${String(shown)}`,
587
- );
588
- }
589
- }