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