@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.
- package/CHANGELOG.md +40 -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/canon-cli/SKILL.md +13 -10
- package/claude/skills/design-pick/SKILL.md +8 -0
- package/claude/skills/design-pick/references/design-config.md +9 -6
- package/claude/skills/design-references/REQUIREMENT.md +32 -0
- package/claude/skills/design-references/SKILL.md +120 -0
- package/claude/skills/design-references/references/films.md +90 -0
- package/claude/skills/design-references/references/sites.md +36 -0
- package/claude/skills/design-taste/SKILL.md +16 -4
- package/claude/skills/design-taste/references/adopted.md +5 -1
- package/claude/skills/design-taste/references/kinds.md +1 -0
- package/claude/skills/design-taste/references/preflight.md +27 -1
- package/claude/skills/design-taste/references/tells.md +2 -0
- 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/review-ui/references/driving.md +33 -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/search-craft/REQUIREMENT.md +3 -3
- package/claude/skills/search-craft/SKILL.md +12 -1
- package/claude/skills/target-scaffold/REQUIREMENT.md +31 -0
- package/claude/skills/target-scaffold/SKILL.md +44 -0
- package/claude/skills/target-setup/SKILL.md +5 -1
- package/claude/skills/task-board/SKILL.md +1 -0
- package/claude/skills/task-board/references/tasks.md +1 -0
- package/claude/skills/ui-checklist/SKILL.md +5 -1
- package/claude/skills/ui-craft/SKILL.md +9 -9
- package/claude/skills/ui-craft/references/adopted.md +4 -2
- package/claude/skills/ui-craft/references/capture.md +8 -0
- package/claude/skills/ui-craft/references/controls.md +6 -0
- package/claude/skills/ui-craft/references/motion.md +6 -0
- package/claude/skills/ui-film/references/take-craft.md +2 -0
- package/docs/agents/output-shape.md +2 -1
- package/docs/target/projects.md +2 -2
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/parallel-features.md +2 -1
- package/docs/workflow/skill-map.md +7 -5
- package/docs/workflow/visual-design-workflow.md +4 -0
- package/governance/ledger/skills/design-references/SKILL.md +38 -0
- package/governance/ledger/skills/design-taste/SKILL.md +36 -30
- package/governance/ledger/skills/design-taste/kinds.md +1 -0
- package/governance/ledger/skills/design-taste/tells.md +22 -20
- package/governance/ledger/skills/search-craft/SKILL.md +6 -0
- package/governance/ledger/skills/ui-craft/capture.md +10 -0
- package/governance/ledger/skills/ui-craft/controls.md +8 -0
- package/governance/ledger/skills/ui-craft/motion.md +9 -1
- package/package.json +3 -3
- package/src/capture/render.ts +154 -2
- package/src/capture/sections.ts +257 -0
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/cases/setup.ts +7 -0
- package/src/commands/capture.ts +149 -4
- package/src/commands/pr.ts +138 -15
- package/src/commands/sessions.ts +88 -6
- package/src/commands/tasks.ts +41 -2
- package/src/commands/tooling.ts +72 -0
- 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/perform.ts +29 -4
- package/src/take/screencast.ts +114 -11
- package/src/tasks/holders.ts +198 -0
- package/src/tasks/validate.ts +148 -0
- package/src/tooling/list.ts +3 -0
- package/src/tooling/scaffold.ts +193 -0
- package/tooling/cloudflare/manifest.toml +1 -1
- package/tooling/cloudflare/reference.md +8 -0
- /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
|
-
|
|
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,
|
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
|
|
9
|
-
* `health = []`. `invalid` is a
|
|
10
|
-
*
|
|
11
|
-
* fallback is the failure the
|
|
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
|
-
| {
|
|
15
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
}
|
package/src/pr/links.ts
ADDED
|
@@ -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
|
|
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
|
|
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 =
|
|
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
|
}
|