@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 +3 -0
- package/bin/scan-app-deps.mjs +94 -5
- package/package.json +1 -1
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
|
|
package/bin/scan-app-deps.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Finds which other apps' operations an app calls by literal proxy path
|
|
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
|
-
|
|
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:
|
|
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';
|