@erclx/canon 4.4.0 → 4.6.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-markdown-propose/REQUIREMENT.md +0 -1
- package/docs/agents/audits.md +6 -6
- package/docs/agents/commands.md +3 -0
- package/docs/agents/context-audit.md +2 -2
- package/docs/agents/driver.md +103 -0
- package/docs/agents/index.md +2 -1
- package/docs/agents/records.md +17 -7
- package/docs/agents/tasks.md +1 -1
- package/package.json +1 -1
- package/scripts/core/check-ignore-parity.sh +9 -3
- package/src/audits/baseline.ts +1 -1
- package/src/browser/engine.ts +50 -7
- package/src/cli.ts +4 -0
- package/src/commands/claude.ts +7 -1
- package/src/commands/context.ts +3 -3
- package/src/commands/design.ts +6 -1
- package/src/commands/driver.ts +266 -0
- package/src/commands/feedback.ts +5 -1
- package/src/commands/gov.ts +2 -1
- package/src/commands/slides.ts +6 -1
- package/src/context/citations.ts +16 -5
- package/src/context/folders.ts +18 -8
- package/src/driver/drive.ts +184 -0
- package/src/driver/probes/details.ts +116 -0
- package/src/driver/probes/diagram.ts +260 -0
- package/src/driver/probes/focus.ts +121 -0
- package/src/driver/probes/viewport.ts +81 -0
- package/src/driver/steps.ts +266 -0
- package/src/gate/measures.ts +1 -1
- package/src/intake/folder.ts +2 -1
- package/src/inventory/walk.ts +6 -18
- package/src/record-root.ts +134 -0
- package/src/records/backup.ts +40 -22
- package/src/records/size.ts +12 -7
- package/src/records/validate.ts +35 -17
- package/src/tasks/answers.ts +14 -7
- package/src/tasks/archive.ts +31 -21
- package/src/teach/workspace.ts +2 -1
- package/tooling/claude/manifest.toml +1 -1
- package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +8 -1
- package/tooling/claude/seeds/.claude/hooks/memory-index.sh +28 -11
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +12 -3
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +4 -0
- package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +28 -10
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import type { Command } from 'commander'
|
|
4
|
+
import { INSTALL_BROWSER, isEngineMissing } from '@/browser/engine'
|
|
5
|
+
import type { DriverRefusal } from '@/driver/drive'
|
|
6
|
+
import { describeViewport } from '@/driver/probes/viewport'
|
|
7
|
+
import { PROBE_NAMES, readDriverPlan } from '@/driver/steps'
|
|
8
|
+
import type { PlanRefusal } from '@/driver/steps'
|
|
9
|
+
import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Holds wiring only. Every browser reference sits behind `loadDriver`, because
|
|
13
|
+
* `src/cli.ts` imports this module at startup and resolving the engine there
|
|
14
|
+
* would put a browser launch in front of every other command.
|
|
15
|
+
*/
|
|
16
|
+
type Driver = typeof import('@/driver/drive')
|
|
17
|
+
|
|
18
|
+
interface RunOptions {
|
|
19
|
+
readonly json?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Spelled here rather than through `plural`, which appends a bare `s` and gets
|
|
24
|
+
* the plural of this noun wrong.
|
|
25
|
+
*/
|
|
26
|
+
function passes(count: number): string {
|
|
27
|
+
return `${count} ${count === 1 ? 'pass' : 'passes'}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What a reader does about each way the run document produced no drive. */
|
|
31
|
+
const PLAN_REFUSALS: Record<PlanRefusal, string> = {
|
|
32
|
+
'unreadable-plan':
|
|
33
|
+
'The run file is not valid JSON, so no step could be read.',
|
|
34
|
+
'no-steps': 'The run names no step, so there is nothing to drive.',
|
|
35
|
+
'no-probes': 'The run names no probe, so every step would measure nothing.',
|
|
36
|
+
'unknown-probe': 'The run names a probe this build does not ship.',
|
|
37
|
+
'bad-step': 'A step is missing something the driver needs to perform it.',
|
|
38
|
+
'no-viewport': 'The run declares no viewport to render at.',
|
|
39
|
+
'no-width': 'The run declares no viewport width.',
|
|
40
|
+
'no-heights':
|
|
41
|
+
'The run names no viewport height, and this command defaults none.',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DRIVE_REFUSALS: Record<DriverRefusal, string> = {
|
|
45
|
+
'browser-missing': 'The browser binary is not installed in this project.',
|
|
46
|
+
'server-unreachable': 'Nothing answered at the URL, so no state was reached.',
|
|
47
|
+
'drive-failed': 'The drive failed against a reachable page.',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function register(program: Command): void {
|
|
51
|
+
program
|
|
52
|
+
.command('drive')
|
|
53
|
+
.description(
|
|
54
|
+
'Walk a page through named interactions and measure each state',
|
|
55
|
+
)
|
|
56
|
+
.argument('<url>', 'Address to drive')
|
|
57
|
+
.argument(
|
|
58
|
+
'<run>',
|
|
59
|
+
'JSON file naming the viewports, the probes, and the steps',
|
|
60
|
+
)
|
|
61
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
62
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
63
|
+
.addHelpText(
|
|
64
|
+
'after',
|
|
65
|
+
[
|
|
66
|
+
'',
|
|
67
|
+
'A render answers about a page as it loads. Every defect that exists',
|
|
68
|
+
'only after a menu opens, an answer is chosen, or the page scrolls is',
|
|
69
|
+
'invisible to one, which is the axis this adds. Probes run after each',
|
|
70
|
+
'step and never on arrival, so a run reaches the load state by opening',
|
|
71
|
+
'with a wait step of its own. Write one wherever canon capture does not',
|
|
72
|
+
'run, since nothing else there measures the page as it first painted.',
|
|
73
|
+
'',
|
|
74
|
+
'The run file carries the viewport, the default probe list, and the',
|
|
75
|
+
'steps. A step names its own probes to override the default. Viewport',
|
|
76
|
+
'heights are never defaulted: the heights a defect hides at are a',
|
|
77
|
+
'property of the layout rather than of this command.',
|
|
78
|
+
'',
|
|
79
|
+
' {',
|
|
80
|
+
' "viewport": { "width": 1440, "heights": [900, 1200, 1500] },',
|
|
81
|
+
' "probes": ["focus", "details"],',
|
|
82
|
+
' "steps": [',
|
|
83
|
+
' { "name": "open the menu", "kind": "click", "target": "#menu" },',
|
|
84
|
+
' { "name": "reach the diagram", "kind": "scroll", "target": "figure",',
|
|
85
|
+
' "probes": ["diagram-geometry", "diagram-strokes"] }',
|
|
86
|
+
' ]',
|
|
87
|
+
' }',
|
|
88
|
+
'',
|
|
89
|
+
'It reports findings and never gates. Every probe here carries a class',
|
|
90
|
+
'of false finding a throwaway version already produced, so a run that',
|
|
91
|
+
'ends the build on its own reading is a claim this catalog has not',
|
|
92
|
+
'earned. Branch on the JSON record instead.',
|
|
93
|
+
'',
|
|
94
|
+
'Needs a reachable page and a browser binary. Install the browser with:',
|
|
95
|
+
` ${INSTALL_BROWSER}`,
|
|
96
|
+
'',
|
|
97
|
+
'Probes:',
|
|
98
|
+
...PROBE_NAMES.map((name) => ` ${name}`),
|
|
99
|
+
'',
|
|
100
|
+
'Step kinds:',
|
|
101
|
+
' click <target> press the first element the selector matches',
|
|
102
|
+
' scroll <target> bring the first match into view',
|
|
103
|
+
' fill <target> type text into the first match',
|
|
104
|
+
' tab [count] advance keyboard focus',
|
|
105
|
+
' wait <ms> hold, for a state the page reaches on its own',
|
|
106
|
+
'',
|
|
107
|
+
'Exit codes:',
|
|
108
|
+
' 0 the drive completed, with its findings reported',
|
|
109
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
110
|
+
'',
|
|
111
|
+
'Examples:',
|
|
112
|
+
' canon drive http://localhost:4173 run.json',
|
|
113
|
+
' canon drive http://localhost:4173 run.json --json',
|
|
114
|
+
'',
|
|
115
|
+
].join('\n'),
|
|
116
|
+
)
|
|
117
|
+
.action(async (url: string, run: string, opts: RunOptions) => {
|
|
118
|
+
process.exitCode = await runDriver(url, run, opts)
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function runDriver(
|
|
123
|
+
url: string,
|
|
124
|
+
runPath: string,
|
|
125
|
+
opts: RunOptions,
|
|
126
|
+
): Promise<number> {
|
|
127
|
+
const emitJson = opts.json ?? false
|
|
128
|
+
const path = resolve(runPath)
|
|
129
|
+
|
|
130
|
+
intro(`canon drive ${url}`)
|
|
131
|
+
|
|
132
|
+
let source: string
|
|
133
|
+
try {
|
|
134
|
+
source = readFileSync(path, 'utf8')
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return refuse(
|
|
137
|
+
emitJson,
|
|
138
|
+
url,
|
|
139
|
+
'no-run-file',
|
|
140
|
+
`No run file at ${path}. ${error instanceof Error ? error.message : String(error)}`,
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const read = readDriverPlan(source)
|
|
145
|
+
if (read.kind === 'refused') {
|
|
146
|
+
return refuse(
|
|
147
|
+
emitJson,
|
|
148
|
+
url,
|
|
149
|
+
read.reason,
|
|
150
|
+
`${PLAN_REFUSALS[read.reason]} ${read.detail}`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const { plan } = read
|
|
155
|
+
|
|
156
|
+
logStep('Scope')
|
|
157
|
+
logInfo(
|
|
158
|
+
`${plural(plan.steps.length, 'step')} at ${plan.viewports.map(describeViewport).join(', ')}, measuring ${plan.probes.join(', ')}`,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const driver = await loadDriver()
|
|
162
|
+
if (!driver) {
|
|
163
|
+
logStep('Browser')
|
|
164
|
+
logError('the browser engine is not installed in this project')
|
|
165
|
+
logWarn(`Install it with: ${INSTALL_BROWSER}`)
|
|
166
|
+
outro()
|
|
167
|
+
emit(emitJson, {
|
|
168
|
+
url,
|
|
169
|
+
run: path,
|
|
170
|
+
reason: 'engine-missing',
|
|
171
|
+
install: INSTALL_BROWSER,
|
|
172
|
+
})
|
|
173
|
+
return 1
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const result = await driver.drive({ url, plan })
|
|
177
|
+
|
|
178
|
+
if (result.status === 'failed') {
|
|
179
|
+
logStep('Refused')
|
|
180
|
+
logWarn(DRIVE_REFUSALS[result.reason])
|
|
181
|
+
if (result.reason === 'server-unreachable') {
|
|
182
|
+
logWarn(`Start whatever serves ${url}, then run this again.`)
|
|
183
|
+
}
|
|
184
|
+
if (result.reason === 'browser-missing') {
|
|
185
|
+
logWarn(`Install the browser binary with: ${INSTALL_BROWSER}`)
|
|
186
|
+
}
|
|
187
|
+
logWarn(result.message.split('\n')[0] ?? '')
|
|
188
|
+
outro()
|
|
189
|
+
emit(emitJson, {
|
|
190
|
+
url,
|
|
191
|
+
run: path,
|
|
192
|
+
reason: result.reason,
|
|
193
|
+
message: result.message,
|
|
194
|
+
...(result.reason === 'browser-missing'
|
|
195
|
+
? { install: INSTALL_BROWSER }
|
|
196
|
+
: {}),
|
|
197
|
+
})
|
|
198
|
+
return 1
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
logStep('Passes')
|
|
202
|
+
for (const pass of result.passes) {
|
|
203
|
+
logInfo(
|
|
204
|
+
`${pass.viewport} ${pass.step} ${plural(pass.probes, 'probe')} ${plural(pass.findings, 'finding')}`,
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
logStep('Findings')
|
|
209
|
+
if (result.findings.length === 0) {
|
|
210
|
+
logInfo(
|
|
211
|
+
`nothing across ${passes(result.passes.length)}, which is a reading rather than a pass mark`,
|
|
212
|
+
)
|
|
213
|
+
} else {
|
|
214
|
+
logInfo(
|
|
215
|
+
`${plural(result.findings.length, 'finding')} across ${passes(result.passes.length)}`,
|
|
216
|
+
)
|
|
217
|
+
for (const finding of result.findings) {
|
|
218
|
+
logInfo(` ${finding.probe} ${finding.selector}`)
|
|
219
|
+
logInfo(` ${finding.detail}`)
|
|
220
|
+
logInfo(` after ${finding.step} at ${finding.viewport}`)
|
|
221
|
+
logInfo(` ${finding.measured}`)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
outro()
|
|
225
|
+
|
|
226
|
+
emit(emitJson, {
|
|
227
|
+
url,
|
|
228
|
+
run: path,
|
|
229
|
+
viewports: plan.viewports,
|
|
230
|
+
passes: result.passes,
|
|
231
|
+
findings: result.findings,
|
|
232
|
+
durationMs: result.durationMs,
|
|
233
|
+
})
|
|
234
|
+
return 0
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Frames a refusal on stderr and puts the record on stdout, so an operator
|
|
239
|
+
* reading the terminal sees the reason rather than a command that appeared to
|
|
240
|
+
* do nothing.
|
|
241
|
+
*/
|
|
242
|
+
function refuse(
|
|
243
|
+
emitJson: boolean,
|
|
244
|
+
url: string,
|
|
245
|
+
reason: string,
|
|
246
|
+
message: string,
|
|
247
|
+
): number {
|
|
248
|
+
logStep('Refused')
|
|
249
|
+
logWarn(message)
|
|
250
|
+
outro()
|
|
251
|
+
emit(emitJson, { url, reason, message })
|
|
252
|
+
return 1
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function loadDriver(): Promise<Driver | undefined> {
|
|
256
|
+
try {
|
|
257
|
+
return await import('@/driver/drive')
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (isEngineMissing(error)) return undefined
|
|
260
|
+
throw error
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function emit(json: boolean, record: unknown): void {
|
|
265
|
+
if (json) process.stdout.write(`${JSON.stringify(record)}\n`)
|
|
266
|
+
}
|
package/src/commands/feedback.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from 'node:path'
|
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
4
|
import { deriveSlug, deriveTitle } from '@/commands/feedback-format'
|
|
5
5
|
import { PROJECT_ROOT } from '@/project-root'
|
|
6
|
+
import { creationRel } from '@/record-root'
|
|
6
7
|
import { createGithubIssue } from '@/github'
|
|
7
8
|
import { frameError, frameSuccess, palette } from '@/ui'
|
|
8
9
|
|
|
@@ -39,7 +40,10 @@ function isToolkitSource(): boolean {
|
|
|
39
40
|
* single ignore entry and the single backed-folder entry it already had.
|
|
40
41
|
*/
|
|
41
42
|
function writeLocal(body: string): string {
|
|
42
|
-
|
|
43
|
+
// Creation, so the destination is the creation default rather than the
|
|
44
|
+
// resolved read root. A toolkit checkout that has migrated its records already
|
|
45
|
+
// carries the folder and resolves the same path either way.
|
|
46
|
+
const relativeDir = creationRel('review', 'feedback')
|
|
43
47
|
const reviewDir = join(PROJECT_ROOT, relativeDir)
|
|
44
48
|
mkdirSync(reviewDir, { recursive: true })
|
|
45
49
|
const filename = `feedback-${deriveSlug(body)}-${timestamp()}.md`
|
package/src/commands/gov.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
scanCounts,
|
|
10
10
|
} from '@/counts/scan'
|
|
11
11
|
import { PROJECT_ROOT } from '@/project-root'
|
|
12
|
+
import { creationRel, SCRATCH } from '@/record-root'
|
|
12
13
|
import { createGovAdapter } from '@/gov/adapter'
|
|
13
14
|
import { regenConsumedRules } from '@/gov/consumed'
|
|
14
15
|
import { installRules, lookupRules } from '@/gov/install'
|
|
@@ -65,7 +66,7 @@ import {
|
|
|
65
66
|
select,
|
|
66
67
|
} from '@/ui'
|
|
67
68
|
|
|
68
|
-
const PAYLOAD_REL =
|
|
69
|
+
const PAYLOAD_REL = creationRel(SCRATCH, 'gov', 'rules.md')
|
|
69
70
|
const RULES_REL = join('.claude', 'rules')
|
|
70
71
|
|
|
71
72
|
interface InstallOptions {
|
package/src/commands/slides.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
|
+
import { creationRel } from '@/record-root'
|
|
4
5
|
import { LAYOUTS } from '@/slides/layouts'
|
|
5
6
|
import { openDeck } from '@/slides/open'
|
|
6
7
|
import { renderSlidesDoc } from '@/slides/render'
|
|
@@ -16,7 +17,11 @@ export function register(program: Command): void {
|
|
|
16
17
|
.command('render')
|
|
17
18
|
.description('Render a SLIDES.md source into a PowerPoint deck')
|
|
18
19
|
.option('-s, --source <path>', 'Source SLIDES.md path', '.claude/SLIDES.md')
|
|
19
|
-
.option(
|
|
20
|
+
.option(
|
|
21
|
+
'-o, --out <path>',
|
|
22
|
+
'Output directory',
|
|
23
|
+
creationRel('review', 'slides'),
|
|
24
|
+
)
|
|
20
25
|
.option('-v, --variant <variant>', 'Override variant (light or dark)')
|
|
21
26
|
.option(
|
|
22
27
|
'-m, --mirror <path>',
|
package/src/context/citations.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { readFile } from 'node:fs/promises'
|
|
3
3
|
import { resolve } from 'node:path'
|
|
4
4
|
import { listRepositoryFiles } from '@/git-files'
|
|
5
|
+
import { RECORD_ROOTS } from '@/record-root'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Suppresses citation checking for the source line carrying it.
|
|
@@ -76,21 +77,31 @@ export function isFixture(rel: string): boolean {
|
|
|
76
77
|
return rel.split('/').some((segment) => FIXTURE_SEGMENTS.includes(segment))
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Both record roots are spelled, so a citation into a folder that has moved is
|
|
82
|
+
* still resolved. A pattern fixed at one root matches nothing after the move and
|
|
83
|
+
* reports nothing, which is a stale reference passing the check that exists to
|
|
84
|
+
* find it rather than a check that fails.
|
|
85
|
+
*/
|
|
79
86
|
export function citationPattern(folders: readonly string[]): RegExp {
|
|
80
|
-
const names = folders.map((name) =>
|
|
81
|
-
|
|
82
|
-
|
|
87
|
+
const names = folders.map((name) => escape(name))
|
|
88
|
+
const roots = RECORD_ROOTS.map((name) => escape(name))
|
|
89
|
+
|
|
83
90
|
return new RegExp(
|
|
84
|
-
|
|
91
|
+
`(?:${roots.join('|')})/(?:${names.join('|')})/[A-Za-z0-9._/-]+\\.md`,
|
|
85
92
|
'g',
|
|
86
93
|
)
|
|
87
94
|
}
|
|
88
95
|
|
|
96
|
+
function escape(value: string): string {
|
|
97
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
98
|
+
}
|
|
99
|
+
|
|
89
100
|
/**
|
|
90
101
|
* A backticked filename carrying no folder, the form a reference takes when it
|
|
91
102
|
* names a sibling rather than a path.
|
|
92
103
|
*
|
|
93
|
-
* `citationPattern` spells
|
|
104
|
+
* `citationPattern` spells a record-root prefix and cannot see this shape at
|
|
94
105
|
* all, which is the reason the form rule exists. Widening that expression to
|
|
95
106
|
* admit a bare name was the alternative and it puts one match in the position of
|
|
96
107
|
* answering two questions, since a spelled path is a reference by construction
|
package/src/context/folders.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { dirname, relative, resolve } from 'node:path'
|
|
3
3
|
import { INDEX_FILE, listIndexes } from '@/indexes/walk'
|
|
4
|
+
import { RECORD_ROOTS } from '@/record-root'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
* Folder names under
|
|
7
|
+
* Folder names under a record root audited by default.
|
|
7
8
|
*
|
|
8
9
|
* A named list rather than the index-plus-entry contract read off disk, so a
|
|
9
10
|
* generated tree satisfying that contract is never measured against a rule
|
|
@@ -12,8 +13,8 @@ import { INDEX_FILE, listIndexes } from '@/indexes/walk'
|
|
|
12
13
|
* here.
|
|
13
14
|
*
|
|
14
15
|
* It doubles as the citation check's scope, since `citationPattern` spells only
|
|
15
|
-
* these names. A
|
|
16
|
-
*
|
|
16
|
+
* these names. A folder left off the list is never resolved, so a path into one
|
|
17
|
+
* goes stale silently rather than failing a push.
|
|
17
18
|
*/
|
|
18
19
|
export const DEFAULT_FOLDERS: readonly string[] = [
|
|
19
20
|
'context',
|
|
@@ -21,8 +22,17 @@ export const DEFAULT_FOLDERS: readonly string[] = [
|
|
|
21
22
|
'wireframes',
|
|
22
23
|
]
|
|
23
24
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The bases every folder in the default list is looked for under, in the record
|
|
27
|
+
* roots' own precedence order.
|
|
28
|
+
*
|
|
29
|
+
* `diagrams` is the one name here that is a session record and moves with them,
|
|
30
|
+
* so the list has to carry the root it moves to. `context` and `wireframes` are
|
|
31
|
+
* tracked and stay, which leaves them resolvable at a root nothing will ever put
|
|
32
|
+
* them under. That costs one `existsSync` apiece and is cheaper than a per-name
|
|
33
|
+
* base map that would state the same split twice.
|
|
34
|
+
*/
|
|
35
|
+
const CLAUDE_BASES: readonly string[] = RECORD_ROOTS
|
|
26
36
|
|
|
27
37
|
/** The project root, reached only by a name the caller asked for. */
|
|
28
38
|
const ROOT_BASE = '.'
|
|
@@ -58,7 +68,7 @@ export interface AuditedFolder {
|
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
/**
|
|
61
|
-
* Names the requested
|
|
71
|
+
* Names the requested record-root folders that actually exist, which is the
|
|
62
72
|
* citation check's scope.
|
|
63
73
|
*
|
|
64
74
|
* A skill or seed pointing into `.claude/wireframes/` is a live instruction for
|
|
@@ -76,7 +86,7 @@ export function presentNames(folders: readonly AuditedFolder[]): string[] {
|
|
|
76
86
|
return [
|
|
77
87
|
...new Set(
|
|
78
88
|
folders
|
|
79
|
-
.filter((folder) => folder.base
|
|
89
|
+
.filter((folder) => CLAUDE_BASES.includes(folder.base))
|
|
80
90
|
.map((folder) => folder.name),
|
|
81
91
|
),
|
|
82
92
|
]
|
|
@@ -157,7 +167,7 @@ export async function resolveFolders(
|
|
|
157
167
|
names: readonly string[] = DEFAULT_FOLDERS,
|
|
158
168
|
{ canResolveAtRoot = false }: ResolveOptions = {},
|
|
159
169
|
): Promise<FolderResolution> {
|
|
160
|
-
const bases = canResolveAtRoot ? [
|
|
170
|
+
const bases = canResolveAtRoot ? [...CLAUDE_BASES, ROOT_BASE] : CLAUDE_BASES
|
|
161
171
|
const folders: AuditedFolder[] = []
|
|
162
172
|
const missing: string[] = []
|
|
163
173
|
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { chromium } from 'playwright-core'
|
|
2
|
+
import type { Browser, Page } from 'playwright-core'
|
|
3
|
+
import { isBrowserMissing, isServerUnreachable } from '@/browser/engine'
|
|
4
|
+
import { probeDetails } from '@/driver/probes/details'
|
|
5
|
+
import {
|
|
6
|
+
probeDiagramGeometry,
|
|
7
|
+
probeDiagramStrokes,
|
|
8
|
+
} from '@/driver/probes/diagram'
|
|
9
|
+
import { probeFocus } from '@/driver/probes/focus'
|
|
10
|
+
import { describeViewport } from '@/driver/probes/viewport'
|
|
11
|
+
import type { Viewport } from '@/driver/probes/viewport'
|
|
12
|
+
import { runStep, stepProbes } from '@/driver/steps'
|
|
13
|
+
import type {
|
|
14
|
+
DriverPlan,
|
|
15
|
+
Finding,
|
|
16
|
+
PlacedFinding,
|
|
17
|
+
ProbeName,
|
|
18
|
+
} from '@/driver/steps'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Walks a page through a caller's interaction sequence and measures each state
|
|
22
|
+
* it reaches. Every browser reference the driver adds lives here, and
|
|
23
|
+
* `src/commands/driver.ts` reaches it through a dynamic import so no other
|
|
24
|
+
* command resolves the engine at startup.
|
|
25
|
+
*
|
|
26
|
+
* Like `@/demo/drive` and `@/inventory/walk`, and unlike `@/capture/render`,
|
|
27
|
+
* this module ships. A command whose whole purpose is measuring someone else's
|
|
28
|
+
* page cannot stay toolkit-only, and nothing here reaches into `@/capture/`,
|
|
29
|
+
* which `files` in `package.json` excludes from the published package, so the
|
|
30
|
+
* two surfaces move independently.
|
|
31
|
+
*
|
|
32
|
+
* What separates it from `canon capture` is the axis it adds. A render answers
|
|
33
|
+
* about a page as it loads, and every defect that exists only after a menu
|
|
34
|
+
* opens, an answer is chosen, or the page scrolls is invisible to one. Probes
|
|
35
|
+
* therefore run after a step rather than on arrival, and a run reaches the load
|
|
36
|
+
* state by opening with a `wait` step of its own. That matters most where
|
|
37
|
+
* capture does not run, since it is toolkit-only and this command is then the
|
|
38
|
+
* only thing measuring the page at all.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Each probe keyed by the name a caller writes in the run. */
|
|
42
|
+
const PROBES: Record<ProbeName, (page: Page) => Promise<Finding[]>> = {
|
|
43
|
+
focus: probeFocus,
|
|
44
|
+
details: probeDetails,
|
|
45
|
+
'diagram-geometry': probeDiagramGeometry,
|
|
46
|
+
'diagram-strokes': probeDiagramStrokes,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DriveOptions {
|
|
50
|
+
readonly url: string
|
|
51
|
+
readonly plan: DriverPlan
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One step measured at one viewport, so a sweep reads as passes rather than a total. */
|
|
55
|
+
export interface PassReading {
|
|
56
|
+
readonly viewport: string
|
|
57
|
+
readonly step: string
|
|
58
|
+
readonly probes: number
|
|
59
|
+
readonly findings: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type DriverRefusal =
|
|
63
|
+
| 'browser-missing'
|
|
64
|
+
| 'server-unreachable'
|
|
65
|
+
| 'drive-failed'
|
|
66
|
+
|
|
67
|
+
export type DriverResult =
|
|
68
|
+
| {
|
|
69
|
+
readonly status: 'driven'
|
|
70
|
+
readonly findings: readonly PlacedFinding[]
|
|
71
|
+
readonly passes: readonly PassReading[]
|
|
72
|
+
readonly durationMs: number
|
|
73
|
+
}
|
|
74
|
+
| {
|
|
75
|
+
readonly status: 'failed'
|
|
76
|
+
readonly reason: DriverRefusal
|
|
77
|
+
readonly message: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function failed(reason: DriverRefusal, error: unknown): DriverResult {
|
|
81
|
+
return {
|
|
82
|
+
status: 'failed',
|
|
83
|
+
reason,
|
|
84
|
+
message: error instanceof Error ? error.message : String(error),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function drive(options: DriveOptions): Promise<DriverResult> {
|
|
89
|
+
const started = Date.now()
|
|
90
|
+
|
|
91
|
+
let browser: Browser
|
|
92
|
+
try {
|
|
93
|
+
browser = await chromium.launch()
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return failed(
|
|
96
|
+
isBrowserMissing(error) ? 'browser-missing' : 'drive-failed',
|
|
97
|
+
error,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const findings: PlacedFinding[] = []
|
|
102
|
+
const passes: PassReading[] = []
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
for (const viewport of options.plan.viewports) {
|
|
106
|
+
const pass = await driveViewport(browser, options, viewport)
|
|
107
|
+
findings.push(...pass.findings)
|
|
108
|
+
passes.push(...pass.passes)
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return failed(
|
|
112
|
+
isServerUnreachable(error) ? 'server-unreachable' : 'drive-failed',
|
|
113
|
+
error,
|
|
114
|
+
)
|
|
115
|
+
} finally {
|
|
116
|
+
// Dropped rather than propagated, because a close that fails beside a drive
|
|
117
|
+
// that already failed would replace the refusal the caller is about to
|
|
118
|
+
// receive with a reason about teardown.
|
|
119
|
+
await browser.close().catch(() => undefined)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
status: 'driven',
|
|
124
|
+
findings,
|
|
125
|
+
passes,
|
|
126
|
+
durationMs: Date.now() - started,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One full pass of the sequence at one viewport, in a context of its own.
|
|
132
|
+
*
|
|
133
|
+
* A fresh context per height rather than a resize of the last one, because a
|
|
134
|
+
* step already run has left the page in a state the next height would inherit,
|
|
135
|
+
* and a sweep exists to ask the same question of each rather than to ask a
|
|
136
|
+
* later one of a page the earlier heights already drove.
|
|
137
|
+
*/
|
|
138
|
+
async function driveViewport(
|
|
139
|
+
browser: Browser,
|
|
140
|
+
options: DriveOptions,
|
|
141
|
+
viewport: Viewport,
|
|
142
|
+
): Promise<{ findings: PlacedFinding[]; passes: PassReading[] }> {
|
|
143
|
+
const context = await browser.newContext({
|
|
144
|
+
viewport,
|
|
145
|
+
// Pointed the opposite way from `@/demo/drive`, which asks for the motion
|
|
146
|
+
// the interface was designed with. A measurement wants it suppressed, since
|
|
147
|
+
// geometry read mid-transition is a reading of neither state.
|
|
148
|
+
reducedMotion: 'reduce',
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const findings: PlacedFinding[] = []
|
|
152
|
+
const passes: PassReading[] = []
|
|
153
|
+
const where = describeViewport(viewport)
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const page = await context.newPage()
|
|
157
|
+
await page.goto(options.url, { waitUntil: 'domcontentloaded' })
|
|
158
|
+
|
|
159
|
+
for (const step of options.plan.steps) {
|
|
160
|
+
await runStep(page, step)
|
|
161
|
+
|
|
162
|
+
const names = stepProbes(options.plan, step)
|
|
163
|
+
let count = 0
|
|
164
|
+
for (const name of names) {
|
|
165
|
+
const probe = PROBES[name]
|
|
166
|
+
for (const finding of await probe(page)) {
|
|
167
|
+
findings.push({ ...finding, step: step.name, viewport: where })
|
|
168
|
+
count += 1
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
passes.push({
|
|
173
|
+
viewport: where,
|
|
174
|
+
step: step.name,
|
|
175
|
+
probes: names.length,
|
|
176
|
+
findings: count,
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
} finally {
|
|
180
|
+
await context.close().catch(() => undefined)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { findings, passes }
|
|
184
|
+
}
|