@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
@@ -0,0 +1,397 @@
1
+ // The surface ratchet: every tracked file under the configured roots is classified in a
2
+ // manifest with one kind, and the kind decides what the file may import or do. Engine-shaped:
3
+ // no project path is named here; the manifest, baseline, roots and messages come from the caller.
4
+ // A manifest with `strict: true` also fails every pending entry, the code lines of `check` files over
5
+ // `budget.check` (which may only go down, against the baseline) and more than `budget.wiring` wiring files.
6
+ import { execFileSync } from 'node:child_process';
7
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { join, posix } from 'node:path';
9
+
10
+ // Built from parts so no source file carries the package scope as a literal.
11
+ const SCOPE = '@ticket-' + 'gate/';
12
+
13
+ const KINDS = [
14
+ 'config',
15
+ 'data',
16
+ 'check',
17
+ 'support',
18
+ 'shim',
19
+ 'test',
20
+ 'wiring',
21
+ 'pending',
22
+ ];
23
+ const CONFIG_TARGETS = new Set(['config', 'data', 'check', 'pending']);
24
+ const CONFIG_NODE = new Set(['node:path', 'node:url']);
25
+ const SHIM_MAX = 15;
26
+ const SHIM_IMPORT = /^(?:@ticket-gate\/|node:url$)/;
27
+ const ENGINE_INTERNAL = /^@ticket-gate\/[^/]+\/(?!config\.mjs$).+/;
28
+ const TEST_EXT = /\.(?:mjs|js|ts)$/;
29
+
30
+ class InputError extends Error {}
31
+ const inputError = (message) => new InputError(message);
32
+
33
+ /** '**' as a whole segment matches 0+ segments, '*' a run of non-'/' characters. */
34
+ export function globMatch(glob, path) {
35
+ const g = glob.split('/');
36
+ const p = path.split('/');
37
+ const seg = (s) =>
38
+ new RegExp(
39
+ `^${s
40
+ .split('*')
41
+ .map((x) => x.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
42
+ .join('[^/]*')}$`,
43
+ );
44
+ const walk = (gi, pi) => {
45
+ if (gi === g.length) return pi === p.length;
46
+ if (g[gi] === '**') {
47
+ for (let k = pi; k <= p.length; k++) if (walk(gi + 1, k)) return true;
48
+ return false;
49
+ }
50
+ return pi < p.length && seg(g[gi]).test(p[pi]) && walk(gi + 1, pi + 1);
51
+ };
52
+ return walk(0, 0);
53
+ }
54
+
55
+ /** Whether a '/' after `out` opens a regex literal (not a division): after nothing, an operator or punctuation. */
56
+ function regexAllowed(out) {
57
+ let k = out.length - 1;
58
+ while (k >= 0 && (out[k] === ' ' || out[k] === '\t')) k--;
59
+ if (k < 0 || out[k] === '\n') return true;
60
+ if ('=(,:;[!&|?{}+-*%<>~^'.includes(out[k])) return true;
61
+ return /(?:^|[^\w$])(?:return|typeof)$/.test(out.slice(0, k + 1));
62
+ }
63
+
64
+ /** The text with comments removed (string and template contents are kept), as non-blank lines. */
65
+ function codeOf(text) {
66
+ let out = '';
67
+ let quote = '';
68
+ for (let i = 0; i < text.length; i++) {
69
+ const c = text[i];
70
+ const next = text[i + 1];
71
+ if (quote) {
72
+ out += c;
73
+ if (c === '\\') out += text[++i] ?? '';
74
+ else if (c === quote) quote = '';
75
+ } else if (c === '/' && next === '/') {
76
+ while (i < text.length && text[i] !== '\n') i++;
77
+ out += '\n';
78
+ } else if (c === '/' && next === '*') {
79
+ i += 2;
80
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) {
81
+ if (text[i] === '\n') out += '\n';
82
+ i++;
83
+ }
84
+ i++;
85
+ } else if (c === '/' && regexAllowed(out)) {
86
+ out += c;
87
+ let inClass = false;
88
+ // Quotes inside a regex body are blanked, so importsOf never reads one as a string start.
89
+ const body = (ch) => (ch === "'" || ch === '"' || ch === '`' ? ' ' : ch);
90
+ for (i++; i < text.length && text[i] !== '\n'; i++) {
91
+ out += body(text[i]);
92
+ if (text[i] === '\\') out += body(text[++i] ?? '');
93
+ else if (text[i] === '[') inClass = true;
94
+ else if (text[i] === ']') inClass = false;
95
+ else if (text[i] === '/' && !inClass) break;
96
+ }
97
+ } else {
98
+ if (c === "'" || c === '"' || c === '`') quote = c;
99
+ out += c;
100
+ }
101
+ }
102
+ return out.split('\n').filter((l) => l.trim() !== '');
103
+ }
104
+
105
+ /** Non-blank lines outside '//' and block comments. */
106
+ export function codeLines(text) {
107
+ return codeOf(text).length;
108
+ }
109
+
110
+ /** A string literal, consumed first so a `from` or `import` inside one is never a specifier. */
111
+ const STRING = '\'(?:[^\'\\\\\\n]|\\\\.)*\'|"(?:[^"\\\\\\n]|\\\\.)*"|`(?:[^`\\\\]|\\\\.)*`';
112
+
113
+ /** Specifiers of `from '...'`, `import('...')` and `import '...'` on code lines, outside strings. */
114
+ export function importsOf(text) {
115
+ const code = codeOf(text).join('\n');
116
+ const out = [];
117
+ for (const re of [
118
+ /\bfrom\s*['"]([^'"]+)['"]/.source,
119
+ /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/.source,
120
+ /(?:^|[;{}])\s*import\s*['"]([^'"]+)['"]/.source,
121
+ ])
122
+ for (const m of code.matchAll(new RegExp(`${STRING}|${re}`, 'gm')))
123
+ if (m[1] !== undefined) out.push(m[1]);
124
+ return out;
125
+ }
126
+
127
+ function readJson(path, what) {
128
+ try {
129
+ return JSON.parse(readFileSync(path, 'utf8'));
130
+ } catch (e) {
131
+ throw inputError(`unreadable ${what} ${path}: ${e.message}`);
132
+ }
133
+ }
134
+
135
+ function loadManifest(path) {
136
+ const m = readJson(path, 'manifest');
137
+ if (!Array.isArray(m?.entries)) throw inputError('entries must be an array');
138
+ // prettier-ignore
139
+ if (m.strict !== undefined && typeof m.strict !== 'boolean') throw inputError('strict must be true or false');
140
+ if (m.strict === true) {
141
+ for (const key of ['check', 'wiring']) {
142
+ // prettier-ignore
143
+ if (!Number.isInteger(m.budget?.[key]) || m.budget[key] < 0) throw inputError(`strict needs budget.${key}, a non-negative integer`);
144
+ }
145
+ }
146
+ const seen = new Set();
147
+ m.entries.forEach((e, i) => {
148
+ const at = `entries[${i}]${Array.isArray(e?.match) ? ` (${e.match[0]})` : ''}`;
149
+ if (!Array.isArray(e?.match) || e.match.length === 0)
150
+ throw inputError(`${at}: match must be a non-empty array`);
151
+ for (const glob of e.match) {
152
+ if (typeof glob !== 'string' || glob === '')
153
+ throw inputError(`${at}: match holds a non-string glob`);
154
+ if (seen.has(glob)) throw inputError(`${at}: glob ${glob} is repeated`);
155
+ seen.add(glob);
156
+ }
157
+ // prettier-ignore
158
+ if (!KINDS.includes(e.kind)) throw inputError(`${at}: unknown kind ${e.kind}`);
159
+ // prettier-ignore
160
+ if (typeof e.why !== 'string' || e.why.trim() === '') throw inputError(`${at}: why must be a non-empty string`);
161
+ });
162
+ return m;
163
+ }
164
+
165
+ function loadBaseline(path) {
166
+ const b = readJson(path, 'baseline');
167
+ if (!Array.isArray(b?.pending))
168
+ throw inputError(`baseline ${path} has no "pending" array`);
169
+ // prettier-ignore
170
+ if (b.budget !== undefined && !(Number.isInteger(b.budget?.check) && b.budget.check >= 0)) throw inputError(`baseline ${path}: budget.check must be a non-negative integer`);
171
+ return { pending: new Set(b.pending), budgetCheck: b.budget?.check };
172
+ }
173
+
174
+ function trackedFiles(root, roots) {
175
+ let raw;
176
+ try {
177
+ raw = execFileSync('git', ['ls-files', '-z', '--', ...roots], {
178
+ cwd: root,
179
+ encoding: 'utf8',
180
+ maxBuffer: 64 * 1024 * 1024,
181
+ });
182
+ } catch (e) {
183
+ throw inputError(`cannot list tracked files in ${root}: ${e.message}`);
184
+ }
185
+ const inRoots = (f) =>
186
+ roots.some((r) => (r.endsWith('/') ? f.startsWith(r) : f === r));
187
+ return raw.split('\0').filter((f) => f && inRoots(f));
188
+ }
189
+
190
+ /**
191
+ * A surface ratchet bound to `cfg`: { manifest, baseline, roots, comment, fix, usage } (paths relative
192
+ * to the repo root). Returns the ratchet entry { baseline, check({ root, args, update }) }.
193
+ */
194
+ export function surface(cfg) {
195
+ const { manifest: manifestPath, baseline: baselinePath, roots } = cfg;
196
+
197
+ function run(root, args, updateFlag) {
198
+ let update = Boolean(updateFlag);
199
+ let seed = false;
200
+ for (const a of args ?? []) {
201
+ if (a === '--update') update = true;
202
+ else if (a === '--seed') seed = true;
203
+ else
204
+ throw Object.assign(inputError(`unknown argument ${a}`), {
205
+ usage: true,
206
+ });
207
+ }
208
+ if (seed && !update)
209
+ throw Object.assign(inputError('--seed needs --update'), {
210
+ usage: true,
211
+ });
212
+
213
+ const manifest = loadManifest(join(root, manifestPath));
214
+ const absBaseline = join(root, baselinePath);
215
+ const haveBaseline = existsSync(absBaseline);
216
+ if (!haveBaseline && !(update && seed))
217
+ throw inputError(
218
+ `no baseline at ${baselinePath}. Fix: run the ratchet once with --update --seed`,
219
+ );
220
+ const { pending: before, budgetCheck: floor } = haveBaseline
221
+ ? loadBaseline(absBaseline)
222
+ : { pending: new Set(), budgetCheck: undefined };
223
+ const strict = manifest.strict === true;
224
+ // prettier-ignore
225
+ if (strict && !update && floor === undefined) throw inputError(`baseline ${baselinePath} has no budget.check. Fix: run the ratchet once with --update`);
226
+
227
+ const globs = manifest.entries.flatMap((entry) =>
228
+ entry.match.map((glob) => ({ glob, entry })),
229
+ );
230
+ const pendingGlobs = globs
231
+ .filter((g) => g.entry.kind === 'pending')
232
+ .map((g) => g.glob);
233
+
234
+ if (update) {
235
+ const seeding = !haveBaseline;
236
+ const added = seeding ? [] : pendingGlobs.filter((g) => !before.has(g));
237
+ const refuse = (list) => ({
238
+ code: 1,
239
+ lines: [
240
+ `surface: refused: pending glob(s) not in the baseline: ${list.join(', ')}. A pending entry is never added.`,
241
+ cfg.fix,
242
+ ],
243
+ });
244
+ // prettier-ignore
245
+ if (added.length > 0) return refuse(added);
246
+ const refuseBudget = () => ({
247
+ code: 1,
248
+ lines: [
249
+ `surface: refused: budget.check ${manifest.budget.check} is over ${floor} in ${baselinePath}. A budget only goes down.`,
250
+ cfg.fix,
251
+ ],
252
+ });
253
+ // prettier-ignore
254
+ if (strict && manifest.budget.check > floor) return refuseBudget();
255
+ const next = [...new Set(pendingGlobs)].sort();
256
+ const removed = [...before].filter((g) => !next.includes(g)).length;
257
+ const kept = strict ? manifest.budget.check : floor;
258
+ const nextBaseline = { comment: cfg.comment, pending: next };
259
+ if (kept !== undefined) nextBaseline.budget = { check: kept };
260
+ writeFileSync(absBaseline, `${JSON.stringify(nextBaseline, null, 2)}\n`);
261
+ return {
262
+ code: 0,
263
+ lines: [
264
+ `surface: baseline ${baselinePath}: ${next.length} pending globs (removed ${removed})${strict ? `, budget.check ${kept}` : ''}`,
265
+ ],
266
+ };
267
+ }
268
+
269
+ const files = trackedFiles(root, roots);
270
+ const used = new Set();
271
+ const hitsOf = new Map();
272
+ for (const file of files) {
273
+ const hits = globs.filter((g) => globMatch(g.glob, file));
274
+ for (const h of hits) used.add(h.glob);
275
+ hitsOf.set(file, hits);
276
+ }
277
+ const kindOf = (file) => hitsOf.get(file)?.[0]?.entry.kind;
278
+ const ownerRe = new RegExp(manifest.ownerPattern ?? '.+');
279
+ const out = [];
280
+ let checkLines = 0;
281
+
282
+ function configImportOk(spec, file) {
283
+ if (spec.startsWith(SCOPE) || CONFIG_NODE.has(spec)) return true;
284
+ if (!spec.startsWith('.')) return false;
285
+ const target = posix.join(posix.dirname(file), spec);
286
+ return CONFIG_TARGETS.has(kindOf(target));
287
+ }
288
+
289
+ for (const file of files) {
290
+ const hits = hitsOf.get(file);
291
+ // prettier-ignore
292
+ if (hits.length === 0) out.push([1, file, 'matches no entry']);
293
+ // prettier-ignore
294
+ if (hits.length > 1) out.push([1, file, `matches ${hits.length} entries`]);
295
+ const kind = hits[0]?.entry.kind;
296
+ if (!kind || kind === 'pending') continue;
297
+ const abs = join(root, file);
298
+ if (!existsSync(abs)) continue;
299
+ const text = readFileSync(abs, 'utf8');
300
+ // prettier-ignore
301
+ if (kind === 'check') checkLines += codeLines(text);
302
+ if ((kind === 'config' || kind === 'data') && file.endsWith('.mjs')) {
303
+ for (const spec of importsOf(text))
304
+ // prettier-ignore
305
+ if (!configImportOk(spec, file)) out.push([3, file, `imports ${spec}`]);
306
+ for (const code of codeOf(text))
307
+ // prettier-ignore
308
+ if (/\bfetch\(/.test(code)) out.push([3, file, 'calls fetch(']);
309
+ }
310
+ if (kind === 'shim' && file.endsWith('.mjs')) {
311
+ const n = codeLines(text);
312
+ if (n > SHIM_MAX)
313
+ out.push([4, file, `has ${n} code lines, over ${SHIM_MAX}`]);
314
+ for (const spec of importsOf(text))
315
+ // prettier-ignore
316
+ if (!SHIM_IMPORT.test(spec)) out.push([4, file, `imports ${spec}`]);
317
+ }
318
+ if (kind === 'test' && TEST_EXT.test(file)) {
319
+ for (const spec of importsOf(text))
320
+ // prettier-ignore
321
+ if (ENGINE_INTERNAL.test(spec)) out.push([6, file, `imports ${spec}`]);
322
+ }
323
+ }
324
+
325
+ for (const { glob, entry } of globs) {
326
+ // prettier-ignore
327
+ if (!used.has(glob)) out.push([1, glob, 'matches no tracked file']);
328
+ if (entry.kind !== 'pending') continue;
329
+ // prettier-ignore
330
+ if (!before.has(glob)) out.push([2, glob, 'pending entry is not in the baseline']);
331
+ // prettier-ignore
332
+ if (!ownerRe.test(entry.owner ?? '')) out.push([2, glob, 'pending entry names no owner ticket']);
333
+ // prettier-ignore
334
+ if (strict) out.push([2, glob, 'pending entry in strict mode']);
335
+ }
336
+
337
+ const wiring = files.filter((f) => kindOf(f) === 'wiring');
338
+ // prettier-ignore
339
+ if (strict && checkLines > manifest.budget.check) out.push([5, manifestPath, `check files have ${checkLines} code lines, over budget.check ${manifest.budget.check}`]);
340
+ // prettier-ignore
341
+ if (strict && manifest.budget.check > floor) out.push([5, manifestPath, `budget.check ${manifest.budget.check} is over ${floor} in ${baselinePath}; the budget only goes down`]);
342
+ // prettier-ignore
343
+ if (strict && wiring.length > manifest.budget.wiring) out.push([6, manifestPath, `${wiring.length} files of kind wiring (${wiring.join(', ')}), over budget.wiring ${manifest.budget.wiring}`]);
344
+
345
+ if (out.length > 0) {
346
+ out.sort((a, b) => a[0] - b[0] || a[1].localeCompare(b[1]));
347
+ return {
348
+ code: 1,
349
+ lines: [
350
+ `surface: FAIL ${out.length} problem(s) in ${files.length} tracked files`,
351
+ ...out.map(([rule, path, why]) => ` [rule ${rule}] ${path}: ${why}`),
352
+ cfg.fix,
353
+ ],
354
+ };
355
+ }
356
+
357
+ const kinds = Object.fromEntries(KINDS.map((k) => [k, 0]));
358
+ for (const f of files) kinds[kindOf(f)]++;
359
+ const owners = new Map();
360
+ for (const { entry } of globs) {
361
+ if (entry.kind !== 'pending') continue;
362
+ owners.set(entry.owner, (owners.get(entry.owner) ?? 0) + 1);
363
+ }
364
+ const ownerList = [...owners]
365
+ .sort((a, b) => a[0].localeCompare(b[0], 'en', { numeric: true }))
366
+ .map(([o, n]) => `${o} ${n}`)
367
+ .join(', ');
368
+ return {
369
+ code: 0,
370
+ lines: [
371
+ `surface: OK ${files.length} tracked files, ${manifest.entries.length} entries, ${globs.length} globs`,
372
+ ` kinds (files): ${KINDS.map((k) => `${k} ${kinds[k]}`).join(', ')}`,
373
+ ` pending: ${pendingGlobs.length} globs (${ownerList})`,
374
+ ...(strict
375
+ ? [
376
+ ` budget: check ${checkLines}/${manifest.budget.check} code lines, wiring ${wiring.length}/${manifest.budget.wiring} files`,
377
+ ]
378
+ : []),
379
+ ],
380
+ };
381
+ }
382
+
383
+ return {
384
+ baseline: baselinePath,
385
+ check({ root, args, update }) {
386
+ try {
387
+ return run(root, args, update);
388
+ } catch (e) {
389
+ if (!(e instanceof InputError)) throw e;
390
+ return {
391
+ code: 2,
392
+ lines: [`surface: ${e.message}`, ...(e.usage ? [cfg.usage] : [])],
393
+ };
394
+ }
395
+ },
396
+ };
397
+ }