Phone hit kit: read IconButton className off the TypeScript AST
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m53s

className must appear once, with no spread after it, as the imported PhoneHit*
identifier bare, a classNames/clsx/cx direct argument, or a template span, and
the identifier must occur nowhere else in the expression. Tag enumeration uses
the same AST. expectPhoneHitClasses takes (string|null)[] (null = no class), as
the phone-hit-pages branch does, and is exercised by a test on a temp source.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Void 2026-11-13 07:28:37 +00:00
commit 4eaa6f9c55
2 changed files with 191 additions and 84 deletions

View file

@ -1,5 +1,14 @@
import { describe, expect, it } from 'vitest';
import { drawnPx, expectSizeTie, iconButtons, phoneHitOf } from './phoneHitTestKit';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { relative } from 'path';
import {
drawnPx,
expectPhoneHitClasses,
expectSizeTie,
iconButtons,
phoneHitOf,
} from './phoneHitTestKit';
describe('phone hit test kit', () => {
it('reads <IconButton only, not a longer component name', () => {
@ -39,38 +48,60 @@ describe('phone hit test kit', () => {
expect(() => expectSizeTie('styles/PhoneHit.css.ts')).toThrow(/no IconButton/);
});
describe('phoneHitOf reads the imported class, applied unconditionally', () => {
describe('phoneHitOf reads the imported class off the AST, applied unconditionally', () => {
const tag = (expr: string) => `<IconButton size="300" className={${expr}}>`;
const ok = (expr: string, name: string) => expect(phoneHitOf(tag(expr))).toBe(name);
const no = (expr: string) => expect(phoneHitOf(tag(expr))).toBeNull();
it('accepts the bare identifier and merges that always include it', () => {
expect(phoneHitOf(tag('PhoneHitSpread26'))).toBe('PhoneHitSpread26');
expect(phoneHitOf(tag('classNames(PhoneHitRoom36, css.other)'))).toBe('PhoneHitRoom36');
expect(phoneHitOf(tag('`${PhoneHit} ${css.other}`'))).toBe('PhoneHit');
expect(phoneHitOf(tag("[PhoneHit, 'a b'].join(' ')"))).toBe('PhoneHit');
expect(phoneHitOf(tag('classNames(css.a, { [css.b]: on }, PhoneHit)'))).toBe('PhoneHit');
ok('PhoneHitSpread26', 'PhoneHitSpread26');
ok('classNames(PhoneHitRoom36, css.other)', 'PhoneHitRoom36');
ok('clsx(css.a, { [css.b]: on }, PhoneHit)', 'PhoneHit');
ok('cx(PhoneHit)', 'PhoneHit');
ok('`${PhoneHit} ${css.other}`', 'PhoneHit');
ok('classNames(/* PhoneHit */ PhoneHit)', 'PhoneHit');
});
it('rejects the name as a string literal', () => {
expect(phoneHitOf(tag("'PhoneHitSpread26'"))).toBeNull();
expect(phoneHitOf(tag('`PhoneHitSpread26`'))).toBeNull();
});
it('rejects a name built by concatenation', () => {
expect(phoneHitOf(tag('"PhoneHitSpread26" + "x"'))).toBeNull();
expect(phoneHitOf(tag("'PhoneHit' + 'Spread26'"))).toBeNull();
it('rejects the name as a string, a concatenation or a comment', () => {
no("'PhoneHitSpread26'");
no('`PhoneHitSpread26`');
no('"PhoneHitSpread26" + "x"');
no("'PhoneHit' + 'Spread26'");
no('/* PhoneHit */ undefined');
no('css.a // PhoneHit');
});
it('rejects a conditionally applied name', () => {
expect(phoneHitOf(tag('cond ? PhoneHitSpread26 : undefined'))).toBeNull();
expect(phoneHitOf(tag('cond && PhoneHitSpread26'))).toBeNull();
expect(phoneHitOf(tag('cond || PhoneHitSpread26'))).toBeNull();
expect(phoneHitOf(tag('maybe ?? PhoneHitSpread26'))).toBeNull();
expect(phoneHitOf(tag("classNames(cond ? PhoneHit : '', css.a)"))).toBeNull();
no('cond ? PhoneHitSpread26 : undefined');
no('cond && PhoneHitSpread26');
no('cond || PhoneHitSpread26');
no('maybe ?? PhoneHitSpread26');
no("classNames(cond ? PhoneHit : '', css.a)");
no('classNames(PhoneHit, cond && PhoneHit)');
});
it('rejects a member of another object and a tag without className', () => {
expect(phoneHitOf(tag('other.PhoneHit'))).toBeNull();
it('rejects every other expression shape the name can hide in', () => {
no('classNames({ PhoneHit: on })');
no('clsx({ [PhoneHit]: on })');
no('`${`${PhoneHit}`}`');
no('typeof PhoneHit');
no("(PhoneHit = 'x')");
no('(PhoneHit, css.a)');
no('(PhoneHit)');
no('() => PhoneHit');
no('PhoneHit.foo');
no('other.PhoneHit');
no('classNames(css.a, [PhoneHit])');
no('wrap(PhoneHit)');
no('PhoneHitSpread26é');
no('`${css.a}`');
});
it('rejects a tag without className, with two, or with a non-expression one', () => {
expect(phoneHitOf('<IconButton size="300">')).toBeNull();
expect(phoneHitOf('<IconButton className={PhoneHit} className={css.a}>')).toBeNull();
expect(phoneHitOf('<IconButton className="PhoneHit">')).toBeNull();
expect(phoneHitOf('<IconButton className={PhoneHit} />')).toBe('PhoneHit');
});
it('rejects a spread after className, which can override it, and accepts one before', () => {
@ -80,7 +111,44 @@ describe('phone hit test kit', () => {
});
it('reads a className past braces inside strings', () => {
expect(phoneHitOf(tag("classNames('{', PhoneHit)"))).toBe('PhoneHit');
ok("classNames('{', PhoneHit)", 'PhoneHit');
});
});
describe('expectPhoneHitClasses', () => {
const check = (src: string, want: (string | null)[]) => {
const dir = mkdtempSync(`${tmpdir()}/phone-hit-kit-`);
try {
writeFileSync(`${dir}/Probe.tsx`, src);
expectPhoneHitClasses(relative(`${process.cwd()}/src/app`, `${dir}/Probe.tsx`), want);
} finally {
rmSync(dir, { recursive: true });
}
};
const imp = "import { PhoneHit, PhoneHitSpread26 } from '../../styles/PhoneHit.css';\n";
const src =
imp +
'const A = () => (<><IconButton className={PhoneHit} /><IconButton className={classNames(PhoneHitSpread26, a)} /><IconButton /></>);';
it('pins each button in order, null meaning no class', () => {
check(src, ['PhoneHit', 'PhoneHitSpread26', null]);
expect(() => check(src, ['PhoneHit', 'PhoneHit', null])).toThrow();
expect(() => check(src, ['PhoneHit', 'PhoneHitSpread26', 'PhoneHit'])).toThrow();
expect(() => check(src, ['PhoneHit', 'PhoneHitSpread26'])).toThrow();
});
it('fails a name that is not read from PhoneHit.css or is applied conditionally', () => {
const local = "const PhoneHit = 'x';\n<IconButton className={PhoneHit} />";
expect(() => check(local, ['PhoneHit'])).toThrow();
expect(() => check(local, [null])).not.toThrow();
const cond = imp + '<IconButton className={on ? PhoneHit : undefined} />';
expect(() => check(cond, ['PhoneHit'])).toThrow();
const aliased =
"import { Other as PhoneHit } from '../../styles/PhoneHit.css';\n<IconButton className={PhoneHit} />";
expect(() => check(aliased, ['PhoneHit'])).toThrow();
const wrongModule =
"import { PhoneHit } from './other';\n<IconButton className={PhoneHit} />";
expect(() => check(wrongModule, ['PhoneHit'])).toThrow();
});
});
});

View file

@ -2,6 +2,7 @@
// group of IconButtons). Not a test file: it registers nothing, the test files call it.
import { readFileSync } from 'fs';
import { expect } from 'vitest';
import ts from 'typescript';
import zlib from 'zlib';
import { compile, processVanillaFile } from '@vanilla-extract/integration';
@ -72,77 +73,113 @@ const tagEnd = (src: string, from: number) => {
return j;
};
/** Every <IconButton (not <IconButtonX): its opening tag, read to its closing > outside any {} expression, and
* what it wraps (empty when self-closing). */
const tsx = (src: string) =>
ts.createSourceFile('source.tsx', src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
type Opening = ts.JsxOpeningElement | ts.JsxSelfClosingElement;
/** Every IconButton element (not IconButtonX) of a parsed source, in source order. */
const elements = (sf: ts.SourceFile) => {
const found: Opening[] = [];
const walk = (n: ts.Node) => {
if (
(ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) &&
ts.isIdentifier(n.tagName) &&
n.tagName.text === 'IconButton'
)
found.push(n);
ts.forEachChild(n, walk);
};
walk(sf);
return found;
};
const bodyOf = (el: Opening) =>
ts.isJsxOpeningElement(el) && ts.isJsxElement(el.parent)
? el.getSourceFile().text.slice(el.end, el.parent.closingElement.getStart())
: '';
/** Every <IconButton (not <IconButtonX): its opening tag and what it wraps (empty when self-closing). */
export const iconButtons = (src: string) =>
[...src.matchAll(/<IconButton(?=[\s>/])/g)].map((m) => {
const start = m.index ?? 0;
const end = tagEnd(src, start);
const tag = src.slice(start, end + 1);
const close = src.indexOf('</IconButton>', end);
return { tag, body: tag.endsWith('/>') || close === -1 ? '' : src.slice(end + 1, close) };
});
elements(tsx(src)).map((el) => ({ tag: el.getText(), body: bodyOf(el) }));
export const iconButtonTags = (src: string) => iconButtons(src).map((b) => b.tag);
/** The expression of a tag's className={...} and the index of its closing brace, read to the balanced brace
* (strings and templates may hold braces); undefined when the tag has none. */
const classNameExpr = (tag: string) => {
const m = /\sclassName=\{/.exec(tag);
if (!m) return undefined;
const from = m.index + m[0].length;
let depth = 1;
let quote = '';
for (let j = from; j < tag.length; j += 1) {
const c = tag[j];
if (quote) {
if (c === '\\') j += 1;
else if (c === quote) quote = '';
} else if (c === '"' || c === "'" || c === '`') quote = c;
else if (c === '{') depth += 1;
else if (c === '}' && (depth -= 1) === 0) return { expr: tag.slice(from, j), end: j };
}
return undefined;
};
/** The expression with its string literals and the literal text of its templates removed (a `${...}` keeps its
* expression), so only identifiers and operators remain. */
const withoutLiterals = (expr: string) =>
expr
.replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g, ' ')
.replace(/`((?:[^`\\]|\\.)*)`/g, (_, body: string) =>
[...body.matchAll(/\$\{([^}]*)\}/g)].map((t) => ` ${t[1]} `).join(' ')
);
/** The PhoneHit* class a tag carries: a bare identifier in its className expression, applied unconditionally. A
* name inside a string, built by concatenation, or behind ?:, &&, || or ??, or followed by a {...spread}, is not the
* imported class (or not always applied), so it reads as null. */
export const phoneHitOf = (tag: string) => {
const found = classNameExpr(tag);
if (found === undefined) return null;
// props spread in after className can override it at runtime; before it they cannot
if (tag.slice(found.end).includes('{...')) return null;
const bare = withoutLiterals(found.expr);
if (/\?|&&|\|\|/.test(bare)) return null;
const names = [...bare.matchAll(/(?<![\w$.])(PhoneHit\w*)(?![\w$])/g)].map((m) => m[1]);
return names.length === 1 ? names[0] : null;
};
/** The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size and
* whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. Also checks
* the name is the one PhoneHit.css.ts exports, not a local of the same name. */
export const expectPhoneHitClasses = (file: string, want: string[]) => {
const src = readSource(file);
expect(iconButtonTags(src).map(phoneHitOf)).toEqual(want);
[...new Set(want)].forEach((name) =>
expect(src).toMatch(
new RegExp(
`import \\{(?:[^}]*,)?\\s*${name}\\s*(?:,[^}]*)?\\} from '(\\.\\./)+styles/PhoneHit\\.css'`
)
)
/** Local names the source imports from the PhoneHit.css module (as written, not aliased). */
const importedHits = (sf: ts.SourceFile) =>
new Set(
sf.statements.flatMap((st) => {
const nb = ts.isImportDeclaration(st) ? st.importClause?.namedBindings : undefined;
return nb &&
ts.isNamedImports(nb) &&
ts.isStringLiteral((st as ts.ImportDeclaration).moduleSpecifier) &&
/^(\.\.\/)+styles\/PhoneHit\.css$/.test(
((st as ts.ImportDeclaration).moduleSpecifier as ts.StringLiteral).text
)
? nb.elements.filter((e) => !e.propertyName).map((e) => e.name.text)
: [];
})
);
const MERGERS = new Set(['classNames', 'clsx', 'cx']);
/** The PhoneHit* class an IconButton carries, or null. className must appear exactly once, with no props spread
* after it (a spread can override it at runtime), and its expression must be: the bare identifier; a classNames /
* clsx / cx call with it as a direct argument; or a template with it as a direct span. The identifier must occur
* nowhere else in the expression, so a key, a member, a closure, a conditional or a comma can never carry it. When
* `imported` is given, the identifier must also be a binding imported from the PhoneHit.css module. */
const hitOf = (el: Opening, imported?: Set<string>): string | null => {
const props = el.attributes.properties;
const at = props.flatMap((p, i) =>
ts.isJsxAttribute(p) && ts.isIdentifier(p.name) && p.name.text === 'className' ? [i] : []
);
if (at.length !== 1 || props.slice(at[0] + 1).some(ts.isJsxSpreadAttribute)) return null;
const attr = props[at[0]] as ts.JsxAttribute;
const expr =
attr.initializer && ts.isJsxExpression(attr.initializer) && attr.initializer.expression;
if (!expr) return null;
const isHit = (n: ts.Node): n is ts.Identifier =>
ts.isIdentifier(n) && /^PhoneHit\w*$/.test(n.text) && (!imported || imported.has(n.text));
let direct: ts.Node[] = [];
if (ts.isIdentifier(expr)) direct = [expr];
else if (
ts.isCallExpression(expr) &&
ts.isIdentifier(expr.expression) &&
MERGERS.has(expr.expression.text)
)
direct = [...expr.arguments];
else if (ts.isTemplateExpression(expr)) direct = expr.templateSpans.map((sp) => sp.expression);
const hits = direct.filter(isHit);
if (hits.length !== 1) return null;
let occurrences = 0;
const count = (n: ts.Node) => {
if (ts.isIdentifier(n) && n.text === hits[0].text) occurrences += 1;
ts.forEachChild(n, count);
};
count(expr);
return occurrences === 1 ? hits[0].text : null;
};
/** The PhoneHit* class of one tag's text (see hitOf), without the import-binding check. */
export const phoneHitOf = (tag: string) => {
const els = elements(tsx(tag.endsWith('/>') ? tag : `${tag}</IconButton>`));
return els.length === 1 ? hitOf(els[0]) : null;
};
/** The PhoneHit* class of each IconButton of a source, in source order; null when it has none or the name is not
* the one imported from PhoneHit.css. */
const phoneHitClasses = (src: string) => {
const sf = tsx(src);
const imported = importedHits(sf);
return elements(sf).map((el) => hitOf(el, imported));
};
/** The class each IconButton must carry, in source order (null = must carry none): the variant is chosen by the
* button's drawn size and whether a clipping container or neighbours surround it, so a swapped variant is a wrong
* hit area. The name must also be the one PhoneHit.css exports, not a local of the same name. */
export const expectPhoneHitClasses = (file: string, want: (string | null)[]) =>
expect(phoneHitClasses(readSource(file))).toEqual(want);
// IconButton `size` -> padding on each side, Icon `size` -> px (folds 2.7.2). Both default to 400.
const BUTTON_PAD: Record<string, number> = { '300': 4, '400': 8, '500': 12, '600': 16 };
const ICON_PX: Record<string, number> = {
@ -188,10 +225,12 @@ export type SizeAllow = Record<string, Record<number, string>>;
export const expectSizeTie = (file: string, allow: SizeAllow = {}) => {
const allowed = allow[file] ?? {};
const seen = new Set<number>();
const buttons = iconButtons(readSource(file));
const src = readSource(file);
const buttons = iconButtons(src);
const classes = phoneHitClasses(src);
expect(buttons.length, `${file} has no IconButton`).toBeGreaterThan(0);
buttons.forEach(({ tag, body }, i) => {
const variant = phoneHitOf(tag)?.match(/^PhoneHit(?:Spread|Room)(\d+)$/);
const variant = classes[i]?.match(/^PhoneHit(?:Spread|Room)(\d+)$/);
if (!variant) {
expect(allowed[i], `${file} #${i} is allow-listed but has no sized variant`).toBeUndefined();
return;