@crouton-kit/capture 1.3.21 → 1.4.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.
@@ -30,6 +30,8 @@ export interface ElementRecord {
30
30
  readonly axName?: string;
31
31
  /** Text content associated with the element, when known. */
32
32
  readonly text?: string;
33
+ /** The element's own captured HTML attributes, keyed by attribute name. */
34
+ readonly attributes?: Readonly<Record<string, string>>;
33
35
  readonly [key: string]: unknown;
34
36
  }
35
37
 
@@ -37,9 +39,240 @@ export interface ElementRecord {
37
39
  // Direct lookups
38
40
  // ============================================================================
39
41
 
40
- /** Elements whose stored `selector` exactly equals `selector`. */
42
+ interface AttributeMatcher {
43
+ readonly name: string;
44
+ readonly value?: string;
45
+ }
46
+
47
+ interface CompoundMatcher {
48
+ readonly tag?: string;
49
+ readonly id?: string;
50
+ readonly classes: readonly string[];
51
+ readonly attributes: readonly AttributeMatcher[];
52
+ readonly nthOfType?: number;
53
+ }
54
+
55
+ type Combinator = 'child' | 'descendant';
56
+
57
+ interface CssMatcher {
58
+ readonly compounds: readonly CompoundMatcher[];
59
+ /** The relation between compound i and compound i + 1. */
60
+ readonly combinators: readonly Combinator[];
61
+ }
62
+
63
+ function isNameStart(char: string | undefined): boolean {
64
+ return char !== undefined && /[A-Za-z_*]/.test(char);
65
+ }
66
+
67
+ function isNameChar(char: string | undefined): boolean {
68
+ return char !== undefined && /[A-Za-z0-9_-]/.test(char);
69
+ }
70
+
71
+ function readName(value: string, start: number): { value: string; end: number } | undefined {
72
+ if (!isNameStart(value[start])) return undefined;
73
+ let end = start + 1;
74
+ while (isNameChar(value[end])) end += 1;
75
+ return { value: value.slice(start, end), end };
76
+ }
77
+
78
+ function unquoteCssValue(value: string): string | undefined {
79
+ const trimmed = value.trim();
80
+ if (!trimmed) return undefined;
81
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
82
+ return trimmed.slice(1, -1).replace(/\\(.)/g, '$1');
83
+ }
84
+ return /\s/.test(trimmed) ? undefined : trimmed;
85
+ }
86
+
87
+ function parseAttribute(value: string): AttributeMatcher | undefined {
88
+ const equals = value.indexOf('=');
89
+ const name = (equals === -1 ? value : value.slice(0, equals)).trim();
90
+ if (!/^[A-Za-z_][A-Za-z0-9_:-]*$/.test(name)) return undefined;
91
+ if (equals === -1) return { name: name.toLowerCase() };
92
+ const attributeValue = unquoteCssValue(value.slice(equals + 1));
93
+ return attributeValue === undefined ? undefined : { name: name.toLowerCase(), value: attributeValue };
94
+ }
95
+
96
+ function parseCompound(value: string): CompoundMatcher | undefined {
97
+ let index = 0;
98
+ let tag: string | undefined;
99
+ const first = readName(value, index);
100
+ if (first) {
101
+ tag = first.value.toLowerCase();
102
+ index = first.end;
103
+ }
104
+ const classes: string[] = [];
105
+ const attributes: AttributeMatcher[] = [];
106
+ let id: string | undefined;
107
+ let nthOfType: number | undefined;
108
+ while (index < value.length) {
109
+ const marker = value[index]!;
110
+ if (marker === '.' || marker === '#') {
111
+ const name = readName(value, index + 1);
112
+ if (!name) return undefined;
113
+ if (marker === '.') classes.push(name.value);
114
+ else if (id === undefined) id = name.value;
115
+ else return undefined;
116
+ index = name.end;
117
+ continue;
118
+ }
119
+ if (marker === '[') {
120
+ let end = index + 1;
121
+ let quote: string | undefined;
122
+ while (end < value.length) {
123
+ const char = value[end]!;
124
+ if (quote) {
125
+ if (char === '\\') end += 2;
126
+ else {
127
+ if (char === quote) quote = undefined;
128
+ end += 1;
129
+ }
130
+ } else if (char === '"' || char === "'") {
131
+ quote = char;
132
+ end += 1;
133
+ } else if (char === ']') break;
134
+ else end += 1;
135
+ }
136
+ if (quote || value[end] !== ']') return undefined;
137
+ const attribute = parseAttribute(value.slice(index + 1, end));
138
+ if (!attribute) return undefined;
139
+ attributes.push(attribute);
140
+ index = end + 1;
141
+ continue;
142
+ }
143
+ if (value.startsWith(':nth-of-type(', index)) {
144
+ const end = value.indexOf(')', index);
145
+ const rawIndex = value.slice(index + ':nth-of-type('.length, end);
146
+ if (end === -1 || !/^\d+$/.test(rawIndex) || nthOfType !== undefined) return undefined;
147
+ nthOfType = Number(rawIndex);
148
+ index = end + 1;
149
+ continue;
150
+ }
151
+ return undefined;
152
+ }
153
+ return tag || id || classes.length || attributes.length || nthOfType !== undefined ? { tag, id, classes, attributes, nthOfType } : undefined;
154
+ }
155
+
156
+ function parseCssMatcher(rawInput: string): CssMatcher | undefined {
157
+ const input = rawInput.trim();
158
+ const compounds: CompoundMatcher[] = [];
159
+ const combinators: Combinator[] = [];
160
+ let index = 0;
161
+ let pendingCombinator: Combinator | undefined;
162
+ while (index < input.length) {
163
+ let sawWhitespace = false;
164
+ while (/\s/.test(input[index] ?? '')) {
165
+ sawWhitespace = true;
166
+ index += 1;
167
+ }
168
+ if (index >= input.length) return undefined;
169
+ if (input[index] === '>') {
170
+ if (!compounds.length || pendingCombinator) return undefined;
171
+ pendingCombinator = 'child';
172
+ index += 1;
173
+ continue;
174
+ }
175
+ if (compounds.length && !pendingCombinator) pendingCombinator = sawWhitespace ? 'descendant' : undefined;
176
+ if (compounds.length && !pendingCombinator) return undefined;
177
+ const start = index;
178
+ let bracketDepth = 0;
179
+ let quote: string | undefined;
180
+ while (index < input.length) {
181
+ const char = input[index]!;
182
+ if (quote) {
183
+ if (char === '\\') index += 2;
184
+ else {
185
+ if (char === quote) quote = undefined;
186
+ index += 1;
187
+ }
188
+ } else if (char === '"' || char === "'") {
189
+ quote = char;
190
+ index += 1;
191
+ } else if (char === '[') {
192
+ bracketDepth += 1;
193
+ index += 1;
194
+ } else if (char === ']') {
195
+ bracketDepth -= 1;
196
+ if (bracketDepth < 0) return undefined;
197
+ index += 1;
198
+ } else if (bracketDepth === 0 && (char === '>' || /\s/.test(char))) break;
199
+ else index += 1;
200
+ }
201
+ if (quote || bracketDepth !== 0) return undefined;
202
+ const compound = parseCompound(input.slice(start, index));
203
+ if (!compound) return undefined;
204
+ if (compounds.length) combinators.push(pendingCombinator!);
205
+ compounds.push(compound);
206
+ pendingCombinator = undefined;
207
+ }
208
+ return compounds.length && !pendingCombinator ? { compounds, combinators } : undefined;
209
+ }
210
+
211
+ function elementAttributes(element: ElementRecord): Readonly<Record<string, string>> {
212
+ return element.attributes ?? {};
213
+ }
214
+
215
+ function terminalGeneratedCompound(element: ElementRecord): CompoundMatcher | undefined {
216
+ const selector = element.selector;
217
+ if (!selector) return undefined;
218
+ const parts = parseCssMatcher(selector)?.compounds;
219
+ return parts?.at(-1);
220
+ }
221
+
222
+ function matchesCompound(element: ElementRecord, compound: CompoundMatcher): boolean {
223
+ const tag = element.tag;
224
+ if (compound.tag && compound.tag !== '*' && (typeof tag !== 'string' || tag.toLowerCase() !== compound.tag)) return false;
225
+ const attributes = elementAttributes(element);
226
+ const generated = terminalGeneratedCompound(element);
227
+ const actualId = attributes.id ?? generated?.id;
228
+ if (compound.id && actualId !== compound.id) return false;
229
+ const classNames = new Set((attributes.class ?? '').split(/\s+/).filter(Boolean));
230
+ for (const className of generated?.classes ?? []) classNames.add(className);
231
+ if (compound.classes.some((className) => !classNames.has(className))) return false;
232
+ for (const attribute of compound.attributes) {
233
+ const actual = Object.entries(attributes).find(([name]) => name.toLowerCase() === attribute.name)?.[1];
234
+ if (actual === undefined || (attribute.value !== undefined && actual !== attribute.value)) return false;
235
+ }
236
+ if (compound.nthOfType !== undefined && generated?.nthOfType !== compound.nthOfType) return false;
237
+ return true;
238
+ }
239
+
240
+ function pathSegments(element: ElementRecord): readonly string[] | undefined {
241
+ const domPath = element.domPath;
242
+ return typeof domPath === 'string' && domPath ? domPath.split('/') : undefined;
243
+ }
244
+
245
+ function treeScope(element: ElementRecord): string | undefined {
246
+ const frame = element.frame;
247
+ if (typeof frame !== 'object' || frame === null || typeof (frame as Record<string, unknown>).frameId !== 'string') return undefined;
248
+ const shadow = element.shadow;
249
+ if (typeof shadow !== 'object' || shadow === null || (shadow as Record<string, unknown>).inShadowDom !== true) return `${(frame as Record<string, unknown>).frameId}:light`;
250
+ const values = shadow as Record<string, unknown>;
251
+ return `${(frame as Record<string, unknown>).frameId}:shadow:${String(values.chainDepth)}:${String(values.hostSelector)}`;
252
+ }
253
+
254
+ function isAncestorOf(ancestor: ElementRecord, descendant: ElementRecord, direct: boolean): boolean {
255
+ const ancestorPath = pathSegments(ancestor);
256
+ const descendantPath = pathSegments(descendant);
257
+ if (!ancestorPath || !descendantPath || ancestorPath.length >= descendantPath.length || treeScope(ancestor) !== treeScope(descendant)) return false;
258
+ if (direct && ancestorPath.length !== descendantPath.length - 1) return false;
259
+ return ancestorPath.every((segment, index) => descendantPath[index] === segment);
260
+ }
261
+
262
+ function matchesCssMatcher(elements: readonly ElementRecord[], target: ElementRecord, matcher: CssMatcher, index = matcher.compounds.length - 1): boolean {
263
+ if (!matchesCompound(target, matcher.compounds[index]!)) return false;
264
+ if (index === 0) return true;
265
+ const direct = matcher.combinators[index - 1] === 'child';
266
+ return elements.some((candidate) => isAncestorOf(candidate, target, direct) && matchesCssMatcher(elements, candidate, matcher, index - 1));
267
+ }
268
+
269
+ /** Elements matching the supported CSS subset against their recorded DOM facts.
270
+ * A verbatim collector-generated selector remains an exact lookup. */
41
271
  export function findBySelector(elements: readonly ElementRecord[], selector: string): ElementRecord[] {
42
- return elements.filter((e) => e.selector === selector);
272
+ const exact = elements.filter((element) => element.selector === selector);
273
+ if (exact.length) return exact;
274
+ const matcher = parseCssMatcher(selector);
275
+ return matcher ? elements.filter((element) => matchesCssMatcher(elements, element, matcher)) : [];
43
276
  }
44
277
 
45
278
  /** The element with this `backendNodeId`, if any. */
@@ -101,9 +334,9 @@ export interface ParsedSelectorInput {
101
334
  * - `axid:<id>` — accessibility node id
102
335
  * - `ax:<name>` — accessibility name (substring)
103
336
  * - `text:<needle>`— text content (substring)
104
- * - anything else — a CSS-ish selector, matched exactly against the
105
- * collector's stored `selector` string (the form every design sample
106
- * uses, e.g. `--selector ".toast-container"`)
337
+ * - anything else — a supported CSS selector: tag, `#id`, `.class`,
338
+ * `[attr]`, `[attr="value"]`, and descendant/child compound sequences,
339
+ * or a verbatim collector-generated selector path.
107
340
  */
108
341
  export function parseSelectorInput(input: string): ParsedSelectorInput {
109
342
  const prefixes: Array<[string, SelectorInputKind]> = [
@@ -330,40 +330,39 @@ export async function waitForPageLoad(
330
330
 
331
331
  /** Root-help representation of this branch, assembled by `src/capture.ts`. */
332
332
  export const COMMAND_BLOCK = `<command name="session">
333
- the artifact container — a session records HAR and bundles every artifact; it may open a tab or adopt one later; while active, every command auto-targets its tab
334
- use when starting scoped work against a page: start first, then every other capture command needs no --target/--port threading
333
+ the artifact container — a session records HAR and bundles every artifact; it may open or adopt a tab; once it has one, every command auto-targets it
334
+ use when starting scoped work against a page: start with --url or --target, then every other capture command needs no --target/--port threading
335
335
  start · stop · list · view · har · log — \`capture session -h\`
336
336
  </command>`;
337
337
 
338
- const START_USAGE = `capture session start [--url <url>] [--hold] — open or prepare a tab, record HAR, and set the active capture context.
338
+ const START_USAGE = `capture session start [--url <url> | --target <tab-id>] [--hold] [--port <n>] — open or adopt a tab, record HAR, and set the active capture context.
339
339
 
340
340
  input:
341
- --url <url> Absolute URL to open in a fresh tab through CDP's
342
- Target.createTarget. Some endpoints, including Electron,
343
- expose existing tabs but reject Target.createTarget; on those
344
- endpoints --url cannot be used.
345
- --hold Hold one CDP browser connection open for the session's
346
- lifetime, so browser-level state (permission grants,
347
- ServiceWorker enablement) survives across separate commands.
348
- With no --url, start a held session and adopt an existing tab
349
- with this supported workflow:
350
- capture session start --hold --port <port>
351
- capture tab list --port <port>
352
- capture page navigate <url> --target <target-id> --port <port>
353
- The navigate command adopts that target for the session; later
354
- page commands then auto-target it.
355
- --port <n> CDP port to attach to; default is auto-detected across localhost.
341
+ --url <url> Absolute URL to open in a fresh tab through CDP's
342
+ Target.createTarget. Some endpoints, including Electron,
343
+ expose existing tabs but reject Target.createTarget; on
344
+ those endpoints --url cannot be used. Conflicts with
345
+ --target.
346
+ --target <tab-id> Existing page tab to adopt. Resolves a full target id or
347
+ an unambiguous prefix of at least four characters at the
348
+ selected CDP endpoint; start fails if it does not resolve.
349
+ CDP_TARGET does not select a tab for session start. Conflicts
350
+ with --url.
351
+ --hold Hold one CDP browser connection open for the session's
352
+ lifetime, so browser-level state (permission grants,
353
+ ServiceWorker enablement) survives across separate commands.
354
+ --port <n> CDP port to attach to; default is auto-detected across localhost.
356
355
 
357
356
  Output:
358
- One <session id=… path=… [url=…]> block: the session id, bundle dir, opened
359
- tab when --url succeeds, HAR recording id, and held-bridge pid when
357
+ One <session id=… path=… [url=…] [target=… port=…]> block: the session id,
358
+ bundle dir, opened or adopted tab, HAR recording id, and held-bridge pid when
360
359
  applicable. --json mirrors the same fields.
361
360
 
362
361
  effects:
363
362
  Creates a private session dir with shots/, starts a HAR recording, opens a
364
- fresh tab only when --url is supplied, optionally holds a CDP bridge, and
365
- registers the session as the active capture context so subsequent commands
366
- auto-target it.`;
363
+ fresh tab only when --url is supplied or adopts the resolved --target tab,
364
+ optionally holds a CDP bridge, and registers the session as the active capture
365
+ context so subsequent commands auto-target its tab.`;
367
366
 
368
367
  const STOP_USAGE = `capture session stop <session-id> — finalize a session and write its bundle manifest.
369
368
 
@@ -475,13 +474,13 @@ effects:
475
474
  the identity-bearing tailer record is registered in the session metadata.`;
476
475
 
477
476
  function printSessionHelp(): void {
478
- console.log(`capture session — the artifact container: records HAR and bundles artifacts; it may open a tab or adopt one later.
477
+ console.log(`capture session — the artifact container: records HAR and bundles artifacts; it may open or adopt a tab.
479
478
 
480
- An active session auto-targets its tab and auto-appends recorded traffic — no
479
+ An active session with a tab auto-targets it and auto-appends recorded traffic — no
481
480
  --target/--har threading. \`stop\` finalizes the session and writes its bundle
482
481
  manifest; \`view\` reads that manifest back.
483
482
 
484
- <subcommand name="start" args="[--url <url>] [--hold]" whenToUse="open or prepare a tab, start HAR, and set the active capture context"/>
483
+ <subcommand name="start" args="[--url <url> | --target <tab-id>] [--hold] [--port <n>]" whenToUse="open or adopt a tab, start HAR, and set the active capture context"/>
485
484
  <subcommand name="stop" args="<session-id>" whenToUse="finalize the session and write its bundle manifest"/>
486
485
  <subcommand name="list" args="" whenToUse="show active and stopped sessions"/>
487
486
  <subcommand name="view" args="<session-id> [--filter shots|har|logs|measure|motion|other]" whenToUse="read back a stopped session's bundle manifest"/>
@@ -506,6 +505,7 @@ export interface SessionStartWorld {
506
505
  deleteHar(harId: string): Promise<void>;
507
506
  detectCdpPort(): Promise<number>;
508
507
  openTab(port: number, url: string): Promise<CDPTarget>;
508
+ findTabById(port: number, targetId: string): Promise<CDPTarget | null>;
509
509
  closeTarget(port: number, targetId: string): Promise<void>;
510
510
  /** Attach to the freshly-opened tab and wait for load; returns page-load-timed-out. */
511
511
  awaitTabReady(target: CDPTarget, url: string): Promise<boolean>;
@@ -527,6 +527,10 @@ const productionStartWorld: SessionStartWorld = {
527
527
  const { openTab } = await import('../cdp.js');
528
528
  return openTab(port, url);
529
529
  },
530
+ async findTabById(port, targetId) {
531
+ const { findTabById } = await import('../cdp/targets.js');
532
+ return findTabById(port, targetId);
533
+ },
530
534
  async closeTarget(port, targetId) {
531
535
  const { closeTarget } = await import('../cdp/targets.js');
532
536
  return closeTarget(port, targetId);
@@ -600,7 +604,7 @@ async function start(parsed: ParsedArgs): Promise<void> {
600
604
  return;
601
605
  }
602
606
 
603
- if (rejectSurplusPositionals(parsed, 'start', 0, 'capture session start [--url <url>] [--hold]')) return;
607
+ if (rejectSurplusPositionals(parsed, 'start', 0, 'capture session start [--url <url> | --target <tab-id>] [--hold] [--port <n>]')) return;
604
608
 
605
609
  return withSessionScopeLifecycle(async () => {
606
610
  const active = getActiveSession();
@@ -616,6 +620,7 @@ async function start(parsed: ParsedArgs): Promise<void> {
616
620
  }
617
621
 
618
622
  const url = parsed.url ?? null;
623
+ const requestedTarget = parsed.target ?? null;
619
624
  const hold = parsed.hold === true;
620
625
  const id = generateId();
621
626
  const dir = sessionDir(id);
@@ -639,10 +644,20 @@ async function start(parsed: ParsedArgs): Promise<void> {
639
644
  const acquiredHarId = harId;
640
645
  acquired.push({ label: 'HAR recording', release: () => startWorld.deleteHar(acquiredHarId) });
641
646
 
642
- if (url || hold) {
647
+ if (url || requestedTarget || hold) {
643
648
  cdpPort = parsed.port ?? await startWorld.detectCdpPort();
644
649
  }
645
650
 
651
+ if (requestedTarget) {
652
+ target = await startWorld.findTabById(cdpPort!, requestedTarget);
653
+ if (!target) {
654
+ throw worldFailure(`No tab found for target "${requestedTarget}" on port ${cdpPort}. Run \`capture tab list --port ${cdpPort}\` to see available tabs.`);
655
+ }
656
+ if (target.type !== 'page') {
657
+ throw worldFailure(`Target "${requestedTarget}" on port ${cdpPort} is ${target.type}, not a page tab. Run \`capture tab list --port ${cdpPort}\` to see available tabs.`);
658
+ }
659
+ }
660
+
646
661
  if (url) {
647
662
  try {
648
663
  target = await startWorld.openTab(cdpPort!, url);
@@ -650,7 +665,7 @@ async function start(parsed: ParsedArgs): Promise<void> {
650
665
  const message = error instanceof Error ? error.message : String(error);
651
666
  if (message.toLowerCase().includes('not supported')) {
652
667
  throw worldFailure(
653
- `Target.createTarget is unsupported on port ${cdpPort}. Use existing tab: \`capture page navigate <url> --target <target-id> --port ${cdpPort}\` after \`capture session start --hold --port ${cdpPort}\` and \`capture tab list\`.`,
668
+ `Target.createTarget is unsupported on port ${cdpPort}. Use an existing tab: \`capture tab list --port ${cdpPort}\`, then \`capture session start --target <target-id> --port ${cdpPort}\`.`,
654
669
  error,
655
670
  );
656
671
  }
@@ -680,7 +695,7 @@ async function start(parsed: ParsedArgs): Promise<void> {
680
695
  startedAt: new Date().toISOString(),
681
696
  stoppedAt: null,
682
697
  stopping: false,
683
- url,
698
+ url: target?.url ?? url,
684
699
  targetId: target?.id ?? null,
685
700
  port: cdpPort,
686
701
  stepCount: 0,
@@ -699,14 +714,14 @@ async function start(parsed: ParsedArgs): Promise<void> {
699
714
  }
700
715
 
701
716
  const rows: FactLine[] = [fact`bundle dir: ${dir}`];
702
- if (target) rows.push(fact`tab ${target.id} opened at ${url ?? ''}`);
717
+ if (target) rows.push(url ? fact`tab ${target.id} opened at ${url}` : fact`tab ${target.id} adopted at ${target.url}`);
703
718
  if (pageLoadTimedOut) rows.push(fact`page load timed out after 10000ms; the session stays attached to target ${target?.id ?? ''}`);
704
719
  if (harId) rows.push(fact`HAR recording ${harId} — traffic auto-appends while the session is active`);
705
720
  if (hold) rows.push(fact`held CDP bridge: pid ${bridgePid ?? 0}`);
706
721
 
707
722
  emitResult({
708
723
  tag: 'session',
709
- attrs: { id, path: dir, ...(url ? { url } : {}) },
724
+ attrs: { id, path: dir, ...(target?.url ? { url: target.url } : {}), ...(target ? { target: target.id } : {}), ...(cdpPort !== null ? { port: cdpPort } : {}) },
710
725
  summary: fact`Session ${id} started; it is now the active capture context.`,
711
726
  sections: [lineList(rows)],
712
727
  followUp: fact`capture session stop ${id}`,
@@ -1,29 +0,0 @@
1
- /** Pure specification-help assembly from Capture's descriptor tree. */
2
- import { type RegistryNode } from './registry.js';
3
-
4
- const usage = (node: RegistryNode): string => `capture${node.path ? ` ${node.path}` : ''}`;
5
- const parameter = (name: string, grammar: string, required: boolean, variadic = false): string => ` ${required ? '<' : '['}${name}${required ? '>' : ']'} ${grammar}${variadic ? ' (variadic)' : ''}`;
6
-
7
- /** Renders only descriptor-owned content; help never parses inputs or performs effects. */
8
- export function assembleHelp(node: RegistryNode): string {
9
- const { help } = node;
10
- const lines = [usage(node), '', help.description, '', `When to use: ${help.whenToUse}`, `Model: ${help.model ?? help.rubric}`];
11
- if (help.globals) lines.push('', 'Globals:', help.globals);
12
- if (help.ioContract) lines.push('', `I/O contract: ${help.ioContract}`);
13
- if (node.kind === 'branch') {
14
- const children = node.children.filter((child) => child.visibility === 'public');
15
- if (children.length) {
16
- lines.push('', 'Commands:');
17
- for (const child of children) lines.push(` ${child.path.split(' ').at(-1)} ${child.help.description} When to use: ${child.help.whenToUse}`);
18
- }
19
- } else {
20
- if (node.positionals.length || node.flags.length) {
21
- lines.push('', 'Parameters:');
22
- for (const positional of node.positionals) lines.push(parameter(positional.name, positional.grammar, positional.required, positional.variadic));
23
- for (const flag of node.flags) lines.push(` ${flag.name}${flag.grammar === 'boolean' ? '' : ` <${flag.grammar}>`}${flag.required ? ' required' : ''}${flag.repeatable ? ' repeatable' : ''}${flag.default === undefined ? '' : ` default=${flag.default}`}${flag.values ? ` values=${flag.values.join('|')}` : ''}${flag.units ? ` units=${flag.units}` : ''}`);
24
- }
25
- lines.push('', `Constraints: ${help.constraints}`, `Output: ${help.orderedOutput}`, `Artifact ownership: ${help.artifactOwnership}`, `Effects: browser=${node.effects.browser}, session=${node.effects.session}, artifact=${node.effects.artifact}, environment=${node.effects.environment}`, `Recovery: ${node.recovery}`);
26
- }
27
- lines.push('', `Next action: ${help.followUp}`);
28
- return `${lines.join('\n')}\n`;
29
- }
@@ -1,22 +0,0 @@
1
- /** Pure pre-dispatch control precedence for U15's future launcher wiring. */
2
- import { assembleHelp } from './command-help.js';
3
- import { hasHelpFlag, hasRootVersionFlag, walkCommand } from './command-walker.js';
4
- import { CAPTURE_REGISTRY, type RegistryBranch, type RegistryNode } from './registry.js';
5
-
6
- export type CommandResolution =
7
- | { readonly kind: 'version' }
8
- | { readonly kind: 'help'; readonly node: RegistryNode; readonly text: string }
9
- | { readonly kind: 'dispatch'; readonly node: RegistryNode; readonly argv: readonly string[] }
10
- | { readonly kind: 'unknown-path'; readonly received: string; readonly expected: readonly string[]; readonly path: string };
11
-
12
- /**
13
- * Applies only control precedence. It deliberately does not parse leaf argv,
14
- * validate `--json`, invoke a handler, or allocate any effectful dependency.
15
- */
16
- export function resolveCommand(argv: readonly string[], root: RegistryBranch = CAPTURE_REGISTRY): CommandResolution {
17
- if (hasRootVersionFlag(argv)) return { kind: 'version' };
18
- const walked = walkCommand(argv, root);
19
- if (walked.kind === 'unknown-path') return { kind: 'unknown-path', received: walked.received, expected: walked.expected, path: walked.resolved.path };
20
- if (hasHelpFlag(walked.remaining)) return { kind: 'help', node: walked.node, text: assembleHelp(walked.node) };
21
- return { kind: 'dispatch', node: walked.node, argv: walked.remaining };
22
- }
@@ -1,35 +0,0 @@
1
- /** Pure exact command-path resolution for the descriptor registry. */
2
- import { CAPTURE_REGISTRY, type RegistryBranch, type RegistryNode } from './registry.js';
3
-
4
- export type WalkResult =
5
- | { readonly kind: 'resolved'; readonly node: RegistryNode; readonly remaining: readonly string[] }
6
- | { readonly kind: 'unknown-path'; readonly received: string; readonly resolved: RegistryBranch; readonly expected: readonly string[]; readonly remaining: readonly string[] };
7
-
8
- /**
9
- * Resolves only exact canonical path tokens. It stops before the first option,
10
- * resolves the deepest known leaf/branch, and leaves all leaf argv untouched.
11
- * Parsing, output compatibility, effects, and handler dispatch are intentionally
12
- * outside this pure walker.
13
- */
14
- export function walkCommand(argv: readonly string[], root: RegistryBranch = CAPTURE_REGISTRY): WalkResult {
15
- let current: RegistryNode = root;
16
- let index = 0;
17
- while (current.kind === 'branch' && index < argv.length) {
18
- const token = argv[index];
19
- if (token.startsWith('-')) break;
20
- const child: RegistryNode | undefined = current.children.find((candidate: RegistryNode) => candidate.path.split(' ').at(-1) === token);
21
- if (!child) return { kind: 'unknown-path', received: token, resolved: current, expected: current.children.filter((candidate) => candidate.visibility === 'public').map((candidate) => candidate.path.split(' ').at(-1)!), remaining: argv.slice(index) };
22
- current = child;
23
- index += 1;
24
- }
25
- return { kind: 'resolved', node: current, remaining: argv.slice(index) };
26
- }
27
-
28
- export function hasHelpFlag(argv: readonly string[]): boolean {
29
- return argv.includes('-h') || argv.includes('--help');
30
- }
31
-
32
- /** Root version is the sole accepted version spelling and wins before walking. */
33
- export function hasRootVersionFlag(argv: readonly string[]): boolean {
34
- return argv[0] === '--version';
35
- }