Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .changeset/intersects-heritage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'svelte-docinfo': patch
---

fix: `intersects` records external types reached through `interface Props extends Bag`

Inherited properties from an external attribute bag are filtered out of a
component's `props` (and a type alias's `members`) whichever way the author
composes them, but only the inline `Bag & {…}` form was recorded in
`intersects` — the `extends` form left no trace, so a consumer could not
render "also accepts button attributes". `interface Props extends
HTMLButtonAttributes` now records `HTMLButtonAttributes` exactly like
`type Props = HTMLButtonAttributes & {…}`. Multiple heritage entries each
appear, in source order; a generic base keeps its written form
(`HTMLAttributes<HTMLDivElement>`).

The labels come from a walk of the written AST, which a heritage clause never
entered. The walk now descends through project-local names generally — a
local interface's `extends` entries, a local alias's right-hand side,
transitively, imports followed to the declaring module — so the recovery also
covers a bag composed behind an intermediate local type, or behind a props
type imported from a sibling module, the form published Svelte libraries use
most. The property filtering these labels pair with is inheritance-blind, so
the labels had to be too. A bag reached by two branches is recorded once.

Two output changes ride along. A leaf naming a *local* type now prefers the
external types behind the name over the name itself: an attribute-forwarding
`interface Props extends Bag {}` — every property inherited, none of its own
— records `Bag` rather than its own `Props`, and a local alias over a utility
type records `Omit<Bag, 'onclick'>` rather than the alias name. A local name
whose definition the walk cannot traverse (a mapped or conditional type)
still falls back to naming itself, so nothing recorded before is dropped.

Two boundaries keep the recovery honest. External names are never descended
into: an external base chain stays one entry, and no node_modules-internal
definition leaks through. And text collected past a declaration boundary that
names that declaration's own type parameters is never emitted — `interface
A<T> extends HTMLAttributes<T>` reached via `Props extends A<HTMLDivElement>`
puts `HTMLAttributes<T>` in hand, `T` dangling at the documented site — so
recovery degrades to the nearest enclosing well-formed name
(`A<HTMLDivElement>` when every property is external) or to no entry. A type
parameter in scope at the annotation site — a generic component's own — still
emits, beside the `genericParams` that document it.
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,9 @@ Hierarchy: `ModuleJson[]` → `DeclarationJson[]` → `MemberJson[]`. Members ne
- `TypeDeclarationJson.members`
- Populated for object-like types (object literals, intersections, mapped types, type references) via `getPropertiesOfType()`
- Skipped for unions, primitives, tuples, generic refs (`Array<T>`, `Promise<T>`)
- External-property filtering (`filterExternalProperties`): properties contributed by external sources (node_modules, declaration files) are dropped, and the external types that contributed them are listed in `intersects`. Shape-general, not intersection-only — applies to every property-bearing shape that reaches extraction: intersections, bare external references (`type Foo = SomeExternal`), and indexed-access (`SvelteHTMLElements['li']`). Unions are skipped here (no `members`, no `intersects`) per the rule above. The `intersects` labels come from an AST walk of the written type, so the `&` / index-access text is preserved verbatim
- External-property filtering (`filterExternalProperties`): properties contributed by external sources (node_modules, declaration files) are dropped, and the external types that contributed them are listed in `intersects`. Shape-general, not intersection-only — applies to every property-bearing shape that reaches extraction: intersections, bare external references (`type Foo = SomeExternal`), and indexed-access (`SvelteHTMLElements['li']`). Unions are skipped here (no `members`, no `intersects`) per the rule above. The `intersects` labels come from an AST walk of the written type, so the `&` / index-access text is preserved verbatim. Membership filtering is inheritance-blind (it tests each property's declaration origin), so the label walk is too: a leaf naming a **project-local** type descends through that type's own composition — a local interface's `extends` entries, a local alias's right-hand side, transitively — and contributes whatever bags that reaches, deduplicated by text and in source order. Only when the descent comes back empty does a leaf fall back to its own text, and then only if wholly external and free of type parameters bound inside the descent: an external name is never descended into (an external base chain stays one entry, no node_modules-internal definition leaks), and a local name surfaces only when it hides a definition the walk can't traverse (mapped, conditional, an instantiated utility type). Text collected past a declaration boundary can name that declaration's own type parameters (`interface A<T> extends HTMLAttributes<T>` reached via `Props extends A<HTMLDivElement>` puts `HTMLAttributes<T>` in hand, `T` dangling at the documented site) — such text is never emitted; recovery degrades to the nearest enclosing well-formed name (`A<HTMLDivElement>` when wholly external) or to no entry. A type parameter in scope at the annotation site itself — a generic component's own param, a generic alias's own param — still emits (`HTMLAttributes<T>` beside `genericParams` documenting `T`). Descending in preference to the name is what keeps a local name out of a field that names external contributors — an attribute-forwarding `interface Props extends Bag {}`, every property inherited, records `Bag` rather than `Props`
- `ComponentDeclarationJson`
- Same external-property filtering as `TypeDeclarationJson` for prop types, but applied unconditionally (the type-alias `hasExtractableProperties` gate is bypassed) — so a component's `intersects` also covers **union** prop types (e.g. `HTMLButtonAttributes | HTMLAnchorAttributes`), which the type-alias path skips
- Same external-property filtering as `TypeDeclarationJson` for prop types, but applied unconditionally (the type-alias `hasExtractableProperties` gate is bypassed) — so a component's `intersects` also covers **union** prop types (e.g. `HTMLButtonAttributes | HTMLAnchorAttributes`), which the type-alias path skips. The local-name descent above is what makes the two ways of writing Svelte props agree: `interface Props extends HTMLButtonAttributes` records the bag exactly like `type Props = HTMLButtonAttributes & {…}` (the type-alias path reaches it too, but only for the shapes `hasExtractableProperties` admits — a bare reference to a local interface is gated out there)
- `acceptsChildren` — true if the component accepts a `Snippet`-typed `children` prop (verified via type inference, not just symbol presence) or uses children implicitly in the template
- `lang: 'js'` for JS-only components; omitted for TypeScript (the default)
- `SnippetDeclarationJson` — template snippets exported from `<script module>`; type signature synthesized as `Snippet<[...]>`. No `returnType`/`overloads`
Expand Down
26 changes: 24 additions & 2 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -820,7 +820,17 @@ export const TypeDeclarationJson = z.strictObject({
...declarationTopLevelFields,
kind: z.literal('type'),
/**
* Types from intersection branches whose properties are all external (filtered out of `members`).
* External types whose properties are filtered out of `members`.
*
* Covers every composition the written type reaches: an intersection or
* union branch, a bare or indexed-access reference, and a type composed
* behind a project-local name (`type Base = Bag & {…}`, a local interface's
* `extends`). Entries carry the written form, so a generic base keeps its
* arguments; each distinct contributor appears once, in source order. A
* local name is used only when it hides a definition the walk cannot
* traverse — a mapped or conditional type, or a local generic base whose
* heritage text names its own type parameters (text that would dangle at
* this site is never emitted).
*
* @see `ComponentDeclarationJson.intersects`, `ClassDeclarationJson.extends`,
* `ClassDeclarationJson.implements`, `InterfaceDeclarationJson.extends`
Expand Down Expand Up @@ -888,7 +898,19 @@ export const ComponentDeclarationJson = z.strictObject({
...declarationTopLevelFields,
kind: z.literal('component'),
/**
* Types from intersection branches whose properties are all external (filtered out of `props`).
* External types whose properties are filtered out of `props` — the
* attribute bags a component forwards, such as `HTMLButtonAttributes` or
* `SvelteHTMLElements['button']`.
*
* Covers every way props compose them: an intersection or union branch, a
* bare or indexed-access annotation, and `interface Props extends Bag`
* (directly or through a local base). Entries carry the written form, so a
* generic base keeps its arguments; each distinct bag appears once, in
* source order. A local name is used only when it hides a definition the
* walk cannot traverse — a mapped or conditional type, or a local generic
* base whose heritage text names its own type parameters (text that would
* dangle at this site is never emitted; the component's own generics stay
* in scope and do emit).
*
* @see `TypeDeclarationJson.intersects`, `ClassDeclarationJson.extends`,
* `ClassDeclarationJson.implements`, `InterfaceDeclarationJson.extends`
Expand Down
Loading