@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.
- package/LICENSE +202 -0
- package/README.md +81 -2
- package/bin/ticket-gate.mjs +261 -0
- package/checks/assertions-unguarded.mjs +31 -0
- package/checks/assertions.mjs +319 -0
- package/checks/cache-report.mjs +313 -0
- package/checks/docs-stale.mjs +69 -0
- package/checks/duplication.mjs +258 -0
- package/checks/ratchet-options.mjs +51 -0
- package/checks/surface.mjs +397 -0
- package/cli.mjs +379 -0
- package/commands-config.mjs +55 -0
- package/config.mjs +597 -0
- package/digest.mjs +195 -0
- package/env-name.mjs +24 -0
- package/failures.mjs +256 -0
- package/fu.mjs +458 -0
- package/graph/js.mjs +517 -0
- package/graph/load-time.mjs +309 -0
- package/graph/typescript.mjs +28 -0
- package/hooks/pre-tool-use.mjs +213 -0
- package/hooks/stop.mjs +495 -0
- package/is-main.mjs +15 -0
- package/kill-group.mjs +13 -0
- package/ledger.mjs +186 -0
- package/lock.d.mts +72 -0
- package/lock.mjs +329 -0
- package/move-proof.mjs +206 -0
- package/mutations/check.mjs +506 -0
- package/mutations/cli.mjs +361 -0
- package/mutations/exec.mjs +120 -0
- package/mutations/gate.mjs +148 -0
- package/mutations/harness.mjs +240 -0
- package/mutations/kinds.mjs +148 -0
- package/mutations/locate.mjs +71 -0
- package/mutations/project.mjs +23 -0
- package/mutations/registry.mjs +173 -0
- package/mutations/rotate.mjs +419 -0
- package/mutations/rotation-status.mjs +93 -0
- package/mutations/run.mjs +252 -0
- package/mutations/select.mjs +333 -0
- package/mutations/served.mjs +216 -0
- package/mutations/validate.mjs +86 -0
- package/mutations/verdict.mjs +159 -0
- package/nightly/cli.mjs +30 -0
- package/nightly/define.mjs +111 -0
- package/nightly/e2e-runs.mjs +73 -0
- package/nightly/gate.mjs +25 -0
- package/nightly/io.mjs +109 -0
- package/nightly/launchd.mjs +42 -0
- package/nightly/record.mjs +231 -0
- package/nightly/run.mjs +470 -0
- package/nightly/schedule.mjs +76 -0
- package/nightly/seed.mjs +78 -0
- package/nightly/status.mjs +189 -0
- package/nightly/suites.mjs +43 -0
- package/nightly-config.mjs +38 -0
- package/nightly-window.mjs +6 -0
- package/pack-hooks-all.mjs +15 -0
- package/pack-hooks.mjs +26 -0
- package/package.json +19 -4
- package/plan.mjs +272 -0
- package/precheck.mjs +90 -0
- package/project.mjs +21 -0
- package/ratchet.mjs +431 -0
- package/receipt.mjs +34 -0
- package/review-map.mjs +432 -0
- package/root.mjs +33 -0
- package/run.mjs +291 -0
- package/runners/playwright.mjs +132 -0
- package/score/adapters/stryker.mjs +111 -0
- package/score/base.mjs +82 -0
- package/score/cli.mjs +182 -0
- package/score/judge.mjs +107 -0
- package/score/lines.mjs +40 -0
- package/score/schema.mjs +68 -0
- package/score/survivors.mjs +103 -0
- package/score/targets.mjs +34 -0
- package/score-config.mjs +120 -0
- package/selection/check.mjs +45 -0
- package/selection/cli.mjs +267 -0
- package/selection/define.mjs +55 -0
- package/selection/for.mjs +235 -0
- package/selection/graphs.mjs +38 -0
- package/selection/project.mjs +11 -0
- package/selection/select.mjs +159 -0
- package/selection/units.mjs +646 -0
- package/selection/value-rules.mjs +142 -0
- package/selection-config.mjs +76 -0
- package/serve.mjs +142 -0
- package/summary.mjs +34 -0
- 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();
|