project-knowledge 0.1.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/CHANGELOG.md +34 -0
- package/INDEX.md +53 -0
- package/README.md +79 -0
- package/_site/README.md +63 -0
- package/_site/_test/ai-profile-test.js +199 -0
- package/_site/_test/baseline-schema-test.js +132 -0
- package/_site/_test/commit-analysis-test.js +184 -0
- package/_site/_test/context-pack-test.js +199 -0
- package/_site/_test/draft-apply-test.js +363 -0
- package/_site/_test/git-validation-test.js +171 -0
- package/_site/_test/hook-trigger-test.js +257 -0
- package/_site/_test/initial-analysis-test.js +228 -0
- package/_site/_test/job-orchestrator-test.js +297 -0
- package/_site/_test/kb-v2-templates-test.js +189 -0
- package/_site/_test/pr-consumer-contract-test.js +236 -0
- package/_site/_test/run-all-tests.js +135 -0
- package/_site/_test/scanner-test.js +206 -0
- package/_site/_test/ui-smoke-test.js +237 -0
- package/_site/_test/ui-test.js +237 -0
- package/_site/index.html +1166 -0
- package/_site/lib/ai-adapter.js +287 -0
- package/_site/lib/analysis-orchestrator.js +433 -0
- package/_site/lib/context-pack-builder.js +290 -0
- package/_site/lib/draft-apply.js +219 -0
- package/_site/lib/git-runner.js +26 -0
- package/_site/lib/hook-manager.js +148 -0
- package/_site/lib/job-orchestrator.js +231 -0
- package/_site/lib/kb-validator.js +224 -0
- package/_site/lib/llm-client.js +126 -0
- package/_site/lib/scanner.js +94 -0
- package/_site/scripts/hook-trigger.js +133 -0
- package/_site/scripts/safe-runner.js +151 -0
- package/_site/server.js +1058 -0
- package/_site/start.bat +26 -0
- package/_site/stop.bat +11 -0
- package/ai-profiles.json +18 -0
- package/docs/ai-knowledge-base-system-design.md +395 -0
- package/docs/pr-consumer-contract.md +198 -0
- package/docs/project-goal.md +72 -0
- package/docs/project-registry-schema.md +46 -0
- package/docs/testing-strategy.md +169 -0
- package/iterations.json +23 -0
- package/package.json +47 -0
- package/scripts/gen-commit-doc.ps1 +178 -0
- package/scripts/gen-commit-doc.sh +197 -0
- package/scripts/list-features.ps1 +41 -0
- package/scripts/register-scheduled-task.bat +5 -0
- package/templates/change.md +59 -0
- package/templates/commit-feature.md +56 -0
- package/templates/feature.md +44 -0
- package/templates/framework.md +80 -0
- package/templates/index-header.md +3 -0
- package/templates/kb-manifest.json +38 -0
- package/templates/module.md +58 -0
- package/templates/project-analysis.md +48 -0
- package/templates/project-goal.md +55 -0
- package/templates/project-readme.md +60 -0
- package/templates/quality-review-rules.md +37 -0
- package/templates/update-entry.md +7 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// TASK-010: Job orchestrator test
|
|
2
|
+
// Run: node _site/_test/job-orchestrator-test.js
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawn } = require('child_process');
|
|
6
|
+
const { makeRepo } = require('./fixtures/make-git-repos');
|
|
7
|
+
const {
|
|
8
|
+
runJob, makeJob, readJobLog, appendJobLog, KNOWN_MODES,
|
|
9
|
+
} = require('../lib/job-orchestrator');
|
|
10
|
+
const { runInitialAnalysis, runCommitAnalysis, readRun, listDrafts } = require('../lib/analysis-orchestrator');
|
|
11
|
+
|
|
12
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
13
|
+
const SERVER = path.join(ROOT, '_site', 'server.js');
|
|
14
|
+
const PROJECTS_JSON = path.join(ROOT, 'projects.json');
|
|
15
|
+
const PORT = process.env.KB_JOB_TEST_PORT || '7800';
|
|
16
|
+
const BASE_URL = `http://127.0.0.1:${PORT}`;
|
|
17
|
+
const TEMP_SLUG = 'task-010-temp';
|
|
18
|
+
|
|
19
|
+
function assert(cond, msg) { if (!cond) throw new Error(msg); }
|
|
20
|
+
|
|
21
|
+
async function waitForServer() {
|
|
22
|
+
const deadline = Date.now() + 15000;
|
|
23
|
+
let lastError;
|
|
24
|
+
while (Date.now() < deadline) {
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetch(`${BASE_URL}/api/state`);
|
|
27
|
+
if (res.ok) return;
|
|
28
|
+
lastError = new Error(`HTTP ${res.status}`);
|
|
29
|
+
} catch (e) { lastError = e; }
|
|
30
|
+
await new Promise(r => setTimeout(r, 250));
|
|
31
|
+
}
|
|
32
|
+
throw lastError || new Error('server did not start');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function json(method, url, body) {
|
|
36
|
+
const res = await fetch(`${BASE_URL}${url}`, {
|
|
37
|
+
method,
|
|
38
|
+
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
39
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
40
|
+
});
|
|
41
|
+
const text = await res.text();
|
|
42
|
+
let data = {};
|
|
43
|
+
if (text) { try { data = JSON.parse(text); } catch { data = { raw: text }; } }
|
|
44
|
+
return { res, data };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function cleanup() {
|
|
48
|
+
const base = path.join(ROOT, 'projects', TEMP_SLUG);
|
|
49
|
+
fs.rmSync(base, { recursive: true, force: true });
|
|
50
|
+
const cur = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
|
|
51
|
+
if (cur[TEMP_SLUG]) {
|
|
52
|
+
delete cur[TEMP_SLUG];
|
|
53
|
+
fs.writeFileSync(PROJECTS_JSON, JSON.stringify(cur, null, 2) + '\n', 'utf-8');
|
|
54
|
+
}
|
|
55
|
+
const log = path.join(ROOT, '.jobs-log.json');
|
|
56
|
+
if (fs.existsSync(log)) fs.unlinkSync(log);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makeProjectsDir(slug) {
|
|
60
|
+
const base = path.join(ROOT, 'projects', slug);
|
|
61
|
+
fs.rmSync(base, { recursive: true, force: true });
|
|
62
|
+
fs.mkdirSync(base, { recursive: true });
|
|
63
|
+
fs.writeFileSync(path.join(base, 'project-goal.md'), '# Goal\n');
|
|
64
|
+
fs.mkdirSync(path.join(base, 'features'), { recursive: true });
|
|
65
|
+
fs.mkdirSync(path.join(base, 'changes'), { recursive: true });
|
|
66
|
+
return base;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
(async () => {
|
|
70
|
+
assert(fs.existsSync(SERVER), 'server.js missing');
|
|
71
|
+
|
|
72
|
+
// 1. KNOWN_MODES lists the expected set
|
|
73
|
+
for (const m of ['legacy', 'scan', 'analyze-initial', 'analyze-commits', 'safe']) {
|
|
74
|
+
assert(KNOWN_MODES.has(m), `KNOWN_MODES should include ${m}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 2. makeJob returns a job with the expected shape
|
|
78
|
+
const j = makeJob({ mode: 'scan', slug: 'ALL' });
|
|
79
|
+
assert(j.jobId && j.jobId.startsWith('job-'), 'jobId should start with job-');
|
|
80
|
+
assert(j.mode === 'scan' && j.slug === 'ALL', 'mode and slug set');
|
|
81
|
+
assert(j.status === 'running', 'initial status is running');
|
|
82
|
+
assert(typeof j.startTime === 'string', 'startTime is an ISO string');
|
|
83
|
+
|
|
84
|
+
// 3. appendJobLog / readJobLog round-trip
|
|
85
|
+
const log = path.join(ROOT, '.jobs-log-test.json');
|
|
86
|
+
if (fs.existsSync(log)) fs.unlinkSync(log);
|
|
87
|
+
appendJobLog(log, { jobId: 'job-a', status: 'success' });
|
|
88
|
+
appendJobLog(log, { jobId: 'job-b', status: 'failed' });
|
|
89
|
+
const readBack = readJobLog(log);
|
|
90
|
+
assert(readBack.length === 2, `expected 2 entries, got ${readBack.length}`);
|
|
91
|
+
assert(readBack[0].jobId === 'job-a' && readBack[1].jobId === 'job-b', 'entries in order');
|
|
92
|
+
fs.unlinkSync(log);
|
|
93
|
+
|
|
94
|
+
// 4. runJob with unknown mode returns failure but still records
|
|
95
|
+
const badJob = makeJob({ mode: 'no-such-mode', slug: 'ALL' });
|
|
96
|
+
const badResult = await runJob({
|
|
97
|
+
job: badJob,
|
|
98
|
+
projects: {},
|
|
99
|
+
projectsPath: PROJECTS_JSON,
|
|
100
|
+
jobsLogPath: log,
|
|
101
|
+
});
|
|
102
|
+
assert(!badResult.summary || badResult.summary.error, 'unknown mode should set summary.error');
|
|
103
|
+
assert(badResult.status === 'failed', 'unknown mode should fail');
|
|
104
|
+
fs.unlinkSync(log);
|
|
105
|
+
|
|
106
|
+
// ----- Integration: scan mode -----
|
|
107
|
+
const repo = makeRepo({ kind: 'multi-commit' });
|
|
108
|
+
const kbBase = makeProjectsDir(TEMP_SLUG);
|
|
109
|
+
const project = {
|
|
110
|
+
slug: TEMP_SLUG,
|
|
111
|
+
kbPath: kbBase,
|
|
112
|
+
gitPath: repo.path,
|
|
113
|
+
localPath: repo.path,
|
|
114
|
+
aiProfileId: 'mock-agent',
|
|
115
|
+
enabled: true,
|
|
116
|
+
headCommit: null,
|
|
117
|
+
lastSeenCommit: null,
|
|
118
|
+
lastAnalyzedCommit: null,
|
|
119
|
+
};
|
|
120
|
+
const projectsMap = { [TEMP_SLUG]: project };
|
|
121
|
+
|
|
122
|
+
const scanJob = makeJob({ mode: 'scan', slug: TEMP_SLUG });
|
|
123
|
+
const scanResult = await runJob({
|
|
124
|
+
job: scanJob,
|
|
125
|
+
projects: projectsMap,
|
|
126
|
+
projectsPath: PROJECTS_JSON,
|
|
127
|
+
jobsLogPath: log,
|
|
128
|
+
});
|
|
129
|
+
assert(scanResult.status === 'success', `scan should succeed, got ${scanResult.status}`);
|
|
130
|
+
assert(scanResult.summary && scanResult.summary.scanned === 1, `expected 1 scanned, got ${JSON.stringify(scanResult.summary)}`);
|
|
131
|
+
assert(projectsMap[TEMP_SLUG].headCommit, 'scan should populate headCommit');
|
|
132
|
+
assert(projectsMap[TEMP_SLUG].lastSeenCommit, 'scan should populate lastSeenCommit');
|
|
133
|
+
assert(projectsMap[TEMP_SLUG].lastScanPendingCount === 3, `expected 3 pending, got ${projectsMap[TEMP_SLUG].lastScanPendingCount}`);
|
|
134
|
+
|
|
135
|
+
// 5. safe mode runs scan + analyze-commits, and crucially does NOT apply drafts
|
|
136
|
+
// Reset the project so analyze-commits sees 3 pending
|
|
137
|
+
projectsMap[TEMP_SLUG].lastAnalyzedCommit = null;
|
|
138
|
+
const safeJob = makeJob({ mode: 'safe', slug: TEMP_SLUG });
|
|
139
|
+
const safeResult = await runJob({
|
|
140
|
+
job: safeJob,
|
|
141
|
+
projects: projectsMap,
|
|
142
|
+
projectsPath: PROJECTS_JSON,
|
|
143
|
+
jobsLogPath: log,
|
|
144
|
+
});
|
|
145
|
+
assert(safeResult.status === 'success', `safe should succeed, got ${safeResult.status}: ${safeResult.summary ? JSON.stringify(safeResult.summary) : ''}`);
|
|
146
|
+
assert(safeResult.summary && safeResult.summary.scan && safeResult.summary.commits, 'safe should include both scan and commits summaries');
|
|
147
|
+
assert(safeResult.summary.applied === 0, `safe must apply 0 drafts, got ${safeResult.summary.applied}`);
|
|
148
|
+
// Drafts were created under _ai/drafts/ but NOT applied to the formal KB
|
|
149
|
+
// Verify there is at least one draft on disk
|
|
150
|
+
const runs = fs.readdirSync(path.join(kbBase, '_ai', 'runs'));
|
|
151
|
+
const appliedRuns = runs.filter(r => {
|
|
152
|
+
try { return JSON.parse(fs.readFileSync(path.join(kbBase, '_ai', 'runs', r), 'utf-8')).applyStatus === 'applied'; }
|
|
153
|
+
catch { return false; }
|
|
154
|
+
});
|
|
155
|
+
assert(appliedRuns.length === 0, `safe mode must NOT apply drafts; found ${appliedRuns.length} applied run(s)`);
|
|
156
|
+
// Verify the formal KB does NOT have any features/<slug>.md or changes/<short>.md (except those from the init template)
|
|
157
|
+
const featuresDir = path.join(kbBase, 'features');
|
|
158
|
+
const changesDir = path.join(kbBase, 'changes');
|
|
159
|
+
const realFeatureFiles = fs.existsSync(featuresDir)
|
|
160
|
+
? fs.readdirSync(featuresDir).filter(f => f !== '00-index.md')
|
|
161
|
+
: [];
|
|
162
|
+
const realChangeFiles = fs.existsSync(changesDir)
|
|
163
|
+
? fs.readdirSync(changesDir).filter(f => f !== '00-index.md')
|
|
164
|
+
: [];
|
|
165
|
+
assert(realFeatureFiles.length === 0, `safe mode must not write features files; found: ${realFeatureFiles.join(',')}`);
|
|
166
|
+
assert(realChangeFiles.length === 0, `safe mode must not write changes files; found: ${realChangeFiles.join(',')}`);
|
|
167
|
+
|
|
168
|
+
// 6. analyze-commits mode runs analysis and creates drafts
|
|
169
|
+
projectsMap[TEMP_SLUG].lastAnalyzedCommit = null;
|
|
170
|
+
const acJob = makeJob({ mode: 'analyze-commits', slug: TEMP_SLUG });
|
|
171
|
+
const acResult = await runJob({
|
|
172
|
+
job: acJob,
|
|
173
|
+
projects: projectsMap,
|
|
174
|
+
projectsPath: PROJECTS_JSON,
|
|
175
|
+
jobsLogPath: log,
|
|
176
|
+
});
|
|
177
|
+
assert(acResult.status === 'success', `analyze-commits should succeed, got ${acResult.status}`);
|
|
178
|
+
assert(acResult.summary && acResult.summary.ran === 1, `expected 1 ran, got ${JSON.stringify(acResult.summary)}`);
|
|
179
|
+
|
|
180
|
+
// 7. analyze-initial mode skips a project that already has a goal
|
|
181
|
+
const aiJob = makeJob({ mode: 'analyze-initial', slug: TEMP_SLUG });
|
|
182
|
+
const aiResult = await runJob({
|
|
183
|
+
job: aiJob,
|
|
184
|
+
projects: projectsMap,
|
|
185
|
+
projectsPath: PROJECTS_JSON,
|
|
186
|
+
jobsLogPath: log,
|
|
187
|
+
});
|
|
188
|
+
assert(aiResult.summary && aiResult.summary.skipped === 1, `analyze-initial should skip project with existing goal; got ${JSON.stringify(aiResult.summary)}`);
|
|
189
|
+
|
|
190
|
+
// 8. analyze-initial on a project with no goal creates an initial run
|
|
191
|
+
const noGoalSlug = TEMP_SLUG + '-no-goal';
|
|
192
|
+
const noGoalBase = makeProjectsDir(noGoalSlug);
|
|
193
|
+
fs.unlinkSync(path.join(noGoalBase, 'project-goal.md'));
|
|
194
|
+
const noGoalRepo = makeRepo({ kind: 'one-commit' });
|
|
195
|
+
projectsMap[noGoalSlug] = {
|
|
196
|
+
slug: noGoalSlug,
|
|
197
|
+
kbPath: noGoalBase,
|
|
198
|
+
gitPath: noGoalRepo.path,
|
|
199
|
+
localPath: noGoalRepo.path,
|
|
200
|
+
aiProfileId: 'mock-agent',
|
|
201
|
+
enabled: true,
|
|
202
|
+
};
|
|
203
|
+
const aiJob2 = makeJob({ mode: 'analyze-initial', slug: noGoalSlug });
|
|
204
|
+
const aiResult2 = await runJob({
|
|
205
|
+
job: aiJob2,
|
|
206
|
+
projects: projectsMap,
|
|
207
|
+
projectsPath: PROJECTS_JSON,
|
|
208
|
+
jobsLogPath: log,
|
|
209
|
+
});
|
|
210
|
+
assert(aiResult2.summary && aiResult2.summary.ran === 1, `analyze-initial should run on a no-goal project; got ${JSON.stringify(aiResult2.summary)}`);
|
|
211
|
+
// Note: the orchestrator does not write project-goal.md; it writes a draft only.
|
|
212
|
+
assert(!fs.existsSync(path.join(noGoalBase, 'project-goal.md')), 'analyze-initial must NOT create project-goal.md directly (it creates a draft)');
|
|
213
|
+
|
|
214
|
+
// 9. scan with slug=ALL iterates enabled projects
|
|
215
|
+
projectsMap[TEMP_SLUG].headCommit = null;
|
|
216
|
+
projectsMap[TEMP_SLUG].lastSeenCommit = null;
|
|
217
|
+
const scanAllJob = makeJob({ mode: 'scan', slug: 'ALL' });
|
|
218
|
+
const scanAllResult = await runJob({
|
|
219
|
+
job: scanAllJob,
|
|
220
|
+
projects: projectsMap,
|
|
221
|
+
projectsPath: PROJECTS_JSON,
|
|
222
|
+
jobsLogPath: log,
|
|
223
|
+
});
|
|
224
|
+
assert(scanAllResult.summary.scanned >= 1, 'scan ALL should hit at least the test project');
|
|
225
|
+
|
|
226
|
+
// 10. unknown slug returns a clear error
|
|
227
|
+
const badSlugJob = makeJob({ mode: 'scan', slug: 'no-such-slug' });
|
|
228
|
+
const badSlugResult = await runJob({
|
|
229
|
+
job: badSlugJob,
|
|
230
|
+
projects: projectsMap,
|
|
231
|
+
projectsPath: PROJECTS_JSON,
|
|
232
|
+
jobsLogPath: log,
|
|
233
|
+
});
|
|
234
|
+
assert(badSlugResult.status === 'failed', 'scan on unknown slug should fail');
|
|
235
|
+
|
|
236
|
+
// 11. Job log persisted
|
|
237
|
+
const persistedLog = readJobLog(log);
|
|
238
|
+
assert(persistedLog.length >= 5, `expected ≥5 persisted jobs, got ${persistedLog.length}`);
|
|
239
|
+
|
|
240
|
+
repo.cleanup();
|
|
241
|
+
noGoalRepo.cleanup();
|
|
242
|
+
fs.rmSync(kbBase, { recursive: true, force: true });
|
|
243
|
+
fs.rmSync(noGoalBase, { recursive: true, force: true });
|
|
244
|
+
fs.unlinkSync(log);
|
|
245
|
+
|
|
246
|
+
// ----- Server tests -----
|
|
247
|
+
const child = spawn(process.execPath, [SERVER], {
|
|
248
|
+
cwd: ROOT,
|
|
249
|
+
env: { ...process.env, KB_SITE_PORT: PORT, KB_SITE_HOST: '127.0.0.1' },
|
|
250
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
251
|
+
windowsHide: true,
|
|
252
|
+
});
|
|
253
|
+
let serverOutput = '';
|
|
254
|
+
child.stdout.on('data', d => { serverOutput += d.toString(); });
|
|
255
|
+
child.stderr.on('data', d => { serverOutput += d.toString(); });
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
await cleanup();
|
|
259
|
+
await waitForServer();
|
|
260
|
+
|
|
261
|
+
// 12. /api/jobs/run dispatches a job and returns jobId
|
|
262
|
+
r = await json('POST', '/api/jobs/run', { mode: 'scan', slug: 'ALL' });
|
|
263
|
+
assert(r.res.ok, `jobs/run should succeed: ${JSON.stringify(r.data)}`);
|
|
264
|
+
assert(r.data.jobId && r.data.jobId.startsWith('job-'), 'should return jobId');
|
|
265
|
+
assert(r.data.mode === 'scan' && r.data.slug === 'ALL', 'should echo mode and slug');
|
|
266
|
+
const dispatchedJobId = r.data.jobId;
|
|
267
|
+
|
|
268
|
+
// 13. /api/jobs returns history
|
|
269
|
+
r = await json('GET', '/api/jobs');
|
|
270
|
+
assert(r.res.ok, 'GET /api/jobs should succeed');
|
|
271
|
+
assert(Array.isArray(r.data.history), 'history should be an array');
|
|
272
|
+
assert(Array.isArray(r.data.knownModes), 'knownModes should be an array');
|
|
273
|
+
assert(r.data.knownModes.includes('safe'), 'knownModes should include safe');
|
|
274
|
+
|
|
275
|
+
// 14. /api/jobs/:jobId returns the job (live or persisted)
|
|
276
|
+
r = await json('GET', `/api/jobs/${dispatchedJobId}`);
|
|
277
|
+
assert(r.res.ok, `GET job should succeed: ${JSON.stringify(r.data)}`);
|
|
278
|
+
assert(r.data.job.jobId === dispatchedJobId, 'should return the right job');
|
|
279
|
+
|
|
280
|
+
// 15. unknown mode returns 400
|
|
281
|
+
r = await json('POST', '/api/jobs/run', { mode: 'no-such-mode' });
|
|
282
|
+
assert(!r.res.ok && r.res.status === 400, 'unknown mode should 400');
|
|
283
|
+
|
|
284
|
+
// 16. unknown jobId returns 404
|
|
285
|
+
r = await json('GET', '/api/jobs/job-999999-9999');
|
|
286
|
+
assert(!r.res.ok && r.res.status === 404, 'unknown job should 404');
|
|
287
|
+
|
|
288
|
+
console.log('TASK-010 job orchestrator test passed');
|
|
289
|
+
} catch (e) {
|
|
290
|
+
console.error('TASK-010 job orchestrator test failed:', e.message);
|
|
291
|
+
if (serverOutput) console.error(serverOutput);
|
|
292
|
+
process.exitCode = 1;
|
|
293
|
+
} finally {
|
|
294
|
+
await cleanup().catch(() => {});
|
|
295
|
+
child.kill();
|
|
296
|
+
}
|
|
297
|
+
})();
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// TASK-003: KB v2 templates test
|
|
2
|
+
// Run: node _site/_test/kb-v2-templates-test.js
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawn } = require('child_process');
|
|
6
|
+
|
|
7
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
8
|
+
const SERVER = path.join(ROOT, '_site', 'server.js');
|
|
9
|
+
const PROJECTS_JSON = path.join(ROOT, 'projects.json');
|
|
10
|
+
const PORT = process.env.KB_V2_TEST_PORT || '7793';
|
|
11
|
+
const BASE_URL = `http://127.0.0.1:${PORT}`;
|
|
12
|
+
const TEMP_SLUG = 'task-003-temp';
|
|
13
|
+
|
|
14
|
+
const REQUIRED_V2_DIRS = [
|
|
15
|
+
'architecture', 'modules', 'commits', 'operations', 'references',
|
|
16
|
+
'requirements', 'features', 'changes', 'quality',
|
|
17
|
+
'_ai/drafts', '_ai/runs', '_ai/context-packs',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const REQUIRED_V2_FILES = [
|
|
21
|
+
'README.md',
|
|
22
|
+
'framework.md',
|
|
23
|
+
'project-goal.md',
|
|
24
|
+
'project-analysis.md',
|
|
25
|
+
'kb-manifest.json',
|
|
26
|
+
'architecture/overview.md',
|
|
27
|
+
'modules/00-index.md',
|
|
28
|
+
'commits/00-index.md',
|
|
29
|
+
'features/00-index.md',
|
|
30
|
+
'changes/00-index.md',
|
|
31
|
+
'quality/quality-review-rules.md',
|
|
32
|
+
'requirements/roadmap.md',
|
|
33
|
+
'requirements/constraints.md',
|
|
34
|
+
'requirements/success-criteria.md',
|
|
35
|
+
'references/source-map.md',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function assert(cond, msg) { if (!cond) throw new Error(msg); }
|
|
39
|
+
|
|
40
|
+
async function waitForServer() {
|
|
41
|
+
const deadline = Date.now() + 15000;
|
|
42
|
+
let lastError;
|
|
43
|
+
while (Date.now() < deadline) {
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch(`${BASE_URL}/api/state`);
|
|
46
|
+
if (res.ok) return;
|
|
47
|
+
lastError = new Error(`HTTP ${res.status}`);
|
|
48
|
+
} catch (e) { lastError = e; }
|
|
49
|
+
await new Promise(r => setTimeout(r, 250));
|
|
50
|
+
}
|
|
51
|
+
throw lastError || new Error('server did not start');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function json(method, url, body) {
|
|
55
|
+
const res = await fetch(`${BASE_URL}${url}`, {
|
|
56
|
+
method,
|
|
57
|
+
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
58
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
59
|
+
});
|
|
60
|
+
const text = await res.text();
|
|
61
|
+
let data = {};
|
|
62
|
+
if (text) { try { data = JSON.parse(text); } catch { data = { raw: text }; } }
|
|
63
|
+
return { res, data };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function cleanupTemp() {
|
|
67
|
+
const base = path.join(ROOT, 'projects', TEMP_SLUG);
|
|
68
|
+
fs.rmSync(base, { recursive: true, force: true });
|
|
69
|
+
const cur = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
|
|
70
|
+
if (cur[TEMP_SLUG]) {
|
|
71
|
+
delete cur[TEMP_SLUG];
|
|
72
|
+
fs.writeFileSync(PROJECTS_JSON, JSON.stringify(cur, null, 2) + '\n', 'utf-8');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
(async () => {
|
|
77
|
+
assert(fs.existsSync(SERVER), 'server.js missing');
|
|
78
|
+
JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
|
|
79
|
+
|
|
80
|
+
const child = spawn(process.execPath, [SERVER], {
|
|
81
|
+
cwd: ROOT,
|
|
82
|
+
env: { ...process.env, KB_SITE_PORT: PORT, KB_SITE_HOST: '127.0.0.1' },
|
|
83
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
84
|
+
windowsHide: true,
|
|
85
|
+
});
|
|
86
|
+
let serverOutput = '';
|
|
87
|
+
child.stdout.on('data', d => { serverOutput += d.toString(); });
|
|
88
|
+
child.stderr.on('data', d => { serverOutput += d.toString(); });
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
await cleanupTemp();
|
|
92
|
+
await waitForServer();
|
|
93
|
+
|
|
94
|
+
// 1. Upsert project + init
|
|
95
|
+
let r = await json('PUT', '/api/projects', {
|
|
96
|
+
slug: TEMP_SLUG,
|
|
97
|
+
config: {
|
|
98
|
+
displayName: 'TASK-003 Temp',
|
|
99
|
+
localPath: 'D:\\__no_such__',
|
|
100
|
+
gitPath: 'D:\\__no_such__',
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
assert(r.res.ok, 'upsert should succeed');
|
|
104
|
+
|
|
105
|
+
r = await json('POST', `/api/projects/${TEMP_SLUG}/init`);
|
|
106
|
+
assert(r.res.ok, 'init should succeed');
|
|
107
|
+
assert(r.data.kbSchemaVersion === 'v2', `init should set schema v2, got ${r.data.kbSchemaVersion}`);
|
|
108
|
+
|
|
109
|
+
const base = path.join(ROOT, 'projects', TEMP_SLUG);
|
|
110
|
+
for (const sub of REQUIRED_V2_DIRS) {
|
|
111
|
+
const p = path.join(base, sub);
|
|
112
|
+
assert(fs.existsSync(p) && fs.statSync(p).isDirectory(), `missing dir: ${sub}`);
|
|
113
|
+
}
|
|
114
|
+
for (const rel of REQUIRED_V2_FILES) {
|
|
115
|
+
const p = path.join(base, rel);
|
|
116
|
+
assert(fs.existsSync(p), `missing file: ${rel}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 2. Manifest parses and points to existing files
|
|
120
|
+
const manifestPath = path.join(base, 'kb-manifest.json');
|
|
121
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
122
|
+
assert(manifest.schema === 'kb-manifest/v1', 'manifest schema should be v1');
|
|
123
|
+
assert(manifest.project === TEMP_SLUG, 'manifest project should match');
|
|
124
|
+
assert(manifest.goal.path === 'project-goal.md', 'manifest should point at project-goal.md');
|
|
125
|
+
assert(Array.isArray(manifest.trustedKnowledge) && manifest.trustedKnowledge.length > 0, 'trustedKnowledge should be non-empty');
|
|
126
|
+
for (const tpath of [manifest.goal.path, manifest.analysis.path, ...manifest.indexes ? Object.values(manifest.indexes) : []]) {
|
|
127
|
+
const full = path.join(base, tpath);
|
|
128
|
+
assert(fs.existsSync(full), `manifest should not point to missing file: ${tpath}`);
|
|
129
|
+
}
|
|
130
|
+
for (const trusted of manifest.trustedKnowledge) {
|
|
131
|
+
const full = path.join(base, trusted);
|
|
132
|
+
assert(fs.existsSync(full), `trusted entry should exist: ${trusted}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 3. Templates rendered with project name
|
|
136
|
+
const goal = fs.readFileSync(path.join(base, 'project-goal.md'), 'utf-8');
|
|
137
|
+
assert(goal.includes(TEMP_SLUG), 'project-goal should contain project name');
|
|
138
|
+
assert(goal.includes('TODO'), 'project-goal should still have TODO placeholders');
|
|
139
|
+
const analysis = fs.readFileSync(path.join(base, 'project-analysis.md'), 'utf-8');
|
|
140
|
+
assert(analysis.includes(TEMP_SLUG), 'project-analysis should contain project name');
|
|
141
|
+
const feature = fs.readFileSync(path.join(base, 'features/00-index.md'), 'utf-8');
|
|
142
|
+
assert(feature.includes('Features'), 'features index should be created');
|
|
143
|
+
|
|
144
|
+
// 4. Idempotent re-init
|
|
145
|
+
const before = fs.readFileSync(manifestPath, 'utf-8');
|
|
146
|
+
r = await json('POST', `/api/projects/${TEMP_SLUG}/init`);
|
|
147
|
+
assert(r.res.ok, 're-init should succeed');
|
|
148
|
+
const after = fs.readFileSync(manifestPath, 'utf-8');
|
|
149
|
+
assert(before === after, 're-init should not rewrite manifest');
|
|
150
|
+
|
|
151
|
+
// 5. Legacy commits/ is preserved when migrating an existing v1 project
|
|
152
|
+
const legacySlug = 'task-003-legacy';
|
|
153
|
+
const legacyBase = path.join(ROOT, 'projects', legacySlug);
|
|
154
|
+
fs.mkdirSync(path.join(legacyBase, 'commits'), { recursive: true });
|
|
155
|
+
const legacyCommitFile = path.join(legacyBase, 'commits', '2026-01-01_aaaaaaa_test.md');
|
|
156
|
+
fs.writeFileSync(legacyCommitFile, 'legacy commit file — should not be deleted\n');
|
|
157
|
+
fs.writeFileSync(path.join(legacyBase, 'README.md'), 'legacy readme\n');
|
|
158
|
+
fs.mkdirSync(path.join(legacyBase, 'modules'), { recursive: true });
|
|
159
|
+
fs.writeFileSync(path.join(legacyBase, 'modules', 'foo.md'), 'legacy module\n');
|
|
160
|
+
|
|
161
|
+
r = await json('PUT', '/api/projects', { slug: legacySlug, config: { displayName: 'legacy' } });
|
|
162
|
+
assert(r.res.ok, 'legacy upsert should succeed');
|
|
163
|
+
r = await json('POST', `/api/projects/${legacySlug}/migrate-v2`);
|
|
164
|
+
assert(r.res.ok, 'migrate-v2 should succeed');
|
|
165
|
+
assert(fs.existsSync(legacyCommitFile), 'migrate-v2 must preserve legacy commits/ files');
|
|
166
|
+
assert(fs.existsSync(path.join(legacyBase, 'kb-manifest.json')), 'migrate-v2 should create manifest');
|
|
167
|
+
assert(fs.existsSync(path.join(legacyBase, 'project-goal.md')), 'migrate-v2 should create project-goal.md');
|
|
168
|
+
|
|
169
|
+
// 6. Bad slug still 400
|
|
170
|
+
r = await json('POST', '/api/projects/INVALID../migrate-v2');
|
|
171
|
+
assert(!r.res.ok && r.res.status === 400, 'migrate-v2 bad slug should 400');
|
|
172
|
+
|
|
173
|
+
console.log('TASK-003 KB v2 templates test passed');
|
|
174
|
+
} catch (e) {
|
|
175
|
+
console.error('TASK-003 KB v2 templates test failed:', e.message);
|
|
176
|
+
if (serverOutput) console.error(serverOutput);
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
} finally {
|
|
179
|
+
await cleanupTemp().catch(() => {});
|
|
180
|
+
const legacyBase = path.join(ROOT, 'projects', 'task-003-legacy');
|
|
181
|
+
fs.rmSync(legacyBase, { recursive: true, force: true });
|
|
182
|
+
const cur = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
|
|
183
|
+
if (cur['task-003-legacy']) {
|
|
184
|
+
delete cur['task-003-legacy'];
|
|
185
|
+
fs.writeFileSync(PROJECTS_JSON, JSON.stringify(cur, null, 2) + '\n', 'utf-8');
|
|
186
|
+
}
|
|
187
|
+
child.kill();
|
|
188
|
+
}
|
|
189
|
+
})();
|