@antprofuse/saddle-islands-tools 0.0.0-stage → 0.1.4
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 +15 -4
- package/tools/adapters/islands/trigger_source.py +53 -0
- package/tools/cli/backend.mjs +24 -0
- package/tools/cli/main.py +38 -0
- package/tools/cli/minifish.mjs +38 -0
- package/tools/cli/minifish.py +52 -0
- package/tools/core/package-identity.mjs +23 -0
- package/tools/core/package_identity.py +58 -0
- package/tools/core/semantic_identity.py +149 -0
- package/tools/core/test_semantic_identity.py +136 -0
- package/tools/docs/README.md +43 -0
- package/tools/docs/backend-testing.md +36 -0
- package/tools/docs/iteration.md +34 -0
- package/tools/docs/minifish-testing.md +32 -0
- package/tools/docs/semantic-identity.md +61 -0
- package/tools/modules/backend/actions.mjs +10 -0
- package/tools/modules/backend/assert.mjs +20 -0
- package/tools/modules/backend/context-receiver.py +30 -0
- package/tools/modules/backend/facility.test.mjs +44 -0
- package/tools/modules/backend/grouped_context.py +100 -0
- package/tools/modules/backend/grpc.mjs +57 -0
- package/tools/modules/backend/index.mjs +9 -0
- package/tools/modules/backend/input-tree.mjs +14 -0
- package/tools/modules/backend/logs.mjs +24 -0
- package/tools/modules/backend/logs.test.mjs +28 -0
- package/tools/modules/backend/metadata.mjs +66 -0
- package/tools/modules/backend/package-lock.json +362 -0
- package/tools/modules/backend/package.json +15 -0
- package/tools/modules/backend/paths.mjs +11 -0
- package/tools/modules/backend/product.mjs +48 -0
- package/tools/modules/backend/rejection-logs.mjs +33 -0
- package/tools/modules/backend/report.mjs +13 -0
- package/tools/modules/backend/report.test.mjs +13 -0
- package/tools/modules/backend/run.mjs +50 -0
- package/tools/modules/backend/util.mjs +24 -0
- package/tools/modules/minifish/checks/guard.test.mjs +13 -0
- package/tools/modules/minifish/checks/package.test.mjs +23 -0
- package/tools/modules/minifish/compiler/build-case.mjs +39 -0
- package/tools/modules/minifish/compiler/case-plan.mjs +175 -0
- package/tools/modules/minifish/compiler/config.mjs +29 -0
- package/tools/modules/minifish/compiler/observation-module-name.mjs +2 -0
- package/tools/modules/minifish/compiler/package-runner.mjs +43 -0
- package/tools/modules/minifish/compiler/semantic-module-gate.mjs +27 -0
- package/tools/modules/minifish/compiler/tool-identity.mjs +14 -0
- package/tools/modules/minifish/harness/batch_cases.py +91 -0
- package/tools/modules/minifish/harness/batch_plugin_groups.py +56 -0
- package/tools/modules/minifish/harness/batch_report.py +104 -0
- package/tools/modules/minifish/harness/browser.mjs +146 -0
- package/tools/modules/minifish/harness/build-plugin-artifact.mjs +25 -0
- package/tools/modules/minifish/harness/capture_contract.py +78 -0
- package/tools/modules/minifish/harness/carousel-observation.mjs +62 -0
- package/tools/modules/minifish/harness/carriers.mjs +49 -0
- package/tools/modules/minifish/harness/check-independent-batch.py +84 -0
- package/tools/modules/minifish/harness/check-runner-isolation.py +102 -0
- package/tools/modules/minifish/harness/component_runtime.py +125 -0
- package/tools/modules/minifish/harness/contract-value.mjs +32 -0
- package/tools/modules/minifish/harness/deferred-visual.mjs +21 -0
- package/tools/modules/minifish/harness/device-prepare.mjs +26 -0
- package/tools/modules/minifish/harness/execution-evidence.mjs +37 -0
- package/tools/modules/minifish/harness/foreground.mjs +20 -0
- package/tools/modules/minifish/harness/header-layout.mjs +47 -0
- package/tools/modules/minifish/harness/held-snapshot.mjs +67 -0
- package/tools/modules/minifish/harness/host-device-fixture.mjs +28 -0
- package/tools/modules/minifish/harness/host-language.mjs +73 -0
- package/tools/modules/minifish/harness/host-native-ui.mjs +39 -0
- package/tools/modules/minifish/harness/host-navigation.mjs +96 -0
- package/tools/modules/minifish/harness/image-loading.mjs +35 -0
- package/tools/modules/minifish/harness/independent-case-runner.mjs +175 -0
- package/tools/modules/minifish/harness/independent-visual.mjs +107 -0
- package/tools/modules/minifish/harness/inline-svg.mjs +32 -0
- package/tools/modules/minifish/harness/input-lifecycle-evidence.mjs +16 -0
- package/tools/modules/minifish/harness/jsapi.mjs +92 -0
- package/tools/modules/minifish/harness/loading-input.mjs +52 -0
- package/tools/modules/minifish/harness/loading-observation.mjs +25 -0
- package/tools/modules/minifish/harness/local-navigation-evidence.mjs +82 -0
- package/tools/modules/minifish/harness/miniapp-screenshot.mjs +38 -0
- package/tools/modules/minifish/harness/native-dom-sampling.mjs +40 -0
- package/tools/modules/minifish/harness/native-enabled.mjs +11 -0
- package/tools/modules/minifish/harness/native-rpc-failure.mjs +11 -0
- package/tools/modules/minifish/harness/native-state.mjs +14 -0
- package/tools/modules/minifish/harness/native-ui-contract.mjs +29 -0
- package/tools/modules/minifish/harness/navigation.mjs +19 -0
- package/tools/modules/minifish/harness/network_isolation.py +134 -0
- package/tools/modules/minifish/harness/painted-source-image.mjs +54 -0
- package/tools/modules/minifish/harness/playwright.mjs +1 -0
- package/tools/modules/minifish/harness/plugin-events.mjs +16 -0
- package/tools/modules/minifish/harness/plugin-package.mjs +23 -0
- package/tools/modules/minifish/harness/plugin_artifact.py +23 -0
- package/tools/modules/minifish/harness/product_identity.py +8 -0
- package/tools/modules/minifish/harness/project_config.py +14 -0
- package/tools/modules/minifish/harness/prototype-visual.mjs +123 -0
- package/tools/modules/minifish/harness/region-order.mjs +16 -0
- package/tools/modules/minifish/harness/renderer-image-evidence.mjs +32 -0
- package/tools/modules/minifish/harness/request-guard.mjs +65 -0
- package/tools/modules/minifish/harness/request-plan.mjs +124 -0
- package/tools/modules/minifish/harness/response-consumption.mjs +106 -0
- package/tools/modules/minifish/harness/retained-page-lifecycle.mjs +48 -0
- package/tools/modules/minifish/harness/rpc-wire.mjs +34 -0
- package/tools/modules/minifish/harness/run-batch.py +133 -0
- package/tools/modules/minifish/harness/run-platform.py +293 -0
- package/tools/modules/minifish/harness/runtime_isolation.py +71 -0
- package/tools/modules/minifish/harness/scroll-container-observation.mjs +40 -0
- package/tools/modules/minifish/harness/semantic-anchor.mjs +139 -0
- package/tools/modules/minifish/harness/semantic-observation.mjs +197 -0
- package/tools/modules/minifish/harness/skeleton-appearance.mjs +49 -0
- package/tools/modules/minifish/harness/target-actions.mjs +120 -0
- package/tools/modules/minifish/harness/test_batch_concurrency.py +125 -0
- package/tools/modules/minifish/harness/test_batch_plugin_groups.py +97 -0
- package/tools/modules/minifish/harness/test_batch_runner.py +107 -0
- package/tools/modules/minifish/harness/test_common_report.py +47 -0
- package/tools/modules/minifish/harness/test_held_capture_contract.py +50 -0
- package/tools/modules/minifish/harness/test_pty_tail.py +39 -0
- package/tools/modules/minifish/harness/test_runtime_dependencies.py +49 -0
- package/tools/modules/minifish/harness/zero-business-plan.mjs +19 -0
- package/tools/modules/minifish/identity.py +6 -0
- package/tools/modules/minifish/package-lock.json +74 -0
- package/tools/modules/minifish/package.json +10 -0
- package/tools/modules/minifish/sdk/package-lock.json +57745 -0
- package/tools/modules/minifish/sdk/package.json +13 -0
- package/tools/modules/workflow/diff_review.py +74 -0
- package/tools/modules/workflow/iteration.py +175 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antprofuse/saddle-islands-tools",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "Independent Saddle–Islands engineering tools",
|
|
5
|
+
"files": [
|
|
6
|
+
"tools"
|
|
7
|
+
],
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+ssh://git@code.alipay.com/profuse/saddle-islands.git"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT OR Apache-2.0",
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public",
|
|
15
|
+
"registry": "https://registry.npmjs.org/"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Islands trigger source validation; consumes the frozen official language guide."""
|
|
2
|
+
import re
|
|
3
|
+
from tools.core.semantic_identity import normalize
|
|
4
|
+
|
|
5
|
+
RETURN_RULE = {'path': 'docs/language/visual.md', 'heading': '页面初始化事务与终点响应'}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve_pointer(document, pointer):
|
|
9
|
+
if not isinstance(pointer, str) or not pointer.startswith('/') or re.search(r'~(?![01])', pointer):
|
|
10
|
+
raise ValueError('invalid source pointer')
|
|
11
|
+
value = document
|
|
12
|
+
for encoded in pointer[1:].split('/'):
|
|
13
|
+
key = encoded.replace('~1', '/').replace('~0', '~')
|
|
14
|
+
if isinstance(value, dict) and key in value:
|
|
15
|
+
value = value[key]
|
|
16
|
+
elif isinstance(value, list) and re.fullmatch(r'0|[1-9][0-9]*', key) and int(key) < len(value):
|
|
17
|
+
value = value[int(key)]
|
|
18
|
+
else:
|
|
19
|
+
raise ValueError('source pointer does not resolve')
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_trigger_source(identity, source, document, *, page_path, language_guide=None):
|
|
24
|
+
"""Validate source shape/resolution. Project compiler still verifies trigger semantics.
|
|
25
|
+
|
|
26
|
+
Caller supplies the actual parsed source and its repository path, not a sample.
|
|
27
|
+
language_guide is the actual frozen Islands visual.md text, not user content.
|
|
28
|
+
"""
|
|
29
|
+
value = normalize(identity)
|
|
30
|
+
expected = f"{value['business']}/spec/domains/{value['domain']}/visual/{value['page']}.yaml"
|
|
31
|
+
if page_path != expected or not isinstance(source, dict) or source.get('path') != page_path:
|
|
32
|
+
raise ValueError('source does not belong to the identity page')
|
|
33
|
+
if source.get('kind') == 'explicit':
|
|
34
|
+
if set(source) != {'kind', 'path', 'pointer'} or source['pointer'] != value['trigger']:
|
|
35
|
+
raise ValueError('explicit source must match trigger')
|
|
36
|
+
return resolve_pointer(document, source['pointer'])
|
|
37
|
+
if source.get('kind') != 'languageDefault' or set(source) != {'kind', 'path', 'pointer', 'rule'}:
|
|
38
|
+
raise ValueError('unknown trigger source shape')
|
|
39
|
+
if value['trigger'] != '/页面/页面返回时事务' or source['pointer'] != '/页面' or source['rule'] != RETURN_RULE:
|
|
40
|
+
raise ValueError('unsupported language default')
|
|
41
|
+
page = resolve_pointer(document, '/页面')
|
|
42
|
+
if not isinstance(page, dict) or '页面返回时事务' in page:
|
|
43
|
+
raise ValueError('return declaration is explicit, not inherited')
|
|
44
|
+
heading = '## ' + RETURN_RULE['heading']
|
|
45
|
+
if not isinstance(language_guide, str) or heading not in language_guide:
|
|
46
|
+
raise ValueError('missing frozen Islands rule')
|
|
47
|
+
section = language_guide.split(heading, 1)[1].split('\n## ', 1)[0]
|
|
48
|
+
if '缺少返回事务声明时整体复用初始化事务配置' not in section:
|
|
49
|
+
raise ValueError('frozen Islands guide does not establish this default')
|
|
50
|
+
initialization = page.get('页面初始化事务', {})
|
|
51
|
+
if not isinstance(initialization, dict):
|
|
52
|
+
raise ValueError('invalid initialization configuration')
|
|
53
|
+
return initialization
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {dirname,resolve} from 'node:path';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import {readFile} from 'node:fs/promises';
|
|
5
|
+
import {verifyIdentity} from '../core/package-identity.mjs';
|
|
6
|
+
const args=process.argv.slice(2);
|
|
7
|
+
if(args.includes('--help')){
|
|
8
|
+
console.log('backend --project <suite-root> --repository <source-root> --adapter <adapter.mjs> --identity <trusted.json> [--list | --binary <executable> --out <fresh-external-output> [--case <id>]]');
|
|
9
|
+
process.exit(0);
|
|
10
|
+
}
|
|
11
|
+
const allowed=new Set(['--project','--repository','--adapter','--identity','--binary','--out','--case','--list']);
|
|
12
|
+
const parsed={};
|
|
13
|
+
for(let i=0;i<args.length;i++){
|
|
14
|
+
const key=args[i];assert(allowed.has(key),'unknown argument '+key);assert(!(key in parsed),'duplicate argument '+key);
|
|
15
|
+
if(key==='--list')parsed[key]=true;
|
|
16
|
+
else{assert(args[i+1]&&!args[i+1].startsWith('--'),'missing value '+key);parsed[key]=args[++i];}
|
|
17
|
+
}
|
|
18
|
+
assert(parsed['--identity'],'explicit frozen tool identity file required');
|
|
19
|
+
const identity=JSON.parse(await readFile(parsed['--identity'],'utf8'));
|
|
20
|
+
const root=resolve(dirname(fileURLToPath(import.meta.url)),'../..');
|
|
21
|
+
await verifyIdentity(root,identity);
|
|
22
|
+
// Load execution dependencies only after source verification succeeds.
|
|
23
|
+
const {run}=await import('../modules/backend/run.mjs');
|
|
24
|
+
await run({projectRoot:parsed['--project'],repositoryRoot:parsed['--repository'],adapter:parsed['--adapter'],out:parsed['--out'],binary:parsed['--binary'],caseId:parsed['--case'],list:!!parsed['--list'],identity});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Saddle–Islands tools: only implemented commands are registered."""
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import sys
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
sys.dont_write_bytecode = True
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main():
|
|
13
|
+
if len(sys.argv) > 1 and sys.argv[1] == 'iteration':
|
|
14
|
+
from tools.modules.workflow.iteration import main as iteration_main
|
|
15
|
+
return iteration_main(sys.argv[2:])
|
|
16
|
+
if len(sys.argv) > 1 and sys.argv[1] == 'identity':
|
|
17
|
+
from tools.core.semantic_identity import main as identity_main
|
|
18
|
+
sys.argv = [sys.argv[0] + ' identity', *sys.argv[2:]]
|
|
19
|
+
return identity_main()
|
|
20
|
+
if len(sys.argv) > 1 and sys.argv[1] == 'backend':
|
|
21
|
+
return subprocess.call(['node', str(Path(__file__).with_name('backend.mjs')), *sys.argv[2:]])
|
|
22
|
+
if len(sys.argv) > 1 and sys.argv[1] == 'minifish':
|
|
23
|
+
args = sys.argv[2:]
|
|
24
|
+
if not args or args == ['--help']:
|
|
25
|
+
print('minifish build|package|plugin|batch|run|report <explicit options>; see tools/docs/minifish-testing.md')
|
|
26
|
+
return 0
|
|
27
|
+
command = args[0]
|
|
28
|
+
if command not in ('build', 'package', 'plugin', 'batch', 'run', 'report'):
|
|
29
|
+
raise SystemExit('Unknown minifish command: ' + command)
|
|
30
|
+
entry = 'minifish.mjs' if command in ('build', 'package', 'plugin') else 'minifish.py'
|
|
31
|
+
return subprocess.call(['node' if entry.endswith('.mjs') else sys.executable, str(Path(__file__).with_name(entry)), *args])
|
|
32
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
33
|
+
parser.add_argument('command', choices=['identity', 'backend', 'minifish', 'iteration'], help='semantic identities, backend tests or Minifish tests')
|
|
34
|
+
parser.parse_args()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == '__main__':
|
|
38
|
+
sys.exit(main())
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {readFile} from 'node:fs/promises';
|
|
5
|
+
import {createHash} from 'node:crypto';
|
|
6
|
+
import {spawnSync} from 'node:child_process';
|
|
7
|
+
import {createRequire,registerHooks} from 'node:module';
|
|
8
|
+
import {pathToFileURL} from 'node:url';
|
|
9
|
+
const [command,...args]=process.argv.slice(2),flags={};
|
|
10
|
+
for(let i=0;i<args.length;i+=2){assert(args[i]?.startsWith('--')&&args[i+1],'Expected named option and value');assert(!Object.hasOwn(flags,args[i].slice(2)),'Duplicate option');flags[args[i].slice(2)]=args[i+1];}
|
|
11
|
+
assert(['build','package','plugin'].includes(command),'Usage: minifish.mjs build|package|plugin --project-root ROOT --config FILE --output DIR --definition CASE | --plan JSON [--tool-node-modules DIR]');
|
|
12
|
+
assert(flags['project-root']&&flags.config&&flags.output,'Explicit project root, config and output required');
|
|
13
|
+
if(!process.execArgv.includes('--experimental-vm-modules')){const child=spawnSync(process.execPath,['--experimental-vm-modules',process.argv[1],...process.argv.slice(2)],{stdio:'inherit',env:process.env});process.exit(child.status??1);}
|
|
14
|
+
const packageRoot=path.resolve(import.meta.dirname,'../..');
|
|
15
|
+
assert(flags['tool-source'],'External --tool-source manifest required; invoke through the trusted project launcher');
|
|
16
|
+
assert(!path.resolve(flags['tool-source']).startsWith(packageRoot+path.sep),'Trust manifest must be outside package');
|
|
17
|
+
const identity=JSON.parse(await readFile(flags['tool-source'],'utf8')),verifier='tools/modules/minifish/compiler/tool-identity.mjs';
|
|
18
|
+
for(const trustedModule of [verifier,'tools/core/package-identity.mjs'])assert.equal(createHash('sha256').update(await readFile(path.join(packageRoot,trustedModule))).digest('hex'),identity.files?.[trustedModule],'Untrusted tool identity verifier: '+trustedModule);
|
|
19
|
+
const {toolIdentity}=await import('../modules/minifish/compiler/tool-identity.mjs');
|
|
20
|
+
await toolIdentity(identity,flags['tool-source']);
|
|
21
|
+
for(const [flag,lock]of [['tool-node-modules','tools/modules/minifish/package-lock.json'],['sdk-node-modules','tools/modules/minifish/sdk/package-lock.json']])if(flags[flag])assert.equal(createHash('sha256').update(await readFile(path.resolve(flags[flag],'../package-lock.json'))).digest('hex'),identity.files[lock],'External dependency lock mismatch: '+flag);
|
|
22
|
+
process.env.MINIFISH_TOOL_SOURCE_LOCK=path.resolve(flags['tool-source']);
|
|
23
|
+
if(flags['tool-node-modules']){
|
|
24
|
+
const require=createRequire(path.join(path.resolve(flags['tool-node-modules']),'../package.json'));
|
|
25
|
+
registerHooks({resolve(specifier,context,next){if(['typescript','@playwright/test'].includes(specifier)){let file=require.resolve(specifier);if(specifier==='@playwright/test')file=path.join(path.dirname(file),'index.mjs');return {url:pathToFileURL(file).href,shortCircuit:true};}return next(specifier,context);}});
|
|
26
|
+
}
|
|
27
|
+
registerHooks({resolve(specifier,context,next){const prefix='@antprofuse/saddle-islands-tools/';if(specifier.startsWith(prefix)){const file=path.resolve(packageRoot,specifier.slice(prefix.length));assert(file.startsWith(packageRoot+path.sep),'Public package import escaped');return {url:pathToFileURL(file).href,shortCircuit:true};}return next(specifier,context);}});
|
|
28
|
+
const {buildCase,buildPlanPackage}=await import('../modules/minifish/compiler/build-case.mjs');
|
|
29
|
+
const options={projectRoot:path.resolve(flags['project-root']),config:path.resolve(flags.config)};
|
|
30
|
+
let result;
|
|
31
|
+
if(command==='plugin'){
|
|
32
|
+
const {loadProject}=await import('../modules/minifish/compiler/config.mjs');
|
|
33
|
+
const project=await loadProject(options.projectRoot,options.config);
|
|
34
|
+
const {validatePluginEvents}=await import(pathToFileURL(await project.resolveAdapter(project.config.pluginAdapter)).href);
|
|
35
|
+
const {buildPluginArtifact}=await import('../modules/minifish/harness/build-plugin-artifact.mjs');
|
|
36
|
+
result=await buildPluginArtifact({planFile:flags.plan,output:flags.output,modules:flags['sdk-node-modules'],source:flags.source,repositoryRoot:path.resolve(project.root,project.config.repositoryRoot),validatePluginEvents});
|
|
37
|
+
}else result=command==='build'?await buildCase(flags.definition,flags.output,options):await buildPlanPackage(JSON.parse(await readFile(flags.plan,'utf8')),flags.output,options);
|
|
38
|
+
console.log(JSON.stringify(result));
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stable public batch/runtime/report entry, all implementation lives in tools."""
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import json
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
sys.dont_write_bytecode = True
|
|
10
|
+
PACKAGE = Path(__file__).resolve().parents[2]
|
|
11
|
+
try:
|
|
12
|
+
index = sys.argv.index('--tool-source')
|
|
13
|
+
manifest_file = Path(sys.argv[index + 1]).resolve(strict=True)
|
|
14
|
+
del sys.argv[index:index + 2]
|
|
15
|
+
except (ValueError, IndexError):
|
|
16
|
+
raise SystemExit('External --tool-source manifest required; invoke through trusted project launcher')
|
|
17
|
+
if PACKAGE in manifest_file.parents: raise SystemExit('Trust manifest must be outside package')
|
|
18
|
+
identity = json.loads(manifest_file.read_text())
|
|
19
|
+
verifier = 'tools/modules/minifish/identity.py'
|
|
20
|
+
for trusted_module in (verifier, 'tools/core/package_identity.py'):
|
|
21
|
+
if hashlib.sha256((PACKAGE / trusted_module).read_bytes()).hexdigest() != identity.get('files', {}).get(trusted_module):
|
|
22
|
+
raise SystemExit('Untrusted tool identity verifier: ' + trusted_module)
|
|
23
|
+
sys.path.insert(0, str(PACKAGE / 'tools/modules/minifish'))
|
|
24
|
+
from identity import verify_package
|
|
25
|
+
verify_package(PACKAGE, manifest_file)
|
|
26
|
+
if '--node-modules' in sys.argv:
|
|
27
|
+
modules = Path(sys.argv[sys.argv.index('--node-modules') + 1]).resolve()
|
|
28
|
+
if hashlib.sha256((modules.parent / 'package-lock.json').read_bytes()).hexdigest() != identity['files']['tools/modules/minifish/sdk/package-lock.json']:
|
|
29
|
+
raise SystemExit('External SDK dependency lock mismatch')
|
|
30
|
+
for flag in ['--output', '--report-dir']:
|
|
31
|
+
if flag in sys.argv:
|
|
32
|
+
output = Path(sys.argv[sys.argv.index(flag) + 1]).resolve()
|
|
33
|
+
if output == PACKAGE or PACKAGE in output.parents:
|
|
34
|
+
raise SystemExit('Outputs must be outside tool package')
|
|
35
|
+
os.environ['MINIFISH_TOOL_SOURCE_LOCK'] = str(manifest_file)
|
|
36
|
+
os.environ['PYTHONDONTWRITEBYTECODE'] = '1'
|
|
37
|
+
ROOT = PACKAGE / 'tools/modules/minifish/harness'
|
|
38
|
+
if len(sys.argv) < 2: raise SystemExit('usage: minifish.py batch|run|report <explicit options>')
|
|
39
|
+
command = sys.argv[1]
|
|
40
|
+
if command in ('batch', 'run'):
|
|
41
|
+
result = subprocess.run([sys.executable, str(ROOT / ('run-batch.py' if command == 'batch' else 'run-platform.py')), *sys.argv[2:]])
|
|
42
|
+
raise SystemExit(result.returncode)
|
|
43
|
+
if command == 'report':
|
|
44
|
+
import argparse
|
|
45
|
+
parser = argparse.ArgumentParser()
|
|
46
|
+
parser.add_argument('--summary', type=Path, required=True)
|
|
47
|
+
parser.add_argument('--output', type=Path, required=True)
|
|
48
|
+
args = parser.parse_args(sys.argv[2:])
|
|
49
|
+
sys.path.insert(0, str(ROOT))
|
|
50
|
+
from batch_report import render_report
|
|
51
|
+
print(render_report(json.loads(args.summary.read_text()), args.output))
|
|
52
|
+
else: raise SystemExit('unknown minifish command: '+command)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {readFile,readdir,lstat,realpath} from 'node:fs/promises';
|
|
4
|
+
import {createHash} from 'node:crypto';
|
|
5
|
+
const sha=bytes=>createHash('sha256').update(bytes).digest('hex');
|
|
6
|
+
const required=['package.json','tools/cli/main.py','tools/cli/backend.mjs','tools/core/package-identity.mjs','tools/core/package_identity.py','tools/modules/backend/package.json','tools/modules/backend/package-lock.json','tools/cli/minifish.mjs','tools/cli/minifish.py','tools/modules/minifish/compiler/tool-identity.mjs','tools/modules/minifish/identity.py','tools/modules/minifish/package.json','tools/modules/minifish/package-lock.json','tools/modules/minifish/sdk/package.json','tools/modules/minifish/sdk/package-lock.json'];
|
|
7
|
+
const dependencies=[{path:'tools/modules/backend/node_modules',packageJson:'tools/modules/backend/package.json',lock:'tools/modules/backend/package-lock.json'},{path:'tools/modules/minifish/node_modules',packageJson:'tools/modules/minifish/package.json',lock:'tools/modules/minifish/package-lock.json'},{path:'tools/modules/minifish/sdk/node_modules',packageJson:'tools/modules/minifish/sdk/package.json',lock:'tools/modules/minifish/sdk/package-lock.json'}];
|
|
8
|
+
export async function verifyIdentity(root,identity){
|
|
9
|
+
root=await realpath(root);
|
|
10
|
+
assert(typeof identity.repository==='string'&&identity.repository.length,'Trusted repository required');assert(/^[a-f0-9]{40}$/.test(identity.commit),'Complete frozen commit required');
|
|
11
|
+
assert(identity.packagePath&&identity.package&&identity.version,'Trusted package path/name/version required');assert(identity.files&&Object.keys(identity.files).length,'Trusted complete file hashes required');
|
|
12
|
+
for(const file of required)assert(Object.hasOwn(identity.files,file),'Missing trusted file: '+file);
|
|
13
|
+
assert.deepEqual(identity.dependencyRoots,dependencies,'Explicit locked dependency boundaries required');
|
|
14
|
+
for(const [file,digest]of Object.entries(identity.files)){
|
|
15
|
+
assert(file&&!path.isAbsolute(file)&&!file.split('/').some(p=>['','.','..'].includes(p))&&/^[a-f0-9]{64}$/.test(digest),'Invalid trusted file: '+file);
|
|
16
|
+
let current=root;for(const part of file.split('/')){current=path.join(current,part);assert(!(await lstat(current)).isSymbolicLink(),'Package symlink forbidden: '+file);}
|
|
17
|
+
assert((await lstat(current)).isFile(),'Regular trusted file required: '+file);assert.equal(sha(await readFile(current)),digest,'Tool file hash mismatch: '+file);
|
|
18
|
+
}
|
|
19
|
+
async function inventory(dir,prefix=''){
|
|
20
|
+
for(const entry of await readdir(dir,{withFileTypes:true})){const file=prefix+entry.name;assert(!entry.isSymbolicLink(),'Package symlink forbidden: '+file);if(file==='.git'||dependencies.some(d=>d.path===file)){assert(entry.isDirectory(),'Installation boundary must be directory: '+file);continue;}if(entry.isDirectory())await inventory(path.join(dir,entry.name),file+'/');else assert(entry.isFile()&&Object.hasOwn(identity.files,file),'Unlisted package file: '+file);}
|
|
21
|
+
}
|
|
22
|
+
await inventory(root);const manifest=JSON.parse(await readFile(path.join(root,'package.json'),'utf8'));assert.equal(manifest.name,identity.package,'Tool package name mismatch');assert.equal(manifest.version,identity.version,'Tool package version mismatch');return identity;
|
|
23
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Project-owned trust bootstrap. Only stdlib runs before all package bytes match."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
REQUIRED = ('package.json', 'tools/cli/main.py', 'tools/cli/backend.mjs', 'tools/core/package-identity.mjs', 'tools/core/package_identity.py', 'tools/modules/backend/package.json', 'tools/modules/backend/package-lock.json', 'tools/cli/minifish.mjs', 'tools/cli/minifish.py',
|
|
8
|
+
'tools/modules/minifish/compiler/tool-identity.mjs', 'tools/modules/minifish/identity.py',
|
|
9
|
+
'tools/modules/minifish/package.json', 'tools/modules/minifish/package-lock.json',
|
|
10
|
+
'tools/modules/minifish/sdk/package.json', 'tools/modules/minifish/sdk/package-lock.json')
|
|
11
|
+
DEPENDENCIES = {'tools/modules/backend/node_modules': ('tools/modules/backend/package.json', 'tools/modules/backend/package-lock.json'), 'tools/modules/minifish/node_modules': ('tools/modules/minifish/package.json', 'tools/modules/minifish/package-lock.json'),
|
|
12
|
+
'tools/modules/minifish/sdk/node_modules': ('tools/modules/minifish/sdk/package.json', 'tools/modules/minifish/sdk/package-lock.json')}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def verify_package(root, manifest_file):
|
|
16
|
+
root = Path(root).resolve(strict=True)
|
|
17
|
+
manifest_file = Path(manifest_file).resolve(strict=True)
|
|
18
|
+
if manifest_file == root or root in manifest_file.parents:
|
|
19
|
+
raise ValueError('Trust manifest must be outside tool package')
|
|
20
|
+
identity = json.loads(manifest_file.read_text())
|
|
21
|
+
if not identity.get('repository') or not re.fullmatch('[a-f0-9]{40}', identity.get('commit', '')):
|
|
22
|
+
raise ValueError('Trusted repository and complete commit required')
|
|
23
|
+
if not identity.get('version') or not identity.get('packagePath') or not identity.get('package'):
|
|
24
|
+
raise ValueError('Trusted package path/name/version required')
|
|
25
|
+
files = identity.get('files', {})
|
|
26
|
+
for name in REQUIRED:
|
|
27
|
+
if name not in files:
|
|
28
|
+
raise ValueError('Missing trusted file: ' + name)
|
|
29
|
+
boundaries = identity.get('dependencyRoots')
|
|
30
|
+
expected = [{'path': p, 'packageJson': v[0], 'lock': v[1]} for p, v in DEPENDENCIES.items()]
|
|
31
|
+
if boundaries != expected:
|
|
32
|
+
raise ValueError('Explicit locked dependency boundaries required')
|
|
33
|
+
for name, digest in files.items():
|
|
34
|
+
parts = name.split('/')
|
|
35
|
+
if not name or name.startswith('/') or any(p in ('', '.', '..') for p in parts) or not re.fullmatch('[a-f0-9]{64}', digest):
|
|
36
|
+
raise ValueError('Invalid trusted file: ' + name)
|
|
37
|
+
file = root / name
|
|
38
|
+
if any((root.joinpath(*parts[:i])).is_symlink() for i in range(1, len(parts) + 1)) or not file.is_file():
|
|
39
|
+
raise ValueError('Missing or unsafe trusted file: ' + name)
|
|
40
|
+
if hashlib.sha256(file.read_bytes()).hexdigest() != digest:
|
|
41
|
+
raise ValueError('Tool file hash mismatch: ' + name)
|
|
42
|
+
def inventory(directory, prefix=''):
|
|
43
|
+
for item in directory.iterdir():
|
|
44
|
+
name = prefix + item.name
|
|
45
|
+
if item.is_symlink():
|
|
46
|
+
raise ValueError('Package symlink forbidden: ' + name)
|
|
47
|
+
if name == '.git' or name in DEPENDENCIES:
|
|
48
|
+
if not item.is_dir(): raise ValueError('Installation boundary must be directory: ' + name)
|
|
49
|
+
continue
|
|
50
|
+
if item.is_dir():
|
|
51
|
+
inventory(item, name + '/')
|
|
52
|
+
elif not item.is_file() or name not in files:
|
|
53
|
+
raise ValueError('Unlisted package file: ' + name)
|
|
54
|
+
inventory(root)
|
|
55
|
+
package = json.loads((root / 'package.json').read_text())
|
|
56
|
+
if package['name'] != identity['package'] or package['version'] != identity['version']:
|
|
57
|
+
raise ValueError('Tool package name/version mismatch')
|
|
58
|
+
return identity
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Semantic identity generator. Python 3 standard library only."""
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import tempfile
|
|
9
|
+
import unicodedata
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
# Explicit Unicode White_Space set, frozen for this algorithm (not Python-version-dependent strip).
|
|
13
|
+
EDGE_SPACE = frozenset(chr(n) for n in [*range(9,14),32,133,160,5760,*range(8192,8203),8232,8233,8239,8287,12288])
|
|
14
|
+
|
|
15
|
+
FIELDS = {'kind', 'business', 'domain', 'page', 'trigger'}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def unique_pairs(pairs):
|
|
19
|
+
result = {}
|
|
20
|
+
for key, value in pairs:
|
|
21
|
+
if key in result:
|
|
22
|
+
raise ValueError(f'duplicate JSON key: {key}')
|
|
23
|
+
result[key] = value
|
|
24
|
+
return result
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def read(path):
|
|
28
|
+
return json.loads(Path(path).read_text(encoding='utf-8'), object_pairs_hook=unique_pairs)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def normalize(identity):
|
|
32
|
+
if not isinstance(identity, dict) or set(identity) != FIELDS:
|
|
33
|
+
raise ValueError('identity must contain exactly: ' + ', '.join(sorted(FIELDS)))
|
|
34
|
+
result = {}
|
|
35
|
+
for key, value in identity.items():
|
|
36
|
+
if not isinstance(value, str) or not value or (value[0] in EDGE_SPACE or value[-1] in EDGE_SPACE):
|
|
37
|
+
raise ValueError(f'{key}: expected nonempty string without surrounding whitespace')
|
|
38
|
+
if any(ord(c) < 32 or 0xD800 <= ord(c) <= 0xDFFF for c in value):
|
|
39
|
+
raise ValueError(f'{key}: control characters and surrogates are forbidden')
|
|
40
|
+
result[key] = unicodedata.normalize('NFC', value)
|
|
41
|
+
if result['kind'] != 'backend_contract':
|
|
42
|
+
raise ValueError('unsupported identity kind')
|
|
43
|
+
if not result['trigger'].startswith('/') or re.search(r'~(?![01])', result['trigger']):
|
|
44
|
+
raise ValueError('trigger: expected RFC 6901 pointer with valid escaping')
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def canonical(identity):
|
|
49
|
+
return json.dumps(normalize(identity), ensure_ascii=False, sort_keys=True, separators=(',', ':'))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def identifier(identity):
|
|
53
|
+
return 'api_' + hashlib.sha256(canonical(identity).encode('utf-8')).hexdigest()[:32]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def validate_registry(registry):
|
|
57
|
+
if not isinstance(registry, dict):
|
|
58
|
+
raise ValueError('registry must be an ID-to-identity object')
|
|
59
|
+
for key, identity in registry.items():
|
|
60
|
+
if identity != normalize(identity):
|
|
61
|
+
raise ValueError(f'{key}: registry identity must already be normalized')
|
|
62
|
+
if key != identifier(identity):
|
|
63
|
+
raise ValueError(f'{key}: identity digest mismatch')
|
|
64
|
+
return registry
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_append_only(base, current):
|
|
68
|
+
validate_registry(base)
|
|
69
|
+
validate_registry(current)
|
|
70
|
+
for key, identity in base.items():
|
|
71
|
+
if current.get(key) != identity:
|
|
72
|
+
raise ValueError(f'{key}: historical entry removed or changed')
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def register(registry, identity):
|
|
76
|
+
validate_registry(registry)
|
|
77
|
+
value = normalize(identity)
|
|
78
|
+
key = identifier(value)
|
|
79
|
+
previous = registry.get(key)
|
|
80
|
+
if previous is not None and previous != value:
|
|
81
|
+
raise ValueError(f'{key}: digest collision')
|
|
82
|
+
registry[key] = value
|
|
83
|
+
return key
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def check_active(registry, contracts):
|
|
87
|
+
validate_registry(registry)
|
|
88
|
+
if not isinstance(contracts, list):
|
|
89
|
+
raise ValueError('active contracts must be a JSON array')
|
|
90
|
+
seen = set()
|
|
91
|
+
for contract in contracts:
|
|
92
|
+
if not isinstance(contract, dict) or set(contract) != {'id', 'identity'}:
|
|
93
|
+
raise ValueError('active record must contain exactly id and identity')
|
|
94
|
+
key = identifier(contract['identity'])
|
|
95
|
+
if contract['id'] != key:
|
|
96
|
+
raise ValueError('active contract digest mismatch')
|
|
97
|
+
if key in seen:
|
|
98
|
+
raise ValueError(f'{key}: duplicate active definition')
|
|
99
|
+
seen.add(key)
|
|
100
|
+
if registry.get(key) != normalize(contract['identity']):
|
|
101
|
+
raise ValueError(f'{key}: active identity is not registered')
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main():
|
|
105
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
106
|
+
sub = parser.add_subparsers(dest='command', required=True)
|
|
107
|
+
generate = sub.add_parser('generate')
|
|
108
|
+
generate.add_argument('identity')
|
|
109
|
+
append = sub.add_parser('register')
|
|
110
|
+
append.add_argument('identity')
|
|
111
|
+
append.add_argument('--registry', required=True)
|
|
112
|
+
verify = sub.add_parser('check')
|
|
113
|
+
verify.add_argument('--registry', required=True)
|
|
114
|
+
verify.add_argument('--baseline', required=True, help='registry extracted from trusted Git base')
|
|
115
|
+
verify.add_argument('--active', required=True, help='all current contracts exported as id/identity JSON array')
|
|
116
|
+
args = parser.parse_args()
|
|
117
|
+
try:
|
|
118
|
+
if args.command == 'generate':
|
|
119
|
+
value = read(args.identity)
|
|
120
|
+
print(json.dumps({'id': identifier(value), 'identity': normalize(value)}, ensure_ascii=False, sort_keys=True))
|
|
121
|
+
elif args.command == 'register':
|
|
122
|
+
path = Path(args.registry)
|
|
123
|
+
registry = read(path) if path.exists() else {}
|
|
124
|
+
key = register(registry, read(args.identity))
|
|
125
|
+
content = json.dumps(registry, ensure_ascii=False, sort_keys=True, indent=2) + '\n'
|
|
126
|
+
# No write occurs until validation succeeds. Commit serially; concurrent branch merges require recheck.
|
|
127
|
+
temporary = None
|
|
128
|
+
try:
|
|
129
|
+
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=path.parent, delete=False) as stream:
|
|
130
|
+
temporary = stream.name
|
|
131
|
+
stream.write(content)
|
|
132
|
+
stream.flush()
|
|
133
|
+
os.fsync(stream.fileno())
|
|
134
|
+
os.replace(temporary, path)
|
|
135
|
+
finally:
|
|
136
|
+
if temporary and os.path.exists(temporary):
|
|
137
|
+
os.unlink(temporary)
|
|
138
|
+
print(key)
|
|
139
|
+
else:
|
|
140
|
+
current = read(args.registry)
|
|
141
|
+
validate_append_only(read(args.baseline), current)
|
|
142
|
+
check_active(current, read(args.active))
|
|
143
|
+
print('identity registry and active contracts: PASS')
|
|
144
|
+
except (ValueError, OSError, TypeError) as error:
|
|
145
|
+
parser.exit(1, f'{error}\n')
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == '__main__':
|
|
149
|
+
main()
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import unittest
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
from tools.core import semantic_identity as s
|
|
10
|
+
from tools.adapters.islands import trigger_source as source_api
|
|
11
|
+
|
|
12
|
+
IDENTITY = {'kind': 'backend_contract', 'business': 'example-app', 'domain': '示例领域', 'page': '示例页面', 'trigger': '/页面/页面初始化事务'}
|
|
13
|
+
EXPECTED = 'api_c6267626643566cd7945122e2bd54b73'
|
|
14
|
+
|
|
15
|
+
class IdentityTests(unittest.TestCase):
|
|
16
|
+
def registry(self):
|
|
17
|
+
return {}
|
|
18
|
+
|
|
19
|
+
def test_fixed_vector(self):
|
|
20
|
+
self.assertEqual(s.identifier(IDENTITY), EXPECTED)
|
|
21
|
+
self.assertEqual(s.canonical(IDENTITY), '{"business":"example-app","domain":"示例领域","kind":"backend_contract","page":"示例页面","trigger":"/页面/页面初始化事务"}')
|
|
22
|
+
|
|
23
|
+
def test_order_and_unicode(self):
|
|
24
|
+
self.assertEqual(s.identifier(dict(reversed(list(IDENTITY.items())))), EXPECTED)
|
|
25
|
+
self.assertEqual(s.identifier({**IDENTITY, 'page':'café'}), s.identifier({**IDENTITY, 'page':'cafe\u0301'}))
|
|
26
|
+
self.assertNotEqual(s.identifier({**IDENTITY, 'page':'Payment'}), EXPECTED)
|
|
27
|
+
|
|
28
|
+
def test_invalid_identity(self):
|
|
29
|
+
for value in [None, '', [], {}, 1, True, ' payment', 'payment\n', '\ud800']:
|
|
30
|
+
with self.subTest(value=repr(value)), self.assertRaises(ValueError):
|
|
31
|
+
s.identifier({**IDENTITY, 'page':value})
|
|
32
|
+
for value in [{**IDENTITY,'name':'title'}, {k:v for k,v in IDENTITY.items() if k!='page'}, {**IDENTITY,'identityVersion':'v2'}]:
|
|
33
|
+
with self.assertRaises(ValueError): s.identifier(value)
|
|
34
|
+
|
|
35
|
+
def test_source_coordinates(self):
|
|
36
|
+
for trigger in ['page_load', '/页面/~x', '/页面/~']:
|
|
37
|
+
with self.assertRaises(ValueError):
|
|
38
|
+
s.identifier({**IDENTITY, 'trigger':trigger})
|
|
39
|
+
self.assertNotEqual(s.identifier({**IDENTITY, 'domain':'其他领域'}), EXPECTED)
|
|
40
|
+
self.assertNotEqual(s.identifier({**IDENTITY, 'business':'其他业务'}), EXPECTED)
|
|
41
|
+
self.assertNotEqual(s.identifier({**IDENTITY, 'trigger':'/页面/页面返回时事务'}), EXPECTED)
|
|
42
|
+
with self.assertRaises(ValueError):
|
|
43
|
+
s.identifier({**IDENTITY, 'purpose':'query'})
|
|
44
|
+
|
|
45
|
+
def test_duplicate_json_keys(self):
|
|
46
|
+
with self.assertRaises(ValueError): json.loads('{"id":1,"id":2}', object_pairs_hook=s.unique_pairs)
|
|
47
|
+
|
|
48
|
+
def test_idempotence_and_append(self):
|
|
49
|
+
registry=self.registry()
|
|
50
|
+
s.register(registry, IDENTITY)
|
|
51
|
+
base=copy.deepcopy(registry)
|
|
52
|
+
s.register(registry, IDENTITY)
|
|
53
|
+
self.assertEqual(registry, base)
|
|
54
|
+
s.register(registry,{**IDENTITY,'trigger':'/页面/页面返回时事务'})
|
|
55
|
+
s.validate_append_only(base,registry)
|
|
56
|
+
s.check_active(registry,[{'id':EXPECTED,'identity':IDENTITY}])
|
|
57
|
+
s.check_active(registry,[]) # Retired identity stays registered.
|
|
58
|
+
|
|
59
|
+
def test_historical_deletion_and_change(self):
|
|
60
|
+
base=self.registry();s.register(base,IDENTITY)
|
|
61
|
+
with self.assertRaises(ValueError): s.validate_append_only(base,self.registry())
|
|
62
|
+
altered=copy.deepcopy(base);altered[EXPECTED]['page']='other'
|
|
63
|
+
with self.assertRaises(ValueError): s.validate_append_only(base,altered)
|
|
64
|
+
|
|
65
|
+
def test_unregistered_duplicate_or_wrong_active(self):
|
|
66
|
+
record={'id':EXPECTED,'identity':IDENTITY}
|
|
67
|
+
with self.assertRaises(ValueError): s.check_active(self.registry(),[record])
|
|
68
|
+
registry=self.registry();s.register(registry,IDENTITY)
|
|
69
|
+
with self.assertRaises(ValueError): s.check_active(registry,[record,record])
|
|
70
|
+
with self.assertRaises(ValueError): s.check_active(registry,[{**record,'id':'api_wrong'}])
|
|
71
|
+
|
|
72
|
+
def test_simulated_digest_collision(self):
|
|
73
|
+
registry=self.registry();s.register(registry,IDENTITY)
|
|
74
|
+
with patch.object(s,'identifier',return_value=EXPECTED):
|
|
75
|
+
with self.assertRaisesRegex(ValueError,'collision'):
|
|
76
|
+
s.register(registry,{**IDENTITY,'page':'other'})
|
|
77
|
+
|
|
78
|
+
class SourceTests(unittest.TestCase):
|
|
79
|
+
page_path = 'example-app/spec/domains/示例领域/visual/示例页面.yaml'
|
|
80
|
+
guide = '## 页面初始化事务与终点响应\n缺少返回事务声明时整体复用初始化事务配置(包括成功响应)。'
|
|
81
|
+
def returning(self):
|
|
82
|
+
return {**IDENTITY, 'trigger':'/页面/页面返回时事务'}
|
|
83
|
+
def source(self):
|
|
84
|
+
return {'kind':'languageDefault', 'path':self.page_path, 'pointer':'/页面', 'rule':dict(source_api.RETURN_RULE)}
|
|
85
|
+
def validate(self, identity, source, document, **kw):
|
|
86
|
+
return source_api.validate_trigger_source(identity, source, document, page_path=self.page_path, language_guide=kw.get('guide',self.guide))
|
|
87
|
+
def test_inherited_return_distinct_from_initialization(self):
|
|
88
|
+
config={'响应':[]};doc={'页面':{'页面初始化事务':config}}
|
|
89
|
+
self.assertEqual(self.validate(self.returning(),self.source(),doc),config)
|
|
90
|
+
self.assertNotEqual(s.identifier(self.returning()),s.identifier(IDENTITY))
|
|
91
|
+
def test_explicit_empty_is_not_inheritance(self):
|
|
92
|
+
doc={'页面':{'页面返回时事务':{}}}
|
|
93
|
+
with self.assertRaises(ValueError):self.validate(self.returning(),self.source(),doc)
|
|
94
|
+
src={'kind':'explicit','path':self.page_path,'pointer':'/页面/页面返回时事务'}
|
|
95
|
+
self.assertEqual(self.validate(self.returning(),src,doc),{})
|
|
96
|
+
def test_wrong_event_rule_and_page_rejected(self):
|
|
97
|
+
for identity,src in [(IDENTITY,self.source()),(self.returning(),{**self.source(),'rule':{}}),(self.returning(),{**self.source(),'path':'other.yaml'})]:
|
|
98
|
+
with self.assertRaises(ValueError):self.validate(identity,src,{'页面':{}})
|
|
99
|
+
with self.assertRaises(ValueError):self.validate(self.returning(),self.source(),{'页面':{}},guide='')
|
|
100
|
+
def test_explicit_missing_or_wrong_pointer(self):
|
|
101
|
+
src={'kind':'explicit','path':self.page_path,'pointer':IDENTITY['trigger']}
|
|
102
|
+
with self.assertRaises(ValueError):self.validate(IDENTITY,src,{'页面':{}})
|
|
103
|
+
with self.assertRaises(ValueError):self.validate(IDENTITY,{**src,'pointer':'/页面'},{'页面':{}})
|
|
104
|
+
def test_pointer_escaping(self):
|
|
105
|
+
self.assertEqual(source_api.resolve_pointer({'a/b':{'~key':[7]}},'/a~1b/~0key/0'),7)
|
|
106
|
+
with self.assertRaises(ValueError):source_api.resolve_pointer({'a':[7]},'/a/00')
|
|
107
|
+
|
|
108
|
+
class CliTests(unittest.TestCase):
|
|
109
|
+
def test_lifecycle_and_failed_write(self):
|
|
110
|
+
script=Path(s.__file__).resolve()
|
|
111
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
112
|
+
root=Path(directory)
|
|
113
|
+
identity=root/'identity.json'; registry=root/'registry.json'
|
|
114
|
+
baseline=root/'baseline.json'; active=root/'active.json'
|
|
115
|
+
identity.write_text(json.dumps(IDENTITY),encoding='utf-8')
|
|
116
|
+
def run(*args):
|
|
117
|
+
return subprocess.run([sys.executable,str(script),*map(str,args)],capture_output=True,text=True)
|
|
118
|
+
generated=run('generate',identity)
|
|
119
|
+
self.assertEqual(generated.returncode,0,generated.stderr)
|
|
120
|
+
self.assertEqual(json.loads(generated.stdout)['id'],EXPECTED)
|
|
121
|
+
self.assertEqual(run('register',identity,'--registry',registry).returncode,0)
|
|
122
|
+
original=registry.read_bytes();baseline.write_bytes(original)
|
|
123
|
+
self.assertEqual(run('register',identity,'--registry',registry).returncode,0)
|
|
124
|
+
self.assertEqual(registry.read_bytes(),original)
|
|
125
|
+
active.write_text(json.dumps([{'id':EXPECTED,'identity':IDENTITY}]),encoding='utf-8')
|
|
126
|
+
self.assertEqual(run('check','--registry',registry,'--baseline',baseline,'--active',active).returncode,0)
|
|
127
|
+
identity.write_text('{"page":null}',encoding='utf-8')
|
|
128
|
+
self.assertNotEqual(run('register',identity,'--registry',registry).returncode,0)
|
|
129
|
+
self.assertEqual(registry.read_bytes(),original)
|
|
130
|
+
registry.write_text(json.dumps({}),encoding='utf-8')
|
|
131
|
+
active.write_text('[]',encoding='utf-8')
|
|
132
|
+
rejected=run('check','--registry',registry,'--baseline',baseline,'--active',active)
|
|
133
|
+
self.assertNotEqual(rejected.returncode,0)
|
|
134
|
+
self.assertIn('removed or changed',rejected.stderr)
|
|
135
|
+
|
|
136
|
+
if __name__=='__main__': unittest.main()
|