@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.
Files changed (104) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +1 -1
  3. package/bin/cli.js +3 -2
  4. package/extensions/pi/index.js +3 -2
  5. package/package.json +5 -1
  6. package/plugins/antigravity/plugin.json +1 -1
  7. package/plugins/factory/skills/factory/SKILL.md +1 -1
  8. package/plugins/opencode/analysis-bridge.js +566 -0
  9. package/plugins/opencode/config-io.js +598 -31
  10. package/plugins/opencode/dep-health.js +1039 -0
  11. package/plugins/opencode/hook-bus.js +434 -0
  12. package/plugins/opencode/index.js +252 -19
  13. package/plugins/opencode/tui.js +230 -17
  14. package/plugins/opencode/weight-baseline.json +16 -0
  15. package/plugins/opencode/weight-signals.js +769 -0
  16. package/plugins/research-cache/skills/research_cache/hasher.py +33 -8
  17. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/errors.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  27. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  28. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  29. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  30. package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
  31. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  32. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  33. package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
  34. package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
  35. package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
  36. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  37. package/runner/mcp_server.py +249 -81
  38. package/runner/refinement.py +5 -0
  39. package/runner/schema_validate.py +63 -1
  40. package/runner/schemas.py +9 -4
  41. package/runner/tests/__pycache__/test_analysis_bridge.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
  52. package/runner/tests/__pycache__/test_dep_health.cpython-311.pyc +0 -0
  53. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  54. package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
  55. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  56. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  57. package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
  58. package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
  59. package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
  60. package/runner/tests/__pycache__/test_hardening_contract.cpython-311.pyc +0 -0
  61. package/runner/tests/__pycache__/test_hook_bus.cpython-311.pyc +0 -0
  62. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  63. package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
  64. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  65. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  66. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  67. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  68. package/runner/tests/__pycache__/test_phase08_waiver_reopens.cpython-311.pyc +0 -0
  69. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  70. package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
  71. package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
  72. package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
  73. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  74. package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
  75. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  76. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  77. package/runner/tests/__pycache__/test_weight_signals.cpython-311.pyc +0 -0
  78. package/runner/tests/fixtures/config_validation_cases.json +60 -0
  79. package/runner/tests/test_analysis_bridge.py +356 -0
  80. package/runner/tests/test_config_schema.py +119 -15
  81. package/runner/tests/test_dep_health.py +484 -0
  82. package/runner/tests/test_hardening_contract.py +391 -0
  83. package/runner/tests/test_hook_bus.py +802 -0
  84. package/runner/tests/test_opencode_ux.py +232 -0
  85. package/runner/tests/test_opencode_v2_registrars.py +548 -16
  86. package/runner/tests/test_phase08_waiver_reopens.py +539 -0
  87. package/runner/tests/test_weight_signals.py +361 -0
  88. package/schemas/config.schema.json +4 -3
  89. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  90. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  91. package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
  92. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  93. package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
  94. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  95. package/skills/factory/SKILL.md +1 -1
  96. package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
  97. package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
  98. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  99. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  100. package/skills/research_cache/hasher.py +33 -8
  101. package/skills/swarm_config/SKILL.md +6 -1
  102. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  103. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  104. package/skills/swarm_config/configure.py +254 -17
@@ -14,6 +14,16 @@ comparisons.
14
14
  Validation is advisory: `_load_agent_dossier` warns and keeps going (#6 item 2.1).
15
15
  `skills/swarm_config/configure.py` uses it as the save-time gate, where it is
16
16
  enforced.
17
+
18
+ `pattern` strings stay ECMA-anchored (`$`, never `\\Z`): the same strings ship
19
+ in `schemas/*.schema.json` and `plugins/opencode/*`, where JS `new
20
+ RegExp(pattern).test()` interprets them — and in JS `\\Z` is an identity
21
+ escape matching a literal "Z", so a `\\Z` in the shared string would break
22
+ the JS side. Python's `re` `$` instead also matches before a trailing
23
+ newline ("https://example.com\n" would pass Python but fail JS), so at
24
+ match time `_ecma_to_python_pattern` rewrites each unescaped `$` outside
25
+ `[...]` to `\\Z` (JS `$` without the `m` flag == Python `\\Z`). Schema
26
+ strings are untouched; `gen_schemas --check` stays green by construction.
17
27
  """
18
28
 
19
29
  import math
@@ -42,6 +52,44 @@ def _type_matches(value: Any, expected: str) -> bool:
42
52
  return py is not None and isinstance(value, py)
43
53
 
44
54
 
55
+ def _ecma_to_python_pattern(pattern: str) -> str:
56
+ """Rewrite ECMA `$` anchors to Python `\\Z` for JS-identical verdicts.
57
+
58
+ Neither side uses multiline: JS `$` (no `m` flag) matches only at the
59
+ true end of input, while Python `$` also matches before a trailing
60
+ newline. Rewriting preserves `re.search` (JSON Schema `pattern` is an
61
+ unanchored search, like JS `RegExp.test`) and leaves the stored schema
62
+ string untouched. Escaped `\\$` and `$` inside `[...]` classes pass
63
+ through unchanged.
64
+ """
65
+ out: List[str] = []
66
+ escaped = False
67
+ in_class = False
68
+ i = 0
69
+ while i < len(pattern):
70
+ ch = pattern[i]
71
+ if escaped:
72
+ out.append(ch)
73
+ escaped = False
74
+ elif ch == "\\":
75
+ out.append(ch)
76
+ escaped = True
77
+ elif ch == "[" and not in_class:
78
+ out.append(ch)
79
+ in_class = True
80
+ elif ch == "]" and in_class:
81
+ out.append(ch)
82
+ in_class = False
83
+ elif ch == "$" and not in_class:
84
+ out.append("\\Z")
85
+ else:
86
+ out.append(ch)
87
+ i += 1
88
+ if escaped:
89
+ out.append("\\")
90
+ return "".join(out)
91
+
92
+
45
93
  def validate(data: Any, schema: Dict[str, Any], path: str = "") -> List[str]:
46
94
  """Return human-readable violations (dotted paths). Empty list == valid."""
47
95
  if not isinstance(schema, dict):
@@ -90,6 +138,17 @@ def validate(data: Any, schema: Dict[str, Any], path: str = "") -> List[str]:
90
138
  # number can never satisfy a bounded contract.
91
139
  problems.append(f"{path or '<root>'}: value {data!r} is not finite")
92
140
  else:
141
+ # WAIVER W22 (F2, Phase 06 `06-parity` REWORK retry 2/3, manager
142
+ # tiebreak binding): `{data!r}` here is CPython shortest-repr
143
+ # (`1e-05`, `1e+16`), while the JS side renders via `String()`
144
+ # (`0.00001`, `10000000000000000`) — the exponential float
145
+ # rendering class diverges on BOTH paths by repr SELECTION
146
+ # thresholds, not just padding, so the JS side must NOT pad
147
+ # (whack-a-mole per second opinion ses_f0b4aa769ffeE0zpoe9la477dw).
148
+ # Pinned by `test_w22_exponential_rendering_waiver`. TRIGGER:
149
+ # revisit only via a port of CPython shortest-repr switching,
150
+ # and only if exponential config values occur
151
+ # (see .roadmap/06-parity/waivers.md W22).
93
152
  minimum = schema.get("minimum")
94
153
  if minimum is not None and data < minimum:
95
154
  problems.append(
@@ -108,7 +167,10 @@ def validate(data: Any, schema: Dict[str, Any], path: str = "") -> List[str]:
108
167
  f"{path or '<root>'}: string shorter than minLength {min_length!r}"
109
168
  )
110
169
  pattern = schema.get("pattern")
111
- if isinstance(pattern, str) and re.search(pattern, data) is None:
170
+ if (
171
+ isinstance(pattern, str)
172
+ and re.search(_ecma_to_python_pattern(pattern), data) is None
173
+ ):
112
174
  problems.append(
113
175
  f"{path or '<root>'}: value {data!r} does not match pattern {pattern!r}"
114
176
  )
package/runner/schemas.py CHANGED
@@ -465,7 +465,7 @@ CONFIG: Dict[str, Any] = {
465
465
  },
466
466
  "searxng_url": {
467
467
  "type": ["string", "null"],
468
- "pattern": "^https?://",
468
+ "pattern": "^https?://[^\\s/]+\\S*$",
469
469
  "default": CONFIG_DEFAULTS["searxng_url"],
470
470
  "x-label": "SearXNG URL",
471
471
  "x-scope": "retrieval",
@@ -473,7 +473,8 @@ CONFIG: Dict[str, Any] = {
473
473
  "description": (
474
474
  "Base URL of a self-hosted SearXNG; exported to spawned children "
475
475
  "as SEARXNG_URL when set (preflight also reads the env var). Must "
476
- "include an http:// or https:// scheme; null leaves the "
476
+ "include an http:// or https:// scheme AND a host "
477
+ "(`http://` with no host is rejected); null leaves the "
477
478
  "environment untouched."
478
479
  ),
479
480
  },
@@ -518,14 +519,18 @@ CONFIG: Dict[str, Any] = {
518
519
  "domain_pack": {
519
520
  "type": ["string", "null"],
520
521
  "minLength": 1,
522
+ "pattern": "^\\S(.*\\S)?$",
521
523
  "default": CONFIG_DEFAULTS["domain_pack"],
522
524
  "x-label": "Domain Pack",
523
525
  "x-scope": "audit",
524
526
  "x-restart": "next-run",
525
527
  "description": (
526
528
  "Regulated Domain Pack id (config/domain_packs/) or null for the "
527
- "legacy constitution. The empty string is rejected; a pack id "
528
- "that does not resolve fails loudly when the constitution loads."
529
+ "legacy constitution. The empty string, whitespace-only strings, "
530
+ "and ids with leading/trailing whitespace are rejected; a pack "
531
+ "id that does not resolve fails loudly when the constitution "
532
+ "loads. UI entry points (iumbtems_set_domain_pack) strip "
533
+ "surrounding whitespace before resolving."
529
534
  ),
530
535
  },
531
536
  "verify": {
@@ -134,6 +134,37 @@
134
134
  "expect": "search_timeout_s",
135
135
  "config": {"search_timeout_s": 0}
136
136
  },
137
+ {
138
+ "name": "float_integral_divergence_above_maximum",
139
+ "valid": false,
140
+ "expect": "maximum",
141
+ "config": {"divergence_threshold": 2.0}
142
+ },
143
+ {
144
+ "name": "float_integral_search_timeout_below_minimum",
145
+ "valid": false,
146
+ "expect": "minimum",
147
+ "config": {"search_timeout_s": 0.0}
148
+ },
149
+ {
150
+ "name": "float_integral_min_fuzzy_above_maximum",
151
+ "valid": false,
152
+ "expect": "min_fuzzy_confidence",
153
+ "config": {"verify": {"min_fuzzy_confidence": 2.0}}
154
+ },
155
+ {
156
+ "name": "float_fractional_cache_ttl_type_error",
157
+ "valid": false,
158
+ "expect": "got float",
159
+ "config": {"cache_ttl_days": -1.5}
160
+ },
161
+ {
162
+ "name": "type_integral_float_in_integer_field",
163
+ "valid": false,
164
+ "expect": "got float",
165
+ "lexeme_sensitive": true,
166
+ "config": {"cache_ttl_days": 2.0}
167
+ },
137
168
  {
138
169
  "name": "type_search_engine_integer",
139
170
  "valid": false,
@@ -211,6 +242,35 @@
211
242
  "valid": false,
212
243
  "expect": "pattern",
213
244
  "config": {"searxng_url": "localhost:8080"}
245
+ },
246
+ {
247
+ "name": "vert_searxng_url_scheme_without_host",
248
+ "valid": false,
249
+ "expect": "pattern",
250
+ "config": {"searxng_url": "http://"}
251
+ },
252
+ {
253
+ "name": "vert_searxng_url_scheme_without_host_slash",
254
+ "valid": false,
255
+ "expect": "pattern",
256
+ "config": {"searxng_url": "https:///path-only"}
257
+ },
258
+ {
259
+ "name": "vert_searxng_url_host_with_port_and_path",
260
+ "valid": true,
261
+ "config": {"searxng_url": "https://searx.local:8888/search"}
262
+ },
263
+ {
264
+ "name": "vert_domain_pack_whitespace_only",
265
+ "valid": false,
266
+ "expect": "pattern",
267
+ "config": {"domain_pack": " "}
268
+ },
269
+ {
270
+ "name": "vert_domain_pack_padded",
271
+ "valid": false,
272
+ "expect": "pattern",
273
+ "config": {"domain_pack": " biopharma "}
214
274
  }
215
275
  ]
216
276
  }
@@ -0,0 +1,356 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for Phase 02-lsp-bridge: CLI-based JS/TS analysis behind the hook bus.
3
+
4
+ Mock-host only (no live TUI, no real Biome spawn): the analyzer is an
5
+ injected mock, every budget is tens of milliseconds, at most zero MCP
6
+ spawns. Pins: dirty-file advisory (findings + allowed:true), clean-file
7
+ empty, analyzer-missing skip (single audit entry, silent), never-deny
8
+ (severe findings still allow), audit-shape (tool/eventId keys on every
9
+ annotation), slow-overrun (timeout annotation, caller unblocked), and the
10
+ setup adopt/dispose wiring in plugins/opencode/index.js.
11
+
12
+ Phase evidence cited in-code per brief:
13
+ [VERIFIED: sha256:f2c0843b598ba6f19cc0e208f8d02e0250c70615e3c484474d50635d0b5cdef8]
14
+ [VERIFIED: sha256:640ab94a0408cef8cc14fb2cbf52f041021a1ce012590b41e8063075a21b2c8f]
15
+ [VERIFIED: sha256:69cacc4b62c2c1fccd5f5f3cf4d78398eb7310ceeb36e9ef423868e530771d90]
16
+ [VERIFIED: sha256:2b08ae78a2475e7d3b3427ba089542f8bd76635b87ab397066af8c0355651fbd]
17
+ [VERIFIED: sha256:7cbceba66852b3d8405d78833d0ce1dad7a25423af59cbacdea2790ba83e4b2a]
18
+ """
19
+
20
+ import json
21
+ import subprocess
22
+ import sys
23
+ import unittest
24
+ from pathlib import Path
25
+
26
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
27
+ if str(PROJECT_ROOT) not in sys.path:
28
+ sys.path.insert(0, str(PROJECT_ROOT))
29
+
30
+
31
+ def run_node(code):
32
+ return subprocess.run(
33
+ ["node", "--input-type=module", "-e", code],
34
+ capture_output=True,
35
+ text=True,
36
+ cwd=str(PROJECT_ROOT),
37
+ )
38
+
39
+
40
+ def last_json_object(stdout):
41
+ lines = [
42
+ line.strip()
43
+ for line in stdout.strip().split("\n")
44
+ if line.strip().startswith("{")
45
+ ]
46
+ return json.loads(lines[-1])
47
+
48
+
49
+ BRIDGE_IMPORT = (
50
+ 'import { createHookBus } from "./plugins/opencode/hook-bus.js";\n'
51
+ 'import { analyzePayload, createBridgeHandler, registerAnalysisBridge } from "./plugins/opencode/analysis-bridge.js";\n'
52
+ )
53
+
54
+ MOCK_TWO_FINDINGS = """
55
+ const runAnalyzer = async (files) => ({
56
+ status: "ok",
57
+ findings: [
58
+ { rule: "lint/suspicious/noDebugger", file: files[0], line: 3, message: "Unexpected debugger statement.", severity: "error" },
59
+ { rule: "lint/style/useConst", file: files[0], line: 1, message: "This let could be const.", severity: "warning" },
60
+ ],
61
+ });
62
+ """
63
+
64
+ SETUP_BRIDGE = """
65
+ const bus = createHookBus();
66
+ const notices = [];
67
+ const notify = (s) => { notices.push(s); };
68
+ const reg = await registerAnalysisBridge(null, { bus, runAnalyzer, notify, timeoutMs: 200 });
69
+ """
70
+
71
+
72
+ class TestDirtyFileAdvisory(unittest.TestCase):
73
+ def test_dirty_file_yields_findings_and_allows(self):
74
+ res = run_node(
75
+ BRIDGE_IMPORT
76
+ + MOCK_TWO_FINDINGS
77
+ + SETUP_BRIDGE
78
+ + """
79
+ const r = await bus.emit("pre-tool-use", {tool: "edit", eventId: "e-dirty", file: "src/app.ts"});
80
+ const audit = bus.getAuditLog();
81
+ const findingAudits = audit.filter((a) => a.event === "notification" && a.tool === "edit" && a.eventId === "e-dirty");
82
+ console.log(JSON.stringify({
83
+ allowed: r.allowed,
84
+ notices: notices.length,
85
+ notice: notices[0] && notices[0].message,
86
+ findingAudits: findingAudits.length,
87
+ reasons: findingAudits.map((a) => a.reason || ""),
88
+ }));
89
+ """
90
+ )
91
+ self.assertEqual(res.returncode, 0, f"dirty advisory test failed: {res.stderr}")
92
+ data = last_json_object(res.stdout)
93
+ self.assertTrue(data["allowed"], "dirty file must still allow (all-advisory)")
94
+ self.assertEqual(data["notices"], 1, "exactly one toast summary per analysis")
95
+ self.assertIn("2 finding", data["notice"])
96
+ # Toast summarizes counts only: no rule ids, no finding text.
97
+ self.assertNotIn("noDebugger", data["notice"])
98
+ self.assertNotIn("debugger statement", data["notice"])
99
+ self.assertEqual(
100
+ data["findingAudits"], 2, "every finding audit-logged on the bus"
101
+ )
102
+ self.assertTrue(
103
+ any("lint/suspicious/noDebugger" in reason for reason in data["reasons"]),
104
+ "finding rule must ride in the audit entry reason",
105
+ )
106
+ self.assertTrue(
107
+ any("lint/style/useConst" in reason for reason in data["reasons"]),
108
+ "every finding rule must be audit-logged",
109
+ )
110
+
111
+
112
+ class TestCleanFileEmpty(unittest.TestCase):
113
+ def test_clean_file_no_annotations(self):
114
+ res = run_node(
115
+ BRIDGE_IMPORT
116
+ + """
117
+ const runAnalyzer = async () => ({status: "ok", findings: []});
118
+ """
119
+ + SETUP_BRIDGE
120
+ + """
121
+ const direct = await analyzePayload({tool: "edit", eventId: "e-clean", file: "src/ok.ts"}, {runAnalyzer, timeoutMs: 200});
122
+ const r = await bus.emit("post-tool-use", {tool: "edit", eventId: "e-clean", file: "src/ok.ts"});
123
+ console.log(JSON.stringify({
124
+ allowed: direct.allowed, annotations: direct.annotations,
125
+ emitAllowed: r.allowed, notices: notices.length,
126
+ }));
127
+ """
128
+ )
129
+ self.assertEqual(res.returncode, 0, f"clean test failed: {res.stderr}")
130
+ data = last_json_object(res.stdout)
131
+ self.assertTrue(data["allowed"])
132
+ self.assertEqual(data["annotations"], [])
133
+ self.assertTrue(data["emitAllowed"])
134
+ self.assertEqual(data["notices"], 0, "clean file must not toast")
135
+
136
+
137
+ class TestAnalyzerMissingSkip(unittest.TestCase):
138
+ def test_missing_analyzer_single_audit_silent_skip(self):
139
+ res = run_node(
140
+ BRIDGE_IMPORT
141
+ + """
142
+ const runAnalyzer = async () => ({status: "missing"});
143
+ """
144
+ + SETUP_BRIDGE
145
+ + """
146
+ const mark = bus.getAuditLog().length;
147
+ let threw = false;
148
+ let r = null;
149
+ try {
150
+ r = await bus.emit("pre-tool-use", {tool: "edit", eventId: "e-miss", file: "src/app.ts"});
151
+ } catch { threw = true; }
152
+ const fresh = bus.getAuditLog().slice(mark);
153
+ console.log(JSON.stringify({
154
+ threw, allowed: r && r.allowed,
155
+ fresh: fresh.length,
156
+ outcomes: fresh.map((a) => a.outcome),
157
+ reasons: fresh.map((a) => a.reason || ""),
158
+ notices: notices.length,
159
+ }));
160
+ """
161
+ )
162
+ self.assertEqual(res.returncode, 0, f"missing test failed: {res.stderr}")
163
+ data = last_json_object(res.stdout)
164
+ self.assertFalse(data["threw"], "analyzer-missing must never throw")
165
+ self.assertTrue(data["allowed"], "analyzer-missing must fail open")
166
+ self.assertEqual(data["fresh"], 1, "exactly one audit entry for the skip")
167
+ self.assertEqual(data["outcomes"], ["allow"])
168
+ self.assertTrue(any("analyzer" in reason for reason in data["reasons"]))
169
+ self.assertEqual(
170
+ data["notices"], 0, "missing analyzer must stay silent (no toast)"
171
+ )
172
+
173
+
174
+ class TestNeverDeny(unittest.TestCase):
175
+ def test_severe_findings_still_allow(self):
176
+ res = run_node(
177
+ BRIDGE_IMPORT
178
+ + """
179
+ const runAnalyzer = async (files) => ({
180
+ status: "ok",
181
+ findings: [
182
+ { rule: "biome/critical-sev", file: files[0], line: 1, message: "catastrophic", severity: "error" },
183
+ { rule: "biome/blocker", file: files[0], line: 2, message: "deny-shaped", severity: "error" },
184
+ ],
185
+ });
186
+ const bus = createHookBus();
187
+ const handler = createBridgeHandler(bus, {runAnalyzer, timeoutMs: 200});
188
+ bus.on("pre-tool-use", handler, {tier: "fast", name: "analysis-bridge"});
189
+ const direct = await handler({tool: "edit", eventId: "e-sev", file: "src/app.ts"});
190
+ const r = await bus.emit("pre-commit", {tool: "edit", eventId: "e-sev", file: "src/app.ts"});
191
+ const verdict = typeof direct === "string" ? direct : direct && direct.verdict;
192
+ console.log(JSON.stringify({
193
+ directAllowed: direct !== false && verdict !== "deny",
194
+ verdict, emitAllowed: r.allowed,
195
+ }));
196
+ """
197
+ )
198
+ self.assertEqual(res.returncode, 0, f"never-deny test failed: {res.stderr}")
199
+ data = last_json_object(res.stdout)
200
+ self.assertTrue(data["directAllowed"], "bridge handler must never return deny")
201
+ self.assertNotEqual(data["verdict"], "deny")
202
+ self.assertNotEqual(data["verdict"], False)
203
+ self.assertTrue(
204
+ data["emitAllowed"], "severe findings must not block the caller"
205
+ )
206
+
207
+
208
+ class TestAuditShape(unittest.TestCase):
209
+ def test_every_annotation_carries_required_keys(self):
210
+ res = run_node(
211
+ BRIDGE_IMPORT
212
+ + MOCK_TWO_FINDINGS
213
+ + """
214
+ const direct = await analyzePayload(
215
+ {tool: "edit", eventId: "e-shape", file: "src/app.ts"},
216
+ {runAnalyzer, timeoutMs: 200},
217
+ );
218
+ const keys = direct.annotations.map((a) => Object.keys(a).sort());
219
+ const bus2 = createHookBus();
220
+ await registerAnalysisBridge(null, {bus: bus2, runAnalyzer, notify: () => {}, timeoutMs: 200});
221
+ await bus2.emit("pre-tool-use", {tool: "edit", eventId: "e-shape", file: "src/app.ts"});
222
+ const records = bus2.getAuditLog().filter((a) => a.event === "notification");
223
+ console.log(JSON.stringify({
224
+ allowed: direct.allowed, keys,
225
+ annotations: direct.annotations,
226
+ recordKeys: records.map((a) => Object.keys(a).sort()),
227
+ recordTools: records.map((a) => [a.tool, a.eventId]),
228
+ }));
229
+ """
230
+ )
231
+ self.assertEqual(res.returncode, 0, f"audit-shape test failed: {res.stderr}")
232
+ data = last_json_object(res.stdout)
233
+ self.assertTrue(data["allowed"])
234
+ required = {"tool", "eventId", "rule", "file", "line", "message", "severity"}
235
+ self.assertEqual(len(data["annotations"]), 2)
236
+ for key_set in data["keys"]:
237
+ self.assertTrue(
238
+ required.issubset(set(key_set)),
239
+ f"annotation missing keys: {required - set(key_set)}",
240
+ )
241
+ for ann in data["annotations"]:
242
+ self.assertEqual(ann["tool"], "edit")
243
+ self.assertEqual(ann["eventId"], "e-shape")
244
+ self.assertEqual(len(data["recordKeys"]), 2, "both findings audit-logged")
245
+ for key_set in data["recordKeys"]:
246
+ self.assertIn("tool", key_set)
247
+ self.assertIn("eventId", key_set)
248
+ for tool, event_id in data["recordTools"]:
249
+ self.assertEqual(tool, "edit")
250
+ self.assertEqual(event_id, "e-shape")
251
+
252
+
253
+ class TestSlowOverrun(unittest.TestCase):
254
+ def test_overrun_degrades_to_timeout_caller_unblocked(self):
255
+ res = run_node(
256
+ BRIDGE_IMPORT
257
+ + """
258
+ const runAnalyzer = () => new Promise(() => {});
259
+ const t0 = Date.now();
260
+ const direct = await analyzePayload(
261
+ {tool: "edit", eventId: "e-slow", file: "src/app.ts"},
262
+ {runAnalyzer, timeoutMs: 60},
263
+ );
264
+ const elapsed = Date.now() - t0;
265
+ const bus2 = createHookBus();
266
+ const handler = createBridgeHandler(bus2, {runAnalyzer, timeoutMs: 60, notify: () => {}});
267
+ bus2.on("pre-tool-use", handler, {tier: "fast", name: "analysis-bridge"});
268
+ const t1 = Date.now();
269
+ const r = await bus2.emit("pre-tool-use", {tool: "edit", eventId: "e-slow2", file: "src/app.ts"}, {fastTimeoutMs: 300});
270
+ const emitElapsed = Date.now() - t1;
271
+ console.log(JSON.stringify({
272
+ allowed: direct.allowed, elapsed,
273
+ rules: direct.annotations.map((a) => a.rule),
274
+ keys: direct.annotations.length ? Object.keys(direct.annotations[0]).sort() : [],
275
+ emitAllowed: r.allowed, emitElapsed,
276
+ }));
277
+ """
278
+ )
279
+ self.assertEqual(res.returncode, 0, f"overrun test failed: {res.stderr}")
280
+ data = last_json_object(res.stdout)
281
+ self.assertTrue(data["allowed"], "overrun must fail open")
282
+ self.assertLess(
283
+ data["elapsed"], 2000, "analyzer overrun must resolve near the fast budget"
284
+ )
285
+ self.assertEqual(data["rules"], ["analysis-bridge/timeout"])
286
+ self.assertTrue(
287
+ {"tool", "eventId", "rule", "file", "line", "message", "severity"}.issubset(
288
+ set(data["keys"])
289
+ )
290
+ )
291
+ self.assertTrue(data["emitAllowed"], "bus caller stays unblocked on overrun")
292
+ self.assertLess(data["emitElapsed"], 2000)
293
+
294
+
295
+ class TestSetupAdoptsBridge(unittest.TestCase):
296
+ def test_setup_adopts_and_disposes_bridge(self):
297
+ res = run_node(
298
+ """
299
+ import plugin, { hookBus } from "./plugins/opencode/index.js";
300
+ const before = {
301
+ pre: hookBus.handlerCount("pre-tool-use"),
302
+ post: hookBus.handlerCount("post-tool-use"),
303
+ commit: hookBus.handlerCount("pre-commit"),
304
+ };
305
+ const host = {
306
+ options: {},
307
+ command: {
308
+ list: async () => ({data: []}),
309
+ transform: async (fn) => { fn({add: () => {}}); return {dispose: async () => {}}; }
310
+ },
311
+ tool: {
312
+ transform: async (fn) => { fn({add: () => {}}); return {dispose: async () => {}}; }
313
+ },
314
+ session: { prompt: async () => ({}) }
315
+ };
316
+ const cleanup = await plugin.setup(host);
317
+ const after = {
318
+ pre: hookBus.handlerCount("pre-tool-use"),
319
+ post: hookBus.handlerCount("post-tool-use"),
320
+ commit: hookBus.handlerCount("pre-commit"),
321
+ };
322
+ const attached = !!host.iumbtemsAnalysisBridge;
323
+ await cleanup();
324
+ const closed = {
325
+ pre: hookBus.handlerCount("pre-tool-use"),
326
+ post: hookBus.handlerCount("post-tool-use"),
327
+ commit: hookBus.handlerCount("pre-commit"),
328
+ };
329
+ console.log(JSON.stringify({before, after, attached, closed}));
330
+ """
331
+ )
332
+ self.assertEqual(res.returncode, 0, f"adopt test failed: {res.stderr}")
333
+ data = last_json_object(res.stdout)
334
+ self.assertTrue(data["attached"], "bridge must be adopted on setup")
335
+ self.assertGreater(
336
+ data["after"]["pre"],
337
+ data["before"]["pre"],
338
+ "pre-tool-use bridge handler subscribed",
339
+ )
340
+ self.assertGreater(
341
+ data["after"]["post"],
342
+ data["before"]["post"],
343
+ "post-tool-use bridge handler subscribed",
344
+ )
345
+ self.assertGreater(
346
+ data["after"]["commit"],
347
+ data["before"]["commit"],
348
+ "pre-commit bridge handler subscribed",
349
+ )
350
+ self.assertEqual(
351
+ data["closed"], data["before"], "cleanup must dispose bridge subscriptions"
352
+ )
353
+
354
+
355
+ if __name__ == "__main__":
356
+ unittest.main()