@roubo/shared 0.1.0

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,232 @@
1
+ // Pure derived-status state machine (FR-009).
2
+ //
3
+ // Platform-agnostic: no fs, no node:crypto, no React. Safe in the Vite client
4
+ // build. Canonical contract types land with testbench-contracts (#6); until
5
+ // then this consumes the local types in testbench-domain-types.ts.
6
+
7
+ import type {
8
+ BenchResults,
9
+ CaseResult,
10
+ CaseStatus,
11
+ ObservationMark,
12
+ TestCasesPlan,
13
+ } from "./testbench-domain-types";
14
+ import { canonicalizeCase } from "./testbench-canonicalize";
15
+
16
+ // Derive a case's status purely from its observation marks (FR-009).
17
+ //
18
+ // The "all observations marked" denominator is NOT inferable from the marks map
19
+ // alone (the map only holds marked observations), so the full observation-id set
20
+ // is passed alongside the marks.
21
+ //
22
+ // Truth table (a single fail short-circuits to "failed", even when other
23
+ // observations are still unmarked, per issue #508):
24
+ // - any observation marked fail => "failed"
25
+ // - no observations marked (and no fail) => "not_started"
26
+ // - some but not all observations marked (no fail) => "in_progress"
27
+ // - all observations marked AND all are pass => "passed"
28
+ //
29
+ // "blocked" is NEVER derived here: marks are pass|fail only, so blocked is
30
+ // reachable only through an explicit override (FR-010), via
31
+ // effectiveStatus = override ?? deriveStatus(...). CaseStatus still includes
32
+ // "blocked" for that override path.
33
+ //
34
+ // Edge: a case with zero observations defined is treated as "not_started".
35
+ // There is nothing for the reviewer to mark, so the case has not been started;
36
+ // it can never auto-advance to passed/failed without an observation to mark.
37
+ export function deriveStatus(
38
+ observationIds: string[],
39
+ marks: Record<string, ObservationMark>,
40
+ ): CaseStatus {
41
+ const total = observationIds.length;
42
+
43
+ // Zero observations defined: nothing to mark, so not started.
44
+ if (total === 0) {
45
+ return "not_started";
46
+ }
47
+
48
+ let markedCount = 0;
49
+ let anyFail = false;
50
+ for (const id of observationIds) {
51
+ const mark = marks[id];
52
+ if (mark === undefined) {
53
+ continue;
54
+ }
55
+ markedCount += 1;
56
+ if (mark.result === "fail") {
57
+ anyFail = true;
58
+ }
59
+ }
60
+
61
+ // A single failed observation moves the case to "failed" immediately, even
62
+ // when other observations are still unmarked (issue #508).
63
+ if (anyFail) {
64
+ return "failed";
65
+ }
66
+ if (markedCount === 0) {
67
+ return "not_started";
68
+ }
69
+ if (markedCount < total) {
70
+ return "in_progress";
71
+ }
72
+ // All observations are marked and none failed.
73
+ return "passed";
74
+ }
75
+
76
+ // Deterministic reconcile (FR-017, spike-407 AC3/AC4/AC5).
77
+ //
78
+ // Diffs a source plan against recorded results by stable case id and classifies
79
+ // each case as added / unchanged / changed / removed. It is non-destructive:
80
+ // removed cases that carry authored results are flagged orphaned (never deleted
81
+ // here), so no ObservationMark, Note, or StatusOverride is ever lost. Physical
82
+ // deletion is a SEPARATE explicit operation (purgeOrphans below), per the issue
83
+ // AC and spike-407 AC5.
84
+
85
+ // The four id buckets a reconcile produces (spike-407 AC3).
86
+ export interface ReconcileClassification {
87
+ // In the plan, no recorded result yet: new, nothing authored to preserve.
88
+ added: string[];
89
+ // In both, canonical case body matches the stored snapshot.
90
+ unchanged: string[];
91
+ // In both, canonical case body differs (or no stored snapshot): marks/notes/
92
+ // override retained, reviewer signalled to re-review.
93
+ changed: string[];
94
+ // Recorded result with no matching plan case: orphan candidate, never deleted.
95
+ removed: string[];
96
+ }
97
+
98
+ export interface ReconcileResult {
99
+ classification: ReconcileClassification;
100
+ // A non-destructive reconciled BenchResults: orphans flagged, changed cases'
101
+ // snapshot refreshed and derivedStatus recomputed from their kept marks, every
102
+ // other datum copied verbatim. The caller persists this; reconcile never
103
+ // deletes.
104
+ nextResults: BenchResults;
105
+ }
106
+
107
+ // Gather every observation id defined across a plan case's steps, so a changed
108
+ // case's derivedStatus can be recomputed from its (kept) marks against the new
109
+ // plan's observation set.
110
+ function planCaseObservationIds(plan: TestCasesPlan, caseId: string): string[] {
111
+ const planCase = plan.cases.find((c) => c.id === caseId);
112
+ if (planCase === undefined) {
113
+ return [];
114
+ }
115
+ const ids: string[] = [];
116
+ for (const step of planCase.steps) {
117
+ for (const observation of step.observations) {
118
+ ids.push(observation.id);
119
+ }
120
+ }
121
+ return ids;
122
+ }
123
+
124
+ export function reconcile(plan: TestCasesPlan, results: BenchResults): ReconcileResult {
125
+ const planCanonById = new Map<string, string>();
126
+ for (const planCase of plan.cases) {
127
+ planCanonById.set(planCase.id, canonicalizeCase(planCase));
128
+ }
129
+ const planIds = new Set(planCanonById.keys());
130
+ const resultIds = new Set(Object.keys(results.caseResults));
131
+
132
+ const classification: ReconcileClassification = {
133
+ added: [],
134
+ unchanged: [],
135
+ changed: [],
136
+ removed: [],
137
+ };
138
+
139
+ for (const caseId of planIds) {
140
+ if (!resultIds.has(caseId)) {
141
+ // Authored nothing yet: safe, nothing to preserve.
142
+ classification.added.push(caseId);
143
+ continue;
144
+ }
145
+ // A result exists; did the case body change? A result with no stored
146
+ // snapshot is conservatively classified changed (prompts re-review, loses
147
+ // nothing). The stored snapshot vs published-contract gap is tracked in #447.
148
+ const stored = results.caseResults[caseId].caseCanon;
149
+ if (stored !== undefined && stored === planCanonById.get(caseId)) {
150
+ classification.unchanged.push(caseId);
151
+ } else {
152
+ classification.changed.push(caseId);
153
+ }
154
+ }
155
+
156
+ for (const caseId of resultIds) {
157
+ if (!planIds.has(caseId)) {
158
+ // Orphan candidate: results are NOT touched in this loop.
159
+ classification.removed.push(caseId);
160
+ }
161
+ }
162
+
163
+ // Build the non-destructive reconciled results. Every recorded result is
164
+ // copied; only additive/metadata mutations are applied.
165
+ const nextCaseResults: Record<string, CaseResult> = {};
166
+ for (const [caseId, result] of Object.entries(results.caseResults)) {
167
+ nextCaseResults[caseId] = copyCaseResult(result);
168
+ }
169
+
170
+ // changed cases: keep every mark, note, and override; only refresh the
171
+ // per-case snapshot and recompute derivedStatus from the kept marks.
172
+ for (const caseId of classification.changed) {
173
+ const next = nextCaseResults[caseId];
174
+ next.caseCanon = planCanonById.get(caseId);
175
+ next.derivedStatus = deriveStatus(planCaseObservationIds(plan, caseId), next.observationMarks);
176
+ }
177
+
178
+ // removed cases: flag orphaned, retain on disk, exclude from the rollup.
179
+ for (const caseId of classification.removed) {
180
+ nextCaseResults[caseId].orphaned = true;
181
+ }
182
+
183
+ return {
184
+ classification,
185
+ nextResults: {
186
+ caseResults: nextCaseResults,
187
+ updatedAt: results.updatedAt,
188
+ },
189
+ };
190
+ }
191
+
192
+ // Deep-copy a CaseResult so reconcile/purge never mutate the caller's input
193
+ // (these are pure functions). Marks, notes, and the override are copied by value.
194
+ function copyCaseResult(result: CaseResult): CaseResult {
195
+ const observationMarks: Record<string, ObservationMark> = {};
196
+ for (const [id, mark] of Object.entries(result.observationMarks)) {
197
+ observationMarks[id] = { ...mark, author: { ...mark.author } };
198
+ }
199
+ const copy: CaseResult = {
200
+ observationMarks,
201
+ derivedStatus: result.derivedStatus,
202
+ notes: result.notes.map((note) => ({ ...note, author: { ...note.author } })),
203
+ };
204
+ if (result.statusOverride !== undefined) {
205
+ copy.statusOverride = {
206
+ ...result.statusOverride,
207
+ author: { ...result.statusOverride.author },
208
+ };
209
+ }
210
+ if (result.orphaned !== undefined) {
211
+ copy.orphaned = result.orphaned;
212
+ }
213
+ if (result.caseCanon !== undefined) {
214
+ copy.caseCanon = result.caseCanon;
215
+ }
216
+ return copy;
217
+ }
218
+
219
+ // Purge orphaned results (FR-017, spike-407 AC5). This is the ONLY delete path,
220
+ // kept SEPARATE from reconcile so physical deletion always requires an explicit,
221
+ // distinct operation. Pure: returns a new BenchResults dropping every entry
222
+ // flagged orphaned, leaving the input untouched.
223
+ export function purgeOrphans(results: BenchResults): BenchResults {
224
+ const caseResults: Record<string, CaseResult> = {};
225
+ for (const [caseId, result] of Object.entries(results.caseResults)) {
226
+ if (result.orphaned === true) {
227
+ continue;
228
+ }
229
+ caseResults[caseId] = copyCaseResult(result);
230
+ }
231
+ return { caseResults, updatedAt: results.updatedAt };
232
+ }
@@ -0,0 +1,132 @@
1
+ // Spike #408: representative zod source for the FR-019 guided-execution
2
+ // targeting-field unions, used to prove z.toJSONSchema() output quality and to
3
+ // drive the generate:schema script + CI drift guard.
4
+ //
5
+ // This is a SPIKE fixture, not the authored TestBench contract. The real
6
+ // test-cases.json / test-results.json schemas are authored under #6; this file
7
+ // exists only to exercise the worst-case shape (an optional union over five
8
+ // distinct targeting strategies) through the generation pipeline so the
9
+ // pipeline itself can be committed and gated before the contracts land.
10
+ //
11
+ // FR-019 reserves OPTIONAL, additive targeting fields:
12
+ // - a per-step `target`
13
+ // - a per-observation `observe`
14
+ // each expressible as one of: CSS selector, ARIA role + accessible name,
15
+ // visible-text anchor, route/URL context, or region. Both fields share the
16
+ // same union shape, so a single `TargetSchema` models both.
17
+
18
+ import { z } from "zod";
19
+
20
+ // Versioned identifier (NFR-005: semver-style versioning lives on the schema's
21
+ // $id). Spike finding: zod's `meta({ id })` registers the schema for internal
22
+ // $ref/$defs reuse but does NOT emit a top-level `$id`. To publish `$id` into
23
+ // the output you pass the literal `$id` key through `meta()` (see the root
24
+ // schema below). That single key is the only post-processing needed; no shim.
25
+ export const TESTBENCH_TARGETING_SCHEMA_ID =
26
+ "https://roubo.dev/schema/testbench-targeting.spike/v0.1.0.json";
27
+
28
+ // ── Targeting strategies ──
29
+ // Each member is a closed object discriminated by a literal `kind`, so the
30
+ // union round-trips through JSON Schema as a clean oneOf with per-branch
31
+ // required/additionalProperties.
32
+
33
+ const CssSelectorTarget = z
34
+ .object({
35
+ kind: z.literal("css"),
36
+ // A CSS selector string, e.g. `#submit` or `button.primary`.
37
+ selector: z.string().min(1),
38
+ })
39
+ .strict()
40
+ .describe("Target an element by CSS selector.");
41
+
42
+ const AriaRoleTarget = z
43
+ .object({
44
+ kind: z.literal("role"),
45
+ // An ARIA role, e.g. `button`, `link`, `heading`.
46
+ role: z.string().min(1),
47
+ // The accessible name the element is matched on.
48
+ name: z.string().min(1),
49
+ })
50
+ .strict()
51
+ .describe("Target an element by ARIA role and accessible name.");
52
+
53
+ const VisibleTextTarget = z
54
+ .object({
55
+ kind: z.literal("text"),
56
+ // Visible text content used as an anchor.
57
+ text: z.string().min(1),
58
+ // When true, match the whole visible string rather than a substring.
59
+ exact: z.boolean().optional(),
60
+ })
61
+ .strict()
62
+ .describe("Target an element by a visible-text anchor.");
63
+
64
+ const RouteTarget = z
65
+ .object({
66
+ kind: z.literal("route"),
67
+ // A route or URL-path context, e.g. `/settings/testbench`.
68
+ path: z.string().min(1),
69
+ })
70
+ .strict()
71
+ .describe("Scope targeting to a route/URL context.");
72
+
73
+ const RegionTarget = z
74
+ .object({
75
+ kind: z.literal("region"),
76
+ // A named landmark/region, e.g. `main`, `navigation`, or a labelled section.
77
+ region: z.string().min(1),
78
+ })
79
+ .strict()
80
+ .describe("Scope targeting to a named page region or landmark.");
81
+
82
+ // The shared optional targeting union. Discriminated on `kind` so consumers get
83
+ // an exhaustive, type-safe switch and JSON Schema emits a clean oneOf.
84
+ export const TargetSchema = z
85
+ .discriminatedUnion("kind", [
86
+ CssSelectorTarget,
87
+ AriaRoleTarget,
88
+ VisibleTextTarget,
89
+ RouteTarget,
90
+ RegionTarget,
91
+ ])
92
+ .describe(
93
+ "An optional, additive guided-execution targeting selector (FR-019): one of a CSS selector, ARIA role + accessible name, visible-text anchor, route/URL context, or named region.",
94
+ );
95
+ export type Target = z.infer<typeof TargetSchema>;
96
+
97
+ // ── Representative step / observation carriers ──
98
+ // These show the union in its real position: an OPTIONAL field on a step and on
99
+ // an observation. Everything else is intentionally minimal; this is a fixture.
100
+
101
+ const Step = z
102
+ .object({
103
+ // Human-readable instruction for the guided-execution step.
104
+ instruction: z.string().min(1),
105
+ // FR-019: optional per-step target. Additive; absence means "unspecified".
106
+ target: TargetSchema.optional(),
107
+ })
108
+ .strict();
109
+
110
+ const Observation = z
111
+ .object({
112
+ // What the tester should look for.
113
+ prompt: z.string().min(1),
114
+ // FR-019: optional per-observation observe target. Same union as `target`.
115
+ observe: TargetSchema.optional(),
116
+ })
117
+ .strict();
118
+
119
+ // Root schema tying the fixture together and carrying the versioned $id.
120
+ export const TestbenchTargetingSpikeSchema = z
121
+ .object({
122
+ steps: z.array(Step),
123
+ observations: z.array(Observation),
124
+ })
125
+ .strict()
126
+ .meta({
127
+ $id: TESTBENCH_TARGETING_SCHEMA_ID,
128
+ title: "TestBench Targeting (spike)",
129
+ description:
130
+ "Spike #408 fixture proving z.toJSONSchema() output quality for the FR-019 optional per-step `target` and per-observation `observe` targeting unions. Not the authored TestBench contract (#6).",
131
+ });
132
+ export type TestbenchTargetingSpike = z.infer<typeof TestbenchTargetingSpikeSchema>;