@erclx/canon 5.78.0 → 5.80.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 (104) hide show
  1. package/CHANGELOG.md +40 -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/canon-cli/SKILL.md +13 -10
  12. package/claude/skills/design-pick/SKILL.md +8 -0
  13. package/claude/skills/design-pick/references/design-config.md +9 -6
  14. package/claude/skills/design-references/REQUIREMENT.md +32 -0
  15. package/claude/skills/design-references/SKILL.md +120 -0
  16. package/claude/skills/design-references/references/films.md +90 -0
  17. package/claude/skills/design-references/references/sites.md +36 -0
  18. package/claude/skills/design-taste/SKILL.md +16 -4
  19. package/claude/skills/design-taste/references/adopted.md +5 -1
  20. package/claude/skills/design-taste/references/kinds.md +1 -0
  21. package/claude/skills/design-taste/references/preflight.md +27 -1
  22. package/claude/skills/design-taste/references/tells.md +2 -0
  23. package/claude/skills/git-followup/SKILL.md +1 -0
  24. package/claude/skills/git-pr/REQUIREMENT.md +2 -0
  25. package/claude/skills/git-pr/SKILL.md +2 -0
  26. package/claude/skills/git-pr/references/final-command.md +34 -2
  27. package/claude/skills/git-pr/references/pr.md +1 -1
  28. package/claude/skills/git-ship/REQUIREMENT.md +2 -2
  29. package/claude/skills/git-ship/SKILL.md +16 -11
  30. package/claude/skills/git-ship/references/ci-watch.md +28 -4
  31. package/claude/skills/markdown-craft/references/markdown.md +4 -4
  32. package/claude/skills/plan-feature/references/plan.md +2 -0
  33. package/claude/skills/review-address/SKILL.md +2 -1
  34. package/claude/skills/review-pr/references/key-changes.md +2 -1
  35. package/claude/skills/review-ui/references/driving.md +33 -1
  36. package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
  37. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  38. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
  39. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  40. package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
  41. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
  42. package/claude/skills/role-worker/SKILL.md +1 -1
  43. package/claude/skills/search-craft/REQUIREMENT.md +3 -3
  44. package/claude/skills/search-craft/SKILL.md +12 -1
  45. package/claude/skills/target-scaffold/REQUIREMENT.md +31 -0
  46. package/claude/skills/target-scaffold/SKILL.md +44 -0
  47. package/claude/skills/target-setup/SKILL.md +5 -1
  48. package/claude/skills/task-board/SKILL.md +1 -0
  49. package/claude/skills/task-board/references/tasks.md +1 -0
  50. package/claude/skills/ui-checklist/SKILL.md +5 -1
  51. package/claude/skills/ui-craft/SKILL.md +9 -9
  52. package/claude/skills/ui-craft/references/adopted.md +4 -2
  53. package/claude/skills/ui-craft/references/capture.md +8 -0
  54. package/claude/skills/ui-craft/references/controls.md +6 -0
  55. package/claude/skills/ui-craft/references/motion.md +6 -0
  56. package/claude/skills/ui-film/references/take-craft.md +2 -0
  57. package/docs/agents/output-shape.md +2 -1
  58. package/docs/target/projects.md +2 -2
  59. package/docs/workflow/ai-workflow.md +1 -1
  60. package/docs/workflow/parallel-features.md +2 -1
  61. package/docs/workflow/skill-map.md +7 -5
  62. package/docs/workflow/visual-design-workflow.md +4 -0
  63. package/governance/ledger/skills/design-references/SKILL.md +38 -0
  64. package/governance/ledger/skills/design-taste/SKILL.md +36 -30
  65. package/governance/ledger/skills/design-taste/kinds.md +1 -0
  66. package/governance/ledger/skills/design-taste/tells.md +22 -20
  67. package/governance/ledger/skills/search-craft/SKILL.md +6 -0
  68. package/governance/ledger/skills/ui-craft/capture.md +10 -0
  69. package/governance/ledger/skills/ui-craft/controls.md +8 -0
  70. package/governance/ledger/skills/ui-craft/motion.md +9 -1
  71. package/package.json +3 -3
  72. package/src/capture/render.ts +154 -2
  73. package/src/capture/sections.ts +257 -0
  74. package/src/claude/cases/misc.ts +5 -0
  75. package/src/claude/cases/setup.ts +7 -0
  76. package/src/commands/capture.ts +149 -4
  77. package/src/commands/pr.ts +138 -15
  78. package/src/commands/sessions.ts +88 -6
  79. package/src/commands/tasks.ts +41 -2
  80. package/src/commands/tooling.ts +72 -0
  81. package/src/git/files.ts +5 -2
  82. package/src/indexes/frontmatter.ts +27 -6
  83. package/src/indexes/render.ts +21 -3
  84. package/src/markdown/colons.ts +14 -4
  85. package/src/markdown/heading-case.ts +9 -2
  86. package/src/pr/bijection.ts +8 -0
  87. package/src/pr/evidence.ts +38 -9
  88. package/src/pr/health.ts +63 -15
  89. package/src/pr/links.ts +160 -0
  90. package/src/pr/local.ts +17 -4
  91. package/src/pr/paths.ts +20 -2
  92. package/src/sessions/stop.ts +145 -0
  93. package/src/sessions/transfer/bundle.ts +1 -1
  94. package/src/take/encode.ts +16 -42
  95. package/src/take/frame-clock.ts +46 -0
  96. package/src/take/perform.ts +29 -4
  97. package/src/take/screencast.ts +114 -11
  98. package/src/tasks/holders.ts +198 -0
  99. package/src/tasks/validate.ts +148 -0
  100. package/src/tooling/list.ts +3 -0
  101. package/src/tooling/scaffold.ts +193 -0
  102. package/tooling/cloudflare/manifest.toml +1 -1
  103. package/tooling/cloudflare/reference.md +8 -0
  104. /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
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,
package/src/pr/health.ts CHANGED
@@ -1,18 +1,26 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
+ /** The key listing folders whose listeners skip the health paths. */
5
+ export const PAGE_ONLY_KEY = 'page-only'
6
+
4
7
  /** Where a project declares what `canon pr local` checks beyond the page. */
5
8
  export const LOCAL_CONFIG_REL = join('canon', 'config', 'local.toml')
6
9
 
7
10
  /**
8
- * The health paths a project declared. `none` is an absent file or an empty
9
- * `health = []`. `invalid` is a declaration that exists and cannot be used,
10
- * which refuses rather than falling back to the page-only probe, since that
11
- * fallback is the failure the declaration exists to catch.
11
+ * The health paths a project declared, with the folders whose listeners skip
12
+ * them. `none` is an absent file or an empty `health = []`. `invalid` is a
13
+ * declaration that exists and cannot be used, which refuses rather than
14
+ * falling back to the page-only probe, since that fallback is the failure the
15
+ * declaration exists to catch.
12
16
  */
13
17
  export type HealthDeclaration =
14
- | { readonly kind: 'paths'; readonly paths: readonly string[] }
15
- | { readonly kind: 'none' }
18
+ | {
19
+ readonly kind: 'paths'
20
+ readonly paths: readonly string[]
21
+ readonly pageOnly: readonly string[]
22
+ }
23
+ | { readonly kind: 'none'; readonly pageOnly: readonly string[] }
16
24
  | { readonly kind: 'invalid'; readonly reason: string }
17
25
 
18
26
  /**
@@ -28,9 +36,24 @@ function isLocalPath(entry: unknown): entry is string {
28
36
  )
29
37
  }
30
38
 
39
+ /**
40
+ * A repository-relative folder as whole segments, or undefined when the entry
41
+ * is not one. Absolute paths, drive letters, backslashes, and `..` refuse,
42
+ * since each would name a place outside the repository. A leading `./` and
43
+ * trailing slashes are dropped so the match compares segments alone.
44
+ */
45
+ function toFolder(entry: unknown): string | undefined {
46
+ if (typeof entry !== 'string') return undefined
47
+ if (entry.startsWith('/') || entry.includes('\\')) return undefined
48
+ if (/^[A-Za-z]:/.test(entry)) return undefined
49
+ const segments = entry.split('/').filter((s) => s !== '' && s !== '.')
50
+ if (segments.length === 0 || segments.includes('..')) return undefined
51
+ return segments.join('/')
52
+ }
53
+
31
54
  export function readHealthPaths(root: string): HealthDeclaration {
32
55
  const path = join(root, LOCAL_CONFIG_REL)
33
- if (!existsSync(path)) return { kind: 'none' }
56
+ if (!existsSync(path)) return { kind: 'none', pageOnly: [] }
34
57
 
35
58
  let parsed: Record<string, unknown>
36
59
  try {
@@ -42,15 +65,40 @@ export function readHealthPaths(root: string): HealthDeclaration {
42
65
  return { kind: 'invalid', reason: `${LOCAL_CONFIG_REL} is not valid TOML` }
43
66
  }
44
67
 
45
- const declared = parsed.health
46
- if (!Array.isArray(declared) || !declared.every(isLocalPath)) {
47
- return {
48
- kind: 'invalid',
49
- reason: `${LOCAL_CONFIG_REL} needs health as a list of paths, each starting with a single /`,
68
+ const hasHealth = 'health' in parsed
69
+ const hasPageOnly = PAGE_ONLY_KEY in parsed
70
+ if (!hasHealth && !hasPageOnly) return invalidHealth()
71
+
72
+ let paths: readonly string[] = []
73
+ if (hasHealth) {
74
+ const declared = parsed.health
75
+ if (!Array.isArray(declared) || !declared.every(isLocalPath)) {
76
+ return invalidHealth()
50
77
  }
78
+ paths = declared
51
79
  }
52
80
 
53
- return declared.length === 0
54
- ? { kind: 'none' }
55
- : { kind: 'paths', paths: declared }
81
+ let pageOnly: readonly string[] = []
82
+ if (hasPageOnly) {
83
+ const declared = parsed[PAGE_ONLY_KEY]
84
+ const folders = Array.isArray(declared) ? declared.map(toFolder) : []
85
+ if (!Array.isArray(declared) || folders.includes(undefined)) {
86
+ return {
87
+ kind: 'invalid',
88
+ reason: `${LOCAL_CONFIG_REL} needs ${PAGE_ONLY_KEY} as a list of folders relative to the repository, with no leading / and no ..`,
89
+ }
90
+ }
91
+ pageOnly = folders as string[]
92
+ }
93
+
94
+ return paths.length === 0
95
+ ? { kind: 'none', pageOnly }
96
+ : { kind: 'paths', paths, pageOnly }
97
+ }
98
+
99
+ function invalidHealth(): HealthDeclaration {
100
+ return {
101
+ kind: 'invalid',
102
+ reason: `${LOCAL_CONFIG_REL} needs health as a list of paths, each starting with a single /`,
103
+ }
56
104
  }
@@ -0,0 +1,160 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { findBacktickSpans } from '@/markdown/backticks'
3
+ import { KEY_CHANGES, LINE_SUFFIX, readSectionAt } from '@/pr/paths'
4
+
5
+ /** A Key changes link whose anchor hashes a path other than its label's. */
6
+ export interface Mislinked {
7
+ readonly path: string
8
+ readonly anchor: string
9
+ }
10
+
11
+ /**
12
+ * The fragment GitHub gives a file's diff in a pull request's Files tab, being
13
+ * `diff-` and the SHA-256 of the raw path. Read off a merged pull request's
14
+ * rendered page, so a path holding a space or a bracket hashes as written.
15
+ */
16
+ export function diffAnchor(path: string): string {
17
+ return `diff-${createHash('sha256').update(path).digest('hex')}`
18
+ }
19
+
20
+ interface Located {
21
+ readonly text: string
22
+ readonly offset: number
23
+ readonly whole: string
24
+ }
25
+
26
+ /** The section's text and where it starts, in the body with line endings normalized. */
27
+ function locateSection(body: string, title: string): Located | undefined {
28
+ const whole = body.replace(/\r\n/g, '\n')
29
+ const found = readSectionAt(whole, title)
30
+ return found === undefined ? undefined : { ...found, whole }
31
+ }
32
+
33
+ /** Blanks every code span, keeping offsets, so a bracket inside a path never reads as link syntax. */
34
+ function maskSpans(text: string): string {
35
+ let out = text
36
+ for (const span of findBacktickSpans(text)) {
37
+ out =
38
+ out.slice(0, span.start) +
39
+ ' '.repeat(span.end - span.start) +
40
+ out.slice(span.end)
41
+ }
42
+ return out
43
+ }
44
+
45
+ /**
46
+ * An inline link: a destination bare or in angle brackets, then an optional
47
+ * quoted title. A form this misses reads as no link, and a span inside it then
48
+ * gets a second link nested in the first.
49
+ */
50
+ const LINK =
51
+ /\[[^\]]*\]\(\s*(<[^>]*>|[^)\s]*)(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/dg
52
+ const DIFF_DESTINATION = /#diff-[0-9a-f]{64}$/
53
+
54
+ interface Edit {
55
+ readonly start: number
56
+ readonly end: number
57
+ readonly text: string
58
+ }
59
+
60
+ /**
61
+ * Writes each code span in `## Key changes` that names a changed file as a link
62
+ * to that file's diff, keeping the span as the label.
63
+ *
64
+ * Only an exact changed path is linked. A partial spelling or a folder names no
65
+ * single row in the Files tab, and a link to a guessed row is worse than none.
66
+ *
67
+ * A span that is the whole label of an existing diff link has its destination
68
+ * rewritten, which is how a wrong anchor clears. A span anywhere else inside a
69
+ * link's brackets is left alone, since a link nested in a link breaks the
70
+ * bullet, and the rewrite stays idempotent.
71
+ *
72
+ * `filesUrl` is the pull request's `/files` address, absolute because the body
73
+ * becomes the squash commit message, where a relative link resolves nowhere.
74
+ */
75
+ export function linkKeyChanges(
76
+ body: string,
77
+ changed: readonly string[],
78
+ filesUrl: string,
79
+ ): string {
80
+ const located = locateSection(body, KEY_CHANGES)
81
+ if (located === undefined) return body
82
+
83
+ const { text } = located
84
+ const known = new Set(changed)
85
+ const links = [...maskSpans(text).matchAll(LINK)].map((match) => ({
86
+ start: match.index ?? 0,
87
+ end: (match.index ?? 0) + match[0].length,
88
+ destination: match[1] ?? '',
89
+ destinationStart: match.indices?.[1]?.[0] ?? 0,
90
+ }))
91
+
92
+ const edits: Edit[] = []
93
+ for (const span of findBacktickSpans(text)) {
94
+ const path = span.content.replace(LINE_SUFFIX, '')
95
+ if (!known.has(path)) continue
96
+ const target = `${filesUrl}#${diffAnchor(path)}`
97
+
98
+ const link = links.find((l) => l.start < span.start && span.end <= l.end)
99
+ if (link === undefined) {
100
+ edits.push({
101
+ start: span.start,
102
+ end: span.end,
103
+ text: `[${text.slice(span.start, span.end)}](${target})`,
104
+ })
105
+ continue
106
+ }
107
+
108
+ const wholeLabel =
109
+ span.start === link.start + 1 && text.startsWith('](', span.end)
110
+ if (!wholeLabel || !DIFF_DESTINATION.test(link.destination)) continue
111
+ edits.push({
112
+ start: link.destinationStart,
113
+ end: link.destinationStart + link.destination.length,
114
+ text: target,
115
+ })
116
+ }
117
+
118
+ let out = ''
119
+ let cursor = 0
120
+ for (const edit of edits) {
121
+ out += text.slice(cursor, edit.start) + edit.text
122
+ cursor = edit.end
123
+ }
124
+ out += text.slice(cursor)
125
+
126
+ return (
127
+ located.whole.slice(0, located.offset) +
128
+ out +
129
+ located.whole.slice(located.offset + text.length)
130
+ )
131
+ }
132
+
133
+ const DIFF_LINK = /\[(`+)([^`]+)\1\]\([^)\s]*#(diff-[0-9a-f]{64})\)/g
134
+
135
+ /**
136
+ * Every Key changes link whose label is a changed path and whose anchor hashes
137
+ * another path.
138
+ *
139
+ * A label that is no changed path, such as a bare file name or a function, is
140
+ * left unjudged: it can open the right file under a spelling this cannot map
141
+ * back to a row, and an accusation needs the row.
142
+ */
143
+ export function findMislinked(
144
+ body: string,
145
+ changed: readonly string[],
146
+ ): Mislinked[] {
147
+ const located = locateSection(body, KEY_CHANGES)
148
+ if (located === undefined) return []
149
+
150
+ const known = new Set(changed)
151
+ const out: Mislinked[] = []
152
+ for (const match of located.text.matchAll(DIFF_LINK)) {
153
+ const path = (match[2] ?? '').trim().replace(LINE_SUFFIX, '')
154
+ const anchor = match[3] ?? ''
155
+ if (known.has(path) && diffAnchor(path) !== anchor) {
156
+ out.push({ path, anchor })
157
+ }
158
+ }
159
+ return out
160
+ }
package/src/pr/local.ts CHANGED
@@ -45,6 +45,11 @@ async function firstFailingPath(
45
45
  return undefined
46
46
  }
47
47
 
48
+ /** Whether `cwd` is `folder` or inside it, compared on whole path segments. */
49
+ function isUnder(cwd: string, folder: string): boolean {
50
+ return cwd === folder || cwd.startsWith(`${folder}/`)
51
+ }
52
+
48
53
  /**
49
54
  * Finds the server this worktree is running: the lowest port whose listening
50
55
  * process runs inside the worktree, answers an HTTP probe, and passes every
@@ -53,11 +58,14 @@ async function firstFailingPath(
53
58
  * lower one is the page by convention. Health is read on the page's own port,
54
59
  * since that is the address a reviewer drives, and a page whose backend fails
55
60
  * refuses as `unhealthy` naming the lowest page port's first failing path.
61
+ * A port whose listener runs under a `pageOnly` folder, matched on whole
62
+ * segments, is found on the page probe alone.
56
63
  */
57
64
  export async function findLocalServer(
58
65
  worktreeRoot: string,
59
66
  runner: LocalRunner,
60
67
  healthPaths: readonly string[] = [],
68
+ pageOnly: readonly string[] = [],
61
69
  ): Promise<LocalResult> {
62
70
  const listeners = await runner.listListeners()
63
71
  if (listeners === undefined) {
@@ -65,17 +73,22 @@ export async function findLocalServer(
65
73
  }
66
74
 
67
75
  const cwdByPid = new Map<number, string | undefined>()
68
- const ports = new Set<number>()
76
+ const ports = new Map<number, boolean>()
77
+ const folders = pageOnly.map((folder) => `${worktreeRoot}/${folder}`)
69
78
  for (const { pid, port } of listeners) {
70
79
  if (!cwdByPid.has(pid)) cwdByPid.set(pid, await runner.cwdOf(pid))
71
80
  const cwd = cwdByPid.get(pid)
72
- if (cwd !== undefined && belongsTo(cwd, worktreeRoot)) ports.add(port)
81
+ if (cwd === undefined || !belongsTo(cwd, worktreeRoot)) continue
82
+ const skipsHealth = folders.some((folder) => isUnder(cwd, folder))
83
+ ports.set(port, (ports.get(port) ?? false) || skipsHealth)
73
84
  }
74
85
 
75
86
  let unhealthy: (HealthFailure & { readonly port: number }) | undefined
76
- for (const port of [...ports].sort((a, b) => a - b)) {
87
+ for (const [port, skipsHealth] of [...ports].sort(([a], [b]) => a - b)) {
77
88
  if (!(await runner.probe(port))) continue
78
- const failure = await firstFailingPath(port, healthPaths, runner)
89
+ const failure = skipsHealth
90
+ ? undefined
91
+ : await firstFailingPath(port, healthPaths, runner)
79
92
  if (failure === undefined) {
80
93
  return { kind: 'found', port, url: `http://localhost:${port}` }
81
94
  }