@sackville-mcp/flake 0.0.1-alpha.0
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/LICENSE +201 -0
- package/dist/index.d.mts +332 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +577 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or Derivative
|
|
95
|
+
Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2026 Curtis Autery
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
|
|
3
|
+
//#region src/classify.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Pure flakiness classifier — the first slice of `@sackville-mcp/flake`, and the only one
|
|
6
|
+
* that touches no I/O. Given each test's run history (an ordered list of pass/fail
|
|
7
|
+
* outcomes), it labels the test and quantifies *how* flaky it is with a binomial
|
|
8
|
+
* confidence bound, so a later operator-gated quarantine slice has a defensible,
|
|
9
|
+
* sample-size-aware number to threshold on rather than a raw "it failed once" reflex.
|
|
10
|
+
*
|
|
11
|
+
* Why Wilson and not the naive p̂ = failures/runs:
|
|
12
|
+
* - The naive rate is wildly overconfident on small samples (1 failure in 2 runs reads
|
|
13
|
+
* as a 50% failure rate; 1 in 100 reads as 1%, but with no sense of how trustworthy
|
|
14
|
+
* either is). The **Wilson score interval** for a binomial proportion gives an
|
|
15
|
+
* asymmetric, always-in-[0,1] confidence interval that stays sane at small n and at
|
|
16
|
+
* the p̂=0 / p̂=1 boundaries (where the normal-approximation Wald interval collapses to
|
|
17
|
+
* a useless zero-width point). We expose its lower bound as `flakeScore`: the
|
|
18
|
+
* conservative "we're confident the test fails at least this often" magnitude — a test
|
|
19
|
+
* that failed 1/100 from infra noise scores far below one failing 30/100, even though a
|
|
20
|
+
* naive "has failed" flag treats them alike.
|
|
21
|
+
*
|
|
22
|
+
* Classification policy (deliberately conservative toward *catching* flakes, but
|
|
23
|
+
* cautious about *condemning* a test as reliable/broken on thin evidence):
|
|
24
|
+
* - A history with **both** a pass and a failure is `flaky` at any run count — observed
|
|
25
|
+
* inconsistency is the definition of flaky; one mixed pair is enough to flag it.
|
|
26
|
+
* - An all-pass or all-fail history is only trusted as `reliable` / `broken` once it
|
|
27
|
+
* clears `minRuns`; below that it is `insufficient-data` (a brand-new all-pass test may
|
|
28
|
+
* simply not have hit its flake yet; a single failure may be a one-off).
|
|
29
|
+
* - An empty history is `insufficient-data`.
|
|
30
|
+
*/
|
|
31
|
+
/** A single recorded execution of a test. */
|
|
32
|
+
interface TestRun {
|
|
33
|
+
passed: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* ISO timestamp of the run. Carried through from the (future) history store for later
|
|
36
|
+
* time-windowing slices; the pure classifier reads only `passed`.
|
|
37
|
+
*/
|
|
38
|
+
at?: string;
|
|
39
|
+
}
|
|
40
|
+
interface TestHistory {
|
|
41
|
+
/** Stable test identifier, e.g. `<file> > <test name>`. */
|
|
42
|
+
id: string;
|
|
43
|
+
/** Runs in any order — the classifier only counts pass/fail, never their sequence. */
|
|
44
|
+
runs: TestRun[];
|
|
45
|
+
}
|
|
46
|
+
type FlakeState = 'flaky' | 'reliable' | 'broken' | 'insufficient-data';
|
|
47
|
+
/** A Wilson score interval, clamped to [0, 1]. */
|
|
48
|
+
interface WilsonInterval {
|
|
49
|
+
lower: number;
|
|
50
|
+
center: number;
|
|
51
|
+
upper: number;
|
|
52
|
+
}
|
|
53
|
+
interface FlakeVerdict {
|
|
54
|
+
id: string;
|
|
55
|
+
state: FlakeState;
|
|
56
|
+
runs: number;
|
|
57
|
+
passes: number;
|
|
58
|
+
failures: number;
|
|
59
|
+
/** Observed failure rate failures/runs (0 when there are no runs). */
|
|
60
|
+
failureRate: number;
|
|
61
|
+
/** Wilson score interval for the true failure rate at the configured confidence. */
|
|
62
|
+
wilson: WilsonInterval;
|
|
63
|
+
/**
|
|
64
|
+
* Conservative flakiness magnitude = the Wilson lower bound of the failure rate. The
|
|
65
|
+
* number a quarantine policy thresholds on: high only when the test fails often AND we
|
|
66
|
+
* have enough runs to be confident. 0 for reliable / empty histories.
|
|
67
|
+
*/
|
|
68
|
+
flakeScore: number;
|
|
69
|
+
}
|
|
70
|
+
interface ClassifyOptions {
|
|
71
|
+
/** z-score for the Wilson interval; default 1.96 (two-sided 95%). */
|
|
72
|
+
z?: number;
|
|
73
|
+
/**
|
|
74
|
+
* Minimum runs before an all-pass / all-fail history is trusted as `reliable` /
|
|
75
|
+
* `broken`. Below it (with no observed inconsistency) the verdict is
|
|
76
|
+
* `insufficient-data`. A *mixed* history is `flaky` at any run count. Default 5.
|
|
77
|
+
*/
|
|
78
|
+
minRuns?: number;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The Wilson score interval for `failures` successes in `runs` Bernoulli trials at
|
|
82
|
+
* confidence `z`. Bounds are clamped to [0, 1]. Zero runs yields a degenerate zero
|
|
83
|
+
* interval (the rate is undefined; the caller marks it insufficient-data).
|
|
84
|
+
*/
|
|
85
|
+
declare function wilsonInterval(failures: number, runs: number, z?: number): WilsonInterval;
|
|
86
|
+
/** Classify a single test's run history into a {@link FlakeVerdict}. */
|
|
87
|
+
declare function classifyHistory(history: TestHistory, opts?: ClassifyOptions): FlakeVerdict;
|
|
88
|
+
/**
|
|
89
|
+
* Classify many histories, preserving input order. Callers rank quarantine candidates by
|
|
90
|
+
* sorting on `flakeScore` (or filtering `state === 'flaky'`).
|
|
91
|
+
*/
|
|
92
|
+
declare function classifyHistories(histories: TestHistory[], opts?: ClassifyOptions): FlakeVerdict[];
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/store.d.ts
|
|
95
|
+
/** A test outcome to record. */
|
|
96
|
+
interface RecordedRun {
|
|
97
|
+
testId: string;
|
|
98
|
+
passed: boolean;
|
|
99
|
+
/** ISO timestamp; defaults to now. */
|
|
100
|
+
at?: string;
|
|
101
|
+
/** Optional wall-clock duration of the run. */
|
|
102
|
+
durationMs?: number;
|
|
103
|
+
/** Optional id grouping all tests from one suite execution (a CI run / batch). */
|
|
104
|
+
runGroup?: string;
|
|
105
|
+
}
|
|
106
|
+
interface HistoryQueryOptions {
|
|
107
|
+
/** Keep only the most recent N runs per test (chronological tail). */
|
|
108
|
+
limitPerTest?: number;
|
|
109
|
+
/** Only include runs at/after this ISO timestamp. */
|
|
110
|
+
since?: string;
|
|
111
|
+
}
|
|
112
|
+
declare class HistoryStore {
|
|
113
|
+
private readonly db;
|
|
114
|
+
private readonly insert;
|
|
115
|
+
constructor(db: Database.Database);
|
|
116
|
+
/** Open (creating if needed) a file-backed history store and run migrations. */
|
|
117
|
+
static open(path: string): HistoryStore;
|
|
118
|
+
/** An in-memory store (tests, ephemeral analysis). */
|
|
119
|
+
static memory(): HistoryStore;
|
|
120
|
+
/** The underlying database — shared with sibling tables (e.g. quarantine). */
|
|
121
|
+
get database(): Database.Database;
|
|
122
|
+
recordRun(run: RecordedRun): void;
|
|
123
|
+
/** Record many runs in a single transaction. */
|
|
124
|
+
recordRuns(runs: RecordedRun[]): void;
|
|
125
|
+
private rows;
|
|
126
|
+
private static toRun;
|
|
127
|
+
/** All histories, grouped per test and sorted by test id. */
|
|
128
|
+
histories(opts?: HistoryQueryOptions): TestHistory[];
|
|
129
|
+
/** One test's history (empty `runs` when never recorded). */
|
|
130
|
+
history(testId: string, opts?: HistoryQueryOptions): TestHistory;
|
|
131
|
+
/**
|
|
132
|
+
* Parse a vitest json report and record every pass/fail assertion as a run. Returns the
|
|
133
|
+
* number of runs recorded (skipped/pending/todo assertions are not counted).
|
|
134
|
+
*/
|
|
135
|
+
ingestReport(report: VitestJsonReport, opts: ParseReportOptions): number;
|
|
136
|
+
/**
|
|
137
|
+
* Parse a pytest-json-report report and record every pass/fail/error test as a run. Returns
|
|
138
|
+
* the number of runs recorded (skipped/xfailed/xpassed tests are not counted). The Python
|
|
139
|
+
* sibling of {@link ingestReport}.
|
|
140
|
+
*/
|
|
141
|
+
ingestPytestReport(report: PytestJsonReport, opts: ParseReportOptions): number;
|
|
142
|
+
/** Classify every test's history straight from the store. */
|
|
143
|
+
classify(opts?: ClassifyOptions & HistoryQueryOptions): FlakeVerdict[];
|
|
144
|
+
close(): void;
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region src/report.d.ts
|
|
148
|
+
/** The subset of a vitest json assertion result we read. */
|
|
149
|
+
interface VitestAssertion {
|
|
150
|
+
ancestorTitles?: string[];
|
|
151
|
+
title?: string;
|
|
152
|
+
fullName?: string;
|
|
153
|
+
status?: string;
|
|
154
|
+
duration?: number | null;
|
|
155
|
+
}
|
|
156
|
+
interface VitestFileResult {
|
|
157
|
+
/** Test file path (absolute as vitest emits it). */
|
|
158
|
+
name?: string;
|
|
159
|
+
assertionResults?: VitestAssertion[];
|
|
160
|
+
}
|
|
161
|
+
interface VitestJsonReport {
|
|
162
|
+
testResults?: VitestFileResult[];
|
|
163
|
+
}
|
|
164
|
+
interface ParseReportOptions {
|
|
165
|
+
/** ISO timestamp stamped on every parsed run. */
|
|
166
|
+
at: string;
|
|
167
|
+
/** When set, file paths are made relative to it for stable, machine-independent ids. */
|
|
168
|
+
projectRoot?: string;
|
|
169
|
+
/** Optional id grouping all runs from this report (a CI run / batch). */
|
|
170
|
+
runGroup?: string;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Parse a vitest json report into recorded runs — one per pass/fail assertion. Skipped /
|
|
174
|
+
* pending / todo assertions are dropped (no pass/fail signal). Pure: no spawning, no I/O.
|
|
175
|
+
*/
|
|
176
|
+
declare function parseVitestJson(report: VitestJsonReport, opts: ParseReportOptions): RecordedRun[];
|
|
177
|
+
//#endregion
|
|
178
|
+
//#region src/pytest.d.ts
|
|
179
|
+
/** One phase (setup/call/teardown) of a pytest test item; `duration` is in seconds. */
|
|
180
|
+
interface PytestPhase {
|
|
181
|
+
duration?: number | null;
|
|
182
|
+
outcome?: string;
|
|
183
|
+
}
|
|
184
|
+
/** The subset of a pytest-json-report test item we read. */
|
|
185
|
+
interface PytestTest {
|
|
186
|
+
nodeid?: string;
|
|
187
|
+
outcome?: string;
|
|
188
|
+
setup?: PytestPhase;
|
|
189
|
+
call?: PytestPhase;
|
|
190
|
+
teardown?: PytestPhase;
|
|
191
|
+
}
|
|
192
|
+
interface PytestJsonReport {
|
|
193
|
+
tests?: PytestTest[];
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Parse a pytest-json-report report into recorded runs — one per pass/fail/error test.
|
|
197
|
+
* Skipped / xfailed / xpassed tests are dropped (no pass/fail signal). Pure: no spawning, no I/O.
|
|
198
|
+
*/
|
|
199
|
+
declare function parsePytestJson(report: PytestJsonReport, opts: ParseReportOptions): RecordedRun[];
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/quarantine.d.ts
|
|
202
|
+
/** Thrown when the paired operator gate denies a quarantine write. */
|
|
203
|
+
declare class QuarantineGateError extends Error {
|
|
204
|
+
constructor(message: string);
|
|
205
|
+
}
|
|
206
|
+
/** Operator-set quarantine policy (the paired gate). */
|
|
207
|
+
interface QuarantinePolicy {
|
|
208
|
+
/** OPERATOR opt-in to allow quarantine writes. Deny-by-default. */
|
|
209
|
+
allowQuarantine: boolean;
|
|
210
|
+
/**
|
|
211
|
+
* OPERATOR cap on quarantine duration (ms from `quarantinedAt`). Load-bearing: a
|
|
212
|
+
* zero/non-positive cap denies every write even with `allowQuarantine`, and a request
|
|
213
|
+
* whose expiry exceeds it is refused (never silently clamped).
|
|
214
|
+
*/
|
|
215
|
+
maxExpiryMs: number;
|
|
216
|
+
}
|
|
217
|
+
interface QuarantineRequest {
|
|
218
|
+
testId: string;
|
|
219
|
+
/** Why it is quarantined — mandatory, non-empty (audit trail). */
|
|
220
|
+
reason: string;
|
|
221
|
+
/** ISO expiry; mandatory, must be in the future and within `maxExpiryMs` of `now`. */
|
|
222
|
+
expiresAt: string;
|
|
223
|
+
/** The flakeScore that justified it (for audit/ranking). */
|
|
224
|
+
flakeScore?: number;
|
|
225
|
+
/** Reference time; defaults to now. */
|
|
226
|
+
now?: string;
|
|
227
|
+
}
|
|
228
|
+
interface QuarantineEntry {
|
|
229
|
+
testId: string;
|
|
230
|
+
reason: string;
|
|
231
|
+
flakeScore: number | null;
|
|
232
|
+
quarantinedAt: string;
|
|
233
|
+
expiresAt: string;
|
|
234
|
+
}
|
|
235
|
+
declare class Quarantine {
|
|
236
|
+
private readonly db;
|
|
237
|
+
private readonly policy;
|
|
238
|
+
constructor(store: HistoryStore | Database.Database, policy: QuarantinePolicy);
|
|
239
|
+
/** Quarantine a test for a bounded window. Throws {@link QuarantineGateError} on denial. */
|
|
240
|
+
quarantine(req: QuarantineRequest): QuarantineEntry;
|
|
241
|
+
/** Lift a quarantine. Ungated. Returns true if a row was removed. */
|
|
242
|
+
release(testId: string): boolean;
|
|
243
|
+
/** Whether a test is currently quarantined (expiry-aware). */
|
|
244
|
+
isQuarantined(testId: string, now?: string): boolean;
|
|
245
|
+
/** Currently-active (unexpired) quarantines, ordered by expiry. */
|
|
246
|
+
active(now?: string): QuarantineEntry[];
|
|
247
|
+
/** Every quarantine row, including expired ones (audit). */
|
|
248
|
+
all(): QuarantineEntry[];
|
|
249
|
+
}
|
|
250
|
+
interface CandidateOptions {
|
|
251
|
+
/** Only verdicts with `flakeScore >= minFlakeScore` (default 0 — every flaky test). */
|
|
252
|
+
minFlakeScore?: number;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Pure helper: rank quarantine candidates from classifier verdicts — `flaky` tests whose
|
|
256
|
+
* `flakeScore` clears the floor, highest first. Never selects `broken` (a real, consistent
|
|
257
|
+
* failure to FIX, not hide) or `reliable`/`insufficient-data`. The write itself is still
|
|
258
|
+
* gated; this only proposes.
|
|
259
|
+
*/
|
|
260
|
+
declare function quarantineCandidates(verdicts: FlakeVerdict[], opts?: CandidateOptions): FlakeVerdict[];
|
|
261
|
+
//#endregion
|
|
262
|
+
//#region src/runner.d.ts
|
|
263
|
+
/** Thrown when the paired operator gate denies a run. */
|
|
264
|
+
declare class FlakeGateError extends Error {
|
|
265
|
+
constructor(message: string);
|
|
266
|
+
}
|
|
267
|
+
interface RunHistoryConfig {
|
|
268
|
+
/** The project to run tests in. */
|
|
269
|
+
projectRoot: string;
|
|
270
|
+
/** OPERATOR allowlist of roots the runner may execute in. Load-bearing even with allowRun. */
|
|
271
|
+
allowedRoots: string[];
|
|
272
|
+
/** OPERATOR opt-in to actually run tests. Deny-by-default. */
|
|
273
|
+
allowRun: boolean;
|
|
274
|
+
/** Wall-clock cap (ms) per iteration, passed to the runner. */
|
|
275
|
+
timeoutMs?: number;
|
|
276
|
+
}
|
|
277
|
+
interface RunAndRecordInput {
|
|
278
|
+
/** How many times to run the suite — flakiness needs repeats. Default 1. */
|
|
279
|
+
repeat?: number;
|
|
280
|
+
/** Positional vitest file filters; default runs the whole suite. */
|
|
281
|
+
files?: string[];
|
|
282
|
+
/** Batch id; each iteration is recorded under `${runGroup}#<i>`. */
|
|
283
|
+
runGroup?: string;
|
|
284
|
+
}
|
|
285
|
+
/** Injected command runner — executes `vitest <argv>` and yields its exit status. */
|
|
286
|
+
type TestRunner = (argv: string[], opts: {
|
|
287
|
+
cwd: string;
|
|
288
|
+
timeoutMs?: number;
|
|
289
|
+
}) => Promise<{
|
|
290
|
+
exitCode: number;
|
|
291
|
+
stdout: string;
|
|
292
|
+
stderr: string;
|
|
293
|
+
}>;
|
|
294
|
+
interface RunAndRecordResult {
|
|
295
|
+
/** False only when repeat <= 0 (the runner was never invoked). */
|
|
296
|
+
ran: boolean;
|
|
297
|
+
iterations: number;
|
|
298
|
+
/** Total runs recorded across all iterations. */
|
|
299
|
+
recorded: number;
|
|
300
|
+
results: {
|
|
301
|
+
exitCode: number;
|
|
302
|
+
passed: boolean;
|
|
303
|
+
}[];
|
|
304
|
+
/** Classifier verdicts over the store AFTER recording this batch. */
|
|
305
|
+
verdicts: FlakeVerdict[];
|
|
306
|
+
}
|
|
307
|
+
/** Default live runner: spawn the local `vitest` as a subprocess (used by the bin, not the gate). */
|
|
308
|
+
declare const defaultVitestRunner: TestRunner;
|
|
309
|
+
/** Default live runner: spawn the local `pytest` as a subprocess (used by the bin, not the gate). */
|
|
310
|
+
declare const defaultPytestRunner: TestRunner;
|
|
311
|
+
/**
|
|
312
|
+
* Run the vitest suite `repeat` times behind the operator gate, recording every outcome into the
|
|
313
|
+
* store, then classify. The actual `vitest` invocation is the injected `runner` (default
|
|
314
|
+
* {@link defaultVitestRunner}).
|
|
315
|
+
*/
|
|
316
|
+
declare function runAndRecord(store: HistoryStore, config: RunHistoryConfig, input: RunAndRecordInput, deps?: {
|
|
317
|
+
runner?: TestRunner;
|
|
318
|
+
reportDir?: string;
|
|
319
|
+
}): Promise<RunAndRecordResult>;
|
|
320
|
+
/**
|
|
321
|
+
* The pytest sibling of {@link runAndRecord} (ADR 0010 addendum): spawn `pytest --json-report`
|
|
322
|
+
* `repeat` times, ingest via the existing `parsePytestJson` (unchanged), classify. Repeats re-run
|
|
323
|
+
* the WHOLE suite — never `pytest-repeat`, whose `[i-N]` nodeid suffix would fragment the
|
|
324
|
+
* one-history-per-nodeid invariant the classifier relies on.
|
|
325
|
+
*/
|
|
326
|
+
declare function runAndRecordPytest(store: HistoryStore, config: RunHistoryConfig, input: RunAndRecordInput, deps?: {
|
|
327
|
+
runner?: TestRunner;
|
|
328
|
+
reportDir?: string;
|
|
329
|
+
}): Promise<RunAndRecordResult>;
|
|
330
|
+
//#endregion
|
|
331
|
+
export { type CandidateOptions, type ClassifyOptions, FlakeGateError, type FlakeState, type FlakeVerdict, type HistoryQueryOptions, HistoryStore, type ParseReportOptions, type PytestJsonReport, type PytestPhase, type PytestTest, Quarantine, type QuarantineEntry, QuarantineGateError, type QuarantinePolicy, type QuarantineRequest, type RecordedRun, type RunAndRecordInput, type RunAndRecordResult, type RunHistoryConfig, type TestHistory, type TestRun, type TestRunner, type VitestAssertion, type VitestFileResult, type VitestJsonReport, type WilsonInterval, classifyHistories, classifyHistory, defaultPytestRunner, defaultVitestRunner, parsePytestJson, parseVitestJson, quarantineCandidates, runAndRecord, runAndRecordPytest, wilsonInterval };
|
|
332
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/classify.ts","../src/store.ts","../src/report.ts","../src/pytest.ts","../src/quarantine.ts","../src/runner.ts"],"mappings":"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"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, readFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import Database from "better-sqlite3";
|
|
6
|
+
//#region src/classify.ts
|
|
7
|
+
const DEFAULT_Z = 1.96;
|
|
8
|
+
const DEFAULT_MIN_RUNS = 5;
|
|
9
|
+
/**
|
|
10
|
+
* The Wilson score interval for `failures` successes in `runs` Bernoulli trials at
|
|
11
|
+
* confidence `z`. Bounds are clamped to [0, 1]. Zero runs yields a degenerate zero
|
|
12
|
+
* interval (the rate is undefined; the caller marks it insufficient-data).
|
|
13
|
+
*/
|
|
14
|
+
function wilsonInterval(failures, runs, z = DEFAULT_Z) {
|
|
15
|
+
if (runs <= 0) return {
|
|
16
|
+
lower: 0,
|
|
17
|
+
center: 0,
|
|
18
|
+
upper: 0
|
|
19
|
+
};
|
|
20
|
+
const n = runs;
|
|
21
|
+
const p = failures / n;
|
|
22
|
+
const z2 = z * z;
|
|
23
|
+
const denom = 1 + z2 / n;
|
|
24
|
+
const center = (p + z2 / (2 * n)) / denom;
|
|
25
|
+
const margin = z / denom * Math.sqrt(p * (1 - p) / n + z2 / (4 * n * n));
|
|
26
|
+
return {
|
|
27
|
+
lower: Math.max(0, center - margin),
|
|
28
|
+
center,
|
|
29
|
+
upper: Math.min(1, center + margin)
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Classify a single test's run history into a {@link FlakeVerdict}. */
|
|
33
|
+
function classifyHistory(history, opts = {}) {
|
|
34
|
+
const z = opts.z ?? DEFAULT_Z;
|
|
35
|
+
const minRuns = opts.minRuns ?? DEFAULT_MIN_RUNS;
|
|
36
|
+
const runs = history.runs.length;
|
|
37
|
+
const passes = history.runs.reduce((n, r) => n + (r.passed ? 1 : 0), 0);
|
|
38
|
+
const failures = runs - passes;
|
|
39
|
+
const failureRate = runs > 0 ? failures / runs : 0;
|
|
40
|
+
const wilson = wilsonInterval(failures, runs, z);
|
|
41
|
+
let state;
|
|
42
|
+
if (runs === 0) state = "insufficient-data";
|
|
43
|
+
else if (passes > 0 && failures > 0) state = "flaky";
|
|
44
|
+
else if (runs < minRuns) state = "insufficient-data";
|
|
45
|
+
else if (failures === 0) state = "reliable";
|
|
46
|
+
else state = "broken";
|
|
47
|
+
return {
|
|
48
|
+
id: history.id,
|
|
49
|
+
state,
|
|
50
|
+
runs,
|
|
51
|
+
passes,
|
|
52
|
+
failures,
|
|
53
|
+
failureRate,
|
|
54
|
+
wilson,
|
|
55
|
+
flakeScore: wilson.lower
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Classify many histories, preserving input order. Callers rank quarantine candidates by
|
|
60
|
+
* sorting on `flakeScore` (or filtering `state === 'flaky'`).
|
|
61
|
+
*/
|
|
62
|
+
function classifyHistories(histories, opts = {}) {
|
|
63
|
+
return histories.map((h) => classifyHistory(h, opts));
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/pytest.ts
|
|
67
|
+
/**
|
|
68
|
+
* pytest-json-report ingestion — turns a `pytest --json-report` report into the
|
|
69
|
+
* {@link RecordedRun}s the history store records. The Python sibling of {@link parseVitestJson}.
|
|
70
|
+
*
|
|
71
|
+
* The store / classifier / quarantine are all **test-id-opaque** (they operate on the
|
|
72
|
+
* `testId` string + pass/fail only), so the Python adapter is purely this shape converter —
|
|
73
|
+
* no change to the engine. Like the vitest parser this module is pure: no spawning, no I/O.
|
|
74
|
+
*
|
|
75
|
+
* Two things differ from the vitest report:
|
|
76
|
+
* - **Stable id:** pytest's `nodeid` (`tests/test_x.py::TestC::test_y`) is already
|
|
77
|
+
* file-qualified, rootdir-relative, and stable, so we use it **verbatim** — none of the
|
|
78
|
+
* `ancestorTitles + title` reconstruction the lossy vitest `fullName` forces.
|
|
79
|
+
* - **Durations are seconds, split across phases.** pytest-json-report records a per-phase
|
|
80
|
+
* `{setup, call, teardown}` duration in *seconds*; we sum the present phases and convert
|
|
81
|
+
* to milliseconds to match {@link RecordedRun.durationMs} (and istanbul/vitest's ms unit).
|
|
82
|
+
*
|
|
83
|
+
* Outcome mapping (mirrors the vitest "pass/fail-signal vs no-signal" split):
|
|
84
|
+
* - `passed` → recorded as a pass.
|
|
85
|
+
* - `failed` → recorded as a failure.
|
|
86
|
+
* - `error` → recorded as a failure: an errored test (a flaky fixture / setup / teardown)
|
|
87
|
+
* did not pass, and that nondeterminism is exactly what the flake pillar hunts.
|
|
88
|
+
* - `skipped` / `xfailed` / `xpassed` → dropped: no clean pass/fail flake signal (an
|
|
89
|
+
* `xfailed` test behaved as declared; a strict `xpassed` surfaces as `failed`).
|
|
90
|
+
*/
|
|
91
|
+
/** A status that carries a pass/fail signal; everything else returns undefined (dropped). */
|
|
92
|
+
function outcome$1(status) {
|
|
93
|
+
if (status === "passed") return true;
|
|
94
|
+
if (status === "failed" || status === "error") return false;
|
|
95
|
+
}
|
|
96
|
+
/** Sum the present phase durations (seconds) → milliseconds, or undefined when none exist. */
|
|
97
|
+
function durationMs(t) {
|
|
98
|
+
let seconds = 0;
|
|
99
|
+
let seen = false;
|
|
100
|
+
for (const phase of [
|
|
101
|
+
t.setup,
|
|
102
|
+
t.call,
|
|
103
|
+
t.teardown
|
|
104
|
+
]) if (typeof phase?.duration === "number") {
|
|
105
|
+
seconds += phase.duration;
|
|
106
|
+
seen = true;
|
|
107
|
+
}
|
|
108
|
+
return seen ? Math.round(seconds * 1e6) / 1e3 : void 0;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Make a nodeid machine-stable. The nodeid is `<file>::<test path>`; pytest already emits
|
|
112
|
+
* `<file>` rootdir-relative, so normally we pass it through. Only when a `projectRoot` is given
|
|
113
|
+
* AND the file part is absolute do we relativize *just that part*, preserving the `::` structure
|
|
114
|
+
* (a blind `relative()` over the whole string would mangle the `::`-delimited test path).
|
|
115
|
+
*/
|
|
116
|
+
function stableId(nodeid, projectRoot) {
|
|
117
|
+
if (projectRoot === void 0) return nodeid;
|
|
118
|
+
const sep = nodeid.indexOf("::");
|
|
119
|
+
const file = sep === -1 ? nodeid : nodeid.slice(0, sep);
|
|
120
|
+
if (!isAbsolute(file)) return nodeid;
|
|
121
|
+
const rel = relative(projectRoot, file);
|
|
122
|
+
return sep === -1 ? rel : rel + nodeid.slice(sep);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Parse a pytest-json-report report into recorded runs — one per pass/fail/error test.
|
|
126
|
+
* Skipped / xfailed / xpassed tests are dropped (no pass/fail signal). Pure: no spawning, no I/O.
|
|
127
|
+
*/
|
|
128
|
+
function parsePytestJson(report, opts) {
|
|
129
|
+
const runs = [];
|
|
130
|
+
for (const t of report.tests ?? []) {
|
|
131
|
+
const passed = outcome$1(t.outcome);
|
|
132
|
+
if (passed === void 0) continue;
|
|
133
|
+
const run = {
|
|
134
|
+
testId: stableId(t.nodeid ?? "<unknown>", opts.projectRoot),
|
|
135
|
+
passed,
|
|
136
|
+
at: opts.at
|
|
137
|
+
};
|
|
138
|
+
const ms = durationMs(t);
|
|
139
|
+
if (ms !== void 0) run.durationMs = ms;
|
|
140
|
+
if (opts.runGroup !== void 0) run.runGroup = opts.runGroup;
|
|
141
|
+
runs.push(run);
|
|
142
|
+
}
|
|
143
|
+
return runs;
|
|
144
|
+
}
|
|
145
|
+
//#endregion
|
|
146
|
+
//#region src/quarantine.ts
|
|
147
|
+
/** Thrown when the paired operator gate denies a quarantine write. */
|
|
148
|
+
var QuarantineGateError = class extends Error {
|
|
149
|
+
constructor(message) {
|
|
150
|
+
super(message);
|
|
151
|
+
this.name = "QuarantineGateError";
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
function migrate$1(db) {
|
|
155
|
+
db.exec(`
|
|
156
|
+
CREATE TABLE IF NOT EXISTS quarantine (
|
|
157
|
+
test_id TEXT PRIMARY KEY,
|
|
158
|
+
reason TEXT NOT NULL,
|
|
159
|
+
flake_score REAL,
|
|
160
|
+
quarantined_at TEXT NOT NULL,
|
|
161
|
+
expires_at TEXT NOT NULL
|
|
162
|
+
);
|
|
163
|
+
`);
|
|
164
|
+
}
|
|
165
|
+
function toEntry(r) {
|
|
166
|
+
return {
|
|
167
|
+
testId: r.test_id,
|
|
168
|
+
reason: r.reason,
|
|
169
|
+
flakeScore: r.flake_score,
|
|
170
|
+
quarantinedAt: r.quarantined_at,
|
|
171
|
+
expiresAt: r.expires_at
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
var Quarantine = class {
|
|
175
|
+
db;
|
|
176
|
+
policy;
|
|
177
|
+
constructor(store, policy) {
|
|
178
|
+
this.db = "database" in store ? store.database : store;
|
|
179
|
+
this.policy = policy;
|
|
180
|
+
migrate$1(this.db);
|
|
181
|
+
}
|
|
182
|
+
/** Quarantine a test for a bounded window. Throws {@link QuarantineGateError} on denial. */
|
|
183
|
+
quarantine(req) {
|
|
184
|
+
if (!this.policy.allowQuarantine) throw new QuarantineGateError("quarantine writes are not enabled (the operator must set allowQuarantine)");
|
|
185
|
+
if (!(this.policy.maxExpiryMs > 0)) throw new QuarantineGateError("no quarantine expiry bound is configured (operator maxExpiryMs must be > 0)");
|
|
186
|
+
const reason = req.reason.trim();
|
|
187
|
+
if (reason === "") throw new QuarantineGateError("a non-empty quarantine reason is required");
|
|
188
|
+
const now = req.now ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
189
|
+
const nowMs = Date.parse(now);
|
|
190
|
+
const expiryMs = Date.parse(req.expiresAt);
|
|
191
|
+
if (Number.isNaN(expiryMs)) throw new QuarantineGateError(`unparseable expiresAt: ${req.expiresAt}`);
|
|
192
|
+
if (expiryMs <= nowMs) throw new QuarantineGateError("expiresAt must be in the future");
|
|
193
|
+
if (expiryMs - nowMs > this.policy.maxExpiryMs) throw new QuarantineGateError(`expiry exceeds the operator cap of ${this.policy.maxExpiryMs}ms`);
|
|
194
|
+
this.db.prepare(`INSERT INTO quarantine (test_id, reason, flake_score, quarantined_at, expires_at)
|
|
195
|
+
VALUES (?, ?, ?, ?, ?)
|
|
196
|
+
ON CONFLICT(test_id) DO UPDATE SET
|
|
197
|
+
reason = excluded.reason,
|
|
198
|
+
flake_score = excluded.flake_score,
|
|
199
|
+
quarantined_at = excluded.quarantined_at,
|
|
200
|
+
expires_at = excluded.expires_at`).run(req.testId, reason, req.flakeScore ?? null, now, req.expiresAt);
|
|
201
|
+
return {
|
|
202
|
+
testId: req.testId,
|
|
203
|
+
reason,
|
|
204
|
+
flakeScore: req.flakeScore ?? null,
|
|
205
|
+
quarantinedAt: now,
|
|
206
|
+
expiresAt: req.expiresAt
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/** Lift a quarantine. Ungated. Returns true if a row was removed. */
|
|
210
|
+
release(testId) {
|
|
211
|
+
return this.db.prepare("DELETE FROM quarantine WHERE test_id = ?").run(testId).changes > 0;
|
|
212
|
+
}
|
|
213
|
+
/** Whether a test is currently quarantined (expiry-aware). */
|
|
214
|
+
isQuarantined(testId, now = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
215
|
+
const row = this.db.prepare("SELECT expires_at FROM quarantine WHERE test_id = ?").get(testId);
|
|
216
|
+
return row !== void 0 && Date.parse(row.expires_at) > Date.parse(now);
|
|
217
|
+
}
|
|
218
|
+
/** Currently-active (unexpired) quarantines, ordered by expiry. */
|
|
219
|
+
active(now = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
220
|
+
return this.db.prepare("SELECT * FROM quarantine WHERE expires_at > ? ORDER BY expires_at, test_id").all(now).map(toEntry);
|
|
221
|
+
}
|
|
222
|
+
/** Every quarantine row, including expired ones (audit). */
|
|
223
|
+
all() {
|
|
224
|
+
return this.db.prepare("SELECT * FROM quarantine ORDER BY test_id").all().map(toEntry);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
/**
|
|
228
|
+
* Pure helper: rank quarantine candidates from classifier verdicts — `flaky` tests whose
|
|
229
|
+
* `flakeScore` clears the floor, highest first. Never selects `broken` (a real, consistent
|
|
230
|
+
* failure to FIX, not hide) or `reliable`/`insufficient-data`. The write itself is still
|
|
231
|
+
* gated; this only proposes.
|
|
232
|
+
*/
|
|
233
|
+
function quarantineCandidates(verdicts, opts = {}) {
|
|
234
|
+
const floor = opts.minFlakeScore ?? 0;
|
|
235
|
+
return verdicts.filter((v) => v.state === "flaky" && v.flakeScore >= floor).sort((a, b) => b.flakeScore - a.flakeScore);
|
|
236
|
+
}
|
|
237
|
+
//#endregion
|
|
238
|
+
//#region src/report.ts
|
|
239
|
+
/**
|
|
240
|
+
* Vitest JSON-report ingestion — turns a `vitest run --reporter=json` report into the
|
|
241
|
+
* {@link RecordedRun}s the history store records.
|
|
242
|
+
*
|
|
243
|
+
* Per ADR 0010 the flake pillar **spawns** `vitest run --reporter=json` and parses its
|
|
244
|
+
* output (a different execution model from coverage's in-process/child-process run and
|
|
245
|
+
* mutation's Stryker delegation — there is no shared runner seam). This module is the
|
|
246
|
+
* pure parser half: no spawning, no I/O — it just maps the report's shape to runs, so it
|
|
247
|
+
* is unit-tested against a committed real-shaped fixture. The gated spawn that produces
|
|
248
|
+
* the report lives in a later slice.
|
|
249
|
+
*
|
|
250
|
+
* The report's `fullName` is the ancestor titles + title joined by a single space, which
|
|
251
|
+
* is lossy (a describe/test boundary is indistinguishable from a space inside a title).
|
|
252
|
+
* We therefore build a stable, file-qualified id from `ancestorTitles + title` joined by
|
|
253
|
+
* ` > ` ourselves, falling back to `fullName`, then `title`, when those are absent.
|
|
254
|
+
*/
|
|
255
|
+
/** A status that carries a pass/fail signal. Skipped/pending/todo do not. */
|
|
256
|
+
function outcome(status) {
|
|
257
|
+
if (status === "passed") return true;
|
|
258
|
+
if (status === "failed") return false;
|
|
259
|
+
}
|
|
260
|
+
function titlePart(a) {
|
|
261
|
+
if (a.ancestorTitles?.length) return [...a.ancestorTitles, a.title ?? ""].join(" > ");
|
|
262
|
+
return a.fullName ?? a.title ?? "<unknown>";
|
|
263
|
+
}
|
|
264
|
+
function fileLabel(name, projectRoot) {
|
|
265
|
+
if (!name) return "";
|
|
266
|
+
return projectRoot ? relative(projectRoot, name) : name;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Parse a vitest json report into recorded runs — one per pass/fail assertion. Skipped /
|
|
270
|
+
* pending / todo assertions are dropped (no pass/fail signal). Pure: no spawning, no I/O.
|
|
271
|
+
*/
|
|
272
|
+
function parseVitestJson(report, opts) {
|
|
273
|
+
const runs = [];
|
|
274
|
+
for (const file of report.testResults ?? []) {
|
|
275
|
+
const label = fileLabel(file.name, opts.projectRoot);
|
|
276
|
+
for (const a of file.assertionResults ?? []) {
|
|
277
|
+
const passed = outcome(a.status);
|
|
278
|
+
if (passed === void 0) continue;
|
|
279
|
+
const run = {
|
|
280
|
+
testId: label ? `${label} > ${titlePart(a)}` : titlePart(a),
|
|
281
|
+
passed,
|
|
282
|
+
at: opts.at
|
|
283
|
+
};
|
|
284
|
+
if (typeof a.duration === "number") run.durationMs = a.duration;
|
|
285
|
+
if (opts.runGroup !== void 0) run.runGroup = opts.runGroup;
|
|
286
|
+
runs.push(run);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return runs;
|
|
290
|
+
}
|
|
291
|
+
//#endregion
|
|
292
|
+
//#region src/runner.ts
|
|
293
|
+
/**
|
|
294
|
+
* The gated vitest runner — the live half of the flake pillar. It **spawns**
|
|
295
|
+
* `vitest run --reporter=json` (per ADR 0010: flake's execution model is spawn-and-parse,
|
|
296
|
+
* distinct from coverage's child-process coverage run and mutation's Stryker delegation),
|
|
297
|
+
* reads the JSON report, and records every outcome into the {@link HistoryStore}. Run the
|
|
298
|
+
* suite `repeat` times to actually surface flakiness, then classify.
|
|
299
|
+
*
|
|
300
|
+
* Two ADR-0010 constraints, mirroring `@sackville-mcp/coverage`'s `runScoped`:
|
|
301
|
+
* 1. **It runs code**, so it is behind a *paired* deny-by-default operator gate — an
|
|
302
|
+
* `allowRun` boolean AND an `allowedRoots` allowlist (load-bearing on its own), with a
|
|
303
|
+
* wall-clock cap. All operator-set; no caller input self-authorizes.
|
|
304
|
+
* 2. **Child-process boundary.** The real `vitest` invocation is an injected
|
|
305
|
+
* {@link TestRunner} (the bin wires a subprocess); the engine owns the gate, argv,
|
|
306
|
+
* report-file plumbing, ingestion, and classification, and is unit-tested with a fake
|
|
307
|
+
* runner — no real spawn in the green gate.
|
|
308
|
+
*/
|
|
309
|
+
/** Thrown when the paired operator gate denies a run. */
|
|
310
|
+
var FlakeGateError = class extends Error {
|
|
311
|
+
constructor(message) {
|
|
312
|
+
super(message);
|
|
313
|
+
this.name = "FlakeGateError";
|
|
314
|
+
this[Symbol.for("sackville.gate-denial")] = true;
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
/** Build the argv for one vitest suite run with the JSON reporter writing to `outFile`. */
|
|
318
|
+
function vitestArgv(files, outFile) {
|
|
319
|
+
return [
|
|
320
|
+
"run",
|
|
321
|
+
...files,
|
|
322
|
+
"--reporter=json",
|
|
323
|
+
`--outputFile=${outFile}`
|
|
324
|
+
];
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Build the argv for one pytest run with the `pytest-json-report` plugin writing to `outFile`
|
|
328
|
+
* (ADR 0010 addendum: json-report now, `pytest-reportlog` staged). No `run` subcommand — pytest
|
|
329
|
+
* takes positional file filters directly. The plugin is an operator-installed dev dependency.
|
|
330
|
+
*/
|
|
331
|
+
function pytestArgv(files, outFile) {
|
|
332
|
+
return [
|
|
333
|
+
"--json-report",
|
|
334
|
+
`--json-report-file=${outFile}`,
|
|
335
|
+
...files
|
|
336
|
+
];
|
|
337
|
+
}
|
|
338
|
+
/** Spawn a local command as a subprocess, surfacing its exit code (never rejecting on non-zero). */
|
|
339
|
+
function spawnRunner(command) {
|
|
340
|
+
return (argv, opts) => new Promise((res) => {
|
|
341
|
+
execFile(command, argv, {
|
|
342
|
+
cwd: opts.cwd,
|
|
343
|
+
timeout: opts.timeoutMs,
|
|
344
|
+
maxBuffer: 64 * 1024 * 1024
|
|
345
|
+
}, (err, stdout, stderr) => {
|
|
346
|
+
res({
|
|
347
|
+
exitCode: err && typeof err.code === "number" ? err.code : err ? 1 : 0,
|
|
348
|
+
stdout: String(stdout),
|
|
349
|
+
stderr: String(stderr)
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
/** Default live runner: spawn the local `vitest` as a subprocess (used by the bin, not the gate). */
|
|
355
|
+
const defaultVitestRunner = spawnRunner("vitest");
|
|
356
|
+
/** Default live runner: spawn the local `pytest` as a subprocess (used by the bin, not the gate). */
|
|
357
|
+
const defaultPytestRunner = spawnRunner("pytest");
|
|
358
|
+
const VITEST = {
|
|
359
|
+
defaultRunner: defaultVitestRunner,
|
|
360
|
+
buildArgv: vitestArgv,
|
|
361
|
+
ingest: (store, parsed, opts) => store.ingestReport(parsed, opts)
|
|
362
|
+
};
|
|
363
|
+
const PYTEST = {
|
|
364
|
+
defaultRunner: defaultPytestRunner,
|
|
365
|
+
buildArgv: pytestArgv,
|
|
366
|
+
ingest: (store, parsed, opts) => store.ingestPytestReport(parsed, opts)
|
|
367
|
+
};
|
|
368
|
+
function assertAllowed(config) {
|
|
369
|
+
if (!config.allowRun) throw new FlakeGateError("test execution is not enabled (the operator must set allowRun)");
|
|
370
|
+
const root = resolve(config.projectRoot);
|
|
371
|
+
if (!config.allowedRoots.map((r) => resolve(r)).includes(root)) throw new FlakeGateError(`project root ${config.projectRoot} is not in the operator allowlist`);
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Run a test suite `repeat` times behind the operator gate via the given {@link FrameworkAdapter},
|
|
375
|
+
* recording every outcome into the store, then classify. The actual invocation is the injected
|
|
376
|
+
* `runner` (default = the adapter's subprocess runner); no real spawn in the green gate.
|
|
377
|
+
*/
|
|
378
|
+
async function runAndRecordWith(fw, store, config, input, deps) {
|
|
379
|
+
assertAllowed(config);
|
|
380
|
+
const repeat = input.repeat ?? 1;
|
|
381
|
+
if (repeat <= 0) return {
|
|
382
|
+
ran: false,
|
|
383
|
+
iterations: 0,
|
|
384
|
+
recorded: 0,
|
|
385
|
+
results: [],
|
|
386
|
+
verdicts: store.classify()
|
|
387
|
+
};
|
|
388
|
+
const runner = deps.runner ?? fw.defaultRunner;
|
|
389
|
+
const reportDir = deps.reportDir ?? mkdtempSync(join(tmpdir(), "sackville-flake-"));
|
|
390
|
+
const files = input.files ?? [];
|
|
391
|
+
const results = [];
|
|
392
|
+
let recorded = 0;
|
|
393
|
+
for (let i = 0; i < repeat; i++) {
|
|
394
|
+
const outFile = join(reportDir, `report-${i}.json`);
|
|
395
|
+
const { exitCode } = await runner(fw.buildArgv(files, outFile), {
|
|
396
|
+
cwd: config.projectRoot,
|
|
397
|
+
timeoutMs: config.timeoutMs
|
|
398
|
+
});
|
|
399
|
+
let parsed;
|
|
400
|
+
try {
|
|
401
|
+
parsed = JSON.parse(readFileSync(outFile, "utf8"));
|
|
402
|
+
} catch {
|
|
403
|
+
throw new Error(`flake run did not produce a JSON report at ${outFile} (exit code ${exitCode})`);
|
|
404
|
+
}
|
|
405
|
+
recorded += fw.ingest(store, parsed, {
|
|
406
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
407
|
+
projectRoot: config.projectRoot,
|
|
408
|
+
runGroup: input.runGroup !== void 0 ? `${input.runGroup}#${i}` : void 0
|
|
409
|
+
});
|
|
410
|
+
results.push({
|
|
411
|
+
exitCode,
|
|
412
|
+
passed: exitCode === 0
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
ran: true,
|
|
417
|
+
iterations: repeat,
|
|
418
|
+
recorded,
|
|
419
|
+
results,
|
|
420
|
+
verdicts: store.classify()
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Run the vitest suite `repeat` times behind the operator gate, recording every outcome into the
|
|
425
|
+
* store, then classify. The actual `vitest` invocation is the injected `runner` (default
|
|
426
|
+
* {@link defaultVitestRunner}).
|
|
427
|
+
*/
|
|
428
|
+
async function runAndRecord(store, config, input, deps = {}) {
|
|
429
|
+
return runAndRecordWith(VITEST, store, config, input, deps);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* The pytest sibling of {@link runAndRecord} (ADR 0010 addendum): spawn `pytest --json-report`
|
|
433
|
+
* `repeat` times, ingest via the existing `parsePytestJson` (unchanged), classify. Repeats re-run
|
|
434
|
+
* the WHOLE suite — never `pytest-repeat`, whose `[i-N]` nodeid suffix would fragment the
|
|
435
|
+
* one-history-per-nodeid invariant the classifier relies on.
|
|
436
|
+
*/
|
|
437
|
+
async function runAndRecordPytest(store, config, input, deps = {}) {
|
|
438
|
+
return runAndRecordWith(PYTEST, store, config, input, deps);
|
|
439
|
+
}
|
|
440
|
+
//#endregion
|
|
441
|
+
//#region src/store.ts
|
|
442
|
+
/**
|
|
443
|
+
* The private run-history store — `@sackville-mcp/flake`'s own SQLite database.
|
|
444
|
+
*
|
|
445
|
+
* Per ADR 0010 this is a **second SQLite owner**, deliberately OUTSIDE the docs-pillar
|
|
446
|
+
* "only `@sackville-mcp/core` touches SQLite" invariant: it is a new, private store for test
|
|
447
|
+
* run outcomes, not a crossing of the Python↔TS polyglot contract (which remains the
|
|
448
|
+
* `schema/sackville.schema.sql` index that `core` reads). It records each test's pass/fail
|
|
449
|
+
* history over time and reads it back as the `TestHistory[]` the pure classifier consumes.
|
|
450
|
+
*
|
|
451
|
+
* The schema is intentionally tiny: one append-only `test_run` row per recorded outcome,
|
|
452
|
+
* plus a `flake_meta` version marker. Quarantine state is a separate table added by the
|
|
453
|
+
* quarantine slice.
|
|
454
|
+
*/
|
|
455
|
+
const SCHEMA_VERSION = 1;
|
|
456
|
+
function migrate(db) {
|
|
457
|
+
db.pragma("journal_mode = WAL");
|
|
458
|
+
db.exec(`
|
|
459
|
+
CREATE TABLE IF NOT EXISTS flake_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
460
|
+
CREATE TABLE IF NOT EXISTS test_run (
|
|
461
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
462
|
+
test_id TEXT NOT NULL,
|
|
463
|
+
passed INTEGER NOT NULL,
|
|
464
|
+
at TEXT NOT NULL,
|
|
465
|
+
duration_ms REAL,
|
|
466
|
+
run_group TEXT
|
|
467
|
+
);
|
|
468
|
+
CREATE INDEX IF NOT EXISTS idx_test_run_test_id_at ON test_run(test_id, at, id);
|
|
469
|
+
`);
|
|
470
|
+
if (!db.prepare("SELECT value FROM flake_meta WHERE key = ?").get("schema_version")) db.prepare("INSERT INTO flake_meta (key, value) VALUES (?, ?)").run("schema_version", String(SCHEMA_VERSION));
|
|
471
|
+
}
|
|
472
|
+
var HistoryStore = class HistoryStore {
|
|
473
|
+
db;
|
|
474
|
+
insert;
|
|
475
|
+
constructor(db) {
|
|
476
|
+
this.db = db;
|
|
477
|
+
migrate(db);
|
|
478
|
+
this.insert = db.prepare("INSERT INTO test_run (test_id, passed, at, duration_ms, run_group) VALUES (?, ?, ?, ?, ?)");
|
|
479
|
+
}
|
|
480
|
+
/** Open (creating if needed) a file-backed history store and run migrations. */
|
|
481
|
+
static open(path) {
|
|
482
|
+
return new HistoryStore(new Database(path));
|
|
483
|
+
}
|
|
484
|
+
/** An in-memory store (tests, ephemeral analysis). */
|
|
485
|
+
static memory() {
|
|
486
|
+
return new HistoryStore(new Database(":memory:"));
|
|
487
|
+
}
|
|
488
|
+
/** The underlying database — shared with sibling tables (e.g. quarantine). */
|
|
489
|
+
get database() {
|
|
490
|
+
return this.db;
|
|
491
|
+
}
|
|
492
|
+
recordRun(run) {
|
|
493
|
+
this.insert.run(run.testId, run.passed ? 1 : 0, run.at ?? (/* @__PURE__ */ new Date()).toISOString(), run.durationMs ?? null, run.runGroup ?? null);
|
|
494
|
+
}
|
|
495
|
+
/** Record many runs in a single transaction. */
|
|
496
|
+
recordRuns(runs) {
|
|
497
|
+
this.db.transaction((batch) => {
|
|
498
|
+
for (const r of batch) this.recordRun(r);
|
|
499
|
+
})(runs);
|
|
500
|
+
}
|
|
501
|
+
rows(opts) {
|
|
502
|
+
const params = [];
|
|
503
|
+
let sql = "SELECT test_id, passed, at FROM test_run";
|
|
504
|
+
if (opts.since !== void 0) {
|
|
505
|
+
sql += " WHERE at >= ?";
|
|
506
|
+
params.push(opts.since);
|
|
507
|
+
}
|
|
508
|
+
sql += " ORDER BY test_id, at, id";
|
|
509
|
+
return this.db.prepare(sql).all(...params);
|
|
510
|
+
}
|
|
511
|
+
static toRun(r) {
|
|
512
|
+
return {
|
|
513
|
+
passed: r.passed !== 0,
|
|
514
|
+
at: r.at
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
/** All histories, grouped per test and sorted by test id. */
|
|
518
|
+
histories(opts = {}) {
|
|
519
|
+
const map = /* @__PURE__ */ new Map();
|
|
520
|
+
for (const r of this.rows(opts)) {
|
|
521
|
+
const list = map.get(r.test_id) ?? [];
|
|
522
|
+
list.push(HistoryStore.toRun(r));
|
|
523
|
+
map.set(r.test_id, list);
|
|
524
|
+
}
|
|
525
|
+
const limit = opts.limitPerTest;
|
|
526
|
+
return [...map.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([id, runs]) => ({
|
|
527
|
+
id,
|
|
528
|
+
runs: limit !== void 0 ? runs.slice(-limit) : runs
|
|
529
|
+
}));
|
|
530
|
+
}
|
|
531
|
+
/** One test's history (empty `runs` when never recorded). */
|
|
532
|
+
history(testId, opts = {}) {
|
|
533
|
+
const params = [testId];
|
|
534
|
+
let sql = "SELECT test_id, passed, at FROM test_run WHERE test_id = ?";
|
|
535
|
+
if (opts.since !== void 0) {
|
|
536
|
+
sql += " AND at >= ?";
|
|
537
|
+
params.push(opts.since);
|
|
538
|
+
}
|
|
539
|
+
sql += " ORDER BY at, id";
|
|
540
|
+
let runs = this.db.prepare(sql).all(...params).map(HistoryStore.toRun);
|
|
541
|
+
if (opts.limitPerTest !== void 0) runs = runs.slice(-opts.limitPerTest);
|
|
542
|
+
return {
|
|
543
|
+
id: testId,
|
|
544
|
+
runs
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Parse a vitest json report and record every pass/fail assertion as a run. Returns the
|
|
549
|
+
* number of runs recorded (skipped/pending/todo assertions are not counted).
|
|
550
|
+
*/
|
|
551
|
+
ingestReport(report, opts) {
|
|
552
|
+
const runs = parseVitestJson(report, opts);
|
|
553
|
+
this.recordRuns(runs);
|
|
554
|
+
return runs.length;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Parse a pytest-json-report report and record every pass/fail/error test as a run. Returns
|
|
558
|
+
* the number of runs recorded (skipped/xfailed/xpassed tests are not counted). The Python
|
|
559
|
+
* sibling of {@link ingestReport}.
|
|
560
|
+
*/
|
|
561
|
+
ingestPytestReport(report, opts) {
|
|
562
|
+
const runs = parsePytestJson(report, opts);
|
|
563
|
+
this.recordRuns(runs);
|
|
564
|
+
return runs.length;
|
|
565
|
+
}
|
|
566
|
+
/** Classify every test's history straight from the store. */
|
|
567
|
+
classify(opts = {}) {
|
|
568
|
+
return classifyHistories(this.histories(opts), opts);
|
|
569
|
+
}
|
|
570
|
+
close() {
|
|
571
|
+
this.db.close();
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
//#endregion
|
|
575
|
+
export { FlakeGateError, HistoryStore, Quarantine, QuarantineGateError, classifyHistories, classifyHistory, defaultPytestRunner, defaultVitestRunner, parsePytestJson, parseVitestJson, quarantineCandidates, runAndRecord, runAndRecordPytest, wilsonInterval };
|
|
576
|
+
|
|
577
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["outcome","migrate"],"sources":["../src/classify.ts","../src/pytest.ts","../src/quarantine.ts","../src/report.ts","../src/runner.ts","../src/store.ts"],"sourcesContent":["/**\n * Pure flakiness classifier — the first slice of `@sackville-mcp/flake`, and the only one\n * that touches no I/O. Given each test's run history (an ordered list of pass/fail\n * outcomes), it labels the test and quantifies *how* flaky it is with a binomial\n * confidence bound, so a later operator-gated quarantine slice has a defensible,\n * sample-size-aware number to threshold on rather than a raw \"it failed once\" reflex.\n *\n * Why Wilson and not the naive p̂ = failures/runs:\n * - The naive rate is wildly overconfident on small samples (1 failure in 2 runs reads\n * as a 50% failure rate; 1 in 100 reads as 1%, but with no sense of how trustworthy\n * either is). The **Wilson score interval** for a binomial proportion gives an\n * asymmetric, always-in-[0,1] confidence interval that stays sane at small n and at\n * the p̂=0 / p̂=1 boundaries (where the normal-approximation Wald interval collapses to\n * a useless zero-width point). We expose its lower bound as `flakeScore`: the\n * conservative \"we're confident the test fails at least this often\" magnitude — a test\n * that failed 1/100 from infra noise scores far below one failing 30/100, even though a\n * naive \"has failed\" flag treats them alike.\n *\n * Classification policy (deliberately conservative toward *catching* flakes, but\n * cautious about *condemning* a test as reliable/broken on thin evidence):\n * - A history with **both** a pass and a failure is `flaky` at any run count — observed\n * inconsistency is the definition of flaky; one mixed pair is enough to flag it.\n * - An all-pass or all-fail history is only trusted as `reliable` / `broken` once it\n * clears `minRuns`; below that it is `insufficient-data` (a brand-new all-pass test may\n * simply not have hit its flake yet; a single failure may be a one-off).\n * - An empty history is `insufficient-data`.\n */\n\n/** A single recorded execution of a test. */\nexport interface TestRun {\n passed: boolean\n /**\n * ISO timestamp of the run. Carried through from the (future) history store for later\n * time-windowing slices; the pure classifier reads only `passed`.\n */\n at?: string\n}\n\nexport interface TestHistory {\n /** Stable test identifier, e.g. `<file> > <test name>`. */\n id: string\n /** Runs in any order — the classifier only counts pass/fail, never their sequence. */\n runs: TestRun[]\n}\n\nexport type FlakeState = 'flaky' | 'reliable' | 'broken' | 'insufficient-data'\n\n/** A Wilson score interval, clamped to [0, 1]. */\nexport interface WilsonInterval {\n lower: number\n center: number\n upper: number\n}\n\nexport interface FlakeVerdict {\n id: string\n state: FlakeState\n runs: number\n passes: number\n failures: number\n /** Observed failure rate failures/runs (0 when there are no runs). */\n failureRate: number\n /** Wilson score interval for the true failure rate at the configured confidence. */\n wilson: WilsonInterval\n /**\n * Conservative flakiness magnitude = the Wilson lower bound of the failure rate. The\n * number a quarantine policy thresholds on: high only when the test fails often AND we\n * have enough runs to be confident. 0 for reliable / empty histories.\n */\n flakeScore: number\n}\n\nexport interface ClassifyOptions {\n /** z-score for the Wilson interval; default 1.96 (two-sided 95%). */\n z?: number\n /**\n * Minimum runs before an all-pass / all-fail history is trusted as `reliable` /\n * `broken`. Below it (with no observed inconsistency) the verdict is\n * `insufficient-data`. A *mixed* history is `flaky` at any run count. Default 5.\n */\n minRuns?: number\n}\n\nconst DEFAULT_Z = 1.96\nconst DEFAULT_MIN_RUNS = 5\n\n/**\n * The Wilson score interval for `failures` successes in `runs` Bernoulli trials at\n * confidence `z`. Bounds are clamped to [0, 1]. Zero runs yields a degenerate zero\n * interval (the rate is undefined; the caller marks it insufficient-data).\n */\nexport function wilsonInterval(failures: number, runs: number, z = DEFAULT_Z): WilsonInterval {\n if (runs <= 0) return { lower: 0, center: 0, upper: 0 }\n const n = runs\n const p = failures / n\n const z2 = z * z\n const denom = 1 + z2 / n\n const center = (p + z2 / (2 * n)) / denom\n const margin = (z / denom) * Math.sqrt((p * (1 - p)) / n + z2 / (4 * n * n))\n return {\n lower: Math.max(0, center - margin),\n center,\n upper: Math.min(1, center + margin),\n }\n}\n\n/** Classify a single test's run history into a {@link FlakeVerdict}. */\nexport function classifyHistory(history: TestHistory, opts: ClassifyOptions = {}): FlakeVerdict {\n const z = opts.z ?? DEFAULT_Z\n const minRuns = opts.minRuns ?? DEFAULT_MIN_RUNS\n\n const runs = history.runs.length\n const passes = history.runs.reduce((n, r) => n + (r.passed ? 1 : 0), 0)\n const failures = runs - passes\n const failureRate = runs > 0 ? failures / runs : 0\n const wilson = wilsonInterval(failures, runs, z)\n\n let state: FlakeState\n if (runs === 0) {\n state = 'insufficient-data'\n } else if (passes > 0 && failures > 0) {\n state = 'flaky'\n } else if (runs < minRuns) {\n // All-pass or all-fail, but too few runs to trust the verdict.\n state = 'insufficient-data'\n } else if (failures === 0) {\n state = 'reliable'\n } else {\n state = 'broken'\n }\n\n return {\n id: history.id,\n state,\n runs,\n passes,\n failures,\n failureRate,\n wilson,\n flakeScore: wilson.lower,\n }\n}\n\n/**\n * Classify many histories, preserving input order. Callers rank quarantine candidates by\n * sorting on `flakeScore` (or filtering `state === 'flaky'`).\n */\nexport function classifyHistories(\n histories: TestHistory[],\n opts: ClassifyOptions = {},\n): FlakeVerdict[] {\n return histories.map((h) => classifyHistory(h, opts))\n}\n","/**\n * pytest-json-report ingestion — turns a `pytest --json-report` report into the\n * {@link RecordedRun}s the history store records. The Python sibling of {@link parseVitestJson}.\n *\n * The store / classifier / quarantine are all **test-id-opaque** (they operate on the\n * `testId` string + pass/fail only), so the Python adapter is purely this shape converter —\n * no change to the engine. Like the vitest parser this module is pure: no spawning, no I/O.\n *\n * Two things differ from the vitest report:\n * - **Stable id:** pytest's `nodeid` (`tests/test_x.py::TestC::test_y`) is already\n * file-qualified, rootdir-relative, and stable, so we use it **verbatim** — none of the\n * `ancestorTitles + title` reconstruction the lossy vitest `fullName` forces.\n * - **Durations are seconds, split across phases.** pytest-json-report records a per-phase\n * `{setup, call, teardown}` duration in *seconds*; we sum the present phases and convert\n * to milliseconds to match {@link RecordedRun.durationMs} (and istanbul/vitest's ms unit).\n *\n * Outcome mapping (mirrors the vitest \"pass/fail-signal vs no-signal\" split):\n * - `passed` → recorded as a pass.\n * - `failed` → recorded as a failure.\n * - `error` → recorded as a failure: an errored test (a flaky fixture / setup / teardown)\n * did not pass, and that nondeterminism is exactly what the flake pillar hunts.\n * - `skipped` / `xfailed` / `xpassed` → dropped: no clean pass/fail flake signal (an\n * `xfailed` test behaved as declared; a strict `xpassed` surfaces as `failed`).\n */\n\nimport { isAbsolute, relative } from 'node:path'\nimport type { ParseReportOptions } from './report.js'\nimport type { RecordedRun } from './store.js'\n\n/** One phase (setup/call/teardown) of a pytest test item; `duration` is in seconds. */\nexport interface PytestPhase {\n duration?: number | null\n outcome?: string\n}\n\n/** The subset of a pytest-json-report test item we read. */\nexport interface PytestTest {\n nodeid?: string\n outcome?: string\n setup?: PytestPhase\n call?: PytestPhase\n teardown?: PytestPhase\n}\n\nexport interface PytestJsonReport {\n tests?: PytestTest[]\n}\n\n/** A status that carries a pass/fail signal; everything else returns undefined (dropped). */\nfunction outcome(status: string | undefined): boolean | undefined {\n if (status === 'passed') return true\n if (status === 'failed' || status === 'error') return false\n return undefined\n}\n\n/** Sum the present phase durations (seconds) → milliseconds, or undefined when none exist. */\nfunction durationMs(t: PytestTest): number | undefined {\n let seconds = 0\n let seen = false\n for (const phase of [t.setup, t.call, t.teardown]) {\n if (typeof phase?.duration === 'number') {\n seconds += phase.duration\n seen = true\n }\n }\n // Round to microsecond precision (in ms) to shed float-sum artifacts.\n return seen ? Math.round(seconds * 1_000_000) / 1000 : undefined\n}\n\n/**\n * Make a nodeid machine-stable. The nodeid is `<file>::<test path>`; pytest already emits\n * `<file>` rootdir-relative, so normally we pass it through. Only when a `projectRoot` is given\n * AND the file part is absolute do we relativize *just that part*, preserving the `::` structure\n * (a blind `relative()` over the whole string would mangle the `::`-delimited test path).\n */\nfunction stableId(nodeid: string, projectRoot?: string): string {\n if (projectRoot === undefined) return nodeid\n const sep = nodeid.indexOf('::')\n const file = sep === -1 ? nodeid : nodeid.slice(0, sep)\n if (!isAbsolute(file)) return nodeid\n const rel = relative(projectRoot, file)\n return sep === -1 ? rel : rel + nodeid.slice(sep)\n}\n\n/**\n * Parse a pytest-json-report report into recorded runs — one per pass/fail/error test.\n * Skipped / xfailed / xpassed tests are dropped (no pass/fail signal). Pure: no spawning, no I/O.\n */\nexport function parsePytestJson(report: PytestJsonReport, opts: ParseReportOptions): RecordedRun[] {\n const runs: RecordedRun[] = []\n for (const t of report.tests ?? []) {\n const passed = outcome(t.outcome)\n if (passed === undefined) continue\n const run: RecordedRun = {\n testId: stableId(t.nodeid ?? '<unknown>', opts.projectRoot),\n passed,\n at: opts.at,\n }\n const ms = durationMs(t)\n if (ms !== undefined) run.durationMs = ms\n if (opts.runGroup !== undefined) run.runGroup = opts.runGroup\n runs.push(run)\n }\n return runs\n}\n","/**\n * Quarantine — the only flake surface that **writes**, and therefore the one with teeth.\n *\n * Quarantining a test tells the gate to tolerate its failure for a bounded window. That\n * is exactly the capability an agent could abuse to turn a red suite green, so per ADR\n * 0010 it sits behind the house **paired deny-by-default operator gate**, adapted to this\n * surface: the pair is `allowQuarantine` (the boolean) + `maxExpiryMs` (the load-bearing\n * bound — a zero/absent cap denies every write even when the boolean is set, and an\n * **expiry is mandatory** so a quarantine can never be permanent). Both are operator-set;\n * no caller input can self-authorize, lengthen past the cap (we fail loud rather than\n * silently clamp), or make a quarantine open-ended.\n *\n * Reads (`isQuarantined`/`active`/`all`) and `release` are ungated: an expired quarantine\n * is automatically inactive, and releasing a test only ever makes the gate stricter.\n */\n\nimport type Database from 'better-sqlite3'\nimport type { FlakeVerdict } from './classify.js'\nimport type { HistoryStore } from './store.js'\n\n/** Thrown when the paired operator gate denies a quarantine write. */\nexport class QuarantineGateError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'QuarantineGateError'\n }\n}\n\n/** Operator-set quarantine policy (the paired gate). */\nexport interface QuarantinePolicy {\n /** OPERATOR opt-in to allow quarantine writes. Deny-by-default. */\n allowQuarantine: boolean\n /**\n * OPERATOR cap on quarantine duration (ms from `quarantinedAt`). Load-bearing: a\n * zero/non-positive cap denies every write even with `allowQuarantine`, and a request\n * whose expiry exceeds it is refused (never silently clamped).\n */\n maxExpiryMs: number\n}\n\nexport interface QuarantineRequest {\n testId: string\n /** Why it is quarantined — mandatory, non-empty (audit trail). */\n reason: string\n /** ISO expiry; mandatory, must be in the future and within `maxExpiryMs` of `now`. */\n expiresAt: string\n /** The flakeScore that justified it (for audit/ranking). */\n flakeScore?: number\n /** Reference time; defaults to now. */\n now?: string\n}\n\nexport interface QuarantineEntry {\n testId: string\n reason: string\n flakeScore: number | null\n quarantinedAt: string\n expiresAt: string\n}\n\ninterface QRow {\n test_id: string\n reason: string\n flake_score: number | null\n quarantined_at: string\n expires_at: string\n}\n\nfunction migrate(db: Database.Database): void {\n db.exec(`\n CREATE TABLE IF NOT EXISTS quarantine (\n test_id TEXT PRIMARY KEY,\n reason TEXT NOT NULL,\n flake_score REAL,\n quarantined_at TEXT NOT NULL,\n expires_at TEXT NOT NULL\n );\n `)\n}\n\nfunction toEntry(r: QRow): QuarantineEntry {\n return {\n testId: r.test_id,\n reason: r.reason,\n flakeScore: r.flake_score,\n quarantinedAt: r.quarantined_at,\n expiresAt: r.expires_at,\n }\n}\n\nexport class Quarantine {\n private readonly db: Database.Database\n private readonly policy: QuarantinePolicy\n\n constructor(store: HistoryStore | Database.Database, policy: QuarantinePolicy) {\n this.db = 'database' in store ? store.database : store\n this.policy = policy\n migrate(this.db)\n }\n\n /** Quarantine a test for a bounded window. Throws {@link QuarantineGateError} on denial. */\n quarantine(req: QuarantineRequest): QuarantineEntry {\n if (!this.policy.allowQuarantine) {\n throw new QuarantineGateError(\n 'quarantine writes are not enabled (the operator must set allowQuarantine)',\n )\n }\n if (!(this.policy.maxExpiryMs > 0)) {\n throw new QuarantineGateError(\n 'no quarantine expiry bound is configured (operator maxExpiryMs must be > 0)',\n )\n }\n const reason = req.reason.trim()\n if (reason === '') {\n throw new QuarantineGateError('a non-empty quarantine reason is required')\n }\n const now = req.now ?? new Date().toISOString()\n const nowMs = Date.parse(now)\n const expiryMs = Date.parse(req.expiresAt)\n if (Number.isNaN(expiryMs)) {\n throw new QuarantineGateError(`unparseable expiresAt: ${req.expiresAt}`)\n }\n if (expiryMs <= nowMs) {\n throw new QuarantineGateError('expiresAt must be in the future')\n }\n if (expiryMs - nowMs > this.policy.maxExpiryMs) {\n throw new QuarantineGateError(\n `expiry exceeds the operator cap of ${this.policy.maxExpiryMs}ms`,\n )\n }\n this.db\n .prepare(\n `INSERT INTO quarantine (test_id, reason, flake_score, quarantined_at, expires_at)\n VALUES (?, ?, ?, ?, ?)\n ON CONFLICT(test_id) DO UPDATE SET\n reason = excluded.reason,\n flake_score = excluded.flake_score,\n quarantined_at = excluded.quarantined_at,\n expires_at = excluded.expires_at`,\n )\n .run(req.testId, reason, req.flakeScore ?? null, now, req.expiresAt)\n return {\n testId: req.testId,\n reason,\n flakeScore: req.flakeScore ?? null,\n quarantinedAt: now,\n expiresAt: req.expiresAt,\n }\n }\n\n /** Lift a quarantine. Ungated. Returns true if a row was removed. */\n release(testId: string): boolean {\n const info = this.db.prepare('DELETE FROM quarantine WHERE test_id = ?').run(testId)\n return info.changes > 0\n }\n\n /** Whether a test is currently quarantined (expiry-aware). */\n isQuarantined(testId: string, now: string = new Date().toISOString()): boolean {\n const row = this.db\n .prepare('SELECT expires_at FROM quarantine WHERE test_id = ?')\n .get(testId) as { expires_at: string } | undefined\n return row !== undefined && Date.parse(row.expires_at) > Date.parse(now)\n }\n\n /** Currently-active (unexpired) quarantines, ordered by expiry. */\n active(now: string = new Date().toISOString()): QuarantineEntry[] {\n const rows = this.db\n .prepare('SELECT * FROM quarantine WHERE expires_at > ? ORDER BY expires_at, test_id')\n .all(now) as QRow[]\n return rows.map(toEntry)\n }\n\n /** Every quarantine row, including expired ones (audit). */\n all(): QuarantineEntry[] {\n const rows = this.db.prepare('SELECT * FROM quarantine ORDER BY test_id').all() as QRow[]\n return rows.map(toEntry)\n }\n}\n\nexport interface CandidateOptions {\n /** Only verdicts with `flakeScore >= minFlakeScore` (default 0 — every flaky test). */\n minFlakeScore?: number\n}\n\n/**\n * Pure helper: rank quarantine candidates from classifier verdicts — `flaky` tests whose\n * `flakeScore` clears the floor, highest first. Never selects `broken` (a real, consistent\n * failure to FIX, not hide) or `reliable`/`insufficient-data`. The write itself is still\n * gated; this only proposes.\n */\nexport function quarantineCandidates(\n verdicts: FlakeVerdict[],\n opts: CandidateOptions = {},\n): FlakeVerdict[] {\n const floor = opts.minFlakeScore ?? 0\n return verdicts\n .filter((v) => v.state === 'flaky' && v.flakeScore >= floor)\n .sort((a, b) => b.flakeScore - a.flakeScore)\n}\n","/**\n * Vitest JSON-report ingestion — turns a `vitest run --reporter=json` report into the\n * {@link RecordedRun}s the history store records.\n *\n * Per ADR 0010 the flake pillar **spawns** `vitest run --reporter=json` and parses its\n * output (a different execution model from coverage's in-process/child-process run and\n * mutation's Stryker delegation — there is no shared runner seam). This module is the\n * pure parser half: no spawning, no I/O — it just maps the report's shape to runs, so it\n * is unit-tested against a committed real-shaped fixture. The gated spawn that produces\n * the report lives in a later slice.\n *\n * The report's `fullName` is the ancestor titles + title joined by a single space, which\n * is lossy (a describe/test boundary is indistinguishable from a space inside a title).\n * We therefore build a stable, file-qualified id from `ancestorTitles + title` joined by\n * ` > ` ourselves, falling back to `fullName`, then `title`, when those are absent.\n */\n\nimport { relative } from 'node:path'\nimport type { RecordedRun } from './store.js'\n\n/** The subset of a vitest json assertion result we read. */\nexport interface VitestAssertion {\n ancestorTitles?: string[]\n title?: string\n fullName?: string\n status?: string\n duration?: number | null\n}\n\nexport interface VitestFileResult {\n /** Test file path (absolute as vitest emits it). */\n name?: string\n assertionResults?: VitestAssertion[]\n}\n\nexport interface VitestJsonReport {\n testResults?: VitestFileResult[]\n}\n\nexport interface ParseReportOptions {\n /** ISO timestamp stamped on every parsed run. */\n at: string\n /** When set, file paths are made relative to it for stable, machine-independent ids. */\n projectRoot?: string\n /** Optional id grouping all runs from this report (a CI run / batch). */\n runGroup?: string\n}\n\n/** A status that carries a pass/fail signal. Skipped/pending/todo do not. */\nfunction outcome(status: string | undefined): boolean | undefined {\n if (status === 'passed') return true\n if (status === 'failed') return false\n return undefined\n}\n\nfunction titlePart(a: VitestAssertion): string {\n if (a.ancestorTitles?.length) return [...a.ancestorTitles, a.title ?? ''].join(' > ')\n return a.fullName ?? a.title ?? '<unknown>'\n}\n\nfunction fileLabel(name: string | undefined, projectRoot?: string): string {\n if (!name) return ''\n return projectRoot ? relative(projectRoot, name) : name\n}\n\n/**\n * Parse a vitest json report into recorded runs — one per pass/fail assertion. Skipped /\n * pending / todo assertions are dropped (no pass/fail signal). Pure: no spawning, no I/O.\n */\nexport function parseVitestJson(report: VitestJsonReport, opts: ParseReportOptions): RecordedRun[] {\n const runs: RecordedRun[] = []\n for (const file of report.testResults ?? []) {\n const label = fileLabel(file.name, opts.projectRoot)\n for (const a of file.assertionResults ?? []) {\n const passed = outcome(a.status)\n if (passed === undefined) continue\n const id = label ? `${label} > ${titlePart(a)}` : titlePart(a)\n const run: RecordedRun = { testId: id, passed, at: opts.at }\n if (typeof a.duration === 'number') run.durationMs = a.duration\n if (opts.runGroup !== undefined) run.runGroup = opts.runGroup\n runs.push(run)\n }\n }\n return runs\n}\n","/**\n * The gated vitest runner — the live half of the flake pillar. It **spawns**\n * `vitest run --reporter=json` (per ADR 0010: flake's execution model is spawn-and-parse,\n * distinct from coverage's child-process coverage run and mutation's Stryker delegation),\n * reads the JSON report, and records every outcome into the {@link HistoryStore}. Run the\n * suite `repeat` times to actually surface flakiness, then classify.\n *\n * Two ADR-0010 constraints, mirroring `@sackville-mcp/coverage`'s `runScoped`:\n * 1. **It runs code**, so it is behind a *paired* deny-by-default operator gate — an\n * `allowRun` boolean AND an `allowedRoots` allowlist (load-bearing on its own), with a\n * wall-clock cap. All operator-set; no caller input self-authorizes.\n * 2. **Child-process boundary.** The real `vitest` invocation is an injected\n * {@link TestRunner} (the bin wires a subprocess); the engine owns the gate, argv,\n * report-file plumbing, ingestion, and classification, and is unit-tested with a fake\n * runner — no real spawn in the green gate.\n */\n\nimport { execFile } from 'node:child_process'\nimport { mkdtempSync, readFileSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { join, resolve } from 'node:path'\nimport type { FlakeVerdict } from './classify.js'\nimport type { PytestJsonReport } from './pytest.js'\nimport type { ParseReportOptions, VitestJsonReport } from './report.js'\nimport type { HistoryStore } from './store.js'\n\n/** Thrown when the paired operator gate denies a run. */\nexport class FlakeGateError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'FlakeGateError'\n // Brand as a gate DENIAL (ADR 0013 Addendum, milestone 5c): the run-driving\n // `@sackville-mcp/verify` reads this global-registry symbol via `isGateDenial` to map a\n // denial to `skipReason:'gate-not-set'` (never `errored`) WITHOUT importing engine\n // code. The `Symbol.for` key string is the cross-package contract.\n ;(this as unknown as Record<symbol, unknown>)[Symbol.for('sackville.gate-denial')] = true\n }\n}\n\nexport interface RunHistoryConfig {\n /** The project to run tests in. */\n projectRoot: string\n /** OPERATOR allowlist of roots the runner may execute in. Load-bearing even with allowRun. */\n allowedRoots: string[]\n /** OPERATOR opt-in to actually run tests. Deny-by-default. */\n allowRun: boolean\n /** Wall-clock cap (ms) per iteration, passed to the runner. */\n timeoutMs?: number\n}\n\nexport interface RunAndRecordInput {\n /** How many times to run the suite — flakiness needs repeats. Default 1. */\n repeat?: number\n /** Positional vitest file filters; default runs the whole suite. */\n files?: string[]\n /** Batch id; each iteration is recorded under `${runGroup}#<i>`. */\n runGroup?: string\n}\n\n/** Injected command runner — executes `vitest <argv>` and yields its exit status. */\nexport type TestRunner = (\n argv: string[],\n opts: { cwd: string; timeoutMs?: number },\n) => Promise<{ exitCode: number; stdout: string; stderr: string }>\n\nexport interface RunAndRecordResult {\n /** False only when repeat <= 0 (the runner was never invoked). */\n ran: boolean\n iterations: number\n /** Total runs recorded across all iterations. */\n recorded: number\n results: { exitCode: number; passed: boolean }[]\n /** Classifier verdicts over the store AFTER recording this batch. */\n verdicts: FlakeVerdict[]\n}\n\n/** Build the argv for one vitest suite run with the JSON reporter writing to `outFile`. */\nfunction vitestArgv(files: string[], outFile: string): string[] {\n return ['run', ...files, '--reporter=json', `--outputFile=${outFile}`]\n}\n\n/**\n * Build the argv for one pytest run with the `pytest-json-report` plugin writing to `outFile`\n * (ADR 0010 addendum: json-report now, `pytest-reportlog` staged). No `run` subcommand — pytest\n * takes positional file filters directly. The plugin is an operator-installed dev dependency.\n */\nfunction pytestArgv(files: string[], outFile: string): string[] {\n return ['--json-report', `--json-report-file=${outFile}`, ...files]\n}\n\n/** Spawn a local command as a subprocess, surfacing its exit code (never rejecting on non-zero). */\nfunction spawnRunner(command: string): TestRunner {\n return (argv, opts) =>\n new Promise((res) => {\n execFile(\n command,\n argv,\n { cwd: opts.cwd, timeout: opts.timeoutMs, maxBuffer: 64 * 1024 * 1024 },\n (err, stdout, stderr) => {\n // The tool exits non-zero on a test failure — surface the code, don't reject.\n const code =\n err && typeof (err as { code?: unknown }).code === 'number'\n ? (err as { code: number }).code\n : err\n ? 1\n : 0\n res({ exitCode: code, stdout: String(stdout), stderr: String(stderr) })\n },\n )\n })\n}\n\n/** Default live runner: spawn the local `vitest` as a subprocess (used by the bin, not the gate). */\nexport const defaultVitestRunner: TestRunner = spawnRunner('vitest')\n\n/** Default live runner: spawn the local `pytest` as a subprocess (used by the bin, not the gate). */\nexport const defaultPytestRunner: TestRunner = spawnRunner('pytest')\n\n/**\n * A test framework's runner specifics: the default subprocess runner, how to build its argv with\n * a per-iteration report file, and how to ingest the parsed report into the store. Everything else\n * (gate, repeat loop, report-file plumbing, classification) is framework-agnostic.\n */\ninterface FrameworkAdapter {\n defaultRunner: TestRunner\n buildArgv(files: string[], outFile: string): string[]\n ingest(store: HistoryStore, parsed: unknown, opts: ParseReportOptions): number\n}\n\nconst VITEST: FrameworkAdapter = {\n defaultRunner: defaultVitestRunner,\n buildArgv: vitestArgv,\n ingest: (store, parsed, opts) => store.ingestReport(parsed as VitestJsonReport, opts),\n}\n\nconst PYTEST: FrameworkAdapter = {\n defaultRunner: defaultPytestRunner,\n buildArgv: pytestArgv,\n ingest: (store, parsed, opts) => store.ingestPytestReport(parsed as PytestJsonReport, opts),\n}\n\nfunction assertAllowed(config: RunHistoryConfig): void {\n if (!config.allowRun) {\n throw new FlakeGateError('test execution is not enabled (the operator must set allowRun)')\n }\n const root = resolve(config.projectRoot)\n const allowed = config.allowedRoots.map((r) => resolve(r))\n if (!allowed.includes(root)) {\n throw new FlakeGateError(`project root ${config.projectRoot} is not in the operator allowlist`)\n }\n}\n\n/**\n * Run a test suite `repeat` times behind the operator gate via the given {@link FrameworkAdapter},\n * recording every outcome into the store, then classify. The actual invocation is the injected\n * `runner` (default = the adapter's subprocess runner); no real spawn in the green gate.\n */\nasync function runAndRecordWith(\n fw: FrameworkAdapter,\n store: HistoryStore,\n config: RunHistoryConfig,\n input: RunAndRecordInput,\n deps: { runner?: TestRunner; reportDir?: string },\n): Promise<RunAndRecordResult> {\n assertAllowed(config)\n\n const repeat = input.repeat ?? 1\n if (repeat <= 0) {\n return { ran: false, iterations: 0, recorded: 0, results: [], verdicts: store.classify() }\n }\n\n const runner = deps.runner ?? fw.defaultRunner\n const reportDir = deps.reportDir ?? mkdtempSync(join(tmpdir(), 'sackville-flake-'))\n const files = input.files ?? []\n const results: { exitCode: number; passed: boolean }[] = []\n let recorded = 0\n\n for (let i = 0; i < repeat; i++) {\n const outFile = join(reportDir, `report-${i}.json`)\n const { exitCode } = await runner(fw.buildArgv(files, outFile), {\n cwd: config.projectRoot,\n timeoutMs: config.timeoutMs,\n })\n let parsed: unknown\n try {\n parsed = JSON.parse(readFileSync(outFile, 'utf8'))\n } catch {\n throw new Error(\n `flake run did not produce a JSON report at ${outFile} (exit code ${exitCode})`,\n )\n }\n recorded += fw.ingest(store, parsed, {\n at: new Date().toISOString(),\n projectRoot: config.projectRoot,\n runGroup: input.runGroup !== undefined ? `${input.runGroup}#${i}` : undefined,\n })\n results.push({ exitCode, passed: exitCode === 0 })\n }\n\n return { ran: true, iterations: repeat, recorded, results, verdicts: store.classify() }\n}\n\n/**\n * Run the vitest suite `repeat` times behind the operator gate, recording every outcome into the\n * store, then classify. The actual `vitest` invocation is the injected `runner` (default\n * {@link defaultVitestRunner}).\n */\nexport async function runAndRecord(\n store: HistoryStore,\n config: RunHistoryConfig,\n input: RunAndRecordInput,\n deps: { runner?: TestRunner; reportDir?: string } = {},\n): Promise<RunAndRecordResult> {\n return runAndRecordWith(VITEST, store, config, input, deps)\n}\n\n/**\n * The pytest sibling of {@link runAndRecord} (ADR 0010 addendum): spawn `pytest --json-report`\n * `repeat` times, ingest via the existing `parsePytestJson` (unchanged), classify. Repeats re-run\n * the WHOLE suite — never `pytest-repeat`, whose `[i-N]` nodeid suffix would fragment the\n * one-history-per-nodeid invariant the classifier relies on.\n */\nexport async function runAndRecordPytest(\n store: HistoryStore,\n config: RunHistoryConfig,\n input: RunAndRecordInput,\n deps: { runner?: TestRunner; reportDir?: string } = {},\n): Promise<RunAndRecordResult> {\n return runAndRecordWith(PYTEST, store, config, input, deps)\n}\n","/**\n * The private run-history store — `@sackville-mcp/flake`'s own SQLite database.\n *\n * Per ADR 0010 this is a **second SQLite owner**, deliberately OUTSIDE the docs-pillar\n * \"only `@sackville-mcp/core` touches SQLite\" invariant: it is a new, private store for test\n * run outcomes, not a crossing of the Python↔TS polyglot contract (which remains the\n * `schema/sackville.schema.sql` index that `core` reads). It records each test's pass/fail\n * history over time and reads it back as the `TestHistory[]` the pure classifier consumes.\n *\n * The schema is intentionally tiny: one append-only `test_run` row per recorded outcome,\n * plus a `flake_meta` version marker. Quarantine state is a separate table added by the\n * quarantine slice.\n */\n\nimport Database from 'better-sqlite3'\nimport {\n type ClassifyOptions,\n classifyHistories,\n type FlakeVerdict,\n type TestHistory,\n type TestRun,\n} from './classify.js'\nimport { type PytestJsonReport, parsePytestJson } from './pytest.js'\nimport { type ParseReportOptions, parseVitestJson, type VitestJsonReport } from './report.js'\n\nconst SCHEMA_VERSION = 1\n\n/** A test outcome to record. */\nexport interface RecordedRun {\n testId: string\n passed: boolean\n /** ISO timestamp; defaults to now. */\n at?: string\n /** Optional wall-clock duration of the run. */\n durationMs?: number\n /** Optional id grouping all tests from one suite execution (a CI run / batch). */\n runGroup?: string\n}\n\nexport interface HistoryQueryOptions {\n /** Keep only the most recent N runs per test (chronological tail). */\n limitPerTest?: number\n /** Only include runs at/after this ISO timestamp. */\n since?: string\n}\n\ninterface RunRow {\n test_id: string\n passed: number\n at: string\n}\n\nfunction migrate(db: Database.Database): void {\n db.pragma('journal_mode = WAL')\n db.exec(`\n CREATE TABLE IF NOT EXISTS flake_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);\n CREATE TABLE IF NOT EXISTS test_run (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n test_id TEXT NOT NULL,\n passed INTEGER NOT NULL,\n at TEXT NOT NULL,\n duration_ms REAL,\n run_group TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_test_run_test_id_at ON test_run(test_id, at, id);\n `)\n const row = db.prepare('SELECT value FROM flake_meta WHERE key = ?').get('schema_version') as\n | { value: string }\n | undefined\n if (!row) {\n db.prepare('INSERT INTO flake_meta (key, value) VALUES (?, ?)').run(\n 'schema_version',\n String(SCHEMA_VERSION),\n )\n }\n}\n\nexport class HistoryStore {\n private readonly db: Database.Database\n private readonly insert: Database.Statement\n\n constructor(db: Database.Database) {\n this.db = db\n migrate(db)\n this.insert = db.prepare(\n 'INSERT INTO test_run (test_id, passed, at, duration_ms, run_group) VALUES (?, ?, ?, ?, ?)',\n )\n }\n\n /** Open (creating if needed) a file-backed history store and run migrations. */\n static open(path: string): HistoryStore {\n return new HistoryStore(new Database(path))\n }\n\n /** An in-memory store (tests, ephemeral analysis). */\n static memory(): HistoryStore {\n return new HistoryStore(new Database(':memory:'))\n }\n\n /** The underlying database — shared with sibling tables (e.g. quarantine). */\n get database(): Database.Database {\n return this.db\n }\n\n recordRun(run: RecordedRun): void {\n this.insert.run(\n run.testId,\n run.passed ? 1 : 0,\n run.at ?? new Date().toISOString(),\n run.durationMs ?? null,\n run.runGroup ?? null,\n )\n }\n\n /** Record many runs in a single transaction. */\n recordRuns(runs: RecordedRun[]): void {\n const tx = this.db.transaction((batch: RecordedRun[]) => {\n for (const r of batch) this.recordRun(r)\n })\n tx(runs)\n }\n\n private rows(opts: HistoryQueryOptions): RunRow[] {\n const params: string[] = []\n let sql = 'SELECT test_id, passed, at FROM test_run'\n if (opts.since !== undefined) {\n sql += ' WHERE at >= ?'\n params.push(opts.since)\n }\n sql += ' ORDER BY test_id, at, id'\n return this.db.prepare(sql).all(...params) as RunRow[]\n }\n\n private static toRun(r: RunRow): TestRun {\n return { passed: r.passed !== 0, at: r.at }\n }\n\n /** All histories, grouped per test and sorted by test id. */\n histories(opts: HistoryQueryOptions = {}): TestHistory[] {\n const map = new Map<string, TestRun[]>()\n for (const r of this.rows(opts)) {\n const list = map.get(r.test_id) ?? []\n list.push(HistoryStore.toRun(r))\n map.set(r.test_id, list)\n }\n const limit = opts.limitPerTest\n return [...map.entries()]\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n .map(([id, runs]) => ({ id, runs: limit !== undefined ? runs.slice(-limit) : runs }))\n }\n\n /** One test's history (empty `runs` when never recorded). */\n history(testId: string, opts: HistoryQueryOptions = {}): TestHistory {\n const params: string[] = [testId]\n let sql = 'SELECT test_id, passed, at FROM test_run WHERE test_id = ?'\n if (opts.since !== undefined) {\n sql += ' AND at >= ?'\n params.push(opts.since)\n }\n sql += ' ORDER BY at, id'\n let runs = (this.db.prepare(sql).all(...params) as RunRow[]).map(HistoryStore.toRun)\n if (opts.limitPerTest !== undefined) runs = runs.slice(-opts.limitPerTest)\n return { id: testId, runs }\n }\n\n /**\n * Parse a vitest json report and record every pass/fail assertion as a run. Returns the\n * number of runs recorded (skipped/pending/todo assertions are not counted).\n */\n ingestReport(report: VitestJsonReport, opts: ParseReportOptions): number {\n const runs = parseVitestJson(report, opts)\n this.recordRuns(runs)\n return runs.length\n }\n\n /**\n * Parse a pytest-json-report report and record every pass/fail/error test as a run. Returns\n * the number of runs recorded (skipped/xfailed/xpassed tests are not counted). The Python\n * sibling of {@link ingestReport}.\n */\n ingestPytestReport(report: PytestJsonReport, opts: ParseReportOptions): number {\n const runs = parsePytestJson(report, opts)\n this.recordRuns(runs)\n return runs.length\n }\n\n /** Classify every test's history straight from the store. */\n classify(opts: ClassifyOptions & HistoryQueryOptions = {}): FlakeVerdict[] {\n return classifyHistories(this.histories(opts), opts)\n }\n\n close(): void {\n this.db.close()\n }\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sackville-mcp/flake",
|
|
3
|
+
"version": "0.0.1-alpha.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": {
|
|
9
|
+
"types": "./dist/index.d.mts",
|
|
10
|
+
"default": "./dist/index.mjs"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.mjs",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"better-sqlite3": "^12.10.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/better-sqlite3": "^7.6.13"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/ceautery/sackville.git",
|
|
30
|
+
"directory": "packages/flake"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsdown src/index.ts --dts",
|
|
34
|
+
"typecheck": "tsc --noEmit"
|
|
35
|
+
}
|
|
36
|
+
}
|