@assembly-js/app-foundation 0.3.5 → 0.3.6

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/CHANGELOG.md CHANGED
@@ -19,6 +19,9 @@ A patch release never changes what an app's build requires; a minor release may
19
19
  not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
20
20
  `bump: minor` change file.
21
21
 
22
+ ## 0.3.6
23
+ - [behavior] `.assembly/app-deps.json` now also records the calls an app makes through another app's generated client (`installerClient.GET('/projects/{id}')`), with `client` in that app's `via`. A client use the scan cannot follow counts as every operation that app exposes. Nothing in the app changes.
24
+
22
25
  ## 0.3.5
23
26
  - [behavior] At the end of every turn the platform records which other apps' operations the app calls by literal API path in `.assembly/app-deps.json`. Publishing an app that other apps depend on is refused while that file is out of date. Nothing in the app changes.
24
27
 
@@ -1,5 +1,5 @@
1
- // Finds which other apps' operations an app calls by literal proxy path and
2
- // describes them as `.assembly/app-deps.json`, so publish reads one file
1
+ // Finds which other apps' operations an app calls, by literal proxy path or
2
+ // through the generated client, and describes them as `.assembly/app-deps.json`, so publish reads one file
3
3
  // instead of Go parsing TypeScript.
4
4
  //
5
5
  // A second projection beside scan-client-calls: it reuses that detector's
@@ -12,6 +12,7 @@
12
12
  // a new file the scan never saw from one it saw and found irrelevant.
13
13
  import { readFileSync } from 'node:fs';
14
14
  import path from 'node:path';
15
+ import { contractTemplate } from './check-contract-routes.mjs';
15
16
  import { literalFetchMethod, scriptKind } from './scan-client-calls.mjs';
16
17
  import { PROXY_METHODS, byteCompare, hashFiles, listFiles, loadTypeScript } from './scan-routes.mjs';
17
18
 
@@ -23,6 +24,16 @@ const SOURCE_FILE = /\.tsx?$/;
23
24
  const PREFILTER = ['/v1/apps/', 'generated/apps/'];
24
25
  const PROXY_PATH = /^\/v1\/apps\/([^/?#]+)\/api(?:\/([^?#]*))?(?:[?#].*)?$/;
25
26
  const VIA_PROXY_PATH = 'proxy-path';
27
+ const VIA_CLIENT = 'client';
28
+ // Lets a reader tell a file from a scanner that sees client calls from an older one that
29
+ // would omit them, since both look the same when an app has none.
30
+ const DETECTS = [VIA_PROXY_PATH, VIA_CLIENT];
31
+ const CLIENT_SPECIFIER = /(?:^|\/)generated\/apps\/([^/]+)\/client(?:\.[jt]s)?$/;
32
+ // The generated client's exports that make calls; its other exports (APP_ID, types) do not.
33
+ const CLIENT_EXPORTS = new Set(['installerClient', 'viewerClient']);
34
+ const CLIENT_DOCUMENT = (appId) => path.join('generated/apps', appId, 'openapi.json');
35
+ const OPENAPI_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
36
+ const PATHS_POINTER = '#/paths/';
26
37
  // One file per installed provider app, written by the platform; each operation carries
27
38
  // `- Method: \`X\`` then `- Path: \`/t\``. Absent for a provider that is not callable.
28
39
  const PROVIDER_DOCS_DIR = '.assembly/docs/apps';
@@ -88,10 +99,56 @@ function resolveString(ts, node, consts) {
88
99
  return null;
89
100
  }
90
101
 
102
+ // The generated-client bindings one file imports: name -> { appId, namespace }.
103
+ function clientBindings(ts, sf, found) {
104
+ const bindings = new Map();
105
+ for (const stmt of sf.statements) {
106
+ const specifier = stmt.moduleSpecifier;
107
+ const match = specifier && ts.isStringLiteral(specifier) ? CLIENT_SPECIFIER.exec(specifier.text) : null;
108
+ if (!match) continue;
109
+ const appId = match[1];
110
+ // A re-export hands the client to code this file cannot follow.
111
+ if (ts.isExportDeclaration(stmt) && !stmt.isTypeOnly) found.push({ appId, client: 'all' });
112
+ const clause = ts.isImportDeclaration(stmt) ? stmt.importClause : undefined;
113
+ if (!clause || clause.isTypeOnly) continue;
114
+ const named = clause.namedBindings;
115
+ if (named && ts.isNamespaceImport(named)) bindings.set(named.name.text, { appId, namespace: true });
116
+ for (const spec of named && ts.isNamedImports(named) ? named.elements : []) {
117
+ if (!spec.isTypeOnly && CLIENT_EXPORTS.has((spec.propertyName ?? spec.name).text)) bindings.set(spec.name.text, { appId, namespace: false });
118
+ }
119
+ }
120
+ return bindings;
121
+ }
122
+
123
+ // `<client>.<VERB>('<template>')` is one op; any other use of a client could be any op.
124
+ function clientUse(ts, node, binding) {
125
+ let client = node;
126
+ if (binding.namespace) {
127
+ const access = node.parent;
128
+ if (!ts.isPropertyAccessExpression(access) || access.expression !== node) return { appId: binding.appId, client: 'all' };
129
+ if (!CLIENT_EXPORTS.has(access.name.text)) return null;
130
+ client = access;
131
+ }
132
+ const verb = client.parent;
133
+ const call = verb?.parent;
134
+ const arg = call && ts.isCallExpression(call) && call.expression === verb ? call.arguments[0] : undefined;
135
+ if (ts.isPropertyAccessExpression(verb) && verb.expression === client && /^[A-Z]+$/.test(verb.name.text) && arg && isLiteral(ts, arg)) {
136
+ return { appId: binding.appId, client: `${verb.name.text} ${arg.text}` };
137
+ }
138
+ return { appId: binding.appId, client: 'all' };
139
+ }
140
+
141
+ const isReference = (ts, node) =>
142
+ !(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) &&
143
+ !ts.isImportSpecifier(node.parent) && !ts.isNamespaceImport(node.parent) &&
144
+ !(ts.isPropertyAssignment(node.parent) && node.parent.name === node);
145
+
91
146
  function collectDeps(ts, file, text) {
92
147
  const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind(ts, file));
93
148
  const consts = constStrings(ts, sf);
94
149
  const found = [];
150
+ const bindings = clientBindings(ts, sf, found);
151
+ const used = new Set();
95
152
  const visit = (node) => {
96
153
  // A bare name is read where its const is declared, not at every use.
97
154
  const value = ts.isIdentifier(node) ? null : resolveString(ts, node, consts);
@@ -106,13 +163,39 @@ function collectDeps(ts, file, text) {
106
163
  if (match && !concatenated && !base) found.push({ appId: match[1], template, method: callSiteMethod(ts, node) });
107
164
  // A resolved expression is one path: its parts are not separate calls.
108
165
  return;
166
+ } else if (ts.isIdentifier(node) && bindings.has(node.text) && isReference(ts, node)) {
167
+ const dep = clientUse(ts, node, bindings.get(node.text));
168
+ if (dep) found.push(dep);
169
+ used.add(node.text);
109
170
  }
110
171
  ts.forEachChild(node, visit);
111
172
  };
112
173
  visit(sf);
174
+ for (const [name, { appId }] of bindings) if (!used.has(name)) found.push({ appId, client: 'all' });
113
175
  return found;
114
176
  }
115
177
 
178
+ // Every op the provider exposes, from the document the client was generated from, or the
179
+ // provider's docs when that is not on disk. Named the way the client names them: without
180
+ // the `/api` prefix, and with a path item that refers to another path serving its operations.
181
+ function allProviderOps(root, appId, provider) {
182
+ try {
183
+ const doc = JSON.parse(readFileSync(path.join(root, CLIENT_DOCUMENT(appId)), 'utf8'));
184
+ const paths = doc.paths ?? {};
185
+ const resolve = (item) => {
186
+ const ref = item && typeof item.$ref === 'string' && item.$ref.startsWith(PATHS_POINTER) ? item.$ref : null;
187
+ return ref ? paths[ref.slice(PATHS_POINTER.length).replace(/~1/g, '/').replace(/~0/g, '~')] : item;
188
+ };
189
+ return Object.entries(paths).flatMap(([documented, entry]) => {
190
+ const item = resolve(entry);
191
+ const template = contractTemplate(documented) || '/';
192
+ return OPENAPI_METHODS.filter((m) => item && typeof item === 'object' && m in item).map((m) => `${m.toUpperCase()} ${template}`);
193
+ });
194
+ } catch {
195
+ return (provider ?? []).map((op) => `${op.method} ${op.template}`);
196
+ }
197
+ }
198
+
116
199
  function providerOps(root, appId) {
117
200
  try {
118
201
  const doc = readFileSync(path.join(root, PROVIDER_DOCS_DIR, `${appId}.md`), 'utf8');
@@ -164,6 +247,7 @@ export function scanAppDeps(root, { loadTs = loadTypeScript } = {}) {
164
247
  const contents = new Map(files.map((f) => [f, readFileSync(path.join(root, f))]));
165
248
  const candidates = files.filter((f) => PREFILTER.some((s) => contents.get(f).includes(s)));
166
249
  const ops = new Map(); // appId -> Set of "<METHOD> <template>"
250
+ const via = new Map(); // appId -> Set of how it is called
167
251
  const providers = new Map(); // appId -> provider operations, or null
168
252
  // Loaded only when there is something to parse, but a missing typescript must
169
253
  // still stop the file being written for a tree that has candidates.
@@ -171,16 +255,21 @@ export function scanAppDeps(root, { loadTs = loadTypeScript } = {}) {
171
255
  for (const file of candidates) {
172
256
  for (const dep of collectDeps(ts, file, contents.get(file).toString('utf8'))) {
173
257
  if (!ops.has(dep.appId)) ops.set(dep.appId, new Set());
258
+ if (!via.has(dep.appId)) via.set(dep.appId, new Set());
174
259
  if (!providers.has(dep.appId)) providers.set(dep.appId, providerOps(root, dep.appId));
175
- for (const op of opsFor(providers.get(dep.appId), dep)) ops.get(dep.appId).add(op);
260
+ const provider = providers.get(dep.appId);
261
+ // A client call already names the provider's template, so it needs no matching.
262
+ const found = !dep.client ? opsFor(provider, dep) : dep.client === 'all' ? allProviderOps(root, dep.appId, provider) : [dep.client];
263
+ for (const op of found) ops.get(dep.appId).add(op);
264
+ via.get(dep.appId).add(dep.client ? VIA_CLIENT : VIA_PROXY_PATH);
176
265
  }
177
266
  }
178
267
  const apps = {};
179
268
  for (const appId of [...ops.keys()].filter((id) => ops.get(id).size).sort(byteCompare)) {
180
- apps[appId] = { ops: [...ops.get(appId)].sort(byteCompare), via: [VIA_PROXY_PATH] };
269
+ apps[appId] = { ops: [...ops.get(appId)].sort(byteCompare), via: DETECTS.filter((v) => via.get(appId).has(v)) };
181
270
  }
182
271
  const hashes = hashFiles(root, files, contents);
183
- return { apps, files: Object.fromEntries(files.map((f) => [f, hashes.get(f)])) };
272
+ return { apps, detects: DETECTS, files: Object.fromEntries(files.map((f) => [f, hashes.get(f)])) };
184
273
  }
185
274
 
186
275
  export const serializeAppDeps = (result) => JSON.stringify(result, null, 2) + '\n';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [