Build independent Everon successor foundation and native regressions
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Archive an isolated native run and the exact current addon sources; never overwrite evidence.
|
||||
Run only after stopping the test and before editing/copying its source tree again.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import argparse,hashlib,json,shutil,subprocess,sys
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('session',type=Path)
|
||||
p.add_argument('name',help='New evidence directory name, e.g. native-2026-09-19-003231')
|
||||
a=p.parse_args()
|
||||
if Path(a.name).name!=a.name or a.name in {'.','..'}:p.error('Name must be one directory component')
|
||||
source=a.session.resolve()
|
||||
if not (source/'console.log').is_file():p.error('Session has no console.log')
|
||||
dest=ROOT/'docs/evidence'/a.name
|
||||
try:dest.mkdir(parents=True,exist_ok=False)
|
||||
except FileExistsError:p.error('Evidence already exists; refusing to overwrite')
|
||||
for name in ['console.log','script.log','error.log','rereintroduced-hq.log','headless-launch.log','runner.json']:
|
||||
if (source/name).is_file():shutil.copyfile(source/name,dest/(name+'.txt'))
|
||||
for capture in sorted(source.glob('err-*.png')):
|
||||
if capture.is_file():shutil.copyfile(capture,dest/capture.name)
|
||||
suffixes={'.c','.conf','.meta','.et','.ent','.layer','.gproj','.layout','.emat'}
|
||||
manifest={str(f.relative_to(ROOT)):hashlib.sha256(f.read_bytes()).hexdigest() for f in sorted((ROOT/'addons').rglob('*')) if f.is_file() and f.suffix in suffixes}
|
||||
(dest/'source-sha256.json').write_text(json.dumps(manifest,indent=2)+'\n')
|
||||
with (dest/'report.json').open('w') as report:
|
||||
result=subprocess.run([sys.executable,str(ROOT/'tools/native_report.py'),str((dest/'console.log.txt').relative_to(ROOT))],cwd=ROOT,stdout=report)
|
||||
print(f'Archived {dest.relative_to(ROOT)}; native report exit={result.returncode} (nonzero retains failures)')
|
||||
sys.exit(result.returncode)
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate addon/resource isolation and execute policy regressions. Not an engine compiler."""
|
||||
from pathlib import Path
|
||||
import json,re,subprocess,sys
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
BASE='58D0FB3206B6F859'
|
||||
def check():
|
||||
ids={};graph={};resources={};paths={}
|
||||
for p in sorted((ROOT/'addons').glob('*/addon.gproj')):
|
||||
text=p.read_text();g=re.search(r'GUID "([A-F0-9]{16})"',text)[1]
|
||||
assert g not in ids,f'Duplicate addon GUID: {p}'
|
||||
ids[g]=p.parent.name;graph[g]=re.findall(r'"([A-F0-9]{16})"',text[text.index('Dependencies'):])
|
||||
assert len(ids)==5,'Expected five addon projects'
|
||||
def walk(g,stack):
|
||||
assert g not in stack,'Dependency cycle: '+str(stack+[g])
|
||||
for dep in graph[g]:
|
||||
assert dep==BASE or dep in ids,f'External addon dependency {dep}'
|
||||
if dep!=BASE:walk(dep,stack+[g])
|
||||
for g in ids:walk(g,[])
|
||||
for p in (ROOT/'addons').rglob('*.meta'):
|
||||
m=re.search(r'Name "\{([A-F0-9]{16})\}([^"\n]+)"',p.read_text())
|
||||
assert m,f'Malformed metadata: {p}'
|
||||
g,path=m.groups();assert g not in resources,f'Duplicate resource GUID {g}'
|
||||
addon=p.relative_to(ROOT/'addons').parts[0]
|
||||
assert p.with_suffix('')==ROOT/'addons'/addon/path, f'Resource path mismatch: {p}'
|
||||
assert p.with_suffix('').is_file(),f'Missing resource for {p}'
|
||||
resources[g]=path;paths[path]=g
|
||||
vanilla=json.loads((ROOT/'docs/vanilla-resources.json').read_text())
|
||||
for p in (ROOT/'addons').rglob('*'):
|
||||
if not p.is_file() or p.suffix not in ['.c','.et','.conf','.ent','.layer','.layout','.emat']:continue
|
||||
s=p.read_text()
|
||||
assert not re.search(r'\b(?:ESCT\w*|SCRTC_\w+|SCRT_\w+|EAI_\w+)\b',s),f'Original ecosystem dependency: {p}'
|
||||
for g,path in re.findall(r'\{([A-F0-9]{16})\}([^"\n]+)',s):
|
||||
assert resources.get(g)==path or vanilla.get(path)==g,f'Unresolved/mismatched resource in {p}: {g} {path}'
|
||||
if p.suffix in ['.et','.conf','.ent','.layout','.emat']:
|
||||
assert p.with_name(p.name+'.meta').exists(),f'Missing metadata: {p}'
|
||||
print(f'PASS: {len(ids)} isolated addons, acyclic dependencies, {len(resources)} unique resources, exact vanilla references')
|
||||
for name in ['test_mission_rules.py','test_settings_migration.py','hq_network_regression.py','helicopter_availability_regression.py','mortar_salvo_regression.py']:
|
||||
subprocess.run([sys.executable,str(ROOT/'tests'/name)],check=True)
|
||||
print('All repository checks passed. Native compilation and multiplayer tests are separate requirements.')
|
||||
if __name__=='__main__':check()
|
||||
@@ -0,0 +1,40 @@
|
||||
// Session-local placement for Enfusion/Reforger test windows only.
|
||||
var targetName = "HDMI-A-1";
|
||||
var placing = false;
|
||||
function isTestWindow(w) {
|
||||
var app = String(w.resourceClass).toLowerCase();
|
||||
return app === "steam_app_1874910" || app.indexOf("armareforger") >= 0 || String(w.caption).indexOf("Enfusion Workbench") >= 0;
|
||||
}
|
||||
function place(w) {
|
||||
if (placing || !isTestWindow(w)) return;
|
||||
placing = true;
|
||||
var outputs = workspace.screens;
|
||||
for (var i = 0; i < outputs.length; i++) {
|
||||
if (outputs[i].name !== targetName) continue;
|
||||
w.fullScreen = false;
|
||||
w.setMaximize(false, false);
|
||||
workspace.sendClientToScreen(w, outputs[i]);
|
||||
var a = outputs[i].geometry;
|
||||
var g = w.frameGeometry;
|
||||
w.frameGeometry = {x: a.x + 20, y: a.y + 30,
|
||||
width: Math.min(g.width, a.width - 40), height: Math.min(g.height, a.height - 60)};
|
||||
print("ERRI_DISPLAY " + w.resourceClass + " -> " + w.output.name + " " + w.frameGeometry.x);
|
||||
placing = false;
|
||||
return;
|
||||
}
|
||||
w.minimized = true;
|
||||
placing = false;
|
||||
print("ERRI_DISPLAY target unavailable; test window minimized");
|
||||
}
|
||||
workspace.windowAdded.connect(function(w) {
|
||||
place(w);
|
||||
w.captionChanged.connect(function() { place(w); });
|
||||
w.frameGeometryChanged.connect(function() { place(w); });
|
||||
w.fullScreenChanged.connect(function() { place(w); });
|
||||
});
|
||||
var windows = workspace.windowList();
|
||||
// Existing user editors are deliberately left untouched.
|
||||
for (var i = 0; i < windows.length; i++) {
|
||||
if (String(windows[i].caption).indexOf("Rereintroduced") >= 0) place(windows[i]);
|
||||
}
|
||||
print("ERRI_DISPLAY guard active for " + targetName);
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run bounded native Workbench smoke tests inside a windowless Gamescope display.
|
||||
Requires Linux, gamescope, Proton, and a disposable COPY of a working Tools compat prefix.
|
||||
No desktop rules or existing Wine processes are changed. This is not a dedicated server.
|
||||
"""
|
||||
import argparse, json, os, shutil, signal, subprocess, time
|
||||
from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--steam', type=Path, default=Path.home()/'.local/share/Steam')
|
||||
p.add_argument('--compat', type=Path, required=True, help='Disposable cloned Proton compatdata directory outside Steam')
|
||||
p.add_argument('--profile', type=Path, required=True, help='Separate disposable successor Workbench profile')
|
||||
p.add_argument('--smoke', choices=['victory', 'lost-helicopter', 'player-death', 'radio-network', 'hq-response'], default='victory')
|
||||
p.add_argument('--rounds', type=int, default=2)
|
||||
p.add_argument('--timeout', type=int, default=360)
|
||||
a = p.parse_args()
|
||||
steam, compat, profile = a.steam.resolve(), a.compat.resolve(), a.profile.resolve()
|
||||
if not 1 <= a.rounds <= 10 or not 60 <= a.timeout <= 1800:
|
||||
p.error('Use 1..10 rounds and a 60..1800 second timeout')
|
||||
if compat.is_relative_to(steam) or not (compat/'pfx/system.reg').is_file():
|
||||
p.error('--compat must be a disposable cloned prefix outside the Steam installation')
|
||||
if profile.is_relative_to(ROOT) or profile.is_relative_to(steam):
|
||||
p.error('--profile must be outside source and Steam directories')
|
||||
if not shutil.which('gamescope') or not shutil.which('pactl'): p.error('gamescope and pactl are required')
|
||||
wb = steam/'steamapps/common/Arma Reforger Tools/Workbench'
|
||||
proton = steam/'steamapps/common/Proton - Experimental/proton'
|
||||
vanilla = steam/'steamapps/common/Arma Reforger/addons/data'
|
||||
if not proton.is_file() or not (wb/'ArmaReforgerWorkbenchSteamDiag.exe').is_file():
|
||||
p.error('Stable Reforger Tools and Proton Experimental must already be installed')
|
||||
profile.mkdir(parents=True, exist_ok=True)
|
||||
addons = profile/'addons'
|
||||
addons.mkdir(exist_ok=True)
|
||||
link = addons/'ArmaReforger'
|
||||
if not link.exists(): link.symlink_to(vanilla, target_is_directory=True)
|
||||
if link.resolve() != vanilla.resolve() or any(x.name not in {'ArmaReforger', 'saves'} for x in addons.iterdir()) or any((addons/'saves').rglob('*.gproj')):
|
||||
p.error('Test profile addons must contain only the intended vanilla link')
|
||||
profile_arg = 'Z:'+str(profile)
|
||||
# Refuse concurrency instead of terminating anybody else's editor.
|
||||
for proc in Path('/proc').iterdir():
|
||||
if not proc.name.isdigit(): continue
|
||||
try:
|
||||
args = (proc/'cmdline').read_bytes().split(b'\0')
|
||||
if profile_arg.encode() in args and any(x.endswith(b'ArmaReforgerWorkbenchSteamDiag.exe') for x in args):
|
||||
p.error('Another Workbench already uses this test profile')
|
||||
except (FileNotFoundError, PermissionError, ProcessLookupError): pass
|
||||
command = ['gamescope', '--backend', 'headless', '-W', '1280', '-H', '720', '-w', '1280', '-h', '720', '-r', '30', '--', str(proton), 'run', str(wb/'ArmaReforgerWorkbenchSteamDiag.exe'), '-gproj', 'Z:'+str(ROOT/'addons/chat/addon.gproj'), '-addonsDir', 'Z:'+str(ROOT/'addons'), '-profile', profile_arg, '-logconsole', '-wbModule=WorldEditor', '-load', 'Worlds/ERR_Everon.ent', '-plugin=ERR_ValidateWorldPlugin', '-errPlay', '1', '-errSmoke', '1', '-errSmokeOutcome', a.smoke, '-noThrow']
|
||||
env = os.environ.copy()
|
||||
env.update(STEAM_COMPAT_DATA_PATH=str(compat), STEAM_COMPAT_CLIENT_INSTALL_PATH=str(steam), WINEPREFIX=str(compat/'pfx'))
|
||||
# A test-only sink prevents startup sounds; a stream check also catches a saved
|
||||
# explicit Wine device selection. Never change the user's default sink.
|
||||
silent_sink = 'erri_tests_silent'
|
||||
sinks = json.loads(subprocess.check_output(['pactl', '-f', 'json', 'list', 'short', 'sinks'], text=True))
|
||||
if not any(sink['name'] == silent_sink for sink in sinks):
|
||||
subprocess.run(['pactl', 'load-module', 'module-null-sink', 'sink_name='+silent_sink, 'sink_properties=device.description=ERRI_Silent_Tests'], check=True, stdout=subprocess.DEVNULL)
|
||||
subprocess.run(['pactl', 'set-sink-mute', silent_sink, '1'], check=True)
|
||||
subprocess.run(['pactl', 'set-sink-volume', silent_sink, '0%'], check=True)
|
||||
sinks = json.loads(subprocess.check_output(['pactl', '-f', 'json', 'list', 'short', 'sinks'], text=True))
|
||||
silent_index = next(sink['index'] for sink in sinks if sink['name'] == silent_sink)
|
||||
env.update(PULSE_SINK=silent_sink, PULSE_PROP='application.id=org.escapistsrereintroduced.tests')
|
||||
muted_streams = set()
|
||||
def silence_test_streams():
|
||||
streams = json.loads(subprocess.check_output(['pactl', '-f', 'json', 'list', 'sink-inputs'], text=True))
|
||||
for stream in streams:
|
||||
props = stream.get('properties', {})
|
||||
owned = props.get('application.id') == 'org.escapistsrereintroduced.tests'
|
||||
pid = props.get('application.process.id', '')
|
||||
if not owned and pid.isdigit():
|
||||
try:
|
||||
owned = ('WINEPREFIX='+str(compat/'pfx')).encode() in (Path('/proc')/pid/'environ').read_bytes().split(b'\0')
|
||||
except (FileNotFoundError, PermissionError, ProcessLookupError): pass
|
||||
if not owned: continue
|
||||
index = str(stream['index'])
|
||||
if stream.get('sink') != silent_index:
|
||||
subprocess.run(['pactl', 'move-sink-input', index, silent_sink], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if not stream.get('mute'):
|
||||
subprocess.run(['pactl', 'set-sink-input-mute', index, '1'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if index not in muted_streams:
|
||||
muted_streams.add(index)
|
||||
print('Test audio routed to silent output and muted (stream '+index+')', flush=True)
|
||||
started = time.time()
|
||||
log_root = profile/'logs'
|
||||
previous = set(log_root.glob('logs_*')) if log_root.exists() else set()
|
||||
result, session, complete_at = 'timeout', None, None
|
||||
with (profile/'headless-launch.log').open('w') as output:
|
||||
child = subprocess.Popen(command, cwd=wb, env=env, stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
try:
|
||||
while time.time()-started < a.timeout:
|
||||
silence_test_streams()
|
||||
candidates = [x for x in log_root.glob('logs_*') if x not in previous]
|
||||
if candidates:
|
||||
session = max(candidates, key=lambda x:x.stat().st_mtime)
|
||||
console = session/'console.log'
|
||||
content = console.read_text(errors='replace') if console.exists() else ''
|
||||
if 'ERR SMOKE FAIL:' in content or 'Virtual Machine Exception' in content or 'Can\'t compile "Game"' in content:
|
||||
result = 'test-failed'; break
|
||||
if content.count('ERR SMOKE COMPLETE:') >= a.rounds:
|
||||
if complete_at is None: complete_at = time.time()
|
||||
if time.time()-complete_at >= 6: result = 'smoke-completed'; break
|
||||
if child.poll() is not None: result = 'process-exited'; break
|
||||
time.sleep(1)
|
||||
finally:
|
||||
# Only this fresh process group belongs to the runner and disposable prefix.
|
||||
try: os.killpg(child.pid, signal.SIGTERM)
|
||||
except ProcessLookupError: pass
|
||||
try: child.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(child.pid, signal.SIGKILL); child.wait()
|
||||
summary = {'result':result, 'session':str(session) if session else None, 'rounds_required':a.rounds, 'display':'headless Gamescope 1280x720/30Hz', 'dedicated_server':False, 'audio':'silent sink and per-test-stream mute'}
|
||||
if session:
|
||||
(session/'runner.json').write_text(json.dumps(summary, indent=2)+'\n')
|
||||
shutil.copyfile(profile/'headless-launch.log', session/'headless-launch.log')
|
||||
print(json.dumps(summary, indent=2), flush=True)
|
||||
if result != 'smoke-completed': raise SystemExit(1)
|
||||
# All native errors still fail the report even when the scripted sequence completed.
|
||||
raise SystemExit(subprocess.call(['python3', str(ROOT/'tools/native_report.py'), str(session/'console.log'), '--require-completions', str(a.rounds)]))
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Import supported legacy settings without modifying either source or an existing destination."""
|
||||
import argparse,json,os,tempfile,sys
|
||||
from pathlib import Path
|
||||
SCHEMA={
|
||||
'reportIntervalSeconds':(10,5,60),
|
||||
'supportSquadLimit':(4,0,8),
|
||||
'responseRadiusMetres':(2000,500,5000),
|
||||
'aiIntelChancePercent':(60,0,100),
|
||||
'intelRevealChancePercent':(15,0,100),
|
||||
'radioDefenceSeconds':(180,30,1800),
|
||||
}
|
||||
AI_KEYS=('reportIntervalSeconds','supportSquadLimit','responseRadiusMetres')
|
||||
INTEL_KEYS=('aiIntelChancePercent','intelRevealChancePercent')
|
||||
def merge(ai=None,intel=None):
|
||||
result={key:spec[0] for key,spec in SCHEMA.items()};deferred=[]
|
||||
for label,data,keys in [('ai',ai,AI_KEYS),('intel',intel,INTEL_KEYS)]:
|
||||
if data is None:continue
|
||||
if not isinstance(data,dict):raise ValueError(label+' settings must be a JSON object')
|
||||
if 'configVersion' in data and (type(data['configVersion']) not in (int,float) or data['configVersion']!=1):
|
||||
raise ValueError(label+' configVersion must be 1')
|
||||
for key in keys:
|
||||
if key not in data:continue
|
||||
value=data[key];_,lo,hi=SCHEMA[key]
|
||||
if type(value) not in (int,float) or not lo<=value<=hi or int(value)!=value:
|
||||
raise ValueError(f'{key} must be an integer in [{lo},{hi}]')
|
||||
result[key]=int(value)
|
||||
deferred.extend(label+':'+key for key in data if key not in keys and key!='configVersion')
|
||||
return result,deferred
|
||||
|
||||
def write_new(path,text):
|
||||
path=Path(path)
|
||||
with tempfile.NamedTemporaryFile(mode='w',dir=path.parent,prefix='.rereintroduced-',delete=False) as f:
|
||||
tmp=Path(f.name)
|
||||
try:
|
||||
f.write(text);f.flush();os.fsync(f.fileno())
|
||||
# Atomic installation that cannot overwrite an existing file, even in a race.
|
||||
os.link(tmp,path)
|
||||
finally:tmp.unlink(missing_ok=True)
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--ai',type=Path,help='Read-only escapists-ai.json source')
|
||||
p.add_argument('--intel',type=Path,help='Read-only escapists-intel.json source')
|
||||
p.add_argument('--out',type=Path,help='New rereintroduced.json; omit to preview on stdout')
|
||||
a=p.parse_args()
|
||||
if not a.ai and not a.intel:p.error('Supply --ai and/or --intel')
|
||||
try:
|
||||
result,deferred=merge(json.loads(a.ai.read_text()) if a.ai else None,json.loads(a.intel.read_text()) if a.intel else None)
|
||||
text=json.dumps(result,indent=2,allow_nan=False)+'\n'
|
||||
if a.out:write_new(a.out,text)
|
||||
else:print(text,end='')
|
||||
if deferred:print('Settings awaiting controller migration (source files retained): '+', '.join(deferred),file=sys.stderr)
|
||||
except (ValueError,OSError) as e:p.error(str(e))
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Print native test commands; never launch a window implicitly."""
|
||||
import argparse,json,subprocess
|
||||
from pathlib import Path
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('mode',choices=['workbench','server'])
|
||||
p.add_argument('--exe',required=True,type=Path)
|
||||
p.add_argument('--profile',required=True,type=Path)
|
||||
p.add_argument('--smoke',choices=['victory','lost-helicopter','player-death', 'radio-network', 'hq-response'],help='Opt-in Workbench test; use a disposable profile')
|
||||
p.add_argument('--execute',action='store_true',help='Deliberately execute the command; Workbench opens a window')
|
||||
p.add_argument('--visible-output',help='User-approved spare display; required when executing Workbench')
|
||||
a=p.parse_args()
|
||||
if a.smoke and a.mode!='workbench':p.error('Scripted smoke is compiled only for Workbench')
|
||||
if a.execute and a.mode=='workbench' and not a.visible_output:
|
||||
p.error('Workbench opens windows. Select/configure a spare display and supply --visible-output first.')
|
||||
command=[str(a.exe),'-addonsDir',str(ROOT/'addons'),'-profile',str(a.profile),'-logconsole']
|
||||
if a.mode=='workbench':
|
||||
command+=['-gproj',str(ROOT/'addons/chat/addon.gproj'),'-wbModule=WorldEditor','-load','Worlds/ERR_Everon.ent']
|
||||
if a.smoke:command+=['-plugin','ERR_ValidateWorldPlugin','-errPlay','1','-errSmoke','1','-errSmokeOutcome',a.smoke]
|
||||
else:
|
||||
if 'server' not in a.exe.name.lower():p.error('Use an actual dedicated server binary for background tests.')
|
||||
command+=['-addons','ERR_Chat','-server','Worlds/ERR_Everon.ent','-maxFPS','60','-logStats','1000']
|
||||
print(json.dumps(command,indent=2))
|
||||
if a.execute:
|
||||
if not a.exe.is_file():p.error('Executable not found')
|
||||
# This helper does not move windows or change desktop settings.
|
||||
raise SystemExit(subprocess.call(command))
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Summarize native smoke evidence without confusing it with gameplay acceptance."""
|
||||
from pathlib import Path
|
||||
import argparse,json,re,sys
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('log',type=Path)
|
||||
p.add_argument('--require-completions',type=int,default=1)
|
||||
a=p.parse_args();s=a.log.read_text(errors='replace')
|
||||
completed=s.count('ERR SMOKE COMPLETE:')
|
||||
failures=[l.strip() for l in s.splitlines() if 'ERR SMOKE FAIL:' in l or 'Virtual Machine Exception' in l or re.search(r'\bSCRIPT\s+\(E\)',l) or "won't take damage and wont replicate" in l]
|
||||
report={'log':str(a.log),'smoke_completions':completed,'assertions_passed':len(re.findall('ERR SMOKE PASS:',s)),'script_or_runtime_failures':failures,'engine_errors':[l.strip() for l in s.splitlines() if re.search(r'\b(?:SCRIPT|WORLD|DEFAULT|RESOURCES|ENTITY|NETWORK|ENGINE)\s+\(E\)',l)],'missing_end_screen':[l.strip() for l in s.splitlines() if 'Could not find' in l and 'endscreen' in l],'scope':'scripted local Workbench integration only; no multiplayer, piloting or FPS claim'}
|
||||
print(json.dumps(report,indent=2))
|
||||
sys.exit(0 if completed>=a.require_completions and not failures and not report['engine_errors'] and not report['missing_end_screen'] else 1)
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Snapshot/verify reference sources without writing to the reference workspace."""
|
||||
import argparse,hashlib,json,sys
|
||||
from pathlib import Path
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('mode',choices=['snapshot','verify'])
|
||||
p.add_argument('reference',type=Path)
|
||||
p.add_argument('manifest',type=Path)
|
||||
a=p.parse_args();ref=a.reference.resolve();manifest=a.manifest.resolve()
|
||||
if manifest.is_relative_to(ref):p.error('Manifest must be outside the read-only reference')
|
||||
suffixes={'.c','.et','.ent','.layer','.conf','.gproj','.meta','.layout','.emat','.md','.py'}
|
||||
current={str(f.relative_to(ref)):hashlib.sha256(f.read_bytes()).hexdigest() for f in sorted(ref.rglob('*')) if f.is_file() and f.suffix in suffixes and not any(part in {'.git','.agents','.codex','__pycache__'} for part in f.relative_to(ref).parts)}
|
||||
if a.mode=='snapshot':
|
||||
if manifest.exists():p.error('Refusing to overwrite an existing baseline')
|
||||
manifest.write_text(json.dumps(current,indent=2)+'\n');print(f'Recorded {len(current)} reference source hashes')
|
||||
else:
|
||||
before=json.loads(manifest.read_text())
|
||||
changed=sorted(k for k in before.keys()|current.keys() if before.get(k)!=current.get(k))
|
||||
if changed:
|
||||
print('Reference changes detected; no files were reverted:');print('\n'.join(changed));sys.exit(1)
|
||||
print(f'PASS: {len(current)} reference sources unchanged')
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Refuse release qualification while acceptance evidence is incomplete."""
|
||||
import json,sys
|
||||
from pathlib import Path
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
ledger=json.loads((ROOT/'docs/acceptance.json').read_text())
|
||||
blocked=[]
|
||||
for name,case in ledger['cases'].items():
|
||||
if case['status']!='passed' or not case['evidence']:
|
||||
blocked.append(name)
|
||||
continue
|
||||
for evidence in case['evidence']:
|
||||
path=(ROOT/evidence).resolve()
|
||||
if not path.is_relative_to(ROOT) or not path.is_file():blocked.append(name);break
|
||||
if blocked:
|
||||
print('Not release-qualified; missing passed evidence:')
|
||||
for name in blocked:print(' - '+name)
|
||||
sys.exit(1)
|
||||
print('Acceptance ledger complete. Review provenance, native packages and publication metadata before release.')
|
||||
Reference in New Issue
Block a user