deveco_hmigbot 0.21.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +26 -0
- package/README.md +50 -0
- package/agents/hmigbot-worker.md +61 -0
- package/agents/hmigbot.md +22 -0
- package/agents/workflow-subagent.md +55 -0
- package/commands/hmigbot.md +17 -0
- package/dist/index.js +1 -0
- package/manifest.json +19 -0
- package/package.json +29 -0
- package/skills/migrate-core/FILES.md +26 -0
- package/skills/migrate-core/SKILL.md +484 -0
- package/skills/migrate-core/references/README.md +64 -0
- package/skills/migrate-core/references/flow/arkts-vector-gate.md +55 -0
- package/skills/migrate-core/references/flow/build-error-patterns.md +52 -0
- package/skills/migrate-core/references/flow/conventions-template.md +244 -0
- package/skills/migrate-core/references/flow/navigation-migration.md +42 -0
- package/skills/migrate-core/references/flow/platform-api-guards.md +59 -0
- package/skills/migrate-core/references/flow/platform-model-gaps.md +43 -0
- package/skills/migrate-core/references/flow/resource-conversion.md +46 -0
- package/skills/migrate-core/references/flow/ui-layout-semantics.md +124 -0
- package/skills/migrate-core/references/flow/unit-breakdown.md +42 -0
- package/skills/migrate-core/references/host-capabilities.md +24 -0
- package/skills/migrate-core/references/topics/app-identity.md +214 -0
- package/skills/migrate-core/references/topics/env-doctor.md +245 -0
- package/skills/migrate-core/references/topics/i18n/README.md +458 -0
- package/skills/migrate-core/references/topics/i18n/references/code-examples.md +304 -0
- package/skills/migrate-core/references/topics/i18n/references/common-pitfalls.md +354 -0
- package/skills/migrate-core/references/topics/i18n/references/dynamic-language-switch.md +464 -0
- package/skills/migrate-core/references/topics/i18n/references/language-codes.md +104 -0
- package/skills/migrate-core/references/topics/icon-sizing.md +98 -0
- package/skills/migrate-core/references/topics/library-migration/README.md +234 -0
- package/skills/migrate-core/references/topics/library-migration/closed-source-sdk.md +128 -0
- package/skills/migrate-core/references/topics/library-migration/download-api-decision.md +84 -0
- package/skills/migrate-core/references/topics/library-migration/library-mapping-table.md +100 -0
- package/skills/migrate-core/references/topics/library-migration/napi-compile-guide.md +84 -0
- package/skills/migrate-core/references/topics/library-migration/ohpm-search-guide.md +73 -0
- package/skills/migrate-core/references/topics/library-migration/stdlib-mapping-table.md +34 -0
- package/skills/migrate-core/references/topics/resources/aar-decompile.md +25 -0
- package/skills/migrate-core/references/topics/resources/conversion-rules.md +625 -0
- package/skills/migrate-core/references/topics/resources/dependency-analysis-rules.md +328 -0
- package/skills/migrate-core/references/topics/resources/material-design-icons.md +173 -0
- package/skills/migrate-core/references/topics/resources/svg-fix-patterns.md +175 -0
- package/skills/migrate-core/references/topics/resources/xml-drawable-to-svg-rules.md +513 -0
- package/skills/migrate-core/references/topics/system-capabilities/README.md +331 -0
- package/skills/migrate-core/references/topics/system-capabilities/avplayer-guide.md +161 -0
- package/skills/migrate-core/references/topics/system-capabilities/background-tasks.md +403 -0
- package/skills/migrate-core/references/topics/system-capabilities/browser-intent.md +121 -0
- package/skills/migrate-core/references/topics/system-capabilities/camera-picker.md +118 -0
- package/skills/migrate-core/references/topics/system-capabilities/document-picker.md +246 -0
- package/skills/migrate-core/references/topics/system-capabilities/file-utils.md +131 -0
- package/skills/migrate-core/references/topics/system-capabilities/permission-helper.md +112 -0
- package/skills/migrate-core/references/topics/system-capabilities/photo-access-helper.md +208 -0
- package/skills/migrate-core/references/topics/system-capabilities/print-management.md +213 -0
- package/skills/migrate-core/references/topics/system-capabilities/share-panel.md +177 -0
- package/skills/migrate-core/references/topics/system-capabilities/system-settings.md +322 -0
- package/skills/migrate-core/references/topics/system-capabilities/telephony-dial.md +49 -0
- package/skills/migrate-core/references/topics/system-capabilities/video-playback.md +42 -0
- package/skills/migrate-core/references/topics/system-capabilities/webview-patterns.md +38 -0
- package/skills/migrate-core/references/topics/ui-alignment/README.md +344 -0
- package/skills/migrate-core/references/topics/ui-alignment/references/dark-mode.md +47 -0
- package/skills/migrate-core/references/topics/ui-alignment/references/layout-mapping.md +301 -0
- package/skills/migrate-core/references/topics/ui-alignment/references/visual-patterns.md +411 -0
- package/skills/migrate-core/scripts/closure/check-anchors.mjs +186 -0
- package/skills/migrate-core/scripts/closure/check-api-guards.mjs +175 -0
- package/skills/migrate-core/scripts/closure/check-consumers.mjs +301 -0
- package/skills/migrate-core/scripts/closure/check-permissions.mjs +165 -0
- package/skills/migrate-core/scripts/closure/check-resources.mjs +130 -0
- package/skills/migrate-core/scripts/closure/check-routes.mjs +527 -0
- package/skills/migrate-core/scripts/closure/check-safearea.mjs +122 -0
- package/skills/migrate-core/scripts/closure/check-stubs.mjs +69 -0
- package/skills/migrate-core/scripts/closure/closure-suite.mjs +256 -0
- package/skills/migrate-core/scripts/closure/idioms.json +105 -0
- package/skills/migrate-core/scripts/convert/convert-resources.mjs +437 -0
- package/skills/migrate-core/scripts/feasibility/feasibility.mjs +235 -0
- package/skills/migrate-core/scripts/feasibility/tables/cross-platform.json +11 -0
- package/skills/migrate-core/scripts/feasibility/tables/deprecated-api.json +10 -0
- package/skills/migrate-core/scripts/feasibility/tables/imported-arkts-core.json +425 -0
- package/skills/migrate-core/scripts/feasibility/tables/lib-equivalence.json +206 -0
- package/skills/migrate-core/scripts/feasibility/tables/system-capabilities.json +22 -0
- package/skills/migrate-core/scripts/front.mjs +107 -0
- package/skills/migrate-core/scripts/interface/ark-extract.mjs +172 -0
- package/skills/migrate-core/scripts/interface/interface.mjs +152 -0
- package/skills/migrate-core/scripts/ledger/ledger.mjs +383 -0
- package/skills/migrate-core/scripts/ledger/parse-cards.mjs +98 -0
- package/skills/migrate-core/scripts/lib/literals.mjs +37 -0
- package/skills/migrate-core/scripts/lib/scan.mjs +315 -0
- package/skills/migrate-core/scripts/smoke/align-sdk.mjs +118 -0
- package/skills/migrate-core/scripts/smoke/ensure-sign.mjs +53 -0
- package/skills/migrate-core/scripts/smoke/smoke.mjs +238 -0
- package/skills/migrate-core/scripts/smoke/verdict.mjs +31 -0
- package/skills/migrate-core/scripts/smoke/walk.mjs +480 -0
- package/skills/migrate-core/scripts/transpile/mapping.json +76 -0
- package/skills/migrate-core/scripts/transpile/transpile-layout.mjs +404 -0
- package/skills/migrate-core/scripts/vectors/run-arkts-vectors.mjs +107 -0
- package/skills/migrate-core/scripts/vectors/setup-arkts-test.mjs +90 -0
- package/skills/migrate-core/scripts/wire/extractors.mjs +258 -0
- package/skills/migrate-core/scripts/wire/wire-routes.mjs +507 -0
- package/skills/migrate-core/templates/acceptance.js +365 -0
- package/skills/migrate-core/templates/explore.js +86 -0
- package/skills/migrate-core/templates/implement.js +211 -0
- package/skills/migrate-core/templates/mig_slices.js +491 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ledger.mjs — 还原台账(全流程唯一状态,单一写手)。
|
|
4
|
+
*
|
|
5
|
+
* 吃掉四本碎账:plan 单元表 + 功能清单 + 门历史 + 债务/降级记录。人读的计划与最终报告
|
|
6
|
+
* 都是它的投影(render 子命令),人不手改台账文件——只经本工具写入,每次写入带历史。
|
|
7
|
+
*
|
|
8
|
+
* 存储:<dir>/ledger.json(单文件,条目带 history[];写入原子:tmp+rename)。
|
|
9
|
+
* 条目集合:pages / features / slices / degradations / debts;门历史:gates[]。
|
|
10
|
+
* AC 四态纪律:untested / passed(需 evidence) / failed / waived(需 reason);
|
|
11
|
+
* slices 状态:planned / implementing / checked / accepted / failed / waived。
|
|
12
|
+
*
|
|
13
|
+
* 子命令:
|
|
14
|
+
* init --dir D --app 名称 [--src path --dst path]
|
|
15
|
+
* add --dir D --type pages|features|slices|degradations|debts --file <JSON 文件>(对象或数组;--json '<…>' 仅 POSIX shell 可用,Windows 一律 --file)
|
|
16
|
+
* status --dir D --type T --id X --to 状态 [--evidence "..."] [--reason "..."] [--layer static|device]
|
|
17
|
+
* --layer 记证据来源:static=读码判断(验收代理)、device=真机走查(4b);device 覆盖 static,static 不许改写 device 的结论
|
|
18
|
+
* gate --dir D --name 名称 --ok true|false [--evidence "..."] [--stage slice|integration|final]
|
|
19
|
+
* list --dir D --type T [--kind K] [--status S] # 列条目(如 4b 待验:--type features --kind on_device --status untested)
|
|
20
|
+
* render --dir D [--out report.md] [--anchors spec/closure/anchors.json] [--walks spec/4b] [--scenarios spec/4b/scenarios.txt]
|
|
21
|
+
* # 投影:进度+四态(按证据层分列)+门历史+降级(含待裁决)+债务;--anchors 追加 PRD 逐行覆盖对照表;
|
|
22
|
+
* # --walks 读 4b 计划/报告目录,按计划里的 scenarios 字段汇总"场景走查"表(PRD 栏由此出,不手写);
|
|
23
|
+
* # 分母取 --scenarios(缺省 <walks>/scenarios.txt:4b 第 0 条枚举的场景全集,一行一个),没有该文件时分母退化为计划提到的并明示
|
|
24
|
+
* verify --dir D [--sdk auto|<SDK 根>] [--require-gates a,b] # 自检:evidence/reason 纪律、状态合法性、
|
|
25
|
+
* # 降级无待裁决(pending)、阻塞债(blk-*)已清、--require-gates 指定的门有记录;
|
|
26
|
+
* # --sdk:kind=no_equiv 的降级其 symbols 列出的 API 名在 SDK d.ts 里有声明 → 违规("无等价"不成立,退回实现)
|
|
27
|
+
* 条目字段:features{id,kind:ui|backend|on_device,note,anchor:文件:行,literals[],status,evidence,layer,reason}
|
|
28
|
+
* slices{id,title,targetPath,deps[],route,expectation,featureIds[],selfTest,status,evidence}
|
|
29
|
+
* degradations{id,note,disposition,kind:no_equiv|approx|policy|source_stub|library,symbols[],probe,status:pending|open|rejected}
|
|
30
|
+
* (实现期登记的降级 status=pending,裁决后 open=成立 / rejected=退回实现;验收只把 open 的当"已登记")
|
|
31
|
+
* 退出码:0 成功 / 1 纪律违规(verify)/ 2 用法或状态机错误
|
|
32
|
+
*/
|
|
33
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync, existsSync, copyFileSync, readdirSync, statSync, rmSync } from 'node:fs';
|
|
34
|
+
import { join, isAbsolute, relative, dirname, resolve } from 'node:path';
|
|
35
|
+
|
|
36
|
+
const argv = process.argv.slice(2);
|
|
37
|
+
if (argv.includes('--help')) { console.log(readFileSync(new URL(import.meta.url), 'utf8').split('\n').filter(l => /^ \* /.test(l)).map(l => l.slice(3)).join('\n')); process.exit(0); }
|
|
38
|
+
// 子命令位置无关:允许 `ledger.mjs --dir X status …`(模板的 ledgerCmd 前缀形态)
|
|
39
|
+
const CMDS = ['init', 'add', 'status', 'gate', 'render', 'verify', 'list'];
|
|
40
|
+
const cmd = argv.find(a => CMDS.includes(a));
|
|
41
|
+
const argOf = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : d; };
|
|
42
|
+
const DIR = argOf('--dir', null);
|
|
43
|
+
if (!cmd || !DIR) { console.error('usage: ledger.mjs <init|add|status|gate|render|verify> --dir <ledger dir> ...'); process.exit(2); }
|
|
44
|
+
const FILE = join(DIR, 'ledger.json');
|
|
45
|
+
const now = () => new Date().toISOString();
|
|
46
|
+
|
|
47
|
+
const VALID = {
|
|
48
|
+
types: ['pages', 'features', 'slices', 'degradations', 'debts'],
|
|
49
|
+
sliceStatus: ['planned', 'implementing', 'checked', 'accepted', 'failed', 'waived'],
|
|
50
|
+
acStatus: ['untested', 'passed', 'failed', 'waived'],
|
|
51
|
+
degStatus: ['pending', 'open', 'rejected'],
|
|
52
|
+
degKind: ['no_equiv', 'approx', 'policy', 'source_stub', 'library'],
|
|
53
|
+
};
|
|
54
|
+
// 单一写手的并发保护:并发 worker(切片流水 DW_CONCURRENCY>1)同时 load→改→save 会互相覆盖(丢更新且无任何报错)。
|
|
55
|
+
// 锁 = 目录(mkdir 在各平台原子),load 时取、save/退出时放;等锁最多 8 s,超过 30 s 的陈旧锁视为持有者已死、直接接管。
|
|
56
|
+
const LOCK = FILE + '.lock';
|
|
57
|
+
let held = false;
|
|
58
|
+
function lock() {
|
|
59
|
+
if (held) return;
|
|
60
|
+
const t0 = Date.now();
|
|
61
|
+
for (;;) {
|
|
62
|
+
try { mkdirSync(LOCK); held = true; return; } catch (e) {
|
|
63
|
+
if (e.code !== 'EEXIST') throw e;
|
|
64
|
+
try { if (Date.now() - statSync(LOCK).mtimeMs > 30000) { rmSync(LOCK, { recursive: true, force: true }); continue; } } catch { /* 刚被放掉 */ }
|
|
65
|
+
if (Date.now() - t0 > 8000) { console.error(`台账被锁超过 8 s(${LOCK})——另一写手卡住?确认后删除该目录重试`); process.exit(3); }
|
|
66
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 40);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function unlock() { if (held) { try { rmSync(LOCK, { recursive: true, force: true }); } catch { /* 已放 */ } held = false; } }
|
|
71
|
+
process.on('exit', unlock);
|
|
72
|
+
const READ_ONLY = new Set(['list', 'render', 'verify']); // 只读命令读完即放锁——verify --sdk 扫 SDK 可能数秒,不许卡住并发写手
|
|
73
|
+
function load() {
|
|
74
|
+
if (!existsSync(FILE)) { console.error(`台账不存在:${FILE}(先 init)`); process.exit(2); }
|
|
75
|
+
lock();
|
|
76
|
+
const l = JSON.parse(readFileSync(FILE, 'utf8'));
|
|
77
|
+
if (READ_ONLY.has(cmd)) unlock();
|
|
78
|
+
return l;
|
|
79
|
+
}
|
|
80
|
+
function save(l) {
|
|
81
|
+
l.updatedAt = now();
|
|
82
|
+
const tmp = FILE + '.tmp';
|
|
83
|
+
writeFileSync(tmp, JSON.stringify(l, null, 2));
|
|
84
|
+
renameSync(tmp, FILE);
|
|
85
|
+
unlock();
|
|
86
|
+
// 存盘即备份:账本对"误删重建"也可恢复(实证:模型中途重建 ledger 抹掉进度)。
|
|
87
|
+
// 备份只在有实质进度时更新,避免一个空 init 就把好账本的备份覆盖成空。
|
|
88
|
+
try {
|
|
89
|
+
const hasProgress = (l.features || []).some(f => f.status !== 'untested') || (l.slices || []).some(s => s.status !== 'planned');
|
|
90
|
+
if (hasProgress) copyFileSync(FILE, FILE + '.bak');
|
|
91
|
+
} catch { /* 备份失败不阻断主写入 */ }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (cmd === 'init') {
|
|
95
|
+
mkdirSync(DIR, { recursive: true });
|
|
96
|
+
if (existsSync(FILE)) { console.error('台账已存在,拒绝覆盖(要改状态用 status,不要 init)'); process.exit(2); }
|
|
97
|
+
// 见到有进度的备份=账本曾存在被删,拒绝空 init 覆盖历史(防"删了重建"抹进度)
|
|
98
|
+
if (existsSync(FILE + '.bak') && !argv.includes('--force')) {
|
|
99
|
+
console.error(`检测到 ${FILE}.bak(账本曾有进度)——疑似误删重建。恢复请 cp "${FILE}.bak" "${FILE}";确要全新重建加 --force`);
|
|
100
|
+
process.exit(2);
|
|
101
|
+
}
|
|
102
|
+
// src/dst 强制相对化(相对 cwd=工作区根)——台账随仓分发/搬运,绝对路径是分发缺陷
|
|
103
|
+
const relIfAbs = (p) => (p && isAbsolute(p) ? (relative(process.cwd(), p) || '.') : p);
|
|
104
|
+
save({ app: argOf('--app', 'unknown'), src: relIfAbs(argOf('--src', '')), dst: relIfAbs(argOf('--dst', '')), createdAt: now(), pages: [], features: [], slices: [], degradations: [], debts: [], gates: [] });
|
|
105
|
+
console.log(`[ledger] init ${FILE}`);
|
|
106
|
+
} else if (cmd === 'add') {
|
|
107
|
+
const l = load();
|
|
108
|
+
const type = argOf('--type');
|
|
109
|
+
if (!VALID.types.includes(type)) { console.error(`type 须为 ${VALID.types.join('|')}`); process.exit(2); }
|
|
110
|
+
let items = JSON.parse(argOf('--json', argOf('--file', null) ? readFileSync(argOf('--file'), 'utf8') : 'null'));
|
|
111
|
+
if (!items) { console.error('缺 --json 或 --file'); process.exit(2); }
|
|
112
|
+
if (!Array.isArray(items)) items = [items];
|
|
113
|
+
// 同 id 且 note 相同 = 同一条目重登(切片重跑/续跑把同一批偏差再报一遍,实证一轮 43 次整批写失败)→ 跳过不报错;
|
|
114
|
+
// 同 id 不同 note 才是真冲突,整批拒绝(保持原子性)
|
|
115
|
+
// 幂等只对"登记型"条目(degradations/debts):同 id 同 note 视为重登跳过;slices/pages/features 重复 id 仍是错误
|
|
116
|
+
const idem = type === 'degradations' || type === 'debts';
|
|
117
|
+
const same = (a, b) => String(a.note || '') === String(b.note || '');
|
|
118
|
+
const dup = [];
|
|
119
|
+
for (const it of items) {
|
|
120
|
+
if (!it.id && type === 'pages' && it.route) it.id = String(it.route); // 路由表(wiring-report)直接入账:id 缺省 = route
|
|
121
|
+
if (!it.id) { console.error(`条目缺 id: ${JSON.stringify(it).slice(0, 80)}`); process.exit(2); }
|
|
122
|
+
const ex = l[type].find(x => x.id === it.id);
|
|
123
|
+
if (ex && !(idem && same(ex, it))) { console.error(`重复 id: ${it.id}${idem ? '(已有条目 note 不同,改 id 或先删)' : ''}`); process.exit(2); }
|
|
124
|
+
if (type === 'degradations') {
|
|
125
|
+
if (it.kind !== undefined && !VALID.degKind.includes(it.kind)) { console.error(`降级 ${it.id} 的 kind 非法:${it.kind}(须为 ${VALID.degKind.join('|')})`); process.exit(2); }
|
|
126
|
+
if (it.status !== undefined && !VALID.degStatus.includes(it.status)) { console.error(`降级 ${it.id} 的 status 非法:${it.status}`); process.exit(2); }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
let added = 0;
|
|
130
|
+
for (const it of items) {
|
|
131
|
+
if (l[type].some(x => x.id === it.id)) { dup.push(it.id); continue; }
|
|
132
|
+
it.status = it.status || (type === 'slices' ? 'planned' : type === 'features' ? 'untested' : 'open');
|
|
133
|
+
it.history = [{ at: now(), to: it.status, note: 'add' }];
|
|
134
|
+
l[type].push(it); added++;
|
|
135
|
+
}
|
|
136
|
+
save(l);
|
|
137
|
+
console.log(`[ledger] +${added} ${type}(共 ${l[type].length})${dup.length ? `,${dup.length} 条同 id 同 note 已存在跳过:${dup.slice(0, 5).join(',')}` : ''}`);
|
|
138
|
+
} else if (cmd === 'status') {
|
|
139
|
+
const l = load();
|
|
140
|
+
const type = argOf('--type'), to = argOf('--to');
|
|
141
|
+
// --id 单个 或 --ids 逗号批量(切片验收同步过账断言用)
|
|
142
|
+
const ids = argOf('--ids', null) ? argOf('--ids').split(',').map(s => s.trim()).filter(Boolean) : [argOf('--id')];
|
|
143
|
+
const missing = ids.filter(id => !(l[type] || []).some(x => x.id === id));
|
|
144
|
+
if (missing.length) { console.error(`找不到 ${type}/${missing.join(',')}`); process.exit(2); }
|
|
145
|
+
// --skip-kind:批量过账时按 kind 跳过(如 on_device 断言不得在静态验收标 passed)
|
|
146
|
+
const skipKind = argOf('--skip-kind', null);
|
|
147
|
+
const skipped = [];
|
|
148
|
+
for (const id of ids) {
|
|
149
|
+
const it = (l[type] || []).find(x => x.id === id);
|
|
150
|
+
if (skipKind && (it.kind || '') === skipKind) { skipped.push(id); continue; }
|
|
151
|
+
const valid = type === 'slices' ? VALID.sliceStatus : type === 'features' ? VALID.acStatus : type === 'degradations' ? VALID.degStatus : null;
|
|
152
|
+
if (valid && !valid.includes(to)) { console.error(`非法状态 ${to},须为 ${valid.join('|')}`); process.exit(2); }
|
|
153
|
+
const evidence = argOf('--evidence', null), reason = argOf('--reason', null), layer = argOf('--layer', null);
|
|
154
|
+
if ((to === 'passed' || to === 'accepted' || to === 'checked') && !evidence) { console.error(`${to} 必须带 --evidence(无证据的通过按失败处理)`); process.exit(2); }
|
|
155
|
+
if (to === 'waived' && !reason) { console.error('waived 必须带 --reason'); process.exit(2); }
|
|
156
|
+
if (layer && !['static', 'device'].includes(layer)) { console.error('--layer 只接受 static|device'); process.exit(2); }
|
|
157
|
+
// 真机过账的证据必须可回溯:--evidence 里须含 <走查报告.report.json>#<步号>,且该步存在、ok 且未跳过(报告由 walk.mjs run --out 落盘)
|
|
158
|
+
// ——实证一轮 25/28 份计划是"直达→存活→截图"骨架、on_device 真机过账 4/87,而"passed"全凭模型转述;账要指到步,不指到人
|
|
159
|
+
if (type === 'features' && layer === 'device' && to === 'passed') {
|
|
160
|
+
const refs = [...String(evidence || '').matchAll(/([^\s"'<>|,;,;]+\.report\.json)#(\d+)/g)];
|
|
161
|
+
if (!refs.length) { console.error(`真机过账 ${id} 的 --evidence 须指向走查报告的步:<路径>.report.json#<步号>(如 spec/4b/flow-cart.report.json#7),收到:${String(evidence || '').slice(0, 80)}`); process.exit(2); }
|
|
162
|
+
let okStep = false, why = '';
|
|
163
|
+
for (const [, rp, si] of refs) {
|
|
164
|
+
let rep = null; try { rep = JSON.parse(readFileSync(resolve(rp), 'utf8')); } catch (e) { why = `报告读不到 ${rp}:${e.message}`; continue; }
|
|
165
|
+
const st = (rep.steps || [])[Number(si)];
|
|
166
|
+
if (!st) { why = `${rp} 无第 ${si} 步`; continue; }
|
|
167
|
+
if (st.skipped || st.ok !== true) { why = `${rp}#${si} 该步 ${st.skipped ? '被跳过' : '未通过'}(${st.op} ${String(st.detail || '').slice(0, 60)})`; continue; }
|
|
168
|
+
okStep = true; break;
|
|
169
|
+
}
|
|
170
|
+
if (!okStep) { console.error(`真机过账 ${id} 的证据步不成立:${why}`); process.exit(2); }
|
|
171
|
+
}
|
|
172
|
+
// 证据分层:真机结论(device)不许被读码判断(static)改写——4b 判 failed 的断言,验收代理再判 passed 也不算
|
|
173
|
+
if (type === 'features' && it.layer === 'device' && layer !== 'device' && to !== it.status && to !== 'waived') { skipped.push(`${id}(device 结论保留)`); continue; }
|
|
174
|
+
it.status = to;
|
|
175
|
+
if (evidence) it.evidence = evidence;
|
|
176
|
+
// 证据可定位性(静态 passed):证据文本里须有真实存在的 文件:行 / 走查报告#步 / 闭合报告;纯散文记 false,verify/render 按数报(不拒收——拒收要等验收模板端到端后再收紧)
|
|
177
|
+
if (type === 'features' && to === 'passed' && layer !== 'device') {
|
|
178
|
+
const ev = String(evidence || '');
|
|
179
|
+
const roots = [process.cwd(), resolve(DIR, '..', '..')];
|
|
180
|
+
// 裸文件名(判词常写 `MainPage.ets:174` 不带目录):在工程源码树里按文件名唯一命中也算可定位;同名多个则不算(歧义)
|
|
181
|
+
const byBase = (() => { let m = null; return (b) => { if (!m) { m = new Map(); const walk = (d, depth) => { if (depth > 8) return; let ents = []; try { ents = readdirSync(d, { withFileTypes: true }); } catch { return; } for (const e of ents) { if (['node_modules', 'oh_modules', '.git', 'build', '.deveco', '.opencode', 'spec'].includes(e.name)) continue; const q = join(d, e.name); if (e.isDirectory()) walk(q, depth + 1); else m.set(e.name, (m.get(e.name) || 0) + 1); } }; walk(resolve(DIR, '..', '..'), 0); } return m.get(b) === 1; }; })();
|
|
182
|
+
const here = (p) => roots.some(r => existsSync(isAbsolute(p) ? p : join(r, p))) || (!/[\\/]/.test(p) && byBase(p));
|
|
183
|
+
// 判据只有一条:证据里某个 路径[:行|#步] 的路径在工程里真实存在——不限扩展名(存在性就是判据)
|
|
184
|
+
const toks = [...ev.matchAll(/((?:[A-Za-z]:)?[^\s"'<>|,;,;()():#]+)(?::\d+|#\d+)?/g)].map(m => m[1]).filter(t => /[./\\]/.test(t)); // 允许 Windows 盘符前缀
|
|
185
|
+
it.evidenceLocatable = toks.some(here);
|
|
186
|
+
}
|
|
187
|
+
if (reason) it.reason = reason;
|
|
188
|
+
if (layer) it.layer = layer;
|
|
189
|
+
if (!Array.isArray(it.history)) it.history = []; // 旧账/手改条目缺 history 不许崩
|
|
190
|
+
it.history.push({ at: now(), to, evidence: evidence || undefined, reason: reason || undefined, layer: layer || undefined });
|
|
191
|
+
}
|
|
192
|
+
save(l);
|
|
193
|
+
const done = ids.filter(i => !skipped.includes(i));
|
|
194
|
+
console.log(`[ledger] ${type}/${done.join(',') || '(无)'} -> ${to}${skipped.length ? `(跳过:${skipped.join(',')}${skipKind ? `,--skip-kind ${skipKind}` : ''})` : ''}`);
|
|
195
|
+
} else if (cmd === 'gate') {
|
|
196
|
+
const l = load();
|
|
197
|
+
const okRaw = argOf('--ok', null);
|
|
198
|
+
if (okRaw !== 'true' && okRaw !== 'false') { console.error(`--ok 只接受 true|false(收到 ${okRaw})——拼错不许静默记 FAIL 污染门历史`); process.exit(2); }
|
|
199
|
+
const ok = okRaw === 'true';
|
|
200
|
+
l.gates.push({ at: now(), name: argOf('--name', '?'), stage: argOf('--stage', 'integration'), ok, evidence: argOf('--evidence', '') });
|
|
201
|
+
save(l);
|
|
202
|
+
console.log(`[ledger] gate ${argOf('--name')} ${ok ? 'PASS' : 'FAIL'}`);
|
|
203
|
+
} else if (cmd === 'list') {
|
|
204
|
+
// 机读清单查询(4b 走查清单派生等):按 type/kind/status 过滤,行式输出 id\tkind\tstatus\ttitle
|
|
205
|
+
const l = load();
|
|
206
|
+
const type = argOf('--type', 'features');
|
|
207
|
+
if (!VALID.types.includes(type)) { console.error(`--type 只接受 ${VALID.types.join('|')}`); process.exit(2); }
|
|
208
|
+
let items = l[type] || [];
|
|
209
|
+
const kind = argOf('--kind', null), status = argOf('--status', null);
|
|
210
|
+
if (kind) items = items.filter(i => i.kind === kind);
|
|
211
|
+
if (status) items = items.filter(i => i.status === status);
|
|
212
|
+
for (const i of items) console.log(`${i.id}\t${i.kind || '-'}\t${i.status}\t${String(i.title || i.note || '').replace(/\s+/g, ' ').slice(0, 110)}`); // features 装账写的是 note
|
|
213
|
+
console.error(`[ledger] ${type} ${items.length} 条${kind ? ` kind=${kind}` : ''}${status ? ` status=${status}` : ''}`);
|
|
214
|
+
} else if (cmd === 'render') {
|
|
215
|
+
const l = load();
|
|
216
|
+
const cnt = (arr, s) => arr.filter(x => x.status === s).length;
|
|
217
|
+
const lines = [];
|
|
218
|
+
lines.push(`# ${l.app} 迁移台账投影`, '', `源:${l.src} 目标:${l.dst} 更新:${l.updatedAt}`, '');
|
|
219
|
+
lines.push(`## 切片进度`, '', `| 状态 | 数量 |`, `|---|---|`);
|
|
220
|
+
for (const s of VALID.sliceStatus) lines.push(`| ${s} | ${cnt(l.slices, s)} |`);
|
|
221
|
+
const kinds = [...new Set(l.features.map(f => f.kind || 'ui'))];
|
|
222
|
+
lines.push('', `## 功能断言四态(分子/分母 = ${cnt(l.features, 'passed')}/${l.features.length}${kinds.length > 1 ? ';' + kinds.map(k => k + '=' + l.features.filter(f => (f.kind || 'ui') === k).length).join(' ') : ''})`, '', `| 态 | 数量 |`, `|---|---|`);
|
|
223
|
+
for (const s of VALID.acStatus) lines.push(`| ${s} | ${cnt(l.features, s)} |`);
|
|
224
|
+
// 证据分层:passed 里多少是真机核过的、多少只是读码判断;failed 同理——账面不把静态判断冒充真机验证
|
|
225
|
+
const byLayer = (st) => { const xs = l.features.filter(f => f.status === st); const dev = xs.filter(f => f.layer === 'device').length; return `${dev} 真机 / ${xs.length - dev} 静态`; };
|
|
226
|
+
lines.push('', `证据来源:passed ${byLayer('passed')};failed ${byLayer('failed')}(静态=验收代理读码判断,真机=4b 走查)`);
|
|
227
|
+
{ const st = l.features.filter(f => f.status === 'passed' && f.layer !== 'device'); const loc = st.filter(f => f.evidenceLocatable === true).length; const unk = st.filter(f => f.evidenceLocatable === undefined).length; const prose = st.length - loc - unk;
|
|
228
|
+
if (st.length) lines.push(`静态 passed 证据可定位(文件:行 / 报告#步 真实存在):${loc}/${st.length}${unk ? `(${unk} 条旧账未记)` : ''}${prose ? ` ⚠ ${prose} 条为散文证据,不可回溯` : ''}`); }
|
|
229
|
+
const waivedNoReason = l.features.filter(f => f.status === 'waived' && !f.reason).length;
|
|
230
|
+
lines.push('', `## 门历史(${l.gates.length} 次)`, '');
|
|
231
|
+
for (const g of l.gates.slice(-20)) lines.push(`- ${g.at} [${g.stage}] ${g.name}: ${g.ok ? '✅' : '❌'} ${g.evidence}`);
|
|
232
|
+
lines.push('', `## 降级与债务`, '');
|
|
233
|
+
const pendingDeg = l.degradations.filter(d => d.status === 'pending').length;
|
|
234
|
+
if (pendingDeg) lines.push(`⚠ ${pendingDeg} 条降级待裁决(pending):验收不把它们当"已登记",收尾 verify 会拦`, '');
|
|
235
|
+
for (const d of l.degradations) lines.push(`- [降级${d.kind ? '·' + d.kind : ''}${d.status === 'pending' ? '·待裁决' : d.status === 'rejected' ? '·已退回' : ''}] ${d.id}: ${d.note || ''}(${d.disposition || '未定处置'}${d.probe ? `;查证:${String(d.probe).slice(0, 80)}` : ''})`);
|
|
236
|
+
for (const d of l.debts) lines.push(`- [债务] ${d.id}: ${d.note || ''} — ${d.status}`);
|
|
237
|
+
if (waivedNoReason) lines.push('', `⚠ ${waivedNoReason} 条豁免缺理由(verify 会拦)`);
|
|
238
|
+
// --anchors <check-anchors 的 --json 报告>:PRD 逐行覆盖对照表直接投影(行号→断言→断言状态),不再由模型手写
|
|
239
|
+
const anchorsFile = argOf('--anchors', null);
|
|
240
|
+
if (anchorsFile) {
|
|
241
|
+
try {
|
|
242
|
+
const rep = JSON.parse(readFileSync(anchorsFile, 'utf8'));
|
|
243
|
+
const table = rep.prdCoverage && Array.isArray(rep.prdCoverage.table) ? rep.prdCoverage.table : null;
|
|
244
|
+
if (table) {
|
|
245
|
+
const st = new Map(l.features.map(f => [f.id, f.status]));
|
|
246
|
+
const modeName = { anchor: '锚点', child: '随父项', section: '随标题', step: '随结果步骤', literal: '字面量对上', uncovered: '未覆盖' };
|
|
247
|
+
const un = table.filter(r => r.mode === 'uncovered').length;
|
|
248
|
+
lines.push('', `## PRD 覆盖对照(${rep.prdCoverage.prd || 'PRD'}:需求行 ${table.length},未覆盖 ${un})`, '', `| 行 | 需求 | 覆盖方式 | 断言(状态) |`, `|---|---|---|---|`);
|
|
249
|
+
for (const r of table) lines.push(`| ${r.line} | ${String(r.text).replace(/\|/g, '\\|').slice(0, 70)} | ${modeName[r.mode] || r.mode} | ${r.by.length ? r.by.map(id => `${id}(${st.get(id) || '?'})`).join(' ') : (r.mode === 'uncovered' ? '—(需豁免票或补卡)' : '')} |`);
|
|
250
|
+
} else lines.push('', `⚠ --anchors 报告无 prdCoverage(跑 check-anchors 时未定位到 PRD)`);
|
|
251
|
+
} catch (e) { lines.push('', `⚠ --anchors 报告不可读:${e.message}`); }
|
|
252
|
+
}
|
|
253
|
+
// --walks <4b 目录>:读 *.json 计划的 scenarios 字段 + 同名 *.report.json 的结果,汇总场景走查表——"场景 N/M"由此出,不由模型手写
|
|
254
|
+
const walksDir = argOf('--walks', null);
|
|
255
|
+
if (walksDir) {
|
|
256
|
+
try {
|
|
257
|
+
const plans = readdirSync(walksDir).filter(f => f.endsWith('.json') && !f.endsWith('.report.json'));
|
|
258
|
+
const rows = new Map(); // scenario → { plans: [], ok: bool|null }
|
|
259
|
+
// 分母:--scenarios <文件>(4b 第 0 条从 PRD 枚举的全集,一行一个 id 或 JSON 数组);不给则退回"计划里提到的"并明示
|
|
260
|
+
const scenFile = argOf('--scenarios', walksDir ? join(walksDir, 'scenarios.txt') : null);
|
|
261
|
+
let universe = null;
|
|
262
|
+
if (scenFile && existsSync(scenFile)) { const t = readFileSync(scenFile, 'utf8').trim(); try { universe = JSON.parse(t).map(String); } catch { universe = t.split(/\r?\n/).map(x => x.trim()).filter(Boolean); } for (const sid of universe) if (!rows.has(sid)) rows.set(sid, { plans: [], ok: null }); }
|
|
263
|
+
let planned = 0, ran = 0, okPlans = 0;
|
|
264
|
+
// 计划形态按步骤结构判:有交互/断言步(tap/type/swipe/scrollUntil/expect/wait)= 流;只有 start/alive/probe/shot = 骨架(出现率证据,不算"走过")
|
|
265
|
+
const INTERACT = new Set(['tap', 'type', 'swipe', 'scrollUntil', 'expect', 'wait']);
|
|
266
|
+
let flows = 0, flowsRan = 0, flowsOk = 0, skeletons = 0; const seenTexts = new Set(), seenIds = new Set(); let streakWarn = [];
|
|
267
|
+
for (const pf of plans) {
|
|
268
|
+
let plan; try { plan = JSON.parse(readFileSync(join(walksDir, pf), 'utf8')); } catch { continue; }
|
|
269
|
+
const isFlow = plan.kind !== 'skeleton' && (plan.steps || []).some(st => INTERACT.has(st.op));
|
|
270
|
+
const rf0 = join(walksDir, pf.replace(/\.json$/, '.report.json'));
|
|
271
|
+
let rep0 = null; try { rep0 = existsSync(rf0) ? JSON.parse(readFileSync(rf0, 'utf8')) : null; } catch { rep0 = null; }
|
|
272
|
+
if (rep0 && rep0.seen) { for (const t of rep0.seen.texts || []) seenTexts.add(t); for (const t of rep0.seen.ids || []) seenIds.add(t); }
|
|
273
|
+
if (rep0 && rep0.failStreak >= 2) streakWarn.push(`${pf.replace(/\.json$/, '')}×${rep0.failStreak}`);
|
|
274
|
+
if (isFlow) { flows++; if (rep0) { flowsRan++; if (rep0.ok) flowsOk++; } } else skeletons++;
|
|
275
|
+
const scen = Array.isArray(plan.scenarios) ? plan.scenarios.map(String) : [];
|
|
276
|
+
if (!scen.length) continue;
|
|
277
|
+
planned++;
|
|
278
|
+
const rf = join(walksDir, pf.replace(/\.json$/, '.report.json'));
|
|
279
|
+
let rep = null; try { rep = existsSync(rf) ? JSON.parse(readFileSync(rf, 'utf8')) : null; } catch { rep = null; }
|
|
280
|
+
if (rep) { ran++; if (rep.ok) okPlans++; }
|
|
281
|
+
const noChange = rep && rep.tapsNoChange ? rep.tapsNoChange : 0;
|
|
282
|
+
for (const sid of scen) { const r = rows.get(sid) || { plans: [], ok: null }; r.plans.push(pf.replace(/\.json$/, '')); if (rep) r.ok = r.ok === false ? false : (rep.ok && !noChange); rows.set(sid, r); }
|
|
283
|
+
}
|
|
284
|
+
const ids = [...rows.keys()].sort((a, b) => (parseInt(a.replace(/\D/g, ''), 10) || 0) - (parseInt(b.replace(/\D/g, ''), 10) || 0) || a.localeCompare(b));
|
|
285
|
+
const walked = ids.filter(i => rows.get(i).ok !== null), passed = ids.filter(i => rows.get(i).ok === true);
|
|
286
|
+
const unplanned = ids.filter(i => !rows.get(i).plans.length);
|
|
287
|
+
lines.push('', `## 场景走查(${walksDir}:计划 ${planned} 份含场景、已跑 ${ran}、计划全过 ${okPlans};场景 ${walked.length}/${ids.length} 已走、${passed.length} 通过${universe ? `,分母来自 ${scenFile}` : ',⚠ 分母只是计划里提到的场景(缺 scenarios.txt 全集)'}${unplanned.length ? `,${unplanned.length} 条未列入任何计划` : ''})`, '', `| 场景 | 结果 | 所在计划 |`, `|---|---|---|`);
|
|
288
|
+
for (const i of ids) { const r = rows.get(i); lines.push(`| ${i} | ${!r.plans.length ? '未列入计划' : r.ok === null ? '未走' : r.ok ? '通过' : '未过(步失败或未预期的无变化点击)'} | ${r.plans.join(' ')} |`); }
|
|
289
|
+
if (!ids.length) lines.push('', `⚠ --walks 目录里没有带 scenarios 字段的计划——有 PRD 时每份流计划须写 "scenarios":["S1","S2"]`);
|
|
290
|
+
// 计划形态账:骨架计划只证明"可达+出现率",不算走查;流计划才算
|
|
291
|
+
lines.push('', `走查形态:流计划 ${flows} 份(已跑 ${flowsRan}、全过 ${flowsOk})· 骨架计划 ${skeletons} 份(直达/存活/出现率,不计"已走")${flows === 0 ? ' ⚠ 没有任何流计划——4b 没有验证过任何操作后的可观察结果' : ''}${streakWarn.length ? ` ⚠ 连续失败≥2 的计划:${streakWarn.join(' ')}(同一缺陷只许一次修+一次复验)` : ''}`);
|
|
292
|
+
// 真机可见性:静态 passed 的 ui 断言,其字面量是否在任一走查 dump 里出现过(seen = walk 报告全程见过的 text/id 并集)
|
|
293
|
+
if (seenTexts.size || seenIds.size) {
|
|
294
|
+
const nz = (x) => String(x).normalize('NFKC').toLowerCase();
|
|
295
|
+
const seenAll = [...seenTexts, ...seenIds].map(nz);
|
|
296
|
+
const staticUi = l.features.filter(f => f.status === 'passed' && f.layer !== 'device' && (f.kind || 'ui') === 'ui' && Array.isArray(f.literals) && f.literals.length);
|
|
297
|
+
const unseen = staticUi.filter(f => !f.literals.some(lit => { const q = nz(lit).replace(/[::].*$/, '').trim(); return q.length > 0 && seenAll.some(t => t.includes(q) || q.includes(t) && t.length >= 3); }));
|
|
298
|
+
lines.push('', `真机可见性(静态 passed 且带字面量的 ui 断言 ${staticUi.length} 条):字面量在走查中出现过 ${staticUi.length - unseen.length} 条 · 从未出现 ${unseen.length} 条${unseen.length ? `(静态判断、真机未见:${unseen.slice(0, 12).map(f => f.id).join(' ')}${unseen.length > 12 ? ' …' : ''})` : ''}`);
|
|
299
|
+
}
|
|
300
|
+
} catch (e) { lines.push('', `⚠ --walks 目录不可读:${e.message}`); }
|
|
301
|
+
}
|
|
302
|
+
// 源模块覆盖:按断言锚点的源路径归到 Gradle 模块(<模块>/src/… 的 <模块> 部分),模块目录存在但 0 条断言 = 未装账模块(范围漏项的结构信号,不理解内容)
|
|
303
|
+
{
|
|
304
|
+
const modOf = (a) => { const m = String(a || '').replace(/\\/g, '/').match(/^(.*?)\/src\//); return m ? m[1] : null; };
|
|
305
|
+
const perMod = new Map();
|
|
306
|
+
for (const f of l.features) { const mod = modOf(f.anchor); if (mod) perMod.set(mod, (perMod.get(mod) || 0) + 1); }
|
|
307
|
+
if (perMod.size) {
|
|
308
|
+
let allMods = null;
|
|
309
|
+
const srcRoot = l.src && existsSync(resolve(l.src)) ? resolve(l.src) : null;
|
|
310
|
+
if (srcRoot) { // 枚举源工程的 Gradle 模块:含 build.gradle(.kts) 且有 src/main 的目录(深度 ≤4)
|
|
311
|
+
allMods = [];
|
|
312
|
+
(function walk(d, rel, depth) { let ents; try { ents = readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
313
|
+
const names = new Set(ents.map(e => e.name));
|
|
314
|
+
if ((names.has('build.gradle') || names.has('build.gradle.kts')) && existsSync(join(d, 'src', 'main'))) allMods.push(rel);
|
|
315
|
+
if (depth >= 4) return;
|
|
316
|
+
for (const e of ents) if (e.isDirectory() && !/^(build|\.gradle|\.git|node_modules|\.idea)$/.test(e.name)) walk(join(d, e.name), rel ? `${rel}/${e.name}` : e.name, depth + 1); })(srcRoot, '', 0);
|
|
317
|
+
}
|
|
318
|
+
const rows = [...perMod.entries()].sort((a, b) => b[1] - a[1]);
|
|
319
|
+
const uncovered = allMods ? allMods.filter(m => m && !perMod.has(m)) : null;
|
|
320
|
+
lines.push('', `## 源模块覆盖(按断言锚点归模块${allMods ? `;源工程 ${allMods.length} 个 Gradle 模块` : ''})`, '', `| 模块 | 断言数 |`, `|---|---|`);
|
|
321
|
+
for (const [m, n] of rows) lines.push(`| ${m} | ${n} |`);
|
|
322
|
+
if (uncovered && uncovered.length) lines.push('', `⚠ 源工程有模块无任何断言锚定(范围漏项的结构信号,按"范围看源码"核对是否该迁):${uncovered.join(' ')}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const out = argOf('--out', null);
|
|
326
|
+
const md = lines.join('\n');
|
|
327
|
+
if (out) { mkdirSync(dirname(resolve(out)), { recursive: true }); writeFileSync(out, md); console.log(`[ledger] render -> ${out}`); } else console.log(md);
|
|
328
|
+
} else if (cmd === 'verify') {
|
|
329
|
+
const l = load();
|
|
330
|
+
const problems = []; const unlocatable = [];
|
|
331
|
+
for (const f of l.features) {
|
|
332
|
+
if (f.status === 'passed' && !f.evidence) problems.push(`feature ${f.id}: passed 无证据`);
|
|
333
|
+
if (f.status === 'passed' && f.layer !== 'device' && f.evidenceLocatable === false) unlocatable.push(f.id);
|
|
334
|
+
if (f.status === 'waived' && !f.reason) problems.push(`feature ${f.id}: waived 无理由`);
|
|
335
|
+
}
|
|
336
|
+
for (const s of l.slices) {
|
|
337
|
+
if (['accepted', 'checked'].includes(s.status) && !s.evidence) problems.push(`slice ${s.id}: ${s.status} 无证据`);
|
|
338
|
+
if (s.status === 'waived' && !s.reason) problems.push(`slice ${s.id}: waived 无理由`); // 对称:features 查了 slices 不查=纪律漏洞
|
|
339
|
+
}
|
|
340
|
+
// 降级纪律:实现期登记的 pending 必须裁决过(open/rejected);kind=no_equiv 必须写查证(probe)
|
|
341
|
+
for (const d of l.degradations || []) {
|
|
342
|
+
if (d.status === 'pending') problems.push(`degradation ${d.id}: 待裁决(pending)——核实后 status 置 open(成立)或 rejected(退回实现)`);
|
|
343
|
+
if (d.status && d.status !== 'rejected' && !d.kind) problems.push(`degradation ${d.id}: 缺 kind(no_equiv|approx|policy|source_stub|library)`);
|
|
344
|
+
if (d.kind === 'no_equiv' && !(Array.isArray(d.symbols) && d.symbols.length)) problems.push(`degradation ${d.id}: kind=no_equiv 但未列 symbols(在 SDK 里逐个查过、确认不存在的 API 名)`);
|
|
345
|
+
}
|
|
346
|
+
// --sdk <根>:no_equiv 的 probe 指名的符号若在 SDK d.ts 里存在,"无等价"不成立(确定性核查,不看措辞)
|
|
347
|
+
let sdkRoot = argOf('--sdk', null);
|
|
348
|
+
// 只看 OpenHarmony 公共 SDK 的声明目录(api/component/arkts/kits);hms 与 ets-loader 的副本不算
|
|
349
|
+
const ETS_SUB = ['api', 'component', 'arkts', 'kits'];
|
|
350
|
+
const asEtsRoot = (r) => { if (!r) return null; for (const c of [r, join(r, 'default', 'openharmony', 'ets'), join(r, 'openharmony', 'ets'), join(r, 'ets'), join(r, 'sdk', 'default', 'openharmony', 'ets')]) if (ETS_SUB.some(sd => existsSync(join(c, sd)))) return c; return null; };
|
|
351
|
+
if (sdkRoot === 'auto') {
|
|
352
|
+
// 与 oh-selfsign 同一组 DevEco 安装根候选(mac/Windows);环境变量可指向 sdk 根或 ets 目录,都归一到 ets 目录
|
|
353
|
+
const roots = [process.env.DEVECO_SDK_HOME, process.env.DEVECO_HOME, '/Applications/DevEco-Studio.app/Contents', process.env.PROGRAMFILES && join(process.env.PROGRAMFILES, 'Huawei', 'DevEco Studio'), 'C:/Program Files/Huawei/DevEco Studio'].filter(Boolean);
|
|
354
|
+
sdkRoot = roots.map(asEtsRoot).find(Boolean) || null;
|
|
355
|
+
if (!sdkRoot) console.log(' · --sdk auto 未找到 DevEco SDK(设 DEVECO_HOME 或 DEVECO_SDK_HOME),跳过符号核查');
|
|
356
|
+
} else if (sdkRoot) { const r = asEtsRoot(sdkRoot); if (!r) problems.push(`--sdk ${sdkRoot} 下找不到 api/component 声明目录(应指向 <DevEco>/sdk/default/openharmony/ets 或其上级)`); sdkRoot = r; }
|
|
357
|
+
if (sdkRoot) {
|
|
358
|
+
const dts = [];
|
|
359
|
+
for (const sd of ETS_SUB) (function walk(d, depth) { let ents; try { ents = readdirSync(d, { withFileTypes: true }); } catch { return; } for (const e of ents) { const p = join(d, e.name); if (e.isDirectory()) { if (depth < 6) walk(p, depth + 1); } else if (/\.d\.e?ts$/.test(e.name)) dts.push(p); } })(join(sdkRoot, sd), 0);
|
|
360
|
+
const cache = new Map(); const textOf = (f) => { if (!cache.has(f)) { try { cache.set(f, readFileSync(f, 'utf8')); } catch { cache.set(f, ''); } } return cache.get(f); };
|
|
361
|
+
// 只核 symbols 里显式列出的 API 名(不解析 probe 散文),且只认声明形态:class/interface/enum/type/function/const/namespace X、
|
|
362
|
+
// 成员 X( / X?: / X: / X<(注释里提到不算)
|
|
363
|
+
for (const d of (l.degradations || []).filter(x => x.kind === 'no_equiv' && Array.isArray(x.symbols) && x.symbols.length)) {
|
|
364
|
+
for (const sym of d.symbols.map(String)) {
|
|
365
|
+
const last = sym.split('.').pop().trim(); if (last.length < 3) continue;
|
|
366
|
+
const esc = last.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
367
|
+
const re = new RegExp(`(?:^|\\s)(?:export\\s+)?(?:declare\\s+)?(?:abstract\\s+)?(?:class|interface|enum|type|function|const|namespace|struct)\\s+${esc}\\b|^\\s+(?:readonly\\s+|static\\s+)?${esc}\\s*[?(:<]`, 'm');
|
|
368
|
+
const hit = dts.find(f => re.test(textOf(f)));
|
|
369
|
+
if (hit) { problems.push(`degradation ${d.id}: 自称无等价,但 symbols 里的 ${sym} 在 SDK 有声明(${relative(sdkRoot, hit)})——退回实现或改 kind`); break; }
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// 阻塞债与门记录(--require-gates a,b):收尾前该清的没清
|
|
374
|
+
for (const d of l.debts || []) if (/^blk-/.test(String(d.id)) && d.status === 'open') problems.push(`debt ${d.id}: 阻塞债仍 open(3b 收口应处置)`);
|
|
375
|
+
for (const g of (argOf('--require-gates', '') || '').split(',').map(x => x.trim()).filter(Boolean)) if (!l.gates.some(x => x.name === g)) problems.push(`gate ${g}: 无记录(该阶段没记门)`);
|
|
376
|
+
for (const p of problems) console.log(` ✗ ${p}`);
|
|
377
|
+
if (unlocatable.length) console.log(` · 静态 passed 证据不可定位 ${unlocatable.length} 条(证据里没有真实存在的 文件:行 / 走查报告#步 / 检查报告 json):${unlocatable.slice(0, 8).join(' ')}${unlocatable.length > 8 ? ' …' : ''}——验收证据须可回溯到代码位置`);
|
|
378
|
+
console.log(problems.length ? `[ledger] verify: ${problems.length} 条纪律违规` : '[ledger] verify: 账实纪律 ✓');
|
|
379
|
+
process.exit(problems.length ? 1 : 0);
|
|
380
|
+
} else {
|
|
381
|
+
console.error(`未知子命令 ${cmd}`);
|
|
382
|
+
process.exit(2);
|
|
383
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* parse-cards.mjs — 断言行机读装账(固定格式,不让模型每轮现写解析脚本)。
|
|
4
|
+
*
|
|
5
|
+
* 断言行(SKILL 2b 规定的机读格式,每条一行):
|
|
6
|
+
* - <ID> [ui|backend|on_device] 断言文本。锚点:<文件>:<行>
|
|
7
|
+
* 容错:锚点冒号全/半角、锚点缺失(记 warning 不丢条)、ID 域前缀任意大写字母+数字(DM-A01 / EDT-E20 式)。
|
|
8
|
+
字面量口径(scripts/lib/literals.mjs,三处共用):断言文本里反引号 / 「」 / “” 包住的 1–60 字为字面量,"id 为 X"(id: X / id=X)句式抽 X;
|
|
9
|
+
占位符不算(<…>、{…}、…、单个大写字母、N/M/X、M:SS/HH:MM/YYYY 样板);对账 NFKC+小写归一化,颜色 #RRGGBB 兼容 0xFFRRGGBB。
|
|
10
|
+
* 卡片文件(cards/*.md)的首个 `# ` 标题作 card 名,文件名作 domain;同 ID 重复只留首条并告警。
|
|
11
|
+
* 产出 features 数组(ledger add --type features --file 直接可用):{id, kind, note, anchor, card, domain, page:null, status:untested}
|
|
12
|
+
*
|
|
13
|
+
* 用法:node parse-cards.mjs --cards <目录> --out <features.json> [--json report] [--wiring <wiring-report.json>]
|
|
14
|
+
* [--project <鸿蒙工程根> [--prd <PRD 文件>]] ← 给了就顺带跑 PRD 覆盖差集与字面量预检(同一轮出结果)
|
|
15
|
+
* --wiring:接线报告的 appInit(Application.onCreate 调用 / ContentProvider / androidx.startup 初始化器)机械生成
|
|
16
|
+
* INIT-A01.. 断言(kind=backend,domain=app-init)——应用级初始化横切所有页面,卡片按页面/域提取时天然漏掉它。
|
|
17
|
+
* 退出码 0 / 2(零条或重复 ID)
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from 'node:fs';
|
|
20
|
+
import { join, basename, dirname, resolve } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { literalsOf } from '../lib/literals.mjs';
|
|
23
|
+
|
|
24
|
+
const argv = process.argv.slice(2);
|
|
25
|
+
if (argv.includes('--help')) { console.log(readFileSync(new URL(import.meta.url), 'utf8').split('\n').filter(l => /^ \* /.test(l)).map(l => l.slice(3)).join('\n')); process.exit(0); }
|
|
26
|
+
const argOf = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : d; };
|
|
27
|
+
const CARDS = argOf('--cards', null), OUT = argOf('--out', null), REPORT = argOf('--json', null), WIRING = argOf('--wiring', null);
|
|
28
|
+
const PROJECT = argOf('--project', null), PRD = argOf('--prd', null);
|
|
29
|
+
if (!CARDS || !OUT || !existsSync(CARDS)) { console.error('usage: parse-cards.mjs --cards <dir> --out <features.json> [--json report] [--wiring <wiring-report.json>] [--project <鸿蒙工程根> [--prd <PRD 文件>]]'); process.exit(2); }
|
|
30
|
+
|
|
31
|
+
const LINE = /^\s*[-*]\s+([A-Z][A-Z0-9]*-[A-Z]?\d+[A-Za-z0-9]*)\s+\[(ui|backend|on_device)\]\s*(.+?)\s*$/;
|
|
32
|
+
const ANCHOR = /[。.]?\s*锚点\s*[::]\s*(\S+?)\s*$/;
|
|
33
|
+
// 断言引用的字面量(稳定 id / 可见文本)机械抽取:反引号、「」、“” 包住的,以及 "id 为 X" 句式(口径在 lib/literals.mjs,
|
|
34
|
+
// 与闭合 check-anchors、walk plan 共用)——供闭合核实产物里真有(实证基准失败 11/13 是 PRD 明写的 id/文本没落地)。
|
|
35
|
+
const features = [], warnings = [], seen = new Map();
|
|
36
|
+
for (const f of readdirSync(CARDS).filter(x => x.endsWith('.md')).sort()) {
|
|
37
|
+
const text = readFileSync(join(CARDS, f), 'utf8');
|
|
38
|
+
const card = (text.match(/^#\s+(.+)$/m) || [])[1]?.trim() || basename(f, '.md');
|
|
39
|
+
const domain = basename(f, '.md');
|
|
40
|
+
let n = 0;
|
|
41
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
42
|
+
const m = raw.match(LINE); if (!m) continue;
|
|
43
|
+
const [, id, kind, rest] = m;
|
|
44
|
+
const am = rest.match(ANCHOR);
|
|
45
|
+
const note = (am ? rest.slice(0, am.index) : rest).replace(/[。.]\s*$/, '').trim();
|
|
46
|
+
const anchor = am ? am[1] : null;
|
|
47
|
+
if (!anchor) warnings.push(`${f}: ${id} 无锚点`);
|
|
48
|
+
if (seen.has(id)) { warnings.push(`${f}: ${id} 重复(首见于 ${seen.get(id)}),已跳过`); continue; }
|
|
49
|
+
seen.set(id, f); n++;
|
|
50
|
+
const literals = literalsOf(note);
|
|
51
|
+
features.push({ id, kind, note, anchor, card, domain, page: null, status: 'untested', ...(literals.length ? { literals } : {}) });
|
|
52
|
+
}
|
|
53
|
+
if (!n) warnings.push(`${f}: 未解析出断言行(格式须为 "- ID [kind] 文本。锚点:文件:行")`);
|
|
54
|
+
}
|
|
55
|
+
// ---- 接线报告 appInit → INIT 卡(机械派生,不靠模型记得)----
|
|
56
|
+
let initCount = 0;
|
|
57
|
+
if (WIRING) {
|
|
58
|
+
let w = null;
|
|
59
|
+
try { w = JSON.parse(readFileSync(WIRING, 'utf8')); } catch { warnings.push(`--wiring 报告不可读:${WIRING}`); }
|
|
60
|
+
const ai = w && w.appInit;
|
|
61
|
+
if (ai) {
|
|
62
|
+
const items = [];
|
|
63
|
+
// 粗粒度:每个 onCreate 位点一张卡(子类/父类各一段),逐项拆解由规划者读锚定源码段完成——脚本不用正则替模型判"哪些调用算初始化"
|
|
64
|
+
for (const s of ai.onCreateSites || []) {
|
|
65
|
+
if (!s.bodyLines) continue; // 只有 super.onCreate() 的空壳不立卡
|
|
66
|
+
items.push({ note: `应用级初始化:${s.cls}.onCreate(${s.file}:${s.startLine}-${s.endLine},${s.bodyLines} 行)在安卓先于任何页面执行——其中每项初始化须接入 EntryAbility.onCreate 并在首页前完成,无鸿蒙等价的逐项记降级`, anchor: s.file ? `${s.file}:${s.startLine}` : null });
|
|
67
|
+
}
|
|
68
|
+
for (const p of ai.startupInitializers || []) items.push({ note: `应用级初始化:androidx.startup 初始化器 ${p.name}(启动即跑)——接入 EntryAbility.onCreate 或记降级`, anchor: p.line ? `${p.manifest || w.manifest}:${p.line}` : null });
|
|
69
|
+
for (const p of ai.providers || []) if (!/androidx\.startup\.InitializationProvider$/.test(p.name)) items.push({ note: `启动即跑的 ContentProvider ${p.name}(onCreate 早于 Application)——判定是否承载初始化职责,是则接入,否则记不适用`, anchor: p.line ? `${p.manifest || w.manifest}:${p.line}` : null });
|
|
70
|
+
items.forEach((it, i) => {
|
|
71
|
+
const id = `INIT-A${String(i + 1).padStart(2, '0')}`;
|
|
72
|
+
if (seen.has(id)) { warnings.push(`INIT 卡 ${id} 与卡片 ID 冲突(首见于 ${seen.get(id)}),已跳过`); return; }
|
|
73
|
+
seen.set(id, 'wiring-report'); initCount++;
|
|
74
|
+
features.push({ id, kind: 'backend', note: it.note, anchor: it.anchor, card: '应用级初始化(接线报告派生)', domain: 'app-init', page: null, status: 'untested' });
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
mkdirSync(dirname(resolve(OUT)), { recursive: true });
|
|
79
|
+
writeFileSync(resolve(OUT), JSON.stringify(features, null, 2));
|
|
80
|
+
const byKind = {}; for (const x of features) byKind[x.kind] = (byKind[x.kind] || 0) + 1;
|
|
81
|
+
const litCount = features.reduce((n, x) => n + (x.literals ? x.literals.length : 0), 0);
|
|
82
|
+
const summary = { cards: readdirSync(CARDS).filter(x => x.endsWith('.md')).length, features: features.length, byKind, initFeatures: initCount, literals: litCount, warnings };
|
|
83
|
+
if (REPORT) writeFileSync(resolve(REPORT), JSON.stringify(summary, null, 2));
|
|
84
|
+
console.log(`[parse-cards] ${summary.cards} 卡 → ${features.length} 条断言 ${JSON.stringify(byKind)}${initCount ? `(含接线报告派生 INIT 卡 ${initCount} 条:须由一个地基单元认领)` : ''} · 字面锚点 ${litCount} 条${litCount ? '' : '(断言里没用反引号/「」标 id 与文本,闭合字面门将空跑)'}${warnings.length ? `;告警 ${warnings.length}:${warnings.slice(0, 5).join(';')}` : ''} → ${OUT}`);
|
|
85
|
+
// --project [--prd]:入账前顺带跑一遍 PRD 覆盖差集(同一轮出结果,省一次往返)——用解析结果临时成账,不动真台账
|
|
86
|
+
if (PROJECT && features.length) {
|
|
87
|
+
const { spawnSync } = await import('node:child_process');
|
|
88
|
+
const { tmpdir } = await import('node:os');
|
|
89
|
+
const tmpLedger = join(tmpdir(), `parse-cards-precheck-${process.pid}.json`);
|
|
90
|
+
writeFileSync(tmpLedger, JSON.stringify({ features }));
|
|
91
|
+
const pre = join(dirname(resolve(OUT)), 'anchors-precheck.json');
|
|
92
|
+
const args = [join(dirname(fileURLToPath(import.meta.url)), '..', 'closure', 'check-anchors.mjs'), '--project', PROJECT, '--ledger', tmpLedger, '--json', pre, ...(PRD ? ['--prd', PRD] : [])];
|
|
93
|
+
const r = spawnSync(process.execPath, args, { encoding: 'utf8', timeout: 120000 });
|
|
94
|
+
const head = (r.stdout || '').split('\n').filter(l => /^\[check-anchors\]|prd_uncovered|prd_anchor_stale/.test(l)).slice(0, 15).join('\n');
|
|
95
|
+
console.log(head || `[parse-cards] 覆盖预检未产出(exit=${r.status}):${(r.stderr || '').trim().slice(0, 200)}`);
|
|
96
|
+
if (head) console.log(`[parse-cards] 覆盖预检报告 → ${pre}(未覆盖行:补卡再入账,或约束/说明行留待闭合豁免)`);
|
|
97
|
+
}
|
|
98
|
+
process.exit(features.length ? 0 : 2);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// literals.mjs — 断言/PRD 字面量的统一口径(parse-cards 抽取、check-anchors 对账、walk 生成 expect 三处共用,避免各写一套分叉)。
|
|
2
|
+
// 只认结构标记,不含任何应用或措辞知识。
|
|
3
|
+
|
|
4
|
+
/** 标记字面量:反引号、「」、“” */
|
|
5
|
+
export const MARK_RE = /`([^`\n]{1,60})`|「([^」\n]{1,60})」|“([^”\n]{1,60})”/g;
|
|
6
|
+
/** "id 为 X" / "ID: X" 句式(大小写不敏感;id 允许字母数字下划线连字符,长度 ≥3) */
|
|
7
|
+
export const ID_RE = /\bid\s*(?:为|是|[::=])\s*`?([A-Za-z][A-Za-z0-9_-]{2,})`?/gi;
|
|
8
|
+
/** 占位符:<…>、{…}、省略号、单个大写字母、M:SS/HH:MM 类格式样板 */
|
|
9
|
+
export const PLACEHOLDER = /<[^>]*>|\{[^}]*\}|…|\.\.\.|^[A-Z]$|^[NMX]\b|\b(?:M{1,2}:SS|HH:MM|YYYY|DD)\b/;
|
|
10
|
+
|
|
11
|
+
export const isPlaceholder = (s) => PLACEHOLDER.test(String(s));
|
|
12
|
+
|
|
13
|
+
/** 从一段文本抽字面量(去占位符、去重、保持出现顺序) */
|
|
14
|
+
export function literalsOf(text) {
|
|
15
|
+
const out = new Set();
|
|
16
|
+
for (const m of String(text).matchAll(MARK_RE)) { const v = (m[1] ?? m[2] ?? m[3] ?? '').trim(); if (v && !isPlaceholder(v)) out.add(v); }
|
|
17
|
+
for (const m of String(text).matchAll(ID_RE)) out.add(m[1].replace(/[-_]+$/, ''));
|
|
18
|
+
return [...out];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 探针:"前缀:值"复合文本只探前缀(值多为动态数据);前缀须 ≥3 字且含非数字,否则("10:30"、"http://x")探全文 */
|
|
22
|
+
export function probeOf(lit) {
|
|
23
|
+
const s = String(lit);
|
|
24
|
+
const c = s.match(/^([^::]{3,}[::])\s*\S/);
|
|
25
|
+
return c && /[^\d\s]/.test(c[1]) ? c[1] : s;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const escapeRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
29
|
+
|
|
30
|
+
/** 对账归一化:Unicode NFKC(全半角标点/数字/字母统一)+ 小写;产物语料与探针同一口径,不含任何词表 */
|
|
31
|
+
export const normText = (s) => String(s).normalize('NFKC').toLowerCase();
|
|
32
|
+
/** 颜色字面量 #RRGGBB/#AARRGGBB 的产物写法变体:'#rrggbb'、0xrrggbb、0xffrrggbb(裸十六进制位不探——"000000"/"abcdef" 会撞数字与标识符) */
|
|
33
|
+
export const hexForms = (lit) => {
|
|
34
|
+
const m = String(lit).trim().match(/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/); if (!m) return [];
|
|
35
|
+
const h = m[1].toLowerCase(); const rgb = h.length === 8 ? h.slice(2) : h;
|
|
36
|
+
return [...new Set([`#${h}`, `0x${h}`, `#${rgb}`, `0x${rgb}`, `0xff${rgb}`])];
|
|
37
|
+
};
|