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,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
|
+
})();
|