@apifuse/provider-sdk 2.2.0-beta.62 → 2.2.0-beta.64
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 +367 -9
- package/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +1 -0
- package/bin/apifuse-check.ts +84 -2
- package/bin/apifuse-dev.ts +11 -31
- package/bin/apifuse-migrate-deployment.ts +200 -0
- package/bin/apifuse-pack-check.ts +11 -4
- package/bin/apifuse-pack-types.ts +187 -4
- package/bin/apifuse-record.ts +44 -32
- package/bin/apifuse-submit-check.ts +9 -5
- package/bin/apifuse-test.ts +3 -1
- package/dist/cli/commands.d.ts +1 -1
- package/dist/cli/commands.js +11 -0
- package/dist/cli/deployment-intent.d.ts +178 -0
- package/dist/cli/deployment-intent.js +277 -0
- package/dist/cli/migrate-deployment-intent.d.ts +132 -0
- package/dist/cli/migrate-deployment-intent.js +1552 -0
- package/dist/cli/migrate-provider-shape.d.ts +5 -1
- package/dist/cli/migrate-provider-shape.js +4 -2
- package/dist/cli/resolver-runtime.d.ts +21 -0
- package/dist/cli/resolver-runtime.js +31 -0
- package/dist/define.d.ts +3 -0
- package/dist/define.js +5 -0
- package/dist/dev.d.ts +3 -1
- package/dist/dev.js +6 -1
- package/dist/engine.d.ts +35 -1
- package/dist/engine.js +21 -2
- package/dist/error-resolution.d.ts +1 -0
- package/dist/error-resolution.js +54 -0
- package/dist/errors.d.ts +39 -1
- package/dist/errors.js +50 -0
- 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/i18n/error-messages.d.ts +86 -0
- package/dist/i18n/error-messages.js +192 -0
- package/dist/i18n/index.d.ts +1 -0
- package/dist/i18n/index.js +1 -0
- package/dist/i18n/keys.d.ts +12 -0
- package/dist/i18n/keys.js +76 -5
- package/dist/index.d.ts +11 -3
- package/dist/index.js +9 -1
- package/dist/lint.d.ts +15 -0
- package/dist/lint.js +469 -82
- package/dist/provider.d.ts +6 -6
- package/dist/provider.js +3 -3
- package/dist/runtime/browser-telemetry-binding.d.ts +5 -0
- package/dist/runtime/browser-telemetry-binding.js +83 -0
- package/dist/runtime/browser-telemetry.d.ts +76 -0
- package/dist/runtime/browser-telemetry.js +165 -0
- package/dist/runtime/browser.d.ts +3 -0
- package/dist/runtime/browser.js +208 -47
- package/dist/runtime/cache-telemetry.d.ts +71 -0
- package/dist/runtime/cache-telemetry.js +177 -0
- package/dist/runtime/cache.js +67 -7
- package/dist/runtime/capability-response.d.ts +3 -0
- package/dist/runtime/capability-response.js +56 -0
- package/dist/runtime/capability-telemetry.d.ts +46 -0
- package/dist/runtime/capability-telemetry.js +68 -0
- package/dist/runtime/diagnostic-env.d.ts +3 -0
- package/dist/runtime/diagnostic-env.js +10 -1
- package/dist/runtime/engine-mode.d.ts +74 -0
- package/dist/runtime/engine-mode.js +152 -0
- package/dist/runtime/handle.js +9 -1
- package/dist/runtime/http-telemetry-guard.d.ts +10 -0
- package/dist/runtime/http-telemetry-guard.js +62 -10
- package/dist/runtime/http.js +141 -35
- package/dist/runtime/instrumentation.js +92 -13
- package/dist/runtime/ocr-telemetry.d.ts +82 -0
- package/dist/runtime/ocr-telemetry.js +192 -0
- package/dist/runtime/ocr.js +35 -8
- package/dist/runtime/request-telemetry.d.ts +2 -0
- package/dist/runtime/request-telemetry.js +2 -1
- package/dist/runtime/resolver-runtime-options.d.ts +14 -0
- package/dist/runtime/resolver-runtime-options.js +33 -0
- package/dist/runtime/resolver-shared.d.ts +3 -2
- package/dist/runtime/resolver-shared.js +4 -4
- package/dist/runtime/resolver-telemetry.d.ts +1 -0
- package/dist/runtime/resolver-telemetry.js +1 -1
- package/dist/runtime/resolver-vendors/browser.d.ts +2 -0
- package/dist/runtime/resolver-vendors/browser.js +46 -0
- package/dist/runtime/resolver-vendors/hosts.d.ts +21 -0
- package/dist/runtime/resolver-vendors/hosts.js +35 -0
- package/dist/runtime/resolver-vendors/hypersolutions.js +2 -2
- package/dist/runtime/resolver-vendors/types.d.ts +5 -4
- package/dist/runtime/resolver.d.ts +4 -1
- package/dist/runtime/resolver.js +46 -13
- package/dist/runtime/state-telemetry.d.ts +79 -0
- package/dist/runtime/state-telemetry.js +194 -0
- package/dist/runtime/state.d.ts +3 -0
- package/dist/runtime/state.js +26 -7
- package/dist/runtime/stealth-cookies.d.ts +5 -1
- package/dist/runtime/stealth-cookies.js +21 -1
- package/dist/runtime/stealth-owned-headers.d.ts +26 -0
- package/dist/runtime/stealth-owned-headers.js +37 -0
- package/dist/runtime/stealth-telemetry.d.ts +92 -0
- package/dist/runtime/stealth-telemetry.js +179 -0
- package/dist/runtime/stealth.d.ts +4 -1
- package/dist/runtime/stealth.js +228 -85
- package/dist/runtime/stt-telemetry.d.ts +77 -0
- package/dist/runtime/stt-telemetry.js +176 -0
- package/dist/runtime/stt.js +15 -6
- package/dist/runtime-boundary-lint.d.ts +132 -0
- package/dist/runtime-boundary-lint.js +586 -0
- package/dist/server/error-stack-frames.d.ts +35 -0
- package/dist/server/error-stack-frames.js +219 -0
- package/dist/server/index.d.ts +7 -0
- package/dist/server/index.js +4 -0
- package/dist/server/sdk-version.d.ts +3 -0
- package/dist/server/sdk-version.js +19 -0
- package/dist/server/self-test.js +1 -18
- package/dist/server/sensitive-values.d.ts +1 -1
- package/dist/server/sensitive-values.js +9 -0
- package/dist/server/serve-implementation.d.ts +51 -2
- package/dist/server/serve-implementation.js +337 -90
- package/dist/server/types.d.ts +1 -0
- package/dist/server/types.js +7 -0
- package/dist/stateful/stateful-provider-owner-forwarder.js +22 -0
- package/dist/stateful/stateful-provider-runtime-context.d.ts +3 -0
- package/dist/stateful/stateful-provider-runtime-context.js +1 -0
- package/dist/types.d.ts +123 -1
- package/dist/typescript-module.d.ts +39 -0
- package/dist/typescript-module.js +72 -0
- package/package.json +6 -1
- package/src/cli/commands.ts +14 -0
- package/src/cli/deployment-intent.ts +364 -0
- package/src/cli/migrate-deployment-intent.ts +1908 -0
- package/src/cli/migrate-provider-shape.ts +10 -4
- package/src/cli/resolver-runtime.ts +72 -0
- package/src/define.ts +13 -0
- package/src/dev.ts +9 -2
- package/src/engine.ts +38 -2
- package/src/error-resolution.ts +58 -0
- package/src/errors.ts +77 -1
- package/src/handle-meta.ts +51 -1
- package/src/handle.ts +92 -10
- package/src/health-scenario.ts +38 -0
- package/src/i18n/error-messages.ts +255 -0
- package/src/i18n/index.ts +1 -0
- package/src/i18n/keys.ts +80 -6
- package/src/index.ts +86 -0
- package/src/lint.ts +604 -91
- package/src/provider.ts +12 -0
- package/src/runtime/browser-telemetry-binding.ts +92 -0
- package/src/runtime/browser-telemetry.ts +289 -0
- package/src/runtime/browser.ts +249 -65
- package/src/runtime/cache-telemetry.ts +247 -0
- package/src/runtime/cache.ts +68 -17
- package/src/runtime/capability-response.ts +53 -0
- package/src/runtime/capability-telemetry.ts +101 -0
- package/src/runtime/diagnostic-env.ts +11 -1
- package/src/runtime/engine-mode.ts +233 -0
- package/src/runtime/handle.ts +9 -1
- package/src/runtime/http-telemetry-guard.ts +66 -13
- package/src/runtime/http.ts +168 -37
- package/src/runtime/instrumentation.ts +105 -16
- package/src/runtime/ocr-telemetry.ts +308 -0
- package/src/runtime/ocr.ts +114 -74
- package/src/runtime/request-telemetry.ts +2 -1
- package/src/runtime/resolver-runtime-options.ts +50 -0
- package/src/runtime/resolver-shared.ts +9 -4
- package/src/runtime/resolver-telemetry.ts +1 -1
- package/src/runtime/resolver-vendors/browser.ts +65 -7
- package/src/runtime/resolver-vendors/hosts.ts +43 -0
- package/src/runtime/resolver-vendors/hypersolutions.ts +2 -2
- package/src/runtime/resolver-vendors/types.ts +5 -4
- package/src/runtime/resolver.ts +64 -21
- package/src/runtime/state-telemetry.ts +298 -0
- package/src/runtime/state.ts +46 -45
- package/src/runtime/stealth-cookies.ts +24 -1
- package/src/runtime/stealth-owned-headers.ts +41 -0
- package/src/runtime/stealth-telemetry.ts +304 -0
- package/src/runtime/stealth.ts +355 -150
- package/src/runtime/stt-telemetry.ts +282 -0
- package/src/runtime/stt.ts +93 -71
- package/src/runtime-boundary-lint.ts +703 -0
- package/src/server/error-stack-frames.ts +214 -0
- package/src/server/index.ts +53 -0
- package/src/server/sdk-version.ts +22 -0
- package/src/server/self-test.ts +1 -21
- package/src/server/sensitive-values.ts +14 -0
- package/src/server/serve-implementation.ts +569 -108
- package/src/server/types.ts +7 -0
- package/src/stateful/stateful-provider-owner-forwarder.ts +23 -0
- package/src/stateful/stateful-provider-runtime-context.ts +4 -0
- package/src/types.ts +128 -1
- package/src/typescript-module.ts +100 -0
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/approval-override.ts.txt +0 -6
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/codemod-syntax.ts.txt +0 -3
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/connection-precedence.ts.txt +0 -10
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-ambiguous-a.ts.txt +0 -3
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-ambiguous-b.ts.txt +0 -3
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-ambiguous-barrel.ts.txt +0 -2
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-ambiguous-index.ts.txt +0 -3
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-computed.ts.txt +0 -6
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-cycle-a.ts.txt +0 -1
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-cycle-b.ts.txt +0 -1
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-cycle-index.ts.txt +0 -3
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-barrel.ts.txt +0 -7
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-catalog.ts.txt +0 -19
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-docs.ts.txt +0 -6
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-index.ts.txt +0 -3
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-stores.ts.txt +0 -8
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-indirect-config.ts.txt +0 -5
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-nonstatic.ts.txt +0 -4
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-package-index.ts.txt +0 -3
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-reexport-index.ts.txt +0 -3
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-reexport-leaf.ts.txt +0 -10
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-reexport-one.ts.txt +0 -1
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-reexport-two.ts.txt +0 -1
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-runtime-composed-index.ts.txt +0 -3
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-runtime-composed-one.ts.txt +0 -6
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-runtime-composed-two.ts.txt +0 -6
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-runtime-mixed-index.ts.txt +0 -14
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-runtime-registry.ts.txt +0 -3
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-star-package-barrel.ts.txt +0 -2
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-star-package-index.ts.txt +0 -3
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-star-package-leaf.ts.txt +0 -3
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/docs-conflict.ts.txt +0 -8
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-hoisted-array-spread.ts.txt +0 -15
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-imported-array-spread.ts.txt +0 -9
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-kebab-operation-id.ts.txt +0 -17
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-map.ts.txt +0 -5
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-mixed-array-spread.ts.txt +0 -24
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-operation.ts.txt +0 -16
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-snake-operation-id.ts.txt +0 -21
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-duplicate-provider.ts.txt +0 -11
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-id-unresolved.ts.txt +0 -7
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-map.ts.txt +0 -3
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-one-to-many.ts.txt +0 -18
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-one-to-one.ts.txt +0 -13
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/hoist-all.ts.txt +0 -31
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/hoisted-const.ts.txt +0 -11
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/imported-spread.ts.txt +0 -11
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-map.ts.txt +0 -11
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-cast-tail.ts.txt +0 -21
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-ekitan.ts.txt +0 -11
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-override.ts.txt +0 -14
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-canonical-en.json +0 -1
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-canonical-ja.json +0 -1
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-canonical-ko.json +0 -1
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-existing-operation-namespace-en.json +0 -8
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/missing-english-locale.ts.txt +0 -7
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/no-safety.ts.txt +0 -6
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/non-literal.ts.txt +0 -7
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/redundant-approval.ts.txt +0 -6
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-binding-ambiguous.ts.txt +0 -12
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-imported-other.ts.txt +0 -7
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-imported.ts.txt +0 -5
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-key-ambiguous.ts.txt +0 -19
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-shorthand.ts.txt +0 -9
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-typed.ts.txt +0 -11
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-unrelated.ts.txt +0 -13
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/safety-conflict.ts.txt +0 -7
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/stream.ts.txt +0 -7
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/tool-router-spread.ts.txt +0 -15
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/unparseable.ts.txt +0 -4
- package/src/cli/__tests__/fixtures/migrate-operation-declaration/verbatim-template.ts.txt +0 -12
|
@@ -0,0 +1,1908 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import type TS from "typescript";
|
|
4
|
+
|
|
5
|
+
import type { ProviderDeploymentOverrides } from "../types.js";
|
|
6
|
+
import {
|
|
7
|
+
DEPLOYMENT_INTENT_FIELD_ORDER,
|
|
8
|
+
deepEqual,
|
|
9
|
+
deploymentIntentsEquivalent,
|
|
10
|
+
normalizeLegacyDeployment,
|
|
11
|
+
parseDeploymentIntent,
|
|
12
|
+
type ResolvedDeploymentIntent,
|
|
13
|
+
resolveDeploymentIntent,
|
|
14
|
+
subtractDeploymentDefaults,
|
|
15
|
+
} from "./deployment-intent.js";
|
|
16
|
+
|
|
17
|
+
export const TYPESCRIPT_REQUIRED_MESSAGE =
|
|
18
|
+
"apifuse migrate-deployment requires typescript; install it in the workspace running the CLI (bun add -d typescript)";
|
|
19
|
+
|
|
20
|
+
const ts: typeof import("typescript") = await loadTypeScript();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Throws (rather than exiting) when the compiler is missing so importers can
|
|
24
|
+
* decide: the migrate command reports and exits, `apifuse check` skips its
|
|
25
|
+
* source-shape inspection.
|
|
26
|
+
*/
|
|
27
|
+
async function loadTypeScript(): Promise<typeof import("typescript")> {
|
|
28
|
+
try {
|
|
29
|
+
return await import("typescript");
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new Error(TYPESCRIPT_REQUIRED_MESSAGE, { cause: error });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Deployment-intent migration: retire a provider repository's standalone
|
|
37
|
+
* `deploy.ts`.
|
|
38
|
+
*
|
|
39
|
+
* End state: deployment intent lives only in the `deployment` key of the
|
|
40
|
+
* `defineProvider({...})` declaration, and only the fields that differ from
|
|
41
|
+
* the runtime profile are written. Everything else goes: the `deploy.ts`
|
|
42
|
+
* file, a `deployment` extra on a `{ ...provider, deployment }` default
|
|
43
|
+
* export, and an `import deployment from "./deploy"`. The `/deploy.ts`
|
|
44
|
+
* CODEOWNERS lock is kept by default: the platform still honors a committed
|
|
45
|
+
* legacy file until its fallback is retired, so re-adding one must keep
|
|
46
|
+
* needing operator review. `dropCodeownersLock` removes it once that
|
|
47
|
+
* fallback is gone.
|
|
48
|
+
*
|
|
49
|
+
* The transform never regenerates values from the profile — it moves the
|
|
50
|
+
* values the file actually declares (hand-edited "mirrors" exist in the
|
|
51
|
+
* fleet) and proves equivalence: the key it writes must resolve to exactly
|
|
52
|
+
* what the legacy file resolved to, or it refuses. Like the other codemods
|
|
53
|
+
* it is conservative: anything it cannot fully account for is reported as
|
|
54
|
+
* `refused` with a reason rather than partially migrated.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** How the `defineProvider()` declaration literal was located. */
|
|
58
|
+
export type DeploymentDeclarationShape =
|
|
59
|
+
/** `defineProvider({ ... })` — the literal is the argument (spreads inside are fine). */
|
|
60
|
+
| "literal-argument"
|
|
61
|
+
/** `const declaration = { ... }; defineProvider(declaration)` — top-level const literal. */
|
|
62
|
+
| "variable-literal-argument";
|
|
63
|
+
|
|
64
|
+
export interface DeploymentIntentMigrationInput {
|
|
65
|
+
/** Current `index.ts` text. */
|
|
66
|
+
readonly indexSource: string;
|
|
67
|
+
/** Current `deploy.ts` text, or undefined when the repository has none. */
|
|
68
|
+
readonly deploySource?: string;
|
|
69
|
+
/** Current `.github/CODEOWNERS` text, or undefined when absent. */
|
|
70
|
+
readonly codeownersSource?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Remove the `/deploy.ts` CODEOWNERS lock (and the scaffold's TEMPORARY
|
|
73
|
+
* comment above it). Off by default — see the module comment.
|
|
74
|
+
*/
|
|
75
|
+
readonly dropCodeownersLock?: boolean;
|
|
76
|
+
readonly indexFileName?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type DeploymentIntentMigration =
|
|
80
|
+
| {
|
|
81
|
+
readonly status: "migrated";
|
|
82
|
+
readonly shape: DeploymentDeclarationShape;
|
|
83
|
+
readonly indexSource: string;
|
|
84
|
+
readonly indexChanged: boolean;
|
|
85
|
+
readonly codeownersSource?: string;
|
|
86
|
+
readonly codeownersChanged: boolean;
|
|
87
|
+
/** True when `deploy.ts` existed and must be deleted. */
|
|
88
|
+
readonly removeDeployFile: boolean;
|
|
89
|
+
/** Resolved view the legacy `deploy.ts` declared, when it existed. */
|
|
90
|
+
readonly legacy?: ResolvedDeploymentIntent;
|
|
91
|
+
/** The minimal key now authored in the declaration (empty = profile defaults). */
|
|
92
|
+
readonly intent: ProviderDeploymentOverrides;
|
|
93
|
+
/** What the key resolves to; equals `legacy` when a legacy file existed. */
|
|
94
|
+
readonly resolved: ResolvedDeploymentIntent;
|
|
95
|
+
readonly notes: readonly string[];
|
|
96
|
+
}
|
|
97
|
+
| {
|
|
98
|
+
readonly status: "unchanged";
|
|
99
|
+
readonly notes: readonly string[];
|
|
100
|
+
}
|
|
101
|
+
| {
|
|
102
|
+
readonly status: "refused";
|
|
103
|
+
readonly reason: string;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const DEPLOYMENT_PROPERTY_NAME = "deployment";
|
|
107
|
+
/** index.ts sits at the repository root; the deploy module is its sibling. */
|
|
108
|
+
const INDEX_VIRTUAL_DIRECTORY = "/";
|
|
109
|
+
const DEPLOY_MODULE_VIRTUAL_PATH = "/deploy";
|
|
110
|
+
const SOURCE_EXTENSION = /\.(?:[cm]?[jt]s|[jt]sx)$/u;
|
|
111
|
+
const LINE_WIDTH = 80;
|
|
112
|
+
/**
|
|
113
|
+
* Emitted above the key only when it carries `runtime`: the deployment
|
|
114
|
+
* runtime axis sits next to the execution `runtime` property and must not
|
|
115
|
+
* be read as the same axis.
|
|
116
|
+
*/
|
|
117
|
+
export const DEPLOYMENT_RUNTIME_AXIS_COMMENT =
|
|
118
|
+
"// Deployment runtime axis (shared | dedicated | browser); not the execution runtime above.";
|
|
119
|
+
|
|
120
|
+
/** CODEOWNERS block the platform scaffold emitted for the mirror; removed verbatim only. */
|
|
121
|
+
const CODEOWNERS_MIRROR_COMMENT = [
|
|
122
|
+
"# Platform-generated deployment mirror (TEMPORARY until the SDK deployment",
|
|
123
|
+
"# passthrough lands): deployment intent is platform-owned, so changes to the",
|
|
124
|
+
"# generated deploy.ts require operator review.",
|
|
125
|
+
];
|
|
126
|
+
const CODEOWNERS_DEPLOY_LINE = /^\/deploy\.ts(?:\s|$)/;
|
|
127
|
+
|
|
128
|
+
export function migrateDeploymentIntent(
|
|
129
|
+
input: DeploymentIntentMigrationInput,
|
|
130
|
+
): DeploymentIntentMigration {
|
|
131
|
+
const notes: string[] = [];
|
|
132
|
+
const fileName = input.indexFileName ?? "index.ts";
|
|
133
|
+
const source = parseSource(fileName, input.indexSource);
|
|
134
|
+
const syntaxError = firstSyntaxError(source);
|
|
135
|
+
if (syntaxError !== undefined) {
|
|
136
|
+
return { status: "refused", reason: `index.ts does not parse: ${syntaxError}` };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let codeowners: { readonly text: string; readonly changed: boolean } | undefined;
|
|
140
|
+
if (input.codeownersSource !== undefined) {
|
|
141
|
+
const stripped = stripCodeownersDeployLock(input.codeownersSource);
|
|
142
|
+
if (input.dropCodeownersLock === true) {
|
|
143
|
+
codeowners = stripped;
|
|
144
|
+
} else {
|
|
145
|
+
codeowners = { text: input.codeownersSource, changed: false };
|
|
146
|
+
if (stripped.changed) {
|
|
147
|
+
notes.push(
|
|
148
|
+
"Kept the /deploy.ts CODEOWNERS entry: the platform still honors a re-added deploy.ts until its legacy fallback is retired; re-run with --drop-codeowners-lock after that.",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Legacy file
|
|
155
|
+
let legacy: ResolvedDeploymentIntent | undefined;
|
|
156
|
+
let legacyRaw: unknown;
|
|
157
|
+
if (input.deploySource !== undefined) {
|
|
158
|
+
const parsed = readLegacyDeployFile(input.deploySource);
|
|
159
|
+
if (!parsed.ok) return { status: "refused", reason: parsed.reason };
|
|
160
|
+
legacy = parsed.value;
|
|
161
|
+
legacyRaw = parsed.raw;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Declaration literal
|
|
165
|
+
const calls = collectDefineProviderCalls(source);
|
|
166
|
+
if (calls.length === 0) {
|
|
167
|
+
return {
|
|
168
|
+
status: "refused",
|
|
169
|
+
reason: "No defineProvider(...) call found; this file does not declare a provider.",
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (calls.length > 1) {
|
|
173
|
+
return {
|
|
174
|
+
status: "refused",
|
|
175
|
+
reason: `Found ${calls.length} defineProvider(...) calls; the transform rewrites exactly one declaration.`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const call = calls[0];
|
|
179
|
+
if (call === undefined)
|
|
180
|
+
return { status: "refused", reason: "Internal: defineProvider call vanished." };
|
|
181
|
+
if (!isTopLevelNode(call)) {
|
|
182
|
+
return {
|
|
183
|
+
status: "refused",
|
|
184
|
+
reason:
|
|
185
|
+
"defineProvider(...) is called inside a function or block; the transform only rewrites a top-level declaration, where bindings cannot be shadowed.",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const located = locateDeclarationLiteral(source, call);
|
|
189
|
+
if (located.status === "refused") return located;
|
|
190
|
+
const { literal: declaration, shape } = located;
|
|
191
|
+
|
|
192
|
+
// ── Existing key inside the declaration
|
|
193
|
+
const existingProperty = resolveDeclarationDeployment(declaration, source);
|
|
194
|
+
if (existingProperty.status === "refused") return existingProperty;
|
|
195
|
+
let existingIntent: ProviderDeploymentOverrides | undefined;
|
|
196
|
+
let existingResolved: ResolvedDeploymentIntent | undefined;
|
|
197
|
+
let existingOpaque = false;
|
|
198
|
+
if (existingProperty.property !== undefined) {
|
|
199
|
+
const evaluated = evaluateStatic(existingProperty.property.initializer, source);
|
|
200
|
+
if (evaluated.ok) {
|
|
201
|
+
const parsedIntent = parseDeploymentIntent(evaluated.value);
|
|
202
|
+
if (!parsedIntent.ok) {
|
|
203
|
+
return {
|
|
204
|
+
status: "refused",
|
|
205
|
+
reason: `The declaration's deployment key is invalid: ${parsedIntent.reason}`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
existingIntent = parsedIntent.value;
|
|
209
|
+
try {
|
|
210
|
+
existingResolved = resolveDeploymentIntent(existingIntent);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
return {
|
|
213
|
+
status: "refused",
|
|
214
|
+
reason: `The declaration's deployment key does not resolve: ${describeError(error)}`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
existingOpaque = true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Default-export spread carrying `deployment`
|
|
223
|
+
const spread = analyzeSpreadExport(source, call, builderVariableName(call, source));
|
|
224
|
+
if (spread.status === "refused") return spread;
|
|
225
|
+
const deployImports = collectLegacyDeployImports(source);
|
|
226
|
+
if (deployImports.status === "refused") return deployImports;
|
|
227
|
+
|
|
228
|
+
let spreadResolved: ResolvedDeploymentIntent | undefined;
|
|
229
|
+
if (spread.deploymentProperty !== undefined) {
|
|
230
|
+
const valueNode = spread.deploymentProperty.value;
|
|
231
|
+
if (valueNode.kind === "deploy-import") {
|
|
232
|
+
if (legacy === undefined) {
|
|
233
|
+
return {
|
|
234
|
+
status: "refused",
|
|
235
|
+
reason:
|
|
236
|
+
"index.ts imports the default export of ./deploy but deploy.ts is missing; restore the file or remove the import first.",
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
// The platform reads the attached object as the deployment KEY
|
|
240
|
+
// (omitted fields resolve to profile defaults), not as a legacy
|
|
241
|
+
// file (omitted hpa meant disabled). Resolve it the way the platform
|
|
242
|
+
// does and require agreement with the legacy reading before the
|
|
243
|
+
// file that satisfies both is deleted.
|
|
244
|
+
const asKey = parseDeploymentIntent(legacyRaw);
|
|
245
|
+
if (!asKey.ok) {
|
|
246
|
+
return {
|
|
247
|
+
status: "refused",
|
|
248
|
+
reason: `deploy.ts is attached as the default export's deployment key but is not a valid key: ${asKey.reason}`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
spreadResolved = resolveDeploymentIntent(asKey.value);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
return {
|
|
255
|
+
status: "refused",
|
|
256
|
+
reason: `deploy.ts, attached as the default export's deployment key, does not resolve: ${describeError(error)}`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (!deploymentIntentsEquivalent(spreadResolved, legacy)) {
|
|
260
|
+
return {
|
|
261
|
+
status: "refused",
|
|
262
|
+
reason:
|
|
263
|
+
"deploy.ts resolves differently as the attached deployment key (omitted fields take profile defaults) than as a legacy file (omitted hpa is disabled); make the file explicit by hand before migrating.",
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
} else {
|
|
267
|
+
const parsedIntent = parseDeploymentIntent(valueNode.value);
|
|
268
|
+
if (!parsedIntent.ok) {
|
|
269
|
+
return {
|
|
270
|
+
status: "refused",
|
|
271
|
+
reason: `The default export's deployment property is invalid: ${parsedIntent.reason}`,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
spreadResolved = resolveDeploymentIntent(parsedIntent.value);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
return {
|
|
278
|
+
status: "refused",
|
|
279
|
+
reason: `The default export's deployment property does not resolve: ${describeError(error)}`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
} else if (deployImports.defaultImport !== undefined) {
|
|
284
|
+
return {
|
|
285
|
+
status: "refused",
|
|
286
|
+
reason:
|
|
287
|
+
"index.ts imports ./deploy but does not attach it as `deployment` on the default export; the transform cannot tell how the value is used.",
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Which resolved view is authoritative
|
|
292
|
+
if (existingOpaque && (legacy !== undefined || spreadResolved !== undefined)) {
|
|
293
|
+
return {
|
|
294
|
+
status: "refused",
|
|
295
|
+
reason:
|
|
296
|
+
"The declaration already carries a deployment key that is not a static literal, so it cannot be compared with the legacy deploy.ts value.",
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
if (
|
|
300
|
+
existingResolved !== undefined &&
|
|
301
|
+
spreadResolved !== undefined &&
|
|
302
|
+
!deploymentIntentsEquivalent(existingResolved, spreadResolved)
|
|
303
|
+
) {
|
|
304
|
+
return {
|
|
305
|
+
status: "refused",
|
|
306
|
+
reason:
|
|
307
|
+
"The declaration's deployment key and the default export's deployment property resolve to different deployments; reconcile them by hand.",
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const keyResolved = spreadResolved ?? existingResolved;
|
|
311
|
+
if (
|
|
312
|
+
legacy !== undefined &&
|
|
313
|
+
keyResolved !== undefined &&
|
|
314
|
+
!deploymentIntentsEquivalent(keyResolved, legacy)
|
|
315
|
+
) {
|
|
316
|
+
return {
|
|
317
|
+
status: "refused",
|
|
318
|
+
reason:
|
|
319
|
+
"deploy.ts and the authored deployment key resolve to different deployments (the key is authoritative for the platform); reconcile them by hand before deleting the file.",
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
const target = keyResolved ?? legacy;
|
|
323
|
+
|
|
324
|
+
if (target === undefined) {
|
|
325
|
+
// Nothing to move: no deploy.ts, no key to hoist. Only the CODEOWNERS
|
|
326
|
+
// lock on a file that no longer exists may still need cleanup.
|
|
327
|
+
if (existingOpaque) {
|
|
328
|
+
notes.push(
|
|
329
|
+
"The declaration's deployment key is not a static literal; it was left untouched.",
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
if (codeowners?.changed) {
|
|
333
|
+
return {
|
|
334
|
+
status: "migrated",
|
|
335
|
+
shape,
|
|
336
|
+
indexSource: input.indexSource,
|
|
337
|
+
indexChanged: false,
|
|
338
|
+
codeownersSource: codeowners.text,
|
|
339
|
+
codeownersChanged: true,
|
|
340
|
+
removeDeployFile: false,
|
|
341
|
+
intent: {},
|
|
342
|
+
resolved: resolveDeploymentIntent(),
|
|
343
|
+
notes,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
return { status: "unchanged", notes };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const intent = subtractDeploymentDefaults(target);
|
|
350
|
+
const resolved = resolveDeploymentIntent(intent);
|
|
351
|
+
if (!deploymentIntentsEquivalent(resolved, target)) {
|
|
352
|
+
return {
|
|
353
|
+
status: "refused",
|
|
354
|
+
reason:
|
|
355
|
+
"Internal: the minimal deployment key does not resolve back to the declared deployment; refusing to write it.",
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Edits
|
|
360
|
+
const edits: TextEdit[] = [];
|
|
361
|
+
if (existingProperty.property !== undefined) {
|
|
362
|
+
if (existingIntent === undefined || !deepEqual(existingIntent, intent)) {
|
|
363
|
+
if (Object.keys(intent).length === 0) {
|
|
364
|
+
if (existingProperty.spreadCarriesDeployment === true) {
|
|
365
|
+
return {
|
|
366
|
+
status: "refused",
|
|
367
|
+
reason:
|
|
368
|
+
"The declaration's deployment key only restates profile defaults, but removing it would expose the deployment carried by a spread in the same literal; reconcile the spread by hand first.",
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
edits.push(removeObjectProperty(existingProperty.property, declaration, source));
|
|
372
|
+
notes.push("Removed a deployment key that only restated profile defaults.");
|
|
373
|
+
} else {
|
|
374
|
+
const indent = lineIndentation(source, existingProperty.property.getStart(source));
|
|
375
|
+
const unit = indentUnitFor(source, declaration, indent);
|
|
376
|
+
edits.push({
|
|
377
|
+
start: existingProperty.property.initializer.getStart(source),
|
|
378
|
+
end: existingProperty.property.initializer.getEnd(),
|
|
379
|
+
text: formatIntentLiteral(intent, indent, unit),
|
|
380
|
+
});
|
|
381
|
+
notes.push("Rewrote the deployment key to the minimal (profile-subtracted) form.");
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
} else if (Object.keys(intent).length > 0) {
|
|
385
|
+
edits.push(insertDeploymentProperty(declaration, intent, source));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (spread.deploymentProperty !== undefined) {
|
|
389
|
+
edits.push(...removeSpreadDeploymentProperty(spread, declaration, source));
|
|
390
|
+
const valueNode = spread.deploymentProperty.value;
|
|
391
|
+
if (
|
|
392
|
+
valueNode.kind === "const" &&
|
|
393
|
+
countIdentifierReferences(source, valueNode.name) === 1 &&
|
|
394
|
+
!hasExportModifier(valueNode.statement)
|
|
395
|
+
) {
|
|
396
|
+
edits.push(removeStatement(valueNode.statement, source));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
for (const statement of deployImports.removable) {
|
|
400
|
+
edits.push(removeStatement(statement, source));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (editsOverlap(edits)) {
|
|
404
|
+
return {
|
|
405
|
+
status: "refused",
|
|
406
|
+
reason: "Internal: the planned source edits overlap; refusing to emit a partial migration.",
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
const output = applyEdits(input.indexSource, edits);
|
|
410
|
+
const verified = parseSource(fileName, output);
|
|
411
|
+
const outputError = firstSyntaxError(verified);
|
|
412
|
+
if (outputError !== undefined) {
|
|
413
|
+
return {
|
|
414
|
+
status: "refused",
|
|
415
|
+
reason: `Transform produced source that does not parse (${outputError}); refusing to emit a partial migration.`,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
if (deployImports.defaultImport !== undefined) {
|
|
419
|
+
const remaining = countIdentifierReferences(verified, deployImports.defaultImport);
|
|
420
|
+
if (remaining > 0) {
|
|
421
|
+
return {
|
|
422
|
+
status: "refused",
|
|
423
|
+
reason: `\`${deployImports.defaultImport}\` (imported from ./deploy) is still referenced after removing the default-export property; the transform cannot delete deploy.ts.`,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
for (const typeName of deployImports.typeOnlyNames) {
|
|
428
|
+
if (countIdentifierReferences(verified, typeName) > 0) {
|
|
429
|
+
return {
|
|
430
|
+
status: "refused",
|
|
431
|
+
reason: `index.ts still uses \`${typeName}\`, a type imported from ./deploy, after the import is removed; deploy.ts is being deleted, so replace it (for example with \`ProviderDeploymentOverrides\` from @apifuse/provider-sdk) by hand before migrating.`,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (referencesLegacyDeployModule(verified)) {
|
|
436
|
+
return {
|
|
437
|
+
status: "refused",
|
|
438
|
+
reason:
|
|
439
|
+
"index.ts still references the ./deploy module after the transform; remove that usage by hand.",
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const indexChanged = output !== input.indexSource;
|
|
444
|
+
const removeDeployFile = input.deploySource !== undefined;
|
|
445
|
+
if (!indexChanged && !removeDeployFile && !codeowners?.changed) {
|
|
446
|
+
return { status: "unchanged", notes };
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
status: "migrated",
|
|
450
|
+
shape,
|
|
451
|
+
indexSource: output,
|
|
452
|
+
indexChanged,
|
|
453
|
+
...(codeowners === undefined ? {} : { codeownersSource: codeowners.text }),
|
|
454
|
+
codeownersChanged: codeowners?.changed ?? false,
|
|
455
|
+
removeDeployFile,
|
|
456
|
+
...(legacy === undefined ? {} : { legacy }),
|
|
457
|
+
intent,
|
|
458
|
+
resolved,
|
|
459
|
+
notes,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Statically read the `deployment` key authored inside the declaration
|
|
465
|
+
* literal of `indexSource` (undefined when absent). Used to prove round
|
|
466
|
+
* trips: the value the transform wrote must resolve to the legacy view.
|
|
467
|
+
*/
|
|
468
|
+
export function readDeclaredDeploymentIntent(
|
|
469
|
+
indexSource: string,
|
|
470
|
+
fileName = "index.ts",
|
|
471
|
+
):
|
|
472
|
+
| { readonly ok: true; readonly value: unknown }
|
|
473
|
+
| { readonly ok: false; readonly reason: string } {
|
|
474
|
+
const source = parseSource(fileName, indexSource);
|
|
475
|
+
const calls = collectDefineProviderCalls(source);
|
|
476
|
+
const call = calls[0];
|
|
477
|
+
if (calls.length !== 1 || call === undefined) {
|
|
478
|
+
return { ok: false, reason: `expected exactly one defineProvider call, found ${calls.length}` };
|
|
479
|
+
}
|
|
480
|
+
const located = locateDeclarationLiteral(source, call);
|
|
481
|
+
if (located.status === "refused") return { ok: false, reason: located.reason };
|
|
482
|
+
const property = resolveDeclarationDeployment(located.literal, source);
|
|
483
|
+
if (property.status === "refused") return { ok: false, reason: property.reason };
|
|
484
|
+
if (property.property === undefined) return { ok: true, value: undefined };
|
|
485
|
+
return evaluateStatic(property.property.initializer, source);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Line (1-based) of a `deployment` property attached to a spread default
|
|
490
|
+
* export (`export default { ...provider, deployment }`), or undefined when
|
|
491
|
+
* the module has no such legacy surface. Used by `apifuse check`: the loaded
|
|
492
|
+
* module cannot tell that extra apart from a declaration-level key, so the
|
|
493
|
+
* source shape is inspected instead.
|
|
494
|
+
*/
|
|
495
|
+
export function findSpreadExportDeploymentProperty(
|
|
496
|
+
indexSource: string,
|
|
497
|
+
fileName = "index.ts",
|
|
498
|
+
): { readonly line: number } | undefined {
|
|
499
|
+
const source = parseSource(fileName, indexSource);
|
|
500
|
+
const exportAssignment = findDefaultExport(source);
|
|
501
|
+
if (exportAssignment === undefined) return undefined;
|
|
502
|
+
const exported = unwrapExpression(exportAssignment.expression);
|
|
503
|
+
if (!ts.isObjectLiteralExpression(exported)) return undefined;
|
|
504
|
+
if (!exported.properties.some(ts.isSpreadAssignment)) return undefined;
|
|
505
|
+
for (const property of exported.properties) {
|
|
506
|
+
if (ts.isSpreadAssignment(property)) continue;
|
|
507
|
+
const name = property.name;
|
|
508
|
+
if (name === undefined) continue;
|
|
509
|
+
let text: string | undefined;
|
|
510
|
+
if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) text = name.text;
|
|
511
|
+
else if (ts.isComputedPropertyName(name)) {
|
|
512
|
+
const inner = unwrapExpression(name.expression);
|
|
513
|
+
if (ts.isStringLiteralLike(inner)) text = inner.text;
|
|
514
|
+
}
|
|
515
|
+
if (text === DEPLOYMENT_PROPERTY_NAME) {
|
|
516
|
+
return { line: source.getLineAndCharacterOfPosition(property.getStart(source)).line + 1 };
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return undefined;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Parse a legacy `deploy.ts` module text into the resolved deployment view. */
|
|
523
|
+
export function readLegacyDeployFile(
|
|
524
|
+
deploySource: string,
|
|
525
|
+
):
|
|
526
|
+
| { readonly ok: true; readonly value: ResolvedDeploymentIntent; readonly raw: unknown }
|
|
527
|
+
| { readonly ok: false; readonly reason: string } {
|
|
528
|
+
const source = parseSource("deploy.ts", deploySource);
|
|
529
|
+
const syntaxError = firstSyntaxError(source);
|
|
530
|
+
if (syntaxError !== undefined) {
|
|
531
|
+
return { ok: false, reason: `deploy.ts does not parse: ${syntaxError}` };
|
|
532
|
+
}
|
|
533
|
+
const exportAssignment = findDefaultExport(source);
|
|
534
|
+
if (exportAssignment === undefined) {
|
|
535
|
+
return {
|
|
536
|
+
ok: false,
|
|
537
|
+
reason: "deploy.ts has no `export default`; the transform cannot read its deployment config.",
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
const evaluated = evaluateStatic(exportAssignment.expression, source);
|
|
541
|
+
if (!evaluated.ok) {
|
|
542
|
+
return {
|
|
543
|
+
ok: false,
|
|
544
|
+
reason: `deploy.ts default export is not a static literal: ${evaluated.reason}`,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
const normalized = normalizeLegacyDeployment(evaluated.value);
|
|
548
|
+
if (!normalized.ok) return normalized;
|
|
549
|
+
return { ok: true, value: normalized.value, raw: evaluated.value };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Repository sources (other than index.ts and deploy.ts themselves) that
|
|
554
|
+
* import the deploy module — `import x from "../deploy"`, re-exports, dynamic
|
|
555
|
+
* `import("./deploy")`, `require("./deploy")`. Deleting deploy.ts while one
|
|
556
|
+
* exists breaks that consumer, so the command refuses until it is removed.
|
|
557
|
+
* `files` carry paths relative to or absolute under `providerRoot`.
|
|
558
|
+
*/
|
|
559
|
+
export function findDeployModuleConsumers(
|
|
560
|
+
providerRoot: string,
|
|
561
|
+
files: readonly { readonly path: string; readonly text: string }[],
|
|
562
|
+
): string[] {
|
|
563
|
+
const deployModule = path.resolve(providerRoot, "deploy");
|
|
564
|
+
const consumers: string[] = [];
|
|
565
|
+
for (const file of files) {
|
|
566
|
+
const absolute = path.resolve(providerRoot, file.path);
|
|
567
|
+
const source = parseSource(absolute, file.text);
|
|
568
|
+
if (sourceImportsModule(source, path.dirname(absolute), deployModule)) {
|
|
569
|
+
consumers.push(path.relative(providerRoot, absolute));
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return consumers.sort();
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Remove the `/deploy.ts` CODEOWNERS line and, when it is preceded by the
|
|
577
|
+
* exact platform-scaffold comment block, that block too. Any other content
|
|
578
|
+
* is preserved byte for byte.
|
|
579
|
+
*/
|
|
580
|
+
export function stripCodeownersDeployLock(text: string): {
|
|
581
|
+
readonly text: string;
|
|
582
|
+
readonly changed: boolean;
|
|
583
|
+
} {
|
|
584
|
+
const newline = text.includes("\r\n") ? "\r\n" : "\n";
|
|
585
|
+
const lines = text.split(newline);
|
|
586
|
+
const output: string[] = [];
|
|
587
|
+
let changed = false;
|
|
588
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
589
|
+
const line = lines[index] ?? "";
|
|
590
|
+
if (!CODEOWNERS_DEPLOY_LINE.test(line)) {
|
|
591
|
+
output.push(line);
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
changed = true;
|
|
595
|
+
const commentStart = output.length - CODEOWNERS_MIRROR_COMMENT.length;
|
|
596
|
+
if (
|
|
597
|
+
commentStart >= 0 &&
|
|
598
|
+
CODEOWNERS_MIRROR_COMMENT.every(
|
|
599
|
+
(expected, offset) => output[commentStart + offset] === expected,
|
|
600
|
+
)
|
|
601
|
+
) {
|
|
602
|
+
output.splice(commentStart, CODEOWNERS_MIRROR_COMMENT.length);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return { text: changed ? output.join(newline) : text, changed };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ── Declaration location ───────────────────────────────────────────────────
|
|
609
|
+
|
|
610
|
+
type DeclarationLocation =
|
|
611
|
+
| {
|
|
612
|
+
readonly status: "ok";
|
|
613
|
+
readonly literal: TS.ObjectLiteralExpression;
|
|
614
|
+
readonly shape: DeploymentDeclarationShape;
|
|
615
|
+
}
|
|
616
|
+
| { readonly status: "refused"; readonly reason: string };
|
|
617
|
+
|
|
618
|
+
function locateDeclarationLiteral(
|
|
619
|
+
source: TS.SourceFile,
|
|
620
|
+
call: TS.CallExpression,
|
|
621
|
+
): DeclarationLocation {
|
|
622
|
+
const argument = call.arguments[0];
|
|
623
|
+
if (argument === undefined) {
|
|
624
|
+
return {
|
|
625
|
+
status: "refused",
|
|
626
|
+
reason: "defineProvider() is called without a declaration argument.",
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
const unwrapped = unwrapExpression(argument);
|
|
630
|
+
if (ts.isObjectLiteralExpression(unwrapped)) {
|
|
631
|
+
return { status: "ok", literal: unwrapped, shape: "literal-argument" };
|
|
632
|
+
}
|
|
633
|
+
if (ts.isIdentifier(unwrapped)) {
|
|
634
|
+
const declaration = findTopLevelConst(source, unwrapped.text);
|
|
635
|
+
if (declaration?.initializer !== undefined) {
|
|
636
|
+
const initializer = unwrapExpression(declaration.initializer);
|
|
637
|
+
if (ts.isObjectLiteralExpression(initializer)) {
|
|
638
|
+
if (!identifierUsesAreReadOnly(source, unwrapped.text)) {
|
|
639
|
+
return {
|
|
640
|
+
status: "refused",
|
|
641
|
+
reason: `\`${unwrapped.text}\` is mutated, aliased, or passed to a call after its initializer, so the transform cannot prove the literal is the effective declaration.`,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
return { status: "ok", literal: initializer, shape: "variable-literal-argument" };
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
status: "refused",
|
|
649
|
+
reason: `defineProvider(${unwrapped.text}) is called with a variable whose initializer is not a top-level const object literal, so the transform cannot place the deployment key.`,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
return {
|
|
653
|
+
status: "refused",
|
|
654
|
+
reason:
|
|
655
|
+
"defineProvider(...) is called with a non-literal argument, so the transform cannot place the deployment key.",
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
type NamedPropertyLookup =
|
|
660
|
+
| {
|
|
661
|
+
readonly status: "ok";
|
|
662
|
+
readonly property: TS.PropertyAssignment | undefined;
|
|
663
|
+
/** A spread in the literal statically carries a `deployment` key (shadowed by `property`). */
|
|
664
|
+
readonly spreadCarriesDeployment?: boolean;
|
|
665
|
+
}
|
|
666
|
+
| { readonly status: "refused"; readonly reason: string };
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* The declaration literal's effective `deployment` property. Object literals
|
|
670
|
+
* are last-wins, so spreads inside the literal are inspected statically: a
|
|
671
|
+
* spread that resolves to a top-level const object literal is scanned
|
|
672
|
+
* (recursively) for a `deployment` key; anything the transform cannot inspect
|
|
673
|
+
* is refused because it cannot prove the spread carries no deployment. A
|
|
674
|
+
* deployment-bearing spread that is the last contributor — or the only one —
|
|
675
|
+
* refuses too, since inserting or rewriting a plain property would not change
|
|
676
|
+
* (or would silently change) the effective deployment.
|
|
677
|
+
*/
|
|
678
|
+
function resolveDeclarationDeployment(
|
|
679
|
+
literal: TS.ObjectLiteralExpression,
|
|
680
|
+
source: TS.SourceFile,
|
|
681
|
+
): NamedPropertyLookup {
|
|
682
|
+
let explicit: TS.PropertyAssignment | undefined;
|
|
683
|
+
let lastSource: "explicit" | "spread" | undefined;
|
|
684
|
+
let lastSpreadText = "";
|
|
685
|
+
let anySpreadCarries = false;
|
|
686
|
+
for (const property of literal.properties) {
|
|
687
|
+
if (ts.isSpreadAssignment(property)) {
|
|
688
|
+
const scan = spreadCarriesDeployment(property.expression, source, new Set());
|
|
689
|
+
if (scan.status === "refused") {
|
|
690
|
+
return { status: "refused", reason: `The declaration spreads ${scan.reason}` };
|
|
691
|
+
}
|
|
692
|
+
if (scan.carries) {
|
|
693
|
+
anySpreadCarries = true;
|
|
694
|
+
lastSource = "spread";
|
|
695
|
+
lastSpreadText = property.expression.getText(source);
|
|
696
|
+
}
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
const named = matchDeploymentProperty(property, "declaration");
|
|
700
|
+
if (named.status === "refused") return named;
|
|
701
|
+
if (named.property === undefined) continue;
|
|
702
|
+
if (explicit !== undefined) {
|
|
703
|
+
return {
|
|
704
|
+
status: "refused",
|
|
705
|
+
reason: "The declaration declares `deployment` more than once; keep a single property.",
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
explicit = named.property;
|
|
709
|
+
lastSource = "explicit";
|
|
710
|
+
}
|
|
711
|
+
if (lastSource === "spread") {
|
|
712
|
+
return {
|
|
713
|
+
status: "refused",
|
|
714
|
+
reason: `The declaration receives its deployment key through \`...${lastSpreadText}\`; hoist that key into a plain \`deployment\` property by hand before migrating.`,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
return { status: "ok", property: explicit, spreadCarriesDeployment: anySpreadCarries };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Match one non-spread object-literal element against the `deployment`
|
|
722
|
+
* name. Only a plain `deployment: <expr>` assignment can be rewritten;
|
|
723
|
+
* shorthand, accessor, method, and computed spellings refuse.
|
|
724
|
+
*/
|
|
725
|
+
function matchDeploymentProperty(
|
|
726
|
+
property: TS.ObjectLiteralElementLike,
|
|
727
|
+
context: string,
|
|
728
|
+
): NamedPropertyLookup {
|
|
729
|
+
const propertyName = property.name;
|
|
730
|
+
if (propertyName === undefined) return { status: "ok", property: undefined };
|
|
731
|
+
if (ts.isComputedPropertyName(propertyName)) {
|
|
732
|
+
const inner = unwrapExpression(propertyName.expression);
|
|
733
|
+
if (ts.isStringLiteralLike(inner)) {
|
|
734
|
+
if (inner.text !== DEPLOYMENT_PROPERTY_NAME) return { status: "ok", property: undefined };
|
|
735
|
+
return {
|
|
736
|
+
status: "refused",
|
|
737
|
+
reason: `The ${context} spells \`${DEPLOYMENT_PROPERTY_NAME}\` as a computed property name; write it as a plain property first.`,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
return {
|
|
741
|
+
status: "refused",
|
|
742
|
+
reason: `The ${context} carries a computed property whose name is not static; it could evaluate to \`${DEPLOYMENT_PROPERTY_NAME}\`, so the transform refuses.`,
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
if (!(ts.isIdentifier(propertyName) || ts.isStringLiteralLike(propertyName))) {
|
|
746
|
+
return { status: "ok", property: undefined };
|
|
747
|
+
}
|
|
748
|
+
if (propertyName.text !== DEPLOYMENT_PROPERTY_NAME) return { status: "ok", property: undefined };
|
|
749
|
+
if (ts.isPropertyAssignment(property)) return { status: "ok", property };
|
|
750
|
+
return {
|
|
751
|
+
status: "refused",
|
|
752
|
+
reason: `The ${context}'s \`${DEPLOYMENT_PROPERTY_NAME}\` property is a ${ts.isShorthandPropertyAssignment(property) ? "shorthand" : "method or accessor"} form the transform cannot rewrite.`,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
type SpreadScan =
|
|
757
|
+
| { readonly status: "ok"; readonly carries: boolean }
|
|
758
|
+
| { readonly status: "refused"; readonly reason: string };
|
|
759
|
+
|
|
760
|
+
/** Whether a spread expression statically resolves to an object that owns a `deployment` key. */
|
|
761
|
+
function spreadCarriesDeployment(
|
|
762
|
+
expression: TS.Expression,
|
|
763
|
+
source: TS.SourceFile,
|
|
764
|
+
seen: Set<string>,
|
|
765
|
+
): SpreadScan {
|
|
766
|
+
const expr = unwrapExpression(expression);
|
|
767
|
+
if (ts.isObjectLiteralExpression(expr)) return objectCarriesDeployment(expr, source, seen);
|
|
768
|
+
if (ts.isIdentifier(expr)) {
|
|
769
|
+
if (seen.has(expr.text)) {
|
|
770
|
+
return {
|
|
771
|
+
status: "refused",
|
|
772
|
+
reason: `\`...${expr.text}\`, which is defined in terms of itself.`,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
const declaration = findTopLevelConst(source, expr.text);
|
|
776
|
+
if (declaration?.initializer !== undefined) {
|
|
777
|
+
const initializer = unwrapExpression(declaration.initializer);
|
|
778
|
+
if (ts.isObjectLiteralExpression(initializer)) {
|
|
779
|
+
if (!identifierUsesAreReadOnly(source, expr.text)) {
|
|
780
|
+
return {
|
|
781
|
+
status: "refused",
|
|
782
|
+
reason: `\`...${expr.text}\`, which is mutated, aliased, or passed to a call after its initializer, so the transform cannot prove what it carries.`,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
const next = new Set(seen);
|
|
786
|
+
next.add(expr.text);
|
|
787
|
+
return objectCarriesDeployment(initializer, source, next);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
status: "refused",
|
|
792
|
+
reason: `\`...${expr.text}\`, which does not resolve to a top-level const object literal, so the transform cannot prove it carries no \`deployment\` key.`,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
return {
|
|
796
|
+
status: "refused",
|
|
797
|
+
reason: `\`...${expr.getText(source)}\`, which is not a statically inspectable object, so the transform cannot prove it carries no \`deployment\` key.`,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function objectCarriesDeployment(
|
|
802
|
+
literal: TS.ObjectLiteralExpression,
|
|
803
|
+
source: TS.SourceFile,
|
|
804
|
+
seen: Set<string>,
|
|
805
|
+
): SpreadScan {
|
|
806
|
+
let carries = false;
|
|
807
|
+
for (const property of literal.properties) {
|
|
808
|
+
if (ts.isSpreadAssignment(property)) {
|
|
809
|
+
const nested = spreadCarriesDeployment(property.expression, source, seen);
|
|
810
|
+
if (nested.status === "refused") return nested;
|
|
811
|
+
carries = carries || nested.carries;
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
const name = property.name;
|
|
815
|
+
if (name === undefined) continue;
|
|
816
|
+
if (ts.isComputedPropertyName(name)) {
|
|
817
|
+
const inner = unwrapExpression(name.expression);
|
|
818
|
+
if (!ts.isStringLiteralLike(inner)) {
|
|
819
|
+
return {
|
|
820
|
+
status: "refused",
|
|
821
|
+
reason: `an object with a computed property name that is not static, so the transform cannot prove it carries no \`deployment\` key.`,
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
if (inner.text === DEPLOYMENT_PROPERTY_NAME) carries = true;
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
if (
|
|
828
|
+
(ts.isIdentifier(name) || ts.isStringLiteralLike(name)) &&
|
|
829
|
+
name.text === DEPLOYMENT_PROPERTY_NAME
|
|
830
|
+
) {
|
|
831
|
+
carries = true;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return { status: "ok", carries };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Name of the variable the `defineProvider(...)` call is bound to — only
|
|
839
|
+
* when that binding is a top-level `const` that is never reassigned, so a
|
|
840
|
+
* later `builder({ operations })` call provably builds THIS declaration.
|
|
841
|
+
*/
|
|
842
|
+
function builderVariableName(call: TS.CallExpression, source: TS.SourceFile): string | undefined {
|
|
843
|
+
const parent = call.parent;
|
|
844
|
+
if (!ts.isVariableDeclaration(parent) || !ts.isIdentifier(parent.name)) return undefined;
|
|
845
|
+
const list = parent.parent;
|
|
846
|
+
if (!ts.isVariableDeclarationList(list) || (list.flags & ts.NodeFlags.Const) === 0)
|
|
847
|
+
return undefined;
|
|
848
|
+
const statement = list.parent;
|
|
849
|
+
if (!ts.isVariableStatement(statement) || statement.parent !== source) return undefined;
|
|
850
|
+
return isReassigned(source, parent.name.text) ? undefined : parent.name.text;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Whether any expression assigns to (or increments) the binding `name`. */
|
|
854
|
+
function isReassigned(source: TS.SourceFile, name: string): boolean {
|
|
855
|
+
let reassigned = false;
|
|
856
|
+
const visit = (node: TS.Node): void => {
|
|
857
|
+
if (reassigned) return;
|
|
858
|
+
if (ts.isIdentifier(node) && node.text === name && isIdentifierReference(node)) {
|
|
859
|
+
const parent = node.parent;
|
|
860
|
+
if (
|
|
861
|
+
(ts.isBinaryExpression(parent) &&
|
|
862
|
+
parent.left === node &&
|
|
863
|
+
parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
|
864
|
+
parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment) ||
|
|
865
|
+
ts.isPrefixUnaryExpression(parent) ||
|
|
866
|
+
ts.isPostfixUnaryExpression(parent)
|
|
867
|
+
) {
|
|
868
|
+
reassigned = true;
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
ts.forEachChild(node, visit);
|
|
873
|
+
};
|
|
874
|
+
visit(source);
|
|
875
|
+
return reassigned;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// ── Default-export spread ─────────────────────────────────────────────────
|
|
879
|
+
|
|
880
|
+
type SpreadDeploymentValue =
|
|
881
|
+
| { readonly kind: "literal"; readonly value: unknown }
|
|
882
|
+
| {
|
|
883
|
+
readonly kind: "const";
|
|
884
|
+
readonly value: unknown;
|
|
885
|
+
readonly name: string;
|
|
886
|
+
readonly statement: TS.VariableStatement;
|
|
887
|
+
}
|
|
888
|
+
| { readonly kind: "deploy-import" };
|
|
889
|
+
|
|
890
|
+
type SpreadExportAnalysis =
|
|
891
|
+
| {
|
|
892
|
+
readonly status: "ok";
|
|
893
|
+
readonly exportAssignment?: TS.ExportAssignment;
|
|
894
|
+
readonly literal?: TS.ObjectLiteralExpression;
|
|
895
|
+
readonly deploymentProperty?: {
|
|
896
|
+
readonly node: TS.PropertyAssignment | TS.ShorthandPropertyAssignment;
|
|
897
|
+
readonly value: SpreadDeploymentValue;
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
| { readonly status: "refused"; readonly reason: string };
|
|
901
|
+
|
|
902
|
+
function analyzeSpreadExport(
|
|
903
|
+
source: TS.SourceFile,
|
|
904
|
+
declarationCall: TS.CallExpression,
|
|
905
|
+
builderName: string | undefined,
|
|
906
|
+
): SpreadExportAnalysis {
|
|
907
|
+
const exportAssignment = findDefaultExport(source);
|
|
908
|
+
if (exportAssignment === undefined) return { status: "ok" };
|
|
909
|
+
const exported = unwrapExpression(exportAssignment.expression);
|
|
910
|
+
if (ts.isIdentifier(exported)) {
|
|
911
|
+
// `export default provider;` — the binding must reach the export
|
|
912
|
+
// untouched (`provider.deployment!.replicas = 2` would make the
|
|
913
|
+
// declaration key the transform rewrites not the effective one).
|
|
914
|
+
if (
|
|
915
|
+
findTopLevelConst(source, exported.text) !== undefined &&
|
|
916
|
+
!identifierUsesAreReadOnly(source, exported.text, new Set(), "provider")
|
|
917
|
+
) {
|
|
918
|
+
return {
|
|
919
|
+
status: "refused",
|
|
920
|
+
reason: `The default export \`${exported.text}\` is mutated, aliased, or passed to a call before it is exported, so the declaration's deployment key may not be the effective one; reconcile by hand.`,
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
return { status: "ok" };
|
|
924
|
+
}
|
|
925
|
+
if (!ts.isObjectLiteralExpression(exported)) return { status: "ok" };
|
|
926
|
+
if (!exported.properties.some(ts.isSpreadAssignment)) return { status: "ok" };
|
|
927
|
+
|
|
928
|
+
let deploymentProperty: Extract<SpreadExportAnalysis, { status: "ok" }>["deploymentProperty"];
|
|
929
|
+
let deploymentIndex = -1;
|
|
930
|
+
let providerSpreadIndex = -1;
|
|
931
|
+
let lastDeploymentSpreadIndex = -1;
|
|
932
|
+
for (const [index, property] of exported.properties.entries()) {
|
|
933
|
+
if (ts.isSpreadAssignment(property)) {
|
|
934
|
+
if (isBuiltProviderSpread(property.expression, source, declarationCall, builderName)) {
|
|
935
|
+
providerSpreadIndex = index;
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
// Any other spread must be statically inspectable: a spread the
|
|
939
|
+
// transform cannot see into could carry (and, if it comes later,
|
|
940
|
+
// override) the deployment key it is about to hoist.
|
|
941
|
+
const scan = spreadCarriesDeployment(property.expression, source, new Set());
|
|
942
|
+
if (scan.status === "refused") {
|
|
943
|
+
return { status: "refused", reason: `The default export spreads ${scan.reason}` };
|
|
944
|
+
}
|
|
945
|
+
if (scan.carries) lastDeploymentSpreadIndex = index;
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
const named = matchDeploymentProperty(property, "default export");
|
|
949
|
+
if (named.status === "refused") {
|
|
950
|
+
// Shorthand `deployment` on the export is a legitimate legacy shape;
|
|
951
|
+
// resolve the identifier instead of refusing it.
|
|
952
|
+
if (
|
|
953
|
+
ts.isShorthandPropertyAssignment(property) &&
|
|
954
|
+
property.name.text === DEPLOYMENT_PROPERTY_NAME
|
|
955
|
+
) {
|
|
956
|
+
if (deploymentProperty !== undefined) return duplicateExportDeployment();
|
|
957
|
+
const value = resolveSpreadValueIdentifier(source, property.name);
|
|
958
|
+
if (value.status === "refused") return value;
|
|
959
|
+
deploymentProperty = { node: property, value: value.value };
|
|
960
|
+
deploymentIndex = index;
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
return named;
|
|
964
|
+
}
|
|
965
|
+
if (named.property === undefined) continue;
|
|
966
|
+
if (deploymentProperty !== undefined) return duplicateExportDeployment();
|
|
967
|
+
const initializer = unwrapExpression(named.property.initializer);
|
|
968
|
+
if (ts.isIdentifier(initializer)) {
|
|
969
|
+
const value = resolveSpreadValueIdentifier(source, initializer);
|
|
970
|
+
if (value.status === "refused") return value;
|
|
971
|
+
deploymentProperty = { node: named.property, value: value.value };
|
|
972
|
+
} else {
|
|
973
|
+
const evaluated = evaluateStatic(initializer, source);
|
|
974
|
+
if (!evaluated.ok) {
|
|
975
|
+
return {
|
|
976
|
+
status: "refused",
|
|
977
|
+
reason: `The default export's deployment property is not a static literal: ${evaluated.reason}`,
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
deploymentProperty = {
|
|
981
|
+
node: named.property,
|
|
982
|
+
value: { kind: "literal", value: evaluated.value },
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
deploymentIndex = index;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
if (deploymentProperty === undefined) {
|
|
989
|
+
if (lastDeploymentSpreadIndex >= 0) {
|
|
990
|
+
return {
|
|
991
|
+
status: "refused",
|
|
992
|
+
reason:
|
|
993
|
+
"The default export receives a deployment key through a spread; hoist that key into the declaration by hand before migrating.",
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
return { status: "ok", exportAssignment, literal: exported };
|
|
997
|
+
}
|
|
998
|
+
if (providerSpreadIndex < 0) {
|
|
999
|
+
return {
|
|
1000
|
+
status: "refused",
|
|
1001
|
+
reason:
|
|
1002
|
+
"The default export carries a deployment property but does not spread the built provider (or that binding is mutated afterwards), so a key hoisted into the declaration would never reach the export; reconcile by hand.",
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
if (providerSpreadIndex > deploymentIndex) {
|
|
1006
|
+
return {
|
|
1007
|
+
status: "refused",
|
|
1008
|
+
reason:
|
|
1009
|
+
"The default export spreads the built provider after its deployment property, so the property may not be the effective deployment; move the spread first or reconcile by hand.",
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
if (lastDeploymentSpreadIndex >= 0) {
|
|
1013
|
+
// After the property: it is not the effective deployment. Before it:
|
|
1014
|
+
// removing the property would expose the spread's deployment.
|
|
1015
|
+
return {
|
|
1016
|
+
status: "refused",
|
|
1017
|
+
reason:
|
|
1018
|
+
"The default export also spreads an object that carries a deployment key, so removing its deployment property would change the effective deployment; reconcile by hand.",
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
return { status: "ok", exportAssignment, literal: exported, deploymentProperty };
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function duplicateExportDeployment(): SpreadExportAnalysis {
|
|
1025
|
+
return {
|
|
1026
|
+
status: "refused",
|
|
1027
|
+
reason: "The default export declares `deployment` more than once; keep a single property.",
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* `...provider` where `provider` is the declaration's built provider: bound
|
|
1033
|
+
* to a call of the declaration builder (`const provider = buildProvider({
|
|
1034
|
+
* operations })`), to the `defineProvider(...)` call itself (single-phase
|
|
1035
|
+
* shape), or that call inline. The built provider carries exactly the
|
|
1036
|
+
* declaration's deployment key, which the transform already accounts for.
|
|
1037
|
+
*/
|
|
1038
|
+
function isBuiltProviderSpread(
|
|
1039
|
+
expression: TS.Expression,
|
|
1040
|
+
source: TS.SourceFile,
|
|
1041
|
+
declarationCall: TS.CallExpression,
|
|
1042
|
+
builderName: string | undefined,
|
|
1043
|
+
): boolean {
|
|
1044
|
+
let expr = unwrapExpression(expression);
|
|
1045
|
+
if (ts.isIdentifier(expr)) {
|
|
1046
|
+
const declaration = findTopLevelConst(source, expr.text);
|
|
1047
|
+
if (declaration?.initializer === undefined) return false;
|
|
1048
|
+
// A binding mutated after the builder call (`provider.deployment = …`)
|
|
1049
|
+
// no longer carries exactly the declaration's deployment.
|
|
1050
|
+
if (!identifierUsesAreReadOnly(source, expr.text, new Set(), "provider")) return false;
|
|
1051
|
+
expr = unwrapExpression(declaration.initializer);
|
|
1052
|
+
}
|
|
1053
|
+
if (expr === declarationCall) return true;
|
|
1054
|
+
return (
|
|
1055
|
+
builderName !== undefined &&
|
|
1056
|
+
ts.isCallExpression(expr) &&
|
|
1057
|
+
ts.isIdentifier(expr.expression) &&
|
|
1058
|
+
expr.expression.text === builderName
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function resolveSpreadValueIdentifier(
|
|
1063
|
+
source: TS.SourceFile,
|
|
1064
|
+
identifier: TS.Identifier,
|
|
1065
|
+
):
|
|
1066
|
+
| { readonly status: "ok"; readonly value: SpreadDeploymentValue }
|
|
1067
|
+
| { readonly status: "refused"; readonly reason: string } {
|
|
1068
|
+
const imports = collectLegacyDeployImports(source);
|
|
1069
|
+
if (imports.status === "ok" && imports.defaultImport === identifier.text) {
|
|
1070
|
+
return { status: "ok", value: { kind: "deploy-import" } };
|
|
1071
|
+
}
|
|
1072
|
+
const declaration = findTopLevelConst(source, identifier.text);
|
|
1073
|
+
if (declaration?.initializer !== undefined) {
|
|
1074
|
+
// Evaluate the identifier itself so the binding's other uses are
|
|
1075
|
+
// checked for mutation, not just its initializer text.
|
|
1076
|
+
const evaluated = evaluateStatic(identifier, source);
|
|
1077
|
+
if (!evaluated.ok) {
|
|
1078
|
+
return {
|
|
1079
|
+
status: "refused",
|
|
1080
|
+
reason: `\`${identifier.text}\` (attached as deployment on the default export) is not a static literal: ${evaluated.reason}`,
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
const statement = declaration.parent.parent;
|
|
1084
|
+
if (!ts.isVariableStatement(statement)) {
|
|
1085
|
+
return {
|
|
1086
|
+
status: "refused",
|
|
1087
|
+
reason: `\`${identifier.text}\` is not declared by a plain variable statement.`,
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
return {
|
|
1091
|
+
status: "ok",
|
|
1092
|
+
value: { kind: "const", value: evaluated.value, name: identifier.text, statement },
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
return {
|
|
1096
|
+
status: "refused",
|
|
1097
|
+
reason: `\`${identifier.text}\` (attached as deployment on the default export) does not resolve to a top-level const or the ./deploy default import.`,
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function removeSpreadDeploymentProperty(
|
|
1102
|
+
spread: Extract<SpreadExportAnalysis, { status: "ok" }>,
|
|
1103
|
+
declaration: TS.ObjectLiteralExpression,
|
|
1104
|
+
source: TS.SourceFile,
|
|
1105
|
+
): TextEdit[] {
|
|
1106
|
+
const { exportAssignment, literal, deploymentProperty } = spread;
|
|
1107
|
+
if (exportAssignment === undefined || literal === undefined || deploymentProperty === undefined)
|
|
1108
|
+
return [];
|
|
1109
|
+
const remaining = literal.properties.filter((property) => property !== deploymentProperty.node);
|
|
1110
|
+
const onlySpread = remaining[0];
|
|
1111
|
+
// Folding re-emits the spread's source text; when the declaration literal
|
|
1112
|
+
// lives inside it (`{ ...defineProvider({...}), deployment }`) that text
|
|
1113
|
+
// would overlap the declaration edits, so only the property is removed.
|
|
1114
|
+
const declarationInsideSpread =
|
|
1115
|
+
onlySpread !== undefined &&
|
|
1116
|
+
declaration.getStart(source) >= onlySpread.getStart(source) &&
|
|
1117
|
+
declaration.getEnd() <= onlySpread.getEnd();
|
|
1118
|
+
if (
|
|
1119
|
+
remaining.length === 1 &&
|
|
1120
|
+
onlySpread !== undefined &&
|
|
1121
|
+
ts.isSpreadAssignment(onlySpread) &&
|
|
1122
|
+
!declarationInsideSpread
|
|
1123
|
+
) {
|
|
1124
|
+
// `export default { ...provider }` folds to `export default provider;`.
|
|
1125
|
+
return [
|
|
1126
|
+
{
|
|
1127
|
+
start: exportAssignment.expression.getStart(source),
|
|
1128
|
+
end: exportAssignment.expression.getEnd(),
|
|
1129
|
+
text: onlySpread.expression.getText(source),
|
|
1130
|
+
},
|
|
1131
|
+
];
|
|
1132
|
+
}
|
|
1133
|
+
return [removeObjectProperty(deploymentProperty.node, literal, source)];
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// ── ./deploy imports ──────────────────────────────────────────────────────
|
|
1137
|
+
|
|
1138
|
+
type LegacyDeployImports =
|
|
1139
|
+
| {
|
|
1140
|
+
readonly status: "ok";
|
|
1141
|
+
/** Local name bound by `import <name> from "./deploy"`, when present. */
|
|
1142
|
+
readonly defaultImport?: string;
|
|
1143
|
+
/** Import declarations that only reference ./deploy and can be dropped. */
|
|
1144
|
+
readonly removable: readonly TS.ImportDeclaration[];
|
|
1145
|
+
/**
|
|
1146
|
+
* Local names of type-only bindings imported from ./deploy (`import
|
|
1147
|
+
* type Config from`, `{ type Config }`). The file is deleted with the
|
|
1148
|
+
* import, so any remaining reference to these names would no longer
|
|
1149
|
+
* type-check; the transform refuses when one survives.
|
|
1150
|
+
*/
|
|
1151
|
+
readonly typeOnlyNames: readonly string[];
|
|
1152
|
+
}
|
|
1153
|
+
| { readonly status: "refused"; readonly reason: string };
|
|
1154
|
+
|
|
1155
|
+
function collectLegacyDeployImports(source: TS.SourceFile): LegacyDeployImports {
|
|
1156
|
+
let defaultImport: string | undefined;
|
|
1157
|
+
const removable: TS.ImportDeclaration[] = [];
|
|
1158
|
+
const typeOnlyNames: string[] = [];
|
|
1159
|
+
const collectNamed = (bindings: TS.NamedImportBindings | undefined): void => {
|
|
1160
|
+
if (bindings === undefined) return;
|
|
1161
|
+
if (ts.isNamedImports(bindings)) {
|
|
1162
|
+
for (const element of bindings.elements) typeOnlyNames.push(element.name.text);
|
|
1163
|
+
} else {
|
|
1164
|
+
typeOnlyNames.push(bindings.name.text);
|
|
1165
|
+
}
|
|
1166
|
+
};
|
|
1167
|
+
for (const statement of source.statements) {
|
|
1168
|
+
if (ts.isExportDeclaration(statement)) {
|
|
1169
|
+
const specifier = statement.moduleSpecifier;
|
|
1170
|
+
if (
|
|
1171
|
+
specifier !== undefined &&
|
|
1172
|
+
ts.isStringLiteralLike(specifier) &&
|
|
1173
|
+
isDeployModuleSpecifier(specifier.text, INDEX_VIRTUAL_DIRECTORY, DEPLOY_MODULE_VIRTUAL_PATH)
|
|
1174
|
+
) {
|
|
1175
|
+
return {
|
|
1176
|
+
status: "refused",
|
|
1177
|
+
reason:
|
|
1178
|
+
"index.ts re-exports from ./deploy; remove that re-export by hand before deleting the file.",
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
if (!ts.isImportDeclaration(statement)) continue;
|
|
1184
|
+
const specifier = statement.moduleSpecifier;
|
|
1185
|
+
if (
|
|
1186
|
+
!ts.isStringLiteralLike(specifier) ||
|
|
1187
|
+
!isDeployModuleSpecifier(specifier.text, INDEX_VIRTUAL_DIRECTORY, DEPLOY_MODULE_VIRTUAL_PATH)
|
|
1188
|
+
) {
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
const clause = statement.importClause;
|
|
1192
|
+
if (clause === undefined) {
|
|
1193
|
+
return {
|
|
1194
|
+
status: "refused",
|
|
1195
|
+
reason: "index.ts has a side-effect import of ./deploy; remove it by hand.",
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
if (clause.isTypeOnly) {
|
|
1199
|
+
if (clause.name !== undefined) typeOnlyNames.push(clause.name.text);
|
|
1200
|
+
collectNamed(clause.namedBindings);
|
|
1201
|
+
removable.push(statement);
|
|
1202
|
+
continue;
|
|
1203
|
+
}
|
|
1204
|
+
if (clause.namedBindings !== undefined) {
|
|
1205
|
+
if (
|
|
1206
|
+
ts.isNamedImports(clause.namedBindings) &&
|
|
1207
|
+
clause.namedBindings.elements.every((element) => element.isTypeOnly)
|
|
1208
|
+
) {
|
|
1209
|
+
// `{ type Config }` alongside the default binding, or alone.
|
|
1210
|
+
collectNamed(clause.namedBindings);
|
|
1211
|
+
if (clause.name === undefined) {
|
|
1212
|
+
removable.push(statement);
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
} else {
|
|
1216
|
+
return {
|
|
1217
|
+
status: "refused",
|
|
1218
|
+
reason:
|
|
1219
|
+
"index.ts imports named or namespace bindings from ./deploy; only the default export can be hoisted.",
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
if (clause.name !== undefined) {
|
|
1224
|
+
if (defaultImport !== undefined) {
|
|
1225
|
+
return { status: "refused", reason: "index.ts imports ./deploy more than once." };
|
|
1226
|
+
}
|
|
1227
|
+
defaultImport = clause.name.text;
|
|
1228
|
+
removable.push(statement);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
return {
|
|
1232
|
+
status: "ok",
|
|
1233
|
+
...(defaultImport === undefined ? {} : { defaultImport }),
|
|
1234
|
+
removable,
|
|
1235
|
+
typeOnlyNames,
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function referencesLegacyDeployModule(source: TS.SourceFile): boolean {
|
|
1240
|
+
return sourceImportsModule(source, INDEX_VIRTUAL_DIRECTORY, DEPLOY_MODULE_VIRTUAL_PATH);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Whether `source` (located in `fromDirectory`) imports the module at
|
|
1245
|
+
* `moduleAbsolutePath` (extension-less) through any specifier position:
|
|
1246
|
+
* static import/re-export, dynamic `import()`, `require()`, `import x =
|
|
1247
|
+
* require()`, or an `import("…")` type. Template-literal specifiers count.
|
|
1248
|
+
*/
|
|
1249
|
+
function sourceImportsModule(
|
|
1250
|
+
source: TS.SourceFile,
|
|
1251
|
+
fromDirectory: string,
|
|
1252
|
+
moduleAbsolutePath: string,
|
|
1253
|
+
): boolean {
|
|
1254
|
+
let found = false;
|
|
1255
|
+
const visit = (node: TS.Node): void => {
|
|
1256
|
+
if (found) return;
|
|
1257
|
+
if (
|
|
1258
|
+
ts.isStringLiteralLike(node) &&
|
|
1259
|
+
isModuleSpecifierPosition(node) &&
|
|
1260
|
+
isDeployModuleSpecifier(node.text, fromDirectory, moduleAbsolutePath)
|
|
1261
|
+
) {
|
|
1262
|
+
found = true;
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
ts.forEachChild(node, visit);
|
|
1266
|
+
};
|
|
1267
|
+
visit(source);
|
|
1268
|
+
return found;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function isModuleSpecifierPosition(node: TS.StringLiteralLike): boolean {
|
|
1272
|
+
const parent = node.parent;
|
|
1273
|
+
return (
|
|
1274
|
+
ts.isImportDeclaration(parent) ||
|
|
1275
|
+
ts.isExportDeclaration(parent) ||
|
|
1276
|
+
ts.isExternalModuleReference(parent) ||
|
|
1277
|
+
(ts.isCallExpression(parent) &&
|
|
1278
|
+
parent.arguments[0] === node &&
|
|
1279
|
+
(parent.expression.kind === ts.SyntaxKind.ImportKeyword ||
|
|
1280
|
+
(ts.isIdentifier(parent.expression) && parent.expression.text === "require"))) ||
|
|
1281
|
+
(ts.isLiteralTypeNode(parent) && ts.isImportTypeNode(parent.parent))
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/** A relative specifier that resolves (extension-less) to `moduleAbsolutePath`. */
|
|
1286
|
+
function isDeployModuleSpecifier(
|
|
1287
|
+
specifier: string,
|
|
1288
|
+
fromDirectory: string,
|
|
1289
|
+
moduleAbsolutePath: string,
|
|
1290
|
+
): boolean {
|
|
1291
|
+
if (!specifier.startsWith(".")) return false;
|
|
1292
|
+
const resolved = path.resolve(fromDirectory, specifier);
|
|
1293
|
+
// Strip the extension from the file name only; a directory segment such
|
|
1294
|
+
// as `/srv/provider.ts/` must survive untouched.
|
|
1295
|
+
const withoutExtension = path.join(
|
|
1296
|
+
path.dirname(resolved),
|
|
1297
|
+
path.basename(resolved).replace(SOURCE_EXTENSION, ""),
|
|
1298
|
+
);
|
|
1299
|
+
return withoutExtension === moduleAbsolutePath;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// ── Static evaluation ─────────────────────────────────────────────────────
|
|
1303
|
+
|
|
1304
|
+
type StaticValue =
|
|
1305
|
+
| { readonly ok: true; readonly value: unknown }
|
|
1306
|
+
| { readonly ok: false; readonly reason: string };
|
|
1307
|
+
|
|
1308
|
+
function evaluateStatic(
|
|
1309
|
+
node: TS.Expression,
|
|
1310
|
+
source: TS.SourceFile,
|
|
1311
|
+
seen: Set<string> = new Set(),
|
|
1312
|
+
): StaticValue {
|
|
1313
|
+
const expression = unwrapExpression(node);
|
|
1314
|
+
if (ts.isObjectLiteralExpression(expression)) {
|
|
1315
|
+
const value: Record<string, unknown> = {};
|
|
1316
|
+
for (const property of expression.properties) {
|
|
1317
|
+
if (ts.isSpreadAssignment(property)) {
|
|
1318
|
+
return { ok: false, reason: "object literal contains a spread" };
|
|
1319
|
+
}
|
|
1320
|
+
const name = property.name;
|
|
1321
|
+
if (
|
|
1322
|
+
name === undefined ||
|
|
1323
|
+
!(ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name))
|
|
1324
|
+
) {
|
|
1325
|
+
return { ok: false, reason: "object literal has a computed or non-static property name" };
|
|
1326
|
+
}
|
|
1327
|
+
if (Object.hasOwn(value, name.text)) {
|
|
1328
|
+
return { ok: false, reason: `object literal repeats property \`${name.text}\`` };
|
|
1329
|
+
}
|
|
1330
|
+
let element: StaticValue;
|
|
1331
|
+
if (ts.isPropertyAssignment(property)) {
|
|
1332
|
+
element = evaluateStatic(property.initializer, source, seen);
|
|
1333
|
+
} else if (ts.isShorthandPropertyAssignment(property)) {
|
|
1334
|
+
element = evaluateStatic(property.name, source, seen);
|
|
1335
|
+
} else {
|
|
1336
|
+
return { ok: false, reason: `property \`${name.text}\` is a method or accessor` };
|
|
1337
|
+
}
|
|
1338
|
+
if (!element.ok) return element;
|
|
1339
|
+
value[name.text] = element.value;
|
|
1340
|
+
}
|
|
1341
|
+
return { ok: true, value };
|
|
1342
|
+
}
|
|
1343
|
+
if (ts.isArrayLiteralExpression(expression)) {
|
|
1344
|
+
const items: unknown[] = [];
|
|
1345
|
+
for (const element of expression.elements) {
|
|
1346
|
+
if (ts.isSpreadElement(element) || ts.isOmittedExpression(element)) {
|
|
1347
|
+
return { ok: false, reason: "array literal contains a spread or hole" };
|
|
1348
|
+
}
|
|
1349
|
+
const item = evaluateStatic(element, source, seen);
|
|
1350
|
+
if (!item.ok) return item;
|
|
1351
|
+
items.push(item.value);
|
|
1352
|
+
}
|
|
1353
|
+
return { ok: true, value: items };
|
|
1354
|
+
}
|
|
1355
|
+
if (ts.isStringLiteralLike(expression)) return { ok: true, value: expression.text };
|
|
1356
|
+
if (ts.isNumericLiteral(expression)) return { ok: true, value: Number(expression.text) };
|
|
1357
|
+
if (
|
|
1358
|
+
ts.isPrefixUnaryExpression(expression) &&
|
|
1359
|
+
expression.operator === ts.SyntaxKind.MinusToken &&
|
|
1360
|
+
ts.isNumericLiteral(expression.operand)
|
|
1361
|
+
) {
|
|
1362
|
+
return { ok: true, value: -Number(expression.operand.text) };
|
|
1363
|
+
}
|
|
1364
|
+
if (expression.kind === ts.SyntaxKind.TrueKeyword) return { ok: true, value: true };
|
|
1365
|
+
if (expression.kind === ts.SyntaxKind.FalseKeyword) return { ok: true, value: false };
|
|
1366
|
+
if (expression.kind === ts.SyntaxKind.NullKeyword) return { ok: true, value: null };
|
|
1367
|
+
if (ts.isIdentifier(expression)) {
|
|
1368
|
+
if (expression.text === "undefined") return { ok: true, value: undefined };
|
|
1369
|
+
if (seen.has(expression.text)) {
|
|
1370
|
+
return { ok: false, reason: `\`${expression.text}\` is defined in terms of itself` };
|
|
1371
|
+
}
|
|
1372
|
+
if (!isTopLevelNode(expression)) {
|
|
1373
|
+
return {
|
|
1374
|
+
ok: false,
|
|
1375
|
+
reason: `\`${expression.text}\` is referenced inside a nested scope where a local binding could shadow the top-level const`,
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
const declaration = findTopLevelConst(source, expression.text);
|
|
1379
|
+
if (declaration?.initializer === undefined) {
|
|
1380
|
+
return {
|
|
1381
|
+
ok: false,
|
|
1382
|
+
reason: `\`${expression.text}\` does not resolve to a top-level const with a literal initializer`,
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
if (!identifierUsesAreReadOnly(source, expression.text)) {
|
|
1386
|
+
return {
|
|
1387
|
+
ok: false,
|
|
1388
|
+
reason: `\`${expression.text}\` is mutated, aliased, or passed to a call after its initializer, so the initializer may not be its effective value`,
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
const next = new Set(seen);
|
|
1392
|
+
next.add(expression.text);
|
|
1393
|
+
return evaluateStatic(declaration.initializer, source, next);
|
|
1394
|
+
}
|
|
1395
|
+
return { ok: false, reason: `${ts.SyntaxKind[expression.kind]} is not a static literal` };
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function unwrapExpression(node: TS.Expression): TS.Expression {
|
|
1399
|
+
let current = node;
|
|
1400
|
+
for (;;) {
|
|
1401
|
+
if (ts.isParenthesizedExpression(current)) current = current.expression;
|
|
1402
|
+
else if (ts.isAsExpression(current)) current = current.expression;
|
|
1403
|
+
else if (ts.isSatisfiesExpression(current)) current = current.expression;
|
|
1404
|
+
else if (ts.isTypeAssertionExpression(current)) current = current.expression;
|
|
1405
|
+
else if (ts.isNonNullExpression(current)) current = current.expression;
|
|
1406
|
+
else return current;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function findTopLevelConst(
|
|
1411
|
+
source: TS.SourceFile,
|
|
1412
|
+
name: string,
|
|
1413
|
+
): TS.VariableDeclaration | undefined {
|
|
1414
|
+
for (const statement of source.statements) {
|
|
1415
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
1416
|
+
if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
|
|
1417
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
1418
|
+
if (ts.isIdentifier(declaration.name) && declaration.name.text === name) {
|
|
1419
|
+
return statement.declarationList.declarations.length === 1 ? declaration : undefined;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
return undefined;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/** Whether an identifier node is a reference (not a declared or property name). */
|
|
1427
|
+
function isIdentifierReference(node: TS.Identifier): boolean {
|
|
1428
|
+
const parent = node.parent;
|
|
1429
|
+
const isDeclaredOrPropertyName =
|
|
1430
|
+
(ts.isPropertyAssignment(parent) && parent.name === node) ||
|
|
1431
|
+
(ts.isPropertyAccessExpression(parent) && parent.name === node) ||
|
|
1432
|
+
(ts.isMethodDeclaration(parent) && parent.name === node) ||
|
|
1433
|
+
(ts.isPropertySignature(parent) && parent.name === node) ||
|
|
1434
|
+
(ts.isImportSpecifier(parent) && parent.propertyName === node) ||
|
|
1435
|
+
ts.isImportClause(parent) ||
|
|
1436
|
+
(ts.isVariableDeclaration(parent) && parent.name === node);
|
|
1437
|
+
return !isDeclaredOrPropertyName;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/** Identifier references to `name` in expression position (property names excluded). */
|
|
1441
|
+
function countIdentifierReferences(source: TS.SourceFile, name: string): number {
|
|
1442
|
+
let count = 0;
|
|
1443
|
+
const visit = (node: TS.Node): void => {
|
|
1444
|
+
if (ts.isIdentifier(node) && node.text === name && isIdentifierReference(node)) count += 1;
|
|
1445
|
+
ts.forEachChild(node, visit);
|
|
1446
|
+
};
|
|
1447
|
+
visit(source);
|
|
1448
|
+
return count;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* True when every reference to the top-level binding `name` is a plain read
|
|
1453
|
+
* the transform accounts for: spread into an object literal, a property
|
|
1454
|
+
* value, a shorthand property, the default export, or a `defineProvider()`
|
|
1455
|
+
* argument. Anything else — a member assignment, an alias, a call that
|
|
1456
|
+
* receives the object — could change or observe the value after its
|
|
1457
|
+
* initializer, so callers refuse instead of trusting the literal.
|
|
1458
|
+
*/
|
|
1459
|
+
/**
|
|
1460
|
+
* What member access on the binding may mean:
|
|
1461
|
+
* - `"value"` (a deployment config or declaration object): every member is
|
|
1462
|
+
* deployment-relevant, so any member access — `config.resources.memory =
|
|
1463
|
+
* …`, `Object.assign(config.resources, …)`, `….push(…)` — disqualifies it.
|
|
1464
|
+
* - `"provider"` (a built provider): only `.deployment` matters; other member
|
|
1465
|
+
* reads such as `provider.operations.x.handler(…)` are fine unless they are
|
|
1466
|
+
* assignment, delete, or update targets.
|
|
1467
|
+
*/
|
|
1468
|
+
type BindingKind = "value" | "provider";
|
|
1469
|
+
|
|
1470
|
+
function identifierUsesAreReadOnly(
|
|
1471
|
+
source: TS.SourceFile,
|
|
1472
|
+
name: string,
|
|
1473
|
+
seen: ReadonlySet<string> = new Set(),
|
|
1474
|
+
kind: BindingKind = "value",
|
|
1475
|
+
): boolean {
|
|
1476
|
+
if (seen.has(name)) return true;
|
|
1477
|
+
const next = new Set(seen);
|
|
1478
|
+
next.add(name);
|
|
1479
|
+
let readOnly = true;
|
|
1480
|
+
const visit = (node: TS.Node): void => {
|
|
1481
|
+
if (!readOnly) return;
|
|
1482
|
+
if (ts.isIdentifier(node) && node.text === name && isIdentifierReference(node)) {
|
|
1483
|
+
let use: TS.Node = node;
|
|
1484
|
+
while (
|
|
1485
|
+
ts.isParenthesizedExpression(use.parent) ||
|
|
1486
|
+
ts.isAsExpression(use.parent) ||
|
|
1487
|
+
ts.isSatisfiesExpression(use.parent) ||
|
|
1488
|
+
ts.isNonNullExpression(use.parent)
|
|
1489
|
+
) {
|
|
1490
|
+
use = use.parent;
|
|
1491
|
+
}
|
|
1492
|
+
const parent = use.parent;
|
|
1493
|
+
let plainRead = ts.isExportAssignment(parent) || isDefineProviderArgument(parent, use);
|
|
1494
|
+
if (
|
|
1495
|
+
!plainRead &&
|
|
1496
|
+
kind === "provider" &&
|
|
1497
|
+
(ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) &&
|
|
1498
|
+
parent.expression === use
|
|
1499
|
+
) {
|
|
1500
|
+
// `provider.operations.x.handler(...)` is a read; `provider.x = …`
|
|
1501
|
+
// is not, and reaching `.deployment` directly is refused outright.
|
|
1502
|
+
plainRead = memberAccessIsRead(parent);
|
|
1503
|
+
}
|
|
1504
|
+
if (
|
|
1505
|
+
!plainRead &&
|
|
1506
|
+
(ts.isSpreadAssignment(parent) ||
|
|
1507
|
+
(ts.isPropertyAssignment(parent) && parent.initializer === use) ||
|
|
1508
|
+
ts.isShorthandPropertyAssignment(parent)) &&
|
|
1509
|
+
ts.isObjectLiteralExpression(parent.parent)
|
|
1510
|
+
) {
|
|
1511
|
+
// Placing the object (or a shallow copy sharing its nested
|
|
1512
|
+
// objects) inside another literal is only a read when that
|
|
1513
|
+
// literal itself cannot be reached and mutated afterwards.
|
|
1514
|
+
plainRead = literalIsAnchored(parent.parent, source, next);
|
|
1515
|
+
}
|
|
1516
|
+
if (!plainRead) readOnly = false;
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
ts.forEachChild(node, visit);
|
|
1520
|
+
};
|
|
1521
|
+
visit(source);
|
|
1522
|
+
return readOnly;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* A member-access chain rooted at the binding is a read unless it (or the
|
|
1527
|
+
* chain it starts) is assigned, deleted, or updated, or unless its first
|
|
1528
|
+
* member is `deployment` itself (or not statically nameable): anything
|
|
1529
|
+
* touching the deployment object directly is left to a human.
|
|
1530
|
+
*/
|
|
1531
|
+
function memberAccessIsRead(
|
|
1532
|
+
access: TS.PropertyAccessExpression | TS.ElementAccessExpression,
|
|
1533
|
+
): boolean {
|
|
1534
|
+
let firstMember: string | undefined;
|
|
1535
|
+
if (ts.isPropertyAccessExpression(access)) firstMember = access.name.text;
|
|
1536
|
+
else if (ts.isStringLiteralLike(access.argumentExpression)) {
|
|
1537
|
+
firstMember = access.argumentExpression.text;
|
|
1538
|
+
}
|
|
1539
|
+
if (firstMember === undefined || firstMember === DEPLOYMENT_PROPERTY_NAME) return false;
|
|
1540
|
+
let chain: TS.Node = access;
|
|
1541
|
+
for (;;) {
|
|
1542
|
+
const parent: TS.Node | undefined = chain.parent;
|
|
1543
|
+
if (parent === undefined) return true;
|
|
1544
|
+
if (
|
|
1545
|
+
(ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) &&
|
|
1546
|
+
parent.expression === chain
|
|
1547
|
+
) {
|
|
1548
|
+
chain = parent;
|
|
1549
|
+
continue;
|
|
1550
|
+
}
|
|
1551
|
+
if (ts.isNonNullExpression(parent) || ts.isParenthesizedExpression(parent)) {
|
|
1552
|
+
chain = parent;
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
if (
|
|
1556
|
+
ts.isBinaryExpression(parent) &&
|
|
1557
|
+
parent.left === chain &&
|
|
1558
|
+
parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
|
1559
|
+
parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment
|
|
1560
|
+
) {
|
|
1561
|
+
return false;
|
|
1562
|
+
}
|
|
1563
|
+
return !(
|
|
1564
|
+
ts.isDeleteExpression(parent) ||
|
|
1565
|
+
ts.isPrefixUnaryExpression(parent) ||
|
|
1566
|
+
ts.isPostfixUnaryExpression(parent)
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
function isDefineProviderArgument(parent: TS.Node, use: TS.Node): boolean {
|
|
1572
|
+
return (
|
|
1573
|
+
ts.isCallExpression(parent) &&
|
|
1574
|
+
ts.isIdentifier(parent.expression) &&
|
|
1575
|
+
parent.expression.text === "defineProvider" &&
|
|
1576
|
+
parent.arguments.some((argument) => argument === use)
|
|
1577
|
+
);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* An object literal is anchored when, climbing through enclosing literals
|
|
1582
|
+
* and wrappers, it ends up as the default export, a `defineProvider()`
|
|
1583
|
+
* argument, or the initializer of a top-level const whose own uses are
|
|
1584
|
+
* read-only. Anything else (a `holder` that is later dereferenced, a call
|
|
1585
|
+
* argument, a return value) may alias and mutate the contents.
|
|
1586
|
+
*/
|
|
1587
|
+
function literalIsAnchored(
|
|
1588
|
+
literal: TS.ObjectLiteralExpression,
|
|
1589
|
+
source: TS.SourceFile,
|
|
1590
|
+
seen: ReadonlySet<string>,
|
|
1591
|
+
): boolean {
|
|
1592
|
+
let node: TS.Node = literal;
|
|
1593
|
+
for (;;) {
|
|
1594
|
+
const parent: TS.Node | undefined = node.parent;
|
|
1595
|
+
if (parent === undefined) return false;
|
|
1596
|
+
if (
|
|
1597
|
+
ts.isParenthesizedExpression(parent) ||
|
|
1598
|
+
ts.isAsExpression(parent) ||
|
|
1599
|
+
ts.isSatisfiesExpression(parent) ||
|
|
1600
|
+
ts.isNonNullExpression(parent) ||
|
|
1601
|
+
ts.isPropertyAssignment(parent) ||
|
|
1602
|
+
ts.isSpreadAssignment(parent) ||
|
|
1603
|
+
ts.isObjectLiteralExpression(parent) ||
|
|
1604
|
+
ts.isArrayLiteralExpression(parent)
|
|
1605
|
+
) {
|
|
1606
|
+
node = parent;
|
|
1607
|
+
continue;
|
|
1608
|
+
}
|
|
1609
|
+
if (ts.isExportAssignment(parent)) return true;
|
|
1610
|
+
if (isDefineProviderArgument(parent, node)) return true;
|
|
1611
|
+
if (
|
|
1612
|
+
ts.isVariableDeclaration(parent) &&
|
|
1613
|
+
parent.initializer === node &&
|
|
1614
|
+
ts.isIdentifier(parent.name) &&
|
|
1615
|
+
ts.isVariableDeclarationList(parent.parent) &&
|
|
1616
|
+
(parent.parent.flags & ts.NodeFlags.Const) !== 0 &&
|
|
1617
|
+
ts.isVariableStatement(parent.parent.parent) &&
|
|
1618
|
+
parent.parent.parent.parent === source
|
|
1619
|
+
) {
|
|
1620
|
+
return identifierUsesAreReadOnly(source, parent.name.text, seen);
|
|
1621
|
+
}
|
|
1622
|
+
return false;
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
function hasExportModifier(statement: TS.VariableStatement): boolean {
|
|
1627
|
+
return (
|
|
1628
|
+
ts
|
|
1629
|
+
.getModifiers(statement)
|
|
1630
|
+
?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) === true
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// ── Emission ──────────────────────────────────────────────────────────────
|
|
1635
|
+
|
|
1636
|
+
function insertDeploymentProperty(
|
|
1637
|
+
declaration: TS.ObjectLiteralExpression,
|
|
1638
|
+
intent: ProviderDeploymentOverrides,
|
|
1639
|
+
source: TS.SourceFile,
|
|
1640
|
+
): TextEdit {
|
|
1641
|
+
const anchor =
|
|
1642
|
+
findAnchorProperty(declaration, "runtime") ??
|
|
1643
|
+
findAnchorProperty(declaration, "version") ??
|
|
1644
|
+
findAnchorProperty(declaration, "id") ??
|
|
1645
|
+
declaration.properties[0];
|
|
1646
|
+
const text = source.getFullText();
|
|
1647
|
+
if (anchor === undefined) {
|
|
1648
|
+
// Empty declaration literal: `{}`.
|
|
1649
|
+
const indent = lineIndentation(source, declaration.getStart(source));
|
|
1650
|
+
const unit = indent.startsWith("\t") ? "\t" : " ";
|
|
1651
|
+
const inner = `${indent}${unit}`;
|
|
1652
|
+
const body = renderPropertyLines(intent, inner, unit);
|
|
1653
|
+
return {
|
|
1654
|
+
start: declaration.getStart(source) + 1,
|
|
1655
|
+
end: declaration.getEnd() - 1,
|
|
1656
|
+
text: `\n${body}\n${indent}`,
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
const indent = lineIndentation(source, anchor.getStart(source));
|
|
1660
|
+
const unit = indentUnitFor(source, declaration, indent);
|
|
1661
|
+
// Insert after the anchor's trailing comma when it has one; otherwise
|
|
1662
|
+
// give the anchor a comma so the literal stays well-formed.
|
|
1663
|
+
let cursor = anchor.getEnd();
|
|
1664
|
+
while (cursor < text.length && /[ \t]/.test(text.charAt(cursor))) cursor += 1;
|
|
1665
|
+
const hasComma = text.charAt(cursor) === ",";
|
|
1666
|
+
const insertAt = hasComma ? cursor + 1 : anchor.getEnd();
|
|
1667
|
+
const prefix = hasComma ? "" : ",";
|
|
1668
|
+
return {
|
|
1669
|
+
start: insertAt,
|
|
1670
|
+
end: insertAt,
|
|
1671
|
+
text: `${prefix}\n${renderPropertyLines(intent, indent, unit)}`,
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
function renderPropertyLines(
|
|
1676
|
+
intent: ProviderDeploymentOverrides,
|
|
1677
|
+
indent: string,
|
|
1678
|
+
unit: string,
|
|
1679
|
+
): string {
|
|
1680
|
+
const lines: string[] = [];
|
|
1681
|
+
if (intent.runtime !== undefined) lines.push(`${indent}${DEPLOYMENT_RUNTIME_AXIS_COMMENT}`);
|
|
1682
|
+
lines.push(`${indent}${DEPLOYMENT_PROPERTY_NAME}: ${formatIntentLiteral(intent, indent, unit)},`);
|
|
1683
|
+
return lines.join("\n");
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
function findAnchorProperty(
|
|
1687
|
+
declaration: TS.ObjectLiteralExpression,
|
|
1688
|
+
name: string,
|
|
1689
|
+
): TS.ObjectLiteralElementLike | undefined {
|
|
1690
|
+
for (const property of declaration.properties) {
|
|
1691
|
+
if (ts.isSpreadAssignment(property)) continue;
|
|
1692
|
+
const propertyName = property.name;
|
|
1693
|
+
if (
|
|
1694
|
+
propertyName !== undefined &&
|
|
1695
|
+
(ts.isIdentifier(propertyName) || ts.isStringLiteralLike(propertyName)) &&
|
|
1696
|
+
propertyName.text === name
|
|
1697
|
+
) {
|
|
1698
|
+
return property;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
return undefined;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
/**
|
|
1705
|
+
* Format the key's object literal starting at column `indent.length +
|
|
1706
|
+
* "deployment: ".length`: inline when it fits the line width, otherwise
|
|
1707
|
+
* expanded one property per line (recursively), trailing commas included —
|
|
1708
|
+
* the same decisions a formatter would keep.
|
|
1709
|
+
*/
|
|
1710
|
+
export function formatIntentLiteral(
|
|
1711
|
+
intent: ProviderDeploymentOverrides,
|
|
1712
|
+
indent: string,
|
|
1713
|
+
unit: string,
|
|
1714
|
+
): string {
|
|
1715
|
+
const ordered: Record<string, unknown> = {};
|
|
1716
|
+
for (const key of DEPLOYMENT_INTENT_FIELD_ORDER) {
|
|
1717
|
+
if (intent[key] !== undefined) ordered[key] = intent[key];
|
|
1718
|
+
}
|
|
1719
|
+
return formatValue(ordered, indent, unit, `${DEPLOYMENT_PROPERTY_NAME}: `.length, 1);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
function formatValue(
|
|
1723
|
+
value: unknown,
|
|
1724
|
+
indent: string,
|
|
1725
|
+
unit: string,
|
|
1726
|
+
prefixWidth: number,
|
|
1727
|
+
suffixWidth: number,
|
|
1728
|
+
): string {
|
|
1729
|
+
if (Array.isArray(value)) {
|
|
1730
|
+
return `[${value.map((item) => formatValue(item, indent, unit, 0, 0)).join(", ")}]`;
|
|
1731
|
+
}
|
|
1732
|
+
if (typeof value === "object" && value !== null) {
|
|
1733
|
+
const entries = Object.entries(value).filter(([, item]) => item !== undefined);
|
|
1734
|
+
if (entries.length === 0) return "{}";
|
|
1735
|
+
const inline = `{ ${entries
|
|
1736
|
+
.map(([key, item]) => `${key}: ${formatValue(item, indent, unit, 0, 0)}`)
|
|
1737
|
+
.join(", ")} }`;
|
|
1738
|
+
if (indent.length + prefixWidth + inline.length + suffixWidth <= LINE_WIDTH) return inline;
|
|
1739
|
+
const inner = `${indent}${unit}`;
|
|
1740
|
+
const lines = entries.map(
|
|
1741
|
+
([key, item]) => `${inner}${key}: ${formatValue(item, inner, unit, `${key}: `.length, 1)},`,
|
|
1742
|
+
);
|
|
1743
|
+
return `{\n${lines.join("\n")}\n${indent}}`;
|
|
1744
|
+
}
|
|
1745
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
1746
|
+
return String(value);
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
function lineIndentation(source: TS.SourceFile, position: number): string {
|
|
1750
|
+
const text = source.getFullText();
|
|
1751
|
+
const lineStart = text.lastIndexOf("\n", position - 1) + 1;
|
|
1752
|
+
const match = /^[ \t]*/.exec(text.slice(lineStart, position));
|
|
1753
|
+
return match?.[0] ?? "";
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function indentUnitFor(
|
|
1757
|
+
source: TS.SourceFile,
|
|
1758
|
+
literal: TS.ObjectLiteralExpression,
|
|
1759
|
+
propertyIndent: string,
|
|
1760
|
+
): string {
|
|
1761
|
+
if (propertyIndent.startsWith("\t")) return "\t";
|
|
1762
|
+
const literalIndent = lineIndentation(source, literal.getStart(source));
|
|
1763
|
+
if (propertyIndent.length > literalIndent.length && propertyIndent.startsWith(literalIndent)) {
|
|
1764
|
+
return propertyIndent.slice(literalIndent.length);
|
|
1765
|
+
}
|
|
1766
|
+
return propertyIndent.length > 0 ? propertyIndent : " ";
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// ── Shared AST helpers ────────────────────────────────────────────────────
|
|
1770
|
+
|
|
1771
|
+
type TextEdit = {
|
|
1772
|
+
readonly start: number;
|
|
1773
|
+
readonly end: number;
|
|
1774
|
+
readonly text: string;
|
|
1775
|
+
};
|
|
1776
|
+
|
|
1777
|
+
function parseSource(fileName: string, text: string): TS.SourceFile {
|
|
1778
|
+
return ts.createSourceFile(
|
|
1779
|
+
fileName,
|
|
1780
|
+
text,
|
|
1781
|
+
ts.ScriptTarget.Latest,
|
|
1782
|
+
/* setParentNodes */ true,
|
|
1783
|
+
ts.ScriptKind.TS,
|
|
1784
|
+
);
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
function collectDefineProviderCalls(source: TS.SourceFile): TS.CallExpression[] {
|
|
1788
|
+
const calls: TS.CallExpression[] = [];
|
|
1789
|
+
const visit = (node: TS.Node): void => {
|
|
1790
|
+
if (
|
|
1791
|
+
ts.isCallExpression(node) &&
|
|
1792
|
+
ts.isIdentifier(node.expression) &&
|
|
1793
|
+
node.expression.text === "defineProvider"
|
|
1794
|
+
) {
|
|
1795
|
+
calls.push(node);
|
|
1796
|
+
}
|
|
1797
|
+
ts.forEachChild(node, visit);
|
|
1798
|
+
};
|
|
1799
|
+
visit(source);
|
|
1800
|
+
return calls;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
function findDefaultExport(source: TS.SourceFile): TS.ExportAssignment | undefined {
|
|
1804
|
+
for (const statement of source.statements) {
|
|
1805
|
+
if (ts.isExportAssignment(statement) && statement.isExportEquals !== true) return statement;
|
|
1806
|
+
}
|
|
1807
|
+
return undefined;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/** Remove one property from an object literal, absorbing a neighboring comma. */
|
|
1811
|
+
function removeObjectProperty(
|
|
1812
|
+
property: TS.ObjectLiteralElementLike,
|
|
1813
|
+
literal: TS.ObjectLiteralExpression,
|
|
1814
|
+
source: TS.SourceFile,
|
|
1815
|
+
): TextEdit {
|
|
1816
|
+
const properties = literal.properties;
|
|
1817
|
+
const index = properties.indexOf(property);
|
|
1818
|
+
const text = source.getFullText();
|
|
1819
|
+
const start = property.getFullStart();
|
|
1820
|
+
let end = property.getEnd();
|
|
1821
|
+
let cursor = end;
|
|
1822
|
+
while (cursor < text.length && /\s/.test(text.charAt(cursor))) cursor += 1;
|
|
1823
|
+
if (text.charAt(cursor) === ",") {
|
|
1824
|
+
end = cursor + 1;
|
|
1825
|
+
} else if (index > 0) {
|
|
1826
|
+
const previous = properties[index - 1];
|
|
1827
|
+
if (previous !== undefined) {
|
|
1828
|
+
let back = previous.getEnd();
|
|
1829
|
+
while (back < text.length && /\s/.test(text.charAt(back))) back += 1;
|
|
1830
|
+
if (text.charAt(back) === ",") return { start: back, end, text: "" };
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
return { start, end, text: "" };
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
/**
|
|
1837
|
+
* Remove a statement's own lines (from the start of its first line through
|
|
1838
|
+
* the line break after it), keeping surrounding blank lines and any comment
|
|
1839
|
+
* block above it intact.
|
|
1840
|
+
*/
|
|
1841
|
+
function removeStatement(statement: TS.Statement, source: TS.SourceFile): TextEdit {
|
|
1842
|
+
const text = source.getFullText();
|
|
1843
|
+
const statementStart = statement.getStart(source);
|
|
1844
|
+
const lineStart = text.lastIndexOf("\n", statementStart - 1) + 1;
|
|
1845
|
+
// Only take the whole line when the statement owns it; a statement that
|
|
1846
|
+
// shares its line with an earlier one is cut out in place.
|
|
1847
|
+
const ownsLine = /^[ \t]*$/.test(text.slice(lineStart, statementStart));
|
|
1848
|
+
let start = ownsLine ? lineStart : statementStart;
|
|
1849
|
+
let end = statement.getEnd();
|
|
1850
|
+
if (ownsLine) {
|
|
1851
|
+
if (text.charAt(end) === "\r") end += 1;
|
|
1852
|
+
if (text.charAt(end) === "\n") end += 1;
|
|
1853
|
+
} else {
|
|
1854
|
+
// Absorb the separating whitespace before the statement so the
|
|
1855
|
+
// neighbor keeps its own line ending intact.
|
|
1856
|
+
while (start > lineStart && /[ \t]/.test(text.charAt(start - 1))) start -= 1;
|
|
1857
|
+
}
|
|
1858
|
+
return { start, end, text: "" };
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
function firstSyntaxError(source: TS.SourceFile): string | undefined {
|
|
1862
|
+
const diagnostics = (source as TS.SourceFile & { parseDiagnostics?: TS.DiagnosticWithLocation[] })
|
|
1863
|
+
.parseDiagnostics;
|
|
1864
|
+
const first = diagnostics?.[0];
|
|
1865
|
+
if (first === undefined) return undefined;
|
|
1866
|
+
const message = ts.flattenDiagnosticMessageText(first.messageText, " ");
|
|
1867
|
+
const { line } = source.getLineAndCharacterOfPosition(first.start);
|
|
1868
|
+
return `${message} (line ${line + 1})`;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function editsOverlap(edits: readonly TextEdit[]): boolean {
|
|
1872
|
+
const ordered = [...edits].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
1873
|
+
for (let index = 1; index < ordered.length; index += 1) {
|
|
1874
|
+
const previous = ordered[index - 1];
|
|
1875
|
+
const current = ordered[index];
|
|
1876
|
+
if (previous !== undefined && current !== undefined && current.start < previous.end)
|
|
1877
|
+
return true;
|
|
1878
|
+
}
|
|
1879
|
+
return false;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
/** Whether `node` sits directly in the module scope (no enclosing function, class, or block). */
|
|
1883
|
+
function isTopLevelNode(node: TS.Node): boolean {
|
|
1884
|
+
for (let current = node.parent; current !== undefined; current = current.parent) {
|
|
1885
|
+
if (
|
|
1886
|
+
ts.isFunctionLike(current) ||
|
|
1887
|
+
ts.isClassLike(current) ||
|
|
1888
|
+
ts.isBlock(current) ||
|
|
1889
|
+
ts.isModuleBlock(current)
|
|
1890
|
+
) {
|
|
1891
|
+
return false;
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
return true;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
function applyEdits(text: string, edits: readonly TextEdit[]): string {
|
|
1898
|
+
const ordered = [...edits].sort((a, b) => b.start - a.start || b.end - a.end);
|
|
1899
|
+
let output = text;
|
|
1900
|
+
for (const edit of ordered) {
|
|
1901
|
+
output = output.slice(0, edit.start) + edit.text + output.slice(edit.end);
|
|
1902
|
+
}
|
|
1903
|
+
return output;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function describeError(error: unknown): string {
|
|
1907
|
+
return error instanceof Error ? error.message : String(error);
|
|
1908
|
+
}
|