- 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 确认
223 lines
6.3 KiB
JavaScript
223 lines
6.3 KiB
JavaScript
#!/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();
|