@apifuse/provider-sdk 2.2.0-beta.21 → 2.2.0-beta.22

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,13 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.2.0-beta.22
4
+
5
+ - Release candidate for main commit b8bf920b5ca053d1bf43018167fd4eedff01700d.
6
+
7
+ ## Unreleased
8
+
9
+ - Added the `thrown-error-code-undeclared` authoring lint (warning level): `apifuse check` now statically flags literal `ProviderError`/`ValidationError` codes that are neither SDK-registered nor declared in any operation's `docs.errorCodes`, surfacing the runtime `unregistered_provider_error_code` signal at check time. The canonical SDK code→status mapping moved to `SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES` in `error-resolution.ts`, shared by the runtime status resolver and the lint.
10
+
3
11
  ## 2.2.0-beta.21
4
12
 
5
13
  - Release candidate for main commit 00f61024fb18db39711dc5076c4621508baa49f5.
@@ -1,2 +1,4 @@
1
+ import type { ProviderErrorStatus } from "./types.js";
1
2
  export declare const SDK_OWNED_PROVIDER_ERROR_CODES: Set<string>;
2
3
  export declare const SDK_RUNTIME_OWNED_ERROR_CODES: Set<string>;
4
+ export declare const SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES: ReadonlyMap<string, ProviderErrorStatus>;
@@ -88,3 +88,30 @@ export const SDK_RUNTIME_OWNED_ERROR_CODES = new Set([
88
88
  "NOT_FOUND",
89
89
  "not_found",
90
90
  ]);
91
+ // Canonical SDK status mapping for recognized provider-thrown error codes.
92
+ // serve.ts toStatusCode consults this map (after operation-declared overrides
93
+ // for non-SDK-owned codes), and the authoring lint treats these codes as
94
+ // SDK-registered. Add new codes here instead of duplicating literals in
95
+ // either consumer.
96
+ export const SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES = new Map([
97
+ ["AUTH_REQUIRED", 401],
98
+ ["reauth_required", 401],
99
+ // Unprovisioned declared secret: a deployment/config defect, never an
100
+ // upstream failure — explicit 400.
101
+ ["MISSING_SECRET", 400],
102
+ ["NOT_FOUND", 404],
103
+ ["not_found", 404],
104
+ ["NO_DATA", 404],
105
+ ["RATE_LIMITED", 429],
106
+ ["UPSTREAM_RATE_LIMIT", 429],
107
+ ["LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR", 429],
108
+ // Deterministic upstream business refusal (honest-provider-error-
109
+ // contract): the upstream evaluated the request and said no under its
110
+ // own rules — a conflict with upstream state, never a 5xx.
111
+ ["UPSTREAM_REJECTED", 409],
112
+ ["UPSTREAM_ERROR", 502],
113
+ ["BLOCKED", 502],
114
+ ["STT_UNAVAILABLE", 503],
115
+ ["UNSUPPORTED_STT_BACKEND", 503],
116
+ ["STATEFUL_FORWARDING_REPLAY_CACHE_FULL", 503],
117
+ ]);
package/dist/lint.d.ts CHANGED
@@ -65,6 +65,11 @@ export declare function lintProvider(provider: {
65
65
  derivations?: Record<string, string>;
66
66
  handler?: unknown;
67
67
  source?: string;
68
+ docs?: {
69
+ errorCodes?: ReadonlyArray<{
70
+ code: string;
71
+ }>;
72
+ };
68
73
  }>;
69
74
  meta?: {
70
75
  contract?: ProviderContractMetaLike;
package/dist/lint.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { SDK_RUNTIME_OWNED_ERROR_CODES, SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES, } from "./error-resolution.js";
1
2
  import { lintPublicSchemaFieldNames } from "./public-schema-field-lint.js";
2
3
  import { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_SENSITIVE_META_KEY } from "./schema.js";
3
4
  const AUTH_OPERATION_ID_PATTERN = /^(?:auth[-_])?(?:login|exchange|continue|refresh|callback)(?:[-_]|$)/i;
@@ -587,6 +588,281 @@ function lintSelfHostedBrowserPatterns(provider, options) {
587
588
  }
588
589
  return diagnostics;
589
590
  }
591
+ const THROWN_ERROR_CONSTRUCTION_PATTERN = /new\s+(?:ProviderError|ValidationError)\s*\(/g;
592
+ const TEST_SOURCE_FILE_PATTERN = /(?:^|\/)(?:__tests__|__mocks__)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
593
+ /**
594
+ * Skips a string literal starting at `startIndex` (which must point at the
595
+ * opening quote). Returns the index of the closing quote, or -1 when the
596
+ * literal is unterminated. Template literals handle nested `${...}`
597
+ * expressions, including strings inside them.
598
+ */
599
+ function skipStringLiteral(source, startIndex) {
600
+ const quote = source[startIndex];
601
+ for (let index = startIndex + 1; index < source.length; index++) {
602
+ const char = source[index];
603
+ if (char === "\\") {
604
+ index++;
605
+ continue;
606
+ }
607
+ if (quote === "`" && char === "$" && source[index + 1] === "{") {
608
+ index = skipTemplateExpression(source, index + 2);
609
+ if (index < 0) {
610
+ return -1;
611
+ }
612
+ continue;
613
+ }
614
+ if (char === quote) {
615
+ return index;
616
+ }
617
+ if (quote !== "`" && char === "\n") {
618
+ return -1;
619
+ }
620
+ }
621
+ return -1;
622
+ }
623
+ function skipTemplateExpression(source, startIndex) {
624
+ let depth = 1;
625
+ for (let index = startIndex; index < source.length; index++) {
626
+ const char = source[index];
627
+ if (char === '"' || char === "'" || char === "`") {
628
+ index = skipStringLiteral(source, index);
629
+ if (index < 0) {
630
+ return -1;
631
+ }
632
+ continue;
633
+ }
634
+ if (char === "{") {
635
+ depth++;
636
+ }
637
+ else if (char === "}") {
638
+ depth--;
639
+ if (depth === 0) {
640
+ return index;
641
+ }
642
+ }
643
+ }
644
+ return -1;
645
+ }
646
+ /**
647
+ * Extracts the argument text of a call whose opening paren has already been
648
+ * consumed (`startIndex` points just past it). Returns undefined when the
649
+ * call never closes in this source, which the caller treats as "skip
650
+ * silently" — this scanner is conservative by design.
651
+ */
652
+ function extractBalancedCallArguments(source, startIndex) {
653
+ let depth = 1;
654
+ for (let index = startIndex; index < source.length; index++) {
655
+ const char = source[index];
656
+ if (char === '"' || char === "'" || char === "`") {
657
+ index = skipStringLiteral(source, index);
658
+ if (index < 0) {
659
+ return undefined;
660
+ }
661
+ continue;
662
+ }
663
+ if (char === "/" && source[index + 1] === "/") {
664
+ const newline = source.indexOf("\n", index);
665
+ if (newline === -1) {
666
+ return undefined;
667
+ }
668
+ index = newline;
669
+ continue;
670
+ }
671
+ if (char === "/" && source[index + 1] === "*") {
672
+ const end = source.indexOf("*/", index + 2);
673
+ if (end === -1) {
674
+ return undefined;
675
+ }
676
+ index = end + 1;
677
+ continue;
678
+ }
679
+ if (char === "(") {
680
+ depth++;
681
+ }
682
+ else if (char === ")") {
683
+ depth--;
684
+ if (depth === 0) {
685
+ return source.slice(startIndex, index);
686
+ }
687
+ }
688
+ }
689
+ return undefined;
690
+ }
691
+ /**
692
+ * Collects literal string values of top-level `code:` properties inside a
693
+ * ProviderError/ValidationError options object. Only plain `"..."` / `'...'`
694
+ * literals at options-object depth count; computed codes (identifiers,
695
+ * ternaries, template substitutions, concatenations, escapes) are skipped
696
+ * silently so the rule never guesses.
697
+ */
698
+ function collectLiteralErrorCodeValues(args) {
699
+ const codes = [];
700
+ let braceDepth = 0;
701
+ let parenDepth = 0;
702
+ let bracketDepth = 0;
703
+ let previousSignificantChar = "";
704
+ for (let index = 0; index < args.length; index++) {
705
+ const char = args[index] ?? "";
706
+ if (char === '"' || char === "'" || char === "`") {
707
+ const end = skipStringLiteral(args, index);
708
+ if (end < 0) {
709
+ return codes;
710
+ }
711
+ index = end;
712
+ previousSignificantChar = char;
713
+ continue;
714
+ }
715
+ if (char === "/" && args[index + 1] === "/") {
716
+ const newline = args.indexOf("\n", index);
717
+ if (newline === -1) {
718
+ return codes;
719
+ }
720
+ index = newline;
721
+ continue;
722
+ }
723
+ if (char === "/" && args[index + 1] === "*") {
724
+ const end = args.indexOf("*/", index + 2);
725
+ if (end === -1) {
726
+ return codes;
727
+ }
728
+ index = end + 1;
729
+ continue;
730
+ }
731
+ if (/\s/.test(char)) {
732
+ continue;
733
+ }
734
+ if (char === "{") {
735
+ braceDepth++;
736
+ }
737
+ else if (char === "}") {
738
+ braceDepth--;
739
+ }
740
+ else if (char === "(") {
741
+ parenDepth++;
742
+ }
743
+ else if (char === ")") {
744
+ parenDepth--;
745
+ }
746
+ else if (char === "[") {
747
+ bracketDepth++;
748
+ }
749
+ else if (char === "]") {
750
+ bracketDepth--;
751
+ }
752
+ else if (braceDepth === 1 &&
753
+ parenDepth === 0 &&
754
+ bracketDepth === 0 &&
755
+ (previousSignificantChar === "{" || previousSignificantChar === ",") &&
756
+ args.startsWith("code", index)) {
757
+ let cursor = index + "code".length;
758
+ while (cursor < args.length && /\s/.test(args[cursor] ?? "")) {
759
+ cursor++;
760
+ }
761
+ if (args[cursor] === ":") {
762
+ cursor++;
763
+ while (cursor < args.length && /\s/.test(args[cursor] ?? "")) {
764
+ cursor++;
765
+ }
766
+ const quote = args[cursor];
767
+ if (quote === '"' || quote === "'") {
768
+ const end = skipStringLiteral(args, cursor);
769
+ if (end > cursor) {
770
+ const value = args.slice(cursor + 1, end);
771
+ let after = end + 1;
772
+ while (after < args.length && /\s/.test(args[after] ?? "")) {
773
+ after++;
774
+ }
775
+ const nextChar = after < args.length ? (args[after] ?? "") : "";
776
+ if (!value.includes("\\") && (nextChar === "," || nextChar === "}" || nextChar === "")) {
777
+ codes.push(value);
778
+ }
779
+ index = end;
780
+ previousSignificantChar = quote;
781
+ continue;
782
+ }
783
+ return codes;
784
+ }
785
+ }
786
+ }
787
+ previousSignificantChar = char;
788
+ }
789
+ return codes;
790
+ }
791
+ function collectLiteralThrownErrorCodes(source) {
792
+ const codes = [];
793
+ THROWN_ERROR_CONSTRUCTION_PATTERN.lastIndex = 0;
794
+ for (let match = THROWN_ERROR_CONSTRUCTION_PATTERN.exec(source); match; match = THROWN_ERROR_CONSTRUCTION_PATTERN.exec(source)) {
795
+ const argsStart = match.index + match[0].length;
796
+ const args = extractBalancedCallArguments(source, argsStart);
797
+ if (args !== undefined) {
798
+ codes.push(...collectLiteralErrorCodeValues(args));
799
+ }
800
+ THROWN_ERROR_CONSTRUCTION_PATTERN.lastIndex = argsStart;
801
+ }
802
+ return codes;
803
+ }
804
+ /**
805
+ * Static counterpart of the runtime `unregistered_provider_error_code`
806
+ * signal (honest-provider-error-contract Phase 3.5.5): flags
807
+ * `new ProviderError(...)` / `new ValidationError(...)` constructions whose
808
+ * literal `code` is neither SDK-registered (SDK_RUNTIME_OWNED_ERROR_CODES
809
+ * plus the canonical status-mapped codes shared with serve.ts toStatusCode)
810
+ * nor declared in any operation's docs.errorCodes. At runtime such a code
811
+ * serves HTTP 500 and emits the signal; this rule surfaces it at check time.
812
+ *
813
+ * A throw site cannot be attributed to a specific operation statically —
814
+ * providers routinely throw from helpers shared across operations — so this
815
+ * rule matches against the provider-level union of declared codes. That is
816
+ * the honest scope: it will not catch a code declared only on the "wrong"
817
+ * operation, and it never claims per-operation attribution it cannot prove.
818
+ * Only literal string codes are checked; computed/dynamic codes and test
819
+ * sources are skipped silently. Warning level: the long tail of existing
820
+ * providers converges gradually, so this must not fail `apifuse check`.
821
+ */
822
+ function lintUndeclaredThrownErrorCodes(provider) {
823
+ const knownCodes = new Set([
824
+ ...SDK_RUNTIME_OWNED_ERROR_CODES,
825
+ ...SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES.keys(),
826
+ ]);
827
+ for (const operation of Object.values(provider.operations ?? {})) {
828
+ for (const entry of operation.docs?.errorCodes ?? []) {
829
+ if (typeof entry?.code === "string") {
830
+ knownCodes.add(entry.code);
831
+ }
832
+ }
833
+ }
834
+ const sources = [];
835
+ const sourceFiles = Object.entries(provider.providerSourceFiles ?? {}).filter(([filePath]) => !TEST_SOURCE_FILE_PATTERN.test(filePath));
836
+ if (sourceFiles.length > 0) {
837
+ for (const [filePath, source] of sourceFiles) {
838
+ sources.push({ field: `sourceFiles.${filePath}`, source });
839
+ }
840
+ }
841
+ else {
842
+ if (provider.authFlowSource) {
843
+ sources.push({ field: "auth.flow", source: provider.authFlowSource });
844
+ }
845
+ for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
846
+ const source = getOperationSource(operation);
847
+ if (source) {
848
+ sources.push({ field: `operations.${operationKey}.handler`, source });
849
+ }
850
+ }
851
+ }
852
+ const diagnostics = [];
853
+ for (const { field, source } of sources) {
854
+ const undeclaredCodes = new Set(collectLiteralThrownErrorCodes(source).filter((code) => !knownCodes.has(code)));
855
+ for (const code of undeclaredCodes) {
856
+ diagnostics.push({
857
+ rule: "thrown-error-code-undeclared",
858
+ level: "warn",
859
+ field,
860
+ message: `Thrown error code "${code}" (${field}) is neither SDK-registered nor declared in any operation's docs.errorCodes; at runtime it serves HTTP 500 and emits the unregistered_provider_error_code signal. Declare it in the owning operation's docs.errorCodes with status and retryable.`,
861
+ });
862
+ }
863
+ }
864
+ return diagnostics;
865
+ }
590
866
  export function lintOperation(op) {
591
867
  const diagnostics = [];
592
868
  const description = op.description ?? "";
@@ -677,6 +953,7 @@ export function lintProvider(provider, options = {}) {
677
953
  ...lintCredentialWriteUsage(provider),
678
954
  ...lintPlaywrightDirectImports(provider),
679
955
  ...lintSelfHostedBrowserPatterns(provider, options),
956
+ ...lintUndeclaredThrownErrorCodes(provider),
680
957
  ];
681
958
  if (provider.operations) {
682
959
  const authMode = provider.auth?.mode;
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { Hono } from "hono";
4
4
  import { z } from "zod";
5
5
  import { AuthAbortError, createAuthFlowHelpers } from "../auth.js";
6
- import { SDK_OWNED_PROVIDER_ERROR_CODES, SDK_RUNTIME_OWNED_ERROR_CODES, } from "../error-resolution.js";
6
+ import { SDK_OWNED_PROVIDER_ERROR_CODES, SDK_RUNTIME_OWNED_ERROR_CODES, SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES, } from "../error-resolution.js";
7
7
  import { AuthError, isProviderError, isSessionExpiredError, isTransportError, isValidationError, ProviderError, } from "../errors.js";
8
8
  import { loadProviderLocaleCatalogs, localizeAuthTurn, } from "../i18n/catalog.js";
9
9
  import { categoryForStatus, sourceForCategory, isRetryableCategory, PROVIDER_OBSERVABILITY_TAXONOMY_VERSION, } from "../observability.js";
@@ -606,34 +606,13 @@ function toStatusCode(error, declaredErrorCode) {
606
606
  isEmittableErrorStatus(declaredErrorCode?.status)) {
607
607
  return declaredErrorCode.status;
608
608
  }
609
- switch (error.code) {
610
- case "AUTH_REQUIRED":
611
- case "reauth_required":
612
- return 401;
613
- // Unprovisioned declared secret: a deployment/config defect, never an
614
- // upstream failure — explicit 400 (was only reached via fallthrough).
615
- case MISSING_SECRET_CODE:
616
- return 400;
617
- case "NOT_FOUND":
618
- case "not_found":
619
- case "NO_DATA":
620
- return 404;
621
- case "RATE_LIMITED":
622
- case "UPSTREAM_RATE_LIMIT":
623
- case "LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR":
624
- return 429;
625
- // Deterministic upstream business refusal (honest-provider-error-
626
- // contract): the upstream evaluated the request and said no under
627
- // its own rules — a conflict with upstream state, never a 5xx.
628
- case "UPSTREAM_REJECTED":
629
- return 409;
630
- case "UPSTREAM_ERROR":
631
- case "BLOCKED":
632
- return 502;
633
- case "STT_UNAVAILABLE":
634
- case "UNSUPPORTED_STT_BACKEND":
635
- case "STATEFUL_FORWARDING_REPLAY_CACHE_FULL":
636
- return 503;
609
+ // Canonical SDK code → status mapping lives in error-resolution.ts so
610
+ // the authoring lint and this runtime path share one source of truth.
611
+ if (typeof error.code === "string") {
612
+ const mappedStatus = SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES.get(error.code);
613
+ if (mappedStatus !== undefined) {
614
+ return mappedStatus;
615
+ }
637
616
  }
638
617
  if (isTransportError(error)) {
639
618
  return error.code === "transport_timeout" ? 504 : 502;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0-beta.21",
2
+ "version": "2.2.0-beta.22",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",
@@ -1,3 +1,5 @@
1
+ import type { ProviderErrorStatus } from "./types.js";
2
+
1
3
  // This set suppresses the unregistered-provider-error-code signal for codes
2
4
  // intentionally emitted by SDK paths. It is not the complete authority for
3
5
  // runtime error resolution: branded errors and additional canonical SDK codes
@@ -89,3 +91,32 @@ export const SDK_RUNTIME_OWNED_ERROR_CODES = new Set([
89
91
  "NOT_FOUND",
90
92
  "not_found",
91
93
  ]);
94
+
95
+ // Canonical SDK status mapping for recognized provider-thrown error codes.
96
+ // serve.ts toStatusCode consults this map (after operation-declared overrides
97
+ // for non-SDK-owned codes), and the authoring lint treats these codes as
98
+ // SDK-registered. Add new codes here instead of duplicating literals in
99
+ // either consumer.
100
+ export const SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES: ReadonlyMap<string, ProviderErrorStatus> =
101
+ new Map<string, ProviderErrorStatus>([
102
+ ["AUTH_REQUIRED", 401],
103
+ ["reauth_required", 401],
104
+ // Unprovisioned declared secret: a deployment/config defect, never an
105
+ // upstream failure — explicit 400.
106
+ ["MISSING_SECRET", 400],
107
+ ["NOT_FOUND", 404],
108
+ ["not_found", 404],
109
+ ["NO_DATA", 404],
110
+ ["RATE_LIMITED", 429],
111
+ ["UPSTREAM_RATE_LIMIT", 429],
112
+ ["LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR", 429],
113
+ // Deterministic upstream business refusal (honest-provider-error-
114
+ // contract): the upstream evaluated the request and said no under its
115
+ // own rules — a conflict with upstream state, never a 5xx.
116
+ ["UPSTREAM_REJECTED", 409],
117
+ ["UPSTREAM_ERROR", 502],
118
+ ["BLOCKED", 502],
119
+ ["STT_UNAVAILABLE", 503],
120
+ ["UNSUPPORTED_STT_BACKEND", 503],
121
+ ["STATEFUL_FORWARDING_REPLAY_CACHE_FULL", 503],
122
+ ]);
package/src/lint.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import type { ZodType } from "zod";
2
2
 
3
+ import {
4
+ SDK_RUNTIME_OWNED_ERROR_CODES,
5
+ SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES,
6
+ } from "./error-resolution.js";
3
7
  import { lintPublicSchemaFieldNames } from "./public-schema-field-lint.js";
4
8
  import { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_SENSITIVE_META_KEY } from "./schema.js";
5
9
 
@@ -799,6 +803,303 @@ function lintSelfHostedBrowserPatterns(
799
803
  return diagnostics;
800
804
  }
801
805
 
806
+ const THROWN_ERROR_CONSTRUCTION_PATTERN = /new\s+(?:ProviderError|ValidationError)\s*\(/g;
807
+
808
+ const TEST_SOURCE_FILE_PATTERN = /(?:^|\/)(?:__tests__|__mocks__)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
809
+
810
+ /**
811
+ * Skips a string literal starting at `startIndex` (which must point at the
812
+ * opening quote). Returns the index of the closing quote, or -1 when the
813
+ * literal is unterminated. Template literals handle nested `${...}`
814
+ * expressions, including strings inside them.
815
+ */
816
+ function skipStringLiteral(source: string, startIndex: number): number {
817
+ const quote = source[startIndex];
818
+ for (let index = startIndex + 1; index < source.length; index++) {
819
+ const char = source[index];
820
+ if (char === "\\") {
821
+ index++;
822
+ continue;
823
+ }
824
+ if (quote === "`" && char === "$" && source[index + 1] === "{") {
825
+ index = skipTemplateExpression(source, index + 2);
826
+ if (index < 0) {
827
+ return -1;
828
+ }
829
+ continue;
830
+ }
831
+ if (char === quote) {
832
+ return index;
833
+ }
834
+ if (quote !== "`" && char === "\n") {
835
+ return -1;
836
+ }
837
+ }
838
+ return -1;
839
+ }
840
+
841
+ function skipTemplateExpression(source: string, startIndex: number): number {
842
+ let depth = 1;
843
+ for (let index = startIndex; index < source.length; index++) {
844
+ const char = source[index];
845
+ if (char === '"' || char === "'" || char === "`") {
846
+ index = skipStringLiteral(source, index);
847
+ if (index < 0) {
848
+ return -1;
849
+ }
850
+ continue;
851
+ }
852
+ if (char === "{") {
853
+ depth++;
854
+ } else if (char === "}") {
855
+ depth--;
856
+ if (depth === 0) {
857
+ return index;
858
+ }
859
+ }
860
+ }
861
+ return -1;
862
+ }
863
+
864
+ /**
865
+ * Extracts the argument text of a call whose opening paren has already been
866
+ * consumed (`startIndex` points just past it). Returns undefined when the
867
+ * call never closes in this source, which the caller treats as "skip
868
+ * silently" — this scanner is conservative by design.
869
+ */
870
+ function extractBalancedCallArguments(source: string, startIndex: number): string | undefined {
871
+ let depth = 1;
872
+ for (let index = startIndex; index < source.length; index++) {
873
+ const char = source[index];
874
+ if (char === '"' || char === "'" || char === "`") {
875
+ index = skipStringLiteral(source, index);
876
+ if (index < 0) {
877
+ return undefined;
878
+ }
879
+ continue;
880
+ }
881
+ if (char === "/" && source[index + 1] === "/") {
882
+ const newline = source.indexOf("\n", index);
883
+ if (newline === -1) {
884
+ return undefined;
885
+ }
886
+ index = newline;
887
+ continue;
888
+ }
889
+ if (char === "/" && source[index + 1] === "*") {
890
+ const end = source.indexOf("*/", index + 2);
891
+ if (end === -1) {
892
+ return undefined;
893
+ }
894
+ index = end + 1;
895
+ continue;
896
+ }
897
+ if (char === "(") {
898
+ depth++;
899
+ } else if (char === ")") {
900
+ depth--;
901
+ if (depth === 0) {
902
+ return source.slice(startIndex, index);
903
+ }
904
+ }
905
+ }
906
+ return undefined;
907
+ }
908
+
909
+ /**
910
+ * Collects literal string values of top-level `code:` properties inside a
911
+ * ProviderError/ValidationError options object. Only plain `"..."` / `'...'`
912
+ * literals at options-object depth count; computed codes (identifiers,
913
+ * ternaries, template substitutions, concatenations, escapes) are skipped
914
+ * silently so the rule never guesses.
915
+ */
916
+ function collectLiteralErrorCodeValues(args: string): string[] {
917
+ const codes: string[] = [];
918
+ let braceDepth = 0;
919
+ let parenDepth = 0;
920
+ let bracketDepth = 0;
921
+ let previousSignificantChar = "";
922
+ for (let index = 0; index < args.length; index++) {
923
+ const char = args[index] ?? "";
924
+ if (char === '"' || char === "'" || char === "`") {
925
+ const end = skipStringLiteral(args, index);
926
+ if (end < 0) {
927
+ return codes;
928
+ }
929
+ index = end;
930
+ previousSignificantChar = char;
931
+ continue;
932
+ }
933
+ if (char === "/" && args[index + 1] === "/") {
934
+ const newline = args.indexOf("\n", index);
935
+ if (newline === -1) {
936
+ return codes;
937
+ }
938
+ index = newline;
939
+ continue;
940
+ }
941
+ if (char === "/" && args[index + 1] === "*") {
942
+ const end = args.indexOf("*/", index + 2);
943
+ if (end === -1) {
944
+ return codes;
945
+ }
946
+ index = end + 1;
947
+ continue;
948
+ }
949
+ if (/\s/.test(char)) {
950
+ continue;
951
+ }
952
+ if (char === "{") {
953
+ braceDepth++;
954
+ } else if (char === "}") {
955
+ braceDepth--;
956
+ } else if (char === "(") {
957
+ parenDepth++;
958
+ } else if (char === ")") {
959
+ parenDepth--;
960
+ } else if (char === "[") {
961
+ bracketDepth++;
962
+ } else if (char === "]") {
963
+ bracketDepth--;
964
+ } else if (
965
+ braceDepth === 1 &&
966
+ parenDepth === 0 &&
967
+ bracketDepth === 0 &&
968
+ (previousSignificantChar === "{" || previousSignificantChar === ",") &&
969
+ args.startsWith("code", index)
970
+ ) {
971
+ let cursor = index + "code".length;
972
+ while (cursor < args.length && /\s/.test(args[cursor] ?? "")) {
973
+ cursor++;
974
+ }
975
+ if (args[cursor] === ":") {
976
+ cursor++;
977
+ while (cursor < args.length && /\s/.test(args[cursor] ?? "")) {
978
+ cursor++;
979
+ }
980
+ const quote = args[cursor];
981
+ if (quote === '"' || quote === "'") {
982
+ const end = skipStringLiteral(args, cursor);
983
+ if (end > cursor) {
984
+ const value = args.slice(cursor + 1, end);
985
+ let after = end + 1;
986
+ while (after < args.length && /\s/.test(args[after] ?? "")) {
987
+ after++;
988
+ }
989
+ const nextChar = after < args.length ? (args[after] ?? "") : "";
990
+ if (!value.includes("\\") && (nextChar === "," || nextChar === "}" || nextChar === "")) {
991
+ codes.push(value);
992
+ }
993
+ index = end;
994
+ previousSignificantChar = quote;
995
+ continue;
996
+ }
997
+ return codes;
998
+ }
999
+ }
1000
+ }
1001
+ previousSignificantChar = char;
1002
+ }
1003
+ return codes;
1004
+ }
1005
+
1006
+ function collectLiteralThrownErrorCodes(source: string): string[] {
1007
+ const codes: string[] = [];
1008
+ THROWN_ERROR_CONSTRUCTION_PATTERN.lastIndex = 0;
1009
+ for (
1010
+ let match = THROWN_ERROR_CONSTRUCTION_PATTERN.exec(source);
1011
+ match;
1012
+ match = THROWN_ERROR_CONSTRUCTION_PATTERN.exec(source)
1013
+ ) {
1014
+ const argsStart = match.index + match[0].length;
1015
+ const args = extractBalancedCallArguments(source, argsStart);
1016
+ if (args !== undefined) {
1017
+ codes.push(...collectLiteralErrorCodeValues(args));
1018
+ }
1019
+ THROWN_ERROR_CONSTRUCTION_PATTERN.lastIndex = argsStart;
1020
+ }
1021
+ return codes;
1022
+ }
1023
+
1024
+ /**
1025
+ * Static counterpart of the runtime `unregistered_provider_error_code`
1026
+ * signal (honest-provider-error-contract Phase 3.5.5): flags
1027
+ * `new ProviderError(...)` / `new ValidationError(...)` constructions whose
1028
+ * literal `code` is neither SDK-registered (SDK_RUNTIME_OWNED_ERROR_CODES
1029
+ * plus the canonical status-mapped codes shared with serve.ts toStatusCode)
1030
+ * nor declared in any operation's docs.errorCodes. At runtime such a code
1031
+ * serves HTTP 500 and emits the signal; this rule surfaces it at check time.
1032
+ *
1033
+ * A throw site cannot be attributed to a specific operation statically —
1034
+ * providers routinely throw from helpers shared across operations — so this
1035
+ * rule matches against the provider-level union of declared codes. That is
1036
+ * the honest scope: it will not catch a code declared only on the "wrong"
1037
+ * operation, and it never claims per-operation attribution it cannot prove.
1038
+ * Only literal string codes are checked; computed/dynamic codes and test
1039
+ * sources are skipped silently. Warning level: the long tail of existing
1040
+ * providers converges gradually, so this must not fail `apifuse check`.
1041
+ */
1042
+ function lintUndeclaredThrownErrorCodes(provider: {
1043
+ authFlowSource?: string;
1044
+ providerSourceFiles?: Record<string, string>;
1045
+ operations?: Record<
1046
+ string,
1047
+ {
1048
+ handler?: unknown;
1049
+ source?: string;
1050
+ docs?: { errorCodes?: ReadonlyArray<{ code: string }> };
1051
+ }
1052
+ >;
1053
+ }): LintDiagnostic[] {
1054
+ const knownCodes = new Set<string>([
1055
+ ...SDK_RUNTIME_OWNED_ERROR_CODES,
1056
+ ...SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES.keys(),
1057
+ ]);
1058
+ for (const operation of Object.values(provider.operations ?? {})) {
1059
+ for (const entry of operation.docs?.errorCodes ?? []) {
1060
+ if (typeof entry?.code === "string") {
1061
+ knownCodes.add(entry.code);
1062
+ }
1063
+ }
1064
+ }
1065
+
1066
+ const sources: Array<{ field: string; source: string }> = [];
1067
+ const sourceFiles = Object.entries(provider.providerSourceFiles ?? {}).filter(
1068
+ ([filePath]) => !TEST_SOURCE_FILE_PATTERN.test(filePath),
1069
+ );
1070
+ if (sourceFiles.length > 0) {
1071
+ for (const [filePath, source] of sourceFiles) {
1072
+ sources.push({ field: `sourceFiles.${filePath}`, source });
1073
+ }
1074
+ } else {
1075
+ if (provider.authFlowSource) {
1076
+ sources.push({ field: "auth.flow", source: provider.authFlowSource });
1077
+ }
1078
+ for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
1079
+ const source = getOperationSource(operation);
1080
+ if (source) {
1081
+ sources.push({ field: `operations.${operationKey}.handler`, source });
1082
+ }
1083
+ }
1084
+ }
1085
+
1086
+ const diagnostics: LintDiagnostic[] = [];
1087
+ for (const { field, source } of sources) {
1088
+ const undeclaredCodes = new Set(
1089
+ collectLiteralThrownErrorCodes(source).filter((code) => !knownCodes.has(code)),
1090
+ );
1091
+ for (const code of undeclaredCodes) {
1092
+ diagnostics.push({
1093
+ rule: "thrown-error-code-undeclared",
1094
+ level: "warn",
1095
+ field,
1096
+ message: `Thrown error code "${code}" (${field}) is neither SDK-registered nor declared in any operation's docs.errorCodes; at runtime it serves HTTP 500 and emits the unregistered_provider_error_code signal. Declare it in the owning operation's docs.errorCodes with status and retryable.`,
1097
+ });
1098
+ }
1099
+ }
1100
+ return diagnostics;
1101
+ }
1102
+
802
1103
  export function lintOperation(op: {
803
1104
  description?: string;
804
1105
  descriptionKey?: string;
@@ -941,6 +1242,7 @@ export function lintProvider(
941
1242
  derivations?: Record<string, string>;
942
1243
  handler?: unknown;
943
1244
  source?: string;
1245
+ docs?: { errorCodes?: ReadonlyArray<{ code: string }> };
944
1246
  }
945
1247
  >;
946
1248
  meta?: {
@@ -958,6 +1260,7 @@ export function lintProvider(
958
1260
  ...lintCredentialWriteUsage(provider),
959
1261
  ...lintPlaywrightDirectImports(provider),
960
1262
  ...lintSelfHostedBrowserPatterns(provider, options),
1263
+ ...lintUndeclaredThrownErrorCodes(provider),
961
1264
  ];
962
1265
 
963
1266
  if (provider.operations) {
@@ -7,6 +7,7 @@ import { AuthAbortError, createAuthFlowHelpers } from "../auth.js";
7
7
  import {
8
8
  SDK_OWNED_PROVIDER_ERROR_CODES,
9
9
  SDK_RUNTIME_OWNED_ERROR_CODES,
10
+ SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES,
10
11
  } from "../error-resolution.js";
11
12
  import {
12
13
  AuthError,
@@ -948,34 +949,13 @@ function toStatusCode(error: unknown, declaredErrorCode?: OperationErrorCode): P
948
949
  ) {
949
950
  return declaredErrorCode.status;
950
951
  }
951
- switch (error.code) {
952
- case "AUTH_REQUIRED":
953
- case "reauth_required":
954
- return 401;
955
- // Unprovisioned declared secret: a deployment/config defect, never an
956
- // upstream failure — explicit 400 (was only reached via fallthrough).
957
- case MISSING_SECRET_CODE:
958
- return 400;
959
- case "NOT_FOUND":
960
- case "not_found":
961
- case "NO_DATA":
962
- return 404;
963
- case "RATE_LIMITED":
964
- case "UPSTREAM_RATE_LIMIT":
965
- case "LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR":
966
- return 429;
967
- // Deterministic upstream business refusal (honest-provider-error-
968
- // contract): the upstream evaluated the request and said no under
969
- // its own rules — a conflict with upstream state, never a 5xx.
970
- case "UPSTREAM_REJECTED":
971
- return 409;
972
- case "UPSTREAM_ERROR":
973
- case "BLOCKED":
974
- return 502;
975
- case "STT_UNAVAILABLE":
976
- case "UNSUPPORTED_STT_BACKEND":
977
- case "STATEFUL_FORWARDING_REPLAY_CACHE_FULL":
978
- return 503;
952
+ // Canonical SDK code → status mapping lives in error-resolution.ts so
953
+ // the authoring lint and this runtime path share one source of truth.
954
+ if (typeof error.code === "string") {
955
+ const mappedStatus = SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES.get(error.code);
956
+ if (mappedStatus !== undefined) {
957
+ return mappedStatus;
958
+ }
979
959
  }
980
960
  if (isTransportError(error)) {
981
961
  return error.code === "transport_timeout" ? 504 : 502;