@erclx/aitk 3.52.0 → 3.53.0

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.
@@ -0,0 +1,189 @@
1
+ import type { Command } from 'commander'
2
+ import {
3
+ cliRunner,
4
+ collectChangedFiles,
5
+ commandRunner,
6
+ exitCodeFor,
7
+ type GateContext,
8
+ repairBareFlag,
9
+ type StageResult,
10
+ type Summary,
11
+ runStages,
12
+ summarize,
13
+ } from '@/gate/sequencer'
14
+ import { STAGES } from '@/gate/stages'
15
+ import { PROJECT_ROOT } from '@/project-root'
16
+ import {
17
+ intro,
18
+ logError,
19
+ logInfo,
20
+ logStep,
21
+ logWarn,
22
+ outro,
23
+ palette,
24
+ pipeOutput,
25
+ plural,
26
+ } from '@/ui'
27
+
28
+ interface RunCommandOptions {
29
+ readonly all?: boolean
30
+ readonly write?: boolean
31
+ readonly nested?: boolean
32
+ readonly json?: boolean
33
+ }
34
+
35
+ export function register(program: Command): void {
36
+ const gate = program
37
+ .command('gate')
38
+ .description('Run the merge gate this repository verifies a branch with')
39
+ .helpOption('-h, --help', 'Show this help message')
40
+
41
+ gate
42
+ .command('run')
43
+ .description(
44
+ 'Run every gating stage in order, scoping shell, types, and tests to the changed set',
45
+ )
46
+ .helpOption('-h, --help', 'Show this help message')
47
+ .option(
48
+ '--all',
49
+ 'Run every stage instead of scoping shell, types, and tests to changed files',
50
+ )
51
+ .option(
52
+ '--no-write',
53
+ 'Check formatting instead of applying it, which is what a merge gate wants',
54
+ )
55
+ .option(
56
+ '--nested',
57
+ 'Suppress the outer frame when another script opened one',
58
+ )
59
+ .option('--json', 'Add a machine-readable record on stdout')
60
+ .addHelpText(
61
+ 'after',
62
+ [
63
+ '',
64
+ 'Exit codes:',
65
+ ' 0 every stage that ran reported, and none found a fact',
66
+ ' 1 a stage found a fact, or could not measure its input under CI',
67
+ '',
68
+ 'A stage halts the run, so clearing a regenerate-then-assert stage',
69
+ 'reveals the next one behind it rather than the whole set at once.',
70
+ '',
71
+ 'A stage that cannot read its input reports rather than passing. On a',
72
+ "contributor's machine that is a warning and the run still exits 0,",
73
+ 'because an absent tool there is somebody mid-setup. Under CI it',
74
+ 'refuses, because the same absence is a broken workflow step and a',
75
+ 'green run over a stage that measured nothing is the pass the gate',
76
+ 'exists to withhold.',
77
+ '',
78
+ 'Examples:',
79
+ ' aitk gate run',
80
+ ' aitk gate run --all --no-write',
81
+ ' aitk gate run --json',
82
+ '',
83
+ ].join('\n'),
84
+ )
85
+ .action(async (opts: RunCommandOptions) => {
86
+ process.exitCode = await runGate(opts)
87
+ })
88
+ }
89
+
90
+ async function runGate(opts: RunCommandOptions): Promise<number> {
91
+ const root = PROJECT_ROOT
92
+ const emitJson = opts.json ?? false
93
+ const nested = opts.nested ?? false
94
+ const write = opts.write ?? true
95
+ const run = commandRunner(root)
96
+
97
+ if (!emitJson && !nested) intro('aitk gate run')
98
+
99
+ await repairBareFlag(root)
100
+
101
+ const changed = opts.all
102
+ ? { scoped: false, files: [] }
103
+ : await collectChangedFiles(run)
104
+ if (!emitJson && changed.notice !== undefined) logWarn(changed.notice)
105
+
106
+ const ctx: GateContext = {
107
+ root,
108
+ ci: process.env.CI === 'true',
109
+ run,
110
+ cli: cliRunner(root),
111
+ write,
112
+ changed: changed.scoped ? changed.files : undefined,
113
+ }
114
+
115
+ const results = await runStages(
116
+ STAGES,
117
+ ctx,
118
+ emitJson ? undefined : (result) => report(result),
119
+ )
120
+ const summary = summarize(results)
121
+ const code = exitCodeFor(results)
122
+
123
+ if (emitJson) {
124
+ const failed = results.find((result) => result.status === 'failed')
125
+ // Diagnostics reach stderr in every mode, so a caller reading the record
126
+ // alone is not the only one told what went wrong.
127
+ if (failed?.failure !== undefined) {
128
+ process.stderr.write(`${failed.label}: ${failed.failure}\n`)
129
+ }
130
+ process.stdout.write(
131
+ `${JSON.stringify({
132
+ ok: code === 0,
133
+ root,
134
+ scoped: changed.scoped,
135
+ changed: changed.files.length,
136
+ summary,
137
+ stages: results.map(({ id, label, status, failure }) => ({
138
+ id,
139
+ label,
140
+ status,
141
+ failure,
142
+ })),
143
+ })}\n`,
144
+ )
145
+ return code
146
+ }
147
+
148
+ if (!nested) close(summary, code)
149
+ return code
150
+ }
151
+
152
+ function report(result: StageResult): void {
153
+ logStep(result.label)
154
+ for (const emission of result.emissions) {
155
+ if (emission.kind === 'info') logInfo(emission.text)
156
+ else if (emission.kind === 'warn') logWarn(emission.text)
157
+ else pipeOutput(emission.text)
158
+ }
159
+ if (result.failure !== undefined) logError(result.failure)
160
+ }
161
+
162
+ /**
163
+ * The closing verdict, which names what the run did not measure rather than
164
+ * reporting a bare pass over it.
165
+ *
166
+ * A run where every stage read its input still closes on the line the script
167
+ * this replaces closed on, so nothing about the ordinary case moved. A run
168
+ * carrying an unmeasured stage says so, because a green line over a stage that
169
+ * looked at nothing is exactly the silence the reporting outcome exists
170
+ * against.
171
+ */
172
+ function close(summary: Summary, code: number): void {
173
+ const { GREEN, NC, RED, YELLOW } = palette(process.stderr)
174
+ outro()
175
+
176
+ if (code !== 0) {
177
+ process.stderr.write(`${RED}✗ Verification failed${NC}\n\n`)
178
+ return
179
+ }
180
+
181
+ if (summary.unmeasured > 0) {
182
+ process.stderr.write(
183
+ `${YELLOW}! Verification passed, ${plural(summary.unmeasured, 'stage')} measured nothing${NC}\n\n`,
184
+ )
185
+ return
186
+ }
187
+
188
+ process.stderr.write(`${GREEN}✓ Verification passed${NC}\n\n`)
189
+ }
@@ -0,0 +1,411 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
+ import { $ } from 'bun'
4
+ import type { Command } from 'commander'
5
+ import { execa } from 'execa'
6
+ import { gitEnv } from '@/git-env'
7
+ import {
8
+ listChangedFiles,
9
+ listRepositoryFiles,
10
+ resolveBaseRef,
11
+ } from '@/git-files'
12
+ import {
13
+ type Bijection,
14
+ type BijectionRefusal,
15
+ compareKeyChanges,
16
+ treeRoots,
17
+ } from '@/pr/bijection'
18
+ import { KEY_CHANGES } from '@/pr/paths'
19
+ import { intro, logInfo, logStep, logWarn, outro, plural } from '@/ui'
20
+
21
+ const GH_TIMEOUT_MS = 30_000
22
+
23
+ /** How many unnamed files the frame prints before it names a count instead. */
24
+ const UNNAMED_PRINT_LIMIT = 10
25
+
26
+ /**
27
+ * Where `gh pr view --json files` stops.
28
+ *
29
+ * It pages the underlying query once and returns at most this many rows with
30
+ * nothing on the record saying so, which was measured against `#1250`: the
31
+ * pull request carries 101 files and the view reports 100. A set silently one
32
+ * short is the worst input this comparison can take, since the missing file is
33
+ * exactly what a claim would then be accused of inventing.
34
+ */
35
+ const GH_VIEW_FILE_CAP = 100
36
+
37
+ interface KeyChangesOptions {
38
+ readonly body?: string
39
+ readonly base?: string
40
+ readonly root?: string
41
+ readonly json?: boolean
42
+ }
43
+
44
+ /** Why the read produced no comparison, ahead of the ones the compare owns. */
45
+ type SourceRefusal =
46
+ | 'gh-missing'
47
+ | 'gh-failed'
48
+ | 'gh-truncated'
49
+ | 'unreadable-body'
50
+ | 'unreadable-tree'
51
+ | 'no-base'
52
+ | 'bad-base'
53
+ | 'unreadable-changes'
54
+
55
+ type Refusal = SourceRefusal | BijectionRefusal
56
+
57
+ /** What a reader does about each way this produced no reading. */
58
+ const REFUSALS: Record<Refusal, string> = {
59
+ 'gh-missing':
60
+ 'gh is not on the path, so no pull request body could be read. Pass --body <path> to read one off disk instead.',
61
+ 'gh-failed':
62
+ 'gh could not answer for this branch. Name the pull request number, or pass --body <path>.',
63
+ 'gh-truncated': `gh returned the first ${GH_VIEW_FILE_CAP} changed files and the paginated read that would complete the set failed, so a claim could be accused of naming a file this read never saw.`,
64
+ 'unreadable-body': 'The file named by --body could not be read.',
65
+ 'unreadable-tree':
66
+ 'git could not list this repository, so no path could be judged whole rather than partial.',
67
+ 'no-base': 'No base resolves against the trunk. Fetch origin or pass --base.',
68
+ 'bad-base':
69
+ 'The ref passed to --base resolves to no commit here. Pass one this tree carries.',
70
+ 'unreadable-changes':
71
+ 'git could not list what this branch changed, so the set is unknown.',
72
+ 'no-section': `This body carries no ## ${KEY_CHANGES} section, so it claims nothing to compare.`,
73
+ 'no-claims': `The ## ${KEY_CHANGES} section carried no path this reader could resolve. That is the extractor failing over prose rather than the body being wrong, so nothing is raised.`,
74
+ 'no-changes':
75
+ 'The pull request changed no files, so there is nothing for a claim to answer.',
76
+ }
77
+
78
+ export function register(program: Command): void {
79
+ const pr = program
80
+ .command('pr')
81
+ .description('Read a pull request body against the change it describes')
82
+ .helpOption('-h, --help', 'Show this help message')
83
+
84
+ pr.command('key-changes')
85
+ .description(
86
+ `Compare the files a body's ## ${KEY_CHANGES} names against its own diff`,
87
+ )
88
+ .argument('[number]', 'Pull request to read, defaulting to this branch')
89
+ .helpOption('-h, --help', 'Show this help message')
90
+ .option(
91
+ '--body <path>',
92
+ 'Read the body from a file rather than the API, ignoring any number',
93
+ )
94
+ .option(
95
+ '--base <ref>',
96
+ 'Far side of the range when --body supplies the body',
97
+ )
98
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
99
+ .option('--json', 'Add a machine-readable record on stdout')
100
+ .addHelpText(
101
+ 'after',
102
+ [
103
+ '',
104
+ 'This repository squash-merges, so a pull request body becomes the commit',
105
+ 'message and the record on the trunk once the branch is gone. A bullet',
106
+ 'claiming a change nobody made corrupts that record, and a changed file no',
107
+ 'bullet names leaves it incomplete.',
108
+ '',
109
+ 'The two directions carry different weight:',
110
+ ' unmet a whole path the body claims and the diff does not carry,',
111
+ ' which is the graded direction',
112
+ ' unnamed a changed file no bullet reached, reported without a grade,',
113
+ ' since a lockfile or a generated asset earns no bullet',
114
+ ' unresolved a path written partially, which can credit a changed file',
115
+ ' and never accuse one',
116
+ '',
117
+ `Only ## ${KEY_CHANGES} is read. ## Technical Context legitimately names`,
118
+ 'files a branch never touched, so widening the read manufactures findings.',
119
+ '',
120
+ 'One class survives the reader: a bullet citing where something is defined',
121
+ 'while claiming an edit elsewhere puts a real path in the claim region and',
122
+ 'points the change at a locative the path does not name. Read the bullet on',
123
+ "the record's preview before filing an unmet path as a stale claim.",
124
+ '',
125
+ 'Exit codes:',
126
+ ' 0 every claimed path is in the diff',
127
+ ' 1 refused, with the reason on stderr or in the JSON record',
128
+ ' 2 at least one claimed path is absent from the diff',
129
+ '',
130
+ 'Examples:',
131
+ ' aitk pr key-changes',
132
+ ' aitk pr key-changes 1265 --json',
133
+ ' aitk pr key-changes --body .claude/.tmp/body.md --base origin/main',
134
+ '',
135
+ ].join('\n'),
136
+ )
137
+ .action(async (number: string | undefined, opts: KeyChangesOptions) => {
138
+ process.exitCode = await runKeyChanges(number, opts)
139
+ })
140
+ }
141
+
142
+ interface PullRequestRead {
143
+ readonly body: string
144
+ readonly changed: readonly string[]
145
+ readonly head: string | undefined
146
+ readonly number: number | undefined
147
+ }
148
+
149
+ type SourceRead =
150
+ | { readonly kind: 'read'; readonly source: PullRequestRead }
151
+ | { readonly kind: 'refused'; readonly reason: SourceRefusal }
152
+
153
+ /**
154
+ * Reads the body and the changed set from the pull request the caller named,
155
+ * or from the one open on this branch.
156
+ *
157
+ * One call wherever the file list fits inside it. The body and the file list
158
+ * have to describe the same head, and reading them separately leaves a window
159
+ * where a push between them compares a body against another commit's files. A
160
+ * pull request at the view's cap takes the second read anyway, because a set
161
+ * short by an unknown number is worse than a set read a moment later.
162
+ */
163
+ async function readFromApi(
164
+ cwd: string,
165
+ number: string | undefined,
166
+ ): Promise<SourceRead> {
167
+ if (Bun.which('gh') === null) {
168
+ return { kind: 'refused', reason: 'gh-missing' }
169
+ }
170
+
171
+ const args = ['pr', 'view']
172
+ if (number !== undefined) args.push(number)
173
+ args.push('--json', 'body,files,headRefOid,number')
174
+
175
+ try {
176
+ // See src/worktrees/reclaim.ts for why gh needs the stripped environment:
177
+ // it resolves its repository through the same variables git does and they
178
+ // beat `cwd`, so a run from inside a hook would answer for another
179
+ // repository and compare this branch's claims against its files.
180
+ const result = await execa('gh', args, {
181
+ cwd,
182
+ timeout: GH_TIMEOUT_MS,
183
+ env: gitEnv(),
184
+ extendEnv: false,
185
+ })
186
+
187
+ const row = JSON.parse(result.stdout) as {
188
+ body?: string
189
+ files?: readonly { path: string }[]
190
+ headRefOid?: string
191
+ number?: number
192
+ }
193
+
194
+ const viewed = (row.files ?? []).map((file) => file.path)
195
+ const changed =
196
+ viewed.length < GH_VIEW_FILE_CAP || row.number === undefined
197
+ ? viewed
198
+ : await listFilesByPage(cwd, row.number)
199
+
200
+ if (changed === undefined) {
201
+ return { kind: 'refused', reason: 'gh-truncated' }
202
+ }
203
+
204
+ return {
205
+ kind: 'read',
206
+ source: {
207
+ body: row.body ?? '',
208
+ changed: [...changed].sort(),
209
+ head: row.headRefOid,
210
+ number: row.number,
211
+ },
212
+ }
213
+ } catch {
214
+ return { kind: 'refused', reason: 'gh-failed' }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Every file a pull request changed, read through the paginated endpoint.
220
+ *
221
+ * Only reached when the view came back at the cap, since it costs a request per
222
+ * page and nearly every pull request here fits in one view. Returns undefined
223
+ * when the follow-up fails, which refuses rather than falling back to the
224
+ * capped set: a comparison run against a set known to be short would accuse a
225
+ * correct bullet of naming a file nobody changed.
226
+ */
227
+ async function listFilesByPage(
228
+ cwd: string,
229
+ number: number,
230
+ ): Promise<string[] | undefined> {
231
+ try {
232
+ // See src/worktrees/reclaim.ts for why gh needs the stripped environment.
233
+ const result = await execa(
234
+ 'gh',
235
+ [
236
+ 'api',
237
+ '--paginate',
238
+ `repos/{owner}/{repo}/pulls/${number}/files`,
239
+ '--jq',
240
+ '.[].filename',
241
+ ],
242
+ { cwd, timeout: GH_TIMEOUT_MS, env: gitEnv(), extendEnv: false },
243
+ )
244
+ return result.stdout.split('\n').filter(Boolean)
245
+ } catch {
246
+ return undefined
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Reads the body off disk and the changed set from git, which is the shape a
252
+ * fixture and a body still being drafted both need.
253
+ */
254
+ async function readFromFile(
255
+ root: string,
256
+ path: string,
257
+ base: string | undefined,
258
+ ): Promise<SourceRead> {
259
+ let body: string
260
+ try {
261
+ body = await readFile(resolve(root, path), 'utf8')
262
+ } catch {
263
+ return { kind: 'refused', reason: 'unreadable-body' }
264
+ }
265
+
266
+ const resolved = await resolveBaseRef(root, base)
267
+ if (resolved === undefined) {
268
+ return {
269
+ kind: 'refused',
270
+ reason: base === undefined ? 'no-base' : 'bad-base',
271
+ }
272
+ }
273
+
274
+ const changed = await listChangedFiles(root, resolved)
275
+ if (changed === undefined) {
276
+ return { kind: 'refused', reason: 'unreadable-changes' }
277
+ }
278
+
279
+ const head = await $`git -C ${root} rev-parse HEAD`
280
+ .env(gitEnv())
281
+ .quiet()
282
+ .nothrow()
283
+
284
+ return {
285
+ kind: 'read',
286
+ source: {
287
+ body,
288
+ changed,
289
+ head: head.exitCode === 0 ? head.text().trim() : undefined,
290
+ number: undefined,
291
+ },
292
+ }
293
+ }
294
+
295
+ async function runKeyChanges(
296
+ number: string | undefined,
297
+ opts: KeyChangesOptions,
298
+ ): Promise<number> {
299
+ const root = resolve(opts.root ?? process.cwd())
300
+ const emitJson = opts.json ?? false
301
+
302
+ intro('aitk pr key-changes')
303
+
304
+ const source =
305
+ opts.body === undefined
306
+ ? await readFromApi(root, number)
307
+ : await readFromFile(root, opts.body, opts.base)
308
+
309
+ if (source.kind === 'refused') return refuse(source.reason, emitJson, root)
310
+
311
+ const tracked = await listRepositoryFiles(root)
312
+ if (tracked === undefined) return refuse('unreadable-tree', emitJson, root)
313
+
314
+ const report: Bijection = compareKeyChanges({
315
+ body: source.source.body,
316
+ changed: source.source.changed,
317
+ roots: treeRoots(tracked, source.source.changed),
318
+ ...(source.source.head !== undefined && { head: source.source.head }),
319
+ })
320
+
321
+ if (report.kind === 'refused') return refuse(report.reason, emitJson, root)
322
+
323
+ logStep('Scope')
324
+ logInfo(
325
+ `${plural(report.claims.length, 'claim')} against ${plural(report.changed.length, 'changed file')}${
326
+ report.head === undefined ? '' : ` at ${report.head.slice(0, 8)}`
327
+ }`,
328
+ )
329
+
330
+ logStep(report.unmet.length === 0 ? 'Claimed' : 'Unmet')
331
+ if (report.unmet.length === 0) {
332
+ logInfo('every claimed path is in the diff')
333
+ } else {
334
+ logWarn(
335
+ `${plural(report.unmet.length, 'claimed path')} the diff does not carry. Correct the bullet, or make the change it describes.`,
336
+ )
337
+ for (const claim of report.unmet) {
338
+ logWarn(`${claim.path} — ${claim.preview}`)
339
+ }
340
+ }
341
+
342
+ // Named rather than counted into the verdict. A generated asset, a lockfile,
343
+ // and a regenerated index all change without earning a bullet, so grading
344
+ // this direction would fire on nearly every branch.
345
+ logStep('Unnamed')
346
+ if (report.unnamed.length === 0) {
347
+ logInfo('every changed file is reached by a bullet')
348
+ } else {
349
+ logInfo(
350
+ `${plural(report.unnamed.length, 'changed file')} no bullet reached. Add one where the change is worth a reader knowing about.`,
351
+ )
352
+ // Capped in the frame and whole in the record. A rename branch measured
353
+ // here left 71 of its 100 files unnamed, correctly, and printing all of
354
+ // them buries the graded direction above under a list nobody reads.
355
+ for (const path of report.unnamed.slice(0, UNNAMED_PRINT_LIMIT)) {
356
+ logInfo(path)
357
+ }
358
+ if (report.unnamed.length > UNNAMED_PRINT_LIMIT) {
359
+ logInfo(
360
+ `…and ${report.unnamed.length - UNNAMED_PRINT_LIMIT} more, whole in the --json record.`,
361
+ )
362
+ }
363
+ }
364
+
365
+ if (report.unresolved.length > 0) {
366
+ logStep('Unresolved')
367
+ logInfo(
368
+ `${plural(report.unresolved.length, 'path')} written partially, so neither direction judged it.`,
369
+ )
370
+ for (const claim of report.unresolved) logInfo(claim.path)
371
+ }
372
+
373
+ outro()
374
+
375
+ if (emitJson) {
376
+ process.stdout.write(
377
+ `${JSON.stringify({
378
+ root,
379
+ ...(source.source.number !== undefined && {
380
+ number: source.source.number,
381
+ }),
382
+ ...(report.head !== undefined && { head: report.head }),
383
+ changed: report.changed,
384
+ claims: report.claims,
385
+ unmet: report.unmet,
386
+ unnamed: report.unnamed,
387
+ unresolved: report.unresolved,
388
+ })}\n`,
389
+ )
390
+ }
391
+
392
+ return report.unmet.length === 0 ? 0 : 2
393
+ }
394
+
395
+ /**
396
+ * Frames a refusal on stderr in both modes and puts the record on stdout alone,
397
+ * so an operator reading the terminal sees the reason rather than a command
398
+ * that appeared to do nothing.
399
+ */
400
+ function refuse(reason: Refusal, emitJson: boolean, root: string): number {
401
+ logStep('Refused')
402
+ logWarn(REFUSALS[reason])
403
+ outro()
404
+
405
+ if (emitJson) {
406
+ process.stdout.write(
407
+ `${JSON.stringify({ root, reason, message: REFUSALS[reason] })}\n`,
408
+ )
409
+ }
410
+ return 1
411
+ }