diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c007e7..9ff4337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.0.0 + +- Explain This Failure: real errors and cause chains. +- Typed API, CLI integration, runnable codebase example and meaningful workflow tests. +- Stable contracts and migration guidance; original humor stays around accurate facts. + ## 0.4.0 46 built-in definitions cover filesystem, permissions, networking, module loading, streams, HTTP response lifecycle and JavaScript errors. Each has a specific explanation, likely causes and practical checks. Rubber-duck commentary has a distinct original line for every supported definition. No commands run automatically and unknown errors remain explicitly unrecognized. diff --git a/MIGRATION.md b/MIGRATION.md index 5058fa7..9fc8faa 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -9,3 +9,9 @@ Install 0.3.0 with npm. Seeds and exact humorous wording are version-specific. D 46 distinct error definitions with explanations, causes and practical checks; original duck commentary for each; structured errorCatalog API and --catalog CLI. Default behavior is retained except that the expanded error catalog now recognizes additional errors. New rotation and release-plan features are opt-in. + +## 0.4.0 to stable 1.0.0 + +diagnoseError/renderDiagnosis and diagnostic CLI flags are additive. Explicit code precedence and existing catalog/batch behavior remain. Use diagnosis.original to hand the same error to your existing handler; serializing a diagnosis omits that reference. + +See README for exact contracts, bounds and failure behavior. diff --git a/README.md b/README.md index ae1a523..e66a627 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,35 @@ # error-translator +## Explain This Failure: real errors and cause chains (1.0.0) + +diagnoseError(error, options) walks actual Error.cause chains with a default eight-layer bound (maxDepth 1–32). It detects cycles, exposes unknown causes honestly and returns structured chain entries with a translation for each layer. It never throws away the original error: result.original is the exact input reference and is deliberately non-enumerable so JSON output excludes it. The caller controls rethrowing, logging and exit behavior. + +```js +import { diagnoseError, renderDiagnosis } from "@deployanyway/error-translator"; +try { + await loadConfig(); +} catch (error) { + const diagnosis = diagnoseError(error, { mode: "rubber-duck" }); + console.error(renderDiagnosis(diagnosis)); + throw error; // preserve failure behavior and the original stack +} +``` + +Options: mode plain/rubber-duck, maxDepth, includeStack (default false). Entries contain depth, name, message, optional code, translation and opt-in stack. stopped is complete/cycle/depth-limit; truncated reports incomplete traversal. Arbitrary primitive causes receive an explicit unknown translation. The caller's errors and causes are not mutated. AggregateError.errors is not flattened; this API follows the cause chain. + +```sh +printf '%s' '{"message":"Config failed","cause":{"code":"ENOENT","message":"open config.json"}}' | error-translator --diagnose --json +node node_modules/@deployanyway/error-translator/examples/explain-failure.mjs ./missing-config.json +``` + +--diagnose accepts a JSON error record from stdin or a quoted argument, with --max-depth and opt-in --include-stack. It rejects batch/catalog/list combinations. Translation/diagnosis exits 0; invalid input exits 2. The runnable filesystem example preserves application failure with exit 1. Messages and opt-in stacks can contain private data; this library does not redact them or run fixes. + +## Stable v1 contract + +Node 22.13+ or Node 24. MIT licensed. CLI flags, structured fields, ESM/CommonJS exports and declarations are covered by tests and installed-package checks. Existing 0.4 APIs remain available except the explicitly documented doggo-log redaction/text-context changes. Future incompatible public API changes require a major release; callers should consume structured fields rather than parse jokes. Exact humorous wording and seeded catalog choices are version-specific. No telemetry, external API keys or network service is needed for core use. + +Run npm test, npm run lint, npm run format:check, npm run coverage, npm run test:types and npm run verify:package from a source checkout. Runnable examples are shipped under examples/. The root demo is https://deployanyway.github.io/. + ## Browse the debugging library (0.4.0) 46 built-in definitions cover filesystem, permissions, networking, module loading, streams, HTTP response lifecycle and JavaScript errors. Each has a specific explanation, likely causes and practical checks. Rubber-duck commentary has a distinct original line for every supported definition. No commands run automatically and unknown errors remain explicitly unrecognized. diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 3810819..b15961d 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,9 +1,11 @@ -# 0.4.0 — 2026-10-08 +# 1.0.0 — Explain This Failure: real errors and cause chains -46 distinct error definitions with explanations, causes and practical checks; original duck commentary for each; structured errorCatalog API and --catalog CLI. +Explain real Node.js errors and cause chains with practical debugging checks. The stack trace has chosen violence; the duck brought context. -Requires Node 22.13+ or 24. See README for copyable CLI and API examples. All required source, type, coverage and installed archive checks passed locally; CI must pass on the final PR head before merging. Original content, MIT code, no backend calls from the libraries. +diagnoseError/renderDiagnosis and diagnostic CLI flags are additive. Explicit code precedence and existing catalog/batch behavior remain. Use diagnosis.original to hand the same error to your existing handler; serializing a diagnosis omits that reference. -## Compatibility +Install: `npm install @deployanyway/error-translator@1.0.0` -Existing APIs remain available; new modes are explicit. Expanded error matching may recognize codes that were previously unknown. +See README for runnable API/CLI examples, supported formats, defaults and limitations. Existing catalogs remain. Core APIs require no online services. Root demo: https://deployanyway.github.io/. + +Validation is recorded in the v1 release report after final CI and installed-package verification. diff --git a/examples/explain-failure.mjs b/examples/explain-failure.mjs new file mode 100644 index 0000000..e5e966a --- /dev/null +++ b/examples/explain-failure.mjs @@ -0,0 +1,11 @@ +import { readFileSync } from "node:fs"; +import { diagnoseError, renderDiagnosis } from "@deployanyway/error-translator"; +const file = process.argv[2] ?? "missing-config.json"; +try { + readFileSync(file, "utf8"); + console.log("Config read successfully."); +} catch (cause) { + const failure = new Error("Application config could not be read", { cause }); + console.error(renderDiagnosis(diagnoseError(failure))); + process.exitCode = 1; +} diff --git a/index.d.cts b/index.d.cts index 8f44227..b20e998 100644 --- a/index.d.cts +++ b/index.d.cts @@ -1,5 +1,11 @@ -export type ErrorInput = - string | Error | { code?: string; name?: string; message?: string }; +export interface ErrorRecord { + code?: string; + name?: string; + message?: string; + stack?: string; + cause?: unknown; +} +export type ErrorInput = string | Error | ErrorRecord; export interface TranslationOptions { mode?: "plain" | "rubber-duck"; } @@ -23,3 +29,26 @@ export function renderTranslation(result: Translation): string; export function listErrors(): string[]; export function errorCatalog(options?: TranslationOptions): Translation[]; +export interface DiagnosisOptions extends TranslationOptions { + maxDepth?: number; + includeStack?: boolean; +} +export interface DiagnosticLayer { + depth: number; + name: string; + message: string; + code?: string; + translation: Translation; + stack?: string; +} +export interface Diagnosis { + readonly original: ErrorInput; + chain: DiagnosticLayer[]; + stopped: "complete" | "cycle" | "depth-limit"; + truncated: boolean; +} +export function diagnoseError( + error: ErrorInput, + options?: DiagnosisOptions, +): Diagnosis; +export function renderDiagnosis(result: Diagnosis): string; diff --git a/index.d.ts b/index.d.ts index 8f44227..b20e998 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,5 +1,11 @@ -export type ErrorInput = - string | Error | { code?: string; name?: string; message?: string }; +export interface ErrorRecord { + code?: string; + name?: string; + message?: string; + stack?: string; + cause?: unknown; +} +export type ErrorInput = string | Error | ErrorRecord; export interface TranslationOptions { mode?: "plain" | "rubber-duck"; } @@ -23,3 +29,26 @@ export function renderTranslation(result: Translation): string; export function listErrors(): string[]; export function errorCatalog(options?: TranslationOptions): Translation[]; +export interface DiagnosisOptions extends TranslationOptions { + maxDepth?: number; + includeStack?: boolean; +} +export interface DiagnosticLayer { + depth: number; + name: string; + message: string; + code?: string; + translation: Translation; + stack?: string; +} +export interface Diagnosis { + readonly original: ErrorInput; + chain: DiagnosticLayer[]; + stopped: "complete" | "cycle" | "depth-limit"; + truncated: boolean; +} +export function diagnoseError( + error: ErrorInput, + options?: DiagnosisOptions, +): Diagnosis; +export function renderDiagnosis(result: Diagnosis): string; diff --git a/package-lock.json b/package-lock.json index 661b1be..7d7c0f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@deployanyway/error-translator", - "version": "0.4.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@deployanyway/error-translator", - "version": "0.4.0", + "version": "1.0.0", "license": "MIT", "bin": { "error-translator": "bin/cli.js" diff --git a/package.json b/package.json index 97a575d..af94eef 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@deployanyway/error-translator", - "version": "0.4.0", - "description": "Plain-English Node.js error explanations and debugging tips, because the stack trace has chosen violence.", + "version": "1.0.0", + "description": "Explain real Node.js errors and cause chains with practical debugging checks. The stack trace has chosen violence; the duck brought context.", "type": "module", "exports": { ".": { @@ -27,7 +27,8 @@ "dist", "index.d.ts", "index.d.cts", - "MIGRATION.md" + "MIGRATION.md", + "examples" ], "engines": { "node": ">=22.13" @@ -74,7 +75,9 @@ "plain-english", "typescript", "humor", - "deployanyway" + "deployanyway", + "error-cause", + "error-handling" ], "main": "./dist/index.cjs", "types": "./index.d.ts", diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 33810d2..ee8e586 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -108,6 +108,28 @@ try { ], temp, ); + assert.ok( + pack.files.some((f) => f.path === "examples/explain-failure.mjs"), + "Runnable example must ship", + ); + assert.equal( + run( + [ + "--input-type=module", + "-e", + "import * as api from '@deployanyway/error-translator';const error=new Error('Wrapped',{cause:Object.assign(new Error('Missing file'),{code:'ENOENT'})}); const r=api.diagnoseError(error); if(r.original!==error||r.chain[1].code!=='ENOENT')throw new Error('Cause lost'); console.log(JSON.stringify(r));", + ], + temp, + ), + run( + [ + "--input-type=commonjs", + "-e", + "const api=require('@deployanyway/error-translator');const error=new Error('Wrapped',{cause:Object.assign(new Error('Missing file'),{code:'ENOENT'})}); const r=api.diagnoseError(error); if(r.original!==error||r.chain[1].code!=='ENOENT')throw new Error('Cause lost'); console.log(JSON.stringify(r));", + ], + temp, + ), + ); console.log( JSON.stringify( { diff --git a/src/cli.js b/src/cli.js index b680f7b..066cf42 100644 --- a/src/cli.js +++ b/src/cli.js @@ -7,6 +7,8 @@ import { translateErrors, listErrors, errorCatalog, + diagnoseError, + renderDiagnosis, } from "./index.js"; import { readStdin } from "./input.js"; @@ -21,12 +23,15 @@ try { batch: { type: "boolean" }, list: { type: "boolean" }, catalog: { type: "boolean" }, + diagnose: { type: "boolean" }, + "include-stack": { type: "boolean" }, + "max-depth": { type: "string" }, mode: { type: "string", default: "plain" }, }, }); if (values.help) { console.log( - 'Usage: error-translator [--json] [--mode plain|rubber-duck]\n\nTranslate errors into human-readable guidance. Without arguments, read stdin (256 KiB). --batch reads a JSON array of 1–100 errors. --list lists supported codes. --catalog prints all explanations and checks.\n\nOptions:\n -h, --help Show help\n -v, --version Show version\n --json Print a structured JSON result\n --mode plain|rubber-duck Keep useful guidance; add duck commentary\n\nExamples:\n error-translator ECONNREFUSED\n error-translator "TypeError: value is not a function" --json\n\nExit codes: 0 translation/help/version; 2 invalid arguments.', + 'Usage: error-translator [--json] [--mode plain|rubber-duck]\n\nTranslate errors into human-readable guidance. --diagnose reads a JSON error/cause record; --max-depth 1..32 bounds it; --include-stack opts into original stacks. Without arguments, read stdin (256 KiB). --batch reads a JSON array of 1–100 errors. --list lists supported codes. --catalog prints all explanations and checks.\n\nOptions:\n -h, --help Show help\n -v, --version Show version\n --json Print a structured JSON result\n --mode plain|rubber-duck Keep useful guidance; add duck commentary\n\nExamples:\n error-translator ECONNREFUSED\n error-translator "TypeError: value is not a function" --json\n\nExit codes: 0 translation/help/version; 2 invalid arguments.', ); } else if (values.version) { console.log( @@ -34,6 +39,24 @@ try { readFileSync(new URL("../package.json", import.meta.url), "utf8"), ).version, ); + } else if (values.diagnose) { + if (values.batch || values.catalog || values.list) + throw new TypeError( + "--diagnose cannot combine with --batch, --catalog or --list.", + ); + const text = positionals.length ? positionals.join(" ") : await readStdin(); + const result = diagnoseError(JSON.parse(text), { + mode: values.mode, + includeStack: values["include-stack"] ?? false, + ...(values["max-depth"] !== undefined + ? { maxDepth: Number(values["max-depth"]) } + : {}), + }); + console.log( + values.json ? JSON.stringify(result, null, 2) : renderDiagnosis(result), + ); + } else if (values["include-stack"] || values["max-depth"] !== undefined) { + throw new TypeError("--include-stack and --max-depth require --diagnose."); } else if (values.catalog) { if (positionals.length || values.batch || values.list) throw new TypeError( diff --git a/src/diagnostics.js b/src/diagnostics.js new file mode 100644 index 0000000..2e38dac --- /dev/null +++ b/src/diagnostics.js @@ -0,0 +1,79 @@ +import { translateError, renderTranslation } from "./index.js"; + +/** Snapshot a bounded cause chain; preserve the original without serializing it. */ +export function diagnoseError(error, options = {}) { + if (!options || typeof options !== "object" || Array.isArray(options)) + throw new TypeError("Options must be an object."); + const maxDepth = options.maxDepth ?? 8, + includeStack = options.includeStack ?? false; + if (!Number.isInteger(maxDepth) || maxDepth < 1 || maxDepth > 32) + throw new RangeError("maxDepth must be an integer from 1 to 32."); + if (typeof includeStack !== "boolean") + throw new TypeError("includeStack must be a boolean."); + // Validate the root using the established translation contract. + translateError(error, options); + const seen = new Set(), + chain = []; + let current = error, + stopped = "complete"; + while (current !== undefined) { + if (chain.length === maxDepth) { + stopped = "depth-limit"; + break; + } + if (current !== null && typeof current === "object") { + if (seen.has(current)) { + stopped = "cycle"; + break; + } + seen.add(current); + } + const object = + current !== null && typeof current === "object" && !Array.isArray(current) + ? current + : undefined; + const raw = typeof current === "string" ? current : object?.message; + const message = typeof raw === "string" ? raw : String(current); + const name = typeof object?.name === "string" ? object.name : "Error"; + const code = typeof object?.code === "string" ? object.code : undefined; + const translation = translateError( + { name, message, ...(code ? { code } : {}) }, + options, + ); + chain.push({ + depth: chain.length, + name, + message, + ...(code ? { code } : {}), + translation, + ...(includeStack && typeof object?.stack === "string" + ? { stack: object.stack } + : {}), + }); + current = object?.cause; + } + const result = { chain, stopped, truncated: stopped !== "complete" }; + Object.defineProperty(result, "original", { + value: error, + enumerable: false, + }); + return result; +} +export function renderDiagnosis(result) { + if ( + !result || + !Array.isArray(result.chain) || + !result.chain.length || + !["complete", "cycle", "depth-limit"].includes(result.stopped) + ) + throw new TypeError("Provide a diagnosis result."); + return ( + result.chain + .map( + (item, index) => + `${index === 0 ? "Failure" : "Caused by"}: ${item.name}${item.code ? " [" + item.code + "]" : ""}: ${item.message}\n${renderTranslation(item.translation)}${item.stack ? "\n\nOriginal stack:\n" + item.stack : ""}`, + ) + .join("\n\n---\n\n") + + (result.truncated ? `\n\nCause traversal stopped: ${result.stopped}.` : "") + ); +} diff --git a/src/index.js b/src/index.js index a20ed7e..b22be53 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,5 @@ import { definitions } from "./definitions.js"; +export { diagnoseError, renderDiagnosis } from "./diagnostics.js"; export const listErrors = () => Object.keys(definitions); /** Translate an ordered, bounded batch without mutating input. */ diff --git a/test/diagnostics.test.js b/test/diagnostics.test.js new file mode 100644 index 0000000..293359c --- /dev/null +++ b/test/diagnostics.test.js @@ -0,0 +1,88 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { URL } from "node:url"; +import { spawnSync } from "node:child_process"; +import { diagnoseError, renderDiagnosis } from "@deployanyway/error-translator"; + +test("real filesystem failure remains intact while cause guidance explains the underlying failure", () => { + let cause; + try { + readFileSync( + new URL("./definitely-missing-v1-fixture.txt", import.meta.url), + ); + } catch (error) { + cause = error; + } + const original = new Error("Could not load application config", { cause }); + const result = diagnoseError(original); + assert.equal(result.original, original); + assert.equal(original.cause, cause); + assert.equal(result.chain[1].code, "ENOENT"); + assert.match(result.chain[1].translation.suggestions.join(" "), /path/); + assert.equal(result.chain[0].translation.code, "UNKNOWN"); + assert.equal(result.chain[0].stack, undefined); + assert.doesNotThrow(() => JSON.stringify(result)); + assert.doesNotMatch(JSON.stringify(result), /"original"/); + assert.match(renderDiagnosis(result), /Caused by: Error \[ENOENT\]/); + assert.equal( + diagnoseError(original, { includeStack: true }).chain[1].stack, + cause.stack, + ); +}); +test("bounded cycle traversal and unknown primitive causes remain explicit", () => { + const cycle = new Error("outer"); + cycle.cause = cycle; + const result = diagnoseError(cycle); + assert.equal(result.stopped, "cycle"); + assert.equal(result.chain.length, 1); + assert.match(renderDiagnosis(result), /cycle/); + assert.doesNotThrow(() => JSON.stringify(result)); + const chain = new Error("one", { + cause: new Error("two", { cause: new Error("three") }), + }); + assert.equal(diagnoseError(chain, { maxDepth: 2 }).stopped, "depth-limit"); + for (const cause of [ + null, + 42, + false, + ["odd"], + { message: "Unknown", code: "CUSTOM" }, + ]) { + const diagnosed = diagnoseError(new Error("wrapper", { cause })); + assert.equal(diagnosed.chain.length, 2); + assert.equal(diagnosed.chain[1].translation.title, "Unrecognized error"); + } + assert.equal(diagnoseError("ENOENT").chain[0].translation.code, "ENOENT"); + assert.match( + renderDiagnosis( + diagnoseError( + { name: "TypeError", message: "Bad value", stack: "private stack" }, + { includeStack: true, mode: "rubber-duck" }, + ), + ), + /Original stack/, + ); +}); +test("invalid options/root/rendering fail and diagnose CLI handles JSON cause input", () => { + for (const maxDepth of [0, 33, 1.5, "2"]) + assert.throws(() => diagnoseError("x", { maxDepth }), RangeError); + assert.throws(() => diagnoseError("x", { includeStack: 1 }), TypeError); + assert.throws(() => diagnoseError("x", null), TypeError); + assert.throws(() => diagnoseError({}), TypeError); + assert.throws(() => renderDiagnosis({ chain: [] }), TypeError); + const run = (args) => + spawnSync(process.execPath, ["bin/cli.js", ...args], { + input: JSON.stringify({ + name: "Error", + message: "Fetch failed", + cause: { code: "ECONNREFUSED", message: "No listener" }, + }), + encoding: "utf8", + }); + const cli = run(["--diagnose", "--json"]); + assert.equal(cli.status, 0, cli.stderr); + assert.equal(JSON.parse(cli.stdout).chain[1].code, "ECONNREFUSED"); + assert.equal(run(["--diagnose", "--batch"]).status, 2); + assert.equal(run(["--max-depth", "2"]).status, 2); +}); diff --git a/test/index.test.js b/test/index.test.js index a043c19..445effa 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -92,7 +92,7 @@ test("CLI text, JSON, help, version, unknown errors", () => { ); assert.equal(cli("--help").status, 0); assert.ok(cli("--help").stdout.includes("Usage:")); - assert.equal(cli("--version").stdout.trim(), "0.4.0"); + assert.equal(cli("--version").stdout.trim(), "1.0.0"); assert.equal(cli("custom error").status, 0); }); test("CLI invalid arguments exit 2 with stderr", () => { diff --git a/test/types.cts b/test/types.cts index d09a40b..5f9e5fb 100644 --- a/test/types.cts +++ b/test/types.cts @@ -5,3 +5,5 @@ api.translateErrors(["ENOENT", new Error("TypeError")], { // @ts-expect-error invalid literal api.translateError("x", { mode: "pirate" }); api.errorCatalog({ mode: "plain" })[0].suggestions; +import diagnosis = require("@deployanyway/error-translator"); +diagnosis.diagnoseError(new Error("failed")).original; diff --git a/test/types.mts b/test/types.mts index 8bce457..7c7eb15 100644 --- a/test/types.mts +++ b/test/types.mts @@ -6,3 +6,10 @@ api.translateErrors(["ENOENT", new Error("TypeError")], { api.translateError("x", { mode: "pirate" }); import { errorCatalog } from "@deployanyway/error-translator"; errorCatalog({ mode: "rubber-duck" })[0].suggestions; +import { diagnoseError, renderDiagnosis } from "@deployanyway/error-translator"; +const diagnosed = diagnoseError( + new Error("outer", { cause: new Error("inner") }), + { includeStack: false, maxDepth: 4 }, +); +renderDiagnosis(diagnosed); +diagnoseError({ message: "failure", cause: { code: "ENOENT" } });