@quolu/lattice 0.52.0 → 0.52.2

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 (182) hide show
  1. package/LICENSE +147 -147
  2. package/README.ja.md +355 -355
  3. package/README.md +258 -258
  4. package/bin/lattice-mcp.mjs +0 -0
  5. package/bin/lattice-scripted-adapter.mjs +0 -0
  6. package/bin/lattice-scripted-worker.mjs +0 -0
  7. package/bin/lattice-work-order-adapter.mjs +0 -0
  8. package/bin/lattice.mjs +0 -0
  9. package/docs/bridge-setup.md +132 -132
  10. package/docs/schemas/lattice.executor_packet.v1.schema.json +57 -57
  11. package/docs/schemas/lattice.executor_receipt.v1.schema.json +66 -66
  12. package/docs/schemas/lattice.phase_todo_revision.v3.schema.json +360 -360
  13. package/docs/schemas/lattice.plan_create_input.v1.schema.json +56 -56
  14. package/docs/schemas/lattice.plan_create_input.v2.schema.json +72 -72
  15. package/docs/schemas/lattice.plan_create_input.v3.schema.json +81 -81
  16. package/docs/schemas/lattice.plan_create_input.v4.schema.json +85 -85
  17. package/docs/schemas/lattice.run_request.v1.schema.json +238 -238
  18. package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -55
  19. package/docs/schemas/lattice.runtime_adapter_registration_input.v1.schema.json +78 -78
  20. package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -86
  21. package/docs/schemas/lattice.todo_extraction.v2.schema.json +298 -298
  22. package/docs/schemas/lattice.todo_extraction.v3.schema.json +146 -146
  23. package/docs/schemas/lattice.todo_revision.v2.schema.json +260 -260
  24. package/docs/schemas/lattice.todo_revision_set.v3.schema.json +363 -363
  25. package/package.json +103 -103
  26. package/sensor/LICENSE +21 -21
  27. package/sensor/NOTICE +19 -19
  28. package/sensor/dist/bin/lattice-sensor.js +9 -9
  29. package/sensor/dist/db/index.js +24 -24
  30. package/sensor/dist/db/migrations.js +41 -41
  31. package/sensor/dist/db/queries.js +164 -164
  32. package/sensor/dist/db/schema.sql +205 -205
  33. package/sensor/dist/directory.js +5 -5
  34. package/sensor/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
  35. package/sensor/dist/extraction/wasm/tree-sitter-cfml.wasm +0 -0
  36. package/sensor/dist/extraction/wasm/tree-sitter-cfquery.wasm +0 -0
  37. package/sensor/dist/extraction/wasm/tree-sitter-cfscript.wasm +0 -0
  38. package/sensor/dist/extraction/wasm/tree-sitter-cobol.wasm +0 -0
  39. package/sensor/dist/extraction/wasm/tree-sitter-erlang.wasm +0 -0
  40. package/sensor/dist/extraction/wasm/tree-sitter-go.wasm +0 -0
  41. package/sensor/dist/extraction/wasm/tree-sitter-java.wasm +0 -0
  42. package/sensor/dist/extraction/wasm/tree-sitter-javascript.wasm +0 -0
  43. package/sensor/dist/extraction/wasm/tree-sitter-nix.wasm +0 -0
  44. package/sensor/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
  45. package/sensor/dist/extraction/wasm/tree-sitter-python.wasm +0 -0
  46. package/sensor/dist/extraction/wasm/tree-sitter-tsx.wasm +0 -0
  47. package/sensor/dist/extraction/wasm/tree-sitter-typescript.wasm +0 -0
  48. package/sensor/dist/extraction/wasm/tree-sitter-vbnet.wasm +0 -0
  49. package/sensor/dist/mcp/liveness-watchdog.js +53 -53
  50. package/sensor/dist/mcp/server-instructions.js +95 -95
  51. package/sensor/package.json +56 -56
  52. package/src/artifact-contracts-v2.mjs +325 -325
  53. package/src/artifact-contracts.mjs +895 -895
  54. package/src/boundary-compiler.mjs +712 -712
  55. package/src/boundary-observation-compiler-v2.mjs +344 -344
  56. package/src/bounded-seam.mjs +230 -230
  57. package/src/bridge-address.mjs +107 -107
  58. package/src/bridge-cli.mjs +297 -297
  59. package/src/bridge-config.mjs +346 -346
  60. package/src/bridge-daemon.mjs +378 -378
  61. package/src/bridge-launch-agent.mjs +323 -323
  62. package/src/bridge-registrar.mjs +102 -102
  63. package/src/bridge-server.mjs +376 -376
  64. package/src/cli-help.mjs +308 -308
  65. package/src/cli-stdio.mjs +40 -40
  66. package/src/control-compiler.mjs +532 -532
  67. package/src/dag-chain.mjs +261 -261
  68. package/src/factory-diagnostics.mjs +188 -188
  69. package/src/hash-chain.mjs +76 -76
  70. package/src/hooks-cli.mjs +1057 -1053
  71. package/src/isolation-runner.mjs +409 -409
  72. package/src/node-version-guard.mjs +44 -44
  73. package/src/project-cli.mjs +697 -697
  74. package/src/project-identity.mjs +130 -130
  75. package/src/rc1-black-box-oracle.mjs +906 -906
  76. package/src/rc1-comparison.mjs +154 -154
  77. package/src/rc1-evidence-bundle.mjs +456 -456
  78. package/src/rc1-v4-campaign.mjs +708 -708
  79. package/src/rc1-v4-transform.mjs +467 -467
  80. package/src/rc1-v5-artifact-set.mjs +855 -855
  81. package/src/rc1-v5-behavior-evidence.mjs +594 -594
  82. package/src/rc1-v5-campaign.mjs +797 -797
  83. package/src/rc1-v5-transform.mjs +421 -421
  84. package/src/rc1-v6-artifact-set.mjs +807 -807
  85. package/src/rc1-v6-behavior-evidence.mjs +273 -273
  86. package/src/rc1-v6-campaign.mjs +623 -623
  87. package/src/rc1-v6-causal-binding.mjs +473 -473
  88. package/src/rc1-v6-measurement.mjs +313 -313
  89. package/src/rc2-artifact-set.mjs +1584 -1584
  90. package/src/rc2-campaign.mjs +1506 -1502
  91. package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
  92. package/src/rc2-delivery-policy-oracle.mjs +134 -134
  93. package/src/rc2-delivery-policy-transform.mjs +1127 -1127
  94. package/src/rc2-rc1-transfer-front-end.mjs +511 -511
  95. package/src/rc3-actual-dogfood.mjs +652 -652
  96. package/src/rc3-dogfood-scaffold.mjs +315 -315
  97. package/src/rc3-scripted-campaign.mjs +1383 -1383
  98. package/src/rc4-stage1-dogfood.mjs +673 -673
  99. package/src/runtime-adapter-registry.mjs +524 -520
  100. package/src/runtime-cli.mjs +4588 -4584
  101. package/src/runtime-contracts.mjs +824 -824
  102. package/src/runtime-control-store.mjs +604 -600
  103. package/src/runtime-controller-protocol.mjs +587 -587
  104. package/src/runtime-decision-verifier.mjs +701 -701
  105. package/src/runtime-diff-observer.mjs +361 -361
  106. package/src/runtime-direct-os-observer.mjs +301 -301
  107. package/src/runtime-driver-state.mjs +166 -162
  108. package/src/runtime-engine.mjs +779 -779
  109. package/src/runtime-errors.mjs +356 -356
  110. package/src/runtime-event-store.mjs +189 -189
  111. package/src/runtime-front-end.mjs +925 -925
  112. package/src/runtime-gate-store.mjs +481 -477
  113. package/src/runtime-hold-recompile.mjs +916 -916
  114. package/src/runtime-io-sentinel.mjs +391 -391
  115. package/src/runtime-lifecycle-lock.mjs +294 -290
  116. package/src/runtime-managed-supervisor.mjs +1490 -1490
  117. package/src/runtime-multi-epoch-store.mjs +838 -834
  118. package/src/runtime-projection.mjs +269 -269
  119. package/src/runtime-pull-intake.mjs +1192 -1188
  120. package/src/runtime-scripted-adapter-controller.mjs +1160 -1156
  121. package/src/runtime-scripted-executor.mjs +163 -163
  122. package/src/runtime-scripted-worktree.mjs +104 -104
  123. package/src/runtime-seam-resolve.mjs +428 -428
  124. package/src/runtime-seam-treatment.mjs +173 -173
  125. package/src/runtime-socket-owner.mjs +125 -125
  126. package/src/runtime-work-order-contracts.mjs +91 -91
  127. package/src/runtime-work-order-controller.mjs +1171 -1167
  128. package/src/runtime-worktree-executor.mjs +199 -199
  129. package/src/schedulability-compiler-v2.mjs +303 -303
  130. package/src/schedulability-verifier-v2.mjs +317 -317
  131. package/src/seam-apply.mjs +549 -549
  132. package/src/seam-commit-shared.mjs +22 -22
  133. package/src/seam-commit-transform.mjs +81 -81
  134. package/src/seam-commit.mjs +18 -18
  135. package/src/seam-cost.mjs +322 -322
  136. package/src/seam-derivation.mjs +188 -188
  137. package/src/seam-gate.mjs +146 -146
  138. package/src/seam-proposal-contracts.mjs +446 -446
  139. package/src/seam-proposal-queries.mjs +521 -521
  140. package/src/seam-proposal.mjs +2011 -2011
  141. package/src/seam-ref.mjs +33 -33
  142. package/src/seam-rewrite.mjs +286 -286
  143. package/src/seam-transform.mjs +554 -554
  144. package/src/seam-verification.mjs +260 -260
  145. package/src/sensor-adapter.mjs +432 -432
  146. package/src/sensor-cli.mjs +139 -139
  147. package/src/sensor-diff.mjs +661 -661
  148. package/src/sensor-node-runtime.mjs +53 -53
  149. package/src/sensor-runtime.mjs +52 -52
  150. package/src/timestamp-contract.mjs +8 -8
  151. package/src/todo-audit-pending.mjs +91 -91
  152. package/src/todo-chain.mjs +178 -178
  153. package/src/todo-cli.mjs +3141 -3126
  154. package/src/todo-contracts.mjs +728 -728
  155. package/src/todo-dashboard-registry.mjs +573 -573
  156. package/src/todo-dispatch-shape.mjs +190 -190
  157. package/src/todo-gantt-html-independence.mjs +239 -239
  158. package/src/todo-gantt-html-shared.mjs +226 -226
  159. package/src/todo-gantt-html-style.mjs +131 -131
  160. package/src/todo-gantt-html.mjs +248 -248
  161. package/src/todo-gantt-layout.mjs +974 -974
  162. package/src/todo-gantt-live.mjs +361 -361
  163. package/src/todo-gantt-nested.mjs +263 -263
  164. package/src/todo-gantt-presentation.mjs +217 -217
  165. package/src/todo-gantt-scope.mjs +123 -123
  166. package/src/todo-gantt-svg.mjs +353 -353
  167. package/src/todo-independence-contracts.mjs +595 -595
  168. package/src/todo-independence-guidance.mjs +322 -322
  169. package/src/todo-independence.mjs +640 -640
  170. package/src/todo-markdown-renderer.mjs +260 -260
  171. package/src/todo-migration.mjs +448 -448
  172. package/src/todo-narrative-anchor.mjs +130 -130
  173. package/src/todo-note-store.mjs +629 -625
  174. package/src/todo-parallel-candidates.mjs +114 -114
  175. package/src/todo-revision.mjs +995 -995
  176. package/src/todo-split.mjs +472 -472
  177. package/src/todo-status.mjs +690 -690
  178. package/src/todo-store-git-transaction.mjs +418 -418
  179. package/src/todo-store.mjs +4322 -4314
  180. package/src/treatment-compiler.mjs +728 -728
  181. package/src/treatment-runner.mjs +656 -656
  182. package/src/witness-scaffold.mjs +180 -180
@@ -1,361 +1,361 @@
1
- import { spawn } from 'node:child_process';
2
- import { createHash } from 'node:crypto';
3
- import { lstat, readFile } from 'node:fs/promises';
4
- import path from 'node:path';
5
-
6
- import { digestArtifact } from './artifact-contracts.mjs';
7
-
8
- /**
9
- * RC3-F diff observer(ADR 0044 Decision 5、plan RC3-F)。
10
- *
11
- * disposable worktreeの実diffを、bounded canonical checkpoint recordへ変換する。
12
- * git diffがwriteの一次sensor(plan「path/resource binding」)であり、executor
13
- * 自己申告を診断入力にしない。observed pathはdeclared scope/他TODOのdeclared write
14
- * へcross-bindされ、宣言外writeと運転中overlapを別findingで検出する。
15
- *
16
- * - symlink・submodule・special fileの変更はfail closed(isolation-runner規律の継承)。
17
- * - 過大diff(entry数・byte数)は黙って切り詰めずreject。
18
- * - commit・branch作成等の禁止操作はHEAD driftとして検出しfail loudする。
19
- */
20
-
21
- const MAX_DIFF_ENTRIES = 256;
22
- const MAX_TRACKED_FILE_BYTES = 4_194_304;
23
- const GIT_SHA1 = /^[0-9a-f]{40}$/;
24
- const LINE_ID = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/;
25
-
26
- function fail(reason) {
27
- throw new TypeError(`diff observer契約違反: ${reason}`);
28
- }
29
-
30
- function plainRecord(value) {
31
- return value !== null
32
- && typeof value === 'object'
33
- && !Array.isArray(value)
34
- && Object.getPrototypeOf(value) === Object.prototype;
35
- }
36
-
37
- function exactRecord(value, keys) {
38
- if (!plainRecord(value)) return false;
39
- const actual = Object.keys(value).sort();
40
- const expected = [...keys].sort();
41
- return actual.length === expected.length
42
- && actual.every((key, index) => key === expected[index]);
43
- }
44
-
45
- function sha256(bytes) {
46
- return createHash('sha256').update(bytes).digest('hex');
47
- }
48
-
49
- function run(command, args, cwd, { allowExitCodes = [0] } = {}) {
50
- return new Promise((resolve, reject) => {
51
- const child = spawn(command, args, { cwd, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
52
- const stdout = [];
53
- const stderr = [];
54
- child.stdout.on('data', (chunk) => stdout.push(chunk));
55
- child.stderr.on('data', (chunk) => stderr.push(chunk));
56
- child.once('error', reject);
57
- child.once('close', (code, signal) => {
58
- if (allowExitCodes.includes(code) && signal === null) {
59
- resolve(Object.assign(Buffer.concat(stdout), { code }));
60
- }
61
- else {
62
- reject(new TypeError(
63
- `${command} ${args[0]} failed (${signal ?? code}): ${Buffer.concat(stderr).toString('utf8').trim()}`,
64
- ));
65
- }
66
- });
67
- });
68
- }
69
-
70
- const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
71
- const MAX_PATH_BYTES = 1_024;
72
-
73
- // receipt契約(runtime-contracts repoRelativePath)と同じbyte-safe規律を使う。
74
- function safeRelativePath(value) {
75
- return typeof value === 'string'
76
- && value.length > 0
77
- && Buffer.byteLength(value, 'utf8') <= MAX_PATH_BYTES
78
- && !CONTROL_CHARACTER.test(value)
79
- && !value.includes('\\')
80
- && !path.posix.isAbsolute(value)
81
- && value === path.posix.normalize(value)
82
- && !value.split('/').includes('..');
83
- }
84
-
85
- function statusEntries(statusBytes) {
86
- const fields = statusBytes.toString('utf8').split('\0');
87
- const entries = [];
88
- for (let index = 0; index < fields.length - 1; index += 1) {
89
- const field = fields[index];
90
- if (!field) continue;
91
- const code = field.slice(0, 2);
92
- const entryPath = field.slice(3);
93
- if (code[0] === 'R' || code[0] === 'C') {
94
- // renameは移動先(added)+移動元(deleted)へ分解する。copyは複製先だけが
95
- // 変更で、複製元は残るため分解しない。
96
- const source = fields[++index];
97
- entries.push({ path: entryPath, code });
98
- if (code[0] === 'R') entries.push({ path: source, code: ' D' });
99
- continue;
100
- }
101
- entries.push({ path: entryPath, code });
102
- }
103
- return entries;
104
- }
105
-
106
- function changeKind(code, exists) {
107
- if (!exists) return 'deleted';
108
- if (code.includes('?') || code.includes('!') || code.includes('A')
109
- || code[0] === 'R' || code[0] === 'C') {
110
- return 'added';
111
- }
112
- return 'modified';
113
- }
114
-
115
- async function assertBaseEntryRegular(worktreePath, baseSha, entryPath) {
116
- // 削除entryはbase側のmodeを検査する(symlink/submodule/specialの削除も
117
- // fail closed。isolation-runner規律の継承)。
118
- const tree = await run('git', ['ls-tree', '-z', baseSha, '--', entryPath], worktreePath);
119
- if (tree.length === 0) return;
120
- const mode = tree.toString('utf8').slice(0, 6);
121
- if (mode === '120000') fail(`symlink changeは許可されない: ${entryPath}`);
122
- if (mode === '160000') fail(`submodule changeは許可されない: ${entryPath}`);
123
- if (mode !== '100644' && mode !== '100755') fail(`special file changeは許可されない: ${entryPath}`);
124
- }
125
-
126
- async function entryRecord(worktreePath, baseSha, entry) {
127
- const absolutePath = path.join(worktreePath, entry.path);
128
- let stat = null;
129
- try {
130
- stat = await lstat(absolutePath);
131
- } catch (error) {
132
- if (error?.code !== 'ENOENT') throw error;
133
- }
134
- if (stat !== null) {
135
- if (stat.isSymbolicLink()) fail(`symlink changeは許可されない: ${entry.path}`);
136
- if (!stat.isFile()) fail(`special file changeは許可されない: ${entry.path}`);
137
- if (stat.size > MAX_TRACKED_FILE_BYTES) fail(`変更fileが上限byteを超える: ${entry.path}`);
138
- } else {
139
- await assertBaseEntryRegular(worktreePath, baseSha, entry.path);
140
- }
141
- return {
142
- path: entry.path,
143
- change: changeKind(entry.code, stat !== null),
144
- content_digest: stat === null ? null : sha256(await readFile(absolutePath)),
145
- };
146
- }
147
-
148
- /**
149
- * worktreeの実diffをbounded canonical recordへ変換する。
150
- *
151
- * HEADはbaseの子孫へ進んでよい(=workerが自分のworktreeでcommitしてよい)。進んだ分は
152
- * `base..HEAD`として観測へ含める。子孫でない位置へ動いた場合——reset、branch切替、rebase——は
153
- * 観測の前提が壊れるのでfail loudする(ADR 0139)。
154
- *
155
- * commitを一律禁止していた頃は、進行中の成果が未commitのままworktreeにしか存在せず、
156
- * 再計画がその変更を含まないsourceに対して行われていた。
157
- */
158
- export async function captureWorktreeDiff(options = {}) {
159
- if (!exactRecord(options, ['worktreePath', 'baseSha'])) {
160
- fail('captureWorktreeDiff optionsがexact shapeでない');
161
- }
162
- const { worktreePath, baseSha } = options;
163
- if (typeof worktreePath !== 'string' || worktreePath.length === 0 || !GIT_SHA1.test(baseSha ?? '')) {
164
- fail('worktreePath/baseShaが不正');
165
- }
166
- const head = (await run('git', ['rev-parse', 'HEAD'], worktreePath)).toString('utf8').trim();
167
- if (head !== baseSha) {
168
- const descendant = await run('git', ['merge-base', '--is-ancestor', baseSha, head], worktreePath, {
169
- allowExitCodes: [0, 1],
170
- });
171
- if (descendant.code !== 0) {
172
- fail(`worktree HEADがbaseの子孫でない(reset・branch切替・rebase): ${head}`);
173
- }
174
- }
175
- // ignored fileへのwriteもwrite sensorの対象にする(gitignore経由の
176
- // scope violation迂回を塞ぐ。isolation-runnerと同じ--ignored=matching)。
177
- const statusBytes = await run('git', [
178
- 'status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignored=matching',
179
- ], worktreePath);
180
- const entries = statusEntries(statusBytes);
181
- // commit済みの変更はstatusへ出ない。base..HEADの範囲も観測へ入れないと、
182
- // commitした瞬間に変更が観測から消える。
183
- if (head !== baseSha) {
184
- const committed = await run('git', [
185
- 'diff', '--name-status', '--no-renames', '-z', `${baseSha}..${head}`,
186
- ], worktreePath);
187
- const fields = committed.toString('utf8').split('\0').filter((field) => field.length > 0);
188
- for (let index = 0; index + 1 < fields.length; index += 2) {
189
- entries.push({ path: fields[index + 1], code: `${fields[index][0]} ` });
190
- }
191
- }
192
- if (entries.length > MAX_DIFF_ENTRIES) {
193
- fail(`diff entry数が上限を超える: ${entries.length}`);
194
- }
195
- // ignored directoryは`!! dir/`の集約entryで届くため、内包fileへ展開する
196
- // (集約のまま扱うとdirectoryをspecial file扱いで落とし、write pathを特定できない)。
197
- const expanded = [];
198
- for (const entry of entries) {
199
- if (!entry.path.endsWith('/')) {
200
- expanded.push(entry);
201
- continue;
202
- }
203
- const inner = await run('git', [
204
- 'ls-files', '--others', '--ignored', '--exclude-standard', '-z', '--', entry.path,
205
- ], worktreePath);
206
- for (const innerPath of inner.toString('utf8').split('\0')) {
207
- if (innerPath.length === 0) continue;
208
- expanded.push({ path: innerPath, code: entry.code });
209
- }
210
- }
211
- if (expanded.length > MAX_DIFF_ENTRIES) {
212
- fail(`diff entry数が上限を超える: ${expanded.length}`);
213
- }
214
- const seen = new Set();
215
- const records = [];
216
- for (const entry of expanded) {
217
- if (!safeRelativePath(entry.path)) fail(`不正なchanged path: ${entry.path}`);
218
- if (seen.has(entry.path)) continue;
219
- seen.add(entry.path);
220
- records.push(await entryRecord(worktreePath, baseSha, entry));
221
- }
222
- records.sort((left, right) => (left.path < right.path ? -1 : 1));
223
- // 観測中のHEAD移動(TOCTOU)を閉じる: 収集後に再確認する。
224
- const headAfter = (await run('git', ['rev-parse', 'HEAD'], worktreePath)).toString('utf8').trim();
225
- if (headAfter !== head) {
226
- fail(`観測中にworktree HEADが動いた: ${headAfter}`);
227
- }
228
- const record = {
229
- schema: 'lattice.checkpoint_diff.v2',
230
- base_sha: baseSha,
231
- // 生み出した木そのものを記録へ縛る。入力側のdigestだけでは、進行中の成果が
232
- // どの状態だったかを後から指せない。
233
- head_sha: head,
234
- entries: records,
235
- };
236
- return {
237
- checkpoint_digest: digestArtifact(record),
238
- diff: record,
239
- };
240
- }
241
-
242
- /**
243
- * 宣言writeがobserved pathを覆うか。末尾`/`はprefixとして読む。
244
- *
245
- * I/O sentinelの早期警報も同じ述語を使う(ADR 0143)。警報とcheckpoint findingで
246
- * 述語が分かれると、「警報は出たがcheckpointでは競合にならない」種類のずれが生まれ、
247
- * どちらが正しいのか誰にも分からなくなる。
248
- */
249
- export function coveredBy(declaredWrites, observedPath) {
250
- return declaredWrites.some((declared) => (
251
- declared === observedPath
252
- || (declared.endsWith('/') && observedPath.startsWith(declared))
253
- ));
254
- }
255
-
256
- function lineGroupsFor(manifests, todoIds) {
257
- if (!plainRecord(manifests)) fail('boundary manifestsが不正');
258
- const groups = new Map();
259
- for (const todoId of [...new Set(todoIds)].sort()) {
260
- const manifest = manifests[todoId];
261
- if (!plainRecord(manifest) || !Array.isArray(manifest.lines ?? [])) {
262
- fail(`boundary manifestのlinesが不正: ${todoId}`);
263
- }
264
- const seen = new Set();
265
- for (const line of manifest.lines ?? []) {
266
- if (!exactRecord(line, ['line_id', 'role', 'anchors'])
267
- || !LINE_ID.test(line.line_id ?? '')
268
- || !['reads', 'writes'].includes(line.role)
269
- || !Array.isArray(line.anchors) || line.anchors.length === 0
270
- || seen.has(line.line_id)) {
271
- fail(`line宣言が不正: ${todoId}`);
272
- }
273
- seen.add(line.line_id);
274
- const group = groups.get(line.line_id) ?? {
275
- readers: new Set(), writers: new Set(), anchorPaths: new Set(),
276
- };
277
- group[line.role === 'reads' ? 'readers' : 'writers'].add(todoId);
278
- for (const anchor of line.anchors) {
279
- const valid = anchor?.kind === 'path'
280
- ? exactRecord(anchor, ['kind', 'path']) && safeRelativePath(anchor.path)
281
- : anchor?.kind === 'symbol'
282
- && exactRecord(anchor, ['kind', 'name', 'path'])
283
- && typeof anchor.name === 'string' && anchor.name.length > 0
284
- && safeRelativePath(anchor.path);
285
- if (!valid) fail(`line anchorが不正: ${todoId}/${line.line_id}`);
286
- // symbol解決は実行時diffに存在しないので、初期実装では宣言済みpathへ近似する。
287
- group.anchorPaths.add(anchor.path);
288
- }
289
- groups.set(line.line_id, group);
290
- }
291
- }
292
- return groups;
293
- }
294
-
295
- /**
296
- * checkpoint diffのobserved pathをdeclared scope/他running TODOのdeclared writeへ
297
- * cross-bindし、closed conflict分類のfindingを返す(producer側の検出。独立再計算は
298
- * runtime-decision-verifierの`classifyObservedDiff`が行う)。
299
- *
300
- * - undeclared_write: 宣言write scope外へのwrite(offender=当該TODO)。
301
- * - observed_write_conflict: 他のrunning TODOのdeclared writeとのpath overlap。
302
- */
303
- export function detectCheckpointFindings(options = {}) {
304
- if (!exactRecord(options, ['todoId', 'checkpoint', 'packets', 'manifests', 'runningTodoIds'])) {
305
- fail('detectCheckpointFindings optionsがexact shapeでない');
306
- }
307
- const { todoId, checkpoint, packets, manifests, runningTodoIds } = options;
308
- if (!plainRecord(checkpoint) || !plainRecord(checkpoint.diff) || !Array.isArray(checkpoint.diff.entries)) {
309
- fail('checkpoint diff recordが不正');
310
- }
311
- const packet = packets[todoId];
312
- if (!plainRecord(packet) || !plainRecord(packet.scope) || !Array.isArray(packet.scope.writes)) {
313
- fail(`packetが不正: ${todoId}`);
314
- }
315
- const manifest = manifests[todoId];
316
- if (!plainRecord(manifest) || !Array.isArray(manifest.writes)) {
317
- fail(`boundary manifestが不正: ${todoId}`);
318
- }
319
- // findingはverifier(classifyObservedDiff)と同じper-path shapeで返す。
320
- const findings = [];
321
- for (const entry of [...checkpoint.diff.entries].sort((l, r) => (l.path < r.path ? -1 : 1))) {
322
- if (!coveredBy(manifest.writes, entry.path)) {
323
- findings.push({ kind: 'undeclared_write', todo_ids: [todoId], path: entry.path });
324
- }
325
- }
326
- for (const otherId of [...runningTodoIds].sort()) {
327
- if (otherId === todoId) continue;
328
- const other = packets[otherId];
329
- if (!plainRecord(other) || !plainRecord(other.scope) || !Array.isArray(other.scope.writes)) {
330
- fail(`packetが不正: ${otherId}`);
331
- }
332
- const otherManifest = manifests[otherId];
333
- if (!plainRecord(otherManifest) || !Array.isArray(otherManifest.writes)) {
334
- fail(`boundary manifestが不正: ${otherId}`);
335
- }
336
- for (const entry of [...checkpoint.diff.entries].sort((l, r) => (l.path < r.path ? -1 : 1))) {
337
- if (coveredBy(otherManifest.writes, entry.path)) {
338
- findings.push({
339
- kind: 'observed_write_conflict',
340
- todo_ids: [todoId, otherId].sort(),
341
- path: entry.path,
342
- });
343
- }
344
- }
345
- }
346
- const observedPaths = new Set(checkpoint.diff.entries.map((entry) => entry.path));
347
- for (const [lineId, group] of lineGroupsFor(
348
- manifests, [todoId, ...runningTodoIds],
349
- )) {
350
- if (group.writers.has(todoId)) continue;
351
- const readers = [...group.readers].filter((readerId) => readerId !== todoId).sort();
352
- if (readers.length === 0
353
- || ![...group.anchorPaths].some((anchorPath) => observedPaths.has(anchorPath))) continue;
354
- findings.push({
355
- kind: 'observed_line_change',
356
- todo_ids: [todoId, ...readers].sort(),
357
- resource_id: lineId,
358
- });
359
- }
360
- return { findings };
361
- }
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { lstat, readFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ import { digestArtifact } from './artifact-contracts.mjs';
7
+
8
+ /**
9
+ * RC3-F diff observer(ADR 0044 Decision 5、plan RC3-F)。
10
+ *
11
+ * disposable worktreeの実diffを、bounded canonical checkpoint recordへ変換する。
12
+ * git diffがwriteの一次sensor(plan「path/resource binding」)であり、executor
13
+ * 自己申告を診断入力にしない。observed pathはdeclared scope/他TODOのdeclared write
14
+ * へcross-bindされ、宣言外writeと運転中overlapを別findingで検出する。
15
+ *
16
+ * - symlink・submodule・special fileの変更はfail closed(isolation-runner規律の継承)。
17
+ * - 過大diff(entry数・byte数)は黙って切り詰めずreject。
18
+ * - commit・branch作成等の禁止操作はHEAD driftとして検出しfail loudする。
19
+ */
20
+
21
+ const MAX_DIFF_ENTRIES = 256;
22
+ const MAX_TRACKED_FILE_BYTES = 4_194_304;
23
+ const GIT_SHA1 = /^[0-9a-f]{40}$/;
24
+ const LINE_ID = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/;
25
+
26
+ function fail(reason) {
27
+ throw new TypeError(`diff observer契約違反: ${reason}`);
28
+ }
29
+
30
+ function plainRecord(value) {
31
+ return value !== null
32
+ && typeof value === 'object'
33
+ && !Array.isArray(value)
34
+ && Object.getPrototypeOf(value) === Object.prototype;
35
+ }
36
+
37
+ function exactRecord(value, keys) {
38
+ if (!plainRecord(value)) return false;
39
+ const actual = Object.keys(value).sort();
40
+ const expected = [...keys].sort();
41
+ return actual.length === expected.length
42
+ && actual.every((key, index) => key === expected[index]);
43
+ }
44
+
45
+ function sha256(bytes) {
46
+ return createHash('sha256').update(bytes).digest('hex');
47
+ }
48
+
49
+ function run(command, args, cwd, { allowExitCodes = [0] } = {}) {
50
+ return new Promise((resolve, reject) => {
51
+ const child = spawn(command, args, { cwd, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
52
+ const stdout = [];
53
+ const stderr = [];
54
+ child.stdout.on('data', (chunk) => stdout.push(chunk));
55
+ child.stderr.on('data', (chunk) => stderr.push(chunk));
56
+ child.once('error', reject);
57
+ child.once('close', (code, signal) => {
58
+ if (allowExitCodes.includes(code) && signal === null) {
59
+ resolve(Object.assign(Buffer.concat(stdout), { code }));
60
+ }
61
+ else {
62
+ reject(new TypeError(
63
+ `${command} ${args[0]} failed (${signal ?? code}): ${Buffer.concat(stderr).toString('utf8').trim()}`,
64
+ ));
65
+ }
66
+ });
67
+ });
68
+ }
69
+
70
+ const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
71
+ const MAX_PATH_BYTES = 1_024;
72
+
73
+ // receipt契約(runtime-contracts repoRelativePath)と同じbyte-safe規律を使う。
74
+ function safeRelativePath(value) {
75
+ return typeof value === 'string'
76
+ && value.length > 0
77
+ && Buffer.byteLength(value, 'utf8') <= MAX_PATH_BYTES
78
+ && !CONTROL_CHARACTER.test(value)
79
+ && !value.includes('\\')
80
+ && !path.posix.isAbsolute(value)
81
+ && value === path.posix.normalize(value)
82
+ && !value.split('/').includes('..');
83
+ }
84
+
85
+ function statusEntries(statusBytes) {
86
+ const fields = statusBytes.toString('utf8').split('\0');
87
+ const entries = [];
88
+ for (let index = 0; index < fields.length - 1; index += 1) {
89
+ const field = fields[index];
90
+ if (!field) continue;
91
+ const code = field.slice(0, 2);
92
+ const entryPath = field.slice(3);
93
+ if (code[0] === 'R' || code[0] === 'C') {
94
+ // renameは移動先(added)+移動元(deleted)へ分解する。copyは複製先だけが
95
+ // 変更で、複製元は残るため分解しない。
96
+ const source = fields[++index];
97
+ entries.push({ path: entryPath, code });
98
+ if (code[0] === 'R') entries.push({ path: source, code: ' D' });
99
+ continue;
100
+ }
101
+ entries.push({ path: entryPath, code });
102
+ }
103
+ return entries;
104
+ }
105
+
106
+ function changeKind(code, exists) {
107
+ if (!exists) return 'deleted';
108
+ if (code.includes('?') || code.includes('!') || code.includes('A')
109
+ || code[0] === 'R' || code[0] === 'C') {
110
+ return 'added';
111
+ }
112
+ return 'modified';
113
+ }
114
+
115
+ async function assertBaseEntryRegular(worktreePath, baseSha, entryPath) {
116
+ // 削除entryはbase側のmodeを検査する(symlink/submodule/specialの削除も
117
+ // fail closed。isolation-runner規律の継承)。
118
+ const tree = await run('git', ['ls-tree', '-z', baseSha, '--', entryPath], worktreePath);
119
+ if (tree.length === 0) return;
120
+ const mode = tree.toString('utf8').slice(0, 6);
121
+ if (mode === '120000') fail(`symlink changeは許可されない: ${entryPath}`);
122
+ if (mode === '160000') fail(`submodule changeは許可されない: ${entryPath}`);
123
+ if (mode !== '100644' && mode !== '100755') fail(`special file changeは許可されない: ${entryPath}`);
124
+ }
125
+
126
+ async function entryRecord(worktreePath, baseSha, entry) {
127
+ const absolutePath = path.join(worktreePath, entry.path);
128
+ let stat = null;
129
+ try {
130
+ stat = await lstat(absolutePath);
131
+ } catch (error) {
132
+ if (error?.code !== 'ENOENT') throw error;
133
+ }
134
+ if (stat !== null) {
135
+ if (stat.isSymbolicLink()) fail(`symlink changeは許可されない: ${entry.path}`);
136
+ if (!stat.isFile()) fail(`special file changeは許可されない: ${entry.path}`);
137
+ if (stat.size > MAX_TRACKED_FILE_BYTES) fail(`変更fileが上限byteを超える: ${entry.path}`);
138
+ } else {
139
+ await assertBaseEntryRegular(worktreePath, baseSha, entry.path);
140
+ }
141
+ return {
142
+ path: entry.path,
143
+ change: changeKind(entry.code, stat !== null),
144
+ content_digest: stat === null ? null : sha256(await readFile(absolutePath)),
145
+ };
146
+ }
147
+
148
+ /**
149
+ * worktreeの実diffをbounded canonical recordへ変換する。
150
+ *
151
+ * HEADはbaseの子孫へ進んでよい(=workerが自分のworktreeでcommitしてよい)。進んだ分は
152
+ * `base..HEAD`として観測へ含める。子孫でない位置へ動いた場合——reset、branch切替、rebase——は
153
+ * 観測の前提が壊れるのでfail loudする(ADR 0139)。
154
+ *
155
+ * commitを一律禁止していた頃は、進行中の成果が未commitのままworktreeにしか存在せず、
156
+ * 再計画がその変更を含まないsourceに対して行われていた。
157
+ */
158
+ export async function captureWorktreeDiff(options = {}) {
159
+ if (!exactRecord(options, ['worktreePath', 'baseSha'])) {
160
+ fail('captureWorktreeDiff optionsがexact shapeでない');
161
+ }
162
+ const { worktreePath, baseSha } = options;
163
+ if (typeof worktreePath !== 'string' || worktreePath.length === 0 || !GIT_SHA1.test(baseSha ?? '')) {
164
+ fail('worktreePath/baseShaが不正');
165
+ }
166
+ const head = (await run('git', ['rev-parse', 'HEAD'], worktreePath)).toString('utf8').trim();
167
+ if (head !== baseSha) {
168
+ const descendant = await run('git', ['merge-base', '--is-ancestor', baseSha, head], worktreePath, {
169
+ allowExitCodes: [0, 1],
170
+ });
171
+ if (descendant.code !== 0) {
172
+ fail(`worktree HEADがbaseの子孫でない(reset・branch切替・rebase): ${head}`);
173
+ }
174
+ }
175
+ // ignored fileへのwriteもwrite sensorの対象にする(gitignore経由の
176
+ // scope violation迂回を塞ぐ。isolation-runnerと同じ--ignored=matching)。
177
+ const statusBytes = await run('git', [
178
+ 'status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignored=matching',
179
+ ], worktreePath);
180
+ const entries = statusEntries(statusBytes);
181
+ // commit済みの変更はstatusへ出ない。base..HEADの範囲も観測へ入れないと、
182
+ // commitした瞬間に変更が観測から消える。
183
+ if (head !== baseSha) {
184
+ const committed = await run('git', [
185
+ 'diff', '--name-status', '--no-renames', '-z', `${baseSha}..${head}`,
186
+ ], worktreePath);
187
+ const fields = committed.toString('utf8').split('\0').filter((field) => field.length > 0);
188
+ for (let index = 0; index + 1 < fields.length; index += 2) {
189
+ entries.push({ path: fields[index + 1], code: `${fields[index][0]} ` });
190
+ }
191
+ }
192
+ if (entries.length > MAX_DIFF_ENTRIES) {
193
+ fail(`diff entry数が上限を超える: ${entries.length}`);
194
+ }
195
+ // ignored directoryは`!! dir/`の集約entryで届くため、内包fileへ展開する
196
+ // (集約のまま扱うとdirectoryをspecial file扱いで落とし、write pathを特定できない)。
197
+ const expanded = [];
198
+ for (const entry of entries) {
199
+ if (!entry.path.endsWith('/')) {
200
+ expanded.push(entry);
201
+ continue;
202
+ }
203
+ const inner = await run('git', [
204
+ 'ls-files', '--others', '--ignored', '--exclude-standard', '-z', '--', entry.path,
205
+ ], worktreePath);
206
+ for (const innerPath of inner.toString('utf8').split('\0')) {
207
+ if (innerPath.length === 0) continue;
208
+ expanded.push({ path: innerPath, code: entry.code });
209
+ }
210
+ }
211
+ if (expanded.length > MAX_DIFF_ENTRIES) {
212
+ fail(`diff entry数が上限を超える: ${expanded.length}`);
213
+ }
214
+ const seen = new Set();
215
+ const records = [];
216
+ for (const entry of expanded) {
217
+ if (!safeRelativePath(entry.path)) fail(`不正なchanged path: ${entry.path}`);
218
+ if (seen.has(entry.path)) continue;
219
+ seen.add(entry.path);
220
+ records.push(await entryRecord(worktreePath, baseSha, entry));
221
+ }
222
+ records.sort((left, right) => (left.path < right.path ? -1 : 1));
223
+ // 観測中のHEAD移動(TOCTOU)を閉じる: 収集後に再確認する。
224
+ const headAfter = (await run('git', ['rev-parse', 'HEAD'], worktreePath)).toString('utf8').trim();
225
+ if (headAfter !== head) {
226
+ fail(`観測中にworktree HEADが動いた: ${headAfter}`);
227
+ }
228
+ const record = {
229
+ schema: 'lattice.checkpoint_diff.v2',
230
+ base_sha: baseSha,
231
+ // 生み出した木そのものを記録へ縛る。入力側のdigestだけでは、進行中の成果が
232
+ // どの状態だったかを後から指せない。
233
+ head_sha: head,
234
+ entries: records,
235
+ };
236
+ return {
237
+ checkpoint_digest: digestArtifact(record),
238
+ diff: record,
239
+ };
240
+ }
241
+
242
+ /**
243
+ * 宣言writeがobserved pathを覆うか。末尾`/`はprefixとして読む。
244
+ *
245
+ * I/O sentinelの早期警報も同じ述語を使う(ADR 0143)。警報とcheckpoint findingで
246
+ * 述語が分かれると、「警報は出たがcheckpointでは競合にならない」種類のずれが生まれ、
247
+ * どちらが正しいのか誰にも分からなくなる。
248
+ */
249
+ export function coveredBy(declaredWrites, observedPath) {
250
+ return declaredWrites.some((declared) => (
251
+ declared === observedPath
252
+ || (declared.endsWith('/') && observedPath.startsWith(declared))
253
+ ));
254
+ }
255
+
256
+ function lineGroupsFor(manifests, todoIds) {
257
+ if (!plainRecord(manifests)) fail('boundary manifestsが不正');
258
+ const groups = new Map();
259
+ for (const todoId of [...new Set(todoIds)].sort()) {
260
+ const manifest = manifests[todoId];
261
+ if (!plainRecord(manifest) || !Array.isArray(manifest.lines ?? [])) {
262
+ fail(`boundary manifestのlinesが不正: ${todoId}`);
263
+ }
264
+ const seen = new Set();
265
+ for (const line of manifest.lines ?? []) {
266
+ if (!exactRecord(line, ['line_id', 'role', 'anchors'])
267
+ || !LINE_ID.test(line.line_id ?? '')
268
+ || !['reads', 'writes'].includes(line.role)
269
+ || !Array.isArray(line.anchors) || line.anchors.length === 0
270
+ || seen.has(line.line_id)) {
271
+ fail(`line宣言が不正: ${todoId}`);
272
+ }
273
+ seen.add(line.line_id);
274
+ const group = groups.get(line.line_id) ?? {
275
+ readers: new Set(), writers: new Set(), anchorPaths: new Set(),
276
+ };
277
+ group[line.role === 'reads' ? 'readers' : 'writers'].add(todoId);
278
+ for (const anchor of line.anchors) {
279
+ const valid = anchor?.kind === 'path'
280
+ ? exactRecord(anchor, ['kind', 'path']) && safeRelativePath(anchor.path)
281
+ : anchor?.kind === 'symbol'
282
+ && exactRecord(anchor, ['kind', 'name', 'path'])
283
+ && typeof anchor.name === 'string' && anchor.name.length > 0
284
+ && safeRelativePath(anchor.path);
285
+ if (!valid) fail(`line anchorが不正: ${todoId}/${line.line_id}`);
286
+ // symbol解決は実行時diffに存在しないので、初期実装では宣言済みpathへ近似する。
287
+ group.anchorPaths.add(anchor.path);
288
+ }
289
+ groups.set(line.line_id, group);
290
+ }
291
+ }
292
+ return groups;
293
+ }
294
+
295
+ /**
296
+ * checkpoint diffのobserved pathをdeclared scope/他running TODOのdeclared writeへ
297
+ * cross-bindし、closed conflict分類のfindingを返す(producer側の検出。独立再計算は
298
+ * runtime-decision-verifierの`classifyObservedDiff`が行う)。
299
+ *
300
+ * - undeclared_write: 宣言write scope外へのwrite(offender=当該TODO)。
301
+ * - observed_write_conflict: 他のrunning TODOのdeclared writeとのpath overlap。
302
+ */
303
+ export function detectCheckpointFindings(options = {}) {
304
+ if (!exactRecord(options, ['todoId', 'checkpoint', 'packets', 'manifests', 'runningTodoIds'])) {
305
+ fail('detectCheckpointFindings optionsがexact shapeでない');
306
+ }
307
+ const { todoId, checkpoint, packets, manifests, runningTodoIds } = options;
308
+ if (!plainRecord(checkpoint) || !plainRecord(checkpoint.diff) || !Array.isArray(checkpoint.diff.entries)) {
309
+ fail('checkpoint diff recordが不正');
310
+ }
311
+ const packet = packets[todoId];
312
+ if (!plainRecord(packet) || !plainRecord(packet.scope) || !Array.isArray(packet.scope.writes)) {
313
+ fail(`packetが不正: ${todoId}`);
314
+ }
315
+ const manifest = manifests[todoId];
316
+ if (!plainRecord(manifest) || !Array.isArray(manifest.writes)) {
317
+ fail(`boundary manifestが不正: ${todoId}`);
318
+ }
319
+ // findingはverifier(classifyObservedDiff)と同じper-path shapeで返す。
320
+ const findings = [];
321
+ for (const entry of [...checkpoint.diff.entries].sort((l, r) => (l.path < r.path ? -1 : 1))) {
322
+ if (!coveredBy(manifest.writes, entry.path)) {
323
+ findings.push({ kind: 'undeclared_write', todo_ids: [todoId], path: entry.path });
324
+ }
325
+ }
326
+ for (const otherId of [...runningTodoIds].sort()) {
327
+ if (otherId === todoId) continue;
328
+ const other = packets[otherId];
329
+ if (!plainRecord(other) || !plainRecord(other.scope) || !Array.isArray(other.scope.writes)) {
330
+ fail(`packetが不正: ${otherId}`);
331
+ }
332
+ const otherManifest = manifests[otherId];
333
+ if (!plainRecord(otherManifest) || !Array.isArray(otherManifest.writes)) {
334
+ fail(`boundary manifestが不正: ${otherId}`);
335
+ }
336
+ for (const entry of [...checkpoint.diff.entries].sort((l, r) => (l.path < r.path ? -1 : 1))) {
337
+ if (coveredBy(otherManifest.writes, entry.path)) {
338
+ findings.push({
339
+ kind: 'observed_write_conflict',
340
+ todo_ids: [todoId, otherId].sort(),
341
+ path: entry.path,
342
+ });
343
+ }
344
+ }
345
+ }
346
+ const observedPaths = new Set(checkpoint.diff.entries.map((entry) => entry.path));
347
+ for (const [lineId, group] of lineGroupsFor(
348
+ manifests, [todoId, ...runningTodoIds],
349
+ )) {
350
+ if (group.writers.has(todoId)) continue;
351
+ const readers = [...group.readers].filter((readerId) => readerId !== todoId).sort();
352
+ if (readers.length === 0
353
+ || ![...group.anchorPaths].some((anchorPath) => observedPaths.has(anchorPath))) continue;
354
+ findings.push({
355
+ kind: 'observed_line_change',
356
+ todo_ids: [todoId, ...readers].sort(),
357
+ resource_id: lineId,
358
+ });
359
+ }
360
+ return { findings };
361
+ }