@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/AUTHORING.md +107 -0
- package/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/bin/apifuse-check.ts +17 -2
- package/bin/apifuse-submit-check.ts +9 -5
- package/dist/handle-meta.d.ts +36 -1
- package/dist/handle-meta.js +16 -0
- package/dist/handle.d.ts +23 -5
- package/dist/handle.js +54 -8
- package/dist/health-scenario.d.ts +38 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/lint.d.ts +5 -0
- package/dist/lint.js +582 -33
- package/dist/provider.d.ts +1 -1
- package/dist/provider.js +1 -1
- package/dist/runtime/handle.js +9 -1
- package/dist/runtime/ocr.js +5 -0
- package/dist/runtime-boundary-lint.d.ts +137 -0
- package/dist/runtime-boundary-lint.js +590 -0
- package/dist/types.d.ts +29 -0
- package/dist/typescript-module.d.ts +39 -0
- package/dist/typescript-module.js +72 -0
- package/package.json +1 -1
- package/src/handle-meta.ts +51 -1
- package/src/handle.ts +92 -10
- package/src/health-scenario.ts +38 -0
- package/src/index.ts +14 -0
- package/src/lint.ts +668 -41
- package/src/provider.ts +2 -0
- package/src/runtime/handle.ts +9 -1
- package/src/runtime/ocr.ts +5 -0
- package/src/runtime-boundary-lint.ts +707 -0
- package/src/types.ts +29 -0
- package/src/typescript-module.ts +100 -0
package/src/lint.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
|
-
|
|
3
1
|
import type { ZodType } from "zod";
|
|
4
2
|
|
|
5
3
|
import {
|
|
@@ -12,9 +10,11 @@ import {
|
|
|
12
10
|
type HandleFieldMeta,
|
|
13
11
|
type HandleKindDeclaration,
|
|
14
12
|
handleFieldDescription,
|
|
13
|
+
handleFieldNameFor,
|
|
14
|
+
handleHasDirectionalFieldNames,
|
|
15
15
|
handleIssuerList,
|
|
16
|
-
isHandleIssuedBy,
|
|
17
16
|
isHandleFieldMeta,
|
|
17
|
+
isHandleIssuedBy,
|
|
18
18
|
} from "./handle-meta.js";
|
|
19
19
|
import { PROVIDER_ERROR_CATALOG_NAMESPACE } from "./i18n/error-messages.js";
|
|
20
20
|
import {
|
|
@@ -27,19 +27,18 @@ import {
|
|
|
27
27
|
isStealthOwnedHeaderName,
|
|
28
28
|
SDK_OWNED_CHROME_HEADER_PREFIX,
|
|
29
29
|
} from "./runtime/stealth-owned-headers.js";
|
|
30
|
+
import {
|
|
31
|
+
lintRuntimeBoundary,
|
|
32
|
+
scriptKindForSourceFile,
|
|
33
|
+
// This module already has a narrower TEST_SOURCE_FILE_PATTERN of its own,
|
|
34
|
+
// used by the older source rules; it does not cover a plain `tests/`
|
|
35
|
+
// directory. Aliased rather than merged because consolidating the two would
|
|
36
|
+
// silently change what those rules scan.
|
|
37
|
+
TEST_SOURCE_FILE_PATTERN as SHARED_TEST_SOURCE_FILE_PATTERN,
|
|
38
|
+
} from "./runtime-boundary-lint.js";
|
|
30
39
|
import { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_SENSITIVE_META_KEY } from "./schema.js";
|
|
31
40
|
import type { AuthMode, OperationApprovalPolicy, OperationRiskClass } from "./types.js";
|
|
32
|
-
|
|
33
|
-
const requireModule = createRequire(import.meta.url);
|
|
34
|
-
// `typeof import(...)` keeps the type without emitting a static import: the
|
|
35
|
-
// typescript package is a CLI-only dependency and src/lint.ts is production
|
|
36
|
-
// runtime, which the typescript-import-boundary test enforces.
|
|
37
|
-
let typeScriptModule: typeof import("typescript") | undefined;
|
|
38
|
-
|
|
39
|
-
function getTypeScript(): typeof import("typescript") {
|
|
40
|
-
typeScriptModule ??= requireModule("typescript") as typeof import("typescript");
|
|
41
|
-
return typeScriptModule;
|
|
42
|
-
}
|
|
41
|
+
import { getTypeScript } from "./typescript-module.js";
|
|
43
42
|
|
|
44
43
|
type AuthModeLike =
|
|
45
44
|
| "none"
|
|
@@ -202,12 +201,17 @@ type ProviderLintOptions = {
|
|
|
202
201
|
mode?: ProviderLintMode;
|
|
203
202
|
};
|
|
204
203
|
|
|
204
|
+
/**
|
|
205
|
+
* A non-blocking audit line: a finding the author acknowledged with
|
|
206
|
+
* `// @apifuse-allow <rule>: <reason>`, kept visible in `apifuse check` output.
|
|
207
|
+
*/
|
|
205
208
|
export interface ProviderLintInformation {
|
|
206
209
|
rule: string;
|
|
207
210
|
message: string;
|
|
208
211
|
field?: string;
|
|
209
212
|
}
|
|
210
213
|
|
|
214
|
+
/** Diagnostics plus the acknowledged findings that accompany them. */
|
|
211
215
|
export interface ProviderLintResult {
|
|
212
216
|
diagnostics: LintDiagnostic[];
|
|
213
217
|
information: ProviderLintInformation[];
|
|
@@ -1148,14 +1152,17 @@ function isReportedOwnedHeaderName(value: string | undefined): boolean {
|
|
|
1148
1152
|
return name.startsWith(OWNED_CLIENT_HINT_HEADER_PREFIX);
|
|
1149
1153
|
}
|
|
1150
1154
|
|
|
1151
|
-
function collectBrowserVersionLiteralFindings(
|
|
1155
|
+
function collectBrowserVersionLiteralFindings(
|
|
1156
|
+
fileName: string,
|
|
1157
|
+
source: string,
|
|
1158
|
+
): BrowserVersionLiteralFinding[] {
|
|
1152
1159
|
const ts = getTypeScript();
|
|
1153
1160
|
const sourceFile = ts.createSourceFile(
|
|
1154
|
-
|
|
1161
|
+
fileName,
|
|
1155
1162
|
source,
|
|
1156
1163
|
ts.ScriptTarget.Latest,
|
|
1157
1164
|
true,
|
|
1158
|
-
ts
|
|
1165
|
+
scriptKindForSourceFile(ts, fileName),
|
|
1159
1166
|
);
|
|
1160
1167
|
let findings: BrowserVersionLiteralFinding[] = [];
|
|
1161
1168
|
const seen = new Set<string>();
|
|
@@ -1297,7 +1304,7 @@ function browserVersionLiteralMessage(finding: BrowserVersionLiteralFinding): st
|
|
|
1297
1304
|
}
|
|
1298
1305
|
|
|
1299
1306
|
function lintBrowserVersionLiterals(provider: ProviderSourceLike): LintDiagnostic[] {
|
|
1300
|
-
const sources: Array<{ field: string; source: string }> = [];
|
|
1307
|
+
const sources: Array<{ field: string; fileName: string; source: string }> = [];
|
|
1301
1308
|
const sourceFiles = Object.entries(provider.providerSourceFiles ?? {}).filter(
|
|
1302
1309
|
([filePath]) =>
|
|
1303
1310
|
JAVASCRIPT_SOURCE_FILE_PATTERN.test(filePath) &&
|
|
@@ -1306,19 +1313,28 @@ function lintBrowserVersionLiterals(provider: ProviderSourceLike): LintDiagnosti
|
|
|
1306
1313
|
);
|
|
1307
1314
|
if (sourceFiles.length > 0) {
|
|
1308
1315
|
for (const [filePath, source] of sourceFiles) {
|
|
1309
|
-
sources.push({ field: `sourceFiles.${filePath}`, source });
|
|
1316
|
+
sources.push({ field: `sourceFiles.${filePath}`, fileName: filePath, source });
|
|
1310
1317
|
}
|
|
1311
1318
|
} else {
|
|
1312
1319
|
if (provider.authFlowSource)
|
|
1313
|
-
sources.push({
|
|
1320
|
+
sources.push({
|
|
1321
|
+
field: "auth.flow",
|
|
1322
|
+
fileName: "auth-flow.ts",
|
|
1323
|
+
source: provider.authFlowSource,
|
|
1324
|
+
});
|
|
1314
1325
|
for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
|
|
1315
1326
|
const source = getOperationSource(operation);
|
|
1316
|
-
if (source)
|
|
1327
|
+
if (source)
|
|
1328
|
+
sources.push({
|
|
1329
|
+
field: `operations.${operationKey}.handler`,
|
|
1330
|
+
fileName: `${operationKey}.ts`,
|
|
1331
|
+
source,
|
|
1332
|
+
});
|
|
1317
1333
|
}
|
|
1318
1334
|
}
|
|
1319
1335
|
|
|
1320
|
-
return sources.flatMap(({ field, source }) =>
|
|
1321
|
-
collectBrowserVersionLiteralFindings(source).map((finding) => ({
|
|
1336
|
+
return sources.flatMap(({ field, fileName, source }) =>
|
|
1337
|
+
collectBrowserVersionLiteralFindings(fileName, source).map((finding) => ({
|
|
1322
1338
|
rule: "browser-version-literal",
|
|
1323
1339
|
level: "error" as const,
|
|
1324
1340
|
field,
|
|
@@ -1995,6 +2011,7 @@ function readHandleKindDeclarations(value: unknown): HandleKindDeclaration[] {
|
|
|
1995
2011
|
typeof record.name === "string" &&
|
|
1996
2012
|
(record.type === "cursor" || record.type === "draft") &&
|
|
1997
2013
|
typeof record.fieldName === "string" &&
|
|
2014
|
+
(record.outputFieldName === undefined || typeof record.outputFieldName === "string") &&
|
|
1998
2015
|
(record.issuedBy === undefined || isHandleIssuedBy(record.issuedBy))
|
|
1999
2016
|
);
|
|
2000
2017
|
});
|
|
@@ -2040,7 +2057,13 @@ function lintHandleDeclarations(provider: {
|
|
|
2040
2057
|
for (const { side, fields } of positions) {
|
|
2041
2058
|
for (const occurrence of fields) {
|
|
2042
2059
|
const field = `operations.${operationKey}.${occurrence.path}`;
|
|
2043
|
-
const { kind
|
|
2060
|
+
const { kind } = occurrence.meta;
|
|
2061
|
+
// A kind may name one handle differently in each direction (the canon's
|
|
2062
|
+
// `cursor` in, `next_cursor` out). The rule is still exact: each side
|
|
2063
|
+
// must use the key declared for THAT side, so the handle's identity
|
|
2064
|
+
// stays readable from the property key and the round trip has exactly
|
|
2065
|
+
// one edge — read `output`, send back under `input`.
|
|
2066
|
+
const expected = handleFieldNameFor(occurrence.meta, side);
|
|
2044
2067
|
|
|
2045
2068
|
if (!kindByName.has(kind)) {
|
|
2046
2069
|
diagnostics.push({
|
|
@@ -2051,12 +2074,16 @@ function lintHandleDeclarations(provider: {
|
|
|
2051
2074
|
});
|
|
2052
2075
|
}
|
|
2053
2076
|
|
|
2054
|
-
if (occurrence.key !==
|
|
2077
|
+
if (occurrence.key !== expected) {
|
|
2078
|
+
const directional = handleHasDirectionalFieldNames(occurrence.meta);
|
|
2079
|
+
const advice = directional
|
|
2080
|
+
? `Use the kind's ${side} fieldName on every ${side} schema; its ${side === "input" ? "output" : "input"} fieldName is "${handleFieldNameFor(occurrence.meta, side === "input" ? "output" : "input")}".`
|
|
2081
|
+
: "Use the same key in every schema so the LLM sees one name.";
|
|
2055
2082
|
diagnostics.push({
|
|
2056
2083
|
rule: "handle-field-name",
|
|
2057
2084
|
level: "error",
|
|
2058
2085
|
field,
|
|
2059
|
-
message: `${providerLabel} operation "${operationKey}" ${side} declares handle kind "${kind}" under property "${occurrence.key}" but the kind's fieldName is "${
|
|
2086
|
+
message: `${providerLabel} operation "${operationKey}" ${side} declares handle kind "${kind}" under property "${occurrence.key}" but the kind's ${directional ? `${side} ` : ""}fieldName is "${expected}". ${advice}`,
|
|
2060
2087
|
});
|
|
2061
2088
|
}
|
|
2062
2089
|
|
|
@@ -2085,7 +2112,7 @@ function lintHandleDeclarations(provider: {
|
|
|
2085
2112
|
rule: "handle-field-parity",
|
|
2086
2113
|
level: "error",
|
|
2087
2114
|
field,
|
|
2088
|
-
message: `${providerLabel} handle kind "${kind.name}" is never issued: no operation output contains its field "${kind
|
|
2115
|
+
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.`,
|
|
2089
2116
|
});
|
|
2090
2117
|
}
|
|
2091
2118
|
if (!accepted) {
|
|
@@ -2093,7 +2120,7 @@ function lintHandleDeclarations(provider: {
|
|
|
2093
2120
|
rule: "handle-field-parity",
|
|
2094
2121
|
level: "error",
|
|
2095
2122
|
field,
|
|
2096
|
-
message: `${providerLabel} handle kind "${kind.name}" is never accepted: no operation input contains its field "${kind
|
|
2123
|
+
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.`,
|
|
2097
2124
|
});
|
|
2098
2125
|
}
|
|
2099
2126
|
|
|
@@ -2119,7 +2146,7 @@ function lintHandleDeclarations(provider: {
|
|
|
2119
2146
|
rule: "handle-issued-by",
|
|
2120
2147
|
level: "error",
|
|
2121
2148
|
field,
|
|
2122
|
-
message: `${providerLabel} handle kind "${kind.name}" declares issuedBy "${known[0]}", but that operation's output has no "${kind
|
|
2149
|
+
message: `${providerLabel} handle kind "${kind.name}" declares issuedBy "${known[0]}", but that operation's output has no "${handleFieldNameFor(kind, "output")}" handle field.`,
|
|
2123
2150
|
});
|
|
2124
2151
|
} else {
|
|
2125
2152
|
diagnostics.push({
|
|
@@ -2130,7 +2157,7 @@ function lintHandleDeclarations(provider: {
|
|
|
2130
2157
|
.map((operationKey) => `"${operationKey}"`)
|
|
2131
2158
|
.join(
|
|
2132
2159
|
", ",
|
|
2133
|
-
)}, but none of those operations' outputs has a "${kind
|
|
2160
|
+
)}, but none of those operations' outputs has a "${handleFieldNameFor(kind, "output")}" handle field. At least one listed issuer must output it.`,
|
|
2134
2161
|
});
|
|
2135
2162
|
}
|
|
2136
2163
|
}
|
|
@@ -2162,7 +2189,7 @@ const LEGACY_CHOICE_IDENTIFIERS = new Set([
|
|
|
2162
2189
|
* the provider) while code inside `${}` interpolations still does. Falls back
|
|
2163
2190
|
* to a raw-source regex when TypeScript is not installed.
|
|
2164
2191
|
*/
|
|
2165
|
-
function findLegacyChoiceUsage(source: string): string | undefined {
|
|
2192
|
+
function findLegacyChoiceUsage(fileName: string, source: string): string | undefined {
|
|
2166
2193
|
let ts: typeof import("typescript");
|
|
2167
2194
|
try {
|
|
2168
2195
|
ts = getTypeScript();
|
|
@@ -2170,11 +2197,11 @@ function findLegacyChoiceUsage(source: string): string | undefined {
|
|
|
2170
2197
|
return LEGACY_CHOICE_USAGE_PATTERN.exec(source)?.[0];
|
|
2171
2198
|
}
|
|
2172
2199
|
const file = ts.createSourceFile(
|
|
2173
|
-
|
|
2200
|
+
fileName,
|
|
2174
2201
|
source,
|
|
2175
2202
|
ts.ScriptTarget.Latest,
|
|
2176
2203
|
false,
|
|
2177
|
-
ts
|
|
2204
|
+
scriptKindForSourceFile(ts, fileName),
|
|
2178
2205
|
);
|
|
2179
2206
|
let found: string | undefined;
|
|
2180
2207
|
const visit = (node: import("typescript").Node): void => {
|
|
@@ -2199,23 +2226,27 @@ function findLegacyChoiceUsage(source: string): string | undefined {
|
|
|
2199
2226
|
}
|
|
2200
2227
|
|
|
2201
2228
|
function lintLegacyChoiceUsage(provider: ProviderSourceLike): LintDiagnostic[] {
|
|
2202
|
-
const sources: Array<{ field: string; source: string }> = [];
|
|
2229
|
+
const sources: Array<{ field: string; fileName: string; source: string }> = [];
|
|
2203
2230
|
|
|
2204
2231
|
if (provider.authFlowSource) {
|
|
2205
|
-
sources.push({ field: "auth.flow", source: provider.authFlowSource });
|
|
2232
|
+
sources.push({ field: "auth.flow", fileName: "auth-flow.ts", source: provider.authFlowSource });
|
|
2206
2233
|
}
|
|
2207
2234
|
for (const [filePath, source] of Object.entries(provider.providerSourceFiles ?? {})) {
|
|
2208
|
-
sources.push({ field: `sourceFiles.${filePath}`, source });
|
|
2235
|
+
sources.push({ field: `sourceFiles.${filePath}`, fileName: filePath, source });
|
|
2209
2236
|
}
|
|
2210
2237
|
for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
|
|
2211
2238
|
const source = getOperationSource(operation);
|
|
2212
2239
|
if (source) {
|
|
2213
|
-
sources.push({
|
|
2240
|
+
sources.push({
|
|
2241
|
+
field: `operations.${operationKey}.handler`,
|
|
2242
|
+
fileName: `${operationKey}.ts`,
|
|
2243
|
+
source,
|
|
2244
|
+
});
|
|
2214
2245
|
}
|
|
2215
2246
|
}
|
|
2216
2247
|
|
|
2217
|
-
return sources.flatMap(({ field, source }) => {
|
|
2218
|
-
const match = findLegacyChoiceUsage(source);
|
|
2248
|
+
return sources.flatMap(({ field, fileName, source }) => {
|
|
2249
|
+
const match = findLegacyChoiceUsage(fileName, source);
|
|
2219
2250
|
if (!match) return [];
|
|
2220
2251
|
return [
|
|
2221
2252
|
{
|
|
@@ -2228,6 +2259,583 @@ function lintLegacyChoiceUsage(provider: ProviderSourceLike): LintDiagnostic[] {
|
|
|
2228
2259
|
});
|
|
2229
2260
|
}
|
|
2230
2261
|
|
|
2262
|
+
// ---------------------------------------------------------------------------
|
|
2263
|
+
// meta-requires-as-const
|
|
2264
|
+
// ---------------------------------------------------------------------------
|
|
2265
|
+
|
|
2266
|
+
const META_AS_CONST_RULE = "meta-requires-as-const";
|
|
2267
|
+
|
|
2268
|
+
/**
|
|
2269
|
+
* Why this is checked from source and not from the loaded provider.
|
|
2270
|
+
*
|
|
2271
|
+
* `defineProvider` is declared `<const TConfig extends ProviderDeclaration>`, so
|
|
2272
|
+
* an object literal written AT the call keeps its literal types. A `const` type
|
|
2273
|
+
* parameter cannot re-narrow a value that widened somewhere else: when the
|
|
2274
|
+
* declaration — or its `meta` block — lives in its own module and is passed by
|
|
2275
|
+
* name, the call sees that module's declared type. `as const` has no runtime
|
|
2276
|
+
* trace at all (the loaded object is byte-identical with and without it), so
|
|
2277
|
+
* only the syntax carries the narrowing, which is why this rule reads
|
|
2278
|
+
* `providerSourceFiles` rather than the provider value.
|
|
2279
|
+
*
|
|
2280
|
+
* What is actually at stake, measured against this SDK rather than reasoned
|
|
2281
|
+
* about (probe: declare `http` only, then read `ctx.files`):
|
|
2282
|
+
*
|
|
2283
|
+
* | how the extracted `meta` is declared | at the call | ProviderContextOf |
|
|
2284
|
+
* |---------------------------------------------|-------------|-------------------|
|
|
2285
|
+
* | `meta` written inline in `defineProvider({...})` | ok | narrowed |
|
|
2286
|
+
* | `meta = { ... } as const` | ok | narrowed |
|
|
2287
|
+
* | `meta = { ... } satisfies ProviderMeta` | ok | narrowed |
|
|
2288
|
+
* | `meta: ProviderMeta = { ... }` | ok | narrowed |
|
|
2289
|
+
* | `meta = { ... }`, no literal-typed field | ok | narrowed |
|
|
2290
|
+
* | `meta = { ... }` with a literal-typed field | **TS2322** | **degenerate** |
|
|
2291
|
+
* | `decl: ProviderDeclaration = { ... }` | **ok** | **degenerate** |
|
|
2292
|
+
* | `decl: Pick<ProviderDeclaration, …>` | ok | narrowed |
|
|
2293
|
+
* | `decl: ProviderDeclaration & { … }` | **ok** | **degenerate** |
|
|
2294
|
+
* | `defineProvider<ProviderDeclaration>({…})` | **ok** | **degenerate** |
|
|
2295
|
+
*
|
|
2296
|
+
* The last two rows are the rule, and they fail differently.
|
|
2297
|
+
*
|
|
2298
|
+
* LOUD (`warn`). A widened `meta` is one property of the call literal, so it
|
|
2299
|
+
* cannot change `keyof TConfig` by itself — rows 5 and 6 differ only in whether
|
|
2300
|
+
* some widened value is still assignable. Once one is not
|
|
2301
|
+
* (`contract.publicSchemaFieldNames` going from `"normalized"` to `string` is
|
|
2302
|
+
* the one the fleet hits), inference for `TConfig` falls back to the constraint
|
|
2303
|
+
* and the context degenerates — but `tsc` also rejects the call with TS2322, so
|
|
2304
|
+
* nothing ships. It warns because row 5 is one added field away from row 6.
|
|
2305
|
+
*
|
|
2306
|
+
* SILENT (`error`). Annotating the WHOLE declaration
|
|
2307
|
+
* (`const decl: ProviderDeclaration = { ... }; defineProvider(decl)`) is
|
|
2308
|
+
* different in kind. It is perfectly assignable, so the call compiles; but
|
|
2309
|
+
* every capability is an OPTIONAL key of `ProviderDeclaration`, so
|
|
2310
|
+
* `keyof TConfig` is all of them at once. Every branch of `ProviderContext` is
|
|
2311
|
+
* keyed on `"<capability>" extends keyof TConfig`, so the context hands
|
|
2312
|
+
* `ctx.files`, `ctx.browser`, `ctx.state` and the rest to a provider that
|
|
2313
|
+
* declared none of them, and no diagnostic anywhere points at it. `satisfies
|
|
2314
|
+
* ProviderDeclaration` checks the same shape and keeps the key set.
|
|
2315
|
+
*
|
|
2316
|
+
* The rule is scoped to what is mechanically decidable from the provider's own
|
|
2317
|
+
* sources. A `meta` written inline at the call is fine (the `const` type
|
|
2318
|
+
* parameter does the work). A value passed by name is checked at its
|
|
2319
|
+
* declaration, following at most {@link MODULE_HOP_LIMIT} re-export hops. An
|
|
2320
|
+
* indexed access such as `ProviderDeclaration["meta"]` names one member and is
|
|
2321
|
+
* not a key-erasing annotation. A value that cannot be resolved inside the
|
|
2322
|
+
* provider tree (a bare-specifier import, a computed expression, a function
|
|
2323
|
+
* call) is left alone rather than guessed at.
|
|
2324
|
+
*/
|
|
2325
|
+
const MODULE_HOP_LIMIT = 4;
|
|
2326
|
+
|
|
2327
|
+
/**
|
|
2328
|
+
* Narrowing verdict for one `const` declaration.
|
|
2329
|
+
*
|
|
2330
|
+
* `silent` separates the two failure modes measured in the table above. A
|
|
2331
|
+
* declaration typed as `ProviderDeclaration` keeps every optional capability
|
|
2332
|
+
* key, so `keyof TConfig` is the whole union, the context degenerates, and
|
|
2333
|
+
* `tsc` says NOTHING — that is an error. A bare object literal degenerates only
|
|
2334
|
+
* once one of its widened values stops being assignable, and when it does the
|
|
2335
|
+
* call fails to compile — that is a warning.
|
|
2336
|
+
*/
|
|
2337
|
+
type NarrowingVerdict =
|
|
2338
|
+
| { kind: "narrowed" }
|
|
2339
|
+
| { kind: "widened"; reason: string; silent: boolean }
|
|
2340
|
+
| { kind: "unresolved" };
|
|
2341
|
+
|
|
2342
|
+
/**
|
|
2343
|
+
* True for a type node that erases the authored key set to
|
|
2344
|
+
* `keyof ProviderDeclaration` — the declaration type itself, or a mapped type
|
|
2345
|
+
* over it (`Partial<ProviderDeclaration>`, `Omit<ProviderDeclaration, "auth">`).
|
|
2346
|
+
*
|
|
2347
|
+
* An indexed access into it (`ProviderDeclaration["meta"]`) is NOT this: it
|
|
2348
|
+
* names one member's type and leaves the declaration's own key set alone, which
|
|
2349
|
+
* is why `satisfies ProviderDeclaration["meta"]` is a safe and used convention.
|
|
2350
|
+
*/
|
|
2351
|
+
function erasesDeclarationKeys(
|
|
2352
|
+
ts: typeof import("typescript"),
|
|
2353
|
+
node: import("typescript").TypeNode,
|
|
2354
|
+
declarationTypeNames: ReadonlySet<string>,
|
|
2355
|
+
): boolean {
|
|
2356
|
+
if (ts.isParenthesizedTypeNode(node)) {
|
|
2357
|
+
return erasesDeclarationKeys(ts, node.type, declarationTypeNames);
|
|
2358
|
+
}
|
|
2359
|
+
// `ProviderDeclaration & { http: true }` still carries every optional
|
|
2360
|
+
// capability key, so an intersection is erasing when any constituent is.
|
|
2361
|
+
if (ts.isIntersectionTypeNode(node) || ts.isUnionTypeNode(node)) {
|
|
2362
|
+
return node.types.some((member) => erasesDeclarationKeys(ts, member, declarationTypeNames));
|
|
2363
|
+
}
|
|
2364
|
+
if (ts.isIndexedAccessTypeNode(node)) return false;
|
|
2365
|
+
if (ts.isTypeReferenceNode(node)) {
|
|
2366
|
+
const name = ts.isIdentifier(node.typeName) ? node.typeName.text : node.typeName.right.text;
|
|
2367
|
+
if (declarationTypeNames.has(name)) return true;
|
|
2368
|
+
// `Pick<ProviderDeclaration, "id" | "http">` names an explicit key subset,
|
|
2369
|
+
// so the authored keys survive and the context stays narrow — measured.
|
|
2370
|
+
// Every other transformation over the declaration (`Omit` keeps all the
|
|
2371
|
+
// keys but one, `Partial`/`Readonly`/`Required` keep them all) leaves the
|
|
2372
|
+
// optional capability keys in place and is erasing.
|
|
2373
|
+
if (name === "Pick") return false;
|
|
2374
|
+
return (node.typeArguments ?? []).some((argument) =>
|
|
2375
|
+
erasesDeclarationKeys(ts, argument, declarationTypeNames),
|
|
2376
|
+
);
|
|
2377
|
+
}
|
|
2378
|
+
return false;
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
/** True for an `@apifuse/provider-sdk` (or subpath) module specifier. */
|
|
2382
|
+
function isSdkSpecifier(specifier: string): boolean {
|
|
2383
|
+
return specifier === "@apifuse/provider-sdk" || specifier.startsWith("@apifuse/provider-sdk/");
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
/**
|
|
2387
|
+
* Local names that denote `ProviderDeclaration` inside one file.
|
|
2388
|
+
*
|
|
2389
|
+
* The type is usually imported under its own name, but `import type
|
|
2390
|
+
* { ProviderDeclaration as Decl }` and `type Decl = ProviderDeclaration` are
|
|
2391
|
+
* the same type wearing another word, and annotating with either erases the
|
|
2392
|
+
* key set just as thoroughly. Comparing the written name alone would miss both.
|
|
2393
|
+
*/
|
|
2394
|
+
function declarationTypeNamesIn(
|
|
2395
|
+
ts: typeof import("typescript"),
|
|
2396
|
+
file: import("typescript").SourceFile,
|
|
2397
|
+
): Set<string> {
|
|
2398
|
+
const names = new Set<string>(["ProviderDeclaration"]);
|
|
2399
|
+
for (const statement of file.statements) {
|
|
2400
|
+
if (ts.isImportDeclaration(statement)) {
|
|
2401
|
+
const bindings = statement.importClause?.namedBindings;
|
|
2402
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
2403
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
2404
|
+
if (!isSdkSpecifier(statement.moduleSpecifier.text)) continue;
|
|
2405
|
+
for (const element of bindings.elements) {
|
|
2406
|
+
if ((element.propertyName?.text ?? element.name.text) === "ProviderDeclaration") {
|
|
2407
|
+
names.add(element.name.text);
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
continue;
|
|
2411
|
+
}
|
|
2412
|
+
if (
|
|
2413
|
+
ts.isTypeAliasDeclaration(statement) &&
|
|
2414
|
+
ts.isTypeReferenceNode(statement.type) &&
|
|
2415
|
+
ts.isIdentifier(statement.type.typeName) &&
|
|
2416
|
+
names.has(statement.type.typeName.text)
|
|
2417
|
+
) {
|
|
2418
|
+
names.add(statement.name.text);
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
return names;
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
/**
|
|
2425
|
+
* Local names bound to the SDK's `defineProvider` inside one file.
|
|
2426
|
+
*
|
|
2427
|
+
* Restricting the callee match to these keeps an unrelated local helper called
|
|
2428
|
+
* `defineProvider` out of the rule, and picks the SDK call up when it is
|
|
2429
|
+
* imported under another name. A file with no SDK import at all falls back to
|
|
2430
|
+
* the bare name, which is what an in-repo fixture looks like.
|
|
2431
|
+
*/
|
|
2432
|
+
function defineProviderNamesIn(
|
|
2433
|
+
ts: typeof import("typescript"),
|
|
2434
|
+
file: import("typescript").SourceFile,
|
|
2435
|
+
): Set<string> {
|
|
2436
|
+
const names = new Set<string>();
|
|
2437
|
+
for (const statement of file.statements) {
|
|
2438
|
+
if (!ts.isImportDeclaration(statement)) continue;
|
|
2439
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
2440
|
+
if (!isSdkSpecifier(statement.moduleSpecifier.text)) continue;
|
|
2441
|
+
const bindings = statement.importClause?.namedBindings;
|
|
2442
|
+
if (!bindings) continue;
|
|
2443
|
+
if (ts.isNamedImports(bindings)) {
|
|
2444
|
+
for (const element of bindings.elements) {
|
|
2445
|
+
if ((element.propertyName?.text ?? element.name.text) === "defineProvider") {
|
|
2446
|
+
names.add(element.name.text);
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
// `import * as sdk from "@apifuse/provider-sdk"` reaches the call as
|
|
2451
|
+
// `sdk.defineProvider`, and calleeName() already reduces that to the
|
|
2452
|
+
// member name.
|
|
2453
|
+
if (ts.isNamespaceImport(bindings)) names.add("defineProvider");
|
|
2454
|
+
}
|
|
2455
|
+
if (names.size > 0) return names;
|
|
2456
|
+
// No SDK import in this file. The bare name is the right fallback for an
|
|
2457
|
+
// in-repo fixture, but not when the file declares or imports a
|
|
2458
|
+
// `defineProvider` of its own — passing a ProviderDeclaration to an
|
|
2459
|
+
// unrelated local helper is not an SDK call and must not fail a check.
|
|
2460
|
+
for (const statement of file.statements) {
|
|
2461
|
+
if (ts.isFunctionDeclaration(statement) && statement.name?.text === "defineProvider") {
|
|
2462
|
+
return names;
|
|
2463
|
+
}
|
|
2464
|
+
if (ts.isVariableStatement(statement)) {
|
|
2465
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
2466
|
+
if (ts.isIdentifier(declaration.name) && declaration.name.text === "defineProvider") {
|
|
2467
|
+
return names;
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
if (ts.isImportDeclaration(statement)) {
|
|
2472
|
+
const bindings = statement.importClause?.namedBindings;
|
|
2473
|
+
if (bindings && ts.isNamedImports(bindings)) {
|
|
2474
|
+
for (const element of bindings.elements) {
|
|
2475
|
+
if (element.name.text === "defineProvider") return names;
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
return new Set(["defineProvider"]);
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
function normalizeSourcePath(path: string): string {
|
|
2484
|
+
return path.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
/** Resolves `./meta` (or `./meta.js`) from `index.ts` against the source map. */
|
|
2488
|
+
function resolveProviderModule(
|
|
2489
|
+
fromFile: string,
|
|
2490
|
+
specifier: string,
|
|
2491
|
+
files: Record<string, string>,
|
|
2492
|
+
): string | undefined {
|
|
2493
|
+
if (!specifier.startsWith(".")) return undefined;
|
|
2494
|
+
const fromSegments = normalizeSourcePath(fromFile).split("/");
|
|
2495
|
+
fromSegments.pop();
|
|
2496
|
+
for (const segment of specifier.split("/")) {
|
|
2497
|
+
if (segment === "" || segment === ".") continue;
|
|
2498
|
+
if (segment === "..") fromSegments.pop();
|
|
2499
|
+
else fromSegments.push(segment);
|
|
2500
|
+
}
|
|
2501
|
+
const joined = fromSegments.join("/");
|
|
2502
|
+
// An ESM provider writes `./meta.js` and ships `meta.mts`; the extension in
|
|
2503
|
+
// the specifier is the EMITTED one, so every TypeScript counterpart of the
|
|
2504
|
+
// scan set has to be tried, not just `.ts`.
|
|
2505
|
+
const withoutJs = joined.replace(/\.(js|mjs|cjs)$/, "");
|
|
2506
|
+
const candidates = [
|
|
2507
|
+
joined,
|
|
2508
|
+
...["ts", "tsx", "mts", "cts"].flatMap((extension) => [
|
|
2509
|
+
`${withoutJs}.${extension}`,
|
|
2510
|
+
`${withoutJs}/index.${extension}`,
|
|
2511
|
+
]),
|
|
2512
|
+
];
|
|
2513
|
+
const normalized = new Map(Object.keys(files).map((key) => [normalizeSourcePath(key), key]));
|
|
2514
|
+
for (const candidate of candidates) {
|
|
2515
|
+
const match = normalized.get(candidate);
|
|
2516
|
+
if (match !== undefined) return match;
|
|
2517
|
+
}
|
|
2518
|
+
return undefined;
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
/** True for `<expr> as const`. */
|
|
2522
|
+
function isConstAssertion(
|
|
2523
|
+
ts: typeof import("typescript"),
|
|
2524
|
+
node: import("typescript").Node,
|
|
2525
|
+
): node is import("typescript").AsExpression {
|
|
2526
|
+
return (
|
|
2527
|
+
ts.isAsExpression(node) &&
|
|
2528
|
+
ts.isTypeReferenceNode(node.type) &&
|
|
2529
|
+
ts.isIdentifier(node.type.typeName) &&
|
|
2530
|
+
node.type.typeName.text === "const"
|
|
2531
|
+
);
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
/**
|
|
2535
|
+
* Decides whether `name`, as declared in `fileName`, still carries its literal
|
|
2536
|
+
* types when it is read from another module. Follows `import`/`export ... from`
|
|
2537
|
+
* re-exports up to {@link MODULE_HOP_LIMIT} hops.
|
|
2538
|
+
*/
|
|
2539
|
+
function narrowingOfExportedConst(
|
|
2540
|
+
ts: typeof import("typescript"),
|
|
2541
|
+
files: Record<string, string>,
|
|
2542
|
+
fileName: string,
|
|
2543
|
+
name: string,
|
|
2544
|
+
hops = 0,
|
|
2545
|
+
): NarrowingVerdict {
|
|
2546
|
+
if (hops > MODULE_HOP_LIMIT) return { kind: "unresolved" };
|
|
2547
|
+
const source = files[fileName];
|
|
2548
|
+
if (source === undefined) return { kind: "unresolved" };
|
|
2549
|
+
const file = ts.createSourceFile(
|
|
2550
|
+
fileName,
|
|
2551
|
+
source,
|
|
2552
|
+
ts.ScriptTarget.Latest,
|
|
2553
|
+
true,
|
|
2554
|
+
scriptKindForSourceFile(ts, fileName),
|
|
2555
|
+
);
|
|
2556
|
+
const declarationTypeNames = declarationTypeNamesIn(ts, file);
|
|
2557
|
+
|
|
2558
|
+
for (const statement of file.statements) {
|
|
2559
|
+
if (ts.isVariableStatement(statement)) {
|
|
2560
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
2561
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name) continue;
|
|
2562
|
+
// An annotation (`: ProviderMeta`) and a `satisfies` check both give
|
|
2563
|
+
// the literal a contextual type whose literal-typed members stay
|
|
2564
|
+
// literal. Measured: neither degenerates ProviderContextOf — UNLESS
|
|
2565
|
+
// the type named is the declaration itself, which replaces the
|
|
2566
|
+
// authored key set with every optional capability key.
|
|
2567
|
+
if (declaration.type) {
|
|
2568
|
+
if (erasesDeclarationKeys(ts, declaration.type, declarationTypeNames)) {
|
|
2569
|
+
return {
|
|
2570
|
+
kind: "widened",
|
|
2571
|
+
silent: true,
|
|
2572
|
+
reason: `it is annotated \`: ${declaration.type.getText(file)}\`, and every capability is an OPTIONAL key of ProviderDeclaration, so \`keyof TConfig\` becomes all of them`,
|
|
2573
|
+
};
|
|
2574
|
+
}
|
|
2575
|
+
return { kind: "narrowed" };
|
|
2576
|
+
}
|
|
2577
|
+
let initializer = declaration.initializer;
|
|
2578
|
+
while (initializer && ts.isParenthesizedExpression(initializer)) {
|
|
2579
|
+
initializer = initializer.expression;
|
|
2580
|
+
}
|
|
2581
|
+
if (!initializer) return { kind: "unresolved" };
|
|
2582
|
+
if (ts.isSatisfiesExpression(initializer)) return { kind: "narrowed" };
|
|
2583
|
+
if (isConstAssertion(ts, initializer)) return { kind: "narrowed" };
|
|
2584
|
+
if (ts.isAsExpression(initializer)) {
|
|
2585
|
+
if (erasesDeclarationKeys(ts, initializer.type, declarationTypeNames)) {
|
|
2586
|
+
return {
|
|
2587
|
+
kind: "widened",
|
|
2588
|
+
silent: true,
|
|
2589
|
+
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`,
|
|
2590
|
+
};
|
|
2591
|
+
}
|
|
2592
|
+
return { kind: "narrowed" };
|
|
2593
|
+
}
|
|
2594
|
+
if (ts.isObjectLiteralExpression(initializer)) {
|
|
2595
|
+
return {
|
|
2596
|
+
kind: "widened",
|
|
2597
|
+
silent: false,
|
|
2598
|
+
reason:
|
|
2599
|
+
"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",
|
|
2600
|
+
};
|
|
2601
|
+
}
|
|
2602
|
+
return { kind: "unresolved" };
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
// `export { providerMeta } from "./meta";` and
|
|
2607
|
+
// `import { providerMeta } from "./meta"; export { providerMeta };`
|
|
2608
|
+
if (ts.isExportDeclaration(statement) && statement.exportClause) {
|
|
2609
|
+
if (!ts.isNamedExports(statement.exportClause)) continue;
|
|
2610
|
+
for (const element of statement.exportClause.elements) {
|
|
2611
|
+
if (element.name.text !== name) continue;
|
|
2612
|
+
const origin = element.propertyName?.text ?? name;
|
|
2613
|
+
const specifier =
|
|
2614
|
+
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
|
|
2615
|
+
? statement.moduleSpecifier.text
|
|
2616
|
+
: undefined;
|
|
2617
|
+
if (specifier === undefined) {
|
|
2618
|
+
return narrowingOfImportedName(ts, files, file, fileName, origin, hops + 1);
|
|
2619
|
+
}
|
|
2620
|
+
const target = resolveProviderModule(fileName, specifier, files);
|
|
2621
|
+
if (target === undefined) return { kind: "unresolved" };
|
|
2622
|
+
return narrowingOfExportedConst(ts, files, target, origin, hops + 1);
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
return narrowingOfImportedName(ts, files, file, fileName, name, hops + 1);
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
/** Follows `import { name } from "./x"` declared in `file` to its definition. */
|
|
2631
|
+
function narrowingOfImportedName(
|
|
2632
|
+
ts: typeof import("typescript"),
|
|
2633
|
+
files: Record<string, string>,
|
|
2634
|
+
file: import("typescript").SourceFile,
|
|
2635
|
+
fileName: string,
|
|
2636
|
+
name: string,
|
|
2637
|
+
hops: number,
|
|
2638
|
+
): NarrowingVerdict {
|
|
2639
|
+
if (hops > MODULE_HOP_LIMIT) return { kind: "unresolved" };
|
|
2640
|
+
for (const statement of file.statements) {
|
|
2641
|
+
if (!ts.isImportDeclaration(statement)) continue;
|
|
2642
|
+
const bindings = statement.importClause?.namedBindings;
|
|
2643
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
2644
|
+
for (const element of bindings.elements) {
|
|
2645
|
+
if (element.name.text !== name) continue;
|
|
2646
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier)) return { kind: "unresolved" };
|
|
2647
|
+
const target = resolveProviderModule(fileName, statement.moduleSpecifier.text, files);
|
|
2648
|
+
if (target === undefined) return { kind: "unresolved" };
|
|
2649
|
+
return narrowingOfExportedConst(ts, files, target, element.propertyName?.text ?? name, hops);
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
return { kind: "unresolved" };
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
type MetaNarrowingFinding = {
|
|
2656
|
+
/** `meta` when the meta block alone is widened, `declaration` for the whole call argument. */
|
|
2657
|
+
subject: "meta" | "declaration";
|
|
2658
|
+
name: string;
|
|
2659
|
+
reason: string;
|
|
2660
|
+
/** True when the degeneration compiles cleanly; see {@link NarrowingVerdict}. */
|
|
2661
|
+
silent: boolean;
|
|
2662
|
+
};
|
|
2663
|
+
|
|
2664
|
+
/**
|
|
2665
|
+
* Finds every `defineProvider(...)` in one file and reports the arguments that
|
|
2666
|
+
* reach it already widened.
|
|
2667
|
+
*/
|
|
2668
|
+
function findWidenedDefineProviderArguments(
|
|
2669
|
+
ts: typeof import("typescript"),
|
|
2670
|
+
files: Record<string, string>,
|
|
2671
|
+
fileName: string,
|
|
2672
|
+
): MetaNarrowingFinding[] {
|
|
2673
|
+
const source = files[fileName];
|
|
2674
|
+
if (source === undefined) return [];
|
|
2675
|
+
const file = ts.createSourceFile(
|
|
2676
|
+
fileName,
|
|
2677
|
+
source,
|
|
2678
|
+
ts.ScriptTarget.Latest,
|
|
2679
|
+
true,
|
|
2680
|
+
scriptKindForSourceFile(ts, fileName),
|
|
2681
|
+
);
|
|
2682
|
+
const findings: MetaNarrowingFinding[] = [];
|
|
2683
|
+
const declarationTypeNames = declarationTypeNamesIn(ts, file);
|
|
2684
|
+
const calleeNames = defineProviderNamesIn(ts, file);
|
|
2685
|
+
|
|
2686
|
+
const check = (subject: "meta" | "declaration", expression: import("typescript").Expression) => {
|
|
2687
|
+
let current = expression;
|
|
2688
|
+
while (ts.isParenthesizedExpression(current)) current = current.expression;
|
|
2689
|
+
// `satisfies` and `as const` narrow a literal written HERE; neither can
|
|
2690
|
+
// re-narrow a binding that widened at its own declaration, and neither
|
|
2691
|
+
// re-narrows a value spread in from one. Unwrap and keep looking rather
|
|
2692
|
+
// than treating the wrapper as proof.
|
|
2693
|
+
while (ts.isSatisfiesExpression(current) || isConstAssertion(ts, current)) {
|
|
2694
|
+
current = current.expression;
|
|
2695
|
+
while (ts.isParenthesizedExpression(current)) current = current.expression;
|
|
2696
|
+
}
|
|
2697
|
+
// `defineProvider({...} as ProviderDeclaration)` and
|
|
2698
|
+
// `defineProvider(declaration as ProviderDeclaration)` erase the key set
|
|
2699
|
+
// at the call itself, without any widened binding to resolve to.
|
|
2700
|
+
if (ts.isAsExpression(current)) {
|
|
2701
|
+
if (erasesDeclarationKeys(ts, current.type, declarationTypeNames)) {
|
|
2702
|
+
findings.push({
|
|
2703
|
+
subject,
|
|
2704
|
+
name: current.expression.getText(file).slice(0, 60),
|
|
2705
|
+
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`,
|
|
2706
|
+
silent: subject === "declaration",
|
|
2707
|
+
});
|
|
2708
|
+
return;
|
|
2709
|
+
}
|
|
2710
|
+
current = current.expression;
|
|
2711
|
+
while (ts.isParenthesizedExpression(current)) current = current.expression;
|
|
2712
|
+
}
|
|
2713
|
+
if (ts.isObjectLiteralExpression(current)) {
|
|
2714
|
+
// Written at the call: the `const` type parameter narrows it. Only a
|
|
2715
|
+
// spread of an already-widened binding can still lose the literals.
|
|
2716
|
+
for (const property of current.properties) {
|
|
2717
|
+
if (ts.isSpreadAssignment(property) && ts.isIdentifier(property.expression)) {
|
|
2718
|
+
check(subject, property.expression);
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
return;
|
|
2722
|
+
}
|
|
2723
|
+
if (!ts.isIdentifier(current)) return;
|
|
2724
|
+
const verdict = narrowingOfImportedName(ts, files, file, fileName, current.text, 1);
|
|
2725
|
+
const resolved =
|
|
2726
|
+
verdict.kind === "unresolved"
|
|
2727
|
+
? narrowingOfExportedConst(ts, files, fileName, current.text, 1)
|
|
2728
|
+
: verdict;
|
|
2729
|
+
if (resolved.kind === "widened") {
|
|
2730
|
+
// A widened `meta` is only ever the loud row: it is one property of the
|
|
2731
|
+
// call literal, so it cannot change `keyof TConfig` by itself.
|
|
2732
|
+
const silent = resolved.silent && subject === "declaration";
|
|
2733
|
+
findings.push({ subject, name: current.text, reason: resolved.reason, silent });
|
|
2734
|
+
}
|
|
2735
|
+
};
|
|
2736
|
+
|
|
2737
|
+
const visit = (node: import("typescript").Node): void => {
|
|
2738
|
+
const callee = ts.isCallExpression(node) ? calleeName(node) : undefined;
|
|
2739
|
+
if (ts.isCallExpression(node) && callee !== undefined && calleeNames.has(callee)) {
|
|
2740
|
+
// `defineProvider<ProviderDeclaration>({...})` pins TConfig explicitly,
|
|
2741
|
+
// so the `const` type parameter never infers anything and even an inline
|
|
2742
|
+
// literal gets the full optional-key set. The value argument looks
|
|
2743
|
+
// perfect; the type argument is the whole defect.
|
|
2744
|
+
const pinned = (node.typeArguments ?? []).find((argument) =>
|
|
2745
|
+
erasesDeclarationKeys(ts, argument, declarationTypeNames),
|
|
2746
|
+
);
|
|
2747
|
+
if (pinned) {
|
|
2748
|
+
findings.push({
|
|
2749
|
+
subject: "declaration",
|
|
2750
|
+
name: `${callee}<${pinned.getText(file)}>`,
|
|
2751
|
+
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`,
|
|
2752
|
+
silent: true,
|
|
2753
|
+
});
|
|
2754
|
+
ts.forEachChild(node, visit);
|
|
2755
|
+
return;
|
|
2756
|
+
}
|
|
2757
|
+
const argument = node.arguments[0];
|
|
2758
|
+
if (argument) {
|
|
2759
|
+
let current: import("typescript").Expression = argument;
|
|
2760
|
+
while (ts.isParenthesizedExpression(current)) current = current.expression;
|
|
2761
|
+
if (ts.isAsExpression(current)) {
|
|
2762
|
+
// `defineProvider(x as T)`: check() decides whether T erases the
|
|
2763
|
+
// key set, and unwraps to x when it does not.
|
|
2764
|
+
check("declaration", current);
|
|
2765
|
+
ts.forEachChild(node, visit);
|
|
2766
|
+
return;
|
|
2767
|
+
}
|
|
2768
|
+
if (ts.isObjectLiteralExpression(current)) {
|
|
2769
|
+
for (const property of current.properties) {
|
|
2770
|
+
if (ts.isPropertyAssignment(property) && staticPropertyName(property.name) === "meta") {
|
|
2771
|
+
check("meta", property.initializer);
|
|
2772
|
+
} else if (
|
|
2773
|
+
ts.isShorthandPropertyAssignment(property) &&
|
|
2774
|
+
property.name.text === "meta"
|
|
2775
|
+
) {
|
|
2776
|
+
check("meta", property.name);
|
|
2777
|
+
} else if (ts.isSpreadAssignment(property)) {
|
|
2778
|
+
check("declaration", property.expression);
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
} else {
|
|
2782
|
+
check("declaration", argument);
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
ts.forEachChild(node, visit);
|
|
2787
|
+
};
|
|
2788
|
+
visit(file);
|
|
2789
|
+
return findings;
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
/**
|
|
2793
|
+
* `meta-requires-as-const`: the provider declaration, and its `meta` block in
|
|
2794
|
+
* particular, should reach `defineProvider` with its literal types intact.
|
|
2795
|
+
*
|
|
2796
|
+
* See the table above {@link MODULE_HOP_LIMIT} for the measured consequences
|
|
2797
|
+
* and for why this warns rather than errors.
|
|
2798
|
+
*/
|
|
2799
|
+
function lintProviderMetaNarrowing(provider: ProviderSourceLike): LintDiagnostic[] {
|
|
2800
|
+
const files = provider.providerSourceFiles ?? {};
|
|
2801
|
+
if (Object.keys(files).length === 0) return [];
|
|
2802
|
+
let ts: typeof import("typescript");
|
|
2803
|
+
try {
|
|
2804
|
+
ts = getTypeScript();
|
|
2805
|
+
} catch {
|
|
2806
|
+
// No TypeScript in the consumer's tree: the other AST rules degrade the
|
|
2807
|
+
// same way rather than guessing from raw text.
|
|
2808
|
+
return [];
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
2812
|
+
for (const fileName of Object.keys(files)) {
|
|
2813
|
+
const normalized = normalizeSourcePath(fileName);
|
|
2814
|
+
if (!/\.(ts|tsx|mts|cts)$/.test(normalized) || /\.d\.(ts|mts|cts)$/.test(normalized)) continue;
|
|
2815
|
+
// Test sources routinely build throwaway declarations to exercise a
|
|
2816
|
+
// capability shape, and several do it with a `ProviderDeclaration`
|
|
2817
|
+
// annotation on purpose. Reporting those would fail `apifuse check` over
|
|
2818
|
+
// code that is not the provider's runtime contract.
|
|
2819
|
+
if (SHARED_TEST_SOURCE_FILE_PATTERN.test(normalized)) continue;
|
|
2820
|
+
for (const finding of findWidenedDefineProviderArguments(ts, files, fileName)) {
|
|
2821
|
+
const what =
|
|
2822
|
+
finding.subject === "meta"
|
|
2823
|
+
? `The \`meta\` block passed to defineProvider (\`${finding.name}\`)`
|
|
2824
|
+
: `The declaration passed to defineProvider (\`${finding.name}\`)`;
|
|
2825
|
+
const consequence = finding.silent
|
|
2826
|
+
? "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."
|
|
2827
|
+
: '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.';
|
|
2828
|
+
diagnostics.push({
|
|
2829
|
+
rule: META_AS_CONST_RULE,
|
|
2830
|
+
level: finding.silent ? "error" : "warn",
|
|
2831
|
+
field: finding.subject,
|
|
2832
|
+
message: `${what} reaches the call already widened: ${finding.reason}. ${consequence}`,
|
|
2833
|
+
});
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
return diagnostics;
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2231
2839
|
export function lintOperation(op: {
|
|
2232
2840
|
descriptionKey?: string;
|
|
2233
2841
|
whenToUseKeys?: readonly string[];
|
|
@@ -2419,8 +3027,13 @@ export function lintProviderWithInformation(
|
|
|
2419
3027
|
...lintErrorCodeDeclarationConflicts(provider),
|
|
2420
3028
|
...lintErrorMessageLocalization(provider),
|
|
2421
3029
|
...lintLegacyChoiceUsage(provider),
|
|
3030
|
+
...lintProviderMetaNarrowing(provider),
|
|
2422
3031
|
...lintHandleDeclarations(provider),
|
|
2423
3032
|
];
|
|
3033
|
+
// Runtime boundary rules (process.env, node:fs/net/child_process, raw
|
|
3034
|
+
// fetch) also yield information entries for `@apifuse-allow` acknowledgements.
|
|
3035
|
+
const runtimeBoundary = lintRuntimeBoundary(getTypeScript(), provider, getOperationSource);
|
|
3036
|
+
diagnostics.push(...runtimeBoundary.diagnostics);
|
|
2424
3037
|
|
|
2425
3038
|
if (provider.operations) {
|
|
2426
3039
|
const authMode = provider.auth?.mode;
|
|
@@ -2464,7 +3077,10 @@ export function lintProviderWithInformation(
|
|
|
2464
3077
|
}
|
|
2465
3078
|
|
|
2466
3079
|
if (!provider.operations) {
|
|
2467
|
-
return
|
|
3080
|
+
return withInformation(
|
|
3081
|
+
applyPinnedWireFieldPaths(provider, diagnostics),
|
|
3082
|
+
runtimeBoundary.information,
|
|
3083
|
+
);
|
|
2468
3084
|
}
|
|
2469
3085
|
|
|
2470
3086
|
diagnostics.push(
|
|
@@ -2495,5 +3111,16 @@ export function lintProviderWithInformation(
|
|
|
2495
3111
|
),
|
|
2496
3112
|
);
|
|
2497
3113
|
|
|
2498
|
-
return
|
|
3114
|
+
return withInformation(
|
|
3115
|
+
applyPinnedWireFieldPaths(provider, diagnostics),
|
|
3116
|
+
runtimeBoundary.information,
|
|
3117
|
+
);
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
function withInformation(
|
|
3121
|
+
result: ProviderLintResult,
|
|
3122
|
+
information: readonly ProviderLintInformation[],
|
|
3123
|
+
): ProviderLintResult {
|
|
3124
|
+
if (information.length === 0) return result;
|
|
3125
|
+
return { diagnostics: result.diagnostics, information: [...information, ...result.information] };
|
|
2499
3126
|
}
|