aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/newos4-maintain.py63
-rwxr-xr-xscripts/newos4-steer.py26
2 files changed, 77 insertions, 12 deletions
diff --git a/scripts/newos4-maintain.py b/scripts/newos4-maintain.py
index dd6c898..c28fb8b 100644
--- a/scripts/newos4-maintain.py
+++ b/scripts/newos4-maintain.py
@@ -21,22 +21,41 @@ def check_versions():
n = html.count(ver)
add('PASS' if n == 3 else 'FAIL', f'version {ver} x{n} in main.html (expect 3)')
-def check_bundles():
+def syntax_failures():
+ bad = []
+ for p in glob.glob(os.path.join(ROOT, 'modules/core/*.js')) + glob.glob(os.path.join(ROOT, 'modules/ext/*.js')):
+ if os.path.basename(p) in ('core-bundle.js', 'critical-ext-bundle.js'): continue
+ if run(['node', '--check', p]).returncode: bad.append(os.path.basename(p))
+ return bad
+
+def check_bundles(bad_syntax=None):
+ """Rebuild only when every module parses.
+
+ rebuild-bundles.py concatenates blindly (no validation), so a module with a
+ syntax error would be baked straight into the served core-bundle.js. Since
+ --fix now runs daily, the rebuild is gated on check_syntax and the produced
+ bundles are re-checked before the run is called clean.
+ """
paths = ('modules/core/core-bundle.js', 'modules/ext/critical-ext-bundle.js')
before = {p: sh(f'md5sum {p}') for p in paths}
if not FIX:
add('PASS', 'bundle check skipped (use --fix)'); return
+ if bad_syntax is None: bad_syntax = syntax_failures()
+ if bad_syntax:
+ add('FAIL', 'bundles rebuild BLOCKED: syntax fail in ' + ', '.join(bad_syntax[:5])); return
r = run([sys.executable, 'scripts/rebuild-bundles.py'], cwd=ROOT)
+ if r.returncode:
+ add('FAIL', 'bundles FAILED'); return
+ invalid = [p for p in paths if run(['node', '--check', os.path.join(ROOT, p)]).returncode]
+ if invalid:
+ add('FAIL', 'bundles rebuilt but INVALID: ' + ', '.join(invalid)); return
after = {p: sh(f'md5sum {p}') for p in paths}
changed = [p for p in paths if before[p] != after[p]]
- add('FAIL' if r.returncode else ('WARN' if changed else 'PASS'),
- 'bundles ' + ('FAILED' if r.returncode else ('rebuilt: ' + ', '.join(changed) if changed else 'clean')))
+ add('WARN' if changed else 'PASS',
+ 'bundles ' + ('rebuilt: ' + ', '.join(changed) if changed else 'clean'))
-def check_syntax():
- bad = []
- for p in glob.glob(os.path.join(ROOT, 'modules/core/*.js')) + glob.glob(os.path.join(ROOT, 'modules/ext/*.js')):
- if os.path.basename(p) in ('core-bundle.js', 'critical-ext-bundle.js'): continue
- if run(['node', '--check', p]).returncode: bad.append(os.path.basename(p))
+def check_syntax(bad=None):
+ bad = syntax_failures() if bad is None else bad
add('PASS' if not bad else 'FAIL', 'syntax ' + ('OK' if not bad else 'fail: ' + ', '.join(bad)))
def check_perms():
@@ -128,6 +147,31 @@ def check_help_coverage():
f'help coverage {len(cmds) - len(missing)}/{len(cmds)} commands'
+ ('' if not missing else f' — undocumented: {", ".join(missing[:8])}'))
+def check_regression_tests():
+ """Every node-runnable test under modules/test/ must pass (2026-10-27).
+
+ The v4.6.102 checkpoint regression was written but no job executed it, so drift could ship
+ silently. Two header conventions exist: '// ... run: node' and '// node modules/test/x.js'.
+ test-suite.js starts with a class (browser-only) and is correctly skipped.
+ """
+ tests, failed = [], []
+ for p in sorted(glob.glob(os.path.join(ROOT, 'modules/test/test-*.js'))):
+ head = ''.join(open(p, encoding='utf-8', errors='ignore').readlines()[:3])
+ if 'run: node' not in head and not head.startswith('// node modules/test/'):
+ continue
+ tests.append(p)
+ try:
+ r = subprocess.run(['node', p], cwd=ROOT, capture_output=True, text=True, timeout=120)
+ except subprocess.TimeoutExpired:
+ failed.append(os.path.basename(p) + ': timeout')
+ continue
+ if r.returncode != 0:
+ lines = (r.stdout or r.stderr).strip().splitlines() or ['no output']
+ failed.append(os.path.basename(p) + ': ' + lines[-1][:120])
+ add('PASS' if not failed else 'FAIL',
+ 'node tests %d/%d pass' % (len(tests) - len(failed), len(tests))
+ + ('' if not failed else ' — ' + '; '.join(failed[:3])))
+
def check_links():
if not DEEP: return
broken = []
@@ -143,7 +187,8 @@ def check_links():
def main():
os.chdir(ROOT)
- check_versions(); check_bundles(); check_syntax(); check_perms(); check_index(); check_data(); check_live(); check_atlas(); check_course(); check_help_coverage(); check_links()
+ bad = syntax_failures() # computed once, gates the bundle rebuild
+ check_versions(); check_syntax(bad); check_regression_tests(); check_bundles(bad); check_perms(); check_index(); check_data(); check_live(); check_atlas(); check_course(); check_help_coverage(); check_links()
fails = [m for l, m in issues if l == 'FAIL']
print('newOS4 maintenance — ' + datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%MZ') + (' [fix]' if FIX else '') + (' [deep]' if DEEP else ''))
for l, m in issues: print(f'{l}: {m}')
diff --git a/scripts/newos4-steer.py b/scripts/newos4-steer.py
index de3b87c..1e387f8 100755
--- a/scripts/newos4-steer.py
+++ b/scripts/newos4-steer.py
@@ -15,6 +15,7 @@ import argparse
import fcntl
import json
import os
+import subprocess
import time
ROOT = '/var/www/webx11.duckdns.org/spaces/newos4'
@@ -48,7 +49,8 @@ def main():
sub = ap.add_subparsers(dest='cmd', required=True)
a = sub.add_parser('add'); a.add_argument('text'); a.add_argument('--priority', default='normal', choices=['high', 'normal', 'low'])
sub.add_parser('list').add_argument('--all', action='store_true')
- d = sub.add_parser('done'); d.add_argument('id')
+ d = sub.add_parser('done'); d.add_argument('id'); d.add_argument('--commit', default='')
+ r = sub.add_parser('reopen'); r.add_argument('id'); r.add_argument('--reason', default='')
sub.add_parser('clear')
args = ap.parse_args()
@@ -76,11 +78,29 @@ def main():
elif args.cmd == 'done':
hit = [r for r in data if r.get('id') == args.id]
if not hit:
- raise SystemExit(f"no such id: {args.id}")
+ raise SystemExit("no such id: " + args.id)
+ if not args.commit:
+ raise SystemExit('refusing: --commit <sha> required - a written plan is not a ship')
+ chk = subprocess.run(['git', '-C', '/var/www/webx11.duckdns.org', 'cat-file', '-e',
+ args.commit + '^{commit}'], capture_output=True, text=True)
+ if chk.returncode != 0:
+ raise SystemExit('refusing: ' + args.commit + ' is not a commit in the repo')
hit[0]['done'] = True
hit[0]['done_at'] = time.strftime('%Y-%m-%dT%H:%M:%S+00:00', time.gmtime())
+ hit[0]['done_commit'] = args.commit
_save(data)
- print('done:', args.id)
+ print('done:', args.id, 'at', args.commit)
+ elif args.cmd == 'reopen':
+ hit = [r for r in data if r.get('id') == args.id]
+ if not hit:
+ raise SystemExit("no such id: " + args.id)
+ hit[0]['done'] = False
+ hit[0].pop('done_at', None)
+ hit[0].pop('done_commit', None)
+ hit[0]['reopened_at'] = time.strftime('%Y-%m-%dT%H:%M:%S+00:00', time.gmtime())
+ hit[0]['reopen_reason'] = (args.reason or '')[:300]
+ _save(data)
+ print('reopened:', args.id)
elif args.cmd == 'clear':
n = 0
for r in data: