From 6cd56e8c4e4f780137f7839570253ce84cfdb89d Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Wed, 27 Aug 2025 15:40:24 -0700 Subject: [PATCH 1/7] Fix filename typo --- test-projects/gjs/src/{placeholer.gjs => placeholder.gjs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test-projects/gjs/src/{placeholer.gjs => placeholder.gjs} (100%) diff --git a/test-projects/gjs/src/placeholer.gjs b/test-projects/gjs/src/placeholder.gjs similarity index 100% rename from test-projects/gjs/src/placeholer.gjs rename to test-projects/gjs/src/placeholder.gjs From 4730d4fba217861986f35aba99a86ce141ec465c Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Wed, 27 Aug 2025 16:20:14 -0700 Subject: [PATCH 2/7] Handle types for gjs imports with explicit extension --- src/parser/ts-patch.js | 75 ++++- .../configs/flat-ts/src/api-consumer.ts | 21 ++ .../configs/flat-ts/src/api-service.gjs | 22 ++ .../flat-ts/src/arbitrary-ext.d.gjs.ts | 19 ++ .../configs/flat-ts/src/arbitrary-ext.gjs | 6 + .../configs/flat-ts/src/debug-import.ts | 13 + .../configs/flat-ts/src/debug-regular.ts | 16 + .../configs/flat-ts/src/debug-types.ts | 16 + .../configs/flat-ts/src/regular-types.d.ts | 25 ++ .../configs/flat-ts/src/simple-test.ts | 15 + .../configs/flat-ts/src/test-arbitrary.ts | 22 ++ .../configs/flat-ts/src/typed-consumer.ts | 38 +++ .../configs/flat-ts/src/typed-service.gjs | 51 +++ .../flat-ts/src/typed-service.gjs.d.ts | 27 ++ test-projects/configs/flat-ts/tsconfig.json | 7 + tests/fixtures/api-client.gjs | 15 + tests/fixtures/api-client.gjs.d.ts | 15 + tests/fixtures/imports-from-gjs.ts | 18 ++ tests/fixtures/imports-with-dts.ts | 36 +++ tests/fixtures/types-export.gjs | 17 + tests/parser.test.js | 306 ++++++++++++++++++ 21 files changed, 770 insertions(+), 10 deletions(-) create mode 100644 test-projects/configs/flat-ts/src/api-consumer.ts create mode 100644 test-projects/configs/flat-ts/src/api-service.gjs create mode 100644 test-projects/configs/flat-ts/src/arbitrary-ext.d.gjs.ts create mode 100644 test-projects/configs/flat-ts/src/arbitrary-ext.gjs create mode 100644 test-projects/configs/flat-ts/src/debug-import.ts create mode 100644 test-projects/configs/flat-ts/src/debug-regular.ts create mode 100644 test-projects/configs/flat-ts/src/debug-types.ts create mode 100644 test-projects/configs/flat-ts/src/regular-types.d.ts create mode 100644 test-projects/configs/flat-ts/src/simple-test.ts create mode 100644 test-projects/configs/flat-ts/src/test-arbitrary.ts create mode 100644 test-projects/configs/flat-ts/src/typed-consumer.ts create mode 100644 test-projects/configs/flat-ts/src/typed-service.gjs create mode 100644 test-projects/configs/flat-ts/src/typed-service.gjs.d.ts create mode 100644 tests/fixtures/api-client.gjs create mode 100644 tests/fixtures/api-client.gjs.d.ts create mode 100644 tests/fixtures/imports-from-gjs.ts create mode 100644 tests/fixtures/imports-with-dts.ts create mode 100644 tests/fixtures/types-export.gjs diff --git a/src/parser/ts-patch.js b/src/parser/ts-patch.js index efc6fbb..205aaf7 100644 --- a/src/parser/ts-patch.js +++ b/src/parser/ts-patch.js @@ -25,12 +25,37 @@ try { const gjsVirtuals = allowGjs ? results.filter((x) => x.endsWith('.gjs')).map((f) => f.replace(/\.gjs$/, '.mjs')) : []; - return results.concat(gtsVirtuals, gjsVirtuals); + // Map .gjs.d.ts to both .mjs.d.ts AND .d.mts patterns + // Also handle .d.gjs.ts (allowArbitraryExtensions pattern for .gjs files) + const gjsDtsVirtuals = allowGjs + ? results + .filter((x) => x.endsWith('.gjs.d.ts')) + .flatMap((f) => [ + f.replace(/\.gjs\.d\.ts$/, '.mjs.d.ts'), + f.replace(/\.gjs\.d\.ts$/, '.d.mts'), + ]) + : []; + // Handle .d.gjs.ts pattern (allowArbitraryExtensions for .gjs files only) + const dGjsVirtuals = allowGjs + ? results + .filter((x) => x.endsWith('.d.gjs.ts')) + .map((f) => f.replace(/\.d\.gjs\.ts$/, '.d.mts')) + : []; + return results.concat(gtsVirtuals, gjsVirtuals, gjsDtsVirtuals, dGjsVirtuals); }, fileExists(fileName) { const gtsExists = fs.existsSync(fileName.replace(/\.m?ts$/, '.gts')); const gjsExists = allowGjs ? fs.existsSync(fileName.replace(/\.m?js$/, '.gjs')) : false; - return gtsExists || gjsExists || fs.existsSync(fileName); + // Check for .gjs.d.ts files with multiple patterns + const gjsDtsExists = allowGjs + ? fs.existsSync(fileName.replace(/\.mjs\.d\.ts$/, '.gjs.d.ts')) || + fs.existsSync(fileName.replace(/\.d\.mts$/, '.gjs.d.ts')) + : false; + // Check for .d.gjs.ts pattern (allowArbitraryExtensions for .gjs files only) + const dGjsExists = allowGjs + ? fs.existsSync(fileName.replace(/\.d\.mts$/, '.d.gjs.ts')) + : false; + return gtsExists || gjsExists || gjsDtsExists || dGjsExists || fs.existsSync(fileName); }, readFile(fname) { let fileName = fname; @@ -42,14 +67,28 @@ try { try { content = fs.readFileSync(fileName).toString(); } catch { - if (fileName.match(/\.m?ts$/)) { + // Handle declaration files first (more specific patterns) + if (fileName.endsWith('.d.mts')) { + // .d.mts files could map to .gjs declaration patterns + if (allowGjs && fs.existsSync(fileName.replace(/\.d\.mts$/, '.d.gjs.ts'))) { + fileName = fileName.replace(/\.d\.mts$/, '.d.gjs.ts'); + } else if (allowGjs && fs.existsSync(fileName.replace(/\.d\.mts$/, '.gjs.d.ts'))) { + fileName = fileName.replace(/\.d\.mts$/, '.gjs.d.ts'); + } + } else if (allowGjs && fileName.endsWith('.mjs.d.ts')) { + fileName = fileName.replace(/\.mjs\.d\.ts$/, '.gjs.d.ts'); + } else if (fileName.match(/\.m?ts$/) && !fileName.endsWith('.d.ts')) { fileName = fileName.replace(/\.m?ts$/, '.gts'); - } else if (allowGjs && fileName.match(/\.m?js$/)) { + } else if (allowGjs && fileName.match(/\.m?js$/) && !fileName.endsWith('.d.ts')) { fileName = fileName.replace(/\.m?js$/, '.gjs'); } content = fs.readFileSync(fileName).toString(); } - if (fileName.endsWith('.gts') || (allowGjs && fileName.endsWith('.gjs'))) { + // Only transform template files, not declaration files + if ( + (fileName.endsWith('.gts') && !fileName.endsWith('.d.ts')) || + (allowGjs && fileName.endsWith('.gjs') && !fileName.endsWith('.d.ts')) + ) { try { content = transformForLint(content).output; } catch (e) { @@ -57,10 +96,11 @@ try { console.error(e); } } + // Only replace extensions in non-declaration files if ( (!fileName.endsWith('.d.ts') && fileName.endsWith('.ts')) || - fileName.endsWith('.gts') || - (allowGjs && fileName.endsWith('.gjs')) + (fileName.endsWith('.gts') && !fileName.endsWith('.d.ts')) || + (allowGjs && fileName.endsWith('.gjs') && !fileName.endsWith('.d.ts')) ) { try { content = replaceExtensions(content); @@ -81,9 +121,24 @@ try { const sourceFile = ts.createSourceFile('__x__.ts', code, ts.ScriptTarget.Latest); const length = jsCode.length; for (const b of sourceFile.statements) { - if (b.kind === ts.SyntaxKind.ImportDeclaration && b.moduleSpecifier.text.endsWith('.gts')) { - const value = b.moduleSpecifier.text.replace(/\.gts$/, '.mts'); - jsCode = replaceRange(jsCode, b.moduleSpecifier.pos + 2, b.moduleSpecifier.end - 1, value); + if (b.kind === ts.SyntaxKind.ImportDeclaration) { + if (b.moduleSpecifier.text.endsWith('.gts')) { + const value = b.moduleSpecifier.text.replace(/\.gts$/, '.mts'); + jsCode = replaceRange( + jsCode, + b.moduleSpecifier.pos + 2, + b.moduleSpecifier.end - 1, + value + ); + } else if (allowGjs && b.moduleSpecifier.text.endsWith('.gjs')) { + const value = b.moduleSpecifier.text.replace(/\.gjs$/, '.mjs'); + jsCode = replaceRange( + jsCode, + b.moduleSpecifier.pos + 2, + b.moduleSpecifier.end - 1, + value + ); + } } } if (length !== jsCode.length) { diff --git a/test-projects/configs/flat-ts/src/api-consumer.ts b/test-projects/configs/flat-ts/src/api-consumer.ts new file mode 100644 index 0000000..76c8f6e --- /dev/null +++ b/test-projects/configs/flat-ts/src/api-consumer.ts @@ -0,0 +1,21 @@ +import type { ApiResponse } from './api-service.gjs'; +import { DataService, ApiComponent } from './api-service.gjs'; + +// Test that we can use the imported type +async function processApiData(): Promise { + const service = new DataService(); + return await service.fetchData(); +} + +// Test that we can reference the imported component +const component = ApiComponent; + +// This should trigger a type error if types aren't resolved +// because we're trying to assign a number to a string property +const badApiResponse: ApiResponse = { + status: 200, + data: { message: 'test' }, + message: 123 // This should be a string, not a number +}; + +export { processApiData, component, badApiResponse }; diff --git a/test-projects/configs/flat-ts/src/api-service.gjs b/test-projects/configs/flat-ts/src/api-service.gjs new file mode 100644 index 0000000..c263b36 --- /dev/null +++ b/test-projects/configs/flat-ts/src/api-service.gjs @@ -0,0 +1,22 @@ +export interface ApiResponse { + status: number; + data: unknown; + message: string; +} + +export class DataService { + apiUrl = 'https://api.example.com'; + + async fetchData(): Promise { + const response = await fetch(this.apiUrl); + return { + status: response.status, + data: await response.json(), + message: response.statusText + }; + } +} + +export const ApiComponent = ; diff --git a/test-projects/configs/flat-ts/src/arbitrary-ext.d.gjs.ts b/test-projects/configs/flat-ts/src/arbitrary-ext.d.gjs.ts new file mode 100644 index 0000000..069a421 --- /dev/null +++ b/test-projects/configs/flat-ts/src/arbitrary-ext.d.gjs.ts @@ -0,0 +1,19 @@ +// Type declarations for arbitrary extension pattern (.d.gjs.ts) +export interface ArbitraryConfig { + id: string; + enabled: boolean; +} + +export type ArbitraryResponse = + | { + success: true; + result: T; + } + | { + success: false; + error: string; + }; + +export declare class ArbitraryService { + process(data: T): ArbitraryResponse; +} diff --git a/test-projects/configs/flat-ts/src/arbitrary-ext.gjs b/test-projects/configs/flat-ts/src/arbitrary-ext.gjs new file mode 100644 index 0000000..3014992 --- /dev/null +++ b/test-projects/configs/flat-ts/src/arbitrary-ext.gjs @@ -0,0 +1,6 @@ +// Dummy .gjs file for arbitrary-ext +export class ArbitraryService { + process(data) { + return { success: true, result: data }; + } +} diff --git a/test-projects/configs/flat-ts/src/debug-import.ts b/test-projects/configs/flat-ts/src/debug-import.ts new file mode 100644 index 0000000..c53fcb7 --- /dev/null +++ b/test-projects/configs/flat-ts/src/debug-import.ts @@ -0,0 +1,13 @@ +import type { ServiceResponse } from './typed-service.gjs'; + +// Test what TypeScript actually thinks the type is +function debugType(): void { + const response: ServiceResponse = {} as any; + + // Let's see what properties TypeScript thinks are available + // @ts-expect-error - intentional error to see what TS thinks + const keys = Object.keys(response); + console.log(keys); +} + +export { debugType }; diff --git a/test-projects/configs/flat-ts/src/debug-regular.ts b/test-projects/configs/flat-ts/src/debug-regular.ts new file mode 100644 index 0000000..c6d9404 --- /dev/null +++ b/test-projects/configs/flat-ts/src/debug-regular.ts @@ -0,0 +1,16 @@ +import type { ServiceResponse } from './regular-types'; + +// Simple test to see if we can use the discriminated union from regular .d.ts +function testRegularDiscriminatedUnion(): void { + const response: ServiceResponse = { + success: true, + data: "test" + }; + + if (response.success) { + // This should be fine - TypeScript should narrow the type + console.log(response.data); + } +} + +export { testRegularDiscriminatedUnion }; diff --git a/test-projects/configs/flat-ts/src/debug-types.ts b/test-projects/configs/flat-ts/src/debug-types.ts new file mode 100644 index 0000000..77ac5f6 --- /dev/null +++ b/test-projects/configs/flat-ts/src/debug-types.ts @@ -0,0 +1,16 @@ +import type { ServiceResponse } from './typed-service.gjs'; + +// Simple test to see if we can use the discriminated union +function testDiscriminatedUnion(): void { + const response: ServiceResponse = { + success: true, + data: "test" + }; + + if (response.success) { + // This should be fine - TypeScript should narrow the type + console.log(response.data); + } +} + +export { testDiscriminatedUnion }; diff --git a/test-projects/configs/flat-ts/src/regular-types.d.ts b/test-projects/configs/flat-ts/src/regular-types.d.ts new file mode 100644 index 0000000..29bdd31 --- /dev/null +++ b/test-projects/configs/flat-ts/src/regular-types.d.ts @@ -0,0 +1,25 @@ +// Test with regular .d.ts file +export interface ServiceConfig { + apiKey: string; + baseUrl: string; + timeout?: number; +} + +export type ServiceResponse = + | { + success: true; + data: T; + error?: undefined; + } + | { + success: false; + data?: undefined; + error: string; + }; + +export declare class TypedService { + constructor(config: ServiceConfig); + config: ServiceConfig; + request(path: string): Promise>; + isConnected(): boolean; +} diff --git a/test-projects/configs/flat-ts/src/simple-test.ts b/test-projects/configs/flat-ts/src/simple-test.ts new file mode 100644 index 0000000..ed64b23 --- /dev/null +++ b/test-projects/configs/flat-ts/src/simple-test.ts @@ -0,0 +1,15 @@ +import { TypedService } from './typed-service.gjs'; + +// Very simple test - if this compiles without 'any' errors, +// then TypeScript is finding the .gjs.d.ts file +const service = new TypedService({ + apiKey: 'test', + baseUrl: 'https://api.example.com' +}); + +// This should be typed as boolean +const connected: boolean = service.isConnected(); + +console.log('Service connected:', connected); + +export { service }; diff --git a/test-projects/configs/flat-ts/src/test-arbitrary.ts b/test-projects/configs/flat-ts/src/test-arbitrary.ts new file mode 100644 index 0000000..2f4e57b --- /dev/null +++ b/test-projects/configs/flat-ts/src/test-arbitrary.ts @@ -0,0 +1,22 @@ +import type { ArbitraryConfig, ArbitraryResponse } from './arbitrary-ext.gjs'; +import { ArbitraryService } from './arbitrary-ext.gjs'; + +// Test that TypeScript can resolve types from .d.gjs.ts files +function testArbitraryExtensions(): void { + const config: ArbitraryConfig = { + id: 'test', + enabled: true + }; + + const service = new ArbitraryService(); + const response: ArbitraryResponse = service.process('hello'); + + if (response.success) { + // TypeScript should know that response.result is a string when success is true + console.log(response.result.toUpperCase()); + } else { + console.error(response.error); + } +} + +export { testArbitraryExtensions }; diff --git a/test-projects/configs/flat-ts/src/typed-consumer.ts b/test-projects/configs/flat-ts/src/typed-consumer.ts new file mode 100644 index 0000000..aa7f987 --- /dev/null +++ b/test-projects/configs/flat-ts/src/typed-consumer.ts @@ -0,0 +1,38 @@ +import type { ServiceConfig, ServiceResponse } from './typed-service.gjs'; +import { TypedService, ServiceComponent } from './typed-service.gjs'; + +// Test that we can use the imported types from the .gjs.d.ts file +const config: ServiceConfig = { + apiKey: 'test-key', + baseUrl: 'https://api.example.com', + timeout: 3000 +}; + +async function testTypedService(): Promise { + const service = new TypedService(config); + + // Test that the service is properly typed + const isConnected: boolean = service.isConnected(); + console.log(`Service connected: ${isConnected}`); + + // Test that the generic response typing works + interface User { + id: number; + name: string; + email: string; + } + + const userResponse: ServiceResponse = await service.request('/user/1'); + + if (userResponse.success && userResponse.data) { + // TypeScript should know that userResponse.data is of type User when success is true + console.log(`User: ${userResponse.data.name} (${userResponse.data.email})`); + } else if (userResponse.error) { + console.error(`Error: ${userResponse.error}`); + } +} + +// Test that we can reference the component +const component = ServiceComponent; + +export { testTypedService, component, config }; diff --git a/test-projects/configs/flat-ts/src/typed-service.gjs b/test-projects/configs/flat-ts/src/typed-service.gjs new file mode 100644 index 0000000..f5f0322 --- /dev/null +++ b/test-projects/configs/flat-ts/src/typed-service.gjs @@ -0,0 +1,51 @@ +// Runtime implementation for typed-service.gjs +export class TypedService { + constructor(config) { + this.config = config; + } + + async request(path) { + try { + const response = await fetch(`${this.config.baseUrl}${path}`, { + headers: { + 'Authorization': `Bearer ${this.config.apiKey}`, + }, + timeout: this.config.timeout || 5000, + }); + + if (!response.ok) { + return { + success: false, + data: null, + error: `HTTP ${response.status}: ${response.statusText}` + }; + } + + const data = await response.json(); + return { + success: true, + data, + }; + } catch (error) { + return { + success: false, + data: null, + error: error.message + }; + } + } + + isConnected() { + return Boolean(this.config.apiKey && this.config.baseUrl); + } +} + +export const ServiceComponent = ; diff --git a/test-projects/configs/flat-ts/src/typed-service.gjs.d.ts b/test-projects/configs/flat-ts/src/typed-service.gjs.d.ts new file mode 100644 index 0000000..38f78a0 --- /dev/null +++ b/test-projects/configs/flat-ts/src/typed-service.gjs.d.ts @@ -0,0 +1,27 @@ +// Type declarations for typed-service.gjs +export interface ServiceConfig { + apiKey: string; + baseUrl: string; + timeout?: number; +} + +export type ServiceResponse = + | { + success: true; + data: T; + error?: undefined; + } + | { + success: false; + data?: undefined; + error: string; + }; + +export declare class TypedService { + constructor(config: ServiceConfig); + config: ServiceConfig; + request(path: string): Promise>; + isConnected(): boolean; +} + +export declare const ServiceComponent: unknown; diff --git a/test-projects/configs/flat-ts/tsconfig.json b/test-projects/configs/flat-ts/tsconfig.json index 48ae5f1..a4ff7bf 100644 --- a/test-projects/configs/flat-ts/tsconfig.json +++ b/test-projects/configs/flat-ts/tsconfig.json @@ -44,6 +44,13 @@ */ "allowImportingTsExtensions": true, + /** + https://www.typescriptlang.org/tsconfig#allowArbitraryExtensions + + Allow importing files with arbitrary extensions like .d.gjs.ts + */ + "allowArbitraryExtensions": true, + "types": ["ember-source/types"] } } diff --git a/tests/fixtures/api-client.gjs b/tests/fixtures/api-client.gjs new file mode 100644 index 0000000..5669286 --- /dev/null +++ b/tests/fixtures/api-client.gjs @@ -0,0 +1,15 @@ +// This is a .gjs file with runtime implementation +export class ApiClient { + constructor(baseUrl) { + this.baseUrl = baseUrl; + } + + async get(path) { + const response = await fetch(`${this.baseUrl}${path}`); + return response.json(); + } +} + +export const DefaultClient = ; diff --git a/tests/fixtures/api-client.gjs.d.ts b/tests/fixtures/api-client.gjs.d.ts new file mode 100644 index 0000000..7309a8b --- /dev/null +++ b/tests/fixtures/api-client.gjs.d.ts @@ -0,0 +1,15 @@ +// Type declarations for api-client.gjs +export interface ApiResponse { + data: T; + status: number; + message?: string; +} + +export declare class ApiClient { + constructor(baseUrl: string); + baseUrl: string; + get(path: string): Promise>; +} + +// Component type - this would typically be inferred from the template +export declare const DefaultClient: unknown; diff --git a/tests/fixtures/imports-from-gjs.ts b/tests/fixtures/imports-from-gjs.ts new file mode 100644 index 0000000..3ffccb6 --- /dev/null +++ b/tests/fixtures/imports-from-gjs.ts @@ -0,0 +1,18 @@ +import type { UserData } from './types-export.gjs'; +import { UserService, ExampleComponent } from './types-export.gjs'; + +// Test that we can use the imported type +const userData: UserData = { + id: 1, + name: 'John Doe', + email: 'john@example.com' +}; + +// Test that we can use the imported class +const userService = new UserService(); +userService.addUser(userData); + +// Test that we can reference the imported component +const component = ExampleComponent; + +export { userData, userService, component }; diff --git a/tests/fixtures/imports-with-dts.ts b/tests/fixtures/imports-with-dts.ts new file mode 100644 index 0000000..02a12f7 --- /dev/null +++ b/tests/fixtures/imports-with-dts.ts @@ -0,0 +1,36 @@ +import type { ApiResponse } from './api-client.gjs'; +import { ApiClient, DefaultClient } from './api-client.gjs'; + +// Test that we can use the imported types from the .gjs.d.ts file +async function testApiClient(): Promise { + const client = new ApiClient('https://api.example.com'); + + // This should be properly typed as ApiResponse + const response = await client.get('/users'); + + // Test that the response has the correct structure from the .d.ts file + console.log(`Status: ${response.status}`); + console.log(`Data:`, response.data); + if (response.message) { + console.log(`Message: ${response.message}`); + } +} + +// Test that we can use generics from the .d.ts file +async function testTypedApiClient(): Promise { + const client = new ApiClient('https://api.example.com'); + + // This should be properly typed as ApiResponse<{ id: number; name: string }> + const userResponse = await client.get<{ id: number; name: string }>('/user/1'); + + // TypeScript should know that userResponse.data has id and name properties + const userId: number = userResponse.data.id; + const userName: string = userResponse.data.name; + + console.log(`User: ${userName} (ID: ${userId})`); +} + +// Test that we can reference the component +const component = DefaultClient; + +export { testApiClient, testTypedApiClient, component }; diff --git a/tests/fixtures/types-export.gjs b/tests/fixtures/types-export.gjs new file mode 100644 index 0000000..7350261 --- /dev/null +++ b/tests/fixtures/types-export.gjs @@ -0,0 +1,17 @@ +export interface UserData { + id: number; + name: string; + email: string; +} + +export class UserService { + users = []; + + addUser(user) { + this.users.push(user); + } +} + +export const ExampleComponent = ; diff --git a/tests/parser.test.js b/tests/parser.test.js index 97b30ae..981d45a 100644 --- a/tests/parser.test.js +++ b/tests/parser.test.js @@ -2755,4 +2755,310 @@ export const NotFound =