From ca1fc2847fc90b85861e63fd09a3a46cf1d4b4be Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Sat, 5 Sep 2026 09:33:34 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7CSS=E6=89=80=E6=9C=89?= =?UTF-8?q?=E6=9D=83=E4=B8=8E=E5=8E=86=E5=8F=B2=E5=85=BC=E5=AE=B9=E9=97=A8?= =?UTF-8?q?=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/css-quality.yml | 17 +- .stylelintrc.json | 25 +- docs/css-modularization-result-20260905.md | 18 +- docs/system-functional-test-cases.md | 6 + docs/testing-progress.md | 10 + package-lock.json | 2 + package.json | 2 + tools/quality/css-ownership.json | 244 +++++++++++++++++++ tools/quality/css-policy.mjs | 213 ++++++++++++++++ tools/quality/verify-css-governance.mjs | 43 +--- tools/quality/verify-css-governance.test.mjs | 131 ++++++++++ 11 files changed, 670 insertions(+), 41 deletions(-) create mode 100644 tools/quality/css-ownership.json create mode 100644 tools/quality/css-policy.mjs diff --git a/.github/workflows/css-quality.yml b/.github/workflows/css-quality.yml index be080ed..6b61f05 100644 --- a/.github/workflows/css-quality.yml +++ b/.github/workflows/css-quality.yml @@ -17,7 +17,20 @@ jobs: node-version: 24 cache: npm - run: npm ci - - name: Verify changed formatting and CSS ownership + - name: Resolve the complete change range env: - QUALITY_BASE_REF: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || format('{0}^', github.sha) }} + EVENT_NAME: ${{ github.event_name }} + PR_BASE: ${{ github.event.pull_request.base.sha }} + PUSH_BEFORE: ${{ github.event.before }} + run: | + if [ "$EVENT_NAME" = pull_request ]; then + base=$(git merge-base "$PR_BASE" HEAD) + elif [ "$PUSH_BEFORE" = 0000000000000000000000000000000000000000 ]; then + base=$(git hash-object -t tree /dev/null) + else + git cat-file -e "$PUSH_BEFORE^{commit}" + base=$PUSH_BEFORE + fi + echo "QUALITY_BASE_REF=$base" >> "$GITHUB_ENV" + - name: Verify changed formatting and CSS ownership run: npm run format:check && npm run style:check && npm run css:verify diff --git a/.stylelintrc.json b/.stylelintrc.json index 2d930dc..187fac3 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -12,15 +12,34 @@ "overrides": [ { "files": [ - "src/styles/*.css", - "src/styles/domains/*.css", "src/apps/admin/channels/AdminChannelsPage.css", "src/apps/admin/enterprise-applications/AdminEnterpriseApplicationsPage.css", "src/apps/admin/security-detection/AdminSecurityDetectionPage.css", "src/apps/admin/sms-records/AdminSmsRecordsPage.css", "src/apps/admin/sms-task-progress/AdminSmsTaskProgressPage.css", "src/apps/admin/system-monitoring/AdminSystemMonitoringPage.css", - "src/apps/client/ClientUsersPage.css" + "src/apps/client/ClientUsersPage.css", + "src/styles/admin.css", + "src/styles/client.css", + "src/styles/components.css", + "src/styles/domains/01-operations-dashboard.css", + "src/styles/domains/02-client-sending.css", + "src/styles/domains/03-client-records.css", + "src/styles/domains/04-signatures.css", + "src/styles/domains/05-templates.css", + "src/styles/domains/06-auth-enterprise.css", + "src/styles/domains/07-admin-operations.css", + "src/styles/domains/08-reporting.css", + "src/styles/domains/09-channels.css", + "src/styles/domains/10-signature-quality.css", + "src/styles/domains/11-deliveries-reporting.css", + "src/styles/domains/12-admin-configuration.css", + "src/styles/domains/13-client-signatures.css", + "src/styles/domains/14-responsive-requeue.css", + "src/styles/domains/index.css", + "src/styles/reset.css", + "src/styles/shell.css", + "src/styles/tokens.css" ], "rules": { "at-rule-empty-line-before": null, diff --git a/docs/css-modularization-result-20260905.md b/docs/css-modularization-result-20260905.md index 2b7709a..9589e09 100644 --- a/docs/css-modularization-result-20260905.md +++ b/docs/css-modularization-result-20260905.md @@ -37,7 +37,7 @@ - `npm run format:check`:增量Prettier现同时覆盖CSS。 - `npm run style:check`:对全部CSS执行Stylelint;迁移前已存在的文件使用显式兼容范围,新CSS默认严格执行标准规则。 - `npm run css:verify`:验证`global.css`不得重建、固定导入顺序、AST基线、选择器顺序、`!important`增长、宽泛业务标签规则和import所有权,并执行允许/拒绝样例测试。 -- `.github/workflows/css-quality.yml`:PR和main推送均以完整提交范围运行格式、Stylelint和CSS治理门禁。 +- `.github/workflows/css-quality.yml`:PR使用目标提交与HEAD的merge-base,main推送使用事件before提交;首次推送使用空树,覆盖一次推送中的全部提交。 - Git归档或部署候选目录没有`.git`时,门禁仍执行当前树的所有权、AST、选择器顺序和例外检查,仅跳过无法取得基线的增量文件比较。 ## 维护边界 @@ -46,3 +46,19 @@ - 14个迁移模块只用于承接已登记的历史规则;新增规则应先判断页面、业务域或公共组件所有权,不得因类名前缀相似直接追加。 - 不得重新创建 `global.css`、扩大Stylelint兼容文件范围或增加无登记例外。 - 迁移模块后续按真实页面证据继续细化时,必须同步更新机器基线,并保持主CSS产物或关键计算样式等价。 + +## 2026-09-05 门禁复核与修复 + +首次迁移完成的是原序分文件,14个模块仍由全局入口加载,响应式尾段仍跨业务域。页面根节点隔离和按所有者进一步收拢是后续工作,不能把文件删除或既有门禁通过等同于完整架构治理完成。本次只修复门禁,不移动、删除或改写应用CSS,不改变业务API、数据库和短信链路。 + +复核发现原Stylelint配置使用目录通配符,原所有权检查只搜索文件名,AST计数未覆盖声明值和媒体条件,main推送只比较最后一个提交。现改为: + +- `tools/quality/css-ownership.json`逐文件登记真实import所有者,Stylelint兼容范围必须与清单中的精确路径一致。新文件不会因进入`styles/`或`domains/`自动继承兼容配置。 +- TypeScript AST解析静态import/export和字面量动态import,PostCSS解析CSS import;解析实际路径并从`src/main.tsx`检查可达性。注释、文件名字符串、同名异目录文件、孤立循环或未被入口引用的所有者不能代替真实引用。当前仅支持仓库使用的相对路径和`@/`别名;新加载机制须同步扩展解析器和拒绝样例。 +- 新CSS必须登记`roots`,根类必须出现在直接TSX所有者的`className`中;每个选择器首个复合选择器须含正向根类,禁止用`:not`、`:has`或根节点同级选择器冒充作用域。根类后的后代和直接子节点可用;仅通过`:is/:where`提供根类的写法暂不接受。该检查证明静态约束,无法代替实际DOM层级、Portal、动态类名和三尺寸浏览器验收。 +- 历史文件登记包含声明值、声明顺序、`!important`和媒体/其他at-rule条件的AST摘要,并明确兼容原因、清理条件。注释和节点外格式不影响摘要,CRLF/LF统一;值内部换行等变化可能要求人工确认。新文件不允许通过随手补历史摘要绕过作用域;历史摘要变更必须附设计或等价证据,由代码审查确认。 +- 已有、具备页面根类的`ReportMaterialImportModal.css`直接执行新规则;其他已登记历史文件保持现状,不在本次门禁修复中扩张视觉改造范围。 +- 无`.git`的部署归档也执行完整清单、真实引用、根类、声明摘要、固定入口顺序及例外范围检查;仅跳过Git增量比较。 +- 拒绝样例覆盖新目录文件的实际Stylelint结果、假import、不可达与缺失路径、错误所有者、未落入className的根类、无根标签/状态/同级逃逸、声明和断点篡改、未登记文件、归档模式以及CRLF/LF兼容。 + +后续修改已有CSS时先确认所有者:页面私有规则迁至页面目录,公共规则按消费者确定组件归属;保持原效果的迁移提供产物或计算样式等价证据,有意视觉调整提供设计依据及真实页面验收后再更新摘要。不得为了门禁变绿批量重算全部摘要。330次重复选择器出现包含有效响应式覆盖,不按重复次数直接删除。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 4d7eabb..3c4a929 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5063,6 +5063,12 @@ npm run verify:phase8 | TC-CSS-MOD-004 | 修改TS/TSX或新增CSS后运行增量格式检查 | 实际变更的CSS进入Prettier;Stylelint对未登记新文件执行严格规则,历史兼容范围不会自动扩大 | | TC-CSS-MOD-005 | 分别首次进入、刷新及跨路由打开核心运营端和客户端页面 | CSS资源无404,无框架错误层和新增控制台错误;布局、弹窗、下拉框、Sticky表头、状态记录及字段卡片与迁移前一致 | | TC-CSS-MOD-006 | 在1600×1000、1366×768和390×844检查核心页面 | 页面无新增横向溢出、遮挡、错位或层级问题,窄屏交互仍可访问 | +| TC-CSS-MOD-007 | 在styles和domains目录新增未登记CSS,或给Stylelint兼容清单添加通配符 | 新文件保持严格Stylelint规则;所有权门禁拒绝未登记文件及兼容范围扩张 | +| TC-CSS-MOD-008 | 仅在注释/字符串中写CSS文件名,引用同名错误路径,或从不可达TSX导入CSS | 均被拒绝;正确相对路径、@/别名、静态/字面量动态import和入口可达关系通过 | +| TC-CSS-MOD-009 | 新CSS登记根类后使用无关类、body button、* button、h1、独立selected、:has/:not或根类同级选择器 | 门禁拒绝;直接TSX所有者包含根className且选择器约束在根本身、后代或直接子节点时通过 | +| TC-CSS-MOD-010 | 不改变规则和声明计数,仅修改历史CSS颜色、断点、声明顺序或important | AST内容摘要检查失败;单纯CRLF/LF转换通过,避免Windows和Linux归档差异 | +| TC-CSS-MOD-011 | 在无.git归档执行门禁,并构造错误import、声明变化、新important和未登记CSS | 完整静态检查仍拒绝违规,不能因为跳过Git增量比较而放行 | +| TC-CSS-MOD-012 | 一次推送包含多个提交,违规或格式问题位于非最后提交 | CI使用push事件before至HEAD完整范围;PR使用merge-base;首次推送使用空树 | ## TC-ADMIN-ENHANCEMENT-20260904 运营看板与配置交互增强 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 7f5c3c6..f6de358 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4530,3 +4530,13 @@ git diff --check - 三条短信相关Redis Stream发布前后逐项一致:`gateway.submit.commands`为`last-delivered-id=1787806802603-0 / entries-read=130918`,`gateway.submit.results`为`1787806917609-0 / 191696`,`gateway.protocol.logs`为`1787806802967-0 / 40472`;全部保持`pending=0 / lag=0`。本轮没有发送、补发、重投或重新入队短信,也没有修改余额、通道、客户或签名业务资料。 - 测试环境真实Chrome回归使用真实API和PostgreSQL数据:运营端13个核心路由、客户端10个核心路由分别在1600×1000、1366×768和390×844检查,共69个页面状态均已登录、非空、无框架错误层及页面级横向溢出;控制台warning/error为0,失败API或静态资源响应为0。外部入口API健康HTTP 200,实际下载CSS`index-CkSy6WDi.css`和JS`index-BLtRK0rD.js`的SHA-256与服务器产物一致;CSS摘要仍为迁移前后的`d7043ee2229a402c6b9284c9153536dd6393446a3b0fe7c47ee7dd05290539c3`。 - 两个临时验收账号已精确删除,数据库剩余数为0,并删除其5个Redis会话;本地认证状态文件和测试机临时发布包也已清理。浏览器自动化最初复用仅含运营端会话的状态文件时,客户端正确返回401并跳转登录;修正验收脚本为客户端真实登录后重新执行,最终30个客户端页面及全部网络检查通过,该测试夹具问题不属于平台回归缺陷。预生产环境未访问或修改。 + +## 2026-09-05 CSS门禁缺口修复(本地验证与测试发布准备) + +- 用户授权修复、代码提交和测试环境发布,未授权推送或预生产访问。开始核验main/HEAD及origin/main为`64bfb9a`;工作区已有7份规范修改、未跟踪设计开发规范及报备补救方案,均保护不动,测试进度仅提交本轮精确新增段落。 +- 根因:Stylelint历史兼容使用目录通配符,所有权仅搜索文件名,宽泛选择器识别不完整,AST计数不检查声明值/媒体条件,CI的main推送仅比较最后一个提交。详见`css-modularization-result-20260905.md`新增复核章节,用例`TC-CSS-MOD-007`至`012`。 +- 新增逐文件所有权和历史AST内容摘要、真实TS/CSS import解析与入口可达性、新文件根className/选择器约束;Stylelint改为精确路径,CI覆盖整次推送及PR merge-base,归档执行完整静态检查。直接声明当前已锁定的PostCSS及selector-parser开发依赖,未升级版本;应用TSX、全部CSS、入口顺序、API、数据库和短信链路均未修改。 +- 本地验证:门禁15项、前端13套65项、API55套619项通过;前端TypeScript/Vite和API正式构建通过,format/style/css、增量ESLint、结构、安全、部署契约和包体积门禁通过。主CSS仍为`index-CkSy6WDi.css`,SHA-256为`d7043ee2229a402c6b9284c9153536dd6393446a3b0fe7c47ee7dd05290539c3`,入口gzip 107.85KiB。构建保留既有Chart超过500kB及耗时提示;API测试中的异常日志来自测试替身的失败场景,不是线上短信操作。 +- 2026-09-05测试机SSH实时标记为`64bfb9ad3d6a4e6f57770839172ea50eee984a18`,API/Gateway正常,三条短信Stream均pending=0/lag=0。测试机实际使用系统盘,不套用预生产UUID/迁盘规则;发布恢复点`/opt/cmpp-platform-backups/css-gates-20260905T013053Z-before-64bfb9a`约451MiB,包含PostgreSQL custom dump、完整运行目录、配置、服务/Stream/挂载与资源摘要基线;pg_restore目录、tar可读性及三项SHA均通过。 +- Browser技能不在当前会话,按前端测试技能使用现有Playwright/Edge。测试环境真实运营端和客户端登录页在1600×1000、1366×768、390×844完成首次进入、刷新、跨入口导航,共6个状态,页面非空、无横向溢出,控制台/页面异常及失败HTTP响应均为0。证据位于本机临时目录`cmpp-css-gates-20260905/browser-smoke.json`及截图。未提交登录、未创建账号,登录后业务交互未重跑,不用登录页或单元测试冒充真实业务验收。 +- 14个模块仍是历史全局兼容分区,进一步按业务收拢响应式和页面隔离未在本轮实施;静态根类检查不能证明实际DOM/Portal层级。发布采用明确提交归档,候选门禁及资源一致性检查通过后仅更新治理文件和文档,不运行全量初始化/迁移/重启脚本。测试发布结果另行追加。 diff --git a/package-lock.json b/package-lock.json index f0392e5..5012297 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,8 @@ "globals": "^17.4.0", "jsdom": "^29.1.1", "msw": "^2.15.0", + "postcss": "8.5.23", + "postcss-selector-parser": "7.1.6", "prettier": "^3.9.6", "stylelint": "^16.23.1", "stylelint-config-standard": "^38.0.0", diff --git a/package.json b/package.json index 11a7932..a168bd1 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,8 @@ "globals": "^17.4.0", "jsdom": "^29.1.1", "msw": "^2.15.0", + "postcss": "8.5.23", + "postcss-selector-parser": "7.1.6", "prettier": "^3.9.6", "stylelint": "^16.23.1", "stylelint-config-standard": "^38.0.0", diff --git a/tools/quality/css-ownership.json b/tools/quality/css-ownership.json new file mode 100644 index 0000000..76fe848 --- /dev/null +++ b/tools/quality/css-ownership.json @@ -0,0 +1,244 @@ +{ + "schemaVersion": 1, + "mainImportOrder": [ + "src/styles/tokens.css", + "src/styles/reset.css", + "src/styles/shell.css", + "src/styles/domains/index.css", + "src/styles/admin.css", + "src/styles/client.css", + "src/styles/components.css" + ], + "files": [ + { + "file": "src/apps/admin/ReportMaterialImportModal.css", + "owners": ["src/apps/admin/ReportMaterialImportModal.tsx"], + "stylelintLegacy": false, + "roots": ["report-material-import-modal"] + }, + { + "file": "src/apps/admin/channels/AdminChannelsPage.css", + "owners": ["src/apps/admin/AdminChannelsPage.tsx"], + "legacyFingerprint": "fc2d168035c9adac5b330a7526424831ac18725da92a8c2392600e08a0891c69", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/apps/admin/enterprise-applications/AdminEnterpriseApplicationsPage.css", + "owners": ["src/apps/admin/AdminEnterpriseApplicationsPage.tsx"], + "legacyFingerprint": "ac5abed49be7edb910ef8746c36f72e953f199b6073d13617594f1158fe4c69b", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/apps/admin/security-detection/AdminSecurityDetectionPage.css", + "owners": ["src/apps/admin/security-detection/AdminSecurityDetectionPage.tsx"], + "legacyFingerprint": "ba5bc7ea3d09615685fee210c625d85ddf1c368d587922fb4953b24591675e06", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/apps/admin/sms-records/AdminSmsRecordsPage.css", + "owners": ["src/apps/admin/AdminSmsRecordsPage.tsx"], + "legacyFingerprint": "a7cdbded6c765662dee0769c9c02159bb01045af08e2fa1842a11f79699fad02", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/apps/admin/sms-task-progress/AdminSmsTaskProgressPage.css", + "owners": ["src/apps/admin/AdminSmsTaskProgressPage.tsx"], + "legacyFingerprint": "a222961a94f15a388892a272e42bf64e122ea55f067bf618126e67ed107014b2", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/apps/admin/system-monitoring/AdminSystemMonitoringPage.css", + "owners": ["src/apps/admin/system-monitoring/AdminSystemMonitoringPage.tsx"], + "legacyFingerprint": "59d09cdce53ef804773d46b1e5807e0e273f2c3ba7e2aa21b864d0d718f75db2", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/apps/client/ClientUsersPage.css", + "owners": ["src/apps/client/ClientUsersPage.tsx"], + "legacyFingerprint": "af9cdb6f0229056437dab22fc0533fa9b0df2e3b013b69088a9815c4468518db", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/admin.css", + "owners": ["src/main.tsx"], + "legacyFingerprint": "b31d1360ca34c0bd5bc81d3687f5db649d5eb775db41faaec58c1ffd358891a1", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/client.css", + "owners": ["src/main.tsx"], + "legacyFingerprint": "b496831577a2296cca8bd1e3ba5a5c9574068f73f8f4c8176fe023957f803219", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/components.css", + "owners": ["src/main.tsx"], + "legacyFingerprint": "8adee9fa7adcc5df32137c6428b944d9a79fd7aab0ceb8ae699aca6686294702", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/domains/01-operations-dashboard.css", + "owners": ["src/styles/domains/index.css"], + "legacyFingerprint": "a9b28b65f14fb518f16620c4ae0fe337fe7807202818154c134b612384f9167d", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/domains/02-client-sending.css", + "owners": ["src/styles/domains/index.css"], + "legacyFingerprint": "b84920adb1891089df78af28066fe0bc1c0d2452057a294a798554312be57b41", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/domains/03-client-records.css", + "owners": ["src/styles/domains/index.css"], + "legacyFingerprint": "1e3eb6377c5436bc008f77cf1721575f5ace209cf0296d635002249272dcf794", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/domains/04-signatures.css", + "owners": ["src/styles/domains/index.css"], + "legacyFingerprint": "0ff9be0753940b7d3312042a93b6a97d1cca92d3e045bfa3f13d064467377874", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/domains/05-templates.css", + "owners": ["src/styles/domains/index.css"], + "legacyFingerprint": "79d3e6a2c2e1cef9dcecaac985344cdc36ce30a31ccfae6d5c29f5801152bd76", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/domains/06-auth-enterprise.css", + "owners": ["src/styles/domains/index.css"], + "legacyFingerprint": "f391234e8afe2085bc3d64c4e86d2f89e9f4aa2554d84abf9ea9f6228a35fbc7", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/domains/07-admin-operations.css", + "owners": ["src/styles/domains/index.css"], + "legacyFingerprint": "e8414200ee4b8354130880f1e3c7b71ea3972d278b1b72414897fb14d554958b", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/domains/08-reporting.css", + "owners": ["src/styles/domains/index.css"], + "legacyFingerprint": "db762c6fef81aac2b4a76746b5a78a607e9711aaeb4e509e2800b86b17eee6a5", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/domains/09-channels.css", + "owners": ["src/styles/domains/index.css"], + "legacyFingerprint": "a32eab767f85b49324e17721b70b8dd0c8f0e5a14d6ef2d3502a56c69f9c0e7a", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/domains/10-signature-quality.css", + "owners": ["src/styles/domains/index.css"], + "legacyFingerprint": "9d79afed6c1550e747dcf6c5ab554a4977b62a93d66421a0a01add9f875e2c30", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/domains/11-deliveries-reporting.css", + "owners": ["src/styles/domains/index.css"], + "legacyFingerprint": "9792e6aedb4c74dcd6aeb26925391315e7df662c038a32be5ffcf3eee282374d", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/domains/12-admin-configuration.css", + "owners": ["src/styles/domains/index.css"], + "legacyFingerprint": "60ac29279c18da425f52efe88b2de9a686a20c4e54ec54d6a424d027490c8e81", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/domains/13-client-signatures.css", + "owners": ["src/styles/domains/index.css"], + "legacyFingerprint": "c90bb9ea3bf4ab278379a806b94187a559a46557de344c934be55968fef5287b", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/domains/14-responsive-requeue.css", + "owners": ["src/styles/domains/index.css"], + "legacyFingerprint": "3d3daaca99d4efbdaaea4dd1ec6a0239274ed52f6de658ed9f889a6d565ec567", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/domains/index.css", + "owners": ["src/main.tsx"], + "legacyFingerprint": "3f287af46d6c7e7f921c43913b52a9a727a8e343a80ad46c45d25baaed073177", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/reset.css", + "owners": ["src/main.tsx"], + "legacyFingerprint": "4be47c806f9a6981a8b73f3844d707fd5cdaaf0d343323de07c6e39089224df1", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/shell.css", + "owners": ["src/main.tsx"], + "legacyFingerprint": "93e26e9dcaa88447e054aeff8e20d4902cdaea54f0ec5a5b5659c84d9c4b9bd8", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/styles/tokens.css", + "owners": ["src/main.tsx"], + "legacyFingerprint": "754135f86b0828fa004270a4be6e7a087cb794eefa3860e8d45f89a6eb1f7223", + "stylelintLegacy": true, + "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", + "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + } + ] +} diff --git a/tools/quality/css-policy.mjs b/tools/quality/css-policy.mjs new file mode 100644 index 0000000..1d61343 --- /dev/null +++ b/tools/quality/css-policy.mjs @@ -0,0 +1,213 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import postcss from 'postcss'; +import selectorParser from 'postcss-selector-parser'; +import ts from 'typescript'; + +// Retain declaration values, order and at-rule conditions; ignore comments and raw formatting only. +export function cssFingerprint(css) { + function nodeValue(node) { + if (node.type === 'comment') return null; + const value = { type: node.type }; + for (const key of ['selector', 'name', 'params', 'prop', 'value', 'important']) { + if (node[key] !== undefined) value[key] = node[key]; + } + if (node.nodes) value.nodes = node.nodes.map(nodeValue).filter(Boolean); + return value; + } + return crypto + .createHash('sha256') + .update(JSON.stringify(nodeValue(postcss.parse(css.replaceAll('\r\n', '\n'))))) + .digest('hex'); +} + +function isKeyframe(rule) { + for (let parent = rule.parent; parent; parent = parent.parent) { + if (parent.type === 'atrule' && /(?:^|-)keyframes$/i.test(parent.name)) return true; + } + return false; +} + +function anchored(selector, roots) { + // A positive class in the first compound must constrain the target itself or an ancestor. + // :not/:has and sibling combinators cannot establish ownership. + const nodes = selector.nodes.filter((node) => node.type !== 'comment'); + const boundary = nodes.findIndex((node) => node.type === 'combinator'); + const compound = boundary === -1 ? nodes : nodes.slice(0, boundary); + return ( + compound.some((node) => node.type === 'class' && roots.includes(node.value)) && + (boundary === -1 || [' ', '>'].includes(nodes[boundary].value)) + ); +} + +export function unscopedSelectors(css, roots) { + const findings = []; + postcss.parse(css).walkRules((rule) => { + if (isKeyframe(rule)) return; + selectorParser((selectors) => { + selectors.each((selector) => { + if (!anchored(selector, roots)) findings.push(selector.toString()); + }); + }).processSync(rule.selector); + }); + return findings; +} + +export function broadBusinessSelectors(css) { + const findings = []; + postcss.parse(css).walkRules((rule) => { + if (isKeyframe(rule)) return; + selectorParser((selectors) => { + selectors.each((selector) => { + const roots = selector.nodes + .filter( + (node) => + node.type === 'class' && !/^(?:selected|active|disabled|loading|item|title|card|is-.+)$/.test(node.value), + ) + .map((node) => node.value); + if (!anchored(selector, roots)) findings.push(selector.toString()); + }); + }).processSync(rule.selector); + }); + return findings; +} + +export function localImports(file, code) { + if (file.endsWith('.css')) { + const imports = []; + postcss.parse(code).walkAtRules('import', (rule) => { + const match = rule.params.match(/^(?:url\(\s*)?['"]([^'"]+)['"]/); + if (!match) throw new Error(`${file}: CSS @import 必须使用明确的引号路径`); + imports.push(match[1]); + }); + return imports; + } + const imports = []; + const source = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true); + function visit(node) { + if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier) { + if (ts.isStringLiteral(node.moduleSpecifier) && !node.importClause?.isTypeOnly && !node.isTypeOnly) { + imports.push(node.moduleSpecifier.text); + } + } + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + if (node.arguments.length === 1 && ts.isStringLiteral(node.arguments[0])) imports.push(node.arguments[0].text); + } + ts.forEachChild(node, visit); + } + visit(source); + return imports; +} + +function sourceFiles(root) { + return fs + .readdirSync(path.join(root, 'src'), { recursive: true }) + .map((file) => `src/${String(file).replaceAll('\\', '/')}`) + .filter((file) => /\.(?:tsx?|css)$/.test(file)); +} + +function declaredRootClasses(file, code) { + const classes = new Set(); + const source = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true); + function visit(node) { + if (ts.isJsxAttribute(node) && node.name.getText(source) === 'className' && node.initializer) { + function collect(value) { + if (ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value)) { + for (const token of value.text.split(/\s+/)) classes.add(token); + } + ts.forEachChild(value, collect); + } + collect(node.initializer); + } + ts.forEachChild(node, visit); + } + visit(source); + return classes; +} + +export function importGraph(root) { + const files = sourceFiles(root); + const available = new Set(files); + const graph = new Map(); + for (const file of files) { + const edges = []; + for (const specifier of localImports(file, fs.readFileSync(path.join(root, file), 'utf8'))) { + if (!specifier.startsWith('.') && !specifier.startsWith('@/')) continue; + const target = specifier.startsWith('@/') + ? `src/${specifier.slice(2)}` + : path.posix.normalize(path.posix.join(path.posix.dirname(file), specifier)); + const resolved = [target, `${target}.ts`, `${target}.tsx`, `${target}/index.ts`, `${target}/index.tsx`].find( + (candidate) => available.has(candidate), + ); + if (resolved) edges.push(resolved); + else if (/\.css$/.test(target)) throw new Error(`${file}: CSS import 不存在 ${specifier}`); + } + graph.set(file, edges); + } + return graph; +} + +export function verifyOwnership(root, policy) { + const graph = importGraph(root); + const reachable = new Set(); + function visit(file) { + if (reachable.has(file)) return; + reachable.add(file); + for (const dependency of graph.get(file) ?? []) visit(dependency); + } + visit('src/main.tsx'); + const cssFiles = [...graph.keys()].filter((file) => file.endsWith('.css')); + const records = new Map(policy.files.map((record) => [record.file, record])); + if (records.size !== policy.files.length) throw new Error('CSS 所有权清单包含重复文件'); + for (const file of records.keys()) { + if (!cssFiles.includes(file)) throw new Error(`CSS 所有权登记文件不存在:${file}`); + } + for (const file of cssFiles) { + const record = records.get(file); + if (!record) throw new Error(`${file}: 必须登记 CSS 所有者和根类名`); + if (!reachable.has(file)) throw new Error(`${file}: 从 src/main.tsx 不可达`); + const owners = [...graph] + .filter(([, edges]) => edges.includes(file)) + .map(([owner]) => owner) + .sort(); + if (!owners.length || JSON.stringify(owners) !== JSON.stringify([...record.owners].sort())) { + throw new Error(`${file}: 实际 import 所有者与登记不一致:${owners.join(', ')}`); + } + const css = fs.readFileSync(path.join(root, file), 'utf8'); + if (record.legacyFingerprint) { + if (!record.reason || !record.removalCondition) throw new Error(`${file}: 历史兼容必须记录原因及清理条件`); + if (cssFingerprint(css) !== record.legacyFingerprint) throw new Error(`${file}: 历史 CSS 内容与已评审基线不一致`); + } else { + if (!record.roots?.length) throw new Error(`${file}: 新 CSS 必须登记非空根类名`); + const declared = new Set( + owners + .filter((owner) => owner.endsWith('.tsx')) + .flatMap((owner) => [...declaredRootClasses(owner, fs.readFileSync(path.join(root, owner), 'utf8'))]), + ); + if (record.roots.some((rootClass) => !declared.has(rootClass))) { + throw new Error(`${file}: 根类名必须出现在直接 TSX 所有者的 className 中`); + } + const findings = unscopedSelectors(css, record.roots); + if (findings.length) throw new Error(`${file}: 选择器未限定在所有者根节点:${findings.join(', ')}`); + if (file.startsWith('src/apps/') && owners.some((owner) => !owner.startsWith(`${path.posix.dirname(file)}/`))) { + throw new Error(`${file}: 新页面 CSS 必须由同目录所有者 import`); + } + postcss.parse(css).walkDecls((declaration) => { + if (declaration.important) throw new Error(`${file}: 新 CSS 禁止未登记的 !important`); + }); + } + } + const config = JSON.parse(fs.readFileSync(path.join(root, '.stylelintrc.json'), 'utf8')); + const compatible = policy.files + .filter((record) => record.stylelintLegacy) + .map((record) => record.file) + .sort(); + if ( + JSON.stringify(config.overrides?.[0]?.files?.slice().sort()) !== JSON.stringify(compatible) || + config.overrides.length !== 1 + ) { + throw new Error('Stylelint 兼容范围必须与精确历史文件清单一致,禁止目录通配符和额外覆盖'); + } + return { graph, cssFiles }; +} diff --git a/tools/quality/verify-css-governance.mjs b/tools/quality/verify-css-governance.mjs index 6b8d2e5..fbbe168 100644 --- a/tools/quality/verify-css-governance.mjs +++ b/tools/quality/verify-css-governance.mjs @@ -5,6 +5,8 @@ import process from 'node:process'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import postcss from 'postcss'; +import { broadBusinessSelectors, verifyOwnership } from './css-policy.mjs'; +export { broadBusinessSelectors } from './css-policy.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -24,18 +26,6 @@ export function analyzeCss(css) { return result; } -export function broadBusinessSelectors(css) { - const findings = []; - postcss.parse(css).walkRules((rule) => { - for (const selector of rule.selectors ?? [rule.selector]) { - if (/^(?:div|section|article|button|table|input|select|textarea)(?:\b|[ >+~:[.#])/.test(selector.trim())) { - findings.push(selector); - } - } - }); - return findings; -} - export function verifyGlobalCssAbsent(repoRoot) { const legacy = path.join(repoRoot, 'src/styles/global.css'); if (fs.existsSync(legacy)) throw new Error('src/styles/global.css 已完成迁移,禁止重新创建'); @@ -45,13 +35,6 @@ function read(relativePath) { return fs.readFileSync(path.join(root, relativePath), 'utf8'); } -function currentCssFiles(directory) { - return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const target = path.join(directory, entry.name); - return entry.isDirectory() ? currentCssFiles(target) : entry.name.endsWith('.css') ? [target] : []; - }); -} - function changedCssFiles() { if (!fs.existsSync(path.join(root, '.git'))) return { base: 'archive', files: [] }; const configuredBase = process.env.QUALITY_BASE_REF; @@ -89,6 +72,12 @@ function baseCss(base, file) { function verify() { verifyGlobalCssAbsent(root); + const policy = JSON.parse(read('tools/quality/css-ownership.json')); + const { graph } = verifyOwnership(root, policy); + const mainStyles = graph.get('src/main.tsx').filter((file) => file.endsWith('.css')); + if (JSON.stringify(mainStyles) !== JSON.stringify(policy.mainImportOrder)) { + throw new Error('应用入口 CSS 层级与已评审基线不一致'); + } const baseline = JSON.parse(read('tools/quality/css-governance-baseline.json')); const entry = read(baseline.entry); const imports = [...entry.matchAll(/@import\s+['"](.+?)['"]/g)].map((match) => @@ -132,22 +121,6 @@ function verify() { } } - const sourceText = currentCssFiles(path.join(root, 'src')) - .map((file) => fs.readFileSync(file, 'utf8')) - .join('\n'); - const codeText = fs - .readdirSync(path.join(root, 'src'), { recursive: true }) - .filter((name) => /\.(?:ts|tsx)$/.test(String(name))) - .map((name) => fs.readFileSync(path.join(root, 'src', String(name)), 'utf8')) - .join('\n'); - for (const file of currentCssFiles(path.join(root, 'src'))) { - const relative = path.relative(path.join(root, 'src'), file).replace(/\\/g, '/'); - if (relative === 'styles/tokens.css' || relative === 'styles/reset.css') continue; - if (!sourceText.includes(path.basename(file)) && !codeText.includes(path.basename(file))) { - throw new Error(`${relative} 没有明确 import 所有者`); - } - } - const duplicateCount = selectorList.length - new Set(selectorList).size; const digest = crypto.createHash('sha256').update(selectorList.join('\n')).digest('hex').slice(0, 12); console.log( diff --git a/tools/quality/verify-css-governance.test.mjs b/tools/quality/verify-css-governance.test.mjs index b531857..3b1632a 100644 --- a/tools/quality/verify-css-governance.test.mjs +++ b/tools/quality/verify-css-governance.test.mjs @@ -4,6 +4,8 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { analyzeCss, broadBusinessSelectors, verifyGlobalCssAbsent } from './verify-css-governance.mjs'; +import { cssFingerprint, localImports, unscopedSelectors, verifyOwnership } from './css-policy.mjs'; +import stylelint from 'stylelint'; test('AST metrics ignore formatting and count selectors and important declarations', () => { assert.deepEqual(analyzeCss('.page { color: red !important; }\n.page a, .page button { display: block; }'), { @@ -31,3 +33,132 @@ test('global.css cannot be recreated after migration', () => { assert.throws(() => verifyGlobalCssAbsent(directory), /禁止重新创建/); fs.rmSync(directory, { recursive: true, force: true }); }); + +test('unrooted tags, universal selectors, standalone states and sibling escapes are rejected', () => { + const selectors = ['body button', '* button', 'h1', '.selected', ':not(.page) button', '.page + button']; + assert.deepEqual( + broadBusinessSelectors(selectors.map((selector) => `${selector} { color: red; }`).join('\n')), + selectors, + ); + assert.deepEqual(broadBusinessSelectors('@keyframes fade { from { opacity: 0; } to { opacity: 1; } }'), []); +}); + +test('page ownership rejects unrelated classes and pseudo-class ownership tricks', () => { + assert.deepEqual( + unscopedSelectors('.page > button, .page.is-active, .page .row + .row { color: red; }', ['page']), + [], + ); + for (const selector of ['.other', '.page + .other', ':has(.page)', ':is(.page, body)', '.page ~ button']) { + assert.deepEqual(unscopedSelectors(`${selector} { color: red; }`, ['page']), [selector]); + } +}); + +test('fingerprint detects declaration values, media conditions, declaration order and important changes', () => { + const original = '@media (max-width: 780px) { .page { color: red; display: grid; } }'; + for (const modified of [ + original.replace('red', 'blue'), + original.replace('780px', '781px'), + original.replace('color: red; display: grid;', 'display: grid; color: red;'), + original.replace('red;', 'red !important;'), + ]) { + assert.notEqual(cssFingerprint(original), cssFingerprint(modified)); + } + assert.equal(cssFingerprint('.page{color:red}'), cssFingerprint('/* note */ .page {\n color: red;\n}')); + const multiline = '.page,\n.other { transition: color 1s,\n background 2s; }'; + assert.equal(cssFingerprint(multiline), cssFingerprint(multiline.replaceAll('\n', '\r\n'))); +}); + +test('import parser ignores comments and strings and recognizes static, dynamic and CSS url imports', () => { + assert.deepEqual( + localImports( + 'page.tsx', + `// import './fake.css';\nconst note = "real.css"; import './real.css'; const Page = import('./Page');`, + ), + ['./real.css', './Page'], + ); + assert.deepEqual(localImports('entry.css', `/* @import './fake.css'; */ @import url('./real.css');`), ['./real.css']); +}); + +function fixture(t) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'cmpp-css-policy-')); + // Only this test-created, absolute temporary directory is removed. + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + function write(file, text) { + fs.mkdirSync(path.dirname(path.join(directory, file)), { recursive: true }); + fs.writeFileSync(path.join(directory, file), text); + } + write('src/main.tsx', `import { Page } from './apps/Page';`); + write('src/apps/Page.tsx', `import './Page.css'; export const Page = () =>
;`); + write('src/apps/Page.css', '.page { color: red; }'); + write('.stylelintrc.json', JSON.stringify({ overrides: [{ files: [] }] })); + const policy = { files: [{ file: 'src/apps/Page.css', owners: ['src/apps/Page.tsx'], roots: ['page'] }] }; + return { directory, write, policy }; +} + +test('new page with real reachable owner and matching roots passes without .git', (t) => { + const { directory, policy } = fixture(t); + assert.doesNotThrow(() => verifyOwnership(directory, policy)); +}); + +test('comment-only imports, disconnected imports and wrong same-name paths fail', (t) => { + const { directory, policy, write } = fixture(t); + write('src/apps/Page.tsx', `// import './Page.css';\nexport const Page = () =>
;`); + assert.throws(() => verifyOwnership(directory, policy), /不可达/); + write('src/apps/Page.tsx', `import './Page.css'; export const Page = () =>
;`); + write('src/main.tsx', 'const note = "Page.tsx";'); + assert.throws(() => verifyOwnership(directory, policy), /不可达/); + write('src/main.tsx', `import './apps/Page';`); + write('src/apps/Page.tsx', `import './missing/Page.css';`); + assert.throws(() => verifyOwnership(directory, policy), /import 不存在/); +}); + +test('ownership registration must match exact importers and rendered class names', (t) => { + const { directory, policy, write } = fixture(t); + policy.files[0].owners = ['src/main.tsx']; + assert.throws(() => verifyOwnership(directory, policy), /所有者与登记不一致/); + policy.files[0].owners = ['src/apps/Page.tsx']; + write( + 'src/apps/Page.tsx', + `import './Page.css'; const note = 'page'; export const Page = () =>
;`, + ); + assert.throws(() => verifyOwnership(directory, policy), /className/); +}); + +test('unregistered files and new important fail in archive mode too', (t) => { + const { directory, policy, write } = fixture(t); + write('src/apps/Page.css', '.page { color: red !important; }'); + assert.throws(() => verifyOwnership(directory, policy), /important/); + write('src/apps/Page.css', '.page { color: red; }'); + write('src/apps/Unknown.css', '.unknown { color: red; }'); + assert.throws(() => verifyOwnership(directory, policy), /必须登记/); +}); + +test('historical declaration or media change fails even when AST counts match', (t) => { + const { directory, policy, write } = fixture(t); + Object.assign(policy.files[0], { + legacyFingerprint: cssFingerprint('.page { color: red; }'), + reason: 'legacy', + removalCondition: 'review', + }); + assert.doesNotThrow(() => verifyOwnership(directory, policy)); + write('src/apps/Page.css', '.page { color: blue; }'); + assert.throws(() => verifyOwnership(directory, policy), /历史 CSS 内容/); +}); + +test('Stylelint wildcard compatibility expansion is rejected', (t) => { + const { directory, policy, write } = fixture(t); + write('.stylelintrc.json', JSON.stringify({ overrides: [{ files: ['src/styles/*.css'] }] })); + assert.throws(() => verifyOwnership(directory, policy), /精确历史文件清单/); +}); + +test('new stylesheet under legacy directories actually receives strict Stylelint rules', async () => { + for (const file of ['src/styles/new-page.css', 'src/styles/domains/15-new-page.css', 'src/apps/NewPage.css']) { + const config = await stylelint.resolveConfig(file); + assert.deepEqual(config.rules['no-duplicate-selectors'], [true]); + const result = await stylelint.lint({ + code: '.page { color: red; }\n.page { color: blue; }', + codeFilename: path.resolve(file), + }); + assert.ok(result.results[0].warnings.some((warning) => warning.rule === 'no-duplicate-selectors')); + } +});