@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +12 -0
  4. package/claude/skills/app-drive/SKILL.md +44 -8
  5. package/claude/skills/app-drive/references/config.md +18 -8
  6. package/claude/skills/app-drive/references/drive-record.md +4 -0
  7. package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
  8. package/claude/skills/auto-ship/SKILL.md +5 -4
  9. package/claude/skills/auto-ship/references/draft-mark.md +33 -15
  10. package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
  11. package/claude/skills/git-followup/SKILL.md +1 -0
  12. package/claude/skills/git-pr/REQUIREMENT.md +2 -0
  13. package/claude/skills/git-pr/SKILL.md +2 -0
  14. package/claude/skills/git-pr/references/final-command.md +34 -2
  15. package/claude/skills/git-pr/references/pr.md +1 -1
  16. package/claude/skills/git-ship/REQUIREMENT.md +2 -2
  17. package/claude/skills/git-ship/SKILL.md +16 -11
  18. package/claude/skills/git-ship/references/ci-watch.md +28 -4
  19. package/claude/skills/markdown-craft/references/markdown.md +4 -4
  20. package/claude/skills/plan-feature/references/plan.md +2 -0
  21. package/claude/skills/review-address/SKILL.md +2 -1
  22. package/claude/skills/review-pr/references/key-changes.md +2 -1
  23. package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
  24. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  25. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
  26. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  27. package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
  28. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
  29. package/claude/skills/role-worker/SKILL.md +1 -1
  30. package/claude/skills/task-board/SKILL.md +1 -0
  31. package/claude/skills/task-board/references/tasks.md +1 -0
  32. package/docs/workflow/ai-workflow.md +1 -1
  33. package/docs/workflow/parallel-features.md +2 -1
  34. package/package.json +1 -1
  35. package/src/commands/pr.ts +138 -15
  36. package/src/commands/sessions.ts +88 -6
  37. package/src/commands/tasks.ts +41 -2
  38. package/src/git/files.ts +5 -2
  39. package/src/indexes/frontmatter.ts +27 -6
  40. package/src/indexes/render.ts +21 -3
  41. package/src/markdown/colons.ts +14 -4
  42. package/src/markdown/heading-case.ts +9 -2
  43. package/src/pr/bijection.ts +8 -0
  44. package/src/pr/evidence.ts +38 -9
  45. package/src/pr/health.ts +63 -15
  46. package/src/pr/links.ts +160 -0
  47. package/src/pr/local.ts +17 -4
  48. package/src/pr/paths.ts +20 -2
  49. package/src/sessions/stop.ts +145 -0
  50. package/src/sessions/transfer/bundle.ts +1 -1
  51. package/src/take/encode.ts +16 -42
  52. package/src/take/frame-clock.ts +46 -0
  53. package/src/take/screencast.ts +114 -11
  54. package/src/tasks/holders.ts +198 -0
  55. package/src/tasks/validate.ts +148 -0
  56. /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
@@ -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(shown, report)
515
+ reportConfidence(listed, report)
482
516
  reportSessions(
483
- shown,
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(shown),
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: shown,
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 ResolvedSession[],
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 `${session.name} ${status} ${held}\n ${session.cwd}`
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
@@ -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(root)
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
- outro()
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
- const changed = await git(root, ['diff', '--name-only', base])
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('\n'), ...untracked.split('\n')]
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 undefined
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 undefined
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 undefined
48
+ return { kind: 'invalid', message: 'frontmatter is not a mapping' }
31
49
  }
32
50
 
33
51
  return {
34
- raw: match[0],
35
- fields: parsed as Record<string, unknown>,
52
+ kind: 'parsed',
53
+ frontmatter: {
54
+ raw: match[0],
55
+ fields: parsed as Record<string, unknown>,
56
+ },
36
57
  }
37
58
  }
38
59
 
@@ -1,5 +1,9 @@
1
1
  import { basename, dirname, resolve } from 'node:path'
2
- import { readField, readFrontmatter } from '@/indexes/frontmatter'
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 frontmatter = await readFrontmatter(path)
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
- errors.push(`${errors.length} missing frontmatter field(s) in ${dir}`)
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
 
@@ -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 list item's lead also reads as a label at any length
42
- * when only code spans, links, commas, and `and`, `or`, or `to` stand in it,
43
- * which is a file entry naming several paths.
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) return false
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(text.replace(CODE_SPAN, 'code'))
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(' ')
@@ -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
 
@@ -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 added = !(await existsAtBase(path))
147
- return { path, state, stem, added }
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 = { path, stem, added }
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 open = split((item) => item.withinTolerance !== true)
580
- const folded = split((item) => item.withinTolerance === true)
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,