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

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 (303) hide show
  1. package/AUTHORING.md +501 -13
  2. package/CHANGELOG.md +165 -1
  3. package/README.md +72 -14
  4. package/SUBMISSION.md +1 -1
  5. package/bin/apifuse-check.ts +88 -4
  6. package/bin/apifuse-dev.ts +38 -5
  7. package/bin/apifuse-pack-check.ts +22 -2
  8. package/bin/apifuse-pack-smoke.ts +57 -2
  9. package/bin/apifuse-pack-types.ts +356 -38
  10. package/bin/apifuse-perf.ts +14 -13
  11. package/bin/apifuse-record.ts +691 -68
  12. package/bin/apifuse-submit-check.ts +518 -37
  13. package/bin/apifuse-sync-assets.ts +117 -0
  14. package/bin/submit-check-delimited-text.ts +50 -0
  15. package/dist/auth-turn/index.d.ts +3 -3
  16. package/dist/auth-turn/index.js +1 -1
  17. package/dist/auth.d.ts +14 -0
  18. package/dist/auth.js +67 -0
  19. package/dist/ceremonies/index.d.ts +16 -0
  20. package/dist/ceremonies/index.js +141 -36
  21. package/dist/cli/commands.d.ts +1 -1
  22. package/dist/cli/commands.js +8 -0
  23. package/dist/cli/create.d.ts +3 -0
  24. package/dist/cli/create.js +34 -35
  25. package/dist/cli/prompt-assets.d.ts +80 -0
  26. package/dist/cli/prompt-assets.js +743 -0
  27. package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
  28. package/dist/cli/templates/provider/Dockerfile.tpl +1 -1
  29. package/dist/cli/templates/provider/README.md.tpl +4 -4
  30. package/dist/cli/templates/provider/index.ts.tpl +6 -3
  31. package/dist/cli/templates/provider/operations/ping.ts.tpl +2 -1
  32. package/dist/config/loader.d.ts +177 -16
  33. package/dist/config/loader.js +424 -127
  34. package/dist/contract-serialization.js +4 -8
  35. package/dist/contract-types.d.ts +1 -0
  36. package/dist/contract.js +2 -0
  37. package/dist/declaration-validation.d.ts +32 -0
  38. package/dist/declaration-validation.js +207 -0
  39. package/dist/define.d.ts +51 -25
  40. package/dist/define.js +752 -38
  41. package/dist/error-resolution.d.ts +4 -0
  42. package/dist/error-resolution.js +122 -0
  43. package/dist/errors.d.ts +18 -0
  44. package/dist/errors.js +40 -0
  45. package/dist/fixture-sanitization.d.ts +28 -0
  46. package/dist/fixture-sanitization.js +217 -0
  47. package/dist/health-scenario.d.ts +1842 -0
  48. package/dist/health-scenario.js +624 -0
  49. package/dist/index.d.ts +19 -9
  50. package/dist/index.js +10 -6
  51. package/dist/lint.d.ts +6 -1
  52. package/dist/lint.js +362 -3
  53. package/dist/native-address.d.ts +43 -0
  54. package/dist/native-address.js +281 -0
  55. package/dist/native-egress-policy.d.ts +31 -0
  56. package/dist/native-egress-policy.js +288 -0
  57. package/dist/observability.d.ts +5 -2
  58. package/dist/observability.js +48 -1
  59. package/dist/provider.d.ts +8 -2
  60. package/dist/provider.js +3 -1
  61. package/dist/runtime/auth-flow.d.ts +5 -1
  62. package/dist/runtime/auth-flow.js +6 -0
  63. package/dist/runtime/browser.d.ts +1 -0
  64. package/dist/runtime/browser.js +492 -49
  65. package/dist/runtime/cache.d.ts +1 -0
  66. package/dist/runtime/cache.js +169 -15
  67. package/dist/runtime/choice-wordlist.d.ts +9 -0
  68. package/dist/runtime/choice-wordlist.js +138 -0
  69. package/dist/runtime/choice.d.ts +13 -1
  70. package/dist/runtime/choice.js +490 -102
  71. package/dist/runtime/executor.d.ts +2 -2
  72. package/dist/runtime/executor.js +26 -2
  73. package/dist/runtime/http.d.ts +1 -0
  74. package/dist/runtime/http.js +515 -53
  75. package/dist/runtime/instrumentation.d.ts +2 -2
  76. package/dist/runtime/instrumentation.js +366 -8
  77. package/dist/runtime/native-network-errors.d.ts +33 -0
  78. package/dist/runtime/native-network-errors.js +69 -0
  79. package/dist/runtime/native-network.d.ts +96 -0
  80. package/dist/runtime/native-network.js +1232 -0
  81. package/dist/runtime/ocr.d.ts +29 -0
  82. package/dist/runtime/ocr.js +440 -0
  83. package/dist/runtime/proxy-errors.js +6 -2
  84. package/dist/runtime/proxy-nodemaven.d.ts +56 -0
  85. package/dist/runtime/proxy-nodemaven.js +146 -0
  86. package/dist/runtime/proxy-telemetry.d.ts +80 -1
  87. package/dist/runtime/proxy-telemetry.js +154 -47
  88. package/dist/runtime/redirects.d.ts +29 -0
  89. package/dist/runtime/redirects.js +36 -0
  90. package/dist/runtime/redis.d.ts +1 -1
  91. package/dist/runtime/redis.js +4 -2
  92. package/dist/runtime/request-options.d.ts +68 -1
  93. package/dist/runtime/request-options.js +548 -0
  94. package/dist/runtime/resolver-config.d.ts +6 -0
  95. package/dist/runtime/resolver-config.js +6 -0
  96. package/dist/runtime/resolver-public.d.ts +1 -0
  97. package/dist/runtime/resolver-public.js +1 -0
  98. package/dist/runtime/resolver-shared.d.ts +3 -0
  99. package/dist/runtime/resolver-shared.js +12 -0
  100. package/dist/runtime/resolver-vendors/bindings.d.ts +48 -0
  101. package/dist/runtime/resolver-vendors/bindings.js +40 -0
  102. package/dist/runtime/resolver-vendors/browser.d.ts +22 -0
  103. package/dist/runtime/resolver-vendors/browser.js +377 -0
  104. package/dist/runtime/resolver-vendors/capsolver.d.ts +22 -0
  105. package/dist/runtime/resolver-vendors/capsolver.js +526 -0
  106. package/dist/runtime/resolver-vendors/hosts.d.ts +2 -0
  107. package/dist/runtime/resolver-vendors/hosts.js +33 -0
  108. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +24 -0
  109. package/dist/runtime/resolver-vendors/twocaptcha.js +407 -0
  110. package/dist/runtime/resolver-vendors/types.d.ts +94 -0
  111. package/dist/runtime/resolver-vendors/types.js +96 -0
  112. package/dist/runtime/resolver.d.ts +60 -0
  113. package/dist/runtime/resolver.js +737 -0
  114. package/dist/runtime/secrets.d.ts +27 -0
  115. package/dist/runtime/secrets.js +51 -0
  116. package/dist/runtime/state.d.ts +3 -0
  117. package/dist/runtime/state.js +277 -71
  118. package/dist/runtime/stealth-cookies.d.ts +20 -0
  119. package/dist/runtime/stealth-cookies.js +111 -0
  120. package/dist/runtime/stealth.d.ts +28 -3
  121. package/dist/runtime/stealth.js +519 -255
  122. package/dist/runtime/stt.js +1 -12
  123. package/dist/runtime/timeout.d.ts +5 -0
  124. package/dist/runtime/timeout.js +12 -0
  125. package/dist/runtime/trace-config.d.ts +12 -0
  126. package/dist/runtime/trace-config.js +61 -0
  127. package/dist/serve.d.ts +1 -1
  128. package/dist/serve.js +1 -1
  129. package/dist/server/index.d.ts +5 -3
  130. package/dist/server/index.js +3 -3
  131. package/dist/server/self-test-input-tokens.d.ts +2 -1
  132. package/dist/server/self-test-input-tokens.js +18 -14
  133. package/dist/server/self-test.d.ts +114 -0
  134. package/dist/server/self-test.js +784 -148
  135. package/dist/server/serve-implementation.d.ts +213 -0
  136. package/dist/server/serve-implementation.js +2173 -0
  137. package/dist/server/serve.d.ts +1 -70
  138. package/dist/server/serve.js +1 -1130
  139. package/dist/server/trace-output.d.ts +4 -0
  140. package/dist/server/trace-output.js +20 -0
  141. package/dist/server/types.d.ts +30 -5
  142. package/dist/server/types.js +13 -1
  143. package/dist/stateful/errors.d.ts +19 -0
  144. package/dist/stateful/errors.js +24 -0
  145. package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
  146. package/dist/stateful/http-provider-event-emitter.js +237 -0
  147. package/dist/stateful/http-session-owner-registry.d.ts +44 -0
  148. package/dist/stateful/http-session-owner-registry.js +210 -0
  149. package/dist/stateful/index.d.ts +18 -0
  150. package/dist/stateful/index.js +18 -0
  151. package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
  152. package/dist/stateful/provider-event-delivery-failures.js +43 -0
  153. package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
  154. package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
  155. package/dist/stateful/provider-event-pipeline.d.ts +50 -0
  156. package/dist/stateful/provider-event-pipeline.js +1 -0
  157. package/dist/stateful/provider-events.d.ts +101 -0
  158. package/dist/stateful/provider-events.js +289 -0
  159. package/dist/stateful/session-key.d.ts +15 -0
  160. package/dist/stateful/session-key.js +86 -0
  161. package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
  162. package/dist/stateful/stateful-provider-adapter-context.js +42 -0
  163. package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
  164. package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
  165. package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
  166. package/dist/stateful/stateful-provider-adapter.js +287 -0
  167. package/dist/stateful/stateful-provider-observability.d.ts +62 -0
  168. package/dist/stateful/stateful-provider-observability.js +161 -0
  169. package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
  170. package/dist/stateful/stateful-provider-owner-forwarder.js +215 -0
  171. package/dist/stateful/stateful-provider-runtime-context.d.ts +32 -0
  172. package/dist/stateful/stateful-provider-runtime-context.js +60 -0
  173. package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
  174. package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
  175. package/dist/stateful/stateful-provider-session-routing.d.ts +67 -0
  176. package/dist/stateful/stateful-provider-session-routing.js +345 -0
  177. package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
  178. package/dist/stateful/stateful-provider-session-runtime.js +245 -0
  179. package/dist/stateful-signing.d.ts +18 -0
  180. package/dist/stateful-signing.js +27 -0
  181. package/dist/stealth/profiles.js +16 -7
  182. package/dist/stream-evidence.d.ts +74 -0
  183. package/dist/stream-evidence.js +785 -0
  184. package/dist/stream.js +7 -1
  185. package/dist/testing/index.d.ts +2 -1
  186. package/dist/testing/index.js +2 -1
  187. package/dist/testing/run.d.ts +32 -2
  188. package/dist/testing/run.js +489 -21
  189. package/dist/trace-sanitization.d.ts +5 -0
  190. package/dist/trace-sanitization.js +45 -0
  191. package/dist/types.d.ts +545 -23
  192. package/dist/types.js +1 -0
  193. package/package.json +44 -5
  194. package/src/auth-turn/index.ts +1 -1
  195. package/src/auth.ts +118 -0
  196. package/src/ceremonies/index.ts +189 -46
  197. package/src/cli/commands.ts +10 -0
  198. package/src/cli/create.ts +42 -35
  199. package/src/cli/prompt-assets.ts +865 -0
  200. package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
  201. package/src/cli/templates/provider/Dockerfile.tpl +1 -1
  202. package/src/cli/templates/provider/README.md.tpl +4 -4
  203. package/src/cli/templates/provider/index.ts.tpl +6 -3
  204. package/src/cli/templates/provider/operations/ping.ts.tpl +2 -1
  205. package/src/config/loader.ts +665 -163
  206. package/src/contract-serialization.ts +5 -7
  207. package/src/contract-types.ts +1 -0
  208. package/src/contract.ts +2 -0
  209. package/src/declaration-validation.ts +266 -0
  210. package/src/define.ts +970 -87
  211. package/src/error-resolution.ts +127 -0
  212. package/src/errors.ts +52 -0
  213. package/src/fixture-sanitization.ts +248 -0
  214. package/src/health-scenario.ts +875 -0
  215. package/src/index.ts +204 -8
  216. package/src/lint.ts +408 -4
  217. package/src/native-address.ts +340 -0
  218. package/src/native-egress-policy.ts +358 -0
  219. package/src/observability.ts +51 -1
  220. package/src/provider.ts +133 -0
  221. package/src/runtime/auth-flow.ts +12 -0
  222. package/src/runtime/browser.ts +661 -63
  223. package/src/runtime/cache.ts +189 -14
  224. package/src/runtime/choice-wordlist.ts +145 -0
  225. package/src/runtime/choice.ts +631 -120
  226. package/src/runtime/executor.ts +40 -7
  227. package/src/runtime/http.ts +641 -61
  228. package/src/runtime/instrumentation.ts +520 -15
  229. package/src/runtime/native-network-errors.ts +99 -0
  230. package/src/runtime/native-network.ts +1605 -0
  231. package/src/runtime/ocr.ts +523 -0
  232. package/src/runtime/proxy-errors.ts +12 -4
  233. package/src/runtime/proxy-nodemaven.ts +221 -0
  234. package/src/runtime/proxy-telemetry.ts +244 -75
  235. package/src/runtime/redirects.ts +66 -0
  236. package/src/runtime/redis.ts +7 -2
  237. package/src/runtime/request-options.ts +680 -1
  238. package/src/runtime/resolver-config.ts +6 -0
  239. package/src/runtime/resolver-public.ts +20 -0
  240. package/src/runtime/resolver-shared.ts +17 -0
  241. package/src/runtime/resolver-vendors/bindings.ts +56 -0
  242. package/src/runtime/resolver-vendors/browser.ts +533 -0
  243. package/src/runtime/resolver-vendors/capsolver.ts +700 -0
  244. package/src/runtime/resolver-vendors/hosts.ts +38 -0
  245. package/src/runtime/resolver-vendors/twocaptcha.ts +539 -0
  246. package/src/runtime/resolver-vendors/types.ts +212 -0
  247. package/src/runtime/resolver.ts +1103 -0
  248. package/src/runtime/secrets.ts +64 -0
  249. package/src/runtime/state.ts +394 -77
  250. package/src/runtime/stealth-cookies.ts +132 -0
  251. package/src/runtime/stealth.ts +675 -289
  252. package/src/runtime/stt.ts +1 -19
  253. package/src/runtime/timeout.ts +18 -0
  254. package/src/runtime/trace-config.ts +77 -0
  255. package/src/serve.ts +6 -1
  256. package/src/server/index.ts +37 -2
  257. package/src/server/self-test-input-tokens.ts +29 -14
  258. package/src/server/self-test.ts +1025 -175
  259. package/src/server/serve-implementation.ts +3250 -0
  260. package/src/server/serve.ts +1 -1626
  261. package/src/server/trace-output.ts +32 -0
  262. package/src/server/types.ts +13 -1
  263. package/src/stateful/README.md +146 -0
  264. package/src/stateful/errors.ts +35 -0
  265. package/src/stateful/http-provider-event-emitter.ts +314 -0
  266. package/src/stateful/http-session-owner-registry.ts +306 -0
  267. package/src/stateful/index.ts +18 -0
  268. package/src/stateful/provider-event-delivery-failures.ts +80 -0
  269. package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
  270. package/src/stateful/provider-event-pipeline.ts +61 -0
  271. package/src/stateful/provider-events.ts +462 -0
  272. package/src/stateful/session-key.ts +111 -0
  273. package/src/stateful/stateful-provider-adapter-context.ts +59 -0
  274. package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
  275. package/src/stateful/stateful-provider-adapter.ts +562 -0
  276. package/src/stateful/stateful-provider-observability.ts +261 -0
  277. package/src/stateful/stateful-provider-owner-forwarder.ts +287 -0
  278. package/src/stateful/stateful-provider-runtime-context.ts +92 -0
  279. package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
  280. package/src/stateful/stateful-provider-session-routing.ts +546 -0
  281. package/src/stateful/stateful-provider-session-runtime.ts +403 -0
  282. package/src/stateful-signing.ts +46 -0
  283. package/src/stealth/profiles.ts +17 -7
  284. package/src/stream-evidence.ts +988 -0
  285. package/src/stream.ts +8 -1
  286. package/src/testing/index.ts +10 -1
  287. package/src/testing/run.ts +658 -15
  288. package/src/trace-sanitization.ts +63 -0
  289. package/src/types.ts +660 -38
  290. package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
  291. package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
  292. /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  293. /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  294. /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  295. /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  296. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  297. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
  298. /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  299. /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  300. /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  301. /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  302. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  303. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
@@ -11,15 +11,21 @@ import * as acorn from "acorn";
11
11
  import { z } from "zod";
12
12
 
13
13
  import packageJson from "../package.json";
14
- import type { ProviderDefinition } from "../src/index.js";
14
+ import { formatPromptAssetIssues, verifyPromptAssets } from "../src/cli/prompt-assets.js";
15
15
  import {
16
16
  loadProviderLocaleCatalogs,
17
17
  type ProviderLocale,
18
18
  validateProviderLocaleCatalogs,
19
19
  } 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";
20
+ import type { ProviderDefinition } from "../src/index.js";
21
+ import { APIFUSE_DESCRIPTION_KEY_META_KEY, safeParseSchemaSync } from "../src/schema.js";
22
+ import {
23
+ findStreamCaptureGroup,
24
+ hasStreamEvidenceMarker,
25
+ parseStreamEvidenceRecord,
26
+ } from "../src/stream-evidence.js";
27
+ import { type CheckResult, PROMPT_ASSETS_CHECK_MESSAGE, runChecks } from "./apifuse-check.js";
28
+ import { hasSubstantiveDelimitedTextStructure } from "./submit-check-delimited-text.js";
23
29
  import { hasSubstantiveXmlStructure } from "./submit-check-xml.js";
24
30
 
25
31
  const TIERS = ["bronze", "silver", "gold", "diamond"] as const;
@@ -267,7 +273,15 @@ export async function buildSubmitCheckReport(
267
273
  const baseChecks = await safeRunChecks(providerRoot);
268
274
  const provider = await safeLoadProvider(providerRoot);
269
275
 
270
- checks.push(...scoreBaseChecks(baseChecks));
276
+ // Prompt-asset freshness is reported by its own dedicated zero-point
277
+ // blocker below; filter the base-check duplicate so it is not double
278
+ // penalized under the definition category.
279
+ checks.push(
280
+ ...scoreBaseChecks(
281
+ baseChecks.filter((result) => result.message !== PROMPT_ASSETS_CHECK_MESSAGE),
282
+ ),
283
+ );
284
+ checks.push(scorePromptAssetFreshness(providerRoot));
271
285
  checks.push(scoreProviderIdSlug(providerRoot, provider));
272
286
  checks.push(scoreNoVendorShim(providerRoot));
273
287
  checks.push(scoreNoVendorImport(providerRoot));
@@ -283,6 +297,7 @@ export async function buildSubmitCheckReport(
283
297
  if (provider) {
284
298
  const smokeResult = args.smoke ? await runSubmitCheckSmoke(providerRoot, provider) : undefined;
285
299
  checks.push(scoreCredentialUsage(providerRoot, provider));
300
+ checks.push(scoreSdkOwnedSecretPresence(providerRoot, provider));
286
301
  checks.push(scoreLocaleCatalog(providerRoot, provider));
287
302
  checks.push(scoreOperationMetadata(provider));
288
303
  checks.push(scoreFixtureCoverage(provider));
@@ -1181,18 +1196,31 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1181
1196
  );
1182
1197
  }
1183
1198
 
1184
- // Scope the scan to the argument of the EXPORTED `defineProvider(...)` call.
1199
+ // Scope the scan to the exported provider builder's implementation argument,
1200
+ // or to the legacy-looking declaration call used by structural test fixtures.
1185
1201
  // A provider can contain helper/non-exported defineProvider calls before the
1186
1202
  // real default export (e.g. test scaffolds), so resolve the default export
1187
1203
  // 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
1204
+ // 1. `export default <builder>(` — phase-separated provider
1205
+ // 2. `export default defineProvider(` structural fixture
1206
+ // 3. `export default <ident>` then `const <ident> = defineProvider(`
1207
+ // 4. fallback: first `defineProvider(` in the file
1191
1208
  let defineParenIndex = -1;
1209
+ const builderDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*\(/.exec(source);
1210
+ if (builderDefault?.[1] !== undefined && builderDefault[1] !== "defineProvider") {
1211
+ defineParenIndex = builderDefault.index + builderDefault[0].length - 1;
1212
+ }
1192
1213
  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 {
1214
+ if (defineParenIndex === -1 && inlineDefault) {
1215
+ const declarationParen = inlineDefault.index + inlineDefault[0].length - 1;
1216
+ const declarationStart = declarationParen + 1;
1217
+ const declaration = balancedValueExpression(source, declarationStart);
1218
+ let cursor = declarationStart + declaration.length;
1219
+ while (/\s/.test(source[cursor] ?? "")) cursor++;
1220
+ if (source[cursor] === ")") cursor++;
1221
+ while (/\s/.test(source[cursor] ?? "")) cursor++;
1222
+ defineParenIndex = source[cursor] === "(" ? cursor : declarationParen;
1223
+ } else if (defineParenIndex === -1) {
1196
1224
  const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(source);
1197
1225
  const exportedName = namedDefault?.[1];
1198
1226
  if (exportedName !== undefined) {
@@ -1221,7 +1249,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1221
1249
  const argStart = defineParenIndex + 1;
1222
1250
  const argText = balancedValueExpression(source, argStart);
1223
1251
 
1224
- // Resolve the value passed as `operations:` inside the defineProvider call,
1252
+ // Resolve the value passed as `operations:` inside the implementation call,
1225
1253
  // following one alias hop. The value is classified as a static object
1226
1254
  // literal (pass) or a factory/call expression (block). The regex index is
1227
1255
  // offset back into the full source so line numbers stay accurate.
@@ -1234,7 +1262,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1234
1262
  opsLine = offsetToLine(source, valueStart);
1235
1263
  }
1236
1264
 
1237
- // Property shorthand: `defineProvider({ ..., operations })` — resolve the
1265
+ // Property shorthand: `buildProvider({ operations })` — resolve the
1238
1266
  // local `operations` const initializer.
1239
1267
  let aliasName: string | undefined;
1240
1268
  if (opsValue === undefined) {
@@ -1380,7 +1408,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1380
1408
  blockerMessage:
1381
1409
  "defineProvider operations are composed by a factory call instead of a static object literal.",
1382
1410
  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>`.",
1411
+ "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
1412
  passMessage: "defineProvider declares operations as a static object literal.",
1385
1413
  });
1386
1414
  }
@@ -1424,6 +1452,159 @@ function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition
1424
1452
  );
1425
1453
  }
1426
1454
 
1455
+ // ---------------------------------------------------------------------------
1456
+ // sdk-owned-secret-presence (warn): provider-local double validation of
1457
+ // declared env secrets.
1458
+ //
1459
+ // The SDK runtime is the single source of truth for secret presence: declared
1460
+ // `required: true` secrets are validated before every handler/auth-flow
1461
+ // invocation and fail with the canonical structured MISSING_SECRET error
1462
+ // (HTTP 400, category credential_unavailable). Provider-local presence guards
1463
+ // (requireServiceKey/requireApiKey style) are dead weight that historically
1464
+ // diverged into inconsistent shapes (CONFIGURATION_ERROR vs MISSING_SECRET,
1465
+ // with/without category), which broke uniform incident attribution when nine
1466
+ // providers shipped with unprovisioned secrets (2026-07-22).
1467
+ //
1468
+ // Heuristic, warn-only: a line reading a declared `required: true` secret via
1469
+ // `.env.get(...)` (string literal or a const alias of a declared name)
1470
+ // followed within a small window by a falsy presence check plus a `throw`.
1471
+ // The rule flags duplication of the SDK gate ONLY: env names that are not
1472
+ // declared in defineProvider secrets[], and optional declarations
1473
+ // (`required: false`/omitted) that the runtime deliberately does not enforce,
1474
+ // are out of scope. Escape hatch:
1475
+ // `// @apifuse-allow sdk-owned-secret-presence: <reason>`.
1476
+ // ---------------------------------------------------------------------------
1477
+
1478
+ const SDK_OWNED_SECRET_PRESENCE_RULE_ID = "sdk-owned-secret-presence";
1479
+ const SECRET_PRESENCE_GUARD_LOOKAHEAD_LINES = 10;
1480
+
1481
+ const ENV_GET_CALL_PATTERN =
1482
+ /\.env\.get\(\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`|([A-Za-z_$][\w$]*))\s*\)/;
1483
+
1484
+ const SECRET_ALIAS_CONST_PATTERN =
1485
+ /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`)/g;
1486
+
1487
+ const ENV_GET_ASSIGNMENT_PATTERN = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=[^;]*\.env\.get\(/;
1488
+
1489
+ // Const aliases of declared secret names (e.g. `const SERVICE_KEY_ENV =
1490
+ // "APIFUSE__PROVIDER__X__SERVICE_KEY"`) so aliased `.env.get(SERVICE_KEY_ENV)`
1491
+ // guards are detected, not just direct string literals.
1492
+ function buildDeclaredSecretAliasMap(
1493
+ providerRoot: string,
1494
+ declaredNames: ReadonlySet<string>,
1495
+ ): Map<string, string> {
1496
+ const aliases = new Map<string, string>();
1497
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
1498
+ const content = readFileSync(filePath, "utf8");
1499
+ for (const match of content.matchAll(SECRET_ALIAS_CONST_PATTERN)) {
1500
+ const alias = match[1];
1501
+ const name = match[2] ?? match[3] ?? match[4];
1502
+ if (alias && name && declaredNames.has(name)) {
1503
+ aliases.set(alias, name);
1504
+ }
1505
+ }
1506
+ }
1507
+ return aliases;
1508
+ }
1509
+
1510
+ function hasLocalSecretPresenceGuard(
1511
+ line: string,
1512
+ remainingLines: readonly string[],
1513
+ declaredNames: ReadonlySet<string>,
1514
+ aliases: ReadonlyMap<string, string>,
1515
+ ): boolean {
1516
+ const match = ENV_GET_CALL_PATTERN.exec(line);
1517
+ if (!match) {
1518
+ return false;
1519
+ }
1520
+ const literal = match[1] ?? match[2] ?? match[3];
1521
+ const identifier = match[4];
1522
+ const readsDeclaredSecret =
1523
+ literal !== undefined
1524
+ ? declaredNames.has(literal)
1525
+ : identifier !== undefined && aliases.has(identifier);
1526
+ if (!readsDeclaredSecret) {
1527
+ return false;
1528
+ }
1529
+
1530
+ const window = [line, ...remainingLines.slice(0, SECRET_PRESENCE_GUARD_LOOKAHEAD_LINES)];
1531
+ if (!window.some((candidate) => /\bthrow\b/.test(candidate))) {
1532
+ return false;
1533
+ }
1534
+
1535
+ // Assigned read (`const key = ctx.env.get(...)`): only a falsy/undefined
1536
+ // check on THAT variable counts as a presence guard. Anchoring on the
1537
+ // assigned identifier avoids false positives from unrelated guards/throws
1538
+ // that merely sit near the env read (mirrors the aliased runtime-guard rule).
1539
+ const assigned = ENV_GET_ASSIGNMENT_PATTERN.exec(line)?.[1];
1540
+ if (assigned) {
1541
+ const escaped = assigned.replace(/\$/g, "\\$");
1542
+ const guardPattern = new RegExp(
1543
+ `(?:!\\s*${escaped}\\b|\\b${escaped}\\s*===?\\s*(?:undefined|null)\\b|\\b${escaped}\\s*==\\s*null\\b|\\b${escaped}(?:\\?\\.|\\.)length\\s*===?\\s*0\\b)`,
1544
+ );
1545
+ return window.some((candidate) => guardPattern.test(candidate));
1546
+ }
1547
+
1548
+ // Un-assigned read: only an inline presence check on the same line counts,
1549
+ // e.g. `if (!ctx.env.get(KEY)) throw ...`.
1550
+ return /(?:if\s*\(\s*!|===?\s*undefined\b|==\s*null\b)/.test(line);
1551
+ }
1552
+
1553
+ function scoreSdkOwnedSecretPresence(
1554
+ providerRoot: string,
1555
+ provider: ProviderDefinition,
1556
+ ): SubmitCheck {
1557
+ const passMessage = "Provider relies on SDK-owned secret presence validation.";
1558
+ // Only `required: true` declarations: those are exactly what the runtime
1559
+ // gate enforces. A presence guard over an optional secret is conditional
1560
+ // business logic the SDK will not replace, not double validation.
1561
+ const declaredNames: ReadonlySet<string> = new Set(
1562
+ (provider.secrets ?? [])
1563
+ .filter((secret) => secret.required === true)
1564
+ .map((secret) => secret.name),
1565
+ );
1566
+ if (declaredNames.size === 0) {
1567
+ return pass(SDK_OWNED_SECRET_PRESENCE_RULE_ID, SDK_NATIVE_CATEGORY, passMessage, 0);
1568
+ }
1569
+
1570
+ const aliases = buildDeclaredSecretAliasMap(providerRoot, declaredNames);
1571
+ const findings = findSourceFindings(providerRoot, (line, remainingLines) =>
1572
+ hasLocalSecretPresenceGuard(line, remainingLines, declaredNames, aliases),
1573
+ );
1574
+ if (findings.length === 0) {
1575
+ return pass(SDK_OWNED_SECRET_PRESENCE_RULE_ID, SDK_NATIVE_CATEGORY, passMessage, 0);
1576
+ }
1577
+
1578
+ const { violations, overridden } = partitionAllowOverrides(
1579
+ providerRoot,
1580
+ findings,
1581
+ SDK_OWNED_SECRET_PRESENCE_RULE_ID,
1582
+ );
1583
+ if (violations.length === 0) {
1584
+ return pass(
1585
+ SDK_OWNED_SECRET_PRESENCE_RULE_ID,
1586
+ SDK_NATIVE_CATEGORY,
1587
+ `${passMessage} ${overridden.length} acknowledged @apifuse-allow override(s).`,
1588
+ 0,
1589
+ formatSourceFindings(overridden),
1590
+ );
1591
+ }
1592
+
1593
+ return {
1594
+ id: SDK_OWNED_SECRET_PRESENCE_RULE_ID,
1595
+ category: SDK_NATIVE_CATEGORY,
1596
+ level: "warn",
1597
+ status: "warn",
1598
+ points: 0,
1599
+ maxPoints: 0,
1600
+ message:
1601
+ "Provider source re-validates declared env secret presence locally; the SDK owns this check.",
1602
+ remediation:
1603
+ "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>`.",
1604
+ evidence: formatSourceFindings(violations),
1605
+ };
1606
+ }
1607
+
1427
1608
  function findSourceLineMatches(
1428
1609
  providerRoot: string,
1429
1610
  pattern: RegExp | ((line: string) => boolean),
@@ -1523,8 +1704,25 @@ function isScannableProviderSourceFile(relativePath: string): boolean {
1523
1704
  );
1524
1705
  }
1525
1706
 
1707
+ const EXCLUDED_SOURCE_DIRECTORY_SEGMENTS = new Set([
1708
+ ".git",
1709
+ ".worktree",
1710
+ "node_modules",
1711
+ "dist",
1712
+ "build",
1713
+ "coverage",
1714
+ ]);
1715
+
1716
+ // `.agents`/`.apifuse` stay IN scope on purpose: managed content there is
1717
+ // markdown/JSON (never scannable), while a planted `.ts`/`.sh` under those
1718
+ // directories must not become a scan-exempt hiding place for secrets, raw
1719
+ // fetch, or vendor imports. Nested dependency/build output directories under
1720
+ // them use the same segment exclusions; `.worktree` is excluded because task
1721
+ // worktrees are duplicate repository checkouts, not provider source.
1526
1722
  function shouldScanSourceDirectory(relativePath: string): boolean {
1527
- return ![".git", "node_modules", "dist", "build", "coverage"].includes(relativePath);
1723
+ return relativePath
1724
+ .split("/")
1725
+ .every((segment) => !EXCLUDED_SOURCE_DIRECTORY_SEGMENTS.has(segment));
1528
1726
  }
1529
1727
 
1530
1728
  function isExcludedTestSource(relativePath: string): boolean {
@@ -1545,6 +1743,27 @@ function formatSourceFindings(findings: readonly SourceFinding[]): string[] {
1545
1743
  return findings.map((finding) => `${finding.file}:${finding.line}`);
1546
1744
  }
1547
1745
 
1746
+ function scorePromptAssetFreshness(providerRoot: string): SubmitCheck {
1747
+ const verification = verifyPromptAssets(providerRoot);
1748
+ if (verification.ok) {
1749
+ return pass(
1750
+ "prompt-assets-fresh",
1751
+ "docs",
1752
+ "SDK-managed agent prompt assets match the installed SDK version.",
1753
+ 0,
1754
+ );
1755
+ }
1756
+
1757
+ return blocker(
1758
+ "prompt-assets-fresh",
1759
+ "docs",
1760
+ "SDK-managed agent prompt assets are missing, stale, or modified.",
1761
+ "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.",
1762
+ 0,
1763
+ formatPromptAssetIssues(verification),
1764
+ );
1765
+ }
1766
+
1548
1767
  function scoreRepositoryDx(providerRoot: string): SubmitCheck {
1549
1768
  const missing: string[] = [];
1550
1769
  if (!existsSync(resolve(providerRoot, ".gitignore"))) {
@@ -1983,11 +2202,16 @@ const GENERATED_LOCAL_ONLY_SCAFFOLD_REASON = /generated local-only scaffold/i;
1983
2202
  function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
1984
2203
  const rawPath = resolve(providerRoot, "__fixtures__", "raw.json");
1985
2204
  let hasRecordedEvidence = false;
2205
+ let fixtureValidationError: string | undefined;
1986
2206
  if (existsSync(rawPath)) {
1987
2207
  try {
1988
- hasRecordedEvidence = hasNonEmptyRecordedFixture(JSON.parse(readFileSync(rawPath, "utf8")));
1989
- } catch {
2208
+ hasRecordedEvidence = recordedFixtureStats(
2209
+ JSON.parse(readFileSync(rawPath, "utf8")),
2210
+ 0,
2211
+ ).hasNestedSubstance;
2212
+ } catch (error) {
1990
2213
  hasRecordedEvidence = false;
2214
+ fixtureValidationError = error instanceof Error ? error.message : String(error);
1991
2215
  }
1992
2216
  }
1993
2217
 
@@ -1999,6 +2223,16 @@ function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefiniti
1999
2223
  0,
2000
2224
  );
2001
2225
  }
2226
+ if (fixtureValidationError) {
2227
+ return blocker(
2228
+ "fixture-provenance",
2229
+ "fixtures",
2230
+ `Malformed recorded fixture evidence in __fixtures__/raw.json: ${fixtureValidationError}`,
2231
+ "Re-run `bun run record` to replace the malformed stream evidence, or repair the named field using the stream evidence contract.",
2232
+ 0,
2233
+ ["__fixtures__/raw.json"],
2234
+ );
2235
+ }
2002
2236
 
2003
2237
  if (allOperationsAreGeneratedLocalScaffold(provider)) {
2004
2238
  return {
@@ -2026,14 +2260,32 @@ function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefiniti
2026
2260
  );
2027
2261
  }
2028
2262
 
2029
- function hasNonEmptyRecordedFixture(value: unknown): boolean {
2030
- return recordedFixtureStats(value, 0).hasNestedSubstance;
2263
+ export function hasNonEmptyRecordedFixture(value: unknown): boolean {
2264
+ try {
2265
+ return recordedFixtureStats(value, 0).hasNestedSubstance;
2266
+ } catch {
2267
+ return false;
2268
+ }
2031
2269
  }
2032
2270
 
2033
2271
  function recordedFixtureStats(
2034
2272
  value: unknown,
2035
2273
  depth: number,
2036
2274
  ): { hasNestedSubstance: boolean; leafValues: number } {
2275
+ if (
2276
+ value !== null &&
2277
+ typeof value === "object" &&
2278
+ !Array.isArray(value) &&
2279
+ (value as Record<string, unknown>).__apifuse_capture__ === true
2280
+ ) {
2281
+ const group = findStreamCaptureGroup(value);
2282
+ if (!group) throw new Error("Stream capture envelope is invalid.");
2283
+ return { hasNestedSubstance: true, leafValues: group.items.length };
2284
+ }
2285
+ if (hasStreamEvidenceMarker(value)) {
2286
+ parseStreamEvidenceRecord(value);
2287
+ return { hasNestedSubstance: true, leafValues: 1 };
2288
+ }
2037
2289
  if (value === null || value === undefined) {
2038
2290
  return { hasNestedSubstance: false, leafValues: 0 };
2039
2291
  }
@@ -2068,10 +2320,14 @@ function recordedFixtureStats(
2068
2320
  if (value.length === 0) {
2069
2321
  return { hasNestedSubstance: false, leafValues: 0 };
2070
2322
  }
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 };
2323
+ // A recorded operation value may be a raw XML or delimited-text success
2324
+ // payload. Treat a substantive one as nested evidence while still counting
2325
+ // the string as a leaf so existing JSON provenance heuristics are unchanged.
2326
+ return {
2327
+ hasNestedSubstance:
2328
+ hasSubstantiveXmlStructure(value) || hasSubstantiveDelimitedTextStructure(value),
2329
+ leafValues: 1,
2330
+ };
2075
2331
  }
2076
2332
  return { hasNestedSubstance: false, leafValues: 1 };
2077
2333
  }
@@ -3546,9 +3802,47 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
3546
3802
  ];
3547
3803
  }
3548
3804
 
3805
+ // Splits secret findings into still-active findings and acknowledged
3806
+ // `// @apifuse-allow secret-scan` overrides, mirroring partitionAllowOverrides
3807
+ // (same pragma placement: the finding line or the line directly above it).
3808
+ // Every finding source carries a line number (entropy candidates and located
3809
+ // SECRET_PATTERNS matches); a finding that somehow lacks one stays active
3810
+ // defensively.
3811
+ function partitionSecretScanAllowOverrides(
3812
+ providerRoot: string,
3813
+ findings: readonly SecretFinding[],
3814
+ ): { active: SecretFinding[]; overridden: SecretFinding[] } {
3815
+ const fileLineCache = new Map<string, string[]>();
3816
+ const active: SecretFinding[] = [];
3817
+ const overridden: SecretFinding[] = [];
3818
+
3819
+ for (const finding of findings) {
3820
+ if (finding.line === undefined) {
3821
+ active.push(finding);
3822
+ continue;
3823
+ }
3824
+ const absolute = resolve(providerRoot, finding.file);
3825
+ let lines = fileLineCache.get(absolute);
3826
+ if (lines === undefined) {
3827
+ lines = existsSync(absolute) ? readFileSync(absolute, "utf8").split(/\r?\n/) : [];
3828
+ fileLineCache.set(absolute, lines);
3829
+ }
3830
+ if (hasAllowOverride(lines, finding.line, "secret-scan")) {
3831
+ overridden.push(finding);
3832
+ } else {
3833
+ active.push(finding);
3834
+ }
3835
+ }
3836
+
3837
+ return { active, overridden };
3838
+ }
3839
+
3549
3840
  function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): SubmitCheck {
3550
- const findings = findSecretFindings(providerRoot, provider?.id);
3551
- const blockerFindings = findings.filter((finding) => finding.level !== "warn");
3841
+ const { active, overridden } = partitionSecretScanAllowOverrides(
3842
+ providerRoot,
3843
+ findSecretFindings(providerRoot, provider?.id),
3844
+ );
3845
+ const blockerFindings = active.filter((finding) => finding.level !== "warn");
3552
3846
  if (blockerFindings.length > 0) {
3553
3847
  return {
3554
3848
  id: "secret-scan",
@@ -3568,7 +3862,15 @@ function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): Subm
3568
3862
  ),
3569
3863
  };
3570
3864
  }
3571
- if (findings.length > 0) {
3865
+ if (active.length > 0 || overridden.length > 0) {
3866
+ const messageBase =
3867
+ active.length > 0
3868
+ ? "High-entropy source strings were found without secret-like identifier context; they may be false positives."
3869
+ : "Potential credential-like strings were found in shareable files.";
3870
+ const message =
3871
+ overridden.length > 0
3872
+ ? `${messageBase} ${overridden.length} acknowledged @apifuse-allow override(s).`
3873
+ : messageBase;
3572
3874
  return {
3573
3875
  id: "secret-scan",
3574
3876
  category: "security",
@@ -3576,11 +3878,10 @@ function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): Subm
3576
3878
  status: "warn",
3577
3879
  points: 8,
3578
3880
  maxPoints: CATEGORY_MAX_POINTS.security,
3579
- message:
3580
- "High-entropy source strings were found without secret-like identifier context; they may be false positives.",
3881
+ message,
3581
3882
  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(
3883
+ '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>`.',
3884
+ evidence: [...active, ...overridden].map(
3584
3885
  (finding) =>
3585
3886
  finding.evidence ??
3586
3887
  `${finding.file}${finding.line ? `:${finding.line}` : ""}: ${finding.label}`,
@@ -3611,8 +3912,19 @@ function findSecretFindings(providerRoot: string, providerId = "<ID>"): SecretFi
3611
3912
  if (!existsSync(filePath)) continue;
3612
3913
  const content = readFileSync(filePath, "utf8");
3613
3914
  for (const [label, pattern] of SECRET_PATTERNS) {
3614
- if (pattern.test(content)) {
3615
- findings.push({ label, file: relativePath });
3915
+ // Locate every match to its line so pattern findings carry the line
3916
+ // information hasAllowOverride needs: `// @apifuse-allow secret-scan`
3917
+ // must behave uniformly across entropy findings and pattern findings.
3918
+ const globalPattern = new RegExp(
3919
+ pattern.source,
3920
+ pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
3921
+ );
3922
+ const seenLines = new Set<number>();
3923
+ for (const match of content.matchAll(globalPattern)) {
3924
+ const line = offsetToLine(content, match.index);
3925
+ if (seenLines.has(line)) continue;
3926
+ seenLines.add(line);
3927
+ findings.push({ label, file: relativePath, line });
3616
3928
  }
3617
3929
  }
3618
3930
  }
@@ -3670,7 +3982,7 @@ export function extractStringLiteralCandidates(line: string): string[] {
3670
3982
  continue;
3671
3983
  }
3672
3984
  if (char === quote) {
3673
- if (cursor - contentStart >= 20) {
3985
+ if (cursor - contentStart >= ENTROPY_CANDIDATE_MIN_LENGTH) {
3674
3986
  candidates.push(line.slice(contentStart, cursor));
3675
3987
  }
3676
3988
  index = cursor;
@@ -3695,6 +4007,25 @@ function classifyEntropyCandidate(input: {
3695
4007
  if (!charset) return undefined;
3696
4008
  const entropy = shannonEntropy(value);
3697
4009
  const secretishContext = SECRETISH_IDENTIFIER_PATTERN.test(input.line);
4010
+ // Word-like SCREAMING_SNAKE values (e.g. error-code constants such as
4011
+ // "AUTH_PASSWORD_LOGIN_CAPTCHA_REQUIRED") may contain secret-ish words
4012
+ // (AUTH/PASSWORD/...) in their own text and would otherwise be permanently
4013
+ // blocker-flagged. They are never skipped — entropy classification always
4014
+ // runs — but when the secret-ish context comes solely from identifier-
4015
+ // constant-shaped literal text (the line with those literals stripped
4016
+ // carries no secret-ish identifier), the finding is capped at a
4017
+ // non-blocking warning instead of a blocker. Stripping constant-shaped
4018
+ // siblings — not just the candidate — matters for lines holding several
4019
+ // constants (e.g. an ERROR_CODES array), while quoted property keys and
4020
+ // header names ("Authorization", "apiKey") stay visible as genuine
4021
+ // external context. Assignments to `apiKey`/`token`/`secret`-style names
4022
+ // still escalate to blockers via the identifier side, and
4023
+ // `// @apifuse-allow secret-scan` remains the reviewed way to silence the
4024
+ // warning.
4025
+ const selfContextOnlyConstant =
4026
+ secretishContext &&
4027
+ isScreamingSnakeConstantValue(value) &&
4028
+ !SECRETISH_IDENTIFIER_PATTERN.test(stripIdentifierConstantLiterals(input.line, value));
3698
4029
  const threshold = charset === "hex" ? 3.0 : secretishContext ? 4.0 : 4.5;
3699
4030
  if (entropy < threshold) return undefined;
3700
4031
 
@@ -3705,16 +4036,161 @@ function classifyEntropyCandidate(input: {
3705
4036
  charset === "hex"
3706
4037
  ? `high-entropy hex string (${entropy.toFixed(2)} bits/char)`
3707
4038
  : `high-entropy base64-like string (${entropy.toFixed(2)} bits/char)`;
4039
+ const contextNote = selfContextOnlyConstant
4040
+ ? "; identifier-like constant (downgraded to warning)"
4041
+ : secretishContext
4042
+ ? ""
4043
+ : "; may be a false positive";
3708
4044
  return {
3709
4045
  label,
3710
4046
  file: input.file,
3711
4047
  line: input.lineNumber,
3712
- level: secretishContext ? "blocker" : "warn",
4048
+ level: secretishContext && !selfContextOnlyConstant ? "blocker" : "warn",
3713
4049
  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"}`,
4050
+ evidence: `${location}: ${label}; preview ${preview}${contextNote}`,
3715
4051
  };
3716
4052
  }
3717
4053
 
4054
+ // Word-like SCREAMING_SNAKE identifier shape: at least two underscore-
4055
+ // separated segments, each essentially pure alphabetic — letters optionally
4056
+ // followed by a SHORT digit suffix (at most 2, e.g. version markers like
4057
+ // "V2") — and at most 15% digits across the whole value. Dictionary-style
4058
+ // constants like "AUTH_PASSWORD_LOGIN_CAPTCHA_REQUIRED" or
4059
+ // "PROVIDER_CONTRACT_V2_REQUIRED" match; digit-heavy segmented material
4060
+ // (e.g. license/credential shapes like "ABCD1234_EFGH5678_IJKL9012"),
4061
+ // uppercase blobs ("XK9J_Q2ZP_M7VN"), and underscore-free hex-like values
4062
+ // ("A1B2C3D4...") do not. This shape gate never skips entropy classification;
4063
+ // it only decides whether a finding whose secret-ish context comes solely
4064
+ // from the literal's own text is downgraded from blocker to warning, so it
4065
+ // deliberately stays strict: values that merely contain a secret-ish word but
4066
+ // are not word-like constants keep full blocker severity.
4067
+ function isScreamingSnakeConstantValue(value: string): boolean {
4068
+ if (!/^[A-Z][A-Z0-9_]*$/.test(value) || !value.includes("_")) return false;
4069
+ const segments = value.split("_");
4070
+ if (segments.length < 2) return false;
4071
+ if (!segments.every((segment) => /^[A-Z]+[0-9]{0,2}$/.test(segment))) return false;
4072
+ const digitCount = value.match(/[0-9]/g)?.length ?? 0;
4073
+ return digitCount / value.length <= 0.15;
4074
+ }
4075
+
4076
+ type LineStringLiteral = {
4077
+ // Index of the opening quote.
4078
+ start: number;
4079
+ // Index just past the closing quote (line end when unterminated).
4080
+ end: number;
4081
+ content: string;
4082
+ closed: boolean;
4083
+ role: "key" | "value";
4084
+ // Nearest unclosed bracket enclosing the literal's start, if any.
4085
+ container?: { bracket: "[" | "(" | "{"; index: number };
4086
+ };
4087
+
4088
+ // Stable identity for the container a literal sits in ("top" when the
4089
+ // literal is not inside any bracket on the line).
4090
+ function literalContainerKey(literal: LineStringLiteral): string {
4091
+ return literal.container ? `${literal.container.bracket}${literal.container.index}` : "top";
4092
+ }
4093
+
4094
+ // Single-pass line tokenizer: extracts every string literal with its span and
4095
+ // classifies its syntactic role once. A literal is a KEY when it is preceded
4096
+ // (ignoring whitespace) by "{", ",", "(", or the line start AND followed
4097
+ // (ignoring whitespace) by ":" — i.e. it names the value next to it. Every
4098
+ // other literal is a VALUE: ternary arms (preceded by "?" or ":"), array
4099
+ // elements, call arguments, and assignment right-hand sides, even when a
4100
+ // ternary's ":" happens to follow them. Uses the same quote/escape walking as
4101
+ // extractStringLiteralCandidates.
4102
+ function tokenizeLineStringLiterals(line: string): LineStringLiteral[] {
4103
+ const literals: LineStringLiteral[] = [];
4104
+ const bracketStack: Array<{ bracket: "[" | "(" | "{"; index: number }> = [];
4105
+ let index = 0;
4106
+ while (index < line.length) {
4107
+ const char = line[index];
4108
+ if (char !== '"' && char !== "'" && char !== "`") {
4109
+ if (char === "[" || char === "(" || char === "{") {
4110
+ bracketStack.push({ bracket: char, index });
4111
+ } else if (char === "]" || char === ")" || char === "}") {
4112
+ bracketStack.pop();
4113
+ }
4114
+ index += 1;
4115
+ continue;
4116
+ }
4117
+ const quote = char;
4118
+ const start = index;
4119
+ const contentStart = index + 1;
4120
+ let cursor = contentStart;
4121
+ let closed = false;
4122
+ while (cursor < line.length) {
4123
+ const inner = line[cursor];
4124
+ if (inner === "\\") {
4125
+ cursor += 2;
4126
+ continue;
4127
+ }
4128
+ if (inner === quote) {
4129
+ closed = true;
4130
+ break;
4131
+ }
4132
+ cursor += 1;
4133
+ }
4134
+ const contentEnd = Math.min(cursor, line.length);
4135
+ const end = closed ? cursor + 1 : line.length;
4136
+ const before = line.slice(0, start).trimEnd();
4137
+ const keyPreceded = before === "" || /[{,(]$/.test(before);
4138
+ const keyFollowed = closed && /^\s*:/.test(line.slice(end));
4139
+ literals.push({
4140
+ start,
4141
+ end,
4142
+ content: line.slice(contentStart, contentEnd),
4143
+ closed,
4144
+ role: keyPreceded && keyFollowed ? "key" : "value",
4145
+ container: bracketStack[bracketStack.length - 1],
4146
+ });
4147
+ index = end;
4148
+ }
4149
+ return literals;
4150
+ }
4151
+
4152
+ // Builds the context text used to decide whether a candidate's secret-ish
4153
+ // context is genuine. The candidate's own literal is ALWAYS stripped
4154
+ // (self-context rule). SIBLING identifier-constant-shaped candidate literals
4155
+ // (VALUE role, SCREAMING_SNAKE shape, >= ENTROPY_CANDIDATE_MIN_LENGTH) are
4156
+ // stripped only when they share the candidate's non-call container — the same
4157
+ // `[...]` array, the same `{...}` object value list, or the bracket-free top
4158
+ // level (ternary arms) — so a value list of error codes cannot poison its own
4159
+ // members' context. Call-argument siblings (inside `(...)`) always keep their
4160
+ // context: in `headers.set("X_LONG_AUTH_TOKEN_NAME", "QWERTY_...")` the first
4161
+ // argument genuinely describes the second, so stripping it would erase real
4162
+ // auth/token context. KEY-role literals are never stripped.
4163
+ function stripIdentifierConstantLiterals(line: string, candidate: string): string {
4164
+ const literals = tokenizeLineStringLiterals(line);
4165
+ const candidateContainers = new Set<string>();
4166
+ for (const literal of literals) {
4167
+ if (literal.content === candidate) {
4168
+ candidateContainers.add(literalContainerKey(literal));
4169
+ }
4170
+ }
4171
+ let result = "";
4172
+ let previousEnd = 0;
4173
+ for (const literal of literals) {
4174
+ result += line.slice(previousEnd, literal.start);
4175
+ const isSelf = literal.content === candidate;
4176
+ const isSameNonCallContainerSibling =
4177
+ literal.role === "value" &&
4178
+ literal.content.length >= ENTROPY_CANDIDATE_MIN_LENGTH &&
4179
+ isScreamingSnakeConstantValue(literal.content) &&
4180
+ literal.container?.bracket !== "(" &&
4181
+ candidateContainers.has(literalContainerKey(literal));
4182
+ if (isSelf || isSameNonCallContainerSibling) {
4183
+ const quote = line[literal.start] ?? "";
4184
+ result += quote + (literal.closed ? quote : "");
4185
+ } else {
4186
+ result += line.slice(literal.start, literal.end);
4187
+ }
4188
+ previousEnd = literal.end;
4189
+ }
4190
+ result += line.slice(previousEnd);
4191
+ return result;
4192
+ }
4193
+
3718
4194
  function shouldConsiderEntropyValue(value: string): boolean {
3719
4195
  const lower = value.toLowerCase();
3720
4196
  if (/^(?:dev-only|local|example|sample|your-|replace|<)/i.test(value)) {
@@ -3730,7 +4206,7 @@ function shouldConsiderEntropyValue(value: string): boolean {
3730
4206
  if (lower.includes("/") && /\.[a-z0-9]{1,8}(?:$|[/?#])/i.test(value)) {
3731
4207
  return false;
3732
4208
  }
3733
- return value.length >= 20;
4209
+ return value.length >= ENTROPY_CANDIDATE_MIN_LENGTH;
3734
4210
  }
3735
4211
 
3736
4212
  function classifyEntropyCharset(value: string): "base64" | "hex" | undefined {
@@ -3764,6 +4240,11 @@ function guessSecretName(line: string): string {
3764
4240
 
3765
4241
  const SECRETISH_IDENTIFIER_PATTERN = /key|token|secret|password|credential|auth/i;
3766
4242
 
4243
+ // Minimum length for a string literal to be considered an entropy candidate.
4244
+ // Shared by candidate extraction, entropy screening, and the context strip so
4245
+ // the three stay coherent.
4246
+ const ENTROPY_CANDIDATE_MIN_LENGTH = 20;
4247
+
3767
4248
  const SECRET_PATTERNS: Array<[string, RegExp]> = [
3768
4249
  ["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
3769
4250
  ["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],