@ticket-gate/engine 0.0.0 → 0.1.0-rc.1

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.
Files changed (92) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +82 -0
  3. package/bin/ticket-gate.mjs +261 -0
  4. package/checks/assertions-unguarded.mjs +31 -0
  5. package/checks/assertions.mjs +319 -0
  6. package/checks/cache-report.mjs +313 -0
  7. package/checks/docs-stale.mjs +69 -0
  8. package/checks/duplication.mjs +258 -0
  9. package/checks/ratchet-options.mjs +51 -0
  10. package/checks/surface.mjs +397 -0
  11. package/cli.mjs +379 -0
  12. package/commands-config.mjs +55 -0
  13. package/config.mjs +597 -0
  14. package/digest.mjs +195 -0
  15. package/env-name.mjs +24 -0
  16. package/failures.mjs +256 -0
  17. package/fu.mjs +458 -0
  18. package/graph/js.mjs +517 -0
  19. package/graph/load-time.mjs +309 -0
  20. package/graph/typescript.mjs +28 -0
  21. package/hooks/pre-tool-use.mjs +213 -0
  22. package/hooks/stop.mjs +495 -0
  23. package/is-main.mjs +15 -0
  24. package/kill-group.mjs +13 -0
  25. package/ledger.mjs +186 -0
  26. package/lock.d.mts +72 -0
  27. package/lock.mjs +329 -0
  28. package/move-proof.mjs +206 -0
  29. package/mutations/check.mjs +506 -0
  30. package/mutations/cli.mjs +361 -0
  31. package/mutations/exec.mjs +120 -0
  32. package/mutations/gate.mjs +148 -0
  33. package/mutations/harness.mjs +240 -0
  34. package/mutations/kinds.mjs +148 -0
  35. package/mutations/locate.mjs +71 -0
  36. package/mutations/project.mjs +23 -0
  37. package/mutations/registry.mjs +173 -0
  38. package/mutations/rotate.mjs +419 -0
  39. package/mutations/rotation-status.mjs +93 -0
  40. package/mutations/run.mjs +252 -0
  41. package/mutations/select.mjs +333 -0
  42. package/mutations/served.mjs +216 -0
  43. package/mutations/validate.mjs +86 -0
  44. package/mutations/verdict.mjs +159 -0
  45. package/nightly/cli.mjs +30 -0
  46. package/nightly/define.mjs +111 -0
  47. package/nightly/e2e-runs.mjs +73 -0
  48. package/nightly/gate.mjs +25 -0
  49. package/nightly/io.mjs +109 -0
  50. package/nightly/launchd.mjs +42 -0
  51. package/nightly/record.mjs +231 -0
  52. package/nightly/run.mjs +470 -0
  53. package/nightly/schedule.mjs +76 -0
  54. package/nightly/seed.mjs +78 -0
  55. package/nightly/status.mjs +189 -0
  56. package/nightly/suites.mjs +43 -0
  57. package/nightly-config.mjs +38 -0
  58. package/nightly-window.mjs +6 -0
  59. package/pack-hooks-all.mjs +15 -0
  60. package/pack-hooks.mjs +26 -0
  61. package/package.json +21 -1
  62. package/plan.mjs +272 -0
  63. package/precheck.mjs +90 -0
  64. package/project.mjs +21 -0
  65. package/ratchet.mjs +431 -0
  66. package/receipt.mjs +34 -0
  67. package/review-map.mjs +432 -0
  68. package/root.mjs +33 -0
  69. package/run.mjs +291 -0
  70. package/runners/playwright.mjs +132 -0
  71. package/score/adapters/stryker.mjs +111 -0
  72. package/score/base.mjs +82 -0
  73. package/score/cli.mjs +182 -0
  74. package/score/judge.mjs +107 -0
  75. package/score/lines.mjs +40 -0
  76. package/score/schema.mjs +68 -0
  77. package/score/survivors.mjs +103 -0
  78. package/score/targets.mjs +34 -0
  79. package/score-config.mjs +120 -0
  80. package/selection/check.mjs +45 -0
  81. package/selection/cli.mjs +267 -0
  82. package/selection/define.mjs +55 -0
  83. package/selection/for.mjs +235 -0
  84. package/selection/graphs.mjs +38 -0
  85. package/selection/project.mjs +11 -0
  86. package/selection/select.mjs +159 -0
  87. package/selection/units.mjs +646 -0
  88. package/selection/value-rules.mjs +142 -0
  89. package/selection-config.mjs +76 -0
  90. package/serve.mjs +142 -0
  91. package/summary.mjs +34 -0
  92. package/tree.mjs +211 -0
@@ -0,0 +1,25 @@
1
+ // What the rest of the engine reads from a gate's `nightly` key: the record, its status line and
2
+ // the gate's own e2e run log. Each answers null when the gate has no nightly.
3
+ import { appendE2eRun, e2eRunOf } from './e2e-runs.mjs';
4
+ import { nightlyDir, readNightly } from './record.mjs';
5
+ import { statusLines } from './status.mjs';
6
+
7
+ /** `{ latest, history }` of the gate's nightly dir, or null without a `nightly` key. */
8
+ export function nightlyRecord(gate) {
9
+ return gate?.nightly ? readNightly(nightlyDir()) : null;
10
+ }
11
+
12
+ /** The first status line (what the dry run prints), or null without a `nightly` key. */
13
+ export function nightlyLine(gate, nowMs) {
14
+ const cfg = gate?.nightly;
15
+ if (!cfg) return null;
16
+ return statusLines({ ...readNightly(nightlyDir()), queued: 0 }, nowMs, undefined, undefined, cfg)[0];
17
+ }
18
+
19
+ /** Append this gate run's e2e line to the nightly dir; null (nothing written) without a `nightly` key. */
20
+ export function recordE2eRun({ gate, results, id, ranAt, dir = nightlyDir() }) {
21
+ if (!gate?.nightly) return null;
22
+ const line = e2eRunOf(results, id, ranAt);
23
+ if (line) appendE2eRun(dir, line);
24
+ return line;
25
+ }
package/nightly/io.mjs ADDED
@@ -0,0 +1,109 @@
1
+ import { spawn } from 'node:child_process';
2
+ import {
3
+ copyFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { dirname, join } from 'node:path';
12
+
13
+ import {
14
+ isAlive,
15
+ lockPath as defaultLockPath,
16
+ mainCheckout as defaultMainCheckout,
17
+ } from '../lock.mjs';
18
+ import { ROOT } from '../root.mjs';
19
+
20
+ export { ROOT } from '../root.mjs';
21
+
22
+ // ---------------------------------------------------------------- real io
23
+
24
+ export function realRun(
25
+ cmd,
26
+ args,
27
+ { cwd, env = {}, limitS, killSignal = 'SIGKILL', graceS = 0 } = {},
28
+ ) {
29
+ return new Promise((resolve) => {
30
+ const child = spawn(cmd, args, {
31
+ cwd,
32
+ env: { ...process.env, ...env },
33
+ detached: true,
34
+ stdio: ['ignore', 'pipe', 'pipe'],
35
+ });
36
+ let out = '';
37
+ let timedOut = false;
38
+ const take = (b) => {
39
+ process.stdout.write(b);
40
+ out = (out + b).slice(-20_000);
41
+ };
42
+ child.stdout.on('data', take);
43
+ child.stderr.on('data', take);
44
+ const killGroup = (sig) => {
45
+ try {
46
+ process.kill(-child.pid, sig);
47
+ } catch {
48
+ // already gone
49
+ }
50
+ };
51
+ let graceTimer = null;
52
+ const timer = limitS
53
+ ? setTimeout(() => {
54
+ timedOut = true;
55
+ killGroup(killSignal);
56
+ if (killSignal !== 'SIGKILL' && graceS > 0)
57
+ graceTimer = setTimeout(() => killGroup('SIGKILL'), graceS * 1000);
58
+ }, limitS * 1000)
59
+ : null;
60
+ child.on('error', (e) => {
61
+ clearTimeout(timer);
62
+ resolve({ code: 127, timedOut: false, out: String(e.message) });
63
+ });
64
+ child.on('close', (code) => {
65
+ clearTimeout(timer);
66
+ clearTimeout(graceTimer);
67
+ if (timedOut && killSignal !== 'SIGKILL') killGroup('SIGKILL'); // stragglers
68
+ resolve({ code: code ?? 1, timedOut, out });
69
+ });
70
+ });
71
+ }
72
+
73
+ export function realIo() {
74
+ const main = defaultMainCheckout(ROOT);
75
+ return {
76
+ now: () => Date.now(),
77
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
78
+ log: (s) => console.log(s),
79
+ lockPath: defaultLockPath(ROOT),
80
+ mainCheckout: main,
81
+ tmpdir: tmpdir(),
82
+ alive: isAlive,
83
+ run: realRun,
84
+ caffeinate: (pid) => {
85
+ try {
86
+ spawn('caffeinate', ['-i', '-w', String(pid)], {
87
+ detached: true,
88
+ stdio: 'ignore',
89
+ })
90
+ .on('error', () => {})
91
+ .unref();
92
+ } catch {
93
+ // not macOS: no sleep inhibitor
94
+ }
95
+ },
96
+ readFile: (p) => readFileSync(p, 'utf8'),
97
+ writeFile: (p, c) => {
98
+ mkdirSync(dirname(p), { recursive: true });
99
+ writeFileSync(p, c);
100
+ },
101
+ copyFile: (a, b) => {
102
+ mkdirSync(dirname(b), { recursive: true });
103
+ copyFileSync(a, b);
104
+ },
105
+ mkdir: (p) => mkdirSync(p, { recursive: true }),
106
+ rm: (p) => rmSync(p, { recursive: true, force: true }),
107
+ exists: (p) => existsSync(p),
108
+ };
109
+ }
@@ -0,0 +1,42 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { ROOT } from './io.mjs';
5
+ import { nightlyDirOf } from './record.mjs';
6
+
7
+ export function renderPlist(template, { repo, node }) {
8
+ return template.replaceAll('__REPO__', repo).replaceAll('__NODE__', node);
9
+ }
10
+
11
+ /**
12
+ * The launchd scheduler adapter (macOS only): `install` writes the plist and prints the
13
+ * commands, or returns exit code 2 with one line on any other platform.
14
+ */
15
+ export function launchd({ label, template, wake, platform = process.platform }) {
16
+ return {
17
+ name: 'launchd',
18
+ install(io, dir) {
19
+ if (platform !== 'darwin') {
20
+ console.error(
21
+ `nightly: FAIL the launchd scheduler needs macOS (this is ${platform}); set nightly.scheduler to another adapter, or call nightly run from your own scheduler`,
22
+ );
23
+ return 2;
24
+ }
25
+ const text = readFileSync(join(ROOT, template), 'utf8');
26
+ const plist = join(dir, `${label}.plist`);
27
+ io.writeFile(
28
+ plist,
29
+ renderPlist(text, { repo: io.mainCheckout, node: process.execPath }),
30
+ );
31
+ const dest = `~/Library/LaunchAgents/${label}.plist`;
32
+ console.log(`Wrote ${plist}. Nothing was loaded; run these yourself:`);
33
+ console.log(` cp ${plist} ${dest}`);
34
+ console.log(` launchctl bootstrap gui/$(id -u) ${dest}`);
35
+ console.log(` sudo pmset repeat wakeorpoweron MTWRFSU ${wake}:00`);
36
+ },
37
+ };
38
+ }
39
+
40
+ export function install(io, cfg) {
41
+ return cfg.scheduler.install(io, nightlyDirOf(io));
42
+ }
@@ -0,0 +1,231 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+
11
+ import { envName } from '../env-name.mjs';
12
+ import { mainCheckout } from '../lock.mjs';
13
+
14
+ export const H = 3_600_000;
15
+ export const FLAKY_PROMOTE = { of: 7, min: 2 };
16
+ export const FRESH_MS = 36 * H;
17
+ export const REACHED = (r) => r.reachedPlaywright === true;
18
+ export const WINDOW_MS = 7 * 24 * H;
19
+ const HISTORY_MAX = 60;
20
+
21
+ export const byRanAtDesc = (a, b) => Date.parse(b.ranAt) - Date.parse(a.ranAt);
22
+
23
+ /** One suite's view of a history line: null = the first suite (the record itself). */
24
+ export const suiteView = (record, name = null) =>
25
+ name === null ? record : (record.suites?.[name] ?? null);
26
+
27
+ const NO_DRIFT = { pathAreas: [], session: /(?!)/ };
28
+
29
+ /**
30
+ * Pages a spec visited outside the areas its `// e2e-areas:` header declares.
31
+ * navigation: { [specFile]: string[] }; headers: Map<specFile, string[]>.
32
+ * Session paths, unmapped paths and specs without a header are never drift.
33
+ */
34
+ export function mapDrift(navigation, headers, drift = NO_DRIFT) {
35
+ const areaOfPath = (pathname) => {
36
+ for (const [re, area] of drift.pathAreas)
37
+ if (re.test(pathname)) return area;
38
+ return null;
39
+ };
40
+ const out = [];
41
+ for (const [file, paths] of Object.entries(navigation ?? {})) {
42
+ const declared = headers.get(file);
43
+ if (!declared) continue;
44
+ for (const pathname of paths) {
45
+ if (drift.session.test(pathname)) continue;
46
+ const area = areaOfPath(pathname);
47
+ if (area !== null && !declared.includes(area))
48
+ out.push({ file, pathname, area });
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+
54
+ const redDrift = (drift, flakySpecs) => ({
55
+ status: 'red',
56
+ reason: 'map-drift',
57
+ redSpecs: [...new Set(drift.map((d) => d.file))].sort(),
58
+ flakySpecs,
59
+ drift,
60
+ });
61
+
62
+ /** Status of one run: error when there is no summary, red, amber, else green. */
63
+ export function classifyRun({
64
+ summary,
65
+ reason,
66
+ history,
67
+ drift = [],
68
+ suite = null,
69
+ }) {
70
+ if (summary === null || summary.stats === null)
71
+ return {
72
+ status: 'error',
73
+ reason: reason ?? 'no-report',
74
+ redSpecs: [],
75
+ flakySpecs: [],
76
+ };
77
+ const flakySpecs = summary.flakySpecs;
78
+ if (summary.stats.unexpected > 0)
79
+ return {
80
+ status: 'red',
81
+ reason: 'failed',
82
+ redSpecs: summary.failedSpecs,
83
+ flakySpecs,
84
+ };
85
+ if (drift.length > 0) return redDrift(drift, flakySpecs);
86
+ const recent = history
87
+ .map((r) => suiteView(r, suite))
88
+ .filter(Boolean)
89
+ .filter(REACHED)
90
+ .sort(byRanAtDesc)
91
+ .slice(0, FLAKY_PROMOTE.of - 1);
92
+ const promoted = flakySpecs.filter(
93
+ (spec) =>
94
+ 1 + recent.filter((r) => (r.flakySpecs ?? []).includes(spec)).length >=
95
+ FLAKY_PROMOTE.min,
96
+ );
97
+ if (promoted.length > 0)
98
+ return {
99
+ status: 'red',
100
+ reason: 'flaky-2-of-7',
101
+ redSpecs: promoted,
102
+ flakySpecs,
103
+ };
104
+ if (summary.stats.flaky > 0)
105
+ return { status: 'amber', reason: null, redSpecs: [], flakySpecs };
106
+ return { status: 'green', reason: null, redSpecs: [], flakySpecs };
107
+ }
108
+
109
+ /** Newest run that reached Playwright: { ranAt, ageMs, stale }, or null. */
110
+ export function freshness(history, nowMs) {
111
+ const newest = history.filter(REACHED).sort(byRanAtDesc)[0];
112
+ if (!newest) return null;
113
+ const ageMs = nowMs - Date.parse(newest.ranAt);
114
+ return { ranAt: newest.ranAt, ageMs, stale: ageMs > FRESH_MS };
115
+ }
116
+
117
+ /** Reads latest.json and history.jsonl (bad lines skipped; absent files -> null / []). */
118
+ export function readNightly(dir) {
119
+ const read = (name) => {
120
+ const p = join(dir, name);
121
+ return existsSync(p) ? readFileSync(p, 'utf8') : '';
122
+ };
123
+ let latest = null;
124
+ try {
125
+ latest = JSON.parse(read('latest.json'));
126
+ } catch {
127
+ latest = null;
128
+ }
129
+ const history = read('history.jsonl')
130
+ .split('\n')
131
+ .filter(Boolean)
132
+ .flatMap((l) => {
133
+ try {
134
+ return [JSON.parse(l)];
135
+ } catch {
136
+ return [];
137
+ }
138
+ });
139
+ return { latest, history };
140
+ }
141
+
142
+ export const nightlyDir = (env = process.env) =>
143
+ envName(env, 'NIGHTLY_DIR') ?? join(mainCheckout(), '.turbo', 'nightly');
144
+
145
+ export const nightlyDirOf = (io) =>
146
+ envName(process.env, 'NIGHTLY_DIR') ?? join(io.mainCheckout, '.turbo', 'nightly');
147
+
148
+ const REPORT_FILE = /^[^.].*-\d{4}-\d{2}-\d{2}\.json$/;
149
+
150
+ /**
151
+ * Per-suite retention (R5). The first suite keeps `<dir>/<sha12>-<day>.json` files;
152
+ * another suite keeps `<dir>/<name>/<sha12>-<day>/` run dirs. `keep.reports`:
153
+ * 'history' (a night a history line names), 'all', or the newest N nights;
154
+ * `keep.traces` drops the traces/ of all but the newest N nights.
155
+ */
156
+ export function retain(dir, history, suites = []) {
157
+ for (const [i, suite] of suites.entries()) {
158
+ const keep = suite.keep ?? { reports: 'history' };
159
+ if (i === 0) {
160
+ const reports = keep.reports ?? 'history';
161
+ if (reports === 'all') continue;
162
+ const order = [];
163
+ for (const l of history.slice().reverse())
164
+ if (l.report && !order.includes(l.report)) order.push(l.report);
165
+ const limit = Number.isInteger(reports) ? reports : order.length;
166
+ const kept = new Set(order.slice(0, limit));
167
+ for (const f of readdirSync(dir))
168
+ if (REPORT_FILE.test(f) && !kept.has(f))
169
+ rmSync(join(dir, f), { force: true });
170
+ continue;
171
+ }
172
+ const root = join(dir, suite.name);
173
+ if (!existsSync(root)) continue;
174
+ const nights = [];
175
+ for (const l of history) {
176
+ const r = l.suites?.[suite.name]?.report;
177
+ const n = r ? r.split('/')[1] : null;
178
+ if (n && !nights.includes(n)) nights.push(n);
179
+ }
180
+ const order = [...nights.reverse()]; // newest first
181
+ const dirs = readdirSync(root);
182
+ // A run dir no history line names is older than every named one; orphans
183
+ // rank among themselves newest first by name.
184
+ const orphans = dirs
185
+ .filter((n) => !order.includes(n))
186
+ .sort()
187
+ .reverse();
188
+ const rank = (n) =>
189
+ order.includes(n) ? order.indexOf(n) : order.length + orphans.indexOf(n);
190
+ const reports = keep.reports ?? 'history';
191
+ for (const n of dirs) {
192
+ const nightDir = join(root, n);
193
+ if (reports === 'history' && rank(n) >= order.length)
194
+ rmSync(nightDir, { recursive: true, force: true });
195
+ else if (Number.isInteger(reports) && rank(n) >= reports)
196
+ rmSync(nightDir, { recursive: true, force: true });
197
+ else if (keep.traces !== undefined && rank(n) >= keep.traces)
198
+ rmSync(join(nightDir, 'traces'), { recursive: true, force: true });
199
+ }
200
+ }
201
+ }
202
+
203
+ export function writeOutputs(
204
+ dir,
205
+ line,
206
+ redTests,
207
+ firstReport,
208
+ suiteReports = {},
209
+ suites = [],
210
+ ) {
211
+ mkdirSync(dir, { recursive: true });
212
+ if (line.report !== null)
213
+ writeFileSync(join(dir, line.report), JSON.stringify(firstReport));
214
+ for (const [name, report] of Object.entries(suiteReports)) {
215
+ const rel = line.suites?.[name]?.report;
216
+ if (!rel || report === null) continue;
217
+ mkdirSync(dirname(join(dir, rel)), { recursive: true });
218
+ writeFileSync(join(dir, rel), JSON.stringify(report));
219
+ }
220
+ const prior = readNightly(dir).history;
221
+ const history = [...prior, line].slice(-HISTORY_MAX);
222
+ writeFileSync(
223
+ join(dir, 'history.jsonl'),
224
+ `${history.map((l) => JSON.stringify(l)).join('\n')}\n`,
225
+ );
226
+ writeFileSync(
227
+ join(dir, 'latest.json'),
228
+ `${JSON.stringify({ ...line, redTests })}\n`,
229
+ );
230
+ retain(dir, history, suites);
231
+ }