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,236 @@
|
|
|
1
|
+
// TASK-011: PR consumer contract test
|
|
2
|
+
// Run: node _site/_test/pr-consumer-contract-test.js
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawn } = require('child_process');
|
|
6
|
+
const { validateKb, buildPrContextPack, MANIFEST_SCHEMA } = require('../lib/kb-validator');
|
|
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_PR_TEST_PORT || '7801';
|
|
12
|
+
const BASE_URL = `http://127.0.0.1:${PORT}`;
|
|
13
|
+
const TEMP_SLUG = 'task-011-temp';
|
|
14
|
+
|
|
15
|
+
const FIX_HEALTHY = path.join(__dirname, 'fixtures', 'pr-context', 'healthy');
|
|
16
|
+
const FIX_NO_GOAL = path.join(__dirname, 'fixtures', 'pr-context', 'missing-goal');
|
|
17
|
+
const FIX_LEAK = path.join(__dirname, 'fixtures', 'pr-context', 'draft-leak');
|
|
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
|
+
function copyDir(src, dest) {
|
|
58
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
59
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
60
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
61
|
+
const s = path.join(src, entry.name);
|
|
62
|
+
const d = path.join(dest, entry.name);
|
|
63
|
+
if (entry.isDirectory()) copyDir(s, d);
|
|
64
|
+
else fs.copyFileSync(s, d);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
(async () => {
|
|
69
|
+
// --- Fixture sanity ---
|
|
70
|
+
for (const f of [FIX_HEALTHY, FIX_NO_GOAL, FIX_LEAK]) {
|
|
71
|
+
assert(fs.existsSync(f), `fixture missing: ${f}`);
|
|
72
|
+
assert(fs.existsSync(path.join(f, 'kb-manifest.json')), `manifest missing in ${f}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 1. MANIFEST_SCHEMA constant
|
|
76
|
+
assert(MANIFEST_SCHEMA === 'kb-manifest/v1', 'MANIFEST_SCHEMA should be kb-manifest/v1');
|
|
77
|
+
|
|
78
|
+
// 2. validateKb(healthy) — should pass
|
|
79
|
+
let r = validateKb(FIX_HEALTHY);
|
|
80
|
+
assert(r.ok, `healthy fixture should validate: ${JSON.stringify(r.errors)}`);
|
|
81
|
+
assert(r.info.goalStatus === 'accepted', `healthy goalStatus should be accepted, got ${r.info.goalStatus}`);
|
|
82
|
+
assert(r.info.analysisStatus === 'present', 'healthy analysisStatus should be present');
|
|
83
|
+
assert(r.info.trustedKnowledgeEntries >= 5, 'healthy should have multiple trusted entries');
|
|
84
|
+
assert(r.info.aiSubdirsPresent.length >= 3, 'healthy should have all _ai/ subdirs');
|
|
85
|
+
assert(r.manifest && r.manifest.schema === MANIFEST_SCHEMA, 'manifest should round-trip');
|
|
86
|
+
assert(Array.isArray(r.errors) && r.errors.length === 0, 'no errors');
|
|
87
|
+
// Healthy fixture may emit a warning if `README.md` is missing from the
|
|
88
|
+
// trusted list, but we DO list it, so no warnings expected.
|
|
89
|
+
assert(!r.warnings || r.warnings.length === 0, `healthy should have no warnings; got ${JSON.stringify(r.warnings)}`);
|
|
90
|
+
|
|
91
|
+
// 3. validateKb(missing-goal) — should pass with goalStatus not-created
|
|
92
|
+
r = validateKb(FIX_NO_GOAL);
|
|
93
|
+
assert(r.ok, `missing-goal should validate: ${JSON.stringify(r.errors)}`);
|
|
94
|
+
assert(r.info.goalStatus === 'not-created', `missing-goal should report goalStatus not-created, got ${r.info.goalStatus}`);
|
|
95
|
+
assert(r.info.analysisStatus === 'present', 'missing-goal analysisStatus should be present');
|
|
96
|
+
|
|
97
|
+
// 4. validateKb(draft-leak) — must fail because a path under _ai/ is in trustedKnowledge
|
|
98
|
+
r = validateKb(FIX_LEAK);
|
|
99
|
+
assert(!r.ok, 'draft-leak must fail validation');
|
|
100
|
+
assert(r.errors.some(e => /trustedKnowledge entry is inside _ai/.test(e)),
|
|
101
|
+
`draft-leak should report a draft-leak error; got ${JSON.stringify(r.errors)}`);
|
|
102
|
+
|
|
103
|
+
// 5. buildPrContextPack(healthy) — returns a pr-context-pack/v1 with goal+analysis+indexes
|
|
104
|
+
let pack = buildPrContextPack(FIX_HEALTHY);
|
|
105
|
+
assert(pack.ok, 'healthy pack should be ok');
|
|
106
|
+
assert(pack.pack.schema === 'pr-context-pack/v1', 'pack schema is v1');
|
|
107
|
+
assert(pack.pack.project === 'healthy', 'pack carries project name');
|
|
108
|
+
assert(pack.pack.goal && pack.pack.goal.path === 'project-goal.md', 'pack has goal with path');
|
|
109
|
+
assert(pack.pack.goal.content && pack.pack.goal.content.length > 0, 'pack goal has content');
|
|
110
|
+
assert(pack.pack.analysis && pack.pack.analysis.path === 'project-analysis.md', 'pack has analysis');
|
|
111
|
+
assert(pack.pack.indexes && pack.pack.indexes.features, 'pack has features index');
|
|
112
|
+
assert(pack.pack.indexes.modules, 'pack has modules index');
|
|
113
|
+
assert(Array.isArray(pack.pack.trustedKnowledge), 'pack has trustedKnowledge array');
|
|
114
|
+
// The pack MUST NOT include anything from _ai/
|
|
115
|
+
const asString = JSON.stringify(pack.pack);
|
|
116
|
+
assert(!/_ai\//.test(asString), 'pack must not include anything from _ai/');
|
|
117
|
+
// README.md and framework.md should be in trustedKnowledge
|
|
118
|
+
const paths = pack.pack.trustedKnowledge.map(t => t.path);
|
|
119
|
+
assert(paths.includes('README.md'), 'trustedKnowledge should include README.md');
|
|
120
|
+
|
|
121
|
+
// 6. buildPrContextPack(missing-goal) — goal field is null
|
|
122
|
+
pack = buildPrContextPack(FIX_NO_GOAL);
|
|
123
|
+
assert(pack.ok, 'missing-goal pack should be ok');
|
|
124
|
+
assert(pack.pack.goal === null, 'missing-goal pack.goal should be null');
|
|
125
|
+
|
|
126
|
+
// 7. buildPrContextPack(draft-leak) — refuses
|
|
127
|
+
pack = buildPrContextPack(FIX_LEAK);
|
|
128
|
+
assert(!pack.ok, 'draft-leak pack should refuse');
|
|
129
|
+
|
|
130
|
+
// 8. validateKb on a non-existent path
|
|
131
|
+
r = validateKb(path.join(__dirname, '__no_such_dir__'));
|
|
132
|
+
assert(!r.ok, 'non-existent kbPath should fail');
|
|
133
|
+
assert(r.status === 400, 'non-existent path should be 400');
|
|
134
|
+
|
|
135
|
+
// 9. validateKb on a path that's not a directory
|
|
136
|
+
r = validateKb(__filename);
|
|
137
|
+
assert(!r.ok, 'file path should fail');
|
|
138
|
+
assert(r.status === 400, 'file path should be 400');
|
|
139
|
+
|
|
140
|
+
// 10. validateKb on a directory with no manifest
|
|
141
|
+
const noManifest = fs.mkdtempSync(path.join(require('os').tmpdir(), 'kb-no-manifest-'));
|
|
142
|
+
r = validateKb(noManifest);
|
|
143
|
+
assert(!r.ok && r.status === 422, 'no-manifest should 422');
|
|
144
|
+
fs.rmSync(noManifest, { recursive: true, force: true });
|
|
145
|
+
|
|
146
|
+
// 11. validateKb on a directory with malformed manifest
|
|
147
|
+
const badManifest = fs.mkdtempSync(path.join(require('os').tmpdir(), 'kb-bad-manifest-'));
|
|
148
|
+
fs.writeFileSync(path.join(badManifest, 'kb-manifest.json'), '{not valid json');
|
|
149
|
+
r = validateKb(badManifest);
|
|
150
|
+
assert(!r.ok && r.status === 422, 'bad manifest should 422');
|
|
151
|
+
fs.rmSync(badManifest, { recursive: true, force: true });
|
|
152
|
+
|
|
153
|
+
// 12. validateKb rejects a manifest with a missing goal file
|
|
154
|
+
const missingGoalFile = fs.mkdtempSync(path.join(require('os').tmpdir(), 'kb-missing-goal-'));
|
|
155
|
+
fs.writeFileSync(path.join(missingGoalFile, 'kb-manifest.json'), JSON.stringify({
|
|
156
|
+
schema: 'kb-manifest/v1',
|
|
157
|
+
project: 'x', kbRoot: 'x',
|
|
158
|
+
goal: { path: 'project-goal.md', status: 'accepted', updatedAt: '2026-06-11' },
|
|
159
|
+
analysis: { path: null, lastAnalyzedCommit: null, updatedAt: null },
|
|
160
|
+
trustedKnowledge: ['README.md'],
|
|
161
|
+
draftAreas: ['_ai/drafts/'],
|
|
162
|
+
}));
|
|
163
|
+
r = validateKb(missingGoalFile);
|
|
164
|
+
assert(!r.ok, 'missing goal file should fail');
|
|
165
|
+
assert(r.errors.some(e => /manifest.goal.path.*does not exist/.test(e)),
|
|
166
|
+
`expected a missing-goal error; got ${JSON.stringify(r.errors)}`);
|
|
167
|
+
fs.rmSync(missingGoalFile, { recursive: true, force: true });
|
|
168
|
+
|
|
169
|
+
// ----- Server tests -----
|
|
170
|
+
const child = spawn(process.execPath, [SERVER], {
|
|
171
|
+
cwd: ROOT,
|
|
172
|
+
env: { ...process.env, KB_SITE_PORT: PORT, KB_SITE_HOST: '127.0.0.1' },
|
|
173
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
174
|
+
windowsHide: true,
|
|
175
|
+
});
|
|
176
|
+
let serverOutput = '';
|
|
177
|
+
child.stdout.on('data', d => { serverOutput += d.toString(); });
|
|
178
|
+
child.stderr.on('data', d => { serverOutput += d.toString(); });
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
await cleanup();
|
|
182
|
+
await waitForServer();
|
|
183
|
+
|
|
184
|
+
// 13. Copy the healthy fixture into a real project and validate via the API
|
|
185
|
+
const projKb = path.join(ROOT, 'projects', TEMP_SLUG);
|
|
186
|
+
copyDir(FIX_HEALTHY, projKb);
|
|
187
|
+
|
|
188
|
+
r = await json('PUT', '/api/projects', {
|
|
189
|
+
slug: TEMP_SLUG,
|
|
190
|
+
config: { displayName: 'TASK-011', localPath: 'D:\\__not_used__', gitPath: 'D:\\__not_used__', kbPath: projKb },
|
|
191
|
+
});
|
|
192
|
+
assert(r.res.ok, 'upsert should succeed');
|
|
193
|
+
|
|
194
|
+
r = await json('POST', `/api/projects/${TEMP_SLUG}/validate-kb`, {});
|
|
195
|
+
assert(r.res.ok, `validate-kb should succeed: ${JSON.stringify(r.data)}`);
|
|
196
|
+
assert(r.data.ok, `result should be ok: ${JSON.stringify(r.data)}`);
|
|
197
|
+
assert(r.data.info.goalStatus === 'accepted', 'api-reported goalStatus accepted');
|
|
198
|
+
|
|
199
|
+
r = await json('GET', `/api/projects/${TEMP_SLUG}/pr-context`);
|
|
200
|
+
assert(r.res.ok, 'pr-context should succeed');
|
|
201
|
+
assert(r.data.ok, 'pr-context result ok');
|
|
202
|
+
assert(r.data.pack.schema === 'pr-context-pack/v1', 'pack schema v1');
|
|
203
|
+
assert(r.data.pack.goal && r.data.pack.goal.content.length > 0, 'pack goal content present');
|
|
204
|
+
|
|
205
|
+
// 14. Draft-leak via API: the API must refuse
|
|
206
|
+
const leakSlug = TEMP_SLUG + '-leak';
|
|
207
|
+
const leakKb = path.join(ROOT, 'projects', leakSlug);
|
|
208
|
+
copyDir(FIX_LEAK, leakKb);
|
|
209
|
+
r = await json('PUT', '/api/projects', {
|
|
210
|
+
slug: leakSlug,
|
|
211
|
+
config: { displayName: 'TASK-011 leak', localPath: 'D:\\__not_used__', gitPath: 'D:\\__not_used__', kbPath: leakKb },
|
|
212
|
+
});
|
|
213
|
+
assert(r.res.ok, 'upsert leak should succeed');
|
|
214
|
+
|
|
215
|
+
r = await json('POST', `/api/projects/${leakSlug}/validate-kb`, {});
|
|
216
|
+
assert(!r.res.ok && r.res.status === 422, 'leak validate should 422');
|
|
217
|
+
assert(r.data.errors && r.data.errors.length > 0, 'errors should be reported');
|
|
218
|
+
|
|
219
|
+
r = await json('GET', `/api/projects/${leakSlug}/pr-context`);
|
|
220
|
+
assert(!r.res.ok && r.res.status === 422, 'leak pr-context should 422');
|
|
221
|
+
|
|
222
|
+
// 15. Bad slug
|
|
223
|
+
r = await json('POST', '/api/projects/-bad-/validate-kb', {});
|
|
224
|
+
assert(!r.res.ok && r.res.status === 400, 'bad slug should 400');
|
|
225
|
+
|
|
226
|
+
console.log('TASK-011 PR consumer contract test passed');
|
|
227
|
+
} catch (e) {
|
|
228
|
+
console.error('TASK-011 PR consumer contract test failed:', e.message);
|
|
229
|
+
if (serverOutput) console.error(serverOutput);
|
|
230
|
+
process.exitCode = 1;
|
|
231
|
+
} finally {
|
|
232
|
+
await cleanup().catch(() => {});
|
|
233
|
+
try { fs.rmSync(path.join(ROOT, 'projects', TEMP_SLUG + '-leak'), { recursive: true, force: true }); } catch {}
|
|
234
|
+
child.kill();
|
|
235
|
+
}
|
|
236
|
+
})();
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// _site/_test/run-all-tests.js
|
|
2
|
+
//
|
|
3
|
+
// Single-command regression suite runner. Iterates the canonical test files
|
|
4
|
+
// under _site/_test/ (everything matching *-test.js) and runs each one in a
|
|
5
|
+
// separate child process, capturing exit code, duration, and the last few
|
|
6
|
+
// lines of output. Writes a Markdown report to TEST-REPORT.md.
|
|
7
|
+
//
|
|
8
|
+
// Usage:
|
|
9
|
+
// node _site/_test/run-all-tests.js # run all *-test.js
|
|
10
|
+
// node _site/_test/run-all-tests.js --no-report # do not write report
|
|
11
|
+
// node _site/_test/run-all-tests.js --include-ui # also run ui-test.js
|
|
12
|
+
// # (requires Playwright)
|
|
13
|
+
//
|
|
14
|
+
// Exit code is 0 only if every test passes.
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const { spawnSync } = require('child_process');
|
|
19
|
+
|
|
20
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
21
|
+
const TEST_DIR = __dirname;
|
|
22
|
+
const REPORT_PATH = path.join(TEST_DIR, 'TEST-REPORT.md');
|
|
23
|
+
|
|
24
|
+
const args = process.argv.slice(2);
|
|
25
|
+
const WRITE_REPORT = !args.includes('--no-report');
|
|
26
|
+
const INCLUDE_UI = args.includes('--include-ui');
|
|
27
|
+
|
|
28
|
+
function listTestFiles() {
|
|
29
|
+
return fs.readdirSync(TEST_DIR)
|
|
30
|
+
.filter(file => file.endsWith('-test.js'))
|
|
31
|
+
// Skip legacy ui-test.js by default. ui-smoke-test.js uses local Chrome via
|
|
32
|
+
// CDP and is safe to run as part of the regression suite.
|
|
33
|
+
.filter(file => INCLUDE_UI || file !== 'ui-test.js')
|
|
34
|
+
.filter(file => file !== 'run-all-tests.js')
|
|
35
|
+
.sort();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function tail(text, lines = 12) {
|
|
39
|
+
const arr = String(text || '').split(/\r?\n/).filter(Boolean);
|
|
40
|
+
return arr.slice(-lines).join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function runOne(file) {
|
|
44
|
+
const start = Date.now();
|
|
45
|
+
const result = spawnSync(process.execPath, [path.join(TEST_DIR, file)], {
|
|
46
|
+
cwd: ROOT,
|
|
47
|
+
encoding: 'utf-8',
|
|
48
|
+
timeout: 300_000,
|
|
49
|
+
env: { ...process.env, FORCE_COLOR: '0' },
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
file,
|
|
53
|
+
passed: result.status === 0,
|
|
54
|
+
exitCode: result.status,
|
|
55
|
+
durationMs: Date.now() - start,
|
|
56
|
+
stdout: result.stdout || '',
|
|
57
|
+
stderr: result.stderr || '',
|
|
58
|
+
outputTail: tail(result.stdout || result.stderr),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
(async () => {
|
|
63
|
+
const files = listTestFiles();
|
|
64
|
+
console.log(`Running ${files.length} test file(s)...\n`);
|
|
65
|
+
|
|
66
|
+
const results = [];
|
|
67
|
+
let totalDuration = 0;
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
process.stdout.write(`> ${file} ... `);
|
|
70
|
+
const result = runOne(file);
|
|
71
|
+
totalDuration += result.durationMs;
|
|
72
|
+
results.push(result);
|
|
73
|
+
|
|
74
|
+
if (result.passed) {
|
|
75
|
+
console.log(`PASS (${result.durationMs}ms)`);
|
|
76
|
+
} else {
|
|
77
|
+
console.log(`FAIL (exit ${result.exitCode}, ${result.durationMs}ms)`);
|
|
78
|
+
if (result.outputTail) {
|
|
79
|
+
console.log('--- tail ---');
|
|
80
|
+
console.log(result.outputTail);
|
|
81
|
+
console.log('--- end tail ---');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const passed = results.filter(result => result.passed).length;
|
|
87
|
+
const failed = results.length - passed;
|
|
88
|
+
console.log('\n=========================================');
|
|
89
|
+
console.log(`Regression suite: ${passed} passed, ${failed} failed (${totalDuration}ms total)`);
|
|
90
|
+
console.log('=========================================\n');
|
|
91
|
+
|
|
92
|
+
if (WRITE_REPORT) {
|
|
93
|
+
const lines = [];
|
|
94
|
+
lines.push('# KB Management Site Regression Test Report');
|
|
95
|
+
lines.push('');
|
|
96
|
+
lines.push(`**Generated**: ${new Date().toISOString()}`);
|
|
97
|
+
lines.push(`**Node**: ${process.version}`);
|
|
98
|
+
lines.push('**Runner**: node _site/_test/run-all-tests.js');
|
|
99
|
+
lines.push('');
|
|
100
|
+
lines.push('## Summary');
|
|
101
|
+
lines.push('');
|
|
102
|
+
lines.push('| # | File | Status | Exit | Duration |');
|
|
103
|
+
lines.push('|--:|------|:------:|-----:|---------:|');
|
|
104
|
+
results.forEach((result, index) => {
|
|
105
|
+
const status = result.passed ? 'PASS' : 'FAIL';
|
|
106
|
+
lines.push(`| ${index + 1} | \`${result.file}\` | ${status} | ${result.exitCode} | ${result.durationMs}ms |`);
|
|
107
|
+
});
|
|
108
|
+
lines.push('');
|
|
109
|
+
lines.push(`**Total**: ${results.length} | **Passed**: ${passed} | **Failed**: ${failed} | **Time**: ${totalDuration}ms`);
|
|
110
|
+
lines.push('');
|
|
111
|
+
|
|
112
|
+
const failures = results.filter(result => !result.passed);
|
|
113
|
+
if (failures.length) {
|
|
114
|
+
lines.push('## Failures');
|
|
115
|
+
lines.push('');
|
|
116
|
+
for (const result of failures) {
|
|
117
|
+
lines.push(`### ${result.file}`);
|
|
118
|
+
lines.push('');
|
|
119
|
+
lines.push('```');
|
|
120
|
+
lines.push(result.outputTail);
|
|
121
|
+
lines.push('```');
|
|
122
|
+
lines.push('');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
fs.writeFileSync(REPORT_PATH, lines.join('\n'), 'utf-8');
|
|
128
|
+
console.log(`Report written to: ${REPORT_PATH}`);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
console.error(`Failed to write report: ${e.message}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
135
|
+
})();
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// TASK-004: Analysis state and scanner test
|
|
2
|
+
// Run: node _site/_test/scanner-test.js
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawn } = require('child_process');
|
|
6
|
+
const { makeRepo, git } = 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_SCAN_TEST_PORT || '7794';
|
|
12
|
+
const BASE_URL = `http://127.0.0.1:${PORT}`;
|
|
13
|
+
const TEMP_SLUG = 'task-004-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 cleanup() {
|
|
44
|
+
const base = path.join(ROOT, 'projects', TEMP_SLUG);
|
|
45
|
+
fs.rmSync(base, { recursive: true, force: true });
|
|
46
|
+
const cur = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
|
|
47
|
+
if (cur[TEMP_SLUG]) {
|
|
48
|
+
delete cur[TEMP_SLUG];
|
|
49
|
+
fs.writeFileSync(PROJECTS_JSON, JSON.stringify(cur, null, 2) + '\n', 'utf-8');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function upsert(slug, extra = {}) {
|
|
54
|
+
const r = await json('PUT', '/api/projects', {
|
|
55
|
+
slug,
|
|
56
|
+
config: { displayName: slug, ...extra },
|
|
57
|
+
});
|
|
58
|
+
assert(r.res.ok, 'upsert failed');
|
|
59
|
+
return r.data;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function getProject(slug) {
|
|
63
|
+
const r = await json('GET', '/api/projects');
|
|
64
|
+
return r.data[slug];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
(async () => {
|
|
68
|
+
assert(fs.existsSync(SERVER), 'server.js missing');
|
|
69
|
+
JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
|
|
70
|
+
|
|
71
|
+
const child = spawn(process.execPath, [SERVER], {
|
|
72
|
+
cwd: ROOT,
|
|
73
|
+
env: { ...process.env, KB_SITE_PORT: PORT, KB_SITE_HOST: '127.0.0.1' },
|
|
74
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
75
|
+
windowsHide: true,
|
|
76
|
+
});
|
|
77
|
+
let serverOutput = '';
|
|
78
|
+
child.stdout.on('data', d => { serverOutput += d.toString(); });
|
|
79
|
+
child.stderr.on('data', d => { serverOutput += d.toString(); });
|
|
80
|
+
|
|
81
|
+
const fixtures = [];
|
|
82
|
+
try {
|
|
83
|
+
await cleanup();
|
|
84
|
+
await waitForServer();
|
|
85
|
+
|
|
86
|
+
// 1. Empty repo: pending=0, mode=initial
|
|
87
|
+
const empty = makeRepo({ kind: 'empty' });
|
|
88
|
+
fixtures.push(empty);
|
|
89
|
+
await upsert(TEMP_SLUG, { localPath: empty.path, gitPath: empty.path });
|
|
90
|
+
|
|
91
|
+
let r = await json('POST', `/api/projects/${TEMP_SLUG}/scan`);
|
|
92
|
+
assert(r.res.ok, 'scan should succeed');
|
|
93
|
+
assert(r.data.repoStatus === 'empty', `empty repo scan should report empty, got ${r.data.repoStatus}`);
|
|
94
|
+
assert(r.data.pendingCount === 0, `empty repo should have 0 pending, got ${r.data.pendingCount}`);
|
|
95
|
+
|
|
96
|
+
let cfg = await getProject(TEMP_SLUG);
|
|
97
|
+
assert(cfg.lastSeenCommit === null || cfg.lastSeenCommit === cfg.headCommit, 'lastSeenCommit should be null/head for empty');
|
|
98
|
+
assert(cfg.lastAnalyzedCommit === null, 'lastAnalyzedCommit must NOT be updated by scan');
|
|
99
|
+
|
|
100
|
+
// 2. Multi-commit repo: first scan → initial mode, all commits
|
|
101
|
+
const multi = makeRepo({ kind: 'multi-commit' });
|
|
102
|
+
fixtures.push(multi);
|
|
103
|
+
await upsert(TEMP_SLUG, { localPath: multi.path, gitPath: multi.path });
|
|
104
|
+
|
|
105
|
+
r = await json('POST', `/api/projects/${TEMP_SLUG}/scan`);
|
|
106
|
+
assert(r.res.ok, 'scan should succeed');
|
|
107
|
+
assert(r.data.mode === 'initial', `first scan should be initial mode, got ${r.data.mode}`);
|
|
108
|
+
assert(r.data.pendingCount === multi.commits.length, `pendingCount should be ${multi.commits.length}, got ${r.data.pendingCount}`);
|
|
109
|
+
assert(r.data.headCommit === multi.headCommit, 'headCommit should match fixture');
|
|
110
|
+
|
|
111
|
+
cfg = await getProject(TEMP_SLUG);
|
|
112
|
+
assert(cfg.lastSeenCommit === multi.headCommit, 'lastSeenCommit should equal head');
|
|
113
|
+
assert(cfg.lastAnalyzedCommit === null, 'lastAnalyzedCommit must NOT be updated by scan');
|
|
114
|
+
|
|
115
|
+
// 3. Add new commits → second scan should detect 2 new pending
|
|
116
|
+
const fs2 = require('fs');
|
|
117
|
+
fs2.writeFileSync(path.join(multi.path, 'src', 'c.ts'), 'export const c = 3;\n');
|
|
118
|
+
git(multi.path, 'add .');
|
|
119
|
+
git(multi.path, 'commit -q -m "feat: add c module"');
|
|
120
|
+
fs2.writeFileSync(path.join(multi.path, 'src', 'd.ts'), 'export const d = 4;\n');
|
|
121
|
+
git(multi.path, 'add .');
|
|
122
|
+
git(multi.path, 'commit -q -m "fix: add d module"');
|
|
123
|
+
const newHead = git(multi.path, 'rev-parse HEAD');
|
|
124
|
+
|
|
125
|
+
// Set lastAnalyzedCommit to the previous head (simulating a prior apply)
|
|
126
|
+
// We need to write to projects.json directly so the next scan uses incremental mode.
|
|
127
|
+
const cur = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
|
|
128
|
+
cur[TEMP_SLUG].lastAnalyzedCommit = multi.headCommit;
|
|
129
|
+
fs.writeFileSync(PROJECTS_JSON, JSON.stringify(cur, null, 2) + '\n', 'utf-8');
|
|
130
|
+
|
|
131
|
+
r = await json('POST', `/api/projects/${TEMP_SLUG}/scan`);
|
|
132
|
+
assert(r.res.ok, 'scan should succeed');
|
|
133
|
+
assert(r.data.mode === 'incremental', `second scan with lastAnalyzedCommit should be incremental, got ${r.data.mode}`);
|
|
134
|
+
assert(r.data.pendingCount === 2, `expected 2 new pending, got ${r.data.pendingCount}`);
|
|
135
|
+
assert(r.data.headCommit === newHead, 'headCommit should be updated to new HEAD');
|
|
136
|
+
assert(r.data.commits[0].subject.includes('fix: add d module'), 'commits should be ordered');
|
|
137
|
+
|
|
138
|
+
cfg = await getProject(TEMP_SLUG);
|
|
139
|
+
assert(cfg.lastSeenCommit === newHead, 'lastSeenCommit should equal new HEAD');
|
|
140
|
+
assert(cfg.lastAnalyzedCommit === multi.headCommit, 'lastAnalyzedCommit must NOT be updated by scan');
|
|
141
|
+
|
|
142
|
+
// 4. No new commits since lastAnalyzedCommit → 0 pending.
|
|
143
|
+
// (The scanner uses lastAnalyzedCommit as the range start, not lastSeenCommit.
|
|
144
|
+
// This is by design: "pending" means "awaiting apply", not "new since last scan".)
|
|
145
|
+
r = await json('POST', `/api/projects/${TEMP_SLUG}/scan`);
|
|
146
|
+
assert(r.res.ok, 'scan should succeed');
|
|
147
|
+
assert(r.data.pendingCount === 2, 'pendingCount is anchored to lastAnalyzedCommit, so it stays at 2');
|
|
148
|
+
assert(r.data.mode === 'incremental', 'should still be incremental mode');
|
|
149
|
+
|
|
150
|
+
// 5. Bad slug returns 400
|
|
151
|
+
r = await json('POST', '/api/projects/INVALID../scan');
|
|
152
|
+
assert(!r.res.ok && r.res.status === 400, 'bad slug should 400');
|
|
153
|
+
|
|
154
|
+
// 6. GET /scan is read-only
|
|
155
|
+
r = await json('GET', `/api/projects/${TEMP_SLUG}/scan`);
|
|
156
|
+
assert(r.res.ok, 'GET scan should succeed');
|
|
157
|
+
assert(r.data.mode === 'incremental', 'GET scan should compute pending');
|
|
158
|
+
|
|
159
|
+
cfg = await getProject(TEMP_SLUG);
|
|
160
|
+
const beforeHead = cfg.lastSeenCommit;
|
|
161
|
+
const beforeAnalyzed = cfg.lastAnalyzedCommit;
|
|
162
|
+
r = await json('GET', `/api/projects/${TEMP_SLUG}/scan`);
|
|
163
|
+
cfg = await getProject(TEMP_SLUG);
|
|
164
|
+
assert(cfg.lastSeenCommit === beforeHead, 'GET scan must not write');
|
|
165
|
+
assert(cfg.lastAnalyzedCommit === beforeAnalyzed, 'GET scan must not change lastAnalyzedCommit');
|
|
166
|
+
|
|
167
|
+
// 7. Missing lastAnalyzedCommit uses initial mode
|
|
168
|
+
const cur2 = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
|
|
169
|
+
cur2[TEMP_SLUG].lastAnalyzedCommit = null;
|
|
170
|
+
fs.writeFileSync(PROJECTS_JSON, JSON.stringify(cur2, null, 2) + '\n', 'utf-8');
|
|
171
|
+
|
|
172
|
+
r = await json('POST', `/api/projects/${TEMP_SLUG}/scan`);
|
|
173
|
+
assert(r.data.mode === 'initial', 'cleared lastAnalyzedCommit should switch back to initial mode');
|
|
174
|
+
assert(r.data.pendingCount > 0, 'initial mode should report all commits');
|
|
175
|
+
|
|
176
|
+
// 8. /api/scan-all scans every enabled project
|
|
177
|
+
const enabledFixture = makeRepo({ kind: 'one-commit' });
|
|
178
|
+
fixtures.push(enabledFixture);
|
|
179
|
+
await json('PUT', '/api/projects', {
|
|
180
|
+
slug: 'task-004-scan-all',
|
|
181
|
+
config: { displayName: 'scan-all', localPath: enabledFixture.path, gitPath: enabledFixture.path },
|
|
182
|
+
});
|
|
183
|
+
r = await json('POST', '/api/scan-all');
|
|
184
|
+
assert(r.res.ok, 'scan-all should succeed');
|
|
185
|
+
const allEntry = r.data.results.find(x => x.slug === 'task-004-scan-all');
|
|
186
|
+
assert(allEntry && allEntry.ok, 'scan-all should include the new project');
|
|
187
|
+
|
|
188
|
+
// cleanup extra
|
|
189
|
+
const cur3 = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
|
|
190
|
+
delete cur3['task-004-scan-all'];
|
|
191
|
+
fs.writeFileSync(PROJECTS_JSON, JSON.stringify(cur3, null, 2) + '\n', 'utf-8');
|
|
192
|
+
fs.rmSync(path.join(ROOT, 'projects', 'task-004-scan-all'), { recursive: true, force: true });
|
|
193
|
+
|
|
194
|
+
console.log('TASK-004 scanner test passed');
|
|
195
|
+
} catch (e) {
|
|
196
|
+
console.error('TASK-004 scanner test failed:', e.message);
|
|
197
|
+
if (serverOutput) console.error(serverOutput);
|
|
198
|
+
process.exitCode = 1;
|
|
199
|
+
} finally {
|
|
200
|
+
for (const f of fixtures) {
|
|
201
|
+
try { f.cleanup(); } catch {}
|
|
202
|
+
}
|
|
203
|
+
await cleanup().catch(() => {});
|
|
204
|
+
child.kill();
|
|
205
|
+
}
|
|
206
|
+
})();
|