@tikoci/centrs 0.1.9-next.22 → 0.1.9-next.23
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/GLOSSARY.txt +24 -0
- package/commands/execute/examples.md +19 -0
- package/commands/explain/README.md +428 -128
- package/commands/explain/examples.md +260 -1
- package/docs/CONSTITUTION.md +4 -0
- package/docs/MATRIX.md +23 -11
- package/package.json +4 -1
- package/src/explain/args.ts +69 -8
- 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 +927 -0
- package/src/explain/highlight-projection.ts +10 -8
- package/src/explain/operators.ts +20 -19
- 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 +103 -6
- package/src/explain/segment.ts +41 -3
- package/src/explain/spaced-args.ts +116 -0
- package/src/explain/symbols.ts +17 -1
- package/src/explain/transport.ts +13 -0
- package/src/explain/verbsplit.ts +14 -188
- package/src/explain/write.ts +8 -1
- package/src/explain.ts +378 -18
- package/src/offline-gate.ts +30 -9
- package/src/protocols/btest-session.ts +30 -10
- package/src/protocols/mac-telnet-console.ts +16 -0
package/src/explain.ts
CHANGED
|
@@ -129,6 +129,7 @@ import { lookupPath } from "./explain/catalog.ts";
|
|
|
129
129
|
import {
|
|
130
130
|
analyzeCoordinates,
|
|
131
131
|
type CoordinateAnalysis,
|
|
132
|
+
runAtByte,
|
|
132
133
|
} from "./explain/coordinates.ts";
|
|
133
134
|
import {
|
|
134
135
|
type Defect,
|
|
@@ -136,6 +137,7 @@ import {
|
|
|
136
137
|
isPositionalFact,
|
|
137
138
|
} from "./explain/defects.ts";
|
|
138
139
|
import { escapeSpans } from "./explain/escape-tokens.ts";
|
|
140
|
+
import { findGroupFaults, type GroupFault } from "./explain/group-faults.ts";
|
|
139
141
|
import { isKnownMenuPath } from "./explain/is-known-menu.ts";
|
|
140
142
|
import { operatorSpans } from "./explain/operator-tokens.ts";
|
|
141
143
|
import { type PathTokenCandidate, pathSpans } from "./explain/path-tokens.ts";
|
|
@@ -157,6 +159,7 @@ import {
|
|
|
157
159
|
findMissingSeparators,
|
|
158
160
|
type MissingSeparator,
|
|
159
161
|
} from "./explain/separator.ts";
|
|
162
|
+
import { findSpacedPairs, type SpacedPair } from "./explain/spaced-args.ts";
|
|
160
163
|
import { stringSpans } from "./explain/string-tokens.ts";
|
|
161
164
|
import { augmentSymbolOccurrences } from "./explain/symbol-values.ts";
|
|
162
165
|
import {
|
|
@@ -374,7 +377,15 @@ export interface ExplainStatementFacts {
|
|
|
374
377
|
ev: string;
|
|
375
378
|
}
|
|
376
379
|
|
|
377
|
-
export type ExplainStatement = ExplainStatementFacts &
|
|
380
|
+
export type ExplainStatement = ExplainStatementFacts &
|
|
381
|
+
ExplainReading & {
|
|
382
|
+
/** Stable within this result: `s0`, `s1`, … in discovery order. */
|
|
383
|
+
id: string;
|
|
384
|
+
/** Owning statement's id; absent at top level. Source containment only. */
|
|
385
|
+
parent?: string;
|
|
386
|
+
/** Index of the innermost published `blocks[]` body containing this statement. */
|
|
387
|
+
block?: number;
|
|
388
|
+
};
|
|
378
389
|
|
|
379
390
|
/**
|
|
380
391
|
* A `[…]` command substitution, re-constituted against its enclosing menu.
|
|
@@ -394,6 +405,8 @@ export type ExplainStatement = ExplainStatementFacts & ExplainReading;
|
|
|
394
405
|
* opinion.
|
|
395
406
|
*/
|
|
396
407
|
export interface ExplainSubcommandFacts extends ExplainStatementFacts {
|
|
408
|
+
/** Id of the statement containing this substitution, including nested brackets. */
|
|
409
|
+
owner: string;
|
|
397
410
|
/** Menu context in force at the bracket, `/` at document root. */
|
|
398
411
|
context: string;
|
|
399
412
|
/** Every path the inner command could resolve to, shortest first. */
|
|
@@ -406,9 +419,11 @@ export type ExplainSubcommand = ExplainSubcommandFacts & ExplainReading;
|
|
|
406
419
|
|
|
407
420
|
/** A `{…}` scope at document level. */
|
|
408
421
|
export interface ExplainBlock {
|
|
422
|
+
/** Owning statement's id, when the body belongs to a published statement. */
|
|
423
|
+
owner?: string;
|
|
409
424
|
/** The argument or directive that opened it (`do`, `on-error`, …). */
|
|
410
425
|
name: string;
|
|
411
|
-
/** The BODY's span, braces excluded. */
|
|
426
|
+
/** The BODY's analyzed-byte span, braces excluded. */
|
|
412
427
|
span: ExplainSpanRange;
|
|
413
428
|
ev: string;
|
|
414
429
|
}
|
|
@@ -424,10 +439,9 @@ export interface ExplainStructure {
|
|
|
424
439
|
*
|
|
425
440
|
* A `do={…}` body's statements are flattened in after their parent (which is
|
|
426
441
|
* what RouterOS IL does to them), so a body statement's span is CONTAINED by
|
|
427
|
-
* its parent's rather than following it, and spans may overlap.
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
* the list as it stands.
|
|
442
|
+
* its parent's rather than following it, and spans may overlap. Filter on an
|
|
443
|
+
* absent `parent` for the top level; use `id`/`parent` for source ancestry.
|
|
444
|
+
* Links assert no lexical inheritance, execution order, or shared environment.
|
|
431
445
|
*/
|
|
432
446
|
statements: ExplainStatement[];
|
|
433
447
|
blocks: ExplainBlock[];
|
|
@@ -750,6 +764,8 @@ const EV = {
|
|
|
750
764
|
braces: "e14",
|
|
751
765
|
separators: "e15",
|
|
752
766
|
escapes: "e16",
|
|
767
|
+
spaced: "e17",
|
|
768
|
+
groups: "e18",
|
|
753
769
|
} as const;
|
|
754
770
|
|
|
755
771
|
type EvidenceKey = keyof typeof EV;
|
|
@@ -904,6 +920,33 @@ const EVIDENCE: Record<EvidenceKey, ExplainEvidence> = {
|
|
|
904
920
|
basis: "heuristic",
|
|
905
921
|
outcome: "ok",
|
|
906
922
|
},
|
|
923
|
+
/**
|
|
924
|
+
* `heuristic`: which adjacent operands could be the spaced spelling is read
|
|
925
|
+
* from the token shapes alone. That the device pairs them, and marks the gap
|
|
926
|
+
* `syntax-obsolete`, is grounded (`src/explain/spaced-args.ts`); which pairs
|
|
927
|
+
* it pairs is per-command schema.
|
|
928
|
+
*/
|
|
929
|
+
spaced: {
|
|
930
|
+
id: EV.spaced,
|
|
931
|
+
source: "canonicalizer",
|
|
932
|
+
probe: "findSpacedPairs",
|
|
933
|
+
basis: "heuristic",
|
|
934
|
+
outcome: "ok",
|
|
935
|
+
},
|
|
936
|
+
/**
|
|
937
|
+
* `heuristic`: every rejection it reports is a `:parse` answer
|
|
938
|
+
* (`src/explain/group-faults.ts`), but which group is an array, an
|
|
939
|
+
* expression or a query, and which token a property's parser would read
|
|
940
|
+
* instead, rests on the brace-role and `where` readings a live probe could
|
|
941
|
+
* overturn.
|
|
942
|
+
*/
|
|
943
|
+
groups: {
|
|
944
|
+
id: EV.groups,
|
|
945
|
+
source: "canonicalizer",
|
|
946
|
+
probe: "findGroupFaults",
|
|
947
|
+
basis: "heuristic",
|
|
948
|
+
outcome: "ok",
|
|
949
|
+
},
|
|
907
950
|
};
|
|
908
951
|
|
|
909
952
|
/** Diagnostic rendering for each defect class. */
|
|
@@ -933,6 +976,13 @@ const DEFECT_DIAGNOSTICS: Record<
|
|
|
933
976
|
message: () =>
|
|
934
977
|
'invalid escape in string: unknown escape, truncated hex, or lowercase hex digit — use \\n \\r \\t \\" \\\\ \\$ \\_ \\? \\a \\b \\f \\v, \\XX with uppercase hex, or \\ before whitespace to continue the line',
|
|
935
978
|
},
|
|
979
|
+
// #506. The device's answer for every follower but a name, `"`, `(` and
|
|
980
|
+
// `[` (`src/explain/quoted-string.ts`); the span is the `$` and that byte.
|
|
981
|
+
"bad-string-variable": {
|
|
982
|
+
severity: "error",
|
|
983
|
+
message: () =>
|
|
984
|
+
`\`$\` in a string must start a variable name (\`$v\`, \`$"v"\`), \`$(…)\` or \`$[…]\`, and RouterOS rejects it (\`syntax error\`) — write \`\\$\` for a literal dollar`,
|
|
985
|
+
},
|
|
936
986
|
"bad-sigil": {
|
|
937
987
|
severity: "error",
|
|
938
988
|
message: () => "invalid sigil: the run must be zero or one character",
|
|
@@ -1284,6 +1334,18 @@ export function explainCommand(
|
|
|
1284
1334
|
// `analyzed` is ASCII by construction, so the offsets need no coordinate test.
|
|
1285
1335
|
const analyzedRange = documentRange(analyzed, coordinates.ascii);
|
|
1286
1336
|
const segmented = segmentStatements(input);
|
|
1337
|
+
if (segmented.discardFrom !== undefined)
|
|
1338
|
+
return withDiscardedTail(
|
|
1339
|
+
explainCommand(
|
|
1340
|
+
input.slice(
|
|
1341
|
+
0,
|
|
1342
|
+
runAtByte(coordinates, segmented.discardFrom).utf16Start,
|
|
1343
|
+
),
|
|
1344
|
+
options,
|
|
1345
|
+
),
|
|
1346
|
+
coordinates,
|
|
1347
|
+
segmented.discardFrom,
|
|
1348
|
+
);
|
|
1287
1349
|
const statementAnalysis = resolveStatements(input);
|
|
1288
1350
|
const sourceRange = coordinates.ascii ? analyzedRange : undefined;
|
|
1289
1351
|
const verbs = resolveVerbsFromStatements(statementAnalysis, sourceRange);
|
|
@@ -1341,8 +1403,14 @@ export function explainCommand(
|
|
|
1341
1403
|
// segments and pairing the two by index attaches the wrong span to every statement after
|
|
1342
1404
|
// the first block. Each split carries its own document-space span.
|
|
1343
1405
|
//
|
|
1344
|
-
const readStatements: ExplainStatement[] = verbs.splits.map((split) =>
|
|
1345
|
-
statementOf(
|
|
1406
|
+
const readStatements: ExplainStatement[] = verbs.splits.map((split, index) =>
|
|
1407
|
+
statementOf(
|
|
1408
|
+
split,
|
|
1409
|
+
analyzed,
|
|
1410
|
+
analyzedRange,
|
|
1411
|
+
index,
|
|
1412
|
+
statementAnalysis.statements[index]?.ownership?.parent,
|
|
1413
|
+
),
|
|
1346
1414
|
);
|
|
1347
1415
|
const canonical = canonicalizeExecuteCommand(input);
|
|
1348
1416
|
// The skip-tolerant token stream every TOKEN-LEVEL rule reads (#316), one
|
|
@@ -1366,17 +1434,38 @@ export function explainCommand(
|
|
|
1366
1434
|
? findMissingSeparators(argumentTokens[index] ?? [])
|
|
1367
1435
|
: [],
|
|
1368
1436
|
);
|
|
1437
|
+
// #350: the same order for the space-for-`=` spelling. Root directives are
|
|
1438
|
+
// excluded because their operands are positional by grammar
|
|
1439
|
+
// (`src/explain/spaced-args.ts`).
|
|
1440
|
+
const spaced = readStatements.map((statement, index) =>
|
|
1441
|
+
statement.resolution === "resolved" &&
|
|
1442
|
+
statement.kind === "command" &&
|
|
1443
|
+
statement.command.path !== "/"
|
|
1444
|
+
? findSpacedPairs(argumentTokens[index] ?? [])
|
|
1445
|
+
: [],
|
|
1446
|
+
);
|
|
1369
1447
|
const gated = enforceGateParity(readStatements, canonical);
|
|
1370
1448
|
const statements = gated.map((statement, index) => {
|
|
1371
1449
|
const runs = separators[index] ?? [];
|
|
1450
|
+
const pairs = spaced[index] ?? [];
|
|
1451
|
+
const separated = withoutSeparatedArguments(statement, runs);
|
|
1452
|
+
const unpaired = withoutSpacedArguments(separated, pairs);
|
|
1372
1453
|
return withStatementTransport(
|
|
1373
|
-
|
|
1454
|
+
unpaired,
|
|
1374
1455
|
verbs.splits[index]?.text ?? "",
|
|
1375
1456
|
options,
|
|
1376
1457
|
runs.length === 0 ? undefined : rejectedBySeparator(runs),
|
|
1458
|
+
// Only a reading this step withdrew. One the lexer already refused
|
|
1459
|
+
// (`source {…}`, `[find]`) keeps the `execute` line it had.
|
|
1460
|
+
unpaired === separated ? undefined : unpairedBySpacing(pairs),
|
|
1377
1461
|
);
|
|
1378
1462
|
});
|
|
1379
1463
|
|
|
1464
|
+
const braceDiagnostics = invalidCommandBraceDiagnostics(
|
|
1465
|
+
verbs.splits,
|
|
1466
|
+
analyzed,
|
|
1467
|
+
analyzedRange,
|
|
1468
|
+
);
|
|
1380
1469
|
const diagnostics: ExplainDiagnostic[] = [
|
|
1381
1470
|
...defects.map(({ defect: d, ev }) => {
|
|
1382
1471
|
const render = DEFECT_DIAGNOSTICS[d.code];
|
|
@@ -1388,8 +1477,15 @@ export function explainCommand(
|
|
|
1388
1477
|
ev,
|
|
1389
1478
|
};
|
|
1390
1479
|
}),
|
|
1391
|
-
...
|
|
1480
|
+
...braceDiagnostics,
|
|
1481
|
+
...groupFaultDiagnostics(
|
|
1482
|
+
findGroupFaults(analyzed, analyzedRange.masked),
|
|
1483
|
+
braceDiagnostics,
|
|
1484
|
+
),
|
|
1392
1485
|
...separators.flatMap((runs) => runs.map(missingSeparatorDiagnostic)),
|
|
1486
|
+
...spaced.flatMap((pairs) =>
|
|
1487
|
+
pairs.filter((pair) => pair.valueShaped).map(spacedArgumentDiagnostic),
|
|
1488
|
+
),
|
|
1393
1489
|
...statements.flatMap((s) => diagnosticsForStatement(s)),
|
|
1394
1490
|
].sort(
|
|
1395
1491
|
(a, b) =>
|
|
@@ -1428,14 +1524,73 @@ export function explainCommand(
|
|
|
1428
1524
|
),
|
|
1429
1525
|
};
|
|
1430
1526
|
|
|
1527
|
+
const blocks: ExplainBlock[] = scopeBlocks(input).map((b) => ({
|
|
1528
|
+
name: b.name,
|
|
1529
|
+
span: { start: b.start, end: b.start + b.body.length },
|
|
1530
|
+
ev: EV.blocks,
|
|
1531
|
+
}));
|
|
1532
|
+
// Link the existing outer-block inventory in UTF-16 source coordinates.
|
|
1533
|
+
// Q4's traversal supplies parentage even when Unicode widened descendant spans.
|
|
1534
|
+
const sourceOffset = (byte: number): number =>
|
|
1535
|
+
coordinates.ascii
|
|
1536
|
+
? byte
|
|
1537
|
+
: byte === coordinates.analyzed.length
|
|
1538
|
+
? input.length
|
|
1539
|
+
: runAtByte(coordinates, byte).utf16Start;
|
|
1540
|
+
const roots = statements.filter(
|
|
1541
|
+
(statement) => statement.parent === undefined,
|
|
1542
|
+
);
|
|
1543
|
+
let rootAt = 0;
|
|
1544
|
+
const blockIndexes = new Map<number, number>();
|
|
1545
|
+
for (const [index, block] of blocks.entries()) {
|
|
1546
|
+
blockIndexes.set(block.span.start, index);
|
|
1547
|
+
while (
|
|
1548
|
+
roots[rootAt] !== undefined &&
|
|
1549
|
+
sourceOffset((roots[rootAt] as ExplainStatement).span.end) <
|
|
1550
|
+
block.span.start
|
|
1551
|
+
)
|
|
1552
|
+
rootAt++;
|
|
1553
|
+
const owner = roots[rootAt];
|
|
1554
|
+
if (
|
|
1555
|
+
owner !== undefined &&
|
|
1556
|
+
sourceOffset(owner.span.start) <= block.span.start &&
|
|
1557
|
+
sourceOffset(owner.span.end) >= block.span.end
|
|
1558
|
+
)
|
|
1559
|
+
block.owner = owner.id;
|
|
1560
|
+
}
|
|
1561
|
+
for (const [index, statement] of statements.entries()) {
|
|
1562
|
+
const ownership = statementAnalysis.statements[index]?.ownership;
|
|
1563
|
+
if (ownership === undefined) continue;
|
|
1564
|
+
const parent = statements[ownership.parent] as ExplainStatement;
|
|
1565
|
+
const block =
|
|
1566
|
+
parent.block ??
|
|
1567
|
+
(parent.parent === undefined
|
|
1568
|
+
? blockIndexes.get(
|
|
1569
|
+
sourceOffset(parent.span.start) + ownership.bodyStart,
|
|
1570
|
+
)
|
|
1571
|
+
: undefined);
|
|
1572
|
+
if (block !== undefined) statement.block = block;
|
|
1573
|
+
}
|
|
1574
|
+
// Publish analyzed-byte spans only after ownership has used source offsets.
|
|
1575
|
+
// Shallow blocks are disjoint and ordered, so one forward walk maps both
|
|
1576
|
+
// boundaries without scanning or encoding a source prefix for every body.
|
|
1577
|
+
if (!coordinates.ascii) {
|
|
1578
|
+
let runAt = 0;
|
|
1579
|
+
const byteOffset = (offset: number): number => {
|
|
1580
|
+
while ((coordinates.runs[runAt]?.utf16Start ?? Infinity) < offset)
|
|
1581
|
+
runAt++;
|
|
1582
|
+
return coordinates.runs[runAt]?.byteStart ?? coordinates.analyzed.length;
|
|
1583
|
+
};
|
|
1584
|
+
for (const block of blocks)
|
|
1585
|
+
block.span = {
|
|
1586
|
+
start: byteOffset(block.span.start),
|
|
1587
|
+
end: byteOffset(block.span.end),
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1431
1590
|
const structure: ExplainStructure = {
|
|
1432
1591
|
statementCount: statements.length,
|
|
1433
1592
|
statements,
|
|
1434
|
-
blocks
|
|
1435
|
-
name: b.name,
|
|
1436
|
-
span: { start: b.start, end: b.start + b.body.length },
|
|
1437
|
-
ev: EV.blocks,
|
|
1438
|
-
})),
|
|
1593
|
+
blocks,
|
|
1439
1594
|
containsWrite: renderWriteVerdict(write.verdict),
|
|
1440
1595
|
subcommands: brackets.resolutions.map(subcommandOf),
|
|
1441
1596
|
// `containsWrite` is the fact `structure` itself asserts; the statements
|
|
@@ -1604,12 +1759,83 @@ export function explainCommand(
|
|
|
1604
1759
|
};
|
|
1605
1760
|
}
|
|
1606
1761
|
|
|
1762
|
+
/** The code the execute gate refuses although it is a warning (#504). */
|
|
1763
|
+
export const DISCARDED_CLOSE_CODE = "explain/canonicalizer/discarded-close";
|
|
1764
|
+
|
|
1765
|
+
/**
|
|
1766
|
+
* The reading of a script RouterOS stops reading early (#504).
|
|
1767
|
+
*
|
|
1768
|
+
* A `}` or `]` that closes nothing at the top level ends the program on the
|
|
1769
|
+
* device: it accepts the script and silently discards that byte and everything
|
|
1770
|
+
* after it (`segment.ts` → `discardFrom`). So `read` is the analysis of the
|
|
1771
|
+
* prefix the device runs. What is added back is the whole input's coordinate
|
|
1772
|
+
* facts, the tail's one `unclassified` token, and a `warning` that spans the
|
|
1773
|
+
* discarded bytes. It is a warning because nothing was rejected; it is refused
|
|
1774
|
+
* by the execute gate all the same, because running a script that is not the
|
|
1775
|
+
* one the operator wrote is what validation exists to stop.
|
|
1776
|
+
*/
|
|
1777
|
+
function withDiscardedTail(
|
|
1778
|
+
read: ExplainData,
|
|
1779
|
+
coordinates: CoordinateAnalysis,
|
|
1780
|
+
from: number,
|
|
1781
|
+
): ExplainData {
|
|
1782
|
+
const bytes = coordinates.analyzed.length;
|
|
1783
|
+
const close = String.fromCharCode(coordinates.analyzed[from] as number);
|
|
1784
|
+
const diagnostics = [
|
|
1785
|
+
...read.diagnostics,
|
|
1786
|
+
{
|
|
1787
|
+
code: DISCARDED_CLOSE_CODE,
|
|
1788
|
+
severity: "warning" as const,
|
|
1789
|
+
message: `stray \`${close}\` closes nothing: RouterOS accepts the script but stops reading here, silently discarding the \`${close}\` and everything after it — remove it, or add the opener it was meant to close`,
|
|
1790
|
+
span: { start: from, end: bytes },
|
|
1791
|
+
ev: EV.segment,
|
|
1792
|
+
},
|
|
1793
|
+
];
|
|
1794
|
+
let tokens = read.tokens;
|
|
1795
|
+
if (tokens !== undefined) {
|
|
1796
|
+
const last = tokens.at(-1);
|
|
1797
|
+
tokens =
|
|
1798
|
+
last?.class === "unclassified" && last.end === from
|
|
1799
|
+
? [...tokens.slice(0, -1), { ...last, end: bytes }]
|
|
1800
|
+
: [
|
|
1801
|
+
...tokens,
|
|
1802
|
+
{
|
|
1803
|
+
start: from,
|
|
1804
|
+
end: bytes,
|
|
1805
|
+
class: "unclassified",
|
|
1806
|
+
ev: EV.coordinates,
|
|
1807
|
+
},
|
|
1808
|
+
];
|
|
1809
|
+
}
|
|
1810
|
+
return {
|
|
1811
|
+
...read,
|
|
1812
|
+
input: {
|
|
1813
|
+
bytes,
|
|
1814
|
+
normalized: !coordinates.ascii,
|
|
1815
|
+
truncated: false,
|
|
1816
|
+
positionMap: positionMap(coordinates),
|
|
1817
|
+
},
|
|
1818
|
+
verdict: verdictOf(diagnostics),
|
|
1819
|
+
...(tokens === undefined ? {} : { tokens }),
|
|
1820
|
+
diagnostics,
|
|
1821
|
+
evidence: citedEvidence(
|
|
1822
|
+
read.structure,
|
|
1823
|
+
diagnostics,
|
|
1824
|
+
read.spans,
|
|
1825
|
+
read.symbols,
|
|
1826
|
+
read.values,
|
|
1827
|
+
tokens,
|
|
1828
|
+
),
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1607
1832
|
/** Attach transport only after the gate-parity guard has had the final word. */
|
|
1608
1833
|
function withStatementTransport(
|
|
1609
1834
|
statement: ExplainStatement,
|
|
1610
1835
|
source: string,
|
|
1611
1836
|
options: ExplainCommandOptions,
|
|
1612
1837
|
rejected?: string,
|
|
1838
|
+
unpaired?: string,
|
|
1613
1839
|
): ExplainStatement {
|
|
1614
1840
|
if (statement.kind !== "command" || statement.arguments === undefined)
|
|
1615
1841
|
return statement;
|
|
@@ -1621,6 +1847,7 @@ function withStatementTransport(
|
|
|
1621
1847
|
arguments: statement.arguments,
|
|
1622
1848
|
source,
|
|
1623
1849
|
...(rejected === undefined ? {} : { rejected }),
|
|
1850
|
+
...(unpaired === undefined ? {} : { unpaired }),
|
|
1624
1851
|
},
|
|
1625
1852
|
{ renderCurl: options.curl, evidenceId: EV.transport },
|
|
1626
1853
|
),
|
|
@@ -1646,9 +1873,13 @@ function statementOf(
|
|
|
1646
1873
|
split: DocumentVerbSplit,
|
|
1647
1874
|
analyzed: string,
|
|
1648
1875
|
analyzedRange: MaskedRange,
|
|
1876
|
+
index: number,
|
|
1877
|
+
parent?: number,
|
|
1649
1878
|
): ExplainStatement {
|
|
1650
1879
|
const reading = readingOf(split);
|
|
1651
1880
|
return {
|
|
1881
|
+
id: `s${index}`,
|
|
1882
|
+
...(parent === undefined ? {} : { parent: `s${parent}` }),
|
|
1652
1883
|
span: { start: split.span.start, end: split.span.end },
|
|
1653
1884
|
...(reading.kind === "command"
|
|
1654
1885
|
? withArguments(reading, split, analyzed, analyzedRange)
|
|
@@ -1851,6 +2082,125 @@ function rejectedBySeparator(runs: readonly MissingSeparator[]): string {
|
|
|
1851
2082
|
return `a second command-shaped run (\`${first?.path} ${first?.verb}\`) sits in this operand stream with no \`;\` or newline before it, so this is not one accepted statement`;
|
|
1852
2083
|
}
|
|
1853
2084
|
|
|
2085
|
+
/**
|
|
2086
|
+
* Withdraw an argument reading that may be the spaced spelling (#350).
|
|
2087
|
+
*
|
|
2088
|
+
* `read: true` with `args: {}` and the operands as positionals is an
|
|
2089
|
+
* affirmative claim that the list was read and names no argument, and the
|
|
2090
|
+
* device contradicts it: `comment "something" regexp 1.1` is
|
|
2091
|
+
* `comment=something;regexp=1.1`. Which adjacent words pair is per-command
|
|
2092
|
+
* schema, so neither reading is published. The statement stays `resolved`;
|
|
2093
|
+
* only the argument list is withdrawn, as #311 does.
|
|
2094
|
+
*/
|
|
2095
|
+
function withoutSpacedArguments(
|
|
2096
|
+
statement: ExplainStatement,
|
|
2097
|
+
pairs: readonly SpacedPair[],
|
|
2098
|
+
): ExplainStatement {
|
|
2099
|
+
if (pairs.length === 0 || statement.kind !== "command") return statement;
|
|
2100
|
+
if (statement.arguments?.read !== true) return statement;
|
|
2101
|
+
const { args: _dropped, ...command } = statement.command;
|
|
2102
|
+
return {
|
|
2103
|
+
...statement,
|
|
2104
|
+
command,
|
|
2105
|
+
arguments: { read: false, why: unpairedBySpacing(pairs) },
|
|
2106
|
+
};
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
/** The one sentence both the withdrawn argument list and transport give. */
|
|
2110
|
+
function unpairedBySpacing(pairs: readonly SpacedPair[]): string {
|
|
2111
|
+
const first = pairs[0];
|
|
2112
|
+
return `\`${first?.name} ${first?.value}\` may be an argument and its value with the \`=\` left out, which RouterOS accepts; offline has no per-command argument list to tell an argument name from a positional operand or a value-less flag`;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
/**
|
|
2116
|
+
* The spaced spelling, said out loud where the value proves it is not a flag.
|
|
2117
|
+
*
|
|
2118
|
+
* `warning`, not `error`: RouterOS accepts the form, but its own highlighter
|
|
2119
|
+
* marks the gap `syntax-obsolete`, which is RouterOS calling the spelling
|
|
2120
|
+
* deprecated. "Possible" because the name is unconfirmed offline;
|
|
2121
|
+
* `/system/script add zzznotanarg "beep"` is the same shape and the device
|
|
2122
|
+
* rejects it. A live `request=child` probe (#491) confirms the name.
|
|
2123
|
+
*/
|
|
2124
|
+
function spacedArgumentDiagnostic(pair: SpacedPair): ExplainDiagnostic {
|
|
2125
|
+
return {
|
|
2126
|
+
code: "explain/canonicalizer/possible-spaced-argument",
|
|
2127
|
+
severity: "warning",
|
|
2128
|
+
message: `possible obsolete spelling: \`${pair.name} ${pair.value}\` reads as \`${pair.name}=${pair.value}\` if \`${pair.name}\` is an argument of this command — RouterOS accepts a space for the \`=\` but marks it obsolete; write \`${pair.name}=${pair.value}\``,
|
|
2129
|
+
span: { start: pair.span.start, end: pair.span.end },
|
|
2130
|
+
ev: EV.spaced,
|
|
2131
|
+
};
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
const INVALID_COMMAND_BRACE =
|
|
2135
|
+
"brace arrays are not valid in command arguments — use a parenthesized comma list such as `(1,2)`, or a bare comma list where the argument schema accepts one";
|
|
2136
|
+
|
|
2137
|
+
/**
|
|
2138
|
+
* The group faults as diagnostics (#505, #509, #511, #512).
|
|
2139
|
+
*
|
|
2140
|
+
* All `error`: each is a `:parse` rejection on CHR 7.24.5 and 7.23.8. An
|
|
2141
|
+
* empty `{}` that `invalidCommandBraceDiagnostics` already rejects as a brace
|
|
2142
|
+
* array in a command argument is one defect, so it is reported once, as that.
|
|
2143
|
+
* The span is the token the fault is about (the operator, the separator, the
|
|
2144
|
+
* `@`, or the whole empty group); the device's column is the byte after an
|
|
2145
|
+
* empty group or a dangling operator.
|
|
2146
|
+
*/
|
|
2147
|
+
function groupFaultDiagnostics(
|
|
2148
|
+
faults: readonly GroupFault[],
|
|
2149
|
+
braces: readonly ExplainDiagnostic[],
|
|
2150
|
+
): ExplainDiagnostic[] {
|
|
2151
|
+
const braceAt = new Set(braces.map((d) => d.span.start));
|
|
2152
|
+
return faults
|
|
2153
|
+
.filter(
|
|
2154
|
+
(f) =>
|
|
2155
|
+
!(
|
|
2156
|
+
f.code === "empty-group" &&
|
|
2157
|
+
f.detail === "{" &&
|
|
2158
|
+
braceAt.has(f.start)
|
|
2159
|
+
) &&
|
|
2160
|
+
!(
|
|
2161
|
+
(f.code === "named-value-brace" || f.code === "glued-token") &&
|
|
2162
|
+
braceAt.has(f.start)
|
|
2163
|
+
),
|
|
2164
|
+
)
|
|
2165
|
+
.map((f) => ({
|
|
2166
|
+
code:
|
|
2167
|
+
f.code === "named-value-brace"
|
|
2168
|
+
? "explain/canonicalizer/invalid-command-brace"
|
|
2169
|
+
: `explain/canonicalizer/${f.code}`,
|
|
2170
|
+
severity: "error",
|
|
2171
|
+
message: groupFaultMessage(f),
|
|
2172
|
+
span: { start: f.start, end: f.end },
|
|
2173
|
+
ev: f.code === "named-value-brace" ? EV.values : EV.groups,
|
|
2174
|
+
}));
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
function groupFaultMessage(f: GroupFault): string {
|
|
2178
|
+
switch (f.code) {
|
|
2179
|
+
case "empty-group":
|
|
2180
|
+
return f.detail === "("
|
|
2181
|
+
? "empty expression: `()` has nothing to evaluate, and RouterOS rejects it (`syntax error`)"
|
|
2182
|
+
: "empty array: RouterOS rejects a `{}` that is the whole value of a statement or an argument (`syntax error`); a nested `{}` (`{1;{}}`) or one in parentheses (`({})`) parses";
|
|
2183
|
+
case "misplaced-separator":
|
|
2184
|
+
if (f.group === "{")
|
|
2185
|
+
return "`;` with no array element before it on its line: RouterOS rejects it (`syntax error`) — a `;` must follow an element, and a newline already separates elements";
|
|
2186
|
+
return f.detail === ";"
|
|
2187
|
+
? "`;` inside `( … )`: an expression cannot hold a statement separator, and RouterOS rejects it (`syntax error`)"
|
|
2188
|
+
: "a newline inside `( … )` ends the statement in the middle of the expression, and RouterOS rejects it (`syntax error`) — join the lines, or end the line with `\\` to continue it";
|
|
2189
|
+
case "missing-operand":
|
|
2190
|
+
return `operator \`${f.detail}\` is missing an operand, and RouterOS rejects it (\`syntax error\`) — only \`-\`, \`!\`, \`>\`, \`~\` and \`any\` take a single operand after them; every other operator needs one on each side`;
|
|
2191
|
+
case "invalid-at-sign":
|
|
2192
|
+
return "unquoted `@` is a syntax error in a command word, an array or an expression — RouterOS accepts it only inside an argument value (`comment=a@b`); quote it, and drop a pasted console prompt such as `[admin@MikroTik] >`";
|
|
2193
|
+
case "named-value-brace":
|
|
2194
|
+
return INVALID_COMMAND_BRACE;
|
|
2195
|
+
case "glued-token":
|
|
2196
|
+
return 'nothing may follow a closed string, `$name` or group in the same token, and a bare word may not run into a `"`, `$` or bracket: RouterOS rejects it (`expected end of command`) — put a space between the two arguments, or join them inside an expression such as `("a" . $v)`';
|
|
2197
|
+
case "invalid-variable":
|
|
2198
|
+
return '`$` must be followed by a variable name (letters and digits) or `"name"`, and RouterOS rejects it here (`syntax error`) — quote a literal dollar (`"\\$"`)';
|
|
2199
|
+
case "invalid-name":
|
|
2200
|
+
return 'a variable name is letters and digits only, and RouterOS rejects any other byte in it (`expected end of command`) — quote the name (`:local "a_b"`) to use other characters';
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
|
|
1854
2204
|
function invalidCommandBraceDiagnostics(
|
|
1855
2205
|
splits: readonly DocumentVerbSplit[],
|
|
1856
2206
|
analyzed: string,
|
|
@@ -1868,10 +2218,19 @@ function invalidCommandBraceDiagnostics(
|
|
|
1868
2218
|
// Require evidence that the brace actually opens a menu container
|
|
1869
2219
|
// (body starts with a submenu name), so `/ip/route/print {1;2}` and
|
|
1870
2220
|
// `/ip { firewall/filter/print {1;2} }` still diagnose the `{1;2}` array.
|
|
2221
|
+
//
|
|
2222
|
+
// A verb the vocabulary or the catalog names is no menu, so its leading
|
|
2223
|
+
// brace is a positional operand: `/system/script add { :beep }` and
|
|
2224
|
+
// `/system/reboot { :beep }` are rejected on CHR 7.24.5 (#496).
|
|
1871
2225
|
const argsText = text.slice(split.argsAt);
|
|
1872
2226
|
const leadingBraceAt = argsText.search(/\S/);
|
|
2227
|
+
const verbIsCommand =
|
|
2228
|
+
VERBS.has(split.verb) ||
|
|
2229
|
+
lookupPath([...split.path.split("/").filter(Boolean), split.verb])
|
|
2230
|
+
?.kind === "command";
|
|
1873
2231
|
let menuBraceAt: number | null = null;
|
|
1874
2232
|
if (
|
|
2233
|
+
!verbIsCommand &&
|
|
1875
2234
|
leadingBraceAt >= 0 &&
|
|
1876
2235
|
argsText[leadingBraceAt] === "{" &&
|
|
1877
2236
|
split.candidates.some((candidate) =>
|
|
@@ -1887,7 +2246,8 @@ function invalidCommandBraceDiagnostics(
|
|
|
1887
2246
|
// A menu block body is a statement list, and a statement may be a
|
|
1888
2247
|
// scripting directive or an ABSOLUTE command — neither of which the
|
|
1889
2248
|
// enclosing menu can vouch for, and neither of which any array literal
|
|
1890
|
-
// can start with. `/interface { ethernet { :put x } }` is
|
|
2249
|
+
// can start with. `/interface { ethernet { :put x } }` is the shape of
|
|
2250
|
+
// corpus script `forum/amm0/topic-184067-routeros-lsp-for-better-syntax-checking-command-completion-in-editors-like-vscod/post-0014-snippet-02.rsc`,
|
|
1891
2251
|
// accepted on 7.21.5/7.23.5/7.24.2/7.25beta3 as
|
|
1892
2252
|
// `(evl /interface/ethernet/ (evl /putmessage=…))` — #348.
|
|
1893
2253
|
menuBraceAt = braceAt;
|
|
@@ -1934,8 +2294,7 @@ function invalidCommandBraceDiagnostics(
|
|
|
1934
2294
|
out.push({
|
|
1935
2295
|
code: "explain/canonicalizer/invalid-command-brace",
|
|
1936
2296
|
severity: "error",
|
|
1937
|
-
message:
|
|
1938
|
-
"brace arrays are not valid in command arguments — use a parenthesized comma list such as `(1,2)`, or a bare comma list where the argument schema accepts one",
|
|
2297
|
+
message: INVALID_COMMAND_BRACE,
|
|
1939
2298
|
span: { start: start + at, end: start + at + 1 },
|
|
1940
2299
|
ev: EV.values,
|
|
1941
2300
|
});
|
|
@@ -2107,6 +2466,7 @@ function subcommandOf(r: Resolution): ExplainSubcommand {
|
|
|
2107
2466
|
),
|
|
2108
2467
|
);
|
|
2109
2468
|
return {
|
|
2469
|
+
owner: `s${r.owner}`,
|
|
2110
2470
|
span: { start: r.span.start, end: r.span.end },
|
|
2111
2471
|
context: r.context,
|
|
2112
2472
|
...reading,
|
package/src/offline-gate.ts
CHANGED
|
@@ -25,6 +25,15 @@
|
|
|
25
25
|
* module never reports success on its behalf. `verdict: "warn"` is an
|
|
26
26
|
* ABSTENTION, not a rejection, and passes through for the same reason.
|
|
27
27
|
*
|
|
28
|
+
* **One warning is refused by code: {@link REFUSED_WARNING_CODES}.** It is not
|
|
29
|
+
* an abstention but a known device behaviour. A `}` or `]` that closes nothing
|
|
30
|
+
* at the top level makes RouterOS accept the script and silently stop reading
|
|
31
|
+
* there, so `/system identity set name=a}` sets `a` and `:put 1};:put 2` runs
|
|
32
|
+
* one command (#504, CHR 7.24.5 and 7.23.8). `explain` reports it as a warning
|
|
33
|
+
* because nothing is rejected; the gate refuses it because the script the
|
|
34
|
+
* device would run is not the one that was written. The list is explicit and
|
|
35
|
+
* stays small: a code joins it only for the same reason.
|
|
36
|
+
*
|
|
28
37
|
* `--validate=false` disables BOTH stages — one flag, one meaning. That was
|
|
29
38
|
* settled with the `--raw` precedence layer on GH#154 and is why callers gate
|
|
30
39
|
* this on the same resolved `validate` setting as the device stage; the escape
|
|
@@ -39,11 +48,20 @@
|
|
|
39
48
|
*/
|
|
40
49
|
|
|
41
50
|
import { CentrsError } from "./errors.ts";
|
|
42
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
DISCARDED_CLOSE_CODE,
|
|
53
|
+
type ExplainDiagnostic,
|
|
54
|
+
explainCommand,
|
|
55
|
+
} from "./explain.ts";
|
|
43
56
|
|
|
44
57
|
/** Validator identity for `meta.validation` and error context. */
|
|
45
58
|
export const OFFLINE_GATE_SOURCE = "offline explain (canonicalizer)";
|
|
46
59
|
|
|
60
|
+
/** Warning codes the gate refuses as it refuses an error (module header). */
|
|
61
|
+
export const REFUSED_WARNING_CODES: ReadonlySet<string> = new Set([
|
|
62
|
+
DISCARDED_CLOSE_CODE,
|
|
63
|
+
]);
|
|
64
|
+
|
|
47
65
|
/** Which stage of the two-stage gate produced a verdict. */
|
|
48
66
|
export type ValidationStageName = "offline" | "device";
|
|
49
67
|
|
|
@@ -61,8 +79,8 @@ export interface OfflineGateFinding {
|
|
|
61
79
|
export interface OfflineGateResult {
|
|
62
80
|
/**
|
|
63
81
|
* `pass` — the analyzer read the whole input. `warn` — it ABSTAINED on part
|
|
64
|
-
* of it (an unresolved menu, say). Both proceed to the device stage;
|
|
65
|
-
* `fail
|
|
82
|
+
* of it (an unresolved menu, say). Both proceed to the device stage; a
|
|
83
|
+
* `fail`, or a warning in {@link REFUSED_WARNING_CODES}, throws.
|
|
66
84
|
*/
|
|
67
85
|
verdict: "pass" | "warn";
|
|
68
86
|
/** Warning-severity diagnostics, kept so a caller can surface the abstention. */
|
|
@@ -95,25 +113,28 @@ export function assertOfflineSyntax(
|
|
|
95
113
|
): OfflineGateResult {
|
|
96
114
|
const analysis = explainCommand(command);
|
|
97
115
|
const errors = analysis.diagnostics.filter(
|
|
98
|
-
(diagnostic) =>
|
|
116
|
+
(diagnostic) =>
|
|
117
|
+
diagnostic.severity === "error" ||
|
|
118
|
+
REFUSED_WARNING_CODES.has(diagnostic.code),
|
|
99
119
|
);
|
|
100
120
|
const warnings = analysis.diagnostics
|
|
101
121
|
.filter((diagnostic) => diagnostic.severity === "warning")
|
|
102
122
|
.map(findingOf);
|
|
103
123
|
|
|
104
|
-
if (
|
|
124
|
+
if (errors.length === 0) {
|
|
105
125
|
return { verdict: analysis.verdict === "warn" ? "warn" : "pass", warnings };
|
|
106
126
|
}
|
|
107
127
|
|
|
108
|
-
// A `fail` verdict always carries at least one error diagnostic, but read it
|
|
109
|
-
// defensively: an empty list would otherwise produce an error that says
|
|
110
|
-
// nothing about what was wrong, which is worse than a generic summary.
|
|
111
128
|
const findings = errors.map(findingOf);
|
|
112
129
|
const first = findings[0];
|
|
113
130
|
throw new CentrsError({
|
|
114
131
|
code: "validation/syntax",
|
|
115
132
|
summary: first
|
|
116
|
-
?
|
|
133
|
+
? `${
|
|
134
|
+
REFUSED_WARNING_CODES.has(first.code)
|
|
135
|
+
? "Refused by offline analysis"
|
|
136
|
+
: "RouterOS syntax rejected by offline analysis"
|
|
137
|
+
}: ${first.message}`
|
|
117
138
|
: "RouterOS syntax rejected by offline analysis.",
|
|
118
139
|
remediation:
|
|
119
140
|
"Run `centrs explain '<command>'` to see the offending bytes in context, " +
|