@apifuse/provider-sdk 2.1.0-beta.8 → 2.2.0-beta.1

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 (133) hide show
  1. package/AUTHORING.md +134 -0
  2. package/CHANGELOG.md +61 -0
  3. package/README.md +21 -9
  4. package/SUBMISSION.md +10 -11
  5. package/bin/apifuse-pack-check.ts +22 -0
  6. package/bin/apifuse-perf.ts +18 -9
  7. package/bin/apifuse-submit-check.ts +1747 -388
  8. package/dist/auth-turn/auth-turn.v1.schema.json +89 -0
  9. package/dist/auth-turn/fixtures/invalid/empty-kind.json +4 -0
  10. package/dist/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
  11. package/dist/auth-turn/fixtures/invalid/missing-kind.json +3 -0
  12. package/dist/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
  13. package/dist/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
  14. package/dist/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
  15. package/dist/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
  16. package/dist/auth-turn/fixtures/valid/abort.json +8 -0
  17. package/dist/auth-turn/fixtures/valid/challenge.json +17 -0
  18. package/dist/auth-turn/fixtures/valid/complete.json +13 -0
  19. package/dist/auth-turn/fixtures/valid/form.json +14 -0
  20. package/dist/auth-turn/fixtures/valid/message.json +13 -0
  21. package/dist/auth-turn/fixtures/valid/multi_choice.json +15 -0
  22. package/dist/auth-turn/fixtures/valid/pending.json +5 -0
  23. package/dist/auth-turn/fixtures/valid/poll.json +9 -0
  24. package/dist/auth-turn/fixtures/valid/redirect.json +16 -0
  25. package/dist/auth-turn/fixtures/valid/retry.json +8 -0
  26. package/dist/auth-turn/fixtures/valid/unknown-kind.json +7 -0
  27. package/dist/auth-turn/index.d.ts +195 -0
  28. package/dist/auth-turn/index.js +133 -0
  29. package/dist/auth.d.ts +76 -0
  30. package/dist/auth.js +436 -0
  31. package/dist/ceremonies/index.js +7 -31
  32. package/dist/cli/create.js +45 -30
  33. package/dist/cli/templates/provider/.dockerignore.tpl +22 -0
  34. package/dist/cli/templates/provider/.gitignore.tpl +22 -0
  35. package/dist/cli/templates/provider/AGENTS.md.tpl +87 -0
  36. package/dist/cli/templates/provider/CLAUDE.md.tpl +1 -0
  37. package/dist/cli/templates/provider/Dockerfile.tpl +7 -0
  38. package/dist/cli/templates/provider/README.md.tpl +163 -0
  39. package/dist/cli/templates/provider/dev.ts.tpl +5 -0
  40. package/dist/cli/templates/provider/domain/README.md.tpl +3 -0
  41. package/dist/cli/templates/provider/index.test.ts.tpl +13 -0
  42. package/dist/cli/templates/provider/index.ts.tpl +15 -0
  43. package/dist/cli/templates/provider/mappers/README.md.tpl +3 -0
  44. package/dist/cli/templates/provider/meta.ts.tpl +7 -0
  45. package/dist/cli/templates/provider/operations/index.ts.tpl +5 -0
  46. package/dist/cli/templates/provider/operations/ping.ts.tpl +24 -0
  47. package/dist/cli/templates/provider/schemas/ping.ts.tpl +24 -0
  48. package/dist/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  49. package/dist/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  50. package/dist/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
  51. package/dist/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  52. package/dist/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  53. package/dist/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
  54. package/dist/cli/templates/provider/start.ts.tpl +5 -0
  55. package/dist/cli/templates/provider/upstream/README.md.tpl +3 -0
  56. package/dist/contract.js +1 -0
  57. package/dist/define.d.ts +6 -1
  58. package/dist/define.js +140 -70
  59. package/dist/index.d.ts +3 -2
  60. package/dist/index.js +2 -1
  61. package/dist/lint.d.ts +1 -0
  62. package/dist/lint.js +27 -0
  63. package/dist/provider.d.ts +4 -2
  64. package/dist/provider.js +2 -1
  65. package/dist/runtime/auth-flow.js +2 -0
  66. package/dist/runtime/browser.js +203 -0
  67. package/dist/runtime/http.js +28 -8
  68. package/dist/runtime/stealth.d.ts +5 -2
  69. package/dist/runtime/stealth.js +157 -4
  70. package/dist/server/index.d.ts +4 -0
  71. package/dist/server/index.js +4 -0
  72. package/dist/server/self-test-input-tokens.d.ts +1 -0
  73. package/dist/server/self-test-input-tokens.js +37 -0
  74. package/dist/server/self-test-redaction.d.ts +20 -0
  75. package/dist/server/self-test-redaction.js +70 -0
  76. package/dist/server/self-test-token.d.ts +30 -0
  77. package/dist/server/self-test-token.js +50 -0
  78. package/dist/server/self-test.d.ts +98 -0
  79. package/dist/server/self-test.js +555 -0
  80. package/dist/server/serve.d.ts +6 -0
  81. package/dist/server/serve.js +33 -8
  82. package/dist/testing/run.js +5 -1
  83. package/dist/types.d.ts +152 -0
  84. package/package.json +10 -3
  85. package/src/auth-turn/auth-turn.v1.schema.json +89 -0
  86. package/src/auth-turn/fixtures/invalid/empty-kind.json +4 -0
  87. package/src/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
  88. package/src/auth-turn/fixtures/invalid/missing-kind.json +3 -0
  89. package/src/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
  90. package/src/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
  91. package/src/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
  92. package/src/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
  93. package/src/auth-turn/fixtures/valid/abort.json +8 -0
  94. package/src/auth-turn/fixtures/valid/challenge.json +17 -0
  95. package/src/auth-turn/fixtures/valid/complete.json +13 -0
  96. package/src/auth-turn/fixtures/valid/form.json +14 -0
  97. package/src/auth-turn/fixtures/valid/message.json +13 -0
  98. package/src/auth-turn/fixtures/valid/multi_choice.json +15 -0
  99. package/src/auth-turn/fixtures/valid/pending.json +5 -0
  100. package/src/auth-turn/fixtures/valid/poll.json +9 -0
  101. package/src/auth-turn/fixtures/valid/redirect.json +16 -0
  102. package/src/auth-turn/fixtures/valid/retry.json +8 -0
  103. package/src/auth-turn/fixtures/valid/unknown-kind.json +7 -0
  104. package/src/auth-turn/index.ts +177 -0
  105. package/src/auth.ts +786 -0
  106. package/src/ceremonies/index.ts +9 -43
  107. package/src/cli/create.ts +60 -97
  108. package/src/cli/templates/provider/AGENTS.md.tpl +87 -0
  109. package/src/cli/templates/provider/CLAUDE.md.tpl +1 -0
  110. package/src/cli/templates/provider/README.md.tpl +7 -4
  111. package/src/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  112. package/src/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  113. package/src/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
  114. package/src/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  115. package/src/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  116. package/src/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
  117. package/src/contract.ts +1 -0
  118. package/src/define.ts +198 -71
  119. package/src/index.ts +16 -0
  120. package/src/lint.ts +33 -0
  121. package/src/provider.ts +27 -0
  122. package/src/runtime/auth-flow.ts +2 -0
  123. package/src/runtime/browser.ts +293 -1
  124. package/src/runtime/http.ts +48 -7
  125. package/src/runtime/stealth.ts +190 -6
  126. package/src/server/index.ts +36 -0
  127. package/src/server/self-test-input-tokens.ts +46 -0
  128. package/src/server/self-test-redaction.ts +97 -0
  129. package/src/server/self-test-token.ts +70 -0
  130. package/src/server/self-test.ts +725 -0
  131. package/src/server/serve.ts +67 -4
  132. package/src/testing/run.ts +9 -1
  133. package/src/types.ts +188 -0
@@ -1,10 +1,13 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
+ import { type ChildProcess, spawn } from "node:child_process";
3
4
  import { existsSync, readdirSync, readFileSync } from "node:fs";
4
5
  import { writeFile } from "node:fs/promises";
6
+ import { createServer } from "node:net";
5
7
  import { basename, dirname, join, relative, resolve } from "node:path";
6
8
  import { pathToFileURL } from "node:url";
7
9
 
10
+ import * as acorn from "acorn";
8
11
  import { z } from "zod";
9
12
 
10
13
  import packageJson from "../package.json";
@@ -15,6 +18,7 @@ import {
15
18
  validateProviderLocaleCatalogs,
16
19
  } from "../src/i18n";
17
20
  import { APIFUSE_DESCRIPTION_KEY_META_KEY } from "../src/schema";
21
+ import { safeParseSchemaSync } from "../src/schema";
18
22
  import { type CheckResult, runChecks } from "./apifuse-check";
19
23
 
20
24
  const TIERS = ["bronze", "silver", "gold", "diamond"] as const;
@@ -35,6 +39,7 @@ export type SubmitCheck = {
35
39
  message: string;
36
40
  remediation?: string;
37
41
  evidence?: string[];
42
+ details?: unknown;
38
43
  };
39
44
 
40
45
  export type SubmitCheckReport = {
@@ -69,6 +74,7 @@ type CliArgs = {
69
74
  isJson: boolean;
70
75
  markdownPath?: string;
71
76
  providerPath?: string;
77
+ smoke: boolean;
72
78
  smokeNote?: string;
73
79
  tier?: BountyTier;
74
80
  };
@@ -76,6 +82,24 @@ type CliArgs = {
76
82
  type SecretFinding = {
77
83
  label: string;
78
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[];
79
103
  };
80
104
 
81
105
  type SourceFinding = {
@@ -98,14 +122,13 @@ const CATEGORY_MAX_POINTS = {
98
122
  docs: 10,
99
123
  } as const;
100
124
 
101
- const REQUIRED_PUBLIC_PROVIDER_LOCALES = [
102
- "en",
103
- "ko",
104
- ] as const satisfies readonly ProviderLocale[];
125
+ const REQUIRED_PUBLIC_PROVIDER_LOCALES = ["en", "ko"] as const satisfies readonly ProviderLocale[];
105
126
 
106
- const HELP_TEXT = `Usage: apifuse submit-check [path] [--tier bronze|silver|gold|diamond] [--json] [--markdown <path>] [--smoke-note <text>]
127
+ const HELP_TEXT = `Usage: apifuse submit-check [path] [--tier bronze|silver|gold|diamond] [--json] [--markdown <path>] [--smoke]
107
128
  Alias: apifuse bounty-check [path]
108
- Default: apifuse submit-check .`;
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.`;
109
132
 
110
133
  export async function main() {
111
134
  try {
@@ -120,10 +143,7 @@ export async function main() {
120
143
  const report = await buildSubmitCheckReport(providerRoot, args);
121
144
 
122
145
  if (args.markdownPath) {
123
- await writeFile(
124
- resolve(process.cwd(), args.markdownPath),
125
- renderMarkdown(report),
126
- );
146
+ await writeFile(resolve(process.cwd(), args.markdownPath), renderMarkdown(report));
127
147
  }
128
148
 
129
149
  if (args.isJson) {
@@ -150,7 +170,7 @@ function normalizeArgs(argv: string[]): string[] {
150
170
  }
151
171
 
152
172
  function parseArgs(argv: string[]): CliArgs {
153
- const args: CliArgs = { isJson: false };
173
+ const args: CliArgs = { isJson: false, smoke: false };
154
174
 
155
175
  for (let index = 0; index < argv.length; index += 1) {
156
176
  const arg = argv[index];
@@ -177,6 +197,11 @@ function parseArgs(argv: string[]): CliArgs {
177
197
  continue;
178
198
  }
179
199
 
200
+ if (arg === "--smoke") {
201
+ args.smoke = true;
202
+ continue;
203
+ }
204
+
180
205
  if (arg === "--smoke-note") {
181
206
  args.smokeNote = requireValue(argv, index, arg);
182
207
  index += 1;
@@ -226,9 +251,7 @@ function parseTier(value: string): BountyTier {
226
251
  if (isBountyTier(value)) {
227
252
  return value;
228
253
  }
229
- throw new Error(
230
- `Invalid --tier "${value}". Expected one of: ${TIERS.join(", ")}`,
231
- );
254
+ throw new Error(`Invalid --tier "${value}". Expected one of: ${TIERS.join(", ")}`);
232
255
  }
233
256
 
234
257
  function isBountyTier(value: string): value is BountyTier {
@@ -237,7 +260,7 @@ function isBountyTier(value: string): value is BountyTier {
237
260
 
238
261
  export async function buildSubmitCheckReport(
239
262
  providerRoot: string,
240
- args: { smokeNote?: string; tier?: BountyTier } = {},
263
+ args: { smoke?: boolean; smokeNote?: string; tier?: BountyTier } = {},
241
264
  ): Promise<SubmitCheckReport> {
242
265
  const checks: SubmitCheck[] = [];
243
266
  const baseChecks = await safeRunChecks(providerRoot);
@@ -257,16 +280,20 @@ export async function buildSubmitCheckReport(
257
280
  checks.push(scoreFlatOperationComposition(providerRoot));
258
281
 
259
282
  if (provider) {
283
+ const smokeResult = args.smoke ? await runSubmitCheckSmoke(providerRoot, provider) : undefined;
260
284
  checks.push(scoreCredentialUsage(providerRoot, provider));
261
285
  checks.push(scoreLocaleCatalog(providerRoot, provider));
262
286
  checks.push(scoreOperationMetadata(provider));
263
287
  checks.push(scoreFixtureCoverage(provider));
288
+ checks.push(scoreFixtureProvenance(providerRoot, provider));
289
+ checks.push(scoreVendorKeyLeak(providerRoot));
290
+ checks.push(scoreVendorTimestampLeak(providerRoot));
264
291
  checks.push(scoreHealthCoverage(provider));
265
292
  checks.push(scoreAuthSafety(provider));
266
- checks.push(scoreSmokeEvidence(args.smokeNote));
293
+ checks.push(scoreSmoke(smokeResult, args.smokeNote));
267
294
  checks.push(...scoreProviderDocs(providerRoot));
268
295
  checks.push(scoreRepositoryDx(providerRoot));
269
- checks.push(scoreSecrets(providerRoot));
296
+ checks.push(scoreSecrets(providerRoot, provider));
270
297
  } else {
271
298
  checks.push(
272
299
  blocker(
@@ -279,22 +306,14 @@ export async function buildSubmitCheckReport(
279
306
  );
280
307
  }
281
308
 
282
- const total = clamp(
283
- Math.round(checks.reduce((sum, check) => sum + check.points, 0)),
284
- 0,
285
- 100,
286
- );
309
+ const total = clamp(Math.round(checks.reduce((sum, check) => sum + check.points, 0)), 0, 100);
287
310
  const blockers = checks.filter(
288
311
  (check) => check.level === "blocker" && check.status === "fail",
289
312
  ).length;
290
313
  const warnings = checks.filter((check) => check.status === "warn").length;
291
314
  const passed = checks.filter((check) => check.status === "pass").length;
292
315
  const verdict: Verdict =
293
- blockers > 0
294
- ? "blocked"
295
- : total >= 90 && warnings === 0
296
- ? "ready"
297
- : "reviewable_with_warnings";
316
+ blockers > 0 ? "blocked" : total >= 90 && warnings === 0 ? "ready" : "reviewable_with_warnings";
298
317
 
299
318
  return {
300
319
  schemaVersion: 1,
@@ -335,18 +354,10 @@ function scoreProviderIdSlug(
335
354
  );
336
355
  }
337
356
 
338
- return pass(
339
- "id-slug",
340
- SDK_NATIVE_CATEGORY,
341
- "Provider id uses the short slug.",
342
- 0,
343
- );
357
+ return pass("id-slug", SDK_NATIVE_CATEGORY, "Provider id uses the short slug.", 0);
344
358
  }
345
359
 
346
- const findings = findSourceLineMatches(
347
- providerRoot,
348
- /["'`]apifuse-provider-[a-z0-9-]/i,
349
- );
360
+ const findings = findSourceLineMatches(providerRoot, /["'`]apifuse-provider-[a-z0-9-]/i);
350
361
  if (findings.length > 0) {
351
362
  return blocker(
352
363
  "id-slug",
@@ -358,12 +369,7 @@ function scoreProviderIdSlug(
358
369
  );
359
370
  }
360
371
 
361
- return pass(
362
- "id-slug",
363
- SDK_NATIVE_CATEGORY,
364
- "Provider id uses the short slug.",
365
- 0,
366
- );
372
+ return pass("id-slug", SDK_NATIVE_CATEGORY, "Provider id uses the short slug.", 0);
367
373
  }
368
374
 
369
375
  function scoreNoVendorShim(providerRoot: string): SubmitCheck {
@@ -388,10 +394,7 @@ function scoreNoVendorShim(providerRoot: string): SubmitCheck {
388
394
  }
389
395
 
390
396
  function scoreNoVendorImport(providerRoot: string): SubmitCheck {
391
- const findings = findSourceLineMatches(
392
- providerRoot,
393
- /from\s+["'][^"']*vendor\//,
394
- );
397
+ const findings = findSourceLineMatches(providerRoot, /from\s+["'][^"']*vendor\//);
395
398
  if (findings.length > 0) {
396
399
  return blocker(
397
400
  "no-vendor-import",
@@ -424,33 +427,24 @@ function scoreDescribeKey(providerRoot: string): SubmitCheck {
424
427
  );
425
428
  }
426
429
 
427
- return pass(
428
- "describe-key",
429
- SDK_NATIVE_CATEGORY,
430
- "Schema descriptions use describeKey.",
431
- 0,
432
- );
430
+ return pass("describe-key", SDK_NATIVE_CATEGORY, "Schema descriptions use describeKey.", 0);
433
431
  }
434
432
 
435
433
  function scoreNoRawFetch(providerRoot: string): SubmitCheck {
436
434
  const findings = findSourceLineMatches(providerRoot, /(?<![.\w])fetch\s*\(/);
437
435
  if (findings.length > 0) {
436
+ const evidence = formatSourceFindings(findings);
438
437
  return blocker(
439
438
  "no-raw-fetch",
440
439
  SDK_NATIVE_CATEGORY,
441
440
  "Provider source calls raw fetch().",
442
- "Replace raw fetch() with ctx.stealth.fetch() (cloud-IP-blocked otherwise) or ctx.http for non-stealth calls.",
441
+ `Replace raw fetch() in ${evidence.join(", ")} with ctx.stealth.fetch() for stealth/cloud-IP-sensitive calls or ctx.http.get/post/request for ordinary HTTP calls.`,
443
442
  0,
444
- formatSourceFindings(findings),
443
+ evidence,
445
444
  );
446
445
  }
447
446
 
448
- return pass(
449
- "no-raw-fetch",
450
- SDK_NATIVE_CATEGORY,
451
- "Provider source avoids raw fetch().",
452
- 0,
453
- );
447
+ return pass("no-raw-fetch", SDK_NATIVE_CATEGORY, "Provider source avoids raw fetch().", 0);
454
448
  }
455
449
 
456
450
  const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
@@ -460,10 +454,7 @@ const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
460
454
  const SDK_CONTEXT_METHOD_ALIAS_PATTERN =
461
455
  /\bconst\s+(\w+)\s*=\s*ctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|choice)\.(?:\w+)/;
462
456
 
463
- function hasRedundantRuntimeGuard(
464
- line: string,
465
- remainingLines: readonly string[],
466
- ): boolean {
457
+ function hasRedundantRuntimeGuard(line: string, remainingLines: readonly string[]): boolean {
467
458
  if (REDUNDANT_RUNTIME_GUARD_PATTERNS.some((pattern) => pattern.test(line))) {
468
459
  return true;
469
460
  }
@@ -474,12 +465,8 @@ function hasRedundantRuntimeGuard(
474
465
  return false;
475
466
  }
476
467
 
477
- const guardPattern = new RegExp(
478
- `(?:typeof\\s+${alias}\\s*!==\\s*["']function["']|!${alias}\\b)`,
479
- );
480
- return remainingLines
481
- .slice(0, 8)
482
- .some((candidate) => guardPattern.test(candidate));
468
+ const guardPattern = new RegExp(`(?:typeof\\s+${alias}\\s*!==\\s*["']function["']|!${alias}\\b)`);
469
+ return remainingLines.slice(0, 8).some((candidate) => guardPattern.test(candidate));
483
470
  }
484
471
 
485
472
  function scoreNoRedundantRuntimeGuards(providerRoot: string): SubmitCheck {
@@ -580,11 +567,7 @@ function scoreAsAssertionCount(providerRoot: string): SubmitCheck {
580
567
 
581
568
  // Returns true when `findingLine` (1-based) or the line directly above it
582
569
  // carries an `// @apifuse-allow <ruleId>:` acknowledgement comment.
583
- function hasAllowOverride(
584
- lines: readonly string[],
585
- findingLine: number,
586
- ruleId: string,
587
- ): boolean {
570
+ function hasAllowOverride(lines: readonly string[], findingLine: number, ruleId: string): boolean {
588
571
  const pattern = new RegExp(`@apifuse-allow\\s+${ruleId}\\b`);
589
572
  const current = lines[findingLine - 1];
590
573
  const previous = lines[findingLine - 2];
@@ -635,11 +618,7 @@ function escapeHatchResult(
635
618
  return pass(ruleId, SDK_NATIVE_CATEGORY, copy.passMessage, 0);
636
619
  }
637
620
 
638
- const { violations, overridden } = partitionAllowOverrides(
639
- providerRoot,
640
- findings,
641
- ruleId,
642
- );
621
+ const { violations, overridden } = partitionAllowOverrides(providerRoot, findings, ruleId);
643
622
 
644
623
  if (violations.length > 0) {
645
624
  return blocker(
@@ -752,10 +731,11 @@ function unwrapParens(expr: string): string {
752
731
  // closing bracket. This lets a property value be read across newlines, so a
753
732
  // multi-line `input: z.object({...})\n.passthrough()` is captured whole.
754
733
  function balancedValueExpression(source: string, valueStart: number): string {
734
+ const masked = maskCommentsAndStrings(source);
755
735
  let depth = 0;
756
736
  let index = valueStart;
757
737
  for (; index < source.length; index += 1) {
758
- const ch = source[index];
738
+ const ch = masked[index];
759
739
  if (ch === "(" || ch === "{" || ch === "[") {
760
740
  depth += 1;
761
741
  } else if (ch === ")" || ch === "}" || ch === "]") {
@@ -952,9 +932,7 @@ function inputKeyIsSchemaField(source: string, propIndex: number): boolean {
952
932
  // across unrelated modules from producing false positives).
953
933
  function fileImportsBinding(source: string, name: string): boolean {
954
934
  const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
955
- return new RegExp(`\\bimport\\b[^;]*\\b${escaped}\\b[^;]*\\bfrom\\b`).test(
956
- source,
957
- );
935
+ return new RegExp(`\\bimport\\b[^;]*\\b${escaped}\\b[^;]*\\bfrom\\b`).test(source);
958
936
  }
959
937
 
960
938
  // Resolves the ORIGINAL exported name for a local binding `localName`. When the
@@ -998,11 +976,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
998
976
  passthroughByFile.set(filePath, localMap);
999
977
  const constDecl =
1000
978
  /(?:^|\n)[ \t]*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?\s*=/g;
1001
- for (
1002
- let match = constDecl.exec(source);
1003
- match !== null;
1004
- match = constDecl.exec(source)
1005
- ) {
979
+ for (let match = constDecl.exec(source); match !== null; match = constDecl.exec(source)) {
1006
980
  const name = match[1];
1007
981
  if (name === undefined) {
1008
982
  continue;
@@ -1041,11 +1015,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
1041
1015
  const relPath = toRelativeProviderPath(providerRoot, filePath);
1042
1016
 
1043
1017
  const inputProp = /\binput\s*:\s*/g;
1044
- for (
1045
- let match = inputProp.exec(source);
1046
- match !== null;
1047
- match = inputProp.exec(source)
1048
- ) {
1018
+ for (let match = inputProp.exec(source); match !== null; match = inputProp.exec(source)) {
1049
1019
  // Skip `input` keys that are fields inside a zod schema body (e.g. an
1050
1020
  // upstream payload modelled as `z.object({ input: ... })`). Only an
1051
1021
  // operation's public `input:` property is in scope for this rule.
@@ -1073,9 +1043,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
1073
1043
  // Imported binding: map a possible `orig as refName` alias
1074
1044
  // back to the exported name the provider-wide map is keyed by.
1075
1045
  const originalName = importedOriginalName(source, refName);
1076
- const site =
1077
- passthroughConsts.get(refName) ??
1078
- passthroughConsts.get(originalName);
1046
+ const site = passthroughConsts.get(refName) ?? passthroughConsts.get(originalName);
1079
1047
  if (site) {
1080
1048
  push(site);
1081
1049
  }
@@ -1121,8 +1089,7 @@ function scoreUnjustifiedLooseSchema(providerRoot: string): SubmitCheck {
1121
1089
  // A `//` justification comment on the same line or the line above
1122
1090
  // (including the `@apifuse-allow loose-schema:` form) acknowledges it.
1123
1091
  const previous = lines[index - 1];
1124
- const justified =
1125
- line.includes("//") || previous?.trim().startsWith("//") === true;
1092
+ const justified = line.includes("//") || previous?.trim().startsWith("//") === true;
1126
1093
  if (!justified) {
1127
1094
  findings.push({
1128
1095
  file: toRelativeProviderPath(providerRoot, filePath),
@@ -1133,8 +1100,7 @@ function scoreUnjustifiedLooseSchema(providerRoot: string): SubmitCheck {
1133
1100
  }
1134
1101
 
1135
1102
  return escapeHatchResult(providerRoot, "unjustified-loose-schema", findings, {
1136
- blockerMessage:
1137
- "Loose schema (z.record/z.unknown/z.any) used without justification.",
1103
+ blockerMessage: "Loose schema (z.record/z.unknown/z.any) used without justification.",
1138
1104
  remediation:
1139
1105
  "Model the real shape with a typed zod schema. If the upstream payload is genuinely arbitrary, add a `// <reason>` comment or `// @apifuse-allow loose-schema: <reason>` on the line above.",
1140
1106
  passMessage: "Loose schemas are justified or absent.",
@@ -1163,24 +1129,18 @@ function spreadIdentifierResolvesToFactory(
1163
1129
  let sawDeclaration = false;
1164
1130
  for (const filePath of [
1165
1131
  indexPath,
1166
- ...listNonTestTypeScriptFiles(providerRoot).filter(
1167
- (p) => resolve(p) !== resolve(indexPath),
1168
- ),
1132
+ ...listNonTestTypeScriptFiles(providerRoot).filter((p) => resolve(p) !== resolve(indexPath)),
1169
1133
  ]) {
1170
1134
  if (!existsSync(filePath)) {
1171
1135
  continue;
1172
1136
  }
1173
- const fileSource =
1174
- filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
1137
+ const fileSource = filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
1175
1138
  const re = new RegExp(declRe.source, "g");
1176
1139
  for (let m = re.exec(fileSource); m !== null; m = re.exec(fileSource)) {
1177
1140
  sawDeclaration = true;
1178
- const expr = unwrapParens(
1179
- balancedValueExpression(fileSource, m.index + m[0].length).trim(),
1180
- );
1141
+ const expr = unwrapParens(balancedValueExpression(fileSource, m.index + m[0].length).trim());
1181
1142
  const isFactory =
1182
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) ||
1183
- hasTopLevelFactorySpread(expr)) &&
1143
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
1184
1144
  !isTransparentObjectReshape(expr);
1185
1145
  if (isFactory) {
1186
1146
  return true;
@@ -1232,9 +1192,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1232
1192
  if (inlineDefault) {
1233
1193
  defineParenIndex = inlineDefault.index + inlineDefault[0].length - 1; // points at `(`
1234
1194
  } else {
1235
- const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(
1236
- source,
1237
- );
1195
+ const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(source);
1238
1196
  const exportedName = namedDefault?.[1];
1239
1197
  if (exportedName !== undefined) {
1240
1198
  const namedDecl = new RegExp(
@@ -1308,9 +1266,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1308
1266
  // Search index.ts first (its line attribution wins), then siblings.
1309
1267
  const searchOrder = [
1310
1268
  indexPath,
1311
- ...listNonTestTypeScriptFiles(providerRoot).filter(
1312
- (p) => resolve(p) !== resolve(indexPath),
1313
- ),
1269
+ ...listNonTestTypeScriptFiles(providerRoot).filter((p) => resolve(p) !== resolve(indexPath)),
1314
1270
  ];
1315
1271
 
1316
1272
  // Collect EVERY same-named declaration across the submission and classify
@@ -1330,23 +1286,15 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1330
1286
  if (!existsSync(filePath)) {
1331
1287
  continue;
1332
1288
  }
1333
- const fileSource =
1334
- filePath === indexPath ? source : readFileSync(filePath, "utf8");
1289
+ const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
1335
1290
  const relPath = toRelativeProviderPath(providerRoot, filePath);
1336
1291
 
1337
1292
  const declRe = new RegExp(aliasDecl.source, "g");
1338
- for (
1339
- let m = declRe.exec(fileSource);
1340
- m !== null;
1341
- m = declRe.exec(fileSource)
1342
- ) {
1293
+ for (let m = declRe.exec(fileSource); m !== null; m = declRe.exec(fileSource)) {
1343
1294
  const valueStart = m.index + m[0].length;
1344
- const expr = unwrapParens(
1345
- balancedValueExpression(fileSource, valueStart).trim(),
1346
- );
1295
+ const expr = unwrapParens(balancedValueExpression(fileSource, valueStart).trim());
1347
1296
  const isFactory =
1348
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) ||
1349
- hasTopLevelFactorySpread(expr)) &&
1297
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
1350
1298
  !isTransparentObjectReshape(expr);
1351
1299
  candidates.push({
1352
1300
  expr,
@@ -1356,11 +1304,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1356
1304
  });
1357
1305
  }
1358
1306
  const destructRe = new RegExp(destructured.source, "g");
1359
- for (
1360
- let m = destructRe.exec(fileSource);
1361
- m !== null;
1362
- m = destructRe.exec(fileSource)
1363
- ) {
1307
+ for (let m = destructRe.exec(fileSource); m !== null; m = destructRe.exec(fileSource)) {
1364
1308
  candidates.push({
1365
1309
  expr: `${m[1]}(`,
1366
1310
  line: offsetToLine(fileSource, m.index),
@@ -1388,9 +1332,9 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1388
1332
  // the unresolved import as a factory-composed (non-static) shape rather
1389
1333
  // than silently passing.
1390
1334
  if (!resolved) {
1391
- const importMatch = new RegExp(
1392
- `\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`,
1393
- ).exec(source);
1335
+ const importMatch = new RegExp(`\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`).exec(
1336
+ source,
1337
+ );
1394
1338
  if (importMatch) {
1395
1339
  effective = `${aliasName}(`;
1396
1340
  effectiveLine = offsetToLine(source, importMatch.index);
@@ -1405,8 +1349,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1405
1349
  // inspect depth-1 entries so that ordinary spreads deep inside operation
1406
1350
  // handler bodies (e.g. `{ ...headers }`, `...arr.map(...)`) are NOT mistaken
1407
1351
  // for a top-level factory composition of the operations map itself.
1408
- const hasFactorySpread =
1409
- effective !== undefined && hasTopLevelFactorySpread(effective);
1352
+ const hasFactorySpread = effective !== undefined && hasTopLevelFactorySpread(effective);
1410
1353
  // A spread of a bare identifier (`{ ...hidden }`) is static ONLY when that
1411
1354
  // identifier resolves to a non-factory declaration. Resolve each top-level
1412
1355
  // spread identifier so an opaque factory map laundered through a variable
@@ -1417,18 +1360,14 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1417
1360
  spreadIdentifierResolvesToFactory(providerRoot, indexPath, source, name),
1418
1361
  );
1419
1362
  const isStaticLiteral =
1420
- effective?.startsWith("{") === true &&
1421
- !hasFactorySpread &&
1422
- !hasFactorySpreadIdentifier;
1363
+ effective?.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
1423
1364
  // A call expression `ident(...)` (factory) or a factory-spread literal is
1424
1365
  // the rejected, non-static shape — UNLESS it is the stdlib
1425
1366
  // `Object.fromEntries(Object.entries(<source-visible obj>)...)` reshape,
1426
1367
  // whose op set is still enumerable from source (verified golden pattern).
1427
1368
  const isFactoryCall =
1428
1369
  effective !== undefined &&
1429
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) ||
1430
- hasFactorySpread ||
1431
- hasFactorySpreadIdentifier) &&
1370
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) || hasFactorySpread || hasFactorySpreadIdentifier) &&
1432
1371
  !isTransparentObjectReshape(effective);
1433
1372
 
1434
1373
  if (isFactoryCall && !isStaticLiteral) {
@@ -1436,19 +1375,13 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1436
1375
  // `// @apifuse-allow flat-operation-composition: <reason>` comment on
1437
1376
  // the reported line (or the line above) downgrades this blocker to a
1438
1377
  // counted warning, consistent with the other structural rules.
1439
- return escapeHatchResult(
1440
- providerRoot,
1441
- ruleId,
1442
- [{ file: effectiveFile, line: effectiveLine }],
1443
- {
1444
- blockerMessage:
1445
- "defineProvider operations are composed by a factory call instead of a static object literal.",
1446
- remediation:
1447
- "Declare operations as a static literal: defineProvider({ operations: { 'op-id': defineOperation({...}) } }). The provider-registry AST gate requires static runtime/operations; factory composition fails the registry build. If composition is unavoidable, add `// @apifuse-allow flat-operation-composition: <reason>`.",
1448
- passMessage:
1449
- "defineProvider declares operations as a static object literal.",
1450
- },
1451
- );
1378
+ return escapeHatchResult(providerRoot, ruleId, [{ file: effectiveFile, line: effectiveLine }], {
1379
+ blockerMessage:
1380
+ "defineProvider operations are composed by a factory call instead of a static object literal.",
1381
+ remediation:
1382
+ "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>`.",
1383
+ passMessage: "defineProvider declares operations as a static object literal.",
1384
+ });
1452
1385
  }
1453
1386
 
1454
1387
  return pass(
@@ -1459,18 +1392,11 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1459
1392
  );
1460
1393
  }
1461
1394
 
1462
- function scoreCredentialUsage(
1463
- providerRoot: string,
1464
- provider: ProviderDefinition,
1465
- ): SubmitCheck {
1466
- const credentialReferences = findSourceLineMatches(
1467
- providerRoot,
1468
- /ctx\.credential/,
1469
- );
1395
+ function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
1396
+ const credentialReferences = findSourceLineMatches(providerRoot, /ctx\.credential/);
1470
1397
  const authMode = provider.auth?.mode ?? "none";
1471
1398
  const credentialKeys = provider.credential?.keys ?? [];
1472
- const storesProviderCredential =
1473
- authMode !== "none" || credentialKeys.length > 0;
1399
+ const storesProviderCredential = authMode !== "none" || credentialKeys.length > 0;
1474
1400
 
1475
1401
  if (storesProviderCredential && credentialReferences.length === 0) {
1476
1402
  return {
@@ -1480,8 +1406,7 @@ function scoreCredentialUsage(
1480
1406
  status: "warn",
1481
1407
  points: 0,
1482
1408
  maxPoints: 0,
1483
- message:
1484
- "Credential-backed provider does not reference credential persistence in source.",
1409
+ message: "Credential-backed provider does not reference credential persistence in source.",
1485
1410
  remediation:
1486
1411
  "Persist provider session state through the SDK credential context instead of process-local state. See providers/catchtable for the reference pattern.",
1487
1412
  };
@@ -1494,9 +1419,7 @@ function scoreCredentialUsage(
1494
1419
  ? "Provider does not declare reusable credentials."
1495
1420
  : "Credential-backed provider references ctx.credential.",
1496
1421
  0,
1497
- credentialReferences.length > 0
1498
- ? formatSourceFindings(credentialReferences)
1499
- : undefined,
1422
+ credentialReferences.length > 0 ? formatSourceFindings(credentialReferences) : undefined,
1500
1423
  );
1501
1424
  }
1502
1425
 
@@ -1504,9 +1427,7 @@ function findSourceLineMatches(
1504
1427
  providerRoot: string,
1505
1428
  pattern: RegExp | ((line: string) => boolean),
1506
1429
  ): SourceFinding[] {
1507
- return findSourceFindings(providerRoot, (line) =>
1508
- matchesLinePattern(line, pattern),
1509
- );
1430
+ return findSourceFindings(providerRoot, (line) => matchesLinePattern(line, pattern));
1510
1431
  }
1511
1432
 
1512
1433
  function findSourceFindings(
@@ -1533,10 +1454,7 @@ function findSourceFindings(
1533
1454
  return findings;
1534
1455
  }
1535
1456
 
1536
- function matchesLinePattern(
1537
- line: string,
1538
- pattern: RegExp | ((line: string) => boolean),
1539
- ): boolean {
1457
+ function matchesLinePattern(line: string, pattern: RegExp | ((line: string) => boolean)): boolean {
1540
1458
  return typeof pattern === "function" ? pattern(line) : pattern.test(line);
1541
1459
  }
1542
1460
 
@@ -1590,11 +1508,7 @@ function collectNonTestTypeScriptFiles(
1590
1508
  }
1591
1509
  continue;
1592
1510
  }
1593
- if (
1594
- entry.isFile() &&
1595
- relativePath.endsWith(".ts") &&
1596
- !isExcludedTestSource(relativePath)
1597
- ) {
1511
+ if (entry.isFile() && relativePath.endsWith(".ts") && !isExcludedTestSource(relativePath)) {
1598
1512
  files.push(entryPath);
1599
1513
  }
1600
1514
  }
@@ -1609,9 +1523,7 @@ function isScannableProviderSourceFile(relativePath: string): boolean {
1609
1523
  }
1610
1524
 
1611
1525
  function shouldScanSourceDirectory(relativePath: string): boolean {
1612
- return ![".git", "node_modules", "dist", "build", "coverage"].includes(
1613
- relativePath,
1614
- );
1526
+ return ![".git", "node_modules", "dist", "build", "coverage"].includes(relativePath);
1615
1527
  }
1616
1528
 
1617
1529
  function isExcludedTestSource(relativePath: string): boolean {
@@ -1624,10 +1536,7 @@ function isExcludedTestSource(relativePath: string): boolean {
1624
1536
  );
1625
1537
  }
1626
1538
 
1627
- function toRelativeProviderPath(
1628
- providerRoot: string,
1629
- filePath: string,
1630
- ): string {
1539
+ function toRelativeProviderPath(providerRoot: string, filePath: string): string {
1631
1540
  return relative(providerRoot, filePath).replaceAll("\\", "/");
1632
1541
  }
1633
1542
 
@@ -1640,6 +1549,9 @@ function scoreRepositoryDx(providerRoot: string): SubmitCheck {
1640
1549
  if (!existsSync(resolve(providerRoot, ".gitignore"))) {
1641
1550
  missing.push(".gitignore");
1642
1551
  }
1552
+ if (!existsSync(resolve(providerRoot, "AGENTS.md"))) {
1553
+ missing.push("AGENTS.md");
1554
+ }
1643
1555
 
1644
1556
  const packageJsonPath = resolve(providerRoot, "package.json");
1645
1557
  const packageScripts = readPackageScripts(packageJsonPath);
@@ -1668,14 +1580,12 @@ function scoreRepositoryDx(providerRoot: string): SubmitCheck {
1668
1580
  maxPoints: 0,
1669
1581
  message: `Generated repository DX guardrails are missing: ${missing.join(", ")}.`,
1670
1582
  remediation:
1671
- "Regenerate with the current `apifuse create` template or add .gitignore plus `type-check: tsc --noEmit` and include it from `check`.",
1583
+ "Regenerate with the current `apifuse create` template or restore the missing files: .gitignore, AGENTS.md (agent contribution guide), plus `type-check: tsc --noEmit` included from `check`.",
1672
1584
  evidence: missing,
1673
1585
  };
1674
1586
  }
1675
1587
 
1676
- function readPackageScripts(
1677
- packageJsonPath: string,
1678
- ): Record<string, unknown> | undefined {
1588
+ function readPackageScripts(packageJsonPath: string): Record<string, unknown> | undefined {
1679
1589
  if (!existsSync(packageJsonPath)) {
1680
1590
  return undefined;
1681
1591
  }
@@ -1790,6 +1700,10 @@ function scoreManagedBrowserRuntime(providerRoot: string): SubmitCheck {
1790
1700
  function scoreBaseChecks(results: CheckResult[]): SubmitCheck[] {
1791
1701
  const failed = results.filter((result) => !result.passed);
1792
1702
  if (failed.length > 0) {
1703
+ const remediation = [
1704
+ "Run `bunx apifuse check .` from the provider root.",
1705
+ ...Array.from(new Set(failed.map(baseCheckRemediation))),
1706
+ ].join(" ");
1793
1707
  return [
1794
1708
  {
1795
1709
  id: "base-checks",
@@ -1799,8 +1713,7 @@ function scoreBaseChecks(results: CheckResult[]): SubmitCheck[] {
1799
1713
  points: 0,
1800
1714
  maxPoints: CATEGORY_MAX_POINTS.definition,
1801
1715
  message: "Base provider checks failed.",
1802
- remediation:
1803
- "Run `bun run check` and fix every failing item before bounty submission.",
1716
+ remediation,
1804
1717
  evidence: failed.map((result) =>
1805
1718
  redact(`${result.message}: ${(result.details ?? []).join("; ")}`),
1806
1719
  ),
@@ -1822,10 +1735,32 @@ function scoreBaseChecks(results: CheckResult[]): SubmitCheck[] {
1822
1735
  ];
1823
1736
  }
1824
1737
 
1825
- function scoreLocaleCatalog(
1826
- providerRoot: string,
1827
- provider: ProviderDefinition,
1828
- ): SubmitCheck {
1738
+ function baseCheckRemediation(result: CheckResult): string {
1739
+ switch (result.message) {
1740
+ case "index.ts exists and exports default defineProvider":
1741
+ return "Fix `index.ts` so it default-exports `defineProvider({...})`.";
1742
+ case "All operations have handler, input, output":
1743
+ return "For each operation named in evidence, add `handler`, `input`, and `output` fields to `defineProvider({ operations })`.";
1744
+ case "All operations have fixtures":
1745
+ return "For each operation named in evidence, add `fixtures.request` and `fixtures.response` values that exercise the operation schemas.";
1746
+ case "Zod schemas parse fixtures without error":
1747
+ return "Update the failing fixture values or their zod schemas until `fixtures.request` and `fixtures.response` parse cleanly.";
1748
+ case "Provider authoring lint has no error-level diagnostics":
1749
+ return "Fix each lint diagnostic shown in evidence, then rerun `bunx apifuse check .`.";
1750
+ case "Provider metadata is declared in defineProvider":
1751
+ return "Fill the missing `defineProvider` metadata fields: `id`, `meta.displayName`, `meta.category`, `runtime`, and `auth.mode`.";
1752
+ case "Dockerfile exists":
1753
+ return "Add a provider-root `Dockerfile` based on the current `apifuse create` template.";
1754
+ case "package.json exists with @apifuse/provider-sdk dependency":
1755
+ return "Add `@apifuse/provider-sdk` to `package.json` dependencies.";
1756
+ case "Base provider checks can run":
1757
+ return "Fix the import/runtime error shown in evidence so `apifuse check` can load the provider.";
1758
+ default:
1759
+ return `Fix the failing base check "${result.message}" shown in evidence.`;
1760
+ }
1761
+ }
1762
+
1763
+ function scoreLocaleCatalog(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
1829
1764
  const requiredKeys = collectProviderRequiredLocaleKeys(provider);
1830
1765
  if (requiredKeys.length === 0) {
1831
1766
  return pass(
@@ -1856,9 +1791,7 @@ function scoreLocaleCatalog(
1856
1791
  "Provider locale catalog is missing required public-provider copy.",
1857
1792
  "Add provider-local locales/en.json and locales/ko.json values for every provider metadata key, operation descriptionKey, and .describeKey() or describeKey() schema field.",
1858
1793
  0,
1859
- validation.issues.map(
1860
- (issue) => `${issue.locale}:${issue.key}: ${issue.message}`,
1861
- ),
1794
+ validation.issues.map((issue) => `${issue.locale}:${issue.key}: ${issue.message}`),
1862
1795
  );
1863
1796
  }
1864
1797
  } catch (error) {
@@ -1881,9 +1814,7 @@ function scoreLocaleCatalog(
1881
1814
  );
1882
1815
  }
1883
1816
 
1884
- function collectProviderRequiredLocaleKeys(
1885
- provider: ProviderDefinition,
1886
- ): string[] {
1817
+ function collectProviderRequiredLocaleKeys(provider: ProviderDefinition): string[] {
1887
1818
  const keys = new Set<string>();
1888
1819
 
1889
1820
  addLocaleKeys(keys, [
@@ -1946,10 +1877,7 @@ function collectSchemaDescriptionKeys(schema: unknown): string[] {
1946
1877
  return keys;
1947
1878
  }
1948
1879
 
1949
- function collectJsonSchemaDescriptionKeys(
1950
- schema: Record<string, unknown>,
1951
- keys: string[],
1952
- ): void {
1880
+ function collectJsonSchemaDescriptionKeys(schema: Record<string, unknown>, keys: string[]): void {
1953
1881
  const descriptionKey = schema[APIFUSE_DESCRIPTION_KEY_META_KEY];
1954
1882
  if (typeof descriptionKey === "string" && descriptionKey.length > 0) {
1955
1883
  keys.push(descriptionKey);
@@ -1981,8 +1909,7 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
1981
1909
  // is enforced at registry catalog-build time, matching how lintOperation
1982
1910
  // skips the raw-description min-length rule when a descriptionKey is set.
1983
1911
  const hasDescriptionKey =
1984
- typeof operation.descriptionKey === "string" &&
1985
- operation.descriptionKey.length > 0;
1912
+ typeof operation.descriptionKey === "string" && operation.descriptionKey.length > 0;
1986
1913
  if (hasDescriptionKey) return false;
1987
1914
  return true;
1988
1915
  })
@@ -2000,14 +1927,12 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
2000
1927
  points: 0,
2001
1928
  maxPoints: CATEGORY_MAX_POINTS.operations,
2002
1929
  message: "One or more operations have weak descriptions.",
2003
- remediation:
2004
- "Add 150+ character English descriptions explaining when to use, when not to use, outputs, and caveats.",
1930
+ remediation: `For ${weakDescriptions.join(", ")}, add an operation \`descriptionKey\` backed by \`locales/en.json\` and \`locales/ko.json\`, or add a 150+ character \`description\` explaining when to use it, when not to use it, outputs, and caveats.`,
2005
1931
  evidence: weakDescriptions,
2006
1932
  };
2007
1933
  }
2008
1934
 
2009
- const points =
2010
- missingAnnotations.length > 0 ? 11 : CATEGORY_MAX_POINTS.operations;
1935
+ const points = missingAnnotations.length > 0 ? 11 : CATEGORY_MAX_POINTS.operations;
2011
1936
  return {
2012
1937
  id: "operation-metadata",
2013
1938
  category: "operations",
@@ -2021,30 +1946,25 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
2021
1946
  : "Operation descriptions and metadata are review-ready.",
2022
1947
  remediation:
2023
1948
  missingAnnotations.length > 0
2024
- ? "Add annotations such as readOnly, destructive, idempotent, openWorld, rateLimit, or timeoutMs where applicable."
1949
+ ? `For ${missingAnnotations.join(", ")}, add \`annotations\` with the applicable safety fields, such as \`readOnly\`, \`destructive\`, \`idempotent\`, \`openWorld\`, \`rateLimit\`, or \`timeoutMs\`.`
2025
1950
  : undefined,
2026
1951
  evidence:
2027
1952
  missingAnnotations.length > 0
2028
- ? missingAnnotations.map(
2029
- (operationId) => `${operationId}: missing annotations`,
2030
- )
1953
+ ? missingAnnotations.map((operationId) => `${operationId}: missing annotations`)
2031
1954
  : operations.map(([operationId]) => operationId),
2032
1955
  };
2033
1956
  }
2034
1957
 
2035
1958
  function scoreFixtureCoverage(provider: ProviderDefinition): SubmitCheck {
2036
1959
  const missing = Object.entries(provider.operations)
2037
- .filter(
2038
- ([, operation]) =>
2039
- !operation.fixtures?.request || !operation.fixtures?.response,
2040
- )
1960
+ .filter(([, operation]) => !operation.fixtures?.request || !operation.fixtures?.response)
2041
1961
  .map(([operationId]) => operationId);
2042
1962
  if (missing.length > 0) {
2043
1963
  return blocker(
2044
1964
  "fixtures",
2045
1965
  "fixtures",
2046
1966
  "One or more operations are missing bidirectional fixtures.",
2047
- "Add fixtures.request and fixtures.response that parse against operation schemas.",
1967
+ `For ${missing.join(", ")}, add \`fixtures.request\` and \`fixtures.response\` values that parse against the operation input and output schemas.`,
2048
1968
  CATEGORY_MAX_POINTS.fixtures,
2049
1969
  missing,
2050
1970
  );
@@ -2057,159 +1977,1451 @@ function scoreFixtureCoverage(provider: ProviderDefinition): SubmitCheck {
2057
1977
  );
2058
1978
  }
2059
1979
 
2060
- function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
2061
- const operations = Object.entries(provider.operations);
2062
- const missing: string[] = [];
2063
- const placeholder: string[] = [];
2064
- const unsupported: string[] = [];
2065
- const generatedStarter: string[] = [];
1980
+ const GENERATED_LOCAL_ONLY_SCAFFOLD_REASON = /generated local-only scaffold/i;
2066
1981
 
2067
- for (const [operationId, operation] of operations) {
2068
- const hasCheck = operation.healthCheck !== undefined;
2069
- const hasUnsupported = operation.healthCheckUnsupported !== undefined;
2070
- if (!hasCheck && !hasUnsupported) {
2071
- missing.push(operationId);
2072
- continue;
2073
- }
2074
- if (hasUnsupported) {
2075
- const reason = operation.healthCheckUnsupported?.reason ?? "";
2076
- unsupported.push(operationId);
2077
- if (/generated local-only scaffold/i.test(reason)) {
2078
- generatedStarter.push(operationId);
2079
- }
2080
- if (
2081
- /(todo|later|tbd|test fixture|unit test|placeholder|not sure|skip for test)/i.test(
2082
- reason,
2083
- )
2084
- ) {
2085
- placeholder.push(operationId);
2086
- }
1982
+ function scoreFixtureProvenance(
1983
+ providerRoot: string,
1984
+ provider: ProviderDefinition,
1985
+ ): SubmitCheck {
1986
+ const rawPath = resolve(providerRoot, "__fixtures__", "raw.json");
1987
+ let hasRecordedEvidence = false;
1988
+ if (existsSync(rawPath)) {
1989
+ try {
1990
+ hasRecordedEvidence = hasNonEmptyRecordedFixture(JSON.parse(readFileSync(rawPath, "utf8")));
1991
+ } catch {
1992
+ hasRecordedEvidence = false;
2087
1993
  }
2088
1994
  }
2089
1995
 
2090
- if (missing.length > 0) {
2091
- return blocker(
2092
- "health-coverage",
2093
- "health",
2094
- "One or more operations lack healthCheck or healthCheckUnsupported.",
2095
- "Declare a safe healthCheck for read-only upstream probes or a specific healthCheckUnsupported.reason.",
2096
- CATEGORY_MAX_POINTS.health,
2097
- missing,
1996
+ if (hasRecordedEvidence) {
1997
+ return pass(
1998
+ "fixture-provenance",
1999
+ "fixtures",
2000
+ "Recorded upstream fixture evidence is present.",
2001
+ 0,
2098
2002
  );
2099
2003
  }
2100
2004
 
2101
- if (placeholder.length > 0) {
2005
+ if (allOperationsAreGeneratedLocalScaffold(provider)) {
2102
2006
  return {
2103
- id: "health-coverage",
2104
- category: "health",
2007
+ id: "fixture-provenance",
2008
+ category: "fixtures",
2105
2009
  level: "warn",
2106
2010
  status: "warn",
2107
- points: 8,
2108
- maxPoints: CATEGORY_MAX_POINTS.health,
2109
- message: "Some healthCheckUnsupported reasons look placeholder-like.",
2011
+ points: 0,
2012
+ maxPoints: 0,
2013
+ message:
2014
+ "Generated local-only scaffold has no recorded upstream fixture evidence yet; run `bun run record` once real operations exist.",
2110
2015
  remediation:
2111
- "Replace placeholder rationale with a specific reason such as destructive mutation, paid call, credential sensitivity, or upstream flakiness.",
2112
- evidence: placeholder,
2016
+ "Run `bun run record` (apifuse record) against the real upstream to capture raw payloads once real operations exist.",
2017
+ evidence: ["__fixtures__/raw.json"],
2113
2018
  };
2114
2019
  }
2115
2020
 
2116
- if (generatedStarter.length > 0) {
2021
+ return blocker(
2022
+ "fixture-provenance",
2023
+ "fixtures",
2024
+ "No recorded upstream fixture evidence (__fixtures__/raw.json is empty or missing).",
2025
+ "Run `bun run record` (apifuse record) against the real upstream to capture actual recorded upstream payloads per operation in __fixtures__/raw.json; derive normalized expectations in tests from mapper(recorded raw). Hand-authored fixtures without recorded provenance are not reviewable.",
2026
+ 0,
2027
+ ["__fixtures__/raw.json"],
2028
+ );
2029
+ }
2030
+
2031
+ function hasNonEmptyRecordedFixture(value: unknown): boolean {
2032
+ return recordedFixtureStats(value, 0).hasNestedSubstance;
2033
+ }
2034
+
2035
+ function recordedFixtureStats(
2036
+ value: unknown,
2037
+ depth: number,
2038
+ ): { hasNestedSubstance: boolean; leafValues: number } {
2039
+ if (value === null || value === undefined) {
2040
+ return { hasNestedSubstance: false, leafValues: 0 };
2041
+ }
2042
+ if (Array.isArray(value)) {
2043
+ let leafValues = 0;
2044
+ let hasNestedSubstance = false;
2045
+ for (const item of value) {
2046
+ const child = recordedFixtureStats(item, depth + 1);
2047
+ leafValues += child.leafValues;
2048
+ hasNestedSubstance ||= child.hasNestedSubstance;
2049
+ }
2117
2050
  return {
2118
- id: "health-coverage",
2119
- category: "health",
2120
- level: "warn",
2121
- status: "warn",
2122
- points: 10,
2123
- maxPoints: CATEGORY_MAX_POINTS.health,
2124
- message:
2125
- "Generated starter operation health rationale is present; replace starter logic before bounty submission.",
2126
- remediation:
2127
- "Replace `ping` with real upstream-backed operations and prefer real healthCheck for safe read-only probes.",
2128
- evidence: generatedStarter,
2051
+ hasNestedSubstance: hasNestedSubstance || (depth >= 1 && value.length > 0 && leafValues >= 2),
2052
+ leafValues,
2129
2053
  };
2130
2054
  }
2131
-
2132
- if (unsupported.length > 0) {
2055
+ if (typeof value === "object") {
2056
+ let leafValues = 0;
2057
+ let hasNestedSubstance = false;
2058
+ for (const item of Object.values(value)) {
2059
+ const child = recordedFixtureStats(item, depth + 1);
2060
+ leafValues += child.leafValues;
2061
+ hasNestedSubstance ||= child.hasNestedSubstance;
2062
+ }
2133
2063
  return {
2134
- id: "health-coverage",
2135
- category: "health",
2136
- level: "warn",
2137
- status: "warn",
2138
- points: 12,
2139
- maxPoints: CATEGORY_MAX_POINTS.health,
2140
- message:
2141
- "Health coverage is declared, with one or more unsupported probes.",
2142
- remediation:
2143
- "Reviewers prefer real healthCheck for safe read-only upstream operations.",
2144
- evidence: unsupported.map(
2145
- (operationId) => `${operationId}: healthCheckUnsupported`,
2146
- ),
2064
+ hasNestedSubstance:
2065
+ hasNestedSubstance || (depth >= 1 && Object.keys(value).length > 0 && leafValues >= 2),
2066
+ leafValues,
2147
2067
  };
2148
2068
  }
2069
+ if (typeof value === "string" && value.length === 0) {
2070
+ return { hasNestedSubstance: false, leafValues: 0 };
2071
+ }
2072
+ return { hasNestedSubstance: false, leafValues: 1 };
2073
+ }
2149
2074
 
2150
- return pass(
2151
- "health-coverage",
2152
- "health",
2153
- "All operations declare real health checks.",
2154
- CATEGORY_MAX_POINTS.health,
2075
+ function allOperationsAreGeneratedLocalScaffold(provider: ProviderDefinition): boolean {
2076
+ const operations = Object.values(provider.operations);
2077
+ return (
2078
+ operations.length > 0 &&
2079
+ operations.every((operation) =>
2080
+ GENERATED_LOCAL_ONLY_SCAFFOLD_REASON.test(operation.healthCheckUnsupported?.reason ?? ""),
2081
+ )
2155
2082
  );
2156
2083
  }
2157
2084
 
2158
- function scoreSmokeEvidence(smokeNote: string | undefined): SubmitCheck {
2159
- if (smokeNote?.trim()) {
2160
- return {
2161
- id: "local-smoke",
2162
- category: "smoke",
2163
- level: "info",
2164
- status: "pass",
2165
- points: CATEGORY_MAX_POINTS.smoke,
2166
- maxPoints: CATEGORY_MAX_POINTS.smoke,
2167
- message: "Local smoke evidence was provided.",
2168
- evidence: [redact(smokeNote.trim())],
2169
- };
2085
+ function scoreVendorKeyLeak(providerRoot: string): SubmitCheck {
2086
+ return escapeHatchResult(providerRoot, "vendor-key-leak", findVendorKeyLeakFindings(providerRoot), {
2087
+ blockerMessage: "Public schema keys leak raw vendor field names.",
2088
+ remediation:
2089
+ "Normalize public request/response fields to APIFuse-standard lowerCamelCase names (e.g. isOpen24h, latitude); keep raw vendor keys only in upstream-parsing schemas (const upstream... = z.object(...)). Add `// @apifuse-allow vendor-key-leak` only with a comment explaining why the vendor name is genuinely canonical.",
2090
+ passMessage: "No vendor field-name leaks detected in public schemas.",
2091
+ });
2092
+ }
2093
+
2094
+ function scoreVendorTimestampLeak(providerRoot: string): SubmitCheck {
2095
+ return escapeHatchResult(
2096
+ providerRoot,
2097
+ "vendor-timestamp-leak",
2098
+ findVendorTimestampLeakFindings(providerRoot),
2099
+ {
2100
+ blockerMessage: "Normalized fixtures carry raw vendor timestamp formats.",
2101
+ remediation:
2102
+ "Convert vendor compact timestamps (yyyymmdd, HHmm, yyyymmddHHmmss) to ISO 8601 (date, time with timezone) at the mapper boundary; fixtures.response must show the normalized form. Add `// @apifuse-allow vendor-timestamp-leak` only when the value is genuinely not a timestamp.",
2103
+ passMessage: "No vendor timestamp formats detected in normalized fixtures.",
2104
+ },
2105
+ );
2106
+ }
2107
+
2108
+ type ObjectRange = {
2109
+ start: number;
2110
+ end: number;
2111
+ };
2112
+
2113
+ type ZObjectLiteral = {
2114
+ objectStart: number;
2115
+ objectEnd: number;
2116
+ callStart: number;
2117
+ };
2118
+
2119
+ type NamedObjectRange = ObjectRange & {
2120
+ name: string;
2121
+ };
2122
+
2123
+ function findVendorKeyLeakFindings(providerRoot: string): SourceFinding[] {
2124
+ const findings: SourceFinding[] = [];
2125
+ const seen = new Set<string>();
2126
+
2127
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
2128
+ const source = readFileSync(filePath, "utf8");
2129
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
2130
+ const upstreamRanges = findUpstreamMarkedConstRanges(source);
2131
+ for (const zObject of findZObjectLiterals(source)) {
2132
+ if (rangeContainsOffset(upstreamRanges, zObject.callStart)) {
2133
+ continue;
2134
+ }
2135
+ if (!zObjectAppearsPublicOutput(source, zObject)) {
2136
+ continue;
2137
+ }
2138
+ for (const keyFinding of vendorKeyFindingsForObject(source, zObject)) {
2139
+ const key = `${relPath}:${keyFinding.line}:${keyFinding.key}`;
2140
+ if (!seen.has(key)) {
2141
+ seen.add(key);
2142
+ findings.push({ file: relPath, line: keyFinding.line });
2143
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
2144
+ return findings;
2145
+ }
2146
+ }
2147
+ }
2148
+ }
2170
2149
  }
2171
2150
 
2172
- return {
2173
- id: "local-smoke",
2174
- category: "smoke",
2175
- level: "warn",
2176
- status: "warn",
2177
- points: 5,
2178
- maxPoints: CATEGORY_MAX_POINTS.smoke,
2179
- message: "No local smoke evidence was provided.",
2180
- remediation:
2181
- "Start `bun run dev`, call `/health` and at least one `POST /v1/{operation}`, then rerun with `--smoke-note` or paste notes in the assigned workspace PR.",
2182
- };
2151
+ return findings;
2183
2152
  }
2184
2153
 
2185
- function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
2186
- const authMode = provider.auth?.mode ?? "none";
2187
- const credentialKeys = provider.credential?.keys ?? [];
2188
- if (authMode === "credentials" && credentialKeys.length === 0) {
2189
- return blocker(
2190
- "auth-safety",
2191
- "auth",
2192
- "Credential-backed auth mode is missing credential.keys.",
2193
- "Declare credential.keys and document local-only connection.secrets debugging.",
2194
- CATEGORY_MAX_POINTS.auth,
2195
- );
2154
+ function findZObjectLiterals(source: string): ZObjectLiteral[] {
2155
+ const literals: ZObjectLiteral[] = [];
2156
+ const masked = maskCommentsAndStrings(source);
2157
+ const callPattern = /\bz\s*\.\s*object\s*\(/g;
2158
+ for (let match = callPattern.exec(masked); match !== null; match = callPattern.exec(masked)) {
2159
+ const parenIndex = masked.indexOf("(", match.index);
2160
+ const objectStart = findNextNonWhitespace(masked, parenIndex + 1);
2161
+ if (objectStart === -1 || masked[objectStart] !== "{") {
2162
+ continue;
2163
+ }
2164
+ const objectEnd = findMatchingBracket(masked, objectStart);
2165
+ if (objectEnd === -1) {
2166
+ continue;
2167
+ }
2168
+ literals.push({
2169
+ objectStart,
2170
+ objectEnd,
2171
+ callStart: match.index,
2172
+ });
2173
+ callPattern.lastIndex = objectEnd;
2196
2174
  }
2175
+ return literals;
2176
+ }
2197
2177
 
2198
- if (authMode === "oauth2" && credentialKeys.length === 0) {
2199
- return {
2200
- id: "auth-safety",
2201
- category: "auth",
2202
- level: "warn",
2203
- status: "warn",
2204
- points: 7,
2205
- maxPoints: CATEGORY_MAX_POINTS.auth,
2206
- message: "OAuth auth mode does not declare persisted credential.keys.",
2207
- remediation:
2208
- "Generated OAuth starters may begin without keys, but bounty-ready OAuth providers should declare persisted token keys once the real token exchange is implemented.",
2209
- };
2178
+ function zObjectAppearsPublicOutput(source: string, zObject: ZObjectLiteral): boolean {
2179
+ const enclosingConst = findConstValueRangeContaining(source, zObject.callStart);
2180
+ if (enclosingConst && /output|response|result/i.test(enclosingConst.name)) {
2181
+ return true;
2210
2182
  }
2183
+ const before = source.slice(Math.max(0, zObject.callStart - 160), zObject.callStart);
2184
+ return /(?:^|[\s,{])(?:output|response)\s*:\s*$/.test(before);
2185
+ }
2211
2186
 
2212
- if (authMode === "none") {
2187
+ function vendorKeyFindingsForObject(
2188
+ source: string,
2189
+ zObject: ZObjectLiteral,
2190
+ ): Array<{ key: string; line: number }> {
2191
+ const keys = collectTopLevelObjectKeys(source, zObject.objectStart, zObject.objectEnd);
2192
+ const digitFamilies = new Map<string, Set<string>>();
2193
+ for (const key of keys) {
2194
+ const digitMatch = /^([a-z][a-zA-Z]*)(\d+)[a-z]*$/i.exec(key.name);
2195
+ if (!digitMatch?.[1] || !digitMatch[2]) {
2196
+ continue;
2197
+ }
2198
+ const digits = digitFamilies.get(digitMatch[1]) ?? new Set<string>();
2199
+ digits.add(digitMatch[2]);
2200
+ digitFamilies.set(digitMatch[1], digits);
2201
+ }
2202
+
2203
+ return keys
2204
+ .filter((key) => {
2205
+ if (!/^[a-z][a-zA-Z0-9]*$/.test(key.name)) {
2206
+ return true;
2207
+ }
2208
+ const digitMatch = /^([a-z][a-zA-Z]*)(\d+)[a-z]*$/i.exec(key.name);
2209
+ return digitMatch?.[1] !== undefined && (digitFamilies.get(digitMatch[1])?.size ?? 0) >= 3;
2210
+ })
2211
+ .map((key) => ({ key: key.name, line: offsetToLine(source, key.offset) }));
2212
+ }
2213
+
2214
+ function collectTopLevelObjectKeys(
2215
+ source: string,
2216
+ objectStart: number,
2217
+ objectEnd: number,
2218
+ ): Array<{ name: string; offset: number }> {
2219
+ const keys: Array<{ name: string; offset: number }> = [];
2220
+ const masked = maskCommentsAndStrings(source);
2221
+ let index = objectStart + 1;
2222
+ while (index < objectEnd) {
2223
+ index = skipWhitespaceAndComments(masked, index, objectEnd);
2224
+ if (index >= objectEnd || masked[index] === "}") {
2225
+ break;
2226
+ }
2227
+ const keyStart = index;
2228
+ let key: string | undefined;
2229
+ const quote = source[index];
2230
+ if (quote === '"' || quote === "'") {
2231
+ const endQuote = findStringEnd(source, index);
2232
+ if (endQuote === -1) {
2233
+ break;
2234
+ }
2235
+ key = source.slice(index + 1, endQuote);
2236
+ index = endQuote + 1;
2237
+ } else if (masked[index] === "[") {
2238
+ const computedEnd = findMatchingBracket(masked, index);
2239
+ const literalStart = findNextNonWhitespace(masked, index + 1);
2240
+ if (computedEnd === -1 || literalStart === -1) {
2241
+ break;
2242
+ }
2243
+ const computedQuote = source[literalStart];
2244
+ if (computedQuote === '"' || computedQuote === "'") {
2245
+ const literalEnd = findStringEnd(source, literalStart);
2246
+ const afterLiteral =
2247
+ literalEnd === -1
2248
+ ? -1
2249
+ : skipWhitespaceAndComments(masked, literalEnd + 1, computedEnd);
2250
+ if (literalEnd !== -1 && afterLiteral === computedEnd) {
2251
+ key = source.slice(literalStart + 1, literalEnd);
2252
+ }
2253
+ }
2254
+ index = computedEnd + 1;
2255
+ } else {
2256
+ const idMatch = /^[A-Za-z_$][\w$]*/.exec(masked.slice(index));
2257
+ if (idMatch?.[0]) {
2258
+ key = idMatch[0];
2259
+ index += idMatch[0].length;
2260
+ }
2261
+ }
2262
+ index = skipWhitespaceAndComments(masked, index, objectEnd);
2263
+ if (key && masked[index] === ":") {
2264
+ keys.push({ name: key, offset: keyStart });
2265
+ index = skipObjectValue(masked, index + 1, objectEnd);
2266
+ } else {
2267
+ // Spread-based composition is intentionally not expanded here; this gate
2268
+ // only evaluates keys visible in the object literal.
2269
+ index = skipObjectValue(masked, index, objectEnd);
2270
+ }
2271
+ if (masked[index] === ",") {
2272
+ index += 1;
2273
+ }
2274
+ }
2275
+ return keys;
2276
+ }
2277
+
2278
+ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[] {
2279
+ const findings: SourceFinding[] = [];
2280
+ const seen = new Set<string>();
2281
+
2282
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
2283
+ const source = readFileSync(filePath, "utf8");
2284
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
2285
+ const zObjectRanges = findZObjectLiterals(source).map((zObject) => ({
2286
+ start: zObject.callStart,
2287
+ end: zObject.objectEnd,
2288
+ }));
2289
+ const upstreamRanges = findUpstreamMarkedConstRanges(source);
2290
+ const fixtureRanges = findPropertyObjectRanges(source, "fixtures");
2291
+ const fixtureResponseRanges = [
2292
+ ...findPropertyObjectRanges(source, "response"),
2293
+ ...findPropertyObjectRanges(source, "output"),
2294
+ ].filter((range) => rangeContainedInRanges(fixtureRanges, range));
2295
+
2296
+ for (const range of fixtureResponseRanges) {
2297
+ for (const literal of findStringLiteralsInRange(source, range)) {
2298
+ if (
2299
+ rangeContainsOffset(zObjectRanges, literal.offset) ||
2300
+ rangeContainsOffset(upstreamRanges, literal.offset) ||
2301
+ !isVendorTimestampCandidate(
2302
+ literal.value,
2303
+ propertyKeyForStringLiteral(source, literal.offset),
2304
+ )
2305
+ ) {
2306
+ continue;
2307
+ }
2308
+ const line = offsetToLine(source, literal.offset);
2309
+ const key = `${relPath}:${line}:${literal.value}`;
2310
+ if (!seen.has(key)) {
2311
+ seen.add(key);
2312
+ findings.push({ file: relPath, line });
2313
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
2314
+ return findings;
2315
+ }
2316
+ }
2317
+ }
2318
+ }
2319
+ }
2320
+
2321
+ return findings;
2322
+ }
2323
+
2324
+ function findPropertyObjectRanges(source: string, propertyName: string): ObjectRange[] {
2325
+ const ranges: ObjectRange[] = [];
2326
+ const masked = maskCommentsAndStrings(source);
2327
+ const escaped = propertyName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2328
+ const pattern = new RegExp(`(?:^|[^\\w$])["']?${escaped}["']?\\s*:`, "g");
2329
+ for (let match = pattern.exec(masked); match !== null; match = pattern.exec(masked)) {
2330
+ const objectStart = findNextNonWhitespace(masked, match.index + match[0].length);
2331
+ if (objectStart === -1 || masked[objectStart] !== "{") {
2332
+ continue;
2333
+ }
2334
+ const objectEnd = findMatchingBracket(masked, objectStart);
2335
+ if (objectEnd === -1) {
2336
+ continue;
2337
+ }
2338
+ ranges.push({ start: objectStart, end: objectEnd });
2339
+ pattern.lastIndex = objectEnd;
2340
+ }
2341
+ return ranges;
2342
+ }
2343
+
2344
+ function findUpstreamMarkedConstRanges(source: string): ObjectRange[] {
2345
+ return findNamedConstValueRanges(source)
2346
+ .filter((range) => /upstream|raw|vendor/i.test(range.name))
2347
+ .map(({ start, end }) => ({ start, end }));
2348
+ }
2349
+
2350
+ function findNamedConstValueRanges(source: string): NamedObjectRange[] {
2351
+ const ranges: NamedObjectRange[] = [];
2352
+ const masked = maskCommentsAndStrings(source);
2353
+ const pattern =
2354
+ /(?:^|\n)[ \t]*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?\s*=/g;
2355
+ for (let match = pattern.exec(masked); match !== null; match = pattern.exec(masked)) {
2356
+ const name = match[1];
2357
+ if (!name) {
2358
+ continue;
2359
+ }
2360
+ const start = match.index + match[0].length;
2361
+ const expression = balancedValueExpression(masked, start);
2362
+ ranges.push({ name, start, end: start + expression.length });
2363
+ }
2364
+ return ranges;
2365
+ }
2366
+
2367
+ function findConstValueRangeContaining(source: string, offset: number): NamedObjectRange | undefined {
2368
+ return findNamedConstValueRanges(source).find((range) => offset >= range.start && offset <= range.end);
2369
+ }
2370
+
2371
+ function findStringLiteralsInRange(
2372
+ source: string,
2373
+ range: ObjectRange,
2374
+ ): Array<{ value: string; offset: number }> {
2375
+ const literals: Array<{ value: string; offset: number }> = [];
2376
+ let index = range.start;
2377
+ while (index <= range.end) {
2378
+ const quote = source[index];
2379
+ if (quote !== '"' && quote !== "'" && quote !== "`") {
2380
+ index += 1;
2381
+ continue;
2382
+ }
2383
+ const end = findStringEnd(source, index);
2384
+ if (end === -1) {
2385
+ break;
2386
+ }
2387
+ if (quote === "`" && source.slice(index + 1, end).includes("${")) {
2388
+ index = end + 1;
2389
+ continue;
2390
+ }
2391
+ literals.push({ value: source.slice(index + 1, end), offset: index });
2392
+ index = end + 1;
2393
+ }
2394
+ return literals;
2395
+ }
2396
+
2397
+ function isVendorTimestampCandidate(value: string, key: string | undefined): boolean {
2398
+ if (/^\d{8}$/.test(value)) {
2399
+ return isPlausibleCompactDate(value);
2400
+ }
2401
+ if (/^\d{12}$/.test(value)) {
2402
+ return isPlausibleCompactDate(value.slice(0, 8)) && isPlausibleHourMinute(value.slice(8, 12));
2403
+ }
2404
+ if (/^\d{14}$/.test(value)) {
2405
+ const seconds = Number(value.slice(12, 14));
2406
+ return (
2407
+ isPlausibleCompactDate(value.slice(0, 8)) &&
2408
+ isPlausibleHourMinute(value.slice(8, 12)) &&
2409
+ seconds >= 0 &&
2410
+ seconds <= 59
2411
+ );
2412
+ }
2413
+ if (
2414
+ /^\d{4}$/.test(value) &&
2415
+ key !== undefined &&
2416
+ /(?:^|_)at$|At$|time|date|open|close|updated|created/i.test(key)
2417
+ ) {
2418
+ return isPlausibleHourMinute(value);
2419
+ }
2420
+ return false;
2421
+ }
2422
+
2423
+ function isPlausibleCompactDate(value: string): boolean {
2424
+ const year = Number(value.slice(0, 4));
2425
+ const month = Number(value.slice(4, 6));
2426
+ const day = Number(value.slice(6, 8));
2427
+ return year >= 1900 && year <= 2099 && month >= 1 && month <= 12 && day >= 1 && day <= 31;
2428
+ }
2429
+
2430
+ function isPlausibleHourMinute(value: string): boolean {
2431
+ const hour = Number(value.slice(0, 2));
2432
+ const minute = Number(value.slice(2, 4));
2433
+ return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59;
2434
+ }
2435
+
2436
+ function findNextNonWhitespace(source: string, start: number): number {
2437
+ for (let index = start; index < source.length; index += 1) {
2438
+ if (!/\s/.test(source[index] ?? "")) {
2439
+ return index;
2440
+ }
2441
+ }
2442
+ return -1;
2443
+ }
2444
+
2445
+ function findMatchingBracket(source: string, openIndex: number): number {
2446
+ const open = source[openIndex];
2447
+ const close = open === "{" ? "}" : open === "(" ? ")" : open === "[" ? "]" : undefined;
2448
+ if (!close) {
2449
+ return -1;
2450
+ }
2451
+ let depth = 0;
2452
+ for (let index = openIndex; index < source.length; index += 1) {
2453
+ const char = source[index];
2454
+ if (char === '"' || char === "'" || char === "`") {
2455
+ const stringEnd = findStringEnd(source, index);
2456
+ if (stringEnd === -1) {
2457
+ return -1;
2458
+ }
2459
+ index = stringEnd;
2460
+ continue;
2461
+ }
2462
+ if (char === open) {
2463
+ depth += 1;
2464
+ } else if (char === close) {
2465
+ depth -= 1;
2466
+ if (depth === 0) {
2467
+ return index;
2468
+ }
2469
+ }
2470
+ }
2471
+ return -1;
2472
+ }
2473
+
2474
+ function findStringEnd(source: string, start: number): number {
2475
+ const quote = source[start];
2476
+ for (let index = start + 1; index < source.length; index += 1) {
2477
+ if (source[index] === "\\") {
2478
+ index += 1;
2479
+ continue;
2480
+ }
2481
+ if (source[index] === quote) {
2482
+ return index;
2483
+ }
2484
+ }
2485
+ return -1;
2486
+ }
2487
+
2488
+ function maskCommentsAndStrings(source: string): string {
2489
+ const chars = source.split("");
2490
+ for (let index = 0; index < source.length; index += 1) {
2491
+ if (source.startsWith("//", index)) {
2492
+ const bodyStart = index + 2;
2493
+ const newline = source.indexOf("\n", bodyStart);
2494
+ const end = newline === -1 ? source.length : newline;
2495
+ for (let bodyIndex = bodyStart; bodyIndex < end; bodyIndex += 1) {
2496
+ chars[bodyIndex] = " ";
2497
+ }
2498
+ index = end;
2499
+ continue;
2500
+ }
2501
+ if (source.startsWith("/*", index)) {
2502
+ const bodyStart = index + 2;
2503
+ const close = source.indexOf("*/", bodyStart);
2504
+ const end = close === -1 ? source.length : close;
2505
+ for (let bodyIndex = bodyStart; bodyIndex < end; bodyIndex += 1) {
2506
+ if (chars[bodyIndex] !== "\n") {
2507
+ chars[bodyIndex] = " ";
2508
+ }
2509
+ }
2510
+ index = close === -1 ? source.length : close + 1;
2511
+ continue;
2512
+ }
2513
+ const quote = source[index];
2514
+ if (quote !== '"' && quote !== "'" && quote !== "`") {
2515
+ continue;
2516
+ }
2517
+ const end = findStringEnd(source, index);
2518
+ if (end === -1) {
2519
+ break;
2520
+ }
2521
+ // Preserve quoted property keys ("response": ...) so range/key scanners
2522
+ // can still match them; only string VALUES are blanked.
2523
+ let probe = end + 1;
2524
+ while (probe < source.length && /\s/.test(source[probe] ?? "")) {
2525
+ probe += 1;
2526
+ }
2527
+ if (source[probe] !== ":") {
2528
+ for (let bodyIndex = index + 1; bodyIndex < end; bodyIndex += 1) {
2529
+ if (chars[bodyIndex] !== "\n") {
2530
+ chars[bodyIndex] = " ";
2531
+ }
2532
+ }
2533
+ }
2534
+ index = end;
2535
+ }
2536
+ return chars.join("");
2537
+ }
2538
+
2539
+ function skipWhitespaceAndComments(source: string, start: number, end: number): number {
2540
+ let index = start;
2541
+ while (index < end) {
2542
+ if (/\s/.test(source[index] ?? "")) {
2543
+ index += 1;
2544
+ continue;
2545
+ }
2546
+ if (source.startsWith("//", index)) {
2547
+ const newline = source.indexOf("\n", index + 2);
2548
+ index = newline === -1 ? end : newline + 1;
2549
+ continue;
2550
+ }
2551
+ if (source.startsWith("/*", index)) {
2552
+ const close = source.indexOf("*/", index + 2);
2553
+ index = close === -1 ? end : close + 2;
2554
+ continue;
2555
+ }
2556
+ break;
2557
+ }
2558
+ return index;
2559
+ }
2560
+
2561
+ function skipObjectValue(source: string, start: number, end: number): number {
2562
+ let index = start;
2563
+ while (index < end) {
2564
+ const char = source[index];
2565
+ if (char === '"' || char === "'" || char === "`") {
2566
+ const stringEnd = findStringEnd(source, index);
2567
+ if (stringEnd === -1) {
2568
+ return end;
2569
+ }
2570
+ index = stringEnd + 1;
2571
+ continue;
2572
+ }
2573
+ if (char === "{" || char === "(" || char === "[") {
2574
+ const close = findMatchingBracket(source, index);
2575
+ if (close === -1) {
2576
+ return end;
2577
+ }
2578
+ index = close + 1;
2579
+ continue;
2580
+ }
2581
+ if (char === "," || char === "}") {
2582
+ return index;
2583
+ }
2584
+ index += 1;
2585
+ }
2586
+ return index;
2587
+ }
2588
+
2589
+ function rangeContainsOffset(ranges: readonly ObjectRange[], offset: number): boolean {
2590
+ return ranges.some((range) => offset >= range.start && offset <= range.end);
2591
+ }
2592
+
2593
+ function rangeContainedInRanges(ranges: readonly ObjectRange[], candidate: ObjectRange): boolean {
2594
+ return ranges.some((range) => candidate.start >= range.start && candidate.end <= range.end);
2595
+ }
2596
+
2597
+ function propertyKeyForStringLiteral(source: string, literalOffset: number): string | undefined {
2598
+ const masked = maskCommentsAndStrings(source);
2599
+ let index = skipWhitespaceBackward(masked, literalOffset - 1);
2600
+ if (masked[index] !== ":") {
2601
+ return undefined;
2602
+ }
2603
+ index = skipWhitespaceBackward(masked, index - 1);
2604
+ if (index < 0) {
2605
+ return undefined;
2606
+ }
2607
+ if (source[index] === '"' || source[index] === "'") {
2608
+ const quote = source[index];
2609
+ let start = index - 1;
2610
+ while (start >= 0) {
2611
+ if (source[start] === quote && source[start - 1] !== "\\") {
2612
+ return source.slice(start + 1, index);
2613
+ }
2614
+ start -= 1;
2615
+ }
2616
+ return undefined;
2617
+ }
2618
+ const keyMatch = /[A-Za-z_$][\w$]*$/.exec(masked.slice(0, index + 1));
2619
+ return keyMatch?.[0];
2620
+ }
2621
+
2622
+ function skipWhitespaceBackward(source: string, start: number): number {
2623
+ let index = start;
2624
+ while (index >= 0 && /\s/.test(source[index] ?? "")) {
2625
+ index -= 1;
2626
+ }
2627
+ return index;
2628
+ }
2629
+
2630
+ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
2631
+ const operations = Object.entries(provider.operations);
2632
+ const missing: string[] = [];
2633
+ const vacuous: string[] = [];
2634
+ const placeholder: string[] = [];
2635
+ const unsupported: string[] = [];
2636
+ const generatedStarter: string[] = [];
2637
+
2638
+ for (const [operationId, operation] of operations) {
2639
+ const hasCheck = operation.healthCheck !== undefined;
2640
+ const hasUnsupported = operation.healthCheckUnsupported !== undefined;
2641
+ if (!hasCheck && !hasUnsupported) {
2642
+ missing.push(operationId);
2643
+ continue;
2644
+ }
2645
+ if (hasCheck && !hasUnsupported && hasOnlyVacuousHealthCases(operation.healthCheck)) {
2646
+ vacuous.push(operationId);
2647
+ }
2648
+ if (hasUnsupported) {
2649
+ const reason = operation.healthCheckUnsupported?.reason ?? "";
2650
+ unsupported.push(operationId);
2651
+ if (/generated local-only scaffold/i.test(reason)) {
2652
+ generatedStarter.push(operationId);
2653
+ }
2654
+ if (
2655
+ /(todo|later|tbd|test fixture|unit test|placeholder|not sure|skip for test)/i.test(reason)
2656
+ ) {
2657
+ placeholder.push(operationId);
2658
+ }
2659
+ }
2660
+ }
2661
+
2662
+ if (missing.length > 0) {
2663
+ return blocker(
2664
+ "health-coverage",
2665
+ "health",
2666
+ "One or more operations lack healthCheck or healthCheckUnsupported.",
2667
+ `For ${missing.join(", ")}, add \`healthCheck: { interval, cases }\` for safe read-only upstream probes, or add \`healthCheckUnsupported: { reason: "<specific reason>" }\`.`,
2668
+ CATEGORY_MAX_POINTS.health,
2669
+ missing,
2670
+ );
2671
+ }
2672
+
2673
+ if (vacuous.length > 0) {
2674
+ return blocker(
2675
+ "health-coverage",
2676
+ "health",
2677
+ "One or more operations have healthCheck cases with empty assertions.",
2678
+ `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.`,
2679
+ CATEGORY_MAX_POINTS.health,
2680
+ vacuous.map((operationId) => `${operationId}: empty healthCheck.assertions`),
2681
+ );
2682
+ }
2683
+
2684
+ if (placeholder.length > 0) {
2685
+ return {
2686
+ id: "health-coverage",
2687
+ category: "health",
2688
+ level: "warn",
2689
+ status: "warn",
2690
+ points: 8,
2691
+ maxPoints: CATEGORY_MAX_POINTS.health,
2692
+ message: "Some healthCheckUnsupported reasons look placeholder-like.",
2693
+ remediation: `For ${placeholder.join(", ")}, replace the placeholder \`healthCheckUnsupported.reason\` with a specific reason such as destructive mutation, paid call, credential sensitivity, or upstream flakiness.`,
2694
+ evidence: placeholder,
2695
+ };
2696
+ }
2697
+
2698
+ if (generatedStarter.length > 0) {
2699
+ return {
2700
+ id: "health-coverage",
2701
+ category: "health",
2702
+ level: "warn",
2703
+ status: "warn",
2704
+ points: 10,
2705
+ maxPoints: CATEGORY_MAX_POINTS.health,
2706
+ message:
2707
+ "Generated starter operation health rationale is present; replace starter logic before bounty submission.",
2708
+ remediation: `Replace generated starter operation(s) ${generatedStarter.join(", ")} with real upstream-backed operations and add \`healthCheck\` for safe read-only probes.`,
2709
+ evidence: generatedStarter,
2710
+ };
2711
+ }
2712
+
2713
+ if (unsupported.length > 0) {
2714
+ return {
2715
+ id: "health-coverage",
2716
+ category: "health",
2717
+ level: "warn",
2718
+ status: "warn",
2719
+ points: 12,
2720
+ maxPoints: CATEGORY_MAX_POINTS.health,
2721
+ message: "Health coverage is declared, with one or more unsupported probes.",
2722
+ 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.`,
2723
+ evidence: unsupported.map((operationId) => `${operationId}: healthCheckUnsupported`),
2724
+ };
2725
+ }
2726
+
2727
+ return pass(
2728
+ "health-coverage",
2729
+ "health",
2730
+ "All operations declare real health checks.",
2731
+ CATEGORY_MAX_POINTS.health,
2732
+ );
2733
+ }
2734
+
2735
+ function hasOnlyVacuousHealthCases(
2736
+ healthCheck: ProviderDefinition["operations"][string]["healthCheck"],
2737
+ ): boolean {
2738
+ const cases = healthCheck?.cases;
2739
+ if (!Array.isArray(cases) || cases.length === 0) {
2740
+ return true;
2741
+ }
2742
+ return cases.every((healthCase) => isVacuousAssertionFunction(healthCase?.assertions));
2743
+ }
2744
+
2745
+ function isVacuousAssertionFunction(assertions: unknown): boolean {
2746
+ if (typeof assertions !== "function") {
2747
+ return true;
2748
+ }
2749
+
2750
+ let source: string;
2751
+ try {
2752
+ source = Function.prototype.toString.call(assertions);
2753
+ } catch {
2754
+ return false;
2755
+ }
2756
+
2757
+ // Native / bound functions stringify to `function () { [native code] }` with
2758
+ // no inspectable body or params. The underlying implementation may inspect
2759
+ // ctx, so fail open (do not flag) rather than mistake it for an empty body.
2760
+ if (/\[native code\]/.test(source)) {
2761
+ return false;
2762
+ }
2763
+
2764
+ const fn = parseAssertionFunction(source);
2765
+ if (!fn) {
2766
+ // Unparseable source → fail open (treat as a real assertion). A false
2767
+ // negative here only misses a no-op; a false positive would wrongly
2768
+ // reject a valid contributor.
2769
+ return false;
2770
+ }
2771
+
2772
+ // A real health assertion MUST either throw when the upstream contract
2773
+ // breaks, or inspect the probe response, which is delivered exclusively
2774
+ // through the assertion's own parameter(s). Working on the parsed AST (not
2775
+ // text) makes this precise at the syntactic layer: a `throw` only counts
2776
+ // when it is a real ThrowStatement in THIS function's body (not inside a
2777
+ // nested, uninvoked function), and a parameter reference is checked against
2778
+ // the actual bound names (destructuring binds the local alias, not the
2779
+ // property key). This closes the whole equivalent-no-op class — empty
2780
+ // bodies, `void 0`, `({})`, `Promise.resolve()`, `await Promise.resolve()`,
2781
+ // `.then()`, `new Promise(r => r())`, side-effect-only bodies, throws hidden
2782
+ // in uninvoked closures — without enumerating spellings.
2783
+ if (functionThrows(fn)) {
2784
+ return false;
2785
+ }
2786
+ const bound = new Set<string>();
2787
+ for (const param of fn.params) {
2788
+ collectBoundNames(param, bound);
2789
+ }
2790
+ if (bound.size === 0) {
2791
+ return true;
2792
+ }
2793
+ // A parameter reference anywhere in the (reachable) body is treated as
2794
+ // inspecting the response. This is deliberately syntactic, not a dataflow
2795
+ // analysis.
2796
+ //
2797
+ // KNOWN LIMITATION (accepted): a body that reads the parameter but never
2798
+ // turns that read into an outcome — no throw, no returned verdict — still
2799
+ // passes, e.g. `({ status }) => { console.info(status); }`. Precisely
2800
+ // rejecting it would require tracking whether the read flows to a throw
2801
+ // argument or return value through arbitrary local bindings and invoked
2802
+ // helpers (`const ok = ctx.output.ok; return ok ? ...` / a called helper that
2803
+ // throws). That is transitive use-def dataflow, and an imprecise version
2804
+ // FALSE-BLOCKS real assertions of exactly those shapes — verified
2805
+ // empirically. Under the fail-open contract (rejecting a real contributor is
2806
+ // strictly worse than missing a no-op) we accept the miss here. This gate
2807
+ // stops accidental/lazy no-ops (empty bodies, `void 0`, `Promise.resolve()`,
2808
+ // throws in uninvoked closures); a determined bypass via a decorative ctx
2809
+ // read is no easier than writing the real one-line `throw`, and the actual
2810
+ // defense against a runtime-empty assertion is the live `--smoke` probe.
2811
+ return !referencesBoundNames(fn, bound);
2812
+ }
2813
+
2814
+ type AssertionFunctionNode =
2815
+ | acorn.ArrowFunctionExpression
2816
+ | acorn.FunctionExpression
2817
+ | acorn.FunctionDeclaration;
2818
+
2819
+ function isFunctionNode(node: acorn.AnyNode): node is AssertionFunctionNode {
2820
+ return (
2821
+ node.type === "ArrowFunctionExpression" ||
2822
+ node.type === "FunctionExpression" ||
2823
+ node.type === "FunctionDeclaration"
2824
+ );
2825
+ }
2826
+
2827
+ /**
2828
+ * Parse the `Function.prototype.toString()` output of an assertion into its AST
2829
+ * function node. The stringified form can be an arrow (`(a) => {}`), a function
2830
+ * expression (`function (a) {}`), or a bare method (`foo() {}`), so try a few
2831
+ * wrappers until one parses. Returns undefined on any parse failure so callers
2832
+ * fail open.
2833
+ */
2834
+ function parseAssertionFunction(source: string): AssertionFunctionNode | undefined {
2835
+ const candidates = [source, `(${source})`, `({${source}})`];
2836
+ for (const candidate of candidates) {
2837
+ let program: acorn.Program;
2838
+ try {
2839
+ program = acorn.parse(candidate, { ecmaVersion: "latest" });
2840
+ } catch {
2841
+ continue;
2842
+ }
2843
+ const fn = findFirstFunction(program);
2844
+ if (fn) {
2845
+ return fn;
2846
+ }
2847
+ }
2848
+ return undefined;
2849
+ }
2850
+
2851
+ /** Depth-first search for the first function node in a parsed program. */
2852
+ function findFirstFunction(root: acorn.AnyNode): AssertionFunctionNode | undefined {
2853
+ let found: AssertionFunctionNode | undefined;
2854
+ walkAst(root, (node) => {
2855
+ if (found) {
2856
+ return false;
2857
+ }
2858
+ if (isFunctionNode(node)) {
2859
+ found = node;
2860
+ return false;
2861
+ }
2862
+ return true;
2863
+ });
2864
+ return found;
2865
+ }
2866
+
2867
+ /**
2868
+ * Collect the identifier names actually BOUND by a parameter pattern. For
2869
+ * destructuring, the binding is the local target (`value`), not the source
2870
+ * property key — so `({ status: ignored })` binds `ignored`, and a body that
2871
+ * merely mentions `status` is not referencing a parameter.
2872
+ */
2873
+ function collectBoundNames(pattern: acorn.Pattern | null, out: Set<string>): void {
2874
+ if (!pattern) {
2875
+ return;
2876
+ }
2877
+ switch (pattern.type) {
2878
+ case "Identifier":
2879
+ out.add(pattern.name);
2880
+ break;
2881
+ case "AssignmentPattern":
2882
+ collectBoundNames(pattern.left, out);
2883
+ break;
2884
+ case "RestElement":
2885
+ collectBoundNames(pattern.argument, out);
2886
+ break;
2887
+ case "ArrayPattern":
2888
+ for (const element of pattern.elements) {
2889
+ collectBoundNames(element, out);
2890
+ }
2891
+ break;
2892
+ case "ObjectPattern":
2893
+ for (const property of pattern.properties) {
2894
+ if (property.type === "RestElement") {
2895
+ collectBoundNames(property.argument, out);
2896
+ } else {
2897
+ // `.value` is the local binding target, `.key` is the source
2898
+ // property name — bind only the former.
2899
+ collectBoundNames(property.value, out);
2900
+ }
2901
+ }
2902
+ break;
2903
+ }
2904
+ }
2905
+
2906
+ /**
2907
+ * True if the function contains a real `throw` statement in ITS OWN body —
2908
+ * descending through control flow but NOT into nested functions, whose throws
2909
+ * do not execute unless that nested function is invoked.
2910
+ */
2911
+ function functionThrows(fn: AssertionFunctionNode): boolean {
2912
+ if (fn.body.type !== "BlockStatement") {
2913
+ // Concise arrow returning an expression cannot contain a throw statement.
2914
+ return false;
2915
+ }
2916
+ let throws = false;
2917
+ walkAst(fn.body, (node) => {
2918
+ if (throws) {
2919
+ return false;
2920
+ }
2921
+ if (node.type === "ThrowStatement") {
2922
+ throws = true;
2923
+ return false;
2924
+ }
2925
+ // Do not descend into nested function bodies.
2926
+ if (isFunctionNode(node)) {
2927
+ return false;
2928
+ }
2929
+ return true;
2930
+ });
2931
+ return throws;
2932
+ }
2933
+
2934
+ /**
2935
+ * True if the function's body references any of the given bound parameter names
2936
+ * as an actual value. Property KEYS (`{ status: ... }`, `obj.status`) are not
2937
+ * references; computed members (`obj[status]`) are. Nested functions that
2938
+ * re-bind the same name shadow it, so their bodies are searched with the
2939
+ * shadowed name removed from the target set.
2940
+ */
2941
+ function referencesBoundNames(fn: AssertionFunctionNode, bound: Set<string>): boolean {
2942
+ if (bound.size === 0) {
2943
+ return false;
2944
+ }
2945
+ let referenced = false;
2946
+ walkAstValues(fn.body, bound, fn.body, null, null, () => {
2947
+ referenced = true;
2948
+ });
2949
+ return referenced;
2950
+ }
2951
+
2952
+ /**
2953
+ * Walk `node`, invoking `onReference` when an Identifier in value position
2954
+ * matches a name in `names`. Skips property keys and non-computed member
2955
+ * properties. On entering a nested function, removes any parameter names it
2956
+ * rebinds (shadowing) from the active set for that subtree, and does NOT descend
2957
+ * into a PROVABLY-UNINVOKED helper — a function bound to a local name that is
2958
+ * never referenced again anywhere in the assertion body, so it cannot run when
2959
+ * the assertion runs (e.g. `(ctx) => { const later = () => ctx.status; }`). Its
2960
+ * parameter reads therefore must not count as inspecting the response, mirroring
2961
+ * how `functionThrows` ignores throws inside nested functions.
2962
+ *
2963
+ * Crucially, a helper that IS referenced again (a call site like `check()`) is
2964
+ * NOT skipped — its body is searched — so real assertions that factor the check
2965
+ * into a local helper still pass. Immediately-invoked callbacks (`.every(cb)`,
2966
+ * IIFEs, callees) are likewise searched. When in doubt we descend (fail open):
2967
+ * the only skip is a helper we can prove is never invoked.
2968
+ */
2969
+ function walkAstValues(
2970
+ node: acorn.AnyNode,
2971
+ names: Set<string>,
2972
+ outerBody: acorn.AnyNode,
2973
+ parent: acorn.AnyNode | null,
2974
+ parentKey: string | null,
2975
+ onReference: () => void,
2976
+ ): void {
2977
+ if (names.size === 0) {
2978
+ return;
2979
+ }
2980
+ if (node.type === "Identifier") {
2981
+ if (names.has(node.name)) {
2982
+ onReference();
2983
+ }
2984
+ return;
2985
+ }
2986
+ // Nested function: subtract its own parameter bindings (shadowing) before
2987
+ // descending into its body — and skip it only when it is a provably
2988
+ // uninvoked helper.
2989
+ if (isFunctionNode(node)) {
2990
+ const shadowed = new Set<string>();
2991
+ for (const param of node.params) {
2992
+ collectBoundNames(param, shadowed);
2993
+ }
2994
+ const visible = new Set<string>();
2995
+ for (const name of names) {
2996
+ if (!shadowed.has(name)) {
2997
+ visible.add(name);
2998
+ }
2999
+ }
3000
+ if (visible.size === 0 || isProvablyUninvokedHelper(node, parent, parentKey, outerBody)) {
3001
+ return;
3002
+ }
3003
+ for (const [key, child] of childEntries(node)) {
3004
+ if (key === "params") {
3005
+ continue;
3006
+ }
3007
+ walkAstValues(child, visible, outerBody, node, key, onReference);
3008
+ }
3009
+ return;
3010
+ }
3011
+ for (const [key, child] of childEntries(node)) {
3012
+ // Skip non-computed property keys (`{ status: x }`) and member
3013
+ // properties (`obj.status`) — these are names, not references.
3014
+ if (key === "key" && node.type === "Property" && !node.computed) {
3015
+ continue;
3016
+ }
3017
+ if (key === "property" && node.type === "MemberExpression" && !node.computed) {
3018
+ continue;
3019
+ }
3020
+ walkAstValues(child, names, outerBody, node, key, onReference);
3021
+ }
3022
+ }
3023
+
3024
+ /**
3025
+ * True if `fn` is a local helper bound to a name that is NEVER referenced again
3026
+ * anywhere in `outerBody` — meaning it is never invoked, so its body does not run
3027
+ * as part of evaluating the assertion. Only these provably-dead helpers are
3028
+ * skipped; a helper with any call site (its name appearing more than once, i.e.
3029
+ * beyond its own declaration) is treated as potentially executed and searched.
3030
+ * Anonymous functions in expression position (call args, callees, returns) are
3031
+ * never "uninvoked helpers" — they may run — so they are not skipped here.
3032
+ */
3033
+ function isProvablyUninvokedHelper(
3034
+ fn: AssertionFunctionNode,
3035
+ parent: acorn.AnyNode | null,
3036
+ parentKey: string | null,
3037
+ outerBody: acorn.AnyNode,
3038
+ ): boolean {
3039
+ let helperName: string | undefined;
3040
+ if (fn.type === "FunctionDeclaration" && fn.id) {
3041
+ helperName = fn.id.name;
3042
+ } else if (
3043
+ parent &&
3044
+ parent.type === "VariableDeclarator" &&
3045
+ parentKey === "init" &&
3046
+ parent.id.type === "Identifier"
3047
+ ) {
3048
+ helperName = parent.id.name;
3049
+ }
3050
+ if (helperName === undefined) {
3051
+ // Not a name-bound helper (anonymous callback / expression). It may run,
3052
+ // so do not skip it.
3053
+ return false;
3054
+ }
3055
+ // Count every occurrence of the helper name in the assertion body. Exactly
3056
+ // one occurrence is its own binding declaration; more than one means there is
3057
+ // at least one reference (call site), so the helper can run.
3058
+ let occurrences = 0;
3059
+ walkAst(outerBody, (node) => {
3060
+ if (node.type === "Identifier" && node.name === helperName) {
3061
+ occurrences += 1;
3062
+ }
3063
+ return true;
3064
+ });
3065
+ return occurrences <= 1;
3066
+ }
3067
+
3068
+ /** Generic pre-order AST walk; `visit` returns false to stop descending. */
3069
+ function walkAst(node: acorn.AnyNode, visit: (node: acorn.AnyNode) => boolean): void {
3070
+ if (!visit(node)) {
3071
+ return;
3072
+ }
3073
+ for (const child of childNodes(node)) {
3074
+ walkAst(child, visit);
3075
+ }
3076
+ }
3077
+
3078
+ function* childNodes(node: acorn.AnyNode): Generator<acorn.AnyNode> {
3079
+ for (const [, child] of childEntries(node)) {
3080
+ yield child;
3081
+ }
3082
+ }
3083
+
3084
+ function* childEntries(node: acorn.AnyNode): Generator<[string, acorn.AnyNode]> {
3085
+ for (const key of Object.keys(node)) {
3086
+ if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") {
3087
+ continue;
3088
+ }
3089
+ const value = (node as unknown as Record<string, unknown>)[key];
3090
+ if (Array.isArray(value)) {
3091
+ for (const item of value) {
3092
+ if (isAstNode(item)) {
3093
+ yield [key, item];
3094
+ }
3095
+ }
3096
+ } else if (isAstNode(value)) {
3097
+ yield [key, value];
3098
+ }
3099
+ }
3100
+ }
3101
+
3102
+ function isAstNode(value: unknown): value is acorn.AnyNode {
3103
+ return (
3104
+ typeof value === "object" &&
3105
+ value !== null &&
3106
+ typeof (value as { type?: unknown }).type === "string"
3107
+ );
3108
+ }
3109
+
3110
+ function scoreSmoke(
3111
+ smokeResult: SmokeResult | undefined,
3112
+ smokeNote: string | undefined,
3113
+ ): SubmitCheck {
3114
+ const deprecatedEvidence = smokeNote?.trim()
3115
+ ? ["Deprecated --smoke-note was provided and ignored for scoring."]
3116
+ : [];
3117
+ if (!smokeResult) {
3118
+ return {
3119
+ id: "local-smoke",
3120
+ category: "smoke",
3121
+ level: "warn",
3122
+ status: "warn",
3123
+ points: 0,
3124
+ maxPoints: CATEGORY_MAX_POINTS.smoke,
3125
+ message: "Measured local smoke was not run.",
3126
+ remediation:
3127
+ "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.",
3128
+ evidence: deprecatedEvidence,
3129
+ };
3130
+ }
3131
+
3132
+ const evidence = [
3133
+ `/health: ${smokeResult.healthOk ? "ok" : "failed"}`,
3134
+ ...smokeResult.operations.map(
3135
+ (outcome) =>
3136
+ `${outcome.operationId}: ${outcome.status}${outcome.httpStatus ? ` HTTP ${outcome.httpStatus}` : ""} - ${outcome.message}`,
3137
+ ),
3138
+ ...deprecatedEvidence,
3139
+ ];
3140
+ const incoherent = smokeResult.operations.filter((outcome) => outcome.status === "incoherent");
3141
+ if (!smokeResult.healthOk || smokeResult.bootError || incoherent.length > 0) {
3142
+ return {
3143
+ id: "local-smoke",
3144
+ category: "smoke",
3145
+ level: "blocker",
3146
+ status: "fail",
3147
+ points: 0,
3148
+ maxPoints: CATEGORY_MAX_POINTS.smoke,
3149
+ message: "Measured smoke failed to verify a coherent provider runtime.",
3150
+ remediation:
3151
+ "Fix the dev server boot, `/health`, or incoherent operation responses, then rerun `bun run submit-check -- --smoke`.",
3152
+ evidence: smokeResult.bootError ? [`boot: ${smokeResult.bootError}`, ...evidence] : evidence,
3153
+ details: smokeResult,
3154
+ };
3155
+ }
3156
+
3157
+ const successes = smokeResult.operations.filter((outcome) => outcome.status === "success");
3158
+ if (successes.length > 0) {
3159
+ return {
3160
+ id: "local-smoke",
3161
+ category: "smoke",
3162
+ level: "info",
3163
+ status: "pass",
3164
+ points: CATEGORY_MAX_POINTS.smoke,
3165
+ maxPoints: CATEGORY_MAX_POINTS.smoke,
3166
+ message: "Measured smoke passed with at least one schema-valid operation success.",
3167
+ evidence,
3168
+ details: smokeResult,
3169
+ };
3170
+ }
3171
+
3172
+ return {
3173
+ id: "local-smoke",
3174
+ category: "smoke",
3175
+ level: "warn",
3176
+ status: "warn",
3177
+ points: 7,
3178
+ maxPoints: CATEGORY_MAX_POINTS.smoke,
3179
+ message: "Runtime path was verified, but no live upstream schema-valid success was observed.",
3180
+ remediation:
3181
+ "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.",
3182
+ evidence,
3183
+ details: smokeResult,
3184
+ };
3185
+ }
3186
+
3187
+ export async function runSubmitCheckSmoke(
3188
+ providerRoot: string,
3189
+ provider?: ProviderDefinition,
3190
+ ): Promise<SmokeResult> {
3191
+ const loadedProvider = provider ?? (await loadProvider(providerRoot));
3192
+ if (!loadedProvider) {
3193
+ return {
3194
+ measured: true,
3195
+ healthOk: false,
3196
+ bootError: "Provider could not be loaded.",
3197
+ operations: [],
3198
+ };
3199
+ }
3200
+
3201
+ const port = await getAvailablePort();
3202
+ const server = spawn("bun", ["run", "dev"], {
3203
+ cwd: providerRoot,
3204
+ env: { ...process.env, APIFUSE__RUNTIME__PORT: String(port) },
3205
+ detached: process.platform !== "win32",
3206
+ stdio: ["ignore", "pipe", "pipe"],
3207
+ });
3208
+ let output = "";
3209
+ server.stdout?.on("data", (chunk) => {
3210
+ output += chunk.toString();
3211
+ });
3212
+ server.stderr?.on("data", (chunk) => {
3213
+ output += chunk.toString();
3214
+ });
3215
+
3216
+ try {
3217
+ const baseUrl = `http://127.0.0.1:${port}`;
3218
+ const health = await waitForSmokeHealth(`${baseUrl}/health`, server, () => output);
3219
+ if (!health.ok) {
3220
+ return {
3221
+ measured: true,
3222
+ healthOk: false,
3223
+ bootError: health.error,
3224
+ operations: [],
3225
+ };
3226
+ }
3227
+ const operations: SmokeOperationOutcome[] = [];
3228
+ for (const [operationId, operation] of Object.entries(loadedProvider.operations)) {
3229
+ operations.push(
3230
+ await smokeOperation(baseUrl, operationId, operation.output, {
3231
+ requestId: `req_submit_check_smoke_${operationId}`,
3232
+ input: operation.fixtures?.request ?? {},
3233
+ headers: {},
3234
+ }),
3235
+ );
3236
+ }
3237
+ return { measured: true, healthOk: true, operations };
3238
+ } finally {
3239
+ await stopSmokeServer(server);
3240
+ }
3241
+ }
3242
+
3243
+ async function smokeOperation(
3244
+ baseUrl: string,
3245
+ operationId: string,
3246
+ outputSchema: ProviderDefinition["operations"][string]["output"],
3247
+ body: unknown,
3248
+ ): Promise<SmokeOperationOutcome> {
3249
+ try {
3250
+ const response = await fetch(`${baseUrl}/v1/${operationId}`, {
3251
+ method: "POST",
3252
+ headers: { "content-type": "application/json" },
3253
+ body: JSON.stringify(body),
3254
+ signal: AbortSignal.timeout(20_000),
3255
+ });
3256
+ const payload = await response.json().catch(() => undefined);
3257
+ if (response.ok && isRecord(payload) && "data" in payload) {
3258
+ const parsed = safeParseSchemaSync(
3259
+ outputSchema,
3260
+ payload.data,
3261
+ `operations.${operationId}.output`,
3262
+ );
3263
+ if (parsed.success) {
3264
+ return {
3265
+ operationId,
3266
+ status: "success",
3267
+ httpStatus: response.status,
3268
+ message: "schema-valid success",
3269
+ };
3270
+ }
3271
+ return {
3272
+ operationId,
3273
+ status: "incoherent",
3274
+ httpStatus: response.status,
3275
+ message: "success payload failed output schema validation",
3276
+ };
3277
+ }
3278
+ if (isStructuredProviderError(payload) && response.status < 500) {
3279
+ return {
3280
+ operationId,
3281
+ status: "structured_error",
3282
+ httpStatus: response.status,
3283
+ message: `${payload.error.code}: ${payload.error.message}`,
3284
+ };
3285
+ }
3286
+ return {
3287
+ operationId,
3288
+ status: "incoherent",
3289
+ httpStatus: response.status,
3290
+ message: isStructuredProviderError(payload)
3291
+ ? `${payload.error.code}: ${payload.error.message}`
3292
+ : "response was not a schema-valid success or structured provider error",
3293
+ };
3294
+ } catch (error) {
3295
+ return {
3296
+ operationId,
3297
+ status: "incoherent",
3298
+ message: error instanceof Error ? error.message : String(error),
3299
+ };
3300
+ }
3301
+ }
3302
+
3303
+ function isStructuredProviderError(
3304
+ value: unknown,
3305
+ ): value is { error: { code: string; message: string } } {
3306
+ return (
3307
+ isRecord(value) &&
3308
+ isRecord(value.error) &&
3309
+ typeof value.error.code === "string" &&
3310
+ typeof value.error.message === "string"
3311
+ );
3312
+ }
3313
+
3314
+ async function getAvailablePort(): Promise<number> {
3315
+ return await new Promise((resolvePromise, rejectPromise) => {
3316
+ const server = createServer();
3317
+ server.once("error", rejectPromise);
3318
+ server.listen(0, "127.0.0.1", () => {
3319
+ const address = server.address();
3320
+ server.close((error) => {
3321
+ if (error) {
3322
+ rejectPromise(error);
3323
+ return;
3324
+ }
3325
+ if (!address || typeof address === "string") {
3326
+ rejectPromise(new Error("Could not allocate a local TCP port."));
3327
+ return;
3328
+ }
3329
+ resolvePromise(address.port);
3330
+ });
3331
+ });
3332
+ });
3333
+ }
3334
+
3335
+ async function waitForSmokeHealth(
3336
+ url: string,
3337
+ server: ChildProcess,
3338
+ getOutput: () => string,
3339
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
3340
+ const deadline = Date.now() + 20_000;
3341
+ let lastError: unknown;
3342
+
3343
+ while (Date.now() < deadline) {
3344
+ if (server.exitCode !== null) {
3345
+ return {
3346
+ ok: false,
3347
+ error: `Dev server exited early with code ${server.exitCode}. ${getOutput()}`,
3348
+ };
3349
+ }
3350
+
3351
+ try {
3352
+ const response = await fetch(url, { signal: AbortSignal.timeout(1_000) });
3353
+ if (response.ok) return { ok: true };
3354
+ lastError = new Error(`${url} returned ${response.status}`);
3355
+ } catch (error) {
3356
+ lastError = error;
3357
+ }
3358
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 200));
3359
+ }
3360
+
3361
+ return {
3362
+ ok: false,
3363
+ error: `Timed out waiting for ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}. ${getOutput()}`,
3364
+ };
3365
+ }
3366
+
3367
+ async function stopSmokeServer(server: ChildProcess): Promise<void> {
3368
+ if (server.exitCode !== null) return;
3369
+ killSmokeProcessTree(server, "SIGTERM");
3370
+ await new Promise<void>((resolvePromise) => {
3371
+ const timeout = setTimeout(() => {
3372
+ if (server.exitCode === null) {
3373
+ killSmokeProcessTree(server, "SIGKILL");
3374
+ }
3375
+ resolvePromise();
3376
+ }, 2_000);
3377
+ server.once("exit", () => {
3378
+ clearTimeout(timeout);
3379
+ resolvePromise();
3380
+ });
3381
+ });
3382
+ }
3383
+
3384
+ function killSmokeProcessTree(server: ChildProcess, signal: NodeJS.Signals): void {
3385
+ if (server.pid === undefined) return;
3386
+ try {
3387
+ if (process.platform === "win32") {
3388
+ server.kill(signal);
3389
+ return;
3390
+ }
3391
+ process.kill(-server.pid, signal);
3392
+ } catch {
3393
+ server.kill(signal);
3394
+ }
3395
+ }
3396
+
3397
+ function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
3398
+ const authMode = provider.auth?.mode ?? "none";
3399
+ const credentialKeys = provider.credential?.keys ?? [];
3400
+ if (authMode === "credentials" && credentialKeys.length === 0) {
3401
+ return blocker(
3402
+ "auth-safety",
3403
+ "auth",
3404
+ "Credential-backed auth mode is missing credential.keys.",
3405
+ "Declare credential.keys and document local-only connection.secrets debugging.",
3406
+ CATEGORY_MAX_POINTS.auth,
3407
+ );
3408
+ }
3409
+
3410
+ if (authMode === "oauth2" && credentialKeys.length === 0) {
3411
+ return {
3412
+ id: "auth-safety",
3413
+ category: "auth",
3414
+ level: "warn",
3415
+ status: "warn",
3416
+ points: 7,
3417
+ maxPoints: CATEGORY_MAX_POINTS.auth,
3418
+ message: "OAuth auth mode does not declare persisted credential.keys.",
3419
+ remediation:
3420
+ "Add `credential: { keys: [...] }` to `defineProvider` with the persisted OAuth token fields returned by the real token exchange.",
3421
+ };
3422
+ }
3423
+
3424
+ if (authMode === "none") {
2213
3425
  const securedOperations = Object.entries(provider.operations).filter(
2214
3426
  ([, operation]) => operation.annotations?.openWorld === false,
2215
3427
  );
@@ -2221,10 +3433,8 @@ function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
2221
3433
  status: "warn",
2222
3434
  points: 7,
2223
3435
  maxPoints: CATEGORY_MAX_POINTS.auth,
2224
- message:
2225
- "Provider is no-auth but at least one operation is not marked openWorld.",
2226
- remediation:
2227
- "Confirm auth.mode and operation annotations match the actual upstream auth model.",
3436
+ message: "Provider is no-auth but at least one operation is not marked openWorld.",
3437
+ remediation: `Either set \`auth.mode\` to the upstream auth model, or mark these public no-auth operations with \`annotations.openWorld: true\`: ${securedOperations.map(([operationId]) => operationId).join(", ")}.`,
2228
3438
  evidence: securedOperations.map(([operationId]) => operationId),
2229
3439
  };
2230
3440
  }
@@ -2266,9 +3476,7 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
2266
3476
 
2267
3477
  const points = Math.max(
2268
3478
  0,
2269
- CATEGORY_MAX_POINTS.docs -
2270
- missing.length * 2 -
2271
- (mentionsSubmitCheck ? 0 : 1),
3479
+ CATEGORY_MAX_POINTS.docs - missing.length * 2 - (mentionsSubmitCheck ? 0 : 1),
2272
3480
  );
2273
3481
 
2274
3482
  return [
@@ -2285,7 +3493,7 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
2285
3493
  : "Provider README includes expected submission guidance.",
2286
3494
  remediation:
2287
3495
  missing.length > 0 || !mentionsSubmitCheck
2288
- ? "Include Parameters, Response, Example, and submit-check evidence guidance."
3496
+ ? "Update `README.md` to include `Parameters`, `Response`, `Example`, and submit-check evidence guidance sections."
2289
3497
  : undefined,
2290
3498
  evidence: [
2291
3499
  ...missing.map(([, label]) => `missing ${label}`),
@@ -2295,9 +3503,10 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
2295
3503
  ];
2296
3504
  }
2297
3505
 
2298
- function scoreSecrets(providerRoot: string): SubmitCheck {
2299
- const findings = findSecretFindings(providerRoot);
2300
- if (findings.length > 0) {
3506
+ function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): SubmitCheck {
3507
+ const findings = findSecretFindings(providerRoot, provider?.id);
3508
+ const blockerFindings = findings.filter((finding) => finding.level !== "warn");
3509
+ if (blockerFindings.length > 0) {
2301
3510
  return {
2302
3511
  id: "secret-scan",
2303
3512
  category: "security",
@@ -2305,11 +3514,34 @@ function scoreSecrets(providerRoot: string): SubmitCheck {
2305
3514
  status: "fail",
2306
3515
  points: 0,
2307
3516
  maxPoints: CATEGORY_MAX_POINTS.security,
3517
+ message: "Potential real credential material was found in shareable files.",
3518
+ remediation:
3519
+ blockerFindings[0]?.remediation ??
3520
+ 'Move hardcoded credentials to env vars read via `ctx.env.get("APIFUSE__PROVIDER__<ID>__<NAME>")` and rotate the leaked credential.',
3521
+ evidence: blockerFindings.map(
3522
+ (finding) =>
3523
+ finding.evidence ??
3524
+ `${finding.file}${finding.line ? `:${finding.line}` : ""}: ${finding.label}`,
3525
+ ),
3526
+ };
3527
+ }
3528
+ if (findings.length > 0) {
3529
+ return {
3530
+ id: "secret-scan",
3531
+ category: "security",
3532
+ level: "warn",
3533
+ status: "warn",
3534
+ points: 8,
3535
+ maxPoints: CATEGORY_MAX_POINTS.security,
2308
3536
  message:
2309
- "Potential real credential material was found in shareable files.",
3537
+ "High-entropy source strings were found without secret-like identifier context; they may be false positives.",
2310
3538
  remediation:
2311
- "Remove real secrets from source, README, and fixtures. Use environment variables and local-only connection.secrets instead.",
2312
- evidence: findings.map((finding) => `${finding.file}: ${finding.label}`),
3539
+ '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.',
3540
+ evidence: findings.map(
3541
+ (finding) =>
3542
+ finding.evidence ??
3543
+ `${finding.file}${finding.line ? `:${finding.line}` : ""}: ${finding.label}`,
3544
+ ),
2313
3545
  };
2314
3546
  }
2315
3547
 
@@ -2321,7 +3553,7 @@ function scoreSecrets(providerRoot: string): SubmitCheck {
2321
3553
  );
2322
3554
  }
2323
3555
 
2324
- function findSecretFindings(providerRoot: string): SecretFinding[] {
3556
+ function findSecretFindings(providerRoot: string, providerId = "<ID>"): SecretFinding[] {
2325
3557
  const candidateFiles = [
2326
3558
  "README.md",
2327
3559
  "index.ts",
@@ -2342,14 +3574,155 @@ function findSecretFindings(providerRoot: string): SecretFinding[] {
2342
3574
  }
2343
3575
  }
2344
3576
 
3577
+ findings.push(...findEntropySecretFindings(providerRoot, providerId));
3578
+ return findings;
3579
+ }
3580
+
3581
+ function findEntropySecretFindings(providerRoot: string, providerId: string): SecretFinding[] {
3582
+ const findings: SecretFinding[] = [];
3583
+ for (const filePath of listNonTestProviderSourceFiles(providerRoot)) {
3584
+ const relativePath = toRelativeProviderPath(providerRoot, filePath);
3585
+ if (isEntropySecretExcludedPath(relativePath)) continue;
3586
+ const content = readFileSync(filePath, "utf8");
3587
+ const lines = content.split(/\r?\n/);
3588
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
3589
+ const line = lines[lineIndex] ?? "";
3590
+ for (const candidate of extractStringLiteralCandidates(line)) {
3591
+ const finding = classifyEntropyCandidate({
3592
+ value: candidate,
3593
+ line,
3594
+ file: relativePath,
3595
+ lineNumber: lineIndex + 1,
3596
+ providerId,
3597
+ });
3598
+ if (finding) findings.push(finding);
3599
+ }
3600
+ }
3601
+ }
2345
3602
  return findings;
2346
3603
  }
2347
3604
 
3605
+ function isEntropySecretExcludedPath(relativePath: string): boolean {
3606
+ return (
3607
+ relativePath.endsWith(".test.ts") ||
3608
+ relativePath.startsWith("__tests__/") ||
3609
+ relativePath.includes("/__tests__/") ||
3610
+ relativePath.startsWith("__fixtures__/") ||
3611
+ relativePath.includes("/__fixtures__/")
3612
+ );
3613
+ }
3614
+
3615
+ export function extractStringLiteralCandidates(line: string): string[] {
3616
+ const candidates: string[] = [];
3617
+ for (let index = 0; index < line.length; index += 1) {
3618
+ const quote = line[index];
3619
+ if (quote !== '"' && quote !== "'" && quote !== "`") continue;
3620
+
3621
+ const contentStart = index + 1;
3622
+ let cursor = contentStart;
3623
+ while (cursor < line.length) {
3624
+ const char = line[cursor];
3625
+ if (char === "\\") {
3626
+ cursor += 2;
3627
+ continue;
3628
+ }
3629
+ if (char === quote) {
3630
+ if (cursor - contentStart >= 20) {
3631
+ candidates.push(line.slice(contentStart, cursor));
3632
+ }
3633
+ index = cursor;
3634
+ break;
3635
+ }
3636
+ cursor += 1;
3637
+ }
3638
+ }
3639
+ return candidates;
3640
+ }
3641
+
3642
+ function classifyEntropyCandidate(input: {
3643
+ value: string;
3644
+ line: string;
3645
+ file: string;
3646
+ lineNumber: number;
3647
+ providerId: string;
3648
+ }): SecretFinding | undefined {
3649
+ const value = input.value;
3650
+ if (!shouldConsiderEntropyValue(value)) return undefined;
3651
+ const charset = classifyEntropyCharset(value);
3652
+ if (!charset) return undefined;
3653
+ const entropy = shannonEntropy(value);
3654
+ const secretishContext = SECRETISH_IDENTIFIER_PATTERN.test(input.line);
3655
+ const threshold = charset === "hex" ? 3.0 : secretishContext ? 4.0 : 4.5;
3656
+ if (entropy < threshold) return undefined;
3657
+
3658
+ const preview = `${value.slice(0, 4)}...[REDACTED length=${value.length}]`;
3659
+ const envName = `APIFUSE__PROVIDER__${input.providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}__${guessSecretName(input.line)}`;
3660
+ const location = `${input.file}:${input.lineNumber}`;
3661
+ const label =
3662
+ charset === "hex"
3663
+ ? `high-entropy hex string (${entropy.toFixed(2)} bits/char)`
3664
+ : `high-entropy base64-like string (${entropy.toFixed(2)} bits/char)`;
3665
+ return {
3666
+ label,
3667
+ file: input.file,
3668
+ line: input.lineNumber,
3669
+ level: secretishContext ? "blocker" : "warn",
3670
+ remediation: `Move ${location} to an env var read via \`ctx.env.get("${envName}")\` and rotate the leaked credential.`,
3671
+ evidence: `${location}: ${label}; preview ${preview}${secretishContext ? "" : "; may be a false positive"}`,
3672
+ };
3673
+ }
3674
+
3675
+ function shouldConsiderEntropyValue(value: string): boolean {
3676
+ const lower = value.toLowerCase();
3677
+ if (/^(?:dev-only|local|example|sample|your-|replace|<)/i.test(value)) {
3678
+ return false;
3679
+ }
3680
+ if (/^sha(?:256|512)-/i.test(value)) return false;
3681
+ if (/\s/.test(value)) return false;
3682
+ if (value.includes("${")) return false;
3683
+ if (value.includes("/")) return false;
3684
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return false;
3685
+ if (/^(?:\.{0,2}\/|~\/|[A-Za-z]:\\)/.test(value)) return false;
3686
+ if (value.includes(".") && /^[A-Za-z0-9_.-]+$/.test(value)) return false;
3687
+ if (lower.includes("/") && /\.[a-z0-9]{1,8}(?:$|[/?#])/i.test(value)) {
3688
+ return false;
3689
+ }
3690
+ return value.length >= 20;
3691
+ }
3692
+
3693
+ function classifyEntropyCharset(value: string): "base64" | "hex" | undefined {
3694
+ if (/^[a-f0-9]+$/i.test(value) && value.length >= 32) return "hex";
3695
+ const base64ishChars = value.match(/[A-Za-z0-9+/=_-]/g)?.length ?? 0;
3696
+ if (base64ishChars / value.length >= 0.9) return "base64";
3697
+ return undefined;
3698
+ }
3699
+
3700
+ function shannonEntropy(value: string): number {
3701
+ const counts = new Map<string, number>();
3702
+ for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1);
3703
+ let entropy = 0;
3704
+ for (const count of counts.values()) {
3705
+ const probability = count / value.length;
3706
+ entropy -= probability * Math.log2(probability);
3707
+ }
3708
+ return entropy;
3709
+ }
3710
+
3711
+ function guessSecretName(line: string): string {
3712
+ const match =
3713
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line) ??
3714
+ /["']?([A-Za-z_$][\w$-]*)["']?\s*:/.exec(line);
3715
+ const raw = match?.[1] ?? "SECRET";
3716
+ return raw
3717
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
3718
+ .toUpperCase()
3719
+ .replace(/[^A-Z0-9]+/g, "_");
3720
+ }
3721
+
3722
+ const SECRETISH_IDENTIFIER_PATTERN = /key|token|secret|password|credential|auth/i;
3723
+
2348
3724
  const SECRET_PATTERNS: Array<[string, RegExp]> = [
2349
- [
2350
- "JWT-like token",
2351
- /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/,
2352
- ],
3725
+ ["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
2353
3726
  ["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],
2354
3727
  ["Stripe live key", /(?:sk|rk)_live_[A-Za-z0-9]{20,}/],
2355
3728
  ["Bearer token", /Bearer\s+[A-Za-z0-9._~+/=-]{32,}/i],
@@ -2359,9 +3732,7 @@ const SECRET_PATTERNS: Array<[string, RegExp]> = [
2359
3732
  ],
2360
3733
  ];
2361
3734
 
2362
- async function safeLoadProvider(
2363
- providerRoot: string,
2364
- ): Promise<ProviderDefinition | undefined> {
3735
+ async function safeLoadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
2365
3736
  try {
2366
3737
  return await loadProvider(providerRoot);
2367
3738
  } catch {
@@ -2369,9 +3740,7 @@ async function safeLoadProvider(
2369
3740
  }
2370
3741
  }
2371
3742
 
2372
- async function loadProvider(
2373
- providerRoot: string,
2374
- ): Promise<ProviderDefinition | undefined> {
3743
+ async function loadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
2375
3744
  const entryPath = resolve(providerRoot, "index.ts");
2376
3745
  if (!existsSync(entryPath)) {
2377
3746
  return undefined;
@@ -2451,8 +3820,7 @@ export function renderText(report: SubmitCheckReport): string {
2451
3820
  ];
2452
3821
 
2453
3822
  for (const check of report.checks) {
2454
- const marker =
2455
- check.status === "pass" ? "✓" : check.status === "warn" ? "⚠" : "✗";
3823
+ const marker = check.status === "pass" ? "✓" : check.status === "warn" ? "⚠" : "✗";
2456
3824
  lines.push(
2457
3825
  `${marker} [${check.category}] ${check.message} (${check.points}/${check.maxPoints})`,
2458
3826
  );
@@ -2474,9 +3842,7 @@ export function renderMarkdown(report: SubmitCheckReport): string {
2474
3842
  `- **Provider**: ${report.provider.id}@${report.provider.version}`,
2475
3843
  `- **SDK**: ${report.provider.sdkVersion}`,
2476
3844
  `- **Runtime/Auth**: ${report.provider.runtime} / ${report.provider.authMode}`,
2477
- ...(report.provider.tier
2478
- ? [`- **Bounty tier**: ${report.provider.tier}`]
2479
- : []),
3845
+ ...(report.provider.tier ? [`- **Bounty tier**: ${report.provider.tier}`] : []),
2480
3846
  `- **Score**: ${report.score.total}/${report.score.max}`,
2481
3847
  `- **Verdict**: ${report.score.verdict}`,
2482
3848
  `- **Blockers**: ${report.summary.blockers}`,
@@ -2489,12 +3855,7 @@ export function renderMarkdown(report: SubmitCheckReport): string {
2489
3855
  ];
2490
3856
 
2491
3857
  for (const check of report.checks) {
2492
- const status =
2493
- check.status === "pass"
2494
- ? "PASS"
2495
- : check.status === "warn"
2496
- ? "WARN"
2497
- : "FAIL";
3858
+ const status = check.status === "pass" ? "PASS" : check.status === "warn" ? "WARN" : "FAIL";
2498
3859
  lines.push(
2499
3860
  `| ${status} | ${escapeMarkdown(check.category)} | ${escapeMarkdown(check.message)} | ${check.points}/${check.maxPoints} | ${escapeMarkdown(check.remediation ?? "")} |`,
2500
3861
  );
@@ -2524,9 +3885,7 @@ function redact(value: string): string {
2524
3885
  }
2525
3886
 
2526
3887
  function toGlobalRegex(pattern: RegExp): RegExp {
2527
- return pattern.global
2528
- ? pattern
2529
- : new RegExp(pattern.source, `${pattern.flags}g`);
3888
+ return pattern.global ? pattern : new RegExp(pattern.source, `${pattern.flags}g`);
2530
3889
  }
2531
3890
 
2532
3891
  function clamp(value: number, min: number, max: number): number {