@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,661 +1,661 @@
1
- /**
2
- * 2つのindexの構造グラフ差分。
3
- *
4
- * 動機は upstream追従である。sensor/ には由来元のupstreamがあり、54コミット分の差分を
5
- * 人がgrepとdiffで消化した(由来の正本は sensor/UPSTREAM.json)。両側のindexへ別々に
6
- * 照会はできても、差分を機械列挙する入口が無かった。ここがその入口である。
7
- * 用途はupstream追従に限らない——2つのtreeの構造グラフを突き合わせる汎用の入口である。
8
- *
9
- * ## なぜ node id で突き合わせないか
10
- *
11
- * node idは `<kind>:` + sha256(`相対path:kind:name:start_line`) であり、**行番号が埋まっている**。
12
- * idで突き合わせると、関数が数行ずれただけで両側とも別idになり、そのnodeも、そのnodeを
13
- * 端点に持つ全ての辺も、偽の追加+削除として出る。54コミット級では差分が全て偽陽性で埋まる。
14
- * よって突き合わせは行番号を含まない自然キー `kind|file_path|qualified_name|name` で行い、
15
- * 辺も端点idを自然キーへ解決してから比べる。
16
- *
17
- * ## 同一キーが複数ある時(overload・無名関数・同名の重複定義)
18
- *
19
- * 自然キーは一意でない。同キーの集団は両側をstart_line順に並べて先頭から対にする。対の中で
20
- * 属性が違えば changed、属性同一で行だけ違えば moved、余った分が added/removed になる。
21
- * 対応付けは発見的であり、「10個の無名callbackのうち3番目が消えた」を正確に指す保証は無い。
22
- * 件数と、その集団に何が起きたかは正しく出る。
23
- *
24
- * ## 出さないもの
25
- *
26
- * 揮発列(updated_at・indexed_at・modified_at・size・content_hash)は比較しない。
27
- * 端点を解決できない辺、subtree外へ出る辺は比較から外すが、**件数をexcludedへ必ず出す**。
28
- * 黙って落とすと「差分なし」と「見ていない」が同じ顔になる。
29
- *
30
- * ## 規模の前提
31
- *
32
- * 両側のnode・edge・fileを全件メモリへ載せてから突き合わせる。件数summaryを正確に出す以上、
33
- * 全件を見ないと済まないからである。`--limit`が切るのは**出力の明細だけ**で、読み込み量は
34
- * 切らない。実測の目安は片側17k nodes/72k edgesで数百MB未満だが、桁が2つ上がるindexでは
35
- * この前提ごと見直しが要る(streaming差分は別設計になる)。
36
- */
37
-
38
- import { existsSync } from 'node:fs';
39
- import path from 'node:path';
40
- import { DatabaseSync } from 'node:sqlite';
41
-
42
- export const SENSOR_DB_RELATIVE_PATH = path.join('.lattice', 'sensor', 'sensor.db');
43
-
44
- const DEFAULT_LIMIT = 200;
45
- // 制御文字を区切りに使う。識別子・path・qualified nameのどれにも現れないので、
46
- // 名前の中の記号が偶然キーを割るという壊れ方をしない。
47
- const KEY_SEPARATOR = '\u0000';
48
- const EDGE_SEPARATOR = '\u0001';
49
-
50
- /** 対で比べる属性。差分は「どの属性が変わったか」を名前で返し、消費側が絞れるようにする。 */
51
- const COMPARED_NODE_FIELDS = Object.freeze([
52
- 'language', 'signature', 'visibility', 'return_type', 'docstring',
53
- 'decorators', 'type_parameters',
54
- 'is_exported', 'is_async', 'is_static', 'is_abstract',
55
- 'span', 'extent_offset',
56
- ]);
57
-
58
- export class SensorDiffError extends Error {
59
- constructor(code, message, detail) {
60
- super(message);
61
- this.name = 'SensorDiffError';
62
- this.code = code;
63
- this.detail = detail ?? {};
64
- }
65
- }
66
-
67
- /** `from=to` を1件解釈する。`=` を含まない指定は黙って無視せずusage errorにする。 */
68
- function parseMapping(spec) {
69
- const index = spec.indexOf('=');
70
- if (index <= 0 || index === spec.length - 1) return null;
71
- return { from: normalizeRelative(spec.slice(0, index)), to: normalizeRelative(spec.slice(index + 1)) };
72
- }
73
-
74
- function normalizeRelative(value) {
75
- const normalized = value.replaceAll('\\', '/')
76
- .replace(/^\.\//u, '').replace(/^\/+/u, '').replace(/\/+$/u, '');
77
- // `.` はrootそのものを指す慣用である。これを剥がさないとprefixが `./` になり、
78
- // どのpathにも一致せず「全件がsubtree外」=差分ゼロという静かな嘘になる。
79
- return normalized === '.' ? '' : normalized;
80
- }
81
-
82
- /**
83
- * subtreeで絞ってprefixを剥がし、改名写像を当てる。写像は最長prefix一致で1回だけ適用する
84
- * (連鎖適用すると写像順で結果が変わり、再現しない)。
85
- */
86
- function makePathNormalizer(subtree, mappings) {
87
- const prefix = subtree === '' ? '' : `${subtree}/`;
88
- const ordered = [...mappings].sort((a, b) => b.from.length - a.from.length);
89
- return (rawPath) => {
90
- const normalized = normalizeRelative(rawPath);
91
- if (prefix !== '' && !normalized.startsWith(prefix)) return null;
92
- let result = prefix === '' ? normalized : normalized.slice(prefix.length);
93
- for (const { from, to } of ordered) {
94
- if (result === from) { result = to; break; }
95
- if (result.startsWith(`${from}/`)) { result = `${to}/${result.slice(from.length + 1)}`; break; }
96
- }
97
- return result;
98
- };
99
- }
100
-
101
- function openIndex(root) {
102
- const resolvedRoot = path.resolve(root);
103
- const dbPath = path.join(resolvedRoot, SENSOR_DB_RELATIVE_PATH);
104
- if (!existsSync(dbPath)) {
105
- throw new SensorDiffError(
106
- 'LATTICE_SENSOR_DIFF_INDEX_MISSING',
107
- `no sensor index at ${dbPath}`,
108
- { root: resolvedRoot, database: dbPath, next_action: `lattice sensor init ${root} --json` },
109
- );
110
- }
111
- try {
112
- return { root: resolvedRoot, dbPath, db: new DatabaseSync(dbPath, { readOnly: true }) };
113
- } catch (error) {
114
- throw new SensorDiffError(
115
- 'LATTICE_SENSOR_DIFF_INDEX_UNREADABLE',
116
- `cannot read sensor index at ${dbPath}`,
117
- { root: resolvedRoot, database: dbPath, cause: error?.message ?? 'unknown error' },
118
- );
119
- }
120
- }
121
-
122
- /** 読み出しが要求する列。1つでも欠ければindexが古い。 */
123
- const REQUIRED_COLUMNS = Object.freeze({
124
- nodes: ['id', 'kind', 'name', 'qualified_name', 'file_path', 'language', 'start_line', 'end_line',
125
- 'signature', 'visibility', 'return_type', 'docstring', 'decorators', 'type_parameters',
126
- 'extent_start_line', 'is_exported', 'is_async', 'is_static', 'is_abstract'],
127
- edges: ['source', 'target', 'kind'],
128
- files: ['path', 'language', 'content_hash', 'node_count', 'extraction_version', 'errors'],
129
- });
130
-
131
- /**
132
- * 読み出す前にschemaを確かめる。確かめないと、古いindexとの比較が「列が無い」というSQLiteの
133
- * 生の例外で落ちる——comparabilityを返す前に死ぬので、degradedにすら降りられない。
134
- */
135
- function assertReadableSchema(handle, side) {
136
- const missing = [];
137
- for (const [table, columns] of Object.entries(REQUIRED_COLUMNS)) {
138
- const present = new Set(handle.db.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
139
- if (present.size === 0) { missing.push(`${table}.*`); continue; }
140
- for (const column of columns) if (!present.has(column)) missing.push(`${table}.${column}`);
141
- }
142
- if (missing.length === 0) return;
143
- throw new SensorDiffError(
144
- 'LATTICE_SENSOR_DIFF_SCHEMA_UNSUPPORTED',
145
- `sensor index schema at ${handle.dbPath} is missing columns this diff requires`,
146
- {
147
- side,
148
- root: handle.root,
149
- database: handle.dbPath,
150
- missing_columns: missing,
151
- next_action: `lattice sensor init ${handle.root} --json`,
152
- },
153
- );
154
- }
155
-
156
- /**
157
- * 片側の読み出しを1つの読み取りtransactionで囲う。囲わないと、並行するsyncのcommitが
158
- * node読出しとedge読出しの間へ割り込み、実在しないdanglingや偽の差分を作る。
159
- * A側とB側は別DBなので、両者が同一時刻の断面である保証はここでは得られない(片側ずつの
160
- * 内部整合だけを保証する)。
161
- */
162
- function withReadSnapshot(handle, read) {
163
- handle.db.exec('BEGIN DEFERRED');
164
- try {
165
- const result = read();
166
- handle.db.exec('COMMIT');
167
- return result;
168
- } catch (error) {
169
- try { handle.db.exec('ROLLBACK'); } catch { /* 元の失敗を潰さない */ }
170
- throw error;
171
- }
172
- }
173
-
174
- /**
175
- * qualified nameがfile pathを埋め込む種類のnode(file node の qualified_name は
176
- * `sub/lib.mjs` そのもの)は、pathへ当てた subtree 剥がしと改名写像を qualified name にも
177
- * 当てる。当てないと、揃えたはずの2つのtreeでfile nodeだけが追加+削除として残る。
178
- */
179
- function rewriteQualifiedName(qualifiedName, rawPath, normalizedPath) {
180
- if (qualifiedName === rawPath) return normalizedPath;
181
- for (const separator of ['/', ':', '#']) {
182
- if (qualifiedName.startsWith(`${rawPath}${separator}`)) {
183
- return `${normalizedPath}${qualifiedName.slice(rawPath.length)}`;
184
- }
185
- }
186
- return qualifiedName;
187
- }
188
-
189
- function naturalKey(kind, filePath, qualifiedName, name) {
190
- return `${kind}${KEY_SEPARATOR}${filePath}${KEY_SEPARATOR}${qualifiedName}${KEY_SEPARATOR}${name}`;
191
- }
192
-
193
- /**
194
- * `files.errors`は部分抽出時のerrorを持つ。抽出が途中で折れたfileはnodeが欠けたまま
195
- * 正常なfileの顔で並ぶので、数えて表に出さないと「差分なし」と「抽出が不完全」が同じ顔になる。
196
- */
197
- function hasExtractionErrors(raw) {
198
- if (raw === null || raw === undefined || raw === '') return false;
199
- try {
200
- const parsed = JSON.parse(raw);
201
- return Array.isArray(parsed) ? parsed.length > 0 : Boolean(parsed);
202
- } catch {
203
- // 読めないerrors列そのものが異状である。無かったことにしない。
204
- return true;
205
- }
206
- }
207
-
208
- /**
209
- * 片側を読み出して、自然キーで索き直した形にする。
210
- *
211
- * キーは突き合わせにしか使わず、出力へ戻す時は`keyMeta`/`edgeMeta`に控えた構造をそのまま
212
- * 使う。キー文字列を割って復元しない——POSIXのfile名は `/` とNUL以外の制御文字を許すので、
213
- * 区切りに選んだ文字がpathに現れた瞬間に復元が壊れる。
214
- *
215
- * `idToKey`は辺の端点解決に使う。subtree外のnodeはキーを持たないので、そのnodeを端点に持つ
216
- * 辺は比較対象外になる(件数はcallerがexcludedへ出す)。
217
- */
218
- function loadSide(handle, normalizePath, side) {
219
- const nodesByKey = new Map();
220
- const keyMeta = new Map();
221
- const edgeMeta = new Map();
222
- const idToKey = new Map();
223
- // subtreeで外したidも覚えておく。「indexが壊れている」と「比較の射程外」は原因が違うので、
224
- // 除外件数を1つにまとめない。
225
- const knownIds = new Set();
226
- let nodesOutsideSubtree = 0;
227
-
228
- const nodeRows = handle.db.prepare(`
229
- SELECT id, kind, name, qualified_name, file_path, language,
230
- start_line, end_line, signature, visibility, return_type, docstring,
231
- decorators, type_parameters, extent_start_line,
232
- is_exported, is_async, is_static, is_abstract
233
- FROM nodes
234
- `).all();
235
- for (const row of nodeRows) {
236
- knownIds.add(row.id);
237
- const filePath = normalizePath(row.file_path);
238
- if (filePath === null) { nodesOutsideSubtree += 1; continue; }
239
- const qualifiedName = rewriteQualifiedName(
240
- row.qualified_name, normalizeRelative(row.file_path), filePath,
241
- );
242
- const key = naturalKey(row.kind, filePath, qualifiedName, row.name);
243
- const entry = {
244
- key,
245
- start_line: row.start_line,
246
- attributes: {
247
- language: row.language,
248
- signature: row.signature,
249
- visibility: row.visibility,
250
- return_type: row.return_type,
251
- docstring: row.docstring,
252
- decorators: row.decorators,
253
- type_parameters: row.type_parameters,
254
- is_exported: row.is_exported,
255
- is_async: row.is_async,
256
- is_static: row.is_static,
257
- is_abstract: row.is_abstract,
258
- span: row.end_line - row.start_line,
259
- extent_offset: row.extent_start_line === null ? null : row.extent_start_line - row.start_line,
260
- },
261
- };
262
- const bucket = nodesByKey.get(key);
263
- if (bucket === undefined) {
264
- nodesByKey.set(key, [entry]);
265
- keyMeta.set(key, {
266
- kind: row.kind, file_path: filePath, qualified_name: qualifiedName, name: row.name,
267
- });
268
- } else bucket.push(entry);
269
- idToKey.set(row.id, key);
270
- }
271
- for (const bucket of nodesByKey.values()) bucket.sort((a, b) => a.start_line - b.start_line);
272
-
273
- const edgeCounts = new Map();
274
- let danglingEdges = 0;
275
- let edgesOutsideSubtree = 0;
276
- for (const row of handle.db.prepare('SELECT source, target, kind FROM edges').all()) {
277
- const source = idToKey.get(row.source);
278
- const target = idToKey.get(row.target);
279
- if (source === undefined || target === undefined) {
280
- // nodes表に居ないidはindexの不整合(dangling)、居るのにキーが無いならsubtreeで外した分。
281
- if (knownIds.has(row.source) && knownIds.has(row.target)) edgesOutsideSubtree += 1;
282
- else danglingEdges += 1;
283
- continue;
284
- }
285
- const key = `${source}${EDGE_SEPARATOR}${target}${EDGE_SEPARATOR}${row.kind}`;
286
- const seen = edgeCounts.get(key);
287
- if (seen === undefined) {
288
- edgeCounts.set(key, 1);
289
- edgeMeta.set(key, { kind: row.kind, source: keyMeta.get(source), target: keyMeta.get(target) });
290
- } else edgeCounts.set(key, seen + 1);
291
- }
292
-
293
- const files = new Map();
294
- const rawPathByNormalized = new Map();
295
- let filesOutsideSubtree = 0;
296
- let filesWithExtractionErrors = 0;
297
- for (const row of handle.db.prepare('SELECT path, language, content_hash, node_count, extraction_version, errors FROM files').all()) {
298
- const filePath = normalizePath(row.path);
299
- if (filePath === null) { filesOutsideSubtree += 1; continue; }
300
- const collided = rawPathByNormalized.get(filePath);
301
- if (collided !== undefined) {
302
- // 写像が2つの実在fileを同じpathへ潰した。片方が黙って消えると、消えた側の全symbolが
303
- // 「無かったこと」になり、差分は静かに嘘をつく。指定した人にしか直せないので止める。
304
- throw new SensorDiffError(
305
- 'LATTICE_SENSOR_DIFF_PATH_COLLISION',
306
- `path mapping collapses two indexed files onto "${filePath}"`,
307
- { side, root: handle.root, normalized_path: filePath, source_paths: [collided, row.path] },
308
- );
309
- }
310
- rawPathByNormalized.set(filePath, row.path);
311
- if (hasExtractionErrors(row.errors)) filesWithExtractionErrors += 1;
312
- files.set(filePath, {
313
- language: row.language,
314
- content_hash: row.content_hash,
315
- node_count: row.node_count,
316
- extraction_version: row.extraction_version,
317
- });
318
- }
319
-
320
- return {
321
- nodesByKey,
322
- keyMeta,
323
- edgeCounts,
324
- edgeMeta,
325
- files,
326
- schema_version: handle.db.prepare('SELECT MAX(version) AS v FROM schema_versions').get()?.v ?? null,
327
- counts: {
328
- nodes: nodeRows.length - nodesOutsideSubtree,
329
- edges: [...edgeCounts.values()].reduce((sum, n) => sum + n, 0),
330
- files: files.size,
331
- },
332
- integrity: { files_with_extraction_errors: filesWithExtractionErrors },
333
- excluded: {
334
- nodes_outside_subtree: nodesOutsideSubtree,
335
- files_outside_subtree: filesOutsideSubtree,
336
- edges_outside_subtree: edgesOutsideSubtree,
337
- dangling_edge_endpoints: danglingEdges,
338
- },
339
- };
340
- }
341
-
342
- function versionHistogram(files) {
343
- const counts = {};
344
- for (const record of files.values()) {
345
- counts[record.extraction_version] = (counts[record.extraction_version] ?? 0) + 1;
346
- }
347
- return Object.fromEntries(Object.entries(counts).sort((x, y) => Number(x[0]) - Number(y[0])));
348
- }
349
-
350
- /**
351
- * indexそのものの素性を突き合わせる。ここが食い違うなら、差分はcodeの変化とは限らない。
352
- *
353
- * extraction versionは集合でなく**同じfileどうしで**比べる。集合だけを見ると、
354
- * A={x:24,y:25}/B={x:25,y:24} のように対応が入れ替わっていても両方 {24,25} で一致し、
355
- * 中断したincremental syncが作る本物の食い違いを取りこぼす。
356
- */
357
- function assessComparability(sideA, sideB) {
358
- const reasons = [];
359
- if (sideA.schema_version !== sideB.schema_version) {
360
- reasons.push(`schema version differs (a=${sideA.schema_version}, b=${sideB.schema_version})`);
361
- }
362
- let mismatched = 0;
363
- for (const [filePath, left] of sideA.files) {
364
- const right = sideB.files.get(filePath);
365
- if (right !== undefined && left.extraction_version !== right.extraction_version) mismatched += 1;
366
- }
367
- if (mismatched > 0) {
368
- reasons.push(`${mismatched} common file(s) were extracted at different extraction versions`);
369
- }
370
- const errored = sideA.integrity.files_with_extraction_errors
371
- + sideB.integrity.files_with_extraction_errors;
372
- if (errored > 0) {
373
- reasons.push(`${errored} indexed file(s) recorded extraction errors`
374
- + ` (a=${sideA.integrity.files_with_extraction_errors},`
375
- + ` b=${sideB.integrity.files_with_extraction_errors});`
376
- + ' their symbols may be missing rather than removed');
377
- }
378
- return {
379
- // degradedは「比べるな」ではなく「この差分はcodeの変化だけを意味しない」という宣言である。
380
- status: reasons.length === 0 ? 'ok' : 'degraded',
381
- reasons,
382
- extraction_version_mismatched_files: mismatched,
383
- a: {
384
- schema_version: sideA.schema_version,
385
- extraction_versions: versionHistogram(sideA.files),
386
- files_with_extraction_errors: sideA.integrity.files_with_extraction_errors,
387
- },
388
- b: {
389
- schema_version: sideB.schema_version,
390
- extraction_versions: versionHistogram(sideB.files),
391
- files_with_extraction_errors: sideB.integrity.files_with_extraction_errors,
392
- },
393
- };
394
- }
395
-
396
- function changedFields(left, right) {
397
- const fields = [];
398
- for (const field of COMPARED_NODE_FIELDS) {
399
- if (left.attributes[field] !== right.attributes[field]) fields.push(field);
400
- }
401
- return fields;
402
- }
403
-
404
- function diffNodes(sideA, sideB) {
405
- const added = [];
406
- const removed = [];
407
- const changed = [];
408
- const moved = [];
409
- let unchanged = 0;
410
-
411
- const keys = new Set([...sideA.nodesByKey.keys(), ...sideB.nodesByKey.keys()]);
412
- for (const key of keys) {
413
- // 出力の名前・pathはキー文字列を割って復元せず、読み出し時に控えた構造をそのまま使う。
414
- const identity = sideA.keyMeta.get(key) ?? sideB.keyMeta.get(key);
415
- const left = sideA.nodesByKey.get(key) ?? [];
416
- const right = sideB.nodesByKey.get(key) ?? [];
417
- const paired = Math.min(left.length, right.length);
418
- for (let i = 0; i < paired; i += 1) {
419
- const fields = changedFields(left[i], right[i]);
420
- if (fields.length > 0) {
421
- changed.push({
422
- ...identity,
423
- fields,
424
- a: { start_line: left[i].start_line },
425
- b: { start_line: right[i].start_line },
426
- });
427
- } else if (left[i].start_line !== right[i].start_line) {
428
- moved.push({ ...identity, a_start_line: left[i].start_line, b_start_line: right[i].start_line });
429
- } else {
430
- unchanged += 1;
431
- }
432
- }
433
- for (let i = paired; i < left.length; i += 1) {
434
- removed.push({ ...identity, start_line: left[i].start_line, language: left[i].attributes.language });
435
- }
436
- for (let i = paired; i < right.length; i += 1) {
437
- added.push({ ...identity, start_line: right[i].start_line, language: right[i].attributes.language });
438
- }
439
- }
440
- return { added, removed, changed, moved, unchanged };
441
- }
442
-
443
- function diffEdges(sideA, sideB) {
444
- const added = [];
445
- const removed = [];
446
- let common = 0;
447
- const keys = new Set([...sideA.edgeCounts.keys(), ...sideB.edgeCounts.keys()]);
448
- for (const key of keys) {
449
- const identity = sideA.edgeMeta.get(key) ?? sideB.edgeMeta.get(key);
450
- const left = sideA.edgeCounts.get(key) ?? 0;
451
- const right = sideB.edgeCounts.get(key) ?? 0;
452
- common += Math.min(left, right);
453
- for (let i = 0; i < left - right; i += 1) removed.push(identity);
454
- for (let i = 0; i < right - left; i += 1) added.push(identity);
455
- }
456
- return { added, removed, common };
457
- }
458
-
459
- function diffFiles(sideA, sideB) {
460
- const added = [];
461
- const removed = [];
462
- const changed = [];
463
- let identicalContent = 0;
464
- let common = 0;
465
- for (const [filePath, left] of sideA.files) {
466
- const right = sideB.files.get(filePath);
467
- if (right === undefined) { removed.push({ path: filePath, language: left.language }); continue; }
468
- common += 1;
469
- if (left.content_hash === right.content_hash) identicalContent += 1;
470
- if (left.language !== right.language) {
471
- changed.push({ path: filePath, field: 'language', a: left.language, b: right.language });
472
- }
473
- }
474
- for (const [filePath, right] of sideB.files) {
475
- if (!sideA.files.has(filePath)) added.push({ path: filePath, language: right.language });
476
- }
477
- return { added, removed, changed, common, identical_content: identicalContent };
478
- }
479
-
480
- function tally(items, pick) {
481
- const counts = {};
482
- for (const item of items) {
483
- const value = pick(item) ?? 'unknown';
484
- counts[value] = (counts[value] ?? 0) + 1;
485
- }
486
- return Object.fromEntries(Object.entries(counts).sort((a, b) => b[1] - a[1]));
487
- }
488
-
489
- function fieldTally(changed) {
490
- const counts = {};
491
- for (const entry of changed) for (const field of entry.fields) counts[field] = (counts[field] ?? 0) + 1;
492
- return Object.fromEntries(Object.entries(counts).sort((a, b) => b[1] - a[1]));
493
- }
494
-
495
- /** 明細は切ってよいが、切ったことと切った量は必ず出す。件数summaryは常に正確。 */
496
- function applyLimit(lists, limit, truncation) {
497
- if (limit === 0) return lists;
498
- const out = {};
499
- for (const [name, items] of Object.entries(lists)) {
500
- if (items.length <= limit) { out[name] = items; continue; }
501
- out[name] = items.slice(0, limit);
502
- truncation[name] = { returned: limit, omitted: items.length - limit, total: items.length };
503
- }
504
- return out;
505
- }
506
-
507
- /** added/removedは`start_line`、movedは`a_start_line`、changedは`a.start_line`に行を持つ。 */
508
- function entryLine(entry) {
509
- return entry.start_line ?? entry.a_start_line ?? entry.a?.start_line ?? 0;
510
- }
511
-
512
- function sortNodeEntries(entries) {
513
- return entries.sort((a, b) => a.file_path.localeCompare(b.file_path)
514
- || entryLine(a) - entryLine(b)
515
- || a.qualified_name.localeCompare(b.qualified_name));
516
- }
517
-
518
- function sortEdgeEntries(entries) {
519
- return entries.sort((a, b) => a.source.file_path.localeCompare(b.source.file_path)
520
- || a.source.qualified_name.localeCompare(b.source.qualified_name)
521
- || a.kind.localeCompare(b.kind)
522
- || a.target.qualified_name.localeCompare(b.target.qualified_name));
523
- }
524
-
525
- /**
526
- * 2つのindexを突き合わせて差分を返す。片側にindexが無ければ SensorDiffError を投げる
527
- * (勝手に索引しない——索引はrepoの状態を書き換える操作であり、差分を見に来た人の依頼ではない)。
528
- */
529
- export function compareSensorIndexes(request) {
530
- const limit = request.limit ?? DEFAULT_LIMIT;
531
- const handleA = openIndex(request.a.root);
532
- let handleB;
533
- try {
534
- handleB = openIndex(request.b.root);
535
- } catch (error) {
536
- handleA.db.close();
537
- throw error;
538
- }
539
- try {
540
- // 読み出す前にschemaを確かめる。読んでから落ちると、素性を返す機会そのものを失う。
541
- assertReadableSchema(handleA, 'a');
542
- assertReadableSchema(handleB, 'b');
543
- const sideA = withReadSnapshot(handleA, () => loadSide(
544
- handleA, makePathNormalizer(request.a.subtree ?? '', request.a.mappings ?? []), 'a',
545
- ));
546
- const sideB = withReadSnapshot(handleB, () => loadSide(
547
- handleB, makePathNormalizer(request.b.subtree ?? '', request.b.mappings ?? []), 'b',
548
- ));
549
- const comparability = assessComparability(sideA, sideB);
550
-
551
- const nodes = diffNodes(sideA, sideB);
552
- const edges = diffEdges(sideA, sideB);
553
- const files = diffFiles(sideA, sideB);
554
-
555
- const truncation = {};
556
- const lists = applyLimit({
557
- 'files.added': files.added.sort((x, y) => x.path.localeCompare(y.path)),
558
- 'files.removed': files.removed.sort((x, y) => x.path.localeCompare(y.path)),
559
- 'files.changed': files.changed.sort((x, y) => x.path.localeCompare(y.path)),
560
- 'nodes.added': sortNodeEntries(nodes.added),
561
- 'nodes.removed': sortNodeEntries(nodes.removed),
562
- 'nodes.changed': sortNodeEntries(nodes.changed),
563
- 'nodes.moved': sortNodeEntries(nodes.moved),
564
- 'edges.added': sortEdgeEntries(edges.added),
565
- 'edges.removed': sortEdgeEntries(edges.removed),
566
- }, limit, truncation);
567
-
568
- return {
569
- schema: 'lattice.sensor_diff_result.v1',
570
- provider: 'lattice',
571
- sensor_owner: 'lattice',
572
- command: 'diff',
573
- a: {
574
- root: handleA.root,
575
- database: handleA.dbPath,
576
- subtree: request.a.subtree ?? '',
577
- indexed: sideA.counts,
578
- },
579
- b: {
580
- root: handleB.root,
581
- database: handleB.dbPath,
582
- subtree: request.b.subtree ?? '',
583
- indexed: sideB.counts,
584
- },
585
- comparability,
586
- summary: {
587
- files: {
588
- added: files.added.length,
589
- removed: files.removed.length,
590
- changed: files.changed.length,
591
- common: files.common,
592
- identical_content: files.identical_content,
593
- },
594
- nodes: {
595
- added: nodes.added.length,
596
- removed: nodes.removed.length,
597
- changed: nodes.changed.length,
598
- moved: nodes.moved.length,
599
- unchanged: nodes.unchanged,
600
- },
601
- nodes_added_by_kind: tally(nodes.added, (n) => n.kind),
602
- nodes_removed_by_kind: tally(nodes.removed, (n) => n.kind),
603
- nodes_added_by_language: tally(nodes.added, (n) => n.language),
604
- nodes_removed_by_language: tally(nodes.removed, (n) => n.language),
605
- nodes_changed_by_field: fieldTally(nodes.changed),
606
- edges: { added: edges.added.length, removed: edges.removed.length, common: edges.common },
607
- edges_added_by_kind: tally(edges.added, (e) => e.kind),
608
- edges_removed_by_kind: tally(edges.removed, (e) => e.kind),
609
- },
610
- excluded: { a: sideA.excluded, b: sideB.excluded },
611
- integrity: { a: sideA.integrity, b: sideB.integrity },
612
- limit,
613
- truncation,
614
- files: { added: lists['files.added'], removed: lists['files.removed'], changed: lists['files.changed'] },
615
- nodes: {
616
- added: lists['nodes.added'],
617
- removed: lists['nodes.removed'],
618
- changed: lists['nodes.changed'],
619
- moved: lists['nodes.moved'],
620
- },
621
- edges: { added: lists['edges.added'], removed: lists['edges.removed'] },
622
- };
623
- } finally {
624
- handleA.db.close();
625
- handleB.db.close();
626
- }
627
- }
628
-
629
- /**
630
- * `sensor diff` のargvを解釈する。解釈できない指定はnullを返し、callerがusage errorにする
631
- * (知らないoptionを黙って捨てると、指定したつもりの絞り込みが効かないまま差分を信じてしまう)。
632
- */
633
- export function parseSensorDiffArgs(words) {
634
- if (words.length < 2) return null;
635
- const request = {
636
- a: { root: words[0], subtree: '', mappings: [] },
637
- b: { root: words[1], subtree: '', mappings: [] },
638
- limit: DEFAULT_LIMIT,
639
- };
640
- if (request.a.root.startsWith('--') || request.b.root.startsWith('--')) return null;
641
- for (let i = 2; i < words.length; i += 1) {
642
- const flag = words[i];
643
- const value = words[i + 1];
644
- if (value === undefined || value.startsWith('--')) return null;
645
- if (flag === '--subtree-a') request.a.subtree = normalizeRelative(value);
646
- else if (flag === '--subtree-b') request.b.subtree = normalizeRelative(value);
647
- else if (flag === '--map-a' || flag === '--map-b') {
648
- const mapping = parseMapping(value);
649
- if (mapping === null) return null;
650
- (flag === '--map-a' ? request.a : request.b).mappings.push(mapping);
651
- } else if (flag === '--limit') {
652
- // 桁だけ見てNumber()へ渡すと、長い10進数がInfinityになって上限が消える。
653
- if (!/^\d+$/u.test(value)) return null;
654
- const parsed = Number(value);
655
- if (!Number.isSafeInteger(parsed)) return null;
656
- request.limit = parsed;
657
- } else return null;
658
- i += 1;
659
- }
660
- return request;
661
- }
1
+ /**
2
+ * 2つのindexの構造グラフ差分。
3
+ *
4
+ * 動機は upstream追従である。sensor/ には由来元のupstreamがあり、54コミット分の差分を
5
+ * 人がgrepとdiffで消化した(由来の正本は sensor/UPSTREAM.json)。両側のindexへ別々に
6
+ * 照会はできても、差分を機械列挙する入口が無かった。ここがその入口である。
7
+ * 用途はupstream追従に限らない——2つのtreeの構造グラフを突き合わせる汎用の入口である。
8
+ *
9
+ * ## なぜ node id で突き合わせないか
10
+ *
11
+ * node idは `<kind>:` + sha256(`相対path:kind:name:start_line`) であり、**行番号が埋まっている**。
12
+ * idで突き合わせると、関数が数行ずれただけで両側とも別idになり、そのnodeも、そのnodeを
13
+ * 端点に持つ全ての辺も、偽の追加+削除として出る。54コミット級では差分が全て偽陽性で埋まる。
14
+ * よって突き合わせは行番号を含まない自然キー `kind|file_path|qualified_name|name` で行い、
15
+ * 辺も端点idを自然キーへ解決してから比べる。
16
+ *
17
+ * ## 同一キーが複数ある時(overload・無名関数・同名の重複定義)
18
+ *
19
+ * 自然キーは一意でない。同キーの集団は両側をstart_line順に並べて先頭から対にする。対の中で
20
+ * 属性が違えば changed、属性同一で行だけ違えば moved、余った分が added/removed になる。
21
+ * 対応付けは発見的であり、「10個の無名callbackのうち3番目が消えた」を正確に指す保証は無い。
22
+ * 件数と、その集団に何が起きたかは正しく出る。
23
+ *
24
+ * ## 出さないもの
25
+ *
26
+ * 揮発列(updated_at・indexed_at・modified_at・size・content_hash)は比較しない。
27
+ * 端点を解決できない辺、subtree外へ出る辺は比較から外すが、**件数をexcludedへ必ず出す**。
28
+ * 黙って落とすと「差分なし」と「見ていない」が同じ顔になる。
29
+ *
30
+ * ## 規模の前提
31
+ *
32
+ * 両側のnode・edge・fileを全件メモリへ載せてから突き合わせる。件数summaryを正確に出す以上、
33
+ * 全件を見ないと済まないからである。`--limit`が切るのは**出力の明細だけ**で、読み込み量は
34
+ * 切らない。実測の目安は片側17k nodes/72k edgesで数百MB未満だが、桁が2つ上がるindexでは
35
+ * この前提ごと見直しが要る(streaming差分は別設計になる)。
36
+ */
37
+
38
+ import { existsSync } from 'node:fs';
39
+ import path from 'node:path';
40
+ import { DatabaseSync } from 'node:sqlite';
41
+
42
+ export const SENSOR_DB_RELATIVE_PATH = path.join('.lattice', 'sensor', 'sensor.db');
43
+
44
+ const DEFAULT_LIMIT = 200;
45
+ // 制御文字を区切りに使う。識別子・path・qualified nameのどれにも現れないので、
46
+ // 名前の中の記号が偶然キーを割るという壊れ方をしない。
47
+ const KEY_SEPARATOR = '\u0000';
48
+ const EDGE_SEPARATOR = '\u0001';
49
+
50
+ /** 対で比べる属性。差分は「どの属性が変わったか」を名前で返し、消費側が絞れるようにする。 */
51
+ const COMPARED_NODE_FIELDS = Object.freeze([
52
+ 'language', 'signature', 'visibility', 'return_type', 'docstring',
53
+ 'decorators', 'type_parameters',
54
+ 'is_exported', 'is_async', 'is_static', 'is_abstract',
55
+ 'span', 'extent_offset',
56
+ ]);
57
+
58
+ export class SensorDiffError extends Error {
59
+ constructor(code, message, detail) {
60
+ super(message);
61
+ this.name = 'SensorDiffError';
62
+ this.code = code;
63
+ this.detail = detail ?? {};
64
+ }
65
+ }
66
+
67
+ /** `from=to` を1件解釈する。`=` を含まない指定は黙って無視せずusage errorにする。 */
68
+ function parseMapping(spec) {
69
+ const index = spec.indexOf('=');
70
+ if (index <= 0 || index === spec.length - 1) return null;
71
+ return { from: normalizeRelative(spec.slice(0, index)), to: normalizeRelative(spec.slice(index + 1)) };
72
+ }
73
+
74
+ function normalizeRelative(value) {
75
+ const normalized = value.replaceAll('\\', '/')
76
+ .replace(/^\.\//u, '').replace(/^\/+/u, '').replace(/\/+$/u, '');
77
+ // `.` はrootそのものを指す慣用である。これを剥がさないとprefixが `./` になり、
78
+ // どのpathにも一致せず「全件がsubtree外」=差分ゼロという静かな嘘になる。
79
+ return normalized === '.' ? '' : normalized;
80
+ }
81
+
82
+ /**
83
+ * subtreeで絞ってprefixを剥がし、改名写像を当てる。写像は最長prefix一致で1回だけ適用する
84
+ * (連鎖適用すると写像順で結果が変わり、再現しない)。
85
+ */
86
+ function makePathNormalizer(subtree, mappings) {
87
+ const prefix = subtree === '' ? '' : `${subtree}/`;
88
+ const ordered = [...mappings].sort((a, b) => b.from.length - a.from.length);
89
+ return (rawPath) => {
90
+ const normalized = normalizeRelative(rawPath);
91
+ if (prefix !== '' && !normalized.startsWith(prefix)) return null;
92
+ let result = prefix === '' ? normalized : normalized.slice(prefix.length);
93
+ for (const { from, to } of ordered) {
94
+ if (result === from) { result = to; break; }
95
+ if (result.startsWith(`${from}/`)) { result = `${to}/${result.slice(from.length + 1)}`; break; }
96
+ }
97
+ return result;
98
+ };
99
+ }
100
+
101
+ function openIndex(root) {
102
+ const resolvedRoot = path.resolve(root);
103
+ const dbPath = path.join(resolvedRoot, SENSOR_DB_RELATIVE_PATH);
104
+ if (!existsSync(dbPath)) {
105
+ throw new SensorDiffError(
106
+ 'LATTICE_SENSOR_DIFF_INDEX_MISSING',
107
+ `no sensor index at ${dbPath}`,
108
+ { root: resolvedRoot, database: dbPath, next_action: `lattice sensor init ${root} --json` },
109
+ );
110
+ }
111
+ try {
112
+ return { root: resolvedRoot, dbPath, db: new DatabaseSync(dbPath, { readOnly: true }) };
113
+ } catch (error) {
114
+ throw new SensorDiffError(
115
+ 'LATTICE_SENSOR_DIFF_INDEX_UNREADABLE',
116
+ `cannot read sensor index at ${dbPath}`,
117
+ { root: resolvedRoot, database: dbPath, cause: error?.message ?? 'unknown error' },
118
+ );
119
+ }
120
+ }
121
+
122
+ /** 読み出しが要求する列。1つでも欠ければindexが古い。 */
123
+ const REQUIRED_COLUMNS = Object.freeze({
124
+ nodes: ['id', 'kind', 'name', 'qualified_name', 'file_path', 'language', 'start_line', 'end_line',
125
+ 'signature', 'visibility', 'return_type', 'docstring', 'decorators', 'type_parameters',
126
+ 'extent_start_line', 'is_exported', 'is_async', 'is_static', 'is_abstract'],
127
+ edges: ['source', 'target', 'kind'],
128
+ files: ['path', 'language', 'content_hash', 'node_count', 'extraction_version', 'errors'],
129
+ });
130
+
131
+ /**
132
+ * 読み出す前にschemaを確かめる。確かめないと、古いindexとの比較が「列が無い」というSQLiteの
133
+ * 生の例外で落ちる——comparabilityを返す前に死ぬので、degradedにすら降りられない。
134
+ */
135
+ function assertReadableSchema(handle, side) {
136
+ const missing = [];
137
+ for (const [table, columns] of Object.entries(REQUIRED_COLUMNS)) {
138
+ const present = new Set(handle.db.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
139
+ if (present.size === 0) { missing.push(`${table}.*`); continue; }
140
+ for (const column of columns) if (!present.has(column)) missing.push(`${table}.${column}`);
141
+ }
142
+ if (missing.length === 0) return;
143
+ throw new SensorDiffError(
144
+ 'LATTICE_SENSOR_DIFF_SCHEMA_UNSUPPORTED',
145
+ `sensor index schema at ${handle.dbPath} is missing columns this diff requires`,
146
+ {
147
+ side,
148
+ root: handle.root,
149
+ database: handle.dbPath,
150
+ missing_columns: missing,
151
+ next_action: `lattice sensor init ${handle.root} --json`,
152
+ },
153
+ );
154
+ }
155
+
156
+ /**
157
+ * 片側の読み出しを1つの読み取りtransactionで囲う。囲わないと、並行するsyncのcommitが
158
+ * node読出しとedge読出しの間へ割り込み、実在しないdanglingや偽の差分を作る。
159
+ * A側とB側は別DBなので、両者が同一時刻の断面である保証はここでは得られない(片側ずつの
160
+ * 内部整合だけを保証する)。
161
+ */
162
+ function withReadSnapshot(handle, read) {
163
+ handle.db.exec('BEGIN DEFERRED');
164
+ try {
165
+ const result = read();
166
+ handle.db.exec('COMMIT');
167
+ return result;
168
+ } catch (error) {
169
+ try { handle.db.exec('ROLLBACK'); } catch { /* 元の失敗を潰さない */ }
170
+ throw error;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * qualified nameがfile pathを埋め込む種類のnode(file node の qualified_name は
176
+ * `sub/lib.mjs` そのもの)は、pathへ当てた subtree 剥がしと改名写像を qualified name にも
177
+ * 当てる。当てないと、揃えたはずの2つのtreeでfile nodeだけが追加+削除として残る。
178
+ */
179
+ function rewriteQualifiedName(qualifiedName, rawPath, normalizedPath) {
180
+ if (qualifiedName === rawPath) return normalizedPath;
181
+ for (const separator of ['/', ':', '#']) {
182
+ if (qualifiedName.startsWith(`${rawPath}${separator}`)) {
183
+ return `${normalizedPath}${qualifiedName.slice(rawPath.length)}`;
184
+ }
185
+ }
186
+ return qualifiedName;
187
+ }
188
+
189
+ function naturalKey(kind, filePath, qualifiedName, name) {
190
+ return `${kind}${KEY_SEPARATOR}${filePath}${KEY_SEPARATOR}${qualifiedName}${KEY_SEPARATOR}${name}`;
191
+ }
192
+
193
+ /**
194
+ * `files.errors`は部分抽出時のerrorを持つ。抽出が途中で折れたfileはnodeが欠けたまま
195
+ * 正常なfileの顔で並ぶので、数えて表に出さないと「差分なし」と「抽出が不完全」が同じ顔になる。
196
+ */
197
+ function hasExtractionErrors(raw) {
198
+ if (raw === null || raw === undefined || raw === '') return false;
199
+ try {
200
+ const parsed = JSON.parse(raw);
201
+ return Array.isArray(parsed) ? parsed.length > 0 : Boolean(parsed);
202
+ } catch {
203
+ // 読めないerrors列そのものが異状である。無かったことにしない。
204
+ return true;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * 片側を読み出して、自然キーで索き直した形にする。
210
+ *
211
+ * キーは突き合わせにしか使わず、出力へ戻す時は`keyMeta`/`edgeMeta`に控えた構造をそのまま
212
+ * 使う。キー文字列を割って復元しない——POSIXのfile名は `/` とNUL以外の制御文字を許すので、
213
+ * 区切りに選んだ文字がpathに現れた瞬間に復元が壊れる。
214
+ *
215
+ * `idToKey`は辺の端点解決に使う。subtree外のnodeはキーを持たないので、そのnodeを端点に持つ
216
+ * 辺は比較対象外になる(件数はcallerがexcludedへ出す)。
217
+ */
218
+ function loadSide(handle, normalizePath, side) {
219
+ const nodesByKey = new Map();
220
+ const keyMeta = new Map();
221
+ const edgeMeta = new Map();
222
+ const idToKey = new Map();
223
+ // subtreeで外したidも覚えておく。「indexが壊れている」と「比較の射程外」は原因が違うので、
224
+ // 除外件数を1つにまとめない。
225
+ const knownIds = new Set();
226
+ let nodesOutsideSubtree = 0;
227
+
228
+ const nodeRows = handle.db.prepare(`
229
+ SELECT id, kind, name, qualified_name, file_path, language,
230
+ start_line, end_line, signature, visibility, return_type, docstring,
231
+ decorators, type_parameters, extent_start_line,
232
+ is_exported, is_async, is_static, is_abstract
233
+ FROM nodes
234
+ `).all();
235
+ for (const row of nodeRows) {
236
+ knownIds.add(row.id);
237
+ const filePath = normalizePath(row.file_path);
238
+ if (filePath === null) { nodesOutsideSubtree += 1; continue; }
239
+ const qualifiedName = rewriteQualifiedName(
240
+ row.qualified_name, normalizeRelative(row.file_path), filePath,
241
+ );
242
+ const key = naturalKey(row.kind, filePath, qualifiedName, row.name);
243
+ const entry = {
244
+ key,
245
+ start_line: row.start_line,
246
+ attributes: {
247
+ language: row.language,
248
+ signature: row.signature,
249
+ visibility: row.visibility,
250
+ return_type: row.return_type,
251
+ docstring: row.docstring,
252
+ decorators: row.decorators,
253
+ type_parameters: row.type_parameters,
254
+ is_exported: row.is_exported,
255
+ is_async: row.is_async,
256
+ is_static: row.is_static,
257
+ is_abstract: row.is_abstract,
258
+ span: row.end_line - row.start_line,
259
+ extent_offset: row.extent_start_line === null ? null : row.extent_start_line - row.start_line,
260
+ },
261
+ };
262
+ const bucket = nodesByKey.get(key);
263
+ if (bucket === undefined) {
264
+ nodesByKey.set(key, [entry]);
265
+ keyMeta.set(key, {
266
+ kind: row.kind, file_path: filePath, qualified_name: qualifiedName, name: row.name,
267
+ });
268
+ } else bucket.push(entry);
269
+ idToKey.set(row.id, key);
270
+ }
271
+ for (const bucket of nodesByKey.values()) bucket.sort((a, b) => a.start_line - b.start_line);
272
+
273
+ const edgeCounts = new Map();
274
+ let danglingEdges = 0;
275
+ let edgesOutsideSubtree = 0;
276
+ for (const row of handle.db.prepare('SELECT source, target, kind FROM edges').all()) {
277
+ const source = idToKey.get(row.source);
278
+ const target = idToKey.get(row.target);
279
+ if (source === undefined || target === undefined) {
280
+ // nodes表に居ないidはindexの不整合(dangling)、居るのにキーが無いならsubtreeで外した分。
281
+ if (knownIds.has(row.source) && knownIds.has(row.target)) edgesOutsideSubtree += 1;
282
+ else danglingEdges += 1;
283
+ continue;
284
+ }
285
+ const key = `${source}${EDGE_SEPARATOR}${target}${EDGE_SEPARATOR}${row.kind}`;
286
+ const seen = edgeCounts.get(key);
287
+ if (seen === undefined) {
288
+ edgeCounts.set(key, 1);
289
+ edgeMeta.set(key, { kind: row.kind, source: keyMeta.get(source), target: keyMeta.get(target) });
290
+ } else edgeCounts.set(key, seen + 1);
291
+ }
292
+
293
+ const files = new Map();
294
+ const rawPathByNormalized = new Map();
295
+ let filesOutsideSubtree = 0;
296
+ let filesWithExtractionErrors = 0;
297
+ for (const row of handle.db.prepare('SELECT path, language, content_hash, node_count, extraction_version, errors FROM files').all()) {
298
+ const filePath = normalizePath(row.path);
299
+ if (filePath === null) { filesOutsideSubtree += 1; continue; }
300
+ const collided = rawPathByNormalized.get(filePath);
301
+ if (collided !== undefined) {
302
+ // 写像が2つの実在fileを同じpathへ潰した。片方が黙って消えると、消えた側の全symbolが
303
+ // 「無かったこと」になり、差分は静かに嘘をつく。指定した人にしか直せないので止める。
304
+ throw new SensorDiffError(
305
+ 'LATTICE_SENSOR_DIFF_PATH_COLLISION',
306
+ `path mapping collapses two indexed files onto "${filePath}"`,
307
+ { side, root: handle.root, normalized_path: filePath, source_paths: [collided, row.path] },
308
+ );
309
+ }
310
+ rawPathByNormalized.set(filePath, row.path);
311
+ if (hasExtractionErrors(row.errors)) filesWithExtractionErrors += 1;
312
+ files.set(filePath, {
313
+ language: row.language,
314
+ content_hash: row.content_hash,
315
+ node_count: row.node_count,
316
+ extraction_version: row.extraction_version,
317
+ });
318
+ }
319
+
320
+ return {
321
+ nodesByKey,
322
+ keyMeta,
323
+ edgeCounts,
324
+ edgeMeta,
325
+ files,
326
+ schema_version: handle.db.prepare('SELECT MAX(version) AS v FROM schema_versions').get()?.v ?? null,
327
+ counts: {
328
+ nodes: nodeRows.length - nodesOutsideSubtree,
329
+ edges: [...edgeCounts.values()].reduce((sum, n) => sum + n, 0),
330
+ files: files.size,
331
+ },
332
+ integrity: { files_with_extraction_errors: filesWithExtractionErrors },
333
+ excluded: {
334
+ nodes_outside_subtree: nodesOutsideSubtree,
335
+ files_outside_subtree: filesOutsideSubtree,
336
+ edges_outside_subtree: edgesOutsideSubtree,
337
+ dangling_edge_endpoints: danglingEdges,
338
+ },
339
+ };
340
+ }
341
+
342
+ function versionHistogram(files) {
343
+ const counts = {};
344
+ for (const record of files.values()) {
345
+ counts[record.extraction_version] = (counts[record.extraction_version] ?? 0) + 1;
346
+ }
347
+ return Object.fromEntries(Object.entries(counts).sort((x, y) => Number(x[0]) - Number(y[0])));
348
+ }
349
+
350
+ /**
351
+ * indexそのものの素性を突き合わせる。ここが食い違うなら、差分はcodeの変化とは限らない。
352
+ *
353
+ * extraction versionは集合でなく**同じfileどうしで**比べる。集合だけを見ると、
354
+ * A={x:24,y:25}/B={x:25,y:24} のように対応が入れ替わっていても両方 {24,25} で一致し、
355
+ * 中断したincremental syncが作る本物の食い違いを取りこぼす。
356
+ */
357
+ function assessComparability(sideA, sideB) {
358
+ const reasons = [];
359
+ if (sideA.schema_version !== sideB.schema_version) {
360
+ reasons.push(`schema version differs (a=${sideA.schema_version}, b=${sideB.schema_version})`);
361
+ }
362
+ let mismatched = 0;
363
+ for (const [filePath, left] of sideA.files) {
364
+ const right = sideB.files.get(filePath);
365
+ if (right !== undefined && left.extraction_version !== right.extraction_version) mismatched += 1;
366
+ }
367
+ if (mismatched > 0) {
368
+ reasons.push(`${mismatched} common file(s) were extracted at different extraction versions`);
369
+ }
370
+ const errored = sideA.integrity.files_with_extraction_errors
371
+ + sideB.integrity.files_with_extraction_errors;
372
+ if (errored > 0) {
373
+ reasons.push(`${errored} indexed file(s) recorded extraction errors`
374
+ + ` (a=${sideA.integrity.files_with_extraction_errors},`
375
+ + ` b=${sideB.integrity.files_with_extraction_errors});`
376
+ + ' their symbols may be missing rather than removed');
377
+ }
378
+ return {
379
+ // degradedは「比べるな」ではなく「この差分はcodeの変化だけを意味しない」という宣言である。
380
+ status: reasons.length === 0 ? 'ok' : 'degraded',
381
+ reasons,
382
+ extraction_version_mismatched_files: mismatched,
383
+ a: {
384
+ schema_version: sideA.schema_version,
385
+ extraction_versions: versionHistogram(sideA.files),
386
+ files_with_extraction_errors: sideA.integrity.files_with_extraction_errors,
387
+ },
388
+ b: {
389
+ schema_version: sideB.schema_version,
390
+ extraction_versions: versionHistogram(sideB.files),
391
+ files_with_extraction_errors: sideB.integrity.files_with_extraction_errors,
392
+ },
393
+ };
394
+ }
395
+
396
+ function changedFields(left, right) {
397
+ const fields = [];
398
+ for (const field of COMPARED_NODE_FIELDS) {
399
+ if (left.attributes[field] !== right.attributes[field]) fields.push(field);
400
+ }
401
+ return fields;
402
+ }
403
+
404
+ function diffNodes(sideA, sideB) {
405
+ const added = [];
406
+ const removed = [];
407
+ const changed = [];
408
+ const moved = [];
409
+ let unchanged = 0;
410
+
411
+ const keys = new Set([...sideA.nodesByKey.keys(), ...sideB.nodesByKey.keys()]);
412
+ for (const key of keys) {
413
+ // 出力の名前・pathはキー文字列を割って復元せず、読み出し時に控えた構造をそのまま使う。
414
+ const identity = sideA.keyMeta.get(key) ?? sideB.keyMeta.get(key);
415
+ const left = sideA.nodesByKey.get(key) ?? [];
416
+ const right = sideB.nodesByKey.get(key) ?? [];
417
+ const paired = Math.min(left.length, right.length);
418
+ for (let i = 0; i < paired; i += 1) {
419
+ const fields = changedFields(left[i], right[i]);
420
+ if (fields.length > 0) {
421
+ changed.push({
422
+ ...identity,
423
+ fields,
424
+ a: { start_line: left[i].start_line },
425
+ b: { start_line: right[i].start_line },
426
+ });
427
+ } else if (left[i].start_line !== right[i].start_line) {
428
+ moved.push({ ...identity, a_start_line: left[i].start_line, b_start_line: right[i].start_line });
429
+ } else {
430
+ unchanged += 1;
431
+ }
432
+ }
433
+ for (let i = paired; i < left.length; i += 1) {
434
+ removed.push({ ...identity, start_line: left[i].start_line, language: left[i].attributes.language });
435
+ }
436
+ for (let i = paired; i < right.length; i += 1) {
437
+ added.push({ ...identity, start_line: right[i].start_line, language: right[i].attributes.language });
438
+ }
439
+ }
440
+ return { added, removed, changed, moved, unchanged };
441
+ }
442
+
443
+ function diffEdges(sideA, sideB) {
444
+ const added = [];
445
+ const removed = [];
446
+ let common = 0;
447
+ const keys = new Set([...sideA.edgeCounts.keys(), ...sideB.edgeCounts.keys()]);
448
+ for (const key of keys) {
449
+ const identity = sideA.edgeMeta.get(key) ?? sideB.edgeMeta.get(key);
450
+ const left = sideA.edgeCounts.get(key) ?? 0;
451
+ const right = sideB.edgeCounts.get(key) ?? 0;
452
+ common += Math.min(left, right);
453
+ for (let i = 0; i < left - right; i += 1) removed.push(identity);
454
+ for (let i = 0; i < right - left; i += 1) added.push(identity);
455
+ }
456
+ return { added, removed, common };
457
+ }
458
+
459
+ function diffFiles(sideA, sideB) {
460
+ const added = [];
461
+ const removed = [];
462
+ const changed = [];
463
+ let identicalContent = 0;
464
+ let common = 0;
465
+ for (const [filePath, left] of sideA.files) {
466
+ const right = sideB.files.get(filePath);
467
+ if (right === undefined) { removed.push({ path: filePath, language: left.language }); continue; }
468
+ common += 1;
469
+ if (left.content_hash === right.content_hash) identicalContent += 1;
470
+ if (left.language !== right.language) {
471
+ changed.push({ path: filePath, field: 'language', a: left.language, b: right.language });
472
+ }
473
+ }
474
+ for (const [filePath, right] of sideB.files) {
475
+ if (!sideA.files.has(filePath)) added.push({ path: filePath, language: right.language });
476
+ }
477
+ return { added, removed, changed, common, identical_content: identicalContent };
478
+ }
479
+
480
+ function tally(items, pick) {
481
+ const counts = {};
482
+ for (const item of items) {
483
+ const value = pick(item) ?? 'unknown';
484
+ counts[value] = (counts[value] ?? 0) + 1;
485
+ }
486
+ return Object.fromEntries(Object.entries(counts).sort((a, b) => b[1] - a[1]));
487
+ }
488
+
489
+ function fieldTally(changed) {
490
+ const counts = {};
491
+ for (const entry of changed) for (const field of entry.fields) counts[field] = (counts[field] ?? 0) + 1;
492
+ return Object.fromEntries(Object.entries(counts).sort((a, b) => b[1] - a[1]));
493
+ }
494
+
495
+ /** 明細は切ってよいが、切ったことと切った量は必ず出す。件数summaryは常に正確。 */
496
+ function applyLimit(lists, limit, truncation) {
497
+ if (limit === 0) return lists;
498
+ const out = {};
499
+ for (const [name, items] of Object.entries(lists)) {
500
+ if (items.length <= limit) { out[name] = items; continue; }
501
+ out[name] = items.slice(0, limit);
502
+ truncation[name] = { returned: limit, omitted: items.length - limit, total: items.length };
503
+ }
504
+ return out;
505
+ }
506
+
507
+ /** added/removedは`start_line`、movedは`a_start_line`、changedは`a.start_line`に行を持つ。 */
508
+ function entryLine(entry) {
509
+ return entry.start_line ?? entry.a_start_line ?? entry.a?.start_line ?? 0;
510
+ }
511
+
512
+ function sortNodeEntries(entries) {
513
+ return entries.sort((a, b) => a.file_path.localeCompare(b.file_path)
514
+ || entryLine(a) - entryLine(b)
515
+ || a.qualified_name.localeCompare(b.qualified_name));
516
+ }
517
+
518
+ function sortEdgeEntries(entries) {
519
+ return entries.sort((a, b) => a.source.file_path.localeCompare(b.source.file_path)
520
+ || a.source.qualified_name.localeCompare(b.source.qualified_name)
521
+ || a.kind.localeCompare(b.kind)
522
+ || a.target.qualified_name.localeCompare(b.target.qualified_name));
523
+ }
524
+
525
+ /**
526
+ * 2つのindexを突き合わせて差分を返す。片側にindexが無ければ SensorDiffError を投げる
527
+ * (勝手に索引しない——索引はrepoの状態を書き換える操作であり、差分を見に来た人の依頼ではない)。
528
+ */
529
+ export function compareSensorIndexes(request) {
530
+ const limit = request.limit ?? DEFAULT_LIMIT;
531
+ const handleA = openIndex(request.a.root);
532
+ let handleB;
533
+ try {
534
+ handleB = openIndex(request.b.root);
535
+ } catch (error) {
536
+ handleA.db.close();
537
+ throw error;
538
+ }
539
+ try {
540
+ // 読み出す前にschemaを確かめる。読んでから落ちると、素性を返す機会そのものを失う。
541
+ assertReadableSchema(handleA, 'a');
542
+ assertReadableSchema(handleB, 'b');
543
+ const sideA = withReadSnapshot(handleA, () => loadSide(
544
+ handleA, makePathNormalizer(request.a.subtree ?? '', request.a.mappings ?? []), 'a',
545
+ ));
546
+ const sideB = withReadSnapshot(handleB, () => loadSide(
547
+ handleB, makePathNormalizer(request.b.subtree ?? '', request.b.mappings ?? []), 'b',
548
+ ));
549
+ const comparability = assessComparability(sideA, sideB);
550
+
551
+ const nodes = diffNodes(sideA, sideB);
552
+ const edges = diffEdges(sideA, sideB);
553
+ const files = diffFiles(sideA, sideB);
554
+
555
+ const truncation = {};
556
+ const lists = applyLimit({
557
+ 'files.added': files.added.sort((x, y) => x.path.localeCompare(y.path)),
558
+ 'files.removed': files.removed.sort((x, y) => x.path.localeCompare(y.path)),
559
+ 'files.changed': files.changed.sort((x, y) => x.path.localeCompare(y.path)),
560
+ 'nodes.added': sortNodeEntries(nodes.added),
561
+ 'nodes.removed': sortNodeEntries(nodes.removed),
562
+ 'nodes.changed': sortNodeEntries(nodes.changed),
563
+ 'nodes.moved': sortNodeEntries(nodes.moved),
564
+ 'edges.added': sortEdgeEntries(edges.added),
565
+ 'edges.removed': sortEdgeEntries(edges.removed),
566
+ }, limit, truncation);
567
+
568
+ return {
569
+ schema: 'lattice.sensor_diff_result.v1',
570
+ provider: 'lattice',
571
+ sensor_owner: 'lattice',
572
+ command: 'diff',
573
+ a: {
574
+ root: handleA.root,
575
+ database: handleA.dbPath,
576
+ subtree: request.a.subtree ?? '',
577
+ indexed: sideA.counts,
578
+ },
579
+ b: {
580
+ root: handleB.root,
581
+ database: handleB.dbPath,
582
+ subtree: request.b.subtree ?? '',
583
+ indexed: sideB.counts,
584
+ },
585
+ comparability,
586
+ summary: {
587
+ files: {
588
+ added: files.added.length,
589
+ removed: files.removed.length,
590
+ changed: files.changed.length,
591
+ common: files.common,
592
+ identical_content: files.identical_content,
593
+ },
594
+ nodes: {
595
+ added: nodes.added.length,
596
+ removed: nodes.removed.length,
597
+ changed: nodes.changed.length,
598
+ moved: nodes.moved.length,
599
+ unchanged: nodes.unchanged,
600
+ },
601
+ nodes_added_by_kind: tally(nodes.added, (n) => n.kind),
602
+ nodes_removed_by_kind: tally(nodes.removed, (n) => n.kind),
603
+ nodes_added_by_language: tally(nodes.added, (n) => n.language),
604
+ nodes_removed_by_language: tally(nodes.removed, (n) => n.language),
605
+ nodes_changed_by_field: fieldTally(nodes.changed),
606
+ edges: { added: edges.added.length, removed: edges.removed.length, common: edges.common },
607
+ edges_added_by_kind: tally(edges.added, (e) => e.kind),
608
+ edges_removed_by_kind: tally(edges.removed, (e) => e.kind),
609
+ },
610
+ excluded: { a: sideA.excluded, b: sideB.excluded },
611
+ integrity: { a: sideA.integrity, b: sideB.integrity },
612
+ limit,
613
+ truncation,
614
+ files: { added: lists['files.added'], removed: lists['files.removed'], changed: lists['files.changed'] },
615
+ nodes: {
616
+ added: lists['nodes.added'],
617
+ removed: lists['nodes.removed'],
618
+ changed: lists['nodes.changed'],
619
+ moved: lists['nodes.moved'],
620
+ },
621
+ edges: { added: lists['edges.added'], removed: lists['edges.removed'] },
622
+ };
623
+ } finally {
624
+ handleA.db.close();
625
+ handleB.db.close();
626
+ }
627
+ }
628
+
629
+ /**
630
+ * `sensor diff` のargvを解釈する。解釈できない指定はnullを返し、callerがusage errorにする
631
+ * (知らないoptionを黙って捨てると、指定したつもりの絞り込みが効かないまま差分を信じてしまう)。
632
+ */
633
+ export function parseSensorDiffArgs(words) {
634
+ if (words.length < 2) return null;
635
+ const request = {
636
+ a: { root: words[0], subtree: '', mappings: [] },
637
+ b: { root: words[1], subtree: '', mappings: [] },
638
+ limit: DEFAULT_LIMIT,
639
+ };
640
+ if (request.a.root.startsWith('--') || request.b.root.startsWith('--')) return null;
641
+ for (let i = 2; i < words.length; i += 1) {
642
+ const flag = words[i];
643
+ const value = words[i + 1];
644
+ if (value === undefined || value.startsWith('--')) return null;
645
+ if (flag === '--subtree-a') request.a.subtree = normalizeRelative(value);
646
+ else if (flag === '--subtree-b') request.b.subtree = normalizeRelative(value);
647
+ else if (flag === '--map-a' || flag === '--map-b') {
648
+ const mapping = parseMapping(value);
649
+ if (mapping === null) return null;
650
+ (flag === '--map-a' ? request.a : request.b).mappings.push(mapping);
651
+ } else if (flag === '--limit') {
652
+ // 桁だけ見てNumber()へ渡すと、長い10進数がInfinityになって上限が消える。
653
+ if (!/^\d+$/u.test(value)) return null;
654
+ const parsed = Number(value);
655
+ if (!Number.isSafeInteger(parsed)) return null;
656
+ request.limit = parsed;
657
+ } else return null;
658
+ i += 1;
659
+ }
660
+ return request;
661
+ }