@patronage/factory-ci 1.0.0-alpha.4 → 1.0.0-alpha.6

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 CHANGED
@@ -166,6 +166,29 @@ Nothing is cached. The token is returned to the caller, which owns its lifetime
166
166
 
167
167
  `options` are all injectable seams: `fetch`, `now`, `readPrivateKey`, and a `timeoutMs` per request (five seconds by default). Tests substitute the first three; production passes at most a timeout.
168
168
 
169
+ ### Vitest suite profiling
170
+
171
+ ```ts
172
+ import { runVitestProfile } from "@patronage/factory-ci";
173
+
174
+ const profile = await runVitestProfile({
175
+ cwd: packageRoot,
176
+ gitDirectory: repositoryRoot,
177
+ maxWorkers: 4,
178
+ outputPath: "/abs/path/profile.json",
179
+ samples: 3,
180
+ slowLimit: 20,
181
+ });
182
+ ```
183
+
184
+ `runVitestProfile(options, dependencies?)` takes N **serial** Vitest runs at one worker count and writes a machine-readable profile after every sample: per-file and per-test timings sorted slowest first, duration statistics across samples, and the machine the samples ran on. Samples never overlap — concurrent runs would measure CPU and I/O contention instead of the worker count under test — and the profile is rewritten after each one, so a run that goes red at sample three still leaves two usable measurements on disk. A failed sample throws `VitestProfileError`, which carries the `exitCode` a caller should exit with.
185
+
186
+ The **environment capture is the reason this is shared**. The #640 runner comparison only resolved because each sample recorded its `cpuModel`: 4-CPU samples that were indistinguishable by label split into two non-overlapping populations, AMD at 15.3–16.2s and Intel at 24.6–24.9s. `captureVitestProfileEnvironment({ cwd, gitDirectory? })` records CPU model and count, available parallelism, total memory, arch, platform, OS release, Node version, the **consumer's** Vitest version (resolved from `cwd`, never this package's), and the Git head and dirtiness. Git failures and an unresolvable Vitest degrade to `null` / `"unknown"` rather than throwing: an artifact from a tarball checkout is still a measurement.
187
+
188
+ `normalizeVitestProfileSample(report, input)` folds one Vitest `--reporter=json` document into a sample; a missing report yields `reportAvailable: false` instead of nothing. `writeVitestProfile(path, profile)` is the atomic write — a partial file must never be readable as a complete measurement. Every emitted document carries `schemaVersion: VITEST_PROFILE_SCHEMA_VERSION` and `tool: VITEST_PROFILE_TOOL` so artifacts from different repositories are comparable and self-identifying.
189
+
190
+ **Everything a repository decides stays with the repository**: worker counts, sample counts, slow-list length, output-path conventions, runner labels, artifact upload, and console output. `onSampleStart` / `onSampleComplete` hand the caller each sample so it can print whatever it prints; this package logs nothing. `stdio` for the Vitest child is the caller's too — `"inherit"` by default, so a caller whose own stdout is structured passes `"ignore"`. A reporting hook is a console, not a control: if `onSampleStart` or `onSampleComplete` throws, the sample still runs and the `VitestProfileError` still wins, and the hook's error surfaces only when the sample was otherwise green. `samples` below one is refused outright — it would resolve green having measured nothing and written no artifact. `now`, `runSample`, and `writeResult` are injectable seams for tests.
191
+
169
192
  ### Published-package contract
170
193
 
171
194
  The tests pack the actual tarball, extract it into a throwaway external consumer, import the built package root without the workspace's `development` condition, assert the exact runtime exports, and exercise the workflow and stage interfaces. This catches source-only successes, stale or missing `dist/`, exports-map mistakes, and accidental tarball growth before the attended release check.
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { StdioOptions } from "node:child_process";
2
+
1
3
  //#region src/actions.d.ts
2
4
  /**
3
5
  * The GitHub Actions this family's workflow generators use, pinned to full
@@ -415,6 +417,34 @@ interface ProofReuseCommand {
415
417
  * must refuse instead.
416
418
  */
417
419
  declare const proofReuseRequiredCommands: (commands: readonly ProofReuseCommand[]) => readonly string[] | undefined;
420
+ /**
421
+ * Resolve command *identities* to their `ProofReuseCommand` objects (ADR
422
+ * 0021).
423
+ *
424
+ * Selection by name is deliberately a consumer decision (proof-surfaces.ts in
425
+ * software-factory-hq, the `paitronage:verify` filter in paitronage's
426
+ * `verify.ts`): only the consumer knows which named commands a guarded
427
+ * surface requires. What both of those implementations independently
428
+ * hand-rolled is the same lookup — find each name in the profile's command
429
+ * catalog, and refuse to silently shrink the required set when a name has no
430
+ * entry. That lookup is what this function is: the mechanic, not the
431
+ * selection.
432
+ *
433
+ * Throwing at generation time (rather than returning `undefined` or an empty
434
+ * array) is deliberate: a name with no catalog entry is a mistake in the
435
+ * generator source, not a runtime condition a consumer should have to check
436
+ * for, and a required set that quietly loses an entry is exactly what makes a
437
+ * passing proof trivially "covering".
438
+ *
439
+ * `selectionLabel` names the failure, nothing else: it is not part of the
440
+ * selection this function resolves, only prose a consumer supplies for its
441
+ * own thrown error (e.g. HQ's surface name, "core" or "docs"). The message
442
+ * deliberately says "the profile's command catalog" rather than naming
443
+ * `software-factory.profile.json`: a fleet-generic library must not assume
444
+ * every consumer's catalog is that exact file, so this wording differs
445
+ * on purpose from the HQ-local message it replaced.
446
+ */
447
+ declare const resolveProofReuseCommands: (catalog: readonly ProofReuseCommand[], names: readonly string[], selectionLabel?: string) => readonly ProofReuseCommand[];
418
448
  interface FactoryProofGateOptions {
419
449
  /**
420
450
  * The profile command objects this guarded surface selected. A consumer with
@@ -510,6 +540,216 @@ declare const proofReuseCoverage: ({
510
540
  /** `proofReuseCoverage`, as a build failure. */
511
541
  declare const assertProofReuseCoverage: (input: ProofReuseCoverageInput) => ProofReuseCoverageReport;
512
542
  //#endregion
543
+ //#region src/vitest-profile.d.ts
544
+ /** Schema version of the emitted profile document. */
545
+ declare const VITEST_PROFILE_SCHEMA_VERSION = 1;
546
+ /** `tool` discriminator every emitted profile carries. */
547
+ declare const VITEST_PROFILE_TOOL = "factory-ci-vitest-profile";
548
+ /** Per-assertion status as Vitest's JSON reporter spells it. */
549
+ type VitestTestStatus = "disabled" | "failed" | "passed" | "pending" | "skipped" | "todo";
550
+ /** The subset of Vitest's `--reporter=json` document this module reads. */
551
+ interface VitestJsonReport {
552
+ numFailedTests: number;
553
+ numPassedTests: number;
554
+ numPendingTests: number;
555
+ numTodoTests: number;
556
+ numTotalTests: number;
557
+ numTotalTestSuites: number;
558
+ success: boolean;
559
+ testResults: {
560
+ assertionResults: {
561
+ duration?: number | null;
562
+ fullName: string;
563
+ status: VitestTestStatus;
564
+ }[];
565
+ endTime: number;
566
+ name: string;
567
+ startTime: number;
568
+ status: "failed" | "passed";
569
+ }[];
570
+ }
571
+ /** Duration statistics over one population of samples, in milliseconds. */
572
+ interface VitestProfileDurationSummary {
573
+ maximum: number;
574
+ mean: number;
575
+ median: number;
576
+ minimum: number;
577
+ }
578
+ /**
579
+ * The machine a sample ran on, plus the commit it measured. `cpuModel` is the
580
+ * field that makes two runs comparable at all: hosted runner pools mix silicon
581
+ * behind one label.
582
+ */
583
+ interface VitestProfileEnvironment {
584
+ arch: string;
585
+ availableParallelism: number;
586
+ cpuCount: number;
587
+ cpuModel: string | null;
588
+ gitDirty: boolean | null;
589
+ gitHead: string | null;
590
+ node: string;
591
+ osRelease: string;
592
+ platform: string;
593
+ totalMemoryBytes: number;
594
+ vitest: string;
595
+ }
596
+ /** One complete Vitest run inside a profile. */
597
+ interface VitestProfileSample {
598
+ counts: {
599
+ failed: number;
600
+ passed: number;
601
+ pending: number;
602
+ suites: number;
603
+ tests: number;
604
+ todo: number;
605
+ } | null;
606
+ durationMs: number;
607
+ endedAt: string;
608
+ exitCode: number;
609
+ failure: string | null;
610
+ files: {
611
+ durationMs: number;
612
+ path: string;
613
+ status: "failed" | "passed";
614
+ }[];
615
+ reportAvailable: boolean;
616
+ sample: number;
617
+ startedAt: string;
618
+ tests: {
619
+ durationMs: number;
620
+ file: string;
621
+ name: string;
622
+ status: VitestTestStatus;
623
+ }[];
624
+ }
625
+ /** The emitted profile document. */
626
+ interface VitestProfile {
627
+ command: string[];
628
+ endedAt: string;
629
+ environment: VitestProfileEnvironment;
630
+ options: {
631
+ maxWorkers: number;
632
+ samples: number;
633
+ slowLimit: number;
634
+ };
635
+ rawReportDirectory: string;
636
+ runs: VitestProfileSample[];
637
+ schemaVersion: typeof VITEST_PROFILE_SCHEMA_VERSION;
638
+ startedAt: string;
639
+ summary: {
640
+ durationMs: VitestProfileDurationSummary;
641
+ slowFiles: {
642
+ durationMs: VitestProfileDurationSummary;
643
+ path: string;
644
+ samples: number;
645
+ }[];
646
+ slowTests: {
647
+ durationMs: VitestProfileDurationSummary;
648
+ file: string;
649
+ name: string;
650
+ samples: number;
651
+ }[];
652
+ };
653
+ tool: typeof VITEST_PROFILE_TOOL;
654
+ }
655
+ /** What a caller must decide before a profile can run. */
656
+ interface VitestProfileOptions {
657
+ /** Directory Vitest runs in; file paths are recorded relative to it. */
658
+ cwd: string;
659
+ /** Directory whose Git state is recorded. Defaults to `cwd`. */
660
+ gitDirectory?: string;
661
+ /** Vitest `--maxWorkers` for every sample. */
662
+ maxWorkers: number;
663
+ /** Absolute path of the profile document to write. */
664
+ outputPath: string;
665
+ /** Called before each sample starts. */
666
+ onSampleStart?: (input: {
667
+ maxWorkers: number;
668
+ sample: number;
669
+ samples: number;
670
+ }) => void;
671
+ /** Called after each sample is normalized and persisted. */
672
+ onSampleComplete?: (input: {
673
+ result: VitestProfileSample;
674
+ sample: number;
675
+ samples: number;
676
+ }) => void;
677
+ /** How many serial runs to take. */
678
+ samples: number;
679
+ /** How many slow files and slow tests to keep in the summary. */
680
+ slowLimit: number;
681
+ /**
682
+ * `stdio` for the Vitest child. Defaults to `"inherit"`, which is what a
683
+ * caller printing progress to a terminal wants; a caller whose own stdout is
684
+ * structured passes `"ignore"` to silence the run.
685
+ */
686
+ stdio?: StdioOptions;
687
+ }
688
+ /** Outcome of one Vitest invocation, as `runSample` reports it. */
689
+ interface VitestProfileSampleExecution {
690
+ durationMs: number;
691
+ exitCode: number;
692
+ failure?: string | null;
693
+ report: VitestJsonReport | null;
694
+ }
695
+ /** Injectable seams; production passes none of them. */
696
+ interface VitestProfileDependencies {
697
+ now?: () => Date;
698
+ runSample?: (input: {
699
+ cwd: string;
700
+ maxWorkers: number;
701
+ reportPath: string;
702
+ sample: number;
703
+ stdio: StdioOptions;
704
+ }) => Promise<VitestProfileSampleExecution>;
705
+ writeResult?: (outputPath: string, profile: VitestProfile) => Promise<void>;
706
+ }
707
+ /**
708
+ * A sample failed. The partial profile is already on disk; `exitCode` is the
709
+ * status a caller should exit with.
710
+ */
711
+ declare class VitestProfileError extends Error {
712
+ readonly exitCode: number;
713
+ constructor(message: string, exitCode: number);
714
+ }
715
+ /**
716
+ * Fold one Vitest JSON report into a profile sample: file and test timings,
717
+ * both sorted slowest first, plus the run's counts. A missing report (crash,
718
+ * timeout, unwritable output) yields a sample with `reportAvailable: false`
719
+ * rather than nothing at all.
720
+ */
721
+ declare const normalizeVitestProfileSample: (report: VitestJsonReport | null, input: {
722
+ cwd: string;
723
+ durationMs: number;
724
+ endedAt: Date;
725
+ exitCode: number;
726
+ failure?: string | null;
727
+ sample: number;
728
+ startedAt: Date;
729
+ }) => VitestProfileSample;
730
+ /**
731
+ * Record the machine and commit a profile was taken on. Vitest's version is
732
+ * resolved from `cwd`, so it is the consumer's Vitest and not this package's.
733
+ * Git failures degrade to `null` — an artifact from a tarball checkout is still
734
+ * a usable measurement.
735
+ */
736
+ declare const captureVitestProfileEnvironment: (options: {
737
+ cwd: string;
738
+ gitDirectory?: string;
739
+ }) => Promise<VitestProfileEnvironment>;
740
+ /**
741
+ * Write a profile document atomically: a partial file must never be readable
742
+ * as a complete measurement, and the profile is rewritten after every sample.
743
+ */
744
+ declare const writeVitestProfile: (outputPath: string, profile: VitestProfile) => Promise<void>;
745
+ /**
746
+ * Take `samples` serial Vitest runs at one worker count and persist the profile
747
+ * after each one. Samples never overlap: concurrent runs would measure CPU and
748
+ * I/O contention instead of the worker count under test. A failing sample
749
+ * throws `VitestProfileError` with the partial profile already written.
750
+ */
751
+ declare const runVitestProfile: (options: VitestProfileOptions, dependencies?: VitestProfileDependencies) => Promise<VitestProfile>;
752
+ //#endregion
513
753
  //#region src/workflow-shell-lint.d.ts
514
754
  /**
515
755
  * Parse-check the shell embedded in generated workflow YAML (#376).
@@ -561,4 +801,4 @@ declare const assertWorkflowShellParses: (yaml: string, options: {
561
801
  readonly source: string;
562
802
  }) => void;
563
803
  //#endregion
564
- 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, type WorkflowShellParseFailure, type WorkflowStep, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, executeAlchemyEntry, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands, workflowRunBlocks, workflowShellParseFailures };
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 };