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

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
package/src/graph.ts ADDED
@@ -0,0 +1,591 @@
1
+ /**
2
+ * Der Dateigraph selbst: Aufbau, Abfrage, Traversierung, Fortschreibung.
3
+ *
4
+ * DREI ENTSCHEIDUNGEN, die hier festgeschrieben sind:
5
+ *
6
+ * 1. `unresolvedImportCount` zaehlt Kanten mit
7
+ * `resolution.status === "unresolved"`, NICHT solche mit
8
+ * `kind === "unresolved"`. Die normative Regel von `validateFileEdge`
9
+ * lautet: ein nicht aufloesbarer Import behaelt `kind: "import"`. Wer nach
10
+ * der Art zaehlte, bekaeme in jedem realen Lauf die Zahl 0 — und damit
11
+ * eine Kennzahl, die genau das verschweigt, wofuer sie da ist (E4).
12
+ *
13
+ * 2. Nur AUFGELOESTE Kanten mit Ziel im Workspace sind Graphverbindungen.
14
+ * Externe und unaufgeloeste Kanten existieren weiter, zaehlen in die
15
+ * Import-Resolution-Rate und stehen in `imports`, haben aber keinen
16
+ * Zielknoten — es gibt keinen.
17
+ *
18
+ * 3. Die Traversierung bricht Zyklen NICHT auf, sie meldet sie. Der
19
+ * Zyklenschutz verhindert nur die Endlosschleife; jeder wiedergetroffene
20
+ * Knoten landet in `revisitSkippedFileIds` und jeder geschlossene Pfad in
21
+ * `cycles`.
22
+ */
23
+ import type {
24
+ FileEdgeV1,
25
+ FileGraphMetricsV1,
26
+ FileNodeV1,
27
+ SkipLedgerEntryV1,
28
+ SkipLedgerV1,
29
+ TraversalDirection,
30
+ TraversalLimitsV1,
31
+ } from "@untangle-code/domain";
32
+ import {
33
+ buildSkipLedgerEntry,
34
+ compareCodepoints,
35
+ summarizeSkipReasons,
36
+ uniqueSortedByCodepoints,
37
+ } from "@untangle-code/domain";
38
+
39
+ import { assignContainerService, assignPackage } from "./assignment.js";
40
+ import type { EdgeBuildContextV1 } from "./edges.js";
41
+ import { buildEdgesForFile, compareEdges } from "./edges.js";
42
+ import { FileIndex } from "./file-index.js";
43
+ import type { AdjacencyV1 } from "./metrics.js";
44
+ import { DEFAULT_GRAPH_LIMITS, buildAdjacency, computeFileGraphMetrics } from "./metrics.js";
45
+ import { WorkspacePackageCatalog } from "./package-catalog.js";
46
+ import { basenameOf, dirnameOf, extensionOf, normalizePath } from "./paths.js";
47
+ import { DEFAULT_EXTENSION_ORDER } from "./resolve/candidates.js";
48
+ import type { FileGraphNeighborhoodV1, MonotonicClock } from "./traverse.js";
49
+ import { DEFAULT_TRAVERSAL_LIMITS, traverseFileGraph } from "./traverse.js";
50
+ import type {
51
+ AliasRuleV1,
52
+ FileGraphLimitsV1,
53
+ FileGraphSnapshotV1,
54
+ FileImportsV1,
55
+ FileTraversalResultV1,
56
+ ImportResolverV1,
57
+ RepositoryInputV1,
58
+ WorkspaceTopologyV1,
59
+ } from "./types.js";
60
+
61
+ /**
62
+ * Standardbudgets und Uhr leben seit dem Bau des Lesepfades in `traverse.ts`
63
+ * — hier stehen sie nur noch als Weiterreichung, damit der bisherige
64
+ * Einstiegspunkt des Pakets unveraendert bleibt.
65
+ */
66
+ export { DEFAULT_TRAVERSAL_LIMITS };
67
+ export type { MonotonicClock };
68
+
69
+ export interface BuildFileGraphInputV1 {
70
+ workspaceId: string;
71
+ graphGeneration: string;
72
+ repositories: readonly RepositoryInputV1[];
73
+ files: readonly FileImportsV1[];
74
+ topology: WorkspaceTopologyV1;
75
+ /** Aliasregeln aus Projektkonfigurationen (siehe `aliasRulesFromConfigs`). */
76
+ projectAliases?: readonly AliasRuleV1[];
77
+ /** Aliasregeln aus der Nutzerkonfiguration. */
78
+ configuredAliases?: readonly AliasRuleV1[];
79
+ extensionOrder?: readonly string[];
80
+ limits?: Partial<FileGraphLimitsV1>;
81
+ /** Ersetzt die Standardpipeline; nur fuer Erweiterungen gedacht. */
82
+ pipeline?: readonly ImportResolverV1[];
83
+ }
84
+
85
+ /** Eine Aenderung fuer die inkrementelle Fortschreibung. */
86
+ export type FileGraphChangeV1 =
87
+ | { kind: "added"; file: FileImportsV1 }
88
+ | { kind: "modified"; file: FileImportsV1 }
89
+ | { kind: "removed"; fileId: string };
90
+
91
+ /** Was eine Fortschreibung tatsaechlich angefasst hat. */
92
+ export interface FileGraphUpdateResultV1 {
93
+ /** Dateien, deren Kanten neu gebaut wurden, codepoint-sortiert. */
94
+ recomputedFileIds: string[];
95
+ addedEdgeIds: string[];
96
+ removedEdgeIds: string[];
97
+ /** Knoten, deren `dependents` sich geaendert haben. */
98
+ affectedFileIds: string[];
99
+ skipped: SkipLedgerEntryV1[];
100
+ }
101
+
102
+ interface TraverseOptionsV1 {
103
+ direction: TraversalDirection;
104
+ limits?: Partial<TraversalLimitsV1>;
105
+ now?: MonotonicClock;
106
+ }
107
+
108
+ export class FileDependencyGraph {
109
+ private readonly nodesById = new Map<string, FileNodeV1>();
110
+ private readonly edgesById = new Map<string, FileEdgeV1>();
111
+ private readonly edgeIdsByFrom = new Map<string, string[]>();
112
+ private readonly filesById = new Map<string, FileImportsV1>();
113
+ /**
114
+ * Ledger-Eintraege JE DATEI statt in einer flachen Liste.
115
+ *
116
+ * Eine flache Liste laesst sich nur ueber den Pfad aufraeumen — und der ist
117
+ * genau das, was sich bei einer Umbenennung aendert. Ein Eintrag der alten
118
+ * Fassung bliebe dann unter dem ALTEN Pfad stehen, und beim Entfernen einer
119
+ * Datei bliebe er ganz. Beides sind Meldungen ueber Dateien, die der Graph
120
+ * nicht mehr kennt; `countsByReason` waere dauerhaft falsch. Ueber die
121
+ * `file_id` ist das Aufraeumen exakt.
122
+ */
123
+ private readonly skipEntriesByFile = new Map<string, SkipLedgerEntryV1[]>();
124
+ private adjacency: AdjacencyV1 = { forward: new Map(), reverse: new Map() };
125
+ private cachedMetrics: FileGraphMetricsV1 | null = null;
126
+
127
+ private constructor(
128
+ readonly workspaceId: string,
129
+ readonly graphGeneration: string,
130
+ private readonly index: FileIndex,
131
+ private readonly edgeContext: EdgeBuildContextV1,
132
+ private readonly topology: WorkspaceTopologyV1,
133
+ private readonly limits: FileGraphLimitsV1,
134
+ ) {}
135
+
136
+ static build(input: BuildFileGraphInputV1): FileDependencyGraph {
137
+ const index = FileIndex.fromFiles(input.files);
138
+ const packages = input.topology.packages;
139
+ // Repository-bewusster Katalog: eine Karte nach Namen allein liesse
140
+ // gleichnamige Pakete zweier Repositories einander ersatzlos verdecken.
141
+ const packageCatalog = new WorkspacePackageCatalog(packages);
142
+ const refIdByRepoId = new Map(
143
+ input.repositories.map((repository) => [repository.repoId, repository.refId]),
144
+ );
145
+ const edgeContext: EdgeBuildContextV1 = {
146
+ workspaceId: input.workspaceId,
147
+ graphGeneration: input.graphGeneration,
148
+ packages,
149
+ packageCatalog,
150
+ services: input.topology.services,
151
+ projectAliases: input.projectAliases ?? [],
152
+ configuredAliases: input.configuredAliases ?? [],
153
+ extensionOrder: input.extensionOrder ?? DEFAULT_EXTENSION_ORDER,
154
+ files: index,
155
+ refIdByRepoId,
156
+ resolveFileId: (repoId, path) => index.fileIdOf(repoId, path),
157
+ ...(input.pipeline === undefined ? {} : { pipeline: input.pipeline }),
158
+ };
159
+ const graph = new FileDependencyGraph(
160
+ input.workspaceId,
161
+ input.graphGeneration,
162
+ index,
163
+ edgeContext,
164
+ input.topology,
165
+ { ...DEFAULT_GRAPH_LIMITS, ...input.limits },
166
+ );
167
+
168
+ for (const file of input.files) {
169
+ graph.filesById.set(file.fileId, file);
170
+ }
171
+ for (const file of [...input.files].sort((left, right) =>
172
+ compareCodepoints(left.fileId, right.fileId),
173
+ )) {
174
+ graph.rebuildEdgesOf(file);
175
+ }
176
+ graph.refresh();
177
+ return graph;
178
+ }
179
+
180
+ // -------------------------------------------------------------------------
181
+ // Abfragen
182
+ // -------------------------------------------------------------------------
183
+
184
+ nodes(): FileNodeV1[] {
185
+ return [...this.nodesById.values()].sort((left, right) =>
186
+ compareCodepoints(left.fileId, right.fileId),
187
+ );
188
+ }
189
+
190
+ edges(): FileEdgeV1[] {
191
+ return [...this.edgesById.values()].sort(compareEdges);
192
+ }
193
+
194
+ node(fileId: string): FileNodeV1 | null {
195
+ return this.nodesById.get(fileId) ?? null;
196
+ }
197
+
198
+ edge(edgeId: string): FileEdgeV1 | null {
199
+ return this.edgesById.get(edgeId) ?? null;
200
+ }
201
+
202
+ /** Kanten, deren Quelle `fileId` ist, in kanonischer Ordnung. */
203
+ edgesFrom(fileId: string): FileEdgeV1[] {
204
+ return (this.edgeIdsByFrom.get(fileId) ?? [])
205
+ .map((edgeId) => this.edgesById.get(edgeId))
206
+ .filter((edge): edge is FileEdgeV1 => edge !== undefined)
207
+ .sort(compareEdges);
208
+ }
209
+
210
+ metrics(): FileGraphMetricsV1 {
211
+ if (this.cachedMetrics === null) {
212
+ this.cachedMetrics = computeFileGraphMetrics({
213
+ workspaceId: this.workspaceId,
214
+ graphGeneration: this.graphGeneration,
215
+ nodes: this.nodes(),
216
+ edges: this.edges(),
217
+ adjacency: this.adjacency,
218
+ limits: this.limits,
219
+ });
220
+ }
221
+ return this.cachedMetrics;
222
+ }
223
+
224
+ /** Alle Ledger-Eintraege des Graphen, in kanonischer Ordnung. */
225
+ private allSkipEntries(): SkipLedgerEntryV1[] {
226
+ const entries: SkipLedgerEntryV1[] = [];
227
+ for (const fileId of [...this.skipEntriesByFile.keys()].sort(compareCodepoints)) {
228
+ entries.push(...(this.skipEntriesByFile.get(fileId) ?? []));
229
+ }
230
+ return entries;
231
+ }
232
+
233
+ skipLedger(): SkipLedgerV1 {
234
+ const entries = sortSkipEntries(this.allSkipEntries());
235
+ return {
236
+ schemaVersion: 1,
237
+ generationId: this.graphGeneration,
238
+ entries,
239
+ countsByReason: summarizeSkipReasons(entries),
240
+ };
241
+ }
242
+
243
+ /** Vollstaendiges, serialisierbares Abbild inklusive Topologie und Ledger. */
244
+ snapshot(): FileGraphSnapshotV1 {
245
+ const graphEntries = sortSkipEntries([
246
+ ...this.allSkipEntries(),
247
+ ...this.topology.skipped.entries,
248
+ ]);
249
+ return {
250
+ schemaVersion: 1,
251
+ workspaceId: this.workspaceId,
252
+ graphGeneration: this.graphGeneration,
253
+ nodes: this.nodes(),
254
+ edges: this.edges(),
255
+ metrics: this.metrics(),
256
+ topology: this.topology,
257
+ skipped: {
258
+ schemaVersion: 1,
259
+ generationId: this.graphGeneration,
260
+ entries: graphEntries,
261
+ countsByReason: summarizeSkipReasons(graphEntries),
262
+ },
263
+ };
264
+ }
265
+
266
+ // -------------------------------------------------------------------------
267
+ // Traversierung
268
+ // -------------------------------------------------------------------------
269
+
270
+ /** Woran haenge ich (vorwaerts, ueber `dependencies`). */
271
+ dependenciesOf(
272
+ fileId: string,
273
+ limits?: Partial<TraversalLimitsV1>,
274
+ now?: MonotonicClock,
275
+ ): FileTraversalResultV1 {
276
+ return this.traverse([fileId], {
277
+ direction: "forward",
278
+ ...(limits === undefined ? {} : { limits }),
279
+ ...(now === undefined ? {} : { now }),
280
+ });
281
+ }
282
+
283
+ /** Wer haengt an mir (rueckwaerts, ueber `dependents`) — die Wirkungsfrage. */
284
+ dependentsOf(
285
+ fileId: string,
286
+ limits?: Partial<TraversalLimitsV1>,
287
+ now?: MonotonicClock,
288
+ ): FileTraversalResultV1 {
289
+ return this.traverse([fileId], {
290
+ direction: "reverse",
291
+ ...(limits === undefined ? {} : { limits }),
292
+ ...(now === undefined ? {} : { now }),
293
+ });
294
+ }
295
+
296
+ /**
297
+ * Tiefensuche mit Budget und Zyklenmeldung.
298
+ *
299
+ * Die Traversierung selbst steht in `traverse.ts` — sie ist wortgleich die,
300
+ * die auch ein aus einem Abbild zurueckgeladener Graph benutzt. Hier bleibt
301
+ * nur die Nachbarschaft: die Adjazenz dieses Graphen und die Frage, welche
302
+ * Kante zwei benachbarte Dateien verbindet.
303
+ */
304
+ traverse(rootFileIds: readonly string[], options: TraverseOptionsV1): FileTraversalResultV1 {
305
+ const neighborhood: FileGraphNeighborhoodV1 = {
306
+ adjacency: this.adjacency,
307
+ edgeIdBetween: (from, to, direction) => this.edgeIdBetween(from, to, direction),
308
+ };
309
+ return traverseFileGraph(neighborhood, rootFileIds, options);
310
+ }
311
+
312
+ // -------------------------------------------------------------------------
313
+ // Inkrementelle Fortschreibung
314
+ // -------------------------------------------------------------------------
315
+
316
+ /**
317
+ * Schreibt den Graphen fort, ohne ihn neu zu bauen.
318
+ *
319
+ * WELCHE DATEIEN NEU GERECHNET WERDEN — und warum nicht nur die geaenderte:
320
+ *
321
+ * - `modified` (Inhalt geaendert, Pfad gleich): NUR die Datei selbst. Ihre
322
+ * Existenz hat sich nicht geaendert, also kann sich die Aufloesung keiner
323
+ * anderen Datei geaendert haben.
324
+ * - `added` / `removed`: zusaetzlich jede Datei, deren Aufloesung von der
325
+ * Existenz dieses Pfades abhaengt. Die ist exakt bestimmbar, weil jede
326
+ * unaufgeloeste Kante ihre geprueften Kandidaten mitfuehrt (E4 zahlt sich
327
+ * hier aus): wer den betroffenen Pfad geprueft und nicht gefunden hat,
328
+ * findet ihn jetzt. Dazu kommen die bisherigen Abhaengigen und alle
329
+ * Dateien, die auf denselben Stamm aufgeloest haben (Verdeckungsfall:
330
+ * ein neues `util.ts` verdeckt ein bestehendes `util.js`).
331
+ */
332
+ applyChanges(changes: readonly FileGraphChangeV1[]): FileGraphUpdateResultV1 {
333
+ const previousEdgeIds = new Set(this.edgesById.keys());
334
+ const dependentsBefore = new Map(
335
+ [...this.adjacency.reverse.entries()].map(([id, sources]) => [id, sources.join("/")]),
336
+ );
337
+
338
+ const toRecompute = new Set<string>();
339
+ const affectedPaths: { repoId: string; path: string }[] = [];
340
+ /**
341
+ * WP-195: Ein `removed` fuer eine `file_id`, die dieser Graph gar nicht
342
+ * kennt, verschwand bisher spurlos — `entryById` gab `null`, `dropFile`
343
+ * fand nichts, und der Aufrufer bekam ein Ergebnis, das aussah, als sei
344
+ * die Loeschung ausgefuehrt worden. Genau daran erkennt man aber, dass
345
+ * zwei Zustaende auseinandergelaufen sind: der Waechter meldet die
346
+ * Loeschung einer Datei, die der Graph nie hatte. Das ist ein Befund, kein
347
+ * Nichts.
348
+ */
349
+ const unknownRemovals: string[] = [];
350
+
351
+ for (const change of changes) {
352
+ if (change.kind === "removed") {
353
+ const entry = this.index.entryById(change.fileId);
354
+ if (entry !== null) {
355
+ affectedPaths.push({ repoId: entry.repoId, path: entry.path });
356
+ } else {
357
+ unknownRemovals.push(change.fileId);
358
+ }
359
+ this.dropFile(change.fileId);
360
+ continue;
361
+ }
362
+ const file = change.file;
363
+ const nextPath = normalizePath(file.path);
364
+ const previous = this.index.entryById(file.fileId);
365
+ let pathChanged = false;
366
+ if (previous !== null && (previous.path !== nextPath || previous.repoId !== file.repoId)) {
367
+ // Ein Pfadwechsel ist fachlich ein Entfernen plus ein Hinzufuegen —
368
+ // und zwar BEIDES. Der alte Pfad verschwindet:
369
+ pathChanged = true;
370
+ affectedPaths.push({ repoId: previous.repoId, path: previous.path });
371
+ }
372
+ // ... und der neue Pfad entsteht. Nur die erste Haelfte zu melden war
373
+ // der Fehler: wer den neuen Pfad vergeblich geprueft hatte, findet ihn
374
+ // jetzt (A5), und wer auf denselben Stamm mit schwaecherer Endung
375
+ // aufgeloest hatte, wird jetzt verdeckt (A6). Beide Faelle blieben
376
+ // ungerechnet, das Ergebnis wich vom Neubau ab, und weil die Kante auf
377
+ // eine EXISTIERENDE Datei zeigte, meldete auch das Ledger nichts (E4).
378
+ if (change.kind === "added" || previous === null || pathChanged) {
379
+ affectedPaths.push({ repoId: file.repoId, path: nextPath });
380
+ }
381
+ this.filesById.set(file.fileId, file);
382
+ this.index.add({
383
+ fileId: file.fileId,
384
+ repoId: file.repoId,
385
+ refId: file.refId,
386
+ path: nextPath,
387
+ languageId: file.languageId,
388
+ contentHash: file.contentHash,
389
+ });
390
+ toRecompute.add(file.fileId);
391
+ }
392
+
393
+ for (const affected of affectedPaths) {
394
+ for (const fileId of this.filesDependingOnPath(affected.repoId, affected.path)) {
395
+ toRecompute.add(fileId);
396
+ }
397
+ }
398
+
399
+ const skipped: SkipLedgerEntryV1[] = [];
400
+ for (const fileId of unknownRemovals.sort(compareCodepoints)) {
401
+ skipped.push(
402
+ buildSkipLedgerEntry({
403
+ stage: "file_graph",
404
+ subjectKind: "file",
405
+ subjectId: fileId,
406
+ reason: "missing_index_entry",
407
+ detail: `a removal was reported for file id "${fileId}", but this graph never contained it; nothing was removed`,
408
+ recoverable: true,
409
+ }),
410
+ );
411
+ }
412
+ for (const fileId of [...toRecompute].sort(compareCodepoints)) {
413
+ const file = this.filesById.get(fileId);
414
+ if (file === undefined) continue;
415
+ skipped.push(...this.rebuildEdgesOf(file));
416
+ }
417
+
418
+ this.refresh();
419
+
420
+ const currentEdgeIds = new Set(this.edgesById.keys());
421
+ const addedEdgeIds = [...currentEdgeIds].filter((id) => !previousEdgeIds.has(id));
422
+ const removedEdgeIds = [...previousEdgeIds].filter((id) => !currentEdgeIds.has(id));
423
+ const affectedFileIds: string[] = [];
424
+ for (const [id, sources] of this.adjacency.reverse) {
425
+ if (dependentsBefore.get(id) !== sources.join("/")) {
426
+ affectedFileIds.push(id);
427
+ }
428
+ }
429
+ for (const id of dependentsBefore.keys()) {
430
+ if (!this.adjacency.reverse.has(id)) {
431
+ affectedFileIds.push(id);
432
+ }
433
+ }
434
+
435
+ return {
436
+ recomputedFileIds: [...toRecompute].sort(compareCodepoints),
437
+ addedEdgeIds: addedEdgeIds.sort(compareCodepoints),
438
+ removedEdgeIds: removedEdgeIds.sort(compareCodepoints),
439
+ affectedFileIds: uniqueSortedByCodepoints(affectedFileIds),
440
+ skipped,
441
+ };
442
+ }
443
+
444
+ // -------------------------------------------------------------------------
445
+ // intern
446
+ // -------------------------------------------------------------------------
447
+
448
+ private edgeIdBetween(from: string, to: string, direction: TraversalDirection): string | null {
449
+ const source = direction === "forward" ? from : to;
450
+ const target = direction === "forward" ? to : from;
451
+ for (const edgeId of this.edgeIdsByFrom.get(source) ?? []) {
452
+ const edge = this.edgesById.get(edgeId);
453
+ if (edge?.resolution.status === "resolved" && edge.resolution.targetFileId === target) {
454
+ return edgeId;
455
+ }
456
+ }
457
+ return null;
458
+ }
459
+
460
+ /** Dateien, deren Aufloesung sich durch das Auftauchen/Verschwinden von `path` aendern kann. */
461
+ private filesDependingOnPath(repoId: string, path: string): string[] {
462
+ const stem = stemOf(path);
463
+ const result = new Set<string>();
464
+ for (const edge of this.edgesById.values()) {
465
+ if (edge.resolution.status === "unresolved") {
466
+ if ((edge.resolution.rejectedCandidates ?? []).includes(path)) {
467
+ result.add(edge.fromFileId);
468
+ }
469
+ continue;
470
+ }
471
+ if (edge.resolution.status !== "resolved") continue;
472
+ if (edge.resolution.targetRepoId !== repoId) continue;
473
+ if (edge.resolution.targetPath === path || stemOf(edge.resolution.targetPath) === stem) {
474
+ result.add(edge.fromFileId);
475
+ }
476
+ }
477
+ return [...result].sort(compareCodepoints);
478
+ }
479
+
480
+ private dropFile(fileId: string): void {
481
+ this.removeEdgesOf(fileId);
482
+ this.filesById.delete(fileId);
483
+ this.nodesById.delete(fileId);
484
+ // Ohne diese Zeile blieben Ledger-Eintraege ueber eine Datei stehen, die
485
+ // der Graph nicht mehr kennt — Ueberberichten statt Verschweigen, aber
486
+ // `countsByReason` waere trotzdem falsch.
487
+ this.skipEntriesByFile.delete(fileId);
488
+ this.index.remove(fileId);
489
+ }
490
+
491
+ private removeEdgesOf(fileId: string): void {
492
+ for (const edgeId of this.edgeIdsByFrom.get(fileId) ?? []) {
493
+ this.edgesById.delete(edgeId);
494
+ }
495
+ this.edgeIdsByFrom.delete(fileId);
496
+ }
497
+
498
+ private rebuildEdgesOf(file: FileImportsV1): SkipLedgerEntryV1[] {
499
+ this.removeEdgesOf(file.fileId);
500
+ const built = buildEdgesForFile(file, this.edgeContext);
501
+ const edgeIds: string[] = [];
502
+ for (const edge of built.edges) {
503
+ this.edgesById.set(edge.edgeId, edge);
504
+ edgeIds.push(edge.edgeId);
505
+ }
506
+ this.edgeIdsByFrom.set(file.fileId, edgeIds);
507
+ // Die Eintraege der vorigen Fassung dieser Datei werden ERSETZT, nicht
508
+ // ergaenzt — sonst waechst das Ledger bei jeder Fortschreibung um
509
+ // Duplikate. Ersetzt wird ueber die `file_id`, damit auch ein Pfadwechsel
510
+ // die alten Eintraege mitnimmt.
511
+ if (built.skipped.length === 0) {
512
+ this.skipEntriesByFile.delete(file.fileId);
513
+ } else {
514
+ this.skipEntriesByFile.set(file.fileId, [...built.skipped]);
515
+ }
516
+ return built.skipped;
517
+ }
518
+
519
+ /** Baut Knoten, Nachbarschaft und Metrikzwischenspeicher neu auf. */
520
+ private refresh(): void {
521
+ const nodeIds = [...this.filesById.keys()].sort(compareCodepoints);
522
+ this.adjacency = buildAdjacency(nodeIds, [...this.edgesById.values()]);
523
+ this.nodesById.clear();
524
+ for (const fileId of nodeIds) {
525
+ const file = this.filesById.get(fileId) as FileImportsV1;
526
+ const edges = this.edgesFrom(fileId);
527
+ const node: FileNodeV1 = {
528
+ schemaVersion: 1,
529
+ workspaceId: this.workspaceId,
530
+ repoId: file.repoId,
531
+ refId: file.refId,
532
+ fileId,
533
+ filePath: normalizePath(file.path),
534
+ language: file.languageId,
535
+ contentHash: file.contentHash,
536
+ imports: uniqueSortedByCodepoints(edges.map((edge) => edge.rawSpecifier)),
537
+ dependencies: [...(this.adjacency.forward.get(fileId) ?? [])],
538
+ dependents: [...(this.adjacency.reverse.get(fileId) ?? [])],
539
+ graphGeneration: this.graphGeneration,
540
+ unresolvedImportCount: edges.filter((edge) => edge.resolution.status === "unresolved")
541
+ .length,
542
+ };
543
+ // Paket- und Dienstzuordnung direkt bestimmen, NICHT aus einer Kante
544
+ // ableiten: eine Datei ohne Importe hat keine Kante, gehoert aber
545
+ // trotzdem zu einem Paket.
546
+ const owningPackage = assignPackage(this.topology.packages, file.repoId, file.path);
547
+ const owningService = assignContainerService(this.topology.services, file.repoId, file.path);
548
+ if (owningPackage !== null) node.packageName = owningPackage.name;
549
+ if (owningService !== null) node.containerService = owningService.name;
550
+ this.nodesById.set(fileId, node);
551
+ }
552
+ this.cachedMetrics = null;
553
+ }
554
+ }
555
+
556
+ /** Kurzform des Aufbaus. */
557
+ export function buildFileGraph(input: BuildFileGraphInputV1): FileDependencyGraph {
558
+ return FileDependencyGraph.build(input);
559
+ }
560
+
561
+ /**
562
+ * Stamm eines Pfades: ohne Erweiterung und ohne abschliessendes `index`.
563
+ * `src/util.ts`, `src/util.js` und `src/util/index.ts` haben denselben Stamm —
564
+ * genau die Dateien, die einander bei der Kandidatenprobe verdecken koennen.
565
+ */
566
+ export function stemOf(path: string): string {
567
+ const normalized = normalizePath(path);
568
+ const extension = extensionOf(normalized);
569
+ const withoutExtension =
570
+ extension === "" ? normalized : normalized.slice(0, normalized.length - extension.length);
571
+ return basenameOf(withoutExtension) === "index" ? dirnameOf(withoutExtension) : withoutExtension;
572
+ }
573
+
574
+ /**
575
+ * Kanonische Reihenfolge der Ledger-Eintraege.
576
+ *
577
+ * Ohne sie haengt die Reihenfolge daran, in welcher Folge Dateien verarbeitet
578
+ * wurden — und die haengt beim echten Scanner an der Nebenlaeufigkeit. Zwei
579
+ * Laeufe desselben Standes muessen dasselbe Dokument ergeben, sonst ist jeder
580
+ * daraus abgeleitete Hash wertlos.
581
+ */
582
+ function sortSkipEntries(entries: readonly SkipLedgerEntryV1[]): SkipLedgerEntryV1[] {
583
+ return [...entries].sort(
584
+ (left, right) =>
585
+ compareCodepoints(left.stage, right.stage) ||
586
+ compareCodepoints(left.subjectKind, right.subjectKind) ||
587
+ compareCodepoints(left.path ?? "", right.path ?? "") ||
588
+ compareCodepoints(left.reason, right.reason) ||
589
+ compareCodepoints(left.detail, right.detail),
590
+ );
591
+ }
package/src/index.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `@untangle-code/file-graph` — der Dateiabhaengigkeitsgraph (spec.md §14,
3
+ * architecture.md §16).
4
+ *
5
+ * Beantwortet drei Fragen:
6
+ * - Welche Datei haengt von welcher ab (`dependenciesOf`)?
7
+ * - Wer bricht, wenn ich diese Datei aendere (`dependentsOf`)?
8
+ * - Wie haengen die Teilprojekte eines Monorepos zusammen
9
+ * (`loadWorkspaceTopology`: Pakete UND Container-Dienste)?
10
+ *
11
+ * Dieses Modul ist der EINZIGE oeffentliche Export des Pakets.
12
+ */
13
+
14
+ // Pfadhilfen (POSIX, repo-relativ)
15
+ export * from "./paths.js";
16
+
17
+ // Vertraege des Pakets
18
+ export * from "./types.js";
19
+
20
+ // Dateiverzeichnis der Aufloesung
21
+ export * from "./file-index.js";
22
+
23
+ // Zuordnung Datei → Paket / Container-Dienst
24
+ export * from "./assignment.js";
25
+
26
+ // Topologie: Manifeste, Projektkonfigurationen, Workspace-Definition, Compose
27
+ export * from "./topology/jsonc.js";
28
+ export * from "./topology/manifest.js";
29
+ export * from "./topology/project-config.js";
30
+ export * from "./topology/workspace-definition.js";
31
+ export * from "./topology/compose.js";
32
+ export * from "./topology/load.js";
33
+
34
+ // Modulaufloesung
35
+ export * from "./resolve/candidates.js";
36
+ export * from "./resolve/package-exports.js";
37
+ export * from "./resolve/resolvers.js";
38
+ export * from "./resolve/pipeline.js";
39
+
40
+ // Kanten, Metriken, Graph
41
+ export * from "./edges.js";
42
+ export * from "./metrics.js";
43
+ export * from "./graph.js";
44
+
45
+ // Die Traversierung als freie Funktion — sie bedient den gebauten UND den
46
+ // zurueckgeladenen Graphen. `DEFAULT_TRAVERSAL_LIMITS` und `MonotonicClock`
47
+ // stehen bewusst NICHT hier, sondern kommen weiter aus `graph.js`: sie waren
48
+ // immer schon dort zu haben, und zwei Sternexporte desselben Namens waeren
49
+ // ein doppelter Ausgang fuer eine einzige Sache.
50
+ export { traverseFileGraph } from "./traverse.js";
51
+ export type { FileGraphNeighborhoodV1, TraverseFileGraphOptionsV1 } from "./traverse.js";
52
+
53
+ // Ablage ueber den bestehenden GraphStore-Port
54
+ export * from "./persistence.js";
55
+
56
+ // Lesepfad: aus einem Abbild wieder einen abfragbaren Graphen machen
57
+ export * from "./restore.js";
58
+ export * from "./boundaries/index.js";