@jskit-ai/architecture-inspector 0.1.1 → 0.1.2

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.
@@ -2,19 +2,15 @@ import { EventEmitter } from "node:events";
2
2
  import { Stats } from "node:fs";
3
3
  import { Duplex } from "node:stream";
4
4
  import ts from "typescript";
5
- import { isFunctionLike, unwrapExpression } from "./functions.js";
5
+ import { isCalledInPlace, isFunctionLike, owningParameter, parameterNames, runsDuring, unwrapExpression } from "./functions.js";
6
6
  import { isClassLike } from "./symbols.js";
7
7
  import { isLiteralPattern } from "./evaluate.js";
8
+ import { NODE_GLOBALS, createExternalValues, isComponentFile } from "./external.js";
8
9
 
9
10
  const MAX_TARGETS = 16;
10
11
  const MAX_TYPE_DEPTH = 6;
11
- // Node globals TypeScript's default libraries do not declare. Calls to them
12
- // are provably external.
13
- const NODE_GLOBALS = new Set(["process", "Buffer", "require", "module", "exports", "__dirname", "__filename", "global", "setImmediate", "clearImmediate"]);
14
- // Vue's `<script setup>` compiler macros are globals of component scripts;
15
- // emit functions and model refs they return are Vue's own values.
12
+ // Vue's `<script setup>` compiler macros are globals of component scripts.
16
13
  const VUE_MACROS = new Set(["defineProps", "defineEmits", "defineExpose", "defineOptions", "defineSlots", "defineModel", "withDefaults"]);
17
- const VUE_EXTERNAL_VALUES = new Set(["defineEmits", "defineModel"]);
18
14
  // Node objects untyped receivers most often hold; TypeScript's libraries do
19
15
  // not declare their members, so the names are read from Node itself.
20
16
  const NODE_MEMBER_SOURCES = Object.freeze([EventEmitter.prototype, Stats.prototype, Duplex.prototype]);
@@ -41,15 +37,6 @@ function codeSide(file) {
41
37
  return "shared";
42
38
  }
43
39
 
44
- function isComponentFile(file) {
45
- return Boolean(file) && file.endsWith(".vue");
46
- }
47
-
48
- function isNullish(expression) {
49
- const value = unwrapExpression(expression);
50
- return value.kind === ts.SyntaxKind.NullKeyword || (ts.isIdentifier(value) && value.text === "undefined");
51
- }
52
-
53
40
  function compatibleSides(left, right) {
54
41
  return left === "shared" || right === "shared" || left === right;
55
42
  }
@@ -78,47 +65,6 @@ function libraryMemberNames(program) {
78
65
  return names;
79
66
  }
80
67
 
81
- // A function that runs while the code around it runs: one passed straight to
82
- // a call, or called in place.
83
- function isCalledInPlace(fn) {
84
- let child = fn;
85
- let parent = fn.parent;
86
- while (parent && ts.isParenthesizedExpression(parent)) {
87
- child = parent;
88
- parent = parent.parent;
89
- }
90
- return Boolean(parent) && (ts.isCallExpression(parent) || ts.isNewExpression(parent)) &&
91
- (parent.expression === child || Boolean(parent.arguments?.includes(child)));
92
- }
93
-
94
- // Whether code at `node` runs while `fn` runs: no function between them is
95
- // stored, returned or declared to run later.
96
- function runsDuring(node, fn) {
97
- for (let current = node.parent; current; current = current.parent) {
98
- if (current === fn) {
99
- return true;
100
- }
101
- if (isFunctionLike(current) && !isCalledInPlace(current)) {
102
- return false;
103
- }
104
- }
105
- return false;
106
- }
107
-
108
- // The names a function's parameters bind, destructured ones included.
109
- function parameterNames(fn) {
110
- const names = new Set();
111
- const visit = (name) => {
112
- if (ts.isIdentifier(name)) {
113
- names.add(name.text);
114
- } else if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) {
115
- name.elements.forEach((element) => ts.isBindingElement(element) && visit(element.name));
116
- }
117
- };
118
- fn.parameters.forEach((parameter) => visit(parameter.name));
119
- return names;
120
- }
121
-
122
68
  function constructorOf(implementation) {
123
69
  if (!isClassLike(implementation)) {
124
70
  return implementation;
@@ -126,16 +72,50 @@ function constructorOf(implementation) {
126
72
  return implementation.members.find((member) => ts.isConstructorDeclaration(member) && member.body) || null;
127
73
  }
128
74
 
129
- // Call resolution over one program: TypeScript checker targets, defineFeature
130
- // injection, argument flow, literal-array unrolling and receiver names, each
131
- // labelled with its certainty. Provably external calls are dropped.
75
+ /**
76
+ * Call resolution over one program: TypeScript checker targets, defineFeature
77
+ * injection, argument flow, literal-array unrolling and receiver names, each
78
+ * labelled with its certainty. Provably external calls are dropped.
79
+ * @param {object} dependencies
80
+ * @param {import("typescript").Program} dependencies.program
81
+ * @param {import("typescript").TypeChecker} dependencies.checker
82
+ * @param {import("./types.js").CallSymbols} dependencies.symbols
83
+ * @param {import("./types.js").CallEvaluator} dependencies.evaluator
84
+ * @param {import("./types.js").FeatureBindings["bindings"]} dependencies.bindings
85
+ * @param {Map<string, import("./types.js").FeatureBindings["actions"]>} dependencies.actionsById
86
+ * @param {import("./types.js").FileOf} dependencies.fileOf
87
+ * @param {Map<string, import("typescript").Node[]>} dependencies.functionNames
88
+ */
132
89
  function createResolver({ program, checker, symbols, evaluator, bindings, actionsById, fileOf, functionNames }) {
133
90
  const builtinMembers = libraryMemberNames(program);
91
+ const { isExternalValue, isExternalDeclaration } = createExternalValues({ checker, symbols, fileOf });
92
+ /** @type {Map<import("typescript").Node, import("typescript").CallExpression[]>} */
134
93
  const callSites = new Map();
135
94
  const direct = new Map();
95
+ // The functions each indexed call reaches, and the calls whose index entry
96
+ // came from a guess (several values or an ambiguous injection).
97
+ /** @type {Map<import("typescript").CallExpression, import("typescript").Node[]>} */
98
+ const indexedTargets = new Map();
99
+ /** @type {Set<import("typescript").CallExpression>} */
100
+ const probableSites = new Set();
136
101
 
137
102
  function implementationsFor(symbol) {
138
- return symbols.symbolImplementations(symbol).map(constructorOf).filter(Boolean);
103
+ const found = symbols.symbolImplementations(symbol).map(constructorOf).filter(Boolean);
104
+ return found.length ? found : declaredMemberImplementations(symbol);
105
+ }
106
+
107
+ // A member a type declares without a value (`{ run: typeof run }` or
108
+ // `@property {typeof run} run`) holds the function or class its type names.
109
+ function declaredMemberImplementations(symbol) {
110
+ const declarations = symbol?.declarations || [];
111
+ if (!declarations.length || !declarations.every((declaration) => ts.isPropertySignature(declaration) || ts.isJSDocPropertyTag(declaration))) {
112
+ return [];
113
+ }
114
+ const type = checker.getTypeOfSymbol(symbol);
115
+ if (!type.getCallSignatures().length && !type.getConstructSignatures().length) {
116
+ return [];
117
+ }
118
+ return symbols.symbolImplementations(type.getSymbol()).map(constructorOf).filter(Boolean);
139
119
  }
140
120
 
141
121
  // Pass 1: what the checker resolves on its own, and the call-site index
@@ -179,14 +159,26 @@ function createResolver({ program, checker, symbols, evaluator, bindings, action
179
159
  return { kind: "other" };
180
160
  }
181
161
 
182
- // Records that a call reaches these functions, for argument flow.
183
- function indexSite(call, targets) {
162
+ // Records that a call reaches these functions, for argument flow. A
163
+ // probable site's values are never exact.
164
+ function indexSite(call, targets, probable = false) {
165
+ const known = indexedTargets.get(call) || [];
184
166
  for (const target of targets) {
167
+ if (known.includes(target)) {
168
+ continue;
169
+ }
170
+ known.push(target);
185
171
  if (!callSites.has(target)) {
186
172
  callSites.set(target, []);
187
173
  }
188
174
  callSites.get(target).push(call);
189
175
  }
176
+ if (known.length) {
177
+ indexedTargets.set(call, known);
178
+ }
179
+ if (probable) {
180
+ probableSites.add(call);
181
+ }
190
182
  }
191
183
 
192
184
  // The checker's own targets of each call; returns them per call.
@@ -199,28 +191,6 @@ function createResolver({ program, checker, symbols, evaluator, bindings, action
199
191
  });
200
192
  }
201
193
 
202
- // The function (and parameter position) a parameter or destructured
203
- // parameter element belongs to, with its property path.
204
- function owningParameter(declaration) {
205
- const pathParts = [];
206
- let current = declaration;
207
- while (current && ts.isBindingElement(current)) {
208
- if (!ts.isObjectBindingPattern(current.parent)) {
209
- return null;
210
- }
211
- const key = current.propertyName || current.name;
212
- if (!ts.isIdentifier(key) && !ts.isStringLiteralLike(key)) {
213
- return null;
214
- }
215
- pathParts.unshift(key.text);
216
- current = current.parent.parent;
217
- }
218
- if (!current || !ts.isParameter(current) || !isFunctionLike(current.parent)) {
219
- return null;
220
- }
221
- return { fn: current.parent, index: current.parent.parameters.indexOf(current), path: pathParts };
222
- }
223
-
224
194
  // The value one call gives a parameter: the argument, or the property of an
225
195
  // object literal argument a destructured parameter takes, with the rest of
226
196
  // its property path.
@@ -242,7 +212,13 @@ function createResolver({ program, checker, symbols, evaluator, bindings, action
242
212
 
243
213
  function argumentValues(declaration) {
244
214
  const owner = owningParameter(declaration);
245
- return owner ? (callSites.get(owner.fn) || []).map((call) => argumentValue(call, owner)).filter(Boolean) : [];
215
+ if (!owner) {
216
+ return [];
217
+ }
218
+ return (callSites.get(owner.fn) || []).map((call) => {
219
+ const value = argumentValue(call, owner);
220
+ return value && { ...value, probable: probableSites.has(call) };
221
+ }).filter(Boolean);
246
222
  }
247
223
 
248
224
  function propertyValueExpressions(property) {
@@ -389,11 +365,24 @@ function createResolver({ program, checker, symbols, evaluator, bindings, action
389
365
  return { resolution: "unresolved", certainty: "unresolved", method: "none", targets: [] };
390
366
  }
391
367
 
392
- // The functions a passed value can be, added to `targets`.
393
- function addValueTargets(value, targets, exact = false) {
368
+ // The functions a passed value can be, added to `targets`. With `passedOn`,
369
+ // the path of parameters followed so far, a parameter the value passes on
370
+ // holds what its own callers give it; a callback a helper runs (passedCall)
371
+ // is never followed to another caller's value.
372
+ function addValueTargets(value, targets, exact = false, passedOn = null) {
394
373
  const found = value.path.length
395
374
  ? [...targetsFromTypes(valueTypes({ expression: value.expression, path: value.path.slice(0, -1) }, 0, new Set()), [value.path.at(-1)]).targets.keys()]
396
375
  : symbols.expressionImplementations(value.expression, 0).map(constructorOf).filter(Boolean);
376
+ const expression = unwrapExpression(value.expression);
377
+ const parameter = passedOn && !found.length && !value.path.length && ts.isIdentifier(expression) ? symbols.declarationOf(expression) : null;
378
+ if (parameter && (ts.isParameter(parameter) || ts.isBindingElement(parameter)) &&
379
+ !passedOn.includes(parameter) && passedOn.length < MAX_TYPE_DEPTH) {
380
+ const values = argumentValues(parameter);
381
+ for (const passed of values) {
382
+ addValueTargets(passed, targets, exact && values.length === 1 && !passed.probable, [...passedOn, parameter]);
383
+ }
384
+ return;
385
+ }
397
386
  for (const target of found) {
398
387
  targets.set(target, { exact: exact && found.length === 1 && !value.path.length, via: "argument-flow" });
399
388
  }
@@ -419,7 +408,7 @@ function createResolver({ program, checker, symbols, evaluator, bindings, action
419
408
  }
420
409
  const values = argumentValues(declaration);
421
410
  for (const value of values) {
422
- addValueTargets(value, targets, values.length === 1);
411
+ addValueTargets(value, targets, values.length === 1 && !value.probable, []);
423
412
  }
424
413
  return targets;
425
414
  }
@@ -431,6 +420,46 @@ function createResolver({ program, checker, symbols, evaluator, bindings, action
431
420
  return direct.get(call).targets || [];
432
421
  }
433
422
 
423
+ // The functions a call reaches as the index knows them: the checker's, or
424
+ // those injection or argument flow resolved.
425
+ function siteTargets(call) {
426
+ return indexedTargets.get(call) || checkerTargets(call);
427
+ }
428
+
429
+ // The calls whose callee is a parameter or a destructured name: those
430
+ // injection or argument flow can resolve. What a call's callee is declared
431
+ // as depends only on its own file.
432
+ function boundCandidates(calls) {
433
+ return calls.filter((call) => {
434
+ checkerTargets(call);
435
+ const declaration = direct.get(call).declaration;
436
+ return declaration && (ts.isParameter(declaration) || ts.isBindingElement(declaration));
437
+ });
438
+ }
439
+
440
+ // Calls the checker cannot resolve but injection or argument flow can are
441
+ // call sites too, so argument flow and the callbacks a call passes reach
442
+ // through them. Each round can expose more; the targets and guesses only
443
+ // grow, so the rounds stop. Takes the candidates of every file, since one
444
+ // file's arguments decide another's targets, and returns each candidate's
445
+ // targets and whether they are a guess.
446
+ function indexBoundCalls(candidates) {
447
+ for (let changed = true; changed;) {
448
+ changed = false;
449
+ for (const call of candidates) {
450
+ const found = boundFunctionTargets(direct.get(call).declaration, call);
451
+ const targets = found.size > MAX_TARGETS ? [] : [...found.keys()];
452
+ const probable = targets.length !== 1 || !found.get(targets[0]).exact;
453
+ const known = indexedTargets.get(call) || [];
454
+ if (targets.some((target) => !known.includes(target)) || (probable && targets.length && !probableSites.has(call))) {
455
+ indexSite(call, targets, probable);
456
+ changed = true;
457
+ }
458
+ }
459
+ }
460
+ return candidates.map((call) => ({ targets: indexedTargets.get(call) || [], probable: probableSites.has(call) }));
461
+ }
462
+
434
463
  // The parameters ({ index, path }) a function calls while it runs: in its
435
464
  // own body, in callbacks it passes on, or by forwarding them to a function
436
465
  // that does. A cycle adds nothing.
@@ -462,7 +491,7 @@ function createResolver({ program, checker, symbols, evaluator, bindings, action
462
491
  }
463
492
  node.arguments.forEach((argument, position) => {
464
493
  const forwarded = own(argument);
465
- for (const target of forwarded ? checkerTargets(node) : []) {
494
+ for (const target of forwarded ? siteTargets(node) : []) {
466
495
  for (const inner of invokedParameters(target)) {
467
496
  if (inner.index === position) {
468
497
  add(forwarded.index, [...forwarded.path, ...inner.path]);
@@ -486,11 +515,11 @@ function createResolver({ program, checker, symbols, evaluator, bindings, action
486
515
  return null;
487
516
  }
488
517
  const targets = new Map();
489
- for (const callee of checkerTargets(call)) {
518
+ for (const callee of siteTargets(call)) {
490
519
  for (const parameter of invokedParameters(callee)) {
491
520
  const value = argumentValue(call, parameter);
492
521
  if (value) {
493
- addValueTargets(value, targets, true);
522
+ addValueTargets(value, targets, !probableSites.has(call));
494
523
  }
495
524
  }
496
525
  }
@@ -498,96 +527,6 @@ function createResolver({ program, checker, symbols, evaluator, bindings, action
498
527
  return result?.resolution === "unresolved" ? null : result;
499
528
  }
500
529
 
501
- // Whether a value is provably produced by a package or builtin.
502
- function isExternalValue(expression, depth = 0) {
503
- const root = symbols.receiverRoot(expression);
504
- if (!root || depth > MAX_TYPE_DEPTH) {
505
- return false;
506
- }
507
- if (ts.isIdentifier(root)) {
508
- if (symbols.isPackageImport(root)) {
509
- return true;
510
- }
511
- const symbol = symbols.symbolAt(root);
512
- if (!symbol) {
513
- return NODE_GLOBALS.has(root.text) || (isComponentFile(fileOf(root)) && VUE_EXTERNAL_VALUES.has(root.text));
514
- }
515
- if (symbols.isLibrarySymbol(symbol)) {
516
- return true;
517
- }
518
- const declaration = symbols.declarationOf(root);
519
- return Boolean(declaration) && isExternalDeclaration(declaration, depth + 1);
520
- }
521
- return ts.isStringLiteralLike(root) || ts.isArrayLiteralExpression(root) || ts.isRegularExpressionLiteral(root) ||
522
- ts.isTemplateExpression(root) || ts.isNumericLiteral(root);
523
- }
524
-
525
- // Whether every value a declared name can hold comes from a package or
526
- // builtin: its initializer and later assignments, the iterable it is taken
527
- // from, the value it is destructured from, or the external call a callback
528
- // parameter is supplied by.
529
- function isExternalDeclaration(declaration, depth) {
530
- let current = declaration;
531
- while (current && ts.isBindingElement(current)) {
532
- current = current.parent.parent;
533
- }
534
- if (!current || depth > MAX_TYPE_DEPTH) {
535
- return false;
536
- }
537
- if (ts.isParameter(current)) {
538
- const fn = current.parent;
539
- const call = fn.parent;
540
- return (ts.isCallExpression(call) || ts.isNewExpression(call)) && Boolean(call.arguments?.includes(fn)) &&
541
- isExternalCallee(call.expression, depth + 1);
542
- }
543
- if (!ts.isVariableDeclaration(current)) {
544
- return false;
545
- }
546
- const statement = current.parent?.parent;
547
- if (statement && ts.isForOfStatement(statement) && statement.initializer === current.parent) {
548
- return isExternalValue(statement.expression, depth + 1);
549
- }
550
- const values = [current.initializer, ...assignedValues(current)].filter((value) => value && !isNullish(value));
551
- return values.length > 0 && values.every((value) => isExternalValue(value, depth + 1));
552
- }
553
-
554
- function isExternalCallee(expression, depth) {
555
- const callee = unwrapExpression(expression);
556
- if (ts.isIdentifier(callee)) {
557
- const symbol = symbols.symbolAt(callee);
558
- return symbol ? symbols.isPackageImport(callee) || symbols.isLibrarySymbol(symbol) : NODE_GLOBALS.has(callee.text);
559
- }
560
- return ts.isPropertyAccessExpression(callee) && isExternalValue(callee.expression, depth);
561
- }
562
-
563
- // The values later assigned to a `let` or `var` name within its scope.
564
- const assignments = new Map();
565
- function assignedValues(declaration) {
566
- if (!ts.isIdentifier(declaration.name) || declaration.parent.flags & ts.NodeFlags.Const) {
567
- return [];
568
- }
569
- if (!assignments.has(declaration)) {
570
- const symbol = checker.getSymbolAtLocation(declaration.name);
571
- let scope = declaration.parent;
572
- while (scope && !isFunctionLike(scope) && !ts.isSourceFile(scope)) {
573
- scope = scope.parent;
574
- }
575
- const values = [];
576
- const visit = (node) => {
577
- if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
578
- ts.isIdentifier(node.left) && node.left.text === declaration.name.text && checker.getSymbolAtLocation(node.left) === symbol) {
579
- values.push(node.right);
580
- }
581
- ts.forEachChild(node, visit);
582
- };
583
- if (scope) {
584
- visit(scope);
585
- }
586
- assignments.set(declaration, values);
587
- }
588
- return assignments.get(declaration);
589
- }
590
-
591
530
  function receiverHint(receiver, candidates) {
592
531
  const text = receiver?.getText().split(".").pop()?.replace(/Service$/u, "").toLowerCase();
593
532
  if (!text || !/^[a-z][\w]*$/u.test(text)) {
@@ -713,7 +652,7 @@ function createResolver({ program, checker, symbols, evaluator, bindings, action
713
652
  }
714
653
  }
715
654
 
716
- return { indexCalls, indexSite, resolve, passedCall, builtinMembers };
655
+ return { indexCalls, indexSite, boundCandidates, indexBoundCalls, resolve, passedCall, builtinMembers };
717
656
  }
718
657
 
719
658
  export { codeSide, createResolver };
@@ -88,6 +88,9 @@ function createSymbols(context) {
88
88
  if (ts.isShorthandPropertyAssignment(declaration)) {
89
89
  return symbolImplementations(checker.getShorthandAssignmentValueSymbol(declaration), depth + 1);
90
90
  }
91
+ if (ts.isBindingElement(declaration)) {
92
+ return bindingElementImplementations(declaration, depth + 1);
93
+ }
91
94
  if (ts.isExportSpecifier(declaration) || ts.isImportSpecifier(declaration) || ts.isImportClause(declaration)) {
92
95
  return symbolImplementations(checker.getSymbolAtLocation(declaration.name), depth + 1);
93
96
  }
@@ -104,6 +107,26 @@ function createSymbols(context) {
104
107
  return [];
105
108
  }
106
109
 
110
+ // A name an object pattern binds (`const { a: b } = source`, a parameter's
111
+ // pattern, or one nested in another) holds the property it reads from the
112
+ // type of what the pattern destructures.
113
+ function bindingElementImplementations(element, depth) {
114
+ const key = element.propertyName || element.name;
115
+ if (!ts.isObjectBindingPattern(element.parent) || element.dotDotDotToken || !(ts.isIdentifier(key) || ts.isStringLiteralLike(key))) {
116
+ return [];
117
+ }
118
+ const property = checker.getTypeAtLocation(element.parent).getProperty(key.text);
119
+ const found = new Set();
120
+ for (const declaration of property?.declarations || []) {
121
+ if (declaration !== element) {
122
+ for (const implementation of declarationImplementations(declaration, depth)) {
123
+ found.add(implementation);
124
+ }
125
+ }
126
+ }
127
+ return [...found];
128
+ }
129
+
107
130
  function expressionImplementations(expression, depth) {
108
131
  const value = unwrapExpression(expression);
109
132
  if (!value || depth > MAX_DEPTH) {
@@ -0,0 +1,9 @@
1
+ // The collaborators the call layer's factories hand each other over one
2
+ // loaded call program.
3
+
4
+ /** @typedef {ReturnType<typeof import("./program.js").loadCallProgram>["fileOf"]} FileOf */
5
+ /** @typedef {ReturnType<typeof import("./symbols.js").createSymbols>} CallSymbols */
6
+ /** @typedef {ReturnType<typeof import("./evaluate.js").createEvaluator>} CallEvaluator */
7
+ /** @typedef {ReturnType<typeof import("./features.js").bindFeatures>} FeatureBindings */
8
+
9
+ export {};