@apifuse/provider-sdk 2.2.0-beta.46 → 2.2.0-beta.47

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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.2.0-beta.47
4
+
5
+ - Release candidate for main commit 0ff0c6c90153af3aab75978eb73040c9adaa2609.
6
+
3
7
  ## 2.2.0-beta.46
4
8
 
5
9
  - Release candidate for main commit 0340fbc51b4ad71689bb1ec61b9b818dedda4587.
@@ -4312,6 +4312,7 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
4312
4312
  const operations = Object.entries(provider.operations);
4313
4313
  const missing: string[] = [];
4314
4314
  const vacuous: string[] = [];
4315
+ const evidenceFreeScenario: string[] = [];
4315
4316
  const placeholder: string[] = [];
4316
4317
  const unsupported: string[] = [];
4317
4318
  const generatedStarter: string[] = [];
@@ -4323,8 +4324,13 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
4323
4324
  missing.push(operationId);
4324
4325
  continue;
4325
4326
  }
4326
- if (hasCheck && !hasUnsupported && hasOnlyVacuousHealthCases(operation.healthCheck)) {
4327
- vacuous.push(operationId);
4327
+ if (hasCheck && !hasUnsupported) {
4328
+ const vacuousClass = vacuousHealthCaseClass(operation.healthCheck);
4329
+ if (vacuousClass === "empty-assertions") {
4330
+ vacuous.push(operationId);
4331
+ } else if (vacuousClass === "evidence-free-scenario") {
4332
+ evidenceFreeScenario.push(operationId);
4333
+ }
4328
4334
  }
4329
4335
  if (hasUnsupported) {
4330
4336
  const reason = operation.healthCheckUnsupported?.reason ?? "";
@@ -4351,14 +4357,36 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
4351
4357
  );
4352
4358
  }
4353
4359
 
4354
- if (vacuous.length > 0) {
4360
+ if (vacuous.length > 0 || evidenceFreeScenario.length > 0) {
4361
+ const remediationParts: string[] = [];
4362
+ if (vacuous.length > 0) {
4363
+ remediationParts.push(
4364
+ `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.`,
4365
+ );
4366
+ }
4367
+ if (evidenceFreeScenario.length > 0) {
4368
+ remediationParts.push(
4369
+ `healthCheck scenario for ${evidenceFreeScenario.join(", ")} asserts nothing beyond transport — add an assert step whose expression reads the probe response (a field under data, an extracted value, or a quantifier over response rows), not just status_2xx or a bare response-envelope check.`,
4370
+ );
4371
+ }
4372
+ const message =
4373
+ evidenceFreeScenario.length === 0
4374
+ ? "One or more operations have healthCheck cases with empty assertions."
4375
+ : vacuous.length === 0
4376
+ ? "One or more operations have healthCheck scenarios without response evidence."
4377
+ : "One or more operations have healthCheck cases with empty assertions or evidence-free scenarios.";
4355
4378
  return blocker(
4356
4379
  "health-coverage",
4357
4380
  "health",
4358
- "One or more operations have healthCheck cases with empty assertions.",
4359
- `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.`,
4381
+ message,
4382
+ remediationParts.join(" "),
4360
4383
  CATEGORY_MAX_POINTS.health,
4361
- vacuous.map((operationId) => `${operationId}: empty healthCheck.assertions`),
4384
+ [
4385
+ ...vacuous.map((operationId) => `${operationId}: empty healthCheck.assertions`),
4386
+ ...evidenceFreeScenario.map(
4387
+ (operationId) => `${operationId}: healthCheck scenario asserts nothing beyond transport`,
4388
+ ),
4389
+ ],
4362
4390
  );
4363
4391
  }
4364
4392
 
@@ -4413,14 +4441,216 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
4413
4441
  );
4414
4442
  }
4415
4443
 
4416
- function hasOnlyVacuousHealthCases(
4444
+ type VacuousHealthCaseClass = "empty-assertions" | "evidence-free-scenario";
4445
+
4446
+ /**
4447
+ * Classify an operation whose every health case is vacuous. Returns undefined
4448
+ * when at least one case carries real coverage: an imperative case with a
4449
+ * non-vacuous assertions function, or a declarative case whose scenario reads
4450
+ * the probe response. When all cases are vacuous, the class picks the blocker
4451
+ * wording — "empty-assertions" whenever any vacuous case is imperative (the
4452
+ * pre-scenario behavior, kept byte-identical), "evidence-free-scenario" when
4453
+ * the suite is scenario-only.
4454
+ */
4455
+ function vacuousHealthCaseClass(
4417
4456
  healthCheck: ProviderDefinition["operations"][string]["healthCheck"],
4418
- ): boolean {
4457
+ ): VacuousHealthCaseClass | undefined {
4419
4458
  const cases = healthCheck?.cases;
4420
4459
  if (!Array.isArray(cases) || cases.length === 0) {
4460
+ return "empty-assertions";
4461
+ }
4462
+ let sawImperativeCase = false;
4463
+ for (const healthCase of cases) {
4464
+ if (healthCase?.scenario !== undefined) {
4465
+ if (scenarioCarriesResponseEvidence(healthCase.scenario)) {
4466
+ return undefined;
4467
+ }
4468
+ continue;
4469
+ }
4470
+ sawImperativeCase = true;
4471
+ if (!isVacuousAssertionFunction(healthCase?.assertions)) {
4472
+ return undefined;
4473
+ }
4474
+ }
4475
+ return sawImperativeCase ? "empty-assertions" : "evidence-free-scenario";
4476
+ }
4477
+
4478
+ /**
4479
+ * A declarative case (`scenario`) carries real coverage when at least one
4480
+ * `assert` step reads the probe response beyond the transport envelope. The
4481
+ * SDK's own definitions drive this judgement:
4482
+ *
4483
+ * - The assert step is the scenario's only verdict-bearing construct
4484
+ * (`AssertResult.passed`). A guard can only attribute
4485
+ * degraded/expected_absence and stop the run (an escape hatch, not a health
4486
+ * verdict), and an operation step does not intrinsically fail on a non-2xx
4487
+ * response — that is what the explicit `status_2xx` predicate exists for. So
4488
+ * a scenario without a semantic assert step proves nothing beyond the
4489
+ * operation merely executing, which is the same emptiness the imperative
4490
+ * vacuity check flags.
4491
+ * - Leaves that cannot observe upstream content carry no evidence: predicates
4492
+ * whose operands read nothing produced by a step (pure literals,
4493
+ * attempt/credential metadata, and the `status_code`/`request_id`/`kind`
4494
+ * envelope fields of a step result — this is what makes the legacy transport
4495
+ * pin `status_2xx` on `["status_code"]` non-evidence), predicates comparing
4496
+ * a reference against an identical copy of itself (`equals(x, x)` holds for
4497
+ * any response), and positive-polarity presence/type pins (`exists`,
4498
+ * `type_is`, `not(not_exists(...))`, zero-bound `array_length_gte`) on the
4499
+ * bare `data` root — the declarative spellings of the legacy transport-only
4500
+ * assertion (`status === 200` + `typeof data === "object"`), satisfied by
4501
+ * any well-formed response body. Polarity matters: `not(exists(data))`
4502
+ * ("this probe must return no body") is falsified by ordinary responses, so
4503
+ * the carve-out only applies where the pin appears un-negated.
4504
+ * - Everything else counts: paths under `data`, extract-step results,
4505
+ * `duration_ms` (parity with imperative `ctx.durationMs` reads),
4506
+ * `status_2xx` applied to a body path (envelope-style upstreams answer HTTP
4507
+ * 200 and carry the real code in the body), and quantifiers, which iterate
4508
+ * response rows by construction.
4509
+ *
4510
+ * Fail-open contract (same as isVacuousAssertionFunction): the provider module
4511
+ * already validated this scenario against HealthScenarioSchema in its own SDK
4512
+ * at import time, so any shape this walk does not recognize (unknown step
4513
+ * kind, expression kind, operator, or reference namespace) indicates a newer
4514
+ * grammar, not an empty probe — treat it as evidence rather than flag it.
4515
+ * The inverse direction is accepted and documented: a determined author can
4516
+ * still fabricate a read that never fails (a decorative deep path inside a
4517
+ * disjunction, a degenerate bound). That effort is comparable to writing the
4518
+ * real one-line assertion — the same accepted limitation as the imperative
4519
+ * check's decorative-ctx-read — and the live `--smoke` probe remains the
4520
+ * runtime defense.
4521
+ */
4522
+ export function scenarioCarriesResponseEvidence(scenario: unknown): boolean {
4523
+ if (!isRecord(scenario) || !Array.isArray(scenario.steps)) {
4524
+ return true;
4525
+ }
4526
+ let sawUnknownStepKind = false;
4527
+ for (const step of scenario.steps) {
4528
+ if (!isRecord(step)) {
4529
+ sawUnknownStepKind = true;
4530
+ continue;
4531
+ }
4532
+ if (step.kind === "assert") {
4533
+ if (expressionReadsResponse(step.expression)) {
4534
+ return true;
4535
+ }
4536
+ continue;
4537
+ }
4538
+ if (step.kind !== "operation" && step.kind !== "extract" && step.kind !== "guard") {
4539
+ sawUnknownStepKind = true;
4540
+ }
4541
+ }
4542
+ return sawUnknownStepKind;
4543
+ }
4544
+
4545
+ /**
4546
+ * Predicate operators of the current scenario grammar. An operator outside
4547
+ * this set indicates a newer grammar and fails open (counts as evidence),
4548
+ * because a future operator may source the response through fields this walk
4549
+ * does not know about.
4550
+ */
4551
+ const KNOWN_SCENARIO_PREDICATE_OPERATORS: ReadonlySet<string> = new Set([
4552
+ "exists",
4553
+ "not_exists",
4554
+ "non_empty",
4555
+ "is_true",
4556
+ "equals",
4557
+ "not_equals",
4558
+ "contains",
4559
+ "matches",
4560
+ "number_gt",
4561
+ "number_gte",
4562
+ "number_lt",
4563
+ "number_lte",
4564
+ "array_length_eq",
4565
+ "array_length_gte",
4566
+ "array_length_lte",
4567
+ "status_2xx",
4568
+ "type_is",
4569
+ ]);
4570
+
4571
+ function expressionReadsResponse(expression: unknown, negationDepth = 0): boolean {
4572
+ if (!isRecord(expression)) {
4421
4573
  return true;
4422
4574
  }
4423
- return cases.every((healthCase) => isVacuousAssertionFunction(healthCase?.assertions));
4575
+ if (expression.kind === "all" || expression.kind === "any") {
4576
+ return Array.isArray(expression.clauses)
4577
+ ? expression.clauses.some((clause) => expressionReadsResponse(clause, negationDepth))
4578
+ : true;
4579
+ }
4580
+ if (expression.kind === "not") {
4581
+ return expressionReadsResponse(expression.clause, negationDepth + 1);
4582
+ }
4583
+ if (expression.kind === "quantifier") {
4584
+ return true;
4585
+ }
4586
+ if (expression.kind !== "predicate" || typeof expression.operator !== "string") {
4587
+ return true;
4588
+ }
4589
+ const operator = expression.operator;
4590
+ if (!KNOWN_SCENARIO_PREDICATE_OPERATORS.has(operator)) {
4591
+ return true;
4592
+ }
4593
+ const actualReads = operandReadsStepOutput(expression.actual);
4594
+ const expectedReads = operandReadsStepOutput(expression.expected);
4595
+ if (!actualReads && !expectedReads) {
4596
+ return false;
4597
+ }
4598
+ // A predicate comparing a reference against an identical copy of itself
4599
+ // (equals(x, x) and friends) holds or fails identically for every response,
4600
+ // so the reads discriminate nothing.
4601
+ if (
4602
+ actualReads &&
4603
+ expectedReads &&
4604
+ JSON.stringify(expression.actual) === JSON.stringify(expression.expected)
4605
+ ) {
4606
+ return false;
4607
+ }
4608
+ // Presence/type pins on the whole `data` root are satisfied by any
4609
+ // well-formed JSON body — but only in positive polarity. Under an odd
4610
+ // number of `not` wrappers the same pin means "this probe must return no
4611
+ // body / a different shape", which ordinary responses falsify, so it is a
4612
+ // real (if unusual) constraint and stays evidence.
4613
+ const positivePolarity = negationDepth % 2 === 0;
4614
+ const bareEnvelopePin = positivePolarity
4615
+ ? operator === "exists" ||
4616
+ operator === "type_is" ||
4617
+ (operator === "array_length_gte" && expression.expected === 0)
4618
+ : operator === "not_exists";
4619
+ if (bareEnvelopePin && !expectedReads && isBareResponseEnvelope(expression.actual)) {
4620
+ return false;
4621
+ }
4622
+ return true;
4623
+ }
4624
+
4625
+ function operandReadsStepOutput(operand: unknown): boolean {
4626
+ if (!isRecord(operand) || !isRecord(operand.ref)) {
4627
+ return false;
4628
+ }
4629
+ const reference = operand.ref;
4630
+ if (reference.namespace === "attempt" || reference.namespace === "credentials") {
4631
+ return false;
4632
+ }
4633
+ if (reference.namespace !== "steps") {
4634
+ return true;
4635
+ }
4636
+ if (!Array.isArray(reference.path) || reference.path.length === 0) {
4637
+ return true;
4638
+ }
4639
+ const head = reference.path[0];
4640
+ return head !== "status_code" && head !== "request_id" && head !== "kind";
4641
+ }
4642
+
4643
+ function isBareResponseEnvelope(operand: unknown): boolean {
4644
+ if (!isRecord(operand) || !isRecord(operand.ref)) {
4645
+ return false;
4646
+ }
4647
+ const reference = operand.ref;
4648
+ return (
4649
+ reference.namespace === "steps" &&
4650
+ Array.isArray(reference.path) &&
4651
+ reference.path.length === 1 &&
4652
+ reference.path[0] === "data"
4653
+ );
4424
4654
  }
4425
4655
 
4426
4656
  function isVacuousAssertionFunction(assertions: unknown): boolean {
@@ -1,3 +1,14 @@
1
- import type { ResolverContext } from "../types.js";
1
+ import type { ChallengeSolution, ProviderChallenge, ResolverContext } from "../types.js";
2
+ import type { TraceRecorder } from "./trace.js";
2
3
  export declare const RESOLVER_INSTRUMENTATION_METADATA: unique symbol;
4
+ /**
5
+ * Internal solve surface. The instrumentation layer threads its recorder as a
6
+ * third argument so vendor-level spans (`resolver.vendor.*`) attach to the
7
+ * active trace. Every wrapper that re-exposes `solve` must forward the extra
8
+ * arguments; a wrapper typed against the public two-argument `ResolverContext`
9
+ * silently drops the recorder and vendor spans vanish.
10
+ */
11
+ export type ResolverSolveWithRecorder = {
12
+ solve(challenge: ProviderChallenge, signal?: AbortSignal, traceRecorder?: TraceRecorder): Promise<ChallengeSolution>;
13
+ };
3
14
  export declare function createUnsupportedResolverClient(reason?: string): ResolverContext;
@@ -682,8 +682,8 @@ export function bindResolverSignal(resolver, defaultSignal) {
682
682
  if (!defaultSignal)
683
683
  return resolver;
684
684
  const boundResolver = {
685
- solve(challenge, signal = defaultSignal) {
686
- return resolver.solve(challenge, signal);
685
+ solve(challenge, signal = defaultSignal, traceRecorder) {
686
+ return resolver.solve(challenge, signal, traceRecorder);
687
687
  },
688
688
  };
689
689
  if (resolverCaches.has(resolver)) {
@@ -24,7 +24,7 @@ import { getProviderBaseUrl } from "../runtime/provider.js";
24
24
  import { createOcrClientFromEnv } from "../runtime/ocr.js";
25
25
  import { PROXY_AUTH_IP_DENIED_CODE, PROXY_EDGE_AUTH_REJECTED_CODE, PROXY_POOL_EXHAUSTED_CODE, } from "../runtime/proxy-errors.js";
26
26
  import { PROVIDER_TELEMETRY_HEADER, ProxyTelemetryCollector, } from "../runtime/proxy-telemetry.js";
27
- import { createUnsupportedResolverClient } from "../runtime/resolver-shared.js";
27
+ import { createUnsupportedResolverClient, } from "../runtime/resolver-shared.js";
28
28
  import { assertRequiredSecretsPresent, listMissingRequiredSecrets, MISSING_SECRET_CODE, } from "../runtime/secrets.js";
29
29
  import { createProviderRuntimeStateFromEnv, createUnsupportedProviderRuntimeState, } from "../runtime/state.js";
30
30
  import { StealthCookieJar } from "../runtime/stealth-cookies.js";
@@ -291,8 +291,10 @@ function bindResolverSignalWithoutRuntime(resolver, defaultSignal) {
291
291
  if (!defaultSignal)
292
292
  return resolver;
293
293
  return {
294
- solve(challenge, signal = defaultSignal) {
295
- return resolver.solve(challenge, signal);
294
+ solve(challenge, signal = defaultSignal, traceRecorder) {
295
+ // Forward the instrumentation trace recorder so resolver.vendor.* spans
296
+ // survive this wrapper. See ResolverSolveWithRecorder in resolver-shared.ts.
297
+ return resolver.solve(challenge, signal, traceRecorder);
296
298
  },
297
299
  };
298
300
  }
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0-beta.46",
2
+ "version": "2.2.0-beta.47",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",
@@ -1,10 +1,26 @@
1
1
  import { ProviderError } from "../errors.js";
2
- import type { ResolverContext } from "../types.js";
2
+ import type { ChallengeSolution, ProviderChallenge, ResolverContext } from "../types.js";
3
+ import type { TraceRecorder } from "./trace.js";
3
4
 
4
5
  export const RESOLVER_INSTRUMENTATION_METADATA = Symbol.for(
5
6
  "@apifuse/provider-sdk/runtime/resolver-instrumentation-metadata",
6
7
  );
7
8
 
9
+ /**
10
+ * Internal solve surface. The instrumentation layer threads its recorder as a
11
+ * third argument so vendor-level spans (`resolver.vendor.*`) attach to the
12
+ * active trace. Every wrapper that re-exposes `solve` must forward the extra
13
+ * arguments; a wrapper typed against the public two-argument `ResolverContext`
14
+ * silently drops the recorder and vendor spans vanish.
15
+ */
16
+ export type ResolverSolveWithRecorder = {
17
+ solve(
18
+ challenge: ProviderChallenge,
19
+ signal?: AbortSignal,
20
+ traceRecorder?: TraceRecorder,
21
+ ): Promise<ChallengeSolution>;
22
+ };
23
+
8
24
  export function createUnsupportedResolverClient(reason?: string): ResolverContext {
9
25
  return {
10
26
  async solve() {
@@ -41,6 +41,7 @@ import {
41
41
  import {
42
42
  createUnsupportedResolverClient,
43
43
  RESOLVER_INSTRUMENTATION_METADATA,
44
+ type ResolverSolveWithRecorder,
44
45
  } from "./resolver-shared.js";
45
46
  import {
46
47
  APIFUSE__CDP_POOL__URL,
@@ -1022,8 +1023,12 @@ export function bindResolverSignal(
1022
1023
  ): ResolverContext {
1023
1024
  if (!defaultSignal) return resolver;
1024
1025
  const boundResolver: ResolverContext = {
1025
- solve(challenge, signal = defaultSignal) {
1026
- return resolver.solve(challenge, signal);
1026
+ solve(challenge, signal = defaultSignal, traceRecorder?: TraceRecorder) {
1027
+ return (resolver as Partial<ResolverSolveWithRecorder> & ResolverContext).solve(
1028
+ challenge,
1029
+ signal,
1030
+ traceRecorder,
1031
+ );
1027
1032
  },
1028
1033
  };
1029
1034
  if (resolverCaches.has(resolver)) {
@@ -64,7 +64,10 @@ import {
64
64
  type ProxyTelemetryLogPayload,
65
65
  } from "../runtime/proxy-telemetry.js";
66
66
  import type * as ResolverRuntimeModule from "../runtime/resolver.js";
67
- import { createUnsupportedResolverClient } from "../runtime/resolver-shared.js";
67
+ import {
68
+ createUnsupportedResolverClient,
69
+ type ResolverSolveWithRecorder,
70
+ } from "../runtime/resolver-shared.js";
68
71
  import {
69
72
  assertRequiredSecretsPresent,
70
73
  listMissingRequiredSecrets,
@@ -77,7 +80,7 @@ import {
77
80
  import { StealthCookieJar } from "../runtime/stealth-cookies.js";
78
81
  import type * as StealthRuntimeModule from "../runtime/stealth.js";
79
82
  import { createSttClientFromEnv } from "../runtime/stt.js";
80
- import { createTraceContext } from "../runtime/trace.js";
83
+ import { createTraceContext, type TraceRecorder } from "../runtime/trace.js";
81
84
  import { resolveTraceConfigFromEnv } from "../runtime/trace-config.js";
82
85
  import { parseSchema } from "../schema.js";
83
86
  import {
@@ -493,8 +496,14 @@ function bindResolverSignalWithoutRuntime(
493
496
  ): ResolverContext {
494
497
  if (!defaultSignal) return resolver;
495
498
  return {
496
- solve(challenge, signal = defaultSignal) {
497
- return resolver.solve(challenge, signal);
499
+ solve(challenge, signal = defaultSignal, traceRecorder?: TraceRecorder) {
500
+ // Forward the instrumentation trace recorder so resolver.vendor.* spans
501
+ // survive this wrapper. See ResolverSolveWithRecorder in resolver-shared.ts.
502
+ return (resolver as Partial<ResolverSolveWithRecorder> & ResolverContext).solve(
503
+ challenge,
504
+ signal,
505
+ traceRecorder,
506
+ );
498
507
  },
499
508
  };
500
509
  }