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