@sanqianx/project-knowledge 4.7.0 → 5.0.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 +836 -799
- package/README.md +58 -41
- package/_modules/knowledge-engine/README.md +91 -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 +263 -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/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 +154 -0
- package/_modules/knowledge-engine/src/store.js +236 -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/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/codex-conversation-projection-test.js +2 -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/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/fresh-hook-reimport-test.js +53 -0
- package/_site/_test/fresh-product-data-test.js +42 -0
- 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-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/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 +44 -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/packaged-ui-smoke-test.js +11 -9
- 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-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/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 +109 -108
- 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 +1 -0
- 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 +6 -74
- package/_site/lib/engine-connection.js +67 -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 +908 -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/llm-client.js +161 -161
- package/_site/lib/markdown-knowledge-indexer.js +230 -230
- package/_site/lib/module-bridge.js +191 -36
- package/_site/lib/post-commit-automation.js +87 -87
- package/_site/lib/product-data.js +24 -0
- package/_site/lib/project-lifecycle-service.js +535 -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 +1579 -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 +42 -29
- package/docs/fresh-v5-start.md +28 -0
- 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 +14 -9
- 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 +52 -149
- package/ui/product.css +9 -0
- package/ui/product.js +412 -0
- package/vendor-manifest.json +45 -0
- package/_site/_test/data-dir-migration-test.js +0 -77
- package/_site/_test/import-ui-flow-test.js +0 -171
- package/_site/_test/knowledge-migration-test.js +0 -75
- package/_site/_test/legacy-forward-compat-test.js +0 -257
- package/_site/_test/legacy-project-upgrade-e2e-test.js +0 -363
- package/_site/_test/p0-data-migration-characterization-test.js +0 -37
- package/_site/_test/p0-e2e-gate-test.js +0 -347
- package/_site/_test/project-control-panel-task14-test.js +0 -63
- package/_site/_test/project-layout-v2-migration-test.js +0 -127
- package/_site/_test/server-runtime-migration-safety-test.js +0 -27
- 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/_site/_test/v4122-upgrade-data-contract-test.js +0 -56
- package/_site/lib/data-state-classifier.js +0 -40
- package/_site/lib/legacy-data-manifest.js +0 -26
- package/_site/lib/migration-service.js +0 -442
- package/ui/app.css +0 -58
- package/ui/app.js +0 -607
- package/ui/i18n.js +0 -146
package/_site/lib/data-dir.js
CHANGED
|
@@ -1,81 +1,13 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const os = require('os');
|
|
4
|
-
const crypto = require('crypto');
|
|
5
|
-
const { LEGACY_ASSETS, assetPath } = require('./legacy-data-manifest');
|
|
6
|
-
const { isEmptyJson } = require('./data-state-classifier');
|
|
7
|
-
|
|
8
|
-
let _resolved = null;
|
|
9
4
|
|
|
5
|
+
let resolved = null;
|
|
10
6
|
function resolveDataDirPath(options = {}) {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
return path.resolve(fromEnv || path.join(homeDir, '.project-knowledge'));
|
|
7
|
+
const configured = options.dataDir === undefined ? process.env.KB_DATA_DIR : options.dataDir;
|
|
8
|
+
return path.resolve(configured || path.join(options.homeDir || os.homedir(), '.project-knowledge-v5'));
|
|
14
9
|
}
|
|
15
10
|
function ensureDataDir(dataDir = resolveDataDirPath()) { fs.mkdirSync(dataDir, { recursive: true }); return path.resolve(dataDir); }
|
|
16
|
-
function getDataDir() { if (!
|
|
17
|
-
function _resetCache() {
|
|
18
|
-
|
|
19
|
-
function directoryDigest(root) {
|
|
20
|
-
const hash = crypto.createHash('sha256');
|
|
21
|
-
const walk = current => {
|
|
22
|
-
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
23
|
-
const filePath = path.join(current, entry.name);
|
|
24
|
-
const relative = path.relative(root, filePath).replace(/\\/g, '/');
|
|
25
|
-
hash.update(`${entry.isDirectory() ? 'd' : 'f'}:${relative}\0`);
|
|
26
|
-
if (entry.isDirectory()) walk(filePath); else if (entry.isFile()) hash.update(fs.readFileSync(filePath));
|
|
27
|
-
}
|
|
28
|
-
};
|
|
29
|
-
walk(root); return hash.digest('hex');
|
|
30
|
-
}
|
|
31
|
-
function isEmptyAsset(filePath, asset) {
|
|
32
|
-
if (!fs.existsSync(filePath)) return true;
|
|
33
|
-
if (asset.kind === 'dir') return fs.readdirSync(filePath).length === 0;
|
|
34
|
-
if (fs.statSync(filePath).size === 0) return true;
|
|
35
|
-
if (!filePath.endsWith('.json')) return false;
|
|
36
|
-
try { const raw = fs.readFileSync(filePath, 'utf8'); return isEmptyJson(JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw)); } catch { return false; }
|
|
37
|
-
}
|
|
38
|
-
function assetsEqual(source, target, asset) { return asset.kind === 'dir' ? directoryDigest(source) === directoryDigest(target) : fileDigest(source) === fileDigest(target); }
|
|
39
|
-
function copyDir(source, target) {
|
|
40
|
-
fs.mkdirSync(target, { recursive: true });
|
|
41
|
-
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
|
42
|
-
const from = path.join(source, entry.name); const to = path.join(target, entry.name);
|
|
43
|
-
if (entry.isDirectory()) copyDir(from, to); else if (entry.isFile()) fs.copyFileSync(from, to);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function migrateFromLegacy({ legacyRoot, logger } = {}) {
|
|
48
|
-
if (process.env.KB_SKIP_MIGRATION === '1') return { ok: true, migrated: false, reason: 'skipped via KB_SKIP_MIGRATION=1' };
|
|
49
|
-
if (!legacyRoot || !fs.existsSync(legacyRoot)) return { ok: true, migrated: false, reason: legacyRoot ? 'legacy root does not exist' : 'no legacy root provided' };
|
|
50
|
-
const dataDir = getDataDir();
|
|
51
|
-
if (path.resolve(legacyRoot) === dataDir) return { ok: true, migrated: false, reason: 'legacy root equals data dir' };
|
|
52
|
-
// Packaged application roots may include empty scaffolding such as
|
|
53
|
-
// `projects/`. It is not user data and must never conflict with populated
|
|
54
|
-
// runtime data during startup relocation.
|
|
55
|
-
const sources = LEGACY_ASSETS.filter(asset => {
|
|
56
|
-
const source = assetPath(legacyRoot, asset);
|
|
57
|
-
return fs.existsSync(source) && !isEmptyAsset(source, asset);
|
|
58
|
-
});
|
|
59
|
-
if (!sources.length) return { ok: true, migrated: false, reason: 'no legacy assets found' };
|
|
60
|
-
const conflicts = [];
|
|
61
|
-
for (const asset of sources) {
|
|
62
|
-
const source = assetPath(legacyRoot, asset); const target = assetPath(dataDir, asset, 'target');
|
|
63
|
-
if (fs.existsSync(target) && !isEmptyAsset(target, asset) && !assetsEqual(source, target, asset)) conflicts.push(asset.target);
|
|
64
|
-
}
|
|
65
|
-
if (conflicts.length) return { ok: false, migrated: false, requiresManualRecovery: true, reason: 'legacy-data-conflict', conflicts, source: legacyRoot, target: dataDir };
|
|
66
|
-
ensureDataDir(dataDir);
|
|
67
|
-
const result = { ok: true, migrated: false, files: 0, dirs: 0, source: legacyRoot, target: dataDir };
|
|
68
|
-
try {
|
|
69
|
-
for (const asset of sources) {
|
|
70
|
-
const source = assetPath(legacyRoot, asset); const target = assetPath(dataDir, asset, 'target');
|
|
71
|
-
if (fs.existsSync(target) && assetsEqual(source, target, asset)) continue;
|
|
72
|
-
if (asset.kind === 'dir') { if (fs.existsSync(target)) fs.rmSync(target, { recursive: true, force: true }); copyDir(source, target); result.dirs++; }
|
|
73
|
-
else { fs.mkdirSync(path.dirname(target), { recursive: true }); fs.copyFileSync(source, target); result.files++; }
|
|
74
|
-
}
|
|
75
|
-
result.migrated = result.files > 0 || result.dirs > 0;
|
|
76
|
-
if (result.migrated && logger) logger(`migrated runtime data to ${dataDir} (${result.files} files, ${result.dirs} dirs from ${legacyRoot})`);
|
|
77
|
-
return result;
|
|
78
|
-
} catch (error) { return { ...result, ok: false, error: `failed to relocate legacy runtime data: ${error.message}` }; }
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
module.exports = { resolveDataDirPath, ensureDataDir, getDataDir, migrateFromLegacy, LEGACY_ASSETS, _resetCache };
|
|
11
|
+
function getDataDir() { if (!resolved) resolved = resolveDataDirPath(); return resolved; }
|
|
12
|
+
function _resetCache() { resolved = null; }
|
|
13
|
+
module.exports = { resolveDataDirPath, ensureDataDir, getDataDir, _resetCache };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const { DomainError } = require('./contracts');
|
|
2
|
+
const { execGit } = require('./git-runner');
|
|
3
|
+
|
|
4
|
+
async function connectEngine(runtime) {
|
|
5
|
+
if (runtime.analysisOwner === 'legacy' || runtime.engineConnecting) return;
|
|
6
|
+
runtime.engineConnecting = true;
|
|
7
|
+
let step = 'engine-health';
|
|
8
|
+
let projectId = '';
|
|
9
|
+
let httpStatus = 0;
|
|
10
|
+
try {
|
|
11
|
+
const bridge = runtime.moduleBridge;
|
|
12
|
+
if (bridge.engineCompatible === undefined) bridge.engineCompatible = false;
|
|
13
|
+
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${bridge.runtimeToken}` };
|
|
14
|
+
const health = await bridge._fetch(`${bridge.engineUrl}/api/health`);
|
|
15
|
+
httpStatus = health.status;
|
|
16
|
+
if (!health.ok) throw new Error('ENGINE_UNAVAILABLE');
|
|
17
|
+
const engine = await health.json();
|
|
18
|
+
if (engine.name !== 'knowledge-engine' || engine.dataSchemaVersion !== 3) throw new Error('ENGINE_VERSION_MISMATCH');
|
|
19
|
+
step = 'runtime-authentication';
|
|
20
|
+
const auth = await bridge._fetch(`${bridge.engineUrl}/api/runtime-auth`, { headers });
|
|
21
|
+
httpStatus = auth.status;
|
|
22
|
+
if (!auth.ok) throw new Error('RUNTIME_AUTH_REQUIRED');
|
|
23
|
+
bridge.engineCompatible = true;
|
|
24
|
+
step = 'project-wiring';
|
|
25
|
+
for (const id of runtime.registryStore.listIds()) {
|
|
26
|
+
projectId = id;
|
|
27
|
+
const config = runtime.projectStore.readConfig(id);
|
|
28
|
+
const modules = await bridge.registerProjectLinks({ projectId: id, name: config.displayName, workspacePath: config.repoPath, knowledgePath: config.knowledgePath });
|
|
29
|
+
if (!modules.engine.ok || !modules.eventBridge.ok) throw new Error('PROJECT_WIRING_PENDING');
|
|
30
|
+
}
|
|
31
|
+
projectId = '';
|
|
32
|
+
step = 'runtime-configuration';
|
|
33
|
+
await bridge.syncEngineConfiguration();
|
|
34
|
+
step = 'engine-activation';
|
|
35
|
+
const activated = await bridge._fetch(`${bridge.engineUrl}/api/control`, { method: 'POST', headers, body: JSON.stringify({ enabled: true }) });
|
|
36
|
+
httpStatus = activated.status;
|
|
37
|
+
if (!activated.ok) throw new Error(activated.status === 403 ? 'RUNTIME_AUTH_REQUIRED' : 'ENGINE_ACTIVATION_FAILED');
|
|
38
|
+
runtime.ownership = { owner: 'engine', ready: true };
|
|
39
|
+
} catch (error) {
|
|
40
|
+
const codes = ['ENGINE_UNAVAILABLE', 'ENGINE_VERSION_MISMATCH', 'PROJECT_WIRING_PENDING', 'RUNTIME_AUTH_REQUIRED', 'ENGINE_ACTIVATION_FAILED'];
|
|
41
|
+
const code = codes.includes(error.message) ? error.message : 'ENGINE_CONNECTION_FAILED';
|
|
42
|
+
if (['ENGINE_VERSION_MISMATCH', 'RUNTIME_AUTH_REQUIRED'].includes(code)) runtime.moduleBridge.engineCompatible = false;
|
|
43
|
+
const messages = {
|
|
44
|
+
ENGINE_VERSION_MISMATCH: '检测到旧版知识引擎,请停止旧版服务后重新启动新版。',
|
|
45
|
+
RUNTIME_AUTH_REQUIRED: '引擎服务与外壳鉴权不一致,请停止旧版服务后重新启动新版。',
|
|
46
|
+
PROJECT_WIRING_PENDING: '项目接线未完成,请检查模块连接。',
|
|
47
|
+
};
|
|
48
|
+
const message = messages[code] || '知识引擎连接未完成,服务恢复后自动重试。';
|
|
49
|
+
const previous = runtime.ownership?.error;
|
|
50
|
+
runtime.ownership = { owner: 'engine', ready: false, message, error: { code, step, httpStatus, ...(projectId ? { projectId } : {}) } };
|
|
51
|
+
if (previous?.code !== code || previous?.step !== step || (previous?.projectId || '') !== projectId) await runtime.logger.warn('engine.connection_pending', message, { component: 'engine-connection', phase: step, projectId, context: { code, httpStatus } });
|
|
52
|
+
} finally { runtime.engineConnecting = false; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function forwardCommitToBridge(runtime, event) {
|
|
56
|
+
const config = runtime.projectStore.readConfig(event.projectId);
|
|
57
|
+
if (!runtime.layout.pathsEqual(config.repoPath, event.repoRoot) || !/^[a-f0-9]{40}$/i.test(event.head || '')) throw new DomainError('INVALID_ARGUMENT', '提交通知与项目绑定不匹配。');
|
|
58
|
+
const exists = await execGit(config.repoPath, ['cat-file', '-e', `${event.head}^{commit}`]);
|
|
59
|
+
if (!exists.ok) throw new DomainError('INVALID_ARGUMENT', '提交不属于当前仓库。');
|
|
60
|
+
const identity = await runtime.bridgeAdapter.resolveRepoIdentity(config.repoPath);
|
|
61
|
+
const result = await runtime.bridgeAdapter.appendCommitBoundary({ projectId: event.projectId, repoIdentity: identity.repoIdentity, commitSha: event.head, branch: event.branch || null, operationId: event.operationId });
|
|
62
|
+
if (result.status !== 'captured') throw new DomainError('MODULE_UNREACHABLE', '事件记录失败,请恢复事件模块后重试。', { status: 503 });
|
|
63
|
+
runtime.moduleBridge._fetch(`${runtime.moduleBridge.engineUrl}/api/bridge/notify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).catch(() => {});
|
|
64
|
+
return { journalSequence: result.boundary?.journalSequence || result.boundary?.sequence };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { connectEngine, forwardCommitToBridge };
|
|
@@ -1,228 +1,228 @@
|
|
|
1
|
-
const crypto = require('crypto');
|
|
2
|
-
const fs = require('fs');
|
|
3
|
-
const path = require('path');
|
|
4
|
-
const AtomicFile = require('./atomic-file');
|
|
5
|
-
const { DomainError } = require('./contracts');
|
|
6
|
-
|
|
7
|
-
const PATCH_MANIFEST_SCHEMA = 'patch-evidence-manifest/v1';
|
|
8
|
-
const COMMIT_EVIDENCE_SCHEMA = 'commit-evidence-bundle/v1';
|
|
9
|
-
const DEFAULT_CHUNK_BYTES = 512 * 1024;
|
|
10
|
-
|
|
11
|
-
function hashBuffer(value) {
|
|
12
|
-
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function manifestPayload(manifest) {
|
|
16
|
-
const { manifestHash, ...payload } = manifest;
|
|
17
|
-
return payload;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function lineSafeChunks(buffer, maxChunkBytes = DEFAULT_CHUNK_BYTES) {
|
|
21
|
-
const limit = Math.max(64 * 1024, Number(maxChunkBytes || DEFAULT_CHUNK_BYTES));
|
|
22
|
-
const chunks = [];
|
|
23
|
-
let start = 0;
|
|
24
|
-
while (start < buffer.length) {
|
|
25
|
-
let end = Math.min(buffer.length, start + limit);
|
|
26
|
-
if (end < buffer.length) {
|
|
27
|
-
const previousNewline = buffer.lastIndexOf(0x0a, end - 1);
|
|
28
|
-
if (previousNewline >= start) end = previousNewline + 1;
|
|
29
|
-
else {
|
|
30
|
-
const nextNewline = buffer.indexOf(0x0a, end);
|
|
31
|
-
end = nextNewline === -1 ? buffer.length : nextNewline + 1;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
if (end <= start) end = buffer.length;
|
|
35
|
-
chunks.push(buffer.subarray(start, end));
|
|
36
|
-
start = end;
|
|
37
|
-
}
|
|
38
|
-
if (!chunks.length) chunks.push(Buffer.alloc(0));
|
|
39
|
-
return chunks;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function chunkMetadata(buffer, inheritedSourcePaths = []) {
|
|
43
|
-
const text = buffer.toString('utf8');
|
|
44
|
-
const sourcePaths = [...inheritedSourcePaths];
|
|
45
|
-
const hunks = [];
|
|
46
|
-
for (const line of text.split('\n')) {
|
|
47
|
-
const diff = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
48
|
-
if (diff) {
|
|
49
|
-
sourcePaths.push(diff[1], diff[2]);
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
if (line.startsWith('@@')) hunks.push(line.slice(0, 512));
|
|
53
|
-
}
|
|
54
|
-
return {
|
|
55
|
-
sourcePaths: [...new Set(sourcePaths)],
|
|
56
|
-
hunkHeaders: hunks,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function readManifestStrict(manifestPath, atomic = AtomicFile) {
|
|
61
|
-
return atomic.readJsonStrict(manifestPath, {
|
|
62
|
-
category: 'patch-evidence-manifest',
|
|
63
|
-
validate: manifest => {
|
|
64
|
-
if (!manifest || manifest.schema !== PATCH_MANIFEST_SCHEMA || !Array.isArray(manifest.chunks)
|
|
65
|
-
|| !manifest.fullPatchHash || !Number.isInteger(manifest.totalBytes) || manifest.totalBytes < 0
|
|
66
|
-
|| manifest.chunkCount !== manifest.chunks.length) {
|
|
67
|
-
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Patch evidence manifest is invalid.', { status: 500 });
|
|
68
|
-
}
|
|
69
|
-
if (manifest.manifestHash !== hashBuffer(Buffer.from(JSON.stringify(manifestPayload(manifest)), 'utf8'))) {
|
|
70
|
-
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Patch evidence manifest hash does not match.', { status: 500 });
|
|
71
|
-
}
|
|
72
|
-
return manifest;
|
|
73
|
-
},
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function safeChunkPath(evidenceRoot, relativePath) {
|
|
78
|
-
if (!/^patches\/\d{6}\.patch$/.test(String(relativePath || ''))) {
|
|
79
|
-
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Patch evidence chunk path is invalid.', { status: 500 });
|
|
80
|
-
}
|
|
81
|
-
const root = path.resolve(evidenceRoot);
|
|
82
|
-
const target = path.resolve(root, ...relativePath.split('/'));
|
|
83
|
-
const relative = path.relative(root, target);
|
|
84
|
-
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
85
|
-
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Patch evidence chunk escapes its read-only root.', { status: 500 });
|
|
86
|
-
}
|
|
87
|
-
return target;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
class ExactPatchEvidenceBundle {
|
|
91
|
-
constructor(options = {}) {
|
|
92
|
-
this.atomic = options.atomic || AtomicFile;
|
|
93
|
-
this.maxChunkBytes = Number(options.maxChunkBytes || DEFAULT_CHUNK_BYTES);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
build(input = {}) {
|
|
97
|
-
const evidenceRoot = path.resolve(String(input.evidenceRoot || ''));
|
|
98
|
-
if (!input.evidenceRoot) throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Patch evidence root is required.', { status: 500 });
|
|
99
|
-
const patch = Buffer.isBuffer(input.patch) ? input.patch : Buffer.from(String(input.patch || ''), 'utf8');
|
|
100
|
-
const fullPatchHash = hashBuffer(patch);
|
|
101
|
-
if (input.patchHash && input.patchHash !== fullPatchHash) {
|
|
102
|
-
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Full patch hash changed before evidence bundling.', { status: 500 });
|
|
103
|
-
}
|
|
104
|
-
const manifestPath = path.join(evidenceRoot, 'patch-manifest.json');
|
|
105
|
-
if (fs.existsSync(manifestPath)) {
|
|
106
|
-
const manifest = this.verify(evidenceRoot, { expectedPatchHash: fullPatchHash, expectedBytes: patch.length });
|
|
107
|
-
return this.describe(evidenceRoot, manifest);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
fs.mkdirSync(path.join(evidenceRoot, 'patches'), { recursive: true });
|
|
111
|
-
const chunks = lineSafeChunks(patch, this.maxChunkBytes);
|
|
112
|
-
let activeSourcePaths = [];
|
|
113
|
-
const records = chunks.map((chunk, index) => {
|
|
114
|
-
const relativePath = `patches/${String(index + 1).padStart(6, '0')}.patch`;
|
|
115
|
-
const filePath = safeChunkPath(evidenceRoot, relativePath);
|
|
116
|
-
this.atomic.writeFileAtomic(filePath, chunk);
|
|
117
|
-
const metadata = chunkMetadata(chunk, activeSourcePaths);
|
|
118
|
-
if (metadata.sourcePaths.length) activeSourcePaths = metadata.sourcePaths.slice(-2);
|
|
119
|
-
return {
|
|
120
|
-
sequence: index + 1,
|
|
121
|
-
path: relativePath,
|
|
122
|
-
bytes: chunk.length,
|
|
123
|
-
sha256: hashBuffer(chunk),
|
|
124
|
-
...metadata,
|
|
125
|
-
};
|
|
126
|
-
});
|
|
127
|
-
const manifest = {
|
|
128
|
-
schema: PATCH_MANIFEST_SCHEMA,
|
|
129
|
-
fullPatchHash,
|
|
130
|
-
totalBytes: patch.length,
|
|
131
|
-
chunkCount: records.length,
|
|
132
|
-
chunks: records,
|
|
133
|
-
manifestHash: '',
|
|
134
|
-
};
|
|
135
|
-
manifest.manifestHash = hashBuffer(Buffer.from(JSON.stringify(manifestPayload(manifest)), 'utf8'));
|
|
136
|
-
this.atomic.writeJsonAtomic(manifestPath, manifest);
|
|
137
|
-
const commitEvidence = {
|
|
138
|
-
schema: COMMIT_EVIDENCE_SCHEMA,
|
|
139
|
-
commit: input.commit || {},
|
|
140
|
-
files: input.files || [],
|
|
141
|
-
fullPatchHash,
|
|
142
|
-
totalBytes: patch.length,
|
|
143
|
-
patchManifestHash: manifest.manifestHash,
|
|
144
|
-
};
|
|
145
|
-
this.atomic.writeJsonAtomic(path.join(evidenceRoot, 'commit.json'), commitEvidence);
|
|
146
|
-
this.verify(evidenceRoot, { expectedPatchHash: fullPatchHash, expectedBytes: patch.length });
|
|
147
|
-
return this.describe(evidenceRoot, manifest);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
verify(evidenceRoot, expected = {}) {
|
|
151
|
-
try {
|
|
152
|
-
return this.verifyUnchecked(evidenceRoot, expected);
|
|
153
|
-
} catch (error) {
|
|
154
|
-
if (error && error.code === 'EVIDENCE_INTEGRITY_FAILED') throw error;
|
|
155
|
-
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Patch evidence bundle could not be read or verified.', {
|
|
156
|
-
status: 500,
|
|
157
|
-
cause: error,
|
|
158
|
-
retryable: true,
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
verifyUnchecked(evidenceRoot, expected = {}) {
|
|
164
|
-
const root = path.resolve(evidenceRoot);
|
|
165
|
-
const manifest = readManifestStrict(path.join(root, 'patch-manifest.json'), this.atomic);
|
|
166
|
-
const commitEvidence = this.atomic.readJsonStrict(path.join(root, 'commit.json'), {
|
|
167
|
-
category: 'commit-evidence-bundle',
|
|
168
|
-
validate: value => {
|
|
169
|
-
if (!value || value.schema !== COMMIT_EVIDENCE_SCHEMA || value.fullPatchHash !== manifest.fullPatchHash
|
|
170
|
-
|| value.totalBytes !== manifest.totalBytes || value.patchManifestHash !== manifest.manifestHash
|
|
171
|
-
|| !value.commit || !Array.isArray(value.files)) {
|
|
172
|
-
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Commit evidence metadata does not match the patch manifest.', { status: 500 });
|
|
173
|
-
}
|
|
174
|
-
return value;
|
|
175
|
-
},
|
|
176
|
-
});
|
|
177
|
-
if (expected.expectedPatchHash && manifest.fullPatchHash !== expected.expectedPatchHash) {
|
|
178
|
-
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Frozen patch evidence hash does not match the claim.', { status: 500 });
|
|
179
|
-
}
|
|
180
|
-
if (expected.expectedBytes != null && manifest.totalBytes !== expected.expectedBytes) {
|
|
181
|
-
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Frozen patch evidence byte count does not match the claim.', { status: 500 });
|
|
182
|
-
}
|
|
183
|
-
const aggregate = crypto.createHash('sha256');
|
|
184
|
-
let bytes = 0;
|
|
185
|
-
manifest.chunks.forEach((chunk, index) => {
|
|
186
|
-
if (chunk.sequence !== index + 1 || !Number.isInteger(chunk.bytes) || chunk.bytes < 0 || !chunk.sha256) {
|
|
187
|
-
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Patch evidence chunk order or metadata is invalid.', { status: 500 });
|
|
188
|
-
}
|
|
189
|
-
const filePath = safeChunkPath(root, chunk.path);
|
|
190
|
-
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile() || fs.lstatSync(filePath).isSymbolicLink()) {
|
|
191
|
-
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Patch evidence chunk is missing or unsafe.', { status: 500 });
|
|
192
|
-
}
|
|
193
|
-
const value = fs.readFileSync(filePath);
|
|
194
|
-
if (value.length !== chunk.bytes || hashBuffer(value) !== chunk.sha256) {
|
|
195
|
-
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Patch evidence chunk failed integrity validation.', { status: 500 });
|
|
196
|
-
}
|
|
197
|
-
aggregate.update(value);
|
|
198
|
-
bytes += value.length;
|
|
199
|
-
});
|
|
200
|
-
if (bytes !== manifest.totalBytes || `sha256:${aggregate.digest('hex')}` !== manifest.fullPatchHash) {
|
|
201
|
-
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Patch evidence chunks do not exactly cover the full diff.', { status: 500 });
|
|
202
|
-
}
|
|
203
|
-
void commitEvidence;
|
|
204
|
-
return manifest;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
describe(evidenceRoot, manifest) {
|
|
208
|
-
return {
|
|
209
|
-
root: path.resolve(evidenceRoot),
|
|
210
|
-
commitPath: path.join(path.resolve(evidenceRoot), 'commit.json'),
|
|
211
|
-
manifestPath: path.join(path.resolve(evidenceRoot), 'patch-manifest.json'),
|
|
212
|
-
manifestHash: manifest.manifestHash,
|
|
213
|
-
chunkCount: manifest.chunkCount,
|
|
214
|
-
totalBytes: manifest.totalBytes,
|
|
215
|
-
chunkBytes: manifest.chunks.map(chunk => chunk.bytes),
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
module.exports = {
|
|
221
|
-
PATCH_MANIFEST_SCHEMA,
|
|
222
|
-
COMMIT_EVIDENCE_SCHEMA,
|
|
223
|
-
DEFAULT_CHUNK_BYTES,
|
|
224
|
-
ExactPatchEvidenceBundle,
|
|
225
|
-
hashBuffer,
|
|
226
|
-
lineSafeChunks,
|
|
227
|
-
readManifestStrict,
|
|
228
|
-
};
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const AtomicFile = require('./atomic-file');
|
|
5
|
+
const { DomainError } = require('./contracts');
|
|
6
|
+
|
|
7
|
+
const PATCH_MANIFEST_SCHEMA = 'patch-evidence-manifest/v1';
|
|
8
|
+
const COMMIT_EVIDENCE_SCHEMA = 'commit-evidence-bundle/v1';
|
|
9
|
+
const DEFAULT_CHUNK_BYTES = 512 * 1024;
|
|
10
|
+
|
|
11
|
+
function hashBuffer(value) {
|
|
12
|
+
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function manifestPayload(manifest) {
|
|
16
|
+
const { manifestHash, ...payload } = manifest;
|
|
17
|
+
return payload;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function lineSafeChunks(buffer, maxChunkBytes = DEFAULT_CHUNK_BYTES) {
|
|
21
|
+
const limit = Math.max(64 * 1024, Number(maxChunkBytes || DEFAULT_CHUNK_BYTES));
|
|
22
|
+
const chunks = [];
|
|
23
|
+
let start = 0;
|
|
24
|
+
while (start < buffer.length) {
|
|
25
|
+
let end = Math.min(buffer.length, start + limit);
|
|
26
|
+
if (end < buffer.length) {
|
|
27
|
+
const previousNewline = buffer.lastIndexOf(0x0a, end - 1);
|
|
28
|
+
if (previousNewline >= start) end = previousNewline + 1;
|
|
29
|
+
else {
|
|
30
|
+
const nextNewline = buffer.indexOf(0x0a, end);
|
|
31
|
+
end = nextNewline === -1 ? buffer.length : nextNewline + 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (end <= start) end = buffer.length;
|
|
35
|
+
chunks.push(buffer.subarray(start, end));
|
|
36
|
+
start = end;
|
|
37
|
+
}
|
|
38
|
+
if (!chunks.length) chunks.push(Buffer.alloc(0));
|
|
39
|
+
return chunks;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function chunkMetadata(buffer, inheritedSourcePaths = []) {
|
|
43
|
+
const text = buffer.toString('utf8');
|
|
44
|
+
const sourcePaths = [...inheritedSourcePaths];
|
|
45
|
+
const hunks = [];
|
|
46
|
+
for (const line of text.split('\n')) {
|
|
47
|
+
const diff = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
48
|
+
if (diff) {
|
|
49
|
+
sourcePaths.push(diff[1], diff[2]);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (line.startsWith('@@')) hunks.push(line.slice(0, 512));
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
sourcePaths: [...new Set(sourcePaths)],
|
|
56
|
+
hunkHeaders: hunks,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readManifestStrict(manifestPath, atomic = AtomicFile) {
|
|
61
|
+
return atomic.readJsonStrict(manifestPath, {
|
|
62
|
+
category: 'patch-evidence-manifest',
|
|
63
|
+
validate: manifest => {
|
|
64
|
+
if (!manifest || manifest.schema !== PATCH_MANIFEST_SCHEMA || !Array.isArray(manifest.chunks)
|
|
65
|
+
|| !manifest.fullPatchHash || !Number.isInteger(manifest.totalBytes) || manifest.totalBytes < 0
|
|
66
|
+
|| manifest.chunkCount !== manifest.chunks.length) {
|
|
67
|
+
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Patch evidence manifest is invalid.', { status: 500 });
|
|
68
|
+
}
|
|
69
|
+
if (manifest.manifestHash !== hashBuffer(Buffer.from(JSON.stringify(manifestPayload(manifest)), 'utf8'))) {
|
|
70
|
+
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Patch evidence manifest hash does not match.', { status: 500 });
|
|
71
|
+
}
|
|
72
|
+
return manifest;
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function safeChunkPath(evidenceRoot, relativePath) {
|
|
78
|
+
if (!/^patches\/\d{6}\.patch$/.test(String(relativePath || ''))) {
|
|
79
|
+
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Patch evidence chunk path is invalid.', { status: 500 });
|
|
80
|
+
}
|
|
81
|
+
const root = path.resolve(evidenceRoot);
|
|
82
|
+
const target = path.resolve(root, ...relativePath.split('/'));
|
|
83
|
+
const relative = path.relative(root, target);
|
|
84
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
85
|
+
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Patch evidence chunk escapes its read-only root.', { status: 500 });
|
|
86
|
+
}
|
|
87
|
+
return target;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
class ExactPatchEvidenceBundle {
|
|
91
|
+
constructor(options = {}) {
|
|
92
|
+
this.atomic = options.atomic || AtomicFile;
|
|
93
|
+
this.maxChunkBytes = Number(options.maxChunkBytes || DEFAULT_CHUNK_BYTES);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
build(input = {}) {
|
|
97
|
+
const evidenceRoot = path.resolve(String(input.evidenceRoot || ''));
|
|
98
|
+
if (!input.evidenceRoot) throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Patch evidence root is required.', { status: 500 });
|
|
99
|
+
const patch = Buffer.isBuffer(input.patch) ? input.patch : Buffer.from(String(input.patch || ''), 'utf8');
|
|
100
|
+
const fullPatchHash = hashBuffer(patch);
|
|
101
|
+
if (input.patchHash && input.patchHash !== fullPatchHash) {
|
|
102
|
+
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Full patch hash changed before evidence bundling.', { status: 500 });
|
|
103
|
+
}
|
|
104
|
+
const manifestPath = path.join(evidenceRoot, 'patch-manifest.json');
|
|
105
|
+
if (fs.existsSync(manifestPath)) {
|
|
106
|
+
const manifest = this.verify(evidenceRoot, { expectedPatchHash: fullPatchHash, expectedBytes: patch.length });
|
|
107
|
+
return this.describe(evidenceRoot, manifest);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
fs.mkdirSync(path.join(evidenceRoot, 'patches'), { recursive: true });
|
|
111
|
+
const chunks = lineSafeChunks(patch, this.maxChunkBytes);
|
|
112
|
+
let activeSourcePaths = [];
|
|
113
|
+
const records = chunks.map((chunk, index) => {
|
|
114
|
+
const relativePath = `patches/${String(index + 1).padStart(6, '0')}.patch`;
|
|
115
|
+
const filePath = safeChunkPath(evidenceRoot, relativePath);
|
|
116
|
+
this.atomic.writeFileAtomic(filePath, chunk);
|
|
117
|
+
const metadata = chunkMetadata(chunk, activeSourcePaths);
|
|
118
|
+
if (metadata.sourcePaths.length) activeSourcePaths = metadata.sourcePaths.slice(-2);
|
|
119
|
+
return {
|
|
120
|
+
sequence: index + 1,
|
|
121
|
+
path: relativePath,
|
|
122
|
+
bytes: chunk.length,
|
|
123
|
+
sha256: hashBuffer(chunk),
|
|
124
|
+
...metadata,
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
const manifest = {
|
|
128
|
+
schema: PATCH_MANIFEST_SCHEMA,
|
|
129
|
+
fullPatchHash,
|
|
130
|
+
totalBytes: patch.length,
|
|
131
|
+
chunkCount: records.length,
|
|
132
|
+
chunks: records,
|
|
133
|
+
manifestHash: '',
|
|
134
|
+
};
|
|
135
|
+
manifest.manifestHash = hashBuffer(Buffer.from(JSON.stringify(manifestPayload(manifest)), 'utf8'));
|
|
136
|
+
this.atomic.writeJsonAtomic(manifestPath, manifest);
|
|
137
|
+
const commitEvidence = {
|
|
138
|
+
schema: COMMIT_EVIDENCE_SCHEMA,
|
|
139
|
+
commit: input.commit || {},
|
|
140
|
+
files: input.files || [],
|
|
141
|
+
fullPatchHash,
|
|
142
|
+
totalBytes: patch.length,
|
|
143
|
+
patchManifestHash: manifest.manifestHash,
|
|
144
|
+
};
|
|
145
|
+
this.atomic.writeJsonAtomic(path.join(evidenceRoot, 'commit.json'), commitEvidence);
|
|
146
|
+
this.verify(evidenceRoot, { expectedPatchHash: fullPatchHash, expectedBytes: patch.length });
|
|
147
|
+
return this.describe(evidenceRoot, manifest);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
verify(evidenceRoot, expected = {}) {
|
|
151
|
+
try {
|
|
152
|
+
return this.verifyUnchecked(evidenceRoot, expected);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (error && error.code === 'EVIDENCE_INTEGRITY_FAILED') throw error;
|
|
155
|
+
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Patch evidence bundle could not be read or verified.', {
|
|
156
|
+
status: 500,
|
|
157
|
+
cause: error,
|
|
158
|
+
retryable: true,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
verifyUnchecked(evidenceRoot, expected = {}) {
|
|
164
|
+
const root = path.resolve(evidenceRoot);
|
|
165
|
+
const manifest = readManifestStrict(path.join(root, 'patch-manifest.json'), this.atomic);
|
|
166
|
+
const commitEvidence = this.atomic.readJsonStrict(path.join(root, 'commit.json'), {
|
|
167
|
+
category: 'commit-evidence-bundle',
|
|
168
|
+
validate: value => {
|
|
169
|
+
if (!value || value.schema !== COMMIT_EVIDENCE_SCHEMA || value.fullPatchHash !== manifest.fullPatchHash
|
|
170
|
+
|| value.totalBytes !== manifest.totalBytes || value.patchManifestHash !== manifest.manifestHash
|
|
171
|
+
|| !value.commit || !Array.isArray(value.files)) {
|
|
172
|
+
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Commit evidence metadata does not match the patch manifest.', { status: 500 });
|
|
173
|
+
}
|
|
174
|
+
return value;
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
if (expected.expectedPatchHash && manifest.fullPatchHash !== expected.expectedPatchHash) {
|
|
178
|
+
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Frozen patch evidence hash does not match the claim.', { status: 500 });
|
|
179
|
+
}
|
|
180
|
+
if (expected.expectedBytes != null && manifest.totalBytes !== expected.expectedBytes) {
|
|
181
|
+
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Frozen patch evidence byte count does not match the claim.', { status: 500 });
|
|
182
|
+
}
|
|
183
|
+
const aggregate = crypto.createHash('sha256');
|
|
184
|
+
let bytes = 0;
|
|
185
|
+
manifest.chunks.forEach((chunk, index) => {
|
|
186
|
+
if (chunk.sequence !== index + 1 || !Number.isInteger(chunk.bytes) || chunk.bytes < 0 || !chunk.sha256) {
|
|
187
|
+
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Patch evidence chunk order or metadata is invalid.', { status: 500 });
|
|
188
|
+
}
|
|
189
|
+
const filePath = safeChunkPath(root, chunk.path);
|
|
190
|
+
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile() || fs.lstatSync(filePath).isSymbolicLink()) {
|
|
191
|
+
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Patch evidence chunk is missing or unsafe.', { status: 500 });
|
|
192
|
+
}
|
|
193
|
+
const value = fs.readFileSync(filePath);
|
|
194
|
+
if (value.length !== chunk.bytes || hashBuffer(value) !== chunk.sha256) {
|
|
195
|
+
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Patch evidence chunk failed integrity validation.', { status: 500 });
|
|
196
|
+
}
|
|
197
|
+
aggregate.update(value);
|
|
198
|
+
bytes += value.length;
|
|
199
|
+
});
|
|
200
|
+
if (bytes !== manifest.totalBytes || `sha256:${aggregate.digest('hex')}` !== manifest.fullPatchHash) {
|
|
201
|
+
throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Patch evidence chunks do not exactly cover the full diff.', { status: 500 });
|
|
202
|
+
}
|
|
203
|
+
void commitEvidence;
|
|
204
|
+
return manifest;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
describe(evidenceRoot, manifest) {
|
|
208
|
+
return {
|
|
209
|
+
root: path.resolve(evidenceRoot),
|
|
210
|
+
commitPath: path.join(path.resolve(evidenceRoot), 'commit.json'),
|
|
211
|
+
manifestPath: path.join(path.resolve(evidenceRoot), 'patch-manifest.json'),
|
|
212
|
+
manifestHash: manifest.manifestHash,
|
|
213
|
+
chunkCount: manifest.chunkCount,
|
|
214
|
+
totalBytes: manifest.totalBytes,
|
|
215
|
+
chunkBytes: manifest.chunks.map(chunk => chunk.bytes),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = {
|
|
221
|
+
PATCH_MANIFEST_SCHEMA,
|
|
222
|
+
COMMIT_EVIDENCE_SCHEMA,
|
|
223
|
+
DEFAULT_CHUNK_BYTES,
|
|
224
|
+
ExactPatchEvidenceBundle,
|
|
225
|
+
hashBuffer,
|
|
226
|
+
lineSafeChunks,
|
|
227
|
+
readManifestStrict,
|
|
228
|
+
};
|