@opum-ai/lore 0.1.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.
- package/LICENSE +21 -0
- package/README.md +306 -0
- package/bin/lore.cjs +109 -0
- package/package.json +67 -0
- package/src/adapters/backlog.ts +1084 -0
- package/src/adapters/git.ts +221 -0
- package/src/cli.ts +667 -0
- package/src/commands/agent.ts +301 -0
- package/src/commands/agents.ts +302 -0
- package/src/commands/args.ts +209 -0
- package/src/commands/changed.ts +70 -0
- package/src/commands/check.ts +1031 -0
- package/src/commands/codex-bridge.ts +49 -0
- package/src/commands/concurrency.ts +48 -0
- package/src/commands/context.ts +292 -0
- package/src/commands/discover.ts +89 -0
- package/src/commands/explorer.ts +253 -0
- package/src/commands/export.ts +93 -0
- package/src/commands/fswrite.ts +928 -0
- package/src/commands/graph.ts +291 -0
- package/src/commands/help.ts +151 -0
- package/src/commands/impact.ts +59 -0
- package/src/commands/init.ts +583 -0
- package/src/commands/instructions.ts +91 -0
- package/src/commands/link.ts +929 -0
- package/src/commands/new.ts +476 -0
- package/src/commands/orphans.ts +457 -0
- package/src/commands/path.ts +67 -0
- package/src/commands/provenance.ts +68 -0
- package/src/commands/query.ts +312 -0
- package/src/commands/reconcile-shared.ts +280 -0
- package/src/commands/rename.ts +585 -0
- package/src/commands/replace.ts +320 -0
- package/src/commands/scaffold.ts +346 -0
- package/src/commands/schema.ts +293 -0
- package/src/commands/snapshot.ts +130 -0
- package/src/commands/supersede.ts +400 -0
- package/src/commands/sync.ts +371 -0
- package/src/commands/tasks.ts +271 -0
- package/src/commands/traversal.ts +151 -0
- package/src/commands/validate.ts +226 -0
- package/src/config.ts +598 -0
- package/src/core/agent-bridge.ts +287 -0
- package/src/core/agent-context.ts +498 -0
- package/src/core/agent-profile.ts +447 -0
- package/src/core/bundle.ts +893 -0
- package/src/core/check.ts +853 -0
- package/src/core/codex-bridge.ts +100 -0
- package/src/core/concept.ts +597 -0
- package/src/core/consumer-scaffold.ts +433 -0
- package/src/core/context.ts +271 -0
- package/src/core/explorer-contract.ts +441 -0
- package/src/core/explorer-qualification.ts +58 -0
- package/src/core/explorer.ts +518 -0
- package/src/core/finding.ts +31 -0
- package/src/core/graph.ts +201 -0
- package/src/core/indexes.ts +436 -0
- package/src/core/instructions.ts +209 -0
- package/src/core/ladybug-driver.ts +1795 -0
- package/src/core/ladybug-lifecycle.ts +1178 -0
- package/src/core/ladybug-native.ts +95 -0
- package/src/core/ladybug-source.ts +667 -0
- package/src/core/links.ts +681 -0
- package/src/core/log.ts +253 -0
- package/src/core/managed-block.ts +540 -0
- package/src/core/manifest.ts +718 -0
- package/src/core/order.ts +13 -0
- package/src/core/profile.ts +1007 -0
- package/src/core/projection.ts +195 -0
- package/src/core/query.ts +542 -0
- package/src/core/reconcile.ts +236 -0
- package/src/core/replace.ts +419 -0
- package/src/core/retrieval.ts +213 -0
- package/src/core/rewrite.ts +940 -0
- package/src/core/scaffold.ts +255 -0
- package/src/core/schema.ts +366 -0
- package/src/core/snapshot-runtime.ts +52 -0
- package/src/core/snapshot-store.ts +287 -0
- package/src/core/snapshot.ts +711 -0
- package/src/core/template.ts +429 -0
- package/src/core/traversal.ts +487 -0
- package/src/core/validate.ts +517 -0
- package/src/core/workspace-contract.ts +473 -0
- package/src/core/workspace-projection.ts +365 -0
- package/src/core/workspace-retrieval.ts +196 -0
- package/src/core/workspace-source.ts +174 -0
- package/src/errors.ts +697 -0
- package/src/meta.ts +7 -0
- package/src/output.ts +589 -0
- package/src/scripts/upstream-backlog-watch.ts +288 -0
- package/src/state.ts +390 -0
package/src/meta.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
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
|
+
}
|