@apifuse/provider-sdk 2.1.0-beta.9 → 2.2.0-beta.10

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 (263) hide show
  1. package/AUTHORING.md +240 -0
  2. package/CHANGELOG.md +93 -0
  3. package/README.md +26 -10
  4. package/SUBMISSION.md +11 -12
  5. package/bin/apifuse-check.ts +44 -59
  6. package/bin/apifuse-create.ts +1 -1
  7. package/bin/apifuse-dev.ts +27 -52
  8. package/bin/apifuse-pack-check.ts +36 -0
  9. package/bin/apifuse-pack-smoke.ts +22 -81
  10. package/bin/apifuse-pack-types.ts +266 -0
  11. package/bin/apifuse-perf.ts +45 -127
  12. package/bin/apifuse-record.ts +53 -70
  13. package/bin/apifuse-submit-check.ts +2177 -353
  14. package/bin/apifuse-sync-assets.ts +117 -0
  15. package/bin/apifuse.ts +1 -1
  16. package/bin/submit-check-delimited-text.ts +50 -0
  17. package/bin/submit-check-xml-semantics.ts +204 -0
  18. package/bin/submit-check-xml.ts +134 -0
  19. package/dist/auth-turn/auth-turn.v1.schema.json +89 -0
  20. package/dist/auth-turn/fixtures/invalid/empty-kind.json +4 -0
  21. package/dist/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
  22. package/dist/auth-turn/fixtures/invalid/missing-kind.json +3 -0
  23. package/dist/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
  24. package/dist/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
  25. package/dist/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
  26. package/dist/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
  27. package/dist/auth-turn/fixtures/valid/abort.json +8 -0
  28. package/dist/auth-turn/fixtures/valid/challenge.json +17 -0
  29. package/dist/auth-turn/fixtures/valid/complete.json +13 -0
  30. package/dist/auth-turn/fixtures/valid/form.json +14 -0
  31. package/dist/auth-turn/fixtures/valid/message.json +13 -0
  32. package/dist/auth-turn/fixtures/valid/multi_choice.json +15 -0
  33. package/dist/auth-turn/fixtures/valid/pending.json +5 -0
  34. package/dist/auth-turn/fixtures/valid/poll.json +9 -0
  35. package/dist/auth-turn/fixtures/valid/redirect.json +16 -0
  36. package/dist/auth-turn/fixtures/valid/retry.json +8 -0
  37. package/dist/auth-turn/fixtures/valid/unknown-kind.json +7 -0
  38. package/dist/auth-turn/index.d.ts +195 -0
  39. package/dist/auth-turn/index.js +133 -0
  40. package/dist/auth.d.ts +76 -0
  41. package/dist/auth.js +427 -0
  42. package/dist/ceremonies/index.d.ts +1 -1
  43. package/dist/ceremonies/index.js +14 -48
  44. package/dist/cli/commands.d.ts +1 -1
  45. package/dist/cli/commands.js +8 -0
  46. package/dist/cli/create.d.ts +3 -0
  47. package/dist/cli/create.js +47 -33
  48. package/dist/cli/prompt-assets.d.ts +80 -0
  49. package/dist/cli/prompt-assets.js +743 -0
  50. package/dist/cli/templates/provider/.agents/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  51. package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  52. package/dist/cli/templates/provider/.agents/skills/normalization-standards/SKILL.md.tpl +57 -0
  53. package/dist/cli/templates/provider/.agents/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  54. package/dist/cli/templates/provider/.agents/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  55. package/dist/cli/templates/provider/.agents/skills/upstream-notes/README.md.tpl +13 -0
  56. package/dist/cli/templates/provider/.dockerignore.tpl +22 -0
  57. package/dist/cli/templates/provider/.gitignore.tpl +22 -0
  58. package/dist/cli/templates/provider/AGENTS.md.tpl +96 -0
  59. package/dist/cli/templates/provider/Dockerfile.tpl +7 -0
  60. package/dist/cli/templates/provider/README.md.tpl +163 -0
  61. package/dist/cli/templates/provider/dev.ts.tpl +5 -0
  62. package/dist/cli/templates/provider/domain/README.md.tpl +3 -0
  63. package/dist/cli/templates/provider/index.test.ts.tpl +13 -0
  64. package/dist/cli/templates/provider/index.ts.tpl +15 -0
  65. package/dist/cli/templates/provider/mappers/README.md.tpl +3 -0
  66. package/dist/cli/templates/provider/meta.ts.tpl +7 -0
  67. package/dist/cli/templates/provider/operations/index.ts.tpl +5 -0
  68. package/dist/cli/templates/provider/operations/ping.ts.tpl +24 -0
  69. package/dist/cli/templates/provider/schemas/ping.ts.tpl +24 -0
  70. package/dist/cli/templates/provider/start.ts.tpl +5 -0
  71. package/dist/cli/templates/provider/upstream/README.md.tpl +3 -0
  72. package/dist/config/loader.d.ts +149 -8
  73. package/dist/config/loader.js +378 -89
  74. package/dist/contract-serialization.d.ts +2 -2
  75. package/dist/contract-serialization.js +3 -6
  76. package/dist/contract-types.d.ts +2 -2
  77. package/dist/contract.d.ts +3 -3
  78. package/dist/contract.js +5 -6
  79. package/dist/define.d.ts +13 -1
  80. package/dist/define.js +245 -178
  81. package/dist/dev.d.ts +1 -1
  82. package/dist/dev.js +1 -1
  83. package/dist/errors.d.ts +4 -1
  84. package/dist/errors.js +48 -0
  85. package/dist/i18n/catalog.d.ts +2 -2
  86. package/dist/i18n/catalog.js +4 -10
  87. package/dist/i18n/index.d.ts +2 -2
  88. package/dist/i18n/index.js +2 -2
  89. package/dist/i18n/keys.d.ts +2 -2
  90. package/dist/index.d.ts +44 -41
  91. package/dist/index.js +39 -36
  92. package/dist/lint.d.ts +1 -0
  93. package/dist/lint.js +35 -15
  94. package/dist/provider.d.ts +11 -9
  95. package/dist/provider.js +9 -8
  96. package/dist/public-schema-field-lint.d.ts +1 -1
  97. package/dist/recipes/gov-api.js +1 -1
  98. package/dist/runtime/auth-flow.d.ts +1 -1
  99. package/dist/runtime/auth-flow.js +4 -2
  100. package/dist/runtime/browser.d.ts +1 -1
  101. package/dist/runtime/browser.js +214 -25
  102. package/dist/runtime/cache.d.ts +1 -1
  103. package/dist/runtime/cache.js +4 -8
  104. package/dist/runtime/choice.d.ts +1 -1
  105. package/dist/runtime/choice.js +31 -35
  106. package/dist/runtime/credential.d.ts +1 -1
  107. package/dist/runtime/credential.js +1 -1
  108. package/dist/runtime/env.d.ts +1 -1
  109. package/dist/runtime/executor.d.ts +1 -1
  110. package/dist/runtime/executor.js +15 -3
  111. package/dist/runtime/http.d.ts +2 -2
  112. package/dist/runtime/http.js +160 -344
  113. package/dist/runtime/insights.d.ts +1 -1
  114. package/dist/runtime/insights.js +6 -13
  115. package/dist/runtime/instrumentation.d.ts +2 -2
  116. package/dist/runtime/instrumentation.js +56 -19
  117. package/dist/runtime/keyring.js +1 -1
  118. package/dist/runtime/namespace.js +1 -1
  119. package/dist/runtime/otlp.d.ts +1 -1
  120. package/dist/runtime/perf.d.ts +1 -1
  121. package/dist/runtime/provider.d.ts +1 -1
  122. package/dist/runtime/provider.js +1 -2
  123. package/dist/runtime/proxy-errors.d.ts +1 -1
  124. package/dist/runtime/proxy-errors.js +9 -7
  125. package/dist/runtime/proxy-nodemaven.d.ts +35 -0
  126. package/dist/runtime/proxy-nodemaven.js +128 -0
  127. package/dist/runtime/proxy-retry-policy.d.ts +40 -0
  128. package/dist/runtime/proxy-retry-policy.js +326 -0
  129. package/dist/runtime/proxy-telemetry.d.ts +2 -1
  130. package/dist/runtime/proxy-telemetry.js +55 -52
  131. package/dist/runtime/redis.d.ts +1 -1
  132. package/dist/runtime/redis.js +2 -4
  133. package/dist/runtime/request-options.d.ts +1 -1
  134. package/dist/runtime/secrets.d.ts +27 -0
  135. package/dist/runtime/secrets.js +51 -0
  136. package/dist/runtime/state.d.ts +2 -2
  137. package/dist/runtime/state.js +15 -4
  138. package/dist/runtime/stealth.d.ts +7 -4
  139. package/dist/runtime/stealth.js +257 -215
  140. package/dist/runtime/stt.d.ts +1 -1
  141. package/dist/runtime/stt.js +11 -15
  142. package/dist/runtime/trace.d.ts +2 -2
  143. package/dist/runtime/trace.js +2 -4
  144. package/dist/runtime/waterfall.d.ts +1 -1
  145. package/dist/schema.d.ts +1 -1
  146. package/dist/schema.js +7 -15
  147. package/dist/serve.d.ts +1 -1
  148. package/dist/serve.js +1 -1
  149. package/dist/server/index.d.ts +7 -3
  150. package/dist/server/index.js +6 -2
  151. package/dist/server/self-test-input-tokens.d.ts +1 -0
  152. package/dist/server/self-test-input-tokens.js +37 -0
  153. package/dist/server/self-test-redaction.d.ts +20 -0
  154. package/dist/server/self-test-redaction.js +70 -0
  155. package/dist/server/self-test-token.d.ts +30 -0
  156. package/dist/server/self-test-token.js +50 -0
  157. package/dist/server/self-test.d.ts +199 -0
  158. package/dist/server/self-test.js +1113 -0
  159. package/dist/server/serve.d.ts +14 -3
  160. package/dist/server/serve.js +135 -64
  161. package/dist/server/types.d.ts +10 -9
  162. package/dist/server/types.js +3 -7
  163. package/dist/stealth/profiles.d.ts +1 -1
  164. package/dist/stealth/profiles.js +5 -14
  165. package/dist/stream.d.ts +1 -1
  166. package/dist/testing/index.d.ts +2 -2
  167. package/dist/testing/index.js +2 -2
  168. package/dist/testing/run.d.ts +1 -1
  169. package/dist/testing/run.js +12 -15
  170. package/dist/types.d.ts +237 -1
  171. package/dist/user-input.d.ts +30 -0
  172. package/dist/user-input.js +66 -0
  173. package/package.json +16 -5
  174. package/src/auth-turn/auth-turn.v1.schema.json +89 -0
  175. package/src/auth-turn/fixtures/invalid/empty-kind.json +4 -0
  176. package/src/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
  177. package/src/auth-turn/fixtures/invalid/missing-kind.json +3 -0
  178. package/src/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
  179. package/src/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
  180. package/src/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
  181. package/src/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
  182. package/src/auth-turn/fixtures/valid/abort.json +8 -0
  183. package/src/auth-turn/fixtures/valid/challenge.json +17 -0
  184. package/src/auth-turn/fixtures/valid/complete.json +13 -0
  185. package/src/auth-turn/fixtures/valid/form.json +14 -0
  186. package/src/auth-turn/fixtures/valid/message.json +13 -0
  187. package/src/auth-turn/fixtures/valid/multi_choice.json +15 -0
  188. package/src/auth-turn/fixtures/valid/pending.json +5 -0
  189. package/src/auth-turn/fixtures/valid/poll.json +9 -0
  190. package/src/auth-turn/fixtures/valid/redirect.json +16 -0
  191. package/src/auth-turn/fixtures/valid/retry.json +8 -0
  192. package/src/auth-turn/fixtures/valid/unknown-kind.json +7 -0
  193. package/src/auth-turn/index.ts +177 -0
  194. package/src/auth.ts +728 -0
  195. package/src/ceremonies/index.ts +33 -121
  196. package/src/cli/commands.ts +10 -0
  197. package/src/cli/create.ts +69 -99
  198. package/src/cli/prompt-assets.ts +865 -0
  199. package/src/cli/templates/provider/.agents/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  200. package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  201. package/src/cli/templates/provider/.agents/skills/normalization-standards/SKILL.md.tpl +57 -0
  202. package/src/cli/templates/provider/.agents/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  203. package/src/cli/templates/provider/.agents/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  204. package/src/cli/templates/provider/.agents/skills/upstream-notes/README.md.tpl +13 -0
  205. package/src/cli/templates/provider/AGENTS.md.tpl +96 -0
  206. package/src/cli/templates/provider/README.md.tpl +7 -4
  207. package/src/config/loader.ts +543 -208
  208. package/src/contract-serialization.ts +5 -11
  209. package/src/contract-types.ts +2 -2
  210. package/src/contract.ts +13 -28
  211. package/src/define.ts +397 -528
  212. package/src/dev.ts +4 -9
  213. package/src/errors.ts +58 -5
  214. package/src/i18n/catalog.ts +10 -32
  215. package/src/i18n/index.ts +2 -2
  216. package/src/i18n/keys.ts +5 -11
  217. package/src/index.ts +64 -41
  218. package/src/lint.ts +122 -159
  219. package/src/provider.ts +40 -9
  220. package/src/public-schema-field-lint.ts +7 -33
  221. package/src/recipes/gov-api.ts +2 -5
  222. package/src/runtime/auth-flow.ts +6 -6
  223. package/src/runtime/browser.ts +320 -151
  224. package/src/runtime/cache.ts +20 -67
  225. package/src/runtime/choice.ts +79 -132
  226. package/src/runtime/credential.ts +2 -2
  227. package/src/runtime/env.ts +1 -1
  228. package/src/runtime/executor.ts +23 -20
  229. package/src/runtime/http.ts +216 -539
  230. package/src/runtime/insights.ts +15 -53
  231. package/src/runtime/instrumentation.ts +78 -65
  232. package/src/runtime/keyring.ts +7 -19
  233. package/src/runtime/namespace.ts +2 -7
  234. package/src/runtime/otlp.ts +12 -23
  235. package/src/runtime/perf.ts +1 -1
  236. package/src/runtime/provider.ts +4 -9
  237. package/src/runtime/proxy-errors.ts +29 -42
  238. package/src/runtime/proxy-nodemaven.ts +178 -0
  239. package/src/runtime/proxy-retry-policy.ts +469 -0
  240. package/src/runtime/proxy-telemetry.ts +79 -77
  241. package/src/runtime/redis.ts +4 -12
  242. package/src/runtime/request-options.ts +4 -13
  243. package/src/runtime/secrets.ts +64 -0
  244. package/src/runtime/state.ts +41 -110
  245. package/src/runtime/stealth.ts +331 -369
  246. package/src/runtime/stt.ts +38 -94
  247. package/src/runtime/trace.ts +14 -44
  248. package/src/runtime/waterfall.ts +5 -18
  249. package/src/schema.ts +23 -84
  250. package/src/serve.ts +1 -1
  251. package/src/server/index.ts +44 -3
  252. package/src/server/self-test-input-tokens.ts +46 -0
  253. package/src/server/self-test-redaction.ts +97 -0
  254. package/src/server/self-test-token.ts +70 -0
  255. package/src/server/self-test.ts +1450 -0
  256. package/src/server/serve.ts +206 -216
  257. package/src/server/types.ts +7 -19
  258. package/src/stealth/profiles.ts +10 -26
  259. package/src/stream.ts +8 -19
  260. package/src/testing/index.ts +2 -2
  261. package/src/testing/run.ts +24 -64
  262. package/src/types.ts +274 -1
  263. package/src/user-input.ts +118 -0
@@ -1,21 +1,30 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
+ import { type ChildProcess, spawn } from "node:child_process";
3
4
  import { existsSync, readdirSync, readFileSync } from "node:fs";
4
5
  import { writeFile } from "node:fs/promises";
6
+ import { createServer } from "node:net";
5
7
  import { basename, dirname, join, relative, resolve } from "node:path";
6
8
  import { pathToFileURL } from "node:url";
7
9
 
10
+ import * as acorn from "acorn";
8
11
  import { z } from "zod";
9
12
 
10
13
  import packageJson from "../package.json";
11
- import type { ProviderDefinition } from "../src";
14
+ import {
15
+ formatPromptAssetIssues,
16
+ verifyPromptAssets,
17
+ } from "../src/cli/prompt-assets.js";
12
18
  import {
13
19
  loadProviderLocaleCatalogs,
14
20
  type ProviderLocale,
15
21
  validateProviderLocaleCatalogs,
16
- } from "../src/i18n";
17
- import { APIFUSE_DESCRIPTION_KEY_META_KEY } from "../src/schema";
18
- import { type CheckResult, runChecks } from "./apifuse-check";
22
+ } from "../src/i18n/index.js";
23
+ import type { ProviderDefinition } from "../src/index.js";
24
+ import { APIFUSE_DESCRIPTION_KEY_META_KEY, safeParseSchemaSync } from "../src/schema.js";
25
+ import { type CheckResult, PROMPT_ASSETS_CHECK_MESSAGE, runChecks } from "./apifuse-check.js";
26
+ import { hasSubstantiveDelimitedTextStructure } from "./submit-check-delimited-text.js";
27
+ import { hasSubstantiveXmlStructure } from "./submit-check-xml.js";
19
28
 
20
29
  const TIERS = ["bronze", "silver", "gold", "diamond"] as const;
21
30
  const TIER_VALUES: ReadonlySet<string> = new Set(TIERS);
@@ -35,6 +44,7 @@ export type SubmitCheck = {
35
44
  message: string;
36
45
  remediation?: string;
37
46
  evidence?: string[];
47
+ details?: unknown;
38
48
  };
39
49
 
40
50
  export type SubmitCheckReport = {
@@ -69,6 +79,7 @@ type CliArgs = {
69
79
  isJson: boolean;
70
80
  markdownPath?: string;
71
81
  providerPath?: string;
82
+ smoke: boolean;
72
83
  smokeNote?: string;
73
84
  tier?: BountyTier;
74
85
  };
@@ -76,6 +87,24 @@ type CliArgs = {
76
87
  type SecretFinding = {
77
88
  label: string;
78
89
  file: string;
90
+ line?: number;
91
+ level?: CheckLevel;
92
+ remediation?: string;
93
+ evidence?: string;
94
+ };
95
+
96
+ export type SmokeOperationOutcome = {
97
+ operationId: string;
98
+ status: "success" | "structured_error" | "incoherent";
99
+ httpStatus?: number;
100
+ message: string;
101
+ };
102
+
103
+ export type SmokeResult = {
104
+ measured: true;
105
+ healthOk: boolean;
106
+ bootError?: string;
107
+ operations: SmokeOperationOutcome[];
79
108
  };
80
109
 
81
110
  type SourceFinding = {
@@ -98,14 +127,13 @@ const CATEGORY_MAX_POINTS = {
98
127
  docs: 10,
99
128
  } as const;
100
129
 
101
- const REQUIRED_PUBLIC_PROVIDER_LOCALES = [
102
- "en",
103
- "ko",
104
- ] as const satisfies readonly ProviderLocale[];
130
+ const REQUIRED_PUBLIC_PROVIDER_LOCALES = ["en", "ko"] as const satisfies readonly ProviderLocale[];
105
131
 
106
- const HELP_TEXT = `Usage: apifuse submit-check [path] [--tier bronze|silver|gold|diamond] [--json] [--markdown <path>] [--smoke-note <text>]
132
+ const HELP_TEXT = `Usage: apifuse submit-check [path] [--tier bronze|silver|gold|diamond] [--json] [--markdown <path>] [--smoke]
107
133
  Alias: apifuse bounty-check [path]
108
- Default: apifuse submit-check .`;
134
+ Default: apifuse submit-check .
135
+
136
+ Smoke: --smoke boots the provider dev server, checks /health, and POSTs every operation fixture. APIFUSE__PROVIDER__* env vars enable live upstream calls; without them, structured provider errors can still verify runtime routing. --smoke-note is deprecated and ignored for scoring.`;
109
137
 
110
138
  export async function main() {
111
139
  try {
@@ -120,10 +148,7 @@ export async function main() {
120
148
  const report = await buildSubmitCheckReport(providerRoot, args);
121
149
 
122
150
  if (args.markdownPath) {
123
- await writeFile(
124
- resolve(process.cwd(), args.markdownPath),
125
- renderMarkdown(report),
126
- );
151
+ await writeFile(resolve(process.cwd(), args.markdownPath), renderMarkdown(report));
127
152
  }
128
153
 
129
154
  if (args.isJson) {
@@ -150,7 +175,7 @@ function normalizeArgs(argv: string[]): string[] {
150
175
  }
151
176
 
152
177
  function parseArgs(argv: string[]): CliArgs {
153
- const args: CliArgs = { isJson: false };
178
+ const args: CliArgs = { isJson: false, smoke: false };
154
179
 
155
180
  for (let index = 0; index < argv.length; index += 1) {
156
181
  const arg = argv[index];
@@ -177,6 +202,11 @@ function parseArgs(argv: string[]): CliArgs {
177
202
  continue;
178
203
  }
179
204
 
205
+ if (arg === "--smoke") {
206
+ args.smoke = true;
207
+ continue;
208
+ }
209
+
180
210
  if (arg === "--smoke-note") {
181
211
  args.smokeNote = requireValue(argv, index, arg);
182
212
  index += 1;
@@ -226,9 +256,7 @@ function parseTier(value: string): BountyTier {
226
256
  if (isBountyTier(value)) {
227
257
  return value;
228
258
  }
229
- throw new Error(
230
- `Invalid --tier "${value}". Expected one of: ${TIERS.join(", ")}`,
231
- );
259
+ throw new Error(`Invalid --tier "${value}". Expected one of: ${TIERS.join(", ")}`);
232
260
  }
233
261
 
234
262
  function isBountyTier(value: string): value is BountyTier {
@@ -237,13 +265,21 @@ function isBountyTier(value: string): value is BountyTier {
237
265
 
238
266
  export async function buildSubmitCheckReport(
239
267
  providerRoot: string,
240
- args: { smokeNote?: string; tier?: BountyTier } = {},
268
+ args: { smoke?: boolean; smokeNote?: string; tier?: BountyTier } = {},
241
269
  ): Promise<SubmitCheckReport> {
242
270
  const checks: SubmitCheck[] = [];
243
271
  const baseChecks = await safeRunChecks(providerRoot);
244
272
  const provider = await safeLoadProvider(providerRoot);
245
273
 
246
- checks.push(...scoreBaseChecks(baseChecks));
274
+ // Prompt-asset freshness is reported by its own dedicated zero-point
275
+ // blocker below; filter the base-check duplicate so it is not double
276
+ // penalized under the definition category.
277
+ checks.push(
278
+ ...scoreBaseChecks(
279
+ baseChecks.filter((result) => result.message !== PROMPT_ASSETS_CHECK_MESSAGE),
280
+ ),
281
+ );
282
+ checks.push(scorePromptAssetFreshness(providerRoot));
247
283
  checks.push(scoreProviderIdSlug(providerRoot, provider));
248
284
  checks.push(scoreNoVendorShim(providerRoot));
249
285
  checks.push(scoreNoVendorImport(providerRoot));
@@ -257,16 +293,21 @@ export async function buildSubmitCheckReport(
257
293
  checks.push(scoreFlatOperationComposition(providerRoot));
258
294
 
259
295
  if (provider) {
296
+ const smokeResult = args.smoke ? await runSubmitCheckSmoke(providerRoot, provider) : undefined;
260
297
  checks.push(scoreCredentialUsage(providerRoot, provider));
298
+ checks.push(scoreSdkOwnedSecretPresence(providerRoot, provider));
261
299
  checks.push(scoreLocaleCatalog(providerRoot, provider));
262
300
  checks.push(scoreOperationMetadata(provider));
263
301
  checks.push(scoreFixtureCoverage(provider));
302
+ checks.push(scoreFixtureProvenance(providerRoot, provider));
303
+ checks.push(scoreVendorKeyLeak(providerRoot));
304
+ checks.push(scoreVendorTimestampLeak(providerRoot));
264
305
  checks.push(scoreHealthCoverage(provider));
265
306
  checks.push(scoreAuthSafety(provider));
266
- checks.push(scoreSmokeEvidence(args.smokeNote));
307
+ checks.push(scoreSmoke(smokeResult, args.smokeNote));
267
308
  checks.push(...scoreProviderDocs(providerRoot));
268
309
  checks.push(scoreRepositoryDx(providerRoot));
269
- checks.push(scoreSecrets(providerRoot));
310
+ checks.push(scoreSecrets(providerRoot, provider));
270
311
  } else {
271
312
  checks.push(
272
313
  blocker(
@@ -279,22 +320,14 @@ export async function buildSubmitCheckReport(
279
320
  );
280
321
  }
281
322
 
282
- const total = clamp(
283
- Math.round(checks.reduce((sum, check) => sum + check.points, 0)),
284
- 0,
285
- 100,
286
- );
323
+ const total = clamp(Math.round(checks.reduce((sum, check) => sum + check.points, 0)), 0, 100);
287
324
  const blockers = checks.filter(
288
325
  (check) => check.level === "blocker" && check.status === "fail",
289
326
  ).length;
290
327
  const warnings = checks.filter((check) => check.status === "warn").length;
291
328
  const passed = checks.filter((check) => check.status === "pass").length;
292
329
  const verdict: Verdict =
293
- blockers > 0
294
- ? "blocked"
295
- : total >= 90 && warnings === 0
296
- ? "ready"
297
- : "reviewable_with_warnings";
330
+ blockers > 0 ? "blocked" : total >= 90 && warnings === 0 ? "ready" : "reviewable_with_warnings";
298
331
 
299
332
  return {
300
333
  schemaVersion: 1,
@@ -335,18 +368,10 @@ function scoreProviderIdSlug(
335
368
  );
336
369
  }
337
370
 
338
- return pass(
339
- "id-slug",
340
- SDK_NATIVE_CATEGORY,
341
- "Provider id uses the short slug.",
342
- 0,
343
- );
371
+ return pass("id-slug", SDK_NATIVE_CATEGORY, "Provider id uses the short slug.", 0);
344
372
  }
345
373
 
346
- const findings = findSourceLineMatches(
347
- providerRoot,
348
- /["'`]apifuse-provider-[a-z0-9-]/i,
349
- );
374
+ const findings = findSourceLineMatches(providerRoot, /["'`]apifuse-provider-[a-z0-9-]/i);
350
375
  if (findings.length > 0) {
351
376
  return blocker(
352
377
  "id-slug",
@@ -358,12 +383,7 @@ function scoreProviderIdSlug(
358
383
  );
359
384
  }
360
385
 
361
- return pass(
362
- "id-slug",
363
- SDK_NATIVE_CATEGORY,
364
- "Provider id uses the short slug.",
365
- 0,
366
- );
386
+ return pass("id-slug", SDK_NATIVE_CATEGORY, "Provider id uses the short slug.", 0);
367
387
  }
368
388
 
369
389
  function scoreNoVendorShim(providerRoot: string): SubmitCheck {
@@ -388,10 +408,7 @@ function scoreNoVendorShim(providerRoot: string): SubmitCheck {
388
408
  }
389
409
 
390
410
  function scoreNoVendorImport(providerRoot: string): SubmitCheck {
391
- const findings = findSourceLineMatches(
392
- providerRoot,
393
- /from\s+["'][^"']*vendor\//,
394
- );
411
+ const findings = findSourceLineMatches(providerRoot, /from\s+["'][^"']*vendor\//);
395
412
  if (findings.length > 0) {
396
413
  return blocker(
397
414
  "no-vendor-import",
@@ -424,33 +441,24 @@ function scoreDescribeKey(providerRoot: string): SubmitCheck {
424
441
  );
425
442
  }
426
443
 
427
- return pass(
428
- "describe-key",
429
- SDK_NATIVE_CATEGORY,
430
- "Schema descriptions use describeKey.",
431
- 0,
432
- );
444
+ return pass("describe-key", SDK_NATIVE_CATEGORY, "Schema descriptions use describeKey.", 0);
433
445
  }
434
446
 
435
447
  function scoreNoRawFetch(providerRoot: string): SubmitCheck {
436
448
  const findings = findSourceLineMatches(providerRoot, /(?<![.\w])fetch\s*\(/);
437
449
  if (findings.length > 0) {
450
+ const evidence = formatSourceFindings(findings);
438
451
  return blocker(
439
452
  "no-raw-fetch",
440
453
  SDK_NATIVE_CATEGORY,
441
454
  "Provider source calls raw fetch().",
442
- "Replace raw fetch() with ctx.stealth.fetch() (cloud-IP-blocked otherwise) or ctx.http for non-stealth calls.",
455
+ `Replace raw fetch() in ${evidence.join(", ")} with ctx.stealth.fetch() for stealth/cloud-IP-sensitive calls or ctx.http.get/post/request for ordinary HTTP calls.`,
443
456
  0,
444
- formatSourceFindings(findings),
457
+ evidence,
445
458
  );
446
459
  }
447
460
 
448
- return pass(
449
- "no-raw-fetch",
450
- SDK_NATIVE_CATEGORY,
451
- "Provider source avoids raw fetch().",
452
- 0,
453
- );
461
+ return pass("no-raw-fetch", SDK_NATIVE_CATEGORY, "Provider source avoids raw fetch().", 0);
454
462
  }
455
463
 
456
464
  const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
@@ -460,10 +468,7 @@ const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
460
468
  const SDK_CONTEXT_METHOD_ALIAS_PATTERN =
461
469
  /\bconst\s+(\w+)\s*=\s*ctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|choice)\.(?:\w+)/;
462
470
 
463
- function hasRedundantRuntimeGuard(
464
- line: string,
465
- remainingLines: readonly string[],
466
- ): boolean {
471
+ function hasRedundantRuntimeGuard(line: string, remainingLines: readonly string[]): boolean {
467
472
  if (REDUNDANT_RUNTIME_GUARD_PATTERNS.some((pattern) => pattern.test(line))) {
468
473
  return true;
469
474
  }
@@ -474,12 +479,8 @@ function hasRedundantRuntimeGuard(
474
479
  return false;
475
480
  }
476
481
 
477
- const guardPattern = new RegExp(
478
- `(?:typeof\\s+${alias}\\s*!==\\s*["']function["']|!${alias}\\b)`,
479
- );
480
- return remainingLines
481
- .slice(0, 8)
482
- .some((candidate) => guardPattern.test(candidate));
482
+ const guardPattern = new RegExp(`(?:typeof\\s+${alias}\\s*!==\\s*["']function["']|!${alias}\\b)`);
483
+ return remainingLines.slice(0, 8).some((candidate) => guardPattern.test(candidate));
483
484
  }
484
485
 
485
486
  function scoreNoRedundantRuntimeGuards(providerRoot: string): SubmitCheck {
@@ -580,11 +581,7 @@ function scoreAsAssertionCount(providerRoot: string): SubmitCheck {
580
581
 
581
582
  // Returns true when `findingLine` (1-based) or the line directly above it
582
583
  // carries an `// @apifuse-allow <ruleId>:` acknowledgement comment.
583
- function hasAllowOverride(
584
- lines: readonly string[],
585
- findingLine: number,
586
- ruleId: string,
587
- ): boolean {
584
+ function hasAllowOverride(lines: readonly string[], findingLine: number, ruleId: string): boolean {
588
585
  const pattern = new RegExp(`@apifuse-allow\\s+${ruleId}\\b`);
589
586
  const current = lines[findingLine - 1];
590
587
  const previous = lines[findingLine - 2];
@@ -635,11 +632,7 @@ function escapeHatchResult(
635
632
  return pass(ruleId, SDK_NATIVE_CATEGORY, copy.passMessage, 0);
636
633
  }
637
634
 
638
- const { violations, overridden } = partitionAllowOverrides(
639
- providerRoot,
640
- findings,
641
- ruleId,
642
- );
635
+ const { violations, overridden } = partitionAllowOverrides(providerRoot, findings, ruleId);
643
636
 
644
637
  if (violations.length > 0) {
645
638
  return blocker(
@@ -752,10 +745,11 @@ function unwrapParens(expr: string): string {
752
745
  // closing bracket. This lets a property value be read across newlines, so a
753
746
  // multi-line `input: z.object({...})\n.passthrough()` is captured whole.
754
747
  function balancedValueExpression(source: string, valueStart: number): string {
748
+ const masked = maskCommentsAndStrings(source);
755
749
  let depth = 0;
756
750
  let index = valueStart;
757
751
  for (; index < source.length; index += 1) {
758
- const ch = source[index];
752
+ const ch = masked[index];
759
753
  if (ch === "(" || ch === "{" || ch === "[") {
760
754
  depth += 1;
761
755
  } else if (ch === ")" || ch === "}" || ch === "]") {
@@ -952,9 +946,7 @@ function inputKeyIsSchemaField(source: string, propIndex: number): boolean {
952
946
  // across unrelated modules from producing false positives).
953
947
  function fileImportsBinding(source: string, name: string): boolean {
954
948
  const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
955
- return new RegExp(`\\bimport\\b[^;]*\\b${escaped}\\b[^;]*\\bfrom\\b`).test(
956
- source,
957
- );
949
+ return new RegExp(`\\bimport\\b[^;]*\\b${escaped}\\b[^;]*\\bfrom\\b`).test(source);
958
950
  }
959
951
 
960
952
  // Resolves the ORIGINAL exported name for a local binding `localName`. When the
@@ -998,11 +990,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
998
990
  passthroughByFile.set(filePath, localMap);
999
991
  const constDecl =
1000
992
  /(?:^|\n)[ \t]*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?\s*=/g;
1001
- for (
1002
- let match = constDecl.exec(source);
1003
- match !== null;
1004
- match = constDecl.exec(source)
1005
- ) {
993
+ for (let match = constDecl.exec(source); match !== null; match = constDecl.exec(source)) {
1006
994
  const name = match[1];
1007
995
  if (name === undefined) {
1008
996
  continue;
@@ -1041,11 +1029,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
1041
1029
  const relPath = toRelativeProviderPath(providerRoot, filePath);
1042
1030
 
1043
1031
  const inputProp = /\binput\s*:\s*/g;
1044
- for (
1045
- let match = inputProp.exec(source);
1046
- match !== null;
1047
- match = inputProp.exec(source)
1048
- ) {
1032
+ for (let match = inputProp.exec(source); match !== null; match = inputProp.exec(source)) {
1049
1033
  // Skip `input` keys that are fields inside a zod schema body (e.g. an
1050
1034
  // upstream payload modelled as `z.object({ input: ... })`). Only an
1051
1035
  // operation's public `input:` property is in scope for this rule.
@@ -1073,9 +1057,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
1073
1057
  // Imported binding: map a possible `orig as refName` alias
1074
1058
  // back to the exported name the provider-wide map is keyed by.
1075
1059
  const originalName = importedOriginalName(source, refName);
1076
- const site =
1077
- passthroughConsts.get(refName) ??
1078
- passthroughConsts.get(originalName);
1060
+ const site = passthroughConsts.get(refName) ?? passthroughConsts.get(originalName);
1079
1061
  if (site) {
1080
1062
  push(site);
1081
1063
  }
@@ -1121,8 +1103,7 @@ function scoreUnjustifiedLooseSchema(providerRoot: string): SubmitCheck {
1121
1103
  // A `//` justification comment on the same line or the line above
1122
1104
  // (including the `@apifuse-allow loose-schema:` form) acknowledges it.
1123
1105
  const previous = lines[index - 1];
1124
- const justified =
1125
- line.includes("//") || previous?.trim().startsWith("//") === true;
1106
+ const justified = line.includes("//") || previous?.trim().startsWith("//") === true;
1126
1107
  if (!justified) {
1127
1108
  findings.push({
1128
1109
  file: toRelativeProviderPath(providerRoot, filePath),
@@ -1133,8 +1114,7 @@ function scoreUnjustifiedLooseSchema(providerRoot: string): SubmitCheck {
1133
1114
  }
1134
1115
 
1135
1116
  return escapeHatchResult(providerRoot, "unjustified-loose-schema", findings, {
1136
- blockerMessage:
1137
- "Loose schema (z.record/z.unknown/z.any) used without justification.",
1117
+ blockerMessage: "Loose schema (z.record/z.unknown/z.any) used without justification.",
1138
1118
  remediation:
1139
1119
  "Model the real shape with a typed zod schema. If the upstream payload is genuinely arbitrary, add a `// <reason>` comment or `// @apifuse-allow loose-schema: <reason>` on the line above.",
1140
1120
  passMessage: "Loose schemas are justified or absent.",
@@ -1163,24 +1143,18 @@ function spreadIdentifierResolvesToFactory(
1163
1143
  let sawDeclaration = false;
1164
1144
  for (const filePath of [
1165
1145
  indexPath,
1166
- ...listNonTestTypeScriptFiles(providerRoot).filter(
1167
- (p) => resolve(p) !== resolve(indexPath),
1168
- ),
1146
+ ...listNonTestTypeScriptFiles(providerRoot).filter((p) => resolve(p) !== resolve(indexPath)),
1169
1147
  ]) {
1170
1148
  if (!existsSync(filePath)) {
1171
1149
  continue;
1172
1150
  }
1173
- const fileSource =
1174
- filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
1151
+ const fileSource = filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
1175
1152
  const re = new RegExp(declRe.source, "g");
1176
1153
  for (let m = re.exec(fileSource); m !== null; m = re.exec(fileSource)) {
1177
1154
  sawDeclaration = true;
1178
- const expr = unwrapParens(
1179
- balancedValueExpression(fileSource, m.index + m[0].length).trim(),
1180
- );
1155
+ const expr = unwrapParens(balancedValueExpression(fileSource, m.index + m[0].length).trim());
1181
1156
  const isFactory =
1182
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) ||
1183
- hasTopLevelFactorySpread(expr)) &&
1157
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
1184
1158
  !isTransparentObjectReshape(expr);
1185
1159
  if (isFactory) {
1186
1160
  return true;
@@ -1232,9 +1206,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1232
1206
  if (inlineDefault) {
1233
1207
  defineParenIndex = inlineDefault.index + inlineDefault[0].length - 1; // points at `(`
1234
1208
  } else {
1235
- const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(
1236
- source,
1237
- );
1209
+ const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(source);
1238
1210
  const exportedName = namedDefault?.[1];
1239
1211
  if (exportedName !== undefined) {
1240
1212
  const namedDecl = new RegExp(
@@ -1308,9 +1280,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1308
1280
  // Search index.ts first (its line attribution wins), then siblings.
1309
1281
  const searchOrder = [
1310
1282
  indexPath,
1311
- ...listNonTestTypeScriptFiles(providerRoot).filter(
1312
- (p) => resolve(p) !== resolve(indexPath),
1313
- ),
1283
+ ...listNonTestTypeScriptFiles(providerRoot).filter((p) => resolve(p) !== resolve(indexPath)),
1314
1284
  ];
1315
1285
 
1316
1286
  // Collect EVERY same-named declaration across the submission and classify
@@ -1330,23 +1300,15 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1330
1300
  if (!existsSync(filePath)) {
1331
1301
  continue;
1332
1302
  }
1333
- const fileSource =
1334
- filePath === indexPath ? source : readFileSync(filePath, "utf8");
1303
+ const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
1335
1304
  const relPath = toRelativeProviderPath(providerRoot, filePath);
1336
1305
 
1337
1306
  const declRe = new RegExp(aliasDecl.source, "g");
1338
- for (
1339
- let m = declRe.exec(fileSource);
1340
- m !== null;
1341
- m = declRe.exec(fileSource)
1342
- ) {
1307
+ for (let m = declRe.exec(fileSource); m !== null; m = declRe.exec(fileSource)) {
1343
1308
  const valueStart = m.index + m[0].length;
1344
- const expr = unwrapParens(
1345
- balancedValueExpression(fileSource, valueStart).trim(),
1346
- );
1309
+ const expr = unwrapParens(balancedValueExpression(fileSource, valueStart).trim());
1347
1310
  const isFactory =
1348
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) ||
1349
- hasTopLevelFactorySpread(expr)) &&
1311
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
1350
1312
  !isTransparentObjectReshape(expr);
1351
1313
  candidates.push({
1352
1314
  expr,
@@ -1356,11 +1318,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1356
1318
  });
1357
1319
  }
1358
1320
  const destructRe = new RegExp(destructured.source, "g");
1359
- for (
1360
- let m = destructRe.exec(fileSource);
1361
- m !== null;
1362
- m = destructRe.exec(fileSource)
1363
- ) {
1321
+ for (let m = destructRe.exec(fileSource); m !== null; m = destructRe.exec(fileSource)) {
1364
1322
  candidates.push({
1365
1323
  expr: `${m[1]}(`,
1366
1324
  line: offsetToLine(fileSource, m.index),
@@ -1388,9 +1346,9 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1388
1346
  // the unresolved import as a factory-composed (non-static) shape rather
1389
1347
  // than silently passing.
1390
1348
  if (!resolved) {
1391
- const importMatch = new RegExp(
1392
- `\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`,
1393
- ).exec(source);
1349
+ const importMatch = new RegExp(`\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`).exec(
1350
+ source,
1351
+ );
1394
1352
  if (importMatch) {
1395
1353
  effective = `${aliasName}(`;
1396
1354
  effectiveLine = offsetToLine(source, importMatch.index);
@@ -1405,8 +1363,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1405
1363
  // inspect depth-1 entries so that ordinary spreads deep inside operation
1406
1364
  // handler bodies (e.g. `{ ...headers }`, `...arr.map(...)`) are NOT mistaken
1407
1365
  // for a top-level factory composition of the operations map itself.
1408
- const hasFactorySpread =
1409
- effective !== undefined && hasTopLevelFactorySpread(effective);
1366
+ const hasFactorySpread = effective !== undefined && hasTopLevelFactorySpread(effective);
1410
1367
  // A spread of a bare identifier (`{ ...hidden }`) is static ONLY when that
1411
1368
  // identifier resolves to a non-factory declaration. Resolve each top-level
1412
1369
  // spread identifier so an opaque factory map laundered through a variable
@@ -1417,18 +1374,14 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1417
1374
  spreadIdentifierResolvesToFactory(providerRoot, indexPath, source, name),
1418
1375
  );
1419
1376
  const isStaticLiteral =
1420
- effective?.startsWith("{") === true &&
1421
- !hasFactorySpread &&
1422
- !hasFactorySpreadIdentifier;
1377
+ effective?.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
1423
1378
  // A call expression `ident(...)` (factory) or a factory-spread literal is
1424
1379
  // the rejected, non-static shape — UNLESS it is the stdlib
1425
1380
  // `Object.fromEntries(Object.entries(<source-visible obj>)...)` reshape,
1426
1381
  // whose op set is still enumerable from source (verified golden pattern).
1427
1382
  const isFactoryCall =
1428
1383
  effective !== undefined &&
1429
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) ||
1430
- hasFactorySpread ||
1431
- hasFactorySpreadIdentifier) &&
1384
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) || hasFactorySpread || hasFactorySpreadIdentifier) &&
1432
1385
  !isTransparentObjectReshape(effective);
1433
1386
 
1434
1387
  if (isFactoryCall && !isStaticLiteral) {
@@ -1436,19 +1389,13 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1436
1389
  // `// @apifuse-allow flat-operation-composition: <reason>` comment on
1437
1390
  // the reported line (or the line above) downgrades this blocker to a
1438
1391
  // counted warning, consistent with the other structural rules.
1439
- return escapeHatchResult(
1440
- providerRoot,
1441
- ruleId,
1442
- [{ file: effectiveFile, line: effectiveLine }],
1443
- {
1444
- blockerMessage:
1445
- "defineProvider operations are composed by a factory call instead of a static object literal.",
1446
- remediation:
1447
- "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>`.",
1448
- passMessage:
1449
- "defineProvider declares operations as a static object literal.",
1450
- },
1451
- );
1392
+ return escapeHatchResult(providerRoot, ruleId, [{ file: effectiveFile, line: effectiveLine }], {
1393
+ blockerMessage:
1394
+ "defineProvider operations are composed by a factory call instead of a static object literal.",
1395
+ remediation:
1396
+ "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>`.",
1397
+ passMessage: "defineProvider declares operations as a static object literal.",
1398
+ });
1452
1399
  }
1453
1400
 
1454
1401
  return pass(
@@ -1459,18 +1406,11 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1459
1406
  );
1460
1407
  }
1461
1408
 
1462
- function scoreCredentialUsage(
1463
- providerRoot: string,
1464
- provider: ProviderDefinition,
1465
- ): SubmitCheck {
1466
- const credentialReferences = findSourceLineMatches(
1467
- providerRoot,
1468
- /ctx\.credential/,
1469
- );
1409
+ function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
1410
+ const credentialReferences = findSourceLineMatches(providerRoot, /ctx\.credential/);
1470
1411
  const authMode = provider.auth?.mode ?? "none";
1471
1412
  const credentialKeys = provider.credential?.keys ?? [];
1472
- const storesProviderCredential =
1473
- authMode !== "none" || credentialKeys.length > 0;
1413
+ const storesProviderCredential = authMode !== "none" || credentialKeys.length > 0;
1474
1414
 
1475
1415
  if (storesProviderCredential && credentialReferences.length === 0) {
1476
1416
  return {
@@ -1480,8 +1420,7 @@ function scoreCredentialUsage(
1480
1420
  status: "warn",
1481
1421
  points: 0,
1482
1422
  maxPoints: 0,
1483
- message:
1484
- "Credential-backed provider does not reference credential persistence in source.",
1423
+ message: "Credential-backed provider does not reference credential persistence in source.",
1485
1424
  remediation:
1486
1425
  "Persist provider session state through the SDK credential context instead of process-local state. See providers/catchtable for the reference pattern.",
1487
1426
  };
@@ -1494,19 +1433,168 @@ function scoreCredentialUsage(
1494
1433
  ? "Provider does not declare reusable credentials."
1495
1434
  : "Credential-backed provider references ctx.credential.",
1496
1435
  0,
1497
- credentialReferences.length > 0
1498
- ? formatSourceFindings(credentialReferences)
1499
- : undefined,
1436
+ credentialReferences.length > 0 ? formatSourceFindings(credentialReferences) : undefined,
1437
+ );
1438
+ }
1439
+
1440
+ // ---------------------------------------------------------------------------
1441
+ // sdk-owned-secret-presence (warn): provider-local double validation of
1442
+ // declared env secrets.
1443
+ //
1444
+ // The SDK runtime is the single source of truth for secret presence: declared
1445
+ // `required: true` secrets are validated before every handler/auth-flow
1446
+ // invocation and fail with the canonical structured MISSING_SECRET error
1447
+ // (HTTP 400, category credential_unavailable). Provider-local presence guards
1448
+ // (requireServiceKey/requireApiKey style) are dead weight that historically
1449
+ // diverged into inconsistent shapes (CONFIGURATION_ERROR vs MISSING_SECRET,
1450
+ // with/without category), which broke uniform incident attribution when nine
1451
+ // providers shipped with unprovisioned secrets (2026-07-22).
1452
+ //
1453
+ // Heuristic, warn-only: a line reading a declared `required: true` secret via
1454
+ // `.env.get(...)` (string literal or a const alias of a declared name)
1455
+ // followed within a small window by a falsy presence check plus a `throw`.
1456
+ // The rule flags duplication of the SDK gate ONLY: env names that are not
1457
+ // declared in defineProvider secrets[], and optional declarations
1458
+ // (`required: false`/omitted) that the runtime deliberately does not enforce,
1459
+ // are out of scope. Escape hatch:
1460
+ // `// @apifuse-allow sdk-owned-secret-presence: <reason>`.
1461
+ // ---------------------------------------------------------------------------
1462
+
1463
+ const SDK_OWNED_SECRET_PRESENCE_RULE_ID = "sdk-owned-secret-presence";
1464
+ const SECRET_PRESENCE_GUARD_LOOKAHEAD_LINES = 10;
1465
+
1466
+ const ENV_GET_CALL_PATTERN =
1467
+ /\.env\.get\(\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`|([A-Za-z_$][\w$]*))\s*\)/;
1468
+
1469
+ const SECRET_ALIAS_CONST_PATTERN =
1470
+ /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`)/g;
1471
+
1472
+ const ENV_GET_ASSIGNMENT_PATTERN = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=[^;]*\.env\.get\(/;
1473
+
1474
+ // Const aliases of declared secret names (e.g. `const SERVICE_KEY_ENV =
1475
+ // "APIFUSE__PROVIDER__X__SERVICE_KEY"`) so aliased `.env.get(SERVICE_KEY_ENV)`
1476
+ // guards are detected, not just direct string literals.
1477
+ function buildDeclaredSecretAliasMap(
1478
+ providerRoot: string,
1479
+ declaredNames: ReadonlySet<string>,
1480
+ ): Map<string, string> {
1481
+ const aliases = new Map<string, string>();
1482
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
1483
+ const content = readFileSync(filePath, "utf8");
1484
+ for (const match of content.matchAll(SECRET_ALIAS_CONST_PATTERN)) {
1485
+ const alias = match[1];
1486
+ const name = match[2] ?? match[3] ?? match[4];
1487
+ if (alias && name && declaredNames.has(name)) {
1488
+ aliases.set(alias, name);
1489
+ }
1490
+ }
1491
+ }
1492
+ return aliases;
1493
+ }
1494
+
1495
+ function hasLocalSecretPresenceGuard(
1496
+ line: string,
1497
+ remainingLines: readonly string[],
1498
+ declaredNames: ReadonlySet<string>,
1499
+ aliases: ReadonlyMap<string, string>,
1500
+ ): boolean {
1501
+ const match = ENV_GET_CALL_PATTERN.exec(line);
1502
+ if (!match) {
1503
+ return false;
1504
+ }
1505
+ const literal = match[1] ?? match[2] ?? match[3];
1506
+ const identifier = match[4];
1507
+ const readsDeclaredSecret =
1508
+ literal !== undefined
1509
+ ? declaredNames.has(literal)
1510
+ : identifier !== undefined && aliases.has(identifier);
1511
+ if (!readsDeclaredSecret) {
1512
+ return false;
1513
+ }
1514
+
1515
+ const window = [line, ...remainingLines.slice(0, SECRET_PRESENCE_GUARD_LOOKAHEAD_LINES)];
1516
+ if (!window.some((candidate) => /\bthrow\b/.test(candidate))) {
1517
+ return false;
1518
+ }
1519
+
1520
+ // Assigned read (`const key = ctx.env.get(...)`): only a falsy/undefined
1521
+ // check on THAT variable counts as a presence guard. Anchoring on the
1522
+ // assigned identifier avoids false positives from unrelated guards/throws
1523
+ // that merely sit near the env read (mirrors the aliased runtime-guard rule).
1524
+ const assigned = ENV_GET_ASSIGNMENT_PATTERN.exec(line)?.[1];
1525
+ if (assigned) {
1526
+ const escaped = assigned.replace(/\$/g, "\\$");
1527
+ const guardPattern = new RegExp(
1528
+ `(?:!\\s*${escaped}\\b|\\b${escaped}\\s*===?\\s*(?:undefined|null)\\b|\\b${escaped}\\s*==\\s*null\\b|\\b${escaped}(?:\\?\\.|\\.)length\\s*===?\\s*0\\b)`,
1529
+ );
1530
+ return window.some((candidate) => guardPattern.test(candidate));
1531
+ }
1532
+
1533
+ // Un-assigned read: only an inline presence check on the same line counts,
1534
+ // e.g. `if (!ctx.env.get(KEY)) throw ...`.
1535
+ return /(?:if\s*\(\s*!|===?\s*undefined\b|==\s*null\b)/.test(line);
1536
+ }
1537
+
1538
+ function scoreSdkOwnedSecretPresence(
1539
+ providerRoot: string,
1540
+ provider: ProviderDefinition,
1541
+ ): SubmitCheck {
1542
+ const passMessage = "Provider relies on SDK-owned secret presence validation.";
1543
+ // Only `required: true` declarations: those are exactly what the runtime
1544
+ // gate enforces. A presence guard over an optional secret is conditional
1545
+ // business logic the SDK will not replace, not double validation.
1546
+ const declaredNames: ReadonlySet<string> = new Set(
1547
+ (provider.secrets ?? [])
1548
+ .filter((secret) => secret.required === true)
1549
+ .map((secret) => secret.name),
1550
+ );
1551
+ if (declaredNames.size === 0) {
1552
+ return pass(SDK_OWNED_SECRET_PRESENCE_RULE_ID, SDK_NATIVE_CATEGORY, passMessage, 0);
1553
+ }
1554
+
1555
+ const aliases = buildDeclaredSecretAliasMap(providerRoot, declaredNames);
1556
+ const findings = findSourceFindings(providerRoot, (line, remainingLines) =>
1557
+ hasLocalSecretPresenceGuard(line, remainingLines, declaredNames, aliases),
1558
+ );
1559
+ if (findings.length === 0) {
1560
+ return pass(SDK_OWNED_SECRET_PRESENCE_RULE_ID, SDK_NATIVE_CATEGORY, passMessage, 0);
1561
+ }
1562
+
1563
+ const { violations, overridden } = partitionAllowOverrides(
1564
+ providerRoot,
1565
+ findings,
1566
+ SDK_OWNED_SECRET_PRESENCE_RULE_ID,
1500
1567
  );
1568
+ if (violations.length === 0) {
1569
+ return pass(
1570
+ SDK_OWNED_SECRET_PRESENCE_RULE_ID,
1571
+ SDK_NATIVE_CATEGORY,
1572
+ `${passMessage} ${overridden.length} acknowledged @apifuse-allow override(s).`,
1573
+ 0,
1574
+ formatSourceFindings(overridden),
1575
+ );
1576
+ }
1577
+
1578
+ return {
1579
+ id: SDK_OWNED_SECRET_PRESENCE_RULE_ID,
1580
+ category: SDK_NATIVE_CATEGORY,
1581
+ level: "warn",
1582
+ status: "warn",
1583
+ points: 0,
1584
+ maxPoints: 0,
1585
+ message:
1586
+ "Provider source re-validates declared env secret presence locally; the SDK owns this check.",
1587
+ remediation:
1588
+ "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>`.",
1589
+ evidence: formatSourceFindings(violations),
1590
+ };
1501
1591
  }
1502
1592
 
1503
1593
  function findSourceLineMatches(
1504
1594
  providerRoot: string,
1505
1595
  pattern: RegExp | ((line: string) => boolean),
1506
1596
  ): SourceFinding[] {
1507
- return findSourceFindings(providerRoot, (line) =>
1508
- matchesLinePattern(line, pattern),
1509
- );
1597
+ return findSourceFindings(providerRoot, (line) => matchesLinePattern(line, pattern));
1510
1598
  }
1511
1599
 
1512
1600
  function findSourceFindings(
@@ -1533,10 +1621,7 @@ function findSourceFindings(
1533
1621
  return findings;
1534
1622
  }
1535
1623
 
1536
- function matchesLinePattern(
1537
- line: string,
1538
- pattern: RegExp | ((line: string) => boolean),
1539
- ): boolean {
1624
+ function matchesLinePattern(line: string, pattern: RegExp | ((line: string) => boolean)): boolean {
1540
1625
  return typeof pattern === "function" ? pattern(line) : pattern.test(line);
1541
1626
  }
1542
1627
 
@@ -1590,11 +1675,7 @@ function collectNonTestTypeScriptFiles(
1590
1675
  }
1591
1676
  continue;
1592
1677
  }
1593
- if (
1594
- entry.isFile() &&
1595
- relativePath.endsWith(".ts") &&
1596
- !isExcludedTestSource(relativePath)
1597
- ) {
1678
+ if (entry.isFile() && relativePath.endsWith(".ts") && !isExcludedTestSource(relativePath)) {
1598
1679
  files.push(entryPath);
1599
1680
  }
1600
1681
  }
@@ -1608,10 +1689,12 @@ function isScannableProviderSourceFile(relativePath: string): boolean {
1608
1689
  );
1609
1690
  }
1610
1691
 
1692
+ // `.agents`/`.apifuse` stay IN scope on purpose: managed content there is
1693
+ // markdown/JSON (never scannable), while a planted `.ts`/`.sh` under those
1694
+ // directories must not become a scan-exempt hiding place for secrets, raw
1695
+ // fetch, or vendor imports.
1611
1696
  function shouldScanSourceDirectory(relativePath: string): boolean {
1612
- return ![".git", "node_modules", "dist", "build", "coverage"].includes(
1613
- relativePath,
1614
- );
1697
+ return ![".git", "node_modules", "dist", "build", "coverage"].includes(relativePath);
1615
1698
  }
1616
1699
 
1617
1700
  function isExcludedTestSource(relativePath: string): boolean {
@@ -1624,10 +1707,7 @@ function isExcludedTestSource(relativePath: string): boolean {
1624
1707
  );
1625
1708
  }
1626
1709
 
1627
- function toRelativeProviderPath(
1628
- providerRoot: string,
1629
- filePath: string,
1630
- ): string {
1710
+ function toRelativeProviderPath(providerRoot: string, filePath: string): string {
1631
1711
  return relative(providerRoot, filePath).replaceAll("\\", "/");
1632
1712
  }
1633
1713
 
@@ -1635,11 +1715,35 @@ function formatSourceFindings(findings: readonly SourceFinding[]): string[] {
1635
1715
  return findings.map((finding) => `${finding.file}:${finding.line}`);
1636
1716
  }
1637
1717
 
1718
+ function scorePromptAssetFreshness(providerRoot: string): SubmitCheck {
1719
+ const verification = verifyPromptAssets(providerRoot);
1720
+ if (verification.ok) {
1721
+ return pass(
1722
+ "prompt-assets-fresh",
1723
+ "docs",
1724
+ "SDK-managed agent prompt assets match the installed SDK version.",
1725
+ 0,
1726
+ );
1727
+ }
1728
+
1729
+ return blocker(
1730
+ "prompt-assets-fresh",
1731
+ "docs",
1732
+ "SDK-managed agent prompt assets are missing, stale, or modified.",
1733
+ "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.",
1734
+ 0,
1735
+ formatPromptAssetIssues(verification),
1736
+ );
1737
+ }
1738
+
1638
1739
  function scoreRepositoryDx(providerRoot: string): SubmitCheck {
1639
1740
  const missing: string[] = [];
1640
1741
  if (!existsSync(resolve(providerRoot, ".gitignore"))) {
1641
1742
  missing.push(".gitignore");
1642
1743
  }
1744
+ if (!existsSync(resolve(providerRoot, "AGENTS.md"))) {
1745
+ missing.push("AGENTS.md");
1746
+ }
1643
1747
 
1644
1748
  const packageJsonPath = resolve(providerRoot, "package.json");
1645
1749
  const packageScripts = readPackageScripts(packageJsonPath);
@@ -1668,14 +1772,12 @@ function scoreRepositoryDx(providerRoot: string): SubmitCheck {
1668
1772
  maxPoints: 0,
1669
1773
  message: `Generated repository DX guardrails are missing: ${missing.join(", ")}.`,
1670
1774
  remediation:
1671
- "Regenerate with the current `apifuse create` template or add .gitignore plus `type-check: tsc --noEmit` and include it from `check`.",
1775
+ "Regenerate with the current `apifuse create` template or restore the missing files: .gitignore, AGENTS.md (agent contribution guide), plus `type-check: tsc --noEmit` included from `check`.",
1672
1776
  evidence: missing,
1673
1777
  };
1674
1778
  }
1675
1779
 
1676
- function readPackageScripts(
1677
- packageJsonPath: string,
1678
- ): Record<string, unknown> | undefined {
1780
+ function readPackageScripts(packageJsonPath: string): Record<string, unknown> | undefined {
1679
1781
  if (!existsSync(packageJsonPath)) {
1680
1782
  return undefined;
1681
1783
  }
@@ -1790,6 +1892,10 @@ function scoreManagedBrowserRuntime(providerRoot: string): SubmitCheck {
1790
1892
  function scoreBaseChecks(results: CheckResult[]): SubmitCheck[] {
1791
1893
  const failed = results.filter((result) => !result.passed);
1792
1894
  if (failed.length > 0) {
1895
+ const remediation = [
1896
+ "Run `bunx apifuse check .` from the provider root.",
1897
+ ...Array.from(new Set(failed.map(baseCheckRemediation))),
1898
+ ].join(" ");
1793
1899
  return [
1794
1900
  {
1795
1901
  id: "base-checks",
@@ -1799,8 +1905,7 @@ function scoreBaseChecks(results: CheckResult[]): SubmitCheck[] {
1799
1905
  points: 0,
1800
1906
  maxPoints: CATEGORY_MAX_POINTS.definition,
1801
1907
  message: "Base provider checks failed.",
1802
- remediation:
1803
- "Run `bun run check` and fix every failing item before bounty submission.",
1908
+ remediation,
1804
1909
  evidence: failed.map((result) =>
1805
1910
  redact(`${result.message}: ${(result.details ?? []).join("; ")}`),
1806
1911
  ),
@@ -1822,10 +1927,32 @@ function scoreBaseChecks(results: CheckResult[]): SubmitCheck[] {
1822
1927
  ];
1823
1928
  }
1824
1929
 
1825
- function scoreLocaleCatalog(
1826
- providerRoot: string,
1827
- provider: ProviderDefinition,
1828
- ): SubmitCheck {
1930
+ function baseCheckRemediation(result: CheckResult): string {
1931
+ switch (result.message) {
1932
+ case "index.ts exists and exports default defineProvider":
1933
+ return "Fix `index.ts` so it default-exports `defineProvider({...})`.";
1934
+ case "All operations have handler, input, output":
1935
+ return "For each operation named in evidence, add `handler`, `input`, and `output` fields to `defineProvider({ operations })`.";
1936
+ case "All operations have fixtures":
1937
+ return "For each operation named in evidence, add `fixtures.request` and `fixtures.response` values that exercise the operation schemas.";
1938
+ case "Zod schemas parse fixtures without error":
1939
+ return "Update the failing fixture values or their zod schemas until `fixtures.request` and `fixtures.response` parse cleanly.";
1940
+ case "Provider authoring lint has no error-level diagnostics":
1941
+ return "Fix each lint diagnostic shown in evidence, then rerun `bunx apifuse check .`.";
1942
+ case "Provider metadata is declared in defineProvider":
1943
+ return "Fill the missing `defineProvider` metadata fields: `id`, `meta.displayName`, `meta.category`, `runtime`, and `auth.mode`.";
1944
+ case "Dockerfile exists":
1945
+ return "Add a provider-root `Dockerfile` based on the current `apifuse create` template.";
1946
+ case "package.json exists with @apifuse/provider-sdk dependency":
1947
+ return "Add `@apifuse/provider-sdk` to `package.json` dependencies.";
1948
+ case "Base provider checks can run":
1949
+ return "Fix the import/runtime error shown in evidence so `apifuse check` can load the provider.";
1950
+ default:
1951
+ return `Fix the failing base check "${result.message}" shown in evidence.`;
1952
+ }
1953
+ }
1954
+
1955
+ function scoreLocaleCatalog(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
1829
1956
  const requiredKeys = collectProviderRequiredLocaleKeys(provider);
1830
1957
  if (requiredKeys.length === 0) {
1831
1958
  return pass(
@@ -1856,9 +1983,7 @@ function scoreLocaleCatalog(
1856
1983
  "Provider locale catalog is missing required public-provider copy.",
1857
1984
  "Add provider-local locales/en.json and locales/ko.json values for every provider metadata key, operation descriptionKey, and .describeKey() or describeKey() schema field.",
1858
1985
  0,
1859
- validation.issues.map(
1860
- (issue) => `${issue.locale}:${issue.key}: ${issue.message}`,
1861
- ),
1986
+ validation.issues.map((issue) => `${issue.locale}:${issue.key}: ${issue.message}`),
1862
1987
  );
1863
1988
  }
1864
1989
  } catch (error) {
@@ -1881,9 +2006,7 @@ function scoreLocaleCatalog(
1881
2006
  );
1882
2007
  }
1883
2008
 
1884
- function collectProviderRequiredLocaleKeys(
1885
- provider: ProviderDefinition,
1886
- ): string[] {
2009
+ function collectProviderRequiredLocaleKeys(provider: ProviderDefinition): string[] {
1887
2010
  const keys = new Set<string>();
1888
2011
 
1889
2012
  addLocaleKeys(keys, [
@@ -1946,10 +2069,7 @@ function collectSchemaDescriptionKeys(schema: unknown): string[] {
1946
2069
  return keys;
1947
2070
  }
1948
2071
 
1949
- function collectJsonSchemaDescriptionKeys(
1950
- schema: Record<string, unknown>,
1951
- keys: string[],
1952
- ): void {
2072
+ function collectJsonSchemaDescriptionKeys(schema: Record<string, unknown>, keys: string[]): void {
1953
2073
  const descriptionKey = schema[APIFUSE_DESCRIPTION_KEY_META_KEY];
1954
2074
  if (typeof descriptionKey === "string" && descriptionKey.length > 0) {
1955
2075
  keys.push(descriptionKey);
@@ -1981,8 +2101,7 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
1981
2101
  // is enforced at registry catalog-build time, matching how lintOperation
1982
2102
  // skips the raw-description min-length rule when a descriptionKey is set.
1983
2103
  const hasDescriptionKey =
1984
- typeof operation.descriptionKey === "string" &&
1985
- operation.descriptionKey.length > 0;
2104
+ typeof operation.descriptionKey === "string" && operation.descriptionKey.length > 0;
1986
2105
  if (hasDescriptionKey) return false;
1987
2106
  return true;
1988
2107
  })
@@ -2000,14 +2119,12 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
2000
2119
  points: 0,
2001
2120
  maxPoints: CATEGORY_MAX_POINTS.operations,
2002
2121
  message: "One or more operations have weak descriptions.",
2003
- remediation:
2004
- "Add 150+ character English descriptions explaining when to use, when not to use, outputs, and caveats.",
2122
+ remediation: `For ${weakDescriptions.join(", ")}, add an operation \`descriptionKey\` backed by \`locales/en.json\` and \`locales/ko.json\`, or add a 150+ character \`description\` explaining when to use it, when not to use it, outputs, and caveats.`,
2005
2123
  evidence: weakDescriptions,
2006
2124
  };
2007
2125
  }
2008
2126
 
2009
- const points =
2010
- missingAnnotations.length > 0 ? 11 : CATEGORY_MAX_POINTS.operations;
2127
+ const points = missingAnnotations.length > 0 ? 11 : CATEGORY_MAX_POINTS.operations;
2011
2128
  return {
2012
2129
  id: "operation-metadata",
2013
2130
  category: "operations",
@@ -2021,30 +2138,25 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
2021
2138
  : "Operation descriptions and metadata are review-ready.",
2022
2139
  remediation:
2023
2140
  missingAnnotations.length > 0
2024
- ? "Add annotations such as readOnly, destructive, idempotent, openWorld, rateLimit, or timeoutMs where applicable."
2141
+ ? `For ${missingAnnotations.join(", ")}, add \`annotations\` with the applicable safety fields, such as \`readOnly\`, \`destructive\`, \`idempotent\`, \`openWorld\`, \`rateLimit\`, or \`timeoutMs\`.`
2025
2142
  : undefined,
2026
2143
  evidence:
2027
2144
  missingAnnotations.length > 0
2028
- ? missingAnnotations.map(
2029
- (operationId) => `${operationId}: missing annotations`,
2030
- )
2145
+ ? missingAnnotations.map((operationId) => `${operationId}: missing annotations`)
2031
2146
  : operations.map(([operationId]) => operationId),
2032
2147
  };
2033
2148
  }
2034
2149
 
2035
2150
  function scoreFixtureCoverage(provider: ProviderDefinition): SubmitCheck {
2036
2151
  const missing = Object.entries(provider.operations)
2037
- .filter(
2038
- ([, operation]) =>
2039
- !operation.fixtures?.request || !operation.fixtures?.response,
2040
- )
2152
+ .filter(([, operation]) => !operation.fixtures?.request || !operation.fixtures?.response)
2041
2153
  .map(([operationId]) => operationId);
2042
2154
  if (missing.length > 0) {
2043
2155
  return blocker(
2044
2156
  "fixtures",
2045
2157
  "fixtures",
2046
2158
  "One or more operations are missing bidirectional fixtures.",
2047
- "Add fixtures.request and fixtures.response that parse against operation schemas.",
2159
+ `For ${missing.join(", ")}, add \`fixtures.request\` and \`fixtures.response\` values that parse against the operation input and output schemas.`,
2048
2160
  CATEGORY_MAX_POINTS.fixtures,
2049
2161
  missing,
2050
2162
  );
@@ -2057,97 +2169,800 @@ function scoreFixtureCoverage(provider: ProviderDefinition): SubmitCheck {
2057
2169
  );
2058
2170
  }
2059
2171
 
2060
- function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
2061
- const operations = Object.entries(provider.operations);
2062
- const missing: string[] = [];
2063
- const placeholder: string[] = [];
2064
- const unsupported: string[] = [];
2065
- const generatedStarter: string[] = [];
2172
+ const GENERATED_LOCAL_ONLY_SCAFFOLD_REASON = /generated local-only scaffold/i;
2066
2173
 
2067
- for (const [operationId, operation] of operations) {
2068
- const hasCheck = operation.healthCheck !== undefined;
2069
- const hasUnsupported = operation.healthCheckUnsupported !== undefined;
2070
- if (!hasCheck && !hasUnsupported) {
2071
- missing.push(operationId);
2072
- continue;
2073
- }
2074
- if (hasUnsupported) {
2075
- const reason = operation.healthCheckUnsupported?.reason ?? "";
2076
- unsupported.push(operationId);
2077
- if (/generated local-only scaffold/i.test(reason)) {
2078
- generatedStarter.push(operationId);
2079
- }
2080
- if (
2081
- /(todo|later|tbd|test fixture|unit test|placeholder|not sure|skip for test)/i.test(
2082
- reason,
2083
- )
2084
- ) {
2085
- placeholder.push(operationId);
2086
- }
2174
+ function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
2175
+ const rawPath = resolve(providerRoot, "__fixtures__", "raw.json");
2176
+ let hasRecordedEvidence = false;
2177
+ if (existsSync(rawPath)) {
2178
+ try {
2179
+ hasRecordedEvidence = hasNonEmptyRecordedFixture(JSON.parse(readFileSync(rawPath, "utf8")));
2180
+ } catch {
2181
+ hasRecordedEvidence = false;
2087
2182
  }
2088
2183
  }
2089
2184
 
2090
- if (missing.length > 0) {
2091
- return blocker(
2092
- "health-coverage",
2093
- "health",
2094
- "One or more operations lack healthCheck or healthCheckUnsupported.",
2095
- "Declare a safe healthCheck for read-only upstream probes or a specific healthCheckUnsupported.reason.",
2096
- CATEGORY_MAX_POINTS.health,
2097
- missing,
2185
+ if (hasRecordedEvidence) {
2186
+ return pass(
2187
+ "fixture-provenance",
2188
+ "fixtures",
2189
+ "Recorded upstream fixture evidence is present.",
2190
+ 0,
2098
2191
  );
2099
2192
  }
2100
2193
 
2101
- if (placeholder.length > 0) {
2194
+ if (allOperationsAreGeneratedLocalScaffold(provider)) {
2102
2195
  return {
2103
- id: "health-coverage",
2104
- category: "health",
2196
+ id: "fixture-provenance",
2197
+ category: "fixtures",
2105
2198
  level: "warn",
2106
2199
  status: "warn",
2107
- points: 8,
2108
- maxPoints: CATEGORY_MAX_POINTS.health,
2109
- message: "Some healthCheckUnsupported reasons look placeholder-like.",
2200
+ points: 0,
2201
+ maxPoints: 0,
2202
+ message:
2203
+ "Generated local-only scaffold has no recorded upstream fixture evidence yet; run `bun run record` once real operations exist.",
2110
2204
  remediation:
2111
- "Replace placeholder rationale with a specific reason such as destructive mutation, paid call, credential sensitivity, or upstream flakiness.",
2112
- evidence: placeholder,
2205
+ "Run `bun run record` (apifuse record) against the real upstream to capture raw payloads once real operations exist.",
2206
+ evidence: ["__fixtures__/raw.json"],
2113
2207
  };
2114
2208
  }
2115
2209
 
2116
- if (generatedStarter.length > 0) {
2210
+ return blocker(
2211
+ "fixture-provenance",
2212
+ "fixtures",
2213
+ "No recorded upstream fixture evidence (__fixtures__/raw.json is empty or missing).",
2214
+ "Run `bun run record` (apifuse record) against the real upstream to capture actual recorded upstream payloads per operation in __fixtures__/raw.json; derive normalized expectations in tests from mapper(recorded raw). Hand-authored fixtures without recorded provenance are not reviewable.",
2215
+ 0,
2216
+ ["__fixtures__/raw.json"],
2217
+ );
2218
+ }
2219
+
2220
+ export function hasNonEmptyRecordedFixture(value: unknown): boolean {
2221
+ return recordedFixtureStats(value, 0).hasNestedSubstance;
2222
+ }
2223
+
2224
+ function recordedFixtureStats(
2225
+ value: unknown,
2226
+ depth: number,
2227
+ ): { hasNestedSubstance: boolean; leafValues: number } {
2228
+ if (value === null || value === undefined) {
2229
+ return { hasNestedSubstance: false, leafValues: 0 };
2230
+ }
2231
+ if (Array.isArray(value)) {
2232
+ let leafValues = 0;
2233
+ let hasNestedSubstance = false;
2234
+ for (const item of value) {
2235
+ const child = recordedFixtureStats(item, depth + 1);
2236
+ leafValues += child.leafValues;
2237
+ hasNestedSubstance ||= child.hasNestedSubstance;
2238
+ }
2117
2239
  return {
2118
- id: "health-coverage",
2119
- category: "health",
2120
- level: "warn",
2121
- status: "warn",
2122
- points: 10,
2123
- maxPoints: CATEGORY_MAX_POINTS.health,
2124
- message:
2125
- "Generated starter operation health rationale is present; replace starter logic before bounty submission.",
2126
- remediation:
2127
- "Replace `ping` with real upstream-backed operations and prefer real healthCheck for safe read-only probes.",
2128
- evidence: generatedStarter,
2240
+ hasNestedSubstance: hasNestedSubstance || (depth >= 1 && value.length > 0 && leafValues >= 2),
2241
+ leafValues,
2129
2242
  };
2130
2243
  }
2131
-
2132
- if (unsupported.length > 0) {
2244
+ if (typeof value === "object") {
2245
+ let leafValues = 0;
2246
+ let hasNestedSubstance = false;
2247
+ for (const item of Object.values(value)) {
2248
+ const child = recordedFixtureStats(item, depth + 1);
2249
+ leafValues += child.leafValues;
2250
+ hasNestedSubstance ||= child.hasNestedSubstance;
2251
+ }
2133
2252
  return {
2134
- id: "health-coverage",
2135
- category: "health",
2136
- level: "warn",
2137
- status: "warn",
2138
- points: 12,
2139
- maxPoints: CATEGORY_MAX_POINTS.health,
2140
- message:
2141
- "Health coverage is declared, with one or more unsupported probes.",
2142
- remediation:
2143
- "Reviewers prefer real healthCheck for safe read-only upstream operations.",
2144
- evidence: unsupported.map(
2145
- (operationId) => `${operationId}: healthCheckUnsupported`,
2146
- ),
2253
+ hasNestedSubstance:
2254
+ hasNestedSubstance || (depth >= 1 && Object.keys(value).length > 0 && leafValues >= 2),
2255
+ leafValues,
2256
+ };
2257
+ }
2258
+ if (typeof value === "string") {
2259
+ if (value.length === 0) {
2260
+ return { hasNestedSubstance: false, leafValues: 0 };
2261
+ }
2262
+ // A recorded operation value may be a raw XML or delimited-text success
2263
+ // payload. Treat a substantive one as nested evidence while still counting
2264
+ // the string as a leaf so existing JSON provenance heuristics are unchanged.
2265
+ return {
2266
+ hasNestedSubstance:
2267
+ hasSubstantiveXmlStructure(value) || hasSubstantiveDelimitedTextStructure(value),
2268
+ leafValues: 1,
2147
2269
  };
2148
2270
  }
2271
+ return { hasNestedSubstance: false, leafValues: 1 };
2272
+ }
2149
2273
 
2150
- return pass(
2274
+ function allOperationsAreGeneratedLocalScaffold(provider: ProviderDefinition): boolean {
2275
+ const operations = Object.values(provider.operations);
2276
+ return (
2277
+ operations.length > 0 &&
2278
+ operations.every((operation) =>
2279
+ GENERATED_LOCAL_ONLY_SCAFFOLD_REASON.test(operation.healthCheckUnsupported?.reason ?? ""),
2280
+ )
2281
+ );
2282
+ }
2283
+
2284
+ function scoreVendorKeyLeak(providerRoot: string): SubmitCheck {
2285
+ return escapeHatchResult(
2286
+ providerRoot,
2287
+ "vendor-key-leak",
2288
+ findVendorKeyLeakFindings(providerRoot),
2289
+ {
2290
+ blockerMessage: "Public schema keys leak raw vendor field names.",
2291
+ remediation:
2292
+ "Normalize public request/response fields to APIFuse-standard lowerCamelCase names (e.g. isOpen24h, latitude); keep raw vendor keys only in upstream-parsing schemas (const upstream... = z.object(...)). Add `// @apifuse-allow vendor-key-leak` only with a comment explaining why the vendor name is genuinely canonical.",
2293
+ passMessage: "No vendor field-name leaks detected in public schemas.",
2294
+ },
2295
+ );
2296
+ }
2297
+
2298
+ function scoreVendorTimestampLeak(providerRoot: string): SubmitCheck {
2299
+ return escapeHatchResult(
2300
+ providerRoot,
2301
+ "vendor-timestamp-leak",
2302
+ findVendorTimestampLeakFindings(providerRoot),
2303
+ {
2304
+ blockerMessage: "Normalized fixtures carry raw vendor timestamp formats.",
2305
+ remediation:
2306
+ "Convert vendor compact timestamps (yyyymmdd, HHmm, yyyymmddHHmmss) to ISO 8601 (date, time with timezone) at the mapper boundary; fixtures.response must show the normalized form. Add `// @apifuse-allow vendor-timestamp-leak` only when the value is genuinely not a timestamp.",
2307
+ passMessage: "No vendor timestamp formats detected in normalized fixtures.",
2308
+ },
2309
+ );
2310
+ }
2311
+
2312
+ type ObjectRange = {
2313
+ start: number;
2314
+ end: number;
2315
+ };
2316
+
2317
+ type ZObjectLiteral = {
2318
+ objectStart: number;
2319
+ objectEnd: number;
2320
+ callStart: number;
2321
+ };
2322
+
2323
+ type NamedObjectRange = ObjectRange & {
2324
+ name: string;
2325
+ };
2326
+
2327
+ function findVendorKeyLeakFindings(providerRoot: string): SourceFinding[] {
2328
+ const findings: SourceFinding[] = [];
2329
+ const seen = new Set<string>();
2330
+
2331
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
2332
+ const source = readFileSync(filePath, "utf8");
2333
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
2334
+ const upstreamRanges = findUpstreamMarkedConstRanges(source);
2335
+ for (const zObject of findZObjectLiterals(source)) {
2336
+ if (rangeContainsOffset(upstreamRanges, zObject.callStart)) {
2337
+ continue;
2338
+ }
2339
+ if (!zObjectAppearsPublicOutput(source, zObject)) {
2340
+ continue;
2341
+ }
2342
+ for (const keyFinding of vendorKeyFindingsForObject(source, zObject)) {
2343
+ const key = `${relPath}:${keyFinding.line}:${keyFinding.key}`;
2344
+ if (!seen.has(key)) {
2345
+ seen.add(key);
2346
+ findings.push({ file: relPath, line: keyFinding.line });
2347
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
2348
+ return findings;
2349
+ }
2350
+ }
2351
+ }
2352
+ }
2353
+ }
2354
+
2355
+ return findings;
2356
+ }
2357
+
2358
+ function findZObjectLiterals(source: string): ZObjectLiteral[] {
2359
+ const literals: ZObjectLiteral[] = [];
2360
+ const masked = maskCommentsAndStrings(source);
2361
+ const callPattern = /\bz\s*\.\s*object\s*\(/g;
2362
+ for (let match = callPattern.exec(masked); match !== null; match = callPattern.exec(masked)) {
2363
+ const parenIndex = masked.indexOf("(", match.index);
2364
+ const objectStart = findNextNonWhitespace(masked, parenIndex + 1);
2365
+ if (objectStart === -1 || masked[objectStart] !== "{") {
2366
+ continue;
2367
+ }
2368
+ const objectEnd = findMatchingBracket(masked, objectStart);
2369
+ if (objectEnd === -1) {
2370
+ continue;
2371
+ }
2372
+ literals.push({
2373
+ objectStart,
2374
+ objectEnd,
2375
+ callStart: match.index,
2376
+ });
2377
+ callPattern.lastIndex = objectEnd;
2378
+ }
2379
+ return literals;
2380
+ }
2381
+
2382
+ function zObjectAppearsPublicOutput(source: string, zObject: ZObjectLiteral): boolean {
2383
+ const enclosingConst = findConstValueRangeContaining(source, zObject.callStart);
2384
+ if (enclosingConst && /output|response|result/i.test(enclosingConst.name)) {
2385
+ return true;
2386
+ }
2387
+ const before = source.slice(Math.max(0, zObject.callStart - 160), zObject.callStart);
2388
+ return /(?:^|[\s,{])(?:output|response)\s*:\s*$/.test(before);
2389
+ }
2390
+
2391
+ function vendorKeyFindingsForObject(
2392
+ source: string,
2393
+ zObject: ZObjectLiteral,
2394
+ ): Array<{ key: string; line: number }> {
2395
+ const keys = collectTopLevelObjectKeys(source, zObject.objectStart, zObject.objectEnd);
2396
+ const digitFamilies = new Map<string, Set<string>>();
2397
+ for (const key of keys) {
2398
+ const member = numberedFamilyMember(key.name);
2399
+ if (!member) {
2400
+ continue;
2401
+ }
2402
+ const positions = digitFamilies.get(member.base) ?? new Set<string>();
2403
+ positions.add(member.position);
2404
+ digitFamilies.set(member.base, positions);
2405
+ }
2406
+
2407
+ return keys
2408
+ .filter((key) => {
2409
+ if (!isAllowedPublicOutputKeyName(key.name)) {
2410
+ return true;
2411
+ }
2412
+ const member = numberedFamilyMember(key.name);
2413
+ return member !== null && (digitFamilies.get(member.base)?.size ?? 0) >= 3;
2414
+ })
2415
+ .map((key) => ({ key: key.name, line: offsetToLine(source, key.offset) }));
2416
+ }
2417
+
2418
+ // A numbered vendor family is a base name plus a numeric position and an
2419
+ // optional trailing letter suffix, in either compact/camel form (sensor1,
2420
+ // duty1s) or semantic snake_case form (sensor_1, duty_time_1s). Both styles
2421
+ // normalize to the same { base, position } so a family of >=3 distinct
2422
+ // positions is caught regardless of which naming style the vendor leaked
2423
+ // through. Returns null for names that carry no numeric position.
2424
+ function numberedFamilyMember(name: string): { base: string; position: string } | null {
2425
+ const camelMatch = /^([a-z][a-zA-Z]*)(\d+)[a-z]*$/i.exec(name);
2426
+ if (camelMatch?.[1] && camelMatch[2]) {
2427
+ return { base: camelMatch[1], position: camelMatch[2] };
2428
+ }
2429
+ const snakeMatch = /^([a-z][a-z0-9]*(?:_[a-z0-9]+)*?)_(\d+)[a-z]*$/.exec(name);
2430
+ if (snakeMatch?.[1] && snakeMatch[2]) {
2431
+ return { base: snakeMatch[1], position: snakeMatch[2] };
2432
+ }
2433
+ return null;
2434
+ }
2435
+
2436
+ // Public output keys may use APIFuse lowerCamelCase (isOpen24h, latitude) or
2437
+ // semantic snake_case (pharmacy_id, weekly_hours, total_count, scan_exhausted).
2438
+ // Both are normalized, human-authored names. Raw vendor keys leak through mixed
2439
+ // case or uppercase acronyms (MKioskTy) and match neither, so they stay flagged.
2440
+ // Numbered vendor families still pass this name gate in either style
2441
+ // (sensor1/2/3 or sensor_1/sensor_2/sensor_3), so they are caught separately by
2442
+ // the >=3-member numberedFamilyMember check in vendorKeyFindingsForObject.
2443
+ function isAllowedPublicOutputKeyName(name: string): boolean {
2444
+ const isLowerCamelCase = /^[a-z][a-zA-Z0-9]*$/.test(name);
2445
+ const isSemanticSnakeCase = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/.test(name);
2446
+ return isLowerCamelCase || isSemanticSnakeCase;
2447
+ }
2448
+
2449
+ function collectTopLevelObjectKeys(
2450
+ source: string,
2451
+ objectStart: number,
2452
+ objectEnd: number,
2453
+ ): Array<{ name: string; offset: number }> {
2454
+ const keys: Array<{ name: string; offset: number }> = [];
2455
+ const masked = maskCommentsAndStrings(source);
2456
+ let index = objectStart + 1;
2457
+ while (index < objectEnd) {
2458
+ index = skipWhitespaceAndComments(masked, index, objectEnd);
2459
+ if (index >= objectEnd || masked[index] === "}") {
2460
+ break;
2461
+ }
2462
+ const keyStart = index;
2463
+ let key: string | undefined;
2464
+ const quote = source[index];
2465
+ if (quote === '"' || quote === "'") {
2466
+ const endQuote = findStringEnd(source, index);
2467
+ if (endQuote === -1) {
2468
+ break;
2469
+ }
2470
+ key = source.slice(index + 1, endQuote);
2471
+ index = endQuote + 1;
2472
+ } else if (masked[index] === "[") {
2473
+ const computedEnd = findMatchingBracket(masked, index);
2474
+ const literalStart = findNextNonWhitespace(masked, index + 1);
2475
+ if (computedEnd === -1 || literalStart === -1) {
2476
+ break;
2477
+ }
2478
+ const computedQuote = source[literalStart];
2479
+ if (computedQuote === '"' || computedQuote === "'") {
2480
+ const literalEnd = findStringEnd(source, literalStart);
2481
+ const afterLiteral =
2482
+ literalEnd === -1 ? -1 : skipWhitespaceAndComments(masked, literalEnd + 1, computedEnd);
2483
+ if (literalEnd !== -1 && afterLiteral === computedEnd) {
2484
+ key = source.slice(literalStart + 1, literalEnd);
2485
+ }
2486
+ }
2487
+ index = computedEnd + 1;
2488
+ } else {
2489
+ const idMatch = /^[A-Za-z_$][\w$]*/.exec(masked.slice(index));
2490
+ if (idMatch?.[0]) {
2491
+ key = idMatch[0];
2492
+ index += idMatch[0].length;
2493
+ }
2494
+ }
2495
+ index = skipWhitespaceAndComments(masked, index, objectEnd);
2496
+ if (key && masked[index] === ":") {
2497
+ keys.push({ name: key, offset: keyStart });
2498
+ index = skipObjectValue(masked, index + 1, objectEnd);
2499
+ } else {
2500
+ // Spread-based composition is intentionally not expanded here; this gate
2501
+ // only evaluates keys visible in the object literal.
2502
+ index = skipObjectValue(masked, index, objectEnd);
2503
+ }
2504
+ if (masked[index] === ",") {
2505
+ index += 1;
2506
+ }
2507
+ }
2508
+ return keys;
2509
+ }
2510
+
2511
+ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[] {
2512
+ const findings: SourceFinding[] = [];
2513
+ const seen = new Set<string>();
2514
+
2515
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
2516
+ const source = readFileSync(filePath, "utf8");
2517
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
2518
+ const zObjectRanges = findZObjectLiterals(source).map((zObject) => ({
2519
+ start: zObject.callStart,
2520
+ end: zObject.objectEnd,
2521
+ }));
2522
+ const upstreamRanges = findUpstreamMarkedConstRanges(source);
2523
+ const fixtureRanges = findPropertyObjectRanges(source, "fixtures");
2524
+ const fixtureResponseRanges = [
2525
+ ...findPropertyObjectRanges(source, "response"),
2526
+ ...findPropertyObjectRanges(source, "output"),
2527
+ ].filter((range) => rangeContainedInRanges(fixtureRanges, range));
2528
+
2529
+ for (const range of fixtureResponseRanges) {
2530
+ for (const literal of findStringLiteralsInRange(source, range)) {
2531
+ if (
2532
+ rangeContainsOffset(zObjectRanges, literal.offset) ||
2533
+ rangeContainsOffset(upstreamRanges, literal.offset) ||
2534
+ !isVendorTimestampCandidate(
2535
+ literal.value,
2536
+ propertyKeyForStringLiteral(source, literal.offset),
2537
+ )
2538
+ ) {
2539
+ continue;
2540
+ }
2541
+ const line = offsetToLine(source, literal.offset);
2542
+ const key = `${relPath}:${line}:${literal.value}`;
2543
+ if (!seen.has(key)) {
2544
+ seen.add(key);
2545
+ findings.push({ file: relPath, line });
2546
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
2547
+ return findings;
2548
+ }
2549
+ }
2550
+ }
2551
+ }
2552
+ }
2553
+
2554
+ return findings;
2555
+ }
2556
+
2557
+ function findPropertyObjectRanges(source: string, propertyName: string): ObjectRange[] {
2558
+ const ranges: ObjectRange[] = [];
2559
+ const masked = maskCommentsAndStrings(source);
2560
+ const escaped = propertyName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2561
+ const pattern = new RegExp(`(?:^|[^\\w$])["']?${escaped}["']?\\s*:`, "g");
2562
+ for (let match = pattern.exec(masked); match !== null; match = pattern.exec(masked)) {
2563
+ const objectStart = findNextNonWhitespace(masked, match.index + match[0].length);
2564
+ if (objectStart === -1 || masked[objectStart] !== "{") {
2565
+ continue;
2566
+ }
2567
+ const objectEnd = findMatchingBracket(masked, objectStart);
2568
+ if (objectEnd === -1) {
2569
+ continue;
2570
+ }
2571
+ ranges.push({ start: objectStart, end: objectEnd });
2572
+ pattern.lastIndex = objectEnd;
2573
+ }
2574
+ return ranges;
2575
+ }
2576
+
2577
+ function findUpstreamMarkedConstRanges(source: string): ObjectRange[] {
2578
+ return findNamedConstValueRanges(source)
2579
+ .filter((range) => /upstream|raw|vendor/i.test(range.name))
2580
+ .map(({ start, end }) => ({ start, end }));
2581
+ }
2582
+
2583
+ function findNamedConstValueRanges(source: string): NamedObjectRange[] {
2584
+ const ranges: NamedObjectRange[] = [];
2585
+ const masked = maskCommentsAndStrings(source);
2586
+ const pattern =
2587
+ /(?:^|\n)[ \t]*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?\s*=/g;
2588
+ for (let match = pattern.exec(masked); match !== null; match = pattern.exec(masked)) {
2589
+ const name = match[1];
2590
+ if (!name) {
2591
+ continue;
2592
+ }
2593
+ const start = match.index + match[0].length;
2594
+ const expression = balancedValueExpression(masked, start);
2595
+ ranges.push({ name, start, end: start + expression.length });
2596
+ }
2597
+ return ranges;
2598
+ }
2599
+
2600
+ function findConstValueRangeContaining(
2601
+ source: string,
2602
+ offset: number,
2603
+ ): NamedObjectRange | undefined {
2604
+ return findNamedConstValueRanges(source).find(
2605
+ (range) => offset >= range.start && offset <= range.end,
2606
+ );
2607
+ }
2608
+
2609
+ function findStringLiteralsInRange(
2610
+ source: string,
2611
+ range: ObjectRange,
2612
+ ): Array<{ value: string; offset: number }> {
2613
+ const literals: Array<{ value: string; offset: number }> = [];
2614
+ let index = range.start;
2615
+ while (index <= range.end) {
2616
+ const quote = source[index];
2617
+ if (quote !== '"' && quote !== "'" && quote !== "`") {
2618
+ index += 1;
2619
+ continue;
2620
+ }
2621
+ const end = findStringEnd(source, index);
2622
+ if (end === -1) {
2623
+ break;
2624
+ }
2625
+ if (quote === "`" && source.slice(index + 1, end).includes("${")) {
2626
+ index = end + 1;
2627
+ continue;
2628
+ }
2629
+ literals.push({ value: source.slice(index + 1, end), offset: index });
2630
+ index = end + 1;
2631
+ }
2632
+ return literals;
2633
+ }
2634
+
2635
+ function isVendorTimestampCandidate(value: string, key: string | undefined): boolean {
2636
+ if (/^\d{8}$/.test(value)) {
2637
+ return isPlausibleCompactDate(value);
2638
+ }
2639
+ if (/^\d{12}$/.test(value)) {
2640
+ return isPlausibleCompactDate(value.slice(0, 8)) && isPlausibleHourMinute(value.slice(8, 12));
2641
+ }
2642
+ if (/^\d{14}$/.test(value)) {
2643
+ const seconds = Number(value.slice(12, 14));
2644
+ return (
2645
+ isPlausibleCompactDate(value.slice(0, 8)) &&
2646
+ isPlausibleHourMinute(value.slice(8, 12)) &&
2647
+ seconds >= 0 &&
2648
+ seconds <= 59
2649
+ );
2650
+ }
2651
+ if (
2652
+ /^\d{4}$/.test(value) &&
2653
+ key !== undefined &&
2654
+ /(?:^|_)at$|At$|time|date|open|close|updated|created/i.test(key)
2655
+ ) {
2656
+ return isPlausibleHourMinute(value);
2657
+ }
2658
+ return false;
2659
+ }
2660
+
2661
+ function isPlausibleCompactDate(value: string): boolean {
2662
+ const year = Number(value.slice(0, 4));
2663
+ const month = Number(value.slice(4, 6));
2664
+ const day = Number(value.slice(6, 8));
2665
+ return year >= 1900 && year <= 2099 && month >= 1 && month <= 12 && day >= 1 && day <= 31;
2666
+ }
2667
+
2668
+ function isPlausibleHourMinute(value: string): boolean {
2669
+ const hour = Number(value.slice(0, 2));
2670
+ const minute = Number(value.slice(2, 4));
2671
+ return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59;
2672
+ }
2673
+
2674
+ function findNextNonWhitespace(source: string, start: number): number {
2675
+ for (let index = start; index < source.length; index += 1) {
2676
+ if (!/\s/.test(source[index] ?? "")) {
2677
+ return index;
2678
+ }
2679
+ }
2680
+ return -1;
2681
+ }
2682
+
2683
+ function findMatchingBracket(source: string, openIndex: number): number {
2684
+ const open = source[openIndex];
2685
+ const close = open === "{" ? "}" : open === "(" ? ")" : open === "[" ? "]" : undefined;
2686
+ if (!close) {
2687
+ return -1;
2688
+ }
2689
+ let depth = 0;
2690
+ for (let index = openIndex; index < source.length; index += 1) {
2691
+ const char = source[index];
2692
+ if (char === '"' || char === "'" || char === "`") {
2693
+ const stringEnd = findStringEnd(source, index);
2694
+ if (stringEnd === -1) {
2695
+ return -1;
2696
+ }
2697
+ index = stringEnd;
2698
+ continue;
2699
+ }
2700
+ if (char === open) {
2701
+ depth += 1;
2702
+ } else if (char === close) {
2703
+ depth -= 1;
2704
+ if (depth === 0) {
2705
+ return index;
2706
+ }
2707
+ }
2708
+ }
2709
+ return -1;
2710
+ }
2711
+
2712
+ function findStringEnd(source: string, start: number): number {
2713
+ const quote = source[start];
2714
+ for (let index = start + 1; index < source.length; index += 1) {
2715
+ if (source[index] === "\\") {
2716
+ index += 1;
2717
+ continue;
2718
+ }
2719
+ if (source[index] === quote) {
2720
+ return index;
2721
+ }
2722
+ }
2723
+ return -1;
2724
+ }
2725
+
2726
+ function maskCommentsAndStrings(source: string): string {
2727
+ const chars = source.split("");
2728
+ for (let index = 0; index < source.length; index += 1) {
2729
+ if (source.startsWith("//", index)) {
2730
+ const bodyStart = index + 2;
2731
+ const newline = source.indexOf("\n", bodyStart);
2732
+ const end = newline === -1 ? source.length : newline;
2733
+ for (let bodyIndex = bodyStart; bodyIndex < end; bodyIndex += 1) {
2734
+ chars[bodyIndex] = " ";
2735
+ }
2736
+ index = end;
2737
+ continue;
2738
+ }
2739
+ if (source.startsWith("/*", index)) {
2740
+ const bodyStart = index + 2;
2741
+ const close = source.indexOf("*/", bodyStart);
2742
+ const end = close === -1 ? source.length : close;
2743
+ for (let bodyIndex = bodyStart; bodyIndex < end; bodyIndex += 1) {
2744
+ if (chars[bodyIndex] !== "\n") {
2745
+ chars[bodyIndex] = " ";
2746
+ }
2747
+ }
2748
+ index = close === -1 ? source.length : close + 1;
2749
+ continue;
2750
+ }
2751
+ const quote = source[index];
2752
+ if (quote !== '"' && quote !== "'" && quote !== "`") {
2753
+ continue;
2754
+ }
2755
+ const end = findStringEnd(source, index);
2756
+ if (end === -1) {
2757
+ break;
2758
+ }
2759
+ // Preserve quoted property keys ("response": ...) so range/key scanners
2760
+ // can still match them; only string VALUES are blanked.
2761
+ let probe = end + 1;
2762
+ while (probe < source.length && /\s/.test(source[probe] ?? "")) {
2763
+ probe += 1;
2764
+ }
2765
+ if (source[probe] !== ":") {
2766
+ for (let bodyIndex = index + 1; bodyIndex < end; bodyIndex += 1) {
2767
+ if (chars[bodyIndex] !== "\n") {
2768
+ chars[bodyIndex] = " ";
2769
+ }
2770
+ }
2771
+ }
2772
+ index = end;
2773
+ }
2774
+ return chars.join("");
2775
+ }
2776
+
2777
+ function skipWhitespaceAndComments(source: string, start: number, end: number): number {
2778
+ let index = start;
2779
+ while (index < end) {
2780
+ if (/\s/.test(source[index] ?? "")) {
2781
+ index += 1;
2782
+ continue;
2783
+ }
2784
+ if (source.startsWith("//", index)) {
2785
+ const newline = source.indexOf("\n", index + 2);
2786
+ index = newline === -1 ? end : newline + 1;
2787
+ continue;
2788
+ }
2789
+ if (source.startsWith("/*", index)) {
2790
+ const close = source.indexOf("*/", index + 2);
2791
+ index = close === -1 ? end : close + 2;
2792
+ continue;
2793
+ }
2794
+ break;
2795
+ }
2796
+ return index;
2797
+ }
2798
+
2799
+ function skipObjectValue(source: string, start: number, end: number): number {
2800
+ let index = start;
2801
+ while (index < end) {
2802
+ const char = source[index];
2803
+ if (char === '"' || char === "'" || char === "`") {
2804
+ const stringEnd = findStringEnd(source, index);
2805
+ if (stringEnd === -1) {
2806
+ return end;
2807
+ }
2808
+ index = stringEnd + 1;
2809
+ continue;
2810
+ }
2811
+ if (char === "{" || char === "(" || char === "[") {
2812
+ const close = findMatchingBracket(source, index);
2813
+ if (close === -1) {
2814
+ return end;
2815
+ }
2816
+ index = close + 1;
2817
+ continue;
2818
+ }
2819
+ if (char === "," || char === "}") {
2820
+ return index;
2821
+ }
2822
+ index += 1;
2823
+ }
2824
+ return index;
2825
+ }
2826
+
2827
+ function rangeContainsOffset(ranges: readonly ObjectRange[], offset: number): boolean {
2828
+ return ranges.some((range) => offset >= range.start && offset <= range.end);
2829
+ }
2830
+
2831
+ function rangeContainedInRanges(ranges: readonly ObjectRange[], candidate: ObjectRange): boolean {
2832
+ return ranges.some((range) => candidate.start >= range.start && candidate.end <= range.end);
2833
+ }
2834
+
2835
+ function propertyKeyForStringLiteral(source: string, literalOffset: number): string | undefined {
2836
+ const masked = maskCommentsAndStrings(source);
2837
+ let index = skipWhitespaceBackward(masked, literalOffset - 1);
2838
+ if (masked[index] !== ":") {
2839
+ return undefined;
2840
+ }
2841
+ index = skipWhitespaceBackward(masked, index - 1);
2842
+ if (index < 0) {
2843
+ return undefined;
2844
+ }
2845
+ if (source[index] === '"' || source[index] === "'") {
2846
+ const quote = source[index];
2847
+ let start = index - 1;
2848
+ while (start >= 0) {
2849
+ if (source[start] === quote && source[start - 1] !== "\\") {
2850
+ return source.slice(start + 1, index);
2851
+ }
2852
+ start -= 1;
2853
+ }
2854
+ return undefined;
2855
+ }
2856
+ const keyMatch = /[A-Za-z_$][\w$]*$/.exec(masked.slice(0, index + 1));
2857
+ return keyMatch?.[0];
2858
+ }
2859
+
2860
+ function skipWhitespaceBackward(source: string, start: number): number {
2861
+ let index = start;
2862
+ while (index >= 0 && /\s/.test(source[index] ?? "")) {
2863
+ index -= 1;
2864
+ }
2865
+ return index;
2866
+ }
2867
+
2868
+ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
2869
+ const operations = Object.entries(provider.operations);
2870
+ const missing: string[] = [];
2871
+ const vacuous: string[] = [];
2872
+ const placeholder: string[] = [];
2873
+ const unsupported: string[] = [];
2874
+ const generatedStarter: string[] = [];
2875
+
2876
+ for (const [operationId, operation] of operations) {
2877
+ const hasCheck = operation.healthCheck !== undefined;
2878
+ const hasUnsupported = operation.healthCheckUnsupported !== undefined;
2879
+ if (!hasCheck && !hasUnsupported) {
2880
+ missing.push(operationId);
2881
+ continue;
2882
+ }
2883
+ if (hasCheck && !hasUnsupported && hasOnlyVacuousHealthCases(operation.healthCheck)) {
2884
+ vacuous.push(operationId);
2885
+ }
2886
+ if (hasUnsupported) {
2887
+ const reason = operation.healthCheckUnsupported?.reason ?? "";
2888
+ unsupported.push(operationId);
2889
+ if (/generated local-only scaffold/i.test(reason)) {
2890
+ generatedStarter.push(operationId);
2891
+ }
2892
+ if (
2893
+ /(todo|later|tbd|test fixture|unit test|placeholder|not sure|skip for test)/i.test(reason)
2894
+ ) {
2895
+ placeholder.push(operationId);
2896
+ }
2897
+ }
2898
+ }
2899
+
2900
+ if (missing.length > 0) {
2901
+ return blocker(
2902
+ "health-coverage",
2903
+ "health",
2904
+ "One or more operations lack healthCheck or healthCheckUnsupported.",
2905
+ `For ${missing.join(", ")}, add \`healthCheck: { interval, cases }\` for safe read-only upstream probes, or add \`healthCheckUnsupported: { reason: "<specific reason>" }\`.`,
2906
+ CATEGORY_MAX_POINTS.health,
2907
+ missing,
2908
+ );
2909
+ }
2910
+
2911
+ if (vacuous.length > 0) {
2912
+ return blocker(
2913
+ "health-coverage",
2914
+ "health",
2915
+ "One or more operations have healthCheck cases with empty assertions.",
2916
+ `healthCheck.assertions for ${vacuous.join(", ")} is empty — assert on status and response shape (e.g. throw or return {status:'degraded'} when the upstream contract breaks), or declare healthCheckUnsupported with a specific reason if the operation genuinely cannot be probed.`,
2917
+ CATEGORY_MAX_POINTS.health,
2918
+ vacuous.map((operationId) => `${operationId}: empty healthCheck.assertions`),
2919
+ );
2920
+ }
2921
+
2922
+ if (placeholder.length > 0) {
2923
+ return {
2924
+ id: "health-coverage",
2925
+ category: "health",
2926
+ level: "warn",
2927
+ status: "warn",
2928
+ points: 8,
2929
+ maxPoints: CATEGORY_MAX_POINTS.health,
2930
+ message: "Some healthCheckUnsupported reasons look placeholder-like.",
2931
+ remediation: `For ${placeholder.join(", ")}, replace the placeholder \`healthCheckUnsupported.reason\` with a specific reason such as destructive mutation, paid call, credential sensitivity, or upstream flakiness.`,
2932
+ evidence: placeholder,
2933
+ };
2934
+ }
2935
+
2936
+ if (generatedStarter.length > 0) {
2937
+ return {
2938
+ id: "health-coverage",
2939
+ category: "health",
2940
+ level: "warn",
2941
+ status: "warn",
2942
+ points: 10,
2943
+ maxPoints: CATEGORY_MAX_POINTS.health,
2944
+ message:
2945
+ "Generated starter operation health rationale is present; replace starter logic before bounty submission.",
2946
+ remediation: `Replace generated starter operation(s) ${generatedStarter.join(", ")} with real upstream-backed operations and add \`healthCheck\` for safe read-only probes.`,
2947
+ evidence: generatedStarter,
2948
+ };
2949
+ }
2950
+
2951
+ if (unsupported.length > 0) {
2952
+ return {
2953
+ id: "health-coverage",
2954
+ category: "health",
2955
+ level: "warn",
2956
+ status: "warn",
2957
+ points: 12,
2958
+ maxPoints: CATEGORY_MAX_POINTS.health,
2959
+ message: "Health coverage is declared, with one or more unsupported probes.",
2960
+ remediation: `For ${unsupported.join(", ")}, replace \`healthCheckUnsupported\` with \`healthCheck: { interval, cases }\` when the upstream operation is safe and read-only; keep unsupported only for destructive, paid, credential-sensitive, or flaky probes with a specific reason.`,
2961
+ evidence: unsupported.map((operationId) => `${operationId}: healthCheckUnsupported`),
2962
+ };
2963
+ }
2964
+
2965
+ return pass(
2151
2966
  "health-coverage",
2152
2967
  "health",
2153
2968
  "All operations declare real health checks.",
@@ -2155,8 +2970,430 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
2155
2970
  );
2156
2971
  }
2157
2972
 
2158
- function scoreSmokeEvidence(smokeNote: string | undefined): SubmitCheck {
2159
- if (smokeNote?.trim()) {
2973
+ function hasOnlyVacuousHealthCases(
2974
+ healthCheck: ProviderDefinition["operations"][string]["healthCheck"],
2975
+ ): boolean {
2976
+ const cases = healthCheck?.cases;
2977
+ if (!Array.isArray(cases) || cases.length === 0) {
2978
+ return true;
2979
+ }
2980
+ return cases.every((healthCase) => isVacuousAssertionFunction(healthCase?.assertions));
2981
+ }
2982
+
2983
+ function isVacuousAssertionFunction(assertions: unknown): boolean {
2984
+ if (typeof assertions !== "function") {
2985
+ return true;
2986
+ }
2987
+
2988
+ let source: string;
2989
+ try {
2990
+ source = Function.prototype.toString.call(assertions);
2991
+ } catch {
2992
+ return false;
2993
+ }
2994
+
2995
+ // Native / bound functions stringify to `function () { [native code] }` with
2996
+ // no inspectable body or params. The underlying implementation may inspect
2997
+ // ctx, so fail open (do not flag) rather than mistake it for an empty body.
2998
+ if (/\[native code\]/.test(source)) {
2999
+ return false;
3000
+ }
3001
+
3002
+ const fn = parseAssertionFunction(source);
3003
+ if (!fn) {
3004
+ // Unparseable source → fail open (treat as a real assertion). A false
3005
+ // negative here only misses a no-op; a false positive would wrongly
3006
+ // reject a valid contributor.
3007
+ return false;
3008
+ }
3009
+
3010
+ // A real health assertion MUST either throw when the upstream contract
3011
+ // breaks, or inspect the probe response, which is delivered exclusively
3012
+ // through the assertion's own parameter(s). Working on the parsed AST (not
3013
+ // text) makes this precise at the syntactic layer: a `throw` only counts
3014
+ // when it is a real ThrowStatement in THIS function's body (not inside a
3015
+ // nested, uninvoked function), and a parameter reference is checked against
3016
+ // the actual bound names (destructuring binds the local alias, not the
3017
+ // property key). This closes the whole equivalent-no-op class — empty
3018
+ // bodies, `void 0`, `({})`, `Promise.resolve()`, `await Promise.resolve()`,
3019
+ // `.then()`, `new Promise(r => r())`, side-effect-only bodies, throws hidden
3020
+ // in uninvoked closures — without enumerating spellings.
3021
+ if (functionThrows(fn)) {
3022
+ return false;
3023
+ }
3024
+ const bound = new Set<string>();
3025
+ for (const param of fn.params) {
3026
+ collectBoundNames(param, bound);
3027
+ }
3028
+ if (bound.size === 0) {
3029
+ return true;
3030
+ }
3031
+ // A parameter reference anywhere in the (reachable) body is treated as
3032
+ // inspecting the response. This is deliberately syntactic, not a dataflow
3033
+ // analysis.
3034
+ //
3035
+ // KNOWN LIMITATION (accepted): a body that reads the parameter but never
3036
+ // turns that read into an outcome — no throw, no returned verdict — still
3037
+ // passes, e.g. `({ status }) => { console.info(status); }`. Precisely
3038
+ // rejecting it would require tracking whether the read flows to a throw
3039
+ // argument or return value through arbitrary local bindings and invoked
3040
+ // helpers (`const ok = ctx.output.ok; return ok ? ...` / a called helper that
3041
+ // throws). That is transitive use-def dataflow, and an imprecise version
3042
+ // FALSE-BLOCKS real assertions of exactly those shapes — verified
3043
+ // empirically. Under the fail-open contract (rejecting a real contributor is
3044
+ // strictly worse than missing a no-op) we accept the miss here. This gate
3045
+ // stops accidental/lazy no-ops (empty bodies, `void 0`, `Promise.resolve()`,
3046
+ // throws in uninvoked closures); a determined bypass via a decorative ctx
3047
+ // read is no easier than writing the real one-line `throw`, and the actual
3048
+ // defense against a runtime-empty assertion is the live `--smoke` probe.
3049
+ return !referencesBoundNames(fn, bound);
3050
+ }
3051
+
3052
+ type AssertionFunctionNode =
3053
+ | acorn.ArrowFunctionExpression
3054
+ | acorn.FunctionExpression
3055
+ | acorn.FunctionDeclaration;
3056
+
3057
+ function isFunctionNode(node: acorn.AnyNode): node is AssertionFunctionNode {
3058
+ return (
3059
+ node.type === "ArrowFunctionExpression" ||
3060
+ node.type === "FunctionExpression" ||
3061
+ node.type === "FunctionDeclaration"
3062
+ );
3063
+ }
3064
+
3065
+ /**
3066
+ * Parse the `Function.prototype.toString()` output of an assertion into its AST
3067
+ * function node. The stringified form can be an arrow (`(a) => {}`), a function
3068
+ * expression (`function (a) {}`), or a bare method (`foo() {}`), so try a few
3069
+ * wrappers until one parses. Returns undefined on any parse failure so callers
3070
+ * fail open.
3071
+ */
3072
+ function parseAssertionFunction(source: string): AssertionFunctionNode | undefined {
3073
+ const candidates = [source, `(${source})`, `({${source}})`];
3074
+ for (const candidate of candidates) {
3075
+ let program: acorn.Program;
3076
+ try {
3077
+ program = acorn.parse(candidate, { ecmaVersion: "latest" });
3078
+ } catch {
3079
+ continue;
3080
+ }
3081
+ const fn = findFirstFunction(program);
3082
+ if (fn) {
3083
+ return fn;
3084
+ }
3085
+ }
3086
+ return undefined;
3087
+ }
3088
+
3089
+ /** Depth-first search for the first function node in a parsed program. */
3090
+ function findFirstFunction(root: acorn.AnyNode): AssertionFunctionNode | undefined {
3091
+ let found: AssertionFunctionNode | undefined;
3092
+ walkAst(root, (node) => {
3093
+ if (found) {
3094
+ return false;
3095
+ }
3096
+ if (isFunctionNode(node)) {
3097
+ found = node;
3098
+ return false;
3099
+ }
3100
+ return true;
3101
+ });
3102
+ return found;
3103
+ }
3104
+
3105
+ /**
3106
+ * Collect the identifier names actually BOUND by a parameter pattern. For
3107
+ * destructuring, the binding is the local target (`value`), not the source
3108
+ * property key — so `({ status: ignored })` binds `ignored`, and a body that
3109
+ * merely mentions `status` is not referencing a parameter.
3110
+ */
3111
+ function collectBoundNames(pattern: acorn.Pattern | null, out: Set<string>): void {
3112
+ if (!pattern) {
3113
+ return;
3114
+ }
3115
+ switch (pattern.type) {
3116
+ case "Identifier":
3117
+ out.add(pattern.name);
3118
+ break;
3119
+ case "AssignmentPattern":
3120
+ collectBoundNames(pattern.left, out);
3121
+ break;
3122
+ case "RestElement":
3123
+ collectBoundNames(pattern.argument, out);
3124
+ break;
3125
+ case "ArrayPattern":
3126
+ for (const element of pattern.elements) {
3127
+ collectBoundNames(element, out);
3128
+ }
3129
+ break;
3130
+ case "ObjectPattern":
3131
+ for (const property of pattern.properties) {
3132
+ if (property.type === "RestElement") {
3133
+ collectBoundNames(property.argument, out);
3134
+ } else {
3135
+ // `.value` is the local binding target, `.key` is the source
3136
+ // property name — bind only the former.
3137
+ collectBoundNames(property.value, out);
3138
+ }
3139
+ }
3140
+ break;
3141
+ }
3142
+ }
3143
+
3144
+ /**
3145
+ * True if the function contains a real `throw` statement in ITS OWN body —
3146
+ * descending through control flow but NOT into nested functions, whose throws
3147
+ * do not execute unless that nested function is invoked.
3148
+ */
3149
+ function functionThrows(fn: AssertionFunctionNode): boolean {
3150
+ if (fn.body.type !== "BlockStatement") {
3151
+ // Concise arrow returning an expression cannot contain a throw statement.
3152
+ return false;
3153
+ }
3154
+ let throws = false;
3155
+ walkAst(fn.body, (node) => {
3156
+ if (throws) {
3157
+ return false;
3158
+ }
3159
+ if (node.type === "ThrowStatement") {
3160
+ throws = true;
3161
+ return false;
3162
+ }
3163
+ // Do not descend into nested function bodies.
3164
+ if (isFunctionNode(node)) {
3165
+ return false;
3166
+ }
3167
+ return true;
3168
+ });
3169
+ return throws;
3170
+ }
3171
+
3172
+ /**
3173
+ * True if the function's body references any of the given bound parameter names
3174
+ * as an actual value. Property KEYS (`{ status: ... }`, `obj.status`) are not
3175
+ * references; computed members (`obj[status]`) are. Nested functions that
3176
+ * re-bind the same name shadow it, so their bodies are searched with the
3177
+ * shadowed name removed from the target set.
3178
+ */
3179
+ function referencesBoundNames(fn: AssertionFunctionNode, bound: Set<string>): boolean {
3180
+ if (bound.size === 0) {
3181
+ return false;
3182
+ }
3183
+ let referenced = false;
3184
+ walkAstValues(fn.body, bound, fn.body, null, null, () => {
3185
+ referenced = true;
3186
+ });
3187
+ return referenced;
3188
+ }
3189
+
3190
+ /**
3191
+ * Walk `node`, invoking `onReference` when an Identifier in value position
3192
+ * matches a name in `names`. Skips property keys and non-computed member
3193
+ * properties. On entering a nested function, removes any parameter names it
3194
+ * rebinds (shadowing) from the active set for that subtree, and does NOT descend
3195
+ * into a PROVABLY-UNINVOKED helper — a function bound to a local name that is
3196
+ * never referenced again anywhere in the assertion body, so it cannot run when
3197
+ * the assertion runs (e.g. `(ctx) => { const later = () => ctx.status; }`). Its
3198
+ * parameter reads therefore must not count as inspecting the response, mirroring
3199
+ * how `functionThrows` ignores throws inside nested functions.
3200
+ *
3201
+ * Crucially, a helper that IS referenced again (a call site like `check()`) is
3202
+ * NOT skipped — its body is searched — so real assertions that factor the check
3203
+ * into a local helper still pass. Immediately-invoked callbacks (`.every(cb)`,
3204
+ * IIFEs, callees) are likewise searched. When in doubt we descend (fail open):
3205
+ * the only skip is a helper we can prove is never invoked.
3206
+ */
3207
+ function walkAstValues(
3208
+ node: acorn.AnyNode,
3209
+ names: Set<string>,
3210
+ outerBody: acorn.AnyNode,
3211
+ parent: acorn.AnyNode | null,
3212
+ parentKey: string | null,
3213
+ onReference: () => void,
3214
+ ): void {
3215
+ if (names.size === 0) {
3216
+ return;
3217
+ }
3218
+ if (node.type === "Identifier") {
3219
+ if (names.has(node.name)) {
3220
+ onReference();
3221
+ }
3222
+ return;
3223
+ }
3224
+ // Nested function: subtract its own parameter bindings (shadowing) before
3225
+ // descending into its body — and skip it only when it is a provably
3226
+ // uninvoked helper.
3227
+ if (isFunctionNode(node)) {
3228
+ const shadowed = new Set<string>();
3229
+ for (const param of node.params) {
3230
+ collectBoundNames(param, shadowed);
3231
+ }
3232
+ const visible = new Set<string>();
3233
+ for (const name of names) {
3234
+ if (!shadowed.has(name)) {
3235
+ visible.add(name);
3236
+ }
3237
+ }
3238
+ if (visible.size === 0 || isProvablyUninvokedHelper(node, parent, parentKey, outerBody)) {
3239
+ return;
3240
+ }
3241
+ for (const [key, child] of childEntries(node)) {
3242
+ if (key === "params") {
3243
+ continue;
3244
+ }
3245
+ walkAstValues(child, visible, outerBody, node, key, onReference);
3246
+ }
3247
+ return;
3248
+ }
3249
+ for (const [key, child] of childEntries(node)) {
3250
+ // Skip non-computed property keys (`{ status: x }`) and member
3251
+ // properties (`obj.status`) — these are names, not references.
3252
+ if (key === "key" && node.type === "Property" && !node.computed) {
3253
+ continue;
3254
+ }
3255
+ if (key === "property" && node.type === "MemberExpression" && !node.computed) {
3256
+ continue;
3257
+ }
3258
+ walkAstValues(child, names, outerBody, node, key, onReference);
3259
+ }
3260
+ }
3261
+
3262
+ /**
3263
+ * True if `fn` is a local helper bound to a name that is NEVER referenced again
3264
+ * anywhere in `outerBody` — meaning it is never invoked, so its body does not run
3265
+ * as part of evaluating the assertion. Only these provably-dead helpers are
3266
+ * skipped; a helper with any call site (its name appearing more than once, i.e.
3267
+ * beyond its own declaration) is treated as potentially executed and searched.
3268
+ * Anonymous functions in expression position (call args, callees, returns) are
3269
+ * never "uninvoked helpers" — they may run — so they are not skipped here.
3270
+ */
3271
+ function isProvablyUninvokedHelper(
3272
+ fn: AssertionFunctionNode,
3273
+ parent: acorn.AnyNode | null,
3274
+ parentKey: string | null,
3275
+ outerBody: acorn.AnyNode,
3276
+ ): boolean {
3277
+ let helperName: string | undefined;
3278
+ if (fn.type === "FunctionDeclaration" && fn.id) {
3279
+ helperName = fn.id.name;
3280
+ } else if (
3281
+ parent &&
3282
+ parent.type === "VariableDeclarator" &&
3283
+ parentKey === "init" &&
3284
+ parent.id.type === "Identifier"
3285
+ ) {
3286
+ helperName = parent.id.name;
3287
+ }
3288
+ if (helperName === undefined) {
3289
+ // Not a name-bound helper (anonymous callback / expression). It may run,
3290
+ // so do not skip it.
3291
+ return false;
3292
+ }
3293
+ // Count every occurrence of the helper name in the assertion body. Exactly
3294
+ // one occurrence is its own binding declaration; more than one means there is
3295
+ // at least one reference (call site), so the helper can run.
3296
+ let occurrences = 0;
3297
+ walkAst(outerBody, (node) => {
3298
+ if (node.type === "Identifier" && node.name === helperName) {
3299
+ occurrences += 1;
3300
+ }
3301
+ return true;
3302
+ });
3303
+ return occurrences <= 1;
3304
+ }
3305
+
3306
+ /** Generic pre-order AST walk; `visit` returns false to stop descending. */
3307
+ function walkAst(node: acorn.AnyNode, visit: (node: acorn.AnyNode) => boolean): void {
3308
+ if (!visit(node)) {
3309
+ return;
3310
+ }
3311
+ for (const child of childNodes(node)) {
3312
+ walkAst(child, visit);
3313
+ }
3314
+ }
3315
+
3316
+ function* childNodes(node: acorn.AnyNode): Generator<acorn.AnyNode> {
3317
+ for (const [, child] of childEntries(node)) {
3318
+ yield child;
3319
+ }
3320
+ }
3321
+
3322
+ function* childEntries(node: acorn.AnyNode): Generator<[string, acorn.AnyNode]> {
3323
+ for (const key of Object.keys(node)) {
3324
+ if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") {
3325
+ continue;
3326
+ }
3327
+ const value = (node as unknown as Record<string, unknown>)[key];
3328
+ if (Array.isArray(value)) {
3329
+ for (const item of value) {
3330
+ if (isAstNode(item)) {
3331
+ yield [key, item];
3332
+ }
3333
+ }
3334
+ } else if (isAstNode(value)) {
3335
+ yield [key, value];
3336
+ }
3337
+ }
3338
+ }
3339
+
3340
+ function isAstNode(value: unknown): value is acorn.AnyNode {
3341
+ return (
3342
+ typeof value === "object" &&
3343
+ value !== null &&
3344
+ typeof (value as { type?: unknown }).type === "string"
3345
+ );
3346
+ }
3347
+
3348
+ function scoreSmoke(
3349
+ smokeResult: SmokeResult | undefined,
3350
+ smokeNote: string | undefined,
3351
+ ): SubmitCheck {
3352
+ const deprecatedEvidence = smokeNote?.trim()
3353
+ ? ["Deprecated --smoke-note was provided and ignored for scoring."]
3354
+ : [];
3355
+ if (!smokeResult) {
3356
+ return {
3357
+ id: "local-smoke",
3358
+ category: "smoke",
3359
+ level: "warn",
3360
+ status: "warn",
3361
+ points: 0,
3362
+ maxPoints: CATEGORY_MAX_POINTS.smoke,
3363
+ message: "Measured local smoke was not run.",
3364
+ remediation:
3365
+ "Rerun submit-check with `--smoke` so it boots the provider, verifies `/health`, and POSTs every operation fixture. Set APIFUSE__PROVIDER__* env vars when live upstream credentials are available.",
3366
+ evidence: deprecatedEvidence,
3367
+ };
3368
+ }
3369
+
3370
+ const evidence = [
3371
+ `/health: ${smokeResult.healthOk ? "ok" : "failed"}`,
3372
+ ...smokeResult.operations.map(
3373
+ (outcome) =>
3374
+ `${outcome.operationId}: ${outcome.status}${outcome.httpStatus ? ` HTTP ${outcome.httpStatus}` : ""} - ${outcome.message}`,
3375
+ ),
3376
+ ...deprecatedEvidence,
3377
+ ];
3378
+ const incoherent = smokeResult.operations.filter((outcome) => outcome.status === "incoherent");
3379
+ if (!smokeResult.healthOk || smokeResult.bootError || incoherent.length > 0) {
3380
+ return {
3381
+ id: "local-smoke",
3382
+ category: "smoke",
3383
+ level: "blocker",
3384
+ status: "fail",
3385
+ points: 0,
3386
+ maxPoints: CATEGORY_MAX_POINTS.smoke,
3387
+ message: "Measured smoke failed to verify a coherent provider runtime.",
3388
+ remediation:
3389
+ "Fix the dev server boot, `/health`, or incoherent operation responses, then rerun `bun run submit-check -- --smoke`.",
3390
+ evidence: smokeResult.bootError ? [`boot: ${smokeResult.bootError}`, ...evidence] : evidence,
3391
+ details: smokeResult,
3392
+ };
3393
+ }
3394
+
3395
+ const successes = smokeResult.operations.filter((outcome) => outcome.status === "success");
3396
+ if (successes.length > 0) {
2160
3397
  return {
2161
3398
  id: "local-smoke",
2162
3399
  category: "smoke",
@@ -2164,8 +3401,9 @@ function scoreSmokeEvidence(smokeNote: string | undefined): SubmitCheck {
2164
3401
  status: "pass",
2165
3402
  points: CATEGORY_MAX_POINTS.smoke,
2166
3403
  maxPoints: CATEGORY_MAX_POINTS.smoke,
2167
- message: "Local smoke evidence was provided.",
2168
- evidence: [redact(smokeNote.trim())],
3404
+ message: "Measured smoke passed with at least one schema-valid operation success.",
3405
+ evidence,
3406
+ details: smokeResult,
2169
3407
  };
2170
3408
  }
2171
3409
 
@@ -2174,14 +3412,226 @@ function scoreSmokeEvidence(smokeNote: string | undefined): SubmitCheck {
2174
3412
  category: "smoke",
2175
3413
  level: "warn",
2176
3414
  status: "warn",
2177
- points: 5,
3415
+ points: 7,
2178
3416
  maxPoints: CATEGORY_MAX_POINTS.smoke,
2179
- message: "No local smoke evidence was provided.",
3417
+ message: "Runtime path was verified, but no live upstream schema-valid success was observed.",
2180
3418
  remediation:
2181
- "Start `bun run dev`, call `/health` and at least one `POST /v1/{operation}`, then rerun with `--smoke-note` or paste notes in the assigned workspace PR.",
3419
+ "Provide APIFUSE__PROVIDER__* env vars or fixture-safe upstream access, then rerun `bun run submit-check -- --smoke` to capture at least one schema-valid success.",
3420
+ evidence,
3421
+ details: smokeResult,
3422
+ };
3423
+ }
3424
+
3425
+ export async function runSubmitCheckSmoke(
3426
+ providerRoot: string,
3427
+ provider?: ProviderDefinition,
3428
+ ): Promise<SmokeResult> {
3429
+ const loadedProvider = provider ?? (await loadProvider(providerRoot));
3430
+ if (!loadedProvider) {
3431
+ return {
3432
+ measured: true,
3433
+ healthOk: false,
3434
+ bootError: "Provider could not be loaded.",
3435
+ operations: [],
3436
+ };
3437
+ }
3438
+
3439
+ const port = await getAvailablePort();
3440
+ const server = spawn("bun", ["run", "dev"], {
3441
+ cwd: providerRoot,
3442
+ env: { ...process.env, APIFUSE__RUNTIME__PORT: String(port) },
3443
+ detached: process.platform !== "win32",
3444
+ stdio: ["ignore", "pipe", "pipe"],
3445
+ });
3446
+ let output = "";
3447
+ server.stdout?.on("data", (chunk) => {
3448
+ output += chunk.toString();
3449
+ });
3450
+ server.stderr?.on("data", (chunk) => {
3451
+ output += chunk.toString();
3452
+ });
3453
+
3454
+ try {
3455
+ const baseUrl = `http://127.0.0.1:${port}`;
3456
+ const health = await waitForSmokeHealth(`${baseUrl}/health`, server, () => output);
3457
+ if (!health.ok) {
3458
+ return {
3459
+ measured: true,
3460
+ healthOk: false,
3461
+ bootError: health.error,
3462
+ operations: [],
3463
+ };
3464
+ }
3465
+ const operations: SmokeOperationOutcome[] = [];
3466
+ for (const [operationId, operation] of Object.entries(loadedProvider.operations)) {
3467
+ operations.push(
3468
+ await smokeOperation(baseUrl, operationId, operation.output, {
3469
+ requestId: `req_submit_check_smoke_${operationId}`,
3470
+ input: operation.fixtures?.request ?? {},
3471
+ headers: {},
3472
+ }),
3473
+ );
3474
+ }
3475
+ return { measured: true, healthOk: true, operations };
3476
+ } finally {
3477
+ await stopSmokeServer(server);
3478
+ }
3479
+ }
3480
+
3481
+ async function smokeOperation(
3482
+ baseUrl: string,
3483
+ operationId: string,
3484
+ outputSchema: ProviderDefinition["operations"][string]["output"],
3485
+ body: unknown,
3486
+ ): Promise<SmokeOperationOutcome> {
3487
+ try {
3488
+ const response = await fetch(`${baseUrl}/v1/${operationId}`, {
3489
+ method: "POST",
3490
+ headers: { "content-type": "application/json" },
3491
+ body: JSON.stringify(body),
3492
+ signal: AbortSignal.timeout(20_000),
3493
+ });
3494
+ const payload = await response.json().catch(() => undefined);
3495
+ if (response.ok && isRecord(payload) && "data" in payload) {
3496
+ const parsed = safeParseSchemaSync(
3497
+ outputSchema,
3498
+ payload.data,
3499
+ `operations.${operationId}.output`,
3500
+ );
3501
+ if (parsed.success) {
3502
+ return {
3503
+ operationId,
3504
+ status: "success",
3505
+ httpStatus: response.status,
3506
+ message: "schema-valid success",
3507
+ };
3508
+ }
3509
+ return {
3510
+ operationId,
3511
+ status: "incoherent",
3512
+ httpStatus: response.status,
3513
+ message: "success payload failed output schema validation",
3514
+ };
3515
+ }
3516
+ if (isStructuredProviderError(payload) && response.status < 500) {
3517
+ return {
3518
+ operationId,
3519
+ status: "structured_error",
3520
+ httpStatus: response.status,
3521
+ message: `${payload.error.code}: ${payload.error.message}`,
3522
+ };
3523
+ }
3524
+ return {
3525
+ operationId,
3526
+ status: "incoherent",
3527
+ httpStatus: response.status,
3528
+ message: isStructuredProviderError(payload)
3529
+ ? `${payload.error.code}: ${payload.error.message}`
3530
+ : "response was not a schema-valid success or structured provider error",
3531
+ };
3532
+ } catch (error) {
3533
+ return {
3534
+ operationId,
3535
+ status: "incoherent",
3536
+ message: error instanceof Error ? error.message : String(error),
3537
+ };
3538
+ }
3539
+ }
3540
+
3541
+ function isStructuredProviderError(
3542
+ value: unknown,
3543
+ ): value is { error: { code: string; message: string } } {
3544
+ return (
3545
+ isRecord(value) &&
3546
+ isRecord(value.error) &&
3547
+ typeof value.error.code === "string" &&
3548
+ typeof value.error.message === "string"
3549
+ );
3550
+ }
3551
+
3552
+ async function getAvailablePort(): Promise<number> {
3553
+ return await new Promise((resolvePromise, rejectPromise) => {
3554
+ const server = createServer();
3555
+ server.once("error", rejectPromise);
3556
+ server.listen(0, "127.0.0.1", () => {
3557
+ const address = server.address();
3558
+ server.close((error) => {
3559
+ if (error) {
3560
+ rejectPromise(error);
3561
+ return;
3562
+ }
3563
+ if (!address || typeof address === "string") {
3564
+ rejectPromise(new Error("Could not allocate a local TCP port."));
3565
+ return;
3566
+ }
3567
+ resolvePromise(address.port);
3568
+ });
3569
+ });
3570
+ });
3571
+ }
3572
+
3573
+ async function waitForSmokeHealth(
3574
+ url: string,
3575
+ server: ChildProcess,
3576
+ getOutput: () => string,
3577
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
3578
+ const deadline = Date.now() + 20_000;
3579
+ let lastError: unknown;
3580
+
3581
+ while (Date.now() < deadline) {
3582
+ if (server.exitCode !== null) {
3583
+ return {
3584
+ ok: false,
3585
+ error: `Dev server exited early with code ${server.exitCode}. ${getOutput()}`,
3586
+ };
3587
+ }
3588
+
3589
+ try {
3590
+ const response = await fetch(url, { signal: AbortSignal.timeout(1_000) });
3591
+ if (response.ok) return { ok: true };
3592
+ lastError = new Error(`${url} returned ${response.status}`);
3593
+ } catch (error) {
3594
+ lastError = error;
3595
+ }
3596
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 200));
3597
+ }
3598
+
3599
+ return {
3600
+ ok: false,
3601
+ error: `Timed out waiting for ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}. ${getOutput()}`,
2182
3602
  };
2183
3603
  }
2184
3604
 
3605
+ async function stopSmokeServer(server: ChildProcess): Promise<void> {
3606
+ if (server.exitCode !== null) return;
3607
+ killSmokeProcessTree(server, "SIGTERM");
3608
+ await new Promise<void>((resolvePromise) => {
3609
+ const timeout = setTimeout(() => {
3610
+ if (server.exitCode === null) {
3611
+ killSmokeProcessTree(server, "SIGKILL");
3612
+ }
3613
+ resolvePromise();
3614
+ }, 2_000);
3615
+ server.once("exit", () => {
3616
+ clearTimeout(timeout);
3617
+ resolvePromise();
3618
+ });
3619
+ });
3620
+ }
3621
+
3622
+ function killSmokeProcessTree(server: ChildProcess, signal: NodeJS.Signals): void {
3623
+ if (server.pid === undefined) return;
3624
+ try {
3625
+ if (process.platform === "win32") {
3626
+ server.kill(signal);
3627
+ return;
3628
+ }
3629
+ process.kill(-server.pid, signal);
3630
+ } catch {
3631
+ server.kill(signal);
3632
+ }
3633
+ }
3634
+
2185
3635
  function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
2186
3636
  const authMode = provider.auth?.mode ?? "none";
2187
3637
  const credentialKeys = provider.credential?.keys ?? [];
@@ -2205,7 +3655,7 @@ function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
2205
3655
  maxPoints: CATEGORY_MAX_POINTS.auth,
2206
3656
  message: "OAuth auth mode does not declare persisted credential.keys.",
2207
3657
  remediation:
2208
- "Generated OAuth starters may begin without keys, but bounty-ready OAuth providers should declare persisted token keys once the real token exchange is implemented.",
3658
+ "Add `credential: { keys: [...] }` to `defineProvider` with the persisted OAuth token fields returned by the real token exchange.",
2209
3659
  };
2210
3660
  }
2211
3661
 
@@ -2221,10 +3671,8 @@ function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
2221
3671
  status: "warn",
2222
3672
  points: 7,
2223
3673
  maxPoints: CATEGORY_MAX_POINTS.auth,
2224
- message:
2225
- "Provider is no-auth but at least one operation is not marked openWorld.",
2226
- remediation:
2227
- "Confirm auth.mode and operation annotations match the actual upstream auth model.",
3674
+ message: "Provider is no-auth but at least one operation is not marked openWorld.",
3675
+ remediation: `Either set \`auth.mode\` to the upstream auth model, or mark these public no-auth operations with \`annotations.openWorld: true\`: ${securedOperations.map(([operationId]) => operationId).join(", ")}.`,
2228
3676
  evidence: securedOperations.map(([operationId]) => operationId),
2229
3677
  };
2230
3678
  }
@@ -2266,9 +3714,7 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
2266
3714
 
2267
3715
  const points = Math.max(
2268
3716
  0,
2269
- CATEGORY_MAX_POINTS.docs -
2270
- missing.length * 2 -
2271
- (mentionsSubmitCheck ? 0 : 1),
3717
+ CATEGORY_MAX_POINTS.docs - missing.length * 2 - (mentionsSubmitCheck ? 0 : 1),
2272
3718
  );
2273
3719
 
2274
3720
  return [
@@ -2285,7 +3731,7 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
2285
3731
  : "Provider README includes expected submission guidance.",
2286
3732
  remediation:
2287
3733
  missing.length > 0 || !mentionsSubmitCheck
2288
- ? "Include Parameters, Response, Example, and submit-check evidence guidance."
3734
+ ? "Update `README.md` to include `Parameters`, `Response`, `Example`, and submit-check evidence guidance sections."
2289
3735
  : undefined,
2290
3736
  evidence: [
2291
3737
  ...missing.map(([, label]) => `missing ${label}`),
@@ -2295,9 +3741,48 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
2295
3741
  ];
2296
3742
  }
2297
3743
 
2298
- function scoreSecrets(providerRoot: string): SubmitCheck {
2299
- const findings = findSecretFindings(providerRoot);
2300
- if (findings.length > 0) {
3744
+ // Splits secret findings into still-active findings and acknowledged
3745
+ // `// @apifuse-allow secret-scan` overrides, mirroring partitionAllowOverrides
3746
+ // (same pragma placement: the finding line or the line directly above it).
3747
+ // Every finding source carries a line number (entropy candidates and located
3748
+ // SECRET_PATTERNS matches); a finding that somehow lacks one stays active
3749
+ // defensively.
3750
+ function partitionSecretScanAllowOverrides(
3751
+ providerRoot: string,
3752
+ findings: readonly SecretFinding[],
3753
+ ): { active: SecretFinding[]; overridden: SecretFinding[] } {
3754
+ const fileLineCache = new Map<string, string[]>();
3755
+ const active: SecretFinding[] = [];
3756
+ const overridden: SecretFinding[] = [];
3757
+
3758
+ for (const finding of findings) {
3759
+ if (finding.line === undefined) {
3760
+ active.push(finding);
3761
+ continue;
3762
+ }
3763
+ const absolute = resolve(providerRoot, finding.file);
3764
+ let lines = fileLineCache.get(absolute);
3765
+ if (lines === undefined) {
3766
+ lines = existsSync(absolute) ? readFileSync(absolute, "utf8").split(/\r?\n/) : [];
3767
+ fileLineCache.set(absolute, lines);
3768
+ }
3769
+ if (hasAllowOverride(lines, finding.line, "secret-scan")) {
3770
+ overridden.push(finding);
3771
+ } else {
3772
+ active.push(finding);
3773
+ }
3774
+ }
3775
+
3776
+ return { active, overridden };
3777
+ }
3778
+
3779
+ function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): SubmitCheck {
3780
+ const { active, overridden } = partitionSecretScanAllowOverrides(
3781
+ providerRoot,
3782
+ findSecretFindings(providerRoot, provider?.id),
3783
+ );
3784
+ const blockerFindings = active.filter((finding) => finding.level !== "warn");
3785
+ if (blockerFindings.length > 0) {
2301
3786
  return {
2302
3787
  id: "secret-scan",
2303
3788
  category: "security",
@@ -2305,11 +3790,41 @@ function scoreSecrets(providerRoot: string): SubmitCheck {
2305
3790
  status: "fail",
2306
3791
  points: 0,
2307
3792
  maxPoints: CATEGORY_MAX_POINTS.security,
2308
- message:
2309
- "Potential real credential material was found in shareable files.",
3793
+ message: "Potential real credential material was found in shareable files.",
3794
+ remediation:
3795
+ blockerFindings[0]?.remediation ??
3796
+ 'Move hardcoded credentials to env vars read via `ctx.env.get("APIFUSE__PROVIDER__<ID>__<NAME>")` and rotate the leaked credential.',
3797
+ evidence: blockerFindings.map(
3798
+ (finding) =>
3799
+ finding.evidence ??
3800
+ `${finding.file}${finding.line ? `:${finding.line}` : ""}: ${finding.label}`,
3801
+ ),
3802
+ };
3803
+ }
3804
+ if (active.length > 0 || overridden.length > 0) {
3805
+ const messageBase =
3806
+ active.length > 0
3807
+ ? "High-entropy source strings were found without secret-like identifier context; they may be false positives."
3808
+ : "Potential credential-like strings were found in shareable files.";
3809
+ const message =
3810
+ overridden.length > 0
3811
+ ? `${messageBase} ${overridden.length} acknowledged @apifuse-allow override(s).`
3812
+ : messageBase;
3813
+ return {
3814
+ id: "secret-scan",
3815
+ category: "security",
3816
+ level: "warn",
3817
+ status: "warn",
3818
+ points: 8,
3819
+ maxPoints: CATEGORY_MAX_POINTS.security,
3820
+ message,
2310
3821
  remediation:
2311
- "Remove real secrets from source, README, and fixtures. Use environment variables and local-only connection.secrets instead.",
2312
- evidence: findings.map((finding) => `${finding.file}: ${finding.label}`),
3822
+ '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>`.',
3823
+ evidence: [...active, ...overridden].map(
3824
+ (finding) =>
3825
+ finding.evidence ??
3826
+ `${finding.file}${finding.line ? `:${finding.line}` : ""}: ${finding.label}`,
3827
+ ),
2313
3828
  };
2314
3829
  }
2315
3830
 
@@ -2321,7 +3836,7 @@ function scoreSecrets(providerRoot: string): SubmitCheck {
2321
3836
  );
2322
3837
  }
2323
3838
 
2324
- function findSecretFindings(providerRoot: string): SecretFinding[] {
3839
+ function findSecretFindings(providerRoot: string, providerId = "<ID>"): SecretFinding[] {
2325
3840
  const candidateFiles = [
2326
3841
  "README.md",
2327
3842
  "index.ts",
@@ -2336,20 +3851,343 @@ function findSecretFindings(providerRoot: string): SecretFinding[] {
2336
3851
  if (!existsSync(filePath)) continue;
2337
3852
  const content = readFileSync(filePath, "utf8");
2338
3853
  for (const [label, pattern] of SECRET_PATTERNS) {
2339
- if (pattern.test(content)) {
2340
- findings.push({ label, file: relativePath });
3854
+ // Locate every match to its line so pattern findings carry the line
3855
+ // information hasAllowOverride needs: `// @apifuse-allow secret-scan`
3856
+ // must behave uniformly across entropy findings and pattern findings.
3857
+ const globalPattern = new RegExp(
3858
+ pattern.source,
3859
+ pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
3860
+ );
3861
+ const seenLines = new Set<number>();
3862
+ for (const match of content.matchAll(globalPattern)) {
3863
+ const line = offsetToLine(content, match.index);
3864
+ if (seenLines.has(line)) continue;
3865
+ seenLines.add(line);
3866
+ findings.push({ label, file: relativePath, line });
2341
3867
  }
2342
3868
  }
2343
3869
  }
2344
3870
 
3871
+ findings.push(...findEntropySecretFindings(providerRoot, providerId));
3872
+ return findings;
3873
+ }
3874
+
3875
+ function findEntropySecretFindings(providerRoot: string, providerId: string): SecretFinding[] {
3876
+ const findings: SecretFinding[] = [];
3877
+ for (const filePath of listNonTestProviderSourceFiles(providerRoot)) {
3878
+ const relativePath = toRelativeProviderPath(providerRoot, filePath);
3879
+ if (isEntropySecretExcludedPath(relativePath)) continue;
3880
+ const content = readFileSync(filePath, "utf8");
3881
+ const lines = content.split(/\r?\n/);
3882
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
3883
+ const line = lines[lineIndex] ?? "";
3884
+ for (const candidate of extractStringLiteralCandidates(line)) {
3885
+ const finding = classifyEntropyCandidate({
3886
+ value: candidate,
3887
+ line,
3888
+ file: relativePath,
3889
+ lineNumber: lineIndex + 1,
3890
+ providerId,
3891
+ });
3892
+ if (finding) findings.push(finding);
3893
+ }
3894
+ }
3895
+ }
2345
3896
  return findings;
2346
3897
  }
2347
3898
 
3899
+ function isEntropySecretExcludedPath(relativePath: string): boolean {
3900
+ return (
3901
+ relativePath.endsWith(".test.ts") ||
3902
+ relativePath.startsWith("__tests__/") ||
3903
+ relativePath.includes("/__tests__/") ||
3904
+ relativePath.startsWith("__fixtures__/") ||
3905
+ relativePath.includes("/__fixtures__/")
3906
+ );
3907
+ }
3908
+
3909
+ export function extractStringLiteralCandidates(line: string): string[] {
3910
+ const candidates: string[] = [];
3911
+ for (let index = 0; index < line.length; index += 1) {
3912
+ const quote = line[index];
3913
+ if (quote !== '"' && quote !== "'" && quote !== "`") continue;
3914
+
3915
+ const contentStart = index + 1;
3916
+ let cursor = contentStart;
3917
+ while (cursor < line.length) {
3918
+ const char = line[cursor];
3919
+ if (char === "\\") {
3920
+ cursor += 2;
3921
+ continue;
3922
+ }
3923
+ if (char === quote) {
3924
+ if (cursor - contentStart >= ENTROPY_CANDIDATE_MIN_LENGTH) {
3925
+ candidates.push(line.slice(contentStart, cursor));
3926
+ }
3927
+ index = cursor;
3928
+ break;
3929
+ }
3930
+ cursor += 1;
3931
+ }
3932
+ }
3933
+ return candidates;
3934
+ }
3935
+
3936
+ function classifyEntropyCandidate(input: {
3937
+ value: string;
3938
+ line: string;
3939
+ file: string;
3940
+ lineNumber: number;
3941
+ providerId: string;
3942
+ }): SecretFinding | undefined {
3943
+ const value = input.value;
3944
+ if (!shouldConsiderEntropyValue(value)) return undefined;
3945
+ const charset = classifyEntropyCharset(value);
3946
+ if (!charset) return undefined;
3947
+ const entropy = shannonEntropy(value);
3948
+ const secretishContext = SECRETISH_IDENTIFIER_PATTERN.test(input.line);
3949
+ // Word-like SCREAMING_SNAKE values (e.g. error-code constants such as
3950
+ // "AUTH_PASSWORD_LOGIN_CAPTCHA_REQUIRED") may contain secret-ish words
3951
+ // (AUTH/PASSWORD/...) in their own text and would otherwise be permanently
3952
+ // blocker-flagged. They are never skipped — entropy classification always
3953
+ // runs — but when the secret-ish context comes solely from identifier-
3954
+ // constant-shaped literal text (the line with those literals stripped
3955
+ // carries no secret-ish identifier), the finding is capped at a
3956
+ // non-blocking warning instead of a blocker. Stripping constant-shaped
3957
+ // siblings — not just the candidate — matters for lines holding several
3958
+ // constants (e.g. an ERROR_CODES array), while quoted property keys and
3959
+ // header names ("Authorization", "apiKey") stay visible as genuine
3960
+ // external context. Assignments to `apiKey`/`token`/`secret`-style names
3961
+ // still escalate to blockers via the identifier side, and
3962
+ // `// @apifuse-allow secret-scan` remains the reviewed way to silence the
3963
+ // warning.
3964
+ const selfContextOnlyConstant =
3965
+ secretishContext &&
3966
+ isScreamingSnakeConstantValue(value) &&
3967
+ !SECRETISH_IDENTIFIER_PATTERN.test(stripIdentifierConstantLiterals(input.line, value));
3968
+ const threshold = charset === "hex" ? 3.0 : secretishContext ? 4.0 : 4.5;
3969
+ if (entropy < threshold) return undefined;
3970
+
3971
+ const preview = `${value.slice(0, 4)}...[REDACTED length=${value.length}]`;
3972
+ const envName = `APIFUSE__PROVIDER__${input.providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}__${guessSecretName(input.line)}`;
3973
+ const location = `${input.file}:${input.lineNumber}`;
3974
+ const label =
3975
+ charset === "hex"
3976
+ ? `high-entropy hex string (${entropy.toFixed(2)} bits/char)`
3977
+ : `high-entropy base64-like string (${entropy.toFixed(2)} bits/char)`;
3978
+ const contextNote = selfContextOnlyConstant
3979
+ ? "; identifier-like constant (downgraded to warning)"
3980
+ : secretishContext
3981
+ ? ""
3982
+ : "; may be a false positive";
3983
+ return {
3984
+ label,
3985
+ file: input.file,
3986
+ line: input.lineNumber,
3987
+ level: secretishContext && !selfContextOnlyConstant ? "blocker" : "warn",
3988
+ remediation: `Move ${location} to an env var read via \`ctx.env.get("${envName}")\` and rotate the leaked credential.`,
3989
+ evidence: `${location}: ${label}; preview ${preview}${contextNote}`,
3990
+ };
3991
+ }
3992
+
3993
+ // Word-like SCREAMING_SNAKE identifier shape: at least two underscore-
3994
+ // separated segments, each essentially pure alphabetic — letters optionally
3995
+ // followed by a SHORT digit suffix (at most 2, e.g. version markers like
3996
+ // "V2") — and at most 15% digits across the whole value. Dictionary-style
3997
+ // constants like "AUTH_PASSWORD_LOGIN_CAPTCHA_REQUIRED" or
3998
+ // "PROVIDER_CONTRACT_V2_REQUIRED" match; digit-heavy segmented material
3999
+ // (e.g. license/credential shapes like "ABCD1234_EFGH5678_IJKL9012"),
4000
+ // uppercase blobs ("XK9J_Q2ZP_M7VN"), and underscore-free hex-like values
4001
+ // ("A1B2C3D4...") do not. This shape gate never skips entropy classification;
4002
+ // it only decides whether a finding whose secret-ish context comes solely
4003
+ // from the literal's own text is downgraded from blocker to warning, so it
4004
+ // deliberately stays strict: values that merely contain a secret-ish word but
4005
+ // are not word-like constants keep full blocker severity.
4006
+ function isScreamingSnakeConstantValue(value: string): boolean {
4007
+ if (!/^[A-Z][A-Z0-9_]*$/.test(value) || !value.includes("_")) return false;
4008
+ const segments = value.split("_");
4009
+ if (segments.length < 2) return false;
4010
+ if (!segments.every((segment) => /^[A-Z]+[0-9]{0,2}$/.test(segment))) return false;
4011
+ const digitCount = value.match(/[0-9]/g)?.length ?? 0;
4012
+ return digitCount / value.length <= 0.15;
4013
+ }
4014
+
4015
+ type LineStringLiteral = {
4016
+ // Index of the opening quote.
4017
+ start: number;
4018
+ // Index just past the closing quote (line end when unterminated).
4019
+ end: number;
4020
+ content: string;
4021
+ closed: boolean;
4022
+ role: "key" | "value";
4023
+ // Nearest unclosed bracket enclosing the literal's start, if any.
4024
+ container?: { bracket: "[" | "(" | "{"; index: number };
4025
+ };
4026
+
4027
+ // Stable identity for the container a literal sits in ("top" when the
4028
+ // literal is not inside any bracket on the line).
4029
+ function literalContainerKey(literal: LineStringLiteral): string {
4030
+ return literal.container
4031
+ ? `${literal.container.bracket}${literal.container.index}`
4032
+ : "top";
4033
+ }
4034
+
4035
+ // Single-pass line tokenizer: extracts every string literal with its span and
4036
+ // classifies its syntactic role once. A literal is a KEY when it is preceded
4037
+ // (ignoring whitespace) by "{", ",", "(", or the line start AND followed
4038
+ // (ignoring whitespace) by ":" — i.e. it names the value next to it. Every
4039
+ // other literal is a VALUE: ternary arms (preceded by "?" or ":"), array
4040
+ // elements, call arguments, and assignment right-hand sides, even when a
4041
+ // ternary's ":" happens to follow them. Uses the same quote/escape walking as
4042
+ // extractStringLiteralCandidates.
4043
+ function tokenizeLineStringLiterals(line: string): LineStringLiteral[] {
4044
+ const literals: LineStringLiteral[] = [];
4045
+ const bracketStack: Array<{ bracket: "[" | "(" | "{"; index: number }> = [];
4046
+ let index = 0;
4047
+ while (index < line.length) {
4048
+ const char = line[index];
4049
+ if (char !== '"' && char !== "'" && char !== "`") {
4050
+ if (char === "[" || char === "(" || char === "{") {
4051
+ bracketStack.push({ bracket: char, index });
4052
+ } else if (char === "]" || char === ")" || char === "}") {
4053
+ bracketStack.pop();
4054
+ }
4055
+ index += 1;
4056
+ continue;
4057
+ }
4058
+ const quote = char;
4059
+ const start = index;
4060
+ const contentStart = index + 1;
4061
+ let cursor = contentStart;
4062
+ let closed = false;
4063
+ while (cursor < line.length) {
4064
+ const inner = line[cursor];
4065
+ if (inner === "\\") {
4066
+ cursor += 2;
4067
+ continue;
4068
+ }
4069
+ if (inner === quote) {
4070
+ closed = true;
4071
+ break;
4072
+ }
4073
+ cursor += 1;
4074
+ }
4075
+ const contentEnd = Math.min(cursor, line.length);
4076
+ const end = closed ? cursor + 1 : line.length;
4077
+ const before = line.slice(0, start).trimEnd();
4078
+ const keyPreceded = before === "" || /[{,(]$/.test(before);
4079
+ const keyFollowed = closed && /^\s*:/.test(line.slice(end));
4080
+ literals.push({
4081
+ start,
4082
+ end,
4083
+ content: line.slice(contentStart, contentEnd),
4084
+ closed,
4085
+ role: keyPreceded && keyFollowed ? "key" : "value",
4086
+ container: bracketStack[bracketStack.length - 1],
4087
+ });
4088
+ index = end;
4089
+ }
4090
+ return literals;
4091
+ }
4092
+
4093
+ // Builds the context text used to decide whether a candidate's secret-ish
4094
+ // context is genuine. The candidate's own literal is ALWAYS stripped
4095
+ // (self-context rule). SIBLING identifier-constant-shaped candidate literals
4096
+ // (VALUE role, SCREAMING_SNAKE shape, >= ENTROPY_CANDIDATE_MIN_LENGTH) are
4097
+ // stripped only when they share the candidate's non-call container — the same
4098
+ // `[...]` array, the same `{...}` object value list, or the bracket-free top
4099
+ // level (ternary arms) — so a value list of error codes cannot poison its own
4100
+ // members' context. Call-argument siblings (inside `(...)`) always keep their
4101
+ // context: in `headers.set("X_LONG_AUTH_TOKEN_NAME", "QWERTY_...")` the first
4102
+ // argument genuinely describes the second, so stripping it would erase real
4103
+ // auth/token context. KEY-role literals are never stripped.
4104
+ function stripIdentifierConstantLiterals(line: string, candidate: string): string {
4105
+ const literals = tokenizeLineStringLiterals(line);
4106
+ const candidateContainers = new Set<string>();
4107
+ for (const literal of literals) {
4108
+ if (literal.content === candidate) {
4109
+ candidateContainers.add(literalContainerKey(literal));
4110
+ }
4111
+ }
4112
+ let result = "";
4113
+ let previousEnd = 0;
4114
+ for (const literal of literals) {
4115
+ result += line.slice(previousEnd, literal.start);
4116
+ const isSelf = literal.content === candidate;
4117
+ const isSameNonCallContainerSibling =
4118
+ literal.role === "value" &&
4119
+ literal.content.length >= ENTROPY_CANDIDATE_MIN_LENGTH &&
4120
+ isScreamingSnakeConstantValue(literal.content) &&
4121
+ literal.container?.bracket !== "(" &&
4122
+ candidateContainers.has(literalContainerKey(literal));
4123
+ if (isSelf || isSameNonCallContainerSibling) {
4124
+ const quote = line[literal.start] ?? "";
4125
+ result += quote + (literal.closed ? quote : "");
4126
+ } else {
4127
+ result += line.slice(literal.start, literal.end);
4128
+ }
4129
+ previousEnd = literal.end;
4130
+ }
4131
+ result += line.slice(previousEnd);
4132
+ return result;
4133
+ }
4134
+
4135
+ function shouldConsiderEntropyValue(value: string): boolean {
4136
+ const lower = value.toLowerCase();
4137
+ if (/^(?:dev-only|local|example|sample|your-|replace|<)/i.test(value)) {
4138
+ return false;
4139
+ }
4140
+ if (/^sha(?:256|512)-/i.test(value)) return false;
4141
+ if (/\s/.test(value)) return false;
4142
+ if (value.includes("${")) return false;
4143
+ if (value.includes("/")) return false;
4144
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return false;
4145
+ if (/^(?:\.{0,2}\/|~\/|[A-Za-z]:\\)/.test(value)) return false;
4146
+ if (value.includes(".") && /^[A-Za-z0-9_.-]+$/.test(value)) return false;
4147
+ if (lower.includes("/") && /\.[a-z0-9]{1,8}(?:$|[/?#])/i.test(value)) {
4148
+ return false;
4149
+ }
4150
+ return value.length >= ENTROPY_CANDIDATE_MIN_LENGTH;
4151
+ }
4152
+
4153
+ function classifyEntropyCharset(value: string): "base64" | "hex" | undefined {
4154
+ if (/^[a-f0-9]+$/i.test(value) && value.length >= 32) return "hex";
4155
+ const base64ishChars = value.match(/[A-Za-z0-9+/=_-]/g)?.length ?? 0;
4156
+ if (base64ishChars / value.length >= 0.9) return "base64";
4157
+ return undefined;
4158
+ }
4159
+
4160
+ function shannonEntropy(value: string): number {
4161
+ const counts = new Map<string, number>();
4162
+ for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1);
4163
+ let entropy = 0;
4164
+ for (const count of counts.values()) {
4165
+ const probability = count / value.length;
4166
+ entropy -= probability * Math.log2(probability);
4167
+ }
4168
+ return entropy;
4169
+ }
4170
+
4171
+ function guessSecretName(line: string): string {
4172
+ const match =
4173
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line) ??
4174
+ /["']?([A-Za-z_$][\w$-]*)["']?\s*:/.exec(line);
4175
+ const raw = match?.[1] ?? "SECRET";
4176
+ return raw
4177
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
4178
+ .toUpperCase()
4179
+ .replace(/[^A-Z0-9]+/g, "_");
4180
+ }
4181
+
4182
+ const SECRETISH_IDENTIFIER_PATTERN = /key|token|secret|password|credential|auth/i;
4183
+
4184
+ // Minimum length for a string literal to be considered an entropy candidate.
4185
+ // Shared by candidate extraction, entropy screening, and the context strip so
4186
+ // the three stay coherent.
4187
+ const ENTROPY_CANDIDATE_MIN_LENGTH = 20;
4188
+
2348
4189
  const SECRET_PATTERNS: Array<[string, RegExp]> = [
2349
- [
2350
- "JWT-like token",
2351
- /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/,
2352
- ],
4190
+ ["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
2353
4191
  ["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],
2354
4192
  ["Stripe live key", /(?:sk|rk)_live_[A-Za-z0-9]{20,}/],
2355
4193
  ["Bearer token", /Bearer\s+[A-Za-z0-9._~+/=-]{32,}/i],
@@ -2359,9 +4197,7 @@ const SECRET_PATTERNS: Array<[string, RegExp]> = [
2359
4197
  ],
2360
4198
  ];
2361
4199
 
2362
- async function safeLoadProvider(
2363
- providerRoot: string,
2364
- ): Promise<ProviderDefinition | undefined> {
4200
+ async function safeLoadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
2365
4201
  try {
2366
4202
  return await loadProvider(providerRoot);
2367
4203
  } catch {
@@ -2369,9 +4205,7 @@ async function safeLoadProvider(
2369
4205
  }
2370
4206
  }
2371
4207
 
2372
- async function loadProvider(
2373
- providerRoot: string,
2374
- ): Promise<ProviderDefinition | undefined> {
4208
+ async function loadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
2375
4209
  const entryPath = resolve(providerRoot, "index.ts");
2376
4210
  if (!existsSync(entryPath)) {
2377
4211
  return undefined;
@@ -2451,8 +4285,7 @@ export function renderText(report: SubmitCheckReport): string {
2451
4285
  ];
2452
4286
 
2453
4287
  for (const check of report.checks) {
2454
- const marker =
2455
- check.status === "pass" ? "✓" : check.status === "warn" ? "⚠" : "✗";
4288
+ const marker = check.status === "pass" ? "✓" : check.status === "warn" ? "⚠" : "✗";
2456
4289
  lines.push(
2457
4290
  `${marker} [${check.category}] ${check.message} (${check.points}/${check.maxPoints})`,
2458
4291
  );
@@ -2474,9 +4307,7 @@ export function renderMarkdown(report: SubmitCheckReport): string {
2474
4307
  `- **Provider**: ${report.provider.id}@${report.provider.version}`,
2475
4308
  `- **SDK**: ${report.provider.sdkVersion}`,
2476
4309
  `- **Runtime/Auth**: ${report.provider.runtime} / ${report.provider.authMode}`,
2477
- ...(report.provider.tier
2478
- ? [`- **Bounty tier**: ${report.provider.tier}`]
2479
- : []),
4310
+ ...(report.provider.tier ? [`- **Bounty tier**: ${report.provider.tier}`] : []),
2480
4311
  `- **Score**: ${report.score.total}/${report.score.max}`,
2481
4312
  `- **Verdict**: ${report.score.verdict}`,
2482
4313
  `- **Blockers**: ${report.summary.blockers}`,
@@ -2489,12 +4320,7 @@ export function renderMarkdown(report: SubmitCheckReport): string {
2489
4320
  ];
2490
4321
 
2491
4322
  for (const check of report.checks) {
2492
- const status =
2493
- check.status === "pass"
2494
- ? "PASS"
2495
- : check.status === "warn"
2496
- ? "WARN"
2497
- : "FAIL";
4323
+ const status = check.status === "pass" ? "PASS" : check.status === "warn" ? "WARN" : "FAIL";
2498
4324
  lines.push(
2499
4325
  `| ${status} | ${escapeMarkdown(check.category)} | ${escapeMarkdown(check.message)} | ${check.points}/${check.maxPoints} | ${escapeMarkdown(check.remediation ?? "")} |`,
2500
4326
  );
@@ -2524,9 +4350,7 @@ function redact(value: string): string {
2524
4350
  }
2525
4351
 
2526
4352
  function toGlobalRegex(pattern: RegExp): RegExp {
2527
- return pattern.global
2528
- ? pattern
2529
- : new RegExp(pattern.source, `${pattern.flags}g`);
4353
+ return pattern.global ? pattern : new RegExp(pattern.source, `${pattern.flags}g`);
2530
4354
  }
2531
4355
 
2532
4356
  function clamp(value: number, min: number, max: number): number {