@erclx/canon 5.78.0 → 5.79.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/CHANGELOG.md +23 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +12 -0
- package/claude/skills/app-drive/SKILL.md +44 -8
- package/claude/skills/app-drive/references/config.md +18 -8
- package/claude/skills/app-drive/references/drive-record.md +4 -0
- package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
- package/claude/skills/auto-ship/SKILL.md +5 -4
- package/claude/skills/auto-ship/references/draft-mark.md +33 -15
- package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/REQUIREMENT.md +2 -0
- package/claude/skills/git-pr/SKILL.md +2 -0
- package/claude/skills/git-pr/references/final-command.md +34 -2
- package/claude/skills/git-pr/references/pr.md +1 -1
- package/claude/skills/git-ship/REQUIREMENT.md +2 -2
- package/claude/skills/git-ship/SKILL.md +16 -11
- package/claude/skills/git-ship/references/ci-watch.md +28 -4
- package/claude/skills/markdown-craft/references/markdown.md +4 -4
- package/claude/skills/plan-feature/references/plan.md +2 -0
- package/claude/skills/review-address/SKILL.md +2 -1
- package/claude/skills/review-pr/references/key-changes.md +2 -1
- package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
- package/claude/skills/role-orchestrator/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
- package/claude/skills/role-worker/SKILL.md +1 -1
- package/claude/skills/task-board/SKILL.md +1 -0
- package/claude/skills/task-board/references/tasks.md +1 -0
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/parallel-features.md +2 -1
- package/package.json +1 -1
- package/src/commands/pr.ts +138 -15
- package/src/commands/sessions.ts +88 -6
- package/src/commands/tasks.ts +41 -2
- package/src/git/files.ts +5 -2
- package/src/indexes/frontmatter.ts +27 -6
- package/src/indexes/render.ts +21 -3
- package/src/markdown/colons.ts +14 -4
- package/src/markdown/heading-case.ts +9 -2
- package/src/pr/bijection.ts +8 -0
- package/src/pr/evidence.ts +38 -9
- package/src/pr/health.ts +63 -15
- package/src/pr/links.ts +160 -0
- package/src/pr/local.ts +17 -4
- package/src/pr/paths.ts +20 -2
- package/src/sessions/stop.ts +145 -0
- package/src/sessions/transfer/bundle.ts +1 -1
- package/src/take/encode.ts +16 -42
- package/src/take/frame-clock.ts +46 -0
- package/src/take/screencast.ts +114 -11
- package/src/tasks/holders.ts +198 -0
- package/src/tasks/validate.ts +148 -0
- /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
package/src/commands/sessions.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type SessionReport,
|
|
12
12
|
selfOf,
|
|
13
13
|
} from '@/sessions/resolve'
|
|
14
|
+
import { readStop, type StopReading } from '@/sessions/stop'
|
|
14
15
|
import {
|
|
15
16
|
type Behind,
|
|
16
17
|
exportSession,
|
|
@@ -32,8 +33,11 @@ interface ListCommandOptions {
|
|
|
32
33
|
readonly branch?: string
|
|
33
34
|
readonly repository?: string
|
|
34
35
|
readonly self?: boolean
|
|
36
|
+
readonly stopped?: boolean
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
type ListedSession = ResolvedSession & StopReading
|
|
40
|
+
|
|
37
41
|
const REASONS: Record<string, string> = {
|
|
38
42
|
'not-a-repository': 'working outside any git repository',
|
|
39
43
|
'detached-head': 'detached HEAD, so the worktree holds no branch name',
|
|
@@ -67,6 +71,7 @@ export function register(program: Command): void {
|
|
|
67
71
|
'--self',
|
|
68
72
|
"Report the caller's own row, and refuse where the roster holds none",
|
|
69
73
|
)
|
|
74
|
+
.option('--stopped', 'Report only the sessions a usage limit stopped')
|
|
70
75
|
.addHelpText(
|
|
71
76
|
'after',
|
|
72
77
|
[
|
|
@@ -161,12 +166,30 @@ export function register(program: Command): void {
|
|
|
161
166
|
'framed listing renders the same dwell beside the status, at the',
|
|
162
167
|
'coarsest unit that keeps it a whole number.',
|
|
163
168
|
'',
|
|
169
|
+
'Each row also carries "stopped" and "stopResets", read from the tail of',
|
|
170
|
+
'the session transcript. "stopped" is "usage-limit" when the last',
|
|
171
|
+
'assistant entry is a limit stop, passing over a synthetic entry with no',
|
|
172
|
+
'error, such as the one a client restart writes. "stopResets" holds the',
|
|
173
|
+
'text the client wrote after "resets", or null when it wrote none.',
|
|
174
|
+
'"stopped" is "unknown" when the transcript could not be read or a',
|
|
175
|
+
'limit-worded entry has another shape, so the row may be stopped, and',
|
|
176
|
+
'null otherwise. A "busy" row reads',
|
|
177
|
+
'null without opening its transcript, since a turn is running.',
|
|
178
|
+
'',
|
|
179
|
+
'--stopped keeps the "usage-limit" rows after --branch and --self apply.',
|
|
180
|
+
'It drops "unknown" rows, and "stopsUnread" counts them before it does,',
|
|
181
|
+
'so a count above zero means a row here may be stopped and is not listed.',
|
|
182
|
+
'"foreignDead" above zero leaves the same doubt about a session this',
|
|
183
|
+
'reader could not check, and an empty result says so on stderr.',
|
|
184
|
+
'An empty result exits 0. "claimed" still reads the whole roster.',
|
|
185
|
+
'',
|
|
164
186
|
'Examples:',
|
|
165
187
|
' canon sessions list',
|
|
166
188
|
' canon sessions list --json',
|
|
167
189
|
' canon sessions list --branch feat/parser --json',
|
|
168
190
|
' canon sessions list --branch chore/agents --repository ../caret --json',
|
|
169
191
|
' canon sessions list --self --json',
|
|
192
|
+
' canon sessions list --stopped --json',
|
|
170
193
|
'',
|
|
171
194
|
].join('\n'),
|
|
172
195
|
)
|
|
@@ -477,15 +500,37 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
477
500
|
? await checkClaim(opts.branch, { cwd: at, resolve: async () => report })
|
|
478
501
|
: null
|
|
479
502
|
|
|
503
|
+
const read: readonly ListedSession[] = shown.map((session) => ({
|
|
504
|
+
...session,
|
|
505
|
+
...readStop(session),
|
|
506
|
+
}))
|
|
507
|
+
const stopsUnread = read.filter(
|
|
508
|
+
(session) => session.stopped === 'unknown',
|
|
509
|
+
).length
|
|
510
|
+
const listed = opts.stopped
|
|
511
|
+
? read.filter((session) => session.stopped === 'usage-limit')
|
|
512
|
+
: read
|
|
513
|
+
|
|
480
514
|
intro('canon sessions list')
|
|
481
|
-
reportConfidence(
|
|
515
|
+
reportConfidence(listed, report)
|
|
482
516
|
reportSessions(
|
|
483
|
-
|
|
517
|
+
listed,
|
|
484
518
|
opts.branch,
|
|
485
519
|
repository,
|
|
486
520
|
own !== null,
|
|
487
521
|
report.foreignDead,
|
|
522
|
+
opts.stopped ? stopsUnread : null,
|
|
488
523
|
)
|
|
524
|
+
if (opts.stopped && stopsUnread > 0) {
|
|
525
|
+
logWarn(
|
|
526
|
+
`${plural(stopsUnread, 'row')} could not be read and may be stopped, so this list can be short.`,
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
if (opts.stopped && own === null && report.foreignDead > 0) {
|
|
530
|
+
logWarn(
|
|
531
|
+
`${report.foreignDead} records from another pid namespace have no answering socket, so a stopped session may be missing from this list.`,
|
|
532
|
+
)
|
|
533
|
+
}
|
|
489
534
|
if (claim) reportClaim(claim)
|
|
490
535
|
outro()
|
|
491
536
|
|
|
@@ -494,8 +539,9 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
494
539
|
`${JSON.stringify({
|
|
495
540
|
dir: report.dir,
|
|
496
541
|
// Over the rows shown, so an unrelated row --branch dropped cannot weaken it.
|
|
497
|
-
confidence: confidenceOf(
|
|
542
|
+
confidence: confidenceOf(listed),
|
|
498
543
|
foreignDead: report.foreignDead,
|
|
544
|
+
stopsUnread,
|
|
499
545
|
branch: opts.branch ?? null,
|
|
500
546
|
repository,
|
|
501
547
|
worktree: claim?.worktree ?? null,
|
|
@@ -503,7 +549,7 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
503
549
|
claimed: claim?.claimed ?? null,
|
|
504
550
|
sessionsReadable: claim?.sessionsReadable ?? null,
|
|
505
551
|
refsReadable: claim?.refsReadable ?? null,
|
|
506
|
-
sessions:
|
|
552
|
+
sessions: listed,
|
|
507
553
|
})}\n`,
|
|
508
554
|
)
|
|
509
555
|
}
|
|
@@ -578,11 +624,12 @@ function reportConfidence(
|
|
|
578
624
|
}
|
|
579
625
|
|
|
580
626
|
function reportSessions(
|
|
581
|
-
sessions: readonly
|
|
627
|
+
sessions: readonly ListedSession[],
|
|
582
628
|
branch: string | undefined,
|
|
583
629
|
repository: string | null,
|
|
584
630
|
scoped: boolean,
|
|
585
631
|
foreignDead: number,
|
|
632
|
+
stopsUnread: number | null,
|
|
586
633
|
): void {
|
|
587
634
|
logStep('Sessions')
|
|
588
635
|
|
|
@@ -596,6 +643,11 @@ function reportSessions(
|
|
|
596
643
|
// was narrowed to one row before the branch filter ran. Reporting the wider
|
|
597
644
|
// answer there would claim a reading this run never took.
|
|
598
645
|
if (sessions.length === 0) {
|
|
646
|
+
if (stopsUnread !== null) {
|
|
647
|
+
logInfo(noneStopped(branch, scoped, foreignDead, stopsUnread))
|
|
648
|
+
return
|
|
649
|
+
}
|
|
650
|
+
|
|
599
651
|
if (branch) {
|
|
600
652
|
logInfo(
|
|
601
653
|
scoped
|
|
@@ -630,12 +682,42 @@ function reportSessions(
|
|
|
630
682
|
`unresolved: ${REASONS[session.unresolved ?? ''] ?? 'unknown'}`
|
|
631
683
|
const dwell = formatDwell(session.statusDwellMs)
|
|
632
684
|
const status = dwell ? `${session.status} ${dwell}` : session.status
|
|
633
|
-
return
|
|
685
|
+
return [
|
|
686
|
+
`${session.name} ${status} ${held}`,
|
|
687
|
+
` ${session.cwd}`,
|
|
688
|
+
...stopLine(session),
|
|
689
|
+
].join('\n')
|
|
634
690
|
})
|
|
635
691
|
.join('\n'),
|
|
636
692
|
)
|
|
637
693
|
}
|
|
638
694
|
|
|
695
|
+
/**
|
|
696
|
+
* Words an empty `--stopped` result by what was read, since "no session is
|
|
697
|
+
* stopped" is a claim about the machine that a row this run could not read, or
|
|
698
|
+
* a record it could not check, leaves open.
|
|
699
|
+
*/
|
|
700
|
+
function noneStopped(
|
|
701
|
+
branch: string | undefined,
|
|
702
|
+
scoped: boolean,
|
|
703
|
+
foreignDead: number,
|
|
704
|
+
stopsUnread: number,
|
|
705
|
+
): string {
|
|
706
|
+
if (stopsUnread > 0 || (!scoped && foreignDead > 0)) {
|
|
707
|
+
return 'No session this reader could check is stopped.'
|
|
708
|
+
}
|
|
709
|
+
if (scoped) return 'This session is not stopped.'
|
|
710
|
+
return branch
|
|
711
|
+
? `No session holding ${branch} is stopped.`
|
|
712
|
+
: 'No session is stopped.'
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function stopLine(session: StopReading): string[] {
|
|
716
|
+
if (session.stopped === null) return []
|
|
717
|
+
const resets = session.stopResets ? `, resets ${session.stopResets}` : ''
|
|
718
|
+
return [` stopped: ${session.stopped}${resets}`]
|
|
719
|
+
}
|
|
720
|
+
|
|
639
721
|
/**
|
|
640
722
|
* Renders the dwell at the coarsest unit that keeps it a whole number, since a
|
|
641
723
|
* reader scanning a roster wants an age at a glance rather than a millisecond
|
package/src/commands/tasks.ts
CHANGED
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
type ValidateOutcome,
|
|
73
73
|
validateBoard,
|
|
74
74
|
} from '@/tasks/validate'
|
|
75
|
+
import { liveReaders } from '@/tasks/holders'
|
|
75
76
|
import {
|
|
76
77
|
intro,
|
|
77
78
|
logAdd,
|
|
@@ -110,6 +111,7 @@ interface DeclineCommandOptions {
|
|
|
110
111
|
interface ValidateCommandOptions {
|
|
111
112
|
readonly json?: boolean
|
|
112
113
|
readonly root?: string
|
|
114
|
+
readonly live?: boolean
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
interface CitationsCommandOptions {
|
|
@@ -323,6 +325,10 @@ export function register(program: Command): void {
|
|
|
323
325
|
.helpOption('-h, --help', 'Show this help message')
|
|
324
326
|
.option('--json', 'Add a machine-readable record on stdout')
|
|
325
327
|
.option('--root <path>', 'Board root, defaulting to the main worktree')
|
|
328
|
+
.option(
|
|
329
|
+
'--live',
|
|
330
|
+
'Also read the session roster and open pull requests for held rows',
|
|
331
|
+
)
|
|
326
332
|
.addHelpText(
|
|
327
333
|
'after',
|
|
328
334
|
[
|
|
@@ -338,6 +344,12 @@ export function register(program: Command): void {
|
|
|
338
344
|
` no Waiting on cell renders past ${WAITING_LIMIT} characters`,
|
|
339
345
|
' a blocker citing a bare vNNN.N label settles like a linked one',
|
|
340
346
|
' a file two Run now rows share is listed, never failed',
|
|
347
|
+
' with --live, no Up next or Needs a plan row is held by a live session',
|
|
348
|
+
" or an open pull request on its plan's branch",
|
|
349
|
+
'',
|
|
350
|
+
'The `live` field states each --live source as read, unread, or not-asked.',
|
|
351
|
+
'A source that fails reads as unread and moves no exit code, so read that',
|
|
352
|
+
'field rather than taking exit 0 as clean. A run without --live reads neither.',
|
|
341
353
|
'',
|
|
342
354
|
'Refusals, read off `reason`:',
|
|
343
355
|
' no-board the repository holds no `.canon/tasks/` board',
|
|
@@ -356,6 +368,7 @@ export function register(program: Command): void {
|
|
|
356
368
|
'Examples:',
|
|
357
369
|
' canon tasks validate',
|
|
358
370
|
' canon tasks validate --json',
|
|
371
|
+
' canon tasks validate --live --json',
|
|
359
372
|
'',
|
|
360
373
|
].join('\n'),
|
|
361
374
|
)
|
|
@@ -1911,7 +1924,10 @@ function reportNextLabel(
|
|
|
1911
1924
|
|
|
1912
1925
|
async function runValidate(opts: ValidateCommandOptions): Promise<number> {
|
|
1913
1926
|
const root = opts.root ?? (await mainWorktreeRoot())
|
|
1914
|
-
const outcome = await validateBoard(
|
|
1927
|
+
const outcome = await validateBoard(
|
|
1928
|
+
root,
|
|
1929
|
+
opts.live ? { live: liveReaders(root) } : {},
|
|
1930
|
+
)
|
|
1915
1931
|
|
|
1916
1932
|
return reportValidation(outcome, opts.json ?? false, root)
|
|
1917
1933
|
}
|
|
@@ -2390,8 +2406,30 @@ function reportValidation(
|
|
|
2390
2406
|
} else {
|
|
2391
2407
|
for (const task of outcome.unplaced) logWarn(describeUnplaced(task))
|
|
2392
2408
|
}
|
|
2393
|
-
|
|
2409
|
+
|
|
2410
|
+
if (outcome.live.sessions !== 'not-asked') {
|
|
2411
|
+
logStep('Held rows')
|
|
2412
|
+
if (
|
|
2413
|
+
outcome.live.sessions === 'read' &&
|
|
2414
|
+
outcome.live.pulls === 'read' &&
|
|
2415
|
+
!outcome.findings.some((finding) => finding.kind === 'row-held')
|
|
2416
|
+
) {
|
|
2417
|
+
logInfo('no row outside run now is held by a session or a pull request')
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
// Unread is a warning in both modes, since diagnostics reach stderr with --json too.
|
|
2423
|
+
if (outcome.live.sessions === 'unread') {
|
|
2424
|
+
logWarn('the session roster could not be read, so no session was checked')
|
|
2394
2425
|
}
|
|
2426
|
+
if (outcome.live.pulls === 'unread') {
|
|
2427
|
+
logWarn(
|
|
2428
|
+
'pull requests could not be read, so a hold may be missed or already merged',
|
|
2429
|
+
)
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
if (!emitJson) outro()
|
|
2395
2433
|
|
|
2396
2434
|
if (emitJson) {
|
|
2397
2435
|
process.stdout.write(
|
|
@@ -2408,6 +2446,7 @@ function reportValidation(
|
|
|
2408
2446
|
shared: outcome.shared,
|
|
2409
2447
|
wide: outcome.wide,
|
|
2410
2448
|
unplaced: outcome.unplaced,
|
|
2449
|
+
live: outcome.live,
|
|
2411
2450
|
})}\n`,
|
|
2412
2451
|
)
|
|
2413
2452
|
}
|
package/src/git/files.ts
CHANGED
|
@@ -56,15 +56,18 @@ export async function listChangedFiles(
|
|
|
56
56
|
root: string,
|
|
57
57
|
base: string,
|
|
58
58
|
): Promise<string[] | undefined> {
|
|
59
|
-
|
|
59
|
+
// NUL-separated, since newline output C-quotes a path holding a quote or a
|
|
60
|
+
// non-ASCII character and the quoted spelling names no file.
|
|
61
|
+
const changed = await git(root, ['diff', '--name-only', '-z', base])
|
|
60
62
|
const untracked = await git(root, [
|
|
61
63
|
'ls-files',
|
|
64
|
+
'-z',
|
|
62
65
|
'--others',
|
|
63
66
|
'--exclude-standard',
|
|
64
67
|
])
|
|
65
68
|
if (changed === undefined || untracked === undefined) return undefined
|
|
66
69
|
|
|
67
|
-
const paths = [...changed.split('\
|
|
70
|
+
const paths = [...changed.split('\0'), ...untracked.split('\0')]
|
|
68
71
|
return [...new Set(paths.filter(Boolean))].sort()
|
|
69
72
|
}
|
|
70
73
|
|
|
@@ -17,22 +17,43 @@ export interface Frontmatter {
|
|
|
17
17
|
* missing block instead of ending the caller's whole run.
|
|
18
18
|
*/
|
|
19
19
|
export function parseFrontmatter(source: string): Frontmatter | undefined {
|
|
20
|
+
const reading = readFrontmatterSource(source)
|
|
21
|
+
return reading.kind === 'parsed' ? reading.frontmatter : undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type FrontmatterReading =
|
|
25
|
+
| { readonly kind: 'absent' }
|
|
26
|
+
| { readonly kind: 'invalid'; readonly message: string }
|
|
27
|
+
| { readonly kind: 'parsed'; readonly frontmatter: Frontmatter }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Tells a source with no block from one whose block failed to parse, which
|
|
31
|
+
* `parseFrontmatter` folds into one undefined. A block that parses to a list
|
|
32
|
+
* or a scalar reads as invalid, since no field can be read off it.
|
|
33
|
+
*/
|
|
34
|
+
export function readFrontmatterSource(source: string): FrontmatterReading {
|
|
20
35
|
const match = source.match(FRONTMATTER)
|
|
21
|
-
if (!match) return
|
|
36
|
+
if (!match) return { kind: 'absent' }
|
|
22
37
|
|
|
23
38
|
let parsed: unknown
|
|
24
39
|
try {
|
|
25
40
|
parsed = Bun.YAML.parse(match[1])
|
|
26
|
-
} catch {
|
|
27
|
-
return
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return {
|
|
43
|
+
kind: 'invalid',
|
|
44
|
+
message: error instanceof Error ? error.message : String(error),
|
|
45
|
+
}
|
|
28
46
|
}
|
|
29
47
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
30
|
-
return
|
|
48
|
+
return { kind: 'invalid', message: 'frontmatter is not a mapping' }
|
|
31
49
|
}
|
|
32
50
|
|
|
33
51
|
return {
|
|
34
|
-
|
|
35
|
-
|
|
52
|
+
kind: 'parsed',
|
|
53
|
+
frontmatter: {
|
|
54
|
+
raw: match[0],
|
|
55
|
+
fields: parsed as Record<string, unknown>,
|
|
56
|
+
},
|
|
36
57
|
}
|
|
37
58
|
}
|
|
38
59
|
|
package/src/indexes/render.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { basename, dirname, resolve } from 'node:path'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
readField,
|
|
4
|
+
readFrontmatter,
|
|
5
|
+
readFrontmatterSource,
|
|
6
|
+
} from '@/indexes/frontmatter'
|
|
3
7
|
import { INDEX_FILE, listIndexes } from '@/indexes/walk'
|
|
4
8
|
|
|
5
9
|
export interface IndexEntry {
|
|
@@ -81,8 +85,19 @@ export async function collectEntries(
|
|
|
81
85
|
const entries: IndexEntry[] = []
|
|
82
86
|
const errors: string[] = []
|
|
83
87
|
|
|
88
|
+
let invalid = 0
|
|
89
|
+
|
|
84
90
|
for (const path of paths) {
|
|
85
|
-
const
|
|
91
|
+
const reading = readFrontmatterSource(await Bun.file(path).text())
|
|
92
|
+
if (reading.kind === 'invalid') {
|
|
93
|
+
invalid += 1
|
|
94
|
+
errors.push(
|
|
95
|
+
`frontmatter is not valid YAML in ${path}: ${reading.message}`,
|
|
96
|
+
)
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
const frontmatter =
|
|
100
|
+
reading.kind === 'parsed' ? reading.frontmatter : undefined
|
|
86
101
|
const title = readField(frontmatter, 'title')
|
|
87
102
|
const description = readField(frontmatter, 'description')
|
|
88
103
|
|
|
@@ -101,7 +116,10 @@ export async function collectEntries(
|
|
|
101
116
|
}
|
|
102
117
|
|
|
103
118
|
if (errors.length > 0) {
|
|
104
|
-
|
|
119
|
+
const missing = errors.length - invalid
|
|
120
|
+
if (missing > 0) {
|
|
121
|
+
errors.push(`${missing} missing frontmatter field(s) in ${dir}`)
|
|
122
|
+
}
|
|
105
123
|
return { ok: false, errors }
|
|
106
124
|
}
|
|
107
125
|
|
package/src/markdown/colons.ts
CHANGED
|
@@ -30,6 +30,12 @@ const BOLD_LABEL = /^(\*\*|__)[^*_]+\1$/
|
|
|
30
30
|
/** What may stand in a lead once its code spans and links are masked. */
|
|
31
31
|
const CONNECTIVE = /\b(?:and|or|to)\b|,/g
|
|
32
32
|
|
|
33
|
+
/** Words and apostrophes or hyphens between them, with no sentence punctuation. */
|
|
34
|
+
const PLAIN_LEAD = /^[\p{L}\p{N}][\p{L}\p{N}'’ -]*$/u
|
|
35
|
+
|
|
36
|
+
/** The longest plain lead a line may open on and still read as a label. */
|
|
37
|
+
const LEAD_WORDS = 3
|
|
38
|
+
|
|
33
39
|
/** Blanks a span while holding its width, so a column stays where it was. */
|
|
34
40
|
function blank(match: string): string {
|
|
35
41
|
return ' '.repeat(match.length)
|
|
@@ -38,16 +44,20 @@ function blank(match: string): string {
|
|
|
38
44
|
/**
|
|
39
45
|
* Whether the text before a line's first colon is a label rather than a
|
|
40
46
|
* clause: the lead of a list item, or a prefix that is only emphasis and a
|
|
41
|
-
* masked code span. A
|
|
42
|
-
*
|
|
43
|
-
*
|
|
47
|
+
* masked code span. A line, after any quote marker, also reads as a label when
|
|
48
|
+
* its lead is at most three plain words, such as `Issue` or `Test strategy`.
|
|
49
|
+
* A list item's lead also reads as a label at any length when only code spans,
|
|
50
|
+
* links, commas, and `and`, `or`, or `to` stand in it, which is a file entry
|
|
51
|
+
* naming several paths.
|
|
44
52
|
*/
|
|
45
53
|
function isLabel(prefix: string, listItem: boolean): boolean {
|
|
46
54
|
if (BOLD_LABEL.test(prefix.trim())) return true
|
|
47
55
|
|
|
48
56
|
const words = prefix.replace(/[*_]/g, ' ').trim()
|
|
49
57
|
if (words === '') return true
|
|
50
|
-
if (!listItem)
|
|
58
|
+
if (!listItem) {
|
|
59
|
+
return PLAIN_LEAD.test(words) && words.split(/\s+/).length <= LEAD_WORDS
|
|
60
|
+
}
|
|
51
61
|
if (words.replace(CONNECTIVE, ' ').trim() === '') return true
|
|
52
62
|
return words.split(/\s+/).length <= 4
|
|
53
63
|
}
|
|
@@ -14,15 +14,22 @@ const TITLE = /^title:\s*(["']?)(.*?)\1\s*$/
|
|
|
14
14
|
const CODE_SPAN = /(`+)(?:(?!\1).)*\1/g
|
|
15
15
|
const CAPITALIZED = /^[A-Z][a-z]/
|
|
16
16
|
|
|
17
|
+
/** A task's phase label, `v` and dotted digits, which the template fixes. */
|
|
18
|
+
const PHASE_LABEL = /^v\d+\.\d+:\s+/
|
|
19
|
+
|
|
17
20
|
/**
|
|
18
21
|
* The words of `text` that break sentence case.
|
|
19
22
|
*
|
|
20
23
|
* A code span stands for one lowercase word, so a heading opening on one
|
|
21
24
|
* counts that span as its first word. The first word with a letter in it is
|
|
22
|
-
* exempt, which keeps a leading numeral from promoting the next word.
|
|
25
|
+
* exempt, which keeps a leading numeral from promoting the next word. A
|
|
26
|
+
* leading phase label is not a word of the heading, so the first word is the
|
|
27
|
+
* one after it.
|
|
23
28
|
*/
|
|
24
29
|
function capitalizedAfterFirst(text: string): string[] {
|
|
25
|
-
const masked = maskDisplayed(
|
|
30
|
+
const masked = maskDisplayed(
|
|
31
|
+
text.replace(PHASE_LABEL, '').replace(CODE_SPAN, 'code'),
|
|
32
|
+
)
|
|
26
33
|
.replace(/\s+/g, ' ')
|
|
27
34
|
.trim()
|
|
28
35
|
const words = masked === '' ? [] : masked.split(' ')
|
package/src/pr/bijection.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RenamePair } from '@/git/files'
|
|
2
|
+
import { findMislinked, type Mislinked } from '@/pr/links'
|
|
2
3
|
import { extractKeyChangePaths, KEY_CHANGES, type PathClaim } from '@/pr/paths'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -62,6 +63,12 @@ export interface BijectionReport {
|
|
|
62
63
|
* comparison has no way to tell a stale claim from one it could not check.
|
|
63
64
|
*/
|
|
64
65
|
readonly evidenceUnread: boolean
|
|
66
|
+
/**
|
|
67
|
+
* Links in the section whose anchor hashes a path other than the one their
|
|
68
|
+
* label names, which open the wrong file's diff. Empty for a body that links
|
|
69
|
+
* nothing, since an unlinked path is the ordinary form.
|
|
70
|
+
*/
|
|
71
|
+
readonly mislinked: readonly Mislinked[]
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
export type Bijection =
|
|
@@ -231,6 +238,7 @@ export function compareKeyChanges(input: BijectionInput): Bijection {
|
|
|
231
238
|
unnamed: reached.filter((path) => !owesNoBullet(path)),
|
|
232
239
|
incidental: reached.filter(owesNoBullet),
|
|
233
240
|
evidenceUnread,
|
|
241
|
+
mislinked: findMislinked(input.body, input.changed),
|
|
234
242
|
}
|
|
235
243
|
}
|
|
236
244
|
|
package/src/pr/evidence.ts
CHANGED
|
@@ -46,6 +46,9 @@ const FOLDED_SUMMARY = 'Within tolerance'
|
|
|
46
46
|
/** The heading every body opens with. */
|
|
47
47
|
const EVIDENCE_HEADING = '## Evidence'
|
|
48
48
|
|
|
49
|
+
/** How the line counting renamed images whose pixels did not change opens. */
|
|
50
|
+
const MOVED_PREFIX = '**Moved unchanged:**'
|
|
51
|
+
|
|
49
52
|
/** How the visible commit line opens. */
|
|
50
53
|
const BASE_PREFIX = '**Base:**'
|
|
51
54
|
|
|
@@ -82,14 +85,18 @@ const IMAGE_EXTENSION = /\.(png|jpe?g|gif|webp|avif|svg)$/i
|
|
|
82
85
|
export interface EvidenceItem {
|
|
83
86
|
readonly path: string
|
|
84
87
|
readonly stem: string
|
|
85
|
-
/** True when `path` did not exist at the comparison's base commit. */
|
|
88
|
+
/** True when `path` did not exist at the comparison's base commit and was not renamed from a path that did. */
|
|
86
89
|
readonly added: boolean
|
|
90
|
+
/** Where a renamed image sat at the base, which its before image reads from. Absent for any image that was not renamed. */
|
|
91
|
+
readonly basePath?: string
|
|
87
92
|
/** Pixel width of the head image, read from its PNG header. Absent when unread. */
|
|
88
93
|
readonly width?: number
|
|
89
94
|
/** Pixel width of the base image, absent for an added case or an unread header. */
|
|
90
95
|
readonly baseWidth?: number
|
|
91
96
|
/** Whether the head differs from its base by no more than sub-visual noise. Absent when the pair was not compared. */
|
|
92
97
|
readonly withinTolerance?: boolean
|
|
98
|
+
/** Whether the head and base files hold the same bytes. Read only for a renamed image, where it alone lets the listing drop the pair, since a small edit also passes the tolerance. */
|
|
99
|
+
readonly identical?: boolean
|
|
93
100
|
}
|
|
94
101
|
|
|
95
102
|
export interface EvidenceState {
|
|
@@ -131,11 +138,13 @@ function splitEvidencePath(path: string): {
|
|
|
131
138
|
/**
|
|
132
139
|
* Groups every evidence path in `changed` by its state, keying each entry
|
|
133
140
|
* inside a state by filename stem so a before/after pair sharing a name
|
|
134
|
-
* across two states can be read as one case.
|
|
141
|
+
* across two states can be read as one case. A path in `renamedFrom` was moved
|
|
142
|
+
* from the path it maps to, so it is not added and its base reads from there.
|
|
135
143
|
*/
|
|
136
144
|
export async function groupEvidence(
|
|
137
145
|
changed: readonly string[],
|
|
138
146
|
existsAtBase: ExistsAtBase,
|
|
147
|
+
renamedFrom: ReadonlyMap<string, string> = new Map(),
|
|
139
148
|
): Promise<EvidenceReading> {
|
|
140
149
|
const paths = changed.filter(isEvidencePath)
|
|
141
150
|
if (paths.length === 0) return { kind: 'refused', reason: 'no-evidence' }
|
|
@@ -143,14 +152,20 @@ export async function groupEvidence(
|
|
|
143
152
|
const resolved = await Promise.all(
|
|
144
153
|
paths.map(async (path) => {
|
|
145
154
|
const { state, stem } = splitEvidencePath(path)
|
|
146
|
-
const
|
|
147
|
-
|
|
155
|
+
const basePath = renamedFrom.get(path)
|
|
156
|
+
const added = basePath === undefined && !(await existsAtBase(path))
|
|
157
|
+
return { path, state, stem, added, basePath }
|
|
148
158
|
}),
|
|
149
159
|
)
|
|
150
160
|
|
|
151
161
|
const byState = new Map<string, EvidenceItem[]>()
|
|
152
|
-
for (const { path, state, stem, added } of resolved) {
|
|
153
|
-
const item: EvidenceItem = {
|
|
162
|
+
for (const { path, state, stem, added, basePath } of resolved) {
|
|
163
|
+
const item: EvidenceItem = {
|
|
164
|
+
path,
|
|
165
|
+
stem,
|
|
166
|
+
added,
|
|
167
|
+
...(basePath !== undefined && { basePath }),
|
|
168
|
+
}
|
|
154
169
|
const items = byState.get(state)
|
|
155
170
|
if (items === undefined) byState.set(state, [item])
|
|
156
171
|
else items.push(item)
|
|
@@ -283,7 +298,7 @@ function baseFold(item: EvidenceItem, repo: string, base: string): string[] {
|
|
|
283
298
|
'<summary>Base</summary>',
|
|
284
299
|
'',
|
|
285
300
|
evidenceImage(
|
|
286
|
-
rawUrl(repo, base, item.path),
|
|
301
|
+
rawUrl(repo, base, item.basePath ?? item.path),
|
|
287
302
|
`${escapeCell(item.stem)} base`,
|
|
288
303
|
item.baseWidth,
|
|
289
304
|
),
|
|
@@ -576,8 +591,16 @@ export function renderEvidenceBody(
|
|
|
576
591
|
states
|
|
577
592
|
.map((entry) => ({ ...entry, items: entry.items.filter(keep) }))
|
|
578
593
|
.filter((entry) => entry.items.length > 0)
|
|
579
|
-
const
|
|
580
|
-
|
|
594
|
+
const isMoved = (item: EvidenceItem): boolean =>
|
|
595
|
+
item.basePath !== undefined && item.identical === true
|
|
596
|
+
const open = split((item) => !isMoved(item) && item.withinTolerance !== true)
|
|
597
|
+
const folded = split(
|
|
598
|
+
(item) => !isMoved(item) && item.withinTolerance === true,
|
|
599
|
+
)
|
|
600
|
+
const movedCount = states.reduce(
|
|
601
|
+
(total, entry) => total + entry.items.filter(isMoved).length,
|
|
602
|
+
0,
|
|
603
|
+
)
|
|
581
604
|
|
|
582
605
|
const headCount = open.reduce((total, entry) => total + entry.items.length, 0)
|
|
583
606
|
const opener =
|
|
@@ -592,6 +615,12 @@ export function renderEvidenceBody(
|
|
|
592
615
|
...opening,
|
|
593
616
|
`${BASE_PREFIX} ${commitLink(repo, base)} · **Head:** ${commitLink(repo, head)}`,
|
|
594
617
|
'',
|
|
618
|
+
...(movedCount === 0
|
|
619
|
+
? []
|
|
620
|
+
: [
|
|
621
|
+
`${MOVED_PREFIX} ${movedCount} ${movedCount === 1 ? 'image' : 'images'}`,
|
|
622
|
+
'',
|
|
623
|
+
]),
|
|
595
624
|
...sections,
|
|
596
625
|
'',
|
|
597
626
|
...closing,
|