@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.
Files changed (196) hide show
  1. package/.agents/plugins/marketplace.json +17 -17
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/CHANGELOG.md +836 -799
  4. package/README.md +58 -41
  5. package/_modules/knowledge-engine/README.md +91 -0
  6. package/_modules/knowledge-engine/package.json +38 -0
  7. package/_modules/knowledge-engine/src/analysis-contract.js +25 -0
  8. package/_modules/knowledge-engine/src/analysis-worker.js +54 -0
  9. package/_modules/knowledge-engine/src/bin.js +52 -0
  10. package/_modules/knowledge-engine/src/bridge-client.js +24 -0
  11. package/_modules/knowledge-engine/src/coordinator.js +263 -0
  12. package/_modules/knowledge-engine/src/headless-analyzer.js +75 -0
  13. package/_modules/knowledge-engine/src/memories.js +85 -0
  14. package/_modules/knowledge-engine/src/retrieval.js +116 -0
  15. package/_modules/knowledge-engine/src/runtime/README.md +7 -0
  16. package/_modules/knowledge-engine/src/runtime/atomic-file.js +204 -0
  17. package/_modules/knowledge-engine/src/runtime/automation-config.js +140 -0
  18. package/_modules/knowledge-engine/src/runtime/commit-conversation-binder.js +249 -0
  19. package/_modules/knowledge-engine/src/runtime/commit-processing-ledger.js +26 -0
  20. package/_modules/knowledge-engine/src/runtime/commit-prompt.js +202 -0
  21. package/_modules/knowledge-engine/src/runtime/commit-reconciler.js +432 -0
  22. package/_modules/knowledge-engine/src/runtime/content-hash.js +4 -0
  23. package/_modules/knowledge-engine/src/runtime/contracts.js +279 -0
  24. package/_modules/knowledge-engine/src/runtime/conversation-exclusions.js +204 -0
  25. package/_modules/knowledge-engine/src/runtime/conversation-store.js +387 -0
  26. package/_modules/knowledge-engine/src/runtime/evidence-bundle.js +228 -0
  27. package/_modules/knowledge-engine/src/runtime/git-runner.js +62 -0
  28. package/_modules/knowledge-engine/src/runtime/knowledge-promotion.js +444 -0
  29. package/_modules/knowledge-engine/src/runtime/knowledge-retrieval-service.js +402 -0
  30. package/_modules/knowledge-engine/src/runtime/layout.js +44 -0
  31. package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +230 -0
  32. package/_modules/knowledge-engine/src/runtime/scanner.js +298 -0
  33. package/_modules/knowledge-engine/src/server.js +154 -0
  34. package/_modules/knowledge-engine/src/store.js +236 -0
  35. package/_modules/knowledge-engine/ui/app.css +172 -0
  36. package/_modules/knowledge-engine/ui/app.js +297 -0
  37. package/_modules/knowledge-engine/ui/index.html +95 -0
  38. package/_site/README.md +30 -30
  39. package/_site/_test/ai-profile-resolver-test.js +137 -137
  40. package/_site/_test/automation-queue-test.js +14 -14
  41. package/_site/_test/automation-ui-test.js +40 -40
  42. package/_site/_test/background-task-registry-test.js +43 -43
  43. package/_site/_test/baseline-schema-test.js +91 -91
  44. package/_site/_test/chat-claudecodeui-match-test.js +92 -92
  45. package/_site/_test/claude-executable-discovery-test.js +62 -62
  46. package/_site/_test/claude-workbench-test.js +170 -170
  47. package/_site/_test/codex-conversation-projection-test.js +2 -1
  48. package/_site/_test/commit-conversation-binding-test.js +95 -95
  49. package/_site/_test/commit-evidence-test.js +110 -110
  50. package/_site/_test/conversation-api-test.js +109 -109
  51. package/_site/_test/conversation-store-test.js +136 -136
  52. package/_site/_test/desktop-browser-compat-test.js +28 -28
  53. package/_site/_test/desktop-hook-runtime-regression-test.js +204 -204
  54. package/_site/_test/explicit-commit-processor-test.js +37 -37
  55. package/_site/_test/fixtures/make-git-repos.js +12 -1
  56. package/_site/_test/folder-picker-output-test.js +19 -19
  57. package/_site/_test/fresh-hook-reimport-test.js +53 -0
  58. package/_site/_test/fresh-product-data-test.js +42 -0
  59. package/_site/_test/full-integration-e2e-test.js +165 -165
  60. package/_site/_test/git-validation-test.js +2 -2
  61. package/_site/_test/hook-runtime-endpoint-test.js +88 -88
  62. package/_site/_test/hook-status-repair-api-test.js +152 -152
  63. package/_site/_test/hook-trigger-test.js +85 -85
  64. package/_site/_test/import-preflight-api-test.js +234 -233
  65. package/_site/_test/index-writer-concurrency-test.js +135 -135
  66. package/_site/_test/integration-adapters-test.js +171 -171
  67. package/_site/_test/integration-surface-coverage-test.js +164 -164
  68. package/_site/_test/knowledge-language-control-test.js +170 -170
  69. package/_site/_test/knowledge-promotion-recovery-test.js +260 -260
  70. package/_site/_test/knowledge-query-test.js +54 -54
  71. package/_site/_test/knowledge-retrieval-service-test.js +77 -77
  72. package/_site/_test/knowledge-storage-startup-test.js +52 -52
  73. package/_site/_test/knowledge-store-logs-supervision-test.js +89 -89
  74. package/_site/_test/logging-api-test.js +89 -89
  75. package/_site/_test/logging-sse-no-gap-test.js +88 -88
  76. package/_site/_test/logging-ui-test.js +106 -104
  77. package/_site/_test/markdown-delta-overlay-test.js +85 -85
  78. package/_site/_test/markdown-maintenance-api-test.js +75 -75
  79. package/_site/_test/mcp-server-test.js +149 -149
  80. package/_site/_test/module-artifact-integrity-test.js +27 -0
  81. package/_site/_test/module-boundary-test.js +30 -0
  82. package/_site/_test/module-bridge-eventbridge-test.js +7 -0
  83. package/_site/_test/module-model-configuration-test.js +44 -0
  84. package/_site/_test/module-process-lifecycle-test.js +24 -0
  85. package/_site/_test/module-stream-proxy-test.js +34 -0
  86. package/_site/_test/non-release-ci-test.js +34 -34
  87. package/_site/_test/offline-boundary-isolation-test.js +32 -32
  88. package/_site/_test/packaged-ui-smoke-test.js +11 -9
  89. package/_site/_test/path-consistency-test.js +151 -151
  90. package/_site/_test/pending-sweep-test.js +9 -9
  91. package/_site/_test/post-commit-automation-test.js +87 -87
  92. package/_site/_test/product-diagnostics-ui-test.js +75 -0
  93. package/_site/_test/product-import-ui-test.js +92 -0
  94. package/_site/_test/project-delete-recovery-test.js +64 -64
  95. package/_site/_test/project-goal-editor-test.js +144 -144
  96. package/_site/_test/project-lifecycle-transaction-test.js +106 -106
  97. package/_site/_test/prompt-settings-test.js +115 -115
  98. package/_site/_test/protected-architecture-gate-test.js +122 -122
  99. package/_site/_test/refactor-characterization-test.js +36 -36
  100. package/_site/_test/release-version-sync-test.js +1 -1
  101. package/_site/_test/requirement-recorder-test.js +173 -173
  102. package/_site/_test/run-all-tests.js +159 -156
  103. package/_site/_test/shared-contracts-test.js +58 -58
  104. package/_site/_test/startup-analysis-disabled-test.js +10 -10
  105. package/_site/_test/storage-foundation-test.js +78 -78
  106. package/_site/_test/structured-logger-test.js +101 -101
  107. package/_site/_test/tracking-start-test.js +109 -108
  108. package/_site/_test/workbench-permission-test.js +117 -117
  109. package/_site/_test/workspace-ui-contract-test.js +28 -43
  110. package/_site/lib/ai-profile-resolver.js +78 -78
  111. package/_site/lib/ai-workspace.js +101 -101
  112. package/_site/lib/automation-config.js +140 -140
  113. package/_site/lib/bridge-adapter.js +1 -0
  114. package/_site/lib/bridge-consumer-service.js +402 -402
  115. package/_site/lib/claude-cli-runner.js +1510 -1510
  116. package/_site/lib/commit-conversation-binder.js +234 -234
  117. package/_site/lib/commit-processing-ledger.js +27 -27
  118. package/_site/lib/commit-prompt.js +202 -202
  119. package/_site/lib/commit-reconciler.js +406 -406
  120. package/_site/lib/contracts.js +284 -279
  121. package/_site/lib/conversation-query-service.js +144 -144
  122. package/_site/lib/conversation-store.js +385 -385
  123. package/_site/lib/data-dir.js +6 -74
  124. package/_site/lib/engine-connection.js +67 -0
  125. package/_site/lib/evidence-bundle.js +228 -228
  126. package/_site/lib/folder-picker-output.js +21 -21
  127. package/_site/lib/git-runner.js +62 -62
  128. package/_site/lib/github-team-store.js +1172 -1172
  129. package/_site/lib/hook-manager.js +203 -202
  130. package/_site/lib/index-service.js +173 -173
  131. package/_site/lib/integration-installer.js +908 -907
  132. package/_site/lib/kb-framework.js +176 -176
  133. package/_site/lib/kb-validator.js +124 -124
  134. package/_site/lib/knowledge-promotion.js +435 -435
  135. package/_site/lib/knowledge-retrieval-service.js +401 -401
  136. package/_site/lib/knowledge-tool-runtime.js +359 -359
  137. package/_site/lib/llm-client.js +161 -161
  138. package/_site/lib/markdown-knowledge-indexer.js +230 -230
  139. package/_site/lib/module-bridge.js +191 -36
  140. package/_site/lib/post-commit-automation.js +87 -87
  141. package/_site/lib/product-data.js +24 -0
  142. package/_site/lib/project-lifecycle-service.js +535 -497
  143. package/_site/lib/project-store.js +268 -262
  144. package/_site/lib/requirement-recorder.js +278 -278
  145. package/_site/lib/runtime-endpoint.js +155 -155
  146. package/_site/lib/scanner.js +298 -298
  147. package/_site/lib/server-app.js +1579 -1520
  148. package/_site/lib/settings-store.js +133 -113
  149. package/_site/lib/storage-layout.js +162 -162
  150. package/_site/lib/structured-logger.js +574 -574
  151. package/_site/scripts/folder-picker.ps1 +156 -156
  152. package/_site/scripts/hook-trigger.js +157 -156
  153. package/_site/scripts/install-module-candidates.js +69 -0
  154. package/_site/scripts/pack-module-candidates.js +72 -0
  155. package/_site/scripts/sync-release-version.js +2 -2
  156. package/_site/scripts/vendor-modules.js +47 -0
  157. package/_site/scripts/verify-module-runtime.js +45 -0
  158. package/_site/scripts/verify-product-runtime.js +34 -0
  159. package/bin/project-knowledge-mcp.js +194 -194
  160. package/docs/README.zh-CN.md +42 -29
  161. package/docs/fresh-v5-start.md +28 -0
  162. package/docs/project-registry-schema.md +22 -22
  163. package/docs/testing-strategy.md +33 -33
  164. package/module-runtime-manifest.json +220 -0
  165. package/package.json +14 -9
  166. package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
  167. package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
  168. package/plugins/project-knowledge/.mcp.json +1 -1
  169. package/plugins/project-knowledge/opencode/project-knowledge.md +3 -3
  170. package/plugins/project-knowledge/skills/project-knowledge/SKILL.md +28 -28
  171. package/templates/project-readme.md +34 -34
  172. package/ui/favicon.svg +38 -38
  173. package/ui/index.html +52 -149
  174. package/ui/product.css +9 -0
  175. package/ui/product.js +412 -0
  176. package/vendor-manifest.json +45 -0
  177. package/_site/_test/data-dir-migration-test.js +0 -77
  178. package/_site/_test/import-ui-flow-test.js +0 -171
  179. package/_site/_test/knowledge-migration-test.js +0 -75
  180. package/_site/_test/legacy-forward-compat-test.js +0 -257
  181. package/_site/_test/legacy-project-upgrade-e2e-test.js +0 -363
  182. package/_site/_test/p0-data-migration-characterization-test.js +0 -37
  183. package/_site/_test/p0-e2e-gate-test.js +0 -347
  184. package/_site/_test/project-control-panel-task14-test.js +0 -63
  185. package/_site/_test/project-layout-v2-migration-test.js +0 -127
  186. package/_site/_test/server-runtime-migration-safety-test.js +0 -27
  187. package/_site/_test/task15-20-ui-flow-test.js +0 -148
  188. package/_site/_test/ui-i18n-toggle-test.js +0 -114
  189. package/_site/_test/ui-smoke-test.js +0 -73
  190. package/_site/_test/v4122-upgrade-data-contract-test.js +0 -56
  191. package/_site/lib/data-state-classifier.js +0 -40
  192. package/_site/lib/legacy-data-manifest.js +0 -26
  193. package/_site/lib/migration-service.js +0 -442
  194. package/ui/app.css +0 -58
  195. package/ui/app.js +0 -607
  196. package/ui/i18n.js +0 -146
@@ -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 fromEnv = options.dataDir === undefined ? process.env.KB_DATA_DIR : options.dataDir;
12
- const homeDir = options.homeDir || os.homedir();
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 (!_resolved) _resolved = resolveDataDirPath(); return _resolved; }
17
- function _resetCache() { _resolved = null; }
18
- function fileDigest(filePath) { return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); }
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
+ };