@apifuse/provider-sdk 2.1.0-beta.5 → 2.1.0-beta.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +2 -2
- package/SUBMISSION.md +2 -1
- package/bin/apifuse-check.ts +60 -6
- package/bin/apifuse-dev.ts +48 -5
- package/bin/apifuse-perf.ts +50 -11
- package/bin/apifuse-record.ts +35 -11
- package/bin/apifuse-submit-check.ts +1425 -3
- package/dist/ceremonies/index.d.ts +41 -0
- package/dist/ceremonies/index.js +490 -0
- package/dist/choice-token.d.ts +24 -0
- package/dist/choice-token.js +74 -0
- package/dist/cli/commands.d.ts +10 -0
- package/dist/cli/commands.js +80 -0
- package/dist/cli/create.d.ts +47 -0
- package/dist/cli/create.js +762 -0
- package/dist/config/loader.d.ts +107 -0
- package/dist/config/loader.js +935 -0
- package/dist/contract-json.d.ts +9 -0
- package/dist/contract-json.js +51 -0
- package/dist/contract-serialization.d.ts +4 -0
- package/dist/contract-serialization.js +78 -0
- package/dist/contract-types.d.ts +49 -0
- package/dist/contract-types.js +1 -0
- package/dist/contract.d.ts +6 -0
- package/dist/contract.js +155 -0
- package/dist/define.d.ts +97 -0
- package/dist/define.js +1320 -0
- package/dist/dev.d.ts +9 -0
- package/dist/dev.js +15 -0
- package/dist/errors.d.ts +59 -0
- package/dist/errors.js +97 -0
- package/dist/i18n/catalog.d.ts +29 -0
- package/dist/i18n/catalog.js +159 -0
- package/dist/i18n/index.d.ts +2 -0
- package/dist/i18n/index.js +2 -0
- package/dist/i18n/keys.d.ts +10 -0
- package/dist/i18n/keys.js +34 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +37 -0
- package/dist/lint.d.ts +73 -0
- package/dist/lint.js +702 -0
- package/dist/observability.d.ts +5 -0
- package/dist/observability.js +39 -0
- package/dist/provider.d.ts +9 -0
- package/dist/provider.js +8 -0
- package/dist/public-schema-field-lint.d.ts +2 -0
- package/dist/public-schema-field-lint.js +158 -0
- package/dist/recipes/gov-api.d.ts +19 -0
- package/dist/recipes/gov-api.js +72 -0
- package/dist/recipes/rest-api.d.ts +21 -0
- package/dist/recipes/rest-api.js +115 -0
- package/dist/runtime/auth-flow.d.ts +14 -0
- package/dist/runtime/auth-flow.js +44 -0
- package/dist/runtime/browser.d.ts +25 -0
- package/dist/runtime/browser.js +1034 -0
- package/dist/runtime/cache.d.ts +10 -0
- package/dist/runtime/cache.js +372 -0
- package/dist/runtime/choice.d.ts +15 -0
- package/dist/runtime/choice.js +435 -0
- package/dist/runtime/credential.d.ts +8 -0
- package/dist/runtime/credential.js +61 -0
- package/dist/runtime/env.d.ts +2 -0
- package/dist/runtime/env.js +10 -0
- package/dist/runtime/executor.d.ts +16 -0
- package/dist/runtime/executor.js +51 -0
- package/dist/runtime/http.d.ts +8 -0
- package/dist/runtime/http.js +706 -0
- package/dist/runtime/insights.d.ts +9 -0
- package/dist/runtime/insights.js +324 -0
- package/dist/runtime/instrumentation.d.ts +8 -0
- package/dist/runtime/instrumentation.js +269 -0
- package/dist/runtime/key-derivation.d.ts +24 -0
- package/dist/runtime/key-derivation.js +73 -0
- package/dist/runtime/keyring.d.ts +25 -0
- package/dist/runtime/keyring.js +93 -0
- package/dist/runtime/namespace.d.ts +9 -0
- package/dist/runtime/namespace.js +19 -0
- package/dist/runtime/otlp.d.ts +39 -0
- package/dist/runtime/otlp.js +103 -0
- package/dist/runtime/perf.d.ts +12 -0
- package/dist/runtime/perf.js +52 -0
- package/dist/runtime/prevalidate.d.ts +12 -0
- package/dist/runtime/prevalidate.js +173 -0
- package/dist/runtime/provider.d.ts +2 -0
- package/dist/runtime/provider.js +11 -0
- package/dist/runtime/proxy-errors.d.ts +21 -0
- package/dist/runtime/proxy-errors.js +83 -0
- package/dist/runtime/proxy-telemetry.d.ts +8 -0
- package/dist/runtime/proxy-telemetry.js +174 -0
- package/dist/runtime/redis.d.ts +17 -0
- package/dist/runtime/redis.js +82 -0
- package/dist/runtime/request-options.d.ts +3 -0
- package/dist/runtime/request-options.js +42 -0
- package/dist/runtime/state.d.ts +17 -0
- package/dist/runtime/state.js +344 -0
- package/dist/runtime/stealth.d.ts +18 -0
- package/dist/runtime/stealth.js +827 -0
- package/dist/runtime/stt.d.ts +22 -0
- package/dist/runtime/stt.js +480 -0
- package/dist/runtime/trace.d.ts +26 -0
- package/dist/runtime/trace.js +142 -0
- package/dist/runtime/waterfall.d.ts +12 -0
- package/dist/runtime/waterfall.js +147 -0
- package/dist/schema.d.ts +74 -0
- package/dist/schema.js +243 -0
- package/dist/serve.d.ts +1 -0
- package/dist/serve.js +1 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.js +2 -0
- package/dist/server/serve.d.ts +64 -0
- package/dist/server/serve.js +1110 -0
- package/dist/server/types.d.ts +136 -0
- package/dist/server/types.js +86 -0
- package/dist/stealth/profiles.d.ts +4 -0
- package/dist/stealth/profiles.js +259 -0
- package/dist/stream.d.ts +44 -0
- package/dist/stream.js +151 -0
- package/dist/testing/helpers.d.ts +23 -0
- package/dist/testing/helpers.js +95 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +2 -0
- package/dist/testing/run.d.ts +34 -0
- package/dist/testing/run.js +303 -0
- package/dist/types.d.ts +1324 -0
- package/dist/types.js +61 -0
- package/dist/utils/date.d.ts +6 -0
- package/dist/utils/date.js +101 -0
- package/dist/utils/parse.d.ts +16 -0
- package/dist/utils/parse.js +51 -0
- package/dist/utils/text.d.ts +4 -0
- package/dist/utils/text.js +14 -0
- package/dist/utils/transform.d.ts +8 -0
- package/dist/utils/transform.js +48 -0
- package/package.json +42 -25
- package/src/ceremonies/index.ts +8 -2
- package/src/choice-token.ts +1 -0
- package/src/cli/commands.ts +8 -5
- package/src/cli/create.ts +28 -0
- package/src/cli/templates/provider/operations/ping.ts.tpl +3 -2
- package/src/cli/templates/provider/schemas/ping.ts.tpl +8 -0
- package/src/config/loader.ts +19 -1
- package/src/contract-json.ts +75 -0
- package/src/contract-serialization.ts +89 -0
- package/src/contract-types.ts +52 -0
- package/src/contract.ts +215 -0
- package/src/define.ts +37 -2
- package/src/errors.ts +15 -0
- package/src/i18n/catalog.ts +156 -0
- package/src/index.ts +22 -1
- package/src/lint.ts +256 -37
- package/src/provider.ts +45 -2
- package/src/runtime/browser.ts +685 -30
- package/src/runtime/cache.ts +35 -89
- package/src/runtime/choice.ts +760 -0
- package/src/runtime/executor.ts +19 -2
- package/src/runtime/redis.ts +116 -0
- package/src/runtime/state.ts +487 -0
- package/src/runtime/stealth.ts +8 -1
- package/src/server/serve.ts +361 -46
- package/src/server/types.ts +2 -0
- package/src/testing/run.ts +16 -3
- package/src/types.ts +209 -6
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
4
|
import { writeFile } from "node:fs/promises";
|
|
5
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
7
|
|
|
8
8
|
import { z } from "zod";
|
|
@@ -61,6 +61,10 @@ export type SubmitCheckReport = {
|
|
|
61
61
|
checks: SubmitCheck[];
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
export function isAutoPromotionEligible(report: SubmitCheckReport): boolean {
|
|
65
|
+
return report.score.total >= 95 && report.summary.blockers === 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
64
68
|
type CliArgs = {
|
|
65
69
|
isJson: boolean;
|
|
66
70
|
markdownPath?: string;
|
|
@@ -74,6 +78,15 @@ type SecretFinding = {
|
|
|
74
78
|
file: string;
|
|
75
79
|
};
|
|
76
80
|
|
|
81
|
+
type SourceFinding = {
|
|
82
|
+
file: string;
|
|
83
|
+
line: number;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const SDK_NATIVE_CATEGORY = "sdk-native";
|
|
87
|
+
const VENDOR_SHIM_PROVIDER_ID_PREFIX = "apifuse-provider-";
|
|
88
|
+
const MAX_SOURCE_FINDING_EVIDENCE = 5;
|
|
89
|
+
|
|
77
90
|
const CATEGORY_MAX_POINTS = {
|
|
78
91
|
definition: 15,
|
|
79
92
|
operations: 15,
|
|
@@ -231,8 +244,20 @@ export async function buildSubmitCheckReport(
|
|
|
231
244
|
const provider = await safeLoadProvider(providerRoot);
|
|
232
245
|
|
|
233
246
|
checks.push(...scoreBaseChecks(baseChecks));
|
|
247
|
+
checks.push(scoreProviderIdSlug(providerRoot, provider));
|
|
248
|
+
checks.push(scoreNoVendorShim(providerRoot));
|
|
249
|
+
checks.push(scoreNoVendorImport(providerRoot));
|
|
250
|
+
checks.push(scoreDescribeKey(providerRoot));
|
|
251
|
+
checks.push(scoreNoRawFetch(providerRoot));
|
|
252
|
+
checks.push(scoreNoRedundantRuntimeGuards(providerRoot));
|
|
253
|
+
checks.push(scoreManagedBrowserRuntime(providerRoot));
|
|
254
|
+
checks.push(scoreAsAssertionCount(providerRoot));
|
|
255
|
+
checks.push(scoreUnsafeInputPassthrough(providerRoot));
|
|
256
|
+
checks.push(scoreUnjustifiedLooseSchema(providerRoot));
|
|
257
|
+
checks.push(scoreFlatOperationComposition(providerRoot));
|
|
234
258
|
|
|
235
259
|
if (provider) {
|
|
260
|
+
checks.push(scoreCredentialUsage(providerRoot, provider));
|
|
236
261
|
checks.push(scoreLocaleCatalog(providerRoot, provider));
|
|
237
262
|
checks.push(scoreOperationMetadata(provider));
|
|
238
263
|
checks.push(scoreFixtureCoverage(provider));
|
|
@@ -288,6 +313,1328 @@ export async function buildSubmitCheckReport(
|
|
|
288
313
|
};
|
|
289
314
|
}
|
|
290
315
|
|
|
316
|
+
function scoreProviderIdSlug(
|
|
317
|
+
providerRoot: string,
|
|
318
|
+
provider: ProviderDefinition | undefined,
|
|
319
|
+
): SubmitCheck {
|
|
320
|
+
const remediation =
|
|
321
|
+
'Rename defineProvider({ id }) to the short slug (e.g. "tabelog", not "apifuse-provider-tabelog"). Also update manifest/PROVIDER_ID consts and tests. Grep: git grep "apifuse-provider-<name>".';
|
|
322
|
+
|
|
323
|
+
// Prefer the loaded provider id; fall back to scanning source so the rule
|
|
324
|
+
// still fires when the provider fails to load (e.g. a vendor shim or other
|
|
325
|
+
// structural problem prevents defineProvider from resolving).
|
|
326
|
+
if (provider) {
|
|
327
|
+
if (provider.id.startsWith(VENDOR_SHIM_PROVIDER_ID_PREFIX)) {
|
|
328
|
+
return blocker(
|
|
329
|
+
"id-slug",
|
|
330
|
+
SDK_NATIVE_CATEGORY,
|
|
331
|
+
"Provider id uses the apifuse-provider- prefix.",
|
|
332
|
+
remediation,
|
|
333
|
+
0,
|
|
334
|
+
[provider.id],
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return pass(
|
|
339
|
+
"id-slug",
|
|
340
|
+
SDK_NATIVE_CATEGORY,
|
|
341
|
+
"Provider id uses the short slug.",
|
|
342
|
+
0,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const findings = findSourceLineMatches(
|
|
347
|
+
providerRoot,
|
|
348
|
+
/["'`]apifuse-provider-[a-z0-9-]/i,
|
|
349
|
+
);
|
|
350
|
+
if (findings.length > 0) {
|
|
351
|
+
return blocker(
|
|
352
|
+
"id-slug",
|
|
353
|
+
SDK_NATIVE_CATEGORY,
|
|
354
|
+
"Provider id uses the apifuse-provider- prefix.",
|
|
355
|
+
remediation,
|
|
356
|
+
0,
|
|
357
|
+
formatSourceFindings(findings),
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return pass(
|
|
362
|
+
"id-slug",
|
|
363
|
+
SDK_NATIVE_CATEGORY,
|
|
364
|
+
"Provider id uses the short slug.",
|
|
365
|
+
0,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function scoreNoVendorShim(providerRoot: string): SubmitCheck {
|
|
370
|
+
const vendorPath = resolve(providerRoot, "vendor");
|
|
371
|
+
if (existsSync(vendorPath)) {
|
|
372
|
+
return blocker(
|
|
373
|
+
"no-vendor-shim",
|
|
374
|
+
SDK_NATIVE_CATEGORY,
|
|
375
|
+
"Provider contains a vendor/ SDK shim directory.",
|
|
376
|
+
"Delete vendor/ and import directly from @apifuse/provider-sdk (/provider, root, /testing). SDK-absent symbols (e.g. createStateContext) must use real SDK equivalents (createUnsupportedProviderRuntimeState for unused ctx.state).",
|
|
377
|
+
0,
|
|
378
|
+
[vendorPath],
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return pass(
|
|
383
|
+
"no-vendor-shim",
|
|
384
|
+
SDK_NATIVE_CATEGORY,
|
|
385
|
+
"Provider does not contain a vendor/ SDK shim directory.",
|
|
386
|
+
0,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function scoreNoVendorImport(providerRoot: string): SubmitCheck {
|
|
391
|
+
const findings = findSourceLineMatches(
|
|
392
|
+
providerRoot,
|
|
393
|
+
/from\s+["'][^"']*vendor\//,
|
|
394
|
+
);
|
|
395
|
+
if (findings.length > 0) {
|
|
396
|
+
return blocker(
|
|
397
|
+
"no-vendor-import",
|
|
398
|
+
SDK_NATIVE_CATEGORY,
|
|
399
|
+
"Provider source imports from vendor/ shim.",
|
|
400
|
+
"Re-point every import from ../vendor/provider-sdk to @apifuse/provider-sdk/provider, @apifuse/provider-sdk, or @apifuse/provider-sdk/testing.",
|
|
401
|
+
0,
|
|
402
|
+
formatSourceFindings(findings),
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return pass(
|
|
407
|
+
"no-vendor-import",
|
|
408
|
+
SDK_NATIVE_CATEGORY,
|
|
409
|
+
"Provider source imports directly from the SDK.",
|
|
410
|
+
0,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function scoreDescribeKey(providerRoot: string): SubmitCheck {
|
|
415
|
+
const findings = findSourceLineMatches(providerRoot, /\.describe\(["']/);
|
|
416
|
+
if (findings.length > 0) {
|
|
417
|
+
return blocker(
|
|
418
|
+
"describe-key",
|
|
419
|
+
SDK_NATIVE_CATEGORY,
|
|
420
|
+
"Schema descriptions use raw .describe() prose instead of describeKey.",
|
|
421
|
+
'Replace .describe("prose") with describeKey(schema, key, { description }) backed by locale keys in locales/en.json + ko.json.',
|
|
422
|
+
0,
|
|
423
|
+
formatSourceFindings(findings),
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return pass(
|
|
428
|
+
"describe-key",
|
|
429
|
+
SDK_NATIVE_CATEGORY,
|
|
430
|
+
"Schema descriptions use describeKey.",
|
|
431
|
+
0,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function scoreNoRawFetch(providerRoot: string): SubmitCheck {
|
|
436
|
+
const findings = findSourceLineMatches(providerRoot, /(?<![.\w])fetch\s*\(/);
|
|
437
|
+
if (findings.length > 0) {
|
|
438
|
+
return blocker(
|
|
439
|
+
"no-raw-fetch",
|
|
440
|
+
SDK_NATIVE_CATEGORY,
|
|
441
|
+
"Provider source calls raw fetch().",
|
|
442
|
+
"Replace raw fetch() with ctx.stealth.fetch() (cloud-IP-blocked otherwise) or ctx.http for non-stealth calls.",
|
|
443
|
+
0,
|
|
444
|
+
formatSourceFindings(findings),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return pass(
|
|
449
|
+
"no-raw-fetch",
|
|
450
|
+
SDK_NATIVE_CATEGORY,
|
|
451
|
+
"Provider source avoids raw fetch().",
|
|
452
|
+
0,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const REDUNDANT_RUNTIME_GUARD_PATTERNS: readonly RegExp[] = [
|
|
457
|
+
/\bctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|choice)\?\./,
|
|
458
|
+
];
|
|
459
|
+
|
|
460
|
+
const SDK_CONTEXT_METHOD_ALIAS_PATTERN =
|
|
461
|
+
/\bconst\s+(\w+)\s*=\s*ctx\.(?:stealth|http|cache|state|browser|trace|auth|stt|choice)\.(?:\w+)/;
|
|
462
|
+
|
|
463
|
+
function hasRedundantRuntimeGuard(
|
|
464
|
+
line: string,
|
|
465
|
+
remainingLines: readonly string[],
|
|
466
|
+
): boolean {
|
|
467
|
+
if (REDUNDANT_RUNTIME_GUARD_PATTERNS.some((pattern) => pattern.test(line))) {
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const aliasMatch = SDK_CONTEXT_METHOD_ALIAS_PATTERN.exec(line);
|
|
472
|
+
const alias = aliasMatch?.[1];
|
|
473
|
+
if (!alias) {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const guardPattern = new RegExp(
|
|
478
|
+
`(?:typeof\\s+${alias}\\s*!==\\s*["']function["']|!${alias}\\b)`,
|
|
479
|
+
);
|
|
480
|
+
return remainingLines
|
|
481
|
+
.slice(0, 8)
|
|
482
|
+
.some((candidate) => guardPattern.test(candidate));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function scoreNoRedundantRuntimeGuards(providerRoot: string): SubmitCheck {
|
|
486
|
+
const findings = findSourceFindings(providerRoot, hasRedundantRuntimeGuard);
|
|
487
|
+
if (findings.length > 0) {
|
|
488
|
+
return blocker(
|
|
489
|
+
"no-redundant-runtime-guards",
|
|
490
|
+
SDK_NATIVE_CATEGORY,
|
|
491
|
+
"Provider source has redundant runtime guard code for SDK-owned context APIs.",
|
|
492
|
+
"Trust the provider SDK context contract: call ctx.stealth.fetch(), ctx.http, and other SDK-owned context APIs directly. Remove optional chaining and typeof function guards around non-null runtime clients.",
|
|
493
|
+
0,
|
|
494
|
+
formatSourceFindings(findings),
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return pass(
|
|
499
|
+
"no-redundant-runtime-guards",
|
|
500
|
+
SDK_NATIVE_CATEGORY,
|
|
501
|
+
"Provider source avoids redundant runtime guard code around SDK-owned context APIs.",
|
|
502
|
+
0,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const AS_ASSERTION_PATTERN =
|
|
507
|
+
/\bas\s+(any|unknown|never|string|number|boolean)\b|\bas\s+[A-Z]|\bas\s+\{|\bas\s+Record\b|\bas\s+typeof\b/;
|
|
508
|
+
|
|
509
|
+
function countAsAssertions(providerRoot: string): {
|
|
510
|
+
count: number;
|
|
511
|
+
findings: SourceFinding[];
|
|
512
|
+
} {
|
|
513
|
+
let count = 0;
|
|
514
|
+
const findings: SourceFinding[] = [];
|
|
515
|
+
|
|
516
|
+
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
517
|
+
const content = readFileSync(filePath, "utf8");
|
|
518
|
+
const lines = content.split(/\r?\n/);
|
|
519
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
520
|
+
const line = lines[index];
|
|
521
|
+
if (
|
|
522
|
+
line === undefined ||
|
|
523
|
+
line.includes("import") ||
|
|
524
|
+
/\bas\s*const\b/.test(line) ||
|
|
525
|
+
!AS_ASSERTION_PATTERN.test(line)
|
|
526
|
+
) {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
count += 1;
|
|
531
|
+
if (findings.length < MAX_SOURCE_FINDING_EVIDENCE) {
|
|
532
|
+
findings.push({
|
|
533
|
+
file: toRelativeProviderPath(providerRoot, filePath),
|
|
534
|
+
line: index + 1,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return { count, findings };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function scoreAsAssertionCount(providerRoot: string): SubmitCheck {
|
|
544
|
+
const { count, findings } = countAsAssertions(providerRoot);
|
|
545
|
+
const assertionLabel = "as " + "Type";
|
|
546
|
+
const remediation = `Replace \`${assertionLabel}\` with zod \`schema.safeParse()\` or \`if ('key' in obj)\` type guards. \`as const\` is allowed.`;
|
|
547
|
+
|
|
548
|
+
if (count > 20) {
|
|
549
|
+
return blocker(
|
|
550
|
+
"as-assertion-count",
|
|
551
|
+
SDK_NATIVE_CATEGORY,
|
|
552
|
+
`Provider uses ${count} type assertions (${assertionLabel}). Replace with zod safeParse or type guards.`,
|
|
553
|
+
remediation,
|
|
554
|
+
0,
|
|
555
|
+
formatSourceFindings(findings),
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (count >= 6) {
|
|
560
|
+
return {
|
|
561
|
+
id: "as-assertion-count",
|
|
562
|
+
category: SDK_NATIVE_CATEGORY,
|
|
563
|
+
level: "warn",
|
|
564
|
+
status: "warn",
|
|
565
|
+
points: 0,
|
|
566
|
+
maxPoints: 0,
|
|
567
|
+
message: `Provider uses ${count} type assertions (${assertionLabel}). Replace with zod safeParse or type guards.`,
|
|
568
|
+
remediation,
|
|
569
|
+
evidence: formatSourceFindings(findings),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return pass(
|
|
574
|
+
"as-assertion-count",
|
|
575
|
+
SDK_NATIVE_CATEGORY,
|
|
576
|
+
"Type assertions are within the recommended limit.",
|
|
577
|
+
0,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Returns true when `findingLine` (1-based) or the line directly above it
|
|
582
|
+
// carries an `// @apifuse-allow <ruleId>:` acknowledgement comment.
|
|
583
|
+
function hasAllowOverride(
|
|
584
|
+
lines: readonly string[],
|
|
585
|
+
findingLine: number,
|
|
586
|
+
ruleId: string,
|
|
587
|
+
): boolean {
|
|
588
|
+
const pattern = new RegExp(`@apifuse-allow\\s+${ruleId}\\b`);
|
|
589
|
+
const current = lines[findingLine - 1];
|
|
590
|
+
const previous = lines[findingLine - 2];
|
|
591
|
+
return (
|
|
592
|
+
(current !== undefined && pattern.test(current)) ||
|
|
593
|
+
(previous !== undefined && pattern.test(previous))
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Splits source findings into non-overridden (still violations) and
|
|
598
|
+
// acknowledged (escape-hatched) sets by re-reading each file's lines.
|
|
599
|
+
function partitionAllowOverrides(
|
|
600
|
+
providerRoot: string,
|
|
601
|
+
findings: readonly SourceFinding[],
|
|
602
|
+
ruleId: string,
|
|
603
|
+
): { violations: SourceFinding[]; overridden: SourceFinding[] } {
|
|
604
|
+
const fileLineCache = new Map<string, string[]>();
|
|
605
|
+
const violations: SourceFinding[] = [];
|
|
606
|
+
const overridden: SourceFinding[] = [];
|
|
607
|
+
|
|
608
|
+
for (const finding of findings) {
|
|
609
|
+
const absolute = resolve(providerRoot, finding.file);
|
|
610
|
+
let lines = fileLineCache.get(absolute);
|
|
611
|
+
if (lines === undefined) {
|
|
612
|
+
lines = readFileSync(absolute, "utf8").split(/\r?\n/);
|
|
613
|
+
fileLineCache.set(absolute, lines);
|
|
614
|
+
}
|
|
615
|
+
if (hasAllowOverride(lines, finding.line, ruleId)) {
|
|
616
|
+
overridden.push(finding);
|
|
617
|
+
} else {
|
|
618
|
+
violations.push(finding);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return { violations, overridden };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Builds a blocker/warn/pass result for an escape-hatch-aware rule:
|
|
626
|
+
// any non-overridden finding => blocker; only acknowledged overrides => warn;
|
|
627
|
+
// nothing => pass.
|
|
628
|
+
function escapeHatchResult(
|
|
629
|
+
providerRoot: string,
|
|
630
|
+
ruleId: string,
|
|
631
|
+
findings: readonly SourceFinding[],
|
|
632
|
+
copy: { blockerMessage: string; remediation: string; passMessage: string },
|
|
633
|
+
): SubmitCheck {
|
|
634
|
+
if (findings.length === 0) {
|
|
635
|
+
return pass(ruleId, SDK_NATIVE_CATEGORY, copy.passMessage, 0);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const { violations, overridden } = partitionAllowOverrides(
|
|
639
|
+
providerRoot,
|
|
640
|
+
findings,
|
|
641
|
+
ruleId,
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
if (violations.length > 0) {
|
|
645
|
+
return blocker(
|
|
646
|
+
ruleId,
|
|
647
|
+
SDK_NATIVE_CATEGORY,
|
|
648
|
+
copy.blockerMessage,
|
|
649
|
+
copy.remediation,
|
|
650
|
+
0,
|
|
651
|
+
formatSourceFindings(violations),
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return {
|
|
656
|
+
id: ruleId,
|
|
657
|
+
category: SDK_NATIVE_CATEGORY,
|
|
658
|
+
level: "warn",
|
|
659
|
+
status: "warn",
|
|
660
|
+
points: 0,
|
|
661
|
+
maxPoints: 0,
|
|
662
|
+
message: `${copy.blockerMessage} ${overridden.length} acknowledged @apifuse-allow override(s).`,
|
|
663
|
+
remediation: copy.remediation,
|
|
664
|
+
evidence: formatSourceFindings(overridden),
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// 1-based line number of a character offset in `source`.
|
|
669
|
+
function offsetToLine(source: string, offset: number): number {
|
|
670
|
+
let line = 1;
|
|
671
|
+
for (let index = 0; index < offset && index < source.length; index += 1) {
|
|
672
|
+
if (source[index] === "\n") {
|
|
673
|
+
line += 1;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return line;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ---------------------------------------------------------------------------
|
|
680
|
+
// SDK-native structural rules (input-passthrough, loose-schema, flat-operation)
|
|
681
|
+
//
|
|
682
|
+
// SCOPE & LIMITATION: these checks are source-grep heuristics, not a full AST
|
|
683
|
+
// analysis. They are deliberately tuned against the new-structure golden corpus
|
|
684
|
+
// (demaecan / kakaomap / triple) to catch the common non-standard SDK
|
|
685
|
+
// integration shapes seen in bounty submissions: inline/aliased/multi-line
|
|
686
|
+
// input .passthrough(), unjustified loose schemas, and factory-composed
|
|
687
|
+
// operations (inline, aliased, destructured, sibling-module, or unresolved
|
|
688
|
+
// import). They balance brackets and resolve one alias hop across the whole
|
|
689
|
+
// provider submission so trivial formatting/aliasing/module-split bypasses do
|
|
690
|
+
// not slip through.
|
|
691
|
+
//
|
|
692
|
+
// The flat-operation rule guards the "unsafe form" (an op map built by an
|
|
693
|
+
// OPAQUE builder whose operation set is hidden at the call site), not the mere
|
|
694
|
+
// presence of a function call. The stdlib enumerate-and-reshape idiom
|
|
695
|
+
// `Object.fromEntries(Object.entries(<source-visible obj>) ...)` is exempted:
|
|
696
|
+
// its op set still originates from a source-enumerable object and is only
|
|
697
|
+
// filtered/reshaped by pure built-ins. This is the verified golden pattern
|
|
698
|
+
// (triple narrows a statically-defined op object by a whitelist Set). Any other
|
|
699
|
+
// call — `makeOperations()`, a destructured factory, or
|
|
700
|
+
// `Object.fromEntries(buildEntries())` with no source-visible `Object.entries`
|
|
701
|
+
// — stays classified as factory composition and is blocked.
|
|
702
|
+
//
|
|
703
|
+
// They do NOT achieve AST-completeness. Known residual bypasses (schemas or
|
|
704
|
+
// operation maps imported from an external npm package, computed/dynamic
|
|
705
|
+
// property construction, or deliberate obfuscation) are out of reach for a
|
|
706
|
+
// text scan. submit-check is a bounty-workspace gate that runs ALONGSIDE human
|
|
707
|
+
// review; manual review remains the final backstop for adversarial submissions.
|
|
708
|
+
// Promoting these rules to a real TypeScript AST pass (ts.createSourceFile)
|
|
709
|
+
// is tracked as deferred follow-up work (Phase 8.7) and would require adding
|
|
710
|
+
// TypeScript as a provider-sdk dependency.
|
|
711
|
+
// ---------------------------------------------------------------------------
|
|
712
|
+
|
|
713
|
+
// Matches a `.passthrough()` call tolerant of whitespace before the parens or
|
|
714
|
+
// between them, so `.passthrough ()` / `.passthrough\n()` are still detected.
|
|
715
|
+
const PASSTHROUGH_CALL = /\.passthrough\s*\(\s*\)/;
|
|
716
|
+
|
|
717
|
+
// Strips redundant wrapping parentheses from an expression so that a value like
|
|
718
|
+
// `(makeOperations())` or `((x))` classifies the same as `makeOperations()`.
|
|
719
|
+
// Only unwraps when the leading `(` matches the trailing `)` at depth 0 (i.e.
|
|
720
|
+
// the whole expression is parenthesized), preserving call expressions such as
|
|
721
|
+
// `makeOperations()` whose first `(` is not a wrapper.
|
|
722
|
+
function unwrapParens(expr: string): string {
|
|
723
|
+
let value = expr.trim();
|
|
724
|
+
while (value.startsWith("(")) {
|
|
725
|
+
let depth = 0;
|
|
726
|
+
let matchIndex = -1;
|
|
727
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
728
|
+
const ch = value[i];
|
|
729
|
+
if (ch === "(") {
|
|
730
|
+
depth += 1;
|
|
731
|
+
} else if (ch === ")") {
|
|
732
|
+
depth -= 1;
|
|
733
|
+
if (depth === 0) {
|
|
734
|
+
matchIndex = i;
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
// Only a true wrapper spans the entire expression (closing paren is the
|
|
740
|
+
// last char). Otherwise the leading `(` belongs to a sub-expression.
|
|
741
|
+
if (matchIndex === value.length - 1) {
|
|
742
|
+
value = value.slice(1, -1).trim();
|
|
743
|
+
} else {
|
|
744
|
+
break;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return value;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Returns the value-expression substring starting at `valueStart`, balanced
|
|
751
|
+
// across (){}[] and stopping at the first top-level `,`/`;` or unmatched
|
|
752
|
+
// closing bracket. This lets a property value be read across newlines, so a
|
|
753
|
+
// multi-line `input: z.object({...})\n.passthrough()` is captured whole.
|
|
754
|
+
function balancedValueExpression(source: string, valueStart: number): string {
|
|
755
|
+
let depth = 0;
|
|
756
|
+
let index = valueStart;
|
|
757
|
+
for (; index < source.length; index += 1) {
|
|
758
|
+
const ch = source[index];
|
|
759
|
+
if (ch === "(" || ch === "{" || ch === "[") {
|
|
760
|
+
depth += 1;
|
|
761
|
+
} else if (ch === ")" || ch === "}" || ch === "]") {
|
|
762
|
+
if (depth === 0) {
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
depth -= 1;
|
|
766
|
+
} else if ((ch === "," || ch === ";") && depth === 0) {
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return source.slice(valueStart, index);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// True when an object-literal expression spreads a CALL expression at its top
|
|
774
|
+
// level, e.g. `{ ...makeOperations() }` or `{ ...a, ...build(x) }`. Spreads
|
|
775
|
+
// nested deeper than the outer object (inside handler bodies, nested objects,
|
|
776
|
+
// or arrays) are ignored, so only a factory composition of the object itself
|
|
777
|
+
// is detected. Input is expected to start at the outer `{`.
|
|
778
|
+
function hasTopLevelFactorySpread(expr: string): boolean {
|
|
779
|
+
const open = expr.indexOf("{");
|
|
780
|
+
if (open === -1) {
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
783
|
+
let depth = 0;
|
|
784
|
+
for (let i = open; i < expr.length; i += 1) {
|
|
785
|
+
const ch = expr[i];
|
|
786
|
+
if (ch === "{" || ch === "(" || ch === "[") {
|
|
787
|
+
depth += 1;
|
|
788
|
+
} else if (ch === "}" || ch === ")" || ch === "]") {
|
|
789
|
+
depth -= 1;
|
|
790
|
+
if (depth === 0) {
|
|
791
|
+
break;
|
|
792
|
+
}
|
|
793
|
+
} else if (ch === "." && depth === 1 && expr.startsWith("...", i)) {
|
|
794
|
+
// A spread at the object's own level. Check whether the spread
|
|
795
|
+
// argument is a call expression (factory) rather than a plain
|
|
796
|
+
// identifier/member spread of an already-built object.
|
|
797
|
+
const rest = expr.slice(i + 3);
|
|
798
|
+
if (/^\s*[A-Za-z_$][\w$.]*\s*\(/.test(rest)) {
|
|
799
|
+
return true;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Collects the depth-1 spread IDENTIFIERS of an object-literal expression that
|
|
807
|
+
// are bare identifiers (not call expressions), e.g. `{ ...hidden, ...base }` ->
|
|
808
|
+
// ["hidden", "base"]. A `...makeOps()` call spread is already caught by
|
|
809
|
+
// hasTopLevelFactorySpread, so it is excluded here. These identifiers must be
|
|
810
|
+
// resolved to their declarations: `const hidden = makeOperations()` spread as
|
|
811
|
+
// `{ ...hidden }` is still a factory-composed map and must block.
|
|
812
|
+
function topLevelSpreadIdentifiers(expr: string): string[] {
|
|
813
|
+
const open = expr.indexOf("{");
|
|
814
|
+
if (open === -1) {
|
|
815
|
+
return [];
|
|
816
|
+
}
|
|
817
|
+
const names: string[] = [];
|
|
818
|
+
let depth = 0;
|
|
819
|
+
for (let i = open; i < expr.length; i += 1) {
|
|
820
|
+
const ch = expr[i];
|
|
821
|
+
if (ch === "{" || ch === "(" || ch === "[") {
|
|
822
|
+
depth += 1;
|
|
823
|
+
} else if (ch === "}" || ch === ")" || ch === "]") {
|
|
824
|
+
depth -= 1;
|
|
825
|
+
if (depth === 0) {
|
|
826
|
+
break;
|
|
827
|
+
}
|
|
828
|
+
} else if (ch === "." && depth === 1 && expr.startsWith("...", i)) {
|
|
829
|
+
const rest = expr.slice(i + 3);
|
|
830
|
+
// Bare identifier spread (no call parens) -> needs declaration
|
|
831
|
+
// resolution. `...obj.prop` member spreads are treated as already
|
|
832
|
+
// built and ignored (the leading identifier is captured).
|
|
833
|
+
const m = rest.match(/^\s*([A-Za-z_$][\w$]*)\s*(?![\w$(])/);
|
|
834
|
+
if (m?.[1]) {
|
|
835
|
+
names.push(m[1]);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return names;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// A call expression is an OPAQUE builder (block) when it invokes a
|
|
843
|
+
// provider-authored function whose body — and therefore the operation set — is
|
|
844
|
+
// not visible at the call site, e.g. `makeOperations()` or a destructured
|
|
845
|
+
// `const { operations } = createProviderComposition(...)`. It is NOT opaque
|
|
846
|
+
// when it is the stdlib `Object.fromEntries(Object.entries(<obj>) ...)`
|
|
847
|
+
// enumerate-and-reshape idiom: the operation set still originates from a
|
|
848
|
+
// source-visible object (the `Object.entries(...)` argument) and is merely
|
|
849
|
+
// filtered/reshaped by pure built-ins, so the registry/reviewer can still
|
|
850
|
+
// enumerate the op map from source. This is the verified golden pattern (a
|
|
851
|
+
// statically-defined op object narrowed by a whitelist Set).
|
|
852
|
+
//
|
|
853
|
+
// The exemption requires `Object.entries(` to be the ROOT of fromEntries'
|
|
854
|
+
// FIRST argument — not merely present somewhere inside the expression. This
|
|
855
|
+
// rejects opaque maps that only mention `Object.entries` deeper in a predicate,
|
|
856
|
+
// e.g. `Object.fromEntries(buildEntries().filter(([id]) => Object.entries(ALLOWED).some(...)))`,
|
|
857
|
+
// whose entries still originate from the opaque `buildEntries()` call. Any other
|
|
858
|
+
// expression — `Object.fromEntries(buildEntries())`, a destructured factory,
|
|
859
|
+
// `makeOperations()` — stays classified as factory composition.
|
|
860
|
+
const TRANSPARENT_RESHAPE_HEAD = /^Object\s*\.\s*fromEntries\s*\(/;
|
|
861
|
+
const OBJECT_ENTRIES_HEAD = /^Object\s*\.\s*entries\s*\(/;
|
|
862
|
+
function isTransparentObjectReshape(expr: string): boolean {
|
|
863
|
+
const head = TRANSPARENT_RESHAPE_HEAD.exec(expr);
|
|
864
|
+
if (!head) {
|
|
865
|
+
return false;
|
|
866
|
+
}
|
|
867
|
+
// First argument starts immediately after `fromEntries(`. The reshape is
|
|
868
|
+
// transparent only when that argument's root callee is `Object.entries(`
|
|
869
|
+
// (optionally chained: `Object.entries(obj).filter(...)`), so the source
|
|
870
|
+
// object is enumerable from source rather than produced by an opaque call.
|
|
871
|
+
const firstArg = expr.slice(head[0].length).trimStart();
|
|
872
|
+
return OBJECT_ENTRIES_HEAD.test(firstArg);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Decide whether an `input:` property at `propIndex` is an operation's public
|
|
876
|
+
// input schema (the thing the rule guards) or merely a field literally named
|
|
877
|
+
// "input" inside a zod schema body (e.g. modelling an upstream payload that
|
|
878
|
+
// happens to have an `input` field: `z.object({ input: z.object(...) })`).
|
|
879
|
+
// We walk backwards to the directly-enclosing `{` and inspect the token that
|
|
880
|
+
// opened it: if that brace is the argument of a zod builder call such as
|
|
881
|
+
// `z.object(`, `z.strictObject(`, `z.looseObject(`, `z.record(`, or a bare
|
|
882
|
+
// `.object(` / `.shape(`, the `input` key is a schema field, not an operation
|
|
883
|
+
// input. Operation inputs live in a plain object literal (the operation
|
|
884
|
+
// definition), so their enclosing `{` is NOT immediately preceded by `(` of a
|
|
885
|
+
// schema builder.
|
|
886
|
+
function inputKeyIsSchemaField(source: string, propIndex: number): boolean {
|
|
887
|
+
let depth = 0;
|
|
888
|
+
let i = propIndex - 1;
|
|
889
|
+
for (; i >= 0; i -= 1) {
|
|
890
|
+
const ch = source[i];
|
|
891
|
+
if (ch === "}" || ch === ")" || ch === "]") {
|
|
892
|
+
depth += 1;
|
|
893
|
+
} else if (ch === "(" || ch === "[") {
|
|
894
|
+
if (depth === 0) {
|
|
895
|
+
// Reached an opening paren/bracket that directly contains the
|
|
896
|
+
// property — an array/call arg position, not an object literal.
|
|
897
|
+
return false;
|
|
898
|
+
}
|
|
899
|
+
depth -= 1;
|
|
900
|
+
} else if (ch === "{") {
|
|
901
|
+
if (depth === 0) {
|
|
902
|
+
break;
|
|
903
|
+
}
|
|
904
|
+
depth -= 1;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
if (i < 0) {
|
|
908
|
+
return false;
|
|
909
|
+
}
|
|
910
|
+
// `i` indexes the directly-enclosing `{`. Look at the non-whitespace text
|
|
911
|
+
// immediately before it. A zod object/record builder opens with `(` then
|
|
912
|
+
// optionally whitespace then `{`, so the char before `{` is `(` and the
|
|
913
|
+
// callee just before that `(` is a zod builder identifier.
|
|
914
|
+
let j = i - 1;
|
|
915
|
+
while (j >= 0 && /\s/.test(source[j] ?? "")) {
|
|
916
|
+
j -= 1;
|
|
917
|
+
}
|
|
918
|
+
if (source[j] !== "(") {
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
921
|
+
// Capture the callee identifier chain that ends at this `(` and test its
|
|
922
|
+
// final member against the set of zod builders that take an object body.
|
|
923
|
+
const before = source.slice(Math.max(0, j - 60), j);
|
|
924
|
+
const calleeMatch = before.match(/([A-Za-z_$][\w$]*)\s*$/);
|
|
925
|
+
const callee = calleeMatch?.[1];
|
|
926
|
+
if (callee === undefined) {
|
|
927
|
+
return false;
|
|
928
|
+
}
|
|
929
|
+
const SCHEMA_BODY_BUILDERS = new Set([
|
|
930
|
+
"object",
|
|
931
|
+
"strictObject",
|
|
932
|
+
"looseObject",
|
|
933
|
+
"record",
|
|
934
|
+
"shape",
|
|
935
|
+
"extend",
|
|
936
|
+
"merge",
|
|
937
|
+
"catchall",
|
|
938
|
+
"partial",
|
|
939
|
+
"required",
|
|
940
|
+
"pick",
|
|
941
|
+
"omit",
|
|
942
|
+
"augment",
|
|
943
|
+
]);
|
|
944
|
+
return SCHEMA_BODY_BUILDERS.has(callee);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// True when `source` imports the binding `name` from another module, i.e. a
|
|
948
|
+
// top-level `import { ..., name, ... } from "..."` (named or aliased) or a
|
|
949
|
+
// default/namespace import of `name`. Used to confirm an `input: <alias>`
|
|
950
|
+
// reference actually binds to an imported declaration before resolving it
|
|
951
|
+
// against the provider-wide passthrough map (prevents same-name collisions
|
|
952
|
+
// across unrelated modules from producing false positives).
|
|
953
|
+
function fileImportsBinding(source: string, name: string): boolean {
|
|
954
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
955
|
+
return new RegExp(`\\bimport\\b[^;]*\\b${escaped}\\b[^;]*\\bfrom\\b`).test(
|
|
956
|
+
source,
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Resolves the ORIGINAL exported name for a local binding `localName`. When the
|
|
961
|
+
// file imports it under an alias — `import { requestSchema as inputSchema }` —
|
|
962
|
+
// the provider-wide passthrough map is keyed by the exported declaration name
|
|
963
|
+
// (`requestSchema`), not the local alias (`inputSchema`), so the alias must be
|
|
964
|
+
// mapped back before lookup. Returns `localName` unchanged when there is no
|
|
965
|
+
// aliased import (plain `import { requestSchema }` or a local declaration).
|
|
966
|
+
function importedOriginalName(source: string, localName: string): string {
|
|
967
|
+
const escaped = localName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
968
|
+
// Match `<original> as <localName>` inside any import specifier list.
|
|
969
|
+
const aliasMatch = new RegExp(
|
|
970
|
+
`\\bimport\\b[^;]*\\{[^}]*\\b([A-Za-z_$][\\w$]*)\\s+as\\s+${escaped}\\b[^}]*\\}[^;]*\\bfrom\\b`,
|
|
971
|
+
).exec(source);
|
|
972
|
+
return aliasMatch?.[1] ?? localName;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function scoreUnsafeInputPassthrough(providerRoot: string): SubmitCheck {
|
|
976
|
+
const findings: SourceFinding[] = [];
|
|
977
|
+
const files = listNonTestTypeScriptFiles(providerRoot);
|
|
978
|
+
|
|
979
|
+
// Pass 1: collect every passthrough schema const across the WHOLE provider
|
|
980
|
+
// submission (not per-file), keyed by name -> declaration site. This lets an
|
|
981
|
+
// `input:` in index.ts resolve a non-`input`-named passthrough schema that
|
|
982
|
+
// was declared in another module (e.g. schemas.ts) and imported.
|
|
983
|
+
type ConstSite = { file: string; line: number };
|
|
984
|
+
const passthroughConsts = new Map<string, ConstSite>();
|
|
985
|
+
// Per-file map of passthrough const declarations, so an `input: <alias>` can
|
|
986
|
+
// resolve its ACTUAL binding (a same-file local declaration) before falling
|
|
987
|
+
// back to an imported cross-module schema. This prevents a generic name like
|
|
988
|
+
// `requestSchema` declared in one module from being matched against an
|
|
989
|
+
// unrelated `input: requestSchema` in another module (a false positive on a
|
|
990
|
+
// strict schema that merely shares the identifier).
|
|
991
|
+
const passthroughByFile = new Map<string, Map<string, ConstSite>>();
|
|
992
|
+
const fileSources = new Map<string, string>();
|
|
993
|
+
for (const filePath of files) {
|
|
994
|
+
const source = readFileSync(filePath, "utf8");
|
|
995
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
996
|
+
fileSources.set(filePath, source);
|
|
997
|
+
const localMap = new Map<string, ConstSite>();
|
|
998
|
+
passthroughByFile.set(filePath, localMap);
|
|
999
|
+
const constDecl =
|
|
1000
|
+
/(?:^|\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
|
+
) {
|
|
1006
|
+
const name = match[1];
|
|
1007
|
+
if (name === undefined) {
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
const valueStart = match.index + match[0].length;
|
|
1011
|
+
const value = balancedValueExpression(source, valueStart);
|
|
1012
|
+
if (PASSTHROUGH_CALL.test(value)) {
|
|
1013
|
+
const site: ConstSite = {
|
|
1014
|
+
file: relPath,
|
|
1015
|
+
line: offsetToLine(source, valueStart),
|
|
1016
|
+
};
|
|
1017
|
+
localMap.set(name, site);
|
|
1018
|
+
// First declaration wins for line attribution; duplicate names
|
|
1019
|
+
// across modules are rare and either site is a valid pointer.
|
|
1020
|
+
if (!passthroughConsts.has(name)) {
|
|
1021
|
+
passthroughConsts.set(name, site);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const seen = new Set<string>();
|
|
1028
|
+
const push = (site: ConstSite) => {
|
|
1029
|
+
const key = `${site.file}:${site.line}`;
|
|
1030
|
+
if (!seen.has(key)) {
|
|
1031
|
+
seen.add(key);
|
|
1032
|
+
findings.push({ file: site.file, line: site.line });
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
|
|
1036
|
+
// Pass 2: inspect every `input:` property value across all files. A value
|
|
1037
|
+
// that is itself a passthrough expression, or that references a passthrough
|
|
1038
|
+
// const by name (resolved against the provider-wide map), is a violation.
|
|
1039
|
+
for (const filePath of files) {
|
|
1040
|
+
const source = fileSources.get(filePath) ?? readFileSync(filePath, "utf8");
|
|
1041
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
1042
|
+
|
|
1043
|
+
const inputProp = /\binput\s*:\s*/g;
|
|
1044
|
+
for (
|
|
1045
|
+
let match = inputProp.exec(source);
|
|
1046
|
+
match !== null;
|
|
1047
|
+
match = inputProp.exec(source)
|
|
1048
|
+
) {
|
|
1049
|
+
// Skip `input` keys that are fields inside a zod schema body (e.g. an
|
|
1050
|
+
// upstream payload modelled as `z.object({ input: ... })`). Only an
|
|
1051
|
+
// operation's public `input:` property is in scope for this rule.
|
|
1052
|
+
if (inputKeyIsSchemaField(source, match.index)) {
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
const valueStart = match.index + match[0].length;
|
|
1056
|
+
const value = balancedValueExpression(source, valueStart);
|
|
1057
|
+
if (PASSTHROUGH_CALL.test(value)) {
|
|
1058
|
+
push({ file: relPath, line: offsetToLine(source, valueStart) });
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
const ref = value.trim().match(/^([A-Za-z_$][\w$]*)/);
|
|
1062
|
+
const refName = ref?.[1];
|
|
1063
|
+
if (refName) {
|
|
1064
|
+
// Resolve the alias by BINDING, not by global name. Prefer a
|
|
1065
|
+
// passthrough const declared in THIS file; otherwise only fall
|
|
1066
|
+
// back to the provider-wide map when this file actually imports
|
|
1067
|
+
// `refName` (so a generic name shared across modules cannot link
|
|
1068
|
+
// an unrelated strict input to a foreign passthrough schema).
|
|
1069
|
+
const localSite = passthroughByFile.get(filePath)?.get(refName);
|
|
1070
|
+
if (localSite) {
|
|
1071
|
+
push(localSite);
|
|
1072
|
+
} else if (fileImportsBinding(source, refName)) {
|
|
1073
|
+
// Imported binding: map a possible `orig as refName` alias
|
|
1074
|
+
// back to the exported name the provider-wide map is keyed by.
|
|
1075
|
+
const originalName = importedOriginalName(source, refName);
|
|
1076
|
+
const site =
|
|
1077
|
+
passthroughConsts.get(refName) ??
|
|
1078
|
+
passthroughConsts.get(originalName);
|
|
1079
|
+
if (site) {
|
|
1080
|
+
push(site);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// `input,` shorthand binds a local `input` const; flag it if that const
|
|
1087
|
+
// is a passthrough schema declared in THIS file (the binding the
|
|
1088
|
+
// shorthand actually closes over).
|
|
1089
|
+
if (/(?:^|\n)[ \t]*input\s*,/.test(source)) {
|
|
1090
|
+
const localInput = passthroughByFile.get(filePath)?.get("input");
|
|
1091
|
+
if (localInput) {
|
|
1092
|
+
push(localInput);
|
|
1093
|
+
} else if (fileImportsBinding(source, "input")) {
|
|
1094
|
+
const site = passthroughConsts.get("input");
|
|
1095
|
+
if (site) {
|
|
1096
|
+
push(site);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
return escapeHatchResult(providerRoot, "unsafe-input-passthrough", findings, {
|
|
1103
|
+
blockerMessage:
|
|
1104
|
+
"Public input schema uses .passthrough(); unknown caller fields are silently accepted or dropped.",
|
|
1105
|
+
remediation:
|
|
1106
|
+
"Use strict input schemas (z.object({...}) without .passthrough()). If upstream form replay genuinely needs it, allowlist the forwarded fields and add `// @apifuse-allow unsafe-input-passthrough: <reason>`.",
|
|
1107
|
+
passMessage: "Input schemas do not use unscoped .passthrough().",
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function scoreUnjustifiedLooseSchema(providerRoot: string): SubmitCheck {
|
|
1112
|
+
const findings: SourceFinding[] = [];
|
|
1113
|
+
|
|
1114
|
+
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
1115
|
+
const lines = readFileSync(filePath, "utf8").split(/\r?\n/);
|
|
1116
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1117
|
+
const line = lines[index];
|
|
1118
|
+
if (line === undefined || !/\bz\.(record|unknown|any)\s*\(/.test(line)) {
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
// A `//` justification comment on the same line or the line above
|
|
1122
|
+
// (including the `@apifuse-allow loose-schema:` form) acknowledges it.
|
|
1123
|
+
const previous = lines[index - 1];
|
|
1124
|
+
const justified =
|
|
1125
|
+
line.includes("//") || previous?.trim().startsWith("//") === true;
|
|
1126
|
+
if (!justified) {
|
|
1127
|
+
findings.push({
|
|
1128
|
+
file: toRelativeProviderPath(providerRoot, filePath),
|
|
1129
|
+
line: index + 1,
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
return escapeHatchResult(providerRoot, "unjustified-loose-schema", findings, {
|
|
1136
|
+
blockerMessage:
|
|
1137
|
+
"Loose schema (z.record/z.unknown/z.any) used without justification.",
|
|
1138
|
+
remediation:
|
|
1139
|
+
"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
|
+
passMessage: "Loose schemas are justified or absent.",
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// True when `name` resolves, anywhere in the provider submission, to a
|
|
1145
|
+
// declaration whose initializer is an OPAQUE factory — a call expression
|
|
1146
|
+
// (`const hidden = makeOperations()`) or itself a factory spread — or to an
|
|
1147
|
+
// imported binding with no local declaration (out-of-view construction). Used
|
|
1148
|
+
// to classify a top-level spread identifier (`{ ...hidden }`) so an opaque
|
|
1149
|
+
// factory map cannot be laundered through a variable before being spread. The
|
|
1150
|
+
// stdlib transparent reshape is NOT treated as a factory (parity with the
|
|
1151
|
+
// direct-alias classification).
|
|
1152
|
+
function spreadIdentifierResolvesToFactory(
|
|
1153
|
+
providerRoot: string,
|
|
1154
|
+
indexPath: string,
|
|
1155
|
+
indexSource: string,
|
|
1156
|
+
name: string,
|
|
1157
|
+
): boolean {
|
|
1158
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1159
|
+
const declRe = new RegExp(
|
|
1160
|
+
`(?:^|\\n)[ \t]*(?:export\\s+)?(?:const|let|var)\\s+${escaped}\\s*(?::[^=\\n]+)?\\s*=`,
|
|
1161
|
+
"g",
|
|
1162
|
+
);
|
|
1163
|
+
let sawDeclaration = false;
|
|
1164
|
+
for (const filePath of [
|
|
1165
|
+
indexPath,
|
|
1166
|
+
...listNonTestTypeScriptFiles(providerRoot).filter(
|
|
1167
|
+
(p) => resolve(p) !== resolve(indexPath),
|
|
1168
|
+
),
|
|
1169
|
+
]) {
|
|
1170
|
+
if (!existsSync(filePath)) {
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
const fileSource =
|
|
1174
|
+
filePath === indexPath ? indexSource : readFileSync(filePath, "utf8");
|
|
1175
|
+
const re = new RegExp(declRe.source, "g");
|
|
1176
|
+
for (let m = re.exec(fileSource); m !== null; m = re.exec(fileSource)) {
|
|
1177
|
+
sawDeclaration = true;
|
|
1178
|
+
const expr = unwrapParens(
|
|
1179
|
+
balancedValueExpression(fileSource, m.index + m[0].length).trim(),
|
|
1180
|
+
);
|
|
1181
|
+
const isFactory =
|
|
1182
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) ||
|
|
1183
|
+
hasTopLevelFactorySpread(expr)) &&
|
|
1184
|
+
!isTransparentObjectReshape(expr);
|
|
1185
|
+
if (isFactory) {
|
|
1186
|
+
return true;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
// No local declaration anywhere but imported into index.ts => constructed
|
|
1191
|
+
// out of view; treat as factory (conservative, false-negative-safe).
|
|
1192
|
+
if (!sawDeclaration && fileImportsBinding(indexSource, name)) {
|
|
1193
|
+
return true;
|
|
1194
|
+
}
|
|
1195
|
+
return false;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
|
|
1199
|
+
const indexPath = resolve(providerRoot, "index.ts");
|
|
1200
|
+
const ruleId = "flat-operation-composition";
|
|
1201
|
+
if (!existsSync(indexPath)) {
|
|
1202
|
+
return pass(
|
|
1203
|
+
ruleId,
|
|
1204
|
+
SDK_NATIVE_CATEGORY,
|
|
1205
|
+
"Provider index.ts not found; flat-operation check skipped.",
|
|
1206
|
+
0,
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const source = readFileSync(indexPath, "utf8");
|
|
1211
|
+
// Use the same whitespace-tolerant detection as the resolver below, so a
|
|
1212
|
+
// `defineProvider (` / `defineProvider\n(` formatting cannot pass the early
|
|
1213
|
+
// exit before the real classification runs.
|
|
1214
|
+
if (!/\bdefineProvider\s*\(/.test(source)) {
|
|
1215
|
+
return pass(
|
|
1216
|
+
ruleId,
|
|
1217
|
+
SDK_NATIVE_CATEGORY,
|
|
1218
|
+
"No defineProvider call to evaluate for operation composition.",
|
|
1219
|
+
0,
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// Scope the scan to the argument of the EXPORTED `defineProvider(...)` call.
|
|
1224
|
+
// A provider can contain helper/non-exported defineProvider calls before the
|
|
1225
|
+
// real default export (e.g. test scaffolds), so resolve the default export
|
|
1226
|
+
// rather than blindly taking the first regex match. Resolution order:
|
|
1227
|
+
// 1. `export default defineProvider(` — inline default export
|
|
1228
|
+
// 2. `export default <ident>` then `const <ident> = defineProvider(`
|
|
1229
|
+
// 3. fallback: first `defineProvider(` in the file
|
|
1230
|
+
let defineParenIndex = -1;
|
|
1231
|
+
const inlineDefault = /\bexport\s+default\s+defineProvider\s*\(/.exec(source);
|
|
1232
|
+
if (inlineDefault) {
|
|
1233
|
+
defineParenIndex = inlineDefault.index + inlineDefault[0].length - 1; // points at `(`
|
|
1234
|
+
} else {
|
|
1235
|
+
const namedDefault = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/.exec(
|
|
1236
|
+
source,
|
|
1237
|
+
);
|
|
1238
|
+
const exportedName = namedDefault?.[1];
|
|
1239
|
+
if (exportedName !== undefined) {
|
|
1240
|
+
const namedDecl = new RegExp(
|
|
1241
|
+
`(?:^|\\n)[ \t]*(?:export\\s+)?(?:const|let|var)\\s+${exportedName}\\s*(?::[^=\\n]+)?\\s*=\\s*defineProvider\\s*\\(`,
|
|
1242
|
+
).exec(source);
|
|
1243
|
+
if (namedDecl) {
|
|
1244
|
+
defineParenIndex = namedDecl.index + namedDecl[0].length - 1;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
if (defineParenIndex === -1) {
|
|
1248
|
+
const firstCall = /\bdefineProvider\s*\(/.exec(source);
|
|
1249
|
+
if (firstCall) {
|
|
1250
|
+
defineParenIndex = firstCall.index + firstCall[0].length - 1;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
if (defineParenIndex === -1) {
|
|
1255
|
+
return pass(
|
|
1256
|
+
ruleId,
|
|
1257
|
+
SDK_NATIVE_CATEGORY,
|
|
1258
|
+
"No defineProvider call to evaluate for operation composition.",
|
|
1259
|
+
0,
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
const argStart = defineParenIndex + 1;
|
|
1263
|
+
const argText = balancedValueExpression(source, argStart);
|
|
1264
|
+
|
|
1265
|
+
// Resolve the value passed as `operations:` inside the defineProvider call,
|
|
1266
|
+
// following one alias hop. The value is classified as a static object
|
|
1267
|
+
// literal (pass) or a factory/call expression (block). The regex index is
|
|
1268
|
+
// offset back into the full source so line numbers stay accurate.
|
|
1269
|
+
const opsProp = /\boperations\s*:\s*/.exec(argText);
|
|
1270
|
+
let opsValue: string | undefined;
|
|
1271
|
+
let opsLine = 1;
|
|
1272
|
+
if (opsProp) {
|
|
1273
|
+
const valueStart = argStart + opsProp.index + opsProp[0].length;
|
|
1274
|
+
opsValue = unwrapParens(balancedValueExpression(source, valueStart).trim());
|
|
1275
|
+
opsLine = offsetToLine(source, valueStart);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// Property shorthand: `defineProvider({ ..., operations })` — resolve the
|
|
1279
|
+
// local `operations` const initializer.
|
|
1280
|
+
let aliasName: string | undefined;
|
|
1281
|
+
if (opsValue === undefined) {
|
|
1282
|
+
if (/\boperations\s*[,}]/.test(argText)) {
|
|
1283
|
+
aliasName = "operations";
|
|
1284
|
+
}
|
|
1285
|
+
} else if (/^[A-Za-z_$][\w$]*$/.test(opsValue)) {
|
|
1286
|
+
// `operations: ops` — a bare identifier alias to resolve.
|
|
1287
|
+
aliasName = opsValue;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// Determine the effective initializer expression to classify. The alias may
|
|
1291
|
+
// be declared in index.ts OR re-exported from a sibling module (the common
|
|
1292
|
+
// generated scaffold: `import { operations } from "./operations"` where
|
|
1293
|
+
// ./operations.ts builds the map with makeOperations()/Object.fromEntries).
|
|
1294
|
+
// Resolve across every provider source file so cross-module factory
|
|
1295
|
+
// composition cannot evade the blocker.
|
|
1296
|
+
let effective = opsValue;
|
|
1297
|
+
let effectiveLine = opsLine;
|
|
1298
|
+
let effectiveFile = "index.ts";
|
|
1299
|
+
if (aliasName !== undefined) {
|
|
1300
|
+
const aliasDecl = new RegExp(
|
|
1301
|
+
`(?:^|\\n)[ \t]*(?:export\\s+)?(?:const|let|var)\\s+${aliasName}\\s*(?::[^=\\n]+)?\\s*=`,
|
|
1302
|
+
);
|
|
1303
|
+
// Destructured factory form: `const { operations } = makeOps()`.
|
|
1304
|
+
const destructured = new RegExp(
|
|
1305
|
+
`(?:^|\\n)[ \t]*(?:export\\s+)?(?:const|let|var)\\s*\\{[^}]*\\b${aliasName}\\b[^}]*\\}\\s*=\\s*([A-Za-z_$][\\w$.]*)\\s*\\(`,
|
|
1306
|
+
);
|
|
1307
|
+
|
|
1308
|
+
// Search index.ts first (its line attribution wins), then siblings.
|
|
1309
|
+
const searchOrder = [
|
|
1310
|
+
indexPath,
|
|
1311
|
+
...listNonTestTypeScriptFiles(providerRoot).filter(
|
|
1312
|
+
(p) => resolve(p) !== resolve(indexPath),
|
|
1313
|
+
),
|
|
1314
|
+
];
|
|
1315
|
+
|
|
1316
|
+
// Collect EVERY same-named declaration across the submission and classify
|
|
1317
|
+
// each as factory vs static. A factory declaration anywhere wins, so a
|
|
1318
|
+
// decoy static `const operations = {}` in an earlier-scanned file cannot
|
|
1319
|
+
// mask a factory-composed declaration in another module. (We deliberately
|
|
1320
|
+
// do not resolve the exact import target path; "any same-named factory
|
|
1321
|
+
// blocks" is the conservative, false-negative-avoiding choice for a gate.)
|
|
1322
|
+
type Candidate = {
|
|
1323
|
+
expr: string;
|
|
1324
|
+
line: number;
|
|
1325
|
+
file: string;
|
|
1326
|
+
isFactory: boolean;
|
|
1327
|
+
};
|
|
1328
|
+
const candidates: Candidate[] = [];
|
|
1329
|
+
for (const filePath of searchOrder) {
|
|
1330
|
+
if (!existsSync(filePath)) {
|
|
1331
|
+
continue;
|
|
1332
|
+
}
|
|
1333
|
+
const fileSource =
|
|
1334
|
+
filePath === indexPath ? source : readFileSync(filePath, "utf8");
|
|
1335
|
+
const relPath = toRelativeProviderPath(providerRoot, filePath);
|
|
1336
|
+
|
|
1337
|
+
const declRe = new RegExp(aliasDecl.source, "g");
|
|
1338
|
+
for (
|
|
1339
|
+
let m = declRe.exec(fileSource);
|
|
1340
|
+
m !== null;
|
|
1341
|
+
m = declRe.exec(fileSource)
|
|
1342
|
+
) {
|
|
1343
|
+
const valueStart = m.index + m[0].length;
|
|
1344
|
+
const expr = unwrapParens(
|
|
1345
|
+
balancedValueExpression(fileSource, valueStart).trim(),
|
|
1346
|
+
);
|
|
1347
|
+
const isFactory =
|
|
1348
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(expr) ||
|
|
1349
|
+
hasTopLevelFactorySpread(expr)) &&
|
|
1350
|
+
!isTransparentObjectReshape(expr);
|
|
1351
|
+
candidates.push({
|
|
1352
|
+
expr,
|
|
1353
|
+
line: offsetToLine(fileSource, valueStart),
|
|
1354
|
+
file: relPath,
|
|
1355
|
+
isFactory,
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
const destructRe = new RegExp(destructured.source, "g");
|
|
1359
|
+
for (
|
|
1360
|
+
let m = destructRe.exec(fileSource);
|
|
1361
|
+
m !== null;
|
|
1362
|
+
m = destructRe.exec(fileSource)
|
|
1363
|
+
) {
|
|
1364
|
+
candidates.push({
|
|
1365
|
+
expr: `${m[1]}(`,
|
|
1366
|
+
line: offsetToLine(fileSource, m.index),
|
|
1367
|
+
file: relPath,
|
|
1368
|
+
isFactory: true,
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
const resolved = candidates.length > 0;
|
|
1374
|
+
if (resolved) {
|
|
1375
|
+
// Prefer a factory declaration (it blocks); otherwise keep the first
|
|
1376
|
+
// static declaration for line attribution.
|
|
1377
|
+
const factory = candidates.find((c) => c.isFactory);
|
|
1378
|
+
const chosen = factory ?? candidates[0];
|
|
1379
|
+
if (chosen !== undefined) {
|
|
1380
|
+
effective = chosen.expr;
|
|
1381
|
+
effectiveLine = chosen.line;
|
|
1382
|
+
effectiveFile = chosen.file;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// An imported alias that resolves to no local declaration anywhere in the
|
|
1387
|
+
// submission means the operations map is constructed out of view. Treat
|
|
1388
|
+
// the unresolved import as a factory-composed (non-static) shape rather
|
|
1389
|
+
// than silently passing.
|
|
1390
|
+
if (!resolved) {
|
|
1391
|
+
const importMatch = new RegExp(
|
|
1392
|
+
`\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`,
|
|
1393
|
+
).exec(source);
|
|
1394
|
+
if (importMatch) {
|
|
1395
|
+
effective = `${aliasName}(`;
|
|
1396
|
+
effectiveLine = offsetToLine(source, importMatch.index);
|
|
1397
|
+
effectiveFile = "index.ts";
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// A value starting with `{` is an object literal, but it is only STATIC if
|
|
1403
|
+
// its TOP-LEVEL entries are all explicit properties. A factory spread such
|
|
1404
|
+
// as `{ ...makeOperations() }` still composes the map dynamically. We only
|
|
1405
|
+
// inspect depth-1 entries so that ordinary spreads deep inside operation
|
|
1406
|
+
// handler bodies (e.g. `{ ...headers }`, `...arr.map(...)`) are NOT mistaken
|
|
1407
|
+
// for a top-level factory composition of the operations map itself.
|
|
1408
|
+
const hasFactorySpread =
|
|
1409
|
+
effective !== undefined && hasTopLevelFactorySpread(effective);
|
|
1410
|
+
// A spread of a bare identifier (`{ ...hidden }`) is static ONLY when that
|
|
1411
|
+
// identifier resolves to a non-factory declaration. Resolve each top-level
|
|
1412
|
+
// spread identifier so an opaque factory map laundered through a variable
|
|
1413
|
+
// (`const hidden = makeOperations(); operations: { ...hidden }`) still blocks.
|
|
1414
|
+
const hasFactorySpreadIdentifier =
|
|
1415
|
+
effective !== undefined &&
|
|
1416
|
+
topLevelSpreadIdentifiers(effective).some((name) =>
|
|
1417
|
+
spreadIdentifierResolvesToFactory(providerRoot, indexPath, source, name),
|
|
1418
|
+
);
|
|
1419
|
+
const isStaticLiteral =
|
|
1420
|
+
effective?.startsWith("{") === true &&
|
|
1421
|
+
!hasFactorySpread &&
|
|
1422
|
+
!hasFactorySpreadIdentifier;
|
|
1423
|
+
// A call expression `ident(...)` (factory) or a factory-spread literal is
|
|
1424
|
+
// the rejected, non-static shape — UNLESS it is the stdlib
|
|
1425
|
+
// `Object.fromEntries(Object.entries(<source-visible obj>)...)` reshape,
|
|
1426
|
+
// whose op set is still enumerable from source (verified golden pattern).
|
|
1427
|
+
const isFactoryCall =
|
|
1428
|
+
effective !== undefined &&
|
|
1429
|
+
(/^[A-Za-z_$][\w$.]*\s*\(/.test(effective) ||
|
|
1430
|
+
hasFactorySpread ||
|
|
1431
|
+
hasFactorySpreadIdentifier) &&
|
|
1432
|
+
!isTransparentObjectReshape(effective);
|
|
1433
|
+
|
|
1434
|
+
if (isFactoryCall && !isStaticLiteral) {
|
|
1435
|
+
// Route through the shared escape-hatch partitioner so an
|
|
1436
|
+
// `// @apifuse-allow flat-operation-composition: <reason>` comment on
|
|
1437
|
+
// the reported line (or the line above) downgrades this blocker to a
|
|
1438
|
+
// counted warning, consistent with the other structural rules.
|
|
1439
|
+
return escapeHatchResult(
|
|
1440
|
+
providerRoot,
|
|
1441
|
+
ruleId,
|
|
1442
|
+
[{ file: effectiveFile, line: effectiveLine }],
|
|
1443
|
+
{
|
|
1444
|
+
blockerMessage:
|
|
1445
|
+
"defineProvider operations are composed by a factory call instead of a static object literal.",
|
|
1446
|
+
remediation:
|
|
1447
|
+
"Declare operations as a static literal: defineProvider({ operations: { 'op-id': defineOperation({...}) } }). The provider-registry AST gate requires static runtime/operations; factory composition fails the registry build. If composition is unavoidable, add `// @apifuse-allow flat-operation-composition: <reason>`.",
|
|
1448
|
+
passMessage:
|
|
1449
|
+
"defineProvider declares operations as a static object literal.",
|
|
1450
|
+
},
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
return pass(
|
|
1455
|
+
ruleId,
|
|
1456
|
+
SDK_NATIVE_CATEGORY,
|
|
1457
|
+
"defineProvider declares operations as a static object literal.",
|
|
1458
|
+
0,
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function scoreCredentialUsage(
|
|
1463
|
+
providerRoot: string,
|
|
1464
|
+
provider: ProviderDefinition,
|
|
1465
|
+
): SubmitCheck {
|
|
1466
|
+
const credentialReferences = findSourceLineMatches(
|
|
1467
|
+
providerRoot,
|
|
1468
|
+
/ctx\.credential/,
|
|
1469
|
+
);
|
|
1470
|
+
const authMode = provider.auth?.mode ?? "none";
|
|
1471
|
+
const credentialKeys = provider.credential?.keys ?? [];
|
|
1472
|
+
const storesProviderCredential =
|
|
1473
|
+
authMode !== "none" || credentialKeys.length > 0;
|
|
1474
|
+
|
|
1475
|
+
if (storesProviderCredential && credentialReferences.length === 0) {
|
|
1476
|
+
return {
|
|
1477
|
+
id: "credential-usage",
|
|
1478
|
+
category: SDK_NATIVE_CATEGORY,
|
|
1479
|
+
level: "warn",
|
|
1480
|
+
status: "warn",
|
|
1481
|
+
points: 0,
|
|
1482
|
+
maxPoints: 0,
|
|
1483
|
+
message:
|
|
1484
|
+
"Credential-backed provider does not reference credential persistence in source.",
|
|
1485
|
+
remediation:
|
|
1486
|
+
"Persist provider session state through the SDK credential context instead of process-local state. See providers/catchtable for the reference pattern.",
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
return pass(
|
|
1491
|
+
"credential-usage",
|
|
1492
|
+
SDK_NATIVE_CATEGORY,
|
|
1493
|
+
authMode === "none" && credentialKeys.length === 0
|
|
1494
|
+
? "Provider does not declare reusable credentials."
|
|
1495
|
+
: "Credential-backed provider references ctx.credential.",
|
|
1496
|
+
0,
|
|
1497
|
+
credentialReferences.length > 0
|
|
1498
|
+
? formatSourceFindings(credentialReferences)
|
|
1499
|
+
: undefined,
|
|
1500
|
+
);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
function findSourceLineMatches(
|
|
1504
|
+
providerRoot: string,
|
|
1505
|
+
pattern: RegExp | ((line: string) => boolean),
|
|
1506
|
+
): SourceFinding[] {
|
|
1507
|
+
return findSourceFindings(providerRoot, (line) =>
|
|
1508
|
+
matchesLinePattern(line, pattern),
|
|
1509
|
+
);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
function findSourceFindings(
|
|
1513
|
+
providerRoot: string,
|
|
1514
|
+
matchesLine: (line: string, remainingLines: readonly string[]) => boolean,
|
|
1515
|
+
): SourceFinding[] {
|
|
1516
|
+
const findings: SourceFinding[] = [];
|
|
1517
|
+
for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
|
|
1518
|
+
const content = readFileSync(filePath, "utf8");
|
|
1519
|
+
const lines = content.split(/\r?\n/);
|
|
1520
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1521
|
+
const line = lines[index];
|
|
1522
|
+
if (line !== undefined && matchesLine(line, lines.slice(index + 1))) {
|
|
1523
|
+
findings.push({
|
|
1524
|
+
file: toRelativeProviderPath(providerRoot, filePath),
|
|
1525
|
+
line: index + 1,
|
|
1526
|
+
});
|
|
1527
|
+
if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
|
|
1528
|
+
return findings;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
return findings;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function matchesLinePattern(
|
|
1537
|
+
line: string,
|
|
1538
|
+
pattern: RegExp | ((line: string) => boolean),
|
|
1539
|
+
): boolean {
|
|
1540
|
+
return typeof pattern === "function" ? pattern(line) : pattern.test(line);
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
function listNonTestTypeScriptFiles(providerRoot: string): string[] {
|
|
1544
|
+
const files: string[] = [];
|
|
1545
|
+
collectNonTestTypeScriptFiles(providerRoot, providerRoot, files);
|
|
1546
|
+
return files;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
function listNonTestProviderSourceFiles(providerRoot: string): string[] {
|
|
1550
|
+
const files: string[] = [];
|
|
1551
|
+
collectNonTestProviderSourceFiles(providerRoot, providerRoot, files);
|
|
1552
|
+
return files;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
function collectNonTestProviderSourceFiles(
|
|
1556
|
+
providerRoot: string,
|
|
1557
|
+
currentPath: string,
|
|
1558
|
+
files: string[],
|
|
1559
|
+
): void {
|
|
1560
|
+
for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
|
|
1561
|
+
const entryPath = join(currentPath, entry.name);
|
|
1562
|
+
const relativePath = toRelativeProviderPath(providerRoot, entryPath);
|
|
1563
|
+
if (entry.isDirectory()) {
|
|
1564
|
+
if (shouldScanSourceDirectory(relativePath)) {
|
|
1565
|
+
collectNonTestProviderSourceFiles(providerRoot, entryPath, files);
|
|
1566
|
+
}
|
|
1567
|
+
continue;
|
|
1568
|
+
}
|
|
1569
|
+
if (
|
|
1570
|
+
entry.isFile() &&
|
|
1571
|
+
isScannableProviderSourceFile(relativePath) &&
|
|
1572
|
+
!isExcludedTestSource(relativePath)
|
|
1573
|
+
) {
|
|
1574
|
+
files.push(entryPath);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function collectNonTestTypeScriptFiles(
|
|
1580
|
+
providerRoot: string,
|
|
1581
|
+
currentPath: string,
|
|
1582
|
+
files: string[],
|
|
1583
|
+
): void {
|
|
1584
|
+
for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
|
|
1585
|
+
const entryPath = join(currentPath, entry.name);
|
|
1586
|
+
const relativePath = toRelativeProviderPath(providerRoot, entryPath);
|
|
1587
|
+
if (entry.isDirectory()) {
|
|
1588
|
+
if (shouldScanSourceDirectory(relativePath)) {
|
|
1589
|
+
collectNonTestTypeScriptFiles(providerRoot, entryPath, files);
|
|
1590
|
+
}
|
|
1591
|
+
continue;
|
|
1592
|
+
}
|
|
1593
|
+
if (
|
|
1594
|
+
entry.isFile() &&
|
|
1595
|
+
relativePath.endsWith(".ts") &&
|
|
1596
|
+
!isExcludedTestSource(relativePath)
|
|
1597
|
+
) {
|
|
1598
|
+
files.push(entryPath);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
function isScannableProviderSourceFile(relativePath: string): boolean {
|
|
1604
|
+
return (
|
|
1605
|
+
/\.(?:ts|tsx|js|jsx|mjs|cjs|sh|bash)$/.test(relativePath) ||
|
|
1606
|
+
/(?:^|\/)Dockerfile(?:\.|$)/.test(relativePath) ||
|
|
1607
|
+
/(?:^|\/)entrypoint(?:\.|$)/.test(relativePath)
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function shouldScanSourceDirectory(relativePath: string): boolean {
|
|
1612
|
+
return ![".git", "node_modules", "dist", "build", "coverage"].includes(
|
|
1613
|
+
relativePath,
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
function isExcludedTestSource(relativePath: string): boolean {
|
|
1618
|
+
return (
|
|
1619
|
+
relativePath.endsWith(".test.ts") ||
|
|
1620
|
+
relativePath.startsWith("__tests__/") ||
|
|
1621
|
+
relativePath.includes("/__tests__/") ||
|
|
1622
|
+
relativePath.startsWith("tests/") ||
|
|
1623
|
+
relativePath.includes("/tests/")
|
|
1624
|
+
);
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
function toRelativeProviderPath(
|
|
1628
|
+
providerRoot: string,
|
|
1629
|
+
filePath: string,
|
|
1630
|
+
): string {
|
|
1631
|
+
return relative(providerRoot, filePath).replaceAll("\\", "/");
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
function formatSourceFindings(findings: readonly SourceFinding[]): string[] {
|
|
1635
|
+
return findings.map((finding) => `${finding.file}:${finding.line}`);
|
|
1636
|
+
}
|
|
1637
|
+
|
|
291
1638
|
function scoreRepositoryDx(providerRoot: string): SubmitCheck {
|
|
292
1639
|
const missing: string[] = [];
|
|
293
1640
|
if (!existsSync(resolve(providerRoot, ".gitignore"))) {
|
|
@@ -353,7 +1700,7 @@ function checkScriptRunsTypeCheck(checkScript: unknown): boolean {
|
|
|
353
1700
|
|
|
354
1701
|
async function safeRunChecks(providerRoot: string): Promise<CheckResult[]> {
|
|
355
1702
|
try {
|
|
356
|
-
return await runChecks(providerRoot);
|
|
1703
|
+
return await runChecks(providerRoot, { lintMode: "standalone" });
|
|
357
1704
|
} catch (error) {
|
|
358
1705
|
return [
|
|
359
1706
|
{
|
|
@@ -365,6 +1712,81 @@ async function safeRunChecks(providerRoot: string): Promise<CheckResult[]> {
|
|
|
365
1712
|
}
|
|
366
1713
|
}
|
|
367
1714
|
|
|
1715
|
+
const SUBMIT_CHECK_BROWSER_PATTERNS: ReadonlyArray<{
|
|
1716
|
+
rule: string;
|
|
1717
|
+
pattern: RegExp;
|
|
1718
|
+
}> = [
|
|
1719
|
+
{
|
|
1720
|
+
rule: "browser-self-hosted-launch",
|
|
1721
|
+
pattern: /\b(?:playwright|chromium|firefox|webkit|puppeteer)\.launch\s*\(/,
|
|
1722
|
+
},
|
|
1723
|
+
{
|
|
1724
|
+
rule: "browser-self-hosted-child-process",
|
|
1725
|
+
pattern:
|
|
1726
|
+
/\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync|Bun\.spawn|Bun\.spawnSync)\s*\([^;]*\b(?:google-chrome|chrome|chromium|chromium-browser)\b|\$`[^`]*\b(?:google-chrome|chrome|chromium|chromium-browser)\b/,
|
|
1727
|
+
},
|
|
1728
|
+
{
|
|
1729
|
+
rule: "browser-self-hosted-remote-debugging-port",
|
|
1730
|
+
pattern:
|
|
1731
|
+
/(?:\b(?:google-chrome|chrome|chromium|chromium-browser)\b[\s\S]{0,240}--remote-debugging-port\b|--remote-debugging-port(?:=|\s+))/,
|
|
1732
|
+
},
|
|
1733
|
+
{
|
|
1734
|
+
rule: "browser-direct-cdp-version-poll",
|
|
1735
|
+
pattern: /\/json\/version\b/,
|
|
1736
|
+
},
|
|
1737
|
+
{
|
|
1738
|
+
rule: "browser-provider-local-cdp-env",
|
|
1739
|
+
pattern:
|
|
1740
|
+
/\b(?!APIFUSE__CDP_POOL__URL\b)[A-Z][A-Z0-9_]*_CDP_URL\b|process\.env(?:\.(?!APIFUSE__CDP_POOL__URL\b)[A-Z0-9_]*_CDP_URL\b|\[\s*["'`](?!APIFUSE__CDP_POOL__URL\b)[A-Z0-9_]*_CDP_URL["'`]\s*\])/,
|
|
1741
|
+
},
|
|
1742
|
+
];
|
|
1743
|
+
|
|
1744
|
+
function scoreManagedBrowserRuntime(providerRoot: string): SubmitCheck {
|
|
1745
|
+
const maxManagedBrowserEvidence = MAX_SOURCE_FINDING_EVIDENCE * 2;
|
|
1746
|
+
const browserFindings: string[] = [];
|
|
1747
|
+
for (const filePath of listNonTestProviderSourceFiles(providerRoot)) {
|
|
1748
|
+
const content = readFileSync(filePath, "utf8");
|
|
1749
|
+
const lines = content.split(/\r?\n/);
|
|
1750
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1751
|
+
const line = lines[index];
|
|
1752
|
+
if (line === undefined) continue;
|
|
1753
|
+
for (const { rule, pattern } of SUBMIT_CHECK_BROWSER_PATTERNS) {
|
|
1754
|
+
pattern.lastIndex = 0;
|
|
1755
|
+
if (!pattern.test(line)) continue;
|
|
1756
|
+
browserFindings.push(
|
|
1757
|
+
`${rule} ${toRelativeProviderPath(providerRoot, filePath)}:${index + 1}`,
|
|
1758
|
+
);
|
|
1759
|
+
if (browserFindings.length >= maxManagedBrowserEvidence) break;
|
|
1760
|
+
}
|
|
1761
|
+
if (browserFindings.length >= maxManagedBrowserEvidence) break;
|
|
1762
|
+
}
|
|
1763
|
+
if (browserFindings.length >= maxManagedBrowserEvidence) break;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
if (browserFindings.length > 0) {
|
|
1767
|
+
return {
|
|
1768
|
+
id: "managed-browser-runtime",
|
|
1769
|
+
category: SDK_NATIVE_CATEGORY,
|
|
1770
|
+
level: "warn",
|
|
1771
|
+
status: "warn",
|
|
1772
|
+
points: 0,
|
|
1773
|
+
maxPoints: 0,
|
|
1774
|
+
message:
|
|
1775
|
+
"Provider source contains self-hosted browser/CDP patterns that APIFuse maintainers must review before promotion.",
|
|
1776
|
+
remediation:
|
|
1777
|
+
"Use ctx.browser backed by the managed CDP Pool. Do not launch Playwright/Puppeteer/Chrome, poll /json/version, or read provider-local *_CDP_URL env vars in provider runtime code.",
|
|
1778
|
+
evidence: browserFindings.map(redact),
|
|
1779
|
+
};
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
return pass(
|
|
1783
|
+
"managed-browser-runtime",
|
|
1784
|
+
SDK_NATIVE_CATEGORY,
|
|
1785
|
+
"Provider source avoids self-hosted browser/CDP runtime patterns.",
|
|
1786
|
+
0,
|
|
1787
|
+
);
|
|
1788
|
+
}
|
|
1789
|
+
|
|
368
1790
|
function scoreBaseChecks(results: CheckResult[]): SubmitCheck[] {
|
|
369
1791
|
const failed = results.filter((result) => !result.passed);
|
|
370
1792
|
if (failed.length > 0) {
|