@tikoci/centrs 0.1.9-next.23 → 0.1.9-next.24

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 (53) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/GLOSSARY.txt +14 -0
  3. package/README.md +2 -2
  4. package/commands/api/AGENTS.md +18 -1
  5. package/commands/api/README.md +10 -6
  6. package/commands/api/examples.md +1 -1
  7. package/commands/execute/README.md +26 -6
  8. package/commands/execute/examples.md +24 -5
  9. package/commands/explain/README.md +452 -61
  10. package/commands/explain/examples.md +191 -41
  11. package/commands/retrieve/README.md +13 -2
  12. package/commands/retrieve/examples.md +30 -1
  13. package/commands/transfer/README.md +8 -4
  14. package/docs/CLI.md +19 -5
  15. package/docs/CONSTITUTION.md +14 -9
  16. package/docs/MATRIX.md +34 -21
  17. package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
  18. package/docs/errors/routeros/permission-denied.md +24 -0
  19. package/docs/errors/transport/edge-auth-failed.md +22 -0
  20. package/docs/errors/transport/write-outcome-unknown.md +3 -0
  21. package/docs/errors/usage/invalid-cursor.md +20 -0
  22. package/docs/errors/validation/unknown-path.md +9 -0
  23. package/package.json +2 -2
  24. package/src/api.ts +34 -39
  25. package/src/cli/explain.ts +206 -47
  26. package/src/core/envelope.ts +10 -3
  27. package/src/core/error-catalog.ts +15 -0
  28. package/src/core/execute-command.ts +53 -0
  29. package/src/core/fanout.ts +12 -2
  30. package/src/core/inspect.ts +4 -5
  31. package/src/core/routeros-errors.ts +25 -0
  32. package/src/execute-cmr.ts +2 -8
  33. package/src/execute.ts +48 -20
  34. package/src/explain/args.ts +115 -10
  35. package/src/explain/group-faults.ts +162 -29
  36. package/src/explain/operators.ts +2 -1
  37. package/src/explain/scope-brace.ts +226 -11
  38. package/src/explain/segment.ts +136 -1
  39. package/src/explain/symbols.ts +146 -6
  40. package/src/explain/values.ts +55 -4
  41. package/src/explain-live-facets.ts +739 -0
  42. package/src/explain-live-types.ts +490 -0
  43. package/src/explain-live.ts +593 -0
  44. package/src/explain.ts +395 -43
  45. package/src/index.ts +6 -0
  46. package/src/protocols/adapter.ts +88 -10
  47. package/src/protocols/mac-telnet.ts +38 -10
  48. package/src/protocols/native-api.ts +13 -12
  49. package/src/retrieve-follow.ts +2 -1
  50. package/src/retrieve-sample.ts +2 -1
  51. package/src/retrieve-wait.ts +10 -6
  52. package/src/retrieve.ts +77 -27
  53. package/src/transfer.ts +38 -13
@@ -185,10 +185,17 @@ export interface EnvelopeValidationMeta {
185
185
  * `stages[]` then says which stage was not reached and why (GH#435).
186
186
  */
187
187
  result?: "passed" | "failed" | "skipped";
188
- /** Syntax gate status for commands that use RouterOS `:parse`. */
188
+ /**
189
+ * The syntax verdict (`:parse` or the offline analyzer). Present only when a
190
+ * stage gave one; absent means no stage was asked (#535).
191
+ */
189
192
  syntax?: boolean;
190
- /** Semantic `/console/inspect` gate status, or not-applicable for script mode. */
191
- semantic?: boolean | "not-applicable";
193
+ /**
194
+ * The name-level verdict (`/console/inspect` or a console `:parse` `bad
195
+ * parameter`). Present only when a stage gave one; absent means no stage was
196
+ * asked, including for script input, which has no names to check (#535).
197
+ */
198
+ semantic?: boolean;
192
199
  availableAttributes?: readonly string[];
193
200
  /**
194
201
  * Per-stage detail for the gate described in
@@ -431,6 +431,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
431
431
  code: "routeros/mac-telnet-unsupported-auth",
432
432
  summary: "The device requested an unsupported MAC-Telnet auth mode.",
433
433
  },
434
+ {
435
+ code: "routeros/permission-denied",
436
+ summary:
437
+ "RouterOS refused the command because the user's group lacks a policy it needs.",
438
+ },
434
439
  {
435
440
  code: "routeros/protocol-not-implemented",
436
441
  summary: "The requested protocol is planned but not implemented yet.",
@@ -563,6 +568,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
563
568
  code: "transport/dns",
564
569
  summary: "The device host name could not be resolved.",
565
570
  },
571
+ {
572
+ code: "transport/edge-auth-failed",
573
+ summary:
574
+ "An HTTP layer in front of RouterOS (auth proxy, tunnel edge, WAF) refused the request before RouterOS answered it.",
575
+ },
566
576
  {
567
577
  code: "transport/host-key-mismatch",
568
578
  summary: "The device's SSH host key did not match the known key.",
@@ -652,6 +662,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
652
662
  summary:
653
663
  "The command or mode cannot fan out across multiple targets (e.g. terminal, --listen/--stream).",
654
664
  },
665
+ {
666
+ code: "usage/invalid-cursor",
667
+ summary:
668
+ "An explain --cursor is not a byte offset inside the input being analyzed.",
669
+ },
655
670
  {
656
671
  code: "usage/invalid-method",
657
672
  summary: "The -X/--method value is not a supported HTTP method.",
@@ -104,11 +104,64 @@ export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
104
104
  if (Object.keys(command.attributes).some(isFileOutputAttribute)) {
105
105
  return true;
106
106
  }
107
+ if (isBareMenuNavigation(command)) {
108
+ return false;
109
+ }
107
110
  return !READ_ONLY_EXECUTE_VERBS.has(command.verb.toLowerCase());
108
111
  }
109
112
  return scriptContainsWriteShapedCommand(command.input);
110
113
  }
111
114
 
115
+ /**
116
+ * A truly bare known-menu path (`/system/identity`, no attributes or queries)
117
+ * is console navigation — a no-op read, not a command — so it needs no write
118
+ * confirmation. The APIs refuse it (`no such command`) and the consoles
119
+ * navigate silently; neither mutates.
120
+ *
121
+ * This is narrower than the script branch on purpose. There, `/ip firewall
122
+ * filter` stays write-shaped (#387): a later line may carry a relative verb,
123
+ * so navigation fails closed. Here structured mode proves the input is a
124
+ * single path token (any second word without `=` — including a second line —
125
+ * falls back to script), so no follow-up is possible. The carve-out still
126
+ * fails closed three ways: anything the menu table does not carry stays gated,
127
+ * a verb in the known-mutator list stays gated even if the table ever
128
+ * mis-tags its path, and any attribute or query (including `;`-smuggled text,
129
+ * which lands in the verb or an attribute, never in a clean menu reading)
130
+ * keeps the gate shut. The gate is shared, so bare menus also stop tripping
131
+ * `cdb/write-not-permitted` on `mcp=ro` targets (and stop needing
132
+ * `confirm:true` on `mcp=rw` ones), and `explain`'s `writeShaped` flips to
133
+ * false for these inputs — harmless in all three, since the input is a
134
+ * no-op read either way.
135
+ */
136
+ function isBareMenuNavigation(command: CanonicalExecuteCommand): boolean {
137
+ if (
138
+ Object.keys(command.attributes).length > 0 ||
139
+ command.queries.length > 0
140
+ ) {
141
+ return false;
142
+ }
143
+ if (KNOWN_WRITE_EXECUTE_VERBS.has(command.verb.toLowerCase())) {
144
+ return false;
145
+ }
146
+ const segments = `${command.path}/${command.verb}`.split("/").filter(Boolean);
147
+ if (segments.length < 2) {
148
+ return false;
149
+ }
150
+ return isKnownMenuPath(segments);
151
+ }
152
+
153
+ /**
154
+ * Whether `execute` was asked to run a bare menu path (see
155
+ * {@link isBareMenuNavigation}). The envelope carries a steering tip for it:
156
+ * navigation runs — the gate no longer stops it — but it answers nothing, so
157
+ * the tip names the read that does.
158
+ */
159
+ export function isBareMenuPathCommand(
160
+ command: CanonicalExecuteCommand,
161
+ ): boolean {
162
+ return command.mode === "structured" && isBareMenuNavigation(command);
163
+ }
164
+
112
165
  /**
113
166
  * The attributes that turn an otherwise read-only command into a device write.
114
167
  *
@@ -94,6 +94,7 @@ export const NOT_SENT_TRANSPORT_CODES: readonly string[] = [
94
94
  "transport/auth-failed",
95
95
  "transport/connection-refused",
96
96
  "transport/dns",
97
+ "transport/edge-auth-failed",
97
98
  "transport/host-key-mismatch",
98
99
  "transport/insecure-trust",
99
100
  "transport/local-tool-missing",
@@ -106,15 +107,24 @@ export const NOT_SENT_TRANSPORT_CODES: readonly string[] = [
106
107
  * #448: a write whose request was already sent when a transport failure ended
107
108
  * it may have run on the router. It is never retried, and its error envelope
108
109
  * says the outcome is unknown rather than implying nothing happened.
110
+ *
111
+ * #549: the same warning covers a dispatched multi-statement write refused
112
+ * with `routeros/permission-denied`, which can be partly applied — statements
113
+ * before the refused one already ran. A single refused statement did not run
114
+ * (#541), so it stays silent; the caller passes whether the dispatch carried
115
+ * more than one statement.
109
116
  */
110
117
  export function sentWriteWarnings(
111
118
  write: boolean,
112
119
  error: unknown,
113
120
  trace: ValidationTrace | undefined,
121
+ multiStatement = false,
114
122
  ): Warning[] {
115
123
  if (!write || !trace?.dispatched) return [];
116
- if (
117
- !(error instanceof CentrsError) ||
124
+ if (!(error instanceof CentrsError)) return [];
125
+ if (error.code === "routeros/permission-denied") {
126
+ if (!multiStatement) return [];
127
+ } else if (
118
128
  !error.code.startsWith("transport/") ||
119
129
  NOT_SENT_TRANSPORT_CODES.includes(error.code)
120
130
  ) {
@@ -39,12 +39,11 @@ export interface InspectBackend {
39
39
 
40
40
  /**
41
41
  * The `/console/inspect` request modes (lsp's `InspectRequest`). Only `child`
42
- * and `completion` are wired today (the two the {@link InspectBackend} seam
42
+ * and `completion` are wired here (the two the {@link InspectBackend} seam
43
43
  * exposes), plus `input` for completion over CLI text
44
- * ({@link inspectWhereBooleans}); `highlight` (token-level error detection),
45
- * `syntax` (parse tree) and the `.query`/`.proplist` request fields are the
46
- * documented extension point for value-level validation — widen the adapter
47
- * seam when adding them.
44
+ * ({@link inspectWhereBooleans}). The protocol adapter also supports
45
+ * `highlight` and `syntax` for live explain, which sends `syntax` for argument
46
+ * paths only (`src/explain-live-facets.ts`).
48
47
  */
49
48
  export type InspectRequestKind =
50
49
  | "child"
@@ -107,6 +107,31 @@ function cleanToken(token: string): string {
107
107
  * rules here; keep the most specific patterns above broader ones.
108
108
  */
109
109
  export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
110
+ {
111
+ code: "routeros/permission-denied",
112
+ description:
113
+ "RouterOS refused the command because the user's group lacks a policy it needs.",
114
+ // Grounded on CHR 7.25. REST answers HTTP 500 with this as `detail`:
115
+ // `not enough permissions (9)` for a write without `write`, and
116
+ // `std failure: not allowed (9)` for any request (even a GET) when the
117
+ // group has `rest-api` but not `api` (fixed in 7.26beta3). The native API traps with the
118
+ // first string; `/execute as-string` returns it as text with a
119
+ // `(<path>; line N)` suffix. In a multi-statement script the refusal is
120
+ // the last line, after earlier statements' stdout (#549: `:put
121
+ // "p5-first"; /system/identity/set …` as a read-only user returns HTTP
122
+ // 200 with ret `p5-first\r\nnot enough permissions (9)
123
+ // (/system/identity/set; line 1)`, identical on native-api), so the
124
+ // trailed form matches too — with the location suffix required, so a
125
+ // script that merely prints the bare phrase stays ordinary output.
126
+ test: /not enough permissions|not allowed \(9\)/i,
127
+ resultTest:
128
+ /^(?:not enough permissions \(9\)(?: \(\/[^\s();]+; line \d+\))?|[\s\S]*\r?\nnot enough permissions \(9\) \(\/[^\s();]+; line \d+\))$/i,
129
+ build: (_match, raw) => ({
130
+ summary: `RouterOS refused the command for lack of permission: ${raw.trim()}`,
131
+ remediation:
132
+ "Grant the missing policy to the RouterOS user's group (`/user/group`): `read` for reads, `write` for changes. If every REST request fails with `not allowed (9)`, the group has `rest-api` without `api`; before RouterOS 7.26beta3, REST needs both. Retrying will not help.",
133
+ }),
134
+ },
110
135
  {
111
136
  code: "routeros/unknown-path",
112
137
  description:
@@ -1002,8 +1002,7 @@ function devicePassed(
1002
1002
  source: CMR_VALIDATION_SOURCE,
1003
1003
  result: "passed",
1004
1004
  syntax: true,
1005
- semantic:
1006
- resolved.canonical.mode === "structured" ? true : "not-applicable",
1005
+ ...(resolved.canonical.mode === "structured" ? { semantic: true } : {}),
1007
1006
  stages: passedStages(
1008
1007
  offline ?? { verdict: "pass", warnings: [] },
1009
1008
  CMR_VALIDATION_SOURCE,
@@ -1023,12 +1022,7 @@ function deviceRejected(
1023
1022
  enabled: true,
1024
1023
  source: CMR_VALIDATION_SOURCE,
1025
1024
  result: "failed",
1026
- ...(nameRejected
1027
- ? { syntax: true, semantic: false }
1028
- : {
1029
- syntax: false,
1030
- ...(structured ? {} : { semantic: "not-applicable" as const }),
1031
- }),
1025
+ ...(nameRejected ? { syntax: true, semantic: false } : { syntax: false }),
1032
1026
  stages: [
1033
1027
  offlineStagePassed(offline?.verdict),
1034
1028
  { stage: "device", source: CMR_VALIDATION_SOURCE, result: "failed" },
package/src/execute.ts CHANGED
@@ -27,6 +27,7 @@ import { buildTip, notReachedReason } from "./core/envelope.ts";
27
27
  import {
28
28
  type CanonicalExecuteCommand,
29
29
  canonicalizeExecuteCommand,
30
+ isBareMenuPathCommand,
30
31
  isWriteShaped,
31
32
  } from "./core/execute-command.ts";
32
33
  import { sentWriteWarnings } from "./core/fanout.ts";
@@ -40,6 +41,7 @@ import {
40
41
  SYNTAX_REJECTED_REMEDIATION,
41
42
  serializeCentrsError,
42
43
  } from "./errors.ts";
44
+ import { countExecutableStatements } from "./explain/segment.ts";
43
45
  import {
44
46
  assertOfflineSyntax,
45
47
  isOfflineGateRejection,
@@ -162,6 +164,7 @@ export type ExecuteErrorEnvelope = CentrsErrorEnvelope<ExecuteOperationMeta>;
162
164
  export {
163
165
  type CanonicalExecuteCommand,
164
166
  canonicalizeExecuteCommand,
167
+ isBareMenuPathCommand,
165
168
  isWriteShaped,
166
169
  };
167
170
 
@@ -608,6 +611,22 @@ export function buildExecuteErrorEnvelope(
608
611
  };
609
612
  }
610
613
 
614
+ /**
615
+ * Whether the dispatch carried more than one statement (#549). Mirrors
616
+ * `runCommand`: the consoles run the raw CLI text whatever the canonical mode,
617
+ * while REST/native run structured input as one path-POST/talk sentence — a
618
+ * `;` inside a structured value never separates there, so the refusal means
619
+ * nothing ran. Executed control scopes count too: `:if … do={ A; B }` applies
620
+ * A before a refused B, while stored bodies never run at dispatch.
621
+ */
622
+ function isMultiStatementDispatch(resolved: ResolvedExecuteRequest): boolean {
623
+ const runsRawText =
624
+ resolved.via.value === "mac-telnet" ||
625
+ resolved.via.value === "ssh" ||
626
+ resolved.canonical.mode === "script";
627
+ return runsRawText && countExecutableStatements(resolved.command) > 1;
628
+ }
629
+
611
630
  export function buildExecuteErrorEnvelopeFromResolved(
612
631
  resolved: ResolvedExecuteRequest,
613
632
  error: unknown,
@@ -633,6 +652,7 @@ export function buildExecuteErrorEnvelopeFromResolved(
633
652
  isWriteShaped(resolved.canonical),
634
653
  centrsError,
635
654
  trace,
655
+ isMultiStatementDispatch(resolved),
636
656
  ),
637
657
  ],
638
658
  tips: resolvedExecuteTips(resolved),
@@ -664,20 +684,15 @@ function failedValidationMeta(
664
684
  }
665
685
  const deviceSource = deviceStageSource(resolved);
666
686
  const structured = resolved.canonical.mode === "structured";
667
- // A flat summary is present only when its check gave a verdict (GH#435).
668
- // Script-shaped input has no semantic half at all: `not-applicable`. For
669
- // structured input, a syntax rejection means the name-level check was
670
- // never asked, so `semantic` is left out rather than reported `false`.
671
- const semanticNotReached = structured
672
- ? {}
673
- : { semantic: "not-applicable" as const };
687
+ // A flat summary is present only when a stage gave that verdict (#535). A
688
+ // syntax rejection means the name-level check was never asked, so
689
+ // `semantic` is left out rather than reported `false`.
674
690
  if (isOfflineGateRejection(error)) {
675
691
  return {
676
692
  enabled: true,
677
693
  source: OFFLINE_GATE_SOURCE,
678
694
  result: "failed",
679
695
  syntax: false,
680
- ...semanticNotReached,
681
696
  stages: [
682
697
  { stage: "offline", source: OFFLINE_GATE_SOURCE, result: "failed" },
683
698
  {
@@ -705,9 +720,7 @@ function failedValidationMeta(
705
720
  enabled: true,
706
721
  source: deviceSource,
707
722
  result: "failed",
708
- ...(nameRejected
709
- ? { syntax: true, semantic: false }
710
- : { syntax: false, ...semanticNotReached }),
723
+ ...(nameRejected ? { syntax: true, semantic: false } : { syntax: false }),
711
724
  stages: [
712
725
  offlineStagePassed(trace?.offlineVerdict),
713
726
  { stage: "device", source: deviceSource, result: "failed" },
@@ -781,15 +794,14 @@ export function offlineStagePassed(
781
794
  * `validate=false`. Both stages are listed as `skipped` rather than omitted:
782
795
  * the constitution's rule is that a gated surface always reports each stage, so
783
796
  * a consumer reading `stages` never has to special-case the disabled shape.
784
- * `reason` is left off — `enabled: false` already says why.
797
+ * `reason` is left off — `enabled: false` already says why. No stage answered,
798
+ * so there is no `syntax` / `semantic` summary (#535).
785
799
  */
786
800
  export function disabledValidationMeta(): EnvelopeValidationMeta {
787
801
  return {
788
802
  enabled: false,
789
803
  source: "disabled",
790
804
  result: "skipped",
791
- syntax: false,
792
- semantic: false,
793
805
  stages: [
794
806
  { stage: "offline", source: OFFLINE_GATE_SOURCE, result: "skipped" },
795
807
  { stage: "device", source: "device preflight", result: "skipped" },
@@ -862,7 +874,6 @@ async function validateExecuteCommand(
862
874
  source: ":put [:parse]",
863
875
  result: "passed",
864
876
  syntax: true,
865
- semantic: "not-applicable",
866
877
  stages: passedStages(offline, ":put [:parse]"),
867
878
  };
868
879
  }
@@ -929,8 +940,7 @@ async function validateConsoleParseCommand(
929
940
  source: `:put [:parse ...] over ${resolved.via.value}`,
930
941
  result: "passed",
931
942
  syntax: true,
932
- semantic:
933
- resolved.canonical.mode === "structured" ? true : "not-applicable",
943
+ ...(resolved.canonical.mode === "structured" ? { semantic: true } : {}),
934
944
  stages: passedStages(
935
945
  offline,
936
946
  `:put [:parse ...] over ${resolved.via.value}`,
@@ -1452,20 +1462,38 @@ function executeRequestSummary(
1452
1462
  * downstream auth error.
1453
1463
  */
1454
1464
  export function resolvedExecuteTips(resolved: ResolvedExecuteRequest): Tip[] {
1465
+ const tips: Tip[] = [];
1455
1466
  if (
1456
1467
  resolved.via.value === "mac-telnet" &&
1457
1468
  resolved.auth.username === undefined &&
1458
1469
  !resolved.auth.passwordProvided
1459
1470
  ) {
1460
- return [
1471
+ tips.push(
1461
1472
  buildTip(
1462
1473
  "tip/mac-telnet-no-credentials",
1463
1474
  "No username or password was resolved for this mac-telnet target.",
1464
1475
  "mac-telnet logs in with RouterOS credentials over MTWEI (MD5 is refused): pass --username/--password, set CENTRS_USERNAME/CENTRS_PASSWORD, or add a CDB record for this device.",
1465
1476
  ),
1466
- ];
1477
+ );
1478
+ }
1479
+ // A bare menu path runs — the write gate no longer stops it — but it
1480
+ // answers nothing: consoles navigate silently and the APIs refuse it with
1481
+ // `no such command`. Name the read that does answer. `canonical` is
1482
+ // always resolved in production; the guard keeps hermetic stubs total.
1483
+ if (
1484
+ resolved.canonical !== undefined &&
1485
+ isBareMenuPathCommand(resolved.canonical)
1486
+ ) {
1487
+ const menu = `${resolved.canonical.path}/${resolved.canonical.verb}`;
1488
+ tips.push(
1489
+ buildTip(
1490
+ "tip/execute-bare-menu-path",
1491
+ `"${menu}" is a menu path, not a command: consoles navigate to it (no output) and the APIs refuse it with "no such command".`,
1492
+ `Append a verb such as "${menu}/print", or read rows with "centrs retrieve <router> ${menu}".`,
1493
+ ),
1494
+ );
1467
1495
  }
1468
- return [];
1496
+ return tips;
1469
1497
  }
1470
1498
 
1471
1499
  function dataFromResult(result: ProtocolExecuteResult): unknown {
@@ -78,7 +78,12 @@
78
78
 
79
79
  import { isScopeBrace, isScopeBraceIn } from "./blocks.ts";
80
80
  import { braceSlotAcceptsBrace, braceSlotTakesArray } from "./brace-slots.ts";
81
- import { braceStartsStatements } from "./scope-brace.ts";
81
+ import {
82
+ braceBodyIsScriptLike,
83
+ braceStartsStatements,
84
+ isScriptBodyName,
85
+ scopeNameFromMasked,
86
+ } from "./scope-brace.ts";
82
87
  import {
83
88
  type MaskedRange,
84
89
  maskComments,
@@ -353,6 +358,34 @@ function braceValueOwner(
353
358
  return ARGUMENT_NAME.test(name) ? name.toLowerCase() : undefined;
354
359
  }
355
360
 
361
+ /**
362
+ * The `name` of a `name= {…}` brace: the token before the gap ends in a glued
363
+ * `name=` run (#554).
364
+ *
365
+ * {@link braceValueOwner} only sees the `=` glued to the brace (`down={`). A
366
+ * space after the `=` starts a new token, so the brace reads as positional
367
+ * there — but on CHR 7.25 `down= { :beep }` lowers to `down-script=` exactly
368
+ * like the glued spelling, while `comment= {1;2}` is a `syntax error` at the
369
+ * `{`. Only spaces and tabs span the gap: a newline there is a statement
370
+ * boundary, not a gap, and the name run must start its token, so `=down= {`
371
+ * and `down = {` own nothing.
372
+ */
373
+ function spacedBraceOwner(text: string, at: number): string | undefined {
374
+ let j = at - 1;
375
+ let gap = 0;
376
+ while (text[j] === " " || text[j] === "\t") {
377
+ j--;
378
+ gap++;
379
+ }
380
+ if (gap === 0 || text[j] !== "=") return undefined;
381
+ const nameEnd = j;
382
+ j--;
383
+ while (j >= 0 && /[A-Za-z0-9.-]/.test(text[j] as string)) j--;
384
+ if (j >= 0 && !/[\s([]/.test(text[j] as string)) return undefined;
385
+ const name = text.slice(j + 1, nameEnd);
386
+ return ARGUMENT_NAME.test(name) ? name.toLowerCase() : undefined;
387
+ }
388
+
356
389
  /**
357
390
  * Whether a `{` at `at` may open an array literal in this statement.
358
391
  *
@@ -1715,6 +1748,22 @@ export function comparesNamedValue(
1715
1748
  return /^[A-Za-z.]/.test(name) && /[\s(]/.test(text[j] as string);
1716
1749
  }
1717
1750
 
1751
+ /** One top-level `{` in command arguments that is not a proven body. */
1752
+ export interface CommandBraceFinding {
1753
+ /** Offset of the `{` in the text passed to the lexer. */
1754
+ at: number;
1755
+ /**
1756
+ * True when `name={` with a script-like body on a non-root command outside
1757
+ * `where`, where `name` is not a proven script-body slot (#525). The brace
1758
+ * may abbreviate one (`down=` for `down-script=`), but telling it from
1759
+ * `comment=` needs the command's argument list, which is schema — so the
1760
+ * caller warns instead of rejecting, and the device decides.
1761
+ */
1762
+ ambiguous: boolean;
1763
+ /** The `name` of `name=`, lower-cased, when `ambiguous` is true. */
1764
+ name?: string;
1765
+ }
1766
+
1718
1767
  /**
1719
1768
  * Locate top-level `{...}` values that RouterOS rejects in command arguments.
1720
1769
  *
@@ -1724,15 +1773,21 @@ export function comparesNamedValue(
1724
1773
  * excluded: their brace is a script body, not an array. The caller applies
1725
1774
  * this only to non-root commands; root scripting directives use the
1726
1775
  * `(verb, slot)` table instead.
1776
+ *
1777
+ * A `name={` with a script-like body that is not a proven slot is returned
1778
+ * with `ambiguous: true` rather than as an error (#525), and so is the spaced
1779
+ * `name= {…}` spelling (`down= { :beep }` lowers to `down-script=` on CHR
1780
+ * 7.25). Array-content braces (`comment={1;2}`), positional braces, `where`
1781
+ * comparisons and root slots keep their rejection.
1727
1782
  */
1728
1783
  export function invalidCommandBraceOffsets(
1729
1784
  text: string,
1730
1785
  from: number,
1731
1786
  directiveVerb?: string,
1732
1787
  range?: MaskedRange,
1733
- ): number[] {
1788
+ ): CommandBraceFinding[] {
1734
1789
  const structural = maskOf(range) ?? maskComments(text);
1735
- const offsets: number[] = [];
1790
+ const offsets: CommandBraceFinding[] = [];
1736
1791
  const stack: string[] = [];
1737
1792
  let i = Math.max(0, from);
1738
1793
  let tokenStart: number | null = null;
@@ -1783,7 +1838,54 @@ export function invalidCommandBraceOffsets(
1783
1838
  tokenStart === null
1784
1839
  ? undefined
1785
1840
  : braceValueOwner(structural, tokenStart, i);
1786
- const slot = owner === null ? `#${positionals}` : owner;
1841
+ // A space after the `=` owns the brace the same way the glued `=`
1842
+ // does (#554), so the slot attributes the spaced owner before
1843
+ // falling back to positional: `:put x= { :beep }` reads slot `x`,
1844
+ // not `#0` (which `put` accepts, swallowing the error).
1845
+ const spacedOwner =
1846
+ typeof owner === "string" ? undefined : spacedBraceOwner(structural, i);
1847
+ const slot = owner === null ? (spacedOwner ?? `#${positionals}`) : owner;
1848
+ // A `where` named comparison reads the brace with the property's own
1849
+ // parser and rejects it (`expected <name> value`), so the #525 body
1850
+ // guess must not shield it below — `gateway={ :beep }` included.
1851
+ const inWhereComparison =
1852
+ directiveVerb === undefined &&
1853
+ whereAt !== null &&
1854
+ comparesNamedValue(structural, i, whereAt);
1855
+ // The ambiguity check comes first: `braceStartsStatements` reads an
1856
+ // unproven `name={` with a script-like body as a body too (#525), so
1857
+ // reaching that skip would swallow the warning that marks the guess.
1858
+ // Control scopes (`do={` on a monitor, `:retry … command={`) are not
1859
+ // guesses — the scope table already proves them — so they stay out.
1860
+ // A space after the `=` owns the brace the same way the glued `=`
1861
+ // does (`down= { :beep }` lowers to `down-script=` on CHR 7.25), so
1862
+ // the spaced owner warns under the same gates.
1863
+ const scopeName =
1864
+ directiveVerb === undefined && whereAt === null
1865
+ ? range === undefined
1866
+ ? scopeNameFromMasked(structural, i)
1867
+ : scopeNameFromMasked(range.masked, range.start + i, range.start)
1868
+ : null;
1869
+ const ambiguousOwner = typeof owner === "string" ? owner : spacedOwner;
1870
+ if (
1871
+ directiveVerb === undefined &&
1872
+ whereAt === null &&
1873
+ scopeName === null &&
1874
+ typeof ambiguousOwner === "string" &&
1875
+ !isScriptBodyName(ambiguousOwner) &&
1876
+ braceBodyIsScriptLike(structural, i, end ?? structural.length)
1877
+ ) {
1878
+ offsets.push({ at: i, ambiguous: true, name: ambiguousOwner });
1879
+ i = end ?? i + 1;
1880
+ continue;
1881
+ }
1882
+ // A root directive's slots and a `where` named comparison never take
1883
+ // a script body through an unproven name: `:put x={ :beep }` and
1884
+ // `where gateway={ :beep }` are device rejections, so the brace reads
1885
+ // as a VALUE for the body check and the error below stands (#554).
1886
+ // Proven slots still skip — the gate is only the unproven disjunct.
1887
+ const unprovenName =
1888
+ directiveVerb !== undefined || inWhereComparison ? "value" : "body";
1787
1889
  if (
1788
1890
  // Asked of the DOCUMENT when the caller anchored this statement, so the
1789
1891
  // statement index behind it is the document's one index rather than a
@@ -1791,19 +1893,22 @@ export function invalidCommandBraceOffsets(
1791
1893
  // subtree, and so was rebuilt once per enclosing level (#322). The floor
1792
1894
  // makes the two readings the same answer (`scope-brace.ts`).
1793
1895
  (range === undefined
1794
- ? braceStartsStatements(structural, i)
1795
- : braceStartsStatements(range.masked, range.start + i, range.start)) ||
1896
+ ? braceStartsStatements(structural, i, 0, unprovenName)
1897
+ : braceStartsStatements(
1898
+ range.masked,
1899
+ range.start + i,
1900
+ range.start,
1901
+ unprovenName,
1902
+ )) ||
1796
1903
  (directiveVerb !== undefined &&
1797
1904
  slot !== undefined &&
1798
1905
  braceSlotAcceptsBrace(directiveVerb, slot)) ||
1799
- (directiveVerb === undefined &&
1800
- whereAt !== null &&
1801
- !comparesNamedValue(structural, i, whereAt))
1906
+ (directiveVerb === undefined && whereAt !== null && !inWhereComparison)
1802
1907
  ) {
1803
1908
  i = end ?? structural.length;
1804
1909
  continue;
1805
1910
  }
1806
- offsets.push(i);
1911
+ offsets.push({ at: i, ambiguous: false });
1807
1912
  i = end ?? i + 1;
1808
1913
  }
1809
1914
  return offsets;