@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,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
|
+
}
|