@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
|
@@ -24,7 +24,10 @@ import type {
|
|
|
24
24
|
ExplainSymbolOccurrence,
|
|
25
25
|
ExplainValueOccurrence,
|
|
26
26
|
} from "../explain.ts";
|
|
27
|
-
import {
|
|
27
|
+
import { scopeBlocksIn } from "./blocks.ts";
|
|
28
|
+
import { documentRange, type MaskedRange, nestedRange } from "./segment.ts";
|
|
29
|
+
import type { SplitOwnerIndex } from "./split-owner.ts";
|
|
30
|
+
import { buildSplitOwnerIndex } from "./split-owner.ts";
|
|
28
31
|
import type { SymbolOccurrence } from "./symbols.ts";
|
|
29
32
|
import type { DocumentVerbSplit } from "./verbsplit.ts";
|
|
30
33
|
|
|
@@ -36,88 +39,118 @@ interface BlockInfo {
|
|
|
36
39
|
branchArm?: "do" | "else";
|
|
37
40
|
}
|
|
38
41
|
|
|
42
|
+
/**
|
|
43
|
+
* One binding's reaching literals at a program point.
|
|
44
|
+
*
|
|
45
|
+
* Treated as immutable: a state is replaced, never edited in place, so the same
|
|
46
|
+
* object can sit in the live map and in a journal entry at once. That aliasing
|
|
47
|
+
* is what makes an unchanged binding cost nothing at a block edge (#320).
|
|
48
|
+
*/
|
|
39
49
|
type BindingState = {
|
|
40
|
-
valueIds:
|
|
41
|
-
unknown: boolean;
|
|
50
|
+
readonly valueIds: ReadonlySet<string>;
|
|
51
|
+
readonly unknown: boolean;
|
|
42
52
|
};
|
|
43
53
|
|
|
44
|
-
function
|
|
45
|
-
source: Map<string, BindingState>,
|
|
46
|
-
): Map<string, BindingState> {
|
|
47
|
-
const out = new Map<string, BindingState>();
|
|
48
|
-
for (const [k, v] of source)
|
|
49
|
-
out.set(k, { valueIds: new Set(v.valueIds), unknown: v.unknown });
|
|
50
|
-
return out;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function setsEqual(a: Set<string>, b: Set<string>): boolean {
|
|
54
|
+
function setsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
|
54
55
|
if (a.size !== b.size) return false;
|
|
55
56
|
for (const v of a) if (!b.has(v)) return false;
|
|
56
57
|
return true;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Merge one binding across a non-exhaustive block edge: the block may not have
|
|
62
|
+
* run, so the state after it admits both the pre-block value and whatever the
|
|
63
|
+
* block left. A loop body may also have run more than once, which is what makes
|
|
64
|
+
* a changed binding `unknown` even when both ends spell the same literal.
|
|
65
|
+
*
|
|
66
|
+
* Returning `snap` itself when the block did not change the binding is
|
|
67
|
+
* deliberate: it is the case that used to cost a full map copy.
|
|
68
|
+
*/
|
|
69
|
+
function mergeBlockExit(
|
|
70
|
+
snap: BindingState | undefined,
|
|
71
|
+
current: BindingState,
|
|
72
|
+
isLoop: boolean,
|
|
73
|
+
): BindingState {
|
|
74
|
+
// Introduced inside the block: after the block it may never have been set.
|
|
75
|
+
if (snap === undefined)
|
|
76
|
+
return { valueIds: new Set(current.valueIds), unknown: true };
|
|
77
|
+
if (
|
|
78
|
+
!snap.unknown &&
|
|
79
|
+
!current.unknown &&
|
|
80
|
+
setsEqual(snap.valueIds, current.valueIds)
|
|
81
|
+
)
|
|
82
|
+
return snap;
|
|
83
|
+
return {
|
|
84
|
+
valueIds: new Set([...snap.valueIds, ...current.valueIds]),
|
|
85
|
+
unknown: snap.unknown || current.unknown || isLoop,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Merge one binding across an exhaustive `if`/`else` pair. Exactly one arm runs,
|
|
91
|
+
* so a value seen in both arms stays known and the reaching set is their union;
|
|
92
|
+
* a binding only one arm defines is `unknown` after the merge.
|
|
93
|
+
*/
|
|
94
|
+
function mergeBranchArms(
|
|
95
|
+
left: BindingState | undefined,
|
|
96
|
+
right: BindingState | undefined,
|
|
97
|
+
): BindingState | undefined {
|
|
98
|
+
if (left === undefined || right === undefined) {
|
|
99
|
+
const present = left ?? right;
|
|
100
|
+
if (present === undefined) return undefined;
|
|
101
|
+
return { valueIds: new Set(present.valueIds), unknown: true };
|
|
81
102
|
}
|
|
82
|
-
return
|
|
103
|
+
return {
|
|
104
|
+
valueIds: new Set([...left.valueIds, ...right.valueIds]),
|
|
105
|
+
unknown: left.unknown || right.unknown,
|
|
106
|
+
};
|
|
83
107
|
}
|
|
84
108
|
|
|
85
109
|
/**
|
|
86
|
-
* Collect all scope blocks in `
|
|
110
|
+
* Collect all scope blocks in `range`, recursively.
|
|
87
111
|
*
|
|
88
112
|
* `splits` are the flattened statement splits for the same document; they are
|
|
89
113
|
* used to decide whether a `do={...}` body belongs to a loop verb (`foreach`,
|
|
90
114
|
* `for`, `while`) or to a branch (`if`, `do`, `else`, `on-error`).
|
|
115
|
+
*
|
|
116
|
+
* The range replaces the `(text, base)` pair it used to take — `base` was
|
|
117
|
+
* already the body's offset in the document, so nothing is lost, and the
|
|
118
|
+
* document's comment mask now descends with it instead of being re-derived over
|
|
119
|
+
* each nested body. A body carries every body below it, so that re-derivation
|
|
120
|
+
* was the O(bytes x depth) term (#322).
|
|
91
121
|
*/
|
|
92
122
|
function collectBlocks(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
analyzed: string,
|
|
123
|
+
range: MaskedRange,
|
|
124
|
+
owners: SplitOwnerIndex,
|
|
96
125
|
splits: readonly DocumentVerbSplit[],
|
|
97
126
|
): BlockInfo[] {
|
|
98
127
|
const out: BlockInfo[] = [];
|
|
99
|
-
const blocks =
|
|
100
|
-
const start =
|
|
128
|
+
const blocks = scopeBlocksIn(range).map((block) => {
|
|
129
|
+
const start = range.start + block.start;
|
|
101
130
|
const bracePos = start - 1;
|
|
102
|
-
const ownerIndex =
|
|
103
|
-
analyzed,
|
|
104
|
-
splits,
|
|
105
|
-
bracePos,
|
|
106
|
-
bracePos + 1,
|
|
107
|
-
);
|
|
131
|
+
const ownerIndex = owners.ownerOf(bracePos, bracePos + 1);
|
|
108
132
|
return {
|
|
109
133
|
block,
|
|
110
134
|
ownerIndex,
|
|
111
135
|
owner: ownerIndex === undefined ? undefined : splits[ownerIndex],
|
|
112
136
|
};
|
|
113
137
|
});
|
|
138
|
+
// Group the siblings once. Re-filtering `blocks` inside a loop over
|
|
139
|
+
// `blocks` is the same quadratic shape #317 removed from the owner lookup,
|
|
140
|
+
// and a `do={…}`-heavy document grows both factors together.
|
|
141
|
+
const byOwner = new Map<number, typeof blocks>();
|
|
142
|
+
for (const entry of blocks) {
|
|
143
|
+
if (entry.ownerIndex === undefined) continue;
|
|
144
|
+
const list = byOwner.get(entry.ownerIndex);
|
|
145
|
+
if (list === undefined) byOwner.set(entry.ownerIndex, [entry]);
|
|
146
|
+
else list.push(entry);
|
|
147
|
+
}
|
|
114
148
|
const exhaustiveGroups = new Set<number>();
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
);
|
|
149
|
+
// One pass per owner, not per block: every sibling of an `if` reaches the
|
|
150
|
+
// same verdict, so asking each of them re-runs both filters over the whole
|
|
151
|
+
// group. Siblings share an owner index, so they share the owner itself.
|
|
152
|
+
for (const [ownerIndex, siblings] of byOwner) {
|
|
153
|
+
if (siblings[0]?.owner?.verb?.toLowerCase() !== "if") continue;
|
|
121
154
|
const doBlocks = siblings.filter(
|
|
122
155
|
(other) => other.block.name.toLowerCase() === "do",
|
|
123
156
|
);
|
|
@@ -130,11 +163,11 @@ function collectBlocks(
|
|
|
130
163
|
(doBlocks[0]?.block.start ?? Number.POSITIVE_INFINITY) <
|
|
131
164
|
(elseBlocks[0]?.block.start ?? Number.NEGATIVE_INFINITY)
|
|
132
165
|
)
|
|
133
|
-
exhaustiveGroups.add(
|
|
166
|
+
exhaustiveGroups.add(ownerIndex);
|
|
134
167
|
}
|
|
135
168
|
|
|
136
169
|
for (const { block: b, ownerIndex, owner } of blocks) {
|
|
137
|
-
const start =
|
|
170
|
+
const start = range.start + b.start;
|
|
138
171
|
const end = start + b.body.length;
|
|
139
172
|
// If source mapping cannot identify the owning statement, use loop-like
|
|
140
173
|
// merging: an incomplete reaching set is safer than treating an unknown
|
|
@@ -156,7 +189,13 @@ function collectBlocks(
|
|
|
156
189
|
? { branchGroup: ownerIndex, branchArm }
|
|
157
190
|
: {}),
|
|
158
191
|
});
|
|
159
|
-
out.push(
|
|
192
|
+
out.push(
|
|
193
|
+
...collectBlocks(
|
|
194
|
+
nestedRange(range, b.start, b.start + b.body.length),
|
|
195
|
+
owners,
|
|
196
|
+
splits,
|
|
197
|
+
),
|
|
198
|
+
);
|
|
160
199
|
}
|
|
161
200
|
return out;
|
|
162
201
|
}
|
|
@@ -170,35 +209,11 @@ function collectBlocks(
|
|
|
170
209
|
* link `i` to the `in` attribute. Only the same statement's values are
|
|
171
210
|
* candidates, which also prevents cross-statement mis-linking.
|
|
172
211
|
*/
|
|
173
|
-
function owningSplitIndex(
|
|
174
|
-
analyzed: string,
|
|
175
|
-
splits: readonly DocumentVerbSplit[],
|
|
176
|
-
start: number,
|
|
177
|
-
end: number,
|
|
178
|
-
): number | undefined {
|
|
179
|
-
let owner: number | undefined;
|
|
180
|
-
let ownerLength = Number.POSITIVE_INFINITY;
|
|
181
|
-
for (let i = 0; i < splits.length; i++) {
|
|
182
|
-
const split = splits[i] as DocumentVerbSplit;
|
|
183
|
-
if (split.span.start > start || end > split.span.end) continue;
|
|
184
|
-
// A widened fallback span cannot prove statement ownership. Match the
|
|
185
|
-
// same addressability gate used by `valuesOf`/`argumentsOf`.
|
|
186
|
-
if (analyzed.slice(split.span.start, split.span.end) !== split.text)
|
|
187
|
-
continue;
|
|
188
|
-
const length = split.span.end - split.span.start;
|
|
189
|
-
if (length < ownerLength) {
|
|
190
|
-
owner = i;
|
|
191
|
-
ownerLength = length;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
return owner;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
212
|
function defValueId(
|
|
198
213
|
occurrence: SymbolOccurrence,
|
|
199
214
|
splitIndex: number | undefined,
|
|
200
215
|
values: readonly ExplainValueOccurrence[],
|
|
201
|
-
|
|
216
|
+
positionalValuesBySplit: ReadonlyMap<number, readonly number[]>,
|
|
202
217
|
): string | undefined {
|
|
203
218
|
if (
|
|
204
219
|
occurrence.role !== "declaration" &&
|
|
@@ -217,18 +232,15 @@ function defValueId(
|
|
|
217
232
|
|
|
218
233
|
let best: ExplainValueOccurrence | undefined;
|
|
219
234
|
let bestDist = Number.POSITIVE_INFINITY;
|
|
220
|
-
|
|
235
|
+
// Parent statement spans include their scope bodies. Requiring the
|
|
236
|
+
// innermost addressable statement to match prevents `:local f do={:put 1}`
|
|
237
|
+
// from claiming the body's `1`, and prevents one branch from lending a
|
|
238
|
+
// literal to a non-literal definition in another branch. That same
|
|
239
|
+
// requirement is what lets the candidates be bucketed per statement once
|
|
240
|
+
// instead of re-scanned per symbol (#317).
|
|
241
|
+
for (const i of positionalValuesBySplit.get(splitIndex) ?? []) {
|
|
221
242
|
const value = values[i] as ExplainValueOccurrence;
|
|
222
|
-
// Parent statement spans include their scope bodies. Requiring the
|
|
223
|
-
// innermost addressable statement to match prevents `:local f do={:put
|
|
224
|
-
// 1}` from claiming the body's `1`, and prevents one branch from lending
|
|
225
|
-
// a literal to a non-literal definition in another branch.
|
|
226
|
-
if (valueSplitIndexes[i] !== splitIndex) continue;
|
|
227
|
-
if (value.kind === "element") continue;
|
|
228
243
|
if (value.span.start <= occurrence.start) continue;
|
|
229
|
-
// RouterOS declaration/assignment RHS syntax is positional. Attribute
|
|
230
|
-
// values in the same statement belong to the directive, not this symbol.
|
|
231
|
-
if (value.kind !== "positional") continue;
|
|
232
244
|
const dist = value.span.start - occurrence.end;
|
|
233
245
|
if (dist < 0 || dist >= bestDist) continue;
|
|
234
246
|
bestDist = dist;
|
|
@@ -252,12 +264,25 @@ export function augmentSymbolOccurrences(
|
|
|
252
264
|
splits: readonly DocumentVerbSplit[],
|
|
253
265
|
): ExplainSymbolOccurrence[] {
|
|
254
266
|
const augmented: ExplainSymbolOccurrence[] = [];
|
|
267
|
+
const owners = buildSplitOwnerIndex(analyzed, splits);
|
|
255
268
|
const symbolSplitIndexes = symbols.map((occ) =>
|
|
256
|
-
|
|
257
|
-
);
|
|
258
|
-
const valueSplitIndexes = values.map((value) =>
|
|
259
|
-
owningSplitIndex(analyzed, splits, value.span.start, value.span.end),
|
|
269
|
+
owners.ownerOf(occ.start, occ.end),
|
|
260
270
|
);
|
|
271
|
+
// RouterOS declaration/assignment RHS syntax is positional, so an attribute
|
|
272
|
+
// value in the same statement belongs to the directive, not to the symbol —
|
|
273
|
+
// `:foreach i in={…}` must not link `i` to the `in` attribute. Bucketing
|
|
274
|
+
// only the positional values keeps `defValueId`'s search inside the one
|
|
275
|
+
// statement that can supply the RHS.
|
|
276
|
+
const positionalValuesBySplit = new Map<number, number[]>();
|
|
277
|
+
for (let i = 0; i < values.length; i++) {
|
|
278
|
+
const value = values[i] as ExplainValueOccurrence;
|
|
279
|
+
if (value.kind !== "positional") continue;
|
|
280
|
+
const splitIndex = owners.ownerOf(value.span.start, value.span.end);
|
|
281
|
+
if (splitIndex === undefined) continue;
|
|
282
|
+
const list = positionalValuesBySplit.get(splitIndex);
|
|
283
|
+
if (list === undefined) positionalValuesBySplit.set(splitIndex, [i]);
|
|
284
|
+
else list.push(i);
|
|
285
|
+
}
|
|
261
286
|
const valueOrder = new Map(values.map((value, index) => [value.id, index]));
|
|
262
287
|
// Map from occurrence index -> valueId for quick lookup during flow pass
|
|
263
288
|
const defMap = new Map<number, string>();
|
|
@@ -267,12 +292,12 @@ export function augmentSymbolOccurrences(
|
|
|
267
292
|
occ,
|
|
268
293
|
symbolSplitIndexes[i],
|
|
269
294
|
values,
|
|
270
|
-
|
|
295
|
+
positionalValuesBySplit,
|
|
271
296
|
);
|
|
272
297
|
if (vid !== undefined) defMap.set(i, vid);
|
|
273
298
|
}
|
|
274
299
|
|
|
275
|
-
const blocks = collectBlocks(analyzed,
|
|
300
|
+
const blocks = collectBlocks(documentRange(analyzed), owners, splits);
|
|
276
301
|
type FlowEvent =
|
|
277
302
|
| {
|
|
278
303
|
offset: number;
|
|
@@ -331,18 +356,51 @@ export function augmentSymbolOccurrences(
|
|
|
331
356
|
|
|
332
357
|
// Occurrences are already sorted by start
|
|
333
358
|
const ordered = symbols.map((occ, index) => ({ occ, index }));
|
|
334
|
-
// Flow state
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
359
|
+
// Flow state.
|
|
360
|
+
//
|
|
361
|
+
// One live map, plus a journal per open block recording the pre-block value
|
|
362
|
+
// of every binding that block writes. A block edge then costs what the block
|
|
363
|
+
// CHANGED, not what the document declares (#320): snapshotting the whole map
|
|
364
|
+
// at every enter and rebuilding it at every exit made a branch-dense script
|
|
365
|
+
// quadratic — 3.1 M binding copies on 89 KiB — because blocks and bindings
|
|
366
|
+
// both grow with the document.
|
|
367
|
+
const current = new Map<string, BindingState>();
|
|
368
|
+
type Frame = {
|
|
369
|
+
/** binding id -> its value when this block was entered (absent = new). */
|
|
370
|
+
journal: Map<string, BindingState | undefined>;
|
|
338
371
|
isLoop: boolean;
|
|
339
372
|
branchGroup?: number;
|
|
340
373
|
branchArm?: "do" | "else";
|
|
341
|
-
}
|
|
374
|
+
};
|
|
375
|
+
const stack: Frame[] = [];
|
|
376
|
+
/** Per exhaustive `if` group: the `do` arm's pre/post pair per touched id. */
|
|
342
377
|
const branchGroups = new Map<
|
|
343
378
|
number,
|
|
344
|
-
|
|
379
|
+
Map<string, { snap: BindingState | undefined; doFinal: BindingState }>
|
|
345
380
|
>();
|
|
381
|
+
/** Record a binding's pre-block value once per frame; later writes keep it. */
|
|
382
|
+
const note = (
|
|
383
|
+
frame: Frame | undefined,
|
|
384
|
+
id: string,
|
|
385
|
+
before: BindingState | undefined,
|
|
386
|
+
): void => {
|
|
387
|
+
if (frame !== undefined && !frame.journal.has(id))
|
|
388
|
+
frame.journal.set(id, before);
|
|
389
|
+
};
|
|
390
|
+
const setBinding = (id: string, state: BindingState): void => {
|
|
391
|
+
note(stack[stack.length - 1], id, current.get(id));
|
|
392
|
+
current.set(id, state);
|
|
393
|
+
};
|
|
394
|
+
/** Write a merged/rewound value for a binding the enclosing frame now owns. */
|
|
395
|
+
const applyToParent = (
|
|
396
|
+
id: string,
|
|
397
|
+
before: BindingState | undefined,
|
|
398
|
+
after: BindingState | undefined,
|
|
399
|
+
): void => {
|
|
400
|
+
note(stack[stack.length - 1], id, before);
|
|
401
|
+
if (after === undefined) current.delete(id);
|
|
402
|
+
else current.set(id, after);
|
|
403
|
+
};
|
|
346
404
|
let eventIdx = 0;
|
|
347
405
|
const processEventsUpTo = (pos: number): void => {
|
|
348
406
|
while (eventIdx < events.length) {
|
|
@@ -351,86 +409,64 @@ export function augmentSymbolOccurrences(
|
|
|
351
409
|
eventIdx++;
|
|
352
410
|
if (ev.type === "unknown-definition") {
|
|
353
411
|
for (const bid of ev.bindingIds)
|
|
354
|
-
|
|
412
|
+
setBinding(bid, { valueIds: new Set(), unknown: true });
|
|
355
413
|
continue;
|
|
356
414
|
}
|
|
357
415
|
if (ev.type === "enter") {
|
|
358
416
|
stack.push({
|
|
359
|
-
|
|
417
|
+
journal: new Map(),
|
|
360
418
|
isLoop: ev.isLoop,
|
|
361
419
|
...(ev.branchGroup === undefined || ev.branchArm === undefined
|
|
362
420
|
? {}
|
|
363
421
|
: { branchGroup: ev.branchGroup, branchArm: ev.branchArm }),
|
|
364
422
|
});
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
const merged = cloneStateMap(snapshot);
|
|
386
|
-
const allIds = new Set<string>([...snapshot.keys(), ...current.keys()]);
|
|
387
|
-
for (const id of allIds) {
|
|
388
|
-
const snap = snapshot.get(id);
|
|
389
|
-
const cur = current.get(id);
|
|
390
|
-
if (snap === undefined && cur !== undefined) {
|
|
391
|
-
// Defined inside block only — after block it may not have run
|
|
392
|
-
merged.set(id, {
|
|
393
|
-
valueIds: new Set(cur.valueIds),
|
|
394
|
-
unknown: true,
|
|
395
|
-
});
|
|
396
|
-
} else if (snap !== undefined && cur !== undefined) {
|
|
397
|
-
const equal =
|
|
398
|
-
!snap.unknown &&
|
|
399
|
-
!cur.unknown &&
|
|
400
|
-
setsEqual(snap.valueIds, cur.valueIds);
|
|
401
|
-
if (!equal) {
|
|
402
|
-
const union = new Set<string>([
|
|
403
|
-
...snap.valueIds,
|
|
404
|
-
...cur.valueIds,
|
|
405
|
-
]);
|
|
406
|
-
const unknown = snap.unknown || cur.unknown || isLoop;
|
|
407
|
-
merged.set(id, { valueIds: union, unknown });
|
|
408
|
-
} else if (snap.unknown || cur.unknown) {
|
|
409
|
-
merged.set(id, {
|
|
410
|
-
valueIds: new Set(snap.valueIds),
|
|
411
|
-
unknown: true,
|
|
412
|
-
});
|
|
413
|
-
}
|
|
414
|
-
}
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const top = stack.pop();
|
|
426
|
+
if (top === undefined) continue;
|
|
427
|
+
// A binding the block never wrote has the same state on both sides of
|
|
428
|
+
// the edge, and every merge rule below maps that pair back to itself.
|
|
429
|
+
// So the journal — the bindings the block DID write — is the whole
|
|
430
|
+
// edge, whatever else the document has declared.
|
|
431
|
+
if (top.branchGroup !== undefined && top.branchArm === "do") {
|
|
432
|
+
const arm = new Map<
|
|
433
|
+
string,
|
|
434
|
+
{ snap: BindingState | undefined; doFinal: BindingState }
|
|
435
|
+
>();
|
|
436
|
+
for (const [id, snap] of top.journal) {
|
|
437
|
+
arm.set(id, { snap, doFinal: current.get(id) as BindingState });
|
|
438
|
+
// Rewind: the `else` arm starts from the state before the `if`.
|
|
439
|
+
if (snap === undefined) current.delete(id);
|
|
440
|
+
else current.set(id, snap);
|
|
415
441
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
442
|
+
branchGroups.set(top.branchGroup, arm);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (top.branchGroup !== undefined && top.branchArm === "else") {
|
|
446
|
+
const arm = branchGroups.get(top.branchGroup);
|
|
447
|
+
if (arm !== undefined) {
|
|
448
|
+
const touched = new Set<string>([
|
|
449
|
+
...arm.keys(),
|
|
450
|
+
...top.journal.keys(),
|
|
451
|
+
]);
|
|
452
|
+
for (const id of touched) {
|
|
453
|
+
const recorded = arm.get(id);
|
|
454
|
+
// Untouched by one arm means that arm left the pre-`if` value.
|
|
455
|
+
const before =
|
|
456
|
+
recorded === undefined ? top.journal.get(id) : recorded.snap;
|
|
457
|
+
const left = recorded === undefined ? before : recorded.doFinal;
|
|
458
|
+
applyToParent(id, before, mergeBranchArms(left, current.get(id)));
|
|
430
459
|
}
|
|
460
|
+
branchGroups.delete(top.branchGroup);
|
|
461
|
+
continue;
|
|
431
462
|
}
|
|
432
|
-
current = merged;
|
|
433
463
|
}
|
|
464
|
+
for (const [id, snap] of top.journal)
|
|
465
|
+
applyToParent(
|
|
466
|
+
id,
|
|
467
|
+
snap,
|
|
468
|
+
mergeBlockExit(snap, current.get(id) as BindingState, top.isLoop),
|
|
469
|
+
);
|
|
434
470
|
}
|
|
435
471
|
};
|
|
436
472
|
|
|
@@ -453,7 +489,7 @@ export function augmentSymbolOccurrences(
|
|
|
453
489
|
(base as { valueId?: string }).valueId = vid;
|
|
454
490
|
// Update flow state for this binding
|
|
455
491
|
for (const bid of occ.bindingIds) {
|
|
456
|
-
|
|
492
|
+
setBinding(bid, { valueIds: new Set([vid]), unknown: false });
|
|
457
493
|
}
|
|
458
494
|
} else if (
|
|
459
495
|
occ.role === "declaration" ||
|
|
@@ -463,7 +499,7 @@ export function augmentSymbolOccurrences(
|
|
|
463
499
|
// Defining occurrence with no literal RHS — introduces unknown
|
|
464
500
|
if (occ.bindingIds.length > 0 && !deferredDefinitions.has(index)) {
|
|
465
501
|
for (const bid of occ.bindingIds) {
|
|
466
|
-
|
|
502
|
+
setBinding(bid, { valueIds: new Set(), unknown: true });
|
|
467
503
|
}
|
|
468
504
|
}
|
|
469
505
|
} else if (occ.role === "reference" || occ.role === "field") {
|
package/src/explain/symbols.ts
CHANGED
|
@@ -477,7 +477,10 @@ const isIdentStart = (c: string): boolean => /[A-Za-z_]/.test(c);
|
|
|
477
477
|
export function resolveSymbols(original: string): SymbolAnalysis {
|
|
478
478
|
const analysis = analyzeCoordinates(original);
|
|
479
479
|
// `analyzed` is pure ASCII, so a string built from it has index === byte.
|
|
480
|
-
|
|
480
|
+
// An already-ASCII input is that string, so the decode is skipped (#322).
|
|
481
|
+
const text = analysis.ascii
|
|
482
|
+
? original
|
|
483
|
+
: new TextDecoder().decode(analysis.analyzed);
|
|
481
484
|
|
|
482
485
|
// S7's "inside a `(…)` expression" test, precomputed in one forward pass.
|
|
483
486
|
// Scanning backward per bare word was O(n²) on a long single-line script: an
|
|
@@ -875,6 +878,23 @@ export function resolveSymbols(original: string): SymbolAnalysis {
|
|
|
875
878
|
break;
|
|
876
879
|
}
|
|
877
880
|
|
|
881
|
+
// --- `'` is not a RouterOS token (#355) ---------------------------------
|
|
882
|
+
//
|
|
883
|
+
// Same fail-closed stop as the malformed escape above, for the same reason:
|
|
884
|
+
// the device rejects at this byte and classes nothing after it, so carrying
|
|
885
|
+
// confident symbol classes across it would publish facts about bytes the
|
|
886
|
+
// console never reached. `:put 'abc'; :put $after` must not name `$after`.
|
|
887
|
+
//
|
|
888
|
+
// This walk reaches here only in CODE — a `"` string is consumed by the
|
|
889
|
+
// quote phase above and a comment by the `#` branch below — so an
|
|
890
|
+
// apostrophe inside either never trips it, matching `segment.ts`.
|
|
891
|
+
// Occurrences BEFORE the defect stand (the lab's X1 rule).
|
|
892
|
+
if (c === "'") {
|
|
893
|
+
defects.push(defectAt("invalid-apostrophe", i, "'"));
|
|
894
|
+
defect = true;
|
|
895
|
+
break;
|
|
896
|
+
}
|
|
897
|
+
|
|
878
898
|
// --- comments: `#` in statement-leading position (Q1 rule H4) ------------
|
|
879
899
|
//
|
|
880
900
|
// Done inline rather than through `segment.ts`'s shared `maskComments`:
|
package/src/explain/transport.ts
CHANGED
|
@@ -101,6 +101,16 @@ export interface ExplainTransportInput {
|
|
|
101
101
|
arguments: ExplainArguments;
|
|
102
102
|
/** The statement exactly as written, for the execute invocation. */
|
|
103
103
|
source: string;
|
|
104
|
+
/**
|
|
105
|
+
* Why the statement is not accepted by RouterOS, when the analysis has decided
|
|
106
|
+
* that it is not (#311).
|
|
107
|
+
*
|
|
108
|
+
* Distinct from an unread argument list, which means "offline cannot name the
|
|
109
|
+
* request" and correctly falls through to `execute` — the raw CLI surface can
|
|
110
|
+
* still carry those bytes. Here the bytes themselves are the defect, so no
|
|
111
|
+
* centrs surface should be offered for them and none is rendered.
|
|
112
|
+
*/
|
|
113
|
+
rejected?: string;
|
|
104
114
|
}
|
|
105
115
|
|
|
106
116
|
export interface ExplainTransportOptions {
|
|
@@ -424,6 +434,13 @@ export function classifyExplainTransport(
|
|
|
424
434
|
const { path, verb, args = {} } = input.command;
|
|
425
435
|
const lower = verb.toLowerCase();
|
|
426
436
|
|
|
437
|
+
// Before every mapping rule: a statement the analysis has REJECTED gets no
|
|
438
|
+
// invocation at all. Falling through to `execute` here would print a
|
|
439
|
+
// ready-to-paste `centrs execute` line for bytes the same result declares a
|
|
440
|
+
// syntax error, which is the false confident reading in a second place.
|
|
441
|
+
if (input.rejected !== undefined)
|
|
442
|
+
return unknown(input.rejected, options.evidenceId);
|
|
443
|
+
|
|
427
444
|
// Root scripting directives have no RouterOS REST path. They always ride the
|
|
428
445
|
// raw execute surface, regardless of whether their operands are literal.
|
|
429
446
|
if (path === "/")
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* B2 value fill — claims argument value bytes and array-literal interiors on the residual.
|
|
3
3
|
*
|
|
4
4
|
* This is the third B2 fill of #264, after the argument `name=` fill. It runs
|
|
5
|
-
* **after** proof-only `spans` (comment + variable-*)
|
|
6
|
-
* **before** the operator fill, so the fill order is
|
|
7
|
-
* `spans → arg → value → operator`. That ordering retires
|
|
8
|
-
* already noted in `operator-tokens.ts`: once value bytes
|
|
9
|
-
* inside a value is no longer read as concatenation.
|
|
5
|
+
* **after** proof-only `spans` (comment + variable-*), the `arg` fill and the
|
|
6
|
+
* `escaped` fill, and **before** the operator fill, so the fill order is
|
|
7
|
+
* structural: `spans → arg → escaped → value → operator`. That ordering retires
|
|
8
|
+
* the glued-dot residue already noted in `operator-tokens.ts`: once value bytes
|
|
9
|
+
* are claimed a `.` inside a value is no longer read as concatenation.
|
|
10
10
|
*
|
|
11
11
|
* Source is `data.values.occurrences` (already rebased into document analyzed-byte
|
|
12
12
|
* space by `src/explain.ts`), not `Argument.valueSpan`. That is the load-bearing
|
|
@@ -20,14 +20,17 @@
|
|
|
20
20
|
* that is nobody's `parent`. Container detection is `Set` of `parent` ids — no
|
|
21
21
|
* tree walk, members follow their container in the same flat list.
|
|
22
22
|
*
|
|
23
|
-
* `span` is quotes-INCLUDED by contract
|
|
24
|
-
* a
|
|
23
|
+
* `span` is quotes-INCLUDED by contract, and the quotes stay part of the `value`
|
|
24
|
+
* run — a consumer recovers them from its own first and last byte (#264 B5).
|
|
25
|
+
* Escapes do NOT: the `escaped` fill claims them one stage earlier, so a value
|
|
26
|
+
* carrying one arrives here already holed and this fill emits a fragment either
|
|
27
|
+
* side. Nothing double-claims, because that fill offered only residual.
|
|
25
28
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
29
|
+
* One `value` class for every leaf span, regardless of shape/observed/schema
|
|
30
|
+
* type — the three #225 facts stay where they already are, in `data.values[]`,
|
|
31
|
+
* joined by offset. Do not read `ExplainValueOccurrence.facts` to pick a class.
|
|
32
|
+
* #264 B5 kept this merge for that reason: a class per shape would be a fourth
|
|
33
|
+
* copy of those axes, free to contradict the three that are already published.
|
|
31
34
|
*
|
|
32
35
|
* ~1,720 leaf bytes are already held by `variable-*` spans (a variable inside
|
|
33
36
|
* a quoted value). Reuses the clipping shape from `arg-tokens.ts`: offer only
|