@apifuse/provider-sdk 2.2.0-beta.63 → 2.2.0-beta.65

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.
@@ -0,0 +1,590 @@
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, reported at `error` level: a finding fails `apifuse check` and
7
+ * `apifuse submit-check` (official and standalone mode alike). The first
8
+ * release reported them at `warn` as a migration window; the window closed once
9
+ * the fleet's request-path hits were fixed or acknowledged. A deliberate
10
+ * exception is acknowledged in code (see below), never through a hidden
11
+ * allowlist.
12
+ *
13
+ * - `process-env-direct-read`: `process.env.X`, `process.env["X"]`, a bare
14
+ * `process.env` object use, and the `Bun.env` equivalents. Runtime bootstrap
15
+ * keys (`APIFUSE__RUNTIME__*`) are exempt everywhere because `serve()`
16
+ * callers read the port and pod identity before a context exists.
17
+ * - `node-runtime-module-import`: value imports of `fs`, `fs/promises`, `net`,
18
+ * `tls`, `dgram`, `http`, `https`, `http2`, `child_process` (with or without
19
+ * the `node:` prefix) via import/export declarations, `require()`, or dynamic
20
+ * `import()`, plus the Bun equivalents `Bun.spawn`, `Bun.spawnSync`, `Bun.$`,
21
+ * `Bun.file`, `Bun.write`. Type-only imports are not runtime reach and are
22
+ * ignored.
23
+ * - `direct-fetch-call`: a call to the global `fetch` (bare identifier, or
24
+ * `globalThis`/`window`/`self`/`global` member). A bare `fetch` call whose
25
+ * enclosing scope declares a `fetch` binding (parameter, variable, import,
26
+ * function, catch clause) is calling that binding, not the global, and is
27
+ * left alone; the check is lexical, so a wrapper that takes `fetch` as a
28
+ * parameter does not hide a global `fetch()` elsewhere in the same file.
29
+ * `ctx.stealth.fetch()` is a member call and never matches.
30
+ *
31
+ * Files are parsed with the script kind their extension implies (`.ts` as
32
+ * TypeScript, `.tsx` as TSX, …). Parsing everything as TSX would make a
33
+ * generic arrow (`<T>(value: T) => value`) or an angle-bracket assertion in an
34
+ * ordinary `.ts` file read as JSX, and everything after it would silently drop
35
+ * out of the AST.
36
+ *
37
+ * Scope is the provider *runtime* source: JavaScript/TypeScript files under
38
+ * `providerSourceFiles` minus tests, recorded fixtures, the root bootstrap
39
+ * entrypoints (`dev.ts`, `start.ts`, `deploy.ts`) and the operator tooling
40
+ * directories (`scripts/`, `tools/`, `bin/`). Tooling legitimately reads the
41
+ * ambient environment and the filesystem when a human runs it; the rule is
42
+ * about code the pod executes on behalf of a request.
43
+ *
44
+ * A deliberate exception is acknowledged with the same escape hatch the submit
45
+ * check uses: `// @apifuse-allow <rule>: <reason>` on the finding line or the
46
+ * line directly above it. Acknowledged findings are reported as information so
47
+ * `apifuse check` still lists them.
48
+ */
49
+ import { getTypeScript, isTypeScriptCompilerModule, } from "./typescript-module.js";
50
+ export const PROCESS_ENV_DIRECT_READ_RULE = "process-env-direct-read";
51
+ export const NODE_RUNTIME_MODULE_IMPORT_RULE = "node-runtime-module-import";
52
+ export const DIRECT_FETCH_CALL_RULE = "direct-fetch-call";
53
+ export const RUNTIME_BOUNDARY_RULES = [
54
+ PROCESS_ENV_DIRECT_READ_RULE,
55
+ NODE_RUNTIME_MODULE_IMPORT_RULE,
56
+ DIRECT_FETCH_CALL_RULE,
57
+ ];
58
+ /**
59
+ * Environment names a provider may read from `process.env` directly. These
60
+ * are consumed before `serve()` builds a context (port, pod identity), so
61
+ * `ctx.env` cannot carry them. The whole `APIFUSE__RUNTIME__` family is the
62
+ * bootstrap contract; nothing else is exempt.
63
+ */
64
+ export const BOOTSTRAP_ENV_NAME_PREFIX = "APIFUSE__RUNTIME__";
65
+ export function isBootstrapEnvName(name) {
66
+ return name.startsWith(BOOTSTRAP_ENV_NAME_PREFIX);
67
+ }
68
+ /** Root entrypoints `serve()`/`startDevServer()` are called from; never request-path code. */
69
+ const BOOTSTRAP_ENTRYPOINT_FILE_PATTERN = /^(?:dev|start|deploy)\.[cm]?[jt]sx?$/;
70
+ /** Operator tooling directories at the provider root (fixture recorders, smoke scripts). */
71
+ const TOOLING_DIRECTORY_PATTERN = /^(?:scripts|tools|bin)\//;
72
+ export const TEST_SOURCE_FILE_PATTERN = /(?:^|\/)(?:__tests__|__mocks__|tests)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
73
+ const RECORDED_FIXTURE_SOURCE_FILE_PATTERN = /(?:^|\/)__fixtures__(?:\/|$)|(?:^|\/)__tests__\/fixtures(?:\/|$)/;
74
+ const JAVASCRIPT_SOURCE_FILE_PATTERN = /\.[cm]?[jt]sx?$/;
75
+ const DECLARATION_FILE_PATTERN = /\.d\.[cm]?ts$/;
76
+ /**
77
+ * Node built-ins whose value import means the provider talks to the
78
+ * filesystem, the network, or child processes on its own. Deliberately not the
79
+ * whole `node:` namespace: `node:path`, `node:crypto`, `node:url`,
80
+ * `node:buffer` and friends are pure helpers the SDK itself uses.
81
+ */
82
+ const FORBIDDEN_NODE_MODULES = new Set([
83
+ "fs",
84
+ "fs/promises",
85
+ "net",
86
+ "tls",
87
+ "dgram",
88
+ "http",
89
+ "https",
90
+ "http2",
91
+ "child_process",
92
+ ]);
93
+ const FORBIDDEN_BUN_MEMBERS = new Set([
94
+ "spawn",
95
+ "spawnSync",
96
+ "$",
97
+ "file",
98
+ "write",
99
+ ]);
100
+ const GLOBAL_OBJECT_NAMES = new Set([
101
+ "globalThis",
102
+ "window",
103
+ "self",
104
+ "global",
105
+ ]);
106
+ const COMMENT_ONLY_LINE_PATTERN = /^\s*(?:\/\/|\/\*|\*)/;
107
+ const ALLOW_COMMENT_PATTERN = /@apifuse-allow\s+([a-z][a-z0-9-]*)\b(?:\s*:\s*(.*?))?\s*(?:\*\/)?\s*$/;
108
+ /** True for the files the runtime boundary rules look at. */
109
+ export function isRuntimeBoundarySourceFile(relativePath) {
110
+ const normalized = relativePath.replaceAll("\\", "/").replace(/^\.\//, "");
111
+ return (JAVASCRIPT_SOURCE_FILE_PATTERN.test(normalized) &&
112
+ !DECLARATION_FILE_PATTERN.test(normalized) &&
113
+ !TEST_SOURCE_FILE_PATTERN.test(normalized) &&
114
+ !RECORDED_FIXTURE_SOURCE_FILE_PATTERN.test(normalized) &&
115
+ !BOOTSTRAP_ENTRYPOINT_FILE_PATTERN.test(normalized) &&
116
+ !TOOLING_DIRECTORY_PATTERN.test(normalized));
117
+ }
118
+ function normalizeModuleSpecifier(specifier) {
119
+ return specifier.startsWith("node:") ? specifier.slice("node:".length) : specifier;
120
+ }
121
+ function isForbiddenNodeModule(specifier) {
122
+ return FORBIDDEN_NODE_MODULES.has(normalizeModuleSpecifier(specifier));
123
+ }
124
+ function staticString(ts, node) {
125
+ if (!node)
126
+ return undefined;
127
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
128
+ return node.text;
129
+ return undefined;
130
+ }
131
+ function unwrapExpression(ts, expression) {
132
+ let current = expression;
133
+ while (ts.isParenthesizedExpression(current) ||
134
+ ts.isAsExpression(current) ||
135
+ ts.isSatisfiesExpression(current) ||
136
+ ts.isNonNullExpression(current) ||
137
+ ts.isTypeAssertionExpression(current)) {
138
+ current = current.expression;
139
+ }
140
+ return current;
141
+ }
142
+ /** `process` / `globalThis.process` / `Bun` / `globalThis.Bun` */
143
+ function isGlobalReference(ts, node, name) {
144
+ if (ts.isIdentifier(node))
145
+ return node.text === name;
146
+ if (ts.isPropertyAccessExpression(node)) {
147
+ return (ts.isIdentifier(node.expression) &&
148
+ GLOBAL_OBJECT_NAMES.has(node.expression.text) &&
149
+ node.name.text === name);
150
+ }
151
+ if (ts.isElementAccessExpression(node)) {
152
+ return (ts.isIdentifier(node.expression) &&
153
+ GLOBAL_OBJECT_NAMES.has(node.expression.text) &&
154
+ staticString(ts, node.argumentExpression) === name);
155
+ }
156
+ return false;
157
+ }
158
+ /** `process.env`, `process["env"]`, `Bun.env`, and their globalThis-prefixed forms. */
159
+ function envObjectOwner(ts, node) {
160
+ let owner;
161
+ let member;
162
+ if (ts.isPropertyAccessExpression(node)) {
163
+ owner = node.expression;
164
+ member = node.name.text;
165
+ }
166
+ else if (ts.isElementAccessExpression(node)) {
167
+ owner = node.expression;
168
+ member = staticString(ts, node.argumentExpression);
169
+ }
170
+ if (!owner || member !== "env")
171
+ return undefined;
172
+ const unwrapped = unwrapExpression(ts, owner);
173
+ if (isGlobalReference(ts, unwrapped, "process"))
174
+ return "process";
175
+ if (isGlobalReference(ts, unwrapped, "Bun"))
176
+ return "Bun";
177
+ return undefined;
178
+ }
179
+ /**
180
+ * For an env-object node, the statically known key read from it
181
+ * (`process.env.KEY`, `process.env["KEY"]`), or `undefined` when the object
182
+ * itself is used (spread, passed along, dynamic key).
183
+ */
184
+ function envKeyReadFrom(ts, envNode) {
185
+ const parent = envNode.parent;
186
+ if (!parent)
187
+ return undefined;
188
+ if (ts.isPropertyAccessExpression(parent) && parent.expression === envNode) {
189
+ return parent.name.text;
190
+ }
191
+ if (ts.isElementAccessExpression(parent) && parent.expression === envNode) {
192
+ return staticString(ts, parent.argumentExpression);
193
+ }
194
+ return undefined;
195
+ }
196
+ /**
197
+ * True when the import statement still loads the module at runtime. A
198
+ * clause-level `import type`, and a named import whose every binding is
199
+ * inline `type`, are erased by Bun and by tsc (without
200
+ * `verbatimModuleSyntax`), so they never reach the module. A side-effect
201
+ * import (`import "node:fs"`), a default or namespace binding, or any
202
+ * value-level named binding does load it.
203
+ */
204
+ function importDeclarationLoadsModule(ts, node) {
205
+ const clause = node.importClause;
206
+ if (!clause)
207
+ return true;
208
+ if (clause.isTypeOnly)
209
+ return false;
210
+ if (clause.name)
211
+ return true;
212
+ const bindings = clause.namedBindings;
213
+ if (!bindings)
214
+ return true;
215
+ if (ts.isNamespaceImport(bindings))
216
+ return true;
217
+ return bindings.elements.some((element) => !element.isTypeOnly);
218
+ }
219
+ /** Same rule for `export … from`: erased when type-only at the clause or on every specifier. */
220
+ function exportDeclarationLoadsModule(ts, node) {
221
+ if (node.isTypeOnly)
222
+ return false;
223
+ const clause = node.exportClause;
224
+ if (!clause)
225
+ return true;
226
+ if (ts.isNamespaceExport(clause))
227
+ return true;
228
+ return clause.elements.some((element) => !element.isTypeOnly);
229
+ }
230
+ /**
231
+ * Script kind for a provider source file, from its extension. `.tsx`/`.jsx`
232
+ * enable JSX; everything else (`.ts`, `.mts`, `.cts`, `.js`, `.mjs`, `.cjs`,
233
+ * and the synthetic names used for in-memory sources) does not, so a generic
234
+ * arrow or `<T>value` assertion in a `.ts` file parses as TypeScript instead
235
+ * of swallowing the rest of the file as unterminated JSX.
236
+ */
237
+ export function scriptKindForSourceFile(ts, fileName) {
238
+ const lower = fileName.toLowerCase();
239
+ if (lower.endsWith(".tsx"))
240
+ return ts.ScriptKind.TSX;
241
+ if (lower.endsWith(".jsx"))
242
+ return ts.ScriptKind.JSX;
243
+ if (/\.[cm]?js$/.test(lower))
244
+ return ts.ScriptKind.JS;
245
+ return ts.ScriptKind.TS;
246
+ }
247
+ /** Containers that own `let`/`const`/`class`/`function` declarations. */
248
+ function isBlockScopeContainer(ts, node) {
249
+ return (ts.isSourceFile(node) ||
250
+ ts.isBlock(node) ||
251
+ ts.isModuleBlock(node) ||
252
+ ts.isCaseBlock(node) ||
253
+ ts.isForStatement(node) ||
254
+ ts.isForInStatement(node) ||
255
+ ts.isForOfStatement(node));
256
+ }
257
+ /** Containers that own `var` declarations (hoisted past blocks). */
258
+ function isFunctionScopeContainer(ts, node) {
259
+ return (ts.isSourceFile(node) ||
260
+ ts.isModuleBlock(node) ||
261
+ ts.isFunctionLike(node) ||
262
+ ts.isClassStaticBlockDeclaration(node));
263
+ }
264
+ function nearestAncestor(node, sourceFile, predicate) {
265
+ let current = node.parent;
266
+ while (current && !predicate(current))
267
+ current = current.parent;
268
+ return current ?? sourceFile;
269
+ }
270
+ /**
271
+ * Value bindings per lexical scope: parameters on their function, `let`/`const`
272
+ * and named function/class declarations on the enclosing block, `var` on the
273
+ * enclosing function body, imports on the file, a catch variable on its
274
+ * clause, a named function/class expression on itself. Destructuring patterns
275
+ * contribute every leaf name.
276
+ */
277
+ function collectScopeBindings(ts, sourceFile) {
278
+ const bindings = new Map();
279
+ const bind = (scope, name) => {
280
+ const names = bindings.get(scope) ?? new Set();
281
+ names.add(name);
282
+ bindings.set(scope, names);
283
+ };
284
+ const bindPattern = (scope, name) => {
285
+ if (ts.isIdentifier(name)) {
286
+ bind(scope, name.text);
287
+ return;
288
+ }
289
+ for (const element of name.elements) {
290
+ if (ts.isBindingElement(element))
291
+ bindPattern(scope, element.name);
292
+ }
293
+ };
294
+ const blockScopeOf = (node) => {
295
+ const container = nearestAncestor(node, sourceFile, (candidate) => isBlockScopeContainer(ts, candidate));
296
+ // A for-in/for-of binding is visible in the loop body only; the iterable
297
+ // expression is evaluated before it exists (`for (const fetch of
298
+ // fetch(url))` reaches past the loop binding).
299
+ if (ts.isForInStatement(container) || ts.isForOfStatement(container)) {
300
+ return container.statement;
301
+ }
302
+ return container;
303
+ };
304
+ const functionScopeOf = (node) => {
305
+ const container = nearestAncestor(node, sourceFile, (candidate) => isFunctionScopeContainer(ts, candidate));
306
+ // `var` hoists to the function *body*, which parameter initializers cannot
307
+ // see: `function load(result = fetch(url)) { var fetch = transport; }`
308
+ // still calls the global in the initializer.
309
+ const body = container.body;
310
+ if (ts.isFunctionLike(container) && body && ts.isBlock(body))
311
+ return body;
312
+ return container;
313
+ };
314
+ const visit = (node) => {
315
+ if (ts.isVariableDeclaration(node)) {
316
+ if (ts.isCatchClause(node.parent)) {
317
+ bindPattern(node.parent, node.name);
318
+ }
319
+ else if (ts.getCombinedNodeFlags(node) & ts.NodeFlags.BlockScoped) {
320
+ bindPattern(blockScopeOf(node.parent), node.name);
321
+ }
322
+ else {
323
+ bindPattern(functionScopeOf(node.parent), node.name);
324
+ }
325
+ }
326
+ else if (ts.isParameter(node)) {
327
+ bindPattern(node.parent, node.name);
328
+ }
329
+ else if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) {
330
+ if (node.name)
331
+ bind(blockScopeOf(node), node.name.text);
332
+ }
333
+ else if (ts.isFunctionExpression(node) || ts.isClassExpression(node)) {
334
+ if (node.name)
335
+ bind(node, node.name.text);
336
+ }
337
+ else if (ts.isEnumDeclaration(node)) {
338
+ bind(blockScopeOf(node), node.name.text);
339
+ }
340
+ else if (ts.isModuleDeclaration(node) && ts.isIdentifier(node.name)) {
341
+ bind(blockScopeOf(node), node.name.text);
342
+ }
343
+ else if (ts.isImportClause(node) || ts.isImportSpecifier(node)) {
344
+ if (node.name)
345
+ bind(sourceFile, node.name.text);
346
+ }
347
+ else if (ts.isNamespaceImport(node) || ts.isImportEqualsDeclaration(node)) {
348
+ bind(sourceFile, node.name.text);
349
+ }
350
+ ts.forEachChild(node, visit);
351
+ };
352
+ visit(sourceFile);
353
+ return bindings;
354
+ }
355
+ /** True when a scope enclosing `node` (or `node` itself) declares `name`. */
356
+ function isBindingInScope(bindings, node, name) {
357
+ let current = node;
358
+ while (current) {
359
+ if (bindings.get(current)?.has(name))
360
+ return true;
361
+ current = current.parent;
362
+ }
363
+ return false;
364
+ }
365
+ function readAllowComment(lines, line, rule) {
366
+ const current = lines[line - 1];
367
+ const previous = lines[line - 2];
368
+ // A trailing comment on the finding line, or a comment-only line directly
369
+ // above it. A trailing acknowledgement on the previous line belongs to that
370
+ // line's finding and must not leak onto this one.
371
+ const candidates = [current, previous?.match(COMMENT_ONLY_LINE_PATTERN) ? previous : undefined];
372
+ for (const candidate of candidates) {
373
+ if (candidate === undefined)
374
+ continue;
375
+ const match = ALLOW_COMMENT_PATTERN.exec(candidate);
376
+ if (match && match[1] === rule) {
377
+ return match[2]?.trim() || "(no reason given)";
378
+ }
379
+ }
380
+ return undefined;
381
+ }
382
+ export function analyzeRuntimeBoundary(ts, fileName, source) {
383
+ const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, scriptKindForSourceFile(ts, fileName));
384
+ const lines = source.split(/\r?\n/);
385
+ const scopeBindings = collectScopeBindings(ts, sourceFile);
386
+ const raw = [];
387
+ const seen = new Set();
388
+ const record = (rule, node, subject) => {
389
+ const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
390
+ const key = `${rule}:${line}:${subject}`;
391
+ if (seen.has(key))
392
+ return;
393
+ seen.add(key);
394
+ raw.push({ rule, line, subject });
395
+ };
396
+ const visit = (node) => {
397
+ // --- process.env / Bun.env ---------------------------------------
398
+ const envOwner = envObjectOwner(ts, node);
399
+ if (envOwner) {
400
+ const key = envKeyReadFrom(ts, node);
401
+ if (key === undefined || !isBootstrapEnvName(key)) {
402
+ record(PROCESS_ENV_DIRECT_READ_RULE, node, key === undefined ? `${envOwner}.env` : `${envOwner}.env.${key}`);
403
+ }
404
+ // Do not descend: the owner is a global, nothing below is interesting.
405
+ return;
406
+ }
407
+ // --- node:fs / node:net / node:child_process … ---------------------
408
+ if (ts.isImportDeclaration(node)) {
409
+ const specifier = staticString(ts, node.moduleSpecifier);
410
+ if (specifier && importDeclarationLoadsModule(ts, node) && isForbiddenNodeModule(specifier)) {
411
+ record(NODE_RUNTIME_MODULE_IMPORT_RULE, node, specifier);
412
+ }
413
+ return;
414
+ }
415
+ if (ts.isExportDeclaration(node)) {
416
+ const specifier = staticString(ts, node.moduleSpecifier);
417
+ if (specifier && exportDeclarationLoadsModule(ts, node) && isForbiddenNodeModule(specifier)) {
418
+ record(NODE_RUNTIME_MODULE_IMPORT_RULE, node, specifier);
419
+ }
420
+ return;
421
+ }
422
+ if (ts.isImportEqualsDeclaration(node) && !node.isTypeOnly) {
423
+ const reference = node.moduleReference;
424
+ if (ts.isExternalModuleReference(reference)) {
425
+ const specifier = staticString(ts, reference.expression);
426
+ if (specifier && isForbiddenNodeModule(specifier)) {
427
+ record(NODE_RUNTIME_MODULE_IMPORT_RULE, node, specifier);
428
+ }
429
+ }
430
+ return;
431
+ }
432
+ if (ts.isCallExpression(node)) {
433
+ const callee = unwrapExpression(ts, node.expression);
434
+ // require("node:fs") / import("node:fs")
435
+ if ((ts.isIdentifier(callee) && callee.text === "require") ||
436
+ callee.kind === ts.SyntaxKind.ImportKeyword) {
437
+ const specifier = staticString(ts, node.arguments[0]);
438
+ if (specifier && isForbiddenNodeModule(specifier)) {
439
+ record(NODE_RUNTIME_MODULE_IMPORT_RULE, node, specifier);
440
+ }
441
+ }
442
+ // Bun.spawn(...) / Bun.file(...) / Bun.write(...) / Bun.$(...)
443
+ if (ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) {
444
+ const owner = unwrapExpression(ts, callee.expression);
445
+ const member = ts.isPropertyAccessExpression(callee)
446
+ ? callee.name.text
447
+ : staticString(ts, callee.argumentExpression);
448
+ if (member !== undefined &&
449
+ FORBIDDEN_BUN_MEMBERS.has(member) &&
450
+ isGlobalReference(ts, owner, "Bun")) {
451
+ record(NODE_RUNTIME_MODULE_IMPORT_RULE, node, `Bun.${member}()`);
452
+ }
453
+ }
454
+ // fetch(...) / globalThis.fetch(...)
455
+ if (ts.isIdentifier(callee) &&
456
+ callee.text === "fetch" &&
457
+ !isBindingInScope(scopeBindings, node, "fetch")) {
458
+ record(DIRECT_FETCH_CALL_RULE, node, "fetch()");
459
+ }
460
+ else if ((ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) &&
461
+ isGlobalReference(ts, callee, "fetch")) {
462
+ record(DIRECT_FETCH_CALL_RULE, node, "globalThis.fetch()");
463
+ }
464
+ }
465
+ // Bun.$`…` tagged template
466
+ if (ts.isTaggedTemplateExpression(node)) {
467
+ const tag = unwrapExpression(ts, node.tag);
468
+ if (ts.isPropertyAccessExpression(tag) &&
469
+ tag.name.text === "$" &&
470
+ isGlobalReference(ts, unwrapExpression(ts, tag.expression), "Bun")) {
471
+ record(NODE_RUNTIME_MODULE_IMPORT_RULE, node, "Bun.$``");
472
+ }
473
+ }
474
+ ts.forEachChild(node, visit);
475
+ };
476
+ visit(sourceFile);
477
+ const findings = [];
478
+ const acknowledged = [];
479
+ for (const finding of raw) {
480
+ const reason = readAllowComment(lines, finding.line, finding.rule);
481
+ if (reason === undefined)
482
+ findings.push(finding);
483
+ else
484
+ acknowledged.push({ ...finding, reason });
485
+ }
486
+ return { findings, acknowledged };
487
+ }
488
+ function describeSubjects(findings) {
489
+ const bySubject = new Map();
490
+ for (const finding of findings) {
491
+ const lines = bySubject.get(finding.subject) ?? [];
492
+ lines.push(finding.line);
493
+ bySubject.set(finding.subject, lines);
494
+ }
495
+ return [...bySubject.entries()]
496
+ .map(([subject, lines]) => `${subject} (line ${lines.join(", ")})`)
497
+ .join("; ");
498
+ }
499
+ function remediation(rule) {
500
+ switch (rule) {
501
+ case PROCESS_ENV_DIRECT_READ_RULE:
502
+ 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>\`.`;
503
+ case NODE_RUNTIME_MODULE_IMPORT_RULE:
504
+ 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>\`.`;
505
+ case DIRECT_FETCH_CALL_RULE:
506
+ 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>\`.`;
507
+ }
508
+ }
509
+ /**
510
+ * Lint the provider runtime boundary. Returns error-level diagnostics (one per
511
+ * file and rule, listing every line) and information entries for findings
512
+ * acknowledged with `@apifuse-allow`.
513
+ */
514
+ export function lintRuntimeBoundary(ts, provider, readOperationSource) {
515
+ const sources = [];
516
+ const sourceFiles = Object.entries(provider.providerSourceFiles ?? {}).filter(([filePath]) => isRuntimeBoundarySourceFile(filePath));
517
+ if (Object.keys(provider.providerSourceFiles ?? {}).length > 0) {
518
+ for (const [filePath, source] of sourceFiles) {
519
+ sources.push({ field: `sourceFiles.${filePath}`, fileName: filePath, source });
520
+ }
521
+ }
522
+ else {
523
+ // In-memory providers (tests, embedded definitions) have no file tree;
524
+ // fall back to the auth flow and operation sources the way the other
525
+ // source rules do.
526
+ if (provider.authFlowSource) {
527
+ sources.push({
528
+ field: "auth.flow",
529
+ fileName: "auth-flow.ts",
530
+ source: provider.authFlowSource,
531
+ });
532
+ }
533
+ for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
534
+ const source = readOperationSource(operation);
535
+ if (source) {
536
+ sources.push({
537
+ field: `operations.${operationKey}.handler`,
538
+ fileName: `${operationKey}.ts`,
539
+ source,
540
+ });
541
+ }
542
+ }
543
+ }
544
+ const diagnostics = [];
545
+ const information = [];
546
+ for (const { field, fileName, source } of sources) {
547
+ const report = analyzeRuntimeBoundary(ts, fileName, source);
548
+ for (const rule of RUNTIME_BOUNDARY_RULES) {
549
+ const findings = report.findings.filter((finding) => finding.rule === rule);
550
+ if (findings.length > 0) {
551
+ diagnostics.push({
552
+ rule,
553
+ level: "error",
554
+ field,
555
+ message: `${describeSubjects(findings)}: ${remediation(rule)}`,
556
+ });
557
+ }
558
+ for (const finding of report.acknowledged.filter((entry) => entry.rule === rule)) {
559
+ information.push({
560
+ rule,
561
+ field,
562
+ message: `Acknowledged ${finding.subject} at line ${finding.line}. Reason: ${finding.reason}`,
563
+ });
564
+ }
565
+ }
566
+ }
567
+ return { diagnostics, information };
568
+ }
569
+ /**
570
+ * Lint provider runtime source files for the runtime boundary rules without a
571
+ * provider definition: `files` maps provider-root-relative paths to source
572
+ * text (the same shape `apifuse check` collects as `providerSourceFiles`).
573
+ * Out-of-scope paths (tests, recorded fixtures, `.d.ts`, the root
574
+ * `dev.ts`/`start.ts`/`deploy.ts`, `scripts/`, `tools/`, `bin/`) are skipped
575
+ * with `isRuntimeBoundarySourceFile`. Diagnostics carry
576
+ * `field: "sourceFiles.<path>"`; `@apifuse-allow` acknowledgements come back
577
+ * as `information`.
578
+ *
579
+ * Parses with `options.typescript` when given, otherwise with the compiler
580
+ * module resolved from the installed SDK (see `getTypeScript`). This is the
581
+ * entry point the platform contract check uses to apply the rules with the
582
+ * monorepo's SDK pin instead of each provider's own pin.
583
+ */
584
+ export function lintRuntimeBoundarySources(files, options = {}) {
585
+ const ts = options.typescript ?? getTypeScript();
586
+ if (!isTypeScriptCompilerModule(ts)) {
587
+ throw new TypeError("lintRuntimeBoundarySources: options.typescript must be a loaded TypeScript compiler API module (typescript 6.x or @typescript/typescript6) exposing createSourceFile.");
588
+ }
589
+ return lintRuntimeBoundary(ts, { providerSourceFiles: files }, () => "");
590
+ }
package/dist/types.d.ts CHANGED
@@ -198,6 +198,25 @@ export interface OcrCaptchaResult {
198
198
  readonly model: string;
199
199
  }
200
200
  export interface OcrContext {
201
+ /**
202
+ * Whether this context is backed by a configured OCR backend.
203
+ *
204
+ * The SDK owns this verdict: clients built by `createOcrClientFromEnv` for a
205
+ * configured backend report `true`; the unsupported/error clients handed to
206
+ * undeclared or misconfigured providers report `false`. Providers branch on
207
+ * this flag (for example, "require manual CAPTCHA input") instead of probing
208
+ * the engine-owned `APIFUSE__OCR__*` environment names themselves.
209
+ *
210
+ * `true` promises a configured backend, not a successful call: upstream
211
+ * failures still surface as errors from `recognize` / `extractCaptchaText`.
212
+ *
213
+ * Implementations must expose this as an own enumerable data property (not a
214
+ * getter or prototype member) so that wrappers which spread the context, such
215
+ * as `bindOcrTelemetry`, preserve it. Custom `OcrContext` overrides passed to
216
+ * `serve({ ocr })`, `createFlowContext({ ocr })`, or test contexts must set it
217
+ * explicitly.
218
+ */
219
+ readonly available: boolean;
201
220
  recognize(request: OcrRecognizeRequest): Promise<OcrResult>;
202
221
  extractCaptchaText(image: OcrImageInput, options?: OcrCaptchaOptions): Promise<OcrCaptchaResult>;
203
222
  }
@@ -670,6 +689,16 @@ export type HealthCheckCase<TInput = unknown, TOutput = unknown> = {
670
689
  *
671
690
  * MUST NOT access scheduler, recorder, or any runtime type — pure data
672
691
  * + lambda only.
692
+ *
693
+ * NOTE: a closure cannot cross the registry's serialization boundary, so
694
+ * the platform health monitor cannot execute this hook — it runs a case's
695
+ * serialized `scenario` and nothing else. A case that carries only
696
+ * `assertions` is still published as a probe, but its outcome is
697
+ * permanently `unknown` / `monitoring_unavailable`, so the operation
698
+ * reads as monitored while nothing is being checked. This hook runs only
699
+ * in the provider runtime's own self-test
700
+ * (`/internal/health/self-test`). Express anything that must be
701
+ * monitored in production as a `scenario`.
673
702
  */
674
703
  assertions: (ctx: HealthCheckAssertionContext<TOutput>) => void | Promise<void> | HealthCheckCaseResult | Promise<HealthCheckCaseResult>;
675
704
  /** Declarative scenarios replace the imperative preparation and assertion hooks. */
@@ -0,0 +1,39 @@
1
+ /** The TypeScript compiler API surface the source-level lint rules parse with. */
2
+ export type TypeScriptModule = typeof import("typescript");
3
+ /**
4
+ * What a caller hands over as a compiler module. Typed structurally so the
5
+ * option does not depend on which `typescript` package the caller's own type
6
+ * resolution lands on: under a `typescript@7` pin `typeof import("typescript")`
7
+ * is the native shell's version module, not the compiler API. The members are
8
+ * optional for the same reason — the shell has none of them — and
9
+ * `isTypeScriptCompilerModule` decides at runtime whether the module can parse.
10
+ */
11
+ export type TypeScriptCompilerModuleLike = {
12
+ readonly createSourceFile?: (...parameters: never[]) => unknown;
13
+ readonly ScriptTarget?: object;
14
+ readonly version?: string;
15
+ };
16
+ /**
17
+ * Package specifiers that may carry the compiler API, in resolution order.
18
+ *
19
+ * `typescript@7` (the Go-native toolchain) resolves but its JavaScript entry
20
+ * exports only `version`/`getExePath` — no `createSourceFile`, so the lint
21
+ * rules cannot parse with it. The compiler API of that generation is published
22
+ * as `@typescript/typescript6`, which is what a `typescript@7` dependency tree
23
+ * (for example the APIFuse monorepo root) installs alongside the shell.
24
+ */
25
+ export declare const TYPESCRIPT_COMPILER_MODULE_SPECIFIERS: readonly ["typescript", "@typescript/typescript6"];
26
+ /**
27
+ * True when `candidate` is a loaded module that exposes the parser the lint
28
+ * rules need. Internal (narrows to the `typescript` module type, which is not
29
+ * part of the package surface); `lintRuntimeBoundarySources` applies it to the
30
+ * caller-supplied option.
31
+ */
32
+ export declare function isTypeScriptCompilerModule(candidate: unknown): candidate is TypeScriptModule;
33
+ /**
34
+ * Resolve the compiler API through `load`, trying each specifier in order and
35
+ * accepting the first module that exposes `createSourceFile`. Exposed for the
36
+ * tests; production code uses `getTypeScript()`.
37
+ */
38
+ export declare function loadTypeScriptModule(load: (specifier: string) => unknown, specifiers?: readonly string[]): TypeScriptModule;
39
+ export declare function getTypeScript(): TypeScriptModule;