@qontinui/shared-types 0.2.1 → 0.2.3

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.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Tauri Event Payload Types
3
+ *
4
+ * Wire-format types for payloads emitted on Tauri event channels by the
5
+ * runner. Use these to type the generic parameter of `listen<T>(...)` so
6
+ * future Rust serde renames (e.g. snake_case → camelCase) and shape
7
+ * changes break the TypeScript build instead of silently dropping events
8
+ * at the listener guard.
9
+ *
10
+ * ## Adjacently-tagged envelope (`AppEvent`)
11
+ *
12
+ * Most runner events are variants of the `AppEvent` enum, which is
13
+ * serialized via `#[serde(tag = "event_type", content = "data")]`. The
14
+ * wire payload is therefore `{ event_type: "<VariantName>", data: { ... } }`
15
+ * and consumers MUST read `event.payload.data.<field>` (NOT
16
+ * `event.payload.<field>`). The `AppEvent` discriminated union below
17
+ * encodes that envelope; reaching `data.<field>` after narrowing by
18
+ * `event_type` is type-safe.
19
+ *
20
+ * ## Direct-payload events
21
+ *
22
+ * A handful of events bypass `AppEvent` and emit a payload struct
23
+ * directly:
24
+ * - `terminal-output` → `TerminalOutputEvent`
25
+ * - `terminal-exit` → `TerminalExitEvent`
26
+ * - `terminal-created` → `TerminalInfo`
27
+ * - `finding_detected` / `finding_resolved` → wrap a `RunnerFinding`
28
+ * inside the `AppEvent` envelope's `data.finding` field as untyped
29
+ * JSON; consumers should cast `data.finding as RunnerFinding`.
30
+ *
31
+ * Source of truth:
32
+ * - `AppEvent`: `qontinui-runner/src-tauri/src/event_system/types.rs`
33
+ * - Terminal events: `qontinui-schemas/rust/src/terminal.rs`
34
+ * - `RunnerFinding`: `qontinui-runner/src-tauri/src/tauri_event_payloads.rs`
35
+ *
36
+ * Regenerate via `cd qontinui-runner && npm run gen-events`.
37
+ */
38
+
39
+ export type { AppEvent } from "../generated/AppEvent";
40
+
41
+ // ── Terminal events (direct payloads, not wrapped in AppEvent) ──
42
+ export type { TerminalInfo } from "../generated/TerminalInfo";
43
+ export type { TerminalOutputEvent } from "../generated/TerminalOutputEvent";
44
+ export type { TerminalExitEvent } from "../generated/TerminalExitEvent";
45
+
46
+ // ── Runner-local finding payload (carried inside AppEvent::FindingDetected /
47
+ // AppEvent::FindingResolved as `data.finding`). Distinct from
48
+ // `qontinui_types::verification::Finding`. ──
49
+ export type { RunnerFinding } from "../generated/RunnerFinding";
50
+ export type { RunnerFindingCodeContext } from "../generated/RunnerFindingCodeContext";
51
+ export type { RunnerFindingUserInput } from "../generated/RunnerFindingUserInput";
52
+
53
+ // ── WS relay envelopes (runner→backend) and backend→client status events.
54
+ // Discriminated unions tagged on `type`. Source of truth lives in
55
+ // `qontinui-runner/src-tauri/src/relay_envelopes.rs`; backend-originated
56
+ // events mirror `qontinui-web/backend/app/services/runner/event_publisher.py`. ──
57
+ export type { RunnerRelayMessage } from "../generated/RunnerRelayMessage";
58
+ export type { RunnerStatusEvent } from "../generated/RunnerStatusEvent";
59
+ export type { RunnerConnectedConnection } from "../generated/RunnerConnectedConnection";
60
+
61
+ // ── Dev-only seed-finding payload (`dev:seed-finding` Tauri event from
62
+ // `commands/dev_findings.rs`). Sibling to the camelCase RunnerFinding
63
+ // fields so the TS listener can spread them straight into a Finding. ──
64
+ export type { DevSeedFindingPayload } from "../generated/DevSeedFindingPayload";
65
+
66
+ // ── Recommendation review decision payloads (`review-approved` /
67
+ // `review-rejected` from `commands/productivity.rs`). One struct
68
+ // covers both channels — only `userDecision` differs. ──
69
+ export type { RecommendationReviewDecisionPayload } from "../generated/RecommendationReviewDecisionPayload";
70
+
71
+ // ── Canvas panel payload carried inside AppEvent::CanvasUpdate's
72
+ // `data.panel` field. Wire-format mirror of the runner's `StoredPanel`
73
+ // (`mcp/canvas.rs`). The hand-written `CanvasPanel` in `../canvas/index.ts`
74
+ // pre-dates this generated re-export; both shapes are intentionally
75
+ // identical. New consumers should prefer this generated import. ──
76
+ export type { CanvasPanel as CanvasUpdatePanel } from "../generated/CanvasPanel";
@@ -0,0 +1 @@
1
+ export * from "./_api";
@@ -16,6 +16,61 @@ import type { IRTransition } from "./transition";
16
16
  */
17
17
  export type IRVersion = "1.0";
18
18
 
19
+ /**
20
+ * Axes that the spec <-> IR pairing gate compares. A document's
21
+ * `pairingPolicy.acceptDrift.axes` lists which of these are
22
+ * intentionally allowed to diverge between the legacy spec and the
23
+ * IR's forward projection.
24
+ */
25
+ export type IRPairingAxis = "groupCount" | "groupIds" | "assertionCount";
26
+
27
+ /**
28
+ * Per-document opt-out for specific spec<->IR pairing axes.
29
+ *
30
+ * The canonical use case is a hand-authored IR that uses a different
31
+ * grouping convention than the legacy spec. Without an explicit policy
32
+ * the pairing gate flags it as a mismatch on every PR, which trains
33
+ * reviewers to ignore the gate. The policy makes the divergence
34
+ * explicit and audit-friendly:
35
+ *
36
+ * - `axes` — which fingerprint comparisons to skip
37
+ * - `reason` — human-readable justification (required, surfaced in
38
+ * `check-spec-pairing` summary output)
39
+ * - `since` — ISO date (YYYY-MM-DD) the exemption was added
40
+ * - `expiresAt` — optional ISO date (YYYY-MM-DD) after which the
41
+ * exemption stops applying. Forces periodic
42
+ * re-justification: the pairing gate downgrades
43
+ * expired exemptions back to ordinary mismatches.
44
+ *
45
+ * Mismatches NOT covered by the listed `axes` still fail the gate as
46
+ * usual — you can accept drift on `groupCount` while still requiring
47
+ * the assertion total to match.
48
+ */
49
+ export interface IRAcceptDriftPolicy {
50
+ /** Fingerprint axes whose mismatches are tolerated. */
51
+ axes: IRPairingAxis[];
52
+ /** Human-readable justification for the exemption. */
53
+ reason: string;
54
+ /** ISO date (YYYY-MM-DD) the exemption was added. */
55
+ since: string;
56
+ /**
57
+ * Optional ISO date (YYYY-MM-DD) after which the exemption stops
58
+ * applying. The pairing gate then surfaces the document as an
59
+ * ordinary mismatch.
60
+ */
61
+ expiresAt?: string;
62
+ }
63
+
64
+ /**
65
+ * Per-document pairing policy block. Currently only carries
66
+ * `acceptDrift`; future policies (e.g. assertion-shape exemptions)
67
+ * can extend this without breaking the IR schema.
68
+ */
69
+ export interface IRPairingPolicy {
70
+ /** Opt-out from specific spec<->IR pairing mismatch axes. */
71
+ acceptDrift?: IRAcceptDriftPolicy;
72
+ }
73
+
19
74
  /**
20
75
  * Top-level IR document.
21
76
  */
@@ -33,6 +88,13 @@ export interface IRDocument {
33
88
  /** Where this document came from. */
34
89
  provenance?: IRProvenance;
35
90
 
91
+ /**
92
+ * Per-document policy block — opt-out from specific spec<->IR
93
+ * pairing mismatches. Used by hand-authored IRs that intentionally
94
+ * diverge from the legacy spec shape.
95
+ */
96
+ pairingPolicy?: IRPairingPolicy;
97
+
36
98
  /** State declarations within this document. */
37
99
  states: IRState[];
38
100
  /** Transition declarations within this document. */
@@ -15,6 +15,7 @@
15
15
  "description": { "type": "string" },
16
16
  "metadata": { "$ref": "#/$defs/IRMetadata" },
17
17
  "provenance": { "$ref": "#/$defs/IRProvenance" },
18
+ "pairingPolicy": { "$ref": "#/$defs/IRPairingPolicy" },
18
19
  "initialState": { "type": "string" },
19
20
  "states": {
20
21
  "type": "array",
@@ -26,6 +27,30 @@
26
27
  }
27
28
  },
28
29
  "$defs": {
30
+ "IRPairingPolicy": {
31
+ "type": "object",
32
+ "additionalProperties": false,
33
+ "properties": {
34
+ "acceptDrift": {
35
+ "type": "object",
36
+ "required": ["axes", "reason", "since"],
37
+ "additionalProperties": false,
38
+ "properties": {
39
+ "axes": {
40
+ "type": "array",
41
+ "items": {
42
+ "type": "string",
43
+ "enum": ["groupCount", "groupIds", "assertionCount"]
44
+ },
45
+ "minItems": 1
46
+ },
47
+ "reason": { "type": "string", "minLength": 1 },
48
+ "since": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
49
+ "expiresAt": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" }
50
+ }
51
+ }
52
+ }
53
+ },
29
54
  "IRElementCriteria": {
30
55
  "type": "object",
31
56
  "additionalProperties": false,
@@ -1,182 +0,0 @@
1
- /**
2
- * Legacy spec roster test (Phase A2 fixture gate).
3
- *
4
- * Walks the entire `qontinui_parent` monorepo, collects every shipping
5
- * `*.spec.uibridge.json`, and round-trips each through:
6
- *
7
- * legacy --projectLegacyToIR--> IR --projectIRToBundledPage--> legacy'
8
- *
9
- * For each fixture we assert (semantically — not byte-strict):
10
- * - same number of groups
11
- * - same group ids in input order
12
- * - same total assertion count across all groups
13
- *
14
- * This is coarse but effective: it catches any data loss in the inverse
15
- * projection without forcing the inverse to preserve hand-edited assertion
16
- * ids / severity / category / etc. (which the forward direction regenerates
17
- * from defaults). Phase A4 will tighten the contract for hand-authored
18
- * fields.
19
- *
20
- * The walker fails fast if it finds < 100 fixtures (canary against a
21
- * misconfigured directory walk) — current expected count is 121 (96 runner +
22
- * 19 web + 5 mobile + 2 supervisor) as of 2026-04. The number is allowed to
23
- * grow; the lower bound just guards against accidentally testing nothing.
24
- */
25
-
26
- import { readdirSync, readFileSync, statSync } from "node:fs";
27
- import path from "node:path";
28
-
29
- import { describe, expect, it } from "vitest";
30
-
31
- import {
32
- projectIRToBundledPage,
33
- projectLegacyToIR,
34
- type LegacySpec,
35
- } from "../projection";
36
-
37
- // ---------------------------------------------------------------------------
38
- // Discovery
39
- // ---------------------------------------------------------------------------
40
-
41
- /**
42
- * Resolve the monorepo root from the test file's location. The test file
43
- * lives at `qontinui-schemas/ts/src/ui-bridge-ir/__tests__/`, so five
44
- * `..` segments take us to `qontinui_parent/`.
45
- *
46
- * Use `process.cwd()` as a fallback only when the test runs from outside the
47
- * usual layout (e.g. CI checkout with renamed parent) — vitest sets cwd to
48
- * the package root by default.
49
- */
50
- const MONOREPO_ROOT = path.resolve(
51
- __dirname,
52
- "..",
53
- "..",
54
- "..",
55
- "..",
56
- "..",
57
- );
58
-
59
- // Directories to skip. Note: do NOT include "build" — Next.js apps have route
60
- // folders named `build/` (e.g., qontinui-web/frontend/src/app/(app)/build/)
61
- // that legitimately contain spec fixtures.
62
- const SKIP_DIRS = new Set([
63
- "node_modules",
64
- "target",
65
- "target-agent",
66
- "dist",
67
- ".git",
68
- ".next",
69
- ".venv",
70
- "__pycache__",
71
- "out",
72
- ]);
73
-
74
- /**
75
- * Recursively walk `root`, collecting every `*.spec.uibridge.json` file.
76
- * Skips the directory names in `SKIP_DIRS`. Synchronous; the fixture set is
77
- * small (low hundreds) so we don't bother with worker threads.
78
- */
79
- function collectSpecs(root: string): string[] {
80
- const out: string[] = [];
81
- const stack: string[] = [root];
82
- while (stack.length > 0) {
83
- const current = stack.pop()!;
84
- let entries: import("node:fs").Dirent[];
85
- try {
86
- entries = readdirSync(current, { withFileTypes: true });
87
- } catch {
88
- // Permission errors / vanished dirs — skip silently.
89
- continue;
90
- }
91
- for (const entry of entries) {
92
- if (SKIP_DIRS.has(entry.name)) continue;
93
- const full = path.join(current, entry.name);
94
- if (entry.isDirectory()) {
95
- stack.push(full);
96
- } else if (entry.isFile() && entry.name.endsWith(".spec.uibridge.json")) {
97
- out.push(full);
98
- } else if (entry.isSymbolicLink()) {
99
- // Resolve symlinks defensively — some plugin checkouts use them.
100
- try {
101
- const stat = statSync(full);
102
- if (stat.isDirectory()) {
103
- stack.push(full);
104
- } else if (stat.isFile() && entry.name.endsWith(".spec.uibridge.json")) {
105
- out.push(full);
106
- }
107
- } catch {
108
- // ignore
109
- }
110
- }
111
- }
112
- }
113
- // Sort for deterministic test ordering.
114
- out.sort();
115
- return out;
116
- }
117
-
118
- const SPECS = collectSpecs(MONOREPO_ROOT);
119
-
120
- // ---------------------------------------------------------------------------
121
- // Tests
122
- // ---------------------------------------------------------------------------
123
-
124
- describe("legacy spec roster — discovery sanity", () => {
125
- it("finds at least 100 *.spec.uibridge.json files in the monorepo", () => {
126
- expect(SPECS.length).toBeGreaterThanOrEqual(100);
127
- });
128
-
129
- it("does not include node_modules / dist / target paths", () => {
130
- for (const f of SPECS) {
131
- expect(f).not.toMatch(/[\\/](?:node_modules|dist|target|\.git)[\\/]/);
132
- }
133
- });
134
- });
135
-
136
- describe("legacy spec roster — round-trip via IR", () => {
137
- // Use it.each so every fixture gets its own test name and failures pinpoint
138
- // the exact file. Pre-load each fixture as a tuple for the iterator.
139
- const cases: ReadonlyArray<readonly [string, string]> = SPECS.map((p) => [
140
- path.relative(MONOREPO_ROOT, p),
141
- p,
142
- ] as const);
143
-
144
- it.each(cases)("%s round-trips through IR without data loss", (_relpath, full) => {
145
- let raw = readFileSync(full, "utf-8");
146
- // Strip UTF-8 BOM if present — at least one shipping spec
147
- // (settings-security.spec.uibridge.json) was saved with a BOM.
148
- if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
149
- const legacy = JSON.parse(raw) as LegacySpec;
150
-
151
- const ir = projectLegacyToIR(legacy);
152
- const projected = projectIRToBundledPage(ir);
153
-
154
- // 1. Same number of groups.
155
- expect(projected.groups.length).toBe(legacy.groups.length);
156
-
157
- // 2. Same group ids in input order.
158
- expect(projected.groups.map((g) => g.id)).toEqual(legacy.groups.map((g) => g.id));
159
-
160
- // 3. Same total assertion count across all groups (coarse data-loss check).
161
- const projectedAssertionCount = projected.groups.reduce(
162
- (sum, g) => sum + g.assertions.length,
163
- 0,
164
- );
165
- const legacyAssertionCount = legacy.groups.reduce(
166
- (sum, g) => sum + (Array.isArray(g.assertions) ? g.assertions.length : 0),
167
- 0,
168
- );
169
- // The forward projection emits a placeholder assertion for any state with
170
- // zero requiredElements. Real legacy fixtures never have zero-assertion
171
- // groups, but be defensive: allow projected >= legacy when legacy had
172
- // groups with zero assertions.
173
- const legacyHasEmptyGroup = legacy.groups.some(
174
- (g) => !Array.isArray(g.assertions) || g.assertions.length === 0,
175
- );
176
- if (legacyHasEmptyGroup) {
177
- expect(projectedAssertionCount).toBeGreaterThanOrEqual(legacyAssertionCount);
178
- } else {
179
- expect(projectedAssertionCount).toBe(legacyAssertionCount);
180
- }
181
- });
182
- });