@erclx/aitk 3.27.0 → 3.28.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "3.27.0",
4
+ "version": "3.28.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -59,11 +59,22 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
59
59
  | `aitk census [path]` | Report tracked file count, a breakdown by extension, and a line total that skips whatever reads as binary (`--json`) |
60
60
  | `aitk audits run` | Run every audit as one set, report per check under one verdict, and compare each count to the recorded baseline (`--json`, `--record`) |
61
61
  | `aitk audits list` | List every audit the set runs, with the corpus each reads and whether it gates (`--json`) |
62
+ | `aitk inventory [subject]` | Walk every route a project declares and group its elements by the property each computes, as a listing rather than a gate (`--json`) |
62
63
  | `aitk capture [source]` | Render HTML capture sources to PNG, toolkit-only and absent from an installed package |
63
64
  | `aitk upgrade` | Reinstall the CLI globally with the package manager the install path names (`--json`) |
64
65
 
65
66
  `aitk demo` is the second browser command and the one that ships, since its purpose is running in a target rather than regenerating what this repository commits. It needs a browser binary the package does not carry, installed once with `bunx playwright install chromium`.
66
67
 
68
+ `aitk inventory` is the third and takes the same answer for the same reason. It reads `inventory.toml` at the project root for its base URL, its routes, and the element query each subject runs over, so what it walks comes from the project rather than from the toolkit. It reports how many different answers a site gives for one property and never gates, because whether five focus rings across four routes is a defect is a judgment. A missing server and an unmatched query are both refusals rather than empty listings, since a listing with no rows reads as one consistent answer.
69
+
70
+ ```toml
71
+ base-url = "http://localhost:4173"
72
+ routes = ["/", "/pricing", "/docs"]
73
+
74
+ [subjects.focus]
75
+ query = "button, a[href], input, select, textarea, [tabindex]"
76
+ ```
77
+
67
78
  ## Domain commands
68
79
 
69
80
  Each domain exposes a consistent shape where applicable: `list`, `install`, `sync`, `create`.
@@ -76,6 +87,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
76
87
  | `gov` | `list`, `install`, `sync`, `build`, `regen`, `test-order`, `superseded` |
77
88
  | `claude` | `init`, `sync`, `routing`, `seeds list`, `skills list`, `skills audit`, `skills drift`, `skills reach`, `skills rank`, `setup [dest]` |
78
89
  | `demo` | `compile`, `run` |
90
+ | `inventory` | `run` |
79
91
  | `wiki` | `init` |
80
92
  | `design` | `render` |
81
93
  | `slides` | `render`, `list` |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.27.0",
4
+ "version": "3.28.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * What every browser-driving command needs to know before it can report a
3
+ * failure honestly: how the binary is installed, and how each of the two ways
4
+ * it can be absent reads when it is thrown.
5
+ *
6
+ * The two are different states with different remedies. A package that never
7
+ * resolved means the target installed the CLI without the engine, and a binary
8
+ * that was never downloaded means the engine is present and its browser is not.
9
+ * Both were spelled inside `src/demo/` when `demo` was the only command driving
10
+ * a browser, and a second command is what makes them shared rather than local.
11
+ */
12
+
13
+ /** Fetches the browser revision the pinned engine expects. */
14
+ export const INSTALL_BROWSER = 'bunx playwright install chromium'
15
+
16
+ /**
17
+ * Separates a browser binary that was never installed from every other launch
18
+ * failure, because the first is a setup step the operator has to run and the
19
+ * second is a defect. A target inherits that setup step, which is the stated
20
+ * cost of shipping a browser command outside the toolkit.
21
+ */
22
+ export function isBrowserMissing(error: unknown): boolean {
23
+ const text = error instanceof Error ? error.message : String(error)
24
+ return /executable doesn't exist|playwright install/i.test(text)
25
+ }
26
+
27
+ /**
28
+ * Reports the engine package failing to resolve, which is the case a target
29
+ * hits before installing it. Any other import failure is a defect inside the
30
+ * module being loaded and propagates, rather than being reported as a missing
31
+ * dependency.
32
+ */
33
+ export function isEngineMissing(error: unknown): boolean {
34
+ return (
35
+ typeof error === 'object' &&
36
+ error !== null &&
37
+ 'code' in error &&
38
+ error.code === 'ERR_MODULE_NOT_FOUND'
39
+ )
40
+ }
package/src/cli.ts CHANGED
@@ -16,6 +16,7 @@ import { register as design } from '@/commands/design'
16
16
  import { register as slides } from '@/commands/slides'
17
17
  import { register as capture } from '@/commands/capture'
18
18
  import { register as demo } from '@/commands/demo'
19
+ import { register as inventory } from '@/commands/inventory'
19
20
  import { register as feedback } from '@/commands/feedback'
20
21
  import { register as transcripts } from '@/commands/transcripts'
21
22
  import { register as tasks } from '@/commands/tasks'
@@ -59,6 +60,7 @@ function showHelp(): void {
59
60
  `${GREY}│${NC} slides [cmd] ${GREY}# Slide deck commands (render, list)${NC}`,
60
61
  `${GREY}│${NC} capture [source] ${GREY}# Render HTML capture sources to PNG${NC}`,
61
62
  `${GREY}│${NC} demo [cmd] ${GREY}# Record a running app (compile, run)${NC}`,
63
+ `${GREY}│${NC} inventory [subj] ${GREY}# Report one computed property across every route${NC}`,
62
64
  `${GREY}│${NC} feedback ${GREY}# Write toolkit feedback from stdin to .claude/review/feedback/${NC}`,
63
65
  `${GREY}│${NC} transcripts <url> ${GREY}# Fetch a YouTube transcript with metadata frontmatter${NC}`,
64
66
  `${GREY}│${NC} tasks [cmd] ${GREY}# Task board commands (archive)${NC}`,
@@ -102,6 +104,7 @@ function showHelp(): void {
102
104
  `${GREY}│${NC} aitk slides render`,
103
105
  `${GREY}│${NC} aitk slides list --json`,
104
106
  `${GREY}│${NC} aitk capture assets/install.html`,
107
+ `${GREY}│${NC} aitk inventory focus --json`,
105
108
  `${GREY}│${NC} pbpaste | aitk feedback`,
106
109
  `${GREY}│${NC} aitk transcripts https://youtu.be/VIDEO_ID`,
107
110
  `${GREY}│${NC} aitk tasks archive --pull-request 673 --json`,
@@ -153,6 +156,7 @@ design(program)
153
156
  slides(program)
154
157
  capture(program)
155
158
  demo(program)
159
+ inventory(program)
156
160
  feedback(program)
157
161
  transcripts(program)
158
162
  tasks(program)
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
2
  import { basename, dirname, extname, join, relative, resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
+ import { INSTALL_BROWSER, isEngineMissing } from '@/browser/engine'
4
5
  import { parseDraft } from '@/demo/beats'
5
6
  import { compilePlan, parsePlan, unresolved } from '@/demo/compile'
6
7
  import { convertToMp4, INSTALL_CONVERTER } from '@/demo/container'
@@ -9,7 +10,6 @@ import { loadCursorTheme } from '@/demo/theme'
9
10
  import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
10
11
 
11
12
  const DEFAULT_OUT = 'demos'
12
- const INSTALL_BROWSER = 'bunx playwright install chromium'
13
13
 
14
14
  /**
15
15
  * Holds wiring only. Every browser reference sits behind `loadDriver`, because
@@ -359,20 +359,11 @@ async function loadDriver(): Promise<Driver | undefined> {
359
359
  try {
360
360
  return await import('@/demo/drive')
361
361
  } catch (error) {
362
- if (isModuleNotFound(error)) return undefined
362
+ if (isEngineMissing(error)) return undefined
363
363
  throw error
364
364
  }
365
365
  }
366
366
 
367
- function isModuleNotFound(error: unknown): boolean {
368
- return (
369
- typeof error === 'object' &&
370
- error !== null &&
371
- 'code' in error &&
372
- error.code === 'ERR_MODULE_NOT_FOUND'
373
- )
374
- }
375
-
376
367
  /**
377
368
  * Resolves where one artifact lands. `--out` replaces the directory the plan
378
369
  * names rather than acting as a root the plan's own directory hangs off, which
@@ -0,0 +1,256 @@
1
+ import { resolve } from 'node:path'
2
+ import type { Command } from 'commander'
3
+ import { INSTALL_BROWSER, isEngineMissing } from '@/browser/engine'
4
+ import {
5
+ CONFIG_REL,
6
+ type ConfigRefusal,
7
+ readInventoryConfig,
8
+ } from '@/inventory/config'
9
+ import { abbreviate, groupByTreatment } from '@/inventory/group'
10
+ import { findSubject, SUBJECTS } from '@/inventory/subjects'
11
+ import type { WalkRefusal } from '@/inventory/walk'
12
+ import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
13
+
14
+ /**
15
+ * Holds wiring only. Every browser reference sits behind `loadWalker`, because
16
+ * `src/cli.ts` imports this module at startup and resolving the engine there
17
+ * would put a browser launch in front of every other command.
18
+ */
19
+ type Walker = typeof import('@/inventory/walk')
20
+
21
+ interface RunOptions {
22
+ readonly root?: string
23
+ readonly baseUrl?: string
24
+ readonly json?: boolean
25
+ }
26
+
27
+ /** What a reader does about each way the config produced no walk. */
28
+ const CONFIG_REFUSALS: Record<ConfigRefusal, string> = {
29
+ 'no-config': `No ${CONFIG_REL} here, so no routes and no element query are declared.`,
30
+ 'unreadable-config': `${CONFIG_REL} is not valid TOML, so no route could be read.`,
31
+ 'no-routes': `${CONFIG_REL} carries no route under routes, so there is nothing to walk.`,
32
+ 'no-base-url': `${CONFIG_REL} carries no base-url, so no route resolves to an address.`,
33
+ 'no-subjects': `${CONFIG_REL} declares no subject, and the element query comes from this project rather than from the toolkit.`,
34
+ }
35
+
36
+ const WALK_REFUSALS: Record<WalkRefusal, string> = {
37
+ 'browser-missing': 'The browser binary is not installed in this project.',
38
+ 'server-unreachable':
39
+ 'Nothing answered at the base URL, so no route was read.',
40
+ 'walk-failed': 'The walk failed against a running server.',
41
+ }
42
+
43
+ export function register(program: Command): void {
44
+ const inventory = program
45
+ .command('inventory')
46
+ .description('Report one computed property across every route of a project')
47
+ .helpOption('-h, --help', 'Show this help message')
48
+
49
+ inventory
50
+ .command('run', { isDefault: true })
51
+ .description(
52
+ 'Walk every route and group its elements by the answer each gives',
53
+ )
54
+ .argument('[subject]', 'Treatment to read, defaulting to focus', 'focus')
55
+ .helpOption('-h, --help', 'Show this help message')
56
+ .option('--root <path>', 'Project to read, defaulting to the cwd')
57
+ .option('--base-url <url>', 'Address to walk, overriding the config')
58
+ .option('--json', 'Add a machine-readable record on stdout')
59
+ .addHelpText(
60
+ 'after',
61
+ [
62
+ '',
63
+ `Reads ${CONFIG_REL} for the base URL, the routes, and the element query`,
64
+ 'each subject runs over, so what gets walked comes from the project',
65
+ 'rather than from the toolkit.',
66
+ '',
67
+ 'It reports a listing and never a verdict. The value is seeing how many',
68
+ 'different answers one site gives, and a gate collapses that to one bit.',
69
+ '',
70
+ 'Needs a running server and a browser binary. Install the browser with:',
71
+ ` ${INSTALL_BROWSER}`,
72
+ '',
73
+ 'Subjects:',
74
+ ...SUBJECTS.map((subject) => ` ${subject.name} ${subject.summary}`),
75
+ '',
76
+ 'Exit codes:',
77
+ ' 0 the walk read at least one element and reported its rows',
78
+ ' 1 refused, with the reason on stderr or in the JSON record',
79
+ '',
80
+ 'Examples:',
81
+ ' aitk inventory focus',
82
+ ' aitk inventory focus --json',
83
+ ' aitk inventory focus --base-url http://localhost:3000',
84
+ '',
85
+ ].join('\n'),
86
+ )
87
+ .action(async (subject: string, opts: RunOptions) => {
88
+ process.exitCode = await runInventory(subject, opts)
89
+ })
90
+ }
91
+
92
+ async function runInventory(
93
+ subjectName: string,
94
+ opts: RunOptions,
95
+ ): Promise<number> {
96
+ const root = resolve(opts.root ?? process.cwd())
97
+ const emitJson = opts.json ?? false
98
+
99
+ intro(`aitk inventory ${subjectName}`)
100
+
101
+ const subject = findSubject(subjectName)
102
+ if (!subject) {
103
+ const known = SUBJECTS.map((entry) => entry.name).join(', ')
104
+ return refuse(
105
+ emitJson,
106
+ root,
107
+ 'unknown-subject',
108
+ `No reader named ${subjectName}. This build ships: ${known}.`,
109
+ )
110
+ }
111
+
112
+ const read = readInventoryConfig(root)
113
+ if (read.kind === 'refused') {
114
+ return refuse(emitJson, root, read.reason, CONFIG_REFUSALS[read.reason])
115
+ }
116
+
117
+ const declared = read.config.subjects.find(
118
+ (entry) => entry.name === subjectName,
119
+ )
120
+ if (!declared) {
121
+ const named = read.config.subjects.map((entry) => entry.name).join(', ')
122
+ return refuse(
123
+ emitJson,
124
+ root,
125
+ 'undeclared-subject',
126
+ `${CONFIG_REL} declares no query for ${subjectName}. It declares: ${named}.`,
127
+ )
128
+ }
129
+
130
+ const baseUrl = opts.baseUrl ?? read.config.baseUrl
131
+
132
+ logStep('Scope')
133
+ logInfo(
134
+ `${plural(read.config.routes.length, 'route')} from ${baseUrl}, matching ${declared.query}`,
135
+ )
136
+
137
+ const walker = await loadWalker()
138
+ if (!walker) {
139
+ logStep('Browser')
140
+ logError('the browser engine is not installed in this project')
141
+ logWarn(`Install it with: ${INSTALL_BROWSER}`)
142
+ outro()
143
+ emit(emitJson, {
144
+ root,
145
+ subject: subjectName,
146
+ reason: 'engine-missing',
147
+ install: INSTALL_BROWSER,
148
+ })
149
+ return 1
150
+ }
151
+
152
+ const result = await walker.walk({
153
+ baseUrl,
154
+ routes: read.config.routes,
155
+ subject,
156
+ query: declared.query,
157
+ })
158
+
159
+ if (result.status === 'failed') {
160
+ logStep('Refused')
161
+ logWarn(WALK_REFUSALS[result.reason])
162
+ if (result.reason === 'server-unreachable') {
163
+ logWarn(`Start the project at ${baseUrl}, then run this again.`)
164
+ }
165
+ if (result.reason === 'browser-missing') {
166
+ logWarn(`Install the browser binary with: ${INSTALL_BROWSER}`)
167
+ }
168
+ logWarn(result.message.split('\n')[0] ?? '')
169
+ outro()
170
+ emit(emitJson, {
171
+ root,
172
+ subject: subjectName,
173
+ baseUrl,
174
+ reason: result.reason,
175
+ message: result.message,
176
+ ...(result.reason === 'browser-missing'
177
+ ? { install: INSTALL_BROWSER }
178
+ : {}),
179
+ })
180
+ return 1
181
+ }
182
+
183
+ const groups = groupByTreatment(result.readings)
184
+
185
+ logStep('Routes')
186
+ for (const route of result.routes) {
187
+ logInfo(`${route.route} ${plural(route.elements, 'element')}`)
188
+ }
189
+
190
+ // A walk that matched nothing refuses rather than printing an empty listing,
191
+ // because no rows and one row read the same to anything counting them, and
192
+ // the first says the query reached nothing while the second says the site
193
+ // gives one consistent answer.
194
+ if (groups.length === 0) {
195
+ return refuse(
196
+ emitJson,
197
+ root,
198
+ 'no-elements',
199
+ `No element matched ${declared.query} on any of the ${plural(result.routes.length, 'route')} walked.`,
200
+ )
201
+ }
202
+
203
+ logStep('Treatments')
204
+ logInfo(
205
+ `${plural(groups.length, 'answer')} across ${plural(result.readings.length, 'element')}`,
206
+ )
207
+ for (const group of groups) {
208
+ logInfo(` ${group.count}x ${group.treatment}`)
209
+ logInfo(
210
+ ` ${abbreviate(group.samples, group.count)} on ${abbreviate(group.routes, group.routes.length)}`,
211
+ )
212
+ }
213
+ outro()
214
+
215
+ emit(emitJson, {
216
+ root,
217
+ subject: subjectName,
218
+ baseUrl,
219
+ routes: result.routes,
220
+ elements: result.readings.length,
221
+ treatments: groups,
222
+ durationMs: result.durationMs,
223
+ })
224
+ return 0
225
+ }
226
+
227
+ /**
228
+ * Frames a refusal on stderr and puts the record on stdout, so an operator
229
+ * reading the terminal sees the reason rather than a command that appeared to
230
+ * do nothing.
231
+ */
232
+ function refuse(
233
+ emitJson: boolean,
234
+ root: string,
235
+ reason: string,
236
+ message: string,
237
+ ): number {
238
+ logStep('Refused')
239
+ logWarn(message)
240
+ outro()
241
+ emit(emitJson, { root, reason, message })
242
+ return 1
243
+ }
244
+
245
+ async function loadWalker(): Promise<Walker | undefined> {
246
+ try {
247
+ return await import('@/inventory/walk')
248
+ } catch (error) {
249
+ if (isEngineMissing(error)) return undefined
250
+ throw error
251
+ }
252
+ }
253
+
254
+ function emit(json: boolean, record: unknown): void {
255
+ if (json) process.stdout.write(`${JSON.stringify(record)}\n`)
256
+ }
package/src/demo/drive.ts CHANGED
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
3
3
  import { dirname, join } from 'node:path'
4
4
  import { chromium } from 'playwright-core'
5
5
  import type { Browser, BrowserContext, Page } from 'playwright-core'
6
+ import { isBrowserMissing } from '@/browser/engine'
6
7
  import { deriveSteps } from '@/demo/compile'
7
8
  import type { DemoPlan, DemoStep } from '@/demo/compile'
8
9
  import type { CursorSet } from '@/demo/pointer'
@@ -181,20 +182,17 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
181
182
  }
182
183
 
183
184
  /**
184
- * Separates a browser binary that was never installed from every other launch
185
- * failure, because the first is a setup step the operator has to run and the
186
- * second is a defect. A target inherits that setup step, which is the stated
187
- * cost of shipping this command outside the toolkit.
185
+ * Reads the launch through `@/browser/engine`, which is where the separation
186
+ * between a binary that was never installed and every other launch failure now
187
+ * lives. It moved out of this file when `aitk inventory` became the second
188
+ * command needing it, rather than being copied.
188
189
  */
189
190
  async function launch(): Promise<Launch> {
190
191
  try {
191
192
  return { status: 'launched', value: await chromium.launch() }
192
193
  } catch (error) {
193
- const text = error instanceof Error ? error.message : String(error)
194
194
  return failed(
195
- /executable doesn't exist|playwright install/i.test(text)
196
- ? 'browser-missing'
197
- : 'drive-failed',
195
+ isBrowserMissing(error) ? 'browser-missing' : 'drive-failed',
198
196
  error,
199
197
  )
200
198
  }
@@ -0,0 +1,117 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /**
5
+ * Where a project declares what an inventory walks, spelled once.
6
+ *
7
+ * It sits at the project root rather than under `.claude/`, because the routes
8
+ * and the element queries describe the application rather than the agent
9
+ * surface, and a target reads them the way it reads any other build input.
10
+ */
11
+ export const CONFIG_REL = 'inventory.toml'
12
+
13
+ /** One measurable treatment and the elements that carry it, as the project spells them. */
14
+ export interface InventorySubject {
15
+ readonly name: string
16
+ readonly query: string
17
+ }
18
+
19
+ export interface InventoryConfig {
20
+ readonly baseUrl: string
21
+ readonly routes: readonly string[]
22
+ readonly subjects: readonly InventorySubject[]
23
+ }
24
+
25
+ /**
26
+ * Why a config could not be used, which is never the same as a walk that read
27
+ * nothing.
28
+ *
29
+ * Every value here is a refusal rather than an empty listing, because a listing
30
+ * with no rows reads as a site giving one consistent answer, which is the
31
+ * report an unconfigured project would get for free and the exact wrong reading.
32
+ */
33
+ export type ConfigRefusal =
34
+ | 'no-config'
35
+ | 'unreadable-config'
36
+ | 'no-base-url'
37
+ | 'no-routes'
38
+ | 'no-subjects'
39
+
40
+ export type ConfigRead =
41
+ | { readonly kind: 'config'; readonly config: InventoryConfig }
42
+ | { readonly kind: 'refused'; readonly reason: ConfigRefusal }
43
+
44
+ function readSubjects(table: unknown): InventorySubject[] {
45
+ if (typeof table !== 'object' || table === null || Array.isArray(table)) {
46
+ return []
47
+ }
48
+
49
+ const subjects: InventorySubject[] = []
50
+ for (const [name, value] of Object.entries(table)) {
51
+ if (typeof value !== 'object' || value === null) continue
52
+ const query = (value as Record<string, unknown>).query
53
+ if (typeof query !== 'string' || query === '') continue
54
+ subjects.push({ name, query })
55
+ }
56
+
57
+ return subjects
58
+ }
59
+
60
+ /**
61
+ * Parses config text, so a caller holding the bytes skips the filesystem.
62
+ *
63
+ * A malformed route or subject is dropped rather than refused, matching the row
64
+ * handling in `@/labels/map`: one bad entry should not blind the walk to the
65
+ * other twenty. What that costs is a typo reading as an entry nobody wrote,
66
+ * which the listing surfaces from the other side as a route with no elements.
67
+ */
68
+ export function parseInventoryConfig(source: string): ConfigRead {
69
+ let parsed: Record<string, unknown>
70
+ try {
71
+ parsed = Bun.TOML.parse(source) as Record<string, unknown>
72
+ } catch {
73
+ return { kind: 'refused', reason: 'unreadable-config' }
74
+ }
75
+
76
+ const baseUrl = parsed['base-url']
77
+ if (typeof baseUrl !== 'string' || baseUrl === '') {
78
+ return { kind: 'refused', reason: 'no-base-url' }
79
+ }
80
+
81
+ const routes = Array.isArray(parsed.routes)
82
+ ? parsed.routes.filter(
83
+ (route): route is string => typeof route === 'string' && route !== '',
84
+ )
85
+ : []
86
+ if (routes.length === 0) return { kind: 'refused', reason: 'no-routes' }
87
+
88
+ const subjects = readSubjects(parsed.subjects)
89
+ if (subjects.length === 0) return { kind: 'refused', reason: 'no-subjects' }
90
+
91
+ return { kind: 'config', config: { baseUrl, routes, subjects } }
92
+ }
93
+
94
+ /** Reads the config a project declares at `root`, or says why it could not. */
95
+ export function readInventoryConfig(root: string): ConfigRead {
96
+ let source: string
97
+ try {
98
+ source = readFileSync(join(root, CONFIG_REL), 'utf8')
99
+ } catch {
100
+ return { kind: 'refused', reason: 'no-config' }
101
+ }
102
+
103
+ return parseInventoryConfig(source)
104
+ }
105
+
106
+ /**
107
+ * Joins a base and a route into the address a walk opens.
108
+ *
109
+ * Concatenation rather than `new URL(route, base)`, because the resolver reads
110
+ * a leading slash as absolute and drops any path the base already carries, so a
111
+ * project served under a subdirectory would have every route walk the origin.
112
+ */
113
+ export function routeUrl(baseUrl: string, route: string): string {
114
+ const base = baseUrl.replace(/\/+$/, '')
115
+ const path = route.startsWith('/') ? route : `/${route}`
116
+ return `${base}${path}`
117
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * How many elements a row names before it stops naming them. A row exists to
3
+ * say how many answers a site gives, and the elements under it are there to
4
+ * make one findable rather than to enumerate the set.
5
+ */
6
+ export const SAMPLE_LIMIT = 3
7
+
8
+ /**
9
+ * Names a bounded head of a list and says how many it left out, so a treatment
10
+ * carried by fifty routes reads as fifty rather than as the three named.
11
+ *
12
+ * `total` arrives separately because the two lists reach here differently. A
13
+ * row's samples are already capped when they are collected, so their own length
14
+ * cannot say how many elements the row covers, while its routes arrive whole.
15
+ */
16
+ export function abbreviate(entries: readonly string[], total: number): string {
17
+ const head = entries.slice(0, SAMPLE_LIMIT)
18
+ const elided = total - head.length
19
+ return elided > 0 ? `${head.join(', ')} and ${elided} more` : head.join(', ')
20
+ }
21
+
22
+ /** One element as the walk read it, on the route it was read from. */
23
+ export interface Reading {
24
+ readonly route: string
25
+ readonly selector: string
26
+ readonly treatment: string
27
+ }
28
+
29
+ /** One answer the site gives, and what carries it. */
30
+ export interface TreatmentGroup {
31
+ readonly treatment: string
32
+ readonly count: number
33
+ readonly routes: readonly string[]
34
+ readonly samples: readonly string[]
35
+ }
36
+
37
+ /**
38
+ * Turns a per-element walk into a per-answer listing, which is the whole point
39
+ * of the command.
40
+ *
41
+ * Grouping by the component instead was the obvious shape and it reports what a
42
+ * reader already knows, that a site has buttons and links. Grouping by the
43
+ * computed answer reports the thing nobody can see while building, which is
44
+ * that five components resolved to five different rings.
45
+ *
46
+ * Order is heaviest first, then by treatment, so the dominant answer leads and
47
+ * two runs over one site report one order rather than whatever the walk hit.
48
+ */
49
+ export function groupByTreatment(
50
+ readings: readonly Reading[],
51
+ ): readonly TreatmentGroup[] {
52
+ const byTreatment = new Map<
53
+ string,
54
+ { count: number; routes: string[]; samples: string[] }
55
+ >()
56
+
57
+ for (const { route, selector, treatment } of readings) {
58
+ const row = byTreatment.get(treatment) ?? {
59
+ count: 0,
60
+ routes: [],
61
+ samples: [],
62
+ }
63
+ row.count += 1
64
+ if (!row.routes.includes(route)) row.routes.push(route)
65
+ if (row.samples.length < SAMPLE_LIMIT) row.samples.push(selector)
66
+ byTreatment.set(treatment, row)
67
+ }
68
+
69
+ return [...byTreatment.entries()]
70
+ .map(([treatment, row]) => ({ treatment, ...row }))
71
+ .sort(
72
+ (left, right) =>
73
+ right.count - left.count ||
74
+ left.treatment.localeCompare(right.treatment),
75
+ )
76
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The readers an inventory can run, one per treatment.
3
+ *
4
+ * A reader runs inside the page rather than here, so each one is serialized to
5
+ * source and evaluated by the browser. That is why every helper a reader needs
6
+ * is declared inside its own body: a reference to anything at module scope
7
+ * survives typechecking and throws once the page tries to call it.
8
+ *
9
+ * `focus` is the first and, for now, the only subject. The four sibling
10
+ * instruments this shape was lifted from differ only in their element query and
11
+ * the property they read, which is what makes a subject a row here rather than
12
+ * a command of its own.
13
+ */
14
+
15
+ /** One element as a reader saw it, before any grouping. */
16
+ export interface SubjectReading {
17
+ readonly selector: string
18
+ readonly treatment: string
19
+ }
20
+
21
+ export interface Subject {
22
+ readonly name: string
23
+ /** What the listing means, printed above the rows so a count reads correctly. */
24
+ readonly summary: string
25
+ readonly read: (query: string) => SubjectReading[]
26
+ }
27
+
28
+ /**
29
+ * Reports what each element changes about itself when it takes focus, which is
30
+ * the reading `governance/rules/ui/410-a11y.md` states three rules against and
31
+ * nothing measures.
32
+ *
33
+ * The difference between rest and focus is the treatment, rather than the
34
+ * focused style on its own. A card carrying a resting shadow computes a shadow
35
+ * either way, so reading only the focused state would report a ring on an
36
+ * element whose appearance never moves.
37
+ *
38
+ * An element the browser refuses to focus is named as such rather than folded
39
+ * into the no-treatment row, since a disabled control and a control with no
40
+ * ring are different findings with different remedies.
41
+ */
42
+ function readFocusTreatments(query: string): SubjectReading[] {
43
+ const PROPERTIES = [
44
+ 'outlineStyle',
45
+ 'outlineWidth',
46
+ 'outlineColor',
47
+ 'outlineOffset',
48
+ 'boxShadow',
49
+ 'borderColor',
50
+ 'backgroundColor',
51
+ 'color',
52
+ ] as const
53
+
54
+ const describe = (element: Element): string => {
55
+ const tag = element.tagName.toLowerCase()
56
+ if (element.id) return `${tag}#${element.id}`
57
+ const className = element.getAttribute('class')?.trim().split(/\s+/)[0]
58
+ return className ? `${tag}.${className}` : tag
59
+ }
60
+
61
+ const snapshot = (element: Element): Record<string, string> => {
62
+ const computed = getComputedStyle(element)
63
+ const values: Record<string, string> = {}
64
+ for (const property of PROPERTIES) values[property] = computed[property]
65
+ return values
66
+ }
67
+
68
+ const rows: SubjectReading[] = []
69
+
70
+ // The walk presses Tab before this runs, which leaves one element focused.
71
+ // Reading that element's rest state while it holds focus reports no
72
+ // difference and hides whatever ring it actually draws, so the page starts
73
+ // from nothing focused and every element is blurred again after its turn.
74
+ const entryFocus = document.activeElement
75
+ if (entryFocus instanceof HTMLElement) entryFocus.blur()
76
+
77
+ for (const element of Array.from(document.querySelectorAll(query))) {
78
+ if (!(element instanceof HTMLElement)) continue
79
+
80
+ const rest = snapshot(element)
81
+ element.focus()
82
+ if (document.activeElement !== element) {
83
+ rows.push({ selector: describe(element), treatment: 'not focusable' })
84
+ continue
85
+ }
86
+
87
+ const focused = snapshot(element)
88
+ const changed = PROPERTIES.filter(
89
+ (property) => rest[property] !== focused[property],
90
+ ).map((property) => `${property} ${focused[property]}`)
91
+
92
+ rows.push({
93
+ selector: describe(element),
94
+ treatment:
95
+ changed.length === 0 ? 'no visible change' : changed.join(', '),
96
+ })
97
+ element.blur()
98
+ }
99
+
100
+ return rows
101
+ }
102
+
103
+ const FOCUS: Subject = {
104
+ name: 'focus',
105
+ summary: 'what each element changes about itself when it takes focus',
106
+ read: readFocusTreatments,
107
+ }
108
+
109
+ export const SUBJECTS: readonly Subject[] = [FOCUS]
110
+
111
+ /** Resolves a subject by name, so an unknown one is the caller's to report. */
112
+ export function findSubject(name: string): Subject | undefined {
113
+ return SUBJECTS.find((subject) => subject.name === name)
114
+ }
@@ -0,0 +1,129 @@
1
+ import { chromium } from 'playwright-core'
2
+ import type { Browser } from 'playwright-core'
3
+ import { isBrowserMissing } from '@/browser/engine'
4
+ import { routeUrl } from '@/inventory/config'
5
+ import type { Reading } from '@/inventory/group'
6
+ import type { Subject } from '@/inventory/subjects'
7
+
8
+ /**
9
+ * Walks a running application and reads one property off every element a
10
+ * subject names. Every browser reference the inventory feature adds lives here,
11
+ * and `src/commands/inventory.ts` reaches it through a dynamic import so no
12
+ * other command resolves the engine at startup.
13
+ *
14
+ * Like `@/demo/drive` and unlike `@/capture/render`, this module ships, because
15
+ * a command whose whole purpose is running inside someone else's project cannot
16
+ * stay toolkit-only.
17
+ */
18
+
19
+ /**
20
+ * The reader and the query arrive apart because they come from different
21
+ * owners. The toolkit ships the reader and the project declares which elements
22
+ * it runs over, which is what keeps the walk answering to the target rather
23
+ * than to a fixed selector nobody there chose.
24
+ */
25
+ export interface WalkOptions {
26
+ readonly baseUrl: string
27
+ readonly routes: readonly string[]
28
+ readonly subject: Subject
29
+ readonly query: string
30
+ }
31
+
32
+ /** A route that answered, and the elements read off it. */
33
+ export interface RouteReading {
34
+ readonly route: string
35
+ readonly elements: number
36
+ }
37
+
38
+ export type WalkRefusal =
39
+ | 'browser-missing'
40
+ | 'server-unreachable'
41
+ | 'walk-failed'
42
+
43
+ export type WalkResult =
44
+ | {
45
+ readonly status: 'read'
46
+ readonly readings: readonly Reading[]
47
+ readonly routes: readonly RouteReading[]
48
+ readonly durationMs: number
49
+ }
50
+ | {
51
+ readonly status: 'failed'
52
+ readonly reason: WalkRefusal
53
+ readonly message: string
54
+ }
55
+
56
+ function failed(reason: WalkRefusal, error: unknown): WalkResult {
57
+ return {
58
+ status: 'failed',
59
+ reason,
60
+ message: error instanceof Error ? error.message : String(error),
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Separates a server nobody started from a page that failed for its own
66
+ * reasons. The first is the precondition this command cannot create, and
67
+ * reporting it as an empty listing would say the site gives no answers when
68
+ * nothing was ever asked.
69
+ */
70
+ function isServerUnreachable(error: unknown): boolean {
71
+ const text = error instanceof Error ? error.message : String(error)
72
+ return /ERR_CONNECTION_REFUSED|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION_RESET|ERR_EMPTY_RESPONSE/i.test(
73
+ text,
74
+ )
75
+ }
76
+
77
+ export async function walk(options: WalkOptions): Promise<WalkResult> {
78
+ const started = Date.now()
79
+
80
+ let browser: Browser
81
+ try {
82
+ browser = await chromium.launch()
83
+ } catch (error) {
84
+ return failed(
85
+ isBrowserMissing(error) ? 'browser-missing' : 'walk-failed',
86
+ error,
87
+ )
88
+ }
89
+
90
+ const readings: Reading[] = []
91
+ const routes: RouteReading[] = []
92
+
93
+ try {
94
+ const page = await browser.newPage()
95
+
96
+ for (const route of options.routes) {
97
+ await page.goto(routeUrl(options.baseUrl, route), {
98
+ waitUntil: 'domcontentloaded',
99
+ })
100
+
101
+ // Puts the page in keyboard modality before anything is focused, because
102
+ // a `:focus-visible` ring is the treatment a pointer never reveals and
103
+ // programmatic focus alone does not match it.
104
+ await page.keyboard.press('Tab')
105
+
106
+ const rows = await page.evaluate(options.subject.read, options.query)
107
+ for (const row of rows) readings.push({ route, ...row })
108
+ routes.push({ route, elements: rows.length })
109
+ }
110
+ } catch (error) {
111
+ return failed(
112
+ isServerUnreachable(error) ? 'server-unreachable' : 'walk-failed',
113
+ error,
114
+ )
115
+ } finally {
116
+ // The rejection is dropped rather than propagated, because a close that
117
+ // fails beside a walk that already failed would replace the refusal the
118
+ // caller was about to receive with a reason about teardown. Nothing the
119
+ // caller does depends on the browser having closed cleanly.
120
+ await browser.close().catch(() => undefined)
121
+ }
122
+
123
+ return {
124
+ status: 'read',
125
+ readings,
126
+ routes,
127
+ durationMs: Date.now() - started,
128
+ }
129
+ }
@@ -1,4 +1,5 @@
1
1
  import { $ } from 'bun'
2
+ import { gitEnv } from '@/git-env'
2
3
  import {
3
4
  type Confidence,
4
5
  liveness,
@@ -67,7 +68,10 @@ export interface Located {
67
68
  * that has a worktree and no branch and the second has neither.
68
69
  */
69
70
  async function locate(cwd: string): Promise<Located> {
70
- const top = await $`git -C ${cwd} rev-parse --show-toplevel`.quiet().nothrow()
71
+ const top = await $`git -C ${cwd} rev-parse --show-toplevel`
72
+ .env(gitEnv())
73
+ .quiet()
74
+ .nothrow()
71
75
 
72
76
  if (top.exitCode !== 0) {
73
77
  // git absent and git refusing the directory are both non-zero here. The
@@ -85,7 +89,10 @@ async function locate(cwd: string): Promise<Located> {
85
89
 
86
90
  const worktree = top.stdout.toString().trim()
87
91
  const repository = await repositoryOf(cwd)
88
- const head = await $`git -C ${cwd} branch --show-current`.quiet().nothrow()
92
+ const head = await $`git -C ${cwd} branch --show-current`
93
+ .env(gitEnv())
94
+ .quiet()
95
+ .nothrow()
89
96
  const branch = head.stdout.toString().trim()
90
97
 
91
98
  if (head.exitCode !== 0 || branch.length === 0) {
@@ -105,6 +112,7 @@ async function locate(cwd: string): Promise<Located> {
105
112
  export async function repositoryOf(cwd: string): Promise<string | null> {
106
113
  const dir =
107
114
  await $`git -C ${cwd} rev-parse --path-format=absolute --git-common-dir`
115
+ .env(gitEnv())
108
116
  .quiet()
109
117
  .nothrow()
110
118
 
package/src/worktree.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { $ } from 'bun'
2
+ import { gitEnv } from '@/git-env'
2
3
 
3
4
  /**
4
5
  * Resolves the root of the checkout the caller is standing in, which is the
@@ -10,7 +11,10 @@ import { $ } from 'bun'
10
11
  * subdirectory would resolve a root holding none of the trees a verb reads.
11
12
  */
12
13
  export async function currentWorktreeRoot(): Promise<string> {
13
- const result = await $`git rev-parse --show-toplevel`.quiet().nothrow()
14
+ const result = await $`git rev-parse --show-toplevel`
15
+ .env(gitEnv())
16
+ .quiet()
17
+ .nothrow()
14
18
  if (result.exitCode !== 0) return process.cwd()
15
19
 
16
20
  return result.stdout.toString().trim() || process.cwd()
@@ -27,7 +31,10 @@ export async function currentWorktreeRoot(): Promise<string> {
27
31
  * root in-process is the route a skill body has.
28
32
  */
29
33
  export async function mainWorktreeRoot(): Promise<string> {
30
- const result = await $`git worktree list --porcelain`.quiet().nothrow()
34
+ const result = await $`git worktree list --porcelain`
35
+ .env(gitEnv())
36
+ .quiet()
37
+ .nothrow()
31
38
  if (result.exitCode !== 0) return process.cwd()
32
39
 
33
40
  const line = result.stdout
@@ -52,6 +59,7 @@ export async function listWorktrees(
52
59
  cwd: string = process.cwd(),
53
60
  ): Promise<readonly WorktreeEntry[]> {
54
61
  const result = await $`git -C ${cwd} worktree list --porcelain`
62
+ .env(gitEnv())
55
63
  .quiet()
56
64
  .nothrow()
57
65
  if (result.exitCode !== 0) return []