@sanqianx/project-knowledge 4.6.1
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/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/CHANGELOG.md +780 -0
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +266 -0
- package/_site/README.md +30 -0
- package/_site/_test/ai-profile-resolver-test.js +137 -0
- package/_site/_test/ai-profile-test.js +75 -0
- package/_site/_test/ai-vendor-presets-test.js +91 -0
- package/_site/_test/automation-queue-test.js +14 -0
- package/_site/_test/automation-ui-test.js +40 -0
- package/_site/_test/background-task-registry-test.js +43 -0
- package/_site/_test/baseline-schema-test.js +91 -0
- package/_site/_test/bin-cli-test.js +218 -0
- package/_site/_test/bridge-adapter-test.js +95 -0
- package/_site/_test/bridge-consumer-service-test.js +310 -0
- package/_site/_test/cdp-browser-failure-test.js +100 -0
- package/_site/_test/chat-claudecodeui-match-test.js +92 -0
- package/_site/_test/claude-executable-discovery-test.js +90 -0
- package/_site/_test/claude-session-lifecycle-sweep-test.js +252 -0
- package/_site/_test/claude-workbench-test.js +170 -0
- package/_site/_test/client-capture-restart-e2e-test.js +189 -0
- package/_site/_test/codex-conversation-projection-test.js +59 -0
- package/_site/_test/codex-notify-fanout-test.js +45 -0
- package/_site/_test/commit-boundary-freeze-test.js +147 -0
- package/_site/_test/commit-conversation-binding-test.js +99 -0
- package/_site/_test/commit-evidence-test.js +110 -0
- package/_site/_test/context-pack-test.js +198 -0
- package/_site/_test/conversation-api-test.js +119 -0
- package/_site/_test/conversation-store-test.js +136 -0
- package/_site/_test/cross-repo-isolation-e2e-test.js +187 -0
- package/_site/_test/data-dir-migration-test.js +77 -0
- package/_site/_test/desktop-browser-compat-test.js +28 -0
- package/_site/_test/desktop-hook-runtime-regression-test.js +204 -0
- package/_site/_test/embedding-config-test.js +32 -0
- package/_site/_test/explicit-commit-processor-test.js +37 -0
- package/_site/_test/fixtures/fake-browser-exit.js +10 -0
- package/_site/_test/fixtures/fake-browser-hang.js +10 -0
- package/_site/_test/fixtures/make-git-repos.js +89 -0
- package/_site/_test/folder-picker-output-test.js +19 -0
- package/_site/_test/full-integration-e2e-test.js +165 -0
- package/_site/_test/git-validation-test.js +81 -0
- package/_site/_test/hook-runtime-endpoint-test.js +88 -0
- package/_site/_test/hook-status-repair-api-test.js +152 -0
- package/_site/_test/hook-trigger-test.js +85 -0
- package/_site/_test/hook-worktree-test.js +42 -0
- package/_site/_test/import-preflight-api-test.js +233 -0
- package/_site/_test/import-ui-flow-test.js +171 -0
- package/_site/_test/index-builder-test.js +41 -0
- package/_site/_test/index-writer-concurrency-test.js +135 -0
- package/_site/_test/integration-adapters-test.js +243 -0
- package/_site/_test/integration-capture-setup-test.js +108 -0
- package/_site/_test/integration-surface-coverage-test.js +165 -0
- package/_site/_test/kb-framework-test.js +57 -0
- package/_site/_test/kbpath-follow-test.js +63 -0
- package/_site/_test/knowledge-db-test.js +144 -0
- package/_site/_test/knowledge-language-control-test.js +170 -0
- package/_site/_test/knowledge-maintenance-test.js +40 -0
- package/_site/_test/knowledge-migration-test.js +75 -0
- package/_site/_test/knowledge-promotion-recovery-test.js +260 -0
- package/_site/_test/knowledge-query-test.js +54 -0
- package/_site/_test/knowledge-retrieval-service-test.js +77 -0
- package/_site/_test/knowledge-scopes-test.js +37 -0
- package/_site/_test/knowledge-storage-location-test.js +28 -0
- package/_site/_test/knowledge-storage-startup-test.js +52 -0
- package/_site/_test/knowledge-store-logs-supervision-test.js +89 -0
- package/_site/_test/legacy-claude-session-alias-test.js +106 -0
- package/_site/_test/legacy-forward-compat-test.js +257 -0
- package/_site/_test/legacy-project-upgrade-e2e-test.js +363 -0
- package/_site/_test/legacy-routes-removed-test.js +47 -0
- package/_site/_test/legacy-workbench-exclusion-test.js +92 -0
- package/_site/_test/log-redaction-test.js +58 -0
- package/_site/_test/logging-api-test.js +89 -0
- package/_site/_test/logging-sse-no-gap-test.js +88 -0
- package/_site/_test/logging-ui-test.js +104 -0
- package/_site/_test/markdown-delta-overlay-test.js +85 -0
- package/_site/_test/markdown-maintenance-api-test.js +75 -0
- package/_site/_test/markdown-maintenance-test.js +11 -0
- package/_site/_test/mcp-server-test.js +149 -0
- package/_site/_test/model-context-windows-test.js +89 -0
- package/_site/_test/module-bridge-eventbridge-test.js +193 -0
- package/_site/_test/non-release-ci-test.js +34 -0
- package/_site/_test/offline-boundary-isolation-test.js +32 -0
- package/_site/_test/orphaned-claim-recovery-test.js +80 -0
- package/_site/_test/p0-data-migration-characterization-test.js +37 -0
- package/_site/_test/p0-e2e-gate-test.js +347 -0
- package/_site/_test/package-boundary-test.js +42 -0
- package/_site/_test/package-startup-test.js +56 -0
- package/_site/_test/packaged-ui-smoke-test.js +79 -0
- package/_site/_test/path-consistency-test.js +151 -0
- package/_site/_test/pending-sweep-test.js +9 -0
- package/_site/_test/post-commit-automation-test.js +87 -0
- package/_site/_test/pr-consumer-contract-test.js +55 -0
- package/_site/_test/project-control-panel-task14-test.js +63 -0
- package/_site/_test/project-delete-recovery-test.js +64 -0
- package/_site/_test/project-goal-editor-test.js +145 -0
- package/_site/_test/project-layout-v2-migration-test.js +127 -0
- package/_site/_test/project-lifecycle-transaction-test.js +106 -0
- package/_site/_test/project-remove-running-guard-test.js +79 -0
- package/_site/_test/project-store-test.js +90 -0
- package/_site/_test/prompt-settings-test.js +115 -0
- package/_site/_test/protected-architecture-gate-test.js +123 -0
- package/_site/_test/refactor-characterization-test.js +36 -0
- package/_site/_test/release-version-sync-test.js +98 -0
- package/_site/_test/requirement-binding-test.js +113 -0
- package/_site/_test/requirement-recorder-test.js +173 -0
- package/_site/_test/run-all-tests.js +156 -0
- package/_site/_test/runtime-endpoint-test.js +64 -0
- package/_site/_test/scanner-test.js +83 -0
- package/_site/_test/server-runtime-migration-safety-test.js +27 -0
- package/_site/_test/server-security-test.js +96 -0
- package/_site/_test/sessions-stream-test.js +92 -0
- package/_site/_test/shared-contracts-test.js +58 -0
- package/_site/_test/simple-import-test.js +97 -0
- package/_site/_test/startup-analysis-disabled-test.js +16 -0
- package/_site/_test/storage-foundation-test.js +78 -0
- package/_site/_test/structured-logger-test.js +101 -0
- package/_site/_test/task15-20-ui-flow-test.js +148 -0
- package/_site/_test/team-knowledge-store-sparse-checkout-test.js +196 -0
- package/_site/_test/team-knowledge-store-test.js +131 -0
- package/_site/_test/tracking-start-test.js +143 -0
- package/_site/_test/ui-i18n-toggle-test.js +114 -0
- package/_site/_test/ui-smoke-test.js +73 -0
- package/_site/_test/v4122-upgrade-data-contract-test.js +56 -0
- package/_site/_test/workbench-permission-test.js +118 -0
- package/_site/_test/workspace-ui-contract-test.js +43 -0
- package/_site/lib/ai-profile-resolver.js +78 -0
- package/_site/lib/ai-vendor-presets.js +83 -0
- package/_site/lib/ai-workspace.js +104 -0
- package/_site/lib/atomic-file.js +204 -0
- package/_site/lib/automation-config.js +140 -0
- package/_site/lib/bridge-adapter.js +256 -0
- package/_site/lib/bridge-consumer-service.js +402 -0
- package/_site/lib/claude-cli-runner.js +1599 -0
- package/_site/lib/commit-conversation-binder.js +246 -0
- package/_site/lib/commit-processing-ledger.js +27 -0
- package/_site/lib/commit-prompt.js +202 -0
- package/_site/lib/commit-reconciler.js +444 -0
- package/_site/lib/context-pack-builder.js +299 -0
- package/_site/lib/contracts.js +279 -0
- package/_site/lib/conversation-exclusions.js +204 -0
- package/_site/lib/conversation-query-service.js +161 -0
- package/_site/lib/conversation-store.js +385 -0
- package/_site/lib/data-dir.js +81 -0
- package/_site/lib/data-state-classifier.js +40 -0
- package/_site/lib/embedding-service.js +85 -0
- package/_site/lib/evidence-bundle.js +228 -0
- package/_site/lib/folder-picker-output.js +24 -0
- package/_site/lib/git-runner.js +62 -0
- package/_site/lib/github-team-store.js +1175 -0
- package/_site/lib/hook-manager.js +202 -0
- package/_site/lib/index-builder.js +157 -0
- package/_site/lib/index-service.js +173 -0
- package/_site/lib/integration-installer.js +907 -0
- package/_site/lib/kb-framework.js +191 -0
- package/_site/lib/kb-validator.js +124 -0
- package/_site/lib/knowledge-db.js +363 -0
- package/_site/lib/knowledge-promotion.js +435 -0
- package/_site/lib/knowledge-retrieval-service.js +401 -0
- package/_site/lib/knowledge-schema.js +121 -0
- package/_site/lib/knowledge-tool-runtime.js +359 -0
- package/_site/lib/legacy-data-manifest.js +26 -0
- package/_site/lib/llm-client.js +173 -0
- package/_site/lib/markdown-knowledge-indexer.js +230 -0
- package/_site/lib/migration-service.js +442 -0
- package/_site/lib/model-context-windows.js +82 -0
- package/_site/lib/module-bridge.js +496 -0
- package/_site/lib/post-commit-automation.js +141 -0
- package/_site/lib/project-lifecycle-service.js +511 -0
- package/_site/lib/project-registry-store.js +129 -0
- package/_site/lib/project-store.js +262 -0
- package/_site/lib/prompt-registry.js +148 -0
- package/_site/lib/requirement-adapters.js +10 -0
- package/_site/lib/requirement-binder.js +112 -0
- package/_site/lib/requirement-recorder.js +278 -0
- package/_site/lib/runtime-endpoint.js +160 -0
- package/_site/lib/scanner.js +298 -0
- package/_site/lib/server-app.js +1520 -0
- package/_site/lib/settings-store.js +113 -0
- package/_site/lib/storage-layout.js +162 -0
- package/_site/lib/structured-logger.js +574 -0
- package/_site/scripts/codex-notify-fanout.cjs +51 -0
- package/_site/scripts/folder-picker.ps1 +156 -0
- package/_site/scripts/hook-trigger.js +156 -0
- package/_site/scripts/sync-release-version.js +158 -0
- package/_site/server.js +12 -0
- package/_site/start.bat +26 -0
- package/_site/stop.bat +11 -0
- package/bin/project-knowledge-integrations.js +241 -0
- package/bin/project-knowledge-kb.js +65 -0
- package/bin/project-knowledge-mcp.js +195 -0
- package/bin/project-knowledge.js +439 -0
- package/claude-prompts.json +21 -0
- package/docs/README.zh-CN.md +175 -0
- package/docs/assets/architecture.svg +182 -0
- package/docs/assets/dashboard.png +0 -0
- package/docs/assets/draft-review.png +0 -0
- package/docs/assets/logo.svg +50 -0
- package/docs/assets/runs-drafts.png +0 -0
- package/docs/assets/settings.png +0 -0
- package/docs/project-registry-schema.md +33 -0
- package/docs/team-knowledge-mode-a-plan.md +73 -0
- package/docs/testing-strategy.md +33 -0
- package/package.json +100 -0
- package/plugins/project-knowledge/.claude-plugin/plugin.json +19 -0
- package/plugins/project-knowledge/.codex-plugin/plugin.json +38 -0
- package/plugins/project-knowledge/.mcp.json +14 -0
- package/plugins/project-knowledge/opencode/project-knowledge.md +3 -0
- package/plugins/project-knowledge/skills/project-knowledge/SKILL.md +28 -0
- package/plugins/project-knowledge/skills/project-knowledge/agents/openai.yaml +11 -0
- package/templates/change.md +30 -0
- package/templates/project-readme.md +52 -0
- package/ui/app.css +58 -0
- package/ui/app.js +607 -0
- package/ui/favicon.svg +38 -0
- package/ui/i18n.js +146 -0
- package/ui/index.html +162 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Run: node _site/_test/scanner-test.js
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { spawnSync } = require('child_process');
|
|
8
|
+
const { CommitScanner, scanProject } = require('../lib/scanner');
|
|
9
|
+
|
|
10
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), `kb-scanner-${process.pid}-`));
|
|
11
|
+
|
|
12
|
+
function git(repo, args) {
|
|
13
|
+
const result = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', windowsHide: true });
|
|
14
|
+
if (result.status !== 0) throw new Error(result.stderr || `git ${args.join(' ')} failed`);
|
|
15
|
+
return String(result.stdout || '').trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function initRepo(name) {
|
|
19
|
+
const repo = path.join(temp, name);
|
|
20
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
21
|
+
git(repo, ['init', '--initial-branch=main']);
|
|
22
|
+
git(repo, ['config', 'user.email', 'scanner@example.test']);
|
|
23
|
+
git(repo, ['config', 'user.name', 'Scanner Test']);
|
|
24
|
+
return repo;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function commit(repo, index) {
|
|
28
|
+
fs.writeFileSync(path.join(repo, `${index}.txt`), `${index}\n`, 'utf8');
|
|
29
|
+
git(repo, ['add', `${index}.txt`]);
|
|
30
|
+
git(repo, ['commit', '-m', `commit ${index}`]);
|
|
31
|
+
return git(repo, ['rev-parse', 'HEAD']);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
(async () => {
|
|
35
|
+
const scanner = new CommitScanner({ batchSize: 2 });
|
|
36
|
+
const emptyRepo = initRepo('empty');
|
|
37
|
+
const empty = await scanner.scan({ repoPath: emptyRepo }, { trackingMode: 'normal', trackingStartCommit: null, lastAnalyzedCommit: null });
|
|
38
|
+
assert.strictEqual(empty.status, 'empty-repo');
|
|
39
|
+
assert.deepStrictEqual(empty.commits, []);
|
|
40
|
+
|
|
41
|
+
const repo = initRepo('multi');
|
|
42
|
+
const importedHead = commit(repo, 1);
|
|
43
|
+
const initial = await scanner.scan({ repoPath: repo }, { trackingMode: 'normal', trackingStartCommit: null, lastAnalyzedCommit: null });
|
|
44
|
+
assert.strictEqual(initial.status, 'establish-tracking');
|
|
45
|
+
assert.strictEqual(initial.head, importedHead);
|
|
46
|
+
assert.strictEqual(initial.commits.length, 0, 'pre-import history must not be analyzed');
|
|
47
|
+
|
|
48
|
+
const second = commit(repo, 2);
|
|
49
|
+
const third = commit(repo, 3);
|
|
50
|
+
const fourth = commit(repo, 4);
|
|
51
|
+
const pending = await scanner.scan({ repoPath: repo }, { trackingMode: 'normal', trackingStartCommit: importedHead, lastAnalyzedCommit: null });
|
|
52
|
+
assert.deepStrictEqual(pending.commits, [second, third]);
|
|
53
|
+
assert.strictEqual(pending.pendingCount, 3);
|
|
54
|
+
assert.deepStrictEqual(pending.continuation, { remaining: 1, after: third });
|
|
55
|
+
|
|
56
|
+
const next = await scanner.scan({ repoPath: repo }, { trackingMode: 'normal', trackingStartCommit: importedHead, lastAnalyzedCommit: third });
|
|
57
|
+
assert.deepStrictEqual(next.commits, [fourth], 'lastAnalyzedCommit must take priority over trackingStartCommit');
|
|
58
|
+
assert.strictEqual(next.continuation, null);
|
|
59
|
+
|
|
60
|
+
const firstAfterEmpty = await scanner.scan({ repoPath: repo }, { trackingMode: 'empty-repo', trackingStartCommit: null, lastAnalyzedCommit: null });
|
|
61
|
+
assert.deepStrictEqual(firstAfterEmpty.commits, [importedHead, second], 'empty-repo mode should start with the root commit and expose continuation');
|
|
62
|
+
|
|
63
|
+
const legacyView = await scanProject({ slug: 'legacy-view', gitPath: repo, trackingStartCommit: importedHead }, { batchSize: 10 });
|
|
64
|
+
assert.strictEqual(legacyView.repoStatus, 'ok');
|
|
65
|
+
assert.deepStrictEqual(legacyView.commits.map(item => item.hash), [second, third, fourth]);
|
|
66
|
+
|
|
67
|
+
const tree = git(repo, ['write-tree']);
|
|
68
|
+
const rewritten = git(repo, ['commit-tree', tree, '-m', 'rewritten']);
|
|
69
|
+
git(repo, ['update-ref', 'refs/heads/main', rewritten]);
|
|
70
|
+
const diverged = await scanner.scan({ repoPath: repo }, { trackingMode: 'normal', trackingStartCommit: importedHead, lastAnalyzedCommit: null });
|
|
71
|
+
assert.strictEqual(diverged.status, 'history-diverged');
|
|
72
|
+
assert.strictEqual(diverged.head, rewritten);
|
|
73
|
+
assert.deepStrictEqual(diverged.commits, []);
|
|
74
|
+
|
|
75
|
+
const missing = await scanner.scan({ repoPath: path.join(temp, 'missing') }, { trackingMode: 'normal' });
|
|
76
|
+
assert.strictEqual(missing.status, 'not-git');
|
|
77
|
+
console.log('scanner-test PASS');
|
|
78
|
+
})().catch(error => {
|
|
79
|
+
console.error(error.stack || error.message);
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
}).finally(() => {
|
|
82
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
83
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { initializeRuntime } = require('../lib/server-app');
|
|
6
|
+
|
|
7
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-runtime-migration-safety-'));
|
|
8
|
+
let settingsInitialized = false;
|
|
9
|
+
let registryInitialized = false;
|
|
10
|
+
const runtime = {
|
|
11
|
+
rootDir: path.join(root, 'missing-legacy-root'),
|
|
12
|
+
dataPath: path.join(root, 'data'),
|
|
13
|
+
logger: { info: async () => {} },
|
|
14
|
+
migrationService: { migrateIfNeeded: async () => ({ ok: false, requiresManualRecovery: true, reason: 'fault-injected' }) },
|
|
15
|
+
settingsStore: { initialize: async () => { settingsInitialized = true; } },
|
|
16
|
+
registryStore: { initialize: async () => { registryInitialized = true; } },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
initializeRuntime(runtime).then(() => {
|
|
20
|
+
throw new Error('initializeRuntime must reject an unsuccessful migration');
|
|
21
|
+
}).catch(error => {
|
|
22
|
+
assert.equal(error.code, 'MIGRATION_FAILED');
|
|
23
|
+
assert.equal(settingsInitialized, false, 'settings defaults must not be initialized after migration failure');
|
|
24
|
+
assert.equal(registryInitialized, false, 'empty project registry must not be initialized after migration failure');
|
|
25
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
26
|
+
console.log('server-runtime-migration-safety-test PASS');
|
|
27
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnServer } = require('./helpers/spawn-server');
|
|
6
|
+
const { createRequestHandler } = require('../lib/server-app');
|
|
7
|
+
|
|
8
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
9
|
+
const PORT = 7832;
|
|
10
|
+
const BASE = `http://127.0.0.1:${PORT}`;
|
|
11
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pkb-security-v2-'));
|
|
12
|
+
const REMOTE_PORT = 7833;
|
|
13
|
+
const REMOTE_BASE = `http://127.0.0.1:${REMOTE_PORT}`;
|
|
14
|
+
const remoteDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pkb-security-remote-v2-'));
|
|
15
|
+
|
|
16
|
+
async function waitForServer() {
|
|
17
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
18
|
+
try { if ((await fetch(`${BASE}/api/health`)).ok) return; } catch {}
|
|
19
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
20
|
+
}
|
|
21
|
+
throw new Error('server did not start');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
(async () => {
|
|
25
|
+
assert.throws(
|
|
26
|
+
() => createRequestHandler({}, { host: '0.0.0.0', port: 9999 }),
|
|
27
|
+
error => error && error.code === 'AUTH_REQUIRED',
|
|
28
|
+
'non-loopback bind must require authentication',
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const spawned = spawnServer({ root: ROOT, port: PORT, dataDir, tag: 'security-v2', extraEnv: { KB_SKIP_MIGRATION: '1' } });
|
|
32
|
+
try {
|
|
33
|
+
await waitForServer();
|
|
34
|
+
let response = await fetch(`${BASE}/api/ai-profiles`, { headers: { Origin: 'https://evil.example' } });
|
|
35
|
+
assert.strictEqual(response.status, 403);
|
|
36
|
+
assert.strictEqual(response.headers.get('access-control-allow-origin'), null);
|
|
37
|
+
|
|
38
|
+
response = await fetch(`${BASE}/api/ai-profiles`, {
|
|
39
|
+
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({
|
|
40
|
+
schema: 'ai-profiles/v1', profiles: [{ id: 'secret', enabled: true, apiKeyUpdate: { mode: 'replace', value: 'top-secret-api-key' } }],
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
assert.strictEqual(response.status, 200);
|
|
44
|
+
response = await fetch(`${BASE}/api/ai-profiles`);
|
|
45
|
+
const profileText = await response.text();
|
|
46
|
+
assert(!profileText.includes('top-secret-api-key'));
|
|
47
|
+
assert(!/"apiKey"\s*:/.test(profileText));
|
|
48
|
+
|
|
49
|
+
response = await fetch(`${BASE}/api/raw?path=../../settings.json`);
|
|
50
|
+
assert.strictEqual(response.status, 404);
|
|
51
|
+
const rawText = await response.text();
|
|
52
|
+
assert(!rawText.includes('top-secret-api-key'));
|
|
53
|
+
|
|
54
|
+
response = await fetch(`${BASE}/api/projects/not-valid%2Fid`);
|
|
55
|
+
const errorText = await response.text();
|
|
56
|
+
assert(!/\bstack\b/i.test(errorText));
|
|
57
|
+
assert(!errorText.includes('top-secret-api-key'));
|
|
58
|
+
const error = JSON.parse(errorText);
|
|
59
|
+
assert(error.error.operationId);
|
|
60
|
+
|
|
61
|
+
const remote = spawnServer({
|
|
62
|
+
root: ROOT,
|
|
63
|
+
port: REMOTE_PORT,
|
|
64
|
+
host: '0.0.0.0',
|
|
65
|
+
dataDir: remoteDataDir,
|
|
66
|
+
tag: 'security-remote-v2',
|
|
67
|
+
extraEnv: {
|
|
68
|
+
KB_SKIP_MIGRATION: '1',
|
|
69
|
+
KB_SITE_AUTH_TOKEN: 'remote-auth-secret',
|
|
70
|
+
KB_ALLOWED_ORIGINS: 'https://allowed.example',
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
75
|
+
try {
|
|
76
|
+
const ready = await fetch(`${REMOTE_BASE}/api/health`, { headers: { Authorization: 'Bearer remote-auth-secret', Origin: 'https://allowed.example' } });
|
|
77
|
+
if (ready.ok) break;
|
|
78
|
+
} catch {}
|
|
79
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
80
|
+
}
|
|
81
|
+
let remoteResponse = await fetch(`${REMOTE_BASE}/api/health`, { headers: { Origin: 'https://allowed.example' } });
|
|
82
|
+
assert.strictEqual(remoteResponse.status, 401, 'non-loopback request without token must fail');
|
|
83
|
+
remoteResponse = await fetch(`${REMOTE_BASE}/api/health`, { headers: { Authorization: 'Bearer remote-auth-secret', Origin: 'https://evil.example' } });
|
|
84
|
+
assert.strictEqual(remoteResponse.status, 403, 'non-loopback request from an unlisted Origin must fail');
|
|
85
|
+
remoteResponse = await fetch(`${REMOTE_BASE}/api/health`, { headers: { Authorization: 'Bearer remote-auth-secret', Origin: 'https://allowed.example' } });
|
|
86
|
+
assert.strictEqual(remoteResponse.status, 200);
|
|
87
|
+
assert.strictEqual(remoteResponse.headers.get('access-control-allow-origin'), 'https://allowed.example');
|
|
88
|
+
} finally {
|
|
89
|
+
remote.cleanup();
|
|
90
|
+
}
|
|
91
|
+
console.log('server-security-test PASS');
|
|
92
|
+
} finally {
|
|
93
|
+
await spawned.cleanup();
|
|
94
|
+
fs.rmSync(remoteDataDir, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
})().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
7
|
+
const { spawnServer } = require('./helpers/spawn-server');
|
|
8
|
+
|
|
9
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
10
|
+
const PORT = 7804;
|
|
11
|
+
const BASE = `http://127.0.0.1:${PORT}`;
|
|
12
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pkb-sse-v2-'));
|
|
13
|
+
const repoPath = fs.mkdtempSync(path.join(os.tmpdir(), 'pkb-sse-repo-'));
|
|
14
|
+
|
|
15
|
+
function git(args) {
|
|
16
|
+
const result = spawnSync('git', args, { cwd: repoPath, encoding: 'utf8' });
|
|
17
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function json(method, pathname, body) {
|
|
21
|
+
const response = await fetch(`${BASE}${pathname}`, { method, headers: body === undefined ? undefined : { 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
22
|
+
return { status: response.status, data: await response.json() };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function waitForServer() {
|
|
26
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
27
|
+
try { if ((await fetch(`${BASE}/api/health`)).ok) return; } catch {}
|
|
28
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
29
|
+
}
|
|
30
|
+
throw new Error('server did not start');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function openSse() {
|
|
34
|
+
const frames = [];
|
|
35
|
+
let buffer = '';
|
|
36
|
+
const request = http.get(`${BASE}/api/claude/sessions-stream`, response => {
|
|
37
|
+
assert.strictEqual(response.statusCode, 200);
|
|
38
|
+
response.on('data', chunk => {
|
|
39
|
+
buffer += chunk.toString('utf8');
|
|
40
|
+
let boundary;
|
|
41
|
+
while ((boundary = buffer.indexOf('\n\n')) >= 0) {
|
|
42
|
+
const block = buffer.slice(0, boundary);
|
|
43
|
+
buffer = buffer.slice(boundary + 2);
|
|
44
|
+
const event = /^event:\s*(.+)$/m.exec(block);
|
|
45
|
+
const data = /^data:\s*(.+)$/m.exec(block);
|
|
46
|
+
if (event && data) frames.push({ event: event[1], data: JSON.parse(data[1]) });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
request.on('error', error => { frames.push({ event: 'transport-error', data: { message: error.message } }); });
|
|
51
|
+
return { frames, close: () => request.destroy() };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function waitFor(predicate) {
|
|
55
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
56
|
+
if (predicate()) return;
|
|
57
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
58
|
+
}
|
|
59
|
+
throw new Error('timed out waiting for SSE frame');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
(async () => {
|
|
63
|
+
git(['init', '--initial-branch=main']);
|
|
64
|
+
git(['config', 'user.email', 'sse@example.test']);
|
|
65
|
+
git(['config', 'user.name', 'SSE Test']);
|
|
66
|
+
const spawned = spawnServer({ root: ROOT, port: PORT, dataDir, tag: 'sse-v2', extraEnv: { KB_SKIP_MIGRATION: '1' } });
|
|
67
|
+
try {
|
|
68
|
+
await waitForServer();
|
|
69
|
+
await json('PATCH', '/api/settings', { knowledge: { rootPath: path.join(dataDir, 'knowledge') } });
|
|
70
|
+
await json('PUT', '/api/ai-profiles', { schema: 'ai-profiles/v1', defaultProfileId: 'test', profiles: [{ id: 'test', name: 'Test', enabled: true, implementation: 'claude-code-agent', apiKeyUpdate: { mode: 'replace', value: 'sk-test' } }] });
|
|
71
|
+
const imported = await json('POST', '/api/projects/import', { localPath: repoPath, aiProfileId: 'test' });
|
|
72
|
+
assert.strictEqual(imported.status, 201, JSON.stringify(imported.data));
|
|
73
|
+
|
|
74
|
+
const stream = openSse();
|
|
75
|
+
await waitFor(() => stream.frames.some(frame => frame.event === 'claude/snapshot'));
|
|
76
|
+
const snapshot = stream.frames.find(frame => frame.event === 'claude/snapshot');
|
|
77
|
+
assert(Array.isArray(snapshot.data.sessions));
|
|
78
|
+
|
|
79
|
+
const started = await json('POST', '/api/claude/sessions', { projectId: imported.data.projectId });
|
|
80
|
+
assert.strictEqual(started.status, 201, JSON.stringify(started.data));
|
|
81
|
+
await waitFor(() => stream.frames.some(frame => frame.event === 'claude/sessions-changed' && frame.data.kind === 'create'));
|
|
82
|
+
const created = stream.frames.find(frame => frame.event === 'claude/sessions-changed' && frame.data.kind === 'create');
|
|
83
|
+
assert.strictEqual(created.data.projectSlug, imported.data.projectId);
|
|
84
|
+
const sessions = await json('GET', `/api/claude/sessions?projectId=${encodeURIComponent(imported.data.projectId)}`);
|
|
85
|
+
assert(sessions.data.sessions.some(session => session.sessionId === started.data.sessionId));
|
|
86
|
+
stream.close();
|
|
87
|
+
console.log('sessions-stream-test PASS');
|
|
88
|
+
} finally {
|
|
89
|
+
await spawned.cleanup();
|
|
90
|
+
fs.rmSync(repoPath, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
})().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const contracts = require('../lib/contracts');
|
|
3
|
+
|
|
4
|
+
assert.deepStrictEqual(contracts.TRIGGERS, ['git-hook']);
|
|
5
|
+
assert.deepStrictEqual(contracts.LOG_LEVELS, ['trace', 'debug', 'info', 'warn', 'error', 'fatal']);
|
|
6
|
+
assert.strictEqual(contracts.SCHEMAS.projectState, 'project-state/v2');
|
|
7
|
+
assert.strictEqual(contracts.SCHEMA_VERSIONS.layoutMigration, 'layout-v2');
|
|
8
|
+
|
|
9
|
+
for (const trigger of contracts.TRIGGERS) assert.strictEqual(contracts.validateTrigger(trigger), trigger);
|
|
10
|
+
assert.throws(() => contracts.validateTrigger('simulate'), error => error.code === 'INVALID_TRIGGER');
|
|
11
|
+
assert.throws(() => contracts.validateTrigger('project-init'), error => error.code === 'INVALID_TRIGGER');
|
|
12
|
+
assert.throws(() => contracts.validateTrigger('startup'), error => error.code === 'INVALID_TRIGGER');
|
|
13
|
+
|
|
14
|
+
const publicProfile = contracts.publicAiProfileView({
|
|
15
|
+
id: 'primary',
|
|
16
|
+
name: 'Primary',
|
|
17
|
+
apiKey: 'sk-super-secret-1234',
|
|
18
|
+
authToken: 'second-secret',
|
|
19
|
+
baseUrl: 'https://example.invalid',
|
|
20
|
+
});
|
|
21
|
+
assert.strictEqual(publicProfile.id, 'primary');
|
|
22
|
+
assert.strictEqual(publicProfile.baseUrl, 'https://example.invalid');
|
|
23
|
+
assert.strictEqual(publicProfile.hasApiKey, true);
|
|
24
|
+
assert.strictEqual(publicProfile.apiKeyMasked, '****1234');
|
|
25
|
+
assert.strictEqual(Object.prototype.hasOwnProperty.call(publicProfile, 'apiKey'), false);
|
|
26
|
+
assert.strictEqual(Object.prototype.hasOwnProperty.call(publicProfile, 'authToken'), false);
|
|
27
|
+
|
|
28
|
+
const domainError = new contracts.DomainError('HOOK_CONFLICT', 'A non-managed hook exists.', {
|
|
29
|
+
status: 409,
|
|
30
|
+
operationId: 'op-test',
|
|
31
|
+
details: {
|
|
32
|
+
apiKey: 'must-not-leak',
|
|
33
|
+
stack: 'must-not-leak',
|
|
34
|
+
nested: { authorization: 'Bearer must-not-leak', safe: 'kept' },
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
const envelope = contracts.serializeErrorEnvelope(domainError);
|
|
38
|
+
const serialized = JSON.stringify(envelope);
|
|
39
|
+
assert.strictEqual(envelope.ok, false);
|
|
40
|
+
assert.strictEqual(envelope.error.code, 'HOOK_CONFLICT');
|
|
41
|
+
assert.strictEqual(envelope.error.operationId, 'op-test');
|
|
42
|
+
assert(!serialized.includes('must-not-leak'));
|
|
43
|
+
assert(!serialized.includes('stack'));
|
|
44
|
+
assert(serialized.includes('kept'));
|
|
45
|
+
|
|
46
|
+
assert.strictEqual(contracts.validateStateTransition('idle', 'scanning'), true);
|
|
47
|
+
assert.strictEqual(contracts.validateStateTransition('markdown.promoted', 'state.advanced'), true);
|
|
48
|
+
assert.throws(() => contracts.validateStateTransition('idle', 'markdown.promoted'));
|
|
49
|
+
|
|
50
|
+
assert.strictEqual(contracts.assertMutableProjectPatch({ displayName: 'New', enabled: false }), true);
|
|
51
|
+
assert.throws(() => contracts.assertMutableProjectPatch({ projectId: 'new' }), error => error.code === 'IMMUTABLE_FIELD');
|
|
52
|
+
assert.throws(() => contracts.assertMutableProjectPatch({ knowledgePath: 'D:/new' }), error => error.code === 'IMMUTABLE_FIELD');
|
|
53
|
+
assert.strictEqual(contracts.assertMutableProjectPatch({ knowledgePath: 'D:/new' }, { allowKnowledgePath: true }), true);
|
|
54
|
+
assert.throws(() => contracts.assertMutableProjectPatch({ repoPath: 'D:/moved' }), error => error.code === 'INVALID_ARGUMENT');
|
|
55
|
+
assert.strictEqual(contracts.assertMutableProjectPatch({ repoPath: 'D:/moved' }, { allowRepoPath: true }), true);
|
|
56
|
+
assert.throws(() => contracts.assertMutableProjectPatch({ teamBinding: {} }), error => error.code === 'INVALID_ARGUMENT');
|
|
57
|
+
|
|
58
|
+
console.log('shared-contracts-test PASS');
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
const { spawnServer } = require('./helpers/spawn-server');
|
|
7
|
+
|
|
8
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
9
|
+
const PORT = 7824;
|
|
10
|
+
const BASE = `http://127.0.0.1:${PORT}`;
|
|
11
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pkb-import-v2-'));
|
|
12
|
+
const repoPath = fs.mkdtempSync(path.join(os.tmpdir(), 'pkb-import-repo-'));
|
|
13
|
+
const knowledgeRoot = path.join(dataDir, 'knowledge');
|
|
14
|
+
|
|
15
|
+
function git(args) {
|
|
16
|
+
const result = spawnSync('git', args, { cwd: repoPath, encoding: 'utf8' });
|
|
17
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
18
|
+
return String(result.stdout || '').trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function request(method, pathname, body) {
|
|
22
|
+
const response = await fetch(`${BASE}${pathname}`, {
|
|
23
|
+
method,
|
|
24
|
+
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
|
|
25
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
26
|
+
});
|
|
27
|
+
return { status: response.status, data: await response.json() };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function waitForServer() {
|
|
31
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
32
|
+
try { if ((await fetch(`${BASE}/api/health`)).ok) return; } catch {}
|
|
33
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
34
|
+
}
|
|
35
|
+
throw new Error('server did not start');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
(async () => {
|
|
39
|
+
git(['init', '--initial-branch=main']);
|
|
40
|
+
git(['config', 'user.email', 'import@example.test']);
|
|
41
|
+
git(['config', 'user.name', 'Import Test']);
|
|
42
|
+
fs.writeFileSync(path.join(repoPath, 'README.md'), '# source\n', 'utf8');
|
|
43
|
+
git(['add', 'README.md']);
|
|
44
|
+
git(['commit', '-m', 'baseline']);
|
|
45
|
+
const baseline = git(['rev-parse', 'HEAD']);
|
|
46
|
+
|
|
47
|
+
const spawned = spawnServer({ root: ROOT, port: PORT, dataDir, tag: 'simple-import-v2', extraEnv: { KB_SKIP_MIGRATION: '1' } });
|
|
48
|
+
let output = '';
|
|
49
|
+
spawned.child.stdout.on('data', chunk => { output += chunk; });
|
|
50
|
+
spawned.child.stderr.on('data', chunk => { output += chunk; });
|
|
51
|
+
try {
|
|
52
|
+
await waitForServer();
|
|
53
|
+
let result = await request('PATCH', '/api/settings', { knowledge: { rootPath: knowledgeRoot } });
|
|
54
|
+
assert.strictEqual(result.status, 200, JSON.stringify(result.data));
|
|
55
|
+
|
|
56
|
+
result = await request('POST', '/api/projects/import', { localPath: repoPath, displayName: 'Imported v2' });
|
|
57
|
+
assert.strictEqual(result.status, 201, JSON.stringify(result.data));
|
|
58
|
+
assert.match(result.data.projectId, /^project-/);
|
|
59
|
+
assert.strictEqual(result.data.project.config.projectId, result.data.projectId);
|
|
60
|
+
assert.strictEqual(result.data.project.state.trackingStartCommit, baseline);
|
|
61
|
+
assert.strictEqual(result.data.project.state.lastAnalyzedCommit, null);
|
|
62
|
+
assert.strictEqual(result.data.project.state.analysis.status, 'idle');
|
|
63
|
+
assert.strictEqual(result.data.hook.ok, true);
|
|
64
|
+
assert.strictEqual(result.data.project.state.hook.managedVersion, 2);
|
|
65
|
+
assert.deepStrictEqual(fs.readdirSync(result.data.project.config.knowledgePath), [], 'import must not create speculative knowledge');
|
|
66
|
+
assert(!fs.existsSync(path.join(dataDir, 'projects', result.data.projectId, 'requirements.jsonl')));
|
|
67
|
+
|
|
68
|
+
const projectId = result.data.projectId;
|
|
69
|
+
result = await request('PATCH', `/api/projects/${projectId}`, { displayName: 'Renamed safely', knowledgeLanguage: 'en-US' });
|
|
70
|
+
assert.strictEqual(result.status, 200, JSON.stringify(result.data));
|
|
71
|
+
assert.strictEqual(result.data.config.displayName, 'Renamed safely');
|
|
72
|
+
result = await request('PATCH', `/api/projects/${projectId}`, { repoPath: path.join(repoPath, 'unverified') });
|
|
73
|
+
assert.strictEqual(result.status, 400, 'generic PATCH must not bypass Hook-verified repo relocation');
|
|
74
|
+
assert.strictEqual(result.data.error.code, 'INVALID_ARGUMENT');
|
|
75
|
+
result = await request('PATCH', `/api/projects/${projectId}`, { teamBinding: { provider: 'github' } });
|
|
76
|
+
assert.strictEqual(result.status, 400, 'generic PATCH must not change a fixed team binding');
|
|
77
|
+
|
|
78
|
+
const hookPath = path.join(repoPath, '.git', 'hooks', 'post-commit');
|
|
79
|
+
assert(fs.existsSync(hookPath), 'managed Hook was not installed');
|
|
80
|
+
const hook = fs.readFileSync(hookPath, 'utf8');
|
|
81
|
+
assert(hook.includes('PROJECT-KNOWLEDGE-HOOK'));
|
|
82
|
+
assert(hook.includes(projectId));
|
|
83
|
+
|
|
84
|
+
result = await request('GET', '/api/projects');
|
|
85
|
+
assert.strictEqual(result.status, 200);
|
|
86
|
+
assert.strictEqual(result.data.projects.length, 1);
|
|
87
|
+
assert.strictEqual(result.data.projects[0].projectId, result.data.projects[0].config.projectId);
|
|
88
|
+
assert(!fs.existsSync(path.join(dataDir, 'runtime', 'staging')), 'import must not dispatch analysis');
|
|
89
|
+
console.log('simple-import-test PASS');
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error(output);
|
|
92
|
+
throw error;
|
|
93
|
+
} finally {
|
|
94
|
+
await spawned.cleanup();
|
|
95
|
+
fs.rmSync(repoPath, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
})().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const root = path.resolve(__dirname, '..', '..');
|
|
6
|
+
const server = fs.readFileSync(path.join(root, '_site', 'lib', 'server-app.js'), 'utf8');
|
|
7
|
+
const contracts = require('../lib/contracts');
|
|
8
|
+
const automation = require('../lib/post-commit-automation');
|
|
9
|
+
|
|
10
|
+
assert.deepEqual(contracts.TRIGGERS, ['git-hook']);
|
|
11
|
+
assert.equal(/reconcileProjectCommits\(projectId, 'startup'/.test(server), false);
|
|
12
|
+
assert.equal(server.includes('reconcile.startup_failed'), false);
|
|
13
|
+
assert.equal(Object.hasOwn(automation, 'dispatchPendingAutomations'), false);
|
|
14
|
+
assert.equal(typeof automation.recoverOrphanedClaims, 'function');
|
|
15
|
+
assert.equal(/recoverOrphanedClaims\(runtime\)/.test(server), true);
|
|
16
|
+
console.log('startup-analysis-disabled-test PASS');
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { StorageLayout } = require('../lib/storage-layout');
|
|
6
|
+
const AtomicFile = require('../lib/atomic-file');
|
|
7
|
+
const { SettingsStore } = require('../lib/settings-store');
|
|
8
|
+
|
|
9
|
+
(async () => {
|
|
10
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-storage-foundation-'));
|
|
11
|
+
const dataDir = path.join(root, 'data');
|
|
12
|
+
const knowledgeRoot = path.join(root, 'knowledge');
|
|
13
|
+
const layout = new StorageLayout({ dataDir, platform: 'win32' });
|
|
14
|
+
|
|
15
|
+
assert.strictEqual(layout.pathsEqual('C:\\Work\\Repo', 'c:\\work\\repo\\'), true);
|
|
16
|
+
const posixLayout = new StorageLayout({ dataDir, platform: 'linux' });
|
|
17
|
+
assert.strictEqual(posixLayout.pathsEqual('/tmp/Repo', '/tmp/repo'), false);
|
|
18
|
+
assert.strictEqual(layout.getIndexPath(), path.join(dataDir, 'index', 'knowledge.lancedb'));
|
|
19
|
+
assert(!layout.getIndexPath().startsWith(knowledgeRoot));
|
|
20
|
+
|
|
21
|
+
const rootResult = layout.validateKnowledgeRoot(knowledgeRoot);
|
|
22
|
+
assert.strictEqual(rootResult.ok, true);
|
|
23
|
+
assert.strictEqual(fs.readdirSync(knowledgeRoot).length, 0, 'write probe must not leave files');
|
|
24
|
+
const projectKnowledge = layout.resolveNewProjectKnowledgePath('repo-a1b2c3', { knowledge: { rootPath: knowledgeRoot } });
|
|
25
|
+
assert.strictEqual(projectKnowledge, path.join(knowledgeRoot, 'repo-a1b2c3'));
|
|
26
|
+
assert.strictEqual(layout.getProjectKnowledgePath({ knowledgePath: projectKnowledge }), projectKnowledge);
|
|
27
|
+
|
|
28
|
+
const atomicPath = path.join(dataDir, 'atomic.json');
|
|
29
|
+
AtomicFile.writeJsonAtomic(atomicPath, { revision: 1 });
|
|
30
|
+
assert.throws(() => AtomicFile.writeJsonAtomic(atomicPath, { revision: 2 }, {
|
|
31
|
+
beforeRename() { throw new Error('injected-before-rename'); },
|
|
32
|
+
}), /injected-before-rename/);
|
|
33
|
+
assert.deepStrictEqual(JSON.parse(fs.readFileSync(atomicPath, 'utf8')), { revision: 1 });
|
|
34
|
+
|
|
35
|
+
const jsonlPath = path.join(dataDir, 'append', 'requirements.jsonl');
|
|
36
|
+
await Promise.all(Array.from({ length: 20 }, (_, index) => AtomicFile.appendJsonlLocked(jsonlPath, { index })));
|
|
37
|
+
const lines = fs.readFileSync(jsonlPath, 'utf8').trim().split(/\r?\n/).map(JSON.parse);
|
|
38
|
+
assert.strictEqual(lines.length, 20);
|
|
39
|
+
assert.deepStrictEqual(lines.map(line => line.index).sort((a, b) => a - b), Array.from({ length: 20 }, (_, index) => index));
|
|
40
|
+
|
|
41
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
42
|
+
const staleTemp = path.join(dataDir, '.atomic.json.1.1.abcdef.tmp');
|
|
43
|
+
const unrelated = path.join(dataDir, 'user.tmp');
|
|
44
|
+
fs.writeFileSync(staleTemp, 'stale');
|
|
45
|
+
fs.writeFileSync(unrelated, 'keep');
|
|
46
|
+
const old = new Date(Date.now() - 48 * 60 * 60 * 1000);
|
|
47
|
+
fs.utimesSync(staleTemp, old, old);
|
|
48
|
+
const removed = AtomicFile.cleanupStaleTemps(dataDir);
|
|
49
|
+
assert.deepStrictEqual(removed, [staleTemp]);
|
|
50
|
+
assert(fs.existsSync(unrelated));
|
|
51
|
+
|
|
52
|
+
const store = new SettingsStore({ layout });
|
|
53
|
+
await store.initialize({
|
|
54
|
+
knowledge: { rootPath: knowledgeRoot },
|
|
55
|
+
ai: { schema: 'ai-profiles/v1', profiles: [{ id: 'primary', apiKey: 'secret-key-7890' }] },
|
|
56
|
+
});
|
|
57
|
+
const privateSettings = store.read();
|
|
58
|
+
assert.strictEqual(privateSettings.ai.profiles[0].apiKey, 'secret-key-7890');
|
|
59
|
+
const publicSettings = store.readPublicView();
|
|
60
|
+
assert.strictEqual(publicSettings.ai.profiles[0].hasApiKey, true);
|
|
61
|
+
assert.strictEqual(publicSettings.ai.profiles[0].apiKeyMasked, '****7890');
|
|
62
|
+
assert.strictEqual(Object.prototype.hasOwnProperty.call(publicSettings.ai.profiles[0], 'apiKey'), false);
|
|
63
|
+
|
|
64
|
+
await assert.rejects(
|
|
65
|
+
() => store.updatePatch({ logging: { retentionDays: 0, maxTotalSizeMB: 512 } }),
|
|
66
|
+
error => error.code === 'INVALID_ARGUMENT',
|
|
67
|
+
'runtime retention/capacity settings must not be writable',
|
|
68
|
+
);
|
|
69
|
+
const updated = store.readPublicView();
|
|
70
|
+
assert.deepStrictEqual(updated.logging.levels, ['trace', 'debug', 'info', 'warn', 'error', 'fatal']);
|
|
71
|
+
assert.strictEqual(fs.existsSync(layout.getRecoveryPath()), false, 'optional recovery directory must stay lazy');
|
|
72
|
+
assert.strictEqual(fs.existsSync(layout.getCachePath()), false, 'optional cache directory must stay lazy');
|
|
73
|
+
|
|
74
|
+
console.log('storage-foundation-test PASS');
|
|
75
|
+
})().catch(error => {
|
|
76
|
+
console.error(error.stack || error.message);
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { StorageLayout } = require('../lib/storage-layout');
|
|
6
|
+
const { Logger, LogRepository, localDay } = require('../lib/structured-logger');
|
|
7
|
+
|
|
8
|
+
(async () => {
|
|
9
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-logger-v3-'));
|
|
10
|
+
const layout = new StorageLayout({ dataDir });
|
|
11
|
+
const config = { levels: ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] };
|
|
12
|
+
let stderr = '';
|
|
13
|
+
const logger = new Logger({
|
|
14
|
+
layout,
|
|
15
|
+
settingsProvider: () => config,
|
|
16
|
+
secrets: ['known-secret-value'],
|
|
17
|
+
stderr: { write(value) { stderr += String(value); } },
|
|
18
|
+
});
|
|
19
|
+
const published = [];
|
|
20
|
+
logger.subscribe(record => published.push(record));
|
|
21
|
+
logger.subscribe(() => { throw new Error('subscriber failure must be isolated'); });
|
|
22
|
+
const child = logger.child({ component: 'commit-reconciler', projectId: 'project-a', operationId: 'op-1', runId: 'run-1', commitSha: 'abc' });
|
|
23
|
+
const cause = new Error('cause known-secret-value');
|
|
24
|
+
cause.code = 'ECAUSE';
|
|
25
|
+
const failure = new Error('Bearer top-secret-token');
|
|
26
|
+
failure.code = 'ETEST';
|
|
27
|
+
failure.cause = cause;
|
|
28
|
+
for (const level of ['trace', 'debug', 'info', 'warn', 'error', 'fatal']) {
|
|
29
|
+
await child[level](`test.${level}`, `${level} message`, {
|
|
30
|
+
phase: 'test',
|
|
31
|
+
error: level === 'error' ? failure : null,
|
|
32
|
+
context: { apiKey: 'known-secret-value', nested: { authorization: 'Bearer nested-token', safe: 'kept' } },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
await logger.close();
|
|
36
|
+
|
|
37
|
+
const projectDirectory = layout.getLogPath('project', 'project-a');
|
|
38
|
+
const files = fs.readdirSync(projectDirectory).filter(file => file.endsWith('.jsonl'));
|
|
39
|
+
assert.deepStrictEqual(files, [`${localDay()}.jsonl`], 'one project/day must use exactly one file');
|
|
40
|
+
assert(!files.some(file => /\.\d{3}\.jsonl$/.test(file)), 'v13 writer must never create size segments');
|
|
41
|
+
const allText = fs.readFileSync(path.join(projectDirectory, files[0]), 'utf8');
|
|
42
|
+
assert(!allText.includes('known-secret-value'));
|
|
43
|
+
assert(!allText.includes('top-secret-token'));
|
|
44
|
+
assert(!allText.includes('nested-token'));
|
|
45
|
+
assert(allText.includes('[REDACTED]'));
|
|
46
|
+
assert.strictEqual(published.length, 6, 'each durable append must publish exactly once');
|
|
47
|
+
assert.deepStrictEqual(published.map(record => record.level), ['trace', 'debug', 'info', 'warn', 'error', 'fatal']);
|
|
48
|
+
for (const line of allText.trim().split(/\r?\n/)) {
|
|
49
|
+
const record = JSON.parse(line);
|
|
50
|
+
assert.strictEqual(record.schema, 'log/v2');
|
|
51
|
+
assert.strictEqual(record.projectId, 'project-a');
|
|
52
|
+
assert.strictEqual(record.operationId, 'op-1');
|
|
53
|
+
assert(record.id && record.ts && record.event);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await logger.info('system.completed', 'System record', { component: 'server', operationId: 'op-system' });
|
|
57
|
+
await logger.close();
|
|
58
|
+
assert(fs.existsSync(path.join(layout.getLogPath('system'), `${localDay()}.jsonl`)), 'unscoped logs belong to system/day');
|
|
59
|
+
|
|
60
|
+
const repository = new LogRepository({ layout, secrets: ['known-secret-value'] });
|
|
61
|
+
const first = repository.query({ pageSize: 2, projectId: 'project-a' });
|
|
62
|
+
assert.strictEqual(first.entries.length, 2);
|
|
63
|
+
assert(first.nextCursor);
|
|
64
|
+
const second = repository.query({ pageSize: 2, projectId: 'project-a', cursor: first.nextCursor });
|
|
65
|
+
assert.strictEqual(second.entries.length, 2);
|
|
66
|
+
assert.notStrictEqual(first.entries[0].id, second.entries[0].id);
|
|
67
|
+
assert.throws(
|
|
68
|
+
() => repository.query({ pageSize: 3, projectId: 'project-a', cursor: first.nextCursor }),
|
|
69
|
+
error => error.code === 'LOG_CURSOR_EXPIRED',
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const exportPath = path.join(dataDir, 'exports', 'diagnostics.jsonl');
|
|
73
|
+
const exported = repository.exportToFile(exportPath, { projectId: 'project-a' });
|
|
74
|
+
assert.strictEqual(exported.count, 6);
|
|
75
|
+
assert(!fs.readFileSync(exportPath, 'utf8').includes('known-secret-value'));
|
|
76
|
+
assert.strictEqual(stderr, '');
|
|
77
|
+
assert.strictEqual(typeof repository.cleanup, 'undefined', 'production log cleanup API must not exist');
|
|
78
|
+
|
|
79
|
+
const blocker = path.join(dataDir, 'not-a-directory');
|
|
80
|
+
fs.writeFileSync(blocker, 'blocker');
|
|
81
|
+
const failingLayout = {
|
|
82
|
+
getDataDir() { return dataDir; },
|
|
83
|
+
getLogPath() { return path.join(blocker, 'logs'); },
|
|
84
|
+
};
|
|
85
|
+
const failing = new Logger({
|
|
86
|
+
layout: failingLayout,
|
|
87
|
+
settingsProvider: () => config,
|
|
88
|
+
secrets: ['known-secret-value'],
|
|
89
|
+
stderr: { write(value) { stderr += String(value); } },
|
|
90
|
+
});
|
|
91
|
+
const failingChild = failing.child({ component: 'failure-test' });
|
|
92
|
+
await failingChild.error('logger.failure.test', 'known-secret-value', { error: new Error('known-secret-value') });
|
|
93
|
+
assert.strictEqual(failing.getHealth().status, 'degraded', 'parent and child must share logger health');
|
|
94
|
+
assert.strictEqual(failingChild.getHealth().status, 'degraded');
|
|
95
|
+
assert(stderr.includes('logger fallback'));
|
|
96
|
+
assert(!stderr.includes('known-secret-value'));
|
|
97
|
+
console.log('structured-logger-test PASS');
|
|
98
|
+
})().catch(error => {
|
|
99
|
+
console.error(error.stack || error.message);
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
});
|