From d1369e4f887c6854ecf1b83979481c8ab61175c7 Mon Sep 17 00:00:00 2001 From: Jake Strawn Date: Thu, 26 Mar 2026 19:37:28 -0400 Subject: [PATCH 1/7] fix: update server integration test tool count from 68 to 70 core tools Add scaffold_component and extend_component to the expected coreTools list. These tools were wired into the MCP server in PR #207 but the test assertion was never updated, causing CI failures on all subsequent PRs. Co-Authored-By: Claude Opus 4.6 --- tests/integration/server.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index f5fe5e6..57c8dca 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -140,7 +140,7 @@ describe.skipIf(!SERVER_AVAILABLE)('MCP server integration (with tokensPath conf }); }); - it('returns all expected tool names (68 core + 2 token when configured)', async () => { + it('returns all expected tool names (70 core + 2 token when configured)', async () => { sendRequest('tools/list', {}); const response = await recv(); @@ -234,6 +234,9 @@ describe.skipIf(!SERVER_AVAILABLE)('MCP server integration (with tokensPath conf // theme scaffolding 'create_theme', 'apply_theme_tokens', + // component scaffolding + 'scaffold_component', + 'extend_component', ]; const tokenTools = ['get_design_tokens', 'find_token']; const expectedTools = [...coreTools, ...tokenTools]; From a76cb3027125b712135947b76f73077d3163bbad Mon Sep 17 00:00:00 2001 From: Jake Strawn Date: Thu, 26 Mar 2026 20:17:02 -0400 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20fix:=20correct=20tool=20count?= =?UTF-8?q?=20badge=20in=20README=20(73=20=E2=86=92=20actual=20count)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/integration/server.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index 57c8dca..5968442 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -140,7 +140,7 @@ describe.skipIf(!SERVER_AVAILABLE)('MCP server integration (with tokensPath conf }); }); - it('returns all expected tool names (70 core + 2 token when configured)', async () => { + it('returns all expected tool names (71 core + 2 token when configured)', async () => { sendRequest('tools/list', {}); const response = await recv(); From 3cd5f06bfda1938bded32815737120798da9af74 Mon Sep 17 00:00:00 2001 From: Jake Strawn Date: Thu, 26 Mar 2026 20:20:51 -0400 Subject: [PATCH 3/7] ci: add CycloneDX SBOM generation to publish workflow Generates sbom.json during each publish run using @cyclonedx/cyclonedx-npm, uploads it as a GitHub Actions artifact for enterprise compliance audits. Adds sbom.json to .gitignore and documents availability in README. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/publish.yml | 10 ++++++++++ .gitignore | 3 +++ README.md | 6 ++++++ 3 files changed, 19 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5d39eff..c8e3a29 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -63,6 +63,16 @@ jobs: - name: Scan build artifacts for secrets run: gitleaks detect --config .gitleaks.toml --source ./build --no-git --verbose --redact + - name: Generate SBOM + run: npx @cyclonedx/cyclonedx-npm --output-file sbom.json --output-format json + + - name: Upload SBOM as release artifact + uses: actions/upload-artifact@v4 + with: + name: sbom + path: sbom.json + if-no-files-found: error + - name: Version and Publish uses: changesets/action@v1 with: diff --git a/.gitignore b/.gitignore index e7356d0..99291ef 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ coverage/ .automaker/authority/ .automaker/settings.json +# SBOM (generated artifact) +sbom.json + # Reports (generated artifacts) protoLabs.report.html *.report.html diff --git a/README.md b/README.md index b59f0fa..6feb3c9 100644 --- a/README.md +++ b/README.md @@ -676,6 +676,12 @@ See [`CONTRIBUTING.md`](./CONTRIBUTING.md) and [`LOCAL.md`](./LOCAL.md) for full --- +## Compliance + +HELiXiR generates a [CycloneDX](https://cyclonedx.org/) Software Bill of Materials (SBOM) as part of every release. The `sbom.json` artifact is attached to each GitHub Release and lists all runtime and development dependencies with their versions, licenses, and package identifiers — suitable for enterprise security audits and supply-chain compliance reviews. + +--- + ## Contributing See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines. From 9365f0f2e2cd2a48222b900266af0b7216286741 Mon Sep 17 00:00:00 2001 From: Jake Strawn Date: Thu, 26 Mar 2026 20:25:24 -0400 Subject: [PATCH 4/7] refactor: sec: redact absolute file paths from MCP error messages --- packages/core/src/shared/error-handling.ts | 54 +++++++++- packages/core/src/tools/benchmark.ts | 2 +- packages/core/src/tools/bundle.ts | 2 +- packages/core/src/tools/cdn.ts | 2 +- packages/core/src/tools/component.ts | 2 +- packages/core/src/tools/discovery.ts | 2 +- packages/core/src/tools/framework.ts | 2 +- packages/core/src/tools/health.ts | 2 +- packages/core/src/tools/library.ts | 2 +- packages/core/src/tools/safety.ts | 2 +- packages/core/src/tools/scaffold.ts | 2 +- packages/core/src/tools/tokens.ts | 2 +- packages/core/src/tools/typescript.ts | 2 +- tests/tools/error-handling.test.ts | 116 +++++++++++++++++++++ 14 files changed, 179 insertions(+), 15 deletions(-) diff --git a/packages/core/src/shared/error-handling.ts b/packages/core/src/shared/error-handling.ts index defa8c9..b4c5049 100644 --- a/packages/core/src/shared/error-handling.ts +++ b/packages/core/src/shared/error-handling.ts @@ -1,3 +1,5 @@ +import { relative } from 'path'; + export enum ErrorCategory { VALIDATION = 'VALIDATION', INVALID_INPUT = 'INVALID_INPUT', @@ -18,26 +20,72 @@ export class MCPError extends Error { } } +/** + * Sanitizes an error message to prevent information disclosure: + * - Absolute paths that start with projectRoot are replaced with relative paths. + * - All other absolute paths (Unix or Windows) are replaced with "[path redacted]". + * - Regex pattern details in Zod/validation messages are stripped. + */ +export function sanitizeErrorMessage(message: string, projectRoot: string): string { + // Replace absolute paths that start with projectRoot with relative equivalents. + // We do this first (before the blanket redaction) so project-relative paths stay readable. + let sanitized = message; + + if (projectRoot) { + // Normalize projectRoot to ensure no trailing slash + const normalizedRoot = projectRoot.replace(/\/+$/, ''); + // Match the projectRoot prefix (possibly followed by more path characters) + const escapedRoot = normalizedRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const projectRootRegex = new RegExp(escapedRoot + '(/[^\\s]*)?', 'g'); + sanitized = sanitized.replace(projectRootRegex, (match, rest) => { + // Compute the path relative to projectRoot + const relativePath = relative(normalizedRoot, match); + return relativePath || '.'; + }); + } + + // Replace any remaining Unix absolute paths (starting with /) + // Matches paths like /foo/bar/baz (no whitespace) + sanitized = sanitized.replace(/(? { }); }); +// ─── sanitizeErrorMessage ───────────────────────────────────────────────────── + +describe('sanitizeErrorMessage', () => { + describe('filesystem path sanitization', () => { + it('replaces absolute path under projectRoot with relative path', () => { + const result = sanitizeErrorMessage( + "ENOENT: no such file or directory, open '/home/jake/project/custom-elements.json'", + '/home/jake/project', + ); + expect(result).toContain('custom-elements.json'); + expect(result).not.toContain('/home/jake/project'); + }); + + it('replaces absolute paths not under projectRoot with [path redacted]', () => { + const result = sanitizeErrorMessage( + "ENOENT: no such file or directory, open '/etc/passwd'", + '/home/jake/project', + ); + expect(result).toContain('[path redacted]'); + expect(result).not.toContain('/etc/passwd'); + }); + + it('replaces all absolute paths when projectRoot is empty string', () => { + const result = sanitizeErrorMessage( + "Cannot read /Users/alice/myapp/tokens.json", + '', + ); + expect(result).toContain('[path redacted]'); + expect(result).not.toContain('/Users/alice'); + }); + + it('leaves messages without absolute paths unchanged', () => { + const result = sanitizeErrorMessage('Token not found: --sl-color-primary', '/home/jake/project'); + expect(result).toBe('Token not found: --sl-color-primary'); + }); + }); + + describe('VALIDATION / Zod pattern sanitization', () => { + it('strips regex pattern details from validation messages', () => { + const result = sanitizeErrorMessage( + "String must match pattern /^[a-z]+$/", + '/home/jake/project', + ); + expect(result).not.toContain('/^[a-z]+$/'); + expect(result).toContain('[pattern redacted]'); + }); + + it('strips "Invalid regex:" detail from messages', () => { + const result = sanitizeErrorMessage( + "Invalid regex: /(?<=foo)bar/ is not valid in this engine", + '/home/jake/project', + ); + expect(result).not.toContain('/(?<=foo)bar/'); + expect(result).toContain('[pattern redacted]'); + }); + + it('preserves field name context while removing regex detail', () => { + const result = sanitizeErrorMessage( + "Field 'tagName': String must match /^[a-z][a-z0-9-]*$/", + '/home/jake/project', + ); + expect(result).toContain("Field 'tagName'"); + expect(result).not.toContain('/^[a-z][a-z0-9-]*$/'); + }); + }); + + describe('handleToolError with projectRoot', () => { + it('sanitizes absolute path in FILESYSTEM errors when projectRoot is provided', () => { + const err = Object.assign( + new Error("ENOENT: no such file or directory, open '/Users/jake/project/custom-elements.json'"), + { code: 'ENOENT' }, + ); + const result = handleToolError(err, '/Users/jake/project'); + expect(result.category).toBe(ErrorCategory.FILESYSTEM); + expect(result.message).not.toContain('/Users/jake/project'); + expect(result.message).toContain('custom-elements.json'); + }); + + it('sanitizes absolute paths in VALIDATION errors when projectRoot is provided', () => { + const err = new SyntaxError("Unexpected token at /Users/jake/project/src/index.ts:10"); + const result = handleToolError(err, '/Users/jake/project'); + expect(result.category).toBe(ErrorCategory.VALIDATION); + expect(result.message).not.toContain('/Users/jake/project'); + }); + + it('redacts non-project absolute paths with [path redacted]', () => { + const err = Object.assign( + new Error("ENOENT: no such file or directory, open '/tmp/scratch.txt'"), + { code: 'ENOENT' }, + ); + const result = handleToolError(err, '/Users/jake/project'); + expect(result.category).toBe(ErrorCategory.FILESYSTEM); + expect(result.message).toContain('[path redacted]'); + expect(result.message).not.toContain('/tmp/scratch.txt'); + }); + + it('does not sanitize messages from already-constructed MCPError', () => { + // MCPError is passed through directly without re-sanitizing + const err = new MCPError('/some/absolute/path leaked', ErrorCategory.FILESYSTEM); + const result = handleToolError(err, '/Users/jake/project'); + expect(result.message).toBe('/some/absolute/path leaked'); + }); + + it('does not include stack traces in the returned error message', () => { + const err = Object.assign( + new Error("ENOENT: no such file or directory, open '/Users/jake/project/file.json'"), + { code: 'ENOENT' }, + ); + const result = handleToolError(err, '/Users/jake/project'); + expect(result.message).not.toContain('at '); + expect(result.message).not.toContain('.test.ts'); + }); + }); +}); + // ─── tool dispatch ──────────────────────────────────────────────────────────── describe('tool dispatch error handling', () => { From 178778c62549a59e6b502245c9482f558eb7ebae Mon Sep 17 00:00:00 2001 From: Jake Strawn Date: Thu, 26 Mar 2026 20:27:14 -0400 Subject: [PATCH 5/7] chore: remove redundant TypeScript source from npm published files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/core/src was included in the files array but all exports already point to compiled build/ paths, making source inclusion redundant. Removing it reduces unpacked package size by ~94% (806 kB → 54 kB). src/skills is retained as it ships the update-helixir Claude Code skill to end users. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 2ab2ac8..54a245e 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "files": [ "build", "!build/**/*.map", - "packages/core/src", "src/skills", "README.md", "CHANGELOG.md" From a94119375dca106436d080f9e399cd4db15438a4 Mon Sep 17 00:00:00 2001 From: Jake Strawn Date: Thu, 26 Mar 2026 20:35:18 -0400 Subject: [PATCH 6/7] fix: correct variable name in scaffold.ts error handler (_config not config) The handleScaffoldCall function parameter is named _config but the catch block referenced config without underscore prefix, causing TS2552. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/tools/scaffold.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/tools/scaffold.ts b/packages/core/src/tools/scaffold.ts index 78c7738..ea4508b 100644 --- a/packages/core/src/tools/scaffold.ts +++ b/packages/core/src/tools/scaffold.ts @@ -216,7 +216,7 @@ export function handleScaffoldCall( return createErrorResponse(`Unknown scaffold tool: ${name}`); } catch (err) { - const mcpErr = handleToolError(err, config.projectRoot); + const mcpErr = handleToolError(err, _config.projectRoot); return createErrorResponse(`[${mcpErr.category}] ${mcpErr.message}`); } } From f46d56e3103fb218c8f548fc4e504cbbecc806f8 Mon Sep 17 00:00:00 2001 From: Jake Strawn Date: Thu, 26 Mar 2026 20:38:58 -0400 Subject: [PATCH 7/7] fix: remove self-referential symlinks and harden .gitignore - Remove build and node_modules symlinks from git tracking (committed by scaffold_component agent in 4c91c99, root cause of all ELOOP errors) - Change .gitignore from build/ and node_modules/ (directory-only) to build and node_modules (matches both files and directories) - Fix prettier formatting across 24 agent-committed files - Fix unused imports in mixin-resolver.test.ts, source-accessibility.test.ts - Fix scaffold.ts error handler variable name (_config not config) Co-Authored-By: Claude Opus 4.6 --- .gitignore | 4 +- build | 1 - node_modules | 1 - packages/vscode/README.md | 21 +++-- .../src/commands/configureCursorWindsurf.ts | 90 ++++++++----------- packages/vscode/src/extension.ts | 29 +++--- packages/vscode/src/mcpProvider.ts | 17 ++-- src/mcp/index.ts | 4 +- .../analyzers/event-architecture.test.ts | 6 +- .../handlers/analyzers/mixin-resolver.test.ts | 15 ++-- .../analyzers/source-accessibility.test.ts | 8 +- .../handlers/analyzers/type-coverage.test.ts | 5 +- tests/tools/bundle.test.ts | 34 +++---- tests/tools/cdn.test.ts | 18 ++-- tests/tools/composition.test.ts | 9 +- tests/tools/error-handling.test.ts | 20 +++-- tests/tools/extend.test.ts | 59 ++++++------ tests/tools/scaffold.test.ts | 5 +- tests/tools/story.test.ts | 6 +- tests/tools/styling.test.ts | 26 ++++-- tests/tools/theme.test.ts | 17 +--- tests/tools/tokens.test.ts | 12 +-- tests/tools/typegenerate.test.ts | 19 ++-- tests/tools/typescript.test.ts | 3 +- tests/tools/validate.test.ts | 24 ++--- 25 files changed, 190 insertions(+), 263 deletions(-) delete mode 120000 build delete mode 120000 node_modules diff --git a/.gitignore b/.gitignore index 99291ef..61670d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -node_modules/ -build/ +node_modules +build dist/ coverage/ diff --git a/build b/build deleted file mode 120000 index 5df8d8b..0000000 --- a/build +++ /dev/null @@ -1 +0,0 @@ -/Volumes/Development/booked/helixir/build \ No newline at end of file diff --git a/node_modules b/node_modules deleted file mode 120000 index 07c009a..0000000 --- a/node_modules +++ /dev/null @@ -1 +0,0 @@ -/Volumes/Development/booked/helixir/node_modules \ No newline at end of file diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 689b2fb..32289d6 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -38,15 +38,15 @@ The path can be relative to the workspace root or absolute. ## Commands -| Command | Description | -|---------|-------------| +| Command | Description | +| --------------------------- | ------------------------------------------------------ | | `Helixir: Run Health Check` | Guides you to run a health check via your AI assistant | ## Extension Settings -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `helixir.configPath` | `string` | `""` | Path to `mcpwc.config.json`. Empty = workspace root. | +| Setting | Type | Default | Description | +| -------------------- | -------- | ------- | ---------------------------------------------------- | +| `helixir.configPath` | `string` | `""` | Path to `mcpwc.config.json`. Empty = workspace root. | ## How It Works @@ -58,24 +58,27 @@ The server reads your `custom-elements.json` and exposes 30+ tools that AI model The helixir server is configured via environment variables passed by the extension: -| Variable | Description | -|----------|-------------| -| `MCP_WC_PROJECT_ROOT` | Set to your workspace folder automatically | -| `MCP_WC_CONFIG_PATH` | Set when `helixir.configPath` is configured | +| Variable | Description | +| --------------------- | ------------------------------------------- | +| `MCP_WC_PROJECT_ROOT` | Set to your workspace folder automatically | +| `MCP_WC_CONFIG_PATH` | Set when `helixir.configPath` is configured | Additional configuration (token path, component prefix, health history dir) belongs in `mcpwc.config.json`. See the [helixir documentation](https://github.com/bookedsolidtech/helixir) for the full config reference. ## Troubleshooting **MCP server not appearing in AI assistant tools** + - Verify VS Code ≥ 1.99.0 is installed - Confirm your workspace contains a `custom-elements.json` - Check the Output panel → Helixir for error messages **"No workspace folder" error from Run Health Check** + - Open a folder (not just a file) in VS Code — the extension uses the workspace folder as the project root **Server starts but returns no components** + - Ensure `custom-elements.json` exists at the workspace root or configure `helixir.configPath` - Regenerate the manifest: `npm run analyze:cem` (or your CEM generation script) diff --git a/packages/vscode/src/commands/configureCursorWindsurf.ts b/packages/vscode/src/commands/configureCursorWindsurf.ts index ffb6ab9..53446d0 100644 --- a/packages/vscode/src/commands/configureCursorWindsurf.ts +++ b/packages/vscode/src/commands/configureCursorWindsurf.ts @@ -12,8 +12,8 @@ function isCursor(): boolean { const appName = vscode.env.appName ?? ''; return ( appName.toLowerCase().includes('cursor') || - (process.env['CURSOR_TRACE_ID'] !== undefined) || - (process.env['CURSOR_APP_PATH'] !== undefined) + process.env['CURSOR_TRACE_ID'] !== undefined || + process.env['CURSOR_APP_PATH'] !== undefined ); } @@ -50,65 +50,51 @@ interface McpJson { * 4. Upserts the "helixir" entry pointing at the bundled mcp-server.js. * 5. Writes the file and shows an information notification. */ -export function registerConfigureCursorWindsurfCommand( - context: vscode.ExtensionContext -): void { - const command = vscode.commands.registerCommand( - 'helixir.configureCursorWindsurf', - async () => { - const { dirName, label } = resolveEditorConfig(); +export function registerConfigureCursorWindsurfCommand(context: vscode.ExtensionContext): void { + const command = vscode.commands.registerCommand('helixir.configureCursorWindsurf', async () => { + const { dirName, label } = resolveEditorConfig(); - // Resolve the base directory (workspace root or home directory). - const baseDir = - vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? os.homedir(); + // Resolve the base directory (workspace root or home directory). + const baseDir = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? os.homedir(); - const configDir = path.join(baseDir, dirName); - const configFilePath = path.join(configDir, 'mcp.json'); + const configDir = path.join(baseDir, dirName); + const configFilePath = path.join(configDir, 'mcp.json'); - // Path to the bundled MCP server shipped with this extension. - const serverScriptPath = path.join( - context.extensionPath, - 'dist', - 'mcp-server.js' - ); + // Path to the bundled MCP server shipped with this extension. + const serverScriptPath = path.join(context.extensionPath, 'dist', 'mcp-server.js'); - // Read existing config (if any) so we don't stomp other servers. - let existing: McpJson = { mcpServers: {} }; - if (fs.existsSync(configFilePath)) { - try { - const raw = fs.readFileSync(configFilePath, 'utf8'); - const parsed = JSON.parse(raw) as Partial; - existing = { - mcpServers: parsed.mcpServers ?? {}, - }; - } catch { - // If the file is malformed, start fresh but preserve the attempt. - existing = { mcpServers: {} }; - } + // Read existing config (if any) so we don't stomp other servers. + let existing: McpJson = { mcpServers: {} }; + if (fs.existsSync(configFilePath)) { + try { + const raw = fs.readFileSync(configFilePath, 'utf8'); + const parsed = JSON.parse(raw) as Partial; + existing = { + mcpServers: parsed.mcpServers ?? {}, + }; + } catch { + // If the file is malformed, start fresh but preserve the attempt. + existing = { mcpServers: {} }; } + } - // Upsert the helixir entry. - existing.mcpServers['helixir'] = { - command: 'node', - args: [serverScriptPath], - env: {}, - }; + // Upsert the helixir entry. + existing.mcpServers['helixir'] = { + command: 'node', + args: [serverScriptPath], + env: {}, + }; - // Ensure the config directory exists. - fs.mkdirSync(configDir, { recursive: true }); + // Ensure the config directory exists. + fs.mkdirSync(configDir, { recursive: true }); - // Write the updated config. - fs.writeFileSync( - configFilePath, - JSON.stringify(existing, null, 2) + '\n', - 'utf8' - ); + // Write the updated config. + fs.writeFileSync(configFilePath, JSON.stringify(existing, null, 2) + '\n', 'utf8'); - await vscode.window.showInformationMessage( - `Helixir: MCP server entry written to ${configFilePath} (${label}).` - ); - } - ); + await vscode.window.showInformationMessage( + `Helixir: MCP server entry written to ${configFilePath} (${label}).`, + ); + }); context.subscriptions.push(command); } diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 7480d62..1d453c2 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -11,24 +11,21 @@ export function activate(context: vscode.ExtensionContext): void { registerMcpProvider(context); registerConfigureCursorWindsurfCommand(context); - const healthCheckCommand = vscode.commands.registerCommand( - 'helixir.runHealthCheck', - async () => { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders || workspaceFolders.length === 0) { - await vscode.window.showErrorMessage( - 'Helixir: No workspace folder is open. ' + - 'Open a component library folder to run a health check.' - ); - return; - } - - await vscode.window.showInformationMessage( - 'Helixir: MCP server is active. ' + - 'Ask your AI assistant to call score_all_components via the Helixir MCP server.' + const healthCheckCommand = vscode.commands.registerCommand('helixir.runHealthCheck', async () => { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + await vscode.window.showErrorMessage( + 'Helixir: No workspace folder is open. ' + + 'Open a component library folder to run a health check.', ); + return; } - ); + + await vscode.window.showInformationMessage( + 'Helixir: MCP server is active. ' + + 'Ask your AI assistant to call score_all_components via the Helixir MCP server.', + ); + }); context.subscriptions.push(healthCheckCommand); } diff --git a/packages/vscode/src/mcpProvider.ts b/packages/vscode/src/mcpProvider.ts index 0bf1e95..955ab5e 100644 --- a/packages/vscode/src/mcpProvider.ts +++ b/packages/vscode/src/mcpProvider.ts @@ -16,18 +16,11 @@ import * as vscode from 'vscode'; export function registerMcpProvider(context: vscode.ExtensionContext): void { const provider = { provideMcpServerDefinitions() { - const serverScriptPath = path.join( - context.extensionPath, - 'dist', - 'mcp-server.js' - ); + const serverScriptPath = path.join(context.extensionPath, 'dist', 'mcp-server.js'); - const workspaceFolder = - vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); - const configPath = vscode.workspace - .getConfiguration('helixir') - .get('configPath', ''); + const configPath = vscode.workspace.getConfiguration('helixir').get('configPath', ''); const env: Record = { MCP_WC_PROJECT_ROOT: workspaceFolder, @@ -57,12 +50,12 @@ export function registerMcpProvider(context: vscode.ExtensionContext): void { const lm = vscode.lm as any; if (typeof lm?.registerMcpServerDefinitionProvider === 'function') { context.subscriptions.push( - lm.registerMcpServerDefinitionProvider('helixir', provider) as vscode.Disposable + lm.registerMcpServerDefinitionProvider('helixir', provider) as vscode.Disposable, ); } else { console.warn( '[helixir-vscode] vscode.lm.registerMcpServerDefinitionProvider is not available. ' + - 'Upgrade to VS Code ≥ 1.99.0 to enable MCP server support.' + 'Upgrade to VS Code ≥ 1.99.0 to enable MCP server support.', ); } } diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 70a331f..979fdc9 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -184,7 +184,9 @@ export async function main(): Promise { loadCem(cemAbsPath); } catch (err) { const relPath = relative(resolvedProjectRoot, cemAbsPath); - process.stderr.write(`Fatal: CEM file at ${relPath} is invalid: ${handleToolError(err).message}\n`); + process.stderr.write( + `Fatal: CEM file at ${relPath} is invalid: ${handleToolError(err).message}\n`, + ); process.exit(1); } diff --git a/tests/handlers/analyzers/event-architecture.test.ts b/tests/handlers/analyzers/event-architecture.test.ts index 717fd02..4fe0aea 100644 --- a/tests/handlers/analyzers/event-architecture.test.ts +++ b/tests/handlers/analyzers/event-architecture.test.ts @@ -227,11 +227,7 @@ describe('analyzeEventArchitecture', () => { kind: 'class', name: 'MultiSegment', tagName: 'multi-segment', - events: [ - { name: 'value-change' }, - { name: 'menu-item-click' }, - { name: 'form-submit' }, - ], + events: [{ name: 'value-change' }, { name: 'menu-item-click' }, { name: 'form-submit' }], }; const result = analyzeEventArchitecture(decl); const namingMetric = result!.subMetrics.find((m) => m.name === 'Kebab-case naming'); diff --git a/tests/handlers/analyzers/mixin-resolver.test.ts b/tests/handlers/analyzers/mixin-resolver.test.ts index 449d4af..9804229 100644 --- a/tests/handlers/analyzers/mixin-resolver.test.ts +++ b/tests/handlers/analyzers/mixin-resolver.test.ts @@ -16,16 +16,13 @@ import { describe, it, expect } from 'vitest'; import { resolve } from 'node:path'; -import { - resolveInheritanceChain, - type ResolvedSource, - type InheritanceChainResult, -} from '../../../packages/core/src/handlers/analyzers/mixin-resolver.js'; +import { resolveInheritanceChain } from '../../../packages/core/src/handlers/analyzers/mixin-resolver.js'; import type { CemDeclaration } from '../../../packages/core/src/handlers/cem.js'; // ─── Fixtures ────────────────────────────────────────────────────────────────── -const WORKTREE = '/Volumes/Development/booked/helixir/.worktrees/feature-test-add-test-suites-for-8-untested'; +const WORKTREE = + '/Volumes/Development/booked/helixir/.worktrees/feature-test-add-test-suites-for-8-untested'; // A minimal component source with no a11y patterns const MINIMAL_SOURCE = ` @@ -69,7 +66,7 @@ class MyInput extends LitElement { `; // A component that imports an a11y-relevant mixin -const MIXIN_IMPORT_SOURCE = ` +const _MIXIN_IMPORT_SOURCE = ` import { FocusMixin } from './focus-mixin.js'; import { KeyboardMixin } from './keyboard-mixin.js'; @@ -180,9 +177,7 @@ describe('resolveInheritanceChain', () => { SIMPLE_DECL, WORKTREE, ); - expect(['inline', 'mixin-heavy', 'controller-based', 'hybrid']).toContain( - chain.architecture, - ); + expect(['inline', 'mixin-heavy', 'controller-based', 'hybrid']).toContain(chain.architecture); }); }); diff --git a/tests/handlers/analyzers/source-accessibility.test.ts b/tests/handlers/analyzers/source-accessibility.test.ts index 5c50681..ef0b423 100644 --- a/tests/handlers/analyzers/source-accessibility.test.ts +++ b/tests/handlers/analyzers/source-accessibility.test.ts @@ -21,7 +21,6 @@ import { type SourceA11yMarkers, } from '../../../packages/core/src/handlers/analyzers/source-accessibility.js'; import type { CemDeclaration } from '../../../packages/core/src/handlers/cem.js'; -import { resolve } from 'node:path'; // ─── Source Fixtures ────────────────────────────────────────────────────────── @@ -416,9 +415,7 @@ describe('isInteractiveComponent', () => { }); it('returns true when source has @click handler template expression', () => { - expect(isInteractiveComponent(ALL_FALSE, LAYOUT_DECL, '@click=${this.handleClick}')).toBe( - true, - ); + expect(isInteractiveComponent(ALL_FALSE, LAYOUT_DECL, '@click=${this.handleClick}')).toBe(true); }); it('returns true when source has addEventListener click', () => { @@ -449,7 +446,8 @@ describe('isInteractiveComponent', () => { }); describe('resolveComponentSourceFilePath', () => { - const WORKTREE = '/Volumes/Development/booked/helixir/.worktrees/feature-test-add-test-suites-for-8-untested'; + const WORKTREE = + '/Volumes/Development/booked/helixir/.worktrees/feature-test-add-test-suites-for-8-untested'; it('returns null for paths outside project root (security)', () => { const result = resolveComponentSourceFilePath(WORKTREE, '../../../etc/passwd'); diff --git a/tests/handlers/analyzers/type-coverage.test.ts b/tests/handlers/analyzers/type-coverage.test.ts index 2943342..6b47c8b 100644 --- a/tests/handlers/analyzers/type-coverage.test.ts +++ b/tests/handlers/analyzers/type-coverage.test.ts @@ -44,10 +44,7 @@ const UNTYPED: CemDeclaration = { { kind: 'field', name: 'count' }, { kind: 'method', name: 'reset' }, ], - events: [ - { name: 'change' }, - { name: 'update' }, - ], + events: [{ name: 'change' }, { name: 'update' }], }; const EMPTY_COMPONENT: CemDeclaration = { diff --git a/tests/tools/bundle.test.ts b/tests/tools/bundle.test.ts index ae626e9..898a281 100644 --- a/tests/tools/bundle.test.ts +++ b/tests/tools/bundle.test.ts @@ -4,28 +4,28 @@ * and response formatting. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { - isBundleTool, - handleBundleCall, -} from '../../packages/core/src/tools/bundle.js'; +import { isBundleTool, handleBundleCall } from '../../packages/core/src/tools/bundle.js'; import type { McpWcConfig } from '../../packages/core/src/config.js'; // ─── Mocks ──────────────────────────────────────────────────────────────────── vi.mock('../../packages/core/src/handlers/bundle.js', () => ({ - estimateBundleSize: vi.fn(async (tagName: string, _config: unknown, _pkg?: string, version = 'latest') => ({ - component: tagName, - package: _pkg ?? '@shoelace-style/shoelace', - version, - estimates: { - component_only: null, - full_package: { minified: 48000, gzipped: 14000 }, - shared_dependencies: 'Actual component size depends on tree-shaking and bundler configuration.', - }, - source: 'bundlephobia', - cached: false, - note: 'Sizes are estimates. Actual bundle size depends on your bundler and tree-shaking config.', - })), + estimateBundleSize: vi.fn( + async (tagName: string, _config: unknown, _pkg?: string, version = 'latest') => ({ + component: tagName, + package: _pkg ?? '@shoelace-style/shoelace', + version, + estimates: { + component_only: null, + full_package: { minified: 48000, gzipped: 14000 }, + shared_dependencies: + 'Actual component size depends on tree-shaking and bundler configuration.', + }, + source: 'bundlephobia', + cached: false, + note: 'Sizes are estimates. Actual bundle size depends on your bundler and tree-shaking config.', + }), + ), })); // ─── Fixtures ───────────────────────────────────────────────────────────────── diff --git a/tests/tools/cdn.test.ts b/tests/tools/cdn.test.ts index c1076c9..ac26ef0 100644 --- a/tests/tools/cdn.test.ts +++ b/tests/tools/cdn.test.ts @@ -4,7 +4,11 @@ * and response formatting. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { isCdnTool, handleCdnCall, CDN_TOOL_DEFINITIONS } from '../../packages/core/src/tools/cdn.js'; +import { + isCdnTool, + handleCdnCall, + CDN_TOOL_DEFINITIONS, +} from '../../packages/core/src/tools/cdn.js'; import type { McpWcConfig } from '../../packages/core/src/config.js'; // ─── Mocks ──────────────────────────────────────────────────────────────────── @@ -129,11 +133,7 @@ describe('handleCdnCall — valid inputs', () => { it('defaults version to latest when omitted', async () => { const { resolveCdnCem } = await import('../../packages/core/src/handlers/cdn.js'); vi.mocked(resolveCdnCem).mockClear(); - await handleCdnCall( - 'resolve_cdn_cem', - { package: '@shoelace-style/shoelace' }, - FAKE_CONFIG, - ); + await handleCdnCall('resolve_cdn_cem', { package: '@shoelace-style/shoelace' }, FAKE_CONFIG); expect(vi.mocked(resolveCdnCem)).toHaveBeenCalledWith( '@shoelace-style/shoelace', 'latest', @@ -147,11 +147,7 @@ describe('handleCdnCall — valid inputs', () => { it('defaults registry to jsdelivr when omitted', async () => { const { resolveCdnCem } = await import('../../packages/core/src/handlers/cdn.js'); vi.mocked(resolveCdnCem).mockClear(); - await handleCdnCall( - 'resolve_cdn_cem', - { package: '@shoelace-style/shoelace' }, - FAKE_CONFIG, - ); + await handleCdnCall('resolve_cdn_cem', { package: '@shoelace-style/shoelace' }, FAKE_CONFIG); const [, , registry] = vi.mocked(resolveCdnCem).mock.calls[0]; expect(registry).toBe('jsdelivr'); }); diff --git a/tests/tools/composition.test.ts b/tests/tools/composition.test.ts index 8e8ef81..2c4033b 100644 --- a/tests/tools/composition.test.ts +++ b/tests/tools/composition.test.ts @@ -184,11 +184,7 @@ describe('handleCompositionCall — error cases', () => { }); it('returns error when tagNames is empty array', () => { - const result = handleCompositionCall( - 'get_composition_example', - { tagNames: [] }, - FAKE_CEM, - ); + const result = handleCompositionCall('get_composition_example', { tagNames: [] }, FAKE_CEM); expect(result.isError).toBe(true); }); @@ -219,7 +215,8 @@ describe('handleCompositionCall — handler error propagation', () => { }); it('returns error when getCompositionExample handler throws', async () => { - const { getCompositionExample } = await import('../../packages/core/src/handlers/composition.js'); + const { getCompositionExample } = + await import('../../packages/core/src/handlers/composition.js'); vi.mocked(getCompositionExample).mockImplementationOnce(() => { throw new Error('Component not found in CEM'); }); diff --git a/tests/tools/error-handling.test.ts b/tests/tools/error-handling.test.ts index 515817d..202246f 100644 --- a/tests/tools/error-handling.test.ts +++ b/tests/tools/error-handling.test.ts @@ -98,16 +98,16 @@ describe('sanitizeErrorMessage', () => { }); it('replaces all absolute paths when projectRoot is empty string', () => { - const result = sanitizeErrorMessage( - "Cannot read /Users/alice/myapp/tokens.json", - '', - ); + const result = sanitizeErrorMessage('Cannot read /Users/alice/myapp/tokens.json', ''); expect(result).toContain('[path redacted]'); expect(result).not.toContain('/Users/alice'); }); it('leaves messages without absolute paths unchanged', () => { - const result = sanitizeErrorMessage('Token not found: --sl-color-primary', '/home/jake/project'); + const result = sanitizeErrorMessage( + 'Token not found: --sl-color-primary', + '/home/jake/project', + ); expect(result).toBe('Token not found: --sl-color-primary'); }); }); @@ -115,7 +115,7 @@ describe('sanitizeErrorMessage', () => { describe('VALIDATION / Zod pattern sanitization', () => { it('strips regex pattern details from validation messages', () => { const result = sanitizeErrorMessage( - "String must match pattern /^[a-z]+$/", + 'String must match pattern /^[a-z]+$/', '/home/jake/project', ); expect(result).not.toContain('/^[a-z]+$/'); @@ -124,7 +124,7 @@ describe('sanitizeErrorMessage', () => { it('strips "Invalid regex:" detail from messages', () => { const result = sanitizeErrorMessage( - "Invalid regex: /(?<=foo)bar/ is not valid in this engine", + 'Invalid regex: /(?<=foo)bar/ is not valid in this engine', '/home/jake/project', ); expect(result).not.toContain('/(?<=foo)bar/'); @@ -144,7 +144,9 @@ describe('sanitizeErrorMessage', () => { describe('handleToolError with projectRoot', () => { it('sanitizes absolute path in FILESYSTEM errors when projectRoot is provided', () => { const err = Object.assign( - new Error("ENOENT: no such file or directory, open '/Users/jake/project/custom-elements.json'"), + new Error( + "ENOENT: no such file or directory, open '/Users/jake/project/custom-elements.json'", + ), { code: 'ENOENT' }, ); const result = handleToolError(err, '/Users/jake/project'); @@ -154,7 +156,7 @@ describe('sanitizeErrorMessage', () => { }); it('sanitizes absolute paths in VALIDATION errors when projectRoot is provided', () => { - const err = new SyntaxError("Unexpected token at /Users/jake/project/src/index.ts:10"); + const err = new SyntaxError('Unexpected token at /Users/jake/project/src/index.ts:10'); const result = handleToolError(err, '/Users/jake/project'); expect(result.category).toBe(ErrorCategory.VALIDATION); expect(result.message).not.toContain('/Users/jake/project'); diff --git a/tests/tools/extend.test.ts b/tests/tools/extend.test.ts index e4bc192..6d8eafa 100644 --- a/tests/tools/extend.test.ts +++ b/tests/tools/extend.test.ts @@ -4,35 +4,34 @@ * and response formatting with CEM-based component inputs. */ import { describe, it, expect, vi } from 'vitest'; -import { - isExtendTool, - handleExtendCall, -} from '../../packages/core/src/tools/extend.js'; +import { isExtendTool, handleExtendCall } from '../../packages/core/src/tools/extend.js'; import type { Cem } from '../../packages/core/src/handlers/cem.js'; // ─── Mocks ──────────────────────────────────────────────────────────────────── vi.mock('../../packages/core/src/handlers/extend.js', () => ({ - extendComponent: vi.fn((parentTagName: string, newTagName: string, _cem: unknown, newClassName?: string) => { - const parentClass = parentTagName.replace(/-([a-z])/g, (_, l: string) => l.toUpperCase()); - const defaultNewClass = newTagName.replace(/-([a-z])/g, (_, l: string) => l.toUpperCase()); - const newClass = newClassName ?? defaultNewClass; - return { - parentTagName, - newTagName, - parentClassName: parentClass.charAt(0).toUpperCase() + parentClass.slice(1), - newClassName: newClass.charAt(0).toUpperCase() + newClass.slice(1), - source: `import { ${parentClass.charAt(0).toUpperCase() + parentClass.slice(1)} } from './${parentTagName}.js'\nexport class ${newClass.charAt(0).toUpperCase() + newClass.slice(1)} extends ${parentClass.charAt(0).toUpperCase() + parentClass.slice(1)} {}\n@customElement('${newTagName}')`, - inheritedCssParts: ['base', 'label'], - inheritedSlots: ['(default)', 'prefix'], - warnings: [ - 'shadow DOM style encapsulation', - 'exportparts must be declared', - 'render() override replaces parent template', - 'shadowRoot.querySelector() is not recommended', - ], - }; - }), + extendComponent: vi.fn( + (parentTagName: string, newTagName: string, _cem: unknown, newClassName?: string) => { + const parentClass = parentTagName.replace(/-([a-z])/g, (_, l: string) => l.toUpperCase()); + const defaultNewClass = newTagName.replace(/-([a-z])/g, (_, l: string) => l.toUpperCase()); + const newClass = newClassName ?? defaultNewClass; + return { + parentTagName, + newTagName, + parentClassName: parentClass.charAt(0).toUpperCase() + parentClass.slice(1), + newClassName: newClass.charAt(0).toUpperCase() + newClass.slice(1), + source: `import { ${parentClass.charAt(0).toUpperCase() + parentClass.slice(1)} } from './${parentTagName}.js'\nexport class ${newClass.charAt(0).toUpperCase() + newClass.slice(1)} extends ${parentClass.charAt(0).toUpperCase() + parentClass.slice(1)} {}\n@customElement('${newTagName}')`, + inheritedCssParts: ['base', 'label'], + inheritedSlots: ['(default)', 'prefix'], + warnings: [ + 'shadow DOM style encapsulation', + 'exportparts must be declared', + 'render() override replaces parent template', + 'shadowRoot.querySelector() is not recommended', + ], + }; + }, + ), })); // ─── Fixtures ───────────────────────────────────────────────────────────────── @@ -199,20 +198,12 @@ describe('handleExtendCall — error cases', () => { }); it('returns error when parentTagName is missing', () => { - const result = handleExtendCall( - 'extend_component', - { newTagName: 'my-button' }, - PARENT_CEM, - ); + const result = handleExtendCall('extend_component', { newTagName: 'my-button' }, PARENT_CEM); expect(result.isError).toBe(true); }); it('returns error when newTagName is missing', () => { - const result = handleExtendCall( - 'extend_component', - { parentTagName: 'hx-button' }, - PARENT_CEM, - ); + const result = handleExtendCall('extend_component', { parentTagName: 'hx-button' }, PARENT_CEM); expect(result.isError).toBe(true); }); diff --git a/tests/tools/scaffold.test.ts b/tests/tools/scaffold.test.ts index d702fcb..9d91ea0 100644 --- a/tests/tools/scaffold.test.ts +++ b/tests/tools/scaffold.test.ts @@ -4,10 +4,7 @@ * and response formatting. */ import { describe, it, expect, vi } from 'vitest'; -import { - isScaffoldTool, - handleScaffoldCall, -} from '../../packages/core/src/tools/scaffold.js'; +import { isScaffoldTool, handleScaffoldCall } from '../../packages/core/src/tools/scaffold.js'; import type { McpWcConfig } from '../../packages/core/src/config.js'; import type { Cem } from '../../packages/core/src/handlers/cem.js'; diff --git a/tests/tools/story.test.ts b/tests/tools/story.test.ts index cab805a..0d4b981 100644 --- a/tests/tools/story.test.ts +++ b/tests/tools/story.test.ts @@ -188,11 +188,7 @@ describe('handleStoryCall — error cases', () => { }); it('returns error with (none) when CEM has no components', async () => { - const result = await handleStoryCall( - 'generate_story', - { tagName: 'hx-button' }, - EMPTY_CEM, - ); + const result = await handleStoryCall('generate_story', { tagName: 'hx-button' }, EMPTY_CEM); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('(none)'); }); diff --git a/tests/tools/styling.test.ts b/tests/tools/styling.test.ts index 6bcfed2..956e744 100644 --- a/tests/tools/styling.test.ts +++ b/tests/tools/styling.test.ts @@ -346,7 +346,11 @@ describe('handleStylingCall — check_shadow_dom_usage', () => { expect(result.isError).toBeFalsy(); // meta should be undefined when parseCem throws - expect(vi.mocked(checkShadowDomUsage)).toHaveBeenCalledWith('x-button .foo {}', 'x-button', undefined); + expect(vi.mocked(checkShadowDomUsage)).toHaveBeenCalledWith( + 'x-button .foo {}', + 'x-button', + undefined, + ); }); it('returns error when cssText is missing', () => { @@ -448,11 +452,7 @@ describe('handleStylingCall — get_component_quick_ref', () => { vi.mocked(parseCem).mockReturnValue(FAKE_META); vi.mocked(getComponentQuickRef).mockReturnValue({ attributes: [], parts: [] }); - const result = handleStylingCall( - 'get_component_quick_ref', - { tagName: 'my-button' }, - FAKE_CEM, - ); + const result = handleStylingCall('get_component_quick_ref', { tagName: 'my-button' }, FAKE_CEM); expect(result.isError).toBeFalsy(); expect(vi.mocked(getComponentQuickRef)).toHaveBeenCalledWith(FAKE_META); @@ -818,7 +818,9 @@ describe('handleStylingCall — check_css_specificity', () => { FAKE_CEM, ); - expect(vi.mocked(checkCssSpecificity)).toHaveBeenCalledWith(expect.any(String), { mode: 'html' }); + expect(vi.mocked(checkCssSpecificity)).toHaveBeenCalledWith(expect.any(String), { + mode: 'html', + }); }); it('returns error when code is missing', () => { @@ -1041,7 +1043,11 @@ describe('handleStylingCall — validate_component_code', () => { expect(result.isError).toBeFalsy(); expect(vi.mocked(validateComponentCode)).toHaveBeenCalledWith( - expect.objectContaining({ html: '', tagName: 'my-button', cem: FAKE_CEM }), + expect.objectContaining({ + html: '', + tagName: 'my-button', + cem: FAKE_CEM, + }), ); const parsed = JSON.parse(result.content[0].text); expect(parsed.passed).toBe(true); @@ -1217,7 +1223,9 @@ describe('handleStylingCall — check_dark_mode_patterns', () => { ); expect(result.isError).toBeFalsy(); - expect(vi.mocked(checkDarkModePatterns)).toHaveBeenCalledWith('.dark my-button { color: white; }'); + expect(vi.mocked(checkDarkModePatterns)).toHaveBeenCalledWith( + '.dark my-button { color: white; }', + ); const parsed = JSON.parse(result.content[0].text); expect(parsed.issues).toEqual([]); }); diff --git a/tests/tools/theme.test.ts b/tests/tools/theme.test.ts index 68ecd06..224da0d 100644 --- a/tests/tools/theme.test.ts +++ b/tests/tools/theme.test.ts @@ -4,10 +4,7 @@ * and response formatting with CEM-based inputs. */ import { describe, it, expect, vi } from 'vitest'; -import { - isThemeTool, - handleThemeCall, -} from '../../packages/core/src/tools/theme.js'; +import { isThemeTool, handleThemeCall } from '../../packages/core/src/tools/theme.js'; import type { Cem } from '../../packages/core/src/handlers/cem.js'; // ─── Mocks ──────────────────────────────────────────────────────────────────── @@ -21,11 +18,7 @@ vi.mock('../../packages/core/src/handlers/theme.js', () => ({ fullThemeCSS: `.${opts?.themeName ?? 'theme'}-light { --hx-color-primary: #0066cc; }`, })), applyThemeTokens: vi.fn( - ( - _cem: unknown, - themeTokens: Record, - _tagNames?: string[], - ) => ({ + (_cem: unknown, themeTokens: Record, _tagNames?: string[]) => ({ globalBlock: `:root {\n${Object.entries(themeTokens) .map(([k, v]) => ` ${k}: ${v};`) .join('\n')}\n}`, @@ -163,11 +156,7 @@ describe('handleThemeCall — apply_theme_tokens', () => { '--hx-spacing-md': '1rem', '--hx-font-family': 'sans-serif', }; - const result = await handleThemeCall( - 'apply_theme_tokens', - { themeTokens: tokens }, - RICH_CEM, - ); + const result = await handleThemeCall('apply_theme_tokens', { themeTokens: tokens }, RICH_CEM); expect(result.isError).toBeFalsy(); const parsed = JSON.parse(result.content[0].text); expect(parsed.matchedTokenCount).toBe(3); diff --git a/tests/tools/tokens.test.ts b/tests/tools/tokens.test.ts index aa2346e..6af42d4 100644 --- a/tests/tools/tokens.test.ts +++ b/tests/tools/tokens.test.ts @@ -24,9 +24,9 @@ vi.mock('../../packages/core/src/handlers/tokens.js', () => ({ categories: ['color', 'spacing'], })), findToken: vi.fn(async (_config: unknown, query: string) => ({ - tokens: [ - { name: '--color-primary', value: '#0066cc', category: 'color' }, - ].filter((t) => t.name.includes(query) || t.value.includes(query)), + tokens: [{ name: '--color-primary', value: '#0066cc', category: 'color' }].filter( + (t) => t.name.includes(query) || t.value.includes(query), + ), count: 1, query, })), @@ -111,11 +111,7 @@ describe('handleTokenCall — get_design_tokens', () => { }); it('accepts optional category filter', async () => { - const result = await handleTokenCall( - 'get_design_tokens', - { category: 'color' }, - FAKE_CONFIG, - ); + const result = await handleTokenCall('get_design_tokens', { category: 'color' }, FAKE_CONFIG); expect(result.isError).toBeFalsy(); }); diff --git a/tests/tools/typegenerate.test.ts b/tests/tools/typegenerate.test.ts index 01502b2..7617e42 100644 --- a/tests/tools/typegenerate.test.ts +++ b/tests/tools/typegenerate.test.ts @@ -18,9 +18,10 @@ vi.mock('../../packages/core/src/handlers/typegenerate.js', () => ({ const count = cem.modules.length; return { componentCount: count, - content: count === 0 - ? '// No components found\n' - : 'declare global {\n interface HTMLElementTagNameMap {\n "hx-button": HxButton;\n }\n}\nexport interface HxButton {\n variant?: string;\n disabled?: boolean;\n}\n', + content: + count === 0 + ? '// No components found\n' + : 'declare global {\n interface HTMLElementTagNameMap {\n "hx-button": HxButton;\n }\n}\nexport interface HxButton {\n variant?: string;\n disabled?: boolean;\n}\n', }; }), })); @@ -44,9 +45,7 @@ const BUTTON_CEM: Cem = { { kind: 'field', name: 'variant', type: { text: 'string' } }, { kind: 'field', name: 'disabled', type: { text: 'boolean' } }, ], - attributes: [ - { name: 'variant', type: { text: 'string' } }, - ], + attributes: [{ name: 'variant', type: { text: 'string' } }], }, ], }, @@ -59,16 +58,12 @@ const MULTI_CEM: Cem = { { kind: 'javascript-module', path: 'src/hx-button.ts', - declarations: [ - { kind: 'class', name: 'HxButton', tagName: 'hx-button', members: [] }, - ], + declarations: [{ kind: 'class', name: 'HxButton', tagName: 'hx-button', members: [] }], }, { kind: 'javascript-module', path: 'src/hx-card.ts', - declarations: [ - { kind: 'class', name: 'HxCard', tagName: 'hx-card', members: [] }, - ], + declarations: [{ kind: 'class', name: 'HxCard', tagName: 'hx-card', members: [] }], }, ], }; diff --git a/tests/tools/typescript.test.ts b/tests/tools/typescript.test.ts index 7726ae5..e0152e1 100644 --- a/tests/tools/typescript.test.ts +++ b/tests/tools/typescript.test.ts @@ -230,7 +230,8 @@ describe('handleTypeScriptCall — handler error propagation', () => { }); it('returns error when getProjectDiagnostics handler throws', async () => { - const { getProjectDiagnostics } = await import('../../packages/core/src/handlers/typescript.js'); + const { getProjectDiagnostics } = + await import('../../packages/core/src/handlers/typescript.js'); vi.mocked(getProjectDiagnostics).mockImplementationOnce(() => { throw new Error('Project root does not exist'); }); diff --git a/tests/tools/validate.test.ts b/tests/tools/validate.test.ts index 7da73d2..614171c 100644 --- a/tests/tools/validate.test.ts +++ b/tests/tools/validate.test.ts @@ -14,16 +14,14 @@ import type { Cem } from '../../packages/core/src/handlers/cem.js'; // ─── Mocks ──────────────────────────────────────────────────────────────────── vi.mock('../../packages/core/src/handlers/validate.js', () => ({ - validateUsage: vi.fn( - (tagName: string, html: string, _cem: unknown) => ({ - tagName, - html, - valid: true, - issues: [], - issueCount: 0, - formatted: `## Validation: ${tagName}\n\n**Result:** PASS\n\nNo issues found.`, - }), - ), + validateUsage: vi.fn((tagName: string, html: string, _cem: unknown) => ({ + tagName, + html, + valid: true, + issues: [], + issueCount: 0, + formatted: `## Validation: ${tagName}\n\n**Result:** PASS\n\nNo issues found.`, + })), })); // ─── Fixtures ───────────────────────────────────────────────────────────────── @@ -187,11 +185,7 @@ describe('handleValidateCall — error cases', () => { }); it('returns error when html is missing', () => { - const result = handleValidateCall( - 'validate_usage', - { tagName: 'hx-button' }, - BUTTON_CEM, - ); + const result = handleValidateCall('validate_usage', { tagName: 'hx-button' }, BUTTON_CEM); expect(result.isError).toBe(true); });