@tikoci/centrs 0.1.4 → 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 (68) hide show
  1. package/CHANGELOG.md +309 -0
  2. package/GLOSSARY.txt +40 -1
  3. package/README.md +8 -4
  4. package/commands/AGENTS.md +7 -4
  5. package/commands/api/AGENTS.md +13 -1
  6. package/commands/api/README.md +37 -8
  7. package/commands/api/examples.md +31 -1
  8. package/commands/execute/README.md +39 -10
  9. package/commands/execute/examples.md +79 -7
  10. package/commands/explain/README.md +1195 -155
  11. package/commands/explain/examples.md +99 -3
  12. package/commands/mcp/README.md +7 -4
  13. package/commands/mcp/examples.md +2 -2
  14. package/commands/terminal/examples.md +7 -1
  15. package/docs/CLI.md +2 -2
  16. package/docs/CONSTITUTION.md +72 -15
  17. package/docs/MATRIX.md +89 -52
  18. package/docs/errors/validation/syntax.md +29 -4
  19. package/package.json +13 -2
  20. package/src/api-fanout.ts +17 -4
  21. package/src/api.ts +254 -67
  22. package/src/cli/api.ts +1 -1
  23. package/src/cli/btest.ts +1 -1
  24. package/src/cli/explain.ts +1 -1
  25. package/src/cli/terminal.ts +1 -1
  26. package/src/core/envelope.ts +53 -0
  27. package/src/core/error-catalog.ts +2 -1
  28. package/src/core/execute-command.ts +148 -0
  29. package/src/core/inspect.ts +36 -4
  30. package/src/core/version.ts +4 -0
  31. package/src/core/yaml.ts +86 -0
  32. package/src/execute-fanout.ts +18 -4
  33. package/src/execute.ts +225 -244
  34. package/src/explain/arg-tokens.ts +62 -25
  35. package/src/explain/args.ts +398 -52
  36. package/src/explain/blocks.ts +54 -11
  37. package/src/explain/brace-tokens.ts +4 -3
  38. package/src/explain/catalog.ts +42 -44
  39. package/src/explain/coordinates.ts +159 -0
  40. package/src/explain/defects.ts +2 -1
  41. package/src/explain/escape-tokens.ts +77 -0
  42. package/src/explain/highlight-projection.ts +308 -0
  43. package/src/explain/menus.ts +9 -5
  44. package/src/explain/operator-tokens.ts +13 -7
  45. package/src/explain/operators.ts +27 -20
  46. package/src/explain/path-tokens.ts +1 -1
  47. package/src/explain/pathresolve.ts +358 -113
  48. package/src/explain/quoted-string.ts +110 -20
  49. package/src/explain/scope-brace.ts +175 -22
  50. package/src/explain/segment.ts +429 -21
  51. package/src/explain/separator.ts +187 -0
  52. package/src/explain/split-owner.ts +194 -0
  53. package/src/explain/string-tokens.ts +14 -4
  54. package/src/explain/symbol-values.ts +209 -173
  55. package/src/explain/symbols.ts +21 -1
  56. package/src/explain/transport.ts +17 -0
  57. package/src/explain/value-tokens.ts +15 -12
  58. package/src/explain/verbsplit.ts +165 -23
  59. package/src/explain/write.ts +47 -6
  60. package/src/explain.ts +522 -72
  61. package/src/index.ts +19 -2
  62. package/src/mcp/server.ts +2 -1
  63. package/src/offline-gate.ts +148 -0
  64. package/src/retrieve-fanout.ts +1 -1
  65. package/src/retrieve.ts +1 -59
  66. package/src/settings.ts +2 -1
  67. package/src/transfer-fanout.ts +1 -1
  68. package/src/transfer.ts +1 -1
package/src/execute.ts CHANGED
@@ -5,20 +5,28 @@ import type {
5
5
  CommonSettingsMeta,
6
6
  SettingSource as CoreSettingSource,
7
7
  EnvelopeValidationMeta,
8
+ EnvelopeValidationStage,
8
9
  Tip,
10
+ ValidationTrace,
9
11
  Warning,
10
12
  } from "./core/envelope.ts";
11
13
  import { buildTip } from "./core/envelope.ts";
12
14
  import {
13
- extractCompletionNames,
14
- inspectChildren,
15
- inspectCompletions,
16
- isArgumentNode,
17
- pathTokens,
18
- } from "./core/inspect.ts";
15
+ type CanonicalExecuteCommand,
16
+ canonicalizeExecuteCommand,
17
+ isWriteShaped,
18
+ } from "./core/execute-command.ts";
19
+ import { inspectArgumentNames, pathTokens } from "./core/inspect.ts";
19
20
  import { mapRouterOsError } from "./core/routeros-errors.ts";
20
21
  import { routerOsStringLiteral } from "./core/routeros-string.ts";
22
+ import { toYaml } from "./core/yaml.ts";
21
23
  import { CentrsError, serializeCentrsError } from "./errors.ts";
24
+ import {
25
+ assertOfflineSyntax,
26
+ isOfflineGateRejection,
27
+ OFFLINE_GATE_SOURCE,
28
+ type OfflineGateResult,
29
+ } from "./offline-gate.ts";
22
30
  import {
23
31
  createProtocolAdapter,
24
32
  getProtocolPlan,
@@ -54,7 +62,6 @@ import {
54
62
  resolveTarget,
55
63
  toCoreSource,
56
64
  } from "./resolver/index.ts";
57
- import { toYaml } from "./retrieve.ts";
58
65
 
59
66
  export const executeOutputFormats = ["text", "json", "yaml"] as const;
60
67
  export type ExecuteOutputFormat = (typeof executeOutputFormats)[number];
@@ -125,14 +132,17 @@ export type ExecuteSuccessEnvelope = CentrsSuccessEnvelope<
125
132
  >;
126
133
  export type ExecuteErrorEnvelope = CentrsErrorEnvelope<ExecuteOperationMeta>;
127
134
 
128
- export interface CanonicalExecuteCommand {
129
- mode: "structured" | "script";
130
- input: string;
131
- path: string;
132
- verb: string;
133
- attributes: Record<string, string>;
134
- queries: string[];
135
- }
135
+ /**
136
+ * The execution gate itself lives in `src/core/execute-command.ts` (pure, no
137
+ * transport reach) and is re-exported here because this module is its
138
+ * documented public home — `src/index.ts`, `src/mcp/tools.ts` and the locked
139
+ * contract table all import it from `execute` (#312).
140
+ */
141
+ export {
142
+ type CanonicalExecuteCommand,
143
+ canonicalizeExecuteCommand,
144
+ isWriteShaped,
145
+ };
136
146
 
137
147
  export interface ResolvedExecuteRequest {
138
148
  command: string;
@@ -172,13 +182,23 @@ export async function executeEnvelope(
172
182
  env: Record<string, string | undefined> = Bun.env,
173
183
  ): Promise<ExecuteEnvelope> {
174
184
  let resolved: ResolvedExecuteRequest | undefined;
185
+ // The gate finishes inside `runResolvedExecute`, but the error envelope is
186
+ // built out here after the throw has unwound past it. Without the trace, a
187
+ // RouterOS fault on the *run* — both stages already green — reported a
188
+ // reconstructed `result: "failed"` that erased the real outcome.
189
+ const trace: ValidationTrace = {};
175
190
  try {
176
191
  resolved = await resolveExecuteRequest(request, env);
177
192
  await assertWriteConfirmed(request, resolved);
178
- return await runResolvedExecute(resolved);
193
+ return await runResolvedExecute(resolved, trace);
179
194
  } catch (error) {
180
195
  return resolved
181
- ? buildExecuteErrorEnvelopeFromResolved(resolved, error)
196
+ ? buildExecuteErrorEnvelopeFromResolved(
197
+ resolved,
198
+ error,
199
+ trace.validation,
200
+ trace,
201
+ )
182
202
  : buildExecuteErrorEnvelope(request, error, env);
183
203
  }
184
204
  }
@@ -196,6 +216,7 @@ export async function validateExecuteEnvelope(
196
216
  env: Record<string, string | undefined> = Bun.env,
197
217
  ): Promise<ExecuteEnvelope> {
198
218
  let resolved: ResolvedExecuteRequest | undefined;
219
+ const trace: ValidationTrace = {};
199
220
  try {
200
221
  resolved = await resolveExecuteRequest({ ...request, validate: true }, env);
201
222
  const backend = createProtocolAdapter({
@@ -212,7 +233,7 @@ export async function validateExecuteEnvelope(
212
233
  insecure: resolved.insecure.value,
213
234
  });
214
235
  try {
215
- const validation = await validateExecuteCommand(resolved, backend);
236
+ const validation = await validateExecuteCommand(resolved, backend, trace);
216
237
  return {
217
238
  ok: true,
218
239
  data: null,
@@ -225,13 +246,14 @@ export async function validateExecuteEnvelope(
225
246
  }
226
247
  } catch (error) {
227
248
  return resolved
228
- ? buildExecuteErrorEnvelopeFromResolved(resolved, error)
249
+ ? buildExecuteErrorEnvelopeFromResolved(resolved, error, undefined, trace)
229
250
  : buildExecuteErrorEnvelope(request, error, env);
230
251
  }
231
252
  }
232
253
 
233
254
  export async function runResolvedExecute(
234
255
  resolved: ResolvedExecuteRequest,
256
+ trace: ValidationTrace = {},
235
257
  ): Promise<ExecuteSuccessEnvelope> {
236
258
  const backend = createProtocolAdapter({
237
259
  protocol: resolved.via.value,
@@ -253,16 +275,13 @@ export async function runResolvedExecute(
253
275
 
254
276
  try {
255
277
  if (resolved.validate.value) {
256
- validation = await validateExecuteCommand(resolved, backend);
278
+ validation = await validateExecuteCommand(resolved, backend, trace);
257
279
  } else {
258
- validation = {
259
- enabled: false,
260
- source: "disabled",
261
- result: "skipped",
262
- syntax: false,
263
- semantic: false,
264
- };
280
+ validation = disabledValidationMeta();
265
281
  }
282
+ // Recorded BEFORE the run, so a RouterOS fault or transport drop below
283
+ // reports the gate that actually passed rather than a guess.
284
+ trace.validation = validation;
266
285
 
267
286
  const result = await runCommand(resolved, backend);
268
287
  const routerOsFailure = routerOsFailureFromResult(
@@ -560,6 +579,7 @@ export function buildExecuteErrorEnvelopeFromResolved(
560
579
  resolved: ResolvedExecuteRequest,
561
580
  error: unknown,
562
581
  validation?: EnvelopeValidationMeta,
582
+ trace?: ValidationTrace,
563
583
  ): ExecuteErrorEnvelope {
564
584
  const centrsError =
565
585
  error instanceof CentrsError
@@ -578,23 +598,133 @@ export function buildExecuteErrorEnvelopeFromResolved(
578
598
  tips: resolvedExecuteTips(resolved),
579
599
  meta: metaFromResolved(
580
600
  resolved,
581
- validation ?? {
582
- enabled: resolved.validate.value,
583
- source: resolved.validate.value
584
- ? ":put [:parse] + /console/inspect"
585
- : "disabled",
586
- result: resolved.validate.value ? "failed" : "skipped",
587
- syntax: resolved.validate.value,
588
- semantic: resolved.validate.value
589
- ? resolved.canonical.mode === "structured"
590
- ? false
591
- : "not-applicable"
592
- : false,
593
- },
601
+ validation ??
602
+ trace?.validation ??
603
+ failedValidationMeta(resolved, error, trace),
594
604
  ),
595
605
  };
596
606
  }
597
607
 
608
+ /**
609
+ * The `meta.validation` for an error envelope built without a validation result
610
+ * of its own — i.e. the gate threw, or something downstream did.
611
+ *
612
+ * A stage-1 rejection is the case worth spelling out: the offline analyzer never
613
+ * opens a connection, so reporting the old flat
614
+ * `source: ":put [:parse] + /console/inspect"` / `result: "failed"` would claim
615
+ * a device probe that did not happen. `stages` says which one actually spoke.
616
+ */
617
+ function failedValidationMeta(
618
+ resolved: ResolvedExecuteRequest,
619
+ error: unknown,
620
+ trace?: ValidationTrace,
621
+ ): EnvelopeValidationMeta {
622
+ if (!resolved.validate.value) {
623
+ return disabledValidationMeta();
624
+ }
625
+ const deviceSource = deviceStageSource(resolved);
626
+ // Script-shaped input has no `/console/inspect` half to fail, on either
627
+ // branch below. Reporting `semantic: false` for it would say a semantic gate
628
+ // ran and said no, which is a different claim from "there was none".
629
+ const semantic: boolean | "not-applicable" =
630
+ resolved.canonical.mode === "structured" ? false : "not-applicable";
631
+ if (isOfflineGateRejection(error)) {
632
+ return {
633
+ enabled: true,
634
+ source: OFFLINE_GATE_SOURCE,
635
+ result: "failed",
636
+ syntax: false,
637
+ semantic,
638
+ stages: [
639
+ { stage: "offline", source: OFFLINE_GATE_SOURCE, result: "failed" },
640
+ {
641
+ stage: "device",
642
+ source: deviceSource,
643
+ result: "skipped",
644
+ reason: "offline analysis rejected the command; no connection opened",
645
+ },
646
+ ],
647
+ };
648
+ }
649
+ // Only a `validation/*` error tells us the DEVICE stage rejected it — and
650
+ // only then do we know stage 1 passed, because stage 1 runs first and would
651
+ // have thrown its own (offline-tagged) error otherwise. This builder also
652
+ // catches everything downstream — a RouterOS fault on the run, a transport
653
+ // drop — which passed both stages and is served by `trace.validation`. When
654
+ // neither applies, `stages` is omitted rather than asserting a verdict this
655
+ // function cannot know.
656
+ const deviceRejected =
657
+ error instanceof CentrsError && error.code.startsWith("validation/");
658
+ return {
659
+ enabled: true,
660
+ source: deviceSource,
661
+ result: "failed",
662
+ syntax: true,
663
+ semantic,
664
+ ...(deviceRejected
665
+ ? {
666
+ stages: [
667
+ offlineStagePassed(trace?.offlineVerdict),
668
+ {
669
+ stage: "device" as const,
670
+ source: deviceSource,
671
+ result: "failed" as const,
672
+ },
673
+ ],
674
+ }
675
+ : {}),
676
+ };
677
+ }
678
+
679
+ /** The device-stage validator identity for a resolved request's transport. */
680
+ function deviceStageSource(resolved: ResolvedExecuteRequest): string {
681
+ return resolved.via.value === "mac-telnet" || resolved.via.value === "ssh"
682
+ ? `:put [:parse ...] over ${resolved.via.value}`
683
+ : ":put [:parse] + /console/inspect";
684
+ }
685
+
686
+ /**
687
+ * The offline stage entry for a gate stage 1 cleared.
688
+ *
689
+ * `verdict` comes from the {@link ValidationTrace}, not from a re-run: a `warn`
690
+ * is an ABSTENTION, and an error envelope that flattened it to a bare `passed`
691
+ * would tell a consumer the analyzer judged bytes it explicitly declined to
692
+ * judge. An absent verdict means the caller did not thread a trace, so say
693
+ * nothing extra rather than invent the reason.
694
+ */
695
+ function offlineStagePassed(
696
+ verdict: ValidationTrace["offlineVerdict"],
697
+ ): EnvelopeValidationStage {
698
+ return {
699
+ stage: "offline",
700
+ source: OFFLINE_GATE_SOURCE,
701
+ result: "passed",
702
+ ...(verdict === "warn"
703
+ ? { reason: "analyzer abstained on part of the input" }
704
+ : {}),
705
+ };
706
+ }
707
+
708
+ /**
709
+ * `validate=false`. Both stages are listed as `skipped` rather than omitted:
710
+ * the constitution's rule is that a gated surface always reports each stage, so
711
+ * a consumer reading `stages` never has to special-case the disabled shape.
712
+ * `reason` is left off — `enabled: false` already says why.
713
+ */
714
+ function disabledValidationMeta(): EnvelopeValidationMeta {
715
+ return {
716
+ enabled: false,
717
+ source: "disabled",
718
+ result: "skipped",
719
+ syntax: false,
720
+ semantic: false,
721
+ stages: [
722
+ { stage: "offline", source: OFFLINE_GATE_SOURCE, result: "skipped" },
723
+ { stage: "device", source: "device preflight", result: "skipped" },
724
+ ],
725
+ };
726
+ }
727
+
598
728
  export function renderExecuteEnvelope(
599
729
  envelope: ExecuteEnvelope,
600
730
  format: ExecuteOutputFormat,
@@ -614,86 +744,6 @@ export function renderExecuteEnvelope(
614
744
  }
615
745
  }
616
746
 
617
- /**
618
- * The **script-vs-structured execution gate** — centrs's load-bearing
619
- * discriminator (with {@link isWriteShaped}) for which validation runs and
620
- * whether the write-confirmation prompt fires. centrs owns this gate; the shared,
621
- * prose-tolerant, multi-command canonicalizer that `rosetta` / `lsp-routeros-ts`
622
- * publish is for *canonicalization*, never the structured-mode predicate —
623
- * widening what counts as `structured` is a product regression. Behavior is
624
- * pinned by `test/unit/execute-canonicalize-contract.test.ts`.
625
- *
626
- * centrs deliberately does **not** vendor the shared parser yet. Preconditions
627
- * for adopting it: (1) that contract stays green (no gate widening); (2) the
628
- * vendored file is clean under centrs's strict `tsconfig` (or explicitly
629
- * quarantined with justification); (3) `lsp-routeros-ts` also vendors/consumes
630
- * the same parser shape, so it is genuinely shared, not a premature first copy.
631
- */
632
- export function canonicalizeExecuteCommand(
633
- input: string,
634
- ): CanonicalExecuteCommand {
635
- const asScript = (): CanonicalExecuteCommand => ({
636
- mode: "script",
637
- input,
638
- path: "",
639
- verb: "",
640
- attributes: {},
641
- queries: [],
642
- });
643
-
644
- const trimmed = input.trim();
645
- if (!trimmed.startsWith("/")) {
646
- return asScript();
647
- }
648
-
649
- const tokens = tokenizeRouterOsCli(trimmed);
650
- const pathToken = tokens[0] ?? "";
651
- const pathParts = pathToken.split("/").filter(Boolean);
652
- if (pathParts.length < 2) {
653
- return asScript();
654
- }
655
-
656
- const verb = pathParts.at(-1) ?? "";
657
- const path = `/${pathParts.slice(0, -1).join("/")}`;
658
- const attributes: Record<string, string> = {};
659
- const queries: string[] = [];
660
- for (const token of tokens.slice(1)) {
661
- // `[...]` subshell selectors (e.g. `numbers=[find ...]`) cannot be
662
- // represented as a structured attribute map: the inner command contains
663
- // spaces this tokenizer splits on, which would mangle a write-shaped
664
- // command into corrupt key=value pairs. Fall back to the raw-script path
665
- // so RouterOS evaluates the subshell with its real semantics.
666
- if (token.includes("[") || token.includes("]")) {
667
- return asScript();
668
- }
669
- if (token.startsWith("?")) {
670
- queries.push(token);
671
- continue;
672
- }
673
- const separator = token.indexOf("=");
674
- if (separator <= 0) {
675
- return asScript();
676
- }
677
- attributes[token.slice(0, separator)] = token.slice(separator + 1);
678
- }
679
-
680
- return {
681
- mode: "structured",
682
- input,
683
- path,
684
- verb,
685
- attributes,
686
- queries,
687
- };
688
- }
689
-
690
- export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
691
- return (
692
- command.mode === "structured" &&
693
- ["add", "set", "remove"].includes(command.verb)
694
- );
695
- }
696
-
697
747
  export function validateExecuteRequestShape(request: ExecuteRequest): void {
698
748
  if (!request.command || request.command.trim().length === 0) {
699
749
  throw new CentrsError({
@@ -705,12 +755,31 @@ export function validateExecuteRequestShape(request: ExecuteRequest): void {
705
755
  }
706
756
  }
707
757
 
758
+ /**
759
+ * The two-stage gate (GH#354): offline analysis first, then the device probe.
760
+ *
761
+ * Stage 1 runs before anything is sent, on every transport, so a syntax fault is
762
+ * a byte span and no round trip. It is not a substitute for stage 2 — `explain`
763
+ * has no per-menu schema, so it cannot see an unknown attribute — and it never
764
+ * reports success on the device's behalf. Stage 2 is unchanged: `:parse` (which
765
+ * on a console transport also covers the name-level attribute gate) plus
766
+ * `/console/inspect request=child` for structured input over REST/native.
767
+ */
708
768
  async function validateExecuteCommand(
709
769
  resolved: ResolvedExecuteRequest,
710
770
  backend: ProtocolAdapter,
771
+ trace: ValidationTrace = {},
711
772
  ): Promise<EnvelopeValidationMeta> {
773
+ const offline = assertOfflineSyntax(resolved.command, {
774
+ surface: "execute",
775
+ via: resolved.via.value,
776
+ });
777
+ // Recorded before stage 2 can throw, so a device rejection still reports
778
+ // whether stage 1 read the whole input or abstained on part of it.
779
+ trace.offlineVerdict = offline.verdict;
780
+
712
781
  if (resolved.via.value === "mac-telnet" || resolved.via.value === "ssh") {
713
- return validateConsoleParseCommand(resolved, backend);
782
+ return validateConsoleParseCommand(resolved, backend, offline);
714
783
  }
715
784
 
716
785
  await runSyntaxGate(resolved, backend);
@@ -722,10 +791,14 @@ async function validateExecuteCommand(
722
791
  result: "passed",
723
792
  syntax: true,
724
793
  semantic: "not-applicable",
794
+ stages: passedStages(offline, ":put [:parse]"),
725
795
  };
726
796
  }
727
797
 
728
- const availableAttributes = await inspectExecuteAttributes(resolved, backend);
798
+ const availableAttributes = await inspectArgumentNames(backend, [
799
+ ...pathTokens(resolved.canonical.path),
800
+ resolved.canonical.verb,
801
+ ]);
729
802
  const requested = Object.keys(resolved.canonical.attributes);
730
803
  const missing = requested.filter(
731
804
  (attribute) => !availableAttributes.includes(attribute),
@@ -742,7 +815,7 @@ async function validateExecuteCommand(
742
815
  parameter: missing[0],
743
816
  requestedAttributes: requested,
744
817
  availableAttributes,
745
- validationSource: "/console/inspect request=child+completion",
818
+ validationSource: "/console/inspect request=child",
746
819
  },
747
820
  });
748
821
  }
@@ -754,6 +827,7 @@ async function validateExecuteCommand(
754
827
  syntax: true,
755
828
  semantic: true,
756
829
  availableAttributes,
830
+ stages: passedStages(offline, ":put [:parse] + /console/inspect"),
757
831
  };
758
832
  }
759
833
 
@@ -768,22 +842,8 @@ async function validateExecuteCommand(
768
842
  async function validateConsoleParseCommand(
769
843
  resolved: ResolvedExecuteRequest,
770
844
  backend: ProtocolAdapter,
845
+ offline: OfflineGateResult,
771
846
  ): Promise<EnvelopeValidationMeta> {
772
- if (hasUnbalancedQuotes(resolved.command)) {
773
- throw new CentrsError({
774
- code: "validation/syntax",
775
- summary:
776
- "RouterOS rejected the command syntax during local quote preflight.",
777
- remediation:
778
- "Close the RouterOS string quote, then retry. The command was not executed.",
779
- context: {
780
- command: resolved.command,
781
- validationSource: "local quote preflight before :put [:parse ...]",
782
- via: resolved.via.value,
783
- },
784
- causeData: "unterminated string literal",
785
- });
786
- }
787
847
  const result = await backend.execute({
788
848
  path: "",
789
849
  command: "",
@@ -798,28 +858,40 @@ async function validateConsoleParseCommand(
798
858
  syntax: true,
799
859
  semantic:
800
860
  resolved.canonical.mode === "structured" ? true : "not-applicable",
861
+ stages: passedStages(
862
+ offline,
863
+ `:put [:parse ...] over ${resolved.via.value}`,
864
+ ),
801
865
  };
802
866
  }
803
867
 
868
+ /**
869
+ * The stage breakdown for a gate both stages passed. `offline` is carried rather
870
+ * than re-derived so the meta reports the verdict the run actually saw — a
871
+ * `warn` is an abstention, and saying so is the difference between "the analyzer
872
+ * read all of this" and "it declined to judge part of it".
873
+ */
874
+ function passedStages(
875
+ offline: OfflineGateResult,
876
+ deviceSource: string,
877
+ ): EnvelopeValidationStage[] {
878
+ return [
879
+ {
880
+ stage: "offline",
881
+ source: OFFLINE_GATE_SOURCE,
882
+ result: "passed",
883
+ ...(offline.verdict === "warn"
884
+ ? { reason: "analyzer abstained on part of the input" }
885
+ : {}),
886
+ },
887
+ { stage: "device", source: deviceSource, result: "passed" },
888
+ ];
889
+ }
890
+
804
891
  async function runSyntaxGate(
805
892
  resolved: ResolvedExecuteRequest,
806
893
  backend: ProtocolAdapter,
807
894
  ): Promise<void> {
808
- if (hasUnbalancedQuotes(resolved.command)) {
809
- throw new CentrsError({
810
- code: "validation/syntax",
811
- summary:
812
- "RouterOS rejected the command syntax during local quote preflight.",
813
- remediation:
814
- "Close the RouterOS string quote, then retry. The command was not executed.",
815
- context: {
816
- command: resolved.command,
817
- validationSource: "local quote preflight before :put [:parse ...]",
818
- via: resolved.via.value,
819
- },
820
- causeData: "unterminated string literal",
821
- });
822
- }
823
895
  const script = `:put [:parse ${routerOsStringLiteral(resolved.command)}]`;
824
896
  try {
825
897
  // GH#230: `:parse` never throws (CHR 7.23.3). The diagnostic rides the
@@ -873,27 +945,6 @@ function isPreflightTransportError(error: CentrsError): boolean {
873
945
  return error.code.startsWith("transport/") || error.code.startsWith("auth/");
874
946
  }
875
947
 
876
- async function inspectExecuteAttributes(
877
- resolved: ResolvedExecuteRequest,
878
- backend: ProtocolAdapter,
879
- ): Promise<string[]> {
880
- const commandTokens = [
881
- ...pathTokens(resolved.canonical.path),
882
- resolved.canonical.verb,
883
- ];
884
- const children = await inspectChildren(backend, commandTokens);
885
- const childAttributes = children
886
- .filter(isArgumentNode)
887
- .map((child) => child.name)
888
- .filter(
889
- (name): name is string => typeof name === "string" && name.length > 0,
890
- );
891
- const completionRows = await inspectCompletions(backend, commandTokens);
892
- return [
893
- ...new Set([...childAttributes, ...extractCompletionNames(completionRows)]),
894
- ].sort();
895
- }
896
-
897
948
  async function runCommand(
898
949
  resolved: ResolvedExecuteRequest,
899
950
  backend: ProtocolAdapter,
@@ -1359,76 +1410,6 @@ function renderExecuteErrorText(
1359
1410
  return lines.join("\n");
1360
1411
  }
1361
1412
 
1362
- function hasUnbalancedQuotes(input: string): boolean {
1363
- let quote: '"' | "'" | undefined;
1364
- let escaped = false;
1365
- for (const char of input) {
1366
- if (escaped) {
1367
- escaped = false;
1368
- continue;
1369
- }
1370
- if (char === "\\") {
1371
- escaped = true;
1372
- continue;
1373
- }
1374
- if (quote) {
1375
- if (char === quote) {
1376
- quote = undefined;
1377
- }
1378
- continue;
1379
- }
1380
- if (char === '"' || char === "'") {
1381
- quote = char;
1382
- }
1383
- }
1384
- return quote !== undefined;
1385
- }
1386
-
1387
- function tokenizeRouterOsCli(input: string): string[] {
1388
- const tokens: string[] = [];
1389
- let current = "";
1390
- let quote: '"' | "'" | undefined;
1391
- let escaped = false;
1392
- for (const char of input) {
1393
- if (escaped) {
1394
- current += char;
1395
- escaped = false;
1396
- continue;
1397
- }
1398
- if (char === "\\") {
1399
- escaped = true;
1400
- continue;
1401
- }
1402
- if (quote) {
1403
- if (char === quote) {
1404
- quote = undefined;
1405
- } else {
1406
- current += char;
1407
- }
1408
- continue;
1409
- }
1410
- if (char === '"' || char === "'") {
1411
- quote = char;
1412
- continue;
1413
- }
1414
- if (/\s/.test(char)) {
1415
- if (current.length > 0) {
1416
- tokens.push(current);
1417
- current = "";
1418
- }
1419
- continue;
1420
- }
1421
- current += char;
1422
- }
1423
- if (escaped) {
1424
- current += "\\";
1425
- }
1426
- if (current.length > 0) {
1427
- tokens.push(current);
1428
- }
1429
- return tokens;
1430
- }
1431
-
1432
1413
  function syntaxCause(error: unknown): unknown {
1433
1414
  if (error instanceof CentrsError) {
1434
1415
  return error.causeData ?? error.context ?? error.toJSON();