@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/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 & ExplainReading;
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. A consumer
428
- * that wants only the top level filters on containment; one that wants "every
429
- * command in this script" — the common case, and the reason to flatten — reads
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(split, analyzed, analyzedRange),
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
- withoutSeparatedArguments(statement, runs),
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
- ...invalidCommandBraceDiagnostics(verbs.splits, analyzed, analyzedRange),
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: scopeBlocks(input).map((b) => ({
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 corpus 521,
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,
@@ -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 { type ExplainDiagnostic, explainCommand } from "./explain.ts";
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; only
65
- * `fail` throws.
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) => diagnostic.severity === "error",
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 (analysis.verdict !== "fail") {
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
- ? `RouterOS syntax rejected by offline analysis: ${first.message}`
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, " +