around them are absent.
+ expect(index.usagesOf(symbolOf('spread.tsx', 'Leaf')).map(describeUsage)).toEqual([
+ 'Leaf@44',
+ 'Leaf@49',
+ 'Leaf@54',
+ 'Leaf@59',
+ 'Leaf@64',
+ 'Leaf@70',
+ 'Leaf@80',
+ 'Leaf@85',
+ 'Leaf@121',
+ ]);
+ });
+
+ it('files a usage under the declaration, not under the name it was rendered as', () => {
+ // `export { Aliased as Public }`, rendered as
in app.tsx. Without
+ // alias resolution this would be a second entry nothing ever asks about,
+ // and Aliased would look like it had no call sites at all.
+ expect(index.usagesOf(symbolOf('late.tsx', 'Aliased')).map(describeUsage)).toEqual(['Public@27']);
+ });
+
+ it('reports no usages for a component the Program never renders', () => {
+ expect(index.usagesOf(symbolOf('legacy.tsx', 'Legacy'))).toEqual([]);
+ });
+
+ it('separates being called from being rendered', () => {
+ // The distinction the walk needs: `renderMurky` has live callers no JSX
+ // site can show, so its empty usage list must not read as dead code —
+ // whereas Leaf, rendered nine times and called never, genuinely is not.
+ expect(index.isCalled(symbolOf('spread.tsx', 'renderMurky'))).toBe(true);
+ expect(index.usagesOf(symbolOf('spread.tsx', 'renderMurky'))).toEqual([]);
+ expect(index.isCalled(symbolOf('spread.tsx', 'Leaf'))).toBe(false);
+ });
+
+ it('hands out one stable id per symbol', () => {
+ const leaf = symbolOf('spread.tsx', 'Leaf');
+
+ expect(index.symbolId(leaf)).toBe(index.symbolId(leaf));
+ expect(index.symbolId(leaf)).not.toBe(index.symbolId(symbolOf('spread.tsx', 'Murky')));
+ // Never rendered and never called, so the walk meets it only through the
+ // caches keyed on this id — which still has to be an id.
+ expect(index.symbolId(symbolOf('legacy.tsx', 'Legacy'))).toEqual(expect.any(Number));
+ });
+});
diff --git a/packages/prop-flow/src/usage-index.ts b/packages/prop-flow/src/usage-index.ts
new file mode 100644
index 00000000..e6559606
--- /dev/null
+++ b/packages/prop-flow/src/usage-index.ts
@@ -0,0 +1,99 @@
+import type * as TS from 'typescript';
+import type { ComponentFactory } from './component.js';
+import type { TypeScriptApi } from './typescript-api.js';
+
+export interface UsageIndexOptions {
+ readonly checker: TS.TypeChecker;
+ readonly components: ComponentFactory;
+ readonly program: TS.Program;
+ readonly ts: TypeScriptApi;
+}
+
+export interface UsageIndex {
+ /**
+ * Whether `sym` is ever called as a plain function. Such a symbol has live
+ * callers this index cannot see, so an empty `usagesOf` does not mean dead.
+ */
+ isCalled(sym: TS.Symbol): boolean;
+ /**
+ * A stable number for `sym`. Symbols are objects, so a Map would key on them
+ * directly — the id exists because it is printable, which the caches and the
+ * cycle guard built on top of it both want.
+ */
+ symbolId(sym: TS.Symbol): number;
+ /** Every JSX element rendering `sym`, in Program order. */
+ usagesOf(sym: TS.Symbol): readonly TS.JsxOpeningLikeElement[];
+}
+
+/**
+ * Every JSX usage in the Program, indexed in one walk: component symbol → call
+ * sites, plus the symbols that are CALLED rather than rendered. Building this
+ * eagerly costs one traversal and saves one per prop analysed.
+ *
+ * Import aliases are resolved on the way in, so `
` and the `Aliased`
+ * it was exported as land on the same entry.
+ */
+export function createUsageIndex({ checker, components, program, ts }: UsageIndexOptions): UsageIndex {
+ // Symbols are not primitives, so a WeakMap keyed by symbol is the identity
+ // map; the id it hands out is what everything downstream keys on.
+ const symbolIds = new WeakMap
();
+ let nextSymbolId = 1;
+
+ const calledIds = new Set();
+ const usagesById = new Map();
+
+ for (const sourceFile of program.getSourceFiles()) {
+ // Declarations and dependencies hold no call site this analysis owns.
+ if (!sourceFile.isDeclarationFile && !sourceFile.fileName.includes('/node_modules/')) {
+ visit(sourceFile);
+ }
+ }
+
+ return { isCalled, symbolId, usagesOf };
+
+ function isCalled(sym: TS.Symbol): boolean {
+ return calledIds.has(symbolId(sym));
+ }
+
+ function usagesOf(sym: TS.Symbol): readonly TS.JsxOpeningLikeElement[] {
+ return usagesById.get(symbolId(sym)) ?? [];
+ }
+
+ function symbolId(sym: TS.Symbol): number {
+ let id = symbolIds.get(sym);
+ if (id === undefined) {
+ id = nextSymbolId;
+ nextSymbolId += 1;
+ symbolIds.set(sym, id);
+ }
+ return id;
+ }
+
+ function visit(node: TS.Node): void {
+ if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
+ recordUsage(node);
+ } else if (ts.isCallExpression(node)) {
+ recordCall(node);
+ }
+ ts.forEachChild(node, visit);
+ }
+
+ function recordUsage(node: TS.JsxOpeningLikeElement): void {
+ // Intrinsics () have no symbol; resolves on the property.
+ const sym = checker.getSymbolAtLocation(node.tagName);
+ if (!sym) {
+ return;
+ }
+ const id = symbolId(components.resolveAlias(sym));
+ const list = usagesById.get(id) ?? [];
+ list.push(node);
+ usagesById.set(id, list);
+ }
+
+ function recordCall(node: TS.CallExpression): void {
+ const sym = checker.getSymbolAtLocation(node.expression);
+ if (sym) {
+ calledIds.add(symbolId(components.resolveAlias(sym)));
+ }
+ }
+}
diff --git a/packages/prop-flow/src/values.ts b/packages/prop-flow/src/values.ts
new file mode 100644
index 00000000..ed3a9f3a
--- /dev/null
+++ b/packages/prop-flow/src/values.ts
@@ -0,0 +1,30 @@
+import type * as TS from 'typescript';
+import type { TypeScriptApi } from './typescript-api.js';
+
+/** `` carries no expression to read; the value is always `true`. */
+export const BOOLEAN_SHORTHAND_VALUE = 'true';
+
+/**
+ * The value `expr` resolves to, printed as the checker prints it (`"sm"`,
+ * `true`, `42`, `Tone.Danger`), or null when it is not a single literal.
+ *
+ * Going through the type rather than the syntax is what makes `const SIZE =
+ * 'sm'`, an enum member and a property of an `as const` object all normalise to
+ * the same string as a written-out `size="sm"` — and what keeps everything
+ * else (a call, a parameter, a widened `let`) honestly unknown.
+ */
+export function literalValueOf(checker: TS.TypeChecker, ts: TypeScriptApi, expr: TS.Expression): string | null {
+ // A written-out string literal is read from the node instead: as a JSX
+ // attribute initializer (`size="md"`) it sits outside an expression position,
+ // where the checker has no type to hand back.
+ if (ts.isStringLiteral(expr)) {
+ return JSON.stringify(expr.text);
+ }
+ const type = checker.getTypeAtLocation(expr);
+ // `isLiteral` covers string, number and enum literals; booleans are a union
+ // of two literal types of their own and need the flag.
+ if (type.isLiteral() || (type.flags & ts.TypeFlags.BooleanLiteral) !== 0) {
+ return checker.typeToString(type);
+ }
+ return null;
+}