@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/digest.mjs ADDED
@@ -0,0 +1,195 @@
1
+ // What a verify:ticket step's result depends on, so a green result can be
2
+ // reused when none of it changed.
3
+ //
4
+ // stepDigest(id, cmd) is sha256 over: the step id, its command, the full base
5
+ // sha, the node version (and deno's for deno-check and functions), every
6
+ // gitignored .env* file (path and blob id) and every changed path from
7
+ // treeEntries(base) that is NOT denied for that step. Deny-lists, never
8
+ // allow-lists: a path nobody listed is an input of every step. tools/ is never
9
+ // denied, so a tooling change re-runs everything. One exception: DATA_PATHS
10
+ // names data folders and files under tools/ that only the listed steps read
11
+ // (the mutation registry and the four check baselines are data, not tooling),
12
+ // so every other step may reuse its result.
13
+ //
14
+ // Dependency-free Node ESM: ticket-gate.mjs imports this file, and a gate must
15
+ // stay cheap and must never write anything.
16
+
17
+ import { execFileSync, spawnSync } from 'node:child_process';
18
+ import { createHash } from 'node:crypto';
19
+ import { readFileSync } from 'node:fs';
20
+ import { basename, dirname, join, relative } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ import { GATE } from './project.mjs';
24
+ import { ROOT } from './root.mjs';
25
+ import { baseRef, treeEntries } from './tree.mjs';
26
+
27
+ /** The one rule for "this step counts as verified": verify-ticket and ticket-gate both read it. */
28
+ export const VERIFIED_STATUSES = Object.freeze(['pass', 'human', 'reused']);
29
+
30
+ // Per-step reuse (reusable, deny-list) is declared on each step in gate.config.mjs.
31
+ // A deny entry ending `/` is a path prefix, `*.md` any path ending `.md`,
32
+ // `a/**/*.x` a path under `a/` ending `.x`. maestro is human and has no entry.
33
+ export const STEP_INPUTS = Object.freeze(
34
+ Object.fromEntries(
35
+ GATE.steps
36
+ .filter((s) => !s.human)
37
+ .map((s) => [
38
+ s.id,
39
+ { reusable: s.reuse !== false, deny: s.reuse ? s.reuse.deny : [] },
40
+ ]),
41
+ ),
42
+ );
43
+
44
+ const matches = (pattern, path) => {
45
+ const deep = pattern.indexOf('**/');
46
+ if (deep >= 0)
47
+ return (
48
+ path.startsWith(pattern.slice(0, deep)) &&
49
+ path.endsWith(pattern.slice(deep + 3).replace(/^\*/, ''))
50
+ );
51
+ if (pattern.endsWith('/')) return path.startsWith(pattern);
52
+ if (pattern.startsWith('*')) return path.endsWith(pattern.slice(1));
53
+ return path === pattern;
54
+ };
55
+
56
+ /**
57
+ * Data under tools/: a folder prefix or a file path -> the only step ids it is
58
+ * an input of. The four baselines are read by the project's check script alone (S-2 proves no
59
+ * e2e input references them), so `check --update` does not invalidate e2e reuse.
60
+ */
61
+ export const DATA_PATHS = GATE.dataPaths;
62
+
63
+ // The engine's own directory (relative to the project root, posix) plus the config's toolPrefixes:
64
+ // a change under either is an input of every step unless dataPaths narrows it.
65
+ const ENGINE_PREFIX = `${relative(ROOT, dirname(fileURLToPath(import.meta.url)))
66
+ .split('\\')
67
+ .join('/')}/`;
68
+ const isToolPath = (p) =>
69
+ p.startsWith(ENGINE_PREFIX) || GATE.toolPrefixes.some((t) => p.startsWith(t));
70
+
71
+ /** True iff `path` cannot affect step `id`. An unknown id denies nothing. */
72
+ export function isDenied(id, path) {
73
+ for (const [prefix, steps] of Object.entries(DATA_PATHS))
74
+ if (path.startsWith(prefix)) return !steps.includes(id);
75
+ if (isToolPath(path)) return false;
76
+ return (STEP_INPUTS[id]?.deny ?? []).some((d) => matches(d, path));
77
+ }
78
+
79
+ /** `deno --version` first line; `-` when deno is absent, slow or failing. */
80
+ export function denoVersion() {
81
+ const r = spawnSync('deno', ['--version'], {
82
+ encoding: 'utf8',
83
+ timeout: 5000,
84
+ });
85
+ if (r.error || r.status !== 0) return '-';
86
+ return r.stdout.split('\n')[0].trim() || '-';
87
+ }
88
+
89
+ const gitOut = (args) =>
90
+ execFileSync('git', args, {
91
+ cwd: ROOT,
92
+ encoding: 'utf8',
93
+ maxBuffer: 512 * 1024 * 1024,
94
+ stdio: ['ignore', 'pipe', 'pipe'],
95
+ });
96
+
97
+ const blobId = (bytes) =>
98
+ createHash('sha1')
99
+ .update(`blob ${bytes.length}\0`)
100
+ .update(bytes)
101
+ .digest('hex');
102
+
103
+ // Ignored directories (node_modules/, .turbo/) collapse to one entry and are
104
+ // never walked. A deleted env file simply drops out, which moves the digest.
105
+ function envFiles() {
106
+ return gitOut([
107
+ 'ls-files',
108
+ '-z',
109
+ '--others',
110
+ '--ignored',
111
+ '--exclude-standard',
112
+ '--directory',
113
+ ])
114
+ .split('\0')
115
+ .filter(Boolean)
116
+ .filter(
117
+ (p) =>
118
+ !p.endsWith('/') &&
119
+ basename(p).startsWith('.env') &&
120
+ !p.split('/').includes('node_modules'),
121
+ )
122
+ .sort()
123
+ .map((p) => [p, blobId(readFileSync(join(ROOT, p)))]);
124
+ }
125
+
126
+ /**
127
+ * One read of the tree for many digests: the full base sha, the tool versions,
128
+ * the env files and treeEntries(base). `versions` defaults to the running node
129
+ * and `deno --version` (read once, only when a digest needs it).
130
+ */
131
+ export function inputState({ base, versions } = {}) {
132
+ const sha = gitOut(['rev-parse', base ?? baseRef()]).trim();
133
+ let deno;
134
+ const v = versions ?? {
135
+ node: process.version,
136
+ get deno() {
137
+ return (deno ??= denoVersion());
138
+ },
139
+ };
140
+ return {
141
+ base: sha,
142
+ versions: v,
143
+ // A pack upgrade changes how a step's command or selector behaves: it re-runs every reusable step.
144
+ packs: (GATE.packs ?? []).map((p) => `${p.name}@${p.version}`),
145
+ env: envFiles(),
146
+ entries: treeEntries(sha),
147
+ };
148
+ }
149
+
150
+ const NEEDS_DENO = ['deno-check', 'functions'];
151
+
152
+ /** sha256 hex of everything step `id` running `cmd` depends on (see the header). */
153
+ export function stepDigest(id, cmd, { base, versions, state } = {}) {
154
+ if (!(id in STEP_INPUTS)) throw new Error(`stepDigest: unknown step ${id}`);
155
+ const s = state ?? inputState({ base, versions });
156
+ const h = createHash('sha256');
157
+ const add = (line) => h.update(`${line}\n`);
158
+ add(id);
159
+ add(cmd);
160
+ add(s.base);
161
+ add(`node ${s.versions.node}`);
162
+ for (const pack of s.packs ?? []) add(`pack ${pack}`);
163
+ if (NEEDS_DENO.includes(id)) add(`deno ${s.versions.deno}`);
164
+ for (const [p, blob] of s.env) add(`${p}\0${blob}`);
165
+ s.entries
166
+ .filter(([p]) => !isDenied(id, p))
167
+ .forEach(([p, state]) => add(`${p}\0${state}`));
168
+ return h.digest('hex');
169
+ }
170
+
171
+ export { AFFECTED_NIGHTLY, NIGHTLY_MAX_AGE_MS } from './nightly-window.mjs';
172
+
173
+ /**
174
+ * sha256 hex of what one e2e spec's result depends on: the file,
175
+ * the base, node, the env files, and the changed paths in `inputs` (the ones
176
+ * that select it) plus anything under tools/ except DATA_PATHS (the mutation
177
+ * registry and the check baselines are not inputs of e2e).
178
+ */
179
+ export function specDigest(file, inputs, { base, versions, state } = {}) {
180
+ const s = state ?? inputState({ base, versions });
181
+ const h = createHash('sha256');
182
+ const add = (line) => h.update(`${line}\n`);
183
+ add('e2e-spec');
184
+ add(file);
185
+ add(s.base);
186
+ add(`node ${s.versions.node}`);
187
+ for (const pack of s.packs ?? []) add(`pack ${pack}`);
188
+ for (const [p, blob] of s.env) add(`${p}\0${blob}`);
189
+ s.entries
190
+ .filter(
191
+ ([p]) => inputs.includes(p) || (isToolPath(p) && !isDenied('e2e', p)),
192
+ )
193
+ .forEach(([p, state]) => add(`${p}\0${state}`));
194
+ return h.digest('hex');
195
+ }
package/env-name.mjs ADDED
@@ -0,0 +1,24 @@
1
+ // Environment variable names the engine reads or sets. The current prefix is TICKET_GATE_; the
2
+ // previous prefix is read as a fallback (with one deprecation line per name per process) and is
3
+ // set alongside the new one for the deprecation release. This is the only file that names the
4
+ // previous prefix.
5
+ export const REMOVED_IN = '0.2.0';
6
+ const WARNED = new Set();
7
+
8
+ export function envName(env, name, { log = console.error, warned = WARNED } = {}) {
9
+ const current = env[`TICKET_GATE_${name}`];
10
+ if (current !== undefined) return current;
11
+ const old = `FP_${name}`;
12
+ const value = env[old];
13
+ if (value === undefined) return undefined;
14
+ if (!warned.has(old)) {
15
+ warned.add(old);
16
+ log(`ticket-gate: ${old} is deprecated; set TICKET_GATE_${name} (${old} is read until ${REMOVED_IN})`);
17
+ }
18
+ return value;
19
+ }
20
+
21
+ export const bothNames = (name, value) => ({
22
+ [`TICKET_GATE_${name}`]: value,
23
+ [`FP_${name}`]: value,
24
+ });
package/failures.mjs ADDED
@@ -0,0 +1,256 @@
1
+ // Turns the raw output of the failed steps of the verify command into a short
2
+ // Markdown summary at .turbo/ticket-verify-failures.md: which test, file:line and
3
+ // message per failure, so a dev agent reads 30 lines instead of a 4000-line log.
4
+ // Pure string work (no dependency). The gate calls writeFailures()
5
+ // after the receipt is written; a green run deletes the file.
6
+ //
7
+ // Every recogniser runs over every failed step's output (a `check` step mixes
8
+ // prettier, eslint, tsc, vitest, node:test and the check-* tools), so nothing
9
+ // here branches on a step id. An output no recogniser matches gets a `tail` entry.
10
+
11
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { dirname } from 'node:path';
13
+
14
+ export const FAILURES_REL = '.turbo/ticket-verify-failures.md';
15
+ export const MAX_ENTRIES = 20;
16
+ const MAX_MESSAGE = 240;
17
+ const TAIL_LINES = 15;
18
+
19
+ const ANSI = /\x1b\[[0-9;]*m/g;
20
+ const TURBO_PREFIX = /^[\w@/.-]+:[\w:-]+: /;
21
+
22
+ const stripLine = (l) => l.replace(ANSI, '').replace(TURBO_PREFIX, '');
23
+
24
+ /** Split, strip ANSI and a leading turbo `pkg:task: ` prefix, trim, drop empty lines. */
25
+ export function cleanLines(out) {
26
+ return String(out ?? '')
27
+ .split('\n')
28
+ .map((l) => stripLine(l).trim())
29
+ .filter(Boolean);
30
+ }
31
+
32
+ const cut = (s) =>
33
+ s.length > MAX_MESSAGE ? `${s.slice(0, MAX_MESSAGE - 3)}...` : s;
34
+
35
+ /** `file:///abs/x.mjs:4:1` or `/abs/x.mjs:4:1` becomes a path relative to the cwd. */
36
+ function relPath(p) {
37
+ const bare = p.replace(/^file:\/\//, '');
38
+ const cwd = `${process.cwd()}/`;
39
+ return bare.startsWith(cwd) ? bare.slice(cwd.length) : bare;
40
+ }
41
+
42
+ const entry = (kind, name, message, location = null) => ({
43
+ kind,
44
+ name,
45
+ message,
46
+ location,
47
+ });
48
+
49
+ /** First line after `i` (stopping at `stop`) matching `re`; returns the regex match or null. */
50
+ function look(lines, i, re, stop, max = 40) {
51
+ for (let j = i + 1; j < lines.length && j <= i + max; j += 1) {
52
+ if (stop(lines[j])) return null;
53
+ const m = re.exec(lines[j]);
54
+ if (m) return m;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ const VITEST_FAIL = /^FAIL\s+(\S+)\s+>\s+(.+)$/;
60
+ const VITEST_SUITE_FAIL = /^FAIL\s+(\S+)(?:\s+\[.*\])?$/;
61
+ const VITEST_STOP = (l) => /^FAIL\s/.test(l) || l.startsWith('⎯');
62
+ const VITEST_MSG = /^((?:\w*Error|expected)\b.*)$/;
63
+ const VITEST_LOC = /^❯\s+(?!\S*node_modules)(\S+?:\d+(?::\d+)?)$/;
64
+ const SPEC_FAIL = /^✖\s+(.+?)(?:\s+\([\d.]+m?s\))?$/;
65
+ const SPEC_STOP = (l) => /^[✔✖ℹ▶]/.test(l);
66
+ const TAP_FAIL = /^not ok \d+ - (.+?)(?:\s+#.*)?$/;
67
+ const TAP_STOP = (l) => /^(ok|not ok) \d+ - |^# Subtest/.test(l);
68
+ const PW_FAIL =
69
+ /^\d+\)\s+\[[^\]]+\]\s+›\s+(\S+?:\d+:\d+)\s+›\s+(.+?)(?:\s+─+)?$/;
70
+ const TSC_PAREN = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/;
71
+ const TSC_COLON = /^(.+?):(\d+):(\d+) - error (TS\d+): (.*)$/;
72
+ const ESLINT_FILE = /^\S+\.[cm]?[jt]sx?$/;
73
+ const ESLINT_ROW = /^(\d+):(\d+)\s{2,}error\s{2,}(.+?)(?:\s{2,}([@\w/.-]+))?$/;
74
+ const PRETTIER = /^\[warn\]\s+(\S.*)$/;
75
+ const CHECK_FAILED = /^check:([\w-]+) FAILED \(\d+\):$/;
76
+ const DUP_HEADER =
77
+ /^(?:check:dup: )?(?:\d+ new clone\(s\)|\d+ baseline entr(?:y|ies) (?:no longer match|has an empty|have an empty))/;
78
+ const DUP_LINE = /^(NEW|STALE|NO REASON) /;
79
+ const COMPILER =
80
+ /^(?:new bailout|stale|skipped|unreasoned): |^plugin .* != baseline/;
81
+ const MUTATION = /^mutation: FAIL (.+)$/;
82
+
83
+ /**
84
+ * Every recogniser over cleanLines(out), in output order, de-duplicated. A
85
+ * Failure is { kind, name, message, location }. Returns [] for a clean log.
86
+ */
87
+ export function parseFailures(out) {
88
+ // `lines` keeps the indentation (the check-* tools indent their detail lines);
89
+ // `c` is the same lines trimmed, for everything else.
90
+ const lines = String(out ?? '')
91
+ .split('\n')
92
+ .map((l) => stripLine(l).trimEnd())
93
+ .filter((l) => l.trim());
94
+ const c = lines.map((l) => l.trim());
95
+ const tasks = c.filter((l) => /^Tasks:/.test(l));
96
+ const found = [];
97
+ let file = null; // last eslint file header
98
+ let detail = null; // check:<x> whose indented lines are being collected
99
+
100
+ for (let i = 0; i < c.length; i += 1) {
101
+ const line = c[i];
102
+ const indented = /^\s/.test(lines[i]);
103
+
104
+ if (detail && indented) {
105
+ found.push(entry('check', detail, line));
106
+ continue;
107
+ }
108
+ detail = null;
109
+
110
+ let m;
111
+ if ((m = VITEST_FAIL.exec(line)) || (m = VITEST_SUITE_FAIL.exec(line))) {
112
+ const msg = look(c, i, VITEST_MSG, VITEST_STOP);
113
+ const loc = look(c, i, VITEST_LOC, VITEST_STOP);
114
+ const where = loc ? loc[1] : null;
115
+ found.push(entry('vitest', m[2] ?? m[1], msg ? msg[1] : m[1], where));
116
+ } else if (line.startsWith('✖') && (m = SPEC_FAIL.exec(line))) {
117
+ const name = m[1];
118
+ if (/^failing tests:?$/.test(name) || /^\d+ problems?\b/.test(name))
119
+ continue;
120
+ const msg = look(c, i, /^(\w*Error\b.*|error:.*)$/, SPEC_STOP, 12);
121
+ const before = /^test at (\S+:\d+:\d+)$/.exec(c[i - 1] ?? '');
122
+ const frame = look(c, i, /\((\S+?:\d+:\d+)\)/, SPEC_STOP, 12);
123
+ const where = before
124
+ ? before[1]
125
+ : frame && !/node:|node_modules/.test(frame[1])
126
+ ? relPath(frame[1])
127
+ : null;
128
+ found.push(entry('node-test', name, msg ? msg[1] : name, where));
129
+ } else if ((m = TAP_FAIL.exec(line))) {
130
+ const loc = look(c, i, /^location: '?([^']+)'?$/, TAP_STOP, 30);
131
+ const inline = look(c, i, /^error: (?![|>])'?(.+?)'?$/, TAP_STOP, 30);
132
+ const block = look(c, i, /^error: [|>]-?$/, TAP_STOP, 30);
133
+ const lead = block ? c[c.indexOf(block.input, i) + 1] : null;
134
+ found.push(
135
+ entry(
136
+ 'node-test',
137
+ m[1],
138
+ inline ? inline[1] : (lead ?? m[1]),
139
+ loc ? relPath(loc[1]) : null,
140
+ ),
141
+ );
142
+ } else if ((m = PW_FAIL.exec(line))) {
143
+ const msg = look(c, i, /^(Error:.*)$/, (l) => PW_FAIL.test(l), 12);
144
+ found.push(entry('playwright', m[2], msg ? msg[1] : m[2], m[1]));
145
+ } else if ((m = TSC_PAREN.exec(line)) || (m = TSC_COLON.exec(line))) {
146
+ found.push(entry('tsc', m[4], m[5], `${m[1]}:${m[2]}:${m[3]}`));
147
+ } else if (ESLINT_FILE.test(line)) {
148
+ file = line;
149
+ } else if (file && (m = ESLINT_ROW.exec(line))) {
150
+ found.push(
151
+ entry('eslint', m[4] ?? 'eslint', m[3], `${file}:${m[1]}:${m[2]}`),
152
+ );
153
+ } else if ((m = PRETTIER.exec(line)) && !/^Code style issues/.test(m[1])) {
154
+ found.push(entry('prettier', m[1], 'Code style issues found', m[1]));
155
+ } else if ((m = /^Failed:\s+(.+)$/.exec(line))) {
156
+ for (const task of m[1]
157
+ .split(',')
158
+ .map((t) => t.trim())
159
+ .filter(Boolean))
160
+ found.push(entry('turbo', task, tasks.join(' | ')));
161
+ } else if ((m = CHECK_FAILED.exec(line))) {
162
+ detail = `check:${m[1]}`;
163
+ } else if (/^Duplicate export names/.test(line)) {
164
+ found.push(entry('check', 'check:lib-exports', line));
165
+ } else if (
166
+ /^\S+: web=.* mobile=/.test(line) ||
167
+ /^stale allowlist entry/.test(line)
168
+ ) {
169
+ found.push(entry('check', 'check:lib-exports', line));
170
+ } else if (DUP_HEADER.test(line) || DUP_LINE.test(line)) {
171
+ found.push(entry('check', 'check:dup', line));
172
+ } else if (COMPILER.test(line)) {
173
+ const at = /([\w@./[\]()-]+\.[cm]?[jt]sx?:\d+)/.exec(line);
174
+ found.push(entry('check', 'check:compiler', line, at ? at[1] : null));
175
+ } else if ((m = MUTATION.exec(line))) {
176
+ const p = /^(\S+): (.+)$/.exec(m[1]);
177
+ found.push(
178
+ p
179
+ ? entry('mutation', p[1], p[2], p[1])
180
+ : entry('mutation', 'mutation', m[1]),
181
+ );
182
+ }
183
+ }
184
+ return dedupe(found);
185
+ }
186
+
187
+ /** Drops repeats by kind + name + location, and a location-less entry when a located twin exists. */
188
+ function dedupe(found) {
189
+ // vitest and node:test print each failure twice; only the second copy has a location.
190
+ const twice = (f) => f.kind === 'vitest' || f.kind === 'node-test';
191
+ const located = new Set(
192
+ found
193
+ .filter((f) => f.location && twice(f))
194
+ .map((f) => `${f.kind}\0${f.name}`),
195
+ );
196
+ const seen = new Set();
197
+ return found.filter((f) => {
198
+ if (!f.location && twice(f) && located.has(`${f.kind}\0${f.name}`))
199
+ return false;
200
+ const key = `${f.kind}\0${f.name}\0${f.location ?? ''}\0${f.kind === 'check' ? f.message : ''}`;
201
+ if (seen.has(key)) return false;
202
+ seen.add(key);
203
+ return true;
204
+ });
205
+ }
206
+
207
+ function tailEntry(out) {
208
+ const tail = cleanLines(out).slice(-TAIL_LINES);
209
+ return entry('tail', 'output tail', tail.map(cut).join('\n'));
210
+ }
211
+
212
+ function renderEntry(e) {
213
+ if (e.kind === 'tail')
214
+ return `- [tail] ${e.name} (last ${TAIL_LINES} lines):\n${e.message
215
+ .split('\n')
216
+ .map((l) => ` ${l}`)
217
+ .join('\n')}`;
218
+ const where = e.location ? ` (${e.location})` : '';
219
+ return `- [${e.kind}] ${e.name}: ${cut(e.message)}${where}`;
220
+ }
221
+
222
+ /** The Markdown file body. failures: [{ id, cmd, why, out }] in run order. */
223
+ export function renderFailures({ treeHash, ranAt, failures, stoppedBy }) {
224
+ const lines = [
225
+ // prettier-ignore
226
+ stoppedBy ? `# verify:ticket failures - stopped before the stack steps: ${stoppedBy} red` : '# verify:ticket failures',
227
+ '',
228
+ `treeHash: ${treeHash} ranAt: ${ranAt}`,
229
+ ];
230
+ for (const f of failures) {
231
+ const entries = parseFailures(f.out);
232
+ if (entries.length === 0) entries.push(tailEntry(f.out));
233
+ const shown = entries.slice(0, MAX_ENTRIES);
234
+ lines.push('', `## ${f.id}: \`${f.cmd}\``, `Hint: ${f.why}`);
235
+ for (const e of shown) lines.push(renderEntry(e));
236
+ if (entries.length > MAX_ENTRIES)
237
+ lines.push(
238
+ `- ... and ${entries.length - MAX_ENTRIES} more (full output: the verify:ticket log)`,
239
+ );
240
+ }
241
+ return `${lines.join('\n')}\n`;
242
+ }
243
+
244
+ /**
245
+ * Red run: writes the summary and returns `path`. No failed step: deletes any
246
+ * earlier file (it must never be read as current) and returns null.
247
+ */
248
+ export function writeFailures(path, { treeHash, ranAt, failures, stoppedBy }) {
249
+ if (failures.length === 0) {
250
+ rmSync(path, { force: true });
251
+ return null;
252
+ }
253
+ mkdirSync(dirname(path), { recursive: true });
254
+ writeFileSync(path, renderFailures({ treeHash, ranAt, failures, stoppedBy }));
255
+ return path;
256
+ }