diff --git a/.changeset/doctor-type-text.md b/.changeset/doctor-type-text.md new file mode 100644 index 0000000000000..ad366f5863961 --- /dev/null +++ b/.changeset/doctor-type-text.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/cli': patch +--- + +[chore] Internal groundwork for `astryx doctor integration components`: reading two written TypeScript types as one type when they differ only in how they are written (whitespace, comments, quotes, member order, `Array` for `T[]`, parameter names, `React.` qualifiers), and judging a documented prop type against the type the code declares where both read exactly. Nothing reaches either yet, so the CLI's behavior and output do not change. + +@josephfarina diff --git a/packages/cli/foundation/discovery/type-match.mjs b/packages/cli/foundation/discovery/type-match.mjs new file mode 100644 index 0000000000000..3ade8bc3770f9 --- /dev/null +++ b/packages/cli/foundation/discovery/type-match.mjs @@ -0,0 +1,660 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Judge a documented prop type against the type the code declares, + * only where both sides can be judged exactly. + * + * A documented type matches when its text equals the type as the source + * writes it (or as the compiler prints it) once type-text.mjs normalizes + * both, unless a `React.X` on either side names another type than a bare `X` + * does where the prop is declared (`React.KeyboardEvent` beside the DOM's + * `KeyboardEvent`, or a file's own `Key`); text that writes `readonly` matches only exactly as written, + * whitespace aside. Otherwise both sides are judged only when each is built from exact + * parts: the primitives `string`, `number`, `boolean`, `bigint`, `symbol`, + * `null`, `undefined`, `void` and `object`; string, number and boolean + * literals; string enum members (each of its own enum: another enum's member + * of the same value is another type, and a member beside the literal of its + * own value adds nothing, as TypeScript passes a member where that literal is + * taken); arrays of these (`T[]`, `Array`, + * `ReadonlyArray`); unions of these; and a name that resolves to one of + * these where the prop is declared (not one only TypeScript's own libraries + * declare, which the code may not mean; `React.X` resolves through `React` + * there, as the code would read it). The two sides must then hold the same + * parts, in any order, as TypeScript reduces a union at any depth (a literal + * beside its primitive is absorbed by it), with `undefined` set aside on an + * optional prop, and `null` and `undefined` dropped at any depth when the + * project has no strict null checks. + * + * Every other form is not compared, and never reported as a mismatch: a + * callback, an object, a tuple, a generic with arguments, `keyof`, an + * intersection, a mapped or conditional type, `any` or `unknown`, a numeric + * enum, two enums of one name (TypeScript relates them by their members, by + * rules its versions differ on), and a type that does not resolve. + * + * @input A documented type string, the prop's types, and the compiler. + * @output 'match', 'mismatch' or 'not_compared'. + * @position Used by the prop-drift comparison (prop-drift.mjs). + */ + +import {normalizeTypeText, UNPARSED} from './type-text.mjs'; + +/** @typedef {'match' | 'mismatch' | 'not_compared'} TypeVerdict */ + +/** + * @typedef {object} JudgeContext + * @property {any} ts + * @property {any} checker + * @property {any} location where names in the documented type resolve (the + * prop's declaration, else the component's source file) + * @property {boolean} optional the prop is optional, so `undefined` is set + * aside on both sides + * @property {(file: any) => boolean} [isDefaultLibrary] whether a source + * file is one of TypeScript's own libraries + * @property {boolean} [nullable] the project checks without strict null + * checks, so the compiler drops `null` and `undefined` from every type and + * both are set aside on both sides + * @property {boolean} [exactOptional] the project sets + * `exactOptionalPropertyTypes`: an optional prop takes `undefined` only when + * its type says so, and the compiler's mark for an absent prop is not a + * type the prop takes + * @property {string[][]} written the code's type as the source writes it and + * as the compiler prints it, each a list of union members + */ + +const MAX_DEPTH = 6; + +/** + * A string literal as one part: quoted, with quotes and backslashes escaped, + * so no text inside it reads as other parts (`"a'|'b"` is one literal). + * @param {string} value + */ +const literalPart = value => + `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; + +/** + * Text as part of an enum part: each character but a word character as its + * code point (`%41;`), so no text reads as a separator and no string, an + * unpaired surrogate included, fails to encode. + * @param {string} text + */ +const encodePart = text => + [...text] + .map(c => + /[\w-]/.test(c) ? c : `%${(c.codePointAt(0) ?? 0).toString(16)};`, + ) + .join(''); + +/** @param {string} text */ +const decodePart = text => + text.replace(/%([0-9a-f]+);/g, (_, hex) => + String.fromCodePoint(parseInt(hex, 16)), + ); + +/** Type keywords, which never name an alias. */ +const KEYWORD_TYPES = new Set([ + 'string', + 'number', + 'boolean', + 'bigint', + 'symbol', + 'object', + 'any', + 'unknown', + 'never', + 'void', + 'undefined', + 'null', + 'true', + 'false', +]); + +/** + * Judge a documented type. A doc may name an alias and spell out what it + * stands for, as `Name = expansion` or `Name (expansion)` (text that is not a + * type by itself): both parts are judged, and a mismatch in either is one; + * with one value after it (`Size = 'md'`, `Size = Size.Md`), or after a + * keyword, that part may be a default, and only the name is judged. + * @param {string} docType + * @param {any[]} codeTypes the prop's type in each member of the props type + * @param {JudgeContext} ctx + * @returns {TypeVerdict} + */ +export function judgeType(docType, codeTypes, ctx) { + const notation = parseType(ctx.ts, docType) + ? null + : (/^\s*([A-Za-z_$][\w$.]*)\s*=(?!>)\s*([\s\S]+)$/.exec(docType) ?? + /^\s*([A-Za-z_$][\w$.]*)\s*\(([\s\S]+)\)\s*$/.exec(docType)); + /** @type {string[]} */ + let texts = [docType]; + if (notation) { + const [, name, rest] = notation; + const restNode = parseType(ctx.ts, rest); + if (!restNode) return 'not_compared'; // prose, not a type or a default + // After a type keyword, or as one value (`'md'`, `Size.Md`), the second + // part may be the prop's default, not an alias's expansion: only the + // type is judged. + const isDefault = + KEYWORD_TYPES.has(name) || + (restNode != null && isValue(ctx.ts, restNode)); + texts = isDefault ? [name] : [name, rest]; + } + const verdicts = texts.map(text => judgeOne(text, codeTypes, ctx)); + if (verdicts.includes('mismatch')) return 'mismatch'; + return verdicts.every(verdict => verdict === 'match') + ? 'match' + : 'not_compared'; +} + +/** A string literal, kept as written, or a run of whitespace. */ +const QUOTED_OR_SPACE = + /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|`(?:\\.|[^`\\])*`|\s+/g; + +/** + * A type's text without whitespace outside string literals, but for one space + * between two words. + * @param {string} text + */ +function asWritten(text) { + return text + .trim() + .replace(QUOTED_OR_SPACE, (match, offset, whole) => + !/^\s/.test(match) + ? match + : /[\w$]/.test(whole[offset - 1] ?? '') && + /[\w$]/.test(whole[offset + match.length] ?? '') + ? ' ' + : '', + ); +} + +/** + * @param {string} docType + * @param {any[]} codeTypes + * @param {JudgeContext} ctx + * @returns {TypeVerdict} + */ +function judgeOne(docType, codeTypes, ctx) { + const {optional} = ctx; + const doc = normalizeTypeText(docType, {optional, ts: ctx.ts}).join('|'); + // Text that does not parse is never a match. + if (doc.startsWith(UNPARSED)) return 'not_compared'; + // The text match sets `readonly` aside, so text that writes `readonly` + // matches only as written: the same text, whitespace aside, with + // `readonly` kept on both sides. Any other goes to the parts below. + // Outside string literals: `'readonly'` is a word, not the modifier. + const readonly = (/** @type {string} */ text) => + /\breadonly\b(?!\s*[:?])|\bReadonly(?:Array)?\s* (/^\s/.test(part) ? part : "''")), + ); + const docReadonly = readonly(docType); + const sameText = ctx.written.some( + texts => + (docReadonly || texts.some(readonly) + ? texts.length === 1 && asWritten(texts[0]) === asWritten(docType) + : texts.length > 0 && + [ + ...new Set( + texts.flatMap(text => + normalizeTypeText(text, {optional, ts: ctx.ts}), + ), + ), + ] + .sort() + .join('|') === doc) && !reactNameDiffers(ctx, docType, texts), + ); + if (sameText) return 'match'; + const node = parseType(ctx.ts, docType); + const docParts = node ? docAtoms(ctx, node, 0) : null; + const codeParts = codeAtoms(ctx, codeTypes, 0); + if (!docParts || !codeParts) return 'not_compared'; + // `readonly` set aside, outside string literals. + const mutable = (/** @type {string[]} */ atoms) => + reduced( + ctx, + atoms.map(atom => + atom.replace(/'(?:\\.|[^'\\])*'|readonly\[/g, part => + part === 'readonly[' ? '[' : part, + ), + ), + true, + ); + const doc_ = reduced(ctx, docParts, true); + const code = reduced(ctx, codeParts, true); + // A doc that names a mutable array where the code takes a readonly one + // matches, as TypeScript passes it. + if (doc_ === code || doc_ === mutable(codeParts)) return 'match'; + // Two enums of one name are not judged (see the file's comment). + const owners = new Set( + [...docParts, ...codeParts].join('|').match(/enum:[^#|\]]*#[^#|\]]*/g), + ); + const names = [...owners].map(owner => owner.split('#')[0]); + if (new Set(names).size < names.length) return 'not_compared'; + if (mutable(docParts) !== mutable(codeParts)) return 'mismatch'; + // Only `readonly` differs: a readonly doc for mutable code is wrong; a + // nested mix is not placed. + return code === mutable(codeParts) ? 'mismatch' : 'not_compared'; +} + +/** + * A union's exact parts, as TypeScript reduces them, at any depth: a literal + * beside its primitive is absorbed by it (`IconName | string` is `string`), + * and `null` and `undefined` are dropped without strict null checks, as the + * compiler drops them. On an optional prop `undefined` is set aside too, as + * the prop takes it anyway. + * @param {JudgeContext} ctx + * @param {string[]} atoms + * @param {boolean} top the prop's own type, not an array element + * @returns {string} + */ +function reduced(ctx, atoms, top) { + const kept = new Set( + atoms.filter( + atom => + !(atom === 'undefined' && ((top && ctx.optional) || ctx.nullable)) && + !(atom === 'null' && ctx.nullable), + ), + ); + if (kept.has('string')) { + for (const atom of [...kept]) { + if (atom.startsWith("'") || atom.startsWith('enum:')) kept.delete(atom); + } + } + for (const atom of [...kept]) { + const value = atom.startsWith('enum:') + ? decodePart(atom.slice(atom.lastIndexOf('#') + 1)) + : null; + if (value != null && kept.has(literalPart(value))) kept.delete(atom); + } + if (kept.has('number')) { + for (const atom of [...kept]) { + if (/^-?\d/.test(atom) || /^(?:NaN|-?Infinity)$/.test(atom)) { + kept.delete(atom); + } + } + } + // A member another covers adds nothing, as TypeScript passes it there: + // `undefined` beside `void`, an array beside `object`, and an array whose + // element types another array (readonly, or as mutable) also takes. + if (kept.has('void')) kept.delete('undefined'); + for (const atom of [...kept]) { + if ( + isArrayPart(atom) && + (kept.has('object') || + [...kept].some(other => other !== atom && arrayCovers(other, atom))) + ) { + kept.delete(atom); + } + } + return [...kept].sort().join('|'); +} + +/** @param {string} atom */ +const isArrayPart = atom => /^(?:readonly)?\[/.test(atom); + +/** + * The parts of a reduced union's text, split at the top level, outside + * string literals and arrays. + * @param {string} text + * @returns {string[]} + */ +function unionParts(text) { + /** @type {string[]} */ + const parts = []; + let depth = 0; + let quoted = false; + let start = 0; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (quoted) { + if (c === '\\') i++; + else if (c === "'") quoted = false; + } else if (c === "'") quoted = true; + else if (c === '[') depth++; + else if (c === ']') depth--; + else if (c === '|' && depth === 0) { + parts.push(text.slice(start, i)); + start = i + 1; + } + } + parts.push(text.slice(start)); + return parts.filter(Boolean); +} + +/** + * Does array part `wide` take every value of array part `narrow`: readonly, + * or both mutable, with each of `narrow`'s element types one of `wide`'s or + * covered by one? + * @param {string} wide + * @param {string} narrow + * @returns {boolean} + */ +function arrayCovers(wide, narrow) { + if (!isArrayPart(wide) || !isArrayPart(narrow)) return false; + const readonly = (/** @type {string} */ part) => part.startsWith('readonly'); + if (readonly(narrow) && !readonly(wide)) return false; + const inner = (/** @type {string} */ part) => + unionParts(part.slice(part.indexOf('[') + 1, -1)); + const taken = inner(wide); + return inner(narrow).every( + part => + taken.includes(part) || + (taken.includes('string') && + (part.startsWith("'") || part.startsWith('enum:'))) || + (taken.includes('number') && /^(?:-?\d|NaN$|-?Infinity$)/.test(part)) || + (part === 'undefined' && taken.includes('void')) || + (isArrayPart(part) && + (taken.includes('object') || + taken.some(other => arrayCovers(other, part)))), + ); +} + +/** + * Is a type node one value a default could be: a literal (`'md'`, `0`, + * `false`, `null`), or a name with no type arguments (`Size.Md`)? + * @param {any} ts + * @param {any} node + * @returns {boolean} + */ +function isValue(ts, node) { + if (ts.isParenthesizedTypeNode(node)) return isValue(ts, node.type); + return ( + ts.isLiteralTypeNode(node) || + node.kind === ts.SyntaxKind.UndefinedKeyword || + node.kind === ts.SyntaxKind.NullKeyword || + (ts.isTypeReferenceNode(node) && !node.typeArguments?.length) + ); +} + +/** + * Parse a type expression with the project's TypeScript. + * @param {any} ts + * @param {string} text + * @returns {any | null} the type node, or null when the text is not a type + */ +function parseType(ts, text) { + const source = ts.createSourceFile( + '__documented_type__.ts', + `type __Documented = ${text};`, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const [statement, ...rest] = source.statements; + if ( + (source.parseDiagnostics?.length ?? 0) > 0 || + rest.length > 0 || + !statement || + !ts.isTypeAliasDeclaration(statement) || + statement.type.end !== source.text.length - 1 + ) { + return null; + } + return statement.type; +} + +/** + * The exact parts of a documented type, or null when it has a part that is + * not exact. + * @param {JudgeContext} ctx + * @param {any} node + * @param {number} depth + * @returns {string[] | null} + */ +function docAtoms(ctx, node, depth) { + const {ts} = ctx; + const SyntaxKind = ts.SyntaxKind; + if (depth > MAX_DEPTH) return null; + if (ts.isParenthesizedTypeNode(node)) return docAtoms(ctx, node.type, depth); + if (ts.isUnionTypeNode(node)) { + const parts = node.types.map( + /** @param {any} t */ t => docAtoms(ctx, t, depth + 1), + ); + return parts.every(Boolean) ? parts.flat() : null; + } + /** @type {Record} */ + const keywords = { + [SyntaxKind.StringKeyword]: ['string'], + [SyntaxKind.NumberKeyword]: ['number'], + [SyntaxKind.BooleanKeyword]: ['false', 'true'], + [SyntaxKind.BigIntKeyword]: ['bigint'], + [SyntaxKind.SymbolKeyword]: ['symbol'], + [SyntaxKind.UndefinedKeyword]: ['undefined'], + [SyntaxKind.VoidKeyword]: ['void'], + [SyntaxKind.ObjectKeyword]: ['object'], + [SyntaxKind.NullKeyword]: ['null'], + [SyntaxKind.NeverKeyword]: [], + }; + if (keywords[node.kind]) return keywords[node.kind]; + if (ts.isLiteralTypeNode(node)) { + const literal = node.literal; + if ( + ts.isStringLiteral(literal) || + ts.isNoSubstitutionTemplateLiteral(literal) + ) + return [literalPart(literal.text)]; + if (ts.isNumericLiteral(literal)) return [String(Number(literal.text))]; + if ( + ts.isPrefixUnaryExpression(literal) && + literal.operator === SyntaxKind.MinusToken && + ts.isNumericLiteral(literal.operand) + ) { + return [String(-Number(literal.operand.text))]; + } + if (literal.kind === SyntaxKind.TrueKeyword) return ['true']; + if (literal.kind === SyntaxKind.FalseKeyword) return ['false']; + if (literal.kind === SyntaxKind.NullKeyword) return ['null']; + return null; + } + // A readonly array is its own type: TypeScript does not pass one where a + // mutable array is taken. + const array = (/** @type {any} */ element, readonly = false) => { + const parts = docAtoms(ctx, element, depth + 1); + return parts + ? [`${readonly ? 'readonly' : ''}[${reduced(ctx, parts, false)}]`] + : null; + }; + if (ts.isArrayTypeNode(node)) return array(node.elementType); + if ( + ts.isTypeOperatorNode(node) && + node.operator === SyntaxKind.ReadonlyKeyword && + ts.isArrayTypeNode(node.type) + ) { + return array(node.type.elementType, true); + } + if (ts.isTypeReferenceNode(node)) { + const name = referenceName(ts, node.typeName); + const args = node.typeArguments ?? []; + if ((name === 'Array' || name === 'ReadonlyArray') && args.length === 1) + return array(args[0], name === 'ReadonlyArray'); + if (args.length > 0) return null; + const named = resolveName(ctx, name); + return named ? codeAtoms(ctx, [named], depth + 1) : null; + } + return null; +} + +/** + * The exact parts of the code's type, or null when it has a part that is not + * exact. + * @param {JudgeContext} ctx + * @param {any[]} types + * @param {number} depth + * @returns {string[] | null} + */ +function codeAtoms(ctx, types, depth) { + const {ts, checker} = ctx; + const F = ts.TypeFlags; + if (depth > MAX_DEPTH) return null; + /** @type {string[]} */ + const out = []; + for (const type of types) { + for (const t of type.isUnion() ? type.types : [type]) { + if (t.flags & F.EnumLiteral) { + // A string enum member is exact, and only its own enum's: TypeScript + // takes no other enum's member of the same value. A numeric enum also + // takes numbers. + const decl = t.symbol?.valueDeclaration; + const owner = decl && checker.getSymbolAtLocation(decl.parent.name); + if (!(t.flags & F.StringLiteral) || !owner) return null; + out.push( + `enum:${owner.name}#${encodePart(checker.getFullyQualifiedName(owner))}#${encodePart(t.value)}`, + ); + } else if (t.flags & F.StringLiteral) out.push(literalPart(t.value)); + else if (t.flags & F.NumberLiteral) out.push(String(t.value)); + else if (t.flags & F.BooleanLiteral) out.push(t.intrinsicName); + else if (t.flags & F.String) out.push('string'); + else if (t.flags & F.Number) out.push('number'); + else if (t.flags & F.BigInt) out.push('bigint'); + else if (t.flags & F.ESSymbol) out.push('symbol'); + else if (t.flags & F.Null) out.push('null'); + else if (t.flags & F.Undefined) { + // `exactOptionalPropertyTypes` marks an optional prop with its own + // `undefined`, which `prop={undefined}` does not satisfy. + if (!ctx.exactOptional || t === ctx.checker.getUndefinedType?.()) { + out.push('undefined'); + } + } else if (t.flags & F.Void) out.push('void'); + else if (t.flags & F.NonPrimitive) out.push('object'); + else if (t.flags & F.Never) continue; + else { + const name = t.symbol?.name; + const element = + (name === 'Array' || name === 'ReadonlyArray') && + t.objectFlags & ts.ObjectFlags.Reference + ? checker.getTypeArguments(t)[0] + : undefined; + const parts = element ? codeAtoms(ctx, [element], depth + 1) : null; + if (!parts) return null; + out.push( + `${name === 'ReadonlyArray' ? 'readonly' : ''}[${reduced(ctx, parts, false)}]`, + ); + } + } + } + return out; +} + +/** + * The text of a type reference's name, `React.` kept: it resolves through + * `React` where the prop is declared, which may not be what a bare name + * means there. + * @param {any} ts + * @param {any} typeName + * @returns {string} + */ +function referenceName(ts, typeName) { + if (ts.isIdentifier(typeName)) return typeName.text; + return `${referenceName(ts, typeName.left)}.${typeName.right.text}`; +} + +/** + * Does one side write `React.X` where the other writes a bare `X` that names + * another type where the prop is declared (the DOM's `KeyboardEvent`, a + * file's own `Key`)? The text reading sets the qualifier aside, so then the + * two must not be read as one. A bare name that resolves to nothing there + * leaves `React.X` as what it means. + * @param {JudgeContext} ctx + * @param {string} docType + * @param {string[]} texts the code's type, one way it is written + * @returns {boolean} + */ +function reactNameDiffers(ctx, docType, texts) { + const names = (/** @type {string[]} */ list) => { + const masked = list + .map(text => + text.replace(QUOTED_OR_SPACE, part => (/^\s/.test(part) ? ' ' : "''")), + ) + .join(' '); + const qualified = new Set( + [...masked.matchAll(/(? match[1], + ), + ); + const bare = new Set( + [...masked.matchAll(/(? match[1], + ), + ); + return {qualified, bare}; + }; + const doc = names([docType]); + const code = names(texts); + const crossed = [ + ...[...doc.qualified].filter(name => code.bare.has(name)), + ...[...code.qualified].filter(name => doc.bare.has(name)), + ]; + return crossed.some(name => { + const bare = resolveSymbol(ctx, name); + return !!bare && bare !== resolveSymbol(ctx, `React.${name}`); + }); +} + +/** + * The type a name the documented type uses resolves to, where the prop is + * declared, or null. + * @param {JudgeContext} ctx + * @param {string} name + * @returns {any | null} + */ +function resolveName(ctx, name) { + const {ts, checker} = ctx; + try { + const resolved = resolveSymbol(ctx, name); + // A name only TypeScript's own libraries declare (the DOM's + // `ScrollBehavior`) is one the code may not mean: not resolved. + const declarations = resolved?.declarations ?? []; + if ( + ctx.isDefaultLibrary && + declarations.length > 0 && + declarations.every( + /** @param {any} d */ d => ctx.isDefaultLibrary?.(d.getSourceFile()), + ) + ) { + return null; + } + return resolved && resolved.flags & ts.SymbolFlags.Type + ? checker.getDeclaredTypeOfSymbol(resolved) + : null; + } catch { + return null; + } +} + +/** + * The symbol a name resolves to where the prop is declared, through aliases + * and namespaces (`React.Key` through `React`, imported or global), or + * undefined. + * @param {JudgeContext} ctx + * @param {string} name + * @returns {any} + */ +function resolveSymbol(ctx, name) { + const {ts, checker} = ctx; + const meaning = + ts.SymbolFlags.Type | ts.SymbolFlags.Alias | ts.SymbolFlags.Namespace; + const target = (/** @type {any} */ symbol) => + symbol && symbol.flags & ts.SymbolFlags.Alias + ? checker.getAliasedSymbol(symbol) + : symbol; + try { + const [head, ...tail] = name.split('.'); + // A module's own declarations can come back as local symbols without + // flags: take the export symbol, and the first one that names a type or + // namespace. + let symbol = checker + .getSymbolsInScope(ctx.location, meaning) + .filter(/** @param {any} s */ s => s.name === head) + .map(/** @param {any} s */ s => checker.getExportSymbolOfSymbol(s)) + .find(/** @param {any} s */ s => (s.flags & meaning) !== 0); + for (const part of tail) { + symbol = symbol + ? checker + .getExportsOfModule(target(symbol)) + .find(/** @param {any} s */ s => s.name === part) + : undefined; + } + return target(symbol); + } catch { + return undefined; + } +} diff --git a/packages/cli/foundation/discovery/type-match.test.mjs b/packages/cli/foundation/discovery/type-match.test.mjs new file mode 100644 index 0000000000000..8fdaabe639186 --- /dev/null +++ b/packages/cli/foundation/discovery/type-match.test.mjs @@ -0,0 +1,169 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file A documented type judged against the type the code declares: a match + * or a mismatch only where both sides read exactly, else not compared. + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import {createRequire} from 'node:module'; +import {afterAll, describe, expect, it} from 'vitest'; +import {judgeType} from './type-match.mjs'; + +const ts = createRequire(import.meta.url)('typescript'); +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-type-match-')); +afterAll(() => fs.rmSync(dir, {recursive: true, force: true})); +// React's types, as a global `React` namespace the way they declare it. +const reactTypes = path.join(dir, 'node_modules', '@types', 'react'); +fs.mkdirSync(reactTypes, {recursive: true}); +fs.writeFileSync( + path.join(reactTypes, 'index.d.ts'), + 'export = React;\nexport as namespace React;\ndeclare namespace React {\n type Key = string | number | bigint;\n interface KeyboardEvent { key: string; currentTarget: T }\n}\n', +); + +/** + * Judges documented types against the props of `Props` in `source`. + * @param {string} source + * @returns {(prop: string, docType: string) => string} + */ +function judgeIn(source) { + const file = path.join(dir, 'props.ts'); + fs.writeFileSync(file, source); + const program = ts.createProgram([file], { + strict: true, + noEmit: true, + typeRoots: [path.join(dir, 'node_modules', '@types')], + types: ['react'], + }); + const checker = program.getTypeChecker(); + const props = checker.getTypeAtLocation( + program + .getSourceFile(file) + .statements.find( + (/** @type {any} */ s) => + ts.isInterfaceDeclaration(s) && s.name.text === 'Props', + ).name, + ); + return (prop, docType) => { + const symbol = checker.getPropertyOfType(props, prop); + const declaration = symbol.valueDeclaration; + return judgeType(docType, [checker.getTypeOfSymbol(symbol)], { + ts, + checker, + location: declaration, + optional: (symbol.flags & ts.SymbolFlags.Optional) !== 0, + isDefaultLibrary: (/** @type {any} */ f) => + program.isSourceFileDefaultLibrary(f), + written: [[declaration.type.getText()]], + }); + }; +} + +describe('judgeType', () => { + it("judges exact parts in any order, names for them, a mutable array for a readonly one, an enum member beside its value, literals that hold `|` or `readonly`, `React.X` as React's own, and a member another covers, and compares no other form", () => { + const judge = judgeIn(`enum Color { Red = 'red', Blue = 'blue' } +enum Hue { Red = 'red', Blue = 'blue' } +enum Letter { A = 'a', B = 'b' } +enum Odd { Lone = '\\uD800' } +type Size = 'sm' | 'md'; +type Key = 'a' | 'b'; +export interface Props { + label: string; + size?: Size; + tags: readonly string[]; + names: string[]; + color: Color; + count: number | null; + onPick: (value: string) => void; + pair: 'a' | 'b'; + letter: Letter | 'a' | 'b'; + bracket: 'readonly[x'; + onMode: (mode: 'readonly' | 'edit') => void; + onKey: (event: KeyboardEvent) => void; + onReactKey: (event: React.KeyboardEvent) => void; + rowKey: string | number | bigint; + pick: Key; + odd: Odd; + list: (string | number)[]; + frozen: readonly (string | number)[]; + bag: object; + nothing: void; +} +`); + expect( + [ + ['label', 'string'], + ['label', 'number'], + ['size', "'md' | 'sm'"], + ['size', 'Size'], + ['size', "'sm'"], + ['size', "Size = 'sm'"], + ['tags', 'string[]'], + ['names', 'readonly string[]'], + ['color', 'Color'], + ['color', 'Hue'], + ['color', "'red' | 'blue'"], + ['count', 'number | null'], + ['count', 'number'], + ['onPick', '(value: string) => void'], + ['onPick', '() => void'], + ['pair', `"a'|'b"`], + ['pair', "Letter | 'a' | 'b'"], + ['letter', "'a' | 'b'"], + ['bracket', "'[x'"], + ['onMode', "(mode: 'edit' | 'readonly') => void"], + ['onKey', '(event: KeyboardEvent) => void'], + ['onKey', '(event: React.KeyboardEvent) => void'], + ['onReactKey', '(e: React.KeyboardEvent) => void'], + ['rowKey', 'React.Key'], + ['pick', 'Key'], + ['pick', 'React.Key'], + ['odd', 'Odd'], + ['odd', 'Odd | null'], + ['list', 'string[] | (string | number)[]'], + ['frozen', 'string[] | readonly (string | number)[]'], + ['list', 'readonly string[] | (string | number)[]'], + ['bag', 'object | string[]'], + ['nothing', 'void | undefined'], + ['label', 'any text'], + ].map(([prop, docType]) => `${prop}: ${docType} ${judge(prop, docType)}`), + ).toEqual([ + 'label: string match', + 'label: number mismatch', + "size: 'md' | 'sm' match", + 'size: Size match', + "size: 'sm' mismatch", + "size: Size = 'sm' match", + 'tags: string[] match', + 'names: readonly string[] mismatch', + 'color: Color match', + 'color: Hue mismatch', + "color: 'red' | 'blue' mismatch", + 'count: number | null match', + 'count: number mismatch', + 'onPick: (value: string) => void match', + 'onPick: () => void not_compared', + `pair: "a'|'b" mismatch`, + "pair: Letter | 'a' | 'b' match", + "letter: 'a' | 'b' match", + "bracket: '[x' mismatch", + "onMode: (mode: 'edit' | 'readonly') => void match", + 'onKey: (event: KeyboardEvent) => void match', + 'onKey: (event: React.KeyboardEvent) => void not_compared', + 'onReactKey: (e: React.KeyboardEvent) => void match', + 'rowKey: React.Key match', + 'pick: Key match', + 'pick: React.Key mismatch', + 'odd: Odd match', + 'odd: Odd | null mismatch', + 'list: string[] | (string | number)[] match', + 'frozen: string[] | readonly (string | number)[] match', + 'list: readonly string[] | (string | number)[] mismatch', + 'bag: object | string[] match', + 'nothing: void | undefined match', + 'label: any text not_compared', + ]); + }); +}); diff --git a/packages/cli/foundation/discovery/type-text.mjs b/packages/cli/foundation/discovery/type-text.mjs new file mode 100644 index 0000000000000..0f34101890c13 --- /dev/null +++ b/packages/cli/foundation/discovery/type-text.mjs @@ -0,0 +1,658 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Loose comparison of TypeScript type text, for checking a documented + * prop type against the type its source declares. + * + * {@link normalizeTypeText} parses the text with the project's own TypeScript + * and prints the type canonically, so grouping always comes from the parse. + * Two texts are the same type here when they print the same. The printer + * merges only what TypeScript reads as one type: + * - comments, whitespace, parentheses that change nothing, and quote style; + * - the names of function and tuple parameters (a `this` parameter is kept, + * and a type predicate names its parameter by place); + * - the order of a union's members and of an object type's properties; + * - `Array` and `T[]`; a `React.` qualifier; + * - `true | false` and `boolean`; a string, number or boolean literal beside + * its primitive; `never` among other union members; and `undefined` on an + * optional prop. + * It also sets `readonly`, `Readonly<…>` and `ReadonlyArray<…>` aside: the + * prop type judgment compares text that writes `readonly` exactly instead. + * Below the top of a type, a function or conditional type is always printed + * in parentheses, and a union or intersection wherever its members would + * otherwise bind differently. Any form the printer does not know is kept as + * written (comments and whitespace aside), so it never reads as another + * type. Text that does not parse as one type comes back marked with + * {@link UNPARSED}, which no printed type starts with, so it never matches. + * {@link typeTextTooOld} says when the project's TypeScript is too old to + * read type text with. + * + * @input Type text as written in a doc or a source file, or printed by the + * TypeScript compiler, and the project's own `typescript`. + * @output The canonical members of the type's top-level union, sorted. + * @position Used by the prop type judgment (type-match.mjs). + */ + +/** Marks text that does not parse as one type: no printed type has it. */ +export const UNPARSED = '\u0000'; + +/** + * Why the project's TypeScript is too old for this module (before 4.8, the + * oldest every printed form is tested on), or null. + * @param {{version?: string}} ts + * @returns {string | null} + */ +export function typeTextTooOld(ts) { + const [major, minor] = String(ts.version).split('.').map(Number); + return major > 4 || (major === 4 && minor >= 8) + ? null + : `TypeScript ${ts.version} is older than 4.8, the oldest the type comparison reads`; +} + +/** How tightly a printed type binds: a slot needs at least its level. */ +const FN = 0; // a function, constructor or conditional type +const UNION = 1; +const INTERSECTION = 2; +const OPERATOR = 3; // `keyof T`, `unique symbol`, `infer U` +const POSTFIX = 4; // `T[]`, `T[K]` +const PRIMARY = 5; + +/** + * @typedef {object} Printed + * @property {string} text + * @property {number} level + * @property {'string' | 'number' | 'boolean' | 'other'} [literal] a literal + * its primitive absorbs in a union + */ + +/** @type {WeakMap>} */ +const CACHE = new WeakMap(); + +/** + * Remove comments and whitespace outside string literals, keeping one space + * between two words. Used for text the printer keeps as written. + * @param {string} text + * @returns {string} + */ +function compact(text) { + /** @param {number} at */ + const comment = at => + text[at] === '/' && (text[at + 1] === '*' || text[at + 1] === '/'); + let out = ''; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === '"' || c === "'" || c === '`') { + let j = i + 1; + while (j < text.length && text[j] !== c) j += text[j] === '\\' ? 2 : 1; + out += text.slice(i, j + 1); + i = j; + continue; + } + // Whitespace and comments: either separates two words. + if (/\s/.test(c) || comment(i)) { + let k = i; + while (k < text.length && (/\s/.test(text[k]) || comment(k))) { + if (!comment(k)) { + k++; + continue; + } + const end = + text[k + 1] === '*' + ? text.indexOf('*/', k + 2) + 2 + : text.indexOf('\n', k + 2); + k = end <= 1 ? text.length : end; + } + const prev = out[out.length - 1]; + if (prev && /[\w$]/.test(prev) && /[\w$]/.test(text[k] ?? '')) { + out += ' '; + } + i = k - 1; + continue; + } + out += c; + } + return out; +} + +/** @param {string} value */ +const quote = value => + `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n')}'`; + +/** + * Print a type node canonically. + * @param {any} ts + * @param {any} sf + * @param {any} node + * @returns {Printed} + */ +function print(ts, sf, node) { + const K = ts.SyntaxKind; + /** @type {(child: any, level: number) => string} */ + const slot = (child, level) => at(ts, sf, child, level); + /** @type {(text: string, level?: number) => Printed} */ + const done = (text, level = PRIMARY) => ({text, level}); + const kept = () => done(compact(node.getText(sf)), FN); + + if (ts.isParenthesizedTypeNode(node)) return print(ts, sf, node.type); + if (ts.isUnionTypeNode(node)) { + const parts = unionParts(ts, sf, node, false); + return parts.length === 1 + ? parts[0] + : done(parts.map(display).join('|'), UNION); + } + if (ts.isIntersectionTypeNode(node)) { + /** @type {any[]} */ + const parts = []; + const flatten = (/** @type {any} */ n) => { + const inner = unwrap(ts, n); + if (ts.isIntersectionTypeNode(inner)) inner.types.forEach(flatten); + else parts.push(inner); + }; + flatten(node); + return done(parts.map(p => slot(p, OPERATOR)).join('&'), INTERSECTION); + } + if (ts.isFunctionTypeNode(node) || ts.isConstructorTypeNode(node)) { + const prefix = ts.isConstructorTypeNode(node) + ? `${node.modifiers?.some((/** @type {any} */ m) => m.kind === K.AbstractKeyword) ? 'abstract ' : ''}new` + : ''; + return done( + `${prefix}${typeParameters(ts, sf, node)}(${parameters(ts, sf, node.parameters)})=>${returnType(ts, sf, node)}`, + FN, + ); + } + if (ts.isConditionalTypeNode(node)) { + return done( + `${slot(node.checkType, UNION)} extends ${slot(node.extendsType, UNION)}?${slot(node.trueType, UNION)}:${slot(node.falseType, UNION)}`, + FN, + ); + } + if (ts.isArrayTypeNode(node)) { + return done(`${slot(node.elementType, POSTFIX)}[]`, POSTFIX); + } + if (ts.isIndexedAccessTypeNode(node)) { + return done( + `${slot(node.objectType, POSTFIX)}[${slot(node.indexType, UNION)}]`, + POSTFIX, + ); + } + if (ts.isTypeOperatorNode(node)) { + // `readonly` is set aside. + if (node.operator === K.ReadonlyKeyword) return print(ts, sf, node.type); + const word = node.operator === K.KeyOfKeyword ? 'keyof' : 'unique'; + return done(`${word} ${slot(node.type, OPERATOR)}`, OPERATOR); + } + if (ts.isInferTypeNode(node)) { + const parameter = node.typeParameter; + return parameter.constraint + ? done( + `infer ${parameter.name.text} extends ${slot(parameter.constraint, UNION)}`, + FN, + ) + : done(`infer ${parameter.name.text}`, OPERATOR); + } + if (ts.isTypeReferenceNode(node)) { + const name = entityName(ts, node.typeName, true); + const args = node.typeArguments ?? []; + if (args.length === 1 && (name === 'Array' || name === 'ReadonlyArray')) { + return done(`${slot(args[0], POSTFIX)}[]`, POSTFIX); + } + // `Readonly` is set aside: it prints as `T`, grouped where `T` is. + if (args.length === 1 && name === 'Readonly') return print(ts, sf, args[0]); + return done(name + typeArguments(ts, sf, args)); + } + if (ts.isTypeQueryNode(node)) { + return done( + `typeof ${entityName(ts, node.exprName, false)}${typeArguments(ts, sf, node.typeArguments ?? [])}`, + ); + } + if (ts.isImportTypeNode(node)) { + const literal = node.argument?.literal; + // Import attributes change which file a module resolves to: kept as + // written. + if ( + !literal || + !ts.isStringLiteral(literal) || + node.attributes || + node.assertions + ) { + return kept(); + } + const qualifier = node.qualifier + ? `.${entityName(ts, node.qualifier, false)}` + : ''; + return done( + `${node.isTypeOf ? 'typeof ' : ''}import(${quote(literal.text)})${qualifier}${typeArguments(ts, sf, node.typeArguments ?? [])}`, + ); + } + if (ts.isTupleTypeNode(node)) { + return done( + `[${node.elements.map((/** @type {any} */ e) => tupleElement(ts, sf, e)).join(',')}]`, + ); + } + if (ts.isTypeLiteralNode(node)) return done(typeLiteral(ts, sf, node)); + if (ts.isMappedTypeNode(node)) { + const parameter = node.typeParameter; + const as = node.nameType ? ` as ${slot(node.nameType, UNION)}` : ''; + const question = node.questionToken + ? node.questionToken.kind === K.MinusToken + ? '-?' + : '?' + : ''; + const value = node.type ? `:${slot(node.type, UNION)}` : ''; + return done( + `{[${parameter.name.text} in ${slot(parameter.constraint, UNION)}${as}]${question}${value}}`, + ); + } + if (ts.isLiteralTypeNode(node)) return literal(ts, node.literal) ?? kept(); + if (ts.isTemplateLiteralTypeNode(node)) { + /** @type {(text: string) => string} */ + const raw = text => text.replace(/\\/g, '\\\\').replace(/`|\$\{/g, '\\$&'); + return done( + '`' + + raw(node.head.text) + + node.templateSpans + .map( + (/** @type {any} */ span) => + `\${${slot(span.type, UNION)}}${raw(span.literal.text)}`, + ) + .join('') + + '`', + ); + } + if (node.kind === K.ThisType) return done('this'); + if ( + (node.kind >= K.FirstKeyword && node.kind <= K.LastKeyword) || + node.kind === K.NullKeyword + ) { + const text = node.getText(sf); + return /^[a-z]+$/.test(text) ? done(text) : kept(); + } + return kept(); +} + +/** + * A parenthesized type's inner type, at any depth. + * @param {any} ts + * @param {any} node + */ +function unwrap(ts, node) { + let inner = node; + while (ts.isParenthesizedTypeNode(inner)) inner = inner.type; + return inner; +} + +/** + * A union member as it prints among others: a function or conditional type + * in parentheses, so joining members with `|` cannot read as one function. + * @param {Printed} part + */ +function display(part) { + return part.level < INTERSECTION ? `(${part.text})` : part.text; +} + +/** + * A union's members, canonical, merged and sorted. + * @param {any} ts + * @param {any} sf + * @param {any} node a union, or any type (one member) + * @param {boolean} optional drop `undefined` + * @returns {Printed[]} + */ +function unionParts(ts, sf, node, optional) { + /** @type {Printed[]} */ + const members = []; + const visit = (/** @type {any} */ n) => { + const inner = unwrap(ts, n); + if (ts.isUnionTypeNode(inner)) { + inner.types.forEach(visit); + return; + } + // `Readonly` and `readonly (A | B)` are `A | B`. + if ( + ts.isTypeReferenceNode(inner) && + inner.typeArguments?.length === 1 && + entityName(ts, inner.typeName, true) === 'Readonly' + ) { + visit(inner.typeArguments[0]); + return; + } + if ( + ts.isTypeOperatorNode(inner) && + inner.operator === ts.SyntaxKind.ReadonlyKeyword + ) { + visit(inner.type); + return; + } + members.push(print(ts, sf, inner)); + }; + visit(node); + const texts = new Set(members.map(display)); + /** @type {Set} */ + const absorbed = new Set( + ['string', 'number', 'boolean'].filter(t => texts.has(t)), + ); + if (texts.has('true') && texts.has('false')) absorbed.add('boolean'); + /** @type {Map} */ + const out = new Map(); + for (const m of members) { + if (m.literal && m.literal !== 'other' && absorbed.has(m.literal)) continue; + out.set(display(m), m); + } + if (absorbed.has('boolean')) { + out.set('boolean', {text: 'boolean', level: PRIMARY}); + } + if (out.size > 1) out.delete('never'); + if (optional) out.delete('undefined'); + return [...out.keys()].sort().map(k => /** @type {Printed} */ (out.get(k))); +} + +/** + * A literal type's text: strings single-quoted, numbers by value. + * @param {any} ts + * @param {any} literal + * @returns {Printed | null} + */ +function literal(ts, literal) { + const K = ts.SyntaxKind; + if ( + ts.isStringLiteral(literal) || + literal.kind === K.NoSubstitutionTemplateLiteral + ) { + return {text: quote(literal.text), level: PRIMARY, literal: 'string'}; + } + if (ts.isNumericLiteral(literal)) { + const value = Number(literal.text); + return Number.isFinite(value) + ? {text: String(value), level: PRIMARY, literal: 'number'} + : null; + } + if ( + ts.isPrefixUnaryExpression(literal) && + literal.operator === K.MinusToken && + ts.isNumericLiteral(literal.operand) + ) { + const value = -Number(literal.operand.text); + return Number.isFinite(value) + ? {text: String(value), level: PRIMARY, literal: 'number'} + : null; + } + if (literal.kind === K.TrueKeyword || literal.kind === K.FalseKeyword) { + return { + text: literal.kind === K.TrueKeyword ? 'true' : 'false', + level: PRIMARY, + literal: 'boolean', + }; + } + if (literal.kind === K.NullKeyword) return {text: 'null', level: PRIMARY}; + return null; +} + +/** + * An entity name, `React.` dropped where asked. + * @param {any} ts + * @param {any} name + * @param {boolean} dropReact + * @returns {string} + */ +function entityName(ts, name, dropReact) { + if (ts.isIdentifier(name)) return name.text; + if (ts.isQualifiedName(name)) { + const left = entityName(ts, name.left, false); + return dropReact && left === 'React' + ? name.right.text + : `${dropReact ? entityName(ts, name.left, true) : left}.${name.right.text}`; + } + return compact(name.getText()); +} + +/** + * @param {any} ts + * @param {any} sf + * @param {readonly any[]} args + */ +function typeArguments(ts, sf, args) { + return args.length === 0 + ? '' + : `<${args.map(a => at(ts, sf, a, UNION)).join(',')}>`; +} + +/** + * Print a child, in parentheses when it binds more loosely than the slot. + * @param {any} ts + * @param {any} sf + * @param {any} node + * @param {number} level + */ +function at(ts, sf, node, level) { + const printed = print(ts, sf, node); + return printed.level < level ? `(${printed.text})` : printed.text; +} + +/** + * @param {any} ts + * @param {any} sf + * @param {any} node a function-like node + */ +function typeParameters(ts, sf, node) { + const list = node.typeParameters ?? []; + if (list.length === 0) return ''; + return `<${list + .map( + (/** @type {any} */ p) => + p.name.text + + (p.constraint ? ` extends ${at(ts, sf, p.constraint, UNION)}` : '') + + (p.default ? `=${at(ts, sf, p.default, UNION)}` : ''), + ) + .join(',')}>`; +} + +/** + * A parameter list without names: a `this` parameter keeps its name. + * @param {any} ts + * @param {any} sf + * @param {readonly any[]} list + */ +function parameters(ts, sf, list) { + return list + .map(p => { + const type = p.type ? at(ts, sf, p.type, UNION) : 'any'; + const self = + ts.isIdentifier(p.name) && p.name.text === 'this' ? 'this:' : ''; + return `${p.dotDotDotToken ? '...' : ''}${self}${type}${p.questionToken ? '?' : ''}`; + }) + .join(','); +} + +/** + * A function's return type; a type predicate names its parameter by place. + * @param {any} ts + * @param {any} sf + * @param {any} node a function-like node + */ +function returnType(ts, sf, node) { + const type = node.type; + if (!type) return 'any'; + if (!ts.isTypePredicateNode(type)) return print(ts, sf, type).text; + const name = type.parameterName; + const place = ts.isIdentifier(name) + ? node.parameters.findIndex( + (/** @type {any} */ p) => + ts.isIdentifier(p.name) && p.name.text === name.text, + ) + : -1; + const subject = ts.isIdentifier(name) + ? place === -1 + ? name.text + : `#${place}` + : 'this'; + const asserts = type.assertsModifier ? 'asserts ' : ''; + return `${asserts}${subject}${type.type ? ` is ${at(ts, sf, type.type, UNION)}` : ''}`; +} + +/** + * A tuple element without its name. + * @param {any} ts + * @param {any} sf + * @param {any} element + */ +function tupleElement(ts, sf, element) { + if (ts.isNamedTupleMember(element)) { + return `${element.dotDotDotToken ? '...' : ''}${at(ts, sf, element.type, element.dotDotDotToken || element.questionToken ? POSTFIX : UNION)}${element.questionToken ? '?' : ''}`; + } + if (ts.isOptionalTypeNode(element)) + return `${at(ts, sf, element.type, POSTFIX)}?`; + if (ts.isRestTypeNode(element)) + return `...${at(ts, sf, element.type, POSTFIX)}`; + return at(ts, sf, element, UNION); +} + +/** + * An object type: properties sorted, everything else in written order. + * @param {any} ts + * @param {any} sf + * @param {any} node + */ +function typeLiteral(ts, sf, node) { + /** @type {string[]} */ + const properties = []; + /** @type {string[]} */ + const others = []; + for (const member of node.members) { + const optional = member.questionToken ? '?' : ''; + if (ts.isPropertySignature(member)) { + const type = member.type ? at(ts, sf, member.type, UNION) : 'any'; + properties.push(`${propertyName(ts, member.name)}${optional}:${type}`); + } else if (ts.isMethodSignature(member)) { + others.push( + `${propertyName(ts, member.name)}${optional}${typeParameters(ts, sf, member)}(${parameters(ts, sf, member.parameters)}):${returnType(ts, sf, member)}`, + ); + } else if (ts.isCallSignatureDeclaration(member)) { + others.push( + `${typeParameters(ts, sf, member)}(${parameters(ts, sf, member.parameters)}):${returnType(ts, sf, member)}`, + ); + } else if (ts.isConstructSignatureDeclaration(member)) { + // `new ` with a space: no method name prints that way, so a method + // named `'new'` reads apart. + others.push( + `new ${typeParameters(ts, sf, member)}(${parameters(ts, sf, member.parameters)}):${returnType(ts, sf, member)}`, + ); + } else if (ts.isIndexSignatureDeclaration(member)) { + const key = member.parameters[0]?.type + ? at(ts, sf, member.parameters[0].type, UNION) + : 'any'; + // An index signature's key reads apart from a computed property name. + others.push( + `[_:${key}]:${member.type ? at(ts, sf, member.type, UNION) : 'any'}`, + ); + } else { + // An accessor, as written, without the `;` or `,` that ends it. + others.push(compact(member.getText(sf)).replace(/[;,]$/, '')); + } + } + return `{${[...properties.sort(), ...others].join(';')}}`; +} + +/** + * A property name: a quoted name that is an identifier reads as one. + * @param {any} ts + * @param {any} name + */ +function propertyName(ts, name) { + if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name)) return name.text; + if (ts.isStringLiteral(name)) { + return /^[A-Za-z_$][\w$]*$/.test(name.text) ? name.text : quote(name.text); + } + if (ts.isNumericLiteral(name)) return String(Number(name.text)); + return compact(name.getText()); +} + +/** + * Canonical type text: the members of the type's top-level union, sorted. + * The result is cached and frozen, so no caller can change it for the next. + * @param {string} text + * @param {{ts: any, optional?: boolean}} options `ts` is the project's own + * `typescript`; with `optional`, `undefined` is dropped, as an optional + * prop takes it anyway + * @returns {readonly string[]} + */ +export function normalizeTypeText(text, options) { + const {ts, optional = false} = options; + let cache = CACHE.get(ts); + if (!cache) { + cache = new Map(); + CACHE.set(ts, cache); + } + const key = `${optional ? 1 : 0}${text}`; + const hit = cache.get(key); + if (hit) return hit; + const source = String(text); + const sf = ts.createSourceFile( + 'type-text.ts', + `type T = ${source}\n;`, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const [statement] = sf.statements; + // `Foo<>`, `Foo`, `typeof Foo<>` and JSDoc's forms (`Array.`, + // `?string`, `*`) parse, but only the checker rejects them, as it does a + // mapped type with members, a parameter with a modifier or an initializer, + // a member with a modifier other than `readonly`, a property with an + // initializer, an `in` or `out` type parameter, and an index signature + // without one plain, typed parameter and a type. A comment before the `<` + // is not a `.`. + /** @type {(node: any) => boolean} */ + const invalid = node => { + const args = node.typeArguments; + const name = node.typeName ?? node.exprName ?? node.qualifier; + const between = + args && name + ? sf.text + .slice(name.end, args.pos) + .replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '') + : ''; + return ( + (node.kind >= ts.SyntaxKind.FirstJSDocNode && + node.kind <= ts.SyntaxKind.LastJSDocNode) || + (!!args && + (args.length === 0 || + args.hasTrailingComma || + between.includes('.'))) || + (ts.isPropertySignature(node) && !!node.initializer) || + (ts.isTypeParameterDeclaration(node) && + (node.modifiers ?? []).some( + (/** @type {any} */ m) => + m.kind === ts.SyntaxKind.InKeyword || + m.kind === ts.SyntaxKind.OutKeyword, + )) || + (ts.isIndexSignatureDeclaration(node) && + (node.parameters.length !== 1 || + !node.type || + node.parameters.some( + (/** @type {any} */ p) => + p.questionToken || p.dotDotDotToken || !p.type, + ))) || + (ts.isMappedTypeNode(node) && !!node.members?.length) || + (ts.isParameter(node) && + !!(node.initializer || node.modifiers?.length)) || + (ts.isTypeElement(node) && + (node.modifiers ?? []).some( + (/** @type {any} */ m) => m.kind !== ts.SyntaxKind.ReadonlyKeyword, + )) || + !!ts.forEachChild( + node, + (/** @type {any} */ child) => invalid(child) || undefined, + ) + ); + }; + const result = Object.freeze( + sf.statements.length === 1 && + ts.isTypeAliasDeclaration(statement) && + (sf.parseDiagnostics ?? []).length === 0 && + !invalid(statement.type) + ? unionParts(ts, sf, statement.type, optional).map(display) + : [`${UNPARSED}${compact(source)}`], + ); + cache.set(key, result); + return result; +} diff --git a/packages/cli/foundation/discovery/type-text.test.mjs b/packages/cli/foundation/discovery/type-text.test.mjs new file mode 100644 index 0000000000000..4b3b53e83bb41 --- /dev/null +++ b/packages/cli/foundation/discovery/type-text.test.mjs @@ -0,0 +1,196 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @file The type-text normalization the prop type judgment compares by. */ + +import {createRequire} from 'node:module'; +import {describe, expect, it} from 'vitest'; +import { + normalizeTypeText as normalize, + typeTextTooOld, + UNPARSED, +} from './type-text.mjs'; + +const ts = createRequire(import.meta.url)('typescript'); +/** @type {(text: string, options?: {optional?: boolean}) => readonly string[]} */ +const normalizeTypeText = (text, options = {}) => + normalize(text, {...options, ts}); + +describe('normalizeTypeText', () => { + it.each([ + ["'b' | 'a'", '"a" | "b"'], + ['React.ReactNode', 'ReactNode'], + ['Array', 'string[]'], + ['ReadonlyArray<"a" | "b">', "('a' | 'b')[]"], + ['readonly string[]', 'string[]'], + ['Readonly', 'Status'], + ['true | false', 'boolean'], + ["'sm' | string", 'string'], + ['(e: MouseEvent) => void', '(event: MouseEvent) => void'], + ['{b: number; a?: string}', '{ a?: string, b: number }'], + ['{ /** doc */ a: string; }', '{a: string}'], + ['{get /*c*/ a(): string}', '{get a(): string}'], + ['{get a(): string; b: number}', '{b: number, get a(): string}'], + ['{set a(v: string), b: number}', '{set a(v: string); b: number}'], + ['{a: string, b: number}', '{a: string; b: number}'], + ['"a"|"b"', "'a' | 'b'"], + ['string | never', 'string'], + ['Array<() => void>', '(() => void)[]'], + [ + 'Array<(value: string) => string | undefined>', + '((v: string) => string | undefined)[]', + ], + ['readonly ("a" | "b")[]', "ReadonlyArray<'a' | 'b'>"], + ['readonly [string, number]', '[string, number]'], + ['(() => void) | null', 'null | (() => void)'], + ['() => (string | null)', '() => null | string'], + ['(this: Window) => void', '(this: Window) => void'], + ['(a: X, b: X) => a is T', '(c: X, d: X) => c is T'], + ['Readonly[]', '(A & B)[]'], + ['Readonly<{a: string; b: number}>', '{b: number; a: string}'], + ['Readonly>', 'Record'], + ["(v: unknown) => v is 'x'", "(value: unknown) => value is 'x'"], + ['(a: A, b?: B) => void', '(x: A, y?: B) => void'], + ['[a: string, b?: number]', '[string, number?]'], + ["{'a': string}", '{a: string}'], + ['1e3 | 1000', '1000'], + ['T extends string ? (() => B) : C', 'T extends string ? () => B : C'], + ['(A | B) | C', 'C | B | A'], + ])('reads %s as %s', (a, b) => { + expect(normalizeTypeText(a)).toEqual(normalizeTypeText(b)); + }); + + it('returns a result no caller can change for the next', () => { + const first = normalizeTypeText("'b' | 'a'"); + expect(() => /** @type {string[]} */ (first).push('c')).toThrow(); + expect(normalizeTypeText("'b' | 'a'")).toEqual(["'a'", "'b'"]); + }); + + it('drops undefined only on an optional prop', () => { + expect(normalizeTypeText('string | undefined', {optional: true})).toEqual([ + 'string', + ]); + expect(normalizeTypeText('string | undefined')).toEqual([ + 'string', + 'undefined', + ]); + }); + + it.each([ + ["'a' | 'b'", "'a'"], + ['(a: string) => void', '(a: number) => void'], + // An array of callbacks, and a callback that returns an array. + ['(() => void)[]', '() => void[]'], + [ + '((value: string) => string | undefined)[]', + '(value: string) => string | undefined[]', + ], + ['Array<() => void>', '() => void[]'], + // An array of an intersection, and an intersection with an array. + ['(A & B)[]', 'A & B[]'], + ['Array', 'A & B[]'], + ['(keyof T)[]', 'keyof T[]'], + ['keyof (A & B)', 'keyof A & B'], + // `never` that is a whole type, not a union member. + ['(x: never) => void', '() => void'], + ['[never]', '[]'], + ['Array', '[]'], + // String literals as written. + ["{a: 'x;y'}", "{a: 'x,y'}"], + ["'readonly x'", "'x'"], + ["'React.Node'", "'Node'"], + // A union around a function, and one in its return. + ['(() => ReactNode) | null', '() => ReactNode | null'], + ['(() => string) | undefined', '() => string | undefined'], + ['Array<(() => ReactNode) | null>', 'Array<() => ReactNode | null>'], + ['{f: (() => ReactNode) | null}', '{f: () => ReactNode | null}'], + // `this` is no ordinary parameter. + ['(this: Window, e: Event) => void', '(w: Window, e: Event) => void'], + // A conditional's false branch takes the rest of a union. + ['T extends string ? A : B | C', '(T extends string ? A : B) | C'], + // An array of `Readonly` is no intersection with an array. + ['Readonly[]', 'A & B[]'], + // `keyof` keeps its operand apart from the name after it. + ['Array', 'keyof (Array)'], + // A type predicate names a parameter by its place. + ['(a: X, b: X) => a is T', '(b: X, a: X) => a is T'], + [ + "(a: unknown, b: unknown) => a is 'x'", + "(b: unknown, a: unknown) => a is 'x'", + ], + // A function in a conditional's true branch returns only its own type. + [ + 'T extends string ? () => B : C | A', + 'T extends string ? () => A | B : C', + ], + // `Readonly<…>` keeps its argument grouped. + ['Readonly & C', 'A | (B & C)'], + ['keyof Readonly', '(keyof A) & B'], + // Overloads keep their order; an intersection's members keep theirs. + ['{(a: string): A; (a: number): B}', '{(a: number): B; (a: string): A}'], + // Text that does not parse is kept as written. + ['string?', 'string'], + // Text spelled as the printer prints a type, but that does not parse. + ['((Item[]) => void)[]', '((items: Item[]) => void)[]'], + // A computed property name, and an index signature. + ['{[K]: number}', '{[k: K]: number}'], + // A method named `'new'`, and a construct signature. + ["{'new'(): X}", '{new(): X}'], + // A comment between two words separates them, as a space does. + ['{get/*c*/a(): string}', '{geta(): string}'], + // Import attributes change which file a module resolves to. + [ + "import('dual', {with: {'resolution-mode': 'import'}}).T", + "import('dual', {with: {'resolution-mode': 'require'}}).T", + ], + ])('keeps %s and %s different', (a, b) => { + expect(normalizeTypeText(a)).not.toEqual(normalizeTypeText(b)); + }); + + it('marks text that does not parse, and names a TypeScript too old to read types with', () => { + expect(normalizeTypeText('((Item[]) => void)[]')).toEqual([ + `${UNPARSED}((Item[])=>void)[]`, + ]); + // Forms only the checker rejects. + for (const text of [ + 'Foo<>', + 'Array.', + '{a: Map<>}', + 'typeof Foo<>', + '?string', + 'string?', + '*', + 'function(string): void', + '{[K in keyof P]: P[K]; extra: string}', + '{public a: string}', + '(public a: string) => void', + '(a: string = "x") => void', + '(x: T) => void', + '{[k?: string]: number}', + '{[a: string, b: string]: number}', + "{a: string = 'x'}", + 'Foo', + ]) { + expect(normalizeTypeText(text)[0].startsWith(UNPARSED)).toBe(true); + } + expect(normalizeTypeText('Array /* v1.2 */ ')).toEqual([ + 'string[]', + ]); + expect(typeTextTooOld({version: '4.7.4'})).toBe( + 'TypeScript 4.7.4 is older than 4.8, the oldest the type comparison reads', + ); + for (const version of ['4.8.4', '5.0.4', '6.0.3', ts.version]) { + expect(typeTextTooOld({version})).toBeNull(); + } + }); + + it('keeps a union around a function apart from one in its return, joined as the members are', () => { + for (const [a, b] of [ + ['(() => ReactNode) | null', '() => ReactNode | null'], + ['(T extends string ? A : B) | C', 'T extends string ? A : B | C'], + ]) { + expect(normalizeTypeText(a).join('|')).not.toBe( + normalizeTypeText(b).join('|'), + ); + } + }); +});