@erclx/canon 5.46.0 → 5.47.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/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/REQUIREMENT.md +2 -2
- package/claude/skills/git-pr/SKILL.md +2 -1
- package/claude/skills/git-pr/references/pr.md +58 -7
- package/claude/skills/review-address/SKILL.md +18 -6
- package/claude/skills/review-address/references/post-review-findings.md +4 -1
- package/claude/skills/review-address/references/rebase-only.md +14 -2
- package/claude/skills/review-pr/SKILL.md +9 -2
- package/claude/skills/review-pr/references/body-variants.md +29 -8
- package/claude/skills/review-pr/references/post.md +29 -10
- package/claude/skills/review-ui/SKILL.md +1 -0
- package/claude/skills/review-ui/references/post.md +2 -0
- package/claude/skills/review-ui/references/tick.md +3 -0
- package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
- package/claude/skills/role-reviewer/SKILL.md +3 -0
- package/claude/skills/role-worker/REQUIREMENT.md +1 -1
- package/claude/skills/role-worker/SKILL.md +1 -0
- package/claude/skills/task-board/references/decline.md +2 -2
- package/claude/skills/task-complete/references/archive.md +3 -2
- package/claude/skills/ui-checklist/SKILL.md +3 -3
- package/docs/workflow/zshrc-aliases.md +3 -0
- package/package.json +1 -1
- package/scripts/core/bootstrap.sh +1 -0
- package/src/commands/labels.ts +66 -1
- package/src/commands/pr.ts +113 -4
- package/src/commands/sessions.ts +25 -11
- package/src/commands/tasks.ts +2 -0
- package/src/labels/density.ts +363 -0
- package/src/pr/evidence.ts +1 -1
- package/src/pr/rest.ts +2 -0
- package/src/pr/tick.ts +89 -0
- package/src/sessions/live.ts +26 -0
- package/src/sessions/resolve.ts +62 -11
- package/src/tasks/archive.ts +15 -4
- package/tooling/base/seeds/.github/pull_request_template.md +1 -1
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type DensityFinding,
|
|
3
|
+
type DensityKind,
|
|
4
|
+
densityFindings,
|
|
5
|
+
} from '@/markdown/density'
|
|
6
|
+
import { type BodyLine, bodyLines, visibleText } from '@/markdown/scan'
|
|
7
|
+
import { CHECKPOINTS, LIST_ITEM } from '@/markdown/structure'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The four counted limits a pull request section carries. The heading limits
|
|
11
|
+
* of `densityFindings()` are left out, since a body takes no subheading at all
|
|
12
|
+
* and `subheading` reports that once.
|
|
13
|
+
*/
|
|
14
|
+
const COUNTED = [
|
|
15
|
+
'paragraph-length',
|
|
16
|
+
'paragraph-run',
|
|
17
|
+
'bullet-length',
|
|
18
|
+
'bullet-run',
|
|
19
|
+
] as const satisfies readonly DensityKind[]
|
|
20
|
+
|
|
21
|
+
type CountedKind = (typeof COUNTED)[number]
|
|
22
|
+
|
|
23
|
+
export type BodyDensityKind = CountedKind | 'subheading' | 'mixed-form'
|
|
24
|
+
|
|
25
|
+
function isCounted(kind: DensityKind): kind is CountedKind {
|
|
26
|
+
return (COUNTED as readonly DensityKind[]).includes(kind)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BodyDensityFinding {
|
|
30
|
+
/** The H2 the finding sits under, without its hashes, or null before the first. */
|
|
31
|
+
readonly section: string | null
|
|
32
|
+
readonly kind: BodyDensityKind
|
|
33
|
+
/** The line in the whole body, not in its section. */
|
|
34
|
+
readonly line: number
|
|
35
|
+
readonly value: number | null
|
|
36
|
+
readonly limit: number | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const H2 = /^##\s+(.*?)\s*$/
|
|
40
|
+
const SUBHEADING = /^(#{3,6})\s/
|
|
41
|
+
const BOLD_ONLY = /^(\*\*|__)[^*_].*\1\s*$/
|
|
42
|
+
const BOLD_LED = /^(\*\*|__)\S/
|
|
43
|
+
|
|
44
|
+
interface Section {
|
|
45
|
+
readonly name: string | null
|
|
46
|
+
readonly lines: readonly BodyLine[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every density breach in a pull request body, graded per H2 section after
|
|
51
|
+
* the shape `claude/skills/git-pr/references/pr.md` states.
|
|
52
|
+
*
|
|
53
|
+
* `densityFindings()` stays the docs measure, so the PR exemptions live here
|
|
54
|
+
* as splits around it rather than as options on it. Testing takes no counted
|
|
55
|
+
* limit, since its checkboxes count as a bullet run. Key changes splits at
|
|
56
|
+
* each bold label alone on its own block, whatever its length, and Technical context
|
|
57
|
+
* at each bold-led paragraph, so a lead-in starts a new run. Technical
|
|
58
|
+
* context also reports mixing bullets with paragraphs.
|
|
59
|
+
*/
|
|
60
|
+
export function bodyDensity(body: string): BodyDensityFinding[] {
|
|
61
|
+
const found: BodyDensityFinding[] = []
|
|
62
|
+
|
|
63
|
+
for (const section of sectionsOf(bodyLines(body))) {
|
|
64
|
+
found.push(...subheadings(section))
|
|
65
|
+
if (section.name === 'Testing') continue
|
|
66
|
+
|
|
67
|
+
const chunks =
|
|
68
|
+
section.name === 'Key changes'
|
|
69
|
+
? splitAt(section.lines, opensBlock(section.lines, BOLD_ONLY), true)
|
|
70
|
+
: section.name === 'Technical context'
|
|
71
|
+
? splitAt(section.lines, opensBlock(section.lines, BOLD_LED), false)
|
|
72
|
+
: [section.lines]
|
|
73
|
+
|
|
74
|
+
for (const chunk of chunks) {
|
|
75
|
+
for (const finding of densityFindings(chunk)) {
|
|
76
|
+
const { kind } = finding
|
|
77
|
+
if (!isCounted(kind)) continue
|
|
78
|
+
found.push({ section: section.name, ...finding, kind })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (section.name === 'Technical context') {
|
|
83
|
+
const mixed = mixedForm(section.lines)
|
|
84
|
+
if (mixed !== undefined) {
|
|
85
|
+
found.push({
|
|
86
|
+
section: section.name,
|
|
87
|
+
kind: 'mixed-form',
|
|
88
|
+
line: mixed,
|
|
89
|
+
value: null,
|
|
90
|
+
limit: null,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return found.sort((a, b) => a.line - b.line)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The opening headings of the posts a canon skill writes to a pull request,
|
|
101
|
+
* after `.canon/groundwork/170-github-comment-density/04-post-catalog.md`. A
|
|
102
|
+
* body opening on any other heading is not a post this reader grades.
|
|
103
|
+
*/
|
|
104
|
+
export const POST_HEADINGS = [
|
|
105
|
+
'Evidence',
|
|
106
|
+
'Review',
|
|
107
|
+
'Review closed',
|
|
108
|
+
'UI review',
|
|
109
|
+
'UI review closed',
|
|
110
|
+
'Review response',
|
|
111
|
+
'Post-review findings',
|
|
112
|
+
'Rebase',
|
|
113
|
+
] as const
|
|
114
|
+
|
|
115
|
+
export type PostDensityKind = CountedKind | 'heading-depth'
|
|
116
|
+
|
|
117
|
+
export interface PostDensityFinding extends Omit<BodyDensityFinding, 'kind'> {
|
|
118
|
+
readonly kind: PostDensityKind
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const LENGTHS: readonly DensityKind[] = ['paragraph-length', 'bullet-length']
|
|
122
|
+
const NESTED_ITEM = /^(\s+)(?:[-*+]|\d+\.)\s+(.*)$/
|
|
123
|
+
const CHECKBOX = /^\s*[-*+]\s+\[[ xX]\]/
|
|
124
|
+
const FOOTER = /^🤖/
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Every density breach in a post a skill writes to a pull request, keyed on
|
|
128
|
+
* its opening H2, or none when that heading is not in `POST_HEADINGS`.
|
|
129
|
+
*
|
|
130
|
+
* A post allows H3s, which the UI review groups its verdicts under, so only a
|
|
131
|
+
* heading past H3 reports. The `🤖` footer and the marker never report, and a
|
|
132
|
+
* table never does. Inside `<details>` a block takes the length limits alone,
|
|
133
|
+
* since a reader opens it on purpose. A bold-led paragraph starts a new
|
|
134
|
+
* paragraph run, and a checklist box under `What to look at` takes no length
|
|
135
|
+
* limit, since a driver needs its route, action, and result.
|
|
136
|
+
*
|
|
137
|
+
* A nested item is measured as a bullet of its own, which `densityFindings()`
|
|
138
|
+
* leaves out of its parent, so a finding's subject and its evidence never sum.
|
|
139
|
+
*/
|
|
140
|
+
export function postDensity(body: string): PostDensityFinding[] {
|
|
141
|
+
const lines = bodyLines(body).map((line) =>
|
|
142
|
+
!line.fenced && FOOTER.test(line.text) ? { ...line, text: '' } : line,
|
|
143
|
+
)
|
|
144
|
+
if (!opensOnPostHeading(lines)) return []
|
|
145
|
+
|
|
146
|
+
const found: PostDensityFinding[] = []
|
|
147
|
+
const text = new Map(lines.map((line) => [line.number, line.text]))
|
|
148
|
+
|
|
149
|
+
for (const section of sectionsOf(lines)) {
|
|
150
|
+
const { outside, inside } = foldedApart(section.lines)
|
|
151
|
+
const measured = [
|
|
152
|
+
...splitAt(outside, opensBlock(outside, BOLD_LED), false).flatMap(
|
|
153
|
+
(chunk) => densityFindings(chunk),
|
|
154
|
+
),
|
|
155
|
+
...densityFindings(inside).filter(({ kind }) => LENGTHS.includes(kind)),
|
|
156
|
+
...nestedLengths(section.lines),
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
for (const finding of measured) {
|
|
160
|
+
const { kind } = finding
|
|
161
|
+
if (!isCounted(kind) && kind !== 'heading-depth') continue
|
|
162
|
+
if (
|
|
163
|
+
section.name === 'What to look at' &&
|
|
164
|
+
kind === 'bullet-length' &&
|
|
165
|
+
CHECKBOX.test(text.get(finding.line) ?? '')
|
|
166
|
+
) {
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
found.push({ section: section.name, ...finding, kind })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return found.sort((a, b) => a.line - b.line)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Whether the body's first H2 outside a fence is one of `POST_HEADINGS`. */
|
|
177
|
+
export function isPost(body: string): boolean {
|
|
178
|
+
return opensOnPostHeading(bodyLines(body))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function opensOnPostHeading(lines: readonly BodyLine[]): boolean {
|
|
182
|
+
const opening = lines.find((line) => !line.fenced && H2.test(line.text))
|
|
183
|
+
const heading = opening && H2.exec(opening.text)?.[1]
|
|
184
|
+
return (POST_HEADINGS as readonly string[]).includes(heading ?? '')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Parts a section into the lines outside `<details>` and the lines inside it.
|
|
189
|
+
* The tags stay outside, where each breaks a paragraph run as raw HTML does.
|
|
190
|
+
*/
|
|
191
|
+
function foldedApart(lines: readonly BodyLine[]): {
|
|
192
|
+
outside: BodyLine[]
|
|
193
|
+
inside: BodyLine[]
|
|
194
|
+
} {
|
|
195
|
+
const outside: BodyLine[] = []
|
|
196
|
+
const inside: BodyLine[] = []
|
|
197
|
+
let depth = 0
|
|
198
|
+
|
|
199
|
+
for (const line of lines) {
|
|
200
|
+
const tag = line.fenced ? '' : line.text.trim()
|
|
201
|
+
if (tag.startsWith('<details')) {
|
|
202
|
+
depth++
|
|
203
|
+
outside.push(line)
|
|
204
|
+
} else if (tag.startsWith('</details')) {
|
|
205
|
+
depth = Math.max(0, depth - 1)
|
|
206
|
+
outside.push(line)
|
|
207
|
+
} else if (depth > 0) {
|
|
208
|
+
inside.push(line)
|
|
209
|
+
} else {
|
|
210
|
+
outside.push(line)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { outside, inside }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A `bullet-length` finding for each nested item past the bullet limit, its
|
|
219
|
+
* length taken over the item and the lines indented under it that open no
|
|
220
|
+
* item of their own.
|
|
221
|
+
*/
|
|
222
|
+
function nestedLengths(lines: readonly BodyLine[]): DensityFinding[] {
|
|
223
|
+
const found: DensityFinding[] = []
|
|
224
|
+
|
|
225
|
+
for (const [index, line] of lines.entries()) {
|
|
226
|
+
const match = line.fenced ? null : NESTED_ITEM.exec(line.text)
|
|
227
|
+
if (!match) continue
|
|
228
|
+
|
|
229
|
+
const indent = match[1].length
|
|
230
|
+
const parts = [match[2].trim()]
|
|
231
|
+
for (const next of lines.slice(index + 1)) {
|
|
232
|
+
const lead = /^\s*/.exec(next.text)?.[0].length ?? 0
|
|
233
|
+
if (next.fenced || next.text.trim() === '' || lead <= indent) break
|
|
234
|
+
if (LIST_ITEM.test(next.text)) break
|
|
235
|
+
parts.push(next.text.trim())
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const length = visibleText(parts.join(' ')).length
|
|
239
|
+
if (length > CHECKPOINTS.bullet) {
|
|
240
|
+
found.push({
|
|
241
|
+
kind: 'bullet-length',
|
|
242
|
+
line: line.number,
|
|
243
|
+
value: length,
|
|
244
|
+
limit: CHECKPOINTS.bullet,
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return found
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function sectionsOf(lines: readonly BodyLine[]): Section[] {
|
|
253
|
+
const sections: Section[] = []
|
|
254
|
+
let current: { name: string | null; lines: BodyLine[] } = {
|
|
255
|
+
name: null,
|
|
256
|
+
lines: [],
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const line of lines) {
|
|
260
|
+
const heading = line.fenced ? null : H2.exec(line.text)
|
|
261
|
+
if (heading) {
|
|
262
|
+
sections.push(current)
|
|
263
|
+
current = { name: heading[1], lines: [] }
|
|
264
|
+
} else {
|
|
265
|
+
current.lines.push(line)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
sections.push(current)
|
|
269
|
+
|
|
270
|
+
return sections.filter((section) => section.lines.length > 0)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function subheadings(section: Section): BodyDensityFinding[] {
|
|
274
|
+
return section.lines.flatMap((line) => {
|
|
275
|
+
const match = line.fenced ? null : SUBHEADING.exec(line.text)
|
|
276
|
+
if (!match) return []
|
|
277
|
+
return [
|
|
278
|
+
{
|
|
279
|
+
section: section.name,
|
|
280
|
+
kind: 'subheading' as const,
|
|
281
|
+
line: line.number,
|
|
282
|
+
value: match[1].length,
|
|
283
|
+
limit: 2,
|
|
284
|
+
},
|
|
285
|
+
]
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Cuts a section into chunks at each line the test picks, never inside a
|
|
291
|
+
* fence. `drop` leaves the picked line out of both chunks, as a bold label
|
|
292
|
+
* that only names the group below it.
|
|
293
|
+
*/
|
|
294
|
+
function splitAt(
|
|
295
|
+
lines: readonly BodyLine[],
|
|
296
|
+
breaks: (line: BodyLine, index: number) => boolean,
|
|
297
|
+
drop: boolean,
|
|
298
|
+
): BodyLine[][] {
|
|
299
|
+
const chunks: BodyLine[][] = [[]]
|
|
300
|
+
|
|
301
|
+
for (const [index, line] of lines.entries()) {
|
|
302
|
+
if (!line.fenced && breaks(line, index)) {
|
|
303
|
+
chunks.push(drop ? [] : [line])
|
|
304
|
+
} else {
|
|
305
|
+
chunks[chunks.length - 1].push(line)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return chunks
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Picks a line matching the pattern that opens a block, after a blank line or
|
|
314
|
+
* at the section start. A line directly under a bullet or a paragraph renders
|
|
315
|
+
* as its lazy continuation, so it breaks nothing.
|
|
316
|
+
*/
|
|
317
|
+
function opensBlock(
|
|
318
|
+
lines: readonly BodyLine[],
|
|
319
|
+
pattern: RegExp,
|
|
320
|
+
): (line: BodyLine, index: number) => boolean {
|
|
321
|
+
return (line, index) =>
|
|
322
|
+
pattern.test(line.text) &&
|
|
323
|
+
(index === 0 || lines[index - 1].text.trim() === '')
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The line of the first block of whichever form appears second, when a
|
|
328
|
+
* section carries both bullets and paragraphs, else undefined.
|
|
329
|
+
*
|
|
330
|
+
* A column-zero line directly under an item with no blank between is a lazy
|
|
331
|
+
* continuation of that item, not a paragraph.
|
|
332
|
+
*/
|
|
333
|
+
function mixedForm(lines: readonly BodyLine[]): number | undefined {
|
|
334
|
+
let firstItem: number | undefined
|
|
335
|
+
let firstParagraph: number | undefined
|
|
336
|
+
let previous: 'blank' | 'item' | 'paragraph' | 'other' = 'blank'
|
|
337
|
+
|
|
338
|
+
for (const line of lines) {
|
|
339
|
+
const text = line.text
|
|
340
|
+
if (line.fenced) {
|
|
341
|
+
previous = 'other'
|
|
342
|
+
continue
|
|
343
|
+
}
|
|
344
|
+
if (text.trim() === '') {
|
|
345
|
+
previous = 'blank'
|
|
346
|
+
continue
|
|
347
|
+
}
|
|
348
|
+
if (/^\s/.test(text)) continue
|
|
349
|
+
|
|
350
|
+
if (LIST_ITEM.test(text)) {
|
|
351
|
+
firstItem ??= line.number
|
|
352
|
+
previous = 'item'
|
|
353
|
+
} else if (/^(#|\||>|<)/.test(text)) {
|
|
354
|
+
previous = 'other'
|
|
355
|
+
} else if (previous === 'blank' || previous === 'other') {
|
|
356
|
+
firstParagraph ??= line.number
|
|
357
|
+
previous = 'paragraph'
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (firstItem === undefined || firstParagraph === undefined) return undefined
|
|
362
|
+
return Math.max(firstItem, firstParagraph)
|
|
363
|
+
}
|
package/src/pr/evidence.ts
CHANGED
|
@@ -5,7 +5,7 @@ const EVIDENCE_SEGMENT = 'evidence'
|
|
|
5
5
|
const EVIDENCE_OPEN_LIMIT = 6
|
|
6
6
|
|
|
7
7
|
/** Length of the commit names the visible line shows, the width `git` abbreviates to. */
|
|
8
|
-
const SHORT_SHA_LENGTH = 7
|
|
8
|
+
export const SHORT_SHA_LENGTH = 7
|
|
9
9
|
|
|
10
10
|
/** Prefix of the trailing marker this module writes and reads back. */
|
|
11
11
|
const MARKER_PREFIX = '<!-- pr-evidence:'
|
package/src/pr/rest.ts
CHANGED
|
@@ -42,6 +42,7 @@ export interface PullIdentity {
|
|
|
42
42
|
readonly head?: string
|
|
43
43
|
readonly base?: string
|
|
44
44
|
readonly mergeState?: string
|
|
45
|
+
readonly body?: string
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
export type BranchPull =
|
|
@@ -70,6 +71,7 @@ export function identityOf(row: RestPull): PullIdentity {
|
|
|
70
71
|
...(row.head?.sha !== undefined && { head: row.head.sha }),
|
|
71
72
|
...(row.base?.ref !== undefined && { base: row.base.ref }),
|
|
72
73
|
...(mergeState !== undefined && { mergeState }),
|
|
74
|
+
...(typeof row.body === 'string' && { body: row.body }),
|
|
73
75
|
}
|
|
74
76
|
}
|
|
75
77
|
|
package/src/pr/tick.ts
CHANGED
|
@@ -3,9 +3,98 @@ import {
|
|
|
3
3
|
CHECKLIST_START,
|
|
4
4
|
parseBoxLine,
|
|
5
5
|
readChecklistBoxes,
|
|
6
|
+
SHORT_SHA_LENGTH,
|
|
6
7
|
writeBoxLine,
|
|
7
8
|
} from '@/pr/evidence'
|
|
8
9
|
|
|
10
|
+
const UI_PASS_MARKER = '<!-- pr-ui-pass -->'
|
|
11
|
+
const TESTING_HEADING = /^## Testing\s*$/
|
|
12
|
+
const SECTION_HEADING = /^## /
|
|
13
|
+
const REVIEWER_HEADING = /^## For the reviewer\s*$/
|
|
14
|
+
const UI_PASS_HEAD = /at `([0-9a-f]+)`/
|
|
15
|
+
|
|
16
|
+
/** What the UI pass line reports, either a pass or a reset to a head nobody has driven. */
|
|
17
|
+
export type UiPassSummary =
|
|
18
|
+
| {
|
|
19
|
+
readonly kind: 'passed'
|
|
20
|
+
/** Driven boxes ticked, taste boxes excluded. */
|
|
21
|
+
readonly passed: number
|
|
22
|
+
/** Boxes a driver can run, taste boxes excluded. */
|
|
23
|
+
readonly total: number
|
|
24
|
+
readonly taste: number
|
|
25
|
+
readonly head: string
|
|
26
|
+
}
|
|
27
|
+
| { readonly kind: 'reset'; readonly head: string }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The one `## Testing` line a UI pass owns. It is ticked only when every box
|
|
31
|
+
* a driver can run passed, so a failed box never reads as done, and taste
|
|
32
|
+
* boxes are counted apart so a full pass never reads as one box failed.
|
|
33
|
+
*/
|
|
34
|
+
export function uiPassLine(summary: UiPassSummary): string {
|
|
35
|
+
const at = `\`${summary.head.slice(0, SHORT_SHA_LENGTH)}\``
|
|
36
|
+
if (summary.kind === 'reset') {
|
|
37
|
+
return `- [ ] UI pass, not run at ${at} ${UI_PASS_MARKER}`
|
|
38
|
+
}
|
|
39
|
+
const taste =
|
|
40
|
+
summary.taste === 0
|
|
41
|
+
? ''
|
|
42
|
+
: `${summary.taste} taste ${summary.taste === 1 ? 'box' : 'boxes'} left to the operator`
|
|
43
|
+
if (summary.total === 0) {
|
|
44
|
+
return `- [ ] UI pass, no box a driver can run, ${taste} at ${at} ${UI_PASS_MARKER}`
|
|
45
|
+
}
|
|
46
|
+
const box = summary.passed === summary.total ? 'x' : ' '
|
|
47
|
+
const tail = taste === '' ? '' : `, ${taste}`
|
|
48
|
+
return `- [${box}] UI pass, ${summary.passed} of ${summary.total} boxes passed at ${at}${tail} ${UI_PASS_MARKER}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The short sha the marked line names, absent when the body carries no such line. */
|
|
52
|
+
export function readUiPassHead(body: string): string | undefined {
|
|
53
|
+
const line = body.split('\n').find((row) => row.includes(UI_PASS_MARKER))
|
|
54
|
+
return line === undefined ? undefined : UI_PASS_HEAD.exec(line)?.[1]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Puts the UI pass line in a pull request body and touches nothing else.
|
|
59
|
+
* The marked line is replaced where it stands, else appended to the end of
|
|
60
|
+
* `## Testing`, which is created ahead of `## For the reviewer` when absent.
|
|
61
|
+
*/
|
|
62
|
+
export function writeUiPassLine(body: string, summary: UiPassSummary): string {
|
|
63
|
+
const line = uiPassLine(summary)
|
|
64
|
+
const lines = body.split('\n')
|
|
65
|
+
|
|
66
|
+
const marked = lines.findIndex((row) => row.includes(UI_PASS_MARKER))
|
|
67
|
+
if (marked !== -1) {
|
|
68
|
+
lines[marked] = line
|
|
69
|
+
return lines.join('\n')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const heading = lines.findIndex((row) => TESTING_HEADING.test(row))
|
|
73
|
+
if (heading === -1) {
|
|
74
|
+
const reviewer = lines.findIndex((row) => REVIEWER_HEADING.test(row))
|
|
75
|
+
if (reviewer === -1) {
|
|
76
|
+
return `${body.trimEnd()}\n\n## Testing\n\n${line}\n`
|
|
77
|
+
}
|
|
78
|
+
lines.splice(reviewer, 0, '## Testing', '', line, '')
|
|
79
|
+
return lines.join('\n')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const next = lines.findIndex(
|
|
83
|
+
(row, index) => index > heading && SECTION_HEADING.test(row),
|
|
84
|
+
)
|
|
85
|
+
let last = next === -1 ? lines.length - 1 : next - 1
|
|
86
|
+
while (last > heading && lines[last]?.trim() === '') last -= 1
|
|
87
|
+
|
|
88
|
+
if (last > heading) {
|
|
89
|
+
lines.splice(last + 1, 0, line)
|
|
90
|
+
} else if (lines[heading + 1] === '') {
|
|
91
|
+
lines.splice(heading + 2, 0, line)
|
|
92
|
+
} else {
|
|
93
|
+
lines.splice(heading + 1, 0, '', line)
|
|
94
|
+
}
|
|
95
|
+
return lines.join('\n')
|
|
96
|
+
}
|
|
97
|
+
|
|
9
98
|
export type TickRefusal = 'no-checklist' | 'no-box' | 'taste-box'
|
|
10
99
|
|
|
11
100
|
export type TickResult =
|
package/src/sessions/live.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readlinkSync } from 'node:fs'
|
|
2
|
+
import { createConnection } from 'node:net'
|
|
2
3
|
import type { SessionRecord } from '@/sessions/registry'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -35,6 +36,12 @@ export interface LivenessProbes {
|
|
|
35
36
|
readonly ownDomain?: () => string | null
|
|
36
37
|
/** Whether the messaging socket a foreign record names still exists. */
|
|
37
38
|
readonly socketExists?: (path: string) => boolean
|
|
39
|
+
/**
|
|
40
|
+
* Whether something accepts a connection on the socket within the timeout.
|
|
41
|
+
* Async, so `liveness()` never calls it and a caller with no use for it, such
|
|
42
|
+
* as the gate's lock, states nothing.
|
|
43
|
+
*/
|
|
44
|
+
readonly listens?: (path: string, timeoutMs: number) => Promise<boolean>
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
/**
|
|
@@ -74,11 +81,30 @@ function ownDomain(): string | null {
|
|
|
74
81
|
}
|
|
75
82
|
}
|
|
76
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Connects and closes without writing, so the peer sees no bytes. The socket is
|
|
86
|
+
* destroyed on connect rather than waited on, since a listener that accepts and
|
|
87
|
+
* never closes would otherwise hold the probe open past its timeout.
|
|
88
|
+
*/
|
|
89
|
+
function listens(path: string, timeoutMs: number): Promise<boolean> {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
const socket = createConnection(path)
|
|
92
|
+
const settle = (accepted: boolean): void => {
|
|
93
|
+
socket.destroy()
|
|
94
|
+
resolve(accepted)
|
|
95
|
+
}
|
|
96
|
+
socket.setTimeout(timeoutMs, () => settle(false))
|
|
97
|
+
socket.once('connect', () => settle(true))
|
|
98
|
+
socket.once('error', () => settle(false))
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
77
102
|
export const SYSTEM_PROBES: LivenessProbes = {
|
|
78
103
|
procStartOf,
|
|
79
104
|
responds,
|
|
80
105
|
ownDomain,
|
|
81
106
|
socketExists: existsSync,
|
|
107
|
+
listens,
|
|
82
108
|
}
|
|
83
109
|
|
|
84
110
|
/**
|
package/src/sessions/resolve.ts
CHANGED
|
@@ -23,11 +23,18 @@ export interface ResolvedSession {
|
|
|
23
23
|
readonly kind: string
|
|
24
24
|
readonly status: string
|
|
25
25
|
/**
|
|
26
|
-
* What decided the row was live. `
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* What decided the row was live. `pid` is a start time matched or a pid that
|
|
27
|
+
* answered, `socket` means the record belongs to another pid namespace and
|
|
28
|
+
* only its messaging socket file existed, so the row can be a crashed
|
|
29
|
+
* session's leftover, and `listener` means the messaging socket accepted a
|
|
30
|
+
* connection, which confirms the row wherever the start time cannot be read.
|
|
29
31
|
*/
|
|
30
|
-
readonly via: 'pid' | 'socket'
|
|
32
|
+
readonly via: 'pid' | 'socket' | 'listener'
|
|
33
|
+
/**
|
|
34
|
+
* How this row alone was decided, so a caller scoping the roster derives its
|
|
35
|
+
* confidence over the rows it keeps rather than over the whole machine.
|
|
36
|
+
*/
|
|
37
|
+
readonly confidence: Confidence
|
|
31
38
|
readonly startedAt: string | null
|
|
32
39
|
/**
|
|
33
40
|
* The stamp the record carries beside `status`, kept alongside the dwell it
|
|
@@ -80,6 +87,22 @@ export type SessionReport =
|
|
|
80
87
|
readonly sessions: readonly ResolvedSession[]
|
|
81
88
|
}
|
|
82
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The weakest confidence among the rows, so one read tells a caller whether
|
|
92
|
+
* every row it holds is an identity. A caller scoping the roster calls this on
|
|
93
|
+
* the rows it keeps, since one unrelated row it dropped must not weaken it.
|
|
94
|
+
*/
|
|
95
|
+
export function confidenceOf(
|
|
96
|
+
sessions: readonly Pick<ResolvedSession, 'confidence'>[],
|
|
97
|
+
): Confidence {
|
|
98
|
+
return sessions.every((session) => session.confidence === 'confirmed')
|
|
99
|
+
? 'confirmed'
|
|
100
|
+
: 'unverified'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A live socket here accepted in 2 ms, and every probe runs in parallel. */
|
|
104
|
+
const LISTEN_TIMEOUT_MS = 250
|
|
105
|
+
|
|
83
106
|
export interface ResolveOptions {
|
|
84
107
|
readonly dir?: string
|
|
85
108
|
readonly probes?: LivenessProbes
|
|
@@ -181,6 +204,7 @@ function present(
|
|
|
181
204
|
located: Located,
|
|
182
205
|
now: number,
|
|
183
206
|
via: ResolvedSession['via'],
|
|
207
|
+
confidence: Confidence,
|
|
184
208
|
): ResolvedSession {
|
|
185
209
|
return {
|
|
186
210
|
name: record.name,
|
|
@@ -190,6 +214,7 @@ function present(
|
|
|
190
214
|
kind: record.kind ?? 'unknown',
|
|
191
215
|
status: record.status ?? 'unknown',
|
|
192
216
|
via,
|
|
217
|
+
confidence,
|
|
193
218
|
startedAt:
|
|
194
219
|
record.startedAt === undefined
|
|
195
220
|
? null
|
|
@@ -228,8 +253,11 @@ export async function resolveSessions(
|
|
|
228
253
|
|
|
229
254
|
if (registry.kind === 'absent') return { kind: 'absent', dir: registry.dir }
|
|
230
255
|
|
|
231
|
-
const live: {
|
|
232
|
-
|
|
256
|
+
const live: {
|
|
257
|
+
record: SessionRecord
|
|
258
|
+
via: ResolvedSession['via']
|
|
259
|
+
confidence: Confidence
|
|
260
|
+
}[] = []
|
|
233
261
|
let foreignDead = 0
|
|
234
262
|
|
|
235
263
|
for (const record of registry.records) {
|
|
@@ -238,20 +266,43 @@ export async function resolveSessions(
|
|
|
238
266
|
if (state.foreign) foreignDead += 1
|
|
239
267
|
continue
|
|
240
268
|
}
|
|
241
|
-
|
|
242
|
-
|
|
269
|
+
live.push({
|
|
270
|
+
record,
|
|
271
|
+
via: state.foreign ? 'socket' : 'pid',
|
|
272
|
+
confidence: state.confidence,
|
|
273
|
+
})
|
|
243
274
|
}
|
|
244
275
|
|
|
276
|
+
// Only a row the start time left unverified is probed, so a machine that reads
|
|
277
|
+
// /proc pays nothing. An accept proves the session is running where a pid
|
|
278
|
+
// answering or a socket file existing does not.
|
|
279
|
+
const upgraded = await Promise.all(
|
|
280
|
+
live.map(async (row) => {
|
|
281
|
+
const socket = row.record.messagingSocketPath
|
|
282
|
+
if (
|
|
283
|
+
row.confidence === 'confirmed' ||
|
|
284
|
+
socket === undefined ||
|
|
285
|
+
probes.listens === undefined
|
|
286
|
+
) {
|
|
287
|
+
return row
|
|
288
|
+
}
|
|
289
|
+
const accepted = await probes.listens(socket, LISTEN_TIMEOUT_MS)
|
|
290
|
+
return accepted
|
|
291
|
+
? { ...row, via: 'listener' as const, confidence: 'confirmed' as const }
|
|
292
|
+
: row
|
|
293
|
+
}),
|
|
294
|
+
)
|
|
295
|
+
|
|
245
296
|
const sessions = await Promise.all(
|
|
246
|
-
|
|
247
|
-
present(record, await find(record.cwd), now, via),
|
|
297
|
+
upgraded.map(async ({ record, via, confidence }) =>
|
|
298
|
+
present(record, await find(record.cwd), now, via, confidence),
|
|
248
299
|
),
|
|
249
300
|
)
|
|
250
301
|
|
|
251
302
|
return {
|
|
252
303
|
kind: 'resolved',
|
|
253
304
|
dir: registry.dir,
|
|
254
|
-
confidence,
|
|
305
|
+
confidence: confidenceOf(sessions),
|
|
255
306
|
foreignDead,
|
|
256
307
|
sessions,
|
|
257
308
|
}
|