diff --git a/README.md b/README.md index 1f39f663f..ef32d1eaa 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..b4541ac02 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 the symbols were renamed (`getVertexAI` to `getAI`, `provideVertexAI` to `provideAI`, `VertexAI` to `AI`). Running `ng update @angular/fire` rewrites these for you. 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..3f5600fcd --- /dev/null +++ b/docs/version-21-upgrade.md @@ -0,0 +1,35 @@ +# 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` | `getAI` | +| `provideVertexAI` | `provideAI` | +| `VertexAI` | `AI` | +| `VertexAIInstances` | `AIInstances` | +| `vertexAIInstance$` | `AIInstance$` | +| `VertexAIModule` | `AIModule` | + +`ng update @angular/fire` rewrites these imports and identifiers for you. `getGenerativeModel` and `getImagenModel` keep their names. If you import directly from the Firebase SDK, note it also renamed `firebase/vertexai` to `firebase/ai`. 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/schematics/update/v21/index.ts b/src/schematics/update/v21/index.ts index 3920d9b90..e41f6cab4 100644 --- a/src/schematics/update/v21/index.ts +++ b/src/schematics/update/v21/index.ts @@ -3,6 +3,7 @@ import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; // /tasks directory specifier only resolves under CommonJS. import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks/index.js'; import { alignFirebaseVersion } from '../../common.js'; +import { rewriteVertexAIToAI } from './vertexai-to-ai.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. @@ -10,6 +11,9 @@ export const ngUpdate = (): Rule => ( host: Tree, context: SchematicContext ) => { + // Rewrite Vertex AI imports to AI Logic (source-only edits, no dependency change). + rewriteVertexAIToAI(host, context); + // Align firebase. This step changes dependencies, so only it schedules an install. if (alignFirebaseVersion(host, context)) { context.addTask(new NodePackageInstallTask()); } 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..c7a94e95b --- /dev/null +++ b/src/schematics/update/v21/vertexai-to-ai.jasmine.ts @@ -0,0 +1,258 @@ +import { logging } from '@angular-devkit/core'; +import { HostTree, SchematicContext } from '@angular-devkit/schematics'; +import { rewriteVertexAIToAI } from './vertexai-to-ai.js'; +import 'jasmine'; + +const context = { logger: new logging.Logger('test') } as unknown as SchematicContext; + +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', () => { + 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); + + expect(changed).toBe(true); + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain(`from '@angular/fire/ai'`); + expect(out).not.toContain('vertexai'); + expect(out).toContain('provideAI(() => getAI())'); + expect(out).toContain('inject(AI)'); + expect(out).not.toContain('VertexAI'); + }); + + 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); + + 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); + + expect(tree.readText('src/app/foo.ts')).toBe(`import { getAI } from '@angular/fire/ai';`); + }); + + it('rewrites namespace-import member accesses', () => { + 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); + + 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())'); + }); + + it('leaves unchanged symbols alone', () => { + const tree = treeWith({ + 'src/app/foo.ts': `import { getGenerativeModel, getVertexAI } from '@angular/fire/vertexai';`, + }); + + rewriteVertexAIToAI(tree, context); + + 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); + + 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); + + 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)).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); + + expect(changed).toBe(true); + expect(tree.readText('projects/lib/src/foo.ts')).toBe(`import { getAI } from '@angular/fire/ai';`); + }); + + 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); + + 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); + + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain('fire.AI'); + expect(out).toContain('fire.getAI()'); + expect(out).not.toContain('VertexAI'); + }); + + it('expands a shorthand property instead of changing its key', () => { + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `export const registry = { getVertexAI };`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context); + + expect(tree.readText('src/app/foo.ts')).toContain('{ getVertexAI: getAI }'); + }); + + 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); + + 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 and instance observable', () => { + const tree = treeWith({ + 'src/app/foo.ts': `import { VertexAIInstances, vertexAIInstance$ } from '@angular/fire/vertexai';`, + }); + + rewriteVertexAIToAI(tree, context); + + expect(tree.readText('src/app/foo.ts')) + .toBe(`import { AIInstances, AIInstance$ } 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); + + const out = tree.readText('src/app/foo.ts'); + expect(out).toContain('get getVertexAI()'); + expect(out).toContain('getAI()'); + }); + + it('does not rename a destructuring property key, but does rename a binding initializer', () => { + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `export function a(obj: any) { const { getVertexAI: local } = obj; return local; }`, + `export function b({ cb = getVertexAI }: any) { return cb; }`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, context); + + 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 default initializer IS a genuine use of the import, so it is renamed + expect(out).toContain('cb = getAI'); + }); + + 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); + const afterFirst = tree.readText('src/app/foo.ts'); + const changedAgain = rewriteVertexAIToAI(tree, context); + + expect(changedAgain).toBe(false); + expect(tree.readText('src/app/foo.ts')).toBe(afterFirst); + }); + +}); diff --git a/src/schematics/update/v21/vertexai-to-ai.ts b/src/schematics/update/v21/vertexai-to-ai.ts new file mode 100644 index 000000000..3e5c14c0f --- /dev/null +++ b/src/schematics/update/v21/vertexai-to-ai.ts @@ -0,0 +1,325 @@ +import { posix } from 'path'; +import { SchematicContext, Tree } from '@angular-devkit/schematics'; +import * as ts from 'typescript'; +import { overwriteIfExists, safeReadJSON } from '../../common.js'; + +const OLD_MODULE_SPECIFIERS = ['@angular/fire/vertexai', '@angular/fire/vertexai-preview']; +const NEW_MODULE_SPECIFIER = '@angular/fire/ai'; + +const SYMBOL_RENAMES: Record = { + getVertexAI: 'getAI', + provideVertexAI: 'provideAI', + VertexAI: 'AI', + VertexAIInstances: 'AIInstances', + vertexAIInstance$: 'AIInstance$', + VertexAIModule: 'AIModule', +}; + +/** A single text replacement to apply to a source file, as a `[start, end)` span and its new text. */ +interface TextEdit { + start: number; + end: number; + replacement: string; +} + +/** What pass 1 (declarations) discovers and hands to pass 2 (usages). */ +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; +} + +/** + * Collect every edit needed to move one source file off the old Vertex AI entry points and symbols. + * Runs in two passes: pass 1 rewrites the import/export declarations and records which local + * bindings are in play, then pass 2 rewrites their usages. + * + * @param sourceFile the parsed source file to inspect (never mutated). + * @returns the edits to apply, possibly empty. + */ +const collectEditsForSourceFile = (sourceFile: ts.SourceFile): TextEdit[] => { + const scan = scanDeclarations(sourceFile); + if (scan.renamedLocalBindings.size === 0 && scan.namespaceBindings.size === 0) { + return scan.edits; + } + collectUsageEdits(sourceFile, scan); + return scan.edits; +}; + +/** + * 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 sourceFile the parsed source file. + * @returns the accumulated edits plus the binding maps for pass 2. + */ +const scanDeclarations = (sourceFile: ts.SourceFile): DeclarationScan => { + const scan: DeclarationScan = { + edits: [], + renamedLocalBindings: new Map(), + namespaceBindings: new Set(), + editedSpecifierTokenStarts: new Set(), + }; + for (const statement of sourceFile.statements) { + collectDeclarationEdits(statement, sourceFile, scan); + } + 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 sourceFile the file it belongs to (for token offsets). + * @param scan accumulator mutated in place. + */ +const collectDeclarationEdits = (statement: ts.Statement, sourceFile: ts.SourceFile, scan: DeclarationScan): void => { + const isImport = ts.isImportDeclaration(statement); + const isExport = ts.isExportDeclaration(statement); + if (!isImport && !isExport) { + return; + } + const moduleSpecifier = statement.moduleSpecifier; + if (!moduleSpecifier || !ts.isStringLiteral(moduleSpecifier) || !OLD_MODULE_SPECIFIERS.includes(moduleSpecifier.text)) { + return; + } + + // Rewrite the module specifier text, preserving the surrounding quotes. + scan.edits.push({ + start: moduleSpecifier.getStart(sourceFile) + 1, + end: moduleSpecifier.getEnd() - 1, + replacement: NEW_MODULE_SPECIFIER, + }); + + const namedBindings = isImport ? statement.importClause?.namedBindings : statement.exportClause; + if (namedBindings && ts.isNamespaceImport(namedBindings)) { + scan.namespaceBindings.add(namedBindings.name.text); + return; + } + if (namedBindings && (ts.isNamedImports(namedBindings) || ts.isNamedExports(namedBindings))) { + for (const element of namedBindings.elements) { + collectSpecifierEdit(element, isImport, sourceFile, scan); + } + } +}; + +/** + * 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. + * + * @param element the `{ x }` or `{ x as y }` specifier. + * @param isImport whether the specifier belongs to an import (only imports create a local binding). + * @param sourceFile the file it belongs to (for token offsets). + * @param scan accumulator mutated in place. + */ +const collectSpecifierEdit = ( + element: ts.ImportSpecifier | ts.ExportSpecifier, + isImport: boolean, + sourceFile: ts.SourceFile, + scan: DeclarationScan, +): void => { + const importedNameNode = element.propertyName ?? element.name; + const newName = SYMBOL_RENAMES[importedNameNode.text]; + if (!newName) { + return; + } + scan.edits.push({ + start: importedNameNode.getStart(sourceFile), + end: importedNameNode.getEnd(), + replacement: newName, + }); + scan.editedSpecifierTokenStarts.add(importedNameNode.getStart(sourceFile)); + if (isImport && !element.propertyName) { + scan.renamedLocalBindings.set(element.name.text, newName); + } +}; + +/** + * Pass 2: walk the whole tree and rewrite usages of the bindings found in pass 1, namely renamed + * local identifiers and `ns.oldSymbol` member accesses. + * + * @param sourceFile the parsed source file. + * @param scan the pass-1 result, whose `edits` is extended in place. + */ +const collectUsageEdits = (sourceFile: ts.SourceFile, scan: DeclarationScan): void => { + const visit = (node: ts.Node): void => { + const namespaceEdit = namespaceMemberEdit(node, scan.namespaceBindings, sourceFile); + if (namespaceEdit) { + scan.edits.push(namespaceEdit); + } + if (ts.isIdentifier(node)) { + const usageEdit = identifierUsageEdit(node, scan, sourceFile); + if (usageEdit) { + scan.edits.push(usageEdit); + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); +}; + +/** + * Build the edit for a namespaced `ns.oldSymbol` access, in value position (a PropertyAccessExpression + * like `ns.getVertexAI()`) 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, sourceFile: ts.SourceFile): TextEdit | undefined => { + if ( + ts.isPropertyAccessExpression(node) && + ts.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 ( + ts.isQualifiedName(node) && + ts.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: the import/export specifier itself, member names, + * property/accessor/enum declaration names, and destructuring keys. + * + * @returns the edit, or undefined when the identifier should be left as is. + */ +const identifierUsageEdit = (node: ts.Identifier, scan: DeclarationScan, sourceFile: ts.SourceFile): TextEdit | undefined => { + const replacement = scan.renamedLocalBindings.get(node.text); + if (replacement === undefined) { + return undefined; + } + const start = node.getStart(sourceFile); + if (scan.editedSpecifierTokenStarts.has(start)) { + return undefined; + } + const parent = node.parent; + + // Import specifier name: already handled in pass 1. + if (ts.isImportSpecifier(parent) && (parent.propertyName === node || parent.name === node)) { + return undefined; + } + // 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 (ts.isExportSpecifier(parent) && parent.name === node && !parent.propertyName) { + const exportDeclaration = parent.parent.parent; + if (ts.isExportDeclaration(exportDeclaration) && !exportDeclaration.moduleSpecifier) { + return { start, end: node.getEnd(), replacement: `${replacement} as ${node.text}` }; + } + return undefined; + } + // `{ getVertexAI }` is shorthand for `{ getVertexAI: getVertexAI }`. Only the value changed, so + // expand it rather than rename the key. + if (ts.isShorthandPropertyAssignment(parent) && parent.name === node) { + return { start: node.getEnd(), end: node.getEnd(), replacement: `: ${replacement}` }; + } + // A destructuring key or shorthand binding target (`const { getVertexAI } = x` or + // `const { getVertexAI: local } = x`) reads a property, not the import, so leave it. A binding + // initializer (`{ cb = getVertexAI }`) is a real usage and is not matched by this branch. + if (ts.isBindingElement(parent) && (parent.propertyName === node || (parent.name === node && !parent.propertyName))) { + return undefined; + } + if (isMemberOrDeclaredName(node, parent)) { + return undefined; + } + return { start, end: node.getEnd(), replacement }; +}; + +/** + * 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 = (node: ts.Identifier, parent: ts.Node): boolean => { + const isMemberName = + (ts.isPropertyAccessExpression(parent) && parent.name === node) || + (ts.isQualifiedName(parent) && parent.right === node); + const isDeclaredPropertyName = + (ts.isPropertyAssignment(parent) || + ts.isPropertyDeclaration(parent) || + ts.isPropertySignature(parent) || + ts.isMethodDeclaration(parent) || + ts.isGetAccessorDeclaration(parent) || + ts.isSetAccessorDeclaration(parent) || + ts.isEnumMember(parent)) && + parent.name === node; + return isMemberName || isDeclaredPropertyName; +}; + +/** + * Apply edits to the source text. + * + * @param content the original file text. + * @param edits non-overlapping edits. Applied back to front so earlier offsets stay valid. + * @returns the rewritten text. + */ +const applyEdits = (content: string, edits: TextEdit[]): string => { + return edits + .slice() + .sort((a, b) => b.start - a.start) + .reduce((text, edit) => text.slice(0, edit.start) + edit.replacement + text.slice(edit.end), content); +}; + +/** + * `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. + * + * @returns true if any file was rewritten. + */ +export const rewriteVertexAIToAI = (host: Tree, _context: SchematicContext): boolean => { + const angularJson = host.exists('angular.json') && safeReadJSON('angular.json', host); + if (!angularJson?.projects) { + return false; + } + // angular.json's `sourceRoot` is already workspace-relative and includes the project root, + // so use it directly (falling back to `root`). Joining both would double-count the prefix. + // Tree paths are always posix, so join with posix separators regardless of the host OS. + const srcRoots: string[] = Object.values(angularJson.projects) + .map((project: any) => project.sourceRoot || project.root) + .filter((base: string) => !!base) + .map((base: string) => posix.join('/', base)); + if (srcRoots.length === 0) { + return false; + } + + let changed = false; + host.visit(filePath => { + if ( + !filePath.endsWith('.ts') || + filePath.endsWith('.d.ts') || + !srcRoots.find(root => filePath === root || filePath.startsWith(root + '/')) + ) { + return; + } + const content = host.read(filePath)?.toString(); + if (!content || !OLD_MODULE_SPECIFIERS.some(specifier => content.includes(specifier))) { + return; + } + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); + const edits = collectEditsForSourceFile(sourceFile); + if (edits.length === 0) { + return; + } + const newContent = applyEdits(content, edits); + if (newContent !== content) { + overwriteIfExists(host, filePath, newContent); + changed = true; + } + }); + return changed; +}; 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'), });