ai-spec-dev 0.35.0 → 0.37.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/RELEASE_LOG.md +139 -0
- package/cli/commands/config.ts +18 -0
- package/cli/commands/create.ts +16 -1
- package/cli/utils.ts +4 -0
- package/core/code-generator.ts +6 -4
- package/core/dsl-extractor.ts +9 -1
- package/core/dsl-feedback.ts +7 -1
- package/core/dsl-validator.ts +32 -0
- package/core/key-store.ts +5 -4
- package/core/provider-utils.ts +39 -4
- package/dist/cli/index.js +121 -14
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +122 -15
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +16 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.js +77 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +77 -9
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/tests/code-generator.test.ts +253 -0
- package/tests/context-loader.test.ts +207 -0
- package/tests/dsl-validator.test.ts +105 -0
- package/tests/mock-server-generator.test.ts +404 -0
- package/tests/openapi-exporter.test.ts +310 -0
- package/tests/reviewer.test.ts +214 -0
- package/tests/spec-generator.test.ts +228 -0
- package/tests/spec-versioning.test.ts +205 -0
- package/tests/types-generator.test.ts +347 -0
- package/tests/vcr.test.ts +355 -0
- package/.claude/commands/add-lesson.md +0 -34
- package/.claude/commands/check-layers.md +0 -65
- package/.claude/commands/installed-deps.md +0 -35
- package/.claude/commands/recall-lessons.md +0 -40
- package/.claude/commands/scan-singletons.md +0 -45
- package/.claude/commands/verify-imports.md +0 -48
- package/.claude/settings.local.json +0 -24
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createProvider,
|
|
4
|
+
SpecGenerator,
|
|
5
|
+
PROVIDER_CATALOG,
|
|
6
|
+
SUPPORTED_PROVIDERS,
|
|
7
|
+
DEFAULT_MODELS,
|
|
8
|
+
ENV_KEY_MAP,
|
|
9
|
+
GeminiProvider,
|
|
10
|
+
ClaudeProvider,
|
|
11
|
+
OpenAICompatibleProvider,
|
|
12
|
+
MiMoProvider,
|
|
13
|
+
} from "../core/spec-generator";
|
|
14
|
+
import type { AIProvider } from "../core/spec-generator";
|
|
15
|
+
|
|
16
|
+
// ─── Provider Catalog ────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
describe("PROVIDER_CATALOG", () => {
|
|
19
|
+
it("has at least 8 providers", () => {
|
|
20
|
+
expect(Object.keys(PROVIDER_CATALOG).length).toBeGreaterThanOrEqual(8);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("every provider has required fields", () => {
|
|
24
|
+
for (const [key, meta] of Object.entries(PROVIDER_CATALOG)) {
|
|
25
|
+
expect(meta.displayName, `${key} missing displayName`).toBeTruthy();
|
|
26
|
+
expect(meta.description, `${key} missing description`).toBeTruthy();
|
|
27
|
+
expect(meta.models.length, `${key} has no models`).toBeGreaterThan(0);
|
|
28
|
+
expect(meta.envKey, `${key} missing envKey`).toBeTruthy();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("each provider has a unique envKey", () => {
|
|
33
|
+
const envKeys = Object.values(PROVIDER_CATALOG).map((m) => m.envKey);
|
|
34
|
+
expect(new Set(envKeys).size).toBe(envKeys.length);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ─── Derived Maps ────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
describe("Derived maps", () => {
|
|
41
|
+
it("SUPPORTED_PROVIDERS matches PROVIDER_CATALOG keys", () => {
|
|
42
|
+
expect(SUPPORTED_PROVIDERS.sort()).toEqual(Object.keys(PROVIDER_CATALOG).sort());
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("DEFAULT_MODELS picks first model for each provider", () => {
|
|
46
|
+
for (const [key, meta] of Object.entries(PROVIDER_CATALOG)) {
|
|
47
|
+
expect(DEFAULT_MODELS[key]).toBe(meta.models[0]);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("ENV_KEY_MAP maps provider to envKey", () => {
|
|
52
|
+
for (const [key, meta] of Object.entries(PROVIDER_CATALOG)) {
|
|
53
|
+
expect(ENV_KEY_MAP[key]).toBe(meta.envKey);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ─── createProvider ──────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
describe("createProvider", () => {
|
|
61
|
+
it("creates GeminiProvider for 'gemini'", () => {
|
|
62
|
+
const provider = createProvider("gemini", "fake-key");
|
|
63
|
+
expect(provider).toBeInstanceOf(GeminiProvider);
|
|
64
|
+
expect(provider.providerName).toBe("gemini");
|
|
65
|
+
expect(provider.modelName).toBe(PROVIDER_CATALOG.gemini.models[0]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("creates ClaudeProvider for 'claude'", () => {
|
|
69
|
+
const provider = createProvider("claude", "fake-key");
|
|
70
|
+
expect(provider).toBeInstanceOf(ClaudeProvider);
|
|
71
|
+
expect(provider.providerName).toBe("claude");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("creates MiMoProvider for 'mimo'", () => {
|
|
75
|
+
const provider = createProvider("mimo", "fake-key");
|
|
76
|
+
expect(provider).toBeInstanceOf(MiMoProvider);
|
|
77
|
+
expect(provider.providerName).toBe("mimo");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("creates OpenAICompatibleProvider for 'openai'", () => {
|
|
81
|
+
const provider = createProvider("openai", "fake-key");
|
|
82
|
+
expect(provider).toBeInstanceOf(OpenAICompatibleProvider);
|
|
83
|
+
expect(provider.providerName).toBe("openai");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("creates OpenAICompatibleProvider for 'deepseek'", () => {
|
|
87
|
+
const provider = createProvider("deepseek", "fake-key");
|
|
88
|
+
expect(provider).toBeInstanceOf(OpenAICompatibleProvider);
|
|
89
|
+
expect(provider.providerName).toBe("deepseek");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("creates OpenAICompatibleProvider for 'qwen'", () => {
|
|
93
|
+
const provider = createProvider("qwen", "fake-key");
|
|
94
|
+
expect(provider).toBeInstanceOf(OpenAICompatibleProvider);
|
|
95
|
+
expect(provider.providerName).toBe("qwen");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("uses custom model name when provided", () => {
|
|
99
|
+
const provider = createProvider("gemini", "fake-key", "gemini-2.0-flash");
|
|
100
|
+
expect(provider.modelName).toBe("gemini-2.0-flash");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("throws for unknown provider", () => {
|
|
104
|
+
expect(() => createProvider("nonexistent", "key")).toThrow(/Unknown provider/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("throws with suggestion listing valid providers", () => {
|
|
108
|
+
expect(() => createProvider("bad", "key")).toThrow(/Valid options/);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ─── SpecGenerator ───────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
describe("SpecGenerator", () => {
|
|
115
|
+
function makeProvider(response: string): AIProvider {
|
|
116
|
+
return {
|
|
117
|
+
generate: vi.fn().mockResolvedValue(response),
|
|
118
|
+
providerName: "test",
|
|
119
|
+
modelName: "test-model",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
it("passes idea to provider", async () => {
|
|
124
|
+
const provider = makeProvider("generated spec");
|
|
125
|
+
const gen = new SpecGenerator(provider);
|
|
126
|
+
const result = await gen.generateSpec("Build a todo app");
|
|
127
|
+
expect(result).toBe("generated spec");
|
|
128
|
+
const [prompt] = (provider.generate as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
129
|
+
expect(prompt).toContain("Build a todo app");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("includes architecture decision when provided", async () => {
|
|
133
|
+
const provider = makeProvider("spec");
|
|
134
|
+
const gen = new SpecGenerator(provider);
|
|
135
|
+
await gen.generateSpec("idea", undefined, "Use microservices");
|
|
136
|
+
const [prompt] = (provider.generate as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
137
|
+
expect(prompt).toContain("Architecture Decision");
|
|
138
|
+
expect(prompt).toContain("Use microservices");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("includes project context when provided", async () => {
|
|
142
|
+
const provider = makeProvider("spec");
|
|
143
|
+
const gen = new SpecGenerator(provider);
|
|
144
|
+
await gen.generateSpec("idea", {
|
|
145
|
+
techStack: ["Express", "TypeScript"],
|
|
146
|
+
dependencies: ["express", "prisma"],
|
|
147
|
+
apiStructure: ["src/routes/user.ts"],
|
|
148
|
+
schema: "model User { id Int }",
|
|
149
|
+
constitution: "## 1. Architecture\nUse layered architecture",
|
|
150
|
+
} as any);
|
|
151
|
+
const [prompt] = (provider.generate as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
152
|
+
expect(prompt).toContain("Express");
|
|
153
|
+
expect(prompt).toContain("prisma");
|
|
154
|
+
expect(prompt).toContain("src/routes/user.ts");
|
|
155
|
+
expect(prompt).toContain("model User");
|
|
156
|
+
expect(prompt).toContain("Project Constitution");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("puts constitution before project context", async () => {
|
|
160
|
+
const provider = makeProvider("spec");
|
|
161
|
+
const gen = new SpecGenerator(provider);
|
|
162
|
+
await gen.generateSpec("idea", {
|
|
163
|
+
techStack: ["Express"],
|
|
164
|
+
dependencies: [],
|
|
165
|
+
apiStructure: [],
|
|
166
|
+
constitution: "CONSTITUTION_CONTENT",
|
|
167
|
+
} as any);
|
|
168
|
+
const [prompt] = (provider.generate as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
169
|
+
const constitutionIdx = prompt.indexOf("CONSTITUTION_CONTENT");
|
|
170
|
+
const contextIdx = prompt.indexOf("Project Context");
|
|
171
|
+
expect(constitutionIdx).toBeLessThan(contextIdx);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("omits constitution section when not provided", async () => {
|
|
175
|
+
const provider = makeProvider("spec");
|
|
176
|
+
const gen = new SpecGenerator(provider);
|
|
177
|
+
await gen.generateSpec("idea", {
|
|
178
|
+
techStack: [],
|
|
179
|
+
dependencies: [],
|
|
180
|
+
apiStructure: [],
|
|
181
|
+
} as any);
|
|
182
|
+
const [prompt] = (provider.generate as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
183
|
+
expect(prompt).not.toContain("Project Constitution");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("truncates schema to 3000 chars", async () => {
|
|
187
|
+
const provider = makeProvider("spec");
|
|
188
|
+
const gen = new SpecGenerator(provider);
|
|
189
|
+
const longSchema = "x".repeat(5000);
|
|
190
|
+
await gen.generateSpec("idea", {
|
|
191
|
+
techStack: [],
|
|
192
|
+
dependencies: [],
|
|
193
|
+
apiStructure: [],
|
|
194
|
+
schema: longSchema,
|
|
195
|
+
} as any);
|
|
196
|
+
const [prompt] = (provider.generate as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
197
|
+
// schema.slice(0, 3000) means at most 3000 chars of schema in prompt
|
|
198
|
+
expect(prompt).not.toContain("x".repeat(3001));
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("limits dependencies to 25 entries", async () => {
|
|
202
|
+
const provider = makeProvider("spec");
|
|
203
|
+
const gen = new SpecGenerator(provider);
|
|
204
|
+
const deps = Array.from({ length: 30 }, (_, i) => `dep-${i}`);
|
|
205
|
+
await gen.generateSpec("idea", {
|
|
206
|
+
techStack: [],
|
|
207
|
+
dependencies: deps,
|
|
208
|
+
apiStructure: [],
|
|
209
|
+
} as any);
|
|
210
|
+
const [prompt] = (provider.generate as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
211
|
+
expect(prompt).toContain("dep-24");
|
|
212
|
+
expect(prompt).not.toContain("dep-25");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("limits API structure to 10 entries", async () => {
|
|
216
|
+
const provider = makeProvider("spec");
|
|
217
|
+
const gen = new SpecGenerator(provider);
|
|
218
|
+
const apis = Array.from({ length: 15 }, (_, i) => `src/routes/route-${i}.ts`);
|
|
219
|
+
await gen.generateSpec("idea", {
|
|
220
|
+
techStack: [],
|
|
221
|
+
dependencies: [],
|
|
222
|
+
apiStructure: apis,
|
|
223
|
+
} as any);
|
|
224
|
+
const [prompt] = (provider.generate as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
225
|
+
expect(prompt).toContain("route-9");
|
|
226
|
+
expect(prompt).not.toContain("route-10");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "fs-extra";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as os from "os";
|
|
5
|
+
import {
|
|
6
|
+
slugify,
|
|
7
|
+
computeDiff,
|
|
8
|
+
findLatestVersion,
|
|
9
|
+
nextVersionPath,
|
|
10
|
+
} from "../core/spec-versioning";
|
|
11
|
+
|
|
12
|
+
// ─── slugify ─────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
describe("slugify", () => {
|
|
15
|
+
it("converts simple English to lowercase slug", () => {
|
|
16
|
+
expect(slugify("User Login")).toBe("user-login");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("removes special characters", () => {
|
|
20
|
+
expect(slugify("Add OAuth2.0 Support!")).toBe("add-oauth2-0-support");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("trims leading and trailing hyphens", () => {
|
|
24
|
+
expect(slugify("---hello---")).toBe("hello");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("handles CJK characters by stripping them", () => {
|
|
28
|
+
const result = slugify("用户登录 with OAuth");
|
|
29
|
+
expect(result).toContain("with");
|
|
30
|
+
expect(result).toContain("oauth");
|
|
31
|
+
expect(result).not.toMatch(/[\u4e00-\u9fa5]/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("limits length to 48 characters", () => {
|
|
35
|
+
const long = "a".repeat(100);
|
|
36
|
+
expect(slugify(long).length).toBeLessThanOrEqual(48);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns 'feature' for empty input", () => {
|
|
40
|
+
expect(slugify("")).toBe("feature");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns 'feature' for CJK-only input", () => {
|
|
44
|
+
// CJK gets stripped, leaving empty → fallback
|
|
45
|
+
expect(slugify("用户管理")).toBe("feature");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("collapses multiple consecutive hyphens", () => {
|
|
49
|
+
expect(slugify("hello world")).toBe("hello-world");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ─── computeDiff ─────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
describe("computeDiff", () => {
|
|
56
|
+
it("returns no changes for identical texts", () => {
|
|
57
|
+
const diff = computeDiff("hello\nworld", "hello\nworld");
|
|
58
|
+
expect(diff.added).toBe(0);
|
|
59
|
+
expect(diff.removed).toBe(0);
|
|
60
|
+
expect(diff.unchanged).toBe(2);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("detects added lines", () => {
|
|
64
|
+
const diff = computeDiff("line1", "line1\nline2");
|
|
65
|
+
expect(diff.added).toBe(1);
|
|
66
|
+
expect(diff.removed).toBe(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("detects removed lines", () => {
|
|
70
|
+
const diff = computeDiff("line1\nline2", "line1");
|
|
71
|
+
expect(diff.removed).toBe(1);
|
|
72
|
+
expect(diff.added).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("detects modified lines as remove + add", () => {
|
|
76
|
+
const diff = computeDiff("hello", "world");
|
|
77
|
+
expect(diff.removed).toBe(1);
|
|
78
|
+
expect(diff.added).toBe(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("handles empty old text", () => {
|
|
82
|
+
const diff = computeDiff("", "new content");
|
|
83
|
+
expect(diff.added).toBeGreaterThan(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("handles empty new text", () => {
|
|
87
|
+
const diff = computeDiff("old content", "");
|
|
88
|
+
expect(diff.removed).toBeGreaterThan(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("handles both empty", () => {
|
|
92
|
+
const diff = computeDiff("", "");
|
|
93
|
+
expect(diff.added).toBe(0);
|
|
94
|
+
expect(diff.removed).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("falls back to simple diff for large files (>800 lines)", () => {
|
|
98
|
+
const oldText = Array.from({ length: 900 }, (_, i) => `line ${i}`).join("\n");
|
|
99
|
+
const newText = Array.from({ length: 900 }, (_, i) => `modified ${i}`).join("\n");
|
|
100
|
+
const diff = computeDiff(oldText, newText);
|
|
101
|
+
// Simple diff marks all old as removed and all new as added
|
|
102
|
+
expect(diff.removed).toBe(900);
|
|
103
|
+
expect(diff.added).toBe(900);
|
|
104
|
+
expect(diff.unchanged).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("produces correct line types", () => {
|
|
108
|
+
const diff = computeDiff("keep\nremove", "keep\nadd");
|
|
109
|
+
const types = diff.lines.map((l) => l.type);
|
|
110
|
+
expect(types).toContain("unchanged");
|
|
111
|
+
expect(types).toContain("added");
|
|
112
|
+
expect(types).toContain("removed");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ─── findLatestVersion / nextVersionPath ─────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
describe("findLatestVersion", () => {
|
|
119
|
+
let tmpDir: string;
|
|
120
|
+
|
|
121
|
+
beforeEach(async () => {
|
|
122
|
+
tmpDir = path.join(os.tmpdir(), `version-test-${Date.now()}`);
|
|
123
|
+
await fs.ensureDir(tmpDir);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
afterEach(async () => {
|
|
127
|
+
await fs.remove(tmpDir);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns null for non-existent directory", async () => {
|
|
131
|
+
const result = await findLatestVersion("/nonexistent/path", "feature");
|
|
132
|
+
expect(result).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("returns null when no matching files exist", async () => {
|
|
136
|
+
await fs.writeFile(path.join(tmpDir, "unrelated.md"), "hello");
|
|
137
|
+
const result = await findLatestVersion(tmpDir, "user-login");
|
|
138
|
+
expect(result).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("finds v1 when only v1 exists", async () => {
|
|
142
|
+
await fs.writeFile(path.join(tmpDir, "feature-auth-v1.md"), "spec v1 content");
|
|
143
|
+
const result = await findLatestVersion(tmpDir, "auth");
|
|
144
|
+
expect(result).not.toBeNull();
|
|
145
|
+
expect(result!.version).toBe(1);
|
|
146
|
+
expect(result!.content).toBe("spec v1 content");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("finds latest version among multiple", async () => {
|
|
150
|
+
await fs.writeFile(path.join(tmpDir, "feature-auth-v1.md"), "v1");
|
|
151
|
+
await fs.writeFile(path.join(tmpDir, "feature-auth-v2.md"), "v2");
|
|
152
|
+
await fs.writeFile(path.join(tmpDir, "feature-auth-v3.md"), "v3");
|
|
153
|
+
const result = await findLatestVersion(tmpDir, "auth");
|
|
154
|
+
expect(result!.version).toBe(3);
|
|
155
|
+
expect(result!.content).toBe("v3");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("does not confuse different slugs", async () => {
|
|
159
|
+
await fs.writeFile(path.join(tmpDir, "feature-auth-v5.md"), "auth v5");
|
|
160
|
+
await fs.writeFile(path.join(tmpDir, "feature-user-v2.md"), "user v2");
|
|
161
|
+
const result = await findLatestVersion(tmpDir, "user");
|
|
162
|
+
expect(result!.version).toBe(2);
|
|
163
|
+
expect(result!.content).toBe("user v2");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("handles regex special characters in slug", async () => {
|
|
167
|
+
await fs.writeFile(path.join(tmpDir, "feature-auth-2.0-v1.md"), "spec");
|
|
168
|
+
const result = await findLatestVersion(tmpDir, "auth-2.0");
|
|
169
|
+
expect(result).not.toBeNull();
|
|
170
|
+
expect(result!.version).toBe(1);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("nextVersionPath", () => {
|
|
175
|
+
let tmpDir: string;
|
|
176
|
+
|
|
177
|
+
beforeEach(async () => {
|
|
178
|
+
tmpDir = path.join(os.tmpdir(), `nextver-test-${Date.now()}`);
|
|
179
|
+
await fs.ensureDir(tmpDir);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
afterEach(async () => {
|
|
183
|
+
await fs.remove(tmpDir);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("returns v1 when no versions exist", async () => {
|
|
187
|
+
const result = await nextVersionPath(tmpDir, "auth");
|
|
188
|
+
expect(result.version).toBe(1);
|
|
189
|
+
expect(result.filePath).toContain("feature-auth-v1.md");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("returns v2 when v1 exists", async () => {
|
|
193
|
+
await fs.writeFile(path.join(tmpDir, "feature-auth-v1.md"), "v1");
|
|
194
|
+
const result = await nextVersionPath(tmpDir, "auth");
|
|
195
|
+
expect(result.version).toBe(2);
|
|
196
|
+
expect(result.filePath).toContain("feature-auth-v2.md");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("returns v4 when v3 is latest", async () => {
|
|
200
|
+
await fs.writeFile(path.join(tmpDir, "feature-auth-v1.md"), "v1");
|
|
201
|
+
await fs.writeFile(path.join(tmpDir, "feature-auth-v3.md"), "v3");
|
|
202
|
+
const result = await nextVersionPath(tmpDir, "auth");
|
|
203
|
+
expect(result.version).toBe(4);
|
|
204
|
+
});
|
|
205
|
+
});
|