@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.
- package/LICENSE +661 -0
- package/NOTICE +90 -0
- package/README.md +284 -2
- package/THIRD_PARTY_LICENSES.md +29 -0
- package/dist/assignment.d.ts +15 -0
- package/dist/assignment.d.ts.map +1 -0
- package/dist/assignment.js +75 -0
- package/dist/assignment.js.map +1 -0
- package/dist/boundaries/endpoints.d.ts +11 -0
- package/dist/boundaries/endpoints.d.ts.map +1 -0
- package/dist/boundaries/endpoints.js +195 -0
- package/dist/boundaries/endpoints.js.map +1 -0
- package/dist/boundaries/index.d.ts +9 -0
- package/dist/boundaries/index.d.ts.map +1 -0
- package/dist/boundaries/index.js +93 -0
- package/dist/boundaries/index.js.map +1 -0
- package/dist/boundaries/requests.d.ts +12 -0
- package/dist/boundaries/requests.d.ts.map +1 -0
- package/dist/boundaries/requests.js +311 -0
- package/dist/boundaries/requests.js.map +1 -0
- package/dist/edges.d.ts +49 -0
- package/dist/edges.d.ts.map +1 -0
- package/dist/edges.js +223 -0
- package/dist/edges.js.map +1 -0
- package/dist/file-index.d.ts +37 -0
- package/dist/file-index.d.ts.map +1 -0
- package/dist/file-index.js +103 -0
- package/dist/file-index.js.map +1 -0
- package/dist/graph.d.ts +160 -0
- package/dist/graph.d.ts.map +1 -0
- package/dist/graph.js +448 -0
- package/dist/graph.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics.d.ts +67 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +272 -0
- package/dist/metrics.js.map +1 -0
- package/dist/package-catalog.d.ts +43 -0
- package/dist/package-catalog.d.ts.map +1 -0
- package/dist/package-catalog.js +136 -0
- package/dist/package-catalog.js.map +1 -0
- package/dist/paths.d.ts +56 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +129 -0
- package/dist/paths.js.map +1 -0
- package/dist/persistence.d.ts +83 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +538 -0
- package/dist/persistence.js.map +1 -0
- package/dist/resolve/candidates.d.ts +52 -0
- package/dist/resolve/candidates.d.ts.map +1 -0
- package/dist/resolve/candidates.js +202 -0
- package/dist/resolve/candidates.js.map +1 -0
- package/dist/resolve/package-exports.d.ts +31 -0
- package/dist/resolve/package-exports.d.ts.map +1 -0
- package/dist/resolve/package-exports.js +229 -0
- package/dist/resolve/package-exports.js.map +1 -0
- package/dist/resolve/pipeline.d.ts +11 -0
- package/dist/resolve/pipeline.d.ts.map +1 -0
- package/dist/resolve/pipeline.js +109 -0
- package/dist/resolve/pipeline.js.map +1 -0
- package/dist/resolve/resolvers.d.ts +25 -0
- package/dist/resolve/resolvers.d.ts.map +1 -0
- package/dist/resolve/resolvers.js +367 -0
- package/dist/resolve/resolvers.js.map +1 -0
- package/dist/restore.d.ts +75 -0
- package/dist/restore.d.ts.map +1 -0
- package/dist/restore.js +136 -0
- package/dist/restore.js.map +1 -0
- package/dist/topology/compose.d.ts +27 -0
- package/dist/topology/compose.d.ts.map +1 -0
- package/dist/topology/compose.js +208 -0
- package/dist/topology/compose.js.map +1 -0
- package/dist/topology/jsonc.d.ts +38 -0
- package/dist/topology/jsonc.d.ts.map +1 -0
- package/dist/topology/jsonc.js +137 -0
- package/dist/topology/jsonc.js.map +1 -0
- package/dist/topology/load.d.ts +61 -0
- package/dist/topology/load.d.ts.map +1 -0
- package/dist/topology/load.js +436 -0
- package/dist/topology/load.js.map +1 -0
- package/dist/topology/manifest.d.ts +48 -0
- package/dist/topology/manifest.d.ts.map +1 -0
- package/dist/topology/manifest.js +109 -0
- package/dist/topology/manifest.js.map +1 -0
- package/dist/topology/project-config.d.ts +39 -0
- package/dist/topology/project-config.d.ts.map +1 -0
- package/dist/topology/project-config.js +150 -0
- package/dist/topology/project-config.js.map +1 -0
- package/dist/topology/workspace-definition.d.ts +26 -0
- package/dist/topology/workspace-definition.d.ts.map +1 -0
- package/dist/topology/workspace-definition.js +104 -0
- package/dist/topology/workspace-definition.js.map +1 -0
- package/dist/traverse.d.ts +49 -0
- package/dist/traverse.d.ts.map +1 -0
- package/dist/traverse.js +167 -0
- package/dist/traverse.js.map +1 -0
- package/dist/types.d.ts +332 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +63 -3
- package/src/assignment.ts +83 -0
- package/src/boundaries/endpoints.ts +273 -0
- package/src/boundaries/index.ts +118 -0
- package/src/boundaries/requests.ts +411 -0
- package/src/edges.ts +334 -0
- package/src/file-index.ts +127 -0
- package/src/graph.ts +591 -0
- package/src/index.ts +58 -0
- package/src/metrics.ts +350 -0
- package/src/package-catalog.ts +145 -0
- package/src/paths.ts +138 -0
- package/src/persistence.ts +746 -0
- package/src/resolve/candidates.ts +230 -0
- package/src/resolve/package-exports.ts +268 -0
- package/src/resolve/pipeline.ts +140 -0
- package/src/resolve/resolvers.ts +468 -0
- package/src/restore.ts +201 -0
- package/src/topology/compose.ts +268 -0
- package/src/topology/jsonc.ts +147 -0
- package/src/topology/load.ts +644 -0
- package/src/topology/manifest.ts +132 -0
- package/src/topology/project-config.ts +183 -0
- package/src/topology/workspace-definition.ts +115 -0
- package/src/traverse.ts +232 -0
- package/src/types.ts +372 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aufbau der Monorepo-Topologie aus echten Dateien (architecture.md §16.1
|
|
3
|
+
* Schritt 1 und §16.3, Nutzerwunsch A3).
|
|
4
|
+
*
|
|
5
|
+
* Dies ist die EINZIGE Stelle des Pakets, die ueberhaupt liest — und auch
|
|
6
|
+
* hier nur ueber `FileSystemPort`, nie ueber `node:fs`. Alles danach
|
|
7
|
+
* (Aufloesung, Graphbau, Traversierung, Fortschreibung) ist rein und
|
|
8
|
+
* arbeitet auf dem Ergebnis.
|
|
9
|
+
*
|
|
10
|
+
* Gelesen werden genau vier Arten von Dateien:
|
|
11
|
+
* - `pnpm-workspace.yaml` — die Liste der Teilprojekte;
|
|
12
|
+
* - `package.json` — Paketname, `exports`, Abhaengigkeiten, `workspaces`;
|
|
13
|
+
* - `tsconfig*.json` — Pfad-Aliase und die Abbildung Quelle ↔ Build-Ausgabe;
|
|
14
|
+
* - Container-Kompositionsdateien — die Dienstebene.
|
|
15
|
+
*
|
|
16
|
+
* Jede Datei, die zwar gefunden, aber nicht gelesen werden konnte, landet mit
|
|
17
|
+
* Grund im Skip-Ledger (E4). Ein unlesbares Manifest darf nie dazu fuehren,
|
|
18
|
+
* dass ein ganzes Teilprojekt lautlos aus der Topologie verschwindet.
|
|
19
|
+
*/
|
|
20
|
+
import type { FileSystemPort, SkipLedgerEntryV1, SkipReason } from "@untangle-code/domain";
|
|
21
|
+
import {
|
|
22
|
+
buildSkipLedgerEntry,
|
|
23
|
+
compareCodepoints,
|
|
24
|
+
summarizeSkipReasons,
|
|
25
|
+
} from "@untangle-code/domain";
|
|
26
|
+
|
|
27
|
+
import { WorkspacePackageCatalog, comparePackages } from "../package-catalog.js";
|
|
28
|
+
import { dirnameOf, normalizePath, normalizeSeparators, relativeToRoot } from "../paths.js";
|
|
29
|
+
import type {
|
|
30
|
+
AliasRuleV1,
|
|
31
|
+
ContainerServiceEdgeV1,
|
|
32
|
+
ContainerServiceV1,
|
|
33
|
+
RepositoryInputV1,
|
|
34
|
+
WorkspacePackageEdgeV1,
|
|
35
|
+
WorkspacePackageV1,
|
|
36
|
+
WorkspaceTopologyV1,
|
|
37
|
+
} from "../types.js";
|
|
38
|
+
|
|
39
|
+
import { COMPOSE_FILE_NAMES, parseComposeFile } from "./compose.js";
|
|
40
|
+
import type { ManifestV1 } from "./manifest.js";
|
|
41
|
+
import { DEPENDENCY_FIELDS, isWorkspaceProtocol, parseManifest } from "./manifest.js";
|
|
42
|
+
import type { ProjectConfigV1 } from "./project-config.js";
|
|
43
|
+
import { resolveProjectConfig } from "./project-config.js";
|
|
44
|
+
import { matchesGlobSelection, parsePnpmWorkspace, splitGlobs } from "./workspace-definition.js";
|
|
45
|
+
|
|
46
|
+
/** Verzeichnisnamen, die der Topologielauf nie betritt. */
|
|
47
|
+
export const DEFAULT_IGNORED_DIRECTORIES = [
|
|
48
|
+
".git",
|
|
49
|
+
".hg",
|
|
50
|
+
".svn",
|
|
51
|
+
".turbo",
|
|
52
|
+
"__pycache__",
|
|
53
|
+
"build",
|
|
54
|
+
"coverage",
|
|
55
|
+
"dist",
|
|
56
|
+
"node_modules",
|
|
57
|
+
"out",
|
|
58
|
+
"target",
|
|
59
|
+
"vendor",
|
|
60
|
+
] as const;
|
|
61
|
+
|
|
62
|
+
export interface LoadTopologyOptionsV1 {
|
|
63
|
+
workspaceId: string;
|
|
64
|
+
/** Generation, unter der das Skip-Ledger gefuehrt wird. */
|
|
65
|
+
generationId: string;
|
|
66
|
+
repositories: readonly RepositoryInputV1[];
|
|
67
|
+
/** Ueberschreibt `DEFAULT_IGNORED_DIRECTORIES`, wenn gesetzt. */
|
|
68
|
+
ignoredDirectories?: readonly string[];
|
|
69
|
+
/** Obergrenze der Verzeichnistiefe je Repository. */
|
|
70
|
+
maxDepth?: number;
|
|
71
|
+
/** Obergrenze besuchter Verzeichnisse je Repository (Budget, kein Ratschlag). */
|
|
72
|
+
maxDirectories?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Eine gelesene Projektkonfiguration samt ihrem Repository. */
|
|
76
|
+
export interface ProjectConfigEntryV1 {
|
|
77
|
+
repoId: string;
|
|
78
|
+
config: ProjectConfigV1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Ergebnis inklusive der gelesenen Projektkonfigurationen. */
|
|
82
|
+
export interface LoadedTopologyV1 {
|
|
83
|
+
topology: WorkspaceTopologyV1;
|
|
84
|
+
/** `configKey(repoId, pfad)` → ausgewertete Konfiguration. */
|
|
85
|
+
projectConfigs: Map<string, ProjectConfigEntryV1>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface FoundFile {
|
|
89
|
+
repoId: string;
|
|
90
|
+
/** repo-relativer Pfad. */
|
|
91
|
+
path: string;
|
|
92
|
+
absolutePath: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Baut die Topologie eines Workspace aus den Dateien seiner Repositories. */
|
|
96
|
+
export async function loadWorkspaceTopology(
|
|
97
|
+
fs: FileSystemPort,
|
|
98
|
+
options: LoadTopologyOptionsV1,
|
|
99
|
+
): Promise<LoadedTopologyV1> {
|
|
100
|
+
const ignored = new Set(options.ignoredDirectories ?? DEFAULT_IGNORED_DIRECTORIES);
|
|
101
|
+
const maxDepth = options.maxDepth ?? 12;
|
|
102
|
+
const maxDirectories = options.maxDirectories ?? 20_000;
|
|
103
|
+
const skipped: SkipLedgerEntryV1[] = [];
|
|
104
|
+
|
|
105
|
+
const manifestFiles: FoundFile[] = [];
|
|
106
|
+
const configFiles: FoundFile[] = [];
|
|
107
|
+
const composeFiles: FoundFile[] = [];
|
|
108
|
+
const workspaceFiles: FoundFile[] = [];
|
|
109
|
+
|
|
110
|
+
for (const repository of options.repositories) {
|
|
111
|
+
const root = normalizeSeparators(repository.rootPath);
|
|
112
|
+
let visited = 0;
|
|
113
|
+
let budgetReported = false;
|
|
114
|
+
const queue: { absolutePath: string; depth: number }[] = [{ absolutePath: root, depth: 0 }];
|
|
115
|
+
while (queue.length > 0) {
|
|
116
|
+
const current = queue.shift() as { absolutePath: string; depth: number };
|
|
117
|
+
if (visited >= maxDirectories) {
|
|
118
|
+
if (!budgetReported) {
|
|
119
|
+
budgetReported = true;
|
|
120
|
+
skipped.push(
|
|
121
|
+
buildSkipLedgerEntry({
|
|
122
|
+
stage: "file_graph",
|
|
123
|
+
subjectKind: "file",
|
|
124
|
+
path: relativeToRoot(root, current.absolutePath) ?? current.absolutePath,
|
|
125
|
+
repoId: repository.repoId,
|
|
126
|
+
refId: repository.refId,
|
|
127
|
+
reason: "time_budget_exceeded",
|
|
128
|
+
detail: `topology walk stopped after ${String(maxDirectories)} directories in repository ${repository.repoId}; raise maxDirectories to see the rest`,
|
|
129
|
+
recoverable: true,
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
visited += 1;
|
|
136
|
+
|
|
137
|
+
let entries;
|
|
138
|
+
try {
|
|
139
|
+
entries = await fs.readDir(current.absolutePath);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
skipped.push(
|
|
142
|
+
buildSkipLedgerEntry({
|
|
143
|
+
stage: "file_graph",
|
|
144
|
+
subjectKind: "file",
|
|
145
|
+
path: relativeToRoot(root, current.absolutePath) ?? current.absolutePath,
|
|
146
|
+
repoId: repository.repoId,
|
|
147
|
+
refId: repository.refId,
|
|
148
|
+
reason: "resolution_failed",
|
|
149
|
+
detail: `directory could not be read: ${messageOf(error)}`,
|
|
150
|
+
recoverable: true,
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const entry of [...entries].sort((left, right) =>
|
|
157
|
+
compareCodepoints(left.name, right.name),
|
|
158
|
+
)) {
|
|
159
|
+
const absolutePath = normalizeSeparators(entry.path);
|
|
160
|
+
const relative = relativeToRoot(root, absolutePath);
|
|
161
|
+
if (relative === null) {
|
|
162
|
+
// Ein Eintrag ausserhalb der Repositorywurzel kann nur ueber einen
|
|
163
|
+
// Symlink entstehen; er wird gemeldet, nicht verfolgt.
|
|
164
|
+
skipped.push(
|
|
165
|
+
buildSkipLedgerEntry({
|
|
166
|
+
stage: "file_graph",
|
|
167
|
+
subjectKind: "file",
|
|
168
|
+
path: absolutePath,
|
|
169
|
+
repoId: repository.repoId,
|
|
170
|
+
refId: repository.refId,
|
|
171
|
+
reason: "excluded_by_configuration",
|
|
172
|
+
detail: `entry lies outside the repository root ${root} and was not followed`,
|
|
173
|
+
recoverable: false,
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (entry.kind === "directory") {
|
|
179
|
+
if (ignored.has(entry.name)) continue;
|
|
180
|
+
if (current.depth + 1 > maxDepth) {
|
|
181
|
+
skipped.push(
|
|
182
|
+
buildSkipLedgerEntry({
|
|
183
|
+
stage: "file_graph",
|
|
184
|
+
subjectKind: "file",
|
|
185
|
+
path: relative,
|
|
186
|
+
repoId: repository.repoId,
|
|
187
|
+
refId: repository.refId,
|
|
188
|
+
reason: "excluded_by_configuration",
|
|
189
|
+
detail: `directory depth ${String(current.depth + 1)} exceeds maxDepth ${String(maxDepth)}`,
|
|
190
|
+
recoverable: true,
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
queue.push({ absolutePath, depth: current.depth + 1 });
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (entry.kind !== "file") continue;
|
|
199
|
+
|
|
200
|
+
const found: FoundFile = { repoId: repository.repoId, path: relative, absolutePath };
|
|
201
|
+
const name = entry.name;
|
|
202
|
+
if (name === "package.json") {
|
|
203
|
+
manifestFiles.push(found);
|
|
204
|
+
} else if (/^tsconfig(\..+)?\.json$/i.test(name) || /^jsconfig(\..+)?\.json$/i.test(name)) {
|
|
205
|
+
configFiles.push(found);
|
|
206
|
+
} else if ((COMPOSE_FILE_NAMES as readonly string[]).includes(name)) {
|
|
207
|
+
composeFiles.push(found);
|
|
208
|
+
} else if (name === "pnpm-workspace.yaml" || name === "pnpm-workspace.yml") {
|
|
209
|
+
workspaceFiles.push(found);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const repositoriesById = new Map(
|
|
216
|
+
options.repositories.map((repository) => [repository.repoId, repository]),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// ---- Projektkonfigurationen ------------------------------------------
|
|
220
|
+
const configTexts = new Map<string, string>();
|
|
221
|
+
for (const file of configFiles) {
|
|
222
|
+
try {
|
|
223
|
+
configTexts.set(configKey(file.repoId, file.path), await fs.readTextFile(file.absolutePath));
|
|
224
|
+
} catch (error) {
|
|
225
|
+
skipped.push(
|
|
226
|
+
ledgerEntry(
|
|
227
|
+
repositoriesById,
|
|
228
|
+
file,
|
|
229
|
+
"resolution_failed",
|
|
230
|
+
`project configuration unreadable: ${messageOf(error)}`,
|
|
231
|
+
true,
|
|
232
|
+
),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const projectConfigs = new Map<string, ProjectConfigEntryV1>();
|
|
237
|
+
for (const file of configFiles) {
|
|
238
|
+
const result = resolveProjectConfig(file.path, (path) => {
|
|
239
|
+
const text = configTexts.get(configKey(file.repoId, path));
|
|
240
|
+
if (text !== undefined) return text;
|
|
241
|
+
// `extends`-Ziele koennen ausserhalb des Suchbereichs liegen (z. B. in
|
|
242
|
+
// node_modules). Sie werden nicht nachgeladen — das meldet
|
|
243
|
+
// `resolveProjectConfig` als Warnung, die hier ins Ledger geht.
|
|
244
|
+
return null;
|
|
245
|
+
});
|
|
246
|
+
if (!result.ok) {
|
|
247
|
+
skipped.push(
|
|
248
|
+
ledgerEntry(
|
|
249
|
+
repositoriesById,
|
|
250
|
+
file,
|
|
251
|
+
"resolution_failed",
|
|
252
|
+
`project configuration could not be evaluated: ${result.error}`,
|
|
253
|
+
false,
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
for (const warning of result.warnings) {
|
|
259
|
+
skipped.push(
|
|
260
|
+
ledgerEntry(
|
|
261
|
+
repositoriesById,
|
|
262
|
+
file,
|
|
263
|
+
"resolution_failed",
|
|
264
|
+
`project configuration partially evaluated: ${warning}`,
|
|
265
|
+
false,
|
|
266
|
+
),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
projectConfigs.set(configKey(file.repoId, file.path), {
|
|
270
|
+
repoId: file.repoId,
|
|
271
|
+
config: result.config,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---- Workspace-Globs --------------------------------------------------
|
|
276
|
+
const globsByRepo = new Map<string, string[]>();
|
|
277
|
+
for (const file of workspaceFiles) {
|
|
278
|
+
try {
|
|
279
|
+
const text = await fs.readTextFile(file.absolutePath);
|
|
280
|
+
const parsed = parsePnpmWorkspace(text);
|
|
281
|
+
if (!parsed.ok) {
|
|
282
|
+
skipped.push(
|
|
283
|
+
ledgerEntry(
|
|
284
|
+
repositoriesById,
|
|
285
|
+
file,
|
|
286
|
+
"resolution_failed",
|
|
287
|
+
`workspace definition unreadable: ${parsed.error}`,
|
|
288
|
+
false,
|
|
289
|
+
),
|
|
290
|
+
);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const existing = globsByRepo.get(file.repoId) ?? [];
|
|
294
|
+
globsByRepo.set(file.repoId, [...existing, ...parsed.globs]);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
skipped.push(
|
|
297
|
+
ledgerEntry(
|
|
298
|
+
repositoriesById,
|
|
299
|
+
file,
|
|
300
|
+
"resolution_failed",
|
|
301
|
+
`workspace definition unreadable: ${messageOf(error)}`,
|
|
302
|
+
true,
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---- Manifeste --------------------------------------------------------
|
|
309
|
+
const manifests: { file: FoundFile; manifest: ManifestV1 }[] = [];
|
|
310
|
+
for (const file of manifestFiles) {
|
|
311
|
+
let text: string;
|
|
312
|
+
try {
|
|
313
|
+
text = await fs.readTextFile(file.absolutePath);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
skipped.push(
|
|
316
|
+
ledgerEntry(
|
|
317
|
+
repositoriesById,
|
|
318
|
+
file,
|
|
319
|
+
"resolution_failed",
|
|
320
|
+
`manifest unreadable: ${messageOf(error)}`,
|
|
321
|
+
true,
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const parsed = parseManifest(text);
|
|
327
|
+
if (!parsed.ok) {
|
|
328
|
+
skipped.push(
|
|
329
|
+
ledgerEntry(
|
|
330
|
+
repositoriesById,
|
|
331
|
+
file,
|
|
332
|
+
"parse_failed",
|
|
333
|
+
`manifest could not be parsed: ${parsed.error}`,
|
|
334
|
+
false,
|
|
335
|
+
),
|
|
336
|
+
);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
manifests.push({ file, manifest: parsed.manifest });
|
|
340
|
+
if (parsed.manifest.workspaceGlobs.length > 0 && dirnameOf(file.path) === "") {
|
|
341
|
+
const existing = globsByRepo.get(file.repoId) ?? [];
|
|
342
|
+
globsByRepo.set(file.repoId, [...existing, ...parsed.manifest.workspaceGlobs]);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---- Pakete -----------------------------------------------------------
|
|
347
|
+
const packages: WorkspacePackageV1[] = [];
|
|
348
|
+
const manifestByPackage = new Map<string, ManifestV1>();
|
|
349
|
+
for (const { file, manifest } of manifests) {
|
|
350
|
+
const packageDir = dirnameOf(file.path);
|
|
351
|
+
const globs = globsByRepo.get(file.repoId) ?? [];
|
|
352
|
+
const selection = splitGlobs(globs);
|
|
353
|
+
const isRoot = packageDir === "";
|
|
354
|
+
if (!isRoot && globs.length > 0 && !matchesGlobSelection(selection, packageDir)) {
|
|
355
|
+
skipped.push(
|
|
356
|
+
ledgerEntry(
|
|
357
|
+
repositoriesById,
|
|
358
|
+
file,
|
|
359
|
+
"excluded_by_configuration",
|
|
360
|
+
`manifest directory "${packageDir}" matches no workspace glob (${globs.join(", ")})`,
|
|
361
|
+
false,
|
|
362
|
+
),
|
|
363
|
+
);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (manifest.name === undefined) {
|
|
367
|
+
skipped.push(
|
|
368
|
+
ledgerEntry(
|
|
369
|
+
repositoriesById,
|
|
370
|
+
file,
|
|
371
|
+
"resolution_failed",
|
|
372
|
+
'manifest has no "name" and can therefore not enter the package catalog',
|
|
373
|
+
false,
|
|
374
|
+
),
|
|
375
|
+
);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
const entry: WorkspacePackageV1 = {
|
|
379
|
+
name: manifest.name,
|
|
380
|
+
repoId: file.repoId,
|
|
381
|
+
packageDir,
|
|
382
|
+
manifestPath: file.path,
|
|
383
|
+
workspaceDependencies: [],
|
|
384
|
+
externalDependencies: {},
|
|
385
|
+
};
|
|
386
|
+
if (manifest.version !== undefined) entry.version = manifest.version;
|
|
387
|
+
if (manifest.main !== undefined) entry.main = manifest.main;
|
|
388
|
+
if (manifest.module !== undefined) entry.module = manifest.module;
|
|
389
|
+
if (manifest.types !== undefined) entry.types = manifest.types;
|
|
390
|
+
if (manifest.exports !== undefined) entry.exports = manifest.exports;
|
|
391
|
+
if (manifest.imports !== undefined) entry.imports = manifest.imports;
|
|
392
|
+
|
|
393
|
+
const configPath = packageDir === "" ? "tsconfig.json" : `${packageDir}/tsconfig.json`;
|
|
394
|
+
const config = projectConfigs.get(configKey(file.repoId, configPath))?.config;
|
|
395
|
+
if (config?.outDir !== undefined) entry.outDir = config.outDir;
|
|
396
|
+
if (config?.rootDir !== undefined) entry.rootDir = config.rootDir;
|
|
397
|
+
|
|
398
|
+
packages.push(entry);
|
|
399
|
+
// Das Manifest wird ueber die IDENTITAET des Pakets zurueckgehalten
|
|
400
|
+
// (Repository plus Manifestpfad), nicht ueber seinen Namen: gleichnamige
|
|
401
|
+
// Pakete zweier Repositories wuerden sonst einander verdecken, und ein
|
|
402
|
+
// Paket bekaeme die Abhaengigkeiten eines fremden.
|
|
403
|
+
manifestByPackage.set(packageKey(entry), manifest);
|
|
404
|
+
}
|
|
405
|
+
// Kanonische Ordnung: der Name allein ist bei gleichnamigen Paketen keine
|
|
406
|
+
// eindeutige Sortierung und damit kein Determinismus.
|
|
407
|
+
packages.sort(comparePackages);
|
|
408
|
+
|
|
409
|
+
const catalog = new WorkspacePackageCatalog(packages);
|
|
410
|
+
for (const name of catalog.ambiguousNames()) {
|
|
411
|
+
const owners = catalog.byPackageName(name);
|
|
412
|
+
// Der Massstab ist die Zahl der MANIFESTE, nicht die der Repositories:
|
|
413
|
+
// zwei gleichnamige Manifeste EINES Repositories machen jeden Import auf
|
|
414
|
+
// den Namen genauso unentscheidbar wie zwei Repositories — und dieser
|
|
415
|
+
// Fall blieb frueher ungemeldet, weil nur `repos.size > 1` gefragt wurde.
|
|
416
|
+
const repos = new Set(owners.map((owner) => owner.repoId));
|
|
417
|
+
const scope =
|
|
418
|
+
repos.size === 1
|
|
419
|
+
? `within the single repository "${[...repos][0] as string}"`
|
|
420
|
+
: owners.length > repos.size
|
|
421
|
+
? "in different repositories, and more than once within at least one of them"
|
|
422
|
+
: "in different repositories";
|
|
423
|
+
skipped.push(
|
|
424
|
+
buildSkipLedgerEntry({
|
|
425
|
+
stage: "file_graph",
|
|
426
|
+
subjectKind: "file",
|
|
427
|
+
reason: "ambiguous_resolution",
|
|
428
|
+
detail: `package name "${name}" is declared by ${String(owners.length)} manifests ${scope} (${owners
|
|
429
|
+
.map((owner) => `${owner.repoId}:${owner.manifestPath}`)
|
|
430
|
+
.join(
|
|
431
|
+
", ",
|
|
432
|
+
)}); a reference to it is attributed to the declaring repository first and reported as ambiguous whenever it cannot be attributed to exactly one manifest`,
|
|
433
|
+
recoverable: false,
|
|
434
|
+
}),
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---- Paketkanten ------------------------------------------------------
|
|
439
|
+
const packageEdges: WorkspacePackageEdgeV1[] = [];
|
|
440
|
+
for (const entry of packages) {
|
|
441
|
+
const manifest = manifestByPackage.get(packageKey(entry));
|
|
442
|
+
if (manifest === undefined) continue;
|
|
443
|
+
const workspaceDependencies = new Set<string>();
|
|
444
|
+
const externalDependencies: Record<string, string> = {};
|
|
445
|
+
for (const field of DEPENDENCY_FIELDS) {
|
|
446
|
+
for (const [name, range] of Object.entries(manifest.dependencies[field])) {
|
|
447
|
+
// Aus der Sicht des DEKLARIERENDEN Repositories nachschlagen: das
|
|
448
|
+
// eigene `shared-lib` ist gemeint, nicht das gleichnamige nebenan.
|
|
449
|
+
const lookup = catalog.lookup(entry.repoId, name);
|
|
450
|
+
const target = lookup.status === "found" ? lookup.entry : null;
|
|
451
|
+
// Ein Paketname, der im Katalog steht, IST eine Workspace-Kante —
|
|
452
|
+
// auch ohne `workspace:`-Praefix. pnpm loest `"^1.0.0"` auf ein
|
|
453
|
+
// lokales Paket auf, wenn dessen Version passt; wer nur auf das
|
|
454
|
+
// Protokoll schaut, verliert genau diese Kanten.
|
|
455
|
+
if (target !== null && packageKey(target) !== packageKey(entry)) {
|
|
456
|
+
workspaceDependencies.add(name);
|
|
457
|
+
packageEdges.push({
|
|
458
|
+
fromPackage: entry.name,
|
|
459
|
+
toPackage: name,
|
|
460
|
+
kind: "workspace-dependency",
|
|
461
|
+
dependencyField: field,
|
|
462
|
+
rawRange: range,
|
|
463
|
+
});
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (lookup.status === "ambiguous") {
|
|
467
|
+
// Der Name steht im Workspace, aber nicht auf genau einem Manifest:
|
|
468
|
+
// entweder in mehreren fremden Repositories oder mehrfach im
|
|
469
|
+
// eigenen. Weder Workspace-Kante noch externe Abhaengigkeit waere
|
|
470
|
+
// belegbar — also wird der Fall benannt, nicht geraten.
|
|
471
|
+
skipped.push(
|
|
472
|
+
buildSkipLedgerEntry({
|
|
473
|
+
stage: "file_graph",
|
|
474
|
+
subjectKind: "file",
|
|
475
|
+
path: entry.manifestPath,
|
|
476
|
+
repoId: entry.repoId,
|
|
477
|
+
...(repositoriesById.get(entry.repoId) === undefined
|
|
478
|
+
? {}
|
|
479
|
+
: { refId: (repositoriesById.get(entry.repoId) as RepositoryInputV1).refId }),
|
|
480
|
+
reason: "ambiguous_resolution",
|
|
481
|
+
detail: `package "${entry.name}" declares "${name}" in ${field}, but "${name}" is declared by ${String(lookup.candidates.length)} manifests [${lookup.candidates
|
|
482
|
+
.map((candidate) => `${candidate.repoId}:${candidate.manifestPath}`)
|
|
483
|
+
.join(
|
|
484
|
+
", ",
|
|
485
|
+
)}] and none of them is the single candidate for repository "${entry.repoId}"; no package edge is recorded`,
|
|
486
|
+
recoverable: false,
|
|
487
|
+
}),
|
|
488
|
+
);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (!isWorkspaceProtocol(range)) {
|
|
492
|
+
externalDependencies[name] = range;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
entry.workspaceDependencies = [...workspaceDependencies].sort(compareCodepoints);
|
|
497
|
+
entry.externalDependencies = externalDependencies;
|
|
498
|
+
}
|
|
499
|
+
packageEdges.sort(
|
|
500
|
+
(left, right) =>
|
|
501
|
+
compareCodepoints(left.fromPackage, right.fromPackage) ||
|
|
502
|
+
compareCodepoints(left.toPackage, right.toPackage) ||
|
|
503
|
+
compareCodepoints(left.dependencyField, right.dependencyField),
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
// ---- Dienste ----------------------------------------------------------
|
|
507
|
+
const services: ContainerServiceV1[] = [];
|
|
508
|
+
const serviceEdges: ContainerServiceEdgeV1[] = [];
|
|
509
|
+
for (const file of composeFiles) {
|
|
510
|
+
let text: string;
|
|
511
|
+
try {
|
|
512
|
+
text = await fs.readTextFile(file.absolutePath);
|
|
513
|
+
} catch (error) {
|
|
514
|
+
skipped.push(
|
|
515
|
+
ledgerEntry(
|
|
516
|
+
repositoriesById,
|
|
517
|
+
file,
|
|
518
|
+
"resolution_failed",
|
|
519
|
+
`compose file unreadable: ${messageOf(error)}`,
|
|
520
|
+
true,
|
|
521
|
+
),
|
|
522
|
+
);
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
const parsed = parseComposeFile(file.repoId, file.path, text);
|
|
526
|
+
if (!parsed.ok) {
|
|
527
|
+
skipped.push(
|
|
528
|
+
ledgerEntry(
|
|
529
|
+
repositoriesById,
|
|
530
|
+
file,
|
|
531
|
+
"parse_failed",
|
|
532
|
+
`compose file could not be parsed: ${parsed.error}`,
|
|
533
|
+
false,
|
|
534
|
+
),
|
|
535
|
+
);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
for (const warning of parsed.result.warnings) {
|
|
539
|
+
skipped.push(
|
|
540
|
+
ledgerEntry(
|
|
541
|
+
repositoriesById,
|
|
542
|
+
file,
|
|
543
|
+
"resolution_failed",
|
|
544
|
+
`compose file partially read: ${warning}`,
|
|
545
|
+
false,
|
|
546
|
+
),
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
services.push(...parsed.result.services);
|
|
550
|
+
serviceEdges.push(...parsed.result.edges);
|
|
551
|
+
}
|
|
552
|
+
services.sort(
|
|
553
|
+
(left, right) =>
|
|
554
|
+
compareCodepoints(left.composeFile, right.composeFile) ||
|
|
555
|
+
compareCodepoints(left.name, right.name),
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
const workspaceGlobs = [...new Set([...globsByRepo.values()].flat())].sort(compareCodepoints);
|
|
559
|
+
|
|
560
|
+
const topology: WorkspaceTopologyV1 = {
|
|
561
|
+
schemaVersion: 1,
|
|
562
|
+
workspaceId: options.workspaceId,
|
|
563
|
+
workspaceGlobs,
|
|
564
|
+
packages,
|
|
565
|
+
packageEdges,
|
|
566
|
+
services,
|
|
567
|
+
serviceEdges,
|
|
568
|
+
skipped: {
|
|
569
|
+
schemaVersion: 1,
|
|
570
|
+
generationId: options.generationId,
|
|
571
|
+
entries: skipped,
|
|
572
|
+
countsByReason: summarizeSkipReasons(skipped),
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
return { topology, projectConfigs };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Schluessel eines Konfigurationsfundes: Repository plus repo-relativer Pfad. */
|
|
580
|
+
export function configKey(repoId: string, path: string): string {
|
|
581
|
+
return `${repoId}\u001f${normalizePath(path)}`;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Aliasregeln aller gelesenen Projektkonfigurationen, in stabiler Reihenfolge.
|
|
586
|
+
*
|
|
587
|
+
* Die Reihenfolge ist „spezifischstes Muster zuerst": ein Muster ohne `*`
|
|
588
|
+
* schlaegt eines mit `*`, und laengere Praefixe schlagen kuerzere. Genau so
|
|
589
|
+
* loest TypeScript `paths` auf; ohne diese Ordnung wuerde `@app/*` ein
|
|
590
|
+
* konkretes `@app/core` verdecken.
|
|
591
|
+
*/
|
|
592
|
+
export function aliasRulesFromConfigs(
|
|
593
|
+
configs: ReadonlyMap<string, ProjectConfigEntryV1>,
|
|
594
|
+
): AliasRuleV1[] {
|
|
595
|
+
const rules: AliasRuleV1[] = [];
|
|
596
|
+
for (const { repoId, config } of configs.values()) {
|
|
597
|
+
for (const [pattern, targets] of Object.entries(config.paths)) {
|
|
598
|
+
rules.push({ pattern, targets: [...targets], repoId, source: config.configPath });
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
rules.sort((left, right) => {
|
|
602
|
+
const leftStar = left.pattern.includes("*") ? 1 : 0;
|
|
603
|
+
const rightStar = right.pattern.includes("*") ? 1 : 0;
|
|
604
|
+
if (leftStar !== rightStar) return leftStar - rightStar;
|
|
605
|
+
const leftPrefix = left.pattern.split("*")[0]?.length ?? 0;
|
|
606
|
+
const rightPrefix = right.pattern.split("*")[0]?.length ?? 0;
|
|
607
|
+
if (leftPrefix !== rightPrefix) return rightPrefix - leftPrefix;
|
|
608
|
+
return (
|
|
609
|
+
compareCodepoints(left.pattern, right.pattern) ||
|
|
610
|
+
compareCodepoints(left.source, right.source) ||
|
|
611
|
+
compareCodepoints(left.repoId, right.repoId)
|
|
612
|
+
);
|
|
613
|
+
});
|
|
614
|
+
return rules;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function ledgerEntry(
|
|
618
|
+
repositories: ReadonlyMap<string, RepositoryInputV1>,
|
|
619
|
+
file: FoundFile,
|
|
620
|
+
reason: SkipReason,
|
|
621
|
+
detail: string,
|
|
622
|
+
recoverable: boolean,
|
|
623
|
+
): SkipLedgerEntryV1 {
|
|
624
|
+
const repository = repositories.get(file.repoId);
|
|
625
|
+
return buildSkipLedgerEntry({
|
|
626
|
+
stage: "file_graph",
|
|
627
|
+
subjectKind: "file",
|
|
628
|
+
path: file.path,
|
|
629
|
+
repoId: file.repoId,
|
|
630
|
+
...(repository === undefined ? {} : { refId: repository.refId }),
|
|
631
|
+
reason,
|
|
632
|
+
detail,
|
|
633
|
+
recoverable,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Identitaet eines Pakets: Repository plus Manifestpfad, nicht der Name. */
|
|
638
|
+
function packageKey(entry: WorkspacePackageV1): string {
|
|
639
|
+
return `${entry.repoId}\u001f${entry.manifestPath}`;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function messageOf(error: unknown): string {
|
|
643
|
+
return error instanceof Error ? error.message : String(error);
|
|
644
|
+
}
|