diff --git a/src/chant.test.ts b/src/chant.test.ts index 449b4e3..0f95242 100644 --- a/src/chant.test.ts +++ b/src/chant.test.ts @@ -11,6 +11,10 @@ import { lifecyclePlanArgs, applyArgs, runChantRaw, + stripAnsi, + classifyChantFailure, + ChantCliError, + graphIr, } from "./chant.ts"; import { overlayStatus } from "./overlay.ts"; @@ -313,3 +317,108 @@ describe("runChantRaw — env override reaches the spawn", () => { expect(opts?.env?.AWS_ENDPOINT_URL).toBe("http://localhost:4566"); }); }); + +describe("stripAnsi", () => { + it("removes ANSI colour escapes", () => { + expect(stripAnsi("\x1b[31merror\x1b[0m: broken")).toBe("error: broken"); + }); + + it("is a no-op on plain text", () => { + expect(stripAnsi("plain text, no escapes")).toBe("plain text, no escapes"); + }); +}); + +// #72: classify chant's own stderr into the precondition failures the "open +// your own project" goal routinely hits — lint gate, not-installed/no- +// typegen, and a generic eval failure (e.g. a tier that needs credentials). +// Fixtures below are chant's ACTUAL stderr, captured by shelling +// `graphIr()`/`chant graph --format ir` (0.18.x) against `example/` — the +// lint-gate and not-installed strings straight off chant's own +// cli/handlers/graph.ts ("Refusing to emit graph…") and Node's module +// resolution ("Cannot find package…"); the generic-failure fixture is chant +// surfacing a thrown Error from evaluated source the same way. +describe("classifyChantFailure", () => { + it("classifies the lint gate — chant graph.ts's own 'Refusing to emit graph' message", () => { + const stderr = + "\x1b[31merror\x1b[0m: Refusing to emit graph: source has lint errors. Run `chant lint` and fix them first."; + const failure = classifyChantFailure(stderr); + expect(failure.code).toBe("lint"); + expect(failure.message).toBe("error: Refusing to emit graph: source has lint errors. Run `chant lint` and fix them first."); + expect(failure.remedy).toMatch(/chant lint/); + }); + + it("classifies a missing package — Node's ESM resolution error on an unresolvable import", () => { + const stderr = + "\x1b[31merror\x1b[0m: Cannot find package '@intentius/chant-lexicon-aws' imported from " + + "/private/tmp/behold-72/node_modules/@intentius/chant/src/cli/plugins.ts"; + const failure = classifyChantFailure(stderr); + expect(failure.code).toBe("not-installed"); + expect(failure.message).toContain("Cannot find package '@intentius/chant-lexicon-aws'"); + expect(failure.remedy).toMatch(/npm install/); + expect(failure.remedy).toMatch(/chant typegen/); + }); + + it("classifies a missing module the same way — 'Cannot find module' (CJS-style resolution errors)", () => { + const failure = classifyChantFailure("error: Cannot find module '@intentius/chant'\nRequire stack:\n- /x.js"); + expect(failure.code).toBe("not-installed"); + }); + + it("falls back to a generic eval failure for anything else — e.g. a thrown Error from evaluated source", () => { + const stderr = "\x1b[31merror\x1b[0m: simulated tier eval failure: missing required parameter"; + const failure = classifyChantFailure(stderr); + expect(failure.code).toBe("eval"); + expect(failure.message).toBe("error: simulated tier eval failure: missing required parameter"); + expect(failure.remedy).toBeTruthy(); + }); + + it("never throws on empty stderr — falls back to a generic message", () => { + const failure = classifyChantFailure(""); + expect(failure.code).toBe("eval"); + expect(failure.message).toBeTruthy(); + }); + + it("is case-insensitive on chant's own wording (defensive — matches today's exact casing too)", () => { + expect(classifyChantFailure("REFUSING TO EMIT GRAPH: source has lint errors.").code).toBe("lint"); + expect(classifyChantFailure("cannot find package 'x'").code).toBe("not-installed"); + }); +}); + +describe("ChantCliError", () => { + it("carries the plain 'chant exited : ' message unchanged, for callers that just stringify it", () => { + const err = new ChantCliError(["graph", "src", "--format", "ir"], 1, "boom"); + expect(err.message).toBe("chant graph src --format ir exited 1: boom"); + expect(err).toBeInstanceOf(Error); + }); + + it("also carries the classified failure alongside the message", () => { + const err = new ChantCliError( + ["graph", "src", "--format", "ir"], + 1, + "error: Refusing to emit graph: source has lint errors. Run `chant lint` and fix them first.", + ); + expect(err.failure.code).toBe("lint"); + expect(err.failure.remedy).toMatch(/chant lint/); + }); +}); + +// End-to-end: runChantJson (private, exercised via graphIr) wraps a non-zero +// exit in a ChantCliError rather than a plain Error — the real signal +// server.ts's errorResponse (#72) reads off a caught graph/facet failure. +describe("runChantJson — wraps a non-zero exit in a classified ChantCliError", () => { + beforeEach(() => vi.mocked(spawnMock).mockReset()); + + it("rejects with a ChantCliError carrying the classified failure", async () => { + vi.mocked(spawnMock).mockReturnValue( + fakeProc(1, "", "error: Cannot find package '@intentius/chant-lexicon-aws' imported from x.ts"), + ); + await expect(graphIr("/proj")).rejects.toMatchObject({ + name: "ChantCliError", + failure: { code: "not-installed" }, + }); + }); + + it("still parses stdout as JSON on a zero exit — the happy path is unaffected", async () => { + vi.mocked(spawnMock).mockReturnValue(fakeProc(0, JSON.stringify({ nodes: [], edges: [] }))); + await expect(graphIr("/proj")).resolves.toEqual({ nodes: [], edges: [] }); + }); +}); diff --git a/src/chant.ts b/src/chant.ts index 8c76782..fa46883 100644 --- a/src/chant.ts +++ b/src/chant.ts @@ -165,9 +165,100 @@ export function runChantRaw( }); } +// --------------------------------------------------------------------------- +// Precondition-failure classification (#72) — behold shells the served +// project's OWN chant, which routinely refuses on a project that isn't +// perfectly set up yet (the "open your own project" goal's whole premise, per +// the bundled-Loom-demo era never surfacing this). A non-zero exit used to +// just become a generic `Error` that /api/graph's `errorResponse` stringified +// into an opaque 500. This classifies chant's own stderr into the handful of +// preconditions the CLI itself gates on, so a route can hand back a machine +// code + a suggested remedy instead of a stack trace. +// --------------------------------------------------------------------------- + +/** The precondition failures behold distinguishes (#72's "at least" list): + * - "lint": `chant graph`'s own lint gate — it refuses to emit a graph for + * source that doesn't pass `chant lint` (see chant's cli/handlers/ + * graph.ts, "Refusing to emit graph: source has lint errors"). + * - "not-installed": chant can't resolve a package the project's source + * imports (`Cannot find package`/`Cannot find module`) — either the + * project itself was never `npm install`ed, or it's missing generated + * types (`chant typegen`); either way behold's bin resolution + * (`chantBin` above) fell back to its own pinned chant, which has none of + * the served project's own lexicons. + * - "eval": anything else non-zero — a generic evaluation failure. Covers + * the tier/env-needs-creds case (server.ts generalizes this bucket + * further, folding in the picked tier for a friendlier remedy) and any + * other chant-side error this module doesn't special-case. + */ +export type ChantFailureCode = "lint" | "not-installed" | "eval"; + +/** A classified chant failure: the machine `code`, chant's own message + * (ANSI-stripped, trimmed), and a suggested next step. */ +export interface ChantFailure { + code: ChantFailureCode; + message: string; + remedy: string; +} + +// chant's own `formatError`/`formatWarning` (cli/format.ts) colour output +// whenever `!process.env.NO_COLOR && process.stdout.isTTY !== false` — a +// piped stream's `isTTY` is `undefined`, not `false`, so that check stays +// true for a spawned, non-interactive chant by default. Strip the escapes so +// neither the classification regexes nor the JSON/UI surfaced text carry raw +// control bytes. +const ANSI_RE = /\x1b\[[0-9;]*m/g; + +/** Strip ANSI colour escapes from chant's stderr/stdout. Exported for testing. */ +export function stripAnsi(text: string): string { + return text.replace(ANSI_RE, ""); +} + +/** Classify a failed chant shell-out's stderr into one of `ChantFailureCode` + * (#72). Pattern-matches chant's own wording rather than an exit-code + * convention — chant exits 1 for every failure class alike, so the message + * text is the only signal. Never throws; a message that matches nothing + * specific falls into the generic "eval" bucket. Pure; exported for testing. */ +export function classifyChantFailure(stderr: string): ChantFailure { + const clean = stripAnsi(stderr).trim(); + if (/refusing to emit graph: source has lint errors/i.test(clean)) { + return { + code: "lint", + message: clean || "chant refused to emit the graph: the project has lint errors.", + remedy: "Run `chant lint` in the project to see the errors, fix them, then reload.", + }; + } + if (/cannot find (package|module)\s+['"]/i.test(clean)) { + return { + code: "not-installed", + message: clean || "chant could not resolve a package the project's source imports.", + remedy: "Run `npm install && chant typegen` in the project directory, then reload.", + }; + } + return { + code: "eval", + message: clean || "chant failed to evaluate the project.", + remedy: "Check the project's chant source and environment — see the message above for chant's own error.", + }; +} + +/** Thrown by `runChantJson`/`ciPipeline` on a non-zero chant exit. `message` + * stays the same "chant exited : " text a plain `Error` + * always carried here (so any existing `err instanceof Error ? err.message : + * …` caller is unaffected); `failure` is the classification (#72) a route can + * read straight off the caught error instead of re-parsing the message. */ +export class ChantCliError extends Error { + readonly failure: ChantFailure; + constructor(args: string[], code: number, stderr: string) { + super(`chant ${args.join(" ")} exited ${code}: ${stderr.trim()}`); + this.name = "ChantCliError"; + this.failure = classifyChantFailure(stderr); + } +} + async function runChantJson(args: string[], projectDir?: string, envOverride?: Record): Promise { const { code, stdout, stderr } = await runChantRaw(args, projectDir, envOverride); - if (code !== 0) throw new Error(`chant ${args.join(" ")} exited ${code}: ${stderr.trim()}`); + if (code !== 0) throw new ChantCliError(args, code, stderr); return JSON.parse(stdout) as T; } @@ -362,7 +453,7 @@ export function parseCiPipeline(stdout: string): CiPipeline { export function ciPipeline(projectDir: string, opts: GraphOptions = {}): Promise { const args = ciPipelineArgs(opts); return runChantRaw(args, projectDir, envOverridesFor(opts)).then(({ code, stdout, stderr }) => { - if (code !== 0) throw new Error(`chant ${args.join(" ")} exited ${code}: ${stderr.trim()}`); + if (code !== 0) throw new ChantCliError(args, code, stderr); return parseCiPipeline(stdout); }); } diff --git a/src/server.test.ts b/src/server.test.ts index 16a6c13..b79e3f1 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { EventEmitter } from "node:events"; +import { spawn as spawnMock } from "node:child_process"; // Route-level guard/409 tests for the delegated-write routes (M3 #54's // /api/apply, alongside the pre-existing /api/rollback) — mock the chant @@ -18,6 +20,39 @@ vi.mock("./chant.ts", async (importOriginal) => { return { ...actual, runChantStream: () => streamMock() }; }); +// #72: the precondition-error tests below mock the chant shell-out one layer +// deeper — `node:child_process`'s `spawn` — the same way chant.test.ts does, +// so `graphIr`/`runChantJson` run for REAL and produce a REAL `ChantCliError` +// from a REAL (fake) chant exit, rather than a route's dependency being +// swapped for a function that hands back a bare `Promise.reject(...)`. A +// directly-injected rejected promise is honest about the *shape* chant.ts +// throws but not about *how* — the real code always rejects via an event- +// emitter `close` callback a tick later, and Node's unhandled-rejection +// tracking (surfaced through Hono's own internal promise chaining in +// `app.request`) can flag an eagerly-constructed `Promise.reject` before the +// route's `try { await graphIr(...) } catch` attaches its handler, even +// though it always does moments later. +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawn: vi.fn() }; +}); + +/** A minimal fake ChildProcess: emits `data` on stdout/stderr, then `close`, + * on the next microtask — enough for `runChantRaw`'s listeners. Mirrors + * chant.test.ts's `fakeProc`. */ +function fakeProc(code: number, stdout = "", stderr = ""): ReturnType { + const proc = new EventEmitter() as unknown as ReturnType; + const out = new EventEmitter(); + const err = new EventEmitter(); + Object.assign(proc, { stdout: out, stderr: err }); + queueMicrotask(() => { + if (stdout) out.emit("data", Buffer.from(stdout)); + if (stderr) err.emit("data", Buffer.from(stderr)); + proc.emit("close", code); + }); + return proc; +} + import { createApp } from "./server.ts"; import { OpRunner } from "./op-runner.ts"; import { Broadcaster } from "./events.ts"; @@ -115,3 +150,97 @@ describe("GET /api/ops — applyProgress + running surface the write state", () expect(body.running).toBe("apply all"); }); }); + +// #72: a graph/facet route's failure gets a structured {error, code, remedy} +// body instead of an opaque 500 — errorResponse (src/server.ts) classifying +// whatever the chant shell-out (mocked at the `spawn` layer above) reported. +// Exercised through the real HTTP layer, through the real graphIr/ +// runChantJson/ChantCliError chain, so the route wiring itself — which error +// routes to which endpoint, and how `?tier=` changes the outcome — is +// covered too, not just errorResponse/tierFailure in isolation. +describe("GET /api/graph — structured precondition errors (#72)", () => { + beforeEach(() => vi.mocked(spawnMock).mockReset()); + + it("classifies chant's lint gate as code: lint, with a remedy pointing at `chant lint`", async () => { + vi.mocked(spawnMock).mockReturnValue( + fakeProc(1, "", "error: Refusing to emit graph: source has lint errors. Run `chant lint` and fix them first."), + ); + const { app } = makeApp(undefined); + const res = await app.request("/api/graph"); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string; code: string; remedy: string }; + expect(body.code).toBe("lint"); + expect(body.error).toMatch(/lint errors/); + expect(body.remedy).toMatch(/chant lint/); + }); + + it("classifies a missing-dependency failure as code: not-installed, with an npm install + typegen remedy", async () => { + vi.mocked(spawnMock).mockReturnValue( + fakeProc(1, "", "error: Cannot find package '@intentius/chant-lexicon-aws' imported from x.ts"), + ); + const { app } = makeApp(undefined); + const res = await app.request("/api/graph"); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string; code: string; remedy: string }; + expect(body.code).toBe("not-installed"); + expect(body.remedy).toMatch(/npm install/); + expect(body.remedy).toMatch(/chant typegen/); + }); + + it("classifies anything else as code: eval — the generic fallback", async () => { + vi.mocked(spawnMock).mockReturnValue(fakeProc(1, "", "error: something totally unrelated broke")); + const { app } = makeApp(undefined); + const res = await app.request("/api/graph"); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string; code: string; remedy: string }; + expect(body.code).toBe("eval"); + expect(body.error).toMatch(/something totally unrelated broke/); + }); + + it("?tier= reports code: tier instead of the underlying chant classification (M2, #54, generalized)", async () => { + vi.mocked(spawnMock).mockReturnValue( + fakeProc(1, "", "error: Refusing to emit graph: source has lint errors. Run `chant lint` and fix them first."), + ); + const { app } = makeApp(undefined); + const res = await app.request("/api/graph?tier=production"); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string; code: string; remedy: string }; + expect(body.code).toBe("tier"); + expect(body.error).toContain('"production"'); + expect(body.remedy).toMatch(/different tier/); + }); + + it("a not-installed failure keeps its own code even under a picked tier — it isn't a tier problem", async () => { + vi.mocked(spawnMock).mockReturnValue(fakeProc(1, "", "error: Cannot find package 'x' imported from y.ts")); + const { app } = makeApp(undefined); + const res = await app.request("/api/graph?tier=production"); + const body = (await res.json()) as { code: string }; + expect(body.code).toBe("not-installed"); + }); +}); + +// /api/overlay is where a picked tier's creds gate USUALLY surfaces in +// practice — the SPA's load() routes an env pick here, not /api/graph (see +// web/app.js). Same errorResponse under the hood; verify the wiring reaches +// it too, not just /api/graph's. +describe("GET /api/overlay — structured precondition errors (#72)", () => { + beforeEach(() => vi.mocked(spawnMock).mockReset()); + + it("classifies a tier/creds failure the same way as /api/graph", async () => { + vi.mocked(spawnMock).mockReturnValue( + fakeProc(1, "", "error: Refusing to emit graph: source has lint errors. Run `chant lint` and fix them first."), + ); + const { app } = makeApp(undefined); + const res = await app.request("/api/overlay?env=local&tier=production"); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string; code: string; remedy: string }; + expect(body.code).toBe("tier"); + expect(body.remedy).toMatch(/different tier/); + }); + + it("still 400s with no environment available, unaffected by the #72 changes", async () => { + const { app } = makeApp(undefined); + const res = await app.request("/api/overlay"); + expect(res.status).toBe(400); + }); +}); diff --git a/src/server.ts b/src/server.ts index 932d728..293408d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -26,7 +26,10 @@ import { ciPipeline, lifecyclePlan, runChantRaw, + ChantCliError, + classifyChantFailure, type GraphOptions, + type ChantFailure, } from "./chant.ts"; import { joinComponentStatus, componentStatusColor } from "./component-status.ts"; import { reclassifyOverlay, pruneImports } from "./overlay.ts"; @@ -128,30 +131,63 @@ async function knownComponents(projectDir: string, opts: GraphOptions): Promise< } } -/** A clear, generic note for a graph/facet call that failed while a non-default - * tier was picked (M2, #54) — e.g. loomster's `full` tier trips chant's lint - * gate on Floci (needs real-AWS params it doesn't have here). Undefined when - * no tier was picked, so a plain failure still reads as a plain error. +/** Precondition-failure codes a read route's structured error can carry (#72): + * chant.ts's `ChantFailureCode` (lint gate / not-installed / generic eval — + * classified from chant's own stderr), plus "tier" — a non-default tier that + * needed parameters this host doesn't have, generalized below from what used + * to be a one-off `tierErrorNote`/`tierNote` bolted onto a plain error. */ +export type RouteErrorCode = ChantFailure["code"] | "tier"; + +/** A read route's structured, typed error body (#72): a machine `code`, a + * human `error` message, and a suggested `remedy` — what web/app.js's + * `renderPreconditionError` turns into the entry/error screen instead of a + * blank canvas or a raw stack trace. */ +export interface RouteError { + error: string; + code: RouteErrorCode; + remedy: string; +} + +/** The "tier" failure (M2, #54, generalizing the old `tierErrorNote`) — a + * non-default tier tripped chant's lint/eval gate. A production-only tier + * commonly needs real credentials or a different target this host lacks, and + * that trips the SAME gate a genuinely broken project would; the remedy here + * isn't "edit the source", it's "pick a different tier or supply creds". * Deliberately substrate-name-free (no "Floci"/"loomster" literal) — behold - * has zero per-substrate logic; the note explains the SHAPE of the problem - * (a non-default tier can need parameters this environment lacks), not a - * specific project's story. */ -function tierErrorNote(tier: string | undefined, message: string): string | undefined { - if (!tier) return undefined; - return ( - `chant couldn't evaluate the "${tier}" tier here: ${message}\n` + - `A non-default tier (e.g. a production-only one) can need parameters — real ` + - `credentials, a different target — this environment doesn't have. Pick a ` + - `different tier to see its graph.` - ); + * has zero per-substrate logic; this explains the SHAPE of the problem, not a + * specific project's story. Exported for testing. */ +export function tierFailure(tier: string, message: string): RouteError { + return { + code: "tier", + error: `chant couldn't evaluate the "${tier}" tier here: ${message}`, + remedy: + `A non-default tier (e.g. a production-only one) can need parameters — real ` + + `credentials, a different target — this environment doesn't have. Pick a ` + + `different tier to see its graph.`, + }; } -/** A read route's error response, with a `tierNote` alongside `error` when the - * failure happened under a picked (non-default) tier — see `tierErrorNote`. */ +/** A read route's structured, typed error response (#72). `ChantCliError` + * (chant.ts) already classified a non-zero chant exit — lint gate / + * not-installed / generic eval — at throw time (`err.failure`); anything else + * that reaches a route's catch (a JSON.parse failure, a rejection that isn't + * chant's own) gets the same best-effort classification straight off its + * message text (`classifyChantFailure`), so every route that calls this gets + * a `code`, not just chant's own failures. + * + * A picked, non-default tier (M2, #54) always reports as "tier" instead of + * whatever chant-side code the underlying failure classified as — see + * `tierFailure`. A "not-installed" failure stays itself even under a picked + * tier: that failure is about the project not being there at all, which has + * nothing to do with which tier was asked for. */ function errorResponse(c: Context, opts: GraphOptions, err: unknown) { const message = err instanceof Error ? err.message : String(err); - const tierNote = tierErrorNote(opts.tier, message); - return c.json({ error: message, ...(tierNote ? { tierNote } : {}) }, 500); + const failure = err instanceof ChantCliError ? err.failure : classifyChantFailure(message); + const routeError: RouteError = + opts.tier && failure.code !== "not-installed" + ? tierFailure(opts.tier, failure.message) + : { error: failure.message, code: failure.code, remedy: failure.remedy }; + return c.json(routeError, 500); } /** The deploy axes in play (#59 unify, issue scope: "header line showing @@ -641,7 +677,10 @@ export function createApp( const { svg } = renderGraph(ir, { radial: new URL(c.req.url).searchParams.get("radial") === "1" }); return c.json({ ir, svg, meta: { projectDir: cfg.projectDir, env, mode: "overlay" } }); } catch (err) { - return c.json({ error: err instanceof Error ? err.message : String(err) }, 500); + // #72: the same structured {error, code, remedy} the other read routes + // return — this is in fact where a picked tier's creds gate USUALLY + // surfaces (an env pick routes the SPA's load() here, not /api/graph). + return errorResponse(c, query, err); } }); diff --git a/web/app.js b/web/app.js index bd48cfb..5aa557e 100644 --- a/web/app.js +++ b/web/app.js @@ -824,7 +824,10 @@ async function loadReconcile() { const q = lensParams(new URLSearchParams({ env: view.env })); const res = await apiFetch(`/api/reconcile?${q}`); const j = await res.json(); - if (!res.ok) throw new Error(j.tierNote || j.error || res.statusText); + // #72: the structured {error, code, remedy} src/server.ts's errorResponse + // now sends for every classified failure (tier included — `error` already + // carries the full tier-scoped message, replacing the old `tierNote`). + if (!res.ok) throw new Error(j.error || res.statusText); reconcileCache = j; renderDial(); } catch (e) { @@ -1184,17 +1187,46 @@ function ensureZoomControls(host) { }); } -// A graph/facet failure under a picked tier (M2, #54): the server explains it -// via `tierNote` (src/server.ts `tierErrorNote`) instead of a bare error — a -// calmer inline note in the graph pane, not the alarming red error box. Built -// via textContent, not innerHTML — the note embeds chant's own stderr text. -function renderTierNote(note) { +// Precondition-failure codes (#72) → a short, human title for the entry/error +// screen below. Mirrors every `code` a read route's structured error can +// carry (src/server.ts RouteErrorCode: chant.ts's lint/not-installed/eval, +// plus "tier" — a picked tier that needs creds this host lacks, M2 #54). +const PRECONDITION_TITLE = { + lint: "This project doesn't pass chant lint", + "not-installed": "This project isn't installed", + tier: "This tier needs credentials", + eval: "chant couldn't evaluate this project", +}; + +// A precondition failure — the lint gate, a not-installed/no-typegen project, +// or a tier that needs credentials (#72) — gets a readable card with chant's +// own message and a suggested remedy, not a blank canvas or a raw stack +// trace. Generalizes what used to be a tier-only `tierNote` (the server now +// sends the same structured {error, code, remedy} for every classified +// failure — src/server.ts errorResponse). Text content only (never +// innerHTML) — `error` embeds chant's own stderr verbatim. +function renderPreconditionError(body) { const host = document.getElementById("graph"); host.innerHTML = ""; - const div = document.createElement("div"); - div.className = "tier-note"; - div.textContent = note; - host.appendChild(div); + const card = document.createElement("div"); + card.className = "precondition-error"; + const title = document.createElement("div"); + title.className = "precondition-error-title"; + title.textContent = PRECONDITION_TITLE[body.code] || "chant couldn't evaluate this project"; + card.appendChild(title); + if (body.error) { + const message = document.createElement("div"); + message.className = "precondition-error-message"; + message.textContent = body.error; + card.appendChild(message); + } + if (body.remedy) { + const remedy = document.createElement("div"); + remedy.className = "precondition-error-remedy"; + remedy.textContent = body.remedy; + card.appendChild(remedy); + } + host.appendChild(card); document.getElementById("legend").style.display = "none"; document.getElementById("component-legend").style.display = "none"; } @@ -1247,11 +1279,12 @@ async function load(opts = {}) { const res = await apiFetch(`${endpoint}?${q}`); const body = await res.json(); if (!res.ok) { - // M2 (#54): a tier-scoped failure gets the calmer inline note instead of - // the generic red error box — see renderTierNote(). - if (body.tierNote) { - renderTierNote(body.tierNote); - meta.textContent = `tier ${view.tier} unavailable here`; + // #72: a classified precondition failure (lint gate, not installed, a + // tier that needs credentials) gets the calmer entry/error card instead + // of the generic red error box — see renderPreconditionError(). + if (body.code) { + renderPreconditionError(body); + meta.textContent = body.code === "tier" ? `tier ${view.tier} unavailable here` : "error"; renderDial(); return; } diff --git a/web/index.html b/web/index.html index e730160..152d8b7 100644 --- a/web/index.html +++ b/web/index.html @@ -86,11 +86,16 @@ @keyframes spin { to { transform: rotate(360deg); } } .sel rect, .sel path, .sel > rect { stroke: var(--pending) !important; stroke-width: 2.5 !important; } .err { padding: 24px; color: var(--foreign); } - /* M2 (#54): a graph/facet failure under a picked (non-default) tier — a - distinct, calmer note (not the alarming red .err) since it's usually - expected (e.g. a production-only tier not deployable on a local - target), not a bug. */ - .tier-note { padding: 24px; color: var(--muted); white-space: pre-wrap; max-width: 640px; } + /* #72: a classified precondition failure (chant's lint gate, a project + that isn't installed/typegen'd, a tier that needs credentials) reads + as a calm entry/error card — not the alarming red .err, since these + are usually expected states on a real (imperfect) project, not bugs. + Generalizes the old M2 (#54) tier-only `.tier-note`. */ + .precondition-error { padding: 24px; max-width: 640px; } + .precondition-error-title { color: var(--fg); font-size: 15px; font-weight: 600; margin-bottom: 10px; } + .precondition-error-message { color: var(--muted); white-space: pre-wrap; font: 12px/1.5 ui-monospace, monospace; } + .precondition-error-remedy { color: var(--pending); margin-top: 14px; } + .precondition-error-remedy::before { content: "→ "; } #actions button, #inspect button { background: var(--panel); color: var(--fg); border: 1px solid var(--line); border-radius: 6px; padding: 4px 12px; font-size: 12px; cursor: pointer; } #actions button:hover, #inspect button:hover { border-color: var(--pending); }