@esportsplus/typescript
Advanced tools
| { | ||
| "project": "@esportsplus/typescript", | ||
| "version": "0.29.0", | ||
| "target": "src/ tests/", | ||
| "runs": [ | ||
| { | ||
| "date": "2026-04-11", | ||
| "commit": "e0657fe", | ||
| "mode": "full", | ||
| "files_audited": 13, | ||
| "files_skipped": 0, | ||
| "findings_new": 2, | ||
| "findings_recurring": 0, | ||
| "sqale_rating": "A", | ||
| "agent_results": { | ||
| "correctness": { "status": "ok", "symbols": 26, "sub_targets": 1, "findings": 1 }, | ||
| "security": { "status": "ok", "symbols": 14, "sub_targets": 1, "findings": 0 }, | ||
| "performance": { "status": "ok", "symbols": 15, "sub_targets": 1, "findings": 1 }, | ||
| "architecture": { "status": "ok", "symbols": 32, "sub_targets": 1, "findings": 0 }, | ||
| "testing": { "status": "ok", "symbols": 16, "sub_targets": 1, "findings": 1 } | ||
| }, | ||
| "agent_overlap": {}, | ||
| "estimated_remaining": null | ||
| }, | ||
| { | ||
| "date": "2026-04-11", | ||
| "commit": "e0657fe", | ||
| "mode": "incremental", | ||
| "files_audited": 13, | ||
| "files_skipped": 0, | ||
| "findings_new": 0, | ||
| "findings_recurring": 1, | ||
| "sqale_rating": "A", | ||
| "agent_results": { | ||
| "correctness": { "status": "ok", "symbols": 26, "sub_targets": 1, "findings": 0 }, | ||
| "security": { "status": "ok", "symbols": 14, "sub_targets": 1, "findings": 0 }, | ||
| "performance": { "status": "ok", "symbols": 15, "sub_targets": 1, "findings": 0 }, | ||
| "architecture": { "status": "ok", "symbols": 32, "sub_targets": 1, "findings": 0 }, | ||
| "testing": { "status": "ok", "symbols": 16, "sub_targets": 1, "findings": 1 } | ||
| }, | ||
| "agent_overlap": {}, | ||
| "estimated_remaining": null | ||
| }, | ||
| { | ||
| "date": "2026-04-11", | ||
| "commit": "e0657fe", | ||
| "mode": "incremental", | ||
| "files_audited": 13, | ||
| "files_skipped": 0, | ||
| "findings_new": 0, | ||
| "findings_recurring": 0, | ||
| "sqale_rating": "A", | ||
| "agent_results": { | ||
| "correctness": { "status": "ok", "symbols": 26, "sub_targets": 1, "findings": 0 }, | ||
| "security": { "status": "ok", "symbols": 14, "sub_targets": 1, "findings": 0 }, | ||
| "performance": { "status": "ok", "symbols": 15, "sub_targets": 1, "findings": 0 }, | ||
| "architecture": { "status": "ok", "symbols": 32, "sub_targets": 1, "findings": 0 }, | ||
| "testing": { "status": "ok", "symbols": 16, "sub_targets": 1, "findings": 0 } | ||
| }, | ||
| "agent_overlap": {}, | ||
| "estimated_remaining": null | ||
| }, | ||
| { | ||
| "date": "2026-04-11", | ||
| "commit": "59ea7a3", | ||
| "mode": "incremental", | ||
| "files_audited": 19, | ||
| "files_skipped": 0, | ||
| "findings_new": 9, | ||
| "findings_recurring": 1, | ||
| "sqale_rating": "B", | ||
| "agent_results": { | ||
| "correctness": { "status": "ok", "symbols": 26, "sub_targets": 1, "findings": 0 }, | ||
| "security": { "status": "ok", "symbols": 14, "sub_targets": 1, "findings": 0 }, | ||
| "performance": { "status": "ok", "symbols": 15, "sub_targets": 1, "findings": 0 }, | ||
| "architecture": { "status": "ok", "symbols": 32, "sub_targets": 1, "findings": 2 }, | ||
| "testing": { "status": "ok", "symbols": 35, "sub_targets": 1, "findings": 7 } | ||
| }, | ||
| "agent_overlap": {}, | ||
| "estimated_remaining": null | ||
| }, | ||
| { | ||
| "date": "2026-04-11", | ||
| "commit": "59ea7a3", | ||
| "mode": "incremental", | ||
| "files_audited": 21, | ||
| "files_skipped": 0, | ||
| "findings_new": 0, | ||
| "findings_recurring": 0, | ||
| "sqale_rating": "A", | ||
| "agent_results": { | ||
| "correctness": { "status": "ok", "symbols": 24, "sub_targets": 1, "findings": 0 }, | ||
| "security": { "status": "ok", "symbols": 14, "sub_targets": 1, "findings": 0 }, | ||
| "performance": { "status": "ok", "symbols": 13, "sub_targets": 1, "findings": 0 }, | ||
| "architecture": { "status": "ok", "symbols": 30, "sub_targets": 1, "findings": 0 }, | ||
| "testing": { "status": "ok", "symbols": 35, "sub_targets": 1, "findings": 0 } | ||
| }, | ||
| "agent_overlap": {}, | ||
| "estimated_remaining": null | ||
| }, | ||
| { | ||
| "date": "2026-04-11", | ||
| "commit": "59ea7a3", | ||
| "mode": "incremental", | ||
| "files_audited": 21, | ||
| "files_skipped": 0, | ||
| "findings_new": 0, | ||
| "findings_recurring": 0, | ||
| "sqale_rating": "A", | ||
| "agent_results": { | ||
| "correctness": { "status": "ok", "symbols": 24, "sub_targets": 1, "findings": 0 }, | ||
| "security": { "status": "ok", "symbols": 14, "sub_targets": 1, "findings": 0 }, | ||
| "performance": { "status": "ok", "symbols": 13, "sub_targets": 1, "findings": 0 }, | ||
| "architecture": { "status": "ok", "symbols": 30, "sub_targets": 1, "findings": 0 }, | ||
| "testing": { "status": "ok", "symbols": 35, "sub_targets": 1, "findings": 0 } | ||
| }, | ||
| "agent_overlap": {}, | ||
| "estimated_remaining": null | ||
| } | ||
| ], | ||
| "findings": { | ||
| "F-001": { | ||
| "status": "implemented", | ||
| "file": "src/compiler/coordinator.ts", | ||
| "symbol": "transform", | ||
| "category": "correctness", | ||
| "title": "AST Position Staleness After Code Modification", | ||
| "content_hash": "ast-position-staleness-coordinator-transform", | ||
| "file_hash": "", | ||
| "first_seen": "2026-04-11", | ||
| "last_seen": "2026-04-11", | ||
| "found_by": ["correctness"], | ||
| "priority_score": 66 | ||
| }, | ||
| "F-002": { | ||
| "status": "deferred", | ||
| "file": "src/compiler/coordinator.ts", | ||
| "symbol": "applyImports", | ||
| "category": "optimize", | ||
| "title": "Redundant SourceFile Recreation in applyImports Loop", | ||
| "content_hash": "redundant-sourcefile-recreation-applyimports", | ||
| "file_hash": "", | ||
| "first_seen": "2026-04-11", | ||
| "last_seen": "2026-04-11", | ||
| "found_by": ["performance"], | ||
| "priority_score": 44 | ||
| }, | ||
| "F-003": { | ||
| "status": "implemented", | ||
| "file": "src/", | ||
| "symbol": "*", | ||
| "category": "coverage", | ||
| "title": "Complete Absence of Test Infrastructure", | ||
| "content_hash": "missing-test-infrastructure", | ||
| "file_hash": "", | ||
| "first_seen": "2026-04-11", | ||
| "last_seen": "2026-04-11", | ||
| "found_by": ["testing"], | ||
| "priority_score": 114 | ||
| }, | ||
| "F-004": { | ||
| "status": "implemented", | ||
| "file": "src/compiler/language-service.ts", | ||
| "symbol": "get", | ||
| "category": "loc", | ||
| "title": "languageService.get() is dead code", | ||
| "content_hash": "dead-code-language-service-get", | ||
| "file_hash": "", | ||
| "first_seen": "2026-04-11", | ||
| "last_seen": "2026-04-11", | ||
| "found_by": ["architecture"], | ||
| "priority_score": 45 | ||
| }, | ||
| "F-005": { | ||
| "status": "implemented", | ||
| "file": "src/compiler/language-service.ts", | ||
| "symbol": "delete", | ||
| "category": "loc", | ||
| "title": "languageService.delete() is dead code", | ||
| "content_hash": "dead-code-language-service-delete", | ||
| "file_hash": "", | ||
| "first_seen": "2026-04-11", | ||
| "last_seen": "2026-04-11", | ||
| "found_by": ["architecture"], | ||
| "priority_score": 45 | ||
| }, | ||
| "F-006": { | ||
| "status": "implemented", | ||
| "file": "src/compiler/language-service.ts", | ||
| "symbol": "*", | ||
| "category": "coverage", | ||
| "title": "language-service.ts has 0 test coverage", | ||
| "content_hash": "no-test-coverage-language-service", | ||
| "file_hash": "", | ||
| "first_seen": "2026-04-11", | ||
| "last_seen": "2026-04-11", | ||
| "found_by": ["testing"], | ||
| "priority_score": 50 | ||
| }, | ||
| "F-007": { | ||
| "status": "open", | ||
| "file": "src/cli/tsc.ts", | ||
| "symbol": "*", | ||
| "category": "coverage", | ||
| "title": "cli/tsc.ts has 0 test coverage", | ||
| "content_hash": "no-test-coverage-cli-tsc", | ||
| "file_hash": "", | ||
| "first_seen": "2026-04-11", | ||
| "last_seen": "2026-04-11", | ||
| "found_by": ["testing"], | ||
| "priority_score": 35 | ||
| }, | ||
| "F-008": { | ||
| "status": "implemented", | ||
| "file": "src/compiler/coordinator.ts", | ||
| "symbol": "transform", | ||
| "category": "test-quality", | ||
| "title": "coordinator plugin.transform() throw path untested", | ||
| "content_hash": "test-quality-coordinator-throw-path", | ||
| "file_hash": "", | ||
| "first_seen": "2026-04-11", | ||
| "last_seen": "2026-04-11", | ||
| "found_by": ["testing"], | ||
| "priority_score": 48 | ||
| }, | ||
| "F-009": { | ||
| "status": "implemented", | ||
| "file": "src/compiler/coordinator.ts", | ||
| "symbol": "transform", | ||
| "category": "test-quality", | ||
| "title": "coordinator null sourceFile fallback path untested", | ||
| "content_hash": "test-quality-coordinator-null-sourcefile", | ||
| "file_hash": "", | ||
| "first_seen": "2026-04-11", | ||
| "last_seen": "2026-04-11", | ||
| "found_by": ["testing"], | ||
| "priority_score": 48 | ||
| }, | ||
| "F-010": { | ||
| "status": "implemented", | ||
| "file": "src/compiler/plugins/vite.ts", | ||
| "symbol": "transform", | ||
| "category": "test-quality", | ||
| "title": "vite plugin error catch path untested", | ||
| "content_hash": "test-quality-vite-error-catch", | ||
| "file_hash": "", | ||
| "first_seen": "2026-04-11", | ||
| "last_seen": "2026-04-11", | ||
| "found_by": ["testing"], | ||
| "priority_score": 48 | ||
| }, | ||
| "F-011": { | ||
| "status": "implemented", | ||
| "file": "src/compiler/plugins/vite.ts", | ||
| "symbol": "transform", | ||
| "category": "test-quality", | ||
| "title": "vite plugin sourceFile undefined fallback untested", | ||
| "content_hash": "test-quality-vite-sourcefile-fallback", | ||
| "file_hash": "", | ||
| "first_seen": "2026-04-11", | ||
| "last_seen": "2026-04-11", | ||
| "found_by": ["testing"], | ||
| "priority_score": 45 | ||
| }, | ||
| "F-012": { | ||
| "status": "implemented", | ||
| "file": "src/compiler/imports.ts", | ||
| "symbol": "includes", | ||
| "category": "test-quality", | ||
| "title": "imports.includes() getAliasedSymbol catch block untested", | ||
| "content_hash": "test-quality-imports-aliased-catch", | ||
| "file_hash": "", | ||
| "first_seen": "2026-04-11", | ||
| "last_seen": "2026-04-11", | ||
| "found_by": ["testing"], | ||
| "priority_score": 42 | ||
| } | ||
| }, | ||
| "clean_symbols": {}, | ||
| "convergence": { | ||
| "per_category": { | ||
| "correctness": { | ||
| "findings_per_run": [1, 0, 0, 0, 0, 0], | ||
| "successful_runs": 6, | ||
| "consecutive_zero": 5, | ||
| "last_agent_success": true, | ||
| "status": "CONVERGED", | ||
| "reason": "0 new findings on 5 consecutive successful runs" | ||
| }, | ||
| "security": { | ||
| "findings_per_run": [0, 0, 0, 0, 0, 0], | ||
| "successful_runs": 6, | ||
| "consecutive_zero": 6, | ||
| "last_agent_success": true, | ||
| "status": "CONVERGED", | ||
| "reason": "0 new findings on 6 consecutive successful runs" | ||
| }, | ||
| "performance": { | ||
| "findings_per_run": [1, 0, 0, 0, 0, 0], | ||
| "successful_runs": 6, | ||
| "consecutive_zero": 5, | ||
| "last_agent_success": true, | ||
| "status": "CONVERGED", | ||
| "reason": "0 new findings on 5 consecutive successful runs" | ||
| }, | ||
| "architecture": { | ||
| "findings_per_run": [0, 0, 0, 2, 0, 0], | ||
| "successful_runs": 6, | ||
| "consecutive_zero": 2, | ||
| "last_agent_success": true, | ||
| "status": "CONVERGED", | ||
| "reason": "0 new findings on 2 consecutive runs after fixes" | ||
| }, | ||
| "testing": { | ||
| "findings_per_run": [1, 1, 0, 7, 0, 0], | ||
| "successful_runs": 6, | ||
| "consecutive_zero": 2, | ||
| "last_agent_success": true, | ||
| "status": "CONVERGED", | ||
| "reason": "0 new findings on 2 consecutive runs after fixes" | ||
| } | ||
| }, | ||
| "overall": "CONVERGED", | ||
| "reason": "all 5 categories converged with 0 findings on 2+ consecutive runs" | ||
| } | ||
| } |
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import fs from 'fs'; | ||
| import os from 'os'; | ||
| import path from 'path'; | ||
| import { isPlugin, loadPlugins, normalizePath, runTscAlias } from '~/cli/tsc'; | ||
| describe('isPlugin', () => { | ||
| it('returns true for valid plugin', () => { | ||
| expect(isPlugin({ transform: () => {} })).toBe(true); | ||
| }); | ||
| it('returns false for null', () => { | ||
| expect(isPlugin(null)).toBe(false); | ||
| }); | ||
| it('returns false for empty object', () => { | ||
| expect(isPlugin({})).toBe(false); | ||
| }); | ||
| it('returns false when transform is not a function', () => { | ||
| expect(isPlugin({ transform: 'not-fn' })).toBe(false); | ||
| }); | ||
| it('returns false for primitives', () => { | ||
| expect(isPlugin(42)).toBe(false); | ||
| expect(isPlugin('str')).toBe(false); | ||
| expect(isPlugin(undefined)).toBe(false); | ||
| }); | ||
| }); | ||
| describe('normalizePath', () => { | ||
| it('converts backslashes to forward slashes', () => { | ||
| let result = normalizePath('C:\\foo\\bar.ts'); | ||
| expect(result).not.toContain('\\'); | ||
| expect(result).toContain('/foo/bar'); | ||
| }); | ||
| it('resolves to absolute path', () => { | ||
| let result = normalizePath('relative/file.ts'); | ||
| expect(path.isAbsolute(result)).toBe(true); | ||
| }); | ||
| }); | ||
| describe('loadPlugins', () => { | ||
| let tmpDir: string; | ||
| beforeEach(() => { | ||
| tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tsc-test-')); | ||
| }); | ||
| afterEach(() => { | ||
| fs.rmSync(tmpDir, { recursive: true, force: true }); | ||
| }); | ||
| it('loads a valid plugin with transform export', async () => { | ||
| let pluginFile = 'plugin.mjs'; | ||
| fs.writeFileSync(path.join(tmpDir, pluginFile), 'export default { transform: () => ({}) };'); | ||
| let plugins = await loadPlugins([{ transform: './' + pluginFile }], tmpDir); | ||
| expect(plugins).toHaveLength(1); | ||
| expect(typeof plugins[0].transform).toBe('function'); | ||
| }); | ||
| it('loads a factory function that returns a plugin', async () => { | ||
| let pluginFile = 'factory.mjs'; | ||
| fs.writeFileSync(path.join(tmpDir, pluginFile), 'export default function() { return { transform: () => ({}) }; };'); | ||
| let plugins = await loadPlugins([{ transform: './' + pluginFile }], tmpDir); | ||
| expect(plugins).toHaveLength(1); | ||
| expect(typeof plugins[0].transform).toBe('function'); | ||
| }); | ||
| it('loads array of plugins', async () => { | ||
| let pluginFile = 'array.mjs'; | ||
| fs.writeFileSync(path.join(tmpDir, pluginFile), 'export default [{ transform: () => ({}) }, { transform: () => ({}) }];'); | ||
| let plugins = await loadPlugins([{ transform: './' + pluginFile }], tmpDir); | ||
| expect(plugins).toHaveLength(2); | ||
| }); | ||
| it('skips invalid plugin format with error', async () => { | ||
| let pluginFile = 'invalid.mjs'; | ||
| fs.writeFileSync(path.join(tmpDir, pluginFile), 'export default { notTransform: true };'); | ||
| let spy = vi.spyOn(console, 'error').mockImplementation(() => {}); | ||
| let plugins = await loadPlugins([{ transform: './' + pluginFile }], tmpDir); | ||
| expect(plugins).toHaveLength(0); | ||
| expect(spy).toHaveBeenCalled(); | ||
| spy.mockRestore(); | ||
| }); | ||
| it('skips invalid array element with error', async () => { | ||
| let pluginFile = 'mixed.mjs'; | ||
| fs.writeFileSync(path.join(tmpDir, pluginFile), 'export default [{ transform: () => ({}) }, { bad: true }];'); | ||
| let spy = vi.spyOn(console, 'error').mockImplementation(() => {}); | ||
| let plugins = await loadPlugins([{ transform: './' + pluginFile }], tmpDir); | ||
| expect(plugins).toHaveLength(1); | ||
| expect(spy).toHaveBeenCalled(); | ||
| spy.mockRestore(); | ||
| }); | ||
| it('resolves relative paths from root', async () => { | ||
| let pluginFile = 'relative.mjs'; | ||
| fs.writeFileSync(path.join(tmpDir, pluginFile), 'export default { transform: () => ({}) };'); | ||
| let plugins = await loadPlugins([{ transform: './' + pluginFile }], tmpDir); | ||
| expect(plugins).toHaveLength(1); | ||
| }); | ||
| }); | ||
| describe('runTscAlias', () => { | ||
| it('returns 0 for --noEmit flag', async () => { | ||
| let code = await runTscAlias(['--noEmit']); | ||
| expect(code).toBe(0); | ||
| }); | ||
| it('returns 0 for --help flag', async () => { | ||
| let code = await runTscAlias(['--help']); | ||
| expect(code).toBe(0); | ||
| }); | ||
| it('returns 0 for --version flag', async () => { | ||
| let code = await runTscAlias(['--version']); | ||
| expect(code).toBe(0); | ||
| }); | ||
| it('returns 0 for -v flag', async () => { | ||
| let code = await runTscAlias(['-v']); | ||
| expect(code).toBe(0); | ||
| }); | ||
| }); |
| import { describe, expect, it } from 'vitest'; | ||
| import ts from 'typescript'; | ||
| import ast from '~/compiler/ast'; | ||
| function parse(code: string): ts.SourceFile { | ||
| return ts.createSourceFile('test.ts', code, ts.ScriptTarget.Latest, true); | ||
| } | ||
| function findFirst(file: ts.SourceFile, predicate: (n: ts.Node) => boolean): ts.Node | undefined { | ||
| let result: ts.Node | undefined; | ||
| function visit(node: ts.Node) { | ||
| if (result) { | ||
| return; | ||
| } | ||
| if (predicate(node)) { | ||
| result = node; | ||
| return; | ||
| } | ||
| ts.forEachChild(node, visit); | ||
| } | ||
| ts.forEachChild(file, visit); | ||
| return result; | ||
| } | ||
| describe('ast.expression.name', () => { | ||
| it('returns text for identifiers', () => { | ||
| let file = parse('let x = foo;'), | ||
| node = findFirst(file, ts.isIdentifier); | ||
| expect(node).toBeDefined(); | ||
| expect(ast.expression.name(node as ts.Expression)).toBe('x'); | ||
| }); | ||
| it('returns dotted path for property access', () => { | ||
| let file = parse('a.b.c;'), | ||
| node = findFirst(file, ts.isPropertyAccessExpression); | ||
| expect(node).toBeDefined(); | ||
| expect(ast.expression.name(node as ts.Expression)).toBe('a.b.c'); | ||
| }); | ||
| it('returns null for unsupported expressions', () => { | ||
| let file = parse('foo();'), | ||
| node = findFirst(file, ts.isCallExpression); | ||
| expect(node).toBeDefined(); | ||
| expect(ast.expression.name(node as ts.Expression)).toBeNull(); | ||
| }); | ||
| }); | ||
| describe('ast.inRange', () => { | ||
| it('returns true when node is within range', () => { | ||
| expect(ast.inRange([{ start: 0, end: 100 }], 10, 50)).toBe(true); | ||
| }); | ||
| it('returns true at exact boundaries', () => { | ||
| expect(ast.inRange([{ start: 10, end: 50 }], 10, 50)).toBe(true); | ||
| }); | ||
| it('returns false when node is outside range', () => { | ||
| expect(ast.inRange([{ start: 10, end: 50 }], 0, 9)).toBe(false); | ||
| }); | ||
| it('returns false when node partially overlaps', () => { | ||
| expect(ast.inRange([{ start: 10, end: 50 }], 5, 30)).toBe(false); | ||
| }); | ||
| it('returns false for empty ranges', () => { | ||
| expect(ast.inRange([], 0, 10)).toBe(false); | ||
| }); | ||
| it('checks multiple ranges', () => { | ||
| let ranges = [{ start: 0, end: 10 }, { start: 20, end: 30 }]; | ||
| expect(ast.inRange(ranges, 0, 10)).toBe(true); | ||
| expect(ast.inRange(ranges, 25, 28)).toBe(true); | ||
| expect(ast.inRange(ranges, 11, 19)).toBe(false); | ||
| }); | ||
| }); | ||
| describe('ast.property.path', () => { | ||
| it('returns dotted path for nested access', () => { | ||
| let file = parse('a.b.c.d;'), | ||
| node = findFirst(file, (n) => ts.isPropertyAccessExpression(n) && !ts.isPropertyAccessExpression(n.parent)); | ||
| expect(node).toBeDefined(); | ||
| expect(ast.property.path(node as ts.Expression)).toBe('a.b.c.d'); | ||
| }); | ||
| it('returns null for non-identifier base', () => { | ||
| let file = parse('foo().bar;'), | ||
| node = findFirst(file, ts.isPropertyAccessExpression); | ||
| expect(node).toBeDefined(); | ||
| expect(ast.property.path(node as ts.Expression)).toBeNull(); | ||
| }); | ||
| }); | ||
| describe('ast.test', () => { | ||
| it('returns true when predicate matches root', () => { | ||
| let file = parse('let x = 1;'), | ||
| found = ast.test(file, (n) => ts.isVariableStatement(n)); | ||
| expect(found).toBe(true); | ||
| }); | ||
| it('returns true when predicate matches deep child', () => { | ||
| let file = parse('function f() { return { a: 1 }; }'), | ||
| found = ast.test(file, (n) => ts.isObjectLiteralExpression(n)); | ||
| expect(found).toBe(true); | ||
| }); | ||
| it('returns false when predicate never matches', () => { | ||
| let file = parse('let x = 1;'), | ||
| found = ast.test(file, (n) => ts.isClassDeclaration(n)); | ||
| expect(found).toBe(false); | ||
| }); | ||
| }); |
| import { describe, expect, it } from 'vitest'; | ||
| import code from '~/compiler/code'; | ||
| describe('code', () => { | ||
| it('interpolates values into template', () => { | ||
| let result = code`let x = ${'hello'};`; | ||
| expect(result).toBe("let x = hello;"); | ||
| }); | ||
| it('handles multiple interpolations', () => { | ||
| let result = code`${'a'} + ${'b'} = ${'c'}`; | ||
| expect(result).toBe('a + b = c'); | ||
| }); | ||
| it('collapses null to empty string', () => { | ||
| let result = code`x${null}y`; | ||
| expect(result).toBe('xy'); | ||
| }); | ||
| it('collapses undefined to empty string', () => { | ||
| let result = code`x${undefined}y`; | ||
| expect(result).toBe('xy'); | ||
| }); | ||
| it('collapses false to empty string', () => { | ||
| let result = code`x${false}y`; | ||
| expect(result).toBe('xy'); | ||
| }); | ||
| it('preserves zero', () => { | ||
| let result = code`x${0}y`; | ||
| expect(result).toBe('x0y'); | ||
| }); | ||
| it('preserves empty string', () => { | ||
| let result = code`x${''}y`; | ||
| expect(result).toBe('xy'); | ||
| }); | ||
| it('handles no interpolations', () => { | ||
| let result = code`just plain text`; | ||
| expect(result).toBe('just plain text'); | ||
| }); | ||
| }); | ||
| describe('code.escape', () => { | ||
| it('escapes single quotes', () => { | ||
| expect(code.escape("it's")).toBe("it\\'s"); | ||
| }); | ||
| it('escapes multiple quotes', () => { | ||
| expect(code.escape("a'b'c")).toBe("a\\'b\\'c"); | ||
| }); | ||
| it('returns unchanged string without quotes', () => { | ||
| expect(code.escape('hello')).toBe('hello'); | ||
| }); | ||
| it('handles empty string', () => { | ||
| expect(code.escape('')).toBe(''); | ||
| }); | ||
| }); |
| import { bench, describe, vi } from 'vitest'; | ||
| import ts from 'typescript'; | ||
| import type { Plugin, TransformContext } from '~/compiler/types'; | ||
| import coordinator from '~/compiler/coordinator'; | ||
| vi.mock('~/compiler/language-service', () => ({ | ||
| default: { | ||
| invalidate: vi.fn(), | ||
| update: vi.fn((_root: string, fileName: string, content: string) => { | ||
| let file = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); | ||
| return { | ||
| getSourceFile: () => file, | ||
| getTypeChecker: () => ({} as ts.TypeChecker) | ||
| } as unknown as ts.Program; | ||
| }) | ||
| } | ||
| })); | ||
| function parse(code: string, fileName = 'bench.ts'): ts.SourceFile { | ||
| return ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true); | ||
| } | ||
| function makeProgram(file: ts.SourceFile): ts.Program { | ||
| return { | ||
| getSourceFile: () => file, | ||
| getTypeChecker: () => ({} as ts.TypeChecker) | ||
| } as unknown as ts.Program; | ||
| } | ||
| function makePlugin(transformFn: (ctx: TransformContext) => ReturnType<Plugin['transform']>): Plugin { | ||
| return { transform: transformFn }; | ||
| } | ||
| let code = 'let x = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file); | ||
| describe('applyImports batching', () => { | ||
| bench('10 intents, 1 package', () => { | ||
| let plugin = makePlugin(() => ({ | ||
| imports: [ | ||
| { add: ['a'], package: '@pkg/a' }, | ||
| { add: ['b'], package: '@pkg/a' }, | ||
| { add: ['c'], package: '@pkg/a' }, | ||
| { add: ['d'], package: '@pkg/a' }, | ||
| { add: ['e'], package: '@pkg/a' }, | ||
| { add: ['f'], package: '@pkg/a' }, | ||
| { add: ['g'], package: '@pkg/a' }, | ||
| { add: ['h'], package: '@pkg/a' }, | ||
| { add: ['i'], package: '@pkg/a' }, | ||
| { add: ['j'], package: '@pkg/a' } | ||
| ] | ||
| })); | ||
| coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| }); | ||
| bench('10 intents, 3 packages', () => { | ||
| let plugin = makePlugin(() => ({ | ||
| imports: [ | ||
| { add: ['a'], package: '@pkg/a' }, | ||
| { add: ['b'], package: '@pkg/a' }, | ||
| { add: ['c'], package: '@pkg/a' }, | ||
| { add: ['d'], package: '@pkg/b' }, | ||
| { add: ['e'], package: '@pkg/b' }, | ||
| { add: ['f'], package: '@pkg/b' }, | ||
| { add: ['g'], package: '@pkg/b' }, | ||
| { add: ['h'], package: '@pkg/c' }, | ||
| { add: ['i'], package: '@pkg/c' }, | ||
| { add: ['j'], package: '@pkg/c' } | ||
| ] | ||
| })); | ||
| coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| }); | ||
| bench('10 intents, 10 packages', () => { | ||
| let plugin = makePlugin(() => ({ | ||
| imports: [ | ||
| { add: ['a'], package: '@pkg/a' }, | ||
| { add: ['b'], package: '@pkg/b' }, | ||
| { add: ['c'], package: '@pkg/c' }, | ||
| { add: ['d'], package: '@pkg/d' }, | ||
| { add: ['e'], package: '@pkg/e' }, | ||
| { add: ['f'], package: '@pkg/f' }, | ||
| { add: ['g'], package: '@pkg/g' }, | ||
| { add: ['h'], package: '@pkg/h' }, | ||
| { add: ['i'], package: '@pkg/i' }, | ||
| { add: ['j'], package: '@pkg/j' } | ||
| ] | ||
| })); | ||
| coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| }); | ||
| }); |
| import { describe, expect, it, vi } from 'vitest'; | ||
| import ts from 'typescript'; | ||
| import type { ImportIntent, Plugin, ReplacementIntent, SharedContext, TransformContext } from '~/compiler/types'; | ||
| import coordinator from '~/compiler/coordinator'; | ||
| vi.mock('~/compiler/language-service', () => ({ | ||
| default: { | ||
| invalidate: vi.fn(), | ||
| update: vi.fn((_root: string, fileName: string, content: string) => { | ||
| let file = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); | ||
| return { | ||
| getSourceFile: () => file, | ||
| getTypeChecker: () => ({} as ts.TypeChecker) | ||
| } as unknown as ts.Program; | ||
| }) | ||
| } | ||
| })); | ||
| function parse(code: string, fileName = 'test.ts'): ts.SourceFile { | ||
| return ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true); | ||
| } | ||
| function makeProgram(file: ts.SourceFile): ts.Program { | ||
| return { | ||
| getSourceFile: () => file, | ||
| getTypeChecker: () => ({} as ts.TypeChecker) | ||
| } as unknown as ts.Program; | ||
| } | ||
| function makePlugin(transformFn: (ctx: TransformContext) => ReturnType<Plugin['transform']>): Plugin { | ||
| return { transform: transformFn }; | ||
| } | ||
| describe('coordinator.transform', () => { | ||
| it('returns unchanged when no plugins', () => { | ||
| let code = 'let x = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| result = coordinator.transform([], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(false); | ||
| expect(result.code).toBe(code); | ||
| }); | ||
| it('applies replacement intents', () => { | ||
| let code = 'let x = OLD;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin((ctx) => { | ||
| let node: ts.Node | undefined; | ||
| ts.forEachChild(ctx.sourceFile, function visit(n) { | ||
| if (ts.isIdentifier(n) && n.text === 'OLD') { | ||
| node = n; | ||
| } | ||
| ts.forEachChild(n, visit); | ||
| }); | ||
| if (!node) { | ||
| return {}; | ||
| } | ||
| let intents: ReplacementIntent[] = [{ | ||
| generate: () => 'NEW', | ||
| node | ||
| }]; | ||
| return { replacements: intents }; | ||
| }), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain('NEW'); | ||
| expect(result.code).not.toContain('OLD'); | ||
| }); | ||
| it('applies prepend after imports', () => { | ||
| let code = "import { a } from 'pkg';\nlet x = 1;", | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin(() => ({ | ||
| prepend: ['const GENERATED = true;'] | ||
| })), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain('const GENERATED = true;'); | ||
| let importIdx = result.code.indexOf("import { a } from 'pkg';"), | ||
| generatedIdx = result.code.indexOf('const GENERATED = true;'), | ||
| letIdx = result.code.indexOf('let x = 1;'); | ||
| expect(importIdx).toBeLessThan(generatedIdx); | ||
| expect(generatedIdx).toBeLessThan(letIdx); | ||
| }); | ||
| it('applies import intents', () => { | ||
| let code = 'let x = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| intents: ImportIntent[] = [{ | ||
| add: ['foo'], | ||
| package: 'my-pkg' | ||
| }], | ||
| plugin = makePlugin(() => ({ imports: intents })), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain("import { foo } from 'my-pkg';"); | ||
| }); | ||
| it('skips plugin when patterns do not match', () => { | ||
| let code = 'let x = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin: Plugin = { | ||
| patterns: ['MAGIC_TOKEN'], | ||
| transform: () => ({ prepend: ['SHOULD_NOT_APPEAR'] }) | ||
| }, | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(false); | ||
| expect(result.code).not.toContain('SHOULD_NOT_APPEAR'); | ||
| }); | ||
| it('runs plugin when patterns match', () => { | ||
| let code = 'let MAGIC_TOKEN = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin: Plugin = { | ||
| patterns: ['MAGIC_TOKEN'], | ||
| transform: () => ({ prepend: ['const FOUND = true;'] }) | ||
| }, | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain('const FOUND = true;'); | ||
| }); | ||
| it('re-parses AST between replacements and prepend (F-001 fix)', () => { | ||
| let code = "import { a } from 'pkg';\nlet OLD = 1;\nlet y = 2;", | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin((ctx) => { | ||
| let node: ts.Node | undefined; | ||
| ts.forEachChild(ctx.sourceFile, function visit(n) { | ||
| if (ts.isIdentifier(n) && n.text === 'OLD') { | ||
| node = n; | ||
| } | ||
| ts.forEachChild(n, visit); | ||
| }); | ||
| if (!node) { | ||
| return {}; | ||
| } | ||
| return { | ||
| prepend: ['const PREPENDED = true;'], | ||
| replacements: [{ | ||
| generate: () => 'REPLACED_LONGER_NAME', | ||
| node | ||
| }] | ||
| }; | ||
| }), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain('REPLACED_LONGER_NAME'); | ||
| expect(result.code).toContain('const PREPENDED = true;'); | ||
| // Prepend should be after imports, not corrupted by replacement | ||
| let importEnd = result.code.indexOf("';") + 2, | ||
| prependIdx = result.code.indexOf('const PREPENDED = true;'); | ||
| expect(prependIdx).toBeGreaterThan(importEnd); | ||
| }); | ||
| it('chains multiple plugins', () => { | ||
| let code = 'let x = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin1 = makePlugin(() => ({ prepend: ['const A = 1;'] })), | ||
| plugin2 = makePlugin(() => ({ prepend: ['const B = 2;'] })), | ||
| result = coordinator.transform([plugin1, plugin2], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain('const A = 1;'); | ||
| expect(result.code).toContain('const B = 2;'); | ||
| }); | ||
| it('shares context between plugins', () => { | ||
| let code = 'let x = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| shared: SharedContext = new Map(), | ||
| plugin1 = makePlugin((ctx) => { | ||
| ctx.shared.set('key', 'value'); | ||
| return { prepend: ['const A = 1;'] }; | ||
| }), | ||
| plugin2 = makePlugin((ctx) => { | ||
| let val = ctx.shared.get('key'); | ||
| return { prepend: [`const B = '${val}';`] }; | ||
| }), | ||
| result = coordinator.transform([plugin1, plugin2], code, file, program, '/root', shared); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain("const B = 'value';"); | ||
| }); | ||
| it('applies replacements + imports together with AST re-parse', () => { | ||
| let code = "let OLD = 1;", | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin((ctx) => { | ||
| let node: ts.Node | undefined; | ||
| ts.forEachChild(ctx.sourceFile, function visit(n) { | ||
| if (ts.isIdentifier(n) && n.text === 'OLD') { | ||
| node = n; | ||
| } | ||
| ts.forEachChild(n, visit); | ||
| }); | ||
| if (!node) { | ||
| return {}; | ||
| } | ||
| return { | ||
| imports: [{ add: ['helper'], package: 'utils' }], | ||
| replacements: [{ | ||
| generate: () => 'REPLACED', | ||
| node | ||
| }] | ||
| }; | ||
| }), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain('REPLACED'); | ||
| expect(result.code).toContain("import { helper } from 'utils';"); | ||
| }); | ||
| // F-TEST-001: Coordinator integration tests with real-world patterns | ||
| it('plugin producing replacements + prepend + imports simultaneously', () => { | ||
| let code = "import { reactive } from 'my-pkg';\nlet x = reactive(1);", | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin((ctx) => { | ||
| let node: ts.Node | undefined; | ||
| ts.forEachChild(ctx.sourceFile, function visit(n) { | ||
| if (ts.isIdentifier(n) && n.text === 'reactive') { | ||
| node = n; | ||
| } | ||
| ts.forEachChild(n, visit); | ||
| }); | ||
| if (!node) { | ||
| return {}; | ||
| } | ||
| return { | ||
| imports: [{ namespace: 'NS', package: 'my-pkg', remove: ['reactive'] }], | ||
| prepend: ['class ReactiveState {}'], | ||
| replacements: [{ | ||
| generate: () => 'NS.reactive', | ||
| node | ||
| }] | ||
| }; | ||
| }), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain('NS.reactive'); | ||
| expect(result.code).toContain('class ReactiveState {}'); | ||
| expect(result.code).toContain("import * as NS from 'my-pkg';"); | ||
| expect(result.code).not.toMatch(/import\s*\{[^}]*reactive[^}]*\}\s*from\s*'my-pkg'/); | ||
| let nsImportIdx = result.code.indexOf("import * as NS from 'my-pkg';"), | ||
| classIdx = result.code.indexOf('class ReactiveState {}'), | ||
| bodyIdx = result.code.indexOf('NS.reactive'); | ||
| expect(nsImportIdx).toBeLessThan(classIdx); | ||
| expect(classIdx).toBeLessThan(bodyIdx); | ||
| }); | ||
| it('plugin with generate() closures capturing scope variables', () => { | ||
| let code = 'let myVar = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin((ctx) => { | ||
| let node: ts.Node | undefined, | ||
| varname = ''; | ||
| ts.forEachChild(ctx.sourceFile, function visit(n) { | ||
| if (ts.isIdentifier(n) && n.text === 'myVar') { | ||
| node = n; | ||
| varname = n.text; | ||
| } | ||
| ts.forEachChild(n, visit); | ||
| }); | ||
| if (!node) { | ||
| return {}; | ||
| } | ||
| return { | ||
| replacements: [{ | ||
| generate: () => `NS.write(${varname}, ${varname}.value)`, | ||
| node | ||
| }] | ||
| }; | ||
| }), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain('NS.write(myVar, myVar.value)'); | ||
| }); | ||
| it('file with existing imports, plugin adds namespace + removes specifier', () => { | ||
| let code = "import { other, reactive } from 'my-pkg';\nlet x = 1;", | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin(() => ({ | ||
| imports: [{ namespace: 'NS', package: 'my-pkg', remove: ['reactive'] }] | ||
| })), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain("import * as NS from 'my-pkg';"); | ||
| expect(result.code).toContain('other'); | ||
| expect(result.code).not.toMatch(/import\s*\{[^}]*reactive[^}]*\}\s*from\s*'my-pkg'/); | ||
| }); | ||
| // F-TEST-003: Import manipulation integration tests | ||
| it('adds specifiers to existing import', () => { | ||
| let code = "import { a } from 'pkg';\nlet x = 1;", | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin(() => ({ | ||
| imports: [{ add: ['b', 'c'], package: 'pkg' }] | ||
| })), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain('a'); | ||
| expect(result.code).toContain('b'); | ||
| expect(result.code).toContain('c'); | ||
| expect(result.code).toMatch(/import\s*\{[^}]*a[^}]*b[^}]*c[^}]*\}\s*from\s*'pkg'/); | ||
| }); | ||
| it('removes specifier from import keeping others', () => { | ||
| let code = "import { a, b, reactive } from 'my-pkg';\nlet x = 1;", | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin(() => ({ | ||
| imports: [{ package: 'my-pkg', remove: ['reactive'] }] | ||
| })), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain('a'); | ||
| expect(result.code).toContain('b'); | ||
| expect(result.code).not.toMatch(/import\s*\{[^}]*reactive[^}]*\}\s*from\s*'my-pkg'/); | ||
| }); | ||
| it('adds namespace import to file without package imports', () => { | ||
| let code = 'let x = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin(() => ({ | ||
| imports: [{ namespace: 'NS', package: 'my-pkg' }] | ||
| })), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain("import * as NS from 'my-pkg';"); | ||
| }); | ||
| it('merges duplicate import statements', () => { | ||
| let code = "import { a } from 'pkg';\nimport { b } from 'pkg';\nlet x = 1;", | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin(() => ({ | ||
| imports: [{ add: ['c'], package: 'pkg' }] | ||
| })), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| let importMatches = result.code.match(/import\s*\{[^}]+\}\s*from\s*'pkg'/g); | ||
| expect(importMatches).toHaveLength(1); | ||
| expect(result.code).toContain('a'); | ||
| expect(result.code).toContain('b'); | ||
| expect(result.code).toContain('c'); | ||
| }); | ||
| it('namespace + remove specifier combined', () => { | ||
| let code = "import { reactive } from 'my-pkg';\nlet x = 1;", | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin(() => ({ | ||
| imports: [{ namespace: 'NS', package: 'my-pkg', remove: ['reactive'] }] | ||
| })), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain("import * as NS from 'my-pkg';"); | ||
| expect(result.code).not.toMatch(/import\s*\{[^}]*reactive[^}]*\}\s*from\s*'my-pkg'/); | ||
| }); | ||
| it('adds import to file with different package imports', () => { | ||
| let code = "import { x } from 'other';\nlet a = 1;", | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin(() => ({ | ||
| imports: [{ add: ['y'], package: 'new-pkg' }] | ||
| })), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain("import { x } from 'other';"); | ||
| expect(result.code).toContain("import { y } from 'new-pkg';"); | ||
| }); | ||
| // F-TEST-004: replaceReverse edge cases | ||
| it('multiple non-overlapping replacements', () => { | ||
| let code = 'let OLD1 = 1; let OLD2 = 2;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin((ctx) => { | ||
| let nodes: ts.Node[] = []; | ||
| ts.forEachChild(ctx.sourceFile, function visit(n) { | ||
| if (ts.isIdentifier(n) && (n.text === 'OLD1' || n.text === 'OLD2')) { | ||
| nodes.push(n); | ||
| } | ||
| ts.forEachChild(n, visit); | ||
| }); | ||
| return { | ||
| replacements: nodes.map(node => ({ | ||
| generate: () => node.getText(ctx.sourceFile) === 'OLD1' ? 'NEW1' : 'NEW2', | ||
| node | ||
| })) | ||
| }; | ||
| }), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain('NEW1'); | ||
| expect(result.code).toContain('NEW2'); | ||
| expect(result.code).not.toContain('OLD1'); | ||
| expect(result.code).not.toContain('OLD2'); | ||
| }); | ||
| it('replacement with empty string deletes a node', () => { | ||
| let code = 'let DELETEME = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin((ctx) => { | ||
| let node: ts.Node | undefined; | ||
| ts.forEachChild(ctx.sourceFile, function visit(n) { | ||
| if (ts.isIdentifier(n) && n.text === 'DELETEME') { | ||
| node = n; | ||
| } | ||
| ts.forEachChild(n, visit); | ||
| }); | ||
| if (!node) { | ||
| return {}; | ||
| } | ||
| return { | ||
| replacements: [{ | ||
| generate: () => '', | ||
| node | ||
| }] | ||
| }; | ||
| }), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).not.toContain('DELETEME'); | ||
| }); | ||
| it('replacement at file start', () => { | ||
| let code = 'FIRST_TOKEN;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin((ctx) => { | ||
| let node: ts.Node | undefined; | ||
| ts.forEachChild(ctx.sourceFile, function visit(n) { | ||
| if (ts.isIdentifier(n) && n.text === 'FIRST_TOKEN') { | ||
| node = n; | ||
| } | ||
| ts.forEachChild(n, visit); | ||
| }); | ||
| if (!node) { | ||
| return {}; | ||
| } | ||
| return { | ||
| replacements: [{ | ||
| generate: () => 'REPLACED_FIRST', | ||
| node | ||
| }] | ||
| }; | ||
| }), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain('REPLACED_FIRST'); | ||
| expect(result.code.indexOf('REPLACED_FIRST')).toBe(0); | ||
| }); | ||
| // F-TEST-006: Multi-plugin pipeline | ||
| it('first plugin modifies code, second receives updated code', () => { | ||
| let code = 'let OLD = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin1 = makePlugin((ctx) => { | ||
| let node: ts.Node | undefined; | ||
| ts.forEachChild(ctx.sourceFile, function visit(n) { | ||
| if (ts.isIdentifier(n) && n.text === 'OLD') { | ||
| node = n; | ||
| } | ||
| ts.forEachChild(n, visit); | ||
| }); | ||
| if (!node) { | ||
| return {}; | ||
| } | ||
| return { | ||
| replacements: [{ | ||
| generate: () => 'TRANSFORMED', | ||
| node | ||
| }] | ||
| }; | ||
| }), | ||
| plugin2 = makePlugin((ctx) => { | ||
| if (ctx.code.includes('TRANSFORMED')) { | ||
| return { prepend: ['const SEEN_BY_PLUGIN2 = true;'] }; | ||
| } | ||
| return {}; | ||
| }), | ||
| result = coordinator.transform([plugin1, plugin2], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain('TRANSFORMED'); | ||
| expect(result.code).toContain('const SEEN_BY_PLUGIN2 = true;'); | ||
| }); | ||
| it('three plugins — first skipped, second and third run', () => { | ||
| let code = 'let x = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin1: Plugin = { | ||
| patterns: ['MISSING'], | ||
| transform: () => ({ prepend: ['const SHOULD_NOT_APPEAR = true;'] }) | ||
| }, | ||
| plugin2 = makePlugin(() => ({ prepend: ['const A = 1;'] })), | ||
| plugin3 = makePlugin(() => ({ prepend: ['const B = 2;'] })), | ||
| result = coordinator.transform([plugin1, plugin2, plugin3], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).not.toContain('SHOULD_NOT_APPEAR'); | ||
| expect(result.code).toContain('const A = 1;'); | ||
| expect(result.code).toContain('const B = 2;'); | ||
| }); | ||
| // F-TEST-009: generate() sourceFile correctness | ||
| it('generate() receives correct sourceFile after prior replacement', () => { | ||
| let code = "let TARGET = 'hello';", | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin((ctx) => { | ||
| let node: ts.Node | undefined; | ||
| ts.forEachChild(ctx.sourceFile, function visit(n) { | ||
| if (ts.isIdentifier(n) && n.text === 'TARGET') { | ||
| node = n; | ||
| } | ||
| ts.forEachChild(n, visit); | ||
| }); | ||
| if (!node) { | ||
| return {}; | ||
| } | ||
| return { | ||
| replacements: [{ | ||
| generate: (sf) => `REPLACED_IN_${sf.fileName}`, | ||
| node | ||
| }] | ||
| }; | ||
| }), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain('REPLACED_IN_test.ts'); | ||
| }); | ||
| // F-TEST-005: Pattern filtering edge cases | ||
| it('pattern in string literal still matches', () => { | ||
| let code = 'let x = "reactive(";', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin: Plugin = { | ||
| patterns: ['reactive('], | ||
| transform: () => ({ prepend: ['const MATCHED = true;'] }) | ||
| }, | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain('const MATCHED = true;'); | ||
| }); | ||
| it('multiple patterns, only one matches', () => { | ||
| let code = 'let PRESENT = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin: Plugin = { | ||
| patterns: ['MISSING', 'PRESENT'], | ||
| transform: () => ({ prepend: ['const FOUND = true;'] }) | ||
| }, | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain('const FOUND = true;'); | ||
| }); | ||
| it('no patterns property — plugin always runs', () => { | ||
| let code = 'let x = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin(() => ({ prepend: ['const ALWAYS = true;'] })), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain('const ALWAYS = true;'); | ||
| }); | ||
| // F-TEST-010: applyPrepend edge cases | ||
| it('no imports — prepend goes to start', () => { | ||
| let code = 'let x = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin(() => ({ prepend: ['const A = 1;'] })), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| let prependIdx = result.code.indexOf('const A = 1;'), | ||
| letIdx = result.code.indexOf('let x = 1;'); | ||
| expect(prependIdx).toBeLessThan(letIdx); | ||
| }); | ||
| it('multiple prepend strings appear in order', () => { | ||
| let code = 'let x = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin(() => ({ prepend: ['const A = 1;', 'const B = 2;'] })), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain('const A = 1;'); | ||
| expect(result.code).toContain('const B = 2;'); | ||
| let aIdx = result.code.indexOf('const A = 1;'), | ||
| bIdx = result.code.indexOf('const B = 2;'); | ||
| expect(aIdx).toBeLessThan(bIdx); | ||
| }); | ||
| // F-TEST-011: applyImports multi-intent | ||
| it('two ImportIntents for different packages', () => { | ||
| let code = 'let x = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin(() => ({ | ||
| imports: [ | ||
| { add: ['a'], package: 'pkg-1' }, | ||
| { add: ['b'], package: 'pkg-2' } | ||
| ] | ||
| })), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain("import { a } from 'pkg-1';"); | ||
| expect(result.code).toContain("import { b } from 'pkg-2';"); | ||
| }); | ||
| it('ImportIntent with only remove', () => { | ||
| let code = "import { a, b } from 'pkg';\nlet x = 1;", | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin(() => ({ | ||
| imports: [{ package: 'pkg', remove: ['a'] }] | ||
| })), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain('b'); | ||
| expect(result.code).not.toMatch(/import\s*\{[^}]*a[^}]*\}\s*from\s*'pkg'/); | ||
| expect(result.code).toContain("import { b } from 'pkg';"); | ||
| }); | ||
| it('propagates plugin.transform() exception', () => { | ||
| let code = 'let x = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin(() => { throw new Error('plugin crashed'); }); | ||
| expect(() => coordinator.transform([plugin], code, file, program, '/root', new Map())).toThrow('plugin crashed'); | ||
| }); | ||
| it('falls back to createSourceFile when getSourceFile returns undefined', async () => { | ||
| let code = 'let x = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file); | ||
| let languageService = await import('~/compiler/language-service'); | ||
| vi.mocked(languageService.default.update).mockReturnValueOnce({ | ||
| getSourceFile: () => undefined, | ||
| getTypeChecker: () => ({} as ts.TypeChecker) | ||
| } as unknown as ts.Program); | ||
| let plugin1 = makePlugin(() => ({ prepend: ['const A = 1;'] })), | ||
| plugin2 = makePlugin((ctx) => { | ||
| if (ctx.code.includes('const A = 1;')) { | ||
| return { prepend: ['const B = 2;'] }; | ||
| } | ||
| return {}; | ||
| }), | ||
| result = coordinator.transform([plugin1, plugin2], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain('const A = 1;'); | ||
| expect(result.code).toContain('const B = 2;'); | ||
| }); | ||
| // F-003: applyImports batching | ||
| describe('applyImports batching', () => { | ||
| it('batches multiple intents for the same package into one modify call', () => { | ||
| let code = 'let x = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin(() => ({ | ||
| imports: [ | ||
| { add: ['foo'], package: '@pkg/a' }, | ||
| { add: ['bar'], package: '@pkg/a' }, | ||
| { add: ['baz'], package: '@pkg/a' } | ||
| ] | ||
| })), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain("import { bar, baz, foo } from '@pkg/a';"); | ||
| let importMatches = result.code.match(/import\s*\{[^}]+\}\s*from\s*'@pkg\/a'/g); | ||
| expect(importMatches).toHaveLength(1); | ||
| }); | ||
| it('re-parses only between distinct packages', () => { | ||
| let code = 'let x = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin(() => ({ | ||
| imports: [ | ||
| { add: ['foo'], package: '@pkg/a' }, | ||
| { add: ['bar'], package: '@pkg/a' }, | ||
| { add: ['qux'], package: '@pkg/b' } | ||
| ] | ||
| })), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain("import { bar, foo } from '@pkg/a';"); | ||
| expect(result.code).toContain("import { qux } from '@pkg/b';"); | ||
| }); | ||
| it('merges add and remove for same package', () => { | ||
| let code = "import { bar, foo } from '@pkg/a';\nlet x = 1;", | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin(() => ({ | ||
| imports: [ | ||
| { add: ['baz'], package: '@pkg/a' }, | ||
| { package: '@pkg/a', remove: ['bar'] } | ||
| ] | ||
| })), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain("import { baz, foo } from '@pkg/a';"); | ||
| expect(result.code).not.toMatch(/import\s*\{[^}]*bar[^}]*\}\s*from\s*'@pkg\/a'/); | ||
| }); | ||
| it('preserves namespace across merged intents', () => { | ||
| let code = 'let x = 1;', | ||
| file = parse(code), | ||
| program = makeProgram(file), | ||
| plugin = makePlugin(() => ({ | ||
| imports: [ | ||
| { namespace: 'utils', package: '@pkg/a' }, | ||
| { add: ['foo'], package: '@pkg/a' } | ||
| ] | ||
| })), | ||
| result = coordinator.transform([plugin], code, file, program, '/root', new Map()); | ||
| expect(result.changed).toBe(true); | ||
| expect(result.code).toContain("import * as utils from '@pkg/a';"); | ||
| expect(result.code).toContain("import { foo } from '@pkg/a';"); | ||
| }); | ||
| }); | ||
| }); |
| import { describe, expect, it } from 'vitest'; | ||
| import ts from 'typescript'; | ||
| import imports from '~/compiler/imports'; | ||
| function parse(code: string, fileName = 'test.ts'): ts.SourceFile { | ||
| return ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true); | ||
| } | ||
| describe('imports.all', () => { | ||
| it('finds named imports from a package', () => { | ||
| let file = parse("import { foo, bar } from 'my-pkg';"), | ||
| result = imports.all(file, 'my-pkg'); | ||
| expect(result).toHaveLength(1); | ||
| expect(result[0].specifiers.get('foo')).toBe('foo'); | ||
| expect(result[0].specifiers.get('bar')).toBe('bar'); | ||
| }); | ||
| it('handles aliased imports', () => { | ||
| let file = parse("import { foo as f } from 'my-pkg';"), | ||
| result = imports.all(file, 'my-pkg'); | ||
| expect(result).toHaveLength(1); | ||
| expect(result[0].specifiers.get('foo')).toBe('f'); | ||
| }); | ||
| it('returns empty for non-matching package', () => { | ||
| let file = parse("import { foo } from 'other-pkg';"), | ||
| result = imports.all(file, 'my-pkg'); | ||
| expect(result).toHaveLength(0); | ||
| }); | ||
| it('returns empty when no imports', () => { | ||
| let file = parse('let x = 1;'), | ||
| result = imports.all(file, 'my-pkg'); | ||
| expect(result).toHaveLength(0); | ||
| }); | ||
| it('finds multiple import statements for same package', () => { | ||
| let file = parse("import { a } from 'pkg';\nimport { b } from 'pkg';"), | ||
| result = imports.all(file, 'pkg'); | ||
| expect(result).toHaveLength(2); | ||
| }); | ||
| it('tracks start and end positions', () => { | ||
| let file = parse("import { foo } from 'my-pkg';"), | ||
| result = imports.all(file, 'my-pkg'); | ||
| expect(result[0].start).toBe(0); | ||
| expect(result[0].end).toBeGreaterThan(0); | ||
| }); | ||
| it('handles default import (no named bindings)', () => { | ||
| let file = parse("import pkg from 'my-pkg';"), | ||
| result = imports.all(file, 'my-pkg'); | ||
| expect(result).toHaveLength(1); | ||
| expect(result[0].specifiers.size).toBe(0); | ||
| }); | ||
| it('handles namespace import', () => { | ||
| let file = parse("import * as pkg from 'my-pkg';"), | ||
| result = imports.all(file, 'my-pkg'); | ||
| expect(result).toHaveLength(1); | ||
| expect(result[0].specifiers.size).toBe(0); | ||
| }); | ||
| }); | ||
| describe('imports.includes', () => { | ||
| let mockChecker = { getSymbolAtLocation: () => null } as unknown as ts.TypeChecker; | ||
| function findIdentifier(file: ts.SourceFile, name: string): ts.Identifier | undefined { | ||
| let found: ts.Identifier | undefined; | ||
| ts.forEachChild(file, function visit(n) { | ||
| if (ts.isIdentifier(n) && n.text === name && !found) { | ||
| let parent = n.parent; | ||
| if (!ts.isImportSpecifier(parent) && !ts.isImportClause(parent) && !ts.isNamespaceImport(parent)) { | ||
| found = n; | ||
| } | ||
| } | ||
| ts.forEachChild(n, visit); | ||
| }); | ||
| return found; | ||
| } | ||
| it('direct named import matches', () => { | ||
| let file = parse("import { reactive } from 'my-pkg';\nreactive(x);"), | ||
| node = findIdentifier(file, 'reactive'); | ||
| expect(node).toBeDefined(); | ||
| expect(imports.includes(mockChecker, node!, 'my-pkg', 'reactive')).toBe(true); | ||
| }); | ||
| it('aliased import matches', () => { | ||
| let file = parse("import { foo as f } from 'my-pkg';\nf();"), | ||
| node = findIdentifier(file, 'f'); | ||
| expect(node).toBeDefined(); | ||
| expect(imports.includes(mockChecker, node!, 'my-pkg')).toBe(true); | ||
| }); | ||
| it('non-matching package returns false', () => { | ||
| let file = parse("import { foo } from 'other-pkg';\nfoo();"), | ||
| node = findIdentifier(file, 'foo'); | ||
| expect(node).toBeDefined(); | ||
| expect(imports.includes(mockChecker, node!, 'my-pkg')).toBe(false); | ||
| }); | ||
| it('non-matching symbol name returns false', () => { | ||
| let file = parse("import { foo } from 'my-pkg';\nfoo();"), | ||
| node = findIdentifier(file, 'foo'); | ||
| expect(node).toBeDefined(); | ||
| expect(imports.includes(mockChecker, node!, 'my-pkg', 'bar')).toBe(false); | ||
| }); | ||
| it('cache returns consistent results', () => { | ||
| let file = parse("import { reactive } from 'my-pkg';\nreactive(1);"), | ||
| node = findIdentifier(file, 'reactive'); | ||
| expect(node).toBeDefined(); | ||
| let first = imports.includes(mockChecker, node!, 'my-pkg', 'reactive'), | ||
| second = imports.includes(mockChecker, node!, 'my-pkg', 'reactive'); | ||
| expect(first).toBe(true); | ||
| expect(second).toBe(true); | ||
| expect(first).toBe(second); | ||
| }); | ||
| it('no imports at all returns false', () => { | ||
| let file = parse('let x = 1;\nx;'), | ||
| node = findIdentifier(file, 'x'); | ||
| expect(node).toBeDefined(); | ||
| expect(imports.includes(mockChecker, node!, 'my-pkg')).toBe(false); | ||
| }); | ||
| it('returns false when getAliasedSymbol throws', () => { | ||
| let file = parse("import { foo } from 'my-pkg';\nbar();"), | ||
| node = findIdentifier(file, 'bar'); | ||
| expect(node).toBeDefined(); | ||
| let checker = { | ||
| getSymbolAtLocation: () => ({ | ||
| getDeclarations: () => [] | ||
| }), | ||
| getAliasedSymbol: () => { throw new Error('not an alias'); } | ||
| } as unknown as ts.TypeChecker; | ||
| expect(imports.includes(checker, node!, 'my-pkg')).toBe(false); | ||
| }); | ||
| }); |
| import { describe, expect, it } from 'vitest'; | ||
| import languageService from '~/compiler/language-service'; | ||
| describe('language-service', () => { | ||
| describe('update', () => { | ||
| it('returns a Program when given valid root + fileName + content', () => { | ||
| let root = process.cwd().replace(/\\/g, '/'), | ||
| fileName = root + '/test-virtual-update.ts', | ||
| content = 'let x: number = 42;', | ||
| program = languageService.update(root, fileName, content); | ||
| expect(program).toBeDefined(); | ||
| expect(program.getTypeChecker).toBeDefined(); | ||
| }); | ||
| it('updated content is reflected in the program SourceFile', () => { | ||
| let root = process.cwd().replace(/\\/g, '/'), | ||
| fileName = root + '/test-virtual-reflect.ts', | ||
| content = 'let hello = "world";', | ||
| program = languageService.update(root, fileName, content), | ||
| sourceFile = program.getSourceFile(fileName); | ||
| expect(sourceFile).toBeDefined(); | ||
| expect(sourceFile!.text).toBe(content); | ||
| }); | ||
| it('increments version for updated files', () => { | ||
| let root = process.cwd().replace(/\\/g, '/'), | ||
| fileName = root + '/test-virtual-version.ts'; | ||
| languageService.update(root, fileName, 'let a = 1;'); | ||
| let program = languageService.update(root, fileName, 'let a = 2;'), | ||
| sourceFile = program.getSourceFile(fileName); | ||
| expect(sourceFile).toBeDefined(); | ||
| expect(sourceFile!.text).toBe('let a = 2;'); | ||
| }); | ||
| it('adds new files to rootFiles', () => { | ||
| let root = process.cwd().replace(/\\/g, '/'), | ||
| fileName = root + '/test-virtual-new-root.ts', | ||
| content = 'export const value = 1;', | ||
| program = languageService.update(root, fileName, content), | ||
| sourceFile = program.getSourceFile(fileName); | ||
| expect(sourceFile).toBeDefined(); | ||
| expect(sourceFile!.text).toBe(content); | ||
| }); | ||
| }); | ||
| describe('invalidate', () => { | ||
| it('removes content so next getProgram reads from disk', () => { | ||
| let root = process.cwd().replace(/\\/g, '/'), | ||
| fileName = root + '/test-virtual-invalidate.ts', | ||
| content = 'let val = 99;'; | ||
| languageService.update(root, fileName, content); | ||
| languageService.invalidate(root, fileName); | ||
| let program = languageService.update(root, fileName, 'let val = 100;'), | ||
| sourceFile = program.getSourceFile(fileName); | ||
| expect(sourceFile).toBeDefined(); | ||
| expect(sourceFile!.text).toBe('let val = 100;'); | ||
| }); | ||
| it('increments version for invalidated files', () => { | ||
| let root = process.cwd().replace(/\\/g, '/'), | ||
| fileName = root + '/test-virtual-inv-version.ts'; | ||
| languageService.update(root, fileName, 'let a = 1;'); | ||
| languageService.invalidate(root, fileName); | ||
| let program = languageService.update(root, fileName, 'let a = 3;'), | ||
| sourceFile = program.getSourceFile(fileName); | ||
| expect(sourceFile).toBeDefined(); | ||
| expect(sourceFile!.text).toBe('let a = 3;'); | ||
| }); | ||
| it('no-op when root does not exist in cache', () => { | ||
| expect(() => { | ||
| languageService.invalidate('/nonexistent/root', 'file.ts'); | ||
| }).not.toThrow(); | ||
| }); | ||
| }); | ||
| }); |
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import ts from 'typescript'; | ||
| import type { Plugin } from '~/compiler/types'; | ||
| import tsc from '~/compiler/plugins/tsc'; | ||
| import vite from '~/compiler/plugins/vite'; | ||
| vi.mock('~/compiler/language-service', () => ({ | ||
| default: { | ||
| invalidate: vi.fn(), | ||
| update: vi.fn((_root: string, fileName: string, content: string) => { | ||
| let file = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); | ||
| return { | ||
| getSourceFile: () => file, | ||
| getTypeChecker: () => ({} as ts.TypeChecker) | ||
| } as unknown as ts.Program; | ||
| }) | ||
| } | ||
| })); | ||
| vi.mock('~/compiler/coordinator', () => ({ | ||
| default: { | ||
| transform: vi.fn((_plugins: Plugin[], code: string, _file: ts.SourceFile, _prog: ts.Program, _root: string, _ctx: Map<string, unknown>) => ({ | ||
| changed: false, | ||
| code, | ||
| sourceFile: {} as ts.SourceFile | ||
| })) | ||
| } | ||
| })); | ||
| import coordinator from '~/compiler/coordinator'; | ||
| import languageService from '~/compiler/language-service'; | ||
| describe('plugin.tsc', () => { | ||
| it('returns a function that returns the plugins array', () => { | ||
| let p1: Plugin = { transform: () => ({}) }, | ||
| p2: Plugin = { transform: () => ({}) }, | ||
| factory = tsc([p1, p2]), | ||
| result = factory(); | ||
| expect(result).toEqual([p1, p2]); | ||
| }); | ||
| it('returns empty array for empty input', () => { | ||
| let result = tsc([])(); | ||
| expect(result).toEqual([]); | ||
| }); | ||
| }); | ||
| describe('plugin.vite', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
| it('creates VitePlugin with correct shape', () => { | ||
| let factory = vite({ name: 'test-pkg', plugins: [] }), | ||
| plugin = factory(); | ||
| expect(plugin).toHaveProperty('configResolved'); | ||
| expect(plugin).toHaveProperty('enforce'); | ||
| expect(plugin).toHaveProperty('name'); | ||
| expect(plugin).toHaveProperty('transform'); | ||
| expect(plugin).toHaveProperty('watchChange'); | ||
| }); | ||
| it('name includes package name', () => { | ||
| let plugin = vite({ name: 'test-pkg', plugins: [] })(); | ||
| expect(plugin.name).toBe('test-pkg/compiler/vite'); | ||
| }); | ||
| it('filters non-ts files', () => { | ||
| let plugin = vite({ name: 'test-pkg', plugins: [] })(); | ||
| expect(plugin.transform('code', 'file.css')).toBeNull(); | ||
| }); | ||
| it('filters node_modules', () => { | ||
| let plugin = vite({ name: 'test-pkg', plugins: [] })(); | ||
| expect(plugin.transform('code', 'node_modules/pkg/index.ts')).toBeNull(); | ||
| }); | ||
| it('processes .ts files — returns null when unchanged', () => { | ||
| let plugin = vite({ name: 'test-pkg', plugins: [] })(); | ||
| let result = plugin.transform('let x = 1;', 'src/app.ts'); | ||
| expect(result).toBeNull(); | ||
| expect(coordinator.transform).toHaveBeenCalled(); | ||
| }); | ||
| it('returns transformed code when changed', () => { | ||
| vi.mocked(coordinator.transform).mockReturnValueOnce({ | ||
| changed: true, | ||
| code: 'TRANSFORMED', | ||
| sourceFile: {} as ts.SourceFile | ||
| }); | ||
| let plugin = vite({ name: 'test-pkg', plugins: [] })(); | ||
| let result = plugin.transform('let x = 1;', 'src/app.ts'); | ||
| expect(result).toEqual({ code: 'TRANSFORMED', map: null }); | ||
| }); | ||
| it('watchChange calls onWatchChange and invalidate', () => { | ||
| let onWatchChange = vi.fn(), | ||
| plugin = vite({ name: 'test-pkg', onWatchChange, plugins: [] })(); | ||
| plugin.watchChange('src/app.ts'); | ||
| expect(onWatchChange).toHaveBeenCalled(); | ||
| expect(languageService.invalidate).toHaveBeenCalledWith('', 'src/app.ts'); | ||
| }); | ||
| it('watchChange ignores non-ts files', () => { | ||
| let onWatchChange = vi.fn(), | ||
| plugin = vite({ name: 'test-pkg', onWatchChange, plugins: [] })(); | ||
| plugin.watchChange('style.css'); | ||
| expect(onWatchChange).not.toHaveBeenCalled(); | ||
| }); | ||
| it('configResolved sets root', () => { | ||
| let plugin = vite({ name: 'test-pkg', plugins: [] })(); | ||
| plugin.configResolved({ root: '/my/root' }); | ||
| plugin.transform('let x = 1;', 'src/app.ts'); | ||
| expect(languageService.update).toHaveBeenCalledWith('/my/root', expect.any(String), expect.any(String)); | ||
| }); | ||
| it('catches coordinator.transform() error and returns null', () => { | ||
| vi.mocked(coordinator.transform).mockImplementationOnce(() => { | ||
| throw new Error('transform failed'); | ||
| }); | ||
| let consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}), | ||
| plugin = vite({ name: 'test-pkg', plugins: [] })(); | ||
| let result = plugin.transform('let x = 1;', 'src/app.ts'); | ||
| expect(result).toBeNull(); | ||
| expect(consoleSpy).toHaveBeenCalledWith( | ||
| expect.stringContaining('test-pkg'), | ||
| expect.any(Error) | ||
| ); | ||
| consoleSpy.mockRestore(); | ||
| }); | ||
| it('falls back to createSourceFile when getSourceFile returns undefined', () => { | ||
| vi.mocked(languageService.update).mockReturnValueOnce({ | ||
| getSourceFile: () => undefined, | ||
| getTypeChecker: () => ({} as ts.TypeChecker) | ||
| } as unknown as ts.Program); | ||
| let plugin = vite({ name: 'test-pkg', plugins: [] })(); | ||
| let result = plugin.transform('let x = 1;', 'src/app.ts'); | ||
| expect(result).toBeNull(); | ||
| expect(coordinator.transform).toHaveBeenCalled(); | ||
| }); | ||
| }); |
| import { describe, expect, it } from 'vitest'; | ||
| import uid from '~/compiler/uid'; | ||
| describe('uid', () => { | ||
| it('generates unique ids', () => { | ||
| let a = uid('test'), | ||
| b = uid('test'); | ||
| expect(a).not.toBe(b); | ||
| }); | ||
| it('prefixes with given name', () => { | ||
| let result = uid('myPrefix'); | ||
| expect(result.startsWith('myPrefix_')).toBe(true); | ||
| }); | ||
| it('contains only alphanumeric characters after prefix', () => { | ||
| let result = uid('x'), | ||
| suffix = result.slice(2); // after 'x_' | ||
| expect(suffix).toMatch(/^[A-Za-z0-9]+$/); | ||
| }); | ||
| // F-TEST-008: uid sequential guarantees | ||
| it('sequential calls produce different suffixes (5 calls)', () => { | ||
| let results = new Set<string>(); | ||
| for (let i = 0; i < 5; i++) { | ||
| results.add(uid('x')); | ||
| } | ||
| expect(results.size).toBe(5); | ||
| }); | ||
| it('different prefixes share same namespace', () => { | ||
| let a1 = uid('a'), | ||
| a2 = uid('a'), | ||
| b1 = uid('b'), | ||
| suffixA1 = a1.slice(2), // after 'a_' | ||
| suffixA2 = a2.slice(2), | ||
| suffixB1 = b1.slice(2); // after 'b_' | ||
| // Find common prefix between two 'a' calls — that's the namespace | ||
| let common = ''; | ||
| for (let i = 0, n = Math.min(suffixA1.length, suffixA2.length); i < n; i++) { | ||
| if (suffixA1[i] !== suffixA2[i]) { | ||
| break; | ||
| } | ||
| common += suffixA1[i]; | ||
| } | ||
| expect(common.length).toBeGreaterThan(0); | ||
| expect(suffixB1.startsWith(common)).toBe(true); | ||
| }); | ||
| it('suffix contains valid base-36 characters', () => { | ||
| let result = uid('z'), | ||
| parts = result.slice(2), // after 'z_' | ||
| base36Suffix = parts.match(/[0-9a-z]+$/); | ||
| expect(base36Suffix).not.toBeNull(); | ||
| expect(base36Suffix![0]).toMatch(/^[0-9a-z]+$/); | ||
| }); | ||
| }); |
| import { defineConfig } from 'vitest/config'; | ||
| import path from 'path'; | ||
| export default defineConfig({ | ||
| resolve: { | ||
| alias: { | ||
| '~': path.resolve(__dirname, 'src') | ||
| } | ||
| }, | ||
| test: { | ||
| include: ['tests/**/*.test.ts'] | ||
| } | ||
| }); |
@@ -43,2 +43,2 @@ name: publish to npm | ||
| - run: pnpm build | ||
| - run: pnpm publish --provenance --no-git-checks | ||
| - run: pnpm publish --no-git-checks |
+10
-1
@@ -1,1 +0,10 @@ | ||
| export {}; | ||
| import type { Plugin } from '../compiler/types.js'; | ||
| type PluginConfig = { | ||
| transform: string; | ||
| }; | ||
| declare function build(config: object, tsconfig: string, pluginConfigs: PluginConfig[]): Promise<void>; | ||
| declare function isPlugin(value: unknown): value is Plugin; | ||
| declare function loadPlugins(configs: PluginConfig[], root: string): Promise<Plugin[]>; | ||
| declare function normalizePath(fileName: string): string; | ||
| declare function runTscAlias(args: string[]): Promise<number>; | ||
| export { build, isPlugin, loadPlugins, normalizePath, runTscAlias }; |
+7
-4
@@ -1,8 +0,8 @@ | ||
| import { spawn } from 'child_process'; | ||
| import { createRequire } from 'module'; | ||
| import { PACKAGE_NAME } from '../constants.js'; | ||
| import { pathToFileURL } from 'url'; | ||
| import { spawn } from 'child_process'; | ||
| import coordinator from '../compiler/coordinator.js'; | ||
| import path from 'path'; | ||
| import ts from 'typescript'; | ||
| import coordinator from '../compiler/coordinator.js'; | ||
| import { PACKAGE_NAME } from '../constants.js'; | ||
| const BACKSLASH_REGEX = /\\/g; | ||
@@ -140,2 +140,5 @@ let require = createRequire(import.meta.url), skipFlags = new Set(['--help', '--init', '--noEmit', '--showConfig', '--version', '-h', '-noEmit', '-v']); | ||
| } | ||
| main(); | ||
| if (process.env.VITEST === undefined) { | ||
| main(); | ||
| } | ||
| export { build, isPlugin, loadPlugins, normalizePath, runTscAlias }; |
@@ -1,6 +0,3 @@ | ||
| import ts from 'typescript'; | ||
| type Range = { | ||
| end: number; | ||
| start: number; | ||
| }; | ||
| import type { Range } from './types.js'; | ||
| import { ts } from '../index.js'; | ||
| declare const _default: { | ||
@@ -7,0 +4,0 @@ expression: { |
@@ -1,2 +0,2 @@ | ||
| import ts from 'typescript'; | ||
| import { ts } from '../index.js'; | ||
| const expression = { | ||
@@ -3,0 +3,0 @@ name: (node) => { |
@@ -5,9 +5,27 @@ import { ts } from '../index.js'; | ||
| function applyImports(code, file, intents) { | ||
| let merged = new Map(); | ||
| for (let i = 0, n = intents.length; i < n; i++) { | ||
| let intent = intents[i]; | ||
| code = modify(code, file, intent.package, { | ||
| add: intent.add, | ||
| namespace: intent.namespace, | ||
| remove: intent.remove | ||
| }); | ||
| let intent = intents[i], existing = merged.get(intent.package); | ||
| if (existing) { | ||
| if (intent.add) { | ||
| (existing.add ??= []).push(...intent.add); | ||
| } | ||
| if (intent.namespace) { | ||
| existing.namespace = intent.namespace; | ||
| } | ||
| if (intent.remove) { | ||
| (existing.remove ??= []).push(...intent.remove); | ||
| } | ||
| } | ||
| else { | ||
| merged.set(intent.package, { | ||
| add: intent.add ? [...intent.add] : undefined, | ||
| namespace: intent.namespace, | ||
| remove: intent.remove ? [...intent.remove] : undefined | ||
| }); | ||
| } | ||
| } | ||
| let keys = [...merged.keys()]; | ||
| for (let i = 0, n = keys.length; i < n; i++) { | ||
| code = modify(code, file, keys[i], merged.get(keys[i])); | ||
| if (i < n - 1) { | ||
@@ -44,5 +62,5 @@ file = ts.createSourceFile(file.fileName, code, file.languageVersion, true); | ||
| if (position === 0) { | ||
| return prepend.join('\n') + code; | ||
| return prepend.join('\n') + '\n' + code; | ||
| } | ||
| return code.slice(0, position) + prepend.join('\n') + code.slice(position); | ||
| return code.slice(0, position) + '\n' + prepend.join('\n') + '\n' + code.slice(position); | ||
| } | ||
@@ -110,8 +128,15 @@ function hasPattern(code, patterns) { | ||
| replacements.sort((a, b) => b.start - a.start); | ||
| let result = code; | ||
| let parts = [], pos = code.length; | ||
| for (let i = 0, n = replacements.length; i < n; i++) { | ||
| let r = replacements[i]; | ||
| result = result.substring(0, r.start) + r.newText + result.substring(r.end); | ||
| if (r.end < pos) { | ||
| parts.push(code.substring(r.end, pos)); | ||
| } | ||
| parts.push(r.newText); | ||
| pos = r.start; | ||
| } | ||
| return result; | ||
| if (pos > 0) { | ||
| parts.push(code.substring(0, pos)); | ||
| } | ||
| return parts.reverse().join(''); | ||
| } | ||
@@ -140,2 +165,5 @@ const transform = (plugins, code, file, program, root, shared) => { | ||
| if (prepend?.length) { | ||
| if (pluginChanged) { | ||
| currentFile = ts.createSourceFile(fileName, currentCode, file.languageVersion, true); | ||
| } | ||
| currentCode = applyPrepend(currentCode, currentFile, prepend); | ||
@@ -145,2 +173,5 @@ pluginChanged = true; | ||
| if (imports?.length) { | ||
| if (pluginChanged) { | ||
| currentFile = ts.createSourceFile(fileName, currentCode, file.languageVersion, true); | ||
| } | ||
| currentCode = applyImports(currentCode, currentFile, imports); | ||
@@ -147,0 +178,0 @@ pluginChanged = true; |
| import { ts } from '../index.js'; | ||
| let cache = new WeakMap(); | ||
| function fileNameMatchesPackage(fileName, pkg) { | ||
| let normalized = fileName.replace(/\\/g, '/'), marker = `/node_modules/${pkg}/`; | ||
| return normalized.includes(marker); | ||
| } | ||
| const all = (file, pkg) => { | ||
@@ -63,3 +67,3 @@ let imports = []; | ||
| } | ||
| if (decl.getSourceFile().fileName.includes(pkg)) { | ||
| if (fileNameMatchesPackage(decl.getSourceFile().fileName, pkg)) { | ||
| return true; | ||
@@ -80,3 +84,3 @@ } | ||
| let decl = declarations[i]; | ||
| if (decl.getSourceFile().fileName.includes(pkg)) { | ||
| if (fileNameMatchesPackage(decl.getSourceFile().fileName, pkg)) { | ||
| return true; | ||
@@ -92,3 +96,3 @@ } | ||
| for (let i = 0, n = aliasedDecls.length; i < n; i++) { | ||
| if (aliasedDecls[i].getSourceFile().fileName.includes(pkg)) { | ||
| if (fileNameMatchesPackage(aliasedDecls[i].getSourceFile().fileName, pkg)) { | ||
| return true; | ||
@@ -95,0 +99,0 @@ } |
@@ -1,8 +0,5 @@ | ||
| import ts from 'typescript'; | ||
| declare const get: (root: string) => ts.Program; | ||
| import { ts } from '../index.js'; | ||
| declare const invalidate: (root: string, fileName: string) => void; | ||
| declare const update: (root: string, fileName: string, content: string) => ts.Program; | ||
| declare const _default: { | ||
| delete: (root: string) => void; | ||
| get: (root: string) => ts.Program; | ||
| invalidate: (root: string, fileName: string) => void; | ||
@@ -12,2 +9,2 @@ update: (root: string, fileName: string, content: string) => ts.Program; | ||
| export default _default; | ||
| export { get, invalidate, update }; | ||
| export { invalidate, update }; |
@@ -0,4 +1,4 @@ | ||
| import { PACKAGE_NAME } from '../constants.js'; | ||
| import { ts } from '../index.js'; | ||
| import path from 'path'; | ||
| import ts from 'typescript'; | ||
| import { PACKAGE_NAME } from '../constants.js'; | ||
| let cache = new Map(); | ||
@@ -51,12 +51,2 @@ function create(root) { | ||
| } | ||
| const del = (root) => { | ||
| cache.delete(root); | ||
| }; | ||
| const get = (root) => { | ||
| let entry = getEntry(root), program = entry.service.getProgram(); | ||
| if (!program) { | ||
| throw new Error(`${PACKAGE_NAME}: failed to get program from language service`); | ||
| } | ||
| return program; | ||
| }; | ||
| const invalidate = (root, fileName) => { | ||
@@ -83,3 +73,3 @@ let entry = cache.get(root); | ||
| }; | ||
| export default { delete: del, get, invalidate, update }; | ||
| export { get, invalidate, update }; | ||
| export default { invalidate, update }; | ||
| export { invalidate, update }; |
@@ -22,5 +22,10 @@ import { ts } from '../../index.js'; | ||
| if (!sourceFile) { | ||
| sourceFile = ts.createSourceFile(id, code, ts.ScriptTarget.Latest, true); | ||
| sourceFile = ts.createSourceFile(normalizedId, code, ts.ScriptTarget.Latest, true); | ||
| } | ||
| let result = coordinator.transform(plugins, code, sourceFile, prog, root || '', contexts.get(root || '') ?? contexts.set(root || '', new Map()).get(root || '')); | ||
| let key = root || '', ctx = contexts.get(key); | ||
| if (!ctx) { | ||
| ctx = new Map(); | ||
| contexts.set(key, ctx); | ||
| } | ||
| let result = coordinator.transform(plugins, code, sourceFile, prog, key, ctx); | ||
| if (!result.changed) { | ||
@@ -27,0 +32,0 @@ return null; |
+10
-7
@@ -10,10 +10,11 @@ { | ||
| "dependencies": { | ||
| "@esportsplus/cli-passthrough": "^0.0.12", | ||
| "@esportsplus/utilities": "^0.27.2", | ||
| "@types/node": "^25.0.3", | ||
| "@esportsplus/cli-passthrough": "^0.0.15", | ||
| "@esportsplus/utilities": "^0.27.3", | ||
| "@types/node": "^25.6.0", | ||
| "tsc-alias": "^1.8.16", | ||
| "typescript": "^5.9.3" | ||
| "typescript": "^6.0.2" | ||
| }, | ||
| "devDependencies": { | ||
| "vite": "^6.0.7" | ||
| "vite": "^8.0.8", | ||
| "vitest": "^4.1.4" | ||
| }, | ||
@@ -43,7 +44,9 @@ "exports": { | ||
| "types": "build/index.d.ts", | ||
| "version": "0.29.0", | ||
| "version": "0.29.1", | ||
| "scripts": { | ||
| "bench:run": "vitest bench --run", | ||
| "build": "tsc && tsc-alias", | ||
| "-": "-" | ||
| "-": "-", | ||
| "test": "vitest run" | ||
| } | ||
| } |
+11
-6
@@ -1,13 +0,13 @@ | ||
| import { spawn } from 'child_process'; | ||
| import type { Plugin, SharedContext } from '~/compiler/types'; | ||
| import { createRequire } from 'module'; | ||
| import { PACKAGE_NAME } from '~/constants'; | ||
| import { pathToFileURL } from 'url'; | ||
| import { spawn } from 'child_process'; | ||
| import coordinator from '~/compiler/coordinator'; | ||
| import path from 'path'; | ||
| import ts from 'typescript'; | ||
| import coordinator from '~/compiler/coordinator'; | ||
| import type { Plugin, SharedContext } from '~/compiler/types'; | ||
| import { PACKAGE_NAME } from '~/constants'; | ||
| type PluginConfig = { | ||
| after?: boolean; | ||
| transform: string; | ||
@@ -230,2 +230,7 @@ }; | ||
| main(); | ||
| if (process.env.VITEST === undefined) { | ||
| main(); | ||
| } | ||
| export { build, isPlugin, loadPlugins, normalizePath, runTscAlias }; |
@@ -1,10 +0,5 @@ | ||
| import ts from 'typescript'; | ||
| import type { Range } from './types'; | ||
| import { ts } from '~/index'; | ||
| type Range = { | ||
| end: number; | ||
| start: number; | ||
| }; | ||
| const expression = { | ||
@@ -11,0 +6,0 @@ name: (node: ts.Expression): string | null => { |
| import type { ImportIntent, Plugin, Replacement, ReplacementIntent, SharedContext } from './types'; | ||
| import type { ModifyOptions } from './imports'; | ||
| import { ts } from '~/index'; | ||
| import imports, { ModifyOptions } from './imports'; | ||
| import imports from './imports'; | ||
| import languageService from './language-service'; | ||
@@ -15,18 +17,35 @@ | ||
| function applyImports(code: string, file: ts.SourceFile, intents: ImportIntent[]): string { | ||
| let merged = new Map<string, { add?: string[]; namespace?: string; remove?: string[] }>(); | ||
| for (let i = 0, n = intents.length; i < n; i++) { | ||
| let intent = intents[i]; | ||
| let intent = intents[i], | ||
| existing = merged.get(intent.package); | ||
| code = modify(code, file, intent.package, { | ||
| add: intent.add, | ||
| namespace: intent.namespace, | ||
| remove: intent.remove | ||
| }); | ||
| if (existing) { | ||
| if (intent.add) { | ||
| (existing.add ??= []).push(...intent.add); | ||
| } | ||
| if (intent.namespace) { | ||
| existing.namespace = intent.namespace; | ||
| } | ||
| if (intent.remove) { | ||
| (existing.remove ??= []).push(...intent.remove); | ||
| } | ||
| } | ||
| else { | ||
| merged.set(intent.package, { | ||
| add: intent.add ? [...intent.add] : undefined, | ||
| namespace: intent.namespace, | ||
| remove: intent.remove ? [...intent.remove] : undefined | ||
| }); | ||
| } | ||
| } | ||
| let keys = [...merged.keys()]; | ||
| for (let i = 0, n = keys.length; i < n; i++) { | ||
| code = modify(code, file, keys[i], merged.get(keys[i])!); | ||
| if (i < n - 1) { | ||
| file = ts.createSourceFile( | ||
| file.fileName, | ||
| code, | ||
| file.languageVersion, | ||
| true | ||
| ); | ||
| file = ts.createSourceFile(file.fileName, code, file.languageVersion, true); | ||
| } | ||
@@ -72,6 +91,6 @@ } | ||
| if (position === 0) { | ||
| return prepend.join('\n') + code; | ||
| return prepend.join('\n') + '\n' + code; | ||
| } | ||
| return code.slice(0, position) + prepend.join('\n') + code.slice(position); | ||
| return code.slice(0, position) + '\n' + prepend.join('\n') + '\n' + code.slice(position); | ||
| } | ||
@@ -163,3 +182,4 @@ | ||
| let result = code; | ||
| let parts: string[] = [], | ||
| pos = code.length; | ||
@@ -169,6 +189,15 @@ for (let i = 0, n = replacements.length; i < n; i++) { | ||
| result = result.substring(0, r.start) + r.newText + result.substring(r.end); | ||
| if (r.end < pos) { | ||
| parts.push(code.substring(r.end, pos)); | ||
| } | ||
| parts.push(r.newText); | ||
| pos = r.start; | ||
| } | ||
| return result; | ||
| if (pos > 0) { | ||
| parts.push(code.substring(0, pos)); | ||
| } | ||
| return parts.reverse().join(''); | ||
| } | ||
@@ -218,2 +247,6 @@ | ||
| if (prepend?.length) { | ||
| if (pluginChanged) { | ||
| currentFile = ts.createSourceFile(fileName, currentCode, file.languageVersion, true); | ||
| } | ||
| currentCode = applyPrepend(currentCode, currentFile, prepend); | ||
@@ -224,2 +257,6 @@ pluginChanged = true; | ||
| if (imports?.length) { | ||
| if (pluginChanged) { | ||
| currentFile = ts.createSourceFile(fileName, currentCode, file.languageVersion, true); | ||
| } | ||
| currentCode = applyImports(currentCode, currentFile, imports); | ||
@@ -226,0 +263,0 @@ pluginChanged = true; |
@@ -20,2 +20,10 @@ import { ts } from '~/index'; | ||
| function fileNameMatchesPackage(fileName: string, pkg: string): boolean { | ||
| let normalized = fileName.replace(/\\/g, '/'), | ||
| marker = `/node_modules/${pkg}/`; | ||
| return normalized.includes(marker); | ||
| } | ||
| // Find all named imports from a specific package | ||
@@ -112,3 +120,3 @@ const all = (file: ts.SourceFile, pkg: string): ImportInfo[] => { | ||
| if (decl.getSourceFile().fileName.includes(pkg)) { | ||
| if (fileNameMatchesPackage(decl.getSourceFile().fileName, pkg)) { | ||
| return true; | ||
@@ -138,3 +146,3 @@ } | ||
| if (decl.getSourceFile().fileName.includes(pkg)) { | ||
| if (fileNameMatchesPackage(decl.getSourceFile().fileName, pkg)) { | ||
| return true; | ||
@@ -154,3 +162,3 @@ } | ||
| for (let i = 0, n = aliasedDecls.length; i < n; i++) { | ||
| if (aliasedDecls[i].getSourceFile().fileName.includes(pkg)) { | ||
| if (fileNameMatchesPackage(aliasedDecls[i].getSourceFile().fileName, pkg)) { | ||
| return true; | ||
@@ -157,0 +165,0 @@ } |
@@ -1,6 +0,7 @@ | ||
| import path from 'path'; | ||
| import ts from 'typescript'; | ||
| import { PACKAGE_NAME } from '~/constants'; | ||
| import { ts } from '~/index'; | ||
| import path from 'path'; | ||
| type LanguageServiceEntry = { | ||
@@ -87,17 +88,2 @@ contents: Map<string, string>; | ||
| const del = (root: string): void => { | ||
| cache.delete(root); | ||
| }; | ||
| const get = (root: string): ts.Program => { | ||
| let entry = getEntry(root), | ||
| program = entry.service.getProgram(); | ||
| if (!program) { | ||
| throw new Error(`${PACKAGE_NAME}: failed to get program from language service`); | ||
| } | ||
| return program; | ||
| }; | ||
| const invalidate = (root: string, fileName: string): void => { | ||
@@ -135,3 +121,3 @@ let entry = cache.get(root); | ||
| export default { delete: del, get, invalidate, update }; | ||
| export { get, invalidate, update }; | ||
| export default { invalidate, update }; | ||
| export { invalidate, update }; |
@@ -0,4 +1,5 @@ | ||
| import type { Plugin, SharedContext } from '../types'; | ||
| import type { ResolvedConfig } from 'vite'; | ||
| import type { Plugin, SharedContext } from '../types'; | ||
| import { ts } from '~/index'; | ||
| import coordinator from '../coordinator'; | ||
@@ -50,5 +51,13 @@ import languageService from '../language-service'; | ||
| if (!sourceFile) { | ||
| sourceFile = ts.createSourceFile(id, code, ts.ScriptTarget.Latest, true); | ||
| sourceFile = ts.createSourceFile(normalizedId, code, ts.ScriptTarget.Latest, true); | ||
| } | ||
| let key = root || '', | ||
| ctx = contexts.get(key); | ||
| if (!ctx) { | ||
| ctx = new Map(); | ||
| contexts.set(key, ctx); | ||
| } | ||
| let result = coordinator.transform( | ||
@@ -59,4 +68,4 @@ plugins, | ||
| prog, | ||
| root || '', | ||
| contexts.get(root || '') ?? contexts.set(root || '', new Map()).get(root || '')! | ||
| key, | ||
| ctx | ||
| ); | ||
@@ -63,0 +72,0 @@ |
@@ -6,3 +6,2 @@ { | ||
| "allowSyntheticDefaultImports": true, | ||
| "baseUrl": "${configDir}", | ||
| "declaration": false, | ||
@@ -9,0 +8,0 @@ "esModuleInterop": true, |
Debug access
Supply chain riskUses debug, reflection and dynamic code execution features.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
141411
120.39%70
18.64%3424
111.36%2
100%7
250%+ Added
- Removed
Updated
Updated