From cc611f480fc732fc2763ef2aff5dc836c16e295d Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Sun, 18 Oct 2026 07:10:16 -0700 Subject: [PATCH 1/2] feat(cli): read two written TypeScript types as one when they differ only in how they are written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `normalizeTypeText(text, {ts, optional})` parses type text with the project's own TypeScript and prints the type canonically, so grouping always comes from the parse. It merges only what TypeScript reads as one type: comments, whitespace, needless parentheses and quote style; function and tuple parameter names (a `this` parameter is kept, and a type predicate names its parameter by place); the order of union members and object properties; `Array` and `T[]`; a `React.` qualifier; `true | false` and `boolean`; a literal beside its primitive; `never` among other union members; and `undefined` on an optional prop. It sets `readonly`, `Readonly<…>` and `ReadonlyArray<…>` aside. Below the top of a type, a function or conditional type always prints in parentheses. Text that does not parse comes back marked, in a form no printed type takes, so it never matches; so does `Foo<>`, `Foo`, `typeof Foo<>`, a JSDoc form (`Array.`, `?string`, `*`), 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, or an index signature without one plain, typed parameter and a type, which parse but which only the checker rejects (a comment before the `<` is no `.`). An import type with attributes, and any form the printer does not know, is kept as written, with a comment between two words read as the space it stands for (`get/*c*/a` is `get a`, not `geta`), and an accessor without the `;` or `,` that ends it. `typeTextTooOld` names a project TypeScript older than 4.8, the oldest every printed form is tested on, so a caller can skip the comparison instead of failing. Moved from the prop types PR, which uses it. The result is cached per TypeScript and frozen, so a caller that changes it cannot change the next caller's. --- .changeset/doctor-type-text.md | 7 + .../cli/foundation/discovery/type-text.mjs | 658 ++++++++++++++++++ .../foundation/discovery/type-text.test.mjs | 196 ++++++ 3 files changed, 861 insertions(+) create mode 100644 .changeset/doctor-type-text.md create mode 100644 packages/cli/foundation/discovery/type-text.mjs create mode 100644 packages/cli/foundation/discovery/type-text.test.mjs diff --git a/.changeset/doctor-type-text.md b/.changeset/doctor-type-text.md new file mode 100644 index 0000000000000..e22315c6992df --- /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). Nothing reaches it yet, so the CLI's behavior and output do not change. + +@josephfarina 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('|'), + ); + } + }); +}); From ed2836420ad28208bce6683ded555d2a2e360ab0 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Mon, 19 Oct 2026 10:52:17 -0700 Subject: [PATCH 2/2] feat(cli): judge a documented prop type against the type the code declares `judgeType(docType, codeTypes, ctx)` judges a documented type only where both sides read exactly: the same text once type-text.mjs normalizes both, or the same exact parts (primitives, literals, string enum members of their own enum, arrays of these, unions of these, and names that resolve to them where the prop is declared), in any order, as TypeScript reduces a union. A mutable array type matches a readonly one, since callers can pass it; a readonly one documented for a mutable prop is a mismatch. A string literal is one part, quoted and escaped, so no text inside it reads as other parts (`"a'|'b"` is not `'a' | 'b'`, and `'readonly'` is a word, not the modifier), and a string enum member beside the literal of its own value adds nothing, as TypeScript passes the member where that literal is taken. `React.X` resolves through `React` where the prop is declared, as the code reads it; where a bare `X` there names another type (the DOM's `KeyboardEvent`, a file's own `Key`), the two are never read as one, though the text reading sets the qualifier aside. A union member another member covers adds nothing, as TypeScript passes it there: `undefined` beside `void`, an array beside `object`, and an array whose element types another array also takes (a readonly array covers a mutable one, not the reverse). An enum part encodes its value by code point, so no string, an unpaired surrogate included, fails to encode. Every other form is not compared, never reported as a mismatch. A doc may spell an alias out as `Name = expansion`; one value after it is a default, and only the name is judged. Moved here from the prop types PR, which wires it into the comparison, so that PR stays under 2,000 changed lines. Nothing reaches it yet. --- .changeset/doctor-type-text.md | 2 +- .../cli/foundation/discovery/type-match.mjs | 660 ++++++++++++++++++ .../foundation/discovery/type-match.test.mjs | 169 +++++ 3 files changed, 830 insertions(+), 1 deletion(-) create mode 100644 packages/cli/foundation/discovery/type-match.mjs create mode 100644 packages/cli/foundation/discovery/type-match.test.mjs diff --git a/.changeset/doctor-type-text.md b/.changeset/doctor-type-text.md index e22315c6992df..ad366f5863961 100644 --- a/.changeset/doctor-type-text.md +++ b/.changeset/doctor-type-text.md @@ -2,6 +2,6 @@ '@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). Nothing reaches it yet, so the CLI's behavior and output do not change. +[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', + ]); + }); +});