@sanqianx/project-knowledge 4.6.1 → 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +17 -17
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +817 -780
- package/README.md +48 -36
- package/_modules/knowledge-engine/README.md +88 -0
- package/_modules/knowledge-engine/package.json +38 -0
- package/_modules/knowledge-engine/src/analysis-contract.js +25 -0
- package/_modules/knowledge-engine/src/analysis-worker.js +54 -0
- package/_modules/knowledge-engine/src/bin.js +52 -0
- package/_modules/knowledge-engine/src/bridge-client.js +24 -0
- package/_modules/knowledge-engine/src/coordinator.js +255 -0
- package/_modules/knowledge-engine/src/headless-analyzer.js +75 -0
- package/_modules/knowledge-engine/src/memories.js +85 -0
- package/_modules/knowledge-engine/src/migration.js +88 -0
- package/_modules/knowledge-engine/src/retrieval.js +116 -0
- package/_modules/knowledge-engine/src/runtime/README.md +7 -0
- package/_modules/knowledge-engine/src/runtime/atomic-file.js +204 -0
- package/_modules/knowledge-engine/src/runtime/automation-config.js +140 -0
- package/_modules/knowledge-engine/src/runtime/commit-conversation-binder.js +249 -0
- package/_modules/knowledge-engine/src/runtime/commit-processing-ledger.js +26 -0
- package/_modules/knowledge-engine/src/runtime/commit-prompt.js +202 -0
- package/_modules/knowledge-engine/src/runtime/commit-reconciler.js +432 -0
- package/_modules/knowledge-engine/src/runtime/content-hash.js +4 -0
- package/_modules/knowledge-engine/src/runtime/contracts.js +279 -0
- package/_modules/knowledge-engine/src/runtime/conversation-exclusions.js +204 -0
- package/_modules/knowledge-engine/src/runtime/conversation-store.js +387 -0
- package/_modules/knowledge-engine/src/runtime/evidence-bundle.js +228 -0
- package/_modules/knowledge-engine/src/runtime/git-runner.js +62 -0
- package/_modules/knowledge-engine/src/runtime/knowledge-promotion.js +444 -0
- package/_modules/knowledge-engine/src/runtime/knowledge-retrieval-service.js +402 -0
- package/_modules/knowledge-engine/src/runtime/layout.js +44 -0
- package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +230 -0
- package/_modules/knowledge-engine/src/runtime/scanner.js +298 -0
- package/_modules/knowledge-engine/src/server.js +155 -0
- package/_modules/knowledge-engine/src/store.js +239 -0
- package/_modules/knowledge-engine/ui/app.css +172 -0
- package/_modules/knowledge-engine/ui/app.js +297 -0
- package/_modules/knowledge-engine/ui/index.html +95 -0
- package/_site/README.md +30 -30
- package/_site/_test/ai-profile-resolver-test.js +137 -137
- package/_site/_test/automation-queue-test.js +14 -14
- package/_site/_test/automation-ui-test.js +40 -40
- package/_site/_test/background-task-registry-test.js +43 -43
- package/_site/_test/baseline-schema-test.js +91 -91
- package/_site/_test/bridge-adapter-test.js +1 -1
- package/_site/_test/bridge-consumer-service-test.js +1 -1
- package/_site/_test/chat-claudecodeui-match-test.js +92 -92
- package/_site/_test/claude-executable-discovery-test.js +62 -62
- package/_site/_test/claude-workbench-test.js +170 -170
- package/_site/_test/client-capture-restart-e2e-test.js +4 -4
- package/_site/_test/codex-conversation-projection-test.js +2 -1
- package/_site/_test/commit-boundary-freeze-test.js +1 -1
- package/_site/_test/commit-conversation-binding-test.js +95 -95
- package/_site/_test/commit-evidence-test.js +110 -110
- package/_site/_test/conversation-api-test.js +109 -109
- package/_site/_test/conversation-store-test.js +136 -136
- package/_site/_test/cross-repo-isolation-e2e-test.js +2 -2
- package/_site/_test/data-dir-migration-test.js +57 -57
- package/_site/_test/desktop-browser-compat-test.js +28 -28
- package/_site/_test/desktop-hook-runtime-regression-test.js +204 -204
- package/_site/_test/explicit-commit-processor-test.js +37 -37
- package/_site/_test/fixtures/make-git-repos.js +12 -1
- package/_site/_test/folder-picker-output-test.js +19 -19
- package/_site/_test/full-integration-e2e-test.js +165 -165
- package/_site/_test/git-validation-test.js +2 -2
- package/_site/_test/hook-runtime-endpoint-test.js +88 -88
- package/_site/_test/hook-status-repair-api-test.js +152 -152
- package/_site/_test/hook-trigger-test.js +85 -85
- package/_site/_test/import-preflight-api-test.js +234 -233
- package/_site/_test/index-writer-concurrency-test.js +135 -135
- package/_site/_test/integration-adapters-test.js +171 -171
- package/_site/_test/integration-capture-setup-test.js +1 -1
- package/_site/_test/integration-surface-coverage-test.js +164 -164
- package/_site/_test/knowledge-language-control-test.js +170 -170
- package/_site/_test/knowledge-promotion-recovery-test.js +260 -260
- package/_site/_test/knowledge-query-test.js +54 -54
- package/_site/_test/knowledge-retrieval-service-test.js +77 -77
- package/_site/_test/knowledge-storage-startup-test.js +52 -52
- package/_site/_test/knowledge-store-logs-supervision-test.js +89 -89
- package/_site/_test/legacy-forward-compat-test.js +257 -257
- package/_site/_test/legacy-project-upgrade-e2e-test.js +363 -363
- package/_site/_test/logging-api-test.js +89 -89
- package/_site/_test/logging-sse-no-gap-test.js +88 -88
- package/_site/_test/logging-ui-test.js +106 -104
- package/_site/_test/markdown-delta-overlay-test.js +85 -85
- package/_site/_test/markdown-maintenance-api-test.js +75 -75
- package/_site/_test/mcp-server-test.js +149 -149
- package/_site/_test/module-artifact-integrity-test.js +27 -0
- package/_site/_test/module-boundary-test.js +30 -0
- package/_site/_test/module-bridge-eventbridge-test.js +7 -0
- package/_site/_test/module-model-configuration-test.js +43 -0
- package/_site/_test/module-process-lifecycle-test.js +24 -0
- package/_site/_test/module-stream-proxy-test.js +34 -0
- package/_site/_test/non-release-ci-test.js +34 -34
- package/_site/_test/offline-boundary-isolation-test.js +32 -32
- package/_site/_test/p0-data-migration-characterization-test.js +37 -37
- package/_site/_test/p0-e2e-gate-test.js +347 -347
- package/_site/_test/packaged-ui-smoke-test.js +19 -10
- package/_site/_test/path-consistency-test.js +151 -151
- package/_site/_test/pending-sweep-test.js +9 -9
- package/_site/_test/post-commit-automation-test.js +87 -87
- package/_site/_test/product-diagnostics-ui-test.js +75 -0
- package/_site/_test/product-import-ui-test.js +92 -0
- package/_site/_test/project-delete-recovery-test.js +64 -64
- package/_site/_test/project-goal-editor-test.js +144 -144
- package/_site/_test/project-layout-v2-migration-test.js +127 -127
- package/_site/_test/project-lifecycle-transaction-test.js +106 -106
- package/_site/_test/prompt-settings-test.js +115 -115
- package/_site/_test/protected-architecture-gate-test.js +122 -122
- package/_site/_test/refactor-characterization-test.js +36 -36
- package/_site/_test/release-version-sync-test.js +1 -1
- package/_site/_test/requirement-recorder-test.js +173 -173
- package/_site/_test/run-all-tests.js +159 -156
- package/_site/_test/server-runtime-migration-safety-test.js +27 -27
- package/_site/_test/shared-contracts-test.js +58 -58
- package/_site/_test/startup-analysis-disabled-test.js +10 -10
- package/_site/_test/storage-foundation-test.js +78 -78
- package/_site/_test/structured-logger-test.js +101 -101
- package/_site/_test/tracking-start-test.js +108 -108
- package/_site/_test/v4122-upgrade-data-contract-test.js +56 -56
- package/_site/_test/workbench-permission-test.js +117 -117
- package/_site/_test/workspace-ui-contract-test.js +28 -43
- package/_site/lib/ai-profile-resolver.js +78 -78
- package/_site/lib/ai-workspace.js +101 -101
- package/_site/lib/automation-config.js +140 -140
- package/_site/lib/bridge-adapter.js +5 -1
- package/_site/lib/bridge-consumer-service.js +402 -402
- package/_site/lib/claude-cli-runner.js +1510 -1510
- package/_site/lib/commit-conversation-binder.js +234 -234
- package/_site/lib/commit-processing-ledger.js +27 -27
- package/_site/lib/commit-prompt.js +202 -202
- package/_site/lib/commit-reconciler.js +406 -406
- package/_site/lib/contracts.js +284 -279
- package/_site/lib/conversation-query-service.js +144 -144
- package/_site/lib/conversation-store.js +385 -385
- package/_site/lib/data-dir.js +74 -74
- package/_site/lib/data-state-classifier.js +40 -40
- package/_site/lib/engine-cutover.js +97 -0
- package/_site/lib/evidence-bundle.js +228 -228
- package/_site/lib/folder-picker-output.js +21 -21
- package/_site/lib/git-runner.js +62 -62
- package/_site/lib/github-team-store.js +1172 -1172
- package/_site/lib/hook-manager.js +203 -202
- package/_site/lib/index-service.js +173 -173
- package/_site/lib/integration-installer.js +907 -907
- package/_site/lib/kb-framework.js +176 -176
- package/_site/lib/kb-validator.js +124 -124
- package/_site/lib/knowledge-promotion.js +435 -435
- package/_site/lib/knowledge-retrieval-service.js +401 -401
- package/_site/lib/knowledge-tool-runtime.js +359 -359
- package/_site/lib/legacy-data-manifest.js +26 -26
- package/_site/lib/llm-client.js +161 -161
- package/_site/lib/markdown-knowledge-indexer.js +230 -230
- package/_site/lib/migration-service.js +442 -442
- package/_site/lib/module-bridge.js +198 -33
- package/_site/lib/post-commit-automation.js +87 -87
- package/_site/lib/project-lifecycle-service.js +519 -497
- package/_site/lib/project-store.js +268 -262
- package/_site/lib/requirement-recorder.js +278 -278
- package/_site/lib/runtime-endpoint.js +155 -155
- package/_site/lib/scanner.js +298 -298
- package/_site/lib/server-app.js +1590 -1520
- package/_site/lib/settings-store.js +133 -113
- package/_site/lib/storage-layout.js +162 -162
- package/_site/lib/structured-logger.js +574 -574
- package/_site/scripts/folder-picker.ps1 +156 -156
- package/_site/scripts/hook-trigger.js +157 -156
- package/_site/scripts/install-module-candidates.js +69 -0
- package/_site/scripts/pack-module-candidates.js +72 -0
- package/_site/scripts/sync-release-version.js +2 -2
- package/_site/scripts/vendor-modules.js +47 -0
- package/_site/scripts/verify-module-runtime.js +45 -0
- package/_site/scripts/verify-product-runtime.js +34 -0
- package/bin/project-knowledge-mcp.js +194 -194
- package/docs/README.zh-CN.md +34 -25
- package/docs/project-registry-schema.md +22 -22
- package/docs/testing-strategy.md +33 -33
- package/module-runtime-manifest.json +220 -0
- package/package.json +13 -10
- package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.mcp.json +1 -1
- package/plugins/project-knowledge/opencode/project-knowledge.md +3 -3
- package/plugins/project-knowledge/skills/project-knowledge/SKILL.md +28 -28
- package/templates/project-readme.md +34 -34
- package/ui/favicon.svg +38 -38
- package/ui/index.html +51 -149
- package/ui/product.css +9 -0
- package/ui/product.js +386 -0
- package/vendor-manifest.json +46 -0
- package/_site/_test/import-ui-flow-test.js +0 -171
- package/_site/_test/project-control-panel-task14-test.js +0 -63
- package/_site/_test/task15-20-ui-flow-test.js +0 -148
- package/_site/_test/ui-i18n-toggle-test.js +0 -114
- package/_site/_test/ui-smoke-test.js +0 -73
- package/ui/app.css +0 -58
- package/ui/app.js +0 -607
- package/ui/i18n.js +0 -146
|
@@ -1,363 +1,363 @@
|
|
|
1
|
-
// _site/_test/legacy-project-upgrade-e2e-test.js
|
|
2
|
-
//
|
|
3
|
-
// Characterization harness for the v4.1.22 -> main migration path.
|
|
4
|
-
// Builds a v4.1.22-shaped legacy data dir (projects.json, knowledge-store.json,
|
|
5
|
-
// ai-profiles.json) plus a v1 managed post-commit hook in a real git repo,
|
|
6
|
-
// then runs MigrationService + migrateManagedHook + CommitReconciler to prove
|
|
7
|
-
// the legacy project continues to work without manual re-import.
|
|
8
|
-
//
|
|
9
|
-
// These tests are designed to fail under the current regressions and pass
|
|
10
|
-
// after T01/T02/T03 fixes. They do NOT modify production code.
|
|
11
|
-
|
|
12
|
-
const assert = require('assert');
|
|
13
|
-
const fs = require('fs');
|
|
14
|
-
const os = require('os');
|
|
15
|
-
const path = require('path');
|
|
16
|
-
const { spawnSync } = require('child_process');
|
|
17
|
-
|
|
18
|
-
const { StorageLayout } = require('../lib/storage-layout');
|
|
19
|
-
const { MigrationService } = require('../lib/migration-service');
|
|
20
|
-
const {
|
|
21
|
-
installHook,
|
|
22
|
-
readHookStatus,
|
|
23
|
-
migrateManagedHook,
|
|
24
|
-
LEGACY_HOOK_MARKER,
|
|
25
|
-
HOOK_MARKER,
|
|
26
|
-
} = require('../lib/hook-manager');
|
|
27
|
-
const { ProjectStore } = require('../lib/project-store');
|
|
28
|
-
const { ProjectRegistryStore } = require('../lib/project-registry-store');
|
|
29
|
-
const { Logger } = require('../lib/structured-logger');
|
|
30
|
-
const { migrateManagedHooks } = require('../lib/server-app');
|
|
31
|
-
|
|
32
|
-
function git(repo, args) {
|
|
33
|
-
const result = spawnSync('git', ['-C', repo, ...args], {
|
|
34
|
-
encoding: 'utf8',
|
|
35
|
-
windowsHide: true,
|
|
36
|
-
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
37
|
-
});
|
|
38
|
-
if (result.status !== 0) throw new Error(result.stderr || `git ${args.join(' ')} failed: ${result.status}`);
|
|
39
|
-
return String(result.stdout || '').trim();
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function gitOrNull(repo, args) {
|
|
43
|
-
const result = spawnSync('git', ['-C', repo, ...args], {
|
|
44
|
-
encoding: 'utf8',
|
|
45
|
-
windowsHide: true,
|
|
46
|
-
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
47
|
-
});
|
|
48
|
-
return result.status === 0 ? String(result.stdout || '').trim() : null;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function makeRepo(repoPath) {
|
|
52
|
-
fs.mkdirSync(repoPath, { recursive: true });
|
|
53
|
-
git(repoPath, ['init', '-q', '-b', 'main']);
|
|
54
|
-
git(repoPath, ['config', 'user.email', 'legacy-e2e@example.local']);
|
|
55
|
-
git(repoPath, ['config', 'user.name', 'Legacy E2E']);
|
|
56
|
-
git(repoPath, ['config', 'commit.gpgsign', 'false']);
|
|
57
|
-
fs.writeFileSync(path.join(repoPath, 'README.md'), '# legacy\n');
|
|
58
|
-
git(repoPath, ['add', 'README.md']);
|
|
59
|
-
git(repoPath, ['commit', '-q', '-m', 'initial']);
|
|
60
|
-
return git(repoPath, ['rev-parse', 'HEAD']);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function makeLegacyFixture({ hookBody, includeHook = true } = {}) {
|
|
64
|
-
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-legacy-upgrade-e2e-'));
|
|
65
|
-
const repo = path.join(dataDir, 'repos', 'legacy-sample');
|
|
66
|
-
const kbPath = path.join(dataDir, 'knowledge', 'legacy-sample');
|
|
67
|
-
const initialSha = makeRepo(repo);
|
|
68
|
-
if (includeHook) {
|
|
69
|
-
const gitDir = git(repo, ['rev-parse', '--path-format=absolute', '--git-dir']);
|
|
70
|
-
const hooksDir = path.join(path.resolve(gitDir), 'hooks');
|
|
71
|
-
fs.mkdirSync(hooksDir, { recursive: true });
|
|
72
|
-
fs.writeFileSync(path.join(hooksDir, 'post-commit'), hookBody || (
|
|
73
|
-
`#!/bin/sh\n${LEGACY_HOOK_MARKER} — auto-installed by KB manager (legacy)\n` +
|
|
74
|
-
`node 'C:/legacy/site/_site/scripts/hook-trigger.js' --kb-root 'C:/legacy/site' \\\n` +
|
|
75
|
-
` --repo "${repo.replace(/\\/g, '/')}" >/dev/null 2>&1 || true\nexit 0\n`
|
|
76
|
-
), { mode: 0o755 });
|
|
77
|
-
}
|
|
78
|
-
fs.mkdirSync(kbPath, { recursive: true });
|
|
79
|
-
fs.writeFileSync(path.join(kbPath, 'README.md'), '# legacy knowledge\n');
|
|
80
|
-
fs.writeFileSync(path.join(dataDir, 'projects.json'), JSON.stringify({
|
|
81
|
-
'legacy-sample': {
|
|
82
|
-
schema: 'kb-project/v1',
|
|
83
|
-
displayName: 'Legacy Sample',
|
|
84
|
-
localPath: repo,
|
|
85
|
-
gitPath: repo,
|
|
86
|
-
kbPath,
|
|
87
|
-
trackingStartCommit: initialSha,
|
|
88
|
-
lastAnalyzedCommit: initialSha,
|
|
89
|
-
enabled: true,
|
|
90
|
-
aiProfileId: null,
|
|
91
|
-
knowledgeLanguage: 'zh-CN',
|
|
92
|
-
},
|
|
93
|
-
}, null, 2));
|
|
94
|
-
fs.writeFileSync(path.join(dataDir, 'knowledge-store.json'), JSON.stringify({
|
|
95
|
-
schema: 'knowledge-store/v1',
|
|
96
|
-
rootPath: path.join(dataDir, 'knowledge'),
|
|
97
|
-
configured: true,
|
|
98
|
-
}));
|
|
99
|
-
fs.writeFileSync(path.join(dataDir, 'ai-profiles.json'), JSON.stringify({
|
|
100
|
-
schema: 'ai-profiles/v1',
|
|
101
|
-
profiles: [
|
|
102
|
-
{ id: 'primary', name: 'Primary', vendor: 'anthropic', model: 'claude-sonnet', enabled: true },
|
|
103
|
-
],
|
|
104
|
-
defaultProfileId: 'primary',
|
|
105
|
-
}));
|
|
106
|
-
return { dataDir, repo, kbPath, initialSha };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async function run() {
|
|
110
|
-
const ROOT = path.resolve(__dirname, '..', '..');
|
|
111
|
-
const TRIGGER = path.join(ROOT, '_site', 'scripts', 'hook-trigger.js');
|
|
112
|
-
|
|
113
|
-
// ---- Case 1: legacy v1 hook must be upgraded by migrateManagedHook ----
|
|
114
|
-
{
|
|
115
|
-
const { dataDir, repo, kbPath, initialSha } = makeLegacyFixture();
|
|
116
|
-
const layout = new StorageLayout({ dataDir });
|
|
117
|
-
const migration = new MigrationService({ layout, legacyDataDir: dataDir });
|
|
118
|
-
const result = await migration.migrateIfNeeded({ migrationRunId: 'legacy-upgrade-v1' });
|
|
119
|
-
assert.strictEqual(result.ok, true, 'migration must succeed for a legacy fixture with v1 hook');
|
|
120
|
-
assert.strictEqual(result.completed, true, 'migration must complete');
|
|
121
|
-
const projectId = result.projectMap['legacy-sample'];
|
|
122
|
-
assert(projectId, 'migration must produce deterministic projectId mapping');
|
|
123
|
-
// Repo path preserved (not replaced with data-dir fallback)
|
|
124
|
-
const config = JSON.parse(fs.readFileSync(layout.getProjectConfigPath(projectId), 'utf8'));
|
|
125
|
-
assert.strictEqual(config.repoPath, path.resolve(repo), 'migrated repoPath must point at the real legacy repo, not a data-dir fallback');
|
|
126
|
-
assert.strictEqual(config.knowledgePath, path.resolve(kbPath), 'migrated knowledgePath must equal legacy kbPath');
|
|
127
|
-
assert.strictEqual(config.knowledgeLanguage, 'zh-CN', 'migrated knowledgeLanguage must be preserved');
|
|
128
|
-
const state = JSON.parse(fs.readFileSync(layout.getProjectStatePath(projectId), 'utf8'));
|
|
129
|
-
assert.strictEqual(state.trackingStartCommit, initialSha, 'trackingStartCommit must be preserved');
|
|
130
|
-
assert.strictEqual(state.lastAnalyzedCommit, initialSha, 'lastAnalyzedCommit must be preserved');
|
|
131
|
-
// Now invoke migrateManagedHook against the migrated repo: legacy v1 -> v2
|
|
132
|
-
const migrated = migrateManagedHook({ repoPath: repo, projectId, triggerScriptPath: TRIGGER });
|
|
133
|
-
assert.strictEqual(migrated.migrated, true, 'migrateManagedHook must report legacy->v2 migration');
|
|
134
|
-
assert.strictEqual(migrated.managedVersion, 2, 'managedVersion must advance to 2');
|
|
135
|
-
const status = readHookStatus({ repoPath: repo, projectId });
|
|
136
|
-
assert.strictEqual(status.kbManaged, true, 'after migration hook must be kb-managed');
|
|
137
|
-
assert.strictEqual(status.managedVersion, 2, 'after migration hook must be v2');
|
|
138
|
-
const hookBody = fs.readFileSync(status.hookPath, 'utf8');
|
|
139
|
-
assert(hookBody.includes(HOOK_MARKER), 'migrated hook body must carry the v2 marker');
|
|
140
|
-
assert(!hookBody.includes(LEGACY_HOOK_MARKER), 'migrated hook body must NOT carry the legacy v1 marker');
|
|
141
|
-
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// ---- Case 2: idempotent migration (second run) ----
|
|
145
|
-
{
|
|
146
|
-
const { dataDir } = makeLegacyFixture();
|
|
147
|
-
const layout = new StorageLayout({ dataDir });
|
|
148
|
-
const migration = new MigrationService({ layout, legacyDataDir: dataDir });
|
|
149
|
-
const first = await migration.migrateIfNeeded({ migrationRunId: 'legacy-idempotent' });
|
|
150
|
-
assert.strictEqual(first.completed, true);
|
|
151
|
-
const second = await migration.migrateIfNeeded();
|
|
152
|
-
assert.strictEqual(second.completed, true);
|
|
153
|
-
assert.strictEqual(second.migrated, false, 'second migration must be idempotent (no re-migration)');
|
|
154
|
-
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ---- Case 3: missing legacy hook -> install + verify -> mark migrated ----
|
|
158
|
-
// T03 state-machine case 3: a missing hook must be installed and verified
|
|
159
|
-
// before migrationVersion=2 is written. The pre-fix bug (RC-02) was that
|
|
160
|
-
// migrateManagedHooks unconditionally wrote migrationVersion=2 even when
|
|
161
|
-
// migrateManagedHook returned { reason: 'missing' } with no install.
|
|
162
|
-
{
|
|
163
|
-
const { dataDir, repo } = makeLegacyFixture({ includeHook: false });
|
|
164
|
-
const layout = new StorageLayout({ dataDir });
|
|
165
|
-
const migration = new MigrationService({ layout, legacyDataDir: dataDir });
|
|
166
|
-
const result = await migration.migrateIfNeeded({ migrationRunId: 'legacy-missing-hook' });
|
|
167
|
-
assert.strictEqual(result.ok, true);
|
|
168
|
-
const projectId = result.projectMap['legacy-sample'];
|
|
169
|
-
const preStatus = readHookStatus({ repoPath: repo, projectId });
|
|
170
|
-
assert.strictEqual(preStatus.installed, false, 'fixture must not include a hook file');
|
|
171
|
-
const projectStore = new ProjectStore({ layout });
|
|
172
|
-
const registryStore = new ProjectRegistryStore({ layout });
|
|
173
|
-
await registryStore.initialize();
|
|
174
|
-
const logger = new Logger({ layout, settingsProvider: () => ({ levels: [] }), context: { component: 'legacy-upgrade-e2e' } });
|
|
175
|
-
const runtime = { registryStore, projectStore, logger, layout };
|
|
176
|
-
const hooks = await migrateManagedHooks(runtime);
|
|
177
|
-
const entry = hooks.find(item => item.projectId === projectId);
|
|
178
|
-
assert(entry, 'migrateManagedHooks must produce an entry for the migrated legacy project');
|
|
179
|
-
assert.strictEqual(entry.status, 'verified', 'missing hook must be installed+verified, not silently marked migrated');
|
|
180
|
-
assert.strictEqual(entry.reason, 'installed', 'a previously-missing hook reports reason=installed after migration');
|
|
181
|
-
const postStatus = readHookStatus({ repoPath: repo, projectId });
|
|
182
|
-
assert.strictEqual(postStatus.kbManaged, true, 'after migration the hook must be kb-managed');
|
|
183
|
-
assert.strictEqual(postStatus.managedVersion, 2, 'after migration the hook must be v2');
|
|
184
|
-
const finalState = projectStore.readState(projectId);
|
|
185
|
-
assert.strictEqual(finalState.hook.migrationVersion, 2, 'after successful install+verify, migrationVersion must advance to 2');
|
|
186
|
-
assert(finalState.hook.lastVerifiedAt && /^\d{4}-/.test(finalState.hook.lastVerifiedAt), 'after migration lastVerifiedAt must be a timestamp');
|
|
187
|
-
await logger.close();
|
|
188
|
-
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// ---- Case 3b: missing hook + install failure -> migrationVersion stays < 2 ----
|
|
192
|
-
// When the install step itself fails (e.g. the repo path is gone), the
|
|
193
|
-
// state machine must leave migrationVersion unchanged so the next startup
|
|
194
|
-
// can retry. The pre-fix bug advanced migrationVersion even when no hook
|
|
195
|
-
// was installed at all.
|
|
196
|
-
{
|
|
197
|
-
const { dataDir, repo } = makeLegacyFixture({ includeHook: false });
|
|
198
|
-
const layout = new StorageLayout({ dataDir });
|
|
199
|
-
const migration = new MigrationService({ layout, legacyDataDir: dataDir });
|
|
200
|
-
const result = await migration.migrateIfNeeded({ migrationRunId: 'legacy-missing-install-fail' });
|
|
201
|
-
assert.strictEqual(result.ok, true);
|
|
202
|
-
const projectId = result.projectMap['legacy-sample'];
|
|
203
|
-
// Remove the repo so installHook will fail (missing repo).
|
|
204
|
-
fs.rmSync(repo, { recursive: true, force: true });
|
|
205
|
-
const projectStore = new ProjectStore({ layout });
|
|
206
|
-
const registryStore = new ProjectRegistryStore({ layout });
|
|
207
|
-
await registryStore.initialize();
|
|
208
|
-
const logger = new Logger({ layout, settingsProvider: () => ({ levels: [] }), context: { component: 'legacy-upgrade-e2e' } });
|
|
209
|
-
const runtime = { registryStore, projectStore, logger, layout };
|
|
210
|
-
const hooks = await migrateManagedHooks(runtime);
|
|
211
|
-
// migrateManagedHooks skips projects whose repoPath is missing (the
|
|
212
|
-
// fs.existsSync check at the top of the loop). So the registry entry
|
|
213
|
-
// should not appear here. That is itself the correct behavior — we
|
|
214
|
-
// only assert it does NOT silently mark migrationVersion=2 when the
|
|
215
|
-
// repo is gone.
|
|
216
|
-
const entry = hooks.find(item => item.projectId === projectId);
|
|
217
|
-
if (entry) {
|
|
218
|
-
assert.notStrictEqual(entry.status, 'verified', 'a project whose repo is missing must not be marked verified');
|
|
219
|
-
}
|
|
220
|
-
const finalState = projectStore.readState(projectId);
|
|
221
|
-
assert.strictEqual(finalState.hook.migrationVersion < 2, true,
|
|
222
|
-
'RC-02 regression: a missing-repo project must not be marked migrationVersion=2');
|
|
223
|
-
await logger.close();
|
|
224
|
-
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// ---- Case 4: CHARACTERIZATION — third-party hook must not be silently overwritten ----
|
|
228
|
-
{
|
|
229
|
-
const { dataDir, repo } = makeLegacyFixture();
|
|
230
|
-
const gitDir = git(repo, ['rev-parse', '--path-format=absolute', '--git-dir']);
|
|
231
|
-
const hooksDir = path.join(path.resolve(gitDir), 'hooks');
|
|
232
|
-
fs.mkdirSync(hooksDir, { recursive: true });
|
|
233
|
-
const userHook = '#!/bin/sh\necho "user owned hook"\nexit 0\n';
|
|
234
|
-
fs.writeFileSync(path.join(hooksDir, 'post-commit'), userHook, { mode: 0o755 });
|
|
235
|
-
const layout = new StorageLayout({ dataDir });
|
|
236
|
-
const migration = new MigrationService({ layout, legacyDataDir: dataDir });
|
|
237
|
-
const result = await migration.migrateIfNeeded({ migrationRunId: 'legacy-third-party' });
|
|
238
|
-
assert.strictEqual(result.ok, true);
|
|
239
|
-
const projectId = result.projectMap['legacy-sample'];
|
|
240
|
-
let threw = false;
|
|
241
|
-
try {
|
|
242
|
-
migrateManagedHook({ repoPath: repo, projectId, triggerScriptPath: TRIGGER });
|
|
243
|
-
} catch (error) {
|
|
244
|
-
threw = error && (error.code === 'HOOK_CONFLICT' || /third-party|cannot be migrated/i.test(String(error.message || '')));
|
|
245
|
-
}
|
|
246
|
-
assert.strictEqual(threw, true, 'migrateManagedHook must reject a third-party post-commit hook');
|
|
247
|
-
const after = fs.readFileSync(path.join(hooksDir, 'post-commit'), 'utf8');
|
|
248
|
-
assert.strictEqual(after, userHook, 'third-party hook body must be untouched');
|
|
249
|
-
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// ---- Case 5: end-to-end commit on a migrated legacy project ----
|
|
253
|
-
// Validates that legacy knowledgePath/tracking survive a real commit cycle without manual re-import.
|
|
254
|
-
{
|
|
255
|
-
const { dataDir, repo, initialSha } = makeLegacyFixture();
|
|
256
|
-
const layout = new StorageLayout({ dataDir });
|
|
257
|
-
const migration = new MigrationService({ layout, legacyDataDir: dataDir });
|
|
258
|
-
const result = await migration.migrateIfNeeded({ migrationRunId: 'legacy-commit' });
|
|
259
|
-
assert.strictEqual(result.ok, true);
|
|
260
|
-
const projectId = result.projectMap['legacy-sample'];
|
|
261
|
-
// The fixture includes a v1 hook, so use migrateManagedHook which replaces
|
|
262
|
-
// legacy v1 with v2 using the T02 runtime contract.
|
|
263
|
-
const migrated = migrateManagedHook({ repoPath: repo, projectId, triggerScriptPath: TRIGGER });
|
|
264
|
-
assert.strictEqual(migrated.migrated, true, 'migrateManagedHook must report v1 -> v2');
|
|
265
|
-
assert.strictEqual(migrated.managedVersion, 2, 'migrated managedVersion must be 2');
|
|
266
|
-
// New commit in the legacy repo
|
|
267
|
-
fs.writeFileSync(path.join(repo, 'change.txt'), 'second\n');
|
|
268
|
-
git(repo, ['add', 'change.txt']);
|
|
269
|
-
git(repo, ['commit', '-q', '-m', 'second']);
|
|
270
|
-
const newHead = git(repo, ['rev-parse', 'HEAD']);
|
|
271
|
-
assert.notStrictEqual(newHead, initialSha, 'fixture must create a second commit');
|
|
272
|
-
// Verify hook is present and v2
|
|
273
|
-
const status = readHookStatus({ repoPath: repo, projectId });
|
|
274
|
-
assert.strictEqual(status.kbManaged, true);
|
|
275
|
-
assert.strictEqual(status.managedVersion, 2);
|
|
276
|
-
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// ---- Case 6: second startup after successful migration -> no duplicate rewrite ----
|
|
280
|
-
// The post-T03 contract: once a project is verified migrated, a subsequent
|
|
281
|
-
// startup must not touch the hook file or rewrite migrationVersion.
|
|
282
|
-
{
|
|
283
|
-
const { dataDir, repo } = makeLegacyFixture({ includeHook: false });
|
|
284
|
-
const layout = new StorageLayout({ dataDir });
|
|
285
|
-
const migration = new MigrationService({ layout, legacyDataDir: dataDir });
|
|
286
|
-
const result = await migration.migrateIfNeeded({ migrationRunId: 'legacy-second-startup' });
|
|
287
|
-
assert.strictEqual(result.ok, true);
|
|
288
|
-
const projectId = result.projectMap['legacy-sample'];
|
|
289
|
-
const projectStore = new ProjectStore({ layout });
|
|
290
|
-
const registryStore = new ProjectRegistryStore({ layout });
|
|
291
|
-
await registryStore.initialize();
|
|
292
|
-
const logger = new Logger({ layout, settingsProvider: () => ({ levels: [] }), context: { component: 'legacy-upgrade-e2e' } });
|
|
293
|
-
const runtime = { registryStore, projectStore, logger, layout };
|
|
294
|
-
// First startup
|
|
295
|
-
const firstPass = await migrateManagedHooks(runtime);
|
|
296
|
-
assert(firstPass.length > 0, `migrateManagedHooks must produce at least one entry; got ${JSON.stringify(firstPass)}`);
|
|
297
|
-
const firstEntry = firstPass.find(item => item.projectId === projectId);
|
|
298
|
-
assert(firstEntry, `first pass must include the migrated project; got: ${JSON.stringify(firstPass.map(p => p.projectId))}`);
|
|
299
|
-
assert.strictEqual(firstEntry.status, 'verified');
|
|
300
|
-
assert.strictEqual(firstEntry.reason, 'installed');
|
|
301
|
-
const hookPath = readHookStatus({ repoPath: repo, projectId }).hookPath;
|
|
302
|
-
const firstMtime = fs.statSync(hookPath).mtimeMs;
|
|
303
|
-
const firstBody = fs.readFileSync(hookPath, 'utf8');
|
|
304
|
-
const firstState = projectStore.readState(projectId);
|
|
305
|
-
const firstVerifiedAt = firstState.hook.lastVerifiedAt;
|
|
306
|
-
// Wait briefly so a rewrite would change mtime
|
|
307
|
-
await new Promise(resolve => setTimeout(resolve, 20));
|
|
308
|
-
// Second startup
|
|
309
|
-
const secondPass = await migrateManagedHooks(runtime);
|
|
310
|
-
const secondEntry = secondPass.find(item => item.projectId === projectId);
|
|
311
|
-
assert.strictEqual(secondEntry.status, 'verified');
|
|
312
|
-
assert.strictEqual(secondEntry.reason, 'already-migrated', 'second startup must report reason=already-migrated without rewriting');
|
|
313
|
-
assert.strictEqual(fs.readFileSync(hookPath, 'utf8'), firstBody, 'hook body must be untouched on second startup');
|
|
314
|
-
assert.strictEqual(fs.statSync(hookPath).mtimeMs, firstMtime, 'hook mtime must be unchanged on second startup');
|
|
315
|
-
const secondState = projectStore.readState(projectId);
|
|
316
|
-
assert.strictEqual(secondState.hook.lastVerifiedAt, firstVerifiedAt, 'lastVerifiedAt must not be re-stamped on idempotent pass');
|
|
317
|
-
await logger.close();
|
|
318
|
-
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// ---- Case 7: third-party hook -> migration incomplete (T03 case 4) ----
|
|
322
|
-
// Verifies the migrateManagedHooks state machine reports conflict without
|
|
323
|
-
// overwriting the user's hook.
|
|
324
|
-
{
|
|
325
|
-
const { dataDir, repo } = makeLegacyFixture({ includeHook: false });
|
|
326
|
-
const gitDir = git(repo, ['rev-parse', '--path-format=absolute', '--git-dir']);
|
|
327
|
-
const hooksDir = path.join(path.resolve(gitDir), 'hooks');
|
|
328
|
-
fs.mkdirSync(hooksDir, { recursive: true });
|
|
329
|
-
const userHook = '#!/bin/sh\necho "third-party"\nexit 0\n';
|
|
330
|
-
fs.writeFileSync(path.join(hooksDir, 'post-commit'), userHook, { mode: 0o755 });
|
|
331
|
-
const layout = new StorageLayout({ dataDir });
|
|
332
|
-
const migration = new MigrationService({ layout, legacyDataDir: dataDir });
|
|
333
|
-
const result = await migration.migrateIfNeeded({ migrationRunId: 'legacy-third-party-state' });
|
|
334
|
-
assert.strictEqual(result.ok, true);
|
|
335
|
-
const projectId = result.projectMap['legacy-sample'];
|
|
336
|
-
const projectStore = new ProjectStore({ layout });
|
|
337
|
-
const registryStore = new ProjectRegistryStore({ layout });
|
|
338
|
-
await registryStore.initialize();
|
|
339
|
-
const logger = new Logger({ layout, settingsProvider: () => ({ levels: [] }), context: { component: 'legacy-upgrade-e2e' } });
|
|
340
|
-
const runtime = { registryStore, projectStore, logger, layout };
|
|
341
|
-
const pass = await migrateManagedHooks(runtime);
|
|
342
|
-
const entry = pass.find(item => item.projectId === projectId);
|
|
343
|
-
assert(entry, 'migrateManagedHooks must produce an entry for the third-party-hook project');
|
|
344
|
-
assert.strictEqual(entry.status, 'conflict', 'third-party hook must be reported as conflict, not verified');
|
|
345
|
-
assert.strictEqual(entry.reason, 'third-party');
|
|
346
|
-
const finalState = projectStore.readState(projectId);
|
|
347
|
-
assert.strictEqual(finalState.hook.migrationVersion < 2, true,
|
|
348
|
-
'third-party hook must not be marked migrationVersion=2');
|
|
349
|
-
assert(finalState.hook.lastConflict && finalState.hook.lastConflict.code === 'HOOK_CONFLICT',
|
|
350
|
-
'third-party conflict must be persisted in lastConflict for UI consumption');
|
|
351
|
-
assert.strictEqual(fs.readFileSync(path.join(hooksDir, 'post-commit'), 'utf8'), userHook,
|
|
352
|
-
'third-party hook body must remain untouched');
|
|
353
|
-
await logger.close();
|
|
354
|
-
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
console.log('legacy-project-upgrade-e2e-test PASS');
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
run().catch(error => {
|
|
361
|
-
console.error(error.stack || error.message);
|
|
362
|
-
process.exit(1);
|
|
363
|
-
});
|
|
1
|
+
// _site/_test/legacy-project-upgrade-e2e-test.js
|
|
2
|
+
//
|
|
3
|
+
// Characterization harness for the v4.1.22 -> main migration path.
|
|
4
|
+
// Builds a v4.1.22-shaped legacy data dir (projects.json, knowledge-store.json,
|
|
5
|
+
// ai-profiles.json) plus a v1 managed post-commit hook in a real git repo,
|
|
6
|
+
// then runs MigrationService + migrateManagedHook + CommitReconciler to prove
|
|
7
|
+
// the legacy project continues to work without manual re-import.
|
|
8
|
+
//
|
|
9
|
+
// These tests are designed to fail under the current regressions and pass
|
|
10
|
+
// after T01/T02/T03 fixes. They do NOT modify production code.
|
|
11
|
+
|
|
12
|
+
const assert = require('assert');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { spawnSync } = require('child_process');
|
|
17
|
+
|
|
18
|
+
const { StorageLayout } = require('../lib/storage-layout');
|
|
19
|
+
const { MigrationService } = require('../lib/migration-service');
|
|
20
|
+
const {
|
|
21
|
+
installHook,
|
|
22
|
+
readHookStatus,
|
|
23
|
+
migrateManagedHook,
|
|
24
|
+
LEGACY_HOOK_MARKER,
|
|
25
|
+
HOOK_MARKER,
|
|
26
|
+
} = require('../lib/hook-manager');
|
|
27
|
+
const { ProjectStore } = require('../lib/project-store');
|
|
28
|
+
const { ProjectRegistryStore } = require('../lib/project-registry-store');
|
|
29
|
+
const { Logger } = require('../lib/structured-logger');
|
|
30
|
+
const { migrateManagedHooks } = require('../lib/server-app');
|
|
31
|
+
|
|
32
|
+
function git(repo, args) {
|
|
33
|
+
const result = spawnSync('git', ['-C', repo, ...args], {
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
windowsHide: true,
|
|
36
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
37
|
+
});
|
|
38
|
+
if (result.status !== 0) throw new Error(result.stderr || `git ${args.join(' ')} failed: ${result.status}`);
|
|
39
|
+
return String(result.stdout || '').trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function gitOrNull(repo, args) {
|
|
43
|
+
const result = spawnSync('git', ['-C', repo, ...args], {
|
|
44
|
+
encoding: 'utf8',
|
|
45
|
+
windowsHide: true,
|
|
46
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
47
|
+
});
|
|
48
|
+
return result.status === 0 ? String(result.stdout || '').trim() : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeRepo(repoPath) {
|
|
52
|
+
fs.mkdirSync(repoPath, { recursive: true });
|
|
53
|
+
git(repoPath, ['init', '-q', '-b', 'main']);
|
|
54
|
+
git(repoPath, ['config', 'user.email', 'legacy-e2e@example.local']);
|
|
55
|
+
git(repoPath, ['config', 'user.name', 'Legacy E2E']);
|
|
56
|
+
git(repoPath, ['config', 'commit.gpgsign', 'false']);
|
|
57
|
+
fs.writeFileSync(path.join(repoPath, 'README.md'), '# legacy\n');
|
|
58
|
+
git(repoPath, ['add', 'README.md']);
|
|
59
|
+
git(repoPath, ['commit', '-q', '-m', 'initial']);
|
|
60
|
+
return git(repoPath, ['rev-parse', 'HEAD']);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function makeLegacyFixture({ hookBody, includeHook = true } = {}) {
|
|
64
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-legacy-upgrade-e2e-'));
|
|
65
|
+
const repo = path.join(dataDir, 'repos', 'legacy-sample');
|
|
66
|
+
const kbPath = path.join(dataDir, 'knowledge', 'legacy-sample');
|
|
67
|
+
const initialSha = makeRepo(repo);
|
|
68
|
+
if (includeHook) {
|
|
69
|
+
const gitDir = git(repo, ['rev-parse', '--path-format=absolute', '--git-dir']);
|
|
70
|
+
const hooksDir = path.join(path.resolve(gitDir), 'hooks');
|
|
71
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
72
|
+
fs.writeFileSync(path.join(hooksDir, 'post-commit'), hookBody || (
|
|
73
|
+
`#!/bin/sh\n${LEGACY_HOOK_MARKER} — auto-installed by KB manager (legacy)\n` +
|
|
74
|
+
`node 'C:/legacy/site/_site/scripts/hook-trigger.js' --kb-root 'C:/legacy/site' \\\n` +
|
|
75
|
+
` --repo "${repo.replace(/\\/g, '/')}" >/dev/null 2>&1 || true\nexit 0\n`
|
|
76
|
+
), { mode: 0o755 });
|
|
77
|
+
}
|
|
78
|
+
fs.mkdirSync(kbPath, { recursive: true });
|
|
79
|
+
fs.writeFileSync(path.join(kbPath, 'README.md'), '# legacy knowledge\n');
|
|
80
|
+
fs.writeFileSync(path.join(dataDir, 'projects.json'), JSON.stringify({
|
|
81
|
+
'legacy-sample': {
|
|
82
|
+
schema: 'kb-project/v1',
|
|
83
|
+
displayName: 'Legacy Sample',
|
|
84
|
+
localPath: repo,
|
|
85
|
+
gitPath: repo,
|
|
86
|
+
kbPath,
|
|
87
|
+
trackingStartCommit: initialSha,
|
|
88
|
+
lastAnalyzedCommit: initialSha,
|
|
89
|
+
enabled: true,
|
|
90
|
+
aiProfileId: null,
|
|
91
|
+
knowledgeLanguage: 'zh-CN',
|
|
92
|
+
},
|
|
93
|
+
}, null, 2));
|
|
94
|
+
fs.writeFileSync(path.join(dataDir, 'knowledge-store.json'), JSON.stringify({
|
|
95
|
+
schema: 'knowledge-store/v1',
|
|
96
|
+
rootPath: path.join(dataDir, 'knowledge'),
|
|
97
|
+
configured: true,
|
|
98
|
+
}));
|
|
99
|
+
fs.writeFileSync(path.join(dataDir, 'ai-profiles.json'), JSON.stringify({
|
|
100
|
+
schema: 'ai-profiles/v1',
|
|
101
|
+
profiles: [
|
|
102
|
+
{ id: 'primary', name: 'Primary', vendor: 'anthropic', model: 'claude-sonnet', enabled: true },
|
|
103
|
+
],
|
|
104
|
+
defaultProfileId: 'primary',
|
|
105
|
+
}));
|
|
106
|
+
return { dataDir, repo, kbPath, initialSha };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function run() {
|
|
110
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
111
|
+
const TRIGGER = path.join(ROOT, '_site', 'scripts', 'hook-trigger.js');
|
|
112
|
+
|
|
113
|
+
// ---- Case 1: legacy v1 hook must be upgraded by migrateManagedHook ----
|
|
114
|
+
{
|
|
115
|
+
const { dataDir, repo, kbPath, initialSha } = makeLegacyFixture();
|
|
116
|
+
const layout = new StorageLayout({ dataDir });
|
|
117
|
+
const migration = new MigrationService({ layout, legacyDataDir: dataDir });
|
|
118
|
+
const result = await migration.migrateIfNeeded({ migrationRunId: 'legacy-upgrade-v1' });
|
|
119
|
+
assert.strictEqual(result.ok, true, 'migration must succeed for a legacy fixture with v1 hook');
|
|
120
|
+
assert.strictEqual(result.completed, true, 'migration must complete');
|
|
121
|
+
const projectId = result.projectMap['legacy-sample'];
|
|
122
|
+
assert(projectId, 'migration must produce deterministic projectId mapping');
|
|
123
|
+
// Repo path preserved (not replaced with data-dir fallback)
|
|
124
|
+
const config = JSON.parse(fs.readFileSync(layout.getProjectConfigPath(projectId), 'utf8'));
|
|
125
|
+
assert.strictEqual(config.repoPath, path.resolve(repo), 'migrated repoPath must point at the real legacy repo, not a data-dir fallback');
|
|
126
|
+
assert.strictEqual(config.knowledgePath, path.resolve(kbPath), 'migrated knowledgePath must equal legacy kbPath');
|
|
127
|
+
assert.strictEqual(config.knowledgeLanguage, 'zh-CN', 'migrated knowledgeLanguage must be preserved');
|
|
128
|
+
const state = JSON.parse(fs.readFileSync(layout.getProjectStatePath(projectId), 'utf8'));
|
|
129
|
+
assert.strictEqual(state.trackingStartCommit, initialSha, 'trackingStartCommit must be preserved');
|
|
130
|
+
assert.strictEqual(state.lastAnalyzedCommit, initialSha, 'lastAnalyzedCommit must be preserved');
|
|
131
|
+
// Now invoke migrateManagedHook against the migrated repo: legacy v1 -> v2
|
|
132
|
+
const migrated = migrateManagedHook({ repoPath: repo, projectId, triggerScriptPath: TRIGGER });
|
|
133
|
+
assert.strictEqual(migrated.migrated, true, 'migrateManagedHook must report legacy->v2 migration');
|
|
134
|
+
assert.strictEqual(migrated.managedVersion, 2, 'managedVersion must advance to 2');
|
|
135
|
+
const status = readHookStatus({ repoPath: repo, projectId });
|
|
136
|
+
assert.strictEqual(status.kbManaged, true, 'after migration hook must be kb-managed');
|
|
137
|
+
assert.strictEqual(status.managedVersion, 2, 'after migration hook must be v2');
|
|
138
|
+
const hookBody = fs.readFileSync(status.hookPath, 'utf8');
|
|
139
|
+
assert(hookBody.includes(HOOK_MARKER), 'migrated hook body must carry the v2 marker');
|
|
140
|
+
assert(!hookBody.includes(LEGACY_HOOK_MARKER), 'migrated hook body must NOT carry the legacy v1 marker');
|
|
141
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- Case 2: idempotent migration (second run) ----
|
|
145
|
+
{
|
|
146
|
+
const { dataDir } = makeLegacyFixture();
|
|
147
|
+
const layout = new StorageLayout({ dataDir });
|
|
148
|
+
const migration = new MigrationService({ layout, legacyDataDir: dataDir });
|
|
149
|
+
const first = await migration.migrateIfNeeded({ migrationRunId: 'legacy-idempotent' });
|
|
150
|
+
assert.strictEqual(first.completed, true);
|
|
151
|
+
const second = await migration.migrateIfNeeded();
|
|
152
|
+
assert.strictEqual(second.completed, true);
|
|
153
|
+
assert.strictEqual(second.migrated, false, 'second migration must be idempotent (no re-migration)');
|
|
154
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---- Case 3: missing legacy hook -> install + verify -> mark migrated ----
|
|
158
|
+
// T03 state-machine case 3: a missing hook must be installed and verified
|
|
159
|
+
// before migrationVersion=2 is written. The pre-fix bug (RC-02) was that
|
|
160
|
+
// migrateManagedHooks unconditionally wrote migrationVersion=2 even when
|
|
161
|
+
// migrateManagedHook returned { reason: 'missing' } with no install.
|
|
162
|
+
{
|
|
163
|
+
const { dataDir, repo } = makeLegacyFixture({ includeHook: false });
|
|
164
|
+
const layout = new StorageLayout({ dataDir });
|
|
165
|
+
const migration = new MigrationService({ layout, legacyDataDir: dataDir });
|
|
166
|
+
const result = await migration.migrateIfNeeded({ migrationRunId: 'legacy-missing-hook' });
|
|
167
|
+
assert.strictEqual(result.ok, true);
|
|
168
|
+
const projectId = result.projectMap['legacy-sample'];
|
|
169
|
+
const preStatus = readHookStatus({ repoPath: repo, projectId });
|
|
170
|
+
assert.strictEqual(preStatus.installed, false, 'fixture must not include a hook file');
|
|
171
|
+
const projectStore = new ProjectStore({ layout });
|
|
172
|
+
const registryStore = new ProjectRegistryStore({ layout });
|
|
173
|
+
await registryStore.initialize();
|
|
174
|
+
const logger = new Logger({ layout, settingsProvider: () => ({ levels: [] }), context: { component: 'legacy-upgrade-e2e' } });
|
|
175
|
+
const runtime = { registryStore, projectStore, logger, layout, analysisOwner: 'legacy', bridgeAdapter: { hookBinding: () => ({ dataDir: layout.getDataDir() }) } };
|
|
176
|
+
const hooks = await migrateManagedHooks(runtime);
|
|
177
|
+
const entry = hooks.find(item => item.projectId === projectId);
|
|
178
|
+
assert(entry, 'migrateManagedHooks must produce an entry for the migrated legacy project');
|
|
179
|
+
assert.strictEqual(entry.status, 'verified', 'missing hook must be installed+verified, not silently marked migrated');
|
|
180
|
+
assert.strictEqual(entry.reason, 'installed', 'a previously-missing hook reports reason=installed after migration');
|
|
181
|
+
const postStatus = readHookStatus({ repoPath: repo, projectId });
|
|
182
|
+
assert.strictEqual(postStatus.kbManaged, true, 'after migration the hook must be kb-managed');
|
|
183
|
+
assert.strictEqual(postStatus.managedVersion, 2, 'after migration the hook must be v2');
|
|
184
|
+
const finalState = projectStore.readState(projectId);
|
|
185
|
+
assert.strictEqual(finalState.hook.migrationVersion, 2, 'after successful install+verify, migrationVersion must advance to 2');
|
|
186
|
+
assert(finalState.hook.lastVerifiedAt && /^\d{4}-/.test(finalState.hook.lastVerifiedAt), 'after migration lastVerifiedAt must be a timestamp');
|
|
187
|
+
await logger.close();
|
|
188
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---- Case 3b: missing hook + install failure -> migrationVersion stays < 2 ----
|
|
192
|
+
// When the install step itself fails (e.g. the repo path is gone), the
|
|
193
|
+
// state machine must leave migrationVersion unchanged so the next startup
|
|
194
|
+
// can retry. The pre-fix bug advanced migrationVersion even when no hook
|
|
195
|
+
// was installed at all.
|
|
196
|
+
{
|
|
197
|
+
const { dataDir, repo } = makeLegacyFixture({ includeHook: false });
|
|
198
|
+
const layout = new StorageLayout({ dataDir });
|
|
199
|
+
const migration = new MigrationService({ layout, legacyDataDir: dataDir });
|
|
200
|
+
const result = await migration.migrateIfNeeded({ migrationRunId: 'legacy-missing-install-fail' });
|
|
201
|
+
assert.strictEqual(result.ok, true);
|
|
202
|
+
const projectId = result.projectMap['legacy-sample'];
|
|
203
|
+
// Remove the repo so installHook will fail (missing repo).
|
|
204
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
205
|
+
const projectStore = new ProjectStore({ layout });
|
|
206
|
+
const registryStore = new ProjectRegistryStore({ layout });
|
|
207
|
+
await registryStore.initialize();
|
|
208
|
+
const logger = new Logger({ layout, settingsProvider: () => ({ levels: [] }), context: { component: 'legacy-upgrade-e2e' } });
|
|
209
|
+
const runtime = { registryStore, projectStore, logger, layout, analysisOwner: 'legacy', bridgeAdapter: { hookBinding: () => ({ dataDir: layout.getDataDir() }) } };
|
|
210
|
+
const hooks = await migrateManagedHooks(runtime);
|
|
211
|
+
// migrateManagedHooks skips projects whose repoPath is missing (the
|
|
212
|
+
// fs.existsSync check at the top of the loop). So the registry entry
|
|
213
|
+
// should not appear here. That is itself the correct behavior — we
|
|
214
|
+
// only assert it does NOT silently mark migrationVersion=2 when the
|
|
215
|
+
// repo is gone.
|
|
216
|
+
const entry = hooks.find(item => item.projectId === projectId);
|
|
217
|
+
if (entry) {
|
|
218
|
+
assert.notStrictEqual(entry.status, 'verified', 'a project whose repo is missing must not be marked verified');
|
|
219
|
+
}
|
|
220
|
+
const finalState = projectStore.readState(projectId);
|
|
221
|
+
assert.strictEqual(finalState.hook.migrationVersion < 2, true,
|
|
222
|
+
'RC-02 regression: a missing-repo project must not be marked migrationVersion=2');
|
|
223
|
+
await logger.close();
|
|
224
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---- Case 4: CHARACTERIZATION — third-party hook must not be silently overwritten ----
|
|
228
|
+
{
|
|
229
|
+
const { dataDir, repo } = makeLegacyFixture();
|
|
230
|
+
const gitDir = git(repo, ['rev-parse', '--path-format=absolute', '--git-dir']);
|
|
231
|
+
const hooksDir = path.join(path.resolve(gitDir), 'hooks');
|
|
232
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
233
|
+
const userHook = '#!/bin/sh\necho "user owned hook"\nexit 0\n';
|
|
234
|
+
fs.writeFileSync(path.join(hooksDir, 'post-commit'), userHook, { mode: 0o755 });
|
|
235
|
+
const layout = new StorageLayout({ dataDir });
|
|
236
|
+
const migration = new MigrationService({ layout, legacyDataDir: dataDir });
|
|
237
|
+
const result = await migration.migrateIfNeeded({ migrationRunId: 'legacy-third-party' });
|
|
238
|
+
assert.strictEqual(result.ok, true);
|
|
239
|
+
const projectId = result.projectMap['legacy-sample'];
|
|
240
|
+
let threw = false;
|
|
241
|
+
try {
|
|
242
|
+
migrateManagedHook({ repoPath: repo, projectId, triggerScriptPath: TRIGGER });
|
|
243
|
+
} catch (error) {
|
|
244
|
+
threw = error && (error.code === 'HOOK_CONFLICT' || /third-party|cannot be migrated/i.test(String(error.message || '')));
|
|
245
|
+
}
|
|
246
|
+
assert.strictEqual(threw, true, 'migrateManagedHook must reject a third-party post-commit hook');
|
|
247
|
+
const after = fs.readFileSync(path.join(hooksDir, 'post-commit'), 'utf8');
|
|
248
|
+
assert.strictEqual(after, userHook, 'third-party hook body must be untouched');
|
|
249
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---- Case 5: end-to-end commit on a migrated legacy project ----
|
|
253
|
+
// Validates that legacy knowledgePath/tracking survive a real commit cycle without manual re-import.
|
|
254
|
+
{
|
|
255
|
+
const { dataDir, repo, initialSha } = makeLegacyFixture();
|
|
256
|
+
const layout = new StorageLayout({ dataDir });
|
|
257
|
+
const migration = new MigrationService({ layout, legacyDataDir: dataDir });
|
|
258
|
+
const result = await migration.migrateIfNeeded({ migrationRunId: 'legacy-commit' });
|
|
259
|
+
assert.strictEqual(result.ok, true);
|
|
260
|
+
const projectId = result.projectMap['legacy-sample'];
|
|
261
|
+
// The fixture includes a v1 hook, so use migrateManagedHook which replaces
|
|
262
|
+
// legacy v1 with v2 using the T02 runtime contract.
|
|
263
|
+
const migrated = migrateManagedHook({ repoPath: repo, projectId, triggerScriptPath: TRIGGER });
|
|
264
|
+
assert.strictEqual(migrated.migrated, true, 'migrateManagedHook must report v1 -> v2');
|
|
265
|
+
assert.strictEqual(migrated.managedVersion, 2, 'migrated managedVersion must be 2');
|
|
266
|
+
// New commit in the legacy repo
|
|
267
|
+
fs.writeFileSync(path.join(repo, 'change.txt'), 'second\n');
|
|
268
|
+
git(repo, ['add', 'change.txt']);
|
|
269
|
+
git(repo, ['commit', '-q', '-m', 'second']);
|
|
270
|
+
const newHead = git(repo, ['rev-parse', 'HEAD']);
|
|
271
|
+
assert.notStrictEqual(newHead, initialSha, 'fixture must create a second commit');
|
|
272
|
+
// Verify hook is present and v2
|
|
273
|
+
const status = readHookStatus({ repoPath: repo, projectId });
|
|
274
|
+
assert.strictEqual(status.kbManaged, true);
|
|
275
|
+
assert.strictEqual(status.managedVersion, 2);
|
|
276
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---- Case 6: second startup after successful migration -> no duplicate rewrite ----
|
|
280
|
+
// The post-T03 contract: once a project is verified migrated, a subsequent
|
|
281
|
+
// startup must not touch the hook file or rewrite migrationVersion.
|
|
282
|
+
{
|
|
283
|
+
const { dataDir, repo } = makeLegacyFixture({ includeHook: false });
|
|
284
|
+
const layout = new StorageLayout({ dataDir });
|
|
285
|
+
const migration = new MigrationService({ layout, legacyDataDir: dataDir });
|
|
286
|
+
const result = await migration.migrateIfNeeded({ migrationRunId: 'legacy-second-startup' });
|
|
287
|
+
assert.strictEqual(result.ok, true);
|
|
288
|
+
const projectId = result.projectMap['legacy-sample'];
|
|
289
|
+
const projectStore = new ProjectStore({ layout });
|
|
290
|
+
const registryStore = new ProjectRegistryStore({ layout });
|
|
291
|
+
await registryStore.initialize();
|
|
292
|
+
const logger = new Logger({ layout, settingsProvider: () => ({ levels: [] }), context: { component: 'legacy-upgrade-e2e' } });
|
|
293
|
+
const runtime = { registryStore, projectStore, logger, layout, analysisOwner: 'legacy', bridgeAdapter: { hookBinding: () => ({ dataDir: layout.getDataDir() }) } };
|
|
294
|
+
// First startup
|
|
295
|
+
const firstPass = await migrateManagedHooks(runtime);
|
|
296
|
+
assert(firstPass.length > 0, `migrateManagedHooks must produce at least one entry; got ${JSON.stringify(firstPass)}`);
|
|
297
|
+
const firstEntry = firstPass.find(item => item.projectId === projectId);
|
|
298
|
+
assert(firstEntry, `first pass must include the migrated project; got: ${JSON.stringify(firstPass.map(p => p.projectId))}`);
|
|
299
|
+
assert.strictEqual(firstEntry.status, 'verified');
|
|
300
|
+
assert.strictEqual(firstEntry.reason, 'installed');
|
|
301
|
+
const hookPath = readHookStatus({ repoPath: repo, projectId }).hookPath;
|
|
302
|
+
const firstMtime = fs.statSync(hookPath).mtimeMs;
|
|
303
|
+
const firstBody = fs.readFileSync(hookPath, 'utf8');
|
|
304
|
+
const firstState = projectStore.readState(projectId);
|
|
305
|
+
const firstVerifiedAt = firstState.hook.lastVerifiedAt;
|
|
306
|
+
// Wait briefly so a rewrite would change mtime
|
|
307
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
308
|
+
// Second startup
|
|
309
|
+
const secondPass = await migrateManagedHooks(runtime);
|
|
310
|
+
const secondEntry = secondPass.find(item => item.projectId === projectId);
|
|
311
|
+
assert.strictEqual(secondEntry.status, 'verified');
|
|
312
|
+
assert.strictEqual(secondEntry.reason, 'already-migrated', 'second startup must report reason=already-migrated without rewriting');
|
|
313
|
+
assert.strictEqual(fs.readFileSync(hookPath, 'utf8'), firstBody, 'hook body must be untouched on second startup');
|
|
314
|
+
assert.strictEqual(fs.statSync(hookPath).mtimeMs, firstMtime, 'hook mtime must be unchanged on second startup');
|
|
315
|
+
const secondState = projectStore.readState(projectId);
|
|
316
|
+
assert.strictEqual(secondState.hook.lastVerifiedAt, firstVerifiedAt, 'lastVerifiedAt must not be re-stamped on idempotent pass');
|
|
317
|
+
await logger.close();
|
|
318
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---- Case 7: third-party hook -> migration incomplete (T03 case 4) ----
|
|
322
|
+
// Verifies the migrateManagedHooks state machine reports conflict without
|
|
323
|
+
// overwriting the user's hook.
|
|
324
|
+
{
|
|
325
|
+
const { dataDir, repo } = makeLegacyFixture({ includeHook: false });
|
|
326
|
+
const gitDir = git(repo, ['rev-parse', '--path-format=absolute', '--git-dir']);
|
|
327
|
+
const hooksDir = path.join(path.resolve(gitDir), 'hooks');
|
|
328
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
329
|
+
const userHook = '#!/bin/sh\necho "third-party"\nexit 0\n';
|
|
330
|
+
fs.writeFileSync(path.join(hooksDir, 'post-commit'), userHook, { mode: 0o755 });
|
|
331
|
+
const layout = new StorageLayout({ dataDir });
|
|
332
|
+
const migration = new MigrationService({ layout, legacyDataDir: dataDir });
|
|
333
|
+
const result = await migration.migrateIfNeeded({ migrationRunId: 'legacy-third-party-state' });
|
|
334
|
+
assert.strictEqual(result.ok, true);
|
|
335
|
+
const projectId = result.projectMap['legacy-sample'];
|
|
336
|
+
const projectStore = new ProjectStore({ layout });
|
|
337
|
+
const registryStore = new ProjectRegistryStore({ layout });
|
|
338
|
+
await registryStore.initialize();
|
|
339
|
+
const logger = new Logger({ layout, settingsProvider: () => ({ levels: [] }), context: { component: 'legacy-upgrade-e2e' } });
|
|
340
|
+
const runtime = { registryStore, projectStore, logger, layout, analysisOwner: 'legacy', bridgeAdapter: { hookBinding: () => ({ dataDir: layout.getDataDir() }) } };
|
|
341
|
+
const pass = await migrateManagedHooks(runtime);
|
|
342
|
+
const entry = pass.find(item => item.projectId === projectId);
|
|
343
|
+
assert(entry, 'migrateManagedHooks must produce an entry for the third-party-hook project');
|
|
344
|
+
assert.strictEqual(entry.status, 'conflict', 'third-party hook must be reported as conflict, not verified');
|
|
345
|
+
assert.strictEqual(entry.reason, 'third-party');
|
|
346
|
+
const finalState = projectStore.readState(projectId);
|
|
347
|
+
assert.strictEqual(finalState.hook.migrationVersion < 2, true,
|
|
348
|
+
'third-party hook must not be marked migrationVersion=2');
|
|
349
|
+
assert(finalState.hook.lastConflict && finalState.hook.lastConflict.code === 'HOOK_CONFLICT',
|
|
350
|
+
'third-party conflict must be persisted in lastConflict for UI consumption');
|
|
351
|
+
assert.strictEqual(fs.readFileSync(path.join(hooksDir, 'post-commit'), 'utf8'), userHook,
|
|
352
|
+
'third-party hook body must remain untouched');
|
|
353
|
+
await logger.close();
|
|
354
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
console.log('legacy-project-upgrade-e2e-test PASS');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
run().catch(error => {
|
|
361
|
+
console.error(error.stack || error.message);
|
|
362
|
+
process.exit(1);
|
|
363
|
+
});
|