@apifuse/provider-sdk 2.2.0-beta.64 → 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/AUTHORING.md +12 -4
- package/CHANGELOG.md +4 -0
- package/bin/apifuse-check.ts +17 -2
- package/dist/lint.js +526 -1
- package/dist/runtime-boundary-lint.d.ts +8 -3
- package/dist/runtime-boundary-lint.js +9 -5
- package/package.json +1 -1
- package/src/lint.ts +587 -1
- package/src/runtime-boundary-lint.ts +9 -5
package/AUTHORING.md
CHANGED
|
@@ -900,8 +900,13 @@ Provider runtime code reaches the outside world through the context only:
|
|
|
900
900
|
`ctx.env.get()` / `ctx.credential` for configuration and secrets, `ctx.files` /
|
|
901
901
|
`ctx.cache` / `ctx.state` for data that must outlive a request. Ambient Node
|
|
902
902
|
and Bun globals bypass `allowedHosts`, proxy policy, retries, redaction, the
|
|
903
|
-
secret presence gate, and telemetry. `apifuse check` reports them
|
|
904
|
-
|
|
903
|
+
secret presence gate, and telemetry. `apifuse check` reports them at **error**
|
|
904
|
+
level — a finding fails `apifuse check` and `apifuse submit-check`, in the
|
|
905
|
+
official and the bounty `standalone` mode alike (the level was `warn` for one
|
|
906
|
+
release as a migration window; the window closed once the fleet's request-path
|
|
907
|
+
hits were fixed or acknowledged — a provider that still carries one sees
|
|
908
|
+
`apifuse check` fail on its next SDK bump and fixes or acknowledges it in that
|
|
909
|
+
same PR):
|
|
905
910
|
|
|
906
911
|
- `process-env-direct-read` — `process.env.X`, `process.env["X"]`, a bare
|
|
907
912
|
`process.env` object use, `Bun.env`. The `APIFUSE__RUNTIME__*` bootstrap
|
|
@@ -931,11 +936,14 @@ Scope is runtime source only: tests, recorded fixtures, `.d.ts`, the root
|
|
|
931
936
|
`dev.ts` / `start.ts` / `deploy.ts` entrypoints, and the `scripts/`, `tools/`,
|
|
932
937
|
`bin/` directories at the provider root are not scanned. Keep fixture
|
|
933
938
|
recorders, smoke scripts, and other operator tooling under `scripts/`, and do
|
|
934
|
-
not import from there in request-path modules.
|
|
939
|
+
not import from there in request-path modules. `apifuse check` also leaves out
|
|
940
|
+
any directory that is itself a git checkout (a linked worktree such as
|
|
941
|
+
`.worktree/<branch>/`, a nested clone, an initialised submodule): its files are
|
|
942
|
+
another revision, checked in that checkout, not this provider's runtime source.
|
|
935
943
|
|
|
936
944
|
Acknowledge a deliberate exception on the finding line or the comment-only
|
|
937
945
|
line above it with `// @apifuse-allow <rule>: <reason>`; the finding then
|
|
938
|
-
appears as an `INFO` audit line instead of
|
|
946
|
+
appears as an `INFO` audit line instead of an error, and the check passes.
|
|
939
947
|
|
|
940
948
|
```ts
|
|
941
949
|
// Before: request-path module reads the ambient environment and the disk
|
package/CHANGELOG.md
CHANGED
package/bin/apifuse-check.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
4
|
-
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
|
|
7
7
|
import { z } from "zod";
|
|
@@ -226,6 +226,21 @@ function isScannableProviderSourceFile(relativePath: string): boolean {
|
|
|
226
226
|
);
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* A directory under the provider root that is itself a git checkout — a linked
|
|
231
|
+
* worktree (`.worktree/<branch>/`, whose `.git` is a file pointing at the main
|
|
232
|
+
* repository), a nested clone (`.git` directory), or an initialised submodule.
|
|
233
|
+
* Its files are another revision of some repository, not this provider's
|
|
234
|
+
* runtime source: an older copy of `upstream/` or `scripts/` there would be
|
|
235
|
+
* linted as if it were request-path code at the root (the tooling-directory
|
|
236
|
+
* exemption is anchored at the provider root), and its `apifuse check` is run
|
|
237
|
+
* in that checkout, not here. Git refuses to track a path named `.git`, so the
|
|
238
|
+
* marker cannot be planted in a committed tree to hide a file from the scan.
|
|
239
|
+
*/
|
|
240
|
+
function isNestedCheckout(directory: string): boolean {
|
|
241
|
+
return existsSync(join(directory, ".git"));
|
|
242
|
+
}
|
|
243
|
+
|
|
229
244
|
function collectProviderSourceFiles(providerRoot: string): Record<string, string> {
|
|
230
245
|
const sources: Record<string, string> = {};
|
|
231
246
|
// `.agents`/`.apifuse` are deliberately not skipped: managed content there
|
|
@@ -236,7 +251,7 @@ function collectProviderSourceFiles(providerRoot: string): Record<string, string
|
|
|
236
251
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
237
252
|
const path = resolve(directory, entry.name);
|
|
238
253
|
if (entry.isDirectory()) {
|
|
239
|
-
if (!skipDirectories.has(entry.name)) {
|
|
254
|
+
if (!skipDirectories.has(entry.name) && !isNestedCheckout(path)) {
|
|
240
255
|
visit(path);
|
|
241
256
|
}
|
|
242
257
|
continue;
|
package/dist/lint.js
CHANGED
|
@@ -4,7 +4,12 @@ import { PROVIDER_ERROR_CATALOG_NAMESPACE } from "./i18n/error-messages.js";
|
|
|
4
4
|
import { getProviderLocaleSegments, isProviderLocaleKey, } from "./i18n/keys.js";
|
|
5
5
|
import { lintPublicSchemaFieldNames } from "./public-schema-field-lint.js";
|
|
6
6
|
import { isStealthOwnedHeaderName, SDK_OWNED_CHROME_HEADER_PREFIX, } from "./runtime/stealth-owned-headers.js";
|
|
7
|
-
import { lintRuntimeBoundary, scriptKindForSourceFile
|
|
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
14
|
import { getTypeScript } from "./typescript-module.js";
|
|
10
15
|
const CREDENTIAL_BEARING_AUTH_MODES = [
|
|
@@ -1783,6 +1788,525 @@ function lintLegacyChoiceUsage(provider) {
|
|
|
1783
1788
|
];
|
|
1784
1789
|
});
|
|
1785
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
|
+
}
|
|
1786
2310
|
export function lintOperation(op) {
|
|
1787
2311
|
const diagnostics = [];
|
|
1788
2312
|
const hasDescriptionKey = typeof op.descriptionKey === "string" && op.descriptionKey.length > 0;
|
|
@@ -1885,6 +2409,7 @@ export function lintProviderWithInformation(provider, options = {}) {
|
|
|
1885
2409
|
...lintErrorCodeDeclarationConflicts(provider),
|
|
1886
2410
|
...lintErrorMessageLocalization(provider),
|
|
1887
2411
|
...lintLegacyChoiceUsage(provider),
|
|
2412
|
+
...lintProviderMetaNarrowing(provider),
|
|
1888
2413
|
...lintHandleDeclarations(provider),
|
|
1889
2414
|
];
|
|
1890
2415
|
// Runtime boundary rules (process.env, node:fs/net/child_process, raw
|
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
* through the SDK context (`ctx.http`, `ctx.stealth`, `ctx.env`,
|
|
4
4
|
* `ctx.credential`, `ctx.browser`), not through ambient Node/Bun globals.
|
|
5
5
|
*
|
|
6
|
-
* Three rules,
|
|
7
|
-
*
|
|
6
|
+
* Three rules, reported at `error` level: a finding fails `apifuse check` and
|
|
7
|
+
* `apifuse submit-check` (official and standalone mode alike). The first
|
|
8
|
+
* release reported them at `warn` as a migration window; the window closed once
|
|
9
|
+
* the fleet's request-path hits were fixed or acknowledged. A deliberate
|
|
10
|
+
* exception is acknowledged in code (see below), never through a hidden
|
|
11
|
+
* allowlist.
|
|
8
12
|
*
|
|
9
13
|
* - `process-env-direct-read`: `process.env.X`, `process.env["X"]`, a bare
|
|
10
14
|
* `process.env` object use, and the `Bun.env` equivalents. Runtime bootstrap
|
|
@@ -57,6 +61,7 @@ export type RuntimeBoundaryRule = (typeof RUNTIME_BOUNDARY_RULES)[number];
|
|
|
57
61
|
*/
|
|
58
62
|
export declare const BOOTSTRAP_ENV_NAME_PREFIX = "APIFUSE__RUNTIME__";
|
|
59
63
|
export declare function isBootstrapEnvName(name: string): boolean;
|
|
64
|
+
export declare const TEST_SOURCE_FILE_PATTERN: RegExp;
|
|
60
65
|
type RuntimeBoundaryFinding = {
|
|
61
66
|
rule: RuntimeBoundaryRule;
|
|
62
67
|
/** 1-based line. */
|
|
@@ -90,7 +95,7 @@ export type RuntimeBoundaryLintInput = {
|
|
|
90
95
|
}>;
|
|
91
96
|
};
|
|
92
97
|
/**
|
|
93
|
-
* Lint the provider runtime boundary. Returns
|
|
98
|
+
* Lint the provider runtime boundary. Returns error-level diagnostics (one per
|
|
94
99
|
* file and rule, listing every line) and information entries for findings
|
|
95
100
|
* acknowledged with `@apifuse-allow`.
|
|
96
101
|
*/
|