@apifuse/provider-sdk 2.2.0-beta.4 → 2.2.0-beta.41

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 (315) hide show
  1. package/AUTHORING.md +501 -13
  2. package/CHANGELOG.md +169 -1
  3. package/README.md +72 -14
  4. package/SUBMISSION.md +1 -1
  5. package/bin/apifuse-check.ts +150 -5
  6. package/bin/apifuse-dev.ts +38 -5
  7. package/bin/apifuse-migrate-shape.ts +84 -0
  8. package/bin/apifuse-pack-check.ts +22 -2
  9. package/bin/apifuse-pack-smoke.ts +57 -2
  10. package/bin/apifuse-pack-types.ts +356 -38
  11. package/bin/apifuse-perf.ts +14 -13
  12. package/bin/apifuse-record.ts +691 -68
  13. package/bin/apifuse-submit-check.ts +2301 -282
  14. package/bin/apifuse-sync-assets.ts +117 -0
  15. package/bin/submit-check-delimited-text.ts +50 -0
  16. package/dist/auth-turn/index.d.ts +3 -3
  17. package/dist/auth-turn/index.js +1 -1
  18. package/dist/auth.d.ts +14 -0
  19. package/dist/auth.js +67 -0
  20. package/dist/ceremonies/index.d.ts +16 -0
  21. package/dist/ceremonies/index.js +141 -36
  22. package/dist/cli/commands.d.ts +1 -1
  23. package/dist/cli/commands.js +16 -0
  24. package/dist/cli/create.d.ts +3 -0
  25. package/dist/cli/create.js +40 -35
  26. package/dist/cli/migrate-provider-shape.d.ts +52 -0
  27. package/dist/cli/migrate-provider-shape.js +515 -0
  28. package/dist/cli/prompt-assets.d.ts +80 -0
  29. package/dist/cli/prompt-assets.js +743 -0
  30. package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
  31. package/dist/cli/templates/provider/Dockerfile.tpl +1 -1
  32. package/dist/cli/templates/provider/README.md.tpl +4 -4
  33. package/dist/cli/templates/provider/index.ts.tpl +6 -3
  34. package/dist/cli/templates/provider/operations/ping.ts.tpl +2 -1
  35. package/dist/cli/templates/provider/provider.json.tpl +6 -0
  36. package/dist/config/loader.d.ts +177 -16
  37. package/dist/config/loader.js +424 -127
  38. package/dist/contract-serialization.js +4 -8
  39. package/dist/contract-types.d.ts +1 -0
  40. package/dist/contract.js +3 -0
  41. package/dist/declaration-validation.d.ts +32 -0
  42. package/dist/declaration-validation.js +207 -0
  43. package/dist/define.d.ts +51 -25
  44. package/dist/define.js +774 -39
  45. package/dist/error-observability.d.ts +7 -0
  46. package/dist/error-observability.js +61 -0
  47. package/dist/error-resolution.d.ts +4 -0
  48. package/dist/error-resolution.js +122 -0
  49. package/dist/errors.d.ts +33 -0
  50. package/dist/errors.js +40 -0
  51. package/dist/fixture-sanitization.d.ts +28 -0
  52. package/dist/fixture-sanitization.js +227 -0
  53. package/dist/health-scenario.d.ts +1842 -0
  54. package/dist/health-scenario.js +624 -0
  55. package/dist/index.d.ts +19 -9
  56. package/dist/index.js +10 -6
  57. package/dist/lint.d.ts +6 -1
  58. package/dist/lint.js +362 -3
  59. package/dist/native-address.d.ts +43 -0
  60. package/dist/native-address.js +281 -0
  61. package/dist/native-egress-policy.d.ts +31 -0
  62. package/dist/native-egress-policy.js +288 -0
  63. package/dist/observability.d.ts +5 -2
  64. package/dist/observability.js +48 -1
  65. package/dist/provider.d.ts +8 -2
  66. package/dist/provider.js +3 -1
  67. package/dist/runtime/auth-flow.d.ts +5 -1
  68. package/dist/runtime/auth-flow.js +6 -0
  69. package/dist/runtime/browser.d.ts +1 -0
  70. package/dist/runtime/browser.js +492 -49
  71. package/dist/runtime/cache.d.ts +1 -0
  72. package/dist/runtime/cache.js +169 -15
  73. package/dist/runtime/choice-wordlist.d.ts +9 -0
  74. package/dist/runtime/choice-wordlist.js +138 -0
  75. package/dist/runtime/choice.d.ts +13 -1
  76. package/dist/runtime/choice.js +490 -102
  77. package/dist/runtime/executor.d.ts +2 -2
  78. package/dist/runtime/executor.js +37 -3
  79. package/dist/runtime/http.d.ts +1 -0
  80. package/dist/runtime/http.js +515 -53
  81. package/dist/runtime/instrumentation.d.ts +2 -2
  82. package/dist/runtime/instrumentation.js +366 -8
  83. package/dist/runtime/native-network-errors.d.ts +33 -0
  84. package/dist/runtime/native-network-errors.js +69 -0
  85. package/dist/runtime/native-network.d.ts +96 -0
  86. package/dist/runtime/native-network.js +1232 -0
  87. package/dist/runtime/ocr.d.ts +29 -0
  88. package/dist/runtime/ocr.js +440 -0
  89. package/dist/runtime/proxy-errors.js +6 -2
  90. package/dist/runtime/proxy-nodemaven.d.ts +56 -0
  91. package/dist/runtime/proxy-nodemaven.js +146 -0
  92. package/dist/runtime/proxy-telemetry.d.ts +80 -1
  93. package/dist/runtime/proxy-telemetry.js +154 -47
  94. package/dist/runtime/redirects.d.ts +29 -0
  95. package/dist/runtime/redirects.js +36 -0
  96. package/dist/runtime/redis.d.ts +1 -1
  97. package/dist/runtime/redis.js +4 -2
  98. package/dist/runtime/request-options.d.ts +68 -1
  99. package/dist/runtime/request-options.js +548 -0
  100. package/dist/runtime/resolver-config.d.ts +6 -0
  101. package/dist/runtime/resolver-config.js +6 -0
  102. package/dist/runtime/resolver-public.d.ts +1 -0
  103. package/dist/runtime/resolver-public.js +1 -0
  104. package/dist/runtime/resolver-shared.d.ts +3 -0
  105. package/dist/runtime/resolver-shared.js +12 -0
  106. package/dist/runtime/resolver-vendors/bindings.d.ts +48 -0
  107. package/dist/runtime/resolver-vendors/bindings.js +40 -0
  108. package/dist/runtime/resolver-vendors/browser.d.ts +22 -0
  109. package/dist/runtime/resolver-vendors/browser.js +377 -0
  110. package/dist/runtime/resolver-vendors/capsolver.d.ts +22 -0
  111. package/dist/runtime/resolver-vendors/capsolver.js +526 -0
  112. package/dist/runtime/resolver-vendors/hosts.d.ts +2 -0
  113. package/dist/runtime/resolver-vendors/hosts.js +33 -0
  114. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +24 -0
  115. package/dist/runtime/resolver-vendors/twocaptcha.js +407 -0
  116. package/dist/runtime/resolver-vendors/types.d.ts +94 -0
  117. package/dist/runtime/resolver-vendors/types.js +96 -0
  118. package/dist/runtime/resolver.d.ts +60 -0
  119. package/dist/runtime/resolver.js +737 -0
  120. package/dist/runtime/secrets.d.ts +27 -0
  121. package/dist/runtime/secrets.js +51 -0
  122. package/dist/runtime/state.d.ts +3 -0
  123. package/dist/runtime/state.js +277 -71
  124. package/dist/runtime/stealth-cookies.d.ts +20 -0
  125. package/dist/runtime/stealth-cookies.js +111 -0
  126. package/dist/runtime/stealth.d.ts +28 -3
  127. package/dist/runtime/stealth.js +519 -255
  128. package/dist/runtime/stt.js +1 -12
  129. package/dist/runtime/timeout.d.ts +5 -0
  130. package/dist/runtime/timeout.js +12 -0
  131. package/dist/runtime/trace-config.d.ts +12 -0
  132. package/dist/runtime/trace-config.js +61 -0
  133. package/dist/serve.d.ts +1 -1
  134. package/dist/serve.js +1 -1
  135. package/dist/server/error-observability.d.ts +1 -0
  136. package/dist/server/error-observability.js +1 -0
  137. package/dist/server/index.d.ts +6 -3
  138. package/dist/server/index.js +3 -3
  139. package/dist/server/self-test-input-tokens.d.ts +2 -1
  140. package/dist/server/self-test-input-tokens.js +18 -14
  141. package/dist/server/self-test.d.ts +114 -0
  142. package/dist/server/self-test.js +787 -148
  143. package/dist/server/serve-implementation.d.ts +225 -0
  144. package/dist/server/serve-implementation.js +2242 -0
  145. package/dist/server/serve.d.ts +1 -70
  146. package/dist/server/serve.js +1 -1130
  147. package/dist/server/trace-output.d.ts +4 -0
  148. package/dist/server/trace-output.js +20 -0
  149. package/dist/server/types.d.ts +30 -5
  150. package/dist/server/types.js +13 -1
  151. package/dist/stateful/errors.d.ts +19 -0
  152. package/dist/stateful/errors.js +24 -0
  153. package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
  154. package/dist/stateful/http-provider-event-emitter.js +237 -0
  155. package/dist/stateful/http-session-owner-registry.d.ts +44 -0
  156. package/dist/stateful/http-session-owner-registry.js +210 -0
  157. package/dist/stateful/index.d.ts +18 -0
  158. package/dist/stateful/index.js +18 -0
  159. package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
  160. package/dist/stateful/provider-event-delivery-failures.js +43 -0
  161. package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
  162. package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
  163. package/dist/stateful/provider-event-pipeline.d.ts +50 -0
  164. package/dist/stateful/provider-event-pipeline.js +1 -0
  165. package/dist/stateful/provider-events.d.ts +101 -0
  166. package/dist/stateful/provider-events.js +289 -0
  167. package/dist/stateful/session-key.d.ts +15 -0
  168. package/dist/stateful/session-key.js +86 -0
  169. package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
  170. package/dist/stateful/stateful-provider-adapter-context.js +42 -0
  171. package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
  172. package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
  173. package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
  174. package/dist/stateful/stateful-provider-adapter.js +287 -0
  175. package/dist/stateful/stateful-provider-observability.d.ts +62 -0
  176. package/dist/stateful/stateful-provider-observability.js +161 -0
  177. package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
  178. package/dist/stateful/stateful-provider-owner-forwarder.js +215 -0
  179. package/dist/stateful/stateful-provider-runtime-context.d.ts +32 -0
  180. package/dist/stateful/stateful-provider-runtime-context.js +60 -0
  181. package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
  182. package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
  183. package/dist/stateful/stateful-provider-session-routing.d.ts +67 -0
  184. package/dist/stateful/stateful-provider-session-routing.js +345 -0
  185. package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
  186. package/dist/stateful/stateful-provider-session-runtime.js +245 -0
  187. package/dist/stateful-signing.d.ts +18 -0
  188. package/dist/stateful-signing.js +27 -0
  189. package/dist/stealth/profiles.js +16 -7
  190. package/dist/stream-evidence.d.ts +74 -0
  191. package/dist/stream-evidence.js +785 -0
  192. package/dist/stream.js +7 -1
  193. package/dist/testing/index.d.ts +2 -1
  194. package/dist/testing/index.js +2 -1
  195. package/dist/testing/run.d.ts +32 -2
  196. package/dist/testing/run.js +489 -21
  197. package/dist/trace-sanitization.d.ts +5 -0
  198. package/dist/trace-sanitization.js +45 -0
  199. package/dist/types.d.ts +563 -33
  200. package/dist/types.js +1 -0
  201. package/package.json +44 -5
  202. package/src/auth-turn/index.ts +1 -1
  203. package/src/auth.ts +118 -0
  204. package/src/ceremonies/index.ts +189 -46
  205. package/src/cli/commands.ts +20 -0
  206. package/src/cli/create.ts +48 -35
  207. package/src/cli/migrate-provider-shape.ts +701 -0
  208. package/src/cli/prompt-assets.ts +865 -0
  209. package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
  210. package/src/cli/templates/provider/Dockerfile.tpl +1 -1
  211. package/src/cli/templates/provider/README.md.tpl +4 -4
  212. package/src/cli/templates/provider/index.ts.tpl +6 -3
  213. package/src/cli/templates/provider/operations/ping.ts.tpl +2 -1
  214. package/src/cli/templates/provider/provider.json.tpl +6 -0
  215. package/src/config/loader.ts +665 -163
  216. package/src/contract-serialization.ts +5 -7
  217. package/src/contract-types.ts +1 -0
  218. package/src/contract.ts +3 -0
  219. package/src/declaration-validation.ts +266 -0
  220. package/src/define.ts +1003 -88
  221. package/src/error-observability.ts +64 -0
  222. package/src/error-resolution.ts +127 -0
  223. package/src/errors.ts +68 -0
  224. package/src/fixture-sanitization.ts +264 -0
  225. package/src/health-scenario.ts +875 -0
  226. package/src/index.ts +205 -8
  227. package/src/lint.ts +408 -4
  228. package/src/native-address.ts +340 -0
  229. package/src/native-egress-policy.ts +358 -0
  230. package/src/observability.ts +51 -1
  231. package/src/provider.ts +134 -0
  232. package/src/runtime/auth-flow.ts +12 -0
  233. package/src/runtime/browser.ts +661 -63
  234. package/src/runtime/cache.ts +189 -14
  235. package/src/runtime/choice-wordlist.ts +145 -0
  236. package/src/runtime/choice.ts +631 -120
  237. package/src/runtime/executor.ts +53 -8
  238. package/src/runtime/http.ts +641 -61
  239. package/src/runtime/instrumentation.ts +520 -15
  240. package/src/runtime/native-network-errors.ts +99 -0
  241. package/src/runtime/native-network.ts +1605 -0
  242. package/src/runtime/ocr.ts +523 -0
  243. package/src/runtime/proxy-errors.ts +12 -4
  244. package/src/runtime/proxy-nodemaven.ts +221 -0
  245. package/src/runtime/proxy-telemetry.ts +244 -75
  246. package/src/runtime/redirects.ts +66 -0
  247. package/src/runtime/redis.ts +7 -2
  248. package/src/runtime/request-options.ts +680 -1
  249. package/src/runtime/resolver-config.ts +6 -0
  250. package/src/runtime/resolver-public.ts +20 -0
  251. package/src/runtime/resolver-shared.ts +17 -0
  252. package/src/runtime/resolver-vendors/bindings.ts +56 -0
  253. package/src/runtime/resolver-vendors/browser.ts +533 -0
  254. package/src/runtime/resolver-vendors/capsolver.ts +700 -0
  255. package/src/runtime/resolver-vendors/hosts.ts +38 -0
  256. package/src/runtime/resolver-vendors/twocaptcha.ts +539 -0
  257. package/src/runtime/resolver-vendors/types.ts +212 -0
  258. package/src/runtime/resolver.ts +1103 -0
  259. package/src/runtime/secrets.ts +64 -0
  260. package/src/runtime/state.ts +394 -77
  261. package/src/runtime/stealth-cookies.ts +132 -0
  262. package/src/runtime/stealth.ts +675 -289
  263. package/src/runtime/stt.ts +1 -19
  264. package/src/runtime/timeout.ts +18 -0
  265. package/src/runtime/trace-config.ts +77 -0
  266. package/src/serve.ts +6 -1
  267. package/src/server/error-observability.ts +1 -0
  268. package/src/server/index.ts +39 -2
  269. package/src/server/self-test-input-tokens.ts +29 -14
  270. package/src/server/self-test.ts +1030 -175
  271. package/src/server/serve-implementation.ts +3338 -0
  272. package/src/server/serve.ts +1 -1626
  273. package/src/server/trace-output.ts +32 -0
  274. package/src/server/types.ts +13 -1
  275. package/src/stateful/README.md +146 -0
  276. package/src/stateful/errors.ts +35 -0
  277. package/src/stateful/http-provider-event-emitter.ts +314 -0
  278. package/src/stateful/http-session-owner-registry.ts +306 -0
  279. package/src/stateful/index.ts +18 -0
  280. package/src/stateful/provider-event-delivery-failures.ts +80 -0
  281. package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
  282. package/src/stateful/provider-event-pipeline.ts +61 -0
  283. package/src/stateful/provider-events.ts +462 -0
  284. package/src/stateful/session-key.ts +111 -0
  285. package/src/stateful/stateful-provider-adapter-context.ts +59 -0
  286. package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
  287. package/src/stateful/stateful-provider-adapter.ts +562 -0
  288. package/src/stateful/stateful-provider-observability.ts +261 -0
  289. package/src/stateful/stateful-provider-owner-forwarder.ts +287 -0
  290. package/src/stateful/stateful-provider-runtime-context.ts +92 -0
  291. package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
  292. package/src/stateful/stateful-provider-session-routing.ts +546 -0
  293. package/src/stateful/stateful-provider-session-runtime.ts +403 -0
  294. package/src/stateful-signing.ts +46 -0
  295. package/src/stealth/profiles.ts +17 -7
  296. package/src/stream-evidence.ts +988 -0
  297. package/src/stream.ts +8 -1
  298. package/src/testing/index.ts +10 -1
  299. package/src/testing/run.ts +658 -15
  300. package/src/trace-sanitization.ts +63 -0
  301. package/src/types.ts +698 -65
  302. package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
  303. package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
  304. /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  305. /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  306. /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  307. /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  308. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  309. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
  310. /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  311. /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  312. /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  313. /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  314. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  315. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
@@ -4,22 +4,29 @@ import { type ChildProcess, spawn } from "node:child_process";
4
4
  import { existsSync, readdirSync, readFileSync } from "node:fs";
5
5
  import { writeFile } from "node:fs/promises";
6
6
  import { createServer } from "node:net";
7
- import { basename, dirname, join, relative, resolve } from "node:path";
7
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
8
8
  import { pathToFileURL } from "node:url";
9
9
 
10
10
  import * as acorn from "acorn";
11
+ import ts from "typescript";
11
12
  import { z } from "zod";
12
13
 
13
14
  import packageJson from "../package.json";
14
- import type { ProviderDefinition } from "../src/index.js";
15
+ import { formatPromptAssetIssues, verifyPromptAssets } from "../src/cli/prompt-assets.js";
15
16
  import {
16
17
  loadProviderLocaleCatalogs,
17
18
  type ProviderLocale,
18
19
  validateProviderLocaleCatalogs,
19
20
  } from "../src/i18n/index.js";
20
- import { APIFUSE_DESCRIPTION_KEY_META_KEY } from "../src/schema.js";
21
- import { safeParseSchemaSync } from "../src/schema.js";
22
- import { type CheckResult, runChecks } from "./apifuse-check.js";
21
+ import type { ProviderDefinition } from "../src/index.js";
22
+ import { APIFUSE_DESCRIPTION_KEY_META_KEY, safeParseSchemaSync } from "../src/schema.js";
23
+ import {
24
+ findStreamCaptureGroup,
25
+ hasStreamEvidenceMarker,
26
+ parseStreamEvidenceRecord,
27
+ } from "../src/stream-evidence.js";
28
+ import { type CheckResult, PROMPT_ASSETS_CHECK_MESSAGE, runChecks } from "./apifuse-check.js";
29
+ import { hasSubstantiveDelimitedTextStructure } from "./submit-check-delimited-text.js";
23
30
  import { hasSubstantiveXmlStructure } from "./submit-check-xml.js";
24
31
 
25
32
  const TIERS = ["bronze", "silver", "gold", "diamond"] as const;
@@ -106,11 +113,14 @@ export type SmokeResult = {
106
113
  type SourceFinding = {
107
114
  file: string;
108
115
  line: number;
116
+ detail?: string;
109
117
  };
110
118
 
111
119
  const SDK_NATIVE_CATEGORY = "sdk-native";
112
120
  const VENDOR_SHIM_PROVIDER_ID_PREFIX = "apifuse-provider-";
113
121
  const MAX_SOURCE_FINDING_EVIDENCE = 5;
122
+ const MAX_LOAD_ERROR_EVIDENCE = 5;
123
+ const MAX_LOAD_ERROR_MESSAGE_LENGTH = 500;
114
124
 
115
125
  const CATEGORY_MAX_POINTS = {
116
126
  definition: 15,
@@ -265,24 +275,49 @@ export async function buildSubmitCheckReport(
265
275
  ): Promise<SubmitCheckReport> {
266
276
  const checks: SubmitCheck[] = [];
267
277
  const baseChecks = await safeRunChecks(providerRoot);
268
- const provider = await safeLoadProvider(providerRoot);
269
-
270
- checks.push(...scoreBaseChecks(baseChecks));
271
- checks.push(scoreProviderIdSlug(providerRoot, provider));
272
- checks.push(scoreNoVendorShim(providerRoot));
273
- checks.push(scoreNoVendorImport(providerRoot));
274
- checks.push(scoreDescribeKey(providerRoot));
275
- checks.push(scoreNoRawFetch(providerRoot));
276
- checks.push(scoreNoRedundantRuntimeGuards(providerRoot));
277
- checks.push(scoreManagedBrowserRuntime(providerRoot));
278
- checks.push(scoreAsAssertionCount(providerRoot));
279
- checks.push(scoreUnsafeInputPassthrough(providerRoot));
280
- checks.push(scoreUnjustifiedLooseSchema(providerRoot));
281
- checks.push(scoreFlatOperationComposition(providerRoot));
278
+ const loadResult = await safeLoadProvider(providerRoot);
279
+ const provider = loadResult.ok ? loadResult.provider : undefined;
280
+ let indexParseError: string | undefined;
281
+ if (!provider) {
282
+ const indexPath = resolve(providerRoot, "index.ts");
283
+ if (existsSync(indexPath)) {
284
+ const relPath = toRelativeProviderPath(providerRoot, indexPath);
285
+ try {
286
+ maskCommentsAndStrings(readFileSync(indexPath, "utf8"), relPath);
287
+ } catch (error) {
288
+ indexParseError = error instanceof Error ? error.message : String(error);
289
+ }
290
+ }
291
+ }
292
+
293
+ // Prompt-asset freshness is reported by its own dedicated zero-point
294
+ // blocker below; filter the base-check duplicate so it is not double
295
+ // penalized under the definition category.
296
+ checks.push(
297
+ ...scoreBaseChecks(
298
+ baseChecks.filter((result) => result.message !== PROMPT_ASSETS_CHECK_MESSAGE),
299
+ ),
300
+ );
301
+ checks.push(scorePromptAssetFreshness(providerRoot));
302
+ if (!indexParseError) {
303
+ checks.push(scoreProviderIdSlug(providerRoot, provider));
304
+ checks.push(scoreNoVendorShim(providerRoot));
305
+ checks.push(scoreNoVendorImport(providerRoot));
306
+ checks.push(scoreDescribeKey(providerRoot));
307
+ checks.push(scoreNoRawFetch(providerRoot));
308
+ checks.push(scoreNoDynamicCode(providerRoot));
309
+ checks.push(scoreNoRedundantRuntimeGuards(providerRoot));
310
+ checks.push(scoreManagedBrowserRuntime(providerRoot));
311
+ checks.push(scoreAsAssertionCount(providerRoot));
312
+ checks.push(scoreUnsafeInputPassthrough(providerRoot));
313
+ checks.push(scoreUnjustifiedLooseSchema(providerRoot));
314
+ checks.push(scoreFlatOperationComposition(providerRoot));
315
+ }
282
316
 
283
317
  if (provider) {
284
318
  const smokeResult = args.smoke ? await runSubmitCheckSmoke(providerRoot, provider) : undefined;
285
319
  checks.push(scoreCredentialUsage(providerRoot, provider));
320
+ checks.push(scoreSdkOwnedSecretPresence(providerRoot, provider));
286
321
  checks.push(scoreLocaleCatalog(providerRoot, provider));
287
322
  checks.push(scoreOperationMetadata(provider));
288
323
  checks.push(scoreFixtureCoverage(provider));
@@ -296,15 +331,38 @@ export async function buildSubmitCheckReport(
296
331
  checks.push(scoreRepositoryDx(providerRoot));
297
332
  checks.push(scoreSecrets(providerRoot, provider));
298
333
  } else {
334
+ const loadEvidence = indexParseError
335
+ ? undefined
336
+ : loadResult.ok
337
+ ? undefined
338
+ : formatLoadErrorEvidence(loadResult.error, providerRoot);
339
+ const loadRemediation = indexParseError
340
+ ? "Fix the syntax error reported by the provider-load-parse blocker before apifuse check can load the provider."
341
+ : !loadResult.ok
342
+ ? "Fix the import/initialization failure shown in evidence so apifuse check can load the provider."
343
+ : "Fix index.ts so it default-exports defineProvider(...).";
299
344
  checks.push(
300
345
  blocker(
301
346
  "provider-load",
302
347
  "definition",
303
348
  "Provider could not be loaded.",
304
- "Fix index.ts so it default-exports defineProvider(...).",
349
+ loadRemediation,
305
350
  CATEGORY_MAX_POINTS.definition,
351
+ loadEvidence,
306
352
  ),
307
353
  );
354
+ if (indexParseError) {
355
+ checks.push(
356
+ blocker(
357
+ "provider-load-parse",
358
+ "definition",
359
+ "Provider index.ts could not be parsed safely.",
360
+ "Fix the syntax error in index.ts before submitting.",
361
+ 0,
362
+ [indexParseError],
363
+ ),
364
+ );
365
+ }
308
366
  }
309
367
 
310
368
  const total = clamp(Math.round(checks.reduce((sum, check) => sum + check.points, 0)), 0, 100);
@@ -432,7 +490,7 @@ function scoreDescribeKey(providerRoot: string): SubmitCheck {
432
490
  }
433
491
 
434
492
  function scoreNoRawFetch(providerRoot: string): SubmitCheck {
435
- const findings = findSourceLineMatches(providerRoot, /(?<![.\w])fetch\s*\(/);
493
+ const findings = findGlobalSinkCalls(providerRoot, FETCH_SINKS);
436
494
  if (findings.length > 0) {
437
495
  const evidence = formatSourceFindings(findings);
438
496
  return blocker(
@@ -448,6 +506,435 @@ function scoreNoRawFetch(providerRoot: string): SubmitCheck {
448
506
  return pass("no-raw-fetch", SDK_NATIVE_CATEGORY, "Provider source avoids raw fetch().", 0);
449
507
  }
450
508
 
509
+ const DYNAMIC_CODE_SINKS: ReadonlySet<string> = new Set(["eval", "Function"]);
510
+ const FETCH_SINKS: ReadonlySet<string> = new Set(["fetch"]);
511
+ const DYNAMIC_CODE_GLOBAL_OBJECTS: ReadonlySet<string> = new Set([
512
+ "globalThis",
513
+ "window",
514
+ "self",
515
+ "global",
516
+ ]);
517
+
518
+ function unwrapLocalExpression(expression: ts.Expression): ts.Expression {
519
+ let current = expression;
520
+ while (
521
+ ts.isParenthesizedExpression(current) ||
522
+ ts.isAsExpression(current) ||
523
+ ts.isSatisfiesExpression(current) ||
524
+ ts.isNonNullExpression(current)
525
+ ) {
526
+ current = current.expression;
527
+ }
528
+ if (ts.isBinaryExpression(current) && current.operatorToken.kind === ts.SyntaxKind.CommaToken) {
529
+ return unwrapLocalExpression(current.right);
530
+ }
531
+ return current;
532
+ }
533
+
534
+ type LocalBinding = {
535
+ declaration: ts.Identifier;
536
+ initializer?: ts.Expression;
537
+ destructuredProperty?: string;
538
+ mutable: boolean;
539
+ reassigned: boolean;
540
+ };
541
+
542
+ type LocalBindings = {
543
+ scopes: Map<ts.Node, Map<string, LocalBinding[]>>;
544
+ };
545
+
546
+ function isLexicalScope(node: ts.Node): boolean {
547
+ return (
548
+ ts.isSourceFile(node) ||
549
+ ts.isFunctionLike(node) ||
550
+ ts.isBlock(node) ||
551
+ ts.isModuleBlock(node) ||
552
+ ts.isCaseBlock(node) ||
553
+ ts.isCatchClause(node) ||
554
+ ts.isForStatement(node) ||
555
+ ts.isForInStatement(node) ||
556
+ ts.isForOfStatement(node)
557
+ );
558
+ }
559
+
560
+ function enclosingScope(node: ts.Node, functionScoped: boolean): ts.Node {
561
+ let current: ts.Node | undefined = node.parent;
562
+ while (current !== undefined) {
563
+ if (
564
+ ts.isSourceFile(current) ||
565
+ (functionScoped ? ts.isFunctionLike(current) : isLexicalScope(current))
566
+ ) {
567
+ return current;
568
+ }
569
+ current = current.parent;
570
+ }
571
+ return node.getSourceFile();
572
+ }
573
+
574
+ function collectLocalBindings(sourceFile: ts.SourceFile): LocalBindings {
575
+ const bindings: LocalBindings = { scopes: new Map() };
576
+ const addBinding = (
577
+ scope: ts.Node,
578
+ name: ts.Identifier,
579
+ binding: Omit<LocalBinding, "declaration" | "reassigned">,
580
+ ): void => {
581
+ let scopeBindings = bindings.scopes.get(scope);
582
+ if (scopeBindings === undefined) {
583
+ scopeBindings = new Map();
584
+ bindings.scopes.set(scope, scopeBindings);
585
+ }
586
+ const existing = scopeBindings.get(name.text);
587
+ const localBinding: LocalBinding = { ...binding, declaration: name, reassigned: false };
588
+ if (existing === undefined) {
589
+ scopeBindings.set(name.text, [localBinding]);
590
+ } else {
591
+ existing.push(localBinding);
592
+ }
593
+ };
594
+ const addBindingName = (
595
+ bindingName: ts.BindingName,
596
+ scope: ts.Node,
597
+ binding: Omit<LocalBinding, "declaration" | "reassigned">,
598
+ ): void => {
599
+ if (ts.isIdentifier(bindingName)) {
600
+ addBinding(scope, bindingName, binding);
601
+ return;
602
+ }
603
+ for (const element of bindingName.elements) {
604
+ if (ts.isOmittedExpression(element)) {
605
+ continue;
606
+ }
607
+ if (ts.isObjectBindingPattern(bindingName) && !element.dotDotDotToken) {
608
+ const property = element.propertyName ?? element.name;
609
+ const propertyName = propertyNameText(property);
610
+ if (propertyName !== undefined && ts.isIdentifier(element.name)) {
611
+ addBinding(scope, element.name, {
612
+ initializer: binding.initializer,
613
+ destructuredProperty: propertyName,
614
+ mutable: binding.mutable,
615
+ });
616
+ continue;
617
+ }
618
+ }
619
+ addBindingName(element.name, scope, { mutable: binding.mutable });
620
+ }
621
+ };
622
+ const visit = (node: ts.Node): void => {
623
+ if (ts.isVariableDeclaration(node)) {
624
+ const declarationList = ts.isVariableDeclarationList(node.parent) ? node.parent : undefined;
625
+ const isConstBinding =
626
+ declarationList !== undefined && (declarationList.flags & ts.NodeFlags.Const) !== 0;
627
+ const isBlockScoped =
628
+ declarationList !== undefined && (declarationList.flags & ts.NodeFlags.BlockScoped) !== 0;
629
+ addBindingName(node.name, enclosingScope(node, !isBlockScoped), {
630
+ initializer: node.initializer,
631
+ mutable: !isConstBinding,
632
+ });
633
+ } else if (ts.isFunctionDeclaration(node) && node.name !== undefined) {
634
+ addBinding(enclosingScope(node, false), node.name, { mutable: false });
635
+ } else if (ts.isFunctionExpression(node) && node.name !== undefined) {
636
+ addBinding(node, node.name, { mutable: false });
637
+ } else if (ts.isClassDeclaration(node) && node.name !== undefined) {
638
+ addBinding(enclosingScope(node, false), node.name, { mutable: false });
639
+ } else if (ts.isClassExpression(node) && node.name !== undefined) {
640
+ addBinding(node, node.name, { mutable: false });
641
+ } else if (ts.isParameter(node)) {
642
+ addBindingName(node.name, enclosingScope(node, true), { mutable: true });
643
+ } else if (ts.isImportClause(node) && node.name !== undefined) {
644
+ addBinding(sourceFile, node.name, { mutable: false });
645
+ } else if (ts.isNamespaceImport(node) || ts.isImportSpecifier(node)) {
646
+ addBinding(sourceFile, node.name, { mutable: false });
647
+ }
648
+ ts.forEachChild(node, visit);
649
+ };
650
+ visit(sourceFile);
651
+
652
+ const markReassigned = (identifier: ts.Identifier): void => {
653
+ for (const binding of lookupLocalBindings(identifier, bindings) ?? []) {
654
+ binding.reassigned = true;
655
+ }
656
+ };
657
+ const markAssignmentTarget = (node: ts.Node): void => {
658
+ const target = ts.isExpression(node) ? unwrapLocalExpression(node) : node;
659
+ if (ts.isIdentifier(target)) {
660
+ markReassigned(target);
661
+ return;
662
+ }
663
+ if (ts.isObjectLiteralExpression(target)) {
664
+ for (const element of target.properties) {
665
+ if (ts.isSpreadAssignment(element)) {
666
+ markAssignmentTarget(element.expression);
667
+ } else if (ts.isPropertyAssignment(element)) {
668
+ markAssignmentTarget(element.initializer);
669
+ } else if (ts.isShorthandPropertyAssignment(element)) {
670
+ markAssignmentTarget(element.name);
671
+ }
672
+ }
673
+ } else if (ts.isArrayLiteralExpression(target)) {
674
+ for (const element of target.elements) {
675
+ if (!ts.isOmittedExpression(element)) {
676
+ markAssignmentTarget(ts.isSpreadElement(element) ? element.expression : element);
677
+ }
678
+ }
679
+ }
680
+ };
681
+ const visitAssignments = (node: ts.Node): void => {
682
+ if (
683
+ ts.isBinaryExpression(node) &&
684
+ node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
685
+ node.operatorToken.kind <= ts.SyntaxKind.LastAssignment
686
+ ) {
687
+ markAssignmentTarget(node.left);
688
+ } else if (
689
+ (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
690
+ (node.operator === ts.SyntaxKind.PlusPlusToken ||
691
+ node.operator === ts.SyntaxKind.MinusMinusToken)
692
+ ) {
693
+ markAssignmentTarget(node.operand);
694
+ } else if (
695
+ (ts.isForInStatement(node) || ts.isForOfStatement(node)) &&
696
+ !ts.isVariableDeclarationList(node.initializer)
697
+ ) {
698
+ markAssignmentTarget(node.initializer);
699
+ }
700
+ ts.forEachChild(node, visitAssignments);
701
+ };
702
+ visitAssignments(sourceFile);
703
+ return bindings;
704
+ }
705
+
706
+ function lookupLocalBindings(
707
+ reference: ts.Identifier,
708
+ bindings: LocalBindings,
709
+ ): readonly LocalBinding[] | undefined {
710
+ let current: ts.Node | undefined = reference;
711
+ while (current !== undefined) {
712
+ const localBindings = bindings.scopes.get(current)?.get(reference.text);
713
+ if (localBindings !== undefined && localBindings.length > 0) {
714
+ return localBindings;
715
+ }
716
+ current = current.parent;
717
+ }
718
+ return undefined;
719
+ }
720
+
721
+ function propertyNameText(name: ts.PropertyName | ts.BindingName): string | undefined {
722
+ if (
723
+ ts.isIdentifier(name) ||
724
+ ts.isStringLiteral(name) ||
725
+ ts.isNumericLiteral(name) ||
726
+ ts.isNoSubstitutionTemplateLiteral(name)
727
+ ) {
728
+ return name.text;
729
+ }
730
+ if (ts.isComputedPropertyName(name)) {
731
+ const expression = unwrapLocalExpression(name.expression);
732
+ if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) {
733
+ return expression.text;
734
+ }
735
+ }
736
+ return undefined;
737
+ }
738
+
739
+ function isUnshadowedGlobalObject(reference: ts.Expression, bindings: LocalBindings): boolean {
740
+ const receiver = unwrapLocalExpression(reference);
741
+ return (
742
+ ts.isIdentifier(receiver) &&
743
+ DYNAMIC_CODE_GLOBAL_OBJECTS.has(receiver.text) &&
744
+ lookupLocalBindings(receiver, bindings) === undefined
745
+ );
746
+ }
747
+
748
+ function isGlobalMemberSink(
749
+ expression: ts.Expression,
750
+ sinkNames: ReadonlySet<string>,
751
+ bindings: LocalBindings,
752
+ ): boolean {
753
+ const callee = unwrapLocalExpression(expression);
754
+ let sinkName: string | undefined;
755
+ let receiver: ts.Expression | undefined;
756
+ if (ts.isPropertyAccessExpression(callee)) {
757
+ sinkName = callee.name.text;
758
+ receiver = callee.expression;
759
+ } else if (ts.isElementAccessExpression(callee)) {
760
+ const argument = callee.argumentExpression;
761
+ if (
762
+ argument !== undefined &&
763
+ (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
764
+ ) {
765
+ sinkName = argument.text;
766
+ receiver = callee.expression;
767
+ }
768
+ }
769
+ if (sinkName === undefined || receiver === undefined || !sinkNames.has(sinkName)) {
770
+ return false;
771
+ }
772
+ return isUnshadowedGlobalObject(receiver, bindings);
773
+ }
774
+
775
+ function isGlobalSinkReference(
776
+ expression: ts.Expression,
777
+ sinkNames: ReadonlySet<string>,
778
+ bindings: LocalBindings,
779
+ resolving: ReadonlySet<LocalBinding>,
780
+ ): boolean {
781
+ const reference = unwrapLocalExpression(expression);
782
+ if (isGlobalMemberSink(reference, sinkNames, bindings)) {
783
+ return true;
784
+ }
785
+ if (!ts.isIdentifier(reference)) {
786
+ return false;
787
+ }
788
+ return isGlobalSinkIdentifier(reference, sinkNames, bindings, resolving);
789
+ }
790
+
791
+ function isGlobalSinkIdentifier(
792
+ reference: ts.Identifier,
793
+ sinkNames: ReadonlySet<string>,
794
+ bindings: LocalBindings,
795
+ resolving: ReadonlySet<LocalBinding>,
796
+ ): boolean {
797
+ const localBindings = lookupLocalBindings(reference, bindings);
798
+ if (localBindings === undefined || localBindings.length === 0) {
799
+ return sinkNames.has(reference.text);
800
+ }
801
+ return localBindings.every((binding) => {
802
+ if (resolving.has(binding) || binding.initializer === undefined) {
803
+ return false;
804
+ }
805
+ // A mutable alias is safe to resolve only when no assignment targeting
806
+ // this lexical binding exists in its scope (including nested closures).
807
+ // Reassigned let/var aliases remain unresolved to avoid false positives.
808
+ if (binding.mutable && binding.reassigned) {
809
+ return false;
810
+ }
811
+ const nextResolving = new Set(resolving);
812
+ nextResolving.add(binding);
813
+ if (binding.destructuredProperty !== undefined) {
814
+ return (
815
+ sinkNames.has(binding.destructuredProperty) &&
816
+ isUnshadowedGlobalObject(binding.initializer, bindings)
817
+ );
818
+ }
819
+ return isGlobalSinkReference(binding.initializer, sinkNames, bindings, nextResolving);
820
+ });
821
+ }
822
+
823
+ function isGlobalSinkCallee(
824
+ expression: ts.Expression,
825
+ sinkNames: ReadonlySet<string>,
826
+ bindings: LocalBindings,
827
+ ): boolean {
828
+ const callee = unwrapLocalExpression(expression);
829
+ if (isGlobalMemberSink(callee, sinkNames, bindings)) {
830
+ return true;
831
+ }
832
+ if (!ts.isIdentifier(callee)) {
833
+ return false;
834
+ }
835
+ return isGlobalSinkIdentifier(callee, sinkNames, bindings, new Set());
836
+ }
837
+
838
+ function resolveConstStringIdentifier(
839
+ reference: ts.Identifier,
840
+ bindings: LocalBindings,
841
+ resolving: ReadonlySet<LocalBinding> = new Set(),
842
+ ): string | undefined {
843
+ const localBindings = lookupLocalBindings(reference, bindings);
844
+ if (localBindings === undefined || localBindings.length === 0) {
845
+ return undefined;
846
+ }
847
+ let resolved: string | undefined;
848
+ for (const binding of localBindings) {
849
+ if (
850
+ resolving.has(binding) ||
851
+ binding.mutable ||
852
+ binding.initializer === undefined ||
853
+ binding.destructuredProperty !== undefined
854
+ ) {
855
+ return undefined;
856
+ }
857
+ const nextResolving = new Set(resolving);
858
+ nextResolving.add(binding);
859
+ const initializer = unwrapLocalExpression(binding.initializer);
860
+ let value: string | undefined;
861
+ if (ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer)) {
862
+ value = initializer.text;
863
+ } else if (ts.isIdentifier(initializer)) {
864
+ value = resolveConstStringIdentifier(initializer, bindings, nextResolving);
865
+ }
866
+ if (value === undefined || (resolved !== undefined && resolved !== value)) {
867
+ return undefined;
868
+ }
869
+ resolved = value;
870
+ }
871
+ return resolved;
872
+ }
873
+
874
+ function findGlobalSinkCalls(
875
+ providerRoot: string,
876
+ sinkNames: ReadonlySet<string>,
877
+ includeConstructors = false,
878
+ ): SourceFinding[] {
879
+ const findings: SourceFinding[] = [];
880
+ const seen = new Set<string>();
881
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
882
+ const source = readFileSync(filePath, "utf8");
883
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
884
+ const sourceFile = ts.createSourceFile(
885
+ relPath,
886
+ source,
887
+ ts.ScriptTarget.Latest,
888
+ true,
889
+ ts.ScriptKind.TS,
890
+ );
891
+ const bindings = collectLocalBindings(sourceFile);
892
+ const visit = (node: ts.Node): void => {
893
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
894
+ return;
895
+ }
896
+ if (
897
+ (ts.isCallExpression(node) || (includeConstructors && ts.isNewExpression(node))) &&
898
+ isGlobalSinkCallee(node.expression, sinkNames, bindings)
899
+ ) {
900
+ const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
901
+ const key = `${relPath}:${line}`;
902
+ if (!seen.has(key)) {
903
+ seen.add(key);
904
+ findings.push({ file: relPath, line });
905
+ }
906
+ }
907
+ ts.forEachChild(node, visit);
908
+ };
909
+ visit(sourceFile);
910
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
911
+ break;
912
+ }
913
+ }
914
+ return findings;
915
+ }
916
+
917
+ function scoreNoDynamicCode(providerRoot: string): SubmitCheck {
918
+ const findings = findGlobalSinkCalls(providerRoot, DYNAMIC_CODE_SINKS, true);
919
+ if (findings.length > 0) {
920
+ return blocker(
921
+ "no-dynamic-code",
922
+ SDK_NATIVE_CATEGORY,
923
+ "Dynamic code evaluation is not permitted in provider source.",
924
+ "Remove eval() and Function constructor calls. Provider HTTP access must go through ctx.http.",
925
+ 0,
926
+ formatSourceFindings(findings),
927
+ );
928
+ }
929
+
930
+ return pass(
931
+ "no-dynamic-code",
932
+ SDK_NATIVE_CATEGORY,
933
+ "Provider source avoids dynamic code evaluation.",
934
+ 0,
935
+ );
936
+ }
937
+
451
938
  const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
452
939
  /\bctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|choice)\?\./,
453
940
  ];
@@ -503,7 +990,11 @@ function countAsAssertions(providerRoot: string): {
503
990
 
504
991
  for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
505
992
  const content = readFileSync(filePath, "utf8");
506
- const lines = content.split(/\r?\n/);
993
+ const lines = maskCommentsAndStrings(
994
+ content,
995
+ toRelativeProviderPath(providerRoot, filePath),
996
+ { blankPropertyKeys: true },
997
+ ).split(/\r?\n/);
507
998
  for (let index = 0; index < lines.length; index += 1) {
508
999
  const line = lines[index];
509
1000
  if (
@@ -694,18 +1185,44 @@ function offsetToLine(source: string, offset: number): number {
694
1185
  // between them, so `.passthrough ()` / `.passthrough\n()` are still detected.
695
1186
  const PASSTHROUGH_CALL = /\.passthrough\s*\(\s*\)/;
696
1187
 
1188
+ type ExpressionSlice = {
1189
+ raw: string;
1190
+ masked: string;
1191
+ };
1192
+
1193
+ function trimExpressionSlice(expr: ExpressionSlice): ExpressionSlice {
1194
+ const start = expr.raw.length - expr.raw.trimStart().length;
1195
+ const end = expr.raw.trimEnd().length;
1196
+ return {
1197
+ raw: expr.raw.slice(start, end),
1198
+ masked: expr.masked.slice(start, end),
1199
+ };
1200
+ }
1201
+
1202
+ function balancedExpressionSlice(
1203
+ source: string,
1204
+ valueStart: number,
1205
+ fileName?: string,
1206
+ ): ExpressionSlice {
1207
+ const raw = balancedValueExpression(source, valueStart, fileName);
1208
+ return trimExpressionSlice({
1209
+ raw,
1210
+ masked: maskCommentsAndStrings(source, fileName).slice(valueStart, valueStart + raw.length),
1211
+ });
1212
+ }
1213
+
697
1214
  // Strips redundant wrapping parentheses from an expression so that a value like
698
1215
  // `(makeOperations())` or `((x))` classifies the same as `makeOperations()`.
699
1216
  // Only unwraps when the leading `(` matches the trailing `)` at depth 0 (i.e.
700
1217
  // the whole expression is parenthesized), preserving call expressions such as
701
1218
  // `makeOperations()` whose first `(` is not a wrapper.
702
- function unwrapParens(expr: string): string {
703
- let value = expr.trim();
704
- while (value.startsWith("(")) {
1219
+ function unwrapParens(expr: ExpressionSlice): ExpressionSlice {
1220
+ let value = trimExpressionSlice(expr);
1221
+ while (value.raw.startsWith("(")) {
705
1222
  let depth = 0;
706
1223
  let matchIndex = -1;
707
- for (let i = 0; i < value.length; i += 1) {
708
- const ch = value[i];
1224
+ for (let i = 0; i < value.raw.length; i += 1) {
1225
+ const ch = value.masked[i];
709
1226
  if (ch === "(") {
710
1227
  depth += 1;
711
1228
  } else if (ch === ")") {
@@ -718,8 +1235,11 @@ function unwrapParens(expr: string): string {
718
1235
  }
719
1236
  // Only a true wrapper spans the entire expression (closing paren is the
720
1237
  // last char). Otherwise the leading `(` belongs to a sub-expression.
721
- if (matchIndex === value.length - 1) {
722
- value = value.slice(1, -1).trim();
1238
+ if (matchIndex === value.raw.length - 1) {
1239
+ value = trimExpressionSlice({
1240
+ raw: value.raw.slice(1, -1),
1241
+ masked: value.masked.slice(1, -1),
1242
+ });
723
1243
  } else {
724
1244
  break;
725
1245
  }
@@ -731,8 +1251,8 @@ function unwrapParens(expr: string): string {
731
1251
  // across (){}[] and stopping at the first top-level `,`/`;` or unmatched
732
1252
  // closing bracket. This lets a property value be read across newlines, so a
733
1253
  // multi-line `input: z.object({...})\n.passthrough()` is captured whole.
734
- function balancedValueExpression(source: string, valueStart: number): string {
735
- const masked = maskCommentsAndStrings(source);
1254
+ function balancedValueExpression(source: string, valueStart: number, fileName?: string): string {
1255
+ const masked = maskCommentsAndStrings(source, fileName);
736
1256
  let depth = 0;
737
1257
  let index = valueStart;
738
1258
  for (; index < source.length; index += 1) {
@@ -756,14 +1276,14 @@ function balancedValueExpression(source: string, valueStart: number): string {
756
1276
  // nested deeper than the outer object (inside handler bodies, nested objects,
757
1277
  // or arrays) are ignored, so only a factory composition of the object itself
758
1278
  // is detected. Input is expected to start at the outer `{`.
759
- function hasTopLevelFactorySpread(expr: string): boolean {
760
- const open = expr.indexOf("{");
1279
+ function hasTopLevelFactorySpread(expr: ExpressionSlice): boolean {
1280
+ const open = expr.masked.indexOf("{");
761
1281
  if (open === -1) {
762
1282
  return false;
763
1283
  }
764
1284
  let depth = 0;
765
- for (let i = open; i < expr.length; i += 1) {
766
- const ch = expr[i];
1285
+ for (let i = open; i < expr.raw.length; i += 1) {
1286
+ const ch = expr.masked[i];
767
1287
  if (ch === "{" || ch === "(" || ch === "[") {
768
1288
  depth += 1;
769
1289
  } else if (ch === "}" || ch === ")" || ch === "]") {
@@ -771,11 +1291,11 @@ function hasTopLevelFactorySpread(expr: string): boolean {
771
1291
  if (depth === 0) {
772
1292
  break;
773
1293
  }
774
- } else if (ch === "." && depth === 1 && expr.startsWith("...", i)) {
1294
+ } else if (ch === "." && depth === 1 && expr.masked.startsWith("...", i)) {
775
1295
  // A spread at the object's own level. Check whether the spread
776
1296
  // argument is a call expression (factory) rather than a plain
777
1297
  // identifier/member spread of an already-built object.
778
- const rest = expr.slice(i + 3);
1298
+ const rest = expr.raw.slice(i + 3);
779
1299
  if (/^\s*[A-Za-z_$][\w$.]*\s*\(/.test(rest)) {
780
1300
  return true;
781
1301
  }
@@ -790,15 +1310,15 @@ function hasTopLevelFactorySpread(expr: string): boolean {
790
1310
  // hasTopLevelFactorySpread, so it is excluded here. These identifiers must be
791
1311
  // resolved to their declarations: `const hidden = makeOperations()` spread as
792
1312
  // `{ ...hidden }` is still a factory-composed map and must block.
793
- function topLevelSpreadIdentifiers(expr: string): string[] {
794
- const open = expr.indexOf("{");
1313
+ function topLevelSpreadIdentifiers(expr: ExpressionSlice): string[] {
1314
+ const open = expr.masked.indexOf("{");
795
1315
  if (open === -1) {
796
1316
  return [];
797
1317
  }
798
1318
  const names: string[] = [];
799
1319
  let depth = 0;
800
- for (let i = open; i < expr.length; i += 1) {
801
- const ch = expr[i];
1320
+ for (let i = open; i < expr.raw.length; i += 1) {
1321
+ const ch = expr.masked[i];
802
1322
  if (ch === "{" || ch === "(" || ch === "[") {
803
1323
  depth += 1;
804
1324
  } else if (ch === "}" || ch === ")" || ch === "]") {
@@ -806,8 +1326,8 @@ function topLevelSpreadIdentifiers(expr: string): string[] {
806
1326
  if (depth === 0) {
807
1327
  break;
808
1328
  }
809
- } else if (ch === "." && depth === 1 && expr.startsWith("...", i)) {
810
- const rest = expr.slice(i + 3);
1329
+ } else if (ch === "." && depth === 1 && expr.masked.startsWith("...", i)) {
1330
+ const rest = expr.raw.slice(i + 3);
811
1331
  // Bare identifier spread (no call parens) -> needs declaration
812
1332
  // resolution. `...obj.prop` member spreads are treated as already
813
1333
  // built and ignored (the leading identifier is captured).
@@ -840,8 +1360,8 @@ function topLevelSpreadIdentifiers(expr: string): string[] {
840
1360
  // `makeOperations()` — stays classified as factory composition.
841
1361
  const TRANSPARENT_RESHAPE_HEAD = /^Object\s*\.\s*fromEntries\s*\(/;
842
1362
  const OBJECT_ENTRIES_HEAD = /^Object\s*\.\s*entries\s*\(/;
843
- function isTransparentObjectReshape(expr: string): boolean {
844
- const head = TRANSPARENT_RESHAPE_HEAD.exec(expr);
1363
+ function isTransparentObjectReshape(expr: ExpressionSlice): boolean {
1364
+ const head = TRANSPARENT_RESHAPE_HEAD.exec(expr.masked);
845
1365
  if (!head) {
846
1366
  return false;
847
1367
  }
@@ -849,7 +1369,7 @@ function isTransparentObjectReshape(expr: string): boolean {
849
1369
  // transparent only when that argument's root callee is `Object.entries(`
850
1370
  // (optionally chained: `Object.entries(obj).filter(...)`), so the source
851
1371
  // object is enumerable from source rather than produced by an opaque call.
852
- const firstArg = expr.slice(head[0].length).trimStart();
1372
+ const firstArg = expr.masked.slice(head[0].length).trimStart();
853
1373
  return OBJECT_ENTRIES_HEAD.test(firstArg);
854
1374
  }
855
1375
 
@@ -983,7 +1503,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
983
1503
  continue;
984
1504
  }
985
1505
  const valueStart = match.index + match[0].length;
986
- const value = balancedValueExpression(source, valueStart);
1506
+ const value = balancedValueExpression(source, valueStart, relPath);
987
1507
  if (PASSTHROUGH_CALL.test(value)) {
988
1508
  const site: ConstSite = {
989
1509
  file: relPath,
@@ -1012,8 +1532,8 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
1012
1532
  // that is itself a passthrough expression, or that references a passthrough
1013
1533
  // const by name (resolved against the provider-wide map), is a violation.
1014
1534
  for (const filePath of files) {
1015
- const source = fileSources.get(filePath) ?? readFileSync(filePath, "utf8");
1016
1535
  const relPath = toRelativeProviderPath(providerRoot, filePath);
1536
+ const source = fileSources.get(filePath) ?? readFileSync(filePath, "utf8");
1017
1537
 
1018
1538
  const inputProp = /\binput\s*:\s*/g;
1019
1539
  for (let match = inputProp.exec(source); match !== null; match = inputProp.exec(source)) {
@@ -1024,7 +1544,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
1024
1544
  continue;
1025
1545
  }
1026
1546
  const valueStart = match.index + match[0].length;
1027
- const value = balancedValueExpression(source, valueStart);
1547
+ const value = balancedValueExpression(source, valueStart, relPath);
1028
1548
  if (PASSTHROUGH_CALL.test(value)) {
1029
1549
  push({ file: relPath, line: offsetToLine(source, valueStart) });
1030
1550
  continue;
@@ -1135,13 +1655,19 @@ function spreadIdentifierResolvesToFactory(
1135
1655
  if (!existsSync(filePath)) {
1136
1656
  continue;
1137
1657
  }
1658
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
1138
1659
  const fileSource = filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
1660
+ const maskedFileSource = maskCommentsAndStrings(fileSource, relPath, {
1661
+ blankPropertyKeys: true,
1662
+ });
1139
1663
  const re = new RegExp(declRe.source, "g");
1140
- for (let m = re.exec(fileSource); m !== null; m = re.exec(fileSource)) {
1664
+ for (let m = re.exec(maskedFileSource); m !== null; m = re.exec(maskedFileSource)) {
1141
1665
  sawDeclaration = true;
1142
- const expr = unwrapParens(balancedValueExpression(fileSource, m.index + m[0].length).trim());
1666
+ const expr = unwrapParens(
1667
+ balancedExpressionSlice(fileSource, m.index + m[0].length, relPath),
1668
+ );
1143
1669
  const isFactory =
1144
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
1670
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr.masked) || hasTopLevelFactorySpread(expr)) &&
1145
1671
  !isTransparentObjectReshape(expr);
1146
1672
  if (isFactory) {
1147
1673
  return true;
@@ -1150,109 +1676,351 @@ function spreadIdentifierResolvesToFactory(
1150
1676
  }
1151
1677
  // No local declaration anywhere but imported into index.ts => constructed
1152
1678
  // out of view; treat as factory (conservative, false-negative-safe).
1153
- if (!sawDeclaration && fileImportsBinding(indexSource, name)) {
1679
+ if (
1680
+ !sawDeclaration &&
1681
+ fileImportsBinding(
1682
+ maskCommentsAndStrings(indexSource, "index.ts", { blankPropertyKeys: true }),
1683
+ name,
1684
+ )
1685
+ ) {
1154
1686
  return true;
1155
1687
  }
1156
1688
  return false;
1157
1689
  }
1158
1690
 
1159
- function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1160
- const indexPath = resolve(providerRoot, "index.ts");
1161
- const ruleId = "flat-operation-composition";
1162
- if (!existsSync(indexPath)) {
1163
- return pass(
1164
- ruleId,
1165
- SDK_NATIVE_CATEGORY,
1166
- "Provider index.ts not found; flat-operation check skipped.",
1167
- 0,
1168
- );
1169
- }
1691
+ function isDefineProviderCall(node: ts.CallExpression): boolean {
1692
+ return ts.isIdentifier(node.expression) && node.expression.text === "defineProvider";
1693
+ }
1170
1694
 
1171
- const source = readFileSync(indexPath, "utf8");
1172
- // Use the same whitespace-tolerant detection as the resolver below, so a
1173
- // `defineProvider (` / `defineProvider\n(` formatting cannot pass the early
1174
- // exit before the real classification runs.
1175
- if (!/\bdefineProvider\s*\(/.test(source)) {
1176
- return pass(
1177
- ruleId,
1178
- SDK_NATIVE_CATEGORY,
1179
- "No defineProvider call to evaluate for operation composition.",
1180
- 0,
1181
- );
1695
+ function resolveDefineProviderImplementationCall(
1696
+ candidate: ts.CallExpression,
1697
+ ): ts.CallExpression | undefined {
1698
+ let root = candidate;
1699
+ while (ts.isCallExpression(root.expression)) {
1700
+ root = root.expression;
1182
1701
  }
1702
+ return isDefineProviderCall(root) ? candidate : undefined;
1703
+ }
1183
1704
 
1184
- // Scope the scan to the argument of the EXPORTED `defineProvider(...)` call.
1185
- // A provider can contain helper/non-exported defineProvider calls before the
1186
- // real default export (e.g. test scaffolds), so resolve the default export
1187
- // rather than blindly taking the first regex match. Resolution order:
1188
- // 1. `export default defineProvider(` — inline default export
1189
- // 2. `export default <ident>` then `const <ident> = defineProvider(`
1190
- // 3. fallback: first `defineProvider(` in the file
1191
- let defineParenIndex = -1;
1192
- const inlineDefault = /\bexport\s+default\s+defineProvider\s*\(/.exec(source);
1193
- if (inlineDefault) {
1194
- defineParenIndex = inlineDefault.index + inlineDefault[0].length - 1; // points at `(`
1195
- } else {
1196
- const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(source);
1197
- const exportedName = namedDefault?.[1];
1198
- if (exportedName !== undefined) {
1199
- const namedDecl = new RegExp(
1200
- `(?:^|\\n)[ \t]*(?:export\\s+)?(?:const|let|var)\\s+${exportedName}\\s*(?::[^=\\n]+)?\\s*=\\s*defineProvider\\s*\\(`,
1201
- ).exec(source);
1202
- if (namedDecl) {
1203
- defineParenIndex = namedDecl.index + namedDecl[0].length - 1;
1204
- }
1705
+ function findProviderImplementationCall(sourceFile: ts.SourceFile): ts.CallExpression | undefined {
1706
+ const defaultExports = sourceFile.statements.filter(
1707
+ (statement): statement is ts.ExportAssignment =>
1708
+ ts.isExportAssignment(statement) && !statement.isExportEquals,
1709
+ );
1710
+
1711
+ // Phase-separated providers export a builder invocation directly. Preserve
1712
+ // the previous locator's preference for that call over every defineProvider
1713
+ // declaration that may also appear in the file.
1714
+ for (const assignment of defaultExports) {
1715
+ const expression = unwrapImplementationExpression(assignment.expression);
1716
+ if (!ts.isCallExpression(expression)) {
1717
+ continue;
1205
1718
  }
1206
- if (defineParenIndex === -1) {
1207
- const firstCall = /\bdefineProvider\s*\(/.exec(source);
1208
- if (firstCall) {
1209
- defineParenIndex = firstCall.index + firstCall[0].length - 1;
1210
- }
1719
+ if (resolveDefineProviderImplementationCall(expression) !== undefined) {
1720
+ continue;
1721
+ }
1722
+ if (ts.isIdentifier(expression.expression)) {
1723
+ return expression;
1211
1724
  }
1212
1725
  }
1213
- if (defineParenIndex === -1) {
1214
- return pass(
1215
- ruleId,
1216
- SDK_NATIVE_CATEGORY,
1217
- "No defineProvider call to evaluate for operation composition.",
1218
- 0,
1219
- );
1726
+
1727
+ // For `defineProvider(metadata)(implementation)`, select the outer call whose
1728
+ // argument is the implementation. A plain `defineProvider(implementation)`
1729
+ // selects the single call itself.
1730
+ for (const assignment of defaultExports) {
1731
+ const defaultExpression = unwrapImplementationExpression(assignment.expression);
1732
+ if (!ts.isCallExpression(defaultExpression)) {
1733
+ continue;
1734
+ }
1735
+ const implementationCall = resolveDefineProviderImplementationCall(defaultExpression);
1736
+ if (implementationCall !== undefined) {
1737
+ return implementationCall;
1738
+ }
1220
1739
  }
1221
- const argStart = defineParenIndex + 1;
1222
- const argText = balancedValueExpression(source, argStart);
1223
1740
 
1224
- // Resolve the value passed as `operations:` inside the defineProvider call,
1225
- // following one alias hop. The value is classified as a static object
1226
- // literal (pass) or a factory/call expression (block). The regex index is
1227
- // offset back into the full source so line numbers stay accurate.
1228
- const opsProp = /\boperations\s*:\s*/.exec(argText);
1229
- let opsValue: string | undefined;
1230
- let opsLine = 1;
1231
- if (opsProp) {
1232
- const valueStart = argStart + opsProp.index + opsProp[0].length;
1233
- opsValue = unwrapParens(balancedValueExpression(source, valueStart).trim());
1234
- opsLine = offsetToLine(source, valueStart);
1741
+ // Resolve `export default provider` to a top-level variable initialized by
1742
+ // defineProvider, including its curried implementation call when present.
1743
+ for (const assignment of defaultExports) {
1744
+ if (!ts.isIdentifier(assignment.expression)) {
1745
+ continue;
1746
+ }
1747
+ const exportedName = assignment.expression.text;
1748
+ for (const statement of sourceFile.statements) {
1749
+ if (!ts.isVariableStatement(statement)) {
1750
+ continue;
1751
+ }
1752
+ for (const declaration of statement.declarationList.declarations) {
1753
+ if (
1754
+ !ts.isIdentifier(declaration.name) ||
1755
+ declaration.name.text !== exportedName ||
1756
+ declaration.initializer === undefined
1757
+ ) {
1758
+ continue;
1759
+ }
1760
+ if (!ts.isCallExpression(declaration.initializer)) {
1761
+ continue;
1762
+ }
1763
+ const implementationCall = resolveDefineProviderImplementationCall(declaration.initializer);
1764
+ if (implementationCall !== undefined) {
1765
+ return implementationCall;
1766
+ }
1767
+ }
1768
+ }
1235
1769
  }
1236
1770
 
1237
- // Property shorthand: `defineProvider({ ..., operations })` resolve the
1238
- // local `operations` const initializer.
1239
- let aliasName: string | undefined;
1240
- if (opsValue === undefined) {
1241
- if (/\boperations\s*[,}]/.test(argText)) {
1242
- aliasName = "operations";
1771
+ // Structural fixtures without a recognizable default export retain the
1772
+ // previous fallback to the first defineProvider call in source order.
1773
+ let firstCall: ts.CallExpression | undefined;
1774
+ const visit = (node: ts.Node): void => {
1775
+ if (firstCall !== undefined) {
1776
+ return;
1243
1777
  }
1244
- } else if (/^[A-Za-z_$][\w$]*$/.test(opsValue)) {
1245
- // `operations: ops` — a bare identifier alias to resolve.
1246
- aliasName = opsValue;
1778
+ if (ts.isCallExpression(node)) {
1779
+ const implementationCall = resolveDefineProviderImplementationCall(node);
1780
+ if (implementationCall !== undefined) {
1781
+ firstCall = implementationCall;
1782
+ return;
1783
+ }
1784
+ }
1785
+ ts.forEachChild(node, visit);
1786
+ };
1787
+ visit(sourceFile);
1788
+ return firstCall;
1789
+ }
1790
+
1791
+ function unwrapImplementationExpression(expression: ts.Expression): ts.Expression {
1792
+ let current = expression;
1793
+ while (
1794
+ ts.isSatisfiesExpression(current) ||
1795
+ ts.isAsExpression(current) ||
1796
+ ts.isParenthesizedExpression(current) ||
1797
+ ts.isNonNullExpression(current)
1798
+ ) {
1799
+ current = current.expression;
1247
1800
  }
1801
+ return current;
1802
+ }
1248
1803
 
1249
- // Determine the effective initializer expression to classify. The alias may
1250
- // be declared in index.ts OR re-exported from a sibling module (the common
1251
- // generated scaffold: `import { operations } from "./operations"` where
1252
- // ./operations.ts builds the map with makeOperations()/Object.fromEntries).
1253
- // Resolve across every provider source file so cross-module factory
1254
- // composition cannot evade the blocker.
1255
- let effective = opsValue;
1804
+ function isOperationsProperty(
1805
+ property: ts.ObjectLiteralElementLike,
1806
+ ): property is ts.PropertyAssignment | ts.ShorthandPropertyAssignment {
1807
+ if (ts.isShorthandPropertyAssignment(property)) {
1808
+ return property.name.text === "operations";
1809
+ }
1810
+ if (!ts.isPropertyAssignment(property)) {
1811
+ return false;
1812
+ }
1813
+ return (
1814
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
1815
+ property.name.text === "operations"
1816
+ );
1817
+ }
1818
+
1819
+ function isOperationsAccessorOrMethod(
1820
+ property: ts.ObjectLiteralElementLike,
1821
+ ): property is ts.GetAccessorDeclaration | ts.SetAccessorDeclaration | ts.MethodDeclaration {
1822
+ if (
1823
+ !ts.isGetAccessorDeclaration(property) &&
1824
+ !ts.isSetAccessorDeclaration(property) &&
1825
+ !ts.isMethodDeclaration(property)
1826
+ ) {
1827
+ return false;
1828
+ }
1829
+ return (
1830
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
1831
+ property.name.text === "operations"
1832
+ );
1833
+ }
1834
+
1835
+ function isSourceEnumerableStaticObjectLiteral(expression: ts.Expression): boolean {
1836
+ const unwrapped = unwrapImplementationExpression(expression);
1837
+ if (!ts.isObjectLiteralExpression(unwrapped)) {
1838
+ return false;
1839
+ }
1840
+ return unwrapped.properties.every((property) => {
1841
+ if (ts.isSpreadAssignment(property)) {
1842
+ return isSourceEnumerableStaticObjectLiteral(property.expression);
1843
+ }
1844
+ if (property.name === undefined || ts.isComputedPropertyName(property.name)) {
1845
+ return property.name === undefined;
1846
+ }
1847
+ return property.name.text !== "operations" || isOperationsProperty(property);
1848
+ });
1849
+ }
1850
+
1851
+ function findComputedImplementationProperty(
1852
+ objectLiteral: ts.ObjectLiteralExpression,
1853
+ ): ts.ObjectLiteralElementLike | undefined {
1854
+ for (const property of objectLiteral.properties) {
1855
+ if (property.name !== undefined && ts.isComputedPropertyName(property.name)) {
1856
+ return property;
1857
+ }
1858
+ if (ts.isSpreadAssignment(property)) {
1859
+ const spreadExpression = unwrapImplementationExpression(property.expression);
1860
+ if (ts.isObjectLiteralExpression(spreadExpression)) {
1861
+ const nested = findComputedImplementationProperty(spreadExpression);
1862
+ if (nested !== undefined) {
1863
+ return nested;
1864
+ }
1865
+ }
1866
+ }
1867
+ }
1868
+ return undefined;
1869
+ }
1870
+
1871
+ function findEffectiveOperationsProperty(
1872
+ objectLiteral: ts.ObjectLiteralExpression,
1873
+ ):
1874
+ | ts.PropertyAssignment
1875
+ | ts.ShorthandPropertyAssignment
1876
+ | ts.GetAccessorDeclaration
1877
+ | ts.SetAccessorDeclaration
1878
+ | ts.MethodDeclaration
1879
+ | undefined {
1880
+ let effective:
1881
+ | ts.PropertyAssignment
1882
+ | ts.ShorthandPropertyAssignment
1883
+ | ts.GetAccessorDeclaration
1884
+ | ts.SetAccessorDeclaration
1885
+ | ts.MethodDeclaration
1886
+ | undefined;
1887
+ for (const property of objectLiteral.properties) {
1888
+ if (isOperationsProperty(property) || isOperationsAccessorOrMethod(property)) {
1889
+ effective = property;
1890
+ continue;
1891
+ }
1892
+ if (ts.isSpreadAssignment(property)) {
1893
+ const spreadExpression = unwrapImplementationExpression(property.expression);
1894
+ if (ts.isObjectLiteralExpression(spreadExpression)) {
1895
+ const nested = findEffectiveOperationsProperty(spreadExpression);
1896
+ if (nested !== undefined) {
1897
+ effective = nested;
1898
+ }
1899
+ }
1900
+ }
1901
+ }
1902
+ return effective;
1903
+ }
1904
+
1905
+ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1906
+ const indexPath = resolve(providerRoot, "index.ts");
1907
+ const ruleId = "flat-operation-composition";
1908
+ if (!existsSync(indexPath)) {
1909
+ return pass(
1910
+ ruleId,
1911
+ SDK_NATIVE_CATEGORY,
1912
+ "Provider index.ts not found; flat-operation check skipped.",
1913
+ 0,
1914
+ );
1915
+ }
1916
+
1917
+ const source = readFileSync(indexPath, "utf8");
1918
+ const indexRelPath = toRelativeProviderPath(providerRoot, indexPath);
1919
+ // The report preflight already rejects unparseable index.ts sources before
1920
+ // this rule runs, so this local AST parse only replaces property location.
1921
+ const sourceFile = ts.createSourceFile(
1922
+ indexRelPath,
1923
+ source,
1924
+ ts.ScriptTarget.Latest,
1925
+ true,
1926
+ ts.ScriptKind.TS,
1927
+ );
1928
+ const implementationCall = findProviderImplementationCall(sourceFile);
1929
+ const rawImplementationArg = implementationCall?.arguments[0];
1930
+ const implementationArg =
1931
+ rawImplementationArg === undefined
1932
+ ? undefined
1933
+ : unwrapImplementationExpression(rawImplementationArg);
1934
+ if (implementationArg === undefined || !ts.isObjectLiteralExpression(implementationArg)) {
1935
+ return pass(
1936
+ ruleId,
1937
+ SDK_NATIVE_CATEGORY,
1938
+ "No defineProvider call to evaluate for operation composition.",
1939
+ 0,
1940
+ );
1941
+ }
1942
+
1943
+ // Resolve the top-level operations property from the AST, then feed its raw
1944
+ // initializer offset into the existing expression classifier and alias
1945
+ // resolver unchanged.
1946
+ const opsProp = findEffectiveOperationsProperty(implementationArg);
1947
+ let opsValue: ExpressionSlice | undefined;
1948
+ let opsLine = 1;
1949
+ if (opsProp !== undefined && ts.isPropertyAssignment(opsProp)) {
1950
+ const valueStart = opsProp.initializer.getStart(sourceFile);
1951
+ opsValue = unwrapParens(balancedExpressionSlice(source, valueStart, indexRelPath));
1952
+ opsLine = offsetToLine(source, valueStart);
1953
+ }
1954
+
1955
+ // Property shorthand: `buildProvider({ operations })` — resolve the
1956
+ // local `operations` const initializer.
1957
+ let aliasName: string | undefined;
1958
+ if (opsProp !== undefined && ts.isShorthandPropertyAssignment(opsProp)) {
1959
+ aliasName = "operations";
1960
+ } else if (opsValue !== undefined && /^[A-Za-z_$][\w$]*$/.test(opsValue.masked)) {
1961
+ // `operations: ops` — a bare identifier alias to resolve.
1962
+ aliasName = opsValue.raw;
1963
+ }
1964
+
1965
+ // A computed property might resolve to `operations`, but its composition is
1966
+ // not source-enumerable here. Fail closed, like an unresolved imported alias.
1967
+ const computed = findComputedImplementationProperty(implementationArg);
1968
+ if (computed !== undefined) {
1969
+ const computedLine = offsetToLine(source, computed.getStart(sourceFile));
1970
+ return blocker(
1971
+ ruleId,
1972
+ SDK_NATIVE_CATEGORY,
1973
+ "The defineProvider operations property uses a computed property name that cannot be statically resolved.",
1974
+ "Declare operations with the literal property name `operations` and a static object literal value so the provider-registry AST gate can enumerate it.",
1975
+ 0,
1976
+ [`${indexRelPath}:${computedLine} (computed property name)`],
1977
+ );
1978
+ }
1979
+
1980
+ if (opsProp !== undefined && isOperationsAccessorOrMethod(opsProp)) {
1981
+ const memberLine = offsetToLine(source, opsProp.getStart(sourceFile));
1982
+ return blocker(
1983
+ ruleId,
1984
+ SDK_NATIVE_CATEGORY,
1985
+ "The defineProvider operations member is an accessor/method whose value is not statically enumerable.",
1986
+ "Declare operations as a property assignment with a static object literal value so the provider-registry AST gate can enumerate it.",
1987
+ 0,
1988
+ [`${indexRelPath}:${memberLine} (operations accessor/method)`],
1989
+ );
1990
+ }
1991
+
1992
+ // A spread can override a preceding literal `operations` property. Only an
1993
+ // inline static object literal (including recursively inline static spreads)
1994
+ // exposes all of its property names to this source-only gate. Identifiers,
1995
+ // calls, and other expressions are opaque and therefore fail closed.
1996
+ const opaqueSpread = implementationArg.properties.find(
1997
+ (property): property is ts.SpreadAssignment =>
1998
+ ts.isSpreadAssignment(property) &&
1999
+ !isSourceEnumerableStaticObjectLiteral(property.expression),
2000
+ );
2001
+ if (opaqueSpread !== undefined) {
2002
+ const spreadLine = offsetToLine(source, opaqueSpread.getStart(sourceFile));
2003
+ return escapeHatchResult(
2004
+ providerRoot,
2005
+ ruleId,
2006
+ [{ file: indexRelPath, line: spreadLine }],
2007
+ {
2008
+ blockerMessage:
2009
+ "The defineProvider implementation uses a non-enumerable spread that could override operations.",
2010
+ remediation:
2011
+ "Declare operations directly with a static object literal. Implementation spreads must be inline static object literals so the provider-registry AST gate can enumerate every property name.",
2012
+ passMessage: "defineProvider declares operations as a static object literal.",
2013
+ },
2014
+ );
2015
+ }
2016
+
2017
+ // Determine the effective initializer expression to classify. The alias may
2018
+ // be declared in index.ts OR re-exported from a sibling module (the common
2019
+ // generated scaffold: `import { operations } from "./operations"` where
2020
+ // ./operations.ts builds the map with makeOperations()/Object.fromEntries).
2021
+ // Resolve across every provider source file so cross-module factory
2022
+ // composition cannot evade the blocker.
2023
+ let effective = opsValue;
1256
2024
  let effectiveLine = opsLine;
1257
2025
  let effectiveFile = "index.ts";
1258
2026
  if (aliasName !== undefined) {
@@ -1277,7 +2045,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1277
2045
  // do not resolve the exact import target path; "any same-named factory
1278
2046
  // blocks" is the conservative, false-negative-avoiding choice for a gate.)
1279
2047
  type Candidate = {
1280
- expr: string;
2048
+ expr: ExpressionSlice;
1281
2049
  line: number;
1282
2050
  file: string;
1283
2051
  isFactory: boolean;
@@ -1287,15 +2055,22 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1287
2055
  if (!existsSync(filePath)) {
1288
2056
  continue;
1289
2057
  }
1290
- const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
1291
2058
  const relPath = toRelativeProviderPath(providerRoot, filePath);
2059
+ const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
2060
+ const maskedFileSource = maskCommentsAndStrings(fileSource, relPath, {
2061
+ blankPropertyKeys: true,
2062
+ });
1292
2063
 
1293
2064
  const declRe = new RegExp(aliasDecl.source, "g");
1294
- for (let m = declRe.exec(fileSource); m !== null; m = declRe.exec(fileSource)) {
2065
+ for (
2066
+ let m = declRe.exec(maskedFileSource);
2067
+ m !== null;
2068
+ m = declRe.exec(maskedFileSource)
2069
+ ) {
1295
2070
  const valueStart = m.index + m[0].length;
1296
- const expr = unwrapParens(balancedValueExpression(fileSource, valueStart).trim());
2071
+ const expr = unwrapParens(balancedExpressionSlice(fileSource, valueStart, relPath));
1297
2072
  const isFactory =
1298
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
2073
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr.masked) || hasTopLevelFactorySpread(expr)) &&
1299
2074
  !isTransparentObjectReshape(expr);
1300
2075
  candidates.push({
1301
2076
  expr,
@@ -1305,9 +2080,14 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1305
2080
  });
1306
2081
  }
1307
2082
  const destructRe = new RegExp(destructured.source, "g");
1308
- for (let m = destructRe.exec(fileSource); m !== null; m = destructRe.exec(fileSource)) {
2083
+ for (
2084
+ let m = destructRe.exec(maskedFileSource);
2085
+ m !== null;
2086
+ m = destructRe.exec(maskedFileSource)
2087
+ ) {
2088
+ const raw = `${m[1]}(`;
1309
2089
  candidates.push({
1310
- expr: `${m[1]}(`,
2090
+ expr: { raw, masked: raw },
1311
2091
  line: offsetToLine(fileSource, m.index),
1312
2092
  file: relPath,
1313
2093
  isFactory: true,
@@ -1333,11 +2113,15 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1333
2113
  // the unresolved import as a factory-composed (non-static) shape rather
1334
2114
  // than silently passing.
1335
2115
  if (!resolved) {
1336
- const importMatch = new RegExp(`\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`).exec(
1337
- source,
1338
- );
2116
+ const maskedIndexSource = maskCommentsAndStrings(source, indexRelPath, {
2117
+ blankPropertyKeys: true,
2118
+ });
2119
+ const importMatch = new RegExp(
2120
+ `\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`,
2121
+ ).exec(maskedIndexSource);
1339
2122
  if (importMatch) {
1340
- effective = `${aliasName}(`;
2123
+ const raw = `${aliasName}(`;
2124
+ effective = { raw, masked: raw };
1341
2125
  effectiveLine = offsetToLine(source, importMatch.index);
1342
2126
  effectiveFile = "index.ts";
1343
2127
  }
@@ -1361,14 +2145,16 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1361
2145
  spreadIdentifierResolvesToFactory(providerRoot, indexPath, source, name),
1362
2146
  );
1363
2147
  const isStaticLiteral =
1364
- effective?.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
2148
+ effective?.masked.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
1365
2149
  // A call expression `ident(...)` (factory) or a factory-spread literal is
1366
2150
  // the rejected, non-static shape — UNLESS it is the stdlib
1367
2151
  // `Object.fromEntries(Object.entries(<source-visible obj>)...)` reshape,
1368
2152
  // whose op set is still enumerable from source (verified golden pattern).
1369
2153
  const isFactoryCall =
1370
2154
  effective !== undefined &&
1371
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) || hasFactorySpread || hasFactorySpreadIdentifier) &&
2155
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(effective.masked) ||
2156
+ hasFactorySpread ||
2157
+ hasFactorySpreadIdentifier) &&
1372
2158
  !isTransparentObjectReshape(effective);
1373
2159
 
1374
2160
  if (isFactoryCall && !isStaticLiteral) {
@@ -1380,7 +2166,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1380
2166
  blockerMessage:
1381
2167
  "defineProvider operations are composed by a factory call instead of a static object literal.",
1382
2168
  remediation:
1383
- "Declare operations as a static literal: defineProvider({ operations: { 'op-id': defineOperation({...}) } }). The provider-registry AST gate requires static runtime/operations; factory composition fails the registry build. If composition is unavoidable, add `// @apifuse-allow flat-operation-composition: <reason>`.",
2169
+ "Declare operations as a static literal in the provider builder call. The provider-registry AST gate requires static runtime/operations; factory composition fails the registry build. If composition is unavoidable, add `// @apifuse-allow flat-operation-composition: <reason>`.",
1384
2170
  passMessage: "defineProvider declares operations as a static object literal.",
1385
2171
  });
1386
2172
  }
@@ -1394,7 +2180,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1394
2180
  }
1395
2181
 
1396
2182
  function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
1397
- const credentialReferences = findSourceLineMatches(providerRoot, /ctx\.credential/);
2183
+ const credentialReferences = findCredentialReferences(providerRoot);
1398
2184
  const authMode = provider.auth?.mode ?? "none";
1399
2185
  const credentialKeys = provider.credential?.keys ?? [];
1400
2186
  const storesProviderCredential = authMode !== "none" || credentialKeys.length > 0;
@@ -1424,26 +2210,291 @@ function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition
1424
2210
  );
1425
2211
  }
1426
2212
 
2213
+ function isDirectCredentialReference(expression: ts.Expression): boolean {
2214
+ const reference = unwrapLocalExpression(expression);
2215
+ let receiver: ts.Expression | undefined;
2216
+ let memberName: string | undefined;
2217
+ if (ts.isPropertyAccessExpression(reference)) {
2218
+ receiver = reference.expression;
2219
+ memberName = reference.name.text;
2220
+ } else if (ts.isElementAccessExpression(reference)) {
2221
+ receiver = reference.expression;
2222
+ const argument = reference.argumentExpression;
2223
+ if (
2224
+ argument !== undefined &&
2225
+ (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
2226
+ ) {
2227
+ memberName = argument.text;
2228
+ }
2229
+ }
2230
+ const unwrappedReceiver = receiver === undefined ? undefined : unwrapLocalExpression(receiver);
2231
+ return (
2232
+ memberName === "credential" &&
2233
+ unwrappedReceiver !== undefined &&
2234
+ ts.isIdentifier(unwrappedReceiver) &&
2235
+ unwrappedReceiver.text === "ctx"
2236
+ );
2237
+ }
2238
+
2239
+ function isCredentialReference(
2240
+ expression: ts.Expression,
2241
+ bindings: LocalBindings,
2242
+ resolving: ReadonlySet<LocalBinding> = new Set(),
2243
+ ): boolean {
2244
+ const reference = unwrapLocalExpression(expression);
2245
+ if (isDirectCredentialReference(reference)) {
2246
+ return true;
2247
+ }
2248
+ if (!ts.isIdentifier(reference)) {
2249
+ return false;
2250
+ }
2251
+ const localBindings = lookupLocalBindings(reference, bindings);
2252
+ if (localBindings === undefined || localBindings.length === 0) {
2253
+ return false;
2254
+ }
2255
+ return localBindings.every((binding) => {
2256
+ if (resolving.has(binding) || binding.mutable || binding.initializer === undefined) {
2257
+ return false;
2258
+ }
2259
+ const nextResolving = new Set(resolving);
2260
+ nextResolving.add(binding);
2261
+ if (binding.destructuredProperty !== undefined) {
2262
+ const source = unwrapLocalExpression(binding.initializer);
2263
+ return (
2264
+ binding.destructuredProperty === "credential" &&
2265
+ ts.isIdentifier(source) &&
2266
+ source.text === "ctx"
2267
+ );
2268
+ }
2269
+ return isCredentialReference(binding.initializer, bindings, nextResolving);
2270
+ });
2271
+ }
2272
+
2273
+ function findCredentialReferences(providerRoot: string): SourceFinding[] {
2274
+ const findings: SourceFinding[] = [];
2275
+ const seen = new Set<string>();
2276
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
2277
+ const source = readFileSync(filePath, "utf8");
2278
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
2279
+ const sourceFile = ts.createSourceFile(
2280
+ relPath,
2281
+ source,
2282
+ ts.ScriptTarget.Latest,
2283
+ true,
2284
+ ts.ScriptKind.TS,
2285
+ );
2286
+ const bindings = collectLocalBindings(sourceFile);
2287
+ const visit = (node: ts.Node): void => {
2288
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
2289
+ return;
2290
+ }
2291
+ if (
2292
+ ts.isExpression(node) &&
2293
+ (isDirectCredentialReference(node) ||
2294
+ ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) &&
2295
+ isCredentialReference(node.expression, bindings)))
2296
+ ) {
2297
+ const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
2298
+ const key = `${relPath}:${line}`;
2299
+ if (!seen.has(key)) {
2300
+ seen.add(key);
2301
+ findings.push({ file: relPath, line });
2302
+ }
2303
+ }
2304
+ ts.forEachChild(node, visit);
2305
+ };
2306
+ visit(sourceFile);
2307
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
2308
+ break;
2309
+ }
2310
+ }
2311
+ return findings;
2312
+ }
2313
+
2314
+ // ---------------------------------------------------------------------------
2315
+ // sdk-owned-secret-presence (warn): provider-local double validation of
2316
+ // declared env secrets.
2317
+ //
2318
+ // The SDK runtime is the single source of truth for secret presence: declared
2319
+ // `required: true` secrets are validated before every handler/auth-flow
2320
+ // invocation and fail with the canonical structured MISSING_SECRET error
2321
+ // (HTTP 400, category credential_unavailable). Provider-local presence guards
2322
+ // (requireServiceKey/requireApiKey style) are dead weight that historically
2323
+ // diverged into inconsistent shapes (CONFIGURATION_ERROR vs MISSING_SECRET,
2324
+ // with/without category), which broke uniform incident attribution when nine
2325
+ // providers shipped with unprovisioned secrets (2026-07-22).
2326
+ //
2327
+ // Heuristic, warn-only: a line reading a declared `required: true` secret via
2328
+ // `.env.get(...)` (string literal or a const alias of a declared name)
2329
+ // followed within a small window by a falsy presence check plus a `throw`.
2330
+ // The rule flags duplication of the SDK gate ONLY: env names that are not
2331
+ // declared in defineProvider secrets[], and optional declarations
2332
+ // (`required: false`/omitted) that the runtime deliberately does not enforce,
2333
+ // are out of scope. Escape hatch:
2334
+ // `// @apifuse-allow sdk-owned-secret-presence: <reason>`.
2335
+ // ---------------------------------------------------------------------------
2336
+
2337
+ const SDK_OWNED_SECRET_PRESENCE_RULE_ID = "sdk-owned-secret-presence";
2338
+ const SECRET_PRESENCE_GUARD_LOOKAHEAD_LINES = 10;
2339
+
2340
+ const ENV_GET_CALL_PATTERN =
2341
+ /\.env\.get\(\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`|([A-Za-z_$][\w$]*))\s*\)/;
2342
+
2343
+ const SECRET_ALIAS_CONST_PATTERN =
2344
+ /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`)/g;
2345
+
2346
+ const ENV_GET_ASSIGNMENT_PATTERN = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=[^;]*\.env\.get\(/;
2347
+
2348
+ // Const aliases of declared secret names (e.g. `const SERVICE_KEY_ENV =
2349
+ // "APIFUSE__PROVIDER__X__SERVICE_KEY"`) so aliased `.env.get(SERVICE_KEY_ENV)`
2350
+ // guards are detected, not just direct string literals.
2351
+ function buildDeclaredSecretAliasMap(
2352
+ providerRoot: string,
2353
+ declaredNames: ReadonlySet<string>,
2354
+ ): Map<string, string> {
2355
+ const aliases = new Map<string, string>();
2356
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
2357
+ const content = readFileSync(filePath, "utf8");
2358
+ for (const match of content.matchAll(SECRET_ALIAS_CONST_PATTERN)) {
2359
+ const alias = match[1];
2360
+ const name = match[2] ?? match[3] ?? match[4];
2361
+ if (alias && name && declaredNames.has(name)) {
2362
+ aliases.set(alias, name);
2363
+ }
2364
+ }
2365
+ }
2366
+ return aliases;
2367
+ }
2368
+
2369
+ function hasLocalSecretPresenceGuard(
2370
+ line: string,
2371
+ remainingLines: readonly string[],
2372
+ declaredNames: ReadonlySet<string>,
2373
+ aliases: ReadonlyMap<string, string>,
2374
+ ): boolean {
2375
+ const match = ENV_GET_CALL_PATTERN.exec(line);
2376
+ if (!match) {
2377
+ return false;
2378
+ }
2379
+ const literal = match[1] ?? match[2] ?? match[3];
2380
+ const identifier = match[4];
2381
+ const readsDeclaredSecret =
2382
+ literal !== undefined
2383
+ ? declaredNames.has(literal)
2384
+ : identifier !== undefined && aliases.has(identifier);
2385
+ if (!readsDeclaredSecret) {
2386
+ return false;
2387
+ }
2388
+
2389
+ const window = [line, ...remainingLines.slice(0, SECRET_PRESENCE_GUARD_LOOKAHEAD_LINES)];
2390
+ if (!window.some((candidate) => /\bthrow\b/.test(candidate))) {
2391
+ return false;
2392
+ }
2393
+
2394
+ // Assigned read (`const key = ctx.env.get(...)`): only a falsy/undefined
2395
+ // check on THAT variable counts as a presence guard. Anchoring on the
2396
+ // assigned identifier avoids false positives from unrelated guards/throws
2397
+ // that merely sit near the env read (mirrors the aliased runtime-guard rule).
2398
+ const assigned = ENV_GET_ASSIGNMENT_PATTERN.exec(line)?.[1];
2399
+ if (assigned) {
2400
+ const escaped = assigned.replace(/\$/g, "\\$");
2401
+ const guardPattern = new RegExp(
2402
+ `(?:!\\s*${escaped}\\b|\\b${escaped}\\s*===?\\s*(?:undefined|null)\\b|\\b${escaped}\\s*==\\s*null\\b|\\b${escaped}(?:\\?\\.|\\.)length\\s*===?\\s*0\\b)`,
2403
+ );
2404
+ return window.some((candidate) => guardPattern.test(candidate));
2405
+ }
2406
+
2407
+ // Un-assigned read: only an inline presence check on the same line counts,
2408
+ // e.g. `if (!ctx.env.get(KEY)) throw ...`.
2409
+ return /(?:if\s*\(\s*!|===?\s*undefined\b|==\s*null\b)/.test(line);
2410
+ }
2411
+
2412
+ function scoreSdkOwnedSecretPresence(
2413
+ providerRoot: string,
2414
+ provider: ProviderDefinition,
2415
+ ): SubmitCheck {
2416
+ const passMessage = "Provider relies on SDK-owned secret presence validation.";
2417
+ // Only `required: true` declarations: those are exactly what the runtime
2418
+ // gate enforces. A presence guard over an optional secret is conditional
2419
+ // business logic the SDK will not replace, not double validation.
2420
+ const declaredNames: ReadonlySet<string> = new Set(
2421
+ (provider.secrets ?? [])
2422
+ .filter((secret) => secret.required === true)
2423
+ .map((secret) => secret.name),
2424
+ );
2425
+ if (declaredNames.size === 0) {
2426
+ return pass(SDK_OWNED_SECRET_PRESENCE_RULE_ID, SDK_NATIVE_CATEGORY, passMessage, 0);
2427
+ }
2428
+
2429
+ const aliases = buildDeclaredSecretAliasMap(providerRoot, declaredNames);
2430
+ const findings = findSourceFindings(providerRoot, (line, remainingLines) =>
2431
+ hasLocalSecretPresenceGuard(line, remainingLines, declaredNames, aliases),
2432
+ );
2433
+ if (findings.length === 0) {
2434
+ return pass(SDK_OWNED_SECRET_PRESENCE_RULE_ID, SDK_NATIVE_CATEGORY, passMessage, 0);
2435
+ }
2436
+
2437
+ const { violations, overridden } = partitionAllowOverrides(
2438
+ providerRoot,
2439
+ findings,
2440
+ SDK_OWNED_SECRET_PRESENCE_RULE_ID,
2441
+ );
2442
+ if (violations.length === 0) {
2443
+ return pass(
2444
+ SDK_OWNED_SECRET_PRESENCE_RULE_ID,
2445
+ SDK_NATIVE_CATEGORY,
2446
+ `${passMessage} ${overridden.length} acknowledged @apifuse-allow override(s).`,
2447
+ 0,
2448
+ formatSourceFindings(overridden),
2449
+ );
2450
+ }
2451
+
2452
+ return {
2453
+ id: SDK_OWNED_SECRET_PRESENCE_RULE_ID,
2454
+ category: SDK_NATIVE_CATEGORY,
2455
+ level: "warn",
2456
+ status: "warn",
2457
+ points: 0,
2458
+ maxPoints: 0,
2459
+ message:
2460
+ "Provider source re-validates declared env secret presence locally; the SDK owns this check.",
2461
+ remediation:
2462
+ "The provider SDK validates declared required secrets before handlers and auth flows run and returns the canonical MISSING_SECRET error (HTTP 400, category credential_unavailable). Declare the secret with required: true in defineProvider({ secrets: [...] }), delete the provider-local presence guard (requireServiceKey/requireApiKey style), and read the value directly with ctx.env.get(); the guard is dead weight and its divergent CONFIGURATION_ERROR-style shape is deprecated. Acknowledge intentional exceptions with `// @apifuse-allow sdk-owned-secret-presence: <reason>`.",
2463
+ evidence: formatSourceFindings(violations),
2464
+ };
2465
+ }
2466
+
1427
2467
  function findSourceLineMatches(
1428
2468
  providerRoot: string,
1429
2469
  pattern: RegExp | ((line: string) => boolean),
2470
+ useMaskedSource = false,
1430
2471
  ): SourceFinding[] {
1431
- return findSourceFindings(providerRoot, (line) => matchesLinePattern(line, pattern));
2472
+ return findSourceFindings(
2473
+ providerRoot,
2474
+ (line) => matchesLinePattern(line, pattern),
2475
+ useMaskedSource,
2476
+ );
1432
2477
  }
1433
2478
 
1434
2479
  function findSourceFindings(
1435
2480
  providerRoot: string,
1436
2481
  matchesLine: (line: string, remainingLines: readonly string[]) => boolean,
2482
+ useMaskedSource = false,
1437
2483
  ): SourceFinding[] {
1438
2484
  const findings: SourceFinding[] = [];
1439
2485
  for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
1440
2486
  const content = readFileSync(filePath, "utf8");
1441
- const lines = content.split(/\r?\n/);
2487
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
2488
+ const lines = (
2489
+ useMaskedSource
2490
+ ? maskCommentsAndStrings(content, relPath, { blankPropertyKeys: true })
2491
+ : content
2492
+ ).split(/\r?\n/);
1442
2493
  for (let index = 0; index < lines.length; index += 1) {
1443
2494
  const line = lines[index];
1444
2495
  if (line !== undefined && matchesLine(line, lines.slice(index + 1))) {
1445
2496
  findings.push({
1446
- file: toRelativeProviderPath(providerRoot, filePath),
2497
+ file: relPath,
1447
2498
  line: index + 1,
1448
2499
  });
1449
2500
  if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
@@ -1523,8 +2574,25 @@ function isScannableProviderSourceFile(relativePath: string): boolean {
1523
2574
  );
1524
2575
  }
1525
2576
 
2577
+ const EXCLUDED_SOURCE_DIRECTORY_SEGMENTS = new Set([
2578
+ ".git",
2579
+ ".worktree",
2580
+ "node_modules",
2581
+ "dist",
2582
+ "build",
2583
+ "coverage",
2584
+ ]);
2585
+
2586
+ // `.agents`/`.apifuse` stay IN scope on purpose: managed content there is
2587
+ // markdown/JSON (never scannable), while a planted `.ts`/`.sh` under those
2588
+ // directories must not become a scan-exempt hiding place for secrets, raw
2589
+ // fetch, or vendor imports. Nested dependency/build output directories under
2590
+ // them use the same segment exclusions; `.worktree` is excluded because task
2591
+ // worktrees are duplicate repository checkouts, not provider source.
1526
2592
  function shouldScanSourceDirectory(relativePath: string): boolean {
1527
- return ![".git", "node_modules", "dist", "build", "coverage"].includes(relativePath);
2593
+ return relativePath
2594
+ .split("/")
2595
+ .every((segment) => !EXCLUDED_SOURCE_DIRECTORY_SEGMENTS.has(segment));
1528
2596
  }
1529
2597
 
1530
2598
  function isExcludedTestSource(relativePath: string): boolean {
@@ -1542,7 +2610,31 @@ function toRelativeProviderPath(providerRoot: string, filePath: string): string
1542
2610
  }
1543
2611
 
1544
2612
  function formatSourceFindings(findings: readonly SourceFinding[]): string[] {
1545
- return findings.map((finding) => `${finding.file}:${finding.line}`);
2613
+ return findings.map(
2614
+ (finding) =>
2615
+ `${finding.file}:${finding.line}${finding.detail === undefined ? "" : `: ${finding.detail}`}`,
2616
+ );
2617
+ }
2618
+
2619
+ function scorePromptAssetFreshness(providerRoot: string): SubmitCheck {
2620
+ const verification = verifyPromptAssets(providerRoot);
2621
+ if (verification.ok) {
2622
+ return pass(
2623
+ "prompt-assets-fresh",
2624
+ "docs",
2625
+ "SDK-managed agent prompt assets match the installed SDK version.",
2626
+ 0,
2627
+ );
2628
+ }
2629
+
2630
+ return blocker(
2631
+ "prompt-assets-fresh",
2632
+ "docs",
2633
+ "SDK-managed agent prompt assets are missing, stale, or modified.",
2634
+ "Run `bun run sync-assets` (or `bunx apifuse sync-assets .`) to regenerate AGENTS.md, .agents/skills/**, the CLAUDE.md/.claude/.codex symlinks, and .apifuse/prompt-assets.json for the installed SDK version.",
2635
+ 0,
2636
+ formatPromptAssetIssues(verification),
2637
+ );
1546
2638
  }
1547
2639
 
1548
2640
  function scoreRepositoryDx(providerRoot: string): SubmitCheck {
@@ -1617,7 +2709,7 @@ async function safeRunChecks(providerRoot: string): Promise<CheckResult[]> {
1617
2709
  {
1618
2710
  message: "Base provider checks can run",
1619
2711
  passed: false,
1620
- details: [error instanceof Error ? error.message : String(error)],
2712
+ details: [formatThrownValue(error)],
1621
2713
  },
1622
2714
  ];
1623
2715
  }
@@ -1983,11 +3075,16 @@ const GENERATED_LOCAL_ONLY_SCAFFOLD_REASON = /generated local-only scaffold/i;
1983
3075
  function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
1984
3076
  const rawPath = resolve(providerRoot, "__fixtures__", "raw.json");
1985
3077
  let hasRecordedEvidence = false;
3078
+ let fixtureValidationError: string | undefined;
1986
3079
  if (existsSync(rawPath)) {
1987
3080
  try {
1988
- hasRecordedEvidence = hasNonEmptyRecordedFixture(JSON.parse(readFileSync(rawPath, "utf8")));
1989
- } catch {
3081
+ hasRecordedEvidence = recordedFixtureStats(
3082
+ JSON.parse(readFileSync(rawPath, "utf8")),
3083
+ 0,
3084
+ ).hasNestedSubstance;
3085
+ } catch (error) {
1990
3086
  hasRecordedEvidence = false;
3087
+ fixtureValidationError = error instanceof Error ? error.message : String(error);
1991
3088
  }
1992
3089
  }
1993
3090
 
@@ -1999,6 +3096,16 @@ function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefiniti
1999
3096
  0,
2000
3097
  );
2001
3098
  }
3099
+ if (fixtureValidationError) {
3100
+ return blocker(
3101
+ "fixture-provenance",
3102
+ "fixtures",
3103
+ `Malformed recorded fixture evidence in __fixtures__/raw.json: ${fixtureValidationError}`,
3104
+ "Re-run `bun run record` to replace the malformed stream evidence, or repair the named field using the stream evidence contract.",
3105
+ 0,
3106
+ ["__fixtures__/raw.json"],
3107
+ );
3108
+ }
2002
3109
 
2003
3110
  if (allOperationsAreGeneratedLocalScaffold(provider)) {
2004
3111
  return {
@@ -2026,14 +3133,32 @@ function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefiniti
2026
3133
  );
2027
3134
  }
2028
3135
 
2029
- function hasNonEmptyRecordedFixture(value: unknown): boolean {
2030
- return recordedFixtureStats(value, 0).hasNestedSubstance;
3136
+ export function hasNonEmptyRecordedFixture(value: unknown): boolean {
3137
+ try {
3138
+ return recordedFixtureStats(value, 0).hasNestedSubstance;
3139
+ } catch {
3140
+ return false;
3141
+ }
2031
3142
  }
2032
3143
 
2033
3144
  function recordedFixtureStats(
2034
3145
  value: unknown,
2035
3146
  depth: number,
2036
3147
  ): { hasNestedSubstance: boolean; leafValues: number } {
3148
+ if (
3149
+ value !== null &&
3150
+ typeof value === "object" &&
3151
+ !Array.isArray(value) &&
3152
+ (value as Record<string, unknown>).__apifuse_capture__ === true
3153
+ ) {
3154
+ const group = findStreamCaptureGroup(value);
3155
+ if (!group) throw new Error("Stream capture envelope is invalid.");
3156
+ return { hasNestedSubstance: true, leafValues: group.items.length };
3157
+ }
3158
+ if (hasStreamEvidenceMarker(value)) {
3159
+ parseStreamEvidenceRecord(value);
3160
+ return { hasNestedSubstance: true, leafValues: 1 };
3161
+ }
2037
3162
  if (value === null || value === undefined) {
2038
3163
  return { hasNestedSubstance: false, leafValues: 0 };
2039
3164
  }
@@ -2068,10 +3193,14 @@ function recordedFixtureStats(
2068
3193
  if (value.length === 0) {
2069
3194
  return { hasNestedSubstance: false, leafValues: 0 };
2070
3195
  }
2071
- // A recorded operation value may be a raw XML success payload; treat a
2072
- // substantive, well-formed one as nested evidence while still counting the
2073
- // string as a leaf so existing JSON provenance heuristics are unchanged.
2074
- return { hasNestedSubstance: hasSubstantiveXmlStructure(value), leafValues: 1 };
3196
+ // A recorded operation value may be a raw XML or delimited-text success
3197
+ // payload. Treat a substantive one as nested evidence while still counting
3198
+ // the string as a leaf so existing JSON provenance heuristics are unchanged.
3199
+ return {
3200
+ hasNestedSubstance:
3201
+ hasSubstantiveXmlStructure(value) || hasSubstantiveDelimitedTextStructure(value),
3202
+ leafValues: 1,
3203
+ };
2075
3204
  }
2076
3205
  return { hasNestedSubstance: false, leafValues: 1 };
2077
3206
  }
@@ -2136,68 +3265,326 @@ function findVendorKeyLeakFindings(providerRoot: string): SourceFinding[] {
2136
3265
  for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
2137
3266
  const source = readFileSync(filePath, "utf8");
2138
3267
  const relPath = toRelativeProviderPath(providerRoot, filePath);
3268
+ maskCommentsAndStrings(source, relPath);
3269
+ const sourceFile = ts.createSourceFile(
3270
+ relPath,
3271
+ source,
3272
+ ts.ScriptTarget.Latest,
3273
+ true,
3274
+ ts.ScriptKind.TS,
3275
+ );
3276
+ const bindings = collectLocalBindings(sourceFile);
2139
3277
  const upstreamRanges = findUpstreamMarkedConstRanges(source);
2140
3278
  for (const zObject of findZObjectLiterals(source)) {
2141
3279
  if (rangeContainsOffset(upstreamRanges, zObject.callStart)) {
2142
3280
  continue;
2143
3281
  }
2144
- if (!zObjectAppearsPublicOutput(source, zObject)) {
3282
+ if (!zObjectAppearsPublicOutput(sourceFile, zObject, relPath)) {
2145
3283
  continue;
2146
3284
  }
2147
- for (const keyFinding of vendorKeyFindingsForObject(source, zObject)) {
2148
- const key = `${relPath}:${keyFinding.line}:${keyFinding.key}`;
2149
- if (!seen.has(key)) {
2150
- seen.add(key);
2151
- findings.push({ file: relPath, line: keyFinding.line });
2152
- if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
2153
- return findings;
2154
- }
2155
- }
3285
+ for (const keyFinding of vendorKeyFindingsForObject(
3286
+ source,
3287
+ sourceFile,
3288
+ zObject,
3289
+ bindings,
3290
+ )) {
3291
+ const key = `${relPath}:${keyFinding.line}:${keyFinding.key}`;
3292
+ if (!seen.has(key)) {
3293
+ seen.add(key);
3294
+ findings.push({
3295
+ file: relPath,
3296
+ line: keyFinding.line,
3297
+ detail: keyFinding.key,
3298
+ });
3299
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
3300
+ return findings;
3301
+ }
3302
+ }
3303
+ }
3304
+ }
3305
+ }
3306
+
3307
+ return findings;
3308
+ }
3309
+
3310
+ function findZObjectLiterals(source: string): ZObjectLiteral[] {
3311
+ const literals: ZObjectLiteral[] = [];
3312
+ const masked = maskCommentsAndStrings(source);
3313
+ const callPattern = /\bz\s*\.\s*object\s*\(/g;
3314
+ for (let match = callPattern.exec(masked); match !== null; match = callPattern.exec(masked)) {
3315
+ const parenIndex = masked.indexOf("(", match.index);
3316
+ const objectStart = findNextNonWhitespace(masked, parenIndex + 1);
3317
+ if (objectStart === -1 || masked[objectStart] !== "{") {
3318
+ continue;
3319
+ }
3320
+ const objectEnd = findMatchingBracket(masked, objectStart);
3321
+ if (objectEnd === -1) {
3322
+ continue;
3323
+ }
3324
+ literals.push({
3325
+ objectStart,
3326
+ objectEnd,
3327
+ callStart: match.index,
3328
+ });
3329
+ callPattern.lastIndex = objectEnd;
3330
+ }
3331
+ return literals;
3332
+ }
3333
+
3334
+ function zObjectAppearsPublicOutput(
3335
+ sourceFile: ts.SourceFile,
3336
+ zObject: ZObjectLiteral,
3337
+ relPath: string,
3338
+ ): boolean {
3339
+ let zObjectCall: ts.CallExpression | undefined;
3340
+ const findCall = (node: ts.Node): void => {
3341
+ if (
3342
+ zObjectCall === undefined &&
3343
+ ts.isCallExpression(node) &&
3344
+ node.getStart(sourceFile) === zObject.callStart
3345
+ ) {
3346
+ zObjectCall = node;
3347
+ return;
3348
+ }
3349
+ ts.forEachChild(node, findCall);
3350
+ };
3351
+ findCall(sourceFile);
3352
+ if (zObjectCall === undefined) {
3353
+ return true;
3354
+ }
3355
+
3356
+ let expression: ts.Expression = zObjectCall;
3357
+ while (
3358
+ ts.isParenthesizedExpression(expression.parent) ||
3359
+ ts.isAsExpression(expression.parent) ||
3360
+ ts.isSatisfiesExpression(expression.parent) ||
3361
+ ts.isNonNullExpression(expression.parent)
3362
+ ) {
3363
+ expression = expression.parent;
3364
+ }
3365
+ if (
3366
+ ts.isPropertyAssignment(expression.parent) &&
3367
+ isPublicOutputPropertyName(expression.parent.name)
3368
+ ) {
3369
+ return true;
3370
+ }
3371
+
3372
+ const declaration = findEnclosingVariableDeclaration(expression);
3373
+ if (
3374
+ declaration === undefined ||
3375
+ !ts.isIdentifier(declaration.name) ||
3376
+ declaration.initializer === undefined ||
3377
+ !ts.isVariableDeclarationList(declaration.parent) ||
3378
+ (declaration.parent.flags & ts.NodeFlags.Const) === 0
3379
+ ) {
3380
+ return true;
3381
+ }
3382
+
3383
+ const reachability = localSchemaBindingReachability(
3384
+ sourceFile,
3385
+ declaration.name.text,
3386
+ declaration,
3387
+ new Set(),
3388
+ );
3389
+ if (reachability === "public") {
3390
+ return true;
3391
+ }
3392
+ if (reachability === "unknown") {
3393
+ return true;
3394
+ }
3395
+ // Sibling modules are scanned independently and therefore remain
3396
+ // fail-closed. An exported binding in index.ts is likewise consumable by
3397
+ // another module, so local inertness does not prove it is private.
3398
+ if (relPath !== "index.ts") {
3399
+ return true;
3400
+ }
3401
+ return isExportedVariableBinding(sourceFile, declaration);
3402
+ }
3403
+
3404
+ type SchemaReachability = "public" | "internal" | "unknown";
3405
+
3406
+ const PARSE_LIKE_SCHEMA_METHODS: ReadonlySet<string> = new Set([
3407
+ "parse",
3408
+ "safeParse",
3409
+ "parseAsync",
3410
+ "safeParseAsync",
3411
+ ]);
3412
+
3413
+ function isExportedVariableBinding(
3414
+ sourceFile: ts.SourceFile,
3415
+ declaration: ts.VariableDeclaration,
3416
+ ): boolean {
3417
+ const declarationStatement = declaration.parent.parent;
3418
+ if (
3419
+ ts.isVariableStatement(declarationStatement) &&
3420
+ declarationStatement.modifiers?.some(
3421
+ (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
3422
+ ) === true
3423
+ ) {
3424
+ return true;
3425
+ }
3426
+ if (!ts.isIdentifier(declaration.name)) {
3427
+ return false;
3428
+ }
3429
+ const name = declaration.name.text;
3430
+ let exported = false;
3431
+ const visit = (node: ts.Node): void => {
3432
+ if (exported) {
3433
+ return;
3434
+ }
3435
+ if (ts.isExportSpecifier(node)) {
3436
+ const localName = node.propertyName ?? node.name;
3437
+ if (ts.isIdentifier(localName) && localName.text === name) {
3438
+ exported = true;
3439
+ return;
3440
+ }
3441
+ }
3442
+ if (ts.isExportAssignment(node)) {
3443
+ const expression = unwrapLocalExpression(node.expression);
3444
+ if (ts.isIdentifier(expression) && expression.text === name) {
3445
+ exported = true;
3446
+ return;
2156
3447
  }
2157
3448
  }
3449
+ ts.forEachChild(node, visit);
3450
+ };
3451
+ for (const statement of sourceFile.statements) {
3452
+ visit(statement);
3453
+ if (exported) {
3454
+ break;
3455
+ }
2158
3456
  }
3457
+ return exported;
3458
+ }
2159
3459
 
2160
- return findings;
3460
+ function isPublicOutputPropertyName(name: ts.PropertyName): boolean {
3461
+ const text = propertyNameText(name);
3462
+ return text === "output" || text === "response";
2161
3463
  }
2162
3464
 
2163
- function findZObjectLiterals(source: string): ZObjectLiteral[] {
2164
- const literals: ZObjectLiteral[] = [];
2165
- const masked = maskCommentsAndStrings(source);
2166
- const callPattern = /\bz\s*\.\s*object\s*\(/g;
2167
- for (let match = callPattern.exec(masked); match !== null; match = callPattern.exec(masked)) {
2168
- const parenIndex = masked.indexOf("(", match.index);
2169
- const objectStart = findNextNonWhitespace(masked, parenIndex + 1);
2170
- if (objectStart === -1 || masked[objectStart] !== "{") {
2171
- continue;
3465
+ function findEnclosingVariableDeclaration(node: ts.Node): ts.VariableDeclaration | undefined {
3466
+ let current: ts.Node | undefined = node;
3467
+ while (current !== undefined && !ts.isSourceFile(current)) {
3468
+ if (ts.isVariableDeclaration(current)) {
3469
+ return current;
2172
3470
  }
2173
- const objectEnd = findMatchingBracket(masked, objectStart);
2174
- if (objectEnd === -1) {
2175
- continue;
2176
- }
2177
- literals.push({
2178
- objectStart,
2179
- objectEnd,
2180
- callStart: match.index,
2181
- });
2182
- callPattern.lastIndex = objectEnd;
3471
+ current = current.parent;
2183
3472
  }
2184
- return literals;
3473
+ return undefined;
2185
3474
  }
2186
3475
 
2187
- function zObjectAppearsPublicOutput(source: string, zObject: ZObjectLiteral): boolean {
2188
- const enclosingConst = findConstValueRangeContaining(source, zObject.callStart);
2189
- if (enclosingConst && /output|response|result/i.test(enclosingConst.name)) {
2190
- return true;
3476
+ function localSchemaBindingReachability(
3477
+ sourceFile: ts.SourceFile,
3478
+ name: string,
3479
+ declaration: ts.VariableDeclaration,
3480
+ resolving: ReadonlySet<string>,
3481
+ ): SchemaReachability {
3482
+ if (resolving.has(name)) {
3483
+ return "unknown";
3484
+ }
3485
+ const nextResolving = new Set(resolving);
3486
+ nextResolving.add(name);
3487
+ let result: SchemaReachability = "internal";
3488
+ const visit = (node: ts.Node): void => {
3489
+ if (result === "public") {
3490
+ return;
3491
+ }
3492
+ if (ts.isIdentifier(node) && node.text === name && node !== declaration.name) {
3493
+ const parent = node.parent;
3494
+ if (isParseLikeSchemaReceiver(node)) {
3495
+ return;
3496
+ }
3497
+ if (ts.isExportSpecifier(parent)) {
3498
+ const localName = parent.propertyName ?? parent.name;
3499
+ if (localName === node) {
3500
+ result = "public";
3501
+ }
3502
+ return;
3503
+ }
3504
+ if (
3505
+ ts.isPropertyAssignment(parent) &&
3506
+ parent.initializer === node &&
3507
+ isPublicOutputPropertyName(parent.name)
3508
+ ) {
3509
+ result = "public";
3510
+ return;
3511
+ }
3512
+ if (ts.isShorthandPropertyAssignment(parent)) {
3513
+ result = isPublicOutputPropertyName(parent.name) ? "public" : "unknown";
3514
+ return;
3515
+ }
3516
+ if (
3517
+ ts.isVariableDeclaration(parent) &&
3518
+ parent.initializer === node &&
3519
+ ts.isIdentifier(parent.name) &&
3520
+ ts.isVariableDeclarationList(parent.parent) &&
3521
+ (parent.parent.flags & ts.NodeFlags.Const) !== 0
3522
+ ) {
3523
+ const aliasReachability = localSchemaBindingReachability(
3524
+ sourceFile,
3525
+ parent.name.text,
3526
+ parent,
3527
+ nextResolving,
3528
+ );
3529
+ if (aliasReachability === "public" || result === "internal") {
3530
+ result = aliasReachability;
3531
+ }
3532
+ return;
3533
+ }
3534
+ if (ts.isVoidExpression(parent) || ts.isTypeOfExpression(parent)) {
3535
+ return;
3536
+ }
3537
+ if (
3538
+ (ts.isPropertyAccessExpression(parent) && parent.name === node) ||
3539
+ (ts.isPropertyAssignment(parent) && parent.name === node) ||
3540
+ ts.isBindingElement(parent) ||
3541
+ ts.isImportSpecifier(parent)
3542
+ ) {
3543
+ return;
3544
+ }
3545
+ result = "unknown";
3546
+ }
3547
+ ts.forEachChild(node, visit);
3548
+ };
3549
+ visit(sourceFile);
3550
+ return result;
3551
+ }
3552
+
3553
+ function isParseLikeSchemaReceiver(identifier: ts.Identifier): boolean {
3554
+ const access = identifier.parent;
3555
+ let methodName: string | undefined;
3556
+ if (ts.isPropertyAccessExpression(access) && access.expression === identifier) {
3557
+ methodName = access.name.text;
3558
+ } else if (ts.isElementAccessExpression(access) && access.expression === identifier) {
3559
+ const argument = access.argumentExpression;
3560
+ if (
3561
+ argument !== undefined &&
3562
+ (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument))
3563
+ ) {
3564
+ methodName = argument.text;
3565
+ }
2191
3566
  }
2192
- const before = source.slice(Math.max(0, zObject.callStart - 160), zObject.callStart);
2193
- return /(?:^|[\s,{])(?:output|response)\s*:\s*$/.test(before);
3567
+ return (
3568
+ methodName !== undefined &&
3569
+ PARSE_LIKE_SCHEMA_METHODS.has(methodName) &&
3570
+ ts.isCallExpression(access.parent) &&
3571
+ access.parent.expression === access
3572
+ );
2194
3573
  }
2195
3574
 
2196
3575
  function vendorKeyFindingsForObject(
2197
3576
  source: string,
3577
+ sourceFile: ts.SourceFile,
2198
3578
  zObject: ZObjectLiteral,
3579
+ bindings: LocalBindings,
2199
3580
  ): Array<{ key: string; line: number }> {
2200
- const keys = collectTopLevelObjectKeys(source, zObject.objectStart, zObject.objectEnd);
3581
+ const keys = collectTopLevelObjectKeys(
3582
+ source,
3583
+ sourceFile,
3584
+ zObject.objectStart,
3585
+ zObject.objectEnd,
3586
+ bindings,
3587
+ );
2201
3588
  const digitFamilies = new Map<string, Set<string>>();
2202
3589
  for (const key of keys) {
2203
3590
  const member = numberedFamilyMember(key.name);
@@ -2253,8 +3640,10 @@ function isAllowedPublicOutputKeyName(name: string): boolean {
2253
3640
 
2254
3641
  function collectTopLevelObjectKeys(
2255
3642
  source: string,
3643
+ sourceFile: ts.SourceFile,
2256
3644
  objectStart: number,
2257
3645
  objectEnd: number,
3646
+ bindings: LocalBindings,
2258
3647
  ): Array<{ name: string; offset: number }> {
2259
3648
  const keys: Array<{ name: string; offset: number }> = [];
2260
3649
  const masked = maskCommentsAndStrings(source);
@@ -2276,17 +3665,16 @@ function collectTopLevelObjectKeys(
2276
3665
  index = endQuote + 1;
2277
3666
  } else if (masked[index] === "[") {
2278
3667
  const computedEnd = findMatchingBracket(masked, index);
2279
- const literalStart = findNextNonWhitespace(masked, index + 1);
2280
- if (computedEnd === -1 || literalStart === -1) {
3668
+ if (computedEnd === -1) {
2281
3669
  break;
2282
3670
  }
2283
- const computedQuote = source[literalStart];
2284
- if (computedQuote === '"' || computedQuote === "'") {
2285
- const literalEnd = findStringEnd(source, literalStart);
2286
- const afterLiteral =
2287
- literalEnd === -1 ? -1 : skipWhitespaceAndComments(masked, literalEnd + 1, computedEnd);
2288
- if (literalEnd !== -1 && afterLiteral === computedEnd) {
2289
- key = source.slice(literalStart + 1, literalEnd);
3671
+ const expression = computedPropertyExpressionAtOffset(sourceFile, index);
3672
+ if (expression !== undefined) {
3673
+ const unwrapped = unwrapLocalExpression(expression);
3674
+ if (ts.isStringLiteral(unwrapped) || ts.isNoSubstitutionTemplateLiteral(unwrapped)) {
3675
+ key = unwrapped.text;
3676
+ } else if (ts.isIdentifier(unwrapped)) {
3677
+ key = resolveConstStringIdentifier(unwrapped, bindings);
2290
3678
  }
2291
3679
  }
2292
3680
  index = computedEnd + 1;
@@ -2313,6 +3701,25 @@ function collectTopLevelObjectKeys(
2313
3701
  return keys;
2314
3702
  }
2315
3703
 
3704
+ function computedPropertyExpressionAtOffset(
3705
+ sourceFile: ts.SourceFile,
3706
+ offset: number,
3707
+ ): ts.Expression | undefined {
3708
+ let expression: ts.Expression | undefined;
3709
+ const visit = (node: ts.Node): void => {
3710
+ if (expression !== undefined || offset < node.getFullStart() || offset >= node.getEnd()) {
3711
+ return;
3712
+ }
3713
+ if (ts.isComputedPropertyName(node) && node.getStart(sourceFile) === offset) {
3714
+ expression = node.expression;
3715
+ return;
3716
+ }
3717
+ ts.forEachChild(node, visit);
3718
+ };
3719
+ visit(sourceFile);
3720
+ return expression;
3721
+ }
3722
+
2316
3723
  function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[] {
2317
3724
  const findings: SourceFinding[] = [];
2318
3725
  const seen = new Set<string>();
@@ -2320,6 +3727,15 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
2320
3727
  for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
2321
3728
  const source = readFileSync(filePath, "utf8");
2322
3729
  const relPath = toRelativeProviderPath(providerRoot, filePath);
3730
+ maskCommentsAndStrings(source, relPath);
3731
+ const sourceFile = ts.createSourceFile(
3732
+ relPath,
3733
+ source,
3734
+ ts.ScriptTarget.Latest,
3735
+ true,
3736
+ ts.ScriptKind.TS,
3737
+ );
3738
+ const bindings = collectLocalBindings(sourceFile);
2323
3739
  const zObjectRanges = findZObjectLiterals(source).map((zObject) => ({
2324
3740
  start: zObject.callStart,
2325
3741
  end: zObject.objectEnd,
@@ -2332,7 +3748,7 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
2332
3748
  ].filter((range) => rangeContainedInRanges(fixtureRanges, range));
2333
3749
 
2334
3750
  for (const range of fixtureResponseRanges) {
2335
- for (const literal of findStringLiteralsInRange(source, range)) {
3751
+ for (const literal of findStringLiteralsInRange(source, range, relPath)) {
2336
3752
  if (
2337
3753
  rangeContainsOffset(zObjectRanges, literal.offset) ||
2338
3754
  rangeContainsOffset(upstreamRanges, literal.offset) ||
@@ -2353,12 +3769,86 @@ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[]
2353
3769
  }
2354
3770
  }
2355
3771
  }
3772
+
3773
+ const visitAliases = (node: ts.Node): void => {
3774
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
3775
+ return;
3776
+ }
3777
+ const offset = node.getStart(sourceFile);
3778
+ if (
3779
+ ts.isIdentifier(node) &&
3780
+ isIdentifierValueUse(node) &&
3781
+ offset >= range.start &&
3782
+ offset <= range.end &&
3783
+ !rangeContainsOffset(zObjectRanges, offset) &&
3784
+ !rangeContainsOffset(upstreamRanges, offset)
3785
+ ) {
3786
+ const value = resolveConstStringIdentifier(node, bindings);
3787
+ if (
3788
+ value !== undefined &&
3789
+ isVendorTimestampCandidate(value, propertyNameForValueExpression(node))
3790
+ ) {
3791
+ const line = offsetToLine(source, offset);
3792
+ const key = `${relPath}:${line}:${value}`;
3793
+ if (!seen.has(key)) {
3794
+ seen.add(key);
3795
+ findings.push({ file: relPath, line });
3796
+ }
3797
+ }
3798
+ }
3799
+ ts.forEachChild(node, visitAliases);
3800
+ };
3801
+ visitAliases(sourceFile);
3802
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
3803
+ return findings;
3804
+ }
2356
3805
  }
2357
3806
  }
2358
3807
 
2359
3808
  return findings;
2360
3809
  }
2361
3810
 
3811
+ function isIdentifierValueUse(identifier: ts.Identifier): boolean {
3812
+ const parent = identifier.parent;
3813
+ return !(
3814
+ (ts.isPropertyAssignment(parent) && parent.name === identifier) ||
3815
+ (ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
3816
+ ts.isBindingElement(parent) ||
3817
+ ts.isVariableDeclaration(parent) ||
3818
+ ts.isParameter(parent) ||
3819
+ ts.isImportClause(parent) ||
3820
+ ts.isImportSpecifier(parent) ||
3821
+ ts.isNamespaceImport(parent) ||
3822
+ ts.isFunctionDeclaration(parent) ||
3823
+ ts.isFunctionExpression(parent) ||
3824
+ ts.isClassDeclaration(parent) ||
3825
+ ts.isClassExpression(parent) ||
3826
+ ts.isPropertyDeclaration(parent) ||
3827
+ ts.isPropertySignature(parent) ||
3828
+ ts.isMethodDeclaration(parent) ||
3829
+ ts.isMethodSignature(parent)
3830
+ );
3831
+ }
3832
+
3833
+ function propertyNameForValueExpression(expression: ts.Expression): string | undefined {
3834
+ let current: ts.Expression = expression;
3835
+ while (
3836
+ ts.isParenthesizedExpression(current.parent) ||
3837
+ ts.isAsExpression(current.parent) ||
3838
+ ts.isSatisfiesExpression(current.parent) ||
3839
+ ts.isNonNullExpression(current.parent)
3840
+ ) {
3841
+ current = current.parent;
3842
+ }
3843
+ if (ts.isPropertyAssignment(current.parent) && current.parent.initializer === current) {
3844
+ return propertyNameText(current.parent.name);
3845
+ }
3846
+ if (ts.isShorthandPropertyAssignment(current.parent)) {
3847
+ return current.parent.name.text;
3848
+ }
3849
+ return undefined;
3850
+ }
3851
+
2362
3852
  function findPropertyObjectRanges(source: string, propertyName: string): ObjectRange[] {
2363
3853
  const ranges: ObjectRange[] = [];
2364
3854
  const masked = maskCommentsAndStrings(source);
@@ -2402,23 +3892,16 @@ function findNamedConstValueRanges(source: string): NamedObjectRange[] {
2402
3892
  return ranges;
2403
3893
  }
2404
3894
 
2405
- function findConstValueRangeContaining(
2406
- source: string,
2407
- offset: number,
2408
- ): NamedObjectRange | undefined {
2409
- return findNamedConstValueRanges(source).find(
2410
- (range) => offset >= range.start && offset <= range.end,
2411
- );
2412
- }
2413
-
2414
3895
  function findStringLiteralsInRange(
2415
3896
  source: string,
2416
3897
  range: ObjectRange,
3898
+ fileName: string,
2417
3899
  ): Array<{ value: string; offset: number }> {
2418
3900
  const literals: Array<{ value: string; offset: number }> = [];
3901
+ const masked = maskCommentsAndStrings(source, fileName);
2419
3902
  let index = range.start;
2420
3903
  while (index <= range.end) {
2421
- const quote = source[index];
3904
+ const quote = masked[index];
2422
3905
  if (quote !== '"' && quote !== "'" && quote !== "`") {
2423
3906
  index += 1;
2424
3907
  continue;
@@ -2528,57 +4011,199 @@ function findStringEnd(source: string, start: number): number {
2528
4011
  return -1;
2529
4012
  }
2530
4013
 
2531
- function maskCommentsAndStrings(source: string): string {
2532
- const chars = source.split("");
2533
- for (let index = 0; index < source.length; index += 1) {
2534
- if (source.startsWith("//", index)) {
2535
- const bodyStart = index + 2;
2536
- const newline = source.indexOf("\n", bodyStart);
2537
- const end = newline === -1 ? source.length : newline;
2538
- for (let bodyIndex = bodyStart; bodyIndex < end; bodyIndex += 1) {
2539
- chars[bodyIndex] = " ";
2540
- }
2541
- index = end;
2542
- continue;
4014
+ // Masking now parses with TypeScript, which is orders of magnitude more
4015
+ // expensive than the character walk it replaced, and six scanners call this
4016
+ // helper once per candidate match the same file is masked thousands of
4017
+ // times in one run (measured: 1,872 calls for a 3.4k-line provider). Without
4018
+ // memoization that run regresses from ~8s to minutes and can exceed the CI
4019
+ // validation timeout. Successful results are cached by exact source text and
4020
+ // property-key mode; the cache stays small because a run only ever reads a
4021
+ // handful of files.
4022
+ // Parse failures are NOT cached: they throw and abort the check (fail-closed).
4023
+ const MASK_CACHE_LIMIT = 64;
4024
+ const maskCache = new Map<string, string>();
4025
+
4026
+ // Neutralize { } ( ) [ ], backtick, quotes, slash, backslash, comma, and semicolon
4027
+ // because scanners treat these as depth, string/regex delimiters, or top-level stops.
4028
+ const PRESERVED_PROPERTY_KEY_STRUCTURAL_CHARS = new Set([
4029
+ "{",
4030
+ "}",
4031
+ "(",
4032
+ ")",
4033
+ "[",
4034
+ "]",
4035
+ "`",
4036
+ '"',
4037
+ "'",
4038
+ "/",
4039
+ "\\",
4040
+ ",",
4041
+ ";",
4042
+ ]);
4043
+
4044
+ type MaskCommentsAndStringsOptions = {
4045
+ blankPropertyKeys?: boolean;
4046
+ };
4047
+
4048
+ export function maskCommentsAndStrings(
4049
+ source: string,
4050
+ fileName = "provider.ts",
4051
+ options: MaskCommentsAndStringsOptions = {},
4052
+ ): string {
4053
+ const blankPropertyKeys = options.blankPropertyKeys === true;
4054
+ const cacheKey = `${blankPropertyKeys ? "blank-keys" : "preserve-keys"}\0${source}`;
4055
+ const cached = maskCache.get(cacheKey);
4056
+ if (cached !== undefined) {
4057
+ return cached;
4058
+ }
4059
+ const masked = computeMaskedSource(source, fileName, blankPropertyKeys);
4060
+ if (maskCache.size >= MASK_CACHE_LIMIT) {
4061
+ const oldest = maskCache.keys().next();
4062
+ if (!oldest.done) {
4063
+ maskCache.delete(oldest.value);
2543
4064
  }
2544
- if (source.startsWith("/*", index)) {
2545
- const bodyStart = index + 2;
2546
- const close = source.indexOf("*/", bodyStart);
2547
- const end = close === -1 ? source.length : close;
2548
- for (let bodyIndex = bodyStart; bodyIndex < end; bodyIndex += 1) {
2549
- if (chars[bodyIndex] !== "\n") {
2550
- chars[bodyIndex] = " ";
2551
- }
4065
+ }
4066
+ maskCache.set(cacheKey, masked);
4067
+ return masked;
4068
+ }
4069
+
4070
+ function computeMaskedSource(
4071
+ source: string,
4072
+ fileName: string,
4073
+ blankPropertyKeys: boolean,
4074
+ ): string {
4075
+ const transpiled = ts.transpileModule(source, {
4076
+ // Declaration files trigger an internal TypeScript Debug Failure when
4077
+ // passed to transpileModule. Parsing is all we need here, so always use a
4078
+ // synthetic implementation filename while retaining the real filename
4079
+ // for source mapping and sanitized diagnostics below.
4080
+ fileName: "provider.ts",
4081
+ reportDiagnostics: true,
4082
+ compilerOptions: { target: ts.ScriptTarget.Latest },
4083
+ });
4084
+ const sourceFile = ts.createSourceFile(
4085
+ fileName,
4086
+ source,
4087
+ ts.ScriptTarget.Latest,
4088
+ true,
4089
+ ts.ScriptKind.TS,
4090
+ );
4091
+ const parseDiagnostic = transpiled.diagnostics?.[0];
4092
+ if (parseDiagnostic) {
4093
+ const position = parseDiagnostic.start ?? 0;
4094
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(position);
4095
+ throw new Error(
4096
+ `Cannot safely scan TypeScript source ${sanitizeDiagnosticFileName(fileName)} at ${line + 1}:${character + 1}: ${ts.flattenDiagnosticMessageText(parseDiagnostic.messageText, "\n")}`,
4097
+ );
4098
+ }
4099
+
4100
+ const chars = source.split("");
4101
+ const maskRange = (start: number, end: number): void => {
4102
+ for (let index = start; index < end; index += 1) {
4103
+ // Preserve the backslash of a string line continuation (\ followed
4104
+ // by LF, CRLF, or lone CR) and every line terminator, so masked
4105
+ // string bodies remain syntactically valid and the mask output can
4106
+ // be re-parsed (mask(mask(x)) === mask(x)).
4107
+ if (
4108
+ source[index] === "\\" &&
4109
+ (source[index + 1] === "\n" || source[index + 1] === "\r")
4110
+ ) {
4111
+ continue;
2552
4112
  }
2553
- index = close === -1 ? source.length : close + 1;
2554
- continue;
2555
- }
2556
- const quote = source[index];
2557
- if (quote !== '"' && quote !== "'" && quote !== "`") {
2558
- continue;
4113
+ if (chars[index] !== "\n" && chars[index] !== "\r") chars[index] = " ";
2559
4114
  }
2560
- const end = findStringEnd(source, index);
2561
- if (end === -1) {
2562
- break;
2563
- }
2564
- // Preserve quoted property keys ("response": ...) so range/key scanners
2565
- // can still match them; only string VALUES are blanked.
2566
- let probe = end + 1;
2567
- while (probe < source.length && /\s/.test(source[probe] ?? "")) {
2568
- probe += 1;
4115
+ };
4116
+ const commentRanges = new Map<string, ts.CommentRange>();
4117
+ const addCommentRanges = (ranges: readonly ts.CommentRange[] | undefined): void => {
4118
+ for (const range of ranges ?? []) {
4119
+ commentRanges.set(`${range.pos}:${range.end}`, range);
2569
4120
  }
2570
- if (source[probe] !== ":") {
2571
- for (let bodyIndex = index + 1; bodyIndex < end; bodyIndex += 1) {
2572
- if (chars[bodyIndex] !== "\n") {
2573
- chars[bodyIndex] = " ";
4121
+ };
4122
+
4123
+ const visit = (node: ts.Node): void => {
4124
+ addCommentRanges(ts.getLeadingCommentRanges(source, node.getFullStart()));
4125
+ addCommentRanges(ts.getTrailingCommentRanges(source, node.getEnd()));
4126
+
4127
+ const start = node.getStart(sourceFile);
4128
+ if (ts.isStringLiteral(node)) {
4129
+ // Preserve quoted property keys ("response": ...) by default so range/key
4130
+ // scanners can still match them. Line scanners opt into blanking key bodies
4131
+ // as well, preventing key text from looking like executable source.
4132
+ const isQuotedPropertyKey =
4133
+ ts.isPropertyAssignment(node.parent) && node.parent.name === node;
4134
+ if (blankPropertyKeys || !isQuotedPropertyKey) {
4135
+ maskRange(start + 1, node.end - 1);
4136
+ } else {
4137
+ for (let index = start + 1; index < node.end - 1; index += 1) {
4138
+ if (
4139
+ source[index] === "\\" &&
4140
+ (source[index + 1] === "\n" || source[index + 1] === "\r")
4141
+ ) {
4142
+ continue;
4143
+ }
4144
+ if (PRESERVED_PROPERTY_KEY_STRUCTURAL_CHARS.has(source[index] ?? "")) {
4145
+ chars[index] = " ";
4146
+ }
2574
4147
  }
2575
4148
  }
4149
+ } else if (ts.isRegularExpressionLiteral(node)) {
4150
+ let closeDelimiter = node.end - 1;
4151
+ while (closeDelimiter > start && /[A-Za-z]/.test(source[closeDelimiter] ?? "")) {
4152
+ closeDelimiter -= 1;
4153
+ }
4154
+ maskRange(start + 1, closeDelimiter);
4155
+ } else if (ts.isNoSubstitutionTemplateLiteral(node)) {
4156
+ maskRange(start + 1, node.end - 1);
4157
+ } else if (node.kind === ts.SyntaxKind.TemplateHead) {
4158
+ maskRange(start + 1, node.end - 2);
4159
+ } else if (node.kind === ts.SyntaxKind.TemplateMiddle) {
4160
+ maskRange(start + 1, node.end - 2);
4161
+ } else if (node.kind === ts.SyntaxKind.TemplateTail) {
4162
+ maskRange(start + 1, node.end - 1);
2576
4163
  }
2577
- index = end;
4164
+
4165
+ for (const child of node.getChildren(sourceFile)) visit(child);
4166
+ };
4167
+
4168
+ visit(sourceFile);
4169
+ for (const comment of commentRanges.values()) {
4170
+ const markerLength = 2;
4171
+ const bodyEnd =
4172
+ comment.kind === ts.SyntaxKind.MultiLineCommentTrivia
4173
+ ? comment.end - markerLength
4174
+ : comment.end;
4175
+ maskRange(comment.pos + markerLength, bodyEnd);
2578
4176
  }
2579
4177
  return chars.join("");
2580
4178
  }
2581
4179
 
4180
+ // Escapes every non-printable or formatting character before a submitter-
4181
+ // controlled filename is interpolated into a diagnostic that the CLI prints.
4182
+ // Category-based on purpose: enumerated code-point lists kept missing members
4183
+ // of the same class (C1 controls, U+061C, U+2028/U+2029 were each found
4184
+ // individually in review), so this escapes the whole Unicode categories —
4185
+ // Cc (controls), Cf (formatting, includes all Bidi_Control), Zl/Zp
4186
+ // (line/paragraph separators). Ordinary letters, digits, spaces, and CJK
4187
+ // filenames pass through unchanged.
4188
+ const DIAGNOSTIC_UNSAFE_CHARACTER = /\p{Cc}|\p{Cf}|\p{Zl}|\p{Zp}/u;
4189
+
4190
+ function sanitizeDiagnosticFileName(fileName: string): string {
4191
+ let sanitized = "";
4192
+ for (const character of fileName) {
4193
+ const codePoint = character.codePointAt(0);
4194
+ if (codePoint === undefined) continue;
4195
+ if (DIAGNOSTIC_UNSAFE_CHARACTER.test(character)) {
4196
+ sanitized +=
4197
+ codePoint <= 0xff
4198
+ ? `\\x${codePoint.toString(16).padStart(2, "0")}`
4199
+ : `\\u{${codePoint.toString(16)}}`;
4200
+ } else {
4201
+ sanitized += character;
4202
+ }
4203
+ }
4204
+ return sanitized;
4205
+ }
4206
+
2582
4207
  function skipWhitespaceAndComments(source: string, start: number, end: number): number {
2583
4208
  let index = start;
2584
4209
  while (index < end) {
@@ -3546,9 +5171,47 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
3546
5171
  ];
3547
5172
  }
3548
5173
 
5174
+ // Splits secret findings into still-active findings and acknowledged
5175
+ // `// @apifuse-allow secret-scan` overrides, mirroring partitionAllowOverrides
5176
+ // (same pragma placement: the finding line or the line directly above it).
5177
+ // Every finding source carries a line number (entropy candidates and located
5178
+ // SECRET_PATTERNS matches); a finding that somehow lacks one stays active
5179
+ // defensively.
5180
+ function partitionSecretScanAllowOverrides(
5181
+ providerRoot: string,
5182
+ findings: readonly SecretFinding[],
5183
+ ): { active: SecretFinding[]; overridden: SecretFinding[] } {
5184
+ const fileLineCache = new Map<string, string[]>();
5185
+ const active: SecretFinding[] = [];
5186
+ const overridden: SecretFinding[] = [];
5187
+
5188
+ for (const finding of findings) {
5189
+ if (finding.line === undefined) {
5190
+ active.push(finding);
5191
+ continue;
5192
+ }
5193
+ const absolute = resolve(providerRoot, finding.file);
5194
+ let lines = fileLineCache.get(absolute);
5195
+ if (lines === undefined) {
5196
+ lines = existsSync(absolute) ? readFileSync(absolute, "utf8").split(/\r?\n/) : [];
5197
+ fileLineCache.set(absolute, lines);
5198
+ }
5199
+ if (hasAllowOverride(lines, finding.line, "secret-scan")) {
5200
+ overridden.push(finding);
5201
+ } else {
5202
+ active.push(finding);
5203
+ }
5204
+ }
5205
+
5206
+ return { active, overridden };
5207
+ }
5208
+
3549
5209
  function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): SubmitCheck {
3550
- const findings = findSecretFindings(providerRoot, provider?.id);
3551
- const blockerFindings = findings.filter((finding) => finding.level !== "warn");
5210
+ const { active, overridden } = partitionSecretScanAllowOverrides(
5211
+ providerRoot,
5212
+ findSecretFindings(providerRoot, provider?.id),
5213
+ );
5214
+ const blockerFindings = active.filter((finding) => finding.level !== "warn");
3552
5215
  if (blockerFindings.length > 0) {
3553
5216
  return {
3554
5217
  id: "secret-scan",
@@ -3568,7 +5231,15 @@ function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): Subm
3568
5231
  ),
3569
5232
  };
3570
5233
  }
3571
- if (findings.length > 0) {
5234
+ if (active.length > 0 || overridden.length > 0) {
5235
+ const messageBase =
5236
+ active.length > 0
5237
+ ? "High-entropy source strings were found without secret-like identifier context; they may be false positives."
5238
+ : "Potential credential-like strings were found in shareable files.";
5239
+ const message =
5240
+ overridden.length > 0
5241
+ ? `${messageBase} ${overridden.length} acknowledged @apifuse-allow override(s).`
5242
+ : messageBase;
3572
5243
  return {
3573
5244
  id: "secret-scan",
3574
5245
  category: "security",
@@ -3576,11 +5247,10 @@ function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): Subm
3576
5247
  status: "warn",
3577
5248
  points: 8,
3578
5249
  maxPoints: CATEGORY_MAX_POINTS.security,
3579
- message:
3580
- "High-entropy source strings were found without secret-like identifier context; they may be false positives.",
5250
+ message,
3581
5251
  remediation:
3582
- 'Review the listed strings. If any are credentials, move them to env vars read via `ctx.env.get("APIFUSE__PROVIDER__<ID>__<NAME>")` and rotate the leaked credential; otherwise keep generated blobs in fixtures/tests or document why they are public.',
3583
- evidence: findings.map(
5252
+ 'Review the listed strings. If any are credentials, move them to env vars read via `ctx.env.get("APIFUSE__PROVIDER__<ID>__<NAME>")` and rotate the leaked credential; otherwise keep generated blobs in fixtures/tests or document why they are public with `// @apifuse-allow secret-scan: <reason>`.',
5253
+ evidence: [...active, ...overridden].map(
3584
5254
  (finding) =>
3585
5255
  finding.evidence ??
3586
5256
  `${finding.file}${finding.line ? `:${finding.line}` : ""}: ${finding.label}`,
@@ -3611,8 +5281,19 @@ function findSecretFindings(providerRoot: string, providerId = "<ID>"): SecretFi
3611
5281
  if (!existsSync(filePath)) continue;
3612
5282
  const content = readFileSync(filePath, "utf8");
3613
5283
  for (const [label, pattern] of SECRET_PATTERNS) {
3614
- if (pattern.test(content)) {
3615
- findings.push({ label, file: relativePath });
5284
+ // Locate every match to its line so pattern findings carry the line
5285
+ // information hasAllowOverride needs: `// @apifuse-allow secret-scan`
5286
+ // must behave uniformly across entropy findings and pattern findings.
5287
+ const globalPattern = new RegExp(
5288
+ pattern.source,
5289
+ pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
5290
+ );
5291
+ const seenLines = new Set<number>();
5292
+ for (const match of content.matchAll(globalPattern)) {
5293
+ const line = offsetToLine(content, match.index);
5294
+ if (seenLines.has(line)) continue;
5295
+ seenLines.add(line);
5296
+ findings.push({ label, file: relativePath, line });
3616
5297
  }
3617
5298
  }
3618
5299
  }
@@ -3670,7 +5351,7 @@ export function extractStringLiteralCandidates(line: string): string[] {
3670
5351
  continue;
3671
5352
  }
3672
5353
  if (char === quote) {
3673
- if (cursor - contentStart >= 20) {
5354
+ if (cursor - contentStart >= ENTROPY_CANDIDATE_MIN_LENGTH) {
3674
5355
  candidates.push(line.slice(contentStart, cursor));
3675
5356
  }
3676
5357
  index = cursor;
@@ -3695,6 +5376,25 @@ function classifyEntropyCandidate(input: {
3695
5376
  if (!charset) return undefined;
3696
5377
  const entropy = shannonEntropy(value);
3697
5378
  const secretishContext = SECRETISH_IDENTIFIER_PATTERN.test(input.line);
5379
+ // Word-like SCREAMING_SNAKE values (e.g. error-code constants such as
5380
+ // "AUTH_PASSWORD_LOGIN_CAPTCHA_REQUIRED") may contain secret-ish words
5381
+ // (AUTH/PASSWORD/...) in their own text and would otherwise be permanently
5382
+ // blocker-flagged. They are never skipped — entropy classification always
5383
+ // runs — but when the secret-ish context comes solely from identifier-
5384
+ // constant-shaped literal text (the line with those literals stripped
5385
+ // carries no secret-ish identifier), the finding is capped at a
5386
+ // non-blocking warning instead of a blocker. Stripping constant-shaped
5387
+ // siblings — not just the candidate — matters for lines holding several
5388
+ // constants (e.g. an ERROR_CODES array), while quoted property keys and
5389
+ // header names ("Authorization", "apiKey") stay visible as genuine
5390
+ // external context. Assignments to `apiKey`/`token`/`secret`-style names
5391
+ // still escalate to blockers via the identifier side, and
5392
+ // `// @apifuse-allow secret-scan` remains the reviewed way to silence the
5393
+ // warning.
5394
+ const selfContextOnlyConstant =
5395
+ secretishContext &&
5396
+ isScreamingSnakeConstantValue(value) &&
5397
+ !SECRETISH_IDENTIFIER_PATTERN.test(stripIdentifierConstantLiterals(input.line, value));
3698
5398
  const threshold = charset === "hex" ? 3.0 : secretishContext ? 4.0 : 4.5;
3699
5399
  if (entropy < threshold) return undefined;
3700
5400
 
@@ -3705,16 +5405,161 @@ function classifyEntropyCandidate(input: {
3705
5405
  charset === "hex"
3706
5406
  ? `high-entropy hex string (${entropy.toFixed(2)} bits/char)`
3707
5407
  : `high-entropy base64-like string (${entropy.toFixed(2)} bits/char)`;
5408
+ const contextNote = selfContextOnlyConstant
5409
+ ? "; identifier-like constant (downgraded to warning)"
5410
+ : secretishContext
5411
+ ? ""
5412
+ : "; may be a false positive";
3708
5413
  return {
3709
5414
  label,
3710
5415
  file: input.file,
3711
5416
  line: input.lineNumber,
3712
- level: secretishContext ? "blocker" : "warn",
5417
+ level: secretishContext && !selfContextOnlyConstant ? "blocker" : "warn",
3713
5418
  remediation: `Move ${location} to an env var read via \`ctx.env.get("${envName}")\` and rotate the leaked credential.`,
3714
- evidence: `${location}: ${label}; preview ${preview}${secretishContext ? "" : "; may be a false positive"}`,
5419
+ evidence: `${location}: ${label}; preview ${preview}${contextNote}`,
3715
5420
  };
3716
5421
  }
3717
5422
 
5423
+ // Word-like SCREAMING_SNAKE identifier shape: at least two underscore-
5424
+ // separated segments, each essentially pure alphabetic — letters optionally
5425
+ // followed by a SHORT digit suffix (at most 2, e.g. version markers like
5426
+ // "V2") — and at most 15% digits across the whole value. Dictionary-style
5427
+ // constants like "AUTH_PASSWORD_LOGIN_CAPTCHA_REQUIRED" or
5428
+ // "PROVIDER_CONTRACT_V2_REQUIRED" match; digit-heavy segmented material
5429
+ // (e.g. license/credential shapes like "ABCD1234_EFGH5678_IJKL9012"),
5430
+ // uppercase blobs ("XK9J_Q2ZP_M7VN"), and underscore-free hex-like values
5431
+ // ("A1B2C3D4...") do not. This shape gate never skips entropy classification;
5432
+ // it only decides whether a finding whose secret-ish context comes solely
5433
+ // from the literal's own text is downgraded from blocker to warning, so it
5434
+ // deliberately stays strict: values that merely contain a secret-ish word but
5435
+ // are not word-like constants keep full blocker severity.
5436
+ function isScreamingSnakeConstantValue(value: string): boolean {
5437
+ if (!/^[A-Z][A-Z0-9_]*$/.test(value) || !value.includes("_")) return false;
5438
+ const segments = value.split("_");
5439
+ if (segments.length < 2) return false;
5440
+ if (!segments.every((segment) => /^[A-Z]+[0-9]{0,2}$/.test(segment))) return false;
5441
+ const digitCount = value.match(/[0-9]/g)?.length ?? 0;
5442
+ return digitCount / value.length <= 0.15;
5443
+ }
5444
+
5445
+ type LineStringLiteral = {
5446
+ // Index of the opening quote.
5447
+ start: number;
5448
+ // Index just past the closing quote (line end when unterminated).
5449
+ end: number;
5450
+ content: string;
5451
+ closed: boolean;
5452
+ role: "key" | "value";
5453
+ // Nearest unclosed bracket enclosing the literal's start, if any.
5454
+ container?: { bracket: "[" | "(" | "{"; index: number };
5455
+ };
5456
+
5457
+ // Stable identity for the container a literal sits in ("top" when the
5458
+ // literal is not inside any bracket on the line).
5459
+ function literalContainerKey(literal: LineStringLiteral): string {
5460
+ return literal.container ? `${literal.container.bracket}${literal.container.index}` : "top";
5461
+ }
5462
+
5463
+ // Single-pass line tokenizer: extracts every string literal with its span and
5464
+ // classifies its syntactic role once. A literal is a KEY when it is preceded
5465
+ // (ignoring whitespace) by "{", ",", "(", or the line start AND followed
5466
+ // (ignoring whitespace) by ":" — i.e. it names the value next to it. Every
5467
+ // other literal is a VALUE: ternary arms (preceded by "?" or ":"), array
5468
+ // elements, call arguments, and assignment right-hand sides, even when a
5469
+ // ternary's ":" happens to follow them. Uses the same quote/escape walking as
5470
+ // extractStringLiteralCandidates.
5471
+ function tokenizeLineStringLiterals(line: string): LineStringLiteral[] {
5472
+ const literals: LineStringLiteral[] = [];
5473
+ const bracketStack: Array<{ bracket: "[" | "(" | "{"; index: number }> = [];
5474
+ let index = 0;
5475
+ while (index < line.length) {
5476
+ const char = line[index];
5477
+ if (char !== '"' && char !== "'" && char !== "`") {
5478
+ if (char === "[" || char === "(" || char === "{") {
5479
+ bracketStack.push({ bracket: char, index });
5480
+ } else if (char === "]" || char === ")" || char === "}") {
5481
+ bracketStack.pop();
5482
+ }
5483
+ index += 1;
5484
+ continue;
5485
+ }
5486
+ const quote = char;
5487
+ const start = index;
5488
+ const contentStart = index + 1;
5489
+ let cursor = contentStart;
5490
+ let closed = false;
5491
+ while (cursor < line.length) {
5492
+ const inner = line[cursor];
5493
+ if (inner === "\\") {
5494
+ cursor += 2;
5495
+ continue;
5496
+ }
5497
+ if (inner === quote) {
5498
+ closed = true;
5499
+ break;
5500
+ }
5501
+ cursor += 1;
5502
+ }
5503
+ const contentEnd = Math.min(cursor, line.length);
5504
+ const end = closed ? cursor + 1 : line.length;
5505
+ const before = line.slice(0, start).trimEnd();
5506
+ const keyPreceded = before === "" || /[{,(]$/.test(before);
5507
+ const keyFollowed = closed && /^\s*:/.test(line.slice(end));
5508
+ literals.push({
5509
+ start,
5510
+ end,
5511
+ content: line.slice(contentStart, contentEnd),
5512
+ closed,
5513
+ role: keyPreceded && keyFollowed ? "key" : "value",
5514
+ container: bracketStack[bracketStack.length - 1],
5515
+ });
5516
+ index = end;
5517
+ }
5518
+ return literals;
5519
+ }
5520
+
5521
+ // Builds the context text used to decide whether a candidate's secret-ish
5522
+ // context is genuine. The candidate's own literal is ALWAYS stripped
5523
+ // (self-context rule). SIBLING identifier-constant-shaped candidate literals
5524
+ // (VALUE role, SCREAMING_SNAKE shape, >= ENTROPY_CANDIDATE_MIN_LENGTH) are
5525
+ // stripped only when they share the candidate's non-call container — the same
5526
+ // `[...]` array, the same `{...}` object value list, or the bracket-free top
5527
+ // level (ternary arms) — so a value list of error codes cannot poison its own
5528
+ // members' context. Call-argument siblings (inside `(...)`) always keep their
5529
+ // context: in `headers.set("X_LONG_AUTH_TOKEN_NAME", "QWERTY_...")` the first
5530
+ // argument genuinely describes the second, so stripping it would erase real
5531
+ // auth/token context. KEY-role literals are never stripped.
5532
+ function stripIdentifierConstantLiterals(line: string, candidate: string): string {
5533
+ const literals = tokenizeLineStringLiterals(line);
5534
+ const candidateContainers = new Set<string>();
5535
+ for (const literal of literals) {
5536
+ if (literal.content === candidate) {
5537
+ candidateContainers.add(literalContainerKey(literal));
5538
+ }
5539
+ }
5540
+ let result = "";
5541
+ let previousEnd = 0;
5542
+ for (const literal of literals) {
5543
+ result += line.slice(previousEnd, literal.start);
5544
+ const isSelf = literal.content === candidate;
5545
+ const isSameNonCallContainerSibling =
5546
+ literal.role === "value" &&
5547
+ literal.content.length >= ENTROPY_CANDIDATE_MIN_LENGTH &&
5548
+ isScreamingSnakeConstantValue(literal.content) &&
5549
+ literal.container?.bracket !== "(" &&
5550
+ candidateContainers.has(literalContainerKey(literal));
5551
+ if (isSelf || isSameNonCallContainerSibling) {
5552
+ const quote = line[literal.start] ?? "";
5553
+ result += quote + (literal.closed ? quote : "");
5554
+ } else {
5555
+ result += line.slice(literal.start, literal.end);
5556
+ }
5557
+ previousEnd = literal.end;
5558
+ }
5559
+ result += line.slice(previousEnd);
5560
+ return result;
5561
+ }
5562
+
3718
5563
  function shouldConsiderEntropyValue(value: string): boolean {
3719
5564
  const lower = value.toLowerCase();
3720
5565
  if (/^(?:dev-only|local|example|sample|your-|replace|<)/i.test(value)) {
@@ -3730,7 +5575,7 @@ function shouldConsiderEntropyValue(value: string): boolean {
3730
5575
  if (lower.includes("/") && /\.[a-z0-9]{1,8}(?:$|[/?#])/i.test(value)) {
3731
5576
  return false;
3732
5577
  }
3733
- return value.length >= 20;
5578
+ return value.length >= ENTROPY_CANDIDATE_MIN_LENGTH;
3734
5579
  }
3735
5580
 
3736
5581
  function classifyEntropyCharset(value: string): "base64" | "hex" | undefined {
@@ -3753,8 +5598,9 @@ function shannonEntropy(value: string): number {
3753
5598
 
3754
5599
  function guessSecretName(line: string): string {
3755
5600
  const match =
3756
- /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line) ??
3757
- /["']?([A-Za-z_$][\w$-]*)["']?\s*:/.exec(line);
5601
+ /\b[A-Za-z_$][\w$]*(?:\s*\.\s*([A-Za-z_$][\w$]*))+\s*=/.exec(line) ??
5602
+ /["']?([A-Za-z_$][\w$-]*)["']?\s*:\s*["'`]/.exec(line) ??
5603
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line);
3758
5604
  const raw = match?.[1] ?? "SECRET";
3759
5605
  return raw
3760
5606
  .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
@@ -3764,6 +5610,40 @@ function guessSecretName(line: string): string {
3764
5610
 
3765
5611
  const SECRETISH_IDENTIFIER_PATTERN = /key|token|secret|password|credential|auth/i;
3766
5612
 
5613
+ // Minimum length for a string literal to be considered an entropy candidate.
5614
+ // Shared by candidate extraction, entropy screening, and the context strip so
5615
+ // the three stay coherent.
5616
+ const ENTROPY_CANDIDATE_MIN_LENGTH = 20;
5617
+
5618
+ // A secret-ish name is only supporting evidence. Shorter values must also look
5619
+ // credential-like, and known config tokens/numeric values are never candidates.
5620
+ const SECRET_NAMED_ENV_VALUE_MIN_LENGTH = 4;
5621
+ const SECRET_NAMED_ENV_UNSTRUCTURED_MIN_LENGTH = 8;
5622
+ const NON_SECRET_ENV_VALUE_TOKENS = new Set([
5623
+ "true",
5624
+ "false",
5625
+ "yes",
5626
+ "no",
5627
+ "on",
5628
+ "off",
5629
+ "none",
5630
+ "null",
5631
+ "nil",
5632
+ "auto",
5633
+ "default",
5634
+ "local",
5635
+ "debug",
5636
+ "info",
5637
+ "warn",
5638
+ "error",
5639
+ "always",
5640
+ "never",
5641
+ "enabled",
5642
+ "disabled",
5643
+ "0",
5644
+ "1",
5645
+ ]);
5646
+
3767
5647
  const SECRET_PATTERNS: Array<[string, RegExp]> = [
3768
5648
  ["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
3769
5649
  ["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],
@@ -3775,12 +5655,151 @@ const SECRET_PATTERNS: Array<[string, RegExp]> = [
3775
5655
  ],
3776
5656
  ];
3777
5657
 
3778
- async function safeLoadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
5658
+ type SafeLoadProviderResult =
5659
+ | { ok: true; provider: ProviderDefinition | undefined }
5660
+ | { ok: false; error: unknown };
5661
+
5662
+ async function safeLoadProvider(providerRoot: string): Promise<SafeLoadProviderResult> {
5663
+ try {
5664
+ return { ok: true, provider: await loadProvider(providerRoot) };
5665
+ } catch (error) {
5666
+ return { ok: false, error };
5667
+ }
5668
+ }
5669
+
5670
+ const UNRENDERABLE_LOAD_ERROR = "<thrown value could not be rendered>";
5671
+
5672
+ function formatLoadErrorEvidence(error: unknown, providerRoot: string): string[] {
5673
+ const evidence: string[] = [];
5674
+ const seen = new Set<object>();
5675
+ let current: unknown = error;
5676
+ let frame = 0;
5677
+ while (frame < MAX_LOAD_ERROR_EVIDENCE) {
5678
+ if (typeof current === "object" || typeof current === "function") {
5679
+ if (current !== null && seen.has(current)) {
5680
+ break;
5681
+ }
5682
+ if (current !== null) seen.add(current);
5683
+ }
5684
+ evidence.push(
5685
+ `${frame === 0 ? "Load error" : "Cause"}: ${sanitizeLoadErrorText(
5686
+ formatThrownValue(current),
5687
+ providerRoot,
5688
+ )}`,
5689
+ );
5690
+ frame += 1;
5691
+ const cause = readThrownValueCause(current);
5692
+ if (!cause.found || cause.value === undefined || cause.value === null) {
5693
+ break;
5694
+ }
5695
+ current = cause.value;
5696
+ }
5697
+ return evidence;
5698
+ }
5699
+
5700
+ function formatThrownValue(value: unknown): string {
3779
5701
  try {
3780
- return await loadProvider(providerRoot);
5702
+ if (value instanceof Error) {
5703
+ return String(value.message);
5704
+ }
5705
+ if (
5706
+ value !== null &&
5707
+ (typeof value === "object" || typeof value === "function") &&
5708
+ "message" in value
5709
+ ) {
5710
+ return String((value as { message?: unknown }).message);
5711
+ }
5712
+ return `<non-Error value thrown: ${String(value)}>`;
3781
5713
  } catch {
3782
- return undefined;
5714
+ return UNRENDERABLE_LOAD_ERROR;
5715
+ }
5716
+ }
5717
+
5718
+ function readThrownValueCause(value: unknown): { found: boolean; value?: unknown } {
5719
+ try {
5720
+ if (
5721
+ value === null ||
5722
+ (typeof value !== "object" && typeof value !== "function") ||
5723
+ !("cause" in value)
5724
+ ) {
5725
+ return { found: false };
5726
+ }
5727
+ return { found: true, value: (value as { cause?: unknown }).cause };
5728
+ } catch {
5729
+ return { found: false };
5730
+ }
5731
+ }
5732
+
5733
+ function isPlausibleSecretNamedEnvValue(value: string): boolean {
5734
+ const normalized = value.trim();
5735
+ if (normalized.length < SECRET_NAMED_ENV_VALUE_MIN_LENGTH) return false;
5736
+ if (NON_SECRET_ENV_VALUE_TOKENS.has(normalized.toLowerCase())) return false;
5737
+ if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(normalized)) return false;
5738
+ if (normalized.length >= SECRET_NAMED_ENV_UNSTRUCTURED_MIN_LENGTH) return true;
5739
+
5740
+ const hasLetter = /[A-Za-z]/.test(normalized);
5741
+ const hasDigitOrSymbol = /[^A-Za-z\s]/.test(normalized);
5742
+ return hasLetter && hasDigitOrSymbol;
5743
+ }
5744
+
5745
+ function replaceEnvValue(
5746
+ input: string,
5747
+ value: string,
5748
+ requireIdentifierBoundaries: boolean,
5749
+ ): string {
5750
+ if (!requireIdentifierBoundaries) return input.replaceAll(value, "[REDACTED]");
5751
+ const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5752
+ return input.replace(
5753
+ new RegExp(`(?<![A-Za-z0-9_])${escaped}(?![A-Za-z0-9_])`, "g"),
5754
+ "[REDACTED]",
5755
+ );
5756
+ }
5757
+
5758
+ function sanitizeLoadErrorText(value: string, providerRoot: string): string {
5759
+ let output = value.replace(
5760
+ /(^|[^A-Za-z0-9_.])((?:\/[^\s"'`]+)+)/g,
5761
+ (_match, prefix: string, rawPath: string) => {
5762
+ const pathCandidate = rawPath.replace(/[),.;:!?]+$/u, "");
5763
+ const relativePath = relative(providerRoot, resolve(pathCandidate));
5764
+ const insideProvider =
5765
+ relativePath === "" ||
5766
+ (relativePath !== ".." && !relativePath.startsWith(`..${sep}`));
5767
+ return `${prefix}${insideProvider ? relativePath || "." : "[REDACTED_PATH]"}`;
5768
+ },
5769
+ );
5770
+ output = output.replace(/[A-Za-z]:[\\/][^\s"'`]+/g, "[REDACTED_PATH]");
5771
+ output = output.replace(
5772
+ /((?:password|token|secret|api[_-]?key|credential|authorization)\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|["'`]?[^\s,;"'`]+)/gi,
5773
+ "$1[REDACTED]",
5774
+ );
5775
+ const envValues: Array<{ value: string; requireIdentifierBoundaries: boolean }> = [];
5776
+ for (const [name, item] of Object.entries(process.env)) {
5777
+ if (typeof item !== "string") continue;
5778
+ const secretNamed = SECRETISH_IDENTIFIER_PATTERN.test(name);
5779
+ let shouldRedact = secretNamed;
5780
+ if (secretNamed) {
5781
+ shouldRedact = isPlausibleSecretNamedEnvValue(item);
5782
+ } else if (item.length >= ENTROPY_CANDIDATE_MIN_LENGTH && shouldConsiderEntropyValue(item)) {
5783
+ const charset = classifyEntropyCharset(item);
5784
+ const threshold = charset === "hex" ? 3.0 : 4.5;
5785
+ shouldRedact = charset !== undefined && shannonEntropy(item) >= threshold;
5786
+ }
5787
+ if (shouldRedact) {
5788
+ envValues.push({
5789
+ value: item,
5790
+ requireIdentifierBoundaries:
5791
+ secretNamed && item.trim().length < ENTROPY_CANDIDATE_MIN_LENGTH,
5792
+ });
5793
+ }
5794
+ }
5795
+ envValues.sort((a, b) => b.value.length - a.value.length);
5796
+ for (const envValue of envValues) {
5797
+ output = replaceEnvValue(output, envValue.value, envValue.requireIdentifierBoundaries);
3783
5798
  }
5799
+ output = redact(output).replace(/\s+/g, " ");
5800
+ return output.length > MAX_LOAD_ERROR_MESSAGE_LENGTH
5801
+ ? `${output.slice(0, MAX_LOAD_ERROR_MESSAGE_LENGTH - 1)}…`
5802
+ : output;
3784
5803
  }
3785
5804
 
3786
5805
  async function loadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {