@nick848/fet 1.1.9 → 1.1.11

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/cli/index.js CHANGED
@@ -188,18 +188,18 @@ function toGitNexusState(detection, previous) {
188
188
  };
189
189
  }
190
190
  async function inspectGitNexusGraph(projectRoot, env = process.env) {
191
- const relative5 = env.FET_GITNEXUS_GRAPH_PATH?.trim() || DEFAULT_GRAPH_PATH;
192
- const graphPath = join5(projectRoot, relative5);
191
+ const relative6 = env.FET_GITNEXUS_GRAPH_PATH?.trim() || DEFAULT_GRAPH_PATH;
192
+ const graphPath = join5(projectRoot, relative6);
193
193
  try {
194
194
  const info = await stat2(graphPath);
195
195
  return {
196
- graphPath: relative5,
196
+ graphPath: relative6,
197
197
  graphExists: true,
198
198
  lastIndexedAt: info.mtime.toISOString()
199
199
  };
200
200
  } catch {
201
201
  return {
202
- graphPath: relative5,
202
+ graphPath: relative6,
203
203
  graphExists: false,
204
204
  lastIndexedAt: null
205
205
  };
@@ -2085,6 +2085,17 @@ function renderFetConfig(scan, language = "zh-CN") {
2085
2085
  uiDisplayContract: {
2086
2086
  enabled: true
2087
2087
  },
2088
+ tdd: {
2089
+ enabled: true,
2090
+ mode: "require_before_apply",
2091
+ whenNoTestScript: "block"
2092
+ },
2093
+ visual: {
2094
+ enabled: true,
2095
+ compareMode: "layout-only",
2096
+ requireBeforeVerify: "when_figma",
2097
+ whenNoCapture: "warn"
2098
+ },
2088
2099
  specLanguage: {
2089
2100
  style: "layered_bilingual",
2090
2101
  canonical: "en",
@@ -2250,6 +2261,267 @@ fet verify --done --change ${changeId}
2250
2261
  `;
2251
2262
  }
2252
2263
 
2264
+ // src/tdd/paths.ts
2265
+ function tddFetDirRelative(changeId) {
2266
+ return `openspec/changes/${changeId}/.fet`;
2267
+ }
2268
+ function tddManifestRelativePath(changeId) {
2269
+ return `${tddFetDirRelative(changeId)}/tdd-manifest.yaml`;
2270
+ }
2271
+ function tddSpecRelativePath(changeId) {
2272
+ return `${tddFetDirRelative(changeId)}/tdd-spec.md`;
2273
+ }
2274
+ function tddInstructionsRelativePath(changeId) {
2275
+ return `${tddFetDirRelative(changeId)}/tdd-instructions.md`;
2276
+ }
2277
+ function tddResultsRelativePath(changeId) {
2278
+ return `${tddFetDirRelative(changeId)}/tdd-results.json`;
2279
+ }
2280
+
2281
+ // src/templates/tdd.ts
2282
+ function renderTddInstructions(changeId, manifest, language) {
2283
+ const manifestPath3 = tddManifestRelativePath(changeId);
2284
+ const specPath = tddSpecRelativePath(changeId);
2285
+ const caseList = manifest.cases.map((item) => `- \`${item.id}\`: ${item.title} \u2192 \`${item.testFile}\` (${item.testIds.join(", ")})`).join("\n");
2286
+ if (language === "en") {
2287
+ return `---
2288
+ schemaVersion: 1
2289
+ fetVersion: ${FET_VERSION}
2290
+ changeId: ${changeId}
2291
+ purpose: tdd-instructions
2292
+ generatedAt: ${manifest.generatedAt}
2293
+ ---
2294
+
2295
+ # TDD instructions (this change)
2296
+
2297
+ Create or update unit tests **before** marking implementation tasks done in \`tasks.md\`.
2298
+
2299
+ ## Sources
2300
+ ${manifest.sources.map((s) => `- ${s}`).join("\n")}
2301
+
2302
+ ## Cases (from ${manifestPath3})
2303
+ ${caseList}
2304
+
2305
+ ## Rules
2306
+ 1. Each case must map to a real test file under the repo test tree.
2307
+ 2. Tests should fail until implementation lands (red \u2192 green).
2308
+ 3. Do not edit \`${manifestPath3}\` by hand unless fixing IDs; re-run \`fet tdd --change ${changeId}\` after planning changes.
2309
+ 4. When tests exist, run \`fet test --change ${changeId}\` before \`fet verify\`.
2310
+
2311
+ Human-readable matrix: \`${specPath}\`
2312
+ `;
2313
+ }
2314
+ return `---
2315
+ schemaVersion: 1
2316
+ fetVersion: ${FET_VERSION}
2317
+ changeId: ${changeId}
2318
+ purpose: tdd-instructions
2319
+ generatedAt: ${manifest.generatedAt}
2320
+ ---
2321
+
2322
+ # TDD \u6307\u4EE4\uFF08\u672C change\uFF09
2323
+
2324
+ \u5728\u5C06 \`tasks.md\` \u4E2D\u7684\u4EFB\u52A1\u6807\u4E3A\u5B8C\u6210\u4E4B\u524D\uFF0C\u5148\u521B\u5EFA\u6216\u66F4\u65B0\u5355\u5143\u6D4B\u8BD5\u3002
2325
+
2326
+ ## \u6765\u6E90
2327
+ ${manifest.sources.map((s) => `- ${s}`).join("\n")}
2328
+
2329
+ ## \u7528\u4F8B\uFF08\u89C1 ${manifestPath3}\uFF09
2330
+ ${caseList}
2331
+
2332
+ ## \u89C4\u5219
2333
+ 1. \u6BCF\u4E2A\u7528\u4F8B\u5FC5\u987B\u5BF9\u5E94\u4ED3\u5E93\u5185\u771F\u5B9E\u6D4B\u8BD5\u6587\u4EF6\u3002
2334
+ 2. \u5B9E\u73B0\u843D\u5730\u524D\u6D4B\u8BD5\u5E94\u5904\u4E8E\u5931\u8D25\uFF08\u7EA2\uFF09\u72B6\u6001\uFF0C\u843D\u5730\u540E\u5E94\u53D8\u7EFF\u3002
2335
+ 3. \u9664\u975E\u4FEE\u6B63 ID\uFF0C\u4E0D\u8981\u624B\u6539 \`${manifestPath3}\`\uFF1B\u89C4\u5212\u53D8\u66F4\u540E\u8BF7\u91CD\u65B0\u6267\u884C \`fet tdd --change ${changeId}\`\u3002
2336
+ 4. \u6D4B\u8BD5\u5C31\u7EEA\u540E\u5148 \`fet test --change ${changeId}\`\uFF0C\u518D \`fet verify\`\u3002
2337
+
2338
+ \u53EF\u8BFB\u77E9\u9635\u89C1 \`${specPath}\`
2339
+ `;
2340
+ }
2341
+ function renderTddSpec(changeId, manifest, language) {
2342
+ const rows = manifest.cases.map((item) => renderSpecRow(item, language)).join("\n");
2343
+ if (language === "en") {
2344
+ return `---
2345
+ schemaVersion: 1
2346
+ changeId: ${changeId}
2347
+ generatedAt: ${manifest.generatedAt}
2348
+ planningFingerprint: ${manifest.planningFingerprint}
2349
+ ---
2350
+
2351
+ # TDD case matrix
2352
+
2353
+ | ID | Scenario | Spec reference | Test file | Required |
2354
+ |----|----------|----------------|-----------|----------|
2355
+ ${rows}
2356
+ `;
2357
+ }
2358
+ return `---
2359
+ schemaVersion: 1
2360
+ changeId: ${changeId}
2361
+ generatedAt: ${manifest.generatedAt}
2362
+ planningFingerprint: ${manifest.planningFingerprint}
2363
+ ---
2364
+
2365
+ # TDD \u7528\u4F8B\u77E9\u9635
2366
+
2367
+ | ID | \u573A\u666F | Spec \u5F15\u7528 | \u6D4B\u8BD5\u6587\u4EF6 | \u5FC5\u9700 |
2368
+ |----|------|-----------|----------|------|
2369
+ ${rows}
2370
+ `;
2371
+ }
2372
+ function renderSpecRow(item, language) {
2373
+ const required = language === "en" ? item.required ? "yes" : "no" : item.required ? "\u662F" : "\u5426";
2374
+ return `| ${item.id} | ${escapeTable(item.title)} | ${escapeTable(item.specRef)} | \`${item.testFile}\` | ${required} |`;
2375
+ }
2376
+ function escapeTable(value) {
2377
+ return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
2378
+ }
2379
+ function renderTddApplyNextSteps(changeId, language) {
2380
+ const manifestPath3 = tddManifestRelativePath(changeId);
2381
+ if (language === "en") {
2382
+ return [
2383
+ `Read ${manifestPath3} and tdd-instructions.md; implement code until fet test passes for this change.`,
2384
+ `Run fet test --change ${changeId} before fet verify.`
2385
+ ];
2386
+ }
2387
+ return [
2388
+ `\u9605\u8BFB ${manifestPath3} \u4E0E tdd-instructions.md\uFF1B\u5B9E\u73B0\u4EE3\u7801\u76F4\u81F3\u672C change \u7684 fet test \u901A\u8FC7\u3002`,
2389
+ `\u5728 fet verify \u4E4B\u524D\u5148\u6267\u884C fet test --change ${changeId}\u3002`
2390
+ ];
2391
+ }
2392
+
2393
+ // src/visual/paths.ts
2394
+ function visualFetDirRelative(changeId) {
2395
+ return `openspec/changes/${changeId}/.fet`;
2396
+ }
2397
+ function visualManifestRelativePath(changeId) {
2398
+ return `${visualFetDirRelative(changeId)}/visual-manifest.yaml`;
2399
+ }
2400
+ function visualSpecRelativePath(changeId) {
2401
+ return `${visualFetDirRelative(changeId)}/visual-spec.md`;
2402
+ }
2403
+ function visualInstructionsRelativePath(changeId) {
2404
+ return `${visualFetDirRelative(changeId)}/visual-instructions.md`;
2405
+ }
2406
+ function visualCaptureRelativePath(changeId) {
2407
+ return `${visualFetDirRelative(changeId)}/visual-capture.json`;
2408
+ }
2409
+ function visualResultsRelativePath(changeId) {
2410
+ return `${visualFetDirRelative(changeId)}/visual-results.json`;
2411
+ }
2412
+ function visualBaselinesDirRelative(changeId) {
2413
+ return `${visualFetDirRelative(changeId)}/visual-baselines`;
2414
+ }
2415
+ function visualPageScreenshotRelative(changeId, pageId) {
2416
+ return `${visualBaselinesDirRelative(changeId)}/${pageId}/implementation.png`;
2417
+ }
2418
+
2419
+ // src/templates/visual.ts
2420
+ function renderVisualInstructions(changeId, manifest, language) {
2421
+ const pages = manifest.pages.map(
2422
+ (page) => `- \`${page.id}\`: ${page.title} \u2192 \`${page.route}\` (ignore: ${page.ignoreSelectors.join(", ") || "none"})`
2423
+ ).join("\n");
2424
+ if (language === "en") {
2425
+ return `---
2426
+ schemaVersion: 1
2427
+ fetVersion: ${FET_VERSION}
2428
+ changeId: ${changeId}
2429
+ purpose: visual-instructions
2430
+ generatedAt: ${manifest.generatedAt}
2431
+ compareMode: layout-only
2432
+ ---
2433
+
2434
+ # Visual verification (layout-only)
2435
+
2436
+ Compare **layout / spacing / shell regions** only. Do **not** pixel-match dynamic API text, list rows, or images.
2437
+
2438
+ ## Figma references
2439
+ ${manifest.figmaUrls.map((url) => `- ${url}`).join("\n") || "- (none detected)"}
2440
+
2441
+ ## Pages
2442
+ ${pages}
2443
+
2444
+ ## Default command
2445
+
2446
+ \`\`\`sh
2447
+ fet visual --change ${changeId}
2448
+ \`\`\`
2449
+
2450
+ Runs manifest refresh, capture (Playwright + \`--base-url\`), and layout checks in one step.
2451
+
2452
+ See \`${visualSpecRelativePath(changeId)}\` for the human-readable matrix.
2453
+ `;
2454
+ }
2455
+ return `---
2456
+ schemaVersion: 1
2457
+ fetVersion: ${FET_VERSION}
2458
+ changeId: ${changeId}
2459
+ purpose: visual-instructions
2460
+ generatedAt: ${manifest.generatedAt}
2461
+ compareMode: layout-only
2462
+ ---
2463
+
2464
+ # \u89C6\u89C9\u9A8C\u6536\uFF08\u4EC5 layout-only\uFF09
2465
+
2466
+ \u53EA\u9A8C\u6536 **\u6392\u7248 / \u95F4\u8DDD / \u58F3\u5C42\u533A\u57DF**\uFF0C**\u4E0D\u8981**\u5BF9\u52A8\u6001\u63A5\u53E3\u6587\u6848\u3001\u5217\u8868\u884C\u3001\u56FE\u7247\u505A\u50CF\u7D20\u7EA7\u5BF9\u6BD4\u3002
2467
+
2468
+ ## Figma \u5F15\u7528
2469
+ ${manifest.figmaUrls.map((url) => `- ${url}`).join("\n") || "- \uFF08\u672A\u68C0\u6D4B\u5230\uFF09"}
2470
+
2471
+ ## \u9875\u9762
2472
+ ${pages}
2473
+
2474
+ ## \u9ED8\u8BA4\u547D\u4EE4
2475
+
2476
+ \`\`\`sh
2477
+ fet visual --change ${changeId}
2478
+ \`\`\`
2479
+
2480
+ \u4E00\u6761\u547D\u4EE4\u5B8C\u6210\u6E05\u5355\u66F4\u65B0\u3001\u622A\u56FE\uFF08Playwright + \`--base-url\`\uFF09\u4E0E\u5E03\u5C40\u68C0\u67E5\u3002
2481
+
2482
+ \u8BE6\u89C1 \`${visualSpecRelativePath(changeId)}\`\u3002
2483
+ `;
2484
+ }
2485
+ function renderVisualSpec(changeId, manifest, language) {
2486
+ const rows = manifest.pages.flatMap(
2487
+ (page) => page.checkRegions.map(
2488
+ (region) => `| ${page.id} | ${page.route} | \`${region.selector}\` | ${region.checks.join(", ")} | ${page.ignoreSelectors.join(", ") || "-"} |`
2489
+ )
2490
+ ).join("\n");
2491
+ if (language === "en") {
2492
+ return `---
2493
+ changeId: ${changeId}
2494
+ compareMode: layout-only
2495
+ planningFingerprint: ${manifest.planningFingerprint}
2496
+ ---
2497
+
2498
+ # Visual case matrix (layout-only)
2499
+
2500
+ | Page | Route | Region | Checks | Ignored dynamic |
2501
+ |------|-------|--------|--------|-----------------|
2502
+ ${rows}
2503
+ `;
2504
+ }
2505
+ return `---
2506
+ changeId: ${changeId}
2507
+ compareMode: layout-only
2508
+ planningFingerprint: ${manifest.planningFingerprint}
2509
+ ---
2510
+
2511
+ # \u89C6\u89C9\u7528\u4F8B\u77E9\u9635\uFF08\u4EC5 layout-only\uFF09
2512
+
2513
+ | \u9875\u9762 | \u8DEF\u7531 | \u533A\u57DF | \u68C0\u67E5\u9879 | \u5FFD\u7565\u7684\u52A8\u6001\u533A |
2514
+ |------|------|------|--------|--------------|
2515
+ ${rows}
2516
+ `;
2517
+ }
2518
+ function renderVisualVerifyNextSteps(changeId, language) {
2519
+ if (language === "en") {
2520
+ return [`Run fet visual --change ${changeId} before fet verify when this change references Figma.`];
2521
+ }
2522
+ return [`\u82E5\u672C change \u5F15\u7528 Figma\uFF0C\u8BF7\u5728 fet verify \u4E4B\u524D\u6267\u884C fet visual --change ${changeId}\u3002`];
2523
+ }
2524
+
2253
2525
  // src/templates/figma-guard.ts
2254
2526
  var FIGMA_URL_PATTERN = /https?:\/\/(?:www\.)?figma\.com\/(?:file|design|proto)\/[^\s)\]"'<>]+/gi;
2255
2527
  function figmaStopHandoffRelativePath(changeId) {
@@ -2889,8 +3161,8 @@ async function exists4(path) {
2889
3161
  }
2890
3162
 
2891
3163
  // src/commands/proxy.ts
2892
- import { readFile as readFile14 } from "fs/promises";
2893
- import { join as join19 } from "path";
3164
+ import { readFile as readFile17 } from "fs/promises";
3165
+ import { join as join22 } from "path";
2894
3166
 
2895
3167
  // src/figma-guard.ts
2896
3168
  import { readdir as readdir3, readFile as readFile10, stat as stat7 } from "fs/promises";
@@ -3339,6 +3611,259 @@ async function readOptional4(path) {
3339
3611
  }
3340
3612
  }
3341
3613
 
3614
+ // src/commands/change-id.ts
3615
+ function toKebabId(value) {
3616
+ return value.trim().toLowerCase().replace(/['"]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3617
+ }
3618
+ function msg(language, zh, en) {
3619
+ return language === "en" ? en : zh;
3620
+ }
3621
+ async function resolveChangeId(ctx) {
3622
+ if (ctx.changeId) {
3623
+ return ctx.changeId;
3624
+ }
3625
+ const global = await ctx.stateStore.getOrCreateGlobal();
3626
+ if (global.activeChangeId) {
3627
+ return global.activeChangeId;
3628
+ }
3629
+ const inspection = await ctx.openSpec.inspectProject(ctx.projectRoot);
3630
+ if (inspection.changes.length === 1 && inspection.changes[0]) {
3631
+ return inspection.changes[0];
3632
+ }
3633
+ throw new FetError({
3634
+ code: "INVALID_ARGUMENTS" /* InvalidArguments */,
3635
+ message: msg(ctx.language, "\u65E0\u6CD5\u786E\u5B9A\u76EE\u6807 change", "Cannot determine which change to use"),
3636
+ details: { openChangeIds: inspection.changes },
3637
+ suggestedCommand: "fet <command> --change <change-id>"
3638
+ });
3639
+ }
3640
+ async function assertChangeExists(ctx, changeId) {
3641
+ const inspection = await ctx.openSpec.inspectChange(ctx.projectRoot, changeId);
3642
+ if (!inspection.exists) {
3643
+ throw new FetError({
3644
+ code: "INVALID_ARGUMENTS" /* InvalidArguments */,
3645
+ message: msg(ctx.language, "\u6307\u5B9A\u7684 change \u4E0D\u5B58\u5728", "The specified change does not exist"),
3646
+ details: { changeId },
3647
+ suggestedCommand: `fet doctor`
3648
+ });
3649
+ }
3650
+ }
3651
+
3652
+ // src/tdd/config.ts
3653
+ import { readFile as readFile12 } from "fs/promises";
3654
+ import { join as join18 } from "path";
3655
+ import { parseDocument as parseDocument4 } from "yaml";
3656
+ var DEFAULT_CONFIG3 = {
3657
+ enabled: true,
3658
+ mode: "require_before_apply",
3659
+ whenNoTestScript: "block"
3660
+ };
3661
+ async function loadTddConfig(projectRoot) {
3662
+ try {
3663
+ const raw = await readFile12(join18(projectRoot, "openspec", "config.yaml"), "utf8");
3664
+ const doc = parseDocument4(raw);
3665
+ const fetNode = doc.get("fet", true);
3666
+ const node = fetNode?.get?.("tdd");
3667
+ if (!node || typeof node.get !== "function") {
3668
+ return DEFAULT_CONFIG3;
3669
+ }
3670
+ const enabled = node.get("enabled");
3671
+ const modeRaw = node.get("mode");
3672
+ const whenNoTestScriptRaw = node.get("whenNoTestScript");
3673
+ return {
3674
+ enabled: enabled === void 0 ? true : Boolean(enabled),
3675
+ mode: parseGateMode(modeRaw),
3676
+ whenNoTestScript: parseWhenNoTestScript(whenNoTestScriptRaw)
3677
+ };
3678
+ } catch {
3679
+ return DEFAULT_CONFIG3;
3680
+ }
3681
+ }
3682
+ function parseGateMode(value) {
3683
+ if (value === "off" || value === "optional" || value === "require_before_apply") {
3684
+ return value;
3685
+ }
3686
+ return DEFAULT_CONFIG3.mode;
3687
+ }
3688
+ function parseWhenNoTestScript(value) {
3689
+ if (value === "warn" || value === "skip") {
3690
+ return value;
3691
+ }
3692
+ return DEFAULT_CONFIG3.whenNoTestScript;
3693
+ }
3694
+ function isTddRequired(config) {
3695
+ return config.enabled && config.mode === "require_before_apply";
3696
+ }
3697
+
3698
+ // src/tdd/fingerprint.ts
3699
+ import { createHash } from "crypto";
3700
+ import { readdir as readdir5, readFile as readFile13, stat as stat9 } from "fs/promises";
3701
+ import { join as join19, relative as relative4 } from "path";
3702
+ async function collectPlanningSources(projectRoot, changeId) {
3703
+ const changeRoot = join19(projectRoot, "openspec", "changes", changeId);
3704
+ const sources = [];
3705
+ const rootFiles = ["proposal.md", "tasks.md", "design.md"];
3706
+ for (const name of rootFiles) {
3707
+ const path = join19(changeRoot, name);
3708
+ if (await exists5(path)) {
3709
+ sources.push(relative4(projectRoot, path).replace(/\\/g, "/"));
3710
+ }
3711
+ }
3712
+ const specsDir = join19(changeRoot, "specs");
3713
+ if (await exists5(specsDir)) {
3714
+ for (const file of await walkFiles(specsDir)) {
3715
+ if (file.endsWith(".md")) {
3716
+ sources.push(relative4(projectRoot, file).replace(/\\/g, "/"));
3717
+ }
3718
+ }
3719
+ }
3720
+ return sources.sort();
3721
+ }
3722
+ async function computePlanningFingerprint(projectRoot, changeId) {
3723
+ const sources = await collectPlanningSources(projectRoot, changeId);
3724
+ const hash = createHash("sha256");
3725
+ for (const source of sources) {
3726
+ const content = await readFile13(join19(projectRoot, source), "utf8");
3727
+ hash.update(source);
3728
+ hash.update("\0");
3729
+ hash.update(content);
3730
+ hash.update("\0");
3731
+ }
3732
+ return `sha256:${hash.digest("hex")}`;
3733
+ }
3734
+ async function walkFiles(dir) {
3735
+ const entries = await readdir5(dir, { withFileTypes: true });
3736
+ const files = [];
3737
+ for (const entry of entries) {
3738
+ const path = join19(dir, entry.name);
3739
+ if (entry.isDirectory()) {
3740
+ files.push(...await walkFiles(path));
3741
+ } else if (entry.isFile()) {
3742
+ files.push(path);
3743
+ }
3744
+ }
3745
+ return files;
3746
+ }
3747
+ async function exists5(path) {
3748
+ try {
3749
+ await stat9(path);
3750
+ return true;
3751
+ } catch {
3752
+ return false;
3753
+ }
3754
+ }
3755
+
3756
+ // src/tdd/manifest.ts
3757
+ import { mkdir as mkdir6, readFile as readFile14, stat as stat10 } from "fs/promises";
3758
+ import { dirname as dirname8, join as join20 } from "path";
3759
+ import { parse as parse4, stringify as stringify3 } from "yaml";
3760
+ function tddManifestPath(projectRoot, changeId) {
3761
+ return join20(projectRoot, tddManifestRelativePath(changeId));
3762
+ }
3763
+ async function readTddManifest(projectRoot, changeId) {
3764
+ const path = tddManifestPath(projectRoot, changeId);
3765
+ try {
3766
+ await stat10(path);
3767
+ } catch {
3768
+ return null;
3769
+ }
3770
+ const doc = parse4(await readFile14(path, "utf8"));
3771
+ if (!doc || doc.schemaVersion !== 1 || doc.changeId !== changeId) {
3772
+ return null;
3773
+ }
3774
+ return doc;
3775
+ }
3776
+ async function writeTddManifest(projectRoot, manifest) {
3777
+ const relative6 = tddManifestRelativePath(manifest.changeId);
3778
+ const path = join20(projectRoot, relative6);
3779
+ await mkdir6(dirname8(path), { recursive: true });
3780
+ await atomicWrite(path, stringify3(manifest));
3781
+ return relative6;
3782
+ }
3783
+ async function writeTddResults(projectRoot, results) {
3784
+ const relative6 = tddResultsRelativePath(results.changeId);
3785
+ const path = join20(projectRoot, relative6);
3786
+ await mkdir6(dirname8(path), { recursive: true });
3787
+ await atomicWrite(path, `${JSON.stringify(results, null, 2)}
3788
+ `);
3789
+ return relative6;
3790
+ }
3791
+ function createTddManifest(input) {
3792
+ return {
3793
+ schemaVersion: 1,
3794
+ changeId: input.changeId,
3795
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3796
+ fetVersion: FET_VERSION,
3797
+ planningFingerprint: input.planningFingerprint,
3798
+ sources: input.sources,
3799
+ cases: input.cases,
3800
+ run: {
3801
+ mode: input.cases.length ? "manifest" : "workspace",
3802
+ fallbackCommand: input.testCommand
3803
+ }
3804
+ };
3805
+ }
3806
+
3807
+ // src/tdd/gates.ts
3808
+ async function assertTddReady(ctx, changeId) {
3809
+ const config = await loadTddConfig(ctx.projectRoot);
3810
+ if (!isTddRequired(config)) {
3811
+ return;
3812
+ }
3813
+ const manifest = await readTddManifest(ctx.projectRoot, changeId);
3814
+ if (!manifest) {
3815
+ throw new FetError({
3816
+ code: "STATE_CORRUPTED" /* StateCorrupted */,
3817
+ message: msg(ctx.language, "\u7F3A\u5C11 TDD \u4EA7\u7269\uFF0C\u65E0\u6CD5\u8FDB\u5165 apply\u3002", "TDD artifacts are missing; cannot run apply."),
3818
+ details: { changeId, expected: tddManifestRelativePath(changeId) },
3819
+ suggestedCommand: `fet tdd --change ${changeId}`,
3820
+ recoverable: true
3821
+ });
3822
+ }
3823
+ const fingerprint2 = await computePlanningFingerprint(ctx.projectRoot, changeId);
3824
+ if (manifest.planningFingerprint !== fingerprint2) {
3825
+ throw new FetError({
3826
+ code: "STATE_CORRUPTED" /* StateCorrupted */,
3827
+ message: msg(
3828
+ ctx.language,
3829
+ "\u89C4\u5212\u4EA7\u7269\u5DF2\u53D8\u66F4\uFF0CTDD \u6E05\u5355\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u3002",
3830
+ "Planning artifacts changed; TDD manifest is stale. Regenerate it."
3831
+ ),
3832
+ details: { changeId, manifestFingerprint: manifest.planningFingerprint, currentFingerprint: fingerprint2 },
3833
+ suggestedCommand: `fet tdd --change ${changeId}`,
3834
+ recoverable: true
3835
+ });
3836
+ }
3837
+ }
3838
+ async function assertTestPassed(ctx, changeId) {
3839
+ const config = await loadTddConfig(ctx.projectRoot);
3840
+ if (!config.enabled || config.mode === "off") {
3841
+ return;
3842
+ }
3843
+ const change = await ctx.stateStore.readChange(changeId);
3844
+ const testRun = change?.testRun;
3845
+ if (testRun?.status === "skipped") {
3846
+ return;
3847
+ }
3848
+ if (testRun?.status === "passed" && await fingerprintMatches(ctx, changeId, testRun)) {
3849
+ return;
3850
+ }
3851
+ throw new FetError({
3852
+ code: "STATE_CORRUPTED" /* StateCorrupted */,
3853
+ message: msg(ctx.language, "\u672C change \u5C1A\u672A\u901A\u8FC7 fet test\u3002", "This change has not passed fet test yet."),
3854
+ details: { changeId, testRun: testRun ?? null },
3855
+ suggestedCommand: `fet test --change ${changeId}`,
3856
+ recoverable: true
3857
+ });
3858
+ }
3859
+ async function fingerprintMatches(ctx, changeId, testRun) {
3860
+ const current = await computePlanningFingerprint(ctx.projectRoot, changeId);
3861
+ return testRun.planningFingerprint === current;
3862
+ }
3863
+ function invalidateTestRun(state) {
3864
+ state.testRun = null;
3865
+ }
3866
+
3342
3867
  // src/state/project.ts
3343
3868
  import { execFile as execFile2 } from "child_process";
3344
3869
  import { promisify as promisify2 } from "util";
@@ -3366,8 +3891,8 @@ async function git(cwd, args) {
3366
3891
  }
3367
3892
 
3368
3893
  // src/state/store.ts
3369
- import { mkdir as mkdir6, readFile as readFile12 } from "fs/promises";
3370
- import { join as join18 } from "path";
3894
+ import { mkdir as mkdir7, readFile as readFile15 } from "fs/promises";
3895
+ import { join as join21 } from "path";
3371
3896
 
3372
3897
  // src/language.ts
3373
3898
  var DEFAULT_LANGUAGE = "zh-CN";
@@ -3429,6 +3954,10 @@ function createChangeState(fetVersion, changeId, phase) {
3429
3954
  lastSyncedAt: null
3430
3955
  },
3431
3956
  manualVerify: null,
3957
+ tdd: null,
3958
+ testRun: null,
3959
+ visual: null,
3960
+ visualRun: null,
3432
3961
  lastOpenSpecCommand: null,
3433
3962
  warnings: []
3434
3963
  };
@@ -3485,7 +4014,7 @@ var StateStore = class {
3485
4014
  project;
3486
4015
  async readGlobal() {
3487
4016
  try {
3488
- const value = JSON.parse(await readFile12(this.globalPath(), "utf8"));
4017
+ const value = JSON.parse(await readFile15(this.globalPath(), "utf8"));
3489
4018
  assertGlobalState(value);
3490
4019
  return value;
3491
4020
  } catch (error) {
@@ -3500,13 +4029,13 @@ var StateStore = class {
3500
4029
  }
3501
4030
  async writeGlobal(state) {
3502
4031
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3503
- await mkdir6(join18(this.projectRoot, "openspec"), { recursive: true });
4032
+ await mkdir7(join21(this.projectRoot, "openspec"), { recursive: true });
3504
4033
  await atomicWrite(this.globalPath(), `${JSON.stringify(state, null, 2)}
3505
4034
  `);
3506
4035
  }
3507
4036
  async readChange(changeId) {
3508
4037
  try {
3509
- const value = JSON.parse(await readFile12(this.changePath(changeId), "utf8"));
4038
+ const value = JSON.parse(await readFile15(this.changePath(changeId), "utf8"));
3510
4039
  assertChangeState(value);
3511
4040
  return value;
3512
4041
  } catch (error) {
@@ -3521,15 +4050,15 @@ var StateStore = class {
3521
4050
  }
3522
4051
  async writeChange(state) {
3523
4052
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3524
- await mkdir6(join18(this.projectRoot, "openspec", "changes", state.changeId), { recursive: true });
4053
+ await mkdir7(join21(this.projectRoot, "openspec", "changes", state.changeId), { recursive: true });
3525
4054
  await atomicWrite(this.changePath(state.changeId), `${JSON.stringify(state, null, 2)}
3526
4055
  `);
3527
4056
  }
3528
4057
  globalPath() {
3529
- return join18(this.projectRoot, "openspec", "fet-state.json");
4058
+ return join21(this.projectRoot, "openspec", "fet-state.json");
3530
4059
  }
3531
4060
  changePath(changeId) {
3532
- return join18(this.projectRoot, "openspec", "changes", changeId, "fet-state.json");
4061
+ return join21(this.projectRoot, "openspec", "changes", changeId, "fet-state.json");
3533
4062
  }
3534
4063
  };
3535
4064
  function isNotFound(error) {
@@ -3537,11 +4066,11 @@ function isNotFound(error) {
3537
4066
  }
3538
4067
 
3539
4068
  // src/state/tasks.ts
3540
- import { readFile as readFile13 } from "fs/promises";
4069
+ import { readFile as readFile16 } from "fs/promises";
3541
4070
  async function readCompletedTaskIds(tasksPath) {
3542
4071
  let content;
3543
4072
  try {
3544
- content = await readFile13(tasksPath, "utf8");
4073
+ content = await readFile16(tasksPath, "utf8");
3545
4074
  } catch {
3546
4075
  return [];
3547
4076
  }
@@ -3675,6 +4204,7 @@ async function applyWorkflowCommand(ctx, args) {
3675
4204
  await withProjectLock(ctx.projectRoot, { command: "apply", cwd: ctx.cwd, fetVersion: ctx.fetVersion }, async () => {
3676
4205
  await assertOpenSpecCommandSupported(ctx, "status", "apply");
3677
4206
  await assertOpenSpecCommandSupported(ctx, "instructions", "apply");
4207
+ await assertTddReady(ctx, changeId);
3678
4208
  runState.graphContext = await buildWorkflowGraphContext(ctx, {
3679
4209
  command: "apply",
3680
4210
  args: ["tasks", "--change", changeId],
@@ -3706,6 +4236,7 @@ async function applyWorkflowCommand(ctx, args) {
3706
4236
  const applyNextSteps = [
3707
4237
  `Read openspec/changes/${changeId}/tasks.md and the instructions output.`,
3708
4238
  "Implement pending tasks and update task checkboxes only after the work is done.",
4239
+ ...renderTddApplyNextSteps(changeId, ctx.language),
3709
4240
  `Run fet verify --change ${changeId}`
3710
4241
  ];
3711
4242
  if (uiContract) {
@@ -3713,6 +4244,7 @@ async function applyWorkflowCommand(ctx, args) {
3713
4244
  }
3714
4245
  if (figmaGuard) {
3715
4246
  applyNextSteps.unshift(...renderFigmaApplyNextSteps(changeId, ctx.language, figmaGuard.mode));
4247
+ applyNextSteps.splice(applyNextSteps.length - 1, 0, ...renderVisualVerifyNextSteps(changeId, ctx.language));
3716
4248
  }
3717
4249
  ctx.output.result({
3718
4250
  ok: true,
@@ -3850,7 +4382,7 @@ async function onboardWorkflowCommand(ctx) {
3850
4382
  summary: "fet onboard loaded local FET/OpenSpec workflow context.",
3851
4383
  nextSteps: [
3852
4384
  inspection.changes.length ? `Open changes: ${inspection.changes.join(", ")}` : "No open changes found. Run fet propose <change-id-or-description> to start one.",
3853
- "Use fet continue to prepare planning artifacts, fet apply for implementation instructions, fet verify before archive."
4385
+ "Use fet continue to prepare planning artifacts, fet tdd then fet apply for implementation, fet test then fet verify before archive."
3854
4386
  ],
3855
4387
  data: { activeChangeId: state.activeChangeId, openChangeIds: inspection.changes, archivedChangeIds: inspection.archived }
3856
4388
  });
@@ -3895,7 +4427,7 @@ async function artifactWorkflowCommand(ctx, command, args) {
3895
4427
  `Create or update openspec/changes/${changeId}/${resolveOutputPath(status, artifactId)}`,
3896
4428
  "Review the artifact with the user before generating the next planning file.",
3897
4429
  `Run fet passthrough status --change ${changeId}`,
3898
- status.isComplete ? `Run fet apply --change ${changeId}` : `Run fet continue --change ${changeId} when ready for the next artifact`
4430
+ status.isComplete ? `Run fet tdd --change ${changeId}, then fet apply --change ${changeId}` : `Run fet continue --change ${changeId} when ready for the next artifact`
3899
4431
  ];
3900
4432
  if (uiContract) {
3901
4433
  planningNextSteps.unshift(
@@ -3937,7 +4469,7 @@ async function ensureProposedChange(ctx, args) {
3937
4469
  });
3938
4470
  }
3939
4471
  const input = args.join(" ").trim();
3940
- const changeId = isKebabId(input) ? input : toKebabId(input);
4472
+ const changeId = isKebabId(input) ? input : toKebabId2(input);
3941
4473
  if (!changeId) {
3942
4474
  throw new FetError({
3943
4475
  code: "INVALID_ARGUMENTS" /* InvalidArguments */,
@@ -4087,7 +4619,7 @@ function resolveOutputPath(status, artifactId) {
4087
4619
  function isKebabId(value) {
4088
4620
  return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
4089
4621
  }
4090
- function toKebabId(value) {
4622
+ function toKebabId2(value) {
4091
4623
  return value.trim().toLowerCase().replace(/['"]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
4092
4624
  }
4093
4625
  function parseOpenSpecJson(stdout) {
@@ -4124,7 +4656,7 @@ async function createChangelogEntry(projectRoot, changeId) {
4124
4656
  };
4125
4657
  }
4126
4658
  async function appendChangelog(projectRoot, entry) {
4127
- const changelogPath = join19(projectRoot, "CHANGELOG.md");
4659
+ const changelogPath = join22(projectRoot, "CHANGELOG.md");
4128
4660
  const existing = await readOptional5(changelogPath);
4129
4661
  const legacyContentLabel = "\u66F4\u65B0\u5185\u5BB9";
4130
4662
  const block = `updateTime: ${entry.updateTime}
@@ -4137,12 +4669,12 @@ ${block}` : block;
4137
4669
  await atomicWrite(changelogPath, next);
4138
4670
  }
4139
4671
  async function readChangeRequirement(projectRoot, changeId) {
4140
- const changeRoot = join19(projectRoot, "openspec", "changes", changeId);
4141
- const proposal = await readOptional5(join19(changeRoot, "proposal.md"));
4672
+ const changeRoot = join22(projectRoot, "openspec", "changes", changeId);
4673
+ const proposal = await readOptional5(join22(changeRoot, "proposal.md"));
4142
4674
  if (proposal) {
4143
4675
  return summarizeMarkdown(proposal);
4144
4676
  }
4145
- const readme = await readOptional5(join19(changeRoot, "README.md"));
4677
+ const readme = await readOptional5(join22(changeRoot, "README.md"));
4146
4678
  if (readme) {
4147
4679
  return summarizeMarkdown(readme);
4148
4680
  }
@@ -4154,7 +4686,7 @@ function summarizeMarkdown(content) {
4154
4686
  }
4155
4687
  async function readOptional5(path) {
4156
4688
  try {
4157
- return await readFile14(path, "utf8");
4689
+ return await readFile17(path, "utf8");
4158
4690
  } catch {
4159
4691
  return null;
4160
4692
  }
@@ -4406,115 +4938,1168 @@ function runNpm(command, args, options) {
4406
4938
  stdout: stdout.length ? Buffer.concat(stdout).toString("utf8") : void 0,
4407
4939
  stderr: stderr.length ? Buffer.concat(stderr).toString("utf8") : void 0
4408
4940
  });
4409
- });
4410
- });
4411
- }
4412
- function compareVersions(left, right) {
4413
- const leftVersion = parseVersion(left);
4414
- const rightVersion = parseVersion(right);
4415
- for (let index = 0; index < 3; index += 1) {
4416
- const diff = leftVersion.main[index] - rightVersion.main[index];
4417
- if (diff !== 0) {
4418
- return Math.sign(diff);
4941
+ });
4942
+ });
4943
+ }
4944
+ function compareVersions(left, right) {
4945
+ const leftVersion = parseVersion(left);
4946
+ const rightVersion = parseVersion(right);
4947
+ for (let index = 0; index < 3; index += 1) {
4948
+ const diff = leftVersion.main[index] - rightVersion.main[index];
4949
+ if (diff !== 0) {
4950
+ return Math.sign(diff);
4951
+ }
4952
+ }
4953
+ if (!leftVersion.prerelease.length && rightVersion.prerelease.length) {
4954
+ return 1;
4955
+ }
4956
+ if (leftVersion.prerelease.length && !rightVersion.prerelease.length) {
4957
+ return -1;
4958
+ }
4959
+ const length = Math.max(leftVersion.prerelease.length, rightVersion.prerelease.length);
4960
+ for (let index = 0; index < length; index += 1) {
4961
+ const leftPart = leftVersion.prerelease[index];
4962
+ const rightPart = rightVersion.prerelease[index];
4963
+ if (leftPart === void 0) {
4964
+ return -1;
4965
+ }
4966
+ if (rightPart === void 0) {
4967
+ return 1;
4968
+ }
4969
+ const diff = comparePrereleasePart(leftPart, rightPart);
4970
+ if (diff !== 0) {
4971
+ return diff;
4972
+ }
4973
+ }
4974
+ return 0;
4975
+ }
4976
+ function parseVersion(version) {
4977
+ const [withoutBuild] = version.trim().replace(/^v/i, "").split("+");
4978
+ const [mainValue = "", prereleaseValue = ""] = withoutBuild.split("-");
4979
+ const mainParts = mainValue.split(".").map((part) => Number.parseInt(part, 10));
4980
+ return {
4981
+ main: [
4982
+ Number.isFinite(mainParts[0]) ? mainParts[0] : 0,
4983
+ Number.isFinite(mainParts[1]) ? mainParts[1] : 0,
4984
+ Number.isFinite(mainParts[2]) ? mainParts[2] : 0
4985
+ ],
4986
+ prerelease: prereleaseValue ? prereleaseValue.split(".") : []
4987
+ };
4988
+ }
4989
+ function comparePrereleasePart(left, right) {
4990
+ const leftNumber = /^\d+$/.test(left) ? Number.parseInt(left, 10) : null;
4991
+ const rightNumber = /^\d+$/.test(right) ? Number.parseInt(right, 10) : null;
4992
+ if (leftNumber !== null && rightNumber !== null) {
4993
+ return Math.sign(leftNumber - rightNumber);
4994
+ }
4995
+ if (leftNumber !== null) {
4996
+ return -1;
4997
+ }
4998
+ if (rightNumber !== null) {
4999
+ return 1;
5000
+ }
5001
+ return left.localeCompare(right);
5002
+ }
5003
+
5004
+ // src/commands/update.ts
5005
+ async function updateCommand(ctx) {
5006
+ const packageName = getFetPackageName();
5007
+ const latestVersion = await resolveLatestFetVersion(packageName);
5008
+ const currentVersion = ctx.fetVersion;
5009
+ if (compareVersions(currentVersion, latestVersion) >= 0) {
5010
+ ctx.output.result({
5011
+ ok: true,
5012
+ command: "update",
5013
+ summary: ctx.language === "en" ? `FET is already up to date (${currentVersion}).` : `FET \u5DF2\u662F\u6700\u65B0\u7248 (${currentVersion})\u3002`,
5014
+ data: {
5015
+ packageName,
5016
+ currentVersion,
5017
+ latestVersion,
5018
+ updated: false
5019
+ }
5020
+ });
5021
+ return;
5022
+ }
5023
+ const install = await performFetUpdate(currentVersion, latestVersion, {
5024
+ cwd: ctx.cwd,
5025
+ json: ctx.json,
5026
+ language: ctx.language,
5027
+ info: (message) => ctx.output.info(message)
5028
+ });
5029
+ ctx.output.result({
5030
+ ok: true,
5031
+ command: "update",
5032
+ summary: ctx.language === "en" ? `FET updated from ${currentVersion} to ${latestVersion}.` : `FET \u5DF2\u4ECE ${currentVersion} \u5347\u7EA7\u5230 ${latestVersion}\u3002`,
5033
+ data: {
5034
+ packageName,
5035
+ currentVersion,
5036
+ latestVersion,
5037
+ updated: true,
5038
+ installCommand: install.installCommand
5039
+ }
5040
+ });
5041
+ }
5042
+
5043
+ // src/commands/tdd.ts
5044
+ import { mkdir as mkdir8 } from "fs/promises";
5045
+ import { join as join24 } from "path";
5046
+
5047
+ // src/tdd/extract-cases.ts
5048
+ import { readFile as readFile18 } from "fs/promises";
5049
+ import { join as join23 } from "path";
5050
+ async function extractCasesFromChange(projectRoot, changeId, sources) {
5051
+ const cases = [];
5052
+ const seen = /* @__PURE__ */ new Set();
5053
+ for (const source of sources) {
5054
+ const content = await readFile18(join23(projectRoot, source), "utf8");
5055
+ if (source.endsWith("tasks.md")) {
5056
+ for (const item of extractTaskCases(content, changeId)) {
5057
+ if (!seen.has(item.id)) {
5058
+ seen.add(item.id);
5059
+ cases.push({ ...item, specRef: `${source} \u2014 ${item.specRef}` });
5060
+ }
5061
+ }
5062
+ }
5063
+ if (source.includes("/specs/") && source.endsWith(".md")) {
5064
+ for (const item of extractScenarioCases(content, changeId, source)) {
5065
+ if (!seen.has(item.id)) {
5066
+ seen.add(item.id);
5067
+ cases.push(item);
5068
+ }
5069
+ }
5070
+ }
5071
+ }
5072
+ if (!cases.length) {
5073
+ cases.push({
5074
+ id: `${changeId}-smoke`,
5075
+ title: "Change smoke test",
5076
+ specRef: sources[0] ?? `openspec/changes/${changeId}/`,
5077
+ testFile: `tests/changes/${changeId}.test.ts`,
5078
+ testIds: [`${changeId}-smoke`],
5079
+ required: true
5080
+ });
5081
+ }
5082
+ return cases;
5083
+ }
5084
+ function extractTaskCases(content, changeId) {
5085
+ const cases = [];
5086
+ const lines = content.split(/\r?\n/);
5087
+ for (const line of lines) {
5088
+ const numbered = line.match(/^\s*[-*]\s+\[[ xX]?\]\s+((?:\d+(?:\.\d+)*\.?)?)\s*(.+)$/);
5089
+ if (numbered) {
5090
+ const taskId = numbered[1] || String(cases.length + 1);
5091
+ const title = numbered[2]?.trim() ?? taskId;
5092
+ const id = toKebabId(`${changeId}-task-${taskId}`) || `${changeId}-task-${cases.length + 1}`;
5093
+ cases.push({
5094
+ id,
5095
+ title,
5096
+ specRef: title,
5097
+ testFile: suggestTestFile(changeId, id),
5098
+ testIds: [id],
5099
+ required: true
5100
+ });
5101
+ continue;
5102
+ }
5103
+ const plain = line.match(/^\s*[-*]\s+\[[ xX]?\]\s+(.+)$/);
5104
+ if (plain?.[1]) {
5105
+ const title = plain[1].trim();
5106
+ const id = toKebabId(`${changeId}-${title}`) || `${changeId}-task-${cases.length + 1}`;
5107
+ cases.push({
5108
+ id,
5109
+ title,
5110
+ specRef: title,
5111
+ testFile: suggestTestFile(changeId, id),
5112
+ testIds: [id],
5113
+ required: true
5114
+ });
5115
+ }
5116
+ }
5117
+ return cases;
5118
+ }
5119
+ function extractScenarioCases(content, changeId, source) {
5120
+ const cases = [];
5121
+ const lines = content.split(/\r?\n/);
5122
+ let currentRequirement = "";
5123
+ for (const line of lines) {
5124
+ const req = line.match(/^#{2,4}\s+Requirement:\s*(.+)$/i);
5125
+ if (req?.[1]) {
5126
+ currentRequirement = req[1].trim();
5127
+ }
5128
+ const scenario = line.match(/^#{2,4}\s+Scenario:\s*(.+)$/i);
5129
+ if (scenario?.[1]) {
5130
+ const title = scenario[1].trim();
5131
+ const id = toKebabId(`${changeId}-${title}`) || `${changeId}-scenario-${cases.length + 1}`;
5132
+ cases.push({
5133
+ id,
5134
+ title,
5135
+ specRef: currentRequirement ? `Requirement: ${currentRequirement} \u2014 Scenario: ${title}` : `Scenario: ${title}`,
5136
+ testFile: suggestTestFile(changeId, id),
5137
+ testIds: [id],
5138
+ required: true
5139
+ });
5140
+ }
5141
+ }
5142
+ if (!cases.length && content.trim()) {
5143
+ const id = `${changeId}-spec-smoke`;
5144
+ cases.push({
5145
+ id,
5146
+ title: `Spec coverage for ${source}`,
5147
+ specRef: source,
5148
+ testFile: suggestTestFile(changeId, id),
5149
+ testIds: [id],
5150
+ required: true
5151
+ });
5152
+ }
5153
+ return cases;
5154
+ }
5155
+ function suggestTestFile(changeId, caseId) {
5156
+ const segment = caseId.replace(new RegExp(`^${changeId}-?`), "") || "suite";
5157
+ return `tests/changes/${changeId}/${segment}.test.ts`;
5158
+ }
5159
+
5160
+ // src/commands/tdd.ts
5161
+ async function tddCommand(ctx) {
5162
+ await withProjectLock(ctx.projectRoot, { command: "tdd", cwd: ctx.cwd, fetVersion: ctx.fetVersion }, async () => {
5163
+ const changeId = await resolveChangeId(ctx);
5164
+ await assertChangeExists(ctx, changeId);
5165
+ const config = await loadTddConfig(ctx.projectRoot);
5166
+ const sources = await collectPlanningSources(ctx.projectRoot, changeId);
5167
+ if (!sources.length) {
5168
+ throw new FetError({
5169
+ code: "INVALID_ARGUMENTS" /* InvalidArguments */,
5170
+ message: msg(ctx.language, "\u672A\u627E\u5230\u53EF\u7528\u4E8E\u751F\u6210 TDD \u7684\u89C4\u5212\u4EA7\u7269\u3002", "No planning artifacts found for TDD generation."),
5171
+ details: { changeId },
5172
+ suggestedCommand: `fet continue --change ${changeId}`,
5173
+ recoverable: true
5174
+ });
5175
+ }
5176
+ const planningFingerprint = await computePlanningFingerprint(ctx.projectRoot, changeId);
5177
+ const cases = await extractCasesFromChange(ctx.projectRoot, changeId, sources);
5178
+ const scan = await ctx.scanner.scan(ctx.projectRoot, {});
5179
+ const testCommand2 = scan.commands.test?.command ?? scan.commands["test:unit"]?.command ?? null;
5180
+ const manifest = createTddManifest({
5181
+ changeId,
5182
+ planningFingerprint,
5183
+ sources,
5184
+ cases,
5185
+ testCommand: testCommand2
5186
+ });
5187
+ const manifestPath3 = await writeTddManifest(ctx.projectRoot, manifest);
5188
+ const fetDir = join24(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
5189
+ await mkdir8(fetDir, { recursive: true });
5190
+ const instructionsPath = tddInstructionsRelativePath(changeId);
5191
+ const specPath = tddSpecRelativePath(changeId);
5192
+ await atomicWrite(join24(ctx.projectRoot, instructionsPath), renderTddInstructions(changeId, manifest, ctx.language));
5193
+ await atomicWrite(join24(ctx.projectRoot, specPath), renderTddSpec(changeId, manifest, ctx.language));
5194
+ const changeState = await ctx.stateStore.getOrCreateChange(changeId, "implement");
5195
+ changeState.tdd = {
5196
+ status: "ready",
5197
+ generatedAt: manifest.generatedAt,
5198
+ planningFingerprint,
5199
+ manifestPath: manifestPath3
5200
+ };
5201
+ invalidateTestRun(changeState);
5202
+ changeState.currentPhase = "implement";
5203
+ changeState.phases.implement = { status: "in_progress", updatedAt: manifest.generatedAt };
5204
+ await ctx.stateStore.writeChange(changeState);
5205
+ const global = await ctx.stateStore.getOrCreateGlobal();
5206
+ global.activeChangeId = changeId;
5207
+ await ctx.stateStore.writeGlobal(global);
5208
+ ctx.output.result({
5209
+ ok: true,
5210
+ command: "tdd",
5211
+ summary: msg(
5212
+ ctx.language,
5213
+ `\u5DF2\u4E3A change "${changeId}" \u751F\u6210 TDD \u4EA7\u7269\uFF08${cases.length} \u4E2A\u7528\u4F8B\uFF09\u3002`,
5214
+ `Generated TDD artifacts for change "${changeId}" (${cases.length} case(s)).`
5215
+ ),
5216
+ warnings: !testCommand2 && config.whenNoTestScript !== "skip" ? [
5217
+ msg(
5218
+ ctx.language,
5219
+ "\u9879\u76EE\u672A\u914D\u7F6E test \u811A\u672C\uFF1B\u5B9E\u73B0\u540E\u53EF\u80FD\u65E0\u6CD5\u8FD0\u884C fet test\u3002",
5220
+ "No test script found in the project; fet test may fail after implementation."
5221
+ )
5222
+ ] : void 0,
5223
+ nextSteps: [
5224
+ msg(ctx.language, `\u9605\u8BFB ${instructionsPath}\uFF0C\u7531 IDE/AI \u521B\u5EFA/\u66F4\u65B0\u6E05\u5355\u4E2D\u7684\u6D4B\u8BD5\u6587\u4EF6\u3002`, `Read ${instructionsPath} and create/update test files listed in the manifest.`),
5225
+ msg(ctx.language, `\u5B8C\u6210\u540E\u8FD0\u884C fet apply --change ${changeId}`, `Then run fet apply --change ${changeId}`),
5226
+ msg(ctx.language, `\u5B9E\u73B0\u540E\u8FD0\u884C fet test --change ${changeId}`, `After implementation, run fet test --change ${changeId}`)
5227
+ ],
5228
+ data: {
5229
+ changeId,
5230
+ manifestPath: manifestPath3,
5231
+ specPath,
5232
+ instructionsPath,
5233
+ caseCount: cases.length,
5234
+ planningFingerprint,
5235
+ testCommand: testCommand2
5236
+ }
5237
+ });
5238
+ });
5239
+ }
5240
+
5241
+ // src/commands/run-script.ts
5242
+ import { spawn as spawn2 } from "child_process";
5243
+ async function runShellCommand(commandLine, cwd, extraArgs = []) {
5244
+ const parts = splitCommandLine(commandLine);
5245
+ const executable = parts[0];
5246
+ if (!executable) {
5247
+ throw new Error("Empty command");
5248
+ }
5249
+ const args = [...parts.slice(1), ...extraArgs];
5250
+ return runProcess(executable, args, cwd, commandLine);
5251
+ }
5252
+ async function runProcess(executable, args, cwd, label = executable) {
5253
+ return new Promise((resolve2) => {
5254
+ const stdout = [];
5255
+ const stderr = [];
5256
+ const child = spawn2(executable, args, {
5257
+ cwd,
5258
+ shell: process.platform === "win32",
5259
+ env: process.env
5260
+ });
5261
+ child.stdout?.on("data", (chunk) => stdout.push(chunk));
5262
+ child.stderr?.on("data", (chunk) => stderr.push(chunk));
5263
+ child.on("close", (code, signal) => {
5264
+ resolve2({
5265
+ command: label,
5266
+ args,
5267
+ exitCode: code ?? 1,
5268
+ signal,
5269
+ stdout: Buffer.concat(stdout).toString("utf8"),
5270
+ stderr: Buffer.concat(stderr).toString("utf8")
5271
+ });
5272
+ });
5273
+ child.on("error", () => {
5274
+ resolve2({
5275
+ command: label,
5276
+ args,
5277
+ exitCode: 1,
5278
+ signal: null,
5279
+ stdout: Buffer.concat(stdout).toString("utf8"),
5280
+ stderr: Buffer.concat(stderr).toString("utf8")
5281
+ });
5282
+ });
5283
+ });
5284
+ }
5285
+ function splitCommandLine(commandLine) {
5286
+ return commandLine.trim().split(/\s+/);
5287
+ }
5288
+
5289
+ // src/commands/test.ts
5290
+ async function testCommand(ctx, options) {
5291
+ await withProjectLock(ctx.projectRoot, { command: "test", cwd: ctx.cwd, fetVersion: ctx.fetVersion }, async () => {
5292
+ const changeId = await resolveChangeId(ctx);
5293
+ await assertChangeExists(ctx, changeId);
5294
+ const config = await loadTddConfig(ctx.projectRoot);
5295
+ const manifest = await readTddManifest(ctx.projectRoot, changeId);
5296
+ if (!manifest) {
5297
+ throw new FetError({
5298
+ code: "STATE_CORRUPTED" /* StateCorrupted */,
5299
+ message: msg(ctx.language, "\u7F3A\u5C11 TDD \u6E05\u5355\uFF0C\u65E0\u6CD5\u8FD0\u884C fet test\u3002", "TDD manifest is missing; cannot run fet test."),
5300
+ details: { changeId },
5301
+ suggestedCommand: `fet tdd --change ${changeId}`,
5302
+ recoverable: true
5303
+ });
5304
+ }
5305
+ const planningFingerprint = await computePlanningFingerprint(ctx.projectRoot, changeId);
5306
+ if (manifest.planningFingerprint !== planningFingerprint) {
5307
+ throw new FetError({
5308
+ code: "STATE_CORRUPTED" /* StateCorrupted */,
5309
+ message: msg(ctx.language, "TDD \u6E05\u5355\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u8FD0\u884C fet tdd\u3002", "TDD manifest is stale; rerun fet tdd."),
5310
+ details: { changeId },
5311
+ suggestedCommand: `fet tdd --change ${changeId}`,
5312
+ recoverable: true
5313
+ });
5314
+ }
5315
+ const scan = await ctx.scanner.scan(ctx.projectRoot, {});
5316
+ const testCommand2 = manifest.run.fallbackCommand ?? scan.commands.test?.command ?? scan.commands["test:unit"]?.command ?? null;
5317
+ if (!testCommand2) {
5318
+ if (config.whenNoTestScript === "skip") {
5319
+ await recordSkippedTestRun(ctx, changeId, planningFingerprint, manifestPath(changeId));
5320
+ ctx.output.result({
5321
+ ok: true,
5322
+ command: "test",
5323
+ summary: msg(ctx.language, "\u672A\u914D\u7F6E test \u811A\u672C\uFF0C\u5DF2\u8DF3\u8FC7 fet test\u3002", "No test script configured; fet test skipped."),
5324
+ warnings: [msg(ctx.language, "\u8DF3\u8FC7\u540E\u53EF\u76F4\u63A5 fet verify\uFF08\u82E5\u9879\u76EE\u7B56\u7565\u5141\u8BB8\uFF09\u3002", "You may proceed to fet verify if your policy allows.")],
5325
+ data: { changeId, skipped: true }
5326
+ });
5327
+ return;
5328
+ }
5329
+ throw new FetError({
5330
+ code: "CONFIG_INVALID" /* ConfigInvalid */,
5331
+ message: msg(ctx.language, "\u672A\u627E\u5230 test \u811A\u672C\uFF0C\u65E0\u6CD5\u6267\u884C fet test\u3002", "No test script found; cannot run fet test."),
5332
+ suggestedCommand: "\u5728 package.json \u4E2D\u6DFB\u52A0 scripts.test \u6216\u914D\u7F6E openspec/config.yaml fet.tdd.whenNoTestScript: skip"
5333
+ });
5334
+ }
5335
+ const extraArgs = buildTestArgs(manifest);
5336
+ const planLabel = `${testCommand2}${extraArgs.length ? ` ${extraArgs.join(" ")}` : ""}`;
5337
+ if (options.plan) {
5338
+ ctx.output.result({
5339
+ ok: true,
5340
+ command: "test",
5341
+ summary: msg(ctx.language, "\u5DF2\u751F\u6210 fet test \u6267\u884C\u8BA1\u5212\u3002", "Generated fet test execution plan."),
5342
+ data: {
5343
+ changeId,
5344
+ command: planLabel,
5345
+ cases: manifest.cases.map((item) => ({ id: item.id, testFile: item.testFile }))
5346
+ },
5347
+ nextSteps: [`fet test --change ${changeId}`]
5348
+ });
5349
+ return;
5350
+ }
5351
+ const result = await runShellCommand(testCommand2, ctx.projectRoot, extraArgs);
5352
+ const caseResults = manifest.cases.map((item) => ({
5353
+ id: item.id,
5354
+ status: result.exitCode === 0 ? "passed" : "failed",
5355
+ exitCode: result.exitCode,
5356
+ message: result.exitCode === 0 ? void 0 : msg(ctx.language, "\u6D4B\u8BD5\u547D\u4EE4\u672A\u901A\u8FC7", "Test command failed")
5357
+ }));
5358
+ const resultsDoc = {
5359
+ schemaVersion: 1,
5360
+ changeId,
5361
+ ranAt: (/* @__PURE__ */ new Date()).toISOString(),
5362
+ command: planLabel,
5363
+ exitCode: result.exitCode,
5364
+ planningFingerprint,
5365
+ cases: caseResults
5366
+ };
5367
+ const resultsPath = await writeTddResults(ctx.projectRoot, resultsDoc);
5368
+ const changeState = await ctx.stateStore.getOrCreateChange(changeId, "implement");
5369
+ changeState.testRun = {
5370
+ status: result.exitCode === 0 ? "passed" : "failed",
5371
+ ranAt: resultsDoc.ranAt,
5372
+ command: planLabel,
5373
+ exitCode: result.exitCode,
5374
+ planningFingerprint,
5375
+ manifestPath: manifestPath(changeId),
5376
+ resultsPath
5377
+ };
5378
+ await ctx.stateStore.writeChange(changeState);
5379
+ if (result.exitCode !== 0) {
5380
+ throw new FetError({
5381
+ code: "OPENSPEC_COMMAND_FAILED" /* OpenSpecCommandFailed */,
5382
+ message: msg(ctx.language, "fet test \u672A\u901A\u8FC7\u3002", "fet test failed."),
5383
+ details: {
5384
+ changeId,
5385
+ exitCode: result.exitCode,
5386
+ command: planLabel,
5387
+ resultsPath: tddResultsRelativePath(changeId),
5388
+ stderr: truncate(result.stderr)
5389
+ },
5390
+ suggestedCommand: `fet test --change ${changeId}`,
5391
+ recoverable: true
5392
+ });
5393
+ }
5394
+ ctx.output.result({
5395
+ ok: true,
5396
+ command: "test",
5397
+ summary: msg(ctx.language, `change "${changeId}" \u7684\u5355\u6D4B\u5DF2\u901A\u8FC7\u3002`, `Unit tests passed for change "${changeId}".`),
5398
+ nextSteps: [`fet verify --change ${changeId}`],
5399
+ data: {
5400
+ changeId,
5401
+ command: planLabel,
5402
+ resultsPath: tddResultsRelativePath(changeId),
5403
+ cases: caseResults
5404
+ }
5405
+ });
5406
+ });
5407
+ }
5408
+ function manifestPath(changeId) {
5409
+ return tddManifestRelativePath(changeId);
5410
+ }
5411
+ function buildTestArgs(manifest) {
5412
+ if (!manifest || manifest.run.mode === "workspace") {
5413
+ return [];
5414
+ }
5415
+ const files = [...new Set(manifest.cases.map((item) => item.testFile).filter(Boolean))];
5416
+ return files.length ? ["--", ...files] : [];
5417
+ }
5418
+ async function recordSkippedTestRun(ctx, changeId, planningFingerprint, manifestPathValue) {
5419
+ const changeState = await ctx.stateStore.getOrCreateChange(changeId, "implement");
5420
+ changeState.testRun = {
5421
+ status: "skipped",
5422
+ ranAt: (/* @__PURE__ */ new Date()).toISOString(),
5423
+ command: "(skipped)",
5424
+ exitCode: 0,
5425
+ planningFingerprint,
5426
+ manifestPath: manifestPathValue,
5427
+ resultsPath: null
5428
+ };
5429
+ await ctx.stateStore.writeChange(changeState);
5430
+ }
5431
+ function truncate(value, max = 2e3) {
5432
+ return value.length > max ? `${value.slice(0, max)}\u2026` : value;
5433
+ }
5434
+
5435
+ // src/commands/visual.ts
5436
+ import { mkdir as mkdir10 } from "fs/promises";
5437
+ import { join as join28 } from "path";
5438
+
5439
+ // src/visual/config.ts
5440
+ import { readFile as readFile19 } from "fs/promises";
5441
+ import { join as join25 } from "path";
5442
+ import { parseDocument as parseDocument5 } from "yaml";
5443
+ var DEFAULT_CONFIG4 = {
5444
+ enabled: true,
5445
+ compareMode: "layout-only",
5446
+ requireBeforeVerify: "when_figma",
5447
+ whenNoCapture: "warn"
5448
+ };
5449
+ async function loadVisualConfig(projectRoot) {
5450
+ try {
5451
+ const raw = await readFile19(join25(projectRoot, "openspec", "config.yaml"), "utf8");
5452
+ const doc = parseDocument5(raw);
5453
+ const fetNode = doc.get("fet", true);
5454
+ const node = fetNode?.get?.("visual");
5455
+ if (!node || typeof node.get !== "function") {
5456
+ return DEFAULT_CONFIG4;
5457
+ }
5458
+ const enabled = node.get("enabled");
5459
+ const requireBeforeVerify = node.get("requireBeforeVerify");
5460
+ const whenNoCapture = node.get("whenNoCapture");
5461
+ return {
5462
+ enabled: enabled === void 0 ? true : Boolean(enabled),
5463
+ compareMode: "layout-only",
5464
+ requireBeforeVerify: parseRequireBeforeVerify(requireBeforeVerify),
5465
+ whenNoCapture: parseWhenNoCapture(whenNoCapture)
5466
+ };
5467
+ } catch {
5468
+ return DEFAULT_CONFIG4;
5469
+ }
5470
+ }
5471
+ function parseRequireBeforeVerify(value) {
5472
+ if (value === "off" || value === "always" || value === "when_figma") {
5473
+ return value;
5474
+ }
5475
+ return DEFAULT_CONFIG4.requireBeforeVerify;
5476
+ }
5477
+ function parseWhenNoCapture(value) {
5478
+ if (value === "block" || value === "warn" || value === "skip") {
5479
+ return value;
5480
+ }
5481
+ return DEFAULT_CONFIG4.whenNoCapture;
5482
+ }
5483
+ function isVisualRequiredForVerify(config, hasFigma) {
5484
+ if (!config.enabled) {
5485
+ return false;
5486
+ }
5487
+ if (config.requireBeforeVerify === "off") {
5488
+ return false;
5489
+ }
5490
+ if (config.requireBeforeVerify === "always") {
5491
+ return true;
5492
+ }
5493
+ return hasFigma;
5494
+ }
5495
+
5496
+ // src/visual/playwright.ts
5497
+ import { createRequire } from "module";
5498
+ import { dirname as dirname9, join as join26 } from "path";
5499
+ import { pathToFileURL } from "url";
5500
+ async function resolvePlaywright(projectRoot) {
5501
+ const require2 = createRequire(join26(projectRoot, "package.json"));
5502
+ const candidates = ["playwright", "@playwright/test"];
5503
+ for (const name of candidates) {
5504
+ try {
5505
+ const resolved = require2.resolve(name);
5506
+ const mod = await import(pathToFileURL(resolved).href);
5507
+ if (mod?.chromium) {
5508
+ return mod;
5509
+ }
5510
+ } catch {
5511
+ continue;
5512
+ }
5513
+ }
5514
+ return null;
5515
+ }
5516
+ async function capturePagesWithPlaywright(projectRoot, manifest, baseUrl) {
5517
+ const playwright = await resolvePlaywright(projectRoot);
5518
+ if (!playwright) {
5519
+ return [];
5520
+ }
5521
+ const browser = await playwright.chromium.launch({ headless: true });
5522
+ const results = [];
5523
+ try {
5524
+ for (const page of manifest.pages) {
5525
+ results.push(await captureSinglePage(projectRoot, browser, manifest.changeId, page, baseUrl));
5526
+ }
5527
+ } finally {
5528
+ await browser.close();
5529
+ }
5530
+ return results;
5531
+ }
5532
+ async function captureSinglePage(projectRoot, browser, changeId, pageDef, baseUrl) {
5533
+ const { mkdir: mkdir15 } = await import("fs/promises");
5534
+ const { join: joinPath } = await import("path");
5535
+ const screenshotRelative = visualPageScreenshotRelative(changeId, pageDef.id);
5536
+ const screenshotAbsolute = joinPath(projectRoot, screenshotRelative);
5537
+ await mkdir15(dirname9(screenshotAbsolute), { recursive: true });
5538
+ const url = new URL(pageDef.route, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
5539
+ const pwPage = await browser.newPage();
5540
+ try {
5541
+ await pwPage.setViewportSize(pageDef.viewport);
5542
+ await pwPage.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
5543
+ await pwPage.screenshot({ path: screenshotAbsolute, fullPage: true });
5544
+ const regions = await extractRegions(pwPage, pageDef);
5545
+ return {
5546
+ pageId: pageDef.id,
5547
+ route: pageDef.route,
5548
+ screenshotPath: screenshotRelative,
5549
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
5550
+ regions
5551
+ };
5552
+ } catch {
5553
+ return {
5554
+ pageId: pageDef.id,
5555
+ route: pageDef.route,
5556
+ screenshotPath: null,
5557
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
5558
+ regions: []
5559
+ };
5560
+ } finally {
5561
+ await pwPage.close();
5562
+ }
5563
+ }
5564
+ var EXTRACT_REGIONS_SCRIPT = `
5565
+ (inputSelectors) => {
5566
+ const layouts = [];
5567
+ for (const selector of inputSelectors) {
5568
+ const element = document.querySelector(selector);
5569
+ if (!element) {
5570
+ layouts.push({
5571
+ selector,
5572
+ exists: false,
5573
+ visible: false,
5574
+ width: 0,
5575
+ height: 0,
5576
+ display: "none",
5577
+ position: "static"
5578
+ });
5579
+ continue;
5580
+ }
5581
+ const style = window.getComputedStyle(element);
5582
+ const rect = element.getBoundingClientRect();
5583
+ layouts.push({
5584
+ selector,
5585
+ exists: true,
5586
+ visible: style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0,
5587
+ width: rect.width,
5588
+ height: rect.height,
5589
+ display: style.display,
5590
+ position: style.position
5591
+ });
5592
+ }
5593
+ return layouts;
5594
+ }
5595
+ `;
5596
+ async function extractRegions(page, pageDef) {
5597
+ const selectors = pageDef.checkRegions.map((region) => region.selector);
5598
+ return page.evaluate(EXTRACT_REGIONS_SCRIPT, selectors);
5599
+ }
5600
+
5601
+ // src/visual/manifest.ts
5602
+ import { mkdir as mkdir9, readFile as readFile20, stat as stat11 } from "fs/promises";
5603
+ import { dirname as dirname10, join as join27 } from "path";
5604
+ import { parse as parse5, stringify as stringify4 } from "yaml";
5605
+ function visualManifestPath(projectRoot, changeId) {
5606
+ return join27(projectRoot, visualManifestRelativePath(changeId));
5607
+ }
5608
+ async function readVisualManifest(projectRoot, changeId) {
5609
+ const path = visualManifestPath(projectRoot, changeId);
5610
+ try {
5611
+ await stat11(path);
5612
+ } catch {
5613
+ return null;
5614
+ }
5615
+ const doc = parse5(await readFile20(path, "utf8"));
5616
+ if (!doc || doc.schemaVersion !== 1 || doc.changeId !== changeId) {
5617
+ return null;
5618
+ }
5619
+ return doc;
5620
+ }
5621
+ async function writeVisualManifest(projectRoot, manifest) {
5622
+ const relative6 = visualManifestRelativePath(manifest.changeId);
5623
+ const path = join27(projectRoot, relative6);
5624
+ await mkdir9(dirname10(path), { recursive: true });
5625
+ await atomicWrite(path, stringify4(manifest));
5626
+ return relative6;
5627
+ }
5628
+ async function readVisualCapture(projectRoot, changeId) {
5629
+ const path = join27(projectRoot, visualCaptureRelativePath(changeId));
5630
+ try {
5631
+ const doc = JSON.parse(await readFile20(path, "utf8"));
5632
+ if (doc?.schemaVersion === 1 && doc.changeId === changeId) {
5633
+ return doc;
5634
+ }
5635
+ } catch {
5636
+ return null;
5637
+ }
5638
+ return null;
5639
+ }
5640
+ async function writeVisualCapture(projectRoot, capture) {
5641
+ const relative6 = visualCaptureRelativePath(capture.changeId);
5642
+ const path = join27(projectRoot, relative6);
5643
+ await mkdir9(dirname10(path), { recursive: true });
5644
+ await atomicWrite(path, `${JSON.stringify(capture, null, 2)}
5645
+ `);
5646
+ return relative6;
5647
+ }
5648
+ async function writeVisualResults(projectRoot, results) {
5649
+ const relative6 = visualResultsRelativePath(results.changeId);
5650
+ const path = join27(projectRoot, relative6);
5651
+ await mkdir9(dirname10(path), { recursive: true });
5652
+ await atomicWrite(path, `${JSON.stringify(results, null, 2)}
5653
+ `);
5654
+ return relative6;
5655
+ }
5656
+ function createVisualManifest(input) {
5657
+ return {
5658
+ schemaVersion: 1,
5659
+ changeId: input.changeId,
5660
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
5661
+ fetVersion: FET_VERSION,
5662
+ planningFingerprint: input.planningFingerprint,
5663
+ compareMode: "layout-only",
5664
+ figmaUrls: input.figmaUrls,
5665
+ figmaSources: input.figmaSources,
5666
+ run: { baseUrl: input.baseUrl },
5667
+ pages: input.pages
5668
+ };
5669
+ }
5670
+
5671
+ // src/visual/capture.ts
5672
+ async function runVisualCapture(options) {
5673
+ const warnings = [];
5674
+ const baseUrl = options.baseUrl ?? options.manifest.run.baseUrl;
5675
+ if (!baseUrl) {
5676
+ return handleMissingBaseUrl(options.config, options.language, warnings);
5677
+ }
5678
+ const playwright = await resolvePlaywright(options.projectRoot);
5679
+ if (!playwright) {
5680
+ return handleMissingPlaywright(options.config, options.language, warnings);
5681
+ }
5682
+ const pages = await capturePagesWithPlaywright(options.projectRoot, options.manifest, baseUrl);
5683
+ const failedPages = pages.filter((page) => !page.screenshotPath);
5684
+ const capture = {
5685
+ schemaVersion: 1,
5686
+ changeId: options.changeId,
5687
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
5688
+ baseUrl,
5689
+ pages
5690
+ };
5691
+ await writeVisualCapture(options.projectRoot, capture);
5692
+ if (failedPages.length) {
5693
+ return {
5694
+ status: "failed",
5695
+ capture,
5696
+ warnings,
5697
+ message: options.language === "en" ? `Capture failed for ${failedPages.length} page(s). Check dev server and routes.` : `${failedPages.length} \u4E2A\u9875\u9762\u622A\u56FE\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5 dev \u670D\u52A1\u4E0E\u8DEF\u7531\u3002`
5698
+ };
5699
+ }
5700
+ return { status: "passed", capture, warnings };
5701
+ }
5702
+ function handleMissingBaseUrl(config, language, warnings) {
5703
+ const message = language === "en" ? "No base URL for capture. Pass --base-url or set manifest.run.baseUrl." : "\u672A\u914D\u7F6E base URL\uFF0C\u65E0\u6CD5\u622A\u56FE\u3002\u8BF7\u4F7F\u7528 --base-url \u6216\u5728 manifest \u4E2D\u8BBE\u7F6E run.baseUrl\u3002";
5704
+ warnings.push(message);
5705
+ if (config.whenNoCapture === "block") {
5706
+ return { status: "failed", capture: null, warnings, message };
5707
+ }
5708
+ return { status: "skipped", capture: null, warnings, message };
5709
+ }
5710
+ function handleMissingPlaywright(config, language, warnings) {
5711
+ const message = language === "en" ? "Playwright not found in project. Install playwright or use --check-layout-only after manual capture." : "\u9879\u76EE\u672A\u5B89\u88C5 Playwright\u3002\u8BF7\u5B89\u88C5 playwright\uFF0C\u6216\u5728\u624B\u52A8\u622A\u56FE\u540E\u4F7F\u7528 --check-layout-only\u3002";
5712
+ warnings.push(message);
5713
+ if (config.whenNoCapture === "block") {
5714
+ return { status: "failed", capture: null, warnings, message };
5715
+ }
5716
+ return { status: "skipped", capture: null, warnings, message };
5717
+ }
5718
+
5719
+ // src/visual/check-layout.ts
5720
+ function runLayoutCheck(manifest, capture, language, options) {
5721
+ const regions = [];
5722
+ if (!capture && options?.allowManifestOnly) {
5723
+ return runManifestOnlyValidation(manifest, language);
5724
+ }
5725
+ if (capture?.pages.length) {
5726
+ for (const page of manifest.pages) {
5727
+ const captured = capture.pages.find((item) => item.pageId === page.id);
5728
+ for (const region of page.checkRegions) {
5729
+ const layout = captured?.regions.find((item) => item.selector === region.selector);
5730
+ const result = evaluateRegion(page.id, region.selector, region.checks, layout, language);
5731
+ regions.push(result);
5732
+ }
5733
+ }
5734
+ } else {
5735
+ for (const page of manifest.pages) {
5736
+ if (!page.checkRegions.length) {
5737
+ regions.push({
5738
+ pageId: page.id,
5739
+ selector: "(page)",
5740
+ status: "failed",
5741
+ message: language === "en" ? "No checkRegions defined" : "\u672A\u5B9A\u4E49 checkRegions"
5742
+ });
5743
+ continue;
5744
+ }
5745
+ for (const region of page.checkRegions) {
5746
+ regions.push({
5747
+ pageId: page.id,
5748
+ selector: region.selector,
5749
+ status: capture ? "failed" : "skipped",
5750
+ message: language === "en" ? "No capture data; run capture with --base-url or install Playwright" : "\u65E0\u622A\u56FE\u6570\u636E\uFF1B\u8BF7\u5E26 --base-url \u8FD0\u884C capture \u6216\u5B89\u88C5 Playwright"
5751
+ });
5752
+ }
5753
+ }
5754
+ }
5755
+ const failed = regions.filter((item) => item.status === "failed");
5756
+ if (failed.length) {
5757
+ return {
5758
+ status: "failed",
5759
+ regions,
5760
+ message: language === "en" ? `Layout check failed for ${failed.length} region(s).` : `${failed.length} \u4E2A\u533A\u57DF\u7684\u5E03\u5C40\u68C0\u67E5\u672A\u901A\u8FC7\u3002`
5761
+ };
5762
+ }
5763
+ const skipped = regions.filter((item) => item.status === "skipped");
5764
+ if (skipped.length && !capture) {
5765
+ return {
5766
+ status: "failed",
5767
+ regions,
5768
+ message: language === "en" ? "Layout check requires capture data when regions are defined." : "\u5DF2\u5B9A\u4E49\u68C0\u67E5\u533A\u57DF\u65F6\u9700\u8981 capture \u6570\u636E\u624D\u80FD\u5B8C\u6210\u5E03\u5C40\u9A8C\u6536\u3002"
5769
+ };
5770
+ }
5771
+ return { status: "passed", regions };
5772
+ }
5773
+ function runManifestOnlyValidation(manifest, language) {
5774
+ const regions = [];
5775
+ if (!manifest.pages.length) {
5776
+ return {
5777
+ status: "failed",
5778
+ regions,
5779
+ message: language === "en" ? "Visual manifest has no pages." : "\u89C6\u89C9\u6E05\u5355\u4E2D\u6CA1\u6709\u4EFB\u4F55\u9875\u9762\u3002"
5780
+ };
5781
+ }
5782
+ for (const page of manifest.pages) {
5783
+ for (const region of page.checkRegions) {
5784
+ regions.push({
5785
+ pageId: page.id,
5786
+ selector: region.selector,
5787
+ status: "skipped",
5788
+ message: language === "en" ? "Manifest-only mode (layout-only, no pixel/content compare)" : "\u4EC5 manifest \u6821\u9A8C\uFF08layout-only\uFF0C\u4E0D\u5BF9\u6BD4\u50CF\u7D20/\u52A8\u6001\u5185\u5BB9\uFF09"
5789
+ });
4419
5790
  }
4420
5791
  }
4421
- if (!leftVersion.prerelease.length && rightVersion.prerelease.length) {
4422
- return 1;
4423
- }
4424
- if (leftVersion.prerelease.length && !rightVersion.prerelease.length) {
4425
- return -1;
5792
+ return {
5793
+ status: "passed",
5794
+ regions,
5795
+ message: language === "en" ? "Manifest validated; capture skipped \u2014 layout-only checklist recorded." : "\u6E05\u5355\u6821\u9A8C\u901A\u8FC7\uFF1B\u5DF2\u8DF3\u8FC7\u622A\u56FE \u2014 \u5DF2\u8BB0\u5F55 layout-only \u68C0\u67E5\u9879\u3002"
5796
+ };
5797
+ }
5798
+ function evaluateRegion(pageId, selector, checks, layout, language) {
5799
+ if (!layout) {
5800
+ return {
5801
+ pageId,
5802
+ selector,
5803
+ status: "failed",
5804
+ message: language === "en" ? "Region not found in capture" : "capture \u4E2D\u672A\u627E\u5230\u8BE5\u533A\u57DF"
5805
+ };
4426
5806
  }
4427
- const length = Math.max(leftVersion.prerelease.length, rightVersion.prerelease.length);
4428
- for (let index = 0; index < length; index += 1) {
4429
- const leftPart = leftVersion.prerelease[index];
4430
- const rightPart = rightVersion.prerelease[index];
4431
- if (leftPart === void 0) {
4432
- return -1;
5807
+ for (const check of checks) {
5808
+ if (check === "exists" && !layout.exists) {
5809
+ return { pageId, selector, status: "failed", message: language === "en" ? "Missing element" : "\u5143\u7D20\u4E0D\u5B58\u5728" };
4433
5810
  }
4434
- if (rightPart === void 0) {
4435
- return 1;
5811
+ if (check === "visible" && !layout.visible) {
5812
+ return { pageId, selector, status: "failed", message: language === "en" ? "Not visible" : "\u4E0D\u53EF\u89C1" };
4436
5813
  }
4437
- const diff = comparePrereleasePart(leftPart, rightPart);
4438
- if (diff !== 0) {
4439
- return diff;
5814
+ if (check === "has-box" && (layout.width <= 0 || layout.height <= 0)) {
5815
+ return { pageId, selector, status: "failed", message: language === "en" ? "Zero layout box" : "\u5E03\u5C40\u5C3A\u5BF8\u4E3A 0" };
4440
5816
  }
4441
5817
  }
4442
- return 0;
5818
+ return { pageId, selector, status: "passed" };
4443
5819
  }
4444
- function parseVersion(version) {
4445
- const [withoutBuild] = version.trim().replace(/^v/i, "").split("+");
4446
- const [mainValue = "", prereleaseValue = ""] = withoutBuild.split("-");
4447
- const mainParts = mainValue.split(".").map((part) => Number.parseInt(part, 10));
5820
+ function buildVisualResults(input) {
4448
5821
  return {
4449
- main: [
4450
- Number.isFinite(mainParts[0]) ? mainParts[0] : 0,
4451
- Number.isFinite(mainParts[1]) ? mainParts[1] : 0,
4452
- Number.isFinite(mainParts[2]) ? mainParts[2] : 0
4453
- ],
4454
- prerelease: prereleaseValue ? prereleaseValue.split(".") : []
5822
+ schemaVersion: 1,
5823
+ changeId: input.changeId,
5824
+ ranAt: (/* @__PURE__ */ new Date()).toISOString(),
5825
+ compareMode: "layout-only",
5826
+ planningFingerprint: input.planningFingerprint,
5827
+ steps: input.steps,
5828
+ captureStatus: input.captureStatus,
5829
+ layoutStatus: input.layoutStatus,
5830
+ regions: input.regions
4455
5831
  };
4456
5832
  }
4457
- function comparePrereleasePart(left, right) {
4458
- const leftNumber = /^\d+$/.test(left) ? Number.parseInt(left, 10) : null;
4459
- const rightNumber = /^\d+$/.test(right) ? Number.parseInt(right, 10) : null;
4460
- if (leftNumber !== null && rightNumber !== null) {
4461
- return Math.sign(leftNumber - rightNumber);
4462
- }
4463
- if (leftNumber !== null) {
4464
- return -1;
5833
+
5834
+ // src/visual/generate.ts
5835
+ var DEFAULT_VIEWPORT = { width: 1280, height: 720 };
5836
+ async function buildVisualManifestPages(projectRoot, changeId, figmaUrls) {
5837
+ if (figmaUrls.length) {
5838
+ return [
5839
+ {
5840
+ id: `${changeId}-main`,
5841
+ title: "Main UI screen",
5842
+ route: "/",
5843
+ viewport: DEFAULT_VIEWPORT,
5844
+ dataMode: "mock",
5845
+ mockFixture: null,
5846
+ ignoreSelectors: [".dynamic-list", "[data-dynamic]", "[data-testid='dynamic-content']"],
5847
+ checkRegions: [
5848
+ { selector: "header, [role='banner'], .app-header", checks: ["exists", "visible", "has-box"] },
5849
+ { selector: "main, [role='main'], .app-main", checks: ["exists", "visible", "has-box"] },
5850
+ { selector: "nav, [role='navigation'], .app-nav", checks: ["exists", "has-box"] }
5851
+ ]
5852
+ }
5853
+ ];
4465
5854
  }
4466
- if (rightNumber !== null) {
4467
- return 1;
5855
+ return [
5856
+ {
5857
+ id: `${changeId}-shell`,
5858
+ title: "Application shell",
5859
+ route: "/",
5860
+ viewport: DEFAULT_VIEWPORT,
5861
+ dataMode: "mock",
5862
+ mockFixture: null,
5863
+ ignoreSelectors: [".dynamic-list", "[data-dynamic]"],
5864
+ checkRegions: [{ selector: "body", checks: ["exists", "visible", "has-box"] }]
5865
+ }
5866
+ ];
5867
+ }
5868
+ async function generateVisualManifestInput(projectRoot, changeId, baseUrl) {
5869
+ const { urls, sources } = await collectFigmaUrlsFromChange(projectRoot, changeId);
5870
+ const planningFingerprint = await computePlanningFingerprint(projectRoot, changeId);
5871
+ const pages = await buildVisualManifestPages(projectRoot, changeId, urls);
5872
+ return {
5873
+ planningFingerprint,
5874
+ figmaUrls: urls,
5875
+ figmaSources: sources,
5876
+ pages
5877
+ };
5878
+ }
5879
+ function mergeBaseUrl(manifest, baseUrl) {
5880
+ if (!baseUrl) {
5881
+ return manifest;
4468
5882
  }
4469
- return left.localeCompare(right);
5883
+ return {
5884
+ ...manifest,
5885
+ run: { baseUrl }
5886
+ };
4470
5887
  }
4471
5888
 
4472
- // src/commands/update.ts
4473
- async function updateCommand(ctx) {
4474
- const packageName = getFetPackageName();
4475
- const latestVersion = await resolveLatestFetVersion(packageName);
4476
- const currentVersion = ctx.fetVersion;
4477
- if (compareVersions(currentVersion, latestVersion) >= 0) {
4478
- ctx.output.result({
4479
- ok: true,
4480
- command: "update",
4481
- summary: ctx.language === "en" ? `FET is already up to date (${currentVersion}).` : `FET \u5DF2\u662F\u6700\u65B0\u7248 (${currentVersion})\u3002`,
4482
- data: {
4483
- packageName,
4484
- currentVersion,
4485
- latestVersion,
4486
- updated: false
4487
- }
4488
- });
5889
+ // src/visual/gates.ts
5890
+ async function assertVisualPassed(ctx, changeId) {
5891
+ const config = await loadVisualConfig(ctx.projectRoot);
5892
+ const { urls } = await collectFigmaUrlsFromChange(ctx.projectRoot, changeId);
5893
+ if (!isVisualRequiredForVerify(config, urls.length > 0)) {
4489
5894
  return;
4490
5895
  }
4491
- const install = await performFetUpdate(currentVersion, latestVersion, {
4492
- cwd: ctx.cwd,
4493
- json: ctx.json,
4494
- language: ctx.language,
4495
- info: (message) => ctx.output.info(message)
5896
+ const change = await ctx.stateStore.readChange(changeId);
5897
+ const visualRun = change?.visualRun;
5898
+ if (visualRun?.status === "skipped") {
5899
+ return;
5900
+ }
5901
+ if (visualRun?.status === "passed" && await fingerprintMatches2(ctx, changeId, visualRun)) {
5902
+ return;
5903
+ }
5904
+ throw new FetError({
5905
+ code: "STATE_CORRUPTED" /* StateCorrupted */,
5906
+ message: msg(ctx.language, "\u672C change \u5C1A\u672A\u901A\u8FC7 fet visual\u3002", "This change has not passed fet visual yet."),
5907
+ details: { changeId, visualRun: visualRun ?? null, figmaUrlCount: urls.length },
5908
+ suggestedCommand: `fet visual --change ${changeId}`,
5909
+ recoverable: true
4496
5910
  });
4497
- ctx.output.result({
4498
- ok: true,
4499
- command: "update",
4500
- summary: ctx.language === "en" ? `FET updated from ${currentVersion} to ${latestVersion}.` : `FET \u5DF2\u4ECE ${currentVersion} \u5347\u7EA7\u5230 ${latestVersion}\u3002`,
4501
- data: {
4502
- packageName,
4503
- currentVersion,
4504
- latestVersion,
4505
- updated: true,
4506
- installCommand: install.installCommand
5911
+ }
5912
+ async function fingerprintMatches2(ctx, changeId, visualRun) {
5913
+ const current = await computePlanningFingerprint(ctx.projectRoot, changeId);
5914
+ return visualRun.planningFingerprint === current;
5915
+ }
5916
+ function invalidateVisualRun(state) {
5917
+ state.visualRun = null;
5918
+ }
5919
+ function manifestPath2(changeId) {
5920
+ return visualManifestRelativePath(changeId);
5921
+ }
5922
+
5923
+ // src/commands/visual.ts
5924
+ async function visualCommand(ctx, options) {
5925
+ await withProjectLock(ctx.projectRoot, { command: "visual", cwd: ctx.cwd, fetVersion: ctx.fetVersion }, async () => {
5926
+ const changeId = await resolveChangeId(ctx);
5927
+ await assertChangeExists(ctx, changeId);
5928
+ const config = await loadVisualConfig(ctx.projectRoot);
5929
+ const steps = [];
5930
+ const warnings = [];
5931
+ const manifest = await ensureManifest(ctx, changeId, options.baseUrl ?? null);
5932
+ steps.push("manifest");
5933
+ if (options.plan) {
5934
+ ctx.output.result({
5935
+ ok: true,
5936
+ command: "visual",
5937
+ summary: msg(ctx.language, "\u5DF2\u751F\u6210 fet visual \u6267\u884C\u8BA1\u5212\u3002", "Generated fet visual execution plan."),
5938
+ data: {
5939
+ changeId,
5940
+ compareMode: manifest.compareMode,
5941
+ steps: options.checkLayoutOnly ? ["manifest", "check-layout"] : options.captureOnly ? ["manifest", "capture"] : ["manifest", "capture", "check-layout"],
5942
+ baseUrl: options.baseUrl ?? manifest.run.baseUrl,
5943
+ pages: manifest.pages.map((page) => ({ id: page.id, route: page.route }))
5944
+ },
5945
+ nextSteps: [`fet visual --change ${changeId}${options.baseUrl ? ` --base-url ${options.baseUrl}` : ""}`]
5946
+ });
5947
+ return;
5948
+ }
5949
+ let captureStatus = "skipped";
5950
+ let captureDoc = await readVisualCapture(ctx.projectRoot, changeId);
5951
+ if (!options.checkLayoutOnly) {
5952
+ const captureResult = await runVisualCapture({
5953
+ projectRoot: ctx.projectRoot,
5954
+ changeId,
5955
+ manifest,
5956
+ baseUrl: options.baseUrl ?? null,
5957
+ config,
5958
+ language: ctx.language
5959
+ });
5960
+ warnings.push(...captureResult.warnings);
5961
+ captureStatus = captureResult.status;
5962
+ captureDoc = captureResult.capture;
5963
+ steps.push("capture");
5964
+ if (captureResult.status === "failed") {
5965
+ await recordVisualFailure(ctx, changeId, manifest.planningFingerprint, steps, captureStatus, "failed", [], captureResult.message);
5966
+ throw new FetError({
5967
+ code: "OPENSPEC_COMMAND_FAILED" /* OpenSpecCommandFailed */,
5968
+ message: captureResult.message ?? msg(ctx.language, "fet visual \u622A\u56FE\u5931\u8D25\u3002", "fet visual capture failed."),
5969
+ details: { changeId },
5970
+ suggestedCommand: `fet visual --change ${changeId} --base-url http://localhost:3000`,
5971
+ recoverable: true
5972
+ });
5973
+ }
5974
+ } else {
5975
+ steps.push("capture");
5976
+ captureStatus = captureDoc ? "passed" : "skipped";
5977
+ }
5978
+ if (!options.captureOnly) {
5979
+ const layout = runLayoutCheck(manifest, captureDoc, ctx.language, {
5980
+ allowManifestOnly: captureStatus === "skipped" && config.whenNoCapture !== "block"
5981
+ });
5982
+ steps.push("check-layout");
5983
+ const results = buildVisualResults({
5984
+ changeId,
5985
+ planningFingerprint: manifest.planningFingerprint,
5986
+ steps,
5987
+ captureStatus,
5988
+ layoutStatus: layout.status,
5989
+ regions: layout.regions
5990
+ });
5991
+ const resultsPath = await writeVisualResults(ctx.projectRoot, results);
5992
+ if (layout.status === "failed") {
5993
+ await recordVisualFailure(ctx, changeId, manifest.planningFingerprint, steps, captureStatus, layout.status, layout.regions, layout.message);
5994
+ throw new FetError({
5995
+ code: "OPENSPEC_COMMAND_FAILED" /* OpenSpecCommandFailed */,
5996
+ message: layout.message ?? msg(ctx.language, "fet visual \u5E03\u5C40\u68C0\u67E5\u672A\u901A\u8FC7\u3002", "fet visual layout check failed."),
5997
+ details: { changeId, resultsPath: visualResultsRelativePath(changeId) },
5998
+ suggestedCommand: `fet visual --change ${changeId}`,
5999
+ recoverable: true
6000
+ });
6001
+ }
6002
+ await recordVisualSuccess(ctx, changeId, manifest.planningFingerprint, resultsPath, captureStatus);
6003
+ ctx.output.result({
6004
+ ok: true,
6005
+ command: "visual",
6006
+ summary: msg(
6007
+ ctx.language,
6008
+ `change "${changeId}" \u5DF2\u901A\u8FC7 layout-only \u89C6\u89C9\u9A8C\u6536\u3002`,
6009
+ `Layout-only visual verification passed for change "${changeId}".`
6010
+ ),
6011
+ warnings: warnings.length ? warnings : void 0,
6012
+ nextSteps: [`fet verify --change ${changeId}`],
6013
+ data: { changeId, results, capturePath: captureDoc ? visualCaptureRelativePath(changeId) : null }
6014
+ });
6015
+ return;
4507
6016
  }
6017
+ ctx.output.result({
6018
+ ok: true,
6019
+ command: "visual",
6020
+ summary: msg(ctx.language, "fet visual \u622A\u56FE\u6B65\u9AA4\u5DF2\u5B8C\u6210\u3002", "fet visual capture step completed."),
6021
+ warnings: warnings.length ? warnings : void 0,
6022
+ data: { changeId, captureStatus, capturePath: captureDoc ? visualCaptureRelativePath(changeId) : null }
6023
+ });
6024
+ });
6025
+ }
6026
+ async function ensureManifest(ctx, changeId, baseUrl) {
6027
+ const fingerprint2 = await computePlanningFingerprint(ctx.projectRoot, changeId);
6028
+ const existing = await readVisualManifest(ctx.projectRoot, changeId);
6029
+ if (existing && existing.planningFingerprint === fingerprint2 && (!baseUrl || existing.run.baseUrl === baseUrl)) {
6030
+ return existing;
6031
+ }
6032
+ const input = await generateVisualManifestInput(ctx.projectRoot, changeId, baseUrl);
6033
+ const manifest = mergeBaseUrl(
6034
+ createVisualManifest({
6035
+ changeId,
6036
+ planningFingerprint: input.planningFingerprint,
6037
+ figmaUrls: input.figmaUrls,
6038
+ figmaSources: input.figmaSources,
6039
+ baseUrl,
6040
+ pages: input.pages
6041
+ }),
6042
+ baseUrl
6043
+ );
6044
+ await writeVisualManifest(ctx.projectRoot, manifest);
6045
+ const fetDir = join28(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
6046
+ await mkdir10(fetDir, { recursive: true });
6047
+ await atomicWrite(join28(ctx.projectRoot, visualInstructionsRelativePath(changeId)), renderVisualInstructions(changeId, manifest, ctx.language));
6048
+ await atomicWrite(join28(ctx.projectRoot, visualSpecRelativePath(changeId)), renderVisualSpec(changeId, manifest, ctx.language));
6049
+ const changeState = await ctx.stateStore.getOrCreateChange(changeId, "verify");
6050
+ changeState.visual = {
6051
+ status: "ready",
6052
+ generatedAt: manifest.generatedAt,
6053
+ planningFingerprint: fingerprint2,
6054
+ manifestPath: manifestPath2(changeId)
6055
+ };
6056
+ invalidateVisualRun(changeState);
6057
+ await ctx.stateStore.writeChange(changeState);
6058
+ return manifest;
6059
+ }
6060
+ async function recordVisualSuccess(ctx, changeId, planningFingerprint, resultsPath, captureStatus) {
6061
+ const changeState = await ctx.stateStore.getOrCreateChange(changeId, "verify");
6062
+ changeState.visualRun = {
6063
+ status: "passed",
6064
+ ranAt: (/* @__PURE__ */ new Date()).toISOString(),
6065
+ compareMode: "layout-only",
6066
+ planningFingerprint,
6067
+ manifestPath: manifestPath2(changeId),
6068
+ resultsPath,
6069
+ captureStatus
6070
+ };
6071
+ await ctx.stateStore.writeChange(changeState);
6072
+ }
6073
+ async function recordVisualFailure(ctx, changeId, planningFingerprint, steps, captureStatus, layoutStatus, regions, message) {
6074
+ const results = buildVisualResults({
6075
+ changeId,
6076
+ planningFingerprint,
6077
+ steps,
6078
+ captureStatus,
6079
+ layoutStatus,
6080
+ regions
4508
6081
  });
6082
+ await writeVisualResults(ctx.projectRoot, results);
6083
+ const changeState = await ctx.stateStore.getOrCreateChange(changeId, "verify");
6084
+ changeState.visualRun = {
6085
+ status: "failed",
6086
+ ranAt: (/* @__PURE__ */ new Date()).toISOString(),
6087
+ compareMode: "layout-only",
6088
+ planningFingerprint,
6089
+ manifestPath: manifestPath2(changeId),
6090
+ resultsPath: visualResultsRelativePath(changeId),
6091
+ captureStatus
6092
+ };
6093
+ await ctx.stateStore.writeChange(changeState);
6094
+ if (message) {
6095
+ void message;
6096
+ }
4509
6097
  }
4510
6098
 
4511
6099
  // src/commands/verify.ts
4512
- import { createHash } from "crypto";
4513
- import { mkdir as mkdir7, readFile as readFile15, stat as stat9 } from "fs/promises";
4514
- import { join as join20 } from "path";
4515
- function msg(language, zh, en) {
4516
- return language === "en" ? en : zh;
4517
- }
6100
+ import { createHash as createHash2 } from "crypto";
6101
+ import { mkdir as mkdir11, readFile as readFile21, stat as stat12 } from "fs/promises";
6102
+ import { join as join29 } from "path";
4518
6103
  async function verifyCommand(ctx, options) {
4519
6104
  if (options.auto) {
4520
6105
  const scan = await ctx.scanner.scan(ctx.projectRoot, {});
@@ -4580,10 +6165,12 @@ async function verifyCommand(ctx, options) {
4580
6165
  }
4581
6166
  async function writeInstructions(ctx, changeId) {
4582
6167
  await assertChangeExists(ctx, changeId);
6168
+ await assertTestPassed(ctx, changeId);
6169
+ await assertVisualPassed(ctx, changeId);
4583
6170
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
4584
- const dir = join20(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
4585
- const instructionsPath = join20(dir, "verify-instructions.md");
4586
- await mkdir7(dir, { recursive: true });
6171
+ const dir = join29(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
6172
+ const instructionsPath = join29(dir, "verify-instructions.md");
6173
+ await mkdir11(dir, { recursive: true });
4587
6174
  await atomicWrite(instructionsPath, renderVerifyInstructions(changeId, generatedAt));
4588
6175
  const state = await ctx.stateStore.getOrCreateChange(changeId, "verify");
4589
6176
  state.currentPhase = "verify";
@@ -4598,8 +6185,10 @@ async function writeInstructions(ctx, changeId) {
4598
6185
  }
4599
6186
  async function markDone(ctx, changeId) {
4600
6187
  await assertChangeExists(ctx, changeId);
6188
+ await assertTestPassed(ctx, changeId);
6189
+ await assertVisualPassed(ctx, changeId);
4601
6190
  const declaredAt = (/* @__PURE__ */ new Date()).toISOString();
4602
- const instructionsPath = join20(ctx.projectRoot, "openspec", "changes", changeId, ".fet", "verify-instructions.md");
6191
+ const instructionsPath = join29(ctx.projectRoot, "openspec", "changes", changeId, ".fet", "verify-instructions.md");
4603
6192
  const instructions = await readInstructions(ctx, instructionsPath, changeId);
4604
6193
  const instructionsGeneratedAt = readFrontMatterValue(instructions, "generatedAt") ?? declaredAt;
4605
6194
  const state = await ctx.stateStore.getOrCreateChange(changeId, "verify");
@@ -4621,21 +6210,10 @@ async function markDone(ctx, changeId) {
4621
6210
  nextSteps: [`fet sync --change ${changeId}`, `fet archive --change ${changeId}`]
4622
6211
  });
4623
6212
  }
4624
- async function assertChangeExists(ctx, changeId) {
4625
- const inspection = await ctx.openSpec.inspectChange(ctx.projectRoot, changeId);
4626
- if (!inspection.exists) {
4627
- throw new FetError({
4628
- code: "INVALID_ARGUMENTS" /* InvalidArguments */,
4629
- message: msg(ctx.language, "\u6307\u5B9A\u7684 change \u4E0D\u5B58\u5728", "The specified change does not exist"),
4630
- details: { changeId },
4631
- suggestedCommand: `fet verify --change ${changeId}`
4632
- });
4633
- }
4634
- }
4635
6213
  async function readInstructions(ctx, path, changeId) {
4636
6214
  try {
4637
- await stat9(path);
4638
- const content = await readFile15(path, "utf8");
6215
+ await stat12(path);
6216
+ const content = await readFile21(path, "utf8");
4639
6217
  const fileChangeId = readFrontMatterValue(content, "changeId");
4640
6218
  if (fileChangeId !== changeId) {
4641
6219
  throw new FetError({
@@ -4667,26 +6245,7 @@ function readFrontMatterValue(content, key) {
4667
6245
  return match?.[1]?.trim() ?? null;
4668
6246
  }
4669
6247
  function fingerprint(value) {
4670
- return `sha256:${createHash("sha256").update(JSON.stringify(value)).digest("hex")}`;
4671
- }
4672
- async function resolveChangeId(ctx) {
4673
- if (ctx.changeId) {
4674
- return ctx.changeId;
4675
- }
4676
- const global = await ctx.stateStore.getOrCreateGlobal();
4677
- if (global.activeChangeId) {
4678
- return global.activeChangeId;
4679
- }
4680
- const inspection = await ctx.openSpec.inspectProject(ctx.projectRoot);
4681
- if (inspection.changes.length === 1 && inspection.changes[0]) {
4682
- return inspection.changes[0];
4683
- }
4684
- throw new FetError({
4685
- code: "INVALID_ARGUMENTS" /* InvalidArguments */,
4686
- message: msg(ctx.language, "\u65E0\u6CD5\u786E\u5B9A\u8981\u9A8C\u8BC1\u7684 change", "Cannot determine which change to verify"),
4687
- details: { openChangeIds: inspection.changes },
4688
- suggestedCommand: "fet verify --change <change-id>"
4689
- });
6248
+ return `sha256:${createHash2("sha256").update(JSON.stringify(value)).digest("hex")}`;
4690
6249
  }
4691
6250
 
4692
6251
  // src/model-policy.ts
@@ -4777,11 +6336,12 @@ function renderIdeModelPolicy(command, language = "zh-CN") {
4777
6336
  import { resolve } from "path";
4778
6337
 
4779
6338
  // src/adapters/codex/index.ts
4780
- import { mkdir as mkdir8, readFile as readFile16, stat as stat10 } from "fs/promises";
6339
+ import { mkdir as mkdir12, readFile as readFile22, stat as stat13 } from "fs/promises";
4781
6340
  import { homedir } from "os";
4782
- import { dirname as dirname8, join as join21 } from "path";
6341
+ import { dirname as dirname11, join as join30 } from "path";
4783
6342
 
4784
6343
  // src/adapters/commands.ts
6344
+ var FET_STANDALONE_COMMANDS = ["tdd", "test", "visual"];
4785
6345
  var FET_WORKFLOW_COMMANDS = [
4786
6346
  "explore",
4787
6347
  "propose",
@@ -4796,7 +6356,7 @@ var FET_WORKFLOW_COMMANDS = [
4796
6356
  "onboard"
4797
6357
  ];
4798
6358
  var FET_GRAPH_COMMANDS = ["graph-status", "graph-setup", "graph-init", "graph-refresh", "graph-doctor", "graph-handoff"];
4799
- var FET_ADAPTER_COMMANDS = [...FET_WORKFLOW_COMMANDS, "update", "fill-context", "passthrough", ...FET_GRAPH_COMMANDS];
6359
+ var FET_ADAPTER_COMMANDS = [...FET_WORKFLOW_COMMANDS, ...FET_STANDALONE_COMMANDS, "update", "fill-context", "passthrough", ...FET_GRAPH_COMMANDS];
4800
6360
  function renderFetAdapterUsage(command, args = "[...args]") {
4801
6361
  if (command.startsWith("graph-")) {
4802
6362
  const subcommand = command.slice("graph-".length);
@@ -5864,7 +7424,7 @@ var CodexAdapter = class {
5864
7424
  adapterVersion = 1;
5865
7425
  async detect(projectRoot) {
5866
7426
  return {
5867
- detected: await exists5(join21(projectRoot, ".codex")) || await exists5(join21(projectRoot, "AGENTS.md")),
7427
+ detected: await exists6(join30(projectRoot, ".codex")) || await exists6(join30(projectRoot, "AGENTS.md")),
5868
7428
  reason: "Codex adapter is available for projects that use AGENTS.md"
5869
7429
  };
5870
7430
  }
@@ -5903,7 +7463,7 @@ var CodexAdapter = class {
5903
7463
  if (existing && !existing.includes("FET:MANAGED") && force) {
5904
7464
  await createBackup(target);
5905
7465
  }
5906
- await mkdir8(dirname8(target), { recursive: true });
7466
+ await mkdir12(dirname11(target), { recursive: true });
5907
7467
  await atomicWrite(target, file.content);
5908
7468
  written.push(displayPath);
5909
7469
  }
@@ -5930,9 +7490,9 @@ var CodexAdapter = class {
5930
7490
  };
5931
7491
  function resolveTarget(projectRoot, file) {
5932
7492
  if (file.root === "codex-home") {
5933
- return join21(resolveCodexHome(), file.path);
7493
+ return join30(resolveCodexHome(), file.path);
5934
7494
  }
5935
- return join21(projectRoot, file.path);
7495
+ return join30(projectRoot, file.path);
5936
7496
  }
5937
7497
  function displayPathFor(file) {
5938
7498
  if (file.root === "codex-home") {
@@ -5941,18 +7501,18 @@ function displayPathFor(file) {
5941
7501
  return file.path;
5942
7502
  }
5943
7503
  function resolveCodexHome() {
5944
- return process.env.FET_CODEX_HOME ?? process.env.CODEX_HOME ?? join21(homedir(), ".codex");
7504
+ return process.env.FET_CODEX_HOME ?? process.env.CODEX_HOME ?? join30(homedir(), ".codex");
5945
7505
  }
5946
7506
  async function readExisting(path) {
5947
7507
  try {
5948
- return await readFile16(path, "utf8");
7508
+ return await readFile22(path, "utf8");
5949
7509
  } catch {
5950
7510
  return null;
5951
7511
  }
5952
7512
  }
5953
- async function exists5(path) {
7513
+ async function exists6(path) {
5954
7514
  try {
5955
- await stat10(path);
7515
+ await stat13(path);
5956
7516
  return true;
5957
7517
  } catch {
5958
7518
  return false;
@@ -5960,8 +7520,8 @@ async function exists5(path) {
5960
7520
  }
5961
7521
 
5962
7522
  // src/adapters/cursor/index.ts
5963
- import { mkdir as mkdir9, readFile as readFile17, stat as stat11 } from "fs/promises";
5964
- import { dirname as dirname9, join as join22 } from "path";
7523
+ import { mkdir as mkdir13, readFile as readFile23, stat as stat14 } from "fs/promises";
7524
+ import { dirname as dirname12, join as join31 } from "path";
5965
7525
 
5966
7526
  // src/adapters/cursor/templates.ts
5967
7527
  function cursorFigmaStopRuleFile(language = DEFAULT_LANGUAGE) {
@@ -6093,6 +7653,12 @@ After installation, verify with \`gitnexus --version\`, then run \`fet graph ini
6093
7653
  if (command === "apply") {
6094
7654
  return renderApplySkill(usage, language);
6095
7655
  }
7656
+ if (command === "tdd" || command === "test") {
7657
+ return renderTddTestSkill(command, usage, language);
7658
+ }
7659
+ if (command === "visual") {
7660
+ return renderVisualSkill(usage, language);
7661
+ }
6096
7662
  if (command === "propose" || command === "continue" || command === "ff") {
6097
7663
  return renderPlanningSkill(command, usage, language);
6098
7664
  }
@@ -6210,6 +7776,67 @@ ${figmaBlock}
6210
7776
  ${uiContractBlock}
6211
7777
 
6212
7778
  \u6267\u884C\u524D\u8BF7\u9605\u8BFB AGENTS.md\u3001openspec/config.yaml \u4E0E\u5F53\u524D change \u4E0B\u7684 OpenSpec \u4EA7\u7269\u3002
7779
+
7780
+ \u5B9E\u65BD\u524D\u987B\u5DF2\u8FD0\u884C \`fet tdd\`\uFF1B\u5B58\u5728 \`openspec/changes/<change-id>/.fet/tdd-manifest.yaml\` \u65F6\u6309\u6E05\u5355\u7F16\u5199\u6D4B\u8BD5\u4E0E\u5B9E\u73B0\uFF0C\u5E76\u5728 \`fet verify\` \u524D\u5148 \`fet test\`\u3002
7781
+ `;
7782
+ }
7783
+ function renderVisualSkill(usage, language) {
7784
+ const body = language === "en" ? `Default \`fet visual\` runs manifest refresh, Playwright capture (needs \`--base-url\`), and **layout-only** checks (no pixel match on dynamic API content). Use \`--plan\`, \`--capture-only\`, or \`--check-layout-only\` only when debugging.` : `\u9ED8\u8BA4 \`fet visual\` \u4F1A\u66F4\u65B0\u6E05\u5355\u3001Playwright \u622A\u56FE\uFF08\u9700 \`--base-url\`\uFF09\u5E76\u505A **layout-only** \u68C0\u67E5\uFF08\u4E0D\u5BF9\u52A8\u6001\u63A5\u53E3\u5185\u5BB9\u505A\u50CF\u7D20\u5BF9\u6BD4\uFF09\u3002\u4EC5\u5728\u8C03\u8BD5\u65F6\u4F7F\u7528 \`--plan\`\u3001\`--capture-only\`\u3001\`--check-layout-only\`\u3002`;
7785
+ return `<!-- FET:MANAGED
7786
+ schemaVersion: 1
7787
+ fetVersion: ${FET_VERSION}
7788
+ generator: cursor-adapter
7789
+ adapterVersion: 1
7790
+ command: ${usage}
7791
+ FET:END -->
7792
+
7793
+ ---
7794
+ name: fet-visual
7795
+ description: ${language === "en" ? "Layout-only visual verification for a change" : "change \u7EA7 layout-only \u89C6\u89C9\u9A8C\u6536"}
7796
+ disable-model-invocation: true
7797
+ ---
7798
+
7799
+ ${renderIdeModelPolicy("visual", language)}
7800
+
7801
+ ${languageInstruction(language)}
7802
+
7803
+ \u8BF7\u5728\u7EC8\u7AEF\u4E2D\u6267\u884C\uFF1A
7804
+
7805
+ \`\`\`sh
7806
+ ${usage}
7807
+ \`\`\`
7808
+
7809
+ ${body}
7810
+ `;
7811
+ }
7812
+ function renderTddTestSkill(command, usage, language) {
7813
+ const description = command === "tdd" ? language === "en" ? "Generate per-change TDD manifest and test instructions" : "\u751F\u6210\u672C change \u7684 TDD \u6E05\u5355\u4E0E\u6D4B\u8BD5\u6307\u5F15" : language === "en" ? "Run unit tests scoped to the change TDD manifest" : "\u6309 change TDD \u6E05\u5355\u8FD0\u884C\u5355\u6D4B";
7814
+ const body = command === "tdd" ? language === "en" ? `After planning artifacts exist, run this before \`fet apply\`. It writes \`openspec/changes/<change-id>/.fet/tdd-manifest.yaml\`, \`tdd-spec.md\`, and \`tdd-instructions.md\`. Then create failing tests in the repo before implementation.` : `\u89C4\u5212\u4EA7\u7269\u5C31\u7EEA\u540E\u3001\`fet apply\` \u4E4B\u524D\u8FD0\u884C\u3002\u4F1A\u5199\u5165 \`openspec/changes/<change-id>/.fet/tdd-manifest.yaml\`\u3001\`tdd-spec.md\`\u3001\`tdd-instructions.md\`\uFF0C\u518D\u5728\u4ED3\u5E93\u4E2D\u7F16\u5199\u9884\u671F\u5931\u8D25\u7684\u6D4B\u8BD5\u3002` : language === "en" ? `Run after implementation. Requires a current \`tdd-manifest.yaml\`. Records pass/fail in FET state; \`fet verify\` is blocked until this passes (unless configured to skip).` : `\u5B9E\u73B0\u5B8C\u6210\u540E\u8FD0\u884C\u3002\u9700\u8981\u6709\u6548\u7684 \`tdd-manifest.yaml\`\u3002\u7ED3\u679C\u5199\u5165 FET \u72B6\u6001\uFF1B\u672A\u901A\u8FC7\u524D \`fet verify\` \u4F1A\u88AB\u62E6\u622A\uFF08\u9664\u975E\u914D\u7F6E\u4E3A skip\uFF09\u3002`;
7815
+ return `<!-- FET:MANAGED
7816
+ schemaVersion: 1
7817
+ fetVersion: ${FET_VERSION}
7818
+ generator: cursor-adapter
7819
+ adapterVersion: 1
7820
+ command: ${usage}
7821
+ FET:END -->
7822
+
7823
+ ---
7824
+ name: fet-${command}
7825
+ description: ${description}
7826
+ disable-model-invocation: true
7827
+ ---
7828
+
7829
+ ${renderIdeModelPolicy(command, language)}
7830
+
7831
+ ${languageInstruction(language)}
7832
+
7833
+ \u8BF7\u5728\u7EC8\u7AEF\u4E2D\u6267\u884C\uFF1A
7834
+
7835
+ \`\`\`sh
7836
+ ${usage}
7837
+ \`\`\`
7838
+
7839
+ ${body}
6213
7840
  `;
6214
7841
  }
6215
7842
 
@@ -6219,7 +7846,7 @@ var CursorAdapter = class {
6219
7846
  adapterVersion = 1;
6220
7847
  async detect(projectRoot) {
6221
7848
  return {
6222
- detected: await exists6(join22(projectRoot, ".cursor")),
7849
+ detected: await exists7(join31(projectRoot, ".cursor")),
6223
7850
  reason: "Cursor adapter is available for any project"
6224
7851
  };
6225
7852
  }
@@ -6236,7 +7863,7 @@ var CursorAdapter = class {
6236
7863
  const written = [];
6237
7864
  const skipped = [];
6238
7865
  for (const file of plan.files) {
6239
- const target = join22(projectRoot, file.path);
7866
+ const target = join31(projectRoot, file.path);
6240
7867
  const existing = await readExisting2(target);
6241
7868
  if (existing && !existing.includes("FET:MANAGED") && !force) {
6242
7869
  throw new FetError({
@@ -6249,7 +7876,7 @@ var CursorAdapter = class {
6249
7876
  if (existing && !existing.includes("FET:MANAGED") && force) {
6250
7877
  await createBackup(target);
6251
7878
  }
6252
- await mkdir9(dirname9(target), { recursive: true });
7879
+ await mkdir13(dirname12(target), { recursive: true });
6253
7880
  await atomicWrite(target, file.content);
6254
7881
  written.push(file.path);
6255
7882
  }
@@ -6259,7 +7886,7 @@ var CursorAdapter = class {
6259
7886
  const plan = await this.planInstall(projectRoot);
6260
7887
  const checks = [];
6261
7888
  for (const file of plan.files) {
6262
- const target = join22(projectRoot, file.path);
7889
+ const target = join31(projectRoot, file.path);
6263
7890
  const content = await readExisting2(target);
6264
7891
  const managed = Boolean(content?.includes("FET:MANAGED"));
6265
7892
  const versionMatches = Boolean(content?.includes(`adapterVersion: ${this.adapterVersion}`));
@@ -6275,14 +7902,14 @@ var CursorAdapter = class {
6275
7902
  };
6276
7903
  async function readExisting2(path) {
6277
7904
  try {
6278
- return await readFile17(path, "utf8");
7905
+ return await readFile23(path, "utf8");
6279
7906
  } catch {
6280
7907
  return null;
6281
7908
  }
6282
7909
  }
6283
- async function exists6(path) {
7910
+ async function exists7(path) {
6284
7911
  try {
6285
- await stat11(path);
7912
+ await stat14(path);
6286
7913
  return true;
6287
7914
  } catch {
6288
7915
  return false;
@@ -6294,45 +7921,45 @@ import { execFile as execFile4 } from "child_process";
6294
7921
  import { promisify as promisify4 } from "util";
6295
7922
 
6296
7923
  // src/openspec/inspector.ts
6297
- import { readdir as readdir5, stat as stat12 } from "fs/promises";
6298
- import { join as join23 } from "path";
7924
+ import { readdir as readdir6, stat as stat15 } from "fs/promises";
7925
+ import { join as join32 } from "path";
6299
7926
  async function inspectOpenSpecProject(projectRoot) {
6300
- const openspecPath = join23(projectRoot, "openspec");
6301
- const changesPath = join23(openspecPath, "changes");
6302
- const legacyArchivePath = join23(openspecPath, "archive");
6303
- const changesArchivePath = join23(changesPath, "archive");
7927
+ const openspecPath = join32(projectRoot, "openspec");
7928
+ const changesPath = join32(openspecPath, "changes");
7929
+ const legacyArchivePath = join32(openspecPath, "archive");
7930
+ const changesArchivePath = join32(changesPath, "archive");
6304
7931
  return {
6305
- exists: await exists7(openspecPath),
7932
+ exists: await exists8(openspecPath),
6306
7933
  changes: await listDirectories(changesPath, { exclude: ["archive"] }),
6307
7934
  archived: [.../* @__PURE__ */ new Set([...await listDirectories(legacyArchivePath), ...await listDirectories(changesArchivePath)])]
6308
7935
  };
6309
7936
  }
6310
7937
  async function inspectOpenSpecChange(projectRoot, changeId) {
6311
- const changePath = join23(projectRoot, "openspec", "changes", changeId);
6312
- const tasksPath = join23(changePath, "tasks.md");
6313
- const specsPath = join23(changePath, "specs");
7938
+ const changePath = join32(projectRoot, "openspec", "changes", changeId);
7939
+ const tasksPath = join32(changePath, "tasks.md");
7940
+ const specsPath = join32(changePath, "specs");
6314
7941
  return {
6315
7942
  changeId,
6316
- exists: await exists7(changePath),
6317
- hasProposal: await exists7(join23(changePath, "proposal.md")),
6318
- hasTasks: await exists7(tasksPath),
6319
- hasSpecs: await exists7(specsPath),
7943
+ exists: await exists8(changePath),
7944
+ hasProposal: await exists8(join32(changePath, "proposal.md")),
7945
+ hasTasks: await exists8(tasksPath),
7946
+ hasSpecs: await exists8(specsPath),
6320
7947
  tasksPath,
6321
7948
  changePath
6322
7949
  };
6323
7950
  }
6324
7951
  async function listDirectories(path, options = {}) {
6325
7952
  try {
6326
- const entries = await readdir5(path, { withFileTypes: true });
7953
+ const entries = await readdir6(path, { withFileTypes: true });
6327
7954
  const excluded = new Set(options.exclude ?? []);
6328
7955
  return entries.filter((entry) => entry.isDirectory() && !excluded.has(entry.name)).map((entry) => entry.name);
6329
7956
  } catch {
6330
7957
  return [];
6331
7958
  }
6332
7959
  }
6333
- async function exists7(path) {
7960
+ async function exists8(path) {
6334
7961
  try {
6335
- await stat12(path);
7962
+ await stat15(path);
6336
7963
  return true;
6337
7964
  } catch {
6338
7965
  return false;
@@ -6399,14 +8026,14 @@ function exec(command, args) {
6399
8026
  }
6400
8027
 
6401
8028
  // src/openspec/runner.ts
6402
- import { spawn as spawn2 } from "child_process";
8029
+ import { spawn as spawn3 } from "child_process";
6403
8030
  async function runOpenSpec(executablePath, command, args, options) {
6404
8031
  const spawnCommand = executablePath === "npx openspec" ? "npx" : executablePath;
6405
8032
  const spawnArgs = executablePath === "npx openspec" ? ["openspec", command, ...args] : [command, ...args];
6406
8033
  return new Promise((resolve2, reject) => {
6407
8034
  const stdout = [];
6408
8035
  const stderr = [];
6409
- const child = spawn2(spawnCommand, spawnArgs, {
8036
+ const child = spawn3(spawnCommand, spawnArgs, {
6410
8037
  cwd: options.cwd,
6411
8038
  stdio: options.stdio ?? "inherit",
6412
8039
  shell: process.platform === "win32"
@@ -6515,14 +8142,14 @@ function escapeRegExp(value) {
6515
8142
  }
6516
8143
 
6517
8144
  // src/scanner/routes.ts
6518
- import { readdir as readdir6, stat as stat13 } from "fs/promises";
6519
- import { join as join24, relative as relative4, sep } from "path";
8145
+ import { readdir as readdir7, stat as stat16 } from "fs/promises";
8146
+ import { join as join33, relative as relative5, sep } from "path";
6520
8147
  async function scanRoutes(projectRoot) {
6521
8148
  const candidates = ["src/routes", "src/pages", "app", "pages"];
6522
8149
  const routes = [];
6523
8150
  for (const candidate of candidates) {
6524
- const root = join24(projectRoot, candidate);
6525
- if (!await exists8(root)) {
8151
+ const root = join33(projectRoot, candidate);
8152
+ if (!await exists9(root)) {
6526
8153
  continue;
6527
8154
  }
6528
8155
  for (const file of await listFiles(root)) {
@@ -6530,8 +8157,8 @@ async function scanRoutes(projectRoot) {
6530
8157
  continue;
6531
8158
  }
6532
8159
  routes.push({
6533
- path: inferRoutePath(relative4(root, file)),
6534
- source: relative4(projectRoot, file).split(sep).join("/"),
8160
+ path: inferRoutePath(relative5(root, file)),
8161
+ source: relative5(projectRoot, file).split(sep).join("/"),
6535
8162
  inferred: true,
6536
8163
  confidence: "medium"
6537
8164
  });
@@ -6546,10 +8173,10 @@ function inferRoutePath(relativePath) {
6546
8173
  return `/${withoutIndex}`.replace(/\/+/g, "/");
6547
8174
  }
6548
8175
  async function listFiles(root) {
6549
- const entries = await readdir6(root, { withFileTypes: true });
8176
+ const entries = await readdir7(root, { withFileTypes: true });
6550
8177
  const files = [];
6551
8178
  for (const entry of entries) {
6552
- const path = join24(root, entry.name);
8179
+ const path = join33(root, entry.name);
6553
8180
  if (entry.isDirectory()) {
6554
8181
  files.push(...await listFiles(path));
6555
8182
  } else {
@@ -6558,9 +8185,9 @@ async function listFiles(root) {
6558
8185
  }
6559
8186
  return files;
6560
8187
  }
6561
- async function exists8(path) {
8188
+ async function exists9(path) {
6562
8189
  try {
6563
- await stat13(path);
8190
+ await stat16(path);
6564
8191
  return true;
6565
8192
  } catch {
6566
8193
  return false;
@@ -6717,9 +8344,9 @@ async function createCommandContext(command, options) {
6717
8344
  import { createInterface as createInterface2 } from "readline/promises";
6718
8345
 
6719
8346
  // src/update/check.ts
6720
- import { mkdir as mkdir10, readFile as readFile18, writeFile } from "fs/promises";
8347
+ import { mkdir as mkdir14, readFile as readFile24, writeFile } from "fs/promises";
6721
8348
  import { homedir as homedir2 } from "os";
6722
- import { dirname as dirname10, join as join25 } from "path";
8349
+ import { dirname as dirname13, join as join34 } from "path";
6723
8350
  var DEFAULT_CACHE_TTL_MS = 6 * 60 * 60 * 1e3;
6724
8351
  function getFetUpdateCheckMode(env = process.env) {
6725
8352
  const value = env.FET_UPDATE_CHECK?.trim().toLowerCase();
@@ -6792,11 +8419,11 @@ function formatFetUpdateWarning(availability, language) {
6792
8419
  }
6793
8420
  function cachePath() {
6794
8421
  const home = process.env.FET_UPDATE_CHECK_CACHE_HOME?.trim() || homedir2();
6795
- return join25(home, ".fet", "update-check-cache.json");
8422
+ return join34(home, ".fet", "update-check-cache.json");
6796
8423
  }
6797
8424
  async function readUpdateCheckCache() {
6798
8425
  try {
6799
- const raw = await readFile18(cachePath(), "utf8");
8426
+ const raw = await readFile24(cachePath(), "utf8");
6800
8427
  const parsed = JSON.parse(raw);
6801
8428
  if (typeof parsed.latestVersion !== "string" || typeof parsed.checkedAt !== "string") {
6802
8429
  return null;
@@ -6812,7 +8439,7 @@ async function readUpdateCheckCache() {
6812
8439
  }
6813
8440
  async function writeUpdateCheckCache(cache) {
6814
8441
  const path = cachePath();
6815
- await mkdir10(dirname10(path), { recursive: true });
8442
+ await mkdir14(dirname13(path), { recursive: true });
6816
8443
  await writeFile(path, `${JSON.stringify(cache, null, 2)}
6817
8444
  `, "utf8");
6818
8445
  }
@@ -6899,6 +8526,23 @@ for (const action of ["init", "refresh"]) {
6899
8526
  addGlobalOptions(program.command("doctor").description("\u8BCA\u65AD\u72B6\u6001\u3001\u914D\u7F6E\u4E0E\u4E00\u81F4\u6027").option("--fix-lock", "\u6E05\u7406 FET \u9501\u6587\u4EF6")).action(
6900
8527
  wrap("doctor", (ctx, options) => doctorCommand(ctx, { fixLock: Boolean(options.fixLock) }))
6901
8528
  );
8529
+ addGlobalOptions(program.command("tdd").description("\u6839\u636E\u89C4\u5212\u4EA7\u7269\u751F\u6210 change \u7EA7 TDD \u6E05\u5355\u4E0E\u6D4B\u8BD5\u6307\u5F15")).action(wrap("tdd", tddCommand));
8530
+ addGlobalOptions(program.command("test").description("\u6309 change TDD \u6E05\u5355\u8FD0\u884C\u5355\u6D4B\u5E76\u8BB0\u5F55\u7EFF\u706F\u72B6\u6001").option("--plan", "\u4EC5\u8F93\u51FA\u5C06\u6267\u884C\u7684\u6D4B\u8BD5\u547D\u4EE4\uFF0C\u4E0D\u8FD0\u884C")).action(
8531
+ wrap("test", (ctx, options) => testCommand(ctx, { plan: Boolean(options.plan) }))
8532
+ );
8533
+ addGlobalOptions(
8534
+ program.command("visual").description("layout-only \u89C6\u89C9\u9A8C\u6536\uFF08\u9ED8\u8BA4\uFF1A\u66F4\u65B0\u6E05\u5355 + \u622A\u56FE + \u5E03\u5C40\u68C0\u67E5\uFF09").option("--plan", "\u4EC5\u8F93\u51FA\u6267\u884C\u8BA1\u5212").option("--capture-only", "\u4EC5\u6267\u884C\u622A\u56FE").option("--check-layout-only", "\u4EC5\u6267\u884C\u5E03\u5C40\u68C0\u67E5\uFF08\u4F9D\u8D56\u5DF2\u6709 capture\uFF09").option("--base-url <url>", "\u5E94\u7528 base URL\uFF0C\u7528\u4E8E Playwright \u622A\u56FE")
8535
+ ).action(
8536
+ wrap(
8537
+ "visual",
8538
+ (ctx, options) => visualCommand(ctx, {
8539
+ plan: Boolean(options.plan),
8540
+ captureOnly: Boolean(options.captureOnly),
8541
+ checkLayoutOnly: Boolean(options.checkLayoutOnly),
8542
+ baseUrl: options.baseUrl
8543
+ })
8544
+ )
8545
+ );
6902
8546
  addGlobalOptions(program.command("verify").description("\u6700\u7EC8\u8D28\u91CF\u9A8C\u8BC1").option("--done", "\u58F0\u660E\u624B\u52A8\u9A8C\u8BC1\u5DF2\u5B8C\u6210").option("--auto", "\u751F\u6210\u6216\u6267\u884C\u81EA\u52A8\u9A8C\u8BC1\u8BA1\u5212")).action(
6903
8547
  wrap("verify", verifyCommand)
6904
8548
  );