diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index 04bc19f8d..5d834ef83 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -1,6 +1,7 @@ #!/usr/bin/env node import { runAttempt } from "../lib/attempt-cli.js"; import { printHelp, printVersion, runCli } from "../lib/cli.js"; +import { configureLogger, extractLogOptions } from "../lib/logger.js"; import { runDenyCheck } from "../lib/deny-check.js"; import { runDiscover } from "../lib/discover-cli.js"; import { runFeasibilityCli } from "../lib/feasibility-cli.js"; @@ -31,7 +32,11 @@ import { resolveMinerVersion } from "../lib/version.js"; // cleanly instead of dying mid-write (#4826). Covers every subcommand below, including the local ones. installCliSignalHandlers(); -const cliArgs = process.argv.slice(2); +// Peel the global logging flags (--quiet/--verbose/--log-level) off the front of argv and configure the +// process-wide logger once (#4835), so every command below shares one level-aware logger without re-parsing +// them; the stripped `cliArgs` is what the command dispatch sees. +const { options: logOptions, rest: cliArgs } = extractLogOptions(process.argv.slice(2)); +configureLogger({ ...logOptions, env: process.env }); // `status` and `doctor` are strictly local, offline commands — their contract is to make NO network calls. // `init` stays local by default and only makes a network call when the operator explicitly passes diff --git a/packages/gittensory-miner/docs/env-reference.md b/packages/gittensory-miner/docs/env-reference.md index d95a42ff5..5d751c860 100644 --- a/packages/gittensory-miner/docs/env-reference.md +++ b/packages/gittensory-miner/docs/env-reference.md @@ -13,6 +13,7 @@ Generated by `npm run miner:env-reference`. Do not edit manually. | `GITTENSORY_MINER_GOVERNOR_LEDGER_DB` | `lib/governor-ledger.js` | `""` | | `GITTENSORY_MINER_GOVERNOR_STATE_DB` | `lib/governor-state.js` | (none) | | `GITTENSORY_MINER_KILL_SWITCH` | `lib/config-precedence.js` | `""` | +| `GITTENSORY_MINER_LOG_LEVEL` | `lib/logger.js` | `""` | | `GITTENSORY_MINER_NO_UPDATE_CHECK` | `lib/update-check.js` | `""` | | `GITTENSORY_MINER_ORB_EXPORT_DB` | `lib/orb-export.js` | `""` | | `GITTENSORY_MINER_PLAN_STORE_DB` | `lib/plan-store.js` | `""` | diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index b5aac813a..eea03a184 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -55,6 +55,9 @@ export function printHelp(input) { "", "Options:", " --no-update-check Skip the npm registry version nudge (also GITTENSORY_MINER_NO_UPDATE_CHECK=1)", + " --quiet Log only warnings and errors (also GITTENSORY_MINER_LOG_LEVEL=error)", + " --verbose Log debug-level diagnostics (also GITTENSORY_MINER_LOG_LEVEL=debug)", + " --log-level Set the log level explicitly: silent|error|warn|info|debug", ].join("\n"), ); } diff --git a/packages/gittensory-miner/lib/logger.d.ts b/packages/gittensory-miner/lib/logger.d.ts new file mode 100644 index 000000000..37ab973f2 --- /dev/null +++ b/packages/gittensory-miner/lib/logger.d.ts @@ -0,0 +1,58 @@ +export type LogLevel = "silent" | "error" | "warn" | "info" | "debug"; + +export const LOG_LEVELS: readonly LogLevel[]; +export const DEFAULT_LOG_LEVEL: LogLevel; + +export function isLogLevel(value: unknown): value is LogLevel; + +export function resolveLogLevel(signals?: { + level?: string | undefined; + quiet?: boolean | undefined; + verbose?: boolean | undefined; + envLevel?: string | undefined; +}): LogLevel; + +export function extractLogOptions(argv: string[]): { + options: { quiet: boolean; verbose: boolean; level: string | undefined }; + rest: string[]; +}; + +export function formatFields(fields?: Record | null | undefined): string; + +export function formatLine(line: { + level: string; + message: string; + fields?: Record | null | undefined; + pretty?: boolean | undefined; + timestamp?: string | undefined; +}): string; + +export interface LoggerStreams { + stdout?: { write(chunk: string): unknown } | undefined; + stderr?: { write(chunk: string): unknown } | undefined; +} + +export interface LoggerOptions { + level?: string | undefined; + quiet?: boolean | undefined; + verbose?: boolean | undefined; + pretty?: boolean | undefined; + fields?: Record | undefined; + env?: Record | undefined; + streams?: LoggerStreams | undefined; + now?: (() => string) | undefined; +} + +export interface Logger { + level: LogLevel; + isLevelEnabled(level: string): boolean; + error(message: string, fields?: Record): void; + warn(message: string, fields?: Record): void; + info(message: string, fields?: Record): void; + debug(message: string, fields?: Record): void; + child(fields: Record): Logger; +} + +export function createLogger(options?: LoggerOptions): Logger; +export function configureLogger(options?: LoggerOptions): Logger; +export function getLogger(): Logger; diff --git a/packages/gittensory-miner/lib/logger.js b/packages/gittensory-miner/lib/logger.js new file mode 100644 index 000000000..0ca43a644 --- /dev/null +++ b/packages/gittensory-miner/lib/logger.js @@ -0,0 +1,167 @@ +// Level-aware logging abstraction for the miner CLI (#4835): every CLI file previously reached for ad hoc +// `console.log`/`console.error` with no shared level control, so an operator could neither quiet routine +// chatter nor turn on verbose diagnostics. This module is the one dependency-light logger the CLI configures +// once at startup and every command shares. It is deliberately pure/injectable — `streams`, `now`, and `env` +// are all overridable — so the branchy level/format logic is unit-testable without touching real stdio. +// +// Levels are ordered by severity; a logger at level L emits a method only when the method's severity rank is at +// or below L's rank (so `error` always survives except at `silent`, and `debug` only shows at the most verbose +// setting). `error`/`warn` go to stderr, `info`/`debug` to stdout, matching the existing convention where the +// update-check nudge writes to stderr and normal command output writes to stdout. + +/** Supported log levels, least to most verbose. `silent` suppresses everything. */ +export const LOG_LEVELS = ["silent", "error", "warn", "info", "debug"]; + +/** The level used when nothing (flag, env var, or explicit option) selects one. */ +export const DEFAULT_LOG_LEVEL = "info"; + +// Numeric severity rank per level (higher = more verbose). A method emits when its rank <= the active rank. +const LEVEL_RANK = { silent: 0, error: 1, warn: 2, info: 3, debug: 4 }; + +const defaultClock = () => new Date().toISOString(); + +/** True when `value` names a supported log level. Non-string input is never a level (so an absent option or a + * typo'd env var falls through to the next signal instead of throwing). */ +export function isLogLevel(value) { + return typeof value === "string" && Object.prototype.hasOwnProperty.call(LEVEL_RANK, value); +} + +/** + * Resolve the active level from the available signals, most explicit first: an explicit `level` wins, then + * `--quiet` (→ `error`), then `--verbose` (→ `debug`), then the env-provided level, else the default. `quiet` + * beats `verbose` when both are set, so the safer/quieter choice wins a contradictory invocation. An + * unrecognized `level`/`envLevel` is ignored rather than throwing — a typo logs at the default, never crashes. + * @param {{ level?: string, quiet?: boolean, verbose?: boolean, envLevel?: string }} [signals] + * @returns {string} + */ +export function resolveLogLevel({ level, quiet = false, verbose = false, envLevel } = {}) { + if (isLogLevel(level)) return level; + if (quiet) return "error"; + if (verbose) return "debug"; + if (isLogLevel(envLevel)) return envLevel; + return DEFAULT_LOG_LEVEL; +} + +/** + * Split the global logging flags out of a CLI argv slice, returning the parsed options plus `rest` — the argv + * with those flags (and any `--log-level` value) removed so downstream command parsing never sees them. + * Recognizes `--quiet`, `--verbose`, `--log-level `, and `--log-level=`. No short aliases: `-v` + * is already `--version` and `-h` is `--help` in the CLI entrypoint. + * @param {string[]} argv + * @returns {{ options: { quiet: boolean, verbose: boolean, level: string | undefined }, rest: string[] }} + */ +export function extractLogOptions(argv) { + let quiet = false; + let verbose = false; + let level; + const rest = []; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--quiet") { + quiet = true; + continue; + } + if (arg === "--verbose") { + verbose = true; + continue; + } + if (arg === "--log-level") { + level = argv[index + 1]; + index += 1; + continue; + } + if (arg.startsWith("--log-level=")) { + level = arg.slice("--log-level=".length); + continue; + } + rest.push(arg); + } + return { options: { quiet, verbose, level }, rest }; +} + +function formatFieldValue(value) { + // Quote a string only when it contains whitespace (so it stays one token); serialize everything else as JSON. + if (typeof value === "string") return /\s/.test(value) ? JSON.stringify(value) : value; + return JSON.stringify(value); +} + +/** + * Render structured fields as a stable, sorted ` key=value` suffix (sorted so output is deterministic across + * runs). `undefined` values are dropped; an empty/absent field set yields an empty string. + * @param {Record | null | undefined} fields + * @returns {string} + */ +export function formatFields(fields) { + if (!fields) return ""; + const parts = []; + for (const key of Object.keys(fields).sort()) { + const value = fields[key]; + if (value === undefined) continue; + parts.push(`${key}=${formatFieldValue(value)}`); + } + return parts.length > 0 ? ` ${parts.join(" ")}` : ""; +} + +/** + * Format one log line. Plain mode (the default) is just `message` + any field suffix, keeping human CLI output + * identical to a bare `console.log`. Pretty mode prefixes an optional timestamp and the uppercased level tag, + * for operators who want machine-scannable diagnostics. + * @param {{ level: string, message: string, fields?: Record | null, pretty?: boolean, timestamp?: string }} line + * @returns {string} + */ +export function formatLine({ level, message, fields, pretty, timestamp }) { + const suffix = formatFields(fields); + if (!pretty) return `${message}${suffix}`; + const stamp = timestamp ? `[${timestamp}] ` : ""; + return `${stamp}${level.toUpperCase()} ${message}${suffix}`; +} + +/** + * Build a level-aware logger. All I/O is injectable for tests: `streams` (defaults to process stdout/stderr), + * `now` (defaults to an ISO-8601 clock, only consulted in `pretty` mode), and `env` (defaults to process.env, + * read for `GITTENSORY_MINER_LOG_LEVEL`). `fields` seeds every line with contextual fields; `child(extra)` + * returns a logger that merges additional fields onto this one. + * @param {import("./logger.js").LoggerOptions} [options] + * @returns {import("./logger.js").Logger} + */ +export function createLogger(options = {}) { + const { level, quiet, verbose, pretty = false, fields: baseFields, env = process.env, streams, now } = options; + const stdout = streams?.stdout ?? process.stdout; + const stderr = streams?.stderr ?? process.stderr; + const clock = now ?? defaultClock; + const envLevel = env.GITTENSORY_MINER_LOG_LEVEL ?? ""; + const activeLevel = resolveLogLevel({ level, quiet, verbose, envLevel }); + const threshold = LEVEL_RANK[activeLevel]; + + function emit(methodLevel, stream, message, fields) { + if (LEVEL_RANK[methodLevel] > threshold) return; + const merged = baseFields || fields ? { ...baseFields, ...fields } : undefined; + const timestamp = pretty ? clock() : undefined; + stream.write(`${formatLine({ level: methodLevel, message, fields: merged, pretty, timestamp })}\n`); + } + + return { + level: activeLevel, + isLevelEnabled: (methodLevel) => LEVEL_RANK[methodLevel] <= threshold, + error: (message, fields) => emit("error", stderr, message, fields), + warn: (message, fields) => emit("warn", stderr, message, fields), + info: (message, fields) => emit("info", stdout, message, fields), + debug: (message, fields) => emit("debug", stdout, message, fields), + child: (childFields) => createLogger({ ...options, fields: { ...baseFields, ...childFields } }), + }; +} + +// Process-wide logger. The CLI entrypoint calls `configureLogger` once from the parsed global flags/env so every +// command shares one configured instance via `getLogger`; until then this default-level instance is used. +let processLogger = createLogger(); + +/** Reconfigure the process-wide logger from resolved startup options and return it. */ +export function configureLogger(options) { + processLogger = createLogger(options); + return processLogger; +} + +/** The process-wide logger configured by `configureLogger` (a default-level logger before then). */ +export function getLogger() { + return processLogger; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 51f843c25..597b8316c 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -37,7 +37,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-conflict-resolver.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-pause-cli.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/policy-doc-cache.js && node --check lib/policy-verdict-cache.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-number-parse.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-conflict-resolver.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-pause-cli.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/logger.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/policy-doc-cache.js && node --check lib/policy-verdict-cache.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-number-parse.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@jsonbored/gittensory-engine": "*", diff --git a/test/unit/miner-logger.test.ts b/test/unit/miner-logger.test.ts new file mode 100644 index 000000000..fb53a93a7 --- /dev/null +++ b/test/unit/miner-logger.test.ts @@ -0,0 +1,218 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + configureLogger, + createLogger, + DEFAULT_LOG_LEVEL, + extractLogOptions, + formatFields, + formatLine, + getLogger, + isLogLevel, + LOG_LEVELS, + resolveLogLevel, +} from "../../packages/gittensory-miner/lib/logger.js"; + +// A pair of in-memory streams so a logger's output can be asserted without touching real stdio. +function capture() { + const out: string[] = []; + const err: string[] = []; + return { + out, + err, + streams: { + stdout: { + write: (chunk: string) => { + out.push(chunk); + return true; + }, + }, + stderr: { + write: (chunk: string) => { + err.push(chunk); + return true; + }, + }, + }, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); + configureLogger(); // reset the process-wide logger to its default so tests don't leak state +}); + +describe("log level constants (#4835)", () => { + it("exposes the ordered level list and a default", () => { + expect(LOG_LEVELS).toEqual(["silent", "error", "warn", "info", "debug"]); + expect(DEFAULT_LOG_LEVEL).toBe("info"); + }); +}); + +describe("isLogLevel (#4835)", () => { + it("accepts known levels and rejects unknown or non-string input", () => { + expect(isLogLevel("info")).toBe(true); + expect(isLogLevel("silent")).toBe(true); + expect(isLogLevel("bogus")).toBe(false); // string, but not a level + expect(isLogLevel(5)).toBe(false); // not a string + expect(isLogLevel(undefined)).toBe(false); + }); +}); + +describe("resolveLogLevel (#4835)", () => { + it("prefers an explicit valid level above every other signal", () => { + expect(resolveLogLevel({ level: "warn", quiet: true, verbose: true, envLevel: "debug" })).toBe("warn"); + }); + + it("maps --quiet to error and --verbose to debug, with quiet winning a contradiction", () => { + expect(resolveLogLevel({ quiet: true })).toBe("error"); + expect(resolveLogLevel({ verbose: true })).toBe("debug"); + expect(resolveLogLevel({ quiet: true, verbose: true })).toBe("error"); + }); + + it("falls back to a valid env level, then the default, ignoring typos", () => { + expect(resolveLogLevel({ envLevel: "debug" })).toBe("debug"); + expect(resolveLogLevel({ envLevel: "nope" })).toBe(DEFAULT_LOG_LEVEL); // invalid env ignored + expect(resolveLogLevel({ level: "typo" })).toBe(DEFAULT_LOG_LEVEL); // invalid explicit ignored + expect(resolveLogLevel()).toBe(DEFAULT_LOG_LEVEL); // no signals at all + }); +}); + +describe("extractLogOptions (#4835)", () => { + it("peels --verbose off and leaves the command args untouched", () => { + const { options, rest } = extractLogOptions(["--verbose", "discover", "acme/repo", "--json"]); + expect(options).toEqual({ quiet: false, verbose: true, level: undefined }); + expect(rest).toEqual(["discover", "acme/repo", "--json"]); + }); + + it("supports --quiet and both --log-level spellings", () => { + expect(extractLogOptions(["--quiet"]).options).toEqual({ quiet: true, verbose: false, level: undefined }); + expect(extractLogOptions(["--log-level", "warn", "status"])).toEqual({ + options: { quiet: false, verbose: false, level: "warn" }, + rest: ["status"], + }); + expect(extractLogOptions(["--log-level=silent", "status"]).options.level).toBe("silent"); + }); + + it("tolerates a trailing --log-level with no value and an empty argv", () => { + expect(extractLogOptions(["--log-level"]).options.level).toBeUndefined(); + expect(extractLogOptions([])).toEqual({ + options: { quiet: false, verbose: false, level: undefined }, + rest: [], + }); + }); +}); + +describe("formatFields (#4835)", () => { + it("returns empty for nullish or all-undefined field sets", () => { + expect(formatFields(undefined)).toBe(""); + expect(formatFields(null)).toBe(""); + expect(formatFields({ a: undefined })).toBe(""); + }); + + it("sorts keys, drops undefined, and quotes only whitespace-bearing strings", () => { + expect(formatFields({ b: 2, a: "x", c: "two words", d: undefined })).toBe(' a=x b=2 c="two words"'); + }); +}); + +describe("formatLine (#4835)", () => { + it("plain mode is just the message plus any field suffix", () => { + expect(formatLine({ level: "info", message: "hi" })).toBe("hi"); + expect(formatLine({ level: "info", message: "hi", fields: { a: 1 } })).toBe("hi a=1"); + }); + + it("pretty mode adds an uppercased level tag and an optional timestamp", () => { + expect( + formatLine({ level: "warn", message: "hi", pretty: true, timestamp: "2026-01-01T00:00:00Z" }), + ).toBe("[2026-01-01T00:00:00Z] WARN hi"); + expect(formatLine({ level: "warn", message: "hi", pretty: true })).toBe("WARN hi"); + }); +}); + +describe("createLogger level gating + routing (#4835)", () => { + it("emits methods at or below the active level and suppresses the rest", () => { + const { out, err, streams } = capture(); + const logger = createLogger({ level: "warn", streams }); + logger.error("e"); + logger.warn("w"); + logger.info("i"); + logger.debug("d"); + expect(err).toEqual(["e\n", "w\n"]); // error + warn go to stderr + expect(out).toEqual([]); // info + debug suppressed at the warn threshold + expect(logger.level).toBe("warn"); + expect(logger.isLevelEnabled("warn")).toBe(true); + expect(logger.isLevelEnabled("info")).toBe(false); + }); + + it("routes info/debug to stdout when the level is verbose enough", () => { + const { out, err, streams } = capture(); + const logger = createLogger({ level: "debug", streams }); + logger.info("i"); + logger.debug("d"); + expect(out).toEqual(["i\n", "d\n"]); + expect(err).toEqual([]); + }); +}); + +describe("createLogger fields + child (#4835)", () => { + it("emits no field suffix when neither base nor call-site fields are present", () => { + const { out, streams } = capture(); + createLogger({ streams }).info("plain"); + expect(out).toEqual(["plain\n"]); + }); + + it("merges call-site fields onto the line", () => { + const { out, streams } = capture(); + createLogger({ streams }).info("m", { a: 1 }); + expect(out).toEqual(["m a=1\n"]); + }); + + it("child loggers merge base fields with their own", () => { + const { out, streams } = capture(); + const child = createLogger({ streams, fields: { run: "r1" } }).child({ step: 2 }); + child.info("c"); + expect(out).toEqual(["c run=r1 step=2\n"]); + }); +}); + +describe("createLogger pretty timestamps (#4835)", () => { + it("uses the injected clock in pretty mode", () => { + const { err, streams } = capture(); + createLogger({ level: "error", pretty: true, streams, now: () => "T0" }).error("boom"); + expect(err).toEqual(["[T0] ERROR boom\n"]); + }); + + it("falls back to the default ISO clock when no clock is injected", () => { + const { err, streams } = capture(); + createLogger({ level: "error", pretty: true, streams }).error("boom"); + expect(err[0]).toMatch(/^\[\d{4}-\d{2}-\d{2}T[\d:.]+Z\] ERROR boom\n$/); + }); +}); + +describe("createLogger env + default streams (#4835)", () => { + it("reads GITTENSORY_MINER_LOG_LEVEL when no explicit level is given", () => { + const { out, err, streams } = capture(); + const logger = createLogger({ env: { GITTENSORY_MINER_LOG_LEVEL: "debug" }, streams }); + expect(logger.level).toBe("debug"); + logger.debug("d"); + expect(out).toEqual(["d\n"]); + expect(err).toEqual([]); + }); + + it("ignores an empty env and falls back to real stdout when no streams are injected", () => { + expect(createLogger({ env: {} }).level).toBe(DEFAULT_LOG_LEVEL); + const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + createLogger({}).info("to real stdout"); // default env (process.env) + default streams + expect(write).toHaveBeenCalledWith("to real stdout\n"); + }); +}); + +describe("process logger singleton (#4835)", () => { + it("configureLogger swaps the shared instance returned by getLogger", () => { + const before = getLogger(); + const configured = configureLogger({ level: "debug" }); + expect(getLogger()).toBe(configured); + expect(getLogger()).not.toBe(before); + expect(getLogger().level).toBe("debug"); + }); +});