diff --git a/README.md b/README.md index 95c1f82d8..5d6e6407b 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ export class AppComponent { [Upgrading from v6.0? Check out our guide.](docs/version-7-upgrade.md) +[Upgrading from AngularFire 20? See the v21 upgrade guide.](docs/version-21-upgrade.md) + ### Sample app The [`sample`](sample) folder contains a kitchen sink application that demonstrates use of the "modular" API, in a zoneless server-rendered application, with all the bells and whistles. diff --git a/docs/ai.md b/docs/ai.md index 88839a1fe..d106b0f25 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -8,6 +8,8 @@ Firebase AI Logic gives you access to the latest generative AI models from Googl [Learn more](https://firebase.google.com/docs/ai-logic) +> Firebase AI Logic was previously called **Vertex AI in Firebase**. If you are upgrading from AngularFire 20, the module moved from `@angular/fire/vertexai` to `@angular/fire/ai` and most symbols were renamed (`provideVertexAI` to `provideAI`, `VertexAI` to `AI`). One is not a rename: plain `getAI()` uses the Gemini Developer API backend, so the old `getVertexAI()` maps to `getAI(app, { backend: new VertexAIBackend() })`. Running `ng update @angular/fire` rewrites all of this for you and keeps your app on the Vertex AI backend. See the [AngularFire 20 to 21 upgrade guide](./version-21-upgrade.md). + ## Dependency Injection As a prerequisite, ensure that `AngularFire` has been added to your project via diff --git a/docs/version-21-upgrade.md b/docs/version-21-upgrade.md new file mode 100644 index 000000000..f163129c0 --- /dev/null +++ b/docs/version-21-upgrade.md @@ -0,0 +1,42 @@ +# Upgrading to AngularFire 21 + +AngularFire 21 targets **Angular 21** and the **Firebase JS SDK v12**. Most of the upgrade is handled for you by `ng update`. + +## Run the update + +```bash +ng update @angular/core @angular/cli # move your app to Angular 21 first +ng update @angular/fire # then AngularFire 21 +``` + +`ng update @angular/fire` runs a migration that: + +- **Aligns your `firebase` dependency to `^12.4.0`.** AngularFire 21 requires Firebase JS SDK 12. If your app still requested `firebase` 11, npm would install both 11 and 12 side by side, and the two copies reject each other's objects at runtime. The migration updates the dependency and reinstalls so you end up with a single copy. Verify with `npm ls firebase`. +- **Rewrites Vertex AI imports to AI Logic** (see below). + +## Vertex AI is now Firebase AI Logic + +The Vertex AI module has been renamed to Firebase AI Logic. The `@angular/fire/vertexai` entry point (and the older `@angular/fire/vertexai-preview`) are removed in favor of `@angular/fire/ai`: + +| Before (`@angular/fire/vertexai`) | After (`@angular/fire/ai`) | +|---|---| +| `getVertexAI(app?, { location? })` | `getAI(app, { backend: new VertexAIBackend(location?) })` | +| `provideVertexAI` | `provideAI` | +| `VertexAI` | `AI` | +| `VertexAIError` | `AIError` | +| `VertexAIErrorCode` | `AIErrorCode` | +| `VertexAIModel` | `AIModel` | +| `VertexAIInstances` | `AIInstances` | +| `vertexAIInstance$` | `AIInstance$` | +| `VertexAIModule` | `AIModule` | + +**`getVertexAI` is not a plain rename.** `getAI` already existed alongside it, and a plain `getAI()` call talks to the Gemini Developer API backend, not to Vertex AI. The equivalent of `getVertexAI()` is `getAI(app, { backend: new VertexAIBackend() })`, which is what the migration writes, so your app keeps calling the Vertex AI backend it was configured, enabled, and billed for. A `location` option moves into the `VertexAIBackend` constructor. + +`ng update @angular/fire` rewrites these imports and identifiers for you and logs every `getVertexAI` call it rewrites. Code it cannot rewrite safely (for example when the options are not a literal `{ location }` object or that literal references other rewritten symbols, when the function itself is handed around as a value, when a local declaration in the file reuses an imported symbol's name, or when the file already binds `getAI` or `VertexAIBackend` from a source other than AI Logic) is left in place with a warning. The import path itself still moves to the new entry point, so the leftover code fails to compile there, and nothing changes backends silently. A file where a named `getVertexAI` import has any use that cannot be rewritten keeps every use of its named `getVertexAI` imports in place (namespace-style `ns.getVertexAI(...)` calls are judged per call), and each skipped call is logged. `export * from '@angular/fire/vertexai'` is also left alone (rewriting it would silently rename your re-exported public symbols), so replace it with named re-exports by hand. `VertexAIOptions` was removed rather than renamed (the new `AIOptions` takes a `backend` instead of a `location`), so imports of it are left and warned about. Migrate those sites using the table above. `getGenerativeModel` and `getImagenModel` keep their names. + +Imports straight from the Firebase SDK (`firebase/vertexai`, gone in SDK 12) are rewritten to `firebase/ai` under the same rules. The rewrite parses your sources with the `typescript` package (an optional peer dependency of `@angular/fire`). Every Angular workspace already has it, but if the migration warns that it could not be resolved, install `typescript` and re-run. See [ai.md](./ai.md) for current usage. + +## Other notes + +- **Angular 21 is required.** AngularFire 21 peers `@angular/* ^21.0.0` and does not support Angular 22 (a future AngularFire 22 will). +- The obsolete `@angular/platform-browser-dynamic` peer dependency was removed. No action is needed. diff --git a/src/package.json b/src/package.json index 7619f809f..21f260946 100644 --- a/src/package.json +++ b/src/package.json @@ -32,11 +32,13 @@ "@angular/platform-browser": "^21.0.0", "@angular/platform-server": "^21.0.0", "rxjs": "~7.8.0", - "firebase-tools": "^14.0.0 || ^15.0.0" + "firebase-tools": "^14.0.0 || ^15.0.0", + "typescript": ">=5.8 <6.0" }, "peerDependenciesMeta": { "firebase-tools": { "optional": true }, - "@angular/platform-server": { "optional": true } + "@angular/platform-server": { "optional": true }, + "typescript": { "optional": true } }, "dependencies": { "firebase": "^12.4.0", diff --git a/src/schematics/migration.json b/src/schematics/migration.json index 9dfd04e02..7c040e6a1 100644 --- a/src/schematics/migration.json +++ b/src/schematics/migration.json @@ -8,7 +8,7 @@ }, "migration-v21": { "version": "21.0.0", - "description": "Align the workspace's firebase dependency with the range @angular/fire 21 requires, so the install cannot contain two copies of the firebase SDK", + "description": "Align the workspace's firebase dependency with the range @angular/fire 21 requires, and rewrite Vertex AI imports to Firebase AI Logic (getVertexAI callers keep the Vertex AI backend)", "factory": "./update/v21#ngUpdate" }, "ng-post-upgate": { diff --git a/src/schematics/update/v21/index.jasmine.ts b/src/schematics/update/v21/index.jasmine.ts index 87ebe1353..2911d060b 100644 --- a/src/schematics/update/v21/index.jasmine.ts +++ b/src/schematics/update/v21/index.jasmine.ts @@ -1,5 +1,6 @@ import { logging } from '@angular-devkit/core'; import { HostTree, SchematicContext } from '@angular-devkit/schematics'; +import * as typescript from 'typescript'; import { firebaseVersionRange } from '../../common.js'; import { ngUpdate } from './index.js'; import 'jasmine'; @@ -40,4 +41,45 @@ describe('migration-v21 ngUpdate', () => { expect(addTask).not.toHaveBeenCalled(); }); + it('keeps the firebase alignment when the rewrite throws', () => { + const logger = new logging.Logger('test'); + const warn = spyOn(logger, 'warn'); + const addTask = jasmine.createSpy('addTask'); + const context = { logger, addTask } as unknown as SchematicContext; + const tree = treeWithFirebase('^11.0.0'); + tree.create('angular.json', JSON.stringify({ + projects: { app: { root: '', sourceRoot: 'src' } }, + })); + tree.create('src/app/foo.ts', `import { getVertexAI } from '@angular/fire/vertexai';`); + const throwingCompiler = { + ScriptTarget: typescript.ScriptTarget, + createSourceFile: () => { throw new Error('boom'); }, + } as unknown as typeof typescript; + + ngUpdate({ compiler: throwingCompiler })(tree, context); + + // The rewrite failure costs only the rewrite, never the firebase alignment. + const written = JSON.parse(tree.readText('package.json')); + expect(written.dependencies.firebase).toBe(firebaseVersionRange); + expect(addTask).toHaveBeenCalledTimes(1); + expect(warn.calls.allArgs().map(callArgs => String(callArgs[0])).join('\n')) + .toContain('Skipped the Vertex AI -> AI Logic source rewrite'); + }); + + it('runs the Vertex AI rewrite and the alignment through one ngUpdate call', () => { + const { context, addTask } = contextWithTaskSpy(); + const tree = treeWithFirebase('^11.0.0'); + tree.create('angular.json', JSON.stringify({ + projects: { app: { root: '', sourceRoot: 'src' } }, + })); + tree.create('src/app/foo.ts', `import { getVertexAI } from '@angular/fire/vertexai';`); + + ngUpdate({ compiler: typescript })(tree, context); + + expect(tree.readText('src/app/foo.ts')).toBe(`import { getAI } from '@angular/fire/ai';`); + const written = JSON.parse(tree.readText('package.json')); + expect(written.dependencies.firebase).toBe(firebaseVersionRange); + expect(addTask).toHaveBeenCalledTimes(1); + }); + }); diff --git a/src/schematics/update/v21/index.ts b/src/schematics/update/v21/index.ts index 3920d9b90..b63788985 100644 --- a/src/schematics/update/v21/index.ts +++ b/src/schematics/update/v21/index.ts @@ -1,17 +1,31 @@ import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; -// The explicit index.js subpath keeps this importable from the ESM jasmine run; the bare +// The explicit index.js subpath keeps this importable from the ESM jasmine run. The bare // /tasks directory specifier only resolves under CommonJS. import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks/index.js'; +import type * as ts from 'typescript'; import { alignFirebaseVersion } from '../../common.js'; +import { rewriteVertexAIToAI } from './vertexai-to-ai/index.js'; // ng update re-runs this migration on rc-to-stable transitions (the CLI clamps the migration // range's upper bound to the release version), so it must stay a no-op when nothing changes. -export const ngUpdate = (): Rule => ( +export const ngUpdate = (options?: { compiler?: typeof ts }): Rule => ( host: Tree, context: SchematicContext ) => { + // Align firebase before anything else: it is the one step users cannot do without, so no + // failure below may cost it. This step changes dependencies, so only it schedules an install. if (alignFirebaseVersion(host, context)) { context.addTask(new NodePackageInstallTask()); } + // Rewrite Vertex AI imports to AI Logic (source-only edits, no dependency change). Guarded so + // an unexpected rewrite failure costs only the rewrite, never the alignment above. + try { + rewriteVertexAIToAI(host, context, options?.compiler); + } catch (error) { + context.logger.warn( + `Skipped the Vertex AI -> AI Logic source rewrite: ${error}. ` + + 'Any remaining @angular/fire/vertexai imports need a manual migration - see the v21 upgrade guide (docs/version-21-upgrade.md).' + ); + } return host; }; diff --git a/src/schematics/update/v21/vertexai-to-ai.jasmine.ts b/src/schematics/update/v21/vertexai-to-ai.jasmine.ts new file mode 100644 index 000000000..ca89596f1 --- /dev/null +++ b/src/schematics/update/v21/vertexai-to-ai.jasmine.ts @@ -0,0 +1,958 @@ +import { logging } from '@angular-devkit/core'; +import { HostTree, SchematicContext } from '@angular-devkit/schematics'; +import * as typescript from 'typescript'; +import { applyEdits, rewriteVertexAIToAI } from './vertexai-to-ai/index.js'; +import 'jasmine'; + +const context = { logger: new logging.Logger('test') } as unknown as SchematicContext; + +const contextWithLogSpies = () => { + const logger = new logging.Logger('test'); + const warn = spyOn(logger, 'warn'); + const info = spyOn(logger, 'info'); + const spiedContext = { logger } as unknown as SchematicContext; + return { context: spiedContext, warn, info }; +}; + +const treeWith = (files: Record) => { + const tree = new HostTree(); + tree.create('angular.json', JSON.stringify({ + projects: { app: { root: '', sourceRoot: 'src' } }, + })); + Object.entries(files).forEach(([path, content]) => tree.create(path, content)); + return tree; +}; + +describe('rewriteVertexAIToAI', () => { + + it('rewrites a named import and its usages, keeping getVertexAI calls on the Vertex AI backend', () => { + const source = [ + `import { provideVertexAI, getVertexAI, VertexAI } from '@angular/fire/vertexai';`, + `import { inject } from '@angular/core';`, + ``, + `export const providers = [provideVertexAI(() => getVertexAI())];`, + `export class Foo { private ai = inject(VertexAI); }`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + const changed = rewriteVertexAIToAI(tree, context, typescript); + + expect(changed).toBe(true); + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain(`import { provideAI, getAI, VertexAIBackend, AI } from '@angular/fire/ai';`); + expect(out).toContain('provideAI(() => getAI(undefined, { backend: new VertexAIBackend() }))'); + expect(out).toContain('inject(AI)'); + expect(out).not.toContain('getVertexAI'); + expect(out).not.toContain('provideVertexAI'); + expect(out).not.toContain('@angular/fire/vertexai'); + }); + + it('passes a lone app argument through to the rewritten call', () => { + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `import { getApp } from '@angular/fire/app';`, + `export const vertex = getVertexAI(getApp());`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain(`import { getAI, VertexAIBackend } from '@angular/fire/ai';`); + expect(out).toContain('getAI(getApp(), { backend: new VertexAIBackend() })'); + }); + + it('moves a literal location option into the VertexAIBackend constructor', () => { + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `import { getApp } from '@angular/fire/app';`, + `export const vertex = getVertexAI(getApp(), { location: 'europe-west1' });`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + expect(tree.readText('src/app/foo.ts')) + .toContain(`getAI(getApp(), { backend: new VertexAIBackend('europe-west1') })`); + }); + + it('replaces an empty options literal with the backend object', () => { + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `import { getApp } from '@angular/fire/app';`, + `export const vertex = getVertexAI(getApp(), {});`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + expect(tree.readText('src/app/foo.ts')) + .toContain('getAI(getApp(), { backend: new VertexAIBackend() })'); + }); + + it('logs each rewritten getVertexAI call', () => { + const { context: spiedContext, info } = contextWithLogSpies(); + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `export const vertex = getVertexAI();`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + expect(info).toHaveBeenCalledTimes(1); + expect(info.calls.mostRecent().args[0]).toContain('/src/app/foo.ts:2'); + expect(info.calls.mostRecent().args[0]).toContain('Vertex AI backend'); + }); + + it('leaves a getVertexAI call with non-literal options in place and warns', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import { provideVertexAI, getVertexAI } from '@angular/fire/vertexai';`, + `declare const options: { location: string };`, + `export const vertex = getVertexAI(undefined, options);`, + `export const p = provideVertexAI(() => vertex);`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + const changed = rewriteVertexAIToAI(tree, spiedContext, typescript); + + expect(changed).toBe(true); + const out = tree.readText('src/app/foo.ts'); + // Other symbols and the module specifier still migrate. getVertexAI is left whole so the + // stale import fails to compile loudly instead of silently changing backends. + expect(out).toContain(`import { provideAI, getVertexAI } from '@angular/fire/ai';`); + expect(out).toContain('getVertexAI(undefined, options)'); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.calls.mostRecent().args[0]).toContain('Gemini Developer API'); + }); + + it('leaves every getVertexAI edit out of a file where the binding is used as a value', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `export const factory = getVertexAI;`, + `export const vertex = getVertexAI();`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain(`import { getVertexAI } from '@angular/fire/ai';`); + expect(out).toContain('const factory = getVertexAI;'); + // The direct call would be rewritable alone, but a half-renamed binding cannot compile. + expect(out).toContain('const vertex = getVertexAI();'); + // One warning for the value use, one for the skipped-but-rewritable call. + expect(warn).toHaveBeenCalledTimes(2); + const warnText = warn.calls.allArgs().map(callArgs => String(callArgs[0])).join('\n'); + expect(warnText).toContain('left a rewritable getVertexAI call'); + expect(warnText).toContain('kept together'); + }); + + it('repurposes the getVertexAI specifier when getAI is already imported', () => { + const source = [ + `import { getAI, getVertexAI } from '@angular/fire/vertexai';`, + `export const genAI = getAI();`, + `export const vertex = getVertexAI();`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain(`import { getAI, VertexAIBackend } from '@angular/fire/ai';`); + // The plain getAI call is untouched, and only the getVertexAI call gets the backend pin. + expect(out).toContain('const genAI = getAI();'); + expect(out).toContain('const vertex = getAI(undefined, { backend: new VertexAIBackend() });'); + }); + + it('drops an unused getVertexAI specifier when getAI is already imported', () => { + const tree = treeWith({ + 'src/app/foo.ts': `import { getAI, getVertexAI } from '@angular/fire/vertexai';`, + }); + + rewriteVertexAIToAI(tree, context, typescript); + + expect(tree.readText('src/app/foo.ts')).toBe(`import { getAI } from '@angular/fire/ai';`); + }); + + it('keeps the local name of an aliased getVertexAI import while pinning its calls', () => { + const source = [ + `import { getVertexAI as gv } from '@angular/fire/vertexai';`, + `import { getApp } from '@angular/fire/app';`, + `export const vertex = gv(getApp());`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain(`import { getAI as gv, VertexAIBackend } from '@angular/fire/ai';`); + expect(out).toContain('gv(getApp(), { backend: new VertexAIBackend() })'); + }); + + it('leaves a getVertexAI re-export in place and warns', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const tree = treeWith({ + 'src/app/foo.ts': `export { getVertexAI } from '@angular/fire/vertexai';`, + }); + + const changed = rewriteVertexAIToAI(tree, spiedContext, typescript); + + expect(changed).toBe(true); + expect(tree.readText('src/app/foo.ts')).toBe(`export { getVertexAI } from '@angular/fire/ai';`); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.calls.mostRecent().args[0]).toContain('re-export of getVertexAI'); + }); + + it('preserves the public export name of an un-aliased re-export with a from clause', () => { + const tree = treeWith({ + 'src/app/foo.ts': `export { VertexAI, VertexAIModule as Legacy } from '@angular/fire/vertexai';`, + }); + + rewriteVertexAIToAI(tree, context, typescript); + + expect(tree.readText('src/app/foo.ts')) + .toBe(`export { AI as VertexAI, AIModule as Legacy } from '@angular/fire/ai';`); + }); + + it('rewrites direct firebase SDK imports the same way', () => { + const source = [ + `import { getVertexAI, VertexAIError } from 'firebase/vertexai';`, + `import { getApp } from 'firebase/app';`, + `export const vertex = getVertexAI(getApp());`, + `export const isAIError = (e: unknown) => e instanceof VertexAIError;`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain(`import { getAI, VertexAIBackend, AIError } from 'firebase/ai';`); + expect(out).toContain('getAI(getApp(), { backend: new VertexAIBackend() })'); + expect(out).toContain('instanceof AIError'); + }); + + it('renames only the imported name for an aliased import, leaving usages of the alias', () => { + const source = [ + `import { VertexAI as MyAI } from '@angular/fire/vertexai';`, + `import { inject } from '@angular/core';`, + `export class Foo { private ai = inject(MyAI); }`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain(`import { AI as MyAI } from '@angular/fire/ai';`); + expect(out).toContain('inject(MyAI)'); + }); + + it('handles the older vertexai-preview entry point', () => { + const tree = treeWith({ + 'src/app/foo.ts': `import { getVertexAI } from '@angular/fire/vertexai-preview';`, + }); + + rewriteVertexAIToAI(tree, context, typescript); + + expect(tree.readText('src/app/foo.ts')).toBe(`import { getAI } from '@angular/fire/ai';`); + }); + + it('rewrites namespace-import member accesses, reaching the backend class through the namespace', () => { + const source = [ + `import * as vai from '@angular/fire/vertexai';`, + `export const p = vai.provideVertexAI(() => vai.getVertexAI());`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain(`import * as vai from '@angular/fire/ai';`); + expect(out).toContain('vai.provideAI(() => vai.getAI(undefined, { backend: new vai.VertexAIBackend() }))'); + }); + + it('leaves unchanged symbols alone', () => { + const tree = treeWith({ + 'src/app/foo.ts': `import { getGenerativeModel, getVertexAI } from '@angular/fire/vertexai';`, + }); + + rewriteVertexAIToAI(tree, context, typescript); + + expect(tree.readText('src/app/foo.ts')) + .toBe(`import { getGenerativeModel, getAI } from '@angular/fire/ai';`); + }); + + it('does not touch strings, comments, or unrelated member names (the AST win over regex)', () => { + const source = [ + `import { VertexAI } from '@angular/fire/vertexai';`, + `import { inject } from '@angular/core';`, + `// VertexAI is now AI Logic`, + `export const label = 'VertexAI docs';`, + `export class Foo { VertexAI = 1; private ai = inject(VertexAI); }`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + const out = tree.readText('src/app/foo.ts'); + // comment and string keep the old word + expect(out).toContain('// VertexAI is now AI Logic'); + expect(out).toContain(`'VertexAI docs'`); + // a class member literally named VertexAI is not the import binding, so it is untouched + expect(out).toContain('VertexAI = 1;'); + // the real usage and the import are rewritten + expect(out).toContain(`from '@angular/fire/ai'`); + expect(out).toContain('inject(AI)'); + }); + + it('is a no-op when there is nothing to rewrite', () => { + const tree = treeWith({ + 'src/app/foo.ts': `import { getAI } from '@angular/fire/ai';`, + }); + + const changed = rewriteVertexAIToAI(tree, context, typescript); + + expect(changed).toBe(false); + expect(tree.readText('src/app/foo.ts')).toBe(`import { getAI } from '@angular/fire/ai';`); + }); + + it('does not crash without an angular.json', () => { + const tree = new HostTree(); + tree.create('src/app/foo.ts', `import { getVertexAI } from '@angular/fire/vertexai';`); + + expect(() => rewriteVertexAIToAI(tree, context, typescript)).not.toThrow(); + }); + + it('rewrites files in a non-root project (library / multi-project workspace)', () => { + const tree = new HostTree(); + tree.create('angular.json', JSON.stringify({ + projects: { + app: { root: '', sourceRoot: 'src' }, + lib: { root: 'projects/lib', sourceRoot: 'projects/lib/src' }, + }, + })); + tree.create('projects/lib/src/foo.ts', `import { getVertexAI } from '@angular/fire/vertexai';`); + + const changed = rewriteVertexAIToAI(tree, context, typescript); + + expect(changed).toBe(true); + expect(tree.readText('projects/lib/src/foo.ts')).toBe(`import { getAI } from '@angular/fire/ai';`); + }); + + it('covers a root project with no sourceRoot (older CLI workspaces) without walking node_modules', () => { + const tree = new HostTree(); + tree.create('angular.json', JSON.stringify({ + projects: { app: { root: '' } }, + })); + tree.create('source/foo.ts', `import { getVertexAI } from '@angular/fire/vertexai';`); + tree.create('node_modules/some-dep/index.ts', `import { getVertexAI } from '@angular/fire/vertexai';`); + + const changed = rewriteVertexAIToAI(tree, context, typescript); + + expect(changed).toBe(true); + expect(tree.readText('source/foo.ts')).toBe(`import { getAI } from '@angular/fire/ai';`); + expect(tree.readText('node_modules/some-dep/index.ts')) + .toBe(`import { getVertexAI } from '@angular/fire/vertexai';`); + }); + + it('renames a renamed symbol used in type position', () => { + const source = [ + `import { VertexAI } from '@angular/fire/vertexai';`, + `export let x: VertexAI;`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + expect(tree.readText('src/app/foo.ts')).toContain('let x: AI;'); + }); + + it('rewrites namespace member access in type position', () => { + const source = [ + `import * as fire from '@angular/fire/vertexai';`, + `export function f(): fire.VertexAI { return fire.getVertexAI(); }`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain('fire.AI'); + expect(out).toContain('fire.getAI(undefined, { backend: new fire.VertexAIBackend() })'); + expect(out).not.toContain('getVertexAI'); + }); + + it('leaves a shorthand property in place (it hands the function itself around) and warns', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `export const registry = { getVertexAI };`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain(`import { getVertexAI } from '@angular/fire/ai';`); + // Anchored on the object literal: the import line also contains `{ getVertexAI }`, so a bare + // toContain would pass even if the shorthand were wrongly expanded. + expect(out).toContain('registry = { getVertexAI };'); + expect(out).not.toContain('getVertexAI: getAI'); + expect(warn).toHaveBeenCalled(); + }); + + it('preserves the export name for a bare local re-export', () => { + const source = [ + `import { VertexAI } from '@angular/fire/vertexai';`, + `export { VertexAI };`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain(`import { AI } from '@angular/fire/ai';`); + expect(out).toContain('export { AI as VertexAI };'); + }); + + it('renames the instances token, the instance observable, the model base class, and the error code', () => { + const tree = treeWith({ + 'src/app/foo.ts': `import { VertexAIInstances, vertexAIInstance$, VertexAIModel, VertexAIErrorCode } from '@angular/fire/vertexai';`, + }); + + rewriteVertexAIToAI(tree, context, typescript); + + expect(tree.readText('src/app/foo.ts')) + .toBe(`import { AIInstances, AIInstance$, AIModel, AIErrorCode } from '@angular/fire/ai';`); + }); + + it('does not rename a get/set accessor named like an imported symbol', () => { + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `export class Foo { get getVertexAI() { return 1; } }`, + `export const used = getVertexAI();`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain('get getVertexAI()'); + expect(out).toContain('getAI(undefined, { backend: new VertexAIBackend() })'); + }); + + it('does not treat a destructuring property key as a usage', () => { + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `export function a(obj: any) { const { getVertexAI: local } = obj; return local; }`, + `export const used = getVertexAI();`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + const out = tree.readText('src/app/foo.ts'); + // the property key read from obj is not the import, so it is left untouched + expect(out).toContain('const { getVertexAI: local } = obj;'); + // the direct call is still rewritten + expect(out).toContain('getAI(undefined, { backend: new VertexAIBackend() })'); + }); + + it('leaves a binding initializer that hands the function around, and warns', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `export function b({ cb = getVertexAI }: any) { return cb; }`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain(`import { getVertexAI } from '@angular/fire/ai';`); + expect(out).toContain('cb = getVertexAI'); + expect(warn).toHaveBeenCalled(); + }); + + it('leaves a file alone when a local declaration shadows getVertexAI, and warns', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `export function f(obj: any) { const { getVertexAI } = obj; return getVertexAI(); }`, + `export const vertex = getVertexAI();`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const out = tree.readText('src/app/foo.ts'); + // Name-based matching cannot tell the destructured local from the import, so nothing but the + // module specifier changes and the import breaks loudly instead of redirecting the local call. + expect(out).toContain(`import { getVertexAI } from '@angular/fire/ai';`); + expect(out).toContain('const { getVertexAI } = obj; return getVertexAI();'); + expect(out).toContain('export const vertex = getVertexAI();'); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.calls.mostRecent().args[0]).toContain('declares a local named `getVertexAI`'); + expect(warn.calls.mostRecent().args[0]).toContain('import path itself still moves'); + }); + + it('skips a renamed symbol whose name is shadowed by a local, and warns', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import { provideVertexAI, VertexAI } from '@angular/fire/vertexai';`, + `import { inject } from '@angular/core';`, + `export function f(obj: any) { const { provideVertexAI } = obj; return provideVertexAI(); }`, + `export class Foo { private ai = inject(VertexAI); }`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const out = tree.readText('src/app/foo.ts'); + // provideVertexAI is excluded whole (its local shadow makes it ambiguous). VertexAI still migrates. + expect(out).toContain(`import { provideVertexAI, AI } from '@angular/fire/ai';`); + expect(out).toContain('return provideVertexAI();'); + expect(out).toContain('inject(AI)'); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('skips namespace member rewrites when the namespace name is shadowed, and warns', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import * as vai from '@angular/fire/vertexai';`, + `export function f(vai: any) { return vai.getVertexAI(); }`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain(`import * as vai from '@angular/fire/ai';`); + expect(out).toContain('return vai.getVertexAI();'); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('adds VertexAIBackend once for two getVertexAI specifiers in one import', () => { + const source = [ + `import { getVertexAI, getVertexAI as gv2 } from '@angular/fire/vertexai';`, + `export const a = getVertexAI();`, + `export const b = gv2();`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + const out = tree.readText('src/app/foo.ts'); + const importLine = out.split('\n')[0]; + expect(importLine).toBe(`import { getAI, VertexAIBackend, getAI as gv2 } from '@angular/fire/ai';`); + expect(out).toContain('const a = getAI(undefined, { backend: new VertexAIBackend() });'); + expect(out).toContain('const b = gv2(undefined, { backend: new VertexAIBackend() });'); + }); + + it('adds VertexAIBackend once when two old entry points are imported', () => { + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `import { getVertexAI as fbGet } from 'firebase/vertexai';`, + `export const a = getVertexAI();`, + `export const b = fbGet();`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain(`import { getAI, VertexAIBackend } from '@angular/fire/ai';`); + expect(out).toContain(`import { getAI as fbGet } from 'firebase/ai';`); + // One import plus two constructor calls: the backend class is never double-imported. + expect((out.match(/VertexAIBackend/g) || []).length).toBe(3); + }); + + it('repurposes the specifier when getAI is imported from the new entry point already', () => { + const source = [ + `import { getAI } from '@angular/fire/ai';`, + `import { getVertexAI } from '@angular/fire/vertexai';`, + `export const a = getAI();`, + `export const vertex = getVertexAI();`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain(`import { VertexAIBackend } from '@angular/fire/ai';`); + expect(out).toContain('const a = getAI();'); + expect(out).toContain('const vertex = getAI(undefined, { backend: new VertexAIBackend() });'); + }); + + it('leaves getVertexAI unmigrated when getAI is imported from an unrelated module, and warns', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import { getAI } from 'some-other-lib';`, + `import { getVertexAI } from '@angular/fire/vertexai';`, + `export const v = getVertexAI();`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const out = tree.readText('src/app/foo.ts'); + // Rewriting the call to getAI would silently bind it to some-other-lib's getAI. + expect(out).toContain(`import { getAI } from 'some-other-lib';`); + expect(out).toContain(`import { getVertexAI } from '@angular/fire/ai';`); + expect(out).toContain('const v = getVertexAI();'); + const warnText = warn.calls.allArgs().map(callArgs => String(callArgs[0])).join('\n'); + expect(warnText).toContain('already bound here from a source other than AI Logic'); + expect(warnText).toContain('fails to compile there'); + // The skipped call's own warning names the real cause, the foreign binding. + expect(warnText).toContain('binds getAI or VertexAIBackend from another source'); + }); + + it('leaves getVertexAI unmigrated when the file declares its own getAI, and warns', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `export function getAI(value: unknown) { return value; }`, + `export const v = getVertexAI();`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const out = tree.readText('src/app/foo.ts'); + // Injecting getAI would collide with the local declaration. + expect(out).toContain(`import { getVertexAI } from '@angular/fire/ai';`); + expect(out).toContain('const v = getVertexAI();'); + expect(warn).toHaveBeenCalled(); + }); + + it('leaves a star re-export of the old module unmigrated, and warns', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const tree = treeWith({ + 'src/app/foo.ts': `export * from '@angular/fire/vertexai';`, + }); + + const changed = rewriteVertexAIToAI(tree, spiedContext, typescript); + + // Rewriting the specifier would silently rename every symbol this file re-exports. + expect(changed).toBe(false); + expect(tree.readText('src/app/foo.ts')).toBe(`export * from '@angular/fire/vertexai';`); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.calls.mostRecent().args[0]).toContain('re-exported public symbols'); + }); + + it('expands a renamed symbol used as an object shorthand value, keeping the property name', () => { + const source = [ + `import { provideVertexAI } from '@angular/fire/vertexai';`, + `export const reg = { provideVertexAI };`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + expect(tree.readText('src/app/foo.ts')).toContain('reg = { provideVertexAI: provideAI };'); + }); + + it('moves a shorthand location option into the VertexAIBackend constructor', () => { + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `import { getApp } from '@angular/fire/app';`, + `const location = 'europe-west1';`, + `export const vertex = getVertexAI(getApp(), { location });`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + expect(tree.readText('src/app/foo.ts')) + .toContain('getAI(getApp(), { backend: new VertexAIBackend(location) })'); + }); + + it('leaves a call whose location option references a rewritten symbol, and warns', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import { getVertexAI, VertexAI } from '@angular/fire/vertexai';`, + `declare const region: (marker: unknown) => string;`, + `export const a = getVertexAI(undefined, { location: region(VertexAI) });`, + `export const tail = 42;`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const out = tree.readText('src/app/foo.ts'); + // Replacing the options span would overlap the rename inside it, so the call is left whole + // (with the inner rename still applied) and nothing after the call gets corrupted. + expect(out).toContain('getVertexAI(undefined, { location: region(AI) })'); + expect(out).toContain('export const tail = 42;'); + expect(warn.calls.mostRecent().args[0]).toContain('options mention other symbols this migration rewrites'); + }); + + it('leaves a call whose options nest another getVertexAI call, and warns', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `declare const pick: (value: unknown) => string;`, + `export const a = getVertexAI(undefined, { location: pick(getVertexAI()) });`, + `export const tail = 42;`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain('getVertexAI(undefined, { location: pick(getVertexAI()) })'); + expect(out).toContain('export const tail = 42;'); + // One warning for the unsupported outer call, one for the blocked inner call. + expect(warn).toHaveBeenCalledTimes(2); + }); + + it('leaves a namespace call whose options reference the namespace, and warns', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import * as vai from '@angular/fire/vertexai';`, + `export const a = vai.getVertexAI(undefined, { location: vai.VertexAIModel });`, + `export const tail = 42;`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain('vai.getVertexAI(undefined, { location: vai.AIModel })'); + expect(out).toContain('export const tail = 42;'); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('leaves a removed symbol in place and warns with guidance', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import { getVertexAI, VertexAIOptions } from '@angular/fire/vertexai';`, + `export let options: VertexAIOptions | undefined;`, + `export const a = getVertexAI();`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const out = tree.readText('src/app/foo.ts'); + // VertexAIOptions has no drop-in successor, so it keeps its name (a loud break) while the + // rest of the file still migrates. + expect(out).toContain(`import { getAI, VertexAIBackend, VertexAIOptions } from '@angular/fire/ai';`); + expect(out).toContain('let options: VertexAIOptions | undefined;'); + expect(out).toContain('getAI(undefined, { backend: new VertexAIBackend() })'); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.calls.mostRecent().args[0]).toContain('no longer exists in the new entry point'); + }); + + it('reuses an existing VertexAIBackend import instead of adding a second one', () => { + const source = [ + `import { VertexAIBackend } from '@angular/fire/ai';`, + `import { getVertexAI } from '@angular/fire/vertexai';`, + `export const a = getVertexAI();`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain(`import { VertexAIBackend } from '@angular/fire/ai';`); + expect(out).toContain(`import { getAI } from '@angular/fire/ai';`); + expect(out).toContain('getAI(undefined, { backend: new VertexAIBackend() })'); + // The pre-existing import plus one constructor call: no duplicate binding. + expect((out.match(/VertexAIBackend/g) || []).length).toBe(2); + }); + + it('rewrites a call whose location reads a property that merely shares a rewritten name', () => { + const source = [ + `import { getVertexAI, provideVertexAI } from '@angular/fire/vertexai';`, + `import { getApp } from '@angular/fire/app';`, + `declare const settings: { provideVertexAI: string };`, + `export const p = provideVertexAI(() => 1);`, + `export const a = getVertexAI(getApp(), { location: settings.provideVertexAI });`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + const out = tree.readText('src/app/foo.ts'); + // `settings.provideVertexAI` is a property NAME, which pass 2 never edits, so there is no + // overlap and the call must still rewrite. + expect(out).toContain('getAI(getApp(), { backend: new VertexAIBackend(settings.provideVertexAI) })'); + expect(out).toContain('provideAI(() => 1)'); + }); + + it('rewrites a call whose options contain a property key sharing a rewritten name', () => { + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `import { getApp } from '@angular/fire/app';`, + `declare const build: (value: unknown) => string;`, + `export const a = getVertexAI(getApp(), { location: build({ getVertexAI: true }) });`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + expect(tree.readText('src/app/foo.ts')) + .toContain('getAI(getApp(), { backend: new VertexAIBackend(build({ getVertexAI: true })) })'); + }); + + it('rewrites a namespace call whose location reads a non-renamed namespace member', () => { + const source = [ + `import * as vai from '@angular/fire/vertexai';`, + `export const a = vai.getVertexAI(undefined, { location: vai.DEFAULT_LOCATION });`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + + // `vai.DEFAULT_LOCATION` gets no edit (DEFAULT_LOCATION is not renamed), so no overlap. + expect(tree.readText('src/app/foo.ts')) + .toContain('vai.getAI(undefined, { backend: new vai.VertexAIBackend(vai.DEFAULT_LOCATION) })'); + }); + + it('warns about a removed symbol reached through a namespace import', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import * as vai from '@angular/fire/vertexai';`, + `export const options: vai.VertexAIOptions = {};`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + expect(tree.readText('src/app/foo.ts')).toContain('vai.VertexAIOptions'); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.calls.mostRecent().args[0]).toContain('no longer exists in the new entry point'); + }); + + it('rewrites a statement mixing an aliased vertex import, a dropped one, and getAI', () => { + const source = [ + `import { getVertexAI as gv, getVertexAI, getAI } from '@angular/fire/vertexai';`, + `export const c = gv();`, + `export const keep = getAI;`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + const changed = rewriteVertexAIToAI(tree, context, typescript); + + // The backend insertion after `gv` and the removal of the unused unaliased specifier share + // a boundary offset. They compose, and must not trip the conflicting-edits backstop. + expect(changed).toBe(true); + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain(`import { getAI as gv, VertexAIBackend, getAI } from '@angular/fire/ai';`); + expect(out).toContain('gv(undefined, { backend: new VertexAIBackend() })'); + expect(out).toContain('const keep = getAI;'); + }); + + it('tolerates a null project entry in angular.json', () => { + const tree = new HostTree(); + tree.create('angular.json', JSON.stringify({ + projects: { app: { root: '', sourceRoot: 'src' }, broken: null }, + })); + tree.create('src/app/foo.ts', `import { getVertexAI } from '@angular/fire/vertexai';`); + + const changed = rewriteVertexAIToAI(tree, context, typescript); + + expect(changed).toBe(true); + expect(tree.readText('src/app/foo.ts')).toBe(`import { getAI } from '@angular/fire/ai';`); + }); + + it('handles a file with a very deep expression without overflowing', () => { + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `export const sum = ${new Array(4000).fill('1').join(' + ')};`, + `export const vertex = getVertexAI();`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + const changed = rewriteVertexAIToAI(tree, context, typescript); + + expect(changed).toBe(true); + expect(tree.readText('src/app/foo.ts')) + .toContain('getAI(undefined, { backend: new VertexAIBackend() })'); + }); + + it('skips a file with syntax errors instead of editing its broken tree, and warns', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const broken = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `export const v = getVertexAI(`, + `class {`, + ].join('\n'); + const tree = treeWith({ + 'src/app/broken.ts': broken, + 'src/app/good.ts': `import { getVertexAI } from '@angular/fire/vertexai';`, + }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + // The broken file is untouched (error-recovered offsets are unreliable), the good one migrates. + expect(tree.readText('src/app/broken.ts')).toBe(broken); + expect(tree.readText('src/app/good.ts')).toBe(`import { getAI } from '@angular/fire/ai';`); + expect(warn.calls.allArgs().map(callArgs => String(callArgs[0])).join('\n')) + .toContain('syntax errors'); + }); + + it('handles a sourceRoot with a trailing slash', () => { + const tree = new HostTree(); + tree.create('angular.json', JSON.stringify({ + projects: { app: { root: '', sourceRoot: 'src/' } }, + })); + tree.create('src/app/foo.ts', `import { getVertexAI } from '@angular/fire/vertexai';`); + + const changed = rewriteVertexAIToAI(tree, context, typescript); + + expect(changed).toBe(true); + expect(tree.readText('src/app/foo.ts')).toBe(`import { getAI } from '@angular/fire/ai';`); + }); + + describe('applyEdits', () => { + + it('applies disjoint edits back to front', () => { + expect(applyEdits('abcdef', [ + { start: 1, end: 2, replacement: 'B' }, + { start: 4, end: 5, replacement: 'E' }, + ])).toBe('aBcdEf'); + }); + + it('accepts an insertion adjacent to a replacement boundary', () => { + expect(applyEdits('abcdef', [ + { start: 1, end: 3, replacement: 'X' }, + { start: 3, end: 3, replacement: '+' }, + ])).toBe('aX+def'); + }); + + it('applies a removal and an insertion sharing a start offset in composing order', () => { + expect(applyEdits('ab, drop, cd', [ + { start: 2, end: 2, replacement: '+X' }, + { start: 2, end: 8, replacement: '' }, + ])).toBe('ab+X, cd'); + }); + + it('throws on overlapping edits instead of corrupting the text', () => { + expect(() => applyEdits('abcdef', [ + { start: 1, end: 4, replacement: 'X' }, + { start: 2, end: 3, replacement: 'Y' }, + ])).toThrowError(/conflicting rewrite edits/); + }); + + }); + + it('is idempotent when re-run on already-migrated code', () => { + const source = [ + `import { provideVertexAI, getVertexAI } from '@angular/fire/vertexai';`, + `export const p = provideVertexAI(() => getVertexAI());`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context, typescript); + const afterFirst = tree.readText('src/app/foo.ts'); + const changedAgain = rewriteVertexAIToAI(tree, context, typescript); + + expect(changedAgain).toBe(false); + expect(tree.readText('src/app/foo.ts')).toBe(afterFirst); + }); + +}); diff --git a/src/schematics/update/v21/vertexai-to-ai/ast-walk.ts b/src/schematics/update/v21/vertexai-to-ai/ast-walk.ts new file mode 100644 index 000000000..b5d157860 --- /dev/null +++ b/src/schematics/update/v21/vertexai-to-ai/ast-walk.ts @@ -0,0 +1,27 @@ +// A stack-based depth-first walk over a TypeScript AST. The rewrite runs on user files of +// arbitrary shape, and a recursive walk overflows the call stack on deep expressions (a few +// thousand chained operands), so every full-tree traversal in this package uses this instead. + +import type * as ts from 'typescript'; + +/** + * Visit root and every descendant in source order (pre-order), without recursion. + * + * @param visitNode return false to skip the node's children (subtree pruning). + */ +export const forEachNodeDeep = (compiler: typeof ts, root: ts.Node, visitNode: (node: ts.Node) => boolean | void): void => { + const pending: ts.Node[] = [root]; + while (pending.length > 0) { + const node = pending.pop(); + if (!node || visitNode(node) === false) { + continue; + } + const children: ts.Node[] = []; + compiler.forEachChild(node, child => { + children.push(child); + }); + for (let index = children.length - 1; index >= 0; index--) { + pending.push(children[index]); + } + } +}; diff --git a/src/schematics/update/v21/vertexai-to-ai/compiler.ts b/src/schematics/update/v21/vertexai-to-ai/compiler.ts new file mode 100644 index 000000000..8e8b017f8 --- /dev/null +++ b/src/schematics/update/v21/vertexai-to-ai/compiler.ts @@ -0,0 +1,30 @@ +// How the TypeScript compiler is obtained at ng-update time. It is an optional peer +// dependency, so resolution may fail, and the caller degrades to skip-with-warning +// instead of crashing the migration. + +import { join } from 'path'; +import type * as ts from 'typescript'; + +/** + * Resolve the workspace's `typescript`, or undefined where that is impossible (no `require` in + * this runtime, or the package is not reachable). + */ +export const resolveTypescript = (): typeof ts | undefined => { + if (typeof require !== 'function') { + return undefined; + } + const resolutions: (() => typeof ts)[] = [ + () => require('typescript'), + // The workspace root (ng update's working directory): under an isolated node_modules layout + // the workspace's own typescript may not be reachable from the package itself. + () => require('module').createRequire(join(process.cwd(), 'package.json'))('typescript'), + ]; + for (const resolution of resolutions) { + try { + return resolution(); + } catch { + // Try the next resolution. + } + } + return undefined; +}; diff --git a/src/schematics/update/v21/vertexai-to-ai/index.ts b/src/schematics/update/v21/vertexai-to-ai/index.ts new file mode 100644 index 000000000..413edaf2b --- /dev/null +++ b/src/schematics/update/v21/vertexai-to-ai/index.ts @@ -0,0 +1,255 @@ +// The schematic-facing entry point of the Vertex AI -> AI Logic rewrite: resolves the +// compiler, walks the workspace's project roots, runs the per-file pipeline (safety +// analysis, the two scan passes, the getVertexAI edit builders), applies the edits, and +// logs every rewritten or deliberately skipped site. + +import { posix } from 'path'; +import { SchematicContext, Tree } from '@angular-devkit/schematics'; +import type * as ts from 'typescript'; +import { overwriteIfExists, safeReadJSON } from '../../../common.js'; +import { resolveTypescript } from './compiler.js'; +import { collectUsageEdits, scanDeclarations } from './passes.js'; +import { collectInjectionConflicts, collectShadowedNames } from './safety.js'; +import { MODULE_SPECIFIER_REWRITES, REMOVED_SYMBOL_GUIDANCE } from './tables.js'; +import type { FileContext, FileRewrite, TextEdit, VertexClassification, VertexImport } from './types.js'; +import { buildVertexEdits } from './vertex-edits.js'; + +const UPGRADE_GUIDE = 'see the AngularFire v21 upgrade guide (docs/version-21-upgrade.md)'; + +/** One line of the migration log: a position in the file plus the message for it. */ +interface LogEntry { + level: 'info' | 'warn'; + position: number; + text: string; +} + +/** + * parseDiagnostics is not part of the public SourceFile type but is always populated by + * createSourceFile at runtime. An error-recovered AST carries unreliable offsets, so a file + * with syntax errors must never be edited. + */ +interface ParsedSourceFile { + readonly text: string; + parseDiagnostics?: readonly unknown[]; +} + +/** + * Collect everything needed to move one source file off the old Vertex AI entry points and symbols. + * Runs the safety analyses, then pass 1 (declarations) and pass 2 (usages plus getVertexAI + * classification), then builds the getVertexAI edits from the classification. + * + * @param fileContext the compiler and parsed source file (never mutated). + * @returns the edits to apply plus the positions the migration log reports. + */ +const collectEditsForSourceFile = (fileContext: FileContext): FileRewrite => { + const { sourceFile } = fileContext; + const scan = scanDeclarations(fileContext, collectShadowedNames(fileContext)); + const vertexBindings = new Map(); + for (const vertexImport of scan.vertexImports) { + vertexBindings.set(vertexImport.localName, vertexImport); + } + const vertex: VertexClassification = { supportedCalls: [], unsupported: [] }; + if (scan.renamedLocalBindings.size > 0 || scan.namespaceBindings.size > 0 || vertexBindings.size > 0) { + collectUsageEdits(fileContext, scan, vertexBindings, vertex); + } + for (const specifier of scan.vertexExportSpecifiers) { + vertex.unsupported.push({ + position: specifier.getStart(sourceFile), + reason: 'a re-export of getVertexAI (rewriting it to getAI would silently change its callers\' backend)', + origin: 'export', + }); + } + const injectionConflicts = scan.vertexImports.length > 0 ? collectInjectionConflicts(fileContext) : []; + const vertexEdits = buildVertexEdits(fileContext, scan, vertex, injectionConflicts); + return { + edits: scan.edits.concat(vertexEdits.edits), + vertexCallPositions: vertexEdits.callPositions, + blockedCallPositions: vertexEdits.blockedCallPositions, + unsupportedVertexUsages: vertex.unsupported, + shadowedImports: scan.shadowedImports, + injectionConflicts: vertexEdits.injectionBlocked ? injectionConflicts : [], + starExportPositions: scan.starExportPositions, + removedSymbols: scan.removedSymbols, + }; +}; + +/** + * The log lines one file's rewrite produces: an info per rewritten getVertexAI call and a warn + * per site deliberately left for manual migration. + */ +const rewriteLogEntries = (rewrite: FileRewrite): LogEntry[] => [ + ...rewrite.vertexCallPositions.map((position): LogEntry => ({ + level: 'info', + position, + text: 'rewrote getVertexAI(...) to getAI(..., { backend: new VertexAIBackend(...) }) to keep the call on the Vertex AI backend', + })), + ...rewrite.unsupportedVertexUsages.map((usage): LogEntry => ({ + level: 'warn', + position: usage.position, + text: `left ${usage.reason}. Plain getAI() uses the Gemini Developer API backend, NOT Vertex AI. Migrate this site by hand, ${UPGRADE_GUIDE}`, + })), + ...rewrite.shadowedImports.map((shadow): LogEntry => ({ + level: 'warn', + position: shadow.position, + text: `left the \`${shadow.name}\` import name and its usages unrenamed: this file also declares a local named \`${shadow.name}\`, and name-based rewriting cannot tell the two apart. The import path itself still moves to the new entry point, so the leftover name fails to compile there. Migrate this file by hand, ${UPGRADE_GUIDE}`, + })), + ...rewrite.injectionConflicts.map((conflict): LogEntry => ({ + level: 'warn', + position: conflict.position, + text: `\`${conflict.name}\` is already bound here from a source other than AI Logic, so the backend-preserving rewrite cannot inject or reuse it. The file's getVertexAI code was left as is, and its import path still moves to the new entry point, so the leftover getVertexAI fails to compile there. Migrate it by hand, ${UPGRADE_GUIDE}`, + })), + ...rewrite.blockedCallPositions.map((position): LogEntry => ({ + level: 'warn', + position, + text: rewrite.injectionConflicts.length > 0 + ? 'left a rewritable getVertexAI call unrewritten because this file binds getAI or VertexAIBackend from another source (see that warning). Every use of the file\'s named getVertexAI imports is kept together so the pieces stay consistent, migrate them by hand' + : 'left a rewritable getVertexAI call unrewritten because another getVertexAI use in this file cannot be rewritten (see its own warning). Every use of the file\'s named getVertexAI imports is kept together so the pieces stay consistent, migrate them by hand', + })), + ...rewrite.starExportPositions.map((position): LogEntry => ({ + level: 'warn', + position, + text: `left \`export *\` from an old Vertex AI entry point unmigrated: rewriting it would silently rename this file's re-exported public symbols. Re-export what you need by name from '@angular/fire/ai' instead, ${UPGRADE_GUIDE}`, + })), + ...rewrite.removedSymbols.map((removed): LogEntry => ({ + level: 'warn', + position: removed.position, + text: `left \`${removed.name}\`, which no longer exists in the new entry point (${REMOVED_SYMBOL_GUIDANCE[removed.name]}). The leftover name fails to compile, migrate it by hand, ${UPGRADE_GUIDE}`, + })), +]; + +/** + * Run the rewrite pipeline over one file: parse, collect edits, log the outcome, apply. + * + * @returns true when the file changed. + */ +const rewriteFile = (host: Tree, context: SchematicContext, compiler: typeof ts, filePath: string, content: string): boolean => { + const fileContext: FileContext = { + compiler, + sourceFile: compiler.createSourceFile(filePath, content, compiler.ScriptTarget.Latest, true), + }; + const parsedSourceFile: ParsedSourceFile = fileContext.sourceFile; + if (parsedSourceFile.parseDiagnostics && parsedSourceFile.parseDiagnostics.length > 0) { + context.logger.warn( + `${filePath}: skipped, the file has syntax errors, so the Vertex AI -> AI Logic rewrite cannot run on it safely. Fix the syntax and re-run ng update, or migrate it by hand, ${UPGRADE_GUIDE}` + ); + return false; + } + const rewrite = collectEditsForSourceFile(fileContext); + for (const entry of rewriteLogEntries(rewrite)) { + const line = fileContext.sourceFile.getLineAndCharacterOfPosition(entry.position).line + 1; + context.logger[entry.level](`${filePath}:${line}: ${entry.text}`); + } + if (rewrite.edits.length === 0) { + return false; + } + let newContent: string; + try { + newContent = applyEdits(content, rewrite.edits); + } catch (error) { + // The classifiers are meant to keep edits disjoint, so reaching this means a bug. Leaving + // the file untouched beats corrupting it. + context.logger.warn(`${filePath}: the Vertex AI -> AI Logic rewrite was skipped for this file (${error}). Migrate it by hand, ${UPGRADE_GUIDE}`); + return false; + } + if (newContent === content) { + return false; + } + overwriteIfExists(host, filePath, newContent); + return true; +}; + +/** + * Apply edits to the source text, back to front so earlier offsets stay valid. + * + * @param content the original file text. + * @param edits the edits to apply. Overlapping spans would slice at stale offsets and corrupt + * the output, so they throw instead. + * @returns the rewritten text. + */ +export const applyEdits = (content: string, edits: TextEdit[]): string => { + // At equal starts a span must apply before a zero-width insertion at that offset: the pair + // composes (remove the span, then insert at its former start), while the reverse order would + // slice the inserted text. + const sorted = edits.slice().sort((a, b) => b.start - a.start || b.end - a.end); + for (let index = 1; index < sorted.length; index++) { + if (sorted[index].end > sorted[index - 1].start) { + throw new Error(`conflicting rewrite edits at offsets ${sorted[index].start} and ${sorted[index - 1].start}`); + } + } + return sorted.reduce((text, edit) => text.slice(0, edit.start) + edit.replacement + text.slice(edit.end), content); +}; + +/** + * The workspace-relative source roots to migrate, from angular.json's projects. + * + * `sourceRoot` is already workspace-relative and includes the project root, so it is used + * directly (falling back to `root`). Joining both would double-count the prefix. A root of '' + * is the workspace itself (older CLI versions generate that for the root project) and must + * survive to become '/', not be dropped. Tree paths are always posix, so the roots join with + * posix separators regardless of the host OS. + */ +const collectSourceRoots = (angularJson: any): string[] => + Object.values(angularJson.projects) + .map((project: any) => project?.sourceRoot || project?.root) + .filter((base: any) => typeof base === 'string') + .map((base: string) => { + const joined = posix.join('/', base); + // posix.join keeps a trailing slash ('src/' becomes '/src/'), which would defeat the + // prefix test in shouldVisit and silently skip the project. + return joined === '/' ? joined : joined.replace(/\/+$/, ''); + }); + +/** + * Whether a tree path is a TypeScript source file under one of the source roots. Installed + * dependencies are excluded: a '/' source root would otherwise pull them in. + */ +const shouldVisit = (filePath: string, srcRoots: string[]): boolean => + filePath.endsWith('.ts') && + !filePath.endsWith('.d.ts') && + !filePath.split('/').includes('node_modules') && + srcRoots.some(root => root === '/' || filePath === root || filePath.startsWith(root + '/')); + +/** + * `ng update` migration step: rewrite a workspace's Vertex AI imports and usages onto Firebase AI + * Logic. Visits the TypeScript files under each project's source root and edits any that import from + * an old entry point. getVertexAI calls keep their backend: they become + * `getAI(app, { backend: new VertexAIBackend(location?) })`, and every rewritten or skipped + * getVertexAI site is logged. + * + * @param compiler the TypeScript compiler to parse with. Defaults to resolving the workspace's + * `typescript` (an optional peer dependency). Callers in environments without `require` + * pass their own. + * @returns true if any file was rewritten. + */ +export const rewriteVertexAIToAI = (host: Tree, context: SchematicContext, compiler?: typeof ts): boolean => { + const resolvedCompiler = compiler ?? resolveTypescript(); + const angularJson = host.exists('angular.json') && safeReadJSON('angular.json', host); + if (!angularJson?.projects) { + return false; + } + const srcRoots = collectSourceRoots(angularJson); + if (srcRoots.length === 0) { + return false; + } + + let changed = false; + host.visit(filePath => { + if (!shouldVisit(filePath, srcRoots)) { + return; + } + const content = host.read(filePath)?.toString(); + if (!content || !Object.keys(MODULE_SPECIFIER_REWRITES).some(specifier => content.includes(specifier))) { + return; + } + if (!resolvedCompiler) { + context.logger.warn( + `${filePath} imports a removed Vertex AI entry point, but the Vertex AI -> AI Logic rewrite was skipped: ` + + 'the `typescript` package could not be resolved (it is an optional peer dependency of @angular/fire). ' + + 'Install typescript and re-run, or migrate by hand - see the v21 upgrade guide (docs/version-21-upgrade.md).' + ); + return; + } + changed = rewriteFile(host, context, resolvedCompiler, filePath, content) || changed; + }); + return changed; +}; diff --git a/src/schematics/update/v21/vertexai-to-ai/passes.ts b/src/schematics/update/v21/vertexai-to-ai/passes.ts new file mode 100644 index 000000000..aa2c9e43f --- /dev/null +++ b/src/schematics/update/v21/vertexai-to-ai/passes.ts @@ -0,0 +1,518 @@ +// The two per-file scan passes. Pass 1 walks the top-level import/export declarations, edits +// module specifiers and renamed symbols, and records the bindings in play. Pass 2 walks the +// whole tree, rewrites usages of renamed bindings, and classifies every getVertexAI reference +// for the backend-preserving call rewrite built in vertex-edits.ts. + +import type * as ts from 'typescript'; +import { forEachNodeDeep } from './ast-walk.js'; +import { GET_VERTEX_AI, MODULE_SPECIFIER_REWRITES, REMOVED_SYMBOL_GUIDANCE, SYMBOL_RENAMES } from './tables.js'; +import type { DeclarationScan, FileContext, RemovedSymbol, SupportedVertexCall, TextEdit, VertexClassification, VertexImport } from './types.js'; + +/** The binding groups pass 2 edits by name, used to detect edit sites inside an options literal. */ +interface EditableBindings { + renamedNames: Set; + namespaceNames: Set; + vertexNames: Set; +} + +/** How one getVertexAI options argument maps onto the backend rewrite. */ +interface OptionsClassification { + supported: boolean; + /** The `location` property's source text, when present. */ + locationText?: string; + /** True when the literal contains an edit site pass 2 would also rewrite (see containsEditSite). */ + conflicting?: boolean; +} + +/** + * Pass 1: rewrite the module specifier and renamed symbols of every import/export declaration that + * pulls from an old Vertex AI entry point, and record the bindings whose usages pass 2 must rewrite. + * + * @param fileContext the compiler and parsed source file. + * @param shadowedNames tracked local names a local declaration shadows (see safety.ts). + * @returns the accumulated edits plus the binding maps for pass 2. + */ +export const scanDeclarations = (fileContext: FileContext, shadowedNames: Set): DeclarationScan => { + const { sourceFile } = fileContext; + const scan: DeclarationScan = { + edits: [], + renamedLocalBindings: new Map(), + namespaceBindings: new Set(), + editedSpecifierTokenStarts: new Set(), + vertexImports: [], + vertexExportSpecifiers: [], + shadowedImports: [], + starExportPositions: [], + removedSymbols: [], + }; + for (const statement of sourceFile.statements) { + collectDeclarationEdits(statement, fileContext, scan, shadowedNames); + } + return scan; +}; + +/** + * Handle one top-level statement in pass 1. A no-op unless the statement imports or exports from an + * old Vertex AI entry point, in which case it edits the module specifier and dispatches its bindings. + * + * @param statement the top-level statement to inspect. + * @param fileContext the compiler and parsed source file (for token offsets). + * @param scan accumulator mutated in place. + * @param shadowedNames tracked local names a local declaration shadows. + */ +const collectDeclarationEdits = (statement: ts.Statement, fileContext: FileContext, scan: DeclarationScan, shadowedNames: Set): void => { + const { compiler: tsc, sourceFile } = fileContext; + const isImport = tsc.isImportDeclaration(statement); + const isExport = tsc.isExportDeclaration(statement); + if (!isImport && !isExport) { + return; + } + const moduleSpecifier = statement.moduleSpecifier; + const newModuleSpecifier = moduleSpecifier && tsc.isStringLiteral(moduleSpecifier) && MODULE_SPECIFIER_REWRITES[moduleSpecifier.text]; + if (!newModuleSpecifier) { + return; + } + + // `export * from ''` (and `export * as ns from`) re-exports the module's symbols + // as the file's own public API. Rewriting the specifier would silently rename those public + // symbols, so the statement is left whole (it fails loudly against v21) and warned about. + if (isExport && (!statement.exportClause || tsc.isNamespaceExport(statement.exportClause))) { + scan.starExportPositions.push(statement.getStart(sourceFile)); + return; + } + + // Rewrite the module specifier text, preserving the surrounding quotes. + scan.edits.push({ + start: moduleSpecifier.getStart(sourceFile) + 1, + end: moduleSpecifier.getEnd() - 1, + replacement: newModuleSpecifier, + }); + + const namedBindings = isImport ? statement.importClause?.namedBindings : statement.exportClause; + if (namedBindings && tsc.isNamespaceImport(namedBindings)) { + if (shadowedNames.has(namedBindings.name.text)) { + scan.shadowedImports.push({ position: namedBindings.getStart(sourceFile), name: namedBindings.name.text }); + } else { + scan.namespaceBindings.add(namedBindings.name.text); + } + return; + } + if (namedBindings && (tsc.isNamedImports(namedBindings) || tsc.isNamedExports(namedBindings))) { + for (const element of namedBindings.elements) { + collectSpecifierEdit(element, statement, fileContext, scan, shadowedNames); + } + } +}; + +/** + * Rename a renamed symbol inside one named import/export specifier, and, for an un-aliased import, + * record its local binding so pass 2 rewrites that binding's usages. getVertexAI specifiers are only + * recorded here, and their edits depend on how the binding is used. + * + * @param element the `{ x }` or `{ x as y }` specifier. + * @param statement the import/export declaration the specifier belongs to. + * @param fileContext the compiler and parsed source file (for token offsets). + * @param scan accumulator mutated in place. + * @param shadowedNames tracked local names a local declaration shadows. + */ +const collectSpecifierEdit = ( + element: ts.ImportSpecifier | ts.ExportSpecifier, + statement: ts.Statement, + fileContext: FileContext, + scan: DeclarationScan, + shadowedNames: Set, +): void => { + const { compiler: tsc, sourceFile } = fileContext; + const importedNameNode = element.propertyName ?? element.name; + // A shadowed local name makes the usage pass unsafe for this binding: exclude it whole. + if (tsc.isImportSpecifier(element) && shadowedNames.has(element.name.text)) { + scan.shadowedImports.push({ position: element.getStart(sourceFile), name: element.name.text }); + return; + } + if (importedNameNode.text === GET_VERTEX_AI) { + if (tsc.isImportSpecifier(element) && tsc.isImportDeclaration(statement)) { + scan.vertexImports.push({ element, statement, localName: element.name.text }); + } else if (tsc.isExportSpecifier(element)) { + scan.vertexExportSpecifiers.push(element); + } + return; + } + // A removed symbol has no drop-in successor: leave it (loud break) and warn with guidance. + if (REMOVED_SYMBOL_GUIDANCE[importedNameNode.text] !== undefined) { + scan.removedSymbols.push({ position: importedNameNode.getStart(sourceFile), name: importedNameNode.text }); + return; + } + const newName = SYMBOL_RENAMES[importedNameNode.text]; + if (!newName) { + return; + } + // An un-aliased `export { VertexAI } from '...'` names the file's OWN public export: rename only + // the target and keep the public name via an alias, mirroring the bare local re-export case in + // identifierUsageEdit. `export { VertexAI as Foo } from '...'` already keeps its public name. + const keepPublicName = tsc.isExportSpecifier(element) && !element.propertyName; + scan.edits.push({ + start: importedNameNode.getStart(sourceFile), + end: importedNameNode.getEnd(), + replacement: keepPublicName ? `${newName} as ${importedNameNode.text}` : newName, + }); + scan.editedSpecifierTokenStarts.add(importedNameNode.getStart(sourceFile)); + if (tsc.isImportSpecifier(element) && !element.propertyName) { + scan.renamedLocalBindings.set(element.name.text, newName); + } +}; + +/** + * Pass 2: walk the whole tree, rewrite usages of the bindings found in pass 1 (renamed local + * identifiers and `ns.oldSymbol` member accesses), and classify every getVertexAI reference. + * + * @param fileContext the compiler and parsed source file. + * @param scan the pass-1 result, whose `edits` is extended in place. + * @param vertexBindings local names bound to getVertexAI by a named import. + * @param vertex classification accumulator mutated in place. + */ +export const collectUsageEdits = ( + fileContext: FileContext, + scan: DeclarationScan, + vertexBindings: Map, + vertex: VertexClassification, +): void => { + const { compiler: tsc, sourceFile } = fileContext; + // The binding groups pass 2 edits by name. A getVertexAI options literal containing an actual + // edit site cannot be replaced as a single span (the inner edit would overlap), so + // classification rejects those calls (see containsEditSite). + const editableBindings: EditableBindings = { + renamedNames: new Set(scan.renamedLocalBindings.keys()), + namespaceNames: scan.namespaceBindings, + vertexNames: new Set(vertexBindings.keys()), + }; + forEachNodeDeep(tsc, sourceFile, node => { + const namespaceEdit = namespaceMemberEdit(node, scan.namespaceBindings, fileContext); + if (namespaceEdit) { + scan.edits.push(namespaceEdit); + } + const removedNamespaceMember = namespaceRemovedSymbol(node, scan.namespaceBindings, fileContext); + if (removedNamespaceMember) { + scan.removedSymbols.push(removedNamespaceMember); + } + classifyNamespaceVertexAccess(node, scan.namespaceBindings, fileContext, vertex, editableBindings); + if (tsc.isIdentifier(node)) { + classifyVertexIdentifier(node, vertexBindings, fileContext, vertex, editableBindings); + const usageEdit = identifierUsageEdit(node, scan, fileContext); + if (usageEdit) { + scan.edits.push(usageEdit); + } + } + }); +}; + +/** + * A `ns.` access, in value or type position. No rename exists for it, so the + * site is left as is (a loud break against the new entry point) and warned with guidance. + */ +const namespaceRemovedSymbol = (node: ts.Node, namespaceBindings: Set, fileContext: FileContext): RemovedSymbol | undefined => { + const { compiler: tsc, sourceFile } = fileContext; + if ( + tsc.isPropertyAccessExpression(node) && + tsc.isIdentifier(node.expression) && + namespaceBindings.has(node.expression.text) && + REMOVED_SYMBOL_GUIDANCE[node.name.text] !== undefined + ) { + return { position: node.name.getStart(sourceFile), name: node.name.text }; + } + if ( + tsc.isQualifiedName(node) && + tsc.isIdentifier(node.left) && + namespaceBindings.has(node.left.text) && + REMOVED_SYMBOL_GUIDANCE[node.right.text] !== undefined + ) { + return { position: node.right.getStart(sourceFile), name: node.right.text }; + } + return undefined; +}; + +/** + * Classify a `ns.getVertexAI` member access: a direct call gets the backend-preserving rewrite, + * anything else is left in place and logged. + */ +const classifyNamespaceVertexAccess = ( + node: ts.Node, + namespaceBindings: Set, + fileContext: FileContext, + vertex: VertexClassification, + editableBindings: EditableBindings, +): void => { + const { compiler: tsc, sourceFile } = fileContext; + if ( + !tsc.isPropertyAccessExpression(node) || + !tsc.isIdentifier(node.expression) || + !namespaceBindings.has(node.expression.text) || + node.name.text !== GET_VERTEX_AI + ) { + return; + } + const parent = node.parent; + if (tsc.isCallExpression(parent) && parent.expression === node) { + classifyVertexCall(parent, { namespaceAccess: node }, fileContext, vertex, 'namespace', editableBindings); + return; + } + vertex.unsupported.push({ + position: node.getStart(sourceFile), + reason: `a use of ${node.expression.text}.getVertexAI that is not a direct call`, + origin: 'namespace', + }); +}; + +/** + * Classify one identifier reference to a named getVertexAI import: look-alikes are ignored, a + * direct call gets the backend-preserving rewrite, and any other value reference is left in + * place and logged. + */ +const classifyVertexIdentifier = ( + node: ts.Identifier, + vertexBindings: Map, + fileContext: FileContext, + vertex: VertexClassification, + editableBindings: EditableBindings, +): void => { + const { compiler: tsc, sourceFile } = fileContext; + const binding = vertexBindings.get(node.text); + if (!binding || isNonUsageReference(fileContext, node)) { + return; + } + const parent = node.parent; + // A direct call is the one rewritable shape. + if (tsc.isCallExpression(parent) && parent.expression === node) { + classifyVertexCall(parent, { binding }, fileContext, vertex, 'binding', editableBindings); + return; + } + // Everything else hands the function itself around, where a rename would change backends. + vertex.unsupported.push({ + position: node.getStart(sourceFile), + reason: tsc.isExportSpecifier(parent) + ? 'a re-export of getVertexAI (rewriting it to getAI would silently change its callers\' backend)' + : 'a use of getVertexAI that is not a direct call (only direct calls can be rewritten safely)', + origin: 'binding', + }); +}; + +/** + * Whether pass 2 would actually edit this identifier. Mirrors the passes' own position rules: + * look-alikes (member-access names, property keys, declared names) are never edited, a renamed + * or getVertexAI binding in a value position is, and a namespace name matters only as the left + * side of a member access whose member gets renamed or rewritten. + */ +const producesEdit = (node: ts.Identifier, fileContext: FileContext, editableBindings: EditableBindings): boolean => { + const { compiler: tsc } = fileContext; + if (isNonUsageReference(fileContext, node)) { + return false; + } + if (editableBindings.renamedNames.has(node.text) || editableBindings.vertexNames.has(node.text)) { + return true; + } + if (!editableBindings.namespaceNames.has(node.text)) { + return false; + } + const parent = node.parent; + if (tsc.isPropertyAccessExpression(parent) && parent.expression === node) { + return SYMBOL_RENAMES[parent.name.text] !== undefined || parent.name.text === GET_VERTEX_AI; + } + if (tsc.isQualifiedName(parent) && parent.left === node) { + return SYMBOL_RENAMES[parent.right.text] !== undefined; + } + return false; +}; + +/** + * Whether a subtree contains an identifier that pass 2 would also rewrite. + */ +const containsEditSite = (root: ts.Node, fileContext: FileContext, editableBindings: EditableBindings): boolean => { + const { compiler: tsc } = fileContext; + let found = false; + forEachNodeDeep(tsc, root, node => { + if (found) { + return false; + } + if (tsc.isIdentifier(node) && producesEdit(node, fileContext, editableBindings)) { + found = true; + return false; + } + }); + return found; +}; + +/** + * Whether an options argument is a literal the rewrite understands: an empty object literal, or + * one whose only property is `location` (in either assignment or shorthand form). + */ +const classifyOptionsArgument = (optionsArgument: ts.Expression, fileContext: FileContext, editableBindings: EditableBindings): OptionsClassification => { + const { compiler: tsc, sourceFile } = fileContext; + if (!tsc.isObjectLiteralExpression(optionsArgument)) { + return { supported: false }; + } + // The options literal is replaced as ONE span, so an edit site inside it would produce + // overlapping edits. Such calls are left for manual migration. + if (containsEditSite(optionsArgument, fileContext, editableBindings)) { + return { supported: false, conflicting: true }; + } + if (optionsArgument.properties.length === 0) { + return { supported: true }; + } + const soleProperty = optionsArgument.properties.length === 1 ? optionsArgument.properties[0] : undefined; + if ( + soleProperty && + tsc.isPropertyAssignment(soleProperty) && + tsc.isIdentifier(soleProperty.name) && + soleProperty.name.text === 'location' + ) { + return { supported: true, locationText: soleProperty.initializer.getText(sourceFile) }; + } + if (soleProperty && tsc.isShorthandPropertyAssignment(soleProperty) && soleProperty.name.text === 'location') { + return { supported: true, locationText: 'location' }; + } + return { supported: false }; +}; + +/** + * Decide whether one getVertexAI call's argument shape can be rewritten to + * `getAI(app, { backend: new VertexAIBackend(location?) })` without guessing. + * + * Supported shapes: no arguments, an app argument alone, and an app argument plus an object + * literal whose only property is `location` (or an empty literal). Anything else (a spread, a + * variable holding the options, extra option keys) is left in place and logged. + */ +const classifyVertexCall = ( + call: ts.CallExpression, + target: Pick, + fileContext: FileContext, + vertex: VertexClassification, + origin: 'binding' | 'namespace', + editableBindings: EditableBindings, +): void => { + const { compiler: tsc, sourceFile } = fileContext; + const callArguments = call.arguments; + const hasSpread = callArguments.some(tsc.isSpreadElement); + // Zero arguments or a lone app argument: nothing needs translating. + if (callArguments.length <= 1 && !hasSpread) { + vertex.supportedCalls.push({ call, ...target }); + return; + } + // An app argument plus a literal options object: the location moves into the backend. + let conflicting = false; + if (callArguments.length === 2 && !hasSpread) { + const options = classifyOptionsArgument(callArguments[1], fileContext, editableBindings); + if (options.supported) { + vertex.supportedCalls.push({ call, ...target, locationText: options.locationText }); + return; + } + conflicting = options.conflicting === true; + } + vertex.unsupported.push({ + position: call.getStart(sourceFile), + reason: conflicting + ? 'a getVertexAI call whose options mention other symbols this migration rewrites (replacing both at once would conflict)' + : 'a getVertexAI call whose arguments do not map onto getAI safely (only an optional app argument plus an optional literal `{ location }` object are rewritten)', + origin, + }); +}; + +/** + * Build the edit for a namespaced `ns.oldSymbol` access, in value position (a PropertyAccessExpression + * like `ns.provideVertexAI(...)`) or type position (a QualifiedName like `let x: ns.VertexAI`). + * + * @returns the edit, or undefined when the node is not a renamable namespace access. + */ +const namespaceMemberEdit = (node: ts.Node, namespaceBindings: Set, fileContext: FileContext): TextEdit | undefined => { + const { compiler: tsc, sourceFile } = fileContext; + if ( + tsc.isPropertyAccessExpression(node) && + tsc.isIdentifier(node.expression) && + namespaceBindings.has(node.expression.text) && + SYMBOL_RENAMES[node.name.text] + ) { + return { start: node.name.getStart(sourceFile), end: node.name.getEnd(), replacement: SYMBOL_RENAMES[node.name.text] }; + } + if ( + tsc.isQualifiedName(node) && + tsc.isIdentifier(node.left) && + namespaceBindings.has(node.left.text) && + SYMBOL_RENAMES[node.right.text] + ) { + return { start: node.right.getStart(sourceFile), end: node.right.getEnd(), replacement: SYMBOL_RENAMES[node.right.text] }; + } + return undefined; +}; + +/** + * Build the edit for an identifier that references an un-aliased renamed import. Returns undefined + * for look-alikes that are not references to the import. + * + * @returns the edit, or undefined when the identifier should be left as is. + */ +const identifierUsageEdit = (node: ts.Identifier, scan: DeclarationScan, fileContext: FileContext): TextEdit | undefined => { + const { compiler: tsc, sourceFile } = fileContext; + const replacement = scan.renamedLocalBindings.get(node.text); + if (replacement === undefined) { + return undefined; + } + const start = node.getStart(sourceFile); + // Already renamed in pass 1, or not a reference to the import at all. + if (scan.editedSpecifierTokenStarts.has(start) || isNonUsageReference(fileContext, node)) { + return undefined; + } + const parent = node.parent; + // A bare local re-export `export { VertexAI }` (no `from`) is a usage of the renamed local + // binding. Preserve the external export name by expanding to `export { AI as VertexAI }`. An + // `export { x } from '...'` specifier is instead handled in pass 1. + if (tsc.isExportSpecifier(parent) && parent.name === node && !parent.propertyName) { + const exportDeclaration = parent.parent.parent; + if (tsc.isExportDeclaration(exportDeclaration) && !exportDeclaration.moduleSpecifier) { + return { start, end: node.getEnd(), replacement: `${replacement} as ${node.text}` }; + } + return undefined; + } + // `{ provideVertexAI }` is shorthand for `{ provideVertexAI: provideVertexAI }`. Only the value + // changed, so expand it rather than rename the key. + if (tsc.isShorthandPropertyAssignment(parent) && parent.name === node) { + return { start: node.getEnd(), end: node.getEnd(), replacement: `: ${replacement}` }; + } + return { start, end: node.getEnd(), replacement }; +}; + +/** + * Whether an identifier is not a reference to an imported binding at all: the import specifier + * itself (handled in pass 1), a destructuring key or shorthand binding target (they read a + * property, not the import, while a binding initializer like `{ cb = getVertexAI }` is a real + * usage), or a member-access or declared-property name (see isMemberOrDeclaredName). + */ +const isNonUsageReference = (fileContext: FileContext, node: ts.Identifier): boolean => { + const { compiler: tsc } = fileContext; + const parent = node.parent; + if (tsc.isImportSpecifier(parent) && (parent.propertyName === node || parent.name === node)) { + return true; + } + if (tsc.isBindingElement(parent) && (parent.propertyName === node || (parent.name === node && !parent.propertyName))) { + return true; + } + return isMemberOrDeclaredName(fileContext, node, parent); +}; + +/** + * Whether an identifier is a member-access name (`obj.getVertexAI`) or a declared + * property/accessor/enum-member name rather than a value reference to the import. + */ +const isMemberOrDeclaredName = (fileContext: FileContext, node: ts.Identifier, parent: ts.Node): boolean => { + const { compiler: tsc } = fileContext; + const isMemberName = + (tsc.isPropertyAccessExpression(parent) && parent.name === node) || + (tsc.isQualifiedName(parent) && parent.right === node); + const isDeclaredPropertyName = + (tsc.isPropertyAssignment(parent) || + tsc.isPropertyDeclaration(parent) || + tsc.isPropertySignature(parent) || + tsc.isMethodDeclaration(parent) || + tsc.isGetAccessorDeclaration(parent) || + tsc.isSetAccessorDeclaration(parent) || + tsc.isEnumMember(parent)) && + parent.name === node; + return isMemberName || isDeclaredPropertyName; +}; diff --git a/src/schematics/update/v21/vertexai-to-ai/safety.ts b/src/schematics/update/v21/vertexai-to-ai/safety.ts new file mode 100644 index 000000000..020da17f3 --- /dev/null +++ b/src/schematics/update/v21/vertexai-to-ai/safety.ts @@ -0,0 +1,156 @@ +// The analyses that decide when rewriting is NOT safe: local declarations that shadow a +// tracked import name, and bindings of the injected names (getAI, VertexAIBackend) that come +// from somewhere other than AI Logic. Both matter because the passes match identifiers by +// text without scope analysis, and the design rule is to leave code whole and warn rather +// than ever change behavior silently. + +import type * as ts from 'typescript'; +import { forEachNodeDeep } from './ast-walk.js'; +import { AI_MODULE_SPECIFIERS, BACKEND_CLASS, GET_AI, GET_VERTEX_AI, MODULE_SPECIFIER_REWRITES, SYMBOL_RENAMES } from './tables.js'; +import type { FileContext, ShadowedImport } from './types.js'; + +/** + * The import/export statements that reference an old Vertex AI entry point. + */ +const oldModuleStatementsOf = (fileContext: FileContext): Set => { + const { compiler: tsc, sourceFile } = fileContext; + const statements = new Set(); + for (const statement of sourceFile.statements) { + if ( + (tsc.isImportDeclaration(statement) || tsc.isExportDeclaration(statement)) && + statement.moduleSpecifier && + tsc.isStringLiteral(statement.moduleSpecifier) && + MODULE_SPECIFIER_REWRITES[statement.moduleSpecifier.text] + ) { + statements.add(statement); + } + } + return statements; +}; + +/** + * The local names bound by the old-module imports whose usages the passes rewrite BY NAME: + * un-aliased renamed imports, every getVertexAI import (aliased or not, its calls are rewritten), + * and namespace imports. Aliased renames are absent: their usages are never edited. + */ +const collectTrackedLocalNames = (fileContext: FileContext, oldModuleStatements: Set): Set => { + const { compiler: tsc } = fileContext; + const names = new Set(); + for (const statement of oldModuleStatements) { + if (!tsc.isImportDeclaration(statement)) { + continue; + } + const namedBindings = statement.importClause?.namedBindings; + if (!namedBindings) { + continue; + } + if (tsc.isNamespaceImport(namedBindings)) { + names.add(namedBindings.name.text); + continue; + } + for (const element of namedBindings.elements) { + const importedName = (element.propertyName ?? element.name).text; + if (importedName === GET_VERTEX_AI || (SYMBOL_RENAMES[importedName] && !element.propertyName)) { + names.add(element.name.text); + } + } + } + return names; +}; + +/** + * The local name a node declares, for shadow detection. Lexical declarations only: class members + * and object properties do not shadow imports. + */ +const declaredName = (fileContext: FileContext, node: ts.Node): string | undefined => { + const { compiler: tsc } = fileContext; + if ((tsc.isVariableDeclaration(node) || tsc.isParameter(node) || tsc.isBindingElement(node)) && tsc.isIdentifier(node.name)) { + return node.name.text; + } + if ( + (tsc.isFunctionDeclaration(node) || + tsc.isFunctionExpression(node) || + tsc.isClassDeclaration(node) || + tsc.isClassExpression(node) || + tsc.isInterfaceDeclaration(node) || + tsc.isTypeAliasDeclaration(node) || + tsc.isEnumDeclaration(node)) && + node.name + ) { + return node.name.text; + } + if (tsc.isTypeParameterDeclaration(node)) { + return node.name.text; + } + if (tsc.isImportSpecifier(node) || tsc.isNamespaceImport(node)) { + return node.name.text; + } + if (tsc.isImportClause(node) && node.name) { + return node.name.text; + } + return undefined; +}; + +/** + * Whether an import binding is the AI Logic module's own export under its own name, making it + * safe for rewritten getVertexAI calls to reuse. Anything else bound to an injected name (an + * aliased or default import, a namespace import, or an import from an unrelated module) is a + * conflict. + */ +const isReusableAIImport = (fileContext: FileContext, node: ts.Node): boolean => { + const { compiler: tsc } = fileContext; + if (!tsc.isImportSpecifier(node) || node.propertyName) { + return false; + } + let current: ts.Node | undefined = node; + while (current && !tsc.isImportDeclaration(current)) { + current = current.parent; + } + if (!current || !tsc.isImportDeclaration(current)) { + return false; + } + return tsc.isStringLiteral(current.moduleSpecifier) && AI_MODULE_SPECIFIERS.has(current.moduleSpecifier.text); +}; + +/** + * Which tracked names are also declared locally somewhere outside the old-module imports. The + * usage passes match identifiers by text without scope analysis, so any such name is unsafe to + * rewrite (a shadowed local usage would be redirected to the import) and gets excluded instead. + */ +export const collectShadowedNames = (fileContext: FileContext): Set => { + const { compiler: tsc, sourceFile } = fileContext; + const oldModuleStatements = oldModuleStatementsOf(fileContext); + const trackedNames = collectTrackedLocalNames(fileContext, oldModuleStatements); + const shadowed = new Set(); + if (trackedNames.size === 0) { + return shadowed; + } + forEachNodeDeep(tsc, sourceFile, node => { + if (oldModuleStatements.has(node)) { + return false; + } + const declared = declaredName(fileContext, node); + if (declared && trackedNames.has(declared)) { + shadowed.add(declared); + } + }); + return shadowed; +}; + +/** + * Bindings of getAI or VertexAIBackend that do NOT come from an AI Logic module: a lexical + * declaration, or any other import shape. The rewrite injects references to these names, so a + * foreign binding would capture the rewritten calls or collide with the injected import. The + * file's getVertexAI imports are left unmigrated instead. + */ +export const collectInjectionConflicts = (fileContext: FileContext): ShadowedImport[] => { + const { compiler: tsc, sourceFile } = fileContext; + const conflicts: ShadowedImport[] = []; + forEachNodeDeep(tsc, sourceFile, node => { + const declared = declaredName(fileContext, node); + if ((declared === GET_AI || declared === BACKEND_CLASS) && !isReusableAIImport(fileContext, node)) { + conflicts.push({ position: node.getStart(sourceFile), name: declared }); + } + }); + return conflicts; +}; diff --git a/src/schematics/update/v21/vertexai-to-ai/tables.ts b/src/schematics/update/v21/vertexai-to-ai/tables.ts new file mode 100644 index 000000000..7e125e52e --- /dev/null +++ b/src/schematics/update/v21/vertexai-to-ai/tables.ts @@ -0,0 +1,44 @@ +// The data tables that define the Vertex AI -> AI Logic migration surface: which module +// specifiers move where, which symbols are one-to-one renames, and which names the call +// rewrite injects. Pure data, no logic. + +// Both the AngularFire entry points and the firebase SDK's own entry points are rewritten: this +// migration also moves the workspace to firebase 12, where `firebase/vertexai` is gone. +export const MODULE_SPECIFIER_REWRITES: Record = { + '@angular/fire/vertexai': '@angular/fire/ai', + '@angular/fire/vertexai-preview': '@angular/fire/ai', + 'firebase/vertexai': 'firebase/ai', + 'firebase/vertexai-preview': 'firebase/ai', +}; + +// Straight one-to-one renames. getVertexAI is deliberately absent: getAI and getVertexAI +// coexisted in the old modules and default to DIFFERENT backends (plain getAI() talks to the +// Gemini Developer API), so getVertexAI gets a backend-preserving call rewrite instead: see +// vertex-edits.ts. +export const SYMBOL_RENAMES: Record = { + provideVertexAI: 'provideAI', + VertexAI: 'AI', + VertexAIError: 'AIError', + VertexAIErrorCode: 'AIErrorCode', + VertexAIModel: 'AIModel', + VertexAIInstances: 'AIInstances', + vertexAIInstance$: 'AIInstance$', + VertexAIModule: 'AIModule', +}; + +// Old exports that were removed rather than renamed: their successor is not a drop-in +// replacement, so the import keeps its name (breaking loudly) and the log explains why. +export const REMOVED_SYMBOL_GUIDANCE: Record = { + VertexAIOptions: 'the new AIOptions takes a backend instead of a location, rebuild the options by hand', +}; + +export const GET_VERTEX_AI = 'getVertexAI'; +export const GET_AI = 'getAI'; +export const BACKEND_CLASS = 'VertexAIBackend'; + +// Modules whose getAI / VertexAIBackend are (or become, once rewritten) the AI Logic ones. A +// binding of those names from anywhere else must not be captured by the rewritten calls. +export const AI_MODULE_SPECIFIERS = new Set([ + ...Object.keys(MODULE_SPECIFIER_REWRITES), + ...Object.values(MODULE_SPECIFIER_REWRITES), +]); diff --git a/src/schematics/update/v21/vertexai-to-ai/types.ts b/src/schematics/update/v21/vertexai-to-ai/types.ts new file mode 100644 index 000000000..05b29da04 --- /dev/null +++ b/src/schematics/update/v21/vertexai-to-ai/types.ts @@ -0,0 +1,131 @@ +// The interfaces the rewrite pipeline passes between its stages: text edits, the pass-1 scan +// result, the getVertexAI classification, and the per-file outcome handed back for logging. +// FileContext carries the resolved TypeScript compiler with the parsed file, so no module +// holds compiler state. + +import type * as ts from 'typescript'; + +/** The resolved TypeScript compiler and the parsed file every stage operates on. */ +export interface FileContext { + compiler: typeof ts; + sourceFile: ts.SourceFile; +} + +/** A single text replacement to apply to a source file, as a `[start, end)` span and its new text. */ +export interface TextEdit { + start: number; + end: number; + replacement: string; +} + +/** A named getVertexAI import discovered in pass 1, resolved to edits only after its usages are classified. */ +export interface VertexImport { + element: ts.ImportSpecifier; + statement: ts.ImportDeclaration; + localName: string; +} + +/** One getVertexAI occurrence the rewrite intentionally leaves in place, for the migration log. */ +export interface UnsupportedVertexUsage { + position: number; + reason: string; + /** + * 'binding' usages force every named-import getVertexAI edit in the file to be skipped (a + * half-rewritten binding cannot compile). 'namespace' and 'export' usages only affect themselves. + */ + origin: 'binding' | 'namespace' | 'export'; +} + +/** A getVertexAI call whose argument shape the rewrite understands. */ +export interface SupportedVertexCall { + call: ts.CallExpression; + /** The `location` option's source text when the call passed `{ location: ... }`, else undefined. */ + locationText?: string; + /** Set for `getVertexAI(...)` through a named import, undefined for `ns.getVertexAI(...)`. */ + binding?: VertexImport; + /** Set for `ns.getVertexAI(...)`. The backend class is then reached as `ns.VertexAIBackend`. */ + namespaceAccess?: ts.PropertyAccessExpression; +} + +/** What classifying every getVertexAI reference in a file produced. */ +export interface VertexClassification { + supportedCalls: SupportedVertexCall[]; + unsupported: UnsupportedVertexUsage[]; +} + +/** An import binding excluded from the rewrite because a local declaration reuses its name. */ +export interface ShadowedImport { + position: number; + name: string; +} + +/** An imported name that no longer exists in the new entry points (see REMOVED_SYMBOL_GUIDANCE). */ +export interface RemovedSymbol { + position: number; + name: string; +} + +/** What pass 1 (declarations) discovers and hands to pass 2 (usages). */ +export interface DeclarationScan { + edits: TextEdit[]; + /** + * Un-aliased imported names whose in-file usages must also be renamed (an aliased import + * keeps its local name, so it is not tracked here). + */ + renamedLocalBindings: Map; + /** + * Namespace import names (import * as ns) whose `ns.oldSymbol` accesses must be renamed + * in both value position (PropertyAccessExpression) and type position (QualifiedName). + */ + namespaceBindings: Set; + /** Specifier name tokens already edited in pass 1. The usage pass must not touch them again. */ + editedSpecifierTokenStarts: Set; + /** Named getVertexAI imports, held back from editing until pass 2 classifies their usages. */ + vertexImports: VertexImport[]; + /** + * getVertexAI inside `export { ... } from ''`: always left in place (rewriting it + * to getAI would silently change which backend the re-export's callers reach). + */ + vertexExportSpecifiers: ts.ExportSpecifier[]; + /** + * Import bindings excluded because the file declares a local of the same name: the usage passes + * match by identifier text, so a shadowed name would get its LOCAL usages silently redirected + * to the import. Excluded bindings are left whole (loud compile break) and warned about. + */ + shadowedImports: ShadowedImport[]; + /** + * `export * from ''` statements: rewriting the specifier would silently rename the + * file's re-exported public symbols, so the statement is left whole and warned about. + */ + starExportPositions: number[]; + /** Imported names that were removed rather than renamed: left as is (loud break) and warned. */ + removedSymbols: RemovedSymbol[]; +} + +/** The getVertexAI edit set buildVertexEdits produces for one file. */ +export interface VertexEdits { + edits: TextEdit[]; + /** Offsets of getVertexAI calls rewritten to the backend-preserving getAI form, for the log. */ + callPositions: number[]; + /** Offsets of rewritable calls skipped because the file's getVertexAI edits are blocked. */ + blockedCallPositions: number[]; + /** Whether a getAI / VertexAIBackend binding from a non AI Logic source blocked the edits. */ + injectionBlocked: boolean; +} + +/** The full result of rewriting one source file. */ +export interface FileRewrite { + edits: TextEdit[]; + /** Offsets of getVertexAI calls rewritten to the backend-preserving getAI form, for the log. */ + vertexCallPositions: number[]; + /** Offsets of rewritable calls skipped because the file's getVertexAI edits are blocked. */ + blockedCallPositions: number[]; + unsupportedVertexUsages: UnsupportedVertexUsage[]; + shadowedImports: ShadowedImport[]; + /** Bindings of getAI / VertexAIBackend from a non AI Logic source that blocked the rewrite. */ + injectionConflicts: ShadowedImport[]; + /** `export * from ''` statements, always left for manual migration. */ + starExportPositions: number[]; + /** Imported names that were removed rather than renamed: left as is (loud break) and warned. */ + removedSymbols: RemovedSymbol[]; +} diff --git a/src/schematics/update/v21/vertexai-to-ai/vertex-edits.ts b/src/schematics/update/v21/vertexai-to-ai/vertex-edits.ts new file mode 100644 index 000000000..59c9e42e3 --- /dev/null +++ b/src/schematics/update/v21/vertexai-to-ai/vertex-edits.ts @@ -0,0 +1,162 @@ +// Builders for the getVertexAI edits: the backend-preserving call rewrite, the import +// specifier rename/repurpose/removal, and the VertexAIBackend import, with file-wide +// dedup so no binding is ever injected twice. + +import type * as ts from 'typescript'; +import { BACKEND_CLASS, GET_AI, GET_VERTEX_AI } from './tables.js'; +import type { DeclarationScan, FileContext, ShadowedImport, SupportedVertexCall, TextEdit, VertexClassification, VertexEdits, VertexImport } from './types.js'; + +/** + * Turn the classified getVertexAI references into edits: rewritten calls, the import specifier + * rename/replacement/removal, and the VertexAIBackend import. Every named-binding edit in the + * file is skipped, so the binding stays coherent (the old import then fails to compile loudly, + * and the log says why), when either a named-binding reference was unsupported or the file binds + * getAI / VertexAIBackend from a non AI Logic source that the injected references would hit. + * + * @returns the getVertexAI edits plus the rewritten and skipped call positions for the log. + */ +export const buildVertexEdits = ( + fileContext: FileContext, + scan: DeclarationScan, + vertex: VertexClassification, + injectionConflicts: ShadowedImport[], +): VertexEdits => { + const { sourceFile } = fileContext; + const edits: TextEdit[] = []; + const callPositions: number[] = []; + const blockedCallPositions: number[] = []; + const injectionBlocked = injectionConflicts.length > 0 && scan.vertexImports.length > 0; + const bindingBlocked = vertex.unsupported.some(usage => usage.origin === 'binding') || injectionBlocked; + + const statementsNeedingBackend = new Set(); + for (const supported of vertex.supportedCalls) { + // A blocked file skips its named-binding calls, recording each for the log. + if (supported.binding && bindingBlocked) { + blockedCallPositions.push(supported.call.getStart(sourceFile)); + continue; + } + // Namespace calls reach the backend as `ns.VertexAIBackend`, named imports as a bare name. + const backendReference = supported.namespaceAccess + ? `${supported.namespaceAccess.expression.getText(sourceFile)}.${BACKEND_CLASS}` + : BACKEND_CLASS; + // Rename an un-aliased callee to getAI. An aliased local keeps its name. + if (supported.binding) { + statementsNeedingBackend.add(supported.binding.statement); + if (supported.binding.localName === GET_VERTEX_AI) { + const callee = supported.call.expression; + edits.push({ start: callee.getStart(sourceFile), end: callee.getEnd(), replacement: GET_AI }); + } + } + // Rename `ns.getVertexAI` to `ns.getAI`. + if (supported.namespaceAccess) { + const nameNode = supported.namespaceAccess.name; + edits.push({ start: nameNode.getStart(sourceFile), end: nameNode.getEnd(), replacement: GET_AI }); + } + // Pin the call's arguments to the Vertex AI backend. + edits.push(...vertexArgumentEdits(supported, fileContext, backendReference)); + callPositions.push(supported.call.getStart(sourceFile)); + } + + if (!bindingBlocked) { + // Local-name checks are FILE-wide and the backend import is added at most once, so two + // getVertexAI specifiers (or two old-module statements) cannot inject duplicate bindings. + const importedLocals = importedLocalNames(fileContext); + let backendProvided = importedLocals.has(BACKEND_CLASS); + for (const vertexImport of scan.vertexImports) { + const addBackend = statementsNeedingBackend.has(vertexImport.statement) && !backendProvided; + edits.push(...vertexSpecifierEdits(vertexImport, addBackend, importedLocals.has(GET_AI), fileContext)); + if (addBackend) { + backendProvided = true; + } + } + } + return { edits, callPositions, blockedCallPositions, injectionBlocked }; +}; + +/** Every local name bound by any import declaration in the file (default, namespace, and named). */ +const importedLocalNames = (fileContext: FileContext): Set => { + const { compiler: tsc, sourceFile } = fileContext; + const names = new Set(); + for (const statement of sourceFile.statements) { + if (!tsc.isImportDeclaration(statement) || !statement.importClause) { + continue; + } + if (statement.importClause.name) { + names.add(statement.importClause.name.text); + } + const namedBindings = statement.importClause.namedBindings; + if (!namedBindings) { + continue; + } + if (tsc.isNamespaceImport(namedBindings)) { + names.add(namedBindings.name.text); + } else { + for (const element of namedBindings.elements) { + names.add(element.name.text); + } + } + } + return names; +}; + +/** + * Build the argument edits that pin one rewritten call to the Vertex AI backend. + */ +const vertexArgumentEdits = (supported: SupportedVertexCall, fileContext: FileContext, backendReference: string): TextEdit[] => { + const { sourceFile } = fileContext; + const backendObject = `{ backend: new ${backendReference}(${supported.locationText ?? ''}) }`; + const callArguments = supported.call.arguments; + if (callArguments.length === 0) { + // getAI's app parameter is optional but positional, so the options need an explicit undefined. + return [{ start: supported.call.getEnd() - 1, end: supported.call.getEnd() - 1, replacement: `undefined, ${backendObject}` }]; + } + if (callArguments.length === 1) { + const appArgument = callArguments[0]; + return [{ start: appArgument.getEnd(), end: appArgument.getEnd(), replacement: `, ${backendObject}` }]; + } + const optionsArgument = callArguments[1]; + return [{ start: optionsArgument.getStart(sourceFile), end: optionsArgument.getEnd(), replacement: backendObject }]; +}; + +/** + * Build the import-specifier edits for one named getVertexAI import: rename it to getAI, or, when + * the file already binds a local getAI, remove or repurpose it, and add the VertexAIBackend import + * when the caller says a rewritten call needs it (at most once per file). + */ +const vertexSpecifierEdits = (vertexImport: VertexImport, addBackend: boolean, fileHasGetAILocal: boolean, fileContext: FileContext): TextEdit[] => { + const { compiler: tsc, sourceFile } = fileContext; + const edits: TextEdit[] = []; + const namedBindings = vertexImport.statement.importClause?.namedBindings; + if (!namedBindings || !tsc.isNamedImports(namedBindings)) { + return edits; + } + const elements = namedBindings.elements; + const element = vertexImport.element; + const importedNameNode = element.propertyName ?? element.name; + + // A local getAI already exists (this statement or another import): renaming would bind getAI + // twice, so the specifier is repurposed or removed instead. + if (!element.propertyName && fileHasGetAILocal) { + // Repurpose it for the backend import. + if (addBackend) { + return [{ start: element.getStart(sourceFile), end: element.getEnd(), replacement: BACKEND_CLASS }]; + } + // The only specifier: leave an empty named import behind. + if (elements.length === 1) { + return [{ start: element.getStart(sourceFile), end: element.getEnd(), replacement: '' }]; + } + // Remove the specifier together with the comma between it and its neighbor. + const index = elements.indexOf(element); + return index > 0 + ? [{ start: elements[index - 1].getEnd(), end: element.getEnd(), replacement: '' }] + : [{ start: element.getStart(sourceFile), end: elements[1].getStart(sourceFile), replacement: '' }]; + } + + // Rename getVertexAI to getAI (on the imported name, so an alias keeps its local name), and + // append the backend import when a rewritten call needs it. + edits.push({ start: importedNameNode.getStart(sourceFile), end: importedNameNode.getEnd(), replacement: GET_AI }); + if (addBackend) { + edits.push({ start: element.getEnd(), end: element.getEnd(), replacement: `, ${BACKEND_CLASS}` }); + } + return edits; +}; diff --git a/tools/build.ts b/tools/build.ts index 5b3397360..0d8dd38c4 100644 --- a/tools/build.ts +++ b/tools/build.ts @@ -340,7 +340,10 @@ async function compileSchematics() { "rxjs", "@schematics/angular", "jsonc-parser", - "firebase-tools" + "firebase-tools", + // The v21 migration parses user source with the TypeScript compiler; resolve it from + // the workspace at ng-update time instead of bundling ~3.5MB into the package. + "typescript" ], outdir: dest('schematics'), });