@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 +23 -0
- package/dist/index.d.ts +241 -1
- package/dist/index.js +367 -4
- package/package.json +3 -6
- package/src/index.ts +19 -0
- package/src/proof-reuse-gate.ts +45 -0
- package/src/vitest-profile.ts +631 -0
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 };
|