@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,110 @@
|
|
|
1
|
+
// Run: node _site/_test/commit-evidence-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, TrustedGitReader, EMPTY_TREE_SHA, sha256 } = require('../lib/scanner');
|
|
9
|
+
|
|
10
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), `kb-commit-evidence-${process.pid}-`));
|
|
11
|
+
const repo = path.join(temp, 'repo');
|
|
12
|
+
|
|
13
|
+
function git(args) {
|
|
14
|
+
const result = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
|
|
15
|
+
if (result.status !== 0) throw new Error(result.stderr || `git ${args.join(' ')} failed`);
|
|
16
|
+
return String(result.stdout || '').trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function gitRaw(args) {
|
|
20
|
+
const result = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
|
|
21
|
+
if (result.status !== 0) throw new Error(result.stderr || `git ${args.join(' ')} failed`);
|
|
22
|
+
return String(result.stdout || '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function commitFile(name, content, message) {
|
|
26
|
+
fs.mkdirSync(path.dirname(path.join(repo, name)), { recursive: true });
|
|
27
|
+
fs.writeFileSync(path.join(repo, name), content);
|
|
28
|
+
git(['add', name]);
|
|
29
|
+
git(['commit', '-m', message]);
|
|
30
|
+
return git(['rev-parse', 'HEAD']);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
(async () => {
|
|
34
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
35
|
+
git(['init', '--initial-branch=main']);
|
|
36
|
+
git(['config', 'user.email', 'evidence@example.test']);
|
|
37
|
+
git(['config', 'user.name', 'Evidence Test']);
|
|
38
|
+
const rootCommit = commitFile('src/root.txt', 'root\n', 'root commit');
|
|
39
|
+
const binaryCommit = commitFile('assets/sample.bin', Buffer.from([0, 1, 2, 3, 255]), 'add binary');
|
|
40
|
+
|
|
41
|
+
git(['checkout', '-b', 'feature']);
|
|
42
|
+
const featureCommit = commitFile('src/feature.txt', 'feature\n', 'feature side');
|
|
43
|
+
git(['checkout', 'main']);
|
|
44
|
+
const mainCommit = commitFile('src/main.txt', 'main\n', 'main side');
|
|
45
|
+
git(['merge', '--no-ff', 'feature', '-m', 'merge feature']);
|
|
46
|
+
const mergeCommit = git(['rev-parse', 'HEAD']);
|
|
47
|
+
|
|
48
|
+
const scanner = new CommitScanner({ batchSize: 2 });
|
|
49
|
+
const firstBatch = await scanner.scan({ repoPath: repo }, {
|
|
50
|
+
trackingMode: 'normal', trackingStartCommit: rootCommit, lastAnalyzedCommit: null,
|
|
51
|
+
});
|
|
52
|
+
assert.strictEqual(firstBatch.status, 'ok');
|
|
53
|
+
assert.strictEqual(firstBatch.commits.length, 2, 'batch limit should return a bounded first page');
|
|
54
|
+
assert(firstBatch.continuation && firstBatch.continuation.remaining === 2, 'continuation must expose remaining work');
|
|
55
|
+
const all = await new TrustedGitReader().listCommits(repo, rootCommit, mergeCommit);
|
|
56
|
+
assert.deepStrictEqual(all, [binaryCommit, mainCommit, featureCommit, mergeCommit], 'reverse topo order must include both branch commits and the merge commit');
|
|
57
|
+
|
|
58
|
+
const rootEvidence = await scanner.collectEvidence({ repoPath: repo }, rootCommit, { branch: 'main' });
|
|
59
|
+
assert.strictEqual(rootEvidence.patchMode, 'root-empty-tree');
|
|
60
|
+
assert.strictEqual(rootEvidence.patchBase, EMPTY_TREE_SHA);
|
|
61
|
+
assert(rootEvidence.patch.includes('src/root.txt'), 'root evidence must contain a real empty-tree patch');
|
|
62
|
+
|
|
63
|
+
const binaryEvidence = await scanner.collectEvidence({ repoPath: repo }, binaryCommit, { branch: 'main' });
|
|
64
|
+
assert(binaryEvidence.files.some(file => file.path === 'assets/sample.bin' && file.binary), 'name-status manifest should identify binary files');
|
|
65
|
+
assert(binaryEvidence.patch.includes('GIT binary patch'), 'binary patch evidence should be preserved');
|
|
66
|
+
|
|
67
|
+
const mergeEvidence = await scanner.collectEvidence({ repoPath: repo }, mergeCommit, { branch: 'main' });
|
|
68
|
+
assert.strictEqual(mergeEvidence.parents.length, 2, 'merge metadata should include every parent');
|
|
69
|
+
assert.strictEqual(mergeEvidence.patchMode, 'merge-first-parent');
|
|
70
|
+
assert.strictEqual(mergeEvidence.patchBase, mainCommit, 'merge patch must compare against the first parent');
|
|
71
|
+
assert(mergeEvidence.patch.includes('src/feature.txt'), 'first-parent merge patch should show the introduced feature side');
|
|
72
|
+
|
|
73
|
+
git(['mv', 'src/root.txt', 'src/renamed.txt']);
|
|
74
|
+
git(['commit', '-m', 'rename root evidence']);
|
|
75
|
+
const renameCommit = git(['rev-parse', 'HEAD']);
|
|
76
|
+
const renameEvidence = await scanner.collectEvidence({ repoPath: repo }, renameCommit, { branch: 'main' });
|
|
77
|
+
assert(renameEvidence.files.some(file => file.oldPath === 'src/root.txt' && file.path === 'src/renamed.txt'), 'rename evidence must retain old and new paths');
|
|
78
|
+
|
|
79
|
+
const largeContent = Array.from({ length: 42000 }, (_, index) => `${String(index).padStart(6, '0')} ${'exact-evidence-'.repeat(5)}\n`).join('');
|
|
80
|
+
const largeCommit = commitFile('src/large.txt', largeContent, 'large exact evidence');
|
|
81
|
+
const evidenceRoot = path.join(temp, 'run', 'input', 'evidence');
|
|
82
|
+
const largeReader = new TrustedGitReader({ maxPatchBytes: 2 * 1024 * 1024, maxChunkBytes: 128 * 1024 });
|
|
83
|
+
const large = await largeReader.collectEvidence(repo, largeCommit, { branch: 'main', evidenceRoot });
|
|
84
|
+
assert(large.patchBytes > 2 * 1024 * 1024, 'fixture must cross the production inline threshold');
|
|
85
|
+
assert.strictEqual(large.patch, null, 'large patch should be referenced by exact chunks instead of prompt inline text');
|
|
86
|
+
assert.strictEqual(large.patchChunked, true);
|
|
87
|
+
assert.strictEqual(large.patchOmitted, false, 'exact chunk evidence must not be represented as omitted');
|
|
88
|
+
assert(large.evidenceBundle.chunkCount > 1, 'large patch should be split into ordered chunks');
|
|
89
|
+
const manifest = largeReader.verifyEvidence(large);
|
|
90
|
+
assert.deepStrictEqual(manifest.chunks.map(chunk => chunk.sequence), manifest.chunks.map((_, index) => index + 1));
|
|
91
|
+
assert(manifest.chunks.every(chunk => chunk.sha256.startsWith('sha256:') && chunk.bytes > 0 && chunk.sourcePaths.includes('src/large.txt')));
|
|
92
|
+
const reconstructed = manifest.chunks.map(chunk => fs.readFileSync(path.join(evidenceRoot, ...chunk.path.split('/')))).join('');
|
|
93
|
+
const exactGitPatch = gitRaw(['diff', '--no-ext-diff', '--binary', '--find-renames', '--unified=3', renameCommit, largeCommit]);
|
|
94
|
+
assert.strictEqual(Buffer.byteLength(reconstructed), Buffer.byteLength(exactGitPatch));
|
|
95
|
+
assert.strictEqual(sha256(reconstructed), large.patchHash, 'ordered chunks must exactly reconstruct the full Git diff hash');
|
|
96
|
+
|
|
97
|
+
const corruptPath = path.join(evidenceRoot, ...manifest.chunks[0].path.split('/'));
|
|
98
|
+
fs.appendFileSync(corruptPath, 'corrupt');
|
|
99
|
+
assert.throws(() => largeReader.verifyEvidence(large), error => error.code === 'EVIDENCE_INTEGRITY_FAILED', 'corrupt chunks must fail with a typed evidence error');
|
|
100
|
+
|
|
101
|
+
const missingRootReader = new TrustedGitReader({ maxPatchBytes: 8 });
|
|
102
|
+
await assert.rejects(missingRootReader.collectEvidence(repo, mergeCommit, { branch: 'main' }), error => error.code === 'EVIDENCE_INTEGRITY_FAILED');
|
|
103
|
+
|
|
104
|
+
console.log('commit-evidence-test PASS');
|
|
105
|
+
})().catch(error => {
|
|
106
|
+
console.error(error.stack || error.message);
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
}).finally(() => {
|
|
109
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
110
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// TASK-006: Context pack builder test
|
|
2
|
+
// Run: node _site/_test/context-pack-test.js
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { spawn } = require('child_process');
|
|
7
|
+
const { spawnServer } = require('./helpers/spawn-server');
|
|
8
|
+
const { makeRepo, git } = require('./fixtures/make-git-repos');
|
|
9
|
+
|
|
10
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
11
|
+
const SERVER = path.join(ROOT, '_site', 'server.js');
|
|
12
|
+
// Pre-create a temp data dir and seed it BEFORE requiring any lib modules
|
|
13
|
+
// that capture getDataDir() at module load. Both the test process and
|
|
14
|
+
// the spawned server will use this dir.
|
|
15
|
+
const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), `kb-data-context-pack-${process.pid}-`));
|
|
16
|
+
process.env.KB_DATA_DIR = DATA_DIR;
|
|
17
|
+
require('../lib/data-dir')._resetCache();
|
|
18
|
+
try { fs.copyFileSync(path.join(ROOT, 'claude-prompts.json'), path.join(DATA_DIR, 'claude-prompts.json')); } catch {}
|
|
19
|
+
|
|
20
|
+
const { buildContextPack, isSafePath, PACKAGE_CONFIG_FILES } = require('../lib/context-pack-builder');
|
|
21
|
+
let PROJECTS_JSON = path.join(DATA_DIR, 'projects.json');
|
|
22
|
+
const PORT = process.env.KB_CTX_TEST_PORT || '7796';
|
|
23
|
+
const BASE_URL = `http://127.0.0.1:${PORT}`;
|
|
24
|
+
const TEMP_SLUG = 'task-006-temp';
|
|
25
|
+
|
|
26
|
+
function assert(cond, msg) { if (!cond) throw new Error(msg); }
|
|
27
|
+
|
|
28
|
+
async function waitForServer() {
|
|
29
|
+
const deadline = Date.now() + 15000;
|
|
30
|
+
let lastError;
|
|
31
|
+
while (Date.now() < deadline) {
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(`${BASE_URL}/api/state`);
|
|
34
|
+
if (res.ok) return;
|
|
35
|
+
lastError = new Error(`HTTP ${res.status}`);
|
|
36
|
+
} catch (e) { lastError = e; }
|
|
37
|
+
await new Promise(r => setTimeout(r, 250));
|
|
38
|
+
}
|
|
39
|
+
throw lastError || new Error('server did not start');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function json(method, url, body) {
|
|
43
|
+
const res = await fetch(`${BASE_URL}${url}`, {
|
|
44
|
+
method,
|
|
45
|
+
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
46
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
47
|
+
});
|
|
48
|
+
const text = await res.text();
|
|
49
|
+
let data = {};
|
|
50
|
+
if (text) { try { data = JSON.parse(text); } catch { data = { raw: text }; } }
|
|
51
|
+
return { res, data };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function cleanup() {
|
|
55
|
+
// The whole test data directory is isolated and removed after the server exits.
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
(async () => {
|
|
59
|
+
// 1. Path safety
|
|
60
|
+
assert(isSafePath('/tmp/proj', 'a/b.md'), 'safe path should pass');
|
|
61
|
+
assert(!isSafePath('/tmp/proj', '../escape.md'), 'parent traversal should fail');
|
|
62
|
+
assert(!isSafePath('/tmp/proj', '/etc/passwd'), 'absolute outside should fail');
|
|
63
|
+
// Sibling that is not a sub-path: resolve it relative to the project root and check.
|
|
64
|
+
const siblingResolved = require('path').resolve('/tmp/proj', '../proj-other/x.md');
|
|
65
|
+
assert(!siblingResolved.startsWith('/tmp/proj/') && siblingResolved !== '/tmp/proj',
|
|
66
|
+
'sibling-of-project should not pass when constructed as a traversal');
|
|
67
|
+
assert(Array.isArray(PACKAGE_CONFIG_FILES) && PACKAGE_CONFIG_FILES.includes('package.json'), 'package config list ok');
|
|
68
|
+
|
|
69
|
+
// 2. Unit test: build a context pack for a feature-commit fixture
|
|
70
|
+
const repo = makeRepo({ kind: 'feature-commit' });
|
|
71
|
+
const kbBase = path.join(DATA_DIR, 'projects', TEMP_SLUG);
|
|
72
|
+
fs.mkdirSync(kbBase, { recursive: true });
|
|
73
|
+
// Lay down final minimal KB structure with a module doc that references a changed file.
|
|
74
|
+
fs.mkdirSync(path.join(kbBase, 'modules'), { recursive: true });
|
|
75
|
+
fs.mkdirSync(path.join(kbBase, 'changes'), { recursive: true });
|
|
76
|
+
fs.writeFileSync(path.join(kbBase, 'GOAL.md'), '# Goal — test\n\nGoal text for tests.\n');
|
|
77
|
+
fs.writeFileSync(path.join(kbBase, 'ARCHITECTURE.md'), '# Analysis — test\n');
|
|
78
|
+
fs.writeFileSync(path.join(kbBase, 'modules', 'feature.md'), 'See `src/feature.ts` for the implementation.\n');
|
|
79
|
+
fs.writeFileSync(path.join(kbBase, 'README.md'), '# Project — test\n');
|
|
80
|
+
// package.json lives in the source repo (the fixture already has a src/, but no package.json)
|
|
81
|
+
fs.writeFileSync(path.join(repo.path, 'package.json'), '{"name":"fixture"}');
|
|
82
|
+
|
|
83
|
+
const commits = repo.commits.slice().reverse(); // chronological
|
|
84
|
+
const pack = await buildContextPack({
|
|
85
|
+
project: {
|
|
86
|
+
slug: TEMP_SLUG,
|
|
87
|
+
kbPath: kbBase,
|
|
88
|
+
gitPath: repo.path,
|
|
89
|
+
localPath: repo.path,
|
|
90
|
+
},
|
|
91
|
+
runId: 'run-unit-1',
|
|
92
|
+
trigger: 'commits',
|
|
93
|
+
commits,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
assert(pack.schema === 'context-pack/v1', 'pack schema should be v1');
|
|
97
|
+
assert(pack.runId === 'run-unit-1', 'runId should be honored');
|
|
98
|
+
assert(pack.trigger === 'commits', 'trigger should be commits');
|
|
99
|
+
assert(pack.commitCount === commits.length, 'commitCount should match');
|
|
100
|
+
assert(pack.range, 'range should be set');
|
|
101
|
+
|
|
102
|
+
// Required entries
|
|
103
|
+
const goalEntry = pack.entries.find(e => e.path === 'GOAL.md');
|
|
104
|
+
assert(goalEntry, 'pack should include GOAL.md');
|
|
105
|
+
assert(goalEntry.kind === 'goal', 'goal entry kind should be goal');
|
|
106
|
+
assert(goalEntry.reason, 'goal entry should have a reason');
|
|
107
|
+
const pkg = pack.entries.find(e => e.path === 'package.json');
|
|
108
|
+
assert(pkg && pkg.kind === 'package-config', 'pack should include package.json as package-config');
|
|
109
|
+
const changed = pack.entries.find(e => e.path === 'src/feature.ts');
|
|
110
|
+
assert(changed && changed.kind === 'git-changed', 'pack should include changed file src/feature.ts');
|
|
111
|
+
const related = pack.entries.find(e => e.path === 'modules/feature.md');
|
|
112
|
+
assert(related && related.kind === 'module-doc', 'pack should include related module doc');
|
|
113
|
+
// Every entry must have a reason
|
|
114
|
+
for (const e of pack.entries) {
|
|
115
|
+
assert(typeof e.reason === 'string' && e.reason.length > 0, `entry ${e.path} missing reason`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Disk artifacts
|
|
119
|
+
const onDisk = JSON.parse(fs.readFileSync(path.join(DATA_DIR, '_ai', TEMP_SLUG, 'context-packs', 'run-unit-1', 'context-pack.json'), 'utf-8'));
|
|
120
|
+
assert(onDisk.runId === 'run-unit-1', 'on-disk runId should match');
|
|
121
|
+
|
|
122
|
+
// 3. Large / binary file is summarized or skipped
|
|
123
|
+
const repo2 = makeRepo({ kind: 'binary-commit' });
|
|
124
|
+
const kbBase2 = path.join(DATA_DIR, 'projects', TEMP_SLUG + '-2');
|
|
125
|
+
fs.mkdirSync(kbBase2, { recursive: true });
|
|
126
|
+
fs.writeFileSync(path.join(kbBase2, 'GOAL.md'), '# Goal\n');
|
|
127
|
+
const commits2 = repo2.commits.slice().reverse();
|
|
128
|
+
const pack2 = await buildContextPack({
|
|
129
|
+
project: { slug: TEMP_SLUG + '-2', kbPath: kbBase2, gitPath: repo2.path, localPath: repo2.path },
|
|
130
|
+
runId: 'run-binary-1',
|
|
131
|
+
trigger: 'commits',
|
|
132
|
+
commits: commits2,
|
|
133
|
+
});
|
|
134
|
+
const big = pack2.entries.find(e => e.path === 'big.bin');
|
|
135
|
+
if (big) {
|
|
136
|
+
assert(big.binary || big.skipped === 'too-large' || big.truncated, 'big file should be marked binary/skipped/truncated');
|
|
137
|
+
}
|
|
138
|
+
fs.rmSync(kbBase2, { recursive: true, force: true });
|
|
139
|
+
repo2.cleanup();
|
|
140
|
+
|
|
141
|
+
// 4. Path-traversal attempt is rejected
|
|
142
|
+
const ok = isSafePath(kbBase, '..\\..\\escape.md');
|
|
143
|
+
// On Windows, ..\\ becomes normalized. The resolved path should still be inside kbBase.
|
|
144
|
+
// Test the explicit scenario:
|
|
145
|
+
assert(!isSafePath(kbBase, '..\\..\\..\\..\\Windows\\evil.txt') || path.resolve(kbBase, '..\\..\\..\\..\\Windows\\evil.txt').startsWith(path.resolve(kbBase)),
|
|
146
|
+
'path traversal must be rejected');
|
|
147
|
+
|
|
148
|
+
repo.cleanup();
|
|
149
|
+
|
|
150
|
+
// 5. Server tests
|
|
151
|
+
const _spawned = spawnServer({ root: ROOT, port: Number(PORT), dataDir: DATA_DIR, tag: 'context-pack', });
|
|
152
|
+
const child = _spawned.child;
|
|
153
|
+
let serverOutput = '';
|
|
154
|
+
child.stdout.on('data', d => { serverOutput += d.toString(); });
|
|
155
|
+
child.stderr.on('data', d => { serverOutput += d.toString(); });
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
await cleanup();
|
|
159
|
+
await waitForServer();
|
|
160
|
+
let r = await json('PATCH', '/api/settings', { knowledge: { rootPath: path.join(DATA_DIR, 'knowledge') } });
|
|
161
|
+
assert(r.res.ok, `knowledge root setup should succeed: ${JSON.stringify(r.data)}`);
|
|
162
|
+
|
|
163
|
+
// 6. POST /api/projects/:slug/context-pack for an initial pack
|
|
164
|
+
const initRepo = makeRepo({ kind: 'one-commit' });
|
|
165
|
+
const slug = TEMP_SLUG;
|
|
166
|
+
r = await json('POST', '/api/projects/import', {
|
|
167
|
+
projectId: slug,
|
|
168
|
+
displayName: 'TASK-006',
|
|
169
|
+
localPath: initRepo.path,
|
|
170
|
+
});
|
|
171
|
+
assert(r.res.ok, `v2 import should succeed: ${JSON.stringify(r.data)}`);
|
|
172
|
+
const kbPath = r.data.config.knowledgePath;
|
|
173
|
+
fs.writeFileSync(path.join(kbPath, 'GOAL.md'), '# Goal — server test\n');
|
|
174
|
+
|
|
175
|
+
r = await json('POST', `/api/projects/${slug}/context-pack`, { trigger: 'initial' });
|
|
176
|
+
assert(r.res.ok, 'context-pack initial should succeed');
|
|
177
|
+
assert(r.data.contextPack.entries.length > 0, 'context pack should have entries');
|
|
178
|
+
assert(r.data.contextPack.entries.find(e => e.path === 'GOAL.md'), 'goal should be in pack');
|
|
179
|
+
|
|
180
|
+
r = await json('POST', `/api/projects/${slug}/context-pack`, { trigger: 'commits' });
|
|
181
|
+
assert(r.res.ok, 'context-pack commits should succeed');
|
|
182
|
+
assert(r.data.contextPack.commitCount === 0, 'commits trigger should not treat pre-import history as pending');
|
|
183
|
+
|
|
184
|
+
// 7. Bad slug
|
|
185
|
+
r = await json('POST', '/api/projects/%5Ebad/context-pack', {});
|
|
186
|
+
assert(!r.res.ok && r.res.status === 400, 'bad project id should 400');
|
|
187
|
+
|
|
188
|
+
initRepo.cleanup();
|
|
189
|
+
console.log('TASK-006 context pack test passed');
|
|
190
|
+
} catch (e) {
|
|
191
|
+
console.error('TASK-006 context pack test failed:', e.message);
|
|
192
|
+
if (serverOutput) console.error(serverOutput);
|
|
193
|
+
process.exitCode = 1;
|
|
194
|
+
} finally {
|
|
195
|
+
await cleanup().catch(() => {});
|
|
196
|
+
child.kill();
|
|
197
|
+
}
|
|
198
|
+
})();
|
|
@@ -0,0 +1,119 @@
|
|
|
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 { StorageLayout } = require('../lib/storage-layout');
|
|
7
|
+
const { SettingsStore } = require('../lib/settings-store');
|
|
8
|
+
const { ProjectRegistryStore } = require('../lib/project-registry-store');
|
|
9
|
+
const { ProjectStore } = require('../lib/project-store');
|
|
10
|
+
const { ConversationStore } = require('../lib/conversation-store');
|
|
11
|
+
const { localDate } = require('../lib/conversation-query-service');
|
|
12
|
+
|
|
13
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
14
|
+
const PORT = 8250 + (process.pid % 300);
|
|
15
|
+
const BASE = `http://127.0.0.1:${PORT}`;
|
|
16
|
+
|
|
17
|
+
async function waitForServer() {
|
|
18
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
19
|
+
try { if ((await fetch(`${BASE}/api/health`)).ok) return; } catch {}
|
|
20
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
21
|
+
}
|
|
22
|
+
throw new Error('server did not start');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
(async () => {
|
|
26
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-conversation-api-'));
|
|
27
|
+
const layout = new StorageLayout({ dataDir });
|
|
28
|
+
const settings = new SettingsStore({ layout });
|
|
29
|
+
const registry = new ProjectRegistryStore({ layout });
|
|
30
|
+
const projects = new ProjectStore({ layout });
|
|
31
|
+
await settings.initialize({ knowledge: { rootPath: path.join(dataDir, 'knowledge') } });
|
|
32
|
+
await registry.initialize();
|
|
33
|
+
const projectId = 'project-conversation-api';
|
|
34
|
+
const repoPath = path.join(dataDir, 'repo');
|
|
35
|
+
const knowledgePath = path.join(dataDir, 'knowledge', projectId);
|
|
36
|
+
fs.mkdirSync(repoPath, { recursive: true });
|
|
37
|
+
fs.mkdirSync(knowledgePath, { recursive: true });
|
|
38
|
+
await projects.create(projectId, {
|
|
39
|
+
displayName: 'Conversation API', storageName: 'conversation-api', repoPath, knowledgePath,
|
|
40
|
+
}, { conversationBaselineCursor: 0, conversation: { lastConsumedCursor: 0, captureStatus: 'captured' } });
|
|
41
|
+
await registry.add(projectId, { displayNameSnapshot: 'Conversation API' });
|
|
42
|
+
const store = new ConversationStore({ layout, projectStore: projects });
|
|
43
|
+
const repoIdentity = { repoId: 'conversation-api-repo' };
|
|
44
|
+
const unsafePrompt = '<script>alert("conversation-body")</script> Keep this as plain text.';
|
|
45
|
+
async function append(eventId, sequence, turnId, role, content, capturedAt) {
|
|
46
|
+
return (await store.appendEvent(projectId, {
|
|
47
|
+
eventId, sequence, source: 'codex', eventType: role === 'user' ? 'user_prompt' : 'assistant_response', role, content,
|
|
48
|
+
sessionId: 'session-api', turnId, repoIdentity, projectPath: repoPath, branch: 'main', headAtCapture: '0'.repeat(40),
|
|
49
|
+
capturedAt, identityConfidence: 'high', captureStatus: 'captured',
|
|
50
|
+
})).event;
|
|
51
|
+
}
|
|
52
|
+
const r1 = await append('r1', 1, 'turn-1', 'user', unsafePrompt, '2026-08-18T01:00:00.000Z');
|
|
53
|
+
const a1 = await append('a1', 2, 'turn-1', 'assistant', 'Rendered only as plain assistant text.', '2026-08-18T01:01:00.000Z');
|
|
54
|
+
const r2 = await append('r2', 3, 'turn-2', 'user', 'Second prompt.', '2026-08-18T02:00:00.000Z');
|
|
55
|
+
const a2 = await append('a2', 4, 'turn-2', 'assistant', 'Second reply.', '2026-08-18T02:01:00.000Z');
|
|
56
|
+
await append('r3', 5, 'turn-3', 'user', 'Third prompt.', '2026-08-18T03:00:00.000Z');
|
|
57
|
+
await append('r4', 6, 'turn-other-date', 'user', 'Other date prompt.', '2026-08-19T03:00:00.000Z');
|
|
58
|
+
const projectEvent = event => ({ eventId: event.eventId, sequence: event.sequence, content: event.content, contentHash: event.contentHash, capturedAt: event.capturedAt });
|
|
59
|
+
store.writeSnapshot(projectId, {
|
|
60
|
+
commitSha: '1'.repeat(40), repoIdentity, parentSha: null, boundaryStartCursor: 0, boundaryEndCursor: 2, status: 'available',
|
|
61
|
+
turns: [{ turnId: 'turn-1', source: 'codex', sessionId: 'session-api', bindingKind: 'direct', userEvents: [projectEvent(r1)], assistantEvents: [projectEvent(a1)] }],
|
|
62
|
+
});
|
|
63
|
+
store.writeSnapshot(projectId, {
|
|
64
|
+
commitSha: '3'.repeat(40), repoIdentity, parentSha: '2'.repeat(40), boundaryStartCursor: 5, boundaryEndCursor: 5, status: 'available',
|
|
65
|
+
turns: [{ turnId: 'turn-2', source: 'codex', sessionId: 'session-api', bindingKind: 'shared-spanning', userEvents: [projectEvent(r2)], assistantEvents: [projectEvent(a2)] }],
|
|
66
|
+
});
|
|
67
|
+
store.writeSnapshot(projectId, {
|
|
68
|
+
commitSha: '2'.repeat(40), repoIdentity, parentSha: '1'.repeat(40), boundaryStartCursor: 2, boundaryEndCursor: 4, status: 'available',
|
|
69
|
+
turns: [{ turnId: 'turn-2', source: 'codex', sessionId: 'session-api', bindingKind: 'shared-spanning', userEvents: [projectEvent(r2)], assistantEvents: [projectEvent(a2)] }],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const spawned = spawnServer({ root: ROOT, port: PORT, dataDir, tag: 'conversation-api', extraEnv: { KB_SKIP_MIGRATION: '1' } });
|
|
73
|
+
try {
|
|
74
|
+
await waitForServer();
|
|
75
|
+
let response = await fetch(`${BASE}/api/conversations/projects`);
|
|
76
|
+
let body = await response.json();
|
|
77
|
+
assert.strictEqual(response.status, 200);
|
|
78
|
+
assert.deepStrictEqual(body.projects.map(project => project.projectId), [projectId]);
|
|
79
|
+
assert.strictEqual(Object.hasOwn(body.projects[0], 'repoPath'), false, 'project picker payload must not expose raw repository paths');
|
|
80
|
+
|
|
81
|
+
const date = localDate(r1.capturedAt);
|
|
82
|
+
response = await fetch(`${BASE}/api/conversations/turns?projectId=${projectId}&date=${date}&limit=2`);
|
|
83
|
+
body = await response.json();
|
|
84
|
+
assert.strictEqual(response.status, 200, JSON.stringify(body));
|
|
85
|
+
assert.strictEqual(body.turns.length, 2);
|
|
86
|
+
assert(body.nextCursor && body.hasMore);
|
|
87
|
+
const firstPage = body.turns;
|
|
88
|
+
response = await fetch(`${BASE}/api/conversations/turns?projectId=${projectId}&date=${date}&limit=2&cursor=${encodeURIComponent(body.nextCursor)}`);
|
|
89
|
+
const secondPage = await response.json();
|
|
90
|
+
assert.strictEqual(secondPage.turns.length, 1);
|
|
91
|
+
const allTurns = [...firstPage, ...secondPage.turns];
|
|
92
|
+
const turn1 = allTurns.find(turn => turn.turnId === 'turn-1');
|
|
93
|
+
const turn2 = allTurns.find(turn => turn.turnId === 'turn-2');
|
|
94
|
+
const turn3 = allTurns.find(turn => turn.turnId === 'turn-3');
|
|
95
|
+
assert.strictEqual(turn1.userPrompt, unsafePrompt, 'conversation body should remain exact business data');
|
|
96
|
+
assert.strictEqual(turn1.renderAs, 'plain-text', 'payload must instruct hosts to avoid HTML interpretation');
|
|
97
|
+
assert.strictEqual(turn1.annotation.status, 'committed');
|
|
98
|
+
assert.strictEqual(turn1.annotation.commits[0].shortSha, '1111111');
|
|
99
|
+
assert.strictEqual(turn2.annotation.status, 'associated');
|
|
100
|
+
assert.deepStrictEqual(turn2.annotation.commits.map(commit => commit.shortSha), ['2222222'], 'stale shared-spanning annotations after the next user turn are hidden');
|
|
101
|
+
assert.strictEqual(turn3.annotation.status, 'uncommitted');
|
|
102
|
+
assert(allTurns.every(turn => turn.date === date));
|
|
103
|
+
|
|
104
|
+
response = await fetch(`${BASE}/api/conversations/turns?projectId=${projectId}&date=2026-08-19&cursor=${encodeURIComponent(body.nextCursor)}`);
|
|
105
|
+
assert.strictEqual(response.status, 409, 'cursor must be bound to one project/date query');
|
|
106
|
+
response = await fetch(`${BASE}/api/conversations/sessions?projectId=${projectId}`);
|
|
107
|
+
assert.strictEqual(response.status, 404, 'default product API must not expose source/session diagnostic routes');
|
|
108
|
+
|
|
109
|
+
response = await fetch(`${BASE}/api/logs/export`);
|
|
110
|
+
const exportedLogs = await response.text();
|
|
111
|
+
assert(!exportedLogs.includes('conversation-body'), 'conversation content must never leak into structured logs or export');
|
|
112
|
+
console.log('conversation-api-test PASS');
|
|
113
|
+
} finally {
|
|
114
|
+
await spawned.cleanup();
|
|
115
|
+
}
|
|
116
|
+
})().catch(error => {
|
|
117
|
+
console.error(error.stack || error.message);
|
|
118
|
+
process.exitCode = 1;
|
|
119
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { SCHEMAS } = require('../lib/contracts');
|
|
6
|
+
const { StorageLayout } = require('../lib/storage-layout');
|
|
7
|
+
const { ConversationStore, sha256 } = require('../lib/conversation-store');
|
|
8
|
+
|
|
9
|
+
(async () => {
|
|
10
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-conversation-store-'));
|
|
11
|
+
const dataDir = path.join(root, 'data');
|
|
12
|
+
const knowledgeRoot = path.join(root, 'knowledge');
|
|
13
|
+
fs.mkdirSync(knowledgeRoot, { recursive: true });
|
|
14
|
+
const layout = new StorageLayout({ dataDir });
|
|
15
|
+
const logRecords = [];
|
|
16
|
+
const store = new ConversationStore({
|
|
17
|
+
layout,
|
|
18
|
+
logger: { async info(event, message, input) { logRecords.push({ event, message, input }); } },
|
|
19
|
+
});
|
|
20
|
+
const projectId = 'project-conversation';
|
|
21
|
+
const eventsPath = layout.getProjectConversationEventsPath(projectId);
|
|
22
|
+
assert.strictEqual(fs.existsSync(eventsPath), false, 'conversation business data must be lazy-created');
|
|
23
|
+
|
|
24
|
+
const privatePrompt = 'Implement secret conversation behavior without logging this body.';
|
|
25
|
+
const user = {
|
|
26
|
+
eventId: 'event-user-1',
|
|
27
|
+
sequence: 10,
|
|
28
|
+
source: 'codex',
|
|
29
|
+
eventType: 'user_prompt',
|
|
30
|
+
role: 'user',
|
|
31
|
+
content: privatePrompt,
|
|
32
|
+
sessionId: 'session-1',
|
|
33
|
+
turnId: 'turn-1',
|
|
34
|
+
projectPath: path.join(root, 'repo'),
|
|
35
|
+
repoIdentity: { commonDir: path.join(root, 'repo', '.git') },
|
|
36
|
+
branch: 'main',
|
|
37
|
+
headAtCapture: 'a'.repeat(40),
|
|
38
|
+
capturedAt: '2026-08-18T01:00:00.000Z',
|
|
39
|
+
rawEventType: 'user-prompt-submit',
|
|
40
|
+
identityConfidence: 'high',
|
|
41
|
+
captureStatus: 'captured',
|
|
42
|
+
};
|
|
43
|
+
const concurrent = await Promise.all(Array.from({ length: 8 }, () => store.appendEvent(projectId, user)));
|
|
44
|
+
assert.strictEqual(concurrent.filter(result => result.appended).length, 1, 'eventId dedupe must be atomic under overlap');
|
|
45
|
+
assert.strictEqual(store.readEvents(projectId).length, 1);
|
|
46
|
+
assert.strictEqual(store.readEvents(projectId)[0].content, privatePrompt, 'full prompt belongs in the private business store');
|
|
47
|
+
assert.strictEqual(store.readEvents(projectId)[0].contentHash, sha256(privatePrompt));
|
|
48
|
+
assert(!JSON.stringify(logRecords).includes(privatePrompt), 'conversation body must never enter structured logs');
|
|
49
|
+
assert(JSON.stringify(logRecords).includes(sha256(privatePrompt)), 'logs may retain body hash for correlation');
|
|
50
|
+
|
|
51
|
+
await assert.rejects(
|
|
52
|
+
() => store.appendEvent(projectId, { ...user, content: 'different body' }),
|
|
53
|
+
error => error.code === 'DATA_CORRUPT',
|
|
54
|
+
'same eventId with different content must never be silently deduplicated',
|
|
55
|
+
);
|
|
56
|
+
await store.appendEvent(projectId, {
|
|
57
|
+
...user,
|
|
58
|
+
eventId: 'event-assistant-1',
|
|
59
|
+
sequence: 11,
|
|
60
|
+
eventType: 'assistant_response',
|
|
61
|
+
role: 'assistant',
|
|
62
|
+
content: 'Implemented and verified.',
|
|
63
|
+
capturedAt: '2026-08-18T01:01:00.000Z',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const legacy = {
|
|
67
|
+
schema: SCHEMAS.requirement,
|
|
68
|
+
id: 'req-legacy-1',
|
|
69
|
+
projectId,
|
|
70
|
+
ts: '2026-08-18T00:30:00.000Z',
|
|
71
|
+
client: 'claude',
|
|
72
|
+
sessionId: 'legacy-session',
|
|
73
|
+
branch: 'main',
|
|
74
|
+
headAtRecord: 'b'.repeat(40),
|
|
75
|
+
requirement: 'Legacy explicit requirement.',
|
|
76
|
+
requirementHash: sha256('Legacy explicit requirement.'),
|
|
77
|
+
commitSha: 'c'.repeat(40),
|
|
78
|
+
};
|
|
79
|
+
const imported = await store.importLegacyRequirements(projectId, [legacy, legacy]);
|
|
80
|
+
assert.strictEqual(imported.filter(result => result.appended).length, 1, 'legacy adapter must share eventId dedupe with Bridge events');
|
|
81
|
+
const legacyEvent = store.readEvents(projectId).find(event => event.legacyRequirementId === legacy.id);
|
|
82
|
+
assert.strictEqual(legacyEvent.source, 'claude-code');
|
|
83
|
+
assert.strictEqual(legacyEvent.captureStatus, 'legacy-explicit-adapter');
|
|
84
|
+
assert.strictEqual(legacyEvent.sequence, null, 'legacy evidence must not invent a Bridge cursor');
|
|
85
|
+
|
|
86
|
+
const commitSha = 'd'.repeat(40);
|
|
87
|
+
const boundary = store.writeBoundary(projectId, {
|
|
88
|
+
commitSha,
|
|
89
|
+
repoIdentity: user.repoIdentity,
|
|
90
|
+
parentShas: ['c'.repeat(40)],
|
|
91
|
+
branch: 'main',
|
|
92
|
+
committedAt: '2026-08-18T01:00:30.000Z',
|
|
93
|
+
bridgeCursorAtCommit: 10,
|
|
94
|
+
openTurnIdsAtCommit: ['turn-1'],
|
|
95
|
+
operationId: 'op-boundary-1',
|
|
96
|
+
});
|
|
97
|
+
assert.strictEqual(boundary.schema, SCHEMAS.gitCommitBoundary);
|
|
98
|
+
assert(fs.existsSync(layout.getProjectCommitBoundaryPath(projectId, commitSha)));
|
|
99
|
+
|
|
100
|
+
const snapshot = store.writeSnapshot(projectId, {
|
|
101
|
+
commitSha,
|
|
102
|
+
repoIdentity: user.repoIdentity,
|
|
103
|
+
parentSha: 'c'.repeat(40),
|
|
104
|
+
boundaryStartCursor: 0,
|
|
105
|
+
boundaryEndCursor: 10,
|
|
106
|
+
status: 'available',
|
|
107
|
+
turns: [{
|
|
108
|
+
turnId: 'turn-1',
|
|
109
|
+
source: 'codex',
|
|
110
|
+
sessionId: 'session-1',
|
|
111
|
+
bindingKind: 'direct',
|
|
112
|
+
userEvents: [{ eventId: 'event-user-1', sequence: 10, content: privatePrompt, contentHash: sha256(privatePrompt), capturedAt: user.capturedAt }],
|
|
113
|
+
assistantEvents: [],
|
|
114
|
+
}],
|
|
115
|
+
excludedFuturePromptCount: 0,
|
|
116
|
+
finalizedAt: '2026-08-18T01:02:00.000Z',
|
|
117
|
+
});
|
|
118
|
+
assert(snapshot.snapshotHash.startsWith('sha256:'));
|
|
119
|
+
assert.strictEqual(store.readSnapshot(projectId, commitSha).turns[0].userEvents[0].content, privatePrompt);
|
|
120
|
+
await assert.rejects(
|
|
121
|
+
async () => store.writeSnapshot(projectId, { ...snapshot, status: 'ambiguous', finalizedAt: snapshot.finalizedAt }),
|
|
122
|
+
error => error.code === 'IMMUTABLE_FIELD',
|
|
123
|
+
);
|
|
124
|
+
assert(!layout.getProjectCommitConversationPath(projectId, commitSha).startsWith(knowledgeRoot), 'conversation evidence must remain outside the user Markdown root');
|
|
125
|
+
|
|
126
|
+
const failingProject = 'project-append-failure';
|
|
127
|
+
const blocker = layout.getProjectConversationEventsPath(failingProject);
|
|
128
|
+
fs.mkdirSync(blocker, { recursive: true });
|
|
129
|
+
await assert.rejects(() => store.appendEvent(failingProject, { ...user, eventId: 'event-fail-1' }));
|
|
130
|
+
assert.deepStrictEqual(fs.readdirSync(blocker), [], 'failed append must not create partial event data');
|
|
131
|
+
|
|
132
|
+
console.log('conversation-store-test PASS');
|
|
133
|
+
})().catch(error => {
|
|
134
|
+
console.error(error.stack || error.message);
|
|
135
|
+
process.exitCode = 1;
|
|
136
|
+
});
|