Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions src/chant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import {
lifecyclePlanArgs,
applyArgs,
runChantRaw,
stripAnsi,
classifyChantFailure,
ChantCliError,
graphIr,
} from "./chant.ts";
import { overlayStatus } from "./overlay.ts";

Expand Down Expand Up @@ -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 <args> exited <code>: <stderr>' 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: [] });
});
});
95 changes: 93 additions & 2 deletions src/chant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <args> exited <code>: <stderr>" 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<T>(args: string[], projectDir?: string, envOverride?: Record<string, string>): Promise<T> {
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;
}

Expand Down Expand Up @@ -362,7 +453,7 @@ export function parseCiPipeline(stdout: string): CiPipeline {
export function ciPipeline(projectDir: string, opts: GraphOptions = {}): Promise<CiPipeline> {
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);
});
}
Expand Down
129 changes: 129 additions & 0 deletions src/server.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<typeof import("node:child_process")>();
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<typeof spawnMock> {
const proc = new EventEmitter() as unknown as ReturnType<typeof spawnMock>;
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";
Expand Down Expand Up @@ -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);
});
});
Loading
Loading