@antprofuse/saddle-islands-tools 0.1.4 → 0.1.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antprofuse/saddle-islands-tools",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Independent Saddle–Islands engineering tools",
5
5
  "files": [
6
6
  "tools"
@@ -1,34 +1,63 @@
1
- # 迭代节点渲染
1
+ # 迭代管理与阶段交付
2
2
 
3
- 通过独立工具集消费冻结的workflow Skill参数schema和阶段元数据,不直接调用CoTask,也不启动任务。
3
+ 统一公开入口:`python3 tools/cli/main.py iteration <action>`。所有能力只管理文件、校验与渲染,不调用CoTask、不授予启动许可。以下绝对路径为调用方提供的实际位置。
4
+
5
+ ## 持久元数据
6
+
7
+ 发起者填写workflow包 `resources/iteration.schema.json` 定义的v2实例。`persistentRoot` 必须等于 `--registry/<iteration.id>`,不得位于临时共享根内。Skill及工具定位、来源版本等元数据不全量注入执行者。
8
+
9
+ ```bash
10
+ python3 tools/cli/main.py iteration init --registry /workspace/iterations \
11
+ --input /workspace/prepared-meta.json --workflow /packages/workflow
12
+ python3 tools/cli/main.py iteration list --registry /workspace/iterations
13
+ python3 tools/cli/main.py iteration update --registry /workspace/iterations \
14
+ --input /workspace/updated-meta.json --workflow /packages/workflow \
15
+ --expected-sha256 <current-metadata-sha256>
16
+ ```
17
+
18
+ init拒绝覆盖。update校验当前摘要、保旧元数据历史,再原子替换元数据和刷新索引。操作以目录锁串行;单文件写入原子,多文件不构成数据库事务,索引可由list扫描真实元数据恢复。list只读现有台账,不要求已结束任务的临时共享区仍存在。更新完整元数据须通过schema与结构校验,但不要求已回收的共享区仍存在;不能靠改变状态自动放行任务。
19
+
20
+ 工具支持随包schema使用的受限JSON Schema词汇;未知字段和未知校验关键词拒绝。还核绝对/相对路径、独立持久位置、写入范围、记录输入禁止、分支名、阶段ID与依赖环。渲染成功不证明分支已推送、全部输入实际可读或业务已通过;Owner须在放行前确认。
21
+
22
+ ## 任务渲染
4
23
 
5
24
  ```bash
6
- python3 tools/cli/main.py iteration render \
7
- --workflow /absolute/path/to/workflow-skill \
8
- --input /absolute/path/to/iteration.json \
9
- --output /absolute/path/to/new-review.json
25
+ python3 tools/cli/main.py iteration render --workflow /packages/workflow \
26
+ --input /workspace/iterations/example/iteration.meta.json --output /workspace/rendered.json
10
27
  ```
11
28
 
12
- 输入结构见workflow包的 `resources/iteration.schema.json`,固定阶段规则见 `resources/stage-meta.json`。发起者冻结Skill版本后填写实例;阶段实例不允许覆盖全局git或bizdir。`sharedOutput` 相对全局共享根,正式输出目录与单文件权限由阶段元数据派生,非用户手填。
29
+ 生成公共及执行/检查点Markdown,还有结构化Git权限与依赖。使用context原文,不把JSON塞进任务正文。不覆盖已有输出。localInputs/localOutputs按内容分配,可多个;recordDirectory只写执行记录,不能被其他阶段列为输入。inputCommit允许草稿阶段暂缺,交接后须绑定精确提交。
13
30
 
14
- 输出JSON包含公共上下文和各执行节点的Markdown正文,以及供目录门禁消费的结构化路径权限。调用方使用 `context` 原文更新节点,不能把整个JSON塞进节点说明。不会覆盖已有输出文件。未知参数、缺失必填值、共享根不存在、重复ID/工作分支、共享路径重叠或逃逸、未解析占位符都以非零退出拒绝。
31
+ ## 阶段门禁
15
32
 
16
- 目前是启动前审阅渲染:`acceptedInput` 可指向等待上游检查点生成的登记文件;`inputCommit` 在检查点接受成果后绑定。渲染成功不表示后续分支、输入文件或运行环境已准备完毕,也不授予执行许可。输出还包含阶段检查点及最终检查点的Markdown;工具不自动构建依赖图或调用平台。
33
+ Owner生成阶段最小配置并通过执行环境索引提供:
34
+
35
+ ```bash
36
+ python3 tools/cli/main.py iteration scope --workflow /packages/workflow \
37
+ --input /workspace/iterations/example/iteration.meta.json --stage-id D \
38
+ --output /shared/environment/scopes/design.json
39
+ ```
40
+
41
+ 执行者无需访问Owner元数据,只读取该冻结范围文件:
42
+
43
+ ```bash
44
+ python3 tools/cli/main.py iteration stage-gate --workflow /packages/workflow \
45
+ --input /shared/environment/scopes/design.json --repository /work/business \
46
+ --head <full-candidate-commit> --output /shared/execution-records/design/gate.md
47
+ ```
17
48
 
18
- 本工具对随Skill schema使用的受限JSON Schema关键字执行校验,不冒充通用JSON Schema实现;未知关键字拒绝,不静默忽略。Git差异目录门禁见下。
49
+ 范围来自stage类型及冻结stage-meta,基线使用scope.inputCommit;scope不接受任意路径白名单。scope必须经Owner准备并冻结,门禁本身不验证分发者身份。公开工具只读Git,比较基线至候选;不扫描未提交内容、不自动推送。完整提交、祖先关系和路径权限通过返回0,越界返回3并输出差异Markdown,读取或参数错误返回2。重命名检查两端,空diff允许。未提交修改仍须按Skill处理,不能仅有门禁绿就声称完整交付。
19
50
 
51
+ Owner集成审阅仍可用 `iteration diff-review --input <meta> --workflow <package> --repository <repo> --base <sha> --head <sha> --stage-id D [--stage-id MT] --output <new.md>` 检查已接受阶段权限并集。它只检查路径,不扩展成内容或文件类型门禁。
20
52
 
21
- ## Git目录门禁
53
+ ## 报告与记录归档
22
54
 
23
55
  ```bash
24
- python3 tools/cli/main.py iteration diff-review \
25
- --workflow /absolute/path/to/workflow-skill \
26
- --input /absolute/path/to/iteration.json \
27
- --repository /absolute/path/to/business-checkout \
28
- --base <full-base-commit> --head <full-candidate-commit> \
29
- --stage-id D --output /absolute/path/to/new-diff-review.md
56
+ python3 tools/cli/main.py iteration archive \
57
+ --input /workspace/iterations/example/iteration.meta.json \
58
+ --source /shared/report-content --kind reports --name minifish-r1
30
59
  ```
31
60
 
32
- 基线须为候选的祖先,两个端点必须是完整提交ID。范围来自同一实例参数和冻结阶段元数据,不接受手填任意白名单。集成交付可重复传 `--stage-id` 检查已接受阶段范围的并集。退出0表示路径检查通过,3表示有越界路径(仍写出审阅报告),2表示参数或读取错误。只读Git,不暂存、不修改、不提交。
61
+ `kind`为reports或records。目标在持久迭代目录下,以新内容版本命名,不覆盖原件。逐文件摘要校验复制一致,拒绝符号链接,检查静态HTML/CSS引用是否缺失或逃出归档目录;保存归档清单到records/archives。不会生成ZIP,不删除源目录。源需已停止写入;先后摘要不同则拒绝交付。
33
62
 
34
- 报告按目标父目录汇总文件变更类型和增删行,列所有变更文件;重命名同时检查源、目标路径,删除不能逃避检查。本门禁只核Git记录的变更路径,不追踪链接目标或增加文件类型门禁。该门禁不判断业务内容是否正确,不扫描未提交内容,也不代替缓存、依赖、报告等产物类型审查。
63
+ 不执行或解析JavaScript生成的动态URL、不下载外部HTTP资源,也不证明图片能够解码或预览服务可达。最终检查点须实际打开用户预览链接、检查截图;动态资源在浏览器中核验。服务与持久文件分别管理。结束CoTask前归档并核验,不能结束后才尝试复制已回收文件。
@@ -30,3 +30,15 @@ Eight is the public tool default, not a universal hardware optimum. Lower concur
30
30
  Both `batch-input.json` and `batch-summary.json` contain `concurrencyPolicy`: `defaultWorkers`, `requestedWorkers`, `actualConcurrency` (effective simultaneous worker upper bound, not a measured peak), `workersReason`, and truthful `shardSizes` including plugin tails. The summary's `concurrency` uses that effective upper bound; its worker records retain every actual shard and lifecycle receipt.
31
31
 
32
32
  Legacy component-host integrations must supply an explicit `repositoryRoot` to `component_runtime.main(prepare, sourceRoot, projectRoot, repositoryRoot)`. No directory-depth inference is performed. Current case packages use the public `run` or `batch` commands.
33
+
34
+ ### 区域视觉声明
35
+
36
+ `region` 且 `check: "visual"` 的断言由公共编译器及运行器校验。`visual-expectation.mjs` 公开 `validateVisualExpectation` 和纯函数 `assertVisualMetrics`。声明必须含正数 `designWidth` 和至少一项实际期望;空对象、仅设计宽度、未知键、非有限数值被拒绝,合法零值保留。
37
+
38
+ 支持 `text`(fontSize/fontWeight/color/lineHeight)、`lengths`(padding四边、margin四边、borderRadius、gap/rowGap/columnGap)、`dimensions`(width/height)、`backgroundColor`、`align`,以及 `layout`(position/display/flexDirection/alignItems/justifyContent/overflowX/overflowY/boxSizing)。数值长度以声明的设计宽度换算,字体容差0.02px,几何容差0.25px;布局与颜色按浏览器计算值比较。
39
+
40
+ 运行器在声明的语义载体上独立采集并判定;按压状态使用释放前冻结的原生快照。观察模块的自报成功不能替代公共判定。该合同不证明期望覆盖完整,也不为缺少语义身份的内部部件猜测选择器;这类观察能力须先明确,不能按期望尺寸或颜色挑选能通过的元素。其他断言类别不隐式套用本合同。
41
+
42
+ 可选 `carrier` 默认为 `semantic-root`。`source-content-group` 在语义root内对可见文字/图像取最低公共元素祖先;`owned-text-block` 在该组内要求唯一不属于其他语义目标的文字分支。选择与期待样式、class、原型HTML标签无关,候选缺失或歧义明确失败;不适用的拓扑不得借此猜测观察目标。`viewportInsets` 可声明 left/right/top/bottom 到业务frame视口的设计长度,返回采样记录包含原root盒及是否选中root。
43
+
44
+ 按压快照目前只支持 `semantic-root`,其他carrier在按下前拒绝,不能释放后采样冒充按住状态。这是明确能力限制,不能默默降级。内容分组不覆盖背景装饰/无文字无图像部件或Shadow DOM;相应责任另用适用的公开观察合同。
@@ -17,7 +17,7 @@ async function fixture(){
17
17
  await writeFile(path.join(project,'harness/regions/alternate.mjs'),"import {semanticLocator} from '@antprofuse/saddle-islands-tools/tools/modules/minifish/harness/semantic-anchor.mjs';export function locate(root,name){return semanticLocator(root,name)};export function assertTarget(){}");
18
18
  return {root,project,configFile,config,loaded:await loadProject(project,configFile)};
19
19
  }
20
- test('alternate project static closure records tool/adapter separately and copies no unrelated region',async()=>{const f=await fixture();try{const output=path.join(f.root,'artifact'),records=await packageRunner(f.loaded,output,'regions/alternate.mjs');assert(records.some(r=>r.kind==='tool'&&r.path==='harness/semantic-anchor.mjs'));assert(records.some(r=>r.kind==='adapter'&&r.path==='harness/regions/alternate.mjs'));assert(records.every(r=>/^[a-f0-9]{64}$/.test(r.sha256)));assert(!records.some(r=>r.path.includes('T1')));assert((await readFile(path.join(output,'adapters/harness/regions/alternate.mjs'),'utf8')).includes('../../../tools/harness/semantic-anchor.mjs'));}finally{await rm(f.root,{recursive:true,force:true});}});
20
+ test('alternate project static closure records tool/adapter separately and copies no unrelated region',async()=>{const f=await fixture();try{const output=path.join(f.root,'artifact'),records=await packageRunner(f.loaded,output,'regions/alternate.mjs');assert(records.some(r=>r.kind==='tool'&&r.path==='harness/semantic-anchor.mjs'));for(const name of ['visual-expectation.mjs','visual-carrier.mjs']){assert(records.some(r=>r.kind==='tool'&&r.path==='harness/'+name));assert((await readFile(path.join(output,'tools/harness',name),'utf8')).length>0);}assert(records.some(r=>r.kind==='adapter'&&r.path==='harness/regions/alternate.mjs'));assert(records.every(r=>/^[a-f0-9]{64}$/.test(r.sha256)));assert(!records.some(r=>r.path.includes('T1')));assert((await readFile(path.join(output,'adapters/harness/regions/alternate.mjs'),'utf8')).includes('../../../tools/harness/semantic-anchor.mjs'));}finally{await rm(f.root,{recursive:true,force:true});}});
21
21
  for(const [name,code] of [['unknown package',"import 'fs';"],['dependency traversal',"import '../../outside.mjs';"],['direct DOM',"export const read=root=>root.evaluate(()=>1);"],['dynamic import',"export const read=()=>import('./other.mjs');"]])test(name+' is rejected from the closed observation dependency graph',async()=>{const f=await fixture();try{await writeFile(path.join(f.project,'harness/regions/alternate.mjs'),code);await writeFile(path.join(f.project,'outside.mjs'),'export const leaked=true;');await assert.rejects(packageRunner(f.loaded,path.join(f.root,'artifact'),'regions/alternate.mjs'));}finally{await rm(f.root,{recursive:true,force:true});}});
22
22
  test('symlink aliases, undeclared module, missing config, and package output are rejected',async()=>{const f=await fixture();try{const module=path.join(f.project,'harness/regions/alternate.mjs');await rm(module);await writeFile(path.join(f.project,'harness/other.mjs'),'export const safe=true;');await symlink('../other.mjs',module);await assert.rejects(packageRunner(f.loaded,path.join(f.root,'alias'),'regions/alternate.mjs'),/Symlink/);await assert.rejects(packageRunner(f.loaded,path.join(f.root,'unknown'),'regions/unknown.mjs'),/declared module/);await assert.rejects(loadProject(f.project,path.join(f.project,'missing.json')));await assert.rejects(packageRunner(f.loaded,path.join(f.project,'artifact'),'regions/alternate.mjs'),/outside/);}finally{await rm(f.root,{recursive:true,force:true});}});
23
23
  test('generic composition preserves exact preparation and original ID mappings without project names',()=>{const noop=()=>{},policy={validateSemanticTargets:noop,supplementalDefinition:()=>null,officialTitle:()=>false,validatePreparationOverrides:noop,validateFixtureTypes:noop,validateSpecialBoundaries:noop,validateIndependentFixture:noop,validatePacketPlan:noop,validatePluginEvents:noop,validatePluginAssertionIdentity:noop,validateObservationModuleName:noop};const catalog={cases:[{id:'alternate / page / value / content',title:'value'}]},definition={caseId:catalog.cases[0].id,ancestors:['open'],basis:[{path:'alternate/source.yaml'}],preparation:{},assertions:[{id:'value',kind:'text',target:'alternate / page / value',expected:'42',count:1}]},fragment={id:'open',inputs:{query:{key:'42'}},boundaries:[],actions:[{kind:'open-page'}]};const plan=compileCase(catalog,definition,[fragment],policy);assert.deepEqual(plan.inputs,fragment.inputs);assert.deepEqual(plan.assertions,definition.assertions);assert.equal(plan.case.id,definition.caseId);assert.throws(()=>compileCase(catalog,{...definition,ancestors:['unknown']},[fragment],policy),/missing preparation/);});
@@ -1,3 +1,4 @@
1
+ import {validateRegionVisualAssertions} from '../harness/visual-expectation.mjs';
1
2
  import assert from 'node:assert/strict';
2
3
  import {validateTargetActions} from '../harness/target-actions.mjs';
3
4
  function jsonValue(value, path = '$') {
@@ -34,6 +35,7 @@ export function compileCase(catalog, definition, fragments,policy) {
34
35
  assert(policy&&typeof policy.validateSemanticTargets==='function','Explicit project plan policy required');
35
36
  const {validateSemanticTargets,validateIndependentFixture,validatePacketPlan,validatePluginEvents,validatePluginAssertionIdentity,validateObservationModuleName}=policy;
36
37
  jsonValue(definition);
38
+ validateRegionVisualAssertions(definition.assertions);
37
39
  validateSemanticTargets(definition);
38
40
  jsonValue(fragments);
39
41
  for(const fragment of fragments)validateSemanticTargets(fragment);
@@ -170,6 +172,7 @@ export function compileCase(catalog, definition, fragments,policy) {
170
172
  export function renderCaseModule(plan,validateSemanticTargets) {
171
173
  assert.equal(typeof validateSemanticTargets,'function','Explicit project source validator required');
172
174
  jsonValue(plan);
175
+ validateRegionVisualAssertions(plan.assertions);
173
176
  validateSemanticTargets(plan);
174
177
  return `// Generated from miniapp case definitions. Do not edit.\nexport default ${JSON.stringify(plan, null, 2)};\n`;
175
178
  }
@@ -1,3 +1,4 @@
1
+ import {assertVisualMetrics} from './visual-expectation.mjs';
1
2
  import assert from 'node:assert/strict';
2
3
  import {sampleNativeMetrics,sampleNativeTextVisibility,sampleNativeStyle,sampleNativePressed,sampleHeldNativeState} from './native-dom-sampling.mjs';
3
4
  import {nativeMetricsFrameIdentity} from './independent-visual.mjs';
@@ -53,6 +54,7 @@ export function validateFrozenHeldAssertion(snapshot,a,validators){
53
54
  for(const member of group.members){assert(matchesNativeSemanticIdentity(member.identity,a.target,a.instance),'Original semantic identity and optional instance must match');// Preserve measured :active; declared assertions do not require CSS :active.
54
55
 
55
56
  if(a.enabled)assert.equal(member.state.enabled,true,'Original native enabled obligation');validators.validateObservation(member.metrics,a);
57
+ if(a.kind==='region'&&a.check==='visual'){assert(!a.visual.carrier||a.visual.carrier==='semantic-root','Held visual carrier not captured');assertVisualMetrics(member.metrics,a.visual);}
56
58
  if(a.textFits)validateNativeTextVisibility(member.textPaint,{selfOverflow:'contain'});observations.push({...member.metrics,...(a.textFits?{textPaint:member.textPaint}:{})});
57
59
  }
58
60
  return {count:group.count,visible:group.members.map(m=>m.state.visible),observations,snapshotVersion:snapshot.version,snapshotAt:snapshot.snapshotAt};
@@ -1,3 +1,4 @@
1
+ import {validateRegionVisualAssertions,assertVisualMetrics} from './visual-expectation.mjs';
1
2
  import {hostPolicy} from '@minifish/project-host';
2
3
  import {prepareHeldNativeInput,executeHeldSnapshot} from './held-snapshot.mjs';
3
4
  import {prepareHeldSnapshot,independentBoundary,assertNativeState,projectAssert,locateProject,queryBoundaryIds,validateReleasedPaymentRows} from '@minifish/project-runtime';
@@ -26,6 +27,7 @@ export const screenshotPolicyVersion = 1;
26
27
  function target(root, name) { return semanticLocator(root, name); }
27
28
 
28
29
  export async function runMiniappCase({page, plan, testInfo}) {
30
+ validateRegionVisualAssertions(plan.assertions);
29
31
  assert.equal(plan.resources.length, 0, 'resource handling is not implemented in this runner yet');
30
32
  const boundary = plan.boundaries.length ? independentBoundary(plan.boundaries) : zeroBusinessPlan();
31
33
  const images = installRendererImageEvidence(page);
@@ -92,6 +94,7 @@ export async function runMiniappCase({page, plan, testInfo}) {
92
94
  assert(app, 'missing real page opening');
93
95
  await trace?.attach();
94
96
  input=targetActions({page,locate:(name,instance)=>locate(app.root,name,{instance}),synchronize:()=>synchronizeAnchors(app.root),boundary,receipts:result.actions,beforePress:async()=>{if(artifactOnly)capturePreparation=await prepareMiniappCapture(page);},observeInput:async(stage,action)=>trace?.sample(stage,app.root,boundary.evidence(),action)});
97
+ if(heldSpec)for(const a of plan.assertions)if(a.kind==='region'&&a.check==='visual')assert(!a.visual.carrier||a.visual.carrier==='semantic-root','Held visual carrier requires a supported frozen snapshot contract');
95
98
  if(heldSpec)await prepareHeldNativeInput(app.root,heldSpec,locate(app.root,heldSpec.press.target,{instance:heldSpec.press.instance}));
96
99
  for(const action of plan.beforeTarget??[]){
97
100
  if(action.kind==='leave-return'){
@@ -114,7 +117,12 @@ export async function runMiniappCase({page, plan, testInfo}) {
114
117
  if(assertion.kind==='host-native-ui'){result.observations[assertion.id]=await assertHostNativeUi(page,app,assertion);
115
118
  }else if(assertion.kind==='carousel-frame'){result.observations[assertion.id]=await observeCarousel(app.root,assertion.contract,{urls:assertion.images,records:images.records});
116
119
  }else if(assertion.kind==='host-navigation'){result.observations[assertion.id]=await assertHostNavigation(page,app,assertion,plan.hostSystemPlan);
117
- }else if(assertion.kind==='region'){result.observations[assertion.id]=await observation.assertTarget({app,page,assertion,images});
120
+ }else if(assertion.kind==='region'){
121
+ result.observations[assertion.id]=await observation.assertTarget({app,page,assertion,images});
122
+ if(assertion.check==='visual') {
123
+ const metrics=await nativeMetrics(locator,{carrier:assertion.visual.carrier});
124
+ result.observations[assertion.id]={...result.observations[assertion.id],publicVisual:assertVisualMetrics(metrics,assertion.visual)};
125
+ }
118
126
  }else if(await projectAssert({root:app.root,assertion,locator,locate,images,result})){
119
127
  }else if(['hidden','visible'].includes(assertion.kind)){await assertNativeState(locator,assertion);
120
128
  }else if(assertion.kind==='text'){
@@ -1,3 +1,4 @@
1
+ import {resolveVisualCarrier} from './visual-carrier.mjs';
1
2
  import {sampleNativeMetrics} from './native-dom-sampling.mjs';
2
3
  import {rendererImageFrameId} from './renderer-image-evidence.mjs';
3
4
  import assert from 'node:assert/strict';
@@ -7,9 +8,9 @@ export async function nativeMetricsFrameIdentity(locator) {
7
8
  try{const frame=await handle?.ownerFrame();if(frame)frameId=rendererImageFrameId(locator.page(),frame);}finally{await handle?.dispose();}
8
9
  return frameId;
9
10
  }
10
- export async function nativeMetrics(locator) {
11
+ export async function nativeMetrics(locator, {carrier='semantic-root'}={}) {
11
12
  const frameId=await nativeMetricsFrameIdentity(locator);
12
- const metrics=await locator.evaluate(sampleNativeMetrics);
13
+ const metrics=carrier==='semantic-root'?await locator.evaluate(sampleNativeMetrics):await locator.evaluate(Function('return ((root,kind)=>{const resolve='+resolveVisualCarrier.toString()+';const sample='+sampleNativeMetrics.toString()+';const selected=resolve(root,kind);return {...sample(selected),carrierEvidence:{kind,rootBox:sample(root).box,selectedIsRoot:selected===root}};})')(),carrier);
13
14
  metrics.frameId=frameId;for(const paint of [...metrics.imagePaints,...metrics.imagePaintInventory])paint.frameId=frameId;return metrics;
14
15
  }
15
16
  export function near(actual,expected,label,tolerance=.02){assert(Math.abs(actual-expected)<=tolerance,`${label}: ${actual} != ${expected}`);}
@@ -1,7 +1,7 @@
1
1
  // Existing public browser collectors, extracted without changing measured fields.
2
2
  export function sampleNativeMetrics(el){
3
3
  const s=getComputedStyle(el),r=el.getBoundingClientRect();
4
- const styles=Object.fromEntries(['fontFamily','fontSize','fontWeight','lineHeight','color','backgroundColor','borderRadius','paddingLeft','paddingRight','paddingTop','paddingBottom','marginTop','marginBottom','marginLeft','marginRight','backgroundImage','backgroundSize','backgroundRepeat','backgroundPosition','objectFit'].map(k=>[k,s[k]]));
4
+ const styles=Object.fromEntries(['fontFamily','fontSize','fontWeight','lineHeight','color','backgroundColor','borderRadius','paddingLeft','paddingRight','paddingTop','paddingBottom','marginTop','marginBottom','marginLeft','marginRight','backgroundImage','backgroundSize','backgroundRepeat','backgroundPosition','objectFit','textAlign','position','display','flexDirection','alignItems','justifyContent','overflowX','overflowY','boxSizing','gap','rowGap','columnGap'].map(k=>[k,s[k]]));
5
5
  const texts=[],textStyles=[];
6
6
  function visit(node){if(node.nodeType===3 && node.textContent.trim()){const range=document.createRange();range.selectNodeContents(node);const ts=getComputedStyle(node.parentElement);textStyles.push(Object.fromEntries(['fontFamily','fontSize','fontWeight','lineHeight','color'].map(k=>[k,ts[k]])));for(const b of range.getClientRects())if(b.width&&b.height)texts.push({x:b.x,y:b.y,width:b.width,height:b.height});}else{for(const c of node.childNodes)visit(c);if(node.shadowRoot)visit(node.shadowRoot);}}
7
7
  visit(el);
@@ -0,0 +1,44 @@
1
+ /** Browser-side, expectation-independent selection inside one semantic root. */
2
+ export function resolveVisualCarrier(root, kind = 'semantic-root') {
3
+ if(kind==='semantic-root')return root;
4
+ if(!['source-content-group','owned-text-block'].includes(kind))throw Error('Unsupported visual carrier');
5
+ const visible=element=>{
6
+ for(let p=element;p;p=p.parentElement){const s=getComputedStyle(p);if(s.display==='none'||s.visibility!=='visible'||Number(s.opacity)===0)return false;}
7
+ return true;
8
+ };
9
+ const content=[],texts=[];
10
+ const walk=node=>{
11
+ if(node.nodeType===3&&node.textContent.trim()&&visible(node.parentElement)) {
12
+ const range=node.ownerDocument.createRange();range.selectNodeContents(node);
13
+ if([...range.getClientRects()].some(r=>r.width>0&&r.height>0)){content.push(node.parentElement);texts.push(node);}
14
+ } else if(node.nodeType===1) {
15
+ if(node.shadowRoot)throw Error('Unsupported shadow content carrier topology');
16
+ if(node.tagName==='IMG'&&visible(node)){const r=node.getBoundingClientRect();if(r.width>0&&r.height>0)content.push(node);}
17
+ for(const child of node.childNodes)walk(child);
18
+ }
19
+ };
20
+ walk(root);
21
+ if(!content.length)throw Error('Content carrier requires actual painted text or image');
22
+ let group=content[0];
23
+ while(group&& !content.every(n=>group.contains(n)))group=group.parentElement;
24
+ if(!group||!root.contains(group))throw Error('Content group escaped semantic owner');
25
+ if(kind==='source-content-group')return group;
26
+ const branches=new Set();
27
+ for(const text of texts) {
28
+ let owned=true;
29
+ for(let p=text.parentElement;p&&p!==root;p=p.parentElement){
30
+ const path=p.$$dataset?.anchorId??p.getAttribute('data-anchor-id');
31
+ if(path){owned=false;break;}
32
+ }
33
+ if(!owned)continue;
34
+ let branch=text.parentElement;
35
+ if(branch===group)throw Error('Owned text has no distinct block');
36
+ while(branch.parentElement!==group)branch=branch.parentElement;
37
+ branches.add(branch);
38
+ }
39
+ if(branches.size!==1)throw Error('Owned text block missing or ambiguous');
40
+ const block=[...branches][0];
41
+ // Do not let an unanchored wrapper make text belonging to other targets owned.
42
+ for(const n of block.querySelectorAll('*'))if(n.$$dataset?.anchorId||n.getAttribute('data-anchor-id'))throw Error('Owned text block contains another semantic target');
43
+ return block;
44
+ }
@@ -0,0 +1,79 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ const lengths = new Set(['paddingLeft','paddingRight','paddingTop','paddingBottom','marginLeft','marginRight','marginTop','marginBottom','borderRadius','gap','rowGap','columnGap']);
4
+ const layout = new Set(['position','display','flexDirection','alignItems','justifyContent','overflowX','overflowY','boxSizing']);
5
+ const top = new Set(['designWidth','text','lengths','dimensions','backgroundColor','align','layout','carrier','viewportInsets']);
6
+ function object(value, name) {
7
+ assert(value && Object.getPrototypeOf(value) === Object.prototype, `${name}: object required`);
8
+ }
9
+ function number(value, name) { assert(typeof value === 'number' && Number.isFinite(value), `${name}: finite number required`); }
10
+ function string(value, name) { assert(typeof value === 'string' && value.trim(), `${name}: nonempty string required`); }
11
+ function keys(value, allowed, name, validate) {
12
+ object(value,name);
13
+ for (const [key,item] of Object.entries(value)) {
14
+ assert(allowed.has(key), `${name}: unsupported ${key}`);
15
+ validate(item, `${name}.${key}`);
16
+ }
17
+ return Object.keys(value).length;
18
+ }
19
+
20
+ /** Validates declarations, not whether the source obligations are sufficiently covered. */
21
+ export function validateVisualExpectation(value) {
22
+ object(value,'visual');
23
+ for (const key of Object.keys(value)) assert(top.has(key), `visual: unsupported ${key}`);
24
+ number(value.designWidth,'visual.designWidth');
25
+ assert(value.designWidth > 0,'visual.designWidth must be positive');
26
+ if(value.carrier!==undefined)assert(['semantic-root','source-content-group','owned-text-block'].includes(value.carrier),'visual: unsupported carrier');
27
+ let checks = 0;
28
+ if(value.viewportInsets!==undefined)checks+=keys(value.viewportInsets,new Set(['left','right','top','bottom']),'visual.viewportInsets',number);
29
+ if (value.text !== undefined) {
30
+ checks += keys(value.text,new Set(['fontSize','fontWeight','color','lineHeight']),'visual.text',(v,k)=>k.endsWith('.color')?string(v,k):number(v,k));
31
+ }
32
+ if (value.lengths !== undefined) checks += keys(value.lengths,lengths,'visual.lengths',number);
33
+ if (value.dimensions !== undefined) checks += keys(value.dimensions,new Set(['width','height']),'visual.dimensions',number);
34
+ if (value.layout !== undefined) checks += keys(value.layout,layout,'visual.layout',string);
35
+ for (const key of ['backgroundColor','align']) if (value[key] !== undefined) { string(value[key],`visual.${key}`); checks++; }
36
+ assert(checks > 0,'visual: at least one substantive expectation required');
37
+ return value;
38
+ }
39
+
40
+ function near(actual,expected,tolerance,label) {
41
+ number(actual,label);
42
+ assert(Math.abs(actual-expected)<=tolerance,`${label}: ${actual} != ${expected}`);
43
+ }
44
+ function pixels(value,label) {
45
+ assert(typeof value==='string' && /^-?(?:\d+(?:\.\d*)?|\.\d+)px$/.test(value),`${label}: resolved px required`);
46
+ return Number(value.slice(0,-2));
47
+ }
48
+
49
+ /** Pure public predicate. Metrics must be sampled from the declared semantic carrier. */
50
+ export function assertVisualMetrics(metrics, expectation) {
51
+ const v=validateVisualExpectation(expectation), m=metrics;
52
+ assert(m?.box && m?.viewport && m?.styles,'visual: native metrics required');
53
+ number(m.viewport.width,'viewport.width');assert(m.viewport.width>0,'positive viewport width required');
54
+ number(m.box.width,'box.width');number(m.box.height,'box.height');
55
+ assert(m.box.width>0&&m.box.height>0,'visual: painted carrier required');
56
+ const unit=m.viewport.width/v.designWidth;
57
+ for(const [side,value] of Object.entries(v.viewportInsets??{})) {
58
+ const actual=side==='left'?m.box.x:side==='top'?m.box.y:side==='right'?m.viewport.width-m.box.x-m.box.width:m.viewport.height-m.box.y-m.box.height;
59
+ near(actual,value*unit,.25,`visual.viewportInsets.${side}`);
60
+ }
61
+ if(v.text && Object.keys(v.text).length) {
62
+ assert(m.texts?.length&&m.textStyles?.length,'visual: actual text paint required');
63
+ for(const style of m.textStyles) for(const [key,value] of Object.entries(v.text)) {
64
+ if(key==='color') assert.equal(style.color,value,'visual.text.color');
65
+ else if(key==='fontWeight') {assert(style.fontWeight!==''&&style.fontWeight!=null,'fontWeight required');assert.equal(Number(style.fontWeight),value,'visual.text.fontWeight');}
66
+ else near(pixels(style[key],key),value*unit,.02,`visual.text.${key}`);
67
+ }
68
+ }
69
+ for(const [key,value] of Object.entries(v.lengths??{})) near(pixels(m.styles[key],key),value*unit,.25,`visual.lengths.${key}`);
70
+ for(const [key,value] of Object.entries(v.dimensions??{})) near(m.box[key],value*unit,.25,`visual.dimensions.${key}`);
71
+ for(const [key,value] of Object.entries(v.layout??{})) assert.equal(m.styles[key],value,`visual.layout.${key}`);
72
+ if(v.backgroundColor!==undefined) assert.equal(m.styles.backgroundColor,v.backgroundColor,'visual.backgroundColor');
73
+ if(v.align!==undefined) assert.equal(m.styles.textAlign,v.align,'visual.align');
74
+ return {checks:'passed',expectation:v,metrics:m};
75
+ }
76
+
77
+ export function validateRegionVisualAssertions(assertions) {
78
+ for(const a of assertions??[]) if(a.kind==='region'&&a.check==='visual') validateVisualExpectation(a.visual);
79
+ }
@@ -57,119 +57,192 @@ def branch(value):
57
57
  raise Invalid('Invalid branch name: ' + value)
58
58
 
59
59
 
60
- def render(config, workflow):
60
+ def render(config, workflow, require_shared=True):
61
61
  workflow = Path(workflow)
62
- schema = json.loads((workflow / 'resources/iteration.schema.json').read_text())
62
+ validate(config, json.loads((workflow / 'resources/iteration.schema.json').read_text()))
63
63
  meta = json.loads((workflow / 'resources/stage-meta.json').read_text())
64
- validate(config, schema)
65
64
  if meta['version'] != config['metaVersion']:
66
65
  raise Invalid('Stage metadata version mismatch')
67
- relative(config['bizdir'])
68
- branch(config['git']['branch'])
69
- # Operational input paths are explicit absolute paths, not cwd-relative guesses.
70
- for value in [config['persistentDeliveryDirectory'], config['sharedRoot'], config['source']['root'], config['skillsRoot'], config['toolsRoot'], config['frameworkSkill'], *config['generated'].values()]:
71
- if not Path(value).is_absolute():
72
- raise Invalid('Expected absolute operational input path: ' + value)
73
- shared = Path(config['sharedRoot']).resolve(strict=True)
74
- if not shared.is_dir():
75
- raise Invalid('Shared root must exist as a directory')
76
- def field(label, value):
77
- return f'- {label}:`{value}`'
78
- def expand(template, params):
79
- def get(match):
80
- value = params
81
- for key in match.group(1).split('.'):
82
- if not isinstance(value, dict) or key not in value:
83
- raise Invalid('Unknown template parameter: ' + match.group(1))
84
- value = value[key]
85
- if not isinstance(value, str):
86
- raise Invalid('Template value must be a string')
87
- return value
88
- result = re.sub(r'\{([A-Za-z][A-Za-z0-9_.]*)\}', get, template)
89
- if '{' in result or '}' in result:
90
- raise Invalid('Unresolved template parameter')
91
- return result
92
- nodes, ids, branches, outputs = [], set(), set(), []
66
+ relative(config['bizdir']); branch(config['git']['branch'])
67
+ def absolute(value):
68
+ p = Path(value)
69
+ if not p.is_absolute() or '..' in p.parts:
70
+ raise Invalid('Expected normalized absolute path: ' + value)
71
+ return p.resolve()
72
+ shared = absolute(config['sharedRoot'])
73
+ if require_shared and not shared.is_dir():
74
+ raise Invalid('Shared root must exist')
75
+ persistent = absolute(config['persistentRoot'])
76
+ if persistent == shared or persistent.is_relative_to(shared) or shared.is_relative_to(persistent):
77
+ raise Invalid('Persistent iteration storage must be independent of temporary shared storage')
78
+ absolute(config['source']['root']); absolute(config['environmentIndex'])
79
+ for artifact in config['provenance']:
80
+ absolute(artifact['path'])
81
+ def field(k, v): return f'- {k}:`{v}`'
82
+ def local(k, v): return field(k, 'localPath:' + v)
83
+ records = shared / config['bizdir'] / 'execution-records'
84
+ nodes, ids, branches, writes = [], set(), set(), []
85
+ def output_path(value):
86
+ p = absolute(value)
87
+ if p == shared or not p.is_relative_to(shared):
88
+ raise Invalid('Shared output escapes its root')
89
+ if any(p == old or p.is_relative_to(old) or old.is_relative_to(p) for old in writes):
90
+ raise Invalid('Overlapping shared writers')
91
+ writes.append(p)
92
+ return p
93
93
  for stage in config['stages']:
94
94
  if stage['id'] in ids or stage['workBranch'] in branches:
95
95
  raise Invalid('Duplicate stage ID or work branch')
96
96
  ids.add(stage['id']); branches.add(stage['workBranch']); branch(stage['workBranch'])
97
- relative(stage['sharedOutput'])
98
- output = (shared / stage['sharedOutput']).resolve()
99
- if not output.is_relative_to(shared) or output == shared:
100
- raise Invalid('Shared output escapes its root')
101
- if any(output == old or output.is_relative_to(old) or old.is_relative_to(output) for old in outputs):
102
- raise Invalid('Overlapping shared output directories')
103
- outputs.append(output)
104
- if not Path(stage['acceptedInput']).is_absolute():
105
- raise Invalid('Accepted input registry must be an absolute path')
97
+ rec = output_path(stage['recordDirectory'])
98
+ if rec == records or not rec.is_relative_to(records):
99
+ raise Invalid('Execution records must be inside the business execution-records area')
100
+ for item in stage['localInputs']:
101
+ p = absolute(item['path'])
102
+ if p == records or p.is_relative_to(records) or records.is_relative_to(p):
103
+ raise Invalid('Execution records cannot be a stage input')
104
+ for item in stage['localOutputs']:
105
+ p = output_path(item['path'])
106
+ if p == records or p.is_relative_to(records):
107
+ raise Invalid('Use recordDirectory for execution records')
106
108
  m = meta['stages'][stage['stage']]
107
- params = {**config, **stage}
108
- directories = [expand(v, params) for _, v in m['outputDirectories']]
109
- files = [expand(v, params) for _, v in m['outputFiles']]
110
- for p in directories + files:
111
- if not relative(p).is_relative_to(relative(config['bizdir'])) or p == config['bizdir']:
109
+ def expand(value):
110
+ value = value.replace('{bizdir}', config['bizdir'])
111
+ if '{' in value or '}' in value:
112
+ raise Invalid('Unknown path placeholder')
113
+ if not relative(value).is_relative_to(relative(config['bizdir'])) or value == config['bizdir']:
112
114
  raise Invalid('Output outside business directory')
113
- lines = ['### 输入', '', field('工作分支', stage['workBranch'])] + [field(k, 'localPath:' + expand(v, params)) for k, v in m['inputs']]
114
- if 'inputCommit' in stage:
115
- lines.append(field('阶段输入提交', stage['inputCommit']))
116
- if 'group' in stage:
117
- lines.append(field('业务分组', stage['group']))
118
- lines += ['', '### 执行', '', f'使用 `{m["skill"]}` 完成{m["label"]}阶段。', '',
119
- field('Skill入口', config['skillsRoot'] + '/' + stage['stage'] + '/SKILL.md'), '', '### 交付', '']
120
- lines += [field(k, 'gitBranchPath:' + expand(v, params)) for k, v in m['outputDirectories'] + m['outputFiles']]
121
- lines.append(field('任务信息目录', 'localPath:' + str(output)))
122
- lines += ['', meta['gitDeliveryInstruction'], '', field(meta['deliveryGate']['label'], meta['deliveryGate']['command']), '', meta['deliveryGate']['scope']]
115
+ return value
116
+ directories = [expand(v) for _, v in m['outputDirectories']]
117
+ files = [expand(v) for _, v in m['outputFiles']]
118
+ lines = ['### 输入', '', field('工作分支', stage['workBranch'])]
119
+ lines += [local(i['label'], i['path']) for i in stage['localInputs']]
120
+ if 'group' in stage: lines.append(field('业务分组', stage['group']))
121
+ lines += ['', '### 执行', '', f'使用 Skill `{m["skill"]}` 完成{m["label"]}。', '', '### 交付', '']
122
+ lines += [field(k, 'gitBranchPath:' + expand(v)) for k, v in m['outputDirectories'] + m['outputFiles']]
123
+ lines += [local(i['label'], i['path']) for i in stage['localOutputs']]
124
+ lines.append(local('任务执行记录目录', stage['recordDirectory']))
123
125
  nodes.append({'id':stage['id'], 'stage':stage['stage'], 'context':'\n'.join(lines),
124
126
  'workBranch':stage['workBranch'], 'outputDirectories':directories,
125
- 'outputFiles':files, 'sharedOutput':str(output)})
126
- common = '\n'.join(['### 全局参数', ''] + [field(k, v) for k, v in [
127
- ('Git仓库',config['git']['url']), ('基线分支',config['git']['branch']), ('基线提交',config['git']['commit']),
128
- ('业务目录',config['bizdir']), ('业务源版本',config['source']['commit']), ('业务源根目录',config['source']['root']),
129
- ('共享根目录',str(shared)), ('Skill根目录',config['skillsRoot']), ('工具目录',config['toolsRoot'])]])
130
- def checkpoint(template, params, extra_inputs=()):
131
- return '\n'.join(['### 输入', ''] + [field(k, ('localPath:' if '目录' in k else '') + expand(v, params)) for k, v in template['inputs']] + list(extra_inputs) +
132
- ['', '### 审核', '', template['review'], '', '### 交付', ''] +
133
- [field(k, 'localPath:' + expand(v, params)) for k, v in template['outputs']])
134
- reserved = {n['id'] + '-review' for n in nodes} | {'delivery'}
135
- if ids & reserved:
136
- raise Invalid('Stage ID conflicts with a checkpoint ID')
137
- checkpoints = [{'id':n['id']+'-review', 'context':checkpoint(meta['checkpoint'],
138
- {**config, **n, 'stageOutput':n['sharedOutput']})} for n in nodes]
139
- final = {'id':'delivery', 'context':checkpoint(meta['delivery'], config,
140
- [field(n['id']+'工作分支', n['workBranch']) for n in nodes])}
141
- return {'schemaVersion':1,'metaVersion':meta['version'],'commonContext':common,'nodes':nodes,
127
+ 'outputFiles':files, 'recordDirectory':stage['recordDirectory'],
128
+ 'dependencies':[i+'-review' for i in stage['dependencies']]})
129
+ reserved = {i+'-review' for i in ids} | {'delivery'}
130
+ if ids & reserved: raise Invalid('Stage ID conflicts with a checkpoint ID')
131
+ dependencies = {s['id']:s['dependencies'] for s in config['stages']}
132
+ visited, active = set(), set()
133
+ def visit(key):
134
+ if key not in ids: raise Invalid('Unknown dependency: ' + key)
135
+ if key in active: raise Invalid('Cyclic stage dependency')
136
+ if key in visited: return
137
+ active.add(key)
138
+ for dep in dependencies[key]: visit(dep)
139
+ active.remove(key); visited.add(key)
140
+ for key in ids: visit(key)
141
+ for entry in config['reviews']:
142
+ if entry['stageId'] not in ids: raise Invalid('Unknown reviewed stage')
143
+ absolute(entry['record'])
144
+ common = '\n'.join(['### 公共执行信息', '', field('Git仓库',config['git']['url']),
145
+ field('业务目录',config['bizdir']),local('Skill与工具入口',config['environmentIndex'])])
146
+ checkpoints=[]
147
+ for n in nodes:
148
+ lines=['### 输入','',field('工作分支',n['workBranch']),local('任务执行记录目录',n['recordDirectory']),
149
+ '', '### 审核','',meta['checkpoint']['review'],'','### 交付','',
150
+ local('审核记录目录',str(records/'checkpoints'/Path(n['recordDirectory']).relative_to(records)))]
151
+ checkpoints.append({'id':n['id']+'-review','dependencies':[n['id']],'context':'\n'.join(lines)})
152
+ used={dep for deps in dependencies.values() for dep in deps}
153
+ final={'id':'delivery','dependencies':[n['id']+'-review' for n in nodes if n['id'] not in used],
154
+ 'context':'\n'.join(['### 输入','']+[field(n['stage']+'工作分支',n['workBranch']) for n in nodes if n['id'] not in used]+[
155
+ local('任务执行记录目录',str(records)),'','### 审核','',meta['delivery']['review'],'','### 交付','',
156
+ local('持久迭代目录',str(persistent))])}
157
+ return {'schemaVersion':2,'metaVersion':meta['version'],'commonContext':common,'nodes':nodes,
142
158
  'checkpoints':checkpoints,'delivery':final}
143
159
 
144
160
 
161
+ def stage_scope(config, rendered, stage_id):
162
+ stage = next((s for s in config['stages'] if s['id'] == stage_id), None)
163
+ if stage is None: raise Invalid('Unknown stage ID')
164
+ if 'inputCommit' not in stage: raise Invalid('Stage input commit has not been accepted yet')
165
+ return {'schemaVersion':1,'id':stage_id,'stage':stage['stage'],'bizdir':config['bizdir'],
166
+ 'workBranch':stage['workBranch'],'inputCommit':stage['inputCommit'],
167
+ 'metaVersion':rendered['metaVersion']}
168
+
169
+
170
+ def scope_nodes(scope, workflow):
171
+ text={'type':'string','minLength':1,'pattern':r'^[^\x00-\x1f`{}]+$'}
172
+ props={'schemaVersion':{'const':1},'id':text,'stage':text,'bizdir':text,'workBranch':text,
173
+ 'inputCommit':{'type':'string','pattern':'^[0-9a-f]{40}$'},'metaVersion':{'const':2}}
174
+ validate(scope,{'type':'object','additionalProperties':False,'properties':props,'required':list(props)})
175
+ relative(scope['bizdir']); branch(scope['workBranch'])
176
+ meta=json.loads((Path(workflow)/'resources/stage-meta.json').read_text())
177
+ if meta['version'] != scope['metaVersion']: raise Invalid('Stage metadata version mismatch')
178
+ if scope['stage'] not in meta['stages']: raise Invalid('Unknown stage type')
179
+ stage=meta['stages'][scope['stage']]
180
+ result={}
181
+ for key in ('outputDirectories','outputFiles'):
182
+ paths=[v.replace('{bizdir}',scope['bizdir']) for _,v in stage[key]]
183
+ for p in paths:
184
+ if not relative(p).is_relative_to(relative(scope['bizdir'])) or p == scope['bizdir']:
185
+ raise Invalid('Invalid stage output path')
186
+ result[key]=paths
187
+ return [result]
188
+
189
+
145
190
  def main(argv=None):
146
191
  parser = argparse.ArgumentParser(description=__doc__)
147
- parser.add_argument('action', choices=['render', 'diff-review'])
148
- parser.add_argument('--input', required=True)
149
- parser.add_argument('--workflow', required=True, help='Frozen workflow Skill directory')
150
- parser.add_argument('--output', required=True, help='Review JSON; node contexts are Markdown')
192
+ parser.add_argument('action', choices=['render','diff-review','scope','stage-gate','init','update','list','archive'])
193
+ parser.add_argument('--input', help='Owner metadata or stage scope, depending on command')
194
+ parser.add_argument('--workflow', help='Frozen workflow Skill directory')
195
+ parser.add_argument('--output')
151
196
  parser.add_argument('--repository')
152
197
  parser.add_argument('--base')
153
198
  parser.add_argument('--head')
154
199
  parser.add_argument('--stage-id', action='append')
200
+ parser.add_argument('--registry', help='Initiator workspace persistent iterations directory')
201
+ parser.add_argument('--expected-sha256')
202
+ parser.add_argument('--source')
203
+ parser.add_argument('--kind', choices=['reports','records'])
204
+ parser.add_argument('--name')
155
205
  args = parser.parse_args(argv)
206
+ def require(*names):
207
+ for name in names:
208
+ if not getattr(args,name): raise Invalid('Missing --'+name.replace('_','-'))
156
209
  try:
157
- result = render(json.loads(Path(args.input).read_text()), args.workflow)
158
- target = Path(args.output)
159
- if target.exists():
160
- raise Invalid('Output already exists; choose a new review artifact')
161
- if args.action == 'diff-review':
162
- if not all((args.repository, args.base, args.head, args.stage_id)):
163
- raise Invalid('diff-review requires repository, base, head and stage-id')
164
- selected = [n for n in result['nodes'] if n['id'] in args.stage_id]
165
- if {n['id'] for n in selected} != set(args.stage_id):
166
- raise Invalid('Unknown stage ID in diff-review scope')
210
+ from . import registry
211
+ if args.action == 'list':
212
+ require('registry')
213
+ print(json.dumps(registry.entries(args.registry),ensure_ascii=False,indent=2)); return 0
214
+ require('input')
215
+ config=json.loads(Path(args.input).read_text())
216
+ if args.action == 'archive':
217
+ require('source','kind','name')
218
+ result=registry.archive(args.input,args.source,args.kind,args.name)
219
+ print(json.dumps({'directory':result['directory'],'files':len(result['files'])},ensure_ascii=False)); return 0
220
+ require('workflow')
221
+ if args.action in ('init','update'):
222
+ require('registry')
223
+ if args.action == 'update': require('expected_sha256')
224
+ elif args.expected_sha256: raise Invalid('init does not accept an update hash')
225
+ print(json.dumps(registry.save(config,args.workflow,args.registry,args.expected_sha256),ensure_ascii=False)); return 0
226
+ require('output')
227
+ target=Path(args.output)
228
+ if target.exists(): raise Invalid('Output already exists; choose a new artifact')
229
+ if args.action == 'stage-gate':
230
+ require('repository','head')
231
+ from .diff_review import review
232
+ checked=review(args.repository,config['inputCommit'],args.head,scope_nodes(config,args.workflow))
233
+ target.write_text(checked['markdown']); return 0 if checked['passed'] else 3
234
+ result=render(config,args.workflow)
235
+ if args.action == 'scope':
236
+ if not args.stage_id or len(args.stage_id)!=1: raise Invalid('scope requires exactly one stage-id')
237
+ result=stage_scope(config,result,args.stage_id[0])
238
+ elif args.action == 'diff-review':
239
+ require('repository','base','head','stage_id')
240
+ selected=[n for n in result['nodes'] if n['id'] in args.stage_id]
241
+ if {n['id'] for n in selected} != set(args.stage_id): raise Invalid('Unknown stage ID')
167
242
  from .diff_review import review
168
- checked = review(args.repository, args.base, args.head, selected)
169
- target.write_text(checked['markdown'])
170
- return 0 if checked['passed'] else 3
171
- target.write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n')
243
+ checked=review(args.repository,args.base,args.head,selected)
244
+ target.write_text(checked['markdown']); return 0 if checked['passed'] else 3
245
+ target.write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n')
172
246
  except (ValueError, KeyError, OSError) as error:
173
- print(str(error), file=sys.stderr)
174
- return 2
247
+ print(str(error),file=sys.stderr); return 2
175
248
  return 0
@@ -0,0 +1,151 @@
1
+ """Initiator-owned iteration registry and durable report storage; never drives tasks."""
2
+ from contextlib import contextmanager
3
+ from hashlib import sha256
4
+ from html.parser import HTMLParser
5
+ from pathlib import Path
6
+ from urllib.parse import unquote, urlsplit
7
+ import fcntl
8
+ import json
9
+ import os
10
+ import re
11
+ import shutil
12
+ import tempfile
13
+
14
+
15
+ def digest(path):
16
+ return sha256(Path(path).read_bytes()).hexdigest()
17
+
18
+
19
+ def atomic_json(path, value):
20
+ path = Path(path)
21
+ path.parent.mkdir(parents=True, exist_ok=True)
22
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix='.pending-')
23
+ try:
24
+ with os.fdopen(fd, 'w') as out:
25
+ json.dump(value, out, ensure_ascii=False, indent=2)
26
+ out.write('\n'); out.flush(); os.fsync(out.fileno())
27
+ os.replace(tmp, path)
28
+ finally:
29
+ if os.path.exists(tmp): os.unlink(tmp)
30
+
31
+
32
+ @contextmanager
33
+ def lock(root):
34
+ root = Path(root).resolve()
35
+ root.mkdir(parents=True, exist_ok=True)
36
+ with (root / '.registry.lock').open('a') as stream:
37
+ fcntl.flock(stream, fcntl.LOCK_EX)
38
+ yield root
39
+
40
+
41
+ def entries(root):
42
+ result = []
43
+ for path in sorted(Path(root).glob('*/iteration.meta.json')):
44
+ value = json.loads(path.read_text())
45
+ result.append({'id':value['iteration']['id'], 'goal':value['iteration']['goal'],
46
+ 'state':value['iteration']['state'], 'metadata':str(path), 'sha256':digest(path)})
47
+ return result
48
+
49
+
50
+ def save(config, workflow, root, expected=None):
51
+ from .iteration import render, Invalid
52
+ render(config, workflow, require_shared=False)
53
+ with lock(root) as root:
54
+ target = root / config['iteration']['id']
55
+ if Path(config['persistentRoot']).resolve() != target.resolve():
56
+ raise Invalid('persistentRoot must equal registry root / iteration ID')
57
+ path = target / 'iteration.meta.json'
58
+ if path.exists():
59
+ if not expected or digest(path) != expected:
60
+ raise Invalid('Metadata exists or changed; supply its current SHA256 for update')
61
+ old = json.loads(path.read_text())
62
+ if old['iteration']['id'] != config['iteration']['id']:
63
+ raise Invalid('Cannot change iteration identity')
64
+ history = target / 'records' / 'metadata-history' / (digest(path)+'.json')
65
+ atomic_json(history, old)
66
+ elif expected:
67
+ raise Invalid('Cannot update missing iteration metadata')
68
+ for subdir in ('records','reports'):
69
+ (target/subdir).mkdir(parents=True, exist_ok=True)
70
+ atomic_json(path, config)
71
+ atomic_json(root/'index.json', {'schemaVersion':1,'iterations':entries(root)})
72
+ return {'metadata':str(path),'sha256':digest(path)}
73
+
74
+
75
+ class References(HTMLParser):
76
+ def __init__(self):
77
+ super().__init__(); self.values=[]
78
+ def handle_starttag(self, tag, attrs):
79
+ attrs=dict(attrs)
80
+ if tag == 'base':
81
+ raise ValueError('Report must not override its relative base URL')
82
+ for key in ('src','href','poster','data'):
83
+ if key in attrs: self.values.append(attrs[key])
84
+ if attrs.get('srcset'):
85
+ value=attrs['srcset']
86
+ if not value.startswith('data:'):
87
+ self.values.extend(x.strip().split()[0] for x in value.split(',') if x.strip())
88
+ if attrs.get('style'):
89
+ self.values.extend(css_refs(attrs['style']))
90
+
91
+
92
+ def css_refs(text):
93
+ return re.findall(r'url\(\s*[\"\']?([^\"\')]+)[\"\']?\s*\)',text,flags=re.I) + re.findall(r'@import\s+[\"\']([^\"\']+)[\"\']',text,flags=re.I)
94
+
95
+
96
+ def check_assets(root):
97
+ """Check static HTML/CSS dependencies. JS-generated URLs require browser review."""
98
+ root=Path(root).resolve(); files={}; errors=[]
99
+ for path in sorted(root.rglob('*')):
100
+ if path.is_symlink():
101
+ raise ValueError('Archive source contains a symlink: '+str(path))
102
+ if not path.is_file(): continue
103
+ files[path.relative_to(root).as_posix()]=digest(path)
104
+ refs=[]
105
+ if path.suffix.lower() in ('.html','.htm'):
106
+ text=path.read_text(); parser=References(); parser.feed(text)
107
+ refs=parser.values+css_refs(text)
108
+ elif path.suffix.lower()=='.css': refs=css_refs(path.read_text())
109
+ for ref in refs:
110
+ url=urlsplit(ref.strip())
111
+ if url.scheme in ('http','https','data','mailto','tel') or url.netloc or not url.path: continue
112
+ if url.scheme or url.path.startswith('/'):
113
+ errors.append(f'{path.relative_to(root)}: nonportable reference {ref}'); continue
114
+ linked=(path.parent/unquote(url.path)).resolve()
115
+ if not linked.is_relative_to(root) or not linked.exists():
116
+ errors.append(f'{path.relative_to(root)}: missing/outside reference {ref}')
117
+ if errors: raise ValueError('\n'.join(errors))
118
+ if not files: raise ValueError('Archive contains no files')
119
+ return files
120
+
121
+
122
+ def archive(metadata, source, kind, name):
123
+ from .iteration import Invalid
124
+ if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_-]*',name): raise Invalid('Invalid archive name')
125
+ if kind not in ('reports','records'): raise Invalid('Unknown archive kind')
126
+ metadata=Path(metadata).resolve()
127
+ config=json.loads(metadata.read_text())
128
+ persistent=Path(config['persistentRoot']).resolve()
129
+ if metadata != persistent/'iteration.meta.json': raise Invalid('Not a registered metadata path')
130
+ source=Path(source)
131
+ if source.is_symlink() or not source.is_dir(): raise Invalid('Archive source must be a real directory')
132
+ source=source.resolve()
133
+ if source == persistent or source.is_relative_to(persistent) or persistent.is_relative_to(source):
134
+ raise Invalid('Source and persistent archive must be disjoint')
135
+ before=check_assets(source)
136
+ with lock(persistent.parent):
137
+ target=persistent/kind/name
138
+ if target.exists(): raise Invalid('Archive already exists; use a new content version')
139
+ pending=Path(tempfile.mkdtemp(prefix='.archive-',dir=persistent))
140
+ try:
141
+ shutil.copytree(source,pending/'content')
142
+ after=check_assets(pending/'content')
143
+ if before != after: raise Invalid('Archive source changed while copying')
144
+ target.parent.mkdir(parents=True,exist_ok=True)
145
+ os.replace(pending/'content',target)
146
+ record={'schemaVersion':1,'kind':kind,'name':name,'directory':str(target),
147
+ 'files':after,'staticAssetsVerified':True,'browserReview':'required-for-user-preview'}
148
+ atomic_json(persistent/'records'/'archives'/(kind+'-'+name+'.json'),record)
149
+ return record
150
+ finally:
151
+ shutil.rmtree(pending)