@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/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,
@@ -136,6 +136,7 @@ export {
136
136
  versionFactFromComment,
137
137
  versionInRange,
138
138
  } from "./core/preflight.ts";
139
+ export { centrsVersion } from "./core/version.ts";
139
140
  export {
140
141
  encodeMndpPacket,
141
142
  MNDP_BROADCAST_ADDRESS,
@@ -305,13 +306,20 @@ export {
305
306
  // relative to the statement it was handed; the `Token` is that rebased into
306
307
  // document space by `explainCommand`. A consumer reading `explainCommand`'s
307
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.
308
314
  export {
309
315
  type Argument as ExplainArgument,
310
316
  type ArgumentKind as ExplainArgumentKind,
311
317
  type ArgumentReading as ExplainArgumentReading,
312
318
  type ArgumentsRead as ExplainArgumentsRead,
313
319
  type ArgumentsUnread as ExplainArgumentsUnread,
320
+ type ArgumentTokenReading as ExplainArgumentTokenReading,
314
321
  lexArguments as lexExplainArguments,
322
+ lexArgumentTokens as lexExplainArgumentTokens,
315
323
  lexValueAnchors as lexExplainValueAnchors,
316
324
  type ValueAnchor as ExplainValueAnchor,
317
325
  type ValueAnchorKind as ExplainValueAnchorKind,
@@ -478,6 +486,14 @@ export {
478
486
  explainOutputFormats,
479
487
  renderExplainEnvelope,
480
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";
481
497
  export {
482
498
  type ApiVerb,
483
499
  createProtocolAdapter,
@@ -672,6 +688,7 @@ export {
672
688
  transferFanout,
673
689
  } from "./transfer-fanout.ts";
674
690
 
691
+ import { centrsVersion } from "./core/version.ts";
675
692
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
676
693
 
677
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
+ }
@@ -46,6 +46,7 @@ import {
46
46
  runFanout,
47
47
  summarizeFanout,
48
48
  } from "./core/fanout.ts";
49
+ import { toYaml } from "./core/yaml.ts";
49
50
  import { CentrsError, serializeCentrsError } from "./errors.ts";
50
51
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
51
52
  import {
@@ -71,7 +72,6 @@ import {
71
72
  resolveRetrieveGlobalContext,
72
73
  resolveRetrieveRequest,
73
74
  runResolvedRetrieve,
74
- toYaml,
75
75
  validateRetrieveRequestShape,
76
76
  } from "./retrieve.ts";
77
77
 
package/src/retrieve.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  isCommandNode,
16
16
  pathTokens,
17
17
  } from "./core/inspect.ts";
18
+ import { toYaml } from "./core/yaml.ts";
18
19
  import { CentrsError, serializeCentrsError } from "./errors.ts";
19
20
  import {
20
21
  createProtocolAdapter,
@@ -1192,62 +1193,3 @@ function exhaustiveOutputFormat(value: never): never {
1192
1193
  function isPlainObject(value: unknown): value is Record<string, unknown> {
1193
1194
  return typeof value === "object" && value !== null && !Array.isArray(value);
1194
1195
  }
1195
-
1196
- export function toYaml(value: unknown, indent = 0): string {
1197
- const prefix = " ".repeat(indent);
1198
- if (value === null || value === undefined) {
1199
- return "null";
1200
- }
1201
- if (typeof value === "string") {
1202
- return yamlScalar(value);
1203
- }
1204
- if (typeof value === "number" || typeof value === "boolean") {
1205
- return String(value);
1206
- }
1207
- if (Array.isArray(value)) {
1208
- if (value.length === 0) {
1209
- return `${prefix}[]`;
1210
- }
1211
- return value
1212
- .map((item) => {
1213
- if (isScalar(item)) {
1214
- return `${prefix}- ${toYaml(item, indent + 2)}`;
1215
- }
1216
- return `${prefix}-\n${toYaml(item, indent + 2)}`;
1217
- })
1218
- .join("\n");
1219
- }
1220
- if (isPlainObject(value)) {
1221
- const entries = Object.entries(value).filter(
1222
- ([, entryValue]) => entryValue !== undefined,
1223
- );
1224
- if (entries.length === 0) {
1225
- return `${prefix}{}`;
1226
- }
1227
- return entries
1228
- .map(([key, entryValue]) => {
1229
- if (isScalar(entryValue)) {
1230
- return `${prefix}${key}: ${toYaml(entryValue, indent + 2)}`;
1231
- }
1232
- return `${prefix}${key}:\n${toYaml(entryValue, indent + 2)}`;
1233
- })
1234
- .join("\n");
1235
- }
1236
- return yamlScalar(JSON.stringify(value));
1237
- }
1238
-
1239
- function isScalar(value: unknown): value is boolean | number | string | null {
1240
- return (
1241
- value === null ||
1242
- typeof value === "string" ||
1243
- typeof value === "number" ||
1244
- typeof value === "boolean"
1245
- );
1246
- }
1247
-
1248
- function yamlScalar(value: string): string {
1249
- if (value.length === 0) {
1250
- return '""';
1251
- }
1252
- return JSON.stringify(value);
1253
- }
package/src/settings.ts CHANGED
@@ -24,6 +24,7 @@ import type {
24
24
  Warning,
25
25
  } from "./core/envelope.ts";
26
26
  import { buildTip } from "./core/envelope.ts";
27
+ import { toYaml } from "./core/yaml.ts";
27
28
  import { loadCdb, resolveDevicesSettings } from "./devices.ts";
28
29
  import { CentrsError, serializeCentrsError } from "./errors.ts";
29
30
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
@@ -37,7 +38,7 @@ import {
37
38
  readSettingsFileRaw,
38
39
  resolveOptionalIntegerSetting,
39
40
  } from "./resolver/index.ts";
40
- import { retrieveOutputFormats, toYaml } from "./retrieve.ts";
41
+ import { retrieveOutputFormats } from "./retrieve.ts";
41
42
 
42
43
  // ── Envelope shape ───────────────────────────────────────────────────────────
43
44
 
@@ -38,6 +38,7 @@ import {
38
38
  runFanout,
39
39
  summarizeFanout,
40
40
  } from "./core/fanout.ts";
41
+ import { toYaml } from "./core/yaml.ts";
41
42
  import { CentrsError, serializeCentrsError } from "./errors.ts";
42
43
  import { promptForWriteConfirmation } from "./execute.ts";
43
44
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
@@ -50,7 +51,6 @@ import {
50
51
  type SelectionResolveInput,
51
52
  type TargetSelection,
52
53
  } from "./resolver/index.ts";
53
- import { toYaml } from "./retrieve.ts";
54
54
  import {
55
55
  buildTransferErrorEnvelope,
56
56
  isTransferWriteVerb,
package/src/transfer.ts CHANGED
@@ -29,6 +29,7 @@ import type {
29
29
  EnvelopeValidationMeta,
30
30
  Warning,
31
31
  } from "./core/envelope.ts";
32
+ import { toYaml } from "./core/yaml.ts";
32
33
  import { CentrsError, serializeCentrsError } from "./errors.ts";
33
34
  import {
34
35
  createProtocolAdapter,
@@ -54,7 +55,6 @@ import {
54
55
  resolveTarget,
55
56
  toCoreSource,
56
57
  } from "./resolver/index.ts";
57
- import { toYaml } from "./retrieve.ts";
58
58
 
59
59
  export const transferOutputFormats = ["text", "json", "yaml"] as const;
60
60
  export type TransferOutputFormat = (typeof transferOutputFormats)[number];