@antprofuse/saddle-islands-tools 0.1.9 → 0.1.12
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/package.json +1 -1
- package/tools/adapters/islands/visual_targets.py +106 -0
- package/tools/cli/main.py +2 -2
- package/tools/cli/minifish.mjs +1 -1
- package/tools/cli/minifish.py +13 -1
- package/tools/docs/minifish-testing.md +38 -0
- package/tools/docs/semantic-identity.md +3 -3
- package/tools/modules/minifish/anchor_check.py +108 -0
- package/tools/modules/minifish/anchor_templates.py +31 -0
- package/tools/modules/minifish/checks/package.test.mjs +1 -1
- package/tools/modules/minifish/compiler/anchor-coverage.mjs +37 -0
- package/tools/modules/minifish/compiler/anchor-values-cli.mjs +16 -0
- package/tools/modules/minifish/compiler/anchor-values.mjs +56 -0
- package/tools/modules/minifish/compiler/compile-check.mjs +21 -3
- package/tools/modules/minifish/component_index.py +177 -0
- package/tools/modules/minifish/component_index_bridge.py +14 -0
- package/tools/modules/minifish/harness/browser.mjs +14 -3
- package/tools/modules/minifish/harness/held-snapshot.mjs +5 -3
- package/tools/modules/minifish/harness/held-spatial.mjs +24 -0
- package/tools/modules/minifish/harness/independent-case-runner.mjs +3 -1
- package/tools/modules/minifish/harness/jsapi.mjs +4 -1
- package/tools/modules/minifish/harness/official-bridge-dispatch.mjs +20 -0
- package/tools/modules/minifish/harness/spatial-contract.mjs +18 -0
- package/tools/modules/workflow/compile_gate.py +29 -1
package/package.json
CHANGED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Canonical visual identities from complete frozen Islands query-list pages.
|
|
2
|
+
|
|
3
|
+
This inventory describes source identities, not required physical DOM nodes.
|
|
4
|
+
"""
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def visual_targets(pages):
|
|
10
|
+
if not pages:
|
|
11
|
+
raise ValueError('Complete query pages required')
|
|
12
|
+
objects, offset, total = [], 0, None
|
|
13
|
+
project = pages[0].get('project')
|
|
14
|
+
for page in pages:
|
|
15
|
+
if page.get('tool') != 'islands' or page.get('command') != 'query list' or page.get('status') != 'completed':
|
|
16
|
+
raise ValueError('Completed Islands query list required')
|
|
17
|
+
if page.get('project') != project:
|
|
18
|
+
raise ValueError('Mixed projects')
|
|
19
|
+
if page.get('compilation', {}).get('state') != 'complete':
|
|
20
|
+
raise ValueError('Incomplete source compilation')
|
|
21
|
+
info, rows = page.get('page', {}), page.get('objects', [])
|
|
22
|
+
if info.get('offset') != offset or info.get('returned') != len(rows):
|
|
23
|
+
raise ValueError('Missing, overlapping or inconsistent query page')
|
|
24
|
+
if total is None:
|
|
25
|
+
total = info.get('total')
|
|
26
|
+
if info.get('total') != total:
|
|
27
|
+
raise ValueError('Changing query total')
|
|
28
|
+
offset += len(rows)
|
|
29
|
+
objects.extend(rows)
|
|
30
|
+
if not objects or offset != total:
|
|
31
|
+
raise ValueError('Incomplete query inventory')
|
|
32
|
+
ids = [(o.get('domain'), o.get('id')) for o in objects]
|
|
33
|
+
if any(not all(k) for k in ids) or len(set(ids)) != len(ids):
|
|
34
|
+
raise ValueError('Missing or duplicate object identity')
|
|
35
|
+
snapshots = {o.get('snapshotId') for o in objects}
|
|
36
|
+
if len(snapshots) != 1 or not next(iter(snapshots)):
|
|
37
|
+
raise ValueError('Mixed or missing source snapshot')
|
|
38
|
+
targets = {}
|
|
39
|
+
for obj in objects:
|
|
40
|
+
if obj.get('identity') != 'point' or obj.get('kind') not in ('page', 'component', 'branch'):
|
|
41
|
+
continue
|
|
42
|
+
locations = [loc for loc in obj.get('locations', [])
|
|
43
|
+
if loc.get('kind') in ('visualPage', 'visualComponent')]
|
|
44
|
+
if not locations:
|
|
45
|
+
if obj['kind'] in ('page', 'component'):
|
|
46
|
+
raise ValueError('Visual identity has no visual location')
|
|
47
|
+
continue
|
|
48
|
+
# The upstream ID is opaque to consumers: never reformat or reconstruct it.
|
|
49
|
+
target = obj['id']
|
|
50
|
+
if not isinstance(target, str) or not target.startswith('视图:'):
|
|
51
|
+
raise ValueError('Official visual point ID required')
|
|
52
|
+
if target in targets:
|
|
53
|
+
raise ValueError('Duplicate official visual identity')
|
|
54
|
+
targets[target] = {'target': target, 'id': target, 'kind': obj['kind'],
|
|
55
|
+
'source': obj.get('source'), 'locations': locations}
|
|
56
|
+
if not targets:
|
|
57
|
+
raise ValueError('No visual identities')
|
|
58
|
+
return {'schemaVersion': 2, 'identityContract': 'islands-point-id', 'snapshotId': next(iter(snapshots)),
|
|
59
|
+
'querySha256': hashlib.sha256(json.dumps(pages, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode()).hexdigest(),
|
|
60
|
+
'objectCount': len(objects), 'targets': [targets[k] for k in sorted(targets)]}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def inventory_main(argv, identity):
|
|
64
|
+
"""Consume an Owner-frozen source manifest; never run or alter Islands."""
|
|
65
|
+
import argparse
|
|
66
|
+
from pathlib import Path
|
|
67
|
+
import re
|
|
68
|
+
parser = argparse.ArgumentParser()
|
|
69
|
+
parser.add_argument('--source', type=Path, required=True)
|
|
70
|
+
parser.add_argument('--source-sha256', required=True)
|
|
71
|
+
parser.add_argument('--output', type=Path, required=True)
|
|
72
|
+
args = parser.parse_args(argv)
|
|
73
|
+
data = args.source.read_bytes()
|
|
74
|
+
if hashlib.sha256(data).hexdigest() != args.source_sha256:
|
|
75
|
+
raise ValueError('Frozen source manifest hash mismatch')
|
|
76
|
+
manifest = json.loads(data)
|
|
77
|
+
if manifest.get('schemaVersion') != 1 or not manifest.get('repository') or not re.fullmatch('[a-f0-9]{40}', manifest.get('commit', '')):
|
|
78
|
+
raise ValueError('Frozen source repository and complete commit required')
|
|
79
|
+
entries = manifest.get('queryPages')
|
|
80
|
+
if not isinstance(entries, list) or not entries:
|
|
81
|
+
raise ValueError('Complete query page manifest required')
|
|
82
|
+
pages, paths = [], set()
|
|
83
|
+
base = args.source.resolve().parent
|
|
84
|
+
for entry in entries:
|
|
85
|
+
name = entry['path']
|
|
86
|
+
if not name or name.startswith('/') or any(p in ('', '.', '..') for p in name.split('/')) or name in paths:
|
|
87
|
+
raise ValueError('Unique source-relative query page required')
|
|
88
|
+
paths.add(name)
|
|
89
|
+
file = base / name
|
|
90
|
+
if any(base.joinpath(*name.split('/')[:i]).is_symlink() for i in range(1, len(name.split('/'))+1)):
|
|
91
|
+
raise ValueError('Source page symlinks forbidden')
|
|
92
|
+
raw = file.read_bytes()
|
|
93
|
+
if hashlib.sha256(raw).hexdigest() != entry['sha256']:
|
|
94
|
+
raise ValueError('Frozen query page hash mismatch')
|
|
95
|
+
page = json.loads(raw)
|
|
96
|
+
if not {'page','component','branch'}.issubset(page.get('kinds', [])):
|
|
97
|
+
raise ValueError('Query filter omits visual identity kinds')
|
|
98
|
+
pages.append(page)
|
|
99
|
+
result = visual_targets(pages)
|
|
100
|
+
result['source'] = {'repository': manifest['repository'], 'commit': manifest['commit'], 'manifestSha256':args.source_sha256}
|
|
101
|
+
result['tool'] = {'commit':identity['commit'], 'version':identity['version']}
|
|
102
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
with args.output.open('x') as f:
|
|
104
|
+
json.dump(result, f, ensure_ascii=False, indent=2);f.write('\n')
|
|
105
|
+
print(json.dumps({'targets':len(result['targets']), 'output':str(args.output)}))
|
|
106
|
+
return 0
|
package/tools/cli/main.py
CHANGED
|
@@ -22,10 +22,10 @@ def main():
|
|
|
22
22
|
if len(sys.argv) > 1 and sys.argv[1] == 'minifish':
|
|
23
23
|
args = sys.argv[2:]
|
|
24
24
|
if not args or args == ['--help']:
|
|
25
|
-
print('minifish compile-check|build|package|plugin|batch|run|report <explicit options>; see tools/docs/minifish-testing.md')
|
|
25
|
+
print('minifish anchor-inventory|component-index|anchor-check|compile-check|build|package|plugin|batch|run|report <explicit options>; see tools/docs/minifish-testing.md')
|
|
26
26
|
return 0
|
|
27
27
|
command = args[0]
|
|
28
|
-
if command not in ('compile-check', 'build', 'package', 'plugin', 'batch', 'run', 'report'):
|
|
28
|
+
if command not in ('anchor-inventory', 'component-index', 'anchor-check', 'compile-check', 'build', 'package', 'plugin', 'batch', 'run', 'report'):
|
|
29
29
|
raise SystemExit('Unknown minifish command: ' + command)
|
|
30
30
|
entry = 'minifish.mjs' if command in ('compile-check', 'build', 'package', 'plugin') else 'minifish.py'
|
|
31
31
|
return subprocess.call(['node' if entry.endswith('.mjs') else sys.executable, str(Path(__file__).with_name(entry)), *args])
|
package/tools/cli/minifish.mjs
CHANGED
|
@@ -30,7 +30,7 @@ let result;
|
|
|
30
30
|
if(command==='compile-check'){
|
|
31
31
|
const {compileCheck}=await import('../modules/minifish/compiler/compile-check.mjs');
|
|
32
32
|
result=await compileCheck({...options,manifest:flags.manifest,output:flags.output});
|
|
33
|
-
if(result.failed)process.exitCode=3;
|
|
33
|
+
if(result.failed||!result.anchorCoverage.passed)process.exitCode=3;
|
|
34
34
|
}else if(command==='plugin'){
|
|
35
35
|
const {loadProject}=await import('../modules/minifish/compiler/config.mjs');
|
|
36
36
|
const project=await loadProject(options.projectRoot,options.config);
|
package/tools/cli/minifish.py
CHANGED
|
@@ -35,8 +35,20 @@ for flag in ['--output', '--report-dir']:
|
|
|
35
35
|
os.environ['MINIFISH_TOOL_SOURCE_LOCK'] = str(manifest_file)
|
|
36
36
|
os.environ['PYTHONDONTWRITEBYTECODE'] = '1'
|
|
37
37
|
ROOT = PACKAGE / 'tools/modules/minifish/harness'
|
|
38
|
-
if len(sys.argv) < 2: raise SystemExit('usage: minifish.py batch|run|report <explicit options>')
|
|
38
|
+
if len(sys.argv) < 2: raise SystemExit('usage: minifish.py anchor-inventory|component-index|anchor-check|batch|run|report <explicit options>')
|
|
39
39
|
command = sys.argv[1]
|
|
40
|
+
if command == 'anchor-inventory':
|
|
41
|
+
sys.path.insert(0, str(PACKAGE))
|
|
42
|
+
from tools.adapters.islands.visual_targets import inventory_main
|
|
43
|
+
raise SystemExit(inventory_main(sys.argv[2:], identity))
|
|
44
|
+
if command == 'component-index':
|
|
45
|
+
sys.path.insert(0, str(PACKAGE))
|
|
46
|
+
from tools.modules.minifish.component_index import main as component_index
|
|
47
|
+
raise SystemExit(component_index(sys.argv[2:], identity))
|
|
48
|
+
if command == 'anchor-check':
|
|
49
|
+
sys.path.insert(0, str(PACKAGE))
|
|
50
|
+
from tools.modules.minifish.anchor_check import main as anchor_check
|
|
51
|
+
raise SystemExit(anchor_check(sys.argv[2:], identity))
|
|
40
52
|
if command in ('batch', 'run'):
|
|
41
53
|
result = subprocess.run([sys.executable, str(ROOT / ('run-batch.py' if command == 'batch' else 'run-platform.py')), *sys.argv[2:]])
|
|
42
54
|
raise SystemExit(result.returncode)
|
|
@@ -50,3 +50,41 @@ Legacy component-host integrations must supply an explicit `repositoryRoot` to `
|
|
|
50
50
|
项目配置的 `caseManifest` 指定唯一正式活动清单,命令的 `--manifest` 必须与其一致。活动清单包含 `cases`(definition、sourceCaseIds;定义路径为编译条目的唯一标识)及可选 `pending`(sourceCaseId、reason)。活动与非执行责任必须互斥、唯一且覆盖配置中的完整catalog。历史未激活定义不隐式加入;不可执行原因仍需Owner审核,不计编译通过或业务通过。输入须为干净候选提交内的文件。结果 `compile-result.json` 绑定候选提交、项目树、清单和目录摘要、已验证工具身份,并含逐case结果;有编译错误返回非零。
|
|
51
51
|
|
|
52
52
|
MT使用 `iteration stage-gate` 时必须提供 `--compile-result NEW_EXTERNAL_DIR/compile-result.json`,缺失、过期、漏项、身份不匹配或编译失败均拒绝交付;其他阶段不增加此检查。
|
|
53
|
+
|
|
54
|
+
### 宿主桥接分发兼容
|
|
55
|
+
|
|
56
|
+
工具对已核准摘要的官方 `/lyra/runtime.bundle.js` 使用单处分发兼容适配,确保异步Mock响应不与宿主默认响应竞争。仅Mock管理的调用增加默认响应抑制;`official-ui` 及本地 `pushWindow` 委托保留原观察和宿主分发。该适配不是官方公开接口,不修改业务输入或Mock响应。
|
|
57
|
+
|
|
58
|
+
运行证据 `bridgeDispatchAdapters` 记录适配标识、原始/适配脚本SHA和API范围。未知脚本摘要、缺少预期适配记录均明确失败,不能由业务节点调整SDK版本或自行补丁;须由工具维护者确认兼容后统一交付。该适配不证明未声明调用或业务覆盖完整。
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
## 产品锚点身份静态检查
|
|
62
|
+
|
|
63
|
+
`minifish anchor-inventory` 消费Owner冻结的Islands官方 `query list` 完整分页,原样发布 `objects[].id`。源来自编译组件树,不从产品、已有case或HTML文字生成身份。输出schemaVersion为2,identityContract为`islands-point-id`;targets中的target必须等于id,保留kind、source、locations。禁止把旧展平路径清单作为新版输入。
|
|
64
|
+
|
|
65
|
+
输入 `--source` 为Owner冻结JSON,包含 `schemaVersion: 1`、源仓库 `repository`、完整源 `commit`、按offset排序的 `queryPages: [{path, sha256}]`;文件路径相对该JSON,覆盖指定业务页面的全部查询结果。`--source-sha256` 固定该文件摘要,`--output` 为仓外新文件。缺页、混快照或重复身份拒绝。
|
|
66
|
+
|
|
67
|
+
`minifish anchor-check` 使用 `--repository`、`--product-directory`(仓内路径)、`--head`(当前完整提交)、`--inventory`、`--inventory-sha256`、`--registry-directory`(同业务 `code/.registry`)、`--tool-node-modules`、`--output`。两命令都必须给外部 `--tool-source` 可信工具清单。产品目录必须干净,检查全部Git跟踪AXML模板,拒绝符号链接/子模块,不提供任选文件的绕过参数。
|
|
68
|
+
|
|
69
|
+
产品声明去重cmp集合必须等于登记索引的活动ID集合;索引值必须与源清单中kind=component正式ID集合一一对应。缺少组件、多余身份、分支点冒充组件、空声明集合或未解析动态值均失败。循环实例不拼入组件ID;source中的分支/页面记录用于追溯,不能因为同为正式点就当作组件绑定。
|
|
70
|
+
|
|
71
|
+
动态声明以TypeScript语法树计算字符串、拼接与条件的保守有限取值集合,不执行产品代码。条件相关性可能产生保守多余组合,报告不声称这些组合真实可达。输出绑定源清单摘要、工具身份、产品提交及逐模板摘要;声明问题返回码3。
|
|
72
|
+
|
|
73
|
+
静态集合相等不证明未标注的额外业务组件不存在、不证明借用合法ID的承载正确,也不替代场景中的禁止出现、数量及布局断言。组件不是DOM标签;虚拟组件的逻辑接口需正式绑定合同,本入口尚只采集AXML声明,不可据此宣称所有外部接口已检验。
|
|
74
|
+
|
|
75
|
+
正式ID包含域、页面、完整组件路径和祖先分支,改名或移动可能改变身份,并非不变UUID。工具精确匹配原字符串,负责选择器转义;显示缩略不改变存储值。当前浏览器适配控制覆盖长ID、中文、引号、分支和循环实例,不代替小程序编译与真实运行验证。
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
## 稳定组件登记
|
|
79
|
+
|
|
80
|
+
登记文件位于业务Git的 `code/.registry/`,与 `identity-registry.json` 同目录。`anchors.jsonc` 为cmp到官方源ID的对象,`anchors-retired.jsonc` 为纯退役ID数组。成员不直接编辑,公共工具独占读写。固定每行一项、两空格缩进、每项尾逗号、结束括号前空行及LF尾换行。既有行序不重排,新增尾追加、改映射原位,退役列表只追加。
|
|
81
|
+
|
|
82
|
+
公开命令为 `minifish component-index reconcile|diff|verify --tool-source TRUST --inventory INVENTORY --inventory-sha256 SHA`。更新或校验既有登记时必须成对提供 `--index anchors.jsonc --index-sha256 SHA --retired anchors-retired.jsonc --retired-sha256 SHA`。`reconcile` 的 `--output` 是尚不存在的新候选目录;可提供 `--changes` JSON对象,其中 `remap` 为cmp到新源ID、`retire` 为需退役的cmp数组。工具不猜改名,不在原处逐文件覆盖;Owner核验新目录后将两文件同一Git提交更新。`diff` 仅给源身份增删列表,不作业务判断。首次创建省略旧登记参数。
|
|
83
|
+
|
|
84
|
+
`compile-check` 配置需 `anchorInventory: {path, sha256}`,path为测试项目内冻结源清单。编译器读取同业务Git候选中的 `.registry/anchors.jsonc` 和 `anchors-retired.jsonc`,通过统一公共解析器校验对应关系与冲突,收据记录两文件路径和SHA。只有真实target断言计入组件覆盖,动作与说明文字不计;全量case可编译且断言组件并集等于活动cmp集合才通过。`stage-gate` 重新核验候选Git、工具身份、索引与覆盖集合,不接受过期收据。组件索引不承载测试预期或业务判断。
|
|
85
|
+
|
|
86
|
+
### 按压关系补充观察
|
|
87
|
+
|
|
88
|
+
按压region断言可声明公共 `spatial`:`order: [{above: {target, instance?}, below: {target, instance?}}]` 与 `absent: [{target, instance?}]`。目标使用cmp引用。该版本只支持按压region,其他用法在静态编译及运行输入前拒绝。原有普通区域顺序公共接口保持独立,不静默解释该字段。
|
|
89
|
+
|
|
90
|
+
公共准备将该字段与项目核心按压合同分开校验;一次held快照同时记录原目标、关系目标的几何及排除目标的计数。补充目标不触发等待出现;释放后只判定冻结观察,错误时仍释放。输入350ms限制不变。关系要求两个唯一可见、非重叠且上方在前的真实载体;排除要求零匹配。缺观察、重复身份、隐藏关系目标及未知声明失败。本控制经过真实浏览器单快照验证,不等同于每个业务小程序已经验收。
|
|
@@ -42,14 +42,14 @@
|
|
|
42
42
|
|
|
43
43
|
## 登记表
|
|
44
44
|
|
|
45
|
-
业务根 `code/identity-registry.json` 进入 Git,形状为 `{"<id>":<归一化身份对象>}`。只新增,不删除、不修改已有记录;保存 identity 的完整源语义定位;不额外保存源文件路径、显示文案、状态或时间戳。不包含 identityVersion 或 registryVersion。
|
|
45
|
+
业务根 `code/.registry/identity-registry.json` 进入 Git,形状为 `{"<id>":<归一化身份对象>}`。只新增,不删除、不修改已有记录;保存 identity 的完整源语义定位;不额外保存源文件路径、显示文案、状态或时间戳。不包含 identityVersion 或 registryVersion。
|
|
46
46
|
|
|
47
47
|
相同身份再次登记幂等;同一身份在当前契约集合出现两份定义则重复报错。契约退休仅移除契约文件,登记记录永存。索引只用于身份一致性与历史碰撞检测,不承担业务路由或运行时查询。
|
|
48
48
|
|
|
49
49
|
```sh
|
|
50
50
|
python3 <tool-repository>/tools/cli/main.py identity generate identity.json
|
|
51
|
-
python3 <tool-repository>/tools/cli/main.py identity register identity.json --registry code/identity-registry.json
|
|
52
|
-
python3 <tool-repository>/tools/cli/main.py identity check --registry code/identity-registry.json --baseline /tmp/base-registry.json --active /tmp/active-contract-identities.json
|
|
51
|
+
python3 <tool-repository>/tools/cli/main.py identity register identity.json --registry code/.registry/identity-registry.json
|
|
52
|
+
python3 <tool-repository>/tools/cli/main.py identity check --registry code/.registry/identity-registry.json --baseline /tmp/base-registry.json --active /tmp/active-contract-identities.json
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
active 是从当前**全部**后端契约严格解析导出的 `[{"id":...,"identity":...}]`,不是手工挑选清单。项目契约校验器负责 YAML 安全解析、全量发现以及来源可解析性;本工具不假称校验业务来源。
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Check anchor declarations in a complete Git product template tree.
|
|
2
|
+
|
|
3
|
+
A successful result proves equality of declared component identity sets, not
|
|
4
|
+
complete annotation or semantic correctness of the product. Dynamic values are conservative sets.
|
|
5
|
+
"""
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
from tools.modules.minifish.anchor_templates import template_declarations
|
|
13
|
+
from tools.modules.minifish.component_index import parse_registry, validate
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def digest(data):
|
|
17
|
+
return hashlib.sha256(data).hexdigest()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def git(repo, *args):
|
|
21
|
+
return subprocess.check_output(['git', '-C', str(repo), *args])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check(repo, product, head, inventory, parser, index, retired):
|
|
25
|
+
if not re.fullmatch('[a-f0-9]{40}', head): raise ValueError('Full candidate commit required')
|
|
26
|
+
if not product or product.startswith('/') or any(p in ('', '.', '..') for p in product.split('/')):
|
|
27
|
+
raise ValueError('Repository-relative product directory required')
|
|
28
|
+
if git(repo, 'rev-parse', 'HEAD').decode().strip() != head: raise ValueError('Candidate is not checkout HEAD')
|
|
29
|
+
if git(repo, 'status', '--porcelain', '--untracked-files=all', '--', product).strip(): raise ValueError('Product directory must be clean')
|
|
30
|
+
active = validate(index, inventory, retired)
|
|
31
|
+
allowed = set(active)
|
|
32
|
+
rows, files = [], {}
|
|
33
|
+
tree = git(repo, 'ls-tree', '-rz', head, '--', product).split(b'\0')
|
|
34
|
+
for entry in tree:
|
|
35
|
+
if not entry: continue
|
|
36
|
+
info, rawpath = entry.split(b'\t', 1)
|
|
37
|
+
name = rawpath.decode(); mode = info.split()[0]
|
|
38
|
+
if mode in (b'120000', b'160000'): raise ValueError('Symlink/submodule product inputs are not supported')
|
|
39
|
+
if not name.endswith('.axml'): continue
|
|
40
|
+
data = git(repo, 'show', head+':'+name)
|
|
41
|
+
files[name] = digest(data)
|
|
42
|
+
for declaration in template_declarations(data.decode()): rows.append({'file':name, **declaration})
|
|
43
|
+
if not files: raise ValueError('No tracked product templates')
|
|
44
|
+
if not rows: raise ValueError('No anchor declarations in product templates')
|
|
45
|
+
resolved = parser([r['expression'] for r in rows])
|
|
46
|
+
if len(resolved) != len(rows): raise ValueError('Incomplete parser results')
|
|
47
|
+
issues, declared = [], set()
|
|
48
|
+
for row, values in zip(rows, resolved):
|
|
49
|
+
if not values['ok']: issues.append({**row,'kind':'unresolved','detail':values['error']}); continue
|
|
50
|
+
if not values.get('values') or any(not isinstance(v, str) or not v.strip() for v in values['values']):
|
|
51
|
+
raise ValueError('Empty or invalid resolved anchor values')
|
|
52
|
+
declared.update(values['values'])
|
|
53
|
+
unknown = sorted(set(values['values'])-allowed)
|
|
54
|
+
if unknown: issues.append({**row,'kind':'unknown','values':unknown,'precision':values['precision']})
|
|
55
|
+
missing = sorted(allowed-declared)
|
|
56
|
+
if missing: issues.append({'kind':'missing-components','values':missing})
|
|
57
|
+
if git(repo,'rev-parse','HEAD').decode().strip()!=head or git(repo,'status','--porcelain','--untracked-files=all','--',product).strip():
|
|
58
|
+
raise ValueError('Product changed during inspection')
|
|
59
|
+
return {'schemaVersion':1,'check':'component-anchor-set-equality','candidate':head,'productDirectory':product,
|
|
60
|
+
'sourceSnapshot':inventory['snapshotId'],'sourceQuerySha256':inventory['querySha256'],
|
|
61
|
+
'identityContract':'allocated-component-id','sourceComponentIds':sorted(active.values()),'activeComponentIds':sorted(allowed),'declaredComponentIds':sorted(declared),
|
|
62
|
+
'templateFiles':files,'declarations':len(rows),'issues':issues,'passed':not issues,
|
|
63
|
+
'limitations':['Does not prove all business components are annotated','Does not prove anchor carrier semantics or scenario visibility','Dynamic conditional results are conservative, not a reachability proof']}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main(argv, identity):
|
|
67
|
+
parser=argparse.ArgumentParser()
|
|
68
|
+
parser.add_argument('--repository',type=Path,required=True)
|
|
69
|
+
parser.add_argument('--product-directory',required=True)
|
|
70
|
+
parser.add_argument('--head',required=True)
|
|
71
|
+
parser.add_argument('--inventory',type=Path,required=True)
|
|
72
|
+
parser.add_argument('--inventory-sha256',required=True)
|
|
73
|
+
parser.add_argument('--registry-directory',required=True,help='Git-relative code/.registry directory')
|
|
74
|
+
parser.add_argument('--tool-node-modules',type=Path,required=True)
|
|
75
|
+
parser.add_argument('--output',type=Path,required=True)
|
|
76
|
+
args=parser.parse_args(argv)
|
|
77
|
+
data=args.inventory.read_bytes()
|
|
78
|
+
if digest(data)!=args.inventory_sha256: raise ValueError('Source inventory hash mismatch')
|
|
79
|
+
lock=args.tool_node_modules.resolve().parent/'package-lock.json'
|
|
80
|
+
if digest(lock.read_bytes())!=identity['files']['tools/modules/minifish/package-lock.json']: raise ValueError('Tool dependency lock mismatch')
|
|
81
|
+
output=args.output.resolve();repo=args.repository.resolve()
|
|
82
|
+
if output==repo or repo in output.parents: raise ValueError('Report must be outside repository')
|
|
83
|
+
def resolve(values):
|
|
84
|
+
proc=subprocess.run(['node',str(Path(__file__).parent/'compiler/anchor-values-cli.mjs'),str(args.tool_node_modules.resolve())],input=json.dumps(values),text=True,capture_output=True,check=True)
|
|
85
|
+
return json.loads(proc.stdout)
|
|
86
|
+
reg=args.registry_directory
|
|
87
|
+
if reg.startswith('/') or any(p in ('','.','..') for p in reg.split('/')) or not reg.endswith('code/.registry'):
|
|
88
|
+
raise ValueError('Git-relative business code/.registry directory required')
|
|
89
|
+
if args.product_directory!=reg[:-len('.registry')]+'minifish':
|
|
90
|
+
raise ValueError('Product and registry must belong to the same business code directory')
|
|
91
|
+
if git(repo,'status','--porcelain','--untracked-files=all','--',reg).strip():
|
|
92
|
+
raise ValueError('Registry must be clean')
|
|
93
|
+
bindings={};values=[]
|
|
94
|
+
for name in ('anchors.jsonc','anchors-retired.jsonc'):
|
|
95
|
+
file=reg+'/'+name
|
|
96
|
+
mode=git(repo,'ls-tree',args.head,'--',file).decode().split()
|
|
97
|
+
if not mode or mode[0]!='100644': raise ValueError('Regular committed registry file required')
|
|
98
|
+
raw=git(repo,'show',args.head+':'+file)
|
|
99
|
+
bindings[name]={'path':file,'sha256':digest(raw)}
|
|
100
|
+
values.append(parse_registry(raw.decode()))
|
|
101
|
+
result=check(repo,args.product_directory,args.head,json.loads(data),resolve,*values)
|
|
102
|
+
if git(repo,'status','--porcelain','--untracked-files=all','--',reg).strip(): raise ValueError('Registry changed during inspection')
|
|
103
|
+
result['registry']=bindings
|
|
104
|
+
result.update(sourceInventorySha256=digest(data),tool={'commit':identity['commit'],'version':identity['version']})
|
|
105
|
+
output.parent.mkdir(parents=True,exist_ok=True)
|
|
106
|
+
with output.open('x') as f:json.dump(result,f,ensure_ascii=False,indent=2);f.write('\n')
|
|
107
|
+
print(json.dumps({'passed':result['passed'],'declarations':result['declarations'],'issues':len(result['issues']),'output':str(output)}))
|
|
108
|
+
return 0 if result['passed'] else 3
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Read AXML anchor declarations without executing the template or product.
|
|
2
|
+
|
|
3
|
+
Only declaration extraction is provided here. This is not proof that all
|
|
4
|
+
business components are annotated, nor validation of template syntax.
|
|
5
|
+
"""
|
|
6
|
+
from html.parser import HTMLParser
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AnchorDeclarations(HTMLParser):
|
|
10
|
+
def __init__(self):
|
|
11
|
+
super().__init__(convert_charrefs=True)
|
|
12
|
+
self.declarations = []
|
|
13
|
+
|
|
14
|
+
def handle_starttag(self, tag, attrs):
|
|
15
|
+
values = [value for name, value in attrs if name == 'data-anchor-id']
|
|
16
|
+
if len(values) > 1:
|
|
17
|
+
raise ValueError('Duplicate anchor attribute')
|
|
18
|
+
if values:
|
|
19
|
+
if not values[0] or not values[0].strip():
|
|
20
|
+
raise ValueError('Empty anchor attribute')
|
|
21
|
+
self.declarations.append({'tag': tag, 'line': self.getpos()[0], 'column': self.getpos()[1], 'expression': values[0]})
|
|
22
|
+
|
|
23
|
+
def handle_startendtag(self, tag, attrs):
|
|
24
|
+
self.handle_starttag(tag, attrs)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def template_declarations(source):
|
|
28
|
+
parser = AnchorDeclarations()
|
|
29
|
+
parser.feed(source)
|
|
30
|
+
parser.close()
|
|
31
|
+
return parser.declarations
|
|
@@ -17,7 +17,7 @@ async function fixture(){
|
|
|
17
17
|
await writeFile(path.join(project,'harness/regions/alternate.mjs'),"import {semanticLocator} from '@antprofuse/saddle-islands-tools/tools/modules/minifish/harness/semantic-anchor.mjs';export function locate(root,name){return semanticLocator(root,name)};export function assertTarget(){}");
|
|
18
18
|
return {root,project,configFile,config,loaded:await loadProject(project,configFile)};
|
|
19
19
|
}
|
|
20
|
-
test('alternate project static closure records tool/adapter separately and copies no unrelated region',async()=>{const f=await fixture();try{const output=path.join(f.root,'artifact'),records=await packageRunner(f.loaded,output,'regions/alternate.mjs');assert(records.some(r=>r.kind==='tool'&&r.path==='harness/semantic-anchor.mjs'));for(const name of ['visual-expectation.mjs','visual-carrier.mjs','jsapi.mjs','browser.mjs']){assert(records.some(r=>r.kind==='tool'&&r.path==='harness/'+name));assert((await readFile(path.join(output,'tools/harness',name),'utf8')).length>0);}assert(records.some(r=>r.kind==='adapter'&&r.path==='harness/regions/alternate.mjs'));assert(records.every(r=>/^[a-f0-9]{64}$/.test(r.sha256)));assert(!records.some(r=>r.path.includes('T1')));assert((await readFile(path.join(output,'adapters/harness/regions/alternate.mjs'),'utf8')).includes('../../../tools/harness/semantic-anchor.mjs'));}finally{await rm(f.root,{recursive:true,force:true});}});
|
|
20
|
+
test('alternate project static closure records tool/adapter separately and copies no unrelated region',async()=>{const f=await fixture();try{const output=path.join(f.root,'artifact'),records=await packageRunner(f.loaded,output,'regions/alternate.mjs');assert(records.some(r=>r.kind==='tool'&&r.path==='harness/semantic-anchor.mjs'));for(const name of ['held-spatial.mjs','spatial-contract.mjs','region-order.mjs','visual-expectation.mjs','visual-carrier.mjs','jsapi.mjs','browser.mjs','official-bridge-dispatch.mjs']){assert(records.some(r=>r.kind==='tool'&&r.path==='harness/'+name));assert((await readFile(path.join(output,'tools/harness',name),'utf8')).length>0);}assert(records.some(r=>r.kind==='adapter'&&r.path==='harness/regions/alternate.mjs'));assert(records.every(r=>/^[a-f0-9]{64}$/.test(r.sha256)));assert(!records.some(r=>r.path.includes('T1')));assert((await readFile(path.join(output,'adapters/harness/regions/alternate.mjs'),'utf8')).includes('../../../tools/harness/semantic-anchor.mjs'));}finally{await rm(f.root,{recursive:true,force:true});}});
|
|
21
21
|
for(const [name,code] of [['unknown package',"import 'fs';"],['dependency traversal',"import '../../outside.mjs';"],['direct DOM',"export const read=root=>root.evaluate(()=>1);"],['dynamic import',"export const read=()=>import('./other.mjs');"]])test(name+' is rejected from the closed observation dependency graph',async()=>{const f=await fixture();try{await writeFile(path.join(f.project,'harness/regions/alternate.mjs'),code);await writeFile(path.join(f.project,'outside.mjs'),'export const leaked=true;');await assert.rejects(packageRunner(f.loaded,path.join(f.root,'artifact'),'regions/alternate.mjs'));}finally{await rm(f.root,{recursive:true,force:true});}});
|
|
22
22
|
test('symlink aliases, undeclared module, missing config, and package output are rejected',async()=>{const f=await fixture();try{const module=path.join(f.project,'harness/regions/alternate.mjs');await rm(module);await writeFile(path.join(f.project,'harness/other.mjs'),'export const safe=true;');await symlink('../other.mjs',module);await assert.rejects(packageRunner(f.loaded,path.join(f.root,'alias'),'regions/alternate.mjs'),/Symlink/);await assert.rejects(packageRunner(f.loaded,path.join(f.root,'unknown'),'regions/unknown.mjs'),/declared module/);await assert.rejects(loadProject(f.project,path.join(f.project,'missing.json')));await assert.rejects(packageRunner(f.loaded,path.join(f.project,'artifact'),'regions/alternate.mjs'),/outside/);}finally{await rm(f.root,{recursive:true,force:true});}});
|
|
23
23
|
test('generic composition preserves exact preparation and original ID mappings without project names',()=>{const noop=()=>{},policy={validateSemanticTargets:noop,supplementalDefinition:()=>null,officialTitle:()=>false,validatePreparationOverrides:noop,validateFixtureTypes:noop,validateSpecialBoundaries:noop,validateIndependentFixture:noop,validatePacketPlan:noop,validatePluginEvents:noop,validatePluginAssertionIdentity:noop,validateObservationModuleName:noop};const catalog={cases:[{id:'alternate / page / value / content',title:'value'}]},definition={caseId:catalog.cases[0].id,ancestors:['open'],basis:[{path:'alternate/source.yaml'}],preparation:{},assertions:[{id:'value',kind:'text',target:'alternate / page / value',expected:'42',count:1}]},fragment={id:'open',inputs:{query:{key:'42'}},boundaries:[],actions:[{kind:'open-page'}]};const plan=compileCase(catalog,definition,[fragment],policy);assert.deepEqual(plan.inputs,fragment.inputs);assert.deepEqual(plan.assertions,definition.assertions);assert.equal(plan.case.id,definition.caseId);assert.throws(()=>compileCase(catalog,{...definition,ancestors:['unknown']},[fragment],policy),/missing preparation/);});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {validateSpatial} from '../harness/spatial-contract.mjs';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import {execFileSync} from 'node:child_process';
|
|
5
|
+
import {fileURLToPath} from 'node:url';
|
|
6
|
+
export function sourceComponents(inventory,active,retired){
|
|
7
|
+
const output=execFileSync('python3',[fileURLToPath(new URL('../component_index_bridge.py',import.meta.url))],{
|
|
8
|
+
input:JSON.stringify({inventory,active,retired}),encoding:'utf8',env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'}});
|
|
9
|
+
return JSON.parse(output).sort();
|
|
10
|
+
}
|
|
11
|
+
// Only actual target assertions count. Actions, sourceTarget, basis and nested
|
|
12
|
+
// strings are never recursively treated as evidence of an executed assertion.
|
|
13
|
+
const kinds=new Set(['region','hidden','visible','text','image','text-visual','button-visual','host-navigation','host-native-ui','carousel-frame']);
|
|
14
|
+
export function assertionComponents(plan){
|
|
15
|
+
assert(Array.isArray(plan?.assertions)&&plan.assertions.length,'Compiled target assertions required');
|
|
16
|
+
const seen=new Set(),components=new Set();
|
|
17
|
+
for(const a of plan.assertions){
|
|
18
|
+
assert(a&&typeof a.id==='string'&&a.id&&!seen.has(a.id),'Unique assertion identity required');seen.add(a.id);
|
|
19
|
+
if(a.spatial!==undefined){
|
|
20
|
+
assert(a.kind==='region'&&(plan.beforeTarget??[]).some(x=>x.kind==='press'),'Spatial snapshot currently requires held region assertion');
|
|
21
|
+
validateSpatial(a.spatial);
|
|
22
|
+
}
|
|
23
|
+
assert(kinds.has(a.kind),'Unsupported assertion kind for component coverage: '+a.kind);
|
|
24
|
+
if(a.target===undefined){
|
|
25
|
+
assert(['host-navigation','host-native-ui','carousel-frame'].includes(a.kind),'Component assertion target required');
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
assert(typeof a.target==='string'&&/^cmp_[a-f0-9]{6}$/.test(a.target),'Assertion must use allocated component ID');
|
|
29
|
+
components.add(a.target);
|
|
30
|
+
}
|
|
31
|
+
return [...components].sort();
|
|
32
|
+
}
|
|
33
|
+
export function compareCoverage(source,rows){
|
|
34
|
+
const expected=new Set(source),actual=new Set(rows.flatMap(row=>row.componentIds));
|
|
35
|
+
const missing=[...expected].filter(x=>!actual.has(x)).sort(),extra=[...actual].filter(x=>!expected.has(x)).sort();
|
|
36
|
+
return {identityContract:'allocated-component-id',activeComponentIds:[...expected].sort(),assertedComponentIds:[...actual].sort(),missing,extra,passed:!missing.length&&!extra.length};
|
|
37
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {createRequire, registerHooks} from 'node:module';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {pathToFileURL} from 'node:url';
|
|
4
|
+
const modules = process.argv[2];
|
|
5
|
+
if (modules) {
|
|
6
|
+
const require = createRequire(path.resolve(modules,'../package.json'));
|
|
7
|
+
registerHooks({resolve(specifier, context, next) {
|
|
8
|
+
if(specifier === 'typescript')return {url:pathToFileURL(require.resolve(specifier)).href,shortCircuit:true};
|
|
9
|
+
return next(specifier,context);
|
|
10
|
+
}});
|
|
11
|
+
}
|
|
12
|
+
const {anchorValues} = await import('./anchor-values.mjs');
|
|
13
|
+
let data='';for await (const part of process.stdin)data+=part;
|
|
14
|
+
const values=JSON.parse(data);
|
|
15
|
+
if(!Array.isArray(values))throw new Error('Anchor declarations array required');
|
|
16
|
+
console.log(JSON.stringify(values.map(value=>{try{return {ok:true,...anchorValues(value)};}catch(error){return {ok:false,error:error.message};}})));
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Static over-approximation of anchor strings. Never executes product code. */
|
|
2
|
+
import ts from 'typescript';
|
|
3
|
+
|
|
4
|
+
export function anchorValues(attribute, {limit = 256} = {}) {
|
|
5
|
+
if (typeof attribute !== 'string' || !attribute.trim()) throw new Error('Empty anchor declaration');
|
|
6
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 4096) throw new Error('Invalid expansion limit');
|
|
7
|
+
function bounded(values) {
|
|
8
|
+
const result = [...new Set(values)];
|
|
9
|
+
if (result.length > limit) throw new Error('Anchor expansion limit exceeded');
|
|
10
|
+
return result;
|
|
11
|
+
}
|
|
12
|
+
function join(left, right) {
|
|
13
|
+
if (left.length * right.length > limit) throw new Error('Anchor expansion limit exceeded');
|
|
14
|
+
return bounded(left.flatMap(a => right.map(b => a + b)));
|
|
15
|
+
}
|
|
16
|
+
function expression(source) {
|
|
17
|
+
const file = ts.createSourceFile('anchor.ts', `const __anchor = (${source});`, ts.ScriptTarget.Latest, true);
|
|
18
|
+
if (file.parseDiagnostics.length || file.statements.length !== 1) throw new Error('Invalid anchor expression');
|
|
19
|
+
const declaration = file.statements[0].declarationList?.declarations;
|
|
20
|
+
if (declaration?.length !== 1 || !declaration[0].initializer) throw new Error('Invalid anchor expression');
|
|
21
|
+
function visit(node) {
|
|
22
|
+
if (ts.isParenthesizedExpression(node)) return visit(node.expression);
|
|
23
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return [node.text];
|
|
24
|
+
if (ts.isConditionalExpression(node)) return bounded([...visit(node.whenTrue), ...visit(node.whenFalse)]);
|
|
25
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) return join(visit(node.left), visit(node.right));
|
|
26
|
+
if (ts.isTemplateExpression(node)) {
|
|
27
|
+
let values = [node.head.text];
|
|
28
|
+
for (const span of node.templateSpans) values = join(values, visit(span.expression)).map(value => value + span.literal.text);
|
|
29
|
+
return bounded(values);
|
|
30
|
+
}
|
|
31
|
+
throw new Error('Unresolved dynamic anchor expression: ' + ts.SyntaxKind[node.kind]);
|
|
32
|
+
}
|
|
33
|
+
return visit(declaration[0].initializer);
|
|
34
|
+
}
|
|
35
|
+
// Mustache expressions are delimited by their source text; ambiguous nested
|
|
36
|
+
// braces are rejected rather than silently treated as a fixed identifier.
|
|
37
|
+
let values = [''], position = 0;
|
|
38
|
+
while (position < attribute.length) {
|
|
39
|
+
const open = attribute.indexOf('{{', position);
|
|
40
|
+
if (open < 0) {
|
|
41
|
+
const tail = attribute.slice(position);
|
|
42
|
+
if (tail.includes('}}')) throw new Error('Unbalanced anchor interpolation');
|
|
43
|
+
values = values.map(v => v + tail); break;
|
|
44
|
+
}
|
|
45
|
+
const literal = attribute.slice(position, open);
|
|
46
|
+
if (literal.includes('}}')) throw new Error('Unbalanced anchor interpolation');
|
|
47
|
+
values = values.map(v => v + literal);
|
|
48
|
+
const close = attribute.indexOf('}}', open + 2);
|
|
49
|
+
if (close < 0) throw new Error('Unbalanced anchor interpolation');
|
|
50
|
+
const body = attribute.slice(open + 2, close);
|
|
51
|
+
if (body.includes('{{')) throw new Error('Nested anchor interpolation unsupported');
|
|
52
|
+
values = join(values, expression(body)); position = close + 2;
|
|
53
|
+
}
|
|
54
|
+
if (values.some(v => !v.trim())) throw new Error('Empty possible anchor');
|
|
55
|
+
return {values: bounded(values), precision: attribute.includes('{{') ? 'conservative-finite' : 'literal'};
|
|
56
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {sourceComponents,assertionComponents,compareCoverage} from './anchor-coverage.mjs';
|
|
1
2
|
import assert from 'node:assert/strict';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import {readFile,realpath,mkdir,writeFile} from 'node:fs/promises';
|
|
@@ -37,6 +38,20 @@ export async function compileCheck(options){
|
|
|
37
38
|
const manifest=await readTracked(options.manifest),catalog=await readTracked(path.resolve(root,config.catalog));
|
|
38
39
|
const parsedManifest=JSON.parse(manifest.bytes),parsedCatalog=JSON.parse(catalog.bytes);
|
|
39
40
|
const rows=activeCases(parsedManifest,parsedCatalog);
|
|
41
|
+
assert(config.anchorInventory?.path&&/^[a-f0-9]{64}$/.test(config.anchorInventory.sha256??''),'Owner-frozen anchorInventory required');
|
|
42
|
+
const inventory=await readTracked(path.resolve(root,config.anchorInventory.path));
|
|
43
|
+
assert.equal(sha(inventory.bytes),config.anchorInventory.sha256,'Source component inventory hash mismatch');
|
|
44
|
+
assert(projectPath.endsWith('/code/minifish-tests'),'Expected business test directory');
|
|
45
|
+
const registryPath=projectPath.slice(0,-'minifish-tests'.length)+'.registry';
|
|
46
|
+
assert(!git('status','--porcelain','--untracked-files=all','--',registryPath),'Registry must be clean');
|
|
47
|
+
const registry={},registryText=[];
|
|
48
|
+
for(const name of ['anchors.jsonc','anchors-retired.jsonc']){
|
|
49
|
+
const relative=registryPath+'/'+name;
|
|
50
|
+
assert(git('ls-tree',head,'--',relative).startsWith('100644 '),'Regular committed registry file required');
|
|
51
|
+
const bytes=execFileSync('git',['-C',repo,'show',head+':'+relative]);
|
|
52
|
+
registry[name]={path:relative,sha256:sha(bytes)};registryText.push(bytes.toString('utf8'));
|
|
53
|
+
}
|
|
54
|
+
const sourceComponentIds=sourceComponents(JSON.parse(inventory.bytes),...registryText);
|
|
40
55
|
const adapter=await import(pathToFileURL(await project.resolveAdapter(config.compilerAdapter)).href);
|
|
41
56
|
const definitionRoot=await realpath(path.resolve(root,config.definitions)),results=[];
|
|
42
57
|
for(const row of rows){
|
|
@@ -49,13 +64,16 @@ export async function compileCheck(options){
|
|
|
49
64
|
if(dir===definitionRoot)break;assert(dir.startsWith(definitionRoot+path.sep),'Preparation escaped root');
|
|
50
65
|
}
|
|
51
66
|
assert.deepEqual(definition.ancestors,fragments.map(f=>f.id),'Preparation ancestry mismatch');
|
|
52
|
-
await adapter.compile({projectRoot:root,config,catalog:parsedCatalog,definition,fragments});
|
|
53
|
-
|
|
67
|
+
const plan=await adapter.compile({projectRoot:root,config,catalog:parsedCatalog,definition,fragments});
|
|
68
|
+
const componentIds=assertionComponents(plan);
|
|
69
|
+
results.push({id:row.definition,definition:row.definition,status:'passed',componentIds});
|
|
54
70
|
}catch(e){results.push({id:row.definition,definition:row.definition,status:'failed',error:e.message});}
|
|
55
71
|
}
|
|
56
72
|
git('diff','--exit-code','HEAD','--');assert.equal(git('rev-parse','HEAD'),head,'Candidate changed during compilation');
|
|
57
|
-
const
|
|
73
|
+
const anchorCoverage=compareCoverage(sourceComponentIds,results.filter(r=>r.status==='passed'));
|
|
74
|
+
const result={schemaVersion:2,kind:'minifish-full-compile',head,projectPath,projectTree:git('rev-parse',head+':'+projectPath),
|
|
58
75
|
manifest:{path:manifest.relative,sha256:sha(manifest.bytes)},catalog:{path:catalog.relative,sha256:sha(catalog.bytes)},
|
|
76
|
+
anchorInventory:{path:inventory.relative,sha256:sha(inventory.bytes)},anchorCoverage,registry,
|
|
59
77
|
toolIdentity:await toolIdentity(config.toolSource,path.resolve(root,config.toolSourceManifest)),
|
|
60
78
|
total:results.length,passed:results.filter(r=>r.status==='passed').length,failed:results.filter(r=>r.status==='failed').length,
|
|
61
79
|
nonExecutable:(parsedManifest.pending??[]).map(r=>({id:r.sourceCaseId,reason:r.reason})),
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Stable allocated references. Mapping changes never imply business decisions."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import secrets
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def fingerprint(value):
|
|
9
|
+
return hashlib.sha256(json.dumps(value,ensure_ascii=False,sort_keys=True,separators=(',',':')).encode()).hexdigest()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def source_ids(inventory):
|
|
13
|
+
if inventory.get('schemaVersion')!=2 or inventory.get('identityContract')!='islands-point-id':
|
|
14
|
+
raise ValueError('Official inventory required')
|
|
15
|
+
if not re.fullmatch('[a-f0-9]{64}',inventory.get('source',{}).get('manifestSha256','')):
|
|
16
|
+
raise ValueError('Frozen source identity required')
|
|
17
|
+
seen=set(); components=set()
|
|
18
|
+
for row in inventory.get('targets',[]):
|
|
19
|
+
key=row.get('id')
|
|
20
|
+
if not isinstance(key,str) or not key.startswith('视图:') or key!=row.get('target') or key in seen:
|
|
21
|
+
raise ValueError('Invalid or duplicate source identity')
|
|
22
|
+
seen.add(key)
|
|
23
|
+
if row.get('kind')=='component': components.add(key)
|
|
24
|
+
if not components: raise ValueError('Empty component inventory')
|
|
25
|
+
return components
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate(index, inventory=None, retired=None):
|
|
29
|
+
if not isinstance(index,dict): raise ValueError('Active index must be an object')
|
|
30
|
+
def valid(key): return isinstance(key,str) and re.fullmatch('cmp_[a-f0-9]{6}',key)
|
|
31
|
+
if any(not valid(k) or not isinstance(v,str) or not v.startswith('视图:') for k,v in index.items()):
|
|
32
|
+
raise ValueError('Invalid component mapping')
|
|
33
|
+
if len(set(index.values()))!=len(index): raise ValueError('Duplicate source mapping')
|
|
34
|
+
if retired is not None:
|
|
35
|
+
if not isinstance(retired,list) or any(not valid(k) for k in retired) or len(set(retired))!=len(retired):
|
|
36
|
+
raise ValueError('Invalid retired ID list')
|
|
37
|
+
if set(index)&set(retired): raise ValueError('Active and retired IDs overlap')
|
|
38
|
+
if inventory is not None and set(index.values())!=source_ids(inventory):
|
|
39
|
+
raise ValueError('Incomplete component mapping')
|
|
40
|
+
return dict(index)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def reconcile(inventory, previous=None, remap=None, retire=(), token=None, retired=None):
|
|
44
|
+
"""Return active map and retired IDs. Never infer business identity changes."""
|
|
45
|
+
expected=source_ids(inventory); token=token or (lambda:secrets.token_hex(3))
|
|
46
|
+
previous={} if previous is None else previous
|
|
47
|
+
retired=[] if retired is None else retired
|
|
48
|
+
active=validate(previous,retired=retired)
|
|
49
|
+
remap={} if remap is None else remap
|
|
50
|
+
if not isinstance(remap,dict): raise ValueError('Remap object required')
|
|
51
|
+
if not isinstance(retire,(list,tuple)) or len(set(retire))!=len(retire): raise ValueError('Invalid retire list')
|
|
52
|
+
removing=set(retire)
|
|
53
|
+
if (set(remap)|removing)-active.keys(): raise ValueError('Only active references can change')
|
|
54
|
+
if set(remap)&removing: raise ValueError('Cannot remap and retire same reference')
|
|
55
|
+
for key,destination in remap.items():
|
|
56
|
+
if destination not in expected: raise ValueError('Unknown remap destination')
|
|
57
|
+
active[key]=destination
|
|
58
|
+
for key in removing: del active[key]
|
|
59
|
+
validate(active)
|
|
60
|
+
if set(active.values())-expected: raise ValueError('Removed source requires explicit remap or retirement')
|
|
61
|
+
reserved=set(retired)|removing
|
|
62
|
+
used=set(previous)|reserved
|
|
63
|
+
new_sources=[r['id'] for r in inventory['targets'] if r.get('kind')=='component' and r['id'] not in set(active.values())]
|
|
64
|
+
for source in new_sources:
|
|
65
|
+
for _ in range(1024):
|
|
66
|
+
key='cmp_'+token()
|
|
67
|
+
if not re.fullmatch('cmp_[a-f0-9]{6}',key): raise ValueError('Invalid random allocation')
|
|
68
|
+
if key not in used: break
|
|
69
|
+
else: raise ValueError('Collision retry limit reached')
|
|
70
|
+
used.add(key);active[key]=source
|
|
71
|
+
result=active; history=list(retired)+list(retire)
|
|
72
|
+
validate(result,inventory,history)
|
|
73
|
+
return result,history
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def difference(inventory, previous):
|
|
77
|
+
current=source_ids(inventory)
|
|
78
|
+
active=validate(previous) if previous else {}
|
|
79
|
+
old=set(active.values())
|
|
80
|
+
return {'addedSourceIds':sorted(current-old),'removedSourceIds':sorted(old-current),
|
|
81
|
+
'unchangedSourceIds':sorted(current&old)}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def registry_text(value):
|
|
86
|
+
"""One active mapping or retired ID per line; deterministic Git-friendly JSON."""
|
|
87
|
+
if isinstance(value,dict):
|
|
88
|
+
validate(value)
|
|
89
|
+
elif isinstance(value,list):
|
|
90
|
+
validate({},retired=value)
|
|
91
|
+
else: raise ValueError('Registry object or retired list required')
|
|
92
|
+
dump=lambda x:json.dumps(x,ensure_ascii=False)
|
|
93
|
+
if isinstance(value,dict):
|
|
94
|
+
lines=[' '+dump(k)+': '+dump(v)+',' for k,v in value.items()]
|
|
95
|
+
return '{\n'+'\n'.join(lines)+('\n' if lines else '')+'\n}\n'
|
|
96
|
+
lines=[' '+dump(k)+',' for k in value]
|
|
97
|
+
return '[\n'+'\n'.join(lines)+('\n' if lines else '')+'\n]\n'
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_registry(text):
|
|
102
|
+
"""Read the canonical one-entry-per-line JSONC registry subset, no comments."""
|
|
103
|
+
lines=text.splitlines()
|
|
104
|
+
if len(lines)<3 or (lines[0],lines[-1]) not in (('{','}'),('[',']')):
|
|
105
|
+
raise ValueError('Invalid registry JSONC framing')
|
|
106
|
+
obj=lines[0]=='{'; result={} if obj else []
|
|
107
|
+
for line in lines[1:-1]:
|
|
108
|
+
if not line.strip(): continue
|
|
109
|
+
if not line.endswith(','): raise ValueError('Registry entries require trailing comma')
|
|
110
|
+
raw=line[:-1]
|
|
111
|
+
if obj:
|
|
112
|
+
def unique(pairs):
|
|
113
|
+
result={}
|
|
114
|
+
for key,value in pairs:
|
|
115
|
+
if key in result: raise ValueError('Duplicate allocated ID')
|
|
116
|
+
result[key]=value
|
|
117
|
+
return result
|
|
118
|
+
row=json.loads('{'+raw+'}',object_pairs_hook=unique)
|
|
119
|
+
if len(row)!=1: raise ValueError('Exactly one mapping per line required')
|
|
120
|
+
key=next(iter(row))
|
|
121
|
+
if key in result: raise ValueError('Duplicate allocated ID')
|
|
122
|
+
result.update(row)
|
|
123
|
+
else: result.append(json.loads(raw))
|
|
124
|
+
if text!=registry_text(result): raise ValueError('Noncanonical registry formatting')
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main(argv, identity):
|
|
129
|
+
import argparse
|
|
130
|
+
from pathlib import Path
|
|
131
|
+
parser=argparse.ArgumentParser(description=__doc__)
|
|
132
|
+
parser.add_argument('operation',choices=['reconcile','verify','diff'])
|
|
133
|
+
parser.add_argument('--inventory',type=Path,required=True)
|
|
134
|
+
parser.add_argument('--inventory-sha256',required=True)
|
|
135
|
+
parser.add_argument('--index',type=Path)
|
|
136
|
+
parser.add_argument('--index-sha256')
|
|
137
|
+
parser.add_argument('--retired',type=Path)
|
|
138
|
+
parser.add_argument('--retired-sha256')
|
|
139
|
+
parser.add_argument('--changes',type=Path)
|
|
140
|
+
parser.add_argument('--output',type=Path)
|
|
141
|
+
args=parser.parse_args(argv)
|
|
142
|
+
def read(path,sha,registry=False):
|
|
143
|
+
raw=path.read_bytes()
|
|
144
|
+
if hashlib.sha256(raw).hexdigest()!=sha: raise ValueError('Input hash mismatch: '+str(path))
|
|
145
|
+
return parse_registry(raw.decode()) if registry else json.loads(raw)
|
|
146
|
+
inventory=read(args.inventory,args.inventory_sha256)
|
|
147
|
+
if bool(args.index)!=bool(args.index_sha256): raise ValueError('Index path and hash required together')
|
|
148
|
+
previous=read(args.index,args.index_sha256,True) if args.index else None
|
|
149
|
+
if bool(args.retired)!=bool(args.retired_sha256): raise ValueError('Retired path and hash required together')
|
|
150
|
+
if bool(args.index)!=bool(args.retired): raise ValueError('Existing registry requires both files')
|
|
151
|
+
retired=read(args.retired,args.retired_sha256,True) if args.retired else []
|
|
152
|
+
if previous is not None:
|
|
153
|
+
validate(previous,retired=retired)
|
|
154
|
+
for file,value in [(args.index,previous),(args.retired,retired)]:
|
|
155
|
+
if file.read_bytes()!=registry_text(value).encode('utf-8'):
|
|
156
|
+
raise ValueError('Noncanonical registry formatting: '+str(file))
|
|
157
|
+
if args.operation=='verify':
|
|
158
|
+
if previous is None or args.changes or args.output: raise ValueError('Verify requires index, no changes or output')
|
|
159
|
+
print(json.dumps({'passed':True,'activeComponents':len(validate(previous,inventory,retired))}))
|
|
160
|
+
return 0
|
|
161
|
+
if args.operation=='diff':
|
|
162
|
+
if args.changes or args.output: raise ValueError('Diff does not accept changes or output')
|
|
163
|
+
print(json.dumps(difference(inventory,previous),ensure_ascii=False))
|
|
164
|
+
return 0
|
|
165
|
+
if not args.output: raise ValueError('New output path required')
|
|
166
|
+
changes=json.loads(args.changes.read_text()) if args.changes else {}
|
|
167
|
+
if not isinstance(changes,dict) or set(changes)-{'remap','retire'}: raise ValueError('Unknown change fields')
|
|
168
|
+
result,history=reconcile(inventory,previous,changes.get('remap'),changes.get('retire',[]),retired=retired)
|
|
169
|
+
# A new staging directory prevents partially overwriting the live pair.
|
|
170
|
+
# Consumers accept both files in one Git commit after verification.
|
|
171
|
+
args.output.mkdir(parents=True,exist_ok=False)
|
|
172
|
+
for name,value in [('anchors.jsonc',result),('anchors-retired.jsonc',history)]:
|
|
173
|
+
with (args.output/name).open('x') as stream:
|
|
174
|
+
stream.write(registry_text(value))
|
|
175
|
+
print(json.dumps({'output':str(args.output),'activeComponents':len(result),
|
|
176
|
+
'toolCommit':identity['commit']},ensure_ascii=False))
|
|
177
|
+
return 0
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Internal Node compiler bridge to the single registry parser/validator."""
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import sys
|
|
5
|
+
sys.dont_write_bytecode = True
|
|
6
|
+
sys.path.insert(0,str(Path(__file__).resolve().parents[3]))
|
|
7
|
+
from tools.modules.minifish.component_index import parse_registry,validate
|
|
8
|
+
|
|
9
|
+
if __name__=='__main__':
|
|
10
|
+
request=json.load(sys.stdin)
|
|
11
|
+
active=parse_registry(request['active'])
|
|
12
|
+
retired=parse_registry(request['retired'])
|
|
13
|
+
validate(active,request['inventory'],retired)
|
|
14
|
+
print(json.dumps(list(active)))
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {adaptOfficialBridgeDispatch} from './official-bridge-dispatch.mjs';
|
|
1
2
|
import {installHostDeviceFixtureEvidence} from './host-device-fixture.mjs';
|
|
2
3
|
import {nativeUiResultPath} from './native-ui-contract.mjs';
|
|
3
4
|
import {observeLocalNavigationCandidate,localNavigationEvidence,delegateLocalNavigation,assertLocalNavigationComplete} from './local-navigation-evidence.mjs';
|
|
@@ -9,7 +10,7 @@ import {pluginPackagePlan} from './plugin-package.mjs';
|
|
|
9
10
|
import {installHostLanguage} from './host-language.mjs';
|
|
10
11
|
import {installResponseConsumption} from './response-consumption.mjs';
|
|
11
12
|
import { fulfillRpc } from './rpc-wire.mjs';
|
|
12
|
-
import { bridgePath, jsapiPath, jsapiDeliveryPath, acknowledgeJsapiDelivery, bridgeScript, fulfillJsapi, businessJsapiOption } from './jsapi.mjs';
|
|
13
|
+
import { bridgePath, jsapiPath, jsapiDeliveryPath, acknowledgeJsapiDelivery, bridgeScript, managedMockApis, fulfillJsapi, businessJsapiOption } from './jsapi.mjs';
|
|
13
14
|
|
|
14
15
|
// Official Lyra keeps earlier pages painted in the same simulator stack.
|
|
15
16
|
// The last non-exiting sibling is the active shell; route name alone is ambiguous.
|
|
@@ -42,7 +43,7 @@ export async function openMinifish({ page, plan, query, simulatorUrl, pageRoute,
|
|
|
42
43
|
const hostDeviceEvidence=await installHostDeviceFixtureEvidence(page);
|
|
43
44
|
const loadingObservation=await installLoadingObservation(page);
|
|
44
45
|
await prepareIPhone16ProDevice(page,url);
|
|
45
|
-
const faults = [], blocked = [], materials = [];
|
|
46
|
+
const faults = [], blocked = [], materials = [], bridgeDispatchAdapters = [];
|
|
46
47
|
const officialUi=[...new Set(plan.evidence().expectations.filter(r=>r.execution==='official-ui').map(r=>r.request.operation.slice(6)))];
|
|
47
48
|
const pluginPlan=plugin?pluginPackagePlan({appAddress:url.searchParams.get('appAddress'),appId:url.searchParams.get('appId'),...plugin}):null;
|
|
48
49
|
const pluginEvents=plugin?.events?pluginEventLedger(plugin.events,validatePluginEvents):null;
|
|
@@ -53,6 +54,14 @@ export async function openMinifish({ page, plan, query, simulatorUrl, pageRoute,
|
|
|
53
54
|
const handler = async (route) => {
|
|
54
55
|
const request = route.request(), target = new URL(request.url());
|
|
55
56
|
try {
|
|
57
|
+
if(target.origin===url.origin&&target.pathname==='/lyra/runtime.bundle.js'){
|
|
58
|
+
assert.equal(request.method(),'GET');
|
|
59
|
+
const response=await route.fetch();
|
|
60
|
+
assert(response.ok(),'Official bridge runtime fetch failed');
|
|
61
|
+
const adapted=adaptOfficialBridgeDispatch(await response.text(),{managed:managedMockApis(nativeApis),officialUi});
|
|
62
|
+
bridgeDispatchAdapters.push(adapted.evidence);
|
|
63
|
+
return await route.fulfill({response,body:adapted.body});
|
|
64
|
+
}
|
|
56
65
|
if (target.origin === url.origin && target.pathname === bridgePath) return await route.fulfill({contentType:'application/javascript',body:bridgeScript(url.origin,nativeApis,Boolean(process.env.MINIFISH_LOADING_DIAGNOSTICS_DIR),officialUi,process.env.MINIFISH_CALLBACK_CORRELATION==='1')});
|
|
57
66
|
if(target.origin===url.origin&&target.pathname===jsapiDeliveryPath)return await acknowledgeJsapiDelivery(route,plan);
|
|
58
67
|
if (target.origin === url.origin && target.pathname === jsapiPath) {const wire=request.postDataJSON();callbackCorrelation?.transport(wire,request.frame());observeLocalNavigationCandidate(page,wire);if(officialUi.includes(wire.api)){plan.recordNativeUiRequest('jsapi:'+wire.api,businessJsapiOption(wire.option),wire);return await route.fulfill({status:204});}if(delegateLocalNavigation(page,wire))return await route.fulfill({status:204});return await fulfillJsapi(route, plan);}
|
|
@@ -115,6 +124,7 @@ export async function openMinifish({ page, plan, query, simulatorUrl, pageRoute,
|
|
|
115
124
|
capturePayGuide:(stage)=>guideDiagnostic?.capture(stage),
|
|
116
125
|
flushPayGuide:()=>guideDiagnostic?.flush(),
|
|
117
126
|
assertComplete() {
|
|
127
|
+
assert(bridgeDispatchAdapters.length>0,'Official bridge compatibility adapter was not observed');
|
|
118
128
|
assertLocalNavigationComplete(page,plan.evidence(),queryExpectations);
|
|
119
129
|
assert.deepEqual(faults, [], 'Network violations remain failures even when caught by product');
|
|
120
130
|
plan.assertComplete();
|
|
@@ -123,8 +133,9 @@ export async function openMinifish({ page, plan, query, simulatorUrl, pageRoute,
|
|
|
123
133
|
if(requireHostLanguage)host.assertObserved();
|
|
124
134
|
if(hostSystemPlan)host.assertSystemPlan(hostSystemPlan);
|
|
125
135
|
},
|
|
126
|
-
evidence() { return { ...plan.evidence(), hostDeviceFixture:hostDeviceEvidence.evidence(), callbackCorrelation:callbackCorrelation?.evidence()??[], payGuideDiagnostics:guideDiagnostic?.evidence()??[], hostLanguage:host.evidence(), localNavigation:localNavigationEvidence(page), networkViolations: [...faults], blocked: [...blocked], materials: [...materials], plugin:pluginPlan?.evidence()??null, pluginEvents:pluginEvents?.evidence()??null }; },
|
|
136
|
+
evidence() { return { ...plan.evidence(), bridgeDispatchAdapters:[...bridgeDispatchAdapters], hostDeviceFixture:hostDeviceEvidence.evidence(), callbackCorrelation:callbackCorrelation?.evidence()??[], payGuideDiagnostics:guideDiagnostic?.evidence()??[], hostLanguage:host.evidence(), localNavigation:localNavigationEvidence(page), networkViolations: [...faults], blocked: [...blocked], materials: [...materials], plugin:pluginPlan?.evidence()??null, pluginEvents:pluginEvents?.evidence()??null }; },
|
|
127
137
|
assertArtifactComplete(skipped){
|
|
138
|
+
assert(bridgeDispatchAdapters.length>0,'Official bridge compatibility adapter was not observed');
|
|
128
139
|
assertLocalNavigationComplete(page,plan.evidence(),queryExpectations);
|
|
129
140
|
assert.deepEqual(faults, [], 'Artifact network violations remain failures');
|
|
130
141
|
const audit=plan.assertArtifactComplete(skipped);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {validateFrozenSpatial} from './held-spatial.mjs';
|
|
1
2
|
import {assertVisualMetrics} from './visual-expectation.mjs';
|
|
2
3
|
import assert from 'node:assert/strict';
|
|
3
4
|
import {sampleNativeMetrics,sampleNativeTextVisibility,sampleNativeStyle,sampleNativePressed,sampleHeldNativeState} from './native-dom-sampling.mjs';
|
|
@@ -32,10 +33,10 @@ export async function captureHeldSnapshot(root,spec){
|
|
|
32
33
|
const timeline=root.ownerDocument.defaultView.__seeleHeldTimelineV1;if(!timeline)throw Error('Held timeline must be installed before pressing');
|
|
33
34
|
const now=()=>performance.timeOrigin+performance.now();const start=timeline.events.find(e=>e.type==='touchstart')??timeline.events.find(e=>e.type==='mousedown');if(!start)throw Error('No actual native down event');
|
|
34
35
|
let groups;while(true){const elements=[...root.ownerDocument.querySelectorAll('*')];groups=spec.targets.map(target=>({target,nodes:elements.filter(node=>{const id=identity(node);return matches(id,target.target,target.instance);})}));
|
|
35
|
-
if(groups.every(g=>g.nodes.length)||now()-start.time>=350)break;
|
|
36
|
+
if(groups.every(g=>g.target.waitForPresence===false||g.nodes.length)||now()-start.time>=350)break;
|
|
36
37
|
await new Promise(resolve=>{const timer=setTimeout(resolve,Math.max(0,350-(now()-start.time)));requestAnimationFrame(()=>{clearTimeout(timer);resolve();});});
|
|
37
38
|
}
|
|
38
|
-
const captureStartedAt=now();const result={version:spec.version,captureStartedAt,pressAt:start.time,timedOut:groups.some(g
|
|
39
|
+
const captureStartedAt=now();const result={version:spec.version,captureStartedAt,pressAt:start.time,timedOut:groups.some(g=>g.target.waitForPresence!==false&&!g.nodes.length),groups:groups.map(g=>({key:g.target.key,target:g.target.target,instance:g.target.instance,count:g.nodes.length,members:g.nodes.map(node=>{const m=metrics(node);m.frameId=spec.frameId;for(const p of [...m.imagePaints,...m.imagePaintInventory])p.frameId=spec.frameId;m.text=node.textContent.trim();m.styles={...m.styles,...style(node,{keys:g.target.styleKeys})};return {identity:identity(node),state:state(node),active:pressed(node),metrics:m,textPaint:paint(node)};})})),eventsAtSnapshot:timeline.events.slice()};result.snapshotAt=now();return result;
|
|
39
40
|
}`;
|
|
40
41
|
return freezeHeldSnapshot(await root.locator(':root').evaluate(Function('return ('+body+')')(),spec));
|
|
41
42
|
}
|
|
@@ -57,7 +58,8 @@ export function validateFrozenHeldAssertion(snapshot,a,validators){
|
|
|
57
58
|
if(a.kind==='region'&&a.check==='visual'){assert(!a.visual.carrier||a.visual.carrier==='semantic-root','Held visual carrier not captured');assertVisualMetrics(member.metrics,a.visual);}
|
|
58
59
|
if(a.textFits)validateNativeTextVisibility(member.textPaint,{selfOverflow:'contain'});observations.push({...member.metrics,...(a.textFits?{textPaint:member.textPaint}:{})});
|
|
59
60
|
}
|
|
60
|
-
|
|
61
|
+
const spatial=a.spatial?validateFrozenSpatial(snapshot,a.spatial):undefined;
|
|
62
|
+
return {...(spatial?{spatial}:{}),count:group.count,visible:group.members.map(m=>m.state.visible),observations,snapshotVersion:snapshot.version,snapshotAt:snapshot.snapshotAt};
|
|
61
63
|
}
|
|
62
64
|
// The release happens in finally before any Node validator or boundary wait.
|
|
63
65
|
export async function executeHeldSnapshot({root,spec,release,validators,capture=captureHeldSnapshot,readEvents=readReleasedTimeline}){
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {validateRegionOrder} from './region-order.mjs';
|
|
3
|
+
import {validateSpatial,spatialTarget as target} from './spatial-contract.mjs';
|
|
4
|
+
export {validateSpatial} from './spatial-contract.mjs';
|
|
5
|
+
export function prepareHeldWithSpatial(plan,prepareProject){
|
|
6
|
+
const clean=structuredClone(plan);
|
|
7
|
+
for(const a of clean.assertions){if(a.spatial!==undefined)validateSpatial(a.spatial);delete a.spatial;}
|
|
8
|
+
const result=prepareProject(clean);
|
|
9
|
+
result.assertions=structuredClone(plan.assertions);
|
|
10
|
+
for(const a of plan.assertions){
|
|
11
|
+
if(!a.spatial)continue;
|
|
12
|
+
const refs=[...(a.spatial.absent??[]),...(a.spatial.order??[]).flatMap(p=>[p.above,p.below])];
|
|
13
|
+
for(const ref of refs){const t=target(ref);if(!result.targets.some(existing=>existing.key===t.key))result.targets.push(t);}
|
|
14
|
+
}
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
export function validateFrozenSpatial(snapshot,spec){
|
|
18
|
+
validateSpatial(spec);
|
|
19
|
+
const group=ref=>{const t=target(ref),g=snapshot.groups.find(g=>g.key===t.key);assert(g,'Missing frozen spatial observation');return g;};
|
|
20
|
+
const box=ref=>{const g=group(ref);assert.equal(g.count,1,'Spatial target must be unique');assert.equal(g.members.length,1);const m=g.members[0];assert(m.state.visible,'Spatial target must be visible');return m.metrics.box;};
|
|
21
|
+
const order=(spec.order??[]).map(pair=>validateRegionOrder(box(pair.above),box(pair.below)));
|
|
22
|
+
const absent=(spec.absent??[]).map(ref=>{const g=group(ref);assert.equal(g.count,0,'Forbidden component exists');assert.equal(g.members.length,0);return ref;});
|
|
23
|
+
return {order,absent,snapshotAt:snapshot.snapshotAt};
|
|
24
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {prepareHeldWithSpatial} from './held-spatial.mjs';
|
|
1
2
|
import {validateRegionVisualAssertions,assertVisualMetrics} from './visual-expectation.mjs';
|
|
2
3
|
import {hostPolicy} from '@minifish/project-host';
|
|
3
4
|
import {prepareHeldNativeInput,executeHeldSnapshot} from './held-snapshot.mjs';
|
|
@@ -56,7 +57,8 @@ export async function runMiniappCase({page, plan, testInfo}) {
|
|
|
56
57
|
const locate=(root,name,options)=>locateProject(root,name,options,observation);
|
|
57
58
|
try {
|
|
58
59
|
result.preparation = 'running';
|
|
59
|
-
if(!artifactOnly&&(plan.beforeTarget??[]).some(a=>a.kind==='press'))
|
|
60
|
+
if(!artifactOnly&&plan.assertions.some(a=>a.spatial!==undefined))assert((plan.beforeTarget??[]).some(a=>a.kind==='press'),'Spatial snapshot requires held input');
|
|
61
|
+
if(!artifactOnly&&(plan.beforeTarget??[]).some(a=>a.kind==='press'))heldSpec=prepareHeldWithSpatial(plan,prepareHeldSnapshot);
|
|
60
62
|
if(plan.plugin){
|
|
61
63
|
assert(process.env.MINIFISH_PLUGIN_PACKAGE_URL&&process.env.MINIFISH_PLUGIN_ARTIFACT_MANIFEST,'Explicit compiled plugin artifact required');
|
|
62
64
|
const artifact=JSON.parse(await readFile(process.env.MINIFISH_PLUGIN_ARTIFACT_MANIFEST,'utf8'));
|
|
@@ -9,10 +9,13 @@ export const jsapiPath = '/__bill_tests__/jsapi';
|
|
|
9
9
|
export const jsapiDeliveryPath = '/__bill_tests__/jsapi-delivery';
|
|
10
10
|
const deliveryPlans = new WeakMap();
|
|
11
11
|
const deliveryHeader = 'x-minifish-response-id';
|
|
12
|
+
export function managedMockApis(additionalApis=[]){
|
|
13
|
+
return ['makePhoneCall','tradePay','getAuthCode','startApp','exitMiniProgram','exitApp',...additionalApis,...(additionalApis.includes('getStorage')?['getTinyLocalStorage']:[]),...(additionalApis.includes('setStorage')?['setTinyLocalStorage']:[])];
|
|
14
|
+
}
|
|
12
15
|
export function bridgeScript(origin, additionalApis = [], observeLoading = false, officialUi = [], observeCorrelation = false) {
|
|
13
16
|
assert(!additionalApis.includes('pageScrollTo'),'Official scroll cannot enter renderer Mock list');
|
|
14
17
|
assert(officialUi.every(api=>nativeUiApis.includes(api)),'Finite official UI delegation list');
|
|
15
|
-
const mockedApis =
|
|
18
|
+
const mockedApis = managedMockApis(additionalApis);
|
|
16
19
|
return `window.lyraRendererExtendBridge.beforeMount(params => {
|
|
17
20
|
const hostFixture=${JSON.stringify(hostDeviceFixture)};
|
|
18
21
|
const mounted=(${configureHostDeviceMount.toString()})(params,hostFixture);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
// Compatibility adapter for this exact official runtime, not an SDK public API.
|
|
5
|
+
// The original hook eligibility and native delegation stay byte-for-byte intact.
|
|
6
|
+
export const dispatchRuntimeSha256='a7401f68d3e27235736e29a3c33865ed01260e239c2853ead5482fcab63e15b8';
|
|
7
|
+
export const dispatchFragment='(e||!r||h||A)&&n.preventDefault()';
|
|
8
|
+
export function ownsMockInvocation(api,option,managed,officialUi){
|
|
9
|
+
return managed.includes(api)&&!officialUi.includes(api)
|
|
10
|
+
&&!(api==='pushWindow'&&typeof option?.url==='string'&&option.url.startsWith('#'));
|
|
11
|
+
}
|
|
12
|
+
export function adaptOfficialBridgeDispatch(source,{managed,officialUi}){
|
|
13
|
+
for(const list of [managed,officialUi])assert(Array.isArray(list)&&list.every(x=>typeof x==='string'&&x.length),'Explicit API lists required');
|
|
14
|
+
const sha256=createHash('sha256').update(source).digest('hex');
|
|
15
|
+
assert.equal(sha256,dispatchRuntimeSha256,'Unsupported official bridge runtime: refusing compatibility adaptation');
|
|
16
|
+
assert.equal(source.split(dispatchFragment).length,2,'Official dispatch fragment must be unique');
|
|
17
|
+
const condition=`(${ownsMockInvocation.toString()})(n.apiName,n.option,${JSON.stringify([...new Set(managed)])},${JSON.stringify([...new Set(officialUi)])})`;
|
|
18
|
+
const body=source.replace(dispatchFragment,`(e||!r||h||A||${condition})&&n.preventDefault()`);
|
|
19
|
+
return {body,evidence:{adapter:'mock-default-dispatch-v1',sourceSha256:sha256,adaptedSha256:createHash('sha256').update(body).digest('hex'),managed:[...new Set(managed)],officialUi:[...new Set(officialUi)]}};
|
|
20
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
const keyOf=(target,instance)=>JSON.stringify([target,instance===undefined?{unspecified:true}:instance]);
|
|
3
|
+
export function spatialTarget(ref){
|
|
4
|
+
assert(ref&&Object.keys(ref).every(k=>['target','instance'].includes(k)),'Unknown spatial target field');
|
|
5
|
+
assert(/^cmp_[0-9a-f]{6}$/.test(ref.target),'Allocated spatial component reference required');
|
|
6
|
+
assert(ref.instance===undefined||(Number.isSafeInteger(ref.instance)&&ref.instance>=0),'Invalid spatial instance');
|
|
7
|
+
return {...ref,key:keyOf(ref.target,ref.instance),styleKeys:[],waitForPresence:false};
|
|
8
|
+
}
|
|
9
|
+
export function validateSpatial(spec){
|
|
10
|
+
assert(spec&&Object.keys(spec).every(k=>['order','absent'].includes(k)),'Unknown spatial obligation');
|
|
11
|
+
for(const name of ['order','absent'])assert(spec[name]===undefined||Array.isArray(spec[name]),'Spatial obligation must be an array');
|
|
12
|
+
assert((spec.order?.length??0)+(spec.absent?.length??0)>0,'Empty spatial obligation');
|
|
13
|
+
for(const pair of spec.order??[]){
|
|
14
|
+
assert(pair&&Object.keys(pair).length===2&&pair.above&&pair.below,'Order pair required');
|
|
15
|
+
assert.notEqual(spatialTarget(pair.above).key,spatialTarget(pair.below).key,'Order requires distinct targets');
|
|
16
|
+
}
|
|
17
|
+
for(const ref of spec.absent??[])spatialTarget(ref);
|
|
18
|
+
}
|
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from .iteration import Invalid
|
|
6
6
|
from .diff_review import git
|
|
7
|
+
from tools.modules.minifish.component_index import parse_registry,validate
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
def verify_compile(repo, head, bizdir, receipt_file):
|
|
@@ -14,7 +15,7 @@ def verify_compile(repo, head, bizdir, receipt_file):
|
|
|
14
15
|
def check(ok, message):
|
|
15
16
|
if not ok:
|
|
16
17
|
raise Invalid(message)
|
|
17
|
-
check(r.get('schemaVersion') ==
|
|
18
|
+
check(r.get('schemaVersion') == 2 and r.get('kind') == 'minifish-full-compile', 'Invalid compile receipt')
|
|
18
19
|
check(r.get('head') == head and r.get('projectPath') == project, 'Stale or wrong-project compile receipt')
|
|
19
20
|
check(r.get('projectTree') == git(repo, 'rev-parse', head + ':' + project).decode().strip(), 'Compile tree mismatch')
|
|
20
21
|
def read_bound(key):
|
|
@@ -46,6 +47,33 @@ def verify_compile(repo, head, bizdir, receipt_file):
|
|
|
46
47
|
covered += [x['sourceCaseId'] for x in pending]
|
|
47
48
|
check(len(covered) == len(set(covered)) and len(official) == len(set(official)) and set(covered) == set(official), 'Incomplete catalog coverage')
|
|
48
49
|
check(r.get('nonExecutable') == [{'id': x['sourceCaseId'], 'reason': x['reason']} for x in pending], 'Nonexecutable decisions mismatch')
|
|
50
|
+
inventory = read_bound('anchorInventory')
|
|
51
|
+
check(isinstance(config.get('anchorInventory'), dict), 'Missing configured source component inventory')
|
|
52
|
+
check(r['anchorInventory']['path'] == project + '/' + config['anchorInventory']['path'] and
|
|
53
|
+
r['anchorInventory']['sha256'] == config['anchorInventory']['sha256'], 'Unexpected source component inventory')
|
|
54
|
+
registry = r.get('registry', {})
|
|
55
|
+
registry_values=[]
|
|
56
|
+
for name in ('anchors.jsonc','anchors-retired.jsonc'):
|
|
57
|
+
entry=registry.get(name,{})
|
|
58
|
+
expected=bizdir+'/code/.registry/'+name
|
|
59
|
+
check(entry.get('path')==expected,'Unexpected registry location')
|
|
60
|
+
mode=git(repo,'ls-tree',head,'--',expected).decode()
|
|
61
|
+
check(mode.startswith('100644 '),'Registry must be a regular committed file')
|
|
62
|
+
raw=git(repo,'show',head+':'+expected)
|
|
63
|
+
check(hashlib.sha256(raw).hexdigest()==entry.get('sha256'),'Registry hash mismatch')
|
|
64
|
+
try: registry_values.append(parse_registry(raw.decode()))
|
|
65
|
+
except ValueError as e: raise Invalid(str(e)) from e
|
|
66
|
+
try: active=validate(registry_values[0],inventory,registry_values[1])
|
|
67
|
+
except ValueError as e: raise Invalid(str(e)) from e
|
|
68
|
+
source_ids=list(active)
|
|
69
|
+
check(all(isinstance(x.get('componentIds'), list) and all(isinstance(i, str) for i in x['componentIds']) for x in results), 'Missing compiled assertion identities')
|
|
70
|
+
asserted = sorted({i for row in results for i in row['componentIds']})
|
|
71
|
+
check(asserted == sorted(source_ids), 'Assertion component set differs from source')
|
|
72
|
+
coverage = r.get('anchorCoverage', {})
|
|
73
|
+
check(coverage.get('identityContract') == 'allocated-component-id' and
|
|
74
|
+
coverage.get('activeComponentIds') == sorted(source_ids) and
|
|
75
|
+
coverage.get('assertedComponentIds') == asserted and
|
|
76
|
+
coverage.get('passed') is True and coverage.get('missing') == [] and coverage.get('extra') == [], 'Invalid anchor coverage result')
|
|
49
77
|
check(r.get('productExecution') is False, 'Not a static compilation receipt')
|
|
50
78
|
check(r.get('total') == len(rows) and r.get('passed') == len(rows) and r.get('failed') == 0 and all(x.get('status') == 'passed' for x in results), 'Full compilation did not pass')
|
|
51
79
|
return f"\n\nMT全量编译:{len(rows)}/{len(rows)} 通过;非执行责任 {len(pending)} 项保留独立处置。\n"
|