@apifuse/provider-sdk 2.2.0-beta.63 → 2.2.0-beta.64
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/AUTHORING.md +99 -0
- package/CHANGELOG.md +4 -0
- package/LICENSE +21 -0
- package/bin/apifuse-submit-check.ts +9 -5
- package/dist/handle-meta.d.ts +36 -1
- package/dist/handle-meta.js +16 -0
- package/dist/handle.d.ts +23 -5
- package/dist/handle.js +54 -8
- package/dist/health-scenario.d.ts +38 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/lint.d.ts +5 -0
- package/dist/lint.js +57 -33
- package/dist/provider.d.ts +1 -1
- package/dist/provider.js +1 -1
- package/dist/runtime/handle.js +9 -1
- package/dist/runtime/ocr.js +5 -0
- package/dist/runtime-boundary-lint.d.ts +132 -0
- package/dist/runtime-boundary-lint.js +586 -0
- package/dist/types.d.ts +29 -0
- package/dist/typescript-module.d.ts +39 -0
- package/dist/typescript-module.js +72 -0
- package/package.json +1 -1
- package/src/handle-meta.ts +51 -1
- package/src/handle.ts +92 -10
- package/src/health-scenario.ts +38 -0
- package/src/index.ts +14 -0
- package/src/lint.ts +82 -41
- package/src/provider.ts +2 -0
- package/src/runtime/handle.ts +9 -1
- package/src/runtime/ocr.ts +5 -0
- package/src/runtime-boundary-lint.ts +703 -0
- package/src/types.ts +29 -0
- package/src/typescript-module.ts +100 -0
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime boundary lint: provider runtime source must reach the outside world
|
|
3
|
+
* through the SDK context (`ctx.http`, `ctx.stealth`, `ctx.env`,
|
|
4
|
+
* `ctx.credential`, `ctx.browser`), not through ambient Node/Bun globals.
|
|
5
|
+
*
|
|
6
|
+
* Three rules, all reported at `warn` level in this first release so the
|
|
7
|
+
* fleet can migrate before the level is raised:
|
|
8
|
+
*
|
|
9
|
+
* - `process-env-direct-read`: `process.env.X`, `process.env["X"]`, a bare
|
|
10
|
+
* `process.env` object use, and the `Bun.env` equivalents. Runtime bootstrap
|
|
11
|
+
* keys (`APIFUSE__RUNTIME__*`) are exempt everywhere because `serve()`
|
|
12
|
+
* callers read the port and pod identity before a context exists.
|
|
13
|
+
* - `node-runtime-module-import`: value imports of `fs`, `fs/promises`, `net`,
|
|
14
|
+
* `tls`, `dgram`, `http`, `https`, `http2`, `child_process` (with or without
|
|
15
|
+
* the `node:` prefix) via import/export declarations, `require()`, or dynamic
|
|
16
|
+
* `import()`, plus the Bun equivalents `Bun.spawn`, `Bun.spawnSync`, `Bun.$`,
|
|
17
|
+
* `Bun.file`, `Bun.write`. Type-only imports are not runtime reach and are
|
|
18
|
+
* ignored.
|
|
19
|
+
* - `direct-fetch-call`: a call to the global `fetch` (bare identifier, or
|
|
20
|
+
* `globalThis`/`window`/`self`/`global` member). A bare `fetch` call whose
|
|
21
|
+
* enclosing scope declares a `fetch` binding (parameter, variable, import,
|
|
22
|
+
* function, catch clause) is calling that binding, not the global, and is
|
|
23
|
+
* left alone; the check is lexical, so a wrapper that takes `fetch` as a
|
|
24
|
+
* parameter does not hide a global `fetch()` elsewhere in the same file.
|
|
25
|
+
* `ctx.stealth.fetch()` is a member call and never matches.
|
|
26
|
+
*
|
|
27
|
+
* Files are parsed with the script kind their extension implies (`.ts` as
|
|
28
|
+
* TypeScript, `.tsx` as TSX, …). Parsing everything as TSX would make a
|
|
29
|
+
* generic arrow (`<T>(value: T) => value`) or an angle-bracket assertion in an
|
|
30
|
+
* ordinary `.ts` file read as JSX, and everything after it would silently drop
|
|
31
|
+
* out of the AST.
|
|
32
|
+
*
|
|
33
|
+
* Scope is the provider *runtime* source: JavaScript/TypeScript files under
|
|
34
|
+
* `providerSourceFiles` minus tests, recorded fixtures, the root bootstrap
|
|
35
|
+
* entrypoints (`dev.ts`, `start.ts`, `deploy.ts`) and the operator tooling
|
|
36
|
+
* directories (`scripts/`, `tools/`, `bin/`). Tooling legitimately reads the
|
|
37
|
+
* ambient environment and the filesystem when a human runs it; the rule is
|
|
38
|
+
* about code the pod executes on behalf of a request.
|
|
39
|
+
*
|
|
40
|
+
* A deliberate exception is acknowledged with the same escape hatch the submit
|
|
41
|
+
* check uses: `// @apifuse-allow <rule>: <reason>` on the finding line or the
|
|
42
|
+
* line directly above it. Acknowledged findings are reported as information so
|
|
43
|
+
* `apifuse check` still lists them.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import type { LintDiagnostic, ProviderLintInformation, ProviderLintResult } from "./lint.js";
|
|
47
|
+
import {
|
|
48
|
+
getTypeScript,
|
|
49
|
+
isTypeScriptCompilerModule,
|
|
50
|
+
type TypeScriptCompilerModuleLike,
|
|
51
|
+
type TypeScriptModule,
|
|
52
|
+
} from "./typescript-module.js";
|
|
53
|
+
|
|
54
|
+
type TsNode = import("typescript").Node;
|
|
55
|
+
type TsSourceFile = import("typescript").SourceFile;
|
|
56
|
+
|
|
57
|
+
export const PROCESS_ENV_DIRECT_READ_RULE = "process-env-direct-read";
|
|
58
|
+
export const NODE_RUNTIME_MODULE_IMPORT_RULE = "node-runtime-module-import";
|
|
59
|
+
export const DIRECT_FETCH_CALL_RULE = "direct-fetch-call";
|
|
60
|
+
|
|
61
|
+
export const RUNTIME_BOUNDARY_RULES = [
|
|
62
|
+
PROCESS_ENV_DIRECT_READ_RULE,
|
|
63
|
+
NODE_RUNTIME_MODULE_IMPORT_RULE,
|
|
64
|
+
DIRECT_FETCH_CALL_RULE,
|
|
65
|
+
] as const;
|
|
66
|
+
|
|
67
|
+
export type RuntimeBoundaryRule = (typeof RUNTIME_BOUNDARY_RULES)[number];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Environment names a provider may read from `process.env` directly. These
|
|
71
|
+
* are consumed before `serve()` builds a context (port, pod identity), so
|
|
72
|
+
* `ctx.env` cannot carry them. The whole `APIFUSE__RUNTIME__` family is the
|
|
73
|
+
* bootstrap contract; nothing else is exempt.
|
|
74
|
+
*/
|
|
75
|
+
export const BOOTSTRAP_ENV_NAME_PREFIX = "APIFUSE__RUNTIME__";
|
|
76
|
+
|
|
77
|
+
export function isBootstrapEnvName(name: string): boolean {
|
|
78
|
+
return name.startsWith(BOOTSTRAP_ENV_NAME_PREFIX);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Root entrypoints `serve()`/`startDevServer()` are called from; never request-path code. */
|
|
82
|
+
const BOOTSTRAP_ENTRYPOINT_FILE_PATTERN = /^(?:dev|start|deploy)\.[cm]?[jt]sx?$/;
|
|
83
|
+
/** Operator tooling directories at the provider root (fixture recorders, smoke scripts). */
|
|
84
|
+
const TOOLING_DIRECTORY_PATTERN = /^(?:scripts|tools|bin)\//;
|
|
85
|
+
const TEST_SOURCE_FILE_PATTERN =
|
|
86
|
+
/(?:^|\/)(?:__tests__|__mocks__|tests)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
87
|
+
const RECORDED_FIXTURE_SOURCE_FILE_PATTERN =
|
|
88
|
+
/(?:^|\/)__fixtures__(?:\/|$)|(?:^|\/)__tests__\/fixtures(?:\/|$)/;
|
|
89
|
+
const JAVASCRIPT_SOURCE_FILE_PATTERN = /\.[cm]?[jt]sx?$/;
|
|
90
|
+
const DECLARATION_FILE_PATTERN = /\.d\.[cm]?ts$/;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Node built-ins whose value import means the provider talks to the
|
|
94
|
+
* filesystem, the network, or child processes on its own. Deliberately not the
|
|
95
|
+
* whole `node:` namespace: `node:path`, `node:crypto`, `node:url`,
|
|
96
|
+
* `node:buffer` and friends are pure helpers the SDK itself uses.
|
|
97
|
+
*/
|
|
98
|
+
const FORBIDDEN_NODE_MODULES: ReadonlySet<string> = new Set([
|
|
99
|
+
"fs",
|
|
100
|
+
"fs/promises",
|
|
101
|
+
"net",
|
|
102
|
+
"tls",
|
|
103
|
+
"dgram",
|
|
104
|
+
"http",
|
|
105
|
+
"https",
|
|
106
|
+
"http2",
|
|
107
|
+
"child_process",
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
const FORBIDDEN_BUN_MEMBERS: ReadonlySet<string> = new Set([
|
|
111
|
+
"spawn",
|
|
112
|
+
"spawnSync",
|
|
113
|
+
"$",
|
|
114
|
+
"file",
|
|
115
|
+
"write",
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
const GLOBAL_OBJECT_NAMES: ReadonlySet<string> = new Set([
|
|
119
|
+
"globalThis",
|
|
120
|
+
"window",
|
|
121
|
+
"self",
|
|
122
|
+
"global",
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
const COMMENT_ONLY_LINE_PATTERN = /^\s*(?:\/\/|\/\*|\*)/;
|
|
126
|
+
const ALLOW_COMMENT_PATTERN =
|
|
127
|
+
/@apifuse-allow\s+([a-z][a-z0-9-]*)\b(?:\s*:\s*(.*?))?\s*(?:\*\/)?\s*$/;
|
|
128
|
+
|
|
129
|
+
type RuntimeBoundaryFinding = {
|
|
130
|
+
rule: RuntimeBoundaryRule;
|
|
131
|
+
/** 1-based line. */
|
|
132
|
+
line: number;
|
|
133
|
+
/** What was reached: the env name, module specifier, or call text. */
|
|
134
|
+
subject: string;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
type RuntimeBoundaryFileReport = {
|
|
138
|
+
findings: RuntimeBoundaryFinding[];
|
|
139
|
+
acknowledged: Array<RuntimeBoundaryFinding & { reason: string }>;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** True for the files the runtime boundary rules look at. */
|
|
143
|
+
export function isRuntimeBoundarySourceFile(relativePath: string): boolean {
|
|
144
|
+
const normalized = relativePath.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
145
|
+
return (
|
|
146
|
+
JAVASCRIPT_SOURCE_FILE_PATTERN.test(normalized) &&
|
|
147
|
+
!DECLARATION_FILE_PATTERN.test(normalized) &&
|
|
148
|
+
!TEST_SOURCE_FILE_PATTERN.test(normalized) &&
|
|
149
|
+
!RECORDED_FIXTURE_SOURCE_FILE_PATTERN.test(normalized) &&
|
|
150
|
+
!BOOTSTRAP_ENTRYPOINT_FILE_PATTERN.test(normalized) &&
|
|
151
|
+
!TOOLING_DIRECTORY_PATTERN.test(normalized)
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function normalizeModuleSpecifier(specifier: string): string {
|
|
156
|
+
return specifier.startsWith("node:") ? specifier.slice("node:".length) : specifier;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isForbiddenNodeModule(specifier: string): boolean {
|
|
160
|
+
return FORBIDDEN_NODE_MODULES.has(normalizeModuleSpecifier(specifier));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function staticString(ts: TypeScriptModule, node: TsNode | undefined): string | undefined {
|
|
164
|
+
if (!node) return undefined;
|
|
165
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function unwrapExpression(
|
|
170
|
+
ts: TypeScriptModule,
|
|
171
|
+
expression: import("typescript").Expression,
|
|
172
|
+
): import("typescript").Expression {
|
|
173
|
+
let current = expression;
|
|
174
|
+
while (
|
|
175
|
+
ts.isParenthesizedExpression(current) ||
|
|
176
|
+
ts.isAsExpression(current) ||
|
|
177
|
+
ts.isSatisfiesExpression(current) ||
|
|
178
|
+
ts.isNonNullExpression(current) ||
|
|
179
|
+
ts.isTypeAssertionExpression(current)
|
|
180
|
+
) {
|
|
181
|
+
current = current.expression;
|
|
182
|
+
}
|
|
183
|
+
return current;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** `process` / `globalThis.process` / `Bun` / `globalThis.Bun` */
|
|
187
|
+
function isGlobalReference(ts: TypeScriptModule, node: TsNode, name: string): boolean {
|
|
188
|
+
if (ts.isIdentifier(node)) return node.text === name;
|
|
189
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
190
|
+
return (
|
|
191
|
+
ts.isIdentifier(node.expression) &&
|
|
192
|
+
GLOBAL_OBJECT_NAMES.has(node.expression.text) &&
|
|
193
|
+
node.name.text === name
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
if (ts.isElementAccessExpression(node)) {
|
|
197
|
+
return (
|
|
198
|
+
ts.isIdentifier(node.expression) &&
|
|
199
|
+
GLOBAL_OBJECT_NAMES.has(node.expression.text) &&
|
|
200
|
+
staticString(ts, node.argumentExpression) === name
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** `process.env`, `process["env"]`, `Bun.env`, and their globalThis-prefixed forms. */
|
|
207
|
+
function envObjectOwner(ts: TypeScriptModule, node: TsNode): "process" | "Bun" | undefined {
|
|
208
|
+
let owner: TsNode | undefined;
|
|
209
|
+
let member: string | undefined;
|
|
210
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
211
|
+
owner = node.expression;
|
|
212
|
+
member = node.name.text;
|
|
213
|
+
} else if (ts.isElementAccessExpression(node)) {
|
|
214
|
+
owner = node.expression;
|
|
215
|
+
member = staticString(ts, node.argumentExpression);
|
|
216
|
+
}
|
|
217
|
+
if (!owner || member !== "env") return undefined;
|
|
218
|
+
const unwrapped = unwrapExpression(ts, owner as import("typescript").Expression);
|
|
219
|
+
if (isGlobalReference(ts, unwrapped, "process")) return "process";
|
|
220
|
+
if (isGlobalReference(ts, unwrapped, "Bun")) return "Bun";
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* For an env-object node, the statically known key read from it
|
|
226
|
+
* (`process.env.KEY`, `process.env["KEY"]`), or `undefined` when the object
|
|
227
|
+
* itself is used (spread, passed along, dynamic key).
|
|
228
|
+
*/
|
|
229
|
+
function envKeyReadFrom(ts: TypeScriptModule, envNode: TsNode): string | undefined {
|
|
230
|
+
const parent = envNode.parent;
|
|
231
|
+
if (!parent) return undefined;
|
|
232
|
+
if (ts.isPropertyAccessExpression(parent) && parent.expression === envNode) {
|
|
233
|
+
return parent.name.text;
|
|
234
|
+
}
|
|
235
|
+
if (ts.isElementAccessExpression(parent) && parent.expression === envNode) {
|
|
236
|
+
return staticString(ts, parent.argumentExpression);
|
|
237
|
+
}
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* True when the import statement still loads the module at runtime. A
|
|
243
|
+
* clause-level `import type`, and a named import whose every binding is
|
|
244
|
+
* inline `type`, are erased by Bun and by tsc (without
|
|
245
|
+
* `verbatimModuleSyntax`), so they never reach the module. A side-effect
|
|
246
|
+
* import (`import "node:fs"`), a default or namespace binding, or any
|
|
247
|
+
* value-level named binding does load it.
|
|
248
|
+
*/
|
|
249
|
+
function importDeclarationLoadsModule(
|
|
250
|
+
ts: TypeScriptModule,
|
|
251
|
+
node: import("typescript").ImportDeclaration,
|
|
252
|
+
): boolean {
|
|
253
|
+
const clause = node.importClause;
|
|
254
|
+
if (!clause) return true;
|
|
255
|
+
if (clause.isTypeOnly) return false;
|
|
256
|
+
if (clause.name) return true;
|
|
257
|
+
const bindings = clause.namedBindings;
|
|
258
|
+
if (!bindings) return true;
|
|
259
|
+
if (ts.isNamespaceImport(bindings)) return true;
|
|
260
|
+
return bindings.elements.some((element) => !element.isTypeOnly);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Same rule for `export … from`: erased when type-only at the clause or on every specifier. */
|
|
264
|
+
function exportDeclarationLoadsModule(
|
|
265
|
+
ts: TypeScriptModule,
|
|
266
|
+
node: import("typescript").ExportDeclaration,
|
|
267
|
+
): boolean {
|
|
268
|
+
if (node.isTypeOnly) return false;
|
|
269
|
+
const clause = node.exportClause;
|
|
270
|
+
if (!clause) return true;
|
|
271
|
+
if (ts.isNamespaceExport(clause)) return true;
|
|
272
|
+
return clause.elements.some((element) => !element.isTypeOnly);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Script kind for a provider source file, from its extension. `.tsx`/`.jsx`
|
|
277
|
+
* enable JSX; everything else (`.ts`, `.mts`, `.cts`, `.js`, `.mjs`, `.cjs`,
|
|
278
|
+
* and the synthetic names used for in-memory sources) does not, so a generic
|
|
279
|
+
* arrow or `<T>value` assertion in a `.ts` file parses as TypeScript instead
|
|
280
|
+
* of swallowing the rest of the file as unterminated JSX.
|
|
281
|
+
*/
|
|
282
|
+
export function scriptKindForSourceFile(
|
|
283
|
+
ts: TypeScriptModule,
|
|
284
|
+
fileName: string,
|
|
285
|
+
): import("typescript").ScriptKind {
|
|
286
|
+
const lower = fileName.toLowerCase();
|
|
287
|
+
if (lower.endsWith(".tsx")) return ts.ScriptKind.TSX;
|
|
288
|
+
if (lower.endsWith(".jsx")) return ts.ScriptKind.JSX;
|
|
289
|
+
if (/\.[cm]?js$/.test(lower)) return ts.ScriptKind.JS;
|
|
290
|
+
return ts.ScriptKind.TS;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Scope node → value bindings declared directly in that scope. */
|
|
294
|
+
type ScopeBindings = Map<TsNode, Set<string>>;
|
|
295
|
+
|
|
296
|
+
/** Containers that own `let`/`const`/`class`/`function` declarations. */
|
|
297
|
+
function isBlockScopeContainer(ts: TypeScriptModule, node: TsNode): boolean {
|
|
298
|
+
return (
|
|
299
|
+
ts.isSourceFile(node) ||
|
|
300
|
+
ts.isBlock(node) ||
|
|
301
|
+
ts.isModuleBlock(node) ||
|
|
302
|
+
ts.isCaseBlock(node) ||
|
|
303
|
+
ts.isForStatement(node) ||
|
|
304
|
+
ts.isForInStatement(node) ||
|
|
305
|
+
ts.isForOfStatement(node)
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Containers that own `var` declarations (hoisted past blocks). */
|
|
310
|
+
function isFunctionScopeContainer(ts: TypeScriptModule, node: TsNode): boolean {
|
|
311
|
+
return (
|
|
312
|
+
ts.isSourceFile(node) ||
|
|
313
|
+
ts.isModuleBlock(node) ||
|
|
314
|
+
ts.isFunctionLike(node) ||
|
|
315
|
+
ts.isClassStaticBlockDeclaration(node)
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function nearestAncestor(
|
|
320
|
+
node: TsNode,
|
|
321
|
+
sourceFile: TsSourceFile,
|
|
322
|
+
predicate: (candidate: TsNode) => boolean,
|
|
323
|
+
): TsNode {
|
|
324
|
+
let current: TsNode | undefined = node.parent;
|
|
325
|
+
while (current && !predicate(current)) current = current.parent;
|
|
326
|
+
return current ?? sourceFile;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Value bindings per lexical scope: parameters on their function, `let`/`const`
|
|
331
|
+
* and named function/class declarations on the enclosing block, `var` on the
|
|
332
|
+
* enclosing function body, imports on the file, a catch variable on its
|
|
333
|
+
* clause, a named function/class expression on itself. Destructuring patterns
|
|
334
|
+
* contribute every leaf name.
|
|
335
|
+
*/
|
|
336
|
+
function collectScopeBindings(ts: TypeScriptModule, sourceFile: TsSourceFile): ScopeBindings {
|
|
337
|
+
const bindings: ScopeBindings = new Map();
|
|
338
|
+
const bind = (scope: TsNode, name: string) => {
|
|
339
|
+
const names = bindings.get(scope) ?? new Set<string>();
|
|
340
|
+
names.add(name);
|
|
341
|
+
bindings.set(scope, names);
|
|
342
|
+
};
|
|
343
|
+
const bindPattern = (scope: TsNode, name: import("typescript").BindingName) => {
|
|
344
|
+
if (ts.isIdentifier(name)) {
|
|
345
|
+
bind(scope, name.text);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
for (const element of name.elements) {
|
|
349
|
+
if (ts.isBindingElement(element)) bindPattern(scope, element.name);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
const blockScopeOf = (node: TsNode) => {
|
|
353
|
+
const container = nearestAncestor(node, sourceFile, (candidate) =>
|
|
354
|
+
isBlockScopeContainer(ts, candidate),
|
|
355
|
+
);
|
|
356
|
+
// A for-in/for-of binding is visible in the loop body only; the iterable
|
|
357
|
+
// expression is evaluated before it exists (`for (const fetch of
|
|
358
|
+
// fetch(url))` reaches past the loop binding).
|
|
359
|
+
if (ts.isForInStatement(container) || ts.isForOfStatement(container)) {
|
|
360
|
+
return container.statement;
|
|
361
|
+
}
|
|
362
|
+
return container;
|
|
363
|
+
};
|
|
364
|
+
const functionScopeOf = (node: TsNode) => {
|
|
365
|
+
const container = nearestAncestor(node, sourceFile, (candidate) =>
|
|
366
|
+
isFunctionScopeContainer(ts, candidate),
|
|
367
|
+
);
|
|
368
|
+
// `var` hoists to the function *body*, which parameter initializers cannot
|
|
369
|
+
// see: `function load(result = fetch(url)) { var fetch = transport; }`
|
|
370
|
+
// still calls the global in the initializer.
|
|
371
|
+
const body = (container as { body?: TsNode }).body;
|
|
372
|
+
if (ts.isFunctionLike(container) && body && ts.isBlock(body)) return body;
|
|
373
|
+
return container;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const visit = (node: TsNode) => {
|
|
377
|
+
if (ts.isVariableDeclaration(node)) {
|
|
378
|
+
if (ts.isCatchClause(node.parent)) {
|
|
379
|
+
bindPattern(node.parent, node.name);
|
|
380
|
+
} else if (ts.getCombinedNodeFlags(node) & ts.NodeFlags.BlockScoped) {
|
|
381
|
+
bindPattern(blockScopeOf(node.parent), node.name);
|
|
382
|
+
} else {
|
|
383
|
+
bindPattern(functionScopeOf(node.parent), node.name);
|
|
384
|
+
}
|
|
385
|
+
} else if (ts.isParameter(node)) {
|
|
386
|
+
bindPattern(node.parent, node.name);
|
|
387
|
+
} else if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) {
|
|
388
|
+
if (node.name) bind(blockScopeOf(node), node.name.text);
|
|
389
|
+
} else if (ts.isFunctionExpression(node) || ts.isClassExpression(node)) {
|
|
390
|
+
if (node.name) bind(node, node.name.text);
|
|
391
|
+
} else if (ts.isEnumDeclaration(node)) {
|
|
392
|
+
bind(blockScopeOf(node), node.name.text);
|
|
393
|
+
} else if (ts.isModuleDeclaration(node) && ts.isIdentifier(node.name)) {
|
|
394
|
+
bind(blockScopeOf(node), node.name.text);
|
|
395
|
+
} else if (ts.isImportClause(node) || ts.isImportSpecifier(node)) {
|
|
396
|
+
if (node.name) bind(sourceFile, node.name.text);
|
|
397
|
+
} else if (ts.isNamespaceImport(node) || ts.isImportEqualsDeclaration(node)) {
|
|
398
|
+
bind(sourceFile, node.name.text);
|
|
399
|
+
}
|
|
400
|
+
ts.forEachChild(node, visit);
|
|
401
|
+
};
|
|
402
|
+
visit(sourceFile);
|
|
403
|
+
return bindings;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** True when a scope enclosing `node` (or `node` itself) declares `name`. */
|
|
407
|
+
function isBindingInScope(bindings: ScopeBindings, node: TsNode, name: string): boolean {
|
|
408
|
+
let current: TsNode | undefined = node;
|
|
409
|
+
while (current) {
|
|
410
|
+
if (bindings.get(current)?.has(name)) return true;
|
|
411
|
+
current = current.parent;
|
|
412
|
+
}
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function readAllowComment(
|
|
417
|
+
lines: readonly string[],
|
|
418
|
+
line: number,
|
|
419
|
+
rule: RuntimeBoundaryRule,
|
|
420
|
+
): string | undefined {
|
|
421
|
+
const current = lines[line - 1];
|
|
422
|
+
const previous = lines[line - 2];
|
|
423
|
+
// A trailing comment on the finding line, or a comment-only line directly
|
|
424
|
+
// above it. A trailing acknowledgement on the previous line belongs to that
|
|
425
|
+
// line's finding and must not leak onto this one.
|
|
426
|
+
const candidates = [current, previous?.match(COMMENT_ONLY_LINE_PATTERN) ? previous : undefined];
|
|
427
|
+
for (const candidate of candidates) {
|
|
428
|
+
if (candidate === undefined) continue;
|
|
429
|
+
const match = ALLOW_COMMENT_PATTERN.exec(candidate);
|
|
430
|
+
if (match && match[1] === rule) {
|
|
431
|
+
return match[2]?.trim() || "(no reason given)";
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function analyzeRuntimeBoundary(
|
|
438
|
+
ts: TypeScriptModule,
|
|
439
|
+
fileName: string,
|
|
440
|
+
source: string,
|
|
441
|
+
): RuntimeBoundaryFileReport {
|
|
442
|
+
const sourceFile = ts.createSourceFile(
|
|
443
|
+
fileName,
|
|
444
|
+
source,
|
|
445
|
+
ts.ScriptTarget.Latest,
|
|
446
|
+
true,
|
|
447
|
+
scriptKindForSourceFile(ts, fileName),
|
|
448
|
+
);
|
|
449
|
+
const lines = source.split(/\r?\n/);
|
|
450
|
+
const scopeBindings = collectScopeBindings(ts, sourceFile);
|
|
451
|
+
const raw: RuntimeBoundaryFinding[] = [];
|
|
452
|
+
const seen = new Set<string>();
|
|
453
|
+
|
|
454
|
+
const record = (rule: RuntimeBoundaryRule, node: TsNode, subject: string) => {
|
|
455
|
+
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
456
|
+
const key = `${rule}:${line}:${subject}`;
|
|
457
|
+
if (seen.has(key)) return;
|
|
458
|
+
seen.add(key);
|
|
459
|
+
raw.push({ rule, line, subject });
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const visit = (node: TsNode) => {
|
|
463
|
+
// --- process.env / Bun.env ---------------------------------------
|
|
464
|
+
const envOwner = envObjectOwner(ts, node);
|
|
465
|
+
if (envOwner) {
|
|
466
|
+
const key = envKeyReadFrom(ts, node);
|
|
467
|
+
if (key === undefined || !isBootstrapEnvName(key)) {
|
|
468
|
+
record(
|
|
469
|
+
PROCESS_ENV_DIRECT_READ_RULE,
|
|
470
|
+
node,
|
|
471
|
+
key === undefined ? `${envOwner}.env` : `${envOwner}.env.${key}`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
// Do not descend: the owner is a global, nothing below is interesting.
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// --- node:fs / node:net / node:child_process … ---------------------
|
|
479
|
+
if (ts.isImportDeclaration(node)) {
|
|
480
|
+
const specifier = staticString(ts, node.moduleSpecifier);
|
|
481
|
+
if (specifier && importDeclarationLoadsModule(ts, node) && isForbiddenNodeModule(specifier)) {
|
|
482
|
+
record(NODE_RUNTIME_MODULE_IMPORT_RULE, node, specifier);
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (ts.isExportDeclaration(node)) {
|
|
487
|
+
const specifier = staticString(ts, node.moduleSpecifier);
|
|
488
|
+
if (specifier && exportDeclarationLoadsModule(ts, node) && isForbiddenNodeModule(specifier)) {
|
|
489
|
+
record(NODE_RUNTIME_MODULE_IMPORT_RULE, node, specifier);
|
|
490
|
+
}
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (ts.isImportEqualsDeclaration(node) && !node.isTypeOnly) {
|
|
494
|
+
const reference = node.moduleReference;
|
|
495
|
+
if (ts.isExternalModuleReference(reference)) {
|
|
496
|
+
const specifier = staticString(ts, reference.expression);
|
|
497
|
+
if (specifier && isForbiddenNodeModule(specifier)) {
|
|
498
|
+
record(NODE_RUNTIME_MODULE_IMPORT_RULE, node, specifier);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (ts.isCallExpression(node)) {
|
|
504
|
+
const callee = unwrapExpression(ts, node.expression);
|
|
505
|
+
// require("node:fs") / import("node:fs")
|
|
506
|
+
if (
|
|
507
|
+
(ts.isIdentifier(callee) && callee.text === "require") ||
|
|
508
|
+
callee.kind === ts.SyntaxKind.ImportKeyword
|
|
509
|
+
) {
|
|
510
|
+
const specifier = staticString(ts, node.arguments[0]);
|
|
511
|
+
if (specifier && isForbiddenNodeModule(specifier)) {
|
|
512
|
+
record(NODE_RUNTIME_MODULE_IMPORT_RULE, node, specifier);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// Bun.spawn(...) / Bun.file(...) / Bun.write(...) / Bun.$(...)
|
|
516
|
+
if (ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) {
|
|
517
|
+
const owner = unwrapExpression(ts, callee.expression);
|
|
518
|
+
const member = ts.isPropertyAccessExpression(callee)
|
|
519
|
+
? callee.name.text
|
|
520
|
+
: staticString(ts, callee.argumentExpression);
|
|
521
|
+
if (
|
|
522
|
+
member !== undefined &&
|
|
523
|
+
FORBIDDEN_BUN_MEMBERS.has(member) &&
|
|
524
|
+
isGlobalReference(ts, owner, "Bun")
|
|
525
|
+
) {
|
|
526
|
+
record(NODE_RUNTIME_MODULE_IMPORT_RULE, node, `Bun.${member}()`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
// fetch(...) / globalThis.fetch(...)
|
|
530
|
+
if (
|
|
531
|
+
ts.isIdentifier(callee) &&
|
|
532
|
+
callee.text === "fetch" &&
|
|
533
|
+
!isBindingInScope(scopeBindings, node, "fetch")
|
|
534
|
+
) {
|
|
535
|
+
record(DIRECT_FETCH_CALL_RULE, node, "fetch()");
|
|
536
|
+
} else if (
|
|
537
|
+
(ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) &&
|
|
538
|
+
isGlobalReference(ts, callee, "fetch")
|
|
539
|
+
) {
|
|
540
|
+
record(DIRECT_FETCH_CALL_RULE, node, "globalThis.fetch()");
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// Bun.$`…` tagged template
|
|
544
|
+
if (ts.isTaggedTemplateExpression(node)) {
|
|
545
|
+
const tag = unwrapExpression(ts, node.tag);
|
|
546
|
+
if (
|
|
547
|
+
ts.isPropertyAccessExpression(tag) &&
|
|
548
|
+
tag.name.text === "$" &&
|
|
549
|
+
isGlobalReference(ts, unwrapExpression(ts, tag.expression), "Bun")
|
|
550
|
+
) {
|
|
551
|
+
record(NODE_RUNTIME_MODULE_IMPORT_RULE, node, "Bun.$``");
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
ts.forEachChild(node, visit);
|
|
555
|
+
};
|
|
556
|
+
visit(sourceFile);
|
|
557
|
+
|
|
558
|
+
const findings: RuntimeBoundaryFinding[] = [];
|
|
559
|
+
const acknowledged: Array<RuntimeBoundaryFinding & { reason: string }> = [];
|
|
560
|
+
for (const finding of raw) {
|
|
561
|
+
const reason = readAllowComment(lines, finding.line, finding.rule);
|
|
562
|
+
if (reason === undefined) findings.push(finding);
|
|
563
|
+
else acknowledged.push({ ...finding, reason });
|
|
564
|
+
}
|
|
565
|
+
return { findings, acknowledged };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function describeSubjects(findings: readonly RuntimeBoundaryFinding[]): string {
|
|
569
|
+
const bySubject = new Map<string, number[]>();
|
|
570
|
+
for (const finding of findings) {
|
|
571
|
+
const lines = bySubject.get(finding.subject) ?? [];
|
|
572
|
+
lines.push(finding.line);
|
|
573
|
+
bySubject.set(finding.subject, lines);
|
|
574
|
+
}
|
|
575
|
+
return [...bySubject.entries()]
|
|
576
|
+
.map(([subject, lines]) => `${subject} (line ${lines.join(", ")})`)
|
|
577
|
+
.join("; ");
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function remediation(rule: RuntimeBoundaryRule): string {
|
|
581
|
+
switch (rule) {
|
|
582
|
+
case PROCESS_ENV_DIRECT_READ_RULE:
|
|
583
|
+
return `Provider runtime source reads the ambient environment directly. ctx.env.get(name) sees only the names declared in defineProvider secrets[].name (engine-owned names excluded); declare the value there so the SDK presence gate, redaction, and Doppler projection see it. env: true only enables the ctx.env capability (type and runtime gate) and does not widen the readable names. Test/E2E toggles are not secrets and cannot move to ctx.env: replace them with test doubles (inject ctx.env / ctx.http) or recorded fixtures. Only the ${BOOTSTRAP_ENV_NAME_PREFIX}* bootstrap family may be read from process.env, and only dev.ts/start.ts/deploy.ts and scripts/, tools/, bin/ are outside this rule. Acknowledge a deliberate exception with \`// @apifuse-allow ${PROCESS_ENV_DIRECT_READ_RULE}: <reason>\`.`;
|
|
584
|
+
case NODE_RUNTIME_MODULE_IMPORT_RULE:
|
|
585
|
+
return `Provider runtime source reaches the filesystem, the network, or child processes on its own. Use ctx.http / ctx.stealth for HTTP, ctx.browser for browser work, ctx.files / ctx.cache / ctx.state for data that must persist, and keep recorder or smoke tooling under scripts/. Acknowledge a deliberate exception with \`// @apifuse-allow ${NODE_RUNTIME_MODULE_IMPORT_RULE}: <reason>\`.`;
|
|
586
|
+
case DIRECT_FETCH_CALL_RULE:
|
|
587
|
+
return `Provider runtime source calls the global fetch(), bypassing allowedHosts, proxy policy, retries, redaction, and telemetry. Use ctx.http.get/post/request for ordinary HTTP and ctx.stealth.fetch() for browser-shaped requests. Acknowledge a deliberate exception with \`// @apifuse-allow ${DIRECT_FETCH_CALL_RULE}: <reason>\`.`;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export type RuntimeBoundaryLintInput = {
|
|
592
|
+
authFlowSource?: string;
|
|
593
|
+
providerSourceFiles?: Record<string, string>;
|
|
594
|
+
operations?: Record<string, { handler?: unknown; source?: string }>;
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Lint the provider runtime boundary. Returns warn-level diagnostics (one per
|
|
599
|
+
* file and rule, listing every line) and information entries for findings
|
|
600
|
+
* acknowledged with `@apifuse-allow`.
|
|
601
|
+
*/
|
|
602
|
+
export function lintRuntimeBoundary(
|
|
603
|
+
ts: TypeScriptModule,
|
|
604
|
+
provider: RuntimeBoundaryLintInput,
|
|
605
|
+
readOperationSource: (operation: { handler?: unknown; source?: string }) => string,
|
|
606
|
+
): { diagnostics: LintDiagnostic[]; information: ProviderLintInformation[] } {
|
|
607
|
+
const sources: Array<{ field: string; fileName: string; source: string }> = [];
|
|
608
|
+
const sourceFiles = Object.entries(provider.providerSourceFiles ?? {}).filter(([filePath]) =>
|
|
609
|
+
isRuntimeBoundarySourceFile(filePath),
|
|
610
|
+
);
|
|
611
|
+
if (Object.keys(provider.providerSourceFiles ?? {}).length > 0) {
|
|
612
|
+
for (const [filePath, source] of sourceFiles) {
|
|
613
|
+
sources.push({ field: `sourceFiles.${filePath}`, fileName: filePath, source });
|
|
614
|
+
}
|
|
615
|
+
} else {
|
|
616
|
+
// In-memory providers (tests, embedded definitions) have no file tree;
|
|
617
|
+
// fall back to the auth flow and operation sources the way the other
|
|
618
|
+
// source rules do.
|
|
619
|
+
if (provider.authFlowSource) {
|
|
620
|
+
sources.push({
|
|
621
|
+
field: "auth.flow",
|
|
622
|
+
fileName: "auth-flow.ts",
|
|
623
|
+
source: provider.authFlowSource,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
|
|
627
|
+
const source = readOperationSource(operation);
|
|
628
|
+
if (source) {
|
|
629
|
+
sources.push({
|
|
630
|
+
field: `operations.${operationKey}.handler`,
|
|
631
|
+
fileName: `${operationKey}.ts`,
|
|
632
|
+
source,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
639
|
+
const information: ProviderLintInformation[] = [];
|
|
640
|
+
for (const { field, fileName, source } of sources) {
|
|
641
|
+
const report = analyzeRuntimeBoundary(ts, fileName, source);
|
|
642
|
+
for (const rule of RUNTIME_BOUNDARY_RULES) {
|
|
643
|
+
const findings = report.findings.filter((finding) => finding.rule === rule);
|
|
644
|
+
if (findings.length > 0) {
|
|
645
|
+
diagnostics.push({
|
|
646
|
+
rule,
|
|
647
|
+
level: "warn",
|
|
648
|
+
field,
|
|
649
|
+
message: `${describeSubjects(findings)}: ${remediation(rule)}`,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
for (const finding of report.acknowledged.filter((entry) => entry.rule === rule)) {
|
|
653
|
+
information.push({
|
|
654
|
+
rule,
|
|
655
|
+
field,
|
|
656
|
+
message: `Acknowledged ${finding.subject} at line ${finding.line}. Reason: ${finding.reason}`,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return { diagnostics, information };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export type LintRuntimeBoundarySourcesOptions = {
|
|
665
|
+
/**
|
|
666
|
+
* A loaded TypeScript compiler API module to parse with (`typescript` 6.x
|
|
667
|
+
* or `@typescript/typescript6`). When omitted the SDK resolves one from its
|
|
668
|
+
* own install location, trying `typescript` first and falling back to
|
|
669
|
+
* `@typescript/typescript6` when `typescript` is the 7.x native shell
|
|
670
|
+
* without `createSourceFile`. A caller whose dependency tree pins its own
|
|
671
|
+
* compiler module (the APIFuse monorepo contract check) passes it here so
|
|
672
|
+
* the parse does not depend on what hoists next to the SDK.
|
|
673
|
+
*/
|
|
674
|
+
typescript?: TypeScriptCompilerModuleLike;
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Lint provider runtime source files for the runtime boundary rules without a
|
|
679
|
+
* provider definition: `files` maps provider-root-relative paths to source
|
|
680
|
+
* text (the same shape `apifuse check` collects as `providerSourceFiles`).
|
|
681
|
+
* Out-of-scope paths (tests, recorded fixtures, `.d.ts`, the root
|
|
682
|
+
* `dev.ts`/`start.ts`/`deploy.ts`, `scripts/`, `tools/`, `bin/`) are skipped
|
|
683
|
+
* with `isRuntimeBoundarySourceFile`. Diagnostics carry
|
|
684
|
+
* `field: "sourceFiles.<path>"`; `@apifuse-allow` acknowledgements come back
|
|
685
|
+
* as `information`.
|
|
686
|
+
*
|
|
687
|
+
* Parses with `options.typescript` when given, otherwise with the compiler
|
|
688
|
+
* module resolved from the installed SDK (see `getTypeScript`). This is the
|
|
689
|
+
* entry point the platform contract check uses to apply the rules with the
|
|
690
|
+
* monorepo's SDK pin instead of each provider's own pin.
|
|
691
|
+
*/
|
|
692
|
+
export function lintRuntimeBoundarySources(
|
|
693
|
+
files: Record<string, string>,
|
|
694
|
+
options: LintRuntimeBoundarySourcesOptions = {},
|
|
695
|
+
): ProviderLintResult {
|
|
696
|
+
const ts = options.typescript ?? getTypeScript();
|
|
697
|
+
if (!isTypeScriptCompilerModule(ts)) {
|
|
698
|
+
throw new TypeError(
|
|
699
|
+
"lintRuntimeBoundarySources: options.typescript must be a loaded TypeScript compiler API module (typescript 6.x or @typescript/typescript6) exposing createSourceFile.",
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
return lintRuntimeBoundary(ts, { providerSourceFiles: files }, () => "");
|
|
703
|
+
}
|