@ticket-gate/engine 0.0.0-stage → 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.
- package/LICENSE +202 -0
- package/README.md +81 -2
- package/bin/ticket-gate.mjs +261 -0
- package/checks/assertions-unguarded.mjs +31 -0
- package/checks/assertions.mjs +319 -0
- package/checks/cache-report.mjs +313 -0
- package/checks/docs-stale.mjs +69 -0
- package/checks/duplication.mjs +258 -0
- package/checks/ratchet-options.mjs +51 -0
- package/checks/surface.mjs +397 -0
- package/cli.mjs +379 -0
- package/commands-config.mjs +55 -0
- package/config.mjs +597 -0
- package/digest.mjs +195 -0
- package/env-name.mjs +24 -0
- package/failures.mjs +256 -0
- package/fu.mjs +458 -0
- package/graph/js.mjs +517 -0
- package/graph/load-time.mjs +309 -0
- package/graph/typescript.mjs +28 -0
- package/hooks/pre-tool-use.mjs +213 -0
- package/hooks/stop.mjs +495 -0
- package/is-main.mjs +15 -0
- package/kill-group.mjs +13 -0
- package/ledger.mjs +186 -0
- package/lock.d.mts +72 -0
- package/lock.mjs +329 -0
- package/move-proof.mjs +206 -0
- package/mutations/check.mjs +506 -0
- package/mutations/cli.mjs +361 -0
- package/mutations/exec.mjs +120 -0
- package/mutations/gate.mjs +148 -0
- package/mutations/harness.mjs +240 -0
- package/mutations/kinds.mjs +148 -0
- package/mutations/locate.mjs +71 -0
- package/mutations/project.mjs +23 -0
- package/mutations/registry.mjs +173 -0
- package/mutations/rotate.mjs +419 -0
- package/mutations/rotation-status.mjs +93 -0
- package/mutations/run.mjs +252 -0
- package/mutations/select.mjs +333 -0
- package/mutations/served.mjs +216 -0
- package/mutations/validate.mjs +86 -0
- package/mutations/verdict.mjs +159 -0
- package/nightly/cli.mjs +30 -0
- package/nightly/define.mjs +111 -0
- package/nightly/e2e-runs.mjs +73 -0
- package/nightly/gate.mjs +25 -0
- package/nightly/io.mjs +109 -0
- package/nightly/launchd.mjs +42 -0
- package/nightly/record.mjs +231 -0
- package/nightly/run.mjs +470 -0
- package/nightly/schedule.mjs +76 -0
- package/nightly/seed.mjs +78 -0
- package/nightly/status.mjs +189 -0
- package/nightly/suites.mjs +43 -0
- package/nightly-config.mjs +38 -0
- package/nightly-window.mjs +6 -0
- package/pack-hooks-all.mjs +15 -0
- package/pack-hooks.mjs +26 -0
- package/package.json +19 -4
- package/plan.mjs +272 -0
- package/precheck.mjs +90 -0
- package/project.mjs +21 -0
- package/ratchet.mjs +431 -0
- package/receipt.mjs +34 -0
- package/review-map.mjs +432 -0
- package/root.mjs +33 -0
- package/run.mjs +291 -0
- package/runners/playwright.mjs +132 -0
- package/score/adapters/stryker.mjs +111 -0
- package/score/base.mjs +82 -0
- package/score/cli.mjs +182 -0
- package/score/judge.mjs +107 -0
- package/score/lines.mjs +40 -0
- package/score/schema.mjs +68 -0
- package/score/survivors.mjs +103 -0
- package/score/targets.mjs +34 -0
- package/score-config.mjs +120 -0
- package/selection/check.mjs +45 -0
- package/selection/cli.mjs +267 -0
- package/selection/define.mjs +55 -0
- package/selection/for.mjs +235 -0
- package/selection/graphs.mjs +38 -0
- package/selection/project.mjs +11 -0
- package/selection/select.mjs +159 -0
- package/selection/units.mjs +646 -0
- package/selection/value-rules.mjs +142 -0
- package/selection-config.mjs +76 -0
- package/serve.mjs +142 -0
- package/summary.mjs +34 -0
- package/tree.mjs +211 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import { isAlive, lockPath } from '../lock.mjs';
|
|
4
|
+
import { NIGHTLY_DEFAULTS } from './define.mjs';
|
|
5
|
+
import { FORCED_MAX, forcedShare, readE2eRuns } from './e2e-runs.mjs';
|
|
6
|
+
import {
|
|
7
|
+
H,
|
|
8
|
+
REACHED,
|
|
9
|
+
WINDOW_MS,
|
|
10
|
+
freshness,
|
|
11
|
+
nightlyDir,
|
|
12
|
+
readNightly,
|
|
13
|
+
suiteView,
|
|
14
|
+
} from './record.mjs';
|
|
15
|
+
import { mutationLines } from '../mutations/rotation-status.mjs';
|
|
16
|
+
import { SEED_CAP, fixSeed, realGit, short } from './seed.mjs';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_CFG = {
|
|
19
|
+
...NIGHTLY_DEFAULTS,
|
|
20
|
+
suites: [{ name: '' }],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const localDay = (ms) => new Date(ms).toLocaleDateString('en-CA');
|
|
24
|
+
|
|
25
|
+
/** The two "Move to CI" triggers over the last 7 days. */
|
|
26
|
+
export function moveToCi({
|
|
27
|
+
history,
|
|
28
|
+
nowMs,
|
|
29
|
+
queued,
|
|
30
|
+
suite = null,
|
|
31
|
+
e2eRuns = [],
|
|
32
|
+
}) {
|
|
33
|
+
const recent = history.filter(
|
|
34
|
+
(r) => nowMs - Date.parse(r.ranAt) <= WINDOW_MS,
|
|
35
|
+
);
|
|
36
|
+
// a top-level skipped-busy night counts for every suite
|
|
37
|
+
const busyOf = (r) =>
|
|
38
|
+
r.status === 'skipped-busy' || suiteView(r, suite)?.reason === 'cut-short';
|
|
39
|
+
const maxWallMs = Math.max(
|
|
40
|
+
0,
|
|
41
|
+
...recent
|
|
42
|
+
.map((r) => suiteView(r, suite))
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.filter(REACHED)
|
|
45
|
+
.map((v) => (suite === null ? (v.firstMs ?? v.ms) : v.ms) ?? 0),
|
|
46
|
+
);
|
|
47
|
+
const busy = new Set(
|
|
48
|
+
recent.filter(busyOf).map((r) => localDay(Date.parse(r.ranAt))),
|
|
49
|
+
);
|
|
50
|
+
const busyNights = busy.size;
|
|
51
|
+
const { share, totalMs } = forcedShare(e2eRuns, nowMs);
|
|
52
|
+
return {
|
|
53
|
+
t1: {
|
|
54
|
+
maxWallMs,
|
|
55
|
+
busyNights,
|
|
56
|
+
met: maxWallMs > 4 * H || busyNights >= 2,
|
|
57
|
+
},
|
|
58
|
+
t2: {
|
|
59
|
+
forcedShare: share,
|
|
60
|
+
totalMs,
|
|
61
|
+
queued,
|
|
62
|
+
met: queued >= 3 || (share !== null && share > FORCED_MAX),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function headline(latest, fresh, cfg) {
|
|
68
|
+
if (!latest) return `no nightly runs on record (${cfg.commands.run} --now)`;
|
|
69
|
+
const s = latest.stats;
|
|
70
|
+
const counts = s
|
|
71
|
+
? ` ${s.expected} passed, ${s.unexpected} failed, ${s.flaky} flaky`
|
|
72
|
+
: '';
|
|
73
|
+
let text = `${latest.status} ${short(latest.sha)} ${latest.ranAt}${counts}`;
|
|
74
|
+
if (latest.reason) text += ` reason ${latest.reason}`;
|
|
75
|
+
if (latest.status === 'red' && latest.redSpecs?.length)
|
|
76
|
+
text += ` specs ${latest.redSpecs.join(', ')}`;
|
|
77
|
+
if (latest.status === 'amber' && latest.flakySpecs?.length)
|
|
78
|
+
text += ` flaky ${latest.flakySpecs.join(', ')}`;
|
|
79
|
+
if (fresh === null) text += ' [no run has reached Playwright]';
|
|
80
|
+
else if (fresh.stale)
|
|
81
|
+
text += ` [STALE: last full run ${Math.round(fresh.ageMs / H)} h ago]`;
|
|
82
|
+
return text;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function seedLines(seed) {
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const t of seed.redTests)
|
|
88
|
+
out.push(
|
|
89
|
+
` red: ${t.file} > ${t.title} [${t.project}]: ${String(t.error).split('\n')[0]}`,
|
|
90
|
+
);
|
|
91
|
+
out.push(
|
|
92
|
+
seed.lastGreenSha
|
|
93
|
+
? `fix seed: merges since last green ${short(seed.lastGreenSha)} (suspects first)`
|
|
94
|
+
: `fix seed: no green run on record; the newest ${SEED_CAP} merges (suspects first)`,
|
|
95
|
+
);
|
|
96
|
+
for (const m of seed.merges)
|
|
97
|
+
out.push(` ${m.suspect ? '*' : ' '} ${short(m.sha)} ${m.subject}`);
|
|
98
|
+
out.push(' next: branch fix/nightly-<date> seeded from the lines above');
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Lines to print; [0] is the one-line status. */
|
|
103
|
+
export function statusLines(
|
|
104
|
+
{ latest, history, queued, e2eRuns = [] },
|
|
105
|
+
nowMs,
|
|
106
|
+
git = realGit,
|
|
107
|
+
selects = undefined,
|
|
108
|
+
cfg = DEFAULT_CFG,
|
|
109
|
+
) {
|
|
110
|
+
selects ??= cfg.selects.interim;
|
|
111
|
+
const fresh = freshness(history, nowMs);
|
|
112
|
+
const lines = [headline(latest, fresh, cfg)];
|
|
113
|
+
for (const suite of cfg.suites.slice(1)) {
|
|
114
|
+
const view = latest ? suiteView(latest, suite.name) : null;
|
|
115
|
+
if (view) lines.push(suite.runner.statusLine(view));
|
|
116
|
+
}
|
|
117
|
+
for (const d of latest?.drift ?? [])
|
|
118
|
+
lines.push(
|
|
119
|
+
` drift: ${d.file} visited ${d.pathname} (${d.area}) not in its header`,
|
|
120
|
+
);
|
|
121
|
+
if (latest?.status === 'red') {
|
|
122
|
+
try {
|
|
123
|
+
lines.push(...seedLines(fixSeed({ latest, history, git, selects })));
|
|
124
|
+
} catch (e) {
|
|
125
|
+
lines.push(`fix seed unavailable: ${e.message}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const m = moveToCi({ history, nowMs, queued, e2eRuns });
|
|
129
|
+
const t1Line = (t1, label) =>
|
|
130
|
+
`move-to-ci 1${label}: max wall ${(t1.maxWallMs / H).toFixed(1)} h, busy nights ${t1.busyNights} of last 7 (${t1.met ? 'MET' : 'not met'}; move at over 4 h or 2 nights)`;
|
|
131
|
+
lines.push(t1Line(m.t1, ''));
|
|
132
|
+
for (const suite of cfg.suites.slice(1))
|
|
133
|
+
lines.push(
|
|
134
|
+
t1Line(
|
|
135
|
+
moveToCi({ history, nowMs, queued, e2eRuns, suite: suite.name }).t1,
|
|
136
|
+
` (${suite.name})`,
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
const share2 =
|
|
140
|
+
m.t2.forcedShare === null
|
|
141
|
+
? 'n/a (no gate e2e runs in 14 days)'
|
|
142
|
+
: `${Math.round(m.t2.forcedShare * 100)}% of ${(m.t2.totalMs / H).toFixed(1)} h in 14 days`;
|
|
143
|
+
lines.push(
|
|
144
|
+
`move-to-ci 2: forced-full share ${share2}, queued branches ${m.t2.queued} (${m.t2.met ? 'MET' : 'not met'}; move at over 25% or 3 or more)`,
|
|
145
|
+
);
|
|
146
|
+
lines.push(...mutationLines(latest, history, nowMs, git));
|
|
147
|
+
return lines;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Live waiting gates: <lock>.waiters/<pid>.json whose pid is alive. */
|
|
151
|
+
export function queuedCount() {
|
|
152
|
+
try {
|
|
153
|
+
const dir = `${lockPath()}.waiters`;
|
|
154
|
+
return readdirSync(dir).filter((f) => isAlive(Number.parseInt(f, 10)))
|
|
155
|
+
.length;
|
|
156
|
+
} catch {
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const EXIT = { green: 0, amber: 0, red: 1 };
|
|
162
|
+
|
|
163
|
+
export async function main(argv, cfg) {
|
|
164
|
+
if (argv.length > 0) {
|
|
165
|
+
console.error(
|
|
166
|
+
`nightly:status takes no arguments (got ${argv.join(' ')}). Run: ${cfg.commands.status}`,
|
|
167
|
+
);
|
|
168
|
+
return 2;
|
|
169
|
+
}
|
|
170
|
+
const { latest, history } = readNightly(nightlyDir());
|
|
171
|
+
const selects =
|
|
172
|
+
latest?.status === 'red'
|
|
173
|
+
? ((await cfg.selects.load?.()) ?? undefined)
|
|
174
|
+
: undefined;
|
|
175
|
+
for (const l of statusLines(
|
|
176
|
+
{
|
|
177
|
+
latest,
|
|
178
|
+
history,
|
|
179
|
+
queued: queuedCount(),
|
|
180
|
+
e2eRuns: readE2eRuns(nightlyDir()),
|
|
181
|
+
},
|
|
182
|
+
Date.now(),
|
|
183
|
+
undefined,
|
|
184
|
+
selects,
|
|
185
|
+
cfg,
|
|
186
|
+
))
|
|
187
|
+
console.log(l);
|
|
188
|
+
return latest ? (EXIT[latest.status] ?? 3) : 3;
|
|
189
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The Playwright suite as a runner (the block that lived in run.mjs).
|
|
2
|
+
import { summarizeReport } from '../runners/playwright.mjs';
|
|
3
|
+
|
|
4
|
+
/** playwrightSuite({ cmd }) -> runner: `cmd` + the suite's args + a json reporter, run in the worktree. */
|
|
5
|
+
export function playwrightSuite({ cmd }) {
|
|
6
|
+
return {
|
|
7
|
+
name: 'playwright',
|
|
8
|
+
async run({ wt, io, args = [], limitS, env = {}, out }) {
|
|
9
|
+
io.rm(out);
|
|
10
|
+
const pw = await io.run(
|
|
11
|
+
cmd[0],
|
|
12
|
+
[...cmd.slice(1), ...args, '--reporter=json,list'],
|
|
13
|
+
{
|
|
14
|
+
cwd: wt,
|
|
15
|
+
env: { ...env, PLAYWRIGHT_JSON_OUTPUT_NAME: out },
|
|
16
|
+
limitS,
|
|
17
|
+
},
|
|
18
|
+
);
|
|
19
|
+
let raw = null;
|
|
20
|
+
if (io.exists(out)) {
|
|
21
|
+
try {
|
|
22
|
+
raw = JSON.parse(io.readFile(out));
|
|
23
|
+
} catch {
|
|
24
|
+
raw = null;
|
|
25
|
+
}
|
|
26
|
+
io.rm(out);
|
|
27
|
+
}
|
|
28
|
+
if (pw.timedOut) return { timedOut: true, summary: null, report: null };
|
|
29
|
+
const summary = raw === null ? null : summarizeReport(raw);
|
|
30
|
+
const report =
|
|
31
|
+
summary === null
|
|
32
|
+
? null
|
|
33
|
+
: {
|
|
34
|
+
playwright: raw,
|
|
35
|
+
// [{ file, pathnames }]: what each spec visited (the navigation recorder)
|
|
36
|
+
navigation: Object.entries(summary.navigation).map(
|
|
37
|
+
([file, pathnames]) => ({ file, pathnames }),
|
|
38
|
+
),
|
|
39
|
+
};
|
|
40
|
+
return { timedOut: false, summary, report };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The validated `nightly` key of gate.config.mjs: the nightly's one config object with the
|
|
2
|
+
// payloads the packs contribute (their `nightlyPayloads` hook) added after the project's own.
|
|
3
|
+
// Imports the collection half of the pack hooks only, so config.mjs may import this file.
|
|
4
|
+
import { packHooksAll } from './pack-hooks-all.mjs';
|
|
5
|
+
import { defineNightly } from './nightly/define.mjs';
|
|
6
|
+
|
|
7
|
+
/** The pack hooks that feed the nightly. */
|
|
8
|
+
export const NIGHTLY_HOOKS = Object.freeze(['nightlyPayloads']);
|
|
9
|
+
|
|
10
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
11
|
+
|
|
12
|
+
/** The nightly of a config (`defineNightly` output); a bad one fails as `gate.config: nightly: <reason>`. */
|
|
13
|
+
export function nightlyOf(config, fail) {
|
|
14
|
+
if (!isObject(config.nightly)) fail('nightly', 'must be an object');
|
|
15
|
+
const payloads = [];
|
|
16
|
+
const seen = new Map();
|
|
17
|
+
const add = (by, given) => {
|
|
18
|
+
for (const p of [].concat(given)) {
|
|
19
|
+
if (payloads.includes(p)) continue;
|
|
20
|
+
if (typeof p?.name !== 'string' || p.name === '')
|
|
21
|
+
fail('nightly', `payload from ${by} must be an object with a string name`);
|
|
22
|
+
if (seen.has(p.name)) fail('nightly', `payload "${p.name}" is given by ${seen.get(p.name)} and ${by}`);
|
|
23
|
+
seen.set(p.name, by);
|
|
24
|
+
payloads.push(p);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const own = config.nightly.payloads ?? [];
|
|
28
|
+
if (!Array.isArray(own)) fail('nightly', 'payloads must be an array');
|
|
29
|
+
for (const p of own) add('gate.config', p);
|
|
30
|
+
for (const { by, value } of packHooksAll('nightlyPayloads', config)) add(by, value);
|
|
31
|
+
try {
|
|
32
|
+
return defineNightly({ ...config.nightly, payloads });
|
|
33
|
+
} catch (e) {
|
|
34
|
+
const m = /^nightly config: (.*)$/s.exec(e.message);
|
|
35
|
+
if (m) fail('nightly', m[1]);
|
|
36
|
+
throw e;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The nightly record an affected run may lean on. Import-free so the selection planner can load
|
|
2
|
+
// it while the gate config is still loading (digest.mjs reaches the project config, this does not).
|
|
3
|
+
|
|
4
|
+
/** D4: the newest nightly an affected run may lean on; equals nightly-status FRESH_MS. */
|
|
5
|
+
export const NIGHTLY_MAX_AGE_MS = 36 * 3600 * 1000;
|
|
6
|
+
export const AFFECTED_NIGHTLY = Object.freeze(['green', 'amber']);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The collection half of the pack hooks, with no import: config.mjs reads pack hooks while it
|
|
2
|
+
// validates a gate, and pack-hooks.mjs imports the project's loaded gate (project.mjs), which
|
|
3
|
+
// imports the config that is being validated. Keeping this function here avoids that cycle.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every pack's hook of that name, in `packs` order, as `{ by, value }` (`by` is the pack's name).
|
|
7
|
+
* For hooks that all packs contribute to, such as mutation kinds and harnesses.
|
|
8
|
+
*/
|
|
9
|
+
export function packHooksAll(name, config) {
|
|
10
|
+
const out = [];
|
|
11
|
+
for (const pack of config.packs ?? [])
|
|
12
|
+
if (pack?.[name] !== undefined && pack[name] !== null)
|
|
13
|
+
out.push({ by: pack.name, value: pack[name] });
|
|
14
|
+
return out;
|
|
15
|
+
}
|
package/pack-hooks.mjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// How the engine reaches pack behaviour: only through the named hooks a pack in
|
|
2
|
+
// `GATE.packs` carries (the supabase-next pack documents its own in
|
|
3
|
+
// packs/supabase-next/index.mjs). A missing hook means "no pack does that", and every
|
|
4
|
+
// caller keeps working with a fallback; a config with no packs plans and runs.
|
|
5
|
+
import { packHooksAll as collectHooks } from './pack-hooks-all.mjs';
|
|
6
|
+
import { GATE } from './project.mjs';
|
|
7
|
+
|
|
8
|
+
/** The first pack's hook of that name, or undefined. */
|
|
9
|
+
export function packHook(name, config = GATE) {
|
|
10
|
+
for (const pack of config.packs ?? [])
|
|
11
|
+
if (pack?.[name] !== undefined && pack[name] !== null) return pack[name];
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A hook that may be a function resolving to its object (a pack's lazy load), or an object. */
|
|
16
|
+
export async function packHookAsync(name, config = GATE) {
|
|
17
|
+
const hook = packHook(name, config);
|
|
18
|
+
return typeof hook === 'function' ? await hook() : hook;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Every pack's hook of that name, in `packs` order, as `{ by, value }` (`by` is the pack's name).
|
|
23
|
+
* For hooks that all packs contribute to, such as mutation kinds and harnesses.
|
|
24
|
+
*/
|
|
25
|
+
export const packHooksAll = (name, config = GATE) =>
|
|
26
|
+
collectHooks(name, config);
|
package/package.json
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ticket-gate/engine",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-rc.1",
|
|
4
|
+
"description": "A configurable verification gate for agent-driven ticket work.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": { "node": ">=22" },
|
|
8
|
+
"bin": { "ticket-gate": "bin/ticket-gate.mjs" },
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./config.mjs",
|
|
11
|
+
"./*": "./*",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"files": ["*", "!test"],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/dmitry-prohorov/ticket-gate.git",
|
|
18
|
+
"directory": "packages/engine"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": { "access": "public", "provenance": true }
|
|
21
|
+
}
|
package/plan.mjs
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { GATE } from './project.mjs';
|
|
4
|
+
import { ROOT } from './root.mjs';
|
|
5
|
+
import { globRe, pick } from './config.mjs';
|
|
6
|
+
import { newestLedgerLine } from './ledger.mjs';
|
|
7
|
+
|
|
8
|
+
// Config-only keys (limitS, reuse, stack, option, units) are read here and never copied
|
|
9
|
+
// onto a plan step, so they cannot reach the ledger or the receipt.
|
|
10
|
+
// A step's `when` is a boolean, a glob list or a function of ctx; `cmd` a string or a
|
|
11
|
+
// function of ctx. `base` is the full sha the mutation step compares against; `exists`
|
|
12
|
+
// says whether a changed path is still on disk (a deleted file is not mutated).
|
|
13
|
+
|
|
14
|
+
/** The step ids a red step before which stops the stack steps (`string | string[]`). */
|
|
15
|
+
export const stopsOf = (config) => [].concat(config.stopOnRedBefore ?? []);
|
|
16
|
+
|
|
17
|
+
const globHit = (globs, paths) =>
|
|
18
|
+
paths.some((p) => globs.some((g) => globRe(g).test(p)));
|
|
19
|
+
const noUnits = (sel) => sel.mode === 'affected' && sel.units.length === 0;
|
|
20
|
+
const selectorFailed = (e) => ({
|
|
21
|
+
mode: 'full',
|
|
22
|
+
why: `unit selector failed: ${e.message}`,
|
|
23
|
+
units: [],
|
|
24
|
+
});
|
|
25
|
+
const unitsCmd = (s, sel) => s.units.cmd(sel.units, sel);
|
|
26
|
+
const healthFn = (stack) => typeof stack.health === 'function';
|
|
27
|
+
|
|
28
|
+
/** The step whose `units` carry the nightly-backed planner (`units.nightly`), whatever its id; or null. */
|
|
29
|
+
export const nightlyStepOf = (gate) =>
|
|
30
|
+
gate.steps.find((s) => s.units?.nightly) ?? null;
|
|
31
|
+
|
|
32
|
+
/** The ctx a step's `when`, `cmd` and `units.select` read. */
|
|
33
|
+
export function planCtx(
|
|
34
|
+
paths,
|
|
35
|
+
{ base = 'HEAD', exists = (p) => existsSync(join(ROOT, p)) } = {},
|
|
36
|
+
) {
|
|
37
|
+
const touched = (prefix) => paths.some((p) => p.startsWith(prefix));
|
|
38
|
+
return { paths, base, exists, touched };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const whenOf = (s, ctx) =>
|
|
42
|
+
typeof s.when === 'function'
|
|
43
|
+
? s.when(ctx)
|
|
44
|
+
: Array.isArray(s.when)
|
|
45
|
+
? globHit(s.when, ctx.paths)
|
|
46
|
+
: s.when;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Awaits `units.select(ctx)` of every units step whose `when` holds (or whose optional
|
|
50
|
+
* `units.reaches(ctx)` says the diff reaches it anyway): { stepId: Sel }. A throwing
|
|
51
|
+
* selector gives a full-mode Sel, never a skipped step. Pass the result to planFor as
|
|
52
|
+
* `opts.units`.
|
|
53
|
+
*/
|
|
54
|
+
export async function selectUnits(config, ctx) {
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const s of config.steps) {
|
|
57
|
+
if (!s.units) continue;
|
|
58
|
+
try {
|
|
59
|
+
const reached = whenOf(s, ctx) || (await s.units.reaches?.(ctx));
|
|
60
|
+
if (reached) out[s.id] = await s.units.select(ctx);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
out[s.id] = selectorFailed(e);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const buildPlan = (config, ctx, opts) => {
|
|
69
|
+
const selOf = (s) => {
|
|
70
|
+
if (!s.units) return undefined;
|
|
71
|
+
// A precomputed map is authoritative: a step it does not name has no selection.
|
|
72
|
+
if (opts.units) return opts.units[s.id];
|
|
73
|
+
if (!whenOf(s, ctx)) return undefined;
|
|
74
|
+
let got;
|
|
75
|
+
try {
|
|
76
|
+
got = s.units.select(ctx);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
return selectorFailed(e);
|
|
79
|
+
}
|
|
80
|
+
if (typeof got?.then === 'function') {
|
|
81
|
+
got.catch(() => {});
|
|
82
|
+
throw new Error(
|
|
83
|
+
`units.select of ${s.id} is async: precompute with selectUnits()`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return got;
|
|
87
|
+
};
|
|
88
|
+
return config.steps.map((s) => {
|
|
89
|
+
const step = {
|
|
90
|
+
id: s.id,
|
|
91
|
+
when: whenOf(s, ctx),
|
|
92
|
+
why: s.why,
|
|
93
|
+
cmd: typeof s.cmd === 'function' ? s.cmd(ctx) : s.cmd,
|
|
94
|
+
notRun: s.notRun,
|
|
95
|
+
summary: s.summary,
|
|
96
|
+
};
|
|
97
|
+
if (s.failSummary) step.failSummary = s.failSummary;
|
|
98
|
+
if (s.human) step.human = s.human;
|
|
99
|
+
const sel = selOf(s);
|
|
100
|
+
if (sel) {
|
|
101
|
+
step.selection = sel;
|
|
102
|
+
if (noUnits(sel)) {
|
|
103
|
+
step.when = false;
|
|
104
|
+
step.notRun = sel.why;
|
|
105
|
+
} else {
|
|
106
|
+
step.when = true;
|
|
107
|
+
step.cmd = unitsCmd(s, sel);
|
|
108
|
+
if (sel.prepare) step.prepare = sel.prepare;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return step;
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Pure planning over `config`: which steps run, in what mode, for these changed paths.
|
|
117
|
+
* `opts` = { skip?, units?, exists?, base?, digestOf?, ledger?, fresh?, [option]? }.
|
|
118
|
+
* `steps` are the selected steps in run order; `notRun` the unselected ones;
|
|
119
|
+
* `notSelected` the valid skip ids whose step the diff did not select (a no-op);
|
|
120
|
+
* `unknown` the skip ids that are not step ids; `stopBefore` the ids a red step before
|
|
121
|
+
* which stops the stack steps.
|
|
122
|
+
*/
|
|
123
|
+
export function planFor(config, paths, opts = {}) {
|
|
124
|
+
const { digestOf, ledger = [], fresh = false } = opts;
|
|
125
|
+
const skip = new Set(opts.skip ?? []);
|
|
126
|
+
const stepIds = config.steps.map((s) => s.id);
|
|
127
|
+
const ctx = planCtx(paths, opts);
|
|
128
|
+
const plan = buildPlan(config, ctx, opts);
|
|
129
|
+
const cfg = (id) => config.steps.find((c) => c.id === id);
|
|
130
|
+
const planned = (id) => plan.find((s) => s.id === id);
|
|
131
|
+
// main decides an option step's `when`, command and list files
|
|
132
|
+
// (`prepare`) through opts[step.option]; a plan without the option is today's.
|
|
133
|
+
for (const s of plan) {
|
|
134
|
+
const o = opts[cfg(s.id).option];
|
|
135
|
+
if (o?.when !== undefined) s.when = o.when;
|
|
136
|
+
if (o?.cmd) s.cmd = o.cmd;
|
|
137
|
+
if (o?.prepare) s.prepare = o.prepare;
|
|
138
|
+
}
|
|
139
|
+
const unknown = [...skip].filter((id) => !stepIds.includes(id));
|
|
140
|
+
// The newest ledger line on this step's (id, cmd, digest), or undefined.
|
|
141
|
+
const lastRun = (s) =>
|
|
142
|
+
newestLedgerLine(ledger, {
|
|
143
|
+
id: s.id,
|
|
144
|
+
cmd: s.cmd,
|
|
145
|
+
digest: digestOf(s.id, s.cmd),
|
|
146
|
+
});
|
|
147
|
+
const modeOf = (s) => {
|
|
148
|
+
if (s.human) return 'human';
|
|
149
|
+
if (skip.has(s.id)) return 'skip';
|
|
150
|
+
const c = cfg(s.id);
|
|
151
|
+
if (digestOf && c.reuse && !fresh) {
|
|
152
|
+
const last = lastRun(s);
|
|
153
|
+
// R1, F4: only a full result with no knownRed stands in for a full run.
|
|
154
|
+
const o =
|
|
155
|
+
opts[c.option] ??
|
|
156
|
+
(s.selection && {
|
|
157
|
+
mode: s.selection.mode,
|
|
158
|
+
knownRedCount: s.selection.knownRedCount,
|
|
159
|
+
});
|
|
160
|
+
// prettier-ignore
|
|
161
|
+
if (c.reuse.fullOnly && (o?.mode === 'affected' || (last?.mode ?? 'full') !== 'full' || (o?.knownRedCount ?? 0) > 0)) return 'run';
|
|
162
|
+
if (last?.status === 'pass') return 'reuse';
|
|
163
|
+
}
|
|
164
|
+
return 'run';
|
|
165
|
+
};
|
|
166
|
+
const withMode = (list) => list.map((s) => ({ ...s, mode: modeOf(s) }));
|
|
167
|
+
|
|
168
|
+
// Playwright shares one seed across specs and fails on a used one, so
|
|
169
|
+
// a step that needs a fresh seed is preceded by exactly one reset: in a
|
|
170
|
+
// `satisfiedBy` step if that runs, else in the stack's own reset step.
|
|
171
|
+
const steps0 = withMode(plan.filter((s) => s.when));
|
|
172
|
+
const runs = (id) => steps0.find((s) => s.id === id)?.mode === 'run';
|
|
173
|
+
for (const stack of Object.values(config.stacks)) {
|
|
174
|
+
const { reset } = stack;
|
|
175
|
+
if (!reset) continue;
|
|
176
|
+
planned(reset.step).when =
|
|
177
|
+
reset.before.some(runs) && !reset.satisfiedBy.some(runs);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// R4: the health step checks the stack steps that actually run, and nothing else.
|
|
181
|
+
for (const [name, stack] of Object.entries(config.stacks)) {
|
|
182
|
+
if (!stack.health || healthFn(stack)) continue;
|
|
183
|
+
const stackRuns = plan
|
|
184
|
+
.filter((s) => cfg(s.id).stack === name && s.when && modeOf(s) === 'run')
|
|
185
|
+
.map((s) => s.id);
|
|
186
|
+
const health = planned(stack.health.step);
|
|
187
|
+
health.when = stackRuns.length > 0;
|
|
188
|
+
health.cmd = stack.health.preflight(stackRuns);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const selected = plan.filter((s) => s.when);
|
|
192
|
+
const steps = withMode(selected);
|
|
193
|
+
|
|
194
|
+
// R3: after a reset passes, wait until the services the later stack steps
|
|
195
|
+
// need are healthy. The step's own cmd is untouched.
|
|
196
|
+
for (const [name, stack] of Object.entries(config.stacks)) {
|
|
197
|
+
const { reset } = stack;
|
|
198
|
+
const runStack = steps.filter(
|
|
199
|
+
(s) => cfg(s.id).stack === name && s.mode === 'run',
|
|
200
|
+
);
|
|
201
|
+
for (const s of steps) {
|
|
202
|
+
if (
|
|
203
|
+
!reset ||
|
|
204
|
+
!stack.health?.ready ||
|
|
205
|
+
(s.id !== reset.step && !reset.satisfiedBy.includes(s.id))
|
|
206
|
+
)
|
|
207
|
+
continue;
|
|
208
|
+
const after = runStack
|
|
209
|
+
.slice(runStack.findIndex((r) => r.id === s.id) + 1)
|
|
210
|
+
.map((r) => r.id);
|
|
211
|
+
if (s.mode === 'run' && after.length) s.ready = stack.health.ready(after);
|
|
212
|
+
}
|
|
213
|
+
if (!reset?.note) continue;
|
|
214
|
+
const by = reset.satisfiedBy.find(runs) ?? reset.step;
|
|
215
|
+
for (const s of steps) {
|
|
216
|
+
if (!reset.note.steps.includes(s.id)) continue;
|
|
217
|
+
const base = s.summary;
|
|
218
|
+
s.summary = (out) => `${base(out) ?? 'passed'}; ${reset.note.text(by)}`;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 4.2 form: a function `health` is the step `<stack>:health`, before the stack's first run step.
|
|
223
|
+
for (const [name, stack] of Object.entries(config.stacks)) {
|
|
224
|
+
if (!healthFn(stack)) continue;
|
|
225
|
+
const onStack = (s) => cfg(s.id)?.stack === name && s.mode === 'run';
|
|
226
|
+
const at = steps.findIndex(onStack);
|
|
227
|
+
if (at < 0) continue;
|
|
228
|
+
steps.splice(at, 0, {
|
|
229
|
+
id: `${name}:health`,
|
|
230
|
+
when: true,
|
|
231
|
+
why: `the ${name} stack must be healthy before its steps run`,
|
|
232
|
+
cmd: stack.health(steps.filter(onStack).map((s) => s.id)),
|
|
233
|
+
notRun: `no step needs the ${name} stack`,
|
|
234
|
+
summary: (out) => pick(out, [/.+/]) ?? `${name} stack ok`,
|
|
235
|
+
mode: 'run',
|
|
236
|
+
limitS: 120,
|
|
237
|
+
health: true,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Every non-human step carries its digest; a reused one also says where from.
|
|
242
|
+
const withDigest = (s) => {
|
|
243
|
+
if (!digestOf || s.human || s.health) return s;
|
|
244
|
+
const digest = digestOf(s.id, s.cmd);
|
|
245
|
+
if (s.mode !== 'reuse') return { ...s, digest };
|
|
246
|
+
const last = lastRun(s);
|
|
247
|
+
return {
|
|
248
|
+
...s,
|
|
249
|
+
digest,
|
|
250
|
+
reusedFrom: last.ranAt,
|
|
251
|
+
reusedSummary: String(last.summary ?? 'passed'),
|
|
252
|
+
};
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
steps: steps.map(withDigest),
|
|
257
|
+
notRun: plan.filter((s) => !s.when),
|
|
258
|
+
notSelected: [...skip].filter(
|
|
259
|
+
(id) => stepIds.includes(id) && !selected.some((s) => s.id === id),
|
|
260
|
+
),
|
|
261
|
+
unknown,
|
|
262
|
+
stopBefore: stopsOf(config),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The gate's plan. A plan with no precomputed `opts.units` never calls
|
|
268
|
+
* a units selector (a selector is async; cli.mjs awaits selectUnits() first), so a
|
|
269
|
+
* units step runs its own `cmd`, as before units existed.
|
|
270
|
+
*/
|
|
271
|
+
export const planSteps = (paths, skipIds, opts = {}) =>
|
|
272
|
+
planFor(GATE, paths, { ...opts, skip: skipIds, units: opts.units ?? {} });
|