@erclx/canon 5.66.0 → 5.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +35 -0
- package/claude/skills/app-drive/SKILL.md +112 -0
- package/claude/skills/app-drive/references/config.md +38 -0
- package/claude/skills/app-drive/references/drive-record.md +70 -0
- package/claude/skills/app-drive/references/isolate.md +46 -0
- package/claude/skills/canon-cli/SKILL.md +2 -0
- package/claude/skills/create-skill/references/skill.md +1 -1
- package/claude/skills/plan-feature/references/plan.md +1 -1
- package/claude/skills/plan-intake/references/intake.md +12 -2
- package/claude/skills/pr-evidence/REQUIREMENT.md +1 -1
- package/claude/skills/pr-evidence/SKILL.md +7 -1
- package/claude/skills/pr-evidence/references/evidence.md +33 -3
- package/claude/skills/review-craft/references/ui.md +30 -10
- package/claude/skills/review-ui/SKILL.md +5 -4
- package/claude/skills/review-ui/references/drive-copy.md +4 -0
- package/claude/skills/review-ui/references/driving.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
- package/claude/skills/role-planner/SKILL.md +2 -2
- package/claude/skills/role-worker/SKILL.md +4 -6
- package/claude/skills/session-map/REQUIREMENT.md +1 -1
- package/claude/skills/session-map/SKILL.md +8 -1
- package/claude/skills/session-map/references/session.md +19 -5
- package/claude/skills/ui-record/REQUIREMENT.md +36 -0
- package/claude/skills/ui-record/SKILL.md +114 -0
- package/claude/skills/ui-record/references/config.md +40 -0
- package/claude/skills/ui-record/references/handoff.md +67 -0
- package/claude/skills/ui-record/references/pr-clip.md +84 -0
- package/claude/skills/ui-record/references/take-craft.md +99 -0
- package/docs/agents/records.md +2 -0
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map-on-request.md +1 -0
- package/docs/workflow/skill-map.md +1 -0
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/package.json +5 -2
- package/src/audits/catalog.ts +5 -1
- package/src/browser/agent-cli.ts +68 -0
- package/src/claude/cases/workflow.ts +20 -0
- package/src/cli/help.ts +3 -0
- package/src/cli.ts +4 -0
- package/src/commands/browser.ts +102 -0
- package/src/commands/intake.ts +181 -1
- package/src/commands/pr.ts +252 -85
- package/src/commands/records.ts +18 -1
- package/src/commands/take.ts +302 -0
- package/src/context/requirements.ts +1 -11
- package/src/git/status.ts +73 -0
- package/src/intake/drive-check.ts +355 -0
- package/src/intake/folder.ts +1 -1
- package/src/markdown/scan.ts +10 -0
- package/src/pr/drive-copy.ts +97 -84
- package/src/pr/evidence.ts +208 -78
- package/src/pr/frames.ts +42 -4
- package/src/pr/pixels.ts +44 -0
- package/src/records/backup.ts +28 -4
- package/src/records/session-map.ts +75 -0
- package/src/records/validate.ts +64 -8
- package/src/take/camera.ts +309 -0
- package/src/take/encode.ts +207 -0
- package/src/take/film.ts +211 -0
- package/src/take/fixtures/fade.html +49 -0
- package/src/take/fixtures/fetch.html +38 -0
- package/src/take/log.ts +109 -0
- package/src/take/perform.ts +200 -0
- package/src/take/plan.ts +193 -0
- package/src/take/pointer.ts +168 -0
- package/src/take/reel.ts +215 -0
- package/src/take/screencast.ts +97 -0
- package/src/take/shell.ts +287 -0
- package/src/take/stepper.ts +255 -0
- package/src/take/zoom/NOTICE.md +45 -0
- package/src/take/zoom/math-utils.ts +69 -0
- package/src/take/zoom/region-utils.ts +55 -0
- package/src/take/zoom/spring.ts +133 -0
- package/src/tasks/archive.ts +1 -3
- package/claude/skills/review-ui/scripts/pw.sh +0 -60
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { basename, isAbsolute, join, normalize } from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
changedSince,
|
|
6
|
+
readWorktreeStatus,
|
|
7
|
+
type StatusEntry,
|
|
8
|
+
} from '@/git/status'
|
|
9
|
+
import { extractOrdinal, type IntakeRefused, openFolder } from '@/intake/folder'
|
|
10
|
+
import { requirementsRel } from '@/context/requirements'
|
|
11
|
+
import { bodyLines } from '@/markdown/scan'
|
|
12
|
+
|
|
13
|
+
/** The record a drive leaves in its intake folder, which this check reads. */
|
|
14
|
+
export const DRIVE_RECORD = 'drive.json'
|
|
15
|
+
|
|
16
|
+
/** The one folder inside an intake folder that evidence may live in. */
|
|
17
|
+
export const EVIDENCE_DIR = 'evidence'
|
|
18
|
+
|
|
19
|
+
const SCRATCH = join('.canon', 'tmp', 'app-drive')
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The refusals this check adds to the folder lookup's own, which reach a
|
|
23
|
+
* caller unchanged as an `IntakeRefused`.
|
|
24
|
+
*/
|
|
25
|
+
export const DRIVE_REFUSALS = [
|
|
26
|
+
'bad-input',
|
|
27
|
+
'no-record',
|
|
28
|
+
'bad-record',
|
|
29
|
+
'no-requirements',
|
|
30
|
+
'no-baseline',
|
|
31
|
+
'unreadable-tree',
|
|
32
|
+
] as const
|
|
33
|
+
|
|
34
|
+
export type DriveRefusal = (typeof DRIVE_REFUSALS)[number]
|
|
35
|
+
|
|
36
|
+
export interface DriveRefused {
|
|
37
|
+
readonly ok: false
|
|
38
|
+
readonly reason: DriveRefusal
|
|
39
|
+
readonly message: string
|
|
40
|
+
readonly detail: readonly string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface BaselineRecorded {
|
|
44
|
+
readonly ok: true
|
|
45
|
+
readonly slug: string
|
|
46
|
+
readonly path: string
|
|
47
|
+
readonly entries: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Each list names what failed, and an empty list is a clean axis. */
|
|
51
|
+
export interface DriveFindings {
|
|
52
|
+
/** Goals the record carries no entry for. */
|
|
53
|
+
readonly unmapped: readonly string[]
|
|
54
|
+
/** Goals marked `not-driven` with no reason given. */
|
|
55
|
+
readonly unreasoned: readonly string[]
|
|
56
|
+
/** Entries naming a goal the requirements record does not hold. */
|
|
57
|
+
readonly unknown: readonly string[]
|
|
58
|
+
/** Paths in the main checkout that differ from the baseline. */
|
|
59
|
+
readonly changed: readonly string[]
|
|
60
|
+
/** Evidence paths that resolve outside the folder's `evidence/`. */
|
|
61
|
+
readonly strayEvidence: readonly string[]
|
|
62
|
+
/** Evidence paths inside `evidence/` that hold no file. */
|
|
63
|
+
readonly missingEvidence: readonly string[]
|
|
64
|
+
/** The drive's worktree, when the record names one still on disk. */
|
|
65
|
+
readonly worktreeLeft: readonly string[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface DriveChecked {
|
|
69
|
+
readonly ok: true
|
|
70
|
+
readonly slug: string
|
|
71
|
+
readonly reason: 'clean' | 'failed'
|
|
72
|
+
readonly goals: number
|
|
73
|
+
readonly findings: DriveFindings
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type BaselineOutcome = BaselineRecorded | DriveRefused
|
|
77
|
+
export type CheckOutcome = DriveChecked | DriveRefused | IntakeRefused
|
|
78
|
+
|
|
79
|
+
type GoalStatus = 'driven' | 'not-driven'
|
|
80
|
+
|
|
81
|
+
interface DriveGoal {
|
|
82
|
+
readonly goal: string
|
|
83
|
+
readonly status: GoalStatus
|
|
84
|
+
readonly reason?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface DriveRecord {
|
|
88
|
+
readonly goals: readonly DriveGoal[]
|
|
89
|
+
readonly evidence: readonly string[]
|
|
90
|
+
readonly worktree?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function refuse(
|
|
94
|
+
reason: DriveRefusal,
|
|
95
|
+
message: string,
|
|
96
|
+
detail: readonly string[] = [],
|
|
97
|
+
): DriveRefused {
|
|
98
|
+
return { ok: false, reason, message, detail }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A goal as both sides compare it: a leading bold label and a trailing period
|
|
103
|
+
* dropped, and whitespace collapsed. Requirements records carry no goal ids,
|
|
104
|
+
* so the text is the identity, and stripping the label lets a drive record
|
|
105
|
+
* paste the bullet whole or name the sentence alone.
|
|
106
|
+
*/
|
|
107
|
+
function normalizeGoal(text: string): string {
|
|
108
|
+
return text
|
|
109
|
+
.replace(/\s+/g, ' ')
|
|
110
|
+
.trim()
|
|
111
|
+
.replace(/^\*\*[^*]+\*\*\s*/, '')
|
|
112
|
+
.replace(/\.$/, '')
|
|
113
|
+
.trim()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Every top-level bullet under `## Goals`, through any H3 grouping, up to the
|
|
118
|
+
* next H2. A wrapped bullet's indented continuation joins it, and a nested
|
|
119
|
+
* bullet is detail of its parent rather than a goal of its own.
|
|
120
|
+
*/
|
|
121
|
+
export function readGoals(source: string): string[] {
|
|
122
|
+
const goals: string[] = []
|
|
123
|
+
let inGoals = false
|
|
124
|
+
let current: string | undefined
|
|
125
|
+
let appending = false
|
|
126
|
+
|
|
127
|
+
const close = () => {
|
|
128
|
+
if (current !== undefined) goals.push(normalizeGoal(current))
|
|
129
|
+
current = undefined
|
|
130
|
+
appending = false
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const line of bodyLines(source)) {
|
|
134
|
+
if (line.fenced) continue
|
|
135
|
+
const text = line.text
|
|
136
|
+
|
|
137
|
+
if (/^#{1,2}\s/.test(text)) {
|
|
138
|
+
close()
|
|
139
|
+
inGoals = /^##\s+Goals\s*$/.test(text)
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
if (!inGoals) continue
|
|
143
|
+
|
|
144
|
+
const bullet = /^[-*]\s+(.*)$/.exec(text)
|
|
145
|
+
if (bullet) {
|
|
146
|
+
close()
|
|
147
|
+
current = bullet[1]
|
|
148
|
+
appending = true
|
|
149
|
+
} else if (/^\s+[-*]\s/.test(text) || text.trim() === '') {
|
|
150
|
+
appending = false
|
|
151
|
+
} else if (appending && /^\s+\S/.test(text)) {
|
|
152
|
+
current = `${current} ${text.trim()}`
|
|
153
|
+
} else if (/^#{3,}\s/.test(text)) {
|
|
154
|
+
appending = false
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
close()
|
|
158
|
+
|
|
159
|
+
return goals
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseRecord(raw: string): DriveRecord | string {
|
|
163
|
+
let parsed: unknown
|
|
164
|
+
try {
|
|
165
|
+
parsed = JSON.parse(raw)
|
|
166
|
+
} catch {
|
|
167
|
+
return `${DRIVE_RECORD} does not parse as JSON.`
|
|
168
|
+
}
|
|
169
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
170
|
+
return `${DRIVE_RECORD} is not an object.`
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const { goals, evidence, worktree } = parsed as Record<string, unknown>
|
|
174
|
+
if (!Array.isArray(goals)) {
|
|
175
|
+
return `${DRIVE_RECORD} carries no "goals" list.`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const entries: DriveGoal[] = []
|
|
179
|
+
for (const [index, entry] of goals.entries()) {
|
|
180
|
+
const { goal, status, reason } = (entry ?? {}) as Record<string, unknown>
|
|
181
|
+
if (typeof goal !== 'string' || goal.trim() === '') {
|
|
182
|
+
return `${DRIVE_RECORD} goals[${index}] carries no "goal" text.`
|
|
183
|
+
}
|
|
184
|
+
if (status !== 'driven' && status !== 'not-driven') {
|
|
185
|
+
return `${DRIVE_RECORD} goals[${index}] has a "status" other than "driven" or "not-driven".`
|
|
186
|
+
}
|
|
187
|
+
entries.push({
|
|
188
|
+
goal,
|
|
189
|
+
status,
|
|
190
|
+
...(typeof reason === 'string' && { reason }),
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (
|
|
195
|
+
evidence !== undefined &&
|
|
196
|
+
(!Array.isArray(evidence) ||
|
|
197
|
+
!evidence.every((item): item is string => typeof item === 'string'))
|
|
198
|
+
) {
|
|
199
|
+
return `${DRIVE_RECORD} carries an "evidence" field that is not a list of paths.`
|
|
200
|
+
}
|
|
201
|
+
if (worktree !== undefined && typeof worktree !== 'string') {
|
|
202
|
+
return `${DRIVE_RECORD} carries a "worktree" that is not a path.`
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
goals: entries,
|
|
207
|
+
evidence: evidence ?? [],
|
|
208
|
+
...(worktree !== undefined && { worktree }),
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isEvidencePath(path: string): boolean {
|
|
213
|
+
if (isAbsolute(path)) return false
|
|
214
|
+
const segments = normalize(path).split(/[\\/]/)
|
|
215
|
+
return (
|
|
216
|
+
segments[0] === EVIDENCE_DIR &&
|
|
217
|
+
segments.length > 1 &&
|
|
218
|
+
!segments.includes('..')
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The scratch key for a topic, with any ordinal dropped, so a baseline taken
|
|
224
|
+
* before the intake folder is numbered and a check naming the numbered folder
|
|
225
|
+
* meet at one file. Undefined for a slug that could leave scratch.
|
|
226
|
+
*/
|
|
227
|
+
function bareSlug(slug: string): string | undefined {
|
|
228
|
+
if (!/^[a-z0-9][a-z0-9.-]*$/i.test(slug) || slug.includes('..')) {
|
|
229
|
+
return undefined
|
|
230
|
+
}
|
|
231
|
+
const ordinal = extractOrdinal(slug)
|
|
232
|
+
return ordinal === '' ? slug : slug.slice(ordinal.length + 1)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function baselinePath(root: string, bare: string): string {
|
|
236
|
+
return join(root, SCRATCH, bare, 'baseline.json')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function badSlug(slug: string): DriveRefused {
|
|
240
|
+
return refuse(
|
|
241
|
+
'bad-input',
|
|
242
|
+
`Not an intake slug: ${slug}. Pass the folder's topic, as in app-drive-1-0.`,
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Stores the main checkout's status before a drive, so the check can tell a
|
|
248
|
+
* write the drive made from one already in the tree. Scratch rather than the
|
|
249
|
+
* intake folder, since the baseline is safe to lose once the check passes and
|
|
250
|
+
* the folder holds findings alone. A second call replaces the first.
|
|
251
|
+
*/
|
|
252
|
+
export async function recordBaseline(
|
|
253
|
+
root: string,
|
|
254
|
+
slug: string,
|
|
255
|
+
): Promise<BaselineOutcome> {
|
|
256
|
+
const bare = bareSlug(slug)
|
|
257
|
+
if (bare === undefined) return badSlug(slug)
|
|
258
|
+
|
|
259
|
+
const status = await readWorktreeStatus(root)
|
|
260
|
+
if (status === undefined) {
|
|
261
|
+
return refuse('unreadable-tree', `git cannot read the status of ${root}.`)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const path = baselinePath(root, bare)
|
|
265
|
+
await mkdir(join(path, '..'), { recursive: true })
|
|
266
|
+
await writeFile(path, `${JSON.stringify(status)}\n`)
|
|
267
|
+
|
|
268
|
+
return { ok: true, slug: bare, path, entries: status.length }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Checks a finished drive against its intake folder's `drive.json`: every
|
|
273
|
+
* goal mapped or skipped with a reason, the main checkout unchanged since the
|
|
274
|
+
* baseline, the evidence inside the folder's `evidence/`, and the drive's
|
|
275
|
+
* worktree gone. It reports what failed and never reverts a changed path,
|
|
276
|
+
* since only the operator knows which of them to keep.
|
|
277
|
+
*/
|
|
278
|
+
export async function checkDrive(
|
|
279
|
+
root: string,
|
|
280
|
+
slug: string,
|
|
281
|
+
): Promise<CheckOutcome> {
|
|
282
|
+
const bare = bareSlug(slug)
|
|
283
|
+
if (bare === undefined) return badSlug(slug)
|
|
284
|
+
|
|
285
|
+
const folder = await openFolder(root, slug)
|
|
286
|
+
if (typeof folder !== 'string') return folder
|
|
287
|
+
|
|
288
|
+
const recordPath = join(folder, DRIVE_RECORD)
|
|
289
|
+
if (!existsSync(recordPath)) {
|
|
290
|
+
return refuse(
|
|
291
|
+
'no-record',
|
|
292
|
+
`No ${DRIVE_RECORD} in ${basename(folder)}, so no drive was recorded.`,
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
const record = parseRecord(await readFile(recordPath, 'utf8'))
|
|
296
|
+
if (typeof record === 'string') return refuse('bad-record', record)
|
|
297
|
+
|
|
298
|
+
const requirements = join(root, requirementsRel(root))
|
|
299
|
+
if (!existsSync(requirements)) {
|
|
300
|
+
return refuse(
|
|
301
|
+
'no-requirements',
|
|
302
|
+
`No requirements record at ${requirementsRel(root)}, so no goal can be checked.`,
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
const goals = readGoals(await readFile(requirements, 'utf8'))
|
|
306
|
+
|
|
307
|
+
let baseline: readonly StatusEntry[]
|
|
308
|
+
try {
|
|
309
|
+
baseline = JSON.parse(await readFile(baselinePath(root, bare), 'utf8'))
|
|
310
|
+
} catch {
|
|
311
|
+
return refuse(
|
|
312
|
+
'no-baseline',
|
|
313
|
+
`No baseline for ${bare}. Run canon intake drive-check ${bare} --baseline before the drive.`,
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
const current = await readWorktreeStatus(root)
|
|
317
|
+
if (current === undefined) {
|
|
318
|
+
return refuse('unreadable-tree', `git cannot read the status of ${root}.`)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const entries = new Map(
|
|
322
|
+
record.goals.map((entry) => [normalizeGoal(entry.goal), entry]),
|
|
323
|
+
)
|
|
324
|
+
const known = new Set(goals)
|
|
325
|
+
const unreasoned = goals.filter((goal) => {
|
|
326
|
+
const entry = entries.get(goal)
|
|
327
|
+
return entry?.status === 'not-driven' && !entry.reason?.trim()
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
const findings: DriveFindings = {
|
|
331
|
+
unmapped: goals.filter((goal) => !entries.has(goal)),
|
|
332
|
+
unreasoned,
|
|
333
|
+
unknown: record.goals
|
|
334
|
+
.filter((entry) => !known.has(normalizeGoal(entry.goal)))
|
|
335
|
+
.map((entry) => entry.goal),
|
|
336
|
+
changed: changedSince(baseline, current),
|
|
337
|
+
strayEvidence: record.evidence.filter((path) => !isEvidencePath(path)),
|
|
338
|
+
missingEvidence: record.evidence.filter(
|
|
339
|
+
(path) => isEvidencePath(path) && !existsSync(join(folder, path)),
|
|
340
|
+
),
|
|
341
|
+
worktreeLeft:
|
|
342
|
+
record.worktree !== undefined && existsSync(record.worktree)
|
|
343
|
+
? [record.worktree]
|
|
344
|
+
: [],
|
|
345
|
+
}
|
|
346
|
+
const failed = Object.values(findings).some((list) => list.length > 0)
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
ok: true,
|
|
350
|
+
slug: basename(folder),
|
|
351
|
+
reason: failed ? 'failed' : 'clean',
|
|
352
|
+
goals: goals.length,
|
|
353
|
+
findings,
|
|
354
|
+
}
|
|
355
|
+
}
|
package/src/intake/folder.ts
CHANGED
|
@@ -142,7 +142,7 @@ export function extractOrdinal(name: string): string {
|
|
|
142
142
|
return /^\d{2,}-/.exec(name)?.[0].slice(0, -1) ?? ''
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
async function openFolder(
|
|
145
|
+
export async function openFolder(
|
|
146
146
|
root: string,
|
|
147
147
|
slug: string,
|
|
148
148
|
): Promise<string | IntakeRefused> {
|
package/src/markdown/scan.ts
CHANGED
|
@@ -122,6 +122,16 @@ export function bodyLines(source: string): BodyLine[] {
|
|
|
122
122
|
)
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Counts whitespace-delimited tokens, the unit a record's word cap is stated
|
|
127
|
+
* in. A code span counts as the tokens it holds, so a line citing a long path
|
|
128
|
+
* pays for it the way a reader does. A caller gating on the count hands in
|
|
129
|
+
* prose with fenced lines already dropped.
|
|
130
|
+
*/
|
|
131
|
+
export function wordCount(text: string): number {
|
|
132
|
+
return text.match(/\S+/g)?.length ?? 0
|
|
133
|
+
}
|
|
134
|
+
|
|
125
135
|
/**
|
|
126
136
|
* Drops every fenced block from raw text, frontmatter included as content.
|
|
127
137
|
*
|
package/src/pr/drive-copy.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import {
|
|
2
|
+
cpSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { isAbsolute, join, relative, sep } from 'node:path'
|
|
6
10
|
|
|
7
11
|
/** Where a project names the folders its running preview saves into. */
|
|
8
12
|
export const PREVIEW_CONFIG_REL = join('canon', 'config', 'preview.json')
|
|
@@ -28,6 +32,8 @@ const BRANCH_TYPES = new Set([
|
|
|
28
32
|
export interface PreviewConfig {
|
|
29
33
|
readonly writes: readonly string[]
|
|
30
34
|
readonly env: string
|
|
35
|
+
/** Paths inside a declared path that a rebuild leaves as the server wrote them. */
|
|
36
|
+
readonly keep: readonly string[]
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
export type PreviewConfigRead =
|
|
@@ -39,13 +45,6 @@ export type PreviewConfigRead =
|
|
|
39
45
|
readonly detail: string
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
export interface StatusEntry {
|
|
43
|
-
readonly path: string
|
|
44
|
-
readonly status: string
|
|
45
|
-
/** The content digest, or null where no regular file stands at the path. */
|
|
46
|
-
readonly digest: string | null
|
|
47
|
-
}
|
|
48
|
-
|
|
49
48
|
/**
|
|
50
49
|
* The branch-to-slug transform every branch-derived name shares: a leading
|
|
51
50
|
* type segment comes off when a further segment follows it, and each
|
|
@@ -68,12 +67,25 @@ function isContained(path: string): boolean {
|
|
|
68
67
|
)
|
|
69
68
|
}
|
|
70
69
|
|
|
70
|
+
/** Whether `path` is `parent` or stands below it, both relative to one root. */
|
|
71
|
+
function isWithin(path: string, parent: string): boolean {
|
|
72
|
+
const rel = relative(join('/', parent), join('/', path))
|
|
73
|
+
return (
|
|
74
|
+
rel === '' ||
|
|
75
|
+
(rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
71
79
|
/**
|
|
72
|
-
* Reads `canon/config/preview.json` as
|
|
80
|
+
* Reads `canon/config/preview.json` as
|
|
81
|
+
* `{ "writes": [...], "keep": [...], "env": "<NAME>" }`.
|
|
73
82
|
* A file that does not parse refuses rather than reading as absent, since a
|
|
74
83
|
* typo there would otherwise drive the real folders while the pass reports a
|
|
75
|
-
* copy. A `writes` path that is absolute or climbs with `..`
|
|
76
|
-
* copy never reads or writes outside its two roots
|
|
84
|
+
* copy. A `writes` or `keep` path that is absolute or climbs with `..`
|
|
85
|
+
* refuses, so the copy never reads or writes outside its two roots, and a
|
|
86
|
+
* `keep` path outside every `writes` path refuses, since the rebuild would
|
|
87
|
+
* never reach it, and so does one equal to a `writes` path, which would be
|
|
88
|
+
* declared both reset and kept.
|
|
77
89
|
*/
|
|
78
90
|
export function loadPreviewConfig(worktree: string): PreviewConfigRead {
|
|
79
91
|
const path = join(worktree, PREVIEW_CONFIG_REL)
|
|
@@ -97,7 +109,7 @@ export function loadPreviewConfig(worktree: string): PreviewConfigRead {
|
|
|
97
109
|
}
|
|
98
110
|
}
|
|
99
111
|
|
|
100
|
-
const { writes, env } = parsed as Record<string, unknown>
|
|
112
|
+
const { writes, env, keep = [] } = parsed as Record<string, unknown>
|
|
101
113
|
if (
|
|
102
114
|
!Array.isArray(writes) ||
|
|
103
115
|
!writes.every((item): item is string => typeof item === 'string')
|
|
@@ -108,6 +120,16 @@ export function loadPreviewConfig(worktree: string): PreviewConfigRead {
|
|
|
108
120
|
detail: `${PREVIEW_CONFIG_REL} carries no "writes" list of paths.`,
|
|
109
121
|
}
|
|
110
122
|
}
|
|
123
|
+
if (
|
|
124
|
+
!Array.isArray(keep) ||
|
|
125
|
+
!keep.every((item): item is string => typeof item === 'string')
|
|
126
|
+
) {
|
|
127
|
+
return {
|
|
128
|
+
kind: 'refused',
|
|
129
|
+
reason: 'bad-config',
|
|
130
|
+
detail: `${PREVIEW_CONFIG_REL} names a "keep" that is not a list of paths.`,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
111
133
|
if (env !== undefined && (typeof env !== 'string' || env === '')) {
|
|
112
134
|
return {
|
|
113
135
|
kind: 'refused',
|
|
@@ -116,7 +138,7 @@ export function loadPreviewConfig(worktree: string): PreviewConfigRead {
|
|
|
116
138
|
}
|
|
117
139
|
}
|
|
118
140
|
|
|
119
|
-
const unsafe = writes.find((item) => !isContained(item))
|
|
141
|
+
const unsafe = [...writes, ...keep].find((item) => !isContained(item))
|
|
120
142
|
if (unsafe !== undefined) {
|
|
121
143
|
return {
|
|
122
144
|
kind: 'refused',
|
|
@@ -125,94 +147,85 @@ export function loadPreviewConfig(worktree: string): PreviewConfigRead {
|
|
|
125
147
|
}
|
|
126
148
|
}
|
|
127
149
|
|
|
150
|
+
const stray = keep.find(
|
|
151
|
+
(item) => !writes.some((path) => isWithin(item, path)),
|
|
152
|
+
)
|
|
153
|
+
if (stray !== undefined) {
|
|
154
|
+
return {
|
|
155
|
+
kind: 'refused',
|
|
156
|
+
reason: 'bad-config',
|
|
157
|
+
detail: `${PREVIEW_CONFIG_REL} keeps "${stray}", which no "writes" path holds.`,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const clash = keep.find((item) =>
|
|
162
|
+
writes.some((path) => isWithin(item, path) && isWithin(path, item)),
|
|
163
|
+
)
|
|
164
|
+
if (clash !== undefined) {
|
|
165
|
+
return {
|
|
166
|
+
kind: 'refused',
|
|
167
|
+
reason: 'bad-config',
|
|
168
|
+
detail: `${PREVIEW_CONFIG_REL} keeps "${clash}", which is also a "writes" path, so the rebuild would never reset it.`,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
128
172
|
return {
|
|
129
173
|
kind: 'read',
|
|
130
|
-
config: { writes, env: env ?? DEFAULT_PREVIEW_ENV },
|
|
174
|
+
config: { writes, env: env ?? DEFAULT_PREVIEW_ENV, keep },
|
|
131
175
|
}
|
|
132
176
|
}
|
|
133
177
|
|
|
134
178
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* is
|
|
179
|
+
* Removes `target` except the `keep` paths at or below it, where `rel` is
|
|
180
|
+
* `target` relative to the root `keep` is read against. A folder holding a
|
|
181
|
+
* kept path is emptied around it rather than removed.
|
|
182
|
+
*/
|
|
183
|
+
function clearAround(
|
|
184
|
+
target: string,
|
|
185
|
+
rel: string,
|
|
186
|
+
keep: readonly string[],
|
|
187
|
+
): void {
|
|
188
|
+
if (keep.includes(rel)) return
|
|
189
|
+
const stat = lstatSync(target, { throwIfNoEntry: false })
|
|
190
|
+
if (stat === undefined) return
|
|
191
|
+
if (!stat.isDirectory() || !keep.some((path) => isWithin(path, rel))) {
|
|
192
|
+
rmSync(target, { recursive: true, force: true })
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
for (const entry of readdirSync(target)) {
|
|
196
|
+
clearAround(join(target, entry), join(rel, entry), keep)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Resets each declared path in the copy from the worktree, so the last pass's
|
|
202
|
+
* drives never reach the next one, and leaves everything else under the copy
|
|
203
|
+
* root as it is. A `keep` path inside a declared path is neither cleared nor
|
|
204
|
+
* copied, since the server running on the copy owns it. A path the worktree
|
|
205
|
+
* does not hold has its stale copy removed and is named rather than failing
|
|
206
|
+
* the copy.
|
|
138
207
|
*/
|
|
139
208
|
export function buildDriveCopy(
|
|
140
209
|
worktree: string,
|
|
141
210
|
copyRoot: string,
|
|
142
211
|
writes: readonly string[],
|
|
212
|
+
keep: readonly string[] = [],
|
|
143
213
|
): { readonly copied: readonly string[]; readonly missing: readonly string[] } {
|
|
144
|
-
|
|
214
|
+
const kept = keep.map((path) => relative(worktree, join(worktree, path)))
|
|
145
215
|
const copied: string[] = []
|
|
146
216
|
const missing: string[] = []
|
|
147
217
|
for (const path of writes) {
|
|
148
218
|
const source = join(worktree, path)
|
|
219
|
+
clearAround(join(copyRoot, path), relative(worktree, source), kept)
|
|
149
220
|
if (!existsSync(source)) {
|
|
150
221
|
missing.push(path)
|
|
151
222
|
continue
|
|
152
223
|
}
|
|
153
|
-
cpSync(source, join(copyRoot, path), {
|
|
224
|
+
cpSync(source, join(copyRoot, path), {
|
|
225
|
+
recursive: true,
|
|
226
|
+
filter: (item) => !kept.includes(relative(worktree, item)),
|
|
227
|
+
})
|
|
154
228
|
copied.push(path)
|
|
155
229
|
}
|
|
156
230
|
return { copied, missing }
|
|
157
231
|
}
|
|
158
|
-
|
|
159
|
-
function digestOf(path: string): string | null {
|
|
160
|
-
try {
|
|
161
|
-
if (!statSync(path).isFile()) return null
|
|
162
|
-
return createHash('sha1').update(readFileSync(path)).digest('hex')
|
|
163
|
-
} catch {
|
|
164
|
-
return null
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Every entry `git status` reports, untracked files listed one by one so a
|
|
170
|
-
* file a drive adds inside a tracked folder names itself, each with a content
|
|
171
|
-
* digest so a file already dirty at the baseline still reads as changed when
|
|
172
|
-
* a drive writes it again. Undefined when git cannot read the tree.
|
|
173
|
-
*/
|
|
174
|
-
export async function readWorktreeStatus(
|
|
175
|
-
worktree: string,
|
|
176
|
-
): Promise<readonly StatusEntry[] | undefined> {
|
|
177
|
-
const result =
|
|
178
|
-
await $`git -C ${worktree} status --porcelain=v1 -z --untracked-files=all`
|
|
179
|
-
.env(gitEnv())
|
|
180
|
-
.quiet()
|
|
181
|
-
.nothrow()
|
|
182
|
-
if (result.exitCode !== 0) return undefined
|
|
183
|
-
|
|
184
|
-
const tokens = result.stdout.toString().split('\0')
|
|
185
|
-
const entries: StatusEntry[] = []
|
|
186
|
-
for (let index = 0; index < tokens.length; index++) {
|
|
187
|
-
const token = tokens[index] ?? ''
|
|
188
|
-
if (token.length < 4) continue
|
|
189
|
-
const status = token.slice(0, 2)
|
|
190
|
-
const path = token.slice(3)
|
|
191
|
-
// A rename or copy carries its source as the next token.
|
|
192
|
-
if (status.includes('R') || status.includes('C')) index++
|
|
193
|
-
entries.push({ path, status, digest: digestOf(join(worktree, path)) })
|
|
194
|
-
}
|
|
195
|
-
return entries
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* The paths whose status or content differs from the baseline, in the order
|
|
200
|
-
* git reports them. An entry gone since the baseline is not reported, since a
|
|
201
|
-
* worker committing its own edit mid-pass clears it the same way.
|
|
202
|
-
*/
|
|
203
|
-
export function changedSince(
|
|
204
|
-
baseline: readonly StatusEntry[],
|
|
205
|
-
current: readonly StatusEntry[],
|
|
206
|
-
): readonly string[] {
|
|
207
|
-
const before = new Map(baseline.map((entry) => [entry.path, entry]))
|
|
208
|
-
return current
|
|
209
|
-
.filter((entry) => {
|
|
210
|
-
const prior = before.get(entry.path)
|
|
211
|
-
return (
|
|
212
|
-
prior === undefined ||
|
|
213
|
-
prior.status !== entry.status ||
|
|
214
|
-
prior.digest !== entry.digest
|
|
215
|
-
)
|
|
216
|
-
})
|
|
217
|
-
.map((entry) => entry.path)
|
|
218
|
-
}
|