@apifuse/provider-sdk 2.2.0-beta.67 → 2.2.0-beta.69

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.
Files changed (77) hide show
  1. package/AUTHORING.md +34 -2
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +18 -7
  4. package/bin/apifuse-record.ts +81 -21
  5. package/bin/apifuse-submit-check.ts +170 -28
  6. package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
  7. package/dist/cli/templates/provider/README.md.tpl +17 -9
  8. package/dist/config/loader.d.ts +16 -0
  9. package/dist/config/loader.js +25 -8
  10. package/dist/error-resolution.d.ts +13 -0
  11. package/dist/error-resolution.js +72 -35
  12. package/dist/fixture-sanitization.js +159 -15
  13. package/dist/lint.d.ts +2 -0
  14. package/dist/lint.js +549 -11
  15. package/dist/runtime/diagnostic-redactor.d.ts +6 -1
  16. package/dist/runtime/diagnostic-redactor.js +16 -14
  17. package/dist/runtime/executor.js +3 -0
  18. package/dist/runtime/http.d.ts +2 -0
  19. package/dist/runtime/http.js +17 -103
  20. package/dist/runtime/instrumentation.js +4 -1
  21. package/dist/runtime/provider.d.ts +7 -0
  22. package/dist/runtime/provider.js +14 -0
  23. package/dist/runtime/redirect-hop-headers.d.ts +19 -0
  24. package/dist/runtime/redirect-hop-headers.js +58 -0
  25. package/dist/runtime/redirects.d.ts +33 -1
  26. package/dist/runtime/redirects.js +113 -0
  27. package/dist/runtime/resolver-span-attributes.d.ts +34 -0
  28. package/dist/runtime/resolver-span-attributes.js +93 -0
  29. package/dist/runtime/resolver-usage.d.ts +8 -0
  30. package/dist/runtime/resolver-usage.js +5 -11
  31. package/dist/runtime/resolver-vendors/browser.js +5 -4
  32. package/dist/runtime/resolver-vendors/capsolver.js +11 -10
  33. package/dist/runtime/resolver-vendors/twocaptcha.js +9 -8
  34. package/dist/runtime/resolver.js +7 -6
  35. package/dist/runtime/stealth-owned-headers.d.ts +21 -8
  36. package/dist/runtime/stealth-owned-headers.js +28 -8
  37. package/dist/runtime/stealth-telemetry.d.ts +1 -1
  38. package/dist/runtime/stealth.js +158 -46
  39. package/dist/runtime/trace.js +2 -2
  40. package/dist/server/index.d.ts +1 -0
  41. package/dist/server/serve-implementation.d.ts +2 -1
  42. package/dist/server/serve-implementation.js +45 -30
  43. package/dist/server/state-backend-report.d.ts +70 -0
  44. package/dist/server/state-backend-report.js +113 -0
  45. package/dist/testing/run.js +14 -7
  46. package/dist/trace-sanitization.js +1 -1
  47. package/dist/types.d.ts +30 -6
  48. package/package.json +2 -2
  49. package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
  50. package/src/cli/templates/provider/README.md.tpl +17 -9
  51. package/src/config/loader.ts +49 -12
  52. package/src/error-resolution.ts +89 -35
  53. package/src/fixture-sanitization.ts +175 -17
  54. package/src/lint.ts +595 -10
  55. package/src/runtime/diagnostic-redactor.ts +19 -13
  56. package/src/runtime/executor.ts +3 -0
  57. package/src/runtime/http.ts +24 -112
  58. package/src/runtime/instrumentation.ts +4 -1
  59. package/src/runtime/provider.ts +15 -0
  60. package/src/runtime/redirect-hop-headers.ts +71 -0
  61. package/src/runtime/redirects.ts +150 -1
  62. package/src/runtime/resolver-span-attributes.ts +139 -0
  63. package/src/runtime/resolver-usage.ts +8 -5
  64. package/src/runtime/resolver-vendors/browser.ts +5 -4
  65. package/src/runtime/resolver-vendors/capsolver.ts +11 -10
  66. package/src/runtime/resolver-vendors/twocaptcha.ts +9 -8
  67. package/src/runtime/resolver.ts +7 -6
  68. package/src/runtime/stealth-owned-headers.ts +34 -8
  69. package/src/runtime/stealth-telemetry.ts +6 -1
  70. package/src/runtime/stealth.ts +180 -58
  71. package/src/runtime/trace.ts +6 -2
  72. package/src/server/index.ts +4 -0
  73. package/src/server/serve-implementation.ts +58 -31
  74. package/src/server/state-backend-report.ts +185 -0
  75. package/src/testing/run.ts +15 -2
  76. package/src/trace-sanitization.ts +1 -1
  77. package/src/types.ts +30 -6
package/src/lint.ts CHANGED
@@ -29,12 +29,12 @@ import {
29
29
  } from "./runtime/stealth-owned-headers.js";
30
30
  import {
31
31
  lintRuntimeBoundary,
32
- scriptKindForSourceFile,
33
32
  // This module already has a narrower TEST_SOURCE_FILE_PATTERN of its own,
34
33
  // used by the older source rules; it does not cover a plain `tests/`
35
34
  // directory. Aliased rather than merged because consolidating the two would
36
35
  // silently change what those rules scan.
37
36
  TEST_SOURCE_FILE_PATTERN as SHARED_TEST_SOURCE_FILE_PATTERN,
37
+ scriptKindForSourceFile,
38
38
  } from "./runtime-boundary-lint.js";
39
39
  import { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_SENSITIVE_META_KEY } from "./schema.js";
40
40
  import type { AuthMode, OperationApprovalPolicy, OperationRiskClass } from "./types.js";
@@ -1118,6 +1118,47 @@ function isStealthContextAccess(node: import("typescript").Node): boolean {
1118
1118
  return false;
1119
1119
  }
1120
1120
 
1121
+ function isTupleTypeNode(type: import("typescript").TypeNode | undefined): boolean {
1122
+ if (!type) return false;
1123
+ const ts = getTypeScript();
1124
+ if (ts.isTypeOperatorNode(type)) return isTupleTypeNode(type.type);
1125
+ return ts.isTupleTypeNode(type);
1126
+ }
1127
+
1128
+ /**
1129
+ * True where a two-element array is consumed as a `[name, value]` header entry:
1130
+ * inside an entries list (`new Headers([[name, value]])`,
1131
+ * `Object.fromEntries([...])`), passed to a call such as `tuples.push(...)`, or
1132
+ * declared with a tuple type. A two-name list is bound, iterated, or handed to
1133
+ * a constructor instead — `const STRIP = ["sec-fetch-dest", "referer"]`,
1134
+ * `for (const name of [...])`, `new Set([...])` — and is not an entry. The
1135
+ * position decides because a Sec-Fetch-* value ("document", "navigate") is
1136
+ * token-shaped exactly like a header name.
1137
+ */
1138
+ function isHeaderEntryPosition(node: import("typescript").ArrayLiteralExpression): boolean {
1139
+ const ts = getTypeScript();
1140
+ let child: import("typescript").Node = node;
1141
+ let parent = node.parent;
1142
+ while (
1143
+ parent &&
1144
+ (ts.isParenthesizedExpression(parent) ||
1145
+ ts.isAsExpression(parent) ||
1146
+ ts.isSatisfiesExpression(parent) ||
1147
+ ts.isTypeAssertionExpression(parent))
1148
+ ) {
1149
+ if (!ts.isParenthesizedExpression(parent) && isTupleTypeNode(parent.type)) return true;
1150
+ child = parent;
1151
+ parent = parent.parent;
1152
+ }
1153
+ if (!parent) return false;
1154
+ if (ts.isArrayLiteralExpression(parent)) return true;
1155
+ if (ts.isCallExpression(parent)) return parent.arguments.some((argument) => argument === child);
1156
+ if (ts.isVariableDeclaration(parent)) {
1157
+ return parent.initializer === child && isTupleTypeNode(parent.type);
1158
+ }
1159
+ return false;
1160
+ }
1161
+
1121
1162
  function isStaticUndefined(node: import("typescript").Node | undefined): boolean {
1122
1163
  if (!node) return false;
1123
1164
  const ts = getTypeScript();
@@ -1138,9 +1179,14 @@ function calleeName(node: import("typescript").CallExpression): string | undefin
1138
1179
  * runtime rejection: host, connection, and accept-encoding are left alone
1139
1180
  * because ctx.http callers set them legitimately in the same files, and
1140
1181
  * user-agent only reaches a finding through the "user-agent" kind, which
1141
- * matches a versioned literal value rather than the name — a user-agent read
1142
- * from a variable is rejected by the runtime and stays silent here. Widening
1143
- * the set needs a per-file transport scope and a suppression path first.
1182
+ * matches a versioned literal value rather than the name.
1183
+ *
1184
+ * This rule is the early, same-file signal only. The complete check is the
1185
+ * offline test harness: `runStandardTests` runs every `ctx.stealth` request
1186
+ * through the runtime's own `assertCallerHeadersSupported`, so a header spread
1187
+ * from another module, or a user-agent read from a variable or
1188
+ * `getStealthProfile(...)`, fails the provider's tests exactly as it would fail
1189
+ * in production.
1144
1190
  */
1145
1191
  function isReportedOwnedHeaderName(value: string | undefined): boolean {
1146
1192
  if (value === undefined) return false;
@@ -1247,17 +1293,16 @@ function collectBrowserVersionLiteralFindings(
1247
1293
  }
1248
1294
 
1249
1295
  if (ts.isArrayLiteralExpression(node) && node.elements[0]) {
1250
- // Only a `[name, value]` tuple is a header. A name list such as
1251
- // `new Set(["sec-ch-ua-mobile", "sec-ch-ua-platform"])` is not: it is
1252
- // the argument of a constructor, or its second element is itself an
1253
- // owned name.
1296
+ // Only a `[name, value]` tuple is a header: a two-element array in an
1297
+ // entry position (isHeaderEntryPosition) whose second element is not
1298
+ // itself an owned name.
1254
1299
  const second = node.elements[1];
1255
1300
  inspectHeaderEntry(
1256
1301
  node.elements[0],
1257
1302
  staticStringText(node.elements[0]),
1258
1303
  second,
1259
1304
  node.elements.length === 2 &&
1260
- !ts.isNewExpression(node.parent) &&
1305
+ isHeaderEntryPosition(node) &&
1261
1306
  !isStealthOwnedHeaderName(staticStringText(second) ?? "")
1262
1307
  ? "entry"
1263
1308
  : false,
@@ -1299,7 +1344,7 @@ function browserVersionLiteralMessage(finding: BrowserVersionLiteralFinding): st
1299
1344
  case "sec-ch-ua":
1300
1345
  return 'Hardcoded sec-ch-ua versions can disagree with the stealth TLS fingerprint. Remove the literal and let ctx.stealth generate client hints from stealth: { browser: "chrome", os: "macos" }. ctx.stealth also owns User-Agent, so omit that header there as well; for ctx.http only, derive it from getStealthProfile({ browser: "chrome", os: "macos" }).userAgent.';
1301
1346
  case "owned-header":
1302
- return `ctx.stealth owns the "${finding.literal}" header and rejects a caller value with STEALTH_HEADER_OVERRIDE_UNSUPPORTED (HTTP 500 at request time). Remove it; declare stealth: { requestClass: "navigation" | "xhr" | "post" } to drive Sec-Fetch-* and select the profile with stealth: { browser: "chrome", os: "macos" } for client hints.`;
1347
+ return `ctx.stealth owns the "${finding.literal}" header and rejects a caller value with STEALTH_HEADER_OVERRIDE_UNSUPPORTED (HTTP 500 at request time). Remove it; declare stealth: { requestClass: "navigation" | "form-post" | "xhr" | "post" } to drive Sec-Fetch-* and select the profile with stealth: { browser: "chrome", os: "macos" } for client hints.`;
1303
1348
  }
1304
1349
  }
1305
1350
 
@@ -2836,6 +2881,543 @@ function lintProviderMetaNarrowing(provider: ProviderSourceLike): LintDiagnostic
2836
2881
  return diagnostics;
2837
2882
  }
2838
2883
 
2884
+ const STALE_IF_ERROR_OPTION = "staleIfErrorMs";
2885
+ const STALE_IF_ERROR_PATTERN = /\bstaleIfErrorMs\b/;
2886
+
2887
+ /**
2888
+ * Whether a property assignment's initializer leaves no stale window.
2889
+ * `staleIfErrorMs: 0` makes `staleUntil` equal `freshUntil` in
2890
+ * `runtime/cache.ts`, so a loader failure propagates instead of being answered
2891
+ * from cache — a provider deliberately turning stale serving OFF must not be
2892
+ * told its probes conceal outages.
2893
+ */
2894
+ function disablesStaleWindow(
2895
+ ts: typeof import("typescript"),
2896
+ initializer: import("typescript").Expression,
2897
+ ): boolean {
2898
+ if (ts.isNumericLiteral(initializer)) return Number(initializer.text) === 0;
2899
+ return ts.isIdentifier(initializer) && initializer.text === "undefined";
2900
+ }
2901
+
2902
+ /**
2903
+ * Whether executable source configures a stale-if-error window.
2904
+ *
2905
+ * Parsed, not matched: comments and unrelated string literals must not count,
2906
+ * and a regex cannot tell them apart (an `Accept: "*​/*"` header literal opens a
2907
+ * block comment to a stripping regex and swallows the cache call after it).
2908
+ * This is the approach `findLegacyChoiceUsage` already uses here. All three
2909
+ * spellings of the option count — `staleIfErrorMs: n`, the shorthand
2910
+ * `{ ttlMs, staleIfErrorMs }`, and the quoted `"staleIfErrorMs": n` — because
2911
+ * refactoring between them must not change the verdict. Falls back to the raw
2912
+ * identifier match when TypeScript is not installed, which is the fail-closed
2913
+ * direction for this rule: it warns rather than goes quiet.
2914
+ */
2915
+ function servesStaleIfError(source: string, fileName = "provider.ts"): boolean {
2916
+ if (!source.includes(STALE_IF_ERROR_OPTION)) return false;
2917
+ let ts: typeof import("typescript");
2918
+ try {
2919
+ ts = getTypeScript();
2920
+ } catch {
2921
+ return STALE_IF_ERROR_PATTERN.test(source);
2922
+ }
2923
+ const file = ts.createSourceFile(
2924
+ fileName,
2925
+ source,
2926
+ ts.ScriptTarget.Latest,
2927
+ false,
2928
+ scriptKindForSourceFile(ts, fileName),
2929
+ );
2930
+ /** The statically known name of a property, however it is written. */
2931
+ const propertyName = (name: import("typescript").PropertyName): string | undefined => {
2932
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
2933
+ if (ts.isComputedPropertyName(name) && ts.isStringLiteral(name.expression)) {
2934
+ return name.expression.text;
2935
+ }
2936
+ return undefined;
2937
+ };
2938
+ /**
2939
+ * A type declaring the option is not a cache call setting it. Skipping these
2940
+ * whole subtrees also stops a `CachePolicy` interface from overriding the
2941
+ * disabled-window exemption for a provider whose only real call passes 0.
2942
+ */
2943
+ const isTypeOnly = (node: import("typescript").Node): boolean =>
2944
+ ts.isInterfaceDeclaration(node) ||
2945
+ ts.isTypeAliasDeclaration(node) ||
2946
+ ts.isTypeLiteralNode(node) ||
2947
+ ts.isPropertySignature(node) ||
2948
+ ts.isTypeReferenceNode(node) ||
2949
+ ts.isTypeParameterDeclaration(node);
2950
+ /**
2951
+ * The cache write options that travel with `staleIfErrorMs`
2952
+ * (`ProviderCacheWriteOptions`). An object literal carrying one of these
2953
+ * alongside the option is a cache configuration wherever it is declared.
2954
+ */
2955
+ const siblingCacheOptions = new Set(["ttlMs", "jitterPct"]);
2956
+ /**
2957
+ * A callee that writes the provider cache: `ctx.cache.getOrSet(...)`,
2958
+ * `ctx.cache.set(...)`, and the `cached*` helpers built on them. An unrelated
2959
+ * call receiving an object that happens to carry the name is not cache
2960
+ * configuration. Every one of these takes its options LAST.
2961
+ */
2962
+ const isCacheCallee = (expression: import("typescript").Expression): boolean => {
2963
+ if (ts.isIdentifier(expression)) return /^cached[A-Z]/.test(expression.text);
2964
+ if (!ts.isPropertyAccessExpression(expression)) return false;
2965
+ if (expression.name.text === "getOrSet") return true;
2966
+ return (
2967
+ (expression.name.text === "set" || expression.name.text === "write") &&
2968
+ ts.isPropertyAccessExpression(expression.expression) &&
2969
+ expression.expression.name.text === "cache"
2970
+ );
2971
+ };
2972
+ /**
2973
+ * `const POLICY = { ... }` object literals, so `{ ...POLICY }` can be judged.
2974
+ * Keyed by name file-wide: a shadowed name may resolve to the wrong literal,
2975
+ * which at warning level is a mis-nudge rather than a wrong build — but the
2976
+ * expansion below still has to be cycle-safe, because shadowing can make the
2977
+ * resolution circular in valid source.
2978
+ */
2979
+ const literalsByName = new Map<string, import("typescript").ObjectLiteralExpression>();
2980
+ const collect = (node: import("typescript").Node): void => {
2981
+ if (
2982
+ ts.isVariableDeclaration(node) &&
2983
+ ts.isIdentifier(node.name) &&
2984
+ node.initializer !== undefined &&
2985
+ ts.isObjectLiteralExpression(node.initializer)
2986
+ ) {
2987
+ literalsByName.set(node.name.text, node.initializer);
2988
+ }
2989
+ ts.forEachChild(node, collect);
2990
+ };
2991
+ collect(file);
2992
+
2993
+ type OptionReading = { window: "enabled" | "disabled" | undefined; hasSibling: boolean };
2994
+
2995
+ /**
2996
+ * The EFFECTIVE stale-window setting of an object literal, spreads expanded
2997
+ * and later properties overriding earlier ones the way JavaScript does — so
2998
+ * `{ ...policy, staleIfErrorMs: 0 }` reads as disabled even when `policy`
2999
+ * enables it.
3000
+ */
3001
+ const readOptions = (
3002
+ literal: import("typescript").ObjectLiteralExpression,
3003
+ seen: Set<import("typescript").ObjectLiteralExpression>,
3004
+ ): OptionReading => {
3005
+ if (seen.has(literal)) return { window: undefined, hasSibling: false };
3006
+ seen.add(literal);
3007
+ let window: OptionReading["window"];
3008
+ let hasSibling = false;
3009
+ for (const property of literal.properties) {
3010
+ if (ts.isSpreadAssignment(property)) {
3011
+ const target = ts.isIdentifier(property.expression)
3012
+ ? literalsByName.get(property.expression.text)
3013
+ : ts.isObjectLiteralExpression(property.expression)
3014
+ ? property.expression
3015
+ : undefined;
3016
+ if (target === undefined) continue;
3017
+ const inner = readOptions(target, seen);
3018
+ if (inner.window !== undefined) window = inner.window;
3019
+ hasSibling ||= inner.hasSibling;
3020
+ continue;
3021
+ }
3022
+ if (ts.isShorthandPropertyAssignment(property)) {
3023
+ if (property.name.text === STALE_IF_ERROR_OPTION) window = "enabled";
3024
+ else if (siblingCacheOptions.has(property.name.text)) hasSibling = true;
3025
+ continue;
3026
+ }
3027
+ if (!ts.isPropertyAssignment(property)) continue;
3028
+ const name = propertyName(property.name);
3029
+ if (name === STALE_IF_ERROR_OPTION) {
3030
+ window = disablesStaleWindow(ts, property.initializer) ? "disabled" : "enabled";
3031
+ } else if (name !== undefined && siblingCacheOptions.has(name)) {
3032
+ hasSibling = true;
3033
+ }
3034
+ }
3035
+ seen.delete(literal);
3036
+ return { window, hasSibling };
3037
+ };
3038
+
3039
+ let found = false;
3040
+ /**
3041
+ * `inOptionsArgument` is the second way to recognise a cache configuration:
3042
+ * the literal is the options argument of a cache call. Together with the
3043
+ * sibling-option test this covers the real shapes, while an upstream payload
3044
+ * that merely carries a field of the same name does not count.
3045
+ */
3046
+ const visit = (node: import("typescript").Node, inOptionsArgument: boolean): void => {
3047
+ if (found || isTypeOnly(node)) return;
3048
+ if (ts.isObjectLiteralExpression(node)) {
3049
+ const { window, hasSibling } = readOptions(node, new Set());
3050
+ if (window === "enabled" && (hasSibling || inOptionsArgument)) {
3051
+ found = true;
3052
+ return;
3053
+ }
3054
+ }
3055
+ if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
3056
+ const argumentList = node.arguments ?? [];
3057
+ // Options come last on every cache API; the earlier arguments are the
3058
+ // key, the loader, or the value being cached.
3059
+ const optionsIndex = isCacheCallee(node.expression) ? argumentList.length - 1 : -1;
3060
+ visit(node.expression, false);
3061
+ for (const [index, argument] of [...argumentList].entries()) {
3062
+ visit(argument, index === optionsIndex);
3063
+ }
3064
+ return;
3065
+ }
3066
+ ts.forEachChild(node, (child) => visit(child, false));
3067
+ };
3068
+ visit(file, false);
3069
+ return found;
3070
+ }
3071
+
3072
+ /** The operation-step field that distinguishes a stale-if-error serve. */
3073
+ const SERVED_STALE_CACHE_OPERAND = "served_stale_cache";
3074
+
3075
+ /**
3076
+ * Sources that describe behaviour rather than serve it. Mirrors the broadest
3077
+ * test-source predicate in the repository (`runtime-boundary-lint.ts:82`, which
3078
+ * covers a plain `tests/` directory and `__mocks__/` as well as `__tests__/`)
3079
+ * and adds recorded-fixture directories, so a mock that mentions
3080
+ * `staleIfErrorMs` cannot raise a warning about code the runtime never serves.
3081
+ */
3082
+ const NON_SERVING_SOURCE_FILE_PATTERN =
3083
+ /(?:^|\/)(?:__tests__|__mocks__|__fixtures__|tests|fixtures)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
3084
+
3085
+ /**
3086
+ * Operator tooling and bootstrap entrypoints, mirroring
3087
+ * `runtime-boundary-lint.ts`: a cache option used by a fixture recorder or a
3088
+ * start script is not the request path a probe reads through.
3089
+ */
3090
+ const NON_SERVING_TOOLING_DIRECTORY_PATTERN = /^(?:scripts|tools|bin)\//;
3091
+ const NON_SERVING_BOOTSTRAP_FILE_PATTERN = /^(?:dev|start|deploy)\.[cm]?[jt]sx?$/;
3092
+
3093
+ function isServingProviderSourcePath(relativePath: string): boolean {
3094
+ return (
3095
+ JAVASCRIPT_SOURCE_FILE_PATTERN.test(relativePath) &&
3096
+ !NON_SERVING_SOURCE_FILE_PATTERN.test(relativePath) &&
3097
+ !NON_SERVING_TOOLING_DIRECTORY_PATTERN.test(relativePath) &&
3098
+ !NON_SERVING_BOOTSTRAP_FILE_PATTERN.test(relativePath)
3099
+ );
3100
+ }
3101
+
3102
+ function isLintRecord(value: unknown): value is Record<string, unknown> {
3103
+ return !!value && typeof value === "object" && !Array.isArray(value);
3104
+ }
3105
+
3106
+ /**
3107
+ * Reads `healthCheck.cases` structurally. The lint input is the loaded provider
3108
+ * declaration, which `defineProvider` has already validated, but this rule must
3109
+ * not throw on a shape it did not expect.
3110
+ */
3111
+ function readHealthCheckCases(healthCheck: unknown): Record<string, unknown>[] {
3112
+ if (!isLintRecord(healthCheck)) return [];
3113
+ const cases = healthCheck.cases;
3114
+ if (!Array.isArray(cases)) return [];
3115
+ return cases.filter(isLintRecord);
3116
+ }
3117
+
3118
+ /**
3119
+ * The `result` bindings of the `operation` steps that invoke `operationId`.
3120
+ *
3121
+ * Scoped to the probe's own operation on purpose: a scenario may invoke a
3122
+ * preparatory read before the operation the case is about, and a guard on the
3123
+ * preparation's result says nothing about whether the checked operation was
3124
+ * answered from a stale cache.
3125
+ */
3126
+ function operationStepBindings(steps: readonly unknown[], operationId: string): Set<string> {
3127
+ const bindings = new Set<string>();
3128
+ for (const step of steps) {
3129
+ if (
3130
+ isLintRecord(step) &&
3131
+ step.kind === "operation" &&
3132
+ step.operationId === operationId &&
3133
+ typeof step.result === "string"
3134
+ ) {
3135
+ bindings.add(step.result);
3136
+ }
3137
+ }
3138
+ return bindings;
3139
+ }
3140
+
3141
+ /**
3142
+ * Whether an operand is the operand: the runtime's `served_stale_cache`
3143
+ * projection on an operation step result for the probe's own operation.
3144
+ *
3145
+ * Exact on three counts, because each looser form is a way to silence the rule
3146
+ * without checking cache provenance: a literal `"served_stale_cache"` in an
3147
+ * `inputTemplate` or a `reasonKey` is not a reference at all;
3148
+ * `["data", "served_stale_cache"]` is an upstream payload field the provider
3149
+ * happens to have named that way; and a reference bound to an `extract` result
3150
+ * is not the operation step this probe invoked.
3151
+ */
3152
+ function isStaleCacheOperand(operand: unknown, bindings: ReadonlySet<string>): boolean {
3153
+ if (!isLintRecord(operand)) return false;
3154
+ const reference = operand.ref;
3155
+ return (
3156
+ isLintRecord(reference) &&
3157
+ reference.namespace === "steps" &&
3158
+ typeof reference.binding === "string" &&
3159
+ bindings.has(reference.binding) &&
3160
+ Array.isArray(reference.path) &&
3161
+ reference.path.length === 1 &&
3162
+ reference.path[0] === SERVED_STALE_CACHE_OPERAND
3163
+ );
3164
+ }
3165
+
3166
+ /**
3167
+ * Whether a single predicate over the operand is FALSE for a stale serve, under
3168
+ * `negated` negation depth. `served_stale_cache` is always present and always a
3169
+ * boolean, so `exists` and `type_is: "boolean"` pass for a stale serve too —
3170
+ * reading the operand is not the same as rejecting it.
3171
+ */
3172
+ function predicateRejectsStaleServe(
3173
+ predicate: Record<string, unknown>,
3174
+ bindings: ReadonlySet<string>,
3175
+ negated: boolean,
3176
+ ): boolean {
3177
+ // `equals`/`not_equals` are symmetric and the schema allows a reference on
3178
+ // either side, so `equals(actual: false, expected: <ref>)` is the same check
3179
+ // written the other way round.
3180
+ const onActual = isStaleCacheOperand(predicate.actual, bindings);
3181
+ const onExpected = isStaleCacheOperand(predicate.expected, bindings);
3182
+ if (!onActual && !onExpected) return false;
3183
+ const { operator } = predicate;
3184
+ const literal = onActual ? predicate.expected : predicate.actual;
3185
+ const failsWhenStale =
3186
+ (operator === "not_equals" && literal === true) || (operator === "equals" && literal === false);
3187
+ const holdsWhenStale =
3188
+ (operator === "is_true" && onActual) ||
3189
+ (operator === "equals" && literal === true) ||
3190
+ (operator === "not_equals" && literal === false);
3191
+ return negated ? holdsWhenStale : failsWhenStale;
3192
+ }
3193
+
3194
+ /**
3195
+ * Whether an assertion expression evaluates FALSE when the operation step was
3196
+ * answered from a stale cache — the only thing that makes a guard degrade or an
3197
+ * assert fail. A clause under `any` only counts if every sibling branch also
3198
+ * rejects the stale serve, because `any` is satisfied by the branch that still
3199
+ * passes (`status_2xx` on a stale 200 being the obvious one).
3200
+ */
3201
+ function rejectsStaleServe(node: unknown, bindings: ReadonlySet<string>, negated = false): boolean {
3202
+ if (!isLintRecord(node)) return false;
3203
+ if (node.kind === "not") return rejectsStaleServe(node.clause, bindings, !negated);
3204
+ if (node.kind === "all" || node.kind === "any") {
3205
+ if (!Array.isArray(node.clauses)) return false;
3206
+ // `all` fails if ANY clause fails; `any` fails only if EVERY clause fails.
3207
+ // Negation swaps which of the two the operator behaves like.
3208
+ const conjunctive = negated ? node.kind === "any" : node.kind === "all";
3209
+ return conjunctive
3210
+ ? node.clauses.some((clause) => rejectsStaleServe(clause, bindings, negated))
3211
+ : node.clauses.every((clause) => rejectsStaleServe(clause, bindings, negated));
3212
+ }
3213
+ if (node.kind === "predicate") return predicateRejectsStaleServe(node, bindings, negated);
3214
+ return false;
3215
+ }
3216
+
3217
+ /**
3218
+ * Whether a guard's degrade lands on the operation the case is about. A guard
3219
+ * whose condition reads the checked operation's freshness but attributes the
3220
+ * failure to a different operation leaves the checked one green through the
3221
+ * stale serve — covered on paper, not on the status page.
3222
+ */
3223
+ /**
3224
+ * Whether an assert records its verdict against the checked operation. Same
3225
+ * concern as `guardAttributesTo`: an assertion that covers only some other
3226
+ * operation fails that one, not this one.
3227
+ */
3228
+ function assertCoversOperation(coversOperations: unknown, operationId: string): boolean {
3229
+ return Array.isArray(coversOperations) && coversOperations.includes(operationId);
3230
+ }
3231
+
3232
+ function guardAttributesTo(onFail: unknown, operationId: string): boolean {
3233
+ if (!isLintRecord(onFail) || !Array.isArray(onFail.attribute)) return false;
3234
+ return onFail.attribute.some((entry) => isLintRecord(entry) && entry.operationId === operationId);
3235
+ }
3236
+
3237
+ /**
3238
+ * Whether a scenario reaches a verdict on freshness: a `guard` condition
3239
+ * (degraded, the shape this rule recommends) or an `assert` expression (down,
3240
+ * harsher but still real coverage). Any other position — an extract selector,
3241
+ * an operation input — is not a verdict.
3242
+ *
3243
+ * Order matters, and this is the rule the guidance states: the freshness
3244
+ * verdict must come before any OTHER guard. Every guard stops the scenario when
3245
+ * it fires, so a row or emptiness guard placed first answers a stale serve that
3246
+ * happens to be empty with its own reason and the freshness guard never runs —
3247
+ * the probe degrades for the wrong cause and the tenant reads "no rows" during
3248
+ * an upstream outage. Asserts before it are fine; they are the evidence clauses
3249
+ * and only stop the scenario by failing it outright.
3250
+ */
3251
+ function scenarioChecksFreshness(scenario: unknown, operationId: string): boolean {
3252
+ if (!isLintRecord(scenario) || !Array.isArray(scenario.steps)) return false;
3253
+ const bindings = [...operationStepBindings(scenario.steps, operationId)];
3254
+ if (bindings.length === 0) return false;
3255
+ // EVERY read of the operation must be checked. A scenario that invokes it
3256
+ // twice with different inputs has two chances to serve stale, and one fresh
3257
+ // read must not conceal the other.
3258
+ return bindings.every((binding) =>
3259
+ resultBindingChecksFreshness(
3260
+ scenario.steps as readonly unknown[],
3261
+ binding,
3262
+ operationId,
3263
+ bindings,
3264
+ ),
3265
+ );
3266
+ }
3267
+
3268
+ function resultBindingChecksFreshness(
3269
+ steps: readonly unknown[],
3270
+ binding: string,
3271
+ operationId: string,
3272
+ siblingBindings: readonly string[],
3273
+ ): boolean {
3274
+ const operand = new Set([binding]);
3275
+ let produced = false;
3276
+ for (const step of steps) {
3277
+ if (!isLintRecord(step)) continue;
3278
+ if (!produced) {
3279
+ // Nothing before the read can have checked its freshness.
3280
+ produced = step.kind === "operation" && step.result === binding;
3281
+ continue;
3282
+ }
3283
+ if (
3284
+ step.kind === "assert" &&
3285
+ rejectsStaleServe(step.expression, operand) &&
3286
+ assertCoversOperation(step.coversOperations, operationId)
3287
+ ) {
3288
+ return true;
3289
+ }
3290
+ if (step.kind !== "guard") continue;
3291
+ if (rejectsStaleServe(step.condition, operand)) {
3292
+ return guardAttributesTo(step.onFail, operationId);
3293
+ }
3294
+ // A freshness guard for ANOTHER read of the same operation is not a row or
3295
+ // emptiness guard masking the cause: it degrades on exactly this concern,
3296
+ // so `read A, read B, guard A, guard B` covers both. Anything else stops
3297
+ // the scenario first and shadows a later freshness guard.
3298
+ const coversSibling = siblingBindings.some(
3299
+ (sibling) =>
3300
+ sibling !== binding &&
3301
+ rejectsStaleServe(step.condition, new Set([sibling])) &&
3302
+ guardAttributesTo(step.onFail, operationId),
3303
+ );
3304
+ if (!coversSibling) return false;
3305
+ }
3306
+ return false;
3307
+ }
3308
+
3309
+ /** How many uncovered probes the stale-serve message names before eliding. */
3310
+ const STALE_SERVE_MESSAGE_PROBE_LIMIT = 8;
3311
+
3312
+ /**
3313
+ * Health-check cases the platform monitor cannot execute, and stale-if-error
3314
+ * serves no probe can see.
3315
+ *
3316
+ * The monitor runs a case's SERIALIZED `scenario` and nothing else — a closure
3317
+ * cannot cross the registry's serialization boundary. A case carrying only
3318
+ * `assertions` is still published as a probe, but the registry projects it as
3319
+ * `legacy_function_assertions_not_serializable` and the monitor answers
3320
+ * `unknown` / `monitoring_unavailable` for it forever: the operation reads as
3321
+ * monitored on the status page while nothing is checked.
3322
+ *
3323
+ * A provider that reads its upstream through `ctx.cache.getOrSet(..., {
3324
+ * staleIfErrorMs })` answers an upstream outage with HTTP 200 and a
3325
+ * schema-valid body for the whole stale window, so every clause over
3326
+ * `status_code` and `data` passes and the probe rolls up `ok` through the
3327
+ * outage. `served_stale_cache` on the operation step result is the only operand
3328
+ * that separates that from a live read.
3329
+ *
3330
+ * Both are warnings while the fleet migrates; they become errors once no
3331
+ * repository carries an unmonitored case.
3332
+ */
3333
+ function lintHealthCheckMonitorability(provider: {
3334
+ id?: string;
3335
+ providerSourceFiles?: Record<string, string>;
3336
+ operations?: Record<string, { handler?: unknown; source?: string; healthCheck?: unknown }>;
3337
+ }): LintDiagnostic[] {
3338
+ const diagnostics: LintDiagnostic[] = [];
3339
+ const providerLabel = provider.id ? `Provider "${provider.id}"` : "Provider";
3340
+ // Freshness is judged per CASE. Operation-level bookkeeping would let one
3341
+ // guarded case silence its unguarded siblings, which still report `ok`
3342
+ // through a stale serve — the same fail-open this rule exists to close.
3343
+ const uncoveredByOperation = new Map<string, string[]>();
3344
+
3345
+ for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
3346
+ const cases = readHealthCheckCases(operation.healthCheck);
3347
+ const unmonitored: string[] = [];
3348
+ for (const [caseIndex, healthCase] of cases.entries()) {
3349
+ const caseLabel =
3350
+ typeof healthCase.name === "string" && healthCase.name.length > 0
3351
+ ? `"${healthCase.name}"`
3352
+ : `[${caseIndex}]`;
3353
+ if (healthCase.scenario === undefined) {
3354
+ unmonitored.push(caseLabel);
3355
+ }
3356
+ if (!scenarioChecksFreshness(healthCase.scenario, operationKey)) {
3357
+ const uncovered = uncoveredByOperation.get(operationKey) ?? [];
3358
+ uncovered.push(`${operationKey} ${caseLabel}`);
3359
+ uncoveredByOperation.set(operationKey, uncovered);
3360
+ }
3361
+ }
3362
+ if (unmonitored.length > 0) {
3363
+ diagnostics.push({
3364
+ rule: "health-check-assertions-not-monitored",
3365
+ level: "warn",
3366
+ field: `operations.${operationKey}.healthCheck.cases`,
3367
+ message: `${providerLabel} operation "${operationKey}" health-check ${unmonitored.length === 1 ? "case" : "cases"} ${unmonitored.join(", ")} declare assertions without a scenario. The platform health monitor executes only a case's serialized scenario, so this operation publishes a probe whose outcome is permanently unknown/monitoring_unavailable — it reads as monitored while nothing is checked. Express the checks as scenario: defineHealthScenario({ ... }).`,
3368
+ });
3369
+ }
3370
+ }
3371
+
3372
+ if (uncoveredByOperation.size === 0) return diagnostics;
3373
+
3374
+ // Attribution. A handler that carries the cache call in its OWN source names
3375
+ // exactly the operation that serves stale. A file does not: the linter cannot
3376
+ // follow the call graph, and it cannot reliably tell a handler's own file
3377
+ // from a shared helper either — `getOperationSource` returns the runtime's
3378
+ // `handler.toString()`, which type erasure and reformatting make textually
3379
+ // unequal to the TypeScript on disk. So any file-level evidence degrades the
3380
+ // verdict to "somewhere in this provider", and the message says so instead of
3381
+ // implying every listed probe reads through that cache.
3382
+ const sources = new Set<string>();
3383
+ const operationsServingStale = new Set<string>();
3384
+ for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
3385
+ if (servesStaleIfError(getOperationSource(operation), `${operationKey}.ts`)) {
3386
+ sources.add(`operations.${operationKey} handler`);
3387
+ operationsServingStale.add(operationKey);
3388
+ }
3389
+ }
3390
+ let unattributed = false;
3391
+ for (const [filePath, source] of Object.entries(provider.providerSourceFiles ?? {})) {
3392
+ if (!isServingProviderSourcePath(filePath) || !servesStaleIfError(source, filePath)) continue;
3393
+ sources.add(filePath);
3394
+ unattributed = true;
3395
+ }
3396
+ if (sources.size === 0) return diagnostics;
3397
+
3398
+ const attributable = !unattributed;
3399
+ const uncovered = [...uncoveredByOperation]
3400
+ .filter(([operationKey]) => !attributable || operationsServingStale.has(operationKey))
3401
+ .flatMap(([, probes]) => probes);
3402
+ if (uncovered.length === 0) return diagnostics;
3403
+
3404
+ const elided = uncovered.length - STALE_SERVE_MESSAGE_PROBE_LIMIT;
3405
+ const named = uncovered
3406
+ .slice(0, STALE_SERVE_MESSAGE_PROBE_LIMIT)
3407
+ .join(", ")
3408
+ .concat(elided > 0 ? `, and ${elided} more` : "");
3409
+ const caveat = attributable
3410
+ ? ""
3411
+ : ` The cache call is in provider source the linter cannot attribute to one operation, so every uncovered probe is listed — skip any whose operation does not read through that cache.`;
3412
+ diagnostics.push({
3413
+ rule: "health-check-stale-serve-unguarded",
3414
+ level: "warn",
3415
+ field: "healthCheck",
3416
+ message: `${providerLabel} serves stale-if-error (staleIfErrorMs in ${[...sources].sort().join(", ")}) and these health-check cases never check ${SERVED_STALE_CACHE_OPERAND}: ${named}. During an upstream outage the cache answers HTTP 200 with a schema-valid body, so every clause over status_code and data still passes and those probes report ok for the whole stale window. Add one guard step per affected probe on { ref: { namespace: "steps", binding: "<operation step result>", path: ["${SERVED_STALE_CACHE_OPERAND}"] } } attributing status "degraded" with reasonCode "expected_absence", placed before any row or emptiness guard.${caveat}`,
3417
+ });
3418
+ return diagnostics;
3419
+ }
3420
+
2839
3421
  export function lintOperation(op: {
2840
3422
  descriptionKey?: string;
2841
3423
  whenToUseKeys?: readonly string[];
@@ -2987,6 +3569,8 @@ export function lintProvider(
2987
3569
  fixtures?: unknown;
2988
3570
  handler?: unknown;
2989
3571
  source?: string;
3572
+ /** Read structurally by the health-check monitorability rules. */
3573
+ healthCheck?: unknown;
2990
3574
  errorCodes?: ReadonlyArray<{
2991
3575
  code: string;
2992
3576
  status?: number;
@@ -3029,6 +3613,7 @@ export function lintProviderWithInformation(
3029
3613
  ...lintLegacyChoiceUsage(provider),
3030
3614
  ...lintProviderMetaNarrowing(provider),
3031
3615
  ...lintHandleDeclarations(provider),
3616
+ ...lintHealthCheckMonitorability(provider),
3032
3617
  ];
3033
3618
  // Runtime boundary rules (process.env, node:fs/net/child_process, raw
3034
3619
  // fetch) also yield information entries for `@apifuse-allow` acknowledgements.