@quolu/lattice 0.57.2 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,452 @@
1
+ import { isUtf8 } from 'node:buffer';
2
+
3
+ import { gitSpawnSync } from './git-process.mjs';
4
+ import { compareSensorIndexes } from './sensor-diff.mjs';
5
+ import { digestTodoArtifact, isTodoRef, todoSelfDigest } from './todo-contracts.mjs';
6
+ import {
7
+ explainTodoStructureRealization,
8
+ explainTodoStructureSet,
9
+ } from './todo-structure-contracts.mjs';
10
+
11
+ export const TODO_STRUCTURE_GIT_PROVENANCE_SCHEMA = 'lattice.todo_structure_git_provenance.v1';
12
+ export const TODO_STRUCTURE_GIT_LIMITS = Object.freeze({
13
+ commits: 512,
14
+ changes: 4_096,
15
+ changedLines: 5_000_000,
16
+ sensorDetailsPerBucket: 200,
17
+ });
18
+
19
+ const SHA = /^[0-9a-f]{40}$/u;
20
+ const RAW_HEADER = /^:(\d{6}) (\d{6}) ([0-9a-f]{40}) ([0-9a-f]{40}) ([A-Z])(\d{0,3})$/u;
21
+ const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
22
+ const isPlain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
23
+
24
+ export class TodoStructureGitError extends Error {
25
+ constructor(code, reason, detail = {}) {
26
+ super(reason);
27
+ this.name = 'TodoStructureGitError';
28
+ this.code = code;
29
+ this.detail = { reason, ...detail };
30
+ }
31
+ }
32
+
33
+ function fail(code, reason, detail = {}) {
34
+ throw new TodoStructureGitError(code, reason, detail);
35
+ }
36
+
37
+ function assertStructureSet(structureSet) {
38
+ const result = explainTodoStructureSet(structureSet);
39
+ if (!result.valid) fail('STRUCTURE_GIT_INPUT_INVALID', result.reason, { path: result.path });
40
+ }
41
+
42
+ function defaultRunGit({ args, cwd, maxBuffer = 64 * 1024 * 1024 }) {
43
+ return gitSpawnSync(args, {
44
+ cwd, encoding: null, maxBuffer, stdio: ['ignore', 'pipe', 'pipe'],
45
+ });
46
+ }
47
+
48
+ function gitResult(run, cwd, args, { allow = [0], maxBuffer = 64 * 1024 * 1024 } = {}) {
49
+ let result;
50
+ try {
51
+ result = run({ args, cwd, maxBuffer });
52
+ } catch (error) {
53
+ fail('STRUCTURE_GIT_COMMAND_FAILED', 'git_command_spawn_failed', {
54
+ operation: args[0], cause: error instanceof Error ? error.message : String(error),
55
+ });
56
+ }
57
+ if (!isPlain(result) || !allow.includes(result.status) || result.signal !== null
58
+ || !(Buffer.isBuffer(result.stdout) || typeof result.stdout === 'string')) {
59
+ fail('STRUCTURE_GIT_COMMAND_FAILED', 'git_command_failed', {
60
+ operation: args[0], status: result?.status ?? null, signal: result?.signal ?? null,
61
+ });
62
+ }
63
+ return {
64
+ ...result,
65
+ stdout: Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout, 'utf8'),
66
+ };
67
+ }
68
+
69
+ function runGit(run, cwd, args, options = {}) {
70
+ return gitResult(run, cwd, args, options).stdout;
71
+ }
72
+
73
+ function utf8(buffer, operation) {
74
+ if (!isUtf8(buffer)) fail('STRUCTURE_GIT_PATH_ENCODING_UNSUPPORTED', 'git_output_not_utf8', { operation });
75
+ return buffer.toString('utf8');
76
+ }
77
+
78
+ function stripRecordNewlines(value) {
79
+ return value.replace(/^\n+/u, '').replace(/\n+$/u, '');
80
+ }
81
+
82
+ function shallowRepository(run, cwd) {
83
+ return utf8(runGit(run, cwd, ['rev-parse', '--is-shallow-repository']), 'shallow')
84
+ .trim() === 'true';
85
+ }
86
+
87
+ function resolveGitIdentity(run, cwd, baselineSha, { requireClean }) {
88
+ if (requireClean) {
89
+ const dirty = runGit(run, cwd, [
90
+ 'status', '--porcelain=v1', '-z', '--untracked-files=all',
91
+ ]);
92
+ if (dirty.length > 0) {
93
+ fail('STRUCTURE_GIT_WORKTREE_DIRTY', 'worktree_not_clean', {
94
+ changed_entries: dirty.toString('utf8').split('\0').filter(Boolean).length,
95
+ next_action: 'commit_or_stash_then_retry',
96
+ });
97
+ }
98
+ }
99
+ const headSha = utf8(runGit(run, cwd, ['rev-parse', '--verify', 'HEAD^{commit}']), 'head').trim();
100
+ if (!SHA.test(headSha)) fail('STRUCTURE_GIT_HEAD_INVALID', 'git_head_invalid', { actual: headSha });
101
+
102
+ const baseline = gitResult(run, cwd, ['cat-file', '-e', `${baselineSha}^{commit}`], {
103
+ allow: [0, 1, 128], maxBuffer: 1_024,
104
+ });
105
+ if (baseline.status !== 0) {
106
+ const shallow = shallowRepository(run, cwd);
107
+ fail(shallow ? 'STRUCTURE_GIT_BASELINE_SHALLOW' : 'STRUCTURE_GIT_BASELINE_UNREACHABLE',
108
+ shallow ? 'baseline_missing_from_shallow_history' : 'baseline_commit_unreachable',
109
+ { baseline_sha: baselineSha });
110
+ }
111
+ const ancestor = gitResult(run, cwd, ['merge-base', '--is-ancestor', baselineSha, headSha], {
112
+ allow: [0, 1], maxBuffer: 1_024,
113
+ });
114
+ if (ancestor.status === 1) {
115
+ const shallow = shallowRepository(run, cwd);
116
+ fail(shallow ? 'STRUCTURE_GIT_BASELINE_SHALLOW' : 'STRUCTURE_GIT_BASELINE_NOT_ANCESTOR',
117
+ shallow ? 'baseline_ancestry_incomplete_in_shallow_history' : 'baseline_not_ancestor',
118
+ { baseline_sha: baselineSha, head_sha: headSha });
119
+ }
120
+ return headSha;
121
+ }
122
+
123
+ function parseRevisionList(text) {
124
+ const commits = text.split(/\r?\n/u).filter(Boolean).map((line) => {
125
+ const [commitOid, ...parents] = line.split(' ');
126
+ if (!SHA.test(commitOid) || !parents.every((oid) => SHA.test(oid))) {
127
+ fail('STRUCTURE_GIT_OUTPUT_INVALID', 'rev_list_output_invalid');
128
+ }
129
+ return { commit_oid: commitOid, parent_oids: parents };
130
+ });
131
+ if (commits.length > TODO_STRUCTURE_GIT_LIMITS.commits) {
132
+ fail('STRUCTURE_GIT_HISTORY_TOO_LARGE', 'commit_count_exceeds_limit', {
133
+ actual: commits.length, limit: TODO_STRUCTURE_GIT_LIMITS.commits,
134
+ });
135
+ }
136
+ return commits;
137
+ }
138
+
139
+ function logSections(buffer, operation) {
140
+ const text = utf8(buffer, operation);
141
+ const sections = new Map();
142
+ for (const raw of text.split('\x1e').slice(1)) {
143
+ const separator = raw.indexOf('\0');
144
+ if (separator === -1) fail('STRUCTURE_GIT_OUTPUT_INVALID', 'git_log_section_invalid', { operation });
145
+ const commitOid = stripRecordNewlines(raw.slice(0, separator));
146
+ if (!SHA.test(commitOid) || sections.has(commitOid)) {
147
+ fail('STRUCTURE_GIT_OUTPUT_INVALID', 'git_log_commit_invalid', { operation });
148
+ }
149
+ sections.set(commitOid, raw.slice(separator + 1).split('\0'));
150
+ }
151
+ return sections;
152
+ }
153
+
154
+ function parseRawChanges(buffer) {
155
+ const sections = logSections(buffer, 'raw_diff');
156
+ const changesByCommit = new Map();
157
+ for (const [commitOid, tokens] of sections) {
158
+ const changes = [];
159
+ for (let index = 0; index < tokens.length; index += 1) {
160
+ const header = stripRecordNewlines(tokens[index]);
161
+ if (header === '') continue;
162
+ const matched = RAW_HEADER.exec(header);
163
+ if (matched === null) fail('STRUCTURE_GIT_OUTPUT_INVALID', 'raw_diff_header_invalid', { commit_oid: commitOid });
164
+ const [, oldMode, newMode, oldOid, newOid, status, score] = matched;
165
+ const firstPath = tokens[++index];
166
+ if (!isTodoRef(firstPath)) {
167
+ fail('STRUCTURE_GIT_OUTPUT_INVALID', 'raw_diff_path_missing', { commit_oid: commitOid });
168
+ }
169
+ const renamed = ['R', 'C'].includes(status);
170
+ const secondPath = renamed ? tokens[++index] : null;
171
+ if (renamed && !isTodoRef(secondPath)) {
172
+ fail('STRUCTURE_GIT_OUTPUT_INVALID', 'raw_diff_second_path_missing', { commit_oid: commitOid });
173
+ }
174
+ changes.push({
175
+ status,
176
+ score: score === '' ? null : Number(score),
177
+ path: renamed ? secondPath : firstPath,
178
+ previous_path: renamed ? firstPath : null,
179
+ old_mode: oldMode,
180
+ new_mode: newMode,
181
+ old_oid: oldOid,
182
+ new_oid: newOid,
183
+ });
184
+ }
185
+ changesByCommit.set(commitOid, changes);
186
+ }
187
+ return changesByCommit;
188
+ }
189
+
190
+ function parseNumstat(buffer) {
191
+ const sections = logSections(buffer, 'numstat');
192
+ const statsByCommit = new Map();
193
+ for (const [commitOid, tokens] of sections) {
194
+ const stats = new Map();
195
+ for (const token of tokens) {
196
+ const record = stripRecordNewlines(token);
197
+ if (record === '') continue;
198
+ const first = record.indexOf('\t');
199
+ const second = record.indexOf('\t', first + 1);
200
+ if (first <= 0 || second <= first + 1) {
201
+ fail('STRUCTURE_GIT_OUTPUT_INVALID', 'numstat_record_invalid', { commit_oid: commitOid });
202
+ }
203
+ const addedText = record.slice(0, first);
204
+ const deletedText = record.slice(first + 1, second);
205
+ const filePath = record.slice(second + 1);
206
+ if (!isTodoRef(filePath) || stats.has(filePath)) {
207
+ fail('STRUCTURE_GIT_OUTPUT_INVALID', 'numstat_path_invalid', { commit_oid: commitOid });
208
+ }
209
+ const binary = addedText === '-' && deletedText === '-';
210
+ if (!binary && (!/^\d+$/u.test(addedText) || !/^\d+$/u.test(deletedText))) {
211
+ fail('STRUCTURE_GIT_OUTPUT_INVALID', 'numstat_count_invalid', { commit_oid: commitOid });
212
+ }
213
+ const linesAdded = binary ? null : Number(addedText);
214
+ const linesDeleted = binary ? null : Number(deletedText);
215
+ if (!binary && (!Number.isSafeInteger(linesAdded) || !Number.isSafeInteger(linesDeleted))) {
216
+ fail('STRUCTURE_GIT_OUTPUT_INVALID', 'numstat_count_unsafe', { commit_oid: commitOid });
217
+ }
218
+ stats.set(filePath, {
219
+ binary,
220
+ lines_added: linesAdded,
221
+ lines_deleted: linesDeleted,
222
+ });
223
+ }
224
+ statsByCommit.set(commitOid, stats);
225
+ }
226
+ return statsByCommit;
227
+ }
228
+
229
+ function fileKind(oldMode, newMode) {
230
+ const modes = [oldMode, newMode].filter((mode) => mode !== '000000');
231
+ if (modes.includes('160000')) return 'submodule';
232
+ if (modes.includes('120000')) return 'symlink';
233
+ if (modes.every((mode) => ['100644', '100755'].includes(mode))) return 'regular';
234
+ return 'special';
235
+ }
236
+
237
+ function changeKind(status) {
238
+ return ({ A: 'add', M: 'modify', D: 'delete', R: 'rename', C: 'copy', T: 'type_change' })[status]
239
+ ?? 'unknown';
240
+ }
241
+
242
+ function buildChangesets(commits, rawByCommit, statsByCommit) {
243
+ let totalChanges = 0;
244
+ let totalChangedLines = 0;
245
+ const changesets = commits.map(({ commit_oid: commitOid, parent_oids: parentOids }) => {
246
+ const raw = rawByCommit.get(commitOid) ?? [];
247
+ const stats = statsByCommit.get(commitOid) ?? new Map();
248
+ const changes = raw.map((entry) => {
249
+ const stat = stats.get(entry.path) ?? (entry.previous_path === null ? undefined : stats.get(entry.previous_path));
250
+ const kind = fileKind(entry.old_mode, entry.new_mode);
251
+ if (stat !== undefined && !stat.binary) {
252
+ totalChangedLines += stat.lines_added + stat.lines_deleted;
253
+ }
254
+ return {
255
+ change: changeKind(entry.status),
256
+ status: entry.status,
257
+ path: entry.path,
258
+ previous_path: entry.previous_path,
259
+ similarity: entry.score,
260
+ old_mode: entry.old_mode,
261
+ new_mode: entry.new_mode,
262
+ old_oid: entry.old_oid,
263
+ new_oid: entry.new_oid,
264
+ file_kind: kind,
265
+ binary: kind === 'regular' && stat !== undefined ? stat.binary : null,
266
+ lines_added: stat?.lines_added ?? null,
267
+ lines_deleted: stat?.lines_deleted ?? null,
268
+ };
269
+ }).sort((left, right) => compareText(left.path, right.path)
270
+ || compareText(left.previous_path ?? '', right.previous_path ?? ''));
271
+ totalChanges += changes.length;
272
+ const changeset = {
273
+ schema: 'lattice.todo_structure_changeset.v1',
274
+ commit_oid: commitOid,
275
+ parent_oids: parentOids,
276
+ changes,
277
+ changeset_digest: '',
278
+ };
279
+ changeset.changeset_digest = todoSelfDigest(changeset, 'changeset_digest');
280
+ return changeset;
281
+ });
282
+ if (totalChanges > TODO_STRUCTURE_GIT_LIMITS.changes) {
283
+ fail('STRUCTURE_GIT_DIFF_TOO_LARGE', 'change_count_exceeds_limit', {
284
+ actual: totalChanges, limit: TODO_STRUCTURE_GIT_LIMITS.changes,
285
+ });
286
+ }
287
+ if (totalChangedLines > TODO_STRUCTURE_GIT_LIMITS.changedLines) {
288
+ fail('STRUCTURE_GIT_DIFF_TOO_LARGE', 'changed_line_count_exceeds_limit', {
289
+ actual: totalChangedLines, limit: TODO_STRUCTURE_GIT_LIMITS.changedLines,
290
+ });
291
+ }
292
+ return { changesets, totalChanges, totalChangedLines };
293
+ }
294
+
295
+ function boundedSensorLists(result) {
296
+ return [
297
+ result?.files?.added, result?.files?.removed, result?.files?.changed,
298
+ result?.nodes?.added, result?.nodes?.removed, result?.nodes?.changed, result?.nodes?.moved,
299
+ result?.edges?.added, result?.edges?.removed,
300
+ ].every((list) => Array.isArray(list)
301
+ && list.length <= TODO_STRUCTURE_GIT_LIMITS.sensorDetailsPerBucket);
302
+ }
303
+
304
+ /** compareSensorIndexesの意味を変えず、host固有root/databaseだけを除いた保存形へ写す。 */
305
+ export function projectTodoStructureSensorDiff(result) {
306
+ if (!isPlain(result) || result.schema !== 'lattice.sensor_diff_result.v1'
307
+ || !isPlain(result.comparability) || !isPlain(result.summary)
308
+ || !isPlain(result.excluded) || !isPlain(result.integrity)
309
+ || !['ok', 'degraded'].includes(result.comparability.status)
310
+ || !isPlain(result.a) || !isPlain(result.b)
311
+ || !isPlain(result.truncation) || !boundedSensorLists(result)) {
312
+ fail('STRUCTURE_SENSOR_DIFF_INVALID', 'sensor_diff_result_invalid');
313
+ }
314
+ const projection = {
315
+ schema: result.schema,
316
+ provider: result.provider,
317
+ sensor_owner: result.sensor_owner,
318
+ command: result.command,
319
+ a: { subtree: result.a?.subtree ?? '', indexed: structuredClone(result.a?.indexed ?? null) },
320
+ b: { subtree: result.b?.subtree ?? '', indexed: structuredClone(result.b?.indexed ?? null) },
321
+ comparability: structuredClone(result.comparability),
322
+ summary: structuredClone(result.summary),
323
+ excluded: structuredClone(result.excluded),
324
+ integrity: structuredClone(result.integrity),
325
+ limit: result.limit,
326
+ truncation: structuredClone(result.truncation),
327
+ files: structuredClone(result.files),
328
+ nodes: structuredClone(result.nodes),
329
+ edges: structuredClone(result.edges),
330
+ };
331
+ return { projection, projection_digest: digestTodoArtifact(projection) };
332
+ }
333
+
334
+ function collectSensorDiff(sensorDiffRequest, compareSensor) {
335
+ if (sensorDiffRequest === null) {
336
+ return { status: 'unknown', reason: 'STRUCTURE_SENSOR_DIFF_MISSING', projection: null, projection_digest: null };
337
+ }
338
+ try {
339
+ const requested = {
340
+ ...sensorDiffRequest,
341
+ limit: TODO_STRUCTURE_GIT_LIMITS.sensorDetailsPerBucket,
342
+ };
343
+ const projected = projectTodoStructureSensorDiff(compareSensor(requested));
344
+ return {
345
+ status: projected.projection.comparability.status === 'ok' ? 'ready' : 'degraded',
346
+ reason: projected.projection.comparability.status === 'ok'
347
+ ? null : 'STRUCTURE_SENSOR_DIFF_DEGRADED',
348
+ ...projected,
349
+ };
350
+ } catch (error) {
351
+ if (error instanceof TodoStructureGitError) throw error;
352
+ return {
353
+ status: 'unknown',
354
+ reason: typeof error?.code === 'string' ? error.code : 'STRUCTURE_SENSOR_DIFF_UNAVAILABLE',
355
+ projection: null,
356
+ projection_digest: null,
357
+ };
358
+ }
359
+ }
360
+
361
+ /** cleanなcurrent treeのcommit来歴と既存sensor diffをprovenance artifactへ束縛する。 */
362
+ export function collectTodoStructureGitProvenance({
363
+ repoRoot,
364
+ structureSet,
365
+ sensorDiffRequest = null,
366
+ requireClean = true,
367
+ runGit: run = defaultRunGit,
368
+ compareSensor = compareSensorIndexes,
369
+ } = {}) {
370
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0 || typeof requireClean !== 'boolean'
371
+ || typeof run !== 'function' || typeof compareSensor !== 'function') {
372
+ fail('STRUCTURE_GIT_INPUT_INVALID', 'git_adapter_options_invalid');
373
+ }
374
+ assertStructureSet(structureSet);
375
+ const headSha = resolveGitIdentity(run, repoRoot, structureSet.baseline_sha, { requireClean });
376
+ const revisionText = utf8(runGit(run, repoRoot, [
377
+ 'rev-list', '--parents', '--reverse', '--topo-order',
378
+ `${structureSet.baseline_sha}..${headSha}`,
379
+ ]), 'rev_list');
380
+ const commits = parseRevisionList(revisionText);
381
+ const range = `${structureSet.baseline_sha}..${headSha}`;
382
+ const raw = runGit(run, repoRoot, [
383
+ 'log', '--reverse', '--topo-order', '--format=%x1e%H%x00', '--raw', '-z', '--no-abbrev',
384
+ '--find-renames=50%', '--diff-merges=first-parent', range,
385
+ ]);
386
+ const numstat = runGit(run, repoRoot, [
387
+ 'log', '--reverse', '--topo-order', '--format=%x1e%H%x00', '--numstat', '-z',
388
+ '--no-renames', '--diff-merges=first-parent', range,
389
+ ]);
390
+ const built = buildChangesets(commits, parseRawChanges(raw), parseNumstat(numstat));
391
+ const sensorDiff = collectSensorDiff(sensorDiffRequest, compareSensor);
392
+ const provenance = {
393
+ schema: TODO_STRUCTURE_GIT_PROVENANCE_SCHEMA,
394
+ structure_set_digest: structureSet.structure_set_digest,
395
+ baseline_sha: structureSet.baseline_sha,
396
+ head_sha: headSha,
397
+ commit_order: commits.map(({ commit_oid: oid }) => oid),
398
+ changesets: built.changesets,
399
+ summary: {
400
+ commits: commits.length,
401
+ changes: built.totalChanges,
402
+ changed_lines: built.totalChangedLines,
403
+ regular: built.changesets.flatMap(({ changes }) => changes)
404
+ .filter(({ file_kind: kind }) => kind === 'regular').length,
405
+ symlink: built.changesets.flatMap(({ changes }) => changes)
406
+ .filter(({ file_kind: kind }) => kind === 'symlink').length,
407
+ submodule: built.changesets.flatMap(({ changes }) => changes)
408
+ .filter(({ file_kind: kind }) => kind === 'submodule').length,
409
+ special: built.changesets.flatMap(({ changes }) => changes)
410
+ .filter(({ file_kind: kind }) => kind === 'special').length,
411
+ binary: built.changesets.flatMap(({ changes }) => changes)
412
+ .filter(({ binary }) => binary === true).length,
413
+ renames: built.changesets.flatMap(({ changes }) => changes)
414
+ .filter(({ change }) => change === 'rename').length,
415
+ },
416
+ sensor_diff: sensorDiff,
417
+ provenance_digest: '',
418
+ };
419
+ provenance.provenance_digest = todoSelfDigest(provenance, 'provenance_digest');
420
+ return provenance;
421
+ }
422
+
423
+ /** realizationが明示したcommit OIDを、message推定なしでchangesetへexact束縛する。 */
424
+ export function bindTodoStructureRealizationCommits({ provenance, realizations } = {}) {
425
+ if (!isPlain(provenance) || provenance.schema !== TODO_STRUCTURE_GIT_PROVENANCE_SCHEMA
426
+ || !Array.isArray(provenance.changesets) || !Array.isArray(realizations)) {
427
+ fail('STRUCTURE_GIT_INPUT_INVALID', 'realization_binding_input_invalid');
428
+ }
429
+ const changesets = new Map(provenance.changesets
430
+ .map((changeset) => [changeset.commit_oid, changeset.changeset_digest]));
431
+ return realizations.map((realization) => {
432
+ const explained = explainTodoStructureRealization(realization);
433
+ if (!explained.valid) {
434
+ fail('STRUCTURE_REALIZATION_INVALID', explained.reason, { path: explained.path });
435
+ }
436
+ const commits = realization.commit_oids.map((commitOid) => {
437
+ const changesetDigest = changesets.get(commitOid);
438
+ if (changesetDigest === undefined) {
439
+ fail('STRUCTURE_REALIZATION_COMMIT_UNREACHABLE', 'realization_commit_outside_baseline_range', {
440
+ task_id: realization.task_id, commit_oid: commitOid,
441
+ });
442
+ }
443
+ return { commit_oid: commitOid, changeset_digest: changesetDigest };
444
+ });
445
+ return {
446
+ task_id: realization.task_id,
447
+ realization_digest: realization.realization_digest,
448
+ commits,
449
+ };
450
+ }).sort((left, right) => compareText(left.task_id, right.task_id)
451
+ || compareText(left.realization_digest, right.realization_digest));
452
+ }