@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.
- package/CHANGELOG.md +309 -0
- package/GLOSSARY.txt +40 -1
- package/README.md +8 -4
- package/commands/AGENTS.md +7 -4
- package/commands/api/AGENTS.md +13 -1
- package/commands/api/README.md +37 -8
- package/commands/api/examples.md +31 -1
- package/commands/execute/README.md +39 -10
- package/commands/execute/examples.md +79 -7
- package/commands/explain/README.md +1195 -155
- package/commands/explain/examples.md +99 -3
- package/commands/mcp/README.md +7 -4
- package/commands/mcp/examples.md +2 -2
- package/commands/terminal/examples.md +7 -1
- package/docs/CLI.md +2 -2
- package/docs/CONSTITUTION.md +72 -15
- package/docs/MATRIX.md +89 -52
- package/docs/errors/validation/syntax.md +29 -4
- package/package.json +13 -2
- package/src/api-fanout.ts +17 -4
- package/src/api.ts +254 -67
- package/src/cli/api.ts +1 -1
- package/src/cli/btest.ts +1 -1
- package/src/cli/explain.ts +1 -1
- package/src/cli/terminal.ts +1 -1
- package/src/core/envelope.ts +53 -0
- package/src/core/error-catalog.ts +2 -1
- package/src/core/execute-command.ts +148 -0
- package/src/core/inspect.ts +36 -4
- package/src/core/version.ts +4 -0
- package/src/core/yaml.ts +86 -0
- package/src/execute-fanout.ts +18 -4
- package/src/execute.ts +225 -244
- package/src/explain/arg-tokens.ts +62 -25
- package/src/explain/args.ts +398 -52
- package/src/explain/blocks.ts +54 -11
- package/src/explain/brace-tokens.ts +4 -3
- package/src/explain/catalog.ts +42 -44
- package/src/explain/coordinates.ts +159 -0
- package/src/explain/defects.ts +2 -1
- package/src/explain/escape-tokens.ts +77 -0
- package/src/explain/highlight-projection.ts +308 -0
- package/src/explain/menus.ts +9 -5
- package/src/explain/operator-tokens.ts +13 -7
- package/src/explain/operators.ts +27 -20
- package/src/explain/path-tokens.ts +1 -1
- package/src/explain/pathresolve.ts +358 -113
- package/src/explain/quoted-string.ts +110 -20
- package/src/explain/scope-brace.ts +175 -22
- package/src/explain/segment.ts +429 -21
- package/src/explain/separator.ts +187 -0
- package/src/explain/split-owner.ts +194 -0
- package/src/explain/string-tokens.ts +14 -4
- package/src/explain/symbol-values.ts +209 -173
- package/src/explain/symbols.ts +21 -1
- package/src/explain/transport.ts +17 -0
- package/src/explain/value-tokens.ts +15 -12
- package/src/explain/verbsplit.ts +165 -23
- package/src/explain/write.ts +47 -6
- package/src/explain.ts +522 -72
- package/src/index.ts +19 -2
- package/src/mcp/server.ts +2 -1
- package/src/offline-gate.ts +148 -0
- package/src/retrieve-fanout.ts +1 -1
- package/src/retrieve.ts +1 -59
- package/src/settings.ts +2 -1
- package/src/transfer-fanout.ts +1 -1
- 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
|
+
}
|
package/src/core/inspect.ts
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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,
|
package/src/core/yaml.ts
ADDED
|
@@ -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
|
+
}
|
package/src/execute-fanout.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
298
|
+
buildExecuteErrorEnvelopeFromResolved(
|
|
299
|
+
resolved,
|
|
300
|
+
error,
|
|
301
|
+
undefined,
|
|
302
|
+
traces.get(resolved),
|
|
303
|
+
),
|
|
290
304
|
member,
|
|
291
305
|
),
|
|
292
306
|
sleep,
|