@apifuse/provider-sdk 2.1.0-beta.17 → 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.
- package/CHANGELOG.md +8 -0
- package/README.md +3 -3
- package/SUBMISSION.md +10 -11
- package/bin/apifuse-submit-check.ts +599 -288
- package/dist/cli/create.js +10 -26
- package/dist/cli/templates/provider/README.md.tpl +6 -4
- package/package.json +1 -1
- package/src/cli/create.ts +25 -93
- package/src/cli/templates/provider/README.md.tpl +6 -4
|
@@ -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
|
|
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(
|
|
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,33 +423,24 @@ 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 {
|
|
436
430
|
const findings = findSourceLineMatches(providerRoot, /(?<![.\w])fetch\s*\(/);
|
|
437
431
|
if (findings.length > 0) {
|
|
432
|
+
const evidence = formatSourceFindings(findings);
|
|
438
433
|
return blocker(
|
|
439
434
|
"no-raw-fetch",
|
|
440
435
|
SDK_NATIVE_CATEGORY,
|
|
441
436
|
"Provider source calls raw fetch().",
|
|
442
|
-
|
|
437
|
+
`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
438
|
0,
|
|
444
|
-
|
|
439
|
+
evidence,
|
|
445
440
|
);
|
|
446
441
|
}
|
|
447
442
|
|
|
448
|
-
return pass(
|
|
449
|
-
"no-raw-fetch",
|
|
450
|
-
SDK_NATIVE_CATEGORY,
|
|
451
|
-
"Provider source avoids raw fetch().",
|
|
452
|
-
0,
|
|
453
|
-
);
|
|
443
|
+
return pass("no-raw-fetch", SDK_NATIVE_CATEGORY, "Provider source avoids raw fetch().", 0);
|
|
454
444
|
}
|
|
455
445
|
|
|
456
446
|
const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
|
|
@@ -460,10 +450,7 @@ const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
|
|
|
460
450
|
const SDK_CONTEXT_METHOD_ALIAS_PATTERN =
|
|
461
451
|
/\bconst\s+(\w+)\s*=\s*ctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|choice)\.(?:\w+)/;
|
|
462
452
|
|
|
463
|
-
function hasRedundantRuntimeGuard(
|
|
464
|
-
line: string,
|
|
465
|
-
remainingLines: readonly string[],
|
|
466
|
-
): boolean {
|
|
453
|
+
function hasRedundantRuntimeGuard(line: string, remainingLines: readonly string[]): boolean {
|
|
467
454
|
if (REDUNDANT_RUNTIME_GUARD_PATTERNS.some((pattern) => pattern.test(line))) {
|
|
468
455
|
return true;
|
|
469
456
|
}
|
|
@@ -474,12 +461,8 @@ function hasRedundantRuntimeGuard(
|
|
|
474
461
|
return false;
|
|
475
462
|
}
|
|
476
463
|
|
|
477
|
-
const guardPattern = new RegExp(
|
|
478
|
-
|
|
479
|
-
);
|
|
480
|
-
return remainingLines
|
|
481
|
-
.slice(0, 8)
|
|
482
|
-
.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));
|
|
483
466
|
}
|
|
484
467
|
|
|
485
468
|
function scoreNoRedundantRuntimeGuards(providerRoot: string): SubmitCheck {
|
|
@@ -580,11 +563,7 @@ function scoreAsAssertionCount(providerRoot: string): SubmitCheck {
|
|
|
580
563
|
|
|
581
564
|
// Returns true when `findingLine` (1-based) or the line directly above it
|
|
582
565
|
// carries an `// @apifuse-allow <ruleId>:` acknowledgement comment.
|
|
583
|
-
function hasAllowOverride(
|
|
584
|
-
lines: readonly string[],
|
|
585
|
-
findingLine: number,
|
|
586
|
-
ruleId: string,
|
|
587
|
-
): boolean {
|
|
566
|
+
function hasAllowOverride(lines: readonly string[], findingLine: number, ruleId: string): boolean {
|
|
588
567
|
const pattern = new RegExp(`@apifuse-allow\\s+${ruleId}\\b`);
|
|
589
568
|
const current = lines[findingLine - 1];
|
|
590
569
|
const previous = lines[findingLine - 2];
|
|
@@ -635,11 +614,7 @@ function escapeHatchResult(
|
|
|
635
614
|
return pass(ruleId, SDK_NATIVE_CATEGORY, copy.passMessage, 0);
|
|
636
615
|
}
|
|
637
616
|
|
|
638
|
-
const { violations, overridden } = partitionAllowOverrides(
|
|
639
|
-
providerRoot,
|
|
640
|
-
findings,
|
|
641
|
-
ruleId,
|
|
642
|
-
);
|
|
617
|
+
const { violations, overridden } = partitionAllowOverrides(providerRoot, findings, ruleId);
|
|
643
618
|
|
|
644
619
|
if (violations.length > 0) {
|
|
645
620
|
return blocker(
|
|
@@ -952,9 +927,7 @@ function inputKeyIsSchemaField(source: string, propIndex: number): boolean {
|
|
|
952
927
|
// across unrelated modules from producing false positives).
|
|
953
928
|
function fileImportsBinding(source: string, name: string): boolean {
|
|
954
929
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
955
|
-
return new RegExp(`\\bimport\\b[^;]*\\b${escaped}\\b[^;]*\\bfrom\\b`).test(
|
|
956
|
-
source,
|
|
957
|
-
);
|
|
930
|
+
return new RegExp(`\\bimport\\b[^;]*\\b${escaped}\\b[^;]*\\bfrom\\b`).test(source);
|
|
958
931
|
}
|
|
959
932
|
|
|
960
933
|
// Resolves the ORIGINAL exported name for a local binding `localName`. When the
|
|
@@ -998,11 +971,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
998
971
|
passthroughByFile.set(filePath, localMap);
|
|
999
972
|
const constDecl =
|
|
1000
973
|
/(?:^|\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
|
-
) {
|
|
974
|
+
for (let match = constDecl.exec(source); match !== null; match = constDecl.exec(source)) {
|
|
1006
975
|
const name = match[1];
|
|
1007
976
|
if (name === undefined) {
|
|
1008
977
|
continue;
|
|
@@ -1041,11 +1010,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
1041
1010
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
1042
1011
|
|
|
1043
1012
|
const inputProp = /\binput\s*:\s*/g;
|
|
1044
|
-
for (
|
|
1045
|
-
let match = inputProp.exec(source);
|
|
1046
|
-
match !== null;
|
|
1047
|
-
match = inputProp.exec(source)
|
|
1048
|
-
) {
|
|
1013
|
+
for (let match = inputProp.exec(source); match !== null; match = inputProp.exec(source)) {
|
|
1049
1014
|
// Skip `input` keys that are fields inside a zod schema body (e.g. an
|
|
1050
1015
|
// upstream payload modelled as `z.object({ input: ... })`). Only an
|
|
1051
1016
|
// operation's public `input:` property is in scope for this rule.
|
|
@@ -1073,9 +1038,7 @@ function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
|
1073
1038
|
// Imported binding: map a possible `orig as refName` alias
|
|
1074
1039
|
// back to the exported name the provider-wide map is keyed by.
|
|
1075
1040
|
const originalName = importedOriginalName(source, refName);
|
|
1076
|
-
const site =
|
|
1077
|
-
passthroughConsts.get(refName) ??
|
|
1078
|
-
passthroughConsts.get(originalName);
|
|
1041
|
+
const site = passthroughConsts.get(refName) ?? passthroughConsts.get(originalName);
|
|
1079
1042
|
if (site) {
|
|
1080
1043
|
push(site);
|
|
1081
1044
|
}
|
|
@@ -1121,8 +1084,7 @@ function scoreUnjustifiedLooseSchema(providerRoot: string): SubmitCheck {
|
|
|
1121
1084
|
// A `//` justification comment on the same line or the line above
|
|
1122
1085
|
// (including the `@apifuse-allow loose-schema:` form) acknowledges it.
|
|
1123
1086
|
const previous = lines[index - 1];
|
|
1124
|
-
const justified =
|
|
1125
|
-
line.includes("//") || previous?.trim().startsWith("//") === true;
|
|
1087
|
+
const justified = line.includes("//") || previous?.trim().startsWith("//") === true;
|
|
1126
1088
|
if (!justified) {
|
|
1127
1089
|
findings.push({
|
|
1128
1090
|
file: toRelativeProviderPath(providerRoot, filePath),
|
|
@@ -1133,8 +1095,7 @@ function scoreUnjustifiedLooseSchema(providerRoot: string): SubmitCheck {
|
|
|
1133
1095
|
}
|
|
1134
1096
|
|
|
1135
1097
|
return escapeHatchResult(providerRoot, "unjustified-loose-schema", findings, {
|
|
1136
|
-
blockerMessage:
|
|
1137
|
-
"Loose schema (z.record/z.unknown/z.any) used without justification.",
|
|
1098
|
+
blockerMessage: "Loose schema (z.record/z.unknown/z.any) used without justification.",
|
|
1138
1099
|
remediation:
|
|
1139
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.",
|
|
1140
1101
|
passMessage: "Loose schemas are justified or absent.",
|
|
@@ -1163,24 +1124,18 @@ function spreadIdentifierResolvesToFactory(
|
|
|
1163
1124
|
let sawDeclaration = false;
|
|
1164
1125
|
for (const filePath of [
|
|
1165
1126
|
indexPath,
|
|
1166
|
-
...listNonTestTypeScriptFiles(providerRoot).filter(
|
|
1167
|
-
(p) => resolve(p) !== resolve(indexPath),
|
|
1168
|
-
),
|
|
1127
|
+
...listNonTestTypeScriptFiles(providerRoot).filter((p) => resolve(p) !== resolve(indexPath)),
|
|
1169
1128
|
]) {
|
|
1170
1129
|
if (!existsSync(filePath)) {
|
|
1171
1130
|
continue;
|
|
1172
1131
|
}
|
|
1173
|
-
const fileSource =
|
|
1174
|
-
filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
|
|
1132
|
+
const fileSource = filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
|
|
1175
1133
|
const re = new RegExp(declRe.source, "g");
|
|
1176
1134
|
for (let m = re.exec(fileSource); m !== null; m = re.exec(fileSource)) {
|
|
1177
1135
|
sawDeclaration = true;
|
|
1178
|
-
const expr = unwrapParens(
|
|
1179
|
-
balancedValueExpression(fileSource, m.index + m[0].length).trim(),
|
|
1180
|
-
);
|
|
1136
|
+
const expr = unwrapParens(balancedValueExpression(fileSource, m.index + m[0].length).trim());
|
|
1181
1137
|
const isFactory =
|
|
1182
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) ||
|
|
1183
|
-
hasTopLevelFactorySpread(expr)) &&
|
|
1138
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
|
|
1184
1139
|
!isTransparentObjectReshape(expr);
|
|
1185
1140
|
if (isFactory) {
|
|
1186
1141
|
return true;
|
|
@@ -1232,9 +1187,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1232
1187
|
if (inlineDefault) {
|
|
1233
1188
|
defineParenIndex = inlineDefault.index + inlineDefault[0].length - 1; // points at `(`
|
|
1234
1189
|
} else {
|
|
1235
|
-
const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(
|
|
1236
|
-
source,
|
|
1237
|
-
);
|
|
1190
|
+
const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(source);
|
|
1238
1191
|
const exportedName = namedDefault?.[1];
|
|
1239
1192
|
if (exportedName !== undefined) {
|
|
1240
1193
|
const namedDecl = new RegExp(
|
|
@@ -1308,9 +1261,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1308
1261
|
// Search index.ts first (its line attribution wins), then siblings.
|
|
1309
1262
|
const searchOrder = [
|
|
1310
1263
|
indexPath,
|
|
1311
|
-
...listNonTestTypeScriptFiles(providerRoot).filter(
|
|
1312
|
-
(p) => resolve(p) !== resolve(indexPath),
|
|
1313
|
-
),
|
|
1264
|
+
...listNonTestTypeScriptFiles(providerRoot).filter((p) => resolve(p) !== resolve(indexPath)),
|
|
1314
1265
|
];
|
|
1315
1266
|
|
|
1316
1267
|
// Collect EVERY same-named declaration across the submission and classify
|
|
@@ -1330,23 +1281,15 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1330
1281
|
if (!existsSync(filePath)) {
|
|
1331
1282
|
continue;
|
|
1332
1283
|
}
|
|
1333
|
-
const fileSource =
|
|
1334
|
-
filePath === indexPath ? source : readFileSync(filePath, "utf8");
|
|
1284
|
+
const fileSource = filePath === indexPath ? source : readFileSync(filePath, "utf8");
|
|
1335
1285
|
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
1336
1286
|
|
|
1337
1287
|
const declRe = new RegExp(aliasDecl.source, "g");
|
|
1338
|
-
for (
|
|
1339
|
-
let m = declRe.exec(fileSource);
|
|
1340
|
-
m !== null;
|
|
1341
|
-
m = declRe.exec(fileSource)
|
|
1342
|
-
) {
|
|
1288
|
+
for (let m = declRe.exec(fileSource); m !== null; m = declRe.exec(fileSource)) {
|
|
1343
1289
|
const valueStart = m.index + m[0].length;
|
|
1344
|
-
const expr = unwrapParens(
|
|
1345
|
-
balancedValueExpression(fileSource, valueStart).trim(),
|
|
1346
|
-
);
|
|
1290
|
+
const expr = unwrapParens(balancedValueExpression(fileSource, valueStart).trim());
|
|
1347
1291
|
const isFactory =
|
|
1348
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) ||
|
|
1349
|
-
hasTopLevelFactorySpread(expr)) &&
|
|
1292
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) || hasTopLevelFactorySpread(expr)) &&
|
|
1350
1293
|
!isTransparentObjectReshape(expr);
|
|
1351
1294
|
candidates.push({
|
|
1352
1295
|
expr,
|
|
@@ -1356,11 +1299,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1356
1299
|
});
|
|
1357
1300
|
}
|
|
1358
1301
|
const destructRe = new RegExp(destructured.source, "g");
|
|
1359
|
-
for (
|
|
1360
|
-
let m = destructRe.exec(fileSource);
|
|
1361
|
-
m !== null;
|
|
1362
|
-
m = destructRe.exec(fileSource)
|
|
1363
|
-
) {
|
|
1302
|
+
for (let m = destructRe.exec(fileSource); m !== null; m = destructRe.exec(fileSource)) {
|
|
1364
1303
|
candidates.push({
|
|
1365
1304
|
expr: `${m[1]}(`,
|
|
1366
1305
|
line: offsetToLine(fileSource, m.index),
|
|
@@ -1388,9 +1327,9 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1388
1327
|
// the unresolved import as a factory-composed (non-static) shape rather
|
|
1389
1328
|
// than silently passing.
|
|
1390
1329
|
if (!resolved) {
|
|
1391
|
-
const importMatch = new RegExp(
|
|
1392
|
-
|
|
1393
|
-
)
|
|
1330
|
+
const importMatch = new RegExp(`\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`).exec(
|
|
1331
|
+
source,
|
|
1332
|
+
);
|
|
1394
1333
|
if (importMatch) {
|
|
1395
1334
|
effective = `${aliasName}(`;
|
|
1396
1335
|
effectiveLine = offsetToLine(source, importMatch.index);
|
|
@@ -1405,8 +1344,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1405
1344
|
// inspect depth-1 entries so that ordinary spreads deep inside operation
|
|
1406
1345
|
// handler bodies (e.g. `{ ...headers }`, `...arr.map(...)`) are NOT mistaken
|
|
1407
1346
|
// for a top-level factory composition of the operations map itself.
|
|
1408
|
-
const hasFactorySpread =
|
|
1409
|
-
effective !== undefined && hasTopLevelFactorySpread(effective);
|
|
1347
|
+
const hasFactorySpread = effective !== undefined && hasTopLevelFactorySpread(effective);
|
|
1410
1348
|
// A spread of a bare identifier (`{ ...hidden }`) is static ONLY when that
|
|
1411
1349
|
// identifier resolves to a non-factory declaration. Resolve each top-level
|
|
1412
1350
|
// spread identifier so an opaque factory map laundered through a variable
|
|
@@ -1417,18 +1355,14 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1417
1355
|
spreadIdentifierResolvesToFactory(providerRoot, indexPath, source, name),
|
|
1418
1356
|
);
|
|
1419
1357
|
const isStaticLiteral =
|
|
1420
|
-
effective?.startsWith("{") === true &&
|
|
1421
|
-
!hasFactorySpread &&
|
|
1422
|
-
!hasFactorySpreadIdentifier;
|
|
1358
|
+
effective?.startsWith("{") === true && !hasFactorySpread && !hasFactorySpreadIdentifier;
|
|
1423
1359
|
// A call expression `ident(...)` (factory) or a factory-spread literal is
|
|
1424
1360
|
// the rejected, non-static shape — UNLESS it is the stdlib
|
|
1425
1361
|
// `Object.fromEntries(Object.entries(<source-visible obj>)...)` reshape,
|
|
1426
1362
|
// whose op set is still enumerable from source (verified golden pattern).
|
|
1427
1363
|
const isFactoryCall =
|
|
1428
1364
|
effective !== undefined &&
|
|
1429
|
-
(/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) ||
|
|
1430
|
-
hasFactorySpread ||
|
|
1431
|
-
hasFactorySpreadIdentifier) &&
|
|
1365
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) || hasFactorySpread || hasFactorySpreadIdentifier) &&
|
|
1432
1366
|
!isTransparentObjectReshape(effective);
|
|
1433
1367
|
|
|
1434
1368
|
if (isFactoryCall && !isStaticLiteral) {
|
|
@@ -1436,19 +1370,13 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1436
1370
|
// `// @apifuse-allow flat-operation-composition: <reason>` comment on
|
|
1437
1371
|
// the reported line (or the line above) downgrades this blocker to a
|
|
1438
1372
|
// counted warning, consistent with the other structural rules.
|
|
1439
|
-
return escapeHatchResult(
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
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
|
-
);
|
|
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
|
+
});
|
|
1452
1380
|
}
|
|
1453
1381
|
|
|
1454
1382
|
return pass(
|
|
@@ -1459,18 +1387,11 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
|
1459
1387
|
);
|
|
1460
1388
|
}
|
|
1461
1389
|
|
|
1462
|
-
function scoreCredentialUsage(
|
|
1463
|
-
providerRoot
|
|
1464
|
-
provider: ProviderDefinition,
|
|
1465
|
-
): SubmitCheck {
|
|
1466
|
-
const credentialReferences = findSourceLineMatches(
|
|
1467
|
-
providerRoot,
|
|
1468
|
-
/ctx\.credential/,
|
|
1469
|
-
);
|
|
1390
|
+
function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
|
|
1391
|
+
const credentialReferences = findSourceLineMatches(providerRoot, /ctx\.credential/);
|
|
1470
1392
|
const authMode = provider.auth?.mode ?? "none";
|
|
1471
1393
|
const credentialKeys = provider.credential?.keys ?? [];
|
|
1472
|
-
const storesProviderCredential =
|
|
1473
|
-
authMode !== "none" || credentialKeys.length > 0;
|
|
1394
|
+
const storesProviderCredential = authMode !== "none" || credentialKeys.length > 0;
|
|
1474
1395
|
|
|
1475
1396
|
if (storesProviderCredential && credentialReferences.length === 0) {
|
|
1476
1397
|
return {
|
|
@@ -1480,8 +1401,7 @@ function scoreCredentialUsage(
|
|
|
1480
1401
|
status: "warn",
|
|
1481
1402
|
points: 0,
|
|
1482
1403
|
maxPoints: 0,
|
|
1483
|
-
message:
|
|
1484
|
-
"Credential-backed provider does not reference credential persistence in source.",
|
|
1404
|
+
message: "Credential-backed provider does not reference credential persistence in source.",
|
|
1485
1405
|
remediation:
|
|
1486
1406
|
"Persist provider session state through the SDK credential context instead of process-local state. See providers/catchtable for the reference pattern.",
|
|
1487
1407
|
};
|
|
@@ -1494,9 +1414,7 @@ function scoreCredentialUsage(
|
|
|
1494
1414
|
? "Provider does not declare reusable credentials."
|
|
1495
1415
|
: "Credential-backed provider references ctx.credential.",
|
|
1496
1416
|
0,
|
|
1497
|
-
credentialReferences.length > 0
|
|
1498
|
-
? formatSourceFindings(credentialReferences)
|
|
1499
|
-
: undefined,
|
|
1417
|
+
credentialReferences.length > 0 ? formatSourceFindings(credentialReferences) : undefined,
|
|
1500
1418
|
);
|
|
1501
1419
|
}
|
|
1502
1420
|
|
|
@@ -1504,9 +1422,7 @@ function findSourceLineMatches(
|
|
|
1504
1422
|
providerRoot: string,
|
|
1505
1423
|
pattern: RegExp | ((line: string) => boolean),
|
|
1506
1424
|
): SourceFinding[] {
|
|
1507
|
-
return findSourceFindings(providerRoot, (line) =>
|
|
1508
|
-
matchesLinePattern(line, pattern),
|
|
1509
|
-
);
|
|
1425
|
+
return findSourceFindings(providerRoot, (line) => matchesLinePattern(line, pattern));
|
|
1510
1426
|
}
|
|
1511
1427
|
|
|
1512
1428
|
function findSourceFindings(
|
|
@@ -1533,10 +1449,7 @@ function findSourceFindings(
|
|
|
1533
1449
|
return findings;
|
|
1534
1450
|
}
|
|
1535
1451
|
|
|
1536
|
-
function matchesLinePattern(
|
|
1537
|
-
line: string,
|
|
1538
|
-
pattern: RegExp | ((line: string) => boolean),
|
|
1539
|
-
): boolean {
|
|
1452
|
+
function matchesLinePattern(line: string, pattern: RegExp | ((line: string) => boolean)): boolean {
|
|
1540
1453
|
return typeof pattern === "function" ? pattern(line) : pattern.test(line);
|
|
1541
1454
|
}
|
|
1542
1455
|
|
|
@@ -1590,11 +1503,7 @@ function collectNonTestTypeScriptFiles(
|
|
|
1590
1503
|
}
|
|
1591
1504
|
continue;
|
|
1592
1505
|
}
|
|
1593
|
-
if (
|
|
1594
|
-
entry.isFile() &&
|
|
1595
|
-
relativePath.endsWith(".ts") &&
|
|
1596
|
-
!isExcludedTestSource(relativePath)
|
|
1597
|
-
) {
|
|
1506
|
+
if (entry.isFile() && relativePath.endsWith(".ts") && !isExcludedTestSource(relativePath)) {
|
|
1598
1507
|
files.push(entryPath);
|
|
1599
1508
|
}
|
|
1600
1509
|
}
|
|
@@ -1609,9 +1518,7 @@ function isScannableProviderSourceFile(relativePath: string): boolean {
|
|
|
1609
1518
|
}
|
|
1610
1519
|
|
|
1611
1520
|
function shouldScanSourceDirectory(relativePath: string): boolean {
|
|
1612
|
-
return ![".git", "node_modules", "dist", "build", "coverage"].includes(
|
|
1613
|
-
relativePath,
|
|
1614
|
-
);
|
|
1521
|
+
return ![".git", "node_modules", "dist", "build", "coverage"].includes(relativePath);
|
|
1615
1522
|
}
|
|
1616
1523
|
|
|
1617
1524
|
function isExcludedTestSource(relativePath: string): boolean {
|
|
@@ -1624,10 +1531,7 @@ function isExcludedTestSource(relativePath: string): boolean {
|
|
|
1624
1531
|
);
|
|
1625
1532
|
}
|
|
1626
1533
|
|
|
1627
|
-
function toRelativeProviderPath(
|
|
1628
|
-
providerRoot: string,
|
|
1629
|
-
filePath: string,
|
|
1630
|
-
): string {
|
|
1534
|
+
function toRelativeProviderPath(providerRoot: string, filePath: string): string {
|
|
1631
1535
|
return relative(providerRoot, filePath).replaceAll("\\", "/");
|
|
1632
1536
|
}
|
|
1633
1537
|
|
|
@@ -1673,9 +1577,7 @@ function scoreRepositoryDx(providerRoot: string): SubmitCheck {
|
|
|
1673
1577
|
};
|
|
1674
1578
|
}
|
|
1675
1579
|
|
|
1676
|
-
function readPackageScripts(
|
|
1677
|
-
packageJsonPath: string,
|
|
1678
|
-
): Record<string, unknown> | undefined {
|
|
1580
|
+
function readPackageScripts(packageJsonPath: string): Record<string, unknown> | undefined {
|
|
1679
1581
|
if (!existsSync(packageJsonPath)) {
|
|
1680
1582
|
return undefined;
|
|
1681
1583
|
}
|
|
@@ -1790,6 +1692,10 @@ function scoreManagedBrowserRuntime(providerRoot: string): SubmitCheck {
|
|
|
1790
1692
|
function scoreBaseChecks(results: CheckResult[]): SubmitCheck[] {
|
|
1791
1693
|
const failed = results.filter((result) => !result.passed);
|
|
1792
1694
|
if (failed.length > 0) {
|
|
1695
|
+
const remediation = [
|
|
1696
|
+
"Run `bunx apifuse check .` from the provider root.",
|
|
1697
|
+
...Array.from(new Set(failed.map(baseCheckRemediation))),
|
|
1698
|
+
].join(" ");
|
|
1793
1699
|
return [
|
|
1794
1700
|
{
|
|
1795
1701
|
id: "base-checks",
|
|
@@ -1799,8 +1705,7 @@ function scoreBaseChecks(results: CheckResult[]): SubmitCheck[] {
|
|
|
1799
1705
|
points: 0,
|
|
1800
1706
|
maxPoints: CATEGORY_MAX_POINTS.definition,
|
|
1801
1707
|
message: "Base provider checks failed.",
|
|
1802
|
-
remediation
|
|
1803
|
-
"Run `bun run check` and fix every failing item before bounty submission.",
|
|
1708
|
+
remediation,
|
|
1804
1709
|
evidence: failed.map((result) =>
|
|
1805
1710
|
redact(`${result.message}: ${(result.details ?? []).join("; ")}`),
|
|
1806
1711
|
),
|
|
@@ -1822,10 +1727,32 @@ function scoreBaseChecks(results: CheckResult[]): SubmitCheck[] {
|
|
|
1822
1727
|
];
|
|
1823
1728
|
}
|
|
1824
1729
|
|
|
1825
|
-
function
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1730
|
+
function baseCheckRemediation(result: CheckResult): string {
|
|
1731
|
+
switch (result.message) {
|
|
1732
|
+
case "index.ts exists and exports default defineProvider":
|
|
1733
|
+
return "Fix `index.ts` so it default-exports `defineProvider({...})`.";
|
|
1734
|
+
case "All operations have handler, input, output":
|
|
1735
|
+
return "For each operation named in evidence, add `handler`, `input`, and `output` fields to `defineProvider({ operations })`.";
|
|
1736
|
+
case "All operations have fixtures":
|
|
1737
|
+
return "For each operation named in evidence, add `fixtures.request` and `fixtures.response` values that exercise the operation schemas.";
|
|
1738
|
+
case "Zod schemas parse fixtures without error":
|
|
1739
|
+
return "Update the failing fixture values or their zod schemas until `fixtures.request` and `fixtures.response` parse cleanly.";
|
|
1740
|
+
case "Provider authoring lint has no error-level diagnostics":
|
|
1741
|
+
return "Fix each lint diagnostic shown in evidence, then rerun `bunx apifuse check .`.";
|
|
1742
|
+
case "Provider metadata is declared in defineProvider":
|
|
1743
|
+
return "Fill the missing `defineProvider` metadata fields: `id`, `meta.displayName`, `meta.category`, `runtime`, and `auth.mode`.";
|
|
1744
|
+
case "Dockerfile exists":
|
|
1745
|
+
return "Add a provider-root `Dockerfile` based on the current `apifuse create` template.";
|
|
1746
|
+
case "package.json exists with @apifuse/provider-sdk dependency":
|
|
1747
|
+
return "Add `@apifuse/provider-sdk` to `package.json` dependencies.";
|
|
1748
|
+
case "Base provider checks can run":
|
|
1749
|
+
return "Fix the import/runtime error shown in evidence so `apifuse check` can load the provider.";
|
|
1750
|
+
default:
|
|
1751
|
+
return `Fix the failing base check "${result.message}" shown in evidence.`;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
function scoreLocaleCatalog(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
|
|
1829
1756
|
const requiredKeys = collectProviderRequiredLocaleKeys(provider);
|
|
1830
1757
|
if (requiredKeys.length === 0) {
|
|
1831
1758
|
return pass(
|
|
@@ -1856,9 +1783,7 @@ function scoreLocaleCatalog(
|
|
|
1856
1783
|
"Provider locale catalog is missing required public-provider copy.",
|
|
1857
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.",
|
|
1858
1785
|
0,
|
|
1859
|
-
validation.issues.map(
|
|
1860
|
-
(issue) => `${issue.locale}:${issue.key}: ${issue.message}`,
|
|
1861
|
-
),
|
|
1786
|
+
validation.issues.map((issue) => `${issue.locale}:${issue.key}: ${issue.message}`),
|
|
1862
1787
|
);
|
|
1863
1788
|
}
|
|
1864
1789
|
} catch (error) {
|
|
@@ -1881,9 +1806,7 @@ function scoreLocaleCatalog(
|
|
|
1881
1806
|
);
|
|
1882
1807
|
}
|
|
1883
1808
|
|
|
1884
|
-
function collectProviderRequiredLocaleKeys(
|
|
1885
|
-
provider: ProviderDefinition,
|
|
1886
|
-
): string[] {
|
|
1809
|
+
function collectProviderRequiredLocaleKeys(provider: ProviderDefinition): string[] {
|
|
1887
1810
|
const keys = new Set<string>();
|
|
1888
1811
|
|
|
1889
1812
|
addLocaleKeys(keys, [
|
|
@@ -1946,10 +1869,7 @@ function collectSchemaDescriptionKeys(schema: unknown): string[] {
|
|
|
1946
1869
|
return keys;
|
|
1947
1870
|
}
|
|
1948
1871
|
|
|
1949
|
-
function collectJsonSchemaDescriptionKeys(
|
|
1950
|
-
schema: Record<string, unknown>,
|
|
1951
|
-
keys: string[],
|
|
1952
|
-
): void {
|
|
1872
|
+
function collectJsonSchemaDescriptionKeys(schema: Record<string, unknown>, keys: string[]): void {
|
|
1953
1873
|
const descriptionKey = schema[APIFUSE_DESCRIPTION_KEY_META_KEY];
|
|
1954
1874
|
if (typeof descriptionKey === "string" && descriptionKey.length > 0) {
|
|
1955
1875
|
keys.push(descriptionKey);
|
|
@@ -1981,8 +1901,7 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
|
|
|
1981
1901
|
// is enforced at registry catalog-build time, matching how lintOperation
|
|
1982
1902
|
// skips the raw-description min-length rule when a descriptionKey is set.
|
|
1983
1903
|
const hasDescriptionKey =
|
|
1984
|
-
typeof operation.descriptionKey === "string" &&
|
|
1985
|
-
operation.descriptionKey.length > 0;
|
|
1904
|
+
typeof operation.descriptionKey === "string" && operation.descriptionKey.length > 0;
|
|
1986
1905
|
if (hasDescriptionKey) return false;
|
|
1987
1906
|
return true;
|
|
1988
1907
|
})
|
|
@@ -2000,14 +1919,12 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
|
|
|
2000
1919
|
points: 0,
|
|
2001
1920
|
maxPoints: CATEGORY_MAX_POINTS.operations,
|
|
2002
1921
|
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.",
|
|
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.`,
|
|
2005
1923
|
evidence: weakDescriptions,
|
|
2006
1924
|
};
|
|
2007
1925
|
}
|
|
2008
1926
|
|
|
2009
|
-
const points =
|
|
2010
|
-
missingAnnotations.length > 0 ? 11 : CATEGORY_MAX_POINTS.operations;
|
|
1927
|
+
const points = missingAnnotations.length > 0 ? 11 : CATEGORY_MAX_POINTS.operations;
|
|
2011
1928
|
return {
|
|
2012
1929
|
id: "operation-metadata",
|
|
2013
1930
|
category: "operations",
|
|
@@ -2021,30 +1938,25 @@ function scoreOperationMetadata(provider: ProviderDefinition): SubmitCheck {
|
|
|
2021
1938
|
: "Operation descriptions and metadata are review-ready.",
|
|
2022
1939
|
remediation:
|
|
2023
1940
|
missingAnnotations.length > 0
|
|
2024
|
-
? "
|
|
1941
|
+
? `For ${missingAnnotations.join(", ")}, add \`annotations\` with the applicable safety fields, such as \`readOnly\`, \`destructive\`, \`idempotent\`, \`openWorld\`, \`rateLimit\`, or \`timeoutMs\`.`
|
|
2025
1942
|
: undefined,
|
|
2026
1943
|
evidence:
|
|
2027
1944
|
missingAnnotations.length > 0
|
|
2028
|
-
? missingAnnotations.map(
|
|
2029
|
-
(operationId) => `${operationId}: missing annotations`,
|
|
2030
|
-
)
|
|
1945
|
+
? missingAnnotations.map((operationId) => `${operationId}: missing annotations`)
|
|
2031
1946
|
: operations.map(([operationId]) => operationId),
|
|
2032
1947
|
};
|
|
2033
1948
|
}
|
|
2034
1949
|
|
|
2035
1950
|
function scoreFixtureCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
2036
1951
|
const missing = Object.entries(provider.operations)
|
|
2037
|
-
.filter(
|
|
2038
|
-
([, operation]) =>
|
|
2039
|
-
!operation.fixtures?.request || !operation.fixtures?.response,
|
|
2040
|
-
)
|
|
1952
|
+
.filter(([, operation]) => !operation.fixtures?.request || !operation.fixtures?.response)
|
|
2041
1953
|
.map(([operationId]) => operationId);
|
|
2042
1954
|
if (missing.length > 0) {
|
|
2043
1955
|
return blocker(
|
|
2044
1956
|
"fixtures",
|
|
2045
1957
|
"fixtures",
|
|
2046
1958
|
"One or more operations are missing bidirectional fixtures.",
|
|
2047
|
-
"
|
|
1959
|
+
`For ${missing.join(", ")}, add \`fixtures.request\` and \`fixtures.response\` values that parse against the operation input and output schemas.`,
|
|
2048
1960
|
CATEGORY_MAX_POINTS.fixtures,
|
|
2049
1961
|
missing,
|
|
2050
1962
|
);
|
|
@@ -2078,9 +1990,7 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2078
1990
|
generatedStarter.push(operationId);
|
|
2079
1991
|
}
|
|
2080
1992
|
if (
|
|
2081
|
-
/(todo|later|tbd|test fixture|unit test|placeholder|not sure|skip for test)/i.test(
|
|
2082
|
-
reason,
|
|
2083
|
-
)
|
|
1993
|
+
/(todo|later|tbd|test fixture|unit test|placeholder|not sure|skip for test)/i.test(reason)
|
|
2084
1994
|
) {
|
|
2085
1995
|
placeholder.push(operationId);
|
|
2086
1996
|
}
|
|
@@ -2092,7 +2002,7 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2092
2002
|
"health-coverage",
|
|
2093
2003
|
"health",
|
|
2094
2004
|
"One or more operations lack healthCheck or healthCheckUnsupported.",
|
|
2095
|
-
"
|
|
2005
|
+
`For ${missing.join(", ")}, add \`healthCheck: { interval, cases }\` for safe read-only upstream probes, or add \`healthCheckUnsupported: { reason: "<specific reason>" }\`.`,
|
|
2096
2006
|
CATEGORY_MAX_POINTS.health,
|
|
2097
2007
|
missing,
|
|
2098
2008
|
);
|
|
@@ -2107,8 +2017,7 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2107
2017
|
points: 8,
|
|
2108
2018
|
maxPoints: CATEGORY_MAX_POINTS.health,
|
|
2109
2019
|
message: "Some healthCheckUnsupported reasons look placeholder-like.",
|
|
2110
|
-
remediation:
|
|
2111
|
-
"Replace placeholder rationale 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.`,
|
|
2112
2021
|
evidence: placeholder,
|
|
2113
2022
|
};
|
|
2114
2023
|
}
|
|
@@ -2123,8 +2032,7 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2123
2032
|
maxPoints: CATEGORY_MAX_POINTS.health,
|
|
2124
2033
|
message:
|
|
2125
2034
|
"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.",
|
|
2035
|
+
remediation: `Replace generated starter operation(s) ${generatedStarter.join(", ")} with real upstream-backed operations and add \`healthCheck\` for safe read-only probes.`,
|
|
2128
2036
|
evidence: generatedStarter,
|
|
2129
2037
|
};
|
|
2130
2038
|
}
|
|
@@ -2137,13 +2045,9 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2137
2045
|
status: "warn",
|
|
2138
2046
|
points: 12,
|
|
2139
2047
|
maxPoints: CATEGORY_MAX_POINTS.health,
|
|
2140
|
-
message:
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
"Reviewers prefer real healthCheck for safe read-only upstream operations.",
|
|
2144
|
-
evidence: unsupported.map(
|
|
2145
|
-
(operationId) => `${operationId}: healthCheckUnsupported`,
|
|
2146
|
-
),
|
|
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`),
|
|
2147
2051
|
};
|
|
2148
2052
|
}
|
|
2149
2053
|
|
|
@@ -2155,8 +2059,55 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
2155
2059
|
);
|
|
2156
2060
|
}
|
|
2157
2061
|
|
|
2158
|
-
function
|
|
2159
|
-
|
|
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) {
|
|
2160
2111
|
return {
|
|
2161
2112
|
id: "local-smoke",
|
|
2162
2113
|
category: "smoke",
|
|
@@ -2164,8 +2115,9 @@ function scoreSmokeEvidence(smokeNote: string | undefined): SubmitCheck {
|
|
|
2164
2115
|
status: "pass",
|
|
2165
2116
|
points: CATEGORY_MAX_POINTS.smoke,
|
|
2166
2117
|
maxPoints: CATEGORY_MAX_POINTS.smoke,
|
|
2167
|
-
message: "
|
|
2168
|
-
evidence
|
|
2118
|
+
message: "Measured smoke passed with at least one schema-valid operation success.",
|
|
2119
|
+
evidence,
|
|
2120
|
+
details: smokeResult,
|
|
2169
2121
|
};
|
|
2170
2122
|
}
|
|
2171
2123
|
|
|
@@ -2174,14 +2126,226 @@ function scoreSmokeEvidence(smokeNote: string | undefined): SubmitCheck {
|
|
|
2174
2126
|
category: "smoke",
|
|
2175
2127
|
level: "warn",
|
|
2176
2128
|
status: "warn",
|
|
2177
|
-
points:
|
|
2129
|
+
points: 7,
|
|
2178
2130
|
maxPoints: CATEGORY_MAX_POINTS.smoke,
|
|
2179
|
-
message: "
|
|
2131
|
+
message: "Runtime path was verified, but no live upstream schema-valid success was observed.",
|
|
2180
2132
|
remediation:
|
|
2181
|
-
"
|
|
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,
|
|
2182
2136
|
};
|
|
2183
2137
|
}
|
|
2184
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
|
+
|
|
2185
2349
|
function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
|
|
2186
2350
|
const authMode = provider.auth?.mode ?? "none";
|
|
2187
2351
|
const credentialKeys = provider.credential?.keys ?? [];
|
|
@@ -2205,7 +2369,7 @@ function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
|
|
|
2205
2369
|
maxPoints: CATEGORY_MAX_POINTS.auth,
|
|
2206
2370
|
message: "OAuth auth mode does not declare persisted credential.keys.",
|
|
2207
2371
|
remediation:
|
|
2208
|
-
"
|
|
2372
|
+
"Add `credential: { keys: [...] }` to `defineProvider` with the persisted OAuth token fields returned by the real token exchange.",
|
|
2209
2373
|
};
|
|
2210
2374
|
}
|
|
2211
2375
|
|
|
@@ -2221,10 +2385,8 @@ function scoreAuthSafety(provider: ProviderDefinition): SubmitCheck {
|
|
|
2221
2385
|
status: "warn",
|
|
2222
2386
|
points: 7,
|
|
2223
2387
|
maxPoints: CATEGORY_MAX_POINTS.auth,
|
|
2224
|
-
message:
|
|
2225
|
-
|
|
2226
|
-
remediation:
|
|
2227
|
-
"Confirm auth.mode and operation annotations match the actual upstream auth model.",
|
|
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(", ")}.`,
|
|
2228
2390
|
evidence: securedOperations.map(([operationId]) => operationId),
|
|
2229
2391
|
};
|
|
2230
2392
|
}
|
|
@@ -2266,9 +2428,7 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
|
|
|
2266
2428
|
|
|
2267
2429
|
const points = Math.max(
|
|
2268
2430
|
0,
|
|
2269
|
-
CATEGORY_MAX_POINTS.docs -
|
|
2270
|
-
missing.length * 2 -
|
|
2271
|
-
(mentionsSubmitCheck ? 0 : 1),
|
|
2431
|
+
CATEGORY_MAX_POINTS.docs - missing.length * 2 - (mentionsSubmitCheck ? 0 : 1),
|
|
2272
2432
|
);
|
|
2273
2433
|
|
|
2274
2434
|
return [
|
|
@@ -2285,7 +2445,7 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
|
|
|
2285
2445
|
: "Provider README includes expected submission guidance.",
|
|
2286
2446
|
remediation:
|
|
2287
2447
|
missing.length > 0 || !mentionsSubmitCheck
|
|
2288
|
-
? "
|
|
2448
|
+
? "Update `README.md` to include `Parameters`, `Response`, `Example`, and submit-check evidence guidance sections."
|
|
2289
2449
|
: undefined,
|
|
2290
2450
|
evidence: [
|
|
2291
2451
|
...missing.map(([, label]) => `missing ${label}`),
|
|
@@ -2295,9 +2455,10 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
|
|
|
2295
2455
|
];
|
|
2296
2456
|
}
|
|
2297
2457
|
|
|
2298
|
-
function scoreSecrets(providerRoot: string): SubmitCheck {
|
|
2299
|
-
const findings = findSecretFindings(providerRoot);
|
|
2300
|
-
|
|
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) {
|
|
2301
2462
|
return {
|
|
2302
2463
|
id: "secret-scan",
|
|
2303
2464
|
category: "security",
|
|
@@ -2305,11 +2466,34 @@ function scoreSecrets(providerRoot: string): SubmitCheck {
|
|
|
2305
2466
|
status: "fail",
|
|
2306
2467
|
points: 0,
|
|
2307
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,
|
|
2308
2488
|
message:
|
|
2309
|
-
"
|
|
2489
|
+
"High-entropy source strings were found without secret-like identifier context; they may be false positives.",
|
|
2310
2490
|
remediation:
|
|
2311
|
-
|
|
2312
|
-
evidence: findings.map(
|
|
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
|
+
),
|
|
2313
2497
|
};
|
|
2314
2498
|
}
|
|
2315
2499
|
|
|
@@ -2321,7 +2505,7 @@ function scoreSecrets(providerRoot: string): SubmitCheck {
|
|
|
2321
2505
|
);
|
|
2322
2506
|
}
|
|
2323
2507
|
|
|
2324
|
-
function findSecretFindings(providerRoot: string): SecretFinding[] {
|
|
2508
|
+
function findSecretFindings(providerRoot: string, providerId = "<ID>"): SecretFinding[] {
|
|
2325
2509
|
const candidateFiles = [
|
|
2326
2510
|
"README.md",
|
|
2327
2511
|
"index.ts",
|
|
@@ -2342,14 +2526,155 @@ function findSecretFindings(providerRoot: string): SecretFinding[] {
|
|
|
2342
2526
|
}
|
|
2343
2527
|
}
|
|
2344
2528
|
|
|
2529
|
+
findings.push(...findEntropySecretFindings(providerRoot, providerId));
|
|
2345
2530
|
return findings;
|
|
2346
2531
|
}
|
|
2347
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
|
+
|
|
2348
2676
|
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
|
-
],
|
|
2677
|
+
["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
|
|
2353
2678
|
["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],
|
|
2354
2679
|
["Stripe live key", /(?:sk|rk)_live_[A-Za-z0-9]{20,}/],
|
|
2355
2680
|
["Bearer token", /Bearer\s+[A-Za-z0-9._~+/=-]{32,}/i],
|
|
@@ -2359,9 +2684,7 @@ const SECRET_PATTERNS: Array<[string, RegExp]> = [
|
|
|
2359
2684
|
],
|
|
2360
2685
|
];
|
|
2361
2686
|
|
|
2362
|
-
async function safeLoadProvider(
|
|
2363
|
-
providerRoot: string,
|
|
2364
|
-
): Promise<ProviderDefinition | undefined> {
|
|
2687
|
+
async function safeLoadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
|
|
2365
2688
|
try {
|
|
2366
2689
|
return await loadProvider(providerRoot);
|
|
2367
2690
|
} catch {
|
|
@@ -2369,9 +2692,7 @@ async function safeLoadProvider(
|
|
|
2369
2692
|
}
|
|
2370
2693
|
}
|
|
2371
2694
|
|
|
2372
|
-
async function loadProvider(
|
|
2373
|
-
providerRoot: string,
|
|
2374
|
-
): Promise<ProviderDefinition | undefined> {
|
|
2695
|
+
async function loadProvider(providerRoot: string): Promise<ProviderDefinition | undefined> {
|
|
2375
2696
|
const entryPath = resolve(providerRoot, "index.ts");
|
|
2376
2697
|
if (!existsSync(entryPath)) {
|
|
2377
2698
|
return undefined;
|
|
@@ -2451,8 +2772,7 @@ export function renderText(report: SubmitCheckReport): string {
|
|
|
2451
2772
|
];
|
|
2452
2773
|
|
|
2453
2774
|
for (const check of report.checks) {
|
|
2454
|
-
const marker =
|
|
2455
|
-
check.status === "pass" ? "✓" : check.status === "warn" ? "⚠" : "✗";
|
|
2775
|
+
const marker = check.status === "pass" ? "✓" : check.status === "warn" ? "⚠" : "✗";
|
|
2456
2776
|
lines.push(
|
|
2457
2777
|
`${marker} [${check.category}] ${check.message} (${check.points}/${check.maxPoints})`,
|
|
2458
2778
|
);
|
|
@@ -2474,9 +2794,7 @@ export function renderMarkdown(report: SubmitCheckReport): string {
|
|
|
2474
2794
|
`- **Provider**: ${report.provider.id}@${report.provider.version}`,
|
|
2475
2795
|
`- **SDK**: ${report.provider.sdkVersion}`,
|
|
2476
2796
|
`- **Runtime/Auth**: ${report.provider.runtime} / ${report.provider.authMode}`,
|
|
2477
|
-
...(report.provider.tier
|
|
2478
|
-
? [`- **Bounty tier**: ${report.provider.tier}`]
|
|
2479
|
-
: []),
|
|
2797
|
+
...(report.provider.tier ? [`- **Bounty tier**: ${report.provider.tier}`] : []),
|
|
2480
2798
|
`- **Score**: ${report.score.total}/${report.score.max}`,
|
|
2481
2799
|
`- **Verdict**: ${report.score.verdict}`,
|
|
2482
2800
|
`- **Blockers**: ${report.summary.blockers}`,
|
|
@@ -2489,12 +2807,7 @@ export function renderMarkdown(report: SubmitCheckReport): string {
|
|
|
2489
2807
|
];
|
|
2490
2808
|
|
|
2491
2809
|
for (const check of report.checks) {
|
|
2492
|
-
const status =
|
|
2493
|
-
check.status === "pass"
|
|
2494
|
-
? "PASS"
|
|
2495
|
-
: check.status === "warn"
|
|
2496
|
-
? "WARN"
|
|
2497
|
-
: "FAIL";
|
|
2810
|
+
const status = check.status === "pass" ? "PASS" : check.status === "warn" ? "WARN" : "FAIL";
|
|
2498
2811
|
lines.push(
|
|
2499
2812
|
`| ${status} | ${escapeMarkdown(check.category)} | ${escapeMarkdown(check.message)} | ${check.points}/${check.maxPoints} | ${escapeMarkdown(check.remediation ?? "")} |`,
|
|
2500
2813
|
);
|
|
@@ -2524,9 +2837,7 @@ function redact(value: string): string {
|
|
|
2524
2837
|
}
|
|
2525
2838
|
|
|
2526
2839
|
function toGlobalRegex(pattern: RegExp): RegExp {
|
|
2527
|
-
return pattern.global
|
|
2528
|
-
? pattern
|
|
2529
|
-
: new RegExp(pattern.source, `${pattern.flags}g`);
|
|
2840
|
+
return pattern.global ? pattern : new RegExp(pattern.source, `${pattern.flags}g`);
|
|
2530
2841
|
}
|
|
2531
2842
|
|
|
2532
2843
|
function clamp(value: number, min: number, max: number): number {
|