Test competing navigation ownership and review research thread

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0cffe-206f-7323-89d3-b20ca4cf4bf9
This commit is contained in:
tegwick 2026-09-27 00:45:54 +02:00
parent e484cae45f
commit e8ccb73a12
22 changed files with 4981 additions and 13 deletions

View file

@ -53,3 +53,24 @@ Raw rows observe behavior, so 22 passing rows do not mean raw and managed varian
meet identical focus/feedback obligations. See NF01–NF06. The policy is authored
experimental code; DOM focus and live-region text do not establish assistive-technology
announcements or conformance. History and request-race limits remain explicit.
## Competing completion follow-up and pause
[LEARN-0013 protocol](../../evolution/cycles/2026-09-27-navigation-ownership.md),
[review](../../reviews/NavigationOwnershipExperiment.md), [receipt](ownership-result.json).
Use the same browser environment with `node ownership.mjs`; choose a new
`FEP_RESULT_PATH` (default `/tmp/fep-ownership-result.json`). Six scenarios × two
policies × two browsers: 24 completed rows, all obligations met by guard 12/12 and
previous policy 0/12. These are deliberately adversarial cases, not general quality
scores. Server barriers impose schedules; client completions are observed without
aborting transport or claiming remote rollback.
The previous focus-policy.js remains byte-for-byte unchanged. New ownership-policy.js
uses a generation plus separate focus ownership; its history XHR handler wrapping is
explicitly tied to pinned internals. Initial runner/result retained under
ownership-initial.mjs / ownership-initial-result.json: four Firefox keyboard-helper
failures corrected by backward traversal, without changing policies or obligations.
Initial receipt's ownership.mjs hash corresponds to ownership-initial.mjs.
The [thread review](../../reviews/NavigationThreadReview.md) pauses fixture expansion
and hands the next evidence question to an independent pinned application source study.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
import {chromium,firefox} from 'playwright';
import {createServer} from 'node:http';
import {once} from 'node:events';
import {readFile,writeFile} from 'node:fs/promises';
import {createHash} from 'node:crypto';
import assert from 'node:assert/strict';
const dir=new URL('.',import.meta.url);
const assets=new Map(await Promise.all([['/htmx.js','node_modules/htmx.org/dist/htmx.js'],['/baseline.js','focus-policy.js'],['/guarded.js','ownership-policy.js']].map(async([url,file])=>[url,await readFile(new URL(file,dir))])));
const trace=`window.raceLog=[];for(const name of ['htmx:beforeRequest','htmx:historyCacheMiss'])document.addEventListener(name,e=>{const xhr=e.detail.xhr;xhr.addEventListener('load',()=>window.raceLog.push({event:'loaded',url:xhr.responseURL,status:xhr.status}));});`;
let requests=[],barriers=new Map();
function barrier(key){let resolve;const reached=new Promise(r=>resolve=r);const b={reached,resolve,release:null};barriers.set(key,b);return b;}
const server=createServer((req,res)=>{
const u=new URL(req.url,'http://local');res.setHeader('Cache-Control','no-store');
if(assets.has(u.pathname)){res.setHeader('Content-Type','application/javascript');res.end(assets.get(u.pathname));return;}
if(u.pathname==='/trace.js'){res.setHeader('Content-Type','application/javascript');res.end(trace);return;}
if(u.pathname!=='/articles'){res.writeHead(404);res.end('Not found');return;}
const topic=['alpha','beta','gamma'].includes(u.searchParams.get('topic'))?u.searchParams.get('topic'):'alpha';
const mode=u.searchParams.get('mode')==='guarded'?'guarded':'managed';
const hx=req.headers['hx-request']==='true',history=req.headers['hx-history-restore-request']==='true';
const status=hx&&u.searchParams.has('fail')?503:200;
const content=`<div id="payload"><h1 id="heading" tabindex="-1">${topic}</h1><p>Article ${topic}</p></div>`;
const boost='hx-boost="true" hx-target="#content" hx-select="#payload" hx-swap="innerHTML"';
const href=(t,extra='')=>`/articles?topic=${t}&mode=${mode}${extra}`;
const nav=`<nav><a id="beta" href="${href('beta')}" ${boost}>Visit beta</a><a id="old" href="${href('beta','&hold=old')}" ${boost}>Slow beta</a><a id="oldfail" href="${href('beta','&hold=old&fail=1')}" ${boost}>Slow failed beta</a><a id="new" href="${href('gamma')}" ${boost}>Visit gamma</a><a id="newfail" href="${href('gamma','&fail=1')}" ${boost}>Failed gamma</a><a id="recover" href="${href('gamma')}">Native recovery</a></nav>`;
const full=`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${topic} — Catalog</title><meta name="htmx-config" content='{"historyRestoreAsHxRequest":false}'><script src="/trace.js" defer></script><script src="/${mode==='guarded'?'guarded':'baseline'}.js" defer></script><script src="/htmx.js" defer></script></head><body>${nav}<main id="content" hx-history-elt>${content}</main><label>Persistent search <input id="search"></label><p id="feedback" role="status" aria-live="polite"></p></body></html>`;
const record={path:req.url,hx,history,status,held:false,released:false};requests.push(record);
const send=()=>{record.released=true;res.writeHead(status,{'Content-Type':'text/html'});res.end(status===503?'Injected failure':hx&&!history?content:full);};
const key=history?'history':u.searchParams.get('hold');const b=barriers.get(key);
if(b){record.held=true;b.release=send;b.resolve();}else send();
});
server.listen(0,'127.0.0.1');await once(server,'listening');const base=`http://127.0.0.1:${server.address().port}`;
const results=[],browsers={},output=process.env.FEP_RESULT_PATH||'/tmp/fep-ownership-result.json';
const inspect=p=>p.evaluate(()=>({topic:document.querySelector('#heading')?.textContent,url:location.pathname+location.search,title:document.title,focus:document.activeElement.id||document.activeElement.tagName,search:document.querySelector('#search').value,feedback:document.querySelector('#feedback').textContent}));
async function tabTo(p,id){for(let n=0;n<15;n++){if((await inspect(p)).focus===id)return;await p.keyboard.press('Tab');}throw Error(`Cannot Tab to ${id}`);}
async function activate(p,id){await tabTo(p,id);await p.keyboard.press('Enter');}
async function selected(p,t){await p.waitForFunction(t=>document.querySelector('#heading')?.textContent===t,t);await p.waitForFunction(t=>document.querySelector('#feedback').textContent===`Loaded ${t}`,t);}
async function released(p,b,history=false){b.release();b.release=null;await p.waitForFunction(history=>window.raceLog.some(e=>e.event==='loaded'&&(history?e.url.includes('topic=alpha'):e.url.includes('hold=old'))),history);}
async function held(b){await Promise.race([b.reached,new Promise((_,reject)=>setTimeout(()=>reject(Error('Server barrier not reached')),4000))]);}
function same(a,b,keys){return keys.every(k=>a[k]===b[k]);}
try{
for(const [engine,launcher]of [['chromium',chromium],['firefox',firefox]]){
const path=engine==='chromium'?process.env.FEP_CHROMIUM_PATH:undefined;const browser=await launcher.launch({headless:true,...(path?{executablePath:path}:{})});
browsers[engine]={version:browser.version(),executable:path||launcher.executablePath(),sha256:createHash('sha256').update(await readFile(path||launcher.executablePath())).digest('hex')};
try{for(const mode of ['managed','guarded']){
async function scenario(id,fn){
requests=[];barriers=new Map();const context=await browser.newContext();const p=await context.newPage();p.setDefaultTimeout(4500);
const r={id,engine,mode,status:'pass'};
try{await p.goto(base+`/articles?topic=alpha&mode=${mode}`);await p.waitForFunction(()=>window.htmx?.version==='2.0.11');Object.assign(r,await fn(p));r.obligationsMet=Object.values(r.obligations).every(Boolean);if(mode==='guarded')assert(r.obligationsMet,JSON.stringify(r.obligations));}
catch(e){r.status='fail';r.error=e.message;}
r.final=await inspect(p).catch(()=>null);r.events=await p.evaluate(()=>window.probe).catch(()=>[]);r.completions=await p.evaluate(()=>window.raceLog).catch(()=>[]);r.requests=[...requests];results.push(r);
for(const b of barriers.values())if(b.release){b.release();b.release=null;}await context.close();await writeFile(output,JSON.stringify({status:'incomplete',results},null,2)+'\n');
}
async function backMiss(p){await activate(p,'beta');await selected(p,'beta');await p.evaluate(()=>sessionStorage.removeItem('htmx-history-cache'));const b=barrier('history');await p.goBack();await held(b);return b;}
await scenario('O01-history-focus-moved',async p=>{const b=await backMiss(p);await tabTo(p,'search');await p.keyboard.type('new task');const before=await inspect(p);await released(p,b,true);const after=await inspect(p);return {before,after,obligations:{selection:after.topic==='alpha'&&new URL(base+after.url).searchParams.get('topic')==='alpha',title:after.title==='alpha — Catalog',focus:after.focus==='search',typedText:after.search==='new task'}};});
await scenario('O02-history-superseded',async p=>{const b=await backMiss(p);await activate(p,'new');await selected(p,'gamma');const before=await inspect(p);await released(p,b,true);const after=await inspect(p);return {before,after,obligations:{newerSelection:same(before,after,['topic','url','title']),noFeedbackOrFocusTheft:same(before,after,['feedback','focus'])}};});
await scenario('O03-old-success',async p=>{const b=barrier('old');await activate(p,'old');await held(b);await activate(p,'new');await selected(p,'gamma');const before=await inspect(p);await released(p,b);const after=await inspect(p);return {before,after,obligations:{newerSelection:same(before,after,['topic','url','title']),noFeedbackOrFocusTheft:same(before,after,['feedback','focus'])}};});
await scenario('O04-old-error',async p=>{const b=barrier('old');await activate(p,'oldfail');await held(b);await activate(p,'new');await selected(p,'gamma');const before=await inspect(p);await released(p,b);const after=await inspect(p);return {before,after,obligations:{newerSelection:same(before,after,['topic','url','title']),noObsoleteFeedback:same(before,after,['feedback','focus'])}};});
await scenario('O05-newer-fails',async p=>{const b=barrier('old');await activate(p,'old');await held(b);await activate(p,'newfail');await p.waitForFunction(()=>document.querySelector('#feedback').textContent.includes('Navigation failed'));const before=await inspect(p);assert.equal(before.topic,'alpha');await released(p,b);const after=await inspect(p);return {before,after,obligations:{committedView:same(before,after,['topic','url','title']),newestFailureRetained:same(before,after,['feedback','focus'])}};});
await scenario('O06-history-newer-than-request',async p=>{await activate(p,'beta');await selected(p,'beta');const b=barrier('old');await activate(p,'old');await held(b);await p.goBack();await selected(p,'alpha');const before=await inspect(p);await released(p,b);const after=await inspect(p);return {before,after,obligations:{historySelection:same(before,after,['topic','url','title']),historyContinuation:same(before,after,['feedback','focus'])}};});
}}finally{await browser.close();}
}
}finally{for(const b of barriers.values())if(b.release)b.release();await new Promise(resolve=>server.close(resolve));}
const files={};for(const f of ['ownership.mjs','ownership-policy.js','focus-policy.js','package.json','package-lock.json','node_modules/htmx.org/dist/htmx.js'])files[f]=createHash('sha256').update(await readFile(new URL(f,dir))).digest('hex');
const r={date:new Date().toISOString(),node:process.version,kind:'ordered-navigation-completion-races-in-authored-htmx-fixture',files,browsers,results,completed:results.filter(x=>x.status==='pass').length,total:results.length,obligationsMet:results.filter(x=>x.obligationsMet).length};await writeFile(output,JSON.stringify(r,null,2)+'\n');console.log(JSON.stringify({output,completed:r.completed,total:r.total,obligationsMet:r.obligationsMet,failures:results.filter(x=>x.status==='fail')},null,2));if(r.completed!==r.total)process.exitCode=1;

View file

@ -0,0 +1,31 @@
// Experimental adapter pinned to htmx 2.0.11; not a production router.
window.probe=[];
let generation=0,focusEpoch=0,historyMoveFocus=true;
const pending=new WeakMap();
document.addEventListener('focusin',()=>focusEpoch++);
window.addEventListener('popstate',()=>{generation++;},true);
const notice=text=>{document.querySelector('#feedback').textContent=text;};
function complete(move){const h=document.querySelector('#heading');document.title=`${h.textContent} — Catalog`;notice(`Loaded ${h.textContent}`);if(move)h.focus();}
document.addEventListener('htmx:beforeRequest',e=>{
if(e.detail.target.id==='content')pending.set(e.detail.xhr,{owner:++generation,source:e.detail.elt,move:false});
});
document.addEventListener('htmx:beforeOnLoad',e=>{
const state=pending.get(e.detail.xhr);
if(state&&state.owner!==generation){e.preventDefault();pending.delete(e.detail.xhr);window.raceLog.push({event:'discard-navigation',url:e.detail.xhr.responseURL});}
});
document.addEventListener('htmx:beforeSwap',e=>{const state=pending.get(e.detail.xhr);if(state)state.move=document.activeElement===state.source;});
document.addEventListener('htmx:afterSwap',e=>{const state=pending.get(e.detail.xhr);if(state){complete(state.move);pending.delete(e.detail.xhr);}});
document.addEventListener('htmx:responseError',e=>{if(pending.has(e.detail.xhr)){notice('Navigation failed. Current article retained. Use Native recovery to continue.');pending.delete(e.detail.xhr);}});
document.addEventListener('htmx:historyCacheHit',()=>{historyMoveFocus=true;});
document.addEventListener('htmx:historyCacheMiss',e=>{
const owner=generation,focusAtStart=focusEpoch,path=e.detail.path;
const xhr=e.detail.xhr,original=xhr.onload;
// The public miss event exposes XHR; wrapping its existing handler relies on the pinned implementation.
xhr.onload=function(event){
if(owner!==generation||location.pathname+location.search!==path){window.raceLog.push({event:'discard-history',url:xhr.responseURL});return;}
historyMoveFocus=focusEpoch===focusAtStart;
return original.call(this,event);
};
});
document.addEventListener('htmx:historyRestore',()=>complete(historyMoveFocus));
for(const name of ['htmx:afterSettle','htmx:historyRestore','htmx:responseError'])document.addEventListener(name,()=>window.probe.push({name,focus:document.activeElement.id||document.activeElement.tagName,title:document.title,url:location.href,feedback:document.querySelector('#feedback').textContent}));

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
import {chromium,firefox} from 'playwright';
import {createServer} from 'node:http';
import {once} from 'node:events';
import {readFile,writeFile} from 'node:fs/promises';
import {createHash} from 'node:crypto';
import assert from 'node:assert/strict';
const dir=new URL('.',import.meta.url);
const assets=new Map(await Promise.all([['/htmx.js','node_modules/htmx.org/dist/htmx.js'],['/baseline.js','focus-policy.js'],['/guarded.js','ownership-policy.js']].map(async([url,file])=>[url,await readFile(new URL(file,dir))])));
const trace=`window.raceLog=[];for(const name of ['htmx:beforeRequest','htmx:historyCacheMiss'])document.addEventListener(name,e=>{const xhr=e.detail.xhr;xhr.addEventListener('load',()=>window.raceLog.push({event:'loaded',url:xhr.responseURL,status:xhr.status}));});`;
let requests=[],barriers=new Map();
function barrier(key){let resolve;const reached=new Promise(r=>resolve=r);const b={reached,resolve,release:null};barriers.set(key,b);return b;}
const server=createServer((req,res)=>{
const u=new URL(req.url,'http://local');res.setHeader('Cache-Control','no-store');
if(assets.has(u.pathname)){res.setHeader('Content-Type','application/javascript');res.end(assets.get(u.pathname));return;}
if(u.pathname==='/trace.js'){res.setHeader('Content-Type','application/javascript');res.end(trace);return;}
if(u.pathname!=='/articles'){res.writeHead(404);res.end('Not found');return;}
const topic=['alpha','beta','gamma'].includes(u.searchParams.get('topic'))?u.searchParams.get('topic'):'alpha';
const mode=u.searchParams.get('mode')==='guarded'?'guarded':'managed';
const hx=req.headers['hx-request']==='true',history=req.headers['hx-history-restore-request']==='true';
const status=hx&&u.searchParams.has('fail')?503:200;
const content=`<div id="payload"><h1 id="heading" tabindex="-1">${topic}</h1><p>Article ${topic}</p></div>`;
const boost='hx-boost="true" hx-target="#content" hx-select="#payload" hx-swap="innerHTML"';
const href=(t,extra='')=>`/articles?topic=${t}&mode=${mode}${extra}`;
const nav=`<nav><a id="beta" href="${href('beta')}" ${boost}>Visit beta</a><a id="old" href="${href('beta','&hold=old')}" ${boost}>Slow beta</a><a id="oldfail" href="${href('beta','&hold=old&fail=1')}" ${boost}>Slow failed beta</a><a id="new" href="${href('gamma')}" ${boost}>Visit gamma</a><a id="newfail" href="${href('gamma','&fail=1')}" ${boost}>Failed gamma</a><a id="recover" href="${href('gamma')}">Native recovery</a></nav>`;
const full=`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${topic} — Catalog</title><meta name="htmx-config" content='{"historyRestoreAsHxRequest":false}'><script src="/trace.js" defer></script><script src="/${mode==='guarded'?'guarded':'baseline'}.js" defer></script><script src="/htmx.js" defer></script></head><body>${nav}<main id="content" hx-history-elt>${content}</main><label>Persistent search <input id="search"></label><p id="feedback" role="status" aria-live="polite"></p></body></html>`;
const record={path:req.url,hx,history,status,held:false,released:false};requests.push(record);
const send=()=>{record.released=true;res.writeHead(status,{'Content-Type':'text/html'});res.end(status===503?'Injected failure':hx&&!history?content:full);};
const key=history?'history':u.searchParams.get('hold');const b=barriers.get(key);
if(b){record.held=true;b.release=send;b.resolve();}else send();
});
server.listen(0,'127.0.0.1');await once(server,'listening');const base=`http://127.0.0.1:${server.address().port}`;
const results=[],browsers={},output=process.env.FEP_RESULT_PATH||'/tmp/fep-ownership-result.json';
const inspect=p=>p.evaluate(()=>({topic:document.querySelector('#heading')?.textContent,url:location.pathname+location.search,title:document.title,focus:document.activeElement.id||document.activeElement.tagName,search:document.querySelector('#search').value,feedback:document.querySelector('#feedback').textContent}));
async function tabTo(p,id){for(let n=0;n<15;n++){if((await inspect(p)).focus===id)return;const backwards=await p.evaluate(id=>{const a=document.activeElement,t=document.getElementById(id);return a!==document.body&&Boolean(a.compareDocumentPosition(t)&Node.DOCUMENT_POSITION_PRECEDING);},id);await p.keyboard.press(backwards?'Shift+Tab':'Tab');}throw Error(`Cannot Tab to ${id}`);}
async function activate(p,id){await tabTo(p,id);await p.keyboard.press('Enter');}
async function selected(p,t){await p.waitForFunction(t=>document.querySelector('#heading')?.textContent===t,t);await p.waitForFunction(t=>document.querySelector('#feedback').textContent===`Loaded ${t}`,t);}
async function released(p,b,history=false){b.release();b.release=null;await p.waitForFunction(history=>window.raceLog.some(e=>e.event==='loaded'&&(history?e.url.includes('topic=alpha'):e.url.includes('hold=old'))),history);}
async function held(b){await Promise.race([b.reached,new Promise((_,reject)=>setTimeout(()=>reject(Error('Server barrier not reached')),4000))]);}
function same(a,b,keys){return keys.every(k=>a[k]===b[k]);}
try{
for(const [engine,launcher]of [['chromium',chromium],['firefox',firefox]]){
const path=engine==='chromium'?process.env.FEP_CHROMIUM_PATH:undefined;const browser=await launcher.launch({headless:true,...(path?{executablePath:path}:{})});
browsers[engine]={version:browser.version(),executable:path||launcher.executablePath(),sha256:createHash('sha256').update(await readFile(path||launcher.executablePath())).digest('hex')};
try{for(const mode of ['managed','guarded']){
async function scenario(id,fn){
requests=[];barriers=new Map();const context=await browser.newContext();const p=await context.newPage();p.setDefaultTimeout(4500);
const r={id,engine,mode,status:'pass'};
try{await p.goto(base+`/articles?topic=alpha&mode=${mode}`);await p.waitForFunction(()=>window.htmx?.version==='2.0.11');Object.assign(r,await fn(p));r.obligationsMet=Object.values(r.obligations).every(Boolean);if(mode==='guarded')assert(r.obligationsMet,JSON.stringify(r.obligations));}
catch(e){r.status='fail';r.error=e.message;}
r.final=await inspect(p).catch(()=>null);r.events=await p.evaluate(()=>window.probe).catch(()=>[]);r.completions=await p.evaluate(()=>window.raceLog).catch(()=>[]);r.requests=[...requests];results.push(r);
for(const b of barriers.values())if(b.release){b.release();b.release=null;}await context.close();await writeFile(output,JSON.stringify({status:'incomplete',results},null,2)+'\n');
}
async function backMiss(p){await activate(p,'beta');await selected(p,'beta');await p.evaluate(()=>sessionStorage.removeItem('htmx-history-cache'));const b=barrier('history');await p.goBack();await held(b);return b;}
await scenario('O01-history-focus-moved',async p=>{const b=await backMiss(p);await tabTo(p,'search');await p.keyboard.type('new task');const before=await inspect(p);await released(p,b,true);const after=await inspect(p);return {before,after,obligations:{selection:after.topic==='alpha'&&new URL(base+after.url).searchParams.get('topic')==='alpha',title:after.title==='alpha — Catalog',focus:after.focus==='search',typedText:after.search==='new task'}};});
await scenario('O02-history-superseded',async p=>{const b=await backMiss(p);await activate(p,'new');await selected(p,'gamma');const before=await inspect(p);await released(p,b,true);const after=await inspect(p);return {before,after,obligations:{newerSelection:same(before,after,['topic','url','title']),noFeedbackOrFocusTheft:same(before,after,['feedback','focus'])}};});
await scenario('O03-old-success',async p=>{const b=barrier('old');await activate(p,'old');await held(b);await activate(p,'new');await selected(p,'gamma');const before=await inspect(p);await released(p,b);const after=await inspect(p);return {before,after,obligations:{newerSelection:same(before,after,['topic','url','title']),noFeedbackOrFocusTheft:same(before,after,['feedback','focus'])}};});
await scenario('O04-old-error',async p=>{const b=barrier('old');await activate(p,'oldfail');await held(b);await activate(p,'new');await selected(p,'gamma');const before=await inspect(p);await released(p,b);const after=await inspect(p);return {before,after,obligations:{newerSelection:same(before,after,['topic','url','title']),noObsoleteFeedback:same(before,after,['feedback','focus'])}};});
await scenario('O05-newer-fails',async p=>{const b=barrier('old');await activate(p,'old');await held(b);await activate(p,'newfail');await p.waitForFunction(()=>document.querySelector('#feedback').textContent.includes('Navigation failed'));const before=await inspect(p);assert.equal(before.topic,'alpha');await released(p,b);const after=await inspect(p);return {before,after,obligations:{committedView:same(before,after,['topic','url','title']),newestFailureRetained:same(before,after,['feedback','focus'])}};});
await scenario('O06-history-newer-than-request',async p=>{await activate(p,'beta');await selected(p,'beta');const b=barrier('old');await activate(p,'old');await held(b);await p.goBack();await selected(p,'alpha');const before=await inspect(p);await released(p,b);const after=await inspect(p);return {before,after,obligations:{historySelection:same(before,after,['topic','url','title']),historyContinuation:same(before,after,['feedback','focus'])}};});
}}finally{await browser.close();}
}
}finally{for(const b of barriers.values())if(b.release)b.release();await new Promise(resolve=>server.close(resolve));}
const files={};for(const f of ['ownership.mjs','ownership-policy.js','focus-policy.js','package.json','package-lock.json','node_modules/htmx.org/dist/htmx.js'])files[f]=createHash('sha256').update(await readFile(new URL(f,dir))).digest('hex');
const r={date:new Date().toISOString(),node:process.version,kind:'ordered-navigation-completion-races-in-authored-htmx-fixture',files,browsers,results,completed:results.filter(x=>x.status==='pass').length,total:results.length,obligationsMet:results.filter(x=>x.obligationsMet).length};await writeFile(output,JSON.stringify(r,null,2)+'\n');console.log(JSON.stringify({output,completed:r.completed,total:r.total,obligationsMet:r.obligationsMet,failures:results.filter(x=>x.status==='fail')},null,2));if(r.completed!==r.total)process.exitCode=1;