@tikoci/centrs 0.1.9-next.22 → 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.
- package/CHANGELOG.md +106 -0
- package/GLOSSARY.txt +38 -0
- package/README.md +2 -2
- package/commands/api/AGENTS.md +18 -1
- package/commands/api/README.md +10 -6
- package/commands/api/examples.md +1 -1
- package/commands/execute/README.md +26 -6
- package/commands/execute/examples.md +43 -5
- package/commands/explain/README.md +873 -182
- package/commands/explain/examples.md +447 -38
- package/commands/retrieve/README.md +13 -2
- package/commands/retrieve/examples.md +30 -1
- package/commands/transfer/README.md +8 -4
- package/docs/CLI.md +19 -5
- package/docs/CONSTITUTION.md +18 -9
- package/docs/MATRIX.md +51 -26
- package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
- package/docs/errors/routeros/permission-denied.md +24 -0
- package/docs/errors/transport/edge-auth-failed.md +22 -0
- package/docs/errors/transport/write-outcome-unknown.md +3 -0
- package/docs/errors/usage/invalid-cursor.md +20 -0
- package/docs/errors/validation/unknown-path.md +9 -0
- package/package.json +5 -2
- package/src/api.ts +34 -39
- package/src/cli/explain.ts +206 -47
- package/src/core/envelope.ts +10 -3
- package/src/core/error-catalog.ts +15 -0
- package/src/core/execute-command.ts +53 -0
- package/src/core/fanout.ts +12 -2
- package/src/core/inspect.ts +4 -5
- package/src/core/routeros-errors.ts +25 -0
- package/src/execute-cmr.ts +2 -8
- package/src/execute.ts +48 -20
- package/src/explain/args.ts +181 -15
- package/src/explain/blocks.ts +3 -4
- package/src/explain/brace-slots.ts +5 -0
- package/src/explain/catalog.ts +13 -6
- package/src/explain/defects.ts +1 -0
- package/src/explain/group-faults.ts +1060 -0
- package/src/explain/highlight-projection.ts +10 -8
- package/src/explain/operators.ts +22 -20
- package/src/explain/pathresolve.ts +102 -20
- package/src/explain/quoted-string.ts +41 -6
- package/src/explain/run-tokens.ts +223 -0
- package/src/explain/scope-brace.ts +319 -7
- package/src/explain/segment.ts +177 -4
- package/src/explain/spaced-args.ts +116 -0
- package/src/explain/symbols.ts +163 -7
- package/src/explain/transport.ts +13 -0
- package/src/explain/values.ts +55 -4
- package/src/explain/verbsplit.ts +14 -188
- package/src/explain/write.ts +8 -1
- package/src/explain-live-facets.ts +739 -0
- package/src/explain-live-types.ts +490 -0
- package/src/explain-live.ts +593 -0
- package/src/explain.ts +772 -60
- package/src/index.ts +6 -0
- package/src/offline-gate.ts +30 -9
- package/src/protocols/adapter.ts +88 -10
- package/src/protocols/btest-session.ts +30 -10
- package/src/protocols/mac-telnet-console.ts +16 -0
- package/src/protocols/mac-telnet.ts +38 -10
- package/src/protocols/native-api.ts +13 -12
- package/src/retrieve-follow.ts +2 -1
- package/src/retrieve-sample.ts +2 -1
- package/src/retrieve-wait.ts +10 -6
- package/src/retrieve.ts +77 -27
- package/src/transfer.ts +38 -13
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
|
|
668
|
-
//
|
|
669
|
-
//
|
|
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
|
-
|
|
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 {
|
package/src/explain/args.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
@@ -87,7 +92,7 @@ import {
|
|
|
87
92
|
} from "./segment.ts";
|
|
88
93
|
|
|
89
94
|
/** A RouterOS argument name: bare word, optionally dotted (`.id`, `.proplist`). */
|
|
90
|
-
const ARGUMENT_NAME = /^\.?[A-Za-z][A-Za-z0-9._-]*$/;
|
|
95
|
+
export const ARGUMENT_NAME = /^\.?[A-Za-z][A-Za-z0-9._-]*$/;
|
|
91
96
|
|
|
92
97
|
/** What one argument token is. */
|
|
93
98
|
export type ArgumentKind = "attribute" | "positional" | "query";
|
|
@@ -327,6 +332,12 @@ interface WalkOptions {
|
|
|
327
332
|
* see {@link braceSlotTakesArray}.
|
|
328
333
|
*/
|
|
329
334
|
directiveVerb?: string;
|
|
335
|
+
/**
|
|
336
|
+
* Where a command's top-level `where` positional ends, set by the walk itself
|
|
337
|
+
* once it reads one. From there on a brace is an expression value
|
|
338
|
+
* (see {@link comparesNamedValue}).
|
|
339
|
+
*/
|
|
340
|
+
whereAt?: number;
|
|
330
341
|
}
|
|
331
342
|
|
|
332
343
|
/** How deep member scanning descends before it stops and keeps only the shape. */
|
|
@@ -347,6 +358,34 @@ function braceValueOwner(
|
|
|
347
358
|
return ARGUMENT_NAME.test(name) ? name.toLowerCase() : undefined;
|
|
348
359
|
}
|
|
349
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
|
+
|
|
350
389
|
/**
|
|
351
390
|
* Whether a `{` at `at` may open an array literal in this statement.
|
|
352
391
|
*
|
|
@@ -363,7 +402,11 @@ function braceOpensArray(
|
|
|
363
402
|
options: WalkOptions,
|
|
364
403
|
): boolean {
|
|
365
404
|
const verb = options.directiveVerb;
|
|
366
|
-
if (verb === undefined)
|
|
405
|
+
if (verb === undefined)
|
|
406
|
+
return (
|
|
407
|
+
options.whereAt !== undefined &&
|
|
408
|
+
!comparesNamedValue(text, at, options.whereAt)
|
|
409
|
+
);
|
|
367
410
|
const owner = braceValueOwner(text, tokenStart, at);
|
|
368
411
|
if (owner === undefined) return false;
|
|
369
412
|
return braceSlotTakesArray(
|
|
@@ -390,6 +433,7 @@ function* walkArguments(
|
|
|
390
433
|
let i = Math.max(0, from);
|
|
391
434
|
// How many positionals have been read, which is the NEXT positional's index.
|
|
392
435
|
let positionals = 0;
|
|
436
|
+
let walk = options;
|
|
393
437
|
while (i < text.length) {
|
|
394
438
|
// The MASKED view decides whitespace, because `maskComments` blanks a
|
|
395
439
|
// continuation comment that `text` still spells with a `#`. Reading `text`
|
|
@@ -412,7 +456,7 @@ function* walkArguments(
|
|
|
412
456
|
i += continuation;
|
|
413
457
|
continue;
|
|
414
458
|
}
|
|
415
|
-
const token = scanToken(text, structural, i, positionals,
|
|
459
|
+
const token = scanToken(text, structural, i, positionals, walk);
|
|
416
460
|
if (typeof token === "string") {
|
|
417
461
|
yield token;
|
|
418
462
|
return;
|
|
@@ -430,7 +474,7 @@ function* walkArguments(
|
|
|
430
474
|
// the refusal exists to prevent. Shape only, never a value.
|
|
431
475
|
const read =
|
|
432
476
|
token.why === undefined
|
|
433
|
-
? readToken(text, structural, i, token.end,
|
|
477
|
+
? readToken(text, structural, i, token.end, walk)
|
|
434
478
|
: undecidedToken(text, i, token.end, token.why);
|
|
435
479
|
if (typeof read === "string") {
|
|
436
480
|
if (options.tolerant !== true) {
|
|
@@ -446,6 +490,13 @@ function* walkArguments(
|
|
|
446
490
|
continue;
|
|
447
491
|
}
|
|
448
492
|
if (read.kind === "positional") positionals++;
|
|
493
|
+
if (
|
|
494
|
+
read.kind === "positional" &&
|
|
495
|
+
read.text === "where" &&
|
|
496
|
+
walk.directiveVerb === undefined &&
|
|
497
|
+
walk.whereAt === undefined
|
|
498
|
+
)
|
|
499
|
+
walk = { ...walk, whereAt: token.end };
|
|
449
500
|
yield read;
|
|
450
501
|
i = token.end;
|
|
451
502
|
}
|
|
@@ -1661,26 +1712,87 @@ export function lexValueAnchors(
|
|
|
1661
1712
|
return { complete: true, anchors };
|
|
1662
1713
|
}
|
|
1663
1714
|
|
|
1715
|
+
/**
|
|
1716
|
+
* Whether the brace at `at` is the right side of a `where` comparison that
|
|
1717
|
+
* RouterOS reads with the named property's own value parser (#502).
|
|
1718
|
+
*
|
|
1719
|
+
* A `where` clause is expression context, so a brace there is an array value —
|
|
1720
|
+
* except straight after `<name>=`, `<name>!=` or `<name>==`, spaced or not.
|
|
1721
|
+
* On CHR 7.24.5 and 7.23.8, `:parse` rejects `/ip/route/print where
|
|
1722
|
+
* comment={1;2}` with `expected comment value`, and `where disabled={…}` with
|
|
1723
|
+
* `expected yes or no`. It accepts the brace after `in`, `~`, `<`, `>`, `<=`
|
|
1724
|
+
* and `>=`, on the left of `=`, and after `!interface=`, `$x=` and `"a"=`.
|
|
1725
|
+
*
|
|
1726
|
+
* Only a property name the menu KNOWS is read this way: `where foo-bar={…}` is
|
|
1727
|
+
* accepted. Offline has no per-menu property list (decision 3), so every bare
|
|
1728
|
+
* name counts, and an unknown one is a pinned over-strict reading.
|
|
1729
|
+
*
|
|
1730
|
+
* The name may open a group, `where (interface={"x"})` and `or(comment={…})`,
|
|
1731
|
+
* which `group-faults.ts` asks about (#509).
|
|
1732
|
+
*/
|
|
1733
|
+
export function comparesNamedValue(
|
|
1734
|
+
text: string,
|
|
1735
|
+
at: number,
|
|
1736
|
+
floor: number,
|
|
1737
|
+
): boolean {
|
|
1738
|
+
let j = at - 1;
|
|
1739
|
+
while (j > floor && /\s/.test(text[j] as string)) j--;
|
|
1740
|
+
if (text[j] !== "=") return false;
|
|
1741
|
+
while (j > floor && text[j] === "=") j--;
|
|
1742
|
+
if (text[j] === "<" || text[j] === ">") return false;
|
|
1743
|
+
if (text[j] === "!") j--;
|
|
1744
|
+
while (j > floor && /\s/.test(text[j] as string)) j--;
|
|
1745
|
+
const nameEnd = j + 1;
|
|
1746
|
+
while (j > floor && /[A-Za-z0-9._-]/.test(text[j] as string)) j--;
|
|
1747
|
+
const name = text.slice(j + 1, nameEnd);
|
|
1748
|
+
return /^[A-Za-z.]/.test(name) && /[\s(]/.test(text[j] as string);
|
|
1749
|
+
}
|
|
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
|
+
|
|
1664
1767
|
/**
|
|
1665
1768
|
* Locate top-level `{...}` values that RouterOS rejects in command arguments.
|
|
1666
1769
|
*
|
|
1667
1770
|
* Parentheses and substitutions restore expression context, where a brace
|
|
1668
|
-
* literal is legal
|
|
1669
|
-
*
|
|
1670
|
-
*
|
|
1771
|
+
* literal is legal, and so does a `where` clause, apart from the comparisons
|
|
1772
|
+
* {@link comparesNamedValue} names. Stored-script attributes are also
|
|
1773
|
+
* excluded: their brace is a script body, not an array. The caller applies
|
|
1774
|
+
* this only to non-root commands; root scripting directives use the
|
|
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.
|
|
1671
1782
|
*/
|
|
1672
1783
|
export function invalidCommandBraceOffsets(
|
|
1673
1784
|
text: string,
|
|
1674
1785
|
from: number,
|
|
1675
1786
|
directiveVerb?: string,
|
|
1676
1787
|
range?: MaskedRange,
|
|
1677
|
-
):
|
|
1788
|
+
): CommandBraceFinding[] {
|
|
1678
1789
|
const structural = maskOf(range) ?? maskComments(text);
|
|
1679
|
-
const offsets:
|
|
1790
|
+
const offsets: CommandBraceFinding[] = [];
|
|
1680
1791
|
const stack: string[] = [];
|
|
1681
1792
|
let i = Math.max(0, from);
|
|
1682
1793
|
let tokenStart: number | null = null;
|
|
1683
1794
|
let positionals = 0;
|
|
1795
|
+
let whereAt: number | null = null;
|
|
1684
1796
|
while (i < structural.length) {
|
|
1685
1797
|
const c = structural[i] as string;
|
|
1686
1798
|
if (
|
|
@@ -1689,6 +1801,7 @@ export function invalidCommandBraceOffsets(
|
|
|
1689
1801
|
) {
|
|
1690
1802
|
if (tokenStart !== null) {
|
|
1691
1803
|
const token = structural.slice(tokenStart, i);
|
|
1804
|
+
if (whereAt === null && token === "where") whereAt = i;
|
|
1692
1805
|
if (
|
|
1693
1806
|
!token.startsWith("?") &&
|
|
1694
1807
|
unquotedEquals(token, 0, token.length) === null
|
|
@@ -1725,7 +1838,54 @@ export function invalidCommandBraceOffsets(
|
|
|
1725
1838
|
tokenStart === null
|
|
1726
1839
|
? undefined
|
|
1727
1840
|
: braceValueOwner(structural, tokenStart, i);
|
|
1728
|
-
|
|
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";
|
|
1729
1889
|
if (
|
|
1730
1890
|
// Asked of the DOCUMENT when the caller anchored this statement, so the
|
|
1731
1891
|
// statement index behind it is the document's one index rather than a
|
|
@@ -1733,16 +1893,22 @@ export function invalidCommandBraceOffsets(
|
|
|
1733
1893
|
// subtree, and so was rebuilt once per enclosing level (#322). The floor
|
|
1734
1894
|
// makes the two readings the same answer (`scope-brace.ts`).
|
|
1735
1895
|
(range === undefined
|
|
1736
|
-
? braceStartsStatements(structural, i)
|
|
1737
|
-
: braceStartsStatements(
|
|
1896
|
+
? braceStartsStatements(structural, i, 0, unprovenName)
|
|
1897
|
+
: braceStartsStatements(
|
|
1898
|
+
range.masked,
|
|
1899
|
+
range.start + i,
|
|
1900
|
+
range.start,
|
|
1901
|
+
unprovenName,
|
|
1902
|
+
)) ||
|
|
1738
1903
|
(directiveVerb !== undefined &&
|
|
1739
1904
|
slot !== undefined &&
|
|
1740
|
-
braceSlotAcceptsBrace(directiveVerb, slot))
|
|
1905
|
+
braceSlotAcceptsBrace(directiveVerb, slot)) ||
|
|
1906
|
+
(directiveVerb === undefined && whereAt !== null && !inWhereComparison)
|
|
1741
1907
|
) {
|
|
1742
1908
|
i = end ?? structural.length;
|
|
1743
1909
|
continue;
|
|
1744
1910
|
}
|
|
1745
|
-
offsets.push(i);
|
|
1911
|
+
offsets.push({ at: i, ambiguous: false });
|
|
1746
1912
|
i = end ?? i + 1;
|
|
1747
1913
|
}
|
|
1748
1914
|
return offsets;
|
package/src/explain/blocks.ts
CHANGED
|
@@ -13,10 +13,9 @@
|
|
|
13
13
|
* name (and, for the one head-dependent case, the leading directive) off the
|
|
14
14
|
* statement itself.
|
|
15
15
|
*
|
|
16
|
-
* This module ships
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* traversal of its own.
|
|
16
|
+
* This module ships the single-pass, non-recursive classification primitives
|
|
17
|
+
* the path resolver needs. `explain.ts` publishes source ownership from that
|
|
18
|
+
* resolver's existing bounded traversal; `scopeBlocks` remains a shallow scan.
|
|
20
19
|
*
|
|
21
20
|
* Every string skip here goes through `segment.ts`'s `scanQuotedString` rather
|
|
22
21
|
* than a local quote-to-quote loop, so this module and the segmenter cannot
|
|
@@ -134,6 +134,11 @@ export function braceSlotTakesArray(verb: string, slot: string): boolean {
|
|
|
134
134
|
return BRACE_ARRAY_SLOTS.get(verb)?.has(slot) === true;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/** Whether a `{…}` in this root-directive slot is code or script text. */
|
|
138
|
+
export function braceSlotTakesProgram(verb: string, slot: string): boolean {
|
|
139
|
+
return BRACE_PROGRAM_SLOTS.get(verb)?.has(slot) === true;
|
|
140
|
+
}
|
|
141
|
+
|
|
137
142
|
/** Whether the device sweep proved this root-directive slot accepts `{...}`. */
|
|
138
143
|
export function braceSlotAcceptsBrace(verb: string, slot: string): boolean {
|
|
139
144
|
return (
|
package/src/explain/catalog.ts
CHANGED
|
@@ -1317,6 +1317,11 @@ export function lookupPath(
|
|
|
1317
1317
|
return PATH_CATALOG.get(`/${segments.join("/").toLowerCase()}`);
|
|
1318
1318
|
}
|
|
1319
1319
|
|
|
1320
|
+
/** Segments in the deepest catalog path; no lookup past it can match. */
|
|
1321
|
+
const MAX_PATH_DEPTH = Math.max(
|
|
1322
|
+
...Array.from(PATH_CATALOG.keys(), (path) => path.split("/").length - 1),
|
|
1323
|
+
);
|
|
1324
|
+
|
|
1320
1325
|
/**
|
|
1321
1326
|
* Where a published COMMAND ends inside a path run: the index of the run
|
|
1322
1327
|
* segment that is the VERB, or `null` when no prefix of `segments` is one.
|
|
@@ -1340,12 +1345,14 @@ export function lookupPath(
|
|
|
1340
1345
|
* question; what the segment IS is not.
|
|
1341
1346
|
*/
|
|
1342
1347
|
export function commandVerbIndex(segments: readonly string[]): number | null {
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1348
|
+
// Bounded by the deepest row and built incrementally: a run of thousands of
|
|
1349
|
+
// bare words would otherwise re-join every prefix (#496).
|
|
1350
|
+
const limit = Math.min(segments.length, MAX_PATH_DEPTH);
|
|
1351
|
+
let path = "";
|
|
1352
|
+
for (let depth = 0; depth < limit; depth++) {
|
|
1353
|
+
path += `/${(segments[depth] as string).toLowerCase()}`;
|
|
1354
|
+
if (PATH_CATALOG.get(path)?.kind === "command") return depth;
|
|
1355
|
+
}
|
|
1349
1356
|
return null;
|
|
1350
1357
|
}
|
|
1351
1358
|
|