@nick848/fet 1.1.9 → 1.1.10

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,11 @@ 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
+ },
2088
2093
  specLanguage: {
2089
2094
  style: "layered_bilingual",
2090
2095
  canonical: "en",
@@ -2250,6 +2255,135 @@ fet verify --done --change ${changeId}
2250
2255
  `;
2251
2256
  }
2252
2257
 
2258
+ // src/tdd/paths.ts
2259
+ function tddFetDirRelative(changeId) {
2260
+ return `openspec/changes/${changeId}/.fet`;
2261
+ }
2262
+ function tddManifestRelativePath(changeId) {
2263
+ return `${tddFetDirRelative(changeId)}/tdd-manifest.yaml`;
2264
+ }
2265
+ function tddSpecRelativePath(changeId) {
2266
+ return `${tddFetDirRelative(changeId)}/tdd-spec.md`;
2267
+ }
2268
+ function tddInstructionsRelativePath(changeId) {
2269
+ return `${tddFetDirRelative(changeId)}/tdd-instructions.md`;
2270
+ }
2271
+ function tddResultsRelativePath(changeId) {
2272
+ return `${tddFetDirRelative(changeId)}/tdd-results.json`;
2273
+ }
2274
+
2275
+ // src/templates/tdd.ts
2276
+ function renderTddInstructions(changeId, manifest, language) {
2277
+ const manifestPath2 = tddManifestRelativePath(changeId);
2278
+ const specPath = tddSpecRelativePath(changeId);
2279
+ const caseList = manifest.cases.map((item) => `- \`${item.id}\`: ${item.title} \u2192 \`${item.testFile}\` (${item.testIds.join(", ")})`).join("\n");
2280
+ if (language === "en") {
2281
+ return `---
2282
+ schemaVersion: 1
2283
+ fetVersion: ${FET_VERSION}
2284
+ changeId: ${changeId}
2285
+ purpose: tdd-instructions
2286
+ generatedAt: ${manifest.generatedAt}
2287
+ ---
2288
+
2289
+ # TDD instructions (this change)
2290
+
2291
+ Create or update unit tests **before** marking implementation tasks done in \`tasks.md\`.
2292
+
2293
+ ## Sources
2294
+ ${manifest.sources.map((s) => `- ${s}`).join("\n")}
2295
+
2296
+ ## Cases (from ${manifestPath2})
2297
+ ${caseList}
2298
+
2299
+ ## Rules
2300
+ 1. Each case must map to a real test file under the repo test tree.
2301
+ 2. Tests should fail until implementation lands (red \u2192 green).
2302
+ 3. Do not edit \`${manifestPath2}\` by hand unless fixing IDs; re-run \`fet tdd --change ${changeId}\` after planning changes.
2303
+ 4. When tests exist, run \`fet test --change ${changeId}\` before \`fet verify\`.
2304
+
2305
+ Human-readable matrix: \`${specPath}\`
2306
+ `;
2307
+ }
2308
+ return `---
2309
+ schemaVersion: 1
2310
+ fetVersion: ${FET_VERSION}
2311
+ changeId: ${changeId}
2312
+ purpose: tdd-instructions
2313
+ generatedAt: ${manifest.generatedAt}
2314
+ ---
2315
+
2316
+ # TDD \u6307\u4EE4\uFF08\u672C change\uFF09
2317
+
2318
+ \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
2319
+
2320
+ ## \u6765\u6E90
2321
+ ${manifest.sources.map((s) => `- ${s}`).join("\n")}
2322
+
2323
+ ## \u7528\u4F8B\uFF08\u89C1 ${manifestPath2}\uFF09
2324
+ ${caseList}
2325
+
2326
+ ## \u89C4\u5219
2327
+ 1. \u6BCF\u4E2A\u7528\u4F8B\u5FC5\u987B\u5BF9\u5E94\u4ED3\u5E93\u5185\u771F\u5B9E\u6D4B\u8BD5\u6587\u4EF6\u3002
2328
+ 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
2329
+ 3. \u9664\u975E\u4FEE\u6B63 ID\uFF0C\u4E0D\u8981\u624B\u6539 \`${manifestPath2}\`\uFF1B\u89C4\u5212\u53D8\u66F4\u540E\u8BF7\u91CD\u65B0\u6267\u884C \`fet tdd --change ${changeId}\`\u3002
2330
+ 4. \u6D4B\u8BD5\u5C31\u7EEA\u540E\u5148 \`fet test --change ${changeId}\`\uFF0C\u518D \`fet verify\`\u3002
2331
+
2332
+ \u53EF\u8BFB\u77E9\u9635\u89C1 \`${specPath}\`
2333
+ `;
2334
+ }
2335
+ function renderTddSpec(changeId, manifest, language) {
2336
+ const rows = manifest.cases.map((item) => renderSpecRow(item, language)).join("\n");
2337
+ if (language === "en") {
2338
+ return `---
2339
+ schemaVersion: 1
2340
+ changeId: ${changeId}
2341
+ generatedAt: ${manifest.generatedAt}
2342
+ planningFingerprint: ${manifest.planningFingerprint}
2343
+ ---
2344
+
2345
+ # TDD case matrix
2346
+
2347
+ | ID | Scenario | Spec reference | Test file | Required |
2348
+ |----|----------|----------------|-----------|----------|
2349
+ ${rows}
2350
+ `;
2351
+ }
2352
+ return `---
2353
+ schemaVersion: 1
2354
+ changeId: ${changeId}
2355
+ generatedAt: ${manifest.generatedAt}
2356
+ planningFingerprint: ${manifest.planningFingerprint}
2357
+ ---
2358
+
2359
+ # TDD \u7528\u4F8B\u77E9\u9635
2360
+
2361
+ | ID | \u573A\u666F | Spec \u5F15\u7528 | \u6D4B\u8BD5\u6587\u4EF6 | \u5FC5\u9700 |
2362
+ |----|------|-----------|----------|------|
2363
+ ${rows}
2364
+ `;
2365
+ }
2366
+ function renderSpecRow(item, language) {
2367
+ const required = language === "en" ? item.required ? "yes" : "no" : item.required ? "\u662F" : "\u5426";
2368
+ return `| ${item.id} | ${escapeTable(item.title)} | ${escapeTable(item.specRef)} | \`${item.testFile}\` | ${required} |`;
2369
+ }
2370
+ function escapeTable(value) {
2371
+ return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
2372
+ }
2373
+ function renderTddApplyNextSteps(changeId, language) {
2374
+ const manifestPath2 = tddManifestRelativePath(changeId);
2375
+ if (language === "en") {
2376
+ return [
2377
+ `Read ${manifestPath2} and tdd-instructions.md; implement code until fet test passes for this change.`,
2378
+ `Run fet test --change ${changeId} before fet verify.`
2379
+ ];
2380
+ }
2381
+ return [
2382
+ `\u9605\u8BFB ${manifestPath2} \u4E0E tdd-instructions.md\uFF1B\u5B9E\u73B0\u4EE3\u7801\u76F4\u81F3\u672C change \u7684 fet test \u901A\u8FC7\u3002`,
2383
+ `\u5728 fet verify \u4E4B\u524D\u5148\u6267\u884C fet test --change ${changeId}\u3002`
2384
+ ];
2385
+ }
2386
+
2253
2387
  // src/templates/figma-guard.ts
2254
2388
  var FIGMA_URL_PATTERN = /https?:\/\/(?:www\.)?figma\.com\/(?:file|design|proto)\/[^\s)\]"'<>]+/gi;
2255
2389
  function figmaStopHandoffRelativePath(changeId) {
@@ -2889,8 +3023,8 @@ async function exists4(path) {
2889
3023
  }
2890
3024
 
2891
3025
  // src/commands/proxy.ts
2892
- import { readFile as readFile14 } from "fs/promises";
2893
- import { join as join19 } from "path";
3026
+ import { readFile as readFile17 } from "fs/promises";
3027
+ import { join as join22 } from "path";
2894
3028
 
2895
3029
  // src/figma-guard.ts
2896
3030
  import { readdir as readdir3, readFile as readFile10, stat as stat7 } from "fs/promises";
@@ -3339,6 +3473,259 @@ async function readOptional4(path) {
3339
3473
  }
3340
3474
  }
3341
3475
 
3476
+ // src/commands/change-id.ts
3477
+ function toKebabId(value) {
3478
+ return value.trim().toLowerCase().replace(/['"]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3479
+ }
3480
+ function msg(language, zh, en) {
3481
+ return language === "en" ? en : zh;
3482
+ }
3483
+ async function resolveChangeId(ctx) {
3484
+ if (ctx.changeId) {
3485
+ return ctx.changeId;
3486
+ }
3487
+ const global = await ctx.stateStore.getOrCreateGlobal();
3488
+ if (global.activeChangeId) {
3489
+ return global.activeChangeId;
3490
+ }
3491
+ const inspection = await ctx.openSpec.inspectProject(ctx.projectRoot);
3492
+ if (inspection.changes.length === 1 && inspection.changes[0]) {
3493
+ return inspection.changes[0];
3494
+ }
3495
+ throw new FetError({
3496
+ code: "INVALID_ARGUMENTS" /* InvalidArguments */,
3497
+ message: msg(ctx.language, "\u65E0\u6CD5\u786E\u5B9A\u76EE\u6807 change", "Cannot determine which change to use"),
3498
+ details: { openChangeIds: inspection.changes },
3499
+ suggestedCommand: "fet <command> --change <change-id>"
3500
+ });
3501
+ }
3502
+ async function assertChangeExists(ctx, changeId) {
3503
+ const inspection = await ctx.openSpec.inspectChange(ctx.projectRoot, changeId);
3504
+ if (!inspection.exists) {
3505
+ throw new FetError({
3506
+ code: "INVALID_ARGUMENTS" /* InvalidArguments */,
3507
+ message: msg(ctx.language, "\u6307\u5B9A\u7684 change \u4E0D\u5B58\u5728", "The specified change does not exist"),
3508
+ details: { changeId },
3509
+ suggestedCommand: `fet doctor`
3510
+ });
3511
+ }
3512
+ }
3513
+
3514
+ // src/tdd/config.ts
3515
+ import { readFile as readFile12 } from "fs/promises";
3516
+ import { join as join18 } from "path";
3517
+ import { parseDocument as parseDocument4 } from "yaml";
3518
+ var DEFAULT_CONFIG3 = {
3519
+ enabled: true,
3520
+ mode: "require_before_apply",
3521
+ whenNoTestScript: "block"
3522
+ };
3523
+ async function loadTddConfig(projectRoot) {
3524
+ try {
3525
+ const raw = await readFile12(join18(projectRoot, "openspec", "config.yaml"), "utf8");
3526
+ const doc = parseDocument4(raw);
3527
+ const fetNode = doc.get("fet", true);
3528
+ const node = fetNode?.get?.("tdd");
3529
+ if (!node || typeof node.get !== "function") {
3530
+ return DEFAULT_CONFIG3;
3531
+ }
3532
+ const enabled = node.get("enabled");
3533
+ const modeRaw = node.get("mode");
3534
+ const whenNoTestScriptRaw = node.get("whenNoTestScript");
3535
+ return {
3536
+ enabled: enabled === void 0 ? true : Boolean(enabled),
3537
+ mode: parseGateMode(modeRaw),
3538
+ whenNoTestScript: parseWhenNoTestScript(whenNoTestScriptRaw)
3539
+ };
3540
+ } catch {
3541
+ return DEFAULT_CONFIG3;
3542
+ }
3543
+ }
3544
+ function parseGateMode(value) {
3545
+ if (value === "off" || value === "optional" || value === "require_before_apply") {
3546
+ return value;
3547
+ }
3548
+ return DEFAULT_CONFIG3.mode;
3549
+ }
3550
+ function parseWhenNoTestScript(value) {
3551
+ if (value === "warn" || value === "skip") {
3552
+ return value;
3553
+ }
3554
+ return DEFAULT_CONFIG3.whenNoTestScript;
3555
+ }
3556
+ function isTddRequired(config) {
3557
+ return config.enabled && config.mode === "require_before_apply";
3558
+ }
3559
+
3560
+ // src/tdd/fingerprint.ts
3561
+ import { createHash } from "crypto";
3562
+ import { readdir as readdir5, readFile as readFile13, stat as stat9 } from "fs/promises";
3563
+ import { join as join19, relative as relative4 } from "path";
3564
+ async function collectPlanningSources(projectRoot, changeId) {
3565
+ const changeRoot = join19(projectRoot, "openspec", "changes", changeId);
3566
+ const sources = [];
3567
+ const rootFiles = ["proposal.md", "tasks.md", "design.md"];
3568
+ for (const name of rootFiles) {
3569
+ const path = join19(changeRoot, name);
3570
+ if (await exists5(path)) {
3571
+ sources.push(relative4(projectRoot, path).replace(/\\/g, "/"));
3572
+ }
3573
+ }
3574
+ const specsDir = join19(changeRoot, "specs");
3575
+ if (await exists5(specsDir)) {
3576
+ for (const file of await walkFiles(specsDir)) {
3577
+ if (file.endsWith(".md")) {
3578
+ sources.push(relative4(projectRoot, file).replace(/\\/g, "/"));
3579
+ }
3580
+ }
3581
+ }
3582
+ return sources.sort();
3583
+ }
3584
+ async function computePlanningFingerprint(projectRoot, changeId) {
3585
+ const sources = await collectPlanningSources(projectRoot, changeId);
3586
+ const hash = createHash("sha256");
3587
+ for (const source of sources) {
3588
+ const content = await readFile13(join19(projectRoot, source), "utf8");
3589
+ hash.update(source);
3590
+ hash.update("\0");
3591
+ hash.update(content);
3592
+ hash.update("\0");
3593
+ }
3594
+ return `sha256:${hash.digest("hex")}`;
3595
+ }
3596
+ async function walkFiles(dir) {
3597
+ const entries = await readdir5(dir, { withFileTypes: true });
3598
+ const files = [];
3599
+ for (const entry of entries) {
3600
+ const path = join19(dir, entry.name);
3601
+ if (entry.isDirectory()) {
3602
+ files.push(...await walkFiles(path));
3603
+ } else if (entry.isFile()) {
3604
+ files.push(path);
3605
+ }
3606
+ }
3607
+ return files;
3608
+ }
3609
+ async function exists5(path) {
3610
+ try {
3611
+ await stat9(path);
3612
+ return true;
3613
+ } catch {
3614
+ return false;
3615
+ }
3616
+ }
3617
+
3618
+ // src/tdd/manifest.ts
3619
+ import { mkdir as mkdir6, readFile as readFile14, stat as stat10 } from "fs/promises";
3620
+ import { dirname as dirname8, join as join20 } from "path";
3621
+ import { parse as parse4, stringify as stringify3 } from "yaml";
3622
+ function tddManifestPath(projectRoot, changeId) {
3623
+ return join20(projectRoot, tddManifestRelativePath(changeId));
3624
+ }
3625
+ async function readTddManifest(projectRoot, changeId) {
3626
+ const path = tddManifestPath(projectRoot, changeId);
3627
+ try {
3628
+ await stat10(path);
3629
+ } catch {
3630
+ return null;
3631
+ }
3632
+ const doc = parse4(await readFile14(path, "utf8"));
3633
+ if (!doc || doc.schemaVersion !== 1 || doc.changeId !== changeId) {
3634
+ return null;
3635
+ }
3636
+ return doc;
3637
+ }
3638
+ async function writeTddManifest(projectRoot, manifest) {
3639
+ const relative6 = tddManifestRelativePath(manifest.changeId);
3640
+ const path = join20(projectRoot, relative6);
3641
+ await mkdir6(dirname8(path), { recursive: true });
3642
+ await atomicWrite(path, stringify3(manifest));
3643
+ return relative6;
3644
+ }
3645
+ async function writeTddResults(projectRoot, results) {
3646
+ const relative6 = tddResultsRelativePath(results.changeId);
3647
+ const path = join20(projectRoot, relative6);
3648
+ await mkdir6(dirname8(path), { recursive: true });
3649
+ await atomicWrite(path, `${JSON.stringify(results, null, 2)}
3650
+ `);
3651
+ return relative6;
3652
+ }
3653
+ function createTddManifest(input) {
3654
+ return {
3655
+ schemaVersion: 1,
3656
+ changeId: input.changeId,
3657
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3658
+ fetVersion: FET_VERSION,
3659
+ planningFingerprint: input.planningFingerprint,
3660
+ sources: input.sources,
3661
+ cases: input.cases,
3662
+ run: {
3663
+ mode: input.cases.length ? "manifest" : "workspace",
3664
+ fallbackCommand: input.testCommand
3665
+ }
3666
+ };
3667
+ }
3668
+
3669
+ // src/tdd/gates.ts
3670
+ async function assertTddReady(ctx, changeId) {
3671
+ const config = await loadTddConfig(ctx.projectRoot);
3672
+ if (!isTddRequired(config)) {
3673
+ return;
3674
+ }
3675
+ const manifest = await readTddManifest(ctx.projectRoot, changeId);
3676
+ if (!manifest) {
3677
+ throw new FetError({
3678
+ code: "STATE_CORRUPTED" /* StateCorrupted */,
3679
+ message: msg(ctx.language, "\u7F3A\u5C11 TDD \u4EA7\u7269\uFF0C\u65E0\u6CD5\u8FDB\u5165 apply\u3002", "TDD artifacts are missing; cannot run apply."),
3680
+ details: { changeId, expected: tddManifestRelativePath(changeId) },
3681
+ suggestedCommand: `fet tdd --change ${changeId}`,
3682
+ recoverable: true
3683
+ });
3684
+ }
3685
+ const fingerprint2 = await computePlanningFingerprint(ctx.projectRoot, changeId);
3686
+ if (manifest.planningFingerprint !== fingerprint2) {
3687
+ throw new FetError({
3688
+ code: "STATE_CORRUPTED" /* StateCorrupted */,
3689
+ message: msg(
3690
+ ctx.language,
3691
+ "\u89C4\u5212\u4EA7\u7269\u5DF2\u53D8\u66F4\uFF0CTDD \u6E05\u5355\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u3002",
3692
+ "Planning artifacts changed; TDD manifest is stale. Regenerate it."
3693
+ ),
3694
+ details: { changeId, manifestFingerprint: manifest.planningFingerprint, currentFingerprint: fingerprint2 },
3695
+ suggestedCommand: `fet tdd --change ${changeId}`,
3696
+ recoverable: true
3697
+ });
3698
+ }
3699
+ }
3700
+ async function assertTestPassed(ctx, changeId) {
3701
+ const config = await loadTddConfig(ctx.projectRoot);
3702
+ if (!config.enabled || config.mode === "off") {
3703
+ return;
3704
+ }
3705
+ const change = await ctx.stateStore.readChange(changeId);
3706
+ const testRun = change?.testRun;
3707
+ if (testRun?.status === "skipped") {
3708
+ return;
3709
+ }
3710
+ if (testRun?.status === "passed" && await fingerprintMatches(ctx, changeId, testRun)) {
3711
+ return;
3712
+ }
3713
+ throw new FetError({
3714
+ code: "STATE_CORRUPTED" /* StateCorrupted */,
3715
+ message: msg(ctx.language, "\u672C change \u5C1A\u672A\u901A\u8FC7 fet test\u3002", "This change has not passed fet test yet."),
3716
+ details: { changeId, testRun: testRun ?? null },
3717
+ suggestedCommand: `fet test --change ${changeId}`,
3718
+ recoverable: true
3719
+ });
3720
+ }
3721
+ async function fingerprintMatches(ctx, changeId, testRun) {
3722
+ const current = await computePlanningFingerprint(ctx.projectRoot, changeId);
3723
+ return testRun.planningFingerprint === current;
3724
+ }
3725
+ function invalidateTestRun(state) {
3726
+ state.testRun = null;
3727
+ }
3728
+
3342
3729
  // src/state/project.ts
3343
3730
  import { execFile as execFile2 } from "child_process";
3344
3731
  import { promisify as promisify2 } from "util";
@@ -3366,8 +3753,8 @@ async function git(cwd, args) {
3366
3753
  }
3367
3754
 
3368
3755
  // src/state/store.ts
3369
- import { mkdir as mkdir6, readFile as readFile12 } from "fs/promises";
3370
- import { join as join18 } from "path";
3756
+ import { mkdir as mkdir7, readFile as readFile15 } from "fs/promises";
3757
+ import { join as join21 } from "path";
3371
3758
 
3372
3759
  // src/language.ts
3373
3760
  var DEFAULT_LANGUAGE = "zh-CN";
@@ -3429,6 +3816,8 @@ function createChangeState(fetVersion, changeId, phase) {
3429
3816
  lastSyncedAt: null
3430
3817
  },
3431
3818
  manualVerify: null,
3819
+ tdd: null,
3820
+ testRun: null,
3432
3821
  lastOpenSpecCommand: null,
3433
3822
  warnings: []
3434
3823
  };
@@ -3485,7 +3874,7 @@ var StateStore = class {
3485
3874
  project;
3486
3875
  async readGlobal() {
3487
3876
  try {
3488
- const value = JSON.parse(await readFile12(this.globalPath(), "utf8"));
3877
+ const value = JSON.parse(await readFile15(this.globalPath(), "utf8"));
3489
3878
  assertGlobalState(value);
3490
3879
  return value;
3491
3880
  } catch (error) {
@@ -3500,13 +3889,13 @@ var StateStore = class {
3500
3889
  }
3501
3890
  async writeGlobal(state) {
3502
3891
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3503
- await mkdir6(join18(this.projectRoot, "openspec"), { recursive: true });
3892
+ await mkdir7(join21(this.projectRoot, "openspec"), { recursive: true });
3504
3893
  await atomicWrite(this.globalPath(), `${JSON.stringify(state, null, 2)}
3505
3894
  `);
3506
3895
  }
3507
3896
  async readChange(changeId) {
3508
3897
  try {
3509
- const value = JSON.parse(await readFile12(this.changePath(changeId), "utf8"));
3898
+ const value = JSON.parse(await readFile15(this.changePath(changeId), "utf8"));
3510
3899
  assertChangeState(value);
3511
3900
  return value;
3512
3901
  } catch (error) {
@@ -3521,15 +3910,15 @@ var StateStore = class {
3521
3910
  }
3522
3911
  async writeChange(state) {
3523
3912
  state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3524
- await mkdir6(join18(this.projectRoot, "openspec", "changes", state.changeId), { recursive: true });
3913
+ await mkdir7(join21(this.projectRoot, "openspec", "changes", state.changeId), { recursive: true });
3525
3914
  await atomicWrite(this.changePath(state.changeId), `${JSON.stringify(state, null, 2)}
3526
3915
  `);
3527
3916
  }
3528
3917
  globalPath() {
3529
- return join18(this.projectRoot, "openspec", "fet-state.json");
3918
+ return join21(this.projectRoot, "openspec", "fet-state.json");
3530
3919
  }
3531
3920
  changePath(changeId) {
3532
- return join18(this.projectRoot, "openspec", "changes", changeId, "fet-state.json");
3921
+ return join21(this.projectRoot, "openspec", "changes", changeId, "fet-state.json");
3533
3922
  }
3534
3923
  };
3535
3924
  function isNotFound(error) {
@@ -3537,11 +3926,11 @@ function isNotFound(error) {
3537
3926
  }
3538
3927
 
3539
3928
  // src/state/tasks.ts
3540
- import { readFile as readFile13 } from "fs/promises";
3929
+ import { readFile as readFile16 } from "fs/promises";
3541
3930
  async function readCompletedTaskIds(tasksPath) {
3542
3931
  let content;
3543
3932
  try {
3544
- content = await readFile13(tasksPath, "utf8");
3933
+ content = await readFile16(tasksPath, "utf8");
3545
3934
  } catch {
3546
3935
  return [];
3547
3936
  }
@@ -3675,6 +4064,7 @@ async function applyWorkflowCommand(ctx, args) {
3675
4064
  await withProjectLock(ctx.projectRoot, { command: "apply", cwd: ctx.cwd, fetVersion: ctx.fetVersion }, async () => {
3676
4065
  await assertOpenSpecCommandSupported(ctx, "status", "apply");
3677
4066
  await assertOpenSpecCommandSupported(ctx, "instructions", "apply");
4067
+ await assertTddReady(ctx, changeId);
3678
4068
  runState.graphContext = await buildWorkflowGraphContext(ctx, {
3679
4069
  command: "apply",
3680
4070
  args: ["tasks", "--change", changeId],
@@ -3706,6 +4096,7 @@ async function applyWorkflowCommand(ctx, args) {
3706
4096
  const applyNextSteps = [
3707
4097
  `Read openspec/changes/${changeId}/tasks.md and the instructions output.`,
3708
4098
  "Implement pending tasks and update task checkboxes only after the work is done.",
4099
+ ...renderTddApplyNextSteps(changeId, ctx.language),
3709
4100
  `Run fet verify --change ${changeId}`
3710
4101
  ];
3711
4102
  if (uiContract) {
@@ -3850,7 +4241,7 @@ async function onboardWorkflowCommand(ctx) {
3850
4241
  summary: "fet onboard loaded local FET/OpenSpec workflow context.",
3851
4242
  nextSteps: [
3852
4243
  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."
4244
+ "Use fet continue to prepare planning artifacts, fet tdd then fet apply for implementation, fet test then fet verify before archive."
3854
4245
  ],
3855
4246
  data: { activeChangeId: state.activeChangeId, openChangeIds: inspection.changes, archivedChangeIds: inspection.archived }
3856
4247
  });
@@ -3895,7 +4286,7 @@ async function artifactWorkflowCommand(ctx, command, args) {
3895
4286
  `Create or update openspec/changes/${changeId}/${resolveOutputPath(status, artifactId)}`,
3896
4287
  "Review the artifact with the user before generating the next planning file.",
3897
4288
  `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`
4289
+ status.isComplete ? `Run fet tdd --change ${changeId}, then fet apply --change ${changeId}` : `Run fet continue --change ${changeId} when ready for the next artifact`
3899
4290
  ];
3900
4291
  if (uiContract) {
3901
4292
  planningNextSteps.unshift(
@@ -3937,7 +4328,7 @@ async function ensureProposedChange(ctx, args) {
3937
4328
  });
3938
4329
  }
3939
4330
  const input = args.join(" ").trim();
3940
- const changeId = isKebabId(input) ? input : toKebabId(input);
4331
+ const changeId = isKebabId(input) ? input : toKebabId2(input);
3941
4332
  if (!changeId) {
3942
4333
  throw new FetError({
3943
4334
  code: "INVALID_ARGUMENTS" /* InvalidArguments */,
@@ -4087,7 +4478,7 @@ function resolveOutputPath(status, artifactId) {
4087
4478
  function isKebabId(value) {
4088
4479
  return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
4089
4480
  }
4090
- function toKebabId(value) {
4481
+ function toKebabId2(value) {
4091
4482
  return value.trim().toLowerCase().replace(/['"]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
4092
4483
  }
4093
4484
  function parseOpenSpecJson(stdout) {
@@ -4124,7 +4515,7 @@ async function createChangelogEntry(projectRoot, changeId) {
4124
4515
  };
4125
4516
  }
4126
4517
  async function appendChangelog(projectRoot, entry) {
4127
- const changelogPath = join19(projectRoot, "CHANGELOG.md");
4518
+ const changelogPath = join22(projectRoot, "CHANGELOG.md");
4128
4519
  const existing = await readOptional5(changelogPath);
4129
4520
  const legacyContentLabel = "\u66F4\u65B0\u5185\u5BB9";
4130
4521
  const block = `updateTime: ${entry.updateTime}
@@ -4137,12 +4528,12 @@ ${block}` : block;
4137
4528
  await atomicWrite(changelogPath, next);
4138
4529
  }
4139
4530
  async function readChangeRequirement(projectRoot, changeId) {
4140
- const changeRoot = join19(projectRoot, "openspec", "changes", changeId);
4141
- const proposal = await readOptional5(join19(changeRoot, "proposal.md"));
4531
+ const changeRoot = join22(projectRoot, "openspec", "changes", changeId);
4532
+ const proposal = await readOptional5(join22(changeRoot, "proposal.md"));
4142
4533
  if (proposal) {
4143
4534
  return summarizeMarkdown(proposal);
4144
4535
  }
4145
- const readme = await readOptional5(join19(changeRoot, "README.md"));
4536
+ const readme = await readOptional5(join22(changeRoot, "README.md"));
4146
4537
  if (readme) {
4147
4538
  return summarizeMarkdown(readme);
4148
4539
  }
@@ -4154,7 +4545,7 @@ function summarizeMarkdown(content) {
4154
4545
  }
4155
4546
  async function readOptional5(path) {
4156
4547
  try {
4157
- return await readFile14(path, "utf8");
4548
+ return await readFile17(path, "utf8");
4158
4549
  } catch {
4159
4550
  return null;
4160
4551
  }
@@ -4508,13 +4899,402 @@ async function updateCommand(ctx) {
4508
4899
  });
4509
4900
  }
4510
4901
 
4511
- // 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;
4902
+ // src/commands/tdd.ts
4903
+ import { mkdir as mkdir8 } from "fs/promises";
4904
+ import { join as join24 } from "path";
4905
+
4906
+ // src/tdd/extract-cases.ts
4907
+ import { readFile as readFile18 } from "fs/promises";
4908
+ import { join as join23 } from "path";
4909
+ async function extractCasesFromChange(projectRoot, changeId, sources) {
4910
+ const cases = [];
4911
+ const seen = /* @__PURE__ */ new Set();
4912
+ for (const source of sources) {
4913
+ const content = await readFile18(join23(projectRoot, source), "utf8");
4914
+ if (source.endsWith("tasks.md")) {
4915
+ for (const item of extractTaskCases(content, changeId)) {
4916
+ if (!seen.has(item.id)) {
4917
+ seen.add(item.id);
4918
+ cases.push({ ...item, specRef: `${source} \u2014 ${item.specRef}` });
4919
+ }
4920
+ }
4921
+ }
4922
+ if (source.includes("/specs/") && source.endsWith(".md")) {
4923
+ for (const item of extractScenarioCases(content, changeId, source)) {
4924
+ if (!seen.has(item.id)) {
4925
+ seen.add(item.id);
4926
+ cases.push(item);
4927
+ }
4928
+ }
4929
+ }
4930
+ }
4931
+ if (!cases.length) {
4932
+ cases.push({
4933
+ id: `${changeId}-smoke`,
4934
+ title: "Change smoke test",
4935
+ specRef: sources[0] ?? `openspec/changes/${changeId}/`,
4936
+ testFile: `tests/changes/${changeId}.test.ts`,
4937
+ testIds: [`${changeId}-smoke`],
4938
+ required: true
4939
+ });
4940
+ }
4941
+ return cases;
4942
+ }
4943
+ function extractTaskCases(content, changeId) {
4944
+ const cases = [];
4945
+ const lines = content.split(/\r?\n/);
4946
+ for (const line of lines) {
4947
+ const numbered = line.match(/^\s*[-*]\s+\[[ xX]?\]\s+((?:\d+(?:\.\d+)*\.?)?)\s*(.+)$/);
4948
+ if (numbered) {
4949
+ const taskId = numbered[1] || String(cases.length + 1);
4950
+ const title = numbered[2]?.trim() ?? taskId;
4951
+ const id = toKebabId(`${changeId}-task-${taskId}`) || `${changeId}-task-${cases.length + 1}`;
4952
+ cases.push({
4953
+ id,
4954
+ title,
4955
+ specRef: title,
4956
+ testFile: suggestTestFile(changeId, id),
4957
+ testIds: [id],
4958
+ required: true
4959
+ });
4960
+ continue;
4961
+ }
4962
+ const plain = line.match(/^\s*[-*]\s+\[[ xX]?\]\s+(.+)$/);
4963
+ if (plain?.[1]) {
4964
+ const title = plain[1].trim();
4965
+ const id = toKebabId(`${changeId}-${title}`) || `${changeId}-task-${cases.length + 1}`;
4966
+ cases.push({
4967
+ id,
4968
+ title,
4969
+ specRef: title,
4970
+ testFile: suggestTestFile(changeId, id),
4971
+ testIds: [id],
4972
+ required: true
4973
+ });
4974
+ }
4975
+ }
4976
+ return cases;
4977
+ }
4978
+ function extractScenarioCases(content, changeId, source) {
4979
+ const cases = [];
4980
+ const lines = content.split(/\r?\n/);
4981
+ let currentRequirement = "";
4982
+ for (const line of lines) {
4983
+ const req = line.match(/^#{2,4}\s+Requirement:\s*(.+)$/i);
4984
+ if (req?.[1]) {
4985
+ currentRequirement = req[1].trim();
4986
+ }
4987
+ const scenario = line.match(/^#{2,4}\s+Scenario:\s*(.+)$/i);
4988
+ if (scenario?.[1]) {
4989
+ const title = scenario[1].trim();
4990
+ const id = toKebabId(`${changeId}-${title}`) || `${changeId}-scenario-${cases.length + 1}`;
4991
+ cases.push({
4992
+ id,
4993
+ title,
4994
+ specRef: currentRequirement ? `Requirement: ${currentRequirement} \u2014 Scenario: ${title}` : `Scenario: ${title}`,
4995
+ testFile: suggestTestFile(changeId, id),
4996
+ testIds: [id],
4997
+ required: true
4998
+ });
4999
+ }
5000
+ }
5001
+ if (!cases.length && content.trim()) {
5002
+ const id = `${changeId}-spec-smoke`;
5003
+ cases.push({
5004
+ id,
5005
+ title: `Spec coverage for ${source}`,
5006
+ specRef: source,
5007
+ testFile: suggestTestFile(changeId, id),
5008
+ testIds: [id],
5009
+ required: true
5010
+ });
5011
+ }
5012
+ return cases;
5013
+ }
5014
+ function suggestTestFile(changeId, caseId) {
5015
+ const segment = caseId.replace(new RegExp(`^${changeId}-?`), "") || "suite";
5016
+ return `tests/changes/${changeId}/${segment}.test.ts`;
5017
+ }
5018
+
5019
+ // src/commands/tdd.ts
5020
+ async function tddCommand(ctx) {
5021
+ await withProjectLock(ctx.projectRoot, { command: "tdd", cwd: ctx.cwd, fetVersion: ctx.fetVersion }, async () => {
5022
+ const changeId = await resolveChangeId(ctx);
5023
+ await assertChangeExists(ctx, changeId);
5024
+ const config = await loadTddConfig(ctx.projectRoot);
5025
+ const sources = await collectPlanningSources(ctx.projectRoot, changeId);
5026
+ if (!sources.length) {
5027
+ throw new FetError({
5028
+ code: "INVALID_ARGUMENTS" /* InvalidArguments */,
5029
+ 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."),
5030
+ details: { changeId },
5031
+ suggestedCommand: `fet continue --change ${changeId}`,
5032
+ recoverable: true
5033
+ });
5034
+ }
5035
+ const planningFingerprint = await computePlanningFingerprint(ctx.projectRoot, changeId);
5036
+ const cases = await extractCasesFromChange(ctx.projectRoot, changeId, sources);
5037
+ const scan = await ctx.scanner.scan(ctx.projectRoot, {});
5038
+ const testCommand2 = scan.commands.test?.command ?? scan.commands["test:unit"]?.command ?? null;
5039
+ const manifest = createTddManifest({
5040
+ changeId,
5041
+ planningFingerprint,
5042
+ sources,
5043
+ cases,
5044
+ testCommand: testCommand2
5045
+ });
5046
+ const manifestPath2 = await writeTddManifest(ctx.projectRoot, manifest);
5047
+ const fetDir = join24(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
5048
+ await mkdir8(fetDir, { recursive: true });
5049
+ const instructionsPath = tddInstructionsRelativePath(changeId);
5050
+ const specPath = tddSpecRelativePath(changeId);
5051
+ await atomicWrite(join24(ctx.projectRoot, instructionsPath), renderTddInstructions(changeId, manifest, ctx.language));
5052
+ await atomicWrite(join24(ctx.projectRoot, specPath), renderTddSpec(changeId, manifest, ctx.language));
5053
+ const changeState = await ctx.stateStore.getOrCreateChange(changeId, "implement");
5054
+ changeState.tdd = {
5055
+ status: "ready",
5056
+ generatedAt: manifest.generatedAt,
5057
+ planningFingerprint,
5058
+ manifestPath: manifestPath2
5059
+ };
5060
+ invalidateTestRun(changeState);
5061
+ changeState.currentPhase = "implement";
5062
+ changeState.phases.implement = { status: "in_progress", updatedAt: manifest.generatedAt };
5063
+ await ctx.stateStore.writeChange(changeState);
5064
+ const global = await ctx.stateStore.getOrCreateGlobal();
5065
+ global.activeChangeId = changeId;
5066
+ await ctx.stateStore.writeGlobal(global);
5067
+ ctx.output.result({
5068
+ ok: true,
5069
+ command: "tdd",
5070
+ summary: msg(
5071
+ ctx.language,
5072
+ `\u5DF2\u4E3A change "${changeId}" \u751F\u6210 TDD \u4EA7\u7269\uFF08${cases.length} \u4E2A\u7528\u4F8B\uFF09\u3002`,
5073
+ `Generated TDD artifacts for change "${changeId}" (${cases.length} case(s)).`
5074
+ ),
5075
+ warnings: !testCommand2 && config.whenNoTestScript !== "skip" ? [
5076
+ msg(
5077
+ ctx.language,
5078
+ "\u9879\u76EE\u672A\u914D\u7F6E test \u811A\u672C\uFF1B\u5B9E\u73B0\u540E\u53EF\u80FD\u65E0\u6CD5\u8FD0\u884C fet test\u3002",
5079
+ "No test script found in the project; fet test may fail after implementation."
5080
+ )
5081
+ ] : void 0,
5082
+ nextSteps: [
5083
+ 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.`),
5084
+ msg(ctx.language, `\u5B8C\u6210\u540E\u8FD0\u884C fet apply --change ${changeId}`, `Then run fet apply --change ${changeId}`),
5085
+ msg(ctx.language, `\u5B9E\u73B0\u540E\u8FD0\u884C fet test --change ${changeId}`, `After implementation, run fet test --change ${changeId}`)
5086
+ ],
5087
+ data: {
5088
+ changeId,
5089
+ manifestPath: manifestPath2,
5090
+ specPath,
5091
+ instructionsPath,
5092
+ caseCount: cases.length,
5093
+ planningFingerprint,
5094
+ testCommand: testCommand2
5095
+ }
5096
+ });
5097
+ });
5098
+ }
5099
+
5100
+ // src/commands/run-script.ts
5101
+ import { spawn as spawn2 } from "child_process";
5102
+ async function runShellCommand(commandLine, cwd, extraArgs = []) {
5103
+ const parts = splitCommandLine(commandLine);
5104
+ const executable = parts[0];
5105
+ if (!executable) {
5106
+ throw new Error("Empty command");
5107
+ }
5108
+ const args = [...parts.slice(1), ...extraArgs];
5109
+ return runProcess(executable, args, cwd, commandLine);
4517
5110
  }
5111
+ async function runProcess(executable, args, cwd, label = executable) {
5112
+ return new Promise((resolve2) => {
5113
+ const stdout = [];
5114
+ const stderr = [];
5115
+ const child = spawn2(executable, args, {
5116
+ cwd,
5117
+ shell: process.platform === "win32",
5118
+ env: process.env
5119
+ });
5120
+ child.stdout?.on("data", (chunk) => stdout.push(chunk));
5121
+ child.stderr?.on("data", (chunk) => stderr.push(chunk));
5122
+ child.on("close", (code, signal) => {
5123
+ resolve2({
5124
+ command: label,
5125
+ args,
5126
+ exitCode: code ?? 1,
5127
+ signal,
5128
+ stdout: Buffer.concat(stdout).toString("utf8"),
5129
+ stderr: Buffer.concat(stderr).toString("utf8")
5130
+ });
5131
+ });
5132
+ child.on("error", () => {
5133
+ resolve2({
5134
+ command: label,
5135
+ args,
5136
+ exitCode: 1,
5137
+ signal: null,
5138
+ stdout: Buffer.concat(stdout).toString("utf8"),
5139
+ stderr: Buffer.concat(stderr).toString("utf8")
5140
+ });
5141
+ });
5142
+ });
5143
+ }
5144
+ function splitCommandLine(commandLine) {
5145
+ return commandLine.trim().split(/\s+/);
5146
+ }
5147
+
5148
+ // src/commands/test.ts
5149
+ async function testCommand(ctx, options) {
5150
+ await withProjectLock(ctx.projectRoot, { command: "test", cwd: ctx.cwd, fetVersion: ctx.fetVersion }, async () => {
5151
+ const changeId = await resolveChangeId(ctx);
5152
+ await assertChangeExists(ctx, changeId);
5153
+ const config = await loadTddConfig(ctx.projectRoot);
5154
+ const manifest = await readTddManifest(ctx.projectRoot, changeId);
5155
+ if (!manifest) {
5156
+ throw new FetError({
5157
+ code: "STATE_CORRUPTED" /* StateCorrupted */,
5158
+ 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."),
5159
+ details: { changeId },
5160
+ suggestedCommand: `fet tdd --change ${changeId}`,
5161
+ recoverable: true
5162
+ });
5163
+ }
5164
+ const planningFingerprint = await computePlanningFingerprint(ctx.projectRoot, changeId);
5165
+ if (manifest.planningFingerprint !== planningFingerprint) {
5166
+ throw new FetError({
5167
+ code: "STATE_CORRUPTED" /* StateCorrupted */,
5168
+ 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."),
5169
+ details: { changeId },
5170
+ suggestedCommand: `fet tdd --change ${changeId}`,
5171
+ recoverable: true
5172
+ });
5173
+ }
5174
+ const scan = await ctx.scanner.scan(ctx.projectRoot, {});
5175
+ const testCommand2 = manifest.run.fallbackCommand ?? scan.commands.test?.command ?? scan.commands["test:unit"]?.command ?? null;
5176
+ if (!testCommand2) {
5177
+ if (config.whenNoTestScript === "skip") {
5178
+ await recordSkippedTestRun(ctx, changeId, planningFingerprint, manifestPath(changeId));
5179
+ ctx.output.result({
5180
+ ok: true,
5181
+ command: "test",
5182
+ summary: msg(ctx.language, "\u672A\u914D\u7F6E test \u811A\u672C\uFF0C\u5DF2\u8DF3\u8FC7 fet test\u3002", "No test script configured; fet test skipped."),
5183
+ 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.")],
5184
+ data: { changeId, skipped: true }
5185
+ });
5186
+ return;
5187
+ }
5188
+ throw new FetError({
5189
+ code: "CONFIG_INVALID" /* ConfigInvalid */,
5190
+ 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."),
5191
+ suggestedCommand: "\u5728 package.json \u4E2D\u6DFB\u52A0 scripts.test \u6216\u914D\u7F6E openspec/config.yaml fet.tdd.whenNoTestScript: skip"
5192
+ });
5193
+ }
5194
+ const extraArgs = buildTestArgs(manifest);
5195
+ const planLabel = `${testCommand2}${extraArgs.length ? ` ${extraArgs.join(" ")}` : ""}`;
5196
+ if (options.plan) {
5197
+ ctx.output.result({
5198
+ ok: true,
5199
+ command: "test",
5200
+ summary: msg(ctx.language, "\u5DF2\u751F\u6210 fet test \u6267\u884C\u8BA1\u5212\u3002", "Generated fet test execution plan."),
5201
+ data: {
5202
+ changeId,
5203
+ command: planLabel,
5204
+ cases: manifest.cases.map((item) => ({ id: item.id, testFile: item.testFile }))
5205
+ },
5206
+ nextSteps: [`fet test --change ${changeId}`]
5207
+ });
5208
+ return;
5209
+ }
5210
+ const result = await runShellCommand(testCommand2, ctx.projectRoot, extraArgs);
5211
+ const caseResults = manifest.cases.map((item) => ({
5212
+ id: item.id,
5213
+ status: result.exitCode === 0 ? "passed" : "failed",
5214
+ exitCode: result.exitCode,
5215
+ message: result.exitCode === 0 ? void 0 : msg(ctx.language, "\u6D4B\u8BD5\u547D\u4EE4\u672A\u901A\u8FC7", "Test command failed")
5216
+ }));
5217
+ const resultsDoc = {
5218
+ schemaVersion: 1,
5219
+ changeId,
5220
+ ranAt: (/* @__PURE__ */ new Date()).toISOString(),
5221
+ command: planLabel,
5222
+ exitCode: result.exitCode,
5223
+ planningFingerprint,
5224
+ cases: caseResults
5225
+ };
5226
+ const resultsPath = await writeTddResults(ctx.projectRoot, resultsDoc);
5227
+ const changeState = await ctx.stateStore.getOrCreateChange(changeId, "implement");
5228
+ changeState.testRun = {
5229
+ status: result.exitCode === 0 ? "passed" : "failed",
5230
+ ranAt: resultsDoc.ranAt,
5231
+ command: planLabel,
5232
+ exitCode: result.exitCode,
5233
+ planningFingerprint,
5234
+ manifestPath: manifestPath(changeId),
5235
+ resultsPath
5236
+ };
5237
+ await ctx.stateStore.writeChange(changeState);
5238
+ if (result.exitCode !== 0) {
5239
+ throw new FetError({
5240
+ code: "OPENSPEC_COMMAND_FAILED" /* OpenSpecCommandFailed */,
5241
+ message: msg(ctx.language, "fet test \u672A\u901A\u8FC7\u3002", "fet test failed."),
5242
+ details: {
5243
+ changeId,
5244
+ exitCode: result.exitCode,
5245
+ command: planLabel,
5246
+ resultsPath: tddResultsRelativePath(changeId),
5247
+ stderr: truncate(result.stderr)
5248
+ },
5249
+ suggestedCommand: `fet test --change ${changeId}`,
5250
+ recoverable: true
5251
+ });
5252
+ }
5253
+ ctx.output.result({
5254
+ ok: true,
5255
+ command: "test",
5256
+ summary: msg(ctx.language, `change "${changeId}" \u7684\u5355\u6D4B\u5DF2\u901A\u8FC7\u3002`, `Unit tests passed for change "${changeId}".`),
5257
+ nextSteps: [`fet verify --change ${changeId}`],
5258
+ data: {
5259
+ changeId,
5260
+ command: planLabel,
5261
+ resultsPath: tddResultsRelativePath(changeId),
5262
+ cases: caseResults
5263
+ }
5264
+ });
5265
+ });
5266
+ }
5267
+ function manifestPath(changeId) {
5268
+ return tddManifestRelativePath(changeId);
5269
+ }
5270
+ function buildTestArgs(manifest) {
5271
+ if (!manifest || manifest.run.mode === "workspace") {
5272
+ return [];
5273
+ }
5274
+ const files = [...new Set(manifest.cases.map((item) => item.testFile).filter(Boolean))];
5275
+ return files.length ? ["--", ...files] : [];
5276
+ }
5277
+ async function recordSkippedTestRun(ctx, changeId, planningFingerprint, manifestPathValue) {
5278
+ const changeState = await ctx.stateStore.getOrCreateChange(changeId, "implement");
5279
+ changeState.testRun = {
5280
+ status: "skipped",
5281
+ ranAt: (/* @__PURE__ */ new Date()).toISOString(),
5282
+ command: "(skipped)",
5283
+ exitCode: 0,
5284
+ planningFingerprint,
5285
+ manifestPath: manifestPathValue,
5286
+ resultsPath: null
5287
+ };
5288
+ await ctx.stateStore.writeChange(changeState);
5289
+ }
5290
+ function truncate(value, max = 2e3) {
5291
+ return value.length > max ? `${value.slice(0, max)}\u2026` : value;
5292
+ }
5293
+
5294
+ // src/commands/verify.ts
5295
+ import { createHash as createHash2 } from "crypto";
5296
+ import { mkdir as mkdir9, readFile as readFile19, stat as stat11 } from "fs/promises";
5297
+ import { join as join25 } from "path";
4518
5298
  async function verifyCommand(ctx, options) {
4519
5299
  if (options.auto) {
4520
5300
  const scan = await ctx.scanner.scan(ctx.projectRoot, {});
@@ -4580,10 +5360,11 @@ async function verifyCommand(ctx, options) {
4580
5360
  }
4581
5361
  async function writeInstructions(ctx, changeId) {
4582
5362
  await assertChangeExists(ctx, changeId);
5363
+ await assertTestPassed(ctx, changeId);
4583
5364
  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 });
5365
+ const dir = join25(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
5366
+ const instructionsPath = join25(dir, "verify-instructions.md");
5367
+ await mkdir9(dir, { recursive: true });
4587
5368
  await atomicWrite(instructionsPath, renderVerifyInstructions(changeId, generatedAt));
4588
5369
  const state = await ctx.stateStore.getOrCreateChange(changeId, "verify");
4589
5370
  state.currentPhase = "verify";
@@ -4598,8 +5379,9 @@ async function writeInstructions(ctx, changeId) {
4598
5379
  }
4599
5380
  async function markDone(ctx, changeId) {
4600
5381
  await assertChangeExists(ctx, changeId);
5382
+ await assertTestPassed(ctx, changeId);
4601
5383
  const declaredAt = (/* @__PURE__ */ new Date()).toISOString();
4602
- const instructionsPath = join20(ctx.projectRoot, "openspec", "changes", changeId, ".fet", "verify-instructions.md");
5384
+ const instructionsPath = join25(ctx.projectRoot, "openspec", "changes", changeId, ".fet", "verify-instructions.md");
4603
5385
  const instructions = await readInstructions(ctx, instructionsPath, changeId);
4604
5386
  const instructionsGeneratedAt = readFrontMatterValue(instructions, "generatedAt") ?? declaredAt;
4605
5387
  const state = await ctx.stateStore.getOrCreateChange(changeId, "verify");
@@ -4621,21 +5403,10 @@ async function markDone(ctx, changeId) {
4621
5403
  nextSteps: [`fet sync --change ${changeId}`, `fet archive --change ${changeId}`]
4622
5404
  });
4623
5405
  }
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
5406
  async function readInstructions(ctx, path, changeId) {
4636
5407
  try {
4637
- await stat9(path);
4638
- const content = await readFile15(path, "utf8");
5408
+ await stat11(path);
5409
+ const content = await readFile19(path, "utf8");
4639
5410
  const fileChangeId = readFrontMatterValue(content, "changeId");
4640
5411
  if (fileChangeId !== changeId) {
4641
5412
  throw new FetError({
@@ -4667,26 +5438,7 @@ function readFrontMatterValue(content, key) {
4667
5438
  return match?.[1]?.trim() ?? null;
4668
5439
  }
4669
5440
  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
- });
5441
+ return `sha256:${createHash2("sha256").update(JSON.stringify(value)).digest("hex")}`;
4690
5442
  }
4691
5443
 
4692
5444
  // src/model-policy.ts
@@ -4777,11 +5529,12 @@ function renderIdeModelPolicy(command, language = "zh-CN") {
4777
5529
  import { resolve } from "path";
4778
5530
 
4779
5531
  // src/adapters/codex/index.ts
4780
- import { mkdir as mkdir8, readFile as readFile16, stat as stat10 } from "fs/promises";
5532
+ import { mkdir as mkdir10, readFile as readFile20, stat as stat12 } from "fs/promises";
4781
5533
  import { homedir } from "os";
4782
- import { dirname as dirname8, join as join21 } from "path";
5534
+ import { dirname as dirname9, join as join26 } from "path";
4783
5535
 
4784
5536
  // src/adapters/commands.ts
5537
+ var FET_STANDALONE_COMMANDS = ["tdd", "test"];
4785
5538
  var FET_WORKFLOW_COMMANDS = [
4786
5539
  "explore",
4787
5540
  "propose",
@@ -4796,7 +5549,7 @@ var FET_WORKFLOW_COMMANDS = [
4796
5549
  "onboard"
4797
5550
  ];
4798
5551
  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];
5552
+ var FET_ADAPTER_COMMANDS = [...FET_WORKFLOW_COMMANDS, ...FET_STANDALONE_COMMANDS, "update", "fill-context", "passthrough", ...FET_GRAPH_COMMANDS];
4800
5553
  function renderFetAdapterUsage(command, args = "[...args]") {
4801
5554
  if (command.startsWith("graph-")) {
4802
5555
  const subcommand = command.slice("graph-".length);
@@ -5864,7 +6617,7 @@ var CodexAdapter = class {
5864
6617
  adapterVersion = 1;
5865
6618
  async detect(projectRoot) {
5866
6619
  return {
5867
- detected: await exists5(join21(projectRoot, ".codex")) || await exists5(join21(projectRoot, "AGENTS.md")),
6620
+ detected: await exists6(join26(projectRoot, ".codex")) || await exists6(join26(projectRoot, "AGENTS.md")),
5868
6621
  reason: "Codex adapter is available for projects that use AGENTS.md"
5869
6622
  };
5870
6623
  }
@@ -5903,7 +6656,7 @@ var CodexAdapter = class {
5903
6656
  if (existing && !existing.includes("FET:MANAGED") && force) {
5904
6657
  await createBackup(target);
5905
6658
  }
5906
- await mkdir8(dirname8(target), { recursive: true });
6659
+ await mkdir10(dirname9(target), { recursive: true });
5907
6660
  await atomicWrite(target, file.content);
5908
6661
  written.push(displayPath);
5909
6662
  }
@@ -5930,9 +6683,9 @@ var CodexAdapter = class {
5930
6683
  };
5931
6684
  function resolveTarget(projectRoot, file) {
5932
6685
  if (file.root === "codex-home") {
5933
- return join21(resolveCodexHome(), file.path);
6686
+ return join26(resolveCodexHome(), file.path);
5934
6687
  }
5935
- return join21(projectRoot, file.path);
6688
+ return join26(projectRoot, file.path);
5936
6689
  }
5937
6690
  function displayPathFor(file) {
5938
6691
  if (file.root === "codex-home") {
@@ -5941,18 +6694,18 @@ function displayPathFor(file) {
5941
6694
  return file.path;
5942
6695
  }
5943
6696
  function resolveCodexHome() {
5944
- return process.env.FET_CODEX_HOME ?? process.env.CODEX_HOME ?? join21(homedir(), ".codex");
6697
+ return process.env.FET_CODEX_HOME ?? process.env.CODEX_HOME ?? join26(homedir(), ".codex");
5945
6698
  }
5946
6699
  async function readExisting(path) {
5947
6700
  try {
5948
- return await readFile16(path, "utf8");
6701
+ return await readFile20(path, "utf8");
5949
6702
  } catch {
5950
6703
  return null;
5951
6704
  }
5952
6705
  }
5953
- async function exists5(path) {
6706
+ async function exists6(path) {
5954
6707
  try {
5955
- await stat10(path);
6708
+ await stat12(path);
5956
6709
  return true;
5957
6710
  } catch {
5958
6711
  return false;
@@ -5960,8 +6713,8 @@ async function exists5(path) {
5960
6713
  }
5961
6714
 
5962
6715
  // 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";
6716
+ import { mkdir as mkdir11, readFile as readFile21, stat as stat13 } from "fs/promises";
6717
+ import { dirname as dirname10, join as join27 } from "path";
5965
6718
 
5966
6719
  // src/adapters/cursor/templates.ts
5967
6720
  function cursorFigmaStopRuleFile(language = DEFAULT_LANGUAGE) {
@@ -6093,6 +6846,9 @@ After installation, verify with \`gitnexus --version\`, then run \`fet graph ini
6093
6846
  if (command === "apply") {
6094
6847
  return renderApplySkill(usage, language);
6095
6848
  }
6849
+ if (command === "tdd" || command === "test") {
6850
+ return renderTddTestSkill(command, usage, language);
6851
+ }
6096
6852
  if (command === "propose" || command === "continue" || command === "ff") {
6097
6853
  return renderPlanningSkill(command, usage, language);
6098
6854
  }
@@ -6210,6 +6966,38 @@ ${figmaBlock}
6210
6966
  ${uiContractBlock}
6211
6967
 
6212
6968
  \u6267\u884C\u524D\u8BF7\u9605\u8BFB AGENTS.md\u3001openspec/config.yaml \u4E0E\u5F53\u524D change \u4E0B\u7684 OpenSpec \u4EA7\u7269\u3002
6969
+
6970
+ \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
6971
+ `;
6972
+ }
6973
+ function renderTddTestSkill(command, usage, language) {
6974
+ 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";
6975
+ 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`;
6976
+ return `<!-- FET:MANAGED
6977
+ schemaVersion: 1
6978
+ fetVersion: ${FET_VERSION}
6979
+ generator: cursor-adapter
6980
+ adapterVersion: 1
6981
+ command: ${usage}
6982
+ FET:END -->
6983
+
6984
+ ---
6985
+ name: fet-${command}
6986
+ description: ${description}
6987
+ disable-model-invocation: true
6988
+ ---
6989
+
6990
+ ${renderIdeModelPolicy(command, language)}
6991
+
6992
+ ${languageInstruction(language)}
6993
+
6994
+ \u8BF7\u5728\u7EC8\u7AEF\u4E2D\u6267\u884C\uFF1A
6995
+
6996
+ \`\`\`sh
6997
+ ${usage}
6998
+ \`\`\`
6999
+
7000
+ ${body}
6213
7001
  `;
6214
7002
  }
6215
7003
 
@@ -6219,7 +7007,7 @@ var CursorAdapter = class {
6219
7007
  adapterVersion = 1;
6220
7008
  async detect(projectRoot) {
6221
7009
  return {
6222
- detected: await exists6(join22(projectRoot, ".cursor")),
7010
+ detected: await exists7(join27(projectRoot, ".cursor")),
6223
7011
  reason: "Cursor adapter is available for any project"
6224
7012
  };
6225
7013
  }
@@ -6236,7 +7024,7 @@ var CursorAdapter = class {
6236
7024
  const written = [];
6237
7025
  const skipped = [];
6238
7026
  for (const file of plan.files) {
6239
- const target = join22(projectRoot, file.path);
7027
+ const target = join27(projectRoot, file.path);
6240
7028
  const existing = await readExisting2(target);
6241
7029
  if (existing && !existing.includes("FET:MANAGED") && !force) {
6242
7030
  throw new FetError({
@@ -6249,7 +7037,7 @@ var CursorAdapter = class {
6249
7037
  if (existing && !existing.includes("FET:MANAGED") && force) {
6250
7038
  await createBackup(target);
6251
7039
  }
6252
- await mkdir9(dirname9(target), { recursive: true });
7040
+ await mkdir11(dirname10(target), { recursive: true });
6253
7041
  await atomicWrite(target, file.content);
6254
7042
  written.push(file.path);
6255
7043
  }
@@ -6259,7 +7047,7 @@ var CursorAdapter = class {
6259
7047
  const plan = await this.planInstall(projectRoot);
6260
7048
  const checks = [];
6261
7049
  for (const file of plan.files) {
6262
- const target = join22(projectRoot, file.path);
7050
+ const target = join27(projectRoot, file.path);
6263
7051
  const content = await readExisting2(target);
6264
7052
  const managed = Boolean(content?.includes("FET:MANAGED"));
6265
7053
  const versionMatches = Boolean(content?.includes(`adapterVersion: ${this.adapterVersion}`));
@@ -6275,14 +7063,14 @@ var CursorAdapter = class {
6275
7063
  };
6276
7064
  async function readExisting2(path) {
6277
7065
  try {
6278
- return await readFile17(path, "utf8");
7066
+ return await readFile21(path, "utf8");
6279
7067
  } catch {
6280
7068
  return null;
6281
7069
  }
6282
7070
  }
6283
- async function exists6(path) {
7071
+ async function exists7(path) {
6284
7072
  try {
6285
- await stat11(path);
7073
+ await stat13(path);
6286
7074
  return true;
6287
7075
  } catch {
6288
7076
  return false;
@@ -6294,45 +7082,45 @@ import { execFile as execFile4 } from "child_process";
6294
7082
  import { promisify as promisify4 } from "util";
6295
7083
 
6296
7084
  // src/openspec/inspector.ts
6297
- import { readdir as readdir5, stat as stat12 } from "fs/promises";
6298
- import { join as join23 } from "path";
7085
+ import { readdir as readdir6, stat as stat14 } from "fs/promises";
7086
+ import { join as join28 } from "path";
6299
7087
  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");
7088
+ const openspecPath = join28(projectRoot, "openspec");
7089
+ const changesPath = join28(openspecPath, "changes");
7090
+ const legacyArchivePath = join28(openspecPath, "archive");
7091
+ const changesArchivePath = join28(changesPath, "archive");
6304
7092
  return {
6305
- exists: await exists7(openspecPath),
7093
+ exists: await exists8(openspecPath),
6306
7094
  changes: await listDirectories(changesPath, { exclude: ["archive"] }),
6307
7095
  archived: [.../* @__PURE__ */ new Set([...await listDirectories(legacyArchivePath), ...await listDirectories(changesArchivePath)])]
6308
7096
  };
6309
7097
  }
6310
7098
  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");
7099
+ const changePath = join28(projectRoot, "openspec", "changes", changeId);
7100
+ const tasksPath = join28(changePath, "tasks.md");
7101
+ const specsPath = join28(changePath, "specs");
6314
7102
  return {
6315
7103
  changeId,
6316
- exists: await exists7(changePath),
6317
- hasProposal: await exists7(join23(changePath, "proposal.md")),
6318
- hasTasks: await exists7(tasksPath),
6319
- hasSpecs: await exists7(specsPath),
7104
+ exists: await exists8(changePath),
7105
+ hasProposal: await exists8(join28(changePath, "proposal.md")),
7106
+ hasTasks: await exists8(tasksPath),
7107
+ hasSpecs: await exists8(specsPath),
6320
7108
  tasksPath,
6321
7109
  changePath
6322
7110
  };
6323
7111
  }
6324
7112
  async function listDirectories(path, options = {}) {
6325
7113
  try {
6326
- const entries = await readdir5(path, { withFileTypes: true });
7114
+ const entries = await readdir6(path, { withFileTypes: true });
6327
7115
  const excluded = new Set(options.exclude ?? []);
6328
7116
  return entries.filter((entry) => entry.isDirectory() && !excluded.has(entry.name)).map((entry) => entry.name);
6329
7117
  } catch {
6330
7118
  return [];
6331
7119
  }
6332
7120
  }
6333
- async function exists7(path) {
7121
+ async function exists8(path) {
6334
7122
  try {
6335
- await stat12(path);
7123
+ await stat14(path);
6336
7124
  return true;
6337
7125
  } catch {
6338
7126
  return false;
@@ -6399,14 +7187,14 @@ function exec(command, args) {
6399
7187
  }
6400
7188
 
6401
7189
  // src/openspec/runner.ts
6402
- import { spawn as spawn2 } from "child_process";
7190
+ import { spawn as spawn3 } from "child_process";
6403
7191
  async function runOpenSpec(executablePath, command, args, options) {
6404
7192
  const spawnCommand = executablePath === "npx openspec" ? "npx" : executablePath;
6405
7193
  const spawnArgs = executablePath === "npx openspec" ? ["openspec", command, ...args] : [command, ...args];
6406
7194
  return new Promise((resolve2, reject) => {
6407
7195
  const stdout = [];
6408
7196
  const stderr = [];
6409
- const child = spawn2(spawnCommand, spawnArgs, {
7197
+ const child = spawn3(spawnCommand, spawnArgs, {
6410
7198
  cwd: options.cwd,
6411
7199
  stdio: options.stdio ?? "inherit",
6412
7200
  shell: process.platform === "win32"
@@ -6515,14 +7303,14 @@ function escapeRegExp(value) {
6515
7303
  }
6516
7304
 
6517
7305
  // 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";
7306
+ import { readdir as readdir7, stat as stat15 } from "fs/promises";
7307
+ import { join as join29, relative as relative5, sep } from "path";
6520
7308
  async function scanRoutes(projectRoot) {
6521
7309
  const candidates = ["src/routes", "src/pages", "app", "pages"];
6522
7310
  const routes = [];
6523
7311
  for (const candidate of candidates) {
6524
- const root = join24(projectRoot, candidate);
6525
- if (!await exists8(root)) {
7312
+ const root = join29(projectRoot, candidate);
7313
+ if (!await exists9(root)) {
6526
7314
  continue;
6527
7315
  }
6528
7316
  for (const file of await listFiles(root)) {
@@ -6530,8 +7318,8 @@ async function scanRoutes(projectRoot) {
6530
7318
  continue;
6531
7319
  }
6532
7320
  routes.push({
6533
- path: inferRoutePath(relative4(root, file)),
6534
- source: relative4(projectRoot, file).split(sep).join("/"),
7321
+ path: inferRoutePath(relative5(root, file)),
7322
+ source: relative5(projectRoot, file).split(sep).join("/"),
6535
7323
  inferred: true,
6536
7324
  confidence: "medium"
6537
7325
  });
@@ -6546,10 +7334,10 @@ function inferRoutePath(relativePath) {
6546
7334
  return `/${withoutIndex}`.replace(/\/+/g, "/");
6547
7335
  }
6548
7336
  async function listFiles(root) {
6549
- const entries = await readdir6(root, { withFileTypes: true });
7337
+ const entries = await readdir7(root, { withFileTypes: true });
6550
7338
  const files = [];
6551
7339
  for (const entry of entries) {
6552
- const path = join24(root, entry.name);
7340
+ const path = join29(root, entry.name);
6553
7341
  if (entry.isDirectory()) {
6554
7342
  files.push(...await listFiles(path));
6555
7343
  } else {
@@ -6558,9 +7346,9 @@ async function listFiles(root) {
6558
7346
  }
6559
7347
  return files;
6560
7348
  }
6561
- async function exists8(path) {
7349
+ async function exists9(path) {
6562
7350
  try {
6563
- await stat13(path);
7351
+ await stat15(path);
6564
7352
  return true;
6565
7353
  } catch {
6566
7354
  return false;
@@ -6717,9 +7505,9 @@ async function createCommandContext(command, options) {
6717
7505
  import { createInterface as createInterface2 } from "readline/promises";
6718
7506
 
6719
7507
  // src/update/check.ts
6720
- import { mkdir as mkdir10, readFile as readFile18, writeFile } from "fs/promises";
7508
+ import { mkdir as mkdir12, readFile as readFile22, writeFile } from "fs/promises";
6721
7509
  import { homedir as homedir2 } from "os";
6722
- import { dirname as dirname10, join as join25 } from "path";
7510
+ import { dirname as dirname11, join as join30 } from "path";
6723
7511
  var DEFAULT_CACHE_TTL_MS = 6 * 60 * 60 * 1e3;
6724
7512
  function getFetUpdateCheckMode(env = process.env) {
6725
7513
  const value = env.FET_UPDATE_CHECK?.trim().toLowerCase();
@@ -6792,11 +7580,11 @@ function formatFetUpdateWarning(availability, language) {
6792
7580
  }
6793
7581
  function cachePath() {
6794
7582
  const home = process.env.FET_UPDATE_CHECK_CACHE_HOME?.trim() || homedir2();
6795
- return join25(home, ".fet", "update-check-cache.json");
7583
+ return join30(home, ".fet", "update-check-cache.json");
6796
7584
  }
6797
7585
  async function readUpdateCheckCache() {
6798
7586
  try {
6799
- const raw = await readFile18(cachePath(), "utf8");
7587
+ const raw = await readFile22(cachePath(), "utf8");
6800
7588
  const parsed = JSON.parse(raw);
6801
7589
  if (typeof parsed.latestVersion !== "string" || typeof parsed.checkedAt !== "string") {
6802
7590
  return null;
@@ -6812,7 +7600,7 @@ async function readUpdateCheckCache() {
6812
7600
  }
6813
7601
  async function writeUpdateCheckCache(cache) {
6814
7602
  const path = cachePath();
6815
- await mkdir10(dirname10(path), { recursive: true });
7603
+ await mkdir12(dirname11(path), { recursive: true });
6816
7604
  await writeFile(path, `${JSON.stringify(cache, null, 2)}
6817
7605
  `, "utf8");
6818
7606
  }
@@ -6899,6 +7687,10 @@ for (const action of ["init", "refresh"]) {
6899
7687
  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
7688
  wrap("doctor", (ctx, options) => doctorCommand(ctx, { fixLock: Boolean(options.fixLock) }))
6901
7689
  );
7690
+ 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));
7691
+ 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(
7692
+ wrap("test", (ctx, options) => testCommand(ctx, { plan: Boolean(options.plan) }))
7693
+ );
6902
7694
  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
7695
  wrap("verify", verifyCommand)
6904
7696
  );