Skip to content

Commit 9fa06cb

Browse files
cm-dyoshikawaclaude
andcommitted
refactor: emit conflicting-flags warning once at CLI parsing time
Address review findings: BaseLogger.configure no longer warns on verbose+silent — with the logger now re-configured from resolved config values, the warning fired up to four times, leaked raw console.warn into --json mode via the fallbackLogger, and mislabeled a config-file silent as a CLI flag conflict. The warning moved to warnOnConflictingFlags, called once in wrapCommand with JSON-mode suppression. Silent still always wins over verbose regardless of source. Also restore fallbackLogger state in tests and document the process-global caveat. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2d28625 commit 9fa06cb

5 files changed

Lines changed: 106 additions & 29 deletions

File tree

src/cli/wrap-command.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ import { Command } from "commander";
22

33
import { CLIError } from "../types/json-output.js";
44
import { formatError } from "../utils/error.js";
5-
import { ConsoleLogger, fallbackLogger, JsonLogger, Logger } from "../utils/logger.js";
5+
import {
6+
ConsoleLogger,
7+
fallbackLogger,
8+
JsonLogger,
9+
Logger,
10+
warnOnConflictingFlags,
11+
} from "../utils/logger.js";
612

713
export function createLogger({
814
name,
@@ -57,6 +63,7 @@ export function wrapCommand({
5763
verbose: Boolean(globalOpts.verbose) || Boolean(options.verbose),
5864
silent: Boolean(globalOpts.silent) || Boolean(options.silent),
5965
};
66+
warnOnConflictingFlags({ ...cliLoggerOptions, jsonMode: logger.jsonMode });
6067
logger.configure(cliLoggerOptions);
6168
fallbackLogger.configure(cliLoggerOptions);
6269

src/config/config-resolver.test.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ describe("config-resolver", () => {
173173
expect(fallbackConfigureSpy).toHaveBeenCalledWith({ verbose: false, silent: true });
174174
} finally {
175175
fallbackConfigureSpy.mockRestore();
176+
fallbackLogger.configure({ verbose: false, silent: false });
176177
}
177178
});
178179

@@ -185,12 +186,16 @@ describe("config-resolver", () => {
185186
await writeFileContent(join(testDir, "rulesync.jsonc"), configContent);
186187
const logger = { warn: vi.fn(), configure: vi.fn() } as unknown as Logger;
187188

188-
await ConfigResolver.resolve(
189-
{ configPath: join(testDir, "rulesync.jsonc"), silent: true, verbose: true },
190-
{ logger },
191-
);
189+
try {
190+
await ConfigResolver.resolve(
191+
{ configPath: join(testDir, "rulesync.jsonc"), silent: true, verbose: true },
192+
{ logger },
193+
);
192194

193-
expect(logger.configure).toHaveBeenCalledWith({ verbose: true, silent: true });
195+
expect(logger.configure).toHaveBeenCalledWith({ verbose: true, silent: true });
196+
} finally {
197+
fallbackLogger.configure({ verbose: false, silent: false });
198+
}
194199
});
195200

196201
it("should enable logger verbose from config-file verbose", async () => {
@@ -201,9 +206,13 @@ describe("config-resolver", () => {
201206
await writeFileContent(join(testDir, "rulesync.jsonc"), configContent);
202207
const logger = { warn: vi.fn(), configure: vi.fn() } as unknown as Logger;
203208

204-
await ConfigResolver.resolve({ configPath: join(testDir, "rulesync.jsonc") }, { logger });
209+
try {
210+
await ConfigResolver.resolve({ configPath: join(testDir, "rulesync.jsonc") }, { logger });
205211

206-
expect(logger.configure).toHaveBeenCalledWith({ verbose: true, silent: false });
212+
expect(logger.configure).toHaveBeenCalledWith({ verbose: true, silent: false });
213+
} finally {
214+
fallbackLogger.configure({ verbose: false, silent: false });
215+
}
207216
});
208217

209218
it("should not touch the fallbackLogger when no logger is supplied", async () => {

src/config/config-resolver.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,10 @@ export class ConfigResolver {
295295
// still win via `pick`. Only re-configure when the caller threaded a
296296
// logger through — those call sites pass their CLI flags in the params, so
297297
// precedence stays intact. The shared `fallbackLogger` is kept in sync for
298-
// paths that have no logger threaded through.
298+
// paths that have no logger threaded through. Note: `fallbackLogger` is
299+
// process-global state — do not call `resolve` with a logger from a
300+
// long-lived process (e.g. the MCP server) where one repository's config
301+
// would leak into unrelated later operations.
299302
const resolvedVerbose = pick({
300303
cli: verbose,
301304
file: configByFile.verbose,

src/utils/logger.test.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22

33
import type { Logger } from "./logger.js";
4-
import { ConsoleLogger, fallbackLogger, JsonLogger, warnWithFallback } from "./logger.js";
4+
import {
5+
ConsoleLogger,
6+
fallbackLogger,
7+
JsonLogger,
8+
warnOnConflictingFlags,
9+
warnWithFallback,
10+
} from "./logger.js";
511

612
// Mock vitest module
713
vi.mock("./vitest.js", () => ({
@@ -65,14 +71,14 @@ describe("ConsoleLogger", () => {
6571
});
6672

6773
describe("configure()", () => {
68-
it("should warn when both verbose and silent are enabled", () => {
74+
it("should not warn when both verbose and silent are enabled (warning lives in warnOnConflictingFlags)", () => {
6975
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
7076

7177
logger.configure({ verbose: true, silent: true });
7278

73-
expect(warnSpy).toHaveBeenCalledWith(
74-
"Both --verbose and --silent specified; --silent takes precedence",
75-
);
79+
expect(warnSpy).not.toHaveBeenCalled();
80+
expect(logger.silent).toBe(true);
81+
expect(logger.verbose).toBe(false);
7682

7783
warnSpy.mockRestore();
7884
});
@@ -443,3 +449,44 @@ describe("warnWithFallback", () => {
443449
}
444450
});
445451
});
452+
453+
describe("warnOnConflictingFlags", () => {
454+
it("warns once when both flags are set outside JSON mode", () => {
455+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
456+
457+
try {
458+
warnOnConflictingFlags({ verbose: true, silent: true, jsonMode: false });
459+
460+
expect(warnSpy).toHaveBeenCalledExactlyOnceWith(
461+
"Both --verbose and --silent specified; --silent takes precedence",
462+
);
463+
} finally {
464+
warnSpy.mockRestore();
465+
}
466+
});
467+
468+
it("does not warn in JSON mode", () => {
469+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
470+
471+
try {
472+
warnOnConflictingFlags({ verbose: true, silent: true, jsonMode: true });
473+
474+
expect(warnSpy).not.toHaveBeenCalled();
475+
} finally {
476+
warnSpy.mockRestore();
477+
}
478+
});
479+
480+
it("does not warn when only one flag is set", () => {
481+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
482+
483+
try {
484+
warnOnConflictingFlags({ verbose: true, silent: false, jsonMode: false });
485+
warnOnConflictingFlags({ verbose: false, silent: true, jsonMode: false });
486+
487+
expect(warnSpy).not.toHaveBeenCalled();
488+
} finally {
489+
warnSpy.mockRestore();
490+
}
491+
});
492+
});

src/utils/logger.ts

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -46,20 +46,15 @@ abstract class BaseLogger {
4646
return this._silent;
4747
}
4848

49+
// Silent always wins over verbose, regardless of where each value came
50+
// from (CLI flag or config file). The user-facing warning about the
51+
// conflicting CLI flags lives in `warnOnConflictingFlags`, emitted once at
52+
// CLI-flag parsing time — not here, since `configure` may be called again
53+
// with config-file-derived values.
4954
configure({ verbose, silent }: { verbose: boolean; silent: boolean }): void {
50-
if (verbose && silent) {
51-
this._silent = false;
52-
if (!isEnvTest()) {
53-
this.onConflictingFlags();
54-
}
55-
}
5655
this._silent = silent;
5756
this._verbose = verbose && !silent;
5857
}
59-
60-
protected onConflictingFlags(): void {
61-
console.warn("Both --verbose and --silent specified; --silent takes precedence");
62-
}
6358
}
6459

6560
/**
@@ -141,11 +136,6 @@ export class JsonLogger extends BaseLogger implements Logger {
141136
this._version = version;
142137
}
143138

144-
// Suppress raw console.warn in JSON mode to avoid non-JSON text on stderr
145-
protected override onConflictingFlags(): void {
146-
// No-op: conflicting flags warning is silently ignored in JSON mode
147-
}
148-
149139
get jsonMode(): boolean {
150140
return true;
151141
}
@@ -226,6 +216,27 @@ export class JsonLogger extends BaseLogger implements Logger {
226216
}
227217
}
228218

219+
/**
220+
* Warn once when both `--verbose` and `--silent` were passed on the command
221+
* line. Called at CLI-flag parsing time only (`wrapCommand`), so re-configuring
222+
* a logger from config-file values never re-triggers it. Suppressed in JSON
223+
* mode to keep non-JSON text off stderr, matching the former JsonLogger
224+
* behavior.
225+
*/
226+
export function warnOnConflictingFlags({
227+
verbose,
228+
silent,
229+
jsonMode,
230+
}: {
231+
verbose: boolean;
232+
silent: boolean;
233+
jsonMode: boolean;
234+
}): void {
235+
if (!verbose || !silent || jsonMode || isEnvTest()) return;
236+
// oxlint-disable-next-line no-console
237+
console.warn("Both --verbose and --silent specified; --silent takes precedence");
238+
}
239+
229240
/**
230241
* Shared fallback logger for code paths that have no command logger threaded
231242
* through (module-level translators, `warnWithFallback(undefined, ...)`).

0 commit comments

Comments
 (0)