@patronage/factory-ci 1.0.0-alpha.7 → 1.0.0-alpha.9
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.
- package/README.md +51 -2
- package/dist/index.d.ts +109 -2
- package/dist/index.js +384 -25
- package/package.json +1 -1
- package/src/candidate-lifecycle.ts +29 -0
- package/src/factory-workflow.ts +25 -1
- package/src/index.ts +31 -0
- package/src/production-impact-workflow.ts +109 -0
- package/src/proof-reuse-gate.ts +96 -10
- package/src/proof-reuse-presentation.ts +125 -0
- package/src/vitest-profile-reader.test.ts +208 -0
- package/src/vitest-profile-reader.ts +220 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { readVitestProfileDocument } from "./vitest-profile-reader.ts";
|
|
4
|
+
import type { VitestProfile } from "./vitest-profile.ts";
|
|
5
|
+
|
|
6
|
+
const durations = {
|
|
7
|
+
maximum: 16_200,
|
|
8
|
+
mean: 15_800,
|
|
9
|
+
median: 15_700,
|
|
10
|
+
minimum: 15_300,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** A document shaped exactly as `runVitestProfile` writes it. */
|
|
14
|
+
const validProfile = (): VitestProfile => ({
|
|
15
|
+
command: ["vitest", "run", "--reporter=json", "--maxWorkers=2"],
|
|
16
|
+
endedAt: "2026-08-06T12:01:00.000Z",
|
|
17
|
+
environment: {
|
|
18
|
+
arch: "x64",
|
|
19
|
+
availableParallelism: 4,
|
|
20
|
+
cpuCount: 4,
|
|
21
|
+
cpuModel: "AMD EPYC 9R45",
|
|
22
|
+
gitDirty: false,
|
|
23
|
+
gitHead: "0123456789abcdef0123456789abcdef01234567",
|
|
24
|
+
node: "v24.0.0",
|
|
25
|
+
osRelease: "6.8.0",
|
|
26
|
+
platform: "linux",
|
|
27
|
+
totalMemoryBytes: 16_000_000_000,
|
|
28
|
+
vitest: "3.0.0",
|
|
29
|
+
},
|
|
30
|
+
options: { maxWorkers: 2, samples: 3, slowLimit: 20 },
|
|
31
|
+
rawReportDirectory: "/tmp/profile.raw/abc",
|
|
32
|
+
runs: [
|
|
33
|
+
{
|
|
34
|
+
counts: {
|
|
35
|
+
failed: 0,
|
|
36
|
+
passed: 900,
|
|
37
|
+
pending: 0,
|
|
38
|
+
suites: 80,
|
|
39
|
+
tests: 900,
|
|
40
|
+
todo: 0,
|
|
41
|
+
},
|
|
42
|
+
durationMs: 15_300,
|
|
43
|
+
endedAt: "2026-08-06T12:00:20.000Z",
|
|
44
|
+
exitCode: 0,
|
|
45
|
+
failure: null,
|
|
46
|
+
files: [{ durationMs: 900, path: "src/slow.test.ts", status: "passed" }],
|
|
47
|
+
reportAvailable: true,
|
|
48
|
+
sample: 1,
|
|
49
|
+
startedAt: "2026-08-06T12:00:00.000Z",
|
|
50
|
+
tests: [
|
|
51
|
+
{
|
|
52
|
+
durationMs: 450,
|
|
53
|
+
file: "src/slow.test.ts",
|
|
54
|
+
name: "slow case",
|
|
55
|
+
status: "passed",
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
schemaVersion: 1,
|
|
61
|
+
startedAt: "2026-08-06T12:00:00.000Z",
|
|
62
|
+
summary: {
|
|
63
|
+
durationMs: { ...durations },
|
|
64
|
+
slowFiles: [
|
|
65
|
+
{ durationMs: { ...durations }, path: "src/slow.test.ts", samples: 3 },
|
|
66
|
+
],
|
|
67
|
+
slowTests: [
|
|
68
|
+
{
|
|
69
|
+
durationMs: { ...durations },
|
|
70
|
+
file: "src/slow.test.ts",
|
|
71
|
+
name: "slow case",
|
|
72
|
+
samples: 3,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
tool: "factory-ci-vitest-profile",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("readVitestProfileDocument", () => {
|
|
80
|
+
it("accepts a document the writer emitted", () => {
|
|
81
|
+
const result = readVitestProfileDocument(validProfile());
|
|
82
|
+
|
|
83
|
+
expect(result.kind).toBe("profile");
|
|
84
|
+
if (result.kind === "profile") {
|
|
85
|
+
expect(result.profile.environment.cpuModel).toBe("AMD EPYC 9R45");
|
|
86
|
+
expect(result.profile.summary.durationMs.median).toBe(15_700);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("names the tool mismatch instead of guessing", () => {
|
|
91
|
+
const result = readVitestProfileDocument({
|
|
92
|
+
...validProfile(),
|
|
93
|
+
tool: "someone-elses-artifact",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(result).toEqual({
|
|
97
|
+
kind: "unrecognized",
|
|
98
|
+
reason:
|
|
99
|
+
'tool is "someone-elses-artifact" rather than "factory-ci-vitest-profile"',
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("refuses a future schema version rather than misreading it", () => {
|
|
104
|
+
const result = readVitestProfileDocument({
|
|
105
|
+
...validProfile(),
|
|
106
|
+
schemaVersion: 2,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(result).toEqual({
|
|
110
|
+
kind: "unrecognized",
|
|
111
|
+
reason: "schemaVersion is 2 rather than 1",
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("refuses a document whose environment lost the cpuModel field", () => {
|
|
116
|
+
const document = validProfile() as unknown as {
|
|
117
|
+
environment: Record<string, unknown>;
|
|
118
|
+
};
|
|
119
|
+
delete document.environment.cpuModel;
|
|
120
|
+
|
|
121
|
+
expect(readVitestProfileDocument(document)).toEqual({
|
|
122
|
+
kind: "unrecognized",
|
|
123
|
+
reason: "environment.cpuModel is neither a string nor null",
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("refuses a run without a finite duration", () => {
|
|
128
|
+
const document = validProfile();
|
|
129
|
+
(document.runs[0] as unknown as Record<string, unknown>).durationMs =
|
|
130
|
+
"fast";
|
|
131
|
+
|
|
132
|
+
expect(readVitestProfileDocument(document)).toEqual({
|
|
133
|
+
kind: "unrecognized",
|
|
134
|
+
reason: "runs[0].durationMs is not a finite number",
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("refuses a summary without duration statistics", () => {
|
|
139
|
+
const document = validProfile() as unknown as {
|
|
140
|
+
summary: { durationMs: Record<string, unknown> };
|
|
141
|
+
};
|
|
142
|
+
delete document.summary.durationMs.median;
|
|
143
|
+
|
|
144
|
+
expect(readVitestProfileDocument(document)).toEqual({
|
|
145
|
+
kind: "unrecognized",
|
|
146
|
+
reason: "summary.durationMs.median is not a finite number",
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("refuses empty counts rather than serving undefined fields", () => {
|
|
151
|
+
const document = validProfile();
|
|
152
|
+
(document.runs[0] as unknown as Record<string, unknown>).counts = {};
|
|
153
|
+
|
|
154
|
+
expect(readVitestProfileDocument(document)).toEqual({
|
|
155
|
+
kind: "unrecognized",
|
|
156
|
+
reason: "runs[0].counts.failed is not a finite number",
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("refuses a null slowFiles entry rather than crashing projection", () => {
|
|
161
|
+
const document = validProfile() as unknown as {
|
|
162
|
+
summary: { slowFiles: unknown[] };
|
|
163
|
+
};
|
|
164
|
+
document.summary.slowFiles = [null];
|
|
165
|
+
|
|
166
|
+
expect(readVitestProfileDocument(document)).toEqual({
|
|
167
|
+
kind: "unrecognized",
|
|
168
|
+
reason: "summary.slowFiles[0] is not an object",
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("refuses a slow test whose duration summary is incomplete", () => {
|
|
173
|
+
const document = validProfile() as unknown as {
|
|
174
|
+
summary: { slowTests: { durationMs: Record<string, unknown> }[] };
|
|
175
|
+
};
|
|
176
|
+
const [slowTest] = document.summary.slowTests;
|
|
177
|
+
if (slowTest) {
|
|
178
|
+
delete slowTest.durationMs.median;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
expect(readVitestProfileDocument(document)).toEqual({
|
|
182
|
+
kind: "unrecognized",
|
|
183
|
+
reason: "summary.slowTests[0].durationMs.median is not a finite number",
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("refuses a slow file without a path string", () => {
|
|
188
|
+
const document = validProfile() as unknown as {
|
|
189
|
+
summary: { slowFiles: Record<string, unknown>[] };
|
|
190
|
+
};
|
|
191
|
+
const [slowFile] = document.summary.slowFiles;
|
|
192
|
+
if (slowFile) {
|
|
193
|
+
delete slowFile.path;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
expect(readVitestProfileDocument(document)).toEqual({
|
|
197
|
+
kind: "unrecognized",
|
|
198
|
+
reason: "summary.slowFiles[0].path is not a string",
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("refuses non-object input", () => {
|
|
203
|
+
expect(readVitestProfileDocument(null)).toEqual({
|
|
204
|
+
kind: "unrecognized",
|
|
205
|
+
reason: "the document is not an object",
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -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
|
+
};
|