@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/README.md +14 -2
- package/README_en.md +11 -2
- package/dist/cli/index.js +1846 -202
- package/dist/cli/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
192
|
-
const graphPath = join5(projectRoot,
|
|
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:
|
|
196
|
+
graphPath: relative6,
|
|
197
197
|
graphExists: true,
|
|
198
198
|
lastIndexedAt: info.mtime.toISOString()
|
|
199
199
|
};
|
|
200
200
|
} catch {
|
|
201
201
|
return {
|
|
202
|
-
graphPath:
|
|
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
|
|
2893
|
-
import { join as
|
|
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
|
|
3370
|
-
import { join as
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
4058
|
+
return join21(this.projectRoot, "openspec", "fet-state.json");
|
|
3530
4059
|
}
|
|
3531
4060
|
changePath(changeId) {
|
|
3532
|
-
return
|
|
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
|
|
4069
|
+
import { readFile as readFile16 } from "fs/promises";
|
|
3541
4070
|
async function readCompletedTaskIds(tasksPath) {
|
|
3542
4071
|
let content;
|
|
3543
4072
|
try {
|
|
3544
|
-
content = await
|
|
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
|
|
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 :
|
|
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
|
|
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 =
|
|
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 =
|
|
4141
|
-
const proposal = await readOptional5(
|
|
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(
|
|
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
|
|
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
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
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
|
|
4428
|
-
|
|
4429
|
-
|
|
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 (
|
|
4435
|
-
return
|
|
5811
|
+
if (check === "visible" && !layout.visible) {
|
|
5812
|
+
return { pageId, selector, status: "failed", message: language === "en" ? "Not visible" : "\u4E0D\u53EF\u89C1" };
|
|
4436
5813
|
}
|
|
4437
|
-
|
|
4438
|
-
|
|
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
|
|
5818
|
+
return { pageId, selector, status: "passed" };
|
|
4443
5819
|
}
|
|
4444
|
-
function
|
|
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
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
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
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
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
|
-
|
|
4467
|
-
|
|
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
|
|
5883
|
+
return {
|
|
5884
|
+
...manifest,
|
|
5885
|
+
run: { baseUrl }
|
|
5886
|
+
};
|
|
4470
5887
|
}
|
|
4471
5888
|
|
|
4472
|
-
// src/
|
|
4473
|
-
async function
|
|
4474
|
-
const
|
|
4475
|
-
const
|
|
4476
|
-
|
|
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
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
4495
|
-
|
|
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
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
|
|
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
|
|
4514
|
-
import { join as
|
|
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 =
|
|
4585
|
-
const instructionsPath =
|
|
4586
|
-
await
|
|
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 =
|
|
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
|
|
4638
|
-
const content = await
|
|
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:${
|
|
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
|
|
6339
|
+
import { mkdir as mkdir12, readFile as readFile22, stat as stat13 } from "fs/promises";
|
|
4781
6340
|
import { homedir } from "os";
|
|
4782
|
-
import { dirname as
|
|
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
|
|
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
|
|
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
|
|
7493
|
+
return join30(resolveCodexHome(), file.path);
|
|
5934
7494
|
}
|
|
5935
|
-
return
|
|
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 ??
|
|
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
|
|
7508
|
+
return await readFile22(path, "utf8");
|
|
5949
7509
|
} catch {
|
|
5950
7510
|
return null;
|
|
5951
7511
|
}
|
|
5952
7512
|
}
|
|
5953
|
-
async function
|
|
7513
|
+
async function exists6(path) {
|
|
5954
7514
|
try {
|
|
5955
|
-
await
|
|
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
|
|
5964
|
-
import { dirname as
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
7905
|
+
return await readFile23(path, "utf8");
|
|
6279
7906
|
} catch {
|
|
6280
7907
|
return null;
|
|
6281
7908
|
}
|
|
6282
7909
|
}
|
|
6283
|
-
async function
|
|
7910
|
+
async function exists7(path) {
|
|
6284
7911
|
try {
|
|
6285
|
-
await
|
|
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
|
|
6298
|
-
import { join as
|
|
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 =
|
|
6301
|
-
const changesPath =
|
|
6302
|
-
const legacyArchivePath =
|
|
6303
|
-
const changesArchivePath =
|
|
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
|
|
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 =
|
|
6312
|
-
const tasksPath =
|
|
6313
|
-
const specsPath =
|
|
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
|
|
6317
|
-
hasProposal: await
|
|
6318
|
-
hasTasks: await
|
|
6319
|
-
hasSpecs: await
|
|
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
|
|
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
|
|
7960
|
+
async function exists8(path) {
|
|
6334
7961
|
try {
|
|
6335
|
-
await
|
|
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
|
|
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 =
|
|
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
|
|
6519
|
-
import { join as
|
|
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 =
|
|
6525
|
-
if (!await
|
|
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(
|
|
6534
|
-
source:
|
|
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
|
|
8176
|
+
const entries = await readdir7(root, { withFileTypes: true });
|
|
6550
8177
|
const files = [];
|
|
6551
8178
|
for (const entry of entries) {
|
|
6552
|
-
const path =
|
|
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
|
|
8188
|
+
async function exists9(path) {
|
|
6562
8189
|
try {
|
|
6563
|
-
await
|
|
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
|
|
8347
|
+
import { mkdir as mkdir14, readFile as readFile24, writeFile } from "fs/promises";
|
|
6721
8348
|
import { homedir as homedir2 } from "os";
|
|
6722
|
-
import { dirname as
|
|
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
|
|
8422
|
+
return join34(home, ".fet", "update-check-cache.json");
|
|
6796
8423
|
}
|
|
6797
8424
|
async function readUpdateCheckCache() {
|
|
6798
8425
|
try {
|
|
6799
|
-
const raw = await
|
|
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
|
|
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
|
);
|