@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,386 @@
1
+ import { join } from 'node:path'
2
+ import { execa } from 'execa'
3
+ import type {
4
+ CommandResult,
5
+ Emission,
6
+ MeasureContext,
7
+ RunCommand,
8
+ } from '@/gate/measures'
9
+ import type { Check, Stage } from '@/gate/stages'
10
+ import { gitEnv } from '@/git-env'
11
+ import { PROJECT_ROOT } from '@/project-root'
12
+
13
+ export type StageStatus =
14
+ /** Every check ran and none found a fact. */
15
+ | 'passed'
16
+ /** The changed set carries nothing this stage reads. */
17
+ | 'skipped'
18
+ /** The stage could not read its input, so it has no verdict to give. */
19
+ | 'unmeasured'
20
+ /** A check found a fact, which stops the run here. */
21
+ | 'failed'
22
+
23
+ export interface StageResult {
24
+ readonly id: string
25
+ readonly label: string
26
+ readonly status: StageStatus
27
+ readonly emissions: readonly Emission[]
28
+ /** The remedy line a failed stage prints, naming what to do about it. */
29
+ readonly failure?: string
30
+ }
31
+
32
+ export interface GateContext extends MeasureContext {
33
+ /** Whether the format stage writes or checks. */
34
+ readonly write: boolean
35
+ /**
36
+ * The changed set a scoped stage is read against, or `undefined` where
37
+ * scoping is off and every stage runs.
38
+ */
39
+ readonly changed?: readonly string[]
40
+ }
41
+
42
+ export interface ChangedSet {
43
+ /** `false` where no usable baseline resolved, which runs every stage. */
44
+ readonly scoped: boolean
45
+ readonly files: readonly string[]
46
+ /** What to say about a baseline that did not resolve, if anything. */
47
+ readonly notice?: string
48
+ }
49
+
50
+ /**
51
+ * The branch's committed diff, the working tree, and untracked files as one
52
+ * set. A wider set only means running more stages, so every fallback widens.
53
+ *
54
+ * The baseline is `origin/main` rather than local `main`. On `main` itself the
55
+ * local ref is HEAD, so a merge base against it resolves to HEAD and every
56
+ * commit not yet pushed drops out of the changed set, which would skip the
57
+ * scoped stages on a direct push.
58
+ */
59
+ export async function collectChangedFiles(
60
+ run: RunCommand,
61
+ ): Promise<ChangedSet> {
62
+ const remote = await run(['git', 'merge-base', 'HEAD', 'origin/main'])
63
+ let base = remote.exitCode === 0 ? remote.stdout.trim() : ''
64
+ let localBaseline = false
65
+
66
+ if (base === '') {
67
+ localBaseline = true
68
+ const local = await run(['git', 'merge-base', 'HEAD', 'main'])
69
+ base = local.exitCode === 0 ? local.stdout.trim() : ''
70
+ }
71
+
72
+ if (base === '') {
73
+ return {
74
+ scoped: false,
75
+ files: [],
76
+ notice: 'No merge base with main. Running every stage.',
77
+ }
78
+ }
79
+
80
+ // Without a remote baseline, a merge base equal to HEAD hides committed work.
81
+ if (localBaseline) {
82
+ const head = await run(['git', 'rev-parse', 'HEAD'])
83
+ const revision = head.exitCode === 0 ? head.stdout.trim() : ''
84
+ if (revision === '' || revision === base) {
85
+ return {
86
+ scoped: false,
87
+ files: [],
88
+ notice: 'No pushed baseline to compare against. Running every stage.',
89
+ }
90
+ }
91
+ }
92
+
93
+ const listings = await Promise.all([
94
+ run(['git', 'diff', '--name-only', base, 'HEAD']),
95
+ run(['git', 'diff', '--name-only', 'HEAD']),
96
+ run(['git', 'ls-files', '--others', '--exclude-standard']),
97
+ ])
98
+
99
+ const files = [
100
+ ...new Set(
101
+ listings
102
+ .flatMap((listing) => listing.stdout.split('\n'))
103
+ .map((line) => line.trim())
104
+ .filter((line) => line !== ''),
105
+ ),
106
+ ].sort()
107
+
108
+ return { scoped: true, files }
109
+ }
110
+
111
+ /**
112
+ * Whether a scoped stage has anything to read. Scoping being off answers yes
113
+ * for every stage, which is what `--all` and an absent baseline both mean.
114
+ */
115
+ export function hasChanged(
116
+ scope: RegExp,
117
+ changed: readonly string[] | undefined,
118
+ ): boolean {
119
+ if (changed === undefined) return true
120
+ return changed.some((file) => scope.test(file))
121
+ }
122
+
123
+ /**
124
+ * Runs one stage's checks in order, stopping at the first that finds a fact.
125
+ *
126
+ * Borrowed output is piped whether the check passed or failed, which is what
127
+ * the script this replaces did with a `2>&1` capture and a single
128
+ * `pipe_output`. Keeping the pass side means a run of the old script and a run
129
+ * of this one can be diffed line for line, which is the only evidence
130
+ * available that a port of this size changed no verdict.
131
+ */
132
+ export async function runStage(
133
+ stage: Stage,
134
+ ctx: GateContext,
135
+ ): Promise<StageResult> {
136
+ if (stage.scope !== undefined && !hasChanged(stage.scope, ctx.changed)) {
137
+ return {
138
+ id: stage.id,
139
+ label: stage.label,
140
+ status: 'skipped',
141
+ emissions: [
142
+ { kind: 'info', text: stage.skipped ?? 'Skipped, nothing to read' },
143
+ ],
144
+ }
145
+ }
146
+
147
+ const emissions: Emission[] = []
148
+
149
+ for (const check of stage.checks) {
150
+ const outcome = await runCheck(check, ctx)
151
+ emissions.push(...outcome.emissions)
152
+
153
+ if (outcome.failure !== undefined) {
154
+ return {
155
+ id: stage.id,
156
+ label: stage.label,
157
+ status: 'failed',
158
+ emissions,
159
+ failure: outcome.failure,
160
+ }
161
+ }
162
+
163
+ if (outcome.unmeasured !== undefined) {
164
+ // Under CI an absent input is a broken runner rather than a machine
165
+ // mid-setup, so the same reading refuses there and warns here. Skipping
166
+ // on both would report the pass the stage exists to withhold.
167
+ if (ctx.ci) {
168
+ return {
169
+ id: stage.id,
170
+ label: stage.label,
171
+ status: 'failed',
172
+ emissions,
173
+ failure: `${outcome.unmeasured} Under CI that is a broken runner rather than a machine mid-setup, so this refuses rather than passing.`,
174
+ }
175
+ }
176
+ emissions.push({ kind: 'warn', text: outcome.unmeasured })
177
+ return {
178
+ id: stage.id,
179
+ label: stage.label,
180
+ status: 'unmeasured',
181
+ emissions,
182
+ }
183
+ }
184
+ }
185
+
186
+ if (stage.success !== undefined) {
187
+ emissions.push({ kind: 'info', text: stage.success })
188
+ }
189
+
190
+ return { id: stage.id, label: stage.label, status: 'passed', emissions }
191
+ }
192
+
193
+ interface CheckOutcome {
194
+ readonly emissions: readonly Emission[]
195
+ readonly failure?: string
196
+ readonly unmeasured?: string
197
+ }
198
+
199
+ async function runCheck(check: Check, ctx: GateContext): Promise<CheckOutcome> {
200
+ if (check.kind === 'measure') {
201
+ const report = await check.measure(ctx)
202
+ return {
203
+ emissions: report.emissions,
204
+ failure: report.failure,
205
+ unmeasured: report.unmeasured,
206
+ }
207
+ }
208
+
209
+ if (check.kind === 'drift') {
210
+ return runDrift(check.pathspec, check.failure, ctx)
211
+ }
212
+
213
+ const run = check.kind === 'cli' ? ctx.cli : ctx.run
214
+ const result = await run(check.argv)
215
+ return {
216
+ emissions: [{ kind: 'output', text: result.all }],
217
+ failure: result.exitCode === 0 ? undefined : check.failure,
218
+ }
219
+ }
220
+
221
+ /**
222
+ * A regenerated surface asserted against both the index and the untracked set.
223
+ *
224
+ * The diff alone passes a regen that emitted a file nobody ever committed, so
225
+ * the listing beside it is what makes the assert cover an arrival as well as an
226
+ * edit. Either read failing yields the stage's one remedy line rather than a
227
+ * message of its own, which is what the shell this replaces did with the same
228
+ * string on both of its calls: the remedy is the same either way, since the
229
+ * fix is to stage what the regen wrote.
230
+ */
231
+ async function runDrift(
232
+ pathspec: string,
233
+ failure: string,
234
+ ctx: GateContext,
235
+ ): Promise<CheckOutcome> {
236
+ const diff = await ctx.run([
237
+ 'git',
238
+ 'diff',
239
+ '--exit-code',
240
+ '--quiet',
241
+ '--',
242
+ pathspec,
243
+ ])
244
+ const untracked = await ctx.run([
245
+ 'git',
246
+ 'ls-files',
247
+ '--others',
248
+ '--exclude-standard',
249
+ '--',
250
+ pathspec,
251
+ ])
252
+
253
+ const emissions: Emission[] = [
254
+ { kind: 'output', text: diff.all },
255
+ { kind: 'output', text: untracked.all },
256
+ ]
257
+
258
+ const drifted = diff.exitCode !== 0 || untracked.stdout.trim() !== ''
259
+ return { emissions, failure: drifted ? failure : undefined }
260
+ }
261
+
262
+ /**
263
+ * Runs the stages in table order and stops at the first failure.
264
+ *
265
+ * Stopping is what the script this replaces did with `set -e` and an exiting
266
+ * log line, and it is load bearing for the regenerate-then-assert stages:
267
+ * clearing one reveals the next behind it, so a branch touching several
268
+ * regenerated surfaces costs a stage per surface rather than one round.
269
+ */
270
+ export async function runStages(
271
+ stages: readonly Stage[],
272
+ ctx: GateContext,
273
+ onResult?: (result: StageResult) => void,
274
+ ): Promise<StageResult[]> {
275
+ const results: StageResult[] = []
276
+
277
+ for (const stage of stages) {
278
+ if (stage.when !== undefined && !stage.when({ write: ctx.write })) continue
279
+
280
+ const result = await runStage(stage, ctx)
281
+ results.push(result)
282
+ onResult?.(result)
283
+ if (result.status === 'failed') break
284
+ }
285
+
286
+ return results
287
+ }
288
+
289
+ export interface Summary {
290
+ /** Stages that ran, whatever they concluded. */
291
+ readonly ran: number
292
+ readonly passed: number
293
+ readonly skipped: number
294
+ readonly unmeasured: number
295
+ readonly failed: number
296
+ }
297
+
298
+ export function summarize(results: readonly StageResult[]): Summary {
299
+ const counting = (status: StageStatus) =>
300
+ results.filter((result) => result.status === status).length
301
+
302
+ return {
303
+ ran: results.length,
304
+ passed: counting('passed'),
305
+ skipped: counting('skipped'),
306
+ unmeasured: counting('unmeasured'),
307
+ failed: counting('failed'),
308
+ }
309
+ }
310
+
311
+ /**
312
+ * One code for a failure, which is what a `bun run` caller and a git hook both
313
+ * read. A stage that could not measure does not take a code of its own here,
314
+ * because it has already refused under CI and reports on a contributor's
315
+ * machine, so a second code would name a state no caller branches on.
316
+ */
317
+ export function exitCodeFor(results: readonly StageResult[]): number {
318
+ return results.some((result) => result.status === 'failed') ? 1 : 0
319
+ }
320
+
321
+ /**
322
+ * Spawns a command from the project root with git's resolution variables
323
+ * stripped.
324
+ *
325
+ * A git hook exports `GIT_DIR`, which takes precedence over the working
326
+ * directory, so a stage reading history would resolve against whatever
327
+ * repository the hook points at rather than the tree being verified.
328
+ */
329
+ export function commandRunner(root: string): RunCommand {
330
+ return async (argv) => spawn(argv[0], argv.slice(1), root)
331
+ }
332
+
333
+ /**
334
+ * Spawns this checkout's own CLI rather than whatever `aitk` resolves to.
335
+ *
336
+ * A globally installed binary resolves to the main checkout no matter which
337
+ * worktree is running, so a gate reading through it would measure the wrong
338
+ * tree and report a pass over a branch it never opened.
339
+ */
340
+ export function cliRunner(root: string): RunCommand {
341
+ const cli = join(PROJECT_ROOT, 'src', 'cli.ts')
342
+ return async (argv) => spawn(process.execPath, [cli, ...argv], root)
343
+ }
344
+
345
+ async function spawn(
346
+ file: string,
347
+ args: readonly string[],
348
+ cwd: string,
349
+ ): Promise<CommandResult> {
350
+ const result = await execa(file, [...args], {
351
+ cwd,
352
+ reject: false,
353
+ all: true,
354
+ env: { ...gitEnv(), AITK_NON_INTERACTIVE: '1' },
355
+ extendEnv: false,
356
+ })
357
+
358
+ const code = (result as { code?: string }).code
359
+ return {
360
+ exitCode: result.exitCode ?? 1,
361
+ stdout: result.stdout ?? '',
362
+ stderr: result.stderr ?? '',
363
+ all: result.all ?? '',
364
+ spawnError: code === 'ENOENT' ? `${file} is not on PATH` : undefined,
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Repairs `core.bare`, which Claude Code's worktree entry leaves set in the
370
+ * shared config and nothing restores.
371
+ *
372
+ * It runs ahead of every stage rather than as one of them, because the flag
373
+ * breaks the git reads that scope the run. The rule itself stays in
374
+ * `scripts/lib/worktree.sh`, which is the one bash function under test, so this
375
+ * calls it rather than restating the guard that spares a genuinely bare
376
+ * repository.
377
+ */
378
+ export async function repairBareFlag(root: string): Promise<void> {
379
+ await execa('bash', [join(root, 'scripts/core/repair-bare-flag.sh')], {
380
+ cwd: root,
381
+ reject: false,
382
+ stdio: 'inherit',
383
+ env: { ...gitEnv(), PROJECT_ROOT: root },
384
+ extendEnv: false,
385
+ })
386
+ }