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

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 (243) hide show
  1. package/AUTHORING.md +268 -9
  2. package/CHANGELOG.md +4 -0
  3. package/README.md +1 -0
  4. package/bin/apifuse-check.ts +84 -2
  5. package/bin/apifuse-dev.ts +11 -31
  6. package/bin/apifuse-migrate-deployment.ts +200 -0
  7. package/bin/apifuse-pack-check.ts +11 -4
  8. package/bin/apifuse-pack-types.ts +187 -4
  9. package/bin/apifuse-record.ts +44 -32
  10. package/bin/apifuse-test.ts +3 -1
  11. package/dist/cli/commands.d.ts +1 -1
  12. package/dist/cli/commands.js +11 -0
  13. package/dist/cli/deployment-intent.d.ts +178 -0
  14. package/dist/cli/deployment-intent.js +277 -0
  15. package/dist/cli/migrate-deployment-intent.d.ts +132 -0
  16. package/dist/cli/migrate-deployment-intent.js +1552 -0
  17. package/dist/cli/migrate-provider-shape.d.ts +5 -1
  18. package/dist/cli/migrate-provider-shape.js +4 -2
  19. package/dist/cli/resolver-runtime.d.ts +21 -0
  20. package/dist/cli/resolver-runtime.js +31 -0
  21. package/dist/define.d.ts +3 -0
  22. package/dist/define.js +5 -0
  23. package/dist/dev.d.ts +3 -1
  24. package/dist/dev.js +6 -1
  25. package/dist/engine.d.ts +35 -1
  26. package/dist/engine.js +21 -2
  27. package/dist/error-resolution.d.ts +1 -0
  28. package/dist/error-resolution.js +54 -0
  29. package/dist/errors.d.ts +39 -1
  30. package/dist/errors.js +50 -0
  31. package/dist/i18n/error-messages.d.ts +86 -0
  32. package/dist/i18n/error-messages.js +192 -0
  33. package/dist/i18n/index.d.ts +1 -0
  34. package/dist/i18n/index.js +1 -0
  35. package/dist/i18n/keys.d.ts +12 -0
  36. package/dist/i18n/keys.js +76 -5
  37. package/dist/index.d.ts +7 -1
  38. package/dist/index.js +6 -0
  39. package/dist/lint.d.ts +10 -0
  40. package/dist/lint.js +412 -49
  41. package/dist/provider.d.ts +5 -5
  42. package/dist/provider.js +2 -2
  43. package/dist/runtime/browser-telemetry-binding.d.ts +5 -0
  44. package/dist/runtime/browser-telemetry-binding.js +83 -0
  45. package/dist/runtime/browser-telemetry.d.ts +76 -0
  46. package/dist/runtime/browser-telemetry.js +165 -0
  47. package/dist/runtime/browser.d.ts +3 -0
  48. package/dist/runtime/browser.js +208 -47
  49. package/dist/runtime/cache-telemetry.d.ts +71 -0
  50. package/dist/runtime/cache-telemetry.js +177 -0
  51. package/dist/runtime/cache.js +67 -7
  52. package/dist/runtime/capability-response.d.ts +3 -0
  53. package/dist/runtime/capability-response.js +56 -0
  54. package/dist/runtime/capability-telemetry.d.ts +46 -0
  55. package/dist/runtime/capability-telemetry.js +68 -0
  56. package/dist/runtime/diagnostic-env.d.ts +3 -0
  57. package/dist/runtime/diagnostic-env.js +10 -1
  58. package/dist/runtime/engine-mode.d.ts +74 -0
  59. package/dist/runtime/engine-mode.js +152 -0
  60. package/dist/runtime/http-telemetry-guard.d.ts +10 -0
  61. package/dist/runtime/http-telemetry-guard.js +62 -10
  62. package/dist/runtime/http.js +141 -35
  63. package/dist/runtime/instrumentation.js +92 -13
  64. package/dist/runtime/ocr-telemetry.d.ts +82 -0
  65. package/dist/runtime/ocr-telemetry.js +192 -0
  66. package/dist/runtime/ocr.js +30 -8
  67. package/dist/runtime/request-telemetry.d.ts +2 -0
  68. package/dist/runtime/request-telemetry.js +2 -1
  69. package/dist/runtime/resolver-runtime-options.d.ts +14 -0
  70. package/dist/runtime/resolver-runtime-options.js +33 -0
  71. package/dist/runtime/resolver-shared.d.ts +3 -2
  72. package/dist/runtime/resolver-shared.js +4 -4
  73. package/dist/runtime/resolver-telemetry.d.ts +1 -0
  74. package/dist/runtime/resolver-telemetry.js +1 -1
  75. package/dist/runtime/resolver-vendors/browser.d.ts +2 -0
  76. package/dist/runtime/resolver-vendors/browser.js +46 -0
  77. package/dist/runtime/resolver-vendors/hosts.d.ts +21 -0
  78. package/dist/runtime/resolver-vendors/hosts.js +35 -0
  79. package/dist/runtime/resolver-vendors/hypersolutions.js +2 -2
  80. package/dist/runtime/resolver-vendors/types.d.ts +5 -4
  81. package/dist/runtime/resolver.d.ts +4 -1
  82. package/dist/runtime/resolver.js +46 -13
  83. package/dist/runtime/state-telemetry.d.ts +79 -0
  84. package/dist/runtime/state-telemetry.js +194 -0
  85. package/dist/runtime/state.d.ts +3 -0
  86. package/dist/runtime/state.js +26 -7
  87. package/dist/runtime/stealth-cookies.d.ts +5 -1
  88. package/dist/runtime/stealth-cookies.js +21 -1
  89. package/dist/runtime/stealth-owned-headers.d.ts +26 -0
  90. package/dist/runtime/stealth-owned-headers.js +37 -0
  91. package/dist/runtime/stealth-telemetry.d.ts +92 -0
  92. package/dist/runtime/stealth-telemetry.js +179 -0
  93. package/dist/runtime/stealth.d.ts +4 -1
  94. package/dist/runtime/stealth.js +228 -85
  95. package/dist/runtime/stt-telemetry.d.ts +77 -0
  96. package/dist/runtime/stt-telemetry.js +176 -0
  97. package/dist/runtime/stt.js +15 -6
  98. package/dist/server/error-stack-frames.d.ts +35 -0
  99. package/dist/server/error-stack-frames.js +219 -0
  100. package/dist/server/index.d.ts +7 -0
  101. package/dist/server/index.js +4 -0
  102. package/dist/server/sdk-version.d.ts +3 -0
  103. package/dist/server/sdk-version.js +19 -0
  104. package/dist/server/self-test.js +1 -18
  105. package/dist/server/sensitive-values.d.ts +1 -1
  106. package/dist/server/sensitive-values.js +9 -0
  107. package/dist/server/serve-implementation.d.ts +51 -2
  108. package/dist/server/serve-implementation.js +337 -90
  109. package/dist/server/types.d.ts +1 -0
  110. package/dist/server/types.js +7 -0
  111. package/dist/stateful/stateful-provider-owner-forwarder.js +22 -0
  112. package/dist/stateful/stateful-provider-runtime-context.d.ts +3 -0
  113. package/dist/stateful/stateful-provider-runtime-context.js +1 -0
  114. package/dist/types.d.ts +94 -1
  115. package/package.json +6 -1
  116. package/src/cli/commands.ts +14 -0
  117. package/src/cli/deployment-intent.ts +364 -0
  118. package/src/cli/migrate-deployment-intent.ts +1908 -0
  119. package/src/cli/migrate-provider-shape.ts +10 -4
  120. package/src/cli/resolver-runtime.ts +72 -0
  121. package/src/define.ts +13 -0
  122. package/src/dev.ts +9 -2
  123. package/src/engine.ts +38 -2
  124. package/src/error-resolution.ts +58 -0
  125. package/src/errors.ts +77 -1
  126. package/src/i18n/error-messages.ts +255 -0
  127. package/src/i18n/index.ts +1 -0
  128. package/src/i18n/keys.ts +80 -6
  129. package/src/index.ts +72 -0
  130. package/src/lint.ts +522 -50
  131. package/src/provider.ts +10 -0
  132. package/src/runtime/browser-telemetry-binding.ts +92 -0
  133. package/src/runtime/browser-telemetry.ts +289 -0
  134. package/src/runtime/browser.ts +249 -65
  135. package/src/runtime/cache-telemetry.ts +247 -0
  136. package/src/runtime/cache.ts +68 -17
  137. package/src/runtime/capability-response.ts +53 -0
  138. package/src/runtime/capability-telemetry.ts +101 -0
  139. package/src/runtime/diagnostic-env.ts +11 -1
  140. package/src/runtime/engine-mode.ts +233 -0
  141. package/src/runtime/http-telemetry-guard.ts +66 -13
  142. package/src/runtime/http.ts +168 -37
  143. package/src/runtime/instrumentation.ts +105 -16
  144. package/src/runtime/ocr-telemetry.ts +308 -0
  145. package/src/runtime/ocr.ts +109 -74
  146. package/src/runtime/request-telemetry.ts +2 -1
  147. package/src/runtime/resolver-runtime-options.ts +50 -0
  148. package/src/runtime/resolver-shared.ts +9 -4
  149. package/src/runtime/resolver-telemetry.ts +1 -1
  150. package/src/runtime/resolver-vendors/browser.ts +65 -7
  151. package/src/runtime/resolver-vendors/hosts.ts +43 -0
  152. package/src/runtime/resolver-vendors/hypersolutions.ts +2 -2
  153. package/src/runtime/resolver-vendors/types.ts +5 -4
  154. package/src/runtime/resolver.ts +64 -21
  155. package/src/runtime/state-telemetry.ts +298 -0
  156. package/src/runtime/state.ts +46 -45
  157. package/src/runtime/stealth-cookies.ts +24 -1
  158. package/src/runtime/stealth-owned-headers.ts +41 -0
  159. package/src/runtime/stealth-telemetry.ts +304 -0
  160. package/src/runtime/stealth.ts +355 -150
  161. package/src/runtime/stt-telemetry.ts +282 -0
  162. package/src/runtime/stt.ts +93 -71
  163. package/src/server/error-stack-frames.ts +214 -0
  164. package/src/server/index.ts +53 -0
  165. package/src/server/sdk-version.ts +22 -0
  166. package/src/server/self-test.ts +1 -21
  167. package/src/server/sensitive-values.ts +14 -0
  168. package/src/server/serve-implementation.ts +569 -108
  169. package/src/server/types.ts +7 -0
  170. package/src/stateful/stateful-provider-owner-forwarder.ts +23 -0
  171. package/src/stateful/stateful-provider-runtime-context.ts +4 -0
  172. package/src/types.ts +99 -1
  173. package/src/cli/__tests__/fixtures/migrate-operation-declaration/approval-override.ts.txt +0 -6
  174. package/src/cli/__tests__/fixtures/migrate-operation-declaration/codemod-syntax.ts.txt +0 -3
  175. package/src/cli/__tests__/fixtures/migrate-operation-declaration/connection-precedence.ts.txt +0 -10
  176. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-ambiguous-a.ts.txt +0 -3
  177. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-ambiguous-b.ts.txt +0 -3
  178. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-ambiguous-barrel.ts.txt +0 -2
  179. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-ambiguous-index.ts.txt +0 -3
  180. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-computed.ts.txt +0 -6
  181. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-cycle-a.ts.txt +0 -1
  182. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-cycle-b.ts.txt +0 -1
  183. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-cycle-index.ts.txt +0 -3
  184. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-barrel.ts.txt +0 -7
  185. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-catalog.ts.txt +0 -19
  186. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-docs.ts.txt +0 -6
  187. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-index.ts.txt +0 -3
  188. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-stores.ts.txt +0 -8
  189. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-indirect-config.ts.txt +0 -5
  190. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-nonstatic.ts.txt +0 -4
  191. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-package-index.ts.txt +0 -3
  192. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-reexport-index.ts.txt +0 -3
  193. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-reexport-leaf.ts.txt +0 -10
  194. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-reexport-one.ts.txt +0 -1
  195. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-reexport-two.ts.txt +0 -1
  196. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-runtime-composed-index.ts.txt +0 -3
  197. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-runtime-composed-one.ts.txt +0 -6
  198. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-runtime-composed-two.ts.txt +0 -6
  199. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-runtime-mixed-index.ts.txt +0 -14
  200. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-runtime-registry.ts.txt +0 -3
  201. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-star-package-barrel.ts.txt +0 -2
  202. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-star-package-index.ts.txt +0 -3
  203. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-star-package-leaf.ts.txt +0 -3
  204. package/src/cli/__tests__/fixtures/migrate-operation-declaration/docs-conflict.ts.txt +0 -8
  205. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-hoisted-array-spread.ts.txt +0 -15
  206. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-imported-array-spread.ts.txt +0 -9
  207. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-kebab-operation-id.ts.txt +0 -17
  208. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-map.ts.txt +0 -5
  209. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-mixed-array-spread.ts.txt +0 -24
  210. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-operation.ts.txt +0 -16
  211. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-snake-operation-id.ts.txt +0 -21
  212. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-duplicate-provider.ts.txt +0 -11
  213. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-id-unresolved.ts.txt +0 -7
  214. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-map.ts.txt +0 -3
  215. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-one-to-many.ts.txt +0 -18
  216. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-one-to-one.ts.txt +0 -13
  217. package/src/cli/__tests__/fixtures/migrate-operation-declaration/hoist-all.ts.txt +0 -31
  218. package/src/cli/__tests__/fixtures/migrate-operation-declaration/hoisted-const.ts.txt +0 -11
  219. package/src/cli/__tests__/fixtures/migrate-operation-declaration/imported-spread.ts.txt +0 -11
  220. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-map.ts.txt +0 -11
  221. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-cast-tail.ts.txt +0 -21
  222. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-ekitan.ts.txt +0 -11
  223. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-override.ts.txt +0 -14
  224. package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-canonical-en.json +0 -1
  225. package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-canonical-ja.json +0 -1
  226. package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-canonical-ko.json +0 -1
  227. package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-existing-operation-namespace-en.json +0 -8
  228. package/src/cli/__tests__/fixtures/migrate-operation-declaration/missing-english-locale.ts.txt +0 -7
  229. package/src/cli/__tests__/fixtures/migrate-operation-declaration/no-safety.ts.txt +0 -6
  230. package/src/cli/__tests__/fixtures/migrate-operation-declaration/non-literal.ts.txt +0 -7
  231. package/src/cli/__tests__/fixtures/migrate-operation-declaration/redundant-approval.ts.txt +0 -6
  232. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-binding-ambiguous.ts.txt +0 -12
  233. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-imported-other.ts.txt +0 -7
  234. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-imported.ts.txt +0 -5
  235. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-key-ambiguous.ts.txt +0 -19
  236. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-shorthand.ts.txt +0 -9
  237. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-typed.ts.txt +0 -11
  238. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-unrelated.ts.txt +0 -13
  239. package/src/cli/__tests__/fixtures/migrate-operation-declaration/safety-conflict.ts.txt +0 -7
  240. package/src/cli/__tests__/fixtures/migrate-operation-declaration/stream.ts.txt +0 -7
  241. package/src/cli/__tests__/fixtures/migrate-operation-declaration/tool-router-spread.ts.txt +0 -15
  242. package/src/cli/__tests__/fixtures/migrate-operation-declaration/unparseable.ts.txt +0 -4
  243. package/src/cli/__tests__/fixtures/migrate-operation-declaration/verbatim-template.ts.txt +0 -12
package/src/lint.ts CHANGED
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
3
3
  import type { ZodType } from "zod";
4
4
 
5
5
  import {
6
+ SDK_CANONICAL_ERROR_CODE_RETRYABILITY,
6
7
  SDK_RUNTIME_OWNED_ERROR_CODES,
7
8
  SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES,
8
9
  } from "./error-resolution.js";
@@ -15,7 +16,17 @@ import {
15
16
  isHandleIssuedBy,
16
17
  isHandleFieldMeta,
17
18
  } from "./handle-meta.js";
19
+ import { PROVIDER_ERROR_CATALOG_NAMESPACE } from "./i18n/error-messages.js";
20
+ import {
21
+ getProviderLocaleSegments,
22
+ isProviderLocaleKey,
23
+ type ProviderLocaleCatalog,
24
+ } from "./i18n/keys.js";
18
25
  import { lintPublicSchemaFieldNames } from "./public-schema-field-lint.js";
26
+ import {
27
+ isStealthOwnedHeaderName,
28
+ SDK_OWNED_CHROME_HEADER_PREFIX,
29
+ } from "./runtime/stealth-owned-headers.js";
19
30
  import { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_SENSITIVE_META_KEY } from "./schema.js";
20
31
  import type { AuthMode, OperationApprovalPolicy, OperationRiskClass } from "./types.js";
21
32
 
@@ -1014,7 +1025,14 @@ function lintSelfHostedBrowserPatterns(
1014
1025
  return diagnostics;
1015
1026
  }
1016
1027
 
1017
- const THROWN_ERROR_CONSTRUCTION_PATTERN = /new\s+(?:ProviderError|ValidationError)\s*\(/g;
1028
+ // Captures the constructor name so each rule can pick its own scope: the
1029
+ // undeclared-code rule keeps its historical ProviderError/ValidationError
1030
+ // surface, while the localization rules also cover AuthError.
1031
+ const THROWN_ERROR_CONSTRUCTION_PATTERN = /new\s+(ProviderError|AuthError|ValidationError)\s*\(/g;
1032
+ const UNDECLARED_CODE_ERROR_CLASSES: ReadonlySet<string> = new Set([
1033
+ "ProviderError",
1034
+ "ValidationError",
1035
+ ]);
1018
1036
 
1019
1037
  const TEST_SOURCE_FILE_PATTERN = /(?:^|\/)(?:__tests__|__mocks__)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
1020
1038
  const RECORDED_FIXTURE_SOURCE_FILE_PATTERN =
@@ -1025,8 +1043,13 @@ const VERSIONED_PROFILE_LITERAL_PATTERN =
1025
1043
  const VERSIONED_USER_AGENT_PATTERN = /\b(?:Chrome|CriOS|Firefox|FxiOS|EdgA?|OPR)\/\d+(?:\.\d+)*/i;
1026
1044
  const VERSIONED_SAFARI_USER_AGENT_PATTERN = /\bVersion\/(\d+(?:\.\d+)*)(?=[\s\S]*\bSafari\/\d)/i;
1027
1045
  const VERSIONED_CLIENT_HINT_PATTERN = /(?:^|[;,\s])v\s*=\s*["']?\d+/i;
1046
+ // Calls that write a header entry as (name, value): Headers/Map `set` and
1047
+ // `append`, Node's `setHeader`. Predicates, replacements, and logging calls
1048
+ // take string arguments too and must not count as header writes.
1049
+ const HEADER_SETTER_CALLEE_PATTERN = /^(?:set|append|setHeader|addHeader)$/;
1050
+ const OWNED_CLIENT_HINT_HEADER_PREFIX = "sec-ch-ua";
1028
1051
 
1029
- type BrowserVersionLiteralKind = "profile" | "user-agent" | "sec-ch-ua";
1052
+ type BrowserVersionLiteralKind = "profile" | "user-agent" | "sec-ch-ua" | "owned-header";
1030
1053
 
1031
1054
  type BrowserVersionLiteralFinding = {
1032
1055
  kind: BrowserVersionLiteralKind;
@@ -1054,6 +1077,77 @@ function isSecChUaHeaderName(value: string | undefined): boolean {
1054
1077
  return value?.toLowerCase() === "sec-ch-ua";
1055
1078
  }
1056
1079
 
1080
+ /**
1081
+ * True for the code paths that reach the stealth transport: `ctx.stealth`,
1082
+ * `ctx["stealth"]`, and `const { stealth } = ctx`. Read from the AST so a
1083
+ * comment or string that mentions ctx.stealth does not scope a ctx.http file
1084
+ * into this rule.
1085
+ */
1086
+ function isStealthContextAccess(node: import("typescript").Node): boolean {
1087
+ const ts = getTypeScript();
1088
+ if (ts.isPropertyAccessExpression(node)) {
1089
+ return (
1090
+ ts.isIdentifier(node.expression) &&
1091
+ node.expression.text === "ctx" &&
1092
+ node.name.text === "stealth"
1093
+ );
1094
+ }
1095
+ if (ts.isElementAccessExpression(node)) {
1096
+ return (
1097
+ ts.isIdentifier(node.expression) &&
1098
+ node.expression.text === "ctx" &&
1099
+ staticStringText(node.argumentExpression) === "stealth"
1100
+ );
1101
+ }
1102
+ if (ts.isBindingElement(node)) {
1103
+ const key = node.propertyName ?? node.name;
1104
+ if (!ts.isIdentifier(key) || key.text !== "stealth") return false;
1105
+ const declaration = node.parent?.parent;
1106
+ return (
1107
+ declaration !== undefined &&
1108
+ ts.isVariableDeclaration(declaration) &&
1109
+ declaration.initializer !== undefined &&
1110
+ ts.isIdentifier(declaration.initializer) &&
1111
+ declaration.initializer.text === "ctx"
1112
+ );
1113
+ }
1114
+ return false;
1115
+ }
1116
+
1117
+ function isStaticUndefined(node: import("typescript").Node | undefined): boolean {
1118
+ if (!node) return false;
1119
+ const ts = getTypeScript();
1120
+ return (ts.isIdentifier(node) && node.text === "undefined") || ts.isVoidExpression(node);
1121
+ }
1122
+
1123
+ function calleeName(node: import("typescript").CallExpression): string | undefined {
1124
+ const ts = getTypeScript();
1125
+ if (ts.isPropertyAccessExpression(node.expression)) return node.expression.name.text;
1126
+ if (ts.isIdentifier(node.expression)) return node.expression.text;
1127
+ return undefined;
1128
+ }
1129
+
1130
+ /**
1131
+ * Header names this rule reports in ctx.stealth files: the Sec-Fetch-* and
1132
+ * client-hint (sec-ch-ua*) families, restricted to what the stealth runtime
1133
+ * really rejects (isStealthOwnedHeaderName). Deliberately narrower than the
1134
+ * runtime rejection: host, connection, and accept-encoding are left alone
1135
+ * because ctx.http callers set them legitimately in the same files, and
1136
+ * user-agent only reaches a finding through the "user-agent" kind, which
1137
+ * matches a versioned literal value rather than the name — a user-agent read
1138
+ * from a variable is rejected by the runtime and stays silent here. Widening
1139
+ * the set needs a per-file transport scope and a suppression path first.
1140
+ */
1141
+ function isReportedOwnedHeaderName(value: string | undefined): boolean {
1142
+ if (value === undefined) return false;
1143
+ const name = value.toLowerCase();
1144
+ if (!isStealthOwnedHeaderName(name)) return false;
1145
+ if (name.startsWith(SDK_OWNED_CHROME_HEADER_PREFIX)) {
1146
+ return name.length > SDK_OWNED_CHROME_HEADER_PREFIX.length;
1147
+ }
1148
+ return name.startsWith(OWNED_CLIENT_HINT_HEADER_PREFIX);
1149
+ }
1150
+
1057
1151
  function collectBrowserVersionLiteralFindings(source: string): BrowserVersionLiteralFinding[] {
1058
1152
  const ts = getTypeScript();
1059
1153
  const sourceFile = ts.createSourceFile(
@@ -1063,8 +1157,13 @@ function collectBrowserVersionLiteralFindings(source: string): BrowserVersionLit
1063
1157
  true,
1064
1158
  ts.ScriptKind.TSX,
1065
1159
  );
1066
- const findings: BrowserVersionLiteralFinding[] = [];
1160
+ let findings: BrowserVersionLiteralFinding[] = [];
1067
1161
  const seen = new Set<string>();
1162
+ // Owned-header entries only count once the file is known to reach
1163
+ // ctx.stealth, which may appear after the entry; collect them and decide
1164
+ // after the walk.
1165
+ let stealthAccess = false;
1166
+ const ownedHeaderEntries: Array<{ name: string; position: number; valuePosition?: number }> = [];
1068
1167
 
1069
1168
  const addFinding = (kind: BrowserVersionLiteralKind, literal: string, position: number) => {
1070
1169
  const key = `${kind}:${position}:${literal}`;
@@ -1090,29 +1189,97 @@ function collectBrowserVersionLiteralFindings(source: string): BrowserVersionLit
1090
1189
  }
1091
1190
  };
1092
1191
 
1192
+ // A header entry `name: value` in any of the shapes below. In a ctx.stealth
1193
+ // file an owned name is the finding (the runtime rejects it whatever the
1194
+ // value); elsewhere only a versioned sec-ch-ua value is. A statically
1195
+ // undefined value is exempt only in the "record" shapes, the ones the
1196
+ // runtime drops before the ownership check (`normalizedHeaderEntries`):
1197
+ // `Headers.set(name, undefined)` stringifies to "undefined" and still
1198
+ // throws, so it stays a finding.
1199
+ const inspectHeaderEntry = (
1200
+ nameNode: import("typescript").Node,
1201
+ name: string | undefined,
1202
+ valueNode: import("typescript").Node | undefined,
1203
+ ownedHeaderShape: false | "record" | "entry",
1204
+ ) => {
1205
+ if (name === undefined) return;
1206
+ if (
1207
+ ownedHeaderShape !== false &&
1208
+ isReportedOwnedHeaderName(name) &&
1209
+ !(ownedHeaderShape === "record" && isStaticUndefined(valueNode))
1210
+ ) {
1211
+ ownedHeaderEntries.push({
1212
+ name,
1213
+ position: nameNode.getStart(sourceFile),
1214
+ valuePosition: valueNode?.getStart(sourceFile),
1215
+ });
1216
+ }
1217
+ if (isSecChUaHeaderName(name)) inspectSecChUaValue(valueNode);
1218
+ };
1219
+
1093
1220
  const visit = (node: import("typescript").Node) => {
1094
1221
  const text = staticStringText(node);
1095
1222
  if (text !== undefined) inspectLiteral(text, node.getStart(sourceFile));
1223
+ if (isStealthContextAccess(node)) stealthAccess = true;
1096
1224
 
1097
- if (ts.isPropertyAssignment(node) && isSecChUaHeaderName(staticPropertyName(node.name))) {
1098
- inspectSecChUaValue(node.initializer);
1225
+ if (ts.isPropertyAssignment(node)) {
1226
+ inspectHeaderEntry(node.name, staticPropertyName(node.name), node.initializer, "record");
1099
1227
  }
1100
1228
 
1101
- if (ts.isCallExpression(node) && isSecChUaHeaderName(staticStringText(node.arguments[0]))) {
1102
- inspectSecChUaValue(node.arguments[1]);
1229
+ if (ts.isCallExpression(node) && node.arguments[0]) {
1230
+ // Only `headers.set(name, value)`-style calls write a header;
1231
+ // `name.startsWith("sec-fetch-")` or `log("sec-fetch-dest", x)` do not.
1232
+ inspectHeaderEntry(
1233
+ node.arguments[0],
1234
+ staticStringText(node.arguments[0]),
1235
+ node.arguments[1],
1236
+ node.arguments.length >= 2 && HEADER_SETTER_CALLEE_PATTERN.test(calleeName(node) ?? "")
1237
+ ? "entry"
1238
+ : false,
1239
+ );
1240
+ }
1241
+
1242
+ if (ts.isArrayLiteralExpression(node) && node.elements[0]) {
1243
+ // Only a `[name, value]` tuple is a header. A name list such as
1244
+ // `new Set(["sec-ch-ua-mobile", "sec-ch-ua-platform"])` is not: it is
1245
+ // the argument of a constructor, or its second element is itself an
1246
+ // owned name.
1247
+ const second = node.elements[1];
1248
+ inspectHeaderEntry(
1249
+ node.elements[0],
1250
+ staticStringText(node.elements[0]),
1251
+ second,
1252
+ node.elements.length === 2 &&
1253
+ !ts.isNewExpression(node.parent) &&
1254
+ !isStealthOwnedHeaderName(staticStringText(second) ?? "")
1255
+ ? "entry"
1256
+ : false,
1257
+ );
1103
1258
  }
1104
1259
 
1105
1260
  if (
1106
- ts.isArrayLiteralExpression(node) &&
1107
- isSecChUaHeaderName(staticStringText(node.elements[0]))
1261
+ ts.isBinaryExpression(node) &&
1262
+ node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
1263
+ ts.isElementAccessExpression(node.left)
1108
1264
  ) {
1109
- inspectSecChUaValue(node.elements[1]);
1265
+ const nameNode = node.left.argumentExpression;
1266
+ inspectHeaderEntry(nameNode, staticStringText(nameNode), node.right, "record");
1110
1267
  }
1111
1268
 
1112
1269
  ts.forEachChild(node, visit);
1113
1270
  };
1114
1271
 
1115
1272
  visit(sourceFile);
1273
+ if (stealthAccess) {
1274
+ // The owned name is the finding; do not also report its sec-ch-ua value.
1275
+ const shadowedValues = new Set(ownedHeaderEntries.map((entry) => entry.valuePosition));
1276
+ findings = findings.filter(
1277
+ (finding) => !(finding.kind === "sec-ch-ua" && shadowedValues.has(finding.position)),
1278
+ );
1279
+ for (const entry of ownedHeaderEntries) {
1280
+ addFinding("owned-header", entry.name, entry.position);
1281
+ }
1282
+ }
1116
1283
  return findings.sort((left, right) => left.position - right.position);
1117
1284
  }
1118
1285
 
@@ -1124,6 +1291,8 @@ function browserVersionLiteralMessage(finding: BrowserVersionLiteralFinding): st
1124
1291
  return `Hardcoded User-Agent browser version "${finding.literal}" can disagree with the stealth TLS fingerprint. Remove the literal. ctx.stealth owns User-Agent and rejects a caller value with STEALTH_HEADER_OVERRIDE_UNSUPPORTED, so omit the header there and select the profile with stealth: { browser: "chrome", os: "macos" }. For ctx.http only, derive it from getStealthProfile({ browser: "chrome", os: "macos" }).userAgent.`;
1125
1292
  case "sec-ch-ua":
1126
1293
  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.';
1294
+ case "owned-header":
1295
+ 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.`;
1127
1296
  }
1128
1297
  }
1129
1298
 
@@ -1258,14 +1427,17 @@ function extractBalancedCallArguments(source: string, startIndex: number): strin
1258
1427
  }
1259
1428
 
1260
1429
  /**
1261
- * Collects literal string values of top-level `code:` properties inside a
1262
- * ProviderError/ValidationError options object. Only plain `"..."` / `'...'`
1263
- * literals at options-object depth count; computed codes (identifiers,
1430
+ * Collects literal string values of the named top-level properties inside a
1431
+ * ProviderError/AuthError/ValidationError options object. Only plain `"..."` /
1432
+ * `'...'` literals at options-object depth count; computed values (identifiers,
1264
1433
  * ternaries, template substitutions, concatenations, escapes) are skipped
1265
- * silently so the rule never guesses.
1434
+ * silently so the rules never guess. The first literal wins per property name.
1266
1435
  */
1267
- function collectLiteralErrorCodeValues(args: string): string[] {
1268
- const codes: string[] = [];
1436
+ function collectLiteralErrorOptionValues(
1437
+ args: string,
1438
+ optionNames: readonly string[],
1439
+ ): Record<string, string> {
1440
+ const collected: Record<string, string> = {};
1269
1441
  let braceDepth = 0;
1270
1442
  let parenDepth = 0;
1271
1443
  let bracketDepth = 0;
@@ -1275,7 +1447,7 @@ function collectLiteralErrorCodeValues(args: string): string[] {
1275
1447
  if (char === '"' || char === "'" || char === "`") {
1276
1448
  const end = skipStringLiteral(args, index);
1277
1449
  if (end < 0) {
1278
- return codes;
1450
+ return collected;
1279
1451
  }
1280
1452
  index = end;
1281
1453
  previousSignificantChar = char;
@@ -1284,7 +1456,7 @@ function collectLiteralErrorCodeValues(args: string): string[] {
1284
1456
  if (char === "/" && args[index + 1] === "/") {
1285
1457
  const newline = args.indexOf("\n", index);
1286
1458
  if (newline === -1) {
1287
- return codes;
1459
+ return collected;
1288
1460
  }
1289
1461
  index = newline;
1290
1462
  continue;
@@ -1292,7 +1464,7 @@ function collectLiteralErrorCodeValues(args: string): string[] {
1292
1464
  if (char === "/" && args[index + 1] === "*") {
1293
1465
  const end = args.indexOf("*/", index + 2);
1294
1466
  if (end === -1) {
1295
- return codes;
1467
+ return collected;
1296
1468
  }
1297
1469
  index = end + 1;
1298
1470
  continue;
@@ -1316,10 +1488,14 @@ function collectLiteralErrorCodeValues(args: string): string[] {
1316
1488
  braceDepth === 1 &&
1317
1489
  parenDepth === 0 &&
1318
1490
  bracketDepth === 0 &&
1319
- (previousSignificantChar === "{" || previousSignificantChar === ",") &&
1320
- args.startsWith("code", index)
1491
+ (previousSignificantChar === "{" || previousSignificantChar === ",")
1321
1492
  ) {
1322
- let cursor = index + "code".length;
1493
+ const optionName = optionNames.find((name) => startsWithOptionName(args, index, name));
1494
+ if (optionName === undefined) {
1495
+ previousSignificantChar = char;
1496
+ continue;
1497
+ }
1498
+ let cursor = index + optionName.length;
1323
1499
  while (cursor < args.length && /\s/.test(args[cursor] ?? "")) {
1324
1500
  cursor++;
1325
1501
  }
@@ -1342,23 +1518,41 @@ function collectLiteralErrorCodeValues(args: string): string[] {
1342
1518
  !value.includes("\\") &&
1343
1519
  (nextChar === "," || nextChar === "}" || nextChar === "")
1344
1520
  ) {
1345
- codes.push(value);
1521
+ collected[optionName] ??= value;
1346
1522
  }
1347
1523
  index = end;
1348
1524
  previousSignificantChar = quote;
1349
1525
  continue;
1350
1526
  }
1351
- return codes;
1527
+ return collected;
1352
1528
  }
1353
1529
  }
1354
1530
  }
1355
1531
  previousSignificantChar = char;
1356
1532
  }
1357
- return codes;
1533
+ return collected;
1534
+ }
1535
+
1536
+ /** True when `name` starts at `index` and is not the prefix of a longer identifier. */
1537
+ function startsWithOptionName(args: string, index: number, name: string): boolean {
1538
+ if (!args.startsWith(name, index)) return false;
1539
+ const next = args[index + name.length] ?? "";
1540
+ return !/[A-Za-z0-9_$]/.test(next);
1358
1541
  }
1359
1542
 
1360
- function collectLiteralThrownErrorCodes(source: string): string[] {
1361
- const codes: string[] = [];
1543
+ /** One `new ProviderError|AuthError|ValidationError(...)` construction found by source scan. */
1544
+ type ThrownErrorConstructionSite = {
1545
+ /** Constructor name, e.g. `ProviderError`. */
1546
+ readonly errorClass: string;
1547
+ readonly code?: string;
1548
+ readonly messageKey?: string;
1549
+ readonly fixKey?: string;
1550
+ };
1551
+
1552
+ const THROWN_ERROR_LOCALE_OPTION_NAMES = ["code", "messageKey", "fixKey"] as const;
1553
+
1554
+ function collectThrownErrorConstructionSites(source: string): ThrownErrorConstructionSite[] {
1555
+ const sites: ThrownErrorConstructionSite[] = [];
1362
1556
  THROWN_ERROR_CONSTRUCTION_PATTERN.lastIndex = 0;
1363
1557
  for (
1364
1558
  let match = THROWN_ERROR_CONSTRUCTION_PATTERN.exec(source);
@@ -1368,11 +1562,52 @@ function collectLiteralThrownErrorCodes(source: string): string[] {
1368
1562
  const argsStart = match.index + match[0].length;
1369
1563
  const args = extractBalancedCallArguments(source, argsStart);
1370
1564
  if (args !== undefined) {
1371
- codes.push(...collectLiteralErrorCodeValues(args));
1565
+ const values = collectLiteralErrorOptionValues(args, THROWN_ERROR_LOCALE_OPTION_NAMES);
1566
+ sites.push({
1567
+ errorClass: match[1] ?? "",
1568
+ ...(values.code === undefined ? {} : { code: values.code }),
1569
+ ...(values.messageKey === undefined ? {} : { messageKey: values.messageKey }),
1570
+ ...(values.fixKey === undefined ? {} : { fixKey: values.fixKey }),
1571
+ });
1372
1572
  }
1373
1573
  THROWN_ERROR_CONSTRUCTION_PATTERN.lastIndex = argsStart;
1374
1574
  }
1375
- return codes;
1575
+ return sites;
1576
+ }
1577
+
1578
+ type ThrowSiteLintSource = { readonly field: string; readonly source: string };
1579
+
1580
+ /**
1581
+ * The provider sources a throw-site rule scans, with the diagnostic field path
1582
+ * each one reports under. Prefers the CLI-collected source files (whole-repo
1583
+ * coverage) and falls back to the handler/auth-flow function text available
1584
+ * when `lintProvider` is called without them. Test sources are excluded.
1585
+ */
1586
+ function collectThrowSiteLintSources(provider: {
1587
+ authFlowSource?: string;
1588
+ providerSourceFiles?: Record<string, string>;
1589
+ operations?: Record<string, { handler?: unknown; source?: string }>;
1590
+ }): ThrowSiteLintSource[] {
1591
+ const sourceFiles = Object.entries(provider.providerSourceFiles ?? {}).filter(
1592
+ ([filePath]) => !TEST_SOURCE_FILE_PATTERN.test(filePath),
1593
+ );
1594
+ if (sourceFiles.length > 0) {
1595
+ return sourceFiles.map(([filePath, source]) => ({
1596
+ field: `sourceFiles.${filePath}`,
1597
+ source,
1598
+ }));
1599
+ }
1600
+ const sources: ThrowSiteLintSource[] = [];
1601
+ if (provider.authFlowSource) {
1602
+ sources.push({ field: "auth.flow", source: provider.authFlowSource });
1603
+ }
1604
+ for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
1605
+ const source = getOperationSource(operation);
1606
+ if (source) {
1607
+ sources.push({ field: `operations.${operationKey}.handler`, source });
1608
+ }
1609
+ }
1610
+ return sources;
1376
1611
  }
1377
1612
 
1378
1613
  /**
@@ -1417,30 +1652,15 @@ function lintUndeclaredThrownErrorCodes(provider: {
1417
1652
  }
1418
1653
  }
1419
1654
 
1420
- const sources: Array<{ field: string; source: string }> = [];
1421
- const sourceFiles = Object.entries(provider.providerSourceFiles ?? {}).filter(
1422
- ([filePath]) => !TEST_SOURCE_FILE_PATTERN.test(filePath),
1423
- );
1424
- if (sourceFiles.length > 0) {
1425
- for (const [filePath, source] of sourceFiles) {
1426
- sources.push({ field: `sourceFiles.${filePath}`, source });
1427
- }
1428
- } else {
1429
- if (provider.authFlowSource) {
1430
- sources.push({ field: "auth.flow", source: provider.authFlowSource });
1431
- }
1432
- for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
1433
- const source = getOperationSource(operation);
1434
- if (source) {
1435
- sources.push({ field: `operations.${operationKey}.handler`, source });
1436
- }
1437
- }
1438
- }
1655
+ const sources = collectThrowSiteLintSources(provider);
1439
1656
 
1440
1657
  const diagnostics: LintDiagnostic[] = [];
1441
1658
  for (const { field, source } of sources) {
1442
1659
  const undeclaredCodes = new Set(
1443
- collectLiteralThrownErrorCodes(source).filter((code) => !knownCodes.has(code)),
1660
+ collectThrownErrorConstructionSites(source)
1661
+ .filter((site) => UNDECLARED_CODE_ERROR_CLASSES.has(site.errorClass))
1662
+ .flatMap((site) => (site.code === undefined ? [] : [site.code]))
1663
+ .filter((code) => !knownCodes.has(code)),
1444
1664
  );
1445
1665
  for (const code of undeclaredCodes) {
1446
1666
  diagnostics.push({
@@ -1454,6 +1674,245 @@ function lintUndeclaredThrownErrorCodes(provider: {
1454
1674
  return diagnostics;
1455
1675
  }
1456
1676
 
1677
+ /**
1678
+ * Flags an operation `errorCodes` entry whose declared `status` or `retryable`
1679
+ * contradicts the SDK's canonical resolution for that code.
1680
+ *
1681
+ * For a code the SDK status-maps but does not runtime-own (UPSTREAM_ERROR,
1682
+ * BLOCKED, and the fleet-consensus codes registered alongside them), the
1683
+ * declaration still wins at runtime — `toStatusCode` reads the declaration
1684
+ * before the canonical map. So registering a code never silently rewrites an
1685
+ * existing declaration's status, and it must not: that would change a served
1686
+ * status behind the author's back. What it does create is a fleet that answers
1687
+ * one code two ways. This rule is where that divergence surfaces, by name, with
1688
+ * the canonical value to converge on.
1689
+ *
1690
+ * Codes the SDK runtime-owns are handled separately: `defineProvider` already
1691
+ * warns there, because for those the declaration really is ignored.
1692
+ *
1693
+ * Warning level, deliberately: the divergent providers are live and converge on
1694
+ * their own schedule, so this must not fail `apifuse check`.
1695
+ */
1696
+ function lintErrorCodeDeclarationConflicts(provider: {
1697
+ id?: string;
1698
+ operations?: Record<
1699
+ string,
1700
+ {
1701
+ errorCodes?: ReadonlyArray<{ code: string; status?: number; retryable?: boolean }>;
1702
+ }
1703
+ >;
1704
+ }): LintDiagnostic[] {
1705
+ const diagnostics: LintDiagnostic[] = [];
1706
+ const providerId = provider.id ?? "unknown";
1707
+ for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
1708
+ for (const [index, entry] of (operation.errorCodes ?? []).entries()) {
1709
+ if (typeof entry?.code !== "string") continue;
1710
+ // SDK-owned codes ignore the declaration outright; defineProvider owns
1711
+ // that message so authors are not told two different things.
1712
+ if (SDK_RUNTIME_OWNED_ERROR_CODES.has(entry.code)) continue;
1713
+
1714
+ const field = `operations.${operationKey}.errorCodes[${index}]`;
1715
+ const canonicalStatus = SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES.get(entry.code);
1716
+ if (canonicalStatus !== undefined && entry.status !== undefined) {
1717
+ if (entry.status !== canonicalStatus) {
1718
+ diagnostics.push({
1719
+ rule: "error-code-status-conflicts-sdk",
1720
+ level: "warn",
1721
+ field: `${field}.status`,
1722
+ message: `Provider "${providerId}" operation "${operationKey}" declares status ${entry.status} for SDK-registered error code "${entry.code}", whose canonical status is ${canonicalStatus}. The declaration still wins at runtime, so this operation serves ${entry.status} while the rest of the fleet serves ${canonicalStatus} for the same code. Drop the status (the SDK supplies ${canonicalStatus}) or, if ${entry.status} is genuinely the right answer here, throw a distinct code that means that.`,
1723
+ });
1724
+ }
1725
+ }
1726
+
1727
+ const canonicalRetryable = SDK_CANONICAL_ERROR_CODE_RETRYABILITY.get(entry.code);
1728
+ if (
1729
+ canonicalRetryable !== undefined &&
1730
+ entry.retryable !== undefined &&
1731
+ entry.retryable !== canonicalRetryable
1732
+ ) {
1733
+ diagnostics.push({
1734
+ rule: "error-code-retryable-conflicts-sdk",
1735
+ level: "warn",
1736
+ field: `${field}.retryable`,
1737
+ message: `Provider "${providerId}" operation "${operationKey}" declares retryable ${entry.retryable} for SDK-registered error code "${entry.code}", whose canonical retryability is ${canonicalRetryable}. The declaration still wins at runtime, so callers and the Gateway retry policy get a different answer here than everywhere else the same code is thrown. Drop the retryable (the SDK supplies ${canonicalRetryable}) or throw a distinct code for the retryable condition.`,
1738
+ });
1739
+ }
1740
+ }
1741
+ }
1742
+ return diagnostics;
1743
+ }
1744
+
1745
+ /** Declared `errorCodes[]` entry shape the localization rules read. */
1746
+ type OperationErrorCodeLike = {
1747
+ code: string;
1748
+ messageKey?: string;
1749
+ fixKey?: string;
1750
+ };
1751
+
1752
+ type ErrorLocaleKeyStatus = "ok" | "missing" | "malformed";
1753
+
1754
+ function errorLocaleKeyStatus(catalog: ProviderLocaleCatalog, key: string): ErrorLocaleKeyStatus {
1755
+ if (!isProviderLocaleKey(key)) return "malformed";
1756
+ const value = getProviderLocaleSegments(catalog, key.split("."));
1757
+ return typeof value === "string" && value.trim().length > 0 ? "ok" : "missing";
1758
+ }
1759
+
1760
+ function hasDerivedErrorCatalogText(
1761
+ catalog: ProviderLocaleCatalog,
1762
+ code: string,
1763
+ field: "message" | "fix",
1764
+ ): boolean {
1765
+ const value = getProviderLocaleSegments(catalog, [PROVIDER_ERROR_CATALOG_NAMESPACE, code, field]);
1766
+ return typeof value === "string" && value.trim().length > 0;
1767
+ }
1768
+
1769
+ /**
1770
+ * True when a thrown code reaches the caller as a real provider error whose
1771
+ * text the declared/derived catalog path can actually reach.
1772
+ *
1773
+ * A code qualifies either because the provider declares it in an operation's
1774
+ * `errorCodes`, or because the SDK registers a canonical status for it. The
1775
+ * second arm matters because registering a code is precisely what lets a
1776
+ * provider *delete* the declaration — AUTHORING tells it to for
1777
+ * `UPSTREAM_AUTH_ERROR`, `UPSTREAM_SCHEMA_ERROR` and `INVALID_REQUEST` — and an
1778
+ * undeclared throw of a registered code is no longer the HTTP 500 that
1779
+ * `thrown-error-code-undeclared` reports. Without this arm the two rules leave
1780
+ * a silent hole exactly where the fleet is being told to converge: a 502
1781
+ * `UPSTREAM_SCHEMA_ERROR` served untranslated with nothing warning about it.
1782
+ *
1783
+ * Codes the SDK runtime-owns are excluded: `sdkOwnsErrorResolution` skips the
1784
+ * declaration and derived candidates for them at serve time, so the catalog
1785
+ * entry this rule asks for would never take effect.
1786
+ */
1787
+ function isCatalogReachableThrownCode(code: string, declaredCodes: ReadonlySet<string>): boolean {
1788
+ if (declaredCodes.has(code)) return true;
1789
+ return (
1790
+ !SDK_RUNTIME_OWNED_ERROR_CODES.has(code) && SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES.has(code)
1791
+ );
1792
+ }
1793
+
1794
+ /**
1795
+ * Authoring rules for localized provider error text (see
1796
+ * `ProviderErrorOptions.messageKey`).
1797
+ *
1798
+ * - `error-locale-key-missing` / `error-locale-key-malformed` (**error**): a
1799
+ * literal `messageKey`/`fixKey`, whether on a throw site or on an
1800
+ * `errorCodes[]` declaration, that the provider's `en` catalog does not
1801
+ * resolve to non-empty text, or that is not a legal locale dot path. At
1802
+ * runtime such a key is silently skipped and the caller gets the English
1803
+ * literal, so nothing fails loudly without this rule.
1804
+ * - `thrown-error-message-not-localized` (**warn**): a throw site whose literal
1805
+ * `code` is declared in the provider's `errorCodes` — or SDK-registered, and
1806
+ * so servable without a declaration — but that carries no `messageKey`, no
1807
+ * declaration-level `messageKey`, and no derived `errors.<code>.message` in
1808
+ * `en`. Such an error is served untranslated to every caller. Warning while
1809
+ * the ~2,000 existing sites migrate; it is promoted to error per provider as
1810
+ * each migration wave lands.
1811
+ *
1812
+ * Both rules are skipped entirely when the caller did not supply
1813
+ * `localeCatalogEn`: without the catalog they could only guess.
1814
+ */
1815
+ function lintErrorMessageLocalization(provider: {
1816
+ authFlowSource?: string;
1817
+ providerSourceFiles?: Record<string, string>;
1818
+ localeCatalogEn?: ProviderLocaleCatalog;
1819
+ operations?: Record<
1820
+ string,
1821
+ {
1822
+ handler?: unknown;
1823
+ source?: string;
1824
+ errorCodes?: ReadonlyArray<OperationErrorCodeLike>;
1825
+ }
1826
+ >;
1827
+ }): LintDiagnostic[] {
1828
+ const catalog = provider.localeCatalogEn;
1829
+ if (!catalog) return [];
1830
+
1831
+ const diagnostics: LintDiagnostic[] = [];
1832
+ const declaredCodes = new Set<string>();
1833
+ const codesWithDeclaredMessageKey = new Set<string>();
1834
+
1835
+ for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
1836
+ for (const entry of operation.errorCodes ?? []) {
1837
+ if (typeof entry?.code !== "string") continue;
1838
+ declaredCodes.add(entry.code);
1839
+ for (const keyField of ["messageKey", "fixKey"] as const) {
1840
+ const key = entry[keyField];
1841
+ if (typeof key !== "string") continue;
1842
+ if (keyField === "messageKey") codesWithDeclaredMessageKey.add(entry.code);
1843
+ const status = errorLocaleKeyStatus(catalog, key);
1844
+ if (status === "ok") continue;
1845
+ diagnostics.push(
1846
+ errorLocaleKeyDiagnostic(
1847
+ status,
1848
+ `operations.${operationKey}.errorCodes`,
1849
+ key,
1850
+ `errorCodes entry "${entry.code}" of operation "${operationKey}"`,
1851
+ ),
1852
+ );
1853
+ }
1854
+ }
1855
+ }
1856
+
1857
+ for (const { field, source } of collectThrowSiteLintSources(provider)) {
1858
+ const reportedKeys = new Set<string>();
1859
+ const reportedCodes = new Set<string>();
1860
+ for (const site of collectThrownErrorConstructionSites(source)) {
1861
+ for (const key of [site.messageKey, site.fixKey]) {
1862
+ if (key === undefined || reportedKeys.has(key)) continue;
1863
+ const status = errorLocaleKeyStatus(catalog, key);
1864
+ if (status === "ok") continue;
1865
+ reportedKeys.add(key);
1866
+ diagnostics.push(
1867
+ errorLocaleKeyDiagnostic(status, field, key, `a thrown error in ${field}`),
1868
+ );
1869
+ }
1870
+ const code = site.code;
1871
+ if (code === undefined || reportedCodes.has(code)) continue;
1872
+ if (site.messageKey !== undefined) continue;
1873
+ if (!isCatalogReachableThrownCode(code, declaredCodes)) continue;
1874
+ if (codesWithDeclaredMessageKey.has(code)) continue;
1875
+ if (hasDerivedErrorCatalogText(catalog, code, "message")) continue;
1876
+ reportedCodes.add(code);
1877
+ const origin = declaredCodes.has(code)
1878
+ ? `declared code "${code}"`
1879
+ : `SDK-registered code "${code}"`;
1880
+ const declarationClause = declaredCodes.has(code)
1881
+ ? "its errorCodes declaration has none, and"
1882
+ : "no errorCodes declaration supplies one, and";
1883
+ diagnostics.push({
1884
+ rule: "thrown-error-message-not-localized",
1885
+ level: "warn",
1886
+ field,
1887
+ message: `${site.errorClass} with ${origin} (${field}) has no messageKey, ${declarationClause} locales/en.json has no "${PROVIDER_ERROR_CATALOG_NAMESPACE}.${code}.message"; this error is served untranslated to ko and ja callers. Add messageKey (or "${PROVIDER_ERROR_CATALOG_NAMESPACE}.${code}.message" to every locale catalog).`,
1888
+ });
1889
+ }
1890
+ }
1891
+
1892
+ return diagnostics;
1893
+ }
1894
+
1895
+ function errorLocaleKeyDiagnostic(
1896
+ status: Exclude<ErrorLocaleKeyStatus, "ok">,
1897
+ field: string,
1898
+ key: string,
1899
+ owner: string,
1900
+ ): LintDiagnostic {
1901
+ return status === "malformed"
1902
+ ? {
1903
+ rule: "error-locale-key-malformed",
1904
+ level: "error",
1905
+ field,
1906
+ message: `Locale key ${JSON.stringify(key)} on ${owner} is not a locale dot path such as "errors.upstreamSchema.message" or "errors.UPSTREAM_SCHEMA_ERROR.message"; segments are camelCase, an array index, or — directly under "errors" — the error code itself; the SDK skips a malformed key at serve time and the caller gets the English literal instead.`,
1907
+ }
1908
+ : {
1909
+ rule: "error-locale-key-missing",
1910
+ level: "error",
1911
+ field,
1912
+ message: `Locale key ${JSON.stringify(key)} on ${owner} is missing from locales/en.json (or resolves to a non-string/empty value); the SDK falls back to the English literal at serve time, so the key never takes effect.`,
1913
+ };
1914
+ }
1915
+
1457
1916
  type HandleFieldOccurrence = {
1458
1917
  /** Diagnostic path, e.g. `output.offers[].waiting_token`. */
1459
1918
  path: string;
@@ -1901,6 +2360,11 @@ export function lintProvider(
1901
2360
  };
1902
2361
  authFlowSource?: string;
1903
2362
  providerSourceFiles?: Record<string, string>;
2363
+ /**
2364
+ * The provider's `locales/en.json`, injected by `apifuse check`. Error
2365
+ * message localization rules are skipped when it is absent.
2366
+ */
2367
+ localeCatalogEn?: ProviderLocaleCatalog;
1904
2368
  operations?: Record<
1905
2369
  string,
1906
2370
  {
@@ -1915,7 +2379,13 @@ export function lintProvider(
1915
2379
  fixtures?: unknown;
1916
2380
  handler?: unknown;
1917
2381
  source?: string;
1918
- errorCodes?: ReadonlyArray<{ code: string }>;
2382
+ errorCodes?: ReadonlyArray<{
2383
+ code: string;
2384
+ status?: number;
2385
+ retryable?: boolean;
2386
+ messageKey?: string;
2387
+ fixKey?: string;
2388
+ }>;
1919
2389
  }
1920
2390
  >;
1921
2391
  meta?: {
@@ -1946,6 +2416,8 @@ export function lintProviderWithInformation(
1946
2416
  ...lintSelfHostedBrowserPatterns(provider, options),
1947
2417
  ...lintBrowserVersionLiterals(provider),
1948
2418
  ...lintUndeclaredThrownErrorCodes(provider),
2419
+ ...lintErrorCodeDeclarationConflicts(provider),
2420
+ ...lintErrorMessageLocalization(provider),
1949
2421
  ...lintLegacyChoiceUsage(provider),
1950
2422
  ...lintHandleDeclarations(provider),
1951
2423
  ];