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.
- package/.ai-spec-workspace.json +17 -0
- package/.ai-spec.json +7 -0
- package/cli/pipeline/single-repo.ts +19 -10
- package/core/cli-ui.ts +136 -0
- package/core/code-generator.ts +4 -2
- package/core/error-feedback.ts +4 -2
- package/core/provider-utils.ts +8 -7
- package/demo-backend/.ai-spec-constitution.md +65 -0
- package/demo-backend/package.json +21 -0
- package/demo-backend/prisma/schema.prisma +22 -0
- package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.dsl.json +186 -0
- package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.md +211 -0
- package/demo-backend/src/controllers/bookmark.controller.test.ts +255 -0
- package/demo-backend/src/controllers/bookmark.controller.ts +187 -0
- package/demo-backend/src/index.ts +17 -0
- package/demo-backend/src/routes/bookmark.routes.test.ts +264 -0
- package/demo-backend/src/routes/bookmark.routes.ts +11 -0
- package/demo-backend/src/routes/index.ts +8 -0
- package/demo-backend/src/services/bookmark.service.test.ts +433 -0
- package/demo-backend/src/services/bookmark.service.ts +261 -0
- package/demo-backend/tsconfig.json +12 -0
- package/demo-frontend/.ai-spec-constitution.md +95 -0
- package/demo-frontend/package.json +23 -0
- package/demo-frontend/src/App.tsx +12 -0
- package/demo-frontend/src/main.tsx +9 -0
- package/demo-frontend/tsconfig.json +13 -0
- package/dist/cli/index.js +130 -21
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +130 -21
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.js +80 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +80 -8
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/RELEASE_LOG.md +0 -2962
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
180
|
+
constitutionSpinner.succeed("Constitution generated and saved (.ai-spec-constitution.md)");
|
|
180
181
|
} catch (err) {
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
383
|
+
dslSpinner.succeed("DSL extracted and validated.");
|
|
377
384
|
} else {
|
|
378
385
|
runLogger.stageEnd("dsl_extract", { skipped: true });
|
|
379
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/core/code-generator.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
670
|
+
fileSpinner.fail(`${chalk.bold(item.file)} — ${(err as Error).message}`);
|
|
669
671
|
}
|
|
670
672
|
}
|
|
671
673
|
|
package/core/error-feedback.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
383
|
+
fixSpinner.fail(`Could not auto-fix: ${file}`);
|
|
382
384
|
}
|
|
383
385
|
}
|
|
384
386
|
|
package/core/provider-utils.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.dsl.json
ADDED
|
@@ -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
|
+
}
|