From a87bcf87d3d35ff9a4182221762cd7af9698f753 Mon Sep 17 00:00:00 2001 From: Dominic Buetow Date: Mon, 3 Aug 2026 01:20:33 +0200 Subject: [PATCH] init --- packages/prop-flow/CHANGELOG.md | 38 ++- packages/prop-flow/README.md | 81 +++++- .../prop-flow/fixtures/basic/constant.tsx | 198 +++++++++++++ packages/prop-flow/package.json | 2 +- packages/prop-flow/src/analyzer.test.ts | 92 +++++- packages/prop-flow/src/analyzer.ts | 274 +++++++++++------- packages/prop-flow/src/args.test.ts | 14 +- packages/prop-flow/src/args.ts | 8 +- packages/prop-flow/src/ast.test.ts | 24 +- packages/prop-flow/src/ast.ts | 22 +- packages/prop-flow/src/classify.ts | 32 +- packages/prop-flow/src/cli.test.ts | 43 +++ packages/prop-flow/src/cli.ts | 20 +- packages/prop-flow/src/index.ts | 13 +- packages/prop-flow/src/prop-flow.test.ts | 33 ++- packages/prop-flow/src/prop-flow.ts | 32 +- packages/prop-flow/src/report.test.ts | 54 +++- packages/prop-flow/src/report.ts | 49 +++- packages/prop-flow/src/types.ts | 33 ++- packages/prop-flow/src/usage-index.test.ts | 113 ++++++++ packages/prop-flow/src/usage-index.ts | 99 +++++++ packages/prop-flow/src/values.ts | 30 ++ 22 files changed, 1105 insertions(+), 199 deletions(-) create mode 100644 packages/prop-flow/fixtures/basic/constant.tsx create mode 100644 packages/prop-flow/src/usage-index.test.ts create mode 100644 packages/prop-flow/src/usage-index.ts create mode 100644 packages/prop-flow/src/values.ts diff --git a/packages/prop-flow/CHANGELOG.md b/packages/prop-flow/CHANGELOG.md index 6c628847..3d5f71fd 100644 --- a/packages/prop-flow/CHANGELOG.md +++ b/packages/prop-flow/CHANGELOG.md @@ -5,28 +5,52 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## To Be Released +## 2.0.0 + +- **BREAKING CHANGE**: `OptionalProp` is now `DeclaredProp`, with an added + `optional` field, and `Analyzer.listOptionalProps` is now + `listProps(component, { includeRequired })` +- **BREAKING CHANGE**: `PropAnalysis` carries a required `constant` field, so + every `--json` row has one — `null` where there is no finding +- **BREAKING CHANGE**: `SiteKind` no longer has a `spread` member — former + spread sites are now reported as `passthrough` / `real` / `omit` / `manual` + with a note +- All three land in the library API and the `--json` shape; the CLI arguments + and the text output only gained things +- Props that are passed the same value at every call site are reported as such. + Values are read off the type, so `size="sm"`, a `const`, an enum member and a + property of an `as const` object all resolve to one value; anything the + checker cannot pin to a literal, and any `manual` site, leaves the claim + unmade. `coverage: 'all'` says the omissions land on the value too, via the + component's own default — that is the case where the prop can go away +- `--all-props` widens discovery to required props. They are reported only + where they carry a constant value, under a new `required` verdict; without + the flag the output is unchanged - JSX spreads are resolved instead of being blanket-reported as `manual`: a spread whose type provably lacks the prop is skipped, `{...props}` and `{...rest}` are followed one level up, and a spread of an object literal (or of a `const` bound to one) is read key by key. Only a spread whose type cannot answer the question, or an optional prop in a spread contesting an earlier value, still requires a human +- Optional props declared only in a dependency are no longer reported. A + component spreading `React.ComponentProps<'button'>` inherits some 250 + optional DOM and ARIA props; a verdict on those is true but not actionable, + and it buried the props the author actually owns - Fixed attribute precedence: `` reported `"x"`, but JSX resolves last-wins, so the spread overrides the attribute - Fixed pass-throughs inside render callbacks: `items.map(() => )` was classified as a local value, which could turn into a wrong `justified` or `unnecessary-optional` -- Optional props declared only in a dependency are no longer reported. A - component spreading `React.ComponentProps<'button'>` inherits some 250 - optional DOM and ARIA props; a verdict on those is true but not actionable, - and it buried the props the author actually owns +- Fixed a pass-through into a prop that binds its own default: the omissions at + that level were counted as omissions at the leaf, reporting a prop that is in + fact always set as `caller-dead`. `Relay({ size = 'lg' })` forwarding `size` + feeds `'lg'` down, and that is now what the counts and the value say. Each + absorbed omission is listed at the call site where the default fires, so the + pass count still lines up with the sites below it - A pass-through that climbs into a function which is called rather than rendered (a `renderX({ … })` test helper) now reports `manual` instead of counting its invisible callers as zero, which would report a live prop as `caller-dead` -- `--json` output: `SiteKind` no longer has a `spread` member — former spread - sites are now reported as `passthrough` / `real` / `omit` / `manual` with a - note ## 1.0.0 diff --git a/packages/prop-flow/README.md b/packages/prop-flow/README.md index 7d7ff251..49bc449e 100644 --- a/packages/prop-flow/README.md +++ b/packages/prop-flow/README.md @@ -28,15 +28,16 @@ directory, so it always analyses your code with the compiler your code uses. ## Usage ```bash -$ pnpm prop-flow [propName] [--tsconfig ] [--json] +$ pnpm prop-flow [propName] [--tsconfig ] [--all-props] [--json] ``` -| argument | meaning | -| ------------ | ------------------------------------------------------------------------------------------------------------------- | -| `` | a `.ts`/`.tsx` file containing the component(s) to inspect | -| `[propName]` | one prop; omitted → every optional prop of every component exported from the file | -| `--tsconfig` | override the auto-discovered tsconfig — use the broadest "solution" config so call sites in other packages are seen | -| `--json` | machine-readable output | +| argument | meaning | +| -------------- | ------------------------------------------------------------------------------------------------------------------- | +| `` | a `.ts`/`.tsx` file containing the component(s) to inspect | +| `[propName]` | one prop; omitted → every optional prop of every component exported from the file | +| `--tsconfig` | override the auto-discovered tsconfig — use the broadest "solution" config so call sites in other packages are seen | +| `--all-props` | inspect required props too — reported only where they carry a constant value | +| `--json` | machine-readable output | ``` $ pnpm prop-flow src/Button.tsx title @@ -61,10 +62,50 @@ justified Button.title | `caller-dead` | no call site passes it → optional and always `undefined` | | `unused-component` | the component itself has no call sites in the Program | | `manual` | an unreadable spread or a contested override blocks a static conclusion | +| `required` | the prop has no `?` to judge — listed only for its constant value | Exit codes: `0` success, `1` nothing to do (usage printed), `2` a handled failure (message on stderr). +## Constant values + +A prop that is passed the same value at every call site carries no information: +the value can be inlined and the prop dropped. That question is **orthogonal** +to the `?` — a prop can be `justified` (some call sites omit it) and still be +constant everywhere it is passed, which is the most interesting combination of +all. So it is reported as its own field rather than as a verdict: + +``` +justified Chip.variant + passes=2 omits=1 ambiguous=0 + constant="danger" coverage=passes + real src/App.tsx:4:7 (string literal) + real src/App.tsx:5:7 (string literal) + omit src/App.tsx:6:7 + → genuinely sometimes-absent. The `?` is correct. + → every passing call site sends "danger"; the value could be inlined. +``` + +Values are read off the **type**, not off the syntax, so `size="sm"`, +`size={'sm'}`, a `const SIZE = 'sm'`, an enum member and a property of an +`as const` object all resolve to the same value — and anything the checker +cannot pin to a single literal (a call, a parameter, a widened `let`) leaves +the claim unmade. `` counts as `true`. + +Two coverages: `passes` means every call site that passes the prop agrees; `all` +means nothing anywhere sees another value — either there are no omissions, or +the component's own binding default is that same value, so the omissions land +on it too. `all` is the case where the prop can go away entirely. + +Nothing is reported below two passing sites — with one, "always the same value" +is trivially true. And constant does not mean wrong: `variant="danger"` on the +two delete buttons is constant and correct, which is why the hint stops at +"could be inlined". + +`--all-props` widens *discovery* to required props, not the report: a required +prop shows up only when it actually carries a constant value, under the +`required` verdict. Without the flag the output is exactly as it was. + ## API The same analysis is available programmatically: @@ -76,15 +117,35 @@ const report = analyseProps({ file: 'src/Button.tsx', prop: 'title' }); process.stdout.write(formatText(report)); ``` -`analyseProps` accepts `{ cwd, file, prop, ts, tsconfig }` and returns a -`Report`; passing `ts` injects a specific compiler instead of resolving one +`analyseProps` accepts `{ allProps, cwd, file, prop, ts, tsconfig }` and returns +a `Report`; passing `ts` injects a specific compiler instead of resolving one from `cwd`. ## Limitations Pass-throughs are followed through plain identifiers and `props.x` member access, including inside render callbacks — a `props.x` in `items.map(…)` is -still traced to the surrounding component. +still traced to the surrounding component. When a level of the chain binds its +own default, an omission at *its* call sites is counted as a pass of that +default rather than as an omission at the leaf: `Relay({ size = 'lg' })` +forwarding `size` feeds `'lg'` down, not `undefined`. Those absorbed omissions +are listed individually, at the call site where the default fires: + +``` +NEEDLESS ? Hop.size + passes=2 omits=0 ambiguous=0 + constant="lg" coverage=all + passthrough src/Relay.tsx:9:10 → Relay.size (omissions fall back to its default) + real src/App.tsx:14:7 → Relay.size (the default fires here) + real src/App.tsx:15:7 → Relay.size (the default fires here) +``` + +Otherwise the counts are of *leaves*, not of lines: one pass-through site can +stand for a whole subtree of passes and omissions below it. + +A constancy claim needs every call site to be readable. One `manual` site sinks +it — an unreadable spread could be carrying any value at all — as does a single +value the checker cannot pin to a literal. A spread is only ambiguous when it can actually reach the prop. `{...x}` whose type provably lacks the prop is skipped; `{...props}` and `{...rest}` are diff --git a/packages/prop-flow/fixtures/basic/constant.tsx b/packages/prop-flow/fixtures/basic/constant.tsx new file mode 100644 index 00000000..c1bac53f --- /dev/null +++ b/packages/prop-flow/fixtures/basic/constant.tsx @@ -0,0 +1,198 @@ +// What makes a prop constant across its call sites, and what breaks the claim. +// Each component below is one case; `ConstantApp` at the bottom is the only +// render root in this file, so nothing here moves the counts the other fixtures +// assert. + +export interface ChipProps { + id: string; + /** justified AND constant: two passes of one value, plus an omission */ + variant?: string; +} + +export function Chip({ id, variant }: ChipProps) { + return ; +} + +export interface TileProps { + id: string; + /** constant with coverage=all: the omission lands on the same value */ + size?: string; +} + +export function Tile({ id, size = 'md' }: TileProps) { + return ; +} + +export interface FlagProps { + /** constant `true`: boolean shorthand carries a value without an expression */ + dense?: boolean; + id: string; +} + +export function Flag({ dense, id }: FlagProps) { + return ; +} + +export enum Tone { + Danger = 'danger', + Muted = 'muted', +} + +const dangerTone = Tone.Danger; +const preset = { tone: Tone.Danger } as const; + +export interface TagProps { + id: string; + /** constant: the same enum member written three different ways */ + tone?: Tone; +} + +export function Tag({ id, tone }: TagProps) { + return ; +} + +export interface BlurProps { + id: string; + /** not constant: one call site computes its value, which could be anything */ + label?: string; +} + +export function Blur({ id, label }: BlurProps) { + return {label}; +} + +function compute(): string { + return 'computed'; +} + +export interface SoloProps { + /** not constant: a single passing call site agrees only with itself */ + hint?: string; + id: string; +} + +export function Solo({ hint, id }: SoloProps) { + return {hint}; +} + +export interface HopProps { + id: string; + /** constant `'lg'`: what reaches it is Relay's default, not `undefined` */ + size?: string; +} + +export function Hop({ id, size }: HopProps) { + return ; +} + +export interface RelayProps { + id: string; + size?: string; +} + +/** Carries a default, so an omission at ITS call sites forwards `'lg'` on. */ +export function Relay({ id, size = 'lg' }: RelayProps) { + return ; +} + +export interface DimProps { + id: string; + /** not constant: the default the omissions fall back to is computed */ + size?: string; +} + +export function Dim({ id, size }: DimProps) { + return ; +} + +/** The same absorption as Relay, but a computed default has no value to give. */ +export function Vague({ id, size = compute() }: DimProps) { + return ; +} + +export interface KeepProps { + id: string; + /** constant, but only over the passes: the default is a different value */ + weight?: string; +} + +export function Keep({ id, weight = 'bold' }: KeepProps) { + return ; +} + +export interface DriftProps { + id: string; + /** the same split for the other reason: the default cannot be read at all */ + label?: string; +} + +export function Drift({ id, label = compute() }: DriftProps) { + return {label}; +} + +export interface GaugeProps { + id: string; + /** constant `42`: a number is read off the type, exactly as a string is */ + span?: number; +} + +export function Gauge({ id, span }: GaugeProps) { + return ; +} + +export interface ToggleProps { + id: string; + /** constant `true` written out: booleans are not `isLiteral()` literals */ + on?: boolean; +} + +export function Toggle({ id, on }: ToggleProps) { + return