From b0c5dddeca301415c620f129963257af8af44aa7 Mon Sep 17 00:00:00 2001 From: jackwener Date: Wed, 12 Aug 2026 03:53:18 +0800 Subject: [PATCH] test(ci): remove source-scanning governance gates --- .github/workflows/ci.yml | 11 - apps/desktop/src/renderer/README.md | 4 +- apps/desktop/src/renderer/maka-tokens.css | 3 +- apps/desktop/stories/FIDELITY.md | 2 +- docs/frontend-css-governance.md | 25 +- docs/frontend-css-governance.zh-CN.md | 24 +- docs/settings-astryx-deep-review.zh-CN.md | 2 +- package.json | 2 +- packages/ui/src/styles.css | 6 +- scripts/check-dead-css-baseline.json | 5 - scripts/check-dead-css.mjs | 582 ---------------------- scripts/check-story-annotations.mjs | 145 ------ 12 files changed, 21 insertions(+), 790 deletions(-) delete mode 100644 scripts/check-dead-css-baseline.json delete mode 100755 scripts/check-dead-css.mjs delete mode 100644 scripts/check-story-annotations.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1decc829e..975982dab3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,17 +70,6 @@ jobs: run: npx knip --workspace apps/desktop - name: Knip (packages/ui) run: npx knip --workspace packages/ui - # Dead-CSS governance. Lived only under `check:release`, so a regression - # stayed invisible until a release was cut; it was red on main when this - # step was added. Same class of source-scanning guard as knip above. - - name: Dead CSS - run: node scripts/check-dead-css.mjs --check - # Storybook fidelity: every Product/* story must name the real user path - # to the state it renders. Presence is mechanical; truth stays with the - # reviewer. See apps/desktop/stories/FIDELITY.md. - - name: Story annotations - run: node scripts/check-story-annotations.mjs - test_workspaces: needs: changes if: needs.changes.outputs.standard_workspaces != '' diff --git a/apps/desktop/src/renderer/README.md b/apps/desktop/src/renderer/README.md index 948731294e..10eb4eceb4 100644 --- a/apps/desktop/src/renderer/README.md +++ b/apps/desktop/src/renderer/README.md @@ -47,6 +47,6 @@ Acknowledged transitional states — not TODOs; track work in issues/PRs. ## Contracts & guardrails - Product design intent: `DESIGN.md`. -- CSS cascade / layer / `!important` / dead-CSS / token rules: `docs/frontend-css-governance.md`. The dead-CSS check runs from the repo root via `check:release` (`scripts/check-dead-css.mjs --check`); its baseline is `scripts/check-dead-css-baseline.json`. +- CSS cascade / layer / `!important` / dead-CSS / token rules: `docs/frontend-css-governance.md`. - Component state, ARIA, token, and copy behavior is owned by source and focused contract tests. -- Where prose disagrees with code or behavioral tests, code and tests are the source of truth. CSS conventions are checked by review, focused `scripts/check-*.mjs` commands, and rendered-surface verification; the former source-scanning CSS contract suite has been removed. Build/test entry points are the npm scripts in the root `package.json` (see the top-level `README.md`). +- Where prose disagrees with code or behavioral tests, code and tests are the source of truth. CSS conventions are checked by review and rendered-surface verification. Build/test entry points are the npm scripts in the root `package.json` (see the top-level `README.md`). diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index 3fffb9c8b3..7dfd9b15ff 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -437,8 +437,7 @@ The product used to alias four ladder rungs here — heading 16 / stat 20 / ui 14 / caption 12 — so a call site could name a size. A call site names a ROLE now (see the role table below), and the size is the role's own atom, - so all four reached zero consumers and are gone; check-dead-css found - them, and type-scale-contract.test.ts asserts they stay gone. + so all four reached zero consumers and are gone. The ladder they aliased is still the authority: it is generated by the Astryx type scale in astryx-theme/makaTheme.ts (`{base: 14, ratio: 1.125}`), diff --git a/apps/desktop/stories/FIDELITY.md b/apps/desktop/stories/FIDELITY.md index 647239e832..61d32f8f6a 100644 --- a/apps/desktop/stories/FIDELITY.md +++ b/apps/desktop/stories/FIDELITY.md @@ -15,7 +15,7 @@ So each story carries a `// Real path:` comment directly above it, naming how a export const Populated: Story = { … } ``` -The annotation is prose on purpose. Its value is that someone traced the path and wrote it down; a machine-checkable schema would be satisfied by a plausible-looking lie just as easily. So the convention splits along what a machine can decide. `scripts/check-story-annotations.mjs` checks that the sentence *exists*, and fails on any export it cannot classify rather than skipping it — write stories as `export const Name: Story = …` and nothing else. **It cannot tell you the sentence is true.** Only a reviewer following the call chain can, and reviewing that sentence is the point of writing it. The script's header explains why it stops there. +The annotation is prose on purpose. Its value is that someone traced the path and wrote it down; a mechanical presence check would be satisfied by a plausible-looking lie just as easily. Only a reviewer following the call chain can determine whether it is true. Two of the first batch of annotations were wrong, and both were caught by reading rather than by running anything: one named a path through a builder that cannot produce the state (`CommandPaletteDisabledCommand`), and one named two hosts for a frame that is only one of them. Write the sentence narrow enough to be falsifiable — the host, the builder, the gate — because a sentence vague enough to always be true buys nothing. diff --git a/docs/frontend-css-governance.md b/docs/frontend-css-governance.md index c097946432..3b6de2de35 100644 --- a/docs/frontend-css-governance.md +++ b/docs/frontend-css-governance.md @@ -37,26 +37,18 @@ Astryx reset and component layers come first; Maka base tokens and product `comp - Component-local properties are allowed only with a `/* local: ... */` comment. - Do not add raw colors, radii, or ungoverned z-index values. -## 6. Dead CSS +## 6. How these rules are checked -- `scripts/check-dead-css.mjs` scans `apps/desktop/src/renderer/styles/**/*.css` and `apps/desktop/src/renderer/reference-shell.css`. -- Runtime-generated class names that static search cannot find must be explicitly allowlisted. -- Change `scripts/check-dead-css-baseline.json` only after review confirms the class-count change. - -## 7. How these rules are checked - -The rules above are conventions enforced in review, plus the fast scripts that -survive as their own commands — `check-dead-css`, `check-a11y`, and -`check-console`. The source-scanning contract suite that used to re-assert them -as tests is gone: it charged every refactor a rewrite of its own guards while -catching only what a linter should. +These rules are conventions enforced in review. Static correctness belongs to +Biome, Knip, and typecheck; accessibility keeps its focused check. CSS usage and +Story prose are not decided by repository-wide regex baselines. - Renderer CSS behavior is verified where it renders: Storybook, the app, or an e2e assertion on the real surface. -- A rule worth machine-enforcing belongs in a `scripts/check-*.mjs` (fast, - one job, no build) rather than a test that regexes the source tree. +- Remove selectors with the source or surface that owned them instead of + maintaining an allowlist of strings that may be generated at runtime. -## 8. Change order +## 7. Change order When changing renderer CSS: @@ -65,9 +57,8 @@ When changing renderer CSS: 3. Remove dead selectors. 4. Remove remaining `!important` only after primitive and layer ownership is stable. -## 9. Governing principles +## 8. Governing principles -- Make CI guards trustworthy before structural convergence. - Delete dead CSS before aesthetic refactoring. - Resolve shared `Button`, `Textarea`, and `EmptyState` overrides at the component API seam instead of accumulating renderer specificity. - Every change to cascade order requires the narrowest relevant regression check on the rendered surface. diff --git a/docs/frontend-css-governance.zh-CN.md b/docs/frontend-css-governance.zh-CN.md index 4f3ad0e440..88306f10d0 100644 --- a/docs/frontend-css-governance.zh-CN.md +++ b/docs/frontend-css-governance.zh-CN.md @@ -47,27 +47,15 @@ Astryx reset 和组件层在前,Maka base token 与产品 `components` 在后 - radius - 未纳入约束体系的 z-index -## 6. Dead CSS 规则 +## 6. 这些规则靠什么保证 -- dead CSS 检查脚本是: - - `scripts/check-dead-css.mjs` -- 当前扫描范围包括: - - `apps/desktop/src/renderer/styles/**/*.css` - - `apps/desktop/src/renderer/reference-shell.css` -- 如果某个 class 是运行时动态生成、源码静态搜索不到,必须在脚本 allowlist 中明确登记。 -- 如果 dead class 数量变化,只有在评审明确确认的前提下,才允许修改 `scripts/check-dead-css-baseline.json`。 - -## 7. 这些规则靠什么保证 - -靠评审时的约定,加上仍然独立存在的快速脚本:`check-dead-css`、`check-a11y`、 -`check-console`。原来用测试把这些规则再断言一遍的源码扫描套件已经 -删除——它让每次重构都要顺手改写自己的护栏,抓到的却只是 linter 该抓的东西。 +这些规则靠评审保证。静态正确性交给 Biome、Knip 和 typecheck;accessibility +保留聚焦的检查。CSS 使用关系和 Story 文案不再由全仓 regex baseline 决定。 - renderer CSS 的行为在它真正渲染的地方验证:Storybook、app,或对真实界面的 e2e 断言。 -- 真的值得机器强制的规则,写成 `scripts/check-*.mjs`(快、单一职责、不依赖构建), - 而不是写成一个正则扫源码树的测试。 +- selector 应随其 source 或 surface 一起删除,不维护运行时字符串 allowlist。 -## 8. 推荐改动顺序 +## 7. 推荐改动顺序 调整 renderer CSS 时,建议按下面顺序推进: @@ -76,7 +64,7 @@ Astryx reset 和组件层在前,Maka base token 与产品 `components` 在后 3. 清理 dead selector。 4. 只有在 primitive / layer 架构已经稳定后,再移除剩余 `!important`。 -## 9. 当前治理原则 +## 8. 当前治理原则 - 先保证 CI 护栏可信,再做结构收敛。 - 先删 dead CSS,再谈样式“美化性重构”。 diff --git a/docs/settings-astryx-deep-review.zh-CN.md b/docs/settings-astryx-deep-review.zh-CN.md index c22ae581ae..ec6abd0b26 100644 --- a/docs/settings-astryx-deep-review.zh-CN.md +++ b/docs/settings-astryx-deep-review.zh-CN.md @@ -115,7 +115,7 @@ Badge;彩色药丸只保留真正的计数/枚举 `Token`。 4. **逐页清理 C4/C5/C6**(按第三节清单),记忆页单独做(动作收纳进 `MoreMenu`、条目行化)。 -5. **验证闭环**:四组合(zh/en × light/dark)截图全过;check-dead-css 归零 +5. **验证闭环**:四组合(zh/en × light/dark)截图全过;dead CSS review 归零 (预计再删 memory.css/permission.css/health.css 大半);typecheck + 全检查。 预计削减:settings 专属 CSS 从 ~1900 行降到 <600 行;页面代码同步变薄。 diff --git a/package.json b/package.json index fa8d037403..5265caa181 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "check:stale": "node scripts/check-stale-dist.mjs", "generate:third-party-notices": "node scripts/generate-third-party-notices.mjs", "check:third-party-notices": "node scripts/generate-third-party-notices.mjs --check", - "check:release": "npm run check:stale && npm run check:third-party-notices && node scripts/check-dead-css.mjs --check", + "check:release": "npm run check:stale && npm run check:third-party-notices", "package:macos-arm64": "node scripts/package-macos-arm64.mjs", "verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs", "package:windows-x64": "node scripts/package-windows-x64.mjs", diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index 29a234f450..144d66d5c1 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -749,11 +749,7 @@ `.maka-tool-terminal-*` family is currently unreachable: `previewVariants({part: 'terminal-*'})` has no call site — `TerminalPreview` renders `maka-tool-output-stack` / `ShellOutputBody` / `ToolCodeBlock` - instead — and `check-dead-css` cannot see that because the literals still - exist in `previewVariants`. The alternative is an exemption whose reason is - "nothing renders this", which is a fact about call sites recorded in a - stylesheet and would outlive the day someone wires the family back up. - Deleting the family is its own change. */ + instead. Deleting the family is its own change. */ .maka-tool-terminal-exit { font: var(--maka-text-heading-5); height: var(--h-control-sm); display: inline-flex; align-items: center; white-space: nowrap; padding: 1px var(--space-1-5); border-radius: var(--radius-pill); background: var(--foreground-5); color: var(--foreground-secondary); letter-spacing: var(--tracking-wide); } diff --git a/scripts/check-dead-css-baseline.json b/scripts/check-dead-css-baseline.json deleted file mode 100644 index 8a813cb2d3..0000000000 --- a/scripts/check-dead-css-baseline.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "version": 1, - "maxDeadClassCount": 0, - "maxDeadTokenCount": 0 -} diff --git a/scripts/check-dead-css.mjs b/scripts/check-dead-css.mjs deleted file mode 100755 index 643ab950db..0000000000 --- a/scripts/check-dead-css.mjs +++ /dev/null @@ -1,582 +0,0 @@ -#!/usr/bin/env node -/** - * check-dead-css — detect CSS classes and design tokens with zero consumers. - * - * Classes: parses all .css files under apps/desktop/src/renderer/styles/ - * (including the settings/ sub-directory) plus maka-tokens.css and - * packages/ui/src/styles.css (the component-library sheet imported into the - * product CSS) for class selectors, then searches the renderer and - * packages/ui/src source for consumers. A consumer is an exact class-name match: `maka-shell` in source - * does not keep `.maka-shell-rail` alive, and `maka-shell-rail` does not keep - * `.maka-shell` alive (#1980). - * - * Tokens: parses the custom properties declared in maka-tokens.css and sweeps - * every renderer stylesheet plus the source tree for var() reads. Reads - * inside the token sheet itself only count when they can fire: a read in a - * class rule counts when that rule's classes have consumers, and a read in - * another token's value counts when that token is itself live (#1980). - * - * Known limitations: - * - Dynamic class names (template-string concatenation) cause false - * negatives (reported as dead when actually used). The script outputs - * a DYNAMIC_STYLE_HOOKS allowlist for known runtime-generated classes. - * - A token that is one rung of an ordered scale can legitimately outlive - * its last consumer; RESERVED_SCALE_TOKENS carries those. - * - This is a baseline tool: it establishes a snapshot. CI should enforce - * that the dead counts never INCREASE, not that they reach zero. - * - * Usage: - * node scripts/check-dead-css.mjs # report dead classes + tokens - * node scripts/check-dead-css.mjs --check # exit 1 if count > baseline - * - * Part of issue #253 Round G. - */ -import { readdir, readFile } from 'node:fs/promises'; -import { resolve, join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = resolve(__dirname, '..'); -const BASELINE_PATH = resolve(REPO_ROOT, 'scripts', 'check-dead-css-baseline.json'); - -const RENDERER_ROOT = resolve(REPO_ROOT, 'apps', 'desktop', 'src', 'renderer'); -const STYLES_DIR = resolve(RENDERER_ROOT, 'styles'); -const EXTRA_STYLE_FILES = [resolve(RENDERER_ROOT, 'reference-shell.css')]; -/** - * The Astryx accent bridge in maka-tokens.css re-declares Astryx theme tokens - * (--color-accent and friends) whose var() reads live in the library's own - * stylesheet under node_modules, not in this repo. That stylesheet is a real - * consumer of the token sheet, so it joins the token sweep — and only the - * token sweep: it owns no product classes. - */ -const EXTRA_TOKEN_CONSUMER_FILES = [ - resolve(REPO_ROOT, 'node_modules', '@astryxdesign', 'core', 'dist', 'astryx.css'), - // Astryx components read theme tokens through StyleX, so some var() reads - // (e.g. --color-icon-accent) only exist in the compiled token map, not in - // astryx.css. - resolve(REPO_ROOT, 'node_modules', '@astryxdesign', 'core', 'dist', 'theme', 'tokens.stylex.js'), -]; -const TOKEN_FILE = resolve(RENDERER_ROOT, 'maka-tokens.css'); -/** The component-library sheet — imported into the product CSS, so its - * classes are product classes and join the dead-class scan. Deliberately not - * the whole of packages/ui/src: the only other stylesheets there would be - * generated ones, which own no hand-written product classes. */ -const UI_STYLE_FILE = resolve(REPO_ROOT, 'packages', 'ui', 'src', 'styles.css'); -const SOURCE_ROOTS = [ - resolve(REPO_ROOT, 'apps', 'desktop', 'src', 'renderer'), - resolve(REPO_ROOT, 'packages', 'ui', 'src'), -]; -/** Stylesheets that read tokens without owning product classes. */ -const TOKEN_CONSUMER_ROOTS = [RENDERER_ROOT, resolve(REPO_ROOT, 'packages', 'ui', 'src')]; -/** - * Storybook is the repo's visual loop, so a story reading a token is a real - * consumer — stories legitimately compose their own surfaces out of the design - * vocabulary. Deliberately not a consumer for classes: a product class that - * only a story references is still dead product CSS. - */ -const STORY_ROOTS = [ - resolve(REPO_ROOT, 'apps', 'desktop', 'stories'), - resolve(REPO_ROOT, 'packages', 'ui', 'stories'), -]; -const SOURCE_EXTENSIONS = new Set(['.html', '.js', '.jsx', '.ts', '.tsx']); - -// Classes generated at runtime that won't appear in source grep. -const DYNAMIC_STYLE_HOOKS = new Set([ - 'os-scrollbar-horizontal', - 'os-scrollbar-vertical', - 'is-err', - 'is-error', - 'is-idle', - 'is-needs_reauth', - 'is-ok', - 'is-untested', - 'is-verified', - 'is-warn', - // Astryx renders these stable component classes through themeProps at - // runtime. Responsive page CSS targets them for layout overrides, but they - // do not appear as className literals in Maka source. - 'astryx-button', - 'astryx-badge', - 'astryx-resize-handle-pill', - // EmptyState's root (EmptyState.tsx themeProps). chat-message.css reads it to - // tell an empty chat surface from a conversation with rows in it. - 'astryx-empty-state', - // The column resize handle's hit area (Resizable/ResizeHandle themeProps). - // sidebar.css styles the sidenav handle's own line through it. - 'astryx-resize-handle', - // A form field's outer box (Field themeProps). module-shell.css gives up the - // vendor's tuned control widths inside the module page's control bar once the - // column is narrower than they are. - 'astryx-field', - // AppShell's sidenav slot (AppShell.tsx themeProps); shell-layout.css clears - // its top so the column runs under the transparent titlebar. - 'astryx-app-shell-sidenav', - // AppShell's content column (Layout themeProps); shell-layout.css paints the - // canvas behind the floating content plate on it. - 'astryx-layout-content', - // SideNav shell + items + section titles (sidebar.css product overrides). - 'astryx-side-nav', - 'astryx-side-nav-item', - 'astryx-side-nav-section', - // Selector / MultiSelector trigger shell (themeProps class on the outer - // field). native-cursor.css and model-switcher.css target these so the - // multi-node hit target (label button + sibling chevron) stays one cursor. - 'astryx-selector', - 'astryx-multi-selector', - // Rendered by Astryx's own Collapsible; settings/permission.css targets it to - // size the capability group's disclosure row. - 'astryx-collapsible-trigger', - // ChatComposerDrawer's root (themeProps). composer.css pins its content - // grid's implicit column to the grid's own width so the staged-attachment - // row wraps at the real drawer edge instead of a max-content phantom width. - 'astryx-chat-composer-drawer', - // ChatToolCalls root + CodeBlock root (themeProps). chat-message.css and - // tool presentation styles target them; Astryx emits the class strings at - // runtime so they never appear as Maka className literals. Tests used to - // keep them "live" via markup greps — those greps were removed as vendor - // DOM contracts (#2587). - 'astryx-chat-tool-calls', - 'astryx-codeblock', - // Astryx's Item (themeProps class on every settings row). rows.css squares - // its corners inside an open row group: Item ships a 10px radius for its - // standalone chip use, and our hairline is a border on the Item itself, so - // the radius bent the divider at both ends. - 'astryx-item', - // Rendered by `useTriggerMenu` for the composer's `@` / `/` menus. - // composer-mention.css caps its width: upstream sets a 180px floor and no - // ceiling, and our rows carry a non-wrapping second line. - 'astryx-trigger-menu', - // Workbar Review and Terminal surfaces style Astryx components by their - // stable runtime themeProps classes. - 'astryx-banner', - 'astryx-toolbar', - // xterm.js creates the viewport node internally after mounting. - 'xterm-viewport', - // Appearance palette swatches — composed at runtime via - // `settingsPaletteSwatch-${palette}` in settings/appearance-settings-page.tsx - // (#308), so the per-palette variants never appear as string literals in - // source. Keep in sync with PALETTE_GROUPS in that file. - 'settingsPaletteSwatch-default', - 'settingsPaletteSwatch-onedark', - 'settingsPaletteSwatch-catppuccin-mocha', - 'settingsPaletteSwatch-tokyo-night', - 'settingsPaletteSwatch-nord', - 'settingsPaletteSwatch-coral', - 'settingsPaletteSwatch-azure', - 'settingsPaletteSwatch-forest', - 'settingsPaletteSwatch-dusk', - 'settingsPaletteSwatch-sand', - 'settingsPaletteSwatch-mono', - // Markdown code-block density variants — composed at runtime via - // `maka-markdown-code-${props.density}` in markdown-body.tsx and - // mermaid-diagram.tsx, so the per-density names never appear as string - // literals. Keep in sync with the density prop's values. - 'maka-markdown-code-default', - 'maka-markdown-code-compact', - // Astryx's Markdown renders its document root and every block through - // themeProps, so these classes exist only at runtime. The transcript rhythm - // table in packages/ui/src/styles.css targets them (with `data-density`) to - // own compact prose spacing, which Astryx's density cannot reach on its own. - 'astryx-markdown', - 'astryx-markdown-heading', - // Markdown delegates lists to the List control; the rhythm table re-spaces - // its rows as prose. - 'astryx-list', - 'astryx-list-item', -]); - -/** - * Tokens kept with no current consumer because they are one rung of an ordered - * scale. Deleting a middle rung is what invites the next bare number — the - * point of the scale is that the gaps are named. A token that is merely - * unused, with no series around it, does not belong here; delete it instead. - */ -const RESERVED_SCALE_TOKENS = new Set([ - // Five-rung icon scale meta/control/chrome/empty/plate (Chapter C). The CSS - // tokens mirror ICON_SIZE in @maka/ui's icons.tsx for the CSS-clamped - // sites; a rung with no clamp today (empty, plate) still names its gap. - // --icon-size is the deprecated alias, kept one release. - '--icon-chrome', - '--icon-empty', - '--icon-plate', - '--icon-size', - // Ink tint band --foreground-alpha-10/-16 (visual system 2.0). The 10 rung - // lost its last consumer when quote-chip hover converged onto - // --state-hover-bg (T5-B); the band only reads as a band with both rungs, - // and decorative tints must come from it rather than fresh hand-mixed - // alphas (DESIGN.md §3). - '--foreground-alpha-10', - // z-index scale — --z-titlebar (40) and --z-overlay (300) are live; the - // layering only reads as a scale with the rungs between them present. - '--z-base', - '--z-sticky', - '--z-panel', - '--z-dropdown', - '--z-tooltip', - '--z-modal', - // Surface ladder sunken/base/raised/overlay (visual system 2.0 T1). The - // sunken rung's consumer is the sidebar, which recedes to it in T2; the - // ladder only reads as a ladder with its bottom rung present, and defining - // the scale is precisely what T1 is for. - '--surface-sunken', - // Border strength tiers soft/structural/strong (visual system 2.0 T1). The - // soft tier's consumers are the sidebar rail and the card edge, which - // converge on it in T2-T4; T1 defines the scale without touching product CSS. - '--border-soft', - // Elevation tiers raised/overlay/drag (visual system 2.0 T1) -- product - // names for the theme's shadow scale; consumers adopt them in T2-T4. - '--elevation-raised', - '--elevation-overlay', - '--elevation-drag', - // Tinted status surfaces (visual system 2.0 T4). Four statuses x fill/border, - // plus a reserved strong tier. These rungs are unconsumed TODAY only because - // no current banner happens to be that status at that weight -- and a family - // with holes in it is the failure this family exists to end: fourteen call - // sites each hand-rolled an alpha precisely because there was no complete set - // to consume. A half-defined family sends the next author back to writing - // `oklch(from var(--info) l c h / 0.07)`, and it breaks the regeneration - // guarantee, since a status recolour can only flow through members that - // exist. The strong tier is deliberately reserved rather than convenient: - // DESIGN.md restricts it to data-destruction and irreversible warnings. - '--success-wash-border', - '--info-wash', - '--info-wash-border', - '--destructive-wash-border', - '--destructive-wash-strong', - '--destructive-wash-strong-border', - // Control-height scale, 20/24/28/32/36/40 on the 4px ruler. - '--h-control-xl', - '--h-control-2xl', - // Border widths 1/2/3px. - '--border-width-accent', - // Display type scale — display-1 is the hero rung, display-3 the settings - // nav rung; display-2 was the module-page title until the Astryx Layout - // header took over (#2236), and stays as the middle rung of the series. - '--maka-text-display-2', - // Dimmed-opacity tiers 0.5/0.65/0.8 — disabled and muted are live; the - // pending rung lost its last consumer with the old skills card styles. - '--opacity-pending', - // Zero rung of the spacing ruler. - '--space-0', - // Easing vocabulary (see the motion governance comment in maka-tokens.css): - // --ease-out-strong for feedback/state changes, --ease-in-out-strong for - // on-screen movement. The movement curve lost its last consumer with the - // dead shell recipes (#1980), but deleting the named curve is what invites - // the next bare cubic-bezier. - '--ease-in-out-strong', - // Accent lightness ladder — --action (L0.85 chip), --control (L0.65, the - // rung tuned for WCAG 1.4.11 non-text 3:1) and --accent-solid (L0.52, the - // lowest that clears 1.4.3 for text). The middle rung lost its last - // consumer when the sidebar update chip became an Astryx IconButton and - // stopped hand-painting an accent background; the ladder, and the contrast - // derivation recorded against each rung in maka-tokens.css, only reads as a - // series with it present. --control-foreground is its paired foreground, - // meaningless apart from it. - '--control', - '--control-foreground', -]); - -async function readCssFiles(dir) { - const entries = await readdir(dir, { withFileTypes: true }); - const files = await Promise.all( - entries.map(async (entry) => { - const path = join(dir, entry.name); - if (entry.isDirectory()) return readCssFiles(path); - if (!entry.name.endsWith('.css')) return []; - return [path]; - }), - ); - return files.flat(); -} - -async function readSourceFiles(dir) { - const entries = await readdir(dir, { withFileTypes: true }); - const files = await Promise.all( - entries.map(async (entry) => { - const path = join(dir, entry.name); - if (entry.isDirectory()) return readSourceFiles(path); - if (!SOURCE_EXTENSIONS.has(path.slice(path.lastIndexOf('.')))) return []; - return [await readFile(path, 'utf8')]; - }), - ); - return files.flat(); -} - -function stripCssComments(css) { - return css.replace(/\/\*[\s\S]*?\*\//g, ''); -} - -export function collectClassSelectors(css) { - const selectors = new Set(); - for (const match of stripCssComments(css).matchAll(/\.(-?[_a-zA-Z][_a-zA-Z0-9-]*)/g)) { - const cls = match[1]; - if (!cls.startsWith('-')) selectors.add(cls); - } - return selectors; -} - -/** - * Custom properties declared in the product token sheet. A declaration opens a - * block or follows another one, so anchor on `{` / `;` / line start rather than - * indentation — several tokens share a line in the compact blocks. `--x` inside - * `var(--x)` is preceded by `(` and never matches. - */ -export function collectTokenDefinitions(css) { - const tokens = new Set(); - for (const match of stripCssComments(css).matchAll(/(?:^|[{;])\s*(--[a-z0-9-]+)\s*:/gm)) { - tokens.add(match[1]); - } - return tokens; -} - -/** Custom properties read through var(). */ -export function collectTokenReferences(text) { - const refs = new Set(); - for (const match of text.matchAll(/var\(\s*(--[a-z0-9-]+)/g)) { - refs.add(match[1]); - } - return refs; -} - -/** - * Whether `cls` appears in `blob` as a whole class name. `\b` alone treats a - * hyphen as a boundary, so it would let `maka-shell-rail` keep `.maka-shell` - * alive; class names extend across hyphens, so both ends must be checked - * against the class-name alphabet. - */ -export function hasExactConsumer(blob, cls) { - return new RegExp(`(? { - let cursor = 0; - while (cursor < text.length) { - const open = text.indexOf('{', cursor); - if (open === -1) return; - const selector = text.slice(cursor, open).trim(); - let depth = 1; - let close = open + 1; - while (close < text.length && depth > 0) { - if (text[close] === '{') depth += 1; - else if (text[close] === '}') depth -= 1; - close += 1; - } - const body = text.slice(open + 1, close - 1); - if (body.includes('{')) walk(body); - else rules.push({ selector, body }); - cursor = close; - } - }; - walk(stripCssComments(css)); - return rules; -} - -/** - * var() reads inside the token sheet, attributed to the context that can make - * them fire. A read in a class rule's declaration only counts when every - * class in the rule's selector has a consumer — a rule whose classes are dead - * never applies, so it must not keep its tokens alive. A read inside another - * token's value is a derivation edge: it counts only once the deriving token - * is live (resolved by `resolveLiveTokens`). - */ -export function analyzeTokenSheet(css, isClassLive) { - const derivations = new Map(); - const liveReads = new Set(); - for (const rule of parseLeafRules(css)) { - // A comma group applies when ANY branch matches, and a branch matches - // only when ALL of its classes exist somewhere. - const ruleLive = rule.selector - .split(',') - .some((branch) => [...collectClassSelectors(branch)].every(isClassLive)); - if (!ruleLive) continue; - for (const declaration of rule.body.split(';')) { - const definition = declaration.match(/^\s*(--[a-z0-9-]+)\s*:/); - const reads = collectTokenReferences(declaration); - if (definition) { - const existing = derivations.get(definition[1]) ?? new Set(); - for (const read of reads) existing.add(read); - derivations.set(definition[1], existing); - } else { - for (const read of reads) liveReads.add(read); - } - } - } - return { derivations, liveReads }; -} - -/** Close the externally-seeded live set over the sheet's derivation edges. */ -export function resolveLiveTokens(externalReads, { derivations, liveReads }) { - const live = new Set([...externalReads, ...liveReads]); - let changed = true; - while (changed) { - changed = false; - for (const [from, reads] of derivations) { - if (!live.has(from)) continue; - for (const read of reads) { - if (!live.has(read)) { - live.add(read); - changed = true; - } - } - } - } - return live; -} - -async function main() { - const checkMode = process.argv.includes('--check'); - - // Collect all CSS class selectors. The token sheet owns product classes - // too (shell recipes historically hid there — #1980), so it joins the scan. - const cssFiles = await readCssFiles(STYLES_DIR); - for (const file of [...EXTRA_STYLE_FILES, TOKEN_FILE, UI_STYLE_FILE]) { - cssFiles.push(file); - } - const allClasses = new Set(); - for (const file of cssFiles) { - const css = await readFile(file, 'utf8'); - for (const cls of collectClassSelectors(css)) { - allClasses.add(cls); - } - } - - // Collect all source text - const sources = []; - for (const root of SOURCE_ROOTS) { - sources.push(...(await readSourceFiles(root))); - } - const sourceBlob = sources.join('\n'); - - const stories = []; - for (const root of STORY_ROOTS) { - stories.push(...(await readSourceFiles(root))); - } - const storyBlob = stories.join('\n'); - - // Find dead classes (zero consumers, exact class-name match) - const isClassLive = (cls) => DYNAMIC_STYLE_HOOKS.has(cls) || hasExactConsumer(sourceBlob, cls); - const dead = []; - for (const cls of [...allClasses].sort()) { - if (!isClassLive(cls)) { - dead.push(cls); - } - } - - // Find dead tokens (declared in the product sheet, read by nobody). - // A token's consumers are spread across every stylesheet, not just the ones - // that own classes. Reads inside the token sheet itself only count when - // they can fire (live class rule, or derivation from a live token) — a - // token read exclusively by dead rules is dead with them (#1980). - const tokenCss = await readFile(TOKEN_FILE, 'utf8'); - const definedTokens = collectTokenDefinitions(tokenCss); - const referenced = new Set(); - const tokenCssFiles = [...EXTRA_STYLE_FILES, ...EXTRA_TOKEN_CONSUMER_FILES]; - for (const root of TOKEN_CONSUMER_ROOTS) { - tokenCssFiles.push(...(await readCssFiles(root))); - } - for (const file of tokenCssFiles) { - if (file === TOKEN_FILE) continue; - for (const ref of collectTokenReferences(await readFile(file, 'utf8'))) { - referenced.add(ref); - } - } - for (const ref of collectTokenReferences(sourceBlob)) { - referenced.add(ref); - } - for (const ref of collectTokenReferences(storyBlob)) { - referenced.add(ref); - } - const liveTokens = resolveLiveTokens(referenced, analyzeTokenSheet(tokenCss, isClassLive)); - - const deadTokens = []; - for (const token of [...definedTokens].sort()) { - if (RESERVED_SCALE_TOKENS.has(token)) continue; - if (!liveTokens.has(token)) deadTokens.push(token); - } - - if (dead.length === 0 && deadTokens.length === 0) { - console.log('check-dead-css: no dead classes or tokens found ✓'); - process.exit(0); - } - - if (dead.length > 0) { - console.error(`check-dead-css: ${dead.length} potential dead class(es):`); - for (const cls of dead) { - console.error(` .${cls}`); - } - console.error(''); - console.error('NOTE: dynamic class names (template strings) may cause false'); - console.error('positives. Review each before removing. See DYNAMIC_STYLE_HOOKS'); - console.error('in the script for known runtime-generated classes.'); - } - - if (deadTokens.length > 0) { - console.error(`check-dead-css: ${deadTokens.length} dead token(s) in maka-tokens.css:`); - for (const token of deadTokens) { - console.error(` ${token}`); - } - console.error(''); - console.error('NOTE: a rung of an ordered scale can outlive its last consumer —'); - console.error('add it to RESERVED_SCALE_TOKENS with the series it belongs to.'); - console.error('An unused token with no series around it should be deleted.'); - } - - if (checkMode) { - let baseline; - try { - baseline = JSON.parse(await readFile(BASELINE_PATH, 'utf8')); - } catch (err) { - console.error(`check-dead-css: failed to read baseline at ${BASELINE_PATH}: ${err.message}`); - process.exit(1); - } - - const maxDeadClassCount = Number(baseline?.maxDeadClassCount); - if (!Number.isFinite(maxDeadClassCount) || maxDeadClassCount < 0) { - console.error( - `check-dead-css: baseline ${BASELINE_PATH} must define a non-negative numeric maxDeadClassCount.`, - ); - process.exit(1); - } - - const maxDeadTokenCount = Number(baseline?.maxDeadTokenCount ?? 0); - if (!Number.isFinite(maxDeadTokenCount) || maxDeadTokenCount < 0) { - console.error( - `check-dead-css: baseline ${BASELINE_PATH} must define a non-negative numeric maxDeadTokenCount.`, - ); - process.exit(1); - } - - if (dead.length > maxDeadClassCount) { - console.error( - `check-dead-css: dead class count ${dead.length} exceeds baseline ${maxDeadClassCount}.`, - ); - process.exit(1); - } - - if (deadTokens.length > maxDeadTokenCount) { - console.error( - `check-dead-css: dead token count ${deadTokens.length} exceeds baseline ${maxDeadTokenCount}.`, - ); - process.exit(1); - } - - console.log( - `check-dead-css: within baseline (classes ${dead.length}/${maxDeadClassCount}, tokens ${deadTokens.length}/${maxDeadTokenCount}) ✓`, - ); - } -} - -const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); -if (isMain) { - main().catch((err) => { - console.error('check-dead-css: ERROR', err.message); - process.exit(1); - }); -} diff --git a/scripts/check-story-annotations.mjs b/scripts/check-story-annotations.mjs deleted file mode 100644 index f958c57828..0000000000 --- a/scripts/check-story-annotations.mjs +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env node -/** - * check-story-annotations.mjs — the machine-checkable half of the Storybook - * fidelity convention (apps/desktop/stories/FIDELITY.md). - * - * Every `Product/*` story must carry a `// Real path:` comment naming how a - * user reaches the state it renders. Whether that sentence is TRUE is a review - * question and always will be — a schema is satisfied by a plausible lie just - * as easily. Whether it EXISTS is mechanical, and review demonstrably does not - * hold that line: chat-surface.stories.tsx reached thirteen stories with twelve - * annotations before anyone noticed. - * - * `Primitives/*` and `Design System/*` are exempt, per FIDELITY.md: they show a - * component's states, not a product surface. - * - * This is the shape #1724 kept when it deleted the source-scanning contract - * suite — a fast scripts/check-*.mjs guarding a non-cosmetic invariant, not a - * 675-test suite asserting on source text. It runs in CI's typecheck job next - * to check-dead-css. - */ - -import { readdir, readFile } from 'node:fs/promises'; -import { join, relative, resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; - -const REPO_ROOT = resolve(import.meta.dirname, '..'); -const STORYBOOK_CONFIG = join(REPO_ROOT, 'apps/desktop/.storybook/main.ts'); -const STORY_ROOTS = ['apps/desktop/stories', 'packages/ui/stories']; -const EXEMPT_TITLE_PREFIXES = ['Primitives/', 'Design System/']; - -// `export const Name: Story = …` and nothing else, including the form that -// wraps onto the next line. A story written in another shape is not skipped — -// a guard that silently ignores what it cannot parse passes *because* it did -// not understand, which is the failure it exists to prevent. So ANY_EXPORT is -// deliberately wider than STORY_EXPORT and covers `export {}` re-exports and -// `export async function` too: anything it matches and STORY_EXPORT does not -// is reported rather than waved through. Widening STORY_EXPORT is a deliberate -// edit here. -// The leading `^export\s*$` alternative matters: TSX accepts a line break -// straight after `export`, and a line-based scanner sees neither a keyword on -// the `export` line nor an export on the `const` line — so a story in that -// shape used to pass unread while the check reported success. It is reported -// as unclassifiable rather than parsed, which is the fail-closed half of the -// contract above. -const STORY_EXPORT = /^export const ([A-Za-z0-9_]+): Story =(?:\s|$)/; -const ANY_EXPORT = - /^export\s*$|^export (?:default |async )?(?:const|function|let|var|class|\{)\s*([A-Za-z0-9_]+)?/; -// Anchored at `const meta`, not the first `title:` in the file: a fixture -// literal carrying its own `title` would otherwise decide the whole file's -// namespace — including exempting it outright with `Design System/…`. -const TITLE = /const meta[\s\S]*?title:\s*['"]([^'"]+)['"]/; -// `\S` after the colon: an empty `// Real path:` is not an annotation. -const REAL_PATH = /^\s*\/\/\s*Real path:\s*\S/; -// Every glob main.ts loads stories from, so an added root is caught as well as -// a removed one. -const CONFIG_GLOB = /['"](?:.*?)([\w./-]*?stories)\/\*\*\/\*\.stories\.@?\(?[\w|)]+['"]/g; - -async function storyFiles(root) { - const entries = await readdir(join(REPO_ROOT, root), { - recursive: true, - withFileTypes: true, - }); - return entries - .filter((entry) => entry.isFile() && /\.stories\.tsx?$/.test(entry.name)) - .map((entry) => join(entry.parentPath, entry.name)); -} - -/** - * The scan roots are restated here rather than parsed out of main.ts, so this - * asserts they still match what Storybook actually loads — in both directions. - * A root dropped from main.ts leaves this scanning a tree Storybook ignores; a - * root added there would otherwise go unchecked in silence. - */ -export function checkStorybookRoots(config, problems) { - const configFile = relative(REPO_ROOT, STORYBOOK_CONFIG); - for (const root of STORY_ROOTS) { - // Match the full root, not its last segment: both roots end in `stories`, - // so a leaf match stays satisfied by the other one and silently passes. - if (!config.includes(`${root}/**/*.stories.`)) { - problems.push(`${configFile}: no longer loads ${root}; update STORY_ROOTS`); - } - } - for (const [, loaded] of config.matchAll(CONFIG_GLOB)) { - if (!STORY_ROOTS.some((root) => loaded.endsWith(root))) { - problems.push(`${configFile}: loads ${loaded}, which is not in STORY_ROOTS`); - } - } -} - -export function checkFile(rel, source, problems) { - const title = source.match(TITLE)?.[1]; - if (!title) { - problems.push(`${rel}: no meta title found`); - return; - } - if (EXEMPT_TITLE_PREFIXES.some((prefix) => title.startsWith(prefix))) return; - if (!title.startsWith('Product/')) { - problems.push(`${rel}: title "${title}" is neither Product/* nor an exempt namespace`); - return; - } - - const lines = source.split('\n'); - lines.forEach((line, index) => { - const anyExport = line.match(ANY_EXPORT); - if (!anyExport) return; - const storyExport = line.match(STORY_EXPORT); - if (!storyExport) { - const name = anyExport[1] ?? line.trim(); - problems.push(`${rel}:${index + 1}: ${name} is not \`export const : Story = …\``); - return; - } - // Walk back over the contiguous comment block directly above the export. - let cursor = index - 1; - let annotated = false; - while (cursor >= 0 && lines[cursor].trim().startsWith('//')) { - if (REAL_PATH.test(lines[cursor])) annotated = true; - cursor -= 1; - } - if (!annotated) { - problems.push(`${rel}:${index + 1}: ${storyExport[1]} has no \`// Real path:\` comment`); - } - }); -} - -async function findProblems() { - const problems = []; - checkStorybookRoots(await readFile(STORYBOOK_CONFIG, 'utf8'), problems); - for (const root of STORY_ROOTS) { - for (const path of await storyFiles(root)) { - checkFile(relative(REPO_ROOT, path), await readFile(path, 'utf8'), problems); - } - } - return problems.sort(); -} - -if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { - const problems = await findProblems(); - if (problems.length > 0) { - console.error(`check-story-annotations: ${problems.length} problem(s):`); - for (const problem of problems) console.error(` ${problem}`); - console.error('\nSee apps/desktop/stories/FIDELITY.md.'); - process.exit(1); - } - console.log('check-story-annotations: every Product/* story names its real path ✓'); -}