ai-spec-dev 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.ai-spec-workspace.json +17 -0
  2. package/.ai-spec.json +7 -0
  3. package/cli/pipeline/single-repo.ts +19 -10
  4. package/core/cli-ui.ts +136 -0
  5. package/core/code-generator.ts +4 -2
  6. package/core/error-feedback.ts +4 -2
  7. package/core/provider-utils.ts +8 -7
  8. package/demo-backend/.ai-spec-constitution.md +65 -0
  9. package/demo-backend/package.json +21 -0
  10. package/demo-backend/prisma/schema.prisma +22 -0
  11. package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.dsl.json +186 -0
  12. package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.md +211 -0
  13. package/demo-backend/src/controllers/bookmark.controller.test.ts +255 -0
  14. package/demo-backend/src/controllers/bookmark.controller.ts +187 -0
  15. package/demo-backend/src/index.ts +17 -0
  16. package/demo-backend/src/routes/bookmark.routes.test.ts +264 -0
  17. package/demo-backend/src/routes/bookmark.routes.ts +11 -0
  18. package/demo-backend/src/routes/index.ts +8 -0
  19. package/demo-backend/src/services/bookmark.service.test.ts +433 -0
  20. package/demo-backend/src/services/bookmark.service.ts +261 -0
  21. package/demo-backend/tsconfig.json +12 -0
  22. package/demo-frontend/.ai-spec-constitution.md +95 -0
  23. package/demo-frontend/package.json +23 -0
  24. package/demo-frontend/src/App.tsx +12 -0
  25. package/demo-frontend/src/main.tsx +9 -0
  26. package/demo-frontend/tsconfig.json +13 -0
  27. package/dist/cli/index.js +130 -21
  28. package/dist/cli/index.js.map +1 -1
  29. package/dist/cli/index.mjs +130 -21
  30. package/dist/cli/index.mjs.map +1 -1
  31. package/dist/index.js +80 -8
  32. package/dist/index.js.map +1 -1
  33. package/dist/index.mjs +80 -8
  34. package/dist/index.mjs.map +1 -1
  35. package/package.json +1 -1
  36. package/RELEASE_LOG.md +0 -2962
  37. package/purpose.md +0 -1434
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "bookmark-demo",
3
+ "repos": [
4
+ {
5
+ "name": "demo-backend",
6
+ "path": "demo-backend",
7
+ "type": "node-express",
8
+ "role": "backend"
9
+ },
10
+ {
11
+ "name": "demo-frontend",
12
+ "path": "demo-frontend",
13
+ "type": "react",
14
+ "role": "frontend"
15
+ }
16
+ ]
17
+ }
package/.ai-spec.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "provider": "glm",
3
+ "model": "glm-4.5-air",
4
+ "codegenMode": "api",
5
+ "codegenProvider": "glm",
6
+ "codegenModel": "glm-4.5-air"
7
+ }
@@ -51,6 +51,7 @@ import {
51
51
  loadVcrRecording,
52
52
  } from "../../core/vcr";
53
53
  import { printBanner } from "./helpers";
54
+ import { startSpinner, startStage } from "../../core/cli-ui";
54
55
 
55
56
  // ─── Pipeline Options ────────────────────────────────────────────────────────
56
57
 
@@ -168,7 +169,7 @@ export async function runSingleRepoPipeline(
168
169
  console.log(chalk.yellow(` ⚠ Constitution is long (${context.constitution.length.toLocaleString()} chars). Consider running: ai-spec init --consolidate`));
169
170
  }
170
171
  } else {
171
- console.log(chalk.yellow(" Constitution : not found — auto-generating..."));
172
+ const constitutionSpinner = startSpinner("Constitution not found — auto-generating...");
172
173
  try {
173
174
  const constitutionGen = new ConstitutionGenerator(
174
175
  createProvider(specProviderName, specApiKey, specModelName)
@@ -176,9 +177,9 @@ export async function runSingleRepoPipeline(
176
177
  const constitutionContent = await constitutionGen.generate(currentDir);
177
178
  await constitutionGen.saveConstitution(currentDir, constitutionContent);
178
179
  context.constitution = constitutionContent;
179
- console.log(chalk.green(` Constitution : ✔ generated and saved (.ai-spec-constitution.md)`));
180
+ constitutionSpinner.succeed("Constitution generated and saved (.ai-spec-constitution.md)");
180
181
  } catch (err) {
181
- console.log(chalk.yellow(` Constitution : ⚠ auto-generation failed (${(err as Error).message}), continuing without it.`));
182
+ constitutionSpinner.fail(`Constitution auto-generation failed (${(err as Error).message}), continuing without it.`);
182
183
  }
183
184
  }
184
185
 
@@ -214,17 +215,18 @@ export async function runSingleRepoPipeline(
214
215
  let initialTasks: import("../../core/task-generator").SpecTask[] = [];
215
216
 
216
217
  runLogger.stageStart("spec_gen", { provider: specProviderName, model: specModelName });
218
+ const specSpinner = startStage("spec_gen", `Generating spec with ${specProviderName}/${specModelName}...`);
217
219
  try {
218
220
  if (opts.skipTasks) {
219
221
  const { SpecGenerator } = await import("../../core/spec-generator");
220
222
  const generator = new SpecGenerator(specProvider);
221
223
  initialSpec = await generator.generateSpec(idea, context, architectureDecision);
222
- console.log(chalk.green("Spec generated."));
224
+ specSpinner.succeed("Spec generated.");
223
225
  } else {
224
226
  const result = await generateSpecWithTasks(specProvider, idea, context, architectureDecision);
225
227
  initialSpec = result.spec;
226
228
  initialTasks = result.tasks;
227
- console.log(chalk.green(` ✔ Spec generated.`));
229
+ specSpinner.succeed("Spec generated.");
228
230
  if (initialTasks.length > 0) {
229
231
  console.log(chalk.green(` ✔ ${initialTasks.length} tasks generated (combined call).`));
230
232
  } else {
@@ -233,8 +235,8 @@ export async function runSingleRepoPipeline(
233
235
  }
234
236
  runLogger.stageEnd("spec_gen", { taskCount: initialTasks.length });
235
237
  } catch (err) {
238
+ specSpinner.fail(`Spec generation failed: ${(err as Error).message}`);
236
239
  runLogger.stageFail("spec_gen", (err as Error).message);
237
- console.error(chalk.red(" ✘ Spec generation failed:"), err);
238
240
  process.exit(1);
239
241
  }
240
242
 
@@ -262,7 +264,9 @@ export async function runSingleRepoPipeline(
262
264
  console.log(chalk.blue("\n[3.4/6] Spec quality assessment..."));
263
265
  }
264
266
  runLogger.stageStart("spec_assess");
267
+ const assessSpinner = startStage("spec_assess", "Evaluating spec quality...");
265
268
  const assessment = await assessSpec(specProvider, finalSpec, context.constitution ?? undefined);
269
+ assessSpinner.stop();
266
270
  if (assessment) {
267
271
  runLogger.stageEnd("spec_assess", { overallScore: assessment.overallScore });
268
272
  if (!opts.auto) printSpecAssessment(assessment);
@@ -366,21 +370,24 @@ export async function runSingleRepoPipeline(
366
370
  console.log(chalk.blue("\n[DSL] Extracting structured DSL from spec..."));
367
371
  console.log(chalk.gray(` Provider: ${specProviderName}/${specModelName}`));
368
372
  runLogger.stageStart("dsl_extract");
373
+ const dslSpinner = startStage("dsl_extract", "Extracting DSL from spec...");
369
374
  try {
370
375
  const isFrontend = isFrontendDeps(context.dependencies);
371
- if (isFrontend) console.log(chalk.gray(" Frontend project detected — using ComponentSpec extractor"));
376
+ if (isFrontend) {
377
+ dslSpinner.update("🔗 Extracting DSL (frontend ComponentSpec mode)...");
378
+ }
372
379
  const dslExtractor = new DslExtractor(specProvider);
373
380
  extractedDsl = await dslExtractor.extract(finalSpec, { auto: opts.auto, isFrontend });
374
381
  if (extractedDsl) {
375
382
  runLogger.stageEnd("dsl_extract", { endpoints: extractedDsl.endpoints?.length ?? 0, models: extractedDsl.models?.length ?? 0 });
376
- console.log(chalk.green("DSL extracted and validated."));
383
+ dslSpinner.succeed("DSL extracted and validated.");
377
384
  } else {
378
385
  runLogger.stageEnd("dsl_extract", { skipped: true });
379
- console.log(chalk.yellow("DSL skipped — codegen will use Spec + Tasks only."));
386
+ dslSpinner.fail("DSL skipped — codegen will use Spec + Tasks only.");
380
387
  }
381
388
  } catch (err) {
382
389
  runLogger.stageFail("dsl_extract", (err as Error).message);
383
- console.log(chalk.yellow(`DSL extraction error: ${(err as Error).message} — continuing without DSL.`));
390
+ dslSpinner.fail(`DSL extraction error: ${(err as Error).message} — continuing without DSL.`);
384
391
  }
385
392
  }
386
393
 
@@ -590,6 +597,7 @@ export async function runSingleRepoPipeline(
590
597
  if (!opts.skipReview) {
591
598
  console.log(chalk.blue("\n[9/9] Automated code review (3-pass: architecture + implementation + impact/complexity)..."));
592
599
  runLogger.stageStart("review");
600
+ const reviewSpinner = startStage("review", "Running 3-pass code review...");
593
601
  const reviewer = new CodeReviewer(specProvider, workingDir);
594
602
  const savedSpec = await fs.readFile(specFile, "utf-8");
595
603
 
@@ -598,6 +606,7 @@ export async function runSingleRepoPipeline(
598
606
  } else {
599
607
  reviewResult = await reviewer.reviewCode(savedSpec, specFile);
600
608
  }
609
+ reviewSpinner.succeed("Code review complete.");
601
610
  runLogger.stageEnd("review");
602
611
 
603
612
  // Surface Pass 0 compliance score
package/core/cli-ui.ts ADDED
@@ -0,0 +1,136 @@
1
+ import chalk from "chalk";
2
+
3
+ // ─── Spinner ──────────────────────────────────────────────────────────────────
4
+
5
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
+
7
+ export interface Spinner {
8
+ /** Update the text shown after the spinner. */
9
+ update(text: string): void;
10
+ /** Stop the spinner and show a final message. */
11
+ stop(finalText?: string): void;
12
+ /** Stop with a success (✔) mark. */
13
+ succeed(text: string): void;
14
+ /** Stop with a failure (✘) mark. */
15
+ fail(text: string): void;
16
+ }
17
+
18
+ /**
19
+ * Start a CLI spinner that renders on a single line.
20
+ * Works in any TTY; silently degrades to static text in non-TTY (CI).
21
+ */
22
+ export function startSpinner(text: string): Spinner {
23
+ const isTTY = process.stderr.isTTY;
24
+ let frame = 0;
25
+ let currentText = text;
26
+ let stopped = false;
27
+
28
+ function render() {
29
+ if (stopped) return;
30
+ const symbol = chalk.cyan(SPINNER_FRAMES[frame % SPINNER_FRAMES.length]);
31
+ if (isTTY) {
32
+ process.stderr.write(`\r ${symbol} ${currentText}${" ".repeat(10)}`);
33
+ }
34
+ frame++;
35
+ }
36
+
37
+ // Print initial line for non-TTY
38
+ if (!isTTY) {
39
+ process.stderr.write(` … ${currentText}\n`);
40
+ }
41
+
42
+ const timer = setInterval(render, 80);
43
+ render();
44
+
45
+ return {
46
+ update(newText: string) {
47
+ currentText = newText;
48
+ },
49
+ stop(finalText?: string) {
50
+ if (stopped) return;
51
+ stopped = true;
52
+ clearInterval(timer);
53
+ if (isTTY) {
54
+ process.stderr.write(`\r${" ".repeat(currentText.length + 20)}\r`);
55
+ }
56
+ if (finalText) {
57
+ process.stderr.write(` ${finalText}\n`);
58
+ }
59
+ },
60
+ succeed(successText: string) {
61
+ this.stop(chalk.green(`✔ ${successText}`));
62
+ },
63
+ fail(failText: string) {
64
+ this.stop(chalk.red(`✘ ${failText}`));
65
+ },
66
+ };
67
+ }
68
+
69
+ // ─── Retry Countdown ──────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Show an animated countdown during retry wait.
73
+ * Displays error details + a live seconds countdown.
74
+ */
75
+ export async function retryCountdown(opts: {
76
+ attempt: number;
77
+ maxAttempts: number;
78
+ waitMs: number;
79
+ errorMessage: string;
80
+ label: string;
81
+ }): Promise<void> {
82
+ const { attempt, maxAttempts, waitMs, errorMessage, label } = opts;
83
+ const isTTY = process.stderr.isTTY;
84
+
85
+ // Error box
86
+ const shortErr = errorMessage.length > 120
87
+ ? errorMessage.slice(0, 117) + "..."
88
+ : errorMessage;
89
+
90
+ process.stderr.write("\n");
91
+ process.stderr.write(chalk.yellow(` ┌─ Retry ${attempt}/${maxAttempts} `) + chalk.gray(`[${label}]`) + chalk.yellow(` ${"─".repeat(Math.max(1, 40 - label.length))}\n`));
92
+ process.stderr.write(chalk.yellow(` │ `) + chalk.white(shortErr) + "\n");
93
+ process.stderr.write(chalk.yellow(` │ `) + chalk.gray(`Waiting before retry...`) + "\n");
94
+
95
+ // Animated countdown
96
+ const totalSeconds = Math.ceil(waitMs / 1000);
97
+ for (let s = totalSeconds; s > 0; s--) {
98
+ const bar = chalk.green("█".repeat(totalSeconds - s)) + chalk.gray("░".repeat(s));
99
+ const line = chalk.yellow(` │ `) + `${bar} ${chalk.bold.white(`${s}s`)}`;
100
+ if (isTTY) {
101
+ process.stderr.write(`\r${line}${" ".repeat(10)}`);
102
+ }
103
+ await new Promise<void>((r) => setTimeout(r, 1000));
104
+ }
105
+
106
+ if (isTTY) {
107
+ process.stderr.write(`\r${" ".repeat(70)}\r`);
108
+ }
109
+ process.stderr.write(chalk.yellow(` └─ `) + chalk.cyan(`Retrying now...`) + "\n\n");
110
+ }
111
+
112
+ // ─── Stage Progress ───────────────────────────────────────────────────────────
113
+
114
+ const STAGE_ICONS: Record<string, string> = {
115
+ context_load: "📂",
116
+ design_dialogue: "💬",
117
+ spec_gen: "📝",
118
+ spec_refine: "✏️ ",
119
+ spec_assess: "📊",
120
+ dsl_extract: "🔗",
121
+ dsl_gap_feedback: "🔍",
122
+ codegen: "⚙️ ",
123
+ test_gen: "🧪",
124
+ error_feedback: "🔧",
125
+ review: "🔎",
126
+ self_eval: "📈",
127
+ };
128
+
129
+ /**
130
+ * Start a pipeline stage with a spinner.
131
+ * Returns a handle to succeed/fail/update the stage display.
132
+ */
133
+ export function startStage(stageKey: string, label: string): Spinner {
134
+ const icon = STAGE_ICONS[stageKey] ?? "▸";
135
+ return startSpinner(`${icon} ${label}`);
136
+ }
@@ -22,6 +22,7 @@ import {
22
22
  } from "./codegen/helpers";
23
23
  import { topoSortLayerTasks, printTaskProgress, LAYER_ICONS } from "./codegen/topo-sort";
24
24
  import { estimateTokens, getDefaultBudget } from "./token-budget";
25
+ import { startSpinner } from "./cli-ui";
25
26
 
26
27
  // Re-export public symbols for backward compatibility
27
28
  export { extractBehavioralContract } from "./codegen/helpers";
@@ -654,6 +655,7 @@ ${constitutionSection}
654
655
  === ${existingContent ? "Existing content (modify and return the complete file)" : "Create this file from scratch"} ===
655
656
  ${existingContent || "Output only the complete file content."}`;
656
657
 
658
+ const fileSpinner = startSpinner(`${prefix}Generating ${chalk.bold(item.file)}...`);
657
659
  try {
658
660
  const raw = await this.provider.generate(codePrompt, systemPrompt);
659
661
  const fileContent = stripCodeFences(raw);
@@ -661,11 +663,11 @@ ${existingContent || "Output only the complete file content."}`;
661
663
  await fs.ensureDir(path.dirname(fullPath));
662
664
  await fs.writeFile(fullPath, fileContent, "utf-8");
663
665
  getActiveLogger()?.fileWritten(item.file);
664
- console.log(`${prefix}${existingContent ? chalk.yellow("~") : chalk.green("+")} ${chalk.bold(item.file)} ${chalk.green("✔")}`);
666
+ fileSpinner.succeed(`${existingContent ? chalk.yellow("~") : chalk.green("+")} ${chalk.bold(item.file)}`);
665
667
  successCount++;
666
668
  writtenFiles.push(item.file);
667
669
  } catch (err) {
668
- console.log(`${prefix}${chalk.red("✘")} ${chalk.bold(item.file)} — ${chalk.red((err as Error).message)}`);
670
+ fileSpinner.fail(`${chalk.bold(item.file)} — ${(err as Error).message}`);
669
671
  }
670
672
  }
671
673
 
@@ -7,6 +7,7 @@ import { getCodeGenSystemPrompt } from "../prompts/codegen.prompt";
7
7
  import { SpecDSL } from "./dsl-types";
8
8
  import { buildDslContextSection } from "./dsl-extractor";
9
9
  import { getActiveSnapshot } from "./run-snapshot";
10
+ import { startSpinner } from "./cli-ui";
10
11
 
11
12
  // ─── Types ──────────────────────────────────────────────────────────────────────
12
13
 
@@ -369,16 +370,17 @@ ${fileContent}
369
370
 
370
371
  Output ONLY the complete fixed file content. No markdown fences, no explanations.`;
371
372
 
373
+ const fixSpinner = startSpinner(`Fixing ${chalk.bold(file)} (${fileErrors.length} error(s))...`);
372
374
  try {
373
375
  const raw = await provider.generate(prompt, getCodeGenSystemPrompt());
374
376
  const fixed = raw.replace(/^```\w*\n?/gm, "").replace(/\n?```$/gm, "").trim();
375
377
  await getActiveSnapshot()?.snapshotFile(fullPath);
376
378
  await fs.writeFile(fullPath, fixed, "utf-8");
377
379
  results.push({ fixed: true, file, explanation: `Fixed ${fileErrors.length} error(s)` });
378
- console.log(chalk.green(`Auto-fixed: ${file}`));
380
+ fixSpinner.succeed(`Auto-fixed: ${file}`);
379
381
  } catch (err) {
380
382
  results.push({ fixed: false, file, explanation: `AI fix failed: ${(err as Error).message}` });
381
- console.log(chalk.yellow(`Could not auto-fix: ${file}`));
383
+ fixSpinner.fail(`Could not auto-fix: ${file}`);
382
384
  }
383
385
  }
384
386
 
@@ -1,6 +1,5 @@
1
1
  import chalk from "chalk";
2
-
3
- const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
2
+ import { retryCountdown } from "./cli-ui";
4
3
 
5
4
  // ─── Error Classification ──────────────────────────────────────────────────────
6
5
 
@@ -112,12 +111,14 @@ export async function withReliability<T>(
112
111
  throw classifyError(err, label);
113
112
  }
114
113
  const waitMs = attempt === 0 ? 2_000 : 6_000;
115
- console.warn(
116
- chalk.yellow(` ⚠ ${label} failed (attempt ${attempt + 1}/${retries + 1}), retrying in ${waitMs / 1000}s`) +
117
- chalk.gray(` — ${(err as Error).message}`)
118
- );
119
114
  onRetry?.(attempt + 1, err);
120
- await sleep(waitMs);
115
+ await retryCountdown({
116
+ attempt: attempt + 1,
117
+ maxAttempts: retries + 1,
118
+ waitMs,
119
+ errorMessage: (err as Error).message ?? String(err),
120
+ label,
121
+ });
121
122
  }
122
123
  }
123
124
  /* istanbul ignore next */
@@ -0,0 +1,65 @@
1
+ # Project Constitution
2
+
3
+ ## 1. 架构规则 (Architecture Rules)
4
+ - **分层架构**:项目采用三层架构 `routes → controllers → services → database`。
5
+ - **禁止跨层调用**:
6
+ - `routes` 层仅负责定义路由和中间件,不能包含业务逻辑。
7
+ - `controllers` 层负责接收请求、调用 `services` 和返回响应,不能直接操作数据库。
8
+ - `services` 层封装核心业务逻辑,是唯一允许直接调用数据访问层(如 Prisma)的层级。
9
+ - **模块组织**:功能模块按领域划分,每个模块包含独立的 `routes.ts`, `controller.ts`, `service.ts` 文件。
10
+
11
+ ## 2. 命名规范 (Naming Conventions)
12
+ - **文件命名**:使用 `kebab-case`(如 `user-profile.controller.ts`)。
13
+ - **变量/函数**:使用 `camelCase`(如 `getUserById`)。
14
+ - **类/接口**:使用 `PascalCase`(如 `UserService`)。
15
+ - **路由路径**:使用 `kebab-case`,资源名词复数(如 `/api/v1/user-profiles`)。
16
+
17
+ ## 3. API 规范 (API Patterns)
18
+ - **路由前缀**:
19
+ - 客户端 API:`/api/v1/...`
20
+ - 管理端 API:`/api/v1/admin/...`
21
+ - **统一响应结构**:
22
+ ```json
23
+ {
24
+ "code": 200,
25
+ "message": "success",
26
+ "data": {}
27
+ }
28
+ ```
29
+ - **错误码规范**:
30
+ - `400`:客户端请求错误(参数错误、验证失败)
31
+ - `401`:未认证
32
+ - `403`:无权限
33
+ - `404`:资源不存在
34
+ - `500`:服务器内部错误
35
+ - **认证/鉴权**:使用 `auth.middleware.ts` 保护需要认证的路由,位于 `middleware/` 目录。
36
+
37
+ ## 4. 数据层规范 (Data Layer Rules)
38
+ - **数据库访问**:仅允许在 `service` 层使用 Prisma Client 进行数据库操作。
39
+ - **模型命名**:Prisma model 使用 `PascalCase`(如 `UserProfile`),表名使用 `snake_case`(如 `user_profiles`)。
40
+ - **事务处理**:复杂业务逻辑使用 Prisma 的 `$transaction` API 保证数据一致性。
41
+
42
+ ## 5. 错误处理规范 (Error Handling Patterns)
43
+ - **统一错误中间件**:使用 `error.middleware.ts` 捕获所有错误,位于 `middleware/` 目录。
44
+ - **错误抛出**:在 `service` 或 `controller` 中抛出 `AppError`(自定义错误类,包含 `code` 和 `message`)。
45
+ - **已知错误码**:
46
+ - `VALIDATION_ERROR` (400)
47
+ - `UNAUTHORIZED` (401)
48
+ - `FORBIDDEN` (403)
49
+ - `NOT_FOUND` (404)
50
+ - `INTERNAL_ERROR` (500)
51
+
52
+ ## 6. 禁区 (Red Lines — Never Violate)
53
+ - [ ] **禁止**在 `controller` 或 `route` 层直接操作数据库(必须通过 `service`)。
54
+ - [ ] **禁止**创建重复的配置文件(如错误码、路由定义),必须追加到现有文件。
55
+ - [ ] **禁止**使用 `any` 类型,必须定义明确的接口或类型。
56
+ - [ ] **禁止**提交未通过 `vitest` 测试的代码。
57
+
58
+ ## 7. 测试规范 (Testing Rules)
59
+ - **测试文件位置**:与源文件同目录,命名规则为 `*.test.ts` 或 `*.spec.ts`。
60
+ - **测试覆盖**:必须为所有 `service` 和 `controller` 方法编写单元测试,关键业务流程需包含集成测试。
61
+ - **测试框架**:使用 `vitest` 进行单元测试和集成测试,使用 `supertest` 进行 HTTP 接口测试。
62
+
63
+ ## 8. 共享配置文件清单 (Shared Config Files — Append-Only)
64
+
65
+ (No shared config files detected — will be populated on first run)
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "demo-backend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "ts-node src/index.ts",
7
+ "build": "tsc",
8
+ "test": "vitest run"
9
+ },
10
+ "dependencies": {
11
+ "express": "^4.18.2",
12
+ "cors": "^2.8.5"
13
+ },
14
+ "devDependencies": {
15
+ "typescript": "^5.7.0",
16
+ "@types/express": "^4.17.21",
17
+ "@types/cors": "^2.8.17",
18
+ "ts-node": "^10.9.2",
19
+ "vitest": "^2.1.0"
20
+ }
21
+ }
@@ -0,0 +1,22 @@
1
+ // This is your Prisma schema file,
2
+ // learn more about it in the docs: https://pris.ly/d/prisma-schema
3
+
4
+ generator client {
5
+ provider = "prisma-client-js"
6
+ }
7
+
8
+ datasource db {
9
+ provider = "postgresql"
10
+ url = env("DATABASE_URL")
11
+ }
12
+
13
+ model Bookmark {
14
+ id String @id @default(uuid())
15
+ title String
16
+ url String
17
+ tags String[]
18
+ createdAt DateTime @default(now())
19
+ updatedAt DateTime @updatedAt
20
+
21
+ @@map("bookmarks")
22
+ }
@@ -0,0 +1,186 @@
1
+ {
2
+ "version": "1.0",
3
+ "feature": {
4
+ "id": "bookmark-management-system",
5
+ "title": "Bookmark 管理系统",
6
+ "description": "实现个人书签管理系统,支持创建、查看、编辑、删除网页书签,通过标签分类和分页浏览。"
7
+ },
8
+ "models": [
9
+ {
10
+ "name": "Bookmark",
11
+ "description": "书签数据模型,包含标题、URL和标签等信息",
12
+ "fields": [
13
+ {
14
+ "name": "id",
15
+ "type": "String",
16
+ "required": true,
17
+ "unique": true,
18
+ "description": ""
19
+ },
20
+ {
21
+ "name": "title",
22
+ "type": "String",
23
+ "required": true,
24
+ "unique": false,
25
+ "description": "书签标题"
26
+ },
27
+ {
28
+ "name": "url",
29
+ "type": "String",
30
+ "required": true,
31
+ "unique": false,
32
+ "description": "书签URL,需为有效URL格式"
33
+ },
34
+ {
35
+ "name": "tags",
36
+ "type": "String[]",
37
+ "required": false,
38
+ "unique": false,
39
+ "description": "标签数组"
40
+ },
41
+ {
42
+ "name": "createdAt",
43
+ "type": "DateTime",
44
+ "required": true,
45
+ "unique": false,
46
+ "description": "创建时间"
47
+ },
48
+ {
49
+ "name": "updatedAt",
50
+ "type": "DateTime",
51
+ "required": true,
52
+ "unique": false,
53
+ "description": "更新时间"
54
+ }
55
+ ],
56
+ "relations": []
57
+ }
58
+ ],
59
+ "endpoints": [
60
+ {
61
+ "id": "EP-001",
62
+ "method": "GET",
63
+ "path": "/api/v1/bookmarks",
64
+ "description": "获取书签分页列表",
65
+ "auth": false,
66
+ "request": {
67
+ "body": {},
68
+ "query": {
69
+ "limit": "integer (positive, default 20)",
70
+ "offset": "integer (non-negative, default 0)"
71
+ },
72
+ "params": {}
73
+ },
74
+ "successStatus": 200,
75
+ "successDescription": "返回书签数组及总数",
76
+ "errors": [
77
+ {
78
+ "status": 400,
79
+ "code": "VALIDATION_ERROR",
80
+ "description": "查询参数验证失败,如 limit 或 offset 无效"
81
+ },
82
+ {
83
+ "status": 500,
84
+ "code": "INTERNAL_ERROR",
85
+ "description": "服务器内部错误"
86
+ }
87
+ ]
88
+ },
89
+ {
90
+ "id": "EP-002",
91
+ "method": "POST",
92
+ "path": "/api/v1/bookmarks",
93
+ "description": "创建新书签",
94
+ "auth": false,
95
+ "request": {
96
+ "body": {
97
+ "title": "string (required, non-empty)",
98
+ "url": "string (required, valid URL format)",
99
+ "tags": "string array (optional)"
100
+ },
101
+ "query": {},
102
+ "params": {}
103
+ },
104
+ "successStatus": 201,
105
+ "successDescription": "返回新创建的书签信息",
106
+ "errors": [
107
+ {
108
+ "status": 400,
109
+ "code": "VALIDATION_ERROR",
110
+ "description": "请求体验证失败,如 title 为空或 url 格式无效"
111
+ },
112
+ {
113
+ "status": 500,
114
+ "code": "INTERNAL_ERROR",
115
+ "description": "服务器内部错误"
116
+ }
117
+ ]
118
+ },
119
+ {
120
+ "id": "EP-003",
121
+ "method": "PUT",
122
+ "path": "/api/v1/bookmarks/:id",
123
+ "description": "全量更新指定书签",
124
+ "auth": false,
125
+ "request": {
126
+ "body": {
127
+ "title": "string (required)",
128
+ "url": "string (required, valid URL format)",
129
+ "tags": "string array (required)"
130
+ },
131
+ "query": {},
132
+ "params": {
133
+ "id": "string (UUID format)"
134
+ }
135
+ },
136
+ "successStatus": 200,
137
+ "successDescription": "返回更新后的书签信息",
138
+ "errors": [
139
+ {
140
+ "status": 400,
141
+ "code": "VALIDATION_ERROR",
142
+ "description": "请求体验证失败"
143
+ },
144
+ {
145
+ "status": 404,
146
+ "code": "NOT_FOUND",
147
+ "description": "书签未找到"
148
+ },
149
+ {
150
+ "status": 500,
151
+ "code": "INTERNAL_ERROR",
152
+ "description": "服务器内部错误"
153
+ }
154
+ ]
155
+ },
156
+ {
157
+ "id": "EP-004",
158
+ "method": "DELETE",
159
+ "path": "/api/v1/bookmarks/:id",
160
+ "description": "删除指定书签",
161
+ "auth": false,
162
+ "request": {
163
+ "body": {},
164
+ "query": {},
165
+ "params": {
166
+ "id": "string (UUID format)"
167
+ }
168
+ },
169
+ "successStatus": 204,
170
+ "successDescription": "无响应体",
171
+ "errors": [
172
+ {
173
+ "status": 404,
174
+ "code": "NOT_FOUND",
175
+ "description": "书签未找到"
176
+ },
177
+ {
178
+ "status": 500,
179
+ "code": "INTERNAL_ERROR",
180
+ "description": "服务器内部错误"
181
+ }
182
+ ]
183
+ }
184
+ ],
185
+ "behaviors": []
186
+ }