@ticket-gate/engine 0.0.0 → 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +82 -0
  3. package/bin/ticket-gate.mjs +261 -0
  4. package/checks/assertions-unguarded.mjs +31 -0
  5. package/checks/assertions.mjs +319 -0
  6. package/checks/cache-report.mjs +313 -0
  7. package/checks/docs-stale.mjs +69 -0
  8. package/checks/duplication.mjs +258 -0
  9. package/checks/ratchet-options.mjs +51 -0
  10. package/checks/surface.mjs +397 -0
  11. package/cli.mjs +379 -0
  12. package/commands-config.mjs +55 -0
  13. package/config.mjs +597 -0
  14. package/digest.mjs +195 -0
  15. package/env-name.mjs +24 -0
  16. package/failures.mjs +256 -0
  17. package/fu.mjs +458 -0
  18. package/graph/js.mjs +517 -0
  19. package/graph/load-time.mjs +309 -0
  20. package/graph/typescript.mjs +28 -0
  21. package/hooks/pre-tool-use.mjs +213 -0
  22. package/hooks/stop.mjs +495 -0
  23. package/is-main.mjs +15 -0
  24. package/kill-group.mjs +13 -0
  25. package/ledger.mjs +186 -0
  26. package/lock.d.mts +72 -0
  27. package/lock.mjs +329 -0
  28. package/move-proof.mjs +206 -0
  29. package/mutations/check.mjs +506 -0
  30. package/mutations/cli.mjs +361 -0
  31. package/mutations/exec.mjs +120 -0
  32. package/mutations/gate.mjs +148 -0
  33. package/mutations/harness.mjs +240 -0
  34. package/mutations/kinds.mjs +148 -0
  35. package/mutations/locate.mjs +71 -0
  36. package/mutations/project.mjs +23 -0
  37. package/mutations/registry.mjs +173 -0
  38. package/mutations/rotate.mjs +419 -0
  39. package/mutations/rotation-status.mjs +93 -0
  40. package/mutations/run.mjs +252 -0
  41. package/mutations/select.mjs +333 -0
  42. package/mutations/served.mjs +216 -0
  43. package/mutations/validate.mjs +86 -0
  44. package/mutations/verdict.mjs +159 -0
  45. package/nightly/cli.mjs +30 -0
  46. package/nightly/define.mjs +111 -0
  47. package/nightly/e2e-runs.mjs +73 -0
  48. package/nightly/gate.mjs +25 -0
  49. package/nightly/io.mjs +109 -0
  50. package/nightly/launchd.mjs +42 -0
  51. package/nightly/record.mjs +231 -0
  52. package/nightly/run.mjs +470 -0
  53. package/nightly/schedule.mjs +76 -0
  54. package/nightly/seed.mjs +78 -0
  55. package/nightly/status.mjs +189 -0
  56. package/nightly/suites.mjs +43 -0
  57. package/nightly-config.mjs +38 -0
  58. package/nightly-window.mjs +6 -0
  59. package/pack-hooks-all.mjs +15 -0
  60. package/pack-hooks.mjs +26 -0
  61. package/package.json +21 -1
  62. package/plan.mjs +272 -0
  63. package/precheck.mjs +90 -0
  64. package/project.mjs +21 -0
  65. package/ratchet.mjs +431 -0
  66. package/receipt.mjs +34 -0
  67. package/review-map.mjs +432 -0
  68. package/root.mjs +33 -0
  69. package/run.mjs +291 -0
  70. package/runners/playwright.mjs +132 -0
  71. package/score/adapters/stryker.mjs +111 -0
  72. package/score/base.mjs +82 -0
  73. package/score/cli.mjs +182 -0
  74. package/score/judge.mjs +107 -0
  75. package/score/lines.mjs +40 -0
  76. package/score/schema.mjs +68 -0
  77. package/score/survivors.mjs +103 -0
  78. package/score/targets.mjs +34 -0
  79. package/score-config.mjs +120 -0
  80. package/selection/check.mjs +45 -0
  81. package/selection/cli.mjs +267 -0
  82. package/selection/define.mjs +55 -0
  83. package/selection/for.mjs +235 -0
  84. package/selection/graphs.mjs +38 -0
  85. package/selection/project.mjs +11 -0
  86. package/selection/select.mjs +159 -0
  87. package/selection/units.mjs +646 -0
  88. package/selection/value-rules.mjs +142 -0
  89. package/selection-config.mjs +76 -0
  90. package/serve.mjs +142 -0
  91. package/summary.mjs +34 -0
  92. package/tree.mjs +211 -0
package/review-map.mjs ADDED
@@ -0,0 +1,432 @@
1
+ // Review map: puts every path this branch changes in exactly one tier, so a
2
+ // person knows which files they must read line by line.
3
+ //
4
+ // HUMAN a person reads every line (auth, RLS, migrations, secrets, invites,
5
+ // dependencies, and anything no rule recognises)
6
+ // GATED a person reads only what a gate flags (non-test core and lib logic)
7
+ // MACHINE gates only; a person spot-checks at most (UI, tests, docs, generated)
8
+ //
9
+ // Rules are evaluated in order and the first match wins, so every HUMAN row sits
10
+ // above every other row: HUMAN beats everything. A path no row matches is HUMAN
11
+ // (row D), because an unclassified file is one nobody decided could be skipped.
12
+ //
13
+ // The same walk measures the diff size (SIZE_EXCLUDED rows are left out) that
14
+ // `verify:ticket` warns about above SIZE_WARN and records as `oversize` above
15
+ // SIZE_OVERSIZE; the Stop hook enforces the latter.
16
+ //
17
+ // H11 sits after H10: a diff that adds a lint, type, compiler or mutation
18
+ // suppression (SUPPRESSION) is HUMAN, unless the path is CONTENT_EXEMPT. Only
19
+ // added lines count; the first matching row wins, so H1..H10 keep their reason.
20
+ //
21
+ // H1 (a migration) stays HUMAN, but its reason lists the line ranges outside
22
+ // every `create [or replace] function` statement (migrationReadRanges): a changed
23
+ // function body is read in its supabase/schema/functions/ file, where the diff is
24
+ // the change. A migration the parser cannot split reads whole (no ranges).
25
+ //
26
+ // node review-map.mjs [--base=<sha>] [--full]
27
+ // print the map, write .turbo/review-map.json. MACHINE prints as a count
28
+ // line; --full lists every MACHINE path.
29
+ import { execFileSync } from 'node:child_process';
30
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
31
+ import { basename, dirname, join } from 'node:path';
32
+ import { GATE as config, CONFIG_EXPORTS as project } from './project.mjs';
33
+ import { globRe } from './config.mjs';
34
+ import { packHook } from './pack-hooks.mjs';
35
+ import { registryHooks } from './mutations/gate.mjs';
36
+ import { ROOT } from './root.mjs';
37
+ import { baseRef } from './tree.mjs';
38
+
39
+ // The suppression and registry-retirement rows are the project's: a config that exports
40
+ // none of them has no H11/H12 row and these stay inert.
41
+ export const SUPPRESSION = project.SUPPRESSION ?? /(?!)/;
42
+ export const suppressionLines = project.suppressionLines ?? (() => []);
43
+ export const registryChanges = project.registryChanges ?? (() => []);
44
+ export { ROOT };
45
+ // SQL read ranges are a pack hook (sqlRanges); without one a migration reads whole.
46
+ export const migrationReadRanges = (text, inSnapshot) =>
47
+ packHook('sqlRanges')?.migrationReadRanges?.(text, inSnapshot) ?? null;
48
+ const migrationFacts = (path, root) =>
49
+ packHook('sqlRanges')?.migrationFacts?.(path, root) ?? {
50
+ text: null,
51
+ inSnapshot: undefined,
52
+ };
53
+ const H1_REASON = (fmt) => packHook('sqlRanges').H1_REASON(fmt);
54
+
55
+ // The rules, size thresholds and size exclusions live in gate.config.mjs
56
+ // (reviewMap); the names below are kept for the callers that import them.
57
+ export const RULES = config.reviewMap.rules;
58
+ export const SIZE_EXCLUDED = config.reviewMap.sizeExcluded;
59
+ export const SIZE_WARN = config.reviewMap.warn;
60
+ export const SIZE_OVERSIZE = config.reviewMap.oversize;
61
+ export const HUMAN_LINES = config.reviewMap.humanLines ?? [];
62
+ export const GATED_LABEL = 'GATED (core logic: mutation gate)';
63
+
64
+ const UNCLASSIFIED = {
65
+ id: '-',
66
+ tier: 'HUMAN',
67
+ reason: 'unclassified: no rule matched',
68
+ };
69
+
70
+ export function classify(f) {
71
+ // A config with no catch-all row still puts every path in a tier: HUMAN, unread otherwise.
72
+ const row = RULES.find((r) => r.match(f)) ?? UNCLASSIFIED;
73
+ if (row.id === 'H12')
74
+ return {
75
+ tier: row.tier,
76
+ rule: row.id,
77
+ reason: `${row.reason}: ${registryChanges(f)
78
+ .map((c) => `${c.key} (${c.base ? 'base line' : 'line'} ${c.line})`)
79
+ .join(', ')}`,
80
+ };
81
+ if (row.id === 'H11')
82
+ return {
83
+ tier: row.tier,
84
+ rule: row.id,
85
+ reason: `${row.reason}: lines ${suppressionLines(f).join(', ')}`,
86
+ };
87
+ const ranges =
88
+ row.id === 'H1' && typeof f.text === 'string'
89
+ ? migrationReadRanges(f.text, f.inSnapshot)
90
+ : null;
91
+ if (ranges) {
92
+ const fmt = ranges.map(([a, b]) => (a === b ? `${a}` : `${a}-${b}`));
93
+ return {
94
+ tier: row.tier,
95
+ rule: row.id,
96
+ reason: `${row.reason}: ${H1_REASON(fmt)}`,
97
+ ranges,
98
+ };
99
+ }
100
+ return { tier: row.tier, rule: row.id, reason: row.reason };
101
+ }
102
+
103
+ export function excludedFromSize(path) {
104
+ const probe = { path, lines: [], depsChanged: false };
105
+ return SIZE_EXCLUDED.some((id) =>
106
+ /[/*]/.test(id)
107
+ ? globRe(id).test(path)
108
+ : RULES.find((r) => r.id === id).match(probe),
109
+ );
110
+ }
111
+
112
+ const hitPath = (match, path) =>
113
+ typeof match === 'string' ? globRe(match).test(path) : match.test(path);
114
+
115
+ /** The `line`s whose `match` (a glob or a RegExp) hits a changed path, once each, in config order. */
116
+ export function humanLinesFor(humanLines, paths) {
117
+ return [
118
+ ...new Set(
119
+ humanLines
120
+ .filter((h) => paths.some((p) => hitPath(h.match, p)))
121
+ .map((h) => h.line),
122
+ ),
123
+ ];
124
+ }
125
+
126
+ const DEP_FIELDS = [
127
+ 'dependencies',
128
+ 'devDependencies',
129
+ 'peerDependencies',
130
+ 'optionalDependencies',
131
+ ];
132
+ const stable = (v) =>
133
+ JSON.stringify(
134
+ Object.fromEntries(
135
+ Object.entries(v ?? {}).sort(([a], [b]) => (a < b ? -1 : 1)),
136
+ ),
137
+ );
138
+
139
+ /** True when any dependency field differs; missing and `{}` are equal; bad JSON is true. */
140
+ export function depsChanged(beforeJson, afterJson) {
141
+ let before;
142
+ let after;
143
+ try {
144
+ before = beforeJson === null ? {} : JSON.parse(beforeJson);
145
+ after = afterJson === null ? {} : JSON.parse(afterJson);
146
+ } catch {
147
+ return true;
148
+ }
149
+ return DEP_FIELDS.some((k) => stable(before?.[k]) !== stable(after?.[k]));
150
+ }
151
+
152
+ export function sizeLevel(lines) {
153
+ if (lines > SIZE_OVERSIZE) return 'oversize';
154
+ if (lines > SIZE_WARN) return 'warn';
155
+ return 'ok';
156
+ }
157
+
158
+ const git = (root, args) =>
159
+ execFileSync('git', ['-c', 'core.quotepath=off', ...args], {
160
+ cwd: root,
161
+ encoding: 'utf8',
162
+ maxBuffer: 512 * 1024 * 1024,
163
+ stdio: ['ignore', 'pipe', 'pipe'],
164
+ });
165
+
166
+ const readOrNull = (fn) => {
167
+ try {
168
+ return fn();
169
+ } catch {
170
+ return null;
171
+ }
172
+ };
173
+
174
+ /** Pure parse of `git diff -U0 --no-renames --no-color` output.
175
+ * Map<path, { lines, addedLines }>; the new-file counter starts at the `+c` of
176
+ * each `@@ -a[,b] +c[,d] @@` header and advances on '+' and ' ' lines only. */
177
+ export function parseUnifiedDiff(text) {
178
+ const out = new Map();
179
+ let current = null;
180
+ let inHunk = false;
181
+ let next = 0;
182
+ for (const l of text.split('\n')) {
183
+ const header = /^diff --git a\/(.+) b\/\1$/.exec(l);
184
+ if (header) {
185
+ current = header[1];
186
+ inHunk = false;
187
+ out.set(current, { lines: [], addedLines: [] });
188
+ continue;
189
+ }
190
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(l);
191
+ if (hunk) {
192
+ inHunk = true;
193
+ next = Number(hunk[1]);
194
+ } else if (inHunk && current) {
195
+ const rec = out.get(current);
196
+ if (l.startsWith('+')) {
197
+ rec.lines.push(l.slice(1));
198
+ rec.addedLines.push({ line: next, text: l.slice(1) });
199
+ next += 1;
200
+ } else if (l.startsWith('-')) rec.lines.push(l.slice(1));
201
+ else if (l.startsWith(' ')) next += 1;
202
+ }
203
+ }
204
+ return out;
205
+ }
206
+
207
+ /** Per changed path: added, removed, the diff lines, and whether dependencies changed. */
208
+ export function collect(base, root = ROOT) {
209
+ const tracked = git(root, ['diff', '-z', '--name-only', '--no-renames', base])
210
+ .split('\0')
211
+ .filter(Boolean);
212
+ const untracked = git(root, [
213
+ 'ls-files',
214
+ '-z',
215
+ '--others',
216
+ '--exclude-standard',
217
+ ])
218
+ .split('\0')
219
+ .filter(Boolean);
220
+ const stats = new Map();
221
+ for (const rec of git(root, ['diff', '--numstat', '-z', '--no-renames', base])
222
+ .split('\0')
223
+ .filter(Boolean)) {
224
+ const [a, r, ...rest] = rec.split('\t');
225
+ stats.set(rest.join('\t'), {
226
+ added: Number(a) || 0,
227
+ removed: Number(r) || 0,
228
+ });
229
+ }
230
+ const diffs = parseUnifiedDiff(
231
+ git(root, ['diff', '-U0', '--no-renames', '--no-color', base]),
232
+ );
233
+ const isRegistryFile = (p) => config.registryFile?.(p) === true;
234
+ const paths = [...new Set([...tracked, ...untracked])].sort();
235
+ let baseReg = null;
236
+ let treeReg = null;
237
+ if (paths.some(isRegistryFile)) {
238
+ const reg = registryHooks(config);
239
+ baseReg = reg?.at?.(base, root).entries ?? new Map();
240
+ treeReg = reg?.load?.(root).entries ?? new Map();
241
+ }
242
+ const registryOf = (path) => {
243
+ const retired = [];
244
+ const removed = [];
245
+ for (const [key, v] of treeReg) {
246
+ if (v.path !== path || !v.entry.retired) continue;
247
+ if (!baseReg.get(key)?.entry.retired) retired.push({ key, line: v.line });
248
+ }
249
+ for (const [key, v] of baseReg)
250
+ if (v.path === path && !treeReg.has(key))
251
+ removed.push({ key, line: v.line });
252
+ return { retired, removed };
253
+ };
254
+ return paths.map((path) => {
255
+ let added = 0;
256
+ let removed = 0;
257
+ let lines = [];
258
+ let addedLines = [];
259
+ if (stats.has(path)) ({ added, removed } = stats.get(path));
260
+ else if (untracked.includes(path)) {
261
+ const buf = readOrNull(() => readFileSync(join(root, path)));
262
+ if (buf && !buf.subarray(0, 8000).includes(0)) {
263
+ const text = buf.toString('utf8');
264
+ lines = text.split('\n');
265
+ if (lines[lines.length - 1] === '') lines.pop();
266
+ added = lines.length;
267
+ addedLines = lines.map((text, i) => ({ line: i + 1, text }));
268
+ }
269
+ }
270
+ if (diffs.has(path)) ({ lines, addedLines } = diffs.get(path));
271
+ const deps =
272
+ basename(path) === 'package.json'
273
+ ? depsChanged(
274
+ readOrNull(() => git(root, ['show', `${base}:${path}`])),
275
+ readOrNull(() => readFileSync(join(root, path), 'utf8')),
276
+ )
277
+ : false;
278
+ const { text = null, inSnapshot } = migrationFacts(path, root);
279
+ return {
280
+ path,
281
+ ...(isRegistryFile(path) ? { registry: registryOf(path) } : {}),
282
+ added,
283
+ removed,
284
+ lines,
285
+ addedLines,
286
+ depsChanged: deps,
287
+ text,
288
+ inSnapshot,
289
+ };
290
+ });
291
+ }
292
+
293
+ const empty = () => ({ files: 0, lines: 0 });
294
+
295
+ export function buildMap(files, base) {
296
+ const totals = { HUMAN: empty(), GATED: empty(), MACHINE: empty() };
297
+ let size = 0;
298
+ const out = files
299
+ .map((f) => {
300
+ const { tier, rule, reason, ranges } = classify(f);
301
+ const n = f.added + f.removed;
302
+ totals[tier].files += 1;
303
+ totals[tier].lines += n;
304
+ if (!excludedFromSize(f.path)) size += n;
305
+ return {
306
+ path: f.path,
307
+ tier,
308
+ rule,
309
+ reason,
310
+ added: f.added,
311
+ removed: f.removed,
312
+ ...(ranges ? { ranges } : {}),
313
+ };
314
+ })
315
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
316
+ return {
317
+ base,
318
+ generatedAt: new Date().toISOString(),
319
+ files: out,
320
+ human: humanLinesFor(
321
+ HUMAN_LINES,
322
+ out.map((f) => f.path),
323
+ ),
324
+ totals,
325
+ size: {
326
+ lines: size,
327
+ level: sizeLevel(size),
328
+ warn: SIZE_WARN,
329
+ oversize: SIZE_OVERSIZE,
330
+ },
331
+ };
332
+ }
333
+
334
+ // `collect` lists paths itself rather than through tree-hash's changedPaths,
335
+ // which is bound to this repo's ROOT: RM-G1 runs it against a tmp repo.
336
+ export function reviewMap(base, root = ROOT) {
337
+ const ref = base ?? baseRef();
338
+ const full = git(root, ['rev-parse', ref]).trim();
339
+ return buildMap(collect(ref, root), full);
340
+ }
341
+
342
+ const item = (f) =>
343
+ `- \`${f.path}\` +${f.added} -${f.removed} - ${f.rule} ${f.reason}`;
344
+
345
+ /** machine: 'count' (default) | 'full'. */
346
+ export function renderMarkdown(map, { machine = 'count' } = {}) {
347
+ const section = (tier, title, blurb, folded) => {
348
+ const rows = map.files.filter((f) => f.tier === tier);
349
+ const head = `**${title}** - ${blurb}`;
350
+ if (rows.length === 0) return [head, '- none', ''];
351
+ if (!folded) return [head, ...rows.map(item), ''];
352
+ return [
353
+ head,
354
+ `<details><summary>${rows.length} files</summary>`,
355
+ '',
356
+ ...rows.map(item),
357
+ '</details>',
358
+ '',
359
+ ];
360
+ };
361
+ const t = map.totals;
362
+ const part = (label, x) => `${label}: ${x.files} files, ${x.lines} lines.`;
363
+ return [
364
+ `#### Review map (base ${map.base.slice(0, 7)})`,
365
+ '',
366
+ ...section('HUMAN', 'HUMAN', 'a person reads every line', false),
367
+ ...section(
368
+ 'GATED',
369
+ GATED_LABEL,
370
+ 'a person reads only what a gate flags',
371
+ true,
372
+ ),
373
+ ...(machine === 'full'
374
+ ? section(
375
+ 'MACHINE',
376
+ 'MACHINE',
377
+ 'gates only; a person spot-checks at most',
378
+ true,
379
+ )
380
+ : [
381
+ '**MACHINE** - gates only; a person spot-checks at most',
382
+ `Machine: ${t.MACHINE.files} files, ${t.MACHINE.lines} lines (full list: .turbo/review-map.json)`,
383
+ '',
384
+ ]),
385
+ `${part('Human review', t.HUMAN)} ${part('Gated', t.GATED)} ${part('Machine', t.MACHINE)}`,
386
+ ...(map.human?.length
387
+ ? ['', 'Human steps:', ...map.human.map((l) => `- ${l}`)]
388
+ : []),
389
+ ].join('\n');
390
+ }
391
+
392
+ export function renderSizeLine(size, { color = false } = {}) {
393
+ const L = size.lines;
394
+ const lines = [
395
+ `Size: ${L} changed lines (files in reviewMap.sizeExcluded not counted).`,
396
+ ];
397
+ const level = sizeLevel(L);
398
+ if (level === 'warn' || level === 'oversize') {
399
+ if (level === 'warn')
400
+ lines.push(
401
+ `WARNING: ${L} > ${SIZE_WARN} changed lines: split candidate (${config.reviewMap.splitHint}).`,
402
+ );
403
+ else {
404
+ const o = `OVERSIZE: ${L} > ${SIZE_OVERSIZE} changed lines: the DoD gate blocks unless ${config.dod?.contract.replace('{id}', '<ID>') ?? 'the ticket contract'} section 1 has an "oversize-accepted: <reason>" line.`;
405
+ lines.push(color ? `\x1b[31m${o}\x1b[0m` : o);
406
+ }
407
+ }
408
+ return lines.join('\n');
409
+ }
410
+
411
+ /** Writes the JSON half of the map to <root>/.turbo/review-map.json. */
412
+ export function writeMap(map, root = ROOT) {
413
+ const file = join(root, '.turbo', 'review-map.json');
414
+ mkdirSync(dirname(file), { recursive: true });
415
+ writeFileSync(file, JSON.stringify(map, null, 2));
416
+ return file;
417
+ }
418
+
419
+ export function main(args = process.argv.slice(2)) {
420
+ const base = args
421
+ .find((a) => a.startsWith('--base='))
422
+ ?.slice('--base='.length);
423
+ const map = reviewMap(base);
424
+ writeMap(map);
425
+ console.log(
426
+ renderMarkdown(map, {
427
+ machine: args.includes('--full') ? 'full' : 'count',
428
+ }),
429
+ );
430
+ console.log('');
431
+ console.log(renderSizeLine(map.size, { color: process.stdout.isTTY }));
432
+ }
package/root.mjs ADDED
@@ -0,0 +1,33 @@
1
+ // The project root: the nearest directory at or above the working directory that holds
2
+ // gate.config.mjs. Sync and `node:` only, so the config's own import graph may import it.
3
+ import { existsSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
+
6
+ /** Walk up from `start` to the first dir holding gate.config.mjs; none throws. */
7
+ export function findRoot(start = process.cwd()) {
8
+ let dir = resolve(start);
9
+ for (;;) {
10
+ if (existsSync(join(dir, 'gate.config.mjs'))) return dir;
11
+ const up = dirname(dir);
12
+ if (up === dir)
13
+ throw new Error(`ticket-gate: no gate.config.mjs at or above ${start}`);
14
+ dir = up;
15
+ }
16
+ }
17
+
18
+ // TICKET_GATE_ROOT, when set, names the project root outright (resolved against the working
19
+ // directory) and skips the walk. A tool that runs the engine against its own tree, with no
20
+ // gate.config.mjs there, pins it so an ancestor's config cannot become the root.
21
+ // A hook must reach its own fail-closed branch when no config exists (exit 2 on a tick), so the
22
+ // shared constant falls back to the working directory instead of throwing at import (exit 1,
23
+ // which Claude Code reads as "proceed"). `findRoot` itself still throws.
24
+ function rootOrCwd() {
25
+ if (process.env.TICKET_GATE_ROOT) return resolve(process.env.TICKET_GATE_ROOT);
26
+ try {
27
+ return findRoot();
28
+ } catch {
29
+ return process.cwd();
30
+ }
31
+ }
32
+
33
+ export const ROOT = rootOrCwd();