@apifuse/provider-sdk 2.1.0-beta.2 → 2.1.0-beta.21

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 (231) hide show
  1. package/AUTHORING.md +330 -8
  2. package/CHANGELOG.md +89 -1
  3. package/README.md +64 -17
  4. package/SUBMISSION.md +86 -0
  5. package/bin/apifuse-check.ts +60 -6
  6. package/bin/apifuse-dev.ts +58 -8
  7. package/bin/apifuse-pack-check.ts +32 -2
  8. package/bin/apifuse-pack-smoke.ts +133 -6
  9. package/bin/apifuse-perf.ts +142 -49
  10. package/bin/apifuse-record.ts +182 -104
  11. package/bin/apifuse-submit-check.ts +3243 -0
  12. package/bin/apifuse.ts +1 -1
  13. package/dist/auth.d.ts +76 -0
  14. package/dist/auth.js +436 -0
  15. package/dist/ceremonies/index.d.ts +41 -0
  16. package/dist/ceremonies/index.js +490 -0
  17. package/dist/choice-token.d.ts +24 -0
  18. package/dist/choice-token.js +74 -0
  19. package/dist/cli/commands.d.ts +10 -0
  20. package/dist/cli/commands.js +80 -0
  21. package/dist/cli/create.d.ts +47 -0
  22. package/dist/cli/create.js +777 -0
  23. package/dist/cli/templates/provider/.dockerignore.tpl +22 -0
  24. package/dist/cli/templates/provider/.gitignore.tpl +22 -0
  25. package/dist/cli/templates/provider/AGENTS.md.tpl +87 -0
  26. package/dist/cli/templates/provider/CLAUDE.md.tpl +1 -0
  27. package/dist/cli/templates/provider/Dockerfile.tpl +7 -0
  28. package/dist/cli/templates/provider/README.md.tpl +163 -0
  29. package/dist/cli/templates/provider/dev.ts.tpl +5 -0
  30. package/dist/cli/templates/provider/domain/README.md.tpl +3 -0
  31. package/dist/cli/templates/provider/index.test.ts.tpl +13 -0
  32. package/dist/cli/templates/provider/index.ts.tpl +15 -0
  33. package/dist/cli/templates/provider/mappers/README.md.tpl +3 -0
  34. package/dist/cli/templates/provider/meta.ts.tpl +7 -0
  35. package/dist/cli/templates/provider/operations/index.ts.tpl +5 -0
  36. package/dist/cli/templates/provider/operations/ping.ts.tpl +24 -0
  37. package/dist/cli/templates/provider/schemas/ping.ts.tpl +24 -0
  38. package/dist/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  39. package/dist/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  40. package/dist/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
  41. package/dist/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  42. package/dist/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  43. package/dist/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
  44. package/dist/cli/templates/provider/start.ts.tpl +5 -0
  45. package/dist/cli/templates/provider/upstream/README.md.tpl +3 -0
  46. package/dist/config/loader.d.ts +107 -0
  47. package/dist/config/loader.js +935 -0
  48. package/dist/contract-json.d.ts +9 -0
  49. package/dist/contract-json.js +51 -0
  50. package/dist/contract-serialization.d.ts +4 -0
  51. package/dist/contract-serialization.js +78 -0
  52. package/dist/contract-types.d.ts +49 -0
  53. package/dist/contract-types.js +1 -0
  54. package/dist/contract.d.ts +6 -0
  55. package/dist/contract.js +156 -0
  56. package/dist/define.d.ts +100 -0
  57. package/dist/define.js +1383 -0
  58. package/dist/dev.d.ts +9 -0
  59. package/dist/dev.js +15 -0
  60. package/dist/errors.d.ts +59 -0
  61. package/dist/errors.js +97 -0
  62. package/dist/i18n/catalog.d.ts +29 -0
  63. package/dist/i18n/catalog.js +159 -0
  64. package/dist/i18n/index.d.ts +2 -0
  65. package/dist/i18n/index.js +2 -0
  66. package/dist/i18n/keys.d.ts +10 -0
  67. package/dist/i18n/keys.js +34 -0
  68. package/dist/index.d.ts +42 -0
  69. package/dist/index.js +38 -0
  70. package/dist/lint.d.ts +74 -0
  71. package/dist/lint.js +729 -0
  72. package/dist/observability.d.ts +5 -0
  73. package/dist/observability.js +39 -0
  74. package/dist/provider.d.ts +11 -0
  75. package/dist/provider.js +9 -0
  76. package/dist/public-schema-field-lint.d.ts +2 -0
  77. package/dist/public-schema-field-lint.js +158 -0
  78. package/dist/recipes/gov-api.d.ts +19 -0
  79. package/dist/recipes/gov-api.js +72 -0
  80. package/dist/recipes/rest-api.d.ts +21 -0
  81. package/dist/recipes/rest-api.js +115 -0
  82. package/dist/runtime/auth-flow.d.ts +14 -0
  83. package/dist/runtime/auth-flow.js +46 -0
  84. package/dist/runtime/browser.d.ts +25 -0
  85. package/dist/runtime/browser.js +1237 -0
  86. package/dist/runtime/cache.d.ts +10 -0
  87. package/dist/runtime/cache.js +372 -0
  88. package/dist/runtime/choice.d.ts +15 -0
  89. package/dist/runtime/choice.js +435 -0
  90. package/dist/runtime/credential.d.ts +8 -0
  91. package/dist/runtime/credential.js +61 -0
  92. package/dist/runtime/env.d.ts +2 -0
  93. package/dist/runtime/env.js +10 -0
  94. package/dist/runtime/executor.d.ts +16 -0
  95. package/dist/runtime/executor.js +51 -0
  96. package/dist/runtime/http.d.ts +8 -0
  97. package/dist/runtime/http.js +726 -0
  98. package/dist/runtime/insights.d.ts +9 -0
  99. package/dist/runtime/insights.js +324 -0
  100. package/dist/runtime/instrumentation.d.ts +8 -0
  101. package/dist/runtime/instrumentation.js +269 -0
  102. package/dist/runtime/key-derivation.d.ts +24 -0
  103. package/dist/runtime/key-derivation.js +73 -0
  104. package/dist/runtime/keyring.d.ts +25 -0
  105. package/dist/runtime/keyring.js +93 -0
  106. package/dist/runtime/namespace.d.ts +9 -0
  107. package/dist/runtime/namespace.js +19 -0
  108. package/dist/runtime/otlp.d.ts +39 -0
  109. package/dist/runtime/otlp.js +103 -0
  110. package/dist/runtime/perf.d.ts +12 -0
  111. package/dist/runtime/perf.js +52 -0
  112. package/dist/runtime/prevalidate.d.ts +12 -0
  113. package/dist/runtime/prevalidate.js +173 -0
  114. package/dist/runtime/provider.d.ts +2 -0
  115. package/dist/runtime/provider.js +11 -0
  116. package/dist/runtime/proxy-errors.d.ts +21 -0
  117. package/dist/runtime/proxy-errors.js +83 -0
  118. package/dist/runtime/proxy-telemetry.d.ts +8 -0
  119. package/dist/runtime/proxy-telemetry.js +174 -0
  120. package/dist/runtime/redis.d.ts +17 -0
  121. package/dist/runtime/redis.js +82 -0
  122. package/dist/runtime/request-options.d.ts +3 -0
  123. package/dist/runtime/request-options.js +42 -0
  124. package/dist/runtime/state.d.ts +17 -0
  125. package/dist/runtime/state.js +344 -0
  126. package/dist/runtime/stealth.d.ts +21 -0
  127. package/dist/runtime/stealth.js +980 -0
  128. package/dist/runtime/stt.d.ts +22 -0
  129. package/dist/runtime/stt.js +480 -0
  130. package/dist/runtime/trace.d.ts +26 -0
  131. package/dist/runtime/trace.js +142 -0
  132. package/dist/runtime/waterfall.d.ts +12 -0
  133. package/dist/runtime/waterfall.js +147 -0
  134. package/dist/schema.d.ts +74 -0
  135. package/dist/schema.js +243 -0
  136. package/dist/serve.d.ts +1 -0
  137. package/dist/serve.js +1 -0
  138. package/dist/server/index.d.ts +3 -0
  139. package/dist/server/index.js +2 -0
  140. package/dist/server/serve.d.ts +64 -0
  141. package/dist/server/serve.js +1118 -0
  142. package/dist/server/types.d.ts +136 -0
  143. package/dist/server/types.js +86 -0
  144. package/dist/stealth/profiles.d.ts +4 -0
  145. package/dist/stealth/profiles.js +259 -0
  146. package/dist/stream.d.ts +44 -0
  147. package/dist/stream.js +151 -0
  148. package/dist/testing/helpers.d.ts +23 -0
  149. package/dist/testing/helpers.js +95 -0
  150. package/dist/testing/index.d.ts +2 -0
  151. package/dist/testing/index.js +2 -0
  152. package/dist/testing/run.d.ts +34 -0
  153. package/dist/testing/run.js +307 -0
  154. package/dist/types.d.ts +1467 -0
  155. package/dist/types.js +61 -0
  156. package/dist/utils/date.d.ts +6 -0
  157. package/dist/utils/date.js +101 -0
  158. package/dist/utils/parse.d.ts +16 -0
  159. package/dist/utils/parse.js +51 -0
  160. package/dist/utils/text.d.ts +4 -0
  161. package/dist/utils/text.js +14 -0
  162. package/dist/utils/transform.d.ts +8 -0
  163. package/dist/utils/transform.js +48 -0
  164. package/package.json +57 -29
  165. package/src/auth.ts +786 -0
  166. package/src/ceremonies/index.ts +8 -2
  167. package/src/choice-token.ts +165 -0
  168. package/src/cli/commands.ts +34 -11
  169. package/src/cli/create.ts +254 -128
  170. package/src/cli/templates/provider/.dockerignore.tpl +22 -0
  171. package/src/cli/templates/provider/.gitignore.tpl +22 -0
  172. package/src/cli/templates/provider/AGENTS.md.tpl +87 -0
  173. package/src/cli/templates/provider/CLAUDE.md.tpl +1 -0
  174. package/src/cli/templates/provider/README.md.tpl +87 -7
  175. package/src/cli/templates/provider/dev.ts.tpl +1 -1
  176. package/src/cli/templates/provider/domain/README.md.tpl +3 -0
  177. package/src/cli/templates/provider/index.ts.tpl +5 -47
  178. package/src/cli/templates/provider/mappers/README.md.tpl +3 -0
  179. package/src/cli/templates/provider/meta.ts.tpl +7 -0
  180. package/src/cli/templates/provider/operations/index.ts.tpl +5 -0
  181. package/src/cli/templates/provider/operations/ping.ts.tpl +24 -0
  182. package/src/cli/templates/provider/schemas/ping.ts.tpl +24 -0
  183. package/src/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  184. package/src/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  185. package/src/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
  186. package/src/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  187. package/src/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  188. package/src/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
  189. package/src/cli/templates/provider/start.ts.tpl +1 -1
  190. package/src/cli/templates/provider/upstream/README.md.tpl +3 -0
  191. package/src/config/loader.ts +1224 -9
  192. package/src/contract-json.ts +75 -0
  193. package/src/contract-serialization.ts +89 -0
  194. package/src/contract-types.ts +52 -0
  195. package/src/contract.ts +216 -0
  196. package/src/define.ts +1820 -70
  197. package/src/errors.ts +27 -0
  198. package/src/i18n/catalog.ts +277 -0
  199. package/src/i18n/index.ts +2 -0
  200. package/src/i18n/keys.ts +64 -0
  201. package/src/index.ts +189 -9
  202. package/src/lint.ts +580 -73
  203. package/src/observability.ts +41 -0
  204. package/src/provider.ts +131 -4
  205. package/src/public-schema-field-lint.ts +237 -0
  206. package/src/runtime/auth-flow.ts +9 -0
  207. package/src/runtime/browser.ts +1054 -51
  208. package/src/runtime/cache.ts +528 -0
  209. package/src/runtime/choice.ts +760 -0
  210. package/src/runtime/executor.ts +32 -3
  211. package/src/runtime/http.ts +980 -195
  212. package/src/runtime/insights.ts +11 -11
  213. package/src/runtime/instrumentation.ts +12 -4
  214. package/src/runtime/key-derivation.ts +1 -1
  215. package/src/runtime/keyring.ts +4 -3
  216. package/src/runtime/proxy-errors.ts +132 -0
  217. package/src/runtime/proxy-telemetry.ts +253 -0
  218. package/src/runtime/redis.ts +116 -0
  219. package/src/runtime/request-options.ts +66 -0
  220. package/src/runtime/state.ts +563 -0
  221. package/src/runtime/stealth.ts +1336 -0
  222. package/src/runtime/stt.ts +629 -0
  223. package/src/runtime/trace.ts +1 -1
  224. package/src/schema.ts +363 -1
  225. package/src/server/serve.ts +1192 -75
  226. package/src/server/types.ts +37 -0
  227. package/src/stream.ts +210 -0
  228. package/src/testing/run.ts +40 -6
  229. package/src/types.ts +1283 -59
  230. package/src/runtime/tls.ts +0 -434
  231. package/src/types/playwright-stealth.d.ts +0 -9
@@ -0,0 +1,3243 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { type ChildProcess, spawn } from "node:child_process";
4
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
5
+ import { writeFile } from "node:fs/promises";
6
+ import { createServer } from "node:net";
7
+ import { basename, dirname, join, relative, resolve } from "node:path";
8
+ import { pathToFileURL } from "node:url";
9
+
10
+ import * as acorn from "acorn";
11
+ import { z } from "zod";
12
+
13
+ import packageJson from "../package.json";
14
+ import type { ProviderDefinition } from "../src";
15
+ import {
16
+ loadProviderLocaleCatalogs,
17
+ type ProviderLocale,
18
+ validateProviderLocaleCatalogs,
19
+ } from "../src/i18n";
20
+ import { APIFUSE_DESCRIPTION_KEY_META_KEY } from "../src/schema";
21
+ import { safeParseSchemaSync } from "../src/schema";
22
+ import { type CheckResult, runChecks } from "./apifuse-check";
23
+
24
+ const TIERS = ["bronze", "silver", "gold", "diamond"] as const;
25
+ const TIER_VALUES: ReadonlySet<string> = new Set(TIERS);
26
+ type BountyTier = (typeof TIERS)[number];
27
+
28
+ type CheckLevel = "blocker" | "warn" | "info";
29
+ type CheckStatus = "pass" | "fail" | "warn" | "not_applicable";
30
+ type Verdict = "ready" | "reviewable_with_warnings" | "blocked";
31
+
32
+ export type SubmitCheck = {
33
+ id: string;
34
+ category: string;
35
+ level: CheckLevel;
36
+ status: CheckStatus;
37
+ points: number;
38
+ maxPoints: number;
39
+ message: string;
40
+ remediation?: string;
41
+ evidence?: string[];
42
+ details?: unknown;
43
+ };
44
+
45
+ export type SubmitCheckReport = {
46
+ schemaVersion: 1;
47
+ generatedAt: string;
48
+ provider: {
49
+ id: string;
50
+ version: string;
51
+ runtime: string;
52
+ authMode: string;
53
+ sdkVersion: string;
54
+ tier?: BountyTier;
55
+ };
56
+ score: {
57
+ total: number;
58
+ max: 100;
59
+ verdict: Verdict;
60
+ };
61
+ summary: {
62
+ blockers: number;
63
+ warnings: number;
64
+ passed: number;
65
+ };
66
+ checks: SubmitCheck[];
67
+ };
68
+
69
+ export function isAutoPromotionEligible(report: SubmitCheckReport): boolean {
70
+ return report.score.total >= 95 && report.summary.blockers === 0;
71
+ }
72
+
73
+ type CliArgs = {
74
+ isJson: boolean;
75
+ markdownPath?: string;
76
+ providerPath?: string;
77
+ smoke: boolean;
78
+ smokeNote?: string;
79
+ tier?: BountyTier;
80
+ };
81
+
82
+ type SecretFinding = {
83
+ label: string;
84
+ file: string;
85
+ line?: number;
86
+ level?: CheckLevel;
87
+ remediation?: string;
88
+ evidence?: string;
89
+ };
90
+
91
+ export type SmokeOperationOutcome = {
92
+ operationId: string;
93
+ status: "success" | "structured_error" | "incoherent";
94
+ httpStatus?: number;
95
+ message: string;
96
+ };
97
+
98
+ export type SmokeResult = {
99
+ measured: true;
100
+ healthOk: boolean;
101
+ bootError?: string;
102
+ operations: SmokeOperationOutcome[];
103
+ };
104
+
105
+ type SourceFinding = {
106
+ file: string;
107
+ line: number;
108
+ };
109
+
110
+ const SDK_NATIVE_CATEGORY = "sdk-native";
111
+ const VENDOR_SHIM_PROVIDER_ID_PREFIX = "apifuse-provider-";
112
+ const MAX_SOURCE_FINDING_EVIDENCE = 5;
113
+
114
+ const CATEGORY_MAX_POINTS = {
115
+ definition: 15,
116
+ operations: 15,
117
+ fixtures: 15,
118
+ health: 15,
119
+ smoke: 10,
120
+ auth: 10,
121
+ security: 10,
122
+ docs: 10,
123
+ } as const;
124
+
125
+ const REQUIRED_PUBLIC_PROVIDER_LOCALES = ["en", "ko"] as const satisfies readonly ProviderLocale[];
126
+
127
+ const HELP_TEXT = `Usage: apifuse submit-check [path] [--tier bronze|silver|gold|diamond] [--json] [--markdown <path>] [--smoke]
128
+ Alias: apifuse bounty-check [path]
129
+ Default: apifuse submit-check .
130
+
131
+ 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.`;
132
+
133
+ export async function main() {
134
+ try {
135
+ const args = parseArgs(normalizeArgs(process.argv.slice(2)));
136
+
137
+ if (args.isJson && process.argv.includes("--help")) {
138
+ console.log(JSON.stringify({ help: HELP_TEXT }));
139
+ return;
140
+ }
141
+
142
+ const providerRoot = resolveProviderRoot(args.providerPath ?? ".");
143
+ const report = await buildSubmitCheckReport(providerRoot, args);
144
+
145
+ if (args.markdownPath) {
146
+ await writeFile(resolve(process.cwd(), args.markdownPath), renderMarkdown(report));
147
+ }
148
+
149
+ if (args.isJson) {
150
+ console.log(JSON.stringify(report, null, 2));
151
+ } else {
152
+ console.log(renderText(report));
153
+ if (args.markdownPath) {
154
+ console.log(`\nMarkdown report: ${args.markdownPath}`);
155
+ }
156
+ }
157
+
158
+ if (report.score.verdict === "blocked") {
159
+ process.exit(1);
160
+ }
161
+ } catch (error) {
162
+ console.error(error instanceof Error ? error.message : String(error));
163
+ process.exit(1);
164
+ }
165
+ }
166
+
167
+ function normalizeArgs(argv: string[]): string[] {
168
+ const [command, ...rest] = argv;
169
+ return command === "submit-check" || command === "bounty-check" ? rest : argv;
170
+ }
171
+
172
+ function parseArgs(argv: string[]): CliArgs {
173
+ const args: CliArgs = { isJson: false, smoke: false };
174
+
175
+ for (let index = 0; index < argv.length; index += 1) {
176
+ const arg = argv[index];
177
+ if (!arg) continue;
178
+
179
+ if (arg === "--help" || arg === "-h") {
180
+ console.log(HELP_TEXT);
181
+ process.exit(0);
182
+ }
183
+
184
+ if (arg === "--json") {
185
+ args.isJson = true;
186
+ continue;
187
+ }
188
+
189
+ if (arg === "--markdown") {
190
+ args.markdownPath = requireValue(argv, index, arg);
191
+ index += 1;
192
+ continue;
193
+ }
194
+
195
+ if (arg.startsWith("--markdown=")) {
196
+ args.markdownPath = arg.slice("--markdown=".length);
197
+ continue;
198
+ }
199
+
200
+ if (arg === "--smoke") {
201
+ args.smoke = true;
202
+ continue;
203
+ }
204
+
205
+ if (arg === "--smoke-note") {
206
+ args.smokeNote = requireValue(argv, index, arg);
207
+ index += 1;
208
+ continue;
209
+ }
210
+
211
+ if (arg.startsWith("--smoke-note=")) {
212
+ args.smokeNote = arg.slice("--smoke-note=".length);
213
+ continue;
214
+ }
215
+
216
+ if (arg === "--tier") {
217
+ args.tier = parseTier(requireValue(argv, index, arg));
218
+ index += 1;
219
+ continue;
220
+ }
221
+
222
+ if (arg.startsWith("--tier=")) {
223
+ args.tier = parseTier(arg.slice("--tier=".length));
224
+ continue;
225
+ }
226
+
227
+ if (arg.startsWith("-")) {
228
+ throw new Error(`Unknown option: ${arg}`);
229
+ }
230
+
231
+ if (!args.providerPath) {
232
+ args.providerPath = arg;
233
+ continue;
234
+ }
235
+
236
+ throw new Error(`Unexpected argument: ${arg}`);
237
+ }
238
+
239
+ return args;
240
+ }
241
+
242
+ function requireValue(argv: string[], index: number, label: string): string {
243
+ const value = argv[index + 1];
244
+ if (!value) {
245
+ throw new Error(`Missing value for ${label}.`);
246
+ }
247
+ return value;
248
+ }
249
+
250
+ function parseTier(value: string): BountyTier {
251
+ if (isBountyTier(value)) {
252
+ return value;
253
+ }
254
+ throw new Error(`Invalid --tier "${value}". Expected one of: ${TIERS.join(", ")}`);
255
+ }
256
+
257
+ function isBountyTier(value: string): value is BountyTier {
258
+ return TIER_VALUES.has(value);
259
+ }
260
+
261
+ export async function buildSubmitCheckReport(
262
+ providerRoot: string,
263
+ args: { smoke?: boolean; smokeNote?: string; tier?: BountyTier } = {},
264
+ ): Promise<SubmitCheckReport> {
265
+ const checks: SubmitCheck[] = [];
266
+ const baseChecks = await safeRunChecks(providerRoot);
267
+ const provider = await safeLoadProvider(providerRoot);
268
+
269
+ checks.push(...scoreBaseChecks(baseChecks));
270
+ checks.push(scoreProviderIdSlug(providerRoot, provider));
271
+ checks.push(scoreNoVendorShim(providerRoot));
272
+ checks.push(scoreNoVendorImport(providerRoot));
273
+ checks.push(scoreDescribeKey(providerRoot));
274
+ checks.push(scoreNoRawFetch(providerRoot));
275
+ checks.push(scoreNoRedundantRuntimeGuards(providerRoot));
276
+ checks.push(scoreManagedBrowserRuntime(providerRoot));
277
+ checks.push(scoreAsAssertionCount(providerRoot));
278
+ checks.push(scoreUnsafeInputPassthrough(providerRoot));
279
+ checks.push(scoreUnjustifiedLooseSchema(providerRoot));
280
+ checks.push(scoreFlatOperationComposition(providerRoot));
281
+
282
+ if (provider) {
283
+ const smokeResult = args.smoke ? await runSubmitCheckSmoke(providerRoot, provider) : undefined;
284
+ checks.push(scoreCredentialUsage(providerRoot, provider));
285
+ checks.push(scoreLocaleCatalog(providerRoot, provider));
286
+ checks.push(scoreOperationMetadata(provider));
287
+ checks.push(scoreFixtureCoverage(provider));
288
+ checks.push(scoreHealthCoverage(provider));
289
+ checks.push(scoreAuthSafety(provider));
290
+ checks.push(scoreSmoke(smokeResult, args.smokeNote));
291
+ checks.push(...scoreProviderDocs(providerRoot));
292
+ checks.push(scoreRepositoryDx(providerRoot));
293
+ checks.push(scoreSecrets(providerRoot, provider));
294
+ } else {
295
+ checks.push(
296
+ blocker(
297
+ "provider-load",
298
+ "definition",
299
+ "Provider could not be loaded.",
300
+ "Fix index.ts so it default-exports defineProvider(...).",
301
+ CATEGORY_MAX_POINTS.definition,
302
+ ),
303
+ );
304
+ }
305
+
306
+ const total = clamp(Math.round(checks.reduce((sum, check) => sum + check.points, 0)), 0, 100);
307
+ const blockers = checks.filter(
308
+ (check) => check.level === "blocker" && check.status === "fail",
309
+ ).length;
310
+ const warnings = checks.filter((check) => check.status === "warn").length;
311
+ const passed = checks.filter((check) => check.status === "pass").length;
312
+ const verdict: Verdict =
313
+ blockers > 0 ? "blocked" : total >= 90 && warnings === 0 ? "ready" : "reviewable_with_warnings";
314
+
315
+ return {
316
+ schemaVersion: 1,
317
+ generatedAt: new Date().toISOString(),
318
+ provider: {
319
+ id: provider?.id ?? basename(providerRoot),
320
+ version: provider?.version ?? "unknown",
321
+ runtime: provider?.runtime ?? "unknown",
322
+ authMode: provider?.auth?.mode ?? "none",
323
+ sdkVersion: packageJson.version,
324
+ ...(args.tier ? { tier: args.tier } : {}),
325
+ },
326
+ score: { total, max: 100, verdict },
327
+ summary: { blockers, warnings, passed },
328
+ checks,
329
+ };
330
+ }
331
+
332
+ function scoreProviderIdSlug(
333
+ providerRoot: string,
334
+ provider: ProviderDefinition | undefined,
335
+ ): SubmitCheck {
336
+ const remediation =
337
+ 'Rename defineProvider({ id }) to the short slug (e.g. "tabelog", not "apifuse-provider-tabelog"). Also update manifest/PROVIDER_ID consts and tests. Grep: git grep "apifuse-provider-<name>".';
338
+
339
+ // Prefer the loaded provider id; fall back to scanning source so the rule
340
+ // still fires when the provider fails to load (e.g. a vendor shim or other
341
+ // structural problem prevents defineProvider from resolving).
342
+ if (provider) {
343
+ if (provider.id.startsWith(VENDOR_SHIM_PROVIDER_ID_PREFIX)) {
344
+ return blocker(
345
+ "id-slug",
346
+ SDK_NATIVE_CATEGORY,
347
+ "Provider id uses the apifuse-provider- prefix.",
348
+ remediation,
349
+ 0,
350
+ [provider.id],
351
+ );
352
+ }
353
+
354
+ return pass("id-slug", SDK_NATIVE_CATEGORY, "Provider id uses the short slug.", 0);
355
+ }
356
+
357
+ const findings = findSourceLineMatches(providerRoot, /["'`]apifuse-provider-[a-z0-9-]/i);
358
+ if (findings.length > 0) {
359
+ return blocker(
360
+ "id-slug",
361
+ SDK_NATIVE_CATEGORY,
362
+ "Provider id uses the apifuse-provider- prefix.",
363
+ remediation,
364
+ 0,
365
+ formatSourceFindings(findings),
366
+ );
367
+ }
368
+
369
+ return pass("id-slug", SDK_NATIVE_CATEGORY, "Provider id uses the short slug.", 0);
370
+ }
371
+
372
+ function scoreNoVendorShim(providerRoot: string): SubmitCheck {
373
+ const vendorPath = resolve(providerRoot, "vendor");
374
+ if (existsSync(vendorPath)) {
375
+ return blocker(
376
+ "no-vendor-shim",
377
+ SDK_NATIVE_CATEGORY,
378
+ "Provider contains a vendor/ SDK shim directory.",
379
+ "Delete vendor/ and import directly from @apifuse/provider-sdk (/provider, root, /testing). SDK-absent symbols (e.g. createStateContext) must use real SDK equivalents (createUnsupportedProviderRuntimeState for unused ctx.state).",
380
+ 0,
381
+ [vendorPath],
382
+ );
383
+ }
384
+
385
+ return pass(
386
+ "no-vendor-shim",
387
+ SDK_NATIVE_CATEGORY,
388
+ "Provider does not contain a vendor/ SDK shim directory.",
389
+ 0,
390
+ );
391
+ }
392
+
393
+ function scoreNoVendorImport(providerRoot: string): SubmitCheck {
394
+ const findings = findSourceLineMatches(providerRoot, /from\s+["'][^"']*vendor\//);
395
+ if (findings.length > 0) {
396
+ return blocker(
397
+ "no-vendor-import",
398
+ SDK_NATIVE_CATEGORY,
399
+ "Provider source imports from vendor/ shim.",
400
+ "Re-point every import from ../vendor/provider-sdk to @apifuse/provider-sdk/provider, @apifuse/provider-sdk, or @apifuse/provider-sdk/testing.",
401
+ 0,
402
+ formatSourceFindings(findings),
403
+ );
404
+ }
405
+
406
+ return pass(
407
+ "no-vendor-import",
408
+ SDK_NATIVE_CATEGORY,
409
+ "Provider source imports directly from the SDK.",
410
+ 0,
411
+ );
412
+ }
413
+
414
+ function scoreDescribeKey(providerRoot: string): SubmitCheck {
415
+ const findings = findSourceLineMatches(providerRoot, /\.describe\(["']/);
416
+ if (findings.length > 0) {
417
+ return blocker(
418
+ "describe-key",
419
+ SDK_NATIVE_CATEGORY,
420
+ "Schema descriptions use raw .describe() prose instead of describeKey.",
421
+ 'Replace .describe("prose") with describeKey(schema, key, { description }) backed by locale keys in locales/en.json + ko.json.',
422
+ 0,
423
+ formatSourceFindings(findings),
424
+ );
425
+ }
426
+
427
+ return pass("describe-key", SDK_NATIVE_CATEGORY, "Schema descriptions use describeKey.", 0);
428
+ }
429
+
430
+ function scoreNoRawFetch(providerRoot: string): SubmitCheck {
431
+ const findings = findSourceLineMatches(providerRoot, /(?<![.\w])fetch\s*\(/);
432
+ if (findings.length > 0) {
433
+ const evidence = formatSourceFindings(findings);
434
+ return blocker(
435
+ "no-raw-fetch",
436
+ SDK_NATIVE_CATEGORY,
437
+ "Provider source calls raw fetch().",
438
+ `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.`,
439
+ 0,
440
+ evidence,
441
+ );
442
+ }
443
+
444
+ return pass("no-raw-fetch", SDK_NATIVE_CATEGORY, "Provider source avoids raw fetch().", 0);
445
+ }
446
+
447
+ const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
448
+ /\bctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|choice)\?\./,
449
+ ];
450
+
451
+ const SDK_CONTEXT_METHOD_ALIAS_PATTERN =
452
+ /\bconst\s+(\w+)\s*=\s*ctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|choice)\.(?:\w+)/;
453
+
454
+ function hasRedundantRuntimeGuard(line: string, remainingLines: readonly string[]): boolean {
455
+ if (REDUNDANT_RUNTIME_GUARD_PATTERNS.some((pattern) => pattern.test(line))) {
456
+ return true;
457
+ }
458
+
459
+ const aliasMatch = SDK_CONTEXT_METHOD_ALIAS_PATTERN.exec(line);
460
+ const alias = aliasMatch?.[1];
461
+ if (!alias) {
462
+ return false;
463
+ }
464
+
465
+ const guardPattern = new RegExp(`(?:typeof\\s+${alias}\\s*!==\\s*["']function["']|!${alias}\\b)`);
466
+ return remainingLines.slice(0, 8).some((candidate) => guardPattern.test(candidate));
467
+ }
468
+
469
+ function scoreNoRedundantRuntimeGuards(providerRoot: string): SubmitCheck {
470
+ const findings = findSourceFindings(providerRoot, hasRedundantRuntimeGuard);
471
+ if (findings.length > 0) {
472
+ return blocker(
473
+ "no-redundant-runtime-guards",
474
+ SDK_NATIVE_CATEGORY,
475
+ "Provider source has redundant runtime guard code for SDK-owned context APIs.",
476
+ "Trust the provider SDK context contract: call ctx.stealth.fetch(), ctx.http, and other SDK-owned context APIs directly. Remove optional chaining and typeof function guards around non-null runtime clients.",
477
+ 0,
478
+ formatSourceFindings(findings),
479
+ );
480
+ }
481
+
482
+ return pass(
483
+ "no-redundant-runtime-guards",
484
+ SDK_NATIVE_CATEGORY,
485
+ "Provider source avoids redundant runtime guard code around SDK-owned context APIs.",
486
+ 0,
487
+ );
488
+ }
489
+
490
+ const AS_ASSERTION_PATTERN =
491
+ /\bas\s+(any|unknown|never|string|number|boolean)\b|\bas\s+[A-Z]|\bas\s+\{|\bas\s+Record\b|\bas\s+typeof\b/;
492
+
493
+ function countAsAssertions(providerRoot: string): {
494
+ count: number;
495
+ findings: SourceFinding[];
496
+ } {
497
+ let count = 0;
498
+ const findings: SourceFinding[] = [];
499
+
500
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
501
+ const content = readFileSync(filePath, "utf8");
502
+ const lines = content.split(/\r?\n/);
503
+ for (let index = 0; index < lines.length; index += 1) {
504
+ const line = lines[index];
505
+ if (
506
+ line === undefined ||
507
+ line.includes("import") ||
508
+ /\bas\s*const\b/.test(line) ||
509
+ !AS_ASSERTION_PATTERN.test(line)
510
+ ) {
511
+ continue;
512
+ }
513
+
514
+ count += 1;
515
+ if (findings.length < MAX_SOURCE_FINDING_EVIDENCE) {
516
+ findings.push({
517
+ file: toRelativeProviderPath(providerRoot, filePath),
518
+ line: index + 1,
519
+ });
520
+ }
521
+ }
522
+ }
523
+
524
+ return { count, findings };
525
+ }
526
+
527
+ function scoreAsAssertionCount(providerRoot: string): SubmitCheck {
528
+ const { count, findings } = countAsAssertions(providerRoot);
529
+ const assertionLabel = "as " + "Type";
530
+ const remediation = `Replace \`${assertionLabel}\` with zod \`schema.safeParse()\` or \`if ('key' in obj)\` type guards. \`as const\` is allowed.`;
531
+
532
+ if (count > 20) {
533
+ return blocker(
534
+ "as-assertion-count",
535
+ SDK_NATIVE_CATEGORY,
536
+ `Provider uses ${count} type assertions (${assertionLabel}). Replace with zod safeParse or type guards.`,
537
+ remediation,
538
+ 0,
539
+ formatSourceFindings(findings),
540
+ );
541
+ }
542
+
543
+ if (count >= 6) {
544
+ return {
545
+ id: "as-assertion-count",
546
+ category: SDK_NATIVE_CATEGORY,
547
+ level: "warn",
548
+ status: "warn",
549
+ points: 0,
550
+ maxPoints: 0,
551
+ message: `Provider uses ${count} type assertions (${assertionLabel}). Replace with zod safeParse or type guards.`,
552
+ remediation,
553
+ evidence: formatSourceFindings(findings),
554
+ };
555
+ }
556
+
557
+ return pass(
558
+ "as-assertion-count",
559
+ SDK_NATIVE_CATEGORY,
560
+ "Type assertions are within the recommended limit.",
561
+ 0,
562
+ );
563
+ }
564
+
565
+ // Returns true when `findingLine` (1-based) or the line directly above it
566
+ // carries an `// @apifuse-allow <ruleId>:` acknowledgement comment.
567
+ function hasAllowOverride(lines: readonly string[], findingLine: number, ruleId: string): boolean {
568
+ const pattern = new RegExp(`@apifuse-allow\\s+${ruleId}\\b`);
569
+ const current = lines[findingLine - 1];
570
+ const previous = lines[findingLine - 2];
571
+ return (
572
+ (current !== undefined && pattern.test(current)) ||
573
+ (previous !== undefined && pattern.test(previous))
574
+ );
575
+ }
576
+
577
+ // Splits source findings into non-overridden (still violations) and
578
+ // acknowledged (escape-hatched) sets by re-reading each file's lines.
579
+ function partitionAllowOverrides(
580
+ providerRoot: string,
581
+ findings: readonly SourceFinding[],
582
+ ruleId: string,
583
+ ): { violations: SourceFinding[]; overridden: SourceFinding[] } {
584
+ const fileLineCache = new Map<string, string[]>();
585
+ const violations: SourceFinding[] = [];
586
+ const overridden: SourceFinding[] = [];
587
+
588
+ for (const finding of findings) {
589
+ const absolute = resolve(providerRoot, finding.file);
590
+ let lines = fileLineCache.get(absolute);
591
+ if (lines === undefined) {
592
+ lines = readFileSync(absolute, "utf8").split(/\r?\n/);
593
+ fileLineCache.set(absolute, lines);
594
+ }
595
+ if (hasAllowOverride(lines, finding.line, ruleId)) {
596
+ overridden.push(finding);
597
+ } else {
598
+ violations.push(finding);
599
+ }
600
+ }
601
+
602
+ return { violations, overridden };
603
+ }
604
+
605
+ // Builds a blocker/warn/pass result for an escape-hatch-aware rule:
606
+ // any non-overridden finding => blocker; only acknowledged overrides => warn;
607
+ // nothing => pass.
608
+ function escapeHatchResult(
609
+ providerRoot: string,
610
+ ruleId: string,
611
+ findings: readonly SourceFinding[],
612
+ copy: { blockerMessage: string; remediation: string; passMessage: string },
613
+ ): SubmitCheck {
614
+ if (findings.length === 0) {
615
+ return pass(ruleId, SDK_NATIVE_CATEGORY, copy.passMessage, 0);
616
+ }
617
+
618
+ const { violations, overridden } = partitionAllowOverrides(providerRoot, findings, ruleId);
619
+
620
+ if (violations.length > 0) {
621
+ return blocker(
622
+ ruleId,
623
+ SDK_NATIVE_CATEGORY,
624
+ copy.blockerMessage,
625
+ copy.remediation,
626
+ 0,
627
+ formatSourceFindings(violations),
628
+ );
629
+ }
630
+
631
+ return {
632
+ id: ruleId,
633
+ category: SDK_NATIVE_CATEGORY,
634
+ level: "warn",
635
+ status: "warn",
636
+ points: 0,
637
+ maxPoints: 0,
638
+ message: `${copy.blockerMessage} ${overridden.length} acknowledged @apifuse-allow override(s).`,
639
+ remediation: copy.remediation,
640
+ evidence: formatSourceFindings(overridden),
641
+ };
642
+ }
643
+
644
+ // 1-based line number of a character offset in `source`.
645
+ function offsetToLine(source: string, offset: number): number {
646
+ let line = 1;
647
+ for (let index = 0; index < offset && index < source.length; index += 1) {
648
+ if (source[index] === "\n") {
649
+ line += 1;
650
+ }
651
+ }
652
+ return line;
653
+ }
654
+
655
+ // ---------------------------------------------------------------------------
656
+ // SDK-native structural rules (input-passthrough, loose-schema, flat-operation)
657
+ //
658
+ // SCOPE & LIMITATION: these checks are source-grep heuristics, not a full AST
659
+ // analysis. They are deliberately tuned against the new-structure golden corpus
660
+ // (demaecan / kakaomap / triple) to catch the common non-standard SDK
661
+ // integration shapes seen in bounty submissions: inline/aliased/multi-line
662
+ // input .passthrough(), unjustified loose schemas, and factory-composed
663
+ // operations (inline, aliased, destructured, sibling-module, or unresolved
664
+ // import). They balance brackets and resolve one alias hop across the whole
665
+ // provider submission so trivial formatting/aliasing/module-split bypasses do
666
+ // not slip through.
667
+ //
668
+ // The flat-operation rule guards the "unsafe form" (an op map built by an
669
+ // OPAQUE builder whose operation set is hidden at the call site), not the mere
670
+ // presence of a function call. The stdlib enumerate-and-reshape idiom
671
+ // `Object.fromEntries(Object.entries(<source-visible obj>) ...)` is exempted:
672
+ // its op set still originates from a source-enumerable object and is only
673
+ // filtered/reshaped by pure built-ins. This is the verified golden pattern
674
+ // (triple narrows a statically-defined op object by a whitelist Set). Any other
675
+ // call — `makeOperations()`, a destructured factory, or
676
+ // `Object.fromEntries(buildEntries())` with no source-visible `Object.entries`
677
+ // — stays classified as factory composition and is blocked.
678
+ //
679
+ // They do NOT achieve AST-completeness. Known residual bypasses (schemas or
680
+ // operation maps imported from an external npm package, computed/dynamic
681
+ // property construction, or deliberate obfuscation) are out of reach for a
682
+ // text scan. submit-check is a bounty-workspace gate that runs ALONGSIDE human
683
+ // review; manual review remains the final backstop for adversarial submissions.
684
+ // Promoting these rules to a real TypeScript AST pass (ts.createSourceFile)
685
+ // is tracked as deferred follow-up work (Phase 8.7) and would require adding
686
+ // TypeScript as a provider-sdk dependency.
687
+ // ---------------------------------------------------------------------------
688
+
689
+ // Matches a `.passthrough()` call tolerant of whitespace before the parens or
690
+ // between them, so `.passthrough ()` / `.passthrough\n()` are still detected.
691
+ const PASSTHROUGH_CALL = /\.passthrough\s*\(\s*\)/;
692
+
693
+ // Strips redundant wrapping parentheses from an expression so that a value like
694
+ // `(makeOperations())` or `((x))` classifies the same as `makeOperations()`.
695
+ // Only unwraps when the leading `(` matches the trailing `)` at depth 0 (i.e.
696
+ // the whole expression is parenthesized), preserving call expressions such as
697
+ // `makeOperations()` whose first `(` is not a wrapper.
698
+ function unwrapParens(expr: string): string {
699
+ let value = expr.trim();
700
+ while (value.startsWith("(")) {
701
+ let depth = 0;
702
+ let matchIndex = -1;
703
+ for (let i = 0; i < value.length; i += 1) {
704
+ const ch = value[i];
705
+ if (ch === "(") {
706
+ depth += 1;
707
+ } else if (ch === ")") {
708
+ depth -= 1;
709
+ if (depth === 0) {
710
+ matchIndex = i;
711
+ break;
712
+ }
713
+ }
714
+ }
715
+ // Only a true wrapper spans the entire expression (closing paren is the
716
+ // last char). Otherwise the leading `(` belongs to a sub-expression.
717
+ if (matchIndex === value.length - 1) {
718
+ value = value.slice(1, -1).trim();
719
+ } else {
720
+ break;
721
+ }
722
+ }
723
+ return value;
724
+ }
725
+
726
+ // Returns the value-expression substring starting at `valueStart`, balanced
727
+ // across (){}[] and stopping at the first top-level `,`/`;` or unmatched
728
+ // closing bracket. This lets a property value be read across newlines, so a
729
+ // multi-line `input: z.object({...})\n.passthrough()` is captured whole.
730
+ function balancedValueExpression(source: string, valueStart: number): string {
731
+ let depth = 0;
732
+ let index = valueStart;
733
+ for (; index < source.length; index += 1) {
734
+ const ch = source[index];
735
+ if (ch === "(" || ch === "{" || ch === "[") {
736
+ depth += 1;
737
+ } else if (ch === ")" || ch === "}" || ch === "]") {
738
+ if (depth === 0) {
739
+ break;
740
+ }
741
+ depth -= 1;
742
+ } else if ((ch === "," || ch === ";") && depth === 0) {
743
+ break;
744
+ }
745
+ }
746
+ return source.slice(valueStart, index);
747
+ }
748
+
749
+ // True when an object-literal expression spreads a CALL expression at its top
750
+ // level, e.g. `{ ...makeOperations() }` or `{ ...a, ...build(x) }`. Spreads
751
+ // nested deeper than the outer object (inside handler bodies, nested objects,
752
+ // or arrays) are ignored, so only a factory composition of the object itself
753
+ // is detected. Input is expected to start at the outer `{`.
754
+ function hasTopLevelFactorySpread(expr: string): boolean {
755
+ const open = expr.indexOf("{");
756
+ if (open === -1) {
757
+ return false;
758
+ }
759
+ let depth = 0;
760
+ for (let i = open; i < expr.length; i += 1) {
761
+ const ch = expr[i];
762
+ if (ch === "{" || ch === "(" || ch === "[") {
763
+ depth += 1;
764
+ } else if (ch === "}" || ch === ")" || ch === "]") {
765
+ depth -= 1;
766
+ if (depth === 0) {
767
+ break;
768
+ }
769
+ } else if (ch === "." && depth === 1 && expr.startsWith("...", i)) {
770
+ // A spread at the object's own level. Check whether the spread
771
+ // argument is a call expression (factory) rather than a plain
772
+ // identifier/member spread of an already-built object.
773
+ const rest = expr.slice(i + 3);
774
+ if (/^\s*[A-Za-z_$][\w$.]*\s*\(/.test(rest)) {
775
+ return true;
776
+ }
777
+ }
778
+ }
779
+ return false;
780
+ }
781
+
782
+ // Collects the depth-1 spread IDENTIFIERS of an object-literal expression that
783
+ // are bare identifiers (not call expressions), e.g. `{ ...hidden, ...base }` ->
784
+ // ["hidden", "base"]. A `...makeOps()` call spread is already caught by
785
+ // hasTopLevelFactorySpread, so it is excluded here. These identifiers must be
786
+ // resolved to their declarations: `const hidden = makeOperations()` spread as
787
+ // `{ ...hidden }` is still a factory-composed map and must block.
788
+ function topLevelSpreadIdentifiers(expr: string): string[] {
789
+ const open = expr.indexOf("{");
790
+ if (open === -1) {
791
+ return [];
792
+ }
793
+ const names: string[] = [];
794
+ let depth = 0;
795
+ for (let i = open; i < expr.length; i += 1) {
796
+ const ch = expr[i];
797
+ if (ch === "{" || ch === "(" || ch === "[") {
798
+ depth += 1;
799
+ } else if (ch === "}" || ch === ")" || ch === "]") {
800
+ depth -= 1;
801
+ if (depth === 0) {
802
+ break;
803
+ }
804
+ } else if (ch === "." && depth === 1 && expr.startsWith("...", i)) {
805
+ const rest = expr.slice(i + 3);
806
+ // Bare identifier spread (no call parens) -> needs declaration
807
+ // resolution. `...obj.prop` member spreads are treated as already
808
+ // built and ignored (the leading identifier is captured).
809
+ const m = rest.match(/^\s*([A-Za-z_$][\w$]*)\s*(?![\w$(])/);
810
+ if (m?.[1]) {
811
+ names.push(m[1]);
812
+ }
813
+ }
814
+ }
815
+ return names;
816
+ }
817
+
818
+ // A call expression is an OPAQUE builder (block) when it invokes a
819
+ // provider-authored function whose body — and therefore the operation set — is
820
+ // not visible at the call site, e.g. `makeOperations()` or a destructured
821
+ // `const { operations } = createProviderComposition(...)`. It is NOT opaque
822
+ // when it is the stdlib `Object.fromEntries(Object.entries(<obj>) ...)`
823
+ // enumerate-and-reshape idiom: the operation set still originates from a
824
+ // source-visible object (the `Object.entries(...)` argument) and is merely
825
+ // filtered/reshaped by pure built-ins, so the registry/reviewer can still
826
+ // enumerate the op map from source. This is the verified golden pattern (a
827
+ // statically-defined op object narrowed by a whitelist Set).
828
+ //
829
+ // The exemption requires `Object.entries(` to be the ROOT of fromEntries'
830
+ // FIRST argument — not merely present somewhere inside the expression. This
831
+ // rejects opaque maps that only mention `Object.entries` deeper in a predicate,
832
+ // e.g. `Object.fromEntries(buildEntries().filter(([id]) => Object.entries(ALLOWED).some(...)))`,
833
+ // whose entries still originate from the opaque `buildEntries()` call. Any other
834
+ // expression — `Object.fromEntries(buildEntries())`, a destructured factory,
835
+ // `makeOperations()` — stays classified as factory composition.
836
+ const TRANSPARENT_RESHAPE_HEAD = /^Object\s*\.\s*fromEntries\s*\(/;
837
+ const OBJECT_ENTRIES_HEAD = /^Object\s*\.\s*entries\s*\(/;
838
+ function isTransparentObjectReshape(expr: string): boolean {
839
+ const head = TRANSPARENT_RESHAPE_HEAD.exec(expr);
840
+ if (!head) {
841
+ return false;
842
+ }
843
+ // First argument starts immediately after `fromEntries(`. The reshape is
844
+ // transparent only when that argument's root callee is `Object.entries(`
845
+ // (optionally chained: `Object.entries(obj).filter(...)`), so the source
846
+ // object is enumerable from source rather than produced by an opaque call.
847
+ const firstArg = expr.slice(head[0].length).trimStart();
848
+ return OBJECT_ENTRIES_HEAD.test(firstArg);
849
+ }
850
+
851
+ // Decide whether an `input:` property at `propIndex` is an operation's public
852
+ // input schema (the thing the rule guards) or merely a field literally named
853
+ // "input" inside a zod schema body (e.g. modelling an upstream payload that
854
+ // happens to have an `input` field: `z.object({ input: z.object(...) })`).
855
+ // We walk backwards to the directly-enclosing `{` and inspect the token that
856
+ // opened it: if that brace is the argument of a zod builder call such as
857
+ // `z.object(`, `z.strictObject(`, `z.looseObject(`, `z.record(`, or a bare
858
+ // `.object(` / `.shape(`, the `input` key is a schema field, not an operation
859
+ // input. Operation inputs live in a plain object literal (the operation
860
+ // definition), so their enclosing `{` is NOT immediately preceded by `(` of a
861
+ // schema builder.
862
+ function inputKeyIsSchemaField(source: string, propIndex: number): boolean {
863
+ let depth = 0;
864
+ let i = propIndex - 1;
865
+ for (; i >= 0; i -= 1) {
866
+ const ch = source[i];
867
+ if (ch === "}" || ch === ")" || ch === "]") {
868
+ depth += 1;
869
+ } else if (ch === "(" || ch === "[") {
870
+ if (depth === 0) {
871
+ // Reached an opening paren/bracket that directly contains the
872
+ // property — an array/call arg position, not an object literal.
873
+ return false;
874
+ }
875
+ depth -= 1;
876
+ } else if (ch === "{") {
877
+ if (depth === 0) {
878
+ break;
879
+ }
880
+ depth -= 1;
881
+ }
882
+ }
883
+ if (i < 0) {
884
+ return false;
885
+ }
886
+ // `i` indexes the directly-enclosing `{`. Look at the non-whitespace text
887
+ // immediately before it. A zod object/record builder opens with `(` then
888
+ // optionally whitespace then `{`, so the char before `{` is `(` and the
889
+ // callee just before that `(` is a zod builder identifier.
890
+ let j = i - 1;
891
+ while (j >= 0 && /\s/.test(source[j] ?? "")) {
892
+ j -= 1;
893
+ }
894
+ if (source[j] !== "(") {
895
+ return false;
896
+ }
897
+ // Capture the callee identifier chain that ends at this `(` and test its
898
+ // final member against the set of zod builders that take an object body.
899
+ const before = source.slice(Math.max(0, j - 60), j);
900
+ const calleeMatch = before.match(/([A-Za-z_$][\w$]*)\s*$/);
901
+ const callee = calleeMatch?.[1];
902
+ if (callee === undefined) {
903
+ return false;
904
+ }
905
+ const SCHEMA_BODY_BUILDERS = new Set([
906
+ "object",
907
+ "strictObject",
908
+ "looseObject",
909
+ "record",
910
+ "shape",
911
+ "extend",
912
+ "merge",
913
+ "catchall",
914
+ "partial",
915
+ "required",
916
+ "pick",
917
+ "omit",
918
+ "augment",
919
+ ]);
920
+ return SCHEMA_BODY_BUILDERS.has(callee);
921
+ }
922
+
923
+ // True when `source` imports the binding `name` from another module, i.e. a
924
+ // top-level `import { ..., name, ... } from "..."` (named or aliased) or a
925
+ // default/namespace import of `name`. Used to confirm an `input: <alias>`
926
+ // reference actually binds to an imported declaration before resolving it
927
+ // against the provider-wide passthrough map (prevents same-name collisions
928
+ // across unrelated modules from producing false positives).
929
+ function fileImportsBinding(source: string, name: string): boolean {
930
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
931
+ return new RegExp(`\\bimport\\b[^;]*\\b${escaped}\\b[^;]*\\bfrom\\b`).test(source);
932
+ }
933
+
934
+ // Resolves the ORIGINAL exported name for a local binding `localName`. When the
935
+ // file imports it under an alias — `import { requestSchema as inputSchema }` —
936
+ // the provider-wide passthrough map is keyed by the exported declaration name
937
+ // (`requestSchema`), not the local alias (`inputSchema`), so the alias must be
938
+ // mapped back before lookup. Returns `localName` unchanged when there is no
939
+ // aliased import (plain `import { requestSchema }` or a local declaration).
940
+ function importedOriginalName(source: string, localName: string): string {
941
+ const escaped = localName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
942
+ // Match `<original> as <localName>` inside any import specifier list.
943
+ const aliasMatch = new RegExp(
944
+ `\\bimport\\b[^;]*\\{[^}]*\\b([A-Za-z_$][\\w$]*)\\s+as\\s+${escaped}\\b[^}]*\\}[^;]*\\bfrom\\b`,
945
+ ).exec(source);
946
+ return aliasMatch?.[1] ?? localName;
947
+ }
948
+
949
+ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
950
+ const findings: SourceFinding[] = [];
951
+ const files = listNonTestTypeScriptFiles(providerRoot);
952
+
953
+ // Pass 1: collect every passthrough schema const across the WHOLE provider
954
+ // submission (not per-file), keyed by name -> declaration site. This lets an
955
+ // `input:` in index.ts resolve a non-`input`-named passthrough schema that
956
+ // was declared in another module (e.g. schemas.ts) and imported.
957
+ type ConstSite = { file: string; line: number };
958
+ const passthroughConsts = new Map<string, ConstSite>();
959
+ // Per-file map of passthrough const declarations, so an `input: <alias>` can
960
+ // resolve its ACTUAL binding (a same-file local declaration) before falling
961
+ // back to an imported cross-module schema. This prevents a generic name like
962
+ // `requestSchema` declared in one module from being matched against an
963
+ // unrelated `input: requestSchema` in another module (a false positive on a
964
+ // strict schema that merely shares the identifier).
965
+ const passthroughByFile = new Map<string, Map<string, ConstSite>>();
966
+ const fileSources = new Map<string, string>();
967
+ for (const filePath of files) {
968
+ const source = readFileSync(filePath, "utf8");
969
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
970
+ fileSources.set(filePath, source);
971
+ const localMap = new Map<string, ConstSite>();
972
+ passthroughByFile.set(filePath, localMap);
973
+ const constDecl =
974
+ /(?:^|\n)[ \t]*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?\s*=/g;
975
+ for (let match = constDecl.exec(source); match !== null; match = constDecl.exec(source)) {
976
+ const name = match[1];
977
+ if (name === undefined) {
978
+ continue;
979
+ }
980
+ const valueStart = match.index + match[0].length;
981
+ const value = balancedValueExpression(source, valueStart);
982
+ if (PASSTHROUGH_CALL.test(value)) {
983
+ const site: ConstSite = {
984
+ file: relPath,
985
+ line: offsetToLine(source, valueStart),
986
+ };
987
+ localMap.set(name, site);
988
+ // First declaration wins for line attribution; duplicate names
989
+ // across modules are rare and either site is a valid pointer.
990
+ if (!passthroughConsts.has(name)) {
991
+ passthroughConsts.set(name, site);
992
+ }
993
+ }
994
+ }
995
+ }
996
+
997
+ const seen = new Set<string>();
998
+ const push = (site: ConstSite) => {
999
+ const key = `${site.file}:${site.line}`;
1000
+ if (!seen.has(key)) {
1001
+ seen.add(key);
1002
+ findings.push({ file: site.file, line: site.line });
1003
+ }
1004
+ };
1005
+
1006
+ // Pass 2: inspect every `input:` property value across all files. A value
1007
+ // that is itself a passthrough expression, or that references a passthrough
1008
+ // const by name (resolved against the provider-wide map), is a violation.
1009
+ for (const filePath of files) {
1010
+ const source = fileSources.get(filePath) ?? readFileSync(filePath, "utf8");
1011
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
1012
+
1013
+ const inputProp = /\binput\s*:\s*/g;
1014
+ for (let match = inputProp.exec(source); match !== null; match = inputProp.exec(source)) {
1015
+ // Skip `input` keys that are fields inside a zod schema body (e.g. an
1016
+ // upstream payload modelled as `z.object({ input: ... })`). Only an
1017
+ // operation's public `input:` property is in scope for this rule.
1018
+ if (inputKeyIsSchemaField(source, match.index)) {
1019
+ continue;
1020
+ }
1021
+ const valueStart = match.index + match[0].length;
1022
+ const value = balancedValueExpression(source, valueStart);
1023
+ if (PASSTHROUGH_CALL.test(value)) {
1024
+ push({ file: relPath, line: offsetToLine(source, valueStart) });
1025
+ continue;
1026
+ }
1027
+ const ref = value.trim().match(/^([A-Za-z_$][\w$]*)/);
1028
+ const refName = ref?.[1];
1029
+ if (refName) {
1030
+ // Resolve the alias by BINDING, not by global name. Prefer a
1031
+ // passthrough const declared in THIS file; otherwise only fall
1032
+ // back to the provider-wide map when this file actually imports
1033
+ // `refName` (so a generic name shared across modules cannot link
1034
+ // an unrelated strict input to a foreign passthrough schema).
1035
+ const localSite = passthroughByFile.get(filePath)?.get(refName);
1036
+ if (localSite) {
1037
+ push(localSite);
1038
+ } else if (fileImportsBinding(source, refName)) {
1039
+ // Imported binding: map a possible `orig as refName` alias
1040
+ // back to the exported name the provider-wide map is keyed by.
1041
+ const originalName = importedOriginalName(source, refName);
1042
+ const site = passthroughConsts.get(refName) ?? passthroughConsts.get(originalName);
1043
+ if (site) {
1044
+ push(site);
1045
+ }
1046
+ }
1047
+ }
1048
+ }
1049
+
1050
+ // `input,` shorthand binds a local `input` const; flag it if that const
1051
+ // is a passthrough schema declared in THIS file (the binding the
1052
+ // shorthand actually closes over).
1053
+ if (/(?:^|\n)[ \t]*input\s*,/.test(source)) {
1054
+ const localInput = passthroughByFile.get(filePath)?.get("input");
1055
+ if (localInput) {
1056
+ push(localInput);
1057
+ } else if (fileImportsBinding(source, "input")) {
1058
+ const site = passthroughConsts.get("input");
1059
+ if (site) {
1060
+ push(site);
1061
+ }
1062
+ }
1063
+ }
1064
+ }
1065
+
1066
+ return escapeHatchResult(providerRoot, "unsafe-input-passthrough", findings, {
1067
+ blockerMessage:
1068
+ "Public input schema uses .passthrough(); unknown caller fields are silently accepted or dropped.",
1069
+ remediation:
1070
+ "Use strict input schemas (z.object({...}) without .passthrough()). If upstream form replay genuinely needs it, allowlist the forwarded fields and add `// @apifuse-allow unsafe-input-passthrough: <reason>`.",
1071
+ passMessage: "Input schemas do not use unscoped .passthrough().",
1072
+ });
1073
+ }
1074
+
1075
+ function scoreUnjustifiedLooseSchema(providerRoot: string): SubmitCheck {
1076
+ const findings: SourceFinding[] = [];
1077
+
1078
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
1079
+ const lines = readFileSync(filePath, "utf8").split(/\r?\n/);
1080
+ for (let index = 0; index < lines.length; index += 1) {
1081
+ const line = lines[index];
1082
+ if (line === undefined || !/\bz\.(record|unknown|any)\s*\(/.test(line)) {
1083
+ continue;
1084
+ }
1085
+ // A `//` justification comment on the same line or the line above
1086
+ // (including the `@apifuse-allow loose-schema:` form) acknowledges it.
1087
+ const previous = lines[index - 1];
1088
+ const justified = line.includes("//") || previous?.trim().startsWith("//") === true;
1089
+ if (!justified) {
1090
+ findings.push({
1091
+ file: toRelativeProviderPath(providerRoot, filePath),
1092
+ line: index + 1,
1093
+ });
1094
+ }
1095
+ }
1096
+ }
1097
+
1098
+ return escapeHatchResult(providerRoot, "unjustified-loose-schema", findings, {
1099
+ blockerMessage: "Loose schema (z.record/z.unknown/z.any) used without justification.",
1100
+ remediation:
1101
+ "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.",
1102
+ passMessage: "Loose schemas are justified or absent.",
1103
+ });
1104
+ }
1105
+
1106
+ // True when `name` resolves, anywhere in the provider submission, to a
1107
+ // declaration whose initializer is an OPAQUE factory — a call expression
1108
+ // (`const hidden = makeOperations()`) or itself a factory spread — or to an
1109
+ // imported binding with no local declaration (out-of-view construction). Used
1110
+ // to classify a top-level spread identifier (`{ ...hidden }`) so an opaque
1111
+ // factory map cannot be laundered through a variable before being spread. The
1112
+ // stdlib transparent reshape is NOT treated as a factory (parity with the
1113
+ // direct-alias classification).
1114
+ function spreadIdentifierResolvesToFactory(
1115
+ providerRoot: string,
1116
+ indexPath: string,
1117
+ indexSource: string,
1118
+ name: string,
1119
+ ): boolean {
1120
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1121
+ const declRe = new RegExp(
1122
+ `(?:^|\\n)[ \t]*(?:export\\s+)?(?:const|let|var)\\s+${escaped}\\s*(?::[^=\\n]+)?\\s*=`,
1123
+ "g",
1124
+ );
1125
+ let sawDeclaration = false;
1126
+ for (const filePath of [
1127
+ indexPath,
1128
+ ...listNonTestTypeScriptFiles(providerRoot).filter((p) => resolve(p) !== resolve(indexPath)),
1129
+ ]) {
1130
+ if (!existsSync(filePath)) {
1131
+ continue;
1132
+ }
1133
+ const fileSource = filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
1134
+ const re = new RegExp(declRe.source, "g");
1135
+ for (let m = re.exec(fileSource); m !== null; m = re.exec(fileSource)) {
1136
+ sawDeclaration = true;
1137
+ const expr = unwrapParens(balancedValueExpression(fileSource, m.index + m[0].length).trim());
1138
+ const isFactory =
1139
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
1140
+ !isTransparentObjectReshape(expr);
1141
+ if (isFactory) {
1142
+ return true;
1143
+ }
1144
+ }
1145
+ }
1146
+ // No local declaration anywhere but imported into index.ts => constructed
1147
+ // out of view; treat as factory (conservative, false-negative-safe).
1148
+ if (!sawDeclaration && fileImportsBinding(indexSource, name)) {
1149
+ return true;
1150
+ }
1151
+ return false;
1152
+ }
1153
+
1154
+ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1155
+ const indexPath = resolve(providerRoot, "index.ts");
1156
+ const ruleId = "flat-operation-composition";
1157
+ if (!existsSync(indexPath)) {
1158
+ return pass(
1159
+ ruleId,
1160
+ SDK_NATIVE_CATEGORY,
1161
+ "Provider index.ts not found; flat-operation check skipped.",
1162
+ 0,
1163
+ );
1164
+ }
1165
+
1166
+ const source = readFileSync(indexPath, "utf8");
1167
+ // Use the same whitespace-tolerant detection as the resolver below, so a
1168
+ // `defineProvider (` / `defineProvider\n(` formatting cannot pass the early
1169
+ // exit before the real classification runs.
1170
+ if (!/\bdefineProvider\s*\(/.test(source)) {
1171
+ return pass(
1172
+ ruleId,
1173
+ SDK_NATIVE_CATEGORY,
1174
+ "No defineProvider call to evaluate for operation composition.",
1175
+ 0,
1176
+ );
1177
+ }
1178
+
1179
+ // Scope the scan to the argument of the EXPORTED `defineProvider(...)` call.
1180
+ // A provider can contain helper/non-exported defineProvider calls before the
1181
+ // real default export (e.g. test scaffolds), so resolve the default export
1182
+ // rather than blindly taking the first regex match. Resolution order:
1183
+ // 1. `export default defineProvider(` — inline default export
1184
+ // 2. `export default <ident>` then `const <ident> = defineProvider(`
1185
+ // 3. fallback: first `defineProvider(` in the file
1186
+ let defineParenIndex = -1;
1187
+ const inlineDefault = /\bexport\s+default\s+defineProvider\s*\(/.exec(source);
1188
+ if (inlineDefault) {
1189
+ defineParenIndex = inlineDefault.index + inlineDefault[0].length - 1; // points at `(`
1190
+ } else {
1191
+ const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(source);
1192
+ const exportedName = namedDefault?.[1];
1193
+ if (exportedName !== undefined) {
1194
+ const namedDecl = new RegExp(
1195
+ `(?:^|\\n)[ \t]*(?:export\\s+)?(?:const|let|var)\\s+${exportedName}\\s*(?::[^=\\n]+)?\\s*=\\s*defineProvider\\s*\\(`,
1196
+ ).exec(source);
1197
+ if (namedDecl) {
1198
+ defineParenIndex = namedDecl.index + namedDecl[0].length - 1;
1199
+ }
1200
+ }
1201
+ if (defineParenIndex === -1) {
1202
+ const firstCall = /\bdefineProvider\s*\(/.exec(source);
1203
+ if (firstCall) {
1204
+ defineParenIndex = firstCall.index + firstCall[0].length - 1;
1205
+ }
1206
+ }
1207
+ }
1208
+ if (defineParenIndex === -1) {
1209
+ return pass(
1210
+ ruleId,
1211
+ SDK_NATIVE_CATEGORY,
1212
+ "No defineProvider call to evaluate for operation composition.",
1213
+ 0,
1214
+ );
1215
+ }
1216
+ const argStart = defineParenIndex + 1;
1217
+ const argText = balancedValueExpression(source, argStart);
1218
+
1219
+ // Resolve the value passed as `operations:` inside the defineProvider call,
1220
+ // following one alias hop. The value is classified as a static object
1221
+ // literal (pass) or a factory/call expression (block). The regex index is
1222
+ // offset back into the full source so line numbers stay accurate.
1223
+ const opsProp = /\boperations\s*:\s*/.exec(argText);
1224
+ let opsValue: string | undefined;
1225
+ let opsLine = 1;
1226
+ if (opsProp) {
1227
+ const valueStart = argStart + opsProp.index + opsProp[0].length;
1228
+ opsValue = unwrapParens(balancedValueExpression(source, valueStart).trim());
1229
+ opsLine = offsetToLine(source, valueStart);
1230
+ }
1231
+
1232
+ // Property shorthand: `defineProvider({ ..., operations })` — resolve the
1233
+ // local `operations` const initializer.
1234
+ let aliasName: string | undefined;
1235
+ if (opsValue === undefined) {
1236
+ if (/\boperations\s*[,}]/.test(argText)) {
1237
+ aliasName = "operations";
1238
+ }
1239
+ } else if (/^[A-Za-z_$][\w$]*$/.test(opsValue)) {
1240
+ // `operations: ops` — a bare identifier alias to resolve.
1241
+ aliasName = opsValue;
1242
+ }
1243
+
1244
+ // Determine the effective initializer expression to classify. The alias may
1245
+ // be declared in index.ts OR re-exported from a sibling module (the common
1246
+ // generated scaffold: `import { operations } from "./operations"` where
1247
+ // ./operations.ts builds the map with makeOperations()/Object.fromEntries).
1248
+ // Resolve across every provider source file so cross-module factory
1249
+ // composition cannot evade the blocker.
1250
+ let effective = opsValue;
1251
+ let effectiveLine = opsLine;
1252
+ let effectiveFile = "index.ts";
1253
+ if (aliasName !== undefined) {
1254
+ const aliasDecl = new RegExp(
1255
+ `(?:^|\\n)[ \t]*(?:export\\s+)?(?:const|let|var)\\s+${aliasName}\\s*(?::[^=\\n]+)?\\s*=`,
1256
+ );
1257
+ // Destructured factory form: `const { operations } = makeOps()`.
1258
+ const destructured = new RegExp(
1259
+ `(?:^|\\n)[ \t]*(?:export\\s+)?(?:const|let|var)\\s*\\{[^}]*\\b${aliasName}\\b[^}]*\\}\\s*=\\s*([A-Za-z_$][\\w$.]*)\\s*\\(`,
1260
+ );
1261
+
1262
+ // Search index.ts first (its line attribution wins), then siblings.
1263
+ const searchOrder = [
1264
+ indexPath,
1265
+ ...listNonTestTypeScriptFiles(providerRoot).filter((p) => resolve(p) !== resolve(indexPath)),
1266
+ ];
1267
+
1268
+ // Collect EVERY same-named declaration across the submission and classify
1269
+ // each as factory vs static. A factory declaration anywhere wins, so a
1270
+ // decoy static `const operations = {}` in an earlier-scanned file cannot
1271
+ // mask a factory-composed declaration in another module. (We deliberately
1272
+ // do not resolve the exact import target path; "any same-named factory
1273
+ // blocks" is the conservative, false-negative-avoiding choice for a gate.)
1274
+ type Candidate = {
1275
+ expr: string;
1276
+ line: number;
1277
+ file: string;
1278
+ isFactory: boolean;
1279
+ };
1280
+ const candidates: Candidate[] = [];
1281
+ for (const filePath of searchOrder) {
1282
+ if (!existsSync(filePath)) {
1283
+ continue;
1284
+ }
1285
+ const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
1286
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
1287
+
1288
+ const declRe = new RegExp(aliasDecl.source, "g");
1289
+ for (let m = declRe.exec(fileSource); m !== null; m = declRe.exec(fileSource)) {
1290
+ const valueStart = m.index + m[0].length;
1291
+ const expr = unwrapParens(balancedValueExpression(fileSource, valueStart).trim());
1292
+ const isFactory =
1293
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
1294
+ !isTransparentObjectReshape(expr);
1295
+ candidates.push({
1296
+ expr,
1297
+ line: offsetToLine(fileSource, valueStart),
1298
+ file: relPath,
1299
+ isFactory,
1300
+ });
1301
+ }
1302
+ const destructRe = new RegExp(destructured.source, "g");
1303
+ for (let m = destructRe.exec(fileSource); m !== null; m = destructRe.exec(fileSource)) {
1304
+ candidates.push({
1305
+ expr: `${m[1]}(`,
1306
+ line: offsetToLine(fileSource, m.index),
1307
+ file: relPath,
1308
+ isFactory: true,
1309
+ });
1310
+ }
1311
+ }
1312
+
1313
+ const resolved = candidates.length > 0;
1314
+ if (resolved) {
1315
+ // Prefer a factory declaration (it blocks); otherwise keep the first
1316
+ // static declaration for line attribution.
1317
+ const factory = candidates.find((c) => c.isFactory);
1318
+ const chosen = factory ?? candidates[0];
1319
+ if (chosen !== undefined) {
1320
+ effective = chosen.expr;
1321
+ effectiveLine = chosen.line;
1322
+ effectiveFile = chosen.file;
1323
+ }
1324
+ }
1325
+
1326
+ // An imported alias that resolves to no local declaration anywhere in the
1327
+ // submission means the operations map is constructed out of view. Treat
1328
+ // the unresolved import as a factory-composed (non-static) shape rather
1329
+ // than silently passing.
1330
+ if (!resolved) {
1331
+ const importMatch = new RegExp(`\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`).exec(
1332
+ source,
1333
+ );
1334
+ if (importMatch) {
1335
+ effective = `${aliasName}(`;
1336
+ effectiveLine = offsetToLine(source, importMatch.index);
1337
+ effectiveFile = "index.ts";
1338
+ }
1339
+ }
1340
+ }
1341
+
1342
+ // A value starting with `{` is an object literal, but it is only STATIC if
1343
+ // its TOP-LEVEL entries are all explicit properties. A factory spread such
1344
+ // as `{ ...makeOperations() }` still composes the map dynamically. We only
1345
+ // inspect depth-1 entries so that ordinary spreads deep inside operation
1346
+ // handler bodies (e.g. `{ ...headers }`, `...arr.map(...)`) are NOT mistaken
1347
+ // for a top-level factory composition of the operations map itself.
1348
+ const hasFactorySpread = effective !== undefined && hasTopLevelFactorySpread(effective);
1349
+ // A spread of a bare identifier (`{ ...hidden }`) is static ONLY when that
1350
+ // identifier resolves to a non-factory declaration. Resolve each top-level
1351
+ // spread identifier so an opaque factory map laundered through a variable
1352
+ // (`const hidden = makeOperations(); operations: { ...hidden }`) still blocks.
1353
+ const hasFactorySpreadIdentifier =
1354
+ effective !== undefined &&
1355
+ topLevelSpreadIdentifiers(effective).some((name) =>
1356
+ spreadIdentifierResolvesToFactory(providerRoot, indexPath, source, name),
1357
+ );
1358
+ const isStaticLiteral =
1359
+ effective?.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
1360
+ // A call expression `ident(...)` (factory) or a factory-spread literal is
1361
+ // the rejected, non-static shape — UNLESS it is the stdlib
1362
+ // `Object.fromEntries(Object.entries(<source-visible obj>)...)` reshape,
1363
+ // whose op set is still enumerable from source (verified golden pattern).
1364
+ const isFactoryCall =
1365
+ effective !== undefined &&
1366
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) || hasFactorySpread || hasFactorySpreadIdentifier) &&
1367
+ !isTransparentObjectReshape(effective);
1368
+
1369
+ if (isFactoryCall && !isStaticLiteral) {
1370
+ // Route through the shared escape-hatch partitioner so an
1371
+ // `// @apifuse-allow flat-operation-composition: <reason>` comment on
1372
+ // the reported line (or the line above) downgrades this blocker to a
1373
+ // counted warning, consistent with the other structural rules.
1374
+ return escapeHatchResult(providerRoot, ruleId, [{ file: effectiveFile, line: effectiveLine }], {
1375
+ blockerMessage:
1376
+ "defineProvider operations are composed by a factory call instead of a static object literal.",
1377
+ remediation:
1378
+ "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>`.",
1379
+ passMessage: "defineProvider declares operations as a static object literal.",
1380
+ });
1381
+ }
1382
+
1383
+ return pass(
1384
+ ruleId,
1385
+ SDK_NATIVE_CATEGORY,
1386
+ "defineProvider declares operations as a static object literal.",
1387
+ 0,
1388
+ );
1389
+ }
1390
+
1391
+ function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
1392
+ const credentialReferences = findSourceLineMatches(providerRoot, /ctx\.credential/);
1393
+ const authMode = provider.auth?.mode ?? "none";
1394
+ const credentialKeys = provider.credential?.keys ?? [];
1395
+ const storesProviderCredential = authMode !== "none" || credentialKeys.length > 0;
1396
+
1397
+ if (storesProviderCredential && credentialReferences.length === 0) {
1398
+ return {
1399
+ id: "credential-usage",
1400
+ category: SDK_NATIVE_CATEGORY,
1401
+ level: "warn",
1402
+ status: "warn",
1403
+ points: 0,
1404
+ maxPoints: 0,
1405
+ message: "Credential-backed provider does not reference credential persistence in source.",
1406
+ remediation:
1407
+ "Persist provider session state through the SDK credential context instead of process-local state. See providers/catchtable for the reference pattern.",
1408
+ };
1409
+ }
1410
+
1411
+ return pass(
1412
+ "credential-usage",
1413
+ SDK_NATIVE_CATEGORY,
1414
+ authMode === "none" && credentialKeys.length === 0
1415
+ ? "Provider does not declare reusable credentials."
1416
+ : "Credential-backed provider references ctx.credential.",
1417
+ 0,
1418
+ credentialReferences.length > 0 ? formatSourceFindings(credentialReferences) : undefined,
1419
+ );
1420
+ }
1421
+
1422
+ function findSourceLineMatches(
1423
+ providerRoot: string,
1424
+ pattern: RegExp | ((line: string) => boolean),
1425
+ ): SourceFinding[] {
1426
+ return findSourceFindings(providerRoot, (line) => matchesLinePattern(line, pattern));
1427
+ }
1428
+
1429
+ function findSourceFindings(
1430
+ providerRoot: string,
1431
+ matchesLine: (line: string, remainingLines: readonly string[]) => boolean,
1432
+ ): SourceFinding[] {
1433
+ const findings: SourceFinding[] = [];
1434
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
1435
+ const content = readFileSync(filePath, "utf8");
1436
+ const lines = content.split(/\r?\n/);
1437
+ for (let index = 0; index < lines.length; index += 1) {
1438
+ const line = lines[index];
1439
+ if (line !== undefined && matchesLine(line, lines.slice(index + 1))) {
1440
+ findings.push({
1441
+ file: toRelativeProviderPath(providerRoot, filePath),
1442
+ line: index + 1,
1443
+ });
1444
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
1445
+ return findings;
1446
+ }
1447
+ }
1448
+ }
1449
+ }
1450
+ return findings;
1451
+ }
1452
+
1453
+ function matchesLinePattern(line: string, pattern: RegExp | ((line: string) => boolean)): boolean {
1454
+ return typeof pattern === "function" ? pattern(line) : pattern.test(line);
1455
+ }
1456
+
1457
+ function listNonTestTypeScriptFiles(providerRoot: string): string[] {
1458
+ const files: string[] = [];
1459
+ collectNonTestTypeScriptFiles(providerRoot, providerRoot, files);
1460
+ return files;
1461
+ }
1462
+
1463
+ function listNonTestProviderSourceFiles(providerRoot: string): string[] {
1464
+ const files: string[] = [];
1465
+ collectNonTestProviderSourceFiles(providerRoot, providerRoot, files);
1466
+ return files;
1467
+ }
1468
+
1469
+ function collectNonTestProviderSourceFiles(
1470
+ providerRoot: string,
1471
+ currentPath: string,
1472
+ files: string[],
1473
+ ): void {
1474
+ for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
1475
+ const entryPath = join(currentPath, entry.name);
1476
+ const relativePath = toRelativeProviderPath(providerRoot, entryPath);
1477
+ if (entry.isDirectory()) {
1478
+ if (shouldScanSourceDirectory(relativePath)) {
1479
+ collectNonTestProviderSourceFiles(providerRoot, entryPath, files);
1480
+ }
1481
+ continue;
1482
+ }
1483
+ if (
1484
+ entry.isFile() &&
1485
+ isScannableProviderSourceFile(relativePath) &&
1486
+ !isExcludedTestSource(relativePath)
1487
+ ) {
1488
+ files.push(entryPath);
1489
+ }
1490
+ }
1491
+ }
1492
+
1493
+ function collectNonTestTypeScriptFiles(
1494
+ providerRoot: string,
1495
+ currentPath: string,
1496
+ files: string[],
1497
+ ): void {
1498
+ for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
1499
+ const entryPath = join(currentPath, entry.name);
1500
+ const relativePath = toRelativeProviderPath(providerRoot, entryPath);
1501
+ if (entry.isDirectory()) {
1502
+ if (shouldScanSourceDirectory(relativePath)) {
1503
+ collectNonTestTypeScriptFiles(providerRoot, entryPath, files);
1504
+ }
1505
+ continue;
1506
+ }
1507
+ if (entry.isFile() && relativePath.endsWith(".ts") && !isExcludedTestSource(relativePath)) {
1508
+ files.push(entryPath);
1509
+ }
1510
+ }
1511
+ }
1512
+
1513
+ function isScannableProviderSourceFile(relativePath: string): boolean {
1514
+ return (
1515
+ /\.(?:ts|tsx|js|jsx|mjs|cjs|sh|bash)$/.test(relativePath) ||
1516
+ /(?:^|\/)Dockerfile(?:\.|$)/.test(relativePath) ||
1517
+ /(?:^|\/)entrypoint(?:\.|$)/.test(relativePath)
1518
+ );
1519
+ }
1520
+
1521
+ function shouldScanSourceDirectory(relativePath: string): boolean {
1522
+ return ![".git", "node_modules", "dist", "build", "coverage"].includes(relativePath);
1523
+ }
1524
+
1525
+ function isExcludedTestSource(relativePath: string): boolean {
1526
+ return (
1527
+ relativePath.endsWith(".test.ts") ||
1528
+ relativePath.startsWith("__tests__/") ||
1529
+ relativePath.includes("/__tests__/") ||
1530
+ relativePath.startsWith("tests/") ||
1531
+ relativePath.includes("/tests/")
1532
+ );
1533
+ }
1534
+
1535
+ function toRelativeProviderPath(providerRoot: string, filePath: string): string {
1536
+ return relative(providerRoot, filePath).replaceAll("\\", "/");
1537
+ }
1538
+
1539
+ function formatSourceFindings(findings: readonly SourceFinding[]): string[] {
1540
+ return findings.map((finding) => `${finding.file}:${finding.line}`);
1541
+ }
1542
+
1543
+ function scoreRepositoryDx(providerRoot: string): SubmitCheck {
1544
+ const missing: string[] = [];
1545
+ if (!existsSync(resolve(providerRoot, ".gitignore"))) {
1546
+ missing.push(".gitignore");
1547
+ }
1548
+ if (!existsSync(resolve(providerRoot, "AGENTS.md"))) {
1549
+ missing.push("AGENTS.md");
1550
+ }
1551
+
1552
+ const packageJsonPath = resolve(providerRoot, "package.json");
1553
+ const packageScripts = readPackageScripts(packageJsonPath);
1554
+ if (typeof packageScripts?.["type-check"] !== "string") {
1555
+ missing.push("package.json scripts.type-check");
1556
+ }
1557
+ if (!checkScriptRunsTypeCheck(packageScripts?.check)) {
1558
+ missing.push("package.json scripts.check includes type-check");
1559
+ }
1560
+
1561
+ if (missing.length === 0) {
1562
+ return pass(
1563
+ "repository-dx",
1564
+ "docs",
1565
+ "Repository includes generated-provider DX guardrails.",
1566
+ 0,
1567
+ );
1568
+ }
1569
+
1570
+ return {
1571
+ id: "repository-dx",
1572
+ category: "docs",
1573
+ level: "warn",
1574
+ status: "warn",
1575
+ points: 0,
1576
+ maxPoints: 0,
1577
+ message: `Generated repository DX guardrails are missing: ${missing.join(", ")}.`,
1578
+ remediation:
1579
+ "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`.",
1580
+ evidence: missing,
1581
+ };
1582
+ }
1583
+
1584
+ function readPackageScripts(packageJsonPath: string): Record<string, unknown> | undefined {
1585
+ if (!existsSync(packageJsonPath)) {
1586
+ return undefined;
1587
+ }
1588
+
1589
+ try {
1590
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
1591
+ if (!isRecord(packageJson) || !isRecord(packageJson.scripts)) {
1592
+ return undefined;
1593
+ }
1594
+ return packageJson.scripts;
1595
+ } catch {
1596
+ return undefined;
1597
+ }
1598
+ }
1599
+
1600
+ function checkScriptRunsTypeCheck(checkScript: unknown): boolean {
1601
+ return (
1602
+ typeof checkScript === "string" &&
1603
+ /(?:^|&&|;)\s*bun\s+run\s+type-check(?:\s|$)/.test(checkScript)
1604
+ );
1605
+ }
1606
+
1607
+ async function safeRunChecks(providerRoot: string): Promise<CheckResult[]> {
1608
+ try {
1609
+ return await runChecks(providerRoot, { lintMode: "standalone" });
1610
+ } catch (error) {
1611
+ return [
1612
+ {
1613
+ message: "Base provider checks can run",
1614
+ passed: false,
1615
+ details: [error instanceof Error ? error.message : String(error)],
1616
+ },
1617
+ ];
1618
+ }
1619
+ }
1620
+
1621
+ const SUBMIT_CHECK_BROWSER_PATTERNS: ReadonlyArray<{
1622
+ rule: string;
1623
+ pattern: RegExp;
1624
+ }> = [
1625
+ {
1626
+ rule: "browser-self-hosted-launch",
1627
+ pattern: /\b(?:playwright|chromium|firefox|webkit|puppeteer)\.launch\s*\(/,
1628
+ },
1629
+ {
1630
+ rule: "browser-self-hosted-child-process",
1631
+ pattern:
1632
+ /\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync|Bun\.spawn|Bun\.spawnSync)\s*\([^;]*\b(?:google-chrome|chrome|chromium|chromium-browser)\b|\$`[^`]*\b(?:google-chrome|chrome|chromium|chromium-browser)\b/,
1633
+ },
1634
+ {
1635
+ rule: "browser-self-hosted-remote-debugging-port",
1636
+ pattern:
1637
+ /(?:\b(?:google-chrome|chrome|chromium|chromium-browser)\b[\s\S]{0,240}--remote-debugging-port\b|--remote-debugging-port(?:=|\s+))/,
1638
+ },
1639
+ {
1640
+ rule: "browser-direct-cdp-version-poll",
1641
+ pattern: /\/json\/version\b/,
1642
+ },
1643
+ {
1644
+ rule: "browser-provider-local-cdp-env",
1645
+ pattern:
1646
+ /\b(?!APIFUSE__CDP_POOL__URL\b)[A-Z][A-Z0-9_]*_CDP_URL\b|process\.env(?:\.(?!APIFUSE__CDP_POOL__URL\b)[A-Z0-9_]*_CDP_URL\b|\[\s*["'`](?!APIFUSE__CDP_POOL__URL\b)[A-Z0-9_]*_CDP_URL["'`]\s*\])/,
1647
+ },
1648
+ ];
1649
+
1650
+ function scoreManagedBrowserRuntime(providerRoot: string): SubmitCheck {
1651
+ const maxManagedBrowserEvidence = MAX_SOURCE_FINDING_EVIDENCE * 2;
1652
+ const browserFindings: string[] = [];
1653
+ for (const filePath of listNonTestProviderSourceFiles(providerRoot)) {
1654
+ const content = readFileSync(filePath, "utf8");
1655
+ const lines = content.split(/\r?\n/);
1656
+ for (let index = 0; index < lines.length; index += 1) {
1657
+ const line = lines[index];
1658
+ if (line === undefined) continue;
1659
+ for (const { rule, pattern } of SUBMIT_CHECK_BROWSER_PATTERNS) {
1660
+ pattern.lastIndex = 0;
1661
+ if (!pattern.test(line)) continue;
1662
+ browserFindings.push(
1663
+ `${rule} ${toRelativeProviderPath(providerRoot, filePath)}:${index + 1}`,
1664
+ );
1665
+ if (browserFindings.length >= maxManagedBrowserEvidence) break;
1666
+ }
1667
+ if (browserFindings.length >= maxManagedBrowserEvidence) break;
1668
+ }
1669
+ if (browserFindings.length >= maxManagedBrowserEvidence) break;
1670
+ }
1671
+
1672
+ if (browserFindings.length > 0) {
1673
+ return {
1674
+ id: "managed-browser-runtime",
1675
+ category: SDK_NATIVE_CATEGORY,
1676
+ level: "warn",
1677
+ status: "warn",
1678
+ points: 0,
1679
+ maxPoints: 0,
1680
+ message:
1681
+ "Provider source contains self-hosted browser/CDP patterns that APIFuse maintainers must review before promotion.",
1682
+ remediation:
1683
+ "Use ctx.browser backed by the managed CDP Pool. Do not launch Playwright/Puppeteer/Chrome, poll /json/version, or read provider-local *_CDP_URL env vars in provider runtime code.",
1684
+ evidence: browserFindings.map(redact),
1685
+ };
1686
+ }
1687
+
1688
+ return pass(
1689
+ "managed-browser-runtime",
1690
+ SDK_NATIVE_CATEGORY,
1691
+ "Provider source avoids self-hosted browser/CDP runtime patterns.",
1692
+ 0,
1693
+ );
1694
+ }
1695
+
1696
+ function scoreBaseChecks(results: CheckResult[]): SubmitCheck[] {
1697
+ const failed = results.filter((result) => !result.passed);
1698
+ if (failed.length > 0) {
1699
+ const remediation = [
1700
+ "Run `bunx apifuse check .` from the provider root.",
1701
+ ...Array.from(new Set(failed.map(baseCheckRemediation))),
1702
+ ].join(" ");
1703
+ return [
1704
+ {
1705
+ id: "base-checks",
1706
+ category: "definition",
1707
+ level: "blocker",
1708
+ status: "fail",
1709
+ points: 0,
1710
+ maxPoints: CATEGORY_MAX_POINTS.definition,
1711
+ message: "Base provider checks failed.",
1712
+ remediation,
1713
+ evidence: failed.map((result) =>
1714
+ redact(`${result.message}: ${(result.details ?? []).join("; ")}`),
1715
+ ),
1716
+ },
1717
+ ];
1718
+ }
1719
+
1720
+ return [
1721
+ {
1722
+ id: "base-checks",
1723
+ category: "definition",
1724
+ level: "info",
1725
+ status: "pass",
1726
+ points: CATEGORY_MAX_POINTS.definition,
1727
+ maxPoints: CATEGORY_MAX_POINTS.definition,
1728
+ message: "Base provider checks passed.",
1729
+ evidence: results.map((result) => result.message),
1730
+ },
1731
+ ];
1732
+ }
1733
+
1734
+ function baseCheckRemediation(result: CheckResult): string {
1735
+ switch (result.message) {
1736
+ case "index.ts exists and exports default defineProvider":
1737
+ return "Fix `index.ts` so it default-exports `defineProvider({...})`.";
1738
+ case "All operations have handler, input, output":
1739
+ return "For each operation named in evidence, add `handler`, `input`, and `output` fields to `defineProvider({ operations })`.";
1740
+ case "All operations have fixtures":
1741
+ return "For each operation named in evidence, add `fixtures.request` and `fixtures.response` values that exercise the operation schemas.";
1742
+ case "Zod schemas parse fixtures without error":
1743
+ return "Update the failing fixture values or their zod schemas until `fixtures.request` and `fixtures.response` parse cleanly.";
1744
+ case "Provider authoring lint has no error-level diagnostics":
1745
+ return "Fix each lint diagnostic shown in evidence, then rerun `bunx apifuse check .`.";
1746
+ case "Provider metadata is declared in defineProvider":
1747
+ return "Fill the missing `defineProvider` metadata fields: `id`, `meta.displayName`, `meta.category`, `runtime`, and `auth.mode`.";
1748
+ case "Dockerfile exists":
1749
+ return "Add a provider-root `Dockerfile` based on the current `apifuse create` template.";
1750
+ case "package.json exists with @apifuse/provider-sdk dependency":
1751
+ return "Add `@apifuse/provider-sdk` to `package.json` dependencies.";
1752
+ case "Base provider checks can run":
1753
+ return "Fix the import/runtime error shown in evidence so `apifuse check` can load the provider.";
1754
+ default:
1755
+ return `Fix the failing base check "${result.message}" shown in evidence.`;
1756
+ }
1757
+ }
1758
+
1759
+ function scoreLocaleCatalog(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
1760
+ const requiredKeys = collectProviderRequiredLocaleKeys(provider);
1761
+ if (requiredKeys.length === 0) {
1762
+ return pass(
1763
+ "locale-catalog",
1764
+ "operations",
1765
+ "No key-owned provider metadata or operation metadata requires locale catalog validation.",
1766
+ 0,
1767
+ );
1768
+ }
1769
+
1770
+ try {
1771
+ const availableLocales = REQUIRED_PUBLIC_PROVIDER_LOCALES.filter((locale) =>
1772
+ existsSync(join(providerRoot, "locales", `${locale}.json`)),
1773
+ );
1774
+ const catalogs = loadProviderLocaleCatalogs({
1775
+ providerDir: providerRoot,
1776
+ locales: availableLocales,
1777
+ });
1778
+ const validation = validateProviderLocaleCatalogs({
1779
+ catalogs,
1780
+ requiredLocales: REQUIRED_PUBLIC_PROVIDER_LOCALES,
1781
+ requiredKeys,
1782
+ });
1783
+ if (!validation.ok) {
1784
+ return blocker(
1785
+ "locale-catalog",
1786
+ "operations",
1787
+ "Provider locale catalog is missing required public-provider copy.",
1788
+ "Add provider-local locales/en.json and locales/ko.json values for every provider metadata key, operation descriptionKey, and .describeKey() or describeKey() schema field.",
1789
+ 0,
1790
+ validation.issues.map((issue) => `${issue.locale}:${issue.key}: ${issue.message}`),
1791
+ );
1792
+ }
1793
+ } catch (error) {
1794
+ const message = error instanceof Error ? error.message : String(error);
1795
+ return blocker(
1796
+ "locale-catalog",
1797
+ "operations",
1798
+ "Provider locale catalog is missing required public-provider copy.",
1799
+ "Add provider-local locales/en.json and locales/ko.json values for every provider metadata key, operation descriptionKey, and .describeKey() or describeKey() schema field.",
1800
+ 0,
1801
+ [`*:*: ${message}`],
1802
+ );
1803
+ }
1804
+
1805
+ return pass(
1806
+ "locale-catalog",
1807
+ "operations",
1808
+ "Required provider and operation locale keys resolve in locales/en.json and locales/ko.json.",
1809
+ 0,
1810
+ );
1811
+ }
1812
+
1813
+ function collectProviderRequiredLocaleKeys(provider: ProviderDefinition): string[] {
1814
+ const keys = new Set<string>();
1815
+
1816
+ addLocaleKeys(keys, [
1817
+ provider.meta.descriptionKey,
1818
+ provider.meta.docTitleKey,
1819
+ provider.meta.docDescriptionKey,
1820
+ provider.meta.docSummaryKey,
1821
+ provider.meta.docMarkdownKey,
1822
+ ]);
1823
+
1824
+ const publicProfile = provider.meta.publicProfile;
1825
+ if (publicProfile) {
1826
+ addLocaleKeys(keys, [
1827
+ publicProfile.displayNameKey,
1828
+ publicProfile.shortDescriptionKey,
1829
+ publicProfile.longDescriptionKey,
1830
+ publicProfile.setupSummaryKey,
1831
+ ...(publicProfile.capabilityKeys ?? []),
1832
+ ...(publicProfile.examplePromptKeys ?? []),
1833
+ ...(publicProfile.requirementKeys ?? []),
1834
+ ...(publicProfile.limitationKeys ?? []),
1835
+ ]);
1836
+ }
1837
+
1838
+ for (const operation of Object.values(provider.operations)) {
1839
+ addLocaleKeys(keys, [
1840
+ operation.descriptionKey,
1841
+ operation.docs?.titleKey,
1842
+ operation.docs?.descriptionKey,
1843
+ operation.docs?.summaryKey,
1844
+ operation.docs?.markdownKey,
1845
+ ...(operation.whenToUseKeys ?? []),
1846
+ ...(operation.whenNotToUseKeys ?? []),
1847
+ ...collectSchemaDescriptionKeys(operation.input),
1848
+ ...collectSchemaDescriptionKeys(operation.output),
1849
+ ]);
1850
+ }
1851
+
1852
+ return Array.from(keys);
1853
+ }
1854
+
1855
+ function addLocaleKeys(keys: Set<string>, values: readonly unknown[]): void {
1856
+ for (const key of values) {
1857
+ if (typeof key === "string" && key.length > 0) {
1858
+ keys.add(key);
1859
+ }
1860
+ }
1861
+ }
1862
+
1863
+ function collectSchemaDescriptionKeys(schema: unknown): string[] {
1864
+ if (!(schema instanceof z.ZodType)) {
1865
+ return [];
1866
+ }
1867
+ const jsonSchema = z.toJSONSchema(schema);
1868
+ if (!isRecord(jsonSchema)) {
1869
+ return [];
1870
+ }
1871
+ const keys: string[] = [];
1872
+ collectJsonSchemaDescriptionKeys(jsonSchema, keys);
1873
+ return keys;
1874
+ }
1875
+
1876
+ function collectJsonSchemaDescriptionKeys(schema: Record<string, unknown>, keys: string[]): void {
1877
+ const descriptionKey = schema[APIFUSE_DESCRIPTION_KEY_META_KEY];
1878
+ if (typeof descriptionKey === "string" && descriptionKey.length > 0) {
1879
+ keys.push(descriptionKey);
1880
+ }
1881
+
1882
+ for (const value of Object.values(schema)) {
1883
+ if (isRecord(value)) {
1884
+ collectJsonSchemaDescriptionKeys(value, keys);
1885
+ } else if (Array.isArray(value)) {
1886
+ for (const item of value) {
1887
+ if (isRecord(item)) {
1888
+ collectJsonSchemaDescriptionKeys(item, keys);
1889
+ }
1890
+ }
1891
+ }
1892
+ }
1893
+ }
1894
+
1895
+ function isRecord(value: unknown): value is Record<string, unknown> {
1896
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1897
+ }
1898
+
1899
+ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
1900
+ const operations = Object.entries(provider.operations);
1901
+ const weakDescriptions = operations
1902
+ .filter(([, operation]) => {
1903
+ // Hard-cut providers move operation copy into locale catalogs via
1904
+ // descriptionKey instead of raw inline prose; the resolved text length
1905
+ // is enforced at registry catalog-build time, matching how lintOperation
1906
+ // skips the raw-description min-length rule when a descriptionKey is set.
1907
+ const hasDescriptionKey =
1908
+ typeof operation.descriptionKey === "string" && operation.descriptionKey.length > 0;
1909
+ if (hasDescriptionKey) return false;
1910
+ return true;
1911
+ })
1912
+ .map(([operationId]) => operationId);
1913
+ const missingAnnotations = operations
1914
+ .filter(([, operation]) => !operation.annotations)
1915
+ .map(([operationId]) => operationId);
1916
+
1917
+ if (weakDescriptions.length > 0) {
1918
+ return {
1919
+ id: "operation-metadata",
1920
+ category: "operations",
1921
+ level: "blocker",
1922
+ status: "fail",
1923
+ points: 0,
1924
+ maxPoints: CATEGORY_MAX_POINTS.operations,
1925
+ message: "One or more operations have weak descriptions.",
1926
+ 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.`,
1927
+ evidence: weakDescriptions,
1928
+ };
1929
+ }
1930
+
1931
+ const points = missingAnnotations.length > 0 ? 11 : CATEGORY_MAX_POINTS.operations;
1932
+ return {
1933
+ id: "operation-metadata",
1934
+ category: "operations",
1935
+ level: missingAnnotations.length > 0 ? "warn" : "info",
1936
+ status: missingAnnotations.length > 0 ? "warn" : "pass",
1937
+ points,
1938
+ maxPoints: CATEGORY_MAX_POINTS.operations,
1939
+ message:
1940
+ missingAnnotations.length > 0
1941
+ ? "Operations are described, but some are missing safety annotations."
1942
+ : "Operation descriptions and metadata are review-ready.",
1943
+ remediation:
1944
+ missingAnnotations.length > 0
1945
+ ? `For ${missingAnnotations.join(", ")}, add \`annotations\` with the applicable safety fields, such as \`readOnly\`, \`destructive\`, \`idempotent\`, \`openWorld\`, \`rateLimit\`, or \`timeoutMs\`.`
1946
+ : undefined,
1947
+ evidence:
1948
+ missingAnnotations.length > 0
1949
+ ? missingAnnotations.map((operationId) => `${operationId}: missing annotations`)
1950
+ : operations.map(([operationId]) => operationId),
1951
+ };
1952
+ }
1953
+
1954
+ function scoreFixtureCoverage(provider: ProviderDefinition): SubmitCheck {
1955
+ const missing = Object.entries(provider.operations)
1956
+ .filter(([, operation]) => !operation.fixtures?.request || !operation.fixtures?.response)
1957
+ .map(([operationId]) => operationId);
1958
+ if (missing.length > 0) {
1959
+ return blocker(
1960
+ "fixtures",
1961
+ "fixtures",
1962
+ "One or more operations are missing bidirectional fixtures.",
1963
+ `For ${missing.join(", ")}, add \`fixtures.request\` and \`fixtures.response\` values that parse against the operation input and output schemas.`,
1964
+ CATEGORY_MAX_POINTS.fixtures,
1965
+ missing,
1966
+ );
1967
+ }
1968
+ return pass(
1969
+ "fixtures",
1970
+ "fixtures",
1971
+ "All operations include bidirectional fixtures.",
1972
+ CATEGORY_MAX_POINTS.fixtures,
1973
+ );
1974
+ }
1975
+
1976
+ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
1977
+ const operations = Object.entries(provider.operations);
1978
+ const missing: string[] = [];
1979
+ const vacuous: string[] = [];
1980
+ const placeholder: string[] = [];
1981
+ const unsupported: string[] = [];
1982
+ const generatedStarter: string[] = [];
1983
+
1984
+ for (const [operationId, operation] of operations) {
1985
+ const hasCheck = operation.healthCheck !== undefined;
1986
+ const hasUnsupported = operation.healthCheckUnsupported !== undefined;
1987
+ if (!hasCheck && !hasUnsupported) {
1988
+ missing.push(operationId);
1989
+ continue;
1990
+ }
1991
+ if (hasCheck && !hasUnsupported && hasOnlyVacuousHealthCases(operation.healthCheck)) {
1992
+ vacuous.push(operationId);
1993
+ }
1994
+ if (hasUnsupported) {
1995
+ const reason = operation.healthCheckUnsupported?.reason ?? "";
1996
+ unsupported.push(operationId);
1997
+ if (/generated local-only scaffold/i.test(reason)) {
1998
+ generatedStarter.push(operationId);
1999
+ }
2000
+ if (
2001
+ /(todo|later|tbd|test fixture|unit test|placeholder|not sure|skip for test)/i.test(reason)
2002
+ ) {
2003
+ placeholder.push(operationId);
2004
+ }
2005
+ }
2006
+ }
2007
+
2008
+ if (missing.length > 0) {
2009
+ return blocker(
2010
+ "health-coverage",
2011
+ "health",
2012
+ "One or more operations lack healthCheck or healthCheckUnsupported.",
2013
+ `For ${missing.join(", ")}, add \`healthCheck: { interval, cases }\` for safe read-only upstream probes, or add \`healthCheckUnsupported: { reason: "<specific reason>" }\`.`,
2014
+ CATEGORY_MAX_POINTS.health,
2015
+ missing,
2016
+ );
2017
+ }
2018
+
2019
+ if (vacuous.length > 0) {
2020
+ return blocker(
2021
+ "health-coverage",
2022
+ "health",
2023
+ "One or more operations have healthCheck cases with empty assertions.",
2024
+ `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.`,
2025
+ CATEGORY_MAX_POINTS.health,
2026
+ vacuous.map((operationId) => `${operationId}: empty healthCheck.assertions`),
2027
+ );
2028
+ }
2029
+
2030
+ if (placeholder.length > 0) {
2031
+ return {
2032
+ id: "health-coverage",
2033
+ category: "health",
2034
+ level: "warn",
2035
+ status: "warn",
2036
+ points: 8,
2037
+ maxPoints: CATEGORY_MAX_POINTS.health,
2038
+ message: "Some healthCheckUnsupported reasons look placeholder-like.",
2039
+ remediation: `For ${placeholder.join(", ")}, replace the placeholder \`healthCheckUnsupported.reason\` with a specific reason such as destructive mutation, paid call, credential sensitivity, or upstream flakiness.`,
2040
+ evidence: placeholder,
2041
+ };
2042
+ }
2043
+
2044
+ if (generatedStarter.length > 0) {
2045
+ return {
2046
+ id: "health-coverage",
2047
+ category: "health",
2048
+ level: "warn",
2049
+ status: "warn",
2050
+ points: 10,
2051
+ maxPoints: CATEGORY_MAX_POINTS.health,
2052
+ message:
2053
+ "Generated starter operation health rationale is present; replace starter logic before bounty submission.",
2054
+ remediation: `Replace generated starter operation(s) ${generatedStarter.join(", ")} with real upstream-backed operations and add \`healthCheck\` for safe read-only probes.`,
2055
+ evidence: generatedStarter,
2056
+ };
2057
+ }
2058
+
2059
+ if (unsupported.length > 0) {
2060
+ return {
2061
+ id: "health-coverage",
2062
+ category: "health",
2063
+ level: "warn",
2064
+ status: "warn",
2065
+ points: 12,
2066
+ maxPoints: CATEGORY_MAX_POINTS.health,
2067
+ message: "Health coverage is declared, with one or more unsupported probes.",
2068
+ 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.`,
2069
+ evidence: unsupported.map((operationId) => `${operationId}: healthCheckUnsupported`),
2070
+ };
2071
+ }
2072
+
2073
+ return pass(
2074
+ "health-coverage",
2075
+ "health",
2076
+ "All operations declare real health checks.",
2077
+ CATEGORY_MAX_POINTS.health,
2078
+ );
2079
+ }
2080
+
2081
+ function hasOnlyVacuousHealthCases(
2082
+ healthCheck: ProviderDefinition["operations"][string]["healthCheck"],
2083
+ ): boolean {
2084
+ const cases = healthCheck?.cases;
2085
+ if (!Array.isArray(cases) || cases.length === 0) {
2086
+ return true;
2087
+ }
2088
+ return cases.every((healthCase) => isVacuousAssertionFunction(healthCase?.assertions));
2089
+ }
2090
+
2091
+ function isVacuousAssertionFunction(assertions: unknown): boolean {
2092
+ if (typeof assertions !== "function") {
2093
+ return true;
2094
+ }
2095
+
2096
+ let source: string;
2097
+ try {
2098
+ source = Function.prototype.toString.call(assertions);
2099
+ } catch {
2100
+ return false;
2101
+ }
2102
+
2103
+ // Native / bound functions stringify to `function () { [native code] }` with
2104
+ // no inspectable body or params. The underlying implementation may inspect
2105
+ // ctx, so fail open (do not flag) rather than mistake it for an empty body.
2106
+ if (/\[native code\]/.test(source)) {
2107
+ return false;
2108
+ }
2109
+
2110
+ const fn = parseAssertionFunction(source);
2111
+ if (!fn) {
2112
+ // Unparseable source → fail open (treat as a real assertion). A false
2113
+ // negative here only misses a no-op; a false positive would wrongly
2114
+ // reject a valid contributor.
2115
+ return false;
2116
+ }
2117
+
2118
+ // A real health assertion MUST either throw when the upstream contract
2119
+ // breaks, or inspect the probe response, which is delivered exclusively
2120
+ // through the assertion's own parameter(s). Working on the parsed AST (not
2121
+ // text) makes this precise at the syntactic layer: a `throw` only counts
2122
+ // when it is a real ThrowStatement in THIS function's body (not inside a
2123
+ // nested, uninvoked function), and a parameter reference is checked against
2124
+ // the actual bound names (destructuring binds the local alias, not the
2125
+ // property key). This closes the whole equivalent-no-op class — empty
2126
+ // bodies, `void 0`, `({})`, `Promise.resolve()`, `await Promise.resolve()`,
2127
+ // `.then()`, `new Promise(r => r())`, side-effect-only bodies, throws hidden
2128
+ // in uninvoked closures — without enumerating spellings.
2129
+ if (functionThrows(fn)) {
2130
+ return false;
2131
+ }
2132
+ const bound = new Set<string>();
2133
+ for (const param of fn.params) {
2134
+ collectBoundNames(param, bound);
2135
+ }
2136
+ if (bound.size === 0) {
2137
+ return true;
2138
+ }
2139
+ // A parameter reference anywhere in the (reachable) body is treated as
2140
+ // inspecting the response. This is deliberately syntactic, not a dataflow
2141
+ // analysis.
2142
+ //
2143
+ // KNOWN LIMITATION (accepted): a body that reads the parameter but never
2144
+ // turns that read into an outcome — no throw, no returned verdict — still
2145
+ // passes, e.g. `({ status }) => { console.info(status); }`. Precisely
2146
+ // rejecting it would require tracking whether the read flows to a throw
2147
+ // argument or return value through arbitrary local bindings and invoked
2148
+ // helpers (`const ok = ctx.output.ok; return ok ? ...` / a called helper that
2149
+ // throws). That is transitive use-def dataflow, and an imprecise version
2150
+ // FALSE-BLOCKS real assertions of exactly those shapes — verified
2151
+ // empirically. Under the fail-open contract (rejecting a real contributor is
2152
+ // strictly worse than missing a no-op) we accept the miss here. This gate
2153
+ // stops accidental/lazy no-ops (empty bodies, `void 0`, `Promise.resolve()`,
2154
+ // throws in uninvoked closures); a determined bypass via a decorative ctx
2155
+ // read is no easier than writing the real one-line `throw`, and the actual
2156
+ // defense against a runtime-empty assertion is the live `--smoke` probe.
2157
+ return !referencesBoundNames(fn, bound);
2158
+ }
2159
+
2160
+ type AssertionFunctionNode =
2161
+ | acorn.ArrowFunctionExpression
2162
+ | acorn.FunctionExpression
2163
+ | acorn.FunctionDeclaration;
2164
+
2165
+ function isFunctionNode(node: acorn.AnyNode): node is AssertionFunctionNode {
2166
+ return (
2167
+ node.type === "ArrowFunctionExpression" ||
2168
+ node.type === "FunctionExpression" ||
2169
+ node.type === "FunctionDeclaration"
2170
+ );
2171
+ }
2172
+
2173
+ /**
2174
+ * Parse the `Function.prototype.toString()` output of an assertion into its AST
2175
+ * function node. The stringified form can be an arrow (`(a) => {}`), a function
2176
+ * expression (`function (a) {}`), or a bare method (`foo() {}`), so try a few
2177
+ * wrappers until one parses. Returns undefined on any parse failure so callers
2178
+ * fail open.
2179
+ */
2180
+ function parseAssertionFunction(source: string): AssertionFunctionNode | undefined {
2181
+ const candidates = [source, `(${source})`, `({${source}})`];
2182
+ for (const candidate of candidates) {
2183
+ let program: acorn.Program;
2184
+ try {
2185
+ program = acorn.parse(candidate, { ecmaVersion: "latest" });
2186
+ } catch {
2187
+ continue;
2188
+ }
2189
+ const fn = findFirstFunction(program);
2190
+ if (fn) {
2191
+ return fn;
2192
+ }
2193
+ }
2194
+ return undefined;
2195
+ }
2196
+
2197
+ /** Depth-first search for the first function node in a parsed program. */
2198
+ function findFirstFunction(root: acorn.AnyNode): AssertionFunctionNode | undefined {
2199
+ let found: AssertionFunctionNode | undefined;
2200
+ walkAst(root, (node) => {
2201
+ if (found) {
2202
+ return false;
2203
+ }
2204
+ if (isFunctionNode(node)) {
2205
+ found = node;
2206
+ return false;
2207
+ }
2208
+ return true;
2209
+ });
2210
+ return found;
2211
+ }
2212
+
2213
+ /**
2214
+ * Collect the identifier names actually BOUND by a parameter pattern. For
2215
+ * destructuring, the binding is the local target (`value`), not the source
2216
+ * property key — so `({ status: ignored })` binds `ignored`, and a body that
2217
+ * merely mentions `status` is not referencing a parameter.
2218
+ */
2219
+ function collectBoundNames(pattern: acorn.Pattern | null, out: Set<string>): void {
2220
+ if (!pattern) {
2221
+ return;
2222
+ }
2223
+ switch (pattern.type) {
2224
+ case "Identifier":
2225
+ out.add(pattern.name);
2226
+ break;
2227
+ case "AssignmentPattern":
2228
+ collectBoundNames(pattern.left, out);
2229
+ break;
2230
+ case "RestElement":
2231
+ collectBoundNames(pattern.argument, out);
2232
+ break;
2233
+ case "ArrayPattern":
2234
+ for (const element of pattern.elements) {
2235
+ collectBoundNames(element, out);
2236
+ }
2237
+ break;
2238
+ case "ObjectPattern":
2239
+ for (const property of pattern.properties) {
2240
+ if (property.type === "RestElement") {
2241
+ collectBoundNames(property.argument, out);
2242
+ } else {
2243
+ // `.value` is the local binding target, `.key` is the source
2244
+ // property name — bind only the former.
2245
+ collectBoundNames(property.value, out);
2246
+ }
2247
+ }
2248
+ break;
2249
+ }
2250
+ }
2251
+
2252
+ /**
2253
+ * True if the function contains a real `throw` statement in ITS OWN body —
2254
+ * descending through control flow but NOT into nested functions, whose throws
2255
+ * do not execute unless that nested function is invoked.
2256
+ */
2257
+ function functionThrows(fn: AssertionFunctionNode): boolean {
2258
+ if (fn.body.type !== "BlockStatement") {
2259
+ // Concise arrow returning an expression cannot contain a throw statement.
2260
+ return false;
2261
+ }
2262
+ let throws = false;
2263
+ walkAst(fn.body, (node) => {
2264
+ if (throws) {
2265
+ return false;
2266
+ }
2267
+ if (node.type === "ThrowStatement") {
2268
+ throws = true;
2269
+ return false;
2270
+ }
2271
+ // Do not descend into nested function bodies.
2272
+ if (isFunctionNode(node)) {
2273
+ return false;
2274
+ }
2275
+ return true;
2276
+ });
2277
+ return throws;
2278
+ }
2279
+
2280
+ /**
2281
+ * True if the function's body references any of the given bound parameter names
2282
+ * as an actual value. Property KEYS (`{ status: ... }`, `obj.status`) are not
2283
+ * references; computed members (`obj[status]`) are. Nested functions that
2284
+ * re-bind the same name shadow it, so their bodies are searched with the
2285
+ * shadowed name removed from the target set.
2286
+ */
2287
+ function referencesBoundNames(fn: AssertionFunctionNode, bound: Set<string>): boolean {
2288
+ if (bound.size === 0) {
2289
+ return false;
2290
+ }
2291
+ let referenced = false;
2292
+ walkAstValues(fn.body, bound, fn.body, null, null, () => {
2293
+ referenced = true;
2294
+ });
2295
+ return referenced;
2296
+ }
2297
+
2298
+ /**
2299
+ * Walk `node`, invoking `onReference` when an Identifier in value position
2300
+ * matches a name in `names`. Skips property keys and non-computed member
2301
+ * properties. On entering a nested function, removes any parameter names it
2302
+ * rebinds (shadowing) from the active set for that subtree, and does NOT descend
2303
+ * into a PROVABLY-UNINVOKED helper — a function bound to a local name that is
2304
+ * never referenced again anywhere in the assertion body, so it cannot run when
2305
+ * the assertion runs (e.g. `(ctx) => { const later = () => ctx.status; }`). Its
2306
+ * parameter reads therefore must not count as inspecting the response, mirroring
2307
+ * how `functionThrows` ignores throws inside nested functions.
2308
+ *
2309
+ * Crucially, a helper that IS referenced again (a call site like `check()`) is
2310
+ * NOT skipped — its body is searched — so real assertions that factor the check
2311
+ * into a local helper still pass. Immediately-invoked callbacks (`.every(cb)`,
2312
+ * IIFEs, callees) are likewise searched. When in doubt we descend (fail open):
2313
+ * the only skip is a helper we can prove is never invoked.
2314
+ */
2315
+ function walkAstValues(
2316
+ node: acorn.AnyNode,
2317
+ names: Set<string>,
2318
+ outerBody: acorn.AnyNode,
2319
+ parent: acorn.AnyNode | null,
2320
+ parentKey: string | null,
2321
+ onReference: () => void,
2322
+ ): void {
2323
+ if (names.size === 0) {
2324
+ return;
2325
+ }
2326
+ if (node.type === "Identifier") {
2327
+ if (names.has(node.name)) {
2328
+ onReference();
2329
+ }
2330
+ return;
2331
+ }
2332
+ // Nested function: subtract its own parameter bindings (shadowing) before
2333
+ // descending into its body — and skip it only when it is a provably
2334
+ // uninvoked helper.
2335
+ if (isFunctionNode(node)) {
2336
+ const shadowed = new Set<string>();
2337
+ for (const param of node.params) {
2338
+ collectBoundNames(param, shadowed);
2339
+ }
2340
+ const visible = new Set<string>();
2341
+ for (const name of names) {
2342
+ if (!shadowed.has(name)) {
2343
+ visible.add(name);
2344
+ }
2345
+ }
2346
+ if (visible.size === 0 || isProvablyUninvokedHelper(node, parent, parentKey, outerBody)) {
2347
+ return;
2348
+ }
2349
+ for (const [key, child] of childEntries(node)) {
2350
+ if (key === "params") {
2351
+ continue;
2352
+ }
2353
+ walkAstValues(child, visible, outerBody, node, key, onReference);
2354
+ }
2355
+ return;
2356
+ }
2357
+ for (const [key, child] of childEntries(node)) {
2358
+ // Skip non-computed property keys (`{ status: x }`) and member
2359
+ // properties (`obj.status`) — these are names, not references.
2360
+ if (key === "key" && node.type === "Property" && !node.computed) {
2361
+ continue;
2362
+ }
2363
+ if (key === "property" && node.type === "MemberExpression" && !node.computed) {
2364
+ continue;
2365
+ }
2366
+ walkAstValues(child, names, outerBody, node, key, onReference);
2367
+ }
2368
+ }
2369
+
2370
+ /**
2371
+ * True if `fn` is a local helper bound to a name that is NEVER referenced again
2372
+ * anywhere in `outerBody` — meaning it is never invoked, so its body does not run
2373
+ * as part of evaluating the assertion. Only these provably-dead helpers are
2374
+ * skipped; a helper with any call site (its name appearing more than once, i.e.
2375
+ * beyond its own declaration) is treated as potentially executed and searched.
2376
+ * Anonymous functions in expression position (call args, callees, returns) are
2377
+ * never "uninvoked helpers" — they may run — so they are not skipped here.
2378
+ */
2379
+ function isProvablyUninvokedHelper(
2380
+ fn: AssertionFunctionNode,
2381
+ parent: acorn.AnyNode | null,
2382
+ parentKey: string | null,
2383
+ outerBody: acorn.AnyNode,
2384
+ ): boolean {
2385
+ let helperName: string | undefined;
2386
+ if (fn.type === "FunctionDeclaration" && fn.id) {
2387
+ helperName = fn.id.name;
2388
+ } else if (
2389
+ parent &&
2390
+ parent.type === "VariableDeclarator" &&
2391
+ parentKey === "init" &&
2392
+ parent.id.type === "Identifier"
2393
+ ) {
2394
+ helperName = parent.id.name;
2395
+ }
2396
+ if (helperName === undefined) {
2397
+ // Not a name-bound helper (anonymous callback / expression). It may run,
2398
+ // so do not skip it.
2399
+ return false;
2400
+ }
2401
+ // Count every occurrence of the helper name in the assertion body. Exactly
2402
+ // one occurrence is its own binding declaration; more than one means there is
2403
+ // at least one reference (call site), so the helper can run.
2404
+ let occurrences = 0;
2405
+ walkAst(outerBody, (node) => {
2406
+ if (node.type === "Identifier" && node.name === helperName) {
2407
+ occurrences += 1;
2408
+ }
2409
+ return true;
2410
+ });
2411
+ return occurrences <= 1;
2412
+ }
2413
+
2414
+ /** Generic pre-order AST walk; `visit` returns false to stop descending. */
2415
+ function walkAst(node: acorn.AnyNode, visit: (node: acorn.AnyNode) => boolean): void {
2416
+ if (!visit(node)) {
2417
+ return;
2418
+ }
2419
+ for (const child of childNodes(node)) {
2420
+ walkAst(child, visit);
2421
+ }
2422
+ }
2423
+
2424
+ function* childNodes(node: acorn.AnyNode): Generator<acorn.AnyNode> {
2425
+ for (const [, child] of childEntries(node)) {
2426
+ yield child;
2427
+ }
2428
+ }
2429
+
2430
+ function* childEntries(node: acorn.AnyNode): Generator<[string, acorn.AnyNode]> {
2431
+ for (const key of Object.keys(node)) {
2432
+ if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") {
2433
+ continue;
2434
+ }
2435
+ const value = (node as unknown as Record<string, unknown>)[key];
2436
+ if (Array.isArray(value)) {
2437
+ for (const item of value) {
2438
+ if (isAstNode(item)) {
2439
+ yield [key, item];
2440
+ }
2441
+ }
2442
+ } else if (isAstNode(value)) {
2443
+ yield [key, value];
2444
+ }
2445
+ }
2446
+ }
2447
+
2448
+ function isAstNode(value: unknown): value is acorn.AnyNode {
2449
+ return (
2450
+ typeof value === "object" &&
2451
+ value !== null &&
2452
+ typeof (value as { type?: unknown }).type === "string"
2453
+ );
2454
+ }
2455
+
2456
+ function scoreSmoke(
2457
+ smokeResult: SmokeResult | undefined,
2458
+ smokeNote: string | undefined,
2459
+ ): SubmitCheck {
2460
+ const deprecatedEvidence = smokeNote?.trim()
2461
+ ? ["Deprecated --smoke-note was provided and ignored for scoring."]
2462
+ : [];
2463
+ if (!smokeResult) {
2464
+ return {
2465
+ id: "local-smoke",
2466
+ category: "smoke",
2467
+ level: "warn",
2468
+ status: "warn",
2469
+ points: 0,
2470
+ maxPoints: CATEGORY_MAX_POINTS.smoke,
2471
+ message: "Measured local smoke was not run.",
2472
+ remediation:
2473
+ "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.",
2474
+ evidence: deprecatedEvidence,
2475
+ };
2476
+ }
2477
+
2478
+ const evidence = [
2479
+ `/health: ${smokeResult.healthOk ? "ok" : "failed"}`,
2480
+ ...smokeResult.operations.map(
2481
+ (outcome) =>
2482
+ `${outcome.operationId}: ${outcome.status}${outcome.httpStatus ? ` HTTP ${outcome.httpStatus}` : ""} - ${outcome.message}`,
2483
+ ),
2484
+ ...deprecatedEvidence,
2485
+ ];
2486
+ const incoherent = smokeResult.operations.filter((outcome) => outcome.status === "incoherent");
2487
+ if (!smokeResult.healthOk || smokeResult.bootError || incoherent.length > 0) {
2488
+ return {
2489
+ id: "local-smoke",
2490
+ category: "smoke",
2491
+ level: "blocker",
2492
+ status: "fail",
2493
+ points: 0,
2494
+ maxPoints: CATEGORY_MAX_POINTS.smoke,
2495
+ message: "Measured smoke failed to verify a coherent provider runtime.",
2496
+ remediation:
2497
+ "Fix the dev server boot, `/health`, or incoherent operation responses, then rerun `bun run submit-check -- --smoke`.",
2498
+ evidence: smokeResult.bootError ? [`boot: ${smokeResult.bootError}`, ...evidence] : evidence,
2499
+ details: smokeResult,
2500
+ };
2501
+ }
2502
+
2503
+ const successes = smokeResult.operations.filter((outcome) => outcome.status === "success");
2504
+ if (successes.length > 0) {
2505
+ return {
2506
+ id: "local-smoke",
2507
+ category: "smoke",
2508
+ level: "info",
2509
+ status: "pass",
2510
+ points: CATEGORY_MAX_POINTS.smoke,
2511
+ maxPoints: CATEGORY_MAX_POINTS.smoke,
2512
+ message: "Measured smoke passed with at least one schema-valid operation success.",
2513
+ evidence,
2514
+ details: smokeResult,
2515
+ };
2516
+ }
2517
+
2518
+ return {
2519
+ id: "local-smoke",
2520
+ category: "smoke",
2521
+ level: "warn",
2522
+ status: "warn",
2523
+ points: 7,
2524
+ maxPoints: CATEGORY_MAX_POINTS.smoke,
2525
+ message: "Runtime path was verified, but no live upstream schema-valid success was observed.",
2526
+ remediation:
2527
+ "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.",
2528
+ evidence,
2529
+ details: smokeResult,
2530
+ };
2531
+ }
2532
+
2533
+ export async function runSubmitCheckSmoke(
2534
+ providerRoot: string,
2535
+ provider?: ProviderDefinition,
2536
+ ): Promise<SmokeResult> {
2537
+ const loadedProvider = provider ?? (await loadProvider(providerRoot));
2538
+ if (!loadedProvider) {
2539
+ return {
2540
+ measured: true,
2541
+ healthOk: false,
2542
+ bootError: "Provider could not be loaded.",
2543
+ operations: [],
2544
+ };
2545
+ }
2546
+
2547
+ const port = await getAvailablePort();
2548
+ const server = spawn("bun", ["run", "dev"], {
2549
+ cwd: providerRoot,
2550
+ env: { ...process.env, APIFUSE__RUNTIME__PORT: String(port) },
2551
+ detached: process.platform !== "win32",
2552
+ stdio: ["ignore", "pipe", "pipe"],
2553
+ });
2554
+ let output = "";
2555
+ server.stdout?.on("data", (chunk) => {
2556
+ output += chunk.toString();
2557
+ });
2558
+ server.stderr?.on("data", (chunk) => {
2559
+ output += chunk.toString();
2560
+ });
2561
+
2562
+ try {
2563
+ const baseUrl = `http://127.0.0.1:${port}`;
2564
+ const health = await waitForSmokeHealth(`${baseUrl}/health`, server, () => output);
2565
+ if (!health.ok) {
2566
+ return {
2567
+ measured: true,
2568
+ healthOk: false,
2569
+ bootError: health.error,
2570
+ operations: [],
2571
+ };
2572
+ }
2573
+ const operations: SmokeOperationOutcome[] = [];
2574
+ for (const [operationId, operation] of Object.entries(loadedProvider.operations)) {
2575
+ operations.push(
2576
+ await smokeOperation(baseUrl, operationId, operation.output, {
2577
+ requestId: `req_submit_check_smoke_${operationId}`,
2578
+ input: operation.fixtures?.request ?? {},
2579
+ headers: {},
2580
+ }),
2581
+ );
2582
+ }
2583
+ return { measured: true, healthOk: true, operations };
2584
+ } finally {
2585
+ await stopSmokeServer(server);
2586
+ }
2587
+ }
2588
+
2589
+ async function smokeOperation(
2590
+ baseUrl: string,
2591
+ operationId: string,
2592
+ outputSchema: ProviderDefinition["operations"][string]["output"],
2593
+ body: unknown,
2594
+ ): Promise<SmokeOperationOutcome> {
2595
+ try {
2596
+ const response = await fetch(`${baseUrl}/v1/${operationId}`, {
2597
+ method: "POST",
2598
+ headers: { "content-type": "application/json" },
2599
+ body: JSON.stringify(body),
2600
+ signal: AbortSignal.timeout(20_000),
2601
+ });
2602
+ const payload = await response.json().catch(() => undefined);
2603
+ if (response.ok && isRecord(payload) && "data" in payload) {
2604
+ const parsed = safeParseSchemaSync(
2605
+ outputSchema,
2606
+ payload.data,
2607
+ `operations.${operationId}.output`,
2608
+ );
2609
+ if (parsed.success) {
2610
+ return {
2611
+ operationId,
2612
+ status: "success",
2613
+ httpStatus: response.status,
2614
+ message: "schema-valid success",
2615
+ };
2616
+ }
2617
+ return {
2618
+ operationId,
2619
+ status: "incoherent",
2620
+ httpStatus: response.status,
2621
+ message: "success payload failed output schema validation",
2622
+ };
2623
+ }
2624
+ if (isStructuredProviderError(payload) && response.status < 500) {
2625
+ return {
2626
+ operationId,
2627
+ status: "structured_error",
2628
+ httpStatus: response.status,
2629
+ message: `${payload.error.code}: ${payload.error.message}`,
2630
+ };
2631
+ }
2632
+ return {
2633
+ operationId,
2634
+ status: "incoherent",
2635
+ httpStatus: response.status,
2636
+ message: isStructuredProviderError(payload)
2637
+ ? `${payload.error.code}: ${payload.error.message}`
2638
+ : "response was not a schema-valid success or structured provider error",
2639
+ };
2640
+ } catch (error) {
2641
+ return {
2642
+ operationId,
2643
+ status: "incoherent",
2644
+ message: error instanceof Error ? error.message : String(error),
2645
+ };
2646
+ }
2647
+ }
2648
+
2649
+ function isStructuredProviderError(
2650
+ value: unknown,
2651
+ ): value is { error: { code: string; message: string } } {
2652
+ return (
2653
+ isRecord(value) &&
2654
+ isRecord(value.error) &&
2655
+ typeof value.error.code === "string" &&
2656
+ typeof value.error.message === "string"
2657
+ );
2658
+ }
2659
+
2660
+ async function getAvailablePort(): Promise<number> {
2661
+ return await new Promise((resolvePromise, rejectPromise) => {
2662
+ const server = createServer();
2663
+ server.once("error", rejectPromise);
2664
+ server.listen(0, "127.0.0.1", () => {
2665
+ const address = server.address();
2666
+ server.close((error) => {
2667
+ if (error) {
2668
+ rejectPromise(error);
2669
+ return;
2670
+ }
2671
+ if (!address || typeof address === "string") {
2672
+ rejectPromise(new Error("Could not allocate a local TCP port."));
2673
+ return;
2674
+ }
2675
+ resolvePromise(address.port);
2676
+ });
2677
+ });
2678
+ });
2679
+ }
2680
+
2681
+ async function waitForSmokeHealth(
2682
+ url: string,
2683
+ server: ChildProcess,
2684
+ getOutput: () => string,
2685
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
2686
+ const deadline = Date.now() + 20_000;
2687
+ let lastError: unknown;
2688
+
2689
+ while (Date.now() < deadline) {
2690
+ if (server.exitCode !== null) {
2691
+ return {
2692
+ ok: false,
2693
+ error: `Dev server exited early with code ${server.exitCode}. ${getOutput()}`,
2694
+ };
2695
+ }
2696
+
2697
+ try {
2698
+ const response = await fetch(url, { signal: AbortSignal.timeout(1_000) });
2699
+ if (response.ok) return { ok: true };
2700
+ lastError = new Error(`${url} returned ${response.status}`);
2701
+ } catch (error) {
2702
+ lastError = error;
2703
+ }
2704
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 200));
2705
+ }
2706
+
2707
+ return {
2708
+ ok: false,
2709
+ error: `Timed out waiting for ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}. ${getOutput()}`,
2710
+ };
2711
+ }
2712
+
2713
+ async function stopSmokeServer(server: ChildProcess): Promise<void> {
2714
+ if (server.exitCode !== null) return;
2715
+ killSmokeProcessTree(server, "SIGTERM");
2716
+ await new Promise<void>((resolvePromise) => {
2717
+ const timeout = setTimeout(() => {
2718
+ if (server.exitCode === null) {
2719
+ killSmokeProcessTree(server, "SIGKILL");
2720
+ }
2721
+ resolvePromise();
2722
+ }, 2_000);
2723
+ server.once("exit", () => {
2724
+ clearTimeout(timeout);
2725
+ resolvePromise();
2726
+ });
2727
+ });
2728
+ }
2729
+
2730
+ function killSmokeProcessTree(server: ChildProcess, signal: NodeJS.Signals): void {
2731
+ if (server.pid === undefined) return;
2732
+ try {
2733
+ if (process.platform === "win32") {
2734
+ server.kill(signal);
2735
+ return;
2736
+ }
2737
+ process.kill(-server.pid, signal);
2738
+ } catch {
2739
+ server.kill(signal);
2740
+ }
2741
+ }
2742
+
2743
+ function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
2744
+ const authMode = provider.auth?.mode ?? "none";
2745
+ const credentialKeys = provider.credential?.keys ?? [];
2746
+ if (authMode === "credentials" && credentialKeys.length === 0) {
2747
+ return blocker(
2748
+ "auth-safety",
2749
+ "auth",
2750
+ "Credential-backed auth mode is missing credential.keys.",
2751
+ "Declare credential.keys and document local-only connection.secrets debugging.",
2752
+ CATEGORY_MAX_POINTS.auth,
2753
+ );
2754
+ }
2755
+
2756
+ if (authMode === "oauth2" && credentialKeys.length === 0) {
2757
+ return {
2758
+ id: "auth-safety",
2759
+ category: "auth",
2760
+ level: "warn",
2761
+ status: "warn",
2762
+ points: 7,
2763
+ maxPoints: CATEGORY_MAX_POINTS.auth,
2764
+ message: "OAuth auth mode does not declare persisted credential.keys.",
2765
+ remediation:
2766
+ "Add `credential: { keys: [...] }` to `defineProvider` with the persisted OAuth token fields returned by the real token exchange.",
2767
+ };
2768
+ }
2769
+
2770
+ if (authMode === "none") {
2771
+ const securedOperations = Object.entries(provider.operations).filter(
2772
+ ([, operation]) => operation.annotations?.openWorld === false,
2773
+ );
2774
+ if (securedOperations.length > 0) {
2775
+ return {
2776
+ id: "auth-safety",
2777
+ category: "auth",
2778
+ level: "warn",
2779
+ status: "warn",
2780
+ points: 7,
2781
+ maxPoints: CATEGORY_MAX_POINTS.auth,
2782
+ message: "Provider is no-auth but at least one operation is not marked openWorld.",
2783
+ 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(", ")}.`,
2784
+ evidence: securedOperations.map(([operationId]) => operationId),
2785
+ };
2786
+ }
2787
+ }
2788
+
2789
+ return pass(
2790
+ "auth-safety",
2791
+ "auth",
2792
+ "Auth and credential declarations are internally consistent.",
2793
+ CATEGORY_MAX_POINTS.auth,
2794
+ );
2795
+ }
2796
+
2797
+ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
2798
+ const readmePath = resolve(providerRoot, "README.md");
2799
+ if (!existsSync(readmePath)) {
2800
+ return [
2801
+ {
2802
+ id: "submission-docs",
2803
+ category: "docs",
2804
+ level: "warn",
2805
+ status: "warn",
2806
+ points: 4,
2807
+ maxPoints: CATEGORY_MAX_POINTS.docs,
2808
+ message: "Provider README.md is missing.",
2809
+ remediation:
2810
+ "Add README sections for parameters, response shape, examples, auth/env setup, health coverage, and known upstream constraints.",
2811
+ },
2812
+ ];
2813
+ }
2814
+
2815
+ const readme = readFileSync(readmePath, "utf8").toLowerCase();
2816
+ const missing = [
2817
+ ["parameters", "Parameters"],
2818
+ ["response", "Response"],
2819
+ ["example", "Example"],
2820
+ ].filter(([needle]) => !readme.includes(needle));
2821
+ const mentionsSubmitCheck = readme.includes("submit-check");
2822
+
2823
+ const points = Math.max(
2824
+ 0,
2825
+ CATEGORY_MAX_POINTS.docs - missing.length * 2 - (mentionsSubmitCheck ? 0 : 1),
2826
+ );
2827
+
2828
+ return [
2829
+ {
2830
+ id: "submission-docs",
2831
+ category: "docs",
2832
+ level: missing.length > 0 || !mentionsSubmitCheck ? "warn" : "info",
2833
+ status: missing.length > 0 || !mentionsSubmitCheck ? "warn" : "pass",
2834
+ points,
2835
+ maxPoints: CATEGORY_MAX_POINTS.docs,
2836
+ message:
2837
+ missing.length > 0 || !mentionsSubmitCheck
2838
+ ? "Provider README is present but missing some submission evidence guidance."
2839
+ : "Provider README includes expected submission guidance.",
2840
+ remediation:
2841
+ missing.length > 0 || !mentionsSubmitCheck
2842
+ ? "Update `README.md` to include `Parameters`, `Response`, `Example`, and submit-check evidence guidance sections."
2843
+ : undefined,
2844
+ evidence: [
2845
+ ...missing.map(([, label]) => `missing ${label}`),
2846
+ ...(mentionsSubmitCheck ? [] : ["missing submit-check mention"]),
2847
+ ],
2848
+ },
2849
+ ];
2850
+ }
2851
+
2852
+ function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): SubmitCheck {
2853
+ const findings = findSecretFindings(providerRoot, provider?.id);
2854
+ const blockerFindings = findings.filter((finding) => finding.level !== "warn");
2855
+ if (blockerFindings.length > 0) {
2856
+ return {
2857
+ id: "secret-scan",
2858
+ category: "security",
2859
+ level: "blocker",
2860
+ status: "fail",
2861
+ points: 0,
2862
+ maxPoints: CATEGORY_MAX_POINTS.security,
2863
+ message: "Potential real credential material was found in shareable files.",
2864
+ remediation:
2865
+ blockerFindings[0]?.remediation ??
2866
+ 'Move hardcoded credentials to env vars read via `ctx.env.get("APIFUSE__PROVIDER__<ID>__<NAME>")` and rotate the leaked credential.',
2867
+ evidence: blockerFindings.map(
2868
+ (finding) =>
2869
+ finding.evidence ??
2870
+ `${finding.file}${finding.line ? `:${finding.line}` : ""}: ${finding.label}`,
2871
+ ),
2872
+ };
2873
+ }
2874
+ if (findings.length > 0) {
2875
+ return {
2876
+ id: "secret-scan",
2877
+ category: "security",
2878
+ level: "warn",
2879
+ status: "warn",
2880
+ points: 8,
2881
+ maxPoints: CATEGORY_MAX_POINTS.security,
2882
+ message:
2883
+ "High-entropy source strings were found without secret-like identifier context; they may be false positives.",
2884
+ remediation:
2885
+ '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.',
2886
+ evidence: findings.map(
2887
+ (finding) =>
2888
+ finding.evidence ??
2889
+ `${finding.file}${finding.line ? `:${finding.line}` : ""}: ${finding.label}`,
2890
+ ),
2891
+ };
2892
+ }
2893
+
2894
+ return pass(
2895
+ "secret-scan",
2896
+ "security",
2897
+ "No high-confidence secrets were found in README, source, package, or fixtures.",
2898
+ CATEGORY_MAX_POINTS.security,
2899
+ );
2900
+ }
2901
+
2902
+ function findSecretFindings(providerRoot: string, providerId = "<ID>"): SecretFinding[] {
2903
+ const candidateFiles = [
2904
+ "README.md",
2905
+ "index.ts",
2906
+ "package.json",
2907
+ "__fixtures__/raw.json",
2908
+ "__fixtures__/transform.snap.json",
2909
+ ];
2910
+ const findings: SecretFinding[] = [];
2911
+
2912
+ for (const relativePath of candidateFiles) {
2913
+ const filePath = resolve(providerRoot, relativePath);
2914
+ if (!existsSync(filePath)) continue;
2915
+ const content = readFileSync(filePath, "utf8");
2916
+ for (const [label, pattern] of SECRET_PATTERNS) {
2917
+ if (pattern.test(content)) {
2918
+ findings.push({ label, file: relativePath });
2919
+ }
2920
+ }
2921
+ }
2922
+
2923
+ findings.push(...findEntropySecretFindings(providerRoot, providerId));
2924
+ return findings;
2925
+ }
2926
+
2927
+ function findEntropySecretFindings(providerRoot: string, providerId: string): SecretFinding[] {
2928
+ const findings: SecretFinding[] = [];
2929
+ for (const filePath of listNonTestProviderSourceFiles(providerRoot)) {
2930
+ const relativePath = toRelativeProviderPath(providerRoot, filePath);
2931
+ if (isEntropySecretExcludedPath(relativePath)) continue;
2932
+ const content = readFileSync(filePath, "utf8");
2933
+ const lines = content.split(/\r?\n/);
2934
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
2935
+ const line = lines[lineIndex] ?? "";
2936
+ for (const candidate of extractStringLiteralCandidates(line)) {
2937
+ const finding = classifyEntropyCandidate({
2938
+ value: candidate,
2939
+ line,
2940
+ file: relativePath,
2941
+ lineNumber: lineIndex + 1,
2942
+ providerId,
2943
+ });
2944
+ if (finding) findings.push(finding);
2945
+ }
2946
+ }
2947
+ }
2948
+ return findings;
2949
+ }
2950
+
2951
+ function isEntropySecretExcludedPath(relativePath: string): boolean {
2952
+ return (
2953
+ relativePath.endsWith(".test.ts") ||
2954
+ relativePath.startsWith("__tests__/") ||
2955
+ relativePath.includes("/__tests__/") ||
2956
+ relativePath.startsWith("__fixtures__/") ||
2957
+ relativePath.includes("/__fixtures__/")
2958
+ );
2959
+ }
2960
+
2961
+ export function extractStringLiteralCandidates(line: string): string[] {
2962
+ const candidates: string[] = [];
2963
+ for (let index = 0; index < line.length; index += 1) {
2964
+ const quote = line[index];
2965
+ if (quote !== '"' && quote !== "'" && quote !== "`") continue;
2966
+
2967
+ const contentStart = index + 1;
2968
+ let cursor = contentStart;
2969
+ while (cursor < line.length) {
2970
+ const char = line[cursor];
2971
+ if (char === "\\") {
2972
+ cursor += 2;
2973
+ continue;
2974
+ }
2975
+ if (char === quote) {
2976
+ if (cursor - contentStart >= 20) {
2977
+ candidates.push(line.slice(contentStart, cursor));
2978
+ }
2979
+ index = cursor;
2980
+ break;
2981
+ }
2982
+ cursor += 1;
2983
+ }
2984
+ }
2985
+ return candidates;
2986
+ }
2987
+
2988
+ function classifyEntropyCandidate(input: {
2989
+ value: string;
2990
+ line: string;
2991
+ file: string;
2992
+ lineNumber: number;
2993
+ providerId: string;
2994
+ }): SecretFinding | undefined {
2995
+ const value = input.value;
2996
+ if (!shouldConsiderEntropyValue(value)) return undefined;
2997
+ const charset = classifyEntropyCharset(value);
2998
+ if (!charset) return undefined;
2999
+ const entropy = shannonEntropy(value);
3000
+ const secretishContext = SECRETISH_IDENTIFIER_PATTERN.test(input.line);
3001
+ const threshold = charset === "hex" ? 3.0 : secretishContext ? 4.0 : 4.5;
3002
+ if (entropy < threshold) return undefined;
3003
+
3004
+ const preview = `${value.slice(0, 4)}...[REDACTED length=${value.length}]`;
3005
+ const envName = `APIFUSE__PROVIDER__${input.providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}__${guessSecretName(input.line)}`;
3006
+ const location = `${input.file}:${input.lineNumber}`;
3007
+ const label =
3008
+ charset === "hex"
3009
+ ? `high-entropy hex string (${entropy.toFixed(2)} bits/char)`
3010
+ : `high-entropy base64-like string (${entropy.toFixed(2)} bits/char)`;
3011
+ return {
3012
+ label,
3013
+ file: input.file,
3014
+ line: input.lineNumber,
3015
+ level: secretishContext ? "blocker" : "warn",
3016
+ remediation: `Move ${location} to an env var read via \`ctx.env.get("${envName}")\` and rotate the leaked credential.`,
3017
+ evidence: `${location}: ${label}; preview ${preview}${secretishContext ? "" : "; may be a false positive"}`,
3018
+ };
3019
+ }
3020
+
3021
+ function shouldConsiderEntropyValue(value: string): boolean {
3022
+ const lower = value.toLowerCase();
3023
+ if (/^(?:dev-only|local|example|sample|your-|replace|<)/i.test(value)) {
3024
+ return false;
3025
+ }
3026
+ if (/^sha(?:256|512)-/i.test(value)) return false;
3027
+ if (/\s/.test(value)) return false;
3028
+ if (value.includes("${")) return false;
3029
+ if (value.includes("/")) return false;
3030
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return false;
3031
+ if (/^(?:\.{0,2}\/|~\/|[A-Za-z]:\\)/.test(value)) return false;
3032
+ if (value.includes(".") && /^[A-Za-z0-9_.-]+$/.test(value)) return false;
3033
+ if (lower.includes("/") && /\.[a-z0-9]{1,8}(?:$|[/?#])/i.test(value)) {
3034
+ return false;
3035
+ }
3036
+ return value.length >= 20;
3037
+ }
3038
+
3039
+ function classifyEntropyCharset(value: string): "base64" | "hex" | undefined {
3040
+ if (/^[a-f0-9]+$/i.test(value) && value.length >= 32) return "hex";
3041
+ const base64ishChars = value.match(/[A-Za-z0-9+/=_-]/g)?.length ?? 0;
3042
+ if (base64ishChars / value.length >= 0.9) return "base64";
3043
+ return undefined;
3044
+ }
3045
+
3046
+ function shannonEntropy(value: string): number {
3047
+ const counts = new Map<string, number>();
3048
+ for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1);
3049
+ let entropy = 0;
3050
+ for (const count of counts.values()) {
3051
+ const probability = count / value.length;
3052
+ entropy -= probability * Math.log2(probability);
3053
+ }
3054
+ return entropy;
3055
+ }
3056
+
3057
+ function guessSecretName(line: string): string {
3058
+ const match =
3059
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line) ??
3060
+ /["']?([A-Za-z_$][\w$-]*)["']?\s*:/.exec(line);
3061
+ const raw = match?.[1] ?? "SECRET";
3062
+ return raw
3063
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
3064
+ .toUpperCase()
3065
+ .replace(/[^A-Z0-9]+/g, "_");
3066
+ }
3067
+
3068
+ const SECRETISH_IDENTIFIER_PATTERN = /key|token|secret|password|credential|auth/i;
3069
+
3070
+ const SECRET_PATTERNS: Array<[string, RegExp]> = [
3071
+ ["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
3072
+ ["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],
3073
+ ["Stripe live key", /(?:sk|rk)_live_[A-Za-z0-9]{20,}/],
3074
+ ["Bearer token", /Bearer\s+[A-Za-z0-9._~+/=-]{32,}/i],
3075
+ [
3076
+ "credential field",
3077
+ /"(?:apiKey|api_key|accessToken|access_token|refreshToken|refresh_token|password|secret|sessionCookie|cookie)"\s*:\s*"(?!dev-only|local|example|sample|your-|replace|<)[^"]{16,}"/i,
3078
+ ],
3079
+ ];
3080
+
3081
+ async function safeLoadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
3082
+ try {
3083
+ return await loadProvider(providerRoot);
3084
+ } catch {
3085
+ return undefined;
3086
+ }
3087
+ }
3088
+
3089
+ async function loadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
3090
+ const entryPath = resolve(providerRoot, "index.ts");
3091
+ if (!existsSync(entryPath)) {
3092
+ return undefined;
3093
+ }
3094
+ const module = (await import(pathToFileURL(entryPath).href)) as {
3095
+ default?: ProviderDefinition;
3096
+ };
3097
+ return module.default;
3098
+ }
3099
+
3100
+ function resolveProviderRoot(inputPath: string): string {
3101
+ let current = resolve(process.cwd(), inputPath);
3102
+ if (!existsSync(current)) {
3103
+ throw new Error(`Provider path not found: ${inputPath}`);
3104
+ }
3105
+ if (!existsSync(resolve(current, "index.ts"))) {
3106
+ current = dirname(current);
3107
+ }
3108
+ while (!existsSync(resolve(current, "index.ts"))) {
3109
+ const parent = dirname(current);
3110
+ if (parent === current) {
3111
+ throw new Error(`Could not find provider root for: ${inputPath}`);
3112
+ }
3113
+ current = parent;
3114
+ }
3115
+ return current;
3116
+ }
3117
+
3118
+ function pass(
3119
+ id: string,
3120
+ category: string,
3121
+ message: string,
3122
+ points: number,
3123
+ evidence?: string[],
3124
+ ): SubmitCheck {
3125
+ return {
3126
+ id,
3127
+ category,
3128
+ level: "info",
3129
+ status: "pass",
3130
+ points,
3131
+ maxPoints: points,
3132
+ message,
3133
+ ...(evidence ? { evidence } : {}),
3134
+ };
3135
+ }
3136
+
3137
+ function blocker(
3138
+ id: string,
3139
+ category: string,
3140
+ message: string,
3141
+ remediation: string,
3142
+ maxPoints: number,
3143
+ evidence?: string[],
3144
+ ): SubmitCheck {
3145
+ return {
3146
+ id,
3147
+ category,
3148
+ level: "blocker",
3149
+ status: "fail",
3150
+ points: 0,
3151
+ maxPoints,
3152
+ message,
3153
+ remediation,
3154
+ ...(evidence ? { evidence: evidence.map(redact) } : {}),
3155
+ };
3156
+ }
3157
+
3158
+ export function renderText(report: SubmitCheckReport): string {
3159
+ const lines = [
3160
+ `APIFuse Provider Submission Score: ${report.score.total} / ${report.score.max}`,
3161
+ `Verdict: ${report.score.verdict.toUpperCase()}`,
3162
+ `Provider: ${report.provider.id}@${report.provider.version} (${report.provider.runtime}, auth: ${report.provider.authMode})`,
3163
+ `Blockers: ${report.summary.blockers} Warnings: ${report.summary.warnings} Passed: ${report.summary.passed}`,
3164
+ "",
3165
+ "Checklist:",
3166
+ ];
3167
+
3168
+ for (const check of report.checks) {
3169
+ const marker = check.status === "pass" ? "✓" : check.status === "warn" ? "⚠" : "✗";
3170
+ lines.push(
3171
+ `${marker} [${check.category}] ${check.message} (${check.points}/${check.maxPoints})`,
3172
+ );
3173
+ if (check.remediation) {
3174
+ lines.push(` Fix: ${check.remediation}`);
3175
+ }
3176
+ for (const evidence of check.evidence ?? []) {
3177
+ lines.push(` - ${redact(evidence)}`);
3178
+ }
3179
+ }
3180
+
3181
+ return lines.join("\n");
3182
+ }
3183
+
3184
+ export function renderMarkdown(report: SubmitCheckReport): string {
3185
+ const lines = [
3186
+ "# APIFuse Provider Submission Report",
3187
+ "",
3188
+ `- **Provider**: ${report.provider.id}@${report.provider.version}`,
3189
+ `- **SDK**: ${report.provider.sdkVersion}`,
3190
+ `- **Runtime/Auth**: ${report.provider.runtime} / ${report.provider.authMode}`,
3191
+ ...(report.provider.tier ? [`- **Bounty tier**: ${report.provider.tier}`] : []),
3192
+ `- **Score**: ${report.score.total}/${report.score.max}`,
3193
+ `- **Verdict**: ${report.score.verdict}`,
3194
+ `- **Blockers**: ${report.summary.blockers}`,
3195
+ `- **Warnings**: ${report.summary.warnings}`,
3196
+ "",
3197
+ "## Checklist",
3198
+ "",
3199
+ "| Status | Category | Check | Points | Remediation |",
3200
+ "|---|---|---|---:|---|",
3201
+ ];
3202
+
3203
+ for (const check of report.checks) {
3204
+ const status = check.status === "pass" ? "PASS" : check.status === "warn" ? "WARN" : "FAIL";
3205
+ lines.push(
3206
+ `| ${status} | ${escapeMarkdown(check.category)} | ${escapeMarkdown(check.message)} | ${check.points}/${check.maxPoints} | ${escapeMarkdown(check.remediation ?? "")} |`,
3207
+ );
3208
+ }
3209
+
3210
+ const evidence = report.checks.flatMap((check) =>
3211
+ (check.evidence ?? []).map((item) => `- **${check.id}**: ${redact(item)}`),
3212
+ );
3213
+ if (evidence.length > 0) {
3214
+ lines.push("", "## Evidence", "", ...evidence);
3215
+ }
3216
+
3217
+ lines.push("");
3218
+ return `${lines.join("\n")}\n`;
3219
+ }
3220
+
3221
+ function escapeMarkdown(value: string): string {
3222
+ return redact(value).replaceAll("|", "\\|").replaceAll("\n", " ");
3223
+ }
3224
+
3225
+ function redact(value: string): string {
3226
+ let output = value;
3227
+ for (const [, pattern] of SECRET_PATTERNS) {
3228
+ output = output.replace(toGlobalRegex(pattern), "[REDACTED]");
3229
+ }
3230
+ return output;
3231
+ }
3232
+
3233
+ function toGlobalRegex(pattern: RegExp): RegExp {
3234
+ return pattern.global ? pattern : new RegExp(pattern.source, `${pattern.flags}g`);
3235
+ }
3236
+
3237
+ function clamp(value: number, min: number, max: number): number {
3238
+ return Math.min(max, Math.max(min, value));
3239
+ }
3240
+
3241
+ if (import.meta.main) {
3242
+ await main();
3243
+ }