@apifuse/provider-sdk 2.2.0-beta.63 → 2.2.0-beta.65

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/dist/lint.js CHANGED
@@ -1,20 +1,17 @@
1
- import { createRequire } from "node:module";
2
1
  import { SDK_CANONICAL_ERROR_CODE_RETRYABILITY, SDK_RUNTIME_OWNED_ERROR_CODES, SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES, } from "./error-resolution.js";
3
- import { APIFUSE_HANDLE_META_KEY, handleFieldDescription, handleIssuerList, isHandleIssuedBy, isHandleFieldMeta, } from "./handle-meta.js";
2
+ import { APIFUSE_HANDLE_META_KEY, handleFieldDescription, handleFieldNameFor, handleHasDirectionalFieldNames, handleIssuerList, isHandleFieldMeta, isHandleIssuedBy, } from "./handle-meta.js";
4
3
  import { PROVIDER_ERROR_CATALOG_NAMESPACE } from "./i18n/error-messages.js";
5
4
  import { getProviderLocaleSegments, isProviderLocaleKey, } from "./i18n/keys.js";
6
5
  import { lintPublicSchemaFieldNames } from "./public-schema-field-lint.js";
7
6
  import { isStealthOwnedHeaderName, SDK_OWNED_CHROME_HEADER_PREFIX, } from "./runtime/stealth-owned-headers.js";
7
+ import { lintRuntimeBoundary, scriptKindForSourceFile,
8
+ // This module already has a narrower TEST_SOURCE_FILE_PATTERN of its own,
9
+ // used by the older source rules; it does not cover a plain `tests/`
10
+ // directory. Aliased rather than merged because consolidating the two would
11
+ // silently change what those rules scan.
12
+ TEST_SOURCE_FILE_PATTERN as SHARED_TEST_SOURCE_FILE_PATTERN, } from "./runtime-boundary-lint.js";
8
13
  import { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_SENSITIVE_META_KEY } from "./schema.js";
9
- const requireModule = createRequire(import.meta.url);
10
- // `typeof import(...)` keeps the type without emitting a static import: the
11
- // typescript package is a CLI-only dependency and src/lint.ts is production
12
- // runtime, which the typescript-import-boundary test enforces.
13
- let typeScriptModule;
14
- function getTypeScript() {
15
- typeScriptModule ??= requireModule("typescript");
16
- return typeScriptModule;
17
- }
14
+ import { getTypeScript } from "./typescript-module.js";
18
15
  const CREDENTIAL_BEARING_AUTH_MODES = [
19
16
  "credentials",
20
17
  "oauth2",
@@ -860,9 +857,9 @@ function isReportedOwnedHeaderName(value) {
860
857
  }
861
858
  return name.startsWith(OWNED_CLIENT_HINT_HEADER_PREFIX);
862
859
  }
863
- function collectBrowserVersionLiteralFindings(source) {
860
+ function collectBrowserVersionLiteralFindings(fileName, source) {
864
861
  const ts = getTypeScript();
865
- const sourceFile = ts.createSourceFile("provider-source.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
862
+ const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, scriptKindForSourceFile(ts, fileName));
866
863
  let findings = [];
867
864
  const seen = new Set();
868
865
  // Owned-header entries only count once the file is known to reach
@@ -980,19 +977,27 @@ function lintBrowserVersionLiterals(provider) {
980
977
  !RECORDED_FIXTURE_SOURCE_FILE_PATTERN.test(filePath));
981
978
  if (sourceFiles.length > 0) {
982
979
  for (const [filePath, source] of sourceFiles) {
983
- sources.push({ field: `sourceFiles.${filePath}`, source });
980
+ sources.push({ field: `sourceFiles.${filePath}`, fileName: filePath, source });
984
981
  }
985
982
  }
986
983
  else {
987
984
  if (provider.authFlowSource)
988
- sources.push({ field: "auth.flow", source: provider.authFlowSource });
985
+ sources.push({
986
+ field: "auth.flow",
987
+ fileName: "auth-flow.ts",
988
+ source: provider.authFlowSource,
989
+ });
989
990
  for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
990
991
  const source = getOperationSource(operation);
991
992
  if (source)
992
- sources.push({ field: `operations.${operationKey}.handler`, source });
993
+ sources.push({
994
+ field: `operations.${operationKey}.handler`,
995
+ fileName: `${operationKey}.ts`,
996
+ source,
997
+ });
993
998
  }
994
999
  }
995
- return sources.flatMap(({ field, source }) => collectBrowserVersionLiteralFindings(source).map((finding) => ({
1000
+ return sources.flatMap(({ field, fileName, source }) => collectBrowserVersionLiteralFindings(fileName, source).map((finding) => ({
996
1001
  rule: "browser-version-literal",
997
1002
  level: "error",
998
1003
  field,
@@ -1565,6 +1570,7 @@ function readHandleKindDeclarations(value) {
1565
1570
  return (typeof record.name === "string" &&
1566
1571
  (record.type === "cursor" || record.type === "draft") &&
1567
1572
  typeof record.fieldName === "string" &&
1573
+ (record.outputFieldName === undefined || typeof record.outputFieldName === "string") &&
1568
1574
  (record.issuedBy === undefined || isHandleIssuedBy(record.issuedBy)));
1569
1575
  });
1570
1576
  }
@@ -1600,7 +1606,13 @@ function lintHandleDeclarations(provider) {
1600
1606
  for (const { side, fields } of positions) {
1601
1607
  for (const occurrence of fields) {
1602
1608
  const field = `operations.${operationKey}.${occurrence.path}`;
1603
- const { kind, fieldName } = occurrence.meta;
1609
+ const { kind } = occurrence.meta;
1610
+ // A kind may name one handle differently in each direction (the canon's
1611
+ // `cursor` in, `next_cursor` out). The rule is still exact: each side
1612
+ // must use the key declared for THAT side, so the handle's identity
1613
+ // stays readable from the property key and the round trip has exactly
1614
+ // one edge — read `output`, send back under `input`.
1615
+ const expected = handleFieldNameFor(occurrence.meta, side);
1604
1616
  if (!kindByName.has(kind)) {
1605
1617
  diagnostics.push({
1606
1618
  rule: "handle-kind-undeclared",
@@ -1609,12 +1621,16 @@ function lintHandleDeclarations(provider) {
1609
1621
  message: `${providerLabel} operation "${operationKey}" ${side} field "${occurrence.path}" is a handle of kind "${kind}", which is not declared in provider.handle. Add the kind to handle: [...] or remove the field.`,
1610
1622
  });
1611
1623
  }
1612
- if (occurrence.key !== fieldName) {
1624
+ if (occurrence.key !== expected) {
1625
+ const directional = handleHasDirectionalFieldNames(occurrence.meta);
1626
+ const advice = directional
1627
+ ? `Use the kind's ${side} fieldName on every ${side} schema; its ${side === "input" ? "output" : "input"} fieldName is "${handleFieldNameFor(occurrence.meta, side === "input" ? "output" : "input")}".`
1628
+ : "Use the same key in every schema so the LLM sees one name.";
1613
1629
  diagnostics.push({
1614
1630
  rule: "handle-field-name",
1615
1631
  level: "error",
1616
1632
  field,
1617
- message: `${providerLabel} operation "${operationKey}" ${side} declares handle kind "${kind}" under property "${occurrence.key}" but the kind's fieldName is "${fieldName}". Use the same key in every schema so the LLM sees one name.`,
1633
+ message: `${providerLabel} operation "${operationKey}" ${side} declares handle kind "${kind}" under property "${occurrence.key}" but the kind's ${directional ? `${side} ` : ""}fieldName is "${expected}". ${advice}`,
1618
1634
  });
1619
1635
  }
1620
1636
  if (side === "input") {
@@ -1641,7 +1657,7 @@ function lintHandleDeclarations(provider) {
1641
1657
  rule: "handle-field-parity",
1642
1658
  level: "error",
1643
1659
  field,
1644
- message: `${providerLabel} handle kind "${kind.name}" is never issued: no operation output contains its field "${kind.fieldName}". Add ${kind.name}.field() to the issuing operation's output.`,
1660
+ message: `${providerLabel} handle kind "${kind.name}" is never issued: no operation output contains its field "${handleFieldNameFor(kind, "output")}". Add ${kind.name}.field() to the issuing operation's output.`,
1645
1661
  });
1646
1662
  }
1647
1663
  if (!accepted) {
@@ -1649,7 +1665,7 @@ function lintHandleDeclarations(provider) {
1649
1665
  rule: "handle-field-parity",
1650
1666
  level: "error",
1651
1667
  field,
1652
- message: `${providerLabel} handle kind "${kind.name}" is never accepted: no operation input contains its field "${kind.fieldName}". Add ${kind.name}.field() to the consuming operation's input.`,
1668
+ message: `${providerLabel} handle kind "${kind.name}" is never accepted: no operation input contains its field "${handleFieldNameFor(kind, "input")}". Add ${kind.name}.field() to the consuming operation's input.`,
1653
1669
  });
1654
1670
  }
1655
1671
  if (kind.issuedBy === undefined)
@@ -1676,7 +1692,7 @@ function lintHandleDeclarations(provider) {
1676
1692
  rule: "handle-issued-by",
1677
1693
  level: "error",
1678
1694
  field,
1679
- message: `${providerLabel} handle kind "${kind.name}" declares issuedBy "${known[0]}", but that operation's output has no "${kind.fieldName}" handle field.`,
1695
+ message: `${providerLabel} handle kind "${kind.name}" declares issuedBy "${known[0]}", but that operation's output has no "${handleFieldNameFor(kind, "output")}" handle field.`,
1680
1696
  });
1681
1697
  }
1682
1698
  else {
@@ -1686,7 +1702,7 @@ function lintHandleDeclarations(provider) {
1686
1702
  field,
1687
1703
  message: `${providerLabel} handle kind "${kind.name}" declares issuedBy ${known
1688
1704
  .map((operationKey) => `"${operationKey}"`)
1689
- .join(", ")}, but none of those operations' outputs has a "${kind.fieldName}" handle field. At least one listed issuer must output it.`,
1705
+ .join(", ")}, but none of those operations' outputs has a "${handleFieldNameFor(kind, "output")}" handle field. At least one listed issuer must output it.`,
1690
1706
  });
1691
1707
  }
1692
1708
  }
@@ -1711,7 +1727,7 @@ const LEGACY_CHOICE_IDENTIFIERS = new Set([
1711
1727
  * the provider) while code inside `${}` interpolations still does. Falls back
1712
1728
  * to a raw-source regex when TypeScript is not installed.
1713
1729
  */
1714
- function findLegacyChoiceUsage(source) {
1730
+ function findLegacyChoiceUsage(fileName, source) {
1715
1731
  let ts;
1716
1732
  try {
1717
1733
  ts = getTypeScript();
@@ -1719,7 +1735,7 @@ function findLegacyChoiceUsage(source) {
1719
1735
  catch {
1720
1736
  return LEGACY_CHOICE_USAGE_PATTERN.exec(source)?.[0];
1721
1737
  }
1722
- const file = ts.createSourceFile("provider.ts", source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TSX);
1738
+ const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false, scriptKindForSourceFile(ts, fileName));
1723
1739
  let found;
1724
1740
  const visit = (node) => {
1725
1741
  if (found)
@@ -1743,19 +1759,23 @@ function findLegacyChoiceUsage(source) {
1743
1759
  function lintLegacyChoiceUsage(provider) {
1744
1760
  const sources = [];
1745
1761
  if (provider.authFlowSource) {
1746
- sources.push({ field: "auth.flow", source: provider.authFlowSource });
1762
+ sources.push({ field: "auth.flow", fileName: "auth-flow.ts", source: provider.authFlowSource });
1747
1763
  }
1748
1764
  for (const [filePath, source] of Object.entries(provider.providerSourceFiles ?? {})) {
1749
- sources.push({ field: `sourceFiles.${filePath}`, source });
1765
+ sources.push({ field: `sourceFiles.${filePath}`, fileName: filePath, source });
1750
1766
  }
1751
1767
  for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
1752
1768
  const source = getOperationSource(operation);
1753
1769
  if (source) {
1754
- sources.push({ field: `operations.${operationKey}.handler`, source });
1770
+ sources.push({
1771
+ field: `operations.${operationKey}.handler`,
1772
+ fileName: `${operationKey}.ts`,
1773
+ source,
1774
+ });
1755
1775
  }
1756
1776
  }
1757
- return sources.flatMap(({ field, source }) => {
1758
- const match = findLegacyChoiceUsage(source);
1777
+ return sources.flatMap(({ field, fileName, source }) => {
1778
+ const match = findLegacyChoiceUsage(fileName, source);
1759
1779
  if (!match)
1760
1780
  return [];
1761
1781
  return [
@@ -1768,6 +1788,525 @@ function lintLegacyChoiceUsage(provider) {
1768
1788
  ];
1769
1789
  });
1770
1790
  }
1791
+ // ---------------------------------------------------------------------------
1792
+ // meta-requires-as-const
1793
+ // ---------------------------------------------------------------------------
1794
+ const META_AS_CONST_RULE = "meta-requires-as-const";
1795
+ /**
1796
+ * Why this is checked from source and not from the loaded provider.
1797
+ *
1798
+ * `defineProvider` is declared `<const TConfig extends ProviderDeclaration>`, so
1799
+ * an object literal written AT the call keeps its literal types. A `const` type
1800
+ * parameter cannot re-narrow a value that widened somewhere else: when the
1801
+ * declaration — or its `meta` block — lives in its own module and is passed by
1802
+ * name, the call sees that module's declared type. `as const` has no runtime
1803
+ * trace at all (the loaded object is byte-identical with and without it), so
1804
+ * only the syntax carries the narrowing, which is why this rule reads
1805
+ * `providerSourceFiles` rather than the provider value.
1806
+ *
1807
+ * What is actually at stake, measured against this SDK rather than reasoned
1808
+ * about (probe: declare `http` only, then read `ctx.files`):
1809
+ *
1810
+ * | how the extracted `meta` is declared | at the call | ProviderContextOf |
1811
+ * |---------------------------------------------|-------------|-------------------|
1812
+ * | `meta` written inline in `defineProvider({...})` | ok | narrowed |
1813
+ * | `meta = { ... } as const` | ok | narrowed |
1814
+ * | `meta = { ... } satisfies ProviderMeta` | ok | narrowed |
1815
+ * | `meta: ProviderMeta = { ... }` | ok | narrowed |
1816
+ * | `meta = { ... }`, no literal-typed field | ok | narrowed |
1817
+ * | `meta = { ... }` with a literal-typed field | **TS2322** | **degenerate** |
1818
+ * | `decl: ProviderDeclaration = { ... }` | **ok** | **degenerate** |
1819
+ * | `decl: Pick<ProviderDeclaration, …>` | ok | narrowed |
1820
+ * | `decl: ProviderDeclaration & { … }` | **ok** | **degenerate** |
1821
+ * | `defineProvider<ProviderDeclaration>({…})` | **ok** | **degenerate** |
1822
+ *
1823
+ * The last two rows are the rule, and they fail differently.
1824
+ *
1825
+ * LOUD (`warn`). A widened `meta` is one property of the call literal, so it
1826
+ * cannot change `keyof TConfig` by itself — rows 5 and 6 differ only in whether
1827
+ * some widened value is still assignable. Once one is not
1828
+ * (`contract.publicSchemaFieldNames` going from `"normalized"` to `string` is
1829
+ * the one the fleet hits), inference for `TConfig` falls back to the constraint
1830
+ * and the context degenerates — but `tsc` also rejects the call with TS2322, so
1831
+ * nothing ships. It warns because row 5 is one added field away from row 6.
1832
+ *
1833
+ * SILENT (`error`). Annotating the WHOLE declaration
1834
+ * (`const decl: ProviderDeclaration = { ... }; defineProvider(decl)`) is
1835
+ * different in kind. It is perfectly assignable, so the call compiles; but
1836
+ * every capability is an OPTIONAL key of `ProviderDeclaration`, so
1837
+ * `keyof TConfig` is all of them at once. Every branch of `ProviderContext` is
1838
+ * keyed on `"<capability>" extends keyof TConfig`, so the context hands
1839
+ * `ctx.files`, `ctx.browser`, `ctx.state` and the rest to a provider that
1840
+ * declared none of them, and no diagnostic anywhere points at it. `satisfies
1841
+ * ProviderDeclaration` checks the same shape and keeps the key set.
1842
+ *
1843
+ * The rule is scoped to what is mechanically decidable from the provider's own
1844
+ * sources. A `meta` written inline at the call is fine (the `const` type
1845
+ * parameter does the work). A value passed by name is checked at its
1846
+ * declaration, following at most {@link MODULE_HOP_LIMIT} re-export hops. An
1847
+ * indexed access such as `ProviderDeclaration["meta"]` names one member and is
1848
+ * not a key-erasing annotation. A value that cannot be resolved inside the
1849
+ * provider tree (a bare-specifier import, a computed expression, a function
1850
+ * call) is left alone rather than guessed at.
1851
+ */
1852
+ const MODULE_HOP_LIMIT = 4;
1853
+ /**
1854
+ * True for a type node that erases the authored key set to
1855
+ * `keyof ProviderDeclaration` — the declaration type itself, or a mapped type
1856
+ * over it (`Partial<ProviderDeclaration>`, `Omit<ProviderDeclaration, "auth">`).
1857
+ *
1858
+ * An indexed access into it (`ProviderDeclaration["meta"]`) is NOT this: it
1859
+ * names one member's type and leaves the declaration's own key set alone, which
1860
+ * is why `satisfies ProviderDeclaration["meta"]` is a safe and used convention.
1861
+ */
1862
+ function erasesDeclarationKeys(ts, node, declarationTypeNames) {
1863
+ if (ts.isParenthesizedTypeNode(node)) {
1864
+ return erasesDeclarationKeys(ts, node.type, declarationTypeNames);
1865
+ }
1866
+ // `ProviderDeclaration & { http: true }` still carries every optional
1867
+ // capability key, so an intersection is erasing when any constituent is.
1868
+ if (ts.isIntersectionTypeNode(node) || ts.isUnionTypeNode(node)) {
1869
+ return node.types.some((member) => erasesDeclarationKeys(ts, member, declarationTypeNames));
1870
+ }
1871
+ if (ts.isIndexedAccessTypeNode(node))
1872
+ return false;
1873
+ if (ts.isTypeReferenceNode(node)) {
1874
+ const name = ts.isIdentifier(node.typeName) ? node.typeName.text : node.typeName.right.text;
1875
+ if (declarationTypeNames.has(name))
1876
+ return true;
1877
+ // `Pick<ProviderDeclaration, "id" | "http">` names an explicit key subset,
1878
+ // so the authored keys survive and the context stays narrow — measured.
1879
+ // Every other transformation over the declaration (`Omit` keeps all the
1880
+ // keys but one, `Partial`/`Readonly`/`Required` keep them all) leaves the
1881
+ // optional capability keys in place and is erasing.
1882
+ if (name === "Pick")
1883
+ return false;
1884
+ return (node.typeArguments ?? []).some((argument) => erasesDeclarationKeys(ts, argument, declarationTypeNames));
1885
+ }
1886
+ return false;
1887
+ }
1888
+ /** True for an `@apifuse/provider-sdk` (or subpath) module specifier. */
1889
+ function isSdkSpecifier(specifier) {
1890
+ return specifier === "@apifuse/provider-sdk" || specifier.startsWith("@apifuse/provider-sdk/");
1891
+ }
1892
+ /**
1893
+ * Local names that denote `ProviderDeclaration` inside one file.
1894
+ *
1895
+ * The type is usually imported under its own name, but `import type
1896
+ * { ProviderDeclaration as Decl }` and `type Decl = ProviderDeclaration` are
1897
+ * the same type wearing another word, and annotating with either erases the
1898
+ * key set just as thoroughly. Comparing the written name alone would miss both.
1899
+ */
1900
+ function declarationTypeNamesIn(ts, file) {
1901
+ const names = new Set(["ProviderDeclaration"]);
1902
+ for (const statement of file.statements) {
1903
+ if (ts.isImportDeclaration(statement)) {
1904
+ const bindings = statement.importClause?.namedBindings;
1905
+ if (!bindings || !ts.isNamedImports(bindings))
1906
+ continue;
1907
+ if (!ts.isStringLiteral(statement.moduleSpecifier))
1908
+ continue;
1909
+ if (!isSdkSpecifier(statement.moduleSpecifier.text))
1910
+ continue;
1911
+ for (const element of bindings.elements) {
1912
+ if ((element.propertyName?.text ?? element.name.text) === "ProviderDeclaration") {
1913
+ names.add(element.name.text);
1914
+ }
1915
+ }
1916
+ continue;
1917
+ }
1918
+ if (ts.isTypeAliasDeclaration(statement) &&
1919
+ ts.isTypeReferenceNode(statement.type) &&
1920
+ ts.isIdentifier(statement.type.typeName) &&
1921
+ names.has(statement.type.typeName.text)) {
1922
+ names.add(statement.name.text);
1923
+ }
1924
+ }
1925
+ return names;
1926
+ }
1927
+ /**
1928
+ * Local names bound to the SDK's `defineProvider` inside one file.
1929
+ *
1930
+ * Restricting the callee match to these keeps an unrelated local helper called
1931
+ * `defineProvider` out of the rule, and picks the SDK call up when it is
1932
+ * imported under another name. A file with no SDK import at all falls back to
1933
+ * the bare name, which is what an in-repo fixture looks like.
1934
+ */
1935
+ function defineProviderNamesIn(ts, file) {
1936
+ const names = new Set();
1937
+ for (const statement of file.statements) {
1938
+ if (!ts.isImportDeclaration(statement))
1939
+ continue;
1940
+ if (!ts.isStringLiteral(statement.moduleSpecifier))
1941
+ continue;
1942
+ if (!isSdkSpecifier(statement.moduleSpecifier.text))
1943
+ continue;
1944
+ const bindings = statement.importClause?.namedBindings;
1945
+ if (!bindings)
1946
+ continue;
1947
+ if (ts.isNamedImports(bindings)) {
1948
+ for (const element of bindings.elements) {
1949
+ if ((element.propertyName?.text ?? element.name.text) === "defineProvider") {
1950
+ names.add(element.name.text);
1951
+ }
1952
+ }
1953
+ }
1954
+ // `import * as sdk from "@apifuse/provider-sdk"` reaches the call as
1955
+ // `sdk.defineProvider`, and calleeName() already reduces that to the
1956
+ // member name.
1957
+ if (ts.isNamespaceImport(bindings))
1958
+ names.add("defineProvider");
1959
+ }
1960
+ if (names.size > 0)
1961
+ return names;
1962
+ // No SDK import in this file. The bare name is the right fallback for an
1963
+ // in-repo fixture, but not when the file declares or imports a
1964
+ // `defineProvider` of its own — passing a ProviderDeclaration to an
1965
+ // unrelated local helper is not an SDK call and must not fail a check.
1966
+ for (const statement of file.statements) {
1967
+ if (ts.isFunctionDeclaration(statement) && statement.name?.text === "defineProvider") {
1968
+ return names;
1969
+ }
1970
+ if (ts.isVariableStatement(statement)) {
1971
+ for (const declaration of statement.declarationList.declarations) {
1972
+ if (ts.isIdentifier(declaration.name) && declaration.name.text === "defineProvider") {
1973
+ return names;
1974
+ }
1975
+ }
1976
+ }
1977
+ if (ts.isImportDeclaration(statement)) {
1978
+ const bindings = statement.importClause?.namedBindings;
1979
+ if (bindings && ts.isNamedImports(bindings)) {
1980
+ for (const element of bindings.elements) {
1981
+ if (element.name.text === "defineProvider")
1982
+ return names;
1983
+ }
1984
+ }
1985
+ }
1986
+ }
1987
+ return new Set(["defineProvider"]);
1988
+ }
1989
+ function normalizeSourcePath(path) {
1990
+ return path.replaceAll("\\", "/").replace(/^\.\//, "");
1991
+ }
1992
+ /** Resolves `./meta` (or `./meta.js`) from `index.ts` against the source map. */
1993
+ function resolveProviderModule(fromFile, specifier, files) {
1994
+ if (!specifier.startsWith("."))
1995
+ return undefined;
1996
+ const fromSegments = normalizeSourcePath(fromFile).split("/");
1997
+ fromSegments.pop();
1998
+ for (const segment of specifier.split("/")) {
1999
+ if (segment === "" || segment === ".")
2000
+ continue;
2001
+ if (segment === "..")
2002
+ fromSegments.pop();
2003
+ else
2004
+ fromSegments.push(segment);
2005
+ }
2006
+ const joined = fromSegments.join("/");
2007
+ // An ESM provider writes `./meta.js` and ships `meta.mts`; the extension in
2008
+ // the specifier is the EMITTED one, so every TypeScript counterpart of the
2009
+ // scan set has to be tried, not just `.ts`.
2010
+ const withoutJs = joined.replace(/\.(js|mjs|cjs)$/, "");
2011
+ const candidates = [
2012
+ joined,
2013
+ ...["ts", "tsx", "mts", "cts"].flatMap((extension) => [
2014
+ `${withoutJs}.${extension}`,
2015
+ `${withoutJs}/index.${extension}`,
2016
+ ]),
2017
+ ];
2018
+ const normalized = new Map(Object.keys(files).map((key) => [normalizeSourcePath(key), key]));
2019
+ for (const candidate of candidates) {
2020
+ const match = normalized.get(candidate);
2021
+ if (match !== undefined)
2022
+ return match;
2023
+ }
2024
+ return undefined;
2025
+ }
2026
+ /** True for `<expr> as const`. */
2027
+ function isConstAssertion(ts, node) {
2028
+ return (ts.isAsExpression(node) &&
2029
+ ts.isTypeReferenceNode(node.type) &&
2030
+ ts.isIdentifier(node.type.typeName) &&
2031
+ node.type.typeName.text === "const");
2032
+ }
2033
+ /**
2034
+ * Decides whether `name`, as declared in `fileName`, still carries its literal
2035
+ * types when it is read from another module. Follows `import`/`export ... from`
2036
+ * re-exports up to {@link MODULE_HOP_LIMIT} hops.
2037
+ */
2038
+ function narrowingOfExportedConst(ts, files, fileName, name, hops = 0) {
2039
+ if (hops > MODULE_HOP_LIMIT)
2040
+ return { kind: "unresolved" };
2041
+ const source = files[fileName];
2042
+ if (source === undefined)
2043
+ return { kind: "unresolved" };
2044
+ const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, scriptKindForSourceFile(ts, fileName));
2045
+ const declarationTypeNames = declarationTypeNamesIn(ts, file);
2046
+ for (const statement of file.statements) {
2047
+ if (ts.isVariableStatement(statement)) {
2048
+ for (const declaration of statement.declarationList.declarations) {
2049
+ if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name)
2050
+ continue;
2051
+ // An annotation (`: ProviderMeta`) and a `satisfies` check both give
2052
+ // the literal a contextual type whose literal-typed members stay
2053
+ // literal. Measured: neither degenerates ProviderContextOf — UNLESS
2054
+ // the type named is the declaration itself, which replaces the
2055
+ // authored key set with every optional capability key.
2056
+ if (declaration.type) {
2057
+ if (erasesDeclarationKeys(ts, declaration.type, declarationTypeNames)) {
2058
+ return {
2059
+ kind: "widened",
2060
+ silent: true,
2061
+ reason: `it is annotated \`: ${declaration.type.getText(file)}\`, and every capability is an OPTIONAL key of ProviderDeclaration, so \`keyof TConfig\` becomes all of them`,
2062
+ };
2063
+ }
2064
+ return { kind: "narrowed" };
2065
+ }
2066
+ let initializer = declaration.initializer;
2067
+ while (initializer && ts.isParenthesizedExpression(initializer)) {
2068
+ initializer = initializer.expression;
2069
+ }
2070
+ if (!initializer)
2071
+ return { kind: "unresolved" };
2072
+ if (ts.isSatisfiesExpression(initializer))
2073
+ return { kind: "narrowed" };
2074
+ if (isConstAssertion(ts, initializer))
2075
+ return { kind: "narrowed" };
2076
+ if (ts.isAsExpression(initializer)) {
2077
+ if (erasesDeclarationKeys(ts, initializer.type, declarationTypeNames)) {
2078
+ return {
2079
+ kind: "widened",
2080
+ silent: true,
2081
+ reason: `it is asserted \`as ${initializer.type.getText(file)}\`, and every capability is an OPTIONAL key of ProviderDeclaration, so \`keyof TConfig\` becomes all of them`,
2082
+ };
2083
+ }
2084
+ return { kind: "narrowed" };
2085
+ }
2086
+ if (ts.isObjectLiteralExpression(initializer)) {
2087
+ return {
2088
+ kind: "widened",
2089
+ silent: false,
2090
+ reason: "it is a bare object literal — no `as const`, no annotation, no `satisfies` — so TypeScript widens every literal in it before `defineProvider` ever sees it",
2091
+ };
2092
+ }
2093
+ return { kind: "unresolved" };
2094
+ }
2095
+ }
2096
+ // `export { providerMeta } from "./meta";` and
2097
+ // `import { providerMeta } from "./meta"; export { providerMeta };`
2098
+ if (ts.isExportDeclaration(statement) && statement.exportClause) {
2099
+ if (!ts.isNamedExports(statement.exportClause))
2100
+ continue;
2101
+ for (const element of statement.exportClause.elements) {
2102
+ if (element.name.text !== name)
2103
+ continue;
2104
+ const origin = element.propertyName?.text ?? name;
2105
+ const specifier = statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
2106
+ ? statement.moduleSpecifier.text
2107
+ : undefined;
2108
+ if (specifier === undefined) {
2109
+ return narrowingOfImportedName(ts, files, file, fileName, origin, hops + 1);
2110
+ }
2111
+ const target = resolveProviderModule(fileName, specifier, files);
2112
+ if (target === undefined)
2113
+ return { kind: "unresolved" };
2114
+ return narrowingOfExportedConst(ts, files, target, origin, hops + 1);
2115
+ }
2116
+ }
2117
+ }
2118
+ return narrowingOfImportedName(ts, files, file, fileName, name, hops + 1);
2119
+ }
2120
+ /** Follows `import { name } from "./x"` declared in `file` to its definition. */
2121
+ function narrowingOfImportedName(ts, files, file, fileName, name, hops) {
2122
+ if (hops > MODULE_HOP_LIMIT)
2123
+ return { kind: "unresolved" };
2124
+ for (const statement of file.statements) {
2125
+ if (!ts.isImportDeclaration(statement))
2126
+ continue;
2127
+ const bindings = statement.importClause?.namedBindings;
2128
+ if (!bindings || !ts.isNamedImports(bindings))
2129
+ continue;
2130
+ for (const element of bindings.elements) {
2131
+ if (element.name.text !== name)
2132
+ continue;
2133
+ if (!ts.isStringLiteral(statement.moduleSpecifier))
2134
+ return { kind: "unresolved" };
2135
+ const target = resolveProviderModule(fileName, statement.moduleSpecifier.text, files);
2136
+ if (target === undefined)
2137
+ return { kind: "unresolved" };
2138
+ return narrowingOfExportedConst(ts, files, target, element.propertyName?.text ?? name, hops);
2139
+ }
2140
+ }
2141
+ return { kind: "unresolved" };
2142
+ }
2143
+ /**
2144
+ * Finds every `defineProvider(...)` in one file and reports the arguments that
2145
+ * reach it already widened.
2146
+ */
2147
+ function findWidenedDefineProviderArguments(ts, files, fileName) {
2148
+ const source = files[fileName];
2149
+ if (source === undefined)
2150
+ return [];
2151
+ const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, scriptKindForSourceFile(ts, fileName));
2152
+ const findings = [];
2153
+ const declarationTypeNames = declarationTypeNamesIn(ts, file);
2154
+ const calleeNames = defineProviderNamesIn(ts, file);
2155
+ const check = (subject, expression) => {
2156
+ let current = expression;
2157
+ while (ts.isParenthesizedExpression(current))
2158
+ current = current.expression;
2159
+ // `satisfies` and `as const` narrow a literal written HERE; neither can
2160
+ // re-narrow a binding that widened at its own declaration, and neither
2161
+ // re-narrows a value spread in from one. Unwrap and keep looking rather
2162
+ // than treating the wrapper as proof.
2163
+ while (ts.isSatisfiesExpression(current) || isConstAssertion(ts, current)) {
2164
+ current = current.expression;
2165
+ while (ts.isParenthesizedExpression(current))
2166
+ current = current.expression;
2167
+ }
2168
+ // `defineProvider({...} as ProviderDeclaration)` and
2169
+ // `defineProvider(declaration as ProviderDeclaration)` erase the key set
2170
+ // at the call itself, without any widened binding to resolve to.
2171
+ if (ts.isAsExpression(current)) {
2172
+ if (erasesDeclarationKeys(ts, current.type, declarationTypeNames)) {
2173
+ findings.push({
2174
+ subject,
2175
+ name: current.expression.getText(file).slice(0, 60),
2176
+ reason: `it is asserted \`as ${current.type.getText(file)}\` at the call, and every capability is an OPTIONAL key of ProviderDeclaration, so \`keyof TConfig\` becomes all of them`,
2177
+ silent: subject === "declaration",
2178
+ });
2179
+ return;
2180
+ }
2181
+ current = current.expression;
2182
+ while (ts.isParenthesizedExpression(current))
2183
+ current = current.expression;
2184
+ }
2185
+ if (ts.isObjectLiteralExpression(current)) {
2186
+ // Written at the call: the `const` type parameter narrows it. Only a
2187
+ // spread of an already-widened binding can still lose the literals.
2188
+ for (const property of current.properties) {
2189
+ if (ts.isSpreadAssignment(property) && ts.isIdentifier(property.expression)) {
2190
+ check(subject, property.expression);
2191
+ }
2192
+ }
2193
+ return;
2194
+ }
2195
+ if (!ts.isIdentifier(current))
2196
+ return;
2197
+ const verdict = narrowingOfImportedName(ts, files, file, fileName, current.text, 1);
2198
+ const resolved = verdict.kind === "unresolved"
2199
+ ? narrowingOfExportedConst(ts, files, fileName, current.text, 1)
2200
+ : verdict;
2201
+ if (resolved.kind === "widened") {
2202
+ // A widened `meta` is only ever the loud row: it is one property of the
2203
+ // call literal, so it cannot change `keyof TConfig` by itself.
2204
+ const silent = resolved.silent && subject === "declaration";
2205
+ findings.push({ subject, name: current.text, reason: resolved.reason, silent });
2206
+ }
2207
+ };
2208
+ const visit = (node) => {
2209
+ const callee = ts.isCallExpression(node) ? calleeName(node) : undefined;
2210
+ if (ts.isCallExpression(node) && callee !== undefined && calleeNames.has(callee)) {
2211
+ // `defineProvider<ProviderDeclaration>({...})` pins TConfig explicitly,
2212
+ // so the `const` type parameter never infers anything and even an inline
2213
+ // literal gets the full optional-key set. The value argument looks
2214
+ // perfect; the type argument is the whole defect.
2215
+ const pinned = (node.typeArguments ?? []).find((argument) => erasesDeclarationKeys(ts, argument, declarationTypeNames));
2216
+ if (pinned) {
2217
+ findings.push({
2218
+ subject: "declaration",
2219
+ name: `${callee}<${pinned.getText(file)}>`,
2220
+ reason: `the call pins its type parameter with \`<${pinned.getText(file)}>\`, so the \`const TConfig\` inference never runs and every capability is an OPTIONAL key of ProviderDeclaration`,
2221
+ silent: true,
2222
+ });
2223
+ ts.forEachChild(node, visit);
2224
+ return;
2225
+ }
2226
+ const argument = node.arguments[0];
2227
+ if (argument) {
2228
+ let current = argument;
2229
+ while (ts.isParenthesizedExpression(current))
2230
+ current = current.expression;
2231
+ if (ts.isAsExpression(current)) {
2232
+ // `defineProvider(x as T)`: check() decides whether T erases the
2233
+ // key set, and unwraps to x when it does not.
2234
+ check("declaration", current);
2235
+ ts.forEachChild(node, visit);
2236
+ return;
2237
+ }
2238
+ if (ts.isObjectLiteralExpression(current)) {
2239
+ for (const property of current.properties) {
2240
+ if (ts.isPropertyAssignment(property) && staticPropertyName(property.name) === "meta") {
2241
+ check("meta", property.initializer);
2242
+ }
2243
+ else if (ts.isShorthandPropertyAssignment(property) &&
2244
+ property.name.text === "meta") {
2245
+ check("meta", property.name);
2246
+ }
2247
+ else if (ts.isSpreadAssignment(property)) {
2248
+ check("declaration", property.expression);
2249
+ }
2250
+ }
2251
+ }
2252
+ else {
2253
+ check("declaration", argument);
2254
+ }
2255
+ }
2256
+ }
2257
+ ts.forEachChild(node, visit);
2258
+ };
2259
+ visit(file);
2260
+ return findings;
2261
+ }
2262
+ /**
2263
+ * `meta-requires-as-const`: the provider declaration, and its `meta` block in
2264
+ * particular, should reach `defineProvider` with its literal types intact.
2265
+ *
2266
+ * See the table above {@link MODULE_HOP_LIMIT} for the measured consequences
2267
+ * and for why this warns rather than errors.
2268
+ */
2269
+ function lintProviderMetaNarrowing(provider) {
2270
+ const files = provider.providerSourceFiles ?? {};
2271
+ if (Object.keys(files).length === 0)
2272
+ return [];
2273
+ let ts;
2274
+ try {
2275
+ ts = getTypeScript();
2276
+ }
2277
+ catch {
2278
+ // No TypeScript in the consumer's tree: the other AST rules degrade the
2279
+ // same way rather than guessing from raw text.
2280
+ return [];
2281
+ }
2282
+ const diagnostics = [];
2283
+ for (const fileName of Object.keys(files)) {
2284
+ const normalized = normalizeSourcePath(fileName);
2285
+ if (!/\.(ts|tsx|mts|cts)$/.test(normalized) || /\.d\.(ts|mts|cts)$/.test(normalized))
2286
+ continue;
2287
+ // Test sources routinely build throwaway declarations to exercise a
2288
+ // capability shape, and several do it with a `ProviderDeclaration`
2289
+ // annotation on purpose. Reporting those would fail `apifuse check` over
2290
+ // code that is not the provider's runtime contract.
2291
+ if (SHARED_TEST_SOURCE_FILE_PATTERN.test(normalized))
2292
+ continue;
2293
+ for (const finding of findWidenedDefineProviderArguments(ts, files, fileName)) {
2294
+ const what = finding.subject === "meta"
2295
+ ? `The \`meta\` block passed to defineProvider (\`${finding.name}\`)`
2296
+ : `The declaration passed to defineProvider (\`${finding.name}\`)`;
2297
+ const consequence = finding.silent
2298
+ ? "ProviderContextOf<typeof buildProvider> is therefore ProviderContext<ProviderDeclaration>: ctx.files, ctx.browser, ctx.state and every other capability type-check in a provider that declared none of them, and nothing anywhere reports it — the call itself still compiles. Replace the annotation with `satisfies ProviderDeclaration`, which checks the same shape and leaves the authored key set intact."
2299
+ : 'That is latent rather than broken today: a widened property of `meta` cannot change `keyof TConfig` on its own. It turns into a failure the moment one widened value stops being assignable to ProviderDeclaration — `contract.publicSchemaFieldNames` going from "normalized" to `string` is the one the fleet hits — and then the call fails with TS2322 and the context degenerates at the same time. Add `as const` to the declaration; `satisfies ProviderMeta`, an explicit annotation, or an `as const` on each literal-union field are equally sound.';
2300
+ diagnostics.push({
2301
+ rule: META_AS_CONST_RULE,
2302
+ level: finding.silent ? "error" : "warn",
2303
+ field: finding.subject,
2304
+ message: `${what} reaches the call already widened: ${finding.reason}. ${consequence}`,
2305
+ });
2306
+ }
2307
+ }
2308
+ return diagnostics;
2309
+ }
1771
2310
  export function lintOperation(op) {
1772
2311
  const diagnostics = [];
1773
2312
  const hasDescriptionKey = typeof op.descriptionKey === "string" && op.descriptionKey.length > 0;
@@ -1870,8 +2409,13 @@ export function lintProviderWithInformation(provider, options = {}) {
1870
2409
  ...lintErrorCodeDeclarationConflicts(provider),
1871
2410
  ...lintErrorMessageLocalization(provider),
1872
2411
  ...lintLegacyChoiceUsage(provider),
2412
+ ...lintProviderMetaNarrowing(provider),
1873
2413
  ...lintHandleDeclarations(provider),
1874
2414
  ];
2415
+ // Runtime boundary rules (process.env, node:fs/net/child_process, raw
2416
+ // fetch) also yield information entries for `@apifuse-allow` acknowledgements.
2417
+ const runtimeBoundary = lintRuntimeBoundary(getTypeScript(), provider, getOperationSource);
2418
+ diagnostics.push(...runtimeBoundary.diagnostics);
1875
2419
  if (provider.operations) {
1876
2420
  const authMode = provider.auth?.mode;
1877
2421
  for (const [operationKey, operation] of Object.entries(provider.operations)) {
@@ -1911,7 +2455,7 @@ export function lintProviderWithInformation(provider, options = {}) {
1911
2455
  }
1912
2456
  }
1913
2457
  if (!provider.operations) {
1914
- return applyPinnedWireFieldPaths(provider, diagnostics);
2458
+ return withInformation(applyPinnedWireFieldPaths(provider, diagnostics), runtimeBoundary.information);
1915
2459
  }
1916
2460
  diagnostics.push(...Object.entries(provider.operations).flatMap(([operationKey, operation]) => [
1917
2461
  ...lintOperation({
@@ -1930,5 +2474,10 @@ export function lintProviderWithInformation(provider, options = {}) {
1930
2474
  : `operations.${operationKey}`,
1931
2475
  message: `[${operationKey}] ${diagnostic.message}`,
1932
2476
  }))));
1933
- return applyPinnedWireFieldPaths(provider, diagnostics);
2477
+ return withInformation(applyPinnedWireFieldPaths(provider, diagnostics), runtimeBoundary.information);
2478
+ }
2479
+ function withInformation(result, information) {
2480
+ if (information.length === 0)
2481
+ return result;
2482
+ return { diagnostics: result.diagnostics, information: [...information, ...result.information] };
1934
2483
  }