@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,411 @@
1
+ import type {
2
+ HttpBoundaryFileFactsV1,
3
+ HttpBoundaryProvenanceV1,
4
+ HttpCallFactV1,
5
+ HttpExpressionV1,
6
+ HttpFunctionFactV1,
7
+ } from "@untangle-code/domain";
8
+ import { UntangleError } from "@untangle-code/domain";
9
+
10
+ import { provenance } from "./endpoints.js";
11
+
12
+ type Expression = HttpExpressionV1;
13
+ type Environment = ReadonlyMap<string, Expression>;
14
+ const UNKNOWN: Expression = { kind: "unknown", detail: "unbound value" };
15
+ const UNDEFINED: Expression = { kind: "undefined" };
16
+ const DYNAMIC = "«dynamic»";
17
+ const ENCODED_SEGMENT = "«encoded-segment»";
18
+
19
+ interface Context {
20
+ file: HttpBoundaryFileFactsV1;
21
+ scope: string;
22
+ env: Environment;
23
+ }
24
+
25
+ function functionAt(file: HttpBoundaryFileFactsV1, scope: string): HttpFunctionFactV1 | undefined {
26
+ return file.functions.find((fn) => fn.scope === scope);
27
+ }
28
+
29
+ function referencesName(expr: Expression, name: string, depth = 0): boolean {
30
+ if (depth > 40) return true;
31
+ if (expr.kind === "name") return expr.name === name;
32
+ if (expr.kind === "template")
33
+ return expr.parts.some(
34
+ (part) => typeof part !== "string" && referencesName(part, name, depth + 1),
35
+ );
36
+ if (expr.kind === "object")
37
+ return expr.entries.some((entry) =>
38
+ referencesName("spread" in entry ? entry.spread : entry.value, name, depth + 1),
39
+ );
40
+ if (expr.kind === "member") return referencesName(expr.object, name, depth + 1);
41
+ if (expr.kind === "call")
42
+ return expr.arguments.some((arg) => referencesName(arg, name, depth + 1));
43
+ return false;
44
+ }
45
+
46
+ /** Fail closed when an options object can escape to code whose writes are unknown. */
47
+ function objectEscapes(name: string, context: Context, seen = new Set<string>()): boolean {
48
+ const location = `${context.scope}:${name}`;
49
+ if (seen.has(location) || seen.size >= 12) return true;
50
+ const next = new Set([...seen, location]);
51
+ if (
52
+ context.file.bindings.some(
53
+ (binding) =>
54
+ binding.scope === context.scope &&
55
+ binding.name !== name &&
56
+ referencesName(binding.value, name),
57
+ )
58
+ )
59
+ return true;
60
+ if (
61
+ context.file.bindings.some(
62
+ (binding) =>
63
+ binding.scope === context.scope &&
64
+ binding.name === name &&
65
+ binding.value.kind === "unknown" &&
66
+ binding.value.detail === "reassigned binding",
67
+ )
68
+ )
69
+ return true;
70
+ for (const call of context.file.calls.filter((call) => call.scope === context.scope)) {
71
+ for (let index = 0; index < call.arguments.length; index += 1) {
72
+ const argument = call.arguments[index];
73
+ if (argument?.kind !== "name" || argument.name !== name) continue;
74
+ if (transportName(context.file, call)) continue;
75
+ const target = localFunction(context.file, call);
76
+ const parameter = target?.parameters[index];
77
+ if (
78
+ target === null ||
79
+ target === undefined ||
80
+ parameter === undefined ||
81
+ objectEscapes(parameter.name, { ...context, scope: target.scope, env: new Map() }, next)
82
+ )
83
+ return true;
84
+ }
85
+ }
86
+ return false;
87
+ }
88
+
89
+ function resolveName(name: string, context: Context, seen: Set<string>, depth: number): Expression {
90
+ const lookup = `${context.scope}:${name}`;
91
+ if (seen.has(lookup)) return UNKNOWN;
92
+ const next = new Set([...seen, lookup]);
93
+ const binding = context.file.bindings.filter(
94
+ (item) => item.scope === context.scope && item.name === name,
95
+ );
96
+ if (binding.length > 1) return { kind: "unknown", detail: "multiple or reassigned bindings" };
97
+ if (binding.length === 1 && binding[0]?.value.kind === "object" && objectEscapes(name, context))
98
+ return { kind: "unknown", detail: "object binding escapes static transport analysis" };
99
+ if (binding.length === 1)
100
+ return resolveExpression(
101
+ (binding[0] as (typeof binding)[number]).value,
102
+ context,
103
+ next,
104
+ depth + 1,
105
+ );
106
+ const envValue = context.env.get(name);
107
+ if (envValue !== undefined)
108
+ return envValue.kind === "object" && objectEscapes(name, context)
109
+ ? { kind: "unknown", detail: "object parameter escapes static transport analysis" }
110
+ : envValue;
111
+ const fn = functionAt(context.file, context.scope);
112
+ if (fn?.parameters.some((param) => param.name === name)) return UNKNOWN;
113
+ if (context.scope !== "module")
114
+ return resolveName(
115
+ name,
116
+ { ...context, scope: fn?.parentScope ?? "module", env: new Map() },
117
+ next,
118
+ depth + 1,
119
+ );
120
+ return { kind: "name", name };
121
+ }
122
+
123
+ function resolveExpression(
124
+ expr: Expression,
125
+ context: Context,
126
+ seen = new Set<string>(),
127
+ depth = 0,
128
+ ): Expression {
129
+ if (depth > 40) return UNKNOWN;
130
+ if (expr.kind === "name") return resolveName(expr.name, context, seen, depth);
131
+ const resolve = (value: Expression) => resolveExpression(value, context, seen, depth + 1);
132
+ if (expr.kind === "template")
133
+ return {
134
+ kind: "template",
135
+ parts: expr.parts.map((part) => (typeof part === "string" ? part : resolve(part))),
136
+ };
137
+ if (expr.kind === "object")
138
+ return {
139
+ kind: "object",
140
+ entries: expr.entries.map((entry) =>
141
+ "spread" in entry
142
+ ? { spread: resolve(entry.spread) }
143
+ : { key: entry.key, value: resolve(entry.value) },
144
+ ),
145
+ };
146
+ if (expr.kind === "member") return { ...expr, object: resolve(expr.object) };
147
+ if (expr.kind === "call") {
148
+ if (expr.callee.kind === "name" && expr.callee.name === "encodeURIComponent") {
149
+ const shadowed =
150
+ scopeChain(context.file, context.scope).some(
151
+ (scope) =>
152
+ context.file.bindings.some(
153
+ (binding) => binding.scope === scope && binding.name === "encodeURIComponent",
154
+ ) ||
155
+ functionAt(context.file, scope)?.parameters.some(
156
+ (param) => param.name === "encodeURIComponent",
157
+ ) ||
158
+ context.file.functions.some(
159
+ (fn) => fn.parentScope === scope && fn.name === "encodeURIComponent",
160
+ ),
161
+ ) || context.file.imports.some((raw) => raw.importedNames?.includes("encodeURIComponent"));
162
+ if (shadowed) return UNKNOWN;
163
+ }
164
+ return { ...expr, arguments: expr.arguments.map(resolve) };
165
+ }
166
+ return expr;
167
+ }
168
+
169
+ function scopeChain(file: HttpBoundaryFileFactsV1, scope: string): string[] {
170
+ const result = [scope];
171
+ for (let depth = 0; depth < 30 && scope !== "module"; depth += 1) {
172
+ scope = functionAt(file, scope)?.parentScope ?? "module";
173
+ result.push(scope);
174
+ }
175
+ return result;
176
+ }
177
+
178
+ function transportName(file: HttpBoundaryFileFactsV1, call: HttpCallFactV1): boolean {
179
+ if (call.callee.kind !== "name") return false;
180
+ const name = call.callee.name;
181
+ for (const scope of scopeChain(file, call.scope)) {
182
+ if (file.bindings.some((binding) => binding.scope === scope && binding.name === name))
183
+ return false;
184
+ const fn = functionAt(file, scope);
185
+ const parameter = fn?.parameters.find((param) => param.name === name);
186
+ if (parameter !== undefined) return parameter.fetchTransport === true;
187
+ if (
188
+ file.functions.some((candidate) => candidate.parentScope === scope && candidate.name === name)
189
+ )
190
+ return false;
191
+ if (file.imports.some((raw) => raw.importedNames?.includes(name))) return false;
192
+ }
193
+ return name === "fetch";
194
+ }
195
+
196
+ function localFunction(
197
+ file: HttpBoundaryFileFactsV1,
198
+ call: HttpCallFactV1,
199
+ ): HttpFunctionFactV1 | null {
200
+ if (call.callee.kind !== "name") return null;
201
+ const name = call.callee.name;
202
+ for (const scope of scopeChain(file, call.scope)) {
203
+ const candidates = file.functions.filter((fn) => fn.parentScope === scope && fn.name === name);
204
+ if (candidates.length === 1) return candidates[0] as HttpFunctionFactV1;
205
+ if (
206
+ candidates.length > 1 ||
207
+ file.bindings.some((binding) => binding.scope === scope && binding.name === name)
208
+ )
209
+ return null;
210
+ }
211
+ return null;
212
+ }
213
+
214
+ function render(expr: Expression): string {
215
+ if (expr.kind === "string")
216
+ return expr.value.includes(DYNAMIC) || expr.value.includes(ENCODED_SEGMENT)
217
+ ? DYNAMIC
218
+ : expr.value;
219
+ if (expr.kind === "template")
220
+ return expr.parts.map((part) => (typeof part === "string" ? part : render(part))).join("");
221
+ if (
222
+ expr.kind === "call" &&
223
+ expr.callee.kind === "name" &&
224
+ expr.callee.name === "encodeURIComponent" &&
225
+ expr.arguments.length === 1
226
+ )
227
+ return ENCODED_SEGMENT;
228
+ return DYNAMIC;
229
+ }
230
+
231
+ function methodOf(expr: Expression): string | null {
232
+ if (expr.kind === "undefined") return "GET";
233
+ if (expr.kind !== "object") return null;
234
+ let method: Expression = UNDEFINED;
235
+ for (const entry of expr.entries) {
236
+ if ("spread" in entry) {
237
+ if (entry.spread.kind === "object") {
238
+ const nested = methodOf(entry.spread);
239
+ if (
240
+ nested !== "GET" ||
241
+ entry.spread.entries.some((item) => "key" in item && item.key === "method")
242
+ )
243
+ method = nested === null ? UNKNOWN : { kind: "string", value: nested };
244
+ } else if (entry.spread.kind !== "undefined") method = UNKNOWN;
245
+ } else if (entry.key === "method") method = entry.value;
246
+ }
247
+ if (method.kind === "undefined") return "GET";
248
+ return method.kind === "string" && /^[A-Za-z]+$/.test(method.value)
249
+ ? method.value.toUpperCase()
250
+ : null;
251
+ }
252
+
253
+ function normalizeRequest(url: string): {
254
+ path: string | null;
255
+ origin: string | null;
256
+ reason?: string;
257
+ } {
258
+ const bare = url.split(/[?#]/, 1)[0] ?? "";
259
+ if (bare.includes(DYNAMIC))
260
+ return { path: null, origin: null, reason: "unbounded dynamic URL or path" };
261
+ let path = bare,
262
+ origin: string | null = null;
263
+ if (/^https?:\/\//i.test(bare)) {
264
+ try {
265
+ const parsed = new URL(bare);
266
+ if (parsed.username || parsed.password)
267
+ return { path: null, origin: null, reason: "URL credentials are unsupported" };
268
+ origin = parsed.origin;
269
+ const authorityEnd = bare.indexOf("/", bare.indexOf("://") + 3);
270
+ path = authorityEnd === -1 ? "/" : bare.slice(authorityEnd);
271
+ } catch {
272
+ return { path: null, origin: null, reason: "invalid absolute URL" };
273
+ }
274
+ }
275
+ if (
276
+ !path.startsWith("/") ||
277
+ path.startsWith("//") ||
278
+ path.includes("\\") ||
279
+ path.includes("//") ||
280
+ /%(?:2e|2f|5c)/i.test(path) ||
281
+ /(^|\/)\.{1,2}(\/|$)/.test(path)
282
+ )
283
+ return {
284
+ path: null,
285
+ origin,
286
+ reason: "URL is not a normalized root-relative or absolute HTTP URL",
287
+ };
288
+ if (path.split("/").some((part) => part.includes(ENCODED_SEGMENT) && part !== ENCODED_SEGMENT))
289
+ return { path: null, origin, reason: "dynamic segment has no complete literal boundary" };
290
+ return { path: path.replaceAll(ENCODED_SEGMENT, "{*}"), origin };
291
+ }
292
+
293
+ export interface ExtractedRequest {
294
+ source: HttpBoundaryProvenanceV1;
295
+ method: string | null;
296
+ path: string | null;
297
+ origin: string | null;
298
+ reason?: string;
299
+ provenance: HttpBoundaryProvenanceV1[];
300
+ }
301
+
302
+ /** Bounded, same-file wrapper substitution; no whole-program execution or guessed clients. */
303
+ export function collectRequests(file: HttpBoundaryFileFactsV1): ExtractedRequest[] {
304
+ const result: ExtractedRequest[] = [];
305
+ const transportCalls = file.calls.filter((call) => transportName(file, call));
306
+ const wrappers = new Set(transportCalls.map((call) => call.scope));
307
+ for (let iteration = 0; iteration < 8; iteration += 1) {
308
+ let changed = false;
309
+ for (const call of file.calls) {
310
+ const target = localFunction(file, call);
311
+ if (
312
+ target !== null &&
313
+ target.parameters.length > 0 &&
314
+ wrappers.has(target.scope) &&
315
+ !wrappers.has(call.scope)
316
+ ) {
317
+ wrappers.add(call.scope);
318
+ changed = true;
319
+ }
320
+ }
321
+ if (!changed) break;
322
+ }
323
+ const emit = (
324
+ call: HttpCallFactV1,
325
+ context: Context,
326
+ source: HttpBoundaryProvenanceV1,
327
+ chain: HttpBoundaryProvenanceV1[],
328
+ ): void => {
329
+ if (result.length >= 10000)
330
+ throw new UntangleError("ANALYSIS_INCOMPLETE", "HTTP request expansion budget exceeded");
331
+ const url = render(resolveExpression(call.arguments[0] ?? UNKNOWN, context));
332
+ const method = methodOf(resolveExpression(call.arguments[1] ?? UNDEFINED, context));
333
+ const normalized = normalizeRequest(url);
334
+ result.push({
335
+ source,
336
+ method,
337
+ ...normalized,
338
+ ...(method === null && normalized.reason === undefined
339
+ ? { reason: "HTTP method is not statically known" }
340
+ : {}),
341
+ provenance: [...chain, provenance(file, call.range, "transport")],
342
+ });
343
+ };
344
+ const expand = (
345
+ fn: HttpFunctionFactV1,
346
+ args: Expression[],
347
+ source: HttpBoundaryProvenanceV1,
348
+ chain: HttpBoundaryProvenanceV1[],
349
+ seen: Set<string>,
350
+ ): void => {
351
+ if (seen.has(fn.scope) || seen.size >= 8) {
352
+ result.push({
353
+ source,
354
+ method: null,
355
+ path: null,
356
+ origin: null,
357
+ reason: "local wrapper recursion/depth budget exceeded",
358
+ provenance: chain,
359
+ });
360
+ return;
361
+ }
362
+ instantiated.add(fn.scope);
363
+ const env = new Map(
364
+ fn.parameters.map((param, index) => [
365
+ param.name,
366
+ args[index] ?? param.defaultValue ?? UNDEFINED,
367
+ ]),
368
+ );
369
+ const context = { file, scope: fn.scope, env };
370
+ for (const call of file.calls.filter((call) => call.scope === fn.scope)) {
371
+ if (transportName(file, call)) emit(call, context, source, chain);
372
+ else {
373
+ const target = localFunction(file, call);
374
+ if (target !== null && wrappers.has(target.scope))
375
+ expand(
376
+ target,
377
+ call.arguments.map((arg) => resolveExpression(arg, context)),
378
+ source,
379
+ [...chain, provenance(file, call.range, "transport", target.name)],
380
+ new Set([...seen, fn.scope]),
381
+ );
382
+ }
383
+ }
384
+ };
385
+ const instantiated = new Set<string>();
386
+ for (const call of file.calls) {
387
+ const target = localFunction(file, call);
388
+ if (target === null || !wrappers.has(target.scope) || target.parameters.length === 0) continue;
389
+ const args = call.arguments.map((arg) =>
390
+ resolveExpression(arg, { file, scope: call.scope, env: new Map() }),
391
+ );
392
+ // Pure forwarding calls are expanded through their concrete callers, not invented as requests.
393
+ if (
394
+ args.length === 0 ||
395
+ args.every((arg) => arg.kind === "name" || arg.kind === "unknown" || arg.kind === "undefined")
396
+ )
397
+ continue;
398
+ instantiated.add(target.scope);
399
+ expand(target, args, provenance(file, call.range, "request", target.name), [], new Set());
400
+ }
401
+ for (const call of transportCalls) {
402
+ if (instantiated.has(call.scope)) continue;
403
+ emit(
404
+ call,
405
+ { file, scope: call.scope, env: new Map() },
406
+ provenance(file, call.range, "request"),
407
+ [],
408
+ );
409
+ }
410
+ return result;
411
+ }