@qontinui/shared-types 0.2.2 → 0.2.4

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 (41) hide show
  1. package/dist/TaskRunFindingActionType.d-B4VmmbB6.d.cts +62 -0
  2. package/dist/TaskRunFindingActionType.d-B4VmmbB6.d.ts +62 -0
  3. package/dist/index.d.cts +2 -1
  4. package/dist/index.d.ts +2 -1
  5. package/dist/task-run.d.cts +3 -62
  6. package/dist/task-run.d.ts +3 -62
  7. package/dist/tauri-events.cjs +19 -0
  8. package/dist/tauri-events.cjs.map +1 -0
  9. package/dist/tauri-events.d.cts +1273 -0
  10. package/dist/tauri-events.d.ts +1273 -0
  11. package/dist/tauri-events.js +1 -0
  12. package/dist/tauri-events.js.map +1 -0
  13. package/dist/ui-bridge-ir.cjs +37 -0
  14. package/dist/ui-bridge-ir.cjs.map +1 -1
  15. package/dist/ui-bridge-ir.d.cts +90 -1
  16. package/dist/ui-bridge-ir.d.ts +90 -1
  17. package/dist/ui-bridge-ir.js +37 -0
  18. package/dist/ui-bridge-ir.js.map +1 -1
  19. package/package.json +7 -2
  20. package/src/generated/AppEvent.d.ts +587 -586
  21. package/src/generated/CanvasPanel.d.ts +35 -0
  22. package/src/generated/DevSeedFindingPayload.d.ts +30 -0
  23. package/src/generated/FlowEvent.d.ts +86 -66
  24. package/src/generated/RecommendationReviewDecisionPayload.d.ts +24 -0
  25. package/src/generated/RunnerConnectedConnection.d.ts +34 -0
  26. package/src/generated/RunnerFinding.d.ts +48 -0
  27. package/src/generated/RunnerFindingCodeContext.d.ts +20 -0
  28. package/src/generated/RunnerFindingUserInput.d.ts +19 -0
  29. package/src/generated/RunnerRelayMessage.d.ts +75 -0
  30. package/src/generated/RunnerStatusEvent.d.ts +67 -0
  31. package/src/generated/UiBridgeRequestEnvelope.d.ts +45 -0
  32. package/src/generated/UiBridgeResponseEnvelope.d.ts +66 -0
  33. package/src/generated/index.ts +11 -0
  34. package/src/tauri-events/_api.ts +90 -0
  35. package/src/tauri-events/index.ts +1 -0
  36. package/src/ui-bridge-ir/document.ts +9 -0
  37. package/src/ui-bridge-ir/group.ts +84 -0
  38. package/src/ui-bridge-ir/index.ts +1 -0
  39. package/src/ui-bridge-ir/ir.schema.json +58 -0
  40. package/src/ui-bridge-ir/projection.ts +58 -0
  41. package/src/ui-bridge-ir/__tests__/legacy-roster.test.ts +0 -182
@@ -0,0 +1,90 @@
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";
77
+
78
+ // ── UI Bridge IPC envelope payloads carried on the `ui-bridge-request` and
79
+ // `ui-bridge-response` Tauri channels. Stage 1 of the ui-bridge-request
80
+ // envelope concretization (deferral note in commit ea5d9a61f). The Rust
81
+ // sources live in `qontinui-schemas/rust/src/app_events.rs`; the runner's
82
+ // React handler in `src/hooks/ui-bridge-events/types.ts` should pick the
83
+ // `requestId | type` fields off these to keep the envelope head in sync
84
+ // while leaving its rich set of optional payload fields hand-written.
85
+ // Stage 2 (per-payload tagged union of all 171 request_types) is deferred
86
+ // indefinitely — the existing AssertEqual<AllHandledTypes,
87
+ // UIBridgeRequestType> + per-hook never checks already enforce dispatch
88
+ // exhaustiveness from the right end of the pipeline. ──
89
+ export type { UiBridgeRequestEnvelope } from "../generated/UiBridgeRequestEnvelope";
90
+ export type { UiBridgeResponseEnvelope } from "../generated/UiBridgeResponseEnvelope";
@@ -0,0 +1 @@
1
+ export * from "./_api";
@@ -6,6 +6,7 @@
6
6
  * adapter folds it into ui-bridge-auto's `WorkflowConfig`.
7
7
  */
8
8
 
9
+ import type { IRGroup } from "./group";
9
10
  import type { IRMetadata, IRProvenance } from "./primitives";
10
11
  import type { IRState } from "./state";
11
12
  import type { IRTransition } from "./transition";
@@ -100,6 +101,14 @@ export interface IRDocument {
100
101
  /** Transition declarations within this document. */
101
102
  transitions: IRTransition[];
102
103
 
104
+ /**
105
+ * Groups produced by workflow-criteria synthesis (not by IR state authoring).
106
+ * Synthesis appends to this list; the projection emits them after state-derived
107
+ * groups in legacy `groups[]` output. Authoring tools should treat this field
108
+ * as read-only.
109
+ */
110
+ synthesizedGroups?: IRGroup[];
111
+
103
112
  /** ID of the initial/starting state, if applicable. */
104
113
  initialState?: string;
105
114
  }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * IR-side group + assertion types.
3
+ *
4
+ * The IR's primary authoring surface is `states + transitions`, which the
5
+ * projection maps into legacy `groups[]` (one group per state). However, the
6
+ * runner's `workflow_generation::spec_synthesis` pipeline produces "free-form"
7
+ * groups from acceptance criteria — these are NOT tied to any state and
8
+ * therefore have no home in the state-derived projection path.
9
+ *
10
+ * `IRGroup` is the dedicated channel for those synthesized groups. The IR
11
+ * carries them in the optional `IRDocument.synthesizedGroups` field; the
12
+ * projection appends them after the state-derived groups, in declared order,
13
+ * to preserve byte-stable output.
14
+ *
15
+ * The shape mirrors the legacy `SpecGroup` / `SpecAssertion` (canonical
16
+ * definitions live in `@qontinui/ui-bridge/specs`), but is re-defined here to
17
+ * keep `qontinui-schemas/ts` free of any runtime dep on `@qontinui/ui-bridge/*`
18
+ * (per the schemas package's "zero ui-bridge deps" policy).
19
+ *
20
+ * Note on field shape: the IR-side `IRAssertion` keeps the legacy assertion's
21
+ * field types as loose as the synthesis path emits them. Strict consumers
22
+ * (e.g. the canonical `SpecAssertion` in `@qontinui/ui-bridge/specs`) can
23
+ * narrow as needed at the consumer boundary; the projection preserves the
24
+ * objects verbatim into the legacy output.
25
+ */
26
+
27
+ /**
28
+ * IR-side assertion target. Always `type: "search"` for synthesis-emitted
29
+ * assertions; the wider legacy schema also supports point/region targets but
30
+ * the IR doesn't express those today.
31
+ */
32
+ export interface IRAssertionTarget {
33
+ /** Always `"search"` for synthesis-emitted assertions. */
34
+ type: string;
35
+ /**
36
+ * Free-form criteria object. Kept as a loose record so synthesis can emit
37
+ * partially-populated criteria without tripping the schema.
38
+ */
39
+ criteria: Record<string, unknown>;
40
+ label: string;
41
+ }
42
+
43
+ /**
44
+ * IR-side assertion. Mirrors the legacy `SpecAssertion` shape.
45
+ */
46
+ export interface IRAssertion {
47
+ id: string;
48
+ description: string;
49
+ category: string;
50
+ severity: string;
51
+ assertionType: string;
52
+ target: IRAssertionTarget;
53
+ source: string;
54
+ reviewed: boolean;
55
+ enabled: boolean;
56
+ /**
57
+ * Optional human-readable precondition. Synthesis populates this from the
58
+ * acceptance criterion's verification hint.
59
+ */
60
+ precondition?: string;
61
+ }
62
+
63
+ /**
64
+ * IR-side group produced by workflow-criteria synthesis (i.e. NOT derived
65
+ * from `IRState` annotations). Mirrors the legacy `SpecGroup` shape one-to-one
66
+ * so the projection can pass it through to legacy `groups[]` without loss.
67
+ */
68
+ export interface IRGroup {
69
+ id: string;
70
+ name: string;
71
+ description: string;
72
+ category: string;
73
+ assertions: IRAssertion[];
74
+ /**
75
+ * Provenance of the group itself (typically `"ai-generated"` for synthesis
76
+ * output, but kept open for future authoring channels).
77
+ */
78
+ source?: string;
79
+ /**
80
+ * Free-form tags. Synthesis emits e.g. `["workflow-generated",
81
+ * "acceptance-criteria"]` so downstream tooling can filter.
82
+ */
83
+ tags?: string[];
84
+ }
@@ -18,6 +18,7 @@ export * from "./primitives";
18
18
  export * from "./element-criteria";
19
19
  export * from "./state";
20
20
  export * from "./transition";
21
+ export * from "./group";
21
22
  export * from "./document";
22
23
  export * from "./adapter";
23
24
  export * from "./projection";
@@ -24,6 +24,10 @@
24
24
  "transitions": {
25
25
  "type": "array",
26
26
  "items": { "$ref": "#/$defs/IRTransition" }
27
+ },
28
+ "synthesizedGroups": {
29
+ "type": "array",
30
+ "items": { "$ref": "#/$defs/IRGroup" }
27
31
  }
28
32
  },
29
33
  "$defs": {
@@ -230,6 +234,60 @@
230
234
  "provenance": { "$ref": "#/$defs/IRProvenance" },
231
235
  "crossRefs": { "type": "array", "items": { "$ref": "#/$defs/IRCrossRef" } }
232
236
  }
237
+ },
238
+ "IRAssertionTarget": {
239
+ "type": "object",
240
+ "required": ["type", "criteria", "label"],
241
+ "additionalProperties": false,
242
+ "properties": {
243
+ "type": { "type": "string" },
244
+ "criteria": { "type": "object", "additionalProperties": true },
245
+ "label": { "type": "string" }
246
+ }
247
+ },
248
+ "IRAssertion": {
249
+ "type": "object",
250
+ "required": [
251
+ "id",
252
+ "description",
253
+ "category",
254
+ "severity",
255
+ "assertionType",
256
+ "target",
257
+ "source",
258
+ "reviewed",
259
+ "enabled"
260
+ ],
261
+ "additionalProperties": false,
262
+ "properties": {
263
+ "id": { "type": "string" },
264
+ "description": { "type": "string" },
265
+ "category": { "type": "string" },
266
+ "severity": { "type": "string" },
267
+ "assertionType": { "type": "string" },
268
+ "target": { "$ref": "#/$defs/IRAssertionTarget" },
269
+ "source": { "type": "string" },
270
+ "reviewed": { "type": "boolean" },
271
+ "enabled": { "type": "boolean" },
272
+ "precondition": { "type": "string" }
273
+ }
274
+ },
275
+ "IRGroup": {
276
+ "type": "object",
277
+ "required": ["id", "name", "description", "category", "assertions"],
278
+ "additionalProperties": false,
279
+ "properties": {
280
+ "id": { "type": "string" },
281
+ "name": { "type": "string" },
282
+ "description": { "type": "string" },
283
+ "category": { "type": "string" },
284
+ "assertions": {
285
+ "type": "array",
286
+ "items": { "$ref": "#/$defs/IRAssertion" }
287
+ },
288
+ "source": { "type": "string" },
289
+ "tags": { "type": "array", "items": { "type": "string" } }
290
+ }
233
291
  }
234
292
  }
235
293
  }
@@ -179,6 +179,7 @@
179
179
 
180
180
  import type { IRElementCriteria } from "./element-criteria";
181
181
  import type { IRDocument } from "./document";
182
+ import type { IRAssertion, IRGroup } from "./group";
182
183
  import type { IRState } from "./state";
183
184
  import type { IRTransition, IRTransitionAction, IRWaitSpec } from "./transition";
184
185
 
@@ -432,6 +433,55 @@ function buildGroup(state: IRState): LegacyGroup {
432
433
  };
433
434
  }
434
435
 
436
+ /**
437
+ * Convert an `IRAssertion` (IR-side, free-form criteria) into a `LegacyAssertion`.
438
+ * The shapes are near-identical; the only meaningful difference is that the
439
+ * IR-side `target.criteria` is `Record<string, unknown>` while the legacy
440
+ * shape is `LegacyCriteria` (a typed but extensible record). We pass the
441
+ * criteria object through verbatim — the open `[key: string]: unknown` index
442
+ * signature on `LegacyCriteria` accepts any record shape.
443
+ */
444
+ function synthesizedAssertionToLegacy(assertion: IRAssertion): LegacyAssertion {
445
+ const target: LegacyAssertionTarget = {
446
+ type: "search",
447
+ criteria: assertion.target.criteria as LegacyCriteria,
448
+ label: assertion.target.label,
449
+ };
450
+ const out: LegacyAssertion = {
451
+ id: assertion.id,
452
+ description: assertion.description,
453
+ category: assertion.category,
454
+ severity: assertion.severity as LegacyAssertion["severity"],
455
+ assertionType: assertion.assertionType,
456
+ target,
457
+ source: assertion.source,
458
+ reviewed: assertion.reviewed,
459
+ enabled: assertion.enabled,
460
+ };
461
+ if (assertion.precondition !== undefined) {
462
+ out.precondition = assertion.precondition;
463
+ }
464
+ return out;
465
+ }
466
+
467
+ /**
468
+ * Convert a synthesized `IRGroup` into a `LegacyGroup`. Mirrors the legacy
469
+ * shape one-to-one (modulo the free-form criteria pass-through described in
470
+ * `synthesizedAssertionToLegacy`). The synthesized group always carries a
471
+ * `source` (synthesis emits `"ai-generated"`); fall back to `"ai-generated"`
472
+ * for hand-written IR documents that omit it.
473
+ */
474
+ function synthesizedGroupToLegacy(group: IRGroup): LegacyGroup {
475
+ return {
476
+ id: group.id,
477
+ name: group.name,
478
+ description: group.description,
479
+ category: group.category,
480
+ assertions: group.assertions.map(synthesizedAssertionToLegacy),
481
+ source: group.source ?? "ai-generated",
482
+ };
483
+ }
484
+
435
485
  /**
436
486
  * Convert an IR transition action into the legacy `process[]` step shape.
437
487
  * Renames IR's `type` to legacy `action` (matches the runtime engine
@@ -520,6 +570,14 @@ export function projectIRToBundledPage(doc: IRDocument, notes?: string): LegacyS
520
570
  if (doc.metadata?.tags !== undefined) metadata.tags = doc.metadata.tags;
521
571
 
522
572
  const groups: LegacyGroup[] = doc.states.map(buildGroup);
573
+ if (doc.synthesizedGroups !== undefined) {
574
+ // Append synthesis-produced groups AFTER state-derived ones, in declared
575
+ // order. Byte-stable output (see file header) requires this exact ordering
576
+ // — anything else breaks `check-spec-pairing` style hashes.
577
+ for (const g of doc.synthesizedGroups) {
578
+ groups.push(synthesizedGroupToLegacy(g));
579
+ }
580
+ }
523
581
  const smStates: LegacyStateMachineState[] = doc.states.map((s) =>
524
582
  buildStateMachineState(s, doc.transitions, doc),
525
583
  );
@@ -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
- });