From 9c6b5c581efe5bef1b3c18661e90e73a3ae636cd Mon Sep 17 00:00:00 2001 From: Todd Wolfe Date: Thu, 8 Oct 2026 21:56:19 -0400 Subject: [PATCH] Show equal practical v1 integrations across all five root demos --- .github/workflows/pages.yml | 2 +- README.md | 6 +- app-1.0.0.js | 126 ++++++ dist/browser.js | 79 +++- index.html | 13 +- siblings-1.0.0.js | 511 +++++++++++++++++++++++++ style-54d303f2.css | 25 ++ test/site.test.js | 4 +- test/value.test.js | 36 ++ value-1.0.0.js | 423 ++++++++++++++++++++ vendor/doggo-log/index.js | 28 +- vendor/doggo-log/redaction.js | 73 ++++ vendor/error-translator/diagnostics.js | 79 ++++ vendor/error-translator/index.js | 1 + vendor/excuse-js/incident.js | 83 ++++ vendor/excuse-js/index.js | 1 + vendor/ship-it-meter/index.js | 5 + vendor/ship-it-meter/reports.js | 262 +++++++++++++ 18 files changed, 1739 insertions(+), 18 deletions(-) create mode 100644 app-1.0.0.js create mode 100644 siblings-1.0.0.js create mode 100644 test/value.test.js create mode 100644 value-1.0.0.js create mode 100644 vendor/doggo-log/redaction.js create mode 100644 vendor/error-translator/diagnostics.js create mode 100644 vendor/excuse-js/incident.js create mode 100644 vendor/ship-it-meter/reports.js diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 0aa09cc..a31c161 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 24 - - run: node --check app-0.4.0.js && node --check siblings-0.4.0.js && node --check candidate/redirect.js + - run: node --check app-1.0.0.js && node --check siblings-1.0.0.js && node --check value-1.0.0.js && node --check candidate/redirect.js - run: node --test test/*.test.js deploy: if: github.event_name != 'pull_request' diff --git a/README.md b/README.md index 189a8d0..0d6f69d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Public interactive demo: https://deployanyway.github.io/ -Static HTML, CSS and browser ES modules. No analytics, accounts or backend. Inputs stay in the browser. The five demos use vendored 0.4.0 package source; the dog logger uses a single-string `node:util` adapter because the form does not accept formatting arguments. This adapter is not a replacement for the npm library's full Node formatting behavior. +Static HTML, CSS and browser ES modules. No analytics, accounts or backend. Inputs stay in the browser. The five demos use vendored 1.0.0 package source; the dog logger uses a single-string `node:util` adapter because the form does not accept formatting arguments. This adapter is not a replacement for the npm library's full Node formatting behavior. ## Develop @@ -25,3 +25,7 @@ The organization palette and home badge follow the supplied husky artwork. Dalla ## License MIT. Vendored package licenses are included in their directories. + +## Practical v1 integrations + +Each tool keeps its playful library and adds an equally visible developer-value panel: measured build summaries, Error cause diagnostics, accountable incident drafts, context redaction plus a runnable Node async-scope example, and report-backed release policies. The browser runs actual pure package APIs. AsyncLocalStorage, file reading, CI report collection and process exit preservation run in Node; examples identify those boundaries. Demo receipt data is labeled sample data, never claimed as real CI evidence. diff --git a/app-1.0.0.js b/app-1.0.0.js new file mode 100644 index 0000000..c7e6571 --- /dev/null +++ b/app-1.0.0.js @@ -0,0 +1,126 @@ +import { + renderBro, + listCharacters, + moods, + listThemes, + broMessage, + messageCategories, + messagePresets, +} from "./dist/browser.js"; + +const element = (id) => document.getElementById(id); +for (const [id, names, selected] of [ + ["character", listCharacters(), "husky"], + ["mood", moods(), "sarcastic"], + ["theme", listThemes(), "classic"], +]) { + for (const name of names) { + const option = document.createElement("option"); + option.value = option.textContent = name; + option.selected = name === selected; + element(id).append(option); + } +} +const presetLabel = document.createElement("label"); +presetLabel.textContent = "Message source"; +const presetSelect = document.createElement("select"); +presetSelect.id = "bro-preset"; +for (const value of ["Your message", ...messageCategories()]) { + const option = document.createElement("option"); + option.value = option.textContent = value; + presetSelect.append(option); +} +presetLabel.append(presetSelect); +element("controls").prepend(presetLabel); +const presetList = document.createElement("pre"); +presetList.className = "preset-library"; +presetList.setAttribute("aria-label", "Message preset library"); +element("controls").after(presetList); +function render() { + try { + const random = element("bro-selection").value === "random"; + for (const id of ["character", "mood", "theme"]) + element(id).disabled = random; + const preset = element("bro-preset").value; + const generated = preset !== "Your message"; + element("message").disabled = generated; + element("bro-seed").disabled = !random && !generated; + const text = generated + ? broMessage(preset, { seed: element("bro-seed").value }) + : element("message").value; + presetList.hidden = !generated; + presetList.textContent = generated + ? "All six " + + preset + + " presets:\n" + + messagePresets(preset) + .map((line, i) => i + 1 + ". " + line) + .join("\n") + : ""; + const result = renderBro({ + text, + character: random ? undefined : element("character").value, + mood: random ? undefined : element("mood").value, + theme: random ? undefined : element("theme").value, + mode: element("mode").value, + width: Number(element("width").value), + wrap: element("bro-wrap").checked, + ...(element("bro-layout").value === "plain" + ? { layout: "plain" } + : element("bro-layout").value === "box" + ? { box: true } + : {}), + ...(random ? { random: true, seed: element("bro-seed").value } : {}), + }); + element("output").textContent = + element("bro-format").value === "json" + ? JSON.stringify(result, null, 2) + : result.rendered; + const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'"; + element("bro-command").textContent = + `npx @deployanyway/bro-say@1.0.0 ${generated ? `--preset ${preset}` : quote(text)} --width ${element("width").value}` + + (random + ? ` --random --seed ${quote(element("bro-seed").value)}` + : `${generated ? ` --seed ${quote(element("bro-seed").value)}` : ""} --character ${element("character").value} --mood ${element("mood").value} --theme ${element("theme").value}`) + + (element("mode").value === "think" ? " --think" : "") + + (element("bro-format").value === "json" ? " --json" : "") + + (!element("bro-wrap").checked ? " --no-wrap" : "") + + (element("bro-layout").value === "plain" + ? " --plain" + : element("bro-layout").value === "box" + ? " --box" + : ""); + element("status").textContent = ""; + element("copy").disabled = false; + element("bro-copy-command").disabled = false; + } catch (error) { + element("status").textContent = error.message; + element("output").textContent = ""; + element("copy").disabled = true; + element("bro-copy-command").disabled = true; + element("bro-command").textContent = ""; + } +} +element("controls").addEventListener("input", render); +element("controls").addEventListener("submit", (event) => + event.preventDefault(), +); +element("copy").addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(element("output").textContent); + element("status").textContent = "Copied. Ship the message responsibly."; + } catch { + element("status").textContent = + "Clipboard unavailable. Select and copy the output manually."; + } +}); +render(); + +element("bro-copy-command").addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(element("bro-command").textContent); + element("status").textContent = "Command copied."; + } catch { + element("status").textContent = "Select and copy the command manually."; + } +}); diff --git a/dist/browser.js b/dist/browser.js index 4f66eb5..d1e2796 100644 --- a/dist/browser.js +++ b/dist/browser.js @@ -1495,6 +1495,81 @@ function broMessage(category = "deployment", options = {}) { "message:" + category.trim().toLowerCase() ); } + +// src/build-summary.js +var clean = (value) => Array.from(value).filter( + (char) => char.charCodeAt(0) >= 32 || char === "\n" || char === " " +).join(""); +var count = (value, label) => { + if (!Number.isSafeInteger(value) || value < 0) + throw new TypeError(`${label} must be a nonnegative safe integer.`); + return value; +}; +function summarizeBuild(input = {}) { + if (!input || typeof input !== "object" || Array.isArray(input)) + throw new TypeError("Build input must be an object."); + const result = { label: "Build", status: "unknown" }; + for (const key of ["label", "commit"]) + if (input[key] !== void 0) { + if (typeof input[key] !== "string" || !input[key].trim() || input[key].length > 200) + throw new TypeError( + `${key} must be a nonempty string up to 200 characters.` + ); + result[key] = clean(input[key].trim()); + } + if (input.exitCode !== void 0) { + result.exitCode = input.exitCode === null ? null : count(input.exitCode, "exitCode"); + if (input.exitCode !== null) + result.status = input.exitCode === 0 ? "passed" : "failed"; + } + if (input.durationMs !== void 0) { + if (typeof input.durationMs !== "number" || !Number.isFinite(input.durationMs) || input.durationMs < 0) + throw new TypeError("durationMs must be a nonnegative finite number."); + result.durationMs = input.durationMs; + } + if (input.tests !== void 0) { + if (!input.tests || typeof input.tests !== "object" || Array.isArray(input.tests)) + throw new TypeError("tests must contain measured counts."); + result.tests = { + passed: count(input.tests.passed, "tests.passed"), + failed: count(input.tests.failed, "tests.failed"), + skipped: count(input.tests.skipped ?? 0, "tests.skipped") + }; + if (result.tests.failed > 0) result.status = "failed"; + } + const failures = input.failures ?? []; + if (!Array.isArray(failures) || failures.length > 20 || !failures.every( + (item) => typeof item === "string" && item.trim() && item.length <= 2e3 + )) + throw new TypeError( + "failures must contain up to 20 nonempty strings of at most 2000 characters." + ); + result.failures = failures.map(clean); + if (failures.length) result.status = "failed"; + result.text = [ + `${result.label}: ${result.status.toUpperCase()}`, + `Build exit: ${result.exitCode === void 0 || result.exitCode === null ? "not provided" : result.exitCode}`, + result.tests ? `Tests: ${result.tests.passed} passed, ${result.tests.failed} failed, ${result.tests.skipped} skipped` : "Tests: not provided", + result.durationMs !== void 0 ? `Duration: ${result.durationMs} ms` : "Duration: not provided", + result.commit ? `Commit: ${result.commit}` : "Commit: not provided", + ...result.failures.length ? ["Failures:", ...result.failures.map((item) => "- " + item)] : [] + ].join("\n"); + return result; +} +function renderBuildSummary(input = {}, options = {}) { + if (!options || typeof options !== "object" || Array.isArray(options)) + throw new TypeError("Options must be an object."); + const { format = "character", ...broOptions } = options; + if (!["character", "plain"].includes(format)) + throw new RangeError("Summary format must be character or plain."); + const summary = summarizeBuild(input); + const rendered = format === "plain" ? summary.text : renderBro({ + ...broOptions, + text: summary.text, + mood: broOptions.mood ?? (summary.status === "passed" ? "benji" : summary.status === "failed" ? "on-call" : "skeptic") + }).rendered; + return { status: summary.status, summary, rendered }; +} export { broMessage, brosay2 as brosay, @@ -1505,5 +1580,7 @@ export { messageCategories, messagePresets, moods, - renderBro + renderBro, + renderBuildSummary, + summarizeBuild }; diff --git a/index.html b/index.html index 4f4abda..bd06592 100644 --- a/index.html +++ b/index.html @@ -12,12 +12,13 @@ - + - - + + +
@@ -31,7 +32,7 @@ width="220" height="220" /> -

0.4.0 · AVAILABLE ON NPM

+

1.0.0 · AVAILABLE ON NPM

DeployAnyway.

Software nobody requested, built with questionable priorities, and @@ -97,7 +98,7 @@

Serious about the work. Playful about the day.

>

- All five packages are AVAILABLE ON NPM at version 0.4.0. These demos run + All five packages are AVAILABLE ON NPM at version 1.0.0. These demos run locally in your browser.

@@ -171,7 +172,7 @@

bro-say

Runs entirely in your browser. No message is sent anywhere.

- npm install -g @deployanyway/bro-say@0.4.0 · + npm install -g @deployanyway/bro-say@1.0.0 · All five packages on npm diff --git a/siblings-1.0.0.js b/siblings-1.0.0.js new file mode 100644 index 0000000..04d3b3f --- /dev/null +++ b/siblings-1.0.0.js @@ -0,0 +1,511 @@ +import { + errorCatalog, + listErrors, + translateError, + translateErrors, + renderTranslation, +} from "./vendor/error-translator/index.js"; +import { + listExcuses, + categories, + excuse, + excuseBatch, + excuseReport, +} from "./vendor/excuse-js/index.js"; +import { barkLines, createDogLogger } from "./vendor/doggo-log/index.js"; +import { + releasePlan, + releaseScenarios, + scenarioEvidence, + releaseGate, + preflight, + shipIt, +} from "./vendor/ship-it-meter/index.js"; +const el = (id) => document.getElementById(id); +const q = (value) => "'" + String(value).replaceAll("'", "'\\''") + "'"; +const select = (id, label, values, selected) => { + const wrap = document.createElement("label"); + wrap.textContent = label; + const input = document.createElement("select"); + input.id = id; + for (const value of values) { + const option = document.createElement("option"); + option.value = option.textContent = value; + option.selected = value === selected; + input.append(option); + } + wrap.append(input); + return wrap; +}; +const field = (id, label, value, type = "text") => { + const wrap = document.createElement("label"); + wrap.textContent = label; + const input = document.createElement("input"); + input.id = id; + input.type = type; + input.value = value; + if (type === "number") input.min = "0"; + wrap.append(input); + return wrap; +}; +const check = (id, label, value = true) => { + const wrap = field(id, label, "", "checkbox"); + wrap.firstElementChild.checked = value; + return wrap; +}; +function setup(section, button, output, fields, run) { + const form = document.createElement("form"); + form.className = "demo-controls"; + form.append(...fields); + el(button).before(form); + form.addEventListener("submit", (event) => event.preventDefault()); + const caption = document.createElement("p"); + caption.textContent = "Try it in your terminal (sh/bash):"; + const command = document.createElement("pre"); + command.className = "command"; + command.setAttribute("aria-label", `${section} command`); + const copy = document.createElement("button"); + copy.type = "button"; + copy.textContent = "Copy command"; + const status = document.createElement("p"); + status.setAttribute("role", "status"); + el(output).after(caption, command, copy, status); + copy.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(command.textContent); + status.textContent = "Command copied."; + } catch { + status.textContent = "Select and copy the command manually."; + } + }); + const render = () => { + try { + const result = run(); + caption.textContent = + result.caption || "Try it in your terminal (sh/bash):"; + copy.textContent = result.caption ? "Copy example" : "Copy command"; + el(output).textContent = result.output; + command.textContent = result.command; + copy.disabled = false; + status.textContent = ""; + } catch (error) { + el(output).textContent = "Input needs attention: " + error.message; + command.textContent = ""; + copy.disabled = true; + } + }; + el(button).addEventListener("click", render); + form.addEventListener("input", render); + render(); +} +// Catalogs are read from the actual package, so the choices match its capabilities. +setup( + "error-translator", + "translate", + "translations", + [ + select( + "error-code", + "Supported code / custom input", + [...listErrors(), "Custom message", "JSON batch", "Full catalog"], + "ENOENT", + ), + select( + "error-mode", + "Explanation style", + ["plain", "rubber-duck"], + "rubber-duck", + ), + select("error-format", "Output format", ["readable", "JSON"], "readable"), + ], + () => { + const code = el("error-code").value, + batch = code === "JSON batch"; + el("errors").parentElement.hidden = ![ + "Custom message", + "JSON batch", + ].includes(code); + el("errors").disabled = !["Custom message", "JSON batch"].includes(code); + el("errors").parentElement.firstChild.textContent = batch + ? "Error batch (JSON array)" + : "Custom error message"; + const input = batch + ? JSON.parse(el("errors").value) + : code === "Custom message" + ? el("errors").value + : code; + const options = { mode: el("error-mode").value }; + const catalog = code === "Full catalog"; + const result = catalog + ? errorCatalog(options) + : batch + ? translateErrors(input, options) + : translateError(input, options); + return { + output: + el("error-format").value === "JSON" + ? JSON.stringify(result, null, 2) + : batch || catalog + ? result.map(renderTranslation).join("\n\n---\n\n") + : renderTranslation(result), + command: + (batch ? `printf '%s' ${q(el("errors").value)} | ` : "") + + `npx @deployanyway/error-translator@1.0.0 ${catalog ? "--catalog" : batch ? "--batch" : q(input)} --mode ${options.mode}` + + (el("error-format").value === "JSON" ? " --json" : ""), + }; + }, +); +for (const category of categories()) { + const option = document.createElement("option"); + option.value = option.textContent = category; + el("excuse-category").append(option); +} +setup( + "excuse-js", + "excuse-report", + "excuse-output", + [ + select( + "excuse-mode", + "Generate", + ["report", "single", "batch", "catalog"], + "report", + ), + field("excuse-seed", "Seed (blank = random)", "demo"), + field("excuse-count", "Batch count (1–20)", "3", "number"), + select("excuse-format", "Output format", ["readable", "JSON"], "readable"), + ], + () => { + const category = el("excuse-category").value, + mode = el("excuse-mode").value, + seed = el("excuse-seed").value, + options = seed ? { seed } : {}; + el("excuse-count").disabled = mode !== "batch"; + el("excuse-seed").disabled = mode === "catalog"; + const result = + mode === "catalog" + ? listExcuses(category) + : mode === "report" + ? excuseReport(category, options) + : mode === "batch" + ? excuseBatch(category, { + ...options, + count: Number(el("excuse-count").value), + }) + : excuse(category, options); + return { + output: + el("excuse-format").value === "JSON" + ? JSON.stringify(result, null, 2) + : mode === "report" + ? result.excuse + "\n\nNext step: " + result.nextStep + : Array.isArray(result) + ? result.map((item, i) => `${i + 1}. ${item}`).join("\n") + : result, + command: + `npx @deployanyway/excuse-js@1.0.0 ${category}` + + (seed && mode !== "catalog" ? ` --seed ${q(seed)}` : "") + + (mode === "catalog" + ? " --catalog" + : mode === "report" + ? " --report" + : mode === "batch" + ? ` --count ${el("excuse-count").value}` + : "") + + (el("excuse-format").value === "JSON" ? " --json" : ""), + }; + }, +); +el("excuse-category").addEventListener("change", () => + el("excuse-report").click(), +); +setup( + "doggo-log", + "log-context", + "log-output", + [ + select( + "dog-demo", + "Demonstration", + ["one log", "eight-log sequence", "commentary catalog"], + "one log", + ), + select("dog-bark-mode", "Commentary mode", ["classic", "rotate"], "rotate"), + field("dog-seed", "Commentary seed", "dallas"), + field("dog-message", "Message", "Evidence fetched. Good dog."), + select( + "dog-method", + "Log method", + ["log", "debug", "info", "success", "warn", "error"], + "info", + ), + select( + "dog-level", + "Minimum level", + ["debug", "log", "info", "success", "warn", "error"], + "debug", + ), + field("dog-prefix", "Prefix", "api"), + field( + "dog-context", + "Additional context (JSON scalar fields)", + '{"environment":"demo"}', + ), + select("dog-format", "Output format", ["JSON", "text"], "JSON"), + check("dog-bark", "Dog commentary"), + check("dog-emoji", "Emoji"), + check("dog-time", "Timestamp", false), + check("dog-quiet", "Quiet (suppress output)", false), + ], + () => { + const rawContext = JSON.parse(el("dog-context").value); + if ( + !rawContext || + typeof rawContext !== "object" || + Array.isArray(rawContext) + ) + throw new TypeError("Context must be a JSON object of scalar fields."); + const context = { ...rawContext, requestId: el("request-id").value }; + const config = { + context, + prefix: el("dog-prefix").value, + level: el("dog-level").value, + json: el("dog-format").value === "JSON", + bark: el("dog-bark").checked, + barkMode: el("dog-bark-mode").value, + seed: el("dog-seed").value, + emoji: el("dog-emoji").checked, + timestamp: el("dog-time").checked, + quiet: el("dog-quiet").checked, + write: () => {}, + }; + const method = el("dog-method").value, + demo = el("dog-demo").value; + if (demo === "commentary catalog") + return { + output: barkLines(method) + .map((line, i) => i + 1 + ". " + line) + .join("\n"), + caption: + "Install @deployanyway/doggo-log@1.0.0; save as demo.mjs and run node demo.mjs:", + command: + "import { barkLines } from '@deployanyway/doggo-log';\nconsole.log(barkLines(" + + JSON.stringify(method) + + ").join('\\n'));", + }; + const logger = createDogLogger(config); + if (demo === "eight-log sequence") { + const results = Array.from({ length: 8 }, () => + logger[method](el("dog-message").value), + ); + const codeConfig = { ...config }; + delete codeConfig.write; + return { + output: + results.filter((value) => value !== undefined).join("\n") || + "No logs emitted: quiet mode or minimum level filtered these messages.", + caption: + "Install @deployanyway/doggo-log@1.0.0; save as demo.mjs and run node demo.mjs:", + command: + "import { createDogLogger } from '@deployanyway/doggo-log';\nconst log = createDogLogger(" + + JSON.stringify(codeConfig, null, 2) + + ");\nfor (let i = 0; i < 8; i++) log." + + method + + "(" + + JSON.stringify(el("dog-message").value) + + ");", + }; + } + const result = logger[method](el("dog-message").value); + return { + output: + result === undefined + ? "No log emitted: quiet mode or the minimum level filtered this message." + : config.json + ? JSON.stringify(JSON.parse(result), null, 2) + : result, + command: + `npx @deployanyway/doggo-log@1.0.0 ${el("dog-method").value} ${q(el("dog-message").value)} --level ${config.level} --prefix ${q(config.prefix)} --context ${q(JSON.stringify(context))}` + + (config.json ? " --json" : "") + + (config.bark ? " --bark" : "") + + ` --bark-mode ${config.barkMode} --seed ${q(config.seed)}` + + (!config.emoji ? " --no-emoji" : "") + + (config.timestamp ? " --timestamp" : "") + + (config.quiet ? " --quiet" : ""), + }; + }, +); +el("request-id").addEventListener("input", () => el("log-context").click()); +const scenarioWrap = select( + "ship-scenario", + "Example evidence scenario", + releaseScenarios().map((item) => item.name), + "ready", +); +const scenarioButton = document.createElement("button"); +scenarioButton.type = "button"; +scenarioButton.textContent = "Load scenario"; +const scenarioDescription = document.createElement("p"); +scenarioDescription.setAttribute("role", "status"); +el("evidence").parentElement.before( + scenarioWrap, + scenarioButton, + scenarioDescription, +); +scenarioWrap.addEventListener("change", () => { + scenarioDescription.textContent = releaseScenarios().find( + (item) => item.name === el("ship-scenario").value, + ).description; +}); +scenarioButton.addEventListener("click", () => { + const data = scenarioEvidence(el("ship-scenario").value); + el("evidence").value = JSON.stringify(data, null, 2); + for (const [id, key] of [ + ["ship-tests", "tests"], + ["ship-failing", "failingTests"], + ["ship-coverage", "coverage"], + ["ship-critical", "criticalIssues"], + ["ship-lint", "lintFailures"], + ["ship-branch", "branch"], + ]) + el(id).value = data[key] ?? ""; + el("ship-build").value = + data.build === undefined ? "not provided" : data.build ? "pass" : "fail"; + el("ship-day").value = data.day ?? "monday"; + el("ship-dirty").checked = data.uncommittedChanges ?? false; + scenarioDescription.textContent = releaseScenarios().find( + (item) => item.name === el("ship-scenario").value, + ).description; + el("gate").click(); +}); +const evidenceForm = document.createElement("form"); +evidenceForm.className = "demo-controls"; +const evidenceFields = [ + field("ship-tests", "Total tests", "42", "number"), + field("ship-failing", "Failing tests", "0", "number"), + field("ship-coverage", "Coverage %", "92", "number"), + select("ship-build", "Build", ["pass", "fail", "not provided"], "pass"), + field("ship-critical", "Critical issues", "0", "number"), + field("ship-lint", "Lint failures", "0", "number"), + field("ship-branch", "Branch", "main"), + select( + "ship-day", + "Weekday", + [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ], + "monday", + ), + check("ship-dirty", "Uncommitted changes", false), +]; +evidenceForm.append(...evidenceFields); +el("evidence").parentElement.before(evidenceForm); +evidenceForm.addEventListener("submit", (event) => event.preventDefault()); +evidenceForm.addEventListener("input", () => { + const data = { + tests: Number(el("ship-tests").value), + failingTests: Number(el("ship-failing").value), + coverage: Number(el("ship-coverage").value), + criticalIssues: Number(el("ship-critical").value), + lintFailures: Number(el("ship-lint").value), + branch: el("ship-branch").value, + day: el("ship-day").value, + uncommittedChanges: el("ship-dirty").checked, + }; + for (const [id, key] of [ + ["ship-tests", "tests"], + ["ship-failing", "failingTests"], + ["ship-coverage", "coverage"], + ["ship-critical", "criticalIssues"], + ["ship-lint", "lintFailures"], + ["ship-branch", "branch"], + ]) { + if (el(id).value === "") delete data[key]; + } + if (el("ship-build").value !== "not provided") + data.build = el("ship-build").value === "pass"; + el("evidence").value = JSON.stringify(data, null, 2); + el("gate").click(); +}); +setup( + "ship-it-meter", + "gate", + "gate-output", + [ + select( + "ship-mode", + "Evaluation", + ["release plan", "release gate", "preflight checklist", "score"], + "release plan", + ), + field("ship-min", "Gate minimum score (0–100)", "80", "number"), + select("ship-format", "Output format", ["readable", "JSON"], "readable"), + ], + () => { + const input = JSON.parse(el("evidence").value), + mode = el("ship-mode").value; + el("ship-min").disabled = !["release gate", "release plan"].includes(mode); + const result = + mode === "release plan" + ? releasePlan(input, { minScore: Number(el("ship-min").value) }) + : mode === "release gate" + ? releaseGate(input, { minScore: Number(el("ship-min").value) }) + : mode === "preflight checklist" + ? preflight(input) + : shipIt(input); + const text = [ + result.summary, + `Score: ${result.score}/100 — ${result.verdict}`, + result.passed === undefined + ? "" + : `Gate: ${result.passed ? "PASS" : "BLOCKED"}`, + ...(result.tasks + ? [ + "\nRELEASE TASKS", + ...result.tasks.map( + (task) => + `[${task.priority}] ${task.title}\nVerify: ${task.verify}`, + ), + ] + : []), + ...["reasons", "blockers", "actions"] + .filter((key) => result[key]?.length) + .map( + (key) => + `\n${key.toUpperCase()}\n` + + result[key].map((item) => "- " + item).join("\n"), + ), + ] + .filter(Boolean) + .join("\n"); + return { + output: + el("ship-format").value === "JSON" + ? JSON.stringify(result, null, 2) + : text, + command: + `printf '%s' ${q(el("evidence").value)} | npx @deployanyway/ship-it-meter@1.0.0 --stdin` + + (mode === "release plan" + ? ` --plan --min-score ${el("ship-min").value}` + : mode === "release gate" + ? ` --gate --min-score ${el("ship-min").value}` + : mode === "preflight checklist" + ? " --checklist" + : "") + + (el("ship-format").value === "JSON" ? " --json" : ""), + }; + }, +); +el("evidence").addEventListener("input", () => el("gate").click()); + +// Raw evidence stays editable for missing fields and API validation examples. +el("evidence").parentElement.firstChild.textContent = + "Evidence JSON (generated by controls; also directly editable)"; + +el("errors").addEventListener("input", () => el("translate").click()); diff --git a/style-54d303f2.css b/style-54d303f2.css index d835843..797628b 100644 --- a/style-54d303f2.css +++ b/style-54d303f2.css @@ -302,3 +302,28 @@ button { width: 140px; } } + +.value-demo { + margin-top: 24px; + padding: 20px; + border: 1px solid #2c5568; + border-radius: 10px; + background: #111e27; +} +.value-demo summary { + cursor: pointer; + color: #60d5ff; + font-weight: 700; + font-size: 1.15rem; +} +.value-demo textarea { + width: 100%; + min-width: 0; + box-sizing: border-box; + font-family: monospace; +} +.value-demo .terminal, +.value-demo .command { + max-width: 100%; + overflow: auto; +} diff --git a/test/site.test.js b/test/site.test.js index 73a8e26..98729a4 100644 --- a/test/site.test.js +++ b/test/site.test.js @@ -8,8 +8,8 @@ test("root loads released flagship controls and local assets", () => { assert.match(html, /id="theme"/); assert.match(html, /id="mode"/); for (const asset of [ - "app-0.4.0.js", - "siblings-0.4.0.js", + "app-1.0.0.js", + "siblings-1.0.0.js", "style-54d303f2.css", ]) { assert.ok(html.includes(asset)); diff --git a/test/value.test.js b/test/value.test.js new file mode 100644 index 0000000..515e69d --- /dev/null +++ b/test/value.test.js @@ -0,0 +1,36 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { renderBuildSummary } from "../dist/browser.js"; +import { diagnoseError } from "../vendor/error-translator/index.js"; +import { incidentUpdate } from "../vendor/excuse-js/index.js"; +import { createDogLogger } from "../vendor/doggo-log/index.js"; +import { evaluateReports } from "../vendor/ship-it-meter/index.js"; +test("v1 demo assets preserve failure, causes, missing facts, redaction and report blockers", () => { + assert.equal(renderBuildSummary({ exitCode: 1 }).status, "failed"); + assert.equal(renderBuildSummary({}).status, "unknown"); + const d = diagnoseError({ + message: "Start failed", + cause: { code: "ENOENT", message: "Missing config" }, + }); + assert.equal(d.chain[1].code, "ENOENT"); + assert.equal(incidentUpdate({}).complete, false); + assert.throws(() => incidentUpdate({}, { humor: true }), TypeError); + const line = JSON.parse( + createDogLogger({ + json: true, + context: { authorization: "secret" }, + write: () => {}, + }).info("done"), + ); + assert.equal(line.context.authorization, "[REDACTED]"); + assert.equal(evaluateReports({ commit: "demo" }).passed, false); + const html = readFileSync(new URL("../index.html", import.meta.url), "utf8"); + assert.ok(html.includes("value-1.0.0.js")); + const module = readFileSync( + new URL("../value-1.0.0.js", import.meta.url), + "utf8", + ); + assert.match(module, /not AsyncLocalStorage/); + assert.match(module, /not evidence about this site/); +}); diff --git a/value-1.0.0.js b/value-1.0.0.js new file mode 100644 index 0000000..5be8b68 --- /dev/null +++ b/value-1.0.0.js @@ -0,0 +1,423 @@ +import { + renderBuildSummary, + listCharacters, + moods, + listThemes, +} from "./dist/browser.js"; +import { + diagnoseError, + renderDiagnosis, +} from "./vendor/error-translator/index.js"; +import { incidentUpdate } from "./vendor/excuse-js/index.js"; +import { createDogLogger } from "./vendor/doggo-log/index.js"; +import { evaluateReports } from "./vendor/ship-it-meter/index.js"; +const el = (id) => document.getElementById(id), + quote = (s) => "'" + String(s).replaceAll("'", "'\\''") + "'"; +const control = (id, label, value, choices) => { + const wrap = document.createElement("label"); + wrap.textContent = label; + const input = document.createElement( + choices ? "select" : typeof value === "boolean" ? "input" : "textarea", + ); + input.id = id; + if (choices) + for (const v of choices) { + const o = document.createElement("option"); + o.value = o.textContent = v; + o.selected = v === value; + input.append(o); + } + else if (typeof value === "boolean") { + input.type = "checkbox"; + input.checked = value; + } else { + input.rows = String(value).includes("\n") ? 6 : 2; + input.value = value; + } + wrap.append(input); + return wrap; +}; +const val = (id) => el(id).value, + json = (id) => JSON.parse(val(id)); +function panel(anchor, id, title, description, fields, run) { + const details = document.createElement("details"); + details.open = true; + details.className = "value-demo"; + const summary = document.createElement("summary"); + summary.textContent = title; + const intro = document.createElement("p"); + intro.textContent = description; + const form = document.createElement("form"); + form.className = "demo-controls"; + form.append(...fields); + const output = document.createElement("pre"); + output.id = id + "-output"; + output.className = "terminal"; + output.setAttribute("aria-live", "polite"); + const caption = document.createElement("p"), + code = document.createElement("pre"); + code.className = "command"; + code.id = id + "-command"; + const button = document.createElement("button"); + button.type = "button"; + button.textContent = "Run example"; + const copy = document.createElement("button"); + copy.type = "button"; + copy.textContent = "Copy example"; + const status = document.createElement("p"); + status.setAttribute("role", "status"); + const render = () => { + try { + const r = run(); + output.textContent = r.output; + code.textContent = r.command; + caption.textContent = + r.caption ?? "Try the same input in your terminal (sh/bash):"; + copy.disabled = false; + status.textContent = ""; + } catch (e) { + output.textContent = "Input needs attention: " + e.message; + code.textContent = ""; + copy.disabled = true; + } + }; + form.addEventListener("submit", (e) => { + e.preventDefault(); + render(); + }); + form.addEventListener("input", render); + button.addEventListener("click", render); + copy.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(code.textContent); + status.textContent = "Example copied."; + } catch { + status.textContent = "Select and copy the example manually."; + } + }); + details.append( + summary, + intro, + form, + button, + output, + caption, + code, + copy, + status, + ); + el(anchor).closest("section").append(details); + render(); +} +const pretty = (v) => JSON.stringify(v, null, 2), + pipe = (pkg, flags, data) => + `printf '%s' ${quote(data)} | npx @deployanyway/${pkg}@1.0.0 ${flags}`; +panel( + "output", + "build", + "Build Buddy: actual facts, original characters", + "Summarize your measured build and test results. Missing build status stays unknown. This renders facts; your script must preserve the underlying command exit code.", + [ + control( + "build-facts", + "Measured facts (JSON)", + pretty({ + label: "Release build", + exitCode: 0, + tests: { passed: 42, failed: 0, skipped: 2 }, + durationMs: 1520, + commit: "demo-commit", + }), + ), + control("build-format", "Summary output", "character", [ + "character", + "plain", + "JSON", + ]), + control("build-character", "Character", "husky", listCharacters()), + control("build-mood", "Mood", "benji", moods()), + control("build-theme", "Theme", "classic", listThemes()), + control("build-mode", "Say or think", "say", ["say", "think"]), + control("build-width", "Wrap width", "52"), + ], + () => { + const format = val("build-format"), + r = renderBuildSummary(json("build-facts"), { + format: format === "plain" ? "plain" : "character", + character: val("build-character"), + mood: val("build-mood"), + theme: val("build-theme"), + mode: val("build-mode"), + width: Number(val("build-width")), + }); + return { + output: format === "JSON" ? pretty(r) : r.rendered, + command: pipe( + "bro-say", + "--summary" + + (format === "plain" + ? " --plain" + : ` --character ${val("build-character")} --mood ${val("build-mood")} --theme ${val("build-theme")} --width ${val("build-width")}`) + + (val("build-mode") === "think" && format !== "plain" + ? " --think" + : "") + + (format === "JSON" ? " --json" : ""), + val("build-facts"), + ), + }; + }, +); +panel( + "translate", + "diagnosis", + "Follow the cause, not just the error code", + "Paste an Error-shaped JSON object with nested cause fields. Bounded traversal handles cycles in the API; stacks are opt-in. The Node API also preserves your original Error by reference.", + [ + control( + "diagnosis-facts", + "Error and cause (JSON)", + pretty({ + name: "Error", + message: "Configuration could not load", + cause: { + name: "Error", + code: "ENOENT", + message: "Missing config.json", + }, + }), + ), + control("diagnosis-mode", "Explanation style", "plain", [ + "plain", + "rubber-duck", + ]), + control("diagnosis-depth", "Maximum depth (1–32)", "8"), + control("diagnosis-stack", "Include supplied stack", false), + control("diagnosis-format", "Output", "readable", ["readable", "JSON"]), + ], + () => { + const options = { + mode: val("diagnosis-mode"), + maxDepth: Number(val("diagnosis-depth")), + includeStack: el("diagnosis-stack").checked, + }, + r = diagnoseError(json("diagnosis-facts"), options); + return { + output: + val("diagnosis-format") === "JSON" ? pretty(r) : renderDiagnosis(r), + command: pipe( + "error-translator", + `--diagnose --mode ${options.mode} --max-depth ${options.maxDepth}` + + (options.includeStack ? " --include-stack" : "") + + (val("diagnosis-format") === "JSON" ? " --json" : ""), + val("diagnosis-facts"), + ), + }; + }, +); +panel( + "excuse-report", + "incident", + "Own It: accountable incident updates", + "Draft from facts you provide. Missing facts remain visible; complete means fields supplied, not incident resolved. Public updates keep jokes off. Nothing is posted or sent.", + [ + control( + "incident-facts", + "Known facts (JSON)", + pretty({ + status: "identified", + service: "API", + impact: "Some requests return 503", + action: "Rolling back the latest release", + owner: "On-call developer", + nextUpdate: new Date(Date.now() + 900000).toISOString(), + }), + ), + control("incident-audience", "Audience", "public", ["public", "internal"]), + control( + "incident-humor", + "Internal comic relief (requires internal audience)", + false, + ), + control("incident-seed", "Humor seed", "dallas"), + control("incident-format", "Output", "readable", ["readable", "JSON"]), + control("incident-complete", "Require complete facts in CLI", true), + ], + () => { + const options = { + audience: val("incident-audience"), + humor: el("incident-humor").checked, + seed: val("incident-seed"), + }, + r = incidentUpdate(json("incident-facts"), options); + return { + output: val("incident-format") === "JSON" ? pretty(r) : r.message, + command: pipe( + "excuse-js", + `--incident --audience ${options.audience}` + + (options.humor ? " --humor --seed " + quote(options.seed) : "") + + (val("incident-format") === "JSON" ? " --json" : "") + + (el("incident-complete").checked ? " --require-complete" : ""), + val("incident-facts"), + ), + }; + }, +); +panel( + "log-context", + "redaction", + "Good dog. Guard the context.", + "Try redaction before output reaches a writer. Credential context keys are protected by default; literal secrets require configuration. The Node-only request logger carries scope through async work. Browser output below demonstrates the shared formatting/redaction core, not AsyncLocalStorage.", + [ + control( + "redaction-context", + "Request context (scalar JSON)", + pretty({ + requestId: "dallas-1", + authorization: "demo-secret", + service: "API", + }), + ), + control( + "redaction-message", + "Message", + "Request finished with demo-secret", + ), + control( + "redaction-values", + "Exact secret values (JSON array)", + '["demo-secret"]', + ), + control( + "redaction-keys", + "Custom protected keys (blank uses defaults)", + "", + ), + control("redaction-replacement", "Replacement", "[REDACTED]"), + control("redaction-format", "Output", "JSON", ["JSON", "text"]), + control("redaction-method", "Method", "info", [ + "log", + "debug", + "info", + "success", + "warn", + "error", + ]), + ], + () => { + const redact = { + values: json("redaction-values"), + replacement: val("redaction-replacement"), + }; + if (val("redaction-keys").trim()) + redact.keys = val("redaction-keys") + .split(",") + .map((s) => s.trim()); + const config = { + json: val("redaction-format") === "JSON", + redact, + bark: true, + barkMode: "rotate", + seed: "dallas", + }, + context = json("redaction-context"), + method = val("redaction-method"), + message = val("redaction-message"); + const line = createDogLogger({ ...config, context, write: () => {} })[ + method + ](message); + return { + output: config.json ? pretty(JSON.parse(line)) : line, + caption: + "Node 22.13+/24: install @deployanyway/doggo-log@1.0.0, save as demo.mjs, run node demo.mjs. This example runs the actual asynchronous scope API:", + command: `import { createRequestLogger } from '@deployanyway/doggo-log/context';\nconst log = createRequestLogger(${pretty(config)});\ntry {\n await log.run(${pretty(context)}, async () => {\n await new Promise(resolve => setTimeout(resolve, 10));\n log.${method}(${JSON.stringify(message)});\n });\n} finally { log.dispose(); }`, + }; + }, +); +const stamp = new Date().toISOString(), + commit = "demo-commit"; +const cov = { + total: Object.fromEntries( + ["lines", "statements", "functions", "branches"].map((k) => [ + k, + { total: 10, covered: 10, pct: 100 }, + ]), + ), +}; +const tap = + "TAP version 13\n1..1\n# tests 1\n# pass 1\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n"; +panel( + "gate", + "receipts", + "Bring receipts: a report-backed release policy", + "Demonstration reports are editable sample data, not evidence about this site. In CI, collect real completed reports for the exact commit. Receipt metadata is a pipeline claim, not a signature; do not trust unverified submitted artifacts.", + [ + control("receipt-commit", "Expected commit", commit), + control("receipt-source-commit", "Report commit", commit), + control("receipt-time", "Report capture time (UTC ISO)", stamp), + control("receipt-format", "Test report format", "node-tap", [ + "node-tap", + "jest", + ]), + control( + "receipt-tests", + "Completed test report (Node TAP or Jest JSON)", + tap, + ), + control( + "receipt-coverage", + "Istanbul / c8 coverage-summary.json", + pretty(cov), + ), + control("receipt-build", "Measured build exit code", "0"), + control( + "receipt-policy", + "Policy (JSON)", + pretty({ minTests: 1, minCoverage: 80, minScore: 80, maxAgeMs: 3600000 }), + ), + control("receipt-output", "Output", "readable", ["readable", "JSON"]), + ], + () => { + const meta = { + commit: val("receipt-source-commit"), + capturedAt: val("receipt-time"), + }, + bundle = { + commit: val("receipt-commit"), + tests: { + ...meta, + format: val("receipt-format"), + data: val("receipt-tests"), + }, + coverage: { + ...meta, + format: "istanbul-summary", + data: json("receipt-coverage"), + }, + build: { ...meta, exitCode: Number(val("receipt-build")) }, + }, + policy = json("receipt-policy"), + r = evaluateReports(bundle, policy); + return { + output: + val("receipt-output") === "JSON" + ? pretty(r) + : [ + r.summary, + `Gate: ${r.passed ? "PASS" : "BLOCKED"}`, + `Score: ${r.score}/100`, + ...r.receipts.map( + (x) => + `${x.kind}: ${x.accepted ? "accepted" : x.issues.join("; ")}`, + ), + ...r.blockers, + ].join("\n"), + command: pipe( + "ship-it-meter", + "--reports --policy " + + quote(JSON.stringify(policy)) + + (val("receipt-output") === "JSON" ? " --json" : ""), + JSON.stringify(bundle), + ), + }; + }, +); diff --git a/vendor/doggo-log/index.js b/vendor/doggo-log/index.js index c19772b..ea76407 100644 --- a/vendor/doggo-log/index.js +++ b/vendor/doggo-log/index.js @@ -1,6 +1,7 @@ import { format } from "node:util"; import { levels } from "./levels.js"; import { barkLines, commentaryIndex } from "./commentary.js"; +import { copyContext, createRedactor } from "./redaction.js"; export { barkLines }; /** @@ -22,6 +23,7 @@ export function createDogLogger(options = {}) { prefix: "", level: "info", context: {}, + contextProvider: () => ({}), write: (line, level) => level === "warn" || level === "error" ? console.error(line) @@ -51,7 +53,16 @@ export function createDogLogger(options = {}) { throw new TypeError( "Context values must be strings, finite numbers, booleans or null.", ); - config.context = { ...config.context }; + config.context = copyContext(config.context); + if (typeof config.contextProvider !== "function") + throw new TypeError("contextProvider must be a function."); + const redactor = createRedactor(config.redact); + if (config.redact !== false) + config.redact = { + ...config.redact, + ...(config.redact?.keys ? { keys: [...config.redact.keys] } : {}), + ...(config.redact?.values ? { values: [...config.redact.values] } : {}), + }; if (typeof config.level !== "string" || !Object.hasOwn(levels, config.level)) throw new RangeError( `level must be one of: ${Object.keys(levels).join(", ")}.`, @@ -73,7 +84,11 @@ export function createDogLogger(options = {}) { (...args) => { if (config.quiet || style.rank < levels[config.level].rank) return undefined; - const message = format(...args); + const message = redactor.text(format(...args)); + const context = redactor.context({ + ...config.context, + ...copyContext(config.contextProvider()), + }); const pool = config.bark ? barkLines(level) : undefined; const index = config.barkMode === "rotate" @@ -98,20 +113,19 @@ export function createDogLogger(options = {}) { line = JSON.stringify({ level, message, - ...(Object.keys(config.context).length - ? { context: { ...config.context } } - : {}), + ...(Object.keys(context).length ? { context } : {}), ...(commentary ? { commentary } : {}), - ...(config.prefix ? { prefix: config.prefix } : {}), + ...(config.prefix ? { prefix: redactor.text(config.prefix) } : {}), ...(timestamp ? { timestamp } : {}), }); } else { line = [ timestamp, - config.prefix, + redactor.text(config.prefix), config.emoji ? style.emoji : undefined, level.toUpperCase().padEnd(7), message, + Object.keys(context).length ? JSON.stringify(context) : undefined, commentary, ] .filter((part) => part !== undefined && part !== "") diff --git a/vendor/doggo-log/redaction.js b/vendor/doggo-log/redaction.js new file mode 100644 index 0000000..104d801 --- /dev/null +++ b/vendor/doggo-log/redaction.js @@ -0,0 +1,73 @@ +const defaultKeys = [ + "password", + "passwd", + "token", + "accessToken", + "refreshToken", + "authorization", + "cookie", + "secret", + "apiKey", +]; +const normalize = (key) => key.toLowerCase().replaceAll(/[-_]/g, ""); +export function copyContext(context) { + if (!context || typeof context !== "object" || Array.isArray(context)) + throw new TypeError("context must be an object of scalar fields."); + for (const value of Object.values(context)) + if (!( + value === null || + typeof value === "string" || + typeof value === "boolean" || + (typeof value === "number" && Number.isFinite(value)) + )) + throw new TypeError( + "Context values must be strings, finite numbers, booleans or null.", + ); + return { ...context }; +} +export function createRedactor(options) { + if (options === false) + return { text: (value) => value, context: copyContext }; + if (options === undefined) options = {}; + if (!options || typeof options !== "object" || Array.isArray(options)) + throw new TypeError("redact must be false or an options object."); + const keys = options.keys ?? defaultKeys, + values = options.values ?? [], + replacement = options.replacement ?? "[REDACTED]"; + for (const [name, list] of [ + ["keys", keys], + ["values", values], + ]) + if ( + !Array.isArray(list) || + list.length > 100 || + !list.every((value) => typeof value === "string" && value.length > 0) + ) + throw new TypeError( + `Redaction ${name} must contain at most 100 nonempty strings.`, + ); + if (typeof replacement !== "string") + throw new TypeError("Redaction replacement must be a string."); + const protectedKeys = new Set(keys.map(normalize)); + // Longest first prevents a short secret from exposing a suffix of a longer one. + const literals = [...new Set(values)].sort((a, b) => b.length - a.length); + const text = (value) => + literals.reduce( + (output, secret) => output.split(secret).join(replacement), + value, + ); + return { + text, + context: (context) => + Object.fromEntries( + Object.entries(copyContext(context)).map(([key, value]) => [ + key, + protectedKeys.has(normalize(key)) + ? replacement + : typeof value === "string" + ? text(value) + : value, + ]), + ), + }; +} diff --git a/vendor/error-translator/diagnostics.js b/vendor/error-translator/diagnostics.js new file mode 100644 index 0000000..2e38dac --- /dev/null +++ b/vendor/error-translator/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/vendor/error-translator/index.js b/vendor/error-translator/index.js index a20ed7e..b22be53 100644 --- a/vendor/error-translator/index.js +++ b/vendor/error-translator/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/vendor/excuse-js/incident.js b/vendor/excuse-js/incident.js new file mode 100644 index 0000000..95fe42d --- /dev/null +++ b/vendor/excuse-js/incident.js @@ -0,0 +1,83 @@ +import { excuse } from "./index.js"; +const iso = (value, label) => { + if ( + typeof value !== "string" || + !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(value) || + !Number.isFinite(Date.parse(value)) || + new Date(value).toISOString().slice(0, 19) !== value.slice(0, 19) + ) + throw new TypeError(`${label} must be a UTC ISO timestamp.`); + return value; +}; +const clean = (value) => + Array.from(value) + .filter( + (char) => char.charCodeAt(0) >= 32 || char === "\n" || char === "\t", + ) + .join(""); +/** Draft factual incident communication; public output never adds a joke. */ +export function incidentUpdate(facts = {}, options = {}) { + if (!facts || typeof facts !== "object" || Array.isArray(facts)) + throw new TypeError("Incident facts must be an object."); + if (!options || typeof options !== "object" || Array.isArray(options)) + throw new TypeError("Options must be an object."); + const status = facts.status ?? "investigating", + audience = options.audience ?? "public", + humor = options.humor ?? false; + if ( + !["investigating", "identified", "monitoring", "resolved"].includes(status) + ) + throw new RangeError("Unsupported incident status."); + if (!["public", "internal"].includes(audience) || typeof humor !== "boolean") + throw new TypeError("Choose public/internal audience and boolean humor."); + if (humor && audience !== "internal") + throw new TypeError("Comic relief requires an internal audience."); + const result = { status, audience, missing: [], overdue: false }; + for (const key of ["service", "impact", "action", "owner"]) { + if ( + facts[key] !== undefined && + facts[key] !== null && + (typeof facts[key] !== "string" || + !facts[key].trim() || + facts[key].length > 2000) + ) + throw new TypeError( + `${key} must be a nonempty string up to 2000 characters.`, + ); + result[key] = + facts[key] === undefined || facts[key] === null + ? null + : clean(facts[key].trim()); + if (result[key] === null) result.missing.push(key); + } + const now = iso(options.now ?? new Date().toISOString(), "now"); + result.nextUpdate = + facts.nextUpdate === undefined || facts.nextUpdate === null + ? null + : iso(facts.nextUpdate, "nextUpdate"); + if (result.nextUpdate === null && status !== "resolved") + result.missing.push("nextUpdate"); + result.overdue = + status !== "resolved" && + result.nextUpdate !== null && + Date.parse(result.nextUpdate) < Date.parse(now); + const lines = [ + `Status: ${status}`, + `Service: ${result.service ?? "Not provided"}`, + `Impact: ${result.impact ?? "Not provided — verify before claiming an impact"}`, + `Current action: ${result.action ?? "Not provided"}`, + `Owner: ${result.owner ?? "Not assigned"}`, + `Next update: ${result.nextUpdate ?? (status === "resolved" ? "Not scheduled (status marked resolved)" : "Not scheduled")}${result.overdue ? " — OVERDUE" : ""}`, + ]; + if (result.missing.length) + lines.push("Missing facts: " + result.missing.join(", ")); + result.complete = result.missing.length === 0; + if (humor) { + result.comicRelief = excuse(options.category ?? "production", { + seed: options.seed, + }); + lines.push("\nComic relief (internal only): " + result.comicRelief); + } + result.message = lines.join("\n"); + return result; +} diff --git a/vendor/excuse-js/index.js b/vendor/excuse-js/index.js index 4b211f6..76a44eb 100644 --- a/vendor/excuse-js/index.js +++ b/vendor/excuse-js/index.js @@ -102,3 +102,4 @@ export function listExcuses(category = "deployment") { throw new RangeError("Unknown category: " + category); return [...phrases[key]]; } +export { incidentUpdate } from "./incident.js"; diff --git a/vendor/ship-it-meter/index.js b/vendor/ship-it-meter/index.js index 72cbd77..5f5ff81 100644 --- a/vendor/ship-it-meter/index.js +++ b/vendor/ship-it-meter/index.js @@ -297,3 +297,8 @@ export function releasePlan(input = {}, options = {}) { tasks.sort((a, b) => rank[a.priority] - rank[b.priority]); return { ...gate, summary: summaries[gate.verdict], tasks }; } +export { + parseTestReport, + parseCoverageReport, + evaluateReports, +} from "./reports.js"; diff --git a/vendor/ship-it-meter/reports.js b/vendor/ship-it-meter/reports.js new file mode 100644 index 0000000..95688ae --- /dev/null +++ b/vendor/ship-it-meter/reports.js @@ -0,0 +1,262 @@ +import { releasePlan } from "./index.js"; +const object = (value, label) => { + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new TypeError(`${label} must be an object.`); + return value; +}; +const count = (value, label) => { + if (!Number.isSafeInteger(value) || value < 0) + throw new TypeError(`${label} must be a nonnegative safe integer.`); + return value; +}; +const json = (value) => (typeof value === "string" ? JSON.parse(value) : value); +const date = (value, label) => { + if ( + typeof value !== "string" || + !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(value) || + !Number.isFinite(Date.parse(value)) || + new Date(value).toISOString().slice(0, 19) !== value.slice(0, 19) + ) + throw new TypeError(`${label} must be a UTC ISO timestamp.`); + return Date.parse(value); +}; + +/** Parse completed Node TAP or Jest JSON test results; skipped/todo tests are not passing evidence. */ +export function parseTestReport(data, format = "node-tap") { + if (format === "node-tap") { + if ( + typeof data !== "string" || + !data.startsWith("TAP version 13") || + /^Bail out!/m.test(data) + ) + throw new TypeError("Expected a completed Node TAP v13 report."); + const values = {}; + for (const key of [ + "tests", + "pass", + "fail", + "cancelled", + "skipped", + "todo", + ]) { + const matches = [ + ...data.matchAll(new RegExp(`^# ${key} (\\d+)\\r?$`, "gm")), + ]; + if (matches.length !== 1) + throw new TypeError(`TAP requires one ${key} summary.`); + values[key] = count(Number(matches[0][1]), key); + } + if ( + values.pass + + values.fail + + values.cancelled + + values.skipped + + values.todo !== + values.tests + ) + throw new TypeError("TAP counts do not reconcile."); + return { + tests: values.tests - values.skipped - values.todo, + passedTests: values.pass, + failingTests: values.fail + values.cancelled, + skippedTests: values.skipped, + todoTests: values.todo, + successful: values.fail + values.cancelled === 0, + }; + } + if (format === "jest") { + const report = object(json(data), "Jest report"); + const total = count(report.numTotalTests, "numTotalTests"), + passed = count(report.numPassedTests, "numPassedTests"), + failed = count(report.numFailedTests, "numFailedTests"), + pending = count(report.numPendingTests, "numPendingTests"), + todo = count(report.numTodoTests ?? 0, "numTodoTests"); + if ( + total !== passed + failed + pending + todo || + typeof report.success !== "boolean" + ) + throw new TypeError("Jest counts/success do not reconcile."); + if ( + report.wasInterrupted || + report.testResults?.some((result) => + result.assertionResults?.some( + (assertion) => assertion.wouldRun === true, + ), + ) + ) + throw new TypeError( + "Interrupted or collected-only Jest output is not executed evidence.", + ); + const failedSuites = count( + report.numFailedTestSuites ?? 0, + "numFailedTestSuites", + ); + return { + tests: total - pending - todo, + passedTests: passed, + failingTests: failed, + skippedTests: pending, + todoTests: todo, + successful: report.success && failed === 0 && failedSuites === 0, + }; + } + throw new RangeError("Supported test formats: node-tap, jest."); +} +/** Read Istanbul/c8 coverage-summary.json. Empty instrumented code is not evidence. */ +export function parseCoverageReport(data) { + const total = object( + object(json(data), "Coverage report").total, + "Coverage total", + ), + result = {}; + for (const key of ["lines", "statements", "functions", "branches"]) { + const metric = object(total[key], `Coverage ${key}`), + all = count(metric.total, `${key}.total`), + covered = count(metric.covered, `${key}.covered`); + if ( + (all === 0 && ["lines", "statements"].includes(key)) || + covered > all || + typeof metric.pct !== "number" || + !Number.isFinite(metric.pct) || + metric.pct < 0 || + metric.pct > 100 + ) + throw new TypeError( + `Coverage ${key} must contain measured code and a percentage.`, + ); + const computed = all === 0 ? 100 : (covered / all) * 100; + if (Math.abs(computed - metric.pct) > 0.02) + throw new TypeError(`Coverage ${key} percentage disagrees with counts.`); + result[key] = metric.pct; + } + return result; +} +/** Gate real report envelopes. Caller-supplied commit/timestamps are provenance claims, not signatures. */ +export function evaluateReports(bundle, options = {}) { + object(bundle, "Report bundle"); + object(options, "Policy"); + for (const key of Object.keys(options)) + if ( + !["minTests", "minCoverage", "minScore", "maxAgeMs", "now"].includes(key) + ) + throw new TypeError(`Unknown policy option: ${key}.`); + if (typeof bundle.commit !== "string" || !bundle.commit.trim()) + throw new TypeError("Report bundle requires a nonempty commit."); + const policy = { + minTests: 1, + minCoverage: 80, + minScore: 80, + maxAgeMs: 3600000, + ...options, + }; + for (const [key, value] of Object.entries({ + minTests: 1, + minCoverage: 80, + minScore: 80, + maxAgeMs: 3600000, + })) + if (policy[key] === undefined) policy[key] = value; + count(policy.minTests, "minTests"); + if (policy.minTests < 1) throw new RangeError("minTests must be positive."); + for (const key of ["minCoverage", "minScore"]) + if ( + typeof policy[key] !== "number" || + !Number.isFinite(policy[key]) || + policy[key] < 0 || + policy[key] > 100 + ) + throw new RangeError(`${key} must be from 0 to 100.`); + if (!Number.isSafeInteger(policy.maxAgeMs) || policy.maxAgeMs < 1) + throw new RangeError("maxAgeMs must be a positive safe integer."); + const now = options.now ?? new Date().toISOString(); + const nowMs = date(now, "now"); + const evidence = {}, + receipts = [], + blockers = []; + for (const kind of ["tests", "coverage", "build"]) { + const issues = [], + report = bundle[kind]; + if (!report) { + issues.push(`${kind}: report is missing.`); + } else + try { + object(report, kind); + const captured = date(report.capturedAt, `${kind}.capturedAt`); + if (report.commit !== bundle.commit) + issues.push(`${kind}: commit does not match ${bundle.commit}.`); + if (captured > nowMs) + issues.push(`${kind}: timestamp is in the future.`); + if (nowMs - captured > policy.maxAgeMs) + issues.push(`${kind}: report is stale.`); + if (kind === "tests") { + if (!["node-tap", "jest"].includes(report.format)) + throw new RangeError("Test format must be node-tap or jest."); + const parsed = parseTestReport(report.data, report.format); + evidence.tests = parsed.tests; + evidence.failingTests = parsed.failingTests; + if (!parsed.successful) + issues.push("tests: test execution was unsuccessful."); + if (parsed.tests < policy.minTests) + issues.push( + `tests: ${parsed.tests} executed tests is below ${policy.minTests}.`, + ); + } else if (kind === "coverage") { + if (report.format !== "istanbul-summary") + throw new RangeError("Coverage format must be istanbul-summary."); + const coverage = parseCoverageReport(report.data); + evidence.coverage = coverage.lines; + for (const [metric, value] of Object.entries(coverage)) + if (value < policy.minCoverage) + issues.push( + `coverage: ${metric} ${value}% is below ${policy.minCoverage}%.`, + ); + } else { + count(report.exitCode, "build.exitCode"); + evidence.build = report.exitCode === 0; + if (report.exitCode !== 0) + issues.push(`build: command exited ${report.exitCode}.`); + } + } catch (error) { + issues.push(`${kind}: ${error.message}`); + } + receipts.push({ + kind, + format: kind === "build" ? "exit-code" : (report?.format ?? "missing"), + commit: typeof report?.commit === "string" ? report.commit : null, + capturedAt: + typeof report?.capturedAt === "string" ? report.capturedAt : null, + accepted: issues.length === 0, + issues, + }); + blockers.push(...issues); + } + const plan = releasePlan(evidence, { minScore: policy.minScore }); + const combined = [...new Set([...blockers, ...plan.blockers])]; + const receiptTasks = receipts + .filter((item) => !item.accepted) + .map((item) => ({ + id: "receipt-" + item.kind, + priority: "blocker", + title: "Repair " + item.kind + " evidence", + verify: `Generate a completed ${item.kind} report for ${bundle.commit} with a current UTC timestamp; resolve: ${item.issues.join(" ")}`, + })); + return { + ...plan, + summary: combined.length + ? "The receipts say stop. Confidence must wait." + : plan.summary, + tasks: [...receiptTasks, ...plan.tasks], + passed: combined.length === 0, + blockers: combined, + evidence, + receipts, + commit: bundle.commit, + policy: { + minTests: policy.minTests, + minCoverage: policy.minCoverage, + minScore: policy.minScore, + maxAgeMs: policy.maxAgeMs, + }, + evaluatedAt: now, + }; +}