Repository Wiki
Brandon030722/ark-ui-skill

启发式审计脚本 audit-ark-ui

audit-ark-ui 是 ark-ui 技能内置的零依赖 Node.js 启发式审计脚本(scripts/audit-ark-ui.mjs),对任意前端目录做静态扫描,自动标记缺失的可访问性、响应式缺陷、常见"科幻风模仿"陈词滥调,以及误嵌入官方受保护资产的合规风险,最终输出 JSON 报告并以退出码驱动 CI 门禁。

Purpose and Scope

本页面覆盖 scripts/audit-ark-ui.mjs 的完整实现机制:文件发现与语料聚合、check() 检查调度器、HTML 选择器回查(htmlHasSelector)、JS 选择器与 ARIA ID 引用的一致性推导、全部 13 项启发式检查的正则与严重级别、JSON 报告结构与退出码语义。

本页面不覆盖以下兄弟主题(它们属于质量工具链的其他页面):

  • 证据分析脚本 analyze-css-evidence.py(从公开 CSS 提取颜色/字体/动效/几何证据)——参见其专属页面。
  • 截图复验脚本 capture-showcases.mjs(CDP 视口渲染与横向溢出失败判定)——参见其专属页面。
  • 脚手架脚本 scaffold-ark-ui.py(复制起步模板)——参见其专属页面。
  • 深度档位规范与验收维度本身(references/depth-levels.md 中的四档规范)——本页只说明脚本如何对其中"可静态检测"的子集做机器门禁。

设计意图:ark-ui 工作流要求在生成 UI 后执行"Validate"步骤(见 SKILL.md)。人工逐项核对可访问性与合规条目既慢又容易遗漏,该脚本把其中可以用文本正则可靠判定的条目固化为一道廉价、可重复、可脚本化的门禁;无法静态判定的部分(真实渲染、键盘操作、运行时错误)仍留给人工与截图脚本。

Overview

脚本是一个 105 行的 ESM 顶层 await 程序,只有 node:fs/promises 与 node:path 两个内建依赖,无需安装任何包。其定位是"启发式"审计而非完整 Linter:

  • 输入:一个目录或单个文件路径(process.argv[2],缺省为当前目录 .)。
  • 扫描范围:白名单扩展名 .html、.css、.js、.mjs、.jsx、.ts、.tsx、.vue、.svelte;递归遍历时跳过 node_modules、.next、dist、build。
  • 分析对象:把全部文件内容拼接成四个语料桶——all(全部)、html、css、js——再基于正则做跨语料的派生证据提取与 13 项检查。
  • 输出:stdout 打印 JSON 报告(root、files、errors、warnings、passes),stderr 打印"未找到受支持文件"错误。
  • 退出码:0 无 error;1 存在 error;2 未发现任何受支持文件。

使用场景:在交付 ark-ui 风格界面前跑一次,快速捕获"忘加 viewport meta"、"没有 prefers-reduced-motion"、"拷贝起步模板后改了类名导致 JS 挂钩失效"、"不小心内嵌了 Hypergryph CDN 资源"这类高频回归。

Architecture

Loading diagram...

架构上是单向流水线,四个阶段顺序执行、无回溯:

  1. 文件发现(L8–L22):collect() 递归遍历,按扩展名白名单过滤,产出扁平文件列表。
  2. 语料聚合(L24–L28):并发读取全部文件,按扩展名分别拼接出 all / html / css / js 四个大字符串。
  3. 派生证据(L39–L61):htmlHasSelector 辅助函数 + 两条正则提取链(JS 字面量选择器 → HTML 挂钩回查;ARIA/label 属性 → 声明 ID 集合比对)。
  4. 检查与分桶(L63–L105):13 次 check() 调用,按 severity 参数把失败消息写入 errors 或 warnings,成功消息写入 passes,最后输出报告并按 errors.length 决定退出码。

为什么用"拼接语料 + 正则"而不是逐文件 AST 分析?因为脚本的目标是检测存在性("整个项目里有没有任何一处 prefers-reduced-motion")而非精确定位("第几行违反")。拼接成单一字符串后,一条正则即可覆盖跨文件的全局判定,同时把脚本复杂度压到可被完整审计的 105 行以内——这与"启发式审计"的定位一致:宁可漏报交给人工,也不引入重型解析依赖。

Main Content

阶段一:文件发现与语料聚合

javascript
1const root = resolve(process.argv[2] ?? '.'); 2const supported = new Set(['.html', '.css', '.js', '.mjs', '.jsx', '.ts', '.tsx', '.vue', '.svelte']); 3 4async function collect(path) { 5 const info = await stat(path); 6 if (info.isFile()) return supported.has(extname(path)) ? [path] : []; 7 const entries = await readdir(path, { withFileTypes: true }); 8 const nested = await Promise.all(entries 9 .filter((entry) => !['node_modules', '.next', '.dist', 'build'].includes(entry.name)) 10 .map((entry) => collect(resolve(path, entry.name)))); 11 return nested.flat(); 12}

Source: audit-ark-ui.mjs

(注意:源码第 13 行的忽略列表实为 ['node_modules', '.next', 'dist', 'build'],上表为节选重排;准确列表以上方完整代码摘录为准。)

collect() 的三段式逻辑值得注意:

  • 单文件直通:info.isFile() 分支让脚本既接受目录也接受单个文件(与 SKILL.md 中 <html-or-css-path> 的用法对应),不满足白名单扩展名则返回空数组。
  • 依赖目录黑名单:node_modules、.next、dist、build 被显式排除,避免把第三方库与构建产物当成被审计代码——否则第三方 CSS 中的颜色字面量会立即污染第 99 行的颜色计数检查。
  • Promise.all 并行递归:每个子目录的递归互相独立,并行展开后 flat() 归并,I/O 密集场景下比串行遍历快。

发现阶段之后是空集短路:

javascript
1const files = await collect(root); 2if (!files.length) { 3 console.error(`No supported frontend files found at ${root}`); 4 process.exit(2); 5}

Source: audit-ark-ui.mjs

退出码 2 与"存在 error"的 1 区分开,让调用方能区分"没东西可审"与"审出了问题"。

语料聚合阶段把文件按扩展名分流为四个字符串:

javascript
1const records = await Promise.all(files.map(async (file) => ({ file, text: await readFile(file, 'utf8') }))); 2const all = records.map((record) => record.text).join('\n'); 3const html = records.filter((record) => extname(record.file) === '.html').map((record) => record.text).join('\n'); 4const css = records.filter((record) => extname(record.file) === '.css').map((record) => record.text).join('\n'); 5const js = records.filter((record) => ['.js', '.mjs'].includes(extname(record.file))).map((record) => record.text).join('\n');

Source: audit-ark-ui.mjs

关键取舍:html/css/js 只覆盖原生扩展名,.tsx/.vue/.svelte 中内嵌的标签与样式不会进入对应的语料桶——它们只会体现在 all 中。因此 HTML 结构类检查(viewport、landmark 等)只对真实 .html 文件生效,这是脚本"启发式"边界的一部分:框架单文件组件交给人工与渲染阶段。\n 作为 join 分隔符保证相邻文件内容不会被错误拼接成跨文件的伪匹配。

阶段二:check() 检查调度器

javascript
1const errors = []; 2const warnings = []; 3const passes = []; 4 5function check(condition, pass, fail, severity = 'warning') { 6 if (condition) passes.push(pass); 7 else (severity === 'error' ? errors : warnings).push(fail); 8}

Source: audit-ark-ui.mjs

这是一个极简的三元分桶调度器:每个检查点由 (condition, pass, fail, severity) 四元组描述。设计上有两个值得注意的意图:

  1. passes 也被记录:报告里同时列出通过项,让使用者能确认"哪些维度已被机器验证过",而不是只看到坏消息——这对应 SKILL.md Validate 步骤第 6 条"确认出处/验证覆盖"的审计需求。
  2. 严重级别默认 warning:未显式标注 severity 的检查失败不会置退出码为 1。这把"硬门禁"(合规、可访问性硬伤)与"软建议"(令牌化、颜色收敛)分层,避免风格偏好性建议阻塞流水线。

阶段三:HTML 选择器回查 htmlHasSelector()

javascript
1function htmlHasSelector(selector) { 2 const id = selector.match(/^#([\w-]+)/)?.[1]; 3 if (id) return new RegExp(`\\bid=["']${id}["']`).test(html); 4 5 const className = selector.match(/^\.([\w-]+)/)?.[1]; 6 if (className) return new RegExp(`\\bclass=["'][^"']*\\b${className}\\b`).test(html); 7 8 const attribute = selector.match(/^\[([\w-]+)(?:=["']([^"]+)["'])?\]$/); 9 if (!attribute) return true; 10 const [, name, value] = attribute; 11 return value 12 ? new RegExp(`\\b${name}=["']${value}["']`).test(html) 13 : new RegExp(`\\b${name}(?:=["'][^"']*["'])?`).test(html); 14}

Source: audit-ark-ui.mjs

这是脚本中唯一带分支逻辑的辅助函数,处理三类字面量选择器在 HTML 语料中的"存在性回查":

  • #id → 构造 id="..." 的单词边界正则,注意 \\b 防止 id="user" 匹配到 id="user-name"。
  • .class → 在 class="..." 属性值内部再做单词边界匹配,兼容一个元素挂多个类。
  • [attr] / [attr=value] → 仅有名称时允许属性带任意值;带值时要求精确匹配。
  • 兜底返回 true:无法识别的选择器形式(复合选择器、伪类等)一律视为"存在",避免误报。

设计意图:ark-ui 的起步模板(assets/starter-vanilla/)自带 JS 接线,用户复制模板后常改类名/ID 却忘记同步 JS。querySelector 与 HTML 的静态一致性因此成为高价值、低成本的检测点。

阶段四:两条派生证据链

链路 A —— JS 字面量选择器 → HTML 挂钩:

javascript
const queriedSelectors = [...js.matchAll(/\bquerySelector(?:All)?\(\s*(["'])([^"']+)\1\s*\)/g)] .map((match) => match[2]); const missingSelectors = [...new Set(queriedSelectors.filter((selector) => !htmlHasSelector(selector)))];

Source: audit-ark-ui.mjs

只匹配字符串字面量作为第一参数的 querySelector/querySelectorAll 调用(捕获组 2 是引号内的选择器文本),变量拼接的选择器天然跳过。new Set(...) 对结果去重,避免同一条缺失选择器在警告里重复出现。

链路 B —— ARIA / label 属性 → 声明 ID 集合:

javascript
1const referencedIds = [...html.matchAll(/\b(?:aria-controls|aria-labelledby|aria-describedby|for)=["']([^"']+)["']/g)] 2 .flatMap((match) => match[1].trim().split(/\s+/)); 3const declaredIds = new Set([...html.matchAll(/\bid=["']([^"']+)["']/g)].map((match) => match[1])); 4const missingIds = [...new Set(referencedIds.filter((id) => !declaredIds.has(id)))];

Source: audit-ark-ui.mjs

aria-labelledby/aria-describedby 允许空格分隔的多 ID 引用,因此用 flatMap + split(/\s+/) 展开成单个 ID;declaredIds 建成 Set 做 O(1) 成员判断。这条链是可访问性硬伤(悬空 ARIA 引用)的机器化检测,对应检查表第 93–95 行的 error 级条目。

13 项启发式检查全景

下表汇总全部检查项(按源码出现顺序),级别 即 check() 的 severity 实参:

#检查内容(正则要点)级别通过消息 / 失败消息主旨
1官方 CDN 资产(web.hycdn.cn、hypergryph.com 静态资源)未内嵌error不得嵌入官方 CDN 资产,需替换为原创/用户拥有素材
2无受保护品牌标识引用(Rhodes Island / Arknights / Endfield / Monster Siren + logo/wordmark/copyright 近邻组合)error品牌标识引用需确认授权
3CSS 存在语义令牌 --ark-ink / --ark-paper / --ark-signalwarning应使用语义令牌而非散落调色板字面量
4CSS 定义 prefers-reduced-motionerror循环/过渡/显现效果需有减弱动效规则
5存在 @media 的 max-width 或 orientation 查询error需竖屏/移动端重构,不得依赖桌面缩放
6存在 :focus-visibleerror键盘焦点需有可见样式
7HTML 有 <meta name="viewport">error缺失 viewport meta
8HTML 有 lang= 属性error缺失文档语言声明
9HTML 有语义地标 <main>/<nav>/<header>/<footer>error应使用语义地标
10missingSelectors.length === 0(JS 选择器与 HTML 挂钩一致)warning更新起步接线后改名需同步
11missingIds.length === 0(ARIA/label 引用可解析)errorARIA 引用指向缺失 ID
12无常见科幻陈词滥调命名(scanline/glitch/random-hex/matrix-rain)warning装饰需有真实信息角色
13十六进制颜色字面量计数 < 180warning大量字面颜色应收敛为族/语义令牌

其中第 1–2 项是合规红线,第 4–9、11 项是可访问性/响应式硬门禁,第 3、10、12、13 项是风格与一致性软建议——这个分层直接对应 SKILL.md 中 "Avoid" 一节(不得内嵌受保护资产、不得堆砌 scanline/glitch 等)与 Validate 一节(可见焦点、reduced-motion、竖屏重构)的机器化映射。

以下是几条代表性检查的真实源码:

javascript
check(!/web(?:-ipv6)?\.hycdn\.cn|hypergryph\.com\/(?:.*\.(?:png|jpe?g|svg|woff2?|ttf|eot|mp4))/i.test(all), 'No official CDN assets are embedded.', 'Official Hypergryph CDN assets appear to be embedded; verify rights and replace with original/user-owned assets.', 'error');

Source: audit-ark-ui.mjs

合规检查在 all(全部语料)上执行,因此无论资产 URL 出现在 HTML、CSS 还是 JS 中都会命中;web-ipv6 变体也被覆盖。

javascript
check((all.match(/#[0-9a-fA-F]{3,8}\b/g) ?? []).length < 180, 'Literal color count is restrained.', 'Many literal colors were found; consolidate them into family and semantic tokens.');

Source: audit-ark-ui.mjs

颜色计数用 ?? [] 兜底 match 在零命中时返回 null 的边界情况;阈值 180 是经验值,用于识别"调色板字面量散落"的反模式,与第 3 项语义令牌检查互为补充(一个查"有没有令牌",一个查"字面量是否失控")。

HTML 结构类检查普遍带 !html || 前缀,例如:

javascript
check(!html || /<meta[^>]+name=["']viewport["']/i.test(html), 'Viewport metadata is present.', 'HTML is missing a viewport meta tag.', 'error');

Source: audit-ark-ui.mjs

!html || 是重要的边界处理:纯 CSS / 纯 JS 项目(没有 .html 文件)时语料为空字符串,正则必然失败,若不加短路会误报。这使脚本可以直接对着一个 CSS 文件运行(README 中的用法 <html-or-css-path>)。

报告输出与退出码

javascript
const report = { root, files: files.length, errors, warnings, passes }; console.log(JSON.stringify(report, null, 2)); process.exit(errors.length ? 1 : 0);

Source: audit-ark-ui.mjs

files 记录被扫描的文件数,便于确认审计确实覆盖了预期范围;JSON.stringify(report, null, 2) 输出缩进 JSON,既可人读也可被上层脚本 JSON.parse。退出码语义:0 通过(允许存在 warning)、1 存在 error、2 无可审计文件。

Core Flow

Loading diagram...

调用序列说明:

  1. stat 定性:stat() 先判断入口是文件还是目录,决定走白名单直通还是递归遍历。
  2. 并行读全量:一次性 Promise.all 读入所有文件,无逐文件惰性加载——对超大仓库内存占用换取实现简单与单遍扫描。
  3. 派生证据先于检查:missingSelectors / missingIds 在 check() 调用前完成推导,检查阶段只消费结果,保证"证据提取"与"判定"解耦。
  4. 单次输出:报告只在结尾打印一次,中间不打日志,保证 stdout 是纯 JSON、可被管道安全解析。

Usage Examples

基本用法(对项目目录)

bash
node "$CODEX_HOME/skills/ark-ui/scripts/audit-ark-ui.mjs" <html-or-css-path>

Source: SKILL.md

这是 SKILL.md Validate 步骤第 2 条给出的标准调用形式,参数既可为目录也可为单个 HTML/CSS 文件。

作为验证流水线的一环

bash
node "$CODEX_HOME/skills/ark-ui/scripts/audit-ark-ui.mjs" <html-or-css-path> node "$CODEX_HOME/skills/ark-ui/scripts/capture-showcases.mjs" --mobile python3 "$CODEX_HOME/skills/.system/skill-creator/scripts/quick_validate.py" "$CODEX_HOME/skills/ark-ui"

Source: README.md

README 的"验证"一节把启发式审计、CDP 截图复验、技能结构校验串联成三步质量流水线:本脚本负责静态可判定项,capture-showcases.mjs 负责真实渲染的横向溢出,quick_validate.py 负责技能目录自身的结构合法性——三者职责互补,缺一不可。

Configuration Options

脚本本身无配置文件、无 CLI 选项、无环境变量,全部行为硬编码在源码中:

项类型默认值(硬编码)说明
位置参数 <path>string'.'(process.argv[2] ?? '.')被审计的目录或文件;缺省为当前工作目录
支持扩展名Set.html .css .js .mjs .jsx .ts .tsx .vue .svelte不在集合内的文件被跳过
忽略目录数组node_modules、.next、dist、build递归遍历时剪枝
十六进制颜色阈值number180字面颜色计数超过即产生 warning
默认严重级别string'warning'check() 未传 severity 时按警告分桶

若需调整阈值或扩展忽略列表,唯一途径是直接修改 scripts/audit-ark-ui.mjs——这是刻意的零配置取舍,保证任何环境下行为完全一致。

API Reference

脚本不导出任何符号(无 export),以下是内部函数的参考说明:

collect(path): Promise<string[]>

递归收集受支持扩展名的文件路径。

参数:

  • path (string):绝对化的起始路径(文件或目录)。

返回: Promise<string[]> —— 扁平化的文件路径列表。文件路径直接参与白名单判断;目录则对非忽略条目并行递归后 flat() 归并。

边界行为: 入口是文件但不满足扩展名白名单时返回空数组,随后触发 exit(2)。

Source: audit-ark-ui.mjs

check(condition, pass, fail, severity = 'warning'): void

单一检查点的三元分桶调度器。

参数:

  • condition (boolean):判定条件,真表示通过。
  • pass (string):通过时写入 passes 的消息。
  • fail (string):失败时写入 errors 或 warnings 的消息。
  • severity (string,默认 'warning'):'error' 时失败进入 errors 并影响退出码。

返回: 无(副作用写入模块级数组)。

Source: audit-ark-ui.mjs

htmlHasSelector(selector): boolean

判断一个字面量选择器在 HTML 语料中是否有对应挂钩。

参数:

  • selector (string):JS 中提取的选择器文本,支持 #id、.class、[attr]、[attr=value] 三类形式。

返回: boolean。无法识别的形式返回 true(宁可漏报不误报)。

Source: audit-ark-ui.mjs

退出码契约

退出码含义触发条件
0审计通过存在受支持文件,且 errors.length === 0(warning 不阻塞)
1存在 error 级违规任一 severity: 'error' 检查失败(含 missingIds、CDN 资产、品牌标识、reduced-motion、响应式、focus-visible、viewport、lang、landmark)
2无可审计内容收集结果为空数组,stderr 打印 No supported frontend files found at <root>

Failure Modes, Edge Cases & Concurrency

识别为边界与失败模式(均来自源码证据):

  • 空语料短路:HTML 结构类检查以 !html || 开头,纯 CSS/JS 项目不会误报 viewport/lang/landmark 缺失;all 上的检查(CDN、品牌、陈词滥调、颜色计数)在无 HTML 时同样照常执行。
  • match() 返回 null:颜色计数检查用 (all.match(...) ?? []) 显式兜底,这是全脚本唯一处理 match 空结果的点;其余 matchAll 调用天然返回空迭代器,无需兜底。
  • 无法解析的选择器:htmlHasSelector 兜底 return true,复合选择器(如 .a .b、div > p)不会被误判为缺失——代价是这类选择器的真实缺失不报警。
  • 拼接语料的伪匹配风险:join('\n') 保证文件边界由换行隔开,规避了首尾相接导致的伪单词;但单行内跨行的 HTML 结构仍依赖 \b 与 ["'] 边界约束。
  • 框架文件不在专用语料中:.tsx/.vue/.svelte 只进入 all,其中的选择器与 ARIA 引用不会被一致性检查覆盖(js/html 桶只含原生扩展名)。
  • 并发模型:读文件与目录递归均通过 Promise.all 并行(属于单进程事件循环内的 I/O 并发,非多线程)。脚本只读不写,无共享可变状态竞争;唯一共享可变状态是 errors/warnings/passes 三个数组,且只在全部 I/O 完成后由同步的 check() 序列写入,无数据竞争。
  • 错误传播:readFile/stat/readdir 未做 try/catch,路径不存在或无权限时 Node 以未捕获 rejection 退出(顶层 await 下退出码非 0),错误信息直接由运行时输出。

已知的启发式局限(源码可推断):

  • 正则无法判断语义:scanline 出现在注释或文案中同样触发第 12 项 warning;这正是该检查被定为 warning 而非 error 的原因。
  • 颜色阈值 180 是全局计数,多主题/多模块大项目可能天然超阈值,需人工裁量。

Performance & Operational Notes

  • 单遍扫描:所有文件只读取一次,13 项检查共享同一份语料字符串,复杂度近似 O(语料总字符数 × 正则数)。
  • 内存换简单:全量拼接进内存对常规前端项目(数百文件、数 MB 文本)无压力;对超大 monorepo 需先把入口路径指向目标子目录。
  • CI 集成:JSON 报告输出到 stdout、错误到 stderr、退出码语义清晰,可直接作为 CI 门禁步骤;exit(1) 只由 error 级触发,warning 不会阻塞构建。
  • 无外部依赖:仅 node:fs/promises 与 node:path,无需 npm install,可被任意只读环境直接执行。

Extension Points

脚本无插件机制,扩展方式是直接在源码内新增 check() 调用。新增一条检查只需三步(全部有源码范式可循):

  1. 若需要派生证据,仿照 L54–L61 先用 matchAll 提取并去重;
  2. 调用 check(condition, 通过消息, 失败消息, severity),severity 省略则默认 warning;
  3. 遵循现有分层惯例:合规与可访问性硬伤用 'error',风格建议不传 severity。

现有 13 条检查本身就是新增检查的完整范式库——例如品牌标识检查(L66–L68)展示了"关键词 + 近邻窗口(.{0,24})"的组合写法,可用于扩展其他受保护词族。

Tests

仓库中未发现针对 scripts/audit-ark-ui.mjs 的自动化测试文件(在源码预算内的探索未命中)。间接的用法证据来自文档:

  • SKILL.md 将其定位为"标记缺失可访问性/响应式与常见模仿陈词滥调"的捆绑脚本;
  • README.md 将其列为验证流水线第一步,并强调四档深度均须保持键盘可用、可见焦点、响应式布局与 prefers-reduced-motion——这些正是脚本 error 级检查所机器化的条目。
  • 源码:scripts/audit-ark-ui.mjs
  • 调用约定与 Validate 工作流:SKILL.md
  • 验证流水线全景:README.md
  • 姊妹主题:证据分析脚本 analyze-css-evidence.py、截图复验脚本 capture-showcases.mjs、脚手架 scaffold-ark-ui.py、深度档位规范 references/depth-levels.md(各自有独立页面)

Sources

(3 files)