@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
package/src/api-fanout.ts
CHANGED
|
@@ -39,6 +39,7 @@ import type {
|
|
|
39
39
|
CentrsSuccessEnvelope,
|
|
40
40
|
FanoutData,
|
|
41
41
|
FanoutSummary,
|
|
42
|
+
ValidationTrace,
|
|
42
43
|
} from "./core/envelope.ts";
|
|
43
44
|
import {
|
|
44
45
|
buildFanoutEnvelope,
|
|
@@ -49,6 +50,7 @@ import {
|
|
|
49
50
|
runFanout,
|
|
50
51
|
summarizeFanout,
|
|
51
52
|
} from "./core/fanout.ts";
|
|
53
|
+
import { toYaml } from "./core/yaml.ts";
|
|
52
54
|
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
53
55
|
import { promptForWriteConfirmation } from "./execute.ts";
|
|
54
56
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
@@ -61,7 +63,6 @@ import {
|
|
|
61
63
|
type SelectionResolveInput,
|
|
62
64
|
type TargetSelection,
|
|
63
65
|
} from "./resolver/index.ts";
|
|
64
|
-
import { toYaml } from "./retrieve.ts";
|
|
65
66
|
|
|
66
67
|
/** Compact description of the selector that produced the fan-out. */
|
|
67
68
|
export interface ApiSelectionSummary {
|
|
@@ -246,6 +247,11 @@ export async function apiFanout(
|
|
|
246
247
|
await assertFanoutWriteConfirmed(request, requestSummary, method, expansion);
|
|
247
248
|
|
|
248
249
|
const execute = internals.execute ?? runResolvedApi;
|
|
250
|
+
// Per-target validation trace. `execute` and `onExecuteError` are separate
|
|
251
|
+
// callbacks that share only the resolved request, so the request object is
|
|
252
|
+
// the key: without it a per-target error envelope would reconstruct
|
|
253
|
+
// `meta.validation` instead of reporting the gate that actually ran (GH#354).
|
|
254
|
+
const traces = new WeakMap<ResolvedApiRequest, ValidationTrace>();
|
|
249
255
|
const sleep = internals.sleep ?? defaultFanoutSleep;
|
|
250
256
|
|
|
251
257
|
const targets = await runFanout<
|
|
@@ -292,11 +298,18 @@ export async function apiFanout(
|
|
|
292
298
|
target: memberTargetMeta(member),
|
|
293
299
|
warnings: member.kind === "cdb" ? [...member.resolution.warnings] : [],
|
|
294
300
|
}),
|
|
295
|
-
execute: async (resolved, member) =>
|
|
296
|
-
|
|
301
|
+
execute: async (resolved, member) => {
|
|
302
|
+
const trace: ValidationTrace = {};
|
|
303
|
+
traces.set(resolved, trace);
|
|
304
|
+
return relabelAdhocMember(await execute(resolved, trace), member);
|
|
305
|
+
},
|
|
297
306
|
onExecuteError: (resolved, member, error) =>
|
|
298
307
|
relabelAdhocMember(
|
|
299
|
-
buildApiErrorEnvelopeFromResolved(
|
|
308
|
+
buildApiErrorEnvelopeFromResolved(
|
|
309
|
+
resolved,
|
|
310
|
+
error,
|
|
311
|
+
traces.get(resolved),
|
|
312
|
+
),
|
|
300
313
|
member,
|
|
301
314
|
),
|
|
302
315
|
sleep,
|
package/src/api.ts
CHANGED
|
@@ -21,21 +21,26 @@ import type {
|
|
|
21
21
|
CommonSettingsMeta,
|
|
22
22
|
SettingSource as CoreSettingSource,
|
|
23
23
|
EnvelopeValidationMeta,
|
|
24
|
+
EnvelopeValidationStage,
|
|
24
25
|
Tip,
|
|
26
|
+
ValidationTrace,
|
|
25
27
|
Warning,
|
|
26
28
|
} from "./core/envelope.ts";
|
|
27
29
|
import { buildTip } from "./core/envelope.ts";
|
|
28
30
|
import {
|
|
29
|
-
|
|
30
|
-
inspectChildren,
|
|
31
|
+
inspectArgumentNames,
|
|
31
32
|
inspectChildrenOrEmpty,
|
|
32
|
-
inspectCompletions,
|
|
33
|
-
isArgumentNode,
|
|
34
33
|
isCommandNode,
|
|
35
34
|
pathTokens,
|
|
36
35
|
} from "./core/inspect.ts";
|
|
36
|
+
import { toYaml } from "./core/yaml.ts";
|
|
37
37
|
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
38
38
|
import { promptForWriteConfirmation } from "./execute.ts";
|
|
39
|
+
import {
|
|
40
|
+
assertOfflineSyntax,
|
|
41
|
+
isOfflineGateRejection,
|
|
42
|
+
OFFLINE_GATE_SOURCE,
|
|
43
|
+
} from "./offline-gate.ts";
|
|
39
44
|
import {
|
|
40
45
|
type ApiVerb,
|
|
41
46
|
createProtocolAdapter,
|
|
@@ -64,7 +69,6 @@ import {
|
|
|
64
69
|
resolveTarget,
|
|
65
70
|
toCoreSource,
|
|
66
71
|
} from "./resolver/index.ts";
|
|
67
|
-
import { toYaml } from "./retrieve.ts";
|
|
68
72
|
|
|
69
73
|
export const apiOutputFormats = ["json", "yaml", "text"] as const;
|
|
70
74
|
export type ApiOutputFormat = (typeof apiOutputFormats)[number];
|
|
@@ -97,7 +101,11 @@ export interface ApiRequest {
|
|
|
97
101
|
rawQuery?: readonly string[];
|
|
98
102
|
/** `--proplist` / `--attribute` property projection. */
|
|
99
103
|
proplist?: readonly string[];
|
|
100
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* `--raw`: bare RouterOS body passthrough. DEFAULTS `--validate` to false; an
|
|
106
|
+
* explicit `validate` still wins, and its failures render through the `--raw`
|
|
107
|
+
* error contract (#154).
|
|
108
|
+
*/
|
|
101
109
|
raw?: boolean;
|
|
102
110
|
/** `--listen`: native-api-only open-ended follow (later phase; rejected here). */
|
|
103
111
|
listen?: boolean;
|
|
@@ -124,6 +132,23 @@ export interface ApiRequest {
|
|
|
124
132
|
confirm?: (prompt: string) => Promise<boolean>;
|
|
125
133
|
}
|
|
126
134
|
|
|
135
|
+
/**
|
|
136
|
+
* The `--raw` / `--validate` precedence for a summary built BEFORE (or instead
|
|
137
|
+
* of) full resolution — a usage error, or a fan-out member that never reached
|
|
138
|
+
* `resolveApiRequest`.
|
|
139
|
+
*
|
|
140
|
+
* Ambient sources are deliberately out of reach here: this runs where the CDB
|
|
141
|
+
* and config tiers have not been loaded, so it reports the two layers it CAN
|
|
142
|
+
* see. That is why it is a shared helper rather than an inlined expression —
|
|
143
|
+
* both call sites used to spell `request.raw ? false : …`, which contradicted
|
|
144
|
+
* the resolved request by reporting `validate: false` for an explicit
|
|
145
|
+
* `--raw --validate=true` (#154).
|
|
146
|
+
*/
|
|
147
|
+
function summarizedValidate(request: ApiRequest): boolean {
|
|
148
|
+
if (request.validate !== undefined) return request.validate;
|
|
149
|
+
return !(request.raw ?? false);
|
|
150
|
+
}
|
|
151
|
+
|
|
127
152
|
export interface NormalizedApiEndpoint {
|
|
128
153
|
/** Canonical slash path with the leading `/rest` and any trailing id/`listen` removed. */
|
|
129
154
|
path: string;
|
|
@@ -247,19 +272,25 @@ export async function apiEnvelope(
|
|
|
247
272
|
env: Record<string, string | undefined> = Bun.env,
|
|
248
273
|
): Promise<ApiEnvelope> {
|
|
249
274
|
let resolved: ResolvedApiRequest | undefined;
|
|
275
|
+
// The gate finishes inside `runResolvedApi`, but the error envelope is built
|
|
276
|
+
// out here after the throw has unwound past it. Without the trace, a
|
|
277
|
+
// `transport/*` or `routeros/*` fault on the request — both stages already
|
|
278
|
+
// settled — reported `/console/inspect` as the validator that failed.
|
|
279
|
+
const trace: ValidationTrace = {};
|
|
250
280
|
try {
|
|
251
281
|
resolved = await resolveApiRequest(request, env);
|
|
252
282
|
await assertApiWriteConfirmed(request, resolved);
|
|
253
|
-
return await runResolvedApi(resolved);
|
|
283
|
+
return await runResolvedApi(resolved, trace);
|
|
254
284
|
} catch (error) {
|
|
255
285
|
return resolved
|
|
256
|
-
? buildApiErrorEnvelopeFromResolved(resolved, error)
|
|
286
|
+
? buildApiErrorEnvelopeFromResolved(resolved, error, trace)
|
|
257
287
|
: buildApiErrorEnvelope(request, error, env);
|
|
258
288
|
}
|
|
259
289
|
}
|
|
260
290
|
|
|
261
291
|
export async function runResolvedApi(
|
|
262
292
|
resolved: ResolvedApiRequest,
|
|
293
|
+
trace: ValidationTrace = {},
|
|
263
294
|
): Promise<ApiSuccessEnvelope> {
|
|
264
295
|
assertListenCapability(resolved);
|
|
265
296
|
const backend = createProtocolAdapter({
|
|
@@ -279,18 +310,15 @@ export async function runResolvedApi(
|
|
|
279
310
|
let tips: Tip[] = [];
|
|
280
311
|
try {
|
|
281
312
|
if (resolved.validate.value) {
|
|
282
|
-
const result = await validateApiRequest(resolved, backend);
|
|
313
|
+
const result = await validateApiRequest(resolved, backend, trace);
|
|
283
314
|
validation = result.validation;
|
|
284
315
|
tips = result.tips;
|
|
285
316
|
} else {
|
|
286
|
-
validation =
|
|
287
|
-
enabled: false,
|
|
288
|
-
source: "disabled",
|
|
289
|
-
result: "skipped",
|
|
290
|
-
syntax: false,
|
|
291
|
-
semantic: false,
|
|
292
|
-
};
|
|
317
|
+
validation = disabledApiValidationMeta(resolved);
|
|
293
318
|
}
|
|
319
|
+
// Recorded BEFORE the request, so a transport drop or RouterOS fault below
|
|
320
|
+
// reports the gate that actually settled rather than a guess.
|
|
321
|
+
trace.validation = validation;
|
|
294
322
|
|
|
295
323
|
const result = await backend.apiRequest(buildProtocolApiRequest(resolved));
|
|
296
324
|
return {
|
|
@@ -574,19 +602,40 @@ export async function resolveApiRequest(
|
|
|
574
602
|
)));
|
|
575
603
|
const via = resolveApiProtocol(request, env, listen, cdbResolution, config);
|
|
576
604
|
const format = resolveApiFormat(request, env, config);
|
|
577
|
-
// `--raw`
|
|
578
|
-
//
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
605
|
+
// `--raw` is a PRECEDENCE LAYER, not an override (#154). It ranks below an
|
|
606
|
+
// explicit `--validate` and above every ambient source, so:
|
|
607
|
+
//
|
|
608
|
+
// --validate=true|false explicit CLI flag ← wins
|
|
609
|
+
// --raw → validate=false
|
|
610
|
+
// CENTRS_VALIDATE / CDB comment-kv / config
|
|
611
|
+
// default → validate=true
|
|
612
|
+
//
|
|
613
|
+
// It used to short-circuit the whole ladder, which silently discarded an
|
|
614
|
+
// explicit `--validate=true` — and env, CDB and config with it. `--raw
|
|
615
|
+
// --validate=true` is the intended way to debug `api` when centrs itself is
|
|
616
|
+
// suspect: the gate runs and a failure renders through `rawErrorPayload` as
|
|
617
|
+
// `{code,message}` on stderr with a nonzero exit, which is the `--raw` error
|
|
618
|
+
// contract for any other preflight failure. `--raw` still outranks the
|
|
619
|
+
// ambient sources rather than merely lowering the default, so it behaves the
|
|
620
|
+
// same on every machine regardless of a `CENTRS_VALIDATE` in the environment.
|
|
621
|
+
//
|
|
622
|
+
// The `--raw` arm must SHORT-CIRCUIT, not merely win a comparison: resolving
|
|
623
|
+
// the ambient layer first would parse `CENTRS_VALIDATE` even when `--raw`
|
|
624
|
+
// decides the value, so a malformed ambient value threw `settings/invalid-
|
|
625
|
+
// boolean` out of a call `--raw` had already settled — the same
|
|
626
|
+
// machine-dependence this layer exists to remove.
|
|
627
|
+
const validate: ResolvedSetting<boolean> =
|
|
628
|
+
raw && request.validate === undefined
|
|
629
|
+
? { value: false, source: { kind: "cli", key: "--raw" } }
|
|
630
|
+
: resolveBooleanSetting(
|
|
631
|
+
request.validate,
|
|
632
|
+
env,
|
|
633
|
+
"CENTRS_VALIDATE",
|
|
634
|
+
true,
|
|
635
|
+
"validate",
|
|
636
|
+
cdbResolution?.overrides.validate,
|
|
637
|
+
config,
|
|
638
|
+
);
|
|
590
639
|
const timeoutMs = resolveApiTimeout(
|
|
591
640
|
request.timeout,
|
|
592
641
|
env,
|
|
@@ -906,6 +955,9 @@ export function buildProtocolApiRequest(
|
|
|
906
955
|
return request;
|
|
907
956
|
}
|
|
908
957
|
|
|
958
|
+
/** The api device-stage validator: structured input is inspected, never `:parse`d. */
|
|
959
|
+
const API_DEVICE_STAGE_SOURCE = "/console/inspect request=child";
|
|
960
|
+
|
|
909
961
|
interface ApiValidationResult {
|
|
910
962
|
validation: EnvelopeValidationMeta;
|
|
911
963
|
tips: Tip[];
|
|
@@ -913,22 +965,58 @@ interface ApiValidationResult {
|
|
|
913
965
|
|
|
914
966
|
/**
|
|
915
967
|
* The structured-input gate. Because the input is a path (not a CLI string) the
|
|
916
|
-
* validator is `/console/inspect`, never `:put [:parse]`. Path existence
|
|
917
|
-
*
|
|
968
|
+
* validator is `/console/inspect`, never `:put [:parse]`. Path existence and
|
|
969
|
+
* add/set attribute validity both come from `request=child` — command-level
|
|
970
|
+
* `request=completion` is unsafe on RouterOS 7.12.2 (`core/inspect.ts`). A
|
|
918
971
|
* `/execute` script is a CLI string → `semantic: not-applicable`.
|
|
919
972
|
*/
|
|
920
973
|
async function validateApiRequest(
|
|
921
974
|
resolved: ResolvedApiRequest,
|
|
922
975
|
backend: ProtocolAdapter,
|
|
976
|
+
trace: ValidationTrace = {},
|
|
923
977
|
): Promise<ApiValidationResult> {
|
|
924
978
|
if (resolved.scriptMode) {
|
|
979
|
+
// `/execute` carries a CLI string, so `/console/inspect` has nothing to
|
|
980
|
+
// inspect — which until GH#354 meant the script was sent with NO gate at
|
|
981
|
+
// all (`syntax: false`, `semantic: "not-applicable"`, `result: "passed"`).
|
|
982
|
+
// The offline analyzer is the whole gate here: a syntax fault is now a
|
|
983
|
+
// byte span and no round trip, where before it reached the device.
|
|
984
|
+
const script = scriptOf(resolved);
|
|
985
|
+
const offline = script
|
|
986
|
+
? assertOfflineSyntax(script, {
|
|
987
|
+
surface: "api /execute",
|
|
988
|
+
via: resolved.via.value,
|
|
989
|
+
})
|
|
990
|
+
: undefined;
|
|
991
|
+
trace.offlineVerdict = offline?.verdict;
|
|
992
|
+
const offlineReason =
|
|
993
|
+
offline === undefined
|
|
994
|
+
? "no `script` field to analyze"
|
|
995
|
+
: offline.verdict === "warn"
|
|
996
|
+
? "analyzer abstained on part of the input"
|
|
997
|
+
: undefined;
|
|
925
998
|
return {
|
|
926
999
|
validation: {
|
|
927
1000
|
enabled: true,
|
|
928
|
-
source:
|
|
1001
|
+
source: offline === undefined ? "not-applicable" : OFFLINE_GATE_SOURCE,
|
|
929
1002
|
result: "passed",
|
|
930
|
-
syntax:
|
|
1003
|
+
syntax: offline !== undefined,
|
|
931
1004
|
semantic: "not-applicable",
|
|
1005
|
+
stages: [
|
|
1006
|
+
{
|
|
1007
|
+
stage: "offline",
|
|
1008
|
+
source: OFFLINE_GATE_SOURCE,
|
|
1009
|
+
result: offline === undefined ? "skipped" : "passed",
|
|
1010
|
+
...(offlineReason ? { reason: offlineReason } : {}),
|
|
1011
|
+
},
|
|
1012
|
+
{
|
|
1013
|
+
stage: "device",
|
|
1014
|
+
source: "/console/inspect request=child",
|
|
1015
|
+
result: "skipped",
|
|
1016
|
+
reason:
|
|
1017
|
+
"`/execute` is a CLI string, not a menu path; RouterOS re-validates it on the run",
|
|
1018
|
+
},
|
|
1019
|
+
],
|
|
932
1020
|
},
|
|
933
1021
|
tips: [],
|
|
934
1022
|
};
|
|
@@ -970,7 +1058,7 @@ async function validateApiRequest(
|
|
|
970
1058
|
// Only reject when inspect actually surfaced the command's arguments; if it
|
|
971
1059
|
// returns none, skip rather than false-reject a valid command.
|
|
972
1060
|
if (isCommand && Object.keys(resolved.body).length > 0) {
|
|
973
|
-
const available = await
|
|
1061
|
+
const available = await inspectArgumentNames(backend, tokens);
|
|
974
1062
|
if (available.length > 0) {
|
|
975
1063
|
const requested = Object.keys(resolved.body);
|
|
976
1064
|
const missing = requested.filter(
|
|
@@ -988,7 +1076,7 @@ async function validateApiRequest(
|
|
|
988
1076
|
parameter: missing[0],
|
|
989
1077
|
requestedAttributes: requested,
|
|
990
1078
|
availableAttributes: available,
|
|
991
|
-
validationSource: "/console/inspect request=child
|
|
1079
|
+
validationSource: "/console/inspect request=child",
|
|
992
1080
|
},
|
|
993
1081
|
});
|
|
994
1082
|
}
|
|
@@ -1013,7 +1101,7 @@ async function validateApiRequest(
|
|
|
1013
1101
|
}
|
|
1014
1102
|
|
|
1015
1103
|
if (resolved.verb === "add" || resolved.verb === "set") {
|
|
1016
|
-
const available = await
|
|
1104
|
+
const available = await inspectArgumentNames(backend, [
|
|
1017
1105
|
...tokens,
|
|
1018
1106
|
resolved.verb,
|
|
1019
1107
|
]);
|
|
@@ -1033,14 +1121,14 @@ async function validateApiRequest(
|
|
|
1033
1121
|
parameter: missing[0],
|
|
1034
1122
|
requestedAttributes: requested,
|
|
1035
1123
|
availableAttributes: available,
|
|
1036
|
-
validationSource: "/console/inspect request=child
|
|
1124
|
+
validationSource: "/console/inspect request=child",
|
|
1037
1125
|
},
|
|
1038
1126
|
});
|
|
1039
1127
|
}
|
|
1040
1128
|
return {
|
|
1041
1129
|
validation: {
|
|
1042
1130
|
enabled: true,
|
|
1043
|
-
source: "/console/inspect request=child
|
|
1131
|
+
source: "/console/inspect request=child",
|
|
1044
1132
|
result: "passed",
|
|
1045
1133
|
syntax: false,
|
|
1046
1134
|
semantic: true,
|
|
@@ -1062,6 +1150,21 @@ async function validateApiRequest(
|
|
|
1062
1150
|
};
|
|
1063
1151
|
}
|
|
1064
1152
|
|
|
1153
|
+
/**
|
|
1154
|
+
* The `/execute` script body, when there is one to analyze.
|
|
1155
|
+
*
|
|
1156
|
+
* A missing or blank `script` is not this gate's error to raise:
|
|
1157
|
+
* {@link buildProtocolApiRequest} already rejects it with an actionable
|
|
1158
|
+
* `input/invalid-command` naming the three ways to pass one. Reporting a syntax
|
|
1159
|
+
* fault for an absent field would be the wrong diagnosis.
|
|
1160
|
+
*/
|
|
1161
|
+
function scriptOf(resolved: ResolvedApiRequest): string | undefined {
|
|
1162
|
+
const script = resolved.body["script"];
|
|
1163
|
+
return typeof script === "string" && script.trim().length > 0
|
|
1164
|
+
? script
|
|
1165
|
+
: undefined;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1065
1168
|
function unknownPathError(resolved: ResolvedApiRequest): CentrsError {
|
|
1066
1169
|
return new CentrsError({
|
|
1067
1170
|
code: "validation/unknown-path",
|
|
@@ -1076,23 +1179,6 @@ function unknownPathError(resolved: ResolvedApiRequest): CentrsError {
|
|
|
1076
1179
|
});
|
|
1077
1180
|
}
|
|
1078
1181
|
|
|
1079
|
-
async function inspectApiAttributes(
|
|
1080
|
-
backend: ProtocolAdapter,
|
|
1081
|
-
commandTokens: readonly string[],
|
|
1082
|
-
): Promise<string[]> {
|
|
1083
|
-
const children = await inspectChildren(backend, commandTokens);
|
|
1084
|
-
const childAttributes = children
|
|
1085
|
-
.filter(isArgumentNode)
|
|
1086
|
-
.map((child) => child.name)
|
|
1087
|
-
.filter(
|
|
1088
|
-
(name): name is string => typeof name === "string" && name.length > 0,
|
|
1089
|
-
);
|
|
1090
|
-
const completionRows = await inspectCompletions(backend, commandTokens);
|
|
1091
|
-
return [
|
|
1092
|
-
...new Set([...childAttributes, ...extractCompletionNames(completionRows)]),
|
|
1093
|
-
].sort();
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
1182
|
async function assertApiWriteConfirmed(
|
|
1097
1183
|
request: ApiRequest,
|
|
1098
1184
|
resolved: ResolvedApiRequest,
|
|
@@ -1299,7 +1385,7 @@ export function buildApiErrorEnvelope(
|
|
|
1299
1385
|
: false,
|
|
1300
1386
|
listen: request.listen ?? false,
|
|
1301
1387
|
yes: request.yes ?? false,
|
|
1302
|
-
validate:
|
|
1388
|
+
validate: summarizedValidate(request),
|
|
1303
1389
|
raw: request.raw ?? false,
|
|
1304
1390
|
format: resolveErrorFormat(request, env),
|
|
1305
1391
|
},
|
|
@@ -1315,6 +1401,7 @@ export function buildApiErrorEnvelope(
|
|
|
1315
1401
|
export function buildApiErrorEnvelopeFromResolved(
|
|
1316
1402
|
resolved: ResolvedApiRequest,
|
|
1317
1403
|
error: unknown,
|
|
1404
|
+
trace?: ValidationTrace,
|
|
1318
1405
|
): ApiErrorEnvelope {
|
|
1319
1406
|
const centrsError = asApiError(error);
|
|
1320
1407
|
return {
|
|
@@ -1322,17 +1409,117 @@ export function buildApiErrorEnvelopeFromResolved(
|
|
|
1322
1409
|
error: serializeCentrsError(centrsError),
|
|
1323
1410
|
warnings: [...resolved.warnings],
|
|
1324
1411
|
tips: [],
|
|
1325
|
-
meta: metaFromResolved(
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1412
|
+
meta: metaFromResolved(
|
|
1413
|
+
resolved,
|
|
1414
|
+
trace?.validation ??
|
|
1415
|
+
failedApiValidationMeta(resolved, centrsError, trace),
|
|
1416
|
+
),
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* `meta.validation` for an api error envelope. A stage-1 rejection opened no
|
|
1422
|
+
* connection, so the meta must not report `/console/inspect` as the validator
|
|
1423
|
+
* that failed (GH#354).
|
|
1424
|
+
*/
|
|
1425
|
+
function failedApiValidationMeta(
|
|
1426
|
+
resolved: ResolvedApiRequest,
|
|
1427
|
+
error: CentrsError,
|
|
1428
|
+
trace?: ValidationTrace,
|
|
1429
|
+
): EnvelopeValidationMeta {
|
|
1430
|
+
if (!resolved.validate.value) {
|
|
1431
|
+
return disabledApiValidationMeta(resolved);
|
|
1432
|
+
}
|
|
1433
|
+
if (isOfflineGateRejection(error)) {
|
|
1434
|
+
return {
|
|
1435
|
+
enabled: true,
|
|
1436
|
+
source: OFFLINE_GATE_SOURCE,
|
|
1437
|
+
result: "failed",
|
|
1329
1438
|
syntax: false,
|
|
1330
|
-
semantic:
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1439
|
+
semantic: "not-applicable",
|
|
1440
|
+
stages: [
|
|
1441
|
+
{ stage: "offline", source: OFFLINE_GATE_SOURCE, result: "failed" },
|
|
1442
|
+
{
|
|
1443
|
+
stage: "device",
|
|
1444
|
+
source: API_DEVICE_STAGE_SOURCE,
|
|
1445
|
+
result: "skipped",
|
|
1446
|
+
reason: "offline analysis rejected the script; no connection opened",
|
|
1447
|
+
},
|
|
1448
|
+
],
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
// `stages` is only asserted when a `validation/*` error identifies the device
|
|
1452
|
+
// stage as the rejecter. Everything downstream — a RouterOS fault on the
|
|
1453
|
+
// request, a transport drop — passed the gate and is served by
|
|
1454
|
+
// `trace.validation` before this function is ever reached.
|
|
1455
|
+
const deviceRejected = error.code.startsWith("validation/");
|
|
1456
|
+
return {
|
|
1457
|
+
enabled: true,
|
|
1458
|
+
source: "/console/inspect",
|
|
1459
|
+
result: "failed",
|
|
1460
|
+
syntax: false,
|
|
1461
|
+
semantic: resolved.scriptMode ? "not-applicable" : false,
|
|
1462
|
+
...(deviceRejected
|
|
1463
|
+
? { stages: [offlineApiStage(resolved, trace), deviceStageFailed()] }
|
|
1464
|
+
: {}),
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* The offline-stage entry for an api gate that reached the device stage.
|
|
1470
|
+
*
|
|
1471
|
+
* Script mode is the only api shape stage 1 analyzes; a structured path request
|
|
1472
|
+
* is a path plus a body, not a CLI string, so its offline stage is `skipped`
|
|
1473
|
+
* with the reason spelled out rather than silently reported as passed.
|
|
1474
|
+
*/
|
|
1475
|
+
function offlineApiStage(
|
|
1476
|
+
resolved: ResolvedApiRequest,
|
|
1477
|
+
trace?: ValidationTrace,
|
|
1478
|
+
): EnvelopeValidationStage {
|
|
1479
|
+
if (!resolved.scriptMode) {
|
|
1480
|
+
return {
|
|
1481
|
+
stage: "offline",
|
|
1482
|
+
source: OFFLINE_GATE_SOURCE,
|
|
1483
|
+
result: "skipped",
|
|
1484
|
+
reason: "structured path request, not a CLI string (GH#354)",
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
return {
|
|
1488
|
+
stage: "offline",
|
|
1489
|
+
source: OFFLINE_GATE_SOURCE,
|
|
1490
|
+
result: "passed",
|
|
1491
|
+
...(trace?.offlineVerdict === "warn"
|
|
1492
|
+
? { reason: "analyzer abstained on part of the input" }
|
|
1493
|
+
: {}),
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function deviceStageFailed(): EnvelopeValidationStage {
|
|
1498
|
+
return {
|
|
1499
|
+
stage: "device",
|
|
1500
|
+
source: API_DEVICE_STAGE_SOURCE,
|
|
1501
|
+
result: "failed",
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* `--validate=false` (including a bare `--raw`). Both stages are listed as
|
|
1507
|
+
* `skipped` rather than omitted, so a consumer reading `stages` never has to
|
|
1508
|
+
* special-case the disabled shape; `enabled: false` already carries the why.
|
|
1509
|
+
*/
|
|
1510
|
+
function disabledApiValidationMeta(
|
|
1511
|
+
resolved: ResolvedApiRequest,
|
|
1512
|
+
): EnvelopeValidationMeta {
|
|
1513
|
+
return {
|
|
1514
|
+
enabled: false,
|
|
1515
|
+
source: "disabled",
|
|
1516
|
+
result: "skipped",
|
|
1517
|
+
syntax: false,
|
|
1518
|
+
semantic: resolved.scriptMode ? "not-applicable" : false,
|
|
1519
|
+
stages: [
|
|
1520
|
+
{ stage: "offline", source: OFFLINE_GATE_SOURCE, result: "skipped" },
|
|
1521
|
+
{ stage: "device", source: API_DEVICE_STAGE_SOURCE, result: "skipped" },
|
|
1522
|
+
],
|
|
1336
1523
|
};
|
|
1337
1524
|
}
|
|
1338
1525
|
|
|
@@ -1525,7 +1712,7 @@ export function apiRequestSummaryFromRequest(
|
|
|
1525
1712
|
write: parsed ? isApiMutating(parsed, normalized.path) : false,
|
|
1526
1713
|
listen,
|
|
1527
1714
|
yes: request.yes ?? false,
|
|
1528
|
-
validate:
|
|
1715
|
+
validate: summarizedValidate(request),
|
|
1529
1716
|
raw: request.raw ?? false,
|
|
1530
1717
|
format: resolveErrorFormat(request, env),
|
|
1531
1718
|
query: buildApiQuery(request),
|
package/src/cli/api.ts
CHANGED
|
@@ -94,7 +94,7 @@ export const apiCommand: CliCommandMetadata = {
|
|
|
94
94
|
{
|
|
95
95
|
flag: "--raw",
|
|
96
96
|
description:
|
|
97
|
-
"Strip the envelope; emit bare RouterOS JSON.
|
|
97
|
+
"Strip the envelope; emit bare RouterOS JSON. Defaults `--validate` to false (an explicit `--validate=true` still runs the gate); does not imply `--yes`.",
|
|
98
98
|
},
|
|
99
99
|
{
|
|
100
100
|
flag: "--yes",
|
package/src/cli/btest.ts
CHANGED
|
@@ -23,10 +23,10 @@ import {
|
|
|
23
23
|
renderBtestClientEnvelope,
|
|
24
24
|
renderBtestServerEnvelope,
|
|
25
25
|
} from "../btest.ts";
|
|
26
|
+
import { toYaml } from "../core/yaml.ts";
|
|
26
27
|
import { asCentrsError, formatCentrsErrorText } from "../errors.ts";
|
|
27
28
|
import { describeCentrs, parseDuration } from "../index.ts";
|
|
28
29
|
import type { BtestDirection, BtestProtocol } from "../protocols/btest.ts";
|
|
29
|
-
import { toYaml } from "../retrieve.ts";
|
|
30
30
|
import {
|
|
31
31
|
type CliCommandMetadata,
|
|
32
32
|
type CliCommandOption,
|
package/src/cli/explain.ts
CHANGED
|
@@ -92,7 +92,7 @@ export const explainCliCommand: CliCommandMetadata = {
|
|
|
92
92
|
{
|
|
93
93
|
flag: "--tokens",
|
|
94
94
|
description:
|
|
95
|
-
"Emit the total, gapless token partition behind `data.tokens[]` (
|
|
95
|
+
"Emit the total, gapless token partition behind `data.tokens[]` (every byte not claimed by an analyzer is `unclassified`).",
|
|
96
96
|
},
|
|
97
97
|
{
|
|
98
98
|
flag: "--format",
|
package/src/cli/terminal.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* `process.*`); the core relay in `../terminal.ts` stays io-agnostic.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { toYaml } from "../core/yaml.ts";
|
|
9
10
|
import {
|
|
10
11
|
asCentrsError,
|
|
11
12
|
CentrsError,
|
|
@@ -18,7 +19,6 @@ import {
|
|
|
18
19
|
type TerminalIo,
|
|
19
20
|
type TerminalRequest,
|
|
20
21
|
} from "../index.ts";
|
|
21
|
-
import { toYaml } from "../retrieve.ts";
|
|
22
22
|
import {
|
|
23
23
|
type CliCommandMetadata,
|
|
24
24
|
expectValue,
|
package/src/core/envelope.ts
CHANGED
|
@@ -111,6 +111,50 @@ export interface EnvelopeTargetMeta {
|
|
|
111
111
|
sources?: Record<string, SettingSource>;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* One stage of the two-stage validation gate (GH#354).
|
|
116
|
+
*
|
|
117
|
+
* `offline` is the corpus-gated analyzer (`src/offline-gate.ts`): it opens no
|
|
118
|
+
* connection, so a `failed` offline stage means the device was never contacted.
|
|
119
|
+
* `device` is the RouterOS-side preflight — `:put [:parse …]` and/or
|
|
120
|
+
* `/console/inspect request=child`, depending on the command and transport.
|
|
121
|
+
*
|
|
122
|
+
* Stages appear in the order they run, and a stage that did not run is still
|
|
123
|
+
* listed with `result: "skipped"`. That is the point of the array: a reader must
|
|
124
|
+
* be able to tell "the device accepted it" from "the device was never asked".
|
|
125
|
+
*/
|
|
126
|
+
export interface EnvelopeValidationStage {
|
|
127
|
+
stage: "offline" | "device";
|
|
128
|
+
/** Validator identity, e.g. `offline explain (canonicalizer)` or `:put [:parse]`. */
|
|
129
|
+
source: string;
|
|
130
|
+
result: "passed" | "failed" | "skipped";
|
|
131
|
+
/** Why a stage was skipped, when the reason is not simply `validate=false`. */
|
|
132
|
+
reason?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Mutable sink that lets an error path report the validation that ACTUALLY ran.
|
|
137
|
+
*
|
|
138
|
+
* `runResolvedExecute` / `runResolvedApi` finish the gate and then do the real
|
|
139
|
+
* work, and the error envelope is built by a *different* function after the
|
|
140
|
+
* throw unwinds past them. Without this, a `routeros/*` fault on the run — which
|
|
141
|
+
* happens with both gate stages already green — got a reconstructed
|
|
142
|
+
* `result: "failed"` that erased the real outcome. The runner writes
|
|
143
|
+
* {@link ValidationTrace.validation} the moment the gate returns; the error
|
|
144
|
+
* builder prefers it over anything it could guess (GH#354, PR #356 review).
|
|
145
|
+
*/
|
|
146
|
+
export interface ValidationTrace {
|
|
147
|
+
/** The completed gate result, set once validation finishes. */
|
|
148
|
+
validation?: EnvelopeValidationMeta;
|
|
149
|
+
/**
|
|
150
|
+
* The stage-1 verdict, recorded as soon as stage 1 returns — before stage 2
|
|
151
|
+
* can throw. `warn` is an abstention, and an error envelope that reported it
|
|
152
|
+
* as `passed` would be telling a consumer the analyzer read bytes it declined
|
|
153
|
+
* to judge.
|
|
154
|
+
*/
|
|
155
|
+
offlineVerdict?: "pass" | "warn";
|
|
156
|
+
}
|
|
157
|
+
|
|
114
158
|
export interface EnvelopeValidationMeta {
|
|
115
159
|
enabled: boolean;
|
|
116
160
|
/** Validator identity, e.g. `/console/inspect` or `:put [:parse]`. */
|
|
@@ -121,6 +165,15 @@ export interface EnvelopeValidationMeta {
|
|
|
121
165
|
/** Semantic `/console/inspect` gate status, or not-applicable for script mode. */
|
|
122
166
|
semantic?: boolean | "not-applicable";
|
|
123
167
|
availableAttributes?: readonly string[];
|
|
168
|
+
/**
|
|
169
|
+
* Per-stage detail for the gate described in
|
|
170
|
+
* `docs/CONSTITUTION.md` → "Validation is the product".
|
|
171
|
+
*
|
|
172
|
+
* The flat `source` / `syntax` / `semantic` fields above stay the summary
|
|
173
|
+
* view and keep their meaning; this is the breakdown behind them. Absent on
|
|
174
|
+
* surfaces that have no RouterOS-command gate at all (`btest`).
|
|
175
|
+
*/
|
|
176
|
+
stages?: readonly EnvelopeValidationStage[];
|
|
124
177
|
}
|
|
125
178
|
|
|
126
179
|
export interface EnvelopeTimingMeta {
|
|
@@ -554,7 +554,8 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
554
554
|
},
|
|
555
555
|
{
|
|
556
556
|
code: "validation/syntax",
|
|
557
|
-
summary:
|
|
557
|
+
summary:
|
|
558
|
+
"The command syntax was rejected by the validation gate — offline analysis, or the RouterOS :parse probe.",
|
|
558
559
|
},
|
|
559
560
|
{
|
|
560
561
|
code: "validation/unknown-attribute",
|