sillyspec 3.18.2 → 3.18.4
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/docs/brainstorm-plan-contract.md +64 -0
- package/docs/plan-execute-contract.md +123 -0
- package/docs/revision-mode.md +115 -0
- package/docs/sillyspec/file-lifecycle.md +13 -4
- package/docs/workflow-contract-regression.md +106 -0
- package/package.json +1 -1
- package/packages/dashboard/dist/assets/{index-DpLHK4jv.js → index-Bq_Z2hne.js} +568 -568
- package/packages/dashboard/dist/assets/{index-BcM2J-hv.css → index-O2W5RV4z.css} +1 -1
- package/packages/dashboard/dist/index.html +16 -16
- package/packages/dashboard/src/components/PipelineStage.vue +22 -2
- package/packages/dashboard/src/components/PipelineView.vue +10 -2
- package/packages/dashboard/src/components/StageBadge.vue +17 -3
- package/packages/dashboard/src/components/StepCard.vue +7 -2
- package/src/change-risk-profile.js +167 -0
- package/src/contract-matrix.js +278 -0
- package/src/db.js +6 -0
- package/src/endpoint-extractor.js +315 -0
- package/src/index.js +53 -6
- package/src/init.js +31 -4
- package/src/knowledge-match.js +130 -0
- package/src/progress.js +464 -11
- package/src/run.js +287 -7
- package/src/scan-postcheck.js +34 -2
- package/src/stage-contract.js +86 -6
- package/src/stages/brainstorm.js +23 -0
- package/src/stages/execute.js +158 -4
- package/src/stages/plan.js +82 -0
- package/src/stages/scan.js +40 -0
- package/src/stages/verify.js +63 -2
- package/src/worktree.js +264 -35
- package/test/brainstorm-plan-contract.test.mjs +273 -0
- package/test/contract-artifacts.test.mjs +323 -0
- package/test/knowledge-match.test.mjs +231 -0
- package/test/plan-execute-contract.test.mjs +330 -0
- package/test/platform-failure-samples.test.mjs +4 -0
- package/test/revision-v1.test.mjs +1145 -0
- package/test/scan-knowledge.test.mjs +175 -0
- package/test/scan-postcheck.test.mjs +3 -0
- package/test/spec-dir.test.mjs +8 -3
- package/test/stage-definitions.test.mjs +1 -1
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* endpoint-extractor 和 contract-matrix 测试
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it } from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { writeFileSync, mkdirSync, rmSync } from 'fs'
|
|
8
|
+
import { join } from 'path'
|
|
9
|
+
import { tmpdir } from 'os'
|
|
10
|
+
import {
|
|
11
|
+
extractFastApiEndpoints,
|
|
12
|
+
extractFrontendApiCalls,
|
|
13
|
+
normalizePath,
|
|
14
|
+
diffApiParity,
|
|
15
|
+
} from '../src/endpoint-extractor.js'
|
|
16
|
+
import {
|
|
17
|
+
classifyTask,
|
|
18
|
+
} from '../src/contract-matrix.js'
|
|
19
|
+
|
|
20
|
+
// ─── 路径归一化 ─────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
describe('normalizePath', () => {
|
|
23
|
+
it('模板字符串归一化', () => {
|
|
24
|
+
assert.equal(normalizePath('/api/ppm/project-plan/${id}/plan-nodes'), '/api/ppm/project-plan/{param}/plan-nodes')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('Express 风格参数归一化', () => {
|
|
28
|
+
assert.equal(normalizePath('/api/users/:userId/posts'), '/api/users/{param}/posts')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('无参数不改变', () => {
|
|
32
|
+
assert.equal(normalizePath('/api/ppm/plan-node'), '/api/ppm/plan-node')
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// ─── FastAPI 端点提取 ──────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
describe('extractFastApiEndpoints', () => {
|
|
39
|
+
const tmpDir = join(tmpdir(), 'sillyspec-test-fastapi')
|
|
40
|
+
const routerFile = join(tmpDir, 'router.py')
|
|
41
|
+
|
|
42
|
+
it('提取单行装饰器端点', () => {
|
|
43
|
+
mkdirSync(tmpDir, { recursive: true })
|
|
44
|
+
writeFileSync(routerFile, [
|
|
45
|
+
'from fastapi import APIRouter',
|
|
46
|
+
'router = APIRouter(prefix="/api/ppm")',
|
|
47
|
+
'',
|
|
48
|
+
'@router.get("/plan-node")',
|
|
49
|
+
'async def list_plan_nodes():',
|
|
50
|
+
' pass',
|
|
51
|
+
'',
|
|
52
|
+
'@router.post("/plan-node")',
|
|
53
|
+
'async def create_plan_node():',
|
|
54
|
+
' pass',
|
|
55
|
+
'',
|
|
56
|
+
'@router.get("/project-plan/{plan_id}/plan-nodes")',
|
|
57
|
+
'async def list_ps_plan_nodes(plan_id: str):',
|
|
58
|
+
' pass',
|
|
59
|
+
].join('\n'), 'utf8')
|
|
60
|
+
|
|
61
|
+
const endpoints = extractFastApiEndpoints(routerFile)
|
|
62
|
+
assert.equal(endpoints.length, 3)
|
|
63
|
+
assert.equal(endpoints[0].method, 'GET')
|
|
64
|
+
assert.equal(endpoints[0].path, '/api/ppm/plan-node')
|
|
65
|
+
assert.equal(endpoints[1].method, 'POST')
|
|
66
|
+
assert.equal(endpoints[1].path, '/api/ppm/plan-node')
|
|
67
|
+
assert.equal(endpoints[2].method, 'GET')
|
|
68
|
+
assert.equal(endpoints[2].path, '/api/ppm/project-plan/{plan_id}/plan-nodes')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('prefix + 路径正确合并', () => {
|
|
72
|
+
mkdirSync(tmpDir, { recursive: true })
|
|
73
|
+
writeFileSync(routerFile, [
|
|
74
|
+
'router = APIRouter(prefix="/api/v2")',
|
|
75
|
+
'@router.get("/users")',
|
|
76
|
+
'async def list_users():',
|
|
77
|
+
' pass',
|
|
78
|
+
].join('\n'), 'utf8')
|
|
79
|
+
|
|
80
|
+
const endpoints = extractFastApiEndpoints(routerFile)
|
|
81
|
+
assert.equal(endpoints.length, 1)
|
|
82
|
+
assert.equal(endpoints[0].path, '/api/v2/users')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('空文件返回空', () => {
|
|
86
|
+
mkdirSync(tmpDir, { recursive: true })
|
|
87
|
+
writeFileSync(routerFile, '', 'utf8')
|
|
88
|
+
const endpoints = extractFastApiEndpoints(routerFile)
|
|
89
|
+
assert.equal(endpoints.length, 0)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// cleanup
|
|
93
|
+
it('cleanup', () => {
|
|
94
|
+
try { rmSync(tmpDir, { recursive: true, force: true }) } catch {}
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// ─── 前端 API 调用提取 ─────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
describe('extractFrontendApiCalls', () => {
|
|
101
|
+
const tmpDir = join(tmpdir(), 'sillyspec-test-frontend')
|
|
102
|
+
const apiFile = join(tmpDir, 'plan.ts')
|
|
103
|
+
|
|
104
|
+
it('提取 apiFetch 调用', () => {
|
|
105
|
+
mkdirSync(tmpDir, { recursive: true })
|
|
106
|
+
writeFileSync(apiFile, [
|
|
107
|
+
'export async function listPlanNodes(params: PageReq): Promise<PlanNode[]> {',
|
|
108
|
+
' return apiFetch<PlanNode[]>("/api/ppm/plan-node", { query: params });',
|
|
109
|
+
'}',
|
|
110
|
+
'',
|
|
111
|
+
'export async function getProjectPlan(planId: string): Promise<ProjectPlan> {',
|
|
112
|
+
' return apiFetch<ProjectPlan>(`/api/ppm/project-plan/${planId}`);',
|
|
113
|
+
'}',
|
|
114
|
+
'',
|
|
115
|
+
'export async function listPlanNodesByPlan(planId: string): Promise<PsPlanNode[]> {',
|
|
116
|
+
' return apiFetch<PsPlanNode[]>(`/api/ppm/project-plan/${planId}/plan-nodes`);',
|
|
117
|
+
'}',
|
|
118
|
+
'',
|
|
119
|
+
'export async function createPlanNode(body: CreateReq): Promise<PlanNode> {',
|
|
120
|
+
' return apiFetch<PlanNode>("/api/ppm/plan-node", {',
|
|
121
|
+
' method: "POST",',
|
|
122
|
+
' json: body,',
|
|
123
|
+
' });',
|
|
124
|
+
'}',
|
|
125
|
+
'',
|
|
126
|
+
'export async function deletePlan(id: string): Promise<void> {',
|
|
127
|
+
' await apiFetch(`/api/ppm/plan-node/${id}`, { method: "DELETE" });',
|
|
128
|
+
'}',
|
|
129
|
+
].join('\n'), 'utf8')
|
|
130
|
+
|
|
131
|
+
const calls = extractFrontendApiCalls(apiFile)
|
|
132
|
+
assert.ok(calls.length >= 5)
|
|
133
|
+
|
|
134
|
+
// GET /api/ppm/plan-node
|
|
135
|
+
const listCall = calls.find(c => c.raw === '/api/ppm/plan-node' && c.method === 'GET')
|
|
136
|
+
assert.ok(listCall, 'should find GET /api/ppm/plan-node')
|
|
137
|
+
|
|
138
|
+
// GET with template string → 归一化
|
|
139
|
+
const detailCall = calls.find(c => c.path === '/api/ppm/project-plan/{param}')
|
|
140
|
+
assert.ok(detailCall, 'should find GET /api/ppm/project-plan/{param}')
|
|
141
|
+
|
|
142
|
+
// POST
|
|
143
|
+
const createCall = calls.find(c => c.method === 'POST')
|
|
144
|
+
assert.ok(createCall, 'should find POST call')
|
|
145
|
+
|
|
146
|
+
// DELETE
|
|
147
|
+
const deleteCall = calls.find(c => c.method === 'DELETE')
|
|
148
|
+
assert.ok(deleteCall, 'should find DELETE call')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('模板字符串归一化为 {param}', () => {
|
|
152
|
+
mkdirSync(tmpDir, { recursive: true })
|
|
153
|
+
writeFileSync(apiFile, [
|
|
154
|
+
'const id = "123";',
|
|
155
|
+
'apiFetch(`/api/users/${id}/profile`);',
|
|
156
|
+
].join('\n'), 'utf8')
|
|
157
|
+
|
|
158
|
+
const calls = extractFrontendApiCalls(apiFile)
|
|
159
|
+
assert.equal(calls.length, 1)
|
|
160
|
+
assert.equal(calls[0].path, '/api/users/{param}/profile')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// cleanup
|
|
164
|
+
it('cleanup', () => {
|
|
165
|
+
try { rmSync(tmpDir, { recursive: true, force: true }) } catch {}
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// ─── Parity Check ──────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
describe('diffApiParity', () => {
|
|
172
|
+
it('前端调用后端不存在路径时 missingBackend', () => {
|
|
173
|
+
const frontendCalls = [
|
|
174
|
+
{ method: 'GET', path: '/api/ppm/plan-node', source: 'plan.ts', line: 10 },
|
|
175
|
+
{ method: 'GET', path: '/api/ppm/project-plan/{param}/plan-nodes', source: 'plan.ts', line: 20 },
|
|
176
|
+
]
|
|
177
|
+
const backendEndpoints = [
|
|
178
|
+
{ method: 'GET', path: '/api/ppm/plan-node', source: 'router.py' },
|
|
179
|
+
// 缺少 /project-plan/{id}/plan-nodes
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
const result = diffApiParity(frontendCalls, backendEndpoints)
|
|
183
|
+
assert.equal(result.missingBackend.length, 1)
|
|
184
|
+
assert.equal(result.missingBackend[0].path, '/api/ppm/project-plan/{param}/plan-nodes')
|
|
185
|
+
assert.equal(result.unusedBackend.length, 0)
|
|
186
|
+
assert.equal(result.ok, false)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('全部匹配时 ok', () => {
|
|
190
|
+
const frontendCalls = [
|
|
191
|
+
{ method: 'GET', path: '/api/ppm/plan-node', source: 'plan.ts', line: 10 },
|
|
192
|
+
]
|
|
193
|
+
const backendEndpoints = [
|
|
194
|
+
{ method: 'GET', path: '/api/ppm/plan-node', source: 'router.py' },
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
const result = diffApiParity(frontendCalls, backendEndpoints)
|
|
198
|
+
assert.equal(result.ok, true)
|
|
199
|
+
assert.equal(result.missingBackend.length, 0)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('后端有但前端未调用的路径在 unusedBackend', () => {
|
|
203
|
+
const frontendCalls = []
|
|
204
|
+
const backendEndpoints = [
|
|
205
|
+
{ method: 'GET', path: '/api/ppm/internal/health', source: 'router.py' },
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
const result = diffApiParity(frontendCalls, backendEndpoints)
|
|
209
|
+
assert.equal(result.unusedBackend.length, 1)
|
|
210
|
+
assert.equal(result.unusedBackend[0].path, '/api/ppm/internal/health')
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// ─── Task 分类 ──────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
describe('classifyTask', () => {
|
|
217
|
+
it('后端 router task 识别为 provider', () => {
|
|
218
|
+
const content = '## 目标\n实现 plan 子域后端 router,包含 APIRouter 路由注册。'
|
|
219
|
+
const result = classifyTask(content)
|
|
220
|
+
assert.ok(result.isProvider)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('前端 API client task 识别为 consumer', () => {
|
|
224
|
+
const content = '## 目标\n为前端提供统一 API client,使用 apiFetch 封装。'
|
|
225
|
+
const result = classifyTask(content)
|
|
226
|
+
assert.ok(result.isConsumer)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('纯文档 task 两者都不是', () => {
|
|
230
|
+
const content = '## 目标\n更新 README 文档。'
|
|
231
|
+
const result = classifyTask(content)
|
|
232
|
+
assert.ok(!result.isProvider || result.confidence < 0.5)
|
|
233
|
+
assert.ok(!result.isConsumer || result.confidence < 0.5)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
// ─── 集成测试:复现 PPM 真实场景 ───────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
describe('PPM 真实场景:跨 task 端点漏实现', () => {
|
|
240
|
+
const tmpDir = join(tmpdir(), 'sillyspec-test-ppm')
|
|
241
|
+
const routerFile = join(tmpDir, 'router.py')
|
|
242
|
+
const apiClientFile = join(tmpDir, 'plan.ts')
|
|
243
|
+
|
|
244
|
+
it('verify 应该 FAIL:前端调用 /project-plan/{id}/plan-nodes 但后端未实现', () => {
|
|
245
|
+
mkdirSync(tmpDir, { recursive: true })
|
|
246
|
+
|
|
247
|
+
// 模拟 task-04 后端实现:只注册了基础 CRUD,遗漏嵌套端点
|
|
248
|
+
writeFileSync(routerFile, [
|
|
249
|
+
'router = APIRouter(prefix="/api/ppm")',
|
|
250
|
+
'',
|
|
251
|
+
'@router.get("/plan-node")',
|
|
252
|
+
'async def list_plan_nodes():',
|
|
253
|
+
' pass',
|
|
254
|
+
'',
|
|
255
|
+
'@router.post("/plan-node")',
|
|
256
|
+
'async def create_plan_node():',
|
|
257
|
+
' pass',
|
|
258
|
+
'',
|
|
259
|
+
'@router.get("/project-plan")',
|
|
260
|
+
'async def list_project_plans():',
|
|
261
|
+
' pass',
|
|
262
|
+
'',
|
|
263
|
+
'@router.post("/project-plan")',
|
|
264
|
+
'async def create_project_plan():',
|
|
265
|
+
' pass',
|
|
266
|
+
].join('\n'), 'utf8')
|
|
267
|
+
|
|
268
|
+
// 模拟 task-09 前端 API client:调用了后端不存在的嵌套端点
|
|
269
|
+
writeFileSync(apiClientFile, [
|
|
270
|
+
'export async function listProjectPlans(params: PageReq) {',
|
|
271
|
+
' return apiFetch<ProjectPlan[]>("/api/ppm/project-plan", pageQuery(params));',
|
|
272
|
+
'}',
|
|
273
|
+
'',
|
|
274
|
+
'export async function listPlanNodes(planId: string) {',
|
|
275
|
+
' // ← 后端没实现这个嵌套端点',
|
|
276
|
+
' return apiFetch<PsPlanNode[]>(`/api/ppm/project-plan/${planId}/plan-nodes`);',
|
|
277
|
+
'}',
|
|
278
|
+
'',
|
|
279
|
+
'export async function listPlanNodeDetails(nodeId: string) {',
|
|
280
|
+
' // ← 后端也没实现这个',
|
|
281
|
+
' return apiFetch<PlanNodeDetail[]>(`/api/ppm/plan-node/${nodeId}/details`);',
|
|
282
|
+
'}',
|
|
283
|
+
].join('\n'), 'utf8')
|
|
284
|
+
|
|
285
|
+
// 提取后端端点
|
|
286
|
+
const backendEndpoints = extractFastApiEndpoints(routerFile)
|
|
287
|
+
const backendPaths = new Set(backendEndpoints.map(e => normalizePath(e.path)))
|
|
288
|
+
|
|
289
|
+
// 提取前端调用
|
|
290
|
+
const frontendCalls = extractFrontendApiCalls(apiClientFile)
|
|
291
|
+
const { missingBackend, ok } = diffApiParity(frontendCalls, backendEndpoints)
|
|
292
|
+
|
|
293
|
+
// 断言:后端只实现了基础 CRUD
|
|
294
|
+
assert.ok(backendPaths.has('/api/ppm/plan-node'), '后端应有 GET /plan-node')
|
|
295
|
+
assert.ok(backendPaths.has('/api/ppm/project-plan'), '后端应有 GET /project-plan')
|
|
296
|
+
|
|
297
|
+
// 断言:parity check 失败
|
|
298
|
+
assert.equal(ok, false, 'verify 应该 FAIL')
|
|
299
|
+
assert.equal(missingBackend.length, 2, '应发现 2 个缺失端点')
|
|
300
|
+
|
|
301
|
+
// 断言:缺失的端点正是 PPM 真实 bug 的那两个
|
|
302
|
+
const missingPaths = missingBackend.map(m => m.path)
|
|
303
|
+
assert.ok(
|
|
304
|
+
missingPaths.includes('/api/ppm/project-plan/{param}/plan-nodes'),
|
|
305
|
+
'应捕获缺失的 /project-plan/{id}/plan-nodes'
|
|
306
|
+
)
|
|
307
|
+
assert.ok(
|
|
308
|
+
missingPaths.includes('/api/ppm/plan-node/{param}/details'),
|
|
309
|
+
'应捕获缺失的 /plan-node/{id}/details'
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
// 断言:diff 输出带 source 文件路径
|
|
313
|
+
const planNodesGap = missingBackend.find(
|
|
314
|
+
m => m.path === '/api/ppm/project-plan/{param}/plan-nodes'
|
|
315
|
+
)
|
|
316
|
+
assert.ok(planNodesGap.consumerFile.includes('plan.ts'), '应带前端文件路径')
|
|
317
|
+
assert.equal(planNodesGap.consumerLine, 7, '应带行号')
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('cleanup', () => {
|
|
321
|
+
try { rmSync(tmpDir, { recursive: true, force: true }) } catch {}
|
|
322
|
+
})
|
|
323
|
+
})
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* knowledge-match.test.mjs — knowledge 关键词匹配引擎测试
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { join } from 'path'
|
|
6
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'fs'
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'url'
|
|
8
|
+
import { tmpdir } from 'os'
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
11
|
+
const __dirname = join(__filename, '..') // join(__dirname, '..') to get parent
|
|
12
|
+
const root = join(__dirname, '..')
|
|
13
|
+
|
|
14
|
+
const { parseKnowledgeIndex, matchKnowledge } = await import(
|
|
15
|
+
pathToFileURL(join(root, 'src', 'knowledge-match.js')).href
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
let passed = 0, failed = 0
|
|
19
|
+
|
|
20
|
+
function assert(cond, msg) {
|
|
21
|
+
if (cond) { console.log(` ✅ PASS: ${msg}`); passed++ }
|
|
22
|
+
else { console.log(` ❌ FAIL: ${msg}`); failed++ }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function setup(name) {
|
|
26
|
+
const dir = join(tmpdir(), `kh-test-${name}`)
|
|
27
|
+
mkdirSync(dir, { recursive: true })
|
|
28
|
+
return dir
|
|
29
|
+
}
|
|
30
|
+
function clean(...dirs) {
|
|
31
|
+
for (const d of dirs) try { rmSync(d, { recursive: true, force: true }) } catch {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── 1: 有匹配条目 → matched=true, entries.length > 0 ──
|
|
35
|
+
console.log('\n=== Test 1: 有匹配条目 → matched=true ===')
|
|
36
|
+
{
|
|
37
|
+
const dir = setup('t1')
|
|
38
|
+
try {
|
|
39
|
+
writeFileSync(join(dir, 'INDEX.md'), [
|
|
40
|
+
'# Knowledge Index',
|
|
41
|
+
'',
|
|
42
|
+
'## Conventions',
|
|
43
|
+
'- ESM|module|import → [ESM Only](conventions.md#esm-only)',
|
|
44
|
+
'',
|
|
45
|
+
'## Patterns',
|
|
46
|
+
'- 阶段定义|stage|stages → [Stage Pattern](patterns.md#stage-step-pattern)',
|
|
47
|
+
].join('\n'))
|
|
48
|
+
|
|
49
|
+
const result = matchKnowledge(dir, 'setup ESM module imports')
|
|
50
|
+
assert(result.matched === true, 'matched is true')
|
|
51
|
+
assert(result.entries.length === 1, 'one entry matched')
|
|
52
|
+
assert(result.entries[0].file === 'conventions.md', 'file is conventions.md')
|
|
53
|
+
assert(result.entries[0].anchor === 'esm-only', 'anchor is esm-only')
|
|
54
|
+
assert(result.entries[0].keywords.includes('ESM'), 'keywords include ESM')
|
|
55
|
+
} finally { clean(dir) }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── 2: 无匹配条目 → matched=false, report 包含 "no matches" ──
|
|
59
|
+
console.log('\n=== Test 2: 无匹配条目 → matched=false ===')
|
|
60
|
+
{
|
|
61
|
+
const dir = setup('t2')
|
|
62
|
+
try {
|
|
63
|
+
writeFileSync(join(dir, 'INDEX.md'), [
|
|
64
|
+
'# Knowledge Index',
|
|
65
|
+
'',
|
|
66
|
+
'## Patterns',
|
|
67
|
+
'- 阶段定义|stage → [Stage Pattern](patterns.md#stage-step-pattern)',
|
|
68
|
+
].join('\n'))
|
|
69
|
+
|
|
70
|
+
const result = matchKnowledge(dir, 'implement authentication flow')
|
|
71
|
+
assert(result.matched === false, 'matched is false')
|
|
72
|
+
assert(result.entries.length === 0, 'no entries matched')
|
|
73
|
+
assert(result.report.includes('no matches'), 'report says no matches')
|
|
74
|
+
} finally { clean(dir) }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── 3: INDEX.md 不存在 → matched=false, report 包含 "not found" ──
|
|
78
|
+
console.log('\n=== Test 3: INDEX.md 不存在 → matched=false ===')
|
|
79
|
+
{
|
|
80
|
+
const dir = setup('t3')
|
|
81
|
+
try {
|
|
82
|
+
const result = matchKnowledge(dir, 'any context')
|
|
83
|
+
assert(result.matched === false, 'matched is false')
|
|
84
|
+
assert(result.report.includes('not found'), 'report says not found')
|
|
85
|
+
assert(result.json.matched === false, 'json.matched is false')
|
|
86
|
+
assert(result.json.entry_count === 0, 'json.entry_count is 0')
|
|
87
|
+
} finally { clean(dir) }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── 4: INDEX.md 有空锚点引用 → 正常处理(不过度校验)──
|
|
91
|
+
console.log('\n=== Test 4: INDEX.md 有空锚点引用 → 正常处理 ===')
|
|
92
|
+
{
|
|
93
|
+
const dir = setup('t4')
|
|
94
|
+
try {
|
|
95
|
+
writeFileSync(join(dir, 'INDEX.md'), [
|
|
96
|
+
'# Knowledge Index',
|
|
97
|
+
'',
|
|
98
|
+
'## Conventions',
|
|
99
|
+
'- naming|camelCase → [Naming](conventions.md)',
|
|
100
|
+
].join('\n'))
|
|
101
|
+
|
|
102
|
+
const result = matchKnowledge(dir, 'naming conventions')
|
|
103
|
+
assert(result.matched === true, 'matched is true with empty anchor')
|
|
104
|
+
assert(result.entries[0].anchor === '', 'anchor is empty string')
|
|
105
|
+
assert(result.entries[0].file === 'conventions.md', 'file is correct')
|
|
106
|
+
} finally { clean(dir) }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── 5: 大小写不敏感匹配 → "STAGE" 匹配 "stage" ──
|
|
110
|
+
console.log('\n=== Test 5: 大小写不敏感匹配 ===')
|
|
111
|
+
{
|
|
112
|
+
const dir = setup('t5')
|
|
113
|
+
try {
|
|
114
|
+
writeFileSync(join(dir, 'INDEX.md'), [
|
|
115
|
+
'# Knowledge Index',
|
|
116
|
+
'',
|
|
117
|
+
'## Patterns',
|
|
118
|
+
'- stage|stages → [Stage Pattern](patterns.md#stage-step-pattern)',
|
|
119
|
+
].join('\n'))
|
|
120
|
+
|
|
121
|
+
const result = matchKnowledge(dir, 'implement STAGE definition')
|
|
122
|
+
assert(result.matched === true, 'STAGE matches stage')
|
|
123
|
+
assert(result.entries.length === 1, 'one entry matched')
|
|
124
|
+
} finally { clean(dir) }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── 6: report 格式包含 "Knowledge Context" header ──
|
|
128
|
+
console.log('\n=== Test 6: report 格式包含 Knowledge Context header ===')
|
|
129
|
+
{
|
|
130
|
+
const dir = setup('t6')
|
|
131
|
+
try {
|
|
132
|
+
writeFileSync(join(dir, 'INDEX.md'), [
|
|
133
|
+
'# Knowledge Index',
|
|
134
|
+
'',
|
|
135
|
+
'## Patterns',
|
|
136
|
+
'- 阶段定义|stage → [Stage Pattern](patterns.md#stage-step-pattern)',
|
|
137
|
+
].join('\n'))
|
|
138
|
+
|
|
139
|
+
const result = matchKnowledge(dir, 'stage configuration')
|
|
140
|
+
assert(result.report.includes('Knowledge Context'), 'report has Knowledge Context header')
|
|
141
|
+
assert(result.report.includes('Status: matched'), 'report has Status: matched')
|
|
142
|
+
assert(result.report.includes('Entries: 1'), 'report has Entries: 1')
|
|
143
|
+
assert(result.report.includes('patterns.md#stage-step-pattern'), 'report has source')
|
|
144
|
+
} finally { clean(dir) }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── 7: json 结构正确 ──
|
|
148
|
+
console.log('\n=== Test 7: json 结构正确 ===')
|
|
149
|
+
{
|
|
150
|
+
const dir = setup('t7')
|
|
151
|
+
try {
|
|
152
|
+
writeFileSync(join(dir, 'INDEX.md'), [
|
|
153
|
+
'# Knowledge Index',
|
|
154
|
+
'',
|
|
155
|
+
'## Conventions',
|
|
156
|
+
'- ESM|module → [ESM Only](conventions.md#esm-only)',
|
|
157
|
+
'',
|
|
158
|
+
'## Patterns',
|
|
159
|
+
'- stage|stages → [Stage Pattern](patterns.md#stage-step-pattern)',
|
|
160
|
+
].join('\n'))
|
|
161
|
+
|
|
162
|
+
const result = matchKnowledge(dir, 'stage module setup')
|
|
163
|
+
const j = result.json
|
|
164
|
+
assert(j.matched === true, 'json.matched is true')
|
|
165
|
+
assert(j.entry_count === 2, 'json.entry_count is 2')
|
|
166
|
+
assert(Array.isArray(j.entries), 'json.entries is array')
|
|
167
|
+
assert(j.entries.length === 2, 'json.entries length is 2')
|
|
168
|
+
|
|
169
|
+
const stageEntry = j.entries.find(e => e.anchor === 'stage-step-pattern')
|
|
170
|
+
assert(!!stageEntry, 'stage entry found in json')
|
|
171
|
+
assert(stageEntry.keywords.includes('stage'), 'stage keywords correct')
|
|
172
|
+
assert(stageEntry.category === 'Patterns', 'stage category correct')
|
|
173
|
+
assert(stageEntry.file === 'patterns.md', 'stage file correct')
|
|
174
|
+
} finally { clean(dir) }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── 8: 空目录(目录存在但无 INDEX.md)→ no matches ──
|
|
178
|
+
console.log('\n=== Test 8: 空目录(无 INDEX.md)→ no matches ===')
|
|
179
|
+
{
|
|
180
|
+
const dir = setup('t8')
|
|
181
|
+
try {
|
|
182
|
+
const result = matchKnowledge(dir, 'any task')
|
|
183
|
+
assert(result.matched === false, 'no INDEX.md → matched false')
|
|
184
|
+
assert(result.report.includes('not found'), 'report says not found')
|
|
185
|
+
} finally { clean(dir) }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── 9: parseKnowledgeIndex 返回正确分类 ──
|
|
189
|
+
console.log('\n=== Test 9: parseKnowledgeIndex 分类正确 ===')
|
|
190
|
+
{
|
|
191
|
+
const dir = setup('t9')
|
|
192
|
+
try {
|
|
193
|
+
writeFileSync(join(dir, 'INDEX.md'), [
|
|
194
|
+
'# Knowledge Index',
|
|
195
|
+
'',
|
|
196
|
+
'## Known Issues',
|
|
197
|
+
'- GLM|proxy → [GLM Proxy](known-issues.md#glm-proxy)',
|
|
198
|
+
'',
|
|
199
|
+
'## Conventions',
|
|
200
|
+
'- 命名|naming → [命名规范](conventions.md#naming)',
|
|
201
|
+
].join('\n'))
|
|
202
|
+
|
|
203
|
+
const entries = parseKnowledgeIndex(dir)
|
|
204
|
+
assert(entries.length === 2, 'parsed 2 entries')
|
|
205
|
+
assert(entries[0].category === 'Known Issues', 'first entry category correct')
|
|
206
|
+
assert(entries[1].category === 'Conventions', 'second entry category correct')
|
|
207
|
+
assert(entries[0].display === 'GLM Proxy', 'display text correct')
|
|
208
|
+
} finally { clean(dir) }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── 10: taskContext 为空 → no matches ──
|
|
212
|
+
console.log('\n=== Test 10: taskContext 为空 → no matches ===')
|
|
213
|
+
{
|
|
214
|
+
const dir = setup('t10')
|
|
215
|
+
try {
|
|
216
|
+
writeFileSync(join(dir, 'INDEX.md'), [
|
|
217
|
+
'# Knowledge Index',
|
|
218
|
+
'',
|
|
219
|
+
'## Patterns',
|
|
220
|
+
'- stage → [Stage](patterns.md#stage)',
|
|
221
|
+
].join('\n'))
|
|
222
|
+
|
|
223
|
+
const result = matchKnowledge(dir, '')
|
|
224
|
+
assert(result.matched === false, 'empty context → matched false')
|
|
225
|
+
} finally { clean(dir) }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── 汇总 ──
|
|
229
|
+
console.log(`\n${'='.repeat(40)}`)
|
|
230
|
+
console.log(`knowledge-match tests: ${passed} passed, ${failed} failed, ${passed + failed} total`)
|
|
231
|
+
if (failed > 0) process.exit(1)
|