@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
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The **script-vs-structured execution gate** and its canonical command shape.
3
+ *
4
+ * `execute.ts` owned this pair, which put every consumer of the gate — the MCP
5
+ * tool surface and `explain`'s offline analysis among them — one import away
6
+ * from the transport stack (`mac-telnet` -> `node:dgram`, `native-api` ->
7
+ * `node:crypto`). The functions themselves are pure string work, so they live in
8
+ * `src/core/` and `execute.ts` re-exports them: the public API, the locked
9
+ * contract table in `test/unit/execute-canonicalize-contract.test.ts`, and the
10
+ * gate's behavior are all unchanged by the move (#312).
11
+ *
12
+ * Nothing here reads settings, the filesystem, CDB, or a device. Changing that
13
+ * re-couples `explain`'s browser entry to the transport graph.
14
+ */
15
+
16
+ export interface CanonicalExecuteCommand {
17
+ mode: "structured" | "script";
18
+ input: string;
19
+ path: string;
20
+ verb: string;
21
+ attributes: Record<string, string>;
22
+ queries: string[];
23
+ }
24
+
25
+ /**
26
+ * The **script-vs-structured execution gate** — centrs's load-bearing
27
+ * discriminator (with {@link isWriteShaped}) for which validation runs and
28
+ * whether the write-confirmation prompt fires. centrs owns this gate; the shared,
29
+ * prose-tolerant, multi-command canonicalizer that `rosetta` / `lsp-routeros-ts`
30
+ * publish is for *canonicalization*, never the structured-mode predicate —
31
+ * widening what counts as `structured` is a product regression. Behavior is
32
+ * pinned by `test/unit/execute-canonicalize-contract.test.ts`.
33
+ *
34
+ * centrs deliberately does **not** vendor the shared parser yet. Preconditions
35
+ * for adopting it: (1) that contract stays green (no gate widening); (2) the
36
+ * vendored file is clean under centrs's strict `tsconfig` (or explicitly
37
+ * quarantined with justification); (3) `lsp-routeros-ts` also vendors/consumes
38
+ * the same parser shape, so it is genuinely shared, not a premature first copy.
39
+ */
40
+ export function canonicalizeExecuteCommand(
41
+ input: string,
42
+ ): CanonicalExecuteCommand {
43
+ const asScript = (): CanonicalExecuteCommand => ({
44
+ mode: "script",
45
+ input,
46
+ path: "",
47
+ verb: "",
48
+ attributes: {},
49
+ queries: [],
50
+ });
51
+
52
+ const trimmed = input.trim();
53
+ if (!trimmed.startsWith("/")) {
54
+ return asScript();
55
+ }
56
+
57
+ const tokens = tokenizeRouterOsCli(trimmed);
58
+ const pathToken = tokens[0] ?? "";
59
+ const pathParts = pathToken.split("/").filter(Boolean);
60
+ if (pathParts.length < 2) {
61
+ return asScript();
62
+ }
63
+
64
+ const verb = pathParts.at(-1) ?? "";
65
+ const path = `/${pathParts.slice(0, -1).join("/")}`;
66
+ const attributes: Record<string, string> = {};
67
+ const queries: string[] = [];
68
+ for (const token of tokens.slice(1)) {
69
+ // `[...]` subshell selectors (e.g. `numbers=[find ...]`) cannot be
70
+ // represented as a structured attribute map: the inner command contains
71
+ // spaces this tokenizer splits on, which would mangle a write-shaped
72
+ // command into corrupt key=value pairs. Fall back to the raw-script path
73
+ // so RouterOS evaluates the subshell with its real semantics.
74
+ if (token.includes("[") || token.includes("]")) {
75
+ return asScript();
76
+ }
77
+ if (token.startsWith("?")) {
78
+ queries.push(token);
79
+ continue;
80
+ }
81
+ const separator = token.indexOf("=");
82
+ if (separator <= 0) {
83
+ return asScript();
84
+ }
85
+ attributes[token.slice(0, separator)] = token.slice(separator + 1);
86
+ }
87
+
88
+ return {
89
+ mode: "structured",
90
+ input,
91
+ path,
92
+ verb,
93
+ attributes,
94
+ queries,
95
+ };
96
+ }
97
+
98
+ export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
99
+ return (
100
+ command.mode === "structured" &&
101
+ ["add", "set", "remove"].includes(command.verb)
102
+ );
103
+ }
104
+
105
+ function tokenizeRouterOsCli(input: string): string[] {
106
+ const tokens: string[] = [];
107
+ let current = "";
108
+ let quote: '"' | "'" | undefined;
109
+ let escaped = false;
110
+ for (const char of input) {
111
+ if (escaped) {
112
+ current += char;
113
+ escaped = false;
114
+ continue;
115
+ }
116
+ if (char === "\\") {
117
+ escaped = true;
118
+ continue;
119
+ }
120
+ if (quote) {
121
+ if (char === quote) {
122
+ quote = undefined;
123
+ } else {
124
+ current += char;
125
+ }
126
+ continue;
127
+ }
128
+ if (char === '"' || char === "'") {
129
+ quote = char;
130
+ continue;
131
+ }
132
+ if (/\s/.test(char)) {
133
+ if (current.length > 0) {
134
+ tokens.push(current);
135
+ current = "";
136
+ }
137
+ continue;
138
+ }
139
+ current += char;
140
+ }
141
+ if (escaped) {
142
+ current += "\\";
143
+ }
144
+ if (current.length > 0) {
145
+ tokens.push(current);
146
+ }
147
+ return tokens;
148
+ }
@@ -5,10 +5,16 @@
5
5
  * (path tokenizing, the comma-path join, node-type predicates, completion-name
6
6
  * extraction). This module is the single home so a third consumer — `api`, and a
7
7
  * future `explain`/`check` — reuses one grounded implementation instead of
8
- * forking a fourth. It deliberately holds only the transport-agnostic
9
- * *primitives*; each command composes its own discovery strategy on top (their
10
- * strategies differ: `retrieve` probes print/get support, `execute` unions
11
- * child-args with completions), so the strategy stays in the command.
8
+ * forking a fourth. It holds the transport-agnostic *primitives* plus the one
9
+ * discovery strategy that is shared verbatim: {@link inspectArgumentNames},
10
+ * command-level `request=child` only, used by `execute` and `api`. Strategies
11
+ * that differ stay in the command — `retrieve` probes print/get support and
12
+ * reads argument-level completion (`print,proplist` / `get,value-name`) with a
13
+ * child fallback.
14
+ *
15
+ * Command-level `request=completion` is not part of any argument-discovery
16
+ * strategy: on RouterOS 7.12.2 it hangs the REST handler and closes the
17
+ * native-API connection. See {@link inspectArgumentNames}.
12
18
  *
13
19
  * Grounding: the request modes mirror `tikoci/lsp-routeros-ts`
14
20
  * (`server/src/routeros.ts` `InspectRequest`). The behavioral facts (the array-
@@ -131,6 +137,32 @@ export async function inspectChildren(
131
137
  )) as InspectChildItem[];
132
138
  }
133
139
 
140
+ /**
141
+ * Return the argument names exposed by `request=child` at a command path.
142
+ *
143
+ * Do not supplement this with a command-level `request=completion`: RouterOS
144
+ * 7.12.2 hangs the REST handler and closes the native-API connection for that
145
+ * request shape, while `child` returns the complete argument list immediately.
146
+ * Argument-level completion (for example `print,proplist`) is a distinct probe
147
+ * and remains available through {@link inspectCompletions}.
148
+ */
149
+ export async function inspectArgumentNames(
150
+ backend: InspectBackend,
151
+ tokens: readonly string[],
152
+ ): Promise<string[]> {
153
+ const children = await inspectChildren(backend, tokens);
154
+ return [
155
+ ...new Set(
156
+ children
157
+ .filter(isArgumentNode)
158
+ .map((child) => child.name)
159
+ .filter(
160
+ (name): name is string => typeof name === "string" && name.length > 0,
161
+ ),
162
+ ),
163
+ ].sort();
164
+ }
165
+
134
166
  /** `request=completion` for a token path. */
135
167
  export async function inspectCompletions(
136
168
  backend: InspectBackend,
@@ -0,0 +1,4 @@
1
+ import packageMetadata from "../../package.json" with { type: "json" };
2
+
3
+ /** Package version reported by every frontend and the root library surface. */
4
+ export const centrsVersion = packageMetadata.version;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Envelope YAML rendering — the `--format yaml` writer shared by every command.
3
+ *
4
+ * This is a **renderer, not a YAML library**: it serializes the envelope shapes
5
+ * centrs builds (scalars, arrays, plain objects) and quotes every string through
6
+ * `JSON.stringify`, which is valid YAML double-quoted scalar syntax. It does not
7
+ * parse YAML, emit anchors/aliases, or handle cycles.
8
+ *
9
+ * It lives in `src/core/` because it is **pure** — no Bun, filesystem, network,
10
+ * or CDB reach. `retrieve.ts` used to own it, which put every consumer of
11
+ * `toYaml` one import away from the transport stack; `explain`'s offline entry
12
+ * runs in a browser only because this function no longer drags that graph
13
+ * behind it (#312). Every importer now takes `toYaml` from here; `retrieve.ts`
14
+ * is one of them, and the function itself moved character for character.
15
+ */
16
+
17
+ /**
18
+ * Render `value` as YAML at `indent` spaces of nesting.
19
+ *
20
+ * `undefined` object entries are dropped (they have no YAML spelling and the
21
+ * envelope uses them for absent-optional); `undefined` in scalar position and
22
+ * `null` both render `null`. A non-plain object that is neither array nor
23
+ * scalar falls back to its JSON text as a quoted scalar rather than guessing.
24
+ */
25
+ export function toYaml(value: unknown, indent = 0): string {
26
+ const prefix = " ".repeat(indent);
27
+ if (value === null || value === undefined) {
28
+ return "null";
29
+ }
30
+ if (typeof value === "string") {
31
+ return yamlScalar(value);
32
+ }
33
+ if (typeof value === "number" || typeof value === "boolean") {
34
+ return String(value);
35
+ }
36
+ if (Array.isArray(value)) {
37
+ if (value.length === 0) {
38
+ return `${prefix}[]`;
39
+ }
40
+ return value
41
+ .map((item) => {
42
+ if (isScalar(item)) {
43
+ return `${prefix}- ${toYaml(item, indent + 2)}`;
44
+ }
45
+ return `${prefix}-\n${toYaml(item, indent + 2)}`;
46
+ })
47
+ .join("\n");
48
+ }
49
+ if (isPlainObject(value)) {
50
+ const entries = Object.entries(value).filter(
51
+ ([, entryValue]) => entryValue !== undefined,
52
+ );
53
+ if (entries.length === 0) {
54
+ return `${prefix}{}`;
55
+ }
56
+ return entries
57
+ .map(([key, entryValue]) => {
58
+ if (isScalar(entryValue)) {
59
+ return `${prefix}${key}: ${toYaml(entryValue, indent + 2)}`;
60
+ }
61
+ return `${prefix}${key}:\n${toYaml(entryValue, indent + 2)}`;
62
+ })
63
+ .join("\n");
64
+ }
65
+ return yamlScalar(JSON.stringify(value));
66
+ }
67
+
68
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
69
+ return typeof value === "object" && value !== null && !Array.isArray(value);
70
+ }
71
+
72
+ function isScalar(value: unknown): value is boolean | number | string | null {
73
+ return (
74
+ value === null ||
75
+ typeof value === "string" ||
76
+ typeof value === "number" ||
77
+ typeof value === "boolean"
78
+ );
79
+ }
80
+
81
+ function yamlScalar(value: string): string {
82
+ if (value.length === 0) {
83
+ return '""';
84
+ }
85
+ return JSON.stringify(value);
86
+ }
@@ -26,6 +26,7 @@ import type {
26
26
  CommonSettingsMeta,
27
27
  FanoutData,
28
28
  FanoutSummary,
29
+ ValidationTrace,
29
30
  } from "./core/envelope.ts";
30
31
  import {
31
32
  buildFanoutEnvelope,
@@ -36,6 +37,7 @@ import {
36
37
  runFanout,
37
38
  summarizeFanout,
38
39
  } from "./core/fanout.ts";
40
+ import { toYaml } from "./core/yaml.ts";
39
41
  import { CentrsError, serializeCentrsError } from "./errors.ts";
40
42
  import {
41
43
  buildExecuteErrorEnvelopeFromResolved,
@@ -60,7 +62,6 @@ import {
60
62
  type SelectionResolveInput,
61
63
  type TargetSelection,
62
64
  } from "./resolver/index.ts";
63
- import { toYaml } from "./retrieve.ts";
64
65
 
65
66
  /** Compact description of the selector that produced the fan-out. */
66
67
  export interface ExecuteSelectionSummary {
@@ -233,6 +234,11 @@ export async function executeFanout(
233
234
  await assertFanoutWriteConfirmed(request, global.summary, expansion);
234
235
 
235
236
  const execute = internals.execute ?? runResolvedExecute;
237
+ // Per-target validation trace. `execute` and `onExecuteError` are separate
238
+ // callbacks that share only the resolved request, so the request object is
239
+ // the key: without it a per-target error envelope would reconstruct
240
+ // `meta.validation` instead of reporting the gate that actually ran (GH#354).
241
+ const traces = new WeakMap<ResolvedExecuteRequest, ValidationTrace>();
236
242
  const sleep = internals.sleep ?? defaultFanoutSleep;
237
243
 
238
244
  const targets = await runFanout<
@@ -282,11 +288,19 @@ export async function executeFanout(
282
288
  warnings: member.kind === "cdb" ? [...member.resolution.warnings] : [],
283
289
  summary: "Failed to resolve a fanout target.",
284
290
  }),
285
- execute: async (resolved, member) =>
286
- relabelAdhocMember(await execute(resolved), member),
291
+ execute: async (resolved, member) => {
292
+ const trace: ValidationTrace = {};
293
+ traces.set(resolved, trace);
294
+ return relabelAdhocMember(await execute(resolved, trace), member);
295
+ },
287
296
  onExecuteError: (resolved, member, error) =>
288
297
  relabelAdhocMember(
289
- buildExecuteErrorEnvelopeFromResolved(resolved, error),
298
+ buildExecuteErrorEnvelopeFromResolved(
299
+ resolved,
300
+ error,
301
+ undefined,
302
+ traces.get(resolved),
303
+ ),
290
304
  member,
291
305
  ),
292
306
  sleep,