@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
@@ -0,0 +1,142 @@
1
+ import { listFiles, readText } from '../graph/js.mjs';
2
+ import { lazyTs } from '../graph/typescript.mjs';
3
+
4
+ const ts = lazyTs();
5
+
6
+ export const FRAGMENT_MIN = 8;
7
+
8
+ // ---------------------------------------------------------------- i18n value rule
9
+
10
+ export class FullSignal extends Error {
11
+ constructor(path, rule) {
12
+ super(`${path}: ${rule}`);
13
+ this.path = path;
14
+ this.rule = rule;
15
+ }
16
+ }
17
+
18
+ export const unwrap = (e) => {
19
+ let x = e;
20
+ while (
21
+ x &&
22
+ (ts.isAsExpression(x) ||
23
+ ts.isSatisfiesExpression(x) ||
24
+ ts.isParenthesizedExpression(x))
25
+ )
26
+ x = x.expression;
27
+ return x;
28
+ };
29
+
30
+ /** The exported object literal of an i18n table as { map: dotted key -> value, outside }. */
31
+ export function parseTable(text, path) {
32
+ const sf = ts.createSourceFile(
33
+ path,
34
+ text,
35
+ ts.ScriptTarget.Latest,
36
+ true,
37
+ ts.ScriptKind.TS,
38
+ );
39
+ let obj = null;
40
+ for (const st of sf.statements) {
41
+ if (!ts.isVariableStatement(st)) continue;
42
+ if (!st.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword))
43
+ continue;
44
+ for (const d of st.declarationList.declarations) {
45
+ const init = unwrap(d.initializer);
46
+ if (init && ts.isObjectLiteralExpression(init)) obj = obj ?? init;
47
+ }
48
+ }
49
+ if (!obj) return null;
50
+ const map = new Map();
51
+ const walkObj = (o, prefix) => {
52
+ let i = 0;
53
+ for (const p of o.properties) {
54
+ const name =
55
+ ts.isPropertyAssignment(p) &&
56
+ (ts.isIdentifier(p.name) ||
57
+ ts.isStringLiteral(p.name) ||
58
+ ts.isNumericLiteral(p.name))
59
+ ? p.name.text
60
+ : null;
61
+ if (name === null) {
62
+ map.set(`${prefix}[${i++}:${p.getText(sf)}]`, {
63
+ nonLiteral: p.getText(sf),
64
+ });
65
+ continue;
66
+ }
67
+ const key = prefix ? `${prefix}.${name}` : name;
68
+ const v = unwrap(p.initializer);
69
+ if (ts.isStringLiteral(v) || ts.isNoSubstitutionTemplateLiteral(v))
70
+ map.set(key, v.text);
71
+ else if (ts.isObjectLiteralExpression(v)) walkObj(v, key);
72
+ else map.set(key, { nonLiteral: v.getText(sf) });
73
+ }
74
+ };
75
+ walkObj(obj, '');
76
+ return {
77
+ map,
78
+ outside: text.slice(0, obj.getStart(sf)) + text.slice(obj.getEnd()),
79
+ };
80
+ }
81
+
82
+ /** Placeholder-free fragments of at least FRAGMENT_MIN characters. */
83
+ export const fragmentsOf = (value) =>
84
+ value
85
+ .split(/\{[^}]*\}/)
86
+ .map((s) => s.trim())
87
+ .filter((s) => s.length >= FRAGMENT_MIN);
88
+
89
+ // ---------------------------------------------------------------- object-table rule
90
+
91
+ /** Specs holding a changed or removed table key's key, value, or a long fragment of it. */
92
+ function tableSpecs(path, ctx) {
93
+ const { root, cfg } = ctx;
94
+ const specDir = cfg.specs.dir;
95
+ const headText = ctx.readText(path);
96
+ const baseText = ctx.readBase(path);
97
+ const head = headText === null ? null : parseTable(headText, path);
98
+ const old = baseText === null ? null : parseTable(baseText, path);
99
+ if (!head || !old || head.outside !== old.outside)
100
+ throw new FullSignal(path, 'i18n-structure');
101
+ const needles = new Set();
102
+ for (const [key, oldValue] of old.map) {
103
+ const now = head.map.get(key);
104
+ if (now !== undefined && JSON.stringify(now) === JSON.stringify(oldValue))
105
+ continue;
106
+ if (
107
+ typeof oldValue !== 'string' ||
108
+ (now !== undefined && typeof now !== 'string')
109
+ )
110
+ throw new FullSignal(path, 'i18n-structure');
111
+ needles.add(key);
112
+ if (oldValue.length > 0) needles.add(oldValue);
113
+ for (const f of fragmentsOf(oldValue)) needles.add(f);
114
+ }
115
+ for (const [key, v] of head.map)
116
+ if (!old.map.has(key) && typeof v !== 'string')
117
+ throw new FullSignal(path, 'i18n-structure');
118
+ if (needles.size === 0) return [];
119
+ const hits = [];
120
+ for (const f of listFiles(
121
+ root,
122
+ specDir,
123
+ (p) => p.endsWith('.ts') && !(cfg.outside.testFile?.test(p) ?? false),
124
+ )) {
125
+ const text = readText(root, f) ?? '';
126
+ if (![...needles].some((n) => text.includes(n))) continue;
127
+ if (cfg.specs.match.test(f)) hits.push(f.slice(specDir.length + 1));
128
+ else hits.push(...ctx.graph().importersOf(f));
129
+ }
130
+ return hits;
131
+ }
132
+
133
+ /** The value rule for exported object-literal tables (today's i18n rule): only changed values select specs. */
134
+ export function objectTable(tables) {
135
+ return {
136
+ name: 'object-table',
137
+ rule(path, ctx) {
138
+ if (!tables.includes(path)) return null;
139
+ return { specs: tableSpecs(path, ctx) };
140
+ },
141
+ };
142
+ }
@@ -0,0 +1,76 @@
1
+ // The `selection` key of gate.config.mjs: the project values the e2e selection engine (./selection)
2
+ // reads. Validated at load time; a bad value throws Error('gate.config: selection: <what>').
3
+ // Graph sources, rules and test runners come from the config and from every pack's
4
+ // selectionGraphs, selectionRules and testRunners hooks (config first, then packs in packs order).
5
+ import { packHooksAll } from "./pack-hooks-all.mjs";
6
+ import { ROOT } from "./root.mjs";
7
+ import { SelectionConfigError, defineSelection } from "./selection/define.mjs";
8
+
9
+ export const SELECTION_HOOKS = Object.freeze([
10
+ "selectionGraphs",
11
+ "selectionRules",
12
+ "testRunners",
13
+ ]);
14
+
15
+ const KIND = {
16
+ selectionGraphs: "graph source",
17
+ selectionRules: "rule",
18
+ testRunners: "test runner",
19
+ };
20
+
21
+ /** `{ by, item }` for the config's own items then every pack's, each checked to be a named object. */
22
+ function collect(hook, own, config, fail) {
23
+ const rows = [];
24
+ const add = (by, value) => {
25
+ for (const item of [].concat(value)) {
26
+ if (!item || typeof item !== "object" || typeof item.name !== "string")
27
+ fail(
28
+ "selection",
29
+ `${KIND[hook]} from ${by} must be an object with a string name`,
30
+ );
31
+ rows.push({ by, item });
32
+ }
33
+ };
34
+ if (own !== undefined) add("gate.config", own);
35
+ for (const { by, value } of packHooksAll(hook, config)) add(by, value);
36
+ const seen = new Map();
37
+ for (const { by, item } of rows) {
38
+ if (seen.has(item.name))
39
+ fail(
40
+ "selection",
41
+ `${KIND[hook]} "${item.name}" is given by ${seen.get(item.name)} and ${by}`,
42
+ );
43
+ seen.set(item.name, by);
44
+ }
45
+ return rows;
46
+ }
47
+
48
+ /** Validates `config.selection` and returns the defined selection; `fail(where, what)` throws. */
49
+ export function selectionOf(config, fail) {
50
+ const sel = config.selection;
51
+ if (!sel || typeof sel !== "object" || Array.isArray(sel))
52
+ fail("selection", "must be an object");
53
+ const graphs = collect("selectionGraphs", sel.graphs, config, fail);
54
+ const rules = collect("selectionRules", sel.rules, config, fail);
55
+ const runners = collect("testRunners", undefined, config, fail);
56
+ let runner = sel.runner;
57
+ if (typeof runner === "string") {
58
+ const found = runners.find((r) => r.item.name === runner);
59
+ if (!found) {
60
+ fail("selection", `no test runner named "${runner}"`);
61
+ }
62
+ runner = found.item;
63
+ }
64
+ try {
65
+ return defineSelection({
66
+ ...sel,
67
+ root: sel.root ?? ROOT,
68
+ graphs: graphs.map((r) => r.item),
69
+ rules: rules.map((r) => r.item),
70
+ runner,
71
+ });
72
+ } catch (e) {
73
+ if (e instanceof SelectionConfigError) fail("selection", e.message);
74
+ throw e;
75
+ }
76
+ }
package/serve.mjs ADDED
@@ -0,0 +1,142 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { connect } from 'node:net';
3
+
4
+ import { safeKillGroup } from './kill-group.mjs';
5
+ import { LIMITS, tail } from './mutations/verdict.mjs';
6
+ import { ROOT } from './root.mjs';
7
+
8
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
+
10
+ const connects = (host, port) =>
11
+ new Promise((resolve) => {
12
+ const s = connect({ host, port });
13
+ s.once('connect', () => (s.destroy(), resolve(true)));
14
+ s.once('error', () => (s.destroy(), resolve(false)));
15
+ });
16
+
17
+ /** Is something listening on this port (IPv4 or IPv6 loopback)? */
18
+ export const portInUse = async (port) =>
19
+ (await connects('127.0.0.1', port)) || (await connects('::1', port));
20
+
21
+ const answers = (port) =>
22
+ fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) })
23
+ .then(() => true)
24
+ .catch(() => false);
25
+
26
+ // `opts.start` in its own process group; ready when `opts.port` answers.
27
+ export async function startWebServer(opts, onSpawn) {
28
+ const c = spawn('bash', ['-lc', opts.start], {
29
+ cwd: ROOT,
30
+ env: { ...process.env, ...opts.env },
31
+ detached: true,
32
+ stdio: ['ignore', 'pipe', 'pipe'],
33
+ });
34
+ let out = '';
35
+ let exited = false;
36
+ c.stdout.on('data', (d) => (out += d));
37
+ c.stderr.on('data', (d) => (out += d));
38
+ const gone = new Promise((resolve) => {
39
+ c.on('close', () => ((exited = true), resolve()));
40
+ c.on('error', (err) => ((out += String(err)), (exited = true), resolve()));
41
+ });
42
+ const stop = async () => {
43
+ if (!exited) {
44
+ safeKillGroup(c.pid, 'SIGTERM');
45
+ await Promise.race([gone, sleep(5000)]);
46
+ }
47
+ if (!exited) {
48
+ safeKillGroup(c.pid, 'SIGKILL');
49
+ await Promise.race([gone, sleep(2000)]);
50
+ }
51
+ for (let i = 0; i < 20 && (await portInUse(opts.port)); i++)
52
+ await sleep(250);
53
+ };
54
+ onSpawn?.(stop);
55
+ const died = () => exited;
56
+ const deadline = Date.now() + LIMITS.serve * 1000;
57
+ while (Date.now() < deadline && !exited) {
58
+ if (await answers(opts.port)) return { ok: true, out, stop, died };
59
+ await sleep(500);
60
+ }
61
+ return {
62
+ ok: false,
63
+ out: out || `no answer within ${LIMITS.serve} s`,
64
+ stop,
65
+ died,
66
+ };
67
+ }
68
+
69
+ /** Why a port cannot be served on. Shared with the mutation harness. */
70
+ export const portBusy = (port) =>
71
+ `port ${port} is already in use: a stale server would serve old code. Stop it (lsof -i :${port}), then re-run.`;
72
+
73
+ // Detached, in its own process group, so the timeout can kill the whole build (a compound
74
+ // command forks, and a surviving worker holds the pipes open). A terminal Ctrl-C no longer
75
+ // reaches it, so a SIGINT forwarder does, and is removed when the build ends.
76
+ // How long a build gets to close after SIGINT before its group is killed.
77
+ const INTERRUPT_GRACE_MS = 5000;
78
+
79
+ function runBuild(build, env) {
80
+ return new Promise((resolve) => {
81
+ const c = spawn('bash', ['-lc', build], {
82
+ cwd: ROOT,
83
+ env: { ...process.env, ...env },
84
+ detached: true,
85
+ stdio: ['ignore', 'pipe', 'pipe'],
86
+ });
87
+ let out = '';
88
+ let settled = false;
89
+ let escalation = null;
90
+ const forward = () => {
91
+ if (escalation !== null) return;
92
+ safeKillGroup(c.pid, 'SIGINT');
93
+ // Our listener replaced the default exit; keep it when nobody else handles the signal.
94
+ const exits = process.listenerCount('SIGINT') === 1;
95
+ // A build that ignores SIGINT must not outlive the run: kill the group after a bounded wait.
96
+ escalation = setTimeout(() => {
97
+ safeKillGroup(c.pid, 'SIGKILL');
98
+ if (exits) process.exit(130);
99
+ }, INTERRUPT_GRACE_MS);
100
+ if (exits)
101
+ c.once('close', () => {
102
+ safeKillGroup(c.pid, 'SIGKILL');
103
+ process.exit(130);
104
+ });
105
+ };
106
+ const done = (r) => {
107
+ if (settled) return;
108
+ settled = true;
109
+ clearTimeout(timer);
110
+ if (escalation !== null) clearTimeout(escalation);
111
+ process.removeListener('SIGINT', forward);
112
+ resolve(r);
113
+ };
114
+ process.on('SIGINT', forward);
115
+ const timer = setTimeout(() => {
116
+ safeKillGroup(c.pid, 'SIGKILL');
117
+ done({ code: 124, out: `${out}timed out after ${LIMITS.webBuild} s` });
118
+ }, LIMITS.webBuild * 1000);
119
+ c.stdout.on('data', (d) => (out += d));
120
+ c.stderr.on('data', (d) => (out += d));
121
+ c.on('error', (err) => done({ code: 1, out: String(err) }));
122
+ c.on('close', (code) => done({ code: code ?? 1, out }));
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Refuses a busy port, builds, starts the server and waits until `port` answers.
128
+ * `onSpawn(stop)` runs right after the spawn, so a Ctrl-C during startup can stop it.
129
+ * Throws on a busy port, a failed build or a server that never answers.
130
+ * @returns {Promise<{ stop: () => Promise<void> }>}
131
+ */
132
+ export async function serve({ build, start, port, env }, { onSpawn } = {}) {
133
+ if (await portInUse(port)) throw new Error(portBusy(port));
134
+ const b = await runBuild(build, env);
135
+ if (b.code !== 0) throw new Error(`build failed (${build}): ${tail(b.out)}`);
136
+ const server = await startWebServer({ start, port, env }, onSpawn);
137
+ if (!server.ok) {
138
+ await server.stop();
139
+ throw new Error(`server did not start on port ${port}: ${tail(server.out)}`);
140
+ }
141
+ return { stop: server.stop };
142
+ }
package/summary.mjs ADDED
@@ -0,0 +1,34 @@
1
+ /** `(1m05s)`: whole seconds, rounded, seconds padded to two digits. */
2
+ export function formatMs(ms) {
3
+ const total = Math.round(ms / 1000);
4
+ return `(${Math.floor(total / 60)}m${String(total % 60).padStart(2, '0')}s)`;
5
+ }
6
+
7
+ /** The lines inside the paste block's fence: one per result in run order, then one Not selected line. */
8
+ export function renderSummary(results, notRun, { failures } = {}) {
9
+ const lines = results.map(
10
+ (r) =>
11
+ `${r.cmd}: ${r.summary}${r.ms === undefined ? '' : ` ${formatMs(r.ms)}`}${r.status === 'pass' ? '' : ` [${r.status.toUpperCase()}]`}`,
12
+ );
13
+ if (notRun.length)
14
+ lines.push(`Not selected: ${notRun.map((s) => s.id).join(', ')}`);
15
+ if (failures) lines.push(`Failures: ${failures}`);
16
+ return lines;
17
+ }
18
+
19
+ export const oneLine = (out) =>
20
+ String(out ?? '')
21
+ .split('\n')
22
+ .map((l) => l.trim())
23
+ .filter(Boolean)
24
+ .join(' | ');
25
+
26
+ const ERROR_LINE = /error|fail|stale|vanish|invalid|differ|drop|unguarded/i;
27
+
28
+ export const firstError = (out) => {
29
+ const lines = String(out)
30
+ .split('\n')
31
+ .map((l) => l.trim())
32
+ .filter(Boolean);
33
+ return lines.find((l) => ERROR_LINE.test(l)) ?? lines[0] ?? 'failed';
34
+ };
package/tree.mjs ADDED
@@ -0,0 +1,211 @@
1
+ // Identifies the current working tree, so a verification receipt cannot outlive
2
+ // the code it verified.
3
+ //
4
+ // The hash covers every path this branch changes or adds (measured from the
5
+ // merge-base with `main`, not HEAD, so committing does not empty it), outside
6
+ // the ticket docs. Each path contributes only its final state: mode plus git blob id
7
+ // of the working-tree bytes, `-` if deleted, `d` for a directory. Committing,
8
+ // staging or leaving a file untracked therefore does not move the hash; a
9
+ // changed byte, a changed execute bit, a deletion or a rename does.
10
+ //
11
+ // treeHashAt(commit) is the same hash computed from a commit's tree instead of
12
+ // the working tree, so ticket-gate can ask "was the tree a DoD tick was
13
+ // committed on ever verified green?". verify-ticket records each green run's
14
+ // hash in .turbo/ticket-verify-history.jsonl (readHistory reads it back).
15
+ import { execFileSync, execSync } from 'node:child_process';
16
+ import { createHash } from 'node:crypto';
17
+ import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { ROOT } from './root.mjs';
20
+
21
+ // `.ticket-gate/tree.json` { exclude: [dir, ...] }: paths under these are outside the tree hash.
22
+ // Read once; a missing or unreadable file excludes nothing (stricter: more paths are hashed).
23
+ let EXCLUDE = null;
24
+ export function treeExclude() {
25
+ if (EXCLUDE) return EXCLUDE;
26
+ try {
27
+ const raw = JSON.parse(
28
+ readFileSync(join(ROOT, '.ticket-gate', 'tree.json'), 'utf8'),
29
+ );
30
+ EXCLUDE = Array.isArray(raw.exclude)
31
+ ? raw.exclude.filter((d) => typeof d === 'string' && d)
32
+ : [];
33
+ } catch {
34
+ EXCLUDE = [];
35
+ }
36
+ return EXCLUDE;
37
+ }
38
+ // prettier-ignore
39
+ const excluded = (p) => treeExclude().some((d) => p === d || p.startsWith(`${d}/`));
40
+
41
+ const sh = (cmd) =>
42
+ execSync(cmd, {
43
+ cwd: ROOT,
44
+ encoding: 'utf8',
45
+ // The default 1 MiB overflows (ENOBUFS) on a large unrelated diff.
46
+ maxBuffer: 512 * 1024 * 1024,
47
+ stdio: ['ignore', 'pipe', 'pipe'],
48
+ });
49
+
50
+ /**
51
+ * The ref this branch's work is measured from: the merge-base with `main`
52
+ * (or `origin/main`) when it exists, else HEAD. On `main` itself the two are
53
+ * the same commit.
54
+ */
55
+ export function baseRef(preferred) {
56
+ for (const ref of [
57
+ ...(preferred ? [preferred] : []),
58
+ 'main',
59
+ 'origin/main',
60
+ ]) {
61
+ try {
62
+ return sh(`git merge-base ${ref} HEAD`).trim();
63
+ } catch {
64
+ /* ref missing: try the next one */
65
+ }
66
+ }
67
+ return 'HEAD';
68
+ }
69
+
70
+ const nul = (cmd) => sh(cmd).split('\0').filter(Boolean);
71
+
72
+ export function untrackedFiles() {
73
+ return nul('git ls-files -z --others --exclude-standard');
74
+ }
75
+
76
+ /**
77
+ * Paths this branch changes: committed since the merge-base with main, plus
78
+ * the working tree, plus untracked files. `base` overrides the merge-base.
79
+ * `--no-renames` lists both sides of a rename; `-z` keeps odd names unquoted.
80
+ */
81
+ export function changedPaths(base) {
82
+ const ref = base ?? baseRef();
83
+ const tracked = nul(`git diff -z --name-only --no-renames ${ref}`);
84
+ return [...new Set([...tracked, ...untrackedFiles()])].sort();
85
+ }
86
+
87
+ const blobId = (bytes) =>
88
+ createHash('sha1')
89
+ .update(`blob ${bytes.length}\0`)
90
+ .update(bytes)
91
+ .digest('hex');
92
+
93
+ function stateOf(path) {
94
+ let st;
95
+ try {
96
+ st = lstatSync(join(ROOT, path));
97
+ } catch (err) {
98
+ if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return '-';
99
+ throw err;
100
+ }
101
+ if (st.isSymbolicLink())
102
+ return `120000 ${blobId(Buffer.from(readlinkSync(join(ROOT, path))))}`;
103
+ if (st.isDirectory()) return 'd';
104
+ const mode = st.mode & 0o111 ? '100755' : '100644';
105
+ return `${mode} ${blobId(readFileSync(join(ROOT, path)))}`;
106
+ }
107
+
108
+ /**
109
+ * Sorted (path, state) for every changed path outside the ticket docs. `base`
110
+ * overrides the merge-base; `exclude` is a list of path prefixes ending `/`
111
+ * that are left out as well (a project's registry data).
112
+ */
113
+ export function treeEntries(base = baseRef(), { exclude = [] } = {}) {
114
+ return changedPaths(base)
115
+ .filter((p) => !excluded(p) && !exclude.some((x) => p.startsWith(x)))
116
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
117
+ .map((p) => [p, stateOf(p)]);
118
+ }
119
+
120
+ function digest(entries) {
121
+ const h = createHash('sha256');
122
+ for (const [path, state] of entries) h.update(`${path}\0${state}\n`);
123
+ return h.digest('hex');
124
+ }
125
+
126
+ export function treeHash({ exclude = [] } = {}) {
127
+ return digest(treeEntries(baseRef(), { exclude }));
128
+ }
129
+
130
+ export const HISTORY_REL = '.turbo/ticket-verify-history.jsonl';
131
+
132
+ const gitZ = (args) =>
133
+ execFileSync('git', args, {
134
+ cwd: ROOT,
135
+ encoding: 'utf8',
136
+ maxBuffer: 512 * 1024 * 1024,
137
+ stdio: ['ignore', 'pipe', 'pipe'],
138
+ })
139
+ .split('\0')
140
+ .filter(Boolean);
141
+
142
+ /**
143
+ * Sorted [path, state] for `git diff -z --name-only --no-renames <base> <commit>`
144
+ * outside the ticket docs, states read from <commit> (same strings as stateOf).
145
+ */
146
+ export function treeEntriesAt(commit, base = baseRef()) {
147
+ for (const ref of [commit, base])
148
+ if (typeof ref !== 'string' || ref === '' || ref.startsWith('-'))
149
+ throw new Error('treeHashAt: bad ref');
150
+ // prettier-ignore
151
+ const paths = gitZ(['diff', '-z', '--name-only', '--no-renames', base, commit]).filter((p) => !excluded(p));
152
+ const states = new Map();
153
+ for (const rec of gitZ(['ls-tree', '-r', '-t', '-z', commit])) {
154
+ const tab = rec.indexOf('\t');
155
+ const [mode, type, oid] = rec.slice(0, tab).split(' ');
156
+ states.set(rec.slice(tab + 1), type === 'blob' ? `${mode} ${oid}` : 'd');
157
+ }
158
+ return paths
159
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
160
+ .map((p) => [p, states.get(p) ?? '-']);
161
+ }
162
+
163
+ /** digest(treeEntriesAt(commit, base)); equals treeHash() on a clean tree at HEAD. */
164
+ export function treeHashAt(commit, base = baseRef()) {
165
+ return digest(treeEntriesAt(commit, base));
166
+ }
167
+
168
+ /**
169
+ * Entries of the history file in file order. Missing file -> []. A blank line,
170
+ * a line that is not JSON, or one without a string treeHash is skipped.
171
+ */
172
+ export function readHistory(path = join(ROOT, HISTORY_REL)) {
173
+ if (!existsSync(path)) return [];
174
+ const out = [];
175
+ for (const raw of readFileSync(path, 'utf8').split('\n')) {
176
+ if (raw.trim() === '') continue;
177
+ let entry;
178
+ try {
179
+ entry = JSON.parse(raw);
180
+ } catch {
181
+ continue; // a corrupt line is skipped, never fatal
182
+ }
183
+ if (entry && typeof entry.treeHash === 'string') out.push(entry);
184
+ }
185
+ return out;
186
+ }
187
+
188
+ /**
189
+ * sha of the commit that last changed line `line` (1-based) of working-tree
190
+ * `file`; null when uncommitted, untracked, or on any git error.
191
+ */
192
+ export function lineCommit(file, line) {
193
+ try {
194
+ const first = execFileSync(
195
+ 'git',
196
+ ['blame', '--porcelain', '-L', `${line},${line}`, '--', file],
197
+ {
198
+ cwd: ROOT,
199
+ encoding: 'utf8',
200
+ maxBuffer: 512 * 1024 * 1024,
201
+ stdio: ['ignore', 'pipe', 'pipe'],
202
+ },
203
+ ).split('\n')[0];
204
+ const sha = /^([0-9a-f]{40}) /.exec(first)?.[1];
205
+ if (!sha) return null;
206
+ if (/^0{40}$/.test(sha)) return null;
207
+ return sha;
208
+ } catch {
209
+ return null; // untracked file, or no git: never settled
210
+ }
211
+ }