Repository navigation
feat(cli): compare documented component props with the TypeScript source - #7663
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
PR Analysis Report
|
There was a problem hiding this comment.
This PR is on hold for a maintainer decision: having astryx doctor integration components compare documented props with TypeScript props (the propDrift field, its four warning kinds and the Core known-drift test) has no current spec yet, because AST-039 is still a draft on main and its FR12 exists only in draft #7658. Approval needs a maintainer to provide or identify the governing spec for that behavior, or to decline it. Independent of that decision, three things need changing:
- At 2,921 authored lines the PR is over the 2,000-line threshold for one change; moving the
type_mismatchcomparison (type-match.mjs,type-text.mjsand their tests) to a follow-up would bring it under. - When every documented component is unchecked, the text output still ends with
[ok] Documented props match the code in 0 component(s), so print[ok]only when at least one table was compared. - A documented
stringis reported astype_mismatchagainst'…' | (string & {}), the any-string pattern React uses forautoCompleteandtarget, so treatstring & {}as accepting any string.
[Automated review]
| records(report.unchecked, {fields: ['component', 'doc', 'reason']}), | ||
| ); | ||
| } | ||
| if (report.findings.length === 0 && !failed) { |
There was a problem hiding this comment.
This prints [ok] Documented props match the code in 0 component(s) when every documented table is unchecked (for example, props that extend an uninstalled package) or when no doc has a prop table, so the run ends with a pass token for a comparison that never happened. Consider also requiring report.components > 0 here, and adding the all-unchecked case to doctor-integration.test.mjs.
| */ | ||
| function isPrimitive(ctx, type, primitive) { | ||
| const {TypeFlags} = ctx.ts; | ||
| if (primitive === 'string') return (type.flags & TypeFlags.String) !== 0; |
There was a problem hiding this comment.
string & {}, the any-string idiom React uses for autoComplete, target and similar attributes, has no String flag, so a documented string never matches 'on' | 'off' | (string & {}) and the union's literals are never absorbed. Core's TextInput.autoComplete baseline entry is this case. Treating string & {} and number & {} as accepting any string or number would clear it, and a prop-drift.test.mjs case would pin it.
6ea86f9 to
62fdc47
Compare
There was a problem hiding this comment.
This PR is on hold for a maintainer decision: comparing documented props with TypeScript props in astryx doctor integration components (the propDrift field, its not_in_code, not_in_doc and required_mismatch warnings, loading the project's TypeScript, and the Core known-drift test) has no current spec yet, because AST-039 is still a draft on main and its FR12 exists only in draft #7658. Approval needs a maintainer to provide or identify the governing spec for that behavior, or to decline it. Independent of that decision, one fix is needed:
- A props type that applies
Omitto an import that does not resolve (for exampleextends Omit<MissingProps, 'title'>) is counted as compared and gets[ok]while every documented prop name is accepted; treat unresolved type arguments like an unresolvedextendsso the component is listed as unchecked, and add a test for it.
[Automated review]
| if (type.flags & ts.TypeFlags.Object) { | ||
| const target = | ||
| type.objectFlags & ts.ObjectFlags.Reference ? type.target : type; | ||
| if (target.objectFlags & ts.ObjectFlags.ClassOrInterface) { | ||
| // An unresolved `extends` is dropped from the base types, so read the | ||
| // heritage clauses themselves. | ||
| for (const declaration of target.symbol?.declarations ?? []) { | ||
| for (const clause of declaration.heritageClauses ?? []) { | ||
| for (const node of clause.types) { | ||
| const base = checker.getTypeAtLocation(node); | ||
| if (hasUnresolvedPart(ts, checker, base, depth + 1)) return true; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
A props type that applies Omit (or another mapped type) to an import that does not resolve is not caught here: Omit<any, K> is a mapped object with an any string index signature, not an any, so the component counts as compared, accepts() then takes every documented name, and the run prints [ok]. For example, interface AcmeCardProps extends Omit<MissingProps, 'title'> { label?: string } with a doc that lists a bogusProp reports nothing, while extends MissingProps is listed as unchecked. Treating a type whose alias or reference type arguments include an unresolved any as unresolved would put such a component in unchecked; a prop-drift.test.mjs case for extends Omit<Missing, 'x'> would pin it.
There was a problem hiding this comment.
Fixed. A props type built with Omit<…> or Partial<…> from a type that does not resolve is listed as unchecked, never compared. foundation/discovery/prop-drift.test.mjs › "lists each component whose props it cannot read as not compared" pins extends Omit<MissingProps, 'title'> (Omitted) and Partial<MissingProps> & {…} (Phantom) at 5ddd619.
| ), | ||
| declared: ownDeclarations | ||
| .filter(/** @param {any} d */ d => d.type) | ||
| .map(/** @param {any} d */ d => d.type.getText(d.getSourceFile())), |
There was a problem hiding this comment.
For a method signature such as onPick(value: string, index: number): void, d.type is the return type, so a not_in_doc finding reports codeType as void. Falling back to the compiler's rendering when the declaration is not a property signature would keep codeType as “the type as the source declares it”.
There was a problem hiding this comment.
Fixed. A method prop the doc leaves out reports its function type as codeType, for every non-property form. foundation/discovery/prop-drift.test.mjs › "shows a method prop the doc leaves out with its function type, optional or not" pins onPick(value: string, index: number): void, an optional method and a getter at 5ddd619.
62fdc47 to
d04b441
Compare
There was a problem hiding this comment.
This PR is on hold for a maintainer decision: comparing documented props with TypeScript props in astryx doctor integration components (the propDrift field, its not_in_code, not_in_doc and required_mismatch warnings, loading the project's TypeScript, and the Core known-drift test) has no current spec yet, because AST-039 is still a draft on main and its FR12 exists only in draft #7658. Approval needs a maintainer to provide or identify the governing spec for that behavior, or to decline it. Independent of that decision, one fix is needed:
- A props type that reaches an unresolved import through a named alias or a nested utility type (
type Base = Omit<MissingProps, 'title'>extended by the props interface, orPartial<Omit<MissingProps, 'title'>>) is still counted as compared and gets[ok]while a documented prop the code lacks goes unreported, so treat an index signature whose value is the compiler's error type as unresolved and add tests for both forms.
[Automated review]
| ? checker.getTypeArguments(type) | ||
| : []), | ||
| ]; | ||
| if (made.some(/** @param {any} t */ t => isErrorType(ts, t))) return true; |
There was a problem hiding this comment.
This only catches an unresolved type passed straight into Omit<…> or Partial<…>. Through a named alias (type CardBase = Omit<MissingProps, 'title'>, then interface AcmeCardProps extends CardBase, CardBase & {…}, or props: CardBase) there are no alias type arguments to inspect, and in Partial<Omit<MissingProps, 'title'>> the argument is the Omit result rather than the error type. The component then counts as compared, accepts() takes every documented name through the string index signature, and the run prints [ok] while a documented prop the code lacks goes unreported. In each of these the resulting type's index signatures carry the compiler's error type as their value, so a check such as checker.getIndexInfosOfType(type).some(info => isErrorType(ts, info.type)) here would list them as unchecked, while a written [key: string]: unknown still counts as present. prop-drift.test.mjs cases for the alias and the nested form would pin it.
There was a problem hiding this comment.
Fixed. An index signature whose value is the compiler's error type counts as unresolved, so each of these forms lists the component as unchecked. foundation/discovery/prop-drift.test.mjs › "lists each component whose props it cannot read as not compared" pins the named alias in extends (Named), in an intersection (Joined) and alone (Bare), and the nested Partial<Omit<MissingProps, 'title'>> (Nested), while a written [key: string]: any stays compared (Open), at 5ddd619.
| expect(output).toContain( | ||
| '1 documented prop(s) differ from the code in 1 component(s).', | ||
| ); | ||
| expect(output).not.toContain('[ok] Documented props match'); |
There was a problem hiding this comment.
The command prints [ok] Documented prop names and required flags match …, so this string never appears and the assertion cannot fail. not.toContain('[ok] Documented prop'), as the unchecked-component test below uses, would check that [ok] stays silent beside findings.
d04b441 to
8b14433
Compare
There was a problem hiding this comment.
This PR is on hold for a maintainer decision: comparing documented props with TypeScript props in astryx doctor integration components (the propDrift field, its not_in_code, not_in_doc and required_mismatch warnings, loading the project's TypeScript, and the Core known-drift test) has no current spec yet, because AST-039 is still a draft on main and its FR12 exists only in draft #7658. Approval needs a maintainer to provide or identify the governing spec for that behavior, or to decline it. Independent of that decision, one fix is needed:
- A component whose props are generic over the rendered element (for example
AcmeBox<E extends React.ElementType>withOmit<React.ComponentPropsWithoutRef<E>, 'as'>) counts as compared and can get[ok]while a documented prop the code lacks goes unreported, because thatOmitmember'sstringindex signature (an indexed access intoE's props) makesaccepts()take every name; list such a component as unchecked, or keep it out of[ok], and add a test for it.
[Automated review]
| (checker.getIndexInfosOfType?.(member) ?? []).some( | ||
| /** @param {any} info */ info => { | ||
| const key = info.keyType; | ||
| if (key.flags & ts.TypeFlags.String) return true; |
There was a problem hiding this comment.
For a component whose props are generic over the rendered element, such as type AcmeBoxProps<E extends React.ElementType> = {as?: E; padding?: number} & Omit<React.ComponentPropsWithoutRef<E>, 'as' | 'padding'>, TypeScript gives the Omit member a string index signature whose value is PropsWithoutRef<ComponentProps<E>>[string], so this returns true for every documented name. A documented prop the code lacks (elevation) is never reported, the component counts as compared, and the run prints [ok] Documented prop names and required flags match the code in 1 component(s), although <AcmeBox padding={2} elevation={2} /> fails to type-check. Not letting an index signature whose value is an indexed access into a generic type (ts.TypeFlags.IndexedAccess) accept names, and listing such a component as unchecked or keeping it out of [ok], would close it; a written [key: string]: any, a data-${string} signature and Record<string, unknown> are not indexed accesses and stay accepted. A prop-drift.test.mjs case for an as component whose doc lists a prop the code lacks would pin it.
There was a problem hiding this comment.
Fixed at 5ddd619. A generic component whose prop names come from a type parameter is listed as unchecked and kept out of [ok]: the as case here, the same without Omit, Partial of one, a key remapped from one, and a bare props: P (Poly, Joint, Partly, Remapped, Inferred in "lists each component whose props it cannot read as not compared"). A sweep of other index-signature routes also fixed template literal keys, which now match as TypeScript matches them, and never- or undefined-valued indexes such as Record<string, never>, which take no name ("accepts a name through an index signature only as TypeScript would").
8b14433 to
5ddd619
Compare
There was a problem hiding this comment.
This PR is on hold for a maintainer decision: the props comparison it adds (comparePropDocs, with its not_in_code, not_in_doc and required_mismatch rules, its unchecked and skip behavior, and loading the project's own TypeScript) has no current spec yet, because AST-039 is still a draft on main and its FR12 exists only in draft #7658. Approval needs a maintainer to provide or identify the governing spec for that behavior, or to decline it. Independent of that decision, two small fixes are needed:
- A component whose own type does not resolve, such as a
forwardRefcomponent when React's types are not installed, is listed as “not a component”; give it the unresolved-type reason and add a test. - The Changeset announces a CLI capability that nothing reaches until #7676, which has its own entry; describe this PR as internal groundwork (for example
[chore]) instead.
[Automated review]
| const type = checker.getTypeOfSymbolAtLocation(symbol, sourceFile); | ||
| const signatures = [ | ||
| ...type.getCallSignatures(), | ||
| ...type.getConstructSignatures(), | ||
| ]; | ||
| if (signatures.length === 0) { | ||
| return { | ||
| ok: false, | ||
| reason: `${exportName} in ${sourceName} is not a component (it cannot be called with props)`, | ||
| }; | ||
| } |
There was a problem hiding this comment.
When the export's own type does not resolve, it is the compiler's any, which has no call or construct signatures, so this returns “is not a component”. For example, export const FwdNoReact = React.forwardRef<HTMLDivElement, {label?: string}>(…) in a package without React's types gets FwdNoReact in FwdNoReact.tsx is not a component (it cannot be called with props), and so does export const FcMissing: MissingFC<{label?: string}> = () => null with MissingFC imported from a package that isn't installed. Returning the unresolved-type reason (lines 288–297) when this type is any and the source doesn't write any would keep the reason true; a test with an unresolved react import would pin it.
There was a problem hiding this comment.
Fixed at 33b99ca. An export whose own type the compiler could not resolve (a forwardRef without React's types) gets the unresolved-type reason; an export written : any says it is typed any. foundation/discovery/prop-drift.test.mjs › "lists each component whose props it cannot read as not compared" pins Wrapped (forwardRef from an uninstalled react), and "accepts a name through an index signature only as TypeScript would" pins AnyConst.
| for (const signature of signatures) { | ||
| const parameter = signature.getParameters()[0]; | ||
| if (parameter) { | ||
| propsTypes.push(checker.getTypeOfSymbolAtLocation(parameter, sourceFile)); | ||
| } else { | ||
| propless = true; | ||
| } |
There was a problem hiding this comment.
For a class component this reads the constructor's props, before defaultProps makes a prop optional for JSX callers. class Klass extends React.Component<{label: string; size: number}> with static defaultProps = {size: 1} and a doc that marks size optional gets Klass.size required_mismatch, although TypeScript accepts <Klass label="x" />. Skipping the required comparison for props named in defaultProps, or reading the props through JSX.LibraryManagedAttributes, would avoid the false warning.
There was a problem hiding this comment.
Fixed at 33b99ca. A prop that defaultProps fills, on a class (static field) or a function (assigned), has its required left uncompared, as JSX callers may leave it out. foundation/discovery/prop-drift.test.mjs › "leaves the required flag of a prop that defaultProps fills alone" pins both forms.
| '@astryxdesign/cli': patch | ||
| --- | ||
|
|
||
| [feat] The CLI gains a comparison of the props a component doc documents with the props its TypeScript source declares, read with the project's own `typescript`. It reads every overload, takes a prop name that an index signature accepts only as TypeScript would, and lists a component whose props it cannot read (a type that does not resolve, props typed `any`, or prop names that come from a type parameter) as not compared, rather than guessing. |
There was a problem hiding this comment.
Nothing reaches comparePropDocs in this PR: no command, API function or package export calls it until #7676, which adds its own [feat] entry for the user-visible behavior. Released before #7676, this entry promises a check users cannot run; released with it, the changelog describes the capability twice. A [chore] entry (still patch, so the coverage check passes) describing the internal groundwork would stay true either way.
There was a problem hiding this comment.
Done at 33b99ca: the entry is [chore] (still a patch, and the coverage check passes) and describes the comparison as groundwork with no user-visible change until the follow-up PR runs it.
There was a problem hiding this comment.
Changes requested: the Without<any> fix works, but a prop that only some union members or overloads require is now listed under propsNotChecked instead of compared, so a doc that marks it required: true still draws no required_mismatch, although ComponentPropDoc.required means the prop must be provided and tsc accepts calls that leave it out, which architecture:cli-surface INV33 asks the check to warn about. On Core this hides ContextMenu.items and DropdownMenu.items, whose compound mode takes no items, and lists ten correctly documented props as not fully checked. Please read such a prop as optional, as the no-parameter overload already is (or have INV33 amended to state the exception), and update the overload test to expect required_mismatch for Menu.items; the change removes lines, so the PR stays under 2,000.
- Minor:
{label?: string} & {[K in string]?: any}is listed as typedanybecauseshapeOfalso reads a mapped type'sanytemplate; readingd.typeonly from type aliases compares it like theRecord<string, any>form.
[Automated review]
| const required = | ||
| defaulted.has(name) || propless | ||
| ? false | ||
| : symbols.length === members.length && optional === 0 | ||
| ? true | ||
| : optional === symbols.length | ||
| ? false | ||
| : null; |
There was a problem hiding this comment.
A prop that only some members or overloads require gets null here, and lines 1257–1259 then list it in propsNotChecked instead of comparing it, so a doc marking it required: true draws no warning although tsc accepts a call without it. ComponentPropDoc.required means “True if the prop must be provided”, and INV33 asks for a warning on a required flag the code contradicts. On Core this hides ContextMenu.items and DropdownMenu.items. Reading such a prop as optional, as the no-parameter overload already is, fixes it, adds exactly those two Core findings, and makes the variant listing unnecessary:
const required =
!defaulted.has(name) &&
!propless &&
symbols.length === members.length &&
optional === 0;There was a problem hiding this comment.
Done in ca7e8bc, with the first option: such a prop now reads as optional, as a prop beside an overload that takes no props already does. required means every caller must pass the prop, and tsc accepts a call that leaves this one out. The variant listing is gone. The overload test expects required_mismatch for Menu.items and a match for a variant prop marked optional, and it failed before. On Core this adds exactly ContextMenu.items and DropdownMenu.items, which the stacked props PR's Core test records as known. Only a doc entry that names several props stays listed, since its one flag is exact for none of them.
| const shapeOf = symbol => | ||
| (symbol?.declarations ?? []).flatMap((/** @type {any} */ d) => | ||
| d.type | ||
| ? [d.type] | ||
| : (d.heritageClauses?.flatMap((/** @type {any} */ c) => c.types) ?? []), | ||
| ); |
There was a problem hiding this comment.
Minor: d.type here also reads a mapped type node's value template, so {label?: string; size?: number} & {[K in string]?: any} is listed as typed any, while the same props with & Record<string, any> or an inline [key: string]: any are compared. Taking d.type only from a type alias (ts.isTypeAliasDeclaration(d) ? [d.type] : …) keeps that form compared, and the Without<any> forms stay typed any.
There was a problem hiding this comment.
Fixed in ca7e8bc. A declaration's type is read only from a type alias, so {label?: string} & {[K in string]?: any} is compared like the Record<string, any> form, and the Without<any> forms stay typed any. The overload test adds it.
9c32d9b to
ca7e8bc
Compare
There was a problem hiding this comment.
Changes requested: both fixes from the last review work (a prop that only some variants require now reads as optional, so ContextMenu.items and DropdownMenu.items draw required_mismatch, and {…} & {[K in string]?: any} is compared like Record<string, any>), but a doc entry that names several props (open / onOpenChange) still never has its required flag compared. Those props are listed as not checked instead, so a flag the code contradicts for every named prop draws no warning, which architecture:cli-surface INV33 requires, and an exact flag, such as the Pair.value your overload test pins, is listed too. No doc in this repository writes such an entry yet, so the fix is narrow: give each named prop the entry's flag, compare it as you do for a single-name entry, and update the Pair test; this removes lines, so the PR stays under 2,000.
[Automated review]
| // One flag for several props is exact for none of them (a prop with | ||
| // an entry of its own is checked there). | ||
| if (entries.length === 1) alone.add(propName); | ||
| if (entries.length > 1) shared.add(propName); |
There was a problem hiding this comment.
An entry that names several props never has its required compared: documentedProps gives its names no flag (line 474), this line skips the comparison, and lines 1306–1309 list the names instead. So open / onOpenChange left optional while the code requires both, or value / onChange marked required: true while the code requires neither, draws no required_mismatch, and an exact flag such as the overload test's Pair.value is listed as not checked. INV33 asks for a warning on a required flag the code contradicts, and under this module's own rule (required means every caller must pass the prop) each named prop's flag can be judged as a single entry's is. Giving every name the entry's flag fixes it:
// documentedProps
required: raw.required,
// here
if (docRequired !== code.required) {There was a problem hiding this comment.
Done in d1f58fa, with your fix. Each prop an entry names now gets the entry's required and is compared as a single entry's is. The listing is gone, so propsNotChecked again holds only props built from keys the check can't trace. The overload test pins an entry whose flag fits both props (Pair0) and one the code contradicts for one of the two names (Pair1.label, documented as required but optional in the code). It failed before.
ca7e8bc to
d1f58fa
Compare
There was a problem hiding this comment.
Recommending approval: each prop that an entry like value / onChange names now takes that entry's required flag, so a flag the code contradicts draws required_mismatch and an exact one draws nothing, as the updated Pair0 and Pair1 cases show, and Core's findings are unchanged. With that, the comparison matches every part of architecture:cli-surface INV33 this PR covers, and the PR stays under 2,000 lines. One optional tidy-up: the test file's header and its unused TYPESCRIPT_MISSING_REASON and unusableTypeScriptReason imports still describe the skip test that moved to #7676.
[Automated review]
| import { | ||
| comparePropTables, | ||
| PROPS_NOT_DOCUMENTED, | ||
| TYPESCRIPT_MISSING_REASON, | ||
| } from './prop-drift.mjs'; | ||
| import {unusableTypeScriptReason} from './project-typescript.mjs'; |
There was a problem hiding this comment.
Optional tidy-up: TYPESCRIPT_MISSING_REASON and unusableTypeScriptReason are no longer used in this file since the skip test moved to #7676, and the header above (lines 3–7) still says the file covers “the visible skip when the project has no TypeScript”. Dropping the two imports and that phrase keeps the file honest about what it tests.
There was a problem hiding this comment.
Done in c0b2461. The two imports and the header phrase are gone; they now come with the skip test in the stacked props PR. This push also rebases the PR, with the one below it, onto main 9b8b723, so the PRs above carry main's #7691, which their Core test needs. Apart from that base move and this tidy-up, the diff is unchanged.
4178089 to
9a2f678
Compare
d1f58fa to
c0b2461
Compare
There was a problem hiding this comment.
Recommending approval: comparePropTables matches architecture:cli-surface INV33 for the part this PR covers (missing and undocumented props, required flags, the exempt props, components it cannot read, and the skip without TypeScript), its tests and the CLI's checks pass, and the PR stays under 2,000 lines. One optional fix: findings are sorted by a NUL-joined key with localeCompare, which ignores NUL, so one component's findings can land among another's (on Core, Table's sit among TableCell's and TableRow's); comparing component, prop and kind in turn keeps them together. It can land once #7681 has.
[Automated review]
| findings.sort((a, b) => | ||
| `${a.component}\0${a.prop}\0${a.kind}`.localeCompare( | ||
| `${b.component}\0${b.prop}\0${b.kind}`, | ||
| ), | ||
| ); |
There was a problem hiding this comment.
Optional: ICU collation ignores U+0000 ('a\0b'.localeCompare('ab') === 0), so this key compares as one run of text and a component's findings can sort among those of components that share its prefix. On Core, Table.emptyState lands between TableCell.scope and TableHeaderCell.contextMenuActions. Comparing the fields in turn keeps each component's findings together:
findings.sort(
(a, b) =>
a.component.localeCompare(b.component) ||
a.prop.localeCompare(b.prop) ||
a.kind.localeCompare(b.kind),
);There was a problem hiding this comment.
Done in 2ed0495. Findings now sort by component, then prop, then kind, comparing the fields in turn. The first test adds a BadgeCount beside Badge and pins that all of Badge's findings come first; it failed before. On Core, Table's findings now sit together ahead of TableCell's, and the set of findings is unchanged.
c0b2461 to
2ed0495
Compare
There was a problem hiding this comment.
Changes requested: comparePropTables matches architecture:cli-surface INV33 for the part this PR covers, and the field-by-field sort now keeps each component's findings together, but a generic component whose own props take any name is listed as unchecked instead of compared. namesFromTypeParameter counts an any-valued index signature as names from a type parameter, so function GenericOpen<T>(props: {items: T[]; label?: string; [key: string]: any}) and the & Record<string, any> form get the "names from a type parameter" reason, while the same props without <T> are compared and report label as not_in_doc; the mapped-type check does the same to a bare Record<'value' | 'defaultValue', V> by reading its value argument. Dropping the any-index test and reading only the mapping's constraintType compares these while, on TypeScript 6.0.3, every generic shape the unchecked test pins and React's own {as?: E} & Omit<ComponentPropsWithoutRef<E>, 'as' | 'padding'> polymorphism stay listed, so please make that change and add cases for these shapes.
[Automated review]
| if ( | ||
| (checker.getIndexInfosOfType?.(type) ?? []).some( | ||
| /** @param {any} info */ info => | ||
| info.type.flags & ts.TypeFlags.Any || |
There was a problem hiding this comment.
This counts every any-valued index as names from a type parameter, so a generic component whose own props take any name is listed as unchecked with the reason at line 330 instead of compared: function GenericOpen<T>(props: {items: T[]; label?: string; [key: string]: any}) and {items: T[]; label?: string} & Record<string, any> are listed, while the same props without <T> report label as not_in_doc. The mapped-type check at line 583 does the same to a bare Record<'value' | 'defaultValue', V>, because it also reads Record's value argument.
Dropping this any test and reading only type.constraintType at line 583 compares all three, while the five generic shapes the unchecked test pins, and React's {as?: E} & Omit<ComponentPropsWithoutRef<E>, 'as' | 'padding'>, stay unchecked on TypeScript 6.0.3 and the four tests in this file still pass. A GenericOpen/GenericRecord case in the unchecked test would pin it.
There was a problem hiding this comment.
Fixed in 332eff7. The any-valued index test is gone: an index that takes any name gives names of the component's own. The mapped-type check reads only the mapping's keys (constraintType), not Record's value argument. So GenericOpen<T> with [key: string]: any, the & Record<string, any> form and a bare Record<'value' | 'initial', T> are compared, as they are without <T>. The unchecked test adds the three, and it failed before. Every generic shape that test pins stays listed, on TypeScript 4.8, 5.0 and 6.0, and so does React's {as?: E} & Omit<ComponentPropsWithoutRef<E>, 'as' | 'padding'>, which the stacked props PR's React-types test now pins.
2ed0495 to
332eff7
Compare
There was a problem hiding this comment.
Changes requested: generic components whose own props take any name are now compared as architecture:cli-surface INV33 asks, but this revision's narrower type-parameter test lets a generic component whose prop names come from a mapped type's as clause (for example {[K in 'value' | 'change' as `${N}${Capitalize<K>}`]?: string}) be compared against a props type with no members, so every documented name draws a false not_in_code although TypeScript accepts those props. Written through a named alias such as Prefixed<N>, this shape was listed as unchecked before the change. Please make namesFromTypeParameter also count a mapping whose as clause refers to a type parameter other than its own key (one the component declares, or one passed through an alias argument), and add the alias, inline and gated (as T extends true ? K : never) shapes to the unchecked test.
[Automated review]
| if ((type.objectFlags & ts.ObjectFlags.Mapped) === 0) return false; | ||
| // The keys, which the compiler works out when it reads the members. | ||
| checker.getPropertiesOfType(type); | ||
| return !!type.constraintType && isDeferred(ts, checker, type.constraintType); |
There was a problem hiding this comment.
This now judges a mapped type by its keys alone. When the keys are literals but an as clause builds the names from the component's type parameter ({[K in 'value' | 'change' as `${N}${Capitalize<K>}`]?: string}, or as T extends true ? K : never), the compiler gives the props type no properties and no index signatures while N is open, so the table is compared against nothing and each documented name draws a false not_in_code, although tsc accepts those props. Through a named alias (Prefixed<N>), the alias-argument test removed here used to list it as unchecked. Also returning true when the as clause refers to a type parameter other than the mapping's own key (one the component declares, or an alias parameter bound to a deferred argument) lists these shapes and keeps own-key remaps and the three newly compared shapes compared.
There was a problem hiding this comment.
Fixed in 02eeac1. namesFromTypeParameter now also counts a mapping whose as clause uses a type parameter other than its own key, one the component declares or one an alias passes on, when the compiler names fewer props than the mapping has keys. So the alias, inline and gated shapes are listed. An alias given a fixed argument (Prefixed<'my'>, also through another alias or inside an intersection), an own-key remap (as Exclude<K, 'b'>) and an infer inside the clause stay compared, as do the three shapes compared last round. The unchecked test adds the listed and compared forms; it failed before, and TypeScript 4.8, 5.0 and 6.0 give the same results. Generic props that refer to a type parameter and show the compiler no members at all are listed the same way, while props that are plainly empty (function X(), (props: {}), Record<string, never>), generic or not, are still compared, as tsc rejects each documented prop there.
332eff7 to
02eeac1
Compare
There was a problem hiding this comment.
Changes requested: the as-clause fix works, so Prefixed<N> and its inline and gated forms are now listed as unchecked, but the new property-count test in renamedByTypeParameter also lists a generic component whose props use a key-filtering mapped type with fixed arguments, such as a type-fest-style Except<BaseProps, 'b'> or a PickByValue<Handlers, Function> helper, saying its names come from a type parameter although every prop is known, so its stale and undocumented props go unreported where the previous revision reported them. Please count a type parameter in the clause only while it is still open (the component's own, or an alias parameter whose argument is still deferred), and add a fixed filter in a generic component to the compared cases of the unchecked test; a sketch of that check keeps this PR's three tests passing.
[Automated review]
| const keys = type.constraintType?.isUnion() | ||
| ? type.constraintType.types.length | ||
| : 1; | ||
| return uses(clause) && checker.getPropertiesOfType(type).length < keys; |
There was a problem hiding this comment.
This also fires when the clause's other type parameter is fixed. A key filter such as a type-fest-style Except<BaseProps, 'b'> ({[P in keyof T as Filter<P, K>]: T[P]}) or PickByValue<Handlers, Function> names the alias's K or V and leaves fewer properties than keys, so a generic component using it, such as function ExceptGeneric<T>(props: Except<BaseProps, 'b'> & {items?: T[]}), is listed with the "names from a type parameter" reason although every name is known. Its stale documented prop then draws no not_in_code, where the previous revision and the same props without <T> report it. Counting a type parameter only while it is still open (the component's own, or an alias parameter whose aliasTypeArguments entry is still deferred) compares these again and keeps Prefixed<N>, the inline and the gated forms listed.
There was a problem hiding this comment.
Fixed in dbbf90d. The props-count test is gone. A type parameter in the as clause now counts only while it is still open where the props are read: the component's own, or one an alias passes on whose argument is still a type parameter. The check reads that argument from the instantiation, so an alias inside an intersection or behind another alias is read too. Except<BaseProps, 'b'> and PickByValue<Handlers, Function> in a generic component, and a parameterless alias of one, are compared again, while Prefixed<N>, the inline and gated forms, and the nested and deep alias forms stay listed. Keys made from a type parameter ({[P in on${K}]?: () => void}), which were compared against no props, are listed now too. The unchecked test adds the fixed filters and the fixed and open alias forms; it failed before, and TypeScript 4.8, 5.0 and 6.0 agree.
02eeac1 to
dbbf90d
Compare
There was a problem hiding this comment.
Recommending approval: comparePropTables matches architecture:cli-surface INV33 for the part this PR covers (missing and undocumented props, required flags, the exempt list, unchecked components and the skip without TypeScript), and its tests, lint, formatting, Changeset and CLI-structure checks pass. Nothing calls it until #7676, so please land it after #7681 and ship it with #7676, #7684 and #7667, which bring the exempt-list and missing-compiler tests that INV33's verification row expects.
[Automated review]
…of its export
A foundation check, `comparePropTables`, compares each documented prop
table (a doc's `props`, or a multi-component doc's entry, with the export it
describes) with that export's props, read with the project's own
TypeScript and the package's TypeScript project (`readPackageTsconfig`): a
documented prop the code lacks (`not_in_code`), a prop the package's own
source declares that the table leaves out (`not_in_doc`), and a `required`
that disagrees (`required_mismatch`), sorted by component, then prop, then
kind. A prop a mapped type builds has no
declaration; it is the package's own when its key traces to the
package's own source, as a literal or a property's declaration, through
aliases, `Exclude` and `Extract`, `keyof`, a generic's arguments, an
indexed property's own type, a constant's value, and a component a
`memo`, `lazy` or `forwardRef` call wraps
(`{[K in 'a' | 'b']?: T}`, `Partial<Record<'a' | 'b', T>>`). A key
another package defines is that package's, behind an alias or not, and a
prop whose key the check can't trace is listed in `propsNotChecked`
rather than asked for. A prop the
package's `defaultProps` fills is optional, as it is to JSX callers,
through up to two layers of `memo` and `lazy` as React's JSX types read
them; `defaultProps` typed `any` fills every prop. It reads every overload
(one that takes no props makes every prop optional, as does a member of
the props type that does not require it, since a caller may then leave it
out; an entry that names several props claims its `required` for each)
and each
member of a union of component types (`ComponentType<P>`), takes a name an
index signature accepts exactly as TypeScript would, and lists a component
whose props it cannot read as unchecked with the reason: a reference that
does not resolve in the files the component comes from (its own file, the
files it imports and theirs, dependencies included) or in the declaration
files the config includes, whether a module the program cannot find, a
name its module does not export, a missing `/// <reference>`, a failed
side-effect import where the compiler checks one, or a type package the
config names that cannot be found, so a dependency that is not installed
never produces a finding, however the code reaches the props; in the
component's own files, any type the compiler can only give its error type
(a name or value declared nowhere, a property that doesn't exist, a
JavaScript file's JSDoc type), which `keyof` or a key would hide; props that are
`any`, directly or through the package's own alias of a utility that reads
its keys, written in the signature or as a name the package gives it
(`type P = Without<any>`, an interface extending it), and such a type
passed on to another (`Omit<Omit<any, 'as'>, 'ref'>`, as React's
`forwardRef` does) (a typed props type beside a `Record<string, any>` or a mapped `any`
index is compared, like one with an inline `[key: string]: any`); prop names that come from a type parameter (a deferred type, an index
keyed or valued by one, a mapping whose keys are one or are made from one
(`on${K}`), or whose `as` clause uses one still open where the props are
read, or props that refer to one and show the compiler no members at all;
a generic whose own props take any name, or map keys of its own, is
compared, as are an alias or a key filter given fixed arguments and props
that are plainly empty); or a package config with errors.
Without TypeScript nothing is compared and the report says why.
Finding each doc's tables, and running the comparison in `astryx doctor
integration components`, is the follow-up PR.
dbbf90d to
855c01b
Compare
User need
An integration package documents each component's props in its
<Name>.doc.mjs, and people and agents write code from those tables (astryx component <Name>). When a component changes and its doc does not, the doc says something false: a prop that no longer exists, or a required prop the doc doesn't mention or marks optional. Nothing checks this today. On prop names andrequiredflags alone, Core's own docs disagree with Core's props in 99 places.Problem and solution fit
This PR adds the comparison as a foundation check, on #7681, which reads the package's TypeScript project as
tscreads it and finds the references in it the compiler can't resolve. #7676, stacked on this PR, finds each component doc's prop tables, runs the comparison inastryx doctor integration componentsand adds the repository test over Core's own docs. #7684, stacked on that, reads two written types as one, and #7667, stacked on it, judges prop types. They were one PR until a thorough fix took it past 2,000 lines. It split where the check meets the command, as #7664 and #7673 do, then where comparing a table meets finding the tables, and then where reading the package's TypeScript project meets comparing props.comparePropTablescompares each documented prop table with the props of the export it describes, read with the project's owntypescriptand the package's TypeScript project. A table is a doc'sprops, or a multi-component doc's entry, with the export it documents and the files that may hold it.not_in_code: a documented prop the component doesn't have.not_in_doc: a prop the package's own source declares, or builds with a mapped type whose keys it writes, that the table leaves out. Props a component inherits from React, the DOM or another package aren't required in the table, and neither are props built from another package's keys. A prop a mapped type builds from keys the check can't trace is listed inpropsNotCheckedinstead.required_mismatch: the doc'srequireddisagrees with the code.A component whose props it can't read is listed as
uncheckedwith the reason, and never compared as a guess:/// <reference>whose types or file are missing, a side-effect import where the compiler checks one (the default from TypeScript 6), and, in files that aren't a dependency's, a type name declared nowhere. A module found without types (a JavaScript file) resolves, and its names read asany. The reason names the reference. The program's other roots count the same way, for every component: the package's own declaration files the config includes, and a type package the program loads by name (compilerOptions.types) that can't be found. Past those roots only the package's own files are followed. A dependency's declarations, reached by name, by/// <reference types>or by an import, aren't:tscskips their errors (skipLibCheck), and they reach a component only through the globals they declare."types": ["*"]names every type package found, as TypeScript 6 reads it. However the code reaches the props, a dependency that isn't installed can't then produce a finding. With every dependency installed, nothing is listed for this reason;tsconfig.jsonwith errors, such as anextendsthat isn't installed: every table is listed, with the error, instead of reading the package with defaults;any, written so or through React's component types (FC<any>,memo((props: any) => …),forwardRef<E, any>), and an export typedany;Omit<ComponentPropsWithoutRef<E>, 'as'>), a bareprops: P, or a mapped type over one;forwardReformemocomponent when React's types aren't installed;A name that an index signature takes counts as present only as TypeScript would take it. A
stringkey takes any name; a template literal key takes only names that fit it (data-${string},col${number},on${Capitalize<string>}); and a key whose value isneverorundefined(Record<string, never>, a common way to write "no props") takes none. Without TypeScript nothing is compared, and the report says why; with a TypeScript that has no JavaScript compiler API to read types with (TypeScript 7), the reason names the installed version instead of asking for TypeScript.Current specification
Implements
architecture:cli-surfaceINV33, "Documented component props agree with the source" (AST-039 FR12, which now points to it; DEC-5), with #7681, #7676, #7684 and #7667. Its evidence row names the prop-drift, type-text, doctor-integration and core-prop-docs tests.Public delta and caller burden
[chore]entry that says so.Design and alternatives
tscdoes (which config, its references, and the declaration files it includes). The program holds the component sources and those declaration files.ComponentType<P>: a class or a function, directly or as a higher-order component's result) through each member. A prop only some members or overloads require is optional, as a caller may leave it out, and so is one the package'sdefaultPropsfills, as it is to JSX callers. A field (x.y) is found in any member of a union, through arrays and tuples at any depth (grid[][].id).a / bnames,x.yfields and...restspreads are read as written.not_in_doconly for props the package declares. Requiring every inherited DOM prop in every table would bury the real findings. A table never has to listref,key, or the internal and styling props the publishedComponentPropDoctype tells authors to skip:xstyle,className,style,data-testid, and a prop marked@internal(INV33, as amended in cbb7fa3).Evidence
foundation/discovery/prop-drift.test.mjs: 3 tests ofcomparePropTableshere. To keep each PR under 2,000 lines, the rest of this comparison's tests ride in the PRs stacked on it, which run this code: feat(cli): doctor integration components compares documented props with the code #7676 has the method, union-order,defaultProps, index-signature, React-types, names-and-fields and no-TypeScript tests, and feat(cli): doctor integration components judges documented prop types against the code #7667 has the test of props from other packages and the exempt list. Together they cover, each fix with a test that failed first:data-*index signatures;a / bnames, fields and spreads;defaultPropsfills read as optional, on a class and on a function, in both directions, while a library type's optionaldefaultPropsfills nothing; every overload of a component;extends,Omit<…>,Partial<…>, a named alias inextends, in an intersection and alone, a nested utility, a mapped type written over a type that doesn't resolve, a missing package fiveextendsaway,Pick<…>behind an alias, a template literal key over a missing type, the package's own generic overRequired<Pick<…>>, a class whose base doesn't resolve, props generic over the element they render (with and withoutOmit,Partialof one, a key remapped from one, a bareprops: P), a mapping whoseasclause names props from a type parameter (through an alias, in an intersection or behind another alias, inline, or behind a condition), keys made from one (on${K}), props that refer to a type parameter and show the compiler no members at all, aforwardRefexport whose React types are missing, and a missing export, while a package's ownanyindex signature is still compared, and so are a generic whose own props take any name or map its own keys, anasclause over its own key, an alias or a key filter given fixed arguments (Except<Props, 'b'>,PickByValue<Handlers, Function>), and props that are plainly empty, generic or not, whose documented props are reported;anythroughFC<any>,memo((props: any) => …)andforwardRef<HTMLDivElement, any>, listed as typedany; a props interface declared in an ambient.d.tsthe tsconfig includes, compared; a component typedComponentType<P>, and one a higher-order component returns, compared;Card.HeaderafterObject.assign), a conditional,??, a comma expression, and the package's own higher-order component with no declared return type whose props map overkeyof P;/// <reference types>, and a side-effect import of a missing package; while an installed JavaScript dependency without types leaves the component compared;keyof(Omit<…, keyof X>), aRecord's keys, an alias ofkeyof X, or a template literal key, listed as unchecked;data-*and other template literal keys, anever-valued index (alone and as a union member), a field aRecordon the prop takes, fields in an array of a union, in nested arrays and in a tuple, and props or an export typedany;Since the first automated review
@astryxdesign/cli; the split had left the only one in feat(cli): doctor integration components compares documented props with the code #7676.Omit<…>orPartial<…>from a type that doesn't resolve, directly, through a named alias (inextends, in an intersection or alone) or nested, lists the component as unchecked: an index signature whose value is the compiler's error type counts as unresolved. It no longer counts as compared.After an independent audit
A reviewer who didn't write the code drove the real CLI over 53 package shapes (no, old and broken TypeScript; aliases, unions, optional and method props;
.tsand.mjsdocs; family docs; a replaced Core component; no package) on main, on each head, and on all of them merged, then swept every way an index signature can take a name. Fixed here, each with a test that failed first:namediffers from its file name was compared with the file's export, which gave 18 falsenot_in_codefindings on a real package. Its own props now document the export itsnamenames.extendshops away went undetected. The search follows any depth now, reading each type once.{as?: E} & Omit<ComponentPropsWithoutRef<E>, 'as'>, took every documented name through the generic index, so a prop the code lacks went unreported and the run printed[ok]. A component whose prop names come from a type parameter is now listed as unchecked. That coversOmitandPartialover one, a key remapped from one, an intersection with one, and a bareprops: P, which TypeScript widens to take any prop, so comparing it against its constraint gave false findings.${string}:${string}tookelevationandcol${number}tookcolSpan. Keys now match as TypeScript matches them.neverorundefined(Record<string, never>) took every name. It takes none now.anywere listed with a reason about an uninstalled package. The reason now says the props are typedany, and only when the source writesany: on TypeScript 4.8 and 5.0, an unresolved import can make the props typeany, and that keeps the reason about the import.{[K in keyof MissingProps]?: string}) counted as compared. It is listed as unchecked now.Record<string, string>(labels.close) was reported as missing. A field an index signature on the prop takes counts as present, as TypeScript accepts it.After the next review
forwardReformemocomponent whose React types aren't installed was listed as "not a component". It gets the unresolved-type reason now, like the other types that don't resolve.createProgram, the comparison said TypeScript wasn't installed and asked for it. It names the installed version now and says it can't read types with it.project-typescript.mjsand its doc-loads entry change the same way, byte for byte, in feat(cli): check each authored doc against its published authoring type #7664.[chore]entry; the coverage check accepts it.defaultPropsfills was reported asrequired_mismatchwhen its doc marked it optional, although TypeScript accepts leaving it out. Itsrequiredis not compared now.items[].valuewhere one member hasvalue), in nested arrays (grid[][].id) or in a tuple (pair[].id) was reported as missing. Fields are found through every member, every array level and every tuple element now.: anyis listed as typedany, not as an unresolved type.After the review that followed
anythrough React's component types were misreported:FC<any>andmemo((props: any) => …)were told to install dependencies, andforwardRef<HTMLDivElement, any>counted as compared while taking every documented name. All three are listed as typedanynow. On TypeScript 4.8 and 5.0, which can turn a name that doesn't resolve into a plainany, the source tells the two apart, so those keep the unresolved-type reason.tsconfig.jsonincludes were never loaded, so a component whose props use a type declared there was listed as unchecked. They join the program now, with files that declare globals or augment a module.defaultPropscounts only when the package sets it (a class's static field, or an assignment to a function). React's own types declare an optionaldefaultPropson every component type, and without strict null checks that read as filling every prop, sorequiredwent uncompared.After the round that followed
Pick<…>from a package that isn't installed, behind an alias, counted as compared, and every picked prop read as required, which gave a falserequired_mismatch. A name that doesn't resolve wherever it shapes the props now lists the component as unchecked. An independent sweep of 31 such routes found none still compared:Pick,Required,Readonly,NonNullable,Exclude,Extract,Parameters,ReturnType,ComponentProps<typeof …>, a conditional type,keyof, an indexed access, the package's own generic wrappers, an interface extending an alias, and a class whose base doesn't resolve (directly, through a variable, or React'sComponentwithout React's types), each directly and behind an alias.ComponentType<P>, directly or as a higher-order component's result, was "not a component". Each member of the union is read now.tsconfig.json("files": []withreferences) had its referenced config never read, so a path-aliased import was blamed on missing dependencies. The referenced config that includes the sources is read now, at any depth. A config with errors lists every table with the error, instead of reading the package with defaults.[ok]from each config the CLI never read: a reference two levels down, and a monorepo's root config for a local package with no config of its own. Both are read now, astscreads them. An app's config above an installed package is still never read.Component<Props, MissingState>), and the old one already did the same toforwardRef<MissingHandle, Props>, to a wrapper used only for a member's type, and to an index signature declared with a missing value type. In each, every prop name and flag is known. A generic's argument now counts only when its parameter shapes prop names, read from the generic's own declaration (Pick<T, K extends keyof T>shapes by both,forwardRef<T, P>byPonly). All four are compared now, and every route the sweep listed stays unchecked. A last sweep of the rule found one more false[ok]: a template literal key built on a missing type ({[P in `on${Missing}`]?: () => void}) took any name starting withon. It is listed as unchecked now, written directly or through the package's own generic.namenames another file's export, one table per doc) moved to feat(cli): doctor integration components compares documented props with the code #7676 unchanged, beside the command that uses it. That keeps this PR under 2,000 lines.After the latest review
memo(Inner),forwardRef(render),const Card = Impl,Object.assign(Root, …),satisfies), aPick<…>alias or an`on${…}`key built on a missing package still counted as compared, and every picked prop drew a falserequired_mismatch. The walk now follows each binding to its declaration. An identifier whose import doesn't resolve counts as unresolved where it supplies the props: a call's argument counts only when its parameter's type names a type parameter the result's props depend on, so a missingpropsAreEqualformemo, or a missing config for a higher-order component, leaves the component compared. An own callee's result type is read too. On TypeScript 6, 5.0 and 4.8, every binding shape above is listed as unchecked. An independent audit's 17 binding fixtures (missing values in a higher-order component's arguments, in an object argument, indisplayName, as anObject.assignmember, as a ref handle or behind a conditional) each compare when every prop is known, and are listed when a missing type supplies the props.Parameters<typeof useX>[0]over a missing package drew a falsenot_in_codefor every documented prop, andComponentProps<typeof X>was called typedany. Atypeofquery whose target doesn't resolve now counts as unresolved, on every version. The test pins it on the repository's TypeScript, which already resolves both. On 5.0.4 and 4.8.4, CLI runs on the same fixtures show the false finding before the fix and the unresolved reason after it.After the review that came next
Component<Props>orPureComponent<Props>, withPropsbuilt from a missing package, was still compared and drew a falserequired_mismatchornot_in_code. React'sComponentis an interface merged with a class, and a type parameter was matched by its first declaration only. It matches by any declaration now. On TypeScript 6, 5.0 and 4.8, such classes are listed as unchecked, including through`on${…}`keys andtypeof.tag: Treaches no props. An independent audit's fixtures for such HOCs, and its binding fixtures, all compare or stay unchecked as TypeScript reads them.After the most recent review
Card.HeaderafterObject.assign(Root, {Header: Inner})), a conditional, a comma expression,??or||. Such an export still counted as compared and drew a falserequired_mismatch. So did the package's own higher-order component with no declared return type whose props map overkeyof P./// <reference>whose types or file are missing, and a side-effect import where the compiler checks one. This replaces the binding walk and the shaping rules of the three rounds above, and the PR is 173 lines smaller.tschides that error underskipLibCheck. The walk follows the dependency's files now./// <reference types>or/// <reference path>, and a failed side-effect import under TypeScript 6, dropped the global prop types they carry. They count now.any.forwardRef<MissingHandle, Props>, a class's missing state type,memowith a missingpropsAreEqual. Installing the dependencies compares them.type Base = Pick<Missing, 'title'>), whose properties carry the compiler's error type.After the round after that
compilerOptions.typesnames, wasn't seen by the rule: the walk started from each component's own files. If it wasn't installed, props such asOmit<{label: string; size?: number}, keyof MissingProps>drew a falsenot_in_code, and a mapped type overkeyof MissingPropstook every documented name. The walk now starts from the config's declaration files too, and atypesentry that can't be found counts. Either lists every component, since their globals reach all of them, with the reference named. A test covers both and a control where both resolve; it failed before the fix.@testing-library/jest-dom, whose types referencejest, which this repository doesn't install.tscpasses, becauseskipLibCheckskips errors in a dependency's declarations; following them had listed all of Core. An independent audit found the same through a declaration file's/// <reference types>and through its import of an installed library with a missing optional peer. It also found"types": ["*"], which TypeScript 6 reads as every type package found, reported as a missing package. All three are compared now, astscpasses them, and the test covers them. Core and Lab are unchanged: 176 and 40 components compared, 99 and 108 findings, none unchecked.filesentries.After the latest audit
typeRootstype package. feat(cli): read a package's TypeScript project the way tsc reads it #7681 walks both now, with tests that failed before. Core and Lab are unchanged: 176 and 40 components compared, 99 and 108 findings, none unchecked.After the most recent review
keyofor a template literal key, left the component compared:keyofof the compiler's error type isstring | number | symbol, soOmit<{label: string; size?: number}, keyof X>drew a falsenot_in_code, andPartial<Record<keyof X, string>>, a mapped type over an alias ofkeyof X, and`on${X}`keys took documented props the code doesn't have. A type name that resolves to no declaration, in files that aren't a dependency's, now lists the component, with the name in the reason ("used in …"). That's the import rule extended from modules to names, and it covers a global from an@typespackage that isn't installed, too. The walk lives in feat(cli): read a package's TypeScript project the way tsc reads it #7681. The unchecked test adds all four shapes, and the declaration-file test adds a global made from such a name; both failed before.After the review in round 14
const defaults = undeclared;withkeyof typeof defaults), a property that doesn't exist (keyof Theme['sizes']), and a name declared nowhere in a.jsxcomponent's JSDoc, whichtscdoesn't report. Any type in the component's own files that the compiler can only give its error type now lists it, JSDoc types included in JavaScript files. The walk is in feat(cli): read a package's TypeScript project the way tsc reads it #7681. When the only problem in a component's files is such a type and a global declaration file has a missing import behind it, the reason names the import, since that's what to install. The unchecked test adds all three; each failed before. Core and Lab are unchanged: 176 and 40 compared, 99 and 108 findings, none unchecked.const defaults = {...theme.sizes}(wherethemehas nosizes) withkeyof typeof defaults, and{...undeclaredTones}. The walk in feat(cli): read a package's TypeScript project the way tsc reads it #7681 now checks what atypeofreads. The unchecked test adds both shapes, and both failed before.JSX.Elementunder React's newer types. It now says the component "depends on X (used in Y), a type the compiler can't work out, so its props would be a guess", and asks to check that dependencies are installed and the types compile. The unchecked test checks that wording.Docs
After the review of the exempt props and defaultProps
defaultProps. A prop the package'sdefaultPropsfills was left out of therequiredcomparison, so a doc that marked itrequired: truedrew no finding, though JSX callers may leave it out. It is optional now: such a doc drawsrequired_mismatch, and one that leaves it optional draws nothing. The test covers both directions, and failed before.ref;key, which React reserves; and the internal and styling props the publishedComponentPropDoctype tells authors to skip (authoring/doctypes/base/type.ts):xstyle,className,style,data-testid, and a prop marked@internal. Core'sUNIVERSAL_PROPS(docPropReferences.test.ts) treats the same names as omitted.@deprecatedis no longer exempt: a deprecated prop the component still accepts is still a prop. The test pinsPROPS_NOT_DOCUMENTEDexactly and asserts a deprecated prop warns.minetest (feat(cli): read a package's TypeScript project the way tsc reads it #7681), so the comparison passes oneminefor the whole run.After the review of
memoandlazymemo(Inner)orlazy(() => import('./Inner'))readdefaultPropsfrom the wrapper, which has none, so the propInner'sdefaultPropsfills stayed required. It now reads the wrapped component's, as React's JSX types do, anddefaultPropstypedanyfills every prop. This one reading is the only one: feat(cli): doctor integration components compares documented props with the code #7676 no longer touches it. ThedefaultPropstest addsmemo,lazyandanycases, which failed before. Core and Lab are unchanged.After the review of mapped props
{[K in 'top' | 'bottom']?: number},Partial<Record<'tone' | 'size', string>>, or keys renamed withas. It counted as another package's, so a doc that left it out drew no warning. It is the package's own now when the package's own type syntax builds it, read from the component's declaration and its props parameter and followed only into the package's files.Record<Size, T>,keyof Base, a set likekeyof React.CSSProperties) unlessasrenames it, and so does a prop that a type another package names builds.ownis decided. Thenot_in_doctest adds these shapes, which failed before: own literal keys,Partial<Record<…>>, anasrename, own keys passed to another package's mapping, an interface that extends a mapped type, and anFC<…>-typed export, plus the exempt shapes.memoandlazyare followed two layers deep, as React'sJSX.LibraryManagedAttributesfollows them. Somemo(memo(memo(Klass)))keeps its defaulted prop required, astscdoes. ThedefaultPropstest adds that case, which failed before.docType,codeType,docRequiredandcodeRequired. The printing gives the code's type as the source writes it, a union's members in source order, a method prop's function type, and an intersection's narrowed type. Two small helpers were folded into their callers, and the overload test joined the union test. Core and Lab findings are unchanged.After the review of mapped-prop ownership
type StyleProp = keyof CSSProperties; {[K in StyleProp]?: CSSProperties[K]}drew 857 warnings, whilekeyof CSSPropertieswritten inline drew none, and another package'sResponsive<number>drew its breakpoints. In the other direction, the package's own mapped props drew nothing behindmemo(Inner)orforwardRef(Impl), or when their keys passed throughExclude<…>.keyof, and a conditional's branches (soExcludeandExtract);Opts['size']), and a constant's value ((typeof SIZES)[number]), through its declared type, a cast,as const, and other constants it is bound to;memo,lazyorforwardRefcall wraps.asclause renames. A prop whose key the check can't trace, such as one from a template literal, aninfer, or a function's result, is not asked for and is listed inpropsNotChecked({component, doc, reason, props}), so a clean result never claims it was checked.not_in_doctest adds the four shapes: a local alias of another package's keys (alone and as a type argument), another package'sResponsive<number>,memo(Inner)andforwardRef(Impl), andExclude. It also covers another package's keys through an own interface's property, aninferhelper and a re-bound constant, and checks that the alias and the inline form give the same answer and that an untraced key is listed. These failed before. Core and Lab output is byte-identical.After the review of
anyindex signatures and overloadsRecord<string, any>(orRecord<`data-${string}`, any>) was listed as typedany, thoughtscstill types its declared props, and the same props with an inline[key: string]: anywere compared. An alias'sanyargument now counts only when the alias reads that argument's keys (keyof T), asOmit<any, 'ref'>fromforwardRef<E, any>does. So theRecordform is compared like the inline one, andFC<any>,memo((props: any) => …)andforwardRef<E, any>are still listed as typedany.required, though<Component />type-checks. Such a prop is optional now, so a doc marking it required drawsrequired_mismatch.After the review of props only some variants require
required, so a doc marking itrequired: truedrew nothing, though callers may leave it out. Core'sContextMenu.itemsandDropdownMenu.items, whose compound mode takes noitems, are examples. Neither value is exact for such a prop: for{mode: 'list', items} | {mode: 'compound', children},required: trueis half true, and calling it optional would flag correct docs. So it follows INV33's rule for what the check can't judge exactly: it is listed, never called a match or a mismatch, and never skipped silently. Such a prop goes inpropsNotCheckedwith the reason "only some variants of the props require them, so the doc'srequiredwas not checked". Core lists seven components this way. A prop beside an overload that takes no props stays optional, since<Component />type-checks.anythrough the package's own alias of a key-reading utility (type Without<T> = Omit<T, 'as'>used asWithout<any>) were compared, whileOmit<any, 'as'>written directly was listed as typedany. The check now follows a parameter into the alias the body passes it to, so both are listed the same way. It also reads a name the package gives such props (type P = Without<any>,interface P extends Omit<any, 'as'>), and an alias argument that is itself such a type, as React'sforwardRefpassesOmit<any, 'as'>on to its ownOmit(ortype P = Omit<Omit<any, 'as'>, 'ref'>writes it). An independent audit found both still compared; feat(cli): doctor integration components compares documented props with the code #7676's React-types test covers theforwardRefforms.Without<any>case written four ways. These failed before. To stay under 2,000 lines, two more of the comparison's tests moved up to feat(cli): doctor integration components compares documented props with the code #7676: names that list several props, spreads and fields, and the skip without a usable TypeScript.After the round-21 review
required: truegetsrequired_mismatch.requiredmeans every caller must pass the prop, andtscaccepts a call that leaves this one out, as it does for a prop beside an overload that takes no props or onedefaultPropsfills. A description is where "needed in list mode" belongs. This replaces the last round's listing of such props. On Core it adds exactlyContextMenu.itemsandDropdownMenu.items, whose compound mode takes noitems, and the seven components that round listed are compared again.value / onChange) has onerequiredflag for all of them, which is exact for none, and they were skipped silently. Each such prop is now listed inpropsNotCheckedwith the reason "one doc entry names several props, so itsrequiredwas not checked", and the[ok]line leaves it out. A prop that also has an entry of its own is checked through that entry and not listed.{label?: string} & {[K in string]?: any}was listed as typedany, because the typed-anycheck read a mapped type's value template. It now reads a declaration's type only from a type alias, so this form is compared like theRecord<string, any>form, and theWithout<any>forms stay typedany.required_mismatchfor a variant prop marked required and a match for one marked optional, and adds a two-name entry and the mappedanyindex. It failed before. To stay under 2,000 lines, thedefaultPropstest moved up to feat(cli): doctor integration components compares documented props with the code #7676.After the round-22 review
value / onChange) now gives each named prop the entry'srequiredand compares it as for a single-name entry. This replaces the last round's listing. The flag is a claim about every prop the entry names, so an entry whose one flag can't fit both props draws the mismatch.propsNotCheckedagain holds only props built from keys the check can't trace. The overload test pins an entry whose flag fits both props and one the code contradicts for one of the two names; it failed before.Rebased onto current main
itemsdoc, so of the two Core findings the round-22 change added, onlyDropdownMenu.itemsremains.After the latest review
Table.emptyStatesat betweenTableCell.scopeandTableHeaderCell.contextMenuActions. Findings now sort by component, then prop, then kind. The first test adds aBadgeCountbesideBadgeand pins the order; it failed before. Core's findings are the same set, in the new order.After the review of generic props
function GenericOpen<T>(props: {items: T[]; [key: string]: any}), the same with& Record<string, any>, and a bareRecord<'value' | 'initial', T>. The check counted everyany-valued index as names from a type parameter, and for a mapped type it read the value argument as well as the keys.anytest, since an index that takes any name gives names of the component's own. For a mapped type it reads only the keys. These three shapes are compared, as they are without<T>, and so isPick<T, 'a'>.as,Partial<PropsOf<E>>, a key remapping overkeyof T, a bareprops: P), React's{as?: E} & Omit<ComponentPropsWithoutRef<E>, 'as' | 'padding'>,Record<K, string>and{[key: string]: T}. The unchecked test adds the three compared shapes; it failed before. feat(cli): doctor integration components compares documented props with the code #7676's React-types test pins the React form.After the review of
asclausesasclause over a type parameter ({[K in 'value' | 'change' as `${N}${Capitalize<K>}`]?: string}, oras T extends true ? K : never) has no props the compiler can name while the parameter is open. So the table was compared against nothing, and each documented name drew a falsenot_in_code. Through a named alias (Prefixed<N>) it had been listed until the last round.asclause uses a type parameter other than its own key, whether the component declares it or an alias passes it on, when the compiler names fewer props than the mapping has keys (replaced in the next round, below). An alias given a fixed argument (Prefixed<'my'>, also through another alias or inside an intersection) names every prop, so it is compared. So is anasclause that uses only its own key (as Exclude<K, 'b'>), or aninferinside the clause. The unchecked test adds the alias, inline and gated shapes, and the two compared forms; it failed before. TypeScript 4.8, 5.0 and 6.0 give the same results.Bag<T>for an emptyinterface Bag<T> {}) are listed the same way, instead of drawing anot_in_codefor every documented name. Props that are plainly empty, such asfunction AcmeX(),(props: {})orRecord<string, never>, generic or not, are still compared, and each documented prop is reported, astscrejects it there. The test adds both. Core and Lab give the same results.After the review of fixed key filters
Except<Props, 'b'>,PickByValue<Handlers, Function>) also names fewer props than keys, so a generic component using one was listed though every name is known, and its stale or undocumented props went unreported.asclause now counts only while it is still open where the props are read: the component's own, or one an alias passes on whose argument is still a type parameter. The check reads that argument from the instantiation, so an alias inside an intersection or behind another alias is read too.{[P inon${K}]?: () => void}) were compared against no props; they are listed now too.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, #7676, #7684 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,981 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 #7681 merges. On main 9b8b723 with #7681 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,295 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). 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.