@tikoci/centrs 0.1.3 → 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 (107) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/GLOSSARY.txt +252 -0
  3. package/LICENSE +21 -0
  4. package/README.md +44 -12
  5. package/commands/AGENTS.md +14 -5
  6. package/commands/api/AGENTS.md +13 -1
  7. package/commands/api/README.md +56 -26
  8. package/commands/api/examples.md +46 -1
  9. package/commands/btest/README.md +21 -32
  10. package/commands/check/README.md +6 -1
  11. package/commands/discover/README.md +5 -11
  12. package/commands/execute/README.md +77 -36
  13. package/commands/execute/examples.md +120 -10
  14. package/commands/explain/README.md +2756 -0
  15. package/commands/explain/examples.md +702 -0
  16. package/commands/mcp/README.md +7 -4
  17. package/commands/mcp/examples.md +2 -2
  18. package/commands/retrieve/README.md +25 -27
  19. package/commands/retrieve/examples.md +16 -0
  20. package/commands/terminal/examples.md +7 -1
  21. package/commands/transfer/README.md +28 -20
  22. package/commands/transfer/examples.md +29 -0
  23. package/docs/CLI.md +353 -0
  24. package/docs/CONSTITUTION.md +138 -30
  25. package/docs/MATRIX.md +92 -18
  26. package/docs/errors/README.md +3 -0
  27. package/docs/errors/quickchr/machine-not-found.md +22 -0
  28. package/docs/errors/quickchr/machine-stopped.md +20 -0
  29. package/docs/errors/quickchr/package-unavailable.md +27 -0
  30. package/docs/errors/quickchr/unsupported-via.md +35 -0
  31. package/docs/errors/quickchr/unsupported.md +35 -0
  32. package/docs/errors/routeros/version-unsupported.md +26 -0
  33. package/docs/errors/transport/provider-trust.md +29 -0
  34. package/docs/errors/usage/stdin-ignored.md +28 -0
  35. package/docs/errors/validation/syntax.md +29 -4
  36. package/docs/errors/validation/unknown-attribute.md +37 -4
  37. package/package.json +56 -10
  38. package/src/api-fanout.ts +46 -18
  39. package/src/api.ts +351 -121
  40. package/src/cli/api.ts +26 -7
  41. package/src/cli/btest.ts +19 -3
  42. package/src/cli/execute.ts +25 -6
  43. package/src/cli/explain.ts +503 -0
  44. package/src/cli/retrieve.ts +33 -1
  45. package/src/cli/selection.ts +71 -11
  46. package/src/cli/settings.ts +2 -2
  47. package/src/cli/terminal.ts +1 -1
  48. package/src/cli/transfer.ts +23 -6
  49. package/src/cli.ts +15 -2
  50. package/src/core/envelope.ts +58 -2
  51. package/src/core/error-catalog.ts +42 -1
  52. package/src/core/execute-command.ts +148 -0
  53. package/src/core/inspect.ts +36 -4
  54. package/src/core/preflight.ts +306 -0
  55. package/src/core/routeros-string.ts +24 -0
  56. package/src/core/version.ts +4 -0
  57. package/src/core/yaml.ts +86 -0
  58. package/src/errors.ts +1 -0
  59. package/src/execute-fanout.ts +47 -18
  60. package/src/execute.ts +339 -307
  61. package/src/explain/arg-tokens.ts +151 -0
  62. package/src/explain/args.ts +1749 -0
  63. package/src/explain/blocks.ts +175 -0
  64. package/src/explain/brace-slots.ts +143 -0
  65. package/src/explain/brace-tokens.ts +39 -0
  66. package/src/explain/catalog.ts +1343 -0
  67. package/src/explain/coordinates.ts +488 -0
  68. package/src/explain/defects.ts +218 -0
  69. package/src/explain/escape-tokens.ts +77 -0
  70. package/src/explain/highlight-projection.ts +308 -0
  71. package/src/explain/is-known-menu.ts +17 -0
  72. package/src/explain/menus.ts +668 -0
  73. package/src/explain/operator-tokens.ts +316 -0
  74. package/src/explain/operators.ts +465 -0
  75. package/src/explain/path-tokens.ts +150 -0
  76. package/src/explain/pathresolve.ts +1569 -0
  77. package/src/explain/quoted-string.ts +331 -0
  78. package/src/explain/scope-brace.ts +461 -0
  79. package/src/explain/segment.ts +1165 -0
  80. package/src/explain/separator.ts +187 -0
  81. package/src/explain/split-owner.ts +194 -0
  82. package/src/explain/string-tokens.ts +77 -0
  83. package/src/explain/symbol-values.ts +556 -0
  84. package/src/explain/symbols.ts +1617 -0
  85. package/src/explain/token-ranges.ts +72 -0
  86. package/src/explain/transport.ts +514 -0
  87. package/src/explain/value-tokens.ts +83 -0
  88. package/src/explain/values.ts +357 -0
  89. package/src/explain/verbs.ts +64 -0
  90. package/src/explain/verbsplit.ts +955 -0
  91. package/src/explain/write.ts +856 -0
  92. package/src/explain.ts +2602 -0
  93. package/src/index.ts +225 -6
  94. package/src/mcp/server.ts +2 -1
  95. package/src/offline-gate.ts +148 -0
  96. package/src/protocols/mac-telnet-console.ts +61 -17
  97. package/src/resolver/facts.ts +1 -1
  98. package/src/resolver/index.ts +23 -4
  99. package/src/resolver/quickchr-provider.ts +597 -0
  100. package/src/resolver/selection.ts +99 -12
  101. package/src/resolver/service-endpoint.ts +155 -0
  102. package/src/resolver/target.ts +1 -1
  103. package/src/retrieve-fanout.ts +27 -13
  104. package/src/retrieve.ts +82 -77
  105. package/src/settings.ts +2 -1
  106. package/src/transfer-fanout.ts +43 -26
  107. package/src/transfer.ts +88 -45
@@ -0,0 +1,488 @@
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
+ /**
91
+ * One run per code point.
92
+ *
93
+ * On the {@link CoordinateAnalysis.ascii} path this array is built ON FIRST
94
+ * READ, not by `analyzeCoordinates`: every caller that only needs an offset
95
+ * or a position can answer from arithmetic there, and materializing one
96
+ * object per character was the single largest allocation in the analyzer
97
+ * (#322). Reading it is always sound — the contract is the array, and the
98
+ * lazy path builds the same one the eager path would have.
99
+ */
100
+ runs: CharRun[];
101
+ /** Byte offset of each line's first character (index === 0-based line). */
102
+ lineStarts: number[];
103
+ /**
104
+ * True when every code point of `original` is ASCII, so `analyzed`,
105
+ * `originalU8` and `original` agree byte for byte and a byte offset IS a
106
+ * UTF-16 offset.
107
+ *
108
+ * This is the property the three coordinate spaces were defined to make
109
+ * checkable, and it is what lets a caller skip the mapping rather than
110
+ * re-derive an identity one. It is a FACT about the input, never a mode: the
111
+ * non-ASCII path below is unchanged and stays the only reading of a
112
+ * non-ASCII document.
113
+ */
114
+ ascii: boolean;
115
+ }
116
+
117
+ /** UTF-8 byte length of a Unicode scalar value. */
118
+ function utf8Len(cp: number): number {
119
+ if (cp < 0x80) return 1;
120
+ if (cp < 0x800) return 2;
121
+ if (cp < 0x10000) return 3;
122
+ return 4;
123
+ }
124
+
125
+ /** Encode a Unicode scalar value to its UTF-8 bytes. */
126
+ function utf8Encode(cp: number): number[] {
127
+ if (cp < 0x80) return [cp];
128
+ if (cp < 0x800) return [0xc0 | (cp >> 6), 0x80 | (cp & 0x3f)];
129
+ if (cp < 0x10000)
130
+ return [0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)];
131
+ return [
132
+ 0xf0 | (cp >> 18),
133
+ 0x80 | ((cp >> 12) & 0x3f),
134
+ 0x80 | ((cp >> 6) & 0x3f),
135
+ 0x80 | (cp & 0x3f),
136
+ ];
137
+ }
138
+
139
+ /**
140
+ * Any code point outside ASCII.
141
+ *
142
+ * `u`-mode, so the test is over CODE POINTS: an astral character is one match
143
+ * rather than two halves, and a lone surrogate — which is not a scalar value
144
+ * and therefore not ASCII either — matches as itself. That is the same
145
+ * population the walk below normalizes, so the two cannot disagree about what
146
+ * "all ASCII" means.
147
+ */
148
+ const NON_ASCII = /[^\p{ASCII}]/u;
149
+
150
+ /**
151
+ * Build the coordinate analysis of `original`: its UTF-8 encoding, the
152
+ * byte-count-preserving `analyzed` surface, and one {@link CharRun} per code
153
+ * point locating it in all three spaces.
154
+ *
155
+ * An ALL-ASCII input takes the identity path. Nothing about the contract
156
+ * changes there — the three spaces coincide, which is what the
157
+ * byte-count-preserving rule above was chosen to guarantee — so the mapping is
158
+ * arithmetic and the per-character {@link CharRun} array is deferred to its
159
+ * first reader. The analyzer re-derived that map for every nested block body it
160
+ * descended into, and `pathresolve`'s own `locate` discards the coordinate
161
+ * facts of a body anyway (they are properties of the root input): on a 60 KiB
162
+ * document nested 64 deep it decoded 7.9 MiB and allocated a run object per
163
+ * byte of it, a quarter of total analysis time (#322).
164
+ */
165
+ export function analyzeCoordinates(original: string): CoordinateAnalysis {
166
+ if (!NON_ASCII.test(original)) return asciiCoordinates(original);
167
+ const runs: CharRun[] = [];
168
+ const u8: number[] = [];
169
+ const analyzed: number[] = [];
170
+ const lineStarts: number[] = [0];
171
+ let line = 0;
172
+ let col = 0;
173
+ let utf16Pos = 0;
174
+
175
+ // `for..of` yields code points and handles surrogate pairs; we track the
176
+ // UTF-16 offset/len separately so an astral char advances col by 2 (its
177
+ // UTF-16 width), which is what an LSP position expects.
178
+ for (const ch of original) {
179
+ let cp = ch.codePointAt(0) as number;
180
+ const utf16Len = ch.length; // 1 or 2
181
+ // A lone surrogate survives in a JS string as a single UTF-16 unit whose
182
+ // code point is in [0xD800,0xDFFF]; it is not a valid scalar → U+FFFD.
183
+ if (cp >= 0xd800 && cp <= 0xdfff) cp = REPLACEMENT;
184
+ const ascii = cp < 0x80;
185
+ const byteStart = u8.length;
186
+
187
+ for (const b of utf8Encode(cp)) {
188
+ u8.push(b);
189
+ analyzed.push(ascii ? b : SUB);
190
+ }
191
+
192
+ runs.push({
193
+ codePoint: cp,
194
+ utf16Start: utf16Pos,
195
+ utf16Len,
196
+ byteStart,
197
+ byteLen: utf8Len(cp),
198
+ ascii,
199
+ line,
200
+ col,
201
+ });
202
+
203
+ utf16Pos += utf16Len;
204
+ // `\n` (0x0A) ends a line. A lone `\r` is an ordinary column, so a `\r\n`
205
+ // sequence advances exactly one line (on the `\n`). Tabs are one column,
206
+ // never expanded — column counts UTF-16 units, not visual width.
207
+ if (cp === 0x0a) {
208
+ line++;
209
+ col = 0;
210
+ lineStarts.push(u8.length);
211
+ } else {
212
+ col += utf16Len;
213
+ }
214
+ }
215
+
216
+ return {
217
+ original,
218
+ analyzed: Uint8Array.from(analyzed),
219
+ originalU8: Uint8Array.from(u8),
220
+ runs,
221
+ lineStarts,
222
+ ascii: false,
223
+ };
224
+ }
225
+
226
+ /**
227
+ * The identity analysis of an all-ASCII input.
228
+ *
229
+ * One byte per code unit, so `analyzed`, `originalU8` and `original` are the
230
+ * same sequence and every offset is the same number in all three spaces. Only
231
+ * `lineStarts` needs a scan; `runs` is built on demand by {@link asciiRuns}
232
+ * because most callers never ask for it.
233
+ */
234
+ function asciiCoordinates(original: string): CoordinateAnalysis {
235
+ const len = original.length;
236
+ const u8 = new Uint8Array(len);
237
+ const lineStarts = [0];
238
+ for (let i = 0; i < len; i++) {
239
+ const code = original.charCodeAt(i);
240
+ u8[i] = code;
241
+ if (code === 0x0a) lineStarts.push(i + 1);
242
+ }
243
+ let runs: CharRun[] | undefined;
244
+ return {
245
+ original,
246
+ // `analyzed` and `originalU8` are equal by definition here, but they stay
247
+ // separate arrays: they are separately typed, separately indexed surfaces
248
+ // and a shared buffer would let a write through one be read through the
249
+ // other.
250
+ analyzed: u8,
251
+ originalU8: u8.slice(),
252
+ get runs(): CharRun[] {
253
+ runs ??= asciiRuns(original, lineStarts);
254
+ return runs;
255
+ },
256
+ lineStarts,
257
+ ascii: true,
258
+ };
259
+ }
260
+
261
+ /** The per-character runs of an all-ASCII input, built on first read. */
262
+ function asciiRuns(original: string, lineStarts: readonly number[]): CharRun[] {
263
+ const runs: CharRun[] = new Array(original.length);
264
+ let line = 0;
265
+ for (let i = 0; i < original.length; i++) {
266
+ const codePoint = original.charCodeAt(i);
267
+ runs[i] = {
268
+ codePoint,
269
+ utf16Start: i,
270
+ utf16Len: 1,
271
+ byteStart: i,
272
+ byteLen: 1,
273
+ ascii: true,
274
+ line,
275
+ col: i - (lineStarts[line] as number),
276
+ };
277
+ if (codePoint === 0x0a) line++;
278
+ }
279
+ return runs;
280
+ }
281
+
282
+ /** 0-based line containing `byte` — the last line start at or before it. */
283
+ function lineAtByte(lineStarts: readonly number[], byte: number): number {
284
+ let lo = 0;
285
+ let hi = lineStarts.length - 1;
286
+ while (lo < hi) {
287
+ const mid = (lo + hi + 1) >> 1;
288
+ if ((lineStarts[mid] as number) <= byte) lo = mid;
289
+ else hi = mid - 1;
290
+ }
291
+ return lo;
292
+ }
293
+
294
+ /**
295
+ * The {@link CharRun} whose byte range contains `byte`. Throws if `byte` is not
296
+ * inside any run (i.e. `byte < 0` or `byte >= analyzed.length`).
297
+ */
298
+ export function runAtByte(a: CoordinateAnalysis, byte: number): CharRun {
299
+ const len = a.analyzed.length;
300
+ if (!Number.isInteger(byte) || byte < 0 || byte >= len)
301
+ throw new Error(
302
+ `runAtByte: ${byte} is not a valid interior byte offset; expected an integer in [0, ${len})`,
303
+ );
304
+ // On the identity path the run IS the byte, so answer it rather than build
305
+ // (and then binary-search) an array of one object per character.
306
+ if (a.ascii) {
307
+ const line = lineAtByte(a.lineStarts, byte);
308
+ return {
309
+ codePoint: a.original.charCodeAt(byte),
310
+ utf16Start: byte,
311
+ utf16Len: 1,
312
+ byteStart: byte,
313
+ byteLen: 1,
314
+ ascii: true,
315
+ line,
316
+ col: byte - (a.lineStarts[line] as number),
317
+ };
318
+ }
319
+ let lo = 0;
320
+ let hi = a.runs.length - 1;
321
+ while (lo <= hi) {
322
+ const mid = (lo + hi) >> 1;
323
+ const r = a.runs[mid] as CharRun;
324
+ if (byte < r.byteStart) hi = mid - 1;
325
+ else if (byte >= r.byteStart + r.byteLen) lo = mid + 1;
326
+ else return r;
327
+ }
328
+ // Unreachable: a valid interior byte always lies inside exactly one run.
329
+ throw new Error(`runAtByte: no run contains byte ${byte} in [0, ${len})`);
330
+ }
331
+
332
+ /**
333
+ * Map an `analyzed` byte offset to an original (line, col) position. A byte
334
+ * landing INSIDE a multi-byte char snaps to that char's boundary (V6).
335
+ * `byte === analyzed.length` is the end-of-input cursor and maps to the
336
+ * position just past the last character.
337
+ */
338
+ export function byteToPosition(a: CoordinateAnalysis, byte: number): Position {
339
+ const len = a.analyzed.length;
340
+ if (!Number.isInteger(byte) || byte < 0 || byte > len)
341
+ throw new Error(
342
+ `byteToPosition: ${byte} is not a valid cursor offset; expected an integer in [0, ${len}]`,
343
+ );
344
+ if (byte === len) {
345
+ // The identity path reads the same answer off `lineStarts`: a trailing
346
+ // newline has already pushed the line that the cursor sits at column 0 of.
347
+ if (a.ascii) {
348
+ const line = a.lineStarts.length - 1;
349
+ return { line, col: len - (a.lineStarts[line] as number) };
350
+ }
351
+ const last = a.runs.at(-1);
352
+ if (!last) return { line: 0, col: 0 };
353
+ if (last.codePoint === 0x0a) return { line: last.line + 1, col: 0 };
354
+ return { line: last.line, col: last.col + last.utf16Len };
355
+ }
356
+ const run = runAtByte(a, byte);
357
+ return { line: run.line, col: run.col };
358
+ }
359
+
360
+ /**
361
+ * Map a half-open analyzed-byte span to an original (line, col) range — the
362
+ * conversion an LSP consumer needs for every defect region and every token span.
363
+ *
364
+ * Deliberately built on {@link byteToPosition} in both directions rather than on
365
+ * {@link positionToByte}: that one is an O(n) linear scan over `runs`, so using
366
+ * it here would make converting a document's worth of spans quadratic.
367
+ * `byteToPosition` binary-searches. Both endpoints snap to a character boundary
368
+ * (V6), and `end === analyzed.length` is legal, so a span reaching end-of-input
369
+ * converts without a special case.
370
+ */
371
+ export function byteSpanToRange(
372
+ a: CoordinateAnalysis,
373
+ start: number,
374
+ end: number,
375
+ ): Range {
376
+ if (end < start)
377
+ throw new Error(
378
+ `byteSpanToRange: end ${end} precedes start ${start}; spans are half-open and forward`,
379
+ );
380
+ return { start: byteToPosition(a, start), end: byteToPosition(a, end) };
381
+ }
382
+
383
+ /**
384
+ * The defect regions that fall out of the coordinate pass itself: a leading BOM,
385
+ * and every run of bytes the ASCII normalization stood in for.
386
+ *
387
+ * Both classes are {@link isPositionalFact}s, never errors — see that predicate
388
+ * for why. They come from `runs` rather than from a second scan because
389
+ * `analyzeCoordinates` has already decided, per code point, whether it is ASCII
390
+ * (`CharRun.ascii`); re-deriving that would be a chance to disagree with it.
391
+ *
392
+ * Non-ASCII runs are COALESCED: a CJK comment or an emoji-bearing value is one
393
+ * region, not one per code point. Contiguity is measured in bytes, so adjacent
394
+ * non-ASCII characters merge even across a surrogate pair.
395
+ *
396
+ * Only a U+FEFF in LEADING position is a `bom`. The same code point later in the
397
+ * document is a zero-width no-break space — ordinary content — and is reported
398
+ * as part of its `non-ascii` run like any other character.
399
+ */
400
+ export function coordinateDefects(a: CoordinateAnalysis): Defect[] {
401
+ // Both classes are non-ASCII code points by definition (U+FEFF included), so
402
+ // an all-ASCII input has neither and the runs never need building.
403
+ if (a.ascii) return [];
404
+ const defects: Defect[] = [];
405
+ const first = a.runs[0];
406
+ const hasBom = first !== undefined && first.codePoint === BOM;
407
+ if (hasBom && first !== undefined)
408
+ defects.push({
409
+ code: "bom",
410
+ start: first.byteStart,
411
+ end: first.byteStart + first.byteLen,
412
+ });
413
+
414
+ let runStart = -1;
415
+ let runEnd = -1;
416
+ for (let i = hasBom ? 1 : 0; i < a.runs.length; i++) {
417
+ const run = a.runs[i] as CharRun;
418
+ if (run.ascii) {
419
+ if (runStart >= 0) {
420
+ defects.push({ code: "non-ascii", start: runStart, end: runEnd });
421
+ runStart = -1;
422
+ }
423
+ continue;
424
+ }
425
+ if (runStart < 0) runStart = run.byteStart;
426
+ runEnd = run.byteStart + run.byteLen;
427
+ }
428
+ if (runStart >= 0)
429
+ defects.push({ code: "non-ascii", start: runStart, end: runEnd });
430
+
431
+ return defects;
432
+ }
433
+
434
+ /**
435
+ * Map an original (line, col) position to an `analyzed` byte offset. `col` may
436
+ * equal the line's UTF-16 length (the position just past the last character /
437
+ * at the newline). Throws if the position addresses no boundary.
438
+ */
439
+ export function positionToByte(
440
+ a: CoordinateAnalysis,
441
+ line: number,
442
+ col: number,
443
+ ): number {
444
+ if (!Number.isInteger(line) || !Number.isInteger(col) || line < 0 || col < 0)
445
+ throw new Error(
446
+ `positionToByte: (line ${line}, col ${col}) is not a valid position; expected non-negative integers`,
447
+ );
448
+ // On the identity path a position is arithmetic: the column IS the byte
449
+ // offset from the line start.
450
+ //
451
+ // The bound is the byte one PAST the line's last character, and on a line
452
+ // that ends in a newline that last character is the newline — so the bound
453
+ // is the next line's start, not one before it. That is the boundary the scan
454
+ // below reaches through `lastOnLine`, whose last run on the line is the
455
+ // newline itself; getting it wrong refused (line 0, col 2) of `a\n\nb`,
456
+ // which the scan answers with byte 2.
457
+ if (a.ascii) {
458
+ if (line >= a.lineStarts.length)
459
+ throw new Error(
460
+ `positionToByte: no character boundary at (line ${line}, col ${col})`,
461
+ );
462
+ const start = a.lineStarts[line] as number;
463
+ const end = (a.lineStarts[line + 1] ?? a.original.length) as number;
464
+ if (start + col > end)
465
+ throw new Error(
466
+ `positionToByte: no character boundary at (line ${line}, col ${col})`,
467
+ );
468
+ return start + col;
469
+ }
470
+ // Single pass: return an exact (line, col) hit immediately; otherwise keep
471
+ // the last run seen on the target line to resolve a just-past-the-end column.
472
+ let lastOnLine: CharRun | undefined;
473
+ for (const run of a.runs) {
474
+ if (run.line !== line) continue;
475
+ if (run.col === col) return run.byteStart;
476
+ lastOnLine = run;
477
+ }
478
+ // col past the last char of the line → byte after that line's last char.
479
+ if (lastOnLine && col === lastOnLine.col + lastOnLine.utf16Len)
480
+ return lastOnLine.byteStart + lastOnLine.byteLen;
481
+ // Empty line (only as the final position after a trailing newline) or col 0
482
+ // of a line with no chars → that line's start byte.
483
+ if (col === 0 && line < a.lineStarts.length)
484
+ return a.lineStarts[line] as number;
485
+ throw new Error(
486
+ `positionToByte: no character boundary at (line ${line}, col ${col})`,
487
+ );
488
+ }
@@ -0,0 +1,218 @@
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
+ | "invalid-apostrophe"
105
+ | "unterminated-string"
106
+ | "unclosed"
107
+ | "unbalanced-close"
108
+ | "bom"
109
+ | "non-ascii";
110
+
111
+ /**
112
+ * `bom` and `non-ascii` are POSITIONAL FACTS, not errors.
113
+ *
114
+ * This predicate exists so a renderer cannot give them error severity by
115
+ * accident, and it is a guard rather than a comment because the failure it
116
+ * prevents is silent and user-facing: `/system identity set name="router-🚀"`
117
+ * (`commands/explain/examples.md` example 22) is a perfectly legal command whose
118
+ * value is non-ASCII by design. Failing it under `--fail-on error` would be a
119
+ * false positive on correct input, and RouterOS comments and string values carry
120
+ * non-ASCII routinely.
121
+ *
122
+ * What these two regions actually record is where the byte-count-preserving
123
+ * normalization stood in for bytes the analyzer cannot read — i.e. the spans a
124
+ * consumer needs in order to map back to the original text. That is a coordinate
125
+ * fact. The other structural codes mark input the analyzer could not structurally
126
+ * resolve, which is a different thing.
127
+ *
128
+ * {@link hasStructuralDefect} is where that distinction is enforced, so a UTF-8
129
+ * comment can never flip a document's write tristate to `unknown`.
130
+ */
131
+ export function isPositionalFact(code: DefectCode): boolean {
132
+ return code === "bom" || code === "non-ascii";
133
+ }
134
+
135
+ /**
136
+ * Does this list carry a defect an analyzer must ABSTAIN on?
137
+ *
138
+ * The abstention gate, and the reason {@link isPositionalFact} is a function
139
+ * rather than a comment: a defect list is not uniformly "bad input". Nine codes
140
+ * mark structure the analyzer could not resolve — part of the document was never
141
+ * walked, so no verdict over it is safe. The other two are coordinate facts about
142
+ * bytes that were read perfectly well. Gating on `defects.length > 0` would
143
+ * abstain on `/system identity set name="router-🚀"`, a legal command, and on the
144
+ * ~12% of the phase-0 corpus that carries non-ASCII.
145
+ */
146
+ export function hasStructuralDefect(defects: readonly Defect[]): boolean {
147
+ return defects.some((d) => !isPositionalFact(d.code));
148
+ }
149
+
150
+ /** A defect spanning exactly the byte at `at`. */
151
+ export function defectAt(
152
+ code: DefectCode,
153
+ at: number,
154
+ detail?: string,
155
+ ): Defect {
156
+ return detail === undefined
157
+ ? { code, start: at, end: at + 1 }
158
+ : { code, start: at, end: at + 1, detail };
159
+ }
160
+
161
+ /**
162
+ * Shift every defect by `base` — the move that carries a block body's
163
+ * body-relative regions into document space.
164
+ *
165
+ * Always returns a new ARRAY, so a caller may accumulate into the result without
166
+ * touching the input; at `base === 0` the elements themselves are shared, which
167
+ * is safe because a Defect is never mutated after construction.
168
+ */
169
+ export function rebaseDefects(
170
+ defects: readonly Defect[],
171
+ base: number,
172
+ ): Defect[] {
173
+ if (base === 0) return [...defects];
174
+ return defects.map((d) =>
175
+ d.detail === undefined
176
+ ? { code: d.code, start: d.start + base, end: d.end + base }
177
+ : {
178
+ code: d.code,
179
+ start: d.start + base,
180
+ end: d.end + base,
181
+ detail: d.detail,
182
+ },
183
+ );
184
+ }
185
+
186
+ /**
187
+ * Concatenate defect lists, dropping exact duplicates.
188
+ *
189
+ * Identity is `code + start + end + detail` — the WHOLE region, never the code
190
+ * alone. That distinction is the entire point: the retired note channel
191
+ * de-duplicated by stringified note, so two `over-depth` events at bytes 40 and
192
+ * 900 became one `"over-depth"` entry and the second offset was gone. Here they
193
+ * are two defects.
194
+ *
195
+ * Order is stable (first occurrence wins) so a caller can report defects in
196
+ * discovery order without sorting; callers that want source order should sort by
197
+ * `start` themselves, since discovery order differs between the statement walk
198
+ * and the bracket walk.
199
+ *
200
+ * The key is JSON rather than a joined string so that an ABSENT `detail` and an
201
+ * empty-string one stay distinguishable, and so the separator cannot be a raw
202
+ * control byte sitting invisibly in this file.
203
+ */
204
+ export function mergeDefects(
205
+ ...lists: readonly (readonly Defect[])[]
206
+ ): Defect[] {
207
+ const seen = new Set<string>();
208
+ const out: Defect[] = [];
209
+ for (const list of lists) {
210
+ for (const d of list) {
211
+ const key = JSON.stringify([d.code, d.start, d.end, d.detail]);
212
+ if (seen.has(key)) continue;
213
+ seen.add(key);
214
+ out.push(d);
215
+ }
216
+ }
217
+ return out;
218
+ }