@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.
Files changed (92) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +81 -2
  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 +19 -4
  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
package/fu.mjs ADDED
@@ -0,0 +1,458 @@
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { DEFAULT_COMMANDS } from './config.mjs';
5
+
6
+ export const ID_RE = /^FU(\d+)([a-z]?)(?: \(([^)]+)\))?$/;
7
+ export const KEYS = ['id', 'status', 'severity', 'owner', 'fixed-in'];
8
+
9
+ const WORDS = /^(true|false|yes|no|on|off|null|y|n)$/i;
10
+
11
+ export function fileIdOf(id) {
12
+ const m = /^(FU\d+[a-z]?)(?: \((.+)\))?$/.exec(id);
13
+ if (!m) return id;
14
+ return m[2] === undefined ? m[1] : `${m[1]}-${m[2]}`;
15
+ }
16
+
17
+ export function idOfFileId(fileId) {
18
+ const m = /^(FU\d+[a-z]?)(?:-(.+))?$/.exec(fileId);
19
+ if (!m) return fileId;
20
+ return m[2] === undefined ? m[1] : `${m[1]} (${m[2]})`;
21
+ }
22
+
23
+ function needsQuote(value) {
24
+ return (
25
+ !/^[A-Za-z(]/.test(value) ||
26
+ value.includes(': ') ||
27
+ value.includes(' #') ||
28
+ /[\t\r\n]/.test(value) ||
29
+ /[:\s]$/.test(value) ||
30
+ WORDS.test(value)
31
+ );
32
+ }
33
+
34
+ function decode(raw, lineNo) {
35
+ if (raw.startsWith('"')) {
36
+ let v;
37
+ try {
38
+ v = JSON.parse(raw);
39
+ } catch {
40
+ throw new Error(`line ${lineNo} does not parse: bad double-quoted value`);
41
+ }
42
+ if (typeof v !== 'string')
43
+ throw new Error(`line ${lineNo} does not parse: not a string`);
44
+ return v;
45
+ }
46
+ if (raw.startsWith("'"))
47
+ throw new Error(
48
+ `line ${lineNo}: single quotes are not supported; use double quotes`,
49
+ );
50
+ return raw;
51
+ }
52
+
53
+ export function parseFile(text) {
54
+ if (!text.startsWith('---\n')) throw new Error('no frontmatter');
55
+ const lines = text.split('\n');
56
+ let close = -1;
57
+ for (let i = 1; i < lines.length; i++) {
58
+ if (lines[i] === '---') {
59
+ close = i;
60
+ break;
61
+ }
62
+ }
63
+ if (close === -1) throw new Error('no frontmatter: closing --- is missing');
64
+ const data = {};
65
+ const keys = [];
66
+ for (let i = 1; i < close; i++) {
67
+ const m = /^([a-z][a-z-]*): (.+)$/s.exec(lines[i]);
68
+ if (!m) throw new Error(`line ${i + 1} does not parse`);
69
+ if (m[1] in data) throw new Error(`line ${i + 1}: repeated key ${m[1]}`);
70
+ data[m[1]] = decode(m[2], i + 1);
71
+ keys.push(m[1]);
72
+ }
73
+ let body = lines.slice(close + 1).join('\n');
74
+ if (body.endsWith('\n')) body = body.slice(0, -1);
75
+ return { data, keys, body };
76
+ }
77
+
78
+ export function serialiseFile({ id, status, severity, owner, fixedIn, body }) {
79
+ const values = {
80
+ id,
81
+ status,
82
+ severity,
83
+ owner,
84
+ 'fixed-in': fixedIn,
85
+ };
86
+ let out = '---\n';
87
+ for (const key of KEYS) {
88
+ const value = values[key];
89
+ if (value === undefined || value === null || value === '') continue;
90
+ out += `${key}: ${needsQuote(value) ? JSON.stringify(value) : value}\n`;
91
+ }
92
+ return `${out}---\n${body}\n`;
93
+ }
94
+
95
+ const notTrailing = (id) =>
96
+ new Error(`${id}: Owner/Fixed in not trailing; migrate by hand`);
97
+
98
+ export function parseRow(line) {
99
+ const m =
100
+ /^- \[( |x)\] \*\*(FU\d+[a-z]?(?: \([^)]+\))?)\*\*(?: \(([^)]*)\))? (.*)$/.exec(
101
+ line,
102
+ );
103
+ if (!m) throw new Error(`not a row: ${line.slice(0, 60)}`);
104
+ const [, box, id, severity, rest] = m;
105
+ const count = (s) => rest.split(s).length - 1;
106
+ const fAt = rest.indexOf('. Fixed in: ');
107
+ const oAt = rest.indexOf('. Owner: ');
108
+ const trailing =
109
+ count('Fixed in:') === (fAt === -1 ? 0 : 1) &&
110
+ count('Owner:') === (oAt === -1 ? 0 : 1) &&
111
+ (fAt === -1 || oAt === -1 || oAt < fAt);
112
+ if (!trailing) throw notTrailing(id);
113
+ let work = rest;
114
+ let fixedIn;
115
+ if (fAt !== -1) {
116
+ fixedIn = work.slice(fAt + '. Fixed in: '.length);
117
+ work = work.slice(0, fAt + 1);
118
+ }
119
+ let owner;
120
+ if (oAt !== -1) {
121
+ owner = work.slice(oAt + '. Owner: '.length);
122
+ work = work.slice(0, oAt + 1);
123
+ }
124
+ const row = { id, status: box === 'x' ? 'done' : 'open' };
125
+ if (severity !== undefined) row.severity = severity;
126
+ if (owner !== undefined) row.owner = owner;
127
+ if (fixedIn !== undefined) row.fixedIn = fixedIn;
128
+ row.body = work;
129
+ return row;
130
+ }
131
+
132
+ export function toRow(text) {
133
+ const { data, body } = parseFile(text);
134
+ return (
135
+ `- [${data.status === 'done' ? 'x' : ' '}] **${data.id}**` +
136
+ (data.severity ? ` (${data.severity})` : '') +
137
+ ` ${body}` +
138
+ (data.owner ? ` Owner: ${data.owner}` : '') +
139
+ (data['fixed-in'] ? ` Fixed in: ${data['fixed-in']}` : '')
140
+ );
141
+ }
142
+
143
+ export function compareIds(a, b) {
144
+ const pa = ID_RE.exec(a);
145
+ const pb = ID_RE.exec(b);
146
+ if (!pa || !pb) return a < b ? -1 : a > b ? 1 : 0;
147
+ if (Number(pa[1]) !== Number(pb[1])) return Number(pa[1]) - Number(pb[1]);
148
+ if (pa[2] !== pb[2]) return pa[2] < pb[2] ? -1 : 1;
149
+ const sa = pa[3] ?? '';
150
+ const sb = pb[3] ?? '';
151
+ if (sa === sb) return 0;
152
+ if (sa === '') return -1;
153
+ if (sb === '') return 1;
154
+ return sa < sb ? -1 : 1;
155
+ }
156
+
157
+ function cut(s, n) {
158
+ return s.length > n ? `${s.slice(0, n - 3)}...` : s;
159
+ }
160
+
161
+ export function summaryOf(body, max) {
162
+ return cut(body.split(/\.(?:\s|$)/)[0].replaceAll('`', ''), max);
163
+ }
164
+
165
+ export function readFollowUps(dir) {
166
+ return readdirSync(dir)
167
+ .filter((n) => n.endsWith('.md') && n !== 'README.md')
168
+ .sort()
169
+ .map((name) => ({ name, text: readFileSync(join(dir, name), 'utf8') }));
170
+ }
171
+
172
+ function parseArgs(args, valueFlags, boolFlags) {
173
+ const flags = {};
174
+ const positional = [];
175
+ for (let i = 0; i < args.length; i++) {
176
+ const a = args[i];
177
+ if (valueFlags.includes(a)) {
178
+ if (i + 1 >= args.length) return { error: `${a} needs a value` };
179
+ flags[a] = args[++i];
180
+ } else if (boolFlags.includes(a)) flags[a] = true;
181
+ else if (a.startsWith('--')) return { error: `unknown flag ${a}` };
182
+ else positional.push(a);
183
+ }
184
+ return { flags, positional };
185
+ }
186
+
187
+ const usage = (fuCmd) =>
188
+ `usage: ${fuCmd} list [--all] [--status open|done] [--owner <s>] | show <id> | next | close <id> --by <TICKET> --sha <sha>`;
189
+
190
+ /**
191
+ * `dir` is where the files are; `label` (default `dir`) is how messages name it; `fuCmd` is the
192
+ * command a message tells the reader to run (the project's `commands.fu`).
193
+ */
194
+ export function run(
195
+ argv,
196
+ { dir, out, err, label = dir, fuCmd = DEFAULT_COMMANDS.fu },
197
+ ) {
198
+ const USAGE = usage(fuCmd);
199
+ const [cmd, ...args] = argv;
200
+ const load = () => {
201
+ const items = [];
202
+ for (const f of readFollowUps(dir)) {
203
+ try {
204
+ items.push({ ...f, ...parseFile(f.text) });
205
+ } catch (e) {
206
+ err(`${label}/${f.name}: ${e.message}`);
207
+ return null;
208
+ }
209
+ }
210
+ return items;
211
+ };
212
+ const find = (items, id) =>
213
+ items.find(
214
+ (f) =>
215
+ f.data.id === id ||
216
+ f.name === `${id}.md` ||
217
+ fileIdOf(id) + '.md' === f.name,
218
+ );
219
+
220
+ if (cmd === 'list') {
221
+ const p = parseArgs(args, ['--status', '--owner'], ['--all']);
222
+ if (p.error || p.positional.length) {
223
+ err(p.error ?? USAGE);
224
+ return 2;
225
+ }
226
+ const status = p.flags['--status'];
227
+ if (status !== undefined && status !== 'open' && status !== 'done') {
228
+ err('--status must be open or done');
229
+ return 2;
230
+ }
231
+ const items = load();
232
+ if (!items) return 1;
233
+ const owner = p.flags['--owner']?.toLowerCase();
234
+ items
235
+ .filter((f) =>
236
+ status === undefined
237
+ ? p.flags['--all'] || f.data.status === 'open'
238
+ : f.data.status === status,
239
+ )
240
+ .filter(
241
+ (f) =>
242
+ owner === undefined ||
243
+ (f.data.owner ?? '').toLowerCase().includes(owner),
244
+ )
245
+ .sort((a, b) => compareIds(a.data.id, b.data.id))
246
+ .forEach((f) =>
247
+ out(
248
+ [
249
+ f.data.id,
250
+ f.data.severity ?? '-',
251
+ cut(f.data.owner ?? '-', 60),
252
+ summaryOf(f.body, 100),
253
+ ].join('\t'),
254
+ ),
255
+ );
256
+ return 0;
257
+ }
258
+
259
+ if (cmd === 'show') {
260
+ if (args.length !== 1) {
261
+ err(USAGE);
262
+ return 2;
263
+ }
264
+ const items = load();
265
+ if (!items) return 1;
266
+ const f = find(items, args[0]);
267
+ if (!f) {
268
+ err(`show: no follow-up ${args[0]} in ${label}/`);
269
+ return 1;
270
+ }
271
+ out(f.text.replace(/\n$/, ''));
272
+ return 0;
273
+ }
274
+
275
+ if (cmd === 'next') {
276
+ if (args.length) {
277
+ err(USAGE);
278
+ return 2;
279
+ }
280
+ let max = 0;
281
+ for (const f of readFollowUps(dir)) {
282
+ const m = /^FU(\d+)/.exec(f.name);
283
+ if (m) max = Math.max(max, Number(m[1]));
284
+ }
285
+ out(`FU${max + 1}`);
286
+ return 0;
287
+ }
288
+
289
+ if (cmd === 'close') {
290
+ const p = parseArgs(args, ['--by', '--sha'], []);
291
+ if (
292
+ p.error ||
293
+ p.positional.length !== 1 ||
294
+ !p.flags['--by'] ||
295
+ !p.flags['--sha']
296
+ ) {
297
+ err(p.error ?? 'close needs <id> --by <TICKET> --sha <sha>');
298
+ return 2;
299
+ }
300
+ const items = load();
301
+ if (!items) return 1;
302
+ const f = find(items, p.positional[0]);
303
+ if (!f) {
304
+ err(`close: no follow-up ${p.positional[0]} in ${label}/`);
305
+ return 1;
306
+ }
307
+ const { data } = f;
308
+ const alreadyDone = data.status === 'done';
309
+ if (alreadyDone) {
310
+ err(`close: ${data.id} is already done (fixed-in: ${data['fixed-in']})`);
311
+ return 1;
312
+ }
313
+ const fixedIn = `${p.flags['--by']} (${p.flags['--sha']}).`;
314
+ const path = join(dir, f.name);
315
+ writeFileSync(
316
+ path,
317
+ serialiseFile({
318
+ id: data.id,
319
+ status: 'done',
320
+ severity: data.severity,
321
+ owner: data.owner,
322
+ fixedIn,
323
+ body: f.body,
324
+ }),
325
+ );
326
+ out(`closed ${data.id}: ${fixedIn}`);
327
+ return 0;
328
+ }
329
+
330
+ err(USAGE);
331
+ return 2;
332
+ }
333
+
334
+ const STATUSES = ['open', 'done'];
335
+ const POINTER_ROW = /^- \[[ xX]\] \*\*FU/m;
336
+
337
+ /** Numbers named in the README sentence that says which ids were minted twice. */
338
+ function grandfathered(readme) {
339
+ const head = readme.split(/^## Collisions \/ ambiguities/m)[0];
340
+ const sentence =
341
+ head.split('\n').find((l) => /minted twice|used twice/.test(l)) ?? '';
342
+ const nums = new Set();
343
+ for (const m of sentence.matchAll(/FU(\d+)(?:-FU(\d+))?/g)) {
344
+ const lo = Number(m[1]);
345
+ const hi = m[2] ? Number(m[2]) : lo;
346
+ for (let n = lo; n <= hi; n++) nums.add(n);
347
+ }
348
+ return nums;
349
+ }
350
+
351
+ /** `dir` is the register folder as the messages name it; `pointerPath` the pointer page's path. */
352
+ export function checkFollowUps({
353
+ files,
354
+ readme,
355
+ pointer,
356
+ dir,
357
+ pointerPath,
358
+ fuCmd = DEFAULT_COMMANDS.fu,
359
+ }) {
360
+ const errors = [];
361
+ const README = `${dir}/README.md`;
362
+ const at = (name) => `${dir}/${name}`;
363
+ if (readme === null) errors.push(`${README} is missing`);
364
+ const exempt = grandfathered(readme ?? '');
365
+ const seen = new Map(); // number -> [{ id, suffixed }]
366
+
367
+ for (const { name, text } of files) {
368
+ let parsed;
369
+ try {
370
+ parsed = parseFile(text);
371
+ } catch (e) {
372
+ errors.push(
373
+ `${at(name)}: ${e.message}; start the file with a --- frontmatter block as ${README} shows.`,
374
+ );
375
+ continue;
376
+ }
377
+ const { data, keys } = parsed;
378
+ for (const key of keys) {
379
+ if (!KEYS.includes(key))
380
+ errors.push(
381
+ `${at(name)}: unknown key "${key}"; allowed keys are ${KEYS.join(', ')}.`,
382
+ );
383
+ }
384
+ const { id, status } = data;
385
+ if (!STATUSES.includes(status))
386
+ errors.push(
387
+ `${at(name)}: status is ${status === undefined ? 'missing' : `"${status}"`}; use "open" or "done".`,
388
+ );
389
+ if (status === 'open' && data.owner === undefined)
390
+ errors.push(
391
+ `${at(name)}: open with no "owner:": add "owner: <ticket or role>" (or "owner: none" with a reason).`,
392
+ );
393
+ if (status === 'done' && data['fixed-in'] === undefined)
394
+ errors.push(
395
+ `${at(name)}: done with no "fixed-in:": add "fixed-in: <ticket> (<sha>)" or say why it was closed without a fix (\`${fuCmd} close\` sets it).`,
396
+ );
397
+
398
+ const m = id === undefined ? null : ID_RE.exec(id);
399
+ if (!m) {
400
+ errors.push(
401
+ `${at(name)}: id ${id === undefined ? 'is missing' : `"${id}"`} does not look like FU<n>, FU<n><letter> or FU<n> (<ticket>).`,
402
+ );
403
+ continue;
404
+ }
405
+ if (name !== `${fileIdOf(id)}.md`)
406
+ errors.push(
407
+ `${at(name)}: the file is named for the wrong id; id ${id} belongs in ${fileIdOf(id)}.md (rename the file or fix the id).`,
408
+ );
409
+
410
+ const num = Number(m[1]);
411
+ const suffixed = Boolean(m[3]);
412
+ if (exempt.has(num)) continue;
413
+ const list = seen.get(num) ?? [];
414
+ const dup = list.find(
415
+ (o) =>
416
+ o.id === id ||
417
+ (o.id.replace(/ \(.*\)$/, '') === id.replace(/ \(.*\)$/, '') &&
418
+ (!o.suffixed || !suffixed)),
419
+ );
420
+ if (dup)
421
+ errors.push(
422
+ `${at(name)}: ${id} duplicates ${dup.id}: take the id \`${fuCmd} next\` prints, or disambiguate both as FUnn (<ticket>) and record the collision in ${README}.`,
423
+ );
424
+ list.push({ id, suffixed });
425
+ seen.set(num, list);
426
+ }
427
+
428
+ if (pointer !== null && POINTER_ROW.test(pointer))
429
+ errors.push(
430
+ `${pointerPath} holds a follow-up row: rows live one per file in ${dir}/; move it there (\`${fuCmd} next\` gives the id) and leave the page a pointer.`,
431
+ );
432
+ return errors;
433
+ }
434
+
435
+ /** The `check:follow-ups` command: `followUps` is the config slot; returns the exit code. */
436
+ export function runCheck(
437
+ root,
438
+ followUps,
439
+ { out, err, fuCmd = DEFAULT_COMMANDS.fu },
440
+ ) {
441
+ const read = (rel) =>
442
+ existsSync(join(root, rel)) ? readFileSync(join(root, rel), 'utf8') : null;
443
+ const errors = checkFollowUps({
444
+ files: readFollowUps(join(root, followUps.dir)),
445
+ readme: read(`${followUps.dir}/README.md`),
446
+ pointer: followUps.pointer ? read(followUps.pointer) : null,
447
+ dir: followUps.dir,
448
+ pointerPath: followUps.pointer,
449
+ fuCmd,
450
+ });
451
+ if (errors.length === 0) {
452
+ out('check:follow-ups OK');
453
+ return 0;
454
+ }
455
+ err(`check:follow-ups FAILED (${errors.length}):`);
456
+ for (const e of errors) err(` ${e}`);
457
+ return 1;
458
+ }