@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 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.
@@ -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
+ }