frontend-precision 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1664 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/init.ts
7
+ import {
8
+ cpSync,
9
+ existsSync as existsSync2,
10
+ mkdirSync,
11
+ readFileSync,
12
+ writeFileSync,
13
+ appendFileSync
14
+ } from "fs";
15
+ import { dirname as dirname2, join as join2 } from "path";
16
+
17
+ // src/config/schema.ts
18
+ import { z } from "zod";
19
+
20
+ // src/lib/safe-slug.ts
21
+ var SAFE_SLUG_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
22
+ function isSafeSlug(value) {
23
+ return typeof value === "string" && !value.includes("..") && !value.includes("/") && !value.includes("\\") && SAFE_SLUG_PATTERN.test(value);
24
+ }
25
+ function assertSafeSlug(value, label) {
26
+ if (!isSafeSlug(value)) {
27
+ throw new Error(
28
+ `Invalid ${label} "${String(value)}". Use a slug of letters, digits, '.', '_' or '-' (no path separators or '..').`
29
+ );
30
+ }
31
+ return value;
32
+ }
33
+
34
+ // src/config/schema.ts
35
+ var ViewportSchema = z.object({
36
+ width: z.number().int().positive(),
37
+ height: z.number().int().positive()
38
+ });
39
+ var RouteSchema = z.object({
40
+ name: z.string().min(1).refine(isSafeSlug, {
41
+ message: "Invalid route name. Use a slug of letters, digits, '.', '_' or '-' with no path separators or '..'."
42
+ }),
43
+ path: z.string().min(1),
44
+ reference: z.string().optional(),
45
+ /**
46
+ * figma: 设计稿导出基线(可宣称视觉验收)
47
+ * regression: 实现自截图回归基线(不得当作 Figma PASS)
48
+ */
49
+ referenceKind: z.enum(["figma", "regression"]).default("figma"),
50
+ viewport: ViewportSchema.optional(),
51
+ waitFor: z.string().optional(),
52
+ hide: z.array(z.string()).optional()
53
+ });
54
+ var RoutesSchema = z.array(RouteSchema).min(1).superRefine((routes, ctx) => {
55
+ const seen = /* @__PURE__ */ new Set();
56
+ routes.forEach((route, index) => {
57
+ if (seen.has(route.name)) {
58
+ ctx.addIssue({
59
+ code: z.ZodIssueCode.custom,
60
+ path: [index, "name"],
61
+ message: `Duplicate route name: ${route.name}`
62
+ });
63
+ }
64
+ seen.add(route.name);
65
+ });
66
+ });
67
+ var ConfigSchema = z.object({
68
+ baseUrl: z.string().url(),
69
+ viewport: ViewportSchema,
70
+ routes: RoutesSchema,
71
+ visual: z.object({
72
+ pixelThreshold: z.number().min(0).max(1).default(0.1),
73
+ maxDiffRatio: z.number().min(0).max(1).default(0.03)
74
+ }).default({ pixelThreshold: 0.1, maxDiffRatio: 0.03 }),
75
+ testCommand: z.string().min(1),
76
+ maxFixAttempts: z.number().int().min(0).max(10).default(3),
77
+ browser: z.enum(["chromium", "firefox", "webkit"]).default("chromium"),
78
+ settleMs: z.number().int().nonnegative().default(300),
79
+ /** 绑定任务目录名,report 自动读取 acceptance.md */
80
+ taskId: z.string().min(1).optional(),
81
+ gates: z.object({
82
+ /** 报告必须加载到至少 1 条 AC,否则 FAIL */
83
+ requireAcceptance: z.boolean().default(true),
84
+ /** 任一 AC 为 PENDING/PARTIAL/FAIL 则整体不能 PASS */
85
+ requireAcceptanceAllPass: z.boolean().default(true),
86
+ /** 存在 referenceKind=regression 的路由时,不得宣称 Figma 视觉通过 */
87
+ requireFigmaVisual: z.boolean().default(false),
88
+ /** reference 与 actual 字节完全相同 → FAIL(防自截图冒充) */
89
+ rejectIdenticalBaseline: z.boolean().default(true)
90
+ }).default({
91
+ requireAcceptance: true,
92
+ requireAcceptanceAllPass: true,
93
+ requireFigmaVisual: false,
94
+ rejectIdenticalBaseline: true
95
+ }),
96
+ checks: z.object({
97
+ typecheck: z.string().optional(),
98
+ lint: z.string().optional(),
99
+ unit: z.string().optional(),
100
+ e2e: z.string().optional()
101
+ }).optional()
102
+ });
103
+ var CONFIG_FILENAME = "frontend-precision.config.json";
104
+
105
+ // src/lib/templates.ts
106
+ import { existsSync } from "fs";
107
+ import { dirname, join } from "path";
108
+ import { fileURLToPath } from "url";
109
+ function getTemplatesRoot() {
110
+ const here = dirname(fileURLToPath(import.meta.url));
111
+ const candidates = [
112
+ join(here, "templates"),
113
+ join(here, "..", "templates"),
114
+ join(here, "..", "..", "src", "templates"),
115
+ join(here, "..", "..", "dist", "templates")
116
+ ];
117
+ for (const c of candidates) {
118
+ if (existsSync(c)) return c;
119
+ }
120
+ throw new Error(
121
+ "Cannot locate templates directory. Rebuild the CLI (`pnpm build`)."
122
+ );
123
+ }
124
+
125
+ // src/commands/init.ts
126
+ function copyIfNeeded(src, dest, force) {
127
+ if (existsSync2(dest) && !force) return "skipped";
128
+ mkdirSync(dirname2(dest), { recursive: true });
129
+ cpSync(src, dest, { recursive: true });
130
+ return existsSync2(dest) && force ? "overwritten" : "created";
131
+ }
132
+ function runInit(options = {}) {
133
+ const cwd = options.cwd ?? process.cwd();
134
+ const force = options.force ?? false;
135
+ const templates = getTemplatesRoot();
136
+ const messages = [];
137
+ const defaultConfigPath = join2(templates, "config.default.json");
138
+ const configPath = join2(cwd, CONFIG_FILENAME);
139
+ if (existsSync2(configPath) && !force) {
140
+ messages.push(`Skipped ${CONFIG_FILENAME} (exists). Use --force to overwrite.`);
141
+ } else {
142
+ writeFileSync(configPath, readFileSync(defaultConfigPath));
143
+ messages.push(
144
+ existsSync2(configPath) && force ? `Overwrote ${CONFIG_FILENAME}` : `Created ${CONFIG_FILENAME}`
145
+ );
146
+ }
147
+ const skillSrc = join2(templates, "skill", "frontend-precision");
148
+ const skillDest = join2(cwd, ".cursor", "skills", "frontend-precision");
149
+ if (existsSync2(skillDest) && !force) {
150
+ messages.push(
151
+ "Skipped .cursor/skills/frontend-precision (exists). Use --force to overwrite."
152
+ );
153
+ } else {
154
+ mkdirSync(join2(cwd, ".cursor", "skills"), { recursive: true });
155
+ cpSync(skillSrc, skillDest, { recursive: true });
156
+ messages.push(
157
+ force ? "Overwrote .cursor/skills/frontend-precision" : "Installed .cursor/skills/frontend-precision"
158
+ );
159
+ }
160
+ const ruleSrc = join2(templates, "rules", "frontend-precision.mdc");
161
+ const ruleDest = join2(cwd, ".cursor", "rules", "frontend-precision.mdc");
162
+ const ruleStatus = copyIfNeeded(ruleSrc, ruleDest, force);
163
+ messages.push(
164
+ ruleStatus === "skipped" ? "Skipped .cursor/rules/frontend-precision.mdc (exists)." : `${ruleStatus === "overwritten" ? "Overwrote" : "Installed"} .cursor/rules/frontend-precision.mdc`
165
+ );
166
+ const taskTplSrc = join2(templates, "task-templates");
167
+ const taskTplDest = join2(
168
+ cwd,
169
+ ".frontend-precision",
170
+ "templates"
171
+ );
172
+ if (!existsSync2(taskTplDest) || force) {
173
+ mkdirSync(taskTplDest, { recursive: true });
174
+ cpSync(taskTplSrc, taskTplDest, { recursive: true });
175
+ messages.push("Installed .frontend-precision/templates (preflight/plan/acceptance)");
176
+ }
177
+ const gitignorePath = join2(cwd, ".gitignore");
178
+ const ignoreLine = ".frontend-precision/artifacts/";
179
+ if (existsSync2(gitignorePath)) {
180
+ const content = readFileSync(gitignorePath, "utf8");
181
+ if (!content.includes(".frontend-precision")) {
182
+ appendFileSync(gitignorePath, `
183
+ # frontend-precision
184
+ .frontend-precision/artifacts/
185
+ `);
186
+ messages.push("Appended .frontend-precision/artifacts/ to .gitignore");
187
+ } else {
188
+ messages.push(`Skipped .gitignore (already mentions frontend-precision)`);
189
+ }
190
+ } else {
191
+ writeFileSync(
192
+ gitignorePath,
193
+ `# frontend-precision
194
+ ${ignoreLine}
195
+ `
196
+ );
197
+ messages.push("Created .gitignore with .frontend-precision/artifacts/");
198
+ }
199
+ messages.push("");
200
+ messages.push("Next steps:");
201
+ messages.push(" 1. Edit frontend-precision.config.json (baseUrl, routes, references)");
202
+ messages.push(" 2. Install Playwright browsers: pnpm exec playwright install chromium");
203
+ messages.push(" 3. In Cursor: /frontend-precision with your PRD + Figma link");
204
+ messages.push(" 4. Run: frontend-precision doctor");
205
+ return { messages, configPath };
206
+ }
207
+
208
+ // src/commands/doctor.ts
209
+ import { existsSync as existsSync4 } from "fs";
210
+ import { resolve as resolve2, join as join3 } from "path";
211
+ import { spawnSync } from "child_process";
212
+
213
+ // src/config/load.ts
214
+ import { readFileSync as readFileSync2, existsSync as existsSync3 } from "fs";
215
+ import { resolve } from "path";
216
+ var ConfigError = class extends Error {
217
+ constructor(message) {
218
+ super(message);
219
+ this.name = "ConfigError";
220
+ }
221
+ };
222
+ function loadConfig(cwd = process.cwd()) {
223
+ const path = resolve(cwd, CONFIG_FILENAME);
224
+ if (!existsSync3(path)) {
225
+ throw new ConfigError(
226
+ `Missing ${CONFIG_FILENAME}. Run \`frontend-precision init\` first.`
227
+ );
228
+ }
229
+ let raw;
230
+ try {
231
+ raw = JSON.parse(readFileSync2(path, "utf8"));
232
+ } catch (err) {
233
+ throw new ConfigError(
234
+ `Invalid JSON in ${CONFIG_FILENAME}: ${err.message}`
235
+ );
236
+ }
237
+ const parsed = ConfigSchema.safeParse(raw);
238
+ if (!parsed.success) {
239
+ const details = parsed.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
240
+ throw new ConfigError(`${CONFIG_FILENAME} validation failed:
241
+ ${details}`);
242
+ }
243
+ return parsed.data;
244
+ }
245
+
246
+ // src/lib/http-status.ts
247
+ function isHealthyHttpStatus(status) {
248
+ return Number.isInteger(status) && status >= 200 && status < 300;
249
+ }
250
+
251
+ // src/commands/doctor.ts
252
+ var defaultDeps = {
253
+ spawnSync,
254
+ fetch: globalThis.fetch.bind(globalThis)
255
+ };
256
+ function spawnOk(result) {
257
+ return result.status === 0;
258
+ }
259
+ async function runDoctor(cwd = process.cwd(), deps = defaultDeps) {
260
+ const checks = [];
261
+ const nodeMajor = Number(process.versions.node.split(".")[0]);
262
+ checks.push({
263
+ name: "Node.js >= 20",
264
+ ok: nodeMajor >= 20,
265
+ detail: `Detected v${process.versions.node}`,
266
+ fix: "Install Node.js 20+ from https://nodejs.org/"
267
+ });
268
+ const pnpm = deps.spawnSync("pnpm", ["--version"], { encoding: "utf8" });
269
+ checks.push({
270
+ name: "pnpm",
271
+ ok: spawnOk(pnpm),
272
+ detail: spawnOk(pnpm) ? `pnpm ${pnpm.stdout.trim()}` : "pnpm not found",
273
+ fix: "corepack enable && corepack prepare pnpm@latest --activate"
274
+ });
275
+ let config = null;
276
+ let configDetail = `Missing ${CONFIG_FILENAME}`;
277
+ let configFix = "Run `frontend-precision init`";
278
+ try {
279
+ config = loadConfig(cwd);
280
+ configDetail = `Valid config at ${join3(cwd, CONFIG_FILENAME)}`;
281
+ } catch (err) {
282
+ if (err instanceof ConfigError) {
283
+ configDetail = err.message;
284
+ configFix = err.message.includes("validation failed") ? "Fix the invalid fields listed above in frontend-precision.config.json" : "Run `frontend-precision init` (use --force only if you intend to replace the file)";
285
+ } else {
286
+ configDetail = err.message;
287
+ }
288
+ }
289
+ checks.push({
290
+ name: CONFIG_FILENAME,
291
+ ok: Boolean(config),
292
+ detail: configDetail,
293
+ fix: configFix
294
+ });
295
+ if (config) {
296
+ let reachable = false;
297
+ let reachDetail = "";
298
+ try {
299
+ const res = await deps.fetch(config.baseUrl, {
300
+ signal: AbortSignal.timeout(5e3)
301
+ });
302
+ reachable = isHealthyHttpStatus(res.status);
303
+ reachDetail = `HTTP ${res.status} from ${config.baseUrl}`;
304
+ if (!reachable) {
305
+ reachDetail += " (expected 2xx)";
306
+ }
307
+ } catch (err) {
308
+ reachDetail = `Unreachable: ${err.message}`;
309
+ }
310
+ checks.push({
311
+ name: "baseUrl reachable",
312
+ ok: reachable,
313
+ detail: reachDetail,
314
+ fix: "Start your dev server, or update baseUrl in config"
315
+ });
316
+ for (const route of config.routes) {
317
+ if (!route.reference) {
318
+ checks.push({
319
+ name: `reference:${route.name}`,
320
+ ok: false,
321
+ detail: "No reference path configured",
322
+ fix: "Set routes[].reference to a PNG baseline (from Figma export or Agent)"
323
+ });
324
+ continue;
325
+ }
326
+ const refPath = resolve2(cwd, route.reference);
327
+ checks.push({
328
+ name: `reference:${route.name}`,
329
+ ok: existsSync4(refPath),
330
+ detail: existsSync4(refPath) ? `Found ${refPath}` : `Missing ${refPath}`,
331
+ fix: "Export baseline PNG or ask Agent to place Figma screenshot"
332
+ });
333
+ }
334
+ const testCmd = config.testCommand.trim();
335
+ checks.push({
336
+ name: "testCommand non-empty",
337
+ ok: testCmd.length > 0,
338
+ detail: testCmd || "(empty)",
339
+ fix: 'Set testCommand e.g. "pnpm test:e2e"'
340
+ });
341
+ }
342
+ const pwInstall = deps.spawnSync(
343
+ process.platform === "win32" ? "npx.cmd" : "npx",
344
+ ["playwright", "--version"],
345
+ { cwd, encoding: "utf8" }
346
+ );
347
+ checks.push({
348
+ name: "Playwright CLI",
349
+ ok: spawnOk(pwInstall),
350
+ detail: spawnOk(pwInstall) ? pwInstall.stdout.trim() || "playwright available" : "Playwright not available via npx",
351
+ fix: "pnpm add -D playwright && pnpm exec playwright install chromium"
352
+ });
353
+ const skillPath = join3(
354
+ cwd,
355
+ ".cursor",
356
+ "skills",
357
+ "frontend-precision",
358
+ "SKILL.md"
359
+ );
360
+ checks.push({
361
+ name: "Cursor Skill installed",
362
+ ok: existsSync4(skillPath),
363
+ detail: existsSync4(skillPath) ? skillPath : "Skill not found",
364
+ fix: "Run `frontend-precision init`"
365
+ });
366
+ return { checks, ok: checks.every((c) => c.ok) };
367
+ }
368
+ function formatDoctor(checks) {
369
+ const lines = ["frontend-precision doctor", ""];
370
+ for (const c of checks) {
371
+ lines.push(`${c.ok ? "\u2713" : "\u2717"} ${c.name}`);
372
+ lines.push(` ${c.detail}`);
373
+ if (!c.ok && c.fix) lines.push(` Fix: ${c.fix}`);
374
+ }
375
+ lines.push("");
376
+ lines.push(
377
+ checks.every((c) => c.ok) ? "All checks passed." : "Some checks failed. Fix the items above before capture/visual/test."
378
+ );
379
+ return lines.join("\n");
380
+ }
381
+
382
+ // src/commands/capture.ts
383
+ import { writeFileSync as writeFileSync3 } from "fs";
384
+ import { join as join6 } from "path";
385
+
386
+ // src/lib/screenshot.ts
387
+ import { writeFileSync as writeFileSync2 } from "fs";
388
+ import { join as join5 } from "path";
389
+ import { chromium, firefox, webkit } from "playwright";
390
+
391
+ // src/lib/paths.ts
392
+ import { mkdirSync as mkdirSync2 } from "fs";
393
+ import { join as join4, resolve as resolve3, sep } from "path";
394
+
395
+ // src/lib/run-id.ts
396
+ function createRunId(date = /* @__PURE__ */ new Date()) {
397
+ const iso = date.toISOString().replace(/[:.]/g, "-");
398
+ const rand = Math.random().toString(36).slice(2, 8);
399
+ return `${iso}_${rand}`;
400
+ }
401
+ function assertSafeRunId(runId) {
402
+ return assertSafeSlug(runId, "run id");
403
+ }
404
+
405
+ // src/lib/paths.ts
406
+ var ARTIFACTS_ROOT = ".frontend-precision";
407
+ var ARTIFACTS_DIR = "artifacts";
408
+ function resolveCwd(cwd) {
409
+ return resolve3(cwd ?? process.cwd());
410
+ }
411
+ function fpRoot(cwd) {
412
+ return join4(resolveCwd(cwd), ARTIFACTS_ROOT);
413
+ }
414
+ function artifactsDir(runId, cwd) {
415
+ const safeId = assertSafeRunId(runId);
416
+ const root = resolve3(join4(fpRoot(cwd), ARTIFACTS_DIR));
417
+ const resolved = resolve3(root, safeId);
418
+ const prefix = root.endsWith(sep) ? root : root + sep;
419
+ if (resolved !== root && !resolved.startsWith(prefix)) {
420
+ throw new Error(
421
+ `Invalid run id "${runId}": resolved path escapes artifacts directory.`
422
+ );
423
+ }
424
+ return resolved;
425
+ }
426
+ function screenshotsDir(runId, cwd) {
427
+ return join4(artifactsDir(runId, cwd), "screenshots");
428
+ }
429
+ function assertContainedPath(root, candidate, label) {
430
+ const resolvedRoot = resolve3(root);
431
+ const resolvedCandidate = resolve3(candidate);
432
+ const prefix = resolvedRoot.endsWith(sep) ? resolvedRoot : resolvedRoot + sep;
433
+ if (resolvedCandidate !== resolvedRoot && !resolvedCandidate.startsWith(prefix)) {
434
+ throw new Error(`${label} escapes its output directory.`);
435
+ }
436
+ return resolvedCandidate;
437
+ }
438
+ function screenshotPath(runId, routeName, cwd) {
439
+ const safeName = assertSafeSlug(routeName, "route name");
440
+ const root = screenshotsDir(runId, cwd);
441
+ return assertContainedPath(
442
+ root,
443
+ resolve3(root, `${safeName}.png`),
444
+ "Route screenshot path"
445
+ );
446
+ }
447
+ function visualDir(runId, routeName, cwd) {
448
+ const safeName = assertSafeSlug(routeName, "route name");
449
+ const root = resolve3(artifactsDir(runId, cwd), "visual");
450
+ return assertContainedPath(
451
+ root,
452
+ resolve3(root, safeName),
453
+ "Route visual path"
454
+ );
455
+ }
456
+ function ensureDir(path) {
457
+ mkdirSync2(path, { recursive: true });
458
+ return path;
459
+ }
460
+
461
+ // src/lib/routes.ts
462
+ function selectRoutes(routes, filter) {
463
+ if (!filter?.length) return routes;
464
+ const missing = filter.filter(
465
+ (name) => !routes.some((route) => route.name === name)
466
+ );
467
+ if (missing.length > 0) {
468
+ const valid = routes.map((r) => r.name).join(", ") || "(none)";
469
+ throw new Error(
470
+ `Unknown route(s): ${missing.join(", ")}. Valid routes: ${valid}`
471
+ );
472
+ }
473
+ return routes.filter((route) => filter.includes(route.name));
474
+ }
475
+
476
+ // src/lib/screenshot.ts
477
+ function browserType(name) {
478
+ switch (name) {
479
+ case "firefox":
480
+ return firefox;
481
+ case "webkit":
482
+ return webkit;
483
+ default:
484
+ return chromium;
485
+ }
486
+ }
487
+ async function captureRoutes(options) {
488
+ const { config, runId, cwd, routeFilter } = options;
489
+ const outDir = ensureDir(screenshotsDir(runId, cwd));
490
+ const routes = selectRoutes(config.routes, routeFilter);
491
+ const browser = await browserType(config.browser).launch({ headless: true });
492
+ const context = await browser.newContext({
493
+ viewport: config.viewport,
494
+ deviceScaleFactor: 1
495
+ });
496
+ const page = await context.newPage();
497
+ const screenshots = [];
498
+ const routeViewports = {};
499
+ try {
500
+ for (const route of routes) {
501
+ const url = new URL(route.path, config.baseUrl).toString();
502
+ const filePath = screenshotPath(runId, route.name, cwd);
503
+ const viewport = route.viewport ?? config.viewport;
504
+ routeViewports[route.name] = viewport;
505
+ try {
506
+ await page.setViewportSize(viewport);
507
+ const response = await page.goto(url, {
508
+ waitUntil: "load",
509
+ timeout: 6e4
510
+ });
511
+ const status = response?.status() ?? 0;
512
+ const finalUrl = page.url();
513
+ if (!response || !isHealthyHttpStatus(status)) {
514
+ throw new Error(
515
+ `HTTP ${status} for ${url} (final: ${finalUrl}); expected 2xx`
516
+ );
517
+ }
518
+ await page.waitForLoadState("networkidle", { timeout: 3e4 }).catch(
519
+ () => void 0
520
+ );
521
+ await page.evaluate(async () => {
522
+ const fonts = globalThis.document?.fonts;
523
+ if (fonts?.ready) await fonts.ready;
524
+ });
525
+ if (route.waitFor) {
526
+ await page.waitForSelector(route.waitFor, { timeout: 3e4 });
527
+ }
528
+ if (route.hide?.length) {
529
+ await page.addStyleTag({
530
+ content: route.hide.map((sel) => `${sel}{visibility:hidden !important;}`).join("\n")
531
+ });
532
+ }
533
+ if (config.settleMs > 0) {
534
+ await page.waitForTimeout(config.settleMs);
535
+ }
536
+ await page.screenshot({ path: filePath, fullPage: false });
537
+ screenshots.push({ name: route.name, path: filePath, url: finalUrl, ok: true });
538
+ } catch (err) {
539
+ screenshots.push({
540
+ name: route.name,
541
+ path: filePath,
542
+ url,
543
+ ok: false,
544
+ error: err.message
545
+ });
546
+ }
547
+ }
548
+ } finally {
549
+ await context.close();
550
+ await browser.close();
551
+ }
552
+ const meta = {
553
+ runId,
554
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
555
+ viewport: config.viewport,
556
+ routeViewports,
557
+ browser: config.browser,
558
+ baseUrl: config.baseUrl,
559
+ screenshots
560
+ };
561
+ const metaPath = join5(outDir, "capture-meta.json");
562
+ writeFileSync2(metaPath, JSON.stringify(meta, null, 2));
563
+ return { runId, screenshots, metaPath };
564
+ }
565
+
566
+ // src/commands/capture.ts
567
+ async function runCapture(options) {
568
+ const cwd = options.cwd ?? process.cwd();
569
+ const config = loadConfig(cwd);
570
+ const runId = options.runId ?? createRunId();
571
+ ensureDir(artifactsDir(runId, cwd));
572
+ const result = await captureRoutes({
573
+ config,
574
+ runId,
575
+ cwd,
576
+ routeFilter: options.routes
577
+ });
578
+ writeFileSync3(
579
+ join6(artifactsDir(runId, cwd), "run-id.txt"),
580
+ runId,
581
+ "utf8"
582
+ );
583
+ const failed = result.screenshots.filter((s) => !s.ok);
584
+ const ok = failed.length === 0;
585
+ const message = [
586
+ `Capture run: ${runId}`,
587
+ ...result.screenshots.map(
588
+ (s) => ` ${s.ok ? "\u2713" : "\u2717"} ${s.name} \u2192 ${s.path}${s.error ? ` (${s.error})` : ""}`
589
+ ),
590
+ `Meta: ${result.metaPath}`
591
+ ].join("\n");
592
+ return { runId, ok, message };
593
+ }
594
+
595
+ // src/commands/visual.ts
596
+ import { resolve as resolve4 } from "path";
597
+ import { existsSync as existsSync6 } from "fs";
598
+
599
+ // src/lib/visual-diff.ts
600
+ import {
601
+ existsSync as existsSync5,
602
+ readFileSync as readFileSync3,
603
+ writeFileSync as writeFileSync4,
604
+ mkdirSync as mkdirSync3,
605
+ copyFileSync
606
+ } from "fs";
607
+ import { createHash } from "crypto";
608
+ import { dirname as dirname3, join as join7 } from "path";
609
+ import { PNG } from "pngjs";
610
+ import pixelmatch from "pixelmatch";
611
+ function sha256File(path) {
612
+ return createHash("sha256").update(readFileSync3(path)).digest("hex");
613
+ }
614
+ function ensureParent(filePath) {
615
+ mkdirSync3(dirname3(filePath), { recursive: true });
616
+ }
617
+ function compareImages(input) {
618
+ const {
619
+ name,
620
+ referencePath,
621
+ actualPath,
622
+ outputDir,
623
+ pixelThreshold,
624
+ maxDiffRatio,
625
+ referenceKind = "figma",
626
+ rejectIdenticalBaseline = true
627
+ } = input;
628
+ mkdirSync3(outputDir, { recursive: true });
629
+ const outRef = join7(outputDir, "reference.png");
630
+ const outActual = join7(outputDir, "actual.png");
631
+ const outDiff = join7(outputDir, "diff.png");
632
+ const outJson = join7(outputDir, "visual-result.json");
633
+ const base = {
634
+ name,
635
+ pixelThreshold,
636
+ maxDiffRatio,
637
+ referenceKind,
638
+ identicalBytes: false,
639
+ paths: {
640
+ reference: outRef,
641
+ actual: outActual,
642
+ diff: null,
643
+ resultJson: outJson
644
+ }
645
+ };
646
+ if (!existsSync5(referencePath)) {
647
+ const result2 = {
648
+ ...base,
649
+ width: 0,
650
+ height: 0,
651
+ diffRatio: 1,
652
+ diffPixels: 0,
653
+ totalPixels: 0,
654
+ passed: false,
655
+ reason: `missing_reference: ${referencePath}`
656
+ };
657
+ writeFileSync4(outJson, JSON.stringify(result2, null, 2));
658
+ return result2;
659
+ }
660
+ if (!existsSync5(actualPath)) {
661
+ copyFileSync(referencePath, outRef);
662
+ const result2 = {
663
+ ...base,
664
+ width: 0,
665
+ height: 0,
666
+ diffRatio: 1,
667
+ diffPixels: 0,
668
+ totalPixels: 0,
669
+ passed: false,
670
+ reason: `missing_actual: ${actualPath}`
671
+ };
672
+ writeFileSync4(outJson, JSON.stringify(result2, null, 2));
673
+ return result2;
674
+ }
675
+ const identicalBytes = sha256File(referencePath) === sha256File(actualPath);
676
+ copyFileSync(referencePath, outRef);
677
+ copyFileSync(actualPath, outActual);
678
+ const rejectIdentical = identicalBytes && rejectIdenticalBaseline && referenceKind === "figma";
679
+ if (rejectIdentical) {
680
+ const result2 = {
681
+ ...base,
682
+ identicalBytes: true,
683
+ width: 0,
684
+ height: 0,
685
+ diffRatio: 0,
686
+ diffPixels: 0,
687
+ totalPixels: 0,
688
+ passed: false,
689
+ reason: "self_baseline_identical: reference SHA-256 equals capture while referenceKind=figma; export a real Figma PNG or set referenceKind=regression"
690
+ };
691
+ writeFileSync4(outJson, JSON.stringify(result2, null, 2));
692
+ return result2;
693
+ }
694
+ let refPng;
695
+ let actPng;
696
+ try {
697
+ refPng = PNG.sync.read(readFileSync3(referencePath));
698
+ actPng = PNG.sync.read(readFileSync3(actualPath));
699
+ } catch (err) {
700
+ const result2 = {
701
+ ...base,
702
+ identicalBytes,
703
+ width: 0,
704
+ height: 0,
705
+ diffRatio: 1,
706
+ diffPixels: 0,
707
+ totalPixels: 0,
708
+ passed: false,
709
+ reason: `image_read_error: ${err.message}`
710
+ };
711
+ writeFileSync4(outJson, JSON.stringify(result2, null, 2));
712
+ return result2;
713
+ }
714
+ if (refPng.width !== actPng.width || refPng.height !== actPng.height) {
715
+ const result2 = {
716
+ ...base,
717
+ identicalBytes,
718
+ width: actPng.width,
719
+ height: actPng.height,
720
+ diffRatio: 1,
721
+ diffPixels: 0,
722
+ totalPixels: refPng.width * refPng.height,
723
+ passed: false,
724
+ reason: `dimension_mismatch: reference=${refPng.width}x${refPng.height} actual=${actPng.width}x${actPng.height}`,
725
+ paths: { ...base.paths, diff: null }
726
+ };
727
+ writeFileSync4(outJson, JSON.stringify(result2, null, 2));
728
+ return result2;
729
+ }
730
+ const { width, height } = refPng;
731
+ const diff = new PNG({ width, height });
732
+ const diffPixels = pixelmatch(
733
+ refPng.data,
734
+ actPng.data,
735
+ diff.data,
736
+ width,
737
+ height,
738
+ { threshold: pixelThreshold }
739
+ );
740
+ const totalPixels = width * height;
741
+ const diffRatio = totalPixels === 0 ? 1 : diffPixels / totalPixels;
742
+ const passed = diffRatio <= maxDiffRatio;
743
+ ensureParent(outDiff);
744
+ writeFileSync4(outDiff, PNG.sync.write(diff));
745
+ const result = {
746
+ ...base,
747
+ identicalBytes,
748
+ width,
749
+ height,
750
+ diffRatio,
751
+ diffPixels,
752
+ totalPixels,
753
+ passed,
754
+ reason: passed ? identicalBytes ? "identical_bytes_allowed" : null : `diff_ratio_exceeded: ${(diffRatio * 100).toFixed(3)}% > ${(maxDiffRatio * 100).toFixed(3)}%`,
755
+ paths: { ...base.paths, diff: outDiff }
756
+ };
757
+ writeFileSync4(outJson, JSON.stringify(result, null, 2));
758
+ return result;
759
+ }
760
+
761
+ // src/commands/visual.ts
762
+ async function runVisual(options) {
763
+ const cwd = options.cwd ?? process.cwd();
764
+ const config = loadConfig(cwd);
765
+ let runId = options.runId;
766
+ if (!runId) {
767
+ runId = createRunId();
768
+ }
769
+ ensureDir(artifactsDir(runId, cwd));
770
+ const routes = selectRoutes(config.routes, options.routes);
771
+ const lines = [`Visual run: ${runId}`];
772
+ let ok = true;
773
+ for (const route of routes) {
774
+ const actualPath = screenshotPath(runId, route.name, cwd);
775
+ const referencePath = route.reference ? resolve4(cwd, route.reference) : "";
776
+ const outDir = visualDir(runId, route.name, cwd);
777
+ if (!route.reference) {
778
+ ok = false;
779
+ lines.push(` \u2717 ${route.name}: no reference configured`);
780
+ compareImages({
781
+ name: route.name,
782
+ referencePath: "",
783
+ actualPath,
784
+ outputDir: outDir,
785
+ pixelThreshold: config.visual.pixelThreshold,
786
+ maxDiffRatio: config.visual.maxDiffRatio,
787
+ referenceKind: route.referenceKind,
788
+ rejectIdenticalBaseline: config.gates.rejectIdenticalBaseline
789
+ });
790
+ continue;
791
+ }
792
+ if (!existsSync6(actualPath)) {
793
+ ok = false;
794
+ lines.push(
795
+ ` \u2717 ${route.name}: missing actual screenshot (run capture first)`
796
+ );
797
+ }
798
+ const result = compareImages({
799
+ name: route.name,
800
+ referencePath,
801
+ actualPath,
802
+ outputDir: outDir,
803
+ pixelThreshold: config.visual.pixelThreshold,
804
+ maxDiffRatio: config.visual.maxDiffRatio,
805
+ referenceKind: route.referenceKind,
806
+ rejectIdenticalBaseline: config.gates.rejectIdenticalBaseline
807
+ });
808
+ if (!result.passed) ok = false;
809
+ const kindTag = `kind=${result.referenceKind}`;
810
+ const identicalTag = result.identicalBytes ? " identical" : "";
811
+ lines.push(
812
+ ` ${result.passed ? "\u2713" : "\u2717"} ${route.name}: diffRatio=${(result.diffRatio * 100).toFixed(3)}% (${kindTag}${identicalTag})${result.reason ? ` \u2014 ${result.reason}` : ""}`
813
+ );
814
+ lines.push(` \u2192 ${result.paths.resultJson}`);
815
+ }
816
+ return { runId, ok, message: lines.join("\n") };
817
+ }
818
+
819
+ // src/lib/test-runner.ts
820
+ import { spawn } from "child_process";
821
+ import { writeFileSync as writeFileSync5 } from "fs";
822
+ import { join as join8 } from "path";
823
+ function runShell(command, cwd) {
824
+ return new Promise((resolve6) => {
825
+ const started = Date.now();
826
+ const child = spawn(command, {
827
+ cwd,
828
+ shell: true,
829
+ env: process.env
830
+ });
831
+ let stdout = "";
832
+ let stderr = "";
833
+ child.stdout.on("data", (d) => {
834
+ stdout += d.toString();
835
+ });
836
+ child.stderr.on("data", (d) => {
837
+ stderr += d.toString();
838
+ });
839
+ child.on("error", (err) => {
840
+ stderr += err.message;
841
+ resolve6({
842
+ exitCode: 127,
843
+ stdout,
844
+ stderr,
845
+ durationMs: Date.now() - started
846
+ });
847
+ });
848
+ child.on("close", (code) => {
849
+ resolve6({
850
+ exitCode: code,
851
+ stdout,
852
+ stderr,
853
+ durationMs: Date.now() - started
854
+ });
855
+ });
856
+ });
857
+ }
858
+ async function runConfiguredTests(options) {
859
+ const cwd = options.cwd ?? process.cwd();
860
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
861
+ const commands = [];
862
+ if (options.config.checks?.typecheck) {
863
+ commands.push({ name: "typecheck", command: options.config.checks.typecheck });
864
+ }
865
+ if (options.config.checks?.lint) {
866
+ commands.push({ name: "lint", command: options.config.checks.lint });
867
+ }
868
+ if (options.config.checks?.unit) {
869
+ commands.push({ name: "unit", command: options.config.checks.unit });
870
+ }
871
+ if (options.config.checks?.e2e) {
872
+ commands.push({ name: "e2e", command: options.config.checks.e2e });
873
+ }
874
+ commands.push({ name: "testCommand", command: options.config.testCommand });
875
+ const seen = /* @__PURE__ */ new Set();
876
+ const unique = commands.filter((c) => {
877
+ if (seen.has(c.command)) return false;
878
+ seen.add(c.command);
879
+ return true;
880
+ });
881
+ const results = [];
882
+ for (const item of unique) {
883
+ if (!item.command.trim()) {
884
+ results.push({
885
+ name: item.name,
886
+ command: item.command,
887
+ status: "FAIL",
888
+ exitCode: null,
889
+ durationMs: 0,
890
+ stdout: "",
891
+ stderr: "",
892
+ reason: "empty_command"
893
+ });
894
+ continue;
895
+ }
896
+ const run = await runShell(item.command, cwd);
897
+ const status = run.exitCode === 0 ? "PASS" : "FAIL";
898
+ results.push({
899
+ name: item.name,
900
+ command: item.command,
901
+ status,
902
+ exitCode: run.exitCode,
903
+ durationMs: run.durationMs,
904
+ stdout: run.stdout,
905
+ stderr: run.stderr,
906
+ reason: status === "FAIL" ? `exit_code_${run.exitCode ?? "null"}` : void 0
907
+ });
908
+ }
909
+ const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
910
+ const suite = {
911
+ runId: options.runId,
912
+ results,
913
+ passed: results.every((r) => r.status === "PASS"),
914
+ startedAt,
915
+ finishedAt
916
+ };
917
+ const dir = ensureDir(artifactsDir(options.runId, cwd));
918
+ writeFileSync5(join8(dir, "test-result.json"), JSON.stringify(suite, null, 2));
919
+ return suite;
920
+ }
921
+
922
+ // src/commands/test.ts
923
+ import { writeFileSync as writeFileSync6 } from "fs";
924
+ import { join as join9 } from "path";
925
+ async function runTestCommand(options) {
926
+ const cwd = options.cwd ?? process.cwd();
927
+ const config = loadConfig(cwd);
928
+ const runId = options.runId ?? createRunId();
929
+ ensureDir(artifactsDir(runId, cwd));
930
+ writeFileSync6(join9(artifactsDir(runId, cwd), "run-id.txt"), runId);
931
+ const suite = await runConfiguredTests({ config, runId, cwd });
932
+ const lines = [
933
+ `Test run: ${runId}`,
934
+ ...suite.results.map(
935
+ (r) => ` ${r.status === "PASS" ? "\u2713" : "\u2717"} ${r.name} (${r.command}) exit=${r.exitCode} ${r.durationMs}ms`
936
+ ),
937
+ `Overall: ${suite.passed ? "PASS" : "FAIL"}`
938
+ ];
939
+ return { runId, ok: suite.passed, message: lines.join("\n") };
940
+ }
941
+
942
+ // src/commands/report.ts
943
+ import { existsSync as existsSync9, readFileSync as readFileSync6 } from "fs";
944
+ import { join as join12 } from "path";
945
+
946
+ // src/lib/report-builder.ts
947
+ import { existsSync as existsSync8, readFileSync as readFileSync5, readdirSync as readdirSync2, writeFileSync as writeFileSync7 } from "fs";
948
+ import { join as join11 } from "path";
949
+
950
+ // src/lib/acceptance.ts
951
+ import { existsSync as existsSync7, readdirSync, readFileSync as readFileSync4, statSync } from "fs";
952
+ import { join as join10, resolve as resolve5, sep as sep2 } from "path";
953
+ var STATUS_SET = /* @__PURE__ */ new Set([
954
+ "PASS",
955
+ "FAIL",
956
+ "PENDING",
957
+ "SKIPPED",
958
+ "PARTIAL"
959
+ ]);
960
+ function normalizeStatus(raw) {
961
+ const value = raw.trim().toUpperCase();
962
+ if (STATUS_SET.has(value)) {
963
+ return value;
964
+ }
965
+ return "PENDING";
966
+ }
967
+ function normalizeAcId(raw) {
968
+ const match = raw.trim().match(/AC-(\d+)/i);
969
+ const digits = match?.[1];
970
+ if (!digits) return raw.trim().toUpperCase();
971
+ return `AC-${digits.padStart(3, "0")}`;
972
+ }
973
+ function normalizeReqId(digits) {
974
+ return `REQ-${digits.padStart(3, "0")}`;
975
+ }
976
+ function parseAcceptanceMarkdown(markdown) {
977
+ const rows = [];
978
+ for (const line of markdown.split(/\r?\n/)) {
979
+ const trimmed = line.trim();
980
+ if (!trimmed.startsWith("|")) continue;
981
+ if (/^\|\s*-+/.test(trimmed)) continue;
982
+ if (/AC\s*ID/i.test(trimmed) && /描述|Description/i.test(trimmed)) continue;
983
+ const parts = trimmed.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
984
+ if (parts.length < 2) continue;
985
+ const idRaw = parts[0];
986
+ if (!idRaw || !/^AC-\d+/i.test(idRaw)) continue;
987
+ const title = parts[1] ?? "";
988
+ const reqCell = parts[2] ?? "";
989
+ const verification = parts.length >= 5 ? parts[3] : void 0;
990
+ const reqMatches = [...reqCell.matchAll(/REQ-(\d+)/gi)].flatMap((m) => {
991
+ const digits = m[1];
992
+ return digits ? [normalizeReqId(digits)] : [];
993
+ });
994
+ const requirementId = reqMatches.length > 0 ? [...new Set(reqMatches)].join(", ") : void 0;
995
+ const statusCell = parts[parts.length - 1] ?? "PENDING";
996
+ const status = normalizeStatus(statusCell);
997
+ rows.push({
998
+ id: normalizeAcId(idRaw),
999
+ title,
1000
+ ...requirementId ? { requirementId } : {},
1001
+ ...verification ? { verification } : {},
1002
+ status
1003
+ });
1004
+ }
1005
+ return rows;
1006
+ }
1007
+ function deriveRequirements(acceptance) {
1008
+ const map = /* @__PURE__ */ new Map();
1009
+ for (const row of acceptance) {
1010
+ const reqIds = (row.requirementId ?? "").split(/[,,\s]+/).map((s) => s.trim()).filter((s) => /^REQ-\d+/i.test(s));
1011
+ for (const raw of reqIds) {
1012
+ const match = raw.match(/REQ-(\d+)/i);
1013
+ const digits = match?.[1];
1014
+ if (!digits) continue;
1015
+ const id = normalizeReqId(digits);
1016
+ const existing = map.get(id);
1017
+ if (!existing) {
1018
+ map.set(id, { id, title: id, status: row.status });
1019
+ continue;
1020
+ }
1021
+ existing.status = mergeStatus(existing.status, row.status);
1022
+ }
1023
+ }
1024
+ return [...map.values()];
1025
+ }
1026
+ function mergeStatus(a, b) {
1027
+ if (a === "FAIL" || b === "FAIL") return "FAIL";
1028
+ if (a === "PENDING" || b === "PENDING") return "PENDING";
1029
+ if (a === "PARTIAL" || b === "PARTIAL") return "PARTIAL";
1030
+ if (a === "SKIPPED" || b === "SKIPPED") return "SKIPPED";
1031
+ return "PASS";
1032
+ }
1033
+ function isPathInside(parent, child) {
1034
+ const root = resolve5(parent);
1035
+ const target = resolve5(child);
1036
+ return target === root || target.startsWith(root + sep2);
1037
+ }
1038
+ function findTaskDir(options) {
1039
+ const tasksRoot = join10(options.cwd, ".frontend-precision", "tasks");
1040
+ if (!existsSync7(tasksRoot)) return null;
1041
+ if (options.taskId) {
1042
+ if (!isSafeSlug(options.taskId)) {
1043
+ return null;
1044
+ }
1045
+ const dir = join10(tasksRoot, options.taskId);
1046
+ if (!existsSync7(dir) || !isPathInside(tasksRoot, dir)) {
1047
+ return null;
1048
+ }
1049
+ return dir;
1050
+ }
1051
+ const entries = readdirSync(tasksRoot).filter((name) => isSafeSlug(name)).map((name) => {
1052
+ const dir = join10(tasksRoot, name);
1053
+ try {
1054
+ const st = statSync(dir);
1055
+ if (!st.isDirectory()) return null;
1056
+ if (!isPathInside(tasksRoot, dir)) return null;
1057
+ return { name, dir, mtime: st.mtimeMs };
1058
+ } catch {
1059
+ return null;
1060
+ }
1061
+ }).filter((e) => !!e).sort((a, b) => b.mtime - a.mtime);
1062
+ if (options.runId) {
1063
+ for (const entry of entries) {
1064
+ const runIdPath = join10(entry.dir, ".run-id");
1065
+ if (!existsSync7(runIdPath)) continue;
1066
+ const saved = readFileSync4(runIdPath, "utf8").trim();
1067
+ if (saved === options.runId && existsSync7(join10(entry.dir, "acceptance.md"))) {
1068
+ return entry.dir;
1069
+ }
1070
+ }
1071
+ }
1072
+ for (const entry of entries) {
1073
+ if (existsSync7(join10(entry.dir, "acceptance.md"))) {
1074
+ return entry.dir;
1075
+ }
1076
+ }
1077
+ return null;
1078
+ }
1079
+ function loadAcceptanceFromTask(options) {
1080
+ const taskDir = findTaskDir(options);
1081
+ if (!taskDir) {
1082
+ return {
1083
+ taskDir: null,
1084
+ acceptance: [],
1085
+ requirements: [],
1086
+ codeChangeSummary: null
1087
+ };
1088
+ }
1089
+ const acceptancePath = join10(taskDir, "acceptance.md");
1090
+ const acceptance = existsSync7(acceptancePath) ? parseAcceptanceMarkdown(readFileSync4(acceptancePath, "utf8")) : [];
1091
+ const summaryPath = join10(taskDir, "code-change-summary.md");
1092
+ const codeChangeSummary = existsSync7(summaryPath) ? readFileSync4(summaryPath, "utf8") : null;
1093
+ return {
1094
+ taskDir,
1095
+ acceptance,
1096
+ requirements: deriveRequirements(acceptance),
1097
+ codeChangeSummary
1098
+ };
1099
+ }
1100
+ function evidenceHintsForRow(row) {
1101
+ const v = `${row.verification ?? ""} ${row.title}`.toLowerCase();
1102
+ const needsVisual = /visual|figma|像素|视觉/.test(v);
1103
+ const needsUnitMention = /\bunit\b|单测|logic\.test/.test(v);
1104
+ const needsTestPass = /playwright|e2e|frontend-precision test|typecheck|unit|单测|tsx --test/.test(
1105
+ v
1106
+ ) || needsUnitMention;
1107
+ return { needsTestPass, needsVisual, needsUnitMention };
1108
+ }
1109
+ function evaluateAcceptanceEvidence(acceptance, options) {
1110
+ const failures = [];
1111
+ const unverified = [];
1112
+ const testsPassed = Boolean(options.tests?.passed);
1113
+ const testBlob = (options.tests?.results ?? []).map((r) => `${r.name} ${r.command} ${r.stdout} ${r.stderr}`).join("\n").toLowerCase();
1114
+ for (const row of acceptance) {
1115
+ if (row.status !== "PASS") continue;
1116
+ const hints = evidenceHintsForRow(row);
1117
+ const acToken = row.id.toLowerCase();
1118
+ if (hints.needsTestPass) {
1119
+ if (!options.tests) {
1120
+ failures.push(
1121
+ `acceptance:${row.id}: PASS without test evidence (tests_not_run)`
1122
+ );
1123
+ continue;
1124
+ }
1125
+ if (!testsPassed) {
1126
+ failures.push(
1127
+ `acceptance:${row.id}: PASS but testCommand failed`
1128
+ );
1129
+ continue;
1130
+ }
1131
+ if (hints.needsUnitMention && !testBlob.includes(acToken) && !/tokenDramaPreviewPlaylistLogic|playlistLogic|\.test\.ts|pass/.test(
1132
+ testBlob
1133
+ )) {
1134
+ unverified.push(`acceptance_evidence_weak:${row.id}`);
1135
+ }
1136
+ }
1137
+ if (hints.needsVisual) {
1138
+ if (options.visualPassedNames.length === 0) {
1139
+ failures.push(
1140
+ `acceptance:${row.id}: PASS without visual evidence`
1141
+ );
1142
+ } else if (!options.hasFigmaVisualPass) {
1143
+ failures.push(
1144
+ `acceptance:${row.id}: PASS claims visual/Figma but no referenceKind=figma pass`
1145
+ );
1146
+ }
1147
+ }
1148
+ }
1149
+ return { failures, unverified };
1150
+ }
1151
+ function evaluateAcceptanceGate(acceptance, options) {
1152
+ const failures = [];
1153
+ const unverified = [];
1154
+ if (!options.requireAcceptance) {
1155
+ return { failures, unverified };
1156
+ }
1157
+ if (acceptance.length === 0) {
1158
+ failures.push(
1159
+ "acceptance_missing: no AC rows found (pass --task-id or ensure .frontend-precision/tasks/*/acceptance.md)"
1160
+ );
1161
+ return { failures, unverified };
1162
+ }
1163
+ const pending = acceptance.filter((a) => a.status === "PENDING");
1164
+ const failed = acceptance.filter((a) => a.status === "FAIL");
1165
+ const partial = acceptance.filter((a) => a.status === "PARTIAL");
1166
+ for (const row of failed) {
1167
+ failures.push(`acceptance:${row.id}: FAIL \u2014 ${row.title}`);
1168
+ }
1169
+ if (options.requireAcceptanceAllPass) {
1170
+ for (const row of pending) {
1171
+ failures.push(`acceptance:${row.id}: PENDING \u2014 ${row.title}`);
1172
+ }
1173
+ for (const row of partial) {
1174
+ failures.push(
1175
+ `acceptance:${row.id}: PARTIAL \u2014 ${row.title} (requireAcceptanceAllPass)`
1176
+ );
1177
+ }
1178
+ } else {
1179
+ for (const row of pending) {
1180
+ unverified.push(`acceptance_pending:${row.id}`);
1181
+ }
1182
+ for (const row of partial) {
1183
+ unverified.push(`acceptance_partial:${row.id}`);
1184
+ }
1185
+ }
1186
+ return { failures, unverified };
1187
+ }
1188
+
1189
+ // src/lib/report-builder.ts
1190
+ function readJsonIfExists(path) {
1191
+ if (!existsSync8(path)) return null;
1192
+ try {
1193
+ return JSON.parse(readFileSync5(path, "utf8"));
1194
+ } catch {
1195
+ return null;
1196
+ }
1197
+ }
1198
+ function collectVisualResults(runDir) {
1199
+ const visualRoot = join11(runDir, "visual");
1200
+ if (!existsSync8(visualRoot)) return [];
1201
+ const results = [];
1202
+ for (const name of readdirSync2(visualRoot)) {
1203
+ const jsonPath = join11(visualRoot, name, "visual-result.json");
1204
+ const data = readJsonIfExists(jsonPath);
1205
+ if (data) results.push(data);
1206
+ }
1207
+ return results;
1208
+ }
1209
+ function collectScreenshots(runDir) {
1210
+ const shotDir = join11(runDir, "screenshots");
1211
+ if (!existsSync8(shotDir)) return [];
1212
+ return readdirSync2(shotDir).filter((f) => f.endsWith(".png")).map((f) => join11(shotDir, f));
1213
+ }
1214
+ function escapeHtml(s) {
1215
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1216
+ }
1217
+ function truncateLog(text, max = 4e3) {
1218
+ if (text.length <= max) return text;
1219
+ return `${text.slice(0, max)}
1220
+ \u2026(truncated)`;
1221
+ }
1222
+ function buildReport(options) {
1223
+ const cwd = options.cwd ?? process.cwd();
1224
+ const runDir = artifactsDir(options.runId, cwd);
1225
+ const capture = readJsonIfExists(
1226
+ join11(runDir, "screenshots", "capture-meta.json")
1227
+ );
1228
+ const visual = collectVisualResults(runDir);
1229
+ const tests = readJsonIfExists(
1230
+ join11(runDir, "test-result.json")
1231
+ );
1232
+ const screenshots = collectScreenshots(runDir);
1233
+ const gates = {
1234
+ requireAcceptance: options.gates?.requireAcceptance ?? true,
1235
+ requireAcceptanceAllPass: options.gates?.requireAcceptanceAllPass ?? true,
1236
+ requireFigmaVisual: options.gates?.requireFigmaVisual ?? false,
1237
+ rejectIdenticalBaseline: options.gates?.rejectIdenticalBaseline ?? true
1238
+ };
1239
+ const fromTask = loadAcceptanceFromTask({
1240
+ cwd,
1241
+ taskId: options.taskId,
1242
+ runId: options.runId
1243
+ });
1244
+ function toAcceptanceStatus(status2) {
1245
+ if (status2 === "BLOCKED") return "FAIL";
1246
+ if (status2 === "PASS" || status2 === "FAIL" || status2 === "PENDING" || status2 === "SKIPPED" || status2 === "PARTIAL") {
1247
+ return status2;
1248
+ }
1249
+ return "PENDING";
1250
+ }
1251
+ const acceptanceRows = options.acceptance && options.acceptance.length > 0 ? options.acceptance.map((a) => ({
1252
+ id: a.id,
1253
+ title: a.title,
1254
+ requirementId: a.requirementId,
1255
+ status: toAcceptanceStatus(a.status)
1256
+ })) : fromTask.acceptance;
1257
+ const requirementRows = options.requirements && options.requirements.length > 0 ? options.requirements.map((r) => ({
1258
+ id: r.id,
1259
+ title: r.title,
1260
+ status: toAcceptanceStatus(r.status)
1261
+ })) : fromTask.requirements;
1262
+ const codeChangeSummary = options.codeChangeSummary ?? fromTask.codeChangeSummary ?? null;
1263
+ const failures = [];
1264
+ const unverified = [];
1265
+ if (capture) {
1266
+ for (const shot of capture.screenshots) {
1267
+ if (!shot.ok) {
1268
+ failures.push(
1269
+ `capture:${shot.name}: ${shot.error ?? "failed"}${shot.url ? ` (${shot.url})` : ""}`
1270
+ );
1271
+ }
1272
+ }
1273
+ } else {
1274
+ unverified.push("capture_not_run");
1275
+ }
1276
+ for (const v of visual) {
1277
+ if (!v.passed) failures.push(`visual:${v.name}: ${v.reason ?? "failed"}`);
1278
+ if (v.referenceKind === "regression") {
1279
+ unverified.push(`figma_visual_not_proven:${v.name}`);
1280
+ }
1281
+ if (v.identicalBytes && v.referenceKind === "regression") {
1282
+ unverified.push(`regression_identical:${v.name}`);
1283
+ }
1284
+ }
1285
+ if (visual.length === 0) {
1286
+ unverified.push("visual_not_run");
1287
+ }
1288
+ const hasFigmaVisualPass = visual.some(
1289
+ (v) => v.passed && (v.referenceKind ?? "figma") === "figma" && !v.identicalBytes
1290
+ );
1291
+ if (gates.requireFigmaVisual && !hasFigmaVisualPass) {
1292
+ failures.push(
1293
+ "figma_visual_required: no passing referenceKind=figma visual (regression/self-baseline does not count)"
1294
+ );
1295
+ }
1296
+ if (tests) {
1297
+ for (const t of tests.results) {
1298
+ if (t.status === "FAIL") {
1299
+ failures.push(`test:${t.name}: ${t.reason ?? `exit ${t.exitCode}`}`);
1300
+ }
1301
+ }
1302
+ } else {
1303
+ unverified.push("tests_not_run");
1304
+ }
1305
+ const acGate = evaluateAcceptanceGate(acceptanceRows, {
1306
+ requireAcceptance: gates.requireAcceptance,
1307
+ requireAcceptanceAllPass: gates.requireAcceptanceAllPass
1308
+ });
1309
+ failures.push(...acGate.failures);
1310
+ unverified.push(...acGate.unverified);
1311
+ if (gates.requireAcceptance) {
1312
+ const evidence = evaluateAcceptanceEvidence(acceptanceRows, {
1313
+ tests,
1314
+ visualPassedNames: visual.filter((v) => v.passed).map((v) => v.name),
1315
+ hasFigmaVisualPass
1316
+ });
1317
+ failures.push(...evidence.failures);
1318
+ unverified.push(...evidence.unverified);
1319
+ }
1320
+ let status = "PASS";
1321
+ const fixAttempts = options.fixAttempts;
1322
+ const maxFixAttempts = options.maxFixAttempts;
1323
+ let blockedReason = options.blockedReason ?? null;
1324
+ if (fixAttempts !== void 0 && maxFixAttempts !== void 0 && fixAttempts > maxFixAttempts) {
1325
+ blockedReason = blockedReason ?? `fix_attempts_exceeded: ${fixAttempts} > maxFixAttempts=${maxFixAttempts}`;
1326
+ }
1327
+ if (blockedReason) {
1328
+ status = "BLOCKED";
1329
+ if (!failures.includes(blockedReason)) failures.push(blockedReason);
1330
+ } else if (failures.length > 0) {
1331
+ status = "FAIL";
1332
+ } else if (unverified.length > 0) {
1333
+ const hardUnverified = unverified.filter(
1334
+ (u) => u === "capture_not_run" || u === "visual_not_run" || u === "tests_not_run" || u.startsWith("acceptance_pending:") || u.startsWith("acceptance_partial:")
1335
+ );
1336
+ if (hardUnverified.length > 0) {
1337
+ status = "SKIPPED";
1338
+ } else if (unverified.some((u) => u.startsWith("figma_visual_not_proven"))) {
1339
+ status = "PASS";
1340
+ } else {
1341
+ status = "PASS";
1342
+ }
1343
+ }
1344
+ const report = {
1345
+ runId: options.runId,
1346
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1347
+ status,
1348
+ summary: {
1349
+ visualPassed: visual.filter((v) => v.passed).length,
1350
+ visualFailed: visual.filter((v) => !v.passed).length,
1351
+ visualSkipped: 0,
1352
+ testsPassed: tests?.results.filter((r) => r.status === "PASS").length ?? 0,
1353
+ testsFailed: tests?.results.filter((r) => r.status === "FAIL").length ?? 0,
1354
+ capturePassed: capture?.screenshots.filter((s) => s.ok).length ?? 0,
1355
+ captureFailed: capture?.screenshots.filter((s) => !s.ok).length ?? 0
1356
+ },
1357
+ requirements: requirementRows.map((r) => ({
1358
+ id: r.id,
1359
+ title: r.title,
1360
+ status: r.status
1361
+ })),
1362
+ acceptance: acceptanceRows.map((a) => ({
1363
+ id: a.id,
1364
+ requirementId: a.requirementId,
1365
+ title: a.title,
1366
+ status: a.status
1367
+ })),
1368
+ capture,
1369
+ visual,
1370
+ tests,
1371
+ failures,
1372
+ unverified,
1373
+ screenshots,
1374
+ codeChangeSummary,
1375
+ manualReview: [
1376
+ "DONE \u4EC5\u5F53\uFF1Areport.status===PASS \u4E14 acceptance \u65E0 PENDING/PARTIAL/FAIL \u4E14\u65E0 figma_visual_not_proven\uFF08\u6216\u5DF2\u660E\u786E\u53EA\u8981 regression\uFF09",
1377
+ "Review Figma vs screenshot for typography and spacing (pixel diff is not full design QA)",
1378
+ "Confirm Loading / Error / Empty states if not covered by e2e",
1379
+ "Confirm no secrets were committed"
1380
+ ],
1381
+ fixAttempts: options.fixAttempts,
1382
+ maxFixAttempts: options.maxFixAttempts
1383
+ };
1384
+ const outDir = ensureDir(runDir);
1385
+ writeFileSync7(join11(outDir, "report.json"), JSON.stringify(report, null, 2));
1386
+ writeFileSync7(join11(outDir, "report.md"), renderMarkdown(report));
1387
+ writeFileSync7(join11(outDir, "report.html"), renderHtml(report));
1388
+ return report;
1389
+ }
1390
+ function renderMarkdown(report) {
1391
+ const lines = [
1392
+ `# Frontend Precision Report`,
1393
+ "",
1394
+ `- Run ID: \`${report.runId}\``,
1395
+ `- Status: **${report.status}**`,
1396
+ `- Generated: ${report.generatedAt}`,
1397
+ "",
1398
+ `## Summary`,
1399
+ "",
1400
+ `| Area | Passed | Failed |`,
1401
+ `|---|---:|---:|`,
1402
+ `| Capture | ${report.summary.capturePassed} | ${report.summary.captureFailed} |`,
1403
+ `| Visual | ${report.summary.visualPassed} | ${report.summary.visualFailed} |`,
1404
+ `| Tests | ${report.summary.testsPassed} | ${report.summary.testsFailed} |`,
1405
+ ""
1406
+ ];
1407
+ if (report.failures.length) {
1408
+ lines.push("## Failures", "");
1409
+ for (const f of report.failures) lines.push(`- ${f}`);
1410
+ lines.push("");
1411
+ }
1412
+ if (report.unverified.length) {
1413
+ lines.push("## Unverified", "");
1414
+ for (const u of report.unverified) lines.push(`- ${u}`);
1415
+ lines.push("");
1416
+ } else {
1417
+ lines.push("## Unverified", "", "- (none)", "");
1418
+ }
1419
+ if (report.visual.length) {
1420
+ lines.push("## Visual", "");
1421
+ for (const v of report.visual) {
1422
+ lines.push(
1423
+ `- **${v.name}**: ${v.passed ? "PASS" : "FAIL"} (diffRatio=${(v.diffRatio * 100).toFixed(3)}%)${v.reason ? ` \u2014 ${v.reason}` : ""}`
1424
+ );
1425
+ if (v.paths.diff) lines.push(` - Diff: \`${v.paths.diff}\``);
1426
+ lines.push(` - Reference: \`${v.paths.reference}\``);
1427
+ lines.push(` - Actual: \`${v.paths.actual}\``);
1428
+ }
1429
+ lines.push("");
1430
+ }
1431
+ if (report.tests) {
1432
+ lines.push("## Tests", "");
1433
+ for (const t of report.tests.results) {
1434
+ lines.push(
1435
+ `- **${t.name}**: ${t.status} (exit=${t.exitCode}, ${t.durationMs}ms)`
1436
+ );
1437
+ lines.push(` - Command: \`${t.command}\``);
1438
+ if (t.stdout?.trim()) {
1439
+ lines.push(" - stdout:", "", "```", truncateLog(t.stdout).trimEnd(), "```", "");
1440
+ }
1441
+ if (t.stderr?.trim()) {
1442
+ lines.push(" - stderr:", "", "```", truncateLog(t.stderr).trimEnd(), "```", "");
1443
+ }
1444
+ }
1445
+ lines.push("");
1446
+ }
1447
+ if (report.requirements.length) {
1448
+ lines.push("## Requirements", "");
1449
+ for (const r of report.requirements) {
1450
+ lines.push(`- ${r.id}: ${r.title} \u2014 ${r.status}`);
1451
+ }
1452
+ lines.push("");
1453
+ }
1454
+ if (report.acceptance.length) {
1455
+ lines.push("## Acceptance Criteria", "");
1456
+ for (const a of report.acceptance) {
1457
+ lines.push(
1458
+ `- ${a.id}${a.requirementId ? ` (${a.requirementId})` : ""}: ${a.title} \u2014 ${a.status}`
1459
+ );
1460
+ }
1461
+ lines.push("");
1462
+ }
1463
+ if (report.codeChangeSummary) {
1464
+ lines.push("## Code Change Summary", "", report.codeChangeSummary, "");
1465
+ }
1466
+ lines.push("## Manual Review", "");
1467
+ for (const m of report.manualReview) lines.push(`- ${m}`);
1468
+ lines.push("");
1469
+ lines.push("## Screenshots", "");
1470
+ if (report.screenshots.length) {
1471
+ for (const s of report.screenshots) lines.push(`- \`${s}\``);
1472
+ } else {
1473
+ lines.push("- (none)");
1474
+ }
1475
+ lines.push("");
1476
+ return lines.join("\n");
1477
+ }
1478
+ function renderHtml(report) {
1479
+ const statusColor = report.status === "PASS" ? "#067647" : report.status === "BLOCKED" ? "#b54708" : report.status === "SKIPPED" ? "#667085" : "#b42318";
1480
+ const failureLis = report.failures.map((f) => `<li>${escapeHtml(f)}</li>`).join("");
1481
+ const unverifiedLis = report.unverified.length ? report.unverified.map((u) => `<li>${escapeHtml(u)}</li>`).join("") : "<li>(none)</li>";
1482
+ const screenshotLis = report.screenshots.length ? report.screenshots.map((s) => `<li><code>${escapeHtml(s)}</code></li>`).join("") : "<li>(none)</li>";
1483
+ const visualRows = report.visual.map(
1484
+ (v) => `<tr><td>${escapeHtml(v.name)}</td><td>${v.passed ? "PASS" : "FAIL"}</td><td>${(v.diffRatio * 100).toFixed(3)}%</td><td>${escapeHtml(v.reason ?? "")}</td><td><code>${escapeHtml(v.paths.diff ?? "")}</code></td></tr>`
1485
+ ).join("");
1486
+ const testRows = report.tests?.results.map((t) => {
1487
+ const logs = [
1488
+ t.stdout?.trim() ? `<details><summary>stdout</summary><pre>${escapeHtml(truncateLog(t.stdout))}</pre></details>` : "",
1489
+ t.stderr?.trim() ? `<details><summary>stderr</summary><pre>${escapeHtml(truncateLog(t.stderr))}</pre></details>` : ""
1490
+ ].filter(Boolean).join("");
1491
+ return `<tr><td>${escapeHtml(t.name)}</td><td>${t.status}</td><td>${t.exitCode}</td><td>${t.durationMs}ms</td><td>${logs || "\u2014"}</td></tr>`;
1492
+ }).join("") ?? "";
1493
+ return `<!doctype html>
1494
+ <html lang="en">
1495
+ <head>
1496
+ <meta charset="utf-8" />
1497
+ <title>Frontend Precision Report \u2014 ${escapeHtml(report.runId)}</title>
1498
+ <style>
1499
+ body{font-family:ui-sans-serif,system-ui,sans-serif;margin:2rem;color:#101828;background:#fff}
1500
+ h1{font-size:1.5rem} table{border-collapse:collapse;width:100%;margin:1rem 0}
1501
+ th,td{border:1px solid #e4e7ec;padding:.5rem .75rem;text-align:left;font-size:.875rem;vertical-align:top}
1502
+ th{background:#f9fafb} .badge{display:inline-block;padding:.25rem .6rem;border-radius:999px;color:#fff;background:${statusColor};font-weight:600}
1503
+ code{background:#f2f4f7;padding:.1rem .35rem;border-radius:4px}
1504
+ pre{white-space:pre-wrap;background:#f2f4f7;padding:.75rem;border-radius:6px;max-height:240px;overflow:auto}
1505
+ </style>
1506
+ </head>
1507
+ <body>
1508
+ <h1>Frontend Precision Report</h1>
1509
+ <p>Run: <code>${escapeHtml(report.runId)}</code></p>
1510
+ <p>Status: <span class="badge">${escapeHtml(report.status)}</span></p>
1511
+ <p>Generated: ${escapeHtml(report.generatedAt)}</p>
1512
+ <h2>Failures</h2>
1513
+ ${failureLis ? `<ul>${failureLis}</ul>` : "<p>None</p>"}
1514
+ <h2>Unverified</h2>
1515
+ <ul>${unverifiedLis}</ul>
1516
+ <h2>Screenshots</h2>
1517
+ <ul>${screenshotLis}</ul>
1518
+ <h2>Visual</h2>
1519
+ <table><thead><tr><th>Name</th><th>Status</th><th>Diff ratio</th><th>Reason</th><th>Diff</th></tr></thead><tbody>${visualRows || '<tr><td colspan="5">No visual results</td></tr>'}</tbody></table>
1520
+ <h2>Tests</h2>
1521
+ <table><thead><tr><th>Name</th><th>Status</th><th>Exit</th><th>Duration</th><th>Logs</th></tr></thead><tbody>${testRows || '<tr><td colspan="5">Tests not run</td></tr>'}</tbody></table>
1522
+ <h2>Manual review</h2>
1523
+ <ul>${report.manualReview.map((m) => `<li>${escapeHtml(m)}</li>`).join("")}</ul>
1524
+ </body>
1525
+ </html>`;
1526
+ }
1527
+
1528
+ // src/commands/report.ts
1529
+ function runReport(options) {
1530
+ const cwd = options.cwd ?? process.cwd();
1531
+ const config = loadConfig(cwd);
1532
+ let runId = options.runId;
1533
+ if (!runId) {
1534
+ runId = createRunId();
1535
+ }
1536
+ const runDir = artifactsDir(runId, cwd);
1537
+ if (!existsSync9(runDir)) {
1538
+ return {
1539
+ runId,
1540
+ ok: false,
1541
+ message: `Artifacts not found for run ${runId}. Run capture/visual/test first.`,
1542
+ reportPath: ""
1543
+ };
1544
+ }
1545
+ let codeChangeSummary = options.codeChangeSummary ?? null;
1546
+ const summaryFile = join12(runDir, "code-change-summary.md");
1547
+ if (!codeChangeSummary && existsSync9(summaryFile)) {
1548
+ codeChangeSummary = readFileSync6(summaryFile, "utf8");
1549
+ }
1550
+ const report = buildReport({
1551
+ runId,
1552
+ cwd,
1553
+ taskId: options.taskId ?? config.taskId,
1554
+ codeChangeSummary,
1555
+ fixAttempts: options.fixAttempts,
1556
+ maxFixAttempts: config.maxFixAttempts,
1557
+ blockedReason: options.blockedReason ?? null,
1558
+ gates: config.gates
1559
+ });
1560
+ const reportPath = join12(runDir, "report.json");
1561
+ const message = [
1562
+ `Report: ${report.status}`,
1563
+ ` JSON: ${reportPath}`,
1564
+ ` MD: ${join12(runDir, "report.md")}`,
1565
+ ` HTML: ${join12(runDir, "report.html")}`,
1566
+ report.acceptance.length ? ` Acceptance: ${report.acceptance.length} rows (${report.acceptance.filter((a) => a.status === "PASS").length} PASS / ${report.acceptance.filter((a) => a.status === "PENDING").length} PENDING)` : " Acceptance: (none loaded)",
1567
+ report.failures.length ? `Failures:
1568
+ ${report.failures.map((f) => ` - ${f}`).join("\n")}` : "No failures recorded.",
1569
+ report.unverified.length ? `Unverified:
1570
+ ${report.unverified.map((u) => ` - ${u}`).join("\n")}` : ""
1571
+ ].filter(Boolean).join("\n");
1572
+ return {
1573
+ runId,
1574
+ ok: report.status === "PASS" && report.failures.length === 0,
1575
+ message,
1576
+ reportPath
1577
+ };
1578
+ }
1579
+
1580
+ // src/index.ts
1581
+ var program = new Command();
1582
+ program.name("frontend-precision").description(
1583
+ "Cursor-first frontend precision toolkit (screenshot, visual diff, tests, reports)"
1584
+ ).version("0.1.0");
1585
+ program.command("init").description("Initialize config, Cursor Skill, and rule templates").option("--force", "Overwrite existing files", false).action((opts) => {
1586
+ const result = runInit({ force: opts.force });
1587
+ console.log(result.messages.join("\n"));
1588
+ });
1589
+ program.command("doctor").description("Diagnose environment and project readiness").action(async () => {
1590
+ const { checks, ok } = await runDoctor();
1591
+ console.log(formatDoctor(checks));
1592
+ process.exitCode = ok ? 0 : 1;
1593
+ });
1594
+ program.command("capture").description("Screenshot configured routes with Playwright").option("--run-id <id>", "Reuse or set run id").option("--route <name>", "Only capture named route (repeatable)", (v, acc) => {
1595
+ acc.push(v);
1596
+ return acc;
1597
+ }, []).action(async (opts) => {
1598
+ try {
1599
+ const result = await runCapture({
1600
+ runId: opts.runId,
1601
+ routes: opts.route.length ? opts.route : void 0
1602
+ });
1603
+ console.log(result.message);
1604
+ process.exitCode = result.ok ? 0 : 1;
1605
+ } catch (err) {
1606
+ handleError(err);
1607
+ }
1608
+ });
1609
+ program.command("visual").description("Compare screenshots against reference PNGs").requiredOption("--run-id <id>", "Run id from capture").option("--route <name>", "Only compare named route (repeatable)", (v, acc) => {
1610
+ acc.push(v);
1611
+ return acc;
1612
+ }, []).action(async (opts) => {
1613
+ try {
1614
+ const result = await runVisual({
1615
+ runId: opts.runId,
1616
+ routes: opts.route.length ? opts.route : void 0
1617
+ });
1618
+ console.log(result.message);
1619
+ process.exitCode = result.ok ? 0 : 1;
1620
+ } catch (err) {
1621
+ handleError(err);
1622
+ }
1623
+ });
1624
+ program.command("test").description("Run configured test commands and record results").option("--run-id <id>", "Attach results to an existing run id").action(async (opts) => {
1625
+ try {
1626
+ const result = await runTestCommand({ runId: opts.runId });
1627
+ console.log(result.message);
1628
+ process.exitCode = result.ok ? 0 : 1;
1629
+ } catch (err) {
1630
+ handleError(err);
1631
+ }
1632
+ });
1633
+ program.command("report").description("Aggregate capture/visual/test into JSON/Markdown/HTML reports").requiredOption("--run-id <id>", "Run id to report").option("--task-id <id>", "Task folder under .frontend-precision/tasks/").option(
1634
+ "--fix-attempts <n>",
1635
+ "Current auto-fix attempt count",
1636
+ (v) => Number(v)
1637
+ ).option("--blocked <reason>", "Mark report as BLOCKED with reason").action(
1638
+ (opts) => {
1639
+ try {
1640
+ const result = runReport({
1641
+ runId: opts.runId,
1642
+ taskId: opts.taskId,
1643
+ fixAttempts: opts.fixAttempts,
1644
+ blockedReason: opts.blocked
1645
+ });
1646
+ console.log(result.message);
1647
+ process.exitCode = result.ok ? 0 : 1;
1648
+ } catch (err) {
1649
+ handleError(err);
1650
+ }
1651
+ }
1652
+ );
1653
+ function handleError(err) {
1654
+ if (err instanceof ConfigError) {
1655
+ console.error(err.message);
1656
+ process.exit(1);
1657
+ }
1658
+ console.error(err instanceof Error ? err.message : String(err));
1659
+ process.exit(1);
1660
+ }
1661
+ program.parseAsync(process.argv).catch((err) => {
1662
+ handleError(err);
1663
+ });
1664
+ //# sourceMappingURL=index.js.map