@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/errors.ts
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
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
|
+
}
|