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.
Files changed (59) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/INDEX.md +53 -0
  3. package/README.md +79 -0
  4. package/_site/README.md +63 -0
  5. package/_site/_test/ai-profile-test.js +199 -0
  6. package/_site/_test/baseline-schema-test.js +132 -0
  7. package/_site/_test/commit-analysis-test.js +184 -0
  8. package/_site/_test/context-pack-test.js +199 -0
  9. package/_site/_test/draft-apply-test.js +363 -0
  10. package/_site/_test/git-validation-test.js +171 -0
  11. package/_site/_test/hook-trigger-test.js +257 -0
  12. package/_site/_test/initial-analysis-test.js +228 -0
  13. package/_site/_test/job-orchestrator-test.js +297 -0
  14. package/_site/_test/kb-v2-templates-test.js +189 -0
  15. package/_site/_test/pr-consumer-contract-test.js +236 -0
  16. package/_site/_test/run-all-tests.js +135 -0
  17. package/_site/_test/scanner-test.js +206 -0
  18. package/_site/_test/ui-smoke-test.js +237 -0
  19. package/_site/_test/ui-test.js +237 -0
  20. package/_site/index.html +1166 -0
  21. package/_site/lib/ai-adapter.js +287 -0
  22. package/_site/lib/analysis-orchestrator.js +433 -0
  23. package/_site/lib/context-pack-builder.js +290 -0
  24. package/_site/lib/draft-apply.js +219 -0
  25. package/_site/lib/git-runner.js +26 -0
  26. package/_site/lib/hook-manager.js +148 -0
  27. package/_site/lib/job-orchestrator.js +231 -0
  28. package/_site/lib/kb-validator.js +224 -0
  29. package/_site/lib/llm-client.js +126 -0
  30. package/_site/lib/scanner.js +94 -0
  31. package/_site/scripts/hook-trigger.js +133 -0
  32. package/_site/scripts/safe-runner.js +151 -0
  33. package/_site/server.js +1058 -0
  34. package/_site/start.bat +26 -0
  35. package/_site/stop.bat +11 -0
  36. package/ai-profiles.json +18 -0
  37. package/docs/ai-knowledge-base-system-design.md +395 -0
  38. package/docs/pr-consumer-contract.md +198 -0
  39. package/docs/project-goal.md +72 -0
  40. package/docs/project-registry-schema.md +46 -0
  41. package/docs/testing-strategy.md +169 -0
  42. package/iterations.json +23 -0
  43. package/package.json +47 -0
  44. package/scripts/gen-commit-doc.ps1 +178 -0
  45. package/scripts/gen-commit-doc.sh +197 -0
  46. package/scripts/list-features.ps1 +41 -0
  47. package/scripts/register-scheduled-task.bat +5 -0
  48. package/templates/change.md +59 -0
  49. package/templates/commit-feature.md +56 -0
  50. package/templates/feature.md +44 -0
  51. package/templates/framework.md +80 -0
  52. package/templates/index-header.md +3 -0
  53. package/templates/kb-manifest.json +38 -0
  54. package/templates/module.md +58 -0
  55. package/templates/project-analysis.md +48 -0
  56. package/templates/project-goal.md +55 -0
  57. package/templates/project-readme.md +60 -0
  58. package/templates/quality-review-rules.md +37 -0
  59. package/templates/update-entry.md +7 -0
@@ -0,0 +1,363 @@
1
+ // TASK-009: Draft Review and Apply test
2
+ // Run: node _site/_test/draft-apply-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 { runCommitAnalysis, readRun, listDrafts } = require('../lib/analysis-orchestrator');
8
+ const {
9
+ applyDrafts, rejectDrafts, validateDraftSchema, isSafeApplyPath,
10
+ } = require('../lib/draft-apply');
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_APPLY_TEST_PORT || '7799';
16
+ const BASE_URL = `http://127.0.0.1:${PORT}`;
17
+ const TEMP_SLUG = 'task-009-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
+ }
56
+
57
+ (async () => {
58
+ assert(fs.existsSync(SERVER), 'server.js missing');
59
+
60
+ // ----- Unit tests on draft-apply primitives -----
61
+ // 1. validateDraftSchema
62
+ assert(validateDraftSchema({ path: 'features/x.md', content: 'hi' }).valid, 'simple md should be valid');
63
+ assert(validateDraftSchema({ path: 'a.json', content: '{}' }).valid, 'json should be valid');
64
+ assert(!validateDraftSchema({ path: 'a.txt', content: 'x' }).valid, '.txt must be rejected');
65
+ assert(!validateDraftSchema({ path: '/abs.md', content: 'x' }).valid, 'absolute path must be rejected');
66
+ assert(!validateDraftSchema({ path: 'a.md', content: '' }).valid, 'empty content must be rejected');
67
+ assert(!validateDraftSchema(null).valid, 'null must be rejected');
68
+
69
+ // 2. isSafeApplyPath
70
+ const kbRoot = path.join(ROOT, 'projects', 'unit');
71
+ assert(isSafeApplyPath(kbRoot, 'features/x.md'), 'plain relative path is safe');
72
+ assert(isSafeApplyPath(kbRoot, 'project-goal.md'), 'project-goal.md is safe (apply layer enforces trust separately)');
73
+ assert(!isSafeApplyPath(kbRoot, '_ai/drafts/x.md'), '_ai paths must be refused');
74
+ assert(!isSafeApplyPath(kbRoot, '../outside.md'), 'traversal must be refused');
75
+ assert(!isSafeApplyPath(kbRoot, '/abs.md'), 'absolute must be refused');
76
+ assert(!isSafeApplyPath(kbRoot, ''), 'empty must be refused');
77
+ assert(!isSafeApplyPath(kbRoot, 'features/../../outside.md'), 'embedded traversal must be refused');
78
+
79
+ // ----- Integration: run an analysis then apply its drafts -----
80
+ const repo = makeRepo({ kind: 'feature-commit' });
81
+ const kbBase = path.join(ROOT, 'projects', TEMP_SLUG);
82
+ fs.rmSync(kbBase, { recursive: true, force: true });
83
+ fs.mkdirSync(kbBase, { recursive: true });
84
+ // Pre-existing project-goal.md that the orchestrator must NOT overwrite
85
+ const existingGoal = '# Project Goal — PRESERVE\n';
86
+ fs.writeFileSync(path.join(kbBase, 'project-goal.md'), existingGoal);
87
+ fs.mkdirSync(path.join(kbBase, 'features'), { recursive: true });
88
+ fs.mkdirSync(path.join(kbBase, 'changes'), { recursive: true });
89
+
90
+ const project = {
91
+ slug: TEMP_SLUG,
92
+ kbPath: kbBase,
93
+ gitPath: repo.path,
94
+ localPath: repo.path,
95
+ aiProfileId: 'mock-agent',
96
+ };
97
+
98
+ let r = await runCommitAnalysis(project);
99
+ assert(r.ok, `commit analysis should succeed: ${r.error || ''}`);
100
+ const runId = r.runId;
101
+ const headCommitAtRun = r.runRecord.headCommitAtRun;
102
+ assert(headCommitAtRun, 'run should record headCommitAtRun');
103
+
104
+ // 3. List drafts
105
+ const drafts = listDrafts(kbBase, runId);
106
+ assert(drafts.length >= 3, `expected 3+ drafts (1 goal + 1 analysis + 2 changes + 1 feature), got ${drafts.length}`);
107
+ // The orchestrator does NOT produce a goal draft for commit runs. It produces changes/* and a feature/*.
108
+
109
+ // 4. Build draft payloads from the on-disk drafts
110
+ const payloads = drafts.map(d => ({
111
+ path: d.path,
112
+ content: fs.readFileSync(path.join(kbBase, '_ai', 'drafts', runId, d.path), 'utf-8'),
113
+ }));
114
+
115
+ // 5. Apply drafts: should succeed, write to KB, create backups, update manifest, mark run as applied
116
+ const applyResult = applyDrafts({
117
+ kbPath: kbBase,
118
+ slug: TEMP_SLUG,
119
+ runId,
120
+ drafts: payloads,
121
+ allowGoalEdit: false,
122
+ headCommitAtRun,
123
+ });
124
+ assert(applyResult.ok, `apply should succeed: ${JSON.stringify(applyResult)}`);
125
+ assert(applyResult.applied.length === payloads.length, `applied length should match, got ${applyResult.applied.length}`);
126
+ assert(applyResult.backups.length === 0, 'no backups expected (no pre-existing targets)');
127
+
128
+ // 6. Files are now on disk in the formal KB
129
+ for (const d of payloads) {
130
+ const target = path.join(kbBase, d.path);
131
+ assert(fs.existsSync(target), `applied file should exist: ${d.path}`);
132
+ const got = fs.readFileSync(target, 'utf-8');
133
+ assert(got === d.content, `applied content should match for ${d.path}`);
134
+ }
135
+
136
+ // 7. Manifest updated
137
+ const manifest = JSON.parse(fs.readFileSync(path.join(kbBase, 'kb-manifest.json'), 'utf-8'));
138
+ assert(manifest.trustedKnowledge.includes('features/'), 'manifest should add features/');
139
+ assert(manifest.trustedKnowledge.includes('changes/'), 'manifest should add changes/');
140
+
141
+ // 8. Run record marked as applied
142
+ const run = readRun(kbBase, runId);
143
+ assert(run.applyStatus === 'applied', `run should be applied, got ${run.applyStatus}`);
144
+ assert(run.appliedAt, 'run should have appliedAt');
145
+ assert(Array.isArray(run.appliedPaths) && run.appliedPaths.length === payloads.length, 'run should list appliedPaths');
146
+ assert(run.advancedLastAnalyzedCommit === headCommitAtRun, 'run should record advancedLastAnalyzedCommit');
147
+
148
+ // 9. Second pass: drafts now need to apply OVER existing files; backups should be created
149
+ const modifiedDrafts = payloads.map(d => ({ path: d.path, content: d.content + '\n<!-- second-pass -->\n' }));
150
+ const applyResult2 = applyDrafts({
151
+ kbPath: kbBase,
152
+ slug: TEMP_SLUG,
153
+ runId,
154
+ drafts: modifiedDrafts,
155
+ allowGoalEdit: false,
156
+ });
157
+ assert(applyResult2.ok, `second apply should succeed: ${JSON.stringify(applyResult2)}`);
158
+ assert(applyResult2.backups.length === modifiedDrafts.length, `should have backups for every overwrite, got ${applyResult2.backups.length}`);
159
+ for (const b of applyResult2.backups) {
160
+ assert(fs.existsSync(path.join(kbBase, b.backup)), `backup should exist on disk: ${b.backup}`);
161
+ }
162
+
163
+ repo.cleanup();
164
+ fs.rmSync(kbBase, { recursive: true, force: true });
165
+
166
+ // ----- Refusal: project-goal.md without allowGoalEdit -----
167
+ const repo2 = makeRepo({ kind: 'one-commit' });
168
+ const kbBase2 = path.join(ROOT, 'projects', TEMP_SLUG + '-goal');
169
+ fs.rmSync(kbBase2, { recursive: true, force: true });
170
+ fs.mkdirSync(kbBase2, { recursive: true });
171
+ fs.writeFileSync(path.join(kbBase2, 'project-goal.md'), '# Goal — original\n');
172
+ const project2 = { ...project, slug: TEMP_SLUG + '-goal', kbPath: kbBase2, gitPath: repo2.path, localPath: repo2.path };
173
+
174
+ // Use a synthetic draft: pretend an analysis run wrote a goal draft
175
+ const fakeRunId = 'commits-fake';
176
+ fs.mkdirSync(path.join(kbBase2, '_ai', 'drafts', fakeRunId), { recursive: true });
177
+ fs.mkdirSync(path.join(kbBase2, '_ai', 'runs'), { recursive: true });
178
+ fs.writeFileSync(path.join(kbBase2, '_ai', 'drafts', fakeRunId, 'project-goal.md'), '# Goal — AI proposal\n');
179
+ fs.writeFileSync(path.join(kbBase2, '_ai', 'runs', fakeRunId + '.json'), JSON.stringify({
180
+ schema: 'ai-run/v1', runId: fakeRunId, type: 'commits', status: 'succeeded',
181
+ }));
182
+
183
+ // 10. Refuse without allowGoalEdit
184
+ const refuse = applyDrafts({
185
+ kbPath: kbBase2,
186
+ slug: TEMP_SLUG + '-goal',
187
+ runId: fakeRunId,
188
+ drafts: [{ path: 'project-goal.md', content: '# Goal — should not write' }],
189
+ allowGoalEdit: false,
190
+ });
191
+ assert(!refuse.ok, 'apply without allowGoalEdit must refuse to overwrite project-goal.md');
192
+ assert(refuse.status === 422, `expected 422, got ${refuse.status}: ${JSON.stringify(refuse)}`);
193
+ // Original file untouched
194
+ const stillThere = fs.readFileSync(path.join(kbBase2, 'project-goal.md'), 'utf-8');
195
+ assert(stillThere === '# Goal — original\n', 'original project-goal.md must be preserved');
196
+
197
+ // 11. Allow with allowGoalEdit: true
198
+ const allow = applyDrafts({
199
+ kbPath: kbBase2,
200
+ slug: TEMP_SLUG + '-goal',
201
+ runId: fakeRunId,
202
+ drafts: [{ path: 'project-goal.md', content: '# Goal — accepted' }],
203
+ allowGoalEdit: true,
204
+ });
205
+ assert(allow.ok, `apply with allowGoalEdit should succeed: ${JSON.stringify(allow)}`);
206
+ const after = fs.readFileSync(path.join(kbBase2, 'project-goal.md'), 'utf-8');
207
+ assert(after === '# Goal — accepted\n' || after === '# Goal — accepted', 'project-goal.md should be overwritten when allowed');
208
+ // Manifest should now record the goal
209
+ const m2 = JSON.parse(fs.readFileSync(path.join(kbBase2, 'kb-manifest.json'), 'utf-8'));
210
+ assert(m2.goal && m2.goal.path === 'project-goal.md', 'manifest should record goal');
211
+ assert(m2.goal.status === 'accepted', 'manifest.goal.status should be accepted');
212
+
213
+ repo2.cleanup();
214
+ fs.rmSync(kbBase2, { recursive: true, force: true });
215
+
216
+ // ----- Reject flow -----
217
+ const repo3 = makeRepo({ kind: 'one-commit' });
218
+ const kbBase3 = path.join(ROOT, 'projects', TEMP_SLUG + '-reject');
219
+ fs.rmSync(kbBase3, { recursive: true, force: true });
220
+ fs.mkdirSync(kbBase3, { recursive: true });
221
+ fs.writeFileSync(path.join(kbBase3, 'project-goal.md'), '# Goal\n');
222
+ const project3 = { ...project, slug: TEMP_SLUG + '-reject', kbPath: kbBase3, gitPath: repo3.path, localPath: repo3.path };
223
+ r = await runCommitAnalysis(project3);
224
+ assert(r.ok, `commit analysis should succeed: ${r.error || ''}`);
225
+
226
+ const rej = rejectDrafts({ kbPath: kbBase3, runId: r.runId, reason: 'not ready' });
227
+ assert(rej.ok, 'reject should succeed');
228
+ const rejRun = readRun(kbBase3, r.runId);
229
+ assert(rejRun.applyStatus === 'rejected', 'run should be marked rejected');
230
+ assert(rejRun.rejectionReason === 'not ready', 'run should record reason');
231
+ repo3.cleanup();
232
+ fs.rmSync(kbBase3, { recursive: true, force: true });
233
+
234
+ // ----- Bad inputs -----
235
+ const noKb = applyDrafts({ kbPath: null, slug: 'x', runId: 'y', drafts: [], allowGoalEdit: false });
236
+ assert(!noKb.ok, 'null kbPath should fail');
237
+ const empty = applyDrafts({ kbPath: kbBase, slug: TEMP_SLUG, runId: 'r', drafts: [], allowGoalEdit: false });
238
+ assert(!empty.ok, 'empty drafts should fail');
239
+ const badSchema = applyDrafts({
240
+ kbPath: kbBase, slug: TEMP_SLUG, runId: 'r',
241
+ drafts: [{ path: 'x.txt', content: 'no' }], allowGoalEdit: false,
242
+ });
243
+ assert(!badSchema.ok && badSchema.status === 422, 'invalid schema should 422');
244
+
245
+ // 12. Unsafe path during apply
246
+ const kbBase4 = path.join(ROOT, 'projects', TEMP_SLUG + '-unsafe');
247
+ fs.rmSync(kbBase4, { recursive: true, force: true });
248
+ fs.mkdirSync(kbBase4, { recursive: true });
249
+ const unsafe = applyDrafts({
250
+ kbPath: kbBase4, slug: TEMP_SLUG + '-unsafe', runId: 'r',
251
+ drafts: [{ path: '_ai/drafts/foo.md', content: 'x' }], allowGoalEdit: false,
252
+ });
253
+ assert(!unsafe.ok && unsafe.status === 422, '_ai/ path must be refused at apply');
254
+ fs.rmSync(kbBase4, { recursive: true, force: true });
255
+
256
+ // ----- Server tests -----
257
+ const child = spawn(process.execPath, [SERVER], {
258
+ cwd: ROOT,
259
+ env: { ...process.env, KB_SITE_PORT: PORT, KB_SITE_HOST: '127.0.0.1' },
260
+ stdio: ['ignore', 'pipe', 'pipe'],
261
+ windowsHide: true,
262
+ });
263
+ let serverOutput = '';
264
+ child.stdout.on('data', d => { serverOutput += d.toString(); });
265
+ child.stderr.on('data', d => { serverOutput += d.toString(); });
266
+
267
+ try {
268
+ await cleanup();
269
+ await waitForServer();
270
+
271
+ // 13. End-to-end
272
+ const repo4 = makeRepo({ kind: 'feature-commit' });
273
+ const slug = TEMP_SLUG;
274
+ const kbPath = path.join(ROOT, 'projects', slug);
275
+ fs.rmSync(kbPath, { recursive: true, force: true });
276
+ fs.mkdirSync(kbPath, { recursive: true });
277
+ fs.writeFileSync(path.join(kbPath, 'project-goal.md'), '# Goal — server\n');
278
+
279
+ r = await json('PUT', '/api/projects', {
280
+ slug,
281
+ config: { displayName: 'TASK-009', localPath: repo4.path, gitPath: repo4.path, kbPath },
282
+ });
283
+ assert(r.res.ok, 'upsert should succeed');
284
+
285
+ r = await json('POST', `/api/projects/${slug}/analyze/commits`, {});
286
+ assert(r.res.ok, `analyze commits should succeed: ${JSON.stringify(r.data)}`);
287
+ const srvRunId = r.data.runId;
288
+ const srvHeadCommit = r.data.run.headCommitAtRun;
289
+ assert(srvHeadCommit, 'run should record headCommitAtRun');
290
+
291
+ // 14. List drafts
292
+ r = await json('GET', `/api/projects/${slug}/drafts/${srvRunId}`);
293
+ assert(r.res.ok, 'list drafts should succeed');
294
+ assert(r.data.drafts.length >= 3, `expected 3+ drafts, got ${r.data.drafts.length}`);
295
+ const serverDrafts = r.data.drafts;
296
+
297
+ // 15. Read raw draft text
298
+ const someDraft = serverDrafts[0];
299
+ r = await json('GET', `/api/projects/${slug}/drafts/${srvRunId}/raw?path=${encodeURIComponent(someDraft.path)}`);
300
+ assert(r.res.ok, 'read raw draft should succeed');
301
+ assert(typeof r.data.content === 'string' && r.data.content.length > 0, 'raw content should be non-empty');
302
+
303
+ // 16. Apply without allowGoalEdit (no goal in these drafts, so this should succeed)
304
+ const payloads2 = serverDrafts.map(d => ({
305
+ path: d.path,
306
+ content: fs.readFileSync(path.join(kbPath, '_ai', 'drafts', srvRunId, d.path), 'utf-8'),
307
+ }));
308
+ r = await json('POST', `/api/projects/${slug}/drafts/${srvRunId}/apply`, {
309
+ drafts: payloads2,
310
+ allowGoalEdit: false,
311
+ });
312
+ assert(r.res.ok, `apply should succeed: ${JSON.stringify(r.data)}`);
313
+ assert(r.data.applied.length === payloads2.length, 'applied length should match');
314
+
315
+ // 17. lastAnalyzedCommit advanced in projects.json
316
+ const cur = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
317
+ assert(cur[slug].lastAnalyzedCommit === srvHeadCommit,
318
+ `lastAnalyzedCommit should advance to ${srvHeadCommit}, got ${cur[slug].lastAnalyzedCommit}`);
319
+
320
+ // 18. Run marked as applied
321
+ r = await json('GET', `/api/projects/${slug}/runs/${srvRunId}`);
322
+ assert(r.data.run.applyStatus === 'applied', `run should be applied, got ${r.data.run.applyStatus}`);
323
+
324
+ // 19. Refuse: try to apply a goal draft without allowGoalEdit
325
+ fs.mkdirSync(path.join(kbPath, '_ai', 'drafts', 'goal-test'), { recursive: true });
326
+ fs.writeFileSync(path.join(kbPath, '_ai', 'drafts', 'goal-test', 'project-goal.md'), '# Goal — bad');
327
+ fs.writeFileSync(path.join(kbPath, '_ai', 'runs', 'goal-test.json'), JSON.stringify({ schema: 'ai-run/v1', runId: 'goal-test' }));
328
+ r = await json('POST', `/api/projects/${slug}/drafts/goal-test/apply`, {
329
+ drafts: [{ path: 'project-goal.md', content: '# Goal — should not write' }],
330
+ allowGoalEdit: false,
331
+ });
332
+ assert(!r.res.ok && r.res.status === 422, 'goal apply without allowGoalEdit should 422');
333
+ // Original project-goal.md preserved
334
+ const goal = fs.readFileSync(path.join(kbPath, 'project-goal.md'), 'utf-8');
335
+ assert(goal.includes('Goal — server'), 'original goal must be preserved');
336
+
337
+ // 20. Reject endpoint
338
+ fs.mkdirSync(path.join(kbPath, '_ai', 'drafts', 'reject-test', 'features'), { recursive: true });
339
+ fs.writeFileSync(path.join(kbPath, '_ai', 'drafts', 'reject-test', 'features', 'x.md'), '# X');
340
+ fs.writeFileSync(path.join(kbPath, '_ai', 'runs', 'reject-test.json'), JSON.stringify({ schema: 'ai-run/v1', runId: 'reject-test' }));
341
+ r = await json('POST', `/api/projects/${slug}/drafts/reject-test/reject`, { reason: 'unit test' });
342
+ assert(r.res.ok, 'reject should succeed');
343
+ r = await json('GET', `/api/projects/${slug}/runs/reject-test`);
344
+ assert(r.data.run.applyStatus === 'rejected', 'run should be rejected');
345
+
346
+ // 21. Bad slug (starts with dash — invalid for isSafeSlug)
347
+ r = await json('POST', '/api/projects/-bad-/drafts/x/apply', { drafts: [] });
348
+ assert(!r.res.ok && r.res.status === 400, 'bad slug should 400');
349
+
350
+ repo4.cleanup();
351
+ console.log('TASK-009 draft-apply test passed');
352
+ } catch (e) {
353
+ console.error('TASK-009 draft-apply test failed:', e.message);
354
+ if (serverOutput) console.error(serverOutput);
355
+ process.exitCode = 1;
356
+ } finally {
357
+ await cleanup().catch(() => {});
358
+ for (const suffix of ['-goal', '-reject', '-unsafe']) {
359
+ try { fs.rmSync(path.join(ROOT, 'projects', TEMP_SLUG + suffix), { recursive: true, force: true }); } catch {}
360
+ }
361
+ child.kill();
362
+ }
363
+ })();
@@ -0,0 +1,171 @@
1
+ // TASK-002: Git import validation test
2
+ // Run: node _site/_test/git-validation-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
+
8
+ const ROOT = path.resolve(__dirname, '..', '..');
9
+ const SERVER = path.join(ROOT, '_site', 'server.js');
10
+ const PROJECTS_JSON = path.join(ROOT, 'projects.json');
11
+ const PORT = process.env.KB_GIT_TEST_PORT || '7792';
12
+ const BASE_URL = `http://127.0.0.1:${PORT}`;
13
+ const TEMP_SLUG = 'task-002-temp';
14
+
15
+ function assert(cond, msg) { if (!cond) throw new Error(msg); }
16
+
17
+ async function waitForServer() {
18
+ const deadline = Date.now() + 15000;
19
+ let lastError;
20
+ while (Date.now() < deadline) {
21
+ try {
22
+ const res = await fetch(`${BASE_URL}/api/state`);
23
+ if (res.ok) return;
24
+ lastError = new Error(`HTTP ${res.status}`);
25
+ } catch (e) { lastError = e; }
26
+ await new Promise(r => setTimeout(r, 250));
27
+ }
28
+ throw lastError || new Error('server did not start');
29
+ }
30
+
31
+ async function json(method, url, body) {
32
+ const res = await fetch(`${BASE_URL}${url}`, {
33
+ method,
34
+ headers: body ? { 'Content-Type': 'application/json' } : undefined,
35
+ body: body ? JSON.stringify(body) : undefined,
36
+ });
37
+ const text = await res.text();
38
+ let data = {};
39
+ if (text) { try { data = JSON.parse(text); } catch { data = { raw: text }; } }
40
+ return { res, data };
41
+ }
42
+
43
+ async function upsertProject(slug, config) {
44
+ const r = await json('PUT', '/api/projects', { slug, config });
45
+ assert(r.res.ok, `upsert failed: ${JSON.stringify(r.data)}`);
46
+ return r.data;
47
+ }
48
+
49
+ async function removeTemp() {
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
+ }
56
+
57
+ (async () => {
58
+ // 1. Static checks
59
+ assert(fs.existsSync(SERVER), 'server.js missing');
60
+ JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
61
+
62
+ const child = spawn(process.execPath, [SERVER], {
63
+ cwd: ROOT,
64
+ env: { ...process.env, KB_SITE_PORT: PORT, KB_SITE_HOST: '127.0.0.1' },
65
+ stdio: ['ignore', 'pipe', 'pipe'],
66
+ windowsHide: true,
67
+ });
68
+ let serverOutput = '';
69
+ child.stdout.on('data', d => { serverOutput += d.toString(); });
70
+ child.stderr.on('data', d => { serverOutput += d.toString(); });
71
+
72
+ const fixtures = [];
73
+ try {
74
+ await waitForServer();
75
+
76
+ // 2. Bad slug returns 400
77
+ const bad = await json('POST', '/api/projects/INVALID..SLUG!/validate-git');
78
+ assert(!bad.res.ok, 'bad slug should not succeed');
79
+ assert(bad.res.status === 400, `bad slug should return 400, got ${bad.res.status}`);
80
+
81
+ // 3. One-commit repo → repoStatus=ok
82
+ const okRepo = makeRepo({ kind: 'one-commit' });
83
+ fixtures.push(okRepo);
84
+ await upsertProject(TEMP_SLUG, {
85
+ displayName: 'TASK-002 OK',
86
+ localPath: okRepo.path,
87
+ gitPath: okRepo.path,
88
+ });
89
+ let r = await json('GET', '/api/projects');
90
+ let cfg = r.data[TEMP_SLUG];
91
+ assert(cfg.repoStatus === 'ok', `one-commit should be ok, got ${cfg.repoStatus}`);
92
+ assert(cfg.headCommit && cfg.headCommit.length >= 7, 'headCommit should be set');
93
+ assert(cfg.currentBranch === 'main', `currentBranch should be main, got ${cfg.currentBranch}`);
94
+
95
+ // validate-git endpoint
96
+ r = await json('POST', `/api/projects/${TEMP_SLUG}/validate-git`);
97
+ assert(r.res.ok, 'validate-git should succeed');
98
+ assert(r.data.repoStatus === 'ok', `validate-git should report ok, got ${r.data.repoStatus}`);
99
+
100
+ // git-status read-only endpoint
101
+ r = await json('GET', `/api/projects/${TEMP_SLUG}/git-status`);
102
+ assert(r.res.ok, 'git-status should return 200');
103
+ assert(r.data.repoStatus === 'ok', 'git-status should report ok');
104
+
105
+ // 4. Not-git folder → repoStatus=not-git
106
+ const notGit = makeRepo({ kind: 'not-git' });
107
+ fixtures.push(notGit);
108
+ await upsertProject(TEMP_SLUG, {
109
+ displayName: 'TASK-002 NotGit',
110
+ localPath: notGit.path,
111
+ gitPath: notGit.path,
112
+ });
113
+ r = await json('GET', `/api/projects/${TEMP_SLUG}/git-status`);
114
+ assert(r.res.ok, 'git-status should return 200 for not-git');
115
+ assert(r.data.repoStatus === 'not-git', `not-git folder should report not-git, got ${r.data.repoStatus}`);
116
+
117
+ // 5. Missing path → repoStatus=missing-path
118
+ const missing = 'D:\\__no_such_path__\\__no_such_repo__';
119
+ await upsertProject(TEMP_SLUG, {
120
+ displayName: 'TASK-002 Missing',
121
+ localPath: missing,
122
+ gitPath: missing,
123
+ });
124
+ r = await json('GET', `/api/projects/${TEMP_SLUG}/git-status`);
125
+ assert(r.res.ok, 'git-status should return 200 for missing');
126
+ assert(r.data.repoStatus === 'missing-path', `missing should report missing-path, got ${r.data.repoStatus}`);
127
+
128
+ // 6. Empty Git repo → repoStatus=empty
129
+ const empty = makeRepo({ kind: 'empty' });
130
+ fixtures.push(empty);
131
+ await upsertProject(TEMP_SLUG, {
132
+ displayName: 'TASK-002 Empty',
133
+ localPath: empty.path,
134
+ gitPath: empty.path,
135
+ });
136
+ r = await json('GET', `/api/projects/${TEMP_SLUG}/git-status`);
137
+ assert(r.res.ok, 'git-status should return 200 for empty');
138
+ assert(r.data.repoStatus === 'empty', `empty should report empty, got ${r.data.repoStatus}`);
139
+ assert(r.data.headCommit === null, 'empty repo should have no headCommit');
140
+
141
+ // 7. Nonexistent slug for validate-git
142
+ r = await json('POST', '/api/projects/nonexistent-12345/validate-git');
143
+ assert(!r.res.ok, 'nonexistent slug should fail');
144
+ assert(r.res.status === 404, 'nonexistent slug should return 404');
145
+
146
+ // 8. Persisted state visible in /api/state
147
+ await upsertProject(TEMP_SLUG, {
148
+ displayName: 'TASK-002 OK',
149
+ localPath: okRepo.path,
150
+ gitPath: okRepo.path,
151
+ });
152
+ r = await json('POST', `/api/projects/${TEMP_SLUG}/validate-git`);
153
+ r = await json('GET', '/api/state');
154
+ cfg = r.data.projects[TEMP_SLUG];
155
+ assert(cfg.repoStatus === 'ok', 'persisted repoStatus should be ok');
156
+ assert(cfg.headCommit && cfg.headCommit.length >= 7, 'persisted headCommit should be set');
157
+ assert(cfg.currentBranch === 'main', 'persisted currentBranch should be main');
158
+
159
+ console.log('TASK-002 git validation test passed');
160
+ } catch (e) {
161
+ console.error('TASK-002 git validation test failed:', e.message);
162
+ if (serverOutput) console.error(serverOutput);
163
+ process.exitCode = 1;
164
+ } finally {
165
+ for (const f of fixtures) {
166
+ try { f.cleanup(); } catch {}
167
+ }
168
+ await removeTemp().catch(() => {});
169
+ child.kill();
170
+ }
171
+ })();