Skip to content
Open
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
71 changes: 56 additions & 15 deletions src/reporters/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,12 @@ function renderNextActions(artifact: RunArtifact): string {
lines.push("");
lines.push(co(ANSI.bold, "Next Actions:"));

lines.push(` Auto-enforce: ${co(ANSI.bold, `npx @kryptosai/mcp-observatory enforce ${targetCmd}`)}`);
lines.push(` ${sym("bullet")} Auto-enforce: ${co(ANSI.bold, `npx @kryptosai/mcp-observatory enforce ${targetCmd}`)}`);

if (isSeatbeltAvailable()) {
lines.push(co(ANSI.dim, " → This generates a policy AND starts the proxy — protecting you immediately"));
lines.push(co(ANSI.dim, ` ${sym("bullet")} This generates a policy AND starts the proxy — protecting you immediately`));
} else {
lines.push(co(ANSI.dim, " → Already know the risks? Enforce at runtime with mcp-seatbelt"));
lines.push(co(ANSI.dim, ` ${sym("bullet")} Already know the risks? Enforce at runtime with mcp-seatbelt`));
}

return lines.join("\n");
Expand All @@ -59,12 +59,12 @@ export { renderNextActions };

function watchStatusIcon(status: CheckStatus): string {
switch (status) {
case "pass": return co(ANSI.green, "✓");
case "fail": return co(ANSI.red, "✗");
case "pass": return co(ANSI.green, sym("pass"));
case "fail": return co(ANSI.red, sym("fail"));
case "partial":
case "flaky": return co(ANSI.yellow, "⚠");
case "flaky": return co(ANSI.yellow, sym("warn"));
case "unsupported":
case "skipped": return co(ANSI.dim, "–");
case "skipped": return co(ANSI.dim, sym("skip"));
}
}

Expand Down Expand Up @@ -123,9 +123,9 @@ export function renderWatchFirstRun(artifact: RunArtifact): string {
return lines.join("\n");
}

/** No changes: header + */
/** No changes: header + pass symbol */
export function renderWatchNoChanges(artifact: RunArtifact): string {
return `${watchHeader(artifact)}\n${co(ANSI.green, "✓ No changes")}`;
return `${watchHeader(artifact)}\n${co(ANSI.green, `${sym("pass")} No changes`)}`;
}

/** Changes detected: header + only the changes */
Expand All @@ -135,24 +135,24 @@ export function renderWatchChanges(artifact: RunArtifact, diff: DiffArtifact): s
if (diff.regressions.length > 0) {
lines.push("");
for (const e of diff.regressions) {
lines.push(co(ANSI.red, ` ${e.id}: ${e.fromStatus ?? "n/a"} → ${e.toStatus ?? "n/a"} ${e.message}`));
lines.push(co(ANSI.red, ` ${sym("fail")} ${e.id}: ${e.fromStatus ?? "n/a"} → ${e.toStatus ?? "n/a"} ${e.message}`));
}
}
if (diff.recoveries.length > 0) {
lines.push("");
for (const e of diff.recoveries) {
lines.push(co(ANSI.green, ` ${e.id}: ${e.fromStatus ?? "n/a"} → ${e.toStatus ?? "n/a"} ${e.message}`));
lines.push(co(ANSI.green, ` ${sym("pass")} ${e.id}: ${e.fromStatus ?? "n/a"} → ${e.toStatus ?? "n/a"} ${e.message}`));
}
}
if (diff.schemaDrift && diff.schemaDrift.length > 0) {
lines.push("");
for (const e of diff.schemaDrift) {
lines.push(co(ANSI.yellow, ` ${e.name} (${e.capability}): ${e.changes.join(", ")}`));
lines.push(co(ANSI.yellow, ` ${sym("warn")} ${e.name} (${e.capability}): ${e.changes.join(", ")}`));
}
}
if (diff.responseChanges && diff.responseChanges.length > 0) {
for (const e of diff.responseChanges) {
lines.push(co(ANSI.yellow, ` ${e.name} (${e.capability}): ${e.change}`));
lines.push(co(ANSI.yellow, ` ${sym("warn")} ${e.name} (${e.capability}): ${e.change}`));
}
}

Expand All @@ -168,6 +168,47 @@ const ANSI = {
reset: "\x1b[0m",
} as const;

// ── Accessible status labels ────────────────────────────────────────────────
// Screen readers either skip the Unicode status glyphs or announce them by
// their character name ("check mark", "heavy multiplication x"), which carries
// no status meaning. In accessible mode they are swapped for text labels.
// Colour is orthogonal and stays on either way.

const SYMBOLS = {
pass: "✓",
fail: "✗",
warn: "⚠",
skip: "–",
info: "ℹ",
bullet: "→",
} as const;

const ACCESSIBLE_SYMBOLS: Record<keyof typeof SYMBOLS, string> = {
pass: "[PASS]",
fail: "[FAIL]",
warn: "[WARN]",
skip: "[SKIP]",
info: "[INFO]",
bullet: ">",
};

let _accessibleMode = false;

/** Swap Unicode status glyphs for screen-reader-friendly text labels. */
export function setAccessibleMode(value: boolean): void {
_accessibleMode = value;
}

/** Whether accessible (text-label) status output is active. */
export function isAccessibleMode(): boolean {
return _accessibleMode;
}

/** The glyph or text label for a status symbol, per the current mode. */
function sym(name: keyof typeof SYMBOLS): string {
return _accessibleMode ? ACCESSIBLE_SYMBOLS[name] : SYMBOLS[name];
}

const _argvNoColor = process.argv.includes("--no-color");

function shouldColor(): boolean {
Expand Down Expand Up @@ -259,7 +300,7 @@ function renderRunTerminal(artifact: RunArtifact): string {
lines.push("");
lines.push(co(ANSI.bold, "What Was Not Tested:"));
for (const entry of notObserved) {
const icon = entry.severity === "warning" ? co(ANSI.yellow, "⚠") : co(ANSI.dim, "ℹ");
const icon = entry.severity === "warning" ? co(ANSI.yellow, sym("warn")) : co(ANSI.dim, sym("info"));
lines.push(` ${icon} ${entry.category}: ${entry.detail}`);
}
}
Expand All @@ -272,7 +313,7 @@ function renderRunTerminal(artifact: RunArtifact): string {
lines.push("");
lines.push(co(ANSI.red, " Security:"));
for (const d of diagnostics.slice(0, 3)) {
lines.push(` ${co(ANSI.dim, "→")} ${d}`);
lines.push(` ${co(ANSI.dim, sym("bullet"))} ${d}`);
}
if (diagnostics.length > 3) {
lines.push(` ${co(ANSI.dim, ` ...and ${diagnostics.length - 3} more (run with --security for full scan)`)}`);
Expand Down
167 changes: 167 additions & 0 deletions tests/reporters-accessible.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { afterEach, describe, expect, it } from "vitest";

import {
isAccessibleMode,
renderTerminal,
renderWatchChanges,
renderWatchFirstRun,
renderWatchNoChanges,
setAccessibleMode,
} from "../src/reporters/terminal.js";
import type { DiffArtifact, RunArtifact } from "../src/types.js";
import { makeArtifact } from "./fixtures/test-helpers.js";

/** The status glyphs accessible mode is responsible for replacing.
*
* The `→` in a status transition ("pass → fail") is deliberately not here: it
* is prose inside a message, not a status symbol, and reads correctly. Only
* the leading bullet arrow is swapped — asserted separately below.
*/
const GLYPHS = ["✓", "✗", "⚠", "–", "ℹ"] as const;

function check(
id: RunArtifact["checks"][number]["id"],
status: RunArtifact["checks"][number]["status"],
): RunArtifact["checks"][number] {
return {
id,
capability: id,
status,
durationMs: 1,
message: `${status} message`,
evidence: [
{
endpoint: `${id}/endpoint`,
advertised: true,
responded: true,
minimalShapePresent: true,
itemCount: 1,
identifiers: ["alpha"],
diagnostics: ["a diagnostic"],
schemas: {},
},
],
};
}

function artifact(): RunArtifact {
const a = makeArtifact([
check("tools", "pass"),
check("security-lite", "fail"),
check("prompts", "partial"),
check("resources", "skipped"),
]);
a.gate = "fail";
return a;
}

function diff(): DiffArtifact {
return {
artifactType: "diff",
schemaVersion: "1.0.0",
gate: "fail",
baseRunId: "base-run",
headRunId: "head-run",
createdAt: "2026-07-02T00:00:00Z",
summary: {
regressions: 1,
recoveries: 1,
unchanged: 0,
added: 0,
removed: 0,
schemaDriftCount: 1,
responseChangeCount: 1,
gate: "fail",
},
regressions: [
{ id: "tools", capability: "tools", fromStatus: "pass", toStatus: "fail", message: "Tool broke" },
],
recoveries: [
{ id: "prompts", capability: "prompts", fromStatus: "fail", toStatus: "pass", message: "Prompt recovered" },
],
unchanged: [],
added: [],
removed: [],
schemaDrift: [
{ capability: "tools", name: "create_issue", severity: "high", changes: ["added required property type"] },
],
responseChanges: [{ capability: "tools", name: "create_issue", change: "response shape changed" }],
} as DiffArtifact;
}

/** Every terminal surface that renders status symbols. */
function renderAll(): string {
return [
renderTerminal(artifact()),
renderWatchFirstRun(artifact()),
renderWatchNoChanges(artifact()),
renderWatchChanges(artifact(), diff()),
].join("\n");
}

afterEach(() => {
setAccessibleMode(false);
});

describe("accessible mode", () => {
it("defaults to off", () => {
expect(isAccessibleMode()).toBe(false);
});

it("keeps the Unicode glyphs when off", () => {
const output = renderAll();

expect(output).toContain("✓");
expect(output).toContain("✗");
expect(output).toContain("⚠");
expect(output).not.toContain("[PASS]");
expect(output).not.toContain("[FAIL]");
});

it("replaces every status glyph with a text label when on", () => {
setAccessibleMode(true);

const output = renderAll();

for (const glyph of GLYPHS) {
expect(output, `expected no "${glyph}" in accessible output`).not.toContain(glyph);
}
expect(output).toContain("[PASS]");
expect(output).toContain("[FAIL]");
expect(output).toContain("[WARN]");
});

it("labels skipped and unsupported checks as [SKIP]", () => {
setAccessibleMode(true);

expect(renderWatchFirstRun(artifact())).toContain("[SKIP]");
});

it("swaps the leading bullet arrow but keeps status-transition arrows", () => {
setAccessibleMode(true);

// Bullet marker on the Next Actions list becomes ASCII.
expect(renderTerminal(artifact())).toContain("> Auto-enforce:");
// The "from → to" arrow is prose in a message and is left alone.
expect(renderWatchChanges(artifact(), diff())).toContain("pass → fail");
});

it("is reversible — turning it back off restores the glyphs", () => {
setAccessibleMode(true);
expect(renderWatchNoChanges(artifact())).toContain("[PASS]");

setAccessibleMode(false);

expect(renderWatchNoChanges(artifact())).toContain("✓");
});

it("leaves colour formatting untouched", () => {
const plain = renderWatchNoChanges(artifact());
setAccessibleMode(true);
const accessible = renderWatchNoChanges(artifact());

// Same ANSI codes on both, only the glyph between them differs.
const codes = (s: string) => s.match(/\x1b\[\d+m/g) ?? [];
expect(codes(accessible)).toEqual(codes(plain));
});
});