diff --git a/.changeset/native-ts71-api.md b/.changeset/native-ts71-api.md new file mode 100644 index 00000000..2e4963e3 --- /dev/null +++ b/.changeset/native-ts71-api.md @@ -0,0 +1,5 @@ +--- +'@0no-co/graphqlsp': minor +--- + +Add an experimental TypeScript 7.1 native API entry for batch GraphQL document diagnostics while preserving the existing tsserver plugin entry. diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b5221dc6..dfe6e08a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -49,3 +49,6 @@ jobs: - name: Test run: pnpm run test:e2e + + - name: Test TypeScript 7.1 native API + run: pnpm --filter graphqlsp-native-test test diff --git a/.gitignore b/.gitignore index 2b55b9e2..7f0fb64f 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,5 @@ dist packages/graphqlsp/api/* packages/graphqlsp/api +packages/graphqlsp/native/* +packages/graphqlsp/native diff --git a/package.json b/package.json index 54635868..a811030c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "prepare": "husky", "dev": "pnpm --filter @0no-co/graphqlsp dev", "launch-debug": "./scripts/launch-debug.sh", + "test:native": "pnpm build && pnpm --filter graphqlsp-native-test test", "test:e2e": "vitest run --no-file-parallelism" }, "prettier": { diff --git a/packages/graphqlsp/README.md b/packages/graphqlsp/README.md index bf73052b..37821ecc 100644 --- a/packages/graphqlsp/README.md +++ b/packages/graphqlsp/README.md @@ -51,6 +51,60 @@ when on a TypeScript file or adding a file like [this](https://github.com/0no-co > } > ``` +## Experimental TypeScript 7.1 native API + +TypeScript 7.1 replaces the legacy in-process compiler API with immutable +native snapshots. GraphQLSP exposes an experimental **batch diagnostics** +adapter at `@0no-co/graphqlsp/native` for +`typescript@7.1.0-dev.20260815.1` and later 7.1 builds with the same unstable +API shape: + +```ts +import { createNativeGraphQLSP } from '@0no-co/graphqlsp/native'; +import path from 'node:path'; +import { buildSchema } from 'graphql'; +import * as ast from 'typescript/unstable/ast'; +import * as sync from 'typescript/unstable/sync'; + +const api = new sync.API({ cwd: process.cwd() }); +const configFile = path.resolve('tsconfig.json'); +const snapshot = api.updateSnapshot({ openProjects: [configFile] }); + +try { + const project = snapshot.getProject(configFile); + if (!project) throw new Error('Project was not loaded'); + + const graphqlsp = createNativeGraphQLSP({ sync, ast }); + const diagnostics = graphqlsp.getDiagnostics( + project, + '/absolute/path/to/source.ts', + buildSchema('type Query { hello: String! }') + ); +} finally { + snapshot.dispose(); + api.close(); +} +``` + +The module namespaces are supplied by the caller deliberately: the legacy +plugin continues to use its workspace TypeScript version, while this adapter +uses the exact native TypeScript instance that created the snapshot. + +This lane runs GraphQLSP's existing document discovery, static template +interpolation, GraphQL validation, dynamic-interpolation warning, diagnostic +codes, and source-offset mapping. It disables `trackFieldUsage` and +co-located-fragment analysis because those editor/project-wide features still +depend on legacy language-service APIs that the native snapshot API does not +expose. + +This is **not an editor plugin replacement yet**. The unstable 7.1 API can read +an LSP-owned snapshot through `API.fromLSPConnection(...)`, but it currently +has no public equivalent of `ts.server.PluginCreateInfo` for injecting custom +diagnostics, hovers, definitions, refactors, and code actions into TypeScript's +editor responses. Keep using the package's existing default entry with +TypeScript 5/6 for those features. A sidecar LSP can use this native batch lane +and publish its returned diagnostics independently. + ### Configuration **Required** diff --git a/packages/graphqlsp/package.json b/packages/graphqlsp/package.json index c7c974fb..87bb5406 100644 --- a/packages/graphqlsp/package.json +++ b/packages/graphqlsp/package.json @@ -18,6 +18,12 @@ "require": "./dist/api.js", "source": "./src/api.ts" }, + "./native": { + "types": "./dist/native.d.ts", + "import": "./dist/native.mjs", + "require": "./dist/native.js", + "source": "./src/native.ts" + }, "./package.json": "./package.json" }, "scripts": { @@ -55,7 +61,7 @@ }, "peerDependencies": { "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", - "typescript": "^5.0.0 || ^6.0.0" + "typescript": "^5.0.0 || ^6.0.0 || >=7.1.0-dev.20260815.1 <7.2.0" }, "publishConfig": { "provenance": true diff --git a/packages/graphqlsp/src/ast/checks.ts b/packages/graphqlsp/src/ast/checks.ts index ad993cd5..47167a29 100644 --- a/packages/graphqlsp/src/ast/checks.ts +++ b/packages/graphqlsp/src/ast/checks.ts @@ -31,6 +31,40 @@ const schemaNameCache = new WeakMap< WeakMap >(); +// TypeScript 7.1's native API exposes the same information through explicit +// predicates/accessors rather than the legacy TypeScript JS helpers. Keeping +// these tiny compatibility probes here lets document discovery remain shared +// by both hosts without changing the existing tsserver path. +const getUnionOrIntersectionTypes = ( + type: ts.Type +): readonly ts.Type[] | null => { + if ('isUnionOrIntersection' in type && type.isUnionOrIntersection()) { + return type.types; + } + + const nativeType = type as ts.Type & { + isUnionType?: () => boolean; + isIntersectionType?: () => boolean; + getTypes?: () => readonly ts.Type[]; + }; + return (nativeType.isUnionType?.() || nativeType.isIntersectionType?.()) && + nativeType.getTypes + ? nativeType.getTypes() + : null; +}; + +const getStringLiteralValue = (type: ts.Type): string | null => { + if ('isStringLiteral' in type && type.isStringLiteral()) return type.value; + const nativeType = type as ts.Type & { + isStringLiteralType?: () => boolean; + value?: unknown; + }; + return nativeType.isStringLiteralType?.() && + typeof nativeType.value === 'string' + ? nativeType.value + : null; +}; + const getCached = ( caches: WeakMap>, checker: ts.TypeChecker, @@ -167,11 +201,14 @@ export const getSchemaName = ( const brandTypeSymbol = type.getProperty('__name'); if (brandTypeSymbol) { const brand = typeChecker.getTypeOfSymbol(brandTypeSymbol); - if (brand.isUnionOrIntersection()) { - const found = brand.types.find(x => x.isStringLiteral()); - return found && found.isStringLiteral() ? found.value : null; - } else if (brand.isStringLiteral()) { - return brand.value; + const types = getUnionOrIntersectionTypes(brand); + if (types) { + for (const member of types) { + const value = getStringLiteralValue(member); + if (value !== null) return value; + } + } else { + return getStringLiteralValue(brand); } } } diff --git a/packages/graphqlsp/src/diagnostics.ts b/packages/graphqlsp/src/diagnostics.ts index 1fe48293..1ccc638a 100644 --- a/packages/graphqlsp/src/diagnostics.ts +++ b/packages/graphqlsp/src/diagnostics.ts @@ -808,10 +808,18 @@ const runDiagnostics = ( ); startChar -= addedCharacters; endChar -= addedCharacters; + const diagnosticStart = startChar + 1; + let diagnosticEnd = endChar + 1; + while ( + diagnosticEnd > diagnosticStart && + /\s/.test(source.text[diagnosticEnd - 1] || '') + ) { + diagnosticEnd--; + } return { ...x, - start: startChar + 1, - length: endChar - startChar, + start: diagnosticStart, + length: diagnosticEnd - diagnosticStart, }; } }) diff --git a/packages/graphqlsp/src/native.ts b/packages/graphqlsp/src/native.ts new file mode 100644 index 00000000..a7eb4820 --- /dev/null +++ b/packages/graphqlsp/src/native.ts @@ -0,0 +1,148 @@ +import type { GraphQLSchema } from 'graphql'; + +import { getGraphQLDiagnostics } from './diagnostics'; +import { init, reset, ts as activeTypeScript } from './ts'; + +export interface NativeGraphQLSPProject { + readonly program: { + getSourceFile(fileName: string): unknown; + }; + readonly checker: unknown; +} + +export interface NativeGraphQLSPConfig { + clientDirectives?: string[]; + templateIsCallExpression?: boolean; +} + +export interface NativeGraphQLSPDiagnostic { + category: number; + code: number; + file: unknown; + messageText: string; + start: number; + length: number; +} + +export interface NativeTypeScriptModules { + /** The `typescript/unstable/sync` module namespace. */ + sync: Record; + /** The `typescript/unstable/ast` module namespace. */ + ast: Record; +} + +let nextNativeDiagnosticVersion = 1; +const nativeDiagnosticVersions = new WeakMap< + object, + WeakMap> +>(); + +const getNativeDiagnosticVersion = ( + project: NativeGraphQLSPProject, + schema: GraphQLSchema, + config: NativeGraphQLSPConfig +): number => { + const configKey = JSON.stringify({ + clientDirectives: config.clientDirectives || [], + templateIsCallExpression: config.templateIsCallExpression ?? true, + }); + let projectVersions = nativeDiagnosticVersions.get(project); + if (!projectVersions) { + nativeDiagnosticVersions.set(project, (projectVersions = new WeakMap())); + } + let versions = projectVersions.get(schema); + if (!versions) projectVersions.set(schema, (versions = new Map())); + let version = versions.get(configKey); + if (!version) + versions.set(configKey, (version = nextNativeDiagnosticVersion++)); + return version; +}; + +/** + * Runs GraphQLSP's real document discovery, template resolution, GraphQL + * validation, and source mapping against a TypeScript 7.1 native snapshot. + * + * This is a batch/native API lane. It does not decorate the native language + * service or publish diagnostics to an editor; TypeScript 7.1 does not expose + * a plugin feature-injection contract equivalent to PluginCreateInfo yet. + */ +export function createNativeGraphQLSP(modules: NativeTypeScriptModules) { + const nativeTypeScript = { + ...modules.ast, + ...modules.sync, + isStringLiteralLike(node: unknown) { + const ast = modules.ast as { + isStringLiteral(node: unknown): boolean; + isNoSubstitutionTemplateLiteral(node: unknown): boolean; + }; + return ( + ast.isStringLiteral(node) || ast.isNoSubstitutionTemplateLiteral(node) + ); + }, + forEachChild( + node: { forEachChild(visit: (node: unknown) => T): T }, + visit: (node: unknown) => T + ) { + return node.forEachChild(visit); + }, + }; + + const getDiagnostics = ( + project: NativeGraphQLSPProject, + fileName: string, + schema: GraphQLSchema, + config: NativeGraphQLSPConfig = {} + ): NativeGraphQLSPDiagnostic[] => { + const previousTypeScript = activeTypeScript; + init({ typescript: nativeTypeScript } as never); + + try { + const program = { + getSourceFile: (name: string) => project.program.getSourceFile(name), + getTypeChecker: () => project.checker, + }; + const info = { + config: { + ...config, + // These project-wide features still depend on legacy language + // service methods that the native snapshot API doesn't expose. + shouldCheckForColocatedFragments: false, + trackFieldUsage: false, + }, + languageService: { + getProgram: () => program, + }, + }; + const schemaRef = { + current: { schema }, + multi: {}, + // GraphQLSP's diagnostics cache keys on SchemaRef.version. Native + // callers pass schema objects directly, so assign stable versions per + // project/schema/config tuple to prevent results leaking across native + // snapshots or invocations. + version: getNativeDiagnosticVersion(project, schema, config), + errors: { config: null, load: new Map(), write: new Map() }, + outputLocations: new Map(), + sourceLocations: new Map(), + turboLocations: new Map(), + checkStale() {}, + }; + + return (getGraphQLDiagnostics( + fileName, + schemaRef as never, + info as never + ) || []) as NativeGraphQLSPDiagnostic[]; + } finally { + // The legacy plugin and public core API share a live TypeScript binding. + // Restore it so invoking this adapter cannot permanently alter that path. + if (previousTypeScript) { + init({ typescript: previousTypeScript }); + } else { + reset(); + } + } + }; + + return { getDiagnostics }; +} diff --git a/packages/graphqlsp/src/ts/index.d.ts b/packages/graphqlsp/src/ts/index.d.ts index 6c1a563a..0f51b9d0 100644 --- a/packages/graphqlsp/src/ts/index.d.ts +++ b/packages/graphqlsp/src/ts/index.d.ts @@ -1,3 +1,4 @@ import typescript from 'typescript/lib/tsserverlibrary'; export declare function init(modules: { typescript: typeof typescript }): void; +export declare function reset(): void; export { typescript as ts }; diff --git a/packages/graphqlsp/src/ts/index.js b/packages/graphqlsp/src/ts/index.js index 41349350..8c1103dc 100644 --- a/packages/graphqlsp/src/ts/index.js +++ b/packages/graphqlsp/src/ts/index.js @@ -2,3 +2,7 @@ export var ts; export function init(modules) { ts = modules.typescript; } + +export function reset() { + ts = undefined; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7e45492..e010ad87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - typescript: ^5.3.3 + typescript@<7: ^5.3.3 ua-parser-js@<0.7.33: '>=0.7.33' postcss@<8.4.31: '>=8.4.31' semver@<5.7.2: '>=5.7.2' @@ -346,6 +346,21 @@ importers: specifier: ^5.3.3 version: 5.3.3 + test/native: + dependencies: + '@0no-co/graphqlsp': + specifier: workspace:* + version: link:../../packages/graphqlsp + gql.tada: + specifier: 1.11.3 + version: 1.11.3(graphql@16.8.1)(typescript@7.1.0-dev.20260821.1) + graphql: + specifier: 16.8.1 + version: 16.8.1 + typescript: + specifier: 7.1.0-dev.20260821.1 + version: 7.1.0-dev.20260821.1 + packages: '@0no-co/graphql.web@1.2.0': @@ -644,7 +659,20 @@ packages: '@gql.tada/cli-utils@1.9.0': resolution: {integrity: sha512-sTlFYC4dFxbJtLwmA43qzt/fo5SHJ4PWFmj45ILNuslZ/0E3Wpb436ll63G5EBFG+Dx/SWMe2S+epVw9rchQVA==} - version: 1.9.0 + peerDependencies: + '@0no-co/graphqlsp': ^1.16.0 + '@gql.tada/svelte-support': 1.0.3 + '@gql.tada/vue-support': 1.0.3 + graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 + typescript: ^5.3.3 + peerDependenciesMeta: + '@gql.tada/svelte-support': + optional: true + '@gql.tada/vue-support': + optional: true + + '@gql.tada/cli-utils@1.9.3': + resolution: {integrity: sha512-P1TiXErpJwIi73sei5fzwGA/SOeCaIHFWFR4RdZPLwqxZzQN0T6MAUivzXBgKCORL67rvYnLaaPoWeqWq/61ug==} peerDependencies: '@0no-co/graphqlsp': ^1.16.0 '@gql.tada/svelte-support': 1.0.3 @@ -669,6 +697,12 @@ packages: graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 typescript: ^5.3.3 + '@gql.tada/internal@1.2.2': + resolution: {integrity: sha512-4lZcElPP6MC8Ct8KN70LR2WQsHjbAsyAmTGG09VsOPhx738UFIYaL0S1XiI2pqq2tj/sDs/y3b9jvKoWGL7iuQ==} + peerDependencies: + graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 + typescript: ^5.3.3 + '@graphql-codegen/add@5.0.3': resolution: {integrity: sha512-SxXPmramkth8XtBlAHu4H4jYcYXM/o3p01+psU+0NADQowA8jtYkK6MW5rV6T+CxkEaNZItfSmZRPgIuypcqnA==} peerDependencies: @@ -1390,6 +1424,48 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript/typescript-darwin-arm64@7.1.0-dev.20260821.1': + resolution: {integrity: sha512-kSoefqC9cAFf1Ui14Gn3XmMDRn2SSEy/V4D9/N/dfhJM4/yfoadBiyeaC88o7LmyJEJQTS4zQjTGRu1q29bbhA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.1.0-dev.20260821.1': + resolution: {integrity: sha512-RJ+NOn3OZ+EYGQE7wiPDcWQkKY5OjOMBk/bhGIfmxKWVJhWYW2Me+4YmacXq+NenzZgOUVhHikFfibMQDvhOtw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-linux-arm64@7.1.0-dev.20260821.1': + resolution: {integrity: sha512-o3xxprsmQ0AKHMUNMgU+QRG+XpRMfswzXUOu+a4ayrQ5AnctYB7IGfdMqOSr3Oc9e/bsOwVY37aZdPcsNAfprg==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.1.0-dev.20260821.1': + resolution: {integrity: sha512-75ryeQNJLg6vyM67JxGBxP6s6dTccg/lvMSQFJARdgAd+WPilxmfzLzobSwlBHZuc5u5gxh9y42kZTpsn0Yq5Q==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-x64@7.1.0-dev.20260821.1': + resolution: {integrity: sha512-m2NY6lwHyjLsTOp0f6GrjvS/b1cIRxQbFxO/z7XtnsO17Gu0GTC9XhXiCuiPhyeipeERWK+SOigmEhY1S3QCcg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-win32-arm64@7.1.0-dev.20260821.1': + resolution: {integrity: sha512-610sBfx98SyaXpRpPJ5B3gsceM8QqhqQaHDdNoG7HsTlbc/F9KEq5slJ2ZbgL2FJ6cc2UimsIbwu6XfLKkBaug==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.1.0-dev.20260821.1': + resolution: {integrity: sha512-gTVCj7ecihltJj8ljlNxyR6/8H5ZEnsQSALdmysyufbyqagKkRcI9iCEa1p9CK/FY3g4wX9GT8a0V3Qr1vZUEQ==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + '@urql/core@3.0.0': resolution: {integrity: sha512-C9nVn3+hhht8K0MEr9zLUgzoapkoX7X0crwMB72LrMqerhBjvEEuTngEFlZfALRwOzHjUNXBT9ZVgyzt6cKRhA==} peerDependencies: @@ -1926,6 +2002,12 @@ packages: peerDependencies: typescript: ^5.3.3 + gql.tada@1.11.3: + resolution: {integrity: sha512-5JCI4j2f0nug8ILaCQys/yjOP78QqqjVUf47OQsME63rZfemsHT3e5vcfbHsnZiG6vxyqpKUJArtXEVwSefUhw==} + hasBin: true + peerDependencies: + typescript: ^5.3.3 + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -2875,6 +2957,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@7.1.0-dev.20260821.1: + resolution: {integrity: sha512-fSbYbC5XWI68TLeO+W0ZEZAJMSuVYlyREG3Z1+zw1vn1Ka6T8rgihYEhm3jNa0Z7PiEyD3NsR1Ux9z2t1viU9g==} + engines: {node: '>=16.20.0'} + hasBin: true + ua-parser-js@1.0.41: resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} hasBin: true @@ -3136,6 +3223,12 @@ snapshots: graphql: 16.8.1 typescript: 5.3.3 + '@0no-co/graphqlsp@1.17.3(graphql@16.8.1)(typescript@7.1.0-dev.20260821.1)': + dependencies: + '@gql.tada/internal': 1.2.0(graphql@16.8.1)(typescript@7.1.0-dev.20260821.1) + graphql: 16.8.1 + typescript: 7.1.0-dev.20260821.1 + '@0no-co/graphqlsp@file:packages/graphqlsp(graphql@16.8.1)(typescript@5.3.3)': dependencies: '@gql.tada/internal': 1.2.1(graphql@16.8.1)(typescript@5.3.3) @@ -3581,19 +3674,32 @@ snapshots: '@fastify/busboy@3.2.0': {} - '@gql.tada/cli-utils@1.9.0(@0no-co/graphqlsp@file:packages/graphqlsp(graphql@16.8.1)(typescript@5.3.3))(graphql@16.8.1)(typescript@5.3.3)': + '@gql.tada/cli-utils@1.9.0(@0no-co/graphqlsp@1.17.3(graphql@16.8.1)(typescript@5.3.3))(graphql@16.8.1)(typescript@5.3.3)': dependencies: - '@0no-co/graphqlsp': file:packages/graphqlsp(graphql@16.8.1)(typescript@5.3.3) + '@0no-co/graphqlsp': 1.17.3(graphql@16.8.1)(typescript@5.3.3) '@gql.tada/internal': 1.2.0(graphql@16.8.1)(typescript@5.3.3) graphql: 16.8.1 typescript: 5.3.3 + '@gql.tada/cli-utils@1.9.3(@0no-co/graphqlsp@1.17.3(graphql@16.8.1)(typescript@7.1.0-dev.20260821.1))(graphql@16.8.1)(typescript@7.1.0-dev.20260821.1)': + dependencies: + '@0no-co/graphqlsp': 1.17.3(graphql@16.8.1)(typescript@7.1.0-dev.20260821.1) + '@gql.tada/internal': 1.2.2(graphql@16.8.1)(typescript@7.1.0-dev.20260821.1) + graphql: 16.8.1 + typescript: 7.1.0-dev.20260821.1 + '@gql.tada/internal@1.2.0(graphql@16.8.1)(typescript@5.3.3)': dependencies: '@0no-co/graphql.web': 1.3.2(graphql@16.8.1) graphql: 16.8.1 typescript: 5.3.3 + '@gql.tada/internal@1.2.0(graphql@16.8.1)(typescript@7.1.0-dev.20260821.1)': + dependencies: + '@0no-co/graphql.web': 1.3.2(graphql@16.8.1) + graphql: 16.8.1 + typescript: 7.1.0-dev.20260821.1 + '@gql.tada/internal@1.2.1(graphql@16.8.1)(typescript@5.3.3)': dependencies: '@0no-co/graphql.web': 1.3.2(graphql@16.8.1) @@ -3606,6 +3712,12 @@ snapshots: graphql: 16.8.1 typescript: 5.9.3 + '@gql.tada/internal@1.2.2(graphql@16.8.1)(typescript@7.1.0-dev.20260821.1)': + dependencies: + '@0no-co/graphql.web': 1.3.2(graphql@16.8.1) + graphql: 16.8.1 + typescript: 7.1.0-dev.20260821.1 + '@graphql-codegen/add@5.0.3(graphql@16.8.1)': dependencies: '@graphql-codegen/plugin-helpers': 5.1.1(graphql@16.8.1) @@ -4482,6 +4594,27 @@ snapshots: dependencies: '@types/node': 18.19.130 + '@typescript/typescript-darwin-arm64@7.1.0-dev.20260821.1': + optional: true + + '@typescript/typescript-darwin-x64@7.1.0-dev.20260821.1': + optional: true + + '@typescript/typescript-linux-arm64@7.1.0-dev.20260821.1': + optional: true + + '@typescript/typescript-linux-arm@7.1.0-dev.20260821.1': + optional: true + + '@typescript/typescript-linux-x64@7.1.0-dev.20260821.1': + optional: true + + '@typescript/typescript-win32-arm64@7.1.0-dev.20260821.1': + optional: true + + '@typescript/typescript-win32-x64@7.1.0-dev.20260821.1': + optional: true + '@urql/core@3.0.0(graphql@16.8.1)': dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@16.8.1) @@ -5017,7 +5150,7 @@ snapshots: dependencies: '@0no-co/graphql.web': 1.3.2(graphql@16.8.1) '@0no-co/graphqlsp': 1.17.3(graphql@16.8.1)(typescript@5.3.3) - '@gql.tada/cli-utils': 1.9.0(@0no-co/graphqlsp@file:packages/graphqlsp(graphql@16.8.1)(typescript@5.3.3))(graphql@16.8.1)(typescript@5.3.3) + '@gql.tada/cli-utils': 1.9.0(@0no-co/graphqlsp@1.17.3(graphql@16.8.1)(typescript@5.3.3))(graphql@16.8.1)(typescript@5.3.3) '@gql.tada/internal': 1.2.0(graphql@16.8.1)(typescript@5.3.3) typescript: 5.3.3 transitivePeerDependencies: @@ -5025,6 +5158,18 @@ snapshots: - '@gql.tada/vue-support' - graphql + gql.tada@1.11.3(graphql@16.8.1)(typescript@7.1.0-dev.20260821.1): + dependencies: + '@0no-co/graphql.web': 1.3.2(graphql@16.8.1) + '@0no-co/graphqlsp': 1.17.3(graphql@16.8.1)(typescript@7.1.0-dev.20260821.1) + '@gql.tada/cli-utils': 1.9.3(@0no-co/graphqlsp@1.17.3(graphql@16.8.1)(typescript@7.1.0-dev.20260821.1))(graphql@16.8.1)(typescript@7.1.0-dev.20260821.1) + '@gql.tada/internal': 1.2.2(graphql@16.8.1)(typescript@7.1.0-dev.20260821.1) + typescript: 7.1.0-dev.20260821.1 + transitivePeerDependencies: + - '@gql.tada/svelte-support' + - '@gql.tada/vue-support' + - graphql + graceful-fs@4.2.11: {} graphql-config@5.1.5(@types/node@22.20.0)(graphql@16.8.1)(typescript@5.3.3): @@ -5928,6 +6073,16 @@ snapshots: typescript@5.9.3: {} + typescript@7.1.0-dev.20260821.1: + optionalDependencies: + '@typescript/typescript-darwin-arm64': 7.1.0-dev.20260821.1 + '@typescript/typescript-darwin-x64': 7.1.0-dev.20260821.1 + '@typescript/typescript-linux-arm': 7.1.0-dev.20260821.1 + '@typescript/typescript-linux-arm64': 7.1.0-dev.20260821.1 + '@typescript/typescript-linux-x64': 7.1.0-dev.20260821.1 + '@typescript/typescript-win32-arm64': 7.1.0-dev.20260821.1 + '@typescript/typescript-win32-x64': 7.1.0-dev.20260821.1 + ua-parser-js@1.0.41: {} unc-path-regex@0.1.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 03d44dc9..6391b231 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,17 +1,30 @@ packages: - 'packages/*' - 'test/e2e/**' + - 'test/native' allowBuilds: esbuild: true +peerDependencyRules: + allowedVersions: + typescript: '7.1.0-dev.20260821.1' + minimumReleaseAgeExclude: - '@0no-co/*' - '@gql.tada/*' - 'gql.tada' + - '@typescript/typescript-darwin-arm64@7.1.0-dev.20260821.1' + - '@typescript/typescript-darwin-x64@7.1.0-dev.20260821.1' + - '@typescript/typescript-linux-arm64@7.1.0-dev.20260821.1' + - '@typescript/typescript-linux-arm@7.1.0-dev.20260821.1' + - '@typescript/typescript-linux-x64@7.1.0-dev.20260821.1' + - '@typescript/typescript-win32-arm64@7.1.0-dev.20260821.1' + - '@typescript/typescript-win32-x64@7.1.0-dev.20260821.1' + - typescript@7.1.0-dev.20260821.1 overrides: - typescript: ^5.3.3 + 'typescript@<7': ^5.3.3 ua-parser-js@<0.7.33: '>=0.7.33' postcss@<8.4.31: '>=8.4.31' semver@<5.7.2: '>=5.7.2' diff --git a/test/e2e/client-preset.test.ts b/test/e2e/client-preset.test.ts index 781e843d..7e67b375 100644 --- a/test/e2e/client-preset.test.ts +++ b/test/e2e/client-preset.test.ts @@ -133,8 +133,8 @@ describe('Fragment + operations', () => { "category": "warning", "code": 52004, "end": { - "line": 10, - "offset": 1, + "line": 9, + "offset": 21, }, "start": { "line": 9, diff --git a/test/e2e/combinations.test.ts b/test/e2e/combinations.test.ts index 66dcd203..cd57faab 100644 --- a/test/e2e/combinations.test.ts +++ b/test/e2e/combinations.test.ts @@ -79,8 +79,8 @@ describe('Fragment + operations', () => { "category": "error", "code": 52001, "end": { - "line": 7, - "offset": 1, + "line": 6, + "offset": 21, }, "start": { "line": 6, @@ -105,8 +105,8 @@ describe('Fragment + operations', () => { "category": "error", "code": 52001, "end": { - "line": 17, - "offset": 1, + "line": 16, + "offset": 16, }, "start": { "line": 16, diff --git a/test/e2e/multi-schema-tada.test.ts b/test/e2e/multi-schema-tada.test.ts index 8dab39fb..27574b91 100644 --- a/test/e2e/multi-schema-tada.test.ts +++ b/test/e2e/multi-schema-tada.test.ts @@ -106,8 +106,8 @@ describe('Multiple schemas', () => { "category": "warning", "code": 52004, "end": { - "line": 12, - "offset": 1, + "line": 11, + "offset": 21, }, "start": { "line": 11, diff --git a/test/e2e/tada.test.ts b/test/e2e/tada.test.ts index 26f891e1..7685d553 100644 --- a/test/e2e/tada.test.ts +++ b/test/e2e/tada.test.ts @@ -157,8 +157,8 @@ describe('Fragment + operations', () => { "category": "warning", "code": 52004, "end": { - "line": 12, - "offset": 1, + "line": 11, + "offset": 21, }, "start": { "line": 11, diff --git a/test/native/fixture/graphql.ts b/test/native/fixture/graphql.ts new file mode 100644 index 00000000..f4762ad7 --- /dev/null +++ b/test/native/fixture/graphql.ts @@ -0,0 +1,4 @@ +import { initGraphQLTada } from 'gql.tada'; +import type { Schema } from './schema.js'; + +export const graphql = initGraphQLTada<{ introspection: Schema }>(); diff --git a/test/native/fixture/index.ts b/test/native/fixture/index.ts new file mode 100644 index 00000000..eb40674f --- /dev/null +++ b/test/native/fixture/index.ts @@ -0,0 +1,35 @@ +import { graphql } from './graphql.js'; +import type { ResultOf } from 'gql.tada'; + +const staticFields = 'id text' as const; + +export const Todos = graphql(` + query Todos { + todos { + ${staticFields} + } + } +`); + +export type TodosResult = ResultOf; +export const validResult: TodosResult = { + todos: [{ id: '1', text: 'native' }], +}; + +export const Invalid = graphql(` + query Invalid { + todos { + ${staticFields} + unknownField + } + } +`); + +declare const dynamicFields: string; +export const Dynamic = graphql(` + query Dynamic { + todos { + ${dynamicFields} + } + } +`); diff --git a/test/native/fixture/schema.ts b/test/native/fixture/schema.ts new file mode 100644 index 00000000..d1d5fee7 --- /dev/null +++ b/test/native/fixture/schema.ts @@ -0,0 +1,74 @@ +export type Schema = { + __schema: { + queryType: { name: 'Query' }; + mutationType: null; + subscriptionType: null; + types: [ + { + kind: 'OBJECT'; + name: 'Query'; + fields: [ + { + name: 'todos'; + args: []; + type: { + kind: 'LIST'; + name: null; + ofType: { kind: 'OBJECT'; name: 'Todo'; ofType: null }; + }; + }, + ]; + inputFields: null; + interfaces: []; + enumValues: null; + possibleTypes: null; + }, + { + kind: 'OBJECT'; + name: 'Todo'; + fields: [ + { + name: 'id'; + args: []; + type: { + kind: 'NON_NULL'; + name: null; + ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null }; + }; + }, + { + name: 'text'; + args: []; + type: { + kind: 'NON_NULL'; + name: null; + ofType: { kind: 'SCALAR'; name: 'String'; ofType: null }; + }; + }, + ]; + inputFields: null; + interfaces: []; + enumValues: null; + possibleTypes: null; + }, + { + kind: 'SCALAR'; + name: 'ID'; + fields: null; + inputFields: null; + interfaces: null; + enumValues: null; + possibleTypes: null; + }, + { + kind: 'SCALAR'; + name: 'String'; + fields: null; + inputFields: null; + interfaces: null; + enumValues: null; + possibleTypes: null; + }, + ]; + }; +}; diff --git a/test/native/native-check.mjs b/test/native/native-check.mjs new file mode 100644 index 00000000..3f34d7d7 --- /dev/null +++ b/test/native/native-check.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { createNativeGraphQLSP } from '@0no-co/graphqlsp/native'; +import { buildSchema } from 'graphql'; +import * as ast from 'typescript/unstable/ast'; +import * as sync from 'typescript/unstable/sync'; + +const { API, DiagnosticCategory } = sync; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const configFile = path.join(root, 'tsconfig.json'); +const sourceFileName = path.join(root, 'fixture/index.ts'); +const schema = buildSchema(` + type Query { todos: [Todo] } + type Todo { id: ID!, text: String! } +`); + +test('real GraphQLSP diagnostics run on a TypeScript 7.1 native snapshot', () => { + const api = new API({ cwd: root, collectTiming: true }); + let snapshot; + try { + snapshot = api.updateSnapshot({ openProjects: [configFile] }); + const project = snapshot.getProject(configFile); + assert.ok(project, 'TypeScript 7.1 should load the fixture project'); + + const source = project.program.getSourceFile(sourceFileName); + assert.ok(source, 'TypeScript 7.1 should expose the fixture source'); + + const resultDeclaration = source.statements + .flatMap(statement => statement.declarationList?.declarations || []) + .find(declaration => declaration.name?.text === 'validResult'); + assert.ok(resultDeclaration); + assert.equal( + project.checker.typeToString( + project.checker.getTypeAtLocation(resultDeclaration.name) + ), + '{ todos: ({ id: string; text: string; } | null)[] | null; }' + ); + assert.deepEqual( + project.program.getSemanticDiagnostics(sourceFileName), + [] + ); + + const nativeGraphQLSP = createNativeGraphQLSP({ sync, ast }); + const diagnostics = nativeGraphQLSP.getDiagnostics( + project, + sourceFileName, + schema + ); + assert.deepEqual( + diagnostics.map(diagnostic => ({ + category: diagnostic.category, + code: diagnostic.code, + })), + [ + { category: DiagnosticCategory.Warning, code: 52009 }, + { category: DiagnosticCategory.Error, code: 52001 }, + ] + ); + + const [dynamicDiagnostic, unknownFieldDiagnostic] = diagnostics; + assert.equal( + source.text.slice( + dynamicDiagnostic.start, + dynamicDiagnostic.start + dynamicDiagnostic.length + ), + '`\n query Dynamic {\n todos {\n ${dynamicFields}\n }\n }\n`' + ); + // GraphQLSP's core maps the validation error start past the expanded + // static interpolation and onto the exact TypeScript token. + assert.equal( + source.text.slice( + unknownFieldDiagnostic.start, + unknownFieldDiagnostic.start + unknownFieldDiagnostic.length + ), + 'unknownField' + ); + assert.equal(unknownFieldDiagnostic.length, 'unknownField'.length); + assert.equal(unknownFieldDiagnostic.file, source); + + // A second schema must not reuse diagnostics cached for the first one. + const permissiveSchema = buildSchema(` + type Query { todos: [Todo] } + type Todo { id: ID!, text: String!, unknownField: String } + `); + const permissiveDiagnostics = nativeGraphQLSP.getDiagnostics( + project, + sourceFileName, + permissiveSchema + ); + assert.deepEqual( + permissiveDiagnostics.map(diagnostic => diagnostic.code), + [52009] + ); + + // An equivalent replacement snapshot must return diagnostics referencing + // its own remote SourceFile rather than a cached object from the prior one. + api.runWithTemporaryFileUpdate( + snapshot, + sourceFileName, + source.text, + replacementSnapshot => { + const replacementProject = replacementSnapshot.getProject(configFile); + assert.ok(replacementProject); + const replacementSource = + replacementProject.program.getSourceFile(sourceFileName); + assert.ok(replacementSource); + const replacementDiagnostics = nativeGraphQLSP.getDiagnostics( + replacementProject, + sourceFileName, + schema + ); + assert.equal(replacementDiagnostics[1].file, replacementSource); + } + ); + + const timing = api.getTimingInfo().totals; + assert.ok(timing.nodesMaterialized > 0); + assert.ok(timing.sourceFilesFetched > 0); + } finally { + snapshot?.dispose(); + api.close(); + } +}); diff --git a/test/native/package.json b/test/native/package.json new file mode 100644 index 00000000..84d0bce8 --- /dev/null +++ b/test/native/package.json @@ -0,0 +1,14 @@ +{ + "name": "graphqlsp-native-test", + "private": true, + "type": "module", + "scripts": { + "test": "node --test native-check.mjs" + }, + "dependencies": { + "@0no-co/graphqlsp": "workspace:*", + "gql.tada": "1.11.3", + "graphql": "16.8.1", + "typescript": "7.1.0-dev.20260821.1" + } +} diff --git a/test/native/tsconfig.json b/test/native/tsconfig.json new file mode 100644 index 00000000..37b28a1b --- /dev/null +++ b/test/native/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "strict": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "target": "esnext", + "skipLibCheck": true, + "noEmit": true + }, + "include": ["./fixture/*.ts"] +} diff --git a/test/unit/findAllCallExpressions.test.ts b/test/unit/findAllCallExpressions.test.ts index 7dc0d96e..7a12b5d8 100644 --- a/test/unit/findAllCallExpressions.test.ts +++ b/test/unit/findAllCallExpressions.test.ts @@ -154,9 +154,10 @@ describe('findAllCallExpressions', () => { expect( source.text.slice( diagnostic!.start!, - diagnostic!.start! + 'unknownField'.length + diagnostic!.start! + diagnostic!.length! ) ).toBe('unknownField'); + expect(diagnostic!.length).toBe('unknownField'.length); }); it('maps diagnostics inside static interpolation back to the expression', () => {