feat(cli): deprecate validate, inspect, layout in favor of check

One stderr notice per invocation and _meta.deprecated: true in JSON mode
(shared helper next to withMeta; layout owns both inspect and layout via
createInspectCommand). Help descriptions gain the pointer. No behavior
change; removal ships separately once migration telemetry says usage
has decayed.

fix(producer): route info/debug logs to stderr — the compiler's
'Localized remote media' line was landing on stdout ahead of validate's
--json payload, breaking every piped consumer. Diagnostics now share
stderr with warn/error; render progress uses its own channel.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-10 13:27:52 -04:00
parent 3a02942a03
commit 58f45ef758
9 changed files with 500 additions and 86 deletions
+81
View File
@@ -0,0 +1,81 @@
import type { CommandDef } from "citty";
import { runCommand } from "citty";
import { afterEach, describe, expect, it, vi } from "vitest";
// See layout.test.ts for why these two dynamic-import targets are mocked:
// resolveProject skips real filesystem resolution, and bundleToSingleHtml
// gives a fast, deterministic failure that exercises run()'s outer catch
// (the JSON failure envelope) without needing headless Chrome.
const FAKE_PROJECT = {
dir: "/fake-project",
name: "fake-project",
indexPath: "/fake-project/index.html",
};
vi.mock("../utils/project.js", () => ({
resolveProject: vi.fn(() => FAKE_PROJECT),
}));
vi.mock("@hyperframes/core/compiler", () => ({
bundleToSingleHtml: vi.fn(async () => {
throw new Error("bundling failed (test double)");
}),
}));
import inspectCommand from "./inspect.js";
function metaDescription(command: CommandDef): string {
const meta = command.meta;
if (meta && typeof meta === "object" && "description" in meta) {
return String(meta.description ?? "");
}
throw new Error("expected a synchronous meta object");
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("inspect command deprecation (U5)", () => {
it("is the compatibility alias for layout, sharing its deprecated description", () => {
expect(metaDescription(inspectCommand)).toContain("(deprecated, use check)");
});
it("prints a one-line deprecation notice naming 'inspect' on stderr, never stdout", async () => {
const stderrWrites: string[] = [];
const stdoutWrites: string[] = [];
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
stderrWrites.push(String(chunk));
return true;
});
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
stdoutWrites.push(String(chunk));
return true;
});
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
vi.spyOn(console, "log").mockImplementation(() => {});
await runCommand(inspectCommand, { rawArgs: ["--json"] });
const stderrText = stderrWrites.join("");
expect(stderrText).toContain("hyperframes inspect");
expect(stderrText).toContain("hyperframes check");
expect(stdoutWrites.join("")).toBe("");
});
it("--json output is valid JSON with _meta.deprecated === true on failure", async () => {
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runCommand(inspectCommand, { rawArgs: ["--json"] });
const jsonCall = logSpy.mock.calls.find(
([arg]) => typeof arg === "string" && arg.trim().startsWith("{"),
);
expect(jsonCall).toBeDefined();
const parsed = JSON.parse(String(jsonCall?.[0]));
expect(parsed.ok).toBe(false);
expect(parsed._meta.deprecated).toBe(true);
});
});
+104
View File
@@ -0,0 +1,104 @@
import type { CommandDef } from "citty";
import { runCommand } from "citty";
import { afterEach, describe, expect, it, vi } from "vitest";
// resolveProject and bundleToSingleHtml are both reached via a dynamic
// `await import(...)` inside layout.ts's run() / runLayoutAudit(), so
// vi.mock intercepts them the same way it would a static import. Mocking
// resolveProject skips real filesystem project resolution; mocking
// bundleToSingleHtml gives a deterministic, fast failure well before any
// real browser or network work — exercising run()'s outer catch (the JSON
// failure envelope) without needing headless Chrome.
const FAKE_PROJECT = {
dir: "/fake-project",
name: "fake-project",
indexPath: "/fake-project/index.html",
};
vi.mock("../utils/project.js", () => ({
resolveProject: vi.fn(() => FAKE_PROJECT),
}));
vi.mock("@hyperframes/core/compiler", () => ({
bundleToSingleHtml: vi.fn(async () => {
throw new Error("bundling failed (test double)");
}),
}));
import { createInspectCommand } from "./layout.js";
/**
* citty's `meta` is `Resolvable<CommandMeta>` (object | promise | thunk).
* This file's commands always define it as a synchronous object literal, so
* narrow to that shape instead of asserting it with `as`.
*/
function metaDescription(command: CommandDef): string {
const meta = command.meta;
if (meta && typeof meta === "object" && "description" in meta) {
return String(meta.description ?? "");
}
throw new Error("expected a synchronous meta object");
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("layout command deprecation (U5)", () => {
it("marks both the layout and inspect command names' shared description as deprecated", () => {
expect(metaDescription(createInspectCommand("layout"))).toContain("(deprecated, use check)");
expect(metaDescription(createInspectCommand("inspect"))).toContain("(deprecated, use check)");
});
it("prints a one-line deprecation notice to stderr and never to stdout", async () => {
const stderrWrites: string[] = [];
const stdoutWrites: string[] = [];
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
stderrWrites.push(String(chunk));
return true;
});
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
stdoutWrites.push(String(chunk));
return true;
});
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
vi.spyOn(console, "log").mockImplementation(() => {});
await runCommand(createInspectCommand("layout"), { rawArgs: ["--json"] });
const stderrText = stderrWrites.join("");
expect(stderrText).toContain("hyperframes layout");
expect(stderrText).toContain("hyperframes check");
expect(stdoutWrites.join("")).toBe("");
});
it("--json output is valid JSON with _meta.deprecated === true on failure", async () => {
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runCommand(createInspectCommand("layout"), { rawArgs: ["--json"] });
const jsonCall = logSpy.mock.calls.find(
([arg]) => typeof arg === "string" && arg.trim().startsWith("{"),
);
expect(jsonCall).toBeDefined();
const parsed = JSON.parse(String(jsonCall?.[0]));
expect(parsed.ok).toBe(false);
expect(parsed._meta.deprecated).toBe(true);
});
it("the inspect command name produces the same _meta.deprecated === true envelope", async () => {
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runCommand(createInspectCommand("inspect"), { rawArgs: ["--json"] });
const jsonCall = logSpy.mock.calls.find(
([arg]) => typeof arg === "string" && arg.trim().startsWith("{"),
);
const parsed = JSON.parse(String(jsonCall?.[0]));
expect(parsed._meta.deprecated).toBe(true);
});
});
+18 -8
View File
@@ -7,7 +7,7 @@ import { c } from "../ui/colors.js";
import { resolveProject } from "../utils/project.js"; import { resolveProject } from "../utils/project.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js"; import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
import { withMeta } from "../utils/updateCheck.js"; import { printDeprecationNotice, withMeta } from "../utils/updateCheck.js";
import { import {
buildLayoutSampleTimes, buildLayoutSampleTimes,
buildTransitionSampleTimes, buildTransitionSampleTimes,
@@ -388,7 +388,8 @@ function resolveMotionSpec(specPath: string, json: boolean): MotionSpec {
if (json) { if (json) {
console.log( console.log(
JSON.stringify( JSON.stringify(
withMeta({ withMeta(
{
schemaVersion: INSPECT_SCHEMA_VERSION, schemaVersion: INSPECT_SCHEMA_VERSION,
ok: false, ok: false,
error: message, error: message,
@@ -397,7 +398,9 @@ function resolveMotionSpec(specPath: string, json: boolean): MotionSpec {
warningCount: 0, warningCount: 0,
infoCount: 0, infoCount: 0,
issueCount: 0, issueCount: 0,
}), },
{ deprecated: true },
),
null, null,
2, 2,
), ),
@@ -422,7 +425,7 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
meta: { meta: {
name: commandName, name: commandName,
description: description:
"Inspect rendered composition layout for text/container overflow, plus optional motion verification via a *.motion.json sidecar", "Inspect rendered composition layout for text/container overflow, plus optional motion verification via a *.motion.json sidecar (deprecated, use check)",
}, },
args: { args: {
dir: { type: "positional", description: "Project directory", required: false }, dir: { type: "positional", description: "Project directory", required: false },
@@ -476,6 +479,7 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
// Pre-existing command-run branching; U1 only swapped the seek internals. // Pre-existing command-run branching; U1 only swapped the seek internals.
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
async run({ args }) { async run({ args }) {
printDeprecationNotice(commandName);
const project = resolveProject(args.dir); const project = resolveProject(args.dir);
const samples = Math.max(1, parseInt(args.samples as string, 10) || 9); const samples = Math.max(1, parseInt(args.samples as string, 10) || 9);
const tolerance = Math.max(0, parseFloat(args.tolerance as string) || 2); const tolerance = Math.max(0, parseFloat(args.tolerance as string) || 2);
@@ -534,7 +538,8 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
if (args.json) { if (args.json) {
console.log( console.log(
JSON.stringify( JSON.stringify(
withMeta({ withMeta(
{
schemaVersion: INSPECT_SCHEMA_VERSION, schemaVersion: INSPECT_SCHEMA_VERSION,
duration: result.duration, duration: result.duration,
samples: result.samples, samples: result.samples,
@@ -552,7 +557,9 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
truncated: limited.truncated, truncated: limited.truncated,
ok, ok,
issues: limited.issues, issues: limited.issues,
}), },
{ deprecated: true },
),
null, null,
2, 2,
), ),
@@ -602,7 +609,8 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
if (args.json) { if (args.json) {
console.log( console.log(
JSON.stringify( JSON.stringify(
withMeta({ withMeta(
{
schemaVersion: INSPECT_SCHEMA_VERSION, schemaVersion: INSPECT_SCHEMA_VERSION,
ok: false, ok: false,
error: message, error: message,
@@ -611,7 +619,9 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
warningCount: 0, warningCount: 0,
infoCount: 0, infoCount: 0,
issueCount: 0, issueCount: 0,
}), },
{ deprecated: true },
),
null, null,
2, 2,
), ),
+85 -1
View File
@@ -1,4 +1,6 @@
import { describe, expect, it, vi } from "vitest"; import type { CommandDef } from "citty";
import { runCommand } from "citty";
import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
extractCompositionErrorsFromLint, extractCompositionErrorsFromLint,
navigationTimeoutHint, navigationTimeoutHint,
@@ -28,6 +30,29 @@ vi.mock("../utils/producer.js", () => ({
})), })),
})); }));
// U5 deprecation tests: resolveProject and lintProject are both reached via a
// dynamic `await import(...)` inside validate.ts's run() / validateInBrowser(),
// so vi.mock intercepts them the same way it would a static import. Mocking
// resolveProject skips real filesystem project resolution; mocking lintProject
// (the first await inside validateInBrowser) gives a fast, deterministic
// failure well before any real browser or network work — exercising run()'s
// outer catch (the JSON failure envelope) without needing headless Chrome.
const FAKE_PROJECT = {
dir: "/fake-project",
name: "fake-project",
indexPath: "/fake-project/index.html",
};
vi.mock("../utils/project.js", () => ({
resolveProject: vi.fn(() => FAKE_PROJECT),
}));
vi.mock("../utils/lintProject.js", () => ({
lintProject: vi.fn(async () => {
throw new Error("lint failed (test double)");
}),
}));
// Regression for the validate audio-duration-probe timeout: a slow-loading // Regression for the validate audio-duration-probe timeout: a slow-loading
// media element's duration was snapshotted once, at a fixed point in time, // media element's duration was snapshotted once, at a fixed point in time,
// and any element still mid-load was permanently misreported as unreadable. // and any element still mid-load was permanently misreported as unreadable.
@@ -282,3 +307,62 @@ describe("navigationTimeoutHint", () => {
expect(navigationTimeoutHint("some string failure", 10000)).toBeNull(); expect(navigationTimeoutHint("some string failure", 10000)).toBeNull();
}); });
}); });
function metaDescription(command: CommandDef): string {
const meta = command.meta;
if (meta && typeof meta === "object" && "description" in meta) {
return String(meta.description ?? "");
}
throw new Error("expected a synchronous meta object");
}
describe("validate command deprecation (U5)", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("marks the command description as deprecated", async () => {
const { default: validateCommand } = await import("./validate.js");
expect(metaDescription(validateCommand)).toContain("(deprecated, use check)");
});
it("prints a one-line deprecation notice to stderr and never to stdout", async () => {
const stderrWrites: string[] = [];
const stdoutWrites: string[] = [];
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
stderrWrites.push(String(chunk));
return true;
});
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
stdoutWrites.push(String(chunk));
return true;
});
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
vi.spyOn(console, "log").mockImplementation(() => {});
const { default: validateCommand } = await import("./validate.js");
await runCommand(validateCommand, { rawArgs: ["--json"] });
const stderrText = stderrWrites.join("");
expect(stderrText).toContain("hyperframes validate");
expect(stderrText).toContain("hyperframes check");
expect(stdoutWrites.join("")).toBe("");
});
it("--json output is valid JSON with _meta.deprecated === true on failure", async () => {
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const { default: validateCommand } = await import("./validate.js");
await runCommand(validateCommand, { rawArgs: ["--json"] });
const jsonCall = logSpy.mock.calls.find(
([arg]) => typeof arg === "string" && arg.trim().startsWith("{"),
);
expect(jsonCall).toBeDefined();
const parsed = JSON.parse(String(jsonCall?.[0]));
expect(parsed.ok).toBe(false);
expect(parsed._meta.deprecated).toBe(true);
});
});
+13 -5
View File
@@ -13,7 +13,7 @@ import { normalizeErrorMessage } from "../utils/errorMessage.js";
import type { ProjectLintResult } from "../utils/lintProject.js"; import type { ProjectLintResult } from "../utils/lintProject.js";
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js"; import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
import { c } from "../ui/colors.js"; import { c } from "../ui/colors.js";
import { withMeta } from "../utils/updateCheck.js"; import { printDeprecationNotice, withMeta } from "../utils/updateCheck.js";
import { import {
resolveCliChromeGpuMode, resolveCliChromeGpuMode,
seekCompositionTimeline, seekCompositionTimeline,
@@ -514,13 +514,16 @@ function emitJsonReport(
): void { ): void {
console.log( console.log(
JSON.stringify( JSON.stringify(
withMeta({ withMeta(
{
ok: errors.length === 0, ok: errors.length === 0,
errors, errors,
warnings, warnings,
contrast, contrast,
contrastFailures: contrastFailures.length, contrastFailures: contrastFailures.length,
}), },
{ deprecated: true },
),
null, null,
2, 2,
), ),
@@ -567,7 +570,11 @@ function emitTextReport(
function emitFailureReport(message: string, asJson: boolean): void { function emitFailureReport(message: string, asJson: boolean): void {
if (asJson) { if (asJson) {
console.log( console.log(
JSON.stringify(withMeta({ ok: false, error: message, errors: [], warnings: [] }), null, 2), JSON.stringify(
withMeta({ ok: false, error: message, errors: [], warnings: [] }, { deprecated: true }),
null,
2,
),
); );
return; return;
} }
@@ -577,7 +584,7 @@ function emitFailureReport(message: string, asJson: boolean): void {
export default defineCommand({ export default defineCommand({
meta: { meta: {
name: "validate", name: "validate",
description: `Load a composition in headless Chrome and report console errors description: `Load a composition in headless Chrome and report console errors (deprecated, use check)
Examples: Examples:
hyperframes validate hyperframes validate
@@ -602,6 +609,7 @@ Examples:
}, },
}, },
async run({ args }) { async run({ args }) {
printDeprecationNotice("validate");
const project = resolveProject(args.dir); const project = resolveProject(args.dir);
const timeout = parseInt(args.timeout as string, 10) || 3000; const timeout = parseInt(args.timeout as string, 10) || 3000;
const useContrast = args.contrast ?? true; const useContrast = args.contrast ?? true;
+63 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { isSafeVersion } from "./updateCheck.js"; import { isSafeVersion, printDeprecationNotice, withMeta } from "./updateCheck.js";
describe("isSafeVersion", () => { describe("isSafeVersion", () => {
it("accepts strict semver, incl. prerelease/build metadata", () => { it("accepts strict semver, incl. prerelease/build metadata", () => {
@@ -150,6 +150,68 @@ async function checkWith(registryVersion: unknown): Promise<{
} }
} }
/**
* U5: validate/inspect/layout are deprecated in favor of `check`. withMeta's
* optional `{ deprecated: true }` is the single place that adds `_meta.deprecated`
* to a --json envelope; every other command (check, lint, ...) calls withMeta
* with no second argument and must never see the key at all — not even `false`.
*/
describe("withMeta — deprecated flag", () => {
it("omits _meta.deprecated entirely when no options are passed (check/lint et al.)", () => {
const wrapped = withMeta({ ok: true });
expect("deprecated" in wrapped._meta).toBe(false);
});
it("omits _meta.deprecated when options.deprecated is false", () => {
const wrapped = withMeta({ ok: true }, { deprecated: false });
expect("deprecated" in wrapped._meta).toBe(false);
});
it("sets _meta.deprecated === true when requested (validate/inspect/layout)", () => {
const wrapped = withMeta({ ok: true }, { deprecated: true });
expect(wrapped._meta.deprecated).toBe(true);
});
it("preserves the rest of the _meta envelope alongside the deprecated flag", () => {
const wrapped = withMeta({ ok: true }, { deprecated: true });
expect(wrapped._meta.version).toEqual(expect.any(String));
expect(typeof wrapped._meta.updateAvailable).toBe("boolean");
});
});
/**
* The stderr-only deprecation notice: printed once per invocation, never on
* stdout, so --json output stays pure JSON while humans still see the notice.
*/
describe("printDeprecationNotice", () => {
it("writes exactly one line to stderr, never stdout", () => {
const stderrWrites: string[] = [];
const stdoutWrites: string[] = [];
const origErrWrite = process.stderr.write.bind(process.stderr);
const origOutWrite = process.stdout.write.bind(process.stdout);
process.stderr.write = ((chunk: unknown) => {
stderrWrites.push(String(chunk));
return true;
}) as typeof process.stderr.write;
process.stdout.write = ((chunk: unknown) => {
stdoutWrites.push(String(chunk));
return true;
}) as typeof process.stdout.write;
try {
printDeprecationNotice("validate");
} finally {
process.stderr.write = origErrWrite;
process.stdout.write = origOutWrite;
}
expect(stdoutWrites).toEqual([]);
expect(stderrWrites).toHaveLength(1);
expect(stderrWrites[0]).toContain("hyperframes validate");
expect(stderrWrites[0]).toContain("hyperframes check");
});
});
describe("checkForUpdate — registry boundary guard", () => { describe("checkForUpdate — registry boundary guard", () => {
afterEach(() => { afterEach(() => {
vi.doUnmock("../telemetry/config.js"); vi.doUnmock("../telemetry/config.js");
+25 -2
View File
@@ -40,6 +40,8 @@ export interface UpdateMeta {
version: string; version: string;
latestVersion?: string; latestVersion?: string;
updateAvailable: boolean; updateAvailable: boolean;
/** Present (and true) only for commands superseded by `check`; absent otherwise. */
deprecated?: boolean;
} }
/** /**
@@ -130,9 +132,30 @@ export function getUpdateMeta(): UpdateMeta {
/** /**
* Wrap a JSON payload with the _meta version envelope. * Wrap a JSON payload with the _meta version envelope.
* Use this in all --json command outputs for consistent agent-friendly metadata. * Use this in all --json command outputs for consistent agent-friendly metadata.
*
* Pass `{ deprecated: true }` from a command superseded by `check` (validate,
* inspect, layout) to add `_meta.deprecated: true`; every other call site is
* unaffected — the key is only ever added, never set to `false`.
*/ */
export function withMeta<T extends object>(data: T): T & { _meta: UpdateMeta } { export function withMeta<T extends object>(
return { ...data, _meta: getUpdateMeta() }; data: T,
options?: { deprecated?: boolean },
): T & { _meta: UpdateMeta } {
const meta = getUpdateMeta();
if (options?.deprecated) meta.deprecated = true;
return { ...data, _meta: meta };
}
/**
* One-line deprecation notice for a command superseded by `check`. Always
* writes to stderr (never stdout), so a --json invocation's stdout stays
* pure, parseable JSON. Call once per invocation, before the command's own
* output.
*/
export function printDeprecationNotice(command: string): void {
process.stderr.write(
`'hyperframes ${command}' is deprecated and will be removed in a future release. Use 'hyperframes check' instead.\n`,
);
} }
/** /**
+65 -29
View File
@@ -1,13 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createConsoleLogger, defaultLogger } from "./logger.js"; import { createConsoleLogger, defaultLogger } from "./logger.js";
import type { LogLevel, ProducerLogger } from "./logger.js"; import type { LogLevel, ProducerLogger } from "./logger.js";
describe("createConsoleLogger", () => { describe("createConsoleLogger", () => {
// We capture calls to console.{log,warn,error} via `mock` so we can // We capture calls to console.{log,warn,error} via `vi.fn` so we can
// assert what would have been printed without polluting test output. // assert what would have been printed without polluting test output.
let logSpy: ReturnType<typeof mock>; let logSpy: ReturnType<typeof vi.fn>;
let warnSpy: ReturnType<typeof mock>; let warnSpy: ReturnType<typeof vi.fn>;
let errorSpy: ReturnType<typeof mock>; let errorSpy: ReturnType<typeof vi.fn>;
let origLog: typeof console.log; let origLog: typeof console.log;
let origWarn: typeof console.warn; let origWarn: typeof console.warn;
let origError: typeof console.error; let origError: typeof console.error;
@@ -16,9 +16,9 @@ describe("createConsoleLogger", () => {
origLog = console.log; origLog = console.log;
origWarn = console.warn; origWarn = console.warn;
origError = console.error; origError = console.error;
logSpy = mock(() => {}); logSpy = vi.fn();
warnSpy = mock(() => {}); warnSpy = vi.fn();
errorSpy = mock(() => {}); errorSpy = vi.fn();
console.log = logSpy as unknown as typeof console.log; console.log = logSpy as unknown as typeof console.log;
console.warn = warnSpy as unknown as typeof console.warn; console.warn = warnSpy as unknown as typeof console.warn;
console.error = errorSpy as unknown as typeof console.error; console.error = errorSpy as unknown as typeof console.error;
@@ -30,6 +30,43 @@ describe("createConsoleLogger", () => {
console.error = origError; console.error = origError;
}); });
// All four levels are stderr-bound (console.error/console.warn); console.log
// (stdout) must never be touched, since producer runs inside CLI commands
// whose stdout is a machine-readable contract (e.g. `validate --json`).
describe("stdout/stderr routing", () => {
it("info and debug write to console.error (stderr), never console.log (stdout)", () => {
const log = createConsoleLogger("debug");
log.info("info-msg");
log.debug("debug-msg");
expect(logSpy).not.toHaveBeenCalled();
expect(errorSpy.mock.calls.map((c) => c[0])).toEqual([
"[INFO] info-msg",
"[DEBUG] debug-msg",
]);
});
it("warn and error keep their pre-existing channels (console.warn / console.error)", () => {
const log = createConsoleLogger("debug");
log.warn("warn-msg");
log.error("error-msg");
expect(logSpy).not.toHaveBeenCalled();
expect(warnSpy.mock.calls[0]?.[0]).toBe("[WARN] warn-msg");
expect(errorSpy.mock.calls[0]?.[0]).toBe("[ERROR] error-msg");
});
it("console.log is never called at any level", () => {
const log = createConsoleLogger("debug");
log.debug("d");
log.info("i");
log.warn("w");
log.error("e");
expect(logSpy).not.toHaveBeenCalled();
});
});
describe("level filtering", () => { describe("level filtering", () => {
it("level=info drops debug, keeps info/warn/error", () => { it("level=info drops debug, keeps info/warn/error", () => {
const log = createConsoleLogger("info"); const log = createConsoleLogger("info");
@@ -38,12 +75,12 @@ describe("createConsoleLogger", () => {
log.warn("warn-msg"); log.warn("warn-msg");
log.error("error-msg"); log.error("error-msg");
expect(logSpy.mock.calls.length).toBe(1); // info + error both route to console.error now (info: stderr routing, error: always stderr).
expect(logSpy.mock.calls[0]?.[0]).toBe("[INFO] info-msg"); expect(errorSpy.mock.calls.length).toBe(2);
expect(errorSpy.mock.calls[0]?.[0]).toBe("[INFO] info-msg");
expect(errorSpy.mock.calls[1]?.[0]).toBe("[ERROR] error-msg");
expect(warnSpy.mock.calls.length).toBe(1); expect(warnSpy.mock.calls.length).toBe(1);
expect(warnSpy.mock.calls[0]?.[0]).toBe("[WARN] warn-msg"); expect(warnSpy.mock.calls[0]?.[0]).toBe("[WARN] warn-msg");
expect(errorSpy.mock.calls.length).toBe(1);
expect(errorSpy.mock.calls[0]?.[0]).toBe("[ERROR] error-msg");
}); });
it("level=debug keeps all four levels", () => { it("level=debug keeps all four levels", () => {
@@ -53,12 +90,12 @@ describe("createConsoleLogger", () => {
log.warn("w"); log.warn("w");
log.error("e"); log.error("e");
// info + debug both go to console.log // debug + info + error all go to console.error
expect(logSpy.mock.calls.length).toBe(2); expect(errorSpy.mock.calls.length).toBe(3);
expect(logSpy.mock.calls[0]?.[0]).toBe("[DEBUG] d"); expect(errorSpy.mock.calls[0]?.[0]).toBe("[DEBUG] d");
expect(logSpy.mock.calls[1]?.[0]).toBe("[INFO] i"); expect(errorSpy.mock.calls[1]?.[0]).toBe("[INFO] i");
expect(errorSpy.mock.calls[2]?.[0]).toBe("[ERROR] e");
expect(warnSpy.mock.calls.length).toBe(1); expect(warnSpy.mock.calls.length).toBe(1);
expect(errorSpy.mock.calls.length).toBe(1);
}); });
it("level=warn drops info and debug, keeps warn/error", () => { it("level=warn drops info and debug, keeps warn/error", () => {
@@ -68,9 +105,9 @@ describe("createConsoleLogger", () => {
log.warn("w"); log.warn("w");
log.error("e"); log.error("e");
expect(logSpy.mock.calls.length).toBe(0);
expect(warnSpy.mock.calls.length).toBe(1);
expect(errorSpy.mock.calls.length).toBe(1); expect(errorSpy.mock.calls.length).toBe(1);
expect(errorSpy.mock.calls[0]?.[0]).toBe("[ERROR] e");
expect(warnSpy.mock.calls.length).toBe(1);
}); });
it("level=error drops everything except error", () => { it("level=error drops everything except error", () => {
@@ -80,7 +117,6 @@ describe("createConsoleLogger", () => {
log.warn("w"); log.warn("w");
log.error("e"); log.error("e");
expect(logSpy.mock.calls.length).toBe(0);
expect(warnSpy.mock.calls.length).toBe(0); expect(warnSpy.mock.calls.length).toBe(0);
expect(errorSpy.mock.calls.length).toBe(1); expect(errorSpy.mock.calls.length).toBe(1);
}); });
@@ -90,8 +126,8 @@ describe("createConsoleLogger", () => {
log.debug("d"); log.debug("d");
log.info("i"); log.info("i");
expect(logSpy.mock.calls.length).toBe(1); expect(errorSpy.mock.calls.length).toBe(1);
expect(logSpy.mock.calls[0]?.[0]).toBe("[INFO] i"); expect(errorSpy.mock.calls[0]?.[0]).toBe("[INFO] i");
}); });
}); });
@@ -100,14 +136,14 @@ describe("createConsoleLogger", () => {
const log = createConsoleLogger("info"); const log = createConsoleLogger("info");
log.info("hello", { a: 1, b: "two" }); log.info("hello", { a: 1, b: "two" });
expect(logSpy.mock.calls[0]?.[0]).toBe('[INFO] hello {"a":1,"b":"two"}'); expect(errorSpy.mock.calls[0]?.[0]).toBe('[INFO] hello {"a":1,"b":"two"}');
}); });
it("emits message only when meta is omitted", () => { it("emits message only when meta is omitted", () => {
const log = createConsoleLogger("info"); const log = createConsoleLogger("info");
log.info("plain"); log.info("plain");
expect(logSpy.mock.calls[0]?.[0]).toBe("[INFO] plain"); expect(errorSpy.mock.calls[0]?.[0]).toBe("[INFO] plain");
}); });
it("does not invoke JSON.stringify when level is filtered out", () => { it("does not invoke JSON.stringify when level is filtered out", () => {
@@ -123,7 +159,7 @@ describe("createConsoleLogger", () => {
}; };
// Should not throw — debug is below the info threshold. // Should not throw — debug is below the info threshold.
log.debug("trap", trap as unknown as Record<string, unknown>); log.debug("trap", trap as unknown as Record<string, unknown>);
expect(logSpy.mock.calls.length).toBe(0); expect(errorSpy.mock.calls.length).toBe(0);
}); });
}); });
@@ -185,7 +221,7 @@ describe("createConsoleLogger", () => {
} }
expect(buildCount).toBe(0); expect(buildCount).toBe(0);
expect(logSpy.mock.calls.length).toBe(0); expect(errorSpy.mock.calls.length).toBe(0);
}); });
it("call-site gate runs the meta builder when debug is enabled", () => { it("call-site gate runs the meta builder when debug is enabled", () => {
@@ -203,7 +239,7 @@ describe("createConsoleLogger", () => {
} }
expect(buildCount).toBe(5); expect(buildCount).toBe(5);
expect(logSpy.mock.calls.length).toBe(5); expect(errorSpy.mock.calls.length).toBe(5);
}); });
it("custom logger without isLevelEnabled falls back to running the meta builder (`?? true`)", () => { it("custom logger without isLevelEnabled falls back to running the meta builder (`?? true`)", () => {
@@ -242,8 +278,8 @@ describe("createConsoleLogger", () => {
defaultLogger.info("default-info"); defaultLogger.info("default-info");
defaultLogger.debug("default-debug"); defaultLogger.debug("default-debug");
expect(logSpy.mock.calls.length).toBe(1); expect(errorSpy.mock.calls.length).toBe(1);
expect(logSpy.mock.calls[0]?.[0]).toBe("[INFO] default-info"); expect(errorSpy.mock.calls[0]?.[0]).toBe("[INFO] default-info");
}); });
it("exposes isLevelEnabled gating debug at info threshold", () => { it("exposes isLevelEnabled gating debug at info threshold", () => {
+8 -2
View File
@@ -4,6 +4,12 @@
* Lightweight pluggable logger with zero dependencies. * Lightweight pluggable logger with zero dependencies.
* Default implementation writes to console with level filtering. * Default implementation writes to console with level filtering.
* *
* All levels write to stderr (console.error/console.warn), never stdout
* producer runs inside CLI commands whose stdout is a machine-readable
* contract (e.g. `validate --json`, `check --json`); an info/debug line on
* stdout would corrupt that output. There is no diagnostic use case that
* needs these lines on stdout specifically, so the whole logger is stderr-only.
*
* Users can provide their own logger (e.g. Winston, Pino) by * Users can provide their own logger (e.g. Winston, Pino) by
* implementing the ProducerLogger interface. * implementing the ProducerLogger interface.
*/ */
@@ -71,12 +77,12 @@ export function createConsoleLogger(level: LogLevel = "info"): ProducerLogger {
}, },
info(message, meta) { info(message, meta) {
if (shouldLog("info")) { if (shouldLog("info")) {
console.log(`[INFO] ${message}${formatMeta(meta)}`); console.error(`[INFO] ${message}${formatMeta(meta)}`);
} }
}, },
debug(message, meta) { debug(message, meta) {
if (shouldLog("debug")) { if (shouldLog("debug")) {
console.log(`[DEBUG] ${message}${formatMeta(meta)}`); console.error(`[DEBUG] ${message}${formatMeta(meta)}`);
} }
}, },
isLevelEnabled(msgLevel) { isLevelEnabled(msgLevel) {