From 4622196fcd268abab0c16a6364b462f9ea3ed663 Mon Sep 17 00:00:00 2001 From: Rebecca Stevens Date: Thu, 23 May 2024 00:54:13 +1200 Subject: [PATCH] fix: handle const Type Parameters --- package.json | 2 ++ pnpm-lock.yaml | 6 ++++ src/flags.ts | 11 ++++++++ src/nodes/utilities.ts | 55 ++++++++++++++++++++++++++++++++++++- src/types/utilities.test.ts | 27 ++++++++++++++++++ src/types/utilities.ts | 2 +- tsconfig.json | 2 +- typings/typescript.d.ts | 11 ++++++++ 8 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 typings/typescript.d.ts diff --git a/package.json b/package.json index f4c9fd5d..836680ea 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@phenomnomnominal/tsquery": "^6.1.3", "@release-it/conventional-changelog": "^8.0.1", "@types/eslint": "^8.56.5", + "@types/semver": "^7.5.8", "@typescript-eslint/eslint-plugin": "^7.3.1", "@typescript-eslint/parser": "^7.3.1", "@typescript/vfs": "^1.5.0", @@ -81,6 +82,7 @@ "prettier-plugin-curly": "^0.2.1", "prettier-plugin-packagejson": "^2.4.7", "release-it": "^17.0.1", + "semver": "^7.6.2", "sentences-per-line": "^0.2.1", "should-semantic-release": "^0.3.0", "tsup": "^8.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45e56271..bacdbcc8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ devDependencies: '@types/eslint': specifier: ^8.56.5 version: 8.56.6 + '@types/semver': + specifier: ^7.5.8 + version: 7.5.8 '@typescript-eslint/eslint-plugin': specifier: ^7.3.1 version: 7.9.0(@typescript-eslint/parser@7.9.0)(eslint@8.57.0)(typescript@5.4.3) @@ -98,6 +101,9 @@ devDependencies: release-it: specifier: ^17.0.1 version: 17.1.1(typescript@5.4.3) + semver: + specifier: ^7.6.2 + version: 7.6.2 sentences-per-line: specifier: ^0.2.1 version: 0.2.1 diff --git a/src/flags.ts b/src/flags.ts index b9fefccc..886cde06 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -89,6 +89,17 @@ export const isSymbolFlagSet: ( flag: ts.SymbolFlags, ) => boolean = isFlagSetOnObject; +/** + * Test if the given symbol's links has the given `ModifierFlags` set. + * @internal + */ +export function isSymbolLinkFlagSet( + symbol: ts.Symbol, + flag: ts.ModifierFlags, +): boolean { + return symbol.links !== undefined && isFlagSet(symbol.links.checkFlags, flag); +} + /** * Test if the given node has the given `TypeFlags` set. * @category Nodes - Flag Utilities diff --git a/src/nodes/utilities.ts b/src/nodes/utilities.ts index 6e2f481b..096be54a 100644 --- a/src/nodes/utilities.ts +++ b/src/nodes/utilities.ts @@ -3,6 +3,7 @@ import ts from "typescript"; +import { isSymbolLinkFlagSet } from "../flags"; import { isConstAssertionExpression, isEntityNameExpression, @@ -27,11 +28,24 @@ export function isBindableObjectDefinePropertyCall( ); } +/** + * Detects whether the property assignment is affected by an enclosing `as const` assertion or const type parameter and therefore treated literally. + */ +export function isInConstContext( + node: ts.PropertyAssignment | ts.ShorthandPropertyAssignment, + typeChecker: ts.TypeChecker, +): boolean { + return ( + isInAsConstContext(node.parent) || + isInConstTypeParameterContext(node, typeChecker) + ); +} + /** * Detects whether an expression is affected by an enclosing `as const` assertion and therefore treated literally. * @internal */ -export function isInConstContext(node: ts.Expression): boolean { +function isInAsConstContext(node: ts.Expression): boolean { let current: ts.Node = node; while (true) { const parent = current.parent; @@ -74,3 +88,42 @@ export function isInConstContext(node: ts.Expression): boolean { } } } + +/** + * Detects whether a property assignment is affected by a const type parameter and therefore treated literally. + * @internal + */ +function isInConstTypeParameterContext( + node: ts.PropertyAssignment | ts.ShorthandPropertyAssignment, + typeChecker: ts.TypeChecker, +): boolean { + let current: ts.PropertyAssignment | ts.ShorthandPropertyAssignment = node; + let callExpression: ts.CallExpression; + + while (true) { + if ( + ts.isPropertyAssignment(current.parent.parent) || + ts.isShorthandPropertyAssignment(current.parent.parent) + ) { + current = current.parent.parent; + continue; + } + + if (ts.isCallExpression(current.parent.parent)) { + callExpression = current.parent.parent; + break; + } + + return false; + } + + const type = typeChecker.getTypeAtLocation(callExpression); + const property = type + .getProperties() + .find((property) => property.getName() === current.name.getText()); + + return ( + property !== undefined && + isSymbolLinkFlagSet(property, ts.ModifierFlags.Readonly) + ); +} diff --git a/src/types/utilities.test.ts b/src/types/utilities.test.ts index b7a8295a..c91eb207 100644 --- a/src/types/utilities.test.ts +++ b/src/types/utilities.test.ts @@ -1,3 +1,4 @@ +import semver from "semver"; import ts from "typescript"; import { describe, expect, it } from "vitest"; @@ -131,6 +132,32 @@ describe("symbolHasReadonlyDeclaration", () => { expect(symbolHasReadonlyDeclaration(symbol, typeChecker)).toBe(expected); }); + + if (semver.gte(ts.version, "5.0.0")) { + it("returns true when the symbol belongs to a property of a nested object literal directly passed into a function that declares the parameter with a const type parameter", () => { + const { sourceFile, typeChecker } = createSourceFileAndTypeChecker(` + declare const fn: (param: A) => A; + + const bar = { baz: 1 }; + fn({ foo: { bar } }); + `); + + const statement = sourceFile.statements.at(-1) as ts.ExpressionStatement; + const callExpression = statement.expression as ts.CallExpression; + const objectLiteral1 = callExpression + .arguments[0] as ts.ObjectLiteralExpression; + const foo = objectLiteral1.properties[0] as ts.PropertyAssignment; + const fooSymbol = (foo as { symbol?: ts.Symbol }).symbol!; + const objectLiteral2 = foo.initializer as ts.ObjectLiteralExpression; + const bar = objectLiteral2.properties[0] as ts.PropertyAssignment; + const barSymbol = (bar as { symbol?: ts.Symbol }).symbol!; + + expect(fooSymbol).toBeDefined(); + expect(barSymbol).toBeDefined(); + expect(symbolHasReadonlyDeclaration(fooSymbol, typeChecker)).toBe(true); + expect(symbolHasReadonlyDeclaration(barSymbol, typeChecker)).toBe(true); + }); + } }); describe("isFalsyType", () => { diff --git a/src/types/utilities.ts b/src/types/utilities.ts index 61ff182d..d16daeed 100644 --- a/src/types/utilities.ts +++ b/src/types/utilities.ts @@ -375,7 +375,7 @@ export function symbolHasReadonlyDeclaration( ts.isEnumMember(node) || ((ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node)) && - isInConstContext(node.parent)), + isInConstContext(node, typeChecker)), ) ); } diff --git a/tsconfig.json b/tsconfig.json index fd910687..38704386 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,5 +12,5 @@ "strict": true, "target": "ES2021" }, - "include": ["src"] + "include": ["src", "typings"] } diff --git a/typings/typescript.d.ts b/typings/typescript.d.ts new file mode 100644 index 00000000..cfa2ecbc --- /dev/null +++ b/typings/typescript.d.ts @@ -0,0 +1,11 @@ +import "typescript"; + +declare module "typescript" { + // internal TS APIs + + interface Symbol { + readonly links?: { + readonly checkFlags: ModifierFlags; + }; + } +}