@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-pr-review/SKILL.md +8 -0
- package/docs/agents/audits.md +1 -1
- package/docs/agents/commands.md +3 -0
- package/docs/agents/gate.md +84 -0
- package/docs/agents/index.md +2 -0
- package/docs/agents/key-changes.md +103 -0
- package/docs/agents/markdown-audit.md +3 -3
- package/docs/agents/state-scoped-risk.md +1 -1
- package/docs/operating-model.md +8 -0
- package/package.json +3 -3
- package/scripts/core/list-seed-roots.sh +18 -0
- package/scripts/core/repair-bare-flag.sh +19 -0
- package/scripts/core/update.sh +3 -3
- package/src/audits/catalog.ts +2 -2
- package/src/audits/run.ts +2 -1
- package/src/cli.ts +7 -0
- package/src/commands/claude.ts +2 -2
- package/src/commands/context.ts +2 -2
- package/src/commands/gate.ts +189 -0
- package/src/commands/pr.ts +411 -0
- package/src/gate/measures.ts +682 -0
- package/src/gate/sequencer.ts +386 -0
- package/src/gate/stages.ts +412 -0
- package/src/pr/bijection.ts +145 -0
- package/src/pr/paths.ts +335 -0
- package/scripts/core/verify.sh +0 -694
|
@@ -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
|
+
}
|