@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,155 @@
1
+ // Work-units contract: the single, compile-time source of truth (in `shared/`)
2
+ // for the published, versioned `work-units.json` file that product-dev breakdown
3
+ // writes and Roubo validates. This mirrors the test-cases / test-results pair in
4
+ // testbench-contracts.ts: a zod source schema, the inferred type, the runtime
5
+ // validator, and the versioned `$id` constant. The shape follows the field
6
+ // reference tables in .specifications/verify-gate/work-unit-model.md VERBATIM and
7
+ // is not re-derived (see that document's "Field reference" section).
8
+ //
9
+ // Scope note (#697): this module authors the zod source schema, the inferred
10
+ // type, the runtime validator, and the versioned `$id` constant. The root carries
11
+ // `.meta({ $id })` so the generate script + CI drift guard emit a versioned JSON
12
+ // Schema from it (schema/work-units.schema.json). Gate evaluation and writing
13
+ // work-units.json are separate, out-of-scope work.
14
+
15
+ import { z } from "zod";
16
+
17
+ // ── Versioned schema identifier ──
18
+ //
19
+ // The published file carries a `$schema` URI whose path embeds a semver, and a
20
+ // matching `schemaVersion` string kept consistent with it. A breaking change
21
+ // ships a major bump; additive optional fields do not bump the version.
22
+
23
+ export const WORK_UNITS_SCHEMA_ID = "https://roubo.dev/schema/work-units/v1.0.0.json";
24
+ export const WORK_UNITS_SCHEMA_VERSION = "1.0.0";
25
+
26
+ // ── tracker (the tracker manifestation; absent before a unit is filed) ──
27
+ //
28
+ // Tracker-agnostic: `system` spans github | ghe | jira so a unit can be filed
29
+ // into any supported tracker. `blocked_by_refs` is a derived projection of
30
+ // `depends_on` into this tracker's ref space (R1); required, may be empty.
31
+ export const TrackerSchema = z
32
+ .object({
33
+ system: z.enum(["github", "ghe", "jira"]),
34
+ // The tracker's external id: an issue number (GitHub) or issue key (Jira).
35
+ ref: z.string(),
36
+ url: z.string(),
37
+ // GitHub GraphQL node id.
38
+ node_id: z.string().optional(),
39
+ // GitHub REST id.
40
+ db_id: z.number().optional(),
41
+ // Derived projection of `depends_on` into this tracker's `ref` space (R1).
42
+ blocked_by_refs: z.array(z.string()),
43
+ })
44
+ .strict();
45
+ export type Tracker = z.infer<typeof TrackerSchema>;
46
+
47
+ // ── implements (test/requirement/story linkage, first-class on every unit, R4) ──
48
+ export const ImplementsSchema = z
49
+ .object({
50
+ requirement_ids: z.array(z.string()),
51
+ user_story_ids: z.array(z.string()),
52
+ // The TC- ids a unit is verified by. For a `kind: "verify"` unit this is the
53
+ // gating test set and must be non-empty (R4); enforced by the root refine.
54
+ test_case_ids: z.array(z.string()),
55
+ })
56
+ .strict();
57
+ export type Implements = z.infer<typeof ImplementsSchema>;
58
+
59
+ // ── Unit ──
60
+ //
61
+ // `.strict()` so the generated JSON Schema emits `additionalProperties: false`
62
+ // and an unknown extra field is rejected. Fields and requiredness follow the
63
+ // work-unit-model.md "Unit" table exactly.
64
+ export const UnitSchema = z
65
+ .object({
66
+ // Minted WU-NNN (bare) or <id_code>-WU-NNN (coded). Permanent identity.
67
+ id: z.string(),
68
+ title: z.string(),
69
+ // Our tracker-agnostic work category; the integration plugin maps it to the
70
+ // tracker's native type.
71
+ type: z.enum(["feature", "task", "spike", "bug"]),
72
+ // Optional durable semantic role. Absent means a plain delivery slice.
73
+ kind: z.enum(["e2e", "doc", "verify"]).optional(),
74
+ description: z.string(),
75
+ acceptance_criteria: z.array(z.string()),
76
+ milestone: z.string().optional(),
77
+ labels: z.array(z.string()).optional(),
78
+ estimate: z.number().optional(),
79
+ // `WU-` ids. The dependency authority (R1). Required, may be empty.
80
+ depends_on: z.array(z.string()),
81
+ // Required on every unit (R4).
82
+ implements: ImplementsSchema,
83
+ // `WU-` ids this unit spans (used by e2e / verify units).
84
+ covers: z.array(z.string()).optional(),
85
+ // Doc-unit only: the documentation artifact it updates.
86
+ target_path: z.string().optional(),
87
+ // Doc-unit only: which doc-standard rule fired.
88
+ trigger_reason: z.string().optional(),
89
+ // The tracker manifestation. Absent before the unit is filed.
90
+ tracker: TrackerSchema.optional(),
91
+ })
92
+ .strict();
93
+ export type Unit = z.infer<typeof UnitSchema>;
94
+
95
+ // ── work-units.json (the versioned envelope) ──
96
+ //
97
+ // `$schema` is constrained to the literal WORK_UNITS_SCHEMA_ID so a wrong
98
+ // `$schema` is rejected, and `schemaVersion` must match WORK_UNITS_SCHEMA_VERSION
99
+ // so the two stay consistent. The verify rule (R4) is enforced at the root: a
100
+ // `kind: "verify"` unit must carry a non-empty `implements.test_case_ids`.
101
+ export const WorkUnitsFileSchema = z
102
+ .object({
103
+ $schema: z.literal(WORK_UNITS_SCHEMA_ID),
104
+ schemaVersion: z.literal(WORK_UNITS_SCHEMA_VERSION),
105
+ // The `.specifications/<slug>/` folder name this file lives in.
106
+ specSlug: z.string(),
107
+ units: z.array(UnitSchema),
108
+ })
109
+ .strict()
110
+ // Pass the literal `$id` key through `.meta()` so `z.toJSONSchema()` emits a
111
+ // top-level `$id` carrying the versioned identifier. The generate script + CI
112
+ // drift guard consume this.
113
+ .meta({ $id: WORK_UNITS_SCHEMA_ID });
114
+ export type WorkUnitsFile = z.infer<typeof WorkUnitsFileSchema>;
115
+
116
+ // ── Runtime validator ──
117
+ //
118
+ // Wraps `safeParse` (never throws) and returns a discriminated result, mirroring
119
+ // validateTestCases / validateTestResults. On failure, each zod issue becomes a
120
+ // clear `path: message` string keyed by the field that failed. The `kind:
121
+ // "verify"` rule is enforced here as a refinement so it produces a field-named
122
+ // error against `implements.test_case_ids` rather than a vague envelope error.
123
+
124
+ export type ValidationResult<T> = { ok: true; data: T } | { ok: false; errors: string[] };
125
+
126
+ function zodIssuesToFieldErrors(issues: z.ZodIssue[]): string[] {
127
+ return issues.map((issue) => {
128
+ const path = issue.path.join(".");
129
+ return path ? `${path}: ${issue.message}` : issue.message;
130
+ });
131
+ }
132
+
133
+ export function validateWorkUnits(raw: unknown): ValidationResult<WorkUnitsFile> {
134
+ const parsed = WorkUnitsFileSchema.safeParse(raw);
135
+ if (!parsed.success) {
136
+ return { ok: false, errors: zodIssuesToFieldErrors(parsed.error.issues) };
137
+ }
138
+ // R4: a `kind: "verify"` unit must have a non-empty implements.test_case_ids.
139
+ // Enforced after the structural parse so the error is precise and field-named.
140
+ // Modeled as a post-parse check (rather than a schema-level union) to keep the
141
+ // generated JSON Schema's per-field errors clean; the rule is still validated
142
+ // at runtime on every call.
143
+ const errors: string[] = [];
144
+ parsed.data.units.forEach((unit, index) => {
145
+ if (unit.kind === "verify" && unit.implements.test_case_ids.length === 0) {
146
+ errors.push(
147
+ `units.${index}.implements.test_case_ids: a kind:"verify" unit must list at least one test case id`,
148
+ );
149
+ }
150
+ });
151
+ if (errors.length > 0) {
152
+ return { ok: false, errors };
153
+ }
154
+ return { ok: true, data: parsed.data };
155
+ }