@untangle-code/file-graph 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +90 -0
  3. package/README.md +284 -2
  4. package/THIRD_PARTY_LICENSES.md +29 -0
  5. package/dist/assignment.d.ts +15 -0
  6. package/dist/assignment.d.ts.map +1 -0
  7. package/dist/assignment.js +75 -0
  8. package/dist/assignment.js.map +1 -0
  9. package/dist/boundaries/endpoints.d.ts +11 -0
  10. package/dist/boundaries/endpoints.d.ts.map +1 -0
  11. package/dist/boundaries/endpoints.js +195 -0
  12. package/dist/boundaries/endpoints.js.map +1 -0
  13. package/dist/boundaries/index.d.ts +9 -0
  14. package/dist/boundaries/index.d.ts.map +1 -0
  15. package/dist/boundaries/index.js +93 -0
  16. package/dist/boundaries/index.js.map +1 -0
  17. package/dist/boundaries/requests.d.ts +12 -0
  18. package/dist/boundaries/requests.d.ts.map +1 -0
  19. package/dist/boundaries/requests.js +311 -0
  20. package/dist/boundaries/requests.js.map +1 -0
  21. package/dist/edges.d.ts +49 -0
  22. package/dist/edges.d.ts.map +1 -0
  23. package/dist/edges.js +223 -0
  24. package/dist/edges.js.map +1 -0
  25. package/dist/file-index.d.ts +37 -0
  26. package/dist/file-index.d.ts.map +1 -0
  27. package/dist/file-index.js +103 -0
  28. package/dist/file-index.js.map +1 -0
  29. package/dist/graph.d.ts +160 -0
  30. package/dist/graph.d.ts.map +1 -0
  31. package/dist/graph.js +448 -0
  32. package/dist/graph.js.map +1 -0
  33. package/dist/index.d.ts +35 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +48 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/metrics.d.ts +67 -0
  38. package/dist/metrics.d.ts.map +1 -0
  39. package/dist/metrics.js +272 -0
  40. package/dist/metrics.js.map +1 -0
  41. package/dist/package-catalog.d.ts +43 -0
  42. package/dist/package-catalog.d.ts.map +1 -0
  43. package/dist/package-catalog.js +136 -0
  44. package/dist/package-catalog.js.map +1 -0
  45. package/dist/paths.d.ts +56 -0
  46. package/dist/paths.d.ts.map +1 -0
  47. package/dist/paths.js +129 -0
  48. package/dist/paths.js.map +1 -0
  49. package/dist/persistence.d.ts +83 -0
  50. package/dist/persistence.d.ts.map +1 -0
  51. package/dist/persistence.js +538 -0
  52. package/dist/persistence.js.map +1 -0
  53. package/dist/resolve/candidates.d.ts +52 -0
  54. package/dist/resolve/candidates.d.ts.map +1 -0
  55. package/dist/resolve/candidates.js +202 -0
  56. package/dist/resolve/candidates.js.map +1 -0
  57. package/dist/resolve/package-exports.d.ts +31 -0
  58. package/dist/resolve/package-exports.d.ts.map +1 -0
  59. package/dist/resolve/package-exports.js +229 -0
  60. package/dist/resolve/package-exports.js.map +1 -0
  61. package/dist/resolve/pipeline.d.ts +11 -0
  62. package/dist/resolve/pipeline.d.ts.map +1 -0
  63. package/dist/resolve/pipeline.js +109 -0
  64. package/dist/resolve/pipeline.js.map +1 -0
  65. package/dist/resolve/resolvers.d.ts +25 -0
  66. package/dist/resolve/resolvers.d.ts.map +1 -0
  67. package/dist/resolve/resolvers.js +367 -0
  68. package/dist/resolve/resolvers.js.map +1 -0
  69. package/dist/restore.d.ts +75 -0
  70. package/dist/restore.d.ts.map +1 -0
  71. package/dist/restore.js +136 -0
  72. package/dist/restore.js.map +1 -0
  73. package/dist/topology/compose.d.ts +27 -0
  74. package/dist/topology/compose.d.ts.map +1 -0
  75. package/dist/topology/compose.js +208 -0
  76. package/dist/topology/compose.js.map +1 -0
  77. package/dist/topology/jsonc.d.ts +38 -0
  78. package/dist/topology/jsonc.d.ts.map +1 -0
  79. package/dist/topology/jsonc.js +137 -0
  80. package/dist/topology/jsonc.js.map +1 -0
  81. package/dist/topology/load.d.ts +61 -0
  82. package/dist/topology/load.d.ts.map +1 -0
  83. package/dist/topology/load.js +436 -0
  84. package/dist/topology/load.js.map +1 -0
  85. package/dist/topology/manifest.d.ts +48 -0
  86. package/dist/topology/manifest.d.ts.map +1 -0
  87. package/dist/topology/manifest.js +109 -0
  88. package/dist/topology/manifest.js.map +1 -0
  89. package/dist/topology/project-config.d.ts +39 -0
  90. package/dist/topology/project-config.d.ts.map +1 -0
  91. package/dist/topology/project-config.js +150 -0
  92. package/dist/topology/project-config.js.map +1 -0
  93. package/dist/topology/workspace-definition.d.ts +26 -0
  94. package/dist/topology/workspace-definition.d.ts.map +1 -0
  95. package/dist/topology/workspace-definition.js +104 -0
  96. package/dist/topology/workspace-definition.js.map +1 -0
  97. package/dist/traverse.d.ts +49 -0
  98. package/dist/traverse.d.ts.map +1 -0
  99. package/dist/traverse.js +167 -0
  100. package/dist/traverse.js.map +1 -0
  101. package/dist/types.d.ts +332 -0
  102. package/dist/types.d.ts.map +1 -0
  103. package/dist/types.js +2 -0
  104. package/dist/types.js.map +1 -0
  105. package/package.json +63 -3
  106. package/src/assignment.ts +83 -0
  107. package/src/boundaries/endpoints.ts +273 -0
  108. package/src/boundaries/index.ts +118 -0
  109. package/src/boundaries/requests.ts +411 -0
  110. package/src/edges.ts +334 -0
  111. package/src/file-index.ts +127 -0
  112. package/src/graph.ts +591 -0
  113. package/src/index.ts +58 -0
  114. package/src/metrics.ts +350 -0
  115. package/src/package-catalog.ts +145 -0
  116. package/src/paths.ts +138 -0
  117. package/src/persistence.ts +746 -0
  118. package/src/resolve/candidates.ts +230 -0
  119. package/src/resolve/package-exports.ts +268 -0
  120. package/src/resolve/pipeline.ts +140 -0
  121. package/src/resolve/resolvers.ts +468 -0
  122. package/src/restore.ts +201 -0
  123. package/src/topology/compose.ts +268 -0
  124. package/src/topology/jsonc.ts +147 -0
  125. package/src/topology/load.ts +644 -0
  126. package/src/topology/manifest.ts +132 -0
  127. package/src/topology/project-config.ts +183 -0
  128. package/src/topology/workspace-definition.ts +115 -0
  129. package/src/traverse.ts +232 -0
  130. package/src/types.ts +372 -0
@@ -0,0 +1,746 @@
1
+ /**
2
+ * Ablage des Dateigraphen ueber den BESTEHENDEN `GraphStore`-Port
3
+ * (domain `ports.ts`, architecture.md §6.11, §11.6).
4
+ *
5
+ * ENTSCHEIDUNG: Dieses Paket definiert KEINEN eigenen Speicher-Port. Der
6
+ * `GraphStore` von `domain` hat mit `graph_files`, `graph_indices` und
7
+ * `graph_meta` genau die drei Sammlungen, die ein Dateigraph braucht, und es
8
+ * gibt bereits eine Kontraktsuite (`@untangle-code/testkit`,
9
+ * `runGraphStoreContract`) plus zwei Adapter dagegen. Ein zweiter Port haette
10
+ * dieselbe Semantik ein zweites Mal beschrieben und beide Adapter zur
11
+ * Doppelimplementierung gezwungen.
12
+ *
13
+ * SHARD-AUFTEILUNG:
14
+ * - `graph_meta` / `<workspace>:meta` — Metriken, Topologie, Skip-Ledger, Knotenzahl;
15
+ * - `graph_files` / `<workspace>:file:<file_id>` — ein Knoten samt seiner Kanten;
16
+ * - `graph_indices` / `<workspace>:by-path` — Repository + Pfad → `file_id`.
17
+ *
18
+ * Ein Shard je Datei (statt eines Riesenshards) ist Absicht: die inkrementelle
19
+ * Fortschreibung schreibt dann genau die Dateien zurueck, die sie neu gerechnet
20
+ * hat.
21
+ */
22
+ import type {
23
+ FileEdgeV1,
24
+ FileNodeV1,
25
+ GraphShardV1,
26
+ GraphStore,
27
+ PortCallOptions,
28
+ } from "@untangle-code/domain";
29
+ import { compareCodepoints } from "@untangle-code/domain";
30
+
31
+ import { compareEdges } from "./edges.js";
32
+ import { normalizePath } from "./paths.js";
33
+ import type { FileGraphSnapshotV1 } from "./types.js";
34
+
35
+ /** Vor Wave 8 verwendete globale Metadaten-ID; nur fuer expliziten Legacy-Fallback. */
36
+ export const META_SHARD_ID = "meta";
37
+ /** Vor Wave 8 verwendete globale Pfadindex-ID; nur fuer expliziten Legacy-Fallback. */
38
+ export const PATH_INDEX_SHARD_ID = "by-path";
39
+ /** Versionierter Praefix der workspace-sicheren Dateigraph-Shards. */
40
+ export const FILE_GRAPH_SHARD_NAMESPACE = "untangle:file-graph:v1";
41
+
42
+ /** Der vollstaendige Geltungsbereich eines persistierten Graphabbilds. */
43
+ export interface GraphSnapshotScope {
44
+ workspaceId: string;
45
+ generationId: string;
46
+ }
47
+
48
+ /** Opt-in fuer das Lesen vor Wave 8 erzeugter, nicht namespaceter Shards. */
49
+ export interface GraphSnapshotReadOptions extends PortCallOptions {
50
+ /**
51
+ * Nur ein explizites `true` aktiviert den Legacy-Pfad. Auch dann wird der
52
+ * gelesene Payload strikt gegen `workspaceId` UND `generationId` geprueft.
53
+ */
54
+ allowLegacyFallback?: boolean;
55
+ }
56
+
57
+ /** Praefix aller Dateigraph-Shards eines Workspace. */
58
+ export function fileGraphWorkspaceShardPrefix(workspaceId: string): string {
59
+ assertIdentifier("workspaceId", workspaceId);
60
+ return `${FILE_GRAPH_SHARD_NAMESPACE}:${String(workspaceId.length)}:${workspaceId}:`;
61
+ }
62
+
63
+ /** Namespacete ID des Metadaten-Shards. */
64
+ export function fileGraphMetaShardId(workspaceId: string): string {
65
+ return `${fileGraphWorkspaceShardPrefix(workspaceId)}meta`;
66
+ }
67
+
68
+ /** Namespacete ID des Pfadindex-Shards. */
69
+ export function fileGraphPathIndexShardId(workspaceId: string): string {
70
+ return `${fileGraphWorkspaceShardPrefix(workspaceId)}by-path`;
71
+ }
72
+
73
+ /** Namespacete ID eines Datei-Shards. */
74
+ export function fileGraphFileShardId(workspaceId: string, fileId: string): string {
75
+ assertIdentifier("fileId", fileId);
76
+ return `${fileGraphWorkspaceShardPrefix(workspaceId)}file:${fileId}`;
77
+ }
78
+
79
+ /** Erzeugt alle Shards eines Abbilds, in stabiler Reihenfolge. */
80
+ export function toGraphShards(snapshot: FileGraphSnapshotV1, writeId: string): GraphShardV1[] {
81
+ assertFileGraphSnapshotScope(snapshot);
82
+ assertIdentifier("writeId", writeId);
83
+
84
+ const edgesByFrom = new Map<string, FileEdgeV1[]>();
85
+ for (const edge of snapshot.edges) {
86
+ const bucket = edgesByFrom.get(edge.fromFileId) ?? [];
87
+ bucket.push(edge);
88
+ edgesByFrom.set(edge.fromFileId, bucket);
89
+ }
90
+
91
+ const shards: GraphShardV1[] = [
92
+ {
93
+ logicalShardId: fileGraphMetaShardId(snapshot.workspaceId),
94
+ collection: "graph_meta",
95
+ generationId: snapshot.graphGeneration,
96
+ writeId,
97
+ payload: {
98
+ persistenceSchemaVersion: 1,
99
+ schemaVersion: snapshot.schemaVersion,
100
+ workspaceId: snapshot.workspaceId,
101
+ graphGeneration: snapshot.graphGeneration,
102
+ nodeCount: snapshot.nodes.length,
103
+ edgeCount: snapshot.edges.length,
104
+ metrics: snapshot.metrics,
105
+ topology: snapshot.topology,
106
+ skipped: snapshot.skipped,
107
+ },
108
+ },
109
+ ];
110
+
111
+ const pathIndex: Record<string, string> = {};
112
+ /**
113
+ * WP-197: Zwei Knoten mit DEMSELBEN Repository-Pfad ueberschrieben einander
114
+ * im Pfadindex — ohne Befund, ohne Ledger. Der Pfadindex ist aber genau der
115
+ * Zeuge, aus dem `readGraphSnapshot` seine Erwartungsmenge bildet: die
116
+ * verlorene `file_id` wurde damit auch beim Zurueckladen nie vermisst, und
117
+ * `missing_file_shard` konnte sie nicht nennen. Der Verlust war doppelt
118
+ * verdeckt. Jetzt behaelt der Index den ERSTEN (nach `file_id` kleinsten)
119
+ * Eintrag — bestimmt und damit reproduzierbar — und die Kollision steht als
120
+ * eigener Eintrag im Skip-Ledger des Abbilds.
121
+ */
122
+ const pathCollisions: { key: string; kept: string; dropped: string }[] = [];
123
+ for (const node of [...snapshot.nodes].sort((left, right) =>
124
+ compareCodepoints(left.fileId, right.fileId),
125
+ )) {
126
+ const key = pathKey(node.repoId, node.filePath);
127
+ const existing = pathIndex[key];
128
+ if (existing !== undefined) {
129
+ pathCollisions.push({ key, kept: existing, dropped: node.fileId });
130
+ } else {
131
+ pathIndex[key] = node.fileId;
132
+ }
133
+ shards.push({
134
+ logicalShardId: fileGraphFileShardId(snapshot.workspaceId, node.fileId),
135
+ collection: "graph_files",
136
+ generationId: snapshot.graphGeneration,
137
+ writeId,
138
+ payload: {
139
+ persistenceSchemaVersion: 1,
140
+ workspaceId: snapshot.workspaceId,
141
+ graphGeneration: snapshot.graphGeneration,
142
+ node,
143
+ edges: (edgesByFrom.get(node.fileId) ?? []).sort(compareEdges),
144
+ },
145
+ });
146
+ }
147
+
148
+ shards.push({
149
+ logicalShardId: fileGraphPathIndexShardId(snapshot.workspaceId),
150
+ collection: "graph_indices",
151
+ generationId: snapshot.graphGeneration,
152
+ writeId,
153
+ payload: {
154
+ persistenceSchemaVersion: 1,
155
+ workspaceId: snapshot.workspaceId,
156
+ graphGeneration: snapshot.graphGeneration,
157
+ pathIndex,
158
+ // Die verdraengten `file_id`s stehen NEBEN dem Index, nicht in ihm: der
159
+ // Index bleibt damit formgleich zu bisherigen Generationen, und der
160
+ // Verlust ist trotzdem benannt und nachlesbar.
161
+ ...(pathCollisions.length === 0 ? {} : { pathIndexCollisions: pathCollisions }),
162
+ },
163
+ });
164
+
165
+ return shards;
166
+ }
167
+
168
+ /** Schreibt ein vollstaendiges Abbild in den Speicher. */
169
+ export async function writeGraphSnapshot(
170
+ store: GraphStore,
171
+ snapshot: FileGraphSnapshotV1,
172
+ writeId: string,
173
+ options?: PortCallOptions,
174
+ ): Promise<void> {
175
+ await store.writeShards(toGraphShards(snapshot, writeId), options);
176
+ }
177
+
178
+ /**
179
+ * Fehler beim Zurueckladen — als Wert, damit der Aufrufer ihn ins Ledger legen
180
+ * kann.
181
+ *
182
+ * `missing_path_index` und `count_mismatch` sind KEINE Zierde: ohne sie
183
+ * verlaeuft der haeufigste Verlustfall (ein Datei-Shard ist nicht mehr da und
184
+ * wird deshalb auch nicht mehr aufgezaehlt) voellig geraeuschlos. Der
185
+ * Pfadindex und die im Metadaten-Shard mitgeschriebenen Sollzahlen sind die
186
+ * beiden unabhaengigen Zeugen dafuer, WIE VIEL da sein muesste.
187
+ */
188
+ export interface SnapshotReadIssue {
189
+ kind:
190
+ | "missing_meta"
191
+ | "missing_path_index"
192
+ | "missing_file_shard"
193
+ | "malformed_payload"
194
+ | "count_mismatch"
195
+ | "workspace_scope_required"
196
+ | "scope_mismatch";
197
+ message: string;
198
+ }
199
+
200
+ export interface SnapshotReadResult {
201
+ snapshot: FileGraphSnapshotV1 | null;
202
+ issues: SnapshotReadIssue[];
203
+ }
204
+
205
+ interface GraphShardLayout {
206
+ metaShardId: string;
207
+ pathIndexShardId: string;
208
+ fileShardId(fileId: string): string;
209
+ ownsFileShard(shardId: string): boolean;
210
+ legacy: boolean;
211
+ }
212
+
213
+ interface FileShardCandidate {
214
+ shardId: string;
215
+ expectedFileId: string | null;
216
+ }
217
+
218
+ /** Bound store pressure while avoiding one network roundtrip per file in series. */
219
+ const FILE_SHARD_READ_CONCURRENCY = 8;
220
+
221
+ async function readFileShards(
222
+ store: GraphStore,
223
+ candidates: readonly FileShardCandidate[],
224
+ generationId: string,
225
+ options: PortCallOptions,
226
+ ): Promise<Array<FileShardCandidate & { shard: GraphShardV1 | null }>> {
227
+ const loaded: Array<FileShardCandidate & { shard: GraphShardV1 | null }> = [];
228
+ for (let offset = 0; offset < candidates.length; offset += FILE_SHARD_READ_CONCURRENCY) {
229
+ options.signal?.throwIfAborted();
230
+ const batch = candidates.slice(offset, offset + FILE_SHARD_READ_CONCURRENCY);
231
+ const results = await Promise.allSettled(
232
+ batch.map(async (candidate) => ({
233
+ ...candidate,
234
+ shard: await store.readShard("graph_files", candidate.shardId, {
235
+ ...options,
236
+ generationId,
237
+ }),
238
+ })),
239
+ );
240
+ // Wait for this bounded batch to settle before propagating the first error
241
+ // in candidate order. No later batch starts after a failure, and no read
242
+ // outlives the call. Completion timing never changes issue/error ordering.
243
+ for (const result of results) {
244
+ if (result.status === "rejected") throw result.reason;
245
+ loaded.push(result.value);
246
+ }
247
+ }
248
+ return loaded;
249
+ }
250
+
251
+ /**
252
+ * Laedt ein Abbild fuer GENAU einen Workspace und GENAU eine Generation.
253
+ *
254
+ * Die alte Signatur `(store, generationId)` bleibt absichtlich typkompatibel,
255
+ * liest aber nichts mehr: ohne Workspace kann kein gespeicherter Graph sicher
256
+ * zugeordnet werden. Alte, globale Shards werden nur ueber
257
+ * `allowLegacyFallback: true` gelesen, nachdem ein vollstaendiger Scope
258
+ * angegeben wurde; jeder Payload wird dabei gegen diesen Scope geprueft.
259
+ */
260
+ export async function readGraphSnapshot(
261
+ store: GraphStore,
262
+ scope: GraphSnapshotScope | string,
263
+ options: GraphSnapshotReadOptions = {},
264
+ ): Promise<SnapshotReadResult> {
265
+ if (typeof scope === "string") {
266
+ return {
267
+ snapshot: null,
268
+ issues: [
269
+ {
270
+ kind: "workspace_scope_required",
271
+ message: `generation "${scope}" was supplied without a workspaceId; use { workspaceId, generationId } so graph reads cannot cross workspace boundaries`,
272
+ },
273
+ ],
274
+ };
275
+ }
276
+ assertGraphSnapshotScope(scope);
277
+ const { allowLegacyFallback, ...callOptions } = options;
278
+
279
+ const scopedLayout: GraphShardLayout = {
280
+ metaShardId: fileGraphMetaShardId(scope.workspaceId),
281
+ pathIndexShardId: fileGraphPathIndexShardId(scope.workspaceId),
282
+ fileShardId: (fileId) => fileGraphFileShardId(scope.workspaceId, fileId),
283
+ ownsFileShard: (shardId) =>
284
+ shardId.startsWith(`${fileGraphWorkspaceShardPrefix(scope.workspaceId)}file:`),
285
+ legacy: false,
286
+ };
287
+ const scoped = await readGraphSnapshotFromLayout(store, scope, scopedLayout, callOptions);
288
+ if (
289
+ allowLegacyFallback !== true ||
290
+ scoped.snapshot !== null ||
291
+ scoped.issues.some((issue) => issue.kind !== "missing_meta")
292
+ ) {
293
+ return scoped;
294
+ }
295
+
296
+ return readGraphSnapshotFromLayout(
297
+ store,
298
+ scope,
299
+ {
300
+ metaShardId: META_SHARD_ID,
301
+ pathIndexShardId: PATH_INDEX_SHARD_ID,
302
+ fileShardId: (fileId) => fileId,
303
+ ownsFileShard: () => true,
304
+ legacy: true,
305
+ },
306
+ callOptions,
307
+ );
308
+ }
309
+
310
+ /**
311
+ * Eigentliche Rueckladung fuer eine namespacete oder explizit angeforderte
312
+ * Legacy-Ablage. Kein Store-Aufruf verzichtet auf `generationId`.
313
+ */
314
+ async function readGraphSnapshotFromLayout(
315
+ store: GraphStore,
316
+ scope: GraphSnapshotScope,
317
+ layout: GraphShardLayout,
318
+ options: PortCallOptions,
319
+ ): Promise<SnapshotReadResult> {
320
+ const issues: SnapshotReadIssue[] = [];
321
+ const { generationId, workspaceId } = scope;
322
+ const metaShard = await store.readShard("graph_meta", layout.metaShardId, {
323
+ ...options,
324
+ generationId,
325
+ });
326
+ if (metaShard === null) {
327
+ issues.push({
328
+ kind: "missing_meta",
329
+ message: `graph_meta/${layout.metaShardId} is absent for workspace "${workspaceId}" and generation "${generationId}"`,
330
+ });
331
+ return { snapshot: null, issues };
332
+ }
333
+ const meta = metaShard.payload;
334
+
335
+ // Der Metadaten-Shard traegt die Pflichtteile des Abbilds. Fehlt einer, kann
336
+ // `FileGraphSnapshotV1` nicht erfuellt werden — dann ist `null` mit
337
+ // benanntem Befund die einzige ehrliche Antwort. Ein Objekt mit
338
+ // `metrics: undefined` an einen Aufrufer zu geben, der laut Vertrag
339
+ // `snapshot.skipped.entries` lesen darf, ist ein verstecktes Loch (E4).
340
+ const metaIssues = validateMetaPayload(meta, scope, layout.metaShardId, metaShard.generationId);
341
+ if (metaIssues.length > 0) {
342
+ issues.push(...metaIssues);
343
+ return { snapshot: null, issues };
344
+ }
345
+
346
+ const nodes: FileNodeV1[] = [];
347
+ const edges: FileEdgeV1[] = [];
348
+
349
+ // Erwartungsmenge NICHT aus `listShardIds` ableiten: ein Shard, den es nicht
350
+ // mehr gibt, wird schlicht nicht aufgezaehlt, und ein Vergleich der Liste mit
351
+ // sich selbst kann nie etwas finden. Der Pfadindex ist der unabhaengige
352
+ // Zeuge, welche `file_id`s zu dieser Generation gehoeren.
353
+ const pathIndexShard = await store.readShard("graph_indices", layout.pathIndexShardId, {
354
+ ...options,
355
+ generationId,
356
+ });
357
+ if (pathIndexShard !== null) {
358
+ issues.push(
359
+ ...validatePayloadScope(
360
+ pathIndexShard.payload,
361
+ scope,
362
+ `graph_indices/${layout.pathIndexShardId}`,
363
+ pathIndexShard.generationId,
364
+ layout.legacy,
365
+ ),
366
+ );
367
+ if (issues.some((issue) => issue.kind === "scope_mismatch")) {
368
+ return { snapshot: null, issues };
369
+ }
370
+ }
371
+ // WP-197: Die beim Schreiben verdraengten `file_id`s stehen im Payload des
372
+ // Pfadindex. Sie hier zu ueberlesen hiesse, den Verlust genau an der Stelle
373
+ // zu verschweigen, an der er wirkt: `expectedFileIds` kennt sie nicht, also
374
+ // koennte `missing_file_shard` sie auch nie vermissen.
375
+ const collisions = pathIndexShard?.payload["pathIndexCollisions"];
376
+ if (Array.isArray(collisions)) {
377
+ for (const collision of collisions) {
378
+ if (!isRecord(collision)) continue;
379
+ issues.push({
380
+ kind: "malformed_payload",
381
+ message: `graph_indices/${layout.pathIndexShardId} of generation "${generationId}" records a path collision: "${String(collision["key"])}" maps to file id "${String(collision["kept"])}", so "${String(collision["dropped"])}" is NOT named by the path index and a missing shard for it could not be detected`,
382
+ });
383
+ }
384
+ }
385
+ const expectedFileIds = expectedFileIdsOf(pathIndexShard?.payload);
386
+ if (expectedFileIds === null) {
387
+ issues.push({
388
+ kind: "missing_path_index",
389
+ message:
390
+ pathIndexShard === null
391
+ ? `graph_indices/${layout.pathIndexShardId} is absent for generation "${generationId}"; missing file shards cannot be named, only counted`
392
+ : `graph_indices/${layout.pathIndexShardId} has no usable "pathIndex" payload for generation "${generationId}"; missing file shards cannot be named, only counted`,
393
+ });
394
+ }
395
+
396
+ const shardCandidates =
397
+ expectedFileIds === null
398
+ ? (await store.listShardIds("graph_files", { ...options, generationId }))
399
+ .filter(layout.ownsFileShard)
400
+ .sort(compareCodepoints)
401
+ .map((shardId) => ({ shardId, expectedFileId: null }))
402
+ : expectedFileIds.map((fileId) => ({
403
+ shardId: layout.fileShardId(fileId),
404
+ expectedFileId: fileId,
405
+ }));
406
+
407
+ const fileShards = await readFileShards(store, shardCandidates, generationId, options);
408
+ for (const { shardId, expectedFileId, shard } of fileShards) {
409
+ if (shard === null) {
410
+ issues.push({
411
+ kind: "missing_file_shard",
412
+ message:
413
+ expectedFileId === null
414
+ ? `graph_files/${shardId} was listed but could not be read`
415
+ : `graph_files/${shardId} is named by the path index of generation "${generationId}" but is absent from the store`,
416
+ });
417
+ continue;
418
+ }
419
+ const payloadScopeIssues = validatePayloadScope(
420
+ shard.payload,
421
+ scope,
422
+ `graph_files/${shardId}`,
423
+ shard.generationId,
424
+ layout.legacy,
425
+ );
426
+ issues.push(...payloadScopeIssues);
427
+ if (payloadScopeIssues.length > 0) continue;
428
+
429
+ const node = shard.payload["node"];
430
+ const shardEdges = shard.payload["edges"];
431
+ if (!isRecord(node) || !Array.isArray(shardEdges)) {
432
+ issues.push({
433
+ kind: "malformed_payload",
434
+ message: `graph_files/${shardId} has no usable "node"/"edges" payload`,
435
+ });
436
+ continue;
437
+ }
438
+ // Der Shard und der Knoten darin muessen DIESELBE Datei sein.
439
+ //
440
+ // Ohne diesen Vergleich ist die haeufigste zahlenerhaltende Verfaelschung
441
+ // unsichtbar: traegt `graph_files/F_c` den Knoten von `F_a`, dann ist der
442
+ // Shard gelistet, der Pfadindex nennt ihn, und drei Shards liefern drei
443
+ // Knoten — beide bisherigen Zeugen (`missing_file_shard`,
444
+ // `count_mismatch`) bescheinigen dem Abbild ausdruecklich
445
+ // Vollstaendigkeit, waehrend `F_c` verschwunden und `F_a` doppelt ist.
446
+ // Ein Abbild, das die Eindeutigkeit der `file_id` verletzt, ist fuer
447
+ // jeden Ableiter falsch, und es unbenannt zu liefern ist genau das
448
+ // stille Ueberspringen, das E4 verbietet.
449
+ const nodeFileId = node["fileId"];
450
+ const expectedPhysicalShardId =
451
+ typeof nodeFileId === "string" && nodeFileId !== "" ? layout.fileShardId(nodeFileId) : null;
452
+ if (
453
+ expectedPhysicalShardId === null ||
454
+ expectedPhysicalShardId !== shardId ||
455
+ (expectedFileId !== null && nodeFileId !== expectedFileId)
456
+ ) {
457
+ issues.push({
458
+ kind: "malformed_payload",
459
+ message: `graph_files/${shardId} carries a node with fileId ${
460
+ typeof nodeFileId === "string" ? `"${nodeFileId}"` : "of a non-string type"
461
+ }; the shard and the node it carries must be the same file`,
462
+ });
463
+ continue;
464
+ }
465
+ if (node["workspaceId"] !== workspaceId || node["graphGeneration"] !== generationId) {
466
+ issues.push({
467
+ kind: "scope_mismatch",
468
+ message: `graph_files/${shardId} carries a node outside requested workspace "${workspaceId}" or generation "${generationId}"`,
469
+ });
470
+ continue;
471
+ }
472
+ const edgeScopeMismatch = shardEdges.some(
473
+ (edge) =>
474
+ !isRecord(edge) ||
475
+ edge["workspaceId"] !== workspaceId ||
476
+ edge["graphGeneration"] !== generationId ||
477
+ edge["fromFileId"] !== nodeFileId,
478
+ );
479
+ if (edgeScopeMismatch) {
480
+ issues.push({
481
+ kind: "scope_mismatch",
482
+ message: `graph_files/${shardId} carries an edge outside the requested scope or source file`,
483
+ });
484
+ continue;
485
+ }
486
+ nodes.push(node as unknown as FileNodeV1);
487
+ edges.push(...(shardEdges as FileEdgeV1[]));
488
+ }
489
+
490
+ if (issues.some((issue) => issue.kind === "scope_mismatch")) {
491
+ return { snapshot: null, issues };
492
+ }
493
+
494
+ // Sollzahlen gegen das tatsaechlich Geladene. Zweiter, vom Pfadindex
495
+ // unabhaengiger Zeuge: er greift auch dann, wenn der Pfadindex selbst fehlt.
496
+ const expectedNodeCount = countOf(meta["nodeCount"]);
497
+ if (expectedNodeCount !== null && expectedNodeCount !== nodes.length) {
498
+ issues.push({
499
+ kind: "count_mismatch",
500
+ message: `graph_meta/${layout.metaShardId} of generation "${generationId}" declares ${String(expectedNodeCount)} nodes, but ${String(nodes.length)} were loaded`,
501
+ });
502
+ }
503
+ const expectedEdgeCount = countOf(meta["edgeCount"]);
504
+ if (expectedEdgeCount !== null && expectedEdgeCount !== edges.length) {
505
+ issues.push({
506
+ kind: "count_mismatch",
507
+ message: `graph_meta/${layout.metaShardId} of generation "${generationId}" declares ${String(expectedEdgeCount)} edges, but ${String(edges.length)} were loaded`,
508
+ });
509
+ }
510
+
511
+ // WP-181: DER DRITTE ZEUGE, der bisher nie befragt wurde.
512
+ //
513
+ // `metrics.nodeCount`/`metrics.edgeCount` liegen im selben Payload und
514
+ // wurden mit demselben Abbild geschrieben — sie sind aber eine EIGENE
515
+ // Aussage, gerechnet vom Metrikpfad, nicht abgeschrieben von
516
+ // `meta.nodeCount`. Wurden nur die aeusseren Zahlen fortgeschrieben und die
517
+ // Metriken nicht (oder umgekehrt), lieferte `readGraphSnapshot` bisher ein
518
+ // Abbild, das sich SELBST widerspricht: `snapshot.metrics.nodeCount` sagt
519
+ // 12, `snapshot.nodes.length` sagt 11 — und beide stehen ohne Vermerk
520
+ // nebeneinander in einem Objekt, das jeder Ableiter fuer in sich stimmig
521
+ // haelt. Ein Widerspruch, den man vorliegen hat und nicht ausspricht, ist
522
+ // dieselbe Klasse wie ein verschwiegener Verlust (E4).
523
+ const metricsPayload = meta["metrics"];
524
+ if (isRecord(metricsPayload)) {
525
+ const metricsNodeCount = countOf(metricsPayload["nodeCount"]);
526
+ if (metricsNodeCount !== null && metricsNodeCount !== nodes.length) {
527
+ issues.push({
528
+ kind: "count_mismatch",
529
+ message: `graph_meta/${layout.metaShardId} of generation "${generationId}" carries metrics.nodeCount ${String(metricsNodeCount)}, but ${String(nodes.length)} nodes were loaded; the returned snapshot would contradict itself`,
530
+ });
531
+ }
532
+ const metricsEdgeCount = countOf(metricsPayload["edgeCount"]);
533
+ if (metricsEdgeCount !== null && metricsEdgeCount !== edges.length) {
534
+ issues.push({
535
+ kind: "count_mismatch",
536
+ message: `graph_meta/${layout.metaShardId} of generation "${generationId}" carries metrics.edgeCount ${String(metricsEdgeCount)}, but ${String(edges.length)} edges were loaded; the returned snapshot would contradict itself`,
537
+ });
538
+ }
539
+ }
540
+
541
+ const snapshot: FileGraphSnapshotV1 = {
542
+ schemaVersion: 1,
543
+ workspaceId,
544
+ graphGeneration: generationId,
545
+ nodes: nodes.sort((left, right) => compareCodepoints(left.fileId, right.fileId)),
546
+ edges: edges.sort(compareEdges),
547
+ metrics: meta["metrics"] as FileGraphSnapshotV1["metrics"],
548
+ topology: meta["topology"] as FileGraphSnapshotV1["topology"],
549
+ skipped: meta["skipped"] as FileGraphSnapshotV1["skipped"],
550
+ };
551
+ return { snapshot, issues };
552
+ }
553
+
554
+ /**
555
+ * Prueft die Pflichtteile des Metadaten-Shards.
556
+ *
557
+ * Geprueft wird nur die TRAGENDE Form — genau die Zusagen, auf die ein
558
+ * Aufrufer sich ohne weitere Pruefung verlassen darf. Eine vollstaendige
559
+ * Schemapruefung waere hier eine zweite Wahrheit neben `domain`; ein
560
+ * ungeprueftes `as` waere gar keine.
561
+ */
562
+ function validateMetaPayload(
563
+ meta: Readonly<Record<string, unknown>>,
564
+ scope: GraphSnapshotScope,
565
+ shardId: string,
566
+ storedGenerationId: string,
567
+ ): SnapshotReadIssue[] {
568
+ const issues = validatePayloadScope(
569
+ meta,
570
+ scope,
571
+ `graph_meta/${shardId}`,
572
+ storedGenerationId,
573
+ false,
574
+ );
575
+ // WP-193: `String(meta["workspaceId"] ?? "")` machte aus einem FEHLENDEN
576
+ // Workspace den Leerstring — und der Leerstring ist im ganzen Produkt die
577
+ // Grenze, innerhalb derer eine Anfrage ueberhaupt gilt. Ein Abbild ohne
578
+ // Workspace wurde damit zu einem Abbild MIT einem (leeren) Workspace, das
579
+ // sich anschliessend gegen jede echte Workspace-Pruefung falsch verhaelt.
580
+ // Ein Pflichtteil, der fehlt, ist ein Befund, kein Vorgabewert.
581
+ const workspaceId = meta["workspaceId"];
582
+ if (typeof workspaceId !== "string" || workspaceId.trim() === "") {
583
+ issues.push({
584
+ kind: "malformed_payload",
585
+ message: `graph_meta/${shardId} has no usable "workspaceId"; a snapshot without a workspace has no scope it could belong to`,
586
+ });
587
+ }
588
+ const metrics = meta["metrics"];
589
+ if (
590
+ !isRecord(metrics) ||
591
+ typeof metrics["nodeCount"] !== "number" ||
592
+ metrics["workspaceId"] !== scope.workspaceId ||
593
+ metrics["graphGeneration"] !== scope.generationId
594
+ ) {
595
+ issues.push({
596
+ kind: "malformed_payload",
597
+ message: `graph_meta/${shardId} has no usable, scope-matching "metrics" payload`,
598
+ });
599
+ }
600
+ const topology = meta["topology"];
601
+ if (
602
+ !isRecord(topology) ||
603
+ topology["workspaceId"] !== scope.workspaceId ||
604
+ !Array.isArray(topology["packages"]) ||
605
+ !Array.isArray(topology["services"]) ||
606
+ !isRecord(topology["skipped"]) ||
607
+ topology["skipped"]["generationId"] !== scope.generationId
608
+ ) {
609
+ issues.push({
610
+ kind: "malformed_payload",
611
+ message: `graph_meta/${shardId} has no usable, scope-matching "topology" payload`,
612
+ });
613
+ }
614
+ const skipped = meta["skipped"];
615
+ if (
616
+ !isRecord(skipped) ||
617
+ skipped["generationId"] !== scope.generationId ||
618
+ !Array.isArray(skipped["entries"]) ||
619
+ !isRecord(skipped["countsByReason"])
620
+ ) {
621
+ issues.push({
622
+ kind: "malformed_payload",
623
+ message: `graph_meta/${shardId} has no usable, scope-matching "skipped" payload`,
624
+ });
625
+ }
626
+ return issues;
627
+ }
628
+
629
+ function validatePayloadScope(
630
+ payload: Readonly<Record<string, unknown>>,
631
+ scope: GraphSnapshotScope,
632
+ subject: string,
633
+ storedGenerationId: string,
634
+ legacy: boolean,
635
+ ): SnapshotReadIssue[] {
636
+ const issues: SnapshotReadIssue[] = [];
637
+ if (storedGenerationId !== scope.generationId) {
638
+ issues.push({
639
+ kind: "scope_mismatch",
640
+ message: `${subject} was returned from generation "${storedGenerationId}" while "${scope.generationId}" was requested`,
641
+ });
642
+ }
643
+
644
+ const payloadWorkspaceId = payload["workspaceId"];
645
+ if (payloadWorkspaceId !== undefined && payloadWorkspaceId !== scope.workspaceId) {
646
+ issues.push({
647
+ kind: "scope_mismatch",
648
+ message: `${subject} belongs to workspace "${String(payloadWorkspaceId)}", not requested workspace "${scope.workspaceId}"`,
649
+ });
650
+ } else if (!legacy && payloadWorkspaceId === undefined) {
651
+ issues.push({
652
+ kind: "scope_mismatch",
653
+ message: `${subject} has no workspaceId persistence marker`,
654
+ });
655
+ }
656
+
657
+ const payloadGeneration = payload["graphGeneration"];
658
+ if (payloadGeneration !== undefined && payloadGeneration !== scope.generationId) {
659
+ issues.push({
660
+ kind: "scope_mismatch",
661
+ message: `${subject} belongs to generation "${String(payloadGeneration)}", not requested generation "${scope.generationId}"`,
662
+ });
663
+ } else if (!legacy && payloadGeneration === undefined) {
664
+ issues.push({
665
+ kind: "scope_mismatch",
666
+ message: `${subject} has no graphGeneration persistence marker`,
667
+ });
668
+ }
669
+ return issues;
670
+ }
671
+
672
+ /** `file_id`s laut Pfadindex; `null`, wenn der Index unbrauchbar ist. */
673
+ function expectedFileIdsOf(
674
+ payload: Readonly<Record<string, unknown>> | undefined,
675
+ ): string[] | null {
676
+ if (payload === undefined) return null;
677
+ const pathIndex = payload["pathIndex"];
678
+ if (!isRecord(pathIndex)) return null;
679
+ const fileIds = new Set<string>();
680
+ for (const value of Object.values(pathIndex)) {
681
+ if (typeof value === "string" && value !== "") fileIds.add(value);
682
+ }
683
+ return [...fileIds].sort(compareCodepoints);
684
+ }
685
+
686
+ function countOf(value: unknown): number | null {
687
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
688
+ }
689
+
690
+ function isRecord(value: unknown): value is Record<string, unknown> {
691
+ return typeof value === "object" && value !== null && !Array.isArray(value);
692
+ }
693
+
694
+ function pathKey(repoId: string, path: string): string {
695
+ return `${repoId}\u001f${normalizePath(path)}`;
696
+ }
697
+
698
+ function assertGraphSnapshotScope(scope: GraphSnapshotScope): void {
699
+ assertIdentifier("workspaceId", scope.workspaceId);
700
+ assertIdentifier("generationId", scope.generationId);
701
+ }
702
+
703
+ function assertFileGraphSnapshotScope(snapshot: FileGraphSnapshotV1): void {
704
+ assertGraphSnapshotScope({
705
+ workspaceId: snapshot.workspaceId,
706
+ generationId: snapshot.graphGeneration,
707
+ });
708
+
709
+ if (
710
+ snapshot.metrics.workspaceId !== snapshot.workspaceId ||
711
+ snapshot.metrics.graphGeneration !== snapshot.graphGeneration ||
712
+ snapshot.topology.workspaceId !== snapshot.workspaceId ||
713
+ snapshot.topology.skipped.generationId !== snapshot.graphGeneration ||
714
+ snapshot.skipped.generationId !== snapshot.graphGeneration
715
+ ) {
716
+ throw new TypeError(
717
+ "file graph metrics, topology and skip ledger must match the snapshot workspace and generation",
718
+ );
719
+ }
720
+ for (const node of snapshot.nodes) {
721
+ if (
722
+ node.workspaceId !== snapshot.workspaceId ||
723
+ node.graphGeneration !== snapshot.graphGeneration
724
+ ) {
725
+ throw new TypeError(
726
+ `file graph node "${node.fileId}" does not match snapshot workspace/generation`,
727
+ );
728
+ }
729
+ }
730
+ for (const edge of snapshot.edges) {
731
+ if (
732
+ edge.workspaceId !== snapshot.workspaceId ||
733
+ edge.graphGeneration !== snapshot.graphGeneration
734
+ ) {
735
+ throw new TypeError(
736
+ `file graph edge "${edge.edgeId}" does not match snapshot workspace/generation`,
737
+ );
738
+ }
739
+ }
740
+ }
741
+
742
+ function assertIdentifier(name: string, value: string): void {
743
+ if (typeof value !== "string" || value.trim() === "") {
744
+ throw new TypeError(`${name} must be a non-empty string`);
745
+ }
746
+ }