From dc21669ae2eec9634b2fc7b819b5b2b74e6bac65 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 12:21:36 +0000 Subject: [PATCH 1/3] fix: add Zod validation and early input checks to CLI commands - analyze: validate --preset with Zod schema (rejects invalid values early) - save-fixture: move --image-scale validation before any file I/O - visual-compare: warn when --figma-url has no node-id - implement: warn for unscoped Figma URL + early --image-scale validation - init: reject --token and --mcp when both provided (mutual exclusivity) - All 4 public commands now use Zod safeParse for runtime option validation Closes #60 https://claude.ai/code/session_018Y1Y4GuLuyeUEp5vHnuUKu --- src/cli/commands/analyze.ts | 35 +++++++++++++++++---------- src/cli/commands/implement.ts | 38 ++++++++++++++++++++++++------ src/cli/commands/init.ts | 5 ++++ src/cli/commands/save-fixture.ts | 35 ++++++++++++++++++++------- src/cli/commands/visual-compare.ts | 35 ++++++++++++++++++++------- 5 files changed, 111 insertions(+), 37 deletions(-) diff --git a/src/cli/commands/analyze.ts b/src/cli/commands/analyze.ts index 8585ad7e..ec478389 100644 --- a/src/cli/commands/analyze.ts +++ b/src/cli/commands/analyze.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync } from "node:fs"; import { writeFile } from "node:fs/promises"; import { resolve, dirname } from "node:path"; import type { CAC } from "cac"; +import { z } from "zod"; import type { RuleConfig, RuleId } from "../../core/contracts/rule.js"; import { analyzeFile } from "../../core/engine/rule-engine.js"; @@ -10,7 +11,7 @@ import { getFigmaToken, getReportsDir, ensureReportsDir, } from "../../core/engine/config-store.js"; import { calculateScores, formatScoreSummary, buildResultJson } from "../../core/engine/scoring.js"; -import { getConfigsWithPreset, RULE_CONFIGS, type Preset } from "../../core/rules/rule-config.js"; +import { getConfigsWithPreset, RULE_CONFIGS } from "../../core/rules/rule-config.js"; import { ruleRegistry } from "../../core/rules/rule-registry.js"; import { loadCustomRules } from "../../core/rules/custom/custom-rule-loader.js"; import { loadConfigFile, mergeConfigs } from "../../core/rules/custom/config-loader.js"; @@ -18,17 +19,18 @@ import { generateHtmlReport } from "../../core/report-html/index.js"; import { trackEvent, trackError, EVENTS } from "../../core/monitoring/index.js"; import { pickRandomScope, countNodes, MAX_NODES_WITHOUT_SCOPE } from "../helpers.js"; -interface AnalyzeOptions { - preset?: Preset; - output?: string; - token?: string; - api?: boolean; - screenshot?: boolean; - customRules?: string; - config?: string; - noOpen?: boolean; - json?: boolean; -} +const AnalyzeOptionsSchema = z.object({ + preset: z.enum(["relaxed", "dev-friendly", "ai-ready", "strict"]).optional(), + output: z.string().optional(), + token: z.string().optional(), + api: z.boolean().optional(), + screenshot: z.boolean().optional(), + customRules: z.string().optional(), + config: z.string().optional(), + noOpen: z.boolean().optional(), + json: z.boolean().optional(), +}); + export function registerAnalyze(cli: CAC): void { cli @@ -47,7 +49,14 @@ export function registerAnalyze(cli: CAC): void { .example(" canicode analyze ./fixtures/my-design --output report.html") .example(" canicode analyze ./fixtures/my-design --custom-rules ./my-rules.json") .example(" canicode analyze ./fixtures/my-design --config ./my-config.json") - .action(async (input: string, options: AnalyzeOptions) => { + .action(async (input: string, rawOptions: Record) => { + const parseResult = AnalyzeOptionsSchema.safeParse(rawOptions); + if (!parseResult.success) { + const msg = parseResult.error.issues.map(i => `--${i.path.join(".")}: ${i.message}`).join("\n"); + console.error(`\nInvalid options:\n${msg}`); + process.exit(1); + } + const options = parseResult.data; const analysisStart = Date.now(); trackEvent(EVENTS.ANALYSIS_STARTED, { source: isJsonFile(input) || isFixtureDir(input) ? "fixture" : "figma" }); // In --json mode, send progress messages to stderr so stdout contains only valid JSON diff --git a/src/cli/commands/implement.ts b/src/cli/commands/implement.ts index 3960a25c..ad953936 100644 --- a/src/cli/commands/implement.ts +++ b/src/cli/commands/implement.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync } from "node:fs"; import { writeFile } from "node:fs/promises"; import { resolve, dirname } from "node:path"; import type { CAC } from "cac"; +import { z } from "zod"; import { parseFigmaUrl } from "../../core/adapters/figma-url-parser.js"; import { analyzeFile } from "../../core/engine/rule-engine.js"; @@ -10,12 +11,13 @@ import { getFigmaToken } from "../../core/engine/config-store.js"; import { calculateScores, buildResultJson } from "../../core/engine/scoring.js"; import { collectVectorNodeIds, collectImageNodes, sanitizeFilename } from "../helpers.js"; -interface ImplementOptions { - token?: string; - output?: string; - prompt?: string; - imageScale?: string; -} +const ImplementOptionsSchema = z.object({ + token: z.string().optional(), + output: z.string().optional(), + prompt: z.string().optional(), + imageScale: z.string().optional(), +}); + export function registerImplement(cli: CAC): void { cli @@ -29,8 +31,30 @@ export function registerImplement(cli: CAC): void { .option("--image-scale ", "Image export scale: 2 for PC (default), 3 for mobile") .example(" canicode implement ./fixtures/my-design") .example(" canicode implement ./fixtures/my-design --prompt ./my-react-prompt.md --image-scale 3") - .action(async (input: string, options: ImplementOptions) => { + .action(async (input: string, rawOptions: Record) => { try { + const parseResult = ImplementOptionsSchema.safeParse(rawOptions); + if (!parseResult.success) { + const msg = parseResult.error.issues.map(i => `--${i.path.join(".")}: ${i.message}`).join("\n"); + console.error(`\nInvalid options:\n${msg}`); + process.exit(1); + } + const options = parseResult.data; + + // Validate --image-scale early + if (options.imageScale !== undefined) { + const scale = Number(options.imageScale); + if (!Number.isFinite(scale) || scale < 1 || scale > 4) { + console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)"); + process.exit(1); + } + } + + // Warn for unscoped Figma URL + if (isFigmaUrl(input) && !parseFigmaUrl(input).nodeId) { + console.warn("Warning: No node-id in Figma URL. Implementation package will cover the entire file."); + console.warn("Tip: Add ?node-id=XXX to target a specific section.\n"); + } const outputDir = resolve(options.output ?? "canicode-implement"); mkdirSync(outputDir, { recursive: true }); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 76eb17ec..04f1e895 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -16,6 +16,11 @@ export function registerInit(cli: CAC): void { .option("--mcp", "Show Figma MCP setup instructions") .action((options: InitOptions) => { try { + if (options.token && options.mcp) { + console.error("Error: --token and --mcp are mutually exclusive. Choose one."); + process.exit(1); + } + if (options.token) { initAiready(options.token); diff --git a/src/cli/commands/save-fixture.ts b/src/cli/commands/save-fixture.ts index f8782a59..4a31e80d 100644 --- a/src/cli/commands/save-fixture.ts +++ b/src/cli/commands/save-fixture.ts @@ -2,19 +2,21 @@ import { mkdirSync } from "node:fs"; import { writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import type { CAC } from "cac"; +import { z } from "zod"; import { parseFigmaUrl } from "../../core/adapters/figma-url-parser.js"; import { loadFile, isFigmaUrl } from "../../core/engine/loader.js"; import { getFigmaToken } from "../../core/engine/config-store.js"; import { collectVectorNodeIds, collectImageNodes, sanitizeFilename, countNodes } from "../helpers.js"; -interface SaveFixtureOptions { - output?: string; - api?: boolean; - token?: string; - imageScale?: string; - name?: string; -} +const SaveFixtureOptionsSchema = z.object({ + output: z.string().optional(), + api: z.boolean().optional(), + token: z.string().optional(), + imageScale: z.string().optional(), + name: z.string().optional(), +}); + export function registerSaveFixture(cli: CAC): void { cli @@ -28,12 +30,29 @@ export function registerSaveFixture(cli: CAC): void { .option("--image-scale ", "Image export scale: 2 for PC (default), 3 for mobile") .example(" canicode save-fixture https://www.figma.com/design/ABC123/MyDesign?node-id=1-234") .example(" canicode save-fixture https://www.figma.com/design/ABC123/MyDesign?node-id=1-234 --image-scale 3") - .action(async (input: string, options: SaveFixtureOptions) => { + .action(async (input: string, rawOptions: Record) => { try { + const parseResult = SaveFixtureOptionsSchema.safeParse(rawOptions); + if (!parseResult.success) { + const msg = parseResult.error.issues.map(i => `--${i.path.join(".")}: ${i.message}`).join("\n"); + console.error(`\nInvalid options:\n${msg}`); + process.exit(1); + } + const options = parseResult.data; + if (!isFigmaUrl(input)) { throw new Error("save-fixture requires a Figma URL as input."); } + // Validate --image-scale early (before any file I/O) + if (options.imageScale !== undefined) { + const scale = Number(options.imageScale); + if (!Number.isFinite(scale) || scale < 1 || scale > 4) { + console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)"); + process.exit(1); + } + } + if (!parseFigmaUrl(input).nodeId) { console.warn("\nWarning: No node-id specified. Saving entire file as fixture."); console.warn("Tip: Add ?node-id=XXX to save a specific section.\n"); diff --git a/src/cli/commands/visual-compare.ts b/src/cli/commands/visual-compare.ts index 279f6822..a31761d1 100644 --- a/src/cli/commands/visual-compare.ts +++ b/src/cli/commands/visual-compare.ts @@ -1,16 +1,19 @@ import { resolve } from "node:path"; import type { CAC } from "cac"; +import { z } from "zod"; +import { parseFigmaUrl } from "../../core/adapters/figma-url-parser.js"; import { getFigmaToken } from "../../core/engine/config-store.js"; -interface VisualCompareOptions { - figmaUrl: string; - token?: string; - output?: string; - width?: number; - height?: number; - figmaScale?: string; -} +const VisualCompareOptionsSchema = z.object({ + figmaUrl: z.string().optional(), + token: z.string().optional(), + output: z.string().optional(), + width: z.unknown().optional(), + height: z.unknown().optional(), + figmaScale: z.string().optional(), +}); + export function registerVisualCompare(cli: CAC): void { cli @@ -25,13 +28,27 @@ export function registerVisualCompare(cli: CAC): void { .option("--height ", "Logical viewport height in CSS px (default: infer from Figma PNG ÷ export scale)") .option("--figma-scale ", "Figma export scale (default: 2, matches save-fixture / @2x PNGs)") .example(" canicode visual-compare ./generated/index.html --figma-url 'https://www.figma.com/design/ABC/File?node-id=1-234'") - .action(async (codePath: string, options: VisualCompareOptions) => { + .action(async (codePath: string, rawOptions: Record) => { try { + const parseResult = VisualCompareOptionsSchema.safeParse(rawOptions); + if (!parseResult.success) { + const msg = parseResult.error.issues.map(i => `--${i.path.join(".")}: ${i.message}`).join("\n"); + console.error(`\nInvalid options:\n${msg}`); + process.exit(1); + } + const options = parseResult.data; + if (!options.figmaUrl) { console.error("Error: --figma-url is required"); process.exitCode = 1; return; } + // Warn if --figma-url has no node-id + if (!parseFigmaUrl(options.figmaUrl).nodeId) { + console.warn("Warning: --figma-url has no node-id. Results may be inaccurate for full files."); + console.warn("Tip: Add ?node-id=XXX to target a specific section.\n"); + } + const token = options.token ?? getFigmaToken(); if (!token) { console.error("Error: Figma token required. Use --token or set FIGMA_TOKEN env var."); From ff168b4fabb5a0196b404d33fee28d3a35dc90d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 12:46:57 +0000 Subject: [PATCH 2/3] test: verify manual test plan for CLI input validation (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 3 manual test cases passed: - --preset invalid → Zod validation error - --token x --mcp → mutual exclusivity error - --image-scale abc → early validation error before file I/O https://claude.ai/code/session_0113aX578Sq8Q4RQeho8jsuV From 6dbbb53f131b531e6af80d946c846919be1a8d8d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 12:56:44 +0000 Subject: [PATCH 3/3] fix: address CodeRabbit review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - visual-compare: z.unknown() → z.union([z.string(), z.number()]) for width/height - implement: remove redundant --image-scale validation (unreachable after early check) - save-fixture: remove redundant --image-scale validation (unreachable after early check) - init: convert to Zod schema with .refine() for --token/--mcp mutual exclusivity https://claude.ai/code/session_018Y1Y4GuLuyeUEp5vHnuUKu --- src/cli/commands/implement.ts | 4 ---- src/cli/commands/init.ts | 21 ++++++++++++++------- src/cli/commands/save-fixture.ts | 4 ---- src/cli/commands/visual-compare.ts | 4 ++-- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/cli/commands/implement.ts b/src/cli/commands/implement.ts index ad953936..995ec700 100644 --- a/src/cli/commands/implement.ts +++ b/src/cli/commands/implement.ts @@ -111,10 +111,6 @@ export function registerImplement(cli: CAC): void { const figmaToken = options.token ?? getFigmaToken(); if (figmaToken) { const imgScale = options.imageScale !== undefined ? Number(options.imageScale) : 2; - if (!Number.isFinite(imgScale) || imgScale < 1 || imgScale > 4) { - console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)"); - process.exitCode = 1; return; - } const { FigmaClient } = await import("../../core/adapters/figma-client.js"); const client = new FigmaClient({ token: figmaToken }); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 04f1e895..f4393086 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -1,25 +1,32 @@ import type { CAC } from "cac"; +import { z } from "zod"; import { initAiready, getConfigPath, getReportsDir, } from "../../core/engine/config-store.js"; -interface InitOptions { - token?: string; - mcp?: boolean; -} +const InitOptionsSchema = z.object({ + token: z.string().optional(), + mcp: z.boolean().optional(), +}).refine( + (opts) => !(opts.token && opts.mcp), + { message: "--token and --mcp are mutually exclusive. Choose one." } +); export function registerInit(cli: CAC): void { cli .command("init", "Set up canicode (Figma token or MCP)") .option("--token ", "Save Figma API token to ~/.canicode/") .option("--mcp", "Show Figma MCP setup instructions") - .action((options: InitOptions) => { + .action((rawOptions: Record) => { try { - if (options.token && options.mcp) { - console.error("Error: --token and --mcp are mutually exclusive. Choose one."); + const parseResult = InitOptionsSchema.safeParse(rawOptions); + if (!parseResult.success) { + const msg = parseResult.error.issues.map(i => i.message).join("\n"); + console.error(`\nInvalid options:\n${msg}`); process.exit(1); } + const options = parseResult.data; if (options.token) { initAiready(options.token); diff --git a/src/cli/commands/save-fixture.ts b/src/cli/commands/save-fixture.ts index 4a31e80d..9ee46d71 100644 --- a/src/cli/commands/save-fixture.ts +++ b/src/cli/commands/save-fixture.ts @@ -142,10 +142,6 @@ export function registerSaveFixture(cli: CAC): void { const imageNodes = collectImageNodes(file.document); if (imageNodes.length > 0) { const imgScale = options.imageScale !== undefined ? Number(options.imageScale) : 2; - if (!Number.isFinite(imgScale) || imgScale < 1 || imgScale > 4) { - console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)"); - process.exitCode = 1; return; - } const imageDir = resolve(fixtureDir, "images"); mkdirSync(imageDir, { recursive: true }); diff --git a/src/cli/commands/visual-compare.ts b/src/cli/commands/visual-compare.ts index a31761d1..875bfc5a 100644 --- a/src/cli/commands/visual-compare.ts +++ b/src/cli/commands/visual-compare.ts @@ -9,8 +9,8 @@ const VisualCompareOptionsSchema = z.object({ figmaUrl: z.string().optional(), token: z.string().optional(), output: z.string().optional(), - width: z.unknown().optional(), - height: z.unknown().optional(), + width: z.union([z.string(), z.number()]).optional(), + height: z.union([z.string(), z.number()]).optional(), figmaScale: z.string().optional(), });