/** * What a part draws only when a prop is passed, and no story passes it (UIG-19, * Katerina's ruling 5). * * `{onReply && }` works in the app, because the app passes * `onReply`. In Storybook nothing does, so the button is simply not there. It * reads as a broken behaviour rather than a missing fixture, and it hides the * very state a reviewer came to see. UIG-17 found five in Peek by hand: Reply on * ConversationCard, Edit and Delete on ReplyCard, Rename and Copy link on * TopicMoreMenu, Open original on ThreadPanel. * * Read with TypeScript, part by part. The gate is looked for only inside the * part's own function (and its `…View` half, when the stories draw that), * because a file is not a component: UIG-17's first scan blamed ReplyCard for * two props that belong to a menu declared beside it. A story passes a prop by * naming it in an `args` object or as an attribute on the part's tag, anywhere * in a story file that imports the part's file. */ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { basename, dirname, join, relative } from 'node:path' import ts from 'typescript' import type { Registry } from './schema' export interface GatedFinding { part: string file: string prop: string /** The part or view whose body holds the gate. */ in: string } const parse = (file: string, text: string) => ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) /** The function a name is declared as: `function X`, `const X = (…) =>`, `memo(…)`, `forwardRef(…)`. */ function declared(sf: ts.SourceFile, name: string): ts.FunctionLikeDeclaration | null { let found: ts.FunctionLikeDeclaration | null = null const unwrap = (e: ts.Expression | undefined): ts.FunctionLikeDeclaration | null => { if (!e) return null if (ts.isArrowFunction(e) || ts.isFunctionExpression(e)) return e if (ts.isCallExpression(e)) return unwrap(e.arguments[0]) if (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e)) return unwrap(e.expression) return null } for (const s of sf.statements) { if (ts.isFunctionDeclaration(s) && s.name?.text === name) found = s if (ts.isVariableStatement(s)) for (const d of s.declarationList.declarations) if (ts.isIdentifier(d.name) && d.name.text === name) found = unwrap(d.initializer) } return found } /** The props a function takes, by the name its body uses: `{ onReply, owner: who }` → onReply→onReply, who→owner. */ function propsOf(fn: ts.FunctionLikeDeclaration): { locals: Map; object: string | null } { const locals = new Map() const first = fn.parameters[0] if (!first) return { locals, object: null } if (ts.isObjectBindingPattern(first.name)) { for (const el of first.name.elements) { if (el.dotDotDotToken || !ts.isIdentifier(el.name)) continue const prop = el.propertyName && ts.isIdentifier(el.propertyName) ? el.propertyName.text : el.name.text locals.set(el.name.text, prop) } return { locals, object: null } } return { locals, object: ts.isIdentifier(first.name) ? first.name.text : null } } const drawsJsx = (node: ts.Node): boolean => { let yes = false const walk = (n: ts.Node) => { if (yes) return if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) yes = true else ts.forEachChild(n, walk) } walk(node) return yes } /** Every prop that gates something drawn: `{p && }`, `{p ? : null}`, `{!!p && …}`, `{p != null && …}`. */ function gates(fn: ts.FunctionLikeDeclaration): Set { const { locals, object } = propsOf(fn) const out = new Set() const propIn = (e: ts.Expression): string | null => { while (ts.isParenthesizedExpression(e) || ts.isNonNullExpression(e)) e = e.expression if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.ExclamationToken && ts.isPrefixUnaryExpression(e.operand)) return propIn(e.operand.operand) if (ts.isBinaryExpression(e) && [ts.SyntaxKind.ExclamationEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken].includes(e.operatorToken.kind)) return propIn(e.left) if (ts.isIdentifier(e)) return locals.get(e.text) ?? null if (object && ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && e.expression.text === object) return e.name.text return null } const walk = (n: ts.Node) => { // A function declared inside is its own component or handler: not this part's markup. if (n !== fn && ts.isFunctionDeclaration(n)) return if (ts.isJsxExpression(n) && n.expression) { const e = n.expression if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && drawsJsx(e.right)) { const p = propIn(e.left) if (p) out.add(p) } if (ts.isConditionalExpression(e) && drawsJsx(e.whenTrue) && !drawsJsx(e.whenFalse)) { const p = propIn(e.condition) if (p) out.add(p) } } ts.forEachChild(n, walk) } if (fn.body) walk(fn.body) return out } /** Stands for "every prop": a spread the reader cannot open might pass any of them. */ const ANY = '*' /** * Every prop name a story file passes: keys of any `args` object, and * attributes on the named tags. A spread of an object declared in the same * file is opened (`args: { replyCount: 3, ...replyRow }`); one it cannot open * — an import, a call — counts as passing everything, so the check stays quiet * rather than wrong. */ function passed(sf: ts.SourceFile, tags: string[]): Set { const out = new Set() const consts = new Map() for (const s of sf.statements) { if (!ts.isVariableStatement(s)) continue for (const d of s.declarationList.declarations) if (ts.isIdentifier(d.name) && d.initializer && ts.isObjectLiteralExpression(d.initializer)) consts.set(d.name.text, d.initializer) } const keysOf = (o: ts.ObjectLiteralExpression, seen: Set) => { for (const p of o.properties) { if (ts.isSpreadAssignment(p)) { // `...replyRow`, or another story's args: `...Topic.args`. const e = p.expression const name = ts.isIdentifier(e) ? e.text : ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && e.name.text === 'args' ? `${e.expression.text}.args` : null let target: ts.ObjectLiteralExpression | undefined if (name && !seen.has(name)) { if (!name.endsWith('.args')) target = consts.get(name) else { const story = consts.get(name.slice(0, -'.args'.length)) const args = story?.properties.find((q) => ts.isPropertyAssignment(q) && ts.isIdentifier(q.name) && q.name.text === 'args') if (args && ts.isPropertyAssignment(args) && ts.isObjectLiteralExpression(args.initializer)) target = args.initializer } } if (target) keysOf(target, new Set([...seen, name!])) else out.add(ANY) } else if (p.name && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name))) out.add(p.name.text) } } const walk = (n: ts.Node) => { if (ts.isPropertyAssignment(n) && ts.isIdentifier(n.name) && n.name.text === 'args') { if (ts.isObjectLiteralExpression(n.initializer)) keysOf(n.initializer, new Set()) else out.add(ANY) } if ((ts.isJsxSelfClosingElement(n) || ts.isJsxOpeningElement(n)) && tags.includes(n.tagName.getText(sf))) { for (const a of n.attributes.properties) { if (ts.isJsxAttribute(a)) out.add(a.name.getText(sf)) else if (ts.isJsxSpreadAttribute(a)) { const target = ts.isIdentifier(a.expression) ? consts.get(a.expression.text) : undefined if (target) keysOf(target, new Set()) // `{...args}` hands on what `args` holds, and those are read where they are written. else if (!(ts.isIdentifier(a.expression) && a.expression.text === 'args')) out.add(ANY) } } } ts.forEachChild(n, walk) } walk(sf) return out } function storyFiles(root: string): string[] { const out: string[] = [] const walk = (dir: string) => { for (const name of readdirSync(dir)) { if (name === 'node_modules' || name.startsWith('.')) continue const path = join(dir, name) if (statSync(path).isDirectory()) walk(path) else if (/\.stories\.tsx?$/.test(name)) out.push(path) } } if (existsSync(join(root, 'src'))) walk(join(root, 'src')) return out } /** The props a part hands straight on to another tag: `` → QuickActions, onReply, onReply. */ function handsOn(fn: ts.FunctionLikeDeclaration, sf: ts.SourceFile): { tag: string; attr: string; prop: string }[] { const { locals, object } = propsOf(fn) const out: { tag: string; attr: string; prop: string }[] = [] const walk = (n: ts.Node) => { if (n !== fn && ts.isFunctionDeclaration(n)) return if (ts.isJsxSelfClosingElement(n) || ts.isJsxOpeningElement(n)) { const tag = n.tagName.getText(sf) if (/^[A-Z]/.test(tag)) { for (const a of n.attributes.properties) { if (!ts.isJsxAttribute(a) || !a.initializer || !ts.isJsxExpression(a.initializer) || !a.initializer.expression) continue const e = a.initializer.expression const prop = ts.isIdentifier(e) ? locals.get(e.text) : object && ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && e.expression.text === object ? e.name.text : undefined if (prop) out.push({ tag, attr: a.name.getText(sf), prop }) } } } ts.forEachChild(n, walk) } if (fn.body) walk(fn.body) return out } /** Where a module specifier points, without its extension: `./X`, or Vite's `@/` for `src/`. */ function target(from: string, spec: string, root: string): string | null { if (spec.startsWith('.')) return join(dirname(from), spec).replace(/\.[jt]sx?$/, '') if (spec.startsWith('@/')) return join(root, 'src', spec.slice(2)).replace(/\.[jt]sx?$/, '') return null } /** * Every prop that gates something a part draws, where some story draws the * part and none of those stories passes the prop. * * "Draws the part" means a story file imports the part's own file — its own * stories, or an umbrella sheet such as Peek's Overlays/Menus, which is how * TopicMoreMenu lost Rename and Copy link. A gate a part only hands on counts * as its own: ConversationCard hands `onReply` to the strip that draws Reply, * so a ConversationCard story that never passes it shows no Reply. Followed * three parts deep at most. */ export interface GatedOptions { /** The classes checked. The contract's by default. */ kinds?: readonly string[] /** * `own`: a part's own stories show everything it can draw — ConversationCard's * stories must show Reply. `anywhere`: a handed-on gate counts as shown when * the part it is handed to has a story that shows it. Katerina rules which. */ pictured?: 'own' | 'anywhere' } export function gatedFindings(registry: Registry, root: string, { kinds = ['reusable', 'promote-candidate'], pictured = 'own' }: GatedOptions = {}): GatedFinding[] { const findings: GatedFinding[] = [] const stories = storyFiles(root).map((file) => ({ file, sf: parse(file, readFileSync(file, 'utf8')) })) const sources = new Map() const sourceOf = (rel: string): ts.SourceFile | null => { const path = join(root, rel) if (!sources.has(path)) { if (!existsSync(path)) return null sources.set(path, parse(path, readFileSync(path, 'utf8'))) } return sources.get(path)! } const fileOf = new Map(registry.entries.filter((e) => e.kind === 'component').map((e) => [e.name, e.sourceFile])) /** The story files that import a file, and every prop they pass to the part or its view. */ const givenTo = new Map }>() const storiesOf = (name: string, rel: string) => { const key = `${rel}#${name}` if (!givenTo.has(key)) { const own = join(root, rel).replace(/\.[jt]sx?$/, '') const drawing = stories.filter(({ file, sf: s }) => s.statements.some((st) => ts.isImportDeclaration(st) && ts.isStringLiteral(st.moduleSpecifier) && target(file, st.moduleSpecifier.text, root) === own), ) givenTo.set(key, { drawn: drawing.length > 0, given: new Set(drawing.flatMap(({ sf: s }) => [...passed(s, [name, `${name}View`])])) }) } return givenTo.get(key)! } const memo = new Map>() /** The props a named part gates on, its own and those it hands to a part that gates. */ const gatesOf = (name: string, sf: ts.SourceFile, depth: number): Set => { const key = `${sf.fileName}#${name}` if (memo.has(key)) return memo.get(key)! memo.set(key, new Set()) const fn = declared(sf, name) const out = fn ? gates(fn) : new Set() if (fn && depth < 3) { for (const { tag, attr, prop } of handsOn(fn, sf)) { // A dialog is shut in a story until someone opens it, so nothing it draws // is missing from the picture (Katerina, 23 September). if (out.has(prop) || /Dialog$/.test(tag)) continue const here = declared(sf, tag) ? sf : fileOf.has(tag) ? sourceOf(fileOf.get(tag)!) : null if (!here || !gatesOf(tag, here, depth + 1).has(attr)) continue // Already pictured one level down: CollapsibleSection hands `trailing` to // SectionHeader, and SectionHeader's own story shows the slot. const theirs = fileOf.get(tag) if (pictured === 'anywhere' && theirs && here !== sf) { const { given } = storiesOf(tag, theirs) if (given.has(attr) || given.has(ANY)) continue } out.add(prop) } } memo.set(key, out) return out } for (const entry of registry.entries) { // The package's entries carry no app class: every one of its components is one. const cls = entry.app?.class ?? 'component' if (!kinds.includes(cls) || entry.kind !== 'component') continue const sf = sourceOf(entry.sourceFile) if (!sf) continue const { drawn, given } = storiesOf(entry.name, entry.sourceFile) // No story draws it at all: the contract's "drawn nowhere" says so, not this. if (!drawn || given.has(ANY)) continue for (const name of [entry.name, `${entry.name}View`]) { for (const prop of gatesOf(name, sf, 0)) { if (!given.has(prop)) findings.push({ part: entry.name, file: entry.sourceFile.split('\\').join('/'), prop, in: name }) } } } return findings }