@tikoci/centrs 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/GLOSSARY.txt +252 -0
  3. package/LICENSE +21 -0
  4. package/README.md +44 -12
  5. package/commands/AGENTS.md +14 -5
  6. package/commands/api/AGENTS.md +13 -1
  7. package/commands/api/README.md +56 -26
  8. package/commands/api/examples.md +46 -1
  9. package/commands/btest/README.md +21 -32
  10. package/commands/check/README.md +6 -1
  11. package/commands/discover/README.md +5 -11
  12. package/commands/execute/README.md +77 -36
  13. package/commands/execute/examples.md +120 -10
  14. package/commands/explain/README.md +2756 -0
  15. package/commands/explain/examples.md +702 -0
  16. package/commands/mcp/README.md +7 -4
  17. package/commands/mcp/examples.md +2 -2
  18. package/commands/retrieve/README.md +25 -27
  19. package/commands/retrieve/examples.md +16 -0
  20. package/commands/terminal/examples.md +7 -1
  21. package/commands/transfer/README.md +28 -20
  22. package/commands/transfer/examples.md +29 -0
  23. package/docs/CLI.md +353 -0
  24. package/docs/CONSTITUTION.md +138 -30
  25. package/docs/MATRIX.md +92 -18
  26. package/docs/errors/README.md +3 -0
  27. package/docs/errors/quickchr/machine-not-found.md +22 -0
  28. package/docs/errors/quickchr/machine-stopped.md +20 -0
  29. package/docs/errors/quickchr/package-unavailable.md +27 -0
  30. package/docs/errors/quickchr/unsupported-via.md +35 -0
  31. package/docs/errors/quickchr/unsupported.md +35 -0
  32. package/docs/errors/routeros/version-unsupported.md +26 -0
  33. package/docs/errors/transport/provider-trust.md +29 -0
  34. package/docs/errors/usage/stdin-ignored.md +28 -0
  35. package/docs/errors/validation/syntax.md +29 -4
  36. package/docs/errors/validation/unknown-attribute.md +37 -4
  37. package/package.json +56 -10
  38. package/src/api-fanout.ts +46 -18
  39. package/src/api.ts +351 -121
  40. package/src/cli/api.ts +26 -7
  41. package/src/cli/btest.ts +19 -3
  42. package/src/cli/execute.ts +25 -6
  43. package/src/cli/explain.ts +503 -0
  44. package/src/cli/retrieve.ts +33 -1
  45. package/src/cli/selection.ts +71 -11
  46. package/src/cli/settings.ts +2 -2
  47. package/src/cli/terminal.ts +1 -1
  48. package/src/cli/transfer.ts +23 -6
  49. package/src/cli.ts +15 -2
  50. package/src/core/envelope.ts +58 -2
  51. package/src/core/error-catalog.ts +42 -1
  52. package/src/core/execute-command.ts +148 -0
  53. package/src/core/inspect.ts +36 -4
  54. package/src/core/preflight.ts +306 -0
  55. package/src/core/routeros-string.ts +24 -0
  56. package/src/core/version.ts +4 -0
  57. package/src/core/yaml.ts +86 -0
  58. package/src/errors.ts +1 -0
  59. package/src/execute-fanout.ts +47 -18
  60. package/src/execute.ts +339 -307
  61. package/src/explain/arg-tokens.ts +151 -0
  62. package/src/explain/args.ts +1749 -0
  63. package/src/explain/blocks.ts +175 -0
  64. package/src/explain/brace-slots.ts +143 -0
  65. package/src/explain/brace-tokens.ts +39 -0
  66. package/src/explain/catalog.ts +1343 -0
  67. package/src/explain/coordinates.ts +488 -0
  68. package/src/explain/defects.ts +218 -0
  69. package/src/explain/escape-tokens.ts +77 -0
  70. package/src/explain/highlight-projection.ts +308 -0
  71. package/src/explain/is-known-menu.ts +17 -0
  72. package/src/explain/menus.ts +668 -0
  73. package/src/explain/operator-tokens.ts +316 -0
  74. package/src/explain/operators.ts +465 -0
  75. package/src/explain/path-tokens.ts +150 -0
  76. package/src/explain/pathresolve.ts +1569 -0
  77. package/src/explain/quoted-string.ts +331 -0
  78. package/src/explain/scope-brace.ts +461 -0
  79. package/src/explain/segment.ts +1165 -0
  80. package/src/explain/separator.ts +187 -0
  81. package/src/explain/split-owner.ts +194 -0
  82. package/src/explain/string-tokens.ts +77 -0
  83. package/src/explain/symbol-values.ts +556 -0
  84. package/src/explain/symbols.ts +1617 -0
  85. package/src/explain/token-ranges.ts +72 -0
  86. package/src/explain/transport.ts +514 -0
  87. package/src/explain/value-tokens.ts +83 -0
  88. package/src/explain/values.ts +357 -0
  89. package/src/explain/verbs.ts +64 -0
  90. package/src/explain/verbsplit.ts +955 -0
  91. package/src/explain/write.ts +856 -0
  92. package/src/explain.ts +2602 -0
  93. package/src/index.ts +225 -6
  94. package/src/mcp/server.ts +2 -1
  95. package/src/offline-gate.ts +148 -0
  96. package/src/protocols/mac-telnet-console.ts +61 -17
  97. package/src/resolver/facts.ts +1 -1
  98. package/src/resolver/index.ts +23 -4
  99. package/src/resolver/quickchr-provider.ts +597 -0
  100. package/src/resolver/selection.ts +99 -12
  101. package/src/resolver/service-endpoint.ts +155 -0
  102. package/src/resolver/target.ts +1 -1
  103. package/src/retrieve-fanout.ts +27 -13
  104. package/src/retrieve.ts +82 -77
  105. package/src/settings.ts +2 -1
  106. package/src/transfer-fanout.ts +43 -26
  107. package/src/transfer.ts +88 -45
package/src/index.ts CHANGED
@@ -1,5 +1,3 @@
1
- export const centrsVersion = "0.1.0";
2
-
3
1
  export {
4
2
  type ApiEnvelope,
5
3
  type ApiErrorEnvelope,
@@ -81,6 +79,7 @@ export {
81
79
  type EnvelopeMeta,
82
80
  type EnvelopeTargetMeta,
83
81
  type EnvelopeValidationMeta,
82
+ type EnvelopeValidationStage,
84
83
  type FanoutData,
85
84
  type FanoutSummary,
86
85
  normalizeWarnings,
@@ -110,6 +109,7 @@ export {
110
109
  type InspectChildItem,
111
110
  type InspectCompletionItem,
112
111
  type InspectRequestKind,
112
+ inspectArgumentNames,
113
113
  inspectChildren,
114
114
  inspectChildrenOrEmpty,
115
115
  inspectCompletions,
@@ -118,6 +118,25 @@ export {
118
118
  isCommandNode,
119
119
  pathTokens,
120
120
  } from "./core/inspect.ts";
121
+ export {
122
+ assertCapability,
123
+ type CapabilityRequirement,
124
+ checkCapability,
125
+ compareRouterOsVersion,
126
+ type DeviceVersion,
127
+ type DeviceVersionSource,
128
+ type PreflightState,
129
+ type PreflightVerdict,
130
+ parseRouterOsVersion,
131
+ probeDeviceVersion,
132
+ type RouterOsVersionParts,
133
+ type RouterOsVersionRange,
134
+ renderSupportedRanges,
135
+ routerOsVersionAtLeast,
136
+ versionFactFromComment,
137
+ versionInRange,
138
+ } from "./core/preflight.ts";
139
+ export { centrsVersion } from "./core/version.ts";
121
140
  export {
122
141
  encodeMndpPacket,
123
142
  MNDP_BROADCAST_ADDRESS,
@@ -276,6 +295,205 @@ export {
276
295
  executeFanout,
277
296
  renderExecuteFanoutEnvelope,
278
297
  } from "./execute-fanout.ts";
298
+ // The lexer's own names are module-scoped and read naturally there; at the
299
+ // package root they need the `Explain` prefix every other explain export
300
+ // carries — a bare `Argument` says nothing about which of centrs's surfaces it
301
+ // belongs to. Aliasing at the re-export is the existing pattern here (see
302
+ // `occurrences as explainOccurrences` below).
303
+ //
304
+ // `ExplainArgument` and `ExplainArgumentToken` are the same SHAPE in two
305
+ // coordinate spaces: this one is what `lexArguments` returns, with spans
306
+ // relative to the statement it was handed; the `Token` is that rebased into
307
+ // document space by `explainCommand`. A consumer reading `explainCommand`'s
308
+ // result wants the latter.
309
+ //
310
+ // `lexExplainArgumentTokens` is the skip-tolerant reading (#316) and is
311
+ // exported for the same reason `lexExplainValueAnchors` is: a consumer building
312
+ // its own token-level rule needs the reading a rule wants, not the one a
313
+ // renderer wants. Its `undecided` tokens carry no `value` and never become one.
314
+ export {
315
+ type Argument as ExplainArgument,
316
+ type ArgumentKind as ExplainArgumentKind,
317
+ type ArgumentReading as ExplainArgumentReading,
318
+ type ArgumentsRead as ExplainArgumentsRead,
319
+ type ArgumentsUnread as ExplainArgumentsUnread,
320
+ type ArgumentTokenReading as ExplainArgumentTokenReading,
321
+ lexArguments as lexExplainArguments,
322
+ lexArgumentTokens as lexExplainArgumentTokens,
323
+ lexValueAnchors as lexExplainValueAnchors,
324
+ type ValueAnchor as ExplainValueAnchor,
325
+ type ValueAnchorKind as ExplainValueAnchorKind,
326
+ type ValueAnchorReading as ExplainValueAnchorReading,
327
+ } from "./explain/args.ts";
328
+ export {
329
+ DIRECTIVE_BODY,
330
+ HEAD_SCOPED_ARG_NAMES,
331
+ isScopeBrace,
332
+ matchBrace,
333
+ SCOPE_ARG_NAMES,
334
+ type ScopeBlock,
335
+ scopeBlocks,
336
+ scopeBodies,
337
+ scopeNameAt,
338
+ } from "./explain/blocks.ts";
339
+ export {
340
+ analyzeCoordinates,
341
+ byteSpanToRange,
342
+ byteToPosition,
343
+ type CharRun,
344
+ type CoordinateAnalysis,
345
+ coordinateDefects,
346
+ type Position,
347
+ positionToByte,
348
+ type Range,
349
+ runAtByte,
350
+ SUB,
351
+ } from "./explain/coordinates.ts";
352
+ export {
353
+ type Defect,
354
+ type DefectCode,
355
+ defectAt,
356
+ hasStructuralDefect,
357
+ isPositionalFact,
358
+ mergeDefects,
359
+ rebaseDefects,
360
+ } from "./explain/defects.ts";
361
+ export {
362
+ type DocumentAnalysis,
363
+ type Resolution,
364
+ resolveDocument,
365
+ resolveStatements,
366
+ type Span,
367
+ type StatementAnalysis,
368
+ type StatementResolution,
369
+ } from "./explain/pathresolve.ts";
370
+ export {
371
+ maskComments,
372
+ type QuotedStringScan,
373
+ type Segment,
374
+ type SegmentResult,
375
+ scanQuotedString,
376
+ segmentStatements,
377
+ } from "./explain/segment.ts";
378
+ export {
379
+ HIGHLIGHT_CLASS,
380
+ resolveSymbols,
381
+ type SymbolAnalysis,
382
+ type SymbolClass,
383
+ type SymbolOccurrence,
384
+ type SymbolRole,
385
+ } from "./explain/symbols.ts";
386
+ export type {
387
+ ExplainApiCandidateTransport,
388
+ ExplainExecuteTransport,
389
+ ExplainRestMethod,
390
+ ExplainRestRequest,
391
+ ExplainTransport,
392
+ ExplainTransportClassification,
393
+ ExplainUnknownTransport,
394
+ } from "./explain/transport.ts";
395
+ export {
396
+ VALUE_SHAPES,
397
+ type ValueContext as ExplainValueContext,
398
+ type ValueShape as ExplainValueShape,
399
+ type ValueShapeOptions as ExplainValueShapeOptions,
400
+ valueShapeHints as explainValueShapeHints,
401
+ } from "./explain/values.ts";
402
+ export {
403
+ type DocumentVerbSplit,
404
+ describeStatement,
405
+ isDirective,
406
+ type RunToken,
407
+ resolveVerb,
408
+ resolveVerbs,
409
+ runTokens,
410
+ type Sep,
411
+ type Split,
412
+ SUBMENU_DIRECTIVES,
413
+ splitRun,
414
+ VERBS,
415
+ type VerbAnalysis,
416
+ type VerbResolution,
417
+ type VerbSplit,
418
+ type VerbSplitCommandReading,
419
+ type VerbSplitMenuReading,
420
+ type VerbSplitRefusal,
421
+ } from "./explain/verbsplit.ts";
422
+ export {
423
+ classifyVerb,
424
+ containsWrite,
425
+ type Occurrence,
426
+ type OccurrenceClass,
427
+ // `occurrences` is too generic for the library root; the module-local name
428
+ // stays short because inside `write.ts` there is only one kind.
429
+ occurrences as explainOccurrences,
430
+ READ_VERBS,
431
+ ROOT_CMDS,
432
+ ROOT_DYNAMIC,
433
+ ROOT_WRITE,
434
+ WRITE_VERBS,
435
+ type WriteAnalysis,
436
+ type WriteVerdict,
437
+ } from "./explain/write.ts";
438
+ export {
439
+ buildExplainErrorEnvelope,
440
+ buildExplainFacetTip,
441
+ type ExplainArguments,
442
+ type ExplainArgumentToken,
443
+ type ExplainBlock,
444
+ type ExplainCanonical,
445
+ type ExplainCommandOptions,
446
+ type ExplainCommandReading,
447
+ type ExplainData,
448
+ type ExplainDiagnostic,
449
+ type ExplainEnvelope,
450
+ type ExplainEnvelopeOptions,
451
+ type ExplainErrorEnvelope,
452
+ type ExplainEvidence,
453
+ type ExplainFailOn,
454
+ type ExplainInput,
455
+ type ExplainMenuReading,
456
+ type ExplainOperationMeta,
457
+ type ExplainOutputFormat,
458
+ type ExplainPositionMapEntry,
459
+ type ExplainReading,
460
+ type ExplainRefusal,
461
+ type ExplainResolution,
462
+ type ExplainSeverity,
463
+ type ExplainSpan,
464
+ type ExplainSpanClass,
465
+ type ExplainSpanRange,
466
+ type ExplainStatement,
467
+ type ExplainStatementFacts,
468
+ type ExplainStructure,
469
+ type ExplainSubcommand,
470
+ type ExplainSubcommandFacts,
471
+ type ExplainSymbolClass,
472
+ type ExplainSymbolOccurrence,
473
+ type ExplainSymbols,
474
+ type ExplainToken,
475
+ type ExplainTokenClass,
476
+ type ExplainValueFacts,
477
+ type ExplainValueOccurrence,
478
+ type ExplainValueShapeFact,
479
+ type ExplainValues,
480
+ type ExplainValueTypeFact,
481
+ type ExplainVerdict,
482
+ explainCommand,
483
+ explainEnvelope,
484
+ explainExitCode,
485
+ explainFailOnLevels,
486
+ explainOutputFormats,
487
+ renderExplainEnvelope,
488
+ } from "./explain.ts";
489
+ export {
490
+ assertOfflineSyntax,
491
+ isOfflineGateRejection,
492
+ OFFLINE_GATE_SOURCE,
493
+ type OfflineGateFinding,
494
+ type OfflineGateResult,
495
+ type ValidationStageName,
496
+ } from "./offline-gate.ts";
279
497
  export {
280
498
  type ApiVerb,
281
499
  createProtocolAdapter,
@@ -304,9 +522,6 @@ export {
304
522
  type CdbGroupTarget,
305
523
  type CdbResolution,
306
524
  type CdbResolveInput,
307
- type CdbSelectionExpansion,
308
- type CdbSelectionMember,
309
- type CdbSelectionResolveInput,
310
525
  type CommentKvGeoKey,
311
526
  type CommentKvKey,
312
527
  type CommentKvLayer,
@@ -323,7 +538,7 @@ export {
323
538
  defaultPortForScheme,
324
539
  deviceLocation,
325
540
  expandCdbGroup,
326
- expandCdbSelection,
541
+ expandSelection,
327
542
  isDefaultRecordTarget,
328
543
  type NearPredicate,
329
544
  type ParsedGpsTuple,
@@ -351,6 +566,9 @@ export {
351
566
  resolveOptionalIntegerSetting,
352
567
  resolveStringSetting,
353
568
  resolveTarget,
569
+ type SelectionExpansion,
570
+ type SelectionMember,
571
+ type SelectionResolveInput,
354
572
  type SelectionWhereClause,
355
573
  type TargetResolveInput,
356
574
  type TargetSelection,
@@ -470,6 +688,7 @@ export {
470
688
  transferFanout,
471
689
  } from "./transfer-fanout.ts";
472
690
 
691
+ import { centrsVersion } from "./core/version.ts";
473
692
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
474
693
 
475
694
  export const plannedSurfaces = ["api", "cli", "tui", "mcp", "proxy"] as const;
package/src/mcp/server.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
11
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
12
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
13
+ import { centrsVersion } from "../core/version.ts";
13
14
  import { type CentrsMcpConfig, resolveMcpConfig } from "./config.ts";
14
15
  import {
15
16
  buildDevicesResource,
@@ -79,7 +80,7 @@ export function createCentrsMcpServer(
79
80
  config: CentrsMcpConfig = resolveMcpConfig(),
80
81
  ): McpServer {
81
82
  const server = new McpServer(
82
- { name: "centrs-mcp", version: "0.1.0" },
83
+ { name: "centrs-mcp", version: centrsVersion },
83
84
  { instructions: SERVER_INSTRUCTIONS },
84
85
  );
85
86
 
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Stage 1 of the validation gate: the offline analyzer in front of the device
3
+ * preflight (GH#354).
4
+ *
5
+ * centrs used to run two validators that did not know about each other — the
6
+ * device preflight (`:put [:parse …]` plus `/console/inspect request=child`)
7
+ * that `execute` / `api` / `validate` share, and offline `explain`, which is
8
+ * byte-accurate and corpus-gated but took no part in validation at all. This
9
+ * module is the seam that joins them: every CLI-shaped input is analyzed here
10
+ * first, with no connection, and only then handed to the device stage.
11
+ *
12
+ * Three things that buys, in the order they matter:
13
+ *
14
+ * 1. A syntax fault is rejected with a **byte span** and **no round trip** — the
15
+ * protocol adapter is never even constructed.
16
+ * 2. The device stage narrows to the semantic half it is actually needed for
17
+ * (`:parse` is name-level only; `/console/inspect` owns the attribute set).
18
+ * 3. Where the device probe is unsafe or unavailable — the 7.12.2 class of
19
+ * problem in GH#343 — the gate degrades to a real gate instead of to nothing.
20
+ *
21
+ * **A clean offline pass is necessary, never sufficient.** `explain` publishes
22
+ * `runtimeAcceptance: "not-proven"` for a reason: `/console/inspect` accepts
23
+ * forms the runtime rejects, and the analyzer has no per-menu schema (decision 3
24
+ * — no offline schema snapshot). The device stage is not optional and this
25
+ * module never reports success on its behalf. `verdict: "warn"` is an
26
+ * ABSTENTION, not a rejection, and passes through for the same reason.
27
+ *
28
+ * `--validate=false` disables BOTH stages — one flag, one meaning. That was
29
+ * settled with the `--raw` precedence layer on GH#154 and is why callers gate
30
+ * this on the same resolved `validate` setting as the device stage; the escape
31
+ * hatch has to cover a stage-1 defect too (GH#355 is a live example of the
32
+ * analyzer being wrong in the other direction).
33
+ *
34
+ * The offending byte range rides `context.span`, never {@link CentrsError}'s
35
+ * `position`. `RouterOsErrorPosition` is documented as RouterOS's own
36
+ * authoritative 1-based byte column, "carried verbatim; never re-derived"
37
+ * (`errors.ts`) — synthesizing one from offline analysis would make a
38
+ * centrs-derived offset indistinguishable from a device-reported one.
39
+ */
40
+
41
+ import { CentrsError } from "./errors.ts";
42
+ import { type ExplainDiagnostic, explainCommand } from "./explain.ts";
43
+
44
+ /** Validator identity for `meta.validation` and error context. */
45
+ export const OFFLINE_GATE_SOURCE = "offline explain (canonicalizer)";
46
+
47
+ /** Which stage of the two-stage gate produced a verdict. */
48
+ export type ValidationStageName = "offline" | "device";
49
+
50
+ /** One rejected span, flattened out of {@link ExplainDiagnostic} for the error context. */
51
+ export interface OfflineGateFinding {
52
+ /** The `explain/<analyzer>/<slug>` diagnostic code. */
53
+ code: string;
54
+ message: string;
55
+ /** Byte offsets into the analyzed input, end-exclusive. */
56
+ start: number;
57
+ end: number;
58
+ }
59
+
60
+ /** Outcome of a stage-1 run that did not reject. */
61
+ export interface OfflineGateResult {
62
+ /**
63
+ * `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.
66
+ */
67
+ verdict: "pass" | "warn";
68
+ /** Warning-severity diagnostics, kept so a caller can surface the abstention. */
69
+ warnings: readonly OfflineGateFinding[];
70
+ }
71
+
72
+ function findingOf(diagnostic: ExplainDiagnostic): OfflineGateFinding {
73
+ return {
74
+ code: diagnostic.code,
75
+ message: diagnostic.message,
76
+ start: diagnostic.span.start,
77
+ end: diagnostic.span.end,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Run the offline stage over a RouterOS CLI string.
83
+ *
84
+ * Throws `validation/syntax` when the analyzer rejects the input; returns the
85
+ * verdict otherwise. Never opens a connection and never throws for any other
86
+ * reason — `explainCommand` itself is documented as total.
87
+ *
88
+ * `surface` names the caller in the error context (`execute`, `api /execute`) so
89
+ * a reader can tell which command's gate spoke; `via` is the resolved protocol
90
+ * when the caller has one, purely for context.
91
+ */
92
+ export function assertOfflineSyntax(
93
+ command: string,
94
+ options: { surface: string; via?: string },
95
+ ): OfflineGateResult {
96
+ const analysis = explainCommand(command);
97
+ const errors = analysis.diagnostics.filter(
98
+ (diagnostic) => diagnostic.severity === "error",
99
+ );
100
+ const warnings = analysis.diagnostics
101
+ .filter((diagnostic) => diagnostic.severity === "warning")
102
+ .map(findingOf);
103
+
104
+ if (analysis.verdict !== "fail") {
105
+ return { verdict: analysis.verdict === "warn" ? "warn" : "pass", warnings };
106
+ }
107
+
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
+ const findings = errors.map(findingOf);
112
+ const first = findings[0];
113
+ throw new CentrsError({
114
+ code: "validation/syntax",
115
+ summary: first
116
+ ? `RouterOS syntax rejected by offline analysis: ${first.message}`
117
+ : "RouterOS syntax rejected by offline analysis.",
118
+ remediation:
119
+ "Run `centrs explain '<command>'` to see the offending bytes in context, " +
120
+ "fix the syntax, then retry. `--validate=false` bypasses both validation " +
121
+ "stages if you are deliberately probing a RouterOS edge centrs reads wrong.",
122
+ context: {
123
+ command,
124
+ validationStage: "offline" satisfies ValidationStageName,
125
+ validationSource: OFFLINE_GATE_SOURCE,
126
+ surface: options.surface,
127
+ ...(options.via ? { via: options.via } : {}),
128
+ ...(first ? { span: { start: first.start, end: first.end } } : {}),
129
+ diagnostics: findings,
130
+ },
131
+ causeData: first?.code ?? "offline analysis rejected the input",
132
+ });
133
+ }
134
+
135
+ /**
136
+ * True when `error` is the rejection {@link assertOfflineSyntax} throws.
137
+ *
138
+ * Callers use this to build honest envelope metadata: on a stage-1 rejection the
139
+ * device stage did not run, and no connection was opened, so the meta must not
140
+ * claim `:parse` or `/console/inspect` failed.
141
+ */
142
+ export function isOfflineGateRejection(error: unknown): boolean {
143
+ return (
144
+ error instanceof CentrsError &&
145
+ error.code === "validation/syntax" &&
146
+ (error.context?.["validationStage"] as string | undefined) === "offline"
147
+ );
148
+ }
@@ -39,10 +39,11 @@
39
39
  * scraped for: `(evl …)` = ok, `syntax error (line …)` = malformed, and
40
40
  * `bad parameter <name> (line …)` = unknown attribute. A single console
41
41
  * `:parse` therefore covers both the syntax and the unknown-attribute
42
- * (semantic) gate — no `/console/inspect` table parsing needed.
42
+ * (name-level) gate — no `/console/inspect` table parsing needed.
43
43
  */
44
44
 
45
45
  import { parseRouterOsPosition } from "../core/routeros-errors.ts";
46
+ import { routerOsStringLiteral } from "../core/routeros-string.ts";
46
47
  import { CentrsError } from "../errors.ts";
47
48
  import {
48
49
  type MacAddress,
@@ -323,8 +324,11 @@ export class MacTelnetConsole {
323
324
  * Validate a command without running it, using a single console `:parse`.
324
325
  * Throws `validation/syntax` for malformed CLI and
325
326
  * `validation/unknown-attribute` for a rejected parameter; returns the parsed
326
- * form on success. One gate covers both because the console's `:parse`
327
- * reports `bad parameter <name>` as well as `syntax error`.
327
+ * form on success. One gate covers both because the console prints the
328
+ * `:parse` return value, which carries `bad parameter <name>` as well as the
329
+ * `(<%%` / `(line N column M)` syntax diagnostics — see
330
+ * {@link classifyParseResult} for the full grammar and why the matcher is
331
+ * anchored to the diagnostic shape rather than to bare words.
328
332
  */
329
333
  async parseGate(cli: string): Promise<string> {
330
334
  const { output } = await this.run(parseScriptFor(cli));
@@ -604,17 +608,61 @@ export function parseScriptFor(cli: string): string {
604
608
  return `:put [:parse ${routerOsStringLiteral(cli)}]`;
605
609
  }
606
610
 
611
+ /*
612
+ * The `:parse` diagnostic grammar, anchored to where RouterOS actually emits
613
+ * each shape. Anchoring is load-bearing, not cosmetic: an *accepted* return
614
+ * echoes the script body, so `:put "bad parameter x"` comes back as
615
+ * `(evl /putmessage=bad parameter x)`. An unanchored search reads that echoed
616
+ * content as a verdict and refuses to run a valid command (GH#230).
617
+ *
618
+ * Two shapes, both corpus-verified against lsp-routeros-ts
619
+ * `parseil_results.il_text` (2736 rows, RouterOS 7.20.8/7.22.1/7.23rc1):
620
+ *
621
+ * 1. WRAPPED — the diagnostic sits inside the returned value, marked by the
622
+ * `(evl ` / `(<%%` wrapper. It can appear mid-return, because a
623
+ * multi-statement script reports the offending statement in place. Every
624
+ * corpus `bad parameter` (8/8) follows `(evl `, and every `bad command
625
+ * name` (135/135) follows `(<%%`.
626
+ * 2. BARE — the diagnostic *is* the whole return, printed on its own line
627
+ * with RouterOS's position suffix. All 425 corpus rows of this shape are
628
+ * the entire return; none appear embedded. Requiring both line-start and
629
+ * the `(line N column M)` suffix is what separates a real verdict from
630
+ * echoed body text.
631
+ */
632
+ const WRAPPED_BAD_PARAMETER = /\(evl\s+bad parameter\s+(\S+)/i;
633
+ const WRAPPED_BAD_COMMAND = /\(<%%\s*bad command name\b/i;
634
+ const BARE_BAD_PARAMETER =
635
+ /^[ \t]*bad parameter\s+(\S+)[^\n]*\(line \d+ column \d+\)/im;
636
+ const BARE_SYNTAX =
637
+ /^[ \t]*(?:syntax error|bad command name|expected [a-z]+(?: [a-z]+)*)[^\n]*\(line \d+ column \d+\)/im;
638
+
607
639
  /**
608
- * Classify the output of a console `:put [:parse "<cli>"]`. A single console
609
- * `:parse` reports both forms (grounded on CHR 7.23.1): `bad parameter <name>`
610
- * for an unknown attribute and `syntax error` / `bad command name` for malformed
611
- * CLI. Throws the matching `validation/*` error; returns on a clean parse.
640
+ * Classify the printed value of `:put [:parse "<cli>"]`. Grounded on CHR 7.23.3
641
+ * (see GH#230): `:parse` *never* throws — it returns a value whose text carries
642
+ * the verdict. For a bare menu path the accepted form is `(evl /canonical/path)`
643
+ * (abbreviations expanded); for script-shaped input the accepted return echoes
644
+ * the statement body and may contain arbitrary text — including text that looks
645
+ * like a diagnostic. Rejections are therefore matched only in the positions
646
+ * RouterOS emits them (see the grammar note above): wrapped as
647
+ * `(evl bad parameter <name> (line N column M) /path)` /
648
+ * `(<%% bad command name <name> (line N column M) a;b;c)`, or bare and
649
+ * line-initial as `expected … (line N column M)` / `syntax error (line N column
650
+ * M)`. A single `:parse` covers both the syntax and the unknown-attribute
651
+ * (name-level) gate on every transport — REST and native API expose the same text
652
+ * in the `ret` value (HTTP 200 / `as-string`), the console transports print it.
653
+ *
654
+ * Throws the matching `validation/*` error; returns on a clean parse.
612
655
  */
613
- export function classifyParseResult(output: string, cli: string): void {
614
- // The console `:parse` rejection carries RouterOS's authoritative byte offset
656
+ export function classifyParseResult(
657
+ output: string,
658
+ cli: string,
659
+ via: string = "mac-telnet",
660
+ ): void {
661
+ // The `:parse` rejection carries RouterOS's authoritative byte offset
615
662
  // (`… (line N column M)`); surface it as the structured `error.position`.
616
663
  const position = parseRouterOsPosition(output);
617
- const unknown = output.match(/bad parameter\s+(\S+)/i);
664
+ const unknown =
665
+ output.match(WRAPPED_BAD_PARAMETER) ?? output.match(BARE_BAD_PARAMETER);
618
666
  if (unknown) {
619
667
  throw new CentrsError({
620
668
  code: "validation/unknown-attribute",
@@ -624,13 +672,13 @@ export function classifyParseResult(output: string, cli: string): void {
624
672
  context: {
625
673
  command: cli,
626
674
  parameter: cleanToken(unknown[1] ?? ""),
627
- validationSource: ":put [:parse ...] over mac-telnet",
675
+ validationSource: `:put [:parse ...] over ${via}`,
628
676
  detail: output,
629
677
  },
630
678
  ...(position ? { position } : {}),
631
679
  });
632
680
  }
633
- if (/syntax error|bad command name|expected\b/i.test(output)) {
681
+ if (WRAPPED_BAD_COMMAND.test(output) || BARE_SYNTAX.test(output)) {
634
682
  throw new CentrsError({
635
683
  code: "validation/syntax",
636
684
  summary: "RouterOS rejected the command syntax while parsing it.",
@@ -638,7 +686,7 @@ export function classifyParseResult(output: string, cli: string): void {
638
686
  "Fix the RouterOS CLI syntax (quotes, brackets, attribute form), then retry.",
639
687
  context: {
640
688
  command: cli,
641
- validationSource: ":put [:parse ...] over mac-telnet",
689
+ validationSource: `:put [:parse ...] over ${via}`,
642
690
  detail: output,
643
691
  },
644
692
  ...(position ? { position } : {}),
@@ -646,10 +694,6 @@ export function classifyParseResult(output: string, cli: string): void {
646
694
  }
647
695
  }
648
696
 
649
- function routerOsStringLiteral(value: string): string {
650
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
651
- }
652
-
653
697
  function cleanToken(token: string): string {
654
698
  return token.replace(/^["'`]+|["'`.,;:)]+$/g, "");
655
699
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Device-class fact extraction + selector matching, shared by the fan-out
3
- * resolver ({@link ../resolver/selection.ts | expandCdbSelection}) and
3
+ * resolver ({@link ../resolver/selection.ts | expandSelection}) and
4
4
  * `devices list` (`src/devices.ts`) so **both surfaces match `--where` /
5
5
  * `--near` / `--bbox` against identical semantics**.
6
6
  *
@@ -88,14 +88,33 @@ export {
88
88
  unresolvedMacError,
89
89
  } from "./mac.ts";
90
90
  export {
91
- type CdbSelectionExpansion,
92
- type CdbSelectionMember,
93
- type CdbSelectionResolveInput,
94
- expandCdbSelection,
91
+ assertNoQuickchrOverrideConflict,
92
+ type QuickchrConnection,
93
+ type QuickchrResolution,
94
+ quickchrConnection,
95
+ resolveQuickchrTarget,
96
+ SUPPORTED_DESCRIPTOR_VERSION,
97
+ } from "./quickchr-provider.ts";
98
+ export {
99
+ expandSelection,
95
100
  isDefaultRecordTarget,
101
+ type SelectionExpansion,
102
+ type SelectionMember,
103
+ type SelectionResolveInput,
96
104
  type SelectionWhereClause,
97
105
  type TargetSelection,
98
106
  } from "./selection.ts";
107
+ export {
108
+ type AnyServiceEndpoint,
109
+ isEndpointAvailable,
110
+ type ServiceEndpoint,
111
+ type ServiceEndpointAuth,
112
+ type ServiceEndpointMap,
113
+ type ServiceEndpointSource,
114
+ type SshEndpointAuth,
115
+ type SshServiceEndpoint,
116
+ type UnavailableEndpoint,
117
+ } from "./service-endpoint.ts";
99
118
  export {
100
119
  type CommentKvLayer,
101
120
  parseBoolean,