@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
|
@@ -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
|
|
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
|
|
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
|
|
528
|
-
"
|
|
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": {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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()
|