@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.
@@ -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, all reported at `warn` level in this first release so the
7
- * fleet can migrate before the level is raised:
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
@@ -65,7 +69,7 @@ export function isBootstrapEnvName(name) {
65
69
  const BOOTSTRAP_ENTRYPOINT_FILE_PATTERN = /^(?:dev|start|deploy)\.[cm]?[jt]sx?$/;
66
70
  /** Operator tooling directories at the provider root (fixture recorders, smoke scripts). */
67
71
  const TOOLING_DIRECTORY_PATTERN = /^(?:scripts|tools|bin)\//;
68
- const TEST_SOURCE_FILE_PATTERN = /(?:^|\/)(?:__tests__|__mocks__|tests)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
72
+ export const TEST_SOURCE_FILE_PATTERN = /(?:^|\/)(?:__tests__|__mocks__|tests)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
69
73
  const RECORDED_FIXTURE_SOURCE_FILE_PATTERN = /(?:^|\/)__fixtures__(?:\/|$)|(?:^|\/)__tests__\/fixtures(?:\/|$)/;
70
74
  const JAVASCRIPT_SOURCE_FILE_PATTERN = /\.[cm]?[jt]sx?$/;
71
75
  const DECLARATION_FILE_PATTERN = /\.d\.[cm]?ts$/;
@@ -503,7 +507,7 @@ function remediation(rule) {
503
507
  }
504
508
  }
505
509
  /**
506
- * Lint the provider runtime boundary. Returns warn-level diagnostics (one per
510
+ * Lint the provider runtime boundary. Returns error-level diagnostics (one per
507
511
  * file and rule, listing every line) and information entries for findings
508
512
  * acknowledged with `@apifuse-allow`.
509
513
  */
@@ -546,7 +550,7 @@ export function lintRuntimeBoundary(ts, provider, readOperationSource) {
546
550
  if (findings.length > 0) {
547
551
  diagnostics.push({
548
552
  rule,
549
- level: "warn",
553
+ level: "error",
550
554
  field,
551
555
  message: `${describeSubjects(findings)}: ${remediation(rule)}`,
552
556
  });
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0-beta.64",
2
+ "version": "2.2.0-beta.65",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",
package/src/lint.ts CHANGED
@@ -27,7 +27,15 @@ import {
27
27
  isStealthOwnedHeaderName,
28
28
  SDK_OWNED_CHROME_HEADER_PREFIX,
29
29
  } from "./runtime/stealth-owned-headers.js";
30
- import { lintRuntimeBoundary, scriptKindForSourceFile } from "./runtime-boundary-lint.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";
31
39
  import { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_SENSITIVE_META_KEY } from "./schema.js";
32
40
  import type { AuthMode, OperationApprovalPolicy, OperationRiskClass } from "./types.js";
33
41
  import { getTypeScript } from "./typescript-module.js";
@@ -2251,6 +2259,583 @@ function lintLegacyChoiceUsage(provider: ProviderSourceLike): LintDiagnostic[] {
2251
2259
  });
2252
2260
  }
2253
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
+
2254
2839
  export function lintOperation(op: {
2255
2840
  descriptionKey?: string;
2256
2841
  whenToUseKeys?: readonly string[];
@@ -2442,6 +3027,7 @@ export function lintProviderWithInformation(
2442
3027
  ...lintErrorCodeDeclarationConflicts(provider),
2443
3028
  ...lintErrorMessageLocalization(provider),
2444
3029
  ...lintLegacyChoiceUsage(provider),
3030
+ ...lintProviderMetaNarrowing(provider),
2445
3031
  ...lintHandleDeclarations(provider),
2446
3032
  ];
2447
3033
  // Runtime boundary rules (process.env, node:fs/net/child_process, raw