@tikoci/centrs 0.1.2 → 0.1.4
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 +98 -0
- package/GLOSSARY.txt +284 -0
- package/LICENSE +21 -0
- package/README.md +55 -11
- package/SECURITY.md +3 -3
- package/commands/AGENTS.md +7 -1
- package/commands/api/README.md +46 -27
- package/commands/api/examples.md +124 -6
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +376 -53
- package/commands/check/examples.md +390 -0
- package/commands/devices/README.md +177 -37
- package/commands/devices/examples.md +148 -0
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +73 -26
- package/commands/execute/examples.md +131 -9
- package/commands/explain/README.md +1716 -0
- package/commands/explain/examples.md +606 -0
- package/commands/retrieve/README.md +55 -71
- package/commands/retrieve/examples.md +56 -9
- package/commands/settings/README.md +286 -22
- package/commands/settings/examples.md +408 -0
- package/commands/terminal/README.md +6 -0
- package/commands/transfer/README.md +57 -19
- package/commands/transfer/examples.md +95 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +102 -26
- package/docs/MATRIX.md +106 -173
- package/docs/errors/README.md +5 -1
- package/docs/errors/cdb/empty-selection.md +19 -0
- package/docs/errors/input/incomplete-gps.md +9 -0
- package/docs/errors/input/invalid-altitude.md +9 -0
- package/docs/errors/input/invalid-bbox.md +9 -0
- package/docs/errors/input/invalid-coordinate.md +9 -0
- package/docs/errors/input/invalid-radius.md +9 -0
- package/docs/errors/internal/settings-failed.md +9 -0
- package/docs/errors/quickchr/machine-not-found.md +22 -0
- package/docs/errors/quickchr/machine-stopped.md +20 -0
- package/docs/errors/quickchr/package-unavailable.md +27 -0
- package/docs/errors/quickchr/unsupported-via.md +35 -0
- package/docs/errors/quickchr/unsupported.md +35 -0
- package/docs/errors/routeros/version-unsupported.md +26 -0
- package/docs/errors/settings/consequential-value.md +26 -0
- package/docs/errors/settings/invalid-boolean.md +21 -3
- package/docs/errors/settings/invalid-format.md +18 -3
- package/docs/errors/settings/invalid-integer.md +18 -3
- package/docs/errors/settings/invalid-timeout.md +20 -3
- package/docs/errors/settings/invalid-via.md +22 -3
- package/docs/errors/settings/reserved-key.md +29 -0
- package/docs/errors/settings/skip-env-file-active.md +25 -0
- package/docs/errors/settings/unknown-key.md +25 -0
- package/docs/errors/transport/provider-trust.md +29 -0
- package/docs/errors/usage/fanout-not-supported.md +16 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +49 -14
- package/src/api-fanout.ts +469 -0
- package/src/api.ts +171 -56
- package/src/cli/api.ts +156 -13
- package/src/cli/btest.ts +38 -4
- package/src/cli/common.ts +11 -0
- package/src/cli/devices.ts +235 -6
- package/src/cli/discover.ts +3 -1
- package/src/cli/execute.ts +129 -9
- package/src/cli/explain.ts +503 -0
- package/src/cli/mcp.ts +8 -1
- package/src/cli/retrieve.ts +167 -90
- package/src/cli/selection.ts +260 -0
- package/src/cli/settings.ts +286 -0
- package/src/cli/terminal.ts +22 -7
- package/src/cli/transfer.ts +192 -51
- package/src/cli.ts +22 -2
- package/src/core/envelope.ts +5 -2
- package/src/core/error-catalog.ts +95 -0
- package/src/core/fanout.ts +143 -2
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +453 -0
- package/src/execute.ts +216 -68
- package/src/explain/arg-tokens.ts +114 -0
- package/src/explain/args.ts +1403 -0
- package/src/explain/blocks.ts +132 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +38 -0
- package/src/explain/catalog.ts +1345 -0
- package/src/explain/coordinates.ts +329 -0
- package/src/explain/defects.ts +217 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +664 -0
- package/src/explain/operator-tokens.ts +310 -0
- package/src/explain/operators.ts +458 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1324 -0
- package/src/explain/quoted-string.ts +241 -0
- package/src/explain/scope-brace.ts +308 -0
- package/src/explain/segment.ts +757 -0
- package/src/explain/string-tokens.ts +67 -0
- package/src/explain/symbol-values.ts +520 -0
- package/src/explain/symbols.ts +1597 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +497 -0
- package/src/explain/value-tokens.ts +80 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +813 -0
- package/src/explain/write.ts +815 -0
- package/src/explain.ts +2152 -0
- package/src/index.ts +307 -1
- package/src/mcp/config.ts +12 -2
- package/src/mcp/tools.ts +137 -2
- package/src/protocols/adapter.ts +38 -18
- package/src/protocols/index.ts +11 -5
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/cdb.ts +10 -2
- package/src/resolver/comment-kv.ts +58 -4
- package/src/resolver/config-file.ts +136 -0
- package/src/resolver/facts.ts +78 -0
- package/src/resolver/geo.ts +453 -0
- package/src/resolver/index.ts +65 -0
- package/src/resolver/mac.ts +12 -3
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +391 -0
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/settings.ts +54 -5
- package/src/resolver/target.ts +21 -3
- package/src/retrieve-fanout.ts +239 -133
- package/src/retrieve.ts +120 -25
- package/src/settings.ts +1078 -0
- package/src/terminal.ts +11 -1
- package/src/transfer-fanout.ts +544 -0
- package/src/transfer.ts +133 -40
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coordinate contract for `explain` (centrs canonicalizer).
|
|
3
|
+
*
|
|
4
|
+
* Ratified by the phase-0 lab, question Q15 (#185) and contracted in
|
|
5
|
+
* `commands/explain/README.md` (the byte-count-preserving coordinate rule).
|
|
6
|
+
* This is the production promotion of the lab's reference mapper; the six
|
|
7
|
+
* invariants it satisfies are pinned as anchor/property tests in
|
|
8
|
+
* `test/unit/explain-coordinates.test.ts` against
|
|
9
|
+
* `test/fixtures/explain/coordinates.json`.
|
|
10
|
+
*
|
|
11
|
+
* THREE COORDINATE SPACES
|
|
12
|
+
*
|
|
13
|
+
* original the caller's JS string. UTF-16 code units. Addressable as
|
|
14
|
+
* (line, utf16Col) — the LSP position space.
|
|
15
|
+
* originalU8 the UTF-8 encoding of `original`. Byte offsets. This is what a
|
|
16
|
+
* byte-oriented device sees on the wire (RouterOS `highlight`
|
|
17
|
+
* emits one token per INPUT BYTE).
|
|
18
|
+
* analyzed originalU8 with every non-ASCII byte replaced by the SUB
|
|
19
|
+
* placeholder (0x1A), SAME LENGTH as originalU8. Pure ASCII, so a
|
|
20
|
+
* JS string built from it has index === byte offset. This is the
|
|
21
|
+
* surface the offline parser runs on and the space in which probe
|
|
22
|
+
* offsets (`--cursor <byte>`, highlight spans) are contracted.
|
|
23
|
+
*
|
|
24
|
+
* WHY BYTE-COUNT-PRESERVING NORMALIZATION (the load-bearing choice)
|
|
25
|
+
*
|
|
26
|
+
* A non-ASCII character is replaced by AS MANY SUB bytes as its UTF-8 length,
|
|
27
|
+
* never collapsed to one. That makes an `analyzed` byte offset === the
|
|
28
|
+
* `originalU8` byte offset identically, so a device highlight span (byte
|
|
29
|
+
* offsets over the UTF-8 it received) and an offline span (analyzed byte
|
|
30
|
+
* offsets) share ONE coordinate system with no fixup. SUB (U+001A) is chosen
|
|
31
|
+
* because it is ASCII, is not whitespace, and is none of the RouterOS
|
|
32
|
+
* delimiters (`; \n { } [ ] ( ) " \ # $ = /`), so it stays glued inside its
|
|
33
|
+
* token and cannot forge a keyword, menu name, or separator.
|
|
34
|
+
*
|
|
35
|
+
* The production parser consumes the `analyzed` STRING (pure ASCII ⇒ its JS
|
|
36
|
+
* index IS the byte offset); this module converts those byte offsets back to
|
|
37
|
+
* the original (line, utf16Col) for LSP consumers. centrs does NOT NFC-fold:
|
|
38
|
+
* a combining mark is its own code point, its own run, and its own column.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import type { Defect } from "./defects.ts";
|
|
42
|
+
|
|
43
|
+
/** SUB (U+001A) — the byte-count-preserving stand-in for a non-ASCII byte. */
|
|
44
|
+
export const SUB = 0x1a;
|
|
45
|
+
|
|
46
|
+
/** U+FFFD REPLACEMENT CHARACTER — where a lone surrogate normalizes to. */
|
|
47
|
+
const REPLACEMENT = 0xfffd;
|
|
48
|
+
|
|
49
|
+
/** BYTE ORDER MARK (U+FEFF) — a defect only in leading position; see `coordinateDefects`. */
|
|
50
|
+
const BOM = 0xfeff;
|
|
51
|
+
|
|
52
|
+
/** A 0-based (line, column) position in `original`; column is a UTF-16 count. */
|
|
53
|
+
export interface Position {
|
|
54
|
+
line: number;
|
|
55
|
+
col: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A half-open (line, col) range in `original` — the LSP range space. */
|
|
59
|
+
export interface Range {
|
|
60
|
+
start: Position;
|
|
61
|
+
end: Position;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One code point of `original`, located in every coordinate space. */
|
|
65
|
+
export interface CharRun {
|
|
66
|
+
/** Unicode scalar value; a lone surrogate is recorded as U+FFFD. */
|
|
67
|
+
codePoint: number;
|
|
68
|
+
/** Offset in `original` (UTF-16 code units). */
|
|
69
|
+
utf16Start: number;
|
|
70
|
+
/** 1 (BMP) or 2 (astral surrogate pair) UTF-16 units. */
|
|
71
|
+
utf16Len: number;
|
|
72
|
+
/** Offset in `analyzed`/`originalU8` (bytes). */
|
|
73
|
+
byteStart: number;
|
|
74
|
+
/** UTF-8 byte length: 1..4 (a lone surrogate → 3 via U+FFFD). */
|
|
75
|
+
byteLen: number;
|
|
76
|
+
/** Whether this run is a single ASCII byte (analyzed byte === original byte). */
|
|
77
|
+
ascii: boolean;
|
|
78
|
+
/** 0-based line. */
|
|
79
|
+
line: number;
|
|
80
|
+
/** 0-based UTF-16 column within the line. */
|
|
81
|
+
col: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The full coordinate analysis of one input string. */
|
|
85
|
+
export interface CoordinateAnalysis {
|
|
86
|
+
original: string;
|
|
87
|
+
/** Pure-ASCII surface; `analyzed.length === originalU8.length`. */
|
|
88
|
+
analyzed: Uint8Array;
|
|
89
|
+
originalU8: Uint8Array;
|
|
90
|
+
runs: CharRun[];
|
|
91
|
+
/** Byte offset of each line's first character (index === 0-based line). */
|
|
92
|
+
lineStarts: number[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** UTF-8 byte length of a Unicode scalar value. */
|
|
96
|
+
function utf8Len(cp: number): number {
|
|
97
|
+
if (cp < 0x80) return 1;
|
|
98
|
+
if (cp < 0x800) return 2;
|
|
99
|
+
if (cp < 0x10000) return 3;
|
|
100
|
+
return 4;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Encode a Unicode scalar value to its UTF-8 bytes. */
|
|
104
|
+
function utf8Encode(cp: number): number[] {
|
|
105
|
+
if (cp < 0x80) return [cp];
|
|
106
|
+
if (cp < 0x800) return [0xc0 | (cp >> 6), 0x80 | (cp & 0x3f)];
|
|
107
|
+
if (cp < 0x10000)
|
|
108
|
+
return [0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)];
|
|
109
|
+
return [
|
|
110
|
+
0xf0 | (cp >> 18),
|
|
111
|
+
0x80 | ((cp >> 12) & 0x3f),
|
|
112
|
+
0x80 | ((cp >> 6) & 0x3f),
|
|
113
|
+
0x80 | (cp & 0x3f),
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build the coordinate analysis of `original`: its UTF-8 encoding, the
|
|
119
|
+
* byte-count-preserving `analyzed` surface, and one {@link CharRun} per code
|
|
120
|
+
* point locating it in all three spaces.
|
|
121
|
+
*/
|
|
122
|
+
export function analyzeCoordinates(original: string): CoordinateAnalysis {
|
|
123
|
+
const runs: CharRun[] = [];
|
|
124
|
+
const u8: number[] = [];
|
|
125
|
+
const analyzed: number[] = [];
|
|
126
|
+
const lineStarts: number[] = [0];
|
|
127
|
+
let line = 0;
|
|
128
|
+
let col = 0;
|
|
129
|
+
let utf16Pos = 0;
|
|
130
|
+
|
|
131
|
+
// `for..of` yields code points and handles surrogate pairs; we track the
|
|
132
|
+
// UTF-16 offset/len separately so an astral char advances col by 2 (its
|
|
133
|
+
// UTF-16 width), which is what an LSP position expects.
|
|
134
|
+
for (const ch of original) {
|
|
135
|
+
let cp = ch.codePointAt(0) as number;
|
|
136
|
+
const utf16Len = ch.length; // 1 or 2
|
|
137
|
+
// A lone surrogate survives in a JS string as a single UTF-16 unit whose
|
|
138
|
+
// code point is in [0xD800,0xDFFF]; it is not a valid scalar → U+FFFD.
|
|
139
|
+
if (cp >= 0xd800 && cp <= 0xdfff) cp = REPLACEMENT;
|
|
140
|
+
const ascii = cp < 0x80;
|
|
141
|
+
const byteStart = u8.length;
|
|
142
|
+
|
|
143
|
+
for (const b of utf8Encode(cp)) {
|
|
144
|
+
u8.push(b);
|
|
145
|
+
analyzed.push(ascii ? b : SUB);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
runs.push({
|
|
149
|
+
codePoint: cp,
|
|
150
|
+
utf16Start: utf16Pos,
|
|
151
|
+
utf16Len,
|
|
152
|
+
byteStart,
|
|
153
|
+
byteLen: utf8Len(cp),
|
|
154
|
+
ascii,
|
|
155
|
+
line,
|
|
156
|
+
col,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
utf16Pos += utf16Len;
|
|
160
|
+
// `\n` (0x0A) ends a line. A lone `\r` is an ordinary column, so a `\r\n`
|
|
161
|
+
// sequence advances exactly one line (on the `\n`). Tabs are one column,
|
|
162
|
+
// never expanded — column counts UTF-16 units, not visual width.
|
|
163
|
+
if (cp === 0x0a) {
|
|
164
|
+
line++;
|
|
165
|
+
col = 0;
|
|
166
|
+
lineStarts.push(u8.length);
|
|
167
|
+
} else {
|
|
168
|
+
col += utf16Len;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
original,
|
|
174
|
+
analyzed: Uint8Array.from(analyzed),
|
|
175
|
+
originalU8: Uint8Array.from(u8),
|
|
176
|
+
runs,
|
|
177
|
+
lineStarts,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The {@link CharRun} whose byte range contains `byte`. Throws if `byte` is not
|
|
183
|
+
* inside any run (i.e. `byte < 0` or `byte >= analyzed.length`).
|
|
184
|
+
*/
|
|
185
|
+
export function runAtByte(a: CoordinateAnalysis, byte: number): CharRun {
|
|
186
|
+
const len = a.analyzed.length;
|
|
187
|
+
if (!Number.isInteger(byte) || byte < 0 || byte >= len)
|
|
188
|
+
throw new Error(
|
|
189
|
+
`runAtByte: ${byte} is not a valid interior byte offset; expected an integer in [0, ${len})`,
|
|
190
|
+
);
|
|
191
|
+
let lo = 0;
|
|
192
|
+
let hi = a.runs.length - 1;
|
|
193
|
+
while (lo <= hi) {
|
|
194
|
+
const mid = (lo + hi) >> 1;
|
|
195
|
+
const r = a.runs[mid] as CharRun;
|
|
196
|
+
if (byte < r.byteStart) hi = mid - 1;
|
|
197
|
+
else if (byte >= r.byteStart + r.byteLen) lo = mid + 1;
|
|
198
|
+
else return r;
|
|
199
|
+
}
|
|
200
|
+
// Unreachable: a valid interior byte always lies inside exactly one run.
|
|
201
|
+
throw new Error(`runAtByte: no run contains byte ${byte} in [0, ${len})`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Map an `analyzed` byte offset to an original (line, col) position. A byte
|
|
206
|
+
* landing INSIDE a multi-byte char snaps to that char's boundary (V6).
|
|
207
|
+
* `byte === analyzed.length` is the end-of-input cursor and maps to the
|
|
208
|
+
* position just past the last character.
|
|
209
|
+
*/
|
|
210
|
+
export function byteToPosition(a: CoordinateAnalysis, byte: number): Position {
|
|
211
|
+
const len = a.analyzed.length;
|
|
212
|
+
if (!Number.isInteger(byte) || byte < 0 || byte > len)
|
|
213
|
+
throw new Error(
|
|
214
|
+
`byteToPosition: ${byte} is not a valid cursor offset; expected an integer in [0, ${len}]`,
|
|
215
|
+
);
|
|
216
|
+
if (byte === len) {
|
|
217
|
+
const last = a.runs.at(-1);
|
|
218
|
+
if (!last) return { line: 0, col: 0 };
|
|
219
|
+
if (last.codePoint === 0x0a) return { line: last.line + 1, col: 0 };
|
|
220
|
+
return { line: last.line, col: last.col + last.utf16Len };
|
|
221
|
+
}
|
|
222
|
+
const run = runAtByte(a, byte);
|
|
223
|
+
return { line: run.line, col: run.col };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Map a half-open analyzed-byte span to an original (line, col) range — the
|
|
228
|
+
* conversion an LSP consumer needs for every defect region and every token span.
|
|
229
|
+
*
|
|
230
|
+
* Deliberately built on {@link byteToPosition} in both directions rather than on
|
|
231
|
+
* {@link positionToByte}: that one is an O(n) linear scan over `runs`, so using
|
|
232
|
+
* it here would make converting a document's worth of spans quadratic.
|
|
233
|
+
* `byteToPosition` binary-searches. Both endpoints snap to a character boundary
|
|
234
|
+
* (V6), and `end === analyzed.length` is legal, so a span reaching end-of-input
|
|
235
|
+
* converts without a special case.
|
|
236
|
+
*/
|
|
237
|
+
export function byteSpanToRange(
|
|
238
|
+
a: CoordinateAnalysis,
|
|
239
|
+
start: number,
|
|
240
|
+
end: number,
|
|
241
|
+
): Range {
|
|
242
|
+
if (end < start)
|
|
243
|
+
throw new Error(
|
|
244
|
+
`byteSpanToRange: end ${end} precedes start ${start}; spans are half-open and forward`,
|
|
245
|
+
);
|
|
246
|
+
return { start: byteToPosition(a, start), end: byteToPosition(a, end) };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The defect regions that fall out of the coordinate pass itself: a leading BOM,
|
|
251
|
+
* and every run of bytes the ASCII normalization stood in for.
|
|
252
|
+
*
|
|
253
|
+
* Both classes are {@link isPositionalFact}s, never errors — see that predicate
|
|
254
|
+
* for why. They come from `runs` rather than from a second scan because
|
|
255
|
+
* `analyzeCoordinates` has already decided, per code point, whether it is ASCII
|
|
256
|
+
* (`CharRun.ascii`); re-deriving that would be a chance to disagree with it.
|
|
257
|
+
*
|
|
258
|
+
* Non-ASCII runs are COALESCED: a CJK comment or an emoji-bearing value is one
|
|
259
|
+
* region, not one per code point. Contiguity is measured in bytes, so adjacent
|
|
260
|
+
* non-ASCII characters merge even across a surrogate pair.
|
|
261
|
+
*
|
|
262
|
+
* Only a U+FEFF in LEADING position is a `bom`. The same code point later in the
|
|
263
|
+
* document is a zero-width no-break space — ordinary content — and is reported
|
|
264
|
+
* as part of its `non-ascii` run like any other character.
|
|
265
|
+
*/
|
|
266
|
+
export function coordinateDefects(a: CoordinateAnalysis): Defect[] {
|
|
267
|
+
const defects: Defect[] = [];
|
|
268
|
+
const first = a.runs[0];
|
|
269
|
+
const hasBom = first !== undefined && first.codePoint === BOM;
|
|
270
|
+
if (hasBom && first !== undefined)
|
|
271
|
+
defects.push({
|
|
272
|
+
code: "bom",
|
|
273
|
+
start: first.byteStart,
|
|
274
|
+
end: first.byteStart + first.byteLen,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
let runStart = -1;
|
|
278
|
+
let runEnd = -1;
|
|
279
|
+
for (let i = hasBom ? 1 : 0; i < a.runs.length; i++) {
|
|
280
|
+
const run = a.runs[i] as CharRun;
|
|
281
|
+
if (run.ascii) {
|
|
282
|
+
if (runStart >= 0) {
|
|
283
|
+
defects.push({ code: "non-ascii", start: runStart, end: runEnd });
|
|
284
|
+
runStart = -1;
|
|
285
|
+
}
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (runStart < 0) runStart = run.byteStart;
|
|
289
|
+
runEnd = run.byteStart + run.byteLen;
|
|
290
|
+
}
|
|
291
|
+
if (runStart >= 0)
|
|
292
|
+
defects.push({ code: "non-ascii", start: runStart, end: runEnd });
|
|
293
|
+
|
|
294
|
+
return defects;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Map an original (line, col) position to an `analyzed` byte offset. `col` may
|
|
299
|
+
* equal the line's UTF-16 length (the position just past the last character /
|
|
300
|
+
* at the newline). Throws if the position addresses no boundary.
|
|
301
|
+
*/
|
|
302
|
+
export function positionToByte(
|
|
303
|
+
a: CoordinateAnalysis,
|
|
304
|
+
line: number,
|
|
305
|
+
col: number,
|
|
306
|
+
): number {
|
|
307
|
+
if (!Number.isInteger(line) || !Number.isInteger(col) || line < 0 || col < 0)
|
|
308
|
+
throw new Error(
|
|
309
|
+
`positionToByte: (line ${line}, col ${col}) is not a valid position; expected non-negative integers`,
|
|
310
|
+
);
|
|
311
|
+
// Single pass: return an exact (line, col) hit immediately; otherwise keep
|
|
312
|
+
// the last run seen on the target line to resolve a just-past-the-end column.
|
|
313
|
+
let lastOnLine: CharRun | undefined;
|
|
314
|
+
for (const run of a.runs) {
|
|
315
|
+
if (run.line !== line) continue;
|
|
316
|
+
if (run.col === col) return run.byteStart;
|
|
317
|
+
lastOnLine = run;
|
|
318
|
+
}
|
|
319
|
+
// col past the last char of the line → byte after that line's last char.
|
|
320
|
+
if (lastOnLine && col === lastOnLine.col + lastOnLine.utf16Len)
|
|
321
|
+
return lastOnLine.byteStart + lastOnLine.byteLen;
|
|
322
|
+
// Empty line (only as the final position after a trailing newline) or col 0
|
|
323
|
+
// of a line with no chars → that line's start byte.
|
|
324
|
+
if (col === 0 && line < a.lineStarts.length)
|
|
325
|
+
return a.lineStarts[line] as number;
|
|
326
|
+
throw new Error(
|
|
327
|
+
`positionToByte: no character boundary at (line ${line}, col ${col})`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defect regions for `explain` (centrs canonicalizer).
|
|
3
|
+
*
|
|
4
|
+
* Ratified by the phase-0 lab, question Q14 (#185), whose coordinate half
|
|
5
|
+
* `commands/explain/README.md` states as "**Malformed input carries a defect
|
|
6
|
+
* *region*** — each diagnostic points at the byte span of its defect". This
|
|
7
|
+
* module is that span, and since #202 it is the ONLY structural-surprise channel
|
|
8
|
+
* an analyzer carries.
|
|
9
|
+
*
|
|
10
|
+
* WHY ONE CHANNEL
|
|
11
|
+
*
|
|
12
|
+
* It was two for exactly one issue. Every analyzer used to carry a flat
|
|
13
|
+
* `notes: string[]` beside this one, and #192 added the regions additively so
|
|
14
|
+
* that any movement in a `notes` value during that change was a real bug rather
|
|
15
|
+
* than refactor noise. The convergence is this: a note answered "did anything
|
|
16
|
+
* go wrong", a defect answers that AND "where", so the note channel was
|
|
17
|
+
* strictly weaker and is gone. The two questions a caller actually asks are
|
|
18
|
+
* {@link hasStructuralDefect} (the abstention gate the notes used to serve) and
|
|
19
|
+
* the regions themselves.
|
|
20
|
+
*
|
|
21
|
+
* The swap was measured, not assumed: over the frozen 913-document corpus and
|
|
22
|
+
* 19,972 targeted mutations (delimiter, quote, escape and non-ASCII injections
|
|
23
|
+
* that made the gate fire on 6,908 of them), `notes.length > 0` and
|
|
24
|
+
* `hasStructuralDefect(defects)` disagreed on ZERO inputs in all five analyzers,
|
|
25
|
+
* and no `containsWrite` tristate moved.
|
|
26
|
+
*
|
|
27
|
+
* COORDINATES
|
|
28
|
+
*
|
|
29
|
+
* `start`/`end` are ANALYZED-byte offsets, half-open, in exactly the space
|
|
30
|
+
* `coordinates.ts` contracts. Because that normalization is byte-count
|
|
31
|
+
* preserving, a defect region is directly comparable to a device `highlight`
|
|
32
|
+
* span with no fixup. Every region is non-empty: a defect that is "at" one
|
|
33
|
+
* byte spans that byte (`[i, i+1)`), never `[i, i)`.
|
|
34
|
+
*
|
|
35
|
+
* WHY REGIONS ARE NOT MERELY THE NOTE STRINGS PARSED
|
|
36
|
+
*
|
|
37
|
+
* Two real losses in the retired note channel are what this module fixed, and
|
|
38
|
+
* neither was recoverable by parsing `over-depth:<n>` back out of a string. Both are
|
|
39
|
+
* reproduced in `test/unit/explain-defects.test.ts`:
|
|
40
|
+
*
|
|
41
|
+
* - `pathresolve` collected notes into a `Set`, so several `over-depth` events
|
|
42
|
+
* at different offsets collapsed into ONE entry and every offset was lost.
|
|
43
|
+
* Two independent 260-deep block chains in one document produced a single
|
|
44
|
+
* `"over-depth"` note; they are two distinct defects.
|
|
45
|
+
* - `pathresolve` emitted a BARE `over-depth` with no offset at all, while
|
|
46
|
+
* `segment` and `symbols` emitted `over-depth:<byte>` — the same class in two
|
|
47
|
+
* shapes, only one of them locatable.
|
|
48
|
+
*
|
|
49
|
+
* A third suspected loss did NOT survive checking, and is recorded so it is
|
|
50
|
+
* not re-derived: segmenting a block body and discarding its findings LOOKS
|
|
51
|
+
* like it would hide a defect inside `do={…}`. It does not. The top-level
|
|
52
|
+
* segmenter scans the whole byte range including block bodies, and
|
|
53
|
+
* `scopeBlocks` only returns depth-0 braces, so the outer scan is a strict
|
|
54
|
+
* superset — six probe shapes (unterminated strings, stray closes and
|
|
55
|
+
* unclosed delimiters inside bodies, nested two deep) surfaced no body-only
|
|
56
|
+
* defect. Body defects propagate and `ScopeBlock.start` exists because that is
|
|
57
|
+
* what lets a defect the WALKER itself raises (its block-descent
|
|
58
|
+
* `over-depth`) carry a document-space region — not because either recovers a
|
|
59
|
+
* hidden class.
|
|
60
|
+
*
|
|
61
|
+
* CLASSES DELIBERATELY NOT DETECTED
|
|
62
|
+
*
|
|
63
|
+
* `truncation` and `stray mid-token delimiter` appeared in the phase-0 draft
|
|
64
|
+
* of the detectable set and are deferred. **The decision and its reasoning
|
|
65
|
+
* live in `commands/explain/README.md` (the executable spec), not here** —
|
|
66
|
+
* this vocabulary follows that list rather than narrowing it. In short:
|
|
67
|
+
* truncation is indistinguishable from a finished token without a schema (it
|
|
68
|
+
* is a `--complete` continuation-state question against a live target), and
|
|
69
|
+
* the stray-delimiter class needs a probe matrix before a detector is worth
|
|
70
|
+
* having — the #201 round is the standing evidence that a lexical rule
|
|
71
|
+
* curated from intuition produces defects the corpus cannot reach.
|
|
72
|
+
*
|
|
73
|
+
* Re-opening either means grounding it first and amending the README's list;
|
|
74
|
+
* `test/unit/explain-defects.test.ts` asserts they stay undetected so the two
|
|
75
|
+
* cannot silently drift apart.
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* One structural defect, located in analyzed-byte space.
|
|
80
|
+
*
|
|
81
|
+
* The `code` vocabulary is the one the retired note channel used, so a fixture
|
|
82
|
+
* or transcript from before #202 is still comparable entry for entry.
|
|
83
|
+
*/
|
|
84
|
+
export interface Defect {
|
|
85
|
+
code: DefectCode;
|
|
86
|
+
/** analyzed-byte offset, inclusive. */
|
|
87
|
+
start: number;
|
|
88
|
+
/** analyzed-byte offset, exclusive. Always `> start`. */
|
|
89
|
+
end: number;
|
|
90
|
+
/**
|
|
91
|
+
* The delimiter or byte that raised it, when the class has one — `"}"` for
|
|
92
|
+
* `unbalanced-close`, the opener for `unclosed`. Never a sentence: prose
|
|
93
|
+
* belongs in a rendered diagnostic, not in the structural record.
|
|
94
|
+
*/
|
|
95
|
+
detail?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type DefectCode =
|
|
99
|
+
| "over-depth"
|
|
100
|
+
| "bad-escape"
|
|
101
|
+
| "bad-string-escape"
|
|
102
|
+
| "bad-sigil"
|
|
103
|
+
| "invalid-hash"
|
|
104
|
+
| "unterminated-string"
|
|
105
|
+
| "unclosed"
|
|
106
|
+
| "unbalanced-close"
|
|
107
|
+
| "bom"
|
|
108
|
+
| "non-ascii";
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* `bom` and `non-ascii` are POSITIONAL FACTS, not errors.
|
|
112
|
+
*
|
|
113
|
+
* This predicate exists so a renderer cannot give them error severity by
|
|
114
|
+
* accident, and it is a guard rather than a comment because the failure it
|
|
115
|
+
* prevents is silent and user-facing: `/system identity set name="router-🚀"`
|
|
116
|
+
* (`commands/explain/examples.md` example 22) is a perfectly legal command whose
|
|
117
|
+
* value is non-ASCII by design. Failing it under `--fail-on error` would be a
|
|
118
|
+
* false positive on correct input, and RouterOS comments and string values carry
|
|
119
|
+
* non-ASCII routinely.
|
|
120
|
+
*
|
|
121
|
+
* What these two regions actually record is where the byte-count-preserving
|
|
122
|
+
* normalization stood in for bytes the analyzer cannot read — i.e. the spans a
|
|
123
|
+
* consumer needs in order to map back to the original text. That is a coordinate
|
|
124
|
+
* fact. The other structural codes mark input the analyzer could not structurally
|
|
125
|
+
* resolve, which is a different thing.
|
|
126
|
+
*
|
|
127
|
+
* {@link hasStructuralDefect} is where that distinction is enforced, so a UTF-8
|
|
128
|
+
* comment can never flip a document's write tristate to `unknown`.
|
|
129
|
+
*/
|
|
130
|
+
export function isPositionalFact(code: DefectCode): boolean {
|
|
131
|
+
return code === "bom" || code === "non-ascii";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Does this list carry a defect an analyzer must ABSTAIN on?
|
|
136
|
+
*
|
|
137
|
+
* The abstention gate, and the reason {@link isPositionalFact} is a function
|
|
138
|
+
* rather than a comment: a defect list is not uniformly "bad input". Seven codes
|
|
139
|
+
* mark structure the analyzer could not resolve — part of the document was never
|
|
140
|
+
* walked, so no verdict over it is safe. The other two are coordinate facts about
|
|
141
|
+
* bytes that were read perfectly well. Gating on `defects.length > 0` would
|
|
142
|
+
* abstain on `/system identity set name="router-🚀"`, a legal command, and on the
|
|
143
|
+
* ~12% of the phase-0 corpus that carries non-ASCII.
|
|
144
|
+
*/
|
|
145
|
+
export function hasStructuralDefect(defects: readonly Defect[]): boolean {
|
|
146
|
+
return defects.some((d) => !isPositionalFact(d.code));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A defect spanning exactly the byte at `at`. */
|
|
150
|
+
export function defectAt(
|
|
151
|
+
code: DefectCode,
|
|
152
|
+
at: number,
|
|
153
|
+
detail?: string,
|
|
154
|
+
): Defect {
|
|
155
|
+
return detail === undefined
|
|
156
|
+
? { code, start: at, end: at + 1 }
|
|
157
|
+
: { code, start: at, end: at + 1, detail };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Shift every defect by `base` — the move that carries a block body's
|
|
162
|
+
* body-relative regions into document space.
|
|
163
|
+
*
|
|
164
|
+
* Always returns a new ARRAY, so a caller may accumulate into the result without
|
|
165
|
+
* touching the input; at `base === 0` the elements themselves are shared, which
|
|
166
|
+
* is safe because a Defect is never mutated after construction.
|
|
167
|
+
*/
|
|
168
|
+
export function rebaseDefects(
|
|
169
|
+
defects: readonly Defect[],
|
|
170
|
+
base: number,
|
|
171
|
+
): Defect[] {
|
|
172
|
+
if (base === 0) return [...defects];
|
|
173
|
+
return defects.map((d) =>
|
|
174
|
+
d.detail === undefined
|
|
175
|
+
? { code: d.code, start: d.start + base, end: d.end + base }
|
|
176
|
+
: {
|
|
177
|
+
code: d.code,
|
|
178
|
+
start: d.start + base,
|
|
179
|
+
end: d.end + base,
|
|
180
|
+
detail: d.detail,
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Concatenate defect lists, dropping exact duplicates.
|
|
187
|
+
*
|
|
188
|
+
* Identity is `code + start + end + detail` — the WHOLE region, never the code
|
|
189
|
+
* alone. That distinction is the entire point: the retired note channel
|
|
190
|
+
* de-duplicated by stringified note, so two `over-depth` events at bytes 40 and
|
|
191
|
+
* 900 became one `"over-depth"` entry and the second offset was gone. Here they
|
|
192
|
+
* are two defects.
|
|
193
|
+
*
|
|
194
|
+
* Order is stable (first occurrence wins) so a caller can report defects in
|
|
195
|
+
* discovery order without sorting; callers that want source order should sort by
|
|
196
|
+
* `start` themselves, since discovery order differs between the statement walk
|
|
197
|
+
* and the bracket walk.
|
|
198
|
+
*
|
|
199
|
+
* The key is JSON rather than a joined string so that an ABSENT `detail` and an
|
|
200
|
+
* empty-string one stay distinguishable, and so the separator cannot be a raw
|
|
201
|
+
* control byte sitting invisibly in this file.
|
|
202
|
+
*/
|
|
203
|
+
export function mergeDefects(
|
|
204
|
+
...lists: readonly (readonly Defect[])[]
|
|
205
|
+
): Defect[] {
|
|
206
|
+
const seen = new Set<string>();
|
|
207
|
+
const out: Defect[] = [];
|
|
208
|
+
for (const list of lists) {
|
|
209
|
+
for (const d of list) {
|
|
210
|
+
const key = JSON.stringify([d.code, d.start, d.end, d.detail]);
|
|
211
|
+
if (seen.has(key)) continue;
|
|
212
|
+
seen.add(key);
|
|
213
|
+
out.push(d);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { lookupPath } from "./catalog.ts";
|
|
2
|
+
import { isMenuPath } from "./menus.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether `segments` name a known RouterOS menu or settings path.
|
|
6
|
+
*
|
|
7
|
+
* Union of the two shipped structure tables (#235): `MENU_PATHS` (#207,
|
|
8
|
+
* `isMenuPath`) and `PATH_CATALOG` menu/settings rows (#228, `lookupPath`).
|
|
9
|
+
* Presence is load-bearing, absence abstains — the caller must not read
|
|
10
|
+
* absence as evidence of a command.
|
|
11
|
+
*/
|
|
12
|
+
export function isKnownMenuPath(segments: readonly string[]): boolean {
|
|
13
|
+
const entry = lookupPath(segments);
|
|
14
|
+
return (
|
|
15
|
+
isMenuPath(segments) || entry?.kind === "menu" || entry?.kind === "settings"
|
|
16
|
+
);
|
|
17
|
+
}
|