Skip to content

feat(cli): compare documented component props with the TypeScript source - #7663

Merged
josephfarina merged 1 commit into
mainfrom
josephfarina/doctor-prop-drift
Oct 19, 2026
Merged

josephfarina merged 1 commit into
mainfrom
josephfarina/doctor-prop-drift

Conversation

@josephfarina

@josephfarina josephfarina commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

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 and required flags 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 tsc reads 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 in astryx doctor integration components and 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.

comparePropTables compares each documented prop table with the props of the export it describes, read with the project's own typescript and the package's TypeScript project. A table is a doc's props, 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 in propsNotChecked instead.
  • required_mismatch: the doc's required disagrees with the code.

A component whose props it can't read is listed as unchecked with the reason, and never compared as a guess:

  • a reference that doesn't resolve in the files the component comes from: its own file, the files it imports, and theirs in turn, the package's dependencies included. That covers a module the program can't find (usually a dependency that isn't installed), a name its module doesn't export, a /// <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 as any. 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: tsc skips 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;
  • with every import resolved, a name declared nowhere where the props type is made from it: an interface's or a class's base, a utility's type argument, or a mapped type over it (directly or behind a named alias);
  • a package tsconfig.json with errors, such as an extends that isn't installed: every table is listed, with the error, instead of reading the package with defaults;
  • props that are any, written so or through React's component types (FC<any>, memo((props: any) => …), forwardRef<E, any>), and an export typed any;
  • prop names that come from a type parameter: a component generic over the element it renders (Omit<ComponentPropsWithoutRef<E>, 'as'>), a bare props: P, or a mapped type over one;
  • an export whose own type doesn't resolve, such as a forwardRef or memo component when React's types aren't installed;
  • a missing export.

A name that an index signature takes counts as present only as TypeScript would take it. A string key 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 is never or undefined (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-surface INV33, "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

Design and alternatives

  • The project's own TypeScript, not a bundled one. It reads the package's types exactly as the package compiles them, and the CLI gains no dependency. feat(cli): read a package's TypeScript project the way tsc reads it #7681 reads the package's TypeScript project the way tsc does (which config, its references, and the declaration files it includes). The program holds the component sources and those declaration files.
  • The real props type. It reads unions, intersections and every overload, and a union of component types (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's defaultProps fills, 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 / b names, x.y fields and ...rest spreads are read as written.
  • not_in_doc only for props the package declares. Requiring every inherited DOM prop in every table would bury the real findings. A table never has to list ref, key, or the internal and styling props the published ComponentPropDoc type tells authors to skip: xstyle, className, style, data-testid, and a prop marked @internal (INV33, as amended in cbb7fa3).
  • Listed, never guessed. When the compiler can't say which props a component has, the comparison says so, instead of reporting props as missing or passing them unread.
  • A missing import lists the component, whatever the code looks like. The check reads the program's own import resolution instead of following each expression that can carry props. A component that would be comparable despite a missing dependency is listed too, so the reason asks for the install that makes it comparable.
  • Rejected: a bundled TypeScript; a flag or environment variable to turn the comparison on (the CLI has no hidden modes).

Evidence

  • foundation/discovery/prop-drift.test.mjs: 3 tests of comparePropTables here. 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:
    • each finding kind; props from another package and the exempt list, pinned exactly, while a deprecated prop warns; data-* index signatures; a / b names, fields and spreads;
    • a requirement that varies across a union; a prop the package's defaultProps fills read as optional, on a class and on a function, in both directions, while a library type's optional defaultProps fills nothing; every overload of a component;
    • each props type it can't read, listed as unchecked: an unresolved extends, Omit<…>, Partial<…>, a named alias in extends, in an intersection and alone, a nested utility, a mapped type written over a type that doesn't resolve, a missing package five extends away, Pick<…> behind an alias, a template literal key over a missing type, the package's own generic over Required<Pick<…>>, a class whose base doesn't resolve, props generic over the element they render (with and without Omit, Partial of one, a key remapped from one, a bare props: P), a mapping whose as clause 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, a forwardRef export whose React types are missing, and a missing export, while a package's own any index signature is still compared, and so are a generic whose own props take any name or map its own keys, an as clause 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;
    • props any through FC<any>, memo((props: any) => …) and forwardRef<HTMLDivElement, any>, listed as typed any; a props interface declared in an ambient .d.ts the tsconfig includes, compared; a component typed ComponentType<P>, and one a higher-order component returns, compared;
    • a missing import in a component's files, listed as unchecked with the import named, however the props reach the export: a missing type used only for one member's value, a property of an own value (Card.Header after Object.assign), a conditional, ??, a comma expression, and the package's own higher-order component with no declared return type whose props map over keyof P;
    • each other reference that doesn't resolve, listed the same way: a name its module doesn't export, a missing import inside an installed dependency's types, a missing /// <reference types>, and a side-effect import of a missing package; while an installed JavaScript dependency without types leaves the component compared;
    • a missing package that only a declaration file the config includes imports, a global type made from a name declared nowhere, and a type package the config names that isn't installed, each listing every component with the reference named; and both resolved, compared;
    • a type name declared nowhere, reached only through keyof (Omit<…, keyof X>), a Record's keys, an alias of keyof X, or a template literal key, listed as unchecked;
    • a package config with errors, listing every table with the error (feat(cli): read a package's TypeScript project the way tsc reads it #7681 tests how the config is read);
    • names an index signature takes, as TypeScript takes them: data-* and other template literal keys, a never-valued index (alone and as a union member), a field a Record on the prop takes, fields in an array of a union, in nested arrays and in a tuple, and props or an export typed any;
    • no TypeScript installed, and a TypeScript 7-style install with no compiler API (one that exports none, one that can't be loaded).
  • On Core (run by feat(cli): doctor integration components compares documented props with the code #7676's repository test): 176 prop tables, compared in about 3.5 s, with 99 findings, unchanged by this round.

Since the first automated review

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; .ts and .mjs docs; 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:

  • A doc whose name differs from its file name was compared with the file's export, which gave 18 false not_in_code findings on a real package. Its own props now document the export its name names.
  • An overloaded component read only its first signature. Every overload is read now.
  • A missing package five extends hops away went undetected. The search follows any depth now, reading each type once.
  • When two docs had a table for one component, the second was skipped silently. Both are compared now.
  • Props generic over the element a component renders, such as {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 covers Omit and Partial over one, a key remapped from one, an intersection with one, and a bare props: P, which TypeScript widens to take any prop, so comparing it against its constraint gave false findings.
  • A template literal index key matched by its first and last text only, so ${string}:${string} took elevation and col${number} took colSpan. Keys now match as TypeScript matches them.
  • An index whose value is never or undefined (Record<string, never>) took every name. It takes none now.
  • Props typed any were listed with a reason about an uninstalled package. The reason now says the props are typed any, and only when the source writes any: on TypeScript 4.8 and 5.0, an unresolved import can make the props type any, and that keeps the reason about the import.
  • A mapped type written over a type that doesn't resolve ({[K in keyof MissingProps]?: string}) counted as compared. It is listed as unchecked now.
  • A field documented on a prop typed 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

  • A forwardRef or memo component 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.
  • With only TypeScript 7 installed, which has no 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.mjs and its doc-loads entry change the same way, byte for byte, in feat(cli): check each authored doc against its published authoring type #7664.
  • The changeset describes the comparison as groundwork with no user-visible change until feat(cli): doctor integration components compares documented props with the code #7676 runs it, as a [chore] entry; the coverage check accepts it.
  • A class or function component's prop that defaultProps fills was reported as required_mismatch when its doc marked it optional, although TypeScript accepts leaving it out. Its required is not compared now.
  • A field in an array of a union (items[].value where one member has value), 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.
  • An export written : any is listed as typed any, not as an unresolved type.

After the review that followed

  • Props that are any through React's component types were misreported: FC<any> and memo((props: any) => …) were told to install dependencies, and forwardRef<HTMLDivElement, any> counted as compared while taking every documented name. All three are listed as typed any now. On TypeScript 4.8 and 5.0, which can turn a name that doesn't resolve into a plain any, the source tells the two apart, so those keep the unresolved-type reason.
  • Ambient declaration files the package's tsconfig.json includes 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.
  • defaultProps counts only when the package sets it (a class's static field, or an assignment to a function). React's own types declare an optional defaultProps on every component type, and without strict null checks that read as filling every prop, so required went uncompared.

After the round that followed

  • A props type built with Pick<…> from a package that isn't installed, behind an alias, counted as compared, and every picked prop read as required, which gave a false required_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's Component without React's types), each directly and behind an alias.
  • A component typed ComponentType<P>, directly or as a higher-order component's result, was "not a component". Each member of the union is read now.
  • A solution-style tsconfig.json ("files": [] with references) 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.
  • The audit then found a false [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, as tsc reads them. An app's config above an installed package is still never read.
  • The new rule made a class with a missing state type unchecked (Component<Props, MissingState>), and the old one already did the same to forwardRef<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> by P only). 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 with on. It is listed as unchecked now, written directly or through the package's own generic.
  • Finding each doc's tables (family docs, member records, a doc whose name names 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

  • When an export reached its props through another binding (memo(Inner), forwardRef(render), const Card = Impl, Object.assign(Root, …), satisfies), a Pick<…> alias or an `on${…}` key built on a missing package still counted as compared, and every picked prop drew a false required_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 missing propsAreEqual for memo, 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, in displayName, as an Object.assign member, 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.
  • On TypeScript 5.0, Parameters<typeof useX>[0] over a missing package drew a false not_in_code for every documented prop, and ComponentProps<typeof X> was called typed any. A typeof query 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.
  • Reading the package's TypeScript project moved, unchanged, with its tests, to feat(cli): read a package's TypeScript project the way tsc reads it #7681, which keeps this PR under 2,000 lines.

After the review that came next

  • A class component taking props through React's Component<Props> or PureComponent<Props>, with Props built from a missing package, was still compared and drew a false required_mismatch or not_in_code. React's Component is 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 and typeof.
  • A component made by the package's own higher-order component with no declared return type was compared the same way. Such a function is now read through its inferred result: the type parameters its returned component's props hold shape the props, and a result that is no component is shaped by all of them. A missing value passed where it supplies no prop still leaves the component compared, as in an own HOC whose extra tag: T reaches no props. An independent audit's fixtures for such HOCs, and its binding fixtures, all compare or stay unchecked as TypeScript reads them.
  • Rebased on feat(cli): read a package's TypeScript project the way tsc reads it #7681's fix for config errors, deep references and global scripts.

After the most recent review

  • With the dependency behind its props not installed, an export could reach its props through a property of an own value (Card.Header after Object.assign(Root, {Header: Inner})), a conditional, a comma expression, ?? or ||. Such an export still counted as compared and drew a false required_mismatch. So did the package's own higher-order component with no declared return type whose props map over keyof P.
  • Rather than follow one more expression form, the check now reads the program's own resolution. If any reference fails to resolve in the files a component comes from (its own file, the files it imports and theirs, the package's dependencies included), the component is listed as unchecked, and the reason names the reference. That covers a module the program can't find, a name its module doesn't export, a /// <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.
  • An independent audit of the rule found four gaps, each fixed with a test that failed first.
    • A name a module doesn't export never counted, because the compiler's stand-in for it carries flags. It counts now.
    • A missing peer inside an installed dependency's type files left the props guessed; tsc hides that error under skipLibCheck. The walk follows the dependency's files now.
    • A missing /// <reference types> or /// <reference path>, and a failed side-effect import under TypeScript 6, dropped the global prop types they carry. They count now.
    • An installed JavaScript module without types was called missing. It resolves now, and its names read as any.
  • A component whose props its own files fully declare, but whose files also import a missing dependency, is now listed too, where it was compared before. Examples: a missing type used only for one member's value, forwardRef<MissingHandle, Props>, a class's missing state type, memo with a missing propsAreEqual. Installing the dependencies compares them.
  • With every import resolved, the type-level checks still catch a name declared nowhere. That includes a mapped type over one behind a named alias (type Base = Pick<Missing, 'title'>), whose properties carry the compiler's error type.
  • With dependencies installed, Core and Lab are unchanged: 176 and 40 components compared, 99 and 108 findings, none unchecked. An independent audit's 212 fixtures changed only where a component's files import a missing dependency (91 of them), and on TypeScript 6, 5.0 and 4.8 every missing-base and binding shape is listed with the import named.

After the round after that

  • A package that only a declaration file the config includes imports, or a type package compilerOptions.types names, wasn't seen by the rule: the walk started from each component's own files. If it wasn't installed, props such as Omit<{label: string; size?: number}, keyof MissingProps> drew a false not_in_code, and a mapped type over keyof MissingProps took every documented name. The walk now starts from the config's declaration files too, and a types entry 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.
  • Past those roots, only the package's own files are followed. Core's shared config names @testing-library/jest-dom, whose types reference jest, which this repository doesn't install. tsc passes, because skipLibCheck skips 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, as tsc passes them, and the test covers them. Core and Lab are unchanged: 176 and 40 components compared, 99 and 108 findings, none unchecked.
  • Only the package's own declaration files start the walk. A local package with no config of its own reads a monorepo root's config, which may include every file in the repository. Walking all of them had listed every component because a test script elsewhere imported a generated file. The program still loads those files for their types. A test covers a package that reads its monorepo's root config; it failed before the fix.
  • Rebased on feat(cli): read a package's TypeScript project the way tsc reads it #7681's fix for files entries.

After the latest audit

  • Finding the references the compiler can't resolve moved, with its tests, to feat(cli): read a package's TypeScript project the way tsc reads it #7681, which reads the package's TypeScript project. This PR had reached 1,992 lines. It now calls that walk for each component's files and for the program's global roots, and keeps the integration tests: every reference kind lists the component with the reference named, and a global one lists every component.
  • An independent audit found two roots the global walk missed: a declaration file the package's own config includes from outside its folder or through a symlinked folder, and the package's own typeRoots type 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

  • A type name declared nowhere, reached only through keyof or a template literal key, left the component compared: keyof of the compiler's error type is string | number | symbol, so Omit<{label: string; size?: number}, keyof X> drew a false not_in_code, and Partial<Record<keyof X, string>>, a mapped type over an alias of keyof 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 @types package 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.
  • Core and Lab are unchanged: 176 and 40 components compared, 99 and 108 findings, none unchecked.

After the review in round 14

  • An error type whose root isn't a name still left the component compared: a value declared nowhere behind a binding (const defaults = undeclared; with keyof typeof defaults), a property that doesn't exist (keyof Theme['sizes']), and a name declared nowhere in a .jsx component's JSDoc, which tsc doesn'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.
  • A value the compiler can't type, spread into an object, still left the component compared: const defaults = {...theme.sizes} (where theme has no sizes) with keyof typeof defaults, and {...undeclaredTones}. The walk in feat(cli): read a package's TypeScript project the way tsc reads it #7681 now checks what a typeof reads. The unchecked test adds both shapes, and both failed before.
  • A message printed a union's members in the order TypeScript made the types, which moves with whatever reads them first; this round's check made Core's messages reorder. Members are printed in the order the source declares them now, through the aliases it names, and the rest sorted by their text. A test makes the compiler's order differ from the source's and pins the source's.
  • The reason for a type the compiler can't work out said "could not resolve, usually a dependency that is not installed", which misleads when the name resolves and only its type is broken, such as a dependency's JSX.Element under 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's defaultProps fills was left out of the required comparison, so a doc that marked it required: true drew no finding, though JSX callers may leave it out. It is optional now: such a doc draws required_mismatch, and one that leaves it optional draws nothing. The test covers both directions, and failed before.
  • The exempt list. It is exactly what INV33 (cbb7fa3) names, from the shipped records: ref; key, which React reserves; and the internal and styling props the published ComponentPropDoc type tells authors to skip (authoring/doctypes/base/type.ts): xstyle, className, style, data-testid, and a prop marked @internal. Core's UNIVERSAL_PROPS (docPropReferences.test.ts) treats the same names as omitted. @deprecated is no longer exempt: a deprecated prop the component still accepts is still a prop. The test pins PROPS_NOT_DOCUMENTED exactly and asserts a deprecated prop warns.
  • Neither change moves a Core or Lab finding.
  • The walk's record is kept per program and per mine test (feat(cli): read a package's TypeScript project the way tsc reads it #7681), so the comparison passes one mine for the whole run.

After the review of memo and lazy

  • An export written memo(Inner) or lazy(() => import('./Inner')) read defaultProps from the wrapper, which has none, so the prop Inner's defaultProps fills stayed required. It now reads the wrapped component's, as React's JSX types do, and defaultProps typed any fills 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. The defaultProps test adds memo, lazy and any cases, which failed before. Core and Lab are unchanged.

After the review of mapped props

  • A prop that a mapped type of the package's own builds from literal keys has no declaration: {[K in 'top' | 'bottom']?: number}, Partial<Record<'tone' | 'size', string>>, or keys renamed with as. 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.
  • A prop whose key another package defines stays exempt (Record<Size, T>, keyof Base, a set like keyof React.CSSProperties) unless as renames it, and so does a prop that a type another package names builds.
  • The rule moved here from feat(cli): doctor integration components compares documented props with the code #7676, because this is where own is decided. The not_in_doc test adds these shapes, which failed before: own literal keys, Partial<Record<…>>, an as rename, own keys passed to another package's mapping, an interface that extends a mapped type, and an FC<…>-typed export, plus the exempt shapes.
  • memo and lazy are followed two layers deep, as React's JSX.LibraryManagedAttributes follows them. So memo(memo(memo(Klass))) keeps its defaulted prop required, as tsc does. The defaultProps test adds that case, which failed before.
  • To stay under 2,000 lines, some fields moved up to feat(cli): doctor integration components compares documented props with the code #7676, with the printing and the two tests behind them. They are the fields a finding carries only for the command to show: docType, codeType, docRequired and codeRequired. 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

  • The old rule looked only at the key syntax written in the mapping. So keys another package defines counted as the package's own behind a local alias or a type argument: type StyleProp = keyof CSSProperties; {[K in StyleProp]?: CSSProperties[K]} drew 857 warnings, while keyof CSSProperties written inline drew none, and another package's Responsive<number> drew its breakpoints. In the other direction, the package's own mapped props drew nothing behind memo(Inner) or forwardRef(Impl), or when their keys passed through Exclude<…>.
  • Ownership now follows where each key comes from. A prop a mapped type builds is the package's own only when its key traces to the package's own source, as a literal or a property declaration, through:
    • aliases, a generic's arguments, keyof, and a conditional's branches (so Exclude and Extract);
    • an indexed property's own type (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;
    • a homomorphic mapping's link to the property it copies;
    • the component a memo, lazy or forwardRef call wraps.
  • A key another package defines is that package's, written inline or behind an alias. That now includes keys an as clause renames. A prop whose key the check can't trace, such as one from a template literal, an infer, or a function's result, is not asked for and is listed in propsNotChecked ({component, doc, reason, props}), so a clean result never claims it was checked.
  • The not_in_doc test adds the four shapes: a local alias of another package's keys (alone and as a type argument), another package's Responsive<number>, memo(Inner) and forwardRef(Impl), and Exclude. It also covers another package's keys through an own interface's property, an infer helper 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.
  • To stay under 2,000 lines, four of the comparison's tests moved up to feat(cli): doctor integration components compares documented props with the code #7676: a declaration file or type package that doesn't resolve, a config with errors, a name through an index signature, and props through React's component types.

After the review of any index signatures and overloads

  • A typed props type intersected with Record<string, any> (or Record<`data-${string}`, any>) was listed as typed any, though tsc still types its declared props, and the same props with an inline [key: string]: any were compared. An alias's any argument now counts only when the alias reads that argument's keys (keyof T), as Omit<any, 'ref'> from forwardRef<E, any> does. So the Record form is compared like the inline one, and FC<any>, memo((props: any) => …) and forwardRef<E, any> are still listed as typed any.
  • With an overload that takes no props, a prop required in another overload was never compared for required, though <Component /> type-checks. Such a prop is optional now, so a doc marking it required draws required_mismatch.
  • The overload test adds both cases, which failed before. Core and Lab are unchanged.

After the review of props only some variants require

  • A prop that only some union members or overloads require was never compared for required, so a doc marking it required: true drew nothing, though callers may leave it out. Core's ContextMenu.items and DropdownMenu.items, whose compound mode takes no items, are examples. Neither value is exact for such a prop: for {mode: 'list', items} | {mode: 'compound', children}, required: true is 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 in propsNotChecked with the reason "only some variants of the props require them, so the doc's required was not checked". Core lists seven components this way. A prop beside an overload that takes no props stays optional, since <Component /> type-checks.
  • Props typed any through the package's own alias of a key-reading utility (type Without<T> = Omit<T, 'as'> used as Without<any>) were compared, while Omit<any, 'as'> written directly was listed as typed any. 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's forwardRef passes Omit<any, 'as'> on to its own Omit (or type 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 the forwardRef forms.
  • The overload test adds a prop one variant requires (marked required, and one marked optional: both listed, neither flagged) and the 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

  • A prop that only some union members or overloads require now reads as optional, and a doc that marks it required: true gets required_mismatch. required means every caller must pass the prop, and tsc accepts a call that leaves this one out, as it does for a prop beside an overload that takes no props or one defaultProps fills. A description is where "needed in list mode" belongs. This replaces the last round's listing of such props. On Core it adds exactly ContextMenu.items and DropdownMenu.items, whose compound mode takes no items, and the seven components that round listed are compared again.
  • A doc entry that names several props (value / onChange) has one required flag for all of them, which is exact for none, and they were skipped silently. Each such prop is now listed in propsNotChecked with the reason "one doc entry names several props, so its required was 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 typed any, because the typed-any check 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 the Record<string, any> form, and the Without<any> forms stay typed any.
  • The overload test expects required_mismatch for a variant prop marked required and a match for one marked optional, and adds a two-name entry and the mapped any index. It failed before. To stay under 2,000 lines, the defaultProps test moved up to feat(cli): doctor integration components compares documented props with the code #7676.

After the round-22 review

  • A doc entry that names several props (value / onChange) now gives each named prop the entry's required and 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. propsNotChecked again 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

After the latest review

  • Findings were sorted by one key that joined component, prop and kind with a NUL. Collation ignores NUL, so a component's findings could sort among those of another sharing its prefix: on Core, Table.emptyState sat between TableCell.scope and TableHeaderCell.contextMenuActions. Findings now sort by component, then prop, then kind. The first test adds a BadgeCount beside Badge and pins the order; it failed before. Core's findings are the same set, in the new order.

After the review of generic props

  • A generic component whose own props take any name was listed as unchecked, "names from a type parameter", instead of compared. Examples: function GenericOpen<T>(props: {items: T[]; [key: string]: any}), the same with & Record<string, any>, and a bare Record<'value' | 'initial', T>. The check counted every any-valued index as names from a type parameter, and for a mapped type it read the value argument as well as the keys.
  • It now drops the any test, 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 is Pick<T, 'a'>.
  • These stay listed, on TypeScript 4.8, 5.0 and 6.0: every generic shape the unchecked test pins (a polymorphic as, Partial<PropsOf<E>>, a key remapping over keyof T, a bare props: 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 as clauses

  • A generic component whose prop names come from a mapping's as clause over a type parameter ({[K in 'value' | 'change' as `${N}${Capitalize<K>}`]?: string}, or as 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 false not_in_code. Through a named alias (Prefixed<N>) it had been listed until the last round.
  • The check now also counts a mapping whose as clause 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 an as clause that uses only its own key (as Exclude<K, 'b'>), or an infer inside 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.
  • Generic props that refer to a type parameter and show the compiler no members at all (Bag<T> for an empty interface Bag<T> {}) are listed the same way, instead of drawing a not_in_code for every documented name. Props that are plainly empty, such as function AcmeX(), (props: {}) or Record<string, never>, generic or not, are still compared, and each documented prop is reported, as tsc rejects it there. The test adds both. Core and Lab give the same results.
  • To keep this PR under 2,000 lines, the test of props from other packages and the exempt list moves up to feat(cli): doctor integration components judges documented prop types against the code #7667, unchanged.

After the review of fixed key filters

  • The props count over-reached: a key filter given fixed arguments (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.
  • The count 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.
  • Keys made from a type parameter ({[P in on${K}]?: () => void}) were compared against no props; they are listed now too.
  • The unchecked test adds a fixed key filter, a fixed pick-by-value, and an alias in an intersection and behind another alias, each fixed (compared) and open (listed), plus the template-key form. It failed before. TypeScript 4.8, 5.0 and 6.0 give the same results, and Core and Lab are unchanged.

Merge order

The order is required:

  1. feat(cli): read a package's TypeScript project the way tsc reads it #7681 (reading the package's TypeScript project), then feat(cli): compare documented component props with the TypeScript source #7663 (the prop comparison, stacked on feat(cli): read a package's TypeScript project the way tsc reads it #7681), then feat(cli): doctor integration components compares documented props with the code #7676 (props in 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).
  2. feat(cli): check each authored doc against its published authoring type #7664 (the doc type check), after feat(cli): read a package's TypeScript project the way tsc reads it #7681: once feat(cli): read a package's TypeScript project the way tsc reads it #7681 merges, feat(cli): check each authored doc against its published authoring type #7664 is rebased onto main, which takes out the 65 lines the two share and brings it to 1,960, and reviewed again. Then feat(cli): doctor integration validate checks each doc against its published authoring type #7673 (doc types in validate, stacked on feat(cli): check each authored doc against its published authoring type #7664).
  3. fix(cli): doc links to a component, page, or block resolve #7668 (doc links to components and templates) any time before feat(cli): integration check runs every source check for a package's build, with --strict #7665.
  4. feat(cli): integration check runs every source check for a package's build, with --strict #7665 (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

Size

1,981 changed lines.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

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 ./api type 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 full pnpm vitest run --project node suite: 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 four doctor integration subcommands were run in text and JSON on 64 package shapes, against main: templates output is byte-identical everywhere; validate and components only add their new section and JSON field; docs differs only where the doc-links PR fixes a link warning. integration check exits 1 exactly when one of the four would, each of its sections is byte-identical to that subcommand's output, and only integration check's three list tests fail until its planned rebase.

@vercel

vercel Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 19, 2026 10:37pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🔄 In progress (for maintainers only) Oct 19, 2026, 10:28 PM UTC

@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Previews ready: Storybook and Sandbox were built with this PR's docsite. CI evidence will be added when available.

View Storybook for this PR · View Sandbox for this PR

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_mismatch comparison (type-match.mjs, type-text.mjs and 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 string is reported as type_mismatch against '…' | (string & {}), the any-string pattern React uses for autoComplete and target, so treat string & {} as accepting any string.

[Automated review]

records(report.unchecked, {fields: ['component', 'doc', 'reason']}),
);
}
if (report.findings.length === 0 && !failed) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Omit to an import that does not resolve (for example extends Omit<MissingProps, 'title'>) is counted as compared and gets [ok] while every documented prop name is accepted; treat unresolved type arguments like an unresolved extends so the component is listed as unchecked, and add a test for it.

[Automated review]

Comment on lines +457 to +472
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;
}
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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())),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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”.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, or Partial<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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> with Omit<React.ComponentPropsWithoutRef<E>, 'as'>) counts as compared and can get [ok] while a documented prop the code lacks goes unreported, because that Omit member's string index signature (an indexed access into E's props) makes accepts() 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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").

@josephfarina
josephfarina force-pushed the josephfarina/doctor-prop-drift branch from 8b14433 to 5ddd619 Compare October 18, 2026 02:16
@josephfarina josephfarina changed the title feat(cli): doctor integration components compares documented props with the code feat(cli): compare documented component props with the TypeScript source Oct 18, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 forwardRef component 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]

Comment on lines +245 to +255
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)`,
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +262 to +268
for (const signature of signatures) {
const parameter = signature.getParameters()[0];
if (parameter) {
propsTypes.push(checker.getTypeOfSymbolAtLocation(parameter, sourceFile));
} else {
propless = true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread .changeset/doctor-prop-comparison.md Outdated
'@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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 typed any because shapeOf also reads a mapped type's any template; reading d.type only from type aliases compares it like the Record<string, any> form.

[Automated review]

Comment on lines +398 to +405
const required =
defaulted.has(name) || propless
? false
: symbols.length === members.length && optional === 0
? true
: optional === symbols.length
? false
: null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +952 to +957
const shapeOf = symbol =>
(symbol?.declarations ?? []).flatMap((/** @type {any} */ d) =>
d.type
? [d.type]
: (d.heritageClauses?.flatMap((/** @type {any} */ c) => c.types) ?? []),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]

Comment on lines +14 to +19
import {
comparePropTables,
PROPS_NOT_DOCUMENTED,
TYPESCRIPT_MISSING_REASON,
} from './prop-drift.mjs';
import {unusableTypeScriptReason} from './project-typescript.mjs';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]

Comment on lines +1302 to +1306
findings.sort((a, b) =>
`${a.component}\0${a.prop}\0${a.kind}`.localeCompare(
`${b.component}\0${b.prop}\0${b.kind}`,
),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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),
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]

Comment on lines +639 to +642
const keys = type.constraintType?.isUnion()
? type.constraintType.types.length
: 1;
return uses(clause) && checker.getPropertiesOfType(type).length < keys;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@josephfarina
josephfarina force-pushed the josephfarina/doctor-prop-drift branch from dbbf90d to 855c01b Compare October 19, 2026 22:18
@josephfarina
josephfarina changed the base branch from josephfarina/doctor-typescript-project to main October 19, 2026 22:18
@josephfarina
josephfarina merged commit 4787e3f into main Oct 19, 2026
41 of 42 checks passed
@github-actions
github-actions Bot deleted the josephfarina/doctor-prop-drift branch October 20, 2026 06:22

This branch was successfully deployed

1 active deployment
Preview — 855c01b6 Deployed Oct 19, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant