@heretek-ai/epistemic-swarm 0.7.21 → 0.7.23
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/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/bin/cli.js +3 -2
- package/extensions/pi/index.js +3 -2
- package/package.json +5 -1
- package/plugins/antigravity/plugin.json +1 -1
- package/plugins/factory/skills/factory/SKILL.md +1 -1
- package/plugins/opencode/analysis-bridge.js +566 -0
- package/plugins/opencode/config-io.js +598 -31
- package/plugins/opencode/dep-health.js +1039 -0
- package/plugins/opencode/hook-bus.js +434 -0
- package/plugins/opencode/index.js +252 -19
- package/plugins/opencode/tui.js +230 -17
- package/plugins/opencode/weight-baseline.json +16 -0
- package/plugins/opencode/weight-signals.js +769 -0
- package/plugins/research-cache/skills/research_cache/hasher.py +33 -8
- package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
- package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
- package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
- package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
- package/runner/__pycache__/errors.cpython-311.pyc +0 -0
- package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
- package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
- package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
- package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
- package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
- package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
- package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
- package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
- package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
- package/runner/mcp_server.py +249 -81
- package/runner/refinement.py +5 -0
- package/runner/schema_validate.py +63 -1
- package/runner/schemas.py +9 -4
- package/runner/tests/__pycache__/test_analysis_bridge.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dep_health.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_hardening_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_hook_bus.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_phase08_waiver_reopens.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_weight_signals.cpython-311.pyc +0 -0
- package/runner/tests/fixtures/config_validation_cases.json +60 -0
- package/runner/tests/test_analysis_bridge.py +356 -0
- package/runner/tests/test_config_schema.py +119 -15
- package/runner/tests/test_dep_health.py +484 -0
- package/runner/tests/test_hardening_contract.py +391 -0
- package/runner/tests/test_hook_bus.py +802 -0
- package/runner/tests/test_opencode_ux.py +232 -0
- package/runner/tests/test_opencode_v2_registrars.py +548 -16
- package/runner/tests/test_phase08_waiver_reopens.py +539 -0
- package/runner/tests/test_weight_signals.py +361 -0
- package/schemas/config.schema.json +4 -3
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/factory/SKILL.md +1 -1
- package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
- package/skills/research_cache/hasher.py +33 -8
- package/skills/swarm_config/SKILL.md +6 -1
- package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
- package/skills/swarm_config/configure.py +254 -17
|
@@ -300,36 +300,373 @@ class TestConfigValidatorParity(unittest.TestCase):
|
|
|
300
300
|
names = {c["name"] for c in cases}
|
|
301
301
|
self.assertIn("vert_domain_pack_empty", names)
|
|
302
302
|
self.assertIn("vert_searxng_url_missing_scheme", names)
|
|
303
|
+
# R3: the integral-float-in-integer-field case must be in the corpus,
|
|
304
|
+
# or parity would be vacuous for int-vs-float typing.
|
|
305
|
+
self.assertIn("type_integral_float_in_integer_field", names)
|
|
303
306
|
|
|
304
307
|
by_name = self._js_verdicts(cases)
|
|
308
|
+
# R3: lexeme-sensitive cases route the JS verdict through the TEXT
|
|
309
|
+
# entrypoint (`validateConfigText`), where the `2.0` lexeme survives.
|
|
310
|
+
# The object entrypoint (`validateConfig`) is lexeme-blind by design
|
|
311
|
+
# (live JS numbers carry no int/float distinction) and keeps its old
|
|
312
|
+
# verdict — see test_r3_integer_field_lexeme_typing for the boundary.
|
|
313
|
+
sensitive = {
|
|
314
|
+
c["name"]: json.dumps(c["config"])
|
|
315
|
+
for c in cases
|
|
316
|
+
if c.get("lexeme_sensitive")
|
|
317
|
+
}
|
|
318
|
+
text_verdicts = self._js_text_verdicts(sensitive) if sensitive else {}
|
|
305
319
|
matches = 0
|
|
306
320
|
for case in cases:
|
|
307
321
|
with self.subTest(case=case["name"]):
|
|
308
322
|
py_problems = validate(case["config"], CONFIG)
|
|
309
323
|
py_valid = not py_problems
|
|
310
|
-
|
|
324
|
+
if case["name"] in text_verdicts:
|
|
325
|
+
js_valid = text_verdicts[case["name"]]["valid"]
|
|
326
|
+
js_problems = text_verdicts[case["name"]]["problems"]
|
|
327
|
+
else:
|
|
328
|
+
js = by_name[case["name"]]
|
|
329
|
+
js_valid = js["valid"]
|
|
330
|
+
js_problems = js["problems"]
|
|
311
331
|
self.assertEqual(
|
|
312
|
-
|
|
332
|
+
js_valid,
|
|
313
333
|
py_valid,
|
|
314
|
-
f"{case['name']}: js={
|
|
334
|
+
f"{case['name']}: js={js_problems} py={py_problems}",
|
|
315
335
|
)
|
|
316
336
|
self.assertEqual(
|
|
317
|
-
|
|
337
|
+
js_valid,
|
|
318
338
|
case["valid"],
|
|
319
339
|
f"{case['name']}: both validators disagree with the fixture",
|
|
320
340
|
)
|
|
321
341
|
if not case["valid"] and case.get("expect"):
|
|
322
342
|
self.assertTrue(
|
|
323
|
-
any(case["expect"] in p for p in
|
|
324
|
-
f"{case['name']}: {case['expect']!r} not in {
|
|
343
|
+
any(case["expect"] in p for p in js_problems),
|
|
344
|
+
f"{case['name']}: {case['expect']!r} not in {js_problems}",
|
|
325
345
|
)
|
|
326
346
|
self.assertTrue(
|
|
327
347
|
any(case["expect"] in p for p in py_problems),
|
|
328
348
|
f"{case['name']}: {case['expect']!r} not in {py_problems}",
|
|
329
349
|
)
|
|
330
|
-
matches +=
|
|
350
|
+
matches += js_valid == py_valid
|
|
331
351
|
self.assertEqual(matches, len(cases), "parity must be 100%")
|
|
332
352
|
|
|
353
|
+
def _js_text_verdicts(self, case_texts):
|
|
354
|
+
"""JS verdicts via the lexeme-aware text entrypoint (R3 routing)."""
|
|
355
|
+
import tempfile
|
|
356
|
+
|
|
357
|
+
with tempfile.NamedTemporaryFile(
|
|
358
|
+
"w", suffix=".json", delete=False, encoding="utf-8"
|
|
359
|
+
) as f:
|
|
360
|
+
json.dump(case_texts, f)
|
|
361
|
+
tmp = f.name
|
|
362
|
+
try:
|
|
363
|
+
res = run_node(
|
|
364
|
+
f"""
|
|
365
|
+
import fs from "node:fs";
|
|
366
|
+
import {{ validateConfigText }} from "./plugins/opencode/config-io.js";
|
|
367
|
+
const cases = JSON.parse(fs.readFileSync({tmp!r}, "utf-8"));
|
|
368
|
+
const out = Object.entries(cases).map(([name, text]) => {{
|
|
369
|
+
const problems = validateConfigText(text);
|
|
370
|
+
return {{name, valid: problems.length === 0, problems}};
|
|
371
|
+
}});
|
|
372
|
+
console.log(JSON.stringify({{results: out}}));
|
|
373
|
+
"""
|
|
374
|
+
)
|
|
375
|
+
finally:
|
|
376
|
+
Path(tmp).unlink(missing_ok=True)
|
|
377
|
+
self.assertEqual(res.returncode, 0, f"js validator run failed: {res.stderr}")
|
|
378
|
+
return {r["name"]: r for r in last_json_object(res.stdout)["results"]}
|
|
379
|
+
|
|
380
|
+
def test_r2_object_path_float_rendering(self):
|
|
381
|
+
"""R2: P2 holds on the path saves use (`validateConfig`, no lexemes).
|
|
382
|
+
|
|
383
|
+
An integral JS number in a float/`number`-context node renders
|
|
384
|
+
`2.0`-style (matching Python `{data!r}` for float values); in an
|
|
385
|
+
integer-context node it renders `2`. Rendering only — verdicts
|
|
386
|
+
unchanged.
|
|
387
|
+
"""
|
|
388
|
+
from runner.schema_validate import validate
|
|
389
|
+
from runner.schemas import CONFIG
|
|
390
|
+
|
|
391
|
+
res = run_node(
|
|
392
|
+
"""
|
|
393
|
+
import { validateConfig } from "./plugins/opencode/config-io.js";
|
|
394
|
+
const floatMsg = validateConfig({ divergence_threshold: 2.0 });
|
|
395
|
+
const intMsg = validateConfig({ cache_ttl_days: 2 });
|
|
396
|
+
const intBad = validateConfig({ cache_ttl_days: -1.5 });
|
|
397
|
+
console.log(JSON.stringify({ floatMsg, intMsg, intBad }));
|
|
398
|
+
"""
|
|
399
|
+
)
|
|
400
|
+
self.assertEqual(res.returncode, 0, res.stderr)
|
|
401
|
+
d = last_json_object(res.stdout)
|
|
402
|
+
# The tiebreak repro, byte-exact on the save path.
|
|
403
|
+
self.assertEqual(
|
|
404
|
+
d["floatMsg"],
|
|
405
|
+
["divergence_threshold: value 2.0 is above maximum 1.0"],
|
|
406
|
+
)
|
|
407
|
+
self.assertEqual(
|
|
408
|
+
d["floatMsg"],
|
|
409
|
+
validate({"divergence_threshold": 2.0}, CONFIG),
|
|
410
|
+
)
|
|
411
|
+
# Integer context still renders `2`, and stays valid.
|
|
412
|
+
self.assertEqual(d["intMsg"], [])
|
|
413
|
+
# Fractional float in an integer field still rejected as float.
|
|
414
|
+
self.assertEqual(
|
|
415
|
+
d["intBad"],
|
|
416
|
+
validate({"cache_ttl_days": -1.5}, CONFIG),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
def test_r3_integer_field_lexeme_typing(self):
|
|
420
|
+
"""R3: text-path uses the lexeme for int-vs-float typing.
|
|
421
|
+
|
|
422
|
+
`{"cache_ttl_days": 2.0}` text is REJECTED like Python (`expected
|
|
423
|
+
integer|null, got float`); `{"cache_ttl_days": 2}` text stays valid.
|
|
424
|
+
The object path keeps its old verdict (lexemes unavailable there —
|
|
425
|
+
documented boundary, not a bug).
|
|
426
|
+
"""
|
|
427
|
+
from runner.schema_validate import validate
|
|
428
|
+
from runner.schemas import CONFIG
|
|
429
|
+
|
|
430
|
+
res = run_node(
|
|
431
|
+
"""
|
|
432
|
+
import { validateConfig, validateConfigText } from "./plugins/opencode/config-io.js";
|
|
433
|
+
console.log(JSON.stringify({
|
|
434
|
+
textFloat: validateConfigText('{"cache_ttl_days": 2.0}'),
|
|
435
|
+
textInt: validateConfigText('{"cache_ttl_days": 2}'),
|
|
436
|
+
objectFloat: validateConfig({ cache_ttl_days: 2.0 }),
|
|
437
|
+
}));
|
|
438
|
+
"""
|
|
439
|
+
)
|
|
440
|
+
self.assertEqual(res.returncode, 0, res.stderr)
|
|
441
|
+
d = last_json_object(res.stdout)
|
|
442
|
+
self.assertEqual(
|
|
443
|
+
d["textFloat"],
|
|
444
|
+
["cache_ttl_days: expected integer|null, got float"],
|
|
445
|
+
)
|
|
446
|
+
self.assertEqual(
|
|
447
|
+
d["textFloat"],
|
|
448
|
+
validate({"cache_ttl_days": 2.0}, CONFIG),
|
|
449
|
+
)
|
|
450
|
+
self.assertEqual(d["textInt"], [])
|
|
451
|
+
# Boundary: the object path cannot see the `2.0` lexeme (live JS
|
|
452
|
+
# number `2`), so it still accepts — verdicts unchanged there.
|
|
453
|
+
# WAIVER W21 (F3): this acceptance is by design; see `typeMatches`
|
|
454
|
+
# in plugins/opencode/config-io.js and .roadmap/06-parity/waivers.md.
|
|
455
|
+
self.assertEqual(d["objectFloat"], [])
|
|
456
|
+
|
|
457
|
+
def test_w20_object_path_number_context_float_rendering_waiver(self):
|
|
458
|
+
"""W20 (F1) WAIVER: object-path int in a number field renders float-style.
|
|
459
|
+
|
|
460
|
+
WAIVER, not a bug: live JS `2` === `2.0` (one double), while Python
|
|
461
|
+
`int(2)` vs `float(2.0)` render `2` vs `2.0` — the message ambiguity
|
|
462
|
+
is INFORMATION-THEORETIC. R2 float-direction rendering is kept by
|
|
463
|
+
design (`validateConfig({divergence_threshold: 2})` reads `2.0`;
|
|
464
|
+
verdicts agree with Python: both reject). The text path
|
|
465
|
+
(`validateConfigText`) is the exact surface. See `pyReprData` in
|
|
466
|
+
plugins/opencode/config-io.js and .roadmap/06-parity/waivers.md W20.
|
|
467
|
+
TRIGGER: flip deliberately only via a future lexeme-aware entrypoint.
|
|
468
|
+
"""
|
|
469
|
+
from runner.schema_validate import validate
|
|
470
|
+
from runner.schemas import CONFIG
|
|
471
|
+
|
|
472
|
+
res = run_node(
|
|
473
|
+
"""
|
|
474
|
+
import { validateConfig, validateConfigText } from "./plugins/opencode/config-io.js";
|
|
475
|
+
console.log(JSON.stringify({
|
|
476
|
+
objectInt: validateConfig({ divergence_threshold: 2 }),
|
|
477
|
+
textInt: validateConfigText('{"divergence_threshold": 2}'),
|
|
478
|
+
textFloat: validateConfigText('{"divergence_threshold": 2.0}'),
|
|
479
|
+
}));
|
|
480
|
+
"""
|
|
481
|
+
)
|
|
482
|
+
self.assertEqual(res.returncode, 0, res.stderr)
|
|
483
|
+
d = last_json_object(res.stdout)
|
|
484
|
+
# Documented object behavior: R2 renders the lexeme-blind integral
|
|
485
|
+
# value float-style. Assert the CURRENT strings so a future
|
|
486
|
+
# lexeme-aware entrypoint flips this deliberately, not silently.
|
|
487
|
+
self.assertEqual(
|
|
488
|
+
d["objectInt"],
|
|
489
|
+
["divergence_threshold: value 2.0 is above maximum 1.0"],
|
|
490
|
+
)
|
|
491
|
+
# Text path is exact: int lexeme renders `2` like Python-on-int.
|
|
492
|
+
self.assertEqual(d["textInt"], validate({"divergence_threshold": 2}, CONFIG))
|
|
493
|
+
self.assertEqual(
|
|
494
|
+
d["textInt"],
|
|
495
|
+
["divergence_threshold: value 2 is above maximum 1.0"],
|
|
496
|
+
)
|
|
497
|
+
# Text-path float lexeme renders `2.0` like Python-on-float.
|
|
498
|
+
self.assertEqual(
|
|
499
|
+
d["textFloat"], validate({"divergence_threshold": 2.0}, CONFIG)
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
def test_w22_exponential_rendering_waiver(self):
|
|
503
|
+
"""W22 (F2) WAIVER: exponential float rendering diverges on BOTH paths.
|
|
504
|
+
|
|
505
|
+
WAIVER, not a bug: `1e-5` -> JS `String()` gives `0.00001` while
|
|
506
|
+
CPython `repr()` gives `1e-05` (and `1e16` -> `10000000000000000`
|
|
507
|
+
vs `1e+16`) — repr SELECTION thresholds differ, not just padding,
|
|
508
|
+
so padding is whack-a-mole per second opinion
|
|
509
|
+
ses_f0b4aa769ffeE0zpoe9la477dw. Do NOT pad. See `pyRepr` in
|
|
510
|
+
plugins/opencode/config-io.js, `validate` in
|
|
511
|
+
runner/schema_validate.py, and .roadmap/06-parity/waivers.md W22.
|
|
512
|
+
TRIGGER: port of CPython shortest-repr switching; revisit only if
|
|
513
|
+
exponential config values occur.
|
|
514
|
+
"""
|
|
515
|
+
import json
|
|
516
|
+
|
|
517
|
+
from runner.schema_validate import validate
|
|
518
|
+
|
|
519
|
+
schema_text = '{"type": "number", "minimum": 0.001}'
|
|
520
|
+
schema = json.loads(schema_text)
|
|
521
|
+
py_problems = validate(json.loads("1e-5"), schema)
|
|
522
|
+
self.assertEqual(py_problems, ["<root>: value 1e-05 is below minimum 0.001"])
|
|
523
|
+
res = run_node(
|
|
524
|
+
"""
|
|
525
|
+
import { parseJsonWithNumberLexemes, validateAgainstSchema } from "./plugins/opencode/config-io.js";
|
|
526
|
+
const schema = parseJsonWithNumberLexemes('{"type": "number", "minimum": 0.001}');
|
|
527
|
+
const data = parseJsonWithNumberLexemes('1e-5');
|
|
528
|
+
const problems = validateAgainstSchema(data.value, schema.value, '', {
|
|
529
|
+
data: data.lexemes, schema: schema.lexemes, schemaPath: '',
|
|
530
|
+
});
|
|
531
|
+
console.log(JSON.stringify({ problems }));
|
|
532
|
+
"""
|
|
533
|
+
)
|
|
534
|
+
self.assertEqual(res.returncode, 0, res.stderr)
|
|
535
|
+
js_problems = last_json_object(res.stdout)["problems"]
|
|
536
|
+
# Pin the DOCUMENTED divergence (assert it differs, with the exact
|
|
537
|
+
# current JS string) so a future repr port flips it deliberately.
|
|
538
|
+
self.assertEqual(js_problems, ["<root>: value 0.00001 is below minimum 0.001"])
|
|
539
|
+
self.assertNotEqual(js_problems, py_problems)
|
|
540
|
+
|
|
541
|
+
def _js_messages(self, case_texts):
|
|
542
|
+
"""Exact JS message lists via the lexeme-aware text entrypoint.
|
|
543
|
+
|
|
544
|
+
`case_texts` maps name -> JSON text of the config (lexemes intact:
|
|
545
|
+
`2.0` stays a float lexeme). `validateConfigText` parses with
|
|
546
|
+
`parseJsonWithNumberLexemes`, so integral floats render Python-`repr`
|
|
547
|
+
style (`0.0`, never `0`).
|
|
548
|
+
"""
|
|
549
|
+
import tempfile
|
|
550
|
+
|
|
551
|
+
with tempfile.NamedTemporaryFile(
|
|
552
|
+
"w", suffix=".json", delete=False, encoding="utf-8"
|
|
553
|
+
) as f:
|
|
554
|
+
json.dump(case_texts, f)
|
|
555
|
+
tmp = f.name
|
|
556
|
+
try:
|
|
557
|
+
res = run_node(
|
|
558
|
+
f"""
|
|
559
|
+
import fs from "node:fs";
|
|
560
|
+
import {{ validateConfigText }} from "./plugins/opencode/config-io.js";
|
|
561
|
+
const cases = JSON.parse(fs.readFileSync({tmp!r}, "utf-8"));
|
|
562
|
+
const out = Object.entries(cases).map(([name, text]) => ({{
|
|
563
|
+
name, problems: validateConfigText(text),
|
|
564
|
+
}}));
|
|
565
|
+
console.log(JSON.stringify({{results: out}}));
|
|
566
|
+
"""
|
|
567
|
+
)
|
|
568
|
+
finally:
|
|
569
|
+
Path(tmp).unlink(missing_ok=True)
|
|
570
|
+
self.assertEqual(res.returncode, 0, f"js validator run failed: {res.stderr}")
|
|
571
|
+
return {
|
|
572
|
+
r["name"]: r["problems"] for r in last_json_object(res.stdout)["results"]
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
def test_js_python_messages_identical(self):
|
|
576
|
+
"""H7: every corpus case yields byte-identical message STRINGS, not
|
|
577
|
+
just identical verdicts (Python `{value!r}` wins: `0.0`, not `0`)."""
|
|
578
|
+
from runner.schema_validate import validate
|
|
579
|
+
from runner.schemas import CONFIG
|
|
580
|
+
|
|
581
|
+
cases = json.loads(self.FIXTURE.read_text(encoding="utf-8"))["cases"]
|
|
582
|
+
names = {c["name"] for c in cases}
|
|
583
|
+
# The H7 float-lexeme cases must be in the corpus, or message parity
|
|
584
|
+
# would silently skip the unified rendering. R3 pins the int-vs-float
|
|
585
|
+
# typing case alongside them.
|
|
586
|
+
for required in (
|
|
587
|
+
"float_integral_divergence_above_maximum",
|
|
588
|
+
"float_integral_search_timeout_below_minimum",
|
|
589
|
+
"float_integral_min_fuzzy_above_maximum",
|
|
590
|
+
"float_fractional_cache_ttl_type_error",
|
|
591
|
+
"type_integral_float_in_integer_field",
|
|
592
|
+
):
|
|
593
|
+
self.assertIn(required, names)
|
|
594
|
+
texts = {c["name"]: json.dumps(c["config"]) for c in cases}
|
|
595
|
+
js_messages = self._js_messages(texts)
|
|
596
|
+
for case in cases:
|
|
597
|
+
with self.subTest(case=case["name"]):
|
|
598
|
+
py_problems = validate(case["config"], CONFIG)
|
|
599
|
+
self.assertEqual(js_messages[case["name"]], py_problems)
|
|
600
|
+
|
|
601
|
+
def test_js_python_float_message_parity_synthetic(self):
|
|
602
|
+
"""H7: minima/maxima/enum/pattern renderings with float lexemes.
|
|
603
|
+
|
|
604
|
+
Synthetic schemas (no CONFIG float enums exist) driven from the SAME
|
|
605
|
+
JSON text on both sides: integral floats (`2.0`), int lexemes in
|
|
606
|
+
float fields (`0.0` vs bound `1`), nested `items` bounds, and a
|
|
607
|
+
string pattern case.
|
|
608
|
+
"""
|
|
609
|
+
import tempfile
|
|
610
|
+
|
|
611
|
+
from runner.schema_validate import validate
|
|
612
|
+
|
|
613
|
+
pairs = [
|
|
614
|
+
# Float bounds, integral + non-integral data.
|
|
615
|
+
(
|
|
616
|
+
'{"type": "number", "minimum": 0.0, "maximum": 1.0}',
|
|
617
|
+
["2.0", "0.0", "-1.5", "0", "1"],
|
|
618
|
+
),
|
|
619
|
+
# Float enum members (integral and fractional).
|
|
620
|
+
(
|
|
621
|
+
'{"type": "number", "enum": [0.0, 0.5, 1.0]}',
|
|
622
|
+
["0.5", "0.75", "2.0", "1"],
|
|
623
|
+
),
|
|
624
|
+
# Int bound under a float-typed schema: `0.0` vs `1`.
|
|
625
|
+
('{"type": "number", "minimum": 1}', ["0.0", "0", "2.0"]),
|
|
626
|
+
# R4: a REAL root-level array — Python renders indices bare
|
|
627
|
+
# (`[1]`); the JS branch used to render `<root>[1]`.
|
|
628
|
+
(
|
|
629
|
+
'{"type": "array", "items": {"type": "number", "maximum": 1.0}}',
|
|
630
|
+
["[0.5, 2.0]", "[0.0, 1.0]", "[]"],
|
|
631
|
+
),
|
|
632
|
+
# Pattern rendering pins the string side of the policy.
|
|
633
|
+
('{"type": "string", "pattern": "^[0-9]+$"}', ['"abc"', '"42"']),
|
|
634
|
+
]
|
|
635
|
+
payload = [{"schema": schema, "data": data_list} for schema, data_list in pairs]
|
|
636
|
+
with tempfile.NamedTemporaryFile(
|
|
637
|
+
"w", suffix=".json", delete=False, encoding="utf-8"
|
|
638
|
+
) as f:
|
|
639
|
+
json.dump(payload, f)
|
|
640
|
+
tmp = f.name
|
|
641
|
+
try:
|
|
642
|
+
res = run_node(
|
|
643
|
+
f"""
|
|
644
|
+
import fs from "node:fs";
|
|
645
|
+
import {{ parseJsonWithNumberLexemes, validateAgainstSchema }} from "./plugins/opencode/config-io.js";
|
|
646
|
+
const pairs = JSON.parse(fs.readFileSync({tmp!r}, "utf-8"));
|
|
647
|
+
const out = pairs.map((p) => {{
|
|
648
|
+
const schema = parseJsonWithNumberLexemes(p.schema);
|
|
649
|
+
return p.data.map((text) => {{
|
|
650
|
+
const data = parseJsonWithNumberLexemes(text);
|
|
651
|
+
return validateAgainstSchema(data.value, schema.value, '', {{
|
|
652
|
+
data: data.lexemes, schema: schema.lexemes, schemaPath: '',
|
|
653
|
+
}});
|
|
654
|
+
}});
|
|
655
|
+
}});
|
|
656
|
+
console.log(JSON.stringify({{results: out}}));
|
|
657
|
+
"""
|
|
658
|
+
)
|
|
659
|
+
finally:
|
|
660
|
+
Path(tmp).unlink(missing_ok=True)
|
|
661
|
+
self.assertEqual(res.returncode, 0, f"js validator run failed: {res.stderr}")
|
|
662
|
+
js_results = last_json_object(res.stdout)["results"]
|
|
663
|
+
for (schema_text, data_list), js_problems_list in zip(pairs, js_results):
|
|
664
|
+
schema = json.loads(schema_text)
|
|
665
|
+
for data_text, js_problems in zip(data_list, js_problems_list):
|
|
666
|
+
with self.subTest(schema=schema_text, data=data_text):
|
|
667
|
+
py_problems = validate(json.loads(data_text), schema)
|
|
668
|
+
self.assertEqual(js_problems, py_problems)
|
|
669
|
+
|
|
333
670
|
def test_js_fallback_defaults_match_canonical(self):
|
|
334
671
|
"""The schema-unreadable fallback must equal CONFIG_DEFAULTS."""
|
|
335
672
|
from runner.schemas import CONFIG_DEFAULTS
|
|
@@ -348,7 +685,62 @@ class TestConfigToolCatalogSurface(unittest.TestCase):
|
|
|
348
685
|
"""B4: the plugin TOOL_CATALOG advertises the full canonical surface."""
|
|
349
686
|
|
|
350
687
|
CONTROL_ARGS = {"base_dir", "show", "expected_hash", "expectedHash"}
|
|
351
|
-
|
|
688
|
+
# R1 twin of the canonical comparator (cf. COMPARE_KEYWORDS in
|
|
689
|
+
# runner/tests/test_config_schema.py): the same scalar keyword set —
|
|
690
|
+
# type/enum/min/max (minimum/maximum)/minLength/pattern/default — with
|
|
691
|
+
# items/additionalProperties recursing as schema nodes, so the JS twin
|
|
692
|
+
# cannot silently narrow what the Python comparator walks.
|
|
693
|
+
KEYWORDS = (
|
|
694
|
+
"type",
|
|
695
|
+
"enum",
|
|
696
|
+
"minimum",
|
|
697
|
+
"maximum",
|
|
698
|
+
"minLength",
|
|
699
|
+
"pattern",
|
|
700
|
+
"default",
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
# R1 accepted asymmetry (precedent pattern: the Accepted-asymmetry note at
|
|
704
|
+
# plugins/opencode/index.js:1116): the live TOOL_CATALOG omits `default`
|
|
705
|
+
# keywords — canonical defaults live in runner/schemas.py (mirrored to
|
|
706
|
+
# schemas/config.schema.json); the catalog is an input shape, not a
|
|
707
|
+
# defaults source, so syncing defaults into it would risk the host
|
|
708
|
+
# auto-injecting them. The twin therefore skips `default` comparison ONLY
|
|
709
|
+
# on these declared paths AND only when the advertised side omits the key
|
|
710
|
+
# entirely. Any other default drift still bites: a wrong advertised value
|
|
711
|
+
# on a listed path, or any default where the canonical node has none
|
|
712
|
+
# (undeclared asymmetry). Keep this set in sync with CONFIG defaults by
|
|
713
|
+
# hand — a new canonical default must be added here deliberately, never
|
|
714
|
+
# silently.
|
|
715
|
+
ACCEPTED_DEFAULT_OMISSIONS = frozenset(
|
|
716
|
+
[
|
|
717
|
+
"search_engine",
|
|
718
|
+
"max_iterations",
|
|
719
|
+
"divergence_threshold",
|
|
720
|
+
"mode",
|
|
721
|
+
"backend",
|
|
722
|
+
"cache_raw_markdown",
|
|
723
|
+
"cache_ttl_days",
|
|
724
|
+
"search_timeout_s",
|
|
725
|
+
"searxng_url",
|
|
726
|
+
"license_whitelist",
|
|
727
|
+
"output_dir",
|
|
728
|
+
"allocation",
|
|
729
|
+
"domain_pack",
|
|
730
|
+
"verify",
|
|
731
|
+
"verify.min_fuzzy_confidence",
|
|
732
|
+
"agents",
|
|
733
|
+
"agents.alpha.backend",
|
|
734
|
+
"agents.alpha.model",
|
|
735
|
+
"agents.alpha.opencode_agent",
|
|
736
|
+
"agents.beta.backend",
|
|
737
|
+
"agents.beta.model",
|
|
738
|
+
"agents.beta.opencode_agent",
|
|
739
|
+
"opencode_auto",
|
|
740
|
+
"opencode_agent",
|
|
741
|
+
"mcp_servers",
|
|
742
|
+
]
|
|
743
|
+
)
|
|
352
744
|
|
|
353
745
|
def _advertised(self):
|
|
354
746
|
res = run_node(
|
|
@@ -361,20 +753,59 @@ class TestConfigToolCatalogSurface(unittest.TestCase):
|
|
|
361
753
|
self.assertEqual(res.returncode, 0, res.stderr)
|
|
362
754
|
return last_json_object(res.stdout)["input"]
|
|
363
755
|
|
|
756
|
+
def _compare_schema_node(self, canonical, advertised, path, problems):
|
|
757
|
+
"""Deep-compare one schema node, recursing into subschemas (R1 twin).
|
|
758
|
+
|
|
759
|
+
Mirrors TestMcpConfigSurface._compare_schema_node: scalar KEYWORDS at
|
|
760
|
+
this node, `properties` recursing per key, `items` as `{path}[]` and
|
|
761
|
+
`additionalProperties` as `{path}.*`. A canonical subschema missing on
|
|
762
|
+
the advertised side is a drift report, never a silent skip. The sole
|
|
763
|
+
carve-out is the declared `default` omission (see
|
|
764
|
+
ACCEPTED_DEFAULT_OMISSIONS).
|
|
765
|
+
"""
|
|
766
|
+
for keyword in self.KEYWORDS:
|
|
767
|
+
if (
|
|
768
|
+
keyword == "default"
|
|
769
|
+
and path in self.ACCEPTED_DEFAULT_OMISSIONS
|
|
770
|
+
and isinstance(advertised, dict)
|
|
771
|
+
and "default" not in advertised
|
|
772
|
+
):
|
|
773
|
+
continue
|
|
774
|
+
if canonical.get(keyword) != (advertised or {}).get(keyword):
|
|
775
|
+
problems.append(
|
|
776
|
+
f"{path}: {keyword} {canonical.get(keyword)!r} != "
|
|
777
|
+
f"{(advertised or {}).get(keyword)!r}"
|
|
778
|
+
)
|
|
779
|
+
if isinstance(canonical.get("properties"), dict):
|
|
780
|
+
if not isinstance((advertised or {}).get("properties"), dict):
|
|
781
|
+
problems.append(f"{path}: properties schema missing on advertised side")
|
|
782
|
+
else:
|
|
783
|
+
self._compare_properties(
|
|
784
|
+
canonical, advertised, f"{path}." if path else "", problems
|
|
785
|
+
)
|
|
786
|
+
for keyword, suffix in (("items", "[]"), ("additionalProperties", ".*")):
|
|
787
|
+
sub_node = canonical.get(keyword)
|
|
788
|
+
if isinstance(sub_node, dict):
|
|
789
|
+
adv_node = (advertised or {}).get(keyword)
|
|
790
|
+
if not isinstance(adv_node, dict):
|
|
791
|
+
problems.append(
|
|
792
|
+
f"{path}{suffix}: {keyword} schema missing on advertised side"
|
|
793
|
+
)
|
|
794
|
+
else:
|
|
795
|
+
self._compare_schema_node(
|
|
796
|
+
sub_node, adv_node, f"{path}{suffix}", problems
|
|
797
|
+
)
|
|
798
|
+
|
|
364
799
|
def _compare(self, canonical, advertised, prefix, problems):
|
|
800
|
+
self._compare_properties(canonical, advertised, prefix, problems)
|
|
801
|
+
|
|
802
|
+
def _compare_properties(self, canonical, advertised, prefix, problems):
|
|
365
803
|
for key, sub in (canonical.get("properties") or {}).items():
|
|
366
804
|
adv = (advertised.get("properties") or {}).get(key)
|
|
367
805
|
if adv is None:
|
|
368
806
|
problems.append(f"missing advertised key: {prefix}{key}")
|
|
369
807
|
continue
|
|
370
|
-
|
|
371
|
-
if keyword in sub and sub.get(keyword) != adv.get(keyword):
|
|
372
|
-
problems.append(
|
|
373
|
-
f"{prefix}{key}: {keyword} {sub.get(keyword)!r} != "
|
|
374
|
-
f"{adv.get(keyword)!r}"
|
|
375
|
-
)
|
|
376
|
-
if isinstance(sub.get("properties"), dict):
|
|
377
|
-
self._compare(sub, adv, f"{prefix}{key}.", problems)
|
|
808
|
+
self._compare_schema_node(sub, adv, f"{prefix}{key}", problems)
|
|
378
809
|
|
|
379
810
|
def test_plugin_catalog_covers_canonical_surface(self):
|
|
380
811
|
artifact = json.loads(
|
|
@@ -401,6 +832,107 @@ class TestConfigToolCatalogSurface(unittest.TestCase):
|
|
|
401
832
|
advertised["properties"]["expectedHash"]["description"],
|
|
402
833
|
)
|
|
403
834
|
|
|
835
|
+
def test_twin_comparator_catches_undeclared_drift(self):
|
|
836
|
+
"""R1: the twin bites on default/items drift outside the allowlist.
|
|
837
|
+
|
|
838
|
+
Each sub-case mutates a FRESH advertised surface exactly once, so a
|
|
839
|
+
green sub-case proves its own keyword is compared (never vacuous via
|
|
840
|
+
a shared broken fixture).
|
|
841
|
+
"""
|
|
842
|
+
import copy
|
|
843
|
+
|
|
844
|
+
artifact = json.loads(
|
|
845
|
+
(PROJECT_ROOT / "schemas" / "config.schema.json").read_text(
|
|
846
|
+
encoding="utf-8"
|
|
847
|
+
)
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
def complained_of(mutator, needle):
|
|
851
|
+
advertised = copy.deepcopy(self._advertised())
|
|
852
|
+
mutator(advertised["properties"])
|
|
853
|
+
problems = []
|
|
854
|
+
self._compare(artifact, advertised, "", problems)
|
|
855
|
+
self.assertTrue(
|
|
856
|
+
any(needle in p for p in problems),
|
|
857
|
+
f"{needle!r} not in {problems}",
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
# Wrong advertised default on an allowlisted path still bites (the
|
|
861
|
+
# carve-out covers omission only, never a conflicting value).
|
|
862
|
+
complained_of(
|
|
863
|
+
lambda props: props["mode"].__setitem__("default", "scout"),
|
|
864
|
+
"mode: default",
|
|
865
|
+
)
|
|
866
|
+
# Undeclared asymmetry bites: the canonical role node carries no
|
|
867
|
+
# `default`, so an advertised one is drift, not the accepted omission.
|
|
868
|
+
complained_of(
|
|
869
|
+
lambda props: props["agents"]["properties"]["alpha"].__setitem__(
|
|
870
|
+
"default", {"backend": None}
|
|
871
|
+
),
|
|
872
|
+
"agents.alpha: default",
|
|
873
|
+
)
|
|
874
|
+
# `items` drift bites (top-level and nested).
|
|
875
|
+
complained_of(
|
|
876
|
+
lambda props: props["license_whitelist"].__setitem__(
|
|
877
|
+
"items", {"type": "integer"}
|
|
878
|
+
),
|
|
879
|
+
"license_whitelist[]: type",
|
|
880
|
+
)
|
|
881
|
+
complained_of(
|
|
882
|
+
lambda props: props["agents"]["properties"]["alpha"]["properties"][
|
|
883
|
+
"backend"
|
|
884
|
+
].__setitem__("items", {"type": "integer"}),
|
|
885
|
+
"agents.alpha.backend[]: type",
|
|
886
|
+
)
|
|
887
|
+
complained_of(
|
|
888
|
+
lambda props: props["license_whitelist"].pop("items"),
|
|
889
|
+
"license_whitelist[]: items schema missing",
|
|
890
|
+
)
|
|
891
|
+
# `additionalProperties` drift bites.
|
|
892
|
+
complained_of(
|
|
893
|
+
lambda props: props["mcp_servers"].__setitem__(
|
|
894
|
+
"additionalProperties", {"type": "string"}
|
|
895
|
+
),
|
|
896
|
+
"mcp_servers.*: type",
|
|
897
|
+
)
|
|
898
|
+
complained_of(
|
|
899
|
+
lambda props: props["mcp_servers"].pop("additionalProperties"),
|
|
900
|
+
"mcp_servers.*: additionalProperties schema missing",
|
|
901
|
+
)
|
|
902
|
+
# A dropped key still bites.
|
|
903
|
+
complained_of(
|
|
904
|
+
lambda props: props.pop("verify"),
|
|
905
|
+
"missing advertised key: verify",
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
def test_declared_default_asymmetry_passes(self):
|
|
909
|
+
"""R1: the allowlisted `default` omissions — and ONLY they — pass."""
|
|
910
|
+
import copy
|
|
911
|
+
|
|
912
|
+
artifact = json.loads(
|
|
913
|
+
(PROJECT_ROOT / "schemas" / "config.schema.json").read_text(
|
|
914
|
+
encoding="utf-8"
|
|
915
|
+
)
|
|
916
|
+
)
|
|
917
|
+
advertised = copy.deepcopy(self._advertised())
|
|
918
|
+
# Every canonical default path is either present-and-equal or a
|
|
919
|
+
# declared omission; nothing else may differ on `default`.
|
|
920
|
+
problems = []
|
|
921
|
+
self._compare(artifact, advertised, "", problems)
|
|
922
|
+
self.assertEqual(
|
|
923
|
+
[p for p in problems if ": default" in p],
|
|
924
|
+
[],
|
|
925
|
+
)
|
|
926
|
+
# The allowlist skips omission only: restoring the CORRECT canonical
|
|
927
|
+
# default must compare equal (proves the skip is scoped to omission,
|
|
928
|
+
# not a blanket `default` ignore — a wrong value still bites, as
|
|
929
|
+
# test_twin_comparator_catches_undeclared_drift pins).
|
|
930
|
+
restored = copy.deepcopy(advertised)
|
|
931
|
+
restored["properties"]["mode"]["default"] = "research"
|
|
932
|
+
problems = []
|
|
933
|
+
self._compare(artifact, restored, "", problems)
|
|
934
|
+
self.assertEqual(problems, [])
|
|
935
|
+
|
|
404
936
|
|
|
405
937
|
class TestWizardSlashRegistration(unittest.TestCase):
|
|
406
938
|
"""B1: `swarm-config` slash + palette entries on the mock host."""
|