@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
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Paketmanifest (`package.json`) → auswertbare Form.
3
+ *
4
+ * Nur die Felder, die fuer Aufloesung und Topologie zaehlen. Alles andere
5
+ * bleibt unangetastet; `exports`/`imports` werden ABSICHTLICH als `unknown`
6
+ * durchgereicht, weil ihre Grammatik (verschachtelte Bedingungen, Arrays,
7
+ * Subpath-Muster) im Resolver ausgewertet wird und eine Vorab-Normalisierung
8
+ * hier Information vernichten wuerde.
9
+ */
10
+ import { compareCodepoints } from "@untangle-code/domain";
11
+
12
+ import { isJsonObject, parseJsonc, stringField, stringRecordField } from "./jsonc.js";
13
+
14
+ /** Abhaengigkeitsbloecke eines Manifests, in Auswertungsreihenfolge. */
15
+ export const DEPENDENCY_FIELDS = [
16
+ "dependencies",
17
+ "devDependencies",
18
+ "peerDependencies",
19
+ "optionalDependencies",
20
+ ] as const;
21
+
22
+ export type DependencyField = (typeof DEPENDENCY_FIELDS)[number];
23
+
24
+ /** Ausgewertetes Manifest. */
25
+ export interface ManifestV1 {
26
+ name?: string;
27
+ version?: string;
28
+ main?: string;
29
+ module?: string;
30
+ types?: string;
31
+ exports?: unknown;
32
+ imports?: unknown;
33
+ /** Block → (Paketname → Versionsangabe). */
34
+ dependencies: Record<DependencyField, Record<string, string>>;
35
+ /** `workspaces`-Feld (npm/Yarn), falls vorhanden. */
36
+ workspaceGlobs: string[];
37
+ }
38
+
39
+ export type ManifestParseResult = { ok: true; manifest: ManifestV1 } | { ok: false; error: string };
40
+
41
+ /** Liest ein Manifest. Wirft nie — ein kaputtes Manifest ist ein Befund, kein Absturz. */
42
+ export function parseManifest(text: string): ManifestParseResult {
43
+ const parsed = parseJsonc(text);
44
+ if (!parsed.ok) {
45
+ return { ok: false, error: parsed.error };
46
+ }
47
+ if (!isJsonObject(parsed.value)) {
48
+ return { ok: false, error: "manifest root is not a JSON object" };
49
+ }
50
+ const root = parsed.value;
51
+ const dependencies = {} as Record<DependencyField, Record<string, string>>;
52
+ for (const field of DEPENDENCY_FIELDS) {
53
+ dependencies[field] = stringRecordField(root, field);
54
+ }
55
+ const manifest: ManifestV1 = {
56
+ dependencies,
57
+ workspaceGlobs: readWorkspaceGlobs(root),
58
+ };
59
+ const name = stringField(root, "name");
60
+ if (name !== undefined) manifest.name = name;
61
+ const version = stringField(root, "version");
62
+ if (version !== undefined) manifest.version = version;
63
+ const main = stringField(root, "main");
64
+ if (main !== undefined) manifest.main = main;
65
+ const moduleField = stringField(root, "module");
66
+ if (moduleField !== undefined) manifest.module = moduleField;
67
+ const types = stringField(root, "types");
68
+ if (types !== undefined) manifest.types = types;
69
+ if (root["exports"] !== undefined) manifest.exports = root["exports"];
70
+ if (root["imports"] !== undefined) manifest.imports = root["imports"];
71
+ return { ok: true, manifest };
72
+ }
73
+
74
+ /**
75
+ * `workspaces` kennt zwei Schreibweisen: eine Liste von Globmustern und ein
76
+ * Objekt mit `packages`. Beide werden gelesen — die Objektform stammt aus
77
+ * Yarn und faellt sonst still unter den Tisch.
78
+ */
79
+ function readWorkspaceGlobs(root: Record<string, unknown>): string[] {
80
+ const value = root["workspaces"];
81
+ if (Array.isArray(value)) {
82
+ return value.filter((entry): entry is string => typeof entry === "string");
83
+ }
84
+ if (isJsonObject(value) && Array.isArray(value["packages"])) {
85
+ return (value["packages"] as unknown[]).filter(
86
+ (entry): entry is string => typeof entry === "string",
87
+ );
88
+ }
89
+ return [];
90
+ }
91
+
92
+ /**
93
+ * True, wenn eine Versionsangabe auf ein Paket DESSELBEN Workspace zeigt.
94
+ *
95
+ * `workspace:*` ist das pnpm-/Yarn-Protokoll. `link:` und `file:` zeigen
96
+ * ebenfalls in den Workspace, sind aber keine Katalogeintraege — sie werden
97
+ * hier bewusst NICHT als Workspace-Abhaengigkeit gezaehlt, weil ihr Ziel ein
98
+ * Pfad und kein Paketname ist; der Pfadfall gehoert in den Resolver, nicht in
99
+ * die Topologie.
100
+ */
101
+ export function isWorkspaceProtocol(range: string): boolean {
102
+ return range.startsWith("workspace:");
103
+ }
104
+
105
+ /** Alle deklarierten Abhaengigkeiten, dedupliziert und codepoint-sortiert. */
106
+ export function declaredDependencyNames(manifest: ManifestV1): string[] {
107
+ const names = new Set<string>();
108
+ for (const field of DEPENDENCY_FIELDS) {
109
+ for (const name of Object.keys(manifest.dependencies[field])) {
110
+ names.add(name);
111
+ }
112
+ }
113
+ return [...names].sort(compareCodepoints);
114
+ }
115
+
116
+ /**
117
+ * Versionsangabe eines Pakets im ersten Block, der es nennt. Reihenfolge ist
118
+ * `DEPENDENCY_FIELDS`, damit dieselbe Antwort unabhaengig von der
119
+ * Schluesselreihenfolge im JSON herauskommt.
120
+ */
121
+ export function declaredRangeOf(
122
+ manifest: ManifestV1,
123
+ packageName: string,
124
+ ): { field: DependencyField; range: string } | null {
125
+ for (const field of DEPENDENCY_FIELDS) {
126
+ const range = manifest.dependencies[field][packageName];
127
+ if (range !== undefined) {
128
+ return { field, range };
129
+ }
130
+ }
131
+ return null;
132
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Projektkonfiguration (`tsconfig.json` und Verwandte) → Pfad-Aliase und
3
+ * Ausgabeverzeichnisse.
4
+ *
5
+ * Zwei Dinge braucht der Dateigraph daraus:
6
+ *
7
+ * 1. `baseUrl` + `paths` — die Pfad-Aliase, die Schritt 3 der Resolver-
8
+ * Pipeline (architecture.md §16.2) aufloest.
9
+ * 2. `rootDir` + `outDir` — die Rueckabbildung von Build-Ausgabe auf Quelle.
10
+ * Ohne sie zeigt jeder Workspace-Import ueber eine `exports`-Map auf
11
+ * `dist/index.js`, also auf eine Datei, die im Quellindex gar nicht
12
+ * vorkommt; die Kante waere dann „unresolved", obwohl das Ziel bekannt
13
+ * ist. Mit ihr wird `dist/index.js` zu `src/index.ts`.
14
+ *
15
+ * `extends` wird verfolgt, weil in einem Monorepo praktisch jede
16
+ * Paketkonfiguration von einer gemeinsamen Basis erbt und `paths` fast immer
17
+ * genau dort steht.
18
+ */
19
+ import { dirnameOf, joinRelative, normalizePath } from "../paths.js";
20
+
21
+ import { isJsonObject, parseJsonc, stringField } from "./jsonc.js";
22
+
23
+ /** Ausgewertete Projektkonfiguration eines Verzeichnisses. */
24
+ export interface ProjectConfigV1 {
25
+ /** repo-relativer Pfad der Konfigurationsdatei. */
26
+ configPath: string;
27
+ /** repo-relativ aufgeloester `baseUrl`; `undefined`, wenn nicht gesetzt. */
28
+ baseUrl?: string;
29
+ /** Aliasmuster → repo-relative Ziele. */
30
+ paths: Record<string, string[]>;
31
+ /** repo-relativ aufgeloester `rootDir`. */
32
+ rootDir?: string;
33
+ /** repo-relativ aufgeloester `outDir`. */
34
+ outDir?: string;
35
+ /** Konfigurationen, von denen geerbt wurde, in Erbfolge. */
36
+ extendsChain: string[];
37
+ }
38
+
39
+ /** Eine einzelne, bereits gelesene Konfigurationsdatei. */
40
+ export interface RawProjectConfigV1 {
41
+ /** repo-relativer Pfad. */
42
+ path: string;
43
+ text: string;
44
+ }
45
+
46
+ export type ProjectConfigResult =
47
+ { ok: true; config: ProjectConfigV1; warnings: string[] } | { ok: false; error: string };
48
+
49
+ /**
50
+ * Wertet eine Projektkonfiguration samt `extends`-Kette aus.
51
+ *
52
+ * `readConfig` liefert den Text einer Konfigurationsdatei oder `null`, wenn es
53
+ * sie nicht gibt. Ein fehlendes `extends`-Ziel ist KEIN Abbruch, sondern eine
54
+ * Warnung im Ergebnis — sonst verlieren wir wegen einer nicht eingecheckten
55
+ * Basisdatei die Aliase des ganzen Pakets, ohne dass es jemand merkt (E4).
56
+ */
57
+ export function resolveProjectConfig(
58
+ configPath: string,
59
+ readConfig: (path: string) => string | null,
60
+ ): ProjectConfigResult {
61
+ const warnings: string[] = [];
62
+ const extendsChain: string[] = [];
63
+ const seen = new Set<string>();
64
+
65
+ // Von der Wurzel der Erbkette zum konkreten Projekt hin sammeln, damit
66
+ // spaetere (spezifischere) Werte frueher gelesene ueberschreiben.
67
+ const layers: { path: string; options: Record<string, unknown> }[] = [];
68
+
69
+ let current: string | null = normalizePath(configPath);
70
+ let depth = 0;
71
+ while (current !== null) {
72
+ if (seen.has(current)) {
73
+ return { ok: false, error: `circular "extends" chain at ${current}` };
74
+ }
75
+ seen.add(current);
76
+ depth += 1;
77
+ if (depth > 32) {
78
+ return { ok: false, error: `"extends" chain longer than 32 at ${current}` };
79
+ }
80
+ const text = readConfig(current);
81
+ if (text === null) {
82
+ if (layers.length === 0) {
83
+ return { ok: false, error: `configuration file not found: ${current}` };
84
+ }
85
+ warnings.push(`extends target not found: ${current}`);
86
+ break;
87
+ }
88
+ const parsed = parseJsonc(text);
89
+ if (!parsed.ok) {
90
+ if (layers.length === 0) {
91
+ return { ok: false, error: `${current}: ${parsed.error}` };
92
+ }
93
+ warnings.push(`extends target unreadable (${current}): ${parsed.error}`);
94
+ break;
95
+ }
96
+ if (!isJsonObject(parsed.value)) {
97
+ warnings.push(`${current}: root is not a JSON object`);
98
+ break;
99
+ }
100
+ const root = parsed.value;
101
+ const options = isJsonObject(root["compilerOptions"]) ? root["compilerOptions"] : {};
102
+ layers.push({ path: current, options });
103
+
104
+ const extendsValue = stringField(root, "extends");
105
+ if (extendsValue === undefined) {
106
+ break;
107
+ }
108
+ const next = joinRelative(dirnameOf(current), extendsValue);
109
+ if (next === null) {
110
+ warnings.push(`extends target escapes the repository root: ${extendsValue}`);
111
+ break;
112
+ }
113
+ extendsChain.push(next);
114
+ // `extends` ohne Erweiterung meint eine JSON-Datei.
115
+ current = /\.json$/i.test(next) ? next : `${next}.json`;
116
+ }
117
+
118
+ const config: ProjectConfigV1 = {
119
+ configPath: normalizePath(configPath),
120
+ paths: {},
121
+ extendsChain,
122
+ };
123
+
124
+ // Von der Basis (letzte Schicht) zum Projekt (erste Schicht) anwenden.
125
+ for (const layer of [...layers].reverse()) {
126
+ const dir = dirnameOf(layer.path);
127
+ const baseUrl = stringField(layer.options, "baseUrl");
128
+ if (baseUrl !== undefined) {
129
+ const resolved = joinRelative(dir, baseUrl);
130
+ if (resolved === null) {
131
+ warnings.push(`baseUrl escapes the repository root in ${layer.path}: ${baseUrl}`);
132
+ } else {
133
+ config.baseUrl = resolved;
134
+ }
135
+ }
136
+ const rootDir = stringField(layer.options, "rootDir");
137
+ if (rootDir !== undefined) {
138
+ const resolved = joinRelative(dir, rootDir);
139
+ if (resolved === null) {
140
+ warnings.push(`rootDir escapes the repository root in ${layer.path}: ${rootDir}`);
141
+ } else {
142
+ config.rootDir = resolved;
143
+ }
144
+ }
145
+ const outDir = stringField(layer.options, "outDir");
146
+ if (outDir !== undefined) {
147
+ const resolved = joinRelative(dir, outDir);
148
+ if (resolved === null) {
149
+ warnings.push(`outDir escapes the repository root in ${layer.path}: ${outDir}`);
150
+ } else {
151
+ config.outDir = resolved;
152
+ }
153
+ }
154
+ const pathsValue = layer.options["paths"];
155
+ if (isJsonObject(pathsValue)) {
156
+ // `paths` ist relativ zu `baseUrl`, ersatzweise zum Verzeichnis der
157
+ // Datei, in der `paths` steht (TypeScript-Verhalten seit 4.1).
158
+ const anchor = config.baseUrl ?? dir;
159
+ for (const [pattern, targets] of Object.entries(pathsValue)) {
160
+ if (!Array.isArray(targets)) {
161
+ warnings.push(`paths["${pattern}"] in ${layer.path} is not an array`);
162
+ continue;
163
+ }
164
+ const resolvedTargets: string[] = [];
165
+ for (const target of targets) {
166
+ if (typeof target !== "string") {
167
+ warnings.push(`paths["${pattern}"] in ${layer.path} contains a non-string target`);
168
+ continue;
169
+ }
170
+ const resolved = joinRelative(anchor, target);
171
+ if (resolved === null) {
172
+ warnings.push(`paths["${pattern}"] target escapes the repository root: ${target}`);
173
+ continue;
174
+ }
175
+ resolvedTargets.push(resolved);
176
+ }
177
+ config.paths[pattern] = resolvedTargets;
178
+ }
179
+ }
180
+ }
181
+
182
+ return { ok: true, config, warnings };
183
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Workspace-Definition und Globabgleich.
3
+ *
4
+ * Quellen fuer die Liste der Teilprojekte, in dieser Reihenfolge:
5
+ * 1. `pnpm-workspace.yaml` (`packages:`) — das pnpm-Protokoll;
6
+ * 2. `workspaces` im Wurzelmanifest (npm/Yarn).
7
+ *
8
+ * Der Globabgleich deckt genau das ab, was Workspace-Definitionen wirklich
9
+ * benutzen: `*` (ein Segment), `**` (beliebig viele Segmente), `?` (ein
10
+ * Zeichen) und ein fuehrendes `!` als Ausschluss. Ein vollstaendiger
11
+ * Glob-Dialekt waere hier ueberdimensioniert — und ein NICHT unterstuetztes
12
+ * Muster wird gemeldet statt still ignoriert (E4).
13
+ */
14
+ import { compareCodepoints } from "@untangle-code/domain";
15
+ import { parse as parseYaml } from "yaml";
16
+
17
+ import { normalizePath } from "../paths.js";
18
+
19
+ export type WorkspaceDefinitionResult =
20
+ { ok: true; globs: string[] } | { ok: false; error: string };
21
+
22
+ /** Liest die `packages:`-Liste einer pnpm-Workspace-Definition. */
23
+ export function parsePnpmWorkspace(text: string): WorkspaceDefinitionResult {
24
+ let document: unknown;
25
+ try {
26
+ document = parseYaml(text) as unknown;
27
+ } catch (error) {
28
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
29
+ }
30
+ if (typeof document !== "object" || document === null || Array.isArray(document)) {
31
+ return { ok: false, error: "workspace document root is not a mapping" };
32
+ }
33
+ const packages = (document as Record<string, unknown>)["packages"];
34
+ if (packages === undefined) {
35
+ return { ok: false, error: 'workspace document has no "packages" list' };
36
+ }
37
+ if (!Array.isArray(packages)) {
38
+ return { ok: false, error: '"packages" is not a list' };
39
+ }
40
+ const globs = packages.filter((entry): entry is string => typeof entry === "string");
41
+ if (globs.length !== packages.length) {
42
+ return { ok: false, error: '"packages" contains non-string entries' };
43
+ }
44
+ return { ok: true, globs };
45
+ }
46
+
47
+ /** Zerlegt eine Globliste in Einschluesse und Ausschluesse. */
48
+ export interface GlobSelectionV1 {
49
+ include: string[];
50
+ exclude: string[];
51
+ }
52
+
53
+ export function splitGlobs(globs: readonly string[]): GlobSelectionV1 {
54
+ const include: string[] = [];
55
+ const exclude: string[] = [];
56
+ for (const glob of globs) {
57
+ if (glob.startsWith("!")) {
58
+ exclude.push(normalizePath(glob.slice(1)));
59
+ } else {
60
+ include.push(normalizePath(glob));
61
+ }
62
+ }
63
+ return { include: include.sort(compareCodepoints), exclude: exclude.sort(compareCodepoints) };
64
+ }
65
+
66
+ /**
67
+ * Uebersetzt ein Workspace-Glob in einen Regulaerausdruck ueber Pfadsegmente.
68
+ *
69
+ * `**` frisst beliebig viele Segmente einschliesslich null, `*` genau ein
70
+ * Segment ohne `/`, `?` genau ein Zeichen ohne `/`. Alles andere wird
71
+ * woertlich verglichen.
72
+ */
73
+ export function globToRegExp(glob: string): RegExp {
74
+ const normalized = normalizePath(glob);
75
+ let pattern = "";
76
+ let index = 0;
77
+ while (index < normalized.length) {
78
+ const char = normalized[index] as string;
79
+ if (char === "*") {
80
+ const isDoubleStar = normalized[index + 1] === "*";
81
+ if (isDoubleStar) {
82
+ // `a/**/b` und `a/**` sollen auch `a/b` bzw. `a` treffen.
83
+ if (normalized[index + 2] === "/") {
84
+ pattern += "(?:.*/)?";
85
+ index += 3;
86
+ continue;
87
+ }
88
+ pattern += ".*";
89
+ index += 2;
90
+ continue;
91
+ }
92
+ pattern += "[^/]*";
93
+ index += 1;
94
+ continue;
95
+ }
96
+ if (char === "?") {
97
+ pattern += "[^/]";
98
+ index += 1;
99
+ continue;
100
+ }
101
+ pattern += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
102
+ index += 1;
103
+ }
104
+ return new RegExp(`^${pattern}$`);
105
+ }
106
+
107
+ /** True, wenn `directory` von der Globauswahl erfasst wird. */
108
+ export function matchesGlobSelection(selection: GlobSelectionV1, directory: string): boolean {
109
+ const candidate = normalizePath(directory);
110
+ const included = selection.include.some((glob) => globToRegExp(glob).test(candidate));
111
+ if (!included) {
112
+ return false;
113
+ }
114
+ return !selection.exclude.some((glob) => globToRegExp(glob).test(candidate));
115
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Die Traversierung des Dateigraphen als FREIE FUNKTION — losgeloest von der
3
+ * Frage, ob der Graph gerade gebaut oder aus einem Abbild zurueckgeladen
4
+ * wurde.
5
+ *
6
+ * WARUM SIE HIER STEHT UND NICHT MEHR IN `graph.ts`: Der Lesepfad
7
+ * (`readGraphSnapshot` → `restoreFileGraph`) braucht exakt dieselbe
8
+ * Traversierung wie der Schreibpfad. Eine zweite, „nur fuer die Anzeige"
9
+ * geschriebene Fassung waere eine zweite Wahrheit ueber Zyklen und Budgets —
10
+ * und Zyklenmeldung wie Abschneidegrund sind genau die Aussagen, an denen ein
11
+ * Werkzeug luegt, wenn es sie zweimal implementiert. `FileDependencyGraph`
12
+ * und `RestoredFileGraph` rufen deshalb DIESELBE Funktion.
13
+ *
14
+ * Die drei Zusagen der Traversierung sind unveraendert:
15
+ *
16
+ * 1. Tiefensuche, nicht Breitensuche: nur ein Pfadstapel kann echte Zyklen
17
+ * BENENNEN statt bloss „hier war ich schon" zu vermerken.
18
+ * 2. Ein Zyklus wird gemeldet, nicht aufgebrochen.
19
+ * 3. Ein abgeschnittenes Ergebnis sagt, DASS und WARUM es abgeschnitten ist
20
+ * (`truncated` + `truncationReason`, E4). Die Tiefengrenze beschneidet nur
21
+ * den betrachteten Ast; ein erschoepftes GLOBALES Budget beendet den Lauf
22
+ * und nennt die nie angefassten Wurzeln.
23
+ */
24
+ import type { TraversalDirection, TraversalLimitsV1 } from "@untangle-code/domain";
25
+ import { uniqueSortedByCodepoints } from "@untangle-code/domain";
26
+
27
+ import type { AdjacencyV1 } from "./metrics.js";
28
+ import type { FileTraversalResultV1 } from "./types.js";
29
+
30
+ /** Standardbudgets einer Traversierung. */
31
+ export const DEFAULT_TRAVERSAL_LIMITS: TraversalLimitsV1 = {
32
+ maxDepth: 16,
33
+ maxNodes: 5_000,
34
+ maxEdges: 20_000,
35
+ maxWallTimeMs: 5_000,
36
+ };
37
+
38
+ /** Uhr fuer Zeitbudgets. Injizierbar, damit Tests nicht auf echte Zeit warten. */
39
+ export type MonotonicClock = () => number;
40
+
41
+ /**
42
+ * Was die Traversierung vom Graphen wissen muss — und sonst nichts.
43
+ *
44
+ * `edgeIdBetween` ist bewusst eine Funktion und keine fertige Karte: der
45
+ * gebaute Graph beantwortet sie ueber seinen Kantenindex, der
46
+ * zurueckgeladene ueber die Kanten des Abbilds. Beide antworten dasselbe.
47
+ */
48
+ export interface FileGraphNeighborhoodV1 {
49
+ readonly adjacency: AdjacencyV1;
50
+ edgeIdBetween(from: string, to: string, direction: TraversalDirection): string | null;
51
+ }
52
+
53
+ export interface TraverseFileGraphOptionsV1 {
54
+ direction: TraversalDirection;
55
+ limits?: Partial<TraversalLimitsV1>;
56
+ now?: MonotonicClock;
57
+ }
58
+
59
+ function defaultClock(): number {
60
+ // `performance.now()` ist monoton und wird ausschliesslich fuer die
61
+ // Budgetentscheidung benutzt — kein Zeitwert landet je in Ergebnisdaten.
62
+ return performance.now();
63
+ }
64
+
65
+ /** Tiefensuche mit Budget und Zyklenmeldung. */
66
+ export function traverseFileGraph(
67
+ neighborhood: FileGraphNeighborhoodV1,
68
+ rootFileIds: readonly string[],
69
+ options: TraverseFileGraphOptionsV1,
70
+ ): FileTraversalResultV1 {
71
+ const limits: TraversalLimitsV1 = { ...DEFAULT_TRAVERSAL_LIMITS, ...options.limits };
72
+ const now = options.now ?? defaultClock;
73
+ const startedAt = now();
74
+ const neighbors =
75
+ options.direction === "forward"
76
+ ? neighborhood.adjacency.forward
77
+ : neighborhood.adjacency.reverse;
78
+
79
+ const roots = uniqueSortedByCodepoints([...rootFileIds]);
80
+ const visited = new Set<string>(roots);
81
+ const visitedOrder: string[] = [];
82
+ const edgeIds: string[] = [];
83
+ const revisitSkipped: string[] = [];
84
+ const cycles: string[][] = [];
85
+ const seenCycleKeys = new Set<string>();
86
+ let depthReached = 0;
87
+ let truncated = false;
88
+ let truncationReason: FileTraversalResultV1["truncationReason"];
89
+ /**
90
+ * GLOBALES Budget erschoepft (Knoten, Kanten, Zeit) — im Unterschied zur
91
+ * Tiefengrenze, die NUR den gerade betrachteten Ast beschneidet. Beides in
92
+ * einem Flag zu fuehren war der Fehler: eine erreichte Tiefe in der ersten
93
+ * Wurzel warf alle weiteren Wurzeln weg, obwohl auf deren Aesten die
94
+ * Tiefengrenze noch gar nicht erreicht war.
95
+ */
96
+ let budgetExhausted = false;
97
+
98
+ const stop = (reason: NonNullable<FileTraversalResultV1["truncationReason"]>): void => {
99
+ truncated = true;
100
+ // WP-196: NICHT der erste Grund, sondern der, an dem tatsaechlich etwas
101
+ // verloren ging. `max_depth` beschneidet nur den gerade betrachteten Ast
102
+ // und laesst die Traversierung weiterlaufen; ein erschoepftes GLOBALES
103
+ // Budget beendet sie. Wer beide in der Reihenfolge ihres Auftretens
104
+ // aufhebt, meldet bei „erst Tiefe, dann Knotenbudget" die Tiefe — und
105
+ // schickt den Leser damit an die falsche Stellschraube, waehrend die
106
+ // Wurzeln in `unvisitedRootFileIds` von einem Budget erzaehlen, das im
107
+ // Abschneidegrund gar nicht vorkommt.
108
+ if (
109
+ truncationReason === undefined ||
110
+ (truncationReason === "max_depth" && reason !== "max_depth")
111
+ ) {
112
+ truncationReason = reason;
113
+ }
114
+ if (reason !== "max_depth") budgetExhausted = true;
115
+ };
116
+
117
+ /**
118
+ * WP-194: Wurzeln, die der Graph GAR NICHT KENNT.
119
+ *
120
+ * `buildAdjacency` legt fuer jeden Knoten einen Eintrag an — auch fuer einen
121
+ * ohne jede Kante. Ein fehlender Eintrag heisst deshalb nicht „isolierter
122
+ * Knoten", sondern „diese Datei ist im Graphen nicht vorhanden". Bisher
123
+ * landete eine solche Wurzel unbesehen in `rootFileIds` und sah dort aus wie
124
+ * jede andere bearbeitete Wurzel: null Nachbarn, kein Hinweis. Wer nach
125
+ * einem Tippfehler im Pfad sucht, bekam ein leeres, unauffaelliges Ergebnis
126
+ * — die Frage wurde nie gestellt, und niemand sagte es.
127
+ */
128
+ const unknownRoots: string[] = [];
129
+
130
+ const unvisitedRoots: string[] = [];
131
+ for (const root of roots) {
132
+ if (!neighborhood.adjacency.forward.has(root)) {
133
+ unknownRoots.push(root);
134
+ continue;
135
+ }
136
+ if (budgetExhausted) {
137
+ // Nicht stillschweigend fallen lassen: diese Wurzeln wurden nie
138
+ // angefasst, und `rootFileIds` allein saehe so aus, als waeren sie
139
+ // bearbeitet worden (E4).
140
+ unvisitedRoots.push(root);
141
+ continue;
142
+ }
143
+ const path: string[] = [root];
144
+ const onPath = new Set<string>([root]);
145
+ const work: { node: string; childIndex: number; depth: number }[] = [
146
+ { node: root, childIndex: 0, depth: 0 },
147
+ ];
148
+
149
+ while (work.length > 0) {
150
+ if (now() - startedAt > limits.maxWallTimeMs) {
151
+ stop("max_wall_time");
152
+ break;
153
+ }
154
+ const frame = work[work.length - 1] as { node: string; childIndex: number; depth: number };
155
+ const children = neighbors.get(frame.node) ?? [];
156
+ if (frame.childIndex >= children.length) {
157
+ work.pop();
158
+ const removed = path.pop();
159
+ if (removed !== undefined) onPath.delete(removed);
160
+ continue;
161
+ }
162
+ const child = children[frame.childIndex] as string;
163
+ frame.childIndex += 1;
164
+
165
+ if (onPath.has(child)) {
166
+ // Zyklus GEFUNDEN — nicht aufgebrochen, sondern benannt.
167
+ const cycle = path.slice(path.indexOf(child));
168
+ const key = cycle.join("/");
169
+ if (!seenCycleKeys.has(key)) {
170
+ seenCycleKeys.add(key);
171
+ cycles.push(cycle);
172
+ }
173
+ revisitSkipped.push(child);
174
+ continue;
175
+ }
176
+
177
+ const edgeId = neighborhood.edgeIdBetween(frame.node, child, options.direction);
178
+ if (edgeId !== null) {
179
+ if (edgeIds.length >= limits.maxEdges) {
180
+ stop("max_edges");
181
+ break;
182
+ }
183
+ edgeIds.push(edgeId);
184
+ }
185
+
186
+ if (visited.has(child)) {
187
+ revisitSkipped.push(child);
188
+ continue;
189
+ }
190
+ if (visitedOrder.length >= limits.maxNodes) {
191
+ stop("max_nodes");
192
+ break;
193
+ }
194
+ visited.add(child);
195
+ visitedOrder.push(child);
196
+
197
+ const childDepth = frame.depth + 1;
198
+ depthReached = Math.max(depthReached, childDepth);
199
+ if (childDepth >= limits.maxDepth) {
200
+ // Die Tiefengrenze ist erreicht: der Knoten zaehlt als besucht, seine
201
+ // Nachbarn werden nicht mehr betrachtet — und genau das wird gemeldet.
202
+ if ((neighbors.get(child) ?? []).length > 0) {
203
+ stop("max_depth");
204
+ }
205
+ continue;
206
+ }
207
+ path.push(child);
208
+ onPath.add(child);
209
+ work.push({ node: child, childIndex: 0, depth: childDepth });
210
+ }
211
+ }
212
+
213
+ return {
214
+ schemaVersion: 1,
215
+ direction: options.direction,
216
+ rootFileIds: roots,
217
+ visitedFileIds: visitedOrder,
218
+ edgeIds,
219
+ depthReached,
220
+ truncated,
221
+ ...(truncationReason === undefined ? {} : { truncationReason }),
222
+ revisitSkippedFileIds: uniqueSortedByCodepoints(revisitSkipped),
223
+ cycles,
224
+ ...(unvisitedRoots.length === 0
225
+ ? {}
226
+ : { unvisitedRootFileIds: uniqueSortedByCodepoints(unvisitedRoots) }),
227
+ ...(unknownRoots.length === 0
228
+ ? {}
229
+ : { unknownRootFileIds: uniqueSortedByCodepoints(unknownRoots) }),
230
+ limits,
231
+ };
232
+ }