@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/execute.ts CHANGED
@@ -5,19 +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";
21
+ import { routerOsStringLiteral } from "./core/routeros-string.ts";
22
+ import { toYaml } from "./core/yaml.ts";
20
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";
21
30
  import {
22
31
  createProtocolAdapter,
23
32
  getProtocolPlan,
@@ -31,6 +40,7 @@ import {
31
40
  parseScriptFor,
32
41
  } from "./protocols/mac-telnet-console.ts";
33
42
  import {
43
+ assertNoQuickchrOverrideConflict,
34
44
  type CdbResolution,
35
45
  effectiveHostCandidate,
36
46
  isIpTransport,
@@ -38,6 +48,7 @@ import {
38
48
  loadEnvFileDefaults,
39
49
  parseDuration,
40
50
  parseResolvePolicy,
51
+ quickchrConnection,
41
52
  type ResolvedAuth,
42
53
  type ResolvedSetting,
43
54
  type ResolvedTarget,
@@ -46,17 +57,23 @@ import {
46
57
  resolveCdb,
47
58
  resolveMacTarget,
48
59
  resolveOptionalIntegerSetting,
60
+ resolveQuickchrTarget,
49
61
  resolveStringSetting,
50
62
  resolveTarget,
51
63
  toCoreSource,
52
64
  } from "./resolver/index.ts";
53
- import { toYaml } from "./retrieve.ts";
54
65
 
55
66
  export const executeOutputFormats = ["text", "json", "yaml"] as const;
56
67
  export type ExecuteOutputFormat = (typeof executeOutputFormats)[number];
57
68
 
58
69
  export interface ExecuteRequest {
59
70
  targetInput?: string;
71
+ /**
72
+ * quickchr machine name (`--quickchr <name>`): resolve host/port/auth from
73
+ * the live VM descriptor instead of CDB/env (`docs/CONSTITUTION.md` →
74
+ * Resolution providers). Conflicts with host/port/username/password/sshKey.
75
+ */
76
+ quickchr?: string;
60
77
  command: string;
61
78
  via?: string;
62
79
  host?: string;
@@ -115,14 +132,17 @@ export type ExecuteSuccessEnvelope = CentrsSuccessEnvelope<
115
132
  >;
116
133
  export type ExecuteErrorEnvelope = CentrsErrorEnvelope<ExecuteOperationMeta>;
117
134
 
118
- export interface CanonicalExecuteCommand {
119
- mode: "structured" | "script";
120
- input: string;
121
- path: string;
122
- verb: string;
123
- attributes: Record<string, string>;
124
- queries: string[];
125
- }
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
+ };
126
146
 
127
147
  export interface ResolvedExecuteRequest {
128
148
  command: string;
@@ -162,13 +182,23 @@ export async function executeEnvelope(
162
182
  env: Record<string, string | undefined> = Bun.env,
163
183
  ): Promise<ExecuteEnvelope> {
164
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 = {};
165
190
  try {
166
191
  resolved = await resolveExecuteRequest(request, env);
167
192
  await assertWriteConfirmed(request, resolved);
168
- return await runResolvedExecute(resolved);
193
+ return await runResolvedExecute(resolved, trace);
169
194
  } catch (error) {
170
195
  return resolved
171
- ? buildExecuteErrorEnvelopeFromResolved(resolved, error)
196
+ ? buildExecuteErrorEnvelopeFromResolved(
197
+ resolved,
198
+ error,
199
+ trace.validation,
200
+ trace,
201
+ )
172
202
  : buildExecuteErrorEnvelope(request, error, env);
173
203
  }
174
204
  }
@@ -186,6 +216,7 @@ export async function validateExecuteEnvelope(
186
216
  env: Record<string, string | undefined> = Bun.env,
187
217
  ): Promise<ExecuteEnvelope> {
188
218
  let resolved: ResolvedExecuteRequest | undefined;
219
+ const trace: ValidationTrace = {};
189
220
  try {
190
221
  resolved = await resolveExecuteRequest({ ...request, validate: true }, env);
191
222
  const backend = createProtocolAdapter({
@@ -202,7 +233,7 @@ export async function validateExecuteEnvelope(
202
233
  insecure: resolved.insecure.value,
203
234
  });
204
235
  try {
205
- const validation = await validateExecuteCommand(resolved, backend);
236
+ const validation = await validateExecuteCommand(resolved, backend, trace);
206
237
  return {
207
238
  ok: true,
208
239
  data: null,
@@ -215,13 +246,14 @@ export async function validateExecuteEnvelope(
215
246
  }
216
247
  } catch (error) {
217
248
  return resolved
218
- ? buildExecuteErrorEnvelopeFromResolved(resolved, error)
249
+ ? buildExecuteErrorEnvelopeFromResolved(resolved, error, undefined, trace)
219
250
  : buildExecuteErrorEnvelope(request, error, env);
220
251
  }
221
252
  }
222
253
 
223
254
  export async function runResolvedExecute(
224
255
  resolved: ResolvedExecuteRequest,
256
+ trace: ValidationTrace = {},
225
257
  ): Promise<ExecuteSuccessEnvelope> {
226
258
  const backend = createProtocolAdapter({
227
259
  protocol: resolved.via.value,
@@ -243,16 +275,13 @@ export async function runResolvedExecute(
243
275
 
244
276
  try {
245
277
  if (resolved.validate.value) {
246
- validation = await validateExecuteCommand(resolved, backend);
278
+ validation = await validateExecuteCommand(resolved, backend, trace);
247
279
  } else {
248
- validation = {
249
- enabled: false,
250
- source: "disabled",
251
- result: "skipped",
252
- syntax: false,
253
- semantic: false,
254
- };
280
+ validation = disabledValidationMeta();
255
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;
256
285
 
257
286
  const result = await runCommand(resolved, backend);
258
287
  const routerOsFailure = routerOsFailureFromResult(
@@ -286,19 +315,30 @@ export async function resolveExecuteRequest(
286
315
  validateExecuteRequestShape(request);
287
316
  const canonical = canonicalizeExecuteCommand(request.command);
288
317
  const config = options.config ?? (await loadEnvFileDefaults(env));
318
+ // A quickchr target is a named-live-provider: it supplies host/port/auth from
319
+ // the live descriptor and bypasses CDB/env resolution for those fields
320
+ // entirely (no CDB load, no `__default__` ladder, no MAC resolution).
321
+ let quickchrResolution: Awaited<
322
+ ReturnType<typeof resolveQuickchrTarget>
323
+ > | null = null;
324
+ if (request.quickchr !== undefined) {
325
+ assertNoQuickchrOverrideConflict(request, request.quickchr);
326
+ quickchrResolution = await resolveQuickchrTarget(request.quickchr);
327
+ }
289
328
  // Fan-out passes the pre-resolved CDB record for a member (the CDB is loaded
290
- // once in `expandCdbSelection`); single-target resolves it here.
291
- const cdbResolution =
292
- options.cdbResolution ??
293
- (await resolveCdb(
294
- {
295
- targetInput: request.targetInput,
296
- cdbFile: request.cdbFile,
297
- cdbPassword: request.cdbPassword,
298
- },
299
- env,
300
- config,
301
- ));
329
+ // once in `expandSelection`); single-target resolves it here.
330
+ const cdbResolution = quickchrResolution
331
+ ? undefined
332
+ : (options.cdbResolution ??
333
+ (await resolveCdb(
334
+ {
335
+ targetInput: request.targetInput,
336
+ cdbFile: request.cdbFile,
337
+ cdbPassword: request.cdbPassword,
338
+ },
339
+ env,
340
+ config,
341
+ )));
302
342
  const via = resolveExecuteProtocol(request, env, cdbResolution, config);
303
343
  const format = resolveFormat(request, env, config);
304
344
  const validate = resolveBooleanSetting(
@@ -325,50 +365,72 @@ export async function resolveExecuteRequest(
325
365
  undefined,
326
366
  config,
327
367
  );
328
- const macResolution = isIpTransport(via.value)
329
- ? await resolveMacTarget({
330
- host: request.host,
331
- targetInput: request.targetInput,
332
- cdbTarget: cdbResolution?.target,
368
+ const macResolution =
369
+ quickchrResolution === null && isIpTransport(via.value)
370
+ ? await resolveMacTarget({
371
+ host: request.host,
372
+ targetInput: request.targetInput,
373
+ cdbTarget: cdbResolution?.target,
374
+ env,
375
+ config,
376
+ policy: parseResolvePolicy(
377
+ request.resolve ??
378
+ env["CENTRS_RESOLVE"] ??
379
+ config["CENTRS_RESOLVE"],
380
+ ),
381
+ operation: "execute",
382
+ })
383
+ : undefined;
384
+ const connection = quickchrResolution
385
+ ? quickchrConnection(quickchrResolution, via.value)
386
+ : null;
387
+ const target = connection
388
+ ? connection.target
389
+ : resolveTarget(
390
+ {
391
+ targetInput: request.targetInput,
392
+ host: request.host,
393
+ port: request.port,
394
+ macResolution,
395
+ },
333
396
  env,
397
+ via.value,
398
+ cdbResolution,
334
399
  config,
335
- policy: parseResolvePolicy(
336
- request.resolve ?? env["CENTRS_RESOLVE"] ?? config["CENTRS_RESOLVE"],
337
- ),
338
- operation: "execute",
339
- })
340
- : undefined;
341
- const target = resolveTarget(
342
- {
343
- targetInput: request.targetInput,
344
- host: request.host,
345
- port: request.port,
346
- macResolution,
347
- },
348
- env,
349
- via.value,
350
- cdbResolution,
351
- config,
352
- );
353
- const auth = resolveAuth(
354
- {
355
- username: request.username,
356
- password: request.password,
357
- sshKey: request.sshKey,
358
- },
359
- env,
360
- cdbResolution,
361
- config,
362
- );
363
- const insecure = resolveBooleanSetting(
364
- request.insecure,
365
- env,
366
- "CENTRS_INSECURE",
367
- false,
368
- "insecure",
369
- cdbResolution?.overrides.insecure,
370
- config,
371
- );
400
+ );
401
+ const auth = connection
402
+ ? connection.auth
403
+ : resolveAuth(
404
+ {
405
+ username: request.username,
406
+ password: request.password,
407
+ sshKey: request.sshKey,
408
+ },
409
+ env,
410
+ cdbResolution,
411
+ config,
412
+ );
413
+ // A quickchr TLS/SSH endpoint is trusted by provenance (self-signed VM cert /
414
+ // ephemeral host key) — `insecure` is forced with provider provenance and the
415
+ // provider-trust warning rides the envelope (see QuickchrConnection.insecure).
416
+ const insecure =
417
+ connection?.insecure === true
418
+ ? {
419
+ value: true,
420
+ source: {
421
+ kind: "provider" as const,
422
+ key: `quickchr:${request.quickchr}`,
423
+ },
424
+ }
425
+ : resolveBooleanSetting(
426
+ request.insecure,
427
+ env,
428
+ "CENTRS_INSECURE",
429
+ false,
430
+ "insecure",
431
+ cdbResolution?.overrides.insecure,
432
+ config,
433
+ );
372
434
 
373
435
  return {
374
436
  command: request.command,
@@ -383,7 +445,9 @@ export async function resolveExecuteRequest(
383
445
  maxResultsBytes,
384
446
  yes: request.yes ?? false,
385
447
  verbose: request.verbose ?? false,
386
- warnings: cdbResolution?.warnings ?? [],
448
+ warnings: connection
449
+ ? [...connection.warnings]
450
+ : (cdbResolution?.warnings ?? []),
387
451
  };
388
452
  }
389
453
 
@@ -515,6 +579,7 @@ export function buildExecuteErrorEnvelopeFromResolved(
515
579
  resolved: ResolvedExecuteRequest,
516
580
  error: unknown,
517
581
  validation?: EnvelopeValidationMeta,
582
+ trace?: ValidationTrace,
518
583
  ): ExecuteErrorEnvelope {
519
584
  const centrsError =
520
585
  error instanceof CentrsError
@@ -533,23 +598,133 @@ export function buildExecuteErrorEnvelopeFromResolved(
533
598
  tips: resolvedExecuteTips(resolved),
534
599
  meta: metaFromResolved(
535
600
  resolved,
536
- validation ?? {
537
- enabled: resolved.validate.value,
538
- source: resolved.validate.value
539
- ? ":put [:parse] + /console/inspect"
540
- : "disabled",
541
- result: resolved.validate.value ? "failed" : "skipped",
542
- syntax: resolved.validate.value,
543
- semantic: resolved.validate.value
544
- ? resolved.canonical.mode === "structured"
545
- ? false
546
- : "not-applicable"
547
- : false,
548
- },
601
+ validation ??
602
+ trace?.validation ??
603
+ failedValidationMeta(resolved, error, trace),
549
604
  ),
550
605
  };
551
606
  }
552
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
+
553
728
  export function renderExecuteEnvelope(
554
729
  envelope: ExecuteEnvelope,
555
730
  format: ExecuteOutputFormat,
@@ -569,86 +744,6 @@ export function renderExecuteEnvelope(
569
744
  }
570
745
  }
571
746
 
572
- /**
573
- * The **script-vs-structured execution gate** — centrs's load-bearing
574
- * discriminator (with {@link isWriteShaped}) for which validation runs and
575
- * whether the write-confirmation prompt fires. centrs owns this gate; the shared,
576
- * prose-tolerant, multi-command canonicalizer that `rosetta` / `lsp-routeros-ts`
577
- * publish is for *canonicalization*, never the structured-mode predicate —
578
- * widening what counts as `structured` is a product regression. Behavior is
579
- * pinned by `test/unit/execute-canonicalize-contract.test.ts`.
580
- *
581
- * centrs deliberately does **not** vendor the shared parser yet. Preconditions
582
- * for adopting it: (1) that contract stays green (no gate widening); (2) the
583
- * vendored file is clean under centrs's strict `tsconfig` (or explicitly
584
- * quarantined with justification); (3) `lsp-routeros-ts` also vendors/consumes
585
- * the same parser shape, so it is genuinely shared, not a premature first copy.
586
- */
587
- export function canonicalizeExecuteCommand(
588
- input: string,
589
- ): CanonicalExecuteCommand {
590
- const asScript = (): CanonicalExecuteCommand => ({
591
- mode: "script",
592
- input,
593
- path: "",
594
- verb: "",
595
- attributes: {},
596
- queries: [],
597
- });
598
-
599
- const trimmed = input.trim();
600
- if (!trimmed.startsWith("/")) {
601
- return asScript();
602
- }
603
-
604
- const tokens = tokenizeRouterOsCli(trimmed);
605
- const pathToken = tokens[0] ?? "";
606
- const pathParts = pathToken.split("/").filter(Boolean);
607
- if (pathParts.length < 2) {
608
- return asScript();
609
- }
610
-
611
- const verb = pathParts.at(-1) ?? "";
612
- const path = `/${pathParts.slice(0, -1).join("/")}`;
613
- const attributes: Record<string, string> = {};
614
- const queries: string[] = [];
615
- for (const token of tokens.slice(1)) {
616
- // `[...]` subshell selectors (e.g. `numbers=[find ...]`) cannot be
617
- // represented as a structured attribute map: the inner command contains
618
- // spaces this tokenizer splits on, which would mangle a write-shaped
619
- // command into corrupt key=value pairs. Fall back to the raw-script path
620
- // so RouterOS evaluates the subshell with its real semantics.
621
- if (token.includes("[") || token.includes("]")) {
622
- return asScript();
623
- }
624
- if (token.startsWith("?")) {
625
- queries.push(token);
626
- continue;
627
- }
628
- const separator = token.indexOf("=");
629
- if (separator <= 0) {
630
- return asScript();
631
- }
632
- attributes[token.slice(0, separator)] = token.slice(separator + 1);
633
- }
634
-
635
- return {
636
- mode: "structured",
637
- input,
638
- path,
639
- verb,
640
- attributes,
641
- queries,
642
- };
643
- }
644
-
645
- export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
646
- return (
647
- command.mode === "structured" &&
648
- ["add", "set", "remove"].includes(command.verb)
649
- );
650
- }
651
-
652
747
  export function validateExecuteRequestShape(request: ExecuteRequest): void {
653
748
  if (!request.command || request.command.trim().length === 0) {
654
749
  throw new CentrsError({
@@ -660,12 +755,31 @@ export function validateExecuteRequestShape(request: ExecuteRequest): void {
660
755
  }
661
756
  }
662
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
+ */
663
768
  async function validateExecuteCommand(
664
769
  resolved: ResolvedExecuteRequest,
665
770
  backend: ProtocolAdapter,
771
+ trace: ValidationTrace = {},
666
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
+
667
781
  if (resolved.via.value === "mac-telnet" || resolved.via.value === "ssh") {
668
- return validateConsoleParseCommand(resolved, backend);
782
+ return validateConsoleParseCommand(resolved, backend, offline);
669
783
  }
670
784
 
671
785
  await runSyntaxGate(resolved, backend);
@@ -677,10 +791,14 @@ async function validateExecuteCommand(
677
791
  result: "passed",
678
792
  syntax: true,
679
793
  semantic: "not-applicable",
794
+ stages: passedStages(offline, ":put [:parse]"),
680
795
  };
681
796
  }
682
797
 
683
- const availableAttributes = await inspectExecuteAttributes(resolved, backend);
798
+ const availableAttributes = await inspectArgumentNames(backend, [
799
+ ...pathTokens(resolved.canonical.path),
800
+ resolved.canonical.verb,
801
+ ]);
684
802
  const requested = Object.keys(resolved.canonical.attributes);
685
803
  const missing = requested.filter(
686
804
  (attribute) => !availableAttributes.includes(attribute),
@@ -694,10 +812,10 @@ async function validateExecuteCommand(
694
812
  context: {
695
813
  path: resolved.canonical.path,
696
814
  verb: resolved.canonical.verb,
697
- attribute: missing[0],
815
+ parameter: missing[0],
698
816
  requestedAttributes: requested,
699
817
  availableAttributes,
700
- validationSource: "/console/inspect request=child+completion",
818
+ validationSource: "/console/inspect request=child",
701
819
  },
702
820
  });
703
821
  }
@@ -709,6 +827,7 @@ async function validateExecuteCommand(
709
827
  syntax: true,
710
828
  semantic: true,
711
829
  availableAttributes,
830
+ stages: passedStages(offline, ":put [:parse] + /console/inspect"),
712
831
  };
713
832
  }
714
833
 
@@ -716,36 +835,22 @@ async function validateExecuteCommand(
716
835
  * Validate a command over a console transport (mac-telnet or ssh) with a single
717
836
  * `:put [:parse ...]`. Unlike REST/native (a syntax gate plus a `/console/inspect`
718
837
  * semantic gate), one console `:parse` reports both `syntax error` and `bad
719
- * parameter <name>`, so it covers syntax and the unknown-attribute (semantic)
838
+ * parameter <name>`, so it covers syntax and the unknown-attribute (name-level)
720
839
  * gate at once — identical strings over mac-telnet and ssh (CHR 7.23.1 grounded;
721
840
  * see `mac-telnet-console.ts` and `ssh.ts`).
722
841
  */
723
842
  async function validateConsoleParseCommand(
724
843
  resolved: ResolvedExecuteRequest,
725
844
  backend: ProtocolAdapter,
845
+ offline: OfflineGateResult,
726
846
  ): Promise<EnvelopeValidationMeta> {
727
- if (hasUnbalancedQuotes(resolved.command)) {
728
- throw new CentrsError({
729
- code: "validation/syntax",
730
- summary:
731
- "RouterOS rejected the command syntax during local quote preflight.",
732
- remediation:
733
- "Close the RouterOS string quote, then retry. The command was not executed.",
734
- context: {
735
- command: resolved.command,
736
- validationSource: "local quote preflight before :put [:parse ...]",
737
- via: resolved.via.value,
738
- },
739
- causeData: "unterminated string literal",
740
- });
741
- }
742
847
  const result = await backend.execute({
743
848
  path: "",
744
849
  command: "",
745
850
  script: parseScriptFor(resolved.command),
746
851
  });
747
852
  // Throws validation/syntax or validation/unknown-attribute on a bad parse.
748
- classifyParseResult(result.ret ?? "", resolved.command);
853
+ classifyParseResult(result.ret ?? "", resolved.command, resolved.via.value);
749
854
  return {
750
855
  enabled: true,
751
856
  source: `:put [:parse ...] over ${resolved.via.value}`,
@@ -753,31 +858,50 @@ async function validateConsoleParseCommand(
753
858
  syntax: true,
754
859
  semantic:
755
860
  resolved.canonical.mode === "structured" ? true : "not-applicable",
861
+ stages: passedStages(
862
+ offline,
863
+ `:put [:parse ...] over ${resolved.via.value}`,
864
+ ),
756
865
  };
757
866
  }
758
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
+
759
891
  async function runSyntaxGate(
760
892
  resolved: ResolvedExecuteRequest,
761
893
  backend: ProtocolAdapter,
762
894
  ): Promise<void> {
763
- if (hasUnbalancedQuotes(resolved.command)) {
764
- throw new CentrsError({
765
- code: "validation/syntax",
766
- summary:
767
- "RouterOS rejected the command syntax during local quote preflight.",
768
- remediation:
769
- "Close the RouterOS string quote, then retry. The command was not executed.",
770
- context: {
771
- command: resolved.command,
772
- validationSource: "local quote preflight before :put [:parse ...]",
773
- via: resolved.via.value,
774
- },
775
- causeData: "unterminated string literal",
776
- });
777
- }
778
895
  const script = `:put [:parse ${routerOsStringLiteral(resolved.command)}]`;
779
896
  try {
780
- await backend.execute({ path: "", command: "", script });
897
+ // GH#230: `:parse` never throws (CHR 7.23.3). The diagnostic rides the
898
+ // success value's text — over REST/native the `ret` field (HTTP 200 /
899
+ // `as-string`), over console the printed output. The old code only watched
900
+ // for a thrown transport/RouterOS error and so never rejected anything.
901
+ // Read the return and classify it: one `:parse` covers syntax and the
902
+ // unknown-attribute (name-level) gate.
903
+ const result = await backend.execute({ path: "", command: "", script });
904
+ classifyParseResult(result.ret ?? "", resolved.command, resolved.via.value);
781
905
  } catch (error) {
782
906
  // The preflight is a syntax gate, but `backend.execute` also surfaces
783
907
  // connection and authentication failures (login happens lazily here).
@@ -788,6 +912,9 @@ async function runSyntaxGate(
788
912
  if (error instanceof CentrsError && isPreflightTransportError(error)) {
789
913
  throw error;
790
914
  }
915
+ if (error instanceof CentrsError && error.code.startsWith("validation/")) {
916
+ throw error;
917
+ }
791
918
  // If the mapped RouterOS fault carried a `(line N column M)` byte offset,
792
919
  // keep it on the wrapped syntax error rather than dropping it.
793
920
  const position = error instanceof CentrsError ? error.position : undefined;
@@ -818,27 +945,6 @@ function isPreflightTransportError(error: CentrsError): boolean {
818
945
  return error.code.startsWith("transport/") || error.code.startsWith("auth/");
819
946
  }
820
947
 
821
- async function inspectExecuteAttributes(
822
- resolved: ResolvedExecuteRequest,
823
- backend: ProtocolAdapter,
824
- ): Promise<string[]> {
825
- const commandTokens = [
826
- ...pathTokens(resolved.canonical.path),
827
- resolved.canonical.verb,
828
- ];
829
- const children = await inspectChildren(backend, commandTokens);
830
- const childAttributes = children
831
- .filter(isArgumentNode)
832
- .map((child) => child.name)
833
- .filter(
834
- (name): name is string => typeof name === "string" && name.length > 0,
835
- );
836
- const completionRows = await inspectCompletions(backend, commandTokens);
837
- return [
838
- ...new Set([...childAttributes, ...extractCompletionNames(completionRows)]),
839
- ].sort();
840
- }
841
-
842
948
  async function runCommand(
843
949
  resolved: ResolvedExecuteRequest,
844
950
  backend: ProtocolAdapter,
@@ -1304,80 +1410,6 @@ function renderExecuteErrorText(
1304
1410
  return lines.join("\n");
1305
1411
  }
1306
1412
 
1307
- function hasUnbalancedQuotes(input: string): boolean {
1308
- let quote: '"' | "'" | undefined;
1309
- let escaped = false;
1310
- for (const char of input) {
1311
- if (escaped) {
1312
- escaped = false;
1313
- continue;
1314
- }
1315
- if (char === "\\") {
1316
- escaped = true;
1317
- continue;
1318
- }
1319
- if (quote) {
1320
- if (char === quote) {
1321
- quote = undefined;
1322
- }
1323
- continue;
1324
- }
1325
- if (char === '"' || char === "'") {
1326
- quote = char;
1327
- }
1328
- }
1329
- return quote !== undefined;
1330
- }
1331
-
1332
- function tokenizeRouterOsCli(input: string): string[] {
1333
- const tokens: string[] = [];
1334
- let current = "";
1335
- let quote: '"' | "'" | undefined;
1336
- let escaped = false;
1337
- for (const char of input) {
1338
- if (escaped) {
1339
- current += char;
1340
- escaped = false;
1341
- continue;
1342
- }
1343
- if (char === "\\") {
1344
- escaped = true;
1345
- continue;
1346
- }
1347
- if (quote) {
1348
- if (char === quote) {
1349
- quote = undefined;
1350
- } else {
1351
- current += char;
1352
- }
1353
- continue;
1354
- }
1355
- if (char === '"' || char === "'") {
1356
- quote = char;
1357
- continue;
1358
- }
1359
- if (/\s/.test(char)) {
1360
- if (current.length > 0) {
1361
- tokens.push(current);
1362
- current = "";
1363
- }
1364
- continue;
1365
- }
1366
- current += char;
1367
- }
1368
- if (escaped) {
1369
- current += "\\";
1370
- }
1371
- if (current.length > 0) {
1372
- tokens.push(current);
1373
- }
1374
- return tokens;
1375
- }
1376
-
1377
- function routerOsStringLiteral(value: string): string {
1378
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
1379
- }
1380
-
1381
1413
  function syntaxCause(error: unknown): unknown {
1382
1414
  if (error instanceof CentrsError) {
1383
1415
  return error.causeData ?? error.context ?? error.toJSON();