启发式审计脚本 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
架构上是单向流水线,四个阶段顺序执行、无回溯:
- 文件发现(L8–L22):
collect()递归遍历,按扩展名白名单过滤,产出扁平文件列表。 - 语料聚合(L24–L28):并发读取全部文件,按扩展名分别拼接出
all/html/css/js四个大字符串。 - 派生证据(L39–L61):
htmlHasSelector辅助函数 + 两条正则提取链(JS 字面量选择器 → HTML 挂钩回查;ARIA/label 属性 → 声明 ID 集合比对)。 - 检查与分桶(L63–L105):13 次
check()调用,按severity参数把失败消息写入errors或warnings,成功消息写入passes,最后输出报告并按errors.length决定退出码。
为什么用"拼接语料 + 正则"而不是逐文件 AST 分析?因为脚本的目标是检测存在性("整个项目里有没有任何一处 prefers-reduced-motion")而非精确定位("第几行违反")。拼接成单一字符串后,一条正则即可覆盖跨文件的全局判定,同时把脚本复杂度压到可被完整审计的 105 行以内——这与"启发式审计"的定位一致:宁可漏报交给人工,也不引入重型解析依赖。
Main Content
阶段一:文件发现与语料聚合
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 密集场景下比串行遍历快。
发现阶段之后是空集短路:
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 区分开,让调用方能区分"没东西可审"与"审出了问题"。
语料聚合阶段把文件按扩展名分流为四个字符串:
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() 检查调度器
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) 四元组描述。设计上有两个值得注意的意图:
passes也被记录:报告里同时列出通过项,让使用者能确认"哪些维度已被机器验证过",而不是只看到坏消息——这对应 SKILL.md Validate 步骤第 6 条"确认出处/验证覆盖"的审计需求。- 严重级别默认
warning:未显式标注severity的检查失败不会置退出码为1。这把"硬门禁"(合规、可访问性硬伤)与"软建议"(令牌化、颜色收敛)分层,避免风格偏好性建议阻塞流水线。
阶段三:HTML 选择器回查 htmlHasSelector()
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 挂钩:
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 集合:
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 | 品牌标识引用需确认授权 |
| 3 | CSS 存在语义令牌 --ark-ink / --ark-paper / --ark-signal | warning | 应使用语义令牌而非散落调色板字面量 |
| 4 | CSS 定义 prefers-reduced-motion | error | 循环/过渡/显现效果需有减弱动效规则 |
| 5 | 存在 @media 的 max-width 或 orientation 查询 | error | 需竖屏/移动端重构,不得依赖桌面缩放 |
| 6 | 存在 :focus-visible | error | 键盘焦点需有可见样式 |
| 7 | HTML 有 <meta name="viewport"> | error | 缺失 viewport meta |
| 8 | HTML 有 lang= 属性 | error | 缺失文档语言声明 |
| 9 | HTML 有语义地标 <main>/<nav>/<header>/<footer> | error | 应使用语义地标 |
| 10 | missingSelectors.length === 0(JS 选择器与 HTML 挂钩一致) | warning | 更新起步接线后改名需同步 |
| 11 | missingIds.length === 0(ARIA/label 引用可解析) | error | ARIA 引用指向缺失 ID |
| 12 | 无常见科幻陈词滥调命名(scanline/glitch/random-hex/matrix-rain) | warning | 装饰需有真实信息角色 |
| 13 | 十六进制颜色字面量计数 < 180 | warning | 大量字面颜色应收敛为族/语义令牌 |
其中第 1–2 项是合规红线,第 4–9、11 项是可访问性/响应式硬门禁,第 3、10、12、13 项是风格与一致性软建议——这个分层直接对应 SKILL.md 中 "Avoid" 一节(不得内嵌受保护资产、不得堆砌 scanline/glitch 等)与 Validate 一节(可见焦点、reduced-motion、竖屏重构)的机器化映射。
以下是几条代表性检查的真实源码:
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 变体也被覆盖。
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 || 前缀,例如:
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>)。
报告输出与退出码
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
调用序列说明:
- stat 定性:
stat()先判断入口是文件还是目录,决定走白名单直通还是递归遍历。 - 并行读全量:一次性
Promise.all读入所有文件,无逐文件惰性加载——对超大仓库内存占用换取实现简单与单遍扫描。 - 派生证据先于检查:
missingSelectors/missingIds在check()调用前完成推导,检查阶段只消费结果,保证"证据提取"与"判定"解耦。 - 单次输出:报告只在结尾打印一次,中间不打日志,保证 stdout 是纯 JSON、可被管道安全解析。
Usage Examples
基本用法(对项目目录)
node "$CODEX_HOME/skills/ark-ui/scripts/audit-ark-ui.mjs" <html-or-css-path>Source: SKILL.md
这是 SKILL.md Validate 步骤第 2 条给出的标准调用形式,参数既可为目录也可为单个 HTML/CSS 文件。
作为验证流水线的一环
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 | 递归遍历时剪枝 |
| 十六进制颜色阈值 | number | 180 | 字面颜色计数超过即产生 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() 调用。新增一条检查只需三步(全部有源码范式可循):
- 若需要派生证据,仿照 L54–L61 先用
matchAll提取并去重; - 调用
check(condition, 通过消息, 失败消息, severity),severity省略则默认 warning; - 遵循现有分层惯例:合规与可访问性硬伤用
'error',风格建议不传 severity。
现有 13 条检查本身就是新增检查的完整范式库——例如品牌标识检查(L66–L68)展示了"关键词 + 近邻窗口(.{0,24})"的组合写法,可用于扩展其他受保护词族。
Tests
仓库中未发现针对 scripts/audit-ark-ui.mjs 的自动化测试文件(在源码预算内的探索未命中)。间接的用法证据来自文档:
- SKILL.md 将其定位为"标记缺失可访问性/响应式与常见模仿陈词滥调"的捆绑脚本;
- README.md 将其列为验证流水线第一步,并强调四档深度均须保持键盘可用、可见焦点、响应式布局与
prefers-reduced-motion——这些正是脚本 error 级检查所机器化的条目。
Related Links
- 源码: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(各自有独立页面)