|
| 1 | +import type { LoaderError } from "@metaobjectsdev/metadata"; |
| 2 | + |
| 3 | +/** |
| 4 | + * One rendering of a metadata LOAD failure, for every command that can hit one. |
| 5 | + * |
| 6 | + * The loader builds a full ADR-0009 envelope — a stable `code`, the `files` the node came |
| 7 | + * from, the `jsonPath` inside the document, and often `suggestions[]` naming the next step. |
| 8 | + * Five commands then printed `` `failed to load metadata: ${err.message}` `` and threw all |
| 9 | + * of it away. So `ERR_ABSTRACT_SUBTYPE_AUTHORED` reached an adopter as a paragraph with no |
| 10 | + * file, no line, no node name and no code — and `--format json` carried the same bare |
| 11 | + * string, so a CI job could not key on the code ADR-0009 promises and the docs name. Five |
| 12 | + * metadata files made that a `grep`; two hundred would not. |
| 13 | + * |
| 14 | + * This does not invent provenance. It reports exactly what the error carries and stays |
| 15 | + * silent about what it does not, so a caller can never read more precision into the line |
| 16 | + * than the loader actually had. |
| 17 | + */ |
| 18 | +export interface LoadErrorReport { |
| 19 | + /** The message plus whatever provenance the envelope carried, for a human. */ |
| 20 | + readonly text: string; |
| 21 | + /** ADR-0009 stable code, when the loader attached one. */ |
| 22 | + readonly code?: string; |
| 23 | + /** The file(s) the failing node was read from. */ |
| 24 | + readonly files?: readonly string[]; |
| 25 | + /** JSON path of the failing node within its document. */ |
| 26 | + readonly jsonPath?: string; |
| 27 | + /** The loader's own next steps. Printed verbatim; never paraphrased. */ |
| 28 | + readonly suggestions?: readonly string[]; |
| 29 | +} |
| 30 | + |
| 31 | +/** True for a thrown value carrying the loader's ADR-0009 envelope. */ |
| 32 | +function isLoaderError(err: unknown): err is LoaderError { |
| 33 | + return ( |
| 34 | + typeof err === "object" && err !== null |
| 35 | + && typeof (err as { code?: unknown }).code === "string" |
| 36 | + && typeof (err as { source?: unknown }).source === "object" |
| 37 | + ); |
| 38 | +} |
| 39 | + |
| 40 | +export function describeLoadError(err: unknown): LoadErrorReport { |
| 41 | + const message = err instanceof Error ? err.message : String(err); |
| 42 | + if (!isLoaderError(err)) return { text: message }; |
| 43 | + |
| 44 | + const source = err.source as { files?: readonly string[]; jsonPath?: string }; |
| 45 | + const files = source.files?.filter((f) => f.length > 0); |
| 46 | + const jsonPath = source.jsonPath; |
| 47 | + const suggestions = err.suggestions?.filter((s) => s.length > 0); |
| 48 | + |
| 49 | + // `code` first: it is the one part a machine keys on, and a human scanning a terminal |
| 50 | + // finds it fastest at the front of the line. Then WHERE, which is the question the |
| 51 | + // message itself can never answer. |
| 52 | + const where = [ |
| 53 | + files !== undefined && files.length > 0 ? files.join(", ") : undefined, |
| 54 | + jsonPath !== undefined && jsonPath.length > 0 ? `at ${jsonPath}` : undefined, |
| 55 | + ].filter((p): p is string => p !== undefined).join(" "); |
| 56 | + |
| 57 | + const head = `${err.code}: ${message}`; |
| 58 | + return { |
| 59 | + text: where.length > 0 ? `${head}\n in ${where}` : head, |
| 60 | + code: err.code, |
| 61 | + ...(files !== undefined && files.length > 0 ? { files } : {}), |
| 62 | + ...(jsonPath !== undefined && jsonPath.length > 0 ? { jsonPath } : {}), |
| 63 | + ...(suggestions !== undefined && suggestions.length > 0 ? { suggestions } : {}), |
| 64 | + }; |
| 65 | +} |
0 commit comments