From b7bdeab5869a78758274ff7206d64171935d37ee Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com>
Date: Sun, 16 Aug 2026 22:15:09 +0800
Subject: [PATCH 1/3] fix(ui): govern icon sizes with an icon-aware check
---
.github/workflows/ci.yml | 1 +
.../renderer/settings/about-settings-page.tsx | 4 +-
.../settings/request-customization-editor.tsx | 4 +-
package-lock.json | 1 +
package.json | 2 +
packages/ui/stories/icons.stories.tsx | 2 +-
scripts/check-icon-size.mjs | 222 ++++++++++++++++++
scripts/check-icon-size.test.mjs | 63 +++++
8 files changed, 294 insertions(+), 5 deletions(-)
create mode 100644 scripts/check-icon-size.mjs
create mode 100644 scripts/check-icon-size.test.mjs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 79f7a0b236..b25d493393 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -73,6 +73,7 @@ jobs:
- run: npm ci
- run: npm run lint
- run: npm run format:check
+ - run: npm run check:icon-size
- run: npm run build
- run: npm run typecheck
# Generated-artifact governance. astryx-theme/maka.{css,js} are built
diff --git a/apps/desktop/src/renderer/settings/about-settings-page.tsx b/apps/desktop/src/renderer/settings/about-settings-page.tsx
index 75a7d58dfd..a5fad5d3b3 100644
--- a/apps/desktop/src/renderer/settings/about-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/about-settings-page.tsx
@@ -1,6 +1,6 @@
import { useEffect, useId, useState } from 'react';
import { Badge, Link, List, ListItem } from '@astryxdesign/core';
-import { Sparkles } from '@maka/ui/icons';
+import { ICON_SIZE, Sparkles } from '@maka/ui/icons';
import {
Banner,
Button,
@@ -174,7 +174,7 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
as_wrapper="div"
className="settingsAboutHero"
as="h2"
- icon={ /* 64% of the 48px plate, matching .providerLogo's fill */}
+ icon={ /* plate glyph inside the About hero mark */}
iconClassName="settingsAboutLogo"
headingRowClassName="settingsAboutHeading"
title="Maka"
diff --git a/apps/desktop/src/renderer/settings/request-customization-editor.tsx b/apps/desktop/src/renderer/settings/request-customization-editor.tsx
index 29101030e5..064b6b0c61 100644
--- a/apps/desktop/src/renderer/settings/request-customization-editor.tsx
+++ b/apps/desktop/src/renderer/settings/request-customization-editor.tsx
@@ -7,7 +7,7 @@ import {
} from '@maka/core/runtime-policy';
import { type RequestHeaderUpdate } from '@maka/core/llm-connections';
import { Button, IconButton, TextArea, TextInput } from '@maka/ui';
-import { Trash2 } from '@maka/ui/icons';
+import { ICON_SIZE, Trash2 } from '@maka/ui/icons';
import { PasswordInput } from './password-input';
export interface RequestHeaderDraft {
@@ -148,7 +148,7 @@ export function RequestHeadersEditor(props: {
variant="ghost"
size="sm"
label={props.copy.removeHeader}
- icon={}
+ icon={}
isDisabled={props.disabled}
onClick={() =>
props.onHeadersChange(props.headers.filter(({ id }) => id !== header.id))
diff --git a/package-lock.json b/package-lock.json
index cc52ac4e70..3414de4b7d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,6 +26,7 @@
"@ai-sdk/provider-utils": "5.0.27",
"@astryxdesign/cli": "0.4.0",
"@astryxdesign/core": "0.4.0",
+ "@babel/parser": "^7.29.7",
"@biomejs/biome": "2.5.6",
"@types/node": "^26.1.2",
"dugite": "3.2.2",
diff --git a/package.json b/package.json
index 78475215df..aee6efd1f5 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,7 @@
"verify:windows-x64": "node scripts/verify-windows-x64.mjs",
"verify:windows-installer": "node scripts/verify-windows-installer-lifecycle.mjs",
"astryx:theme": "node scripts/build-astryx-theme.mjs",
+ "check:icon-size": "node --test scripts/check-icon-size.test.mjs && node scripts/check-icon-size.mjs",
"astryx:surface-inventory": "node scripts/check-astryx-surface-inventory.mjs",
"astryx:surface-inventory:write": "node scripts/generate-astryx-surface-inventory.mjs",
"sync:model-metadata": "node scripts/sync-model-metadata.mjs",
@@ -64,6 +65,7 @@
"@ai-sdk/provider-utils": "5.0.27",
"@astryxdesign/cli": "0.4.0",
"@astryxdesign/core": "0.4.0",
+ "@babel/parser": "^7.29.7",
"@biomejs/biome": "2.5.6",
"@types/node": "^26.1.2",
"dugite": "3.2.2",
diff --git a/packages/ui/stories/icons.stories.tsx b/packages/ui/stories/icons.stories.tsx
index a157e6659c..2fe003ff6f 100644
--- a/packages/ui/stories/icons.stories.tsx
+++ b/packages/ui/stories/icons.stories.tsx
@@ -67,7 +67,7 @@ export const LucideIcons: Story = {
textAlign: 'center',
}}
>
-
+
{name}
))}
diff --git a/scripts/check-icon-size.mjs b/scripts/check-icon-size.mjs
new file mode 100644
index 0000000000..be63f518dd
--- /dev/null
+++ b/scripts/check-icon-size.mjs
@@ -0,0 +1,222 @@
+#!/usr/bin/env node
+/**
+ * Product lucide icons must pick a rung from ICON_SIZE, not a raw pixel.
+ * Parse TSX so comments and unrelated components with a numeric size prop do
+ * not become false positives while aliases and non-integer literals remain
+ * governed.
+ */
+import { globSync, readFileSync } from 'node:fs';
+import { dirname, relative, resolve } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { parse } from '@babel/parser';
+
+const root = fileURLToPath(new URL('..', import.meta.url));
+const GLOBS = [
+ 'packages/ui/src/**/*.{ts,tsx}',
+ 'packages/ui/stories/**/*.{ts,tsx}',
+ 'apps/desktop/src/**/*.{ts,tsx}',
+];
+const ICON_METADATA_EXPORTS = new Set(['ICON_SIZE', 'LucideIcon', 'LucideProps']);
+const DYNAMIC_ICON_TAGS = new Map([['packages/ui/stories/icons.stories.tsx', new Set(['Comp'])]]);
+
+function normalizedRelativePath(repoRoot, file) {
+ return relative(repoRoot, file).split('\\').join('/');
+}
+
+function withoutModuleExtension(path) {
+ return path.replace(/\.(?:m?[jt]sx?)$/u, '');
+}
+
+function isIconSeamSpecifier(specifier, file, repoRoot) {
+ if (specifier === '@maka/ui/icons') return true;
+ if (!specifier.startsWith('.')) return false;
+ return (
+ withoutModuleExtension(resolve(dirname(file), specifier)) ===
+ resolve(repoRoot, 'packages/ui/src/icons')
+ );
+}
+
+function unwrapExpression(expression) {
+ if (
+ expression.type === 'ParenthesizedExpression' ||
+ expression.type === 'TSAsExpression' ||
+ expression.type === 'TSTypeAssertion' ||
+ expression.type === 'TSSatisfiesExpression' ||
+ expression.type === 'TSNonNullExpression'
+ ) {
+ return unwrapExpression(expression.expression);
+ }
+ return expression;
+}
+
+function isNumericConstant(expression) {
+ const unwrapped = unwrapExpression(expression);
+ if (unwrapped.type === 'NumericLiteral') return true;
+ return (
+ unwrapped.type === 'UnaryExpression' &&
+ (unwrapped.operator === '+' || unwrapped.operator === '-') &&
+ unwrapExpression(unwrapped.argument).type === 'NumericLiteral'
+ );
+}
+
+function isKnownIconValue(expression, iconBindings, iconNamespaces) {
+ const unwrapped = unwrapExpression(expression);
+ if (unwrapped.type === 'Identifier') return iconBindings.has(unwrapped.name);
+ if (
+ unwrapped.type === 'MemberExpression' &&
+ !unwrapped.computed &&
+ unwrapped.object.type === 'Identifier' &&
+ unwrapped.property.type === 'Identifier'
+ ) {
+ return (
+ iconNamespaces.has(unwrapped.object.name) &&
+ !ICON_METADATA_EXPORTS.has(unwrapped.property.name)
+ );
+ }
+ if (unwrapped.type === 'ConditionalExpression') {
+ return (
+ isKnownIconValue(unwrapped.consequent, iconBindings, iconNamespaces) &&
+ isKnownIconValue(unwrapped.alternate, iconBindings, iconNamespaces)
+ );
+ }
+ return false;
+}
+
+function visitAst(node, visit) {
+ if (!node || typeof node !== 'object') return;
+ if (typeof node.type === 'string') visit(node);
+ for (const value of Object.values(node)) {
+ if (Array.isArray(value)) {
+ for (const child of value) visitAst(child, visit);
+ } else if (value && typeof value === 'object' && typeof value.type === 'string') {
+ visitAst(value, visit);
+ }
+ }
+}
+
+function importedName(specifier) {
+ if (specifier.imported.type === 'Identifier') return specifier.imported.name;
+ return specifier.imported.value;
+}
+
+function collectIconBindings(program, file, repoRoot) {
+ const iconBindings = new Set();
+ const iconNamespaces = new Set();
+ for (const statement of program.body) {
+ if (
+ statement.type !== 'ImportDeclaration' ||
+ statement.importKind === 'type' ||
+ !isIconSeamSpecifier(statement.source.value, file, repoRoot)
+ ) {
+ continue;
+ }
+ for (const specifier of statement.specifiers) {
+ if (specifier.type === 'ImportNamespaceSpecifier') {
+ iconNamespaces.add(specifier.local.name);
+ } else if (specifier.type === 'ImportSpecifier' && specifier.importKind !== 'type') {
+ const exportedName = importedName(specifier);
+ if (!ICON_METADATA_EXPORTS.has(exportedName)) {
+ iconBindings.add(specifier.local.name);
+ }
+ }
+ }
+ }
+
+ // Preserve simple aliases used to select one of several imported icons.
+ let changed = true;
+ while (changed) {
+ changed = false;
+ visitAst(program, (node) => {
+ if (
+ node.type === 'VariableDeclarator' &&
+ node.id.type === 'Identifier' &&
+ node.init &&
+ !iconBindings.has(node.id.name) &&
+ isKnownIconValue(node.init, iconBindings, iconNamespaces)
+ ) {
+ iconBindings.add(node.id.name);
+ changed = true;
+ }
+ });
+ }
+ return { iconBindings, iconNamespaces };
+}
+
+function isGovernedIconTag(tagName, iconBindings, iconNamespaces, dynamicIconTags) {
+ if (tagName.type === 'JSXIdentifier') {
+ return iconBindings.has(tagName.name) || dynamicIconTags.has(tagName.name);
+ }
+ return (
+ tagName.type === 'JSXMemberExpression' &&
+ tagName.object.type === 'JSXIdentifier' &&
+ tagName.property.type === 'JSXIdentifier' &&
+ iconNamespaces.has(tagName.object.name) &&
+ !ICON_METADATA_EXPORTS.has(tagName.property.name)
+ );
+}
+
+export function findRawIconSizes(sourceText, file, options = {}) {
+ const repoRoot = options.repoRoot ?? root;
+ const program = parse(sourceText, {
+ sourceType: 'module',
+ sourceFilename: file,
+ plugins: ['typescript', 'jsx'],
+ }).program;
+ const { iconBindings, iconNamespaces } = collectIconBindings(program, file, repoRoot);
+ const dynamicIconTags =
+ DYNAMIC_ICON_TAGS.get(normalizedRelativePath(repoRoot, file)) ?? new Set();
+ const hits = [];
+ visitAst(program, (node) => {
+ if (
+ node.type === 'JSXOpeningElement' &&
+ isGovernedIconTag(node.name, iconBindings, iconNamespaces, dynamicIconTags)
+ ) {
+ for (const attribute of node.attributes) {
+ if (
+ attribute.type !== 'JSXAttribute' ||
+ attribute.name.type !== 'JSXIdentifier' ||
+ attribute.name.name !== 'size' ||
+ attribute.value?.type !== 'JSXExpressionContainer' ||
+ attribute.value.expression.type === 'JSXEmptyExpression' ||
+ !isNumericConstant(attribute.value.expression)
+ ) {
+ continue;
+ }
+ hits.push({
+ file: normalizedRelativePath(repoRoot, file),
+ line: attribute.loc.start.line,
+ column: attribute.loc.start.column + 1,
+ expression: sourceText.slice(attribute.start, attribute.end),
+ });
+ }
+ }
+ });
+ return hits;
+}
+
+export function scanIconSizes(repoRoot = root) {
+ const hits = [];
+ for (const pattern of GLOBS) {
+ for (const relativeFile of globSync(pattern, { cwd: repoRoot })) {
+ const file = resolve(repoRoot, relativeFile);
+ hits.push(...findRawIconSizes(readFileSync(file, 'utf8'), file, { repoRoot }));
+ }
+ }
+ return hits;
+}
+
+function run() {
+ const hits = scanIconSizes();
+ if (hits.length > 0) {
+ console.error(
+ `icon sizes must use ICON_SIZE (meta/control/chrome/empty/plate):\n${hits
+ .map((hit) => ` ${hit.file}:${hit.line}:${hit.column} ${hit.expression}`)
+ .join('\n')}`,
+ );
+ process.exitCode = 1;
+ return;
+ }
+ console.log('icon size scale: ok');
+}
+
+if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) run();
diff --git a/scripts/check-icon-size.test.mjs b/scripts/check-icon-size.test.mjs
new file mode 100644
index 0000000000..3704d2e290
--- /dev/null
+++ b/scripts/check-icon-size.test.mjs
@@ -0,0 +1,63 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { findRawIconSizes } from './check-icon-size.mjs';
+
+const repoRoot = '/repo';
+
+function expressions(source, file = `${repoRoot}/apps/desktop/src/example.tsx`) {
+ return findRawIconSizes(source, file, { repoRoot }).map((hit) => hit.expression);
+}
+
+test('rejects numeric sizes on named, aliased, derived, and namespace icon imports', () => {
+ assert.deepEqual(
+ expressions(`
+ import { ICON_SIZE, Search, Trash2 as DeleteIcon } from '@maka/ui/icons';
+ import * as Icons from '@maka/ui/icons';
+ const SelectedIcon = true ? Search : DeleteIcon;
+ export const Example = () => <>
+
+
+
+
+ >;
+ `),
+ ['size={16}', 'size={15.5}', 'size={14 as const}', 'size={+28}'],
+ );
+});
+
+test('accepts ICON_SIZE and ignores unrelated size props, strings, and comments', () => {
+ assert.deepEqual(
+ expressions(`
+ import { ICON_SIZE, Search } from '@maka/ui/icons';
+ /* */
+ const sample = '';
+ export const Example = () => <>
+
+
+
+ >;
+ `),
+ [],
+ );
+});
+
+test('resolves the relative icon seam and governs the dynamic icon story', () => {
+ assert.deepEqual(
+ expressions(
+ `
+ import { Search } from './icons.js';
+ import * as Icons from './icons.js';
+ export const Example = () => <>>;
+ `,
+ `${repoRoot}/packages/ui/src/example.tsx`,
+ ),
+ ['size={13}', 'size={20}'],
+ );
+ assert.deepEqual(
+ expressions(
+ 'export const Example = () => ;',
+ `${repoRoot}/packages/ui/stories/icons.stories.tsx`,
+ ),
+ ['size={20}'],
+ );
+});
From 971aa7aa2dda64a273cc94a4b6ed8e36a083e240 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com>
Date: Sun, 16 Aug 2026 22:54:04 +0800
Subject: [PATCH 2/3] fix(ui): resolve icon-size checks with lexical bindings
Shadowed parameters and local tags are no longer treated as imported
icons. Numeric string sizes such as size="16" are rejected. The Comp
story is governed only when Comp is actually bound to the icon seam.
---
scripts/check-icon-size.mjs | 229 +++++++++++++++++++++----------
scripts/check-icon-size.test.mjs | 39 ++++--
2 files changed, 189 insertions(+), 79 deletions(-)
diff --git a/scripts/check-icon-size.mjs b/scripts/check-icon-size.mjs
index be63f518dd..dbebea4bb5 100644
--- a/scripts/check-icon-size.mjs
+++ b/scripts/check-icon-size.mjs
@@ -17,7 +17,7 @@ const GLOBS = [
'apps/desktop/src/**/*.{ts,tsx}',
];
const ICON_METADATA_EXPORTS = new Set(['ICON_SIZE', 'LucideIcon', 'LucideProps']);
-const DYNAMIC_ICON_TAGS = new Map([['packages/ui/stories/icons.stories.tsx', new Set(['Comp'])]]);
+const NUMERIC_SIZE_STRING = /^[+-]?(?:\d+|\d*\.\d+)$/;
function normalizedRelativePath(repoRoot, file) {
return relative(repoRoot, file).split('\\').join('/');
@@ -59,9 +59,9 @@ function isNumericConstant(expression) {
);
}
-function isKnownIconValue(expression, iconBindings, iconNamespaces) {
+function isKnownIconValue(expression, scope) {
const unwrapped = unwrapExpression(expression);
- if (unwrapped.type === 'Identifier') return iconBindings.has(unwrapped.name);
+ if (unwrapped.type === 'Identifier') return resolveBinding(scope, unwrapped.name) === 'icon';
if (
unwrapped.type === 'MemberExpression' &&
!unwrapped.computed &&
@@ -69,29 +69,108 @@ function isKnownIconValue(expression, iconBindings, iconNamespaces) {
unwrapped.property.type === 'Identifier'
) {
return (
- iconNamespaces.has(unwrapped.object.name) &&
+ resolveBinding(scope, unwrapped.object.name) === 'namespace' &&
!ICON_METADATA_EXPORTS.has(unwrapped.property.name)
);
}
if (unwrapped.type === 'ConditionalExpression') {
- return (
- isKnownIconValue(unwrapped.consequent, iconBindings, iconNamespaces) &&
- isKnownIconValue(unwrapped.alternate, iconBindings, iconNamespaces)
- );
+ return isKnownIconValue(unwrapped.consequent, scope) && isKnownIconValue(unwrapped.alternate, scope);
}
return false;
}
-function visitAst(node, visit) {
- if (!node || typeof node !== 'object') return;
- if (typeof node.type === 'string') visit(node);
- for (const value of Object.values(node)) {
+function createScope(parent = null) {
+ return { parent, names: new Map() };
+}
+
+function defineBinding(scope, name, kind) {
+ scope.names.set(name, kind);
+}
+
+function resolveBinding(scope, name) {
+ for (let current = scope; current; current = current.parent) {
+ if (current.names.has(name)) return current.names.get(name);
+ }
+ return 'unbound';
+}
+
+function patternNames(pattern, names = []) {
+ if (!pattern) return names;
+ if (pattern.type === 'Identifier') {
+ names.push(pattern.name);
+ return names;
+ }
+ if (pattern.type === 'AssignmentPattern') return patternNames(pattern.left, names);
+ if (pattern.type === 'RestElement') return patternNames(pattern.argument, names);
+ if (pattern.type === 'TSParameterProperty') return patternNames(pattern.parameter, names);
+ if (pattern.type === 'ArrayPattern') {
+ for (const element of pattern.elements) patternNames(element, names);
+ return names;
+ }
+ if (pattern.type === 'ObjectPattern') {
+ for (const property of pattern.properties) {
+ if (property.type === 'RestElement') patternNames(property.argument, names);
+ else patternNames(property.value, names);
+ }
+ }
+ return names;
+}
+
+function isFunctionNode(node) {
+ return (
+ node.type === 'FunctionDeclaration' ||
+ node.type === 'FunctionExpression' ||
+ node.type === 'ArrowFunctionExpression' ||
+ node.type === 'ClassMethod' ||
+ node.type === 'ClassPrivateMethod' ||
+ node.type === 'ObjectMethod'
+ );
+}
+
+function childEntries(node) {
+ const entries = [];
+ for (const [key, value] of Object.entries(node)) {
+ if (key === 'type' || key === 'loc' || key === 'start' || key === 'end' || key === 'range') {
+ continue;
+ }
if (Array.isArray(value)) {
- for (const child of value) visitAst(child, visit);
+ for (const child of value) {
+ if (child && typeof child === 'object' && typeof child.type === 'string') {
+ entries.push(child);
+ }
+ }
} else if (value && typeof value === 'object' && typeof value.type === 'string') {
- visitAst(value, visit);
+ entries.push(value);
}
}
+ return entries;
+}
+
+function bindParams(scope, node) {
+ const params = node.params ?? [];
+ for (const param of params) {
+ for (const name of patternNames(param)) defineBinding(scope, name, 'not-icon');
+ }
+ if (node.id?.type === 'Identifier') defineBinding(scope, node.id.name, 'not-icon');
+}
+
+function isNumericSizeAttribute(attribute) {
+ if (
+ attribute.type !== 'JSXAttribute' ||
+ attribute.name.type !== 'JSXIdentifier' ||
+ attribute.name.name !== 'size' ||
+ !attribute.value
+ ) {
+ return false;
+ }
+ if (attribute.value.type === 'StringLiteral') {
+ return NUMERIC_SIZE_STRING.test(attribute.value.value.trim());
+ }
+ return (
+ attribute.value.type === 'JSXExpressionContainer' &&
+ attribute.value.expression.type !== 'JSXEmptyExpression' &&
+ isNumericConstant(attribute.value.expression)
+ );
}
function importedName(specifier) {
@@ -99,9 +178,7 @@ function importedName(specifier) {
return specifier.imported.value;
}
-function collectIconBindings(program, file, repoRoot) {
- const iconBindings = new Set();
- const iconNamespaces = new Set();
+function bindIconImports(program, file, repoRoot, scope) {
for (const statement of program.body) {
if (
statement.type !== 'ImportDeclaration' ||
@@ -112,49 +189,88 @@ function collectIconBindings(program, file, repoRoot) {
}
for (const specifier of statement.specifiers) {
if (specifier.type === 'ImportNamespaceSpecifier') {
- iconNamespaces.add(specifier.local.name);
+ defineBinding(scope, specifier.local.name, 'namespace');
} else if (specifier.type === 'ImportSpecifier' && specifier.importKind !== 'type') {
const exportedName = importedName(specifier);
if (!ICON_METADATA_EXPORTS.has(exportedName)) {
- iconBindings.add(specifier.local.name);
+ defineBinding(scope, specifier.local.name, 'icon');
}
}
}
}
-
- // Preserve simple aliases used to select one of several imported icons.
- let changed = true;
- while (changed) {
- changed = false;
- visitAst(program, (node) => {
- if (
- node.type === 'VariableDeclarator' &&
- node.id.type === 'Identifier' &&
- node.init &&
- !iconBindings.has(node.id.name) &&
- isKnownIconValue(node.init, iconBindings, iconNamespaces)
- ) {
- iconBindings.add(node.id.name);
- changed = true;
- }
- });
- }
- return { iconBindings, iconNamespaces };
}
-function isGovernedIconTag(tagName, iconBindings, iconNamespaces, dynamicIconTags) {
+function isGovernedIconTag(tagName, scope) {
if (tagName.type === 'JSXIdentifier') {
- return iconBindings.has(tagName.name) || dynamicIconTags.has(tagName.name);
+ return resolveBinding(scope, tagName.name) === 'icon';
}
return (
tagName.type === 'JSXMemberExpression' &&
tagName.object.type === 'JSXIdentifier' &&
tagName.property.type === 'JSXIdentifier' &&
- iconNamespaces.has(tagName.object.name) &&
+ resolveBinding(scope, tagName.object.name) === 'namespace' &&
!ICON_METADATA_EXPORTS.has(tagName.property.name)
);
}
+function collectHits(node, scope, sourceText, file, hits) {
+ if (!node || typeof node !== 'object' || typeof node.type !== 'string') return;
+
+ if (isFunctionNode(node)) {
+ const inner = createScope(scope);
+ bindParams(inner, node);
+ for (const child of childEntries(node)) collectHits(child, inner, sourceText, file, hits);
+ return;
+ }
+
+ if (node.type === 'CatchClause') {
+ const inner = createScope(scope);
+ for (const name of patternNames(node.param)) defineBinding(inner, name, 'not-icon');
+ collectHits(node.body, inner, sourceText, file, hits);
+ return;
+ }
+
+ if (node.type === 'BlockStatement') {
+ const inner = createScope(scope);
+ for (const child of node.body) collectHits(child, inner, sourceText, file, hits);
+ return;
+ }
+
+ if (
+ node.type === 'ForStatement' ||
+ node.type === 'ForInStatement' ||
+ node.type === 'ForOfStatement'
+ ) {
+ const inner = createScope(scope);
+ for (const child of childEntries(node)) collectHits(child, inner, sourceText, file, hits);
+ return;
+ }
+
+ if (node.type === 'VariableDeclarator') {
+ if (node.init) collectHits(node.init, scope, sourceText, file, hits);
+ if (node.id.type === 'Identifier') {
+ defineBinding(scope, node.id.name, node.init && isKnownIconValue(node.init, scope) ? 'icon' : 'not-icon');
+ } else {
+ for (const name of patternNames(node.id)) defineBinding(scope, name, 'not-icon');
+ }
+ return;
+ }
+
+ if (node.type === 'JSXOpeningElement' && isGovernedIconTag(node.name, scope)) {
+ for (const attribute of node.attributes) {
+ if (!isNumericSizeAttribute(attribute)) continue;
+ hits.push({
+ file,
+ line: attribute.loc.start.line,
+ column: attribute.loc.start.column + 1,
+ expression: sourceText.slice(attribute.start, attribute.end),
+ });
+ }
+ }
+
+ for (const child of childEntries(node)) collectHits(child, scope, sourceText, file, hits);
+}
+
export function findRawIconSizes(sourceText, file, options = {}) {
const repoRoot = options.repoRoot ?? root;
const program = parse(sourceText, {
@@ -162,35 +278,10 @@ export function findRawIconSizes(sourceText, file, options = {}) {
sourceFilename: file,
plugins: ['typescript', 'jsx'],
}).program;
- const { iconBindings, iconNamespaces } = collectIconBindings(program, file, repoRoot);
- const dynamicIconTags =
- DYNAMIC_ICON_TAGS.get(normalizedRelativePath(repoRoot, file)) ?? new Set();
+ const moduleScope = createScope();
+ bindIconImports(program, file, repoRoot, moduleScope);
const hits = [];
- visitAst(program, (node) => {
- if (
- node.type === 'JSXOpeningElement' &&
- isGovernedIconTag(node.name, iconBindings, iconNamespaces, dynamicIconTags)
- ) {
- for (const attribute of node.attributes) {
- if (
- attribute.type !== 'JSXAttribute' ||
- attribute.name.type !== 'JSXIdentifier' ||
- attribute.name.name !== 'size' ||
- attribute.value?.type !== 'JSXExpressionContainer' ||
- attribute.value.expression.type === 'JSXEmptyExpression' ||
- !isNumericConstant(attribute.value.expression)
- ) {
- continue;
- }
- hits.push({
- file: normalizedRelativePath(repoRoot, file),
- line: attribute.loc.start.line,
- column: attribute.loc.start.column + 1,
- expression: sourceText.slice(attribute.start, attribute.end),
- });
- }
- }
- });
+ collectHits(program, moduleScope, sourceText, normalizedRelativePath(repoRoot, file), hits);
return hits;
}
diff --git a/scripts/check-icon-size.test.mjs b/scripts/check-icon-size.test.mjs
index 3704d2e290..46ab780f15 100644
--- a/scripts/check-icon-size.test.mjs
+++ b/scripts/check-icon-size.test.mjs
@@ -25,7 +25,7 @@ test('rejects numeric sizes on named, aliased, derived, and namespace icon impor
);
});
-test('accepts ICON_SIZE and ignores unrelated size props, strings, and comments', () => {
+test('accepts ICON_SIZE and ignores unrelated size props, comments, and non-numeric strings', () => {
assert.deepEqual(
expressions(`
import { ICON_SIZE, Search } from '@maka/ui/icons';
@@ -33,7 +33,7 @@ test('accepts ICON_SIZE and ignores unrelated size props, strings, and comments'
const sample = '';
export const Example = () => <>
-
+
>;
`),
@@ -41,7 +41,33 @@ test('accepts ICON_SIZE and ignores unrelated size props, strings, and comments'
);
});
-test('resolves the relative icon seam and governs the dynamic icon story', () => {
+test('rejects numeric string sizes on icon tags', () => {
+ assert.deepEqual(
+ expressions(`
+ import { Search } from '@maka/ui/icons';
+ export const Example = () => ;
+ `),
+ ['size="16"'],
+ );
+});
+
+test('does not treat shadowed or unrelated local tags as icons', () => {
+ assert.deepEqual(
+ expressions(`
+ import { Search } from '@maka/ui/icons';
+ function Shadow(Search) {
+ return ;
+ }
+ export const Example = () => {
+ const Comp = Avatar;
+ return ;
+ };
+ `),
+ [],
+ );
+});
+
+test('resolves the relative icon seam', () => {
assert.deepEqual(
expressions(
`
@@ -53,11 +79,4 @@ test('resolves the relative icon seam and governs the dynamic icon story', () =>
),
['size={13}', 'size={20}'],
);
- assert.deepEqual(
- expressions(
- 'export const Example = () => ;',
- `${repoRoot}/packages/ui/stories/icons.stories.tsx`,
- ),
- ['size={20}'],
- );
});
From 9be1c5772126a611e0523ddc1b68abc1a14602af Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=83=AD=E5=90=89=E6=B5=A9?= <1625567290@qq.com>
Date: Sun, 16 Aug 2026 23:26:00 +0800
Subject: [PATCH 3/3] style: format the icon-size checker
Biome wants the long boolean and defineBinding calls wrapped.
---
scripts/check-icon-size.mjs | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/scripts/check-icon-size.mjs b/scripts/check-icon-size.mjs
index dbebea4bb5..56dbadd108 100644
--- a/scripts/check-icon-size.mjs
+++ b/scripts/check-icon-size.mjs
@@ -74,7 +74,9 @@ function isKnownIconValue(expression, scope) {
);
}
if (unwrapped.type === 'ConditionalExpression') {
- return isKnownIconValue(unwrapped.consequent, scope) && isKnownIconValue(unwrapped.alternate, scope);
+ return (
+ isKnownIconValue(unwrapped.consequent, scope) && isKnownIconValue(unwrapped.alternate, scope)
+ );
}
return false;
}
@@ -249,7 +251,11 @@ function collectHits(node, scope, sourceText, file, hits) {
if (node.type === 'VariableDeclarator') {
if (node.init) collectHits(node.init, scope, sourceText, file, hits);
if (node.id.type === 'Identifier') {
- defineBinding(scope, node.id.name, node.init && isKnownIconValue(node.init, scope) ? 'icon' : 'not-icon');
+ defineBinding(
+ scope,
+ node.id.name,
+ node.init && isKnownIconValue(node.init, scope) ? 'icon' : 'not-icon',
+ );
} else {
for (const name of patternNames(node.id)) defineBinding(scope, name, 'not-icon');
}