From 9f9b604dd4531f47cf4db57baeaea67ad1ecbc0f Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli <318295322+StepenkoAnatoli@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:37:38 +0300 Subject: [PATCH] A missing Python is not a skip, it is UNSUP, and it blocks WHAT CHANGED test/canonical-float-policy.test.mjs 5 x `if (!python) return` -> requireCapability WHY The file I added in 90a22a9 returned early when no python was found, which prints `ok` having asserted nothing. harness.mjs already has the mechanism for this, and its docstring says exactly what I did wrong: "It is NOT a skip. A test that returns early on a missing prerequisite prints `ok` having asserted nothing, which is the same false green ADR-0021 exists to prevent, one layer up." Worst in this file of all places: the defect under test IS a Node/Python divergence, so a green run with no python validates nothing at all while claiming the two languages agree. Mine was the only silent skip in the repository. The three older Python tests assert `python.status === 0`, so a python-less host fails them - blunt, but never a false green. WHAT I VERIFIED node research-kit/bin/selftest.mjs -> 565 passed, 0 failed node research-kit/bin/preflight.mjs -> PASS, 0 blocking, 0 warnings Reproduced the missing-python condition by pointing the probe at an interpreter that does not exist: 5 UNSUP PYTHON-NOT-FOUND, blocking=5, exit 1 (selftest.mjs:56) 1 ok the structural scan, which needs no python and correctly still runs Attempted first by stripping PATH; on Windows the child still resolved python, so that reproduction was worthless and I replaced it with one that works. CI cannot hit this path: .github/workflows/offline-suite.yml pins actions/setup-python@v5 at 3.12 and runs `python --version` before the suite. WHAT I DID NOT CHANGE The three older conformance tests. They block correctly already; converting them is a reporting improvement, not a defect fix, and belongs in its own change rather than widening this one. Co-Authored-By: Claude Opus 5 --- .../test/canonical-float-policy.test.mjs | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/research-kit/test/canonical-float-policy.test.mjs b/research-kit/test/canonical-float-policy.test.mjs index 0bda3ec6..0c9c4657 100644 --- a/research-kit/test/canonical-float-policy.test.mjs +++ b/research-kit/test/canonical-float-policy.test.mjs @@ -16,7 +16,7 @@ // remembered to put in a packet. import { spawnSync } from 'node:child_process'; -import { test, describe, assert, fs, path, KIT_ROOT } from './harness.mjs'; +import { test, describe, assert, fs, path, KIT_ROOT, requireCapability } from './harness.mjs'; import { canonicalJson, sha256 } from '../lib/release-validator.mjs'; describe('canonical-float-policy'); @@ -24,7 +24,7 @@ describe('canonical-float-policy'); const COMMON = path.join(KIT_ROOT, 'bin', 'conformance_common.py'); /** Is there a usable python on this host? The Python runners are part of the contract. */ -const python = (() => { +const PYTHON = (() => { for (const exe of ['python', 'python3']) { const probe = spawnSync(exe, ['--version'], { encoding: 'utf8', timeout: 20_000, windowsHide: true }); if (!probe.error && probe.status === 0) return exe; @@ -32,6 +32,21 @@ const python = (() => { return null; })(); +/** + * A host without Python cannot run these tests, and that is NOT a skip. + * + * The first version of this file returned early instead, which printed `ok` having + * asserted nothing. That is precisely the false green `Unsupported` exists to prevent, + * and it was worst here of all places: the defect under test IS a Node/Python + * divergence, so a green run with no Python validates nothing at all while claiming the + * two languages agree. + * + * `requireCapability` reports it under its own label with a reason code, and it BLOCKS - + * `runPending` counts it and the runner exits non-zero. + */ +const python = () => requireCapability(PYTHON, 'PYTHON-NOT-FOUND', + 'no python on this host, so Node/Python canonical agreement cannot be checked'); + // A float cannot cross this boundary as JSON, and that nearly made these tests vacuous. // // The first version sent cases with `JSON.stringify`, so `{a: 1.0}` arrived in Python as @@ -74,7 +89,7 @@ function pythonCanonical(specs, policy) { ' out.append("ERROR:" + type(e).__name__)', 'sys.stdout.write(json.dumps(out))', ].join('\n'); - const run = spawnSync(python, ['-c', script, JSON.stringify(specs)], { + const run = spawnSync(PYTHON, ['-c', script, JSON.stringify(specs)], { encoding: 'utf8', timeout: 60_000, windowsHide: true, }); if (run.status !== 0) throw new Error(`python failed: ${run.stderr?.slice(0, 300)}`); @@ -109,7 +124,7 @@ function pythonTypes(specs) { ' out.append("float" if any(isinstance(x, float) for x in values) else "no-float")', 'sys.stdout.write(json.dumps(out))', ].join('\n'); - const run = spawnSync(python, ['-c', script, JSON.stringify(specs)], { + const run = spawnSync(PYTHON, ['-c', script, JSON.stringify(specs)], { encoding: 'utf8', timeout: 60_000, windowsHide: true, }); if (run.status !== 0) throw new Error(`python failed: ${run.stderr?.slice(0, 300)}`); @@ -135,7 +150,7 @@ const CASES = [ ]; test('POLICY normalize: Node and Python produce identical canonical bytes for floats', () => { - if (!python) return; // no python on this host + python(); // UNSUP, not a silent skip - see the note above // Every case must actually reach Python as a float, or this proves nothing. assert.deepEqual(pythonTypes(CASES), CASES.map(() => 'float'), @@ -150,7 +165,7 @@ test('POLICY normalize: Node and Python produce identical canonical bytes for fl }); test('POLICY normalize: identical bytes mean identical digests', () => { - if (!python) return; + python(); // UNSUP, not a silent skip - see the note above // The bytes are what get hashed, so this is the property the vectors actually rest on. const theirs = pythonCanonical(CASES, 'normalize'); @@ -161,7 +176,7 @@ test('POLICY normalize: identical bytes mean identical digests', () => { }); test('POLICY reject: a float is refused, not silently canonicalised', () => { - if (!python) return; + python(); // UNSUP, not a silent skip - see the note above // The ledger packets carry hashes and chain positions. A float there means the packet // is wrong, and refusing says so; canonicalising it would produce a plausible digest @@ -174,7 +189,7 @@ test('POLICY reject: a float is refused, not silently canonicalised', () => { }); test('POLICY reject: integers still pass under the strict policy', () => { - if (!python) return; + python(); // UNSUP, not a silent skip - see the note above // The strict policy must refuse floats without refusing the packets it exists to run. const values = [{ a: 1 }, { a: 0 }, { a: -5 }, { a: [1, 2, 3] }, { a: 'text' }, { a: null }]; @@ -184,7 +199,7 @@ test('POLICY reject: integers still pass under the strict policy', () => { }); test('the two policies are the only ones, and an unknown one is refused', () => { - if (!python) return; + python(); // UNSUP, not a silent skip - see the note above const result = pythonCanonical([{ a: 1 }], 'silently-guess'); assert.ok(String(result[0]).startsWith('ERROR:'),