@tikoci/centrs 0.1.2 → 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 (134) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/GLOSSARY.txt +284 -0
  3. package/LICENSE +21 -0
  4. package/README.md +55 -11
  5. package/SECURITY.md +3 -3
  6. package/commands/AGENTS.md +7 -1
  7. package/commands/api/README.md +46 -27
  8. package/commands/api/examples.md +124 -6
  9. package/commands/btest/README.md +21 -32
  10. package/commands/check/README.md +376 -53
  11. package/commands/check/examples.md +390 -0
  12. package/commands/devices/README.md +177 -37
  13. package/commands/devices/examples.md +148 -0
  14. package/commands/discover/README.md +5 -11
  15. package/commands/execute/README.md +73 -26
  16. package/commands/execute/examples.md +131 -9
  17. package/commands/explain/README.md +1716 -0
  18. package/commands/explain/examples.md +606 -0
  19. package/commands/retrieve/README.md +55 -71
  20. package/commands/retrieve/examples.md +56 -9
  21. package/commands/settings/README.md +286 -22
  22. package/commands/settings/examples.md +408 -0
  23. package/commands/terminal/README.md +6 -0
  24. package/commands/transfer/README.md +57 -19
  25. package/commands/transfer/examples.md +95 -0
  26. package/docs/CLI.md +353 -0
  27. package/docs/CONSTITUTION.md +102 -26
  28. package/docs/MATRIX.md +106 -173
  29. package/docs/errors/README.md +5 -1
  30. package/docs/errors/cdb/empty-selection.md +19 -0
  31. package/docs/errors/input/incomplete-gps.md +9 -0
  32. package/docs/errors/input/invalid-altitude.md +9 -0
  33. package/docs/errors/input/invalid-bbox.md +9 -0
  34. package/docs/errors/input/invalid-coordinate.md +9 -0
  35. package/docs/errors/input/invalid-radius.md +9 -0
  36. package/docs/errors/internal/settings-failed.md +9 -0
  37. package/docs/errors/quickchr/machine-not-found.md +22 -0
  38. package/docs/errors/quickchr/machine-stopped.md +20 -0
  39. package/docs/errors/quickchr/package-unavailable.md +27 -0
  40. package/docs/errors/quickchr/unsupported-via.md +35 -0
  41. package/docs/errors/quickchr/unsupported.md +35 -0
  42. package/docs/errors/routeros/version-unsupported.md +26 -0
  43. package/docs/errors/settings/consequential-value.md +26 -0
  44. package/docs/errors/settings/invalid-boolean.md +21 -3
  45. package/docs/errors/settings/invalid-format.md +18 -3
  46. package/docs/errors/settings/invalid-integer.md +18 -3
  47. package/docs/errors/settings/invalid-timeout.md +20 -3
  48. package/docs/errors/settings/invalid-via.md +22 -3
  49. package/docs/errors/settings/reserved-key.md +29 -0
  50. package/docs/errors/settings/skip-env-file-active.md +25 -0
  51. package/docs/errors/settings/unknown-key.md +25 -0
  52. package/docs/errors/transport/provider-trust.md +29 -0
  53. package/docs/errors/usage/fanout-not-supported.md +16 -0
  54. package/docs/errors/usage/stdin-ignored.md +28 -0
  55. package/docs/errors/validation/unknown-attribute.md +37 -4
  56. package/package.json +49 -14
  57. package/src/api-fanout.ts +469 -0
  58. package/src/api.ts +171 -56
  59. package/src/cli/api.ts +156 -13
  60. package/src/cli/btest.ts +38 -4
  61. package/src/cli/common.ts +11 -0
  62. package/src/cli/devices.ts +235 -6
  63. package/src/cli/discover.ts +3 -1
  64. package/src/cli/execute.ts +129 -9
  65. package/src/cli/explain.ts +503 -0
  66. package/src/cli/mcp.ts +8 -1
  67. package/src/cli/retrieve.ts +167 -90
  68. package/src/cli/selection.ts +260 -0
  69. package/src/cli/settings.ts +286 -0
  70. package/src/cli/terminal.ts +22 -7
  71. package/src/cli/transfer.ts +192 -51
  72. package/src/cli.ts +22 -2
  73. package/src/core/envelope.ts +5 -2
  74. package/src/core/error-catalog.ts +95 -0
  75. package/src/core/fanout.ts +143 -2
  76. package/src/core/preflight.ts +306 -0
  77. package/src/core/routeros-string.ts +24 -0
  78. package/src/devices.ts +212 -20
  79. package/src/discover.ts +7 -2
  80. package/src/errors.ts +1 -0
  81. package/src/execute-fanout.ts +453 -0
  82. package/src/execute.ts +216 -68
  83. package/src/explain/arg-tokens.ts +114 -0
  84. package/src/explain/args.ts +1403 -0
  85. package/src/explain/blocks.ts +132 -0
  86. package/src/explain/brace-slots.ts +143 -0
  87. package/src/explain/brace-tokens.ts +38 -0
  88. package/src/explain/catalog.ts +1345 -0
  89. package/src/explain/coordinates.ts +329 -0
  90. package/src/explain/defects.ts +217 -0
  91. package/src/explain/is-known-menu.ts +17 -0
  92. package/src/explain/menus.ts +664 -0
  93. package/src/explain/operator-tokens.ts +310 -0
  94. package/src/explain/operators.ts +458 -0
  95. package/src/explain/path-tokens.ts +150 -0
  96. package/src/explain/pathresolve.ts +1324 -0
  97. package/src/explain/quoted-string.ts +241 -0
  98. package/src/explain/scope-brace.ts +308 -0
  99. package/src/explain/segment.ts +757 -0
  100. package/src/explain/string-tokens.ts +67 -0
  101. package/src/explain/symbol-values.ts +520 -0
  102. package/src/explain/symbols.ts +1597 -0
  103. package/src/explain/token-ranges.ts +72 -0
  104. package/src/explain/transport.ts +497 -0
  105. package/src/explain/value-tokens.ts +80 -0
  106. package/src/explain/values.ts +357 -0
  107. package/src/explain/verbs.ts +64 -0
  108. package/src/explain/verbsplit.ts +813 -0
  109. package/src/explain/write.ts +815 -0
  110. package/src/explain.ts +2152 -0
  111. package/src/index.ts +307 -1
  112. package/src/mcp/config.ts +12 -2
  113. package/src/mcp/tools.ts +137 -2
  114. package/src/protocols/adapter.ts +38 -18
  115. package/src/protocols/index.ts +11 -5
  116. package/src/protocols/mac-telnet-console.ts +61 -17
  117. package/src/resolver/cdb.ts +10 -2
  118. package/src/resolver/comment-kv.ts +58 -4
  119. package/src/resolver/config-file.ts +136 -0
  120. package/src/resolver/facts.ts +78 -0
  121. package/src/resolver/geo.ts +453 -0
  122. package/src/resolver/index.ts +65 -0
  123. package/src/resolver/mac.ts +12 -3
  124. package/src/resolver/quickchr-provider.ts +597 -0
  125. package/src/resolver/selection.ts +391 -0
  126. package/src/resolver/service-endpoint.ts +155 -0
  127. package/src/resolver/settings.ts +54 -5
  128. package/src/resolver/target.ts +21 -3
  129. package/src/retrieve-fanout.ts +239 -133
  130. package/src/retrieve.ts +120 -25
  131. package/src/settings.ts +1078 -0
  132. package/src/terminal.ts +11 -1
  133. package/src/transfer-fanout.ts +544 -0
  134. package/src/transfer.ts +133 -40
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,12 +32,15 @@ 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,
37
39
  isMacAddress,
40
+ loadEnvFileDefaults,
38
41
  parseDuration,
39
42
  parseResolvePolicy,
43
+ quickchrConnection,
40
44
  type ResolvedAuth,
41
45
  type ResolvedSetting,
42
46
  type ResolvedTarget,
@@ -45,6 +49,7 @@ import {
45
49
  resolveCdb,
46
50
  resolveMacTarget,
47
51
  resolveOptionalIntegerSetting,
52
+ resolveQuickchrTarget,
48
53
  resolveStringSetting,
49
54
  resolveTarget,
50
55
  toCoreSource,
@@ -56,6 +61,12 @@ export type ExecuteOutputFormat = (typeof executeOutputFormats)[number];
56
61
 
57
62
  export interface ExecuteRequest {
58
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;
59
70
  command: string;
60
71
  via?: string;
61
72
  host?: string;
@@ -277,19 +288,40 @@ export async function runResolvedExecute(
277
288
  export async function resolveExecuteRequest(
278
289
  request: ExecuteRequest,
279
290
  env: Record<string, string | undefined>,
291
+ options: {
292
+ cdbResolution?: CdbResolution;
293
+ config?: Record<string, string | undefined>;
294
+ } = {},
280
295
  ): Promise<ResolvedExecuteRequest> {
281
296
  validateExecuteRequestShape(request);
282
297
  const canonical = canonicalizeExecuteCommand(request.command);
283
- const cdbResolution = await resolveCdb(
284
- {
285
- targetInput: request.targetInput,
286
- cdbFile: request.cdbFile,
287
- cdbPassword: request.cdbPassword,
288
- },
289
- env,
290
- );
291
- const via = resolveExecuteProtocol(request, env, cdbResolution);
292
- const format = resolveFormat(request, env);
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
+ }
309
+ // Fan-out passes the pre-resolved CDB record for a member (the CDB is loaded
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
+ )));
323
+ const via = resolveExecuteProtocol(request, env, cdbResolution, config);
324
+ const format = resolveFormat(request, env, config);
293
325
  const validate = resolveBooleanSetting(
294
326
  request.validate,
295
327
  env,
@@ -297,57 +329,89 @@ export async function resolveExecuteRequest(
297
329
  true,
298
330
  "validate",
299
331
  cdbResolution?.overrides.validate,
332
+ config,
300
333
  );
301
334
  const timeoutMs = resolveTimeoutSetting(
302
335
  request.timeout,
303
336
  env,
304
337
  via.value,
305
338
  cdbResolution?.overrides.timeoutMs,
339
+ config,
306
340
  );
307
341
  const maxResultsBytes = resolveOptionalIntegerSetting(
308
342
  request.maxResultsBytes,
309
343
  env,
310
344
  "CENTRS_MAX_RESULTS",
311
345
  "max-results",
346
+ undefined,
347
+ config,
312
348
  );
313
- const macResolution = isIpTransport(via.value)
314
- ? await resolveMacTarget({
315
- host: request.host,
316
- targetInput: request.targetInput,
317
- 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
+ },
318
377
  env,
319
- policy: parseResolvePolicy(request.resolve ?? env["CENTRS_RESOLVE"]),
320
- operation: "execute",
321
- })
322
- : undefined;
323
- const target = resolveTarget(
324
- {
325
- targetInput: request.targetInput,
326
- host: request.host,
327
- port: request.port,
328
- macResolution,
329
- },
330
- env,
331
- via.value,
332
- cdbResolution,
333
- );
334
- const auth = resolveAuth(
335
- {
336
- username: request.username,
337
- password: request.password,
338
- sshKey: request.sshKey,
339
- },
340
- env,
341
- cdbResolution,
342
- );
343
- const insecure = resolveBooleanSetting(
344
- request.insecure,
345
- env,
346
- "CENTRS_INSECURE",
347
- false,
348
- "insecure",
349
- cdbResolution?.overrides.insecure,
350
- );
378
+ via.value,
379
+ cdbResolution,
380
+ config,
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
+ );
351
415
 
352
416
  return {
353
417
  command: request.command,
@@ -362,7 +426,82 @@ export async function resolveExecuteRequest(
362
426
  maxResultsBytes,
363
427
  yes: request.yes ?? false,
364
428
  verbose: request.verbose ?? false,
365
- warnings: cdbResolution?.warnings ?? [],
429
+ warnings: connection
430
+ ? [...connection.warnings]
431
+ : (cdbResolution?.warnings ?? []),
432
+ };
433
+ }
434
+
435
+ export interface ExecuteGlobalContext {
436
+ via: RouterOsProtocol;
437
+ write: boolean;
438
+ summary: ExecuteRequestSummary;
439
+ settings: CommonSettingsMeta;
440
+ }
441
+
442
+ /**
443
+ * Resolve the target-independent settings (protocol, format, validate, timeout,
444
+ * max-results) for a fan-out's outer `meta.operation.request` summary and its
445
+ * transport-aware concurrency default. No CDB record is consulted; the global
446
+ * `--via` (default `native-api`) decides the summary and concurrency base. Also
447
+ * canonicalizes the command once so write-ness is known before any target runs.
448
+ */
449
+ export function resolveExecuteGlobalContext(
450
+ request: ExecuteRequest,
451
+ env: Record<string, string | undefined>,
452
+ config: Record<string, string | undefined> = {},
453
+ ): ExecuteGlobalContext {
454
+ validateExecuteRequestShape(request);
455
+ const canonical = canonicalizeExecuteCommand(request.command);
456
+ const via = resolveExecuteProtocol(request, env, undefined, config);
457
+ const format = resolveFormat(request, env, config);
458
+ const validate = resolveBooleanSetting(
459
+ request.validate,
460
+ env,
461
+ "CENTRS_VALIDATE",
462
+ true,
463
+ "validate",
464
+ undefined,
465
+ config,
466
+ );
467
+ const timeoutMs = resolveTimeoutSetting(
468
+ request.timeout,
469
+ env,
470
+ via.value,
471
+ undefined,
472
+ config,
473
+ );
474
+ const maxResultsBytes = resolveOptionalIntegerSetting(
475
+ request.maxResultsBytes,
476
+ env,
477
+ "CENTRS_MAX_RESULTS",
478
+ "max-results",
479
+ undefined,
480
+ config,
481
+ );
482
+ return {
483
+ via: via.value,
484
+ write: isWriteShaped(canonical),
485
+ summary: {
486
+ command: request.command,
487
+ canonical,
488
+ write: isWriteShaped(canonical),
489
+ yes: request.yes ?? false,
490
+ validate: validate.value,
491
+ verbose: request.verbose ?? false,
492
+ timeoutMs: timeoutMs.value,
493
+ format: format.value,
494
+ maxResultsBytes: maxResultsBytes?.value,
495
+ },
496
+ settings: {
497
+ via: toCoreSource(via.source),
498
+ timeoutMs: toCoreSource(timeoutMs.source),
499
+ format: toCoreSource(format.source),
500
+ validate: toCoreSource(validate.source),
501
+ maxResultsBytes: maxResultsBytes
502
+ ? toCoreSource(maxResultsBytes.source)
503
+ : undefined,
504
+ },
366
505
  };
367
506
  }
368
507
 
@@ -600,7 +739,7 @@ async function validateExecuteCommand(
600
739
  context: {
601
740
  path: resolved.canonical.path,
602
741
  verb: resolved.canonical.verb,
603
- attribute: missing[0],
742
+ parameter: missing[0],
604
743
  requestedAttributes: requested,
605
744
  availableAttributes,
606
745
  validationSource: "/console/inspect request=child+completion",
@@ -622,7 +761,7 @@ async function validateExecuteCommand(
622
761
  * Validate a command over a console transport (mac-telnet or ssh) with a single
623
762
  * `:put [:parse ...]`. Unlike REST/native (a syntax gate plus a `/console/inspect`
624
763
  * semantic gate), one console `:parse` reports both `syntax error` and `bad
625
- * parameter <name>`, so it covers syntax and the unknown-attribute (semantic)
764
+ * parameter <name>`, so it covers syntax and the unknown-attribute (name-level)
626
765
  * gate at once — identical strings over mac-telnet and ssh (CHR 7.23.1 grounded;
627
766
  * see `mac-telnet-console.ts` and `ssh.ts`).
628
767
  */
@@ -651,7 +790,7 @@ async function validateConsoleParseCommand(
651
790
  script: parseScriptFor(resolved.command),
652
791
  });
653
792
  // Throws validation/syntax or validation/unknown-attribute on a bad parse.
654
- classifyParseResult(result.ret ?? "", resolved.command);
793
+ classifyParseResult(result.ret ?? "", resolved.command, resolved.via.value);
655
794
  return {
656
795
  enabled: true,
657
796
  source: `:put [:parse ...] over ${resolved.via.value}`,
@@ -683,7 +822,14 @@ async function runSyntaxGate(
683
822
  }
684
823
  const script = `:put [:parse ${routerOsStringLiteral(resolved.command)}]`;
685
824
  try {
686
- 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);
687
833
  } catch (error) {
688
834
  // The preflight is a syntax gate, but `backend.execute` also surfaces
689
835
  // connection and authentication failures (login happens lazily here).
@@ -694,6 +840,9 @@ async function runSyntaxGate(
694
840
  if (error instanceof CentrsError && isPreflightTransportError(error)) {
695
841
  throw error;
696
842
  }
843
+ if (error instanceof CentrsError && error.code.startsWith("validation/")) {
844
+ throw error;
845
+ }
697
846
  // If the mapped RouterOS fault carried a `(line N column M)` byte offset,
698
847
  // keep it on the wrapped syntax error rather than dropping it.
699
848
  const position = error instanceof CentrsError ? error.position : undefined;
@@ -756,16 +905,6 @@ async function runCommand(
756
905
  return backend.execute({ path: "", command: "", script: resolved.command });
757
906
  }
758
907
  if (resolved.canonical.mode === "script") {
759
- if (resolved.via.value === "native-api") {
760
- throw new CentrsError({
761
- code: "transport/unsupported-operation",
762
- summary:
763
- "The native API execute path requires a slash-prefixed RouterOS command.",
764
- remediation:
765
- "Use a path-shaped command such as `/system/identity/set name=x`, or pin `--via rest-api` for `/rest/execute` script mode.",
766
- context: { via: resolved.via.value, command: resolved.command },
767
- });
768
- }
769
908
  return backend.execute({ path: "", command: "", script: resolved.command });
770
909
  }
771
910
  return backend.execute({
@@ -821,8 +960,9 @@ function resolveExecuteProtocol(
821
960
  request: ExecuteRequest,
822
961
  env: Record<string, string | undefined>,
823
962
  cdb?: CdbResolution,
963
+ config: Record<string, string | undefined> = {},
824
964
  ): ResolvedSetting<RouterOsProtocol> {
825
- const defaultVia = isUnresolvedMacTarget(request, env, cdb)
965
+ const defaultVia = isUnresolvedMacTarget(request, env, cdb, config)
826
966
  ? "mac-telnet"
827
967
  : "native-api";
828
968
  const via = resolveStringSetting(
@@ -833,6 +973,7 @@ function resolveExecuteProtocol(
833
973
  "via",
834
974
  undefined,
835
975
  cdb?.overrides.via,
976
+ config,
836
977
  );
837
978
  if (!via) {
838
979
  throw new CentrsError({
@@ -877,8 +1018,13 @@ function isUnresolvedMacTarget(
877
1018
  request: ExecuteRequest,
878
1019
  env: Record<string, string | undefined>,
879
1020
  cdb?: CdbResolution,
1021
+ config: Record<string, string | undefined> = {},
880
1022
  ): boolean {
881
- if (request.via !== undefined || env["CENTRS_VIA"] !== undefined) {
1023
+ if (
1024
+ request.via !== undefined ||
1025
+ env["CENTRS_VIA"] !== undefined ||
1026
+ config["CENTRS_VIA"] !== undefined
1027
+ ) {
882
1028
  return false;
883
1029
  }
884
1030
  // Decide the default transport from the *effective* target (host >
@@ -890,6 +1036,7 @@ function isUnresolvedMacTarget(
890
1036
  targetInput: request.targetInput,
891
1037
  cdbTarget: cdb?.target,
892
1038
  env,
1039
+ config,
893
1040
  });
894
1041
  return candidate ? isMacAddress(candidate) : false;
895
1042
  }
@@ -897,6 +1044,7 @@ function isUnresolvedMacTarget(
897
1044
  function resolveFormat(
898
1045
  request: ExecuteRequest,
899
1046
  env: Record<string, string | undefined>,
1047
+ config: Record<string, string | undefined> = {},
900
1048
  ): ResolvedSetting<ExecuteOutputFormat> {
901
1049
  return resolveStringSetting(
902
1050
  request.format,
@@ -905,6 +1053,8 @@ function resolveFormat(
905
1053
  "text",
906
1054
  "format",
907
1055
  parseOutputFormat,
1056
+ undefined,
1057
+ config,
908
1058
  ) as ResolvedSetting<ExecuteOutputFormat>;
909
1059
  }
910
1060
 
@@ -930,6 +1080,7 @@ function resolveTimeoutSetting(
930
1080
  env: Record<string, string | undefined>,
931
1081
  via: RouterOsProtocol,
932
1082
  commentKv?: ResolvedSetting<number>,
1083
+ config: Record<string, string | undefined> = {},
933
1084
  ): ResolvedSetting<number> {
934
1085
  const resolved = resolveStringSetting(
935
1086
  timeout === undefined ? undefined : String(timeout),
@@ -950,6 +1101,7 @@ function resolveTimeoutSetting(
950
1101
  return parsed;
951
1102
  },
952
1103
  commentKv,
1104
+ config,
953
1105
  );
954
1106
  if (!resolved) {
955
1107
  throw new Error("timeout resolution produced no value");
@@ -1277,10 +1429,6 @@ function tokenizeRouterOsCli(input: string): string[] {
1277
1429
  return tokens;
1278
1430
  }
1279
1431
 
1280
- function routerOsStringLiteral(value: string): string {
1281
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
1282
- }
1283
-
1284
1432
  function syntaxCause(error: unknown): unknown {
1285
1433
  if (error instanceof CentrsError) {
1286
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
+ }