@assembly-js/app-foundation 0.3.4 → 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 +6 -0
- package/bin/scan-app-deps.mjs +290 -0
- package/bin/scan-client-calls.mjs +19 -11
- package/bin/scan-routes.mjs +1 -1
- package/bin/scan.mjs +5 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,12 @@ 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
|
+
|
|
25
|
+
## 0.3.5
|
|
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.
|
|
27
|
+
|
|
22
28
|
## 0.3.4
|
|
23
29
|
- [guidance] The build agent is now taught to authenticate inbound webhook calls inside the route, fail closed when the secret is unset, and never add an unauthenticated test endpoint.
|
|
24
30
|
|
|
@@ -0,0 +1,290 @@
|
|
|
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
|
+
// instead of Go parsing TypeScript.
|
|
4
|
+
//
|
|
5
|
+
// A second projection beside scan-client-calls: it reuses that detector's
|
|
6
|
+
// parse setup and `fetch` method reading, but looks for literals rather than
|
|
7
|
+
// SDK receivers. A path is reported when the file alone fixes it: a literal, or a
|
|
8
|
+
// template or `+` built from literals and same-file string consts (the usual way an
|
|
9
|
+
// app names the provider's id). Anything else is computed and left to observed traffic.
|
|
10
|
+
//
|
|
11
|
+
// `files` hashes every .ts/.tsx, not only the ones with a hit, so Go can tell
|
|
12
|
+
// a new file the scan never saw from one it saw and found irrelevant.
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { contractTemplate } from './check-contract-routes.mjs';
|
|
16
|
+
import { literalFetchMethod, scriptKind } from './scan-client-calls.mjs';
|
|
17
|
+
import { PROXY_METHODS, byteCompare, hashFiles, listFiles, loadTypeScript } from './scan-routes.mjs';
|
|
18
|
+
|
|
19
|
+
export const APP_DEPS_OUTPUT = '.assembly/app-deps.json';
|
|
20
|
+
export const CODE_SCAN_FAILED = 'app_deps_scan_failed';
|
|
21
|
+
|
|
22
|
+
// Mirrored in Go (appdepsfile): the one place a scanned source file is recognised.
|
|
23
|
+
const SOURCE_FILE = /\.tsx?$/;
|
|
24
|
+
const PREFILTER = ['/v1/apps/', 'generated/apps/'];
|
|
25
|
+
const PROXY_PATH = /^\/v1\/apps\/([^/?#]+)\/api(?:\/([^?#]*))?(?:[?#].*)?$/;
|
|
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/';
|
|
37
|
+
// One file per installed provider app, written by the platform; each operation carries
|
|
38
|
+
// `- Method: \`X\`` then `- Path: \`/t\``. Absent for a provider that is not callable.
|
|
39
|
+
const PROVIDER_DOCS_DIR = '.assembly/docs/apps';
|
|
40
|
+
const DOC_OPERATION = /^- Method: `([A-Z]+)`\n- Path: `([^`]+)`$/m;
|
|
41
|
+
const DOC_CATCH_ALL = 'This operation is a catch-all.';
|
|
42
|
+
// An inferred route (handlers the scan could not name) has no method to record.
|
|
43
|
+
const INFERRED_METHOD = 'ANY';
|
|
44
|
+
const TEMPLATE_PARAM = /^\{[^}]+\}$/;
|
|
45
|
+
const CLIENT_VERBS = new Set(['get', 'post', 'put', 'patch', 'delete']);
|
|
46
|
+
|
|
47
|
+
const isLiteral = (ts, n) => ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n);
|
|
48
|
+
|
|
49
|
+
// The method a literal's call site states, or null when it is not evident.
|
|
50
|
+
function callSiteMethod(ts, literal) {
|
|
51
|
+
const call = literal.parent;
|
|
52
|
+
if (!ts.isCallExpression(call) || call.arguments[0] !== literal) return null;
|
|
53
|
+
const callee = call.expression;
|
|
54
|
+
const name = ts.isIdentifier(callee) ? callee.text : ts.isPropertyAccessExpression(callee) ? callee.name.text : null;
|
|
55
|
+
if (name === 'fetch') return literalFetchMethod(ts, call.arguments[1])?.toUpperCase() ?? null;
|
|
56
|
+
return CLIENT_VERBS.has(name) && ts.isPropertyAccessExpression(callee) ? name.toUpperCase() : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A name the file binds exactly once, as a `const` initialised with a literal, is the string
|
|
60
|
+
// it spells; any other binding of the same name (a `let`, a parameter) may shadow it.
|
|
61
|
+
function constStrings(ts, sf) {
|
|
62
|
+
const bindings = new Map(); // name -> declaration, or null once bound a second time
|
|
63
|
+
const bind = (name, decl) => bindings.set(name, bindings.has(name) ? null : decl);
|
|
64
|
+
const walk = (node) => {
|
|
65
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) bind(node.name.text, node);
|
|
66
|
+
else if (ts.isParameter(node) && ts.isIdentifier(node.name)) bind(node.name.text, null);
|
|
67
|
+
ts.forEachChild(node, walk);
|
|
68
|
+
};
|
|
69
|
+
walk(sf);
|
|
70
|
+
const values = new Map();
|
|
71
|
+
const resolve = (node) => resolveString(ts, node, values);
|
|
72
|
+
// Source order, so a const built from an earlier one resolves too.
|
|
73
|
+
for (const [name, decl] of bindings) {
|
|
74
|
+
if (!decl || !(decl.parent.flags & ts.NodeFlags.Const) || !decl.initializer) continue;
|
|
75
|
+
const value = resolve(decl.initializer);
|
|
76
|
+
if (value !== null) values.set(name, value);
|
|
77
|
+
}
|
|
78
|
+
return values;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The string an expression always evaluates to, or null when any part of it is not known.
|
|
82
|
+
function resolveString(ts, node, consts) {
|
|
83
|
+
if (isLiteral(ts, node)) return node.text;
|
|
84
|
+
if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node)) return resolveString(ts, node.expression, consts);
|
|
85
|
+
if (ts.isIdentifier(node)) return consts.get(node.text) ?? null;
|
|
86
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
87
|
+
const [left, right] = [resolveString(ts, node.left, consts), resolveString(ts, node.right, consts)];
|
|
88
|
+
return left !== null && right !== null ? left + right : null;
|
|
89
|
+
}
|
|
90
|
+
if (ts.isTemplateExpression(node)) {
|
|
91
|
+
let out = node.head.text;
|
|
92
|
+
for (const span of node.templateSpans) {
|
|
93
|
+
const value = resolveString(ts, span.expression, consts);
|
|
94
|
+
if (value === null) return null;
|
|
95
|
+
out += value + span.literal.text;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
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
|
+
|
|
146
|
+
function collectDeps(ts, file, text) {
|
|
147
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind(ts, file));
|
|
148
|
+
const consts = constStrings(ts, sf);
|
|
149
|
+
const found = [];
|
|
150
|
+
const bindings = clientBindings(ts, sf, found);
|
|
151
|
+
const used = new Set();
|
|
152
|
+
const visit = (node) => {
|
|
153
|
+
// A bare name is read where its const is declared, not at every use.
|
|
154
|
+
const value = ts.isIdentifier(node) ? null : resolveString(ts, node, consts);
|
|
155
|
+
if (value !== null) {
|
|
156
|
+
const match = PROXY_PATH.exec(value);
|
|
157
|
+
// `'/v1/apps/x/api/items/' + id` is only the start of a path.
|
|
158
|
+
const concatenated = ts.isBinaryExpression(node.parent) && node.parent.left === node;
|
|
159
|
+
// `const BASE = `/v1/apps/${id}/api`` names the provider, it does not call its root.
|
|
160
|
+
const base = ts.isVariableDeclaration(node.parent) && !match?.[2];
|
|
161
|
+
// The proxy drops one trailing slash before routing.
|
|
162
|
+
const template = `/${match?.[2] ?? ''}`.replace(/(.)\/$/, '$1');
|
|
163
|
+
if (match && !concatenated && !base) found.push({ appId: match[1], template, method: callSiteMethod(ts, node) });
|
|
164
|
+
// A resolved expression is one path: its parts are not separate calls.
|
|
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);
|
|
170
|
+
}
|
|
171
|
+
ts.forEachChild(node, visit);
|
|
172
|
+
};
|
|
173
|
+
visit(sf);
|
|
174
|
+
for (const [name, { appId }] of bindings) if (!used.has(name)) found.push({ appId, client: 'all' });
|
|
175
|
+
return found;
|
|
176
|
+
}
|
|
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
|
+
|
|
199
|
+
function providerOps(root, appId) {
|
|
200
|
+
try {
|
|
201
|
+
const doc = readFileSync(path.join(root, PROVIDER_DOCS_DIR, `${appId}.md`), 'utf8');
|
|
202
|
+
return doc.split(/\n(?=#{3,4} )/).flatMap((section) => {
|
|
203
|
+
const m = DOC_OPERATION.exec(section);
|
|
204
|
+
return m ? [{ method: m[1], template: m[2], catchAll: section.includes(DOC_CATCH_ALL) }] : [];
|
|
205
|
+
});
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// A `{param}` segment in the provider's template stands for any one segment of the call; the
|
|
212
|
+
// last one of a catch-all stands for one or more.
|
|
213
|
+
const templateMatches = ({ template, catchAll }, literal) => {
|
|
214
|
+
const a = template.split('/');
|
|
215
|
+
const b = literal.split('/');
|
|
216
|
+
if (!(catchAll ? b.length >= a.length : b.length === a.length)) return false;
|
|
217
|
+
return a.every((seg, i) => seg === b[i] || TEMPLATE_PARAM.test(seg));
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// The proxy serves the most specific route: per segment static, then dynamic, then catch-all.
|
|
221
|
+
const specificity = ({ template, catchAll }, literal) => {
|
|
222
|
+
const a = template.split('/');
|
|
223
|
+
return literal.split('/').map((_, i) => {
|
|
224
|
+
if (i >= a.length || (catchAll && i === a.length - 1)) return 2;
|
|
225
|
+
return TEMPLATE_PARAM.test(a[i]) ? 1 : 0;
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
const bySpecificity = (literal) => (x, y) => {
|
|
229
|
+
const [a, b] = [specificity(x, literal), specificity(y, literal)];
|
|
230
|
+
const i = a.findIndex((r, k) => r !== b[k]);
|
|
231
|
+
return i < 0 ? 0 : a[i] - b[i];
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// The ops one literal path stands for: the provider's winning template with every method it
|
|
235
|
+
// exposes, or the literal path itself when the provider's operations are not on disk.
|
|
236
|
+
function opsFor(provider, { template, method }) {
|
|
237
|
+
const matched = (provider ?? []).filter((op) => templateMatches(op, template)).sort(bySpecificity(template));
|
|
238
|
+
if (matched.length === 0) return (method ? [method] : PROXY_METHODS).map((m) => `${m} ${template}`);
|
|
239
|
+
const winner = matched.filter((op) => op.template === matched[0].template);
|
|
240
|
+
if (winner.every((op) => op.method === INFERRED_METHOD)) return [];
|
|
241
|
+
if (method) return [`${method} ${winner[0].template}`];
|
|
242
|
+
return winner.filter((op) => op.method !== INFERRED_METHOD).map((op) => `${op.method} ${op.template}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function scanAppDeps(root, { loadTs = loadTypeScript } = {}) {
|
|
246
|
+
const files = listFiles(root).filter((f) => SOURCE_FILE.test(f)).sort(byteCompare);
|
|
247
|
+
const contents = new Map(files.map((f) => [f, readFileSync(path.join(root, f))]));
|
|
248
|
+
const candidates = files.filter((f) => PREFILTER.some((s) => contents.get(f).includes(s)));
|
|
249
|
+
const ops = new Map(); // appId -> Set of "<METHOD> <template>"
|
|
250
|
+
const via = new Map(); // appId -> Set of how it is called
|
|
251
|
+
const providers = new Map(); // appId -> provider operations, or null
|
|
252
|
+
// Loaded only when there is something to parse, but a missing typescript must
|
|
253
|
+
// still stop the file being written for a tree that has candidates.
|
|
254
|
+
const ts = candidates.length ? loadTs() : null;
|
|
255
|
+
for (const file of candidates) {
|
|
256
|
+
for (const dep of collectDeps(ts, file, contents.get(file).toString('utf8'))) {
|
|
257
|
+
if (!ops.has(dep.appId)) ops.set(dep.appId, new Set());
|
|
258
|
+
if (!via.has(dep.appId)) via.set(dep.appId, new Set());
|
|
259
|
+
if (!providers.has(dep.appId)) providers.set(dep.appId, providerOps(root, dep.appId));
|
|
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);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const apps = {};
|
|
268
|
+
for (const appId of [...ops.keys()].filter((id) => ops.get(id).size).sort(byteCompare)) {
|
|
269
|
+
apps[appId] = { ops: [...ops.get(appId)].sort(byteCompare), via: DETECTS.filter((v) => via.get(appId).has(v)) };
|
|
270
|
+
}
|
|
271
|
+
const hashes = hashFiles(root, files, contents);
|
|
272
|
+
return { apps, detects: DETECTS, files: Object.fromEntries(files.map((f) => [f, hashes.get(f)])) };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export const serializeAppDeps = (result) => JSON.stringify(result, null, 2) + '\n';
|
|
276
|
+
|
|
277
|
+
// Plug-in row for bin/scan.mjs. Any failure returns a warning and no content,
|
|
278
|
+
// so the previous file stays and publish sees it as stale rather than trusting
|
|
279
|
+
// a scan that did not finish.
|
|
280
|
+
export const appDepsScanner = {
|
|
281
|
+
name: 'app-deps',
|
|
282
|
+
output: APP_DEPS_OUTPUT,
|
|
283
|
+
run: (root, options) => {
|
|
284
|
+
try {
|
|
285
|
+
return { content: serializeAppDeps(scanAppDeps(root, options)), warnings: [] };
|
|
286
|
+
} catch (err) {
|
|
287
|
+
return { warnings: [{ code: CODE_SCAN_FAILED, message: `${err && err.message ? err.message : err}` }] };
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
};
|
|
@@ -163,6 +163,22 @@ const isViewerSource = (name) =>
|
|
|
163
163
|
|
|
164
164
|
const lineOf = (sf, node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
165
165
|
|
|
166
|
+
// The HTTP method a `fetch` init object states, shared with the cross-app scan. A missing init or
|
|
167
|
+
// method option is a GET; null means the method is not statically evident.
|
|
168
|
+
export function literalFetchMethod(ts, init) {
|
|
169
|
+
const literal = (n) => n && (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n));
|
|
170
|
+
if (!init) return DEFAULT_FETCH_HTTP_METHOD;
|
|
171
|
+
if (!ts.isObjectLiteralExpression(init)) return null;
|
|
172
|
+
let method = DEFAULT_FETCH_HTTP_METHOD;
|
|
173
|
+
for (const prop of init.properties) {
|
|
174
|
+
if (ts.isSpreadAssignment(prop) || (prop.name && ts.isComputedPropertyName(prop.name))) return null;
|
|
175
|
+
if (prop.name?.text !== 'method') continue;
|
|
176
|
+
if (!ts.isPropertyAssignment(prop) || !literal(prop.initializer)) return null;
|
|
177
|
+
method = prop.initializer.text;
|
|
178
|
+
}
|
|
179
|
+
return method;
|
|
180
|
+
}
|
|
181
|
+
|
|
166
182
|
class Scope {
|
|
167
183
|
constructor(parent) {
|
|
168
184
|
this.parent = parent;
|
|
@@ -174,7 +190,7 @@ class Scope {
|
|
|
174
190
|
}
|
|
175
191
|
}
|
|
176
192
|
|
|
177
|
-
function scriptKind(ts, file) {
|
|
193
|
+
export function scriptKind(ts, file) {
|
|
178
194
|
if (/\.(?:tsx|jsx)$/.test(file)) return ts.ScriptKind.TSX;
|
|
179
195
|
return /\.[cm]?js$/.test(file) ? ts.ScriptKind.JS : ts.ScriptKind.TS;
|
|
180
196
|
}
|
|
@@ -206,16 +222,8 @@ export function collectFileFacts(ts, file, text, sdkMethods = null, localExports
|
|
|
206
222
|
const fetchRoute = (call) => {
|
|
207
223
|
const [pathArg, init] = call.arguments;
|
|
208
224
|
if (!isLiteralText(pathArg)) return null;
|
|
209
|
-
|
|
210
|
-
if (
|
|
211
|
-
if (!ts.isObjectLiteralExpression(init)) return null;
|
|
212
|
-
for (const prop of init.properties) {
|
|
213
|
-
if (ts.isSpreadAssignment(prop) || (prop.name && ts.isComputedPropertyName(prop.name))) return null;
|
|
214
|
-
if (prop.name?.text !== 'method') continue;
|
|
215
|
-
if (!ts.isPropertyAssignment(prop) || !isLiteralText(prop.initializer)) return null;
|
|
216
|
-
method = prop.initializer.text;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
225
|
+
const method = literalFetchMethod(ts, init);
|
|
226
|
+
if (method === null) return null;
|
|
219
227
|
// The SDK prepends the slash a path is missing, so `v1/me` requests `/v1/me`.
|
|
220
228
|
const routePath = pathArg.text.startsWith('/') ? pathArg.text : `/${pathArg.text}`;
|
|
221
229
|
return { path: routePath.replace(/[?#].*$/, ''), method };
|
package/bin/scan-routes.mjs
CHANGED
|
@@ -80,7 +80,7 @@ const gitBlobSha = (bytes) =>
|
|
|
80
80
|
// `git hash-object` applies the repo's clean filters (autocrlf, .gitattributes),
|
|
81
81
|
// so asking git yields the blob that will actually be committed; hashing raw
|
|
82
82
|
// working-tree bytes would not under those filters.
|
|
83
|
-
function hashFiles(root, files, contents) {
|
|
83
|
+
export function hashFiles(root, files, contents) {
|
|
84
84
|
const hashes = new Map();
|
|
85
85
|
const inRepo = existsSync(path.join(root, '.git'));
|
|
86
86
|
const isLink = (f) => lstatSync(path.join(root, f)).isSymbolicLink();
|
package/bin/scan.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { pathToFileURL } from 'node:url';
|
|
12
|
+
import { appDepsScanner } from './scan-app-deps.mjs';
|
|
12
13
|
import { clientCallScanner } from './scan-client-calls.mjs';
|
|
13
14
|
import { scanRoutes, serializeRoutes } from './scan-routes.mjs';
|
|
14
15
|
|
|
@@ -21,6 +22,7 @@ const SCANNERS = [
|
|
|
21
22
|
return { content: serializeRoutes(result), warnings: result.warnings };
|
|
22
23
|
},
|
|
23
24
|
},
|
|
25
|
+
appDepsScanner,
|
|
24
26
|
];
|
|
25
27
|
|
|
26
28
|
// Scanners whose result goes to the caller, not to a committed file. They run
|
|
@@ -48,8 +50,9 @@ export function runScanners(root = process.cwd(), scanners = SCANNERS) {
|
|
|
48
50
|
const warnings = [];
|
|
49
51
|
const outputs = {};
|
|
50
52
|
for (const { scanner, content, result, warnings: found } of results) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
+
// A scanner that failed has no content; its previous file stays, so publish sees it as stale.
|
|
54
|
+
if (scanner.output && content !== undefined) writeIfChanged(path.join(root, scanner.output), content);
|
|
55
|
+
else if (!scanner.output) outputs[scanner.name] = result;
|
|
53
56
|
warnings.push(...found.map((w) => ({ scanner: scanner.name, ...w })));
|
|
54
57
|
}
|
|
55
58
|
return { warnings, outputs };
|