@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,216 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { portBusy, portInUse, startWebServer } from '../serve.mjs';
4
+ import { applyPatch, restorePatch, sessionOf } from './harness.mjs';
5
+ import { projectSetup } from './project.mjs';
6
+ import { LIMITS, tail, verdict } from './verdict.mjs';
7
+
8
+ /** A patch claimed by a harness that defers to a slower run: built and served once per distinct patch. */
9
+ export const isServed = (m, setup = projectSetup()) =>
10
+ sessionOf(m, setup)?.defer !== undefined;
11
+
12
+ /** Twelve hex chars naming one distinct patch. */
13
+ export const patchKey = (m) =>
14
+ createHash('sha256')
15
+ .update(`${m.file}\0${m.within ?? ''}\0${m.find}\0${m.replace}`)
16
+ .digest('hex')
17
+ .slice(0, 12);
18
+
19
+ // A patch under opts.skipBuild (an e2e spec) changes no app code: it runs on the clean server.
20
+ const buildNeeded = (m, opts) => !m.file.startsWith(opts.skipBuild);
21
+
22
+ const needsWebBuild = (m, setup = projectSetup()) => {
23
+ const opts = sessionOf(m, setup)?.opts;
24
+ return opts === undefined || buildNeeded(m, opts);
25
+ };
26
+
27
+ /** Every entry of a group that could not be built or started is `error`, never `killed`. */
28
+ function groupError(group, phase, r) {
29
+ return {
30
+ results: group.entries.map((m) => ({
31
+ id: m.id,
32
+ status: 'error',
33
+ detail: `group ${group.key}: ${phase} failed: ${tail(r.out)}`,
34
+ })),
35
+ build: phase === 'build' ? 'failed' : (group.built ?? 'none'),
36
+ };
37
+ }
38
+
39
+ // Apply one patch, build, serve, run each entry's test against that one server,
40
+ // stop the server, restore the file byte-exact. Returns { results, build }.
41
+ async function runGroup(group, io, state, ctx, opts) {
42
+ const m0 = group.entries[0];
43
+ const rules = ctx.rulesFor(m0);
44
+ const needsBuild = buildNeeded(m0, opts);
45
+ let server = null;
46
+ const body = async () => {
47
+ for (const rule of rules) state[rule.flag] = true;
48
+ applyPatch([m0], io, state);
49
+ for (const rule of rules) {
50
+ const cb = await io[rule.io]();
51
+ if (cb.code !== 0) return groupError(group, 'build', cb);
52
+ }
53
+ if (needsBuild) {
54
+ const build = await io.buildWeb();
55
+ if (build.code !== 0) return groupError(group, 'build', build);
56
+ group.built = 'ok';
57
+ }
58
+ server = await io.startServer();
59
+ state.server = server;
60
+ if (!server.ok) return groupError(group, 'start', server);
61
+ const results = [];
62
+ for (const m of group.entries) {
63
+ const t = await io.run(m.test, { reuse: true });
64
+ if (server.died?.())
65
+ return groupError(group, 'start', {
66
+ out: `server died mid-group: ${tail(t.out)}`,
67
+ });
68
+ results.push(verdict(m, t));
69
+ }
70
+ return { results, build: group.built ?? 'none' };
71
+ };
72
+ let res;
73
+ try {
74
+ res = await body();
75
+ } catch (err) {
76
+ res = groupError(group, 'start', { out: String(err?.message ?? err) });
77
+ } finally {
78
+ try {
79
+ await server?.stop();
80
+ } catch {
81
+ /* best effort: the next group's port check reports a leak */
82
+ }
83
+ state.server = null;
84
+ try {
85
+ restorePatch(state, io);
86
+ } catch (err) {
87
+ const detail = `RESTORE FAILED: ${err.message}. Run ${ctx.setup.commands.restoreHint}`;
88
+ res = {
89
+ ...res,
90
+ results: group.entries.map((m) => ({
91
+ id: m.id,
92
+ status: 'error',
93
+ detail,
94
+ })),
95
+ };
96
+ }
97
+ state.restore = null;
98
+ }
99
+ for (const rule of rules) {
100
+ const b = await io[rule.io]();
101
+ if (b.code === 0) state[rule.flag] = false;
102
+ else
103
+ ctx.fatal = `${rule.cmd} failed after restoring ${m0.file}: ${rule.drift}. Run ${rule.cmd}`;
104
+ }
105
+ return res;
106
+ }
107
+
108
+ // One clean build and server prove every distinct served test passes unmutated.
109
+ async function cleanPhase(entries, io, state, opts) {
110
+ const build = await io.buildWeb();
111
+ if (build.code !== 0)
112
+ return { fatal: `clean ${opts.build} failed: ${tail(build.out)}` };
113
+ const server = await io.startServer();
114
+ state.server = server;
115
+ const base = new Map();
116
+ try {
117
+ if (!server.ok)
118
+ return {
119
+ fatal: `clean server did not start on port ${opts.port}: ${tail(server.out)}`,
120
+ };
121
+ for (const m of entries) {
122
+ if (base.has(m.test)) continue;
123
+ let r;
124
+ try {
125
+ r = await io.run(m.test, { reuse: true });
126
+ } catch (err) {
127
+ r = { code: 1, out: String(err) };
128
+ }
129
+ base.set(m.test, r);
130
+ }
131
+ } finally {
132
+ try {
133
+ await server.stop();
134
+ } catch {
135
+ /* best effort */
136
+ }
137
+ state.server = null;
138
+ }
139
+ return { base };
140
+ }
141
+
142
+ /**
143
+ * The harness for patches tested through a built and served app: one clean
144
+ * build proves the baselines, then one build and server per distinct patch.
145
+ * `opts` = { name, order, port, env, build, start, skipBuild,
146
+ * claims: RegExp, defer }; the harness exposes them as `opts`.
147
+ */
148
+ export function served(opts) {
149
+ return {
150
+ name: opts.name,
151
+ order: opts.order,
152
+ claims: (m) => m.kind === 'patch' && opts.claims.test(m.test),
153
+ defer: opts.defer,
154
+ opts,
155
+ ioMethods: ({ exec }) => ({
156
+ buildWeb: () =>
157
+ exec('bash', ['-lc', opts.build], {
158
+ limitS: LIMITS.webBuild,
159
+ env: opts.env,
160
+ }),
161
+ startServer: () => startWebServer(opts),
162
+ portInUse,
163
+ }),
164
+ async refuse(_entries, io) {
165
+ return (await io.portInUse(opts.port))
166
+ ? portBusy(opts.port)
167
+ : null;
168
+ },
169
+ async open(entries, io, state) {
170
+ if (entries.length === 0)
171
+ return { baselines: new Map(), groups: [], builds: 0 };
172
+ const clean = await cleanPhase(entries, io, state, opts);
173
+ if (clean.fatal) return { fatal: clean.fatal, groups: [], builds: 1 };
174
+ return { baselines: clean.base, builds: 1 };
175
+ },
176
+ // Groups that rebuild leave their mutated build behind, so the groups that do
177
+ // not rebuild (e2e patches) run first, on the clean phase's build.
178
+ group(entries) {
179
+ const byKey = new Map();
180
+ for (const m of entries) {
181
+ const key = patchKey(m);
182
+ if (!byKey.has(key)) byKey.set(key, { key, entries: [] });
183
+ byKey.get(key).entries.push(m);
184
+ }
185
+ const built = (g) => Number(buildNeeded(g.entries[0], opts));
186
+ return [...byKey.values()].sort((a, b) => built(a) - built(b));
187
+ },
188
+ async run(groups, io, emit, state, ctx) {
189
+ const done = [];
190
+ let builds = 0;
191
+ for (const group of groups) {
192
+ const t0 = Date.now();
193
+ const r = await runGroup(group, io, state, ctx, opts);
194
+ if (r.build !== 'none') builds++;
195
+ done.push({
196
+ key: group.key,
197
+ ids: group.entries.map((m) => m.id),
198
+ build: r.build,
199
+ ms: Date.now() - t0,
200
+ });
201
+ for (const x of r.results) emit(x, group.key);
202
+ if (ctx.fatal) break;
203
+ }
204
+ return { groups: done, builds, fatal: ctx.fatal ?? undefined };
205
+ },
206
+ async interrupt(state) {
207
+ try {
208
+ await state.server?.stop();
209
+ } catch {
210
+ /* best effort */
211
+ }
212
+ },
213
+ };
214
+ }
215
+
216
+ export { needsWebBuild, portInUse, startWebServer };
@@ -0,0 +1,86 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { ROOT } from '../root.mjs';
5
+ import { projectSetup } from './project.mjs';
6
+ import { CARET_FILTER, CARET_HINT, locate } from './registry.mjs';
7
+
8
+ /** Schema errors, one string each. [] when valid. Nothing runs before this. */
9
+ export function validateSpec(spec, readFile, setup = projectSetup()) {
10
+ const errors = [];
11
+ if (!spec || typeof spec !== 'object' || !Array.isArray(spec.mutations))
12
+ return ['file must be an object with a "mutations" array'];
13
+ if (spec.mutations.length === 0) errors.push('"mutations" is empty');
14
+ const seen = new Set();
15
+ for (const [i, m] of spec.mutations.entries()) {
16
+ const at = `mutation #${i + 1}${m?.id ? ` (${m.id})` : ''}`;
17
+ if (!m || typeof m !== 'object') {
18
+ errors.push(`${at}: not an object`);
19
+ continue;
20
+ }
21
+ if (typeof m.id !== 'string' || m.id === '') errors.push(`${at}: no id`);
22
+ else if (seen.has(m.id)) errors.push(`${at}: duplicate id ${m.id}`);
23
+ else seen.add(m.id);
24
+ if (typeof m.test !== 'string' || m.test.trim() === '')
25
+ errors.push(`${at}: "test" must be a non-empty command`);
26
+ else if (CARET_FILTER.test(m.test)) errors.push(`${at}: ${CARET_HINT}`);
27
+ const kind = setup.kinds.get(m.kind);
28
+ if (kind?.runs && kind.validate) {
29
+ errors.push(...kind.validate(m, readFile).map((e) => `${at}: ${e}`));
30
+ } else if (m.kind === 'patch') {
31
+ if (m.restore !== undefined)
32
+ errors.push(`${at}: "restore" is only for sql mutations`);
33
+ if (
34
+ typeof m.file !== 'string' ||
35
+ typeof m.find !== 'string' ||
36
+ m.find === ''
37
+ )
38
+ errors.push(`${at}: patch needs "file" and a non-empty "find"`);
39
+ else if (typeof m.replace !== 'string')
40
+ errors.push(`${at}: patch needs a string "replace" (may be empty)`);
41
+ else {
42
+ let src = null;
43
+ try {
44
+ src = readFile(m.file);
45
+ } catch {
46
+ errors.push(`${at}: file ${m.file} does not exist`);
47
+ }
48
+ if (src !== null) {
49
+ const n = locate(src, m.find, m.within).count;
50
+ if (n !== 1)
51
+ errors.push(
52
+ `${at}: "find" occurs ${n} times in ${m.file}, must be exactly 1 (stale or ambiguous mutation: update "find")`,
53
+ );
54
+ }
55
+ }
56
+ } else {
57
+ const names = [...setup.kinds.values()]
58
+ .filter((k) => k.runs && k.name !== 'patch')
59
+ .map((k) => k.name)
60
+ .concat('patch');
61
+ errors.push(
62
+ `${at}: kind must be ${names.map((n) => `"${n}"`).join(' or ')}`,
63
+ );
64
+ }
65
+ }
66
+ return errors;
67
+ }
68
+
69
+ /** Reads and validates; throws with every error listed. */
70
+ export function loadSpec(
71
+ path,
72
+ readFile = (p) => readFileSync(join(ROOT, p), 'utf8'),
73
+ ) {
74
+ let spec;
75
+ try {
76
+ spec = JSON.parse(readFileSync(path, 'utf8'));
77
+ } catch (err) {
78
+ throw new Error(`cannot read ${path}: ${err.message}`);
79
+ }
80
+ const errors = validateSpec(spec, readFile);
81
+ if (errors.length)
82
+ throw new Error(
83
+ `${path} is invalid:\n - ${errors.join('\n - ')}\nFix the mutations file; nothing was run.`,
84
+ );
85
+ return spec;
86
+ }
@@ -0,0 +1,159 @@
1
+ const ANSI = /\x1b\[[0-9;]*m/g;
2
+ const VITEST_NO_FILES = /^No test files found, exiting with code \d+$/m;
3
+ const JEST_NO_TESTS = /^No tests found, exiting with code \d+$/m;
4
+ const count = (line, word) =>
5
+ Number(line.match(new RegExp(`(\\d+) ${word}`))?.[1] ?? 0);
6
+ const lastMatch = (text, re) => [...text.matchAll(re)].at(-1);
7
+
8
+ const PW_NO_TESTS = /^Error: No tests found\.?$/m;
9
+ // prettier-ignore
10
+ const PW_NO_SERVER = /Process from config\.webServer (?:was not able to start|exited early)|Timed out waiting \d+ms from config\.webServer/;
11
+
12
+ // Playwright: the last block of " <n> passed|failed|flaky|skipped|did not run|interrupted" lines.
13
+ function playwrightSummary(text) {
14
+ const re =
15
+ /^ {2}(\d+) (passed|failed|flaky|skipped|did not run|interrupted)\b.*$/;
16
+ let block = [];
17
+ let prev = false;
18
+ for (const l of text.split('\n')) {
19
+ const m = l.match(re);
20
+ if (/^ {4}/.test(l)) continue; // a failed test's header sits inside the block
21
+ if (m) {
22
+ if (!prev) block = [];
23
+ block.push({ n: Number(m[1]), kind: m[2], line: l.trim() });
24
+ prev = true;
25
+ } else prev = false;
26
+ }
27
+ if (!block.length) return null;
28
+ const ran = block
29
+ .filter((b) => b.kind !== 'skipped' && b.kind !== 'did not run')
30
+ .reduce((a, b) => a + b.n, 0);
31
+ return { line: block.map((b) => b.line).join(', '), ran };
32
+ }
33
+
34
+ function vitestSummary(text) {
35
+ const m = lastMatch(text, /^ +(Tests {2}(.+))$/gm);
36
+ if (!m) return null;
37
+ return {
38
+ line: m[1].trim(),
39
+ passed: count(m[2], 'passed'),
40
+ failed: count(m[2], 'failed'),
41
+ filesFailed: /^ +Test Files {2}.*\bfailed\b/m.test(text),
42
+ };
43
+ }
44
+
45
+ function jestSummary(text) {
46
+ const m = lastMatch(text, /^(Tests: +(.+))$/gm);
47
+ if (!m) return null;
48
+ return {
49
+ line: m[1].trim(),
50
+ passed: count(m[2], 'passed'),
51
+ failed: count(m[2], 'failed'),
52
+ suitesFailed: /^Test Suites:.*\bfailed\b/m.test(text),
53
+ };
54
+ }
55
+
56
+ // node --test: the last summary block (spec "ℹ tests N" or TAP "# tests N").
57
+ // With process isolation a filter that matched nothing reports one passing
58
+ // "test" named after the file; those top-level file-only passes do not count.
59
+ function nodeSummary(text, cmd) {
60
+ const block = { tests: null, pass: 0, fail: 0, cancelled: 0 };
61
+ let line = null;
62
+ for (const m of text.matchAll(
63
+ /^[ℹ#] (tests|pass|fail|cancelled) (\d+)\s*$/gm,
64
+ )) {
65
+ if (m[1] === 'tests') {
66
+ Object.assign(block, { pass: 0, fail: 0, cancelled: 0 });
67
+ line = m[0].trim();
68
+ }
69
+ block[m[1]] = Number(m[2]);
70
+ }
71
+ if (line === null) return null;
72
+ const tokens = new Set(
73
+ cmd.split(/\s+/).map((t) => t.replace(/^['"]|['"]$/g, '')),
74
+ );
75
+ let fileOnly = 0;
76
+ for (const m of text.matchAll(
77
+ /^(?:✔ (.+?) \(\d+(?:\.\d+)?ms\)|ok \d+ - (.+?))\s*$/gm,
78
+ ))
79
+ if (tokens.has(m[1] ?? m[2])) fileOnly++;
80
+ return { ...block, line: line.replace(/^[ℹ#] /, ''), fileOnly };
81
+ }
82
+
83
+ /**
84
+ * Did this test output run zero tests? A reason string ('<runner>: <summary>')
85
+ * or null. Positive per runner; output with no recognised summary (shell
86
+ * `true`, grep, playwright) is null and classified by exit code. A run that
87
+ * reports a failure is never zero: a load failure is a kill. `extra` holds the
88
+ * harness parsers (cmd, out) -> reason | null, tried after the built-in ones.
89
+ */
90
+ // prettier-ignore
91
+ export function zeroTestRun(cmd, out, extra = []) {
92
+ out = String(out ?? '');
93
+ const text = out.replace(ANSI, '');
94
+ // A failing node --test run can echo other runners' summaries in an assertion diff.
95
+ const n = nodeSummary(text, cmd);
96
+ if (n && n.fail + n.cancelled > 0) return null;
97
+ if (VITEST_NO_FILES.test(text)) return 'vitest: No test files found';
98
+ const v = vitestSummary(text);
99
+ if (v && v.passed + v.failed === 0 && !v.filesFailed) return `vitest: ${v.line}`;
100
+ if (JEST_NO_TESTS.test(text)) return 'jest: No tests found';
101
+ const j = jestSummary(text);
102
+ if (j && j.passed + j.failed === 0 && !j.suitesFailed) return `jest: ${j.line}`;
103
+ if (n && n.pass - n.fileOnly <= 0 && n.fail + n.cancelled === 0) return `node --test: ${n.line}`;
104
+ if (PW_NO_TESTS.test(text)) return 'playwright: No tests found';
105
+ if (PW_NO_SERVER.test(text)) return 'playwright: webServer did not start';
106
+ const pw = playwrightSummary(text);
107
+ if (pw && pw.ran === 0) return `playwright: ${pw.line}`;
108
+ const extraHit = extra.map((z) => z(cmd, out)).find((r) => r != null);
109
+ return extraHit ?? null;
110
+ }
111
+
112
+ function zeroResult(m, why, when) {
113
+ const fix =
114
+ when === 'before the mutation'
115
+ ? 'fix the "test" filter or path; it selects nothing'
116
+ : 'neither killed nor survived; check the filter';
117
+ return {
118
+ id: m.id,
119
+ status: 'error',
120
+ detail: `ran zero tests ${when} (${why}): ${fix}`,
121
+ };
122
+ }
123
+
124
+ /** Per-child limits in seconds (R10): a child past its limit is an `error`, never a kill. */
125
+ export const LIMITS = {
126
+ run: 900,
127
+ reset: 300,
128
+ build: 300,
129
+ sql: 120,
130
+ probe: 10,
131
+ webBuild: 600,
132
+ serve: 120,
133
+ };
134
+
135
+ function timedOutResult(m, t) {
136
+ return {
137
+ id: m.id,
138
+ status: 'error',
139
+ detail: `TIMEOUT after ${t.limitS ?? LIMITS.run} s: ${m.test}`,
140
+ };
141
+ }
142
+
143
+ // Killed, survived, or (zero tests ran) error; zero is checked before the exit code.
144
+ function verdict(m, t, extra = []) {
145
+ if (t.timedOut === true) return timedOutResult(m, t);
146
+ const zero = zeroTestRun(m.test, t.out, extra);
147
+ if (zero) return zeroResult(m, zero, 'under the mutation');
148
+ return t.code !== 0
149
+ ? { id: m.id, status: 'killed', detail: tail(t.out) }
150
+ : {
151
+ id: m.id,
152
+ status: 'survived',
153
+ detail: `the test still passes with the guard removed (${m.protects ?? m.id}): it does not bite; strengthen the test, then re-run`,
154
+ };
155
+ }
156
+
157
+ const tail = (out) => out.trim().split('\n').slice(-5).join('\n');
158
+
159
+ export { tail, verdict, zeroResult };
@@ -0,0 +1,30 @@
1
+ // `ticket-gate nightly run [--now] | install | status`: the sub-command switch over the runner,
2
+ // the scheduler install and the status printer, with the io the config's payloads add.
3
+ import { loadRegistry } from '../mutations/registry.mjs';
4
+ import { realIo } from './io.mjs';
5
+ import { main as runMain } from './run.mjs';
6
+ import { main as statusMain } from './status.mjs';
7
+
8
+ export const NIGHTLY_USAGE = 'usage: ticket-gate nightly run [--now] | install | status';
9
+
10
+ /** The runner's io: the real one, the registry loader, then every payload's `io` entries. */
11
+ export function nightlyIo(cfg) {
12
+ return {
13
+ ...realIo(),
14
+ registry: cfg.rotate?.registry ?? ((root) => loadRegistry(root)),
15
+ ...Object.assign({}, ...cfg.payloads.map((p) => p.io ?? {})),
16
+ };
17
+ }
18
+
19
+ /** Run one sub-command; resolves to the exit code. */
20
+ export async function nightlyMain([sub, ...rest], cfg) {
21
+ if (sub === 'run' && !rest.includes('--install')) return runMain(rest, cfg, nightlyIo(cfg));
22
+ if (sub === 'install' && rest.length === 0 && !cfg.scheduler) {
23
+ console.error('nightly: FAIL install needs nightly.scheduler in gate.config.mjs');
24
+ return 2;
25
+ }
26
+ if (sub === 'install' && rest.length === 0) return runMain(['--install'], cfg, nightlyIo(cfg));
27
+ if (sub === 'status') return statusMain(rest, cfg);
28
+ console.error(NIGHTLY_USAGE);
29
+ return 2;
30
+ }
@@ -0,0 +1,111 @@
1
+ // The nightly's one config object. `defineNightly` fills the defaults
2
+ // and rejects a config the runner cannot honour; the engine reads nothing else
3
+ // about the project. Dependency-free Node ESM.
4
+
5
+ export const NIGHTLY_DEFAULTS = Object.freeze({
6
+ window: { start: '00:50', end: '06:00', catchUpBy: '00:30' },
7
+ remote: { name: 'origin', branch: 'main' },
8
+ payloads: [],
9
+ keep: { reports: 'history' },
10
+ selects: { interim: () => false, load: null },
11
+ commands: {
12
+ run: 'ticket-gate nightly run',
13
+ install: 'ticket-gate nightly install',
14
+ status: 'ticket-gate nightly status',
15
+ },
16
+ });
17
+
18
+ const bad = (reason) => {
19
+ throw new Error(`nightly config: ${reason}`);
20
+ };
21
+ const posInt = (n) => Number.isInteger(n) && n > 0;
22
+
23
+ function checkKeep(keep, where) {
24
+ if (keep.reports !== undefined) {
25
+ const r = keep.reports;
26
+ if (r !== 'all' && r !== 'history' && !posInt(r))
27
+ bad(
28
+ `${where} keep.reports must be 'all', 'history' or a positive integer`,
29
+ );
30
+ }
31
+ if (keep.traces !== undefined && !posInt(keep.traces))
32
+ bad(`${where} keep.traces must be a positive integer`);
33
+ }
34
+
35
+ function checkSuites(suites) {
36
+ if (!Array.isArray(suites) || suites.length === 0)
37
+ bad('suites must list at least one suite');
38
+ const seen = new Set();
39
+ for (const [i, s] of suites.entries()) {
40
+ if (typeof s?.name !== 'string' || s.name === '')
41
+ bad(`suite ${i} has no name`);
42
+ if (seen.has(s.name)) bad(`duplicate suite name '${s.name}'`);
43
+ seen.add(s.name);
44
+ if (typeof s.runner?.run !== 'function')
45
+ bad(`suite '${s.name}' has no runner.run`);
46
+ if (typeof s.limitS !== 'number' || !(s.limitS > 0))
47
+ bad(`suite '${s.name}' limitS must be greater than 0`);
48
+ if (i > 0 && typeof s.runner.statusLine !== 'function')
49
+ bad(
50
+ `suite '${s.name}' needs runner.statusLine (only the first suite may omit it)`,
51
+ );
52
+ checkKeep(s.keep ?? {}, `suite '${s.name}'`);
53
+ if (i === 0 && s.keep?.traces !== undefined)
54
+ bad(`suite '${s.name}' is the first suite and keeps no traces dirs`);
55
+ }
56
+ }
57
+
58
+ function checkPayloads(payloads) {
59
+ const seen = new Set();
60
+ for (const p of payloads) {
61
+ if (
62
+ typeof p?.name !== 'string' ||
63
+ p.name === '' ||
64
+ typeof p.preflight !== 'function'
65
+ )
66
+ bad('a payload needs a name and a preflight function');
67
+ if (seen.has(p.name)) bad(`duplicate payload name '${p.name}'`);
68
+ seen.add(p.name);
69
+ }
70
+ }
71
+
72
+ /** Fill defaults, validate, freeze. Idempotent. */
73
+ export function defineNightly(cfg) {
74
+ const d = NIGHTLY_DEFAULTS;
75
+ const out = {
76
+ ...cfg,
77
+ window: { ...d.window, ...cfg.window },
78
+ remote: { ...d.remote, ...cfg.remote },
79
+ payloads: cfg.payloads ?? [],
80
+ keep: { ...d.keep, ...cfg.keep },
81
+ selects: { ...d.selects, ...cfg.selects },
82
+ commands: { ...d.commands, ...cfg.commands },
83
+ };
84
+ checkKeep(out.keep, 'config');
85
+ checkSuites(out.suites);
86
+ checkPayloads(out.payloads);
87
+ const w = out.worktree;
88
+ if (
89
+ typeof w?.dir !== 'string' ||
90
+ w.dir === '' ||
91
+ !Array.isArray(w.install) ||
92
+ w.install.length === 0
93
+ )
94
+ bad('worktree needs dir and install');
95
+ // rotate is optional (no rotation runs without it) but never half-formed.
96
+ if (
97
+ out.rotate !== undefined &&
98
+ (!Array.isArray(out.rotate.command) || out.rotate.command.length === 0)
99
+ )
100
+ bad('rotate needs command');
101
+ // The top-level keep is the default for a suite that names none of its own;
102
+ // the first suite keeps no traces dirs, so it inherits reports only.
103
+ out.suites = out.suites.map((s, i) => ({
104
+ ...s,
105
+ keep:
106
+ i === 0
107
+ ? { reports: out.keep.reports, ...s.keep }
108
+ : { ...out.keep, ...s.keep },
109
+ }));
110
+ return Object.freeze(out);
111
+ }
@@ -0,0 +1,73 @@
1
+ // The gate's own e2e runs, one JSON line each, beside the nightly record. The nightly
2
+ // status reads them to say how much of the last two weeks the gate spent in a forced
3
+ // full run. Dependency-free Node ESM (`node:` only).
4
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ export const E2E_RUNS_FILE = 'e2e-runs.jsonl';
8
+ export const E2E_RUNS_MAX = 1000;
9
+ export const FORCED_WINDOW_MS = 14 * 24 * 3_600_000;
10
+ export const FORCED_MAX = 0.25;
11
+
12
+ /** The e2e step's line for one gate run, or null when it did not pass or fail. */
13
+ export function e2eRunOf(results, id, ranAt) {
14
+ const r = results.find(
15
+ (x) =>
16
+ x.id === id &&
17
+ ['pass', 'fail'].includes(x.status) &&
18
+ typeof x.mode === 'string',
19
+ );
20
+ return r ? { ranAt, ms: r.ms, mode: r.mode, why: r.why ?? null } : null;
21
+ }
22
+
23
+ /** Every parsable line of `<dir>/e2e-runs.jsonl`; a missing file is []. */
24
+ export function readE2eRuns(dir) {
25
+ let text;
26
+ try {
27
+ text = readFileSync(join(dir, E2E_RUNS_FILE), 'utf8');
28
+ } catch {
29
+ return [];
30
+ }
31
+ const out = [];
32
+ for (const line of text.split('\n')) {
33
+ if (line.trim() === '') continue;
34
+ try {
35
+ out.push(JSON.parse(line));
36
+ } catch {
37
+ // an unparsable line is skipped
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /** Append `line`, keeping the newest `max` lines. */
44
+ export function appendE2eRun(dir, line, max = E2E_RUNS_MAX) {
45
+ mkdirSync(dir, { recursive: true });
46
+ const lines = [...readE2eRuns(dir), line];
47
+ writeFileSync(
48
+ join(dir, E2E_RUNS_FILE),
49
+ `${lines
50
+ .slice(-max)
51
+ .map((l) => JSON.stringify(l))
52
+ .join('\n')}\n`,
53
+ );
54
+ }
55
+
56
+ /** Share of the last 14 days' gate e2e time spent in a forced full run. */
57
+ export function forcedShare(runs, nowMs) {
58
+ const recent = runs.filter(
59
+ (r) =>
60
+ Number.isFinite(r.ms) && nowMs - Date.parse(r.ranAt) <= FORCED_WINDOW_MS,
61
+ );
62
+ const totalMs = recent.reduce((a, r) => a + r.ms, 0);
63
+ if (totalMs === 0) return { share: null, totalMs: 0 };
64
+ const forced = recent
65
+ .filter(
66
+ (r) =>
67
+ r.mode === 'full' &&
68
+ typeof r.why === 'string' &&
69
+ r.why.startsWith('nightly '),
70
+ )
71
+ .reduce((a, r) => a + r.ms, 0);
72
+ return { share: forced / totalMs, totalMs };
73
+ }