@tikoci/centrs 0.1.3 → 0.1.4

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/GLOSSARY.txt +213 -0
  3. package/LICENSE +21 -0
  4. package/README.md +36 -8
  5. package/commands/AGENTS.md +7 -1
  6. package/commands/api/README.md +19 -18
  7. package/commands/api/examples.md +15 -0
  8. package/commands/btest/README.md +21 -32
  9. package/commands/check/README.md +6 -1
  10. package/commands/discover/README.md +5 -11
  11. package/commands/execute/README.md +41 -29
  12. package/commands/execute/examples.md +41 -3
  13. package/commands/explain/README.md +1716 -0
  14. package/commands/explain/examples.md +606 -0
  15. package/commands/retrieve/README.md +25 -27
  16. package/commands/retrieve/examples.md +16 -0
  17. package/commands/transfer/README.md +28 -20
  18. package/commands/transfer/examples.md +29 -0
  19. package/docs/CLI.md +353 -0
  20. package/docs/CONSTITUTION.md +66 -15
  21. package/docs/MATRIX.md +38 -1
  22. package/docs/errors/README.md +3 -0
  23. package/docs/errors/quickchr/machine-not-found.md +22 -0
  24. package/docs/errors/quickchr/machine-stopped.md +20 -0
  25. package/docs/errors/quickchr/package-unavailable.md +27 -0
  26. package/docs/errors/quickchr/unsupported-via.md +35 -0
  27. package/docs/errors/quickchr/unsupported.md +35 -0
  28. package/docs/errors/routeros/version-unsupported.md +26 -0
  29. package/docs/errors/transport/provider-trust.md +29 -0
  30. package/docs/errors/usage/stdin-ignored.md +28 -0
  31. package/docs/errors/validation/unknown-attribute.md +37 -4
  32. package/package.json +45 -10
  33. package/src/api-fanout.ts +29 -14
  34. package/src/api.ts +97 -54
  35. package/src/cli/api.ts +25 -6
  36. package/src/cli/btest.ts +18 -2
  37. package/src/cli/execute.ts +25 -6
  38. package/src/cli/explain.ts +503 -0
  39. package/src/cli/retrieve.ts +33 -1
  40. package/src/cli/selection.ts +71 -11
  41. package/src/cli/settings.ts +2 -2
  42. package/src/cli/transfer.ts +23 -6
  43. package/src/cli.ts +15 -2
  44. package/src/core/envelope.ts +5 -2
  45. package/src/core/error-catalog.ts +40 -0
  46. package/src/core/preflight.ts +306 -0
  47. package/src/core/routeros-string.ts +24 -0
  48. package/src/errors.ts +1 -0
  49. package/src/execute-fanout.ts +29 -14
  50. package/src/execute.ts +114 -63
  51. package/src/explain/arg-tokens.ts +114 -0
  52. package/src/explain/args.ts +1403 -0
  53. package/src/explain/blocks.ts +132 -0
  54. package/src/explain/brace-slots.ts +143 -0
  55. package/src/explain/brace-tokens.ts +38 -0
  56. package/src/explain/catalog.ts +1345 -0
  57. package/src/explain/coordinates.ts +329 -0
  58. package/src/explain/defects.ts +217 -0
  59. package/src/explain/is-known-menu.ts +17 -0
  60. package/src/explain/menus.ts +664 -0
  61. package/src/explain/operator-tokens.ts +310 -0
  62. package/src/explain/operators.ts +458 -0
  63. package/src/explain/path-tokens.ts +150 -0
  64. package/src/explain/pathresolve.ts +1324 -0
  65. package/src/explain/quoted-string.ts +241 -0
  66. package/src/explain/scope-brace.ts +308 -0
  67. package/src/explain/segment.ts +757 -0
  68. package/src/explain/string-tokens.ts +67 -0
  69. package/src/explain/symbol-values.ts +520 -0
  70. package/src/explain/symbols.ts +1597 -0
  71. package/src/explain/token-ranges.ts +72 -0
  72. package/src/explain/transport.ts +497 -0
  73. package/src/explain/value-tokens.ts +80 -0
  74. package/src/explain/values.ts +357 -0
  75. package/src/explain/verbs.ts +64 -0
  76. package/src/explain/verbsplit.ts +813 -0
  77. package/src/explain/write.ts +815 -0
  78. package/src/explain.ts +2152 -0
  79. package/src/index.ts +206 -4
  80. package/src/protocols/mac-telnet-console.ts +61 -17
  81. package/src/resolver/facts.ts +1 -1
  82. package/src/resolver/index.ts +23 -4
  83. package/src/resolver/quickchr-provider.ts +597 -0
  84. package/src/resolver/selection.ts +99 -12
  85. package/src/resolver/service-endpoint.ts +155 -0
  86. package/src/resolver/target.ts +1 -1
  87. package/src/retrieve-fanout.ts +26 -12
  88. package/src/retrieve.ts +81 -18
  89. package/src/transfer-fanout.ts +42 -25
  90. package/src/transfer.ts +87 -44
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Quote RouterOS command text as one double-quoted string literal.
3
+ *
4
+ * `$` is load-bearing here: RouterOS expands variables inside double quotes,
5
+ * so leaving it bare changes the bytes seen by wrappers such as
6
+ * `:put [:parse "<input>"]` before the parser receives them.
7
+ * Control whitespace (`\t`/`\r`/`\n`) is escaped so the decoded string
8
+ * carries the caller's original bytes. This is defensive: RouterOS 7.20
9
+ * changelog says "replace TAB characters with spaces when editing scripts
10
+ * and added `tab-width` in `/console/settings`" — version-gated (absent
11
+ * before 7.20), width-configurable via `tab-width`, and scoped to the
12
+ * script editor, not grounded as string-literal expansion (see #276).
13
+ * Escaping preserves the caller's bytes whether the device expands or
14
+ * preserves a raw tab.
15
+ */
16
+ export function routerOsStringLiteral(value: string): string {
17
+ return `"${value
18
+ .replaceAll("\\", "\\\\")
19
+ .replaceAll('"', '\\"')
20
+ .replaceAll("$", "\\$")
21
+ .replaceAll("\t", "\\t")
22
+ .replaceAll("\r", "\\r")
23
+ .replaceAll("\n", "\\n")}"`;
24
+ }
package/src/errors.ts CHANGED
@@ -13,6 +13,7 @@ export type CentrsErrorCode =
13
13
  | `input/${string}`
14
14
  | `internal/${string}`
15
15
  | `mndp/${string}`
16
+ | `quickchr/${string}`
16
17
  | `routeros/${string}`
17
18
  | `settings/${string}`
18
19
  | `target/${string}`
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * Boundaries (see `commands/execute/README.md`, `docs/CONSTITUTION.md`), shared
11
11
  * with `api-fanout.ts` / `retrieve-fanout.ts`:
12
- * - The CDB is loaded ONCE (`expandCdbSelection`); a cdb member is resolved with
12
+ * - The CDB is loaded ONCE (`expandSelection`); a cdb member is resolved with
13
13
  * its pre-resolved record, so `resolveCdb`'s `__default__` synthetic fallback
14
14
  * never collides ad-hoc literals.
15
15
  * - A `--default` / `__default__` member is guarded from being dialed as the
@@ -52,12 +52,12 @@ import {
52
52
  } from "./execute.ts";
53
53
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
54
54
  import {
55
- type CdbSelectionExpansion,
56
- type CdbSelectionMember,
57
- type CdbSelectionResolveInput,
58
- expandCdbSelection,
55
+ expandSelection,
59
56
  isDefaultRecordTarget,
60
57
  loadEnvFileDefaults,
58
+ type SelectionExpansion,
59
+ type SelectionMember,
60
+ type SelectionResolveInput,
61
61
  type TargetSelection,
62
62
  } from "./resolver/index.ts";
63
63
  import { toYaml } from "./retrieve.ts";
@@ -69,6 +69,8 @@ export interface ExecuteSelectionSummary {
69
69
  all: boolean;
70
70
  default: boolean;
71
71
  positionals: readonly string[];
72
+ /** quickchr machine names (`--quickchr`, repeatable; exclusive of the rest). */
73
+ quickchr?: readonly string[];
72
74
  }
73
75
 
74
76
  export interface ExecuteFanoutOperationMeta {
@@ -91,10 +93,10 @@ export type ExecuteFanoutErrorEnvelope =
91
93
  export interface ExecuteFanoutInternals {
92
94
  expand?: (
93
95
  selection: TargetSelection,
94
- input: CdbSelectionResolveInput,
96
+ input: SelectionResolveInput,
95
97
  env: Record<string, string | undefined>,
96
98
  config?: Record<string, string | undefined>,
97
- ) => Promise<CdbSelectionExpansion>;
99
+ ) => Promise<SelectionExpansion>;
98
100
  execute?: (
99
101
  resolved: ResolvedExecuteRequest,
100
102
  ) => Promise<ExecuteSuccessEnvelope>;
@@ -116,6 +118,7 @@ function summarizeSelection(
116
118
  all: selection.all,
117
119
  default: selection.default,
118
120
  positionals: selection.positionals,
121
+ quickchr: selection.quickchr,
119
122
  };
120
123
  }
121
124
 
@@ -124,7 +127,7 @@ function fanoutVia(
124
127
  request: ExecuteRequest,
125
128
  env: Record<string, string | undefined>,
126
129
  globalVia: RouterOsProtocol,
127
- expansion: CdbSelectionExpansion,
130
+ expansion: SelectionExpansion,
128
131
  ): RouterOsProtocol {
129
132
  // Deliberately CLI/env only, not `config` — see the identical note in
130
133
  // `retrieve-fanout.ts`'s `resolveFanoutConcurrencyProtocol`.
@@ -139,7 +142,7 @@ function fanoutVia(
139
142
  return anyNative ? "native-api" : globalVia;
140
143
  }
141
144
 
142
- function memberTargetMeta(member: CdbSelectionMember): {
145
+ function memberTargetMeta(member: SelectionMember): {
143
146
  input?: string;
144
147
  identity?: string;
145
148
  recordIndex?: number;
@@ -151,6 +154,9 @@ function memberTargetMeta(member: CdbSelectionMember): {
151
154
  recordIndex: member.recordIndex,
152
155
  };
153
156
  }
157
+ if (member.kind === "quickchr") {
158
+ return { input: member.name, identity: member.name };
159
+ }
154
160
  return { input: member.input };
155
161
  }
156
162
 
@@ -163,7 +169,7 @@ function memberTargetMeta(member: CdbSelectionMember): {
163
169
  */
164
170
  function relabelAdhocMember(
165
171
  envelope: ExecuteEnvelope,
166
- member: CdbSelectionMember,
172
+ member: SelectionMember,
167
173
  ): ExecuteEnvelope {
168
174
  if (member.kind !== "literal") {
169
175
  return envelope;
@@ -195,7 +201,7 @@ export async function executeFanout(
195
201
  const global = resolveExecuteGlobalContext(request, env, config);
196
202
  const selectionSummary = summarizeSelection(selection);
197
203
 
198
- const expand = internals.expand ?? expandCdbSelection;
204
+ const expand = internals.expand ?? expandSelection;
199
205
  const expansion = await expand(
200
206
  selection,
201
207
  {
@@ -230,7 +236,7 @@ export async function executeFanout(
230
236
  const sleep = internals.sleep ?? defaultFanoutSleep;
231
237
 
232
238
  const targets = await runFanout<
233
- CdbSelectionMember,
239
+ SelectionMember,
234
240
  ResolvedExecuteRequest,
235
241
  ExecuteEnvelope
236
242
  >({
@@ -254,6 +260,15 @@ export async function executeFanout(
254
260
  { cdbResolution: member.resolution, config },
255
261
  );
256
262
  }
263
+ if (member.kind === "quickchr") {
264
+ // The live descriptor resolves here, per member — a stopped/unknown
265
+ // machine is an inner per-target failure, not an expansion error.
266
+ return resolveExecuteRequest(
267
+ { ...request, targetInput: undefined, quickchr: member.name },
268
+ env,
269
+ { config },
270
+ );
271
+ }
257
272
  return resolveExecuteRequest(
258
273
  { ...request, targetInput: member.input },
259
274
  env,
@@ -292,7 +307,7 @@ export async function executeFanout(
292
307
  async function assertFanoutWriteConfirmed(
293
308
  request: ExecuteRequest,
294
309
  summary: ExecuteRequestSummary,
295
- expansion: CdbSelectionExpansion,
310
+ expansion: SelectionExpansion,
296
311
  ): Promise<void> {
297
312
  if (!summary.write || request.yes) {
298
313
  return;
@@ -323,7 +338,7 @@ interface ExecuteFanoutEnvelopeInput {
323
338
  requestSummary: ExecuteRequestSummary;
324
339
  settings: CommonSettingsMeta;
325
340
  via: RouterOsProtocol | null;
326
- warnings: CdbSelectionExpansion["warnings"];
341
+ warnings: SelectionExpansion["warnings"];
327
342
  targets: readonly ExecuteEnvelope[];
328
343
  }
329
344
 
package/src/execute.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  pathTokens,
18
18
  } from "./core/inspect.ts";
19
19
  import { mapRouterOsError } from "./core/routeros-errors.ts";
20
+ import { routerOsStringLiteral } from "./core/routeros-string.ts";
20
21
  import { CentrsError, serializeCentrsError } from "./errors.ts";
21
22
  import {
22
23
  createProtocolAdapter,
@@ -31,6 +32,7 @@ import {
31
32
  parseScriptFor,
32
33
  } from "./protocols/mac-telnet-console.ts";
33
34
  import {
35
+ assertNoQuickchrOverrideConflict,
34
36
  type CdbResolution,
35
37
  effectiveHostCandidate,
36
38
  isIpTransport,
@@ -38,6 +40,7 @@ import {
38
40
  loadEnvFileDefaults,
39
41
  parseDuration,
40
42
  parseResolvePolicy,
43
+ quickchrConnection,
41
44
  type ResolvedAuth,
42
45
  type ResolvedSetting,
43
46
  type ResolvedTarget,
@@ -46,6 +49,7 @@ import {
46
49
  resolveCdb,
47
50
  resolveMacTarget,
48
51
  resolveOptionalIntegerSetting,
52
+ resolveQuickchrTarget,
49
53
  resolveStringSetting,
50
54
  resolveTarget,
51
55
  toCoreSource,
@@ -57,6 +61,12 @@ export type ExecuteOutputFormat = (typeof executeOutputFormats)[number];
57
61
 
58
62
  export interface ExecuteRequest {
59
63
  targetInput?: string;
64
+ /**
65
+ * quickchr machine name (`--quickchr <name>`): resolve host/port/auth from
66
+ * the live VM descriptor instead of CDB/env (`docs/CONSTITUTION.md` →
67
+ * Resolution providers). Conflicts with host/port/username/password/sshKey.
68
+ */
69
+ quickchr?: string;
60
70
  command: string;
61
71
  via?: string;
62
72
  host?: string;
@@ -286,19 +296,30 @@ export async function resolveExecuteRequest(
286
296
  validateExecuteRequestShape(request);
287
297
  const canonical = canonicalizeExecuteCommand(request.command);
288
298
  const config = options.config ?? (await loadEnvFileDefaults(env));
299
+ // A quickchr target is a named-live-provider: it supplies host/port/auth from
300
+ // the live descriptor and bypasses CDB/env resolution for those fields
301
+ // entirely (no CDB load, no `__default__` ladder, no MAC resolution).
302
+ let quickchrResolution: Awaited<
303
+ ReturnType<typeof resolveQuickchrTarget>
304
+ > | null = null;
305
+ if (request.quickchr !== undefined) {
306
+ assertNoQuickchrOverrideConflict(request, request.quickchr);
307
+ quickchrResolution = await resolveQuickchrTarget(request.quickchr);
308
+ }
289
309
  // Fan-out passes the pre-resolved CDB record for a member (the CDB is loaded
290
- // once in `expandCdbSelection`); single-target resolves it here.
291
- const cdbResolution =
292
- options.cdbResolution ??
293
- (await resolveCdb(
294
- {
295
- targetInput: request.targetInput,
296
- cdbFile: request.cdbFile,
297
- cdbPassword: request.cdbPassword,
298
- },
299
- env,
300
- config,
301
- ));
310
+ // once in `expandSelection`); single-target resolves it here.
311
+ const cdbResolution = quickchrResolution
312
+ ? undefined
313
+ : (options.cdbResolution ??
314
+ (await resolveCdb(
315
+ {
316
+ targetInput: request.targetInput,
317
+ cdbFile: request.cdbFile,
318
+ cdbPassword: request.cdbPassword,
319
+ },
320
+ env,
321
+ config,
322
+ )));
302
323
  const via = resolveExecuteProtocol(request, env, cdbResolution, config);
303
324
  const format = resolveFormat(request, env, config);
304
325
  const validate = resolveBooleanSetting(
@@ -325,50 +346,72 @@ export async function resolveExecuteRequest(
325
346
  undefined,
326
347
  config,
327
348
  );
328
- const macResolution = isIpTransport(via.value)
329
- ? await resolveMacTarget({
330
- host: request.host,
331
- targetInput: request.targetInput,
332
- cdbTarget: cdbResolution?.target,
349
+ const macResolution =
350
+ quickchrResolution === null && isIpTransport(via.value)
351
+ ? await resolveMacTarget({
352
+ host: request.host,
353
+ targetInput: request.targetInput,
354
+ cdbTarget: cdbResolution?.target,
355
+ env,
356
+ config,
357
+ policy: parseResolvePolicy(
358
+ request.resolve ??
359
+ env["CENTRS_RESOLVE"] ??
360
+ config["CENTRS_RESOLVE"],
361
+ ),
362
+ operation: "execute",
363
+ })
364
+ : undefined;
365
+ const connection = quickchrResolution
366
+ ? quickchrConnection(quickchrResolution, via.value)
367
+ : null;
368
+ const target = connection
369
+ ? connection.target
370
+ : resolveTarget(
371
+ {
372
+ targetInput: request.targetInput,
373
+ host: request.host,
374
+ port: request.port,
375
+ macResolution,
376
+ },
333
377
  env,
378
+ via.value,
379
+ cdbResolution,
334
380
  config,
335
- policy: parseResolvePolicy(
336
- request.resolve ?? env["CENTRS_RESOLVE"] ?? config["CENTRS_RESOLVE"],
337
- ),
338
- operation: "execute",
339
- })
340
- : undefined;
341
- const target = resolveTarget(
342
- {
343
- targetInput: request.targetInput,
344
- host: request.host,
345
- port: request.port,
346
- macResolution,
347
- },
348
- env,
349
- via.value,
350
- cdbResolution,
351
- config,
352
- );
353
- const auth = resolveAuth(
354
- {
355
- username: request.username,
356
- password: request.password,
357
- sshKey: request.sshKey,
358
- },
359
- env,
360
- cdbResolution,
361
- config,
362
- );
363
- const insecure = resolveBooleanSetting(
364
- request.insecure,
365
- env,
366
- "CENTRS_INSECURE",
367
- false,
368
- "insecure",
369
- cdbResolution?.overrides.insecure,
370
- config,
371
- );
381
+ );
382
+ const auth = connection
383
+ ? connection.auth
384
+ : resolveAuth(
385
+ {
386
+ username: request.username,
387
+ password: request.password,
388
+ sshKey: request.sshKey,
389
+ },
390
+ env,
391
+ cdbResolution,
392
+ config,
393
+ );
394
+ // A quickchr TLS/SSH endpoint is trusted by provenance (self-signed VM cert /
395
+ // ephemeral host key) — `insecure` is forced with provider provenance and the
396
+ // provider-trust warning rides the envelope (see QuickchrConnection.insecure).
397
+ const insecure =
398
+ connection?.insecure === true
399
+ ? {
400
+ value: true,
401
+ source: {
402
+ kind: "provider" as const,
403
+ key: `quickchr:${request.quickchr}`,
404
+ },
405
+ }
406
+ : resolveBooleanSetting(
407
+ request.insecure,
408
+ env,
409
+ "CENTRS_INSECURE",
410
+ false,
411
+ "insecure",
412
+ cdbResolution?.overrides.insecure,
413
+ config,
414
+ );
372
415
 
373
416
  return {
374
417
  command: request.command,
@@ -383,7 +426,9 @@ export async function resolveExecuteRequest(
383
426
  maxResultsBytes,
384
427
  yes: request.yes ?? false,
385
428
  verbose: request.verbose ?? false,
386
- warnings: cdbResolution?.warnings ?? [],
429
+ warnings: connection
430
+ ? [...connection.warnings]
431
+ : (cdbResolution?.warnings ?? []),
387
432
  };
388
433
  }
389
434
 
@@ -694,7 +739,7 @@ async function validateExecuteCommand(
694
739
  context: {
695
740
  path: resolved.canonical.path,
696
741
  verb: resolved.canonical.verb,
697
- attribute: missing[0],
742
+ parameter: missing[0],
698
743
  requestedAttributes: requested,
699
744
  availableAttributes,
700
745
  validationSource: "/console/inspect request=child+completion",
@@ -716,7 +761,7 @@ async function validateExecuteCommand(
716
761
  * Validate a command over a console transport (mac-telnet or ssh) with a single
717
762
  * `:put [:parse ...]`. Unlike REST/native (a syntax gate plus a `/console/inspect`
718
763
  * semantic gate), one console `:parse` reports both `syntax error` and `bad
719
- * parameter <name>`, so it covers syntax and the unknown-attribute (semantic)
764
+ * parameter <name>`, so it covers syntax and the unknown-attribute (name-level)
720
765
  * gate at once — identical strings over mac-telnet and ssh (CHR 7.23.1 grounded;
721
766
  * see `mac-telnet-console.ts` and `ssh.ts`).
722
767
  */
@@ -745,7 +790,7 @@ async function validateConsoleParseCommand(
745
790
  script: parseScriptFor(resolved.command),
746
791
  });
747
792
  // Throws validation/syntax or validation/unknown-attribute on a bad parse.
748
- classifyParseResult(result.ret ?? "", resolved.command);
793
+ classifyParseResult(result.ret ?? "", resolved.command, resolved.via.value);
749
794
  return {
750
795
  enabled: true,
751
796
  source: `:put [:parse ...] over ${resolved.via.value}`,
@@ -777,7 +822,14 @@ async function runSyntaxGate(
777
822
  }
778
823
  const script = `:put [:parse ${routerOsStringLiteral(resolved.command)}]`;
779
824
  try {
780
- await backend.execute({ path: "", command: "", script });
825
+ // GH#230: `:parse` never throws (CHR 7.23.3). The diagnostic rides the
826
+ // success value's text — over REST/native the `ret` field (HTTP 200 /
827
+ // `as-string`), over console the printed output. The old code only watched
828
+ // for a thrown transport/RouterOS error and so never rejected anything.
829
+ // Read the return and classify it: one `:parse` covers syntax and the
830
+ // unknown-attribute (name-level) gate.
831
+ const result = await backend.execute({ path: "", command: "", script });
832
+ classifyParseResult(result.ret ?? "", resolved.command, resolved.via.value);
781
833
  } catch (error) {
782
834
  // The preflight is a syntax gate, but `backend.execute` also surfaces
783
835
  // connection and authentication failures (login happens lazily here).
@@ -788,6 +840,9 @@ async function runSyntaxGate(
788
840
  if (error instanceof CentrsError && isPreflightTransportError(error)) {
789
841
  throw error;
790
842
  }
843
+ if (error instanceof CentrsError && error.code.startsWith("validation/")) {
844
+ throw error;
845
+ }
791
846
  // If the mapped RouterOS fault carried a `(line N column M)` byte offset,
792
847
  // keep it on the wrapped syntax error rather than dropping it.
793
848
  const position = error instanceof CentrsError ? error.position : undefined;
@@ -1374,10 +1429,6 @@ function tokenizeRouterOsCli(input: string): string[] {
1374
1429
  return tokens;
1375
1430
  }
1376
1431
 
1377
- function routerOsStringLiteral(value: string): string {
1378
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
1379
- }
1380
-
1381
1432
  function syntaxCause(error: unknown): unknown {
1382
1433
  if (error instanceof CentrsError) {
1383
1434
  return error.causeData ?? error.context ?? error.toJSON();
@@ -0,0 +1,114 @@
1
+ /**
2
+ * B2 arg fill — claims argument names and their `=` on the residual.
3
+ *
4
+ * This is the second B2 fill of #264, added after the operator fill. It runs
5
+ * **after** the proof-only spans (`comment` + `variable-*`) and **before** the
6
+ * operator fill, so the fill order is structural: `spans` claims first, then
7
+ * `arg`, then `operator` sees only what neither wanted. That ordering is why the
8
+ * operator fill can abstain on `, / = -` outside `( )`, on bytes glued after
9
+ * an argument `=`, and on bytes glued into an argument name — those bytes
10
+ * belong to a later fill, not to a smarter operator scanner.
11
+ *
12
+ * The source is `src/explain/args.ts`'s `Argument` — `span` and `valueSpan`
13
+ * already rebased into document analyzed-byte space by `src/explain.ts`. The
14
+ * name run is `[span.start, valueSpan.start - 1)` and the `=` is the single
15
+ * byte at `valueSpan.start - 1`; the value itself is `valueSpan` and is left
16
+ * for the next fill (see #293). A `positional` has no `name`/`valueSpan` and a
17
+ * `query` is a `?` word whose interior `args.ts` explicitly refuses to split
18
+ * (see `Argument.value`'s doc comment), so both are ignored — the `=` is
19
+ * derived from the token shape, never by scanning for the byte.
20
+ *
21
+ * `Argument.value` is not `Argument.text` and is never read: `value` is absent
22
+ * whenever the source spells a substitution or an escape this phase does not
23
+ * decode. This slice claims `span`/`valueSpan` *positions* only.
24
+ *
25
+ * A statement whose bytes were normalized is not addressable: `explain.ts`
26
+ * already refuses those (`its text was normalized`), and this fill inherits the
27
+ * refusal — it only sees tokens from `read === true` statements, which are
28
+ * exactly the addressable ones.
29
+ *
30
+ * Vocabulary is provisional until #264 B5: one `arg` class for both the name
31
+ * bytes and the `=` separator (design decision 2 of #293 — emit first, name
32
+ * later; `highlight`'s `syntax-meta` is a residual merge and never a source).
33
+ * If B5 splits the `=` into its own class, the byte coverage does not move,
34
+ * only the retag.
35
+ */
36
+
37
+ import type { ExplainArgumentToken, ExplainToken } from "../explain.ts";
38
+ import { clipToResidual } from "./token-ranges.ts";
39
+
40
+ export type ArgCandidate = ExplainArgumentToken;
41
+
42
+ /**
43
+ * Argument name + `=` spans on the residual.
44
+ *
45
+ * `analyzed` is the ASCII-normalized document text (only used for length
46
+ * checks); `residual` is the gap set left by earlier fills (sorted, no
47
+ * overlaps); `candidates` are the already-rebased `Argument` tokens from
48
+ * `read === true` statements. Every emitted span's bytes are fully inside
49
+ * `residual`, sorted by `start`, non-overlapping, and carry
50
+ * `class: "arg"` + `ev: "e11"`.
51
+ */
52
+ export function argSpans(
53
+ analyzed: string,
54
+ residual: readonly { start: number; end: number }[],
55
+ candidates: readonly ArgCandidate[],
56
+ ): ExplainToken[] {
57
+ const len = analyzed.length;
58
+ if (len === 0 || residual.length === 0 || candidates.length === 0) return [];
59
+
60
+ const out: ExplainToken[] = [];
61
+
62
+ for (const candidate of candidates) {
63
+ if (candidate.kind !== "attribute") continue;
64
+ if (candidate.valueSpan === undefined) continue;
65
+ if (candidate.name === undefined) continue;
66
+ const nameStart = candidate.span.start;
67
+ const eqEnd = candidate.valueSpan.start;
68
+ const eqStart = eqEnd - 1;
69
+ const nameEnd = eqStart;
70
+ // Defensive: token shape must give non-empty name and single-byte `=`.
71
+ if (
72
+ !Number.isInteger(nameStart) ||
73
+ !Number.isInteger(nameEnd) ||
74
+ !Number.isInteger(eqStart) ||
75
+ !Number.isInteger(eqEnd)
76
+ )
77
+ continue;
78
+ if (nameStart < 0 || eqEnd > len) continue;
79
+ // Non-empty name. This is also what rejects `valueSpan.start === 0`, where
80
+ // `eqStart` would be -1: `nameEnd` is -1 too, so any `nameStart >= 0` trips
81
+ // it. `analyzed[-1]` on the next line would be `undefined` and continue
82
+ // anyway, so the order is safe either way — but the name check is the one
83
+ // that carries the intent.
84
+ if (nameStart >= nameEnd) continue;
85
+ // The whole token's `=` byte must be `=` in the analyzed text — a cheap
86
+ // shape check that catches a caller that passed un-rebased or misaligned
87
+ // spans. Not a scan for `=`; the position is derived from `valueSpan`.
88
+ if (analyzed[eqStart] !== "=") continue;
89
+ // Also ensure the candidate lies fully inside the analyzed length.
90
+ if (candidate.span.start < 0 || candidate.span.end > len) continue;
91
+ if (candidate.valueSpan.start < 0 || candidate.valueSpan.end > len)
92
+ continue;
93
+
94
+ // The name run and the `=` are contiguous (`nameEnd === eqStart`), so
95
+ // clip them as ONE range per candidate. That yields the maximal `[name=]`
96
+ // run for free — split only where the residual itself has a hole — and
97
+ // makes cross-candidate fusion structurally impossible rather than merely
98
+ // unreachable: coalescing adjacent runs globally would merge two distinct
99
+ // attributes into one token if `lexArguments` ever stopped guaranteeing a
100
+ // separator between them. A `variable-*` span claiming only the `=` still
101
+ // leaves the name run behind, because the residual hole does the splitting.
102
+ for (const r of clipToResidual(nameStart, eqEnd, residual)) {
103
+ out.push({
104
+ start: r.start,
105
+ end: r.end,
106
+ class: "arg" as const,
107
+ ev: "e11",
108
+ });
109
+ }
110
+ }
111
+
112
+ out.sort((a, b) => a.start - b.start || a.end - b.end);
113
+ return out;
114
+ }