@antprofuse/saddle-islands-tools 0.1.9 → 0.1.13
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/component_reference.py +62 -0
- 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 +53 -1
- 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/build-case.mjs +7 -1
- package/tools/modules/minifish/compiler/compile-check.mjs +16 -3
- package/tools/modules/minifish/compiler/component-contract.mjs +37 -0
- package/tools/modules/minifish/compiler/config.mjs +1 -1
- package/tools/modules/minifish/component_index.py +192 -0
- package/tools/modules/minifish/component_index_bridge.py +28 -0
- package/tools/modules/minifish/harness/browser.mjs +14 -3
- package/tools/modules/minifish/harness/component-membership.mjs +32 -0
- package/tools/modules/minifish/harness/component-state.mjs +43 -0
- package/tools/modules/minifish/harness/held-snapshot.mjs +23 -6
- package/tools/modules/minifish/harness/held-spatial.mjs +25 -0
- package/tools/modules/minifish/harness/independent-case-runner.mjs +17 -3
- 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 +20 -0
- package/tools/modules/minifish/harness/visual-carrier.mjs +7 -0
- package/tools/modules/minifish/harness/visual-expectation.mjs +1 -1
- package/tools/modules/workflow/compile_gate.py +29 -1
package/package.json
CHANGED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Resolve compiled branch locations without inventing component identities.
|
|
2
|
+
|
|
3
|
+
This resolves source references only; it does not observe or prove product state.
|
|
4
|
+
"""
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from tools.modules.minifish.component_index import validate
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _locations(row):
|
|
10
|
+
source = row.get('source', {}).get('file')
|
|
11
|
+
locations = row.get('locations')
|
|
12
|
+
if not isinstance(source, str) or not source or not isinstance(locations, list) or not locations:
|
|
13
|
+
raise ValueError('Complete compiled source locations required')
|
|
14
|
+
result = []
|
|
15
|
+
for loc in locations:
|
|
16
|
+
if loc.get('kind') != 'visualComponent':
|
|
17
|
+
raise ValueError('Visual component location required')
|
|
18
|
+
page, path = loc.get('page'), loc.get('componentPath')
|
|
19
|
+
# Official top-level locations omit componentPath; nested ones include it.
|
|
20
|
+
if 'componentPath' not in loc:
|
|
21
|
+
path = [{'component': loc.get('component')}]
|
|
22
|
+
if not isinstance(page, str) or not page or not isinstance(path, list) or not path:
|
|
23
|
+
raise ValueError('Complete component path required')
|
|
24
|
+
for segment in path:
|
|
25
|
+
if not isinstance(segment, dict) or set(segment)-{'component', 'branch'}:
|
|
26
|
+
raise ValueError('Unsupported component location segment')
|
|
27
|
+
if any(not isinstance(value, str) or not value for value in segment.values()) or 'component' not in segment:
|
|
28
|
+
raise ValueError('Invalid component location segment')
|
|
29
|
+
if loc.get('component') != path[-1]['component']:
|
|
30
|
+
raise ValueError('Component name disagrees with compiled path')
|
|
31
|
+
result.append((source, page, deepcopy(path)))
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve_branch(inventory, index, retired, branch_id):
|
|
36
|
+
"""Bind an exact official branch ID to one cmp and its terminal state.
|
|
37
|
+
|
|
38
|
+
All ancestor branch segments are retained. No suffix/name matching or ID
|
|
39
|
+
reconstruction is allowed. Multiple inconsistent locations fail closed.
|
|
40
|
+
"""
|
|
41
|
+
active = validate(index, inventory, retired)
|
|
42
|
+
rows = inventory['targets']
|
|
43
|
+
branch = next((r for r in rows if r['id'] == branch_id), None)
|
|
44
|
+
if not branch or branch.get('kind') != 'branch':
|
|
45
|
+
raise ValueError('Exact official branch reference required')
|
|
46
|
+
reverse = {source: key for key, source in active.items()}
|
|
47
|
+
components = [(r, _locations(r)) for r in rows if r.get('kind') == 'component']
|
|
48
|
+
matches = []
|
|
49
|
+
for file, page, path in _locations(branch):
|
|
50
|
+
state = path[-1].pop('branch', None)
|
|
51
|
+
if state is None:
|
|
52
|
+
raise ValueError('Branch location has no terminal state')
|
|
53
|
+
candidates = [r for r, locations in components if (file, page, path) in locations]
|
|
54
|
+
if len(candidates) != 1:
|
|
55
|
+
raise ValueError('Branch owner must resolve uniquely by compiled location')
|
|
56
|
+
matches.append((reverse[candidates[0]['id']], candidates[0]['id'], state))
|
|
57
|
+
if len(set(matches)) != 1:
|
|
58
|
+
raise ValueError('Inconsistent branch owner locations')
|
|
59
|
+
component, source_id, state = matches[0]
|
|
60
|
+
return {'component': component, 'sourceId': source_id, 'state': state,
|
|
61
|
+
'branchSourceId': branch_id, 'evidence': 'compiled-location',
|
|
62
|
+
'observed': False}
|
|
@@ -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)
|
|
@@ -41,7 +41,7 @@ Legacy component-host integrations must supply an explicit `repositoryRoot` to `
|
|
|
41
41
|
|
|
42
42
|
可选 `carrier` 默认为 `semantic-root`。`source-content-group` 在语义root内对可见文字/图像取最低公共元素祖先;`owned-text-block` 在该组内要求唯一不属于其他语义目标的文字分支。选择与期待样式、class、原型HTML标签无关,候选缺失或歧义明确失败;不适用的拓扑不得借此猜测观察目标。`viewportInsets` 可声明 left/right/top/bottom 到业务frame视口的设计长度,返回采样记录包含原root盒及是否选中root。
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
按压快照支持 `semantic-root` 和 `exclusive-parent`,其他carrier在按下前拒绝,不能释放后采样冒充按住状态。这是明确能力限制,不能默默降级。内容分组不覆盖背景装饰/无文字无图像部件或Shadow DOM;相应责任另用适用的公开观察合同。
|
|
45
45
|
|
|
46
46
|
### 全量定义编译门禁
|
|
47
47
|
|
|
@@ -50,3 +50,55 @@ 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限制不变。关系要求两个唯一可见、非重叠且上方在前的真实载体;排除要求零匹配。缺观察、重复身份、隐藏关系目标及未知声明失败。本控制经过真实浏览器单快照验证,不等同于每个业务小程序已经验收。
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
## 组件引用、源状态与关联载体
|
|
94
|
+
|
|
95
|
+
`component-index resolve` 沿用 `--inventory`、`--inventory-sha256`、`--index`、`--index-sha256`、`--retired`、`--retired-sha256` 和外部 `--tool-source`,另传 `--source-id` 官方原文。组件返回分配ID;分支根据官方编译locations精确归属到组件并返回 `state`,保留祖先分支。页面、歧义或不完整来源拒绝,不猜测近似匹配。输出 `observed:false` 仅表示源定位,不能充当产品状态证据。
|
|
96
|
+
|
|
97
|
+
分支断言仍以 `target: "cmp_123abc"` 指定组件,并添加 `componentState: "源分支名"`。产品在同一组件根通过 `data-anchor-state` 声明实际状态,原生 `$$dataset.anchorState` 优先;公共工具独立比较实际声明,编译器只允许该组件正式源中存在的状态。不得为了测试写死预期状态:声明由实际产品状态产生,不驱动业务逻辑。该检查不代替分支的内容、样式、行为断言。缺失/错误状态必须失败;存在性为零的断言不能声称观察到状态。
|
|
98
|
+
|
|
99
|
+
`visual.carrier: "exclusive-parent"` 观察组件根的直接无锚点父包装。父包装必须仅包含这个元素子节点,不能另有非空文本,不能是document根、body或其他组件。它不成为新组件或新覆盖点;选择过程不依赖预期颜色、尺寸、类名。不符合拓扑就报告不支持,不上溯搜索一个有利节点。普通观察可使用 `nativeMetrics(locator,{carrier:'exclusive-parent'})`,业务几何期望仍来自源。held的visual和spatial引用支持该carrier,随原目标在同一次按压快照采样,释放后纯判定。
|
|
100
|
+
|
|
101
|
+
每个编译计划由公共编译器绑定冻结配置、源清单、活动/退役索引的Git提交和SHA;项目编译器不得提供或覆盖该合同。目标、动作、空间关系中的组件引用必须属于活动集合。实际运行在每个目标状态检查全部锚点声明均属于活动集合,包括隐藏节点;held使用同一次快照。该能力承接旧路径残留排除,无需为源中已删除对象分配活动ID或维护负ID列表。它不证明无标注节点或错误借用有效ID不存在,也不代替Spec规定的条件隐藏断言。产品静态集合检查及逐点内容/布局覆盖继续执行。
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
分支未出现与组件本身隐藏必须区分。仅检查分支是否出现时使用 `{kind:"component-state", target:"cmp_123abc", componentState:"源分支名", present:false}`;同一组件可处于另一个正式状态而保持可见。公共工具观察唯一组件、实际状态和可见性,未知或缺失状态不能冒充“该分支不存在”。`present:true` 验证当前可见分支。它仍是原case内的断言,不新增平行case;分支内部的具体组件及内容继续独立断言。
|
|
@@ -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 ['component-membership.mjs','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(['component-state','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,5 @@
|
|
|
1
|
+
import {validateComponentStates} from '../harness/component-state.mjs';
|
|
2
|
+
import {loadComponentContract} from './component-contract.mjs';
|
|
1
3
|
import assert from 'node:assert/strict';
|
|
2
4
|
import path from 'node:path';
|
|
3
5
|
import {readFile,mkdir,writeFile,realpath} from 'node:fs/promises';
|
|
@@ -22,7 +24,11 @@ export async function buildCase(file,output,options){
|
|
|
22
24
|
return buildPlanPackage(plan,output,options);
|
|
23
25
|
}
|
|
24
26
|
export async function buildPlanPackage(plan,output,options){
|
|
25
|
-
const project=await loadProject(options.projectRoot,options.config),identity=await toolIdentity(project.config.toolSource,project.config.toolSourceManifest?path.resolve(project.root,project.config.toolSourceManifest):undefined),destination=await checkOutput(output,project);
|
|
27
|
+
const project=await loadProject(options.projectRoot,options.config),identity=await toolIdentity(project.config.toolSource,project.config.toolSourceManifest?path.resolve(project.root,project.config.toolSourceManifest):undefined),destination=await checkOutput(output,project);
|
|
28
|
+
assert(plan.componentContract===undefined,'Project must not override compiler component contract');
|
|
29
|
+
plan={...plan,componentContract:await loadComponentContract(project)};
|
|
30
|
+
validateComponentStates(plan,plan.componentContract);
|
|
31
|
+
await mkdir(destination,{recursive:true});
|
|
26
32
|
const files=await packageRunner(project,path.join(destination,'runner'),plan.observationModule);
|
|
27
33
|
const body=`// Generated from project definitions; do not edit.\nimport {test} from '@playwright/test';\nexport const plan = ${JSON.stringify(plan,null,2)};\ntest(plan.case.id,async({page},testInfo)=>{const {runMiniappCase}=await import('./runner/independent-case-runner.mjs');await runMiniappCase({page,plan,testInfo});});\n`;
|
|
28
34
|
const artifact=path.join(destination,'case.spec.mjs');await writeFile(artifact,body);
|