@givolt/i3-composer-agent 2.3.1 → 2.3.2
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/.agents/plugins/marketplace.json +1 -1
- package/.claude/commands/i3-prototype.md +5 -7
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.gemini/commands/i3-prototype.toml +5 -7
- package/README.md +12 -7
- package/bin/i3-composer-agent.cjs +33 -2
- package/bin/install.cjs +14 -0
- package/bin/native-migration.cjs +27 -3
- package/commands/i3-prototype.toml +5 -7
- package/docs/codebuddy-setup.md +65 -2
- package/docs/codex-setup.md +4 -0
- package/docs/other-hosts.md +1 -1
- package/docs/ui-prototype-native/MAIN-SESSION-REPORT.md +66 -0
- package/docs/ui-prototype-native/USAGE.md +91 -0
- package/docs/workbuddy-setup.md +4 -0
- package/evals/cases/i3-frontend-ui-engineering.json +16 -0
- package/evals/cases/i3-ui-prototype.json +109 -0
- package/evals/cases/i3-using-agent-skills.json +28 -0
- package/evals/fixtures/i3-ui-prototype/admin-list/assets/reference.css +3 -0
- package/evals/fixtures/i3-ui-prototype/admin-list/assets/reference.js +8 -0
- package/evals/fixtures/i3-ui-prototype/admin-list/capability-definition.json +67 -0
- package/evals/fixtures/i3-ui-prototype/admin-list/index.html +89 -0
- package/evals/fixtures/i3-ui-prototype/admin-list/readme.md +52 -0
- package/evals/fixtures/i3-ui-prototype/admin-list/template.json +8 -0
- package/evals/zh-CN/cases/i3-frontend-ui-engineering.json +17 -0
- package/evals/zh-CN/cases/i3-ui-prototype.json +115 -0
- package/evals/zh-CN/cases/i3-using-agent-skills.json +30 -0
- package/package.json +31 -1
- package/plugin.json +1 -1
- package/scripts/design-provider-test.js +7 -0
- package/scripts/enterprise-template-test.js +27 -1
- package/scripts/lib/prototype-fixture.cjs +17 -0
- package/scripts/prototype-locations-test.js +58 -0
- package/scripts/ui-prototype-content-test.js +123 -0
- package/scripts/ui-prototype-dependencies-test.js +213 -0
- package/scripts/ui-prototype-devtools-test.js +53 -0
- package/scripts/ui-prototype-native-test.js +504 -0
- package/scripts/ui-prototype-template-test.js +261 -0
- package/scripts/ui-prototype-test.js +10 -0
- package/skills/i3-ui-prototype/SKILL.md +138 -19
- package/skills/i3-ui-prototype/assets/capabilities/management.json +294 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/acceptance.json +51 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/component-map.json +13 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/index.html +11 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/package-lock.json +2298 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/package.json +22 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/public/prototype-config.json +6 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/App.vue +136 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/components/DeviceEditor.vue +111 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/config.ts +19 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/content.ts +28 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/domain/devices.ts +50 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/domain/theme.ts +25 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/main.ts +18 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/style.css +50 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/useDevices.ts +66 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/tests/devices.test.ts +44 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/tests/theme.test.ts +26 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/tsconfig.json +15 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/vite.config.ts +4 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/profile.json +174 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/readme.md +61 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/index.html +11 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/package-lock.json +2298 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/package.json +22 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/public/prototype-config.json +21 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/App.vue +51 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/config.ts +18 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/domain/theme.ts +25 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/main.ts +18 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/style.css +13 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/tests/theme.test.ts +26 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/tsconfig.json +15 -0
- package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/vite.config.ts +4 -0
- package/skills/i3-ui-prototype/assets/optional-skills/host-targets.json +737 -0
- package/skills/i3-ui-prototype/assets/optional-skills/sources.json +27 -0
- package/skills/i3-ui-prototype/assets/templates/catalog.js +149 -0
- package/skills/i3-ui-prototype/assets/templates/enterprise-admin/capability-definition.json +294 -0
- package/skills/i3-ui-prototype/assets/templates/enterprise-admin/index.html +507 -2
- package/skills/i3-ui-prototype/assets/templates/enterprise-admin/offline-build.json +265 -0
- package/skills/i3-ui-prototype/assets/templates/enterprise-admin/offline.js +8888 -0
- package/skills/i3-ui-prototype/assets/templates/enterprise-admin/template.json +9 -0
- package/skills/i3-ui-prototype/assets/templates/gallery/index.html +4 -4
- package/skills/i3-ui-prototype/assets/templates/index.html +3 -0
- package/skills/i3-ui-prototype/references/artifact-contract.md +25 -0
- package/skills/i3-ui-prototype/references/content-template.md +76 -0
- package/skills/i3-ui-prototype/references/enterprise-admin.md +6 -0
- package/skills/i3-ui-prototype/references/entry-contract.md +71 -0
- package/skills/i3-ui-prototype/references/expert-adapter.md +33 -5
- package/skills/i3-ui-prototype/references/framework-contract.md +76 -0
- package/skills/i3-ui-prototype/references/interaction-acceptance.md +165 -0
- package/skills/i3-ui-prototype/references/legacy-native.md +58 -0
- package/skills/i3-ui-prototype/references/optional-skills.md +59 -0
- package/skills/i3-ui-prototype/references/template-adapters.md +1 -1
- package/skills/i3-ui-prototype/references/workflow-contract.md +58 -4
- package/skills/i3-ui-prototype/scripts/adapters/easel-html.cjs +1 -1
- package/skills/i3-ui-prototype/scripts/content-template.cjs +98 -0
- package/skills/i3-ui-prototype/scripts/enterprise-template.cjs +13 -6
- package/skills/i3-ui-prototype/scripts/manage-baseline.cjs +1 -1
- package/skills/i3-ui-prototype/scripts/native-browser.cjs +785 -0
- package/skills/i3-ui-prototype/scripts/native-build.cjs +91 -0
- package/skills/i3-ui-prototype/scripts/native-delivery.cjs +91 -0
- package/skills/i3-ui-prototype/scripts/native-devtools.cjs +91 -0
- package/skills/i3-ui-prototype/scripts/native-files.cjs +35 -0
- package/skills/i3-ui-prototype/scripts/native-pilot-lib.cjs +244 -0
- package/skills/i3-ui-prototype/scripts/native-pilot.cjs +34 -0
- package/skills/i3-ui-prototype/scripts/optional-skill-install.cjs +203 -0
- package/skills/i3-ui-prototype/scripts/prototype-dependencies.cjs +81 -0
- package/skills/i3-ui-prototype/scripts/prototype-lib.cjs +26 -6
- package/skills/i3-ui-prototype/scripts/prototype-locations.cjs +65 -0
- package/skills/i3-ui-prototype/scripts/sync-template.cjs +19 -0
- package/skills/i3-ui-prototype/scripts/template-contract.cjs +398 -0
- package/skills/i3-ui-prototype/scripts/validate-result.cjs +117 -0
- package/skills/i3-using-agent-skills/SKILL.md +2 -5
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const {spawnSync}=require('node:child_process');
|
|
3
|
+
const http=require('node:http');
|
|
4
|
+
const F=require('./native-files.cjs');
|
|
5
|
+
const {fs,path,crypto,assert,hash,json,id,inside,walk,write,entries}=F;
|
|
6
|
+
function verifyBuild(directory,buildId,{manifest}) {
|
|
7
|
+
id(buildId); const root=inside(directory,`builds/${buildId}`); const data=json(path.join(root,'manifest.json'));
|
|
8
|
+
assert.equal(data.buildId,buildId,'Build ID mismatch');
|
|
9
|
+
for(const step of ['typecheck','test','build'])assert.ok(Object.hasOwn(data.checks||{},step)&&typeof data.checks[step]==='string',`Required build check missing: ${step}`);
|
|
10
|
+
assert.deepEqual(entries(path.join(root,'site'),walk(path.join(root,'site'))),data.files,'Build bytes changed');
|
|
11
|
+
assert.equal(hash(JSON.stringify(data.files)),data.buildDigest,'Build digest mismatch');
|
|
12
|
+
assert.equal(data.candidateDigest,manifest(directory).digest,'Build is for an old candidate / 旧构建');
|
|
13
|
+
const scripts=Object.keys(data.files).filter(file=>/\.[cm]?js$/.test(file));
|
|
14
|
+
assert.ok(data.files['index.html'] && data.files['prototype-config.json'] && scripts.length,'Native build entry, config and JavaScript required');
|
|
15
|
+
assert.ok(scripts.some(file=>fs.readFileSync(inside(path.join(root,'site'),file),'utf8').includes(data.candidateDigest)),'Built JavaScript must embed its candidate identity / 构建标识');
|
|
16
|
+
return {...data,directory:root};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function verifyBrowserReport(directory,report,build) {
|
|
20
|
+
const start=Date.parse(report.startedAt),end=Date.parse(report.completedAt);
|
|
21
|
+
assert.ok(Number.isFinite(start)&&Number.isFinite(end)&&end>=start,'Browser run timestamps must be valid and ordered');
|
|
22
|
+
const base=new URL(report.previewBaseURL);
|
|
23
|
+
assert.ok(['http:','https:'].includes(base.protocol)&&!base.username&&!base.password&&!base.search&&!base.hash&&base.pathname.endsWith('/'),'Invalid preview base URL');
|
|
24
|
+
assert.ok(Array.isArray(report.observedResources)&&report.observedResources.length,'Observed browser resources required');
|
|
25
|
+
const observations=new Map();
|
|
26
|
+
for(const resource of report.observedResources) {
|
|
27
|
+
assert.ok(Object.hasOwn(build.files,resource.path),`Observed resource is outside the fixed build: ${resource.path}`);
|
|
28
|
+
const actual=new URL(resource.url),expected=new URL(resource.path==='index.html'?'':resource.path,base);
|
|
29
|
+
const entryAlias=resource.path==='index.html'&&actual.pathname===new URL('index.html',base).pathname;
|
|
30
|
+
assert.ok(actual.origin===base.origin&&(actual.pathname===expected.pathname||entryAlias)&&!actual.username&&!actual.password,`Observed resource has a different preview URL: ${resource.path}`);
|
|
31
|
+
assert.equal(resource.status,200,`Observed resource did not return 200: ${resource.path}`);
|
|
32
|
+
assert.equal(resource.sha256,build.files[resource.path],`Observed resource bytes differ: ${resource.path}`);
|
|
33
|
+
assert.ok(typeof resource.resourceType==='string'&&resource.resourceType,'Observed resource type required');
|
|
34
|
+
const type=resource.path==='index.html'?'document':/\.[cm]?js$/.test(resource.path)?'script':resource.path.endsWith('.css')?'stylesheet':resource.path==='prototype-config.json'?'fetch':null;
|
|
35
|
+
if(type)assert.equal(resource.resourceType,type,`Observed resource request type differs: ${resource.path}`);
|
|
36
|
+
observations.set(resource.path,resource);
|
|
37
|
+
}
|
|
38
|
+
const required=Object.keys(build.files).filter(file=>['index.html','prototype-config.json'].includes(file)||/\.(?:[cm]?js|css)$/.test(file));
|
|
39
|
+
for(const file of required)assert.ok(observations.has(file),`Browser did not observe required build resource: ${file}`);
|
|
40
|
+
assert.ok(Array.isArray(report.screenshots)&&report.screenshots.length>=2&&Array.isArray(report.viewports)&&report.viewports.length>=2,'Responsive screenshots required');
|
|
41
|
+
const png=Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]);
|
|
42
|
+
for(const file of report.screenshots) {
|
|
43
|
+
const bytes=fs.readFileSync(inside(directory,file));
|
|
44
|
+
assert.ok(bytes.length>24&&bytes.subarray(0,8).equals(png),`Screenshot must be PNG: ${file}`);
|
|
45
|
+
}
|
|
46
|
+
const trace=fs.readFileSync(inside(directory,report.trace));
|
|
47
|
+
assert.ok(trace.length>=22&&trace[0]===0x50&&trace[1]===0x4b&&[[3,4],[5,6],[7,8]].some(pair=>trace[2]===pair[0]&&trace[3]===pair[1]),'Browser trace must be a ZIP artifact');
|
|
48
|
+
}
|
|
49
|
+
function build(directory,options,{manifest,inspect}) {
|
|
50
|
+
const checked=inspect(directory); assert.equal(checked.status,'passed',checked.errors?.join('; '));
|
|
51
|
+
const before=manifest(directory); const app=inside(directory,'app');
|
|
52
|
+
function run(args,env={}) {
|
|
53
|
+
const result=spawnSync(process.platform==='win32'?'npm.cmd':'npm',args,{cwd:app,env:{...process.env,...env},encoding:'utf8',timeout:180000,maxBuffer:5*1024*1024});
|
|
54
|
+
assert.equal(result.status,0,`npm ${args.join(' ')} failed: ${result.error?.message||result.stderr||result.stdout}`);
|
|
55
|
+
return result.stdout;
|
|
56
|
+
}
|
|
57
|
+
if(options.install) run(['ci','--ignore-scripts','--no-audit','--no-fund']);
|
|
58
|
+
assert.ok(fs.existsSync(path.join(app,'node_modules','vite')),'Local dependencies missing; explicitly build --install');
|
|
59
|
+
const typecheck=run(['run','typecheck']);
|
|
60
|
+
const test=run(['test']);
|
|
61
|
+
fs.mkdirSync(inside(directory,'builds'),{recursive:true}); const stage=fs.mkdtempSync(path.join(directory,'builds','.build-'));
|
|
62
|
+
try {
|
|
63
|
+
const output=run(['run','build','--','--outDir',path.join(stage,'site'),'--emptyOutDir'],{VITE_CANDIDATE_ID:before.digest});
|
|
64
|
+
assert.equal(manifest(directory).digest,before.digest,'Source changed during build');
|
|
65
|
+
const files=entries(path.join(stage,'site'),walk(path.join(stage,'site'))); const buildDigest=hash(JSON.stringify(files)); const buildId=`b-${buildDigest.slice(0,20)}`;
|
|
66
|
+
const data={schemaVersion:1,buildId,buildDigest,candidateDigest:before.digest,inputDigest:before.inputDigest,revision:before.revision,files,toolchain:{node:process.version},checks:{typecheck,test,build:output}};
|
|
67
|
+
write(stage,'manifest.json',data);
|
|
68
|
+
const destination=inside(directory,`builds/${buildId}`);
|
|
69
|
+
if(fs.existsSync(destination)) verifyBuild(directory,buildId,{manifest}); else fs.renameSync(stage,destination);
|
|
70
|
+
write(directory,'last-build.json',{buildId,buildDigest,candidateDigest:before.digest});
|
|
71
|
+
return {status:'built',...data,directory:destination};
|
|
72
|
+
} finally { if(fs.existsSync(stage)) fs.rmSync(stage,{recursive:true,force:true}); }
|
|
73
|
+
}
|
|
74
|
+
async function preview(directory,options,{manifest}) {
|
|
75
|
+
const buildId=options.buildId||json(inside(directory,'last-build.json')).buildId;
|
|
76
|
+
const checked=verifyBuild(directory,buildId,{manifest}); const site=path.join(checked.directory,'site');
|
|
77
|
+
const mime={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json','.svg':'image/svg+xml','.png':'image/png','.woff2':'font/woff2'};
|
|
78
|
+
const server=http.createServer((req,res)=>{
|
|
79
|
+
try {
|
|
80
|
+
const pathname=decodeURIComponent(new URL(req.url,'http://127.0.0.1').pathname);
|
|
81
|
+
res.setHeader('Cache-Control','no-store');
|
|
82
|
+
if(pathname==='/__prototype-build.json') { const current=verifyBuild(directory,buildId,{manifest}); res.setHeader('Content-Type','application/json'); res.end(JSON.stringify({buildId,currentCandidate:current.candidateDigest,buildDigest:current.buildDigest,files:current.files})); return; }
|
|
83
|
+
const relative=pathname==='/'?'index.html':pathname.replace(/^\//,''); const file=inside(site,relative);
|
|
84
|
+
if(!fs.existsSync(file)||!fs.statSync(file).isFile()) { res.writeHead(404); res.end('Not found'); return; }
|
|
85
|
+
res.setHeader('Content-Type',mime[path.extname(file)]||'application/octet-stream'); res.end(fs.readFileSync(file));
|
|
86
|
+
} catch(error) { res.writeHead(409); res.end(error.message); }
|
|
87
|
+
});
|
|
88
|
+
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(options.port||0,'127.0.0.1',resolve);});
|
|
89
|
+
return {server,url:`http://127.0.0.1:${server.address().port}/`,build:checked};
|
|
90
|
+
}
|
|
91
|
+
module.exports={build,verifyBuild,preview,verifyBrowserReport};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const F=require('./native-files.cjs');
|
|
3
|
+
const {fs,path,crypto,assert,json,inside,write}=F;
|
|
4
|
+
const read=(root,file)=>fs.existsSync(inside(root,file))?json(inside(root,file)):null;
|
|
5
|
+
const quote=value=>`'${String(value).replace(/'/g,"'\\''")}'`;
|
|
6
|
+
|
|
7
|
+
function state(directory,options,lib) {
|
|
8
|
+
directory=path.resolve(directory);
|
|
9
|
+
const current=lib.manifest(directory),meta=json(inside(directory,'prototype.json'));
|
|
10
|
+
const review=lib.effectiveReview(meta,current);
|
|
11
|
+
const structure=lib.inspect(directory),advice=read(directory,'qa/design-advice.json'),decisions=read(directory,'design-system/decisions.json');
|
|
12
|
+
let design;
|
|
13
|
+
try { const reviewed=lib.inspectDesignReview(directory,current); design={status:reviewed.status,queries:reviewed.queries,records:reviewed.records,adopted:reviewed.adoptedAdvice.length}; }
|
|
14
|
+
catch(error) { design={status:advice?.status==='pending'||decisions?.reviewStatus==='pending'?'pending':'failed',queries:advice?.queries?.length||0,records:advice?.records?.length||0,errors:[error.message]}; }
|
|
15
|
+
let build={status:'not-run'};
|
|
16
|
+
const pointer=read(directory,'last-build.json');
|
|
17
|
+
if(pointer) {
|
|
18
|
+
try { const checked=lib.verifyBuild(directory,pointer.buildId); build={status:'current',buildId:checked.buildId,buildDigest:checked.buildDigest,checks:Object.keys(checked.checks)}; }
|
|
19
|
+
catch(error) { build={status:pointer.candidateDigest!==current.digest?'stale':'failed',buildId:pointer.buildId,errors:[error.message]}; }
|
|
20
|
+
}
|
|
21
|
+
let browser={status:'not-run'};
|
|
22
|
+
const latest=read(directory,'qa/latest.json');
|
|
23
|
+
if(latest) {
|
|
24
|
+
try {
|
|
25
|
+
const report=json(inside(directory,latest.report)),checked=lib.inspect(directory,{acceptance:true});
|
|
26
|
+
browser={status:report.candidateDigest!==current.digest||report.revision!==current.revision?'stale':checked.status==='passed'?'current':'failed',report:latest.report,reportDigest:F.hash(fs.readFileSync(inside(directory,latest.report))),runId:report.id,executor:report.executor,...(checked.errors?{errors:checked.errors}:{})};
|
|
27
|
+
} catch(error) { browser={status:'failed',errors:[error.message]}; }
|
|
28
|
+
}
|
|
29
|
+
const devtoolsReview=require('./native-devtools.cjs').check(directory,lib);
|
|
30
|
+
const ownership=read(directory,'.prototype-write.lock');
|
|
31
|
+
const savedDelivery=read(directory,'qa/delivery.json');
|
|
32
|
+
const deliveryCurrent=savedDelivery?.schemaVersion===1&&savedDelivery.status==='complete'&&savedDelivery.id===meta.id&&savedDelivery.revision===current.revision&&savedDelivery.candidateDigest===current.digest&&savedDelivery.inputDigest===current.inputDigest&&savedDelivery.buildId===build.buildId&&savedDelivery.buildDigest===build.buildDigest&&savedDelivery.report===browser.report&&savedDelivery.review===review.status&&savedDelivery.humanConfirmationRequired===(review.status!=='approved')&&browser.status==='current'&&((!devtoolsReview.required&&savedDelivery.browserReportDigest===undefined)||savedDelivery.browserReportDigest===browser.reportDigest)&&(!devtoolsReview.required||(devtoolsReview.status==='current'&&savedDelivery.devtoolsReviewDigest===devtoolsReview.reportDigest));
|
|
33
|
+
const delivery=savedDelivery?{status:deliveryCurrent?'current':'stale',path:'qa/delivery.json',revision:savedDelivery.revision,...(deliveryCurrent?{result:savedDelivery}:{})}:null;
|
|
34
|
+
let runtime;
|
|
35
|
+
try { const provider=require('./native-browser.cjs').resolvePlaywright(directory,options); runtime={availability:'available',...provider,launchVerified:false}; }
|
|
36
|
+
catch(error) { runtime={availability:'missing',reason:error.message}; }
|
|
37
|
+
const cli=path.join(__dirname,'native-pilot.cjs');
|
|
38
|
+
const command=(operation,args=[])=>{const argv=[process.execPath,cli,operation,directory,...args];return {argv,shell:argv.map(quote).join(' ')};};
|
|
39
|
+
let nextRevision=/^r(\d+)$/.test(current.revision)?`r${String(Number(current.revision.slice(1))+1).padStart(3,'0')}`:`${current.revision}-next`;
|
|
40
|
+
while(fs.existsSync(inside(directory,`baselines/${nextRevision}`))) nextRevision+='-next';
|
|
41
|
+
const expected=['--expected-revision',current.revision,'--expected-digest',current.digest];
|
|
42
|
+
const browserArgs=[];
|
|
43
|
+
const browserProvider=options.playwrightModule||process.env.PLAYWRIGHT_MODULE||(runtime.availability==='available'?runtime.packageRoot:null);
|
|
44
|
+
if(browserProvider) browserArgs.push('--playwright-module',browserProvider);
|
|
45
|
+
if(options.browserChannel) browserArgs.push('--browser-channel',options.browserChannel);
|
|
46
|
+
if(options.expectedCategories) browserArgs.push('--expected-categories',typeof options.expectedCategories==='string'?options.expectedCategories:JSON.stringify(options.expectedCategories));
|
|
47
|
+
if(options.expectedDescription) browserArgs.push('--expected-description',options.expectedDescription);
|
|
48
|
+
if(options.verifyBlurValidation===true) browserArgs.push('--verify-blur-validation');
|
|
49
|
+
if(options.verifyAtomicStatus===true) browserArgs.push('--verify-atomic-status');
|
|
50
|
+
const commands={state:command('state'),revise:command('revise',['--revision',nextRevision,...expected]),build:command('build',fs.existsSync(inside(directory,'app/node_modules/vite'))?[]:['--install']),browser:command('browser',browserArgs),finalize:command('finalize',expected)};
|
|
51
|
+
if(meta.runtime.contentReference) {
|
|
52
|
+
delete commands.browser;
|
|
53
|
+
commands.recordRun=command('record-run',['--report',path.join(directory,'qa/business-browser-input.json')]);
|
|
54
|
+
}
|
|
55
|
+
if(build.status==='current')commands.preview=command('preview',['--build-id',build.buildId]);
|
|
56
|
+
if(devtoolsReview.required)commands.recordDevtoolsReview=command('record-devtools-review',['--report',path.join(directory,'qa/devtools-review-input.json')]);
|
|
57
|
+
const nextAction=ownership?'wait-for-owner':structure.status!=='passed'?'repair-structure':meta.runtime.contentReference&&meta.business?.status!=='adapted'?'implement-business':!['reviewed','no-match','skipped'].includes(design.status)?'review-design':build.status!=='current'?'build':browser.status!=='current'?(meta.runtime.contentReference?'business-browser-review':'browser'):devtoolsReview.required&&devtoolsReview.status!=='current'?'devtools-review':deliveryCurrent?'return-result':'finalize';
|
|
58
|
+
return {status:structure.status==='passed'?'ready':'failed',operation:'state',readOnly:true,directory,id:meta.id,revision:current.revision,candidateDigest:current.digest,inputDigest:current.inputDigest,identity:current.identity,instanceId:meta.instanceId,scopes:json(inside(directory,'inputs/scope.json')),structure,design,build,browser,devtoolsReview,delivery,browserRuntime:runtime,ownership,verification:meta.verification,review,nextAction,commands,notes:['Design decisions and real provider queries remain the executing role responsibility.','Use the printed commands; no source-code inspection is required to recover flags or digests.','A missing browser provider requires an existing Playwright module path; no automatic install or global changes.','A current DevTools review checks evidence files and identity; actual browser Skill/MCP invocation still requires independent raw host transcript verification.']};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function finalize(directory,options,lib) {
|
|
62
|
+
directory=path.resolve(directory);
|
|
63
|
+
const before=lib.manifest(directory),checked=lib.inspect(directory,{acceptance:true});
|
|
64
|
+
assert.equal(checked.status,'passed',`Current acceptance required / 当前实际验收未通过: ${checked.errors?.join('; ')}`);
|
|
65
|
+
const devtools=require('./native-devtools.cjs');
|
|
66
|
+
const requiredReview=devtools.check(directory,lib);assert.ok(!requiredReview.required||requiredReview.status==='current',`Current DevTools review required: ${requiredReview.errors?.join('; ')||requiredReview.status}`);
|
|
67
|
+
const owner=`finalize-${crypto.randomUUID()}`;
|
|
68
|
+
lib.claim(directory,{owner,expectedRevision:options.expectedRevision||before.revision,expectedDigest:options.expectedDigest||before.digest});
|
|
69
|
+
try {
|
|
70
|
+
const again=lib.inspect(directory,{acceptance:true});assert.equal(again.status,'passed',again.errors?.join('; '));
|
|
71
|
+
const devtoolsReview=devtools.check(directory,lib);assert.ok(!devtoolsReview.required||devtoolsReview.status==='current','Current DevTools review required');
|
|
72
|
+
assert.equal(lib.manifest(directory).digest,before.digest,'Candidate changed before final handoff');
|
|
73
|
+
const meta=json(inside(directory,'prototype.json')),scope=json(inside(directory,'inputs/scope.json'));
|
|
74
|
+
const review=lib.effectiveReview(meta,before);
|
|
75
|
+
const latest=json(inside(directory,'qa/latest.json')),report=json(inside(directory,latest.report));
|
|
76
|
+
const delivery={schemaVersion:1,status:'complete',operation:'finalize',id:meta.id,revision:before.revision,candidateDigest:before.digest,inputDigest:before.inputDigest,framework:meta.framework,template:meta.template,expertMode:meta.expertMode,expert:meta.expert.status,verification:'passed',review:review.status,checks:{structure:'passed',acceptance:'passed',build:'current',browser:'current'},buildId:report.buildId,buildDigest:report.buildDigest,report:latest.report,executor:report.executor,tool:report.tool,handoff:path.join(directory,'HANDOFF.md'),receipt:'qa/delivery.json',openQuestions:scope.openQuestions||[],humanConfirmationRequired:review.status!=='approved'};
|
|
77
|
+
delivery.browserReportDigest=F.hash(fs.readFileSync(inside(directory,latest.report)));
|
|
78
|
+
if(devtoolsReview.required){delivery.devtoolsReview=devtoolsReview.report;delivery.devtoolsReviewDigest=devtoolsReview.reportDigest;delivery.checks.devtoolsReview='current';delivery.invocationEvidence='Verify actual Skill/MCP calls independently in the host transcript';}
|
|
79
|
+
const start='<!-- native-delivery:start -->',end='<!-- native-delivery:end -->';
|
|
80
|
+
const block=[start,'## 当前真实交付状态',`- 原型:${delivery.id} / ${delivery.revision}`,`- candidateDigest:${delivery.candidateDigest}`,`- inputDigest:${delivery.inputDigest}`,`- 构建:${delivery.buildId} / ${delivery.buildDigest}`,`- 浏览器:${latest.report};实际执行者 ${report.executor};${report.tool}`,`- 专业模式:${delivery.expertMode} / ${delivery.expert}`,`- verification:passed;review:${delivery.review}。本工具不确认或冻结基线。`,`- 待确认问题:${delivery.openQuestions.length?JSON.stringify(delivery.openQuestions):'无新增;仍需用户评审。'}`,end].join('\n');
|
|
81
|
+
const file=inside(directory,'HANDOFF.md'),previous=fs.existsSync(file)?fs.readFileSync(file,'utf8'):'';
|
|
82
|
+
const begin=previous.indexOf(start),finish=previous.indexOf(end);
|
|
83
|
+
assert.ok((begin===-1&&finish===-1)||(begin>=0&&finish>begin&&previous.indexOf(start,begin+1)===-1&&previous.indexOf(end,finish+1)===-1),'Ambiguous generated handoff markers; preserve the user file');
|
|
84
|
+
const updated=begin===-1?`${previous.trimEnd()}\n\n${block}\n`:`${previous.slice(0,begin)}${block}${previous.slice(finish+end.length)}`;
|
|
85
|
+
assert.equal(lib.manifest(directory).digest,before.digest,'Candidate changed while finalizing');
|
|
86
|
+
if(updated!==previous) write(directory,'HANDOFF.md',updated);
|
|
87
|
+
write(directory,'qa/delivery.json',delivery);
|
|
88
|
+
return delivery;
|
|
89
|
+
} finally { lib.release(directory,owner); }
|
|
90
|
+
}
|
|
91
|
+
module.exports={state,finalize};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Checks evidence completeness and current identity, not the truth of a model's
|
|
3
|
+
// claims. Actual Skill/MCP invocation must be checked against the host transcript.
|
|
4
|
+
const F=require('./native-files.cjs');
|
|
5
|
+
const {fs,path,assert,hash,json,inside,write,crypto}=F;
|
|
6
|
+
const zlib=require('node:zlib');
|
|
7
|
+
const KINDS=['dom','screenshot','console','network','accessibility','performance'];
|
|
8
|
+
const METHODS=['evaluate_script','take_screenshot','list_console_messages','list_network_requests','take_snapshot','performance_start_trace','performance_stop_trace'];
|
|
9
|
+
const limitations='Evidence structure, file bytes and current identity checked; verify actual Skill/MCP calls and findings independently in the raw host transcript.';
|
|
10
|
+
function policy(){return {schemaVersion:1,required:true,skill:'i3-browser-testing-with-devtools',provider:'chrome-devtools-mcp',observations:[...KINDS]};}
|
|
11
|
+
function readPolicy(directory){
|
|
12
|
+
const configured=json(inside(directory,'inputs/acceptance.json')).browserReview;
|
|
13
|
+
const frameworkFile=inside(directory,'inputs/framework.json');
|
|
14
|
+
if(fs.existsSync(frameworkFile)){const fixed=json(frameworkFile).acceptance?.browserReview;if(fixed!==undefined)assert.deepEqual(configured,fixed,'DevTools browser review policy differs from its fixed framework input');}
|
|
15
|
+
if(configured===undefined)return null;
|
|
16
|
+
assert.equal(configured.schemaVersion,1,'Unknown DevTools review policy');assert.equal(configured.required,true,'DevTools review policy cannot silently opt out');
|
|
17
|
+
assert.deepEqual(configured.observations,KINDS,'Required DevTools observations changed');assert.equal(configured.skill,policy().skill);assert.equal(configured.provider,'chrome-devtools-mcp');return configured;
|
|
18
|
+
}
|
|
19
|
+
const text=(value,label)=>assert.ok(typeof value==='string'&&value.trim(),`${label} required`);
|
|
20
|
+
const crcTable=Array.from({length:256},(_,value)=>{for(let bit=0;bit<8;bit++)value=(value>>>1)^((value&1)?0xedb88320:0);return value>>>0;});
|
|
21
|
+
function crc32(bytes){let crc=0xffffffff;for(const byte of bytes)crc=(crc>>>8)^crcTable[(crc^byte)&255];return (crc^0xffffffff)>>>0;}
|
|
22
|
+
function verifyPng(bytes){
|
|
23
|
+
assert.ok(bytes.length>=57&&bytes.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])),'Complete PNG screenshot required');
|
|
24
|
+
let offset=8,header=null,ended=false;const image=[];
|
|
25
|
+
while(offset<bytes.length){
|
|
26
|
+
assert.ok(offset+12<=bytes.length,'Truncated PNG chunk');const size=bytes.readUInt32BE(offset),end=offset+size+12;assert.ok(end<=bytes.length,'Truncated PNG data');
|
|
27
|
+
const kind=bytes.toString('ascii',offset+4,offset+8),data=bytes.subarray(offset+8,offset+8+size);assert.equal(crc32(bytes.subarray(offset+4,offset+8+size)),bytes.readUInt32BE(offset+8+size),'Invalid PNG chunk CRC');
|
|
28
|
+
if(!header){assert.equal(kind,'IHDR','PNG header must be first');assert.equal(size,13,'Invalid PNG IHDR');header=data;}
|
|
29
|
+
else assert.notEqual(kind,'IHDR','Duplicate PNG header');
|
|
30
|
+
if(kind==='IDAT')image.push(data);
|
|
31
|
+
if(kind==='IEND'){assert.equal(size,0,'Invalid PNG end');assert.equal(end,bytes.length,'Trailing PNG bytes');ended=true;break;}offset=end;
|
|
32
|
+
}
|
|
33
|
+
assert.ok(ended&&image.length,'PNG needs image data and IEND');
|
|
34
|
+
const width=header.readUInt32BE(0),height=header.readUInt32BE(4),depth=header[8],channels={0:1,2:3,3:1,4:2,6:4}[header[9]];
|
|
35
|
+
assert.ok(width&&height&&channels&&[1,2,4,8,16].includes(depth),'Invalid PNG dimensions or color format');assert.equal(header[10],0,'Unsupported PNG compression');assert.equal(header[11],0,'Unsupported PNG filter method');assert.equal(header[12],0,'Expected noninterlaced Chrome screenshot PNG');
|
|
36
|
+
const stride=Math.ceil(width*channels*depth/8)+1,expected=stride*height;assert.ok(Number.isSafeInteger(expected)&&expected<=64*1024*1024,'PNG decoded image is too large');
|
|
37
|
+
let pixels;try{pixels=zlib.inflateSync(Buffer.concat(image),{maxOutputLength:64*1024*1024});}catch{throw new Error('Invalid PNG compressed image data');}
|
|
38
|
+
assert.equal(pixels.length,expected,'Incomplete PNG pixels');for(let row=0;row<height;row++)assert.ok(pixels[row*stride]<=4,'Invalid PNG scanline filter');
|
|
39
|
+
}
|
|
40
|
+
function validate(directory,report,current,build,configured){
|
|
41
|
+
assert.equal(report.schemaVersion,1);assert.equal(report.status,'passed','DevTools review has not passed');
|
|
42
|
+
assert.equal(report.revision,current.revision,'DevTools review revision is stale');assert.equal(report.candidateDigest,current.digest,'DevTools review candidate is stale');
|
|
43
|
+
assert.equal(report.buildId,build.buildId,'DevTools review build is stale');assert.equal(report.buildDigest,build.buildDigest,'DevTools review build bytes differ');
|
|
44
|
+
text(report.executor,'DevTools executor');const start=Date.parse(report.startedAt),end=Date.parse(report.completedAt);assert.ok(Number.isFinite(start)&&Number.isFinite(end)&&end>=start,'DevTools review timestamps required');
|
|
45
|
+
const url=new URL(report.previewBaseURL);assert.ok(url.protocol==='http:'&&['127.0.0.1','localhost','[::1]'].includes(url.hostname)&&!url.username&&!url.password,'DevTools preview must be the known local build');
|
|
46
|
+
assert.equal(report.skill?.name,configured.skill,'Required browser Skill missing');text(report.skill.toolCallId,'Skill tool call ID');text(report.skill.evidence,'Skill host transcript locator');
|
|
47
|
+
assert.equal(report.mcp?.name,'chrome-devtools-mcp');text(report.mcp.version,'MCP version');text(report.mcp.evidence,'MCP host transcript locator');
|
|
48
|
+
assert.ok(Array.isArray(report.mcp.calls)&&report.mcp.calls.length,'Actual MCP call locators required');
|
|
49
|
+
for(const call of report.mcp.calls){assert.ok(typeof call.tool==='string'&&call.tool.startsWith('mcp__'),'Native MCP tool name required');text(call.toolCallId,'MCP tool call ID');}
|
|
50
|
+
assert.equal(new Set(report.mcp.calls.map(call=>call.toolCallId)).size,report.mcp.calls.length,'Duplicate MCP call IDs');
|
|
51
|
+
for(const method of METHODS)assert.ok(report.mcp.calls.some(call=>call.tool.endsWith(`__${method}`)),`Missing DevTools method: ${method}`);
|
|
52
|
+
assert.ok(Array.isArray(report.openItems),'DevTools openItems required');assert.ok(!report.openItems.some(item=>item?.blocking===true),'Blocking DevTools findings remain');
|
|
53
|
+
const evidenceDigests={},seen=new Set();
|
|
54
|
+
for(const kind of KINDS){
|
|
55
|
+
const observed=report.observations?.[kind];assert.equal(observed?.status,'passed',`Missing passed DevTools observation: ${kind}`);text(observed.summary,`${kind} finding`);
|
|
56
|
+
assert.ok(typeof observed.evidence==='string'&&observed.evidence.startsWith('qa/')&&!seen.has(observed.evidence),'Each DevTools observation needs a distinct QA evidence path');seen.add(observed.evidence);
|
|
57
|
+
const file=inside(directory,observed.evidence);assert.ok(fs.statSync(file).isFile(),`${kind} evidence is not a file`);const bytes=fs.readFileSync(file);assert.ok(bytes.length&&bytes.length<=64*1024*1024,`${kind} evidence is empty or too large`);
|
|
58
|
+
if(kind==='screenshot')verifyPng(bytes);
|
|
59
|
+
if(kind==='performance'){
|
|
60
|
+
assert.ok(!bytes.subarray(0,2).equals(Buffer.from('PK')),'Playwright ZIP is not a DevTools trace');
|
|
61
|
+
let trace;try{const decoded=bytes[0]===31&&bytes[1]===139?zlib.gunzipSync(bytes,{maxOutputLength:64*1024*1024}):bytes;trace=JSON.parse(decoded.toString('utf8'));}catch{throw new Error('Parseable DevTools trace JSON required');}
|
|
62
|
+
const events=Array.isArray(trace)?trace:trace?.traceEvents;assert.ok(Array.isArray(events)&&events.length&&events.some(event=>typeof event.name==='string'),'Nonempty DevTools trace events required');
|
|
63
|
+
}
|
|
64
|
+
evidenceDigests[observed.evidence]=hash(bytes);
|
|
65
|
+
}
|
|
66
|
+
if(report.evidenceDigests)assert.deepEqual(report.evidenceDigests,evidenceDigests,'DevTools evidence bytes changed');
|
|
67
|
+
return evidenceDigests;
|
|
68
|
+
}
|
|
69
|
+
function check(directory,lib){
|
|
70
|
+
try{
|
|
71
|
+
const configured=readPolicy(directory);if(!configured)return {status:'not-required',required:false};
|
|
72
|
+
const file=inside(directory,'qa/devtools-review.json');if(!fs.existsSync(file))return {status:'not-run',required:true,missing:[...KINDS],limitations};
|
|
73
|
+
const report=json(file),current=lib.manifest(directory),build=lib.verifyBuild(directory,report.buildId);
|
|
74
|
+
const latestFile=inside(directory,'last-build.json');if(fs.existsSync(latestFile))assert.equal(json(latestFile).buildId,report.buildId,'DevTools review is not for the latest build');
|
|
75
|
+
const browserFile=inside(directory,'qa/latest.json');if(fs.existsSync(browserFile)){const supplemental=json(inside(directory,json(browserFile).report));assert.equal(supplemental.buildId,report.buildId,'DevTools review and browser regression build differ');assert.equal(supplemental.buildDigest,report.buildDigest,'DevTools review and browser regression bytes differ');}
|
|
76
|
+
const evidenceDigests=validate(directory,report,current,build,configured);assert.ok(report.evidenceDigests,'Recorded DevTools evidence hashes required');
|
|
77
|
+
return {status:'current',required:true,report:'qa/devtools-review.json',reportDigest:hash(fs.readFileSync(file)),revision:report.revision,candidateDigest:report.candidateDigest,buildId:report.buildId,buildDigest:report.buildDigest,evidenceDigests,invocationVerified:false,limitations};
|
|
78
|
+
}catch(error){return {status:'failed',required:true,errors:[error.message],limitations};}
|
|
79
|
+
}
|
|
80
|
+
function record(directory,report,lib){
|
|
81
|
+
const configured=readPolicy(directory);assert.ok(configured,'This fixed input has no DevTools review policy; use its original contract');
|
|
82
|
+
const current=lib.manifest(directory),build=lib.verifyBuild(directory,report.buildId),copy=JSON.parse(JSON.stringify(report));
|
|
83
|
+
const latestFile=inside(directory,'last-build.json');if(fs.existsSync(latestFile))assert.equal(json(latestFile).buildId,report.buildId,'DevTools review is not for the latest build');
|
|
84
|
+
copy.evidenceDigests=validate(directory,copy,current,build,configured);
|
|
85
|
+
const owner=`devtools-${crypto.randomUUID()}`;lib.claim(directory,{owner,expectedRevision:current.revision,expectedDigest:current.digest});
|
|
86
|
+
try{
|
|
87
|
+
assert.equal(lib.manifest(directory).digest,current.digest,'Candidate changed during DevTools review');validate(directory,copy,current,lib.verifyBuild(directory,copy.buildId),configured);
|
|
88
|
+
write(directory,'qa/devtools-review.json',copy);return {status:'passed',report:'qa/devtools-review.json',candidateDigest:current.digest,buildId:copy.buildId,invocationVerified:false,limitations};
|
|
89
|
+
}finally{lib.release(directory,owner);}
|
|
90
|
+
}
|
|
91
|
+
module.exports={KINDS,policy,readPolicy,validate,check,record};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const assert = require('node:assert/strict');
|
|
6
|
+
const hash = value => crypto.createHash('sha256').update(value).digest('hex');
|
|
7
|
+
const json = file => JSON.parse(fs.readFileSync(file,'utf8'));
|
|
8
|
+
const id = value => { assert.match(value || '',/^[a-z0-9]+(?:-[a-z0-9]+)*$/,'Invalid identifier'); return value; };
|
|
9
|
+
function inside(root,relative) {
|
|
10
|
+
assert.ok(typeof relative==='string' && relative && !path.isAbsolute(relative) && !path.win32.isAbsolute(relative) && !relative.includes('\\') && relative.split('/').every(p=>p && p!=='.' && p!=='..'),'Unsafe prototype path');
|
|
11
|
+
let current=path.resolve(root);
|
|
12
|
+
for(const part of relative.split('/')) { current=path.join(current,part); if(fs.existsSync(current)) assert.ok(!fs.lstatSync(current).isSymbolicLink(),'Prototype symlink refused'); }
|
|
13
|
+
return current;
|
|
14
|
+
}
|
|
15
|
+
function walk(root,prefix='',exclude=()=>false) {
|
|
16
|
+
const location=prefix?inside(root,prefix):root;
|
|
17
|
+
if(!fs.existsSync(location)) return [];
|
|
18
|
+
return fs.readdirSync(location,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name)).flatMap(entry=>{
|
|
19
|
+
const relative=prefix?`${prefix}/${entry.name}`:entry.name;
|
|
20
|
+
if(exclude(relative,entry.name)) return [];
|
|
21
|
+
assert.ok(!entry.isSymbolicLink(),`Prototype symlink refused: ${relative}`);
|
|
22
|
+
return entry.isDirectory()?walk(root,relative,exclude):entry.isFile()?[relative]:[];
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function write(root,relative,value) {
|
|
26
|
+
const file=inside(root,relative); fs.mkdirSync(path.dirname(file),{recursive:true});
|
|
27
|
+
const tmp=`${file}.${crypto.randomUUID()}.tmp`;
|
|
28
|
+
try { fs.writeFileSync(tmp,typeof value==='string'?value:JSON.stringify(value,null,2)+'\n',{flag:'wx'}); fs.renameSync(tmp,file); }
|
|
29
|
+
finally { if(fs.existsSync(tmp)) fs.unlinkSync(tmp); }
|
|
30
|
+
}
|
|
31
|
+
function entries(root,files) { return Object.fromEntries([...new Set(files)].sort().map(file=>[file,hash(fs.readFileSync(inside(root,file)))])); }
|
|
32
|
+
const cacheRoots = new Set(['node_modules','dist','.vite','.git','coverage',...['app','starter'].flatMap(root=>['node_modules','dist','.vite','.git','coverage'].map(name=>`${root}/${name}`))]);
|
|
33
|
+
// A source/public subdirectory named "dist", or an imported .log file, is still source.
|
|
34
|
+
const excluded = (relative,name) => cacheRoots.has(relative) || name==='.DS_Store' || ['app/tsconfig.tsbuildinfo','starter/tsconfig.tsbuildinfo'].includes(relative);
|
|
35
|
+
module.exports={fs,path,crypto,assert,hash,json,id,inside,walk,write,entries,excluded};
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const F=require('./native-files.cjs');
|
|
3
|
+
const {fs,path,crypto,assert,hash,json,id,inside,walk,write,entries,excluded}=F;
|
|
4
|
+
const skillRoot=path.resolve(__dirname,'..');
|
|
5
|
+
const frameworkRoot=path.join(skillRoot,'assets/frameworks/vue-antdv-next');
|
|
6
|
+
const templates=()=>require('./template-contract.cjs');
|
|
7
|
+
function metadata(directory) { const data=json(inside(directory,'prototype.json')); assert.equal(data.schemaVersion,2,'Not a native v2 prototype'); assert.equal(data.runtime.kind,'vue-antdv-next'); return data; }
|
|
8
|
+
function effectiveReview(data,current) {
|
|
9
|
+
const review=data.review;
|
|
10
|
+
if(review.status!=='approved'||(review.revision===current.revision&&review.digest===current.digest))return review;
|
|
11
|
+
return {status:'pending-review',previousApproval:review,reason:'The stored approval applies to a different revision or candidate digest'};
|
|
12
|
+
}
|
|
13
|
+
function analyze(options={}) {
|
|
14
|
+
if(options.execution!==undefined)assert.ok(['inline','delegated'].includes(options.execution),'Invalid execution declaration: use inline or delegated');
|
|
15
|
+
const project=path.resolve(options.project||'.');
|
|
16
|
+
return {operation:'analyze',readOnly:true,project,projectExists:fs.existsSync(project),supportedOperations:['analyze','create','revise','inspect','confirm-baseline'],execution:options.execution??null,executionEvidence:options.execution===undefined?'unverified':'caller-declared',sideEffects:[]};
|
|
17
|
+
}
|
|
18
|
+
function prepareTemplate(options) { return templates().prepareTemplate(options); }
|
|
19
|
+
function manifest(directory) {
|
|
20
|
+
const data=metadata(directory);
|
|
21
|
+
const inputFiles=walk(directory,'inputs');
|
|
22
|
+
const inputs=entries(directory,inputFiles), inputDigest=hash(JSON.stringify(inputs));
|
|
23
|
+
const files=entries(directory,[...inputFiles,'requirements-map.json',...['app','mocks','design-system'].flatMap(folder=>walk(directory,folder,excluded))]);
|
|
24
|
+
const identity={...(data.runtime.contentReference?{business:data.business}:{}),schemaVersion:2,id:data.id,revision:data.revision,artifactKind:data.artifactKind,runtime:data.runtime,template:data.template,framework:data.framework,expertMode:data.expertMode};
|
|
25
|
+
return {schemaVersion:2,revision:data.revision,identity,inputDigest,files,digest:hash(JSON.stringify({identity,files}))};
|
|
26
|
+
}
|
|
27
|
+
function defaultScope() {
|
|
28
|
+
return {schemaVersion:1,title:'设备清单首期演示需求',requirements:[
|
|
29
|
+
{id:'PILOT-01',text:'设备列表支持查询、当前页多选、分页和批量变更状态',source:{type:'prd',reference:'pilot-fixture/PILOT-01',statement:'21条记录,每页10条;换页或筛选清空选择;批量操作仅影响选中记录'}},
|
|
30
|
+
{id:'PILOT-02',text:'编辑抽屉使用真实表单校验,失败保留输入,成功更新对应记录',source:{type:'prd',reference:'pilot-fixture/PILOT-02',statement:'设备名称必填;可模拟提交失败并重试'}},
|
|
31
|
+
{id:'PILOT-03',text:'加载、空、失败和恢复可触发;抽屉开合、遮罩模糊及焦点可验证',source:{type:'prd',reference:'pilot-fixture/PILOT-03',statement:'首期演示规则;不是任何生产项目的已确认权限或流程'}}
|
|
32
|
+
],exemptions:[],openQuestions:[]};
|
|
33
|
+
}
|
|
34
|
+
function createPrototype(options) {
|
|
35
|
+
assert.ok(options.framework===undefined||options.framework==='vue-antdv-next','Unsupported framework / 不支持的框架');
|
|
36
|
+
assert.ok(options.title===undefined||(typeof options.title==='string'&&options.title.trim()),'Title must be a nonempty string');
|
|
37
|
+
const project=path.resolve(options.project||'.'); assert.ok(fs.statSync(project).isDirectory(),'Project must exist'); id(options.id);
|
|
38
|
+
const mode=options.expertMode||'professional'; assert.ok(['professional','template'].includes(mode),'Invalid expert mode');
|
|
39
|
+
let provider=null;
|
|
40
|
+
if(mode==='professional') {
|
|
41
|
+
provider=require('./prototype-lib.cjs').checkDesignProvider({...options,project});
|
|
42
|
+
if(provider.availability!=='available') return {status:'needs-design-provider',provider,sideEffects:[],choices:['locate-or-repair','explicit-template-mode','analyze']};
|
|
43
|
+
}
|
|
44
|
+
const template=options.templateRoot?templates().loadTemplate(path.resolve(options.templateRoot)):templates().loadBundledTemplate();
|
|
45
|
+
const reference=template.kind==='content-reference';
|
|
46
|
+
assert.ok(!reference||options.scope,'A sourced scope file is required for business generation; no default device demo');
|
|
47
|
+
const scope=options.scope?json(path.resolve(options.scope)):defaultScope();
|
|
48
|
+
const selection=reference?require('./content-template.cjs').scopeInputs(template,scope,templates()):null;
|
|
49
|
+
const content=reference?{pageTitle:scope.title||'业务原型'}:templates().projectAdminListContent(template);
|
|
50
|
+
if(options.title!==undefined)content.pageTitle=options.title;
|
|
51
|
+
const profile=json(path.join(frameworkRoot,'profile.json'));
|
|
52
|
+
const target=inside(project,`prototypes/${options.id}`); assert.ok(!fs.existsSync(target),'Prototype exists; revise without reinitializing');
|
|
53
|
+
const lockFile=inside(project,'.i3-ui-prototypes.lock'); const lock=fs.openSync(lockFile,'wx');
|
|
54
|
+
let stage=null,published=false;
|
|
55
|
+
try {
|
|
56
|
+
fs.mkdirSync(path.dirname(target),{recursive:true}); stage=fs.mkdtempSync(path.join(path.dirname(target),'.native-'));
|
|
57
|
+
const registryPath=inside(project,'.i3-ui-prototypes.json'); const registry=fs.existsSync(registryPath)?json(registryPath):{schemaVersion:1,prototypes:[]};
|
|
58
|
+
assert.equal(registry.schemaVersion,1); assert.ok(Array.isArray(registry.prototypes)); assert.ok(!registry.prototypes.some(p=>p.id===options.id),'Duplicate prototype ID');
|
|
59
|
+
const frameworkFiles=entries(frameworkRoot,walk(frameworkRoot,'',excluded));
|
|
60
|
+
const instanceId=crypto.randomUUID();
|
|
61
|
+
const data={schemaVersion:2,id:options.id,title:options.title||content.pageTitle||'设备清单原型',revision:'r001',artifactKind:'framework-prototype',runtime:{kind:'vue-antdv-next',version:'1',...(reference?{contentReference:true}:{})},...(reference?{business:{status:'needs-adaptation',source:'inputs/scope.json'}}:{}),template:{id:template.id,version:template.version,digest:template.digest},framework:{id:'vue-antdv-next',version:profile.version||'1',digest:hash(JSON.stringify(frameworkFiles))},expertMode:mode,expert:{status:provider?'pending':'skipped',provider},verification:{status:'not-run'},review:{status:'pending-review'},instanceId};
|
|
62
|
+
write(stage,'prototype.json',data);
|
|
63
|
+
for(const file of template.files) {
|
|
64
|
+
assert.equal(file.encoding,'base64');
|
|
65
|
+
const destination=inside(stage,`inputs/template/${file.path}`); fs.mkdirSync(path.dirname(destination),{recursive:true}); fs.writeFileSync(destination,Buffer.from(file.content,'base64'));
|
|
66
|
+
}
|
|
67
|
+
write(stage,'inputs/template-snapshot.json',template);
|
|
68
|
+
write(stage,'inputs/scope.json',scope);
|
|
69
|
+
write(stage,'inputs/capabilities.json',selection?selection.fixed:{schemaVersion:1,definition:template.definition,bindings:template.index.bindings,regions:template.index.regions,decisions:[]});
|
|
70
|
+
write(stage,'inputs/framework.json',{profile,componentMap:json(path.join(frameworkRoot,'component-map.json')),acceptance:json(path.join(frameworkRoot,'acceptance.json')),files:frameworkFiles});
|
|
71
|
+
const checked=templates().checkInstanceCapabilities(path.join(stage,'inputs')); assert.equal(checked.status,'passed',checked.errors?.join('; '));
|
|
72
|
+
const requiredScenarios=checked.requiredScenarios.map(s=>typeof s==='string'?s:s.id);
|
|
73
|
+
write(stage,'inputs/acceptance.json',{schemaVersion:1,requiredScenarios,scenarioDetails:checked.requiredScenarios,frameworkScenarios:reference?['framework-responsive','framework-instance-isolation','template-content-projection',...(selection.fixed.regions.some(region=>region.mode==='edit-form')?['framework-drawer-motion','framework-mask-blur','framework-focus-scroll']:[])]:['framework-drawer-motion','framework-mask-blur','framework-focus-scroll','framework-states','framework-responsive','framework-instance-isolation','template-content-projection'],browserReview:json(path.join(frameworkRoot,'acceptance.json')).browserReview});
|
|
74
|
+
const requirements=json(path.join(stage,'inputs/scope.json')).requirements;
|
|
75
|
+
write(stage,'requirements-map.json',{schemaVersion:2,requirements:requirements.map(r=>({...r,pages:reference?r.pages:template.index.pages.map(page=>page.id),scenarios:reference?checked.requiredScenarios.filter(s=>s.bindingId.startsWith('app-shell/')||r.pages.some(page=>s.bindingId.startsWith(page+'/'))).map(s=>s.id):requiredScenarios})),bindings:selection?selection.fixed.bindings:template.index.bindings});
|
|
76
|
+
const starter=path.join(frameworkRoot,reference?profile.starter:(profile.legacyStarter||profile.starter));
|
|
77
|
+
fs.cpSync(starter,path.join(stage,'app'),{recursive:true,filter:file=>!excluded(`starter/${path.relative(starter,file).split(path.sep).join('/')}`,path.basename(file))});
|
|
78
|
+
const themeBinding=(selection?selection.fixed.bindings:template.index.bindings).find(binding=>binding.capabilityId==='theme');
|
|
79
|
+
write(stage,'app/public/prototype-config.json',{instanceId,storageNamespace:`i3-native-${options.id}-${instanceId}`,mockVersion:reference?'business-v1':'admin-list-v1',title:data.title,...(reference?{pages:scope.pages.map(page=>({id:page.id,title:page.title}))}:{content}),...(themeBinding?{theme:templates().resolveThemeParameters(themeBinding.parameters)}:{})});
|
|
80
|
+
write(stage,'mocks/contract.json',reference?{schemaVersion:1,instanceId,status:'needs-business-data',requirements:scope.requirements.map(r=>r.id)}:{schemaVersion:1,seed:'admin-list-v1',count:21,pageSize:10,selection:'current-page-clear-on-page-or-filter',instanceId});
|
|
81
|
+
write(stage,'design-system/MASTER.md',`# ${data.title}\n\n使用 antDv-next 原生组件与主题。管理列表采用紧凑信息层级,正文与操作可读;错误在字段附近呈现。抽屉保留进入与退出过程,显式开启遮罩模糊。\n\n本地模拟,不代表生产权限。\n`);
|
|
82
|
+
write(stage,'design-system/decisions.json',{schemaVersion:1,records:[],reviewStatus:mode==='template'?'skipped':'pending'});
|
|
83
|
+
write(stage,'qa/design-advice.json',{schemaVersion:2,provider,queries:[],records:[],status:mode==='template'?'skipped':'pending'});
|
|
84
|
+
write(stage,'HANDOFF.md',`# ${data.title}\n\n本次为 ${mode} 模式;来源 ${template.id} ${template.version}。运行形态为真实 Vue+antDv-next。读取 inputs 中固定合同和实际源码,勿从安装目录更新旧合同。\n\n使用 native-pilot.cjs inspect/build/preview。当前未验证,待用户评审;不要把检查通过当用户认可。\n`);
|
|
85
|
+
const candidate=manifest(stage); data.inputs={digest:candidate.inputDigest}; write(stage,'prototype.json',data);
|
|
86
|
+
fs.renameSync(stage,target); published=true;
|
|
87
|
+
registry.prototypes.push({id:options.id,directory:`prototypes/${options.id}`,appliesTo:[],schemaVersion:2}); write(project,'.i3-ui-prototypes.json',registry);
|
|
88
|
+
return {status:'created',directory:target,revision:'r001',digest:manifest(target).digest};
|
|
89
|
+
} catch(error) { if(published) fs.rmSync(target,{recursive:true,force:true}); throw error; }
|
|
90
|
+
finally { if(stage&&fs.existsSync(stage)) fs.rmSync(stage,{recursive:true,force:true}); fs.closeSync(lock); fs.unlinkSync(lockFile); }
|
|
91
|
+
}
|
|
92
|
+
function inspectDesignReview(directory,current=manifest(directory)) {
|
|
93
|
+
const data=metadata(directory);
|
|
94
|
+
if(data.expertMode!=='professional') return {status:'skipped',queries:0,records:0,adoptedAdvice:[]};
|
|
95
|
+
const advice=json(inside(directory,'qa/design-advice.json')); assert.equal(advice.provider?.availability,'available','Professional provider not available');
|
|
96
|
+
assert.ok(advice.provider.digest,'Professional provider identity required');
|
|
97
|
+
assert.ok(advice.queries?.length && advice.queries.every(q=>q.query && q.source && q.resultDigest),'Real design query record required');
|
|
98
|
+
assert.ok(['reviewed','no-match'].includes(advice.status),'Design advice review pending');
|
|
99
|
+
assert.ok(advice.records?.length && advice.records.every(r=>['adopted','adjusted','rejected','no-match'].includes(r.decision) && r.query && advice.queries.some(q=>q.query===r.query) && r.resultIdentity && r.rationale && r.verification),'Advice decisions need query, identity, reasons and verification');
|
|
100
|
+
const decisions=json(inside(directory,'design-system/decisions.json'));
|
|
101
|
+
assert.ok(['reviewed','no-match'].includes(decisions.reviewStatus),'Design contract review pending');
|
|
102
|
+
const stable=r=>({query:r.query,resultIdentity:r.resultIdentity,decision:r.decision,rationale:r.rationale,implementation:r.implementation||null,verificationScenario:r.verificationScenario||null});
|
|
103
|
+
assert.deepEqual(decisions.records.map(stable),advice.records.map(stable),'Design contract differs from advice review');
|
|
104
|
+
const adoptedAdvice=advice.records.filter(r=>['adopted','adjusted'].includes(r.decision));
|
|
105
|
+
for(const record of adoptedAdvice)assert.ok(Object.hasOwn(current.files,record.implementation) && record.verificationScenario,'Adopted advice needs implementation and verified scenario');
|
|
106
|
+
|
|
107
|
+
return {status:advice.status,queries:advice.queries.length,records:advice.records.length,adoptedAdvice};
|
|
108
|
+
}
|
|
109
|
+
function inspectWithInputs(directory,options={},acceptedInputDigest) {
|
|
110
|
+
try {
|
|
111
|
+
const data=metadata(directory); const current=manifest(directory);
|
|
112
|
+
assert.equal(acceptedInputDigest??data.inputs.digest,current.inputDigest,'Input contract changed; explicitly revise fixed inputs');
|
|
113
|
+
const capability=templates().checkInstanceCapabilities(path.join(directory,'inputs')); assert.equal(capability.status,'passed',capability.errors?.join('; '));
|
|
114
|
+
const acceptance=json(inside(directory,'inputs/acceptance.json'));
|
|
115
|
+
require('./native-devtools.cjs').readPolicy(directory);
|
|
116
|
+
for(const scenario of capability.requiredScenarios.map(s=>typeof s==='string'?s:s.id)) assert.ok(acceptance.requiredScenarios.includes(scenario),`Required scenario omitted: ${scenario}`);
|
|
117
|
+
const fixedFramework=json(inside(directory,'inputs/framework.json'));
|
|
118
|
+
if(fixedFramework.acceptance?.scenarios?.some(scenario=>scenario.id==='template-content-projection'&&scenario.required))assert.ok(acceptance.frameworkScenarios.includes('template-content-projection'),'Required template-content-projection scenario omitted from fixed framework acceptance');
|
|
119
|
+
const template=json(inside(directory,'inputs/template-snapshot.json')),mapping=json(inside(directory,'requirements-map.json')),scope=json(inside(directory,'inputs/scope.json'));
|
|
120
|
+
const pageIds=new Set(data.runtime.contentReference?['app-shell',...scope.pages.map(page=>page.id)]:template.index.pages.map(page=>page.id)),bindingIds=new Set(json(inside(directory,'inputs/capabilities.json')).bindings.map(binding=>binding.id));
|
|
121
|
+
for(const requirement of scope.requirements)assert.ok(mapping.requirements.some(item=>item.id===requirement.id),`Requirement omitted from mapping: ${requirement.id}`);
|
|
122
|
+
for(const requirement of mapping.requirements) {
|
|
123
|
+
assert.ok(scope.requirements.some(item=>item.id===requirement.id),`Unknown mapped requirement: ${requirement.id}`);
|
|
124
|
+
if(data.runtime.contentReference)assert.deepEqual(requirement.pages,scope.requirements.find(item=>item.id===requirement.id).pages,`Business requirement page mapping changed: ${requirement.id}`);
|
|
125
|
+
assert.ok(requirement.pages?.length&&requirement.pages.every(page=>pageIds.has(page)),`Unknown page identity in requirement: ${requirement.id}`);
|
|
126
|
+
assert.ok(requirement.scenarios?.length&&requirement.scenarios.every(scenario=>acceptance.requiredScenarios.includes(scenario)||acceptance.frameworkScenarios.includes(scenario)),`Unknown scenario in requirement: ${requirement.id}`);
|
|
127
|
+
}
|
|
128
|
+
for(const binding of mapping.bindings||[])assert.ok(bindingIds.has(binding.id),`Unknown binding in mapping: ${binding.id}`);
|
|
129
|
+
for(const file of ['app/package.json','app/package-lock.json','app/src/App.vue','app/public/prototype-config.json','design-system/MASTER.md']) assert.ok(fs.existsSync(inside(directory,file)),`Missing ${file}`);
|
|
130
|
+
const config=json(inside(directory,'app/public/prototype-config.json')); assert.equal(config.instanceId,data.instanceId); assert.ok(config.storageNamespace.includes(data.instanceId),'Instance storage not isolated');
|
|
131
|
+
if(data.runtime.contentReference) {
|
|
132
|
+
assert.deepEqual(config.pages,scope.pages.map(page=>({id:page.id,title:page.title})),'Business configuration differs from fixed page scope');
|
|
133
|
+
assert.ok(['needs-adaptation','adapted'].includes(data.business?.status),'Unknown business implementation status');
|
|
134
|
+
const fixedBindings=json(inside(directory,'inputs/capabilities.json')).bindings;
|
|
135
|
+
assert.deepEqual((mapping.bindings||[]).map(binding=>binding.id).sort(),fixedBindings.map(binding=>binding.id).sort(),'Business mapping omits fixed capabilities');
|
|
136
|
+
}
|
|
137
|
+
const themeBinding=json(inside(directory,'inputs/capabilities.json')).bindings.find(binding=>data.runtime.contentReference?binding.capabilityId==='theme':binding.id==='devices/device-list/theme');
|
|
138
|
+
if(themeBinding)assert.deepEqual(config.theme,templates().resolveThemeParameters(themeBinding.parameters),'Theme configuration differs from fixed input binding');
|
|
139
|
+
else assert.equal(config.theme,undefined,'Theme requires an explicit optional capability binding');
|
|
140
|
+
const deps=json(inside(directory,'app/package.json')).dependencies; assert.ok(deps['antdv-next'] && deps.vue,'Native framework dependencies missing');
|
|
141
|
+
if(options.acceptance) {
|
|
142
|
+
if(data.runtime.contentReference)assert.equal(data.business?.status,'adapted','Business implementation pending; a scaffold cannot pass acceptance');
|
|
143
|
+
const adoptedAdvice=inspectDesignReview(directory,current).adoptedAdvice;
|
|
144
|
+
const report=options.report||json(inside(directory,json(inside(directory,'qa/latest.json')).report));
|
|
145
|
+
assert.equal(report.status,'passed'); assert.equal(report.candidateDigest,current.digest,'Browser evidence is stale'); assert.equal(report.revision,current.revision);
|
|
146
|
+
const build=verifyBuild(directory,report.buildId); assert.equal(report.buildDigest,build.buildDigest);
|
|
147
|
+
assert.ok(report.executor && report.tool && report.startedAt && report.completedAt && report.source,'Browser run provenance required');
|
|
148
|
+
require('./native-build.cjs').verifyBrowserReport(directory,report,build);
|
|
149
|
+
assert.deepEqual(report.consoleErrors,[],'Browser errors remain');
|
|
150
|
+
const required=[...acceptance.requiredScenarios,...acceptance.frameworkScenarios];
|
|
151
|
+
for(const scenario of required) assert.ok(report.journeys.some(j=>j.id===scenario && j.status==='passed'),`Missing browser scenario: ${scenario}`);
|
|
152
|
+
for(const record of adoptedAdvice)assert.ok(report.journeys.some(j=>j.id===record.verificationScenario && j.status==='passed'),`Advice verification missing: ${record.resultIdentity}`);
|
|
153
|
+
}
|
|
154
|
+
return {status:'passed',level:options.acceptance?'acceptance':'structure',revision:current.revision,digest:current.digest,verification:data.verification.status,review:effectiveReview(data,current).status};
|
|
155
|
+
} catch(error) { return {status:'failed',errors:[error.message]}; }
|
|
156
|
+
}
|
|
157
|
+
function inspect(directory,options={}) { return inspectWithInputs(directory,options); }
|
|
158
|
+
function expected(directory,options) {
|
|
159
|
+
const current=manifest(directory);
|
|
160
|
+
assert.equal(options.expectedRevision,current.revision,'Stale revision / 版本冲突'); assert.equal(options.expectedDigest,current.digest,'Candidate changed / 内容变化'); return current;
|
|
161
|
+
}
|
|
162
|
+
function claim(directory,options) {
|
|
163
|
+
id(options.owner); expected(directory,options);
|
|
164
|
+
const lock=inside(directory,'.prototype-write.lock'); const fd=fs.openSync(lock,'wx');
|
|
165
|
+
try { fs.writeFileSync(fd,JSON.stringify({owner:options.owner,revision:options.expectedRevision,digest:options.expectedDigest})); }
|
|
166
|
+
finally { fs.closeSync(fd); }
|
|
167
|
+
write(directory,'execution.json',{owner:options.owner,status:'editing',revision:options.expectedRevision,digest:options.expectedDigest}); return {owner:options.owner};
|
|
168
|
+
}
|
|
169
|
+
function release(directory,owner) {
|
|
170
|
+
const lock=inside(directory,'.prototype-write.lock'); assert.equal(json(lock).owner,owner,'Different writing owner');
|
|
171
|
+
fs.unlinkSync(lock); write(directory,'execution.json',{owner,status:'released'}); return {status:'released'};
|
|
172
|
+
}
|
|
173
|
+
function mutate(directory,options,fn) {
|
|
174
|
+
const lock=inside(directory,'.prototype-write.lock'); const temporary=!fs.existsSync(lock); const owner=options.owner||`run-${crypto.randomUUID()}`;
|
|
175
|
+
if(temporary) claim(directory,{...options,owner}); else { assert.equal(json(lock).owner,owner,'Candidate has another writing owner'); expected(directory,options); }
|
|
176
|
+
try { return fn(); } finally { if(temporary) release(directory,owner); }
|
|
177
|
+
}
|
|
178
|
+
function revise(directory,options) {
|
|
179
|
+
id(options.revision);
|
|
180
|
+
assert.ok(options.acceptInputs===undefined||typeof options.acceptInputs==='boolean','--accept-inputs must be a boolean flag');
|
|
181
|
+
return mutate(directory,options,()=>{
|
|
182
|
+
const current=manifest(directory);
|
|
183
|
+
const data=metadata(directory); assert.notEqual(data.revision,options.revision); assert.ok(!fs.existsSync(inside(directory,`baselines/${options.revision}`)),'Revision already frozen');
|
|
184
|
+
if(options.acceptInputs) {
|
|
185
|
+
const checked=inspectWithInputs(directory,{},current.inputDigest);
|
|
186
|
+
assert.equal(checked.status,'passed',checked.errors?.join('; '));
|
|
187
|
+
} else assert.equal(data.inputs.digest,current.inputDigest,'Input contract changed; revise --accept-inputs must explicitly validate the new contract');
|
|
188
|
+
assert.equal(manifest(directory).digest,current.digest,'Candidate changed while validating fixed inputs');
|
|
189
|
+
if(options.acceptInputs)data.inputs={digest:current.inputDigest};
|
|
190
|
+
data.revision=options.revision; data.verification={status:'not-run'}; data.review={status:'pending-review'};
|
|
191
|
+
write(directory,'prototype.json',data); return manifest(directory);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
function build(directory,options={}) { return require('./native-build.cjs').build(directory,options,{manifest,inspect}); }
|
|
195
|
+
function verifyBuild(directory,buildId) { return require('./native-build.cjs').verifyBuild(directory,buildId,{manifest}); }
|
|
196
|
+
function preview(directory,options={}) { return require('./native-build.cjs').preview(directory,options,{manifest}); }
|
|
197
|
+
function recordRun(directory,report) {
|
|
198
|
+
report=JSON.parse(JSON.stringify(report));
|
|
199
|
+
id(report.id); const current=manifest(directory); assert.equal(report.candidateDigest,current.digest,'Candidate changed during browser run'); assert.equal(report.revision,current.revision);
|
|
200
|
+
const result=inspect(directory,{acceptance:true,report}); assert.equal(result.status,'passed',result.errors?.join('; '));
|
|
201
|
+
assert.equal(manifest(directory).digest,current.digest,'Candidate changed while checking browser report');
|
|
202
|
+
const relative=`qa/runs/${report.id}/report.json`,existing=inside(directory,relative);
|
|
203
|
+
if(fs.existsSync(existing))assert.deepEqual(json(existing),report,'Published browser run is immutable; use a new run ID');
|
|
204
|
+
else write(directory,relative,report);
|
|
205
|
+
write(directory,'qa/latest.json',{report:relative});
|
|
206
|
+
const data=metadata(directory); data.verification={status:'passed',digest:current.digest,run:report.id};
|
|
207
|
+
data.review=effectiveReview(data,current);
|
|
208
|
+
if(data.expertMode==='professional') {
|
|
209
|
+
const advice=json(inside(directory,'qa/design-advice.json'));
|
|
210
|
+
data.expert.status=advice.records.some(record=>['adopted','adjusted'].includes(record.decision))?'used':advice.records.every(record=>record.decision==='no-match')?'no-match':'reviewed';
|
|
211
|
+
}
|
|
212
|
+
write(directory,'prototype.json',data); return {...result,verification:'passed'};
|
|
213
|
+
}
|
|
214
|
+
function confirmBaseline(directory,confirmation) {
|
|
215
|
+
assert.ok(confirmation.reviewer && confirmation.statement && confirmation.source,'Explicit human confirmation required / 人类确认');
|
|
216
|
+
const current=manifest(directory); assert.equal(confirmation.revision,current.revision); assert.equal(confirmation.digest,current.digest);
|
|
217
|
+
return mutate(directory,{expectedRevision:current.revision,expectedDigest:current.digest},()=>{
|
|
218
|
+
const checked=inspect(directory,{acceptance:true}); assert.equal(checked.status,'passed',checked.errors?.join('; '));
|
|
219
|
+
const devtoolsReview=require('./native-devtools.cjs').check(directory,module.exports);assert.ok(!devtoolsReview.required||devtoolsReview.status==='current',`Current DevTools review required: ${devtoolsReview.errors?.join('; ')||devtoolsReview.status}`);
|
|
220
|
+
const target=inside(directory,`baselines/${id(current.revision)}`); assert.ok(!fs.existsSync(target),'Baseline exists');
|
|
221
|
+
fs.mkdirSync(path.dirname(target),{recursive:true});const stage=fs.mkdtempSync(path.join(path.dirname(target),'.freeze-'));
|
|
222
|
+
try {
|
|
223
|
+
for(const [file,digest]of Object.entries(current.files)) {
|
|
224
|
+
const destination=inside(stage,file);fs.mkdirSync(path.dirname(destination),{recursive:true});fs.copyFileSync(inside(directory,file),destination);
|
|
225
|
+
assert.equal(hash(fs.readFileSync(destination)),digest,`Snapshot copy differs: ${file}`);
|
|
226
|
+
}
|
|
227
|
+
write(stage,'manifest.json',current);write(stage,'review.json',confirmation);const reviewDigest=hash(fs.readFileSync(path.join(stage,'review.json')));
|
|
228
|
+
assert.equal(manifest(directory).digest,current.digest,'Candidate changed during freezing');
|
|
229
|
+
fs.renameSync(stage,target);
|
|
230
|
+
write(directory,'baseline.json',{schemaVersion:2,revision:current.revision,digest:current.digest,reviewDigest,directory:`baselines/${current.revision}`});
|
|
231
|
+
const data=metadata(directory);data.review={status:'approved',revision:current.revision,digest:current.digest};write(directory,'prototype.json',data);return current;
|
|
232
|
+
} finally {if(fs.existsSync(stage))fs.rmSync(stage,{recursive:true,force:true});}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
function compareBaseline(directory) {
|
|
236
|
+
if(!fs.existsSync(inside(directory,'baseline.json'))) return {status:'no-baseline',changes:[],protectedChanges:[]};
|
|
237
|
+
const baseline=json(inside(directory,'baseline.json')); const folder=inside(directory,baseline.directory); const saved=json(path.join(folder,'manifest.json'));
|
|
238
|
+
if(hash(fs.readFileSync(path.join(folder,'review.json')))!==baseline.reviewDigest || saved.digest!==baseline.digest || hash(JSON.stringify({identity:saved.identity,files:saved.files}))!==saved.digest || Object.entries(saved.files).some(([f,d])=>!fs.existsSync(inside(folder,f))||hash(fs.readFileSync(inside(folder,f)))!==d)) return {status:'corrupt-baseline'};
|
|
239
|
+
const current=manifest(directory); const changes=[...new Set([...Object.keys(saved.files),...Object.keys(current.files)])].filter(f=>saved.files[f]!==current.files[f]);
|
|
240
|
+
return {status:current.digest===saved.digest?'unchanged':'changed',changes,protectedChanges:changes,revision:current.revision,baselineRevision:saved.revision};
|
|
241
|
+
}
|
|
242
|
+
function state(directory,options={}) { return require('./native-delivery.cjs').state(directory,options,module.exports); }
|
|
243
|
+
function finalize(directory,options={}) { return require('./native-delivery.cjs').finalize(directory,options,module.exports); }
|
|
244
|
+
module.exports={analyze,prepareTemplate,createPrototype,manifest,inspect,inspectDesignReview,effectiveReview,claim,release,revise,build,verifyBuild,preview,recordRun,confirmBaseline,compareBaseline,state,finalize};
|