@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
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IUMBTEMS coverage + treeshake advisory signals (Phase 04-coverage-treeshake-signals):
|
|
3
|
+
* coverage deltas (existing unittest suites) + esbuild-measured bundle weight
|
|
4
|
+
* (plugin surface) as advisory dossier annotations + audit log.
|
|
5
|
+
*
|
|
6
|
+
* Third hook-bus consumer (after the Phase-02 analysis bridge and the Phase-03
|
|
7
|
+
* dep-health gate): measures (never emits — `metafile:true, write:false`) and
|
|
8
|
+
* reports everything as ADVISORY annotations. This module NEVER returns deny:
|
|
9
|
+
* even a +500% weight blowup allows (pinned by the never-blocks test); the
|
|
10
|
+
* escalation path is a checked-in baseline + explicit user sign-off, deferred
|
|
11
|
+
* to hardening (Gate-0 R5) — there are NO blocking thresholds in this phase.
|
|
12
|
+
*
|
|
13
|
+
* Bundle record shape: `{ tool, eventId, kind, status, entry, bytes, files,
|
|
14
|
+
* topHeaviest, deltaVsBaseline, baselineStale, message }` — every key ALWAYS
|
|
15
|
+
* present (`null` when unknown), reusing the Phase-02 annotation shape
|
|
16
|
+
* `{ tool, eventId, ... }`. Coverage record shape: `{ tool, eventId, kind,
|
|
17
|
+
* status, scope, passedBefore, totalBefore, passedAfter, totalAfter, delta,
|
|
18
|
+
* direction, message }` — likewise always-present. Every record is
|
|
19
|
+
* audit-logged on the bus (one `notification` record per record via the
|
|
20
|
+
* internal audit sink); the toast summarizes counts only (no record text).
|
|
21
|
+
*
|
|
22
|
+
* esbuild missing/failing or malformed metafile degrades to a RECORDED
|
|
23
|
+
* `unavailable` entry and fail-open allow — never throws into the host (same
|
|
24
|
+
* contract as 02-analyzer-missing / 03-offline).
|
|
25
|
+
*
|
|
26
|
+
* Zero runtime dependencies (node builtins only: fs, path, url). esbuild is a
|
|
27
|
+
* pinned devDependency used for measurement only; it is loaded lazily and its
|
|
28
|
+
* absence is a supported state, never an error.
|
|
29
|
+
*
|
|
30
|
+
* Phase evidence (cited per programmer brief):
|
|
31
|
+
* - Plugin host surface (the measured bundle entry)
|
|
32
|
+
* [VERIFIED: sha256:f42787d2ce436bbd6d55e90a15d45b656290a1a44451fff3318b094a4582ce01
|
|
33
|
+
* `file:///home/john/Projects/IUMBTEMS/plugins/opencode/index.js`]
|
|
34
|
+
* - Bus contract (six blockable events, tiered enforcement, fail-open,
|
|
35
|
+
* tool/eventId audit guarantees) — the annotation transport
|
|
36
|
+
* [VERIFIED: sha256:f2c0843b598ba6f19cc0e208f8d02e0250c70615e3c484474d50635d0b5cdef8
|
|
37
|
+
* `file:///home/john/Projects/IUMBTEMS/plugins/opencode/hook-bus.js`]
|
|
38
|
+
* - Never-deny advisory bridge precedent ({allowed:true} on every path,
|
|
39
|
+
* counts-only toast) — the pattern weight-signals reuses
|
|
40
|
+
* [VERIFIED: sha256:cd66253fec923d8210d2863f98d9c5669662ac70c3a1939f2bd8ea30ed007644
|
|
41
|
+
* `file:///home/john/Projects/IUMBTEMS/plugins/opencode/analysis-bridge.js`]
|
|
42
|
+
* - Coverage-gate helper precedent (scanned/unavailable/absent reporting,
|
|
43
|
+
* severities never fail) — the honest-status vocabulary
|
|
44
|
+
* [VERIFIED: sha256:da194d498bc256f2b619c406015a3fb600712ec5b8c8ab9001fc38f0a6f5c8cc
|
|
45
|
+
* `file:///home/john/Projects/IUMBTEMS/plugins/opencode/dep-health.js`]
|
|
46
|
+
* - package.json baseline (runtime solid-js + @opentui/solid only; Biome
|
|
47
|
+
* 2.5.15 the sole devDep — esbuild becomes the second approved devDep)
|
|
48
|
+
* [VERIFIED: sha256:7690b878a654ee4dee2b649b60740bc95d4b746cf786051887153b6f656f2e5e
|
|
49
|
+
* `file:///home/john/Projects/IUMBTEMS/package.json`]
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { readFileSync } from 'node:fs';
|
|
53
|
+
import path from 'node:path';
|
|
54
|
+
import { fileURLToPath } from 'node:url';
|
|
55
|
+
import { SLOW_TIMEOUT_MS } from './hook-bus.js';
|
|
56
|
+
|
|
57
|
+
/** Tool tag carried on every record and audit entry from this consumer. */
|
|
58
|
+
export const WEIGHT_TOOL = 'weight-signals';
|
|
59
|
+
|
|
60
|
+
/** Bus events this consumer subscribes (programmer commit-time point). */
|
|
61
|
+
export const WEIGHT_EVENTS = Object.freeze(['pre-commit']);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build budget: the whole measure must settle inside it. Kept under the
|
|
65
|
+
* slow-tier budget so a hung esbuild can never brick the caller — an overrun
|
|
66
|
+
* degrades to a recorded `unavailable` entry and fail-open allow.
|
|
67
|
+
*/
|
|
68
|
+
export const WEIGHT_TIMEOUT_MS = Math.min(4000, SLOW_TIMEOUT_MS - 1000);
|
|
69
|
+
|
|
70
|
+
/** Cap on heaviest-file entries per bundle record (bounds audit fan-out). */
|
|
71
|
+
export const TOP_N_HEAVIEST = 5;
|
|
72
|
+
|
|
73
|
+
/** Marker distinguishing per-record audit entries on `notification`. */
|
|
74
|
+
export const WEIGHT_AUDIT_KIND = 'weight-signal';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A baseline older than this is `stale-baseline`: still measured against
|
|
78
|
+
* (deltas still compute) but annotated, still allowing. Refresh is an
|
|
79
|
+
* explicit user-confirmed step, never automatic.
|
|
80
|
+
*/
|
|
81
|
+
export const BASELINE_STALE_AFTER_MS = 90 * 24 * 60 * 60 * 1000;
|
|
82
|
+
|
|
83
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
84
|
+
const __dirname = path.dirname(__filename);
|
|
85
|
+
const BASELINE_FILE = path.join(__dirname, 'weight-baseline.json');
|
|
86
|
+
|
|
87
|
+
/** Default bundle entry: the plugin host surface (the shipped code). */
|
|
88
|
+
export const DEFAULT_ENTRY = path.join(__dirname, 'index.js');
|
|
89
|
+
|
|
90
|
+
function resolveTool(payload) {
|
|
91
|
+
try {
|
|
92
|
+
const v = payload?.tool;
|
|
93
|
+
return typeof v === 'string' && v.trim() ? v : WEIGHT_TOOL;
|
|
94
|
+
} catch {
|
|
95
|
+
return WEIGHT_TOOL;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resolveEventId(payload) {
|
|
100
|
+
try {
|
|
101
|
+
const v = payload?.eventId ?? payload?.event_id ?? null;
|
|
102
|
+
return typeof v === 'string' || typeof v === 'number' ? v : null;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function numOrNull(v) {
|
|
109
|
+
try {
|
|
110
|
+
if (v === null || v === undefined) return null;
|
|
111
|
+
if (typeof v === 'string' && !v.trim()) return null;
|
|
112
|
+
const n = Number(v);
|
|
113
|
+
return Number.isFinite(n) ? n : null;
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Normalise one bundle-weight record. Every key is ALWAYS present (`null`
|
|
121
|
+
* when unknown) so audit/dossier consumers can rely on shape — the same
|
|
122
|
+
* guarantee the bus gives `tool`/`eventId` and the bridge gives its seven
|
|
123
|
+
* finding keys. `status` is `scanned` (measured) or `unavailable` (esbuild
|
|
124
|
+
* missing/failed or metafile malformed — a recorded fact, never a throw).
|
|
125
|
+
*/
|
|
126
|
+
export function toWeightRecord({
|
|
127
|
+
tool, eventId, status, entry, bytes, files,
|
|
128
|
+
topHeaviest, deltaVsBaseline, baselineStale, message,
|
|
129
|
+
}) {
|
|
130
|
+
let top = null;
|
|
131
|
+
try {
|
|
132
|
+
if (Array.isArray(topHeaviest)) {
|
|
133
|
+
top = topHeaviest
|
|
134
|
+
.filter((e) => e && typeof e === 'object')
|
|
135
|
+
.slice(0, TOP_N_HEAVIEST)
|
|
136
|
+
.map((e) => ({
|
|
137
|
+
file: typeof e.file === 'string' && e.file ? e.file : null,
|
|
138
|
+
bytes: numOrNull(e.bytes),
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
top = null;
|
|
143
|
+
}
|
|
144
|
+
let delta = null;
|
|
145
|
+
try {
|
|
146
|
+
if (deltaVsBaseline && typeof deltaVsBaseline === 'object') {
|
|
147
|
+
const b = numOrNull(deltaVsBaseline.bytes);
|
|
148
|
+
const p = numOrNull(deltaVsBaseline.pct);
|
|
149
|
+
delta = b === null && p === null ? null : { bytes: b, pct: p };
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
delta = null;
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
tool: typeof tool === 'string' && tool ? tool : WEIGHT_TOOL,
|
|
156
|
+
eventId: eventId ?? null,
|
|
157
|
+
kind: 'bundle-weight',
|
|
158
|
+
status: status === 'scanned' ? 'scanned' : 'unavailable',
|
|
159
|
+
entry: typeof entry === 'string' && entry ? entry : null,
|
|
160
|
+
bytes: numOrNull(bytes),
|
|
161
|
+
files: numOrNull(files),
|
|
162
|
+
topHeaviest: top,
|
|
163
|
+
deltaVsBaseline: delta,
|
|
164
|
+
baselineStale: baselineStale === true,
|
|
165
|
+
message: typeof message === 'string' && message ? message : null,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The eleven always-present bundle-record keys (pinned by the shape test). */
|
|
170
|
+
export const WEIGHT_RECORD_KEYS = Object.freeze([
|
|
171
|
+
'tool', 'eventId', 'kind', 'status', 'entry', 'bytes', 'files',
|
|
172
|
+
'topHeaviest', 'deltaVsBaseline', 'baselineStale', 'message',
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Normalise one coverage-delta record from unittest tallies. `delta` is the
|
|
177
|
+
* pass-rate delta in percentage points (after − before, 2dp); `direction` is
|
|
178
|
+
* `up` | `down` | `flat` (`flat` inside ±0.5pp), or `unknown` when a tally is
|
|
179
|
+
* missing/empty (status `unavailable` — recorded, never a throw).
|
|
180
|
+
*/
|
|
181
|
+
export function toCoverageRecord({
|
|
182
|
+
tool, eventId, scope, passedBefore, totalBefore,
|
|
183
|
+
passedAfter, totalAfter, message,
|
|
184
|
+
}) {
|
|
185
|
+
const pb = numOrNull(passedBefore);
|
|
186
|
+
const tb = numOrNull(totalBefore);
|
|
187
|
+
const pa = numOrNull(passedAfter);
|
|
188
|
+
const ta = numOrNull(totalAfter);
|
|
189
|
+
const usable =
|
|
190
|
+
pb !== null && tb !== null && pa !== null && ta !== null &&
|
|
191
|
+
tb > 0 && ta > 0 && pb >= 0 && pa >= 0 && pb <= tb && pa <= ta;
|
|
192
|
+
let delta = null;
|
|
193
|
+
let direction = 'unknown';
|
|
194
|
+
let status = 'unavailable';
|
|
195
|
+
if (usable) {
|
|
196
|
+
const raw = (pa / ta - pb / tb) * 100;
|
|
197
|
+
delta = Math.round(raw * 100) / 100;
|
|
198
|
+
direction = delta > 0.5 ? 'up' : delta < -0.5 ? 'down' : 'flat';
|
|
199
|
+
status = 'scanned';
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
tool: typeof tool === 'string' && tool ? tool : WEIGHT_TOOL,
|
|
203
|
+
eventId: eventId ?? null,
|
|
204
|
+
kind: 'coverage-delta',
|
|
205
|
+
status,
|
|
206
|
+
scope: typeof scope === 'string' && scope ? scope : null,
|
|
207
|
+
passedBefore: pb,
|
|
208
|
+
totalBefore: tb,
|
|
209
|
+
passedAfter: pa,
|
|
210
|
+
totalAfter: ta,
|
|
211
|
+
delta,
|
|
212
|
+
direction,
|
|
213
|
+
message: typeof message === 'string' && message ? message : null,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The twelve always-present coverage-record keys (pinned by the shape test). */
|
|
218
|
+
export const COVERAGE_RECORD_KEYS = Object.freeze([
|
|
219
|
+
'tool', 'eventId', 'kind', 'status', 'scope', 'passedBefore',
|
|
220
|
+
'totalBefore', 'passedAfter', 'totalAfter', 'delta', 'direction', 'message',
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Coverage delta from unittest count/pass tallies per scope:
|
|
225
|
+
* `{before: {passed, total}, after: {passed, total}, scope}` →
|
|
226
|
+
* `{delta, direction}` inside a full always-present-keys record. Pure, never
|
|
227
|
+
* throws, NEVER denies (advisory only — the record carries `allowed: true`
|
|
228
|
+
* for bus-consumer uniformity).
|
|
229
|
+
*/
|
|
230
|
+
export function coverageDelta({ before, after, scope, tool, eventId } = {}) {
|
|
231
|
+
try {
|
|
232
|
+
const rec = toCoverageRecord({
|
|
233
|
+
tool: resolveTool({ tool }),
|
|
234
|
+
eventId: eventId ?? null,
|
|
235
|
+
scope,
|
|
236
|
+
passedBefore: before?.passed,
|
|
237
|
+
totalBefore: before?.total,
|
|
238
|
+
passedAfter: after?.passed,
|
|
239
|
+
totalAfter: after?.total,
|
|
240
|
+
message: scope
|
|
241
|
+
? `coverage ${scope}: ${before?.passed ?? '?'}/${before?.total ?? '?'} → ${after?.passed ?? '?'}/${after?.total ?? '?'} (advisory; action allowed)`
|
|
242
|
+
: null,
|
|
243
|
+
});
|
|
244
|
+
return { allowed: true, delta: rec.delta, direction: rec.direction, record: rec };
|
|
245
|
+
} catch {
|
|
246
|
+
return {
|
|
247
|
+
allowed: true,
|
|
248
|
+
delta: null,
|
|
249
|
+
direction: 'unknown',
|
|
250
|
+
record: toCoverageRecord({}),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Metafile parsing (pure) + esbuild runner (lazy, fail-open). Never throw.
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Parse an esbuild metafile (`metafile:true`) into
|
|
261
|
+
* `{ status: 'ok', bytes, files, topHeaviest }` where `bytes` sums output
|
|
262
|
+
* bytes, `files` counts distinct inputs (import-graph size), and
|
|
263
|
+
* `topHeaviest` lists the `topN` heaviest inputs. Anything else-shaped
|
|
264
|
+
* resolves `{ status: 'malformed' }`. Never throws.
|
|
265
|
+
*/
|
|
266
|
+
export function parseMetafile(metafile, { topN } = {}) {
|
|
267
|
+
try {
|
|
268
|
+
const n = Number(topN ?? TOP_N_HEAVIEST);
|
|
269
|
+
const cap = Number.isFinite(n) && n > 0 ? Math.floor(n) : TOP_N_HEAVIEST;
|
|
270
|
+
if (!metafile || typeof metafile !== 'object' || Array.isArray(metafile)) {
|
|
271
|
+
return { status: 'malformed' };
|
|
272
|
+
}
|
|
273
|
+
const outputs = metafile.outputs;
|
|
274
|
+
const inputs = metafile.inputs;
|
|
275
|
+
if (!outputs || typeof outputs !== 'object' || Array.isArray(outputs)) {
|
|
276
|
+
return { status: 'malformed' };
|
|
277
|
+
}
|
|
278
|
+
let bytes = 0;
|
|
279
|
+
let sawOutput = false;
|
|
280
|
+
for (const out of Object.values(outputs)) {
|
|
281
|
+
if (!out || typeof out !== 'object') continue;
|
|
282
|
+
const b = Number(out.bytes);
|
|
283
|
+
if (!Number.isFinite(b) || b < 0) return { status: 'malformed' };
|
|
284
|
+
bytes += b;
|
|
285
|
+
sawOutput = true;
|
|
286
|
+
}
|
|
287
|
+
if (!sawOutput) return { status: 'malformed' };
|
|
288
|
+
const entries = [];
|
|
289
|
+
if (inputs && typeof inputs === 'object' && !Array.isArray(inputs)) {
|
|
290
|
+
for (const [file, info] of Object.entries(inputs)) {
|
|
291
|
+
const b = Number(info?.bytes);
|
|
292
|
+
entries.push({ file, bytes: Number.isFinite(b) && b >= 0 ? b : 0 });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
entries.sort((a, b) => b.bytes - a.bytes);
|
|
296
|
+
return {
|
|
297
|
+
status: 'ok',
|
|
298
|
+
bytes,
|
|
299
|
+
files: entries.length,
|
|
300
|
+
topHeaviest: entries.slice(0, cap),
|
|
301
|
+
};
|
|
302
|
+
} catch {
|
|
303
|
+
return { status: 'malformed' };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Default esbuild runner: bundles `entry` with `metafile:true, write:false`
|
|
309
|
+
* (measure, never emit). Resolves `{ status: 'ok', metafile }`,
|
|
310
|
+
* `{ status: 'missing' }` (esbuild not installed — a supported state), or
|
|
311
|
+
* `{ status: 'error', message }`. Never rejects, never throws.
|
|
312
|
+
*/
|
|
313
|
+
export function defaultRunBuild({ entry, timeoutMs } = {}) {
|
|
314
|
+
const budget = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) >= 0
|
|
315
|
+
? Number(timeoutMs) : WEIGHT_TIMEOUT_MS;
|
|
316
|
+
return new Promise((resolve) => {
|
|
317
|
+
let done = false;
|
|
318
|
+
const finish = (v) => {
|
|
319
|
+
if (done) return;
|
|
320
|
+
done = true;
|
|
321
|
+
resolve(v);
|
|
322
|
+
};
|
|
323
|
+
const timer = setTimeout(() => finish({ status: 'timeout' }), budget);
|
|
324
|
+
(async () => {
|
|
325
|
+
let esbuild;
|
|
326
|
+
try {
|
|
327
|
+
esbuild = await import('esbuild');
|
|
328
|
+
} catch {
|
|
329
|
+
clearTimeout(timer);
|
|
330
|
+
finish({ status: 'missing' });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
const build = esbuild.build ?? esbuild.default?.build;
|
|
335
|
+
if (typeof build !== 'function') {
|
|
336
|
+
clearTimeout(timer);
|
|
337
|
+
finish({ status: 'error', message: 'esbuild module has no build()' });
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const result = await build({
|
|
341
|
+
entryPoints: [entry],
|
|
342
|
+
bundle: true,
|
|
343
|
+
metafile: true,
|
|
344
|
+
write: false,
|
|
345
|
+
platform: 'node',
|
|
346
|
+
format: 'esm',
|
|
347
|
+
logLevel: 'silent',
|
|
348
|
+
// esbuild is the measuring tool, not shipped code (this module
|
|
349
|
+
// loads it lazily via dynamic import): keep it external so the
|
|
350
|
+
// weight reflects the plugin surface, not the ruler.
|
|
351
|
+
external: ['esbuild'],
|
|
352
|
+
});
|
|
353
|
+
clearTimeout(timer);
|
|
354
|
+
if (result?.metafile) finish({ status: 'ok', metafile: result.metafile });
|
|
355
|
+
else finish({ status: 'error', message: 'esbuild returned no metafile' });
|
|
356
|
+
} catch (err) {
|
|
357
|
+
clearTimeout(timer);
|
|
358
|
+
finish({ status: 'error', message: String(err?.message ?? err) });
|
|
359
|
+
}
|
|
360
|
+
})().catch((err) => {
|
|
361
|
+
clearTimeout(timer);
|
|
362
|
+
finish({ status: 'error', message: String(err?.message ?? err) });
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Race any runner against the build budget. Never rejects. */
|
|
368
|
+
function runBounded(runnerPromise, timeoutMs) {
|
|
369
|
+
return new Promise((resolve) => {
|
|
370
|
+
let done = false;
|
|
371
|
+
const timer = setTimeout(() => {
|
|
372
|
+
if (done) return;
|
|
373
|
+
done = true;
|
|
374
|
+
resolve({ status: 'timeout' });
|
|
375
|
+
}, timeoutMs);
|
|
376
|
+
Promise.resolve(runnerPromise).then(
|
|
377
|
+
(v) => {
|
|
378
|
+
if (done) return;
|
|
379
|
+
done = true;
|
|
380
|
+
clearTimeout(timer);
|
|
381
|
+
resolve(v && typeof v === 'object' ? v : { status: 'error', message: 'malformed build result' });
|
|
382
|
+
},
|
|
383
|
+
(err) => {
|
|
384
|
+
if (done) return;
|
|
385
|
+
done = true;
|
|
386
|
+
clearTimeout(timer);
|
|
387
|
+
resolve({ status: 'error', message: String(err?.message ?? err) });
|
|
388
|
+
},
|
|
389
|
+
);
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// Baseline (checked in; refresh is user-confirmed only, never automatic).
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Load the checked-in baseline: explicit `text` wins, else read
|
|
399
|
+
* `weight-baseline.json` next to this module. Resolves the parsed object or
|
|
400
|
+
* `null` (missing/unparseable — callers record, never throw). Never throws.
|
|
401
|
+
*/
|
|
402
|
+
export function loadBaseline({ text, file } = {}) {
|
|
403
|
+
try {
|
|
404
|
+
const raw = typeof text === 'string'
|
|
405
|
+
? text
|
|
406
|
+
: readFileSync(file || BASELINE_FILE, 'utf-8');
|
|
407
|
+
const parsed = JSON.parse(String(raw || ''));
|
|
408
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
409
|
+
return parsed;
|
|
410
|
+
} catch {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* A baseline is stale when its `measuredAt` is older than `maxAgeMs`
|
|
417
|
+
* (default `BASELINE_STALE_AFTER_MS`). A stale baseline still measures
|
|
418
|
+
* against (deltas still compute) — it only adds a `stale-baseline`
|
|
419
|
+
* annotation, still allowing. Missing/unparseable `measuredAt` counts as
|
|
420
|
+
* stale (honest: age unknown). Never throws.
|
|
421
|
+
*/
|
|
422
|
+
export function isBaselineStale(baseline, { now, maxAgeMs } = {}) {
|
|
423
|
+
try {
|
|
424
|
+
if (!baseline || typeof baseline !== 'object') return true;
|
|
425
|
+
const maxAge = Number.isFinite(Number(maxAgeMs)) && Number(maxAgeMs) >= 0
|
|
426
|
+
? Number(maxAgeMs) : BASELINE_STALE_AFTER_MS;
|
|
427
|
+
const at = Date.parse(baseline.measuredAt);
|
|
428
|
+
if (!Number.isFinite(at)) return true;
|
|
429
|
+
const t = now !== undefined ? Number(now) : Date.now();
|
|
430
|
+
if (!Number.isFinite(t)) return true;
|
|
431
|
+
return t - at > maxAge;
|
|
432
|
+
} catch {
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function unavailableRecord(tool, eventId, entry, detail) {
|
|
438
|
+
return toWeightRecord({
|
|
439
|
+
tool, eventId, status: 'unavailable', entry,
|
|
440
|
+
bytes: null, files: null, topHeaviest: null, deltaVsBaseline: null,
|
|
441
|
+
baselineStale: false,
|
|
442
|
+
message: `bundle weight unavailable: ${detail} (recorded; action allowed)`,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
// measureBundle + assessWeight — the advisory scanners. NEVER deny.
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Measure one bundle entry. ALWAYS resolves `{ allowed: true, annotations }`
|
|
452
|
+
* — this function cannot deny (advisory-only per gate C1) and never throws.
|
|
453
|
+
*
|
|
454
|
+
* Inputs (all optional): `entry` (defaults to the plugin host surface),
|
|
455
|
+
* `metafile` (a precomputed esbuild metafile — parsed directly, no build),
|
|
456
|
+
* `runBuild` (injected runner; defaults to the lazy esbuild build),
|
|
457
|
+
* `baseline` (parsed baseline object; explicit wins, else the checked-in
|
|
458
|
+
* file), `timeoutMs`, `topN`, `tool`, `eventId`.
|
|
459
|
+
*
|
|
460
|
+
* `annotations[0]` is always the bundle-weight record (status `scanned` or
|
|
461
|
+
* `unavailable`); a stale baseline appends a `stale-baseline` record — still
|
|
462
|
+
* allowing. `deltaVsBaseline` is `{bytes, pct}` against `baseline.bytes`
|
|
463
|
+
* (null without a numeric baseline). A +500% blowup still allows.
|
|
464
|
+
*/
|
|
465
|
+
export async function measureBundle(opts = {}) {
|
|
466
|
+
try {
|
|
467
|
+
const tool = resolveTool(opts);
|
|
468
|
+
const eventId = resolveEventId(opts);
|
|
469
|
+
const entry = typeof opts.entry === 'string' && opts.entry
|
|
470
|
+
? opts.entry
|
|
471
|
+
: DEFAULT_ENTRY;
|
|
472
|
+
const timeoutMs = Number.isFinite(Number(opts.timeoutMs)) && Number(opts.timeoutMs) >= 0
|
|
473
|
+
? Number(opts.timeoutMs) : WEIGHT_TIMEOUT_MS;
|
|
474
|
+
const topN = Number.isFinite(Number(opts.topN)) && Number(opts.topN) > 0
|
|
475
|
+
? Math.floor(Number(opts.topN)) : TOP_N_HEAVIEST;
|
|
476
|
+
const baseline = opts.baseline !== undefined && opts.baseline !== null &&
|
|
477
|
+
typeof opts.baseline === 'object'
|
|
478
|
+
? opts.baseline
|
|
479
|
+
: loadBaseline({ text: typeof opts.baselineText === 'string' ? opts.baselineText : undefined });
|
|
480
|
+
|
|
481
|
+
let parsed = null;
|
|
482
|
+
if (opts.metafile !== undefined && opts.metafile !== null) {
|
|
483
|
+
parsed = parseMetafile(opts.metafile, { topN });
|
|
484
|
+
if (parsed.status !== 'ok') {
|
|
485
|
+
return {
|
|
486
|
+
allowed: true,
|
|
487
|
+
annotations: [unavailableRecord(tool, eventId, entry, 'malformed metafile')],
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
} else {
|
|
491
|
+
const runBuild = typeof opts.runBuild === 'function' ? opts.runBuild : defaultRunBuild;
|
|
492
|
+
const res = await runBounded(
|
|
493
|
+
runBuild({ entry, timeoutMs }),
|
|
494
|
+
timeoutMs,
|
|
495
|
+
);
|
|
496
|
+
if (!res || res.status === 'missing') {
|
|
497
|
+
return {
|
|
498
|
+
allowed: true,
|
|
499
|
+
annotations: [unavailableRecord(tool, eventId, entry, 'esbuild not installed')],
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
if (res.status === 'timeout') {
|
|
503
|
+
return {
|
|
504
|
+
allowed: true,
|
|
505
|
+
annotations: [unavailableRecord(tool, eventId, entry, `esbuild exceeded the ${timeoutMs}ms budget`)],
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
if (res.status !== 'ok') {
|
|
509
|
+
return {
|
|
510
|
+
allowed: true,
|
|
511
|
+
annotations: [unavailableRecord(tool, eventId, entry, res.message || 'esbuild error')],
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
parsed = parseMetafile(res.metafile, { topN });
|
|
515
|
+
if (parsed.status !== 'ok') {
|
|
516
|
+
return {
|
|
517
|
+
allowed: true,
|
|
518
|
+
annotations: [unavailableRecord(tool, eventId, entry, 'malformed metafile from esbuild')],
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const baseBytes = numOrNull(baseline?.bytes);
|
|
524
|
+
const deltaVsBaseline = baseBytes !== null && parsed.bytes !== null
|
|
525
|
+
? {
|
|
526
|
+
bytes: parsed.bytes - baseBytes,
|
|
527
|
+
pct: baseBytes === 0
|
|
528
|
+
? null
|
|
529
|
+
: Math.round(((parsed.bytes - baseBytes) / baseBytes) * 1000) / 10,
|
|
530
|
+
}
|
|
531
|
+
: null;
|
|
532
|
+
const stale = baseline ? isBaselineStale(baseline, { maxAgeMs: opts.maxAgeMs }) : false;
|
|
533
|
+
const annotations = [toWeightRecord({
|
|
534
|
+
tool, eventId, status: 'scanned', entry,
|
|
535
|
+
bytes: parsed.bytes, files: parsed.files, topHeaviest: parsed.topHeaviest,
|
|
536
|
+
deltaVsBaseline, baselineStale: stale,
|
|
537
|
+
message: `bundle ${entry}: ${parsed.bytes} bytes across ${parsed.files} file(s)` +
|
|
538
|
+
(deltaVsBaseline ? ` (delta ${deltaVsBaseline.bytes >= 0 ? '+' : ''}${deltaVsBaseline.bytes} bytes vs baseline)` : ' (no baseline)') +
|
|
539
|
+
' (advisory; action allowed)',
|
|
540
|
+
})];
|
|
541
|
+
if (stale) {
|
|
542
|
+
annotations.push(toWeightRecord({
|
|
543
|
+
tool, eventId, status: 'unavailable', entry,
|
|
544
|
+
bytes: null, files: null, topHeaviest: null, deltaVsBaseline: null,
|
|
545
|
+
baselineStale: true,
|
|
546
|
+
message: 'stale-baseline: baseline older than the refresh window; deltas still computed — refresh is user-confirmed only (recorded; action allowed)',
|
|
547
|
+
}));
|
|
548
|
+
}
|
|
549
|
+
return { allowed: true, annotations };
|
|
550
|
+
} catch {
|
|
551
|
+
// Last resort: fail-open allow with a single unavailable record.
|
|
552
|
+
try {
|
|
553
|
+
return {
|
|
554
|
+
allowed: true,
|
|
555
|
+
annotations: [unavailableRecord(WEIGHT_TOOL, null, null, 'internal fault')],
|
|
556
|
+
};
|
|
557
|
+
} catch {
|
|
558
|
+
return { allowed: true, annotations: [] };
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
// Bus consumer: advisory records → audit log + counts-only toast.
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
/** Best-effort toast: counts only, never any record text. Never throws. */
|
|
568
|
+
export function notifyWeightSummary(opts, summary) {
|
|
569
|
+
try {
|
|
570
|
+
const fn = typeof opts?.notify === 'function' ? opts.notify : defaultWeightHostNotify(opts?.host);
|
|
571
|
+
if (typeof fn !== 'function') return;
|
|
572
|
+
try {
|
|
573
|
+
const r = fn(summary);
|
|
574
|
+
if (r && typeof r.catch === 'function') r.catch(() => {});
|
|
575
|
+
} catch {
|
|
576
|
+
/* notify is best-effort */
|
|
577
|
+
}
|
|
578
|
+
} catch {
|
|
579
|
+
/* never throws */
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function defaultWeightHostNotify(host) {
|
|
584
|
+
if (!host || typeof host !== 'object') return null;
|
|
585
|
+
try {
|
|
586
|
+
const show = host.ui?.toast?.show;
|
|
587
|
+
if (typeof show === 'function') {
|
|
588
|
+
return (s) => show.call(host.ui.toast, {
|
|
589
|
+
title: 'IUMBTEMS weight-signals',
|
|
590
|
+
message: s.message,
|
|
591
|
+
variant: 'info',
|
|
592
|
+
duration: 4000,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
} catch {
|
|
596
|
+
/* fall through */
|
|
597
|
+
}
|
|
598
|
+
try {
|
|
599
|
+
const sink = host.client?.app?.log;
|
|
600
|
+
if (typeof sink === 'function') {
|
|
601
|
+
return (s) => sink.call(host.client.app, {
|
|
602
|
+
body: { service: 'iumbtems', level: 'info', message: `weight-signals: ${s.message}` },
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
} catch {
|
|
606
|
+
/* fall through */
|
|
607
|
+
}
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Build the bus handler. Never throws into the bus and NEVER returns deny:
|
|
613
|
+
* records → `{ verdict: 'advisory', reason: '<n> record(s)…' }` (each record
|
|
614
|
+
* additionally audit-logged via one `notification` record); clean/unavailable
|
|
615
|
+
* paths still record (an `unavailable` entry is a recorded fact, not a
|
|
616
|
+
* failure) — only a targetless skip (no entry, no coverage) returns
|
|
617
|
+
* fail-open `allow`.
|
|
618
|
+
*
|
|
619
|
+
* Options pass through to `measureBundle` (`entry`, `runBuild`, `metafile`,
|
|
620
|
+
* `baseline`, `timeoutMs`, `topN`) plus `coverage: {before, after, scope}`
|
|
621
|
+
* (or `coverageBefore`/`coverageAfter`/`coverageScope`) for the delta record.
|
|
622
|
+
*/
|
|
623
|
+
export function createWeightSignalsHandler(bus, opts = {}) {
|
|
624
|
+
const runOpts = { ...opts };
|
|
625
|
+
return async function weightSignalsHandler(payload) {
|
|
626
|
+
try {
|
|
627
|
+
const cov = payload?.coverage && typeof payload.coverage === 'object'
|
|
628
|
+
? payload.coverage
|
|
629
|
+
: (runOpts.coverage || null);
|
|
630
|
+
const annotations = [];
|
|
631
|
+
const measured = await measureBundle({
|
|
632
|
+
...runOpts,
|
|
633
|
+
tool: payload?.tool,
|
|
634
|
+
eventId: payload?.eventId,
|
|
635
|
+
});
|
|
636
|
+
if (measured && Array.isArray(measured.annotations)) {
|
|
637
|
+
annotations.push(...measured.annotations);
|
|
638
|
+
}
|
|
639
|
+
const before = cov?.before ?? runOpts.coverageBefore ?? null;
|
|
640
|
+
const after = cov?.after ?? runOpts.coverageAfter ?? null;
|
|
641
|
+
if (before && after) {
|
|
642
|
+
const scope = cov?.scope ?? runOpts.coverageScope ?? null;
|
|
643
|
+
const cd = coverageDelta({
|
|
644
|
+
before, after, scope,
|
|
645
|
+
tool: payload?.tool, eventId: payload?.eventId,
|
|
646
|
+
});
|
|
647
|
+
if (cd?.record) annotations.push(cd.record);
|
|
648
|
+
}
|
|
649
|
+
if (annotations.length === 0) {
|
|
650
|
+
return { verdict: 'allow', reason: 'weight-signals: nothing to measure; skipped' };
|
|
651
|
+
}
|
|
652
|
+
// Every record audit-logged on the bus (one `notification` record
|
|
653
|
+
// each; the audit sink below turns each into exactly one audit entry
|
|
654
|
+
// carrying tool/eventId). Best-effort and awaited.
|
|
655
|
+
if (bus && typeof bus.emit === 'function') {
|
|
656
|
+
for (const a of annotations) {
|
|
657
|
+
try {
|
|
658
|
+
await bus.emit('notification', { ...a, auditKind: WEIGHT_AUDIT_KIND });
|
|
659
|
+
} catch {
|
|
660
|
+
/* one record's audit entry must not drop the rest */
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
notifyWeightSummary(runOpts, {
|
|
665
|
+
title: 'IUMBTEMS weight-signals',
|
|
666
|
+
message: `weight-signals: ${annotations.length} record(s) (advisory; action allowed)`,
|
|
667
|
+
});
|
|
668
|
+
return {
|
|
669
|
+
verdict: 'advisory',
|
|
670
|
+
reason: `weight-signals: ${annotations.length} record(s) (advisory; action allowed)`,
|
|
671
|
+
};
|
|
672
|
+
} catch {
|
|
673
|
+
return { verdict: 'allow', reason: 'weight-signals: internal fault; fail-open allow' };
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Internal audit sink on `notification`: turns each per-record entry into
|
|
680
|
+
* exactly one audit entry (fail-open `allow`, so it never blocks anything).
|
|
681
|
+
* Record detail (kind / status / bytes-or-delta, length-capped) rides in the
|
|
682
|
+
* entry's `reason` — `tool`/`eventId` travel in the entry's dedicated keys.
|
|
683
|
+
* Non-weight-signals notifications pass through as plain `allow`.
|
|
684
|
+
*/
|
|
685
|
+
export function weightSignalsAuditSink(payload) {
|
|
686
|
+
try {
|
|
687
|
+
if (payload && payload.auditKind === WEIGHT_AUDIT_KIND) {
|
|
688
|
+
const kind = typeof payload.kind === 'string' && payload.kind ? payload.kind : '?';
|
|
689
|
+
const status = typeof payload.status === 'string' && payload.status ? payload.status : '?';
|
|
690
|
+
let detail = '';
|
|
691
|
+
if (typeof payload.bytes === 'number') detail = `${payload.bytes} bytes`;
|
|
692
|
+
else if (payload.deltaVsBaseline && typeof payload.deltaVsBaseline === 'object') {
|
|
693
|
+
detail = `delta ${payload.deltaVsBaseline.bytes ?? '?'} bytes`;
|
|
694
|
+
} else if (typeof payload.delta === 'number') {
|
|
695
|
+
detail = `delta ${payload.delta}pp ${payload.direction ?? ''}`.trim();
|
|
696
|
+
}
|
|
697
|
+
let message = typeof payload.message === 'string' ? payload.message : '';
|
|
698
|
+
if (message.length > 300) message = `${message.slice(0, 297)}...`;
|
|
699
|
+
const summary = [kind, status, detail].filter(Boolean).join(' ');
|
|
700
|
+
return { verdict: 'allow', reason: `weight-signal ${summary} ${message}`.trim() };
|
|
701
|
+
}
|
|
702
|
+
} catch {
|
|
703
|
+
/* fall through to plain allow */
|
|
704
|
+
}
|
|
705
|
+
return 'allow';
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Adopt the weight-signals consumer alongside the existing registrations
|
|
710
|
+
* (same adopt/best-effort pattern as `setup` in `index.js`). Subscribes the
|
|
711
|
+
* advisory handler on the commit-time event plus the audit sink on
|
|
712
|
+
* `notification`; attaches the registration to the host for debugging
|
|
713
|
+
* (`host.iumbtemsWeightSignals`, best-effort). Returns a disposable
|
|
714
|
+
* registration; returns null (never throws) when there is no bus to adopt.
|
|
715
|
+
*/
|
|
716
|
+
export async function registerWeightSignals(host, opts = {}) {
|
|
717
|
+
try {
|
|
718
|
+
const bus = opts.bus || null;
|
|
719
|
+
if (!bus || typeof bus.on !== 'function' || typeof bus.emit !== 'function') return null;
|
|
720
|
+
const runOpts = { ...opts, host };
|
|
721
|
+
const handler = createWeightSignalsHandler(bus, runOpts);
|
|
722
|
+
const unsubs = [];
|
|
723
|
+
for (const event of WEIGHT_EVENTS) {
|
|
724
|
+
try {
|
|
725
|
+
unsubs.push(bus.on(event, handler, { tier: 'slow', name: 'weight-signals' }));
|
|
726
|
+
} catch {
|
|
727
|
+
/* one failed subscription must not drop the rest */
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
try {
|
|
731
|
+
unsubs.push(bus.on('notification', weightSignalsAuditSink, { tier: 'slow', name: 'weight-signals-audit-sink' }));
|
|
732
|
+
} catch {
|
|
733
|
+
/* sink is best-effort */
|
|
734
|
+
}
|
|
735
|
+
if (unsubs.length === 0) return null;
|
|
736
|
+
try {
|
|
737
|
+
if (host && typeof host === 'object') host.iumbtemsWeightSignals = { bus, events: [...WEIGHT_EVENTS] };
|
|
738
|
+
} catch {
|
|
739
|
+
/* host attach is best-effort */
|
|
740
|
+
}
|
|
741
|
+
let disposed = false;
|
|
742
|
+
return {
|
|
743
|
+
bus,
|
|
744
|
+
events: [...WEIGHT_EVENTS],
|
|
745
|
+
dispose: () => {
|
|
746
|
+
disposed = true;
|
|
747
|
+
for (const unsub of unsubs) {
|
|
748
|
+
try {
|
|
749
|
+
if (typeof unsub === 'function') unsub();
|
|
750
|
+
} catch {
|
|
751
|
+
/* dispose never throws */
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
try {
|
|
755
|
+
if (host && typeof host === 'object' && host.iumbtemsWeightSignals?.bus === bus) {
|
|
756
|
+
delete host.iumbtemsWeightSignals;
|
|
757
|
+
}
|
|
758
|
+
} catch {
|
|
759
|
+
/* dispose never throws */
|
|
760
|
+
}
|
|
761
|
+
},
|
|
762
|
+
get disposed() {
|
|
763
|
+
return disposed;
|
|
764
|
+
},
|
|
765
|
+
};
|
|
766
|
+
} catch {
|
|
767
|
+
return null;
|
|
768
|
+
}
|
|
769
|
+
}
|