@patronage/factory-ci 0.2.1 → 1.0.0-alpha.13

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,220 @@
1
+ /**
2
+ * Read back a Vitest profile artifact (#647).
3
+ *
4
+ * `vitest-profile.ts` owns the writer; this module owns the matching
5
+ * schema-checked reader, so a consumer analyzing profile artifacts collected
6
+ * from CI (for example `psf ci:analyze`) never re-types the document shape.
7
+ * The reader is deliberately strict about the fields an analysis is built on —
8
+ * tool discriminator, schema version, environment capture, per-run durations,
9
+ * and the duration summary — and deliberately silent about everything else, so
10
+ * additive writer changes never invalidate old artifacts.
11
+ *
12
+ * Pure: no I/O, no dependencies. The caller decides where the bytes come from.
13
+ */
14
+
15
+ import type {
16
+ VitestProfile,
17
+ VitestProfileDurationSummary,
18
+ } from "./vitest-profile.ts";
19
+ import {
20
+ VITEST_PROFILE_SCHEMA_VERSION,
21
+ VITEST_PROFILE_TOOL,
22
+ } from "./vitest-profile.ts";
23
+
24
+ /**
25
+ * Outcome of reading one candidate document. `unrecognized` carries the first
26
+ * reason the document failed — an analyzer reports it verbatim rather than
27
+ * treating an unreadable profile as an empty one.
28
+ */
29
+ export type VitestProfileReadResult =
30
+ | { kind: "profile"; profile: VitestProfile }
31
+ | { kind: "unrecognized"; reason: string };
32
+
33
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
34
+ typeof value === "object" && value !== null && !Array.isArray(value);
35
+
36
+ const isFiniteNumber = (value: unknown): value is number =>
37
+ typeof value === "number" && Number.isFinite(value);
38
+
39
+ const durationSummaryFailure = (
40
+ value: unknown,
41
+ where: string
42
+ ): string | undefined => {
43
+ if (!isRecord(value)) {
44
+ return `${where} is not an object`;
45
+ }
46
+ const fields: (keyof VitestProfileDurationSummary)[] = [
47
+ "maximum",
48
+ "mean",
49
+ "median",
50
+ "minimum",
51
+ ];
52
+ const missing = fields.find((field) => !isFiniteNumber(value[field]));
53
+ return missing === undefined
54
+ ? undefined
55
+ : `${where}.${missing} is not a finite number`;
56
+ };
57
+
58
+ const environmentFailure = (value: unknown): string | undefined => {
59
+ if (!isRecord(value)) {
60
+ return "environment is not an object";
61
+ }
62
+ if (!(typeof value.cpuModel === "string" || value.cpuModel === null)) {
63
+ return "environment.cpuModel is neither a string nor null";
64
+ }
65
+ if (!isFiniteNumber(value.cpuCount)) {
66
+ return "environment.cpuCount is not a finite number";
67
+ }
68
+ return undefined;
69
+ };
70
+
71
+ /** The count fields the writer emits; every one must be a finite number. */
72
+ const COUNT_FIELDS = [
73
+ "failed",
74
+ "passed",
75
+ "pending",
76
+ "suites",
77
+ "tests",
78
+ "todo",
79
+ ] as const;
80
+
81
+ const runFailure = (value: unknown, index: number): string | undefined => {
82
+ if (!isRecord(value)) {
83
+ return `runs[${index}] is not an object`;
84
+ }
85
+ if (!isFiniteNumber(value.durationMs)) {
86
+ return `runs[${index}].durationMs is not a finite number`;
87
+ }
88
+ if (value.counts === null) {
89
+ return undefined;
90
+ }
91
+ if (!isRecord(value.counts)) {
92
+ return `runs[${index}].counts is neither an object nor null`;
93
+ }
94
+ const { counts } = value;
95
+ const missing = COUNT_FIELDS.find((field) => !isFiniteNumber(counts[field]));
96
+ return missing === undefined
97
+ ? undefined
98
+ : `runs[${index}].counts.${missing} is not a finite number`;
99
+ };
100
+
101
+ const slowEntryFailure = (
102
+ value: unknown,
103
+ where: string,
104
+ labelField: "name" | "path"
105
+ ): string | undefined => {
106
+ if (!isRecord(value)) {
107
+ return `${where} is not an object`;
108
+ }
109
+ if (typeof value[labelField] !== "string") {
110
+ return `${where}.${labelField} is not a string`;
111
+ }
112
+ if (labelField === "name" && typeof value.file !== "string") {
113
+ return `${where}.file is not a string`;
114
+ }
115
+ return durationSummaryFailure(value.durationMs, `${where}.durationMs`);
116
+ };
117
+
118
+ const summaryFailure = (value: unknown): string | undefined => {
119
+ if (!isRecord(value)) {
120
+ return "summary is not an object";
121
+ }
122
+ const durations = durationSummaryFailure(
123
+ value.durationMs,
124
+ "summary.durationMs"
125
+ );
126
+ if (durations !== undefined) {
127
+ return durations;
128
+ }
129
+ if (!Array.isArray(value.slowFiles)) {
130
+ return "summary.slowFiles is not an array";
131
+ }
132
+ for (const [index, entry] of value.slowFiles.entries()) {
133
+ const failure = slowEntryFailure(
134
+ entry,
135
+ `summary.slowFiles[${index}]`,
136
+ "path"
137
+ );
138
+ if (failure !== undefined) {
139
+ return failure;
140
+ }
141
+ }
142
+ if (!Array.isArray(value.slowTests)) {
143
+ return "summary.slowTests is not an array";
144
+ }
145
+ for (const [index, entry] of value.slowTests.entries()) {
146
+ const failure = slowEntryFailure(
147
+ entry,
148
+ `summary.slowTests[${index}]`,
149
+ "name"
150
+ );
151
+ if (failure !== undefined) {
152
+ return failure;
153
+ }
154
+ }
155
+ return undefined;
156
+ };
157
+
158
+ /**
159
+ * Check one parsed JSON document against the profile contract the writer
160
+ * emits. Returns the typed profile on success and the first mismatch reason
161
+ * otherwise — never a partially-usable value.
162
+ */
163
+ export const readVitestProfileDocument = (
164
+ value: unknown
165
+ ): VitestProfileReadResult => {
166
+ if (!isRecord(value)) {
167
+ return { kind: "unrecognized", reason: "the document is not an object" };
168
+ }
169
+ if (value.tool !== VITEST_PROFILE_TOOL) {
170
+ return {
171
+ kind: "unrecognized",
172
+ reason: `tool is ${JSON.stringify(value.tool)} rather than "${VITEST_PROFILE_TOOL}"`,
173
+ };
174
+ }
175
+ if (value.schemaVersion !== VITEST_PROFILE_SCHEMA_VERSION) {
176
+ return {
177
+ kind: "unrecognized",
178
+ reason: `schemaVersion is ${JSON.stringify(value.schemaVersion)} rather than ${VITEST_PROFILE_SCHEMA_VERSION}`,
179
+ };
180
+ }
181
+ if (
182
+ !(
183
+ Array.isArray(value.command) &&
184
+ value.command.every((part) => typeof part === "string")
185
+ )
186
+ ) {
187
+ return {
188
+ kind: "unrecognized",
189
+ reason: "command is not an array of strings",
190
+ };
191
+ }
192
+ const environment = environmentFailure(value.environment);
193
+ if (environment !== undefined) {
194
+ return { kind: "unrecognized", reason: environment };
195
+ }
196
+ if (!isRecord(value.options)) {
197
+ return { kind: "unrecognized", reason: "options is not an object" };
198
+ }
199
+ if (!isFiniteNumber(value.options.maxWorkers)) {
200
+ return {
201
+ kind: "unrecognized",
202
+ reason: "options.maxWorkers is not a finite number",
203
+ };
204
+ }
205
+ if (!Array.isArray(value.runs)) {
206
+ return { kind: "unrecognized", reason: "runs is not an array" };
207
+ }
208
+ for (const [index, run] of value.runs.entries()) {
209
+ const failure = runFailure(run, index);
210
+ if (failure !== undefined) {
211
+ return { kind: "unrecognized", reason: failure };
212
+ }
213
+ }
214
+ const summary = summaryFailure(value.summary);
215
+ if (summary !== undefined) {
216
+ return { kind: "unrecognized", reason: summary };
217
+ }
218
+
219
+ return { kind: "profile", profile: value as unknown as VitestProfile };
220
+ };