@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
package/config.mjs
ADDED
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
// Validates and freezes gate.config.mjs. Dependency-free; a bad config throws
|
|
2
|
+
// Error('gate.config: <where>: <what>'). Fix the named key in gate.config.mjs and rerun.
|
|
3
|
+
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
import { docsOf, packCommandsOf } from './commands-config.mjs';
|
|
8
|
+
import { ROOT } from './root.mjs';
|
|
9
|
+
import { scoreOf, validateScore } from './score-config.mjs';
|
|
10
|
+
import { nightlyOf } from './nightly-config.mjs';
|
|
11
|
+
import { selectionOf } from './selection-config.mjs';
|
|
12
|
+
|
|
13
|
+
export const GATE_KEYS = Object.freeze([
|
|
14
|
+
'steps',
|
|
15
|
+
'stacks',
|
|
16
|
+
'stopOnRedBefore',
|
|
17
|
+
'dataPaths',
|
|
18
|
+
'reviewMap',
|
|
19
|
+
'dod',
|
|
20
|
+
'pushGuard',
|
|
21
|
+
'base',
|
|
22
|
+
'packs',
|
|
23
|
+
'followUps',
|
|
24
|
+
'precheck',
|
|
25
|
+
'ratchets',
|
|
26
|
+
'roles',
|
|
27
|
+
'skills',
|
|
28
|
+
'toolPrefixes',
|
|
29
|
+
'registryFile',
|
|
30
|
+
'stackHint',
|
|
31
|
+
'commands',
|
|
32
|
+
'mutations',
|
|
33
|
+
'score',
|
|
34
|
+
'selection',
|
|
35
|
+
'docs',
|
|
36
|
+
'nightly',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The commands the engine's messages tell a reader to run. A project whose runner differs (a
|
|
41
|
+
* package.json script, a wrapper) sets `commands` in gate.config.mjs; a key left out keeps the
|
|
42
|
+
* engine's own CLI name. `fu` and `verify` are command prefixes: a message appends arguments.
|
|
43
|
+
* `nightlyStatus` has no default: a project with a nightly sets it, and a message names it only then.
|
|
44
|
+
*/
|
|
45
|
+
export const DEFAULT_COMMANDS = Object.freeze({
|
|
46
|
+
verify: 'ticket-gate verify',
|
|
47
|
+
fu: 'ticket-gate fu',
|
|
48
|
+
moveCheck: 'ticket-gate move-check',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** The effective commands of a gate (a defined one, a plain object or undefined): defaults filled in. */
|
|
52
|
+
export const commandsOf = (gate) => ({
|
|
53
|
+
...DEFAULT_COMMANDS,
|
|
54
|
+
...(gate?.commands ?? {}),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** The commands the mutation engine's messages name; they join `commands` and default in the engine. */
|
|
58
|
+
export const MUTATION_COMMAND_KEYS = Object.freeze([
|
|
59
|
+
'mutate',
|
|
60
|
+
'checkMutations',
|
|
61
|
+
'checkMutationsScript',
|
|
62
|
+
'afterFatal',
|
|
63
|
+
'restoreHint',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const COMMAND_KEYS = [
|
|
67
|
+
...Object.keys(DEFAULT_COMMANDS),
|
|
68
|
+
'nightlyStatus',
|
|
69
|
+
...MUTATION_COMMAND_KEYS,
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const DEFAULT_PROTECTED = ['main', 'master'];
|
|
73
|
+
const DEFAULT_OVERSIZE_WAIVER = /^(?:- )?oversize-accepted: \S/;
|
|
74
|
+
|
|
75
|
+
const TIERS = ['HUMAN', 'GATED', 'MACHINE'];
|
|
76
|
+
const BRANCH_NAME = /^[A-Za-z0-9._/-]+$/;
|
|
77
|
+
|
|
78
|
+
// A step run under FORCE_COLOR (set by `node --test` in a terminal) colours its output.
|
|
79
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
80
|
+
|
|
81
|
+
const outLines = (out) =>
|
|
82
|
+
out
|
|
83
|
+
.split('\n')
|
|
84
|
+
.map((l) => l.replace(ANSI, '').trim())
|
|
85
|
+
.filter(Boolean);
|
|
86
|
+
|
|
87
|
+
export function pick(out, patterns) {
|
|
88
|
+
const lines = outLines(out);
|
|
89
|
+
for (const re of patterns) {
|
|
90
|
+
const hits = lines.filter((l) => re.test(l));
|
|
91
|
+
if (hits.length) return hits[hits.length - 1];
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** turbo prints one "Tasks:" line per sub-command; a false green hides in the ones you drop. */
|
|
97
|
+
export function allTasksLines(out) {
|
|
98
|
+
const hits = outLines(out).filter((l) => /^Tasks:/.test(l));
|
|
99
|
+
return hits.length ? hits.join(' | ') : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const lastLine = (out) => {
|
|
103
|
+
const lines = outLines(out);
|
|
104
|
+
return lines.length ? lines[lines.length - 1] : null;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build a step with the defaults a config rarely varies (4.2): why, notRun, summary
|
|
109
|
+
* and limitS. Never supplies `reuse` (a step reuses only when its author says so) and
|
|
110
|
+
* never a `limitS` on a human step. `summary: { pick: [RegExp] }` is sugar for pick().
|
|
111
|
+
*/
|
|
112
|
+
export function step(spec) {
|
|
113
|
+
const summary = spec.summary?.pick
|
|
114
|
+
? (out) => pick(out, spec.summary.pick)
|
|
115
|
+
: spec.summary;
|
|
116
|
+
return {
|
|
117
|
+
why: 'selected by when',
|
|
118
|
+
notRun: 'not selected by when',
|
|
119
|
+
summary: (out) => lastLine(out) ?? 'passed',
|
|
120
|
+
...spec,
|
|
121
|
+
...(summary ? { summary } : {}),
|
|
122
|
+
limitS: spec.human ? undefined : (spec.limitS ?? 600),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const escapeRe = (c) => c.replace(/[.+^$()|[\]\\]/g, '\\$&');
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A glob as a RegExp over the whole path: `**` crosses `/` (`**` + `/` also matches no
|
|
130
|
+
* directory), `*` and `?` stay inside one segment, `{a,b}` is an alternation.
|
|
131
|
+
*/
|
|
132
|
+
export function globRe(glob) {
|
|
133
|
+
let out = '';
|
|
134
|
+
let depth = 0;
|
|
135
|
+
for (let i = 0; i < glob.length; i++) {
|
|
136
|
+
const c = glob[i];
|
|
137
|
+
if (c === '*' && glob[i + 1] === '*') {
|
|
138
|
+
if (glob[i + 2] === '/') {
|
|
139
|
+
out += '(?:.*/)?';
|
|
140
|
+
i += 2;
|
|
141
|
+
} else {
|
|
142
|
+
out += '.*';
|
|
143
|
+
i += 1;
|
|
144
|
+
}
|
|
145
|
+
} else if (c === '*') out += '[^/]*';
|
|
146
|
+
else if (c === '?') out += '[^/]';
|
|
147
|
+
else if (c === '{') {
|
|
148
|
+
depth += 1;
|
|
149
|
+
out += '(?:';
|
|
150
|
+
} else if (c === '}' && depth > 0) {
|
|
151
|
+
depth -= 1;
|
|
152
|
+
out += ')';
|
|
153
|
+
} else if (c === ',' && depth > 0) out += '|';
|
|
154
|
+
else out += escapeRe(c);
|
|
155
|
+
}
|
|
156
|
+
return new RegExp(`^${out}$`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const isObject = (v) => v && typeof v === 'object' && !Array.isArray(v);
|
|
160
|
+
|
|
161
|
+
const isGlobList = (v) =>
|
|
162
|
+
Array.isArray(v) && v.every((g) => typeof g === 'string' && g);
|
|
163
|
+
|
|
164
|
+
function fail(where, what) {
|
|
165
|
+
throw new Error(`gate.config: ${where}: ${what}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function deepFreeze(value) {
|
|
169
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
170
|
+
Object.freeze(value);
|
|
171
|
+
for (const v of Object.values(value)) deepFreeze(v);
|
|
172
|
+
}
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function defineGate(config) {
|
|
177
|
+
if (!config || typeof config !== 'object')
|
|
178
|
+
fail('config', 'must be an object');
|
|
179
|
+
for (const key of Object.keys(config)) {
|
|
180
|
+
if (!GATE_KEYS.includes(key)) fail(key, 'unknown key');
|
|
181
|
+
}
|
|
182
|
+
const steps = config.steps ?? [];
|
|
183
|
+
const stacks = config.stacks ?? {};
|
|
184
|
+
if (!Array.isArray(steps)) fail('steps', 'must be an array');
|
|
185
|
+
|
|
186
|
+
const seen = new Set();
|
|
187
|
+
steps.forEach((s, i) => {
|
|
188
|
+
const at = `steps[${i}]${s && s.id ? ` (${s.id})` : ''}`;
|
|
189
|
+
if (!s || typeof s.id !== 'string' || !s.id)
|
|
190
|
+
fail(at, 'id must be a non-empty string');
|
|
191
|
+
if (seen.has(s.id)) fail(at, `duplicate step id "${s.id}"`);
|
|
192
|
+
seen.add(s.id);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
steps.forEach((s, i) => {
|
|
196
|
+
const at = `steps[${i}] (${s.id})`;
|
|
197
|
+
// prettier-ignore
|
|
198
|
+
if (!(typeof s.when === 'boolean' || typeof s.when === 'function' || isGlobList(s.when))) fail(at, 'when must be a boolean, a glob list or a function');
|
|
199
|
+
if (typeof s.cmd !== 'string' && typeof s.cmd !== 'function')
|
|
200
|
+
fail(at, 'cmd must be a string or function');
|
|
201
|
+
if (typeof s.why !== 'string') fail(at, 'why must be a string');
|
|
202
|
+
if (typeof s.notRun !== 'string') fail(at, 'notRun must be a string');
|
|
203
|
+
if (typeof s.summary !== 'function') fail(at, 'summary must be a function');
|
|
204
|
+
if (!s.human && !(Number.isInteger(s.limitS) && s.limitS > 0))
|
|
205
|
+
fail(at, 'limitS must be a positive integer');
|
|
206
|
+
if (!s.human && s.reuse !== false && !Array.isArray(s.reuse?.deny))
|
|
207
|
+
fail(at, 'reuse must be false or { deny: [...] }');
|
|
208
|
+
if (s.human && (s.reuse !== undefined || s.limitS !== undefined))
|
|
209
|
+
fail(at, 'a human step has no reuse or limitS');
|
|
210
|
+
if (s.stack !== undefined && !Object.hasOwn(stacks, s.stack))
|
|
211
|
+
fail(at, `unknown stack "${s.stack}"`);
|
|
212
|
+
// prettier-ignore
|
|
213
|
+
if (s.units !== undefined && (typeof s.units?.select !== 'function' || typeof s.units?.cmd !== 'function')) fail(at, 'units needs select(ctx) and cmd(units)');
|
|
214
|
+
if (s.units?.reaches !== undefined && typeof s.units.reaches !== 'function')
|
|
215
|
+
fail(at, 'units.reaches must be a function');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const nightly = steps.filter((s) => s.units?.nightly);
|
|
219
|
+
if (nightly.length > 1) {
|
|
220
|
+
fail('steps', `only one step may carry nightly units: ${nightly[0].id}, ${nightly[1].id}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const known = (where, id) => {
|
|
224
|
+
if (!seen.has(id)) fail(where, `unknown step id "${id}"`);
|
|
225
|
+
};
|
|
226
|
+
for (const [name, stack] of Object.entries(stacks)) {
|
|
227
|
+
const at = `stacks.${name}`;
|
|
228
|
+
if (stack.start !== undefined && typeof stack.start !== 'string')
|
|
229
|
+
fail(`${at}.start`, 'must be a command string');
|
|
230
|
+
if (stack.lock !== undefined && typeof stack.lock?.path !== 'string')
|
|
231
|
+
fail(`${at}.lock`, 'must be { path: string }');
|
|
232
|
+
if (typeof stack.health === 'function') {
|
|
233
|
+
// the 4.2 form: planned as the step `<id>:health`
|
|
234
|
+
} else if (stack.health) known(`${at}.health.step`, stack.health.step);
|
|
235
|
+
if (stack.reset) {
|
|
236
|
+
known(`${at}.reset.step`, stack.reset.step);
|
|
237
|
+
for (const id of stack.reset.satisfiedBy ?? [])
|
|
238
|
+
known(`${at}.reset.satisfiedBy`, id);
|
|
239
|
+
for (const id of stack.reset.before ?? [])
|
|
240
|
+
known(`${at}.reset.before`, id);
|
|
241
|
+
for (const id of stack.reset.note?.steps ?? [])
|
|
242
|
+
known(`${at}.reset.note.steps`, id);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const stops = config.stopOnRedBefore;
|
|
246
|
+
if (stops !== undefined && typeof stops !== 'string' && !Array.isArray(stops))
|
|
247
|
+
fail('stopOnRedBefore', 'must be a step id or an array of step ids');
|
|
248
|
+
for (const id of [].concat(stops ?? [])) known('stopOnRedBefore', id);
|
|
249
|
+
for (const [path, ids] of Object.entries(config.dataPaths ?? {})) {
|
|
250
|
+
for (const id of ids) known(`dataPaths["${path}"]`, id);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
validateServices(stacks, seen);
|
|
254
|
+
if (config.reviewMap !== undefined && !isObject(config.reviewMap))
|
|
255
|
+
fail('reviewMap', 'must be an object');
|
|
256
|
+
const reviewMap = {
|
|
257
|
+
rules: [],
|
|
258
|
+
sizeExcluded: [],
|
|
259
|
+
humanLines: [],
|
|
260
|
+
warn: 800,
|
|
261
|
+
oversize: 1500,
|
|
262
|
+
...config.reviewMap,
|
|
263
|
+
};
|
|
264
|
+
validateReviewMap(reviewMap);
|
|
265
|
+
const dod =
|
|
266
|
+
config.dod === undefined
|
|
267
|
+
? undefined
|
|
268
|
+
: { human: '(human)', oversize: DEFAULT_OVERSIZE_WAIVER, ...config.dod };
|
|
269
|
+
if (dod !== undefined) validateDod(dod);
|
|
270
|
+
if (config.pushGuard !== undefined) validatePushGuard(config.pushGuard);
|
|
271
|
+
const packs = config.packs ?? [];
|
|
272
|
+
const precheck = config.precheck ?? [];
|
|
273
|
+
validatePacks(packs);
|
|
274
|
+
validatePrecheck(precheck);
|
|
275
|
+
validateExtras(config);
|
|
276
|
+
validateRatchets(config.ratchets);
|
|
277
|
+
validateHooks(config);
|
|
278
|
+
validateCommands(config.commands);
|
|
279
|
+
if (config.mutations !== undefined) validateMutations(config.mutations);
|
|
280
|
+
if (config.score !== undefined) validateScore(config.score, fail);
|
|
281
|
+
|
|
282
|
+
return deepFreeze({
|
|
283
|
+
...config,
|
|
284
|
+
steps,
|
|
285
|
+
stacks,
|
|
286
|
+
reviewMap,
|
|
287
|
+
...(dod ? { dod } : {}),
|
|
288
|
+
pushGuard: config.pushGuard ?? { protected: [...DEFAULT_PROTECTED] },
|
|
289
|
+
dataPaths: config.dataPaths ?? {},
|
|
290
|
+
toolPrefixes: config.toolPrefixes ?? [],
|
|
291
|
+
commands: commandsOf(config),
|
|
292
|
+
packs,
|
|
293
|
+
packCommands: packCommandsOf(config, fail),
|
|
294
|
+
precheck,
|
|
295
|
+
...(config.docs !== undefined ? { docs: docsOf(config.docs, fail) } : {}),
|
|
296
|
+
...(config.score !== undefined ? { score: scoreOf(config.score) } : {}),
|
|
297
|
+
...(config.nightly !== undefined ? { nightly: nightlyOf(config, fail) } : {}),
|
|
298
|
+
...(config.selection !== undefined
|
|
299
|
+
? { selection: selectionOf(config, fail) }
|
|
300
|
+
: {}),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// toolPrefixes: path prefixes whose files are an input of every step (unless dataPaths narrows them).
|
|
305
|
+
// registryFile(path): whether a path is mutation-registry data. stackHint(stepId): the command that
|
|
306
|
+
// diagnoses a stack step. splitHint (in reviewMap): the pointer printed beside a size warning.
|
|
307
|
+
function validateHooks(config) {
|
|
308
|
+
const { toolPrefixes, registryFile, stackHint } = config;
|
|
309
|
+
if (
|
|
310
|
+
toolPrefixes !== undefined &&
|
|
311
|
+
!(
|
|
312
|
+
Array.isArray(toolPrefixes) &&
|
|
313
|
+
toolPrefixes.every((p) => typeof p === 'string')
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
fail('toolPrefixes', 'must be an array of path prefixes');
|
|
317
|
+
if (registryFile !== undefined && typeof registryFile !== 'function')
|
|
318
|
+
fail('registryFile', 'must be a function of a repo path');
|
|
319
|
+
if (stackHint !== undefined && typeof stackHint !== 'function')
|
|
320
|
+
fail('stackHint', 'must be a function of a step id');
|
|
321
|
+
const hint = config.reviewMap?.splitHint;
|
|
322
|
+
if (hint !== undefined && typeof hint !== 'string')
|
|
323
|
+
fail('reviewMap.splitHint', 'must be a string');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function validateCommands(commands) {
|
|
327
|
+
if (commands === undefined) return;
|
|
328
|
+
if (!isObject(commands)) fail('commands', 'must be an object');
|
|
329
|
+
for (const [k, v] of Object.entries(commands)) {
|
|
330
|
+
if (!COMMAND_KEYS.includes(k))
|
|
331
|
+
fail(`commands.${k}`, `unknown key (known: ${COMMAND_KEYS.join(', ')})`);
|
|
332
|
+
if (typeof v !== 'string' || !v.trim() || /[\r\n]/.test(v))
|
|
333
|
+
fail(`commands.${k}`, 'must be a non-empty single-line command string');
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export const MUTATION_KEYS = Object.freeze([
|
|
338
|
+
'dir',
|
|
339
|
+
'receipts',
|
|
340
|
+
'area',
|
|
341
|
+
'fieldOrder',
|
|
342
|
+
'legacy',
|
|
343
|
+
'rebuild',
|
|
344
|
+
'childEnv',
|
|
345
|
+
'reuseEnv',
|
|
346
|
+
'kinds',
|
|
347
|
+
]);
|
|
348
|
+
|
|
349
|
+
const isStr = (v) => typeof v === 'string' && v !== '';
|
|
350
|
+
// prettier-ignore
|
|
351
|
+
const isRelDir = (v) => isStr(v) && !v.startsWith('/') && !/^[A-Za-z]:/.test(v) && !v.split(/[\\/]/).includes('..');
|
|
352
|
+
|
|
353
|
+
// `mutations` configures the mutation engine (kinds, rebuild rules, child env, registry folder).
|
|
354
|
+
// `packs` and `commands` stay top-level keys: a pack's kinds and harnesses come from the pack.
|
|
355
|
+
function validateMutations(m) {
|
|
356
|
+
if (!isObject(m)) fail('mutations', 'must be an object');
|
|
357
|
+
for (const k of Object.keys(m))
|
|
358
|
+
if (!MUTATION_KEYS.includes(k)) fail(`mutations.${k}`, 'unknown key');
|
|
359
|
+
if (m.dir !== undefined) {
|
|
360
|
+
// prettier-ignore
|
|
361
|
+
if (!isRelDir(m.dir)) fail('mutations.dir', 'must be a relative directory path');
|
|
362
|
+
}
|
|
363
|
+
if (m.receipts !== undefined) {
|
|
364
|
+
if (!isObject(m.receipts) || !isStr(m.receipts.ticket) || !isStr(m.receipts.registry))
|
|
365
|
+
fail('mutations.receipts', 'must be { ticket: string, registry: string }');
|
|
366
|
+
}
|
|
367
|
+
if (m.area !== undefined && typeof m.area !== 'function')
|
|
368
|
+
fail('mutations.area', 'must be a function of an entry');
|
|
369
|
+
if (
|
|
370
|
+
m.fieldOrder !== undefined &&
|
|
371
|
+
!(Array.isArray(m.fieldOrder) && m.fieldOrder.every(isStr))
|
|
372
|
+
)
|
|
373
|
+
fail('mutations.fieldOrder', 'must be an array of field names');
|
|
374
|
+
if (m.legacy !== undefined && m.legacy !== null) {
|
|
375
|
+
if (
|
|
376
|
+
!isObject(m.legacy) ||
|
|
377
|
+
typeof m.legacy.files !== 'function' ||
|
|
378
|
+
typeof m.legacy.atRef !== 'function'
|
|
379
|
+
)
|
|
380
|
+
fail('mutations.legacy', 'must be null or { files(), atRef(ref, root) }');
|
|
381
|
+
}
|
|
382
|
+
if (m.rebuild !== undefined) {
|
|
383
|
+
if (!Array.isArray(m.rebuild)) fail('mutations.rebuild', 'must be an array');
|
|
384
|
+
m.rebuild.forEach((r, i) => {
|
|
385
|
+
const at = `mutations.rebuild[${i}]`;
|
|
386
|
+
for (const f of ['files', 'cmd', 'io', 'flag', 'drift', 'note'])
|
|
387
|
+
if (!isStr(r?.[f])) fail(`${at}.${f}`, 'must be a non-empty string');
|
|
388
|
+
if (r.unlessTest !== undefined && !(r.unlessTest instanceof RegExp))
|
|
389
|
+
fail(`${at}.unlessTest`, 'must be a RegExp');
|
|
390
|
+
if (r.limitS !== undefined && !(Number.isInteger(r.limitS) && r.limitS > 0))
|
|
391
|
+
fail(`${at}.limitS`, 'must be a positive integer');
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
if (m.childEnv !== undefined) {
|
|
395
|
+
const drop = m.childEnv?.drop;
|
|
396
|
+
if (!(Array.isArray(drop) && drop.every(isStr)))
|
|
397
|
+
fail('mutations.childEnv.drop', 'must be an array of variable names');
|
|
398
|
+
}
|
|
399
|
+
if (m.reuseEnv !== undefined) {
|
|
400
|
+
if (!isObject(m.reuseEnv) || !Object.values(m.reuseEnv).every((v) => typeof v === 'string'))
|
|
401
|
+
fail('mutations.reuseEnv', 'must be an object of string values');
|
|
402
|
+
}
|
|
403
|
+
if (m.kinds !== undefined) {
|
|
404
|
+
if (!isObject(m.kinds)) fail('mutations.kinds', 'must be an object');
|
|
405
|
+
for (const [name, k] of Object.entries(m.kinds)) {
|
|
406
|
+
// prettier-ignore
|
|
407
|
+
if (typeof k?.name !== 'string' || typeof k.runs !== 'boolean' || typeof k.shape !== 'function') fail(`mutations.kinds.${name}`, 'needs name, runs and shape(entry)');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function validatePacks(packs) {
|
|
413
|
+
if (!Array.isArray(packs)) fail('packs', 'must be an array');
|
|
414
|
+
packs.forEach((p, i) => {
|
|
415
|
+
const at = `packs[${i}]`;
|
|
416
|
+
// prettier-ignore
|
|
417
|
+
if (typeof p?.name !== 'string' || typeof p?.version !== 'string') fail(at, 'a pack needs a string name and version');
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function validatePrecheck(list) {
|
|
422
|
+
if (!Array.isArray(list)) fail('precheck', 'must be an array');
|
|
423
|
+
list.forEach((c, i) => {
|
|
424
|
+
const at = `precheck[${i}]`;
|
|
425
|
+
if (typeof c === 'function') return;
|
|
426
|
+
if (typeof c?.id !== 'string' || !c.id)
|
|
427
|
+
fail(at, 'id must be a non-empty string');
|
|
428
|
+
if (typeof c.cmd !== 'string' && typeof c.cmd !== 'function')
|
|
429
|
+
fail(at, 'cmd must be a string or a function of { base }');
|
|
430
|
+
if (typeof c.fix !== 'string') fail(at, 'fix must be a string');
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function validateExtras(config) {
|
|
435
|
+
if (config.base !== undefined && !BRANCH_NAME.test(String(config.base)))
|
|
436
|
+
fail('base', 'must be a git ref ([A-Za-z0-9._/-])');
|
|
437
|
+
const fu = config.followUps;
|
|
438
|
+
if (fu !== undefined) {
|
|
439
|
+
if (!isObject(fu) || typeof fu.dir !== 'string' || !fu.dir)
|
|
440
|
+
fail('followUps', 'must be { dir: string, pointer?: string }');
|
|
441
|
+
if (fu.pointer !== undefined && typeof fu.pointer !== 'string')
|
|
442
|
+
fail('followUps.pointer', 'must be a string');
|
|
443
|
+
}
|
|
444
|
+
if (config.roles !== undefined) {
|
|
445
|
+
if (!isObject(config.roles)) fail('roles', 'must be an object');
|
|
446
|
+
for (const [role, r] of Object.entries(config.roles)) {
|
|
447
|
+
const at = `roles.${role}`;
|
|
448
|
+
if (
|
|
449
|
+
!Array.isArray(r?.owns) ||
|
|
450
|
+
!r.owns.every((g) => typeof g === 'string')
|
|
451
|
+
)
|
|
452
|
+
fail(at, 'owns must be an array of globs');
|
|
453
|
+
if (r.axis !== undefined && typeof r.axis !== 'string')
|
|
454
|
+
fail(at, 'axis must be a string');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const sk = config.skills;
|
|
458
|
+
if (sk !== undefined) {
|
|
459
|
+
if (!isObject(sk)) fail('skills', 'must be an object');
|
|
460
|
+
for (const k of ['lock', 'dir'])
|
|
461
|
+
if (sk[k] !== undefined && typeof sk[k] !== 'string')
|
|
462
|
+
fail(`skills.${k}`, 'must be a string');
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function validateRatchets(ratchets) {
|
|
467
|
+
if (ratchets === undefined) return;
|
|
468
|
+
if (!isObject(ratchets)) fail('ratchets', 'must be an object');
|
|
469
|
+
for (const [name, entry] of Object.entries(ratchets)) {
|
|
470
|
+
const at = `ratchets.${name}`;
|
|
471
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name))
|
|
472
|
+
fail(at, 'name must match /^[a-z][a-z0-9-]*$/');
|
|
473
|
+
if (!isObject(entry)) fail(at, 'must be an object');
|
|
474
|
+
if (typeof entry.check !== 'function')
|
|
475
|
+
throw new Error(`gate.config: ${at}.check must be a function`);
|
|
476
|
+
if (entry.group !== undefined && entry.group !== 'check')
|
|
477
|
+
fail(`${at}.group`, "must be 'check'");
|
|
478
|
+
if (entry.baseline !== undefined && typeof entry.baseline !== 'string')
|
|
479
|
+
fail(`${at}.baseline`, 'must be a string');
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function validateServices(stacks, stepIds) {
|
|
484
|
+
for (const [name, stack] of Object.entries(stacks)) {
|
|
485
|
+
if (stack.services === undefined) continue;
|
|
486
|
+
const at = `stacks.${name}.services`;
|
|
487
|
+
if (!stack.services || typeof stack.services !== 'object')
|
|
488
|
+
fail(at, 'must be an object of step id -> service names');
|
|
489
|
+
for (const [id, list] of Object.entries(stack.services)) {
|
|
490
|
+
if (!stepIds.has(id)) fail(`${at}.${id}`, `unknown step id "${id}"`);
|
|
491
|
+
if (
|
|
492
|
+
!Array.isArray(list) ||
|
|
493
|
+
list.length === 0 ||
|
|
494
|
+
!list.every((n) => typeof n === 'string' && n)
|
|
495
|
+
)
|
|
496
|
+
fail(`${at}.${id}`, 'must be a non-empty array of service names');
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function validateReviewMap(rm) {
|
|
502
|
+
if (!rm || typeof rm !== 'object') fail('reviewMap', 'must be an object');
|
|
503
|
+
if (!Array.isArray(rm.rules)) fail('reviewMap.rules', 'must be an array');
|
|
504
|
+
const ids = new Set();
|
|
505
|
+
rm.rules.forEach((r, i) => {
|
|
506
|
+
const at = `reviewMap.rules[${i}]${r && r.id ? ` (${r.id})` : ''}`;
|
|
507
|
+
if (!r || typeof r.id !== 'string' || !r.id)
|
|
508
|
+
fail(at, 'id must be a non-empty string');
|
|
509
|
+
if (ids.has(r.id)) fail(at, `duplicate rule id "${r.id}"`);
|
|
510
|
+
ids.add(r.id);
|
|
511
|
+
if (!TIERS.includes(r.tier))
|
|
512
|
+
fail(at, `tier must be one of ${TIERS.join(', ')}`);
|
|
513
|
+
if (typeof r.reason !== 'string') fail(at, 'reason must be a string');
|
|
514
|
+
if (typeof r.match !== 'function') fail(at, 'match must be a function');
|
|
515
|
+
});
|
|
516
|
+
if (!Array.isArray(rm.sizeExcluded))
|
|
517
|
+
fail('reviewMap.sizeExcluded', 'must be an array of rule ids or globs');
|
|
518
|
+
for (const id of rm.sizeExcluded)
|
|
519
|
+
if (!/[/*]/.test(String(id)) && !ids.has(id))
|
|
520
|
+
fail(
|
|
521
|
+
'reviewMap.sizeExcluded',
|
|
522
|
+
`"${id}" is not a rule id in reviewMap.rules`,
|
|
523
|
+
);
|
|
524
|
+
(rm.humanLines ?? []).forEach((h, i) => {
|
|
525
|
+
const at = `reviewMap.humanLines[${i}]`;
|
|
526
|
+
// prettier-ignore
|
|
527
|
+
if (typeof h?.line !== 'string' || h.line === '' || !(typeof h.match === 'string' || h.match instanceof RegExp)) fail(at, 'humanLines need { match, line }');
|
|
528
|
+
});
|
|
529
|
+
for (const k of ['warn', 'oversize'])
|
|
530
|
+
if (!Number.isInteger(rm[k]) || rm[k] <= 0)
|
|
531
|
+
fail(`reviewMap.${k}`, 'must be a positive integer');
|
|
532
|
+
if (rm.warn >= rm.oversize)
|
|
533
|
+
fail('reviewMap.warn', 'must be below reviewMap.oversize');
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function validateDod(dod) {
|
|
537
|
+
if (!dod || typeof dod !== 'object') fail('dod', 'must be an object');
|
|
538
|
+
const { files } = dod;
|
|
539
|
+
if (!Array.isArray(files) || files.length === 0)
|
|
540
|
+
fail('dod.files', 'must be a non-empty array of globs');
|
|
541
|
+
if (!files.every((g) => typeof g === 'string' && g))
|
|
542
|
+
fail('dod.files', 'every glob must be a non-empty string');
|
|
543
|
+
if (typeof dod.contract !== 'string' || !dod.contract.includes('{id}'))
|
|
544
|
+
fail('dod.contract', 'must be a path containing {id}');
|
|
545
|
+
if (typeof dod.human !== 'string' || !dod.human)
|
|
546
|
+
fail('dod.human', 'must be a non-empty string');
|
|
547
|
+
if (!(dod.oversize instanceof RegExp))
|
|
548
|
+
fail('dod.oversize', 'must be a RegExp');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function validatePushGuard(guard) {
|
|
552
|
+
if (!guard || typeof guard !== 'object')
|
|
553
|
+
fail('pushGuard', 'must be an object');
|
|
554
|
+
const names = guard.protected;
|
|
555
|
+
if (!Array.isArray(names)) fail('pushGuard.protected', 'must be an array');
|
|
556
|
+
if (guard.protected.length === 0)
|
|
557
|
+
fail(
|
|
558
|
+
'pushGuard.protected',
|
|
559
|
+
'must not be empty: an empty list allows a push to main',
|
|
560
|
+
);
|
|
561
|
+
for (const n of names)
|
|
562
|
+
if (typeof n !== 'string' || !BRANCH_NAME.test(n))
|
|
563
|
+
fail(
|
|
564
|
+
'pushGuard.protected',
|
|
565
|
+
`"${String(n)}" is not a branch name ([A-Za-z0-9._/-])`,
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const REQUIRED_KEYS = ['reviewMap', 'dod'];
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Import <root>/gate.config.mjs and validate its default export again, so a plain
|
|
573
|
+
* object that never went through defineGate is still checked. Rejects with
|
|
574
|
+
* Error('gate.config: ...') when the file is missing, throws, or is invalid; the
|
|
575
|
+
* hooks that call it treat a rejection as "fail closed".
|
|
576
|
+
*/
|
|
577
|
+
export async function loadGateConfig(root = ROOT) {
|
|
578
|
+
const file = join(root, 'gate.config.mjs');
|
|
579
|
+
if (!existsSync(file))
|
|
580
|
+
fail('gate.config.mjs', `not found at ${file}; restore it from git`);
|
|
581
|
+
let mod;
|
|
582
|
+
try {
|
|
583
|
+
mod = await import(pathToFileURL(file).href);
|
|
584
|
+
} catch (err) {
|
|
585
|
+
fail('gate.config.mjs', `does not load: ${err?.message ?? err}`);
|
|
586
|
+
}
|
|
587
|
+
const exported = mod.default;
|
|
588
|
+
if (!exported || typeof exported !== 'object')
|
|
589
|
+
fail('gate.config.mjs', 'must default-export defineGate({...})');
|
|
590
|
+
// packCommands is derived from packs by defineGate, so a frozen gate carries it back in.
|
|
591
|
+
const { packCommands: _derived, ...config } = exported;
|
|
592
|
+
for (const key of REQUIRED_KEYS)
|
|
593
|
+
if (config[key] === undefined) fail(key, 'missing');
|
|
594
|
+
if (!Array.isArray(config.steps) || config.steps.length === 0)
|
|
595
|
+
fail('steps', 'missing or empty');
|
|
596
|
+
return defineGate(config);
|
|
597
|
+
}
|