@apifuse/provider-sdk 2.1.0-beta.18 → 2.1.0-beta.19

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.
@@ -1,7 +1,9 @@
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
 
@@ -15,6 +17,7 @@ import {
15
17
  validateProviderLocaleCatalogs,
16
18
  } from "../src/i18n";
17
19
  import { APIFUSE_DESCRIPTION_KEY_META_KEY } from "../src/schema";
20
+ import { safeParseSchemaSync } from "../src/schema";
18
21
  import { type CheckResult, runChecks } from "./apifuse-check";
19
22
 
20
23
  const TIERS = ["bronze", "silver", "gold", "diamond"] as const;
@@ -35,6 +38,7 @@ export type SubmitCheck = {
35
38
  message: string;
36
39
  remediation?: string;
37
40
  evidence?: string[];
41
+ details?: unknown;
38
42
  };
39
43
 
40
44
  export type SubmitCheckReport = {
@@ -69,6 +73,7 @@ type CliArgs = {
69
73
  isJson: boolean;
70
74
  markdownPath?: string;
71
75
  providerPath?: string;
76
+ smoke: boolean;
72
77
  smokeNote?: string;
73
78
  tier?: BountyTier;
74
79
  };
@@ -76,6 +81,24 @@ type CliArgs = {
76
81
  type SecretFinding = {
77
82
  label: string;
78
83
  file: string;
84
+ line?: number;
85
+ level?: CheckLevel;
86
+ remediation?: string;
87
+ evidence?: string;
88
+ };
89
+
90
+ export type SmokeOperationOutcome = {
91
+ operationId: string;
92
+ status: "success" | "structured_error" | "incoherent";
93
+ httpStatus?: number;
94
+ message: string;
95
+ };
96
+
97
+ export type SmokeResult = {
98
+ measured: true;
99
+ healthOk: boolean;
100
+ bootError?: string;
101
+ operations: SmokeOperationOutcome[];
79
102
  };
80
103
 
81
104
  type SourceFinding = {
@@ -98,14 +121,13 @@ const CATEGORY_MAX_POINTS = {
98
121
  docs: 10,
99
122
  } as const;
100
123
 
101
- const REQUIRED_PUBLIC_PROVIDER_LOCALES = [
102
- "en",
103
- "ko",
104
- ] as const satisfies readonly ProviderLocale[];
124
+ const REQUIRED_PUBLIC_PROVIDER_LOCALES = ["en", "ko"] as const satisfies readonly ProviderLocale[];
105
125
 
106
- const HELP_TEXT = `Usage: apifuse submit-check [path] [--tier bronze|silver|gold|diamond] [--json] [--markdown <path>] [--smoke-note <text>]
126
+ const HELP_TEXT = `Usage: apifuse submit-check [path] [--tier bronze|silver|gold|diamond] [--json] [--markdown <path>] [--smoke]
107
127
  Alias: apifuse bounty-check [path]
108
- Default: apifuse submit-check .`;
128
+ Default: apifuse submit-check .
129
+
130
+ 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
131
 
110
132
  export async function main() {
111
133
  try {
@@ -120,10 +142,7 @@ export async function main() {
120
142
  const report = await buildSubmitCheckReport(providerRoot, args);
121
143
 
122
144
  if (args.markdownPath) {
123
- await writeFile(
124
- resolve(process.cwd(), args.markdownPath),
125
- renderMarkdown(report),
126
- );
145
+ await writeFile(resolve(process.cwd(), args.markdownPath), renderMarkdown(report));
127
146
  }
128
147
 
129
148
  if (args.isJson) {
@@ -150,7 +169,7 @@ function normalizeArgs(argv: string[]): string[] {
150
169
  }
151
170
 
152
171
  function parseArgs(argv: string[]): CliArgs {
153
- const args: CliArgs = { isJson: false };
172
+ const args: CliArgs = { isJson: false, smoke: false };
154
173
 
155
174
  for (let index = 0; index < argv.length; index += 1) {
156
175
  const arg = argv[index];
@@ -177,6 +196,11 @@ function parseArgs(argv: string[]): CliArgs {
177
196
  continue;
178
197
  }
179
198
 
199
+ if (arg === "--smoke") {
200
+ args.smoke = true;
201
+ continue;
202
+ }
203
+
180
204
  if (arg === "--smoke-note") {
181
205
  args.smokeNote = requireValue(argv, index, arg);
182
206
  index += 1;
@@ -226,9 +250,7 @@ function parseTier(value: string): BountyTier {
226
250
  if (isBountyTier(value)) {
227
251
  return value;
228
252
  }
229
- throw new Error(
230
- `Invalid --tier "${value}". Expected one of: ${TIERS.join(", ")}`,
231
- );
253
+ throw new Error(`Invalid --tier "${value}". Expected one of: ${TIERS.join(", ")}`);
232
254
  }
233
255
 
234
256
  function isBountyTier(value: string): value is BountyTier {
@@ -237,7 +259,7 @@ function isBountyTier(value: string): value is BountyTier {
237
259
 
238
260
  export async function buildSubmitCheckReport(
239
261
  providerRoot: string,
240
- args: { smokeNote?: string; tier?: BountyTier } = {},
262
+ args: { smoke?: boolean; smokeNote?: string; tier?: BountyTier } = {},
241
263
  ): Promise<SubmitCheckReport> {
242
264
  const checks: SubmitCheck[] = [];
243
265
  const baseChecks = await safeRunChecks(providerRoot);
@@ -257,16 +279,17 @@ export async function buildSubmitCheckReport(
257
279
  checks.push(scoreFlatOperationComposition(providerRoot));
258
280
 
259
281
  if (provider) {
282
+ const smokeResult = args.smoke ? await runSubmitCheckSmoke(providerRoot, provider) : undefined;
260
283
  checks.push(scoreCredentialUsage(providerRoot, provider));
261
284
  checks.push(scoreLocaleCatalog(providerRoot, provider));
262
285
  checks.push(scoreOperationMetadata(provider));
263
286
  checks.push(scoreFixtureCoverage(provider));
264
287
  checks.push(scoreHealthCoverage(provider));
265
288
  checks.push(scoreAuthSafety(provider));
266
- checks.push(scoreSmokeEvidence(args.smokeNote));
289
+ checks.push(scoreSmoke(smokeResult, args.smokeNote));
267
290
  checks.push(...scoreProviderDocs(providerRoot));
268
291
  checks.push(scoreRepositoryDx(providerRoot));
269
- checks.push(scoreSecrets(providerRoot));
292
+ checks.push(scoreSecrets(providerRoot, provider));
270
293
  } else {
271
294
  checks.push(
272
295
  blocker(
@@ -279,22 +302,14 @@ export async function buildSubmitCheckReport(
279
302
  );
280
303
  }
281
304
 
282
- const total = clamp(
283
- Math.round(checks.reduce((sum, check) => sum + check.points, 0)),
284
- 0,
285
- 100,
286
- );
305
+ const total = clamp(Math.round(checks.reduce((sum, check) => sum + check.points, 0)), 0, 100);
287
306
  const blockers = checks.filter(
288
307
  (check) => check.level === "blocker" && check.status === "fail",
289
308
  ).length;
290
309
  const warnings = checks.filter((check) => check.status === "warn").length;
291
310
  const passed = checks.filter((check) => check.status === "pass").length;
292
311
  const verdict: Verdict =
293
- blockers > 0
294
- ? "blocked"
295
- : total >= 90 && warnings === 0
296
- ? "ready"
297
- : "reviewable_with_warnings";
312
+ blockers > 0 ? "blocked" : total >= 90 && warnings === 0 ? "ready" : "reviewable_with_warnings";
298
313
 
299
314
  return {
300
315
  schemaVersion: 1,
@@ -335,18 +350,10 @@ function scoreProviderIdSlug(
335
350
  );
336
351
  }
337
352
 
338
- return pass(
339
- "id-slug",
340
- SDK_NATIVE_CATEGORY,
341
- "Provider id uses the short slug.",
342
- 0,
343
- );
353
+ return pass("id-slug", SDK_NATIVE_CATEGORY, "Provider id uses the short slug.", 0);
344
354
  }
345
355
 
346
- const findings = findSourceLineMatches(
347
- providerRoot,
348
- /["'`]apifuse-provider-[a-z0-9-]/i,
349
- );
356
+ const findings = findSourceLineMatches(providerRoot, /["'`]apifuse-provider-[a-z0-9-]/i);
350
357
  if (findings.length > 0) {
351
358
  return blocker(
352
359
  "id-slug",
@@ -358,12 +365,7 @@ function scoreProviderIdSlug(
358
365
  );
359
366
  }
360
367
 
361
- return pass(
362
- "id-slug",
363
- SDK_NATIVE_CATEGORY,
364
- "Provider id uses the short slug.",
365
- 0,
366
- );
368
+ return pass("id-slug", SDK_NATIVE_CATEGORY, "Provider id uses the short slug.", 0);
367
369
  }
368
370
 
369
371
  function scoreNoVendorShim(providerRoot: string): SubmitCheck {
@@ -388,10 +390,7 @@ function scoreNoVendorShim(providerRoot: string): SubmitCheck {
388
390
  }
389
391
 
390
392
  function scoreNoVendorImport(providerRoot: string): SubmitCheck {
391
- const findings = findSourceLineMatches(
392
- providerRoot,
393
- /from\s+["'][^"']*vendor\//,
394
- );
393
+ const findings = findSourceLineMatches(providerRoot, /from\s+["'][^"']*vendor\//);
395
394
  if (findings.length > 0) {
396
395
  return blocker(
397
396
  "no-vendor-import",
@@ -424,12 +423,7 @@ function scoreDescribeKey(providerRoot: string): SubmitCheck {
424
423
  );
425
424
  }
426
425
 
427
- return pass(
428
- "describe-key",
429
- SDK_NATIVE_CATEGORY,
430
- "Schema descriptions use describeKey.",
431
- 0,
432
- );
426
+ return pass("describe-key", SDK_NATIVE_CATEGORY, "Schema descriptions use describeKey.", 0);
433
427
  }
434
428
 
435
429
  function scoreNoRawFetch(providerRoot: string): SubmitCheck {
@@ -446,12 +440,7 @@ function scoreNoRawFetch(providerRoot: string): SubmitCheck {
446
440
  );
447
441
  }
448
442
 
449
- return pass(
450
- "no-raw-fetch",
451
- SDK_NATIVE_CATEGORY,
452
- "Provider source avoids raw fetch().",
453
- 0,
454
- );
443
+ return pass("no-raw-fetch", SDK_NATIVE_CATEGORY, "Provider source avoids raw fetch().", 0);
455
444
  }
456
445
 
457
446
  const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
@@ -461,10 +450,7 @@ const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
461
450
  const SDK_CONTEXT_METHOD_ALIAS_PATTERN =
462
451
  /\bconst\s+(\w+)\s*=\s*ctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|choice)\.(?:\w+)/;
463
452
 
464
- function hasRedundantRuntimeGuard(
465
- line: string,
466
- remainingLines: readonly string[],
467
- ): boolean {
453
+ function hasRedundantRuntimeGuard(line: string, remainingLines: readonly string[]): boolean {
468
454
  if (REDUNDANT_RUNTIME_GUARD_PATTERNS.some((pattern) => pattern.test(line))) {
469
455
  return true;
470
456
  }
@@ -475,12 +461,8 @@ function hasRedundantRuntimeGuard(
475
461
  return false;
476
462
  }
477
463
 
478
- const guardPattern = new RegExp(
479
- `(?:typeof\\s+${alias}\\s*!==\\s*["']function["']|!${alias}\\b)`,
480
- );
481
- return remainingLines
482
- .slice(0, 8)
483
- .some((candidate) => guardPattern.test(candidate));
464
+ const guardPattern = new RegExp(`(?:typeof\\s+${alias}\\s*!==\\s*["']function["']|!${alias}\\b)`);
465
+ return remainingLines.slice(0, 8).some((candidate) => guardPattern.test(candidate));
484
466
  }
485
467
 
486
468
  function scoreNoRedundantRuntimeGuards(providerRoot: string): SubmitCheck {
@@ -581,11 +563,7 @@ function scoreAsAssertionCount(providerRoot: string): SubmitCheck {
581
563
 
582
564
  // Returns true when `findingLine` (1-based) or the line directly above it
583
565
  // carries an `// @apifuse-allow <ruleId>:` acknowledgement comment.
584
- function hasAllowOverride(
585
- lines: readonly string[],
586
- findingLine: number,
587
- ruleId: string,
588
- ): boolean {
566
+ function hasAllowOverride(lines: readonly string[], findingLine: number, ruleId: string): boolean {
589
567
  const pattern = new RegExp(`@apifuse-allow\\s+${ruleId}\\b`);
590
568
  const current = lines[findingLine - 1];
591
569
  const previous = lines[findingLine - 2];
@@ -636,11 +614,7 @@ function escapeHatchResult(
636
614
  return pass(ruleId, SDK_NATIVE_CATEGORY, copy.passMessage, 0);
637
615
  }
638
616
 
639
- const { violations, overridden } = partitionAllowOverrides(
640
- providerRoot,
641
- findings,
642
- ruleId,
643
- );
617
+ const { violations, overridden } = partitionAllowOverrides(providerRoot, findings, ruleId);
644
618
 
645
619
  if (violations.length > 0) {
646
620
  return blocker(
@@ -953,9 +927,7 @@ function inputKeyIsSchemaField(source: string, propIndex: number): boolean {
953
927
  // across unrelated modules from producing false positives).
954
928
  function fileImportsBinding(source: string, name: string): boolean {
955
929
  const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
956
- return new RegExp(`\\bimport\\b[^;]*\\b${escaped}\\b[^;]*\\bfrom\\b`).test(
957
- source,
958
- );
930
+ return new RegExp(`\\bimport\\b[^;]*\\b${escaped}\\b[^;]*\\bfrom\\b`).test(source);
959
931
  }
960
932
 
961
933
  // Resolves the ORIGINAL exported name for a local binding `localName`. When the
@@ -999,11 +971,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
999
971
  passthroughByFile.set(filePath, localMap);
1000
972
  const constDecl =
1001
973
  /(?:^|\n)[ \t]*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?\s*=/g;
1002
- for (
1003
- let match = constDecl.exec(source);
1004
- match !== null;
1005
- match = constDecl.exec(source)
1006
- ) {
974
+ for (let match = constDecl.exec(source); match !== null; match = constDecl.exec(source)) {
1007
975
  const name = match[1];
1008
976
  if (name === undefined) {
1009
977
  continue;
@@ -1042,11 +1010,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
1042
1010
  const relPath = toRelativeProviderPath(providerRoot, filePath);
1043
1011
 
1044
1012
  const inputProp = /\binput\s*:\s*/g;
1045
- for (
1046
- let match = inputProp.exec(source);
1047
- match !== null;
1048
- match = inputProp.exec(source)
1049
- ) {
1013
+ for (let match = inputProp.exec(source); match !== null; match = inputProp.exec(source)) {
1050
1014
  // Skip `input` keys that are fields inside a zod schema body (e.g. an
1051
1015
  // upstream payload modelled as `z.object({ input: ... })`). Only an
1052
1016
  // operation's public `input:` property is in scope for this rule.
@@ -1074,9 +1038,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
1074
1038
  // Imported binding: map a possible `orig as refName` alias
1075
1039
  // back to the exported name the provider-wide map is keyed by.
1076
1040
  const originalName = importedOriginalName(source, refName);
1077
- const site =
1078
- passthroughConsts.get(refName) ??
1079
- passthroughConsts.get(originalName);
1041
+ const site = passthroughConsts.get(refName) ?? passthroughConsts.get(originalName);
1080
1042
  if (site) {
1081
1043
  push(site);
1082
1044
  }
@@ -1122,8 +1084,7 @@ function scoreUnjustifiedLooseSchema(providerRoot: string): SubmitCheck {
1122
1084
  // A `//` justification comment on the same line or the line above
1123
1085
  // (including the `@apifuse-allow loose-schema:` form) acknowledges it.
1124
1086
  const previous = lines[index - 1];
1125
- const justified =
1126
- line.includes("//") || previous?.trim().startsWith("//") === true;
1087
+ const justified = line.includes("//") || previous?.trim().startsWith("//") === true;
1127
1088
  if (!justified) {
1128
1089
  findings.push({
1129
1090
  file: toRelativeProviderPath(providerRoot, filePath),
@@ -1134,8 +1095,7 @@ function scoreUnjustifiedLooseSchema(providerRoot: string): SubmitCheck {
1134
1095
  }
1135
1096
 
1136
1097
  return escapeHatchResult(providerRoot, "unjustified-loose-schema", findings, {
1137
- blockerMessage:
1138
- "Loose schema (z.record/z.unknown/z.any) used without justification.",
1098
+ blockerMessage: "Loose schema (z.record/z.unknown/z.any) used without justification.",
1139
1099
  remediation:
1140
1100
  "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.",
1141
1101
  passMessage: "Loose schemas are justified or absent.",
@@ -1164,24 +1124,18 @@ function spreadIdentifierResolvesToFactory(
1164
1124
  let sawDeclaration = false;
1165
1125
  for (const filePath of [
1166
1126
  indexPath,
1167
- ...listNonTestTypeScriptFiles(providerRoot).filter(
1168
- (p) => resolve(p) !== resolve(indexPath),
1169
- ),
1127
+ ...listNonTestTypeScriptFiles(providerRoot).filter((p) => resolve(p) !== resolve(indexPath)),
1170
1128
  ]) {
1171
1129
  if (!existsSync(filePath)) {
1172
1130
  continue;
1173
1131
  }
1174
- const fileSource =
1175
- filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
1132
+ const fileSource = filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
1176
1133
  const re = new RegExp(declRe.source, "g");
1177
1134
  for (let m = re.exec(fileSource); m !== null; m = re.exec(fileSource)) {
1178
1135
  sawDeclaration = true;
1179
- const expr = unwrapParens(
1180
- balancedValueExpression(fileSource, m.index + m[0].length).trim(),
1181
- );
1136
+ const expr = unwrapParens(balancedValueExpression(fileSource, m.index + m[0].length).trim());
1182
1137
  const isFactory =
1183
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) ||
1184
- hasTopLevelFactorySpread(expr)) &&
1138
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
1185
1139
  !isTransparentObjectReshape(expr);
1186
1140
  if (isFactory) {
1187
1141
  return true;
@@ -1233,9 +1187,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1233
1187
  if (inlineDefault) {
1234
1188
  defineParenIndex = inlineDefault.index + inlineDefault[0].length - 1; // points at `(`
1235
1189
  } else {
1236
- const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(
1237
- source,
1238
- );
1190
+ const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(source);
1239
1191
  const exportedName = namedDefault?.[1];
1240
1192
  if (exportedName !== undefined) {
1241
1193
  const namedDecl = new RegExp(
@@ -1309,9 +1261,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1309
1261
  // Search index.ts first (its line attribution wins), then siblings.
1310
1262
  const searchOrder = [
1311
1263
  indexPath,
1312
- ...listNonTestTypeScriptFiles(providerRoot).filter(
1313
- (p) => resolve(p) !== resolve(indexPath),
1314
- ),
1264
+ ...listNonTestTypeScriptFiles(providerRoot).filter((p) => resolve(p) !== resolve(indexPath)),
1315
1265
  ];
1316
1266
 
1317
1267
  // Collect EVERY same-named declaration across the submission and classify
@@ -1331,23 +1281,15 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1331
1281
  if (!existsSync(filePath)) {
1332
1282
  continue;
1333
1283
  }
1334
- const fileSource =
1335
- filePath === indexPath ? source : readFileSync(filePath, "utf8");
1284
+ const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
1336
1285
  const relPath = toRelativeProviderPath(providerRoot, filePath);
1337
1286
 
1338
1287
  const declRe = new RegExp(aliasDecl.source, "g");
1339
- for (
1340
- let m = declRe.exec(fileSource);
1341
- m !== null;
1342
- m = declRe.exec(fileSource)
1343
- ) {
1288
+ for (let m = declRe.exec(fileSource); m !== null; m = declRe.exec(fileSource)) {
1344
1289
  const valueStart = m.index + m[0].length;
1345
- const expr = unwrapParens(
1346
- balancedValueExpression(fileSource, valueStart).trim(),
1347
- );
1290
+ const expr = unwrapParens(balancedValueExpression(fileSource, valueStart).trim());
1348
1291
  const isFactory =
1349
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) ||
1350
- hasTopLevelFactorySpread(expr)) &&
1292
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
1351
1293
  !isTransparentObjectReshape(expr);
1352
1294
  candidates.push({
1353
1295
  expr,
@@ -1357,11 +1299,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1357
1299
  });
1358
1300
  }
1359
1301
  const destructRe = new RegExp(destructured.source, "g");
1360
- for (
1361
- let m = destructRe.exec(fileSource);
1362
- m !== null;
1363
- m = destructRe.exec(fileSource)
1364
- ) {
1302
+ for (let m = destructRe.exec(fileSource); m !== null; m = destructRe.exec(fileSource)) {
1365
1303
  candidates.push({
1366
1304
  expr: `${m[1]}(`,
1367
1305
  line: offsetToLine(fileSource, m.index),
@@ -1389,9 +1327,9 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1389
1327
  // the unresolved import as a factory-composed (non-static) shape rather
1390
1328
  // than silently passing.
1391
1329
  if (!resolved) {
1392
- const importMatch = new RegExp(
1393
- `\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`,
1394
- ).exec(source);
1330
+ const importMatch = new RegExp(`\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`).exec(
1331
+ source,
1332
+ );
1395
1333
  if (importMatch) {
1396
1334
  effective = `${aliasName}(`;
1397
1335
  effectiveLine = offsetToLine(source, importMatch.index);
@@ -1406,8 +1344,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1406
1344
  // inspect depth-1 entries so that ordinary spreads deep inside operation
1407
1345
  // handler bodies (e.g. `{ ...headers }`, `...arr.map(...)`) are NOT mistaken
1408
1346
  // for a top-level factory composition of the operations map itself.
1409
- const hasFactorySpread =
1410
- effective !== undefined && hasTopLevelFactorySpread(effective);
1347
+ const hasFactorySpread = effective !== undefined && hasTopLevelFactorySpread(effective);
1411
1348
  // A spread of a bare identifier (`{ ...hidden }`) is static ONLY when that
1412
1349
  // identifier resolves to a non-factory declaration. Resolve each top-level
1413
1350
  // spread identifier so an opaque factory map laundered through a variable
@@ -1418,18 +1355,14 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1418
1355
  spreadIdentifierResolvesToFactory(providerRoot, indexPath, source, name),
1419
1356
  );
1420
1357
  const isStaticLiteral =
1421
- effective?.startsWith("{") === true &&
1422
- !hasFactorySpread &&
1423
- !hasFactorySpreadIdentifier;
1358
+ effective?.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
1424
1359
  // A call expression `ident(...)` (factory) or a factory-spread literal is
1425
1360
  // the rejected, non-static shape — UNLESS it is the stdlib
1426
1361
  // `Object.fromEntries(Object.entries(<source-visible obj>)...)` reshape,
1427
1362
  // whose op set is still enumerable from source (verified golden pattern).
1428
1363
  const isFactoryCall =
1429
1364
  effective !== undefined &&
1430
- (/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) ||
1431
- hasFactorySpread ||
1432
- hasFactorySpreadIdentifier) &&
1365
+ (/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) || hasFactorySpread || hasFactorySpreadIdentifier) &&
1433
1366
  !isTransparentObjectReshape(effective);
1434
1367
 
1435
1368
  if (isFactoryCall && !isStaticLiteral) {
@@ -1437,19 +1370,13 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1437
1370
  // `// @apifuse-allow flat-operation-composition: <reason>` comment on
1438
1371
  // the reported line (or the line above) downgrades this blocker to a
1439
1372
  // counted warning, consistent with the other structural rules.
1440
- return escapeHatchResult(
1441
- providerRoot,
1442
- ruleId,
1443
- [{ file: effectiveFile, line: effectiveLine }],
1444
- {
1445
- blockerMessage:
1446
- "defineProvider operations are composed by a factory call instead of a static object literal.",
1447
- remediation:
1448
- "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>`.",
1449
- passMessage:
1450
- "defineProvider declares operations as a static object literal.",
1451
- },
1452
- );
1373
+ return escapeHatchResult(providerRoot, ruleId, [{ file: effectiveFile, line: effectiveLine }], {
1374
+ blockerMessage:
1375
+ "defineProvider operations are composed by a factory call instead of a static object literal.",
1376
+ remediation:
1377
+ "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>`.",
1378
+ passMessage: "defineProvider declares operations as a static object literal.",
1379
+ });
1453
1380
  }
1454
1381
 
1455
1382
  return pass(
@@ -1460,18 +1387,11 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
1460
1387
  );
1461
1388
  }
1462
1389
 
1463
- function scoreCredentialUsage(
1464
- providerRoot: string,
1465
- provider: ProviderDefinition,
1466
- ): SubmitCheck {
1467
- const credentialReferences = findSourceLineMatches(
1468
- providerRoot,
1469
- /ctx\.credential/,
1470
- );
1390
+ function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
1391
+ const credentialReferences = findSourceLineMatches(providerRoot, /ctx\.credential/);
1471
1392
  const authMode = provider.auth?.mode ?? "none";
1472
1393
  const credentialKeys = provider.credential?.keys ?? [];
1473
- const storesProviderCredential =
1474
- authMode !== "none" || credentialKeys.length > 0;
1394
+ const storesProviderCredential = authMode !== "none" || credentialKeys.length > 0;
1475
1395
 
1476
1396
  if (storesProviderCredential && credentialReferences.length === 0) {
1477
1397
  return {
@@ -1481,8 +1401,7 @@ function scoreCredentialUsage(
1481
1401
  status: "warn",
1482
1402
  points: 0,
1483
1403
  maxPoints: 0,
1484
- message:
1485
- "Credential-backed provider does not reference credential persistence in source.",
1404
+ message: "Credential-backed provider does not reference credential persistence in source.",
1486
1405
  remediation:
1487
1406
  "Persist provider session state through the SDK credential context instead of process-local state. See providers/catchtable for the reference pattern.",
1488
1407
  };
@@ -1495,9 +1414,7 @@ function scoreCredentialUsage(
1495
1414
  ? "Provider does not declare reusable credentials."
1496
1415
  : "Credential-backed provider references ctx.credential.",
1497
1416
  0,
1498
- credentialReferences.length > 0
1499
- ? formatSourceFindings(credentialReferences)
1500
- : undefined,
1417
+ credentialReferences.length > 0 ? formatSourceFindings(credentialReferences) : undefined,
1501
1418
  );
1502
1419
  }
1503
1420
 
@@ -1505,9 +1422,7 @@ function findSourceLineMatches(
1505
1422
  providerRoot: string,
1506
1423
  pattern: RegExp | ((line: string) => boolean),
1507
1424
  ): SourceFinding[] {
1508
- return findSourceFindings(providerRoot, (line) =>
1509
- matchesLinePattern(line, pattern),
1510
- );
1425
+ return findSourceFindings(providerRoot, (line) => matchesLinePattern(line, pattern));
1511
1426
  }
1512
1427
 
1513
1428
  function findSourceFindings(
@@ -1534,10 +1449,7 @@ function findSourceFindings(
1534
1449
  return findings;
1535
1450
  }
1536
1451
 
1537
- function matchesLinePattern(
1538
- line: string,
1539
- pattern: RegExp | ((line: string) => boolean),
1540
- ): boolean {
1452
+ function matchesLinePattern(line: string, pattern: RegExp | ((line: string) => boolean)): boolean {
1541
1453
  return typeof pattern === "function" ? pattern(line) : pattern.test(line);
1542
1454
  }
1543
1455
 
@@ -1591,11 +1503,7 @@ function collectNonTestTypeScriptFiles(
1591
1503
  }
1592
1504
  continue;
1593
1505
  }
1594
- if (
1595
- entry.isFile() &&
1596
- relativePath.endsWith(".ts") &&
1597
- !isExcludedTestSource(relativePath)
1598
- ) {
1506
+ if (entry.isFile() && relativePath.endsWith(".ts") && !isExcludedTestSource(relativePath)) {
1599
1507
  files.push(entryPath);
1600
1508
  }
1601
1509
  }
@@ -1610,9 +1518,7 @@ function isScannableProviderSourceFile(relativePath: string): boolean {
1610
1518
  }
1611
1519
 
1612
1520
  function shouldScanSourceDirectory(relativePath: string): boolean {
1613
- return ![".git", "node_modules", "dist", "build", "coverage"].includes(
1614
- relativePath,
1615
- );
1521
+ return ![".git", "node_modules", "dist", "build", "coverage"].includes(relativePath);
1616
1522
  }
1617
1523
 
1618
1524
  function isExcludedTestSource(relativePath: string): boolean {
@@ -1625,10 +1531,7 @@ function isExcludedTestSource(relativePath: string): boolean {
1625
1531
  );
1626
1532
  }
1627
1533
 
1628
- function toRelativeProviderPath(
1629
- providerRoot: string,
1630
- filePath: string,
1631
- ): string {
1534
+ function toRelativeProviderPath(providerRoot: string, filePath: string): string {
1632
1535
  return relative(providerRoot, filePath).replaceAll("\\", "/");
1633
1536
  }
1634
1537
 
@@ -1674,9 +1577,7 @@ function scoreRepositoryDx(providerRoot: string): SubmitCheck {
1674
1577
  };
1675
1578
  }
1676
1579
 
1677
- function readPackageScripts(
1678
- packageJsonPath: string,
1679
- ): Record<string, unknown> | undefined {
1580
+ function readPackageScripts(packageJsonPath: string): Record<string, unknown> | undefined {
1680
1581
  if (!existsSync(packageJsonPath)) {
1681
1582
  return undefined;
1682
1583
  }
@@ -1851,10 +1752,7 @@ function baseCheckRemediation(result: CheckResult): string {
1851
1752
  }
1852
1753
  }
1853
1754
 
1854
- function scoreLocaleCatalog(
1855
- providerRoot: string,
1856
- provider: ProviderDefinition,
1857
- ): SubmitCheck {
1755
+ function scoreLocaleCatalog(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
1858
1756
  const requiredKeys = collectProviderRequiredLocaleKeys(provider);
1859
1757
  if (requiredKeys.length === 0) {
1860
1758
  return pass(
@@ -1885,9 +1783,7 @@ function scoreLocaleCatalog(
1885
1783
  "Provider locale catalog is missing required public-provider copy.",
1886
1784
  "Add provider-local locales/en.json and locales/ko.json values for every provider metadata key, operation descriptionKey, and .describeKey() or describeKey() schema field.",
1887
1785
  0,
1888
- validation.issues.map(
1889
- (issue) => `${issue.locale}:${issue.key}: ${issue.message}`,
1890
- ),
1786
+ validation.issues.map((issue) => `${issue.locale}:${issue.key}: ${issue.message}`),
1891
1787
  );
1892
1788
  }
1893
1789
  } catch (error) {
@@ -1910,9 +1806,7 @@ function scoreLocaleCatalog(
1910
1806
  );
1911
1807
  }
1912
1808
 
1913
- function collectProviderRequiredLocaleKeys(
1914
- provider: ProviderDefinition,
1915
- ): string[] {
1809
+ function collectProviderRequiredLocaleKeys(provider: ProviderDefinition): string[] {
1916
1810
  const keys = new Set<string>();
1917
1811
 
1918
1812
  addLocaleKeys(keys, [
@@ -1975,10 +1869,7 @@ function collectSchemaDescriptionKeys(schema: unknown): string[] {
1975
1869
  return keys;
1976
1870
  }
1977
1871
 
1978
- function collectJsonSchemaDescriptionKeys(
1979
- schema: Record<string, unknown>,
1980
- keys: string[],
1981
- ): void {
1872
+ function collectJsonSchemaDescriptionKeys(schema: Record<string, unknown>, keys: string[]): void {
1982
1873
  const descriptionKey = schema[APIFUSE_DESCRIPTION_KEY_META_KEY];
1983
1874
  if (typeof descriptionKey === "string" && descriptionKey.length > 0) {
1984
1875
  keys.push(descriptionKey);
@@ -2010,8 +1901,7 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
2010
1901
  // is enforced at registry catalog-build time, matching how lintOperation
2011
1902
  // skips the raw-description min-length rule when a descriptionKey is set.
2012
1903
  const hasDescriptionKey =
2013
- typeof operation.descriptionKey === "string" &&
2014
- operation.descriptionKey.length > 0;
1904
+ typeof operation.descriptionKey === "string" && operation.descriptionKey.length > 0;
2015
1905
  if (hasDescriptionKey) return false;
2016
1906
  return true;
2017
1907
  })
@@ -2029,14 +1919,12 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
2029
1919
  points: 0,
2030
1920
  maxPoints: CATEGORY_MAX_POINTS.operations,
2031
1921
  message: "One or more operations have weak descriptions.",
2032
- remediation:
2033
- `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.`,
1922
+ 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.`,
2034
1923
  evidence: weakDescriptions,
2035
1924
  };
2036
1925
  }
2037
1926
 
2038
- const points =
2039
- missingAnnotations.length > 0 ? 11 : CATEGORY_MAX_POINTS.operations;
1927
+ const points = missingAnnotations.length > 0 ? 11 : CATEGORY_MAX_POINTS.operations;
2040
1928
  return {
2041
1929
  id: "operation-metadata",
2042
1930
  category: "operations",
@@ -2054,19 +1942,14 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
2054
1942
  : undefined,
2055
1943
  evidence:
2056
1944
  missingAnnotations.length > 0
2057
- ? missingAnnotations.map(
2058
- (operationId) => `${operationId}: missing annotations`,
2059
- )
1945
+ ? missingAnnotations.map((operationId) => `${operationId}: missing annotations`)
2060
1946
  : operations.map(([operationId]) => operationId),
2061
1947
  };
2062
1948
  }
2063
1949
 
2064
1950
  function scoreFixtureCoverage(provider: ProviderDefinition): SubmitCheck {
2065
1951
  const missing = Object.entries(provider.operations)
2066
- .filter(
2067
- ([, operation]) =>
2068
- !operation.fixtures?.request || !operation.fixtures?.response,
2069
- )
1952
+ .filter(([, operation]) => !operation.fixtures?.request || !operation.fixtures?.response)
2070
1953
  .map(([operationId]) => operationId);
2071
1954
  if (missing.length > 0) {
2072
1955
  return blocker(
@@ -2107,9 +1990,7 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
2107
1990
  generatedStarter.push(operationId);
2108
1991
  }
2109
1992
  if (
2110
- /(todo|later|tbd|test fixture|unit test|placeholder|not sure|skip for test)/i.test(
2111
- reason,
2112
- )
1993
+ /(todo|later|tbd|test fixture|unit test|placeholder|not sure|skip for test)/i.test(reason)
2113
1994
  ) {
2114
1995
  placeholder.push(operationId);
2115
1996
  }
@@ -2136,8 +2017,7 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
2136
2017
  points: 8,
2137
2018
  maxPoints: CATEGORY_MAX_POINTS.health,
2138
2019
  message: "Some healthCheckUnsupported reasons look placeholder-like.",
2139
- remediation:
2140
- `For ${placeholder.join(", ")}, replace the placeholder \`healthCheckUnsupported.reason\` with a specific reason such as destructive mutation, paid call, credential sensitivity, or upstream flakiness.`,
2020
+ remediation: `For ${placeholder.join(", ")}, replace the placeholder \`healthCheckUnsupported.reason\` with a specific reason such as destructive mutation, paid call, credential sensitivity, or upstream flakiness.`,
2141
2021
  evidence: placeholder,
2142
2022
  };
2143
2023
  }
@@ -2152,8 +2032,7 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
2152
2032
  maxPoints: CATEGORY_MAX_POINTS.health,
2153
2033
  message:
2154
2034
  "Generated starter operation health rationale is present; replace starter logic before bounty submission.",
2155
- remediation:
2156
- `Replace generated starter operation(s) ${generatedStarter.join(", ")} with real upstream-backed operations and add \`healthCheck\` for safe read-only probes.`,
2035
+ remediation: `Replace generated starter operation(s) ${generatedStarter.join(", ")} with real upstream-backed operations and add \`healthCheck\` for safe read-only probes.`,
2157
2036
  evidence: generatedStarter,
2158
2037
  };
2159
2038
  }
@@ -2166,13 +2045,9 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
2166
2045
  status: "warn",
2167
2046
  points: 12,
2168
2047
  maxPoints: CATEGORY_MAX_POINTS.health,
2169
- message:
2170
- "Health coverage is declared, with one or more unsupported probes.",
2171
- remediation:
2172
- `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.`,
2173
- evidence: unsupported.map(
2174
- (operationId) => `${operationId}: healthCheckUnsupported`,
2175
- ),
2048
+ message: "Health coverage is declared, with one or more unsupported probes.",
2049
+ 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.`,
2050
+ evidence: unsupported.map((operationId) => `${operationId}: healthCheckUnsupported`),
2176
2051
  };
2177
2052
  }
2178
2053
 
@@ -2184,8 +2059,55 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
2184
2059
  );
2185
2060
  }
2186
2061
 
2187
- function scoreSmokeEvidence(smokeNote: string | undefined): SubmitCheck {
2188
- if (smokeNote?.trim()) {
2062
+ function scoreSmoke(
2063
+ smokeResult: SmokeResult | undefined,
2064
+ smokeNote: string | undefined,
2065
+ ): SubmitCheck {
2066
+ const deprecatedEvidence = smokeNote?.trim()
2067
+ ? ["Deprecated --smoke-note was provided and ignored for scoring."]
2068
+ : [];
2069
+ if (!smokeResult) {
2070
+ return {
2071
+ id: "local-smoke",
2072
+ category: "smoke",
2073
+ level: "warn",
2074
+ status: "warn",
2075
+ points: 0,
2076
+ maxPoints: CATEGORY_MAX_POINTS.smoke,
2077
+ message: "Measured local smoke was not run.",
2078
+ remediation:
2079
+ "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.",
2080
+ evidence: deprecatedEvidence,
2081
+ };
2082
+ }
2083
+
2084
+ const evidence = [
2085
+ `/health: ${smokeResult.healthOk ? "ok" : "failed"}`,
2086
+ ...smokeResult.operations.map(
2087
+ (outcome) =>
2088
+ `${outcome.operationId}: ${outcome.status}${outcome.httpStatus ? ` HTTP ${outcome.httpStatus}` : ""} - ${outcome.message}`,
2089
+ ),
2090
+ ...deprecatedEvidence,
2091
+ ];
2092
+ const incoherent = smokeResult.operations.filter((outcome) => outcome.status === "incoherent");
2093
+ if (!smokeResult.healthOk || smokeResult.bootError || incoherent.length > 0) {
2094
+ return {
2095
+ id: "local-smoke",
2096
+ category: "smoke",
2097
+ level: "blocker",
2098
+ status: "fail",
2099
+ points: 0,
2100
+ maxPoints: CATEGORY_MAX_POINTS.smoke,
2101
+ message: "Measured smoke failed to verify a coherent provider runtime.",
2102
+ remediation:
2103
+ "Fix the dev server boot, `/health`, or incoherent operation responses, then rerun `bun run submit-check -- --smoke`.",
2104
+ evidence: smokeResult.bootError ? [`boot: ${smokeResult.bootError}`, ...evidence] : evidence,
2105
+ details: smokeResult,
2106
+ };
2107
+ }
2108
+
2109
+ const successes = smokeResult.operations.filter((outcome) => outcome.status === "success");
2110
+ if (successes.length > 0) {
2189
2111
  return {
2190
2112
  id: "local-smoke",
2191
2113
  category: "smoke",
@@ -2193,8 +2115,9 @@ function scoreSmokeEvidence(smokeNote: string | undefined): SubmitCheck {
2193
2115
  status: "pass",
2194
2116
  points: CATEGORY_MAX_POINTS.smoke,
2195
2117
  maxPoints: CATEGORY_MAX_POINTS.smoke,
2196
- message: "Local smoke evidence was provided.",
2197
- evidence: [redact(smokeNote.trim())],
2118
+ message: "Measured smoke passed with at least one schema-valid operation success.",
2119
+ evidence,
2120
+ details: smokeResult,
2198
2121
  };
2199
2122
  }
2200
2123
 
@@ -2203,14 +2126,226 @@ function scoreSmokeEvidence(smokeNote: string | undefined): SubmitCheck {
2203
2126
  category: "smoke",
2204
2127
  level: "warn",
2205
2128
  status: "warn",
2206
- points: 5,
2129
+ points: 7,
2207
2130
  maxPoints: CATEGORY_MAX_POINTS.smoke,
2208
- message: "No local smoke evidence was provided.",
2131
+ message: "Runtime path was verified, but no live upstream schema-valid success was observed.",
2209
2132
  remediation:
2210
- "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.",
2133
+ "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.",
2134
+ evidence,
2135
+ details: smokeResult,
2211
2136
  };
2212
2137
  }
2213
2138
 
2139
+ export async function runSubmitCheckSmoke(
2140
+ providerRoot: string,
2141
+ provider?: ProviderDefinition,
2142
+ ): Promise<SmokeResult> {
2143
+ const loadedProvider = provider ?? (await loadProvider(providerRoot));
2144
+ if (!loadedProvider) {
2145
+ return {
2146
+ measured: true,
2147
+ healthOk: false,
2148
+ bootError: "Provider could not be loaded.",
2149
+ operations: [],
2150
+ };
2151
+ }
2152
+
2153
+ const port = await getAvailablePort();
2154
+ const server = spawn("bun", ["run", "dev"], {
2155
+ cwd: providerRoot,
2156
+ env: { ...process.env, APIFUSE__RUNTIME__PORT: String(port) },
2157
+ detached: process.platform !== "win32",
2158
+ stdio: ["ignore", "pipe", "pipe"],
2159
+ });
2160
+ let output = "";
2161
+ server.stdout?.on("data", (chunk) => {
2162
+ output += chunk.toString();
2163
+ });
2164
+ server.stderr?.on("data", (chunk) => {
2165
+ output += chunk.toString();
2166
+ });
2167
+
2168
+ try {
2169
+ const baseUrl = `http://127.0.0.1:${port}`;
2170
+ const health = await waitForSmokeHealth(`${baseUrl}/health`, server, () => output);
2171
+ if (!health.ok) {
2172
+ return {
2173
+ measured: true,
2174
+ healthOk: false,
2175
+ bootError: health.error,
2176
+ operations: [],
2177
+ };
2178
+ }
2179
+ const operations: SmokeOperationOutcome[] = [];
2180
+ for (const [operationId, operation] of Object.entries(loadedProvider.operations)) {
2181
+ operations.push(
2182
+ await smokeOperation(baseUrl, operationId, operation.output, {
2183
+ requestId: `req_submit_check_smoke_${operationId}`,
2184
+ input: operation.fixtures?.request ?? {},
2185
+ headers: {},
2186
+ }),
2187
+ );
2188
+ }
2189
+ return { measured: true, healthOk: true, operations };
2190
+ } finally {
2191
+ await stopSmokeServer(server);
2192
+ }
2193
+ }
2194
+
2195
+ async function smokeOperation(
2196
+ baseUrl: string,
2197
+ operationId: string,
2198
+ outputSchema: ProviderDefinition["operations"][string]["output"],
2199
+ body: unknown,
2200
+ ): Promise<SmokeOperationOutcome> {
2201
+ try {
2202
+ const response = await fetch(`${baseUrl}/v1/${operationId}`, {
2203
+ method: "POST",
2204
+ headers: { "content-type": "application/json" },
2205
+ body: JSON.stringify(body),
2206
+ signal: AbortSignal.timeout(20_000),
2207
+ });
2208
+ const payload = await response.json().catch(() => undefined);
2209
+ if (response.ok && isRecord(payload) && "data" in payload) {
2210
+ const parsed = safeParseSchemaSync(
2211
+ outputSchema,
2212
+ payload.data,
2213
+ `operations.${operationId}.output`,
2214
+ );
2215
+ if (parsed.success) {
2216
+ return {
2217
+ operationId,
2218
+ status: "success",
2219
+ httpStatus: response.status,
2220
+ message: "schema-valid success",
2221
+ };
2222
+ }
2223
+ return {
2224
+ operationId,
2225
+ status: "incoherent",
2226
+ httpStatus: response.status,
2227
+ message: "success payload failed output schema validation",
2228
+ };
2229
+ }
2230
+ if (isStructuredProviderError(payload) && response.status < 500) {
2231
+ return {
2232
+ operationId,
2233
+ status: "structured_error",
2234
+ httpStatus: response.status,
2235
+ message: `${payload.error.code}: ${payload.error.message}`,
2236
+ };
2237
+ }
2238
+ return {
2239
+ operationId,
2240
+ status: "incoherent",
2241
+ httpStatus: response.status,
2242
+ message: isStructuredProviderError(payload)
2243
+ ? `${payload.error.code}: ${payload.error.message}`
2244
+ : "response was not a schema-valid success or structured provider error",
2245
+ };
2246
+ } catch (error) {
2247
+ return {
2248
+ operationId,
2249
+ status: "incoherent",
2250
+ message: error instanceof Error ? error.message : String(error),
2251
+ };
2252
+ }
2253
+ }
2254
+
2255
+ function isStructuredProviderError(
2256
+ value: unknown,
2257
+ ): value is { error: { code: string; message: string } } {
2258
+ return (
2259
+ isRecord(value) &&
2260
+ isRecord(value.error) &&
2261
+ typeof value.error.code === "string" &&
2262
+ typeof value.error.message === "string"
2263
+ );
2264
+ }
2265
+
2266
+ async function getAvailablePort(): Promise<number> {
2267
+ return await new Promise((resolvePromise, rejectPromise) => {
2268
+ const server = createServer();
2269
+ server.once("error", rejectPromise);
2270
+ server.listen(0, "127.0.0.1", () => {
2271
+ const address = server.address();
2272
+ server.close((error) => {
2273
+ if (error) {
2274
+ rejectPromise(error);
2275
+ return;
2276
+ }
2277
+ if (!address || typeof address === "string") {
2278
+ rejectPromise(new Error("Could not allocate a local TCP port."));
2279
+ return;
2280
+ }
2281
+ resolvePromise(address.port);
2282
+ });
2283
+ });
2284
+ });
2285
+ }
2286
+
2287
+ async function waitForSmokeHealth(
2288
+ url: string,
2289
+ server: ChildProcess,
2290
+ getOutput: () => string,
2291
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
2292
+ const deadline = Date.now() + 20_000;
2293
+ let lastError: unknown;
2294
+
2295
+ while (Date.now() < deadline) {
2296
+ if (server.exitCode !== null) {
2297
+ return {
2298
+ ok: false,
2299
+ error: `Dev server exited early with code ${server.exitCode}. ${getOutput()}`,
2300
+ };
2301
+ }
2302
+
2303
+ try {
2304
+ const response = await fetch(url, { signal: AbortSignal.timeout(1_000) });
2305
+ if (response.ok) return { ok: true };
2306
+ lastError = new Error(`${url} returned ${response.status}`);
2307
+ } catch (error) {
2308
+ lastError = error;
2309
+ }
2310
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 200));
2311
+ }
2312
+
2313
+ return {
2314
+ ok: false,
2315
+ error: `Timed out waiting for ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}. ${getOutput()}`,
2316
+ };
2317
+ }
2318
+
2319
+ async function stopSmokeServer(server: ChildProcess): Promise<void> {
2320
+ if (server.exitCode !== null) return;
2321
+ killSmokeProcessTree(server, "SIGTERM");
2322
+ await new Promise<void>((resolvePromise) => {
2323
+ const timeout = setTimeout(() => {
2324
+ if (server.exitCode === null) {
2325
+ killSmokeProcessTree(server, "SIGKILL");
2326
+ }
2327
+ resolvePromise();
2328
+ }, 2_000);
2329
+ server.once("exit", () => {
2330
+ clearTimeout(timeout);
2331
+ resolvePromise();
2332
+ });
2333
+ });
2334
+ }
2335
+
2336
+ function killSmokeProcessTree(server: ChildProcess, signal: NodeJS.Signals): void {
2337
+ if (server.pid === undefined) return;
2338
+ try {
2339
+ if (process.platform === "win32") {
2340
+ server.kill(signal);
2341
+ return;
2342
+ }
2343
+ process.kill(-server.pid, signal);
2344
+ } catch {
2345
+ server.kill(signal);
2346
+ }
2347
+ }
2348
+
2214
2349
  function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
2215
2350
  const authMode = provider.auth?.mode ?? "none";
2216
2351
  const credentialKeys = provider.credential?.keys ?? [];
@@ -2250,10 +2385,8 @@ function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
2250
2385
  status: "warn",
2251
2386
  points: 7,
2252
2387
  maxPoints: CATEGORY_MAX_POINTS.auth,
2253
- message:
2254
- "Provider is no-auth but at least one operation is not marked openWorld.",
2255
- remediation:
2256
- `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(", ")}.`,
2388
+ message: "Provider is no-auth but at least one operation is not marked openWorld.",
2389
+ 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(", ")}.`,
2257
2390
  evidence: securedOperations.map(([operationId]) => operationId),
2258
2391
  };
2259
2392
  }
@@ -2295,9 +2428,7 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
2295
2428
 
2296
2429
  const points = Math.max(
2297
2430
  0,
2298
- CATEGORY_MAX_POINTS.docs -
2299
- missing.length * 2 -
2300
- (mentionsSubmitCheck ? 0 : 1),
2431
+ CATEGORY_MAX_POINTS.docs - missing.length * 2 - (mentionsSubmitCheck ? 0 : 1),
2301
2432
  );
2302
2433
 
2303
2434
  return [
@@ -2324,9 +2455,10 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
2324
2455
  ];
2325
2456
  }
2326
2457
 
2327
- function scoreSecrets(providerRoot: string): SubmitCheck {
2328
- const findings = findSecretFindings(providerRoot);
2329
- if (findings.length > 0) {
2458
+ function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): SubmitCheck {
2459
+ const findings = findSecretFindings(providerRoot, provider?.id);
2460
+ const blockerFindings = findings.filter((finding) => finding.level !== "warn");
2461
+ if (blockerFindings.length > 0) {
2330
2462
  return {
2331
2463
  id: "secret-scan",
2332
2464
  category: "security",
@@ -2334,11 +2466,34 @@ function scoreSecrets(providerRoot: string): SubmitCheck {
2334
2466
  status: "fail",
2335
2467
  points: 0,
2336
2468
  maxPoints: CATEGORY_MAX_POINTS.security,
2469
+ message: "Potential real credential material was found in shareable files.",
2470
+ remediation:
2471
+ blockerFindings[0]?.remediation ??
2472
+ 'Move hardcoded credentials to env vars read via `ctx.env.get("APIFUSE__PROVIDER__<ID>__<NAME>")` and rotate the leaked credential.',
2473
+ evidence: blockerFindings.map(
2474
+ (finding) =>
2475
+ finding.evidence ??
2476
+ `${finding.file}${finding.line ? `:${finding.line}` : ""}: ${finding.label}`,
2477
+ ),
2478
+ };
2479
+ }
2480
+ if (findings.length > 0) {
2481
+ return {
2482
+ id: "secret-scan",
2483
+ category: "security",
2484
+ level: "warn",
2485
+ status: "warn",
2486
+ points: 8,
2487
+ maxPoints: CATEGORY_MAX_POINTS.security,
2337
2488
  message:
2338
- "Potential real credential material was found in shareable files.",
2489
+ "High-entropy source strings were found without secret-like identifier context; they may be false positives.",
2339
2490
  remediation:
2340
- "Remove real secrets from source, README, and fixtures. Use environment variables and local-only connection.secrets instead.",
2341
- evidence: findings.map((finding) => `${finding.file}: ${finding.label}`),
2491
+ '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.',
2492
+ evidence: findings.map(
2493
+ (finding) =>
2494
+ finding.evidence ??
2495
+ `${finding.file}${finding.line ? `:${finding.line}` : ""}: ${finding.label}`,
2496
+ ),
2342
2497
  };
2343
2498
  }
2344
2499
 
@@ -2350,7 +2505,7 @@ function scoreSecrets(providerRoot: string): SubmitCheck {
2350
2505
  );
2351
2506
  }
2352
2507
 
2353
- function findSecretFindings(providerRoot: string): SecretFinding[] {
2508
+ function findSecretFindings(providerRoot: string, providerId = "<ID>"): SecretFinding[] {
2354
2509
  const candidateFiles = [
2355
2510
  "README.md",
2356
2511
  "index.ts",
@@ -2371,14 +2526,155 @@ function findSecretFindings(providerRoot: string): SecretFinding[] {
2371
2526
  }
2372
2527
  }
2373
2528
 
2529
+ findings.push(...findEntropySecretFindings(providerRoot, providerId));
2374
2530
  return findings;
2375
2531
  }
2376
2532
 
2533
+ function findEntropySecretFindings(providerRoot: string, providerId: string): SecretFinding[] {
2534
+ const findings: SecretFinding[] = [];
2535
+ for (const filePath of listNonTestProviderSourceFiles(providerRoot)) {
2536
+ const relativePath = toRelativeProviderPath(providerRoot, filePath);
2537
+ if (isEntropySecretExcludedPath(relativePath)) continue;
2538
+ const content = readFileSync(filePath, "utf8");
2539
+ const lines = content.split(/\r?\n/);
2540
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
2541
+ const line = lines[lineIndex] ?? "";
2542
+ for (const candidate of extractStringLiteralCandidates(line)) {
2543
+ const finding = classifyEntropyCandidate({
2544
+ value: candidate,
2545
+ line,
2546
+ file: relativePath,
2547
+ lineNumber: lineIndex + 1,
2548
+ providerId,
2549
+ });
2550
+ if (finding) findings.push(finding);
2551
+ }
2552
+ }
2553
+ }
2554
+ return findings;
2555
+ }
2556
+
2557
+ function isEntropySecretExcludedPath(relativePath: string): boolean {
2558
+ return (
2559
+ relativePath.endsWith(".test.ts") ||
2560
+ relativePath.startsWith("__tests__/") ||
2561
+ relativePath.includes("/__tests__/") ||
2562
+ relativePath.startsWith("__fixtures__/") ||
2563
+ relativePath.includes("/__fixtures__/")
2564
+ );
2565
+ }
2566
+
2567
+ export function extractStringLiteralCandidates(line: string): string[] {
2568
+ const candidates: string[] = [];
2569
+ for (let index = 0; index < line.length; index += 1) {
2570
+ const quote = line[index];
2571
+ if (quote !== '"' && quote !== "'" && quote !== "`") continue;
2572
+
2573
+ const contentStart = index + 1;
2574
+ let cursor = contentStart;
2575
+ while (cursor < line.length) {
2576
+ const char = line[cursor];
2577
+ if (char === "\\") {
2578
+ cursor += 2;
2579
+ continue;
2580
+ }
2581
+ if (char === quote) {
2582
+ if (cursor - contentStart >= 20) {
2583
+ candidates.push(line.slice(contentStart, cursor));
2584
+ }
2585
+ index = cursor;
2586
+ break;
2587
+ }
2588
+ cursor += 1;
2589
+ }
2590
+ }
2591
+ return candidates;
2592
+ }
2593
+
2594
+ function classifyEntropyCandidate(input: {
2595
+ value: string;
2596
+ line: string;
2597
+ file: string;
2598
+ lineNumber: number;
2599
+ providerId: string;
2600
+ }): SecretFinding | undefined {
2601
+ const value = input.value;
2602
+ if (!shouldConsiderEntropyValue(value)) return undefined;
2603
+ const charset = classifyEntropyCharset(value);
2604
+ if (!charset) return undefined;
2605
+ const entropy = shannonEntropy(value);
2606
+ const secretishContext = SECRETISH_IDENTIFIER_PATTERN.test(input.line);
2607
+ const threshold = charset === "hex" ? 3.0 : secretishContext ? 4.0 : 4.5;
2608
+ if (entropy < threshold) return undefined;
2609
+
2610
+ const preview = `${value.slice(0, 4)}...[REDACTED length=${value.length}]`;
2611
+ const envName = `APIFUSE__PROVIDER__${input.providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}__${guessSecretName(input.line)}`;
2612
+ const location = `${input.file}:${input.lineNumber}`;
2613
+ const label =
2614
+ charset === "hex"
2615
+ ? `high-entropy hex string (${entropy.toFixed(2)} bits/char)`
2616
+ : `high-entropy base64-like string (${entropy.toFixed(2)} bits/char)`;
2617
+ return {
2618
+ label,
2619
+ file: input.file,
2620
+ line: input.lineNumber,
2621
+ level: secretishContext ? "blocker" : "warn",
2622
+ remediation: `Move ${location} to an env var read via \`ctx.env.get("${envName}")\` and rotate the leaked credential.`,
2623
+ evidence: `${location}: ${label}; preview ${preview}${secretishContext ? "" : "; may be a false positive"}`,
2624
+ };
2625
+ }
2626
+
2627
+ function shouldConsiderEntropyValue(value: string): boolean {
2628
+ const lower = value.toLowerCase();
2629
+ if (/^(?:dev-only|local|example|sample|your-|replace|<)/i.test(value)) {
2630
+ return false;
2631
+ }
2632
+ if (/^sha(?:256|512)-/i.test(value)) return false;
2633
+ if (/\s/.test(value)) return false;
2634
+ if (value.includes("${")) return false;
2635
+ if (value.includes("/")) return false;
2636
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return false;
2637
+ if (/^(?:\.{0,2}\/|~\/|[A-Za-z]:\\)/.test(value)) return false;
2638
+ if (value.includes(".") && /^[A-Za-z0-9_.-]+$/.test(value)) return false;
2639
+ if (lower.includes("/") && /\.[a-z0-9]{1,8}(?:$|[/?#])/i.test(value)) {
2640
+ return false;
2641
+ }
2642
+ return value.length >= 20;
2643
+ }
2644
+
2645
+ function classifyEntropyCharset(value: string): "base64" | "hex" | undefined {
2646
+ if (/^[a-f0-9]+$/i.test(value) && value.length >= 32) return "hex";
2647
+ const base64ishChars = value.match(/[A-Za-z0-9+/=_-]/g)?.length ?? 0;
2648
+ if (base64ishChars / value.length >= 0.9) return "base64";
2649
+ return undefined;
2650
+ }
2651
+
2652
+ function shannonEntropy(value: string): number {
2653
+ const counts = new Map<string, number>();
2654
+ for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1);
2655
+ let entropy = 0;
2656
+ for (const count of counts.values()) {
2657
+ const probability = count / value.length;
2658
+ entropy -= probability * Math.log2(probability);
2659
+ }
2660
+ return entropy;
2661
+ }
2662
+
2663
+ function guessSecretName(line: string): string {
2664
+ const match =
2665
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/.exec(line) ??
2666
+ /["']?([A-Za-z_$][\w$-]*)["']?\s*:/.exec(line);
2667
+ const raw = match?.[1] ?? "SECRET";
2668
+ return raw
2669
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
2670
+ .toUpperCase()
2671
+ .replace(/[^A-Z0-9]+/g, "_");
2672
+ }
2673
+
2674
+ const SECRETISH_IDENTIFIER_PATTERN = /key|token|secret|password|credential|auth/i;
2675
+
2377
2676
  const SECRET_PATTERNS: Array<[string, RegExp]> = [
2378
- [
2379
- "JWT-like token",
2380
- /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/,
2381
- ],
2677
+ ["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
2382
2678
  ["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],
2383
2679
  ["Stripe live key", /(?:sk|rk)_live_[A-Za-z0-9]{20,}/],
2384
2680
  ["Bearer token", /Bearer\s+[A-Za-z0-9._~+/=-]{32,}/i],
@@ -2388,9 +2684,7 @@ const SECRET_PATTERNS: Array<[string, RegExp]> = [
2388
2684
  ],
2389
2685
  ];
2390
2686
 
2391
- async function safeLoadProvider(
2392
- providerRoot: string,
2393
- ): Promise<ProviderDefinition | undefined> {
2687
+ async function safeLoadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
2394
2688
  try {
2395
2689
  return await loadProvider(providerRoot);
2396
2690
  } catch {
@@ -2398,9 +2692,7 @@ async function safeLoadProvider(
2398
2692
  }
2399
2693
  }
2400
2694
 
2401
- async function loadProvider(
2402
- providerRoot: string,
2403
- ): Promise<ProviderDefinition | undefined> {
2695
+ async function loadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
2404
2696
  const entryPath = resolve(providerRoot, "index.ts");
2405
2697
  if (!existsSync(entryPath)) {
2406
2698
  return undefined;
@@ -2480,8 +2772,7 @@ export function renderText(report: SubmitCheckReport): string {
2480
2772
  ];
2481
2773
 
2482
2774
  for (const check of report.checks) {
2483
- const marker =
2484
- check.status === "pass" ? "✓" : check.status === "warn" ? "⚠" : "✗";
2775
+ const marker = check.status === "pass" ? "✓" : check.status === "warn" ? "⚠" : "✗";
2485
2776
  lines.push(
2486
2777
  `${marker} [${check.category}] ${check.message} (${check.points}/${check.maxPoints})`,
2487
2778
  );
@@ -2503,9 +2794,7 @@ export function renderMarkdown(report: SubmitCheckReport): string {
2503
2794
  `- **Provider**: ${report.provider.id}@${report.provider.version}`,
2504
2795
  `- **SDK**: ${report.provider.sdkVersion}`,
2505
2796
  `- **Runtime/Auth**: ${report.provider.runtime} / ${report.provider.authMode}`,
2506
- ...(report.provider.tier
2507
- ? [`- **Bounty tier**: ${report.provider.tier}`]
2508
- : []),
2797
+ ...(report.provider.tier ? [`- **Bounty tier**: ${report.provider.tier}`] : []),
2509
2798
  `- **Score**: ${report.score.total}/${report.score.max}`,
2510
2799
  `- **Verdict**: ${report.score.verdict}`,
2511
2800
  `- **Blockers**: ${report.summary.blockers}`,
@@ -2518,12 +2807,7 @@ export function renderMarkdown(report: SubmitCheckReport): string {
2518
2807
  ];
2519
2808
 
2520
2809
  for (const check of report.checks) {
2521
- const status =
2522
- check.status === "pass"
2523
- ? "PASS"
2524
- : check.status === "warn"
2525
- ? "WARN"
2526
- : "FAIL";
2810
+ const status = check.status === "pass" ? "PASS" : check.status === "warn" ? "WARN" : "FAIL";
2527
2811
  lines.push(
2528
2812
  `| ${status} | ${escapeMarkdown(check.category)} | ${escapeMarkdown(check.message)} | ${check.points}/${check.maxPoints} | ${escapeMarkdown(check.remediation ?? "")} |`,
2529
2813
  );
@@ -2553,9 +2837,7 @@ function redact(value: string): string {
2553
2837
  }
2554
2838
 
2555
2839
  function toGlobalRegex(pattern: RegExp): RegExp {
2556
- return pattern.global
2557
- ? pattern
2558
- : new RegExp(pattern.source, `${pattern.flags}g`);
2840
+ return pattern.global ? pattern : new RegExp(pattern.source, `${pattern.flags}g`);
2559
2841
  }
2560
2842
 
2561
2843
  function clamp(value: number, min: number, max: number): number {