Files
hms/.claude/skills/design-handoff/scripts/infer-interactions.mjs
iven 6151fde7c4 fix(skills): 修复 design-handoff 医生端原型输出质量
- match-tokens: alias 增加值条件映射,支持 doctor/patient variant 自动检测
- parse-prototype: 支持 IosFrame label prop 提取(JSX 属性模式)
- interaction-rules: login-cta 增加 exclude_patterns 避免"立即关注"误报
- tokens.yml: T.pri/T.priL/T.priD alias 改为数组格式,按值匹配 variant

验证: 患者端 T.pri→--tk-pri 确认,医生端 T.pri→--tk-pri.doctor 确认
2026-05-18 01:00:20 +08:00

223 lines
6.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* infer-interactions.mjs -- 对 HTML 原型源码进行静态模式匹配,推断页面交互行为
*
* 读取 HTML 文件和 interaction-rules.yml用正则匹配源码中的模式
* 输出推断的交互组件、props 和行为描述。
*
* 用法: node infer-interactions.mjs <html-file> <interaction-rules.yml>
*/
import { readFileSync } from 'node:fs';
import { resolve, basename } from 'node:path';
import { createRequire } from 'node:module';
// js-yaml 是 CJS 包,需要用 createRequire 加载
const require = createRequire(import.meta.url);
const yaml = require('js-yaml');
// -- 工具函数 ----------------------------------------------------------------
/**
* 输出错误到 stderr 并以 code 1 退出
*/
function fail(message) {
process.stderr.write(JSON.stringify({ error: message }, null, 2) + '\n');
process.exit(1);
}
/**
* 读取文件内容,失败时调用 fail
*/
function readFileOrFail(filePath, description) {
try {
return readFileSync(filePath, 'utf-8');
} catch (err) {
fail(`无法读取${description}: ${filePath} -- ${err.message}`);
}
}
// -- 组件函数位置追踪 ---------------------------------------------------------
/**
* 提取源码中所有 function/const 组件声明的位置和名称。
* 返回 [{ name, start, end }],按 start 排序。
*
* end 的计算采用简化策略:找到下一个同级 function/const 声明或源码末尾。
* 对于 locations 追踪来说已经足够精确——只要知道 pattern 落在哪个函数内即可。
*/
function extractFunctionRanges(source) {
const ranges = [];
// 匹配 function 声明: function XxxName(
const funcPattern = /function\s+([A-Z]\w*)\s*\(/g;
let match;
while ((match = funcPattern.exec(source)) !== null) {
ranges.push({ name: match[1], start: match.index });
}
// 匹配 const 箭头函数: const XxxName = (...
const arrowPattern = /const\s+([A-Z]\w*)\s*=\s*(?:\([^)]*\)|\w+)\s*=>/g;
while ((match = arrowPattern.exec(source)) !== null) {
ranges.push({ name: match[1], start: match.index });
}
// 按 start 排序,计算 end下一个函数的 start 或源码末尾)
ranges.sort((a, b) => a.start - b.start);
for (let i = 0; i < ranges.length; i++) {
const nextStart = i + 1 < ranges.length ? ranges[i + 1].start : source.length;
ranges[i].end = nextStart;
}
return ranges;
}
/**
* 给定 pattern 在 source 中的匹配位置,找到它所属的函数名。
* 如果不在任何函数内,返回 '全局'。
*/
function findEnclosingFunction(ranges, matchIndex) {
for (const range of ranges) {
if (matchIndex >= range.start && matchIndex < range.end) {
return range.name;
}
}
return '全局';
}
// -- 核心匹配逻辑 ------------------------------------------------------------
/**
* 对单条 rule 执行模式匹配。
* 返回 { matched, matchedPatterns, locations }。
*/
function matchRule(rule, source, funcRanges) {
const requireAll = rule.require_all === true;
const matchedPatterns = [];
const locationSet = new Set();
for (const patternStr of rule.patterns) {
try {
const regex = new RegExp(patternStr, 'g');
let match;
let patternMatched = false;
while ((match = regex.exec(source)) !== null) {
patternMatched = true;
const enclosing = findEnclosingFunction(funcRanges, match.index);
locationSet.add(enclosing);
}
if (patternMatched) {
matchedPatterns.push(patternStr);
}
} catch (err) {
// 正则语法错误,跳过此 pattern 并报告到 stderr
process.stderr.write(`警告: rule "${rule.id}" 的 pattern "${patternStr}" 正则语法错误: ${err.message}\n`);
}
}
const allMatched = requireAll
? matchedPatterns.length === rule.patterns.length
: matchedPatterns.length > 0;
// 排除模式检查:如果任一 exclude_pattern 命中,该规则不匹配
if (allMatched && Array.isArray(rule.exclude_patterns)) {
for (const exclPattern of rule.exclude_patterns) {
try {
const exclRegex = new RegExp(exclPattern);
if (exclRegex.test(source)) {
return {
matched: false,
matchedPatterns: [],
locations: [],
excluded_by: exclPattern,
};
}
} catch (_e) { /* ignore regex errors */ }
}
}
return {
matched: allMatched,
matchedPatterns: allMatched ? matchedPatterns : [],
locations: allMatched ? [...locationSet] : [],
};
}
// -- 主流程 ------------------------------------------------------------------
function main() {
const args = process.argv.slice(2);
if (args.length < 2) {
fail('用法: node infer-interactions.mjs <html-file> <interaction-rules.yml>');
}
const htmlPath = resolve(args[0]);
const rulesPath = resolve(args[1]);
// 读取源文件
const source = readFileOrFail(htmlPath, 'HTML 文件');
// 读取并解析 rules YAML
const rulesContent = readFileOrFail(rulesPath, 'interaction-rules.yml');
let rulesDoc;
try {
rulesDoc = yaml.load(rulesContent);
} catch (err) {
fail(`YAML 解析失败: ${err.message}`);
}
if (!rulesDoc || !Array.isArray(rulesDoc.rules)) {
fail('interaction-rules.yml 格式错误: 缺少顶层的 rules 数组');
}
const rules = rulesDoc.rules;
// 提取函数位置范围表(用于 locations 追踪)
const funcRanges = extractFunctionRanges(source);
// 逐条匹配
const interactions = [];
let matchedCount = 0;
for (const rule of rules) {
const { matched, matchedPatterns, locations } = matchRule(rule, source, funcRanges);
const entry = {
id: rule.id,
name: rule.name,
matched,
};
if (matched) {
matchedCount++;
entry.matchedPatterns = matchedPatterns;
entry.component = rule.infer?.component ?? null;
entry.props = rule.infer?.props ?? '';
entry.behavior = rule.infer?.behavior ?? '';
entry.confidence = rule.confidence ?? 'medium';
if (locations.length > 0) {
entry.locations = locations;
}
}
interactions.push(entry);
}
// 组装输出
const result = {
source: basename(htmlPath),
interactions,
summary: {
total: rules.length,
matched: matchedCount,
unmatched: rules.length - matchedCount,
},
};
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
main();