@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.
package/README.md CHANGED
@@ -17,17 +17,22 @@ Run `jskit-inspect-vue` in the project directory. It reads one bounded JSON
17
17
  document from stdin and writes one `genesis.architecture-observations.v1` JSON
18
18
  document to stdout. Errors use stderr and a failing exit status. It does not
19
19
  import application code, start a server, generate router declarations or retain
20
- a watcher. The package has no Genesis or Vibe64 dependency. The command runs the
20
+ a watcher. The package imports no consumer's code: the input contract is its
21
+ own, and it writes the observation format its consumer defines, which it names
22
+ only by schema id. The command runs the
21
23
  inspection in a child Node process whose old generation is bounded by the size
22
24
  of the declared scripts (192 MB plus 56 MB per MiB), so memory stays near what
23
25
  the inspection holds; a caller that starts Node with its own
24
26
  `--max-old-space-size` keeps that bound and runs in place.
25
27
 
26
- Input supplies `schema: "vibe64.architecture-inspection-input.v1"`, indexed
28
+ Input supplies `schema: "jskit.architecture-inspection-input.v1"`, indexed
27
29
  `files: [{ path, hash }]` and `uiDiscovery.discoveries`. Optional `packages`
28
30
  carry `id`, `name`, `manifest`, `declarations`, `root`, `opaque` and public
29
31
  `entryPoints: [{ importPath, file }]`; `packageIdentity` binds observations to
30
- the same inspected public boundaries. Paths are project-relative
32
+ the same inspected public boundaries. A package resolves for the files under
33
+ each manifest that declares it (`declarations: [{ manifest }]`, or its
34
+ `manifest` when it has none), whatever the dependency kind, so the packages of
35
+ a workspace resolve one another when each workspace manifest is declared. Paths are project-relative
31
36
  and hashes use `sha256:<hex>`. Only declared, hash-matching UTF-8 files are read;
32
37
  symlinks and files over 4 MiB are rejected. Optional `stackHash` and
33
38
  `cacheDirectory` serve the call cache below; other unknown fields are ignored.
@@ -56,11 +61,20 @@ Each discovery supplies `id`, `adapter`, `surface`, `mode`, `registrations`,
56
61
  not evidence of runtime calls. Coverage remains partial; namespace use does
57
62
  not establish which individual exports are consumed.
58
63
  - An HTTP scope names the application's operations and enables scoped literal
59
- `fetch` uses and receivers registered through JSKIT's `defineFeature` setup
60
- `http.router.register`. It is the root `package.json` `name`, or
61
- `--http-scope shop` when the operations should carry another name; without
62
- either, HTTP observations stay unconfigured. `--api-base-path /api` prefixes
63
- `http.router.register` paths. Use `--alias @=src` for declared project aliases.
64
+ `fetch` uses and the routes a `defineFeature` setup registers on its HTTP
65
+ router. It is the root `package.json` `name`, or `--http-scope shop` when
66
+ the operations should carry another name; without either, HTTP observations
67
+ stay unconfigured. Use `--alias @=src` for declared project aliases.
68
+ - `router.register(method, path, options, handler)` becomes an entry operation
69
+ whose controller is the handler, when the router is `http.router` of the
70
+ setup's destructured `http` dependency (or `dependencies.http.router`), used
71
+ in the setup or passed to a project function that registers on the parameter
72
+ receiving it. Such functions may be local or imported through named imports
73
+ and re-exports, at most four calls deep. The method and path must be
74
+ literals, local constants or template literals of them; a call of the
75
+ kernel's `resolveScopedApiBasePath` with literal options stands for the path
76
+ it returns. Anything else is reported as `CONTROLLER_UNRESOLVED`.
77
+ `--api-base-path /api` prefixes these paths.
64
78
  - Actual `CrudListScreen` / `CrudAddEditScreen` uses whose bound screen comes from
65
79
  the matching JSKIT composable can connect to exported `defineCrudJsonApiFeature`
66
80
  controller declarations. The resource must resolve through indexed imports and
@@ -70,6 +84,13 @@ Each discovery supplies `id`, `adapter`, `surface`, `mode`, `registrations`,
70
84
  than claiming a fully expanded runtime URL. Custom clients, transport/resource
71
85
  overrides, dynamic operations and disabled routes do not create guessed links.
72
86
  This shows declared entry points; runtime provider activation is not proven.
87
+ Each such entry operation carries the `actionId` its route runs,
88
+ `crud.<namespace>.<operation>`. The declaration's custom `actions` (a literal
89
+ object, bound locally or imported through named re-exports) that declare a
90
+ literal `route` become `http` entry operations at the declaration's API base
91
+ path (`scope.routeBase` and `relativePath`) plus the route's `path`, with the
92
+ action's `id`, or `crud.<namespace>.<name>` without one. Other forms are reported
93
+ as `CRUD_ACTION_ROUTE_UNRESOLVED`.
73
94
 
74
95
  - Routes registered through the kernel's `createActionRouteScope` become entry
75
96
  operations with their full path and, for `actionRoute`, their `actionId`.
@@ -108,10 +129,19 @@ Each discovery supplies `id`, `adapter`, `surface`, `mode`, `registrations`,
108
129
  destructured factory parameter such as `createActions({ sessions })`)
109
130
  that receives one value across all the project's calls, when that value
110
131
  resolves exactly. Several values stay probable.
132
+ - JSDoc types are declared types: `@param`, `@type`, `@typedef` and
133
+ `import("...")` types, including a package's `./server/types` reached
134
+ through its declared entry points. A call through a parameter or binding
135
+ typed that way is a `checker` call. A member a type declares as `typeof` a
136
+ function (`{ run: typeof run }`, `@property {typeof run} run`) is that
137
+ function.
111
138
  - Action definitions (`{ id, execute }` in a feature's `actions`, also through
112
- pass-through wrappers and literal-array `.map`) become `action` entry
113
- points; `request.executeAction({ actionId: "..." })` reaches their execute
114
- function, and each action route's `controllerFunctionId` names it.
139
+ `create*Actions` factories, pass-through wrappers and literal-array `.map`,
140
+ with ids that are literals or module constants) and the custom actions of a
141
+ CRUD declaration become `action` entry points;
142
+ `request.executeAction({ actionId: "..." })` reaches their execute
143
+ function, and the `controllerFunctionId` of each entry operation that names
144
+ an action is that function.
115
145
  - Calls that are provably external are left out: package imports, builtins,
116
146
  values produced by them (including `let` assignments, `for...of`,
117
147
  destructuring and callbacks they call), Vue's `<script setup>` macros and
@@ -136,7 +166,10 @@ Each discovery supplies `id`, `adapter`, `surface`, `mode`, `registrations`,
136
166
  - **Cache.** With an absolute, existing `cacheDirectory` outside the project,
137
167
  the command keeps `jskit-calls.json` there. An unchanged rerun reuses the
138
168
  whole result. Otherwise a file keeps its calls when its own and its directly
139
- imported sources are unchanged and every target still exists. Any change to a
169
+ imported sources are unchanged and every target still exists. Calls that
170
+ injection or argument flow resolve depend on other files' arguments, so they
171
+ are resolved again on every run; a file whose such calls now reach other
172
+ functions is resolved again, with every reused file whose calls reach it. Any change to a
140
173
  manifest, lockfile, `tsconfig`/`jsconfig`, the inspector's own manifest or
141
174
  source (even without a version bump), packages, UI discovery, options or
142
175
  `stackHash` rebuilds everything. An absent, unusable or corrupt cache means a
@@ -145,7 +178,7 @@ Each discovery supplies `id`, `adapter`, `surface`, `mode`, `registrations`,
145
178
  ### Preview locator
146
179
 
147
180
  `src/previewLocator.js` is a classic script a Preview can inline. It defines
148
- `globalThis.__vibe64LocateElement(element)`, which returns `{ file, name? }` for
181
+ `globalThis.__jskitLocateElement(element)`, which returns `{ file, name? }` for
149
182
  the nearest component with a source file (Vue development builds record it as
150
183
  `__file`) that rendered the element, or `null` for plain DOM. It reads only
151
184
  Vue's component bookkeeping, never text content or values.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/architecture-inspector",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Static Vue architecture inspector for the portable architecture-inspection command contract.",
5
5
  "type": "module",
6
6
  "files": [
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@babel/parser": "7.29.9",
21
- "@jskit-ai/kernel": "0.2.3",
21
+ "@jskit-ai/kernel": "0.2.4",
22
22
  "@vue/compiler-sfc": "3.5.44",
23
23
  "typescript": "6.0.3"
24
24
  },
@@ -4,8 +4,9 @@ import { readFile, rename, stat, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
- // v2: calls a function makes through the callbacks it passes are its own records.
8
- const CACHE_SCHEMA = "jskit.architecture-calls-cache.v2";
7
+ // v3: each file keeps its checker sites, the candidates injection or argument
8
+ // flow can resolve, and the sites they resolved to, apart.
9
+ const CACHE_SCHEMA = "jskit.architecture-calls-cache.v3";
9
10
  const CACHE_FILE = "jskit-calls.json";
10
11
  // Manifests, lockfiles and compiler configurations change how every file
11
12
  // resolves, so any change to them rebuilds everything.
@@ -40,6 +40,11 @@ function normalizedSegment(value) {
40
40
  return value.startsWith("/") || value.startsWith(ANY) || value === "" ? value : `/${value}`;
41
41
  }
42
42
 
43
+ /**
44
+ * @param {object} dependencies
45
+ * @param {import("./types.js").CallSymbols} dependencies.symbols
46
+ * @param {import("./types.js").FileOf} dependencies.fileOf
47
+ */
43
48
  function createEvaluator({ symbols, fileOf }) {
44
49
  const unrolled = new Map();
45
50
 
@@ -0,0 +1,123 @@
1
+ import ts from "typescript";
2
+ import { isFunctionLike, unwrapExpression } from "./functions.js";
3
+
4
+ const MAX_DEPTH = 6;
5
+ // Node globals TypeScript's default libraries do not declare. Calls to them
6
+ // are provably external.
7
+ const NODE_GLOBALS = new Set(["process", "Buffer", "require", "module", "exports", "__dirname", "__filename", "global", "setImmediate", "clearImmediate"]);
8
+ // Emit functions and model refs Vue's `<script setup>` macros return are
9
+ // Vue's own values.
10
+ const VUE_EXTERNAL_VALUES = new Set(["defineEmits", "defineModel"]);
11
+
12
+ function isComponentFile(file) {
13
+ return Boolean(file) && file.endsWith(".vue");
14
+ }
15
+
16
+ function isNullish(expression) {
17
+ const value = unwrapExpression(expression);
18
+ return value.kind === ts.SyntaxKind.NullKeyword || (ts.isIdentifier(value) && value.text === "undefined");
19
+ }
20
+
21
+ /**
22
+ * Whether values are provably produced by a package or builtin, so calls on
23
+ * them leave the project.
24
+ * @param {object} dependencies
25
+ * @param {import("typescript").TypeChecker} dependencies.checker
26
+ * @param {import("./types.js").CallSymbols} dependencies.symbols
27
+ * @param {import("./types.js").FileOf} dependencies.fileOf
28
+ */
29
+ function createExternalValues({ checker, symbols, fileOf }) {
30
+ // Whether a value is provably produced by a package or builtin.
31
+ function isExternalValue(expression, depth = 0) {
32
+ const root = symbols.receiverRoot(expression);
33
+ if (!root || depth > MAX_DEPTH) {
34
+ return false;
35
+ }
36
+ if (ts.isIdentifier(root)) {
37
+ if (symbols.isPackageImport(root)) {
38
+ return true;
39
+ }
40
+ const symbol = symbols.symbolAt(root);
41
+ if (!symbol) {
42
+ return NODE_GLOBALS.has(root.text) || (isComponentFile(fileOf(root)) && VUE_EXTERNAL_VALUES.has(root.text));
43
+ }
44
+ if (symbols.isLibrarySymbol(symbol)) {
45
+ return true;
46
+ }
47
+ const declaration = symbols.declarationOf(root);
48
+ return Boolean(declaration) && isExternalDeclaration(declaration, depth + 1);
49
+ }
50
+ return ts.isStringLiteralLike(root) || ts.isArrayLiteralExpression(root) || ts.isRegularExpressionLiteral(root) ||
51
+ ts.isTemplateExpression(root) || ts.isNumericLiteral(root);
52
+ }
53
+
54
+ // Whether every value a declared name can hold comes from a package or
55
+ // builtin: its initializer and later assignments, the iterable it is taken
56
+ // from, the value it is destructured from, or the external call a callback
57
+ // parameter is supplied by.
58
+ function isExternalDeclaration(declaration, depth) {
59
+ let current = declaration;
60
+ while (current && ts.isBindingElement(current)) {
61
+ current = current.parent.parent;
62
+ }
63
+ if (!current || depth > MAX_DEPTH) {
64
+ return false;
65
+ }
66
+ if (ts.isParameter(current)) {
67
+ const fn = current.parent;
68
+ const call = fn.parent;
69
+ return (ts.isCallExpression(call) || ts.isNewExpression(call)) && Boolean(call.arguments?.includes(fn)) &&
70
+ isExternalCallee(call.expression, depth + 1);
71
+ }
72
+ if (!ts.isVariableDeclaration(current)) {
73
+ return false;
74
+ }
75
+ const statement = current.parent?.parent;
76
+ if (statement && ts.isForOfStatement(statement) && statement.initializer === current.parent) {
77
+ return isExternalValue(statement.expression, depth + 1);
78
+ }
79
+ const values = [current.initializer, ...assignedValues(current)].filter((value) => value && !isNullish(value));
80
+ return values.length > 0 && values.every((value) => isExternalValue(value, depth + 1));
81
+ }
82
+
83
+ function isExternalCallee(expression, depth) {
84
+ const callee = unwrapExpression(expression);
85
+ if (ts.isIdentifier(callee)) {
86
+ const symbol = symbols.symbolAt(callee);
87
+ return symbol ? symbols.isPackageImport(callee) || symbols.isLibrarySymbol(symbol) : NODE_GLOBALS.has(callee.text);
88
+ }
89
+ return ts.isPropertyAccessExpression(callee) && isExternalValue(callee.expression, depth);
90
+ }
91
+
92
+ // The values later assigned to a `let` or `var` name within its scope.
93
+ const assignments = new Map();
94
+ function assignedValues(declaration) {
95
+ if (!ts.isIdentifier(declaration.name) || declaration.parent.flags & ts.NodeFlags.Const) {
96
+ return [];
97
+ }
98
+ if (!assignments.has(declaration)) {
99
+ const symbol = checker.getSymbolAtLocation(declaration.name);
100
+ let scope = declaration.parent;
101
+ while (scope && !isFunctionLike(scope) && !ts.isSourceFile(scope)) {
102
+ scope = scope.parent;
103
+ }
104
+ const values = [];
105
+ const visit = (node) => {
106
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
107
+ ts.isIdentifier(node.left) && node.left.text === declaration.name.text && checker.getSymbolAtLocation(node.left) === symbol) {
108
+ values.push(node.right);
109
+ }
110
+ ts.forEachChild(node, visit);
111
+ };
112
+ if (scope) {
113
+ visit(scope);
114
+ }
115
+ assignments.set(declaration, values);
116
+ }
117
+ return assignments.get(declaration);
118
+ }
119
+
120
+ return Object.freeze({ isExternalValue, isExternalDeclaration });
121
+ }
122
+
123
+ export { NODE_GLOBALS, createExternalValues, isComponentFile };
@@ -2,7 +2,13 @@ import ts from "typescript";
2
2
  import { isFunctionLike, unwrapExpression } from "./functions.js";
3
3
 
4
4
  const FEATURE_PACKAGE = "@jskit-ai/kernel";
5
- const MAX_ACTION_DEPTH = 6;
5
+ const CRUD_PACKAGE = "@jskit-ai/crud-core";
6
+ const CRUD_RESOURCE_PACKAGE = "@jskit-ai/resource-crud-core";
7
+ // How many arrays, spreads, conditionals, calls and functions an action
8
+ // definition may sit under in a feature's actions() result: enough for a
9
+ // flagged spread of a create*Actions call whose definitions are each frozen.
10
+ const MAX_ACTION_DEPTH = 10;
11
+ const MAX_OBJECT_DEPTH = 8;
6
12
 
7
13
  function memberNamed(objectLiteral, name) {
8
14
  return objectLiteral.properties.find((property) =>
@@ -14,15 +20,28 @@ function isActionObject(node) {
14
20
  Boolean(memberNamed(node, "id")) && Boolean(memberNamed(node, "execute"));
15
21
  }
16
22
 
17
- // JSKIT's defineFeature dependency injection, read statically:
18
- // requires/optional tokens, provides tokens, setup outputs and the actions()
19
- // definitions. Feature setup and actions parameters are bound to the
20
- // expressions their providers return. One provider is exact; several are
21
- // ambiguous; none leaves the parameter unbound.
23
+ /**
24
+ * JSKIT's defineFeature dependency injection, read statically:
25
+ * requires/optional tokens, provides tokens, setup outputs and the actions()
26
+ * definitions. Feature setup and actions parameters are bound to the
27
+ * expressions their providers return. One provider is exact; several are
28
+ * ambiguous; none leaves the parameter unbound.
29
+ * @param {object} dependencies
30
+ * @param {import("typescript").CallExpression[]} dependencies.calls
31
+ * @param {import("./types.js").CallSymbols} dependencies.symbols
32
+ * @param {import("./types.js").CallEvaluator} dependencies.evaluator
33
+ */
22
34
  function bindFeatures({ calls, symbols, evaluator }) {
23
35
  const features = [];
36
+ const crudDeclarations = [];
24
37
  for (const call of calls) {
25
38
  const callee = unwrapExpression(call.expression);
39
+ if (ts.isIdentifier(callee) && symbols.isImportOf(callee, CRUD_PACKAGE, ["defineCrudJsonApiFeature"])) {
40
+ const config = objectLiteral(call.arguments[0], symbols);
41
+ if (config) {
42
+ crudDeclarations.push(config);
43
+ }
44
+ }
26
45
  if (!ts.isIdentifier(callee) || !symbols.isImportOf(callee, FEATURE_PACKAGE, ["defineFeature"])) {
27
46
  continue;
28
47
  }
@@ -141,7 +160,98 @@ function bindFeatures({ calls, symbols, evaluator }) {
141
160
  }
142
161
  }
143
162
 
144
- return { features, bindings, actions: collectFeatureActions(features, { symbols, evaluator }) };
163
+ return {
164
+ features,
165
+ bindings,
166
+ actions: [...collectFeatureActions(features, { symbols, evaluator }), ...collectCrudActions(crudDeclarations, { symbols, evaluator })]
167
+ };
168
+ }
169
+
170
+ // The object literal an expression holds, through const bindings, imports and
171
+ // `Object.freeze`, or null.
172
+ function objectLiteral(expression, symbols, depth = 0) {
173
+ let value = unwrapExpression(expression);
174
+ if (value && ts.isCallExpression(value) && value.arguments.length === 1 && ts.isPropertyAccessExpression(value.expression) &&
175
+ value.expression.name.text === "freeze" && ts.isIdentifier(value.expression.expression) && value.expression.expression.text === "Object") {
176
+ value = unwrapExpression(value.arguments[0]);
177
+ }
178
+ if (!value || depth > MAX_OBJECT_DEPTH) {
179
+ return null;
180
+ }
181
+ if (ts.isObjectLiteralExpression(value)) {
182
+ return value;
183
+ }
184
+ const declaration = ts.isIdentifier(value) ? symbols.declarationOf(value) : null;
185
+ return declaration && ts.isVariableDeclaration(declaration) ? objectLiteral(declaration.initializer, symbols, depth + 1) : null;
186
+ }
187
+
188
+ function memberValue(member) {
189
+ if (!member) {
190
+ return null;
191
+ }
192
+ if (ts.isPropertyAssignment(member)) {
193
+ return member.initializer;
194
+ }
195
+ return ts.isShorthandPropertyAssignment(member) ? member.name : null;
196
+ }
197
+
198
+ /**
199
+ * The functions an action's `execute` member runs.
200
+ * @param {import("typescript").Node} member
201
+ * @param {import("./types.js").CallSymbols} symbols
202
+ */
203
+ function executeFunctions(member, symbols) {
204
+ if (isFunctionLike(member)) {
205
+ return [member];
206
+ }
207
+ if (ts.isPropertyAssignment(member)) {
208
+ return symbols.expressionImplementations(member.initializer, 0).filter(isFunctionLike);
209
+ }
210
+ if (ts.isShorthandPropertyAssignment(member)) {
211
+ return symbols.symbolImplementations(symbols.symbolAt(member.name)).filter(isFunctionLike);
212
+ }
213
+ return [];
214
+ }
215
+
216
+ // The custom actions a CRUD declaration adds (`actions: { name: { execute } }`).
217
+ // Each runs its own execute; its id is the declared `id`, or
218
+ // `crud.<resource namespace>.<name>` as crud-core names it.
219
+ function collectCrudActions(declarations, { symbols, evaluator }) {
220
+ const actions = [];
221
+ for (const config of declarations) {
222
+ const definitions = objectLiteral(memberValue(memberNamed(config, "actions")), symbols);
223
+ for (const property of definitions?.properties || []) {
224
+ const name = ts.isPropertyAssignment(property) && (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)) ? property.name.text : "";
225
+ const object = name ? objectLiteral(property.initializer, symbols) : null;
226
+ const execute = object && memberNamed(object, "execute");
227
+ if (!execute) {
228
+ continue;
229
+ }
230
+ // A declared id that is not a literal stays unresolved.
231
+ const idMember = memberNamed(object, "id");
232
+ const declared = idMember ? evaluator.evaluateLiteral(memberValue(idMember))?.trim() : "";
233
+ const namespace = declared === "" ? crudNamespace(config, { symbols, evaluator }) : null;
234
+ const actionId = declared || (namespace ? `crud.${namespace}.${name}` : null);
235
+ actions.push({ actionId, object, execute: executeFunctions(execute, symbols) });
236
+ }
237
+ }
238
+ return actions;
239
+ }
240
+
241
+ // The literal namespace of a CRUD declaration's `defineCrudResource` resource.
242
+ function crudNamespace(config, { symbols, evaluator }) {
243
+ let resource = unwrapExpression(memberValue(memberNamed(config, "resource")));
244
+ for (let depth = 0; resource && ts.isIdentifier(resource) && depth < MAX_OBJECT_DEPTH; depth += 1) {
245
+ const declaration = symbols.declarationOf(resource);
246
+ resource = declaration && ts.isVariableDeclaration(declaration) ? unwrapExpression(declaration.initializer) : null;
247
+ }
248
+ const callee = resource && ts.isCallExpression(resource) ? unwrapExpression(resource.expression) : null;
249
+ if (!callee || !ts.isIdentifier(callee) || !symbols.isImportOf(callee, CRUD_RESOURCE_PACKAGE, ["defineCrudResource"])) {
250
+ return null;
251
+ }
252
+ const definition = objectLiteral(resource.arguments[0], symbols);
253
+ const namespace = definition && evaluator.evaluateLiteral(memberValue(memberNamed(definition, "namespace")));
254
+ return namespace?.trim() || null;
145
255
  }
146
256
 
147
257
  // Action definitions inside each feature's actions() result: object literals
@@ -240,18 +350,6 @@ function collectFeatureActions(features, { symbols, evaluator }) {
240
350
  }
241
351
 
242
352
  const actions = [];
243
- const executeFunctions = (member) => {
244
- if (isFunctionLike(member)) {
245
- return [member];
246
- }
247
- if (ts.isPropertyAssignment(member)) {
248
- return symbols.expressionImplementations(member.initializer, 0).filter(isFunctionLike);
249
- }
250
- if (ts.isShorthandPropertyAssignment(member)) {
251
- return symbols.symbolImplementations(symbols.symbolAt(member.name)).filter(isFunctionLike);
252
- }
253
- return [];
254
- };
255
353
  for (const feature of features) {
256
354
  found.length = 0;
257
355
  if (feature.actionFunctions.length) {
@@ -264,7 +362,7 @@ function collectFeatureActions(features, { symbols, evaluator }) {
264
362
  const idExpression = ts.isPropertyAssignment(idMember) ? idMember.initializer
265
363
  : ts.isShorthandPropertyAssignment(idMember) ? idMember.name : null;
266
364
  const actionId = idExpression && evaluator.evaluateLiteral(idExpression, { indexes, values: new Map() });
267
- actions.push({ actionId, object, execute: executeFunctions(memberNamed(object, "execute")) });
365
+ actions.push({ actionId, object, execute: executeFunctions(memberNamed(object, "execute"), symbols) });
268
366
  }
269
367
  }
270
368
  return actions;
@@ -193,6 +193,69 @@ function collectFileFunctions({ file, hash, sourceFile, checker }) {
193
193
  return { module, functions: module ? [module, ...records] : records, nodes };
194
194
  }
195
195
 
196
+ // A function that runs while the code around it runs: one passed straight to
197
+ // a call, or called in place.
198
+ function isCalledInPlace(fn) {
199
+ let child = fn;
200
+ let parent = fn.parent;
201
+ while (parent && ts.isParenthesizedExpression(parent)) {
202
+ child = parent;
203
+ parent = parent.parent;
204
+ }
205
+ return Boolean(parent) && (ts.isCallExpression(parent) || ts.isNewExpression(parent)) &&
206
+ (parent.expression === child || Boolean(parent.arguments?.includes(child)));
207
+ }
208
+
209
+ // Whether code at `node` runs while `fn` runs: no function between them is
210
+ // stored, returned or declared to run later.
211
+ function runsDuring(node, fn) {
212
+ for (let current = node.parent; current; current = current.parent) {
213
+ if (current === fn) {
214
+ return true;
215
+ }
216
+ if (isFunctionLike(current) && !isCalledInPlace(current)) {
217
+ return false;
218
+ }
219
+ }
220
+ return false;
221
+ }
222
+
223
+ // The names a function's parameters bind, destructured ones included.
224
+ function parameterNames(fn) {
225
+ const names = new Set();
226
+ const visit = (name) => {
227
+ if (ts.isIdentifier(name)) {
228
+ names.add(name.text);
229
+ } else if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) {
230
+ name.elements.forEach((element) => ts.isBindingElement(element) && visit(element.name));
231
+ }
232
+ };
233
+ fn.parameters.forEach((parameter) => visit(parameter.name));
234
+ return names;
235
+ }
236
+
237
+ // The function (and parameter position) a parameter or destructured
238
+ // parameter element belongs to, with its property path.
239
+ function owningParameter(declaration) {
240
+ const pathParts = [];
241
+ let current = declaration;
242
+ while (current && ts.isBindingElement(current)) {
243
+ if (!ts.isObjectBindingPattern(current.parent)) {
244
+ return null;
245
+ }
246
+ const key = current.propertyName || current.name;
247
+ if (!ts.isIdentifier(key) && !ts.isStringLiteralLike(key)) {
248
+ return null;
249
+ }
250
+ pathParts.unshift(key.text);
251
+ current = current.parent.parent;
252
+ }
253
+ if (!current || !ts.isParameter(current) || !isFunctionLike(current.parent)) {
254
+ return null;
255
+ }
256
+ return { fn: current.parent, index: current.parent.parameters.indexOf(current), path: pathParts };
257
+ }
258
+
196
259
  function safeAliased(checker, symbol) {
197
260
  try {
198
261
  return checker.getAliasedSymbol(symbol);
@@ -201,4 +264,14 @@ function safeAliased(checker, symbol) {
201
264
  }
202
265
  }
203
266
 
204
- export { collectFileFunctions, isFunctionLike, point, safeAliased, unwrapExpression };
267
+ export {
268
+ collectFileFunctions,
269
+ isCalledInPlace,
270
+ isFunctionLike,
271
+ owningParameter,
272
+ parameterNames,
273
+ point,
274
+ runsDuring,
275
+ safeAliased,
276
+ unwrapExpression
277
+ };
@@ -35,7 +35,8 @@ function rangeOf(sourceFile, node) {
35
35
  // resolved calls and call sites, transport operation uses, named action entry
36
36
  // points and entry operation controllers. `reuse` holds
37
37
  // earlier call records of files whose own and directly imported sources are
38
- // unchanged; they are kept unless a target no longer exists.
38
+ // unchanged; they are kept unless a target no longer exists or the sites
39
+ // other files' arguments resolve in them have changed.
39
40
  function inspectCalls({
40
41
  projectRoot,
41
42
  sources,
@@ -124,19 +125,25 @@ function inspectCalls({
124
125
  });
125
126
  const functionId = (node) => recordByNode.get(node)?.id;
126
127
  const functionIds = new Set(nodeById.keys());
127
- const reused = new Set([...reuse].filter(([file, cached]) => callsByFile.has(file) &&
128
- cached.calls.every((record) => record.targets.every((target) => functionIds.has(target)))).map(([file]) => file));
128
+ const targetsExist = (entries) => entries.every(({ 1: targets }) => targets.every((target) => functionIds.has(target)));
129
+ const reusable = new Set([...reuse].filter(([file, cached]) => callsByFile.has(file) &&
130
+ targetsExist(cached.sites) && cached.calls.every((record) => record.targets.every((target) => functionIds.has(target))))
131
+ .map(([file]) => file));
129
132
  // Each file's checker targets per call position, in call order, so argument
130
133
  // flow into closures that run later sees the same call sites whether a file
131
- // is reused or resolved.
134
+ // is reused or resolved. A reused file's checker sites depend only on its own
135
+ // and directly imported sources, so they come from the cache.
132
136
  const sitesByFile = new Map();
137
+ const candidatesByFile = new Map();
133
138
  for (const [file, calls] of callsByFile) {
134
- if (reused.has(file)) {
139
+ if (reusable.has(file)) {
140
+ const cached = reuse.get(file);
135
141
  const callAt = new Map(calls.map((call) => [call.getStart(), call]));
136
- for (const [position, targets] of reuse.get(file).sites) {
142
+ for (const [position, targets] of cached.sites) {
137
143
  resolver.indexSite(callAt.get(position), targets.map((target) => nodeById.get(target)));
138
144
  }
139
- sitesByFile.set(file, reuse.get(file).sites);
145
+ sitesByFile.set(file, cached.sites);
146
+ candidatesByFile.set(file, resolver.boundCandidates(cached.boundCandidates.map((position) => callAt.get(position))));
140
147
  continue;
141
148
  }
142
149
  const sites = [];
@@ -146,6 +153,36 @@ function inspectCalls({
146
153
  }
147
154
  });
148
155
  sitesByFile.set(file, sites);
156
+ candidatesByFile.set(file, resolver.boundCandidates(calls));
157
+ }
158
+ // The sites injection and argument flow resolve depend on other files'
159
+ // arguments, so they are resolved on every run, for every file. A site
160
+ // carries a third element when its targets are a guess.
161
+ const candidates = [...candidatesByFile.values()].flat();
162
+ const boundByFile = new Map([...callsByFile.keys()].map((file) => [file, []]));
163
+ resolver.indexBoundCalls(candidates).forEach(({ targets, probable }, index) => {
164
+ const call = candidates[index];
165
+ const file = fileOf(call);
166
+ const ids = targets.map(functionId);
167
+ if (ids.length && ids.every(Boolean) && !sitesByFile.get(file).some(([position]) => position === call.getStart())) {
168
+ boundByFile.get(file).push(probable ? [call.getStart(), ids, 1] : [call.getStart(), ids]);
169
+ }
170
+ });
171
+ // A reused file keeps its records while its bound sites are the ones they
172
+ // were resolved with, and while every file its records reach keeps its own.
173
+ const sameSites = (left, right) => JSON.stringify(left) === JSON.stringify(right);
174
+ const reused = new Set([...reusable].filter((file) => sameSites(reuse.get(file).boundSites, boundByFile.get(file))));
175
+ const changedBound = new Set([...reusable].filter((file) => !reused.has(file)));
176
+ for (let changed = changedBound.size > 0; changed;) {
177
+ changed = false;
178
+ for (const file of reused) {
179
+ const reaches = reuse.get(file).calls.some((record) => record.targets.some((target) => changedBound.has(fileOf(nodeById.get(target)))));
180
+ if (reaches) {
181
+ reused.delete(file);
182
+ changedBound.add(file);
183
+ changed = true;
184
+ }
185
+ }
149
186
  }
150
187
 
151
188
  function enclosingFunction(node) {
@@ -158,12 +195,18 @@ function inspectCalls({
158
195
  return moduleByFile.get(fileOf(node));
159
196
  }
160
197
 
198
+ // What the cache keeps of a file's sites for the next run.
199
+ const fileSites = (file) => ({
200
+ sites: sitesByFile.get(file),
201
+ boundCandidates: candidatesByFile.get(file).map((call) => call.getStart()),
202
+ boundSites: boundByFile.get(file)
203
+ });
161
204
  const fileRecords = new Map();
162
205
  let truncated = false;
163
206
  for (const [file, calls] of callsByFile) {
164
207
  if (reused.has(file)) {
165
208
  const cached = reuse.get(file);
166
- fileRecords.set(file, { functions: functionsByFile.get(file), calls: cached.calls, sites: cached.sites, truncated: cached.truncated });
209
+ fileRecords.set(file, { functions: functionsByFile.get(file), ...fileSites(file), calls: cached.calls, truncated: cached.truncated });
167
210
  if (cached.truncated) {
168
211
  truncated = true;
169
212
  diagnose("CALLS_TRUNCATED", `Only the first ${MAX_CALLS_PER_FILE} calls of this file are recorded.`, file);
@@ -205,7 +248,7 @@ function inspectCalls({
205
248
  });
206
249
  }
207
250
  }
208
- fileRecords.set(file, { functions: functionsByFile.get(file), calls: records, sites: sitesByFile.get(file), truncated: fileTruncated });
251
+ fileRecords.set(file, { functions: functionsByFile.get(file), ...fileSites(file), calls: records, truncated: fileTruncated });
209
252
  }
210
253
 
211
254
  const routes = routeTable(entryOperations);