Repository navigation
feat(cli): read two written TypeScript types as one when they differ only in how they are written - #7684
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
PR Analysis Report📚 Storybook Preview🧪 Sandbox PreviewNo new or modified components detected. Bundle Size SummaryNo component packages changed. Accessibility AuditStatus: No accessibility violations detected. Visual Regression40 of 482 shot(s) changed. A change here is a question, not a failure: check whether the after is the
and 20 more. Generated by PR Enrichment workflow | Storybook | Sandbox | View full report |
There was a problem hiding this comment.
Changes requested: normalizeTypeText currently reads some different types as the same type, which would let the stacked prop-type check (#7667) report a wrong documented type as matching the code. A one-member parenthesized group loses its parentheses, so (() => void)[] and () => void[] normalize identically (as does TypeScript's own print ((value: string) => string | undefined)[] against (value: string) => string | undefined[]), and so do (A & B)[] / A & B[] and (keyof T)[] / keyof T[]; never is also dropped outside unions, and ;, React. and readonly are rewritten inside string literals. Please keep the parentheses when the grouped member is not a primary type (and apply the same check in arrayOf), drop never only from unions with other members, leave string literals untouched, and add these pairs to the "keeps different types different" test. The same change should make Array<() => void> match (() => void)[], and readonly ("a" | "b")[] should be set aside like readonly string[].
[Automated review]
| const members = unionMembers(inner, false); | ||
| out += | ||
| members.length > 1 ? `(${members.join('|')})` : (members[0] ?? '()'); |
There was a problem hiding this comment.
A one-member group loses its parentheses here, so (() => void)[] and () => void[] (an array of callbacks and a callback returning an array) both normalize to ()=>void[]; (A & B)[] / A & B[] and (keyof T)[] / keyof T[] collapse the same way. TypeScript itself prints Array<(value: string) => string | undefined> as ((value: string) => string | undefined)[], so real inputs take this path. Keep the parentheses when the member is not a primary type (a top-level =>, & or conditional, or a leading keyof, typeof, unique or infer).
There was a problem hiding this comment.
Fixed in 1854a0a. A one-member group keeps its parentheses when the member isn't a primary type: a top-level =>, &, | or conditional, or a leading keyof, typeof, unique or infer. (() => void)[] and () => void[], the same pair as TypeScript prints it, (A & B)[] and A & B[], (keyof T)[] and keyof T[], and keyof (A & B) and keyof A & B stay different. They're in the 'keeps different' cases, which failed before.
| /** @param {string} args already normalized */ | ||
| function arrayOf(args) { | ||
| const top = splitTop(args, '|'); | ||
| return top.length > 1 || args.includes('=>') ? `(${args})[]` : `${args}[]`; |
There was a problem hiding this comment.
arrayOf should use the same test as the group branch: Array<A & B> normalizes to A&B[], which reads as A & (B[]), and Array<() => void> keeps its parentheses here while (() => void)[] loses them in the group branch, so those two spellings of one type compare different.
There was a problem hiding this comment.
Fixed in 1854a0a. arrayOf uses the same primary-type test as the group branch. Array<A & B> reads as (A&B)[], and Array<() => void> now matches (() => void)[]. An object type containing a callback (Array<{f: () => void}>) no longer gains parentheses it doesn't need. Both pairs are in the tests.
| members.add(normalizeMember(inner)); | ||
| }; | ||
| for (const part of splitTop(text.replace(/^\|/, ''), '|', true)) visit(part); | ||
| members.delete('never'); |
There was a problem hiding this comment.
This runs for every nested list too (parameters, tuple members, generic arguments), where never is the whole type rather than a union member: (x: never) => void normalizes like () => void, and [never] and Array<never> like []. Drop never only when the union has other members.
There was a problem hiding this comment.
Fixed in 1854a0a. never is dropped only from a union that has other members. (x: never) => void, [never] and Array<never> keep it, and the three are in the 'keeps different' cases. string | never still reads as string.
| * @param {string} inner | ||
| */ | ||
| function listMembers(inner) { | ||
| return splitTop(inner.replace(/;/g, ','), ',') |
There was a problem hiding this comment.
This replace (and the one on line 290, and the React. / readonly replace on line 250) also rewrites string-literal contents: {a: 'x;y'} normalizes like {a: 'x,y'}, and 'readonly x' like 'x'. Skip quoted text when rewriting.
There was a problem hiding this comment.
Fixed in 1854a0a. The ; rewrite in object and list members, the React. strip, and every readonly strip now apply only outside string literals. {a: 'x;y'} and {a: 'x,y'}, 'readonly x' and 'x', and 'React.Node' and 'Node' stay different; all three are in the tests.
| * @returns {string} | ||
| */ | ||
| function normalizeMember(text) { | ||
| let rest = text.replace(/^readonly /, ''); |
There was a problem hiding this comment.
Non-blocking: compact removes the space in readonly ( and readonly [, so this strip and the one on line 250 miss them. readonly ("a" | "b")[], which is how TypeScript prints ReadonlyArray<'a' | 'b'>, stays different from ReadonlyArray<'a' | 'b'>, although the header says readonly is ignored.
There was a problem hiding this comment.
Fixed in 1854a0a. readonly is set aside before a group or a tuple as well as before a word. readonly ("a" | "b")[] reads as ReadonlyArray<'a' | 'b'> does, and readonly [string, number] as [string, number]. Both pairs are in the 'reads as one' cases.
e09f815 to
4be2ee3
Compare
0bd0306 to
1854a0a
Compare
There was a problem hiding this comment.
No blocking issues remain: the conflations raised earlier (grouped callbacks, intersections and keyof before [], never outside unions, rewrites inside string literals) are fixed and covered by the new regression tests, and the module adds no public API or behavior. Three edge cases still read different types as one; none blocks this PR, and each is a small follow-up here or in #7667:
- The
Readonly<…>branch skips theprimarycheck, soReadonly<A & B>[]normalizes likeA & B[]. - A redundant group after
keyofloses its space, soArray<keyof (B)>andkeyof (Array<B>)both becomekeyofB[]. - Parameter names are dropped but a type predicate keeps its name, so
(a: X, b: X) => a is Tequals(b: X, a: X) => a is T.
[Automated review]
| if (name === 'Array' || name === 'ReadonlyArray') { | ||
| out = out.slice(0, -name.length) + arrayOf(args); | ||
| } else if (name === 'Readonly' && !args.includes(',')) { | ||
| out = out.slice(0, -name.length) + args; |
There was a problem hiding this comment.
Non-blocking: this splice skips the primary check that the group branch and arrayOf use, so Readonly<A & B>[] normalizes to A&B[], the same as A & B[], and {f: Readonly<A | B>[]} equals {f: A | B[]}. #7667 keeps Readonly< text out of its text match, so nothing reaches this today. Wrapping args in parentheses unless primary(args) fixes it, and checking splitTop(args, ',').length === 1 instead of !args.includes(',') would let Readonly<{a: string; b: number}> and Readonly<Record<string, string>> read as their argument, as the header says.
There was a problem hiding this comment.
Fixed in 921a37d. Readonly<…> keeps parentheses around an argument that isn't primary when [] follows, so Readonly<A & B>[] reads as (A&B)[] and {f: Readonly<A | B>[]} stays apart from {f: A | B[]}. It also unwraps whenever there's a single argument, checked with splitTop, so Readonly<{a: string; b: number}> and Readonly<Record<string, string>> read as their argument. All four pairs are in the tests; the two different pairs and the two that read as one failed before.
| const members = unionMembers(inner, false); | ||
| out += | ||
| members.length === 1 && primary(members[0]) | ||
| ? members[0] |
There was a problem hiding this comment.
Non-blocking: when the text before the group ends in a word, dropping the parentheses glues the two words. keyof (B) becomes keyofB, which primary no longer treats as a type operator, so Array<keyof (B)> and keyof (Array<B>) both normalize to keyofB[], though the checker reads (keyof B)[] and keyof B[]. Adding a space when out ends, and the member starts, with a word character would fix it.
There was a problem hiding this comment.
Fixed in 921a37d. When the parentheses are dropped and the text before them ends in a word, and the member inside starts with one, a space stays between them. keyof (B) reads as keyof B, so Array<keyof (B)> is (keyof B)[] and keyof (Array<B>) is keyof B[]. The pair is in the 'keeps different' cases.
| const [, rest = '', name = '', optional = '', type = ''] = match; | ||
| // `this` types the call, and is no parameter. | ||
| const self = name === 'this' ? 'this:' : ''; | ||
| return `${rest}${self}${optional}${normalizeTypeText(type).join('|')}`; |
There was a problem hiding this comment.
Non-blocking: the name is dropped here while the return text keeps a type predicate's name, so (a: unknown, b: unknown) => a is string and (b: unknown, a: unknown) => a is string normalize alike, though they narrow different arguments, while (v: unknown) => v is string reads apart from (value: unknown) => value is string. Rare in prop types.
There was a problem hiding this comment.
Fixed in 921a37d. A type predicate's subject is replaced by its parameter's place before the names are set aside: (a: unknown, b: unknown) => a is string reads as (unknown,unknown)=>#0 is string, and the swapped form as #1. (v: unknown) => v is string now reads like (value: unknown) => value is string. asserts x and asserts x is T work the same way, and this is T is kept. Both pairs are in the tests.
4be2ee3 to
37c168d
Compare
1854a0a to
921a37d
Compare
There was a problem hiding this comment.
Changes requested: the earlier follow-ups are fixed, but normalizeTypeText still reads two kinds of different types as one type, and both reach the stacked prop-type check in #7667, which would report a wrong documented type as a match. Please fix these and add each pair to the 'keeps different' tests:
- A function in a conditional's true branch takes the false branch into its return type, so
T extends string ? () => B : C | AandT extends string ? () => A | B : Cnormalize alike. - The predicate-by-place rewrite matches
iswith a trailing space, which compaction removes before a quote or bracket, so(a: unknown, b: unknown) => a is 'x'and(b: unknown, a: unknown) => a is 'x'still normalize alike.
As a smaller follow-up, Readonly<A | B> & C and keyof Readonly<A & B> still lose their grouping, though #7667 compares Readonly< text exactly today.
[Automated review]
| (after.startsWith('[') && !primary(args) ? `(${args})` : args); | ||
| } else out += `<${args}>`; | ||
| } else if (after.startsWith('=>')) { | ||
| const returns = predicateByPlace(inner, after.slice(2)); |
There was a problem hiding this comment.
In a conditional's true branch this takes the false branch into the return type: for T extends string ? () => B : C | A, returns is B:C|A, which is split on | and sorted, so it normalizes to (T extends string?()=>A|B:C), the same as T extends string ? () => A | B : C, though the checker reads () => B and () => A | B. Ending the return type at the conditional's : (or keeping a true-branch function parenthesized) would keep them apart.
There was a problem hiding this comment.
Fixed in 66ab2df, by rebuilding the reading on the parser. normalizeTypeText now parses the text with the project's own TypeScript and prints the tree canonically, so a function's return type is exactly what the parser gives it. Below the top of a type, a function or conditional type always prints in parentheses. T extends string ? () => B : C | A and T extends string ? () => A | B : C print as (T extends string?(()=>B):A|C) and (T extends string?(()=>A|B):C). The pair is in the 'keeps different' cases.
| */ | ||
| function predicateByPlace(params, returns) { | ||
| const match = | ||
| /^(asserts )?([\w$]+) is /.exec(returns) ?? |
There was a problem hiding this comment.
compact drops the space between is and a following quote or bracket, so a is 'x', a is {…}, a is […] and a is (…) arrive as a is'x' and skip this rewrite: (a: unknown, b: unknown) => a is 'x' and (b: unknown, a: unknown) => a is 'x' still normalize alike, and (v: unknown) => v is 'x' still reads apart from (value: unknown) => value is 'x'. Matching is(?![\w$]) instead of is would cover them.
There was a problem hiding this comment.
Fixed in 66ab2df. The predicate's subject comes from the parsed TypePredicate node, not from the text, so spacing can't hide it: (a: unknown, b: unknown) => a is 'x' prints its subject as #0 and the swapped form as #1, and (v: unknown) => v is 'x' now reads like (value: unknown) => value is 'x'. Both pairs are in the tests. Readonly<A | B> & C and keyof Readonly<A & B> keep their grouping too, since Readonly<…> now prints its argument grouped wherever it sits.
37c168d to
45fa44a
Compare
921a37d to
66ab2df
Compare
There was a problem hiding this comment.
Changes requested: the parser-based rebuild fixes the conflations raised last time, and the module adds no public API or behavior, but one pair of different types still reads as one. A method named by the string 'new' prints like a construct signature, so {'new'(): X} and {new(): X} both normalize to {new():X} although TypeScript accepts neither for the other, and the stacked prop-type check (#7667) would call such a documented type a match. Keeping a string-literal name quoted when it is new (or printing construct signatures in a form no method name can produce), with the pair in the 'keeps different' tests, would resolve it.
[Automated review]
| 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); |
There was a problem hiding this comment.
Unquoting an identifier-like name here lets a method named by the string 'new' print as new(...):R, which is exactly how a construct signature prints (line 510). So {'new'(): X} and {new(): X} both normalize to {new():X}, although TypeScript accepts neither for the other. Keeping the name quoted when it is new (or printing construct signatures in a form no method name can produce) would keep them apart; the pair belongs in the 'keeps different' tests.
There was a problem hiding this comment.
Fixed in a0067ad. A construct signature now prints as new (…), with a space. No method name prints that way: an identifier-like quoted name prints bare, and any other stays quoted. So {'new'(): X} prints {new():X} and {new(): X} prints {new ():X}. The pair is in the 'keeps different' tests.
45fa44a to
e28ccbd
Compare
2143de3 to
515eab6
Compare
bba990c to
2f822c8
Compare
There was a problem hiding this comment.
No blocking issues: normalizeTypeText is an internal helper that nothing calls yet, its 73 tests pass on TypeScript 4.8.4, 5.0.4 and 6.0.3, and checking it against the TypeScript checker found no two different valid types that it reads as one. Two optional follow-ups before #7667 relies on it:
- It returns the array it caches, so a caller that changes a result in place changes what later calls return for the same text; freezing the cached array or returning a copy would close that.
- The checker-rejected forms noted earlier (
<in T>, index signatures with an optional, rest or extra parameter, a property initializer,Foo<A,>) still read as their valid neighbour instead of coming back unparsed.
[Automated review]
| } | ||
| const key = `${optional ? 1 : 0}${text}`; | ||
| const hit = cache.get(key); | ||
| if (hit) return hit; |
There was a problem hiding this comment.
Optional, non-blocking: this returns the cached array itself (line 634 stores the same array), so a caller that changes a result in place changes what every later call returns for this text and compiler. For example, pushing 'X' into the result for 'string | number' makes the next call return ["number", "string", "X"]. Freezing result before caching it (with @returns {readonly string[]}) or returning a copy would keep the cache safe; #7667's current code only joins and spreads the results, so nothing is affected today.
515eab6 to
788903f
Compare
2f822c8 to
acb2521
Compare
There was a problem hiding this comment.
No blocking issues: the unparsed mark now covers in and out type parameters, malformed index signatures, property initializers and trailing commas in type arguments, all 73 type-text tests pass on TypeScript 4.8.4, 5.0.4 and 6.0.3, and checking this revision against the TypeScript checker found no two different valid types that normalizeTypeText reads as one. One optional follow-up remains before #7667 relies on the module: normalizeTypeText returns the array it caches, so a caller that changes a result in place changes what later calls return for the same text; freezing the cached array or returning a copy would close that.
[Automated review]
788903f to
e0bf813
Compare
acb2521 to
988b3ac
Compare
There was a problem hiding this comment.
No blocking issues: freezing the cached result closes the last open follow-up, since a caller's change now throws and leaves later results intact, and all 74 type-text tests pass on TypeScript 4.8.4, 5.0.4 and 6.0.3. Checking this revision against the TypeScript checker found no two different valid types that normalizeTypeText reads as one. Nothing else is needed here; it can merge after #7676 in the order the description gives.
[Automated review]
988b3ac to
72f3391
Compare
There was a problem hiding this comment.
No blocking issues: judgeType and normalizeTypeText add no public API or output change, all 75 of their tests pass, and checking judgeType against the TypeScript checker on 4.8.4, 5.0.4 and 6.0.3 found its verdicts right for realistic types. Two optional follow-ups in type-match.mjs, before #7667 relies on it:
- String-literal parts are built unescaped and joined with
|, so a literal containing'|'reads as two literals and"a'|'b"is judged a match for'a' | 'b'; escaping the literal text on both sides, astype-text.mjsdoes, would close it. - A string enum member beside its own value counts as an extra part, so
'a' | 'b'andE | 'a' | 'b'(E a string enum with those values) are reported as a mismatch although TypeScript takes each for the other.
[Automated review]
| ts.isStringLiteral(literal) || | ||
| ts.isNoSubstitutionTemplateLiteral(literal) | ||
| ) | ||
| return [`'${literal.text}'`]; |
There was a problem hiding this comment.
Optional: string-literal parts are built unescaped here and at line 382, and reduced joins parts with |, so a literal whose text contains '|' reads as two literals: a doc of "a'|'b" is judged a match for code typed 'a' | 'b', and the reverse, though TypeScript assigns neither to the other. Escaping \ and ' in the text on both sides, as quote in type-text.mjs does, keeps every literal's boundaries unambiguous.
There was a problem hiding this comment.
Fixed in 64f7fea: each string-literal part is now quoted with \ and ' escaped on both sides, and an enum member's value is encoded, so "a'|'b" and 'a' | 'b' are a mismatch both ways. The verdict test pins it, and it failed before.
| ); | ||
| if (kept.has('string')) { | ||
| for (const atom of [...kept]) { | ||
| if (atom.startsWith("'") || atom.startsWith('enum:')) kept.delete(atom); |
There was a problem hiding this comment.
Optional: without string in the union, a string enum member beside its own value stays a separate part, so 'a' | 'b' and E | 'a' | 'b' (E a string enum with those values) are judged a mismatch either way round, although TypeScript assigns each to the other. Dropping an enum: part whose value is also present as a plain literal, or saying in #7667's guide that such a pair is reported, would settle it.
There was a problem hiding this comment.
Fixed in 64f7fea: an enum: part whose value is also present as a plain literal is dropped, as TypeScript passes the member where that literal is taken. 'a' | 'b' and E | 'a' | 'b' now match both ways, while E | 'a' with E also holding 'b' still differs from 'a' | 'b', as tsc rejects 'b' there. The verdict test pins both directions.
| // matches only as written: the same text, whitespace aside, with | ||
| // `readonly` kept on both sides. Any other goes to the parts below. | ||
| const readonly = (/** @type {string} */ text) => | ||
| /\breadonly\b(?!\s*[:?])|\bReadonly(?:Array)?\s*</.test(text); |
There was a problem hiding this comment.
Optional: this also matches readonly inside a string literal, so a callback typed (mode: 'readonly' | 'edit') => void and documented as (mode: 'edit' | 'readonly') => void takes the exact-text path and comes back not_compared, where the same type with another word is a match. Testing the text with its string literals masked, which QUOTED_OR_SPACE already finds, would keep such types in the normalized comparison.
There was a problem hiding this comment.
Fixed in 64f7fea: the readonly test now reads the text with its string literals masked, and so does the comparison that sets readonly aside, so (mode: 'readonly' | 'edit') => void documented in the other order is a match. The verdict test pins it, and it failed before.
ec0c4fa to
bc10598
Compare
72f3391 to
64f7fea
Compare
There was a problem hiding this comment.
No blocking issues: normalizeTypeText and judgeType add no public API or output change, all 75 of their tests pass, and checking both against the TypeScript checker on 4.8.4, 5.0.4 and 6.0.3 found no false match, with the three earlier type-match.mjs follow-ups fixed. Two optional follow-ups before #7667 relies on the judge:
encodeURIComponent(t.value)(type-match.mjsline 404) throwsURIErrorfor a string enum value holding an unpaired surrogate such as'\uD800', sojudgeTypethrows instead of returning a verdict; an encoding that cannot throw, or treating such a member as not compared, would close it.- A union member another member already covers (
string[]beside(string | number)[], an array besideobject,undefinedbesidevoid) is reported as a mismatch when only one side writes it, though TypeScript treats both as one type; folding such parts, or saying so in the guide, would settle it.
[Automated review]
| const owner = decl && checker.getSymbolAtLocation(decl.parent.name); | ||
| if (!(t.flags & F.StringLiteral) || !owner) return null; | ||
| out.push( | ||
| `enum:${owner.name}#${encodeURIComponent(checker.getFullyQualifiedName(owner))}#${encodeURIComponent(t.value)}`, |
There was a problem hiding this comment.
Optional: encodeURIComponent throws URIError for a string enum value holding an unpaired surrogate, so with enum Lone { A = '\uD800', B = 'b' } judging a prop typed Lone against the doc 'b' throws instead of returning a verdict. An encoding that cannot throw, or returning null (not compared) when this one does, would keep such a type listed as unchecked.
There was a problem hiding this comment.
Fixed in 60fa1df: enum parts now encode the value (and the enum's full name) by code point, which never throws, so Lone with '\uD800' gets a verdict. The verdict test pins it; it threw before.
| } | ||
| } | ||
| } | ||
| return [...kept].sort().join('|'); |
There was a problem hiding this comment.
Optional: a part that another part already covers stays a part of its own, so string[] | (string | number)[] against (string | number)[], object | string[] against object and void | undefined against void each come back mismatch, though TypeScript assigns each pair both ways. Dropping a part another part subsumes here, or saying in #7667's guide that such a pair is reported, would settle it.
There was a problem hiding this comment.
Folded in 60fa1df, as tsc reads it: undefined beside void, an array beside object, and an array whose element types another array also takes add nothing. A readonly array covers a mutable one but not the reverse, so readonly string[] | (string | number)[] against (string | number)[] stays a mismatch (tsc rejects it). The verdict test pins each case; they were mismatches before. #7667's guide states the rule.
64f7fea to
60fa1df
Compare
There was a problem hiding this comment.
No blocking issues: normalizeTypeText and judgeType change no CLI output until #7667 wires them, all 75 of their tests pass, and checks against the TypeScript checker on 4.8.4, 5.0.4 and 6.0.3 found no false match in a strict project, with the new folding of covered members and the React.X resolution giving the checker's answer outside the two cases below. Two optional follow-ups before #7667 relies on the judge:
arrayCoversdoesn't count a string enum member as covered by its own value's literal, soE[] | ('a' | 'b')[]against('a' | 'b')[]is still a mismatch althoughtscassigns each to the other.- Without strict null checks,
nullandundefinedare dropped even besidenever, so a doc ofundefinedfor a prop typedneveris a match althoughtscrejects that value.
[Automated review]
| 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)))), | ||
| ); |
There was a problem hiding this comment.
Optional: reduced drops a string enum member beside the literal of its own value (lines 262–267), but this check doesn't apply that rule to element types. So code typed ('a' | 'b')[] against the doc E[] | ('a' | 'b')[], or 'a'[] against E.A[] | 'a'[] (with E a string enum of 'a' and 'b'), is still a mismatch, though tsc assigns each to the other. Also counting an enum: part as covered when taken holds literalPart(decodePart(value)) would close it.
| !(atom === 'undefined' && ((top && ctx.optional) || ctx.nullable)) && | ||
| !(atom === 'null' && ctx.nullable), |
There was a problem hiding this comment.
Optional: with nullable, null and undefined are dropped even where the other side is never, so a doc of undefined or null for a prop typed never (or readonly never[] | undefined[] for readonly never[]) is a match, while tsc rejects undefined for never even without strict null checks. Keeping them, or returning not_compared, wherever dropping them would leave a union or an array element empty would close it.
bc10598 to
a6638ab
Compare
…only in how they are written
`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<T>` 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<A,>`, `typeof Foo<>`, a JSDoc form
(`Array.<T>`, `?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.
…lares `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.
60fa1df to
ed28364
Compare
User need
astryx doctor integration componentsjudges each documented prop's type against the code (#7667). A doc and the source often write one type in different ways: quotes, the order of a union's members,Array<T>orT[], a callback's parameter names, aReact.qualifier. Compared as written, each of those would be reported as a mismatch where there is none.Problem and solution fit
This PR adds the reading that makes two written types compare as one type when they differ only in how they are written. It is foundation groundwork, on #7676, and #7667, stacked on it, judges prop types with it. They were one PR until fixes took #7667 past 2,000 lines, and it split along this seam:
type-text.mjsand its tests moved here unchanged. The judge that compares a documented type with the code's,judgeTypeintype-match.mjs, followed later for the same reason, also unchanged; #7667 wires it into the comparison.normalizeTypeText(text, {ts, optional})parses the text with the project's own TypeScript and prints the type canonically, so grouping always comes from the parse. It returns the members of the type's top-level union, sorted. It merges only what TypeScript reads as one type:thisparameter is kept, and a type predicate names its parameter by place;Array<T>andT[], and aReact.qualifier;true | falseandboolean, a string, number or boolean literal beside its primitive,neveramong other union members, and, withoptional,undefined.It sets
readonly,Readonly<…>andReadonlyArray<…>aside; #7667 compares text that writesreadonlyexactly instead. Below the top of a type, a function or conditional type always prints in parentheses, and a union or intersection wherever its members would otherwise bind differently. So joining the members with|can never read as one function. Text that doesn't parse as one type, an import type with attributes, and any form the printer doesn't know are kept as written (comments and whitespace aside), so none reads as another type.Current specification
Supports
architecture:cli-surfaceINV33, "Documented component props agree with the source" (AST-039 FR12, which now points to it; DEC-5), with #7681, #7663, #7676 and #7667. Its evidence row names the type-text tests.Public delta and caller burden
[chore]entry that says so.Design and alternatives
readonlyis set aside here, and feat(cli): doctor integration components judges documented prop types against the code #7667 compares text that writes it exactly.Evidence
foundation/discovery/type-text.test.mjs:React.qualifiers,Array<T>(of a callback too),ReadonlyArray<…>,readonly(before a group or a tuple too),Readonly<…>,true | false, a literal beside its primitive, parameter names, object member order and separators, comments, whitespace,neverin a union, a union around a function in either order, a parenthesized return, athisparameter, a type predicate with renamed parameters (also before a string literal), renamed optional and tuple parameters, a quoted property name, a number written two ways, a parenthesized true branch, nested unions, andReadonly<…>of an intersection array, an object type and aRecord;undefineddropped only on an optional prop;(keyof T)[]andkeyof T[],keyof (A & B)andkeyof A & B,neveras a whole type (a parameter, a tuple member, an array element), string literals that contain;,readonlyorReact., a union around a function and one in its return (alone, in an array and in an object), athisparameter and an ordinary one, a conditional whose false branch takes a union, an array ofReadonly<A & B>and an intersection with an array,Array<keyof (B)>andkeyof (Array<B>), type predicates on different parameters (also before a string literal), a function in a conditional's true branch and one that returns the rest,Readonly<A | B> & CandA | (B & C),keyof Readonly<A & B>and(keyof A) & B, overloads in another order, text that doesn't parse, a computed property name and an index signature, import types whose attributes differ, and a method named'new'beside a construct signature.foundation/discovery/type-match.test.mjs:judgeType's verdicts in one table: exact parts in any order and a name for them, matched; a literal left out, a wrong primitive, another enum of the same values, and a literal union for an enum, reported; a mutable array for a readonly one, matched, and a readonly one for a mutable array, reported;nullon both sides; a callback written as the source writes it, matched, and written another way, not compared; a string literal holding'|', not read as two; a string enum member beside its own value's literal, folded away;readonlyinside a string literal, read as a word;React.Xread through React, soReact.KeyboardEventbeside the DOM'sKeyboardEventis not a match andReact.Keyis React's even where the file declares its ownKey; a union member another covers (string[]beside(string | number)[], an array besideobject,undefinedbesidevoid), folded, while a readonly array beside a mutable one is not; an enum value holding an unpaired surrogate, judged without throwing; and prose, not compared.Since it opened
(() => void)[]read like() => void[], and the same for(A & B)[]and(keyof T)[];Array<…>had the same gap. Parentheses stay now around a member that isn't a primary type, in both places, which also makesArray<() => void>match(() => void)[].neverwas dropped outside unions, so(x: never) => voidread like() => void, and[never]andArray<never>like[]. It's dropped only from a union with other members now.;,React.andreadonlyrewrites reached inside string literals, so'x;y'read like'x,y'. Quoted text is left as written now.readonly ("a" | "b")[], TypeScript's print ofReadonlyArray<'a' | 'b'>, kept itsreadonly. It's set aside now likereadonly string[].tsc's assignability, and found three more ways it read different types as one, each fixed with a test that failed first:thisparameter lost its name like any other, so(this: Window, e: Event) => voidread like(w: Window, e: Event) => void. It keeps it now.(() => ReactNode) | nullread like() => ReactNode | null, alone and inside arrays, objects and generic arguments. A function or a conditional among other union members keeps its parentheses now.|, soC ? A : B | Dread like(C ? A : B) | D. A conditional at the top of a type is one member now, as TypeScript reads it.readonly, which feat(cli): doctor integration components judges documented prop types against the code #7667 leaves out of the text match.Readonly<A & B>[]read likeA & B[]:Readonly<…>dropped its parentheses without the check the group branch uses. It keeps them before[]now, and an object type or aRecordinsideReadonly<…>reads as itself.Array<keyof (B)>andkeyof (Array<B>)both read askeyofB[]: dropping the parentheses gluedkeyofto the name. A word before the parentheses stays apart from the word inside now.Readonly<A | B> & Candkeyof Readonly<A & B>also lost their grouping. Since each round found more of these, the reading was rebuilt on the parser, as described above, instead of patched again. The tests keep every earlier pair and add these. On Core and Lab, feat(cli): doctor integration components judges documented prop types against the code #7667 judges exactly the same types, with the same findings and messages.tscboth ways. Every pair it reads as one is the same type,readonlyaside, except two exotic ones, now fixed with tests that failed first: a computed property name read like an index signature ({[K]: number}and{[k: K]: number}), and import types whose attributes differ read alike. It also checked 34 pairs of identical types that print differently, such as intersection order and type-parameter names: each is a match or not compared, never a mismatch. All 66 tests pass.After the fourth review
'new'printed like a construct signature, so{'new'(): X}and{new(): X}read as one type. A construct signature prints asnew (…)now, with a space, which no method name can produce. The pair is in the 'keeps different' tests, and failed before. All 67 tests pass.After the fifth review
{get/*c*/a(): string}(a getter) and{geta(): string}(a method) read as one type. A comment now separates two words as a space does. The pair is in the 'keeps different' tests and failed before, and{get /*c*/ a(): string}still reads as{get a(): string}. All 69 tests pass.After the sixth review
;or,that ends it, so{get a(): string; b: number}and{get a(): string, b: number}read as different types, a false mismatch. The separator is dropped now. The getter and setter pairs are in the 'reads as' tests and failed before. All 71 tests pass.After the seventh review
((Item[]) => void)[], a parse error, normalized the same as((items: Item[]) => void)[]. Unparsed text now comes back marked withUNPARSED, a character no printed type contains, so it never equals a printed type, and feat(cli): doctor integration components judges documented prop types against the code #7667 lists such a doc as not compared. So doesFoo<>or JSDoc'sArray.<T>, which parse but which only the checker rejects. The pair is in the 'keeps different' tests, the other forms in a new test, and they failed before.typeTextTooOld(ts)now names a project TypeScript older than 4.8, the oldest every printed form is tested on, so feat(cli): doctor integration components judges documented prop types against the code #7667 can skip the type comparison with one visible line instead of throwing. A test checks stubbed versions on both sides of 4.8. All 73 tests pass.After the eighth review
typeof Foo<>andimport('x').Foo<>are marked unparsed likeFoo<>. The JSDoc.test reads the text before<with comments removed, so the validArray /* v1.2 */ <string>reads asstring[]. Both are in the unparsed-text test and failed before.After the round-21 review
?string,!string,string?,*,string=,function(string): void) are now marked unparsed, likeArray.<T>. feat(cli): doctor integration components judges documented prop types against the code #7667 lists them as not compared instead of reporting them as differing. Four of them are in the unparsed-text test, which failed before. Valid TypeScript forms such as[string?],[...string[]]and conditional types read as before, on TypeScript 4.8, 5.0 and 6.0.After the round-22 review
{[K in keyof P]: P[K]; extra: string}, TS7061), a type member with a modifier other thanreadonly({public a: string}, TS1070), and a parameter with a modifier or an initializer ((public a: string) => void, TS2369;(a: string = "x") => void, TS2371). Their valid neighbours read as before on TypeScript 4.8, 5.0 and 6.0. The four are in the unparsed-text test, which failed before.Rebased onto current main
After the latest review
<in T>or<out T>on a function type's type parameter (TS1274), an index signature without one plain, typed parameter and a type ({[k?: string]: number},{[a: string, b: string]: number}, TS1019/TS1096), a property with an initializer ({a: string = 'x'}, TS1247), and a trailing comma in type arguments (Foo<A,>, TS1009).<const T>and<T,>still read as valid on TypeScript 5.0 and 6.0, astscaccepts them. Five of the forms are in the unparsed-text test, which failed before.Restacked
normalizeTypeTextreturned its cached array, so a caller that changed a result changed every later one. The cached result is now frozen, and a test checks that a change to one result throws and leaves the next intact; it failed before.The type judge moves here
type-match.mjs, which judges a documented type against the type the code declares, moves here unchanged from feat(cli): doctor integration components judges documented prop types against the code #7667. That PR wires it into the comparison, and with the latest review it would have passed 2,000 lines. A new direct test pins its verdicts, so this PR tests the judge without the comparison. The changeset says the groundwork now covers judging, and that nothing reaches either part yet.After the review of the type judge
|, so a doc of"a'|'b"matched code typed'a' | 'b', and the reverse. Each literal part is now quoted with\and'escaped on both sides, and an enum member's value is encoded, so no text inside a literal reads as other parts.'a' | 'b'andE | 'a' | 'b'were a mismatch either way round, though TypeScript passes the member where that literal is taken. Such a member is now folded away.readonlyinside a string literal was read as the modifier, so(mode: 'readonly' | 'edit') => voiddocumented in the other order came back not compared. Thereadonlytests now read the text with its string literals masked.After the review of
React.namesReact.Xas whatever a bareXnames where the prop is declared, and the text reading sets the qualifier aside. So a handler typed with the DOM'sKeyboardEvent, documented as(e: React.KeyboardEvent) => void, was a judged match thoughtscrejects it, and a correctReact.Keydoc drew a mismatch where the file declares its ownKey.React.Xnow resolves throughReactwhere the prop is declared (an imported namespace or React's global one). The text match is refused when one side writesReact.Xand the other a bareXthat names another type there; both sides writingReact.X, or a bareXimported from React, still match.MouseEventfor the code'sReact.MouseEventmove from judged to not compared (tscrejects such a handler); no finding changes.After the latest approval
encodeURIComponent, which throws for an unpaired surrogate, so judging such an enum threw instead of returning a verdict. Enum parts now encode each character by code point, which never throws and never reads as a separator.string[] | (string | number)[]against(string | number)[],object | string[]againstobject, andvoid | undefinedagainstvoidwere mismatches thoughtscassigns each both ways. Such a member is folded now:undefinedbesidevoid, an array besideobject, and an array whose element types another array also takes. A readonly array covers a mutable one but not the reverse, astscreads it, soreadonly string[] | (string | number)[]against(string | number)[]stays a mismatch.Merge order
The order is required:
doctor integration components, stacked on feat(cli): compare documented component props with the TypeScript source #7663), then feat(cli): read two written TypeScript types as one when they differ only in how they are written #7684 (reading two written types as one, stacked on feat(cli): doctor integration components compares documented props with the code #7676), then feat(cli): doctor integration components judges documented prop types against the code #7667 (prop types, stacked on feat(cli): read two written TypeScript types as one when they differ only in how they are written #7684).validate, stacked on feat(cli): check each authored doc against its published authoring type #7664).integration check) last, after a rebase that adds these PRs' fields and codes to its failures list. Merged earlier, it breaks main.This PR must ship in the same release as
integration check, with #7681, #7663, #7676 and #7667.Overlap with open PRs
astryx componentprints #7623, fix(cli): init writes agent docs every agent reads, at the project root, never in the home folder #7497, spec(AST-039, cli-surface): integration docs are found by their documented names and checked in one command #7658, feat(cli): a component doc can name a differently named export of the file beside it #7659, fix(cli): integration verify reads exports with the reader discovery uses #7660, feat(docsite): put the integration guide's pages under CLI Integrations in the sidebar #7661, feat(cli): integration family members are components under their own names #7674, or the other PRs in this set.Size
1,690 changed lines.
Scope
Testing
The CI test jobs run only for pull requests into main, so this PR's tests run in CI once it is retargeted after #7676 merges. On main 9b8b723 with #7681, #7663, #7676 and this branch: copyright, sync-exports, the published
./apitype check,readme:check, the four CLI typechecks (strict, json-api, authoring-contract, template-docs),check:changesets, the lint job's changeset coverage check (run on the merge commit GitHub builds),check:cli-structure,check:repo, eslint on the changed files, prettier on the new files, and the fullpnpm vitest run --project nodesuite: 10,396 tests pass. The 21 failures are in the npm pack, packed real-world package, theme resolution, CSS graph and browser accessibility tests, which fail the same way on main 9b8b723 on the same machine (they need a package registry and a browser it doesn't reach), plus a packed-consumer test that passes on its own in a full checkout. Earlier in review, with all nine PRs merged into main, the fourdoctor integrationsubcommands were run in text and JSON on 64 package shapes, against main: templates output is byte-identical everywhere;validateandcomponentsonly add their new section and JSON field;docsdiffers only where the doc-links PR fixes a link warning.integration checkexits 1 exactly when one of the four would, each of its sections is byte-identical to that subcommand's output, and onlyintegration check's three list tests fail until its planned rebase.