@patronage/factory-ci 1.0.0-alpha.7 → 1.0.0-alpha.8

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/dist/index.d.ts CHANGED
@@ -324,6 +324,13 @@ declare const FACTORY_PROOF_GATE_CHECK_NAME = "patronage-factory/pr-verify";
324
324
  declare const FACTORY_PROOF_GATE_APP_ID = "4314840";
325
325
  /** Step id the guard condition refers to. */
326
326
  declare const FACTORY_PROOF_GATE_STEP_ID = "factory-proof";
327
+ /**
328
+ * Human-visible name of the gate step as the Actions jobs API serves it. A
329
+ * read-only run analyzer (`psf ci:analyze`, #647) matches on this name to
330
+ * classify a run as proof-reuse versus full fallback, so it is exported from
331
+ * exactly the module that writes it — matching on a re-typed copy would drift.
332
+ */
333
+ declare const FACTORY_PROOF_GATE_STEP_NAME = "Check for factory proof of this head";
327
334
  /**
328
335
  * The shell the gate runs under, and it is a correctness requirement rather
329
336
  * than a preference.
@@ -750,6 +757,26 @@ declare const writeVitestProfile: (outputPath: string, profile: VitestProfile) =
750
757
  */
751
758
  declare const runVitestProfile: (options: VitestProfileOptions, dependencies?: VitestProfileDependencies) => Promise<VitestProfile>;
752
759
  //#endregion
760
+ //#region src/vitest-profile-reader.d.ts
761
+ /**
762
+ * Outcome of reading one candidate document. `unrecognized` carries the first
763
+ * reason the document failed — an analyzer reports it verbatim rather than
764
+ * treating an unreadable profile as an empty one.
765
+ */
766
+ type VitestProfileReadResult = {
767
+ kind: "profile";
768
+ profile: VitestProfile;
769
+ } | {
770
+ kind: "unrecognized";
771
+ reason: string;
772
+ };
773
+ /**
774
+ * Check one parsed JSON document against the profile contract the writer
775
+ * emits. Returns the typed profile on success and the first mismatch reason
776
+ * otherwise — never a partially-usable value.
777
+ */
778
+ declare const readVitestProfileDocument: (value: unknown) => VitestProfileReadResult;
779
+ //#endregion
753
780
  //#region src/workflow-shell-lint.d.ts
754
781
  /**
755
782
  * Parse-check the shell embedded in generated workflow YAML (#376).
@@ -801,4 +828,4 @@ declare const assertWorkflowShellParses: (yaml: string, options: {
801
828
  readonly source: string;
802
829
  }) => void;
803
830
  //#endregion
804
- export { type BundleAlchemyEntryOptions, type CheckoutStepOptions, type ExecuteAlchemyEntryOptions, type ExecuteAlchemyEntryResult, FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_CHECK_NAME, FACTORY_PROOF_GATE_GUARD, FACTORY_PROOF_GATE_IF, FACTORY_PROOF_GATE_MODE_OUTPUT, FACTORY_PROOF_GATE_OUTPUT, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_STEP_ID, type FactoryProofGateOptions, type FactoryProofGateReason, type FactoryProofGateStep, type FactoryWorkflowArtifact, type FactoryWorkflowOptions, type FactoryWorkflowSetupOptions, GitHubApiError, type GithubAppCredentials, type GithubAppTokenOptions, type InstallStepOptions, type LocalPreviewStage, type LocalPreviewStageOptions, NODE_PNPM_ACTION_FAMILY_NODE24, type NodePnpmActionFamily, type ParseLocalPreviewStageExpected, type ParsedLocalPreviewStage, type PinnedAction, type ProofReuseCommand, type ProofReuseCoverageInput, type ProofReuseCoverageReport, type SetupNodeStepOptions, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, type VitestJsonReport, type VitestProfile, type VitestProfileDependencies, type VitestProfileDurationSummary, type VitestProfileEnvironment, VitestProfileError, type VitestProfileOptions, type VitestProfileSample, type VitestProfileSampleExecution, type VitestTestStatus, type WorkflowShellParseFailure, type WorkflowStep, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
831
+ export { type BundleAlchemyEntryOptions, type CheckoutStepOptions, type ExecuteAlchemyEntryOptions, type ExecuteAlchemyEntryResult, FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_CHECK_NAME, FACTORY_PROOF_GATE_GUARD, FACTORY_PROOF_GATE_IF, FACTORY_PROOF_GATE_MODE_OUTPUT, FACTORY_PROOF_GATE_OUTPUT, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_STEP_ID, FACTORY_PROOF_GATE_STEP_NAME, type FactoryProofGateOptions, type FactoryProofGateReason, type FactoryProofGateStep, type FactoryWorkflowArtifact, type FactoryWorkflowOptions, type FactoryWorkflowSetupOptions, GitHubApiError, type GithubAppCredentials, type GithubAppTokenOptions, type InstallStepOptions, type LocalPreviewStage, type LocalPreviewStageOptions, NODE_PNPM_ACTION_FAMILY_NODE24, type NodePnpmActionFamily, type ParseLocalPreviewStageExpected, type ParsedLocalPreviewStage, type PinnedAction, type ProofReuseCommand, type ProofReuseCoverageInput, type ProofReuseCoverageReport, type SetupNodeStepOptions, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, type VitestJsonReport, type VitestProfile, type VitestProfileDependencies, type VitestProfileDurationSummary, type VitestProfileEnvironment, VitestProfileError, type VitestProfileOptions, type VitestProfileReadResult, type VitestProfileSample, type VitestProfileSampleExecution, type VitestTestStatus, type WorkflowShellParseFailure, type WorkflowStep, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
package/dist/index.js CHANGED
@@ -435,6 +435,13 @@ const FACTORY_PROOF_GATE_APP_ID = "4314840";
435
435
  /** Step id the guard condition refers to. */
436
436
  const FACTORY_PROOF_GATE_STEP_ID = "factory-proof";
437
437
  /**
438
+ * Human-visible name of the gate step as the Actions jobs API serves it. A
439
+ * read-only run analyzer (`psf ci:analyze`, #647) matches on this name to
440
+ * classify a run as proof-reuse versus full fallback, so it is exported from
441
+ * exactly the module that writes it — matching on a re-typed copy would drift.
442
+ */
443
+ const FACTORY_PROOF_GATE_STEP_NAME = "Check for factory proof of this head";
444
+ /**
438
445
  * The shell the gate runs under, and it is a correctness requirement rather
439
446
  * than a preference.
440
447
  *
@@ -906,7 +913,7 @@ const factoryProofGateStep = (options) => Object.freeze({
906
913
  }),
907
914
  id: FACTORY_PROOF_GATE_STEP_ID,
908
915
  if: FACTORY_PROOF_GATE_IF,
909
- name: "Check for factory proof of this head",
916
+ name: FACTORY_PROOF_GATE_STEP_NAME,
910
917
  run: factoryProofGateScript(options),
911
918
  shell: FACTORY_PROOF_GATE_SHELL
912
919
  });
@@ -1261,6 +1268,120 @@ const runVitestProfile = async (options, dependencies = {}) => {
1261
1268
  return profile;
1262
1269
  };
1263
1270
  //#endregion
1271
+ //#region src/vitest-profile-reader.ts
1272
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1273
+ const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
1274
+ const durationSummaryFailure = (value, where) => {
1275
+ if (!isRecord(value)) return `${where} is not an object`;
1276
+ const missing = [
1277
+ "maximum",
1278
+ "mean",
1279
+ "median",
1280
+ "minimum"
1281
+ ].find((field) => !isFiniteNumber(value[field]));
1282
+ return missing === void 0 ? void 0 : `${where}.${missing} is not a finite number`;
1283
+ };
1284
+ const environmentFailure = (value) => {
1285
+ if (!isRecord(value)) return "environment is not an object";
1286
+ if (!(typeof value.cpuModel === "string" || value.cpuModel === null)) return "environment.cpuModel is neither a string nor null";
1287
+ if (!isFiniteNumber(value.cpuCount)) return "environment.cpuCount is not a finite number";
1288
+ };
1289
+ /** The count fields the writer emits; every one must be a finite number. */
1290
+ const COUNT_FIELDS = [
1291
+ "failed",
1292
+ "passed",
1293
+ "pending",
1294
+ "suites",
1295
+ "tests",
1296
+ "todo"
1297
+ ];
1298
+ const runFailure = (value, index) => {
1299
+ if (!isRecord(value)) return `runs[${index}] is not an object`;
1300
+ if (!isFiniteNumber(value.durationMs)) return `runs[${index}].durationMs is not a finite number`;
1301
+ if (value.counts === null) return;
1302
+ if (!isRecord(value.counts)) return `runs[${index}].counts is neither an object nor null`;
1303
+ const { counts } = value;
1304
+ const missing = COUNT_FIELDS.find((field) => !isFiniteNumber(counts[field]));
1305
+ return missing === void 0 ? void 0 : `runs[${index}].counts.${missing} is not a finite number`;
1306
+ };
1307
+ const slowEntryFailure = (value, where, labelField) => {
1308
+ if (!isRecord(value)) return `${where} is not an object`;
1309
+ if (typeof value[labelField] !== "string") return `${where}.${labelField} is not a string`;
1310
+ if (labelField === "name" && typeof value.file !== "string") return `${where}.file is not a string`;
1311
+ return durationSummaryFailure(value.durationMs, `${where}.durationMs`);
1312
+ };
1313
+ const summaryFailure = (value) => {
1314
+ if (!isRecord(value)) return "summary is not an object";
1315
+ const durations = durationSummaryFailure(value.durationMs, "summary.durationMs");
1316
+ if (durations !== void 0) return durations;
1317
+ if (!Array.isArray(value.slowFiles)) return "summary.slowFiles is not an array";
1318
+ for (const [index, entry] of value.slowFiles.entries()) {
1319
+ const failure = slowEntryFailure(entry, `summary.slowFiles[${index}]`, "path");
1320
+ if (failure !== void 0) return failure;
1321
+ }
1322
+ if (!Array.isArray(value.slowTests)) return "summary.slowTests is not an array";
1323
+ for (const [index, entry] of value.slowTests.entries()) {
1324
+ const failure = slowEntryFailure(entry, `summary.slowTests[${index}]`, "name");
1325
+ if (failure !== void 0) return failure;
1326
+ }
1327
+ };
1328
+ /**
1329
+ * Check one parsed JSON document against the profile contract the writer
1330
+ * emits. Returns the typed profile on success and the first mismatch reason
1331
+ * otherwise — never a partially-usable value.
1332
+ */
1333
+ const readVitestProfileDocument = (value) => {
1334
+ if (!isRecord(value)) return {
1335
+ kind: "unrecognized",
1336
+ reason: "the document is not an object"
1337
+ };
1338
+ if (value.tool !== "factory-ci-vitest-profile") return {
1339
+ kind: "unrecognized",
1340
+ reason: `tool is ${JSON.stringify(value.tool)} rather than "${VITEST_PROFILE_TOOL}"`
1341
+ };
1342
+ if (value.schemaVersion !== 1) return {
1343
+ kind: "unrecognized",
1344
+ reason: `schemaVersion is ${JSON.stringify(value.schemaVersion)} rather than 1`
1345
+ };
1346
+ if (!(Array.isArray(value.command) && value.command.every((part) => typeof part === "string"))) return {
1347
+ kind: "unrecognized",
1348
+ reason: "command is not an array of strings"
1349
+ };
1350
+ const environment = environmentFailure(value.environment);
1351
+ if (environment !== void 0) return {
1352
+ kind: "unrecognized",
1353
+ reason: environment
1354
+ };
1355
+ if (!isRecord(value.options)) return {
1356
+ kind: "unrecognized",
1357
+ reason: "options is not an object"
1358
+ };
1359
+ if (!isFiniteNumber(value.options.maxWorkers)) return {
1360
+ kind: "unrecognized",
1361
+ reason: "options.maxWorkers is not a finite number"
1362
+ };
1363
+ if (!Array.isArray(value.runs)) return {
1364
+ kind: "unrecognized",
1365
+ reason: "runs is not an array"
1366
+ };
1367
+ for (const [index, run] of value.runs.entries()) {
1368
+ const failure = runFailure(run, index);
1369
+ if (failure !== void 0) return {
1370
+ kind: "unrecognized",
1371
+ reason: failure
1372
+ };
1373
+ }
1374
+ const summary = summaryFailure(value.summary);
1375
+ if (summary !== void 0) return {
1376
+ kind: "unrecognized",
1377
+ reason: summary
1378
+ };
1379
+ return {
1380
+ kind: "profile",
1381
+ profile: value
1382
+ };
1383
+ };
1384
+ //#endregion
1264
1385
  //#region src/workflow-shell-lint.ts
1265
1386
  const RUN_KEY = /^(?<indent>\s*)(?:-\s+)?run:(?<inline>.*)$/u;
1266
1387
  /**
@@ -1525,4 +1646,4 @@ const assertWorkflowShellParses = (yaml, options) => {
1525
1646
  throw new Error(`${options.source} emits shell bash cannot parse; the runner would treat it as a no-op:\n${detail}`);
1526
1647
  };
1527
1648
  //#endregion
1528
- export { FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_CHECK_NAME, FACTORY_PROOF_GATE_GUARD, FACTORY_PROOF_GATE_IF, FACTORY_PROOF_GATE_MODE_OUTPUT, FACTORY_PROOF_GATE_OUTPUT, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_STEP_ID, GitHubApiError, NODE_PNPM_ACTION_FAMILY_NODE24, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, VitestProfileError, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
1649
+ export { FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_CHECK_NAME, FACTORY_PROOF_GATE_GUARD, FACTORY_PROOF_GATE_IF, FACTORY_PROOF_GATE_MODE_OUTPUT, FACTORY_PROOF_GATE_OUTPUT, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_STEP_ID, FACTORY_PROOF_GATE_STEP_NAME, GitHubApiError, NODE_PNPM_ACTION_FAMILY_NODE24, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, VitestProfileError, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patronage/factory-ci",
3
- "version": "1.0.0-alpha.7",
3
+ "version": "1.0.0-alpha.8",
4
4
  "description": "Deep CI and deploy building blocks for Patronage factory projects: workflow source artifacts, hosted diff classification, Alchemy entry execution, and disposable-stage semantics",
5
5
  "keywords": [
6
6
  "alchemy",
package/src/index.ts CHANGED
@@ -60,6 +60,7 @@ export {
60
60
  FACTORY_PROOF_GATE_REASONS,
61
61
  FACTORY_PROOF_GATE_SHELL,
62
62
  FACTORY_PROOF_GATE_STEP_ID,
63
+ FACTORY_PROOF_GATE_STEP_NAME,
63
64
  type FactoryProofGateOptions,
64
65
  type FactoryProofGateReason,
65
66
  factoryProofGateScript,
@@ -90,6 +91,10 @@ export {
90
91
  type VitestTestStatus,
91
92
  writeVitestProfile,
92
93
  } from "./vitest-profile.ts";
94
+ export {
95
+ readVitestProfileDocument,
96
+ type VitestProfileReadResult,
97
+ } from "./vitest-profile-reader.ts";
93
98
  export {
94
99
  assertWorkflowShellParses,
95
100
  workflowRunBlocks,
@@ -57,6 +57,15 @@ export const FACTORY_PROOF_GATE_APP_ID = "4314840";
57
57
  /** Step id the guard condition refers to. */
58
58
  export const FACTORY_PROOF_GATE_STEP_ID = "factory-proof";
59
59
 
60
+ /**
61
+ * Human-visible name of the gate step as the Actions jobs API serves it. A
62
+ * read-only run analyzer (`psf ci:analyze`, #647) matches on this name to
63
+ * classify a run as proof-reuse versus full fallback, so it is exported from
64
+ * exactly the module that writes it — matching on a re-typed copy would drift.
65
+ */
66
+ export const FACTORY_PROOF_GATE_STEP_NAME =
67
+ "Check for factory proof of this head";
68
+
60
69
  /**
61
70
  * The shell the gate runs under, and it is a correctness requirement rather
62
71
  * than a preference.
@@ -619,7 +628,7 @@ export const factoryProofGateStep = (
619
628
  }),
620
629
  id: FACTORY_PROOF_GATE_STEP_ID,
621
630
  if: FACTORY_PROOF_GATE_IF,
622
- name: "Check for factory proof of this head",
631
+ name: FACTORY_PROOF_GATE_STEP_NAME,
623
632
  run: factoryProofGateScript(options),
624
633
  // Never omit: the runner's default `run:` shell supplies `-e`, which
625
634
  // voids the fail-open design. See FACTORY_PROOF_GATE_SHELL.
@@ -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
+ };