@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/metrics.ts ADDED
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Graphmetriken des Dateigraphen (spec.md §14.4).
3
+ *
4
+ * Alle Verfahren sind ITERATIV implementiert, nicht rekursiv. Ein Monorepo
5
+ * mit zehntausenden Dateien hat Ketten, die jeden Aufrufstapel sprengen; ein
6
+ * `RangeError` mitten in der Statistik waere ein Totalausfall der Kennzahlen.
7
+ *
8
+ * ZYKLEN: Die Aufzaehlung elementarer Zyklen ist im schlechtesten Fall
9
+ * exponentiell. Sie laeuft deshalb gegen ein Budget — und wenn das Budget
10
+ * greift, steht das in `truncationReason`. Eine abgeschnittene Zyklusliste
11
+ * darf nie wie eine vollstaendige aussehen (E4).
12
+ */
13
+ import type {
14
+ FileEdgeV1,
15
+ FileGraphCentralityEntryV1,
16
+ FileGraphMetricsV1,
17
+ FileNodeV1,
18
+ } from "@untangle-code/domain";
19
+ import { FILE_GRAPH_VERSION, compareCodepoints } from "@untangle-code/domain";
20
+
21
+ import type { FileGraphLimitsV1 } from "./types.js";
22
+
23
+ /** Benannte Formel hinter `FileGraphCentralityEntryV1.centrality`. */
24
+ export const CENTRALITY_FORMULA =
25
+ "degree-centrality-v1: (inDegree + outDegree) / (2 * max(1, nodeCount - 1)), clamped to [0, 1]";
26
+
27
+ export const DEFAULT_GRAPH_LIMITS: FileGraphLimitsV1 = {
28
+ maxCycles: 1_000,
29
+ topCentralCount: 25,
30
+ maxCycleSearchNodes: 200_000,
31
+ };
32
+
33
+ /** Gerichtete Nachbarschaftslisten, aus aufgeloesten Kanten gebaut. */
34
+ export interface AdjacencyV1 {
35
+ /** fileId → Ziel-fileIds, codepoint-sortiert und dedupliziert. */
36
+ forward: Map<string, string[]>;
37
+ /** fileId → Quell-fileIds, codepoint-sortiert und dedupliziert. */
38
+ reverse: Map<string, string[]>;
39
+ }
40
+
41
+ /**
42
+ * Baut die Nachbarschaft aus den Kanten.
43
+ *
44
+ * Nur `resolved`-Kanten mit einem Ziel INNERHALB der Knotenmenge zaehlen —
45
+ * eine externe Abhaengigkeit hat keinen Knoten, und eine unaufgeloeste erst
46
+ * recht nicht. Beide bleiben als Kante erhalten und in der
47
+ * Import-Resolution-Rate sichtbar, sie sind nur keine Graphverbindung.
48
+ */
49
+ export function buildAdjacency(
50
+ nodeIds: readonly string[],
51
+ edges: readonly FileEdgeV1[],
52
+ ): AdjacencyV1 {
53
+ const known = new Set(nodeIds);
54
+ const forwardSets = new Map<string, Set<string>>();
55
+ const reverseSets = new Map<string, Set<string>>();
56
+ for (const id of nodeIds) {
57
+ forwardSets.set(id, new Set());
58
+ reverseSets.set(id, new Set());
59
+ }
60
+ for (const edge of edges) {
61
+ if (edge.resolution.status !== "resolved") continue;
62
+ const target = edge.resolution.targetFileId;
63
+ if (!known.has(edge.fromFileId) || !known.has(target)) continue;
64
+ forwardSets.get(edge.fromFileId)?.add(target);
65
+ reverseSets.get(target)?.add(edge.fromFileId);
66
+ }
67
+ const forward = new Map<string, string[]>();
68
+ const reverse = new Map<string, string[]>();
69
+ for (const id of nodeIds) {
70
+ forward.set(id, [...(forwardSets.get(id) ?? [])].sort(compareCodepoints));
71
+ reverse.set(id, [...(reverseSets.get(id) ?? [])].sort(compareCodepoints));
72
+ }
73
+ return { forward, reverse };
74
+ }
75
+
76
+ /**
77
+ * Stark zusammenhaengende Komponenten nach Tarjan, iterativ.
78
+ *
79
+ * Ergebnis enthaelt NUR Komponenten mit mehr als einem Knoten — eine
80
+ * einelementige Komponente ohne Selbstschleife ist kein Befund. Selbstbezuege
81
+ * kommen ueber `findCycles` als einelementige Zyklen zurueck.
82
+ */
83
+ export function stronglyConnectedComponents(
84
+ nodeIds: readonly string[],
85
+ forward: ReadonlyMap<string, readonly string[]>,
86
+ ): string[][] {
87
+ const index = new Map<string, number>();
88
+ const lowlink = new Map<string, number>();
89
+ const onStack = new Set<string>();
90
+ const stack: string[] = [];
91
+ const components: string[][] = [];
92
+ let counter = 0;
93
+
94
+ for (const root of nodeIds) {
95
+ if (index.has(root)) continue;
96
+ const work: { node: string; childIndex: number }[] = [{ node: root, childIndex: 0 }];
97
+ index.set(root, counter);
98
+ lowlink.set(root, counter);
99
+ counter += 1;
100
+ stack.push(root);
101
+ onStack.add(root);
102
+
103
+ while (work.length > 0) {
104
+ const frame = work[work.length - 1] as { node: string; childIndex: number };
105
+ const children = forward.get(frame.node) ?? [];
106
+ if (frame.childIndex < children.length) {
107
+ const child = children[frame.childIndex] as string;
108
+ frame.childIndex += 1;
109
+ if (!index.has(child)) {
110
+ index.set(child, counter);
111
+ lowlink.set(child, counter);
112
+ counter += 1;
113
+ stack.push(child);
114
+ onStack.add(child);
115
+ work.push({ node: child, childIndex: 0 });
116
+ } else if (onStack.has(child)) {
117
+ lowlink.set(
118
+ frame.node,
119
+ Math.min(lowlink.get(frame.node) as number, index.get(child) as number),
120
+ );
121
+ }
122
+ continue;
123
+ }
124
+ work.pop();
125
+ const parent = work[work.length - 1];
126
+ if (parent !== undefined) {
127
+ lowlink.set(
128
+ parent.node,
129
+ Math.min(lowlink.get(parent.node) as number, lowlink.get(frame.node) as number),
130
+ );
131
+ }
132
+ if (lowlink.get(frame.node) === index.get(frame.node)) {
133
+ const component: string[] = [];
134
+ for (;;) {
135
+ const popped = stack.pop() as string;
136
+ onStack.delete(popped);
137
+ component.push(popped);
138
+ if (popped === frame.node) break;
139
+ }
140
+ if (component.length > 1) {
141
+ components.push(component.sort(compareCodepoints));
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ components.sort((left, right) => compareCodepoints(left[0] as string, right[0] as string));
148
+ return components;
149
+ }
150
+
151
+ /** Ergebnis der Zyklensuche. */
152
+ export interface CycleSearchResultV1 {
153
+ /** Jeder Zyklus als geordnete Liste seiner file_ids, ohne Wiederholung des Startknotens. */
154
+ cycles: string[][];
155
+ /** Gesetzt, wenn ein Budget die Suche beendet hat. */
156
+ truncationReason?: string;
157
+ }
158
+
159
+ /**
160
+ * Zaehlt elementare Zyklen auf, komponentenweise und gegen ein Budget.
161
+ *
162
+ * Vorgehen: je stark zusammenhaengender Komponente wird von jedem Knoten aus
163
+ * gesucht, aber nur ueber Knoten, die in der komponenteninternen Ordnung NICHT
164
+ * VOR dem Startknoten liegen. Damit wird jeder Zyklus genau einmal gefunden
165
+ * (an seinem kleinsten Knoten) statt einmal je Rotation.
166
+ */
167
+ export function findCycles(
168
+ nodeIds: readonly string[],
169
+ forward: ReadonlyMap<string, readonly string[]>,
170
+ components: readonly (readonly string[])[],
171
+ limits: FileGraphLimitsV1,
172
+ ): CycleSearchResultV1 {
173
+ const cycles: string[][] = [];
174
+ let steps = 0;
175
+ let truncationReason: string | undefined;
176
+
177
+ // Selbstbezuege: eine Datei, die sich selbst importiert. Auch sie zaehlen
178
+ // gegen das Budget — sonst koennte eine Liste voller Selbstbezuege das
179
+ // Limit ueberschreiten, ohne dass es jemand erfaehrt.
180
+ for (const node of nodeIds) {
181
+ if (!(forward.get(node) ?? []).includes(node)) continue;
182
+ if (cycles.length >= limits.maxCycles) {
183
+ truncationReason = `cycle enumeration stopped at maxCycles=${String(limits.maxCycles)}; more elementary cycles exist`;
184
+ break;
185
+ }
186
+ cycles.push([node]);
187
+ }
188
+
189
+ for (const component of components) {
190
+ if (truncationReason !== undefined) break;
191
+ const members = [...component].sort(compareCodepoints);
192
+ const rank = new Map(members.map((id, position) => [id, position]));
193
+
194
+ for (const start of members) {
195
+ if (truncationReason !== undefined) break;
196
+ const startRank = rank.get(start) as number;
197
+ const path: string[] = [start];
198
+ const onPath = new Set<string>([start]);
199
+ const work: { node: string; childIndex: number }[] = [{ node: start, childIndex: 0 }];
200
+
201
+ while (work.length > 0) {
202
+ if (cycles.length >= limits.maxCycles) {
203
+ truncationReason = `cycle enumeration stopped at maxCycles=${String(limits.maxCycles)}; more elementary cycles exist`;
204
+ break;
205
+ }
206
+ if (steps >= limits.maxCycleSearchNodes) {
207
+ truncationReason = `cycle enumeration stopped at maxCycleSearchNodes=${String(limits.maxCycleSearchNodes)}; the cycle list is incomplete`;
208
+ break;
209
+ }
210
+ steps += 1;
211
+ const frame = work[work.length - 1] as { node: string; childIndex: number };
212
+ const children = forward.get(frame.node) ?? [];
213
+ if (frame.childIndex >= children.length) {
214
+ work.pop();
215
+ const removed = path.pop();
216
+ if (removed !== undefined) onPath.delete(removed);
217
+ continue;
218
+ }
219
+ const child = children[frame.childIndex] as string;
220
+ frame.childIndex += 1;
221
+ const childRank = rank.get(child);
222
+ if (childRank === undefined || childRank < startRank) continue;
223
+ if (child === start) {
224
+ if (path.length > 1) {
225
+ cycles.push([...path]);
226
+ }
227
+ continue;
228
+ }
229
+ if (onPath.has(child)) continue;
230
+ path.push(child);
231
+ onPath.add(child);
232
+ work.push({ node: child, childIndex: 0 });
233
+ }
234
+ }
235
+ }
236
+
237
+ cycles.sort(
238
+ (left, right) =>
239
+ left.length - right.length || compareCodepoints(left.join("/"), right.join("/")),
240
+ );
241
+ return truncationReason === undefined ? { cycles } : { cycles, truncationReason };
242
+ }
243
+
244
+ /** Berechnet die vollstaendige Metrikaufstellung. */
245
+ export function computeFileGraphMetrics(input: {
246
+ workspaceId: string;
247
+ graphGeneration: string;
248
+ nodes: readonly FileNodeV1[];
249
+ edges: readonly FileEdgeV1[];
250
+ adjacency: AdjacencyV1;
251
+ limits: FileGraphLimitsV1;
252
+ }): FileGraphMetricsV1 {
253
+ const nodeIds = input.nodes.map((node) => node.fileId).sort(compareCodepoints);
254
+ const components = stronglyConnectedComponents(nodeIds, input.adjacency.forward);
255
+ const cycleSearch = findCycles(nodeIds, input.adjacency.forward, components, input.limits);
256
+
257
+ const orphanFileIds: string[] = [];
258
+ const centrality: FileGraphCentralityEntryV1[] = [];
259
+ const denominator = 2 * Math.max(1, nodeIds.length - 1);
260
+ const pathById = new Map(input.nodes.map((node) => [node.fileId, node.filePath]));
261
+
262
+ for (const id of nodeIds) {
263
+ const outDegree = (input.adjacency.forward.get(id) ?? []).length;
264
+ const inDegree = (input.adjacency.reverse.get(id) ?? []).length;
265
+ if (outDegree === 0 && inDegree === 0) {
266
+ orphanFileIds.push(id);
267
+ }
268
+ centrality.push({
269
+ fileId: id,
270
+ filePath: pathById.get(id) ?? "",
271
+ inDegree,
272
+ outDegree,
273
+ centrality: Math.min(1, (inDegree + outDegree) / denominator),
274
+ });
275
+ }
276
+
277
+ const topCentralFiles = [...centrality]
278
+ .sort(
279
+ (left, right) =>
280
+ right.centrality - left.centrality ||
281
+ right.inDegree - left.inDegree ||
282
+ compareCodepoints(left.fileId, right.fileId),
283
+ )
284
+ .slice(0, Math.max(0, input.limits.topCentralCount));
285
+
286
+ let resolvedCount = 0;
287
+ let externalCount = 0;
288
+ let unresolvedCount = 0;
289
+ for (const edge of input.edges) {
290
+ switch (edge.resolution.status) {
291
+ case "resolved":
292
+ resolvedCount += 1;
293
+ break;
294
+ case "external":
295
+ externalCount += 1;
296
+ break;
297
+ case "unresolved":
298
+ unresolvedCount += 1;
299
+ break;
300
+ }
301
+ }
302
+ const total = resolvedCount + externalCount + unresolvedCount;
303
+
304
+ const repositoryDistribution: Record<string, number> = {};
305
+ const languageDistribution: Record<string, number> = {};
306
+ for (const node of [...input.nodes].sort((left, right) =>
307
+ compareCodepoints(left.fileId, right.fileId),
308
+ )) {
309
+ repositoryDistribution[node.repoId] = (repositoryDistribution[node.repoId] ?? 0) + 1;
310
+ languageDistribution[node.language] = (languageDistribution[node.language] ?? 0) + 1;
311
+ }
312
+
313
+ return {
314
+ schemaVersion: 1,
315
+ workspaceId: input.workspaceId,
316
+ graphGeneration: input.graphGeneration,
317
+ graphVersion: FILE_GRAPH_VERSION,
318
+ nodeCount: input.nodes.length,
319
+ edgeCount: input.edges.length,
320
+ orphanFileIds,
321
+ stronglyConnectedComponents: components,
322
+ cycles: cycleSearch.cycles,
323
+ importResolution: {
324
+ resolvedCount,
325
+ externalCount,
326
+ unresolvedCount,
327
+ rate: total === 0 ? 0 : resolvedCount / total,
328
+ },
329
+ repositoryDistribution: sortRecord(repositoryDistribution),
330
+ languageDistribution: sortRecord(languageDistribution),
331
+ topCentralFiles,
332
+ centralityFormula: CENTRALITY_FORMULA,
333
+ ...(cycleSearch.truncationReason === undefined
334
+ ? {}
335
+ : { truncationReason: cycleSearch.truncationReason }),
336
+ };
337
+ }
338
+
339
+ /**
340
+ * Kanonische Schluesselreihenfolge eines Zaehlerobjekts. Ohne sie haengt die
341
+ * Reihenfolge von der Einfuegereihenfolge ab — und damit das kanonische JSON
342
+ * und jeder daraus abgeleitete Hash.
343
+ */
344
+ function sortRecord(record: Record<string, number>): Record<string, number> {
345
+ const sorted: Record<string, number> = {};
346
+ for (const key of Object.keys(record).sort(compareCodepoints)) {
347
+ sorted[key] = record[key] as number;
348
+ }
349
+ return sorted;
350
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Der Paketkatalog des Workspace — nach Repository UND Name geschluesselt.
3
+ *
4
+ * WARUM NICHT NACH NAMEN ALLEIN: Ein Workspace darf aus mehreren Repositories
5
+ * bestehen (genau das ist der Cross-Repo-Fall, den dieses Paket koennen soll).
6
+ * Paketnamen sind dann NICHT eindeutig: zwei Repositories duerfen beide ein
7
+ * `shared-lib` enthalten, und in einem realen Bestand tun sie es auch — ein
8
+ * abgespaltener Fork, eine geteilte Bibliothek in zwei Staenden, ein
9
+ * gleichnamiges internes Hilfspaket. Ein `Map<name, package>` verliert in
10
+ * diesem Moment das frueher eingefuegte Paket ERSATZLOS: seine
11
+ * Abhaengigkeiten werden aus dem fremden Manifest gefuellt, seine Dateien
12
+ * werden nie mehr ueber den Katalog gefunden, und ein Import auf den Namen
13
+ * loest ins falsche Repository auf — gezaehlt als Erfolg.
14
+ *
15
+ * Der Katalog haelt deshalb ALLE gleichnamigen Pakete und beantwortet jede
16
+ * Frage aus der Sicht eines fragenden Repositories. Mehrdeutigkeit wird
17
+ * gemeldet, nicht aufgeloest (E4).
18
+ */
19
+ import { compareCodepoints } from "@untangle-code/domain";
20
+
21
+ import type { PackageLookupV1, WorkspacePackageCatalogV1, WorkspacePackageV1 } from "./types.js";
22
+
23
+ /** Stabile Ordnung des Katalogs: Name, Repository, Verzeichnis. */
24
+ export function comparePackages(left: WorkspacePackageV1, right: WorkspacePackageV1): number {
25
+ return (
26
+ compareCodepoints(left.name, right.name) ||
27
+ compareCodepoints(left.repoId, right.repoId) ||
28
+ compareCodepoints(left.packageDir, right.packageDir) ||
29
+ compareCodepoints(left.manifestPath, right.manifestPath)
30
+ );
31
+ }
32
+
33
+ export class WorkspacePackageCatalog implements WorkspacePackageCatalogV1 {
34
+ private readonly entries: WorkspacePackageV1[];
35
+ private readonly byName = new Map<string, WorkspacePackageV1[]>();
36
+ private readonly byRepoAndName = new Map<string, WorkspacePackageV1[]>();
37
+
38
+ constructor(packages: readonly WorkspacePackageV1[]) {
39
+ this.entries = [...packages].sort(comparePackages);
40
+ for (const entry of this.entries) {
41
+ const bucket = this.byName.get(entry.name);
42
+ if (bucket === undefined) {
43
+ this.byName.set(entry.name, [entry]);
44
+ } else {
45
+ bucket.push(entry);
46
+ }
47
+ // Auch innerhalb EINES Repositories ist der Name nicht garantiert
48
+ // eindeutig: zwei Verzeichnisse duerfen dasselbe `name`-Feld tragen
49
+ // (ein kopiertes Paket, ein halb vollzogener Umzug). Das ist ein Fehler
50
+ // des Bestandes — aber ihn zu glaetten, indem der erste in kanonischer
51
+ // Ordnung gewinnt, ist GERATEN: der Bereich im Manifest des
52
+ // Importierenden kann sehr wohl auf das zweite zeigen. Der Katalog
53
+ // haelt deshalb auch hier ALLE Eintraege und laesst `lookup` melden
54
+ // statt entscheiden (E4).
55
+ const key = keyOf(entry.repoId, entry.name);
56
+ const owned = this.byRepoAndName.get(key);
57
+ if (owned === undefined) {
58
+ this.byRepoAndName.set(key, [entry]);
59
+ } else {
60
+ owned.push(entry);
61
+ }
62
+ }
63
+ }
64
+
65
+ all(): readonly WorkspacePackageV1[] {
66
+ return this.entries;
67
+ }
68
+
69
+ byPackageName(name: string): readonly WorkspacePackageV1[] {
70
+ return this.byName.get(name) ?? [];
71
+ }
72
+
73
+ /**
74
+ * Das Paket dieses Namens in diesem Repository — nur wenn es GENAU EINES
75
+ * gibt. Bei mehreren gleichnamigen Manifesten desselben Repositories ist
76
+ * `null` die einzige ehrliche Antwort; welches gemeint ist, steht nirgends,
77
+ * und `lookup` meldet den Fall als `ambiguous`.
78
+ */
79
+ inRepo(repoId: string, name: string): WorkspacePackageV1 | null {
80
+ const owned = this.byRepoAndName.get(keyOf(repoId, name)) ?? [];
81
+ return owned.length === 1 ? (owned[0] as WorkspacePackageV1) : null;
82
+ }
83
+
84
+ /** Alle Pakete dieses Namens in diesem Repository, kanonisch geordnet. */
85
+ inRepoAll(repoId: string, name: string): readonly WorkspacePackageV1[] {
86
+ return this.byRepoAndName.get(keyOf(repoId, name)) ?? [];
87
+ }
88
+
89
+ has(name: string): boolean {
90
+ return this.byName.has(name);
91
+ }
92
+
93
+ /**
94
+ * Sucht `name` aus der Sicht von `fromRepoId`.
95
+ *
96
+ * REIHENFOLGE UND IHR GRUND: Das eigene Repository zuerst — ein Manifest, das
97
+ * `shared-lib` deklariert, meint zuerst das `shared-lib`, das neben ihm
98
+ * liegt. Gibt es dort keines und genau EIN fremdes, ist der Cross-Repo-Bezug
99
+ * die einzige moegliche Lesart und damit ein Treffer. Gibt es mehrere
100
+ * fremde, ist der Bezug ohne weitere Angabe nicht entscheidbar — und dann
101
+ * wird geraten oder gemeldet. Geraten wird nicht.
102
+ */
103
+ lookup(fromRepoId: string, name: string): PackageLookupV1 {
104
+ const own = this.inRepoAll(fromRepoId, name);
105
+ if (own.length === 1) {
106
+ return { status: "found", entry: own[0] as WorkspacePackageV1, crossRepo: false };
107
+ }
108
+ // Mehrere gleichnamige Pakete im EIGENEN Repository: nicht auf das erste
109
+ // ausweichen und erst recht nicht ins fremde Repository weitersuchen —
110
+ // beides waere geraten. Die Kandidaten sind die des eigenen Repositories.
111
+ if (own.length > 1) return { status: "ambiguous", candidates: own };
112
+ const candidates = this.byPackageName(name);
113
+ if (candidates.length === 0) return { status: "absent" };
114
+ if (candidates.length === 1) {
115
+ return { status: "found", entry: candidates[0] as WorkspacePackageV1, crossRepo: true };
116
+ }
117
+ return { status: "ambiguous", candidates };
118
+ }
119
+
120
+ /**
121
+ * Namen, die von MEHR ALS EINEM Manifest deklariert werden,
122
+ * codepoint-sortiert.
123
+ *
124
+ * Frueher wurde hier `repos.size > 1` gefragt — damit blieb die Doppelung
125
+ * INNERHALB eines Repositories unsichtbar, obwohl genau sie einen Import
126
+ * unentscheidbar macht. Massstab ist die Zahl der Manifeste, nicht die der
127
+ * Repositories.
128
+ */
129
+ ambiguousNames(): readonly string[] {
130
+ const names: string[] = [];
131
+ for (const [name, bucket] of this.byName) {
132
+ if (bucket.length > 1) names.push(name);
133
+ }
134
+ return names.sort(compareCodepoints);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * U+001F als Trenner: derselbe Trenner wie in `file-index.ts`. Er kommt weder
140
+ * in einer Repository-ID noch in einem Paketnamen vor, also kann der
141
+ * Schluessel von ("ab", "c") nie mit dem von ("a", "bc") kollidieren.
142
+ */
143
+ function keyOf(repoId: string, name: string): string {
144
+ return `${repoId}\u001f${name}`;
145
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Pfadhilfen des Dateigraphen — ausschliesslich POSIX, ausschliesslich
3
+ * repo-relativ.
4
+ *
5
+ * Warum eigene Helfer statt `node:path`: der Dateigraph rechnet nie auf
6
+ * Betriebssystempfaden. Er kennt nur `"/"`-normalisierte, repo-relative
7
+ * Pfade (spec.md §5.4), und `node:path` auf Windows wuerde aus `a/b`
8
+ * beim Zusammensetzen `a\b` machen — damit haette dieselbe Codebasis auf
9
+ * zwei Maschinen zwei verschiedene `file_id`s. `node:path/posix` deckt
10
+ * einen Teil ab, liefert aber `".."`-Ergebnisse ausserhalb der Wurzel
11
+ * still zurueck; hier wird das zu `null` und damit zu einem benannten
12
+ * Fehlschlag (E4).
13
+ */
14
+
15
+ /** Normalisiert Trennzeichen und entfernt fuehrende `"./"` sowie Mehrfachslashes. */
16
+ export function normalizePath(path: string): string {
17
+ const forward = path.replace(/\\/g, "/");
18
+ const collapsed = forward.replace(/\/{2,}/g, "/");
19
+ const withoutLeadingDot = collapsed.startsWith("./") ? collapsed.slice(2) : collapsed;
20
+ const withoutLeadingSlash = withoutLeadingDot.startsWith("/")
21
+ ? withoutLeadingDot.slice(1)
22
+ : withoutLeadingDot;
23
+ return withoutLeadingSlash.endsWith("/") && withoutLeadingSlash.length > 1
24
+ ? withoutLeadingSlash.slice(0, -1)
25
+ : withoutLeadingSlash;
26
+ }
27
+
28
+ /** Verzeichnisanteil eines repo-relativen Pfades; `""` fuer Dateien in der Wurzel. */
29
+ export function dirnameOf(path: string): string {
30
+ const normalized = normalizePath(path);
31
+ const index = normalized.lastIndexOf("/");
32
+ return index === -1 ? "" : normalized.slice(0, index);
33
+ }
34
+
35
+ /** Dateiname ohne Verzeichnisanteil. */
36
+ export function basenameOf(path: string): string {
37
+ const normalized = normalizePath(path);
38
+ const index = normalized.lastIndexOf("/");
39
+ return index === -1 ? normalized : normalized.slice(index + 1);
40
+ }
41
+
42
+ /** Erweiterung inklusive Punkt, kleingeschrieben; `""` bei Dotfiles und ohne Punkt. */
43
+ export function extensionOf(path: string): string {
44
+ const name = basenameOf(path);
45
+ const index = name.lastIndexOf(".");
46
+ if (index <= 0) {
47
+ return "";
48
+ }
49
+ return name.slice(index).toLowerCase();
50
+ }
51
+
52
+ /**
53
+ * Setzt einen Basispfad und ein Segment zusammen und loest `.`/`..` auf.
54
+ *
55
+ * Gibt `null` zurueck, wenn die Aufloesung ueber die Repositorywurzel
56
+ * hinauslaeuft. Ein Import wie `"../../../../etc/passwd"` aus `src/a.ts`
57
+ * ist damit ein benannter Fehlschlag und kein still auf `etc/passwd`
58
+ * gekuerzter Treffer.
59
+ */
60
+ export function joinRelative(baseDir: string, segment: string): string | null {
61
+ const base = normalizePath(baseDir);
62
+ const parts = base === "" ? [] : base.split("/");
63
+ const stack: string[] = [...parts];
64
+ for (const raw of normalizePath(segment).split("/")) {
65
+ if (raw === "" || raw === ".") {
66
+ continue;
67
+ }
68
+ if (raw === "..") {
69
+ if (stack.length === 0) {
70
+ return null;
71
+ }
72
+ stack.pop();
73
+ continue;
74
+ }
75
+ stack.push(raw);
76
+ }
77
+ return stack.join("/");
78
+ }
79
+
80
+ /** True, wenn `path` unterhalb von `directory` liegt (oder gleich ist). */
81
+ export function isWithin(directory: string, path: string): boolean {
82
+ const dir = normalizePath(directory);
83
+ const target = normalizePath(path);
84
+ if (dir === "" || dir === ".") {
85
+ return true;
86
+ }
87
+ return target === dir || target.startsWith(`${dir}/`);
88
+ }
89
+
90
+ /**
91
+ * Pfad von `path` relativ zu `directory`; `null`, wenn `path` nicht darunter
92
+ * liegt. Bewusst kein `..`-Ergebnis: der Aufrufer will hier wissen, ob etwas
93
+ * INNERHALB eines Verzeichnisses liegt, nicht wie man hinaufkommt.
94
+ */
95
+ export function relativeWithin(directory: string, path: string): string | null {
96
+ if (!isWithin(directory, path)) {
97
+ return null;
98
+ }
99
+ const dir = normalizePath(directory);
100
+ const target = normalizePath(path);
101
+ if (dir === "" || dir === ".") {
102
+ return target;
103
+ }
104
+ return target === dir ? "" : target.slice(dir.length + 1);
105
+ }
106
+
107
+ /** True fuer `"./x"`, `"../x"` und `"."`/`".."` selbst. */
108
+ export function isRelativeSpecifier(specifier: string): boolean {
109
+ return specifier === "." || specifier === ".." || /^\.\.?\//.test(specifier);
110
+ }
111
+
112
+ /**
113
+ * Normalisiert nur die Trennzeichen und laesst einen fuehrenden `"/"` stehen.
114
+ *
115
+ * `normalizePath` ist fuer REPO-RELATIVE Pfade gedacht und entfernt deshalb
116
+ * einen fuehrenden Schraegstrich. Auf einen absoluten Wurzelpfad angewandt
117
+ * wuerde daraus ein relativer — und jeder Lesezugriff ginge ins Leere. Fuer
118
+ * absolute Pfade (Repositorywurzeln, Eintraege des Dateisystemports) gilt
119
+ * diese Funktion.
120
+ */
121
+ export function normalizeSeparators(path: string): string {
122
+ const forward = path.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
123
+ return forward.length > 1 && forward.endsWith("/") ? forward.slice(0, -1) : forward;
124
+ }
125
+
126
+ /**
127
+ * Pfad von `path` relativ zur absoluten Wurzel `root`; `null`, wenn `path`
128
+ * nicht darunter liegt. Beide Angaben sind absolut.
129
+ */
130
+ export function relativeToRoot(root: string, path: string): string | null {
131
+ const normalizedRoot = normalizeSeparators(root);
132
+ const normalizedPath = normalizeSeparators(path);
133
+ if (normalizedPath === normalizedRoot) {
134
+ return "";
135
+ }
136
+ const prefix = normalizedRoot.endsWith("/") ? normalizedRoot : `${normalizedRoot}/`;
137
+ return normalizedPath.startsWith(prefix) ? normalizedPath.slice(prefix.length) : null;
138
+ }