@quolu/lattice 0.52.2 → 0.52.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/artifact-contracts-v2.mjs +325 -325
- package/src/artifact-contracts.mjs +895 -895
- package/src/boundary-compiler.mjs +712 -712
- package/src/boundary-observation-compiler-v2.mjs +344 -344
- package/src/bounded-seam.mjs +230 -230
- package/src/bridge-address.mjs +107 -107
- package/src/bridge-cli.mjs +297 -297
- package/src/bridge-config.mjs +346 -346
- package/src/bridge-daemon.mjs +378 -378
- package/src/bridge-hub-protocol.mjs +198 -0
- package/src/bridge-launch-agent.mjs +323 -323
- package/src/bridge-registrar.mjs +102 -102
- package/src/bridge-server.mjs +376 -376
- package/src/cli-help.mjs +308 -308
- package/src/cli-stdio.mjs +40 -40
- package/src/control-compiler.mjs +532 -532
- package/src/dag-chain.mjs +261 -261
- package/src/factory-diagnostics.mjs +188 -188
- package/src/git-process.mjs +72 -0
- package/src/hash-chain.mjs +76 -76
- package/src/hooks-cli.mjs +1057 -1057
- package/src/isolation-runner.mjs +409 -409
- package/src/node-version-guard.mjs +44 -44
- package/src/project-cli.mjs +699 -697
- package/src/project-identity.mjs +130 -130
- package/src/rc1-black-box-oracle.mjs +906 -906
- package/src/rc1-comparison.mjs +154 -154
- package/src/rc1-evidence-bundle.mjs +456 -456
- package/src/rc1-v4-campaign.mjs +708 -708
- package/src/rc1-v4-transform.mjs +467 -467
- package/src/rc1-v5-artifact-set.mjs +855 -855
- package/src/rc1-v5-behavior-evidence.mjs +594 -594
- package/src/rc1-v5-campaign.mjs +797 -797
- package/src/rc1-v5-transform.mjs +421 -421
- package/src/rc1-v6-artifact-set.mjs +807 -807
- package/src/rc1-v6-behavior-evidence.mjs +273 -273
- package/src/rc1-v6-campaign.mjs +623 -623
- package/src/rc1-v6-causal-binding.mjs +473 -473
- package/src/rc1-v6-measurement.mjs +313 -313
- package/src/rc2-artifact-set.mjs +1584 -1584
- package/src/rc2-campaign.mjs +1506 -1506
- package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
- package/src/rc2-delivery-policy-oracle.mjs +134 -134
- package/src/rc2-delivery-policy-transform.mjs +1127 -1127
- package/src/rc2-rc1-transfer-front-end.mjs +511 -511
- package/src/rc3-actual-dogfood.mjs +652 -652
- package/src/rc3-dogfood-scaffold.mjs +315 -315
- package/src/rc3-scripted-campaign.mjs +1383 -1383
- package/src/rc4-stage1-dogfood.mjs +673 -673
- package/src/runtime-adapter-registry.mjs +524 -524
- package/src/runtime-cli.mjs +4588 -4588
- package/src/runtime-contracts.mjs +824 -824
- package/src/runtime-control-store.mjs +604 -604
- package/src/runtime-controller-protocol.mjs +587 -587
- package/src/runtime-decision-verifier.mjs +701 -701
- package/src/runtime-diff-observer.mjs +361 -361
- package/src/runtime-direct-os-observer.mjs +301 -301
- package/src/runtime-driver-state.mjs +166 -166
- package/src/runtime-engine.mjs +779 -779
- package/src/runtime-errors.mjs +356 -356
- package/src/runtime-event-store.mjs +189 -189
- package/src/runtime-front-end.mjs +925 -925
- package/src/runtime-gate-store.mjs +481 -481
- package/src/runtime-hold-recompile.mjs +916 -916
- package/src/runtime-io-sentinel.mjs +391 -391
- package/src/runtime-lifecycle-lock.mjs +294 -294
- package/src/runtime-managed-supervisor.mjs +1490 -1490
- package/src/runtime-multi-epoch-store.mjs +838 -838
- package/src/runtime-projection.mjs +269 -269
- package/src/runtime-pull-intake.mjs +1192 -1192
- package/src/runtime-scripted-adapter-controller.mjs +1160 -1160
- package/src/runtime-scripted-executor.mjs +163 -163
- package/src/runtime-scripted-worktree.mjs +104 -104
- package/src/runtime-seam-resolve.mjs +428 -428
- package/src/runtime-seam-treatment.mjs +173 -173
- package/src/runtime-socket-owner.mjs +125 -125
- package/src/runtime-work-order-contracts.mjs +91 -91
- package/src/runtime-work-order-controller.mjs +1171 -1171
- package/src/runtime-worktree-executor.mjs +199 -199
- package/src/schedulability-compiler-v2.mjs +303 -303
- package/src/schedulability-verifier-v2.mjs +317 -317
- package/src/seam-apply.mjs +549 -549
- package/src/seam-commit-shared.mjs +22 -22
- package/src/seam-commit-transform.mjs +81 -81
- package/src/seam-commit.mjs +18 -18
- package/src/seam-cost.mjs +322 -322
- package/src/seam-derivation.mjs +188 -188
- package/src/seam-gate.mjs +146 -146
- package/src/seam-proposal-contracts.mjs +446 -446
- package/src/seam-proposal-queries.mjs +521 -521
- package/src/seam-proposal.mjs +2011 -2011
- package/src/seam-ref.mjs +33 -33
- package/src/seam-rewrite.mjs +286 -286
- package/src/seam-transform.mjs +554 -554
- package/src/seam-verification.mjs +260 -260
- package/src/sensor-adapter.mjs +432 -432
- package/src/sensor-cli.mjs +139 -139
- package/src/sensor-diff.mjs +661 -661
- package/src/sensor-node-runtime.mjs +53 -53
- package/src/sensor-runtime.mjs +52 -52
- package/src/timestamp-contract.mjs +8 -8
- package/src/todo-audit-pending.mjs +91 -91
- package/src/todo-chain.mjs +178 -178
- package/src/todo-cli.mjs +3143 -3141
- package/src/todo-contracts.mjs +728 -728
- package/src/todo-dashboard-registry.mjs +573 -573
- package/src/todo-dispatch-shape.mjs +190 -190
- package/src/todo-gantt-html-independence.mjs +239 -239
- package/src/todo-gantt-html-shared.mjs +226 -226
- package/src/todo-gantt-html-style.mjs +131 -131
- package/src/todo-gantt-html.mjs +248 -248
- package/src/todo-gantt-layout.mjs +974 -974
- package/src/todo-gantt-live.mjs +361 -361
- package/src/todo-gantt-nested.mjs +263 -263
- package/src/todo-gantt-presentation.mjs +217 -217
- package/src/todo-gantt-scope.mjs +123 -123
- package/src/todo-gantt-svg.mjs +353 -353
- package/src/todo-independence-contracts.mjs +595 -595
- package/src/todo-independence-guidance.mjs +322 -322
- package/src/todo-independence.mjs +640 -640
- package/src/todo-markdown-renderer.mjs +260 -260
- package/src/todo-migration.mjs +448 -448
- package/src/todo-narrative-anchor.mjs +130 -130
- package/src/todo-note-store.mjs +629 -629
- package/src/todo-parallel-candidates.mjs +114 -114
- package/src/todo-revision.mjs +995 -995
- package/src/todo-split.mjs +472 -472
- package/src/todo-status.mjs +690 -690
- package/src/todo-store-git-transaction.mjs +418 -418
- package/src/todo-store.mjs +4360 -4322
- package/src/treatment-compiler.mjs +728 -728
- package/src/treatment-runner.mjs +656 -656
- package/src/witness-scaffold.mjs +180 -180
package/src/sensor-diff.mjs
CHANGED
|
@@ -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
|
+
}
|