diff --git a/packages/angular/cli/BUILD.bazel b/packages/angular/cli/BUILD.bazel index a49e30695138..eed9ad7360f1 100644 --- a/packages/angular/cli/BUILD.bazel +++ b/packages/angular/cli/BUILD.bazel @@ -4,7 +4,7 @@ # found in the LICENSE file at https://angular.dev/license load("@npm//:defs.bzl", "npm_link_all_packages") -load("//tools:defaults.bzl", "jasmine_test", "ng_examples_db", "npm_package", "ts_project") +load("//tools:defaults.bzl", "jasmine_test", "npm_package", "ts_project") load("//tools:ng_cli_schema_generator.bzl", "cli_json_schema") load("//tools:ts_json_schema.bzl", "ts_json_schema") @@ -36,7 +36,6 @@ RUNTIME_ASSETS = glob( ], ) + [ "//packages/angular/cli:lib/config/schema.json", - "//packages/angular/cli:lib/code-examples.db", ":angular_best_practices", ] @@ -87,17 +86,6 @@ ts_project( ], ) -ng_examples_db( - name = "cli_example_database", - srcs = glob( - include = [ - "lib/examples/**/*.md", - ], - ), - out = "lib/code-examples.db", - path = "packages/angular/cli/lib/examples", -) - CLI_SCHEMA_DATA = [ "//packages/angular/build:schemas", "//packages/angular_devkit/build_angular:schemas", diff --git a/packages/angular/cli/lib/examples/if-block.md b/packages/angular/cli/lib/examples/if-block.md deleted file mode 100644 index 806e3d05516c..000000000000 --- a/packages/angular/cli/lib/examples/if-block.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -title: 'Using the @if Built-in Control Flow Block' -summary: 'Demonstrates how to use the @if built-in control flow block to conditionally render content in an Angular template based on a boolean expression.' -keywords: - - '@if' - - 'control flow' - - 'conditional rendering' - - 'template syntax' -related_concepts: - - '@else' - - '@else if' - - 'signals' -related_tools: - - 'modernize' ---- - -## Purpose - -The purpose of this pattern is to create dynamic user interfaces by controlling which elements are rendered to the DOM based on the application's state. This is a fundamental technique for building responsive and interactive components. - -## When to Use - -Use the `@if` block as the modern, preferred alternative to the `*ngIf` directive for all conditional rendering. It offers better type-checking and a cleaner, more intuitive syntax within the template. - -## Key Concepts - -- **`@if` block:** The primary syntax for conditional rendering in modern Angular templates. It evaluates a boolean expression and renders the content within its block if the expression is true. - -## Example Files - -### `conditional-content.component.ts` - -This is a self-contained standalone component that demonstrates the `@if` block with an optional `@else` block. - -```typescript -import { Component, signal } from '@angular/core'; - -@Component({ - selector: 'app-conditional-content', - template: ` - - - @if (isVisible()) { -
This content is conditionally displayed.
- } @else { -
The content is hidden. Click the button to show it.
- } - `, -}) -export class ConditionalContentComponent { - protected readonly isVisible = signal(true); - - toggleVisibility(): void { - this.isVisible.update((v) => !v); - } -} -``` - -## Usage Notes - -- The expression inside the `@if ()` block must evaluate to a boolean. -- This example uses a signal, which is a common pattern, but any boolean property or method call from the component can be used. -- The `@else` block is optional and is rendered when the `@if` condition is `false`. - -## How to Use This Example - -### 1. Import the Component - -In a standalone architecture, import the component into the `imports` array of the parent component where you want to use it. - -```typescript -// in app.component.ts -import { Component } from '@angular/core'; -import { ConditionalContentComponent } from './conditional-content.component'; - -@Component({ - selector: 'app-root', - imports: [ConditionalContentComponent], - template: ` -

My Application

- - `, -}) -export class AppComponent {} -``` diff --git a/packages/angular/cli/src/commands/mcp/mcp-server.ts b/packages/angular/cli/src/commands/mcp/mcp-server.ts index a2bc1b0f9aeb..c7beb7c83397 100644 --- a/packages/angular/cli/src/commands/mcp/mcp-server.ts +++ b/packages/angular/cli/src/commands/mcp/mcp-server.ts @@ -21,7 +21,6 @@ import { DEVSERVER_STOP_TOOL } from './tools/devserver/devserver-stop'; import { DEVSERVER_WAIT_FOR_BUILD_TOOL } from './tools/devserver/devserver-wait-for-build'; import { DOC_SEARCH_TOOL } from './tools/doc-search'; import { E2E_TOOL } from './tools/e2e'; -import { FIND_EXAMPLE_TOOL } from './tools/examples/index'; import { MODERNIZE_TOOL } from './tools/modernize'; import { ZONELESS_MIGRATION_TOOL } from './tools/onpush-zoneless-migration/zoneless-migration'; import { LIST_PROJECTS_TOOL } from './tools/projects'; @@ -41,7 +40,6 @@ const STABLE_TOOLS = [ AI_TUTOR_TOOL, BEST_PRACTICES_TOOL, DOC_SEARCH_TOOL, - FIND_EXAMPLE_TOOL, LIST_PROJECTS_TOOL, ZONELESS_MIGRATION_TOOL, ] as const; @@ -107,7 +105,6 @@ equivalent actions. * **3. Answer User Questions:** - For conceptual questions ("what is..."), use \`search_documentation\`. - - For code examples ("show me how to..."), use \`find_examples\`. @@ -163,9 +160,6 @@ export function assembleToolDeclarations( } const enabledExperimentalTools = new Set(options.experimentalTools); - if (process.env['NG_MCP_CODE_EXAMPLES'] === '1') { - enabledExperimentalTools.add('find_examples'); - } for (const [toolGroupName, toolGroup] of Object.entries(EXPERIMENTAL_TOOL_GROUPS)) { if (enabledExperimentalTools.delete(toolGroupName)) { for (const tool of toolGroup) { diff --git a/packages/angular/cli/src/commands/mcp/tools/examples/database-discovery.ts b/packages/angular/cli/src/commands/mcp/tools/examples/database-discovery.ts deleted file mode 100644 index aeab96e15871..000000000000 --- a/packages/angular/cli/src/commands/mcp/tools/examples/database-discovery.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.dev/license - */ - -import { dirname, isAbsolute, relative, resolve } from 'node:path'; -import type { McpToolContext } from '../tool-registry'; - -/** - * A list of known Angular packages that may contain example databases. - * The tool will attempt to resolve and load example databases from these packages. - */ -const KNOWN_EXAMPLE_PACKAGES = ['@angular/core', '@angular/aria', '@angular/forms']; - -/** - * Attempts to find version-specific example databases from the user's installed - * versions of known Angular packages. It looks for a custom `angular` metadata property in each - * package's `package.json` to locate the database. - * - * @example A sample `package.json` `angular` field: - * ```json - * { - * "angular": { - * "examples": { - * "format": "sqlite", - * "path": "./resources/code-examples.db" - * } - * } - * } - * ``` - * - * @param workspacePath The absolute path to the user's `angular.json` file. - * @param logger The MCP tool context logger for reporting warnings. - * @param host The host interface for file system and module resolution operations. - * @returns A promise that resolves to an array of objects, each containing a database path and source. - */ -export async function getVersionSpecificExampleDatabases( - workspacePath: string, - logger: McpToolContext['logger'], - host: McpToolContext['host'], -): Promise<{ dbPath: string; source: string }[]> { - const databases: { dbPath: string; source: string }[] = []; - - for (const packageName of KNOWN_EXAMPLE_PACKAGES) { - // 1. Resolve the path to package.json - let pkgJsonPath: string; - try { - pkgJsonPath = host.resolveModule(`${packageName}/package.json`, workspacePath); - } catch (e) { - // This is not a warning because the user may not have all known packages installed. - continue; - } - - // 2. Read and parse package.json, then find the database. - try { - const pkgJsonContent = await host.readFile(pkgJsonPath, 'utf-8'); - const pkgJson = JSON.parse(pkgJsonContent); - const examplesInfo = pkgJson['angular']?.examples; - - if ( - examplesInfo && - examplesInfo.format === 'sqlite' && - typeof examplesInfo.path === 'string' - ) { - const packageDirectory = dirname(pkgJsonPath); - const dbPath = resolve(packageDirectory, examplesInfo.path); - - // Ensure the resolved database path is within the package boundary. - const relativePath = relative(packageDirectory, dbPath); - if (relativePath.startsWith('..') || isAbsolute(relativePath)) { - logger.warn( - `Detected a potential path traversal attempt in '${pkgJsonPath}'. ` + - `The path '${examplesInfo.path}' escapes the package boundary. ` + - 'This database will be skipped.', - ); - continue; - } - - // Check the file size to prevent reading a very large file. - const stats = await host.stat(dbPath); - if (stats.size > 10 * 1024 * 1024) { - // 10MB - logger.warn( - `The example database at '${dbPath}' is larger than 10MB (${stats.size} bytes). ` + - 'This is unexpected and the file will not be used.', - ); - continue; - } - - const source = `package ${packageName}@${pkgJson.version}`; - databases.push({ dbPath, source }); - } - } catch (e) { - logger.warn( - `Failed to read or parse version-specific examples metadata referenced in '${pkgJsonPath}': ${ - e instanceof Error ? e.message : e - }.`, - ); - } - } - - return databases; -} diff --git a/packages/angular/cli/src/commands/mcp/tools/examples/database-discovery_spec.ts b/packages/angular/cli/src/commands/mcp/tools/examples/database-discovery_spec.ts deleted file mode 100644 index 0d5680d01c06..000000000000 --- a/packages/angular/cli/src/commands/mcp/tools/examples/database-discovery_spec.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.dev/license - */ - -import type { Stats } from 'node:fs'; -import { Host } from '../../host'; -import { getVersionSpecificExampleDatabases } from './database-discovery'; - -describe('getVersionSpecificExampleDatabases', () => { - let mockHost: jasmine.SpyObj; - let mockLogger: { warn: jasmine.Spy }; - - beforeEach(() => { - mockHost = jasmine.createSpyObj('Host', ['resolveModule', 'readFile', 'stat']); - mockLogger = { - warn: jasmine.createSpy('warn'), - }; - }); - - it('should find a valid example database from a package', async () => { - mockHost.resolveModule.and.callFake((specifier) => { - if (specifier === '@angular/core/package.json') { - return '/path/to/node_modules/@angular/core/package.json'; - } - throw new Error(`Unexpected module specifier: ${specifier}`); - }); - mockHost.readFile.and.resolveTo( - JSON.stringify({ - name: '@angular/core', - version: '18.1.0', - angular: { - examples: { - format: 'sqlite', - path: './resources/code-examples.db', - }, - }, - }), - ); - mockHost.stat.and.resolveTo({ size: 1024 } as Stats); - - const databases = await getVersionSpecificExampleDatabases( - '/path/to/workspace', - mockLogger, - mockHost, - ); - - expect(databases.length).toBe(1); - expect(databases[0].dbPath).toBe( - '/path/to/node_modules/@angular/core/resources/code-examples.db', - ); - expect(databases[0].source).toBe('package @angular/core@18.1.0'); - expect(mockLogger.warn).not.toHaveBeenCalled(); - }); - - it('should skip packages without angular.examples metadata', async () => { - mockHost.resolveModule.and.returnValue('/path/to/node_modules/@angular/core/package.json'); - mockHost.readFile.and.resolveTo(JSON.stringify({ name: '@angular/core', version: '18.1.0' })); - - const databases = await getVersionSpecificExampleDatabases( - '/path/to/workspace', - mockLogger, - mockHost, - ); - - expect(databases.length).toBe(0); - }); - - it('should handle packages that are not found', async () => { - mockHost.resolveModule.and.throwError(new Error('Cannot find module')); - - const databases = await getVersionSpecificExampleDatabases( - '/path/to/workspace', - mockLogger, - mockHost, - ); - - expect(databases.length).toBe(0); - expect(mockLogger.warn).not.toHaveBeenCalled(); - }); - - it('should reject database paths that attempt path traversal', async () => { - mockHost.resolveModule.and.returnValue('/path/to/node_modules/@angular/core/package.json'); - mockHost.readFile.and.resolveTo( - JSON.stringify({ - name: '@angular/core', - version: '18.1.0', - angular: { - examples: { - format: 'sqlite', - path: '../outside-package/danger.db', - }, - }, - }), - ); - - const databases = await getVersionSpecificExampleDatabases( - '/path/to/workspace', - mockLogger, - mockHost, - ); - - expect(databases.length).toBe(0); - expect(mockLogger.warn).toHaveBeenCalledWith( - jasmine.stringMatching(/Detected a potential path traversal attempt/), - ); - }); - - it('should skip database files larger than 10MB', async () => { - mockHost.resolveModule.and.returnValue('/path/to/node_modules/@angular/core/package.json'); - mockHost.readFile.and.resolveTo( - JSON.stringify({ - name: '@angular/core', - version: '18.1.0', - angular: { - examples: { - format: 'sqlite', - path: './resources/code-examples.db', - }, - }, - }), - ); - mockHost.stat.and.resolveTo({ size: 11 * 1024 * 1024 } as Stats); // 11MB - - const databases = await getVersionSpecificExampleDatabases( - '/path/to/workspace', - mockLogger, - mockHost, - ); - - expect(databases.length).toBe(0); - expect(mockLogger.warn).toHaveBeenCalledWith(jasmine.stringMatching(/is larger than 10MB/)); - }); -}); diff --git a/packages/angular/cli/src/commands/mcp/tools/examples/database.ts b/packages/angular/cli/src/commands/mcp/tools/examples/database.ts deleted file mode 100644 index cd4f31a655c0..000000000000 --- a/packages/angular/cli/src/commands/mcp/tools/examples/database.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.dev/license - */ - -import type { DatabaseSync, SQLInputValue } from 'node:sqlite'; -import { escapeSearchQuery } from './query-escaper'; -import type { FindExampleInput } from './schemas'; - -const EXPECTED_SCHEMA_VERSION = 1; - -/** - * Validates the schema version of the example database. - * - * @param db The database connection to validate. - * @param dbSource A string identifying the source of the database (e.g., 'bundled' or a version number). - * @throws An error if the schema version is missing or incompatible. - */ -export function validateDatabaseSchema(db: DatabaseSync, dbSource: string): void { - const schemaVersionResult = db - .prepare('SELECT value FROM metadata WHERE key = ?') - .get('schema_version') as { value: string } | undefined; - const actualSchemaVersion = schemaVersionResult ? Number(schemaVersionResult.value) : undefined; - - if (actualSchemaVersion !== EXPECTED_SCHEMA_VERSION) { - db.close(); - - let errorMessage: string; - if (actualSchemaVersion === undefined) { - errorMessage = 'The example database is missing a schema version and cannot be used.'; - } else if (actualSchemaVersion > EXPECTED_SCHEMA_VERSION) { - errorMessage = - `This project's example database (version ${actualSchemaVersion})` + - ` is newer than what this version of the Angular CLI supports (version ${EXPECTED_SCHEMA_VERSION}).` + - ' Please update your `@angular/cli` package to a newer version.'; - } else { - errorMessage = - `This version of the Angular CLI (expects schema version ${EXPECTED_SCHEMA_VERSION})` + - ` requires a newer example database than the one found in this project (version ${actualSchemaVersion}).`; - } - - throw new Error( - `Incompatible example database schema from source '${dbSource}':\n${errorMessage}`, - ); - } -} - -export function queryDatabase(dbs: DatabaseSync[], input: FindExampleInput) { - const { query, keywords, required_packages, related_concepts, includeExperimental } = input; - - // Build the query dynamically - const params: SQLInputValue[] = []; - let sql = - `SELECT e.title, e.summary, e.keywords, e.required_packages, e.related_concepts, e.related_tools, e.content, ` + - // The `snippet` function generates a contextual snippet of the matched text. - // Column 6 is the `content` column. We highlight matches with asterisks and limit the snippet size. - "snippet(examples_fts, 6, '**', '**', '...', 15) AS snippet, " + - // The `bm25` function returns the relevance score of the match. The weights - // assigned to each column boost the ranking of documents where the search - // term appears in a more important field. - // Column order: title, summary, keywords, required_packages, related_concepts, related_tools, content - 'bm25(examples_fts, 10.0, 5.0, 5.0, 1.0, 2.0, 1.0, 1.0) AS rank ' + - 'FROM examples e JOIN examples_fts ON e.id = examples_fts.rowid'; - const whereClauses = []; - - // FTS query - if (query) { - whereClauses.push('examples_fts MATCH ?'); - params.push(escapeSearchQuery(query)); - } - - // JSON array filters - const addJsonFilter = (column: string, values: string[] | undefined) => { - if (values?.length) { - for (const value of values) { - whereClauses.push(`e.${column} LIKE ?`); - params.push(`%"${value}"%`); - } - } - }; - - addJsonFilter('keywords', keywords); - addJsonFilter('required_packages', required_packages); - addJsonFilter('related_concepts', related_concepts); - - if (!includeExperimental) { - whereClauses.push('e.experimental = 0'); - } - - if (whereClauses.length > 0) { - sql += ` WHERE ${whereClauses.join(' AND ')}`; - } - - // Query database and return results - const examples = []; - const textContent = []; - - for (const db of dbs) { - const queryStatement = db.prepare(sql); - for (const exampleRecord of queryStatement.all(...params)) { - const record = exampleRecord as Record; - const example = { - title: record['title'] as string, - summary: record['summary'] as string, - keywords: JSON.parse((record['keywords'] as string) || '[]') as string[], - required_packages: JSON.parse((record['required_packages'] as string) || '[]') as string[], - related_concepts: JSON.parse((record['related_concepts'] as string) || '[]') as string[], - related_tools: JSON.parse((record['related_tools'] as string) || '[]') as string[], - content: record['content'] as string, - snippet: record['snippet'] as string, - rank: record['rank'] as number, - }; - examples.push(example); - } - } - - // Order the combined results by relevance. - // The `bm25` algorithm returns a smaller number for a more relevant match. - examples.sort((a, b) => a.rank - b.rank); - - // The `rank` field is an internal implementation detail for sorting and should not be - // returned to the user. We create a new array of examples without the `rank`. - const finalExamples = examples.map(({ rank, ...rest }) => rest); - - for (const example of finalExamples) { - // Also create a more structured text output - let text = `## Example: ${example.title}\n**Summary:** ${example.summary}`; - if (example.snippet) { - text += `\n**Snippet:** ${example.snippet}`; - } - text += `\n\n---\n\n${example.content}`; - textContent.push({ type: 'text' as const, text }); - } - - return { - content: textContent, - structuredContent: { examples: finalExamples }, - }; -} diff --git a/packages/angular/cli/src/commands/mcp/tools/examples/index.ts b/packages/angular/cli/src/commands/mcp/tools/examples/index.ts deleted file mode 100644 index 8ceecf2d840d..000000000000 --- a/packages/angular/cli/src/commands/mcp/tools/examples/index.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.dev/license - */ - -import type { DatabaseSync } from 'node:sqlite'; -import { type McpToolContext, declareTool } from '../tool-registry'; -import { queryDatabase, validateDatabaseSchema } from './database'; -import { getVersionSpecificExampleDatabases } from './database-discovery'; -import { setupRuntimeExamples } from './runtime-database'; -import { type FindExampleInput, findExampleInputSchema, findExampleOutputSchema } from './schemas'; -import { suppressSqliteWarning } from './utils'; - -export const FIND_EXAMPLE_TOOL = declareTool({ - name: 'find_examples', - title: 'Find Angular Code Examples', - description: ` - -Augments your knowledge base with a curated database of official, best-practice code examples, -focusing on **modern, new, and recently updated** Angular features. This tool acts as a RAG -(Retrieval-Augmented Generation) source, providing ground-truth information on the latest Angular -APIs and patterns. You **MUST** use it to understand and apply current standards when working with -new or evolving features. - - -* **Knowledge Augmentation:** Learning about new or updated Angular features (e.g., query: 'signal input' or 'deferrable views'). -* **Modern Implementation:** Finding the correct modern syntax for features - (e.g., query: 'functional route guard' or 'http client with fetch'). -* **Refactoring to Modern Patterns:** Upgrading older code by finding examples of new syntax - (e.g., query: 'built-in control flow' to replace "*ngIf"). -* **Advanced Filtering:** Combining a full-text search with filters to narrow results. - (e.g., query: 'forms', required_packages: ['@angular/forms'], keywords: ['validation']) - - -* **Project-Specific Use (Recommended):** For tasks inside a user's project, you **MUST** provide the - \`workspacePath\` argument to get examples that match the project's Angular version. Get this - path from \`list_projects\`. -* **General Use:** If no project context is available (e.g., for general questions or learning), - you can call the tool without the \`workspacePath\` argument. It will return the latest - generic examples. -* **Tool Selection:** This database primarily contains examples for new and recently updated Angular - features. For established, core features, the main documentation (via the - \`search_documentation\` tool) may be a better source of information. -* The examples in this database are the single source of truth for modern Angular coding patterns. -* The search query uses a powerful full-text search syntax (FTS5). Refer to the 'query' - parameter description for detailed syntax rules and examples. -* You can combine the main 'query' with optional filters like 'keywords', 'required_packages', - and 'related_concepts' to create highly specific searches. -`, - inputSchema: findExampleInputSchema.shape, - outputSchema: findExampleOutputSchema.shape, - isReadOnly: true, - isLocalOnly: true, - shouldRegister: ({ logger }) => { - // sqlite database support requires Node.js 22.16+ - const [nodeMajor, nodeMinor] = process.versions.node.split('.', 2).map(Number); - if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 16)) { - logger.warn( - `MCP tool 'find_examples' requires Node.js 22.16 (or higher). ` + - ' Registration of this tool has been skipped.', - ); - - return false; - } - - return true; - }, - factory: createFindExampleHandler, -}); - -async function createFindExampleHandler({ logger, exampleDatabasePath, host }: McpToolContext) { - const runtimeDb = process.env['NG_MCP_EXAMPLES_DIR'] - ? await setupRuntimeExamples(process.env['NG_MCP_EXAMPLES_DIR'], host) - : undefined; - - suppressSqliteWarning(); - - return async (input: FindExampleInput) => { - // If the dev-time override is present, use it and bypass all other logic. - if (runtimeDb) { - return queryDatabase([runtimeDb], input); - } - - const resolvedDbs: { path: string; source: string }[] = []; - - // First, try to get all available version-specific guides. - if (input.workspacePath) { - const versionSpecificDbs = await getVersionSpecificExampleDatabases( - input.workspacePath, - logger, - host, - ); - for (const db of versionSpecificDbs) { - resolvedDbs.push({ path: db.dbPath, source: db.source }); - } - } - - // If no version-specific guides were found for any reason, fall back to the bundled version. - if (resolvedDbs.length === 0 && exampleDatabasePath) { - resolvedDbs.push({ path: exampleDatabasePath, source: 'bundled' }); - } - - if (resolvedDbs.length === 0) { - // This should be prevented by the registration logic in mcp-server.ts - throw new Error('No example databases are available.'); - } - - const { DatabaseSync } = await import('node:sqlite'); - const dbConnections: DatabaseSync[] = []; - - for (const { path, source } of resolvedDbs) { - const db = new DatabaseSync(path, { readOnly: true }); - try { - validateDatabaseSchema(db, source); - dbConnections.push(db); - } catch (e) { - logger.warn((e as Error).message); - // If a database is invalid, we should not query it, but we should not fail the whole tool. - // We will just skip this database and try to use the others. - continue; - } - } - - if (dbConnections.length === 0) { - throw new Error('All available example databases were invalid. Cannot perform query.'); - } - - return queryDatabase(dbConnections, input); - }; -} diff --git a/packages/angular/cli/src/commands/mcp/tools/examples/query-escaper.ts b/packages/angular/cli/src/commands/mcp/tools/examples/query-escaper.ts deleted file mode 100644 index bb6acd375d45..000000000000 --- a/packages/angular/cli/src/commands/mcp/tools/examples/query-escaper.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.dev/license - */ - -/** - * Escapes a search query for FTS5 by tokenizing and quoting terms. - * - * This function processes a raw search string and prepares it for an FTS5 full-text search. - * It correctly handles quoted phrases, logical operators (AND, OR, NOT), parentheses, - * and prefix searches (ending with an asterisk), ensuring that individual search - * terms are properly quoted to be treated as literals by the search engine. - * This is primarily intended to avoid unintentional usage of FTS5 query syntax by consumers. - * - * @param query The raw search query string. - * @returns A sanitized query string suitable for FTS5. - */ -export function escapeSearchQuery(query: string): string { - // This regex tokenizes the query string into parts: - // 1. Quoted phrases (e.g., "foo bar") - // 2. Parentheses ( and ) - // 3. FTS5 operators (AND, OR, NOT, NEAR) - // 4. Words, which can include a trailing asterisk for prefix search (e.g., foo*) - const tokenizer = /"([^"]*)"|([()])|\b(AND|OR|NOT|NEAR)\b|([^\s()]+)/g; - let match; - const result: string[] = []; - let lastIndex = 0; - - while ((match = tokenizer.exec(query)) !== null) { - // Add any whitespace or other characters between tokens - if (match.index > lastIndex) { - result.push(query.substring(lastIndex, match.index)); - } - - const [, quoted, parenthesis, operator, term] = match; - - if (quoted !== undefined) { - // It's a quoted phrase, keep it as is. - result.push(`"${quoted}"`); - } else if (parenthesis) { - // It's a parenthesis, keep it as is. - result.push(parenthesis); - } else if (operator) { - // It's an operator, keep it as is. - result.push(operator); - } else if (term) { - // It's a term that needs to be quoted. - if (term.endsWith('*')) { - result.push(`"${term.slice(0, -1)}"*`); - } else { - result.push(`"${term}"`); - } - } - lastIndex = tokenizer.lastIndex; - } - - // Add any remaining part of the string - if (lastIndex < query.length) { - result.push(query.substring(lastIndex)); - } - - return result.join(''); -} diff --git a/packages/angular/cli/src/commands/mcp/tools/examples/query-escaper_spec.ts b/packages/angular/cli/src/commands/mcp/tools/examples/query-escaper_spec.ts deleted file mode 100644 index 6aa801fe0349..000000000000 --- a/packages/angular/cli/src/commands/mcp/tools/examples/query-escaper_spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.dev/license - */ - -import { escapeSearchQuery } from './query-escaper'; - -describe('escapeSearchQuery', () => { - it('should wrap single terms in double quotes', () => { - expect(escapeSearchQuery('foo')).toBe('"foo"'); - }); - - it('should wrap multiple terms in double quotes', () => { - expect(escapeSearchQuery('foo bar')).toBe('"foo" "bar"'); - }); - - it('should not wrap FTS5 operators', () => { - expect(escapeSearchQuery('foo AND bar')).toBe('"foo" AND "bar"'); - expect(escapeSearchQuery('foo OR bar')).toBe('"foo" OR "bar"'); - expect(escapeSearchQuery('foo NOT bar')).toBe('"foo" NOT "bar"'); - expect(escapeSearchQuery('foo NEAR bar')).toBe('"foo" NEAR "bar"'); - }); - - it('should not wrap terms that are already quoted', () => { - expect(escapeSearchQuery('"foo" bar')).toBe('"foo" "bar"'); - expect(escapeSearchQuery('"foo bar"')).toBe('"foo bar"'); - }); - - it('should handle prefix searches', () => { - expect(escapeSearchQuery('foo*')).toBe('"foo"*'); - expect(escapeSearchQuery('foo* bar')).toBe('"foo"* "bar"'); - }); - - it('should handle multi-word quoted phrases', () => { - expect(escapeSearchQuery('"foo bar" baz')).toBe('"foo bar" "baz"'); - expect(escapeSearchQuery('foo "bar baz"')).toBe('"foo" "bar baz"'); - }); - - it('should handle complex queries', () => { - expect(escapeSearchQuery('("foo bar" OR baz) AND qux*')).toBe( - '("foo bar" OR "baz") AND "qux"*', - ); - }); - - it('should handle multi-word quoted phrases with three or more words', () => { - expect(escapeSearchQuery('"foo bar baz" qux')).toBe('"foo bar baz" "qux"'); - expect(escapeSearchQuery('foo "bar baz qux"')).toBe('"foo" "bar baz qux"'); - expect(escapeSearchQuery('foo "bar baz qux" quux')).toBe('"foo" "bar baz qux" "quux"'); - }); -}); diff --git a/packages/angular/cli/src/commands/mcp/tools/examples/runtime-database.ts b/packages/angular/cli/src/commands/mcp/tools/examples/runtime-database.ts deleted file mode 100644 index 5ca74dc60d63..000000000000 --- a/packages/angular/cli/src/commands/mcp/tools/examples/runtime-database.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.dev/license - */ - -import { join } from 'node:path'; -import type { DatabaseSync } from 'node:sqlite'; -import { z } from 'zod'; -import type { McpToolContext } from '../tool-registry'; - -/** - * A simple YAML front matter parser. - * - * This function extracts the YAML block enclosed by `---` at the beginning of a string - * and parses it into a JavaScript object. It is not a full YAML parser and only - * supports simple key-value pairs and string arrays. - * - * @param content The string content to parse. - * @returns A record containing the parsed front matter data. - */ -function parseFrontmatter(content: string): Record { - const match = content.match(/^---\r?\n(.*?)\r?\n---/s); - if (!match) { - return {}; - } - - const frontmatter = match[1]; - const data: Record = {}; - const lines = frontmatter.split(/\r?\n/); - - let currentKey = ''; - let isArray = false; - const arrayValues: string[] = []; - - for (const line of lines) { - const keyValueMatch = line.match(/^([^:]+):\s*(.*)/); - if (keyValueMatch) { - if (currentKey && isArray) { - data[currentKey] = arrayValues.slice(); - arrayValues.length = 0; - } - - const [, key, value] = keyValueMatch; - currentKey = key.trim(); - isArray = value.trim() === ''; - - if (!isArray) { - const trimmedValue = value.trim(); - if (trimmedValue === 'true') { - data[currentKey] = true; - } else if (trimmedValue === 'false') { - data[currentKey] = false; - } else { - data[currentKey] = trimmedValue; - } - } - } else { - const arrayItemMatch = line.match(/^\s*-\s*(.*)/); - if (arrayItemMatch && currentKey && isArray) { - let value = arrayItemMatch[1].trim(); - // Unquote if the value is quoted. - if ( - (value.startsWith("'") && value.endsWith("'")) || - (value.startsWith('"') && value.endsWith('"')) - ) { - value = value.slice(1, -1); - } - arrayValues.push(value); - } - } - } - - if (currentKey && isArray) { - data[currentKey] = arrayValues; - } - - return data; -} - -export async function setupRuntimeExamples( - examplesPath: string, - host: McpToolContext['host'], -): Promise { - const { DatabaseSync } = await import('node:sqlite'); - const db = new DatabaseSync(':memory:'); - - // Create a relational table to store the structured example data. - db.exec(` - CREATE TABLE metadata ( - key TEXT PRIMARY KEY NOT NULL, - value TEXT NOT NULL - ); - `); - - db.exec(` - INSERT INTO metadata (key, value) VALUES - ('schema_version', '1'), - ('created_at', '${new Date().toISOString()}'); - `); - - db.exec(` - CREATE TABLE examples ( - id INTEGER PRIMARY KEY, - title TEXT NOT NULL, - summary TEXT NOT NULL, - keywords TEXT, - required_packages TEXT, - related_concepts TEXT, - related_tools TEXT, - experimental INTEGER NOT NULL DEFAULT 0, - content TEXT NOT NULL - ); - `); - - // Create an FTS5 virtual table to provide full-text search capabilities. - db.exec(` - CREATE VIRTUAL TABLE examples_fts USING fts5( - title, - summary, - keywords, - required_packages, - related_concepts, - related_tools, - content, - content='examples', - content_rowid='id', - tokenize = 'porter ascii' - ); - `); - - // Create triggers to keep the FTS table synchronized with the examples table. - db.exec(` - CREATE TRIGGER examples_after_insert AFTER INSERT ON examples BEGIN - INSERT INTO examples_fts(rowid, title, summary, keywords, required_packages, related_concepts, related_tools, content) - VALUES ( - new.id, new.title, new.summary, new.keywords, new.required_packages, new.related_concepts, - new.related_tools, new.content - ); - END; - `); - - const insertStatement = db.prepare( - 'INSERT INTO examples(' + - 'title, summary, keywords, required_packages, related_concepts, related_tools, experimental, content' + - ') VALUES(?, ?, ?, ?, ?, ?, ?, ?);', - ); - - const frontmatterSchema = z.object({ - title: z.string(), - summary: z.string(), - keywords: z.array(z.string()).optional(), - required_packages: z.array(z.string()).optional(), - related_concepts: z.array(z.string()).optional(), - related_tools: z.array(z.string()).optional(), - experimental: z.boolean().optional(), - }); - - db.exec('BEGIN TRANSACTION'); - for await (const entry of host.glob('**/*.md', { cwd: examplesPath })) { - if (!entry.isFile()) { - continue; - } - - const content = await host.readFile(join(entry.parentPath, entry.name), 'utf-8'); - const frontmatter = parseFrontmatter(content); - - const validation = frontmatterSchema.safeParse(frontmatter); - if (!validation.success) { - // eslint-disable-next-line no-console - console.warn(`Skipping invalid example file ${entry.name}:`, validation.error.issues); - continue; - } - - const { - title, - summary, - keywords, - required_packages, - related_concepts, - related_tools, - experimental, - } = validation.data; - - insertStatement.run( - title, - summary, - JSON.stringify(keywords ?? []), - JSON.stringify(required_packages ?? []), - JSON.stringify(related_concepts ?? []), - JSON.stringify(related_tools ?? []), - experimental ? 1 : 0, - content, - ); - } - db.exec('END TRANSACTION'); - - return db; -} diff --git a/packages/angular/cli/src/commands/mcp/tools/examples/schemas.ts b/packages/angular/cli/src/commands/mcp/tools/examples/schemas.ts deleted file mode 100644 index 2122f6775bc8..000000000000 --- a/packages/angular/cli/src/commands/mcp/tools/examples/schemas.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.dev/license - */ - -import { z } from 'zod'; - -export const findExampleInputSchema = z.object({ - workspacePath: z - .string() - .optional() - .describe( - 'The absolute path to the `angular.json` file for the workspace. This is used to find the ' + - 'version-specific code examples that correspond to the installed version of the ' + - 'Angular framework. You **MUST** get this path from the `list_projects` tool. ' + - 'If omitted, the tool will search the generic code examples bundled with the CLI.', - ), - query: z - .string() - .describe( - "The primary, conceptual search query. This should capture the user's main goal or question " + - "(e.g., 'lazy loading a route' or 'how to use signal inputs'). The query will be processed " + - 'by a powerful full-text search engine.\n\n' + - 'Key Syntax Features (see https://www.sqlite.org/fts5.html for full documentation):\n' + - ' - AND (default): Space-separated terms are combined with AND.\n' + - ' - Example: \'standalone component\' (finds results with both "standalone" and "component")\n' + - ' - OR: Use the OR operator to find results with either term.\n' + - " - Example: 'validation OR validator'\n" + - ' - NOT: Use the NOT operator to exclude terms.\n' + - " - Example: 'forms NOT reactive'\n" + - ' - Grouping: Use parentheses () to group expressions.\n' + - " - Example: '(validation OR validator) AND forms'\n" + - ' - Phrase Search: Use double quotes "" for exact phrases.\n' + - ' - Example: \'"template-driven forms"\'\n' + - ' - Prefix Search: Use an asterisk * for prefix matching.\n' + - ' - Example: \'rout*\' (matches "route", "router", "routing")', - ), - keywords: z - .array(z.string()) - .optional() - .describe( - 'A list of specific, exact keywords to narrow the search. Use this for precise terms like ', - ), - required_packages: z - .array(z.string()) - .optional() - .describe( - "A list of NPM packages that an example must use. Use this when the user's request is " + - 'specific to a feature within a certain package (e.g., if the user asks about `ngModel`, ' + - 'you should filter by `@angular/forms`).', - ), - related_concepts: z - .array(z.string()) - .optional() - .describe( - 'A list of high-level concepts to filter by. Use this to find examples related to broader ' + - 'architectural ideas or patterns (e.g., `signals`, `dependency injection`, `routing`).', - ), - includeExperimental: z - .boolean() - .optional() - .default(false) - .describe( - 'By default, this tool returns only production-safe examples. Set this to `true` **only if** ' + - 'the user explicitly asks for a bleeding-edge feature or if a stable solution to their ' + - 'problem cannot be found. If you set this to `true`, you **MUST** preface your answer by ' + - 'warning the user that the example uses experimental APIs that are not suitable for production.', - ), -}); - -export type FindExampleInput = z.infer; - -export const findExampleOutputSchema = z.object({ - examples: z.array( - z.object({ - title: z - .string() - .describe( - 'The title of the example. Use this as a heading when presenting the example to the user.', - ), - summary: z - .string() - .describe( - "A one-sentence summary of the example's purpose. Use this to help the user decide " + - 'if the example is relevant to them.', - ), - keywords: z - .array(z.string()) - .optional() - .describe( - 'A list of keywords for the example. You can use these to explain why this example ' + - "was a good match for the user's query.", - ), - required_packages: z - .array(z.string()) - .optional() - .describe( - 'A list of NPM packages required for the example to work. Before presenting the code, ' + - 'you should inform the user if any of these packages need to be installed.', - ), - related_concepts: z - .array(z.string()) - .optional() - .describe( - 'A list of related concepts. You can suggest these to the user as topics for ' + - 'follow-up questions.', - ), - related_tools: z - .array(z.string()) - .optional() - .describe( - 'A list of related MCP tools. You can suggest these as potential next steps for the user.', - ), - content: z - .string() - .describe( - 'A complete, self-contained Angular code example in Markdown format. This should be ' + - 'presented to the user inside a markdown code block.', - ), - snippet: z - .string() - .optional() - .describe( - 'A contextual snippet from the content showing the matched search term. This field is ' + - 'critical for efficiently evaluating a result`s relevance. It enables two primary ', - ), - }), - ), -}); diff --git a/packages/angular/cli/src/commands/mcp/tools/examples/utils.ts b/packages/angular/cli/src/commands/mcp/tools/examples/utils.ts deleted file mode 100644 index 312994d7b3fd..000000000000 --- a/packages/angular/cli/src/commands/mcp/tools/examples/utils.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.dev/license - */ - -/** - * Suppresses the experimental warning emitted by Node.js for the `node:sqlite` module. - * - * This is a workaround to prevent the console from being cluttered with warnings - * about the experimental status of the SQLite module, which is used by this tool. - */ -export function suppressSqliteWarning(): void { - const originalProcessEmit = process.emit; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - process.emit = function (event: string, error?: unknown): any { - if ( - event === 'warning' && - error instanceof Error && - error.name === 'ExperimentalWarning' && - error.message.includes('SQLite') - ) { - return false; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any, prefer-rest-params - return originalProcessEmit.apply(process, arguments as any); - }; -} diff --git a/tests/e2e/tests/mcp/find-examples-basic.ts b/tests/e2e/tests/mcp/find-examples-basic.ts deleted file mode 100644 index b7f42045076c..000000000000 --- a/tests/e2e/tests/mcp/find-examples-basic.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { exec, ProcessOutput, silentNpm } from '../../utils/process'; -import assert from 'node:assert/strict'; - -const MCP_INSPECTOR_PACKAGE_NAME = '@modelcontextprotocol/inspector-cli'; -const MCP_INSPECTOR_PACKAGE_VERSION = '0.16.2'; -const MCP_INSPECTOR_COMMAND_NAME = 'mcp-inspector-cli'; - -async function runInspector(...args: string[]): Promise { - const result = await exec( - MCP_INSPECTOR_COMMAND_NAME, - '--cli', - 'npx', - '--no', - '@angular/cli', - 'mcp', - ...args, - ); - - return result; -} - -export default async function () { - const [nodeMajor, nodeMinor] = process.versions.node.split('.', 2).map(Number); - if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 16)) { - console.log('Test bypassed: find_examples tool requires Node.js 22.16 or higher.'); - - return; - } - - await silentNpm( - 'install', - '--ignore-scripts', - '-g', - `${MCP_INSPECTOR_PACKAGE_NAME}@${MCP_INSPECTOR_PACKAGE_VERSION}`, - ); - - // Ensure `get_best_practices` returns the markdown content - const { stdout: stdoutInsideWorkspace } = await runInspector( - '--method', - 'tools/call', - '--tool-name', - 'find_examples', - '--tool-arg', - 'query=if', - ); - - assert.match(stdoutInsideWorkspace, /Using the @if Built-in Control Flow Block/); -} diff --git a/tools/defaults.bzl b/tools/defaults.bzl index dd706151d169..dd054c9c1462 100644 --- a/tools/defaults.bzl +++ b/tools/defaults.bzl @@ -2,7 +2,6 @@ load("@aspect_rules_jasmine//jasmine:defs.bzl", _jasmine_test = "jasmine_test") load("@aspect_rules_js//js:defs.bzl", _js_binary = "js_binary") load("@bazel_lib//lib:copy_to_bin.bzl", _copy_to_bin = "copy_to_bin") load("@devinfra//bazel/ts_project:index.bzl", "strict_deps_test") -load("@rules_angular//src/ng_examples_db:index.bzl", _ng_examples_db = "ng_examples_db") load("@rules_angular//src/ng_package:index.bzl", _ng_package = "ng_package") load("@rules_angular//src/ts_project:index.bzl", _ts_project = "ts_project") load("//tools:substitutions.bzl", "substitutions") @@ -91,6 +90,3 @@ def jasmine_test(data = [], args = [], **kwargs): data = data + ["//:node_modules/source-map-support"], **kwargs ) - -def ng_examples_db(**kwargs): - _ng_examples_db(**kwargs)