@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,298 +1,298 @@
1
- const crypto = require('crypto');
2
- const fs = require('fs');
3
- const path = require('path');
4
- const { TextDecoder } = require('util');
5
- const { execGit } = require('./git-runner');
6
- const { DomainError } = require('./contracts');
7
- const { ExactPatchEvidenceBundle } = require('./evidence-bundle');
8
-
9
- const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
10
- const DEFAULT_MAX_PATCH_BYTES = 2 * 1024 * 1024;
11
-
12
- function sha256(value) {
13
- return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
14
- }
15
-
16
- function validateSha(value, name = 'commit') {
17
- const sha = String(value || '').trim().toLowerCase();
18
- if (!/^[0-9a-f]{40}$/.test(sha)) throw new DomainError('INVALID_ARGUMENT', `${name} must be a full Git commit SHA.`);
19
- return sha;
20
- }
21
-
22
- function parseNameStatus(text) {
23
- return String(text || '').split(/\r?\n/).filter(Boolean).map(line => {
24
- const columns = line.split('\t');
25
- const status = columns.shift() || '';
26
- const renamed = /^[RC]/.test(status) && columns.length >= 2;
27
- return {
28
- status,
29
- path: renamed ? columns[1] : columns[0] || '',
30
- oldPath: renamed ? columns[0] : null,
31
- binary: false,
32
- };
33
- });
34
- }
35
-
36
- function applyNumstat(files, text) {
37
- const byPath = new Map(files.map(file => [file.path, file]));
38
- for (const line of String(text || '').split(/\r?\n/).filter(Boolean)) {
39
- const [added, deleted, ...nameParts] = line.split('\t');
40
- const name = nameParts[nameParts.length - 1] || '';
41
- const file = byPath.get(name);
42
- if (!file) continue;
43
- file.binary = added === '-' && deleted === '-';
44
- file.added = added === '-' ? null : Number(added);
45
- file.deleted = deleted === '-' ? null : Number(deleted);
46
- }
47
- return files;
48
- }
49
-
50
- class TrustedGitReader {
51
- constructor(options = {}) {
52
- this.execGit = options.execGit || execGit;
53
- this.maxPatchBytes = Number(options.maxPatchBytes || DEFAULT_MAX_PATCH_BYTES);
54
- this.evidenceBundle = options.evidenceBundle || new ExactPatchEvidenceBundle(options);
55
- }
56
-
57
- async run(repoPath, args, timeoutMs = 30_000) {
58
- const result = await this.execGit(repoPath, args, timeoutMs);
59
- if (!result || !result.ok) {
60
- throw new DomainError('INVALID_ARGUMENT', 'Trusted Git read failed.', {
61
- status: 409,
62
- details: { args: args.slice(0, 4), code: result && result.code, error: String(result && (result.stderr || result.error) || '').slice(0, 1000) },
63
- });
64
- }
65
- return String(result.stdout || '');
66
- }
67
-
68
- async runBuffer(repoPath, args, timeoutMs = 30_000) {
69
- const result = await this.execGit(repoPath, args, timeoutMs);
70
- if (!result || !result.ok) {
71
- throw new DomainError('INVALID_ARGUMENT', 'Trusted Git read failed.', {
72
- status: 409,
73
- details: { args: args.slice(0, 4), code: result && result.code, error: String(result && (result.stderr || result.error) || '').slice(0, 1000) },
74
- });
75
- }
76
- return Buffer.isBuffer(result.stdoutBuffer) ? result.stdoutBuffer : Buffer.from(String(result.stdout || ''), 'utf8');
77
- }
78
-
79
- async isRepository(repoPath) {
80
- if (!repoPath || !fs.existsSync(repoPath)) return false;
81
- const result = await this.execGit(repoPath, ['rev-parse', '--is-inside-work-tree'], 10_000);
82
- return Boolean(result && result.ok && String(result.stdout || '').trim() === 'true');
83
- }
84
-
85
- async head(repoPath) {
86
- const result = await this.execGit(repoPath, ['rev-parse', '--verify', 'HEAD'], 10_000);
87
- if (!result || !result.ok) return null;
88
- const sha = String(result.stdout || '').trim().toLowerCase();
89
- return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
90
- }
91
-
92
- async branch(repoPath) {
93
- const result = await this.execGit(repoPath, ['symbolic-ref', '--quiet', '--short', 'HEAD'], 10_000);
94
- return result && result.ok ? String(result.stdout || '').trim() : '';
95
- }
96
-
97
- async isAncestor(repoPath, ancestor, commit) {
98
- validateSha(ancestor, 'baseline');
99
- validateSha(commit, 'HEAD');
100
- const result = await this.execGit(repoPath, ['merge-base', '--is-ancestor', ancestor, commit], 10_000);
101
- return Boolean(result && result.ok);
102
- }
103
-
104
- async listCommits(repoPath, baseline, head) {
105
- validateSha(head, 'HEAD');
106
- const revision = baseline ? `${validateSha(baseline, 'baseline')}..${head}` : head;
107
- const output = await this.run(repoPath, ['rev-list', '--reverse', '--topo-order', revision]);
108
- return output.split(/\r?\n/).map(value => value.trim().toLowerCase()).filter(value => /^[0-9a-f]{40}$/.test(value));
109
- }
110
-
111
- async collectEvidence(repoPath, commitSha, options = {}) {
112
- const commit = validateSha(commitSha);
113
- const raw = await this.run(repoPath, ['show', '-s', '--date=iso-strict', '--format=%H%x00%P%x00%an%x00%aI%x00%s', commit]);
114
- const [resolved, parentsRaw, author, date, ...subjectParts] = raw.replace(/\r?\n$/, '').split('\u0000');
115
- const parents = String(parentsRaw || '').split(/\s+/).filter(Boolean).map(parent => validateSha(parent, 'parent'));
116
- const nameStatus = await this.run(repoPath, ['diff-tree', '--root', '--find-renames', '--name-status', '-r', '--no-commit-id', commit]);
117
- const numstat = await this.run(repoPath, ['diff-tree', '--root', '--find-renames', '--numstat', '-r', '--no-commit-id', commit]);
118
- const files = applyNumstat(parseNameStatus(nameStatus), numstat);
119
- const base = parents.length ? parents[0] : EMPTY_TREE_SHA;
120
- const patchBuffer = await this.runBuffer(repoPath, ['diff', '--no-ext-diff', '--binary', '--find-renames', '--unified=3', base, commit], options.patchTimeoutMs || 60_000);
121
- const patchBytes = patchBuffer.length;
122
- const maxPatchBytes = Number(options.maxPatchBytes || this.maxPatchBytes);
123
- let patch = null;
124
- try { patch = new TextDecoder('utf-8', { fatal: true }).decode(patchBuffer); } catch { patch = null; }
125
- const patchChunked = patchBytes > maxPatchBytes || patch == null;
126
- const branch = options.branch != null ? String(options.branch) : await this.branch(repoPath);
127
- const metadata = {
128
- commitSha: resolved || commit,
129
- parents,
130
- author: author || '',
131
- date: date || '',
132
- subject: subjectParts.join('\u0000').trim(),
133
- branch,
134
- patchBase: base,
135
- patchMode: parents.length > 1 ? 'merge-first-parent' : parents.length === 0 ? 'root-empty-tree' : 'parent',
136
- };
137
- const patchHash = sha256(patchBuffer);
138
- if (patchChunked && !options.evidenceRoot) {
139
- throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Large patch requires an exact evidence bundle root.', { status: 500 });
140
- }
141
- const evidenceBundle = options.evidenceRoot ? this.evidenceBundle.build({
142
- evidenceRoot: options.evidenceRoot,
143
- patch: patchBuffer,
144
- patchHash,
145
- commit: metadata,
146
- files,
147
- }) : null;
148
- const evidenceHash = sha256(JSON.stringify({
149
- metadata,
150
- files,
151
- patchHash,
152
- patchBytes,
153
- evidenceManifestHash: evidenceBundle && evidenceBundle.manifestHash || null,
154
- }));
155
- return {
156
- schema: 'commit-evidence/v1',
157
- ...metadata,
158
- files,
159
- patch: patchChunked ? null : patch,
160
- patchHash,
161
- patchBytes,
162
- patchInline: !patchChunked,
163
- patchChunked,
164
- patchOmitted: false,
165
- patchLimitBytes: maxPatchBytes,
166
- omittedReason: null,
167
- evidenceBundle,
168
- evidenceHash,
169
- };
170
- }
171
-
172
- verifyEvidence(evidence) {
173
- if (!evidence || !evidence.patchHash || !Number.isInteger(evidence.patchBytes)) {
174
- throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Commit evidence is incomplete.', { status: 500 });
175
- }
176
- if (evidence.evidenceBundle) {
177
- return this.evidenceBundle.verify(evidence.evidenceBundle.root, {
178
- expectedPatchHash: evidence.patchHash,
179
- expectedBytes: evidence.patchBytes,
180
- });
181
- }
182
- if (evidence.patchChunked) {
183
- throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Chunked patch has no frozen evidence bundle.', { status: 500 });
184
- }
185
- if (sha256(evidence.patch || '') !== evidence.patchHash) {
186
- throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Inline patch hash does not match.', { status: 500 });
187
- }
188
- return null;
189
- }
190
- }
191
-
192
- class CommitScanner {
193
- constructor(options = {}) {
194
- this.git = options.gitReader || new TrustedGitReader(options);
195
- this.batchSize = Math.max(1, Number(options.batchSize || 200));
196
- }
197
-
198
- async scan(config, state, options = {}) {
199
- const repoPath = config && config.repoPath;
200
- if (!repoPath || !await this.git.isRepository(repoPath)) {
201
- return { status: 'not-git', head: null, baseline: null, commits: [], continuation: null, error: 'repoPath is not an available Git worktree' };
202
- }
203
- const head = await this.git.head(repoPath);
204
- if (!head) {
205
- return { status: 'empty-repo', head: null, baseline: null, commits: [], continuation: null, branch: await this.git.branch(repoPath) };
206
- }
207
- const branch = await this.git.branch(repoPath);
208
- const last = state && state.lastAnalyzedCommit || null;
209
- const tracking = state && state.trackingStartCommit || null;
210
- const baseline = last || tracking || null;
211
- if (!baseline && (!state || state.trackingMode !== 'empty-repo')) {
212
- return { status: 'establish-tracking', head, baseline: null, commits: [], continuation: null, branch };
213
- }
214
- if (baseline && !await this.git.isAncestor(repoPath, baseline, head)) {
215
- return { status: 'history-diverged', head, baseline, commits: [], continuation: null, branch, error: 'tracking baseline is not an ancestor of HEAD' };
216
- }
217
- const all = await this.git.listCommits(repoPath, baseline, head);
218
- const batchSize = Math.max(1, Number(options.batchSize || this.batchSize));
219
- const commits = all.slice(0, batchSize);
220
- return {
221
- status: 'ok',
222
- head,
223
- baseline,
224
- branch,
225
- commits,
226
- pendingCount: all.length,
227
- continuation: all.length > commits.length ? { remaining: all.length - commits.length, after: commits[commits.length - 1] } : null,
228
- };
229
- }
230
-
231
- collectEvidence(config, commitSha, options = {}) {
232
- return this.git.collectEvidence(config.repoPath, commitSha, options);
233
- }
234
-
235
- verifyEvidence(evidence) {
236
- return this.git.verifyEvidence(evidence);
237
- }
238
- }
239
-
240
- // Transitional read-only adapter for callers migrated in T10.
241
- async function scanProject(project, options = {}) {
242
- const scanner = options.scanner || new CommitScanner(options);
243
- const config = { repoPath: project && (project.repoPath || project.gitPath || project.localPath) };
244
- const state = {
245
- lastAnalyzedCommit: project && project.lastAnalyzedCommit || null,
246
- trackingStartCommit: project && project.trackingStartCommit || null,
247
- trackingMode: project && project.trackingMode || 'normal',
248
- };
249
- const scan = await scanner.scan(config, state, options);
250
- const result = {
251
- slug: project && (project.projectId || project.slug),
252
- repoStatus: scan.status === 'empty-repo' ? 'empty' : scan.status === 'not-git' ? 'not-git' : scan.status === 'history-diverged' ? 'diverged' : 'ok',
253
- headCommit: scan.head,
254
- lastAnalyzedCommit: state.lastAnalyzedCommit,
255
- trackingStartCommit: scan.status === 'establish-tracking' ? scan.head : state.trackingStartCommit,
256
- pendingCount: scan.commits.length,
257
- mode: scan.status === 'establish-tracking' ? 'tracking-start' : state.lastAnalyzedCommit ? 'incremental' : state.trackingMode === 'empty-repo' ? 'empty-repo-first-commit' : 'tracked',
258
- range: scan.baseline && scan.head ? `${scan.baseline}..${scan.head}` : null,
259
- commits: [],
260
- continuation: scan.continuation,
261
- error: scan.error || null,
262
- };
263
- for (const commitSha of scan.commits) {
264
- const evidence = await scanner.collectEvidence(config, commitSha, { branch: scan.branch });
265
- result.commits.push({
266
- hash: commitSha,
267
- short: commitSha.slice(0, 7),
268
- date: evidence.date,
269
- author: evidence.author,
270
- subject: evidence.subject,
271
- });
272
- }
273
- return result;
274
- }
275
-
276
- async function applyScanResult(project, scan) {
277
- project.headCommit = scan.headCommit;
278
- project.repoStatus = scan.repoStatus;
279
- if (!project.trackingStartCommit && scan.trackingStartCommit) project.trackingStartCommit = scan.trackingStartCommit;
280
- project.lastSeenCommit = scan.headCommit || project.lastSeenCommit;
281
- project.lastScanAt = new Date().toISOString();
282
- project.lastScanPendingCount = scan.pendingCount;
283
- project.lastScanMode = scan.mode;
284
- project.lastScanError = scan.error || null;
285
- return project;
286
- }
287
-
288
- module.exports = {
289
- EMPTY_TREE_SHA,
290
- DEFAULT_MAX_PATCH_BYTES,
291
- TrustedGitReader,
292
- CommitScanner,
293
- scanProject,
294
- applyScanResult,
295
- parseNameStatus,
296
- sha256,
297
- validateSha,
298
- };
1
+ const crypto = require('crypto');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { TextDecoder } = require('util');
5
+ const { execGit } = require('./git-runner');
6
+ const { DomainError } = require('./contracts');
7
+ const { ExactPatchEvidenceBundle } = require('./evidence-bundle');
8
+
9
+ const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
10
+ const DEFAULT_MAX_PATCH_BYTES = 2 * 1024 * 1024;
11
+
12
+ function sha256(value) {
13
+ return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
14
+ }
15
+
16
+ function validateSha(value, name = 'commit') {
17
+ const sha = String(value || '').trim().toLowerCase();
18
+ if (!/^[0-9a-f]{40}$/.test(sha)) throw new DomainError('INVALID_ARGUMENT', `${name} must be a full Git commit SHA.`);
19
+ return sha;
20
+ }
21
+
22
+ function parseNameStatus(text) {
23
+ return String(text || '').split(/\r?\n/).filter(Boolean).map(line => {
24
+ const columns = line.split('\t');
25
+ const status = columns.shift() || '';
26
+ const renamed = /^[RC]/.test(status) && columns.length >= 2;
27
+ return {
28
+ status,
29
+ path: renamed ? columns[1] : columns[0] || '',
30
+ oldPath: renamed ? columns[0] : null,
31
+ binary: false,
32
+ };
33
+ });
34
+ }
35
+
36
+ function applyNumstat(files, text) {
37
+ const byPath = new Map(files.map(file => [file.path, file]));
38
+ for (const line of String(text || '').split(/\r?\n/).filter(Boolean)) {
39
+ const [added, deleted, ...nameParts] = line.split('\t');
40
+ const name = nameParts[nameParts.length - 1] || '';
41
+ const file = byPath.get(name);
42
+ if (!file) continue;
43
+ file.binary = added === '-' && deleted === '-';
44
+ file.added = added === '-' ? null : Number(added);
45
+ file.deleted = deleted === '-' ? null : Number(deleted);
46
+ }
47
+ return files;
48
+ }
49
+
50
+ class TrustedGitReader {
51
+ constructor(options = {}) {
52
+ this.execGit = options.execGit || execGit;
53
+ this.maxPatchBytes = Number(options.maxPatchBytes || DEFAULT_MAX_PATCH_BYTES);
54
+ this.evidenceBundle = options.evidenceBundle || new ExactPatchEvidenceBundle(options);
55
+ }
56
+
57
+ async run(repoPath, args, timeoutMs = 30_000) {
58
+ const result = await this.execGit(repoPath, args, timeoutMs);
59
+ if (!result || !result.ok) {
60
+ throw new DomainError('INVALID_ARGUMENT', 'Trusted Git read failed.', {
61
+ status: 409,
62
+ details: { args: args.slice(0, 4), code: result && result.code, error: String(result && (result.stderr || result.error) || '').slice(0, 1000) },
63
+ });
64
+ }
65
+ return String(result.stdout || '');
66
+ }
67
+
68
+ async runBuffer(repoPath, args, timeoutMs = 30_000) {
69
+ const result = await this.execGit(repoPath, args, timeoutMs);
70
+ if (!result || !result.ok) {
71
+ throw new DomainError('INVALID_ARGUMENT', 'Trusted Git read failed.', {
72
+ status: 409,
73
+ details: { args: args.slice(0, 4), code: result && result.code, error: String(result && (result.stderr || result.error) || '').slice(0, 1000) },
74
+ });
75
+ }
76
+ return Buffer.isBuffer(result.stdoutBuffer) ? result.stdoutBuffer : Buffer.from(String(result.stdout || ''), 'utf8');
77
+ }
78
+
79
+ async isRepository(repoPath) {
80
+ if (!repoPath || !fs.existsSync(repoPath)) return false;
81
+ const result = await this.execGit(repoPath, ['rev-parse', '--is-inside-work-tree'], 10_000);
82
+ return Boolean(result && result.ok && String(result.stdout || '').trim() === 'true');
83
+ }
84
+
85
+ async head(repoPath) {
86
+ const result = await this.execGit(repoPath, ['rev-parse', '--verify', 'HEAD'], 10_000);
87
+ if (!result || !result.ok) return null;
88
+ const sha = String(result.stdout || '').trim().toLowerCase();
89
+ return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
90
+ }
91
+
92
+ async branch(repoPath) {
93
+ const result = await this.execGit(repoPath, ['symbolic-ref', '--quiet', '--short', 'HEAD'], 10_000);
94
+ return result && result.ok ? String(result.stdout || '').trim() : '';
95
+ }
96
+
97
+ async isAncestor(repoPath, ancestor, commit) {
98
+ validateSha(ancestor, 'baseline');
99
+ validateSha(commit, 'HEAD');
100
+ const result = await this.execGit(repoPath, ['merge-base', '--is-ancestor', ancestor, commit], 10_000);
101
+ return Boolean(result && result.ok);
102
+ }
103
+
104
+ async listCommits(repoPath, baseline, head) {
105
+ validateSha(head, 'HEAD');
106
+ const revision = baseline ? `${validateSha(baseline, 'baseline')}..${head}` : head;
107
+ const output = await this.run(repoPath, ['rev-list', '--reverse', '--topo-order', revision]);
108
+ return output.split(/\r?\n/).map(value => value.trim().toLowerCase()).filter(value => /^[0-9a-f]{40}$/.test(value));
109
+ }
110
+
111
+ async collectEvidence(repoPath, commitSha, options = {}) {
112
+ const commit = validateSha(commitSha);
113
+ const raw = await this.run(repoPath, ['show', '-s', '--date=iso-strict', '--format=%H%x00%P%x00%an%x00%aI%x00%s', commit]);
114
+ const [resolved, parentsRaw, author, date, ...subjectParts] = raw.replace(/\r?\n$/, '').split('\u0000');
115
+ const parents = String(parentsRaw || '').split(/\s+/).filter(Boolean).map(parent => validateSha(parent, 'parent'));
116
+ const nameStatus = await this.run(repoPath, ['diff-tree', '--root', '--find-renames', '--name-status', '-r', '--no-commit-id', commit]);
117
+ const numstat = await this.run(repoPath, ['diff-tree', '--root', '--find-renames', '--numstat', '-r', '--no-commit-id', commit]);
118
+ const files = applyNumstat(parseNameStatus(nameStatus), numstat);
119
+ const base = parents.length ? parents[0] : EMPTY_TREE_SHA;
120
+ const patchBuffer = await this.runBuffer(repoPath, ['diff', '--no-ext-diff', '--binary', '--find-renames', '--unified=3', base, commit], options.patchTimeoutMs || 60_000);
121
+ const patchBytes = patchBuffer.length;
122
+ const maxPatchBytes = Number(options.maxPatchBytes || this.maxPatchBytes);
123
+ let patch = null;
124
+ try { patch = new TextDecoder('utf-8', { fatal: true }).decode(patchBuffer); } catch { patch = null; }
125
+ const patchChunked = patchBytes > maxPatchBytes || patch == null;
126
+ const branch = options.branch != null ? String(options.branch) : await this.branch(repoPath);
127
+ const metadata = {
128
+ commitSha: resolved || commit,
129
+ parents,
130
+ author: author || '',
131
+ date: date || '',
132
+ subject: subjectParts.join('\u0000').trim(),
133
+ branch,
134
+ patchBase: base,
135
+ patchMode: parents.length > 1 ? 'merge-first-parent' : parents.length === 0 ? 'root-empty-tree' : 'parent',
136
+ };
137
+ const patchHash = sha256(patchBuffer);
138
+ if (patchChunked && !options.evidenceRoot) {
139
+ throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Large patch requires an exact evidence bundle root.', { status: 500 });
140
+ }
141
+ const evidenceBundle = options.evidenceRoot ? this.evidenceBundle.build({
142
+ evidenceRoot: options.evidenceRoot,
143
+ patch: patchBuffer,
144
+ patchHash,
145
+ commit: metadata,
146
+ files,
147
+ }) : null;
148
+ const evidenceHash = sha256(JSON.stringify({
149
+ metadata,
150
+ files,
151
+ patchHash,
152
+ patchBytes,
153
+ evidenceManifestHash: evidenceBundle && evidenceBundle.manifestHash || null,
154
+ }));
155
+ return {
156
+ schema: 'commit-evidence/v1',
157
+ ...metadata,
158
+ files,
159
+ patch: patchChunked ? null : patch,
160
+ patchHash,
161
+ patchBytes,
162
+ patchInline: !patchChunked,
163
+ patchChunked,
164
+ patchOmitted: false,
165
+ patchLimitBytes: maxPatchBytes,
166
+ omittedReason: null,
167
+ evidenceBundle,
168
+ evidenceHash,
169
+ };
170
+ }
171
+
172
+ verifyEvidence(evidence) {
173
+ if (!evidence || !evidence.patchHash || !Number.isInteger(evidence.patchBytes)) {
174
+ throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Commit evidence is incomplete.', { status: 500 });
175
+ }
176
+ if (evidence.evidenceBundle) {
177
+ return this.evidenceBundle.verify(evidence.evidenceBundle.root, {
178
+ expectedPatchHash: evidence.patchHash,
179
+ expectedBytes: evidence.patchBytes,
180
+ });
181
+ }
182
+ if (evidence.patchChunked) {
183
+ throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Chunked patch has no frozen evidence bundle.', { status: 500 });
184
+ }
185
+ if (sha256(evidence.patch || '') !== evidence.patchHash) {
186
+ throw new DomainError('EVIDENCE_INTEGRITY_FAILED', 'Inline patch hash does not match.', { status: 500 });
187
+ }
188
+ return null;
189
+ }
190
+ }
191
+
192
+ class CommitScanner {
193
+ constructor(options = {}) {
194
+ this.git = options.gitReader || new TrustedGitReader(options);
195
+ this.batchSize = Math.max(1, Number(options.batchSize || 200));
196
+ }
197
+
198
+ async scan(config, state, options = {}) {
199
+ const repoPath = config && config.repoPath;
200
+ if (!repoPath || !await this.git.isRepository(repoPath)) {
201
+ return { status: 'not-git', head: null, baseline: null, commits: [], continuation: null, error: 'repoPath is not an available Git worktree' };
202
+ }
203
+ const head = await this.git.head(repoPath);
204
+ if (!head) {
205
+ return { status: 'empty-repo', head: null, baseline: null, commits: [], continuation: null, branch: await this.git.branch(repoPath) };
206
+ }
207
+ const branch = await this.git.branch(repoPath);
208
+ const last = state && state.lastAnalyzedCommit || null;
209
+ const tracking = state && state.trackingStartCommit || null;
210
+ const baseline = last || tracking || null;
211
+ if (!baseline && (!state || state.trackingMode !== 'empty-repo')) {
212
+ return { status: 'establish-tracking', head, baseline: null, commits: [], continuation: null, branch };
213
+ }
214
+ if (baseline && !await this.git.isAncestor(repoPath, baseline, head)) {
215
+ return { status: 'history-diverged', head, baseline, commits: [], continuation: null, branch, error: 'tracking baseline is not an ancestor of HEAD' };
216
+ }
217
+ const all = await this.git.listCommits(repoPath, baseline, head);
218
+ const batchSize = Math.max(1, Number(options.batchSize || this.batchSize));
219
+ const commits = all.slice(0, batchSize);
220
+ return {
221
+ status: 'ok',
222
+ head,
223
+ baseline,
224
+ branch,
225
+ commits,
226
+ pendingCount: all.length,
227
+ continuation: all.length > commits.length ? { remaining: all.length - commits.length, after: commits[commits.length - 1] } : null,
228
+ };
229
+ }
230
+
231
+ collectEvidence(config, commitSha, options = {}) {
232
+ return this.git.collectEvidence(config.repoPath, commitSha, options);
233
+ }
234
+
235
+ verifyEvidence(evidence) {
236
+ return this.git.verifyEvidence(evidence);
237
+ }
238
+ }
239
+
240
+ // Transitional read-only adapter for callers migrated in T10.
241
+ async function scanProject(project, options = {}) {
242
+ const scanner = options.scanner || new CommitScanner(options);
243
+ const config = { repoPath: project && (project.repoPath || project.gitPath || project.localPath) };
244
+ const state = {
245
+ lastAnalyzedCommit: project && project.lastAnalyzedCommit || null,
246
+ trackingStartCommit: project && project.trackingStartCommit || null,
247
+ trackingMode: project && project.trackingMode || 'normal',
248
+ };
249
+ const scan = await scanner.scan(config, state, options);
250
+ const result = {
251
+ slug: project && (project.projectId || project.slug),
252
+ repoStatus: scan.status === 'empty-repo' ? 'empty' : scan.status === 'not-git' ? 'not-git' : scan.status === 'history-diverged' ? 'diverged' : 'ok',
253
+ headCommit: scan.head,
254
+ lastAnalyzedCommit: state.lastAnalyzedCommit,
255
+ trackingStartCommit: scan.status === 'establish-tracking' ? scan.head : state.trackingStartCommit,
256
+ pendingCount: scan.commits.length,
257
+ mode: scan.status === 'establish-tracking' ? 'tracking-start' : state.lastAnalyzedCommit ? 'incremental' : state.trackingMode === 'empty-repo' ? 'empty-repo-first-commit' : 'tracked',
258
+ range: scan.baseline && scan.head ? `${scan.baseline}..${scan.head}` : null,
259
+ commits: [],
260
+ continuation: scan.continuation,
261
+ error: scan.error || null,
262
+ };
263
+ for (const commitSha of scan.commits) {
264
+ const evidence = await scanner.collectEvidence(config, commitSha, { branch: scan.branch });
265
+ result.commits.push({
266
+ hash: commitSha,
267
+ short: commitSha.slice(0, 7),
268
+ date: evidence.date,
269
+ author: evidence.author,
270
+ subject: evidence.subject,
271
+ });
272
+ }
273
+ return result;
274
+ }
275
+
276
+ async function applyScanResult(project, scan) {
277
+ project.headCommit = scan.headCommit;
278
+ project.repoStatus = scan.repoStatus;
279
+ if (!project.trackingStartCommit && scan.trackingStartCommit) project.trackingStartCommit = scan.trackingStartCommit;
280
+ project.lastSeenCommit = scan.headCommit || project.lastSeenCommit;
281
+ project.lastScanAt = new Date().toISOString();
282
+ project.lastScanPendingCount = scan.pendingCount;
283
+ project.lastScanMode = scan.mode;
284
+ project.lastScanError = scan.error || null;
285
+ return project;
286
+ }
287
+
288
+ module.exports = {
289
+ EMPTY_TREE_SHA,
290
+ DEFAULT_MAX_PATCH_BYTES,
291
+ TrustedGitReader,
292
+ CommitScanner,
293
+ scanProject,
294
+ applyScanResult,
295
+ parseNameStatus,
296
+ sha256,
297
+ validateSha,
298
+ };