fix: 收紧CSS所有权与历史兼容门禁
This commit is contained in:
@@ -17,7 +17,20 @@ jobs:
|
|||||||
node-version: 24
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- name: Verify changed formatting and CSS ownership
|
- name: Resolve the complete change range
|
||||||
env:
|
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
|
run: npm run format:check && npm run style:check && npm run css:verify
|
||||||
|
|||||||
+22
-3
@@ -12,15 +12,34 @@
|
|||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": [
|
"files": [
|
||||||
"src/styles/*.css",
|
|
||||||
"src/styles/domains/*.css",
|
|
||||||
"src/apps/admin/channels/AdminChannelsPage.css",
|
"src/apps/admin/channels/AdminChannelsPage.css",
|
||||||
"src/apps/admin/enterprise-applications/AdminEnterpriseApplicationsPage.css",
|
"src/apps/admin/enterprise-applications/AdminEnterpriseApplicationsPage.css",
|
||||||
"src/apps/admin/security-detection/AdminSecurityDetectionPage.css",
|
"src/apps/admin/security-detection/AdminSecurityDetectionPage.css",
|
||||||
"src/apps/admin/sms-records/AdminSmsRecordsPage.css",
|
"src/apps/admin/sms-records/AdminSmsRecordsPage.css",
|
||||||
"src/apps/admin/sms-task-progress/AdminSmsTaskProgressPage.css",
|
"src/apps/admin/sms-task-progress/AdminSmsTaskProgressPage.css",
|
||||||
"src/apps/admin/system-monitoring/AdminSystemMonitoringPage.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": {
|
"rules": {
|
||||||
"at-rule-empty-line-before": null,
|
"at-rule-empty-line-before": null,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
- `npm run format:check`:增量Prettier现同时覆盖CSS。
|
- `npm run format:check`:增量Prettier现同时覆盖CSS。
|
||||||
- `npm run style:check`:对全部CSS执行Stylelint;迁移前已存在的文件使用显式兼容范围,新CSS默认严格执行标准规则。
|
- `npm run style:check`:对全部CSS执行Stylelint;迁移前已存在的文件使用显式兼容范围,新CSS默认严格执行标准规则。
|
||||||
- `npm run css:verify`:验证`global.css`不得重建、固定导入顺序、AST基线、选择器顺序、`!important`增长、宽泛业务标签规则和import所有权,并执行允许/拒绝样例测试。
|
- `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、选择器顺序和例外检查,仅跳过无法取得基线的增量文件比较。
|
- Git归档或部署候选目录没有`.git`时,门禁仍执行当前树的所有权、AST、选择器顺序和例外检查,仅跳过无法取得基线的增量文件比较。
|
||||||
|
|
||||||
## 维护边界
|
## 维护边界
|
||||||
@@ -46,3 +46,19 @@
|
|||||||
- 14个迁移模块只用于承接已登记的历史规则;新增规则应先判断页面、业务域或公共组件所有权,不得因类名前缀相似直接追加。
|
- 14个迁移模块只用于承接已登记的历史规则;新增规则应先判断页面、业务域或公共组件所有权,不得因类名前缀相似直接追加。
|
||||||
- 不得重新创建 `global.css`、扩大Stylelint兼容文件范围或增加无登记例外。
|
- 不得重新创建 `global.css`、扩大Stylelint兼容文件范围或增加无登记例外。
|
||||||
- 迁移模块后续按真实页面证据继续细化时,必须同步更新机器基线,并保持主CSS产物或关键计算样式等价。
|
- 迁移模块后续按真实页面证据继续细化时,必须同步更新机器基线,并保持主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次重复选择器出现包含有效响应式覆盖,不按重复次数直接删除。
|
||||||
|
|||||||
@@ -5063,6 +5063,12 @@ npm run verify:phase8
|
|||||||
| TC-CSS-MOD-004 | 修改TS/TSX或新增CSS后运行增量格式检查 | 实际变更的CSS进入Prettier;Stylelint对未登记新文件执行严格规则,历史兼容范围不会自动扩大 |
|
| TC-CSS-MOD-004 | 修改TS/TSX或新增CSS后运行增量格式检查 | 实际变更的CSS进入Prettier;Stylelint对未登记新文件执行严格规则,历史兼容范围不会自动扩大 |
|
||||||
| TC-CSS-MOD-005 | 分别首次进入、刷新及跨路由打开核心运营端和客户端页面 | CSS资源无404,无框架错误层和新增控制台错误;布局、弹窗、下拉框、Sticky表头、状态记录及字段卡片与迁移前一致 |
|
| TC-CSS-MOD-005 | 分别首次进入、刷新及跨路由打开核心运营端和客户端页面 | CSS资源无404,无框架错误层和新增控制台错误;布局、弹窗、下拉框、Sticky表头、状态记录及字段卡片与迁移前一致 |
|
||||||
| TC-CSS-MOD-006 | 在1600×1000、1366×768和390×844检查核心页面 | 页面无新增横向溢出、遮挡、错位或层级问题,窄屏交互仍可访问 |
|
| 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 运营看板与配置交互增强
|
## TC-ADMIN-ENHANCEMENT-20260904 运营看板与配置交互增强
|
||||||
|
|
||||||
|
|||||||
@@ -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`。本轮没有发送、补发、重投或重新入队短信,也没有修改余额、通道、客户或签名业务资料。
|
- 三条短信相关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`。
|
- 测试环境真实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个客户端页面及全部网络检查通过,该测试夹具问题不属于平台回归缺陷。预生产环境未访问或修改。
|
- 两个临时验收账号已精确删除,数据库剩余数为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层级。发布采用明确提交归档,候选门禁及资源一致性检查通过后仅更新治理文件和文档,不运行全量初始化/迁移/重启脚本。测试发布结果另行追加。
|
||||||
|
|||||||
Generated
+2
@@ -34,6 +34,8 @@
|
|||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"msw": "^2.15.0",
|
"msw": "^2.15.0",
|
||||||
|
"postcss": "8.5.23",
|
||||||
|
"postcss-selector-parser": "7.1.6",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"stylelint": "^16.23.1",
|
"stylelint": "^16.23.1",
|
||||||
"stylelint-config-standard": "^38.0.0",
|
"stylelint-config-standard": "^38.0.0",
|
||||||
|
|||||||
@@ -65,6 +65,8 @@
|
|||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"msw": "^2.15.0",
|
"msw": "^2.15.0",
|
||||||
|
"postcss": "8.5.23",
|
||||||
|
"postcss-selector-parser": "7.1.6",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"stylelint": "^16.23.1",
|
"stylelint": "^16.23.1",
|
||||||
"stylelint-config-standard": "^38.0.0",
|
"stylelint-config-standard": "^38.0.0",
|
||||||
|
|||||||
@@ -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;有意样式变更须附依据和验证后更新基线。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import process from 'node:process';
|
|||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import postcss from 'postcss';
|
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)), '../..');
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
|
||||||
@@ -24,18 +26,6 @@ export function analyzeCss(css) {
|
|||||||
return result;
|
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) {
|
export function verifyGlobalCssAbsent(repoRoot) {
|
||||||
const legacy = path.join(repoRoot, 'src/styles/global.css');
|
const legacy = path.join(repoRoot, 'src/styles/global.css');
|
||||||
if (fs.existsSync(legacy)) throw new Error('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');
|
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() {
|
function changedCssFiles() {
|
||||||
if (!fs.existsSync(path.join(root, '.git'))) return { base: 'archive', files: [] };
|
if (!fs.existsSync(path.join(root, '.git'))) return { base: 'archive', files: [] };
|
||||||
const configuredBase = process.env.QUALITY_BASE_REF;
|
const configuredBase = process.env.QUALITY_BASE_REF;
|
||||||
@@ -89,6 +72,12 @@ function baseCss(base, file) {
|
|||||||
|
|
||||||
function verify() {
|
function verify() {
|
||||||
verifyGlobalCssAbsent(root);
|
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 baseline = JSON.parse(read('tools/quality/css-governance-baseline.json'));
|
||||||
const entry = read(baseline.entry);
|
const entry = read(baseline.entry);
|
||||||
const imports = [...entry.matchAll(/@import\s+['"](.+?)['"]/g)].map((match) =>
|
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 duplicateCount = selectorList.length - new Set(selectorList).size;
|
||||||
const digest = crypto.createHash('sha256').update(selectorList.join('\n')).digest('hex').slice(0, 12);
|
const digest = crypto.createHash('sha256').update(selectorList.join('\n')).digest('hex').slice(0, 12);
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { analyzeCss, broadBusinessSelectors, verifyGlobalCssAbsent } from './verify-css-governance.mjs';
|
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', () => {
|
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; }'), {
|
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), /禁止重新创建/);
|
assert.throws(() => verifyGlobalCssAbsent(directory), /禁止重新创建/);
|
||||||
fs.rmSync(directory, { recursive: true, force: true });
|
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 = () => <div className="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 = () => <div className="page" />;`);
|
||||||
|
assert.throws(() => verifyOwnership(directory, policy), /不可达/);
|
||||||
|
write('src/apps/Page.tsx', `import './Page.css'; export const Page = () => <div className="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 = () => <div className="different" />;`,
|
||||||
|
);
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user