@tikoci/centrs 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +309 -0
- package/GLOSSARY.txt +40 -1
- package/README.md +8 -4
- package/commands/AGENTS.md +7 -4
- package/commands/api/AGENTS.md +13 -1
- package/commands/api/README.md +37 -8
- package/commands/api/examples.md +31 -1
- package/commands/execute/README.md +39 -10
- package/commands/execute/examples.md +79 -7
- package/commands/explain/README.md +1195 -155
- package/commands/explain/examples.md +99 -3
- package/commands/mcp/README.md +7 -4
- package/commands/mcp/examples.md +2 -2
- package/commands/terminal/examples.md +7 -1
- package/docs/CLI.md +2 -2
- package/docs/CONSTITUTION.md +72 -15
- package/docs/MATRIX.md +89 -52
- package/docs/errors/validation/syntax.md +29 -4
- package/package.json +13 -2
- package/src/api-fanout.ts +17 -4
- package/src/api.ts +254 -67
- package/src/cli/api.ts +1 -1
- package/src/cli/btest.ts +1 -1
- package/src/cli/explain.ts +1 -1
- package/src/cli/terminal.ts +1 -1
- package/src/core/envelope.ts +53 -0
- package/src/core/error-catalog.ts +2 -1
- package/src/core/execute-command.ts +148 -0
- package/src/core/inspect.ts +36 -4
- package/src/core/version.ts +4 -0
- package/src/core/yaml.ts +86 -0
- package/src/execute-fanout.ts +18 -4
- package/src/execute.ts +225 -244
- package/src/explain/arg-tokens.ts +62 -25
- package/src/explain/args.ts +398 -52
- package/src/explain/blocks.ts +54 -11
- package/src/explain/brace-tokens.ts +4 -3
- package/src/explain/catalog.ts +42 -44
- package/src/explain/coordinates.ts +159 -0
- package/src/explain/defects.ts +2 -1
- package/src/explain/escape-tokens.ts +77 -0
- package/src/explain/highlight-projection.ts +308 -0
- package/src/explain/menus.ts +9 -5
- package/src/explain/operator-tokens.ts +13 -7
- package/src/explain/operators.ts +27 -20
- package/src/explain/path-tokens.ts +1 -1
- package/src/explain/pathresolve.ts +358 -113
- package/src/explain/quoted-string.ts +110 -20
- package/src/explain/scope-brace.ts +175 -22
- package/src/explain/segment.ts +429 -21
- package/src/explain/separator.ts +187 -0
- package/src/explain/split-owner.ts +194 -0
- package/src/explain/string-tokens.ts +14 -4
- package/src/explain/symbol-values.ts +209 -173
- package/src/explain/symbols.ts +21 -1
- package/src/explain/transport.ts +17 -0
- package/src/explain/value-tokens.ts +15 -12
- package/src/explain/verbsplit.ts +165 -23
- package/src/explain/write.ts +47 -6
- package/src/explain.ts +522 -72
- package/src/index.ts +19 -2
- package/src/mcp/server.ts +2 -1
- package/src/offline-gate.ts +148 -0
- package/src/retrieve-fanout.ts +1 -1
- package/src/retrieve.ts +1 -59
- package/src/settings.ts +2 -1
- package/src/transfer-fanout.ts +1 -1
- package/src/transfer.ts +1 -1
package/src/explain/verbsplit.ts
CHANGED
|
@@ -60,6 +60,18 @@
|
|
|
60
60
|
* its precedence over the punctuation guess, and the two places it deliberately
|
|
61
61
|
* does not apply. `splitRun` is again untouched — both tables are consumed here.
|
|
62
62
|
*
|
|
63
|
+
* **The other direction closes too (#324).** R9 kept `pathresolve` from
|
|
64
|
+
* claiming navigation where a verb was decided; the symmetric rule keeps
|
|
65
|
+
* `resolveVerb` from claiming a verb where the whole run names a known MENU
|
|
66
|
+
* and the segment it would promote is not in `VERBS`. `splitRun`'s punctuation
|
|
67
|
+
* fallback used to read `/ip/firewall/address-list name=ytkids` as verb
|
|
68
|
+
* `address-list` at `/ip/firewall` — a verb assembled from a path segment, on
|
|
69
|
+
* a statement whose verb is simply missing. It degrades to `ambiguous`. Priced
|
|
70
|
+
* on the frozen partition: 10 of 17,212 statements flip, every one of them a
|
|
71
|
+
* fabrication withdrawn (`commands/explain/README.md` → The menu path with
|
|
72
|
+
* arguments). Both tables stay floors — presence narrows, absence decides
|
|
73
|
+
* nothing.
|
|
74
|
+
*
|
|
63
75
|
* Like `blocks.ts`, this ships only the boundary primitives. Argument parsing
|
|
64
76
|
* (`k=v` / `?query` after the verb) belongs to the phase-1 canonical assembly,
|
|
65
77
|
* not to the Q6 question. `resolveVerb(text, context)` takes the enclosing menu
|
|
@@ -67,7 +79,7 @@
|
|
|
67
79
|
* resolver's context-certainty contract (#192).
|
|
68
80
|
*/
|
|
69
81
|
|
|
70
|
-
import {
|
|
82
|
+
import { scopeBlocksIn } from "./blocks.ts";
|
|
71
83
|
import { commandVerbIndex } from "./catalog.ts";
|
|
72
84
|
import type { Defect } from "./defects.ts";
|
|
73
85
|
import { isKnownMenuPath } from "./is-known-menu.ts";
|
|
@@ -76,6 +88,12 @@ import {
|
|
|
76
88
|
type Span,
|
|
77
89
|
type StatementAnalysis,
|
|
78
90
|
} from "./pathresolve.ts";
|
|
91
|
+
import {
|
|
92
|
+
documentRange,
|
|
93
|
+
type MaskedRange,
|
|
94
|
+
nestedRange,
|
|
95
|
+
rangeAt,
|
|
96
|
+
} from "./segment.ts";
|
|
79
97
|
import { SUBMENU_DIRECTIVES, VERBS } from "./verbs.ts";
|
|
80
98
|
|
|
81
99
|
const BARE_WORD = /^[A-Za-z][A-Za-z0-9._-]*$/;
|
|
@@ -135,8 +153,14 @@ interface Word {
|
|
|
135
153
|
* offset from a name is therefore wrong on continued statements, and
|
|
136
154
|
* {@link describeStatement} only ever publishes the run's END.
|
|
137
155
|
*/
|
|
138
|
-
function asciiWordSpans(text: string): Word
|
|
139
|
-
|
|
156
|
+
function* asciiWordSpans(text: string): Generator<Word> {
|
|
157
|
+
// Yielded ONE WORD AT A TIME rather than returned as a list. Both readers
|
|
158
|
+
// stop at the first word the leading run cannot take, and a statement's text
|
|
159
|
+
// carries its whole nested `do={…}` subtree — so building a word (and a span
|
|
160
|
+
// per character of it) for the rest of that subtree was work no caller ever
|
|
161
|
+
// looked at, once per enclosing level (#322). A word is complete when the
|
|
162
|
+
// next one starts, which is why the yield happens on the following boundary
|
|
163
|
+
// rather than at the point the word is opened.
|
|
140
164
|
let current: Word | null = null;
|
|
141
165
|
let i = 0;
|
|
142
166
|
while (i < text.length) {
|
|
@@ -152,6 +176,7 @@ function asciiWordSpans(text: string): Word[] {
|
|
|
152
176
|
continue;
|
|
153
177
|
}
|
|
154
178
|
if (isAsciiWhitespace(c)) {
|
|
179
|
+
if (current !== null) yield current;
|
|
155
180
|
current = null;
|
|
156
181
|
i++;
|
|
157
182
|
continue;
|
|
@@ -163,7 +188,6 @@ function asciiWordSpans(text: string): Word[] {
|
|
|
163
188
|
end: i + 1,
|
|
164
189
|
chars: [{ start: i, end: i + 1 }],
|
|
165
190
|
};
|
|
166
|
-
out.push(current);
|
|
167
191
|
} else {
|
|
168
192
|
current.name += c;
|
|
169
193
|
current.end = i + 1;
|
|
@@ -171,7 +195,7 @@ function asciiWordSpans(text: string): Word[] {
|
|
|
171
195
|
}
|
|
172
196
|
i++;
|
|
173
197
|
}
|
|
174
|
-
|
|
198
|
+
if (current !== null) yield current;
|
|
175
199
|
}
|
|
176
200
|
|
|
177
201
|
/**
|
|
@@ -284,14 +308,25 @@ export function runTokens(text: string): RunToken[] {
|
|
|
284
308
|
}
|
|
285
309
|
|
|
286
310
|
/** True when the statement is a scripting directive (`:x`, or a bare `x do={…}`). */
|
|
287
|
-
export function isDirective(text: string): boolean {
|
|
311
|
+
export function isDirective(text: string, range?: MaskedRange): boolean {
|
|
288
312
|
const t = trimAscii(text);
|
|
289
313
|
if (t.startsWith(":")) return true;
|
|
290
|
-
|
|
314
|
+
if (t.startsWith("/")) return false;
|
|
315
|
+
const start = text.length - trimAsciiStart(text).length;
|
|
316
|
+
return (
|
|
317
|
+
scopeBlocksIn(
|
|
318
|
+
range === undefined
|
|
319
|
+
? documentRange(t)
|
|
320
|
+
: nestedRange(range, start, start + t.length),
|
|
321
|
+
).length > 0
|
|
322
|
+
);
|
|
291
323
|
}
|
|
292
324
|
|
|
293
325
|
/** Everything the boundary rule needs from one statement's text. */
|
|
294
|
-
export function describeStatement(
|
|
326
|
+
export function describeStatement(
|
|
327
|
+
text: string,
|
|
328
|
+
range?: MaskedRange,
|
|
329
|
+
): {
|
|
295
330
|
run: RunToken[];
|
|
296
331
|
directive: boolean;
|
|
297
332
|
whole: boolean;
|
|
@@ -331,20 +366,30 @@ export function describeStatement(text: string): {
|
|
|
331
366
|
// V4 — did the run consume every whitespace-separated word? A slash-joined
|
|
332
367
|
// word expands to several run tokens, so compare against the SOURCE words the
|
|
333
368
|
// run covers, not the run length.
|
|
334
|
-
const words = asciiWordSpans(body);
|
|
335
369
|
let covered = 0;
|
|
370
|
+
let words = 0;
|
|
336
371
|
let index = 0;
|
|
372
|
+
// A word the run cannot take is proof there is one more word than the run
|
|
373
|
+
// covered, which is the whole of V4's question — so the walk stops there
|
|
374
|
+
// instead of counting the rest of the statement.
|
|
375
|
+
let everyWordCovered = true;
|
|
337
376
|
const runEnds: (number | null)[] = run.map(() => null);
|
|
338
|
-
for (const word of
|
|
377
|
+
for (const word of asciiWordSpans(body)) {
|
|
378
|
+
words++;
|
|
339
379
|
const parts = word.name.split("/").filter((p) => p.length > 0);
|
|
340
380
|
if (parts.length === 0) continue;
|
|
341
|
-
if (index + parts.length > run.length)
|
|
381
|
+
if (index + parts.length > run.length) {
|
|
382
|
+
everyWordCovered = false;
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
342
385
|
if (
|
|
343
386
|
!parts.every(
|
|
344
387
|
(p, i) => (run[index + i] as RunToken | undefined)?.name === p,
|
|
345
388
|
)
|
|
346
|
-
)
|
|
389
|
+
) {
|
|
390
|
+
everyWordCovered = false;
|
|
347
391
|
break;
|
|
392
|
+
}
|
|
348
393
|
// Only the word's LAST part ends where the word does; an earlier part is
|
|
349
394
|
// followed by more path inside the same word.
|
|
350
395
|
runEnds[index + parts.length - 1] = base + word.end;
|
|
@@ -353,8 +398,8 @@ export function describeStatement(text: string): {
|
|
|
353
398
|
}
|
|
354
399
|
return {
|
|
355
400
|
run,
|
|
356
|
-
directive: isDirective(text),
|
|
357
|
-
whole: covered === words
|
|
401
|
+
directive: isDirective(text, range),
|
|
402
|
+
whole: everyWordCovered && covered === words && run.length > 0,
|
|
358
403
|
runEnds,
|
|
359
404
|
};
|
|
360
405
|
}
|
|
@@ -586,6 +631,25 @@ function unknownSplit(why: string): VerbSplitRefusal {
|
|
|
586
631
|
};
|
|
587
632
|
}
|
|
588
633
|
|
|
634
|
+
/**
|
|
635
|
+
* What follows the leading run: an operand stream, a brace body, or unknown.
|
|
636
|
+
*
|
|
637
|
+
* Only the tail's FIRST byte is read, and only three answers come out of it, so
|
|
638
|
+
* this stays a shape test rather than a second parser. `"body"` is the `{` of a
|
|
639
|
+
* menu container or a scope block; `"argument"` is anything else that is there;
|
|
640
|
+
* `"none"` is a run with nothing after it (V4's bare path) or a tail whose
|
|
641
|
+
* offset `describeStatement` could not establish.
|
|
642
|
+
*/
|
|
643
|
+
function operandTail(
|
|
644
|
+
text: string,
|
|
645
|
+
runEnd: number | null,
|
|
646
|
+
): "argument" | "body" | "none" {
|
|
647
|
+
if (runEnd === null) return "none";
|
|
648
|
+
const tail = trimAsciiStart(text.slice(runEnd));
|
|
649
|
+
if (tail.length === 0) return "none";
|
|
650
|
+
return tail.startsWith("{") ? "body" : "argument";
|
|
651
|
+
}
|
|
652
|
+
|
|
589
653
|
/**
|
|
590
654
|
* Resolve one statement's verb/menu boundary, applying the enclosing menu
|
|
591
655
|
* `context`. Mirrors `pathresolve`'s base rule: a `:`/bare directive and an
|
|
@@ -593,12 +657,16 @@ function unknownSplit(why: string): VerbSplitRefusal {
|
|
|
593
657
|
* `context`. Substitution-headed and variable-segment statements are `unknown`
|
|
594
658
|
* — offline refuses rather than guessing (the Q3/Q4/Q14 fail-closed floor).
|
|
595
659
|
*/
|
|
596
|
-
export function resolveVerb(
|
|
660
|
+
export function resolveVerb(
|
|
661
|
+
text: string,
|
|
662
|
+
context: string,
|
|
663
|
+
range?: MaskedRange,
|
|
664
|
+
): VerbSplit {
|
|
597
665
|
const t = trimAscii(text);
|
|
598
666
|
if (t.startsWith("$") || t.startsWith("[") || t.startsWith("("))
|
|
599
667
|
return unknownSplit("dynamic or substitution-headed statement");
|
|
600
668
|
|
|
601
|
-
const { run, directive, whole, runEnds } = describeStatement(text);
|
|
669
|
+
const { run, directive, whole, runEnds } = describeStatement(text, range);
|
|
602
670
|
if (run.length === 0) return unknownSplit("no leading path token");
|
|
603
671
|
|
|
604
672
|
// `directive` already folds in the bare-directive case (`isDirective`), so it
|
|
@@ -657,6 +725,58 @@ export function resolveVerb(text: string, context: string): VerbSplit {
|
|
|
657
725
|
ambiguous: false,
|
|
658
726
|
why: `published command \`${candidates[catalogAt]}\``,
|
|
659
727
|
};
|
|
728
|
+
// #324 — the symmetric half of R9. R9 made `pathresolve`'s `menuNavPath`
|
|
729
|
+
// consult `VERBS` so it could not claim navigation where a verb was decided;
|
|
730
|
+
// this stops the punctuation rule claiming a verb where the run names a
|
|
731
|
+
// known MENU and the token it would promote is not a console verb.
|
|
732
|
+
// `/ip/firewall/address-list name=ytkids` has no verb in it: the punctuation
|
|
733
|
+
// rule's "no space token, arguments follow — last segment" fallback promotes
|
|
734
|
+
// `address-list`, and the spaced spelling `/ip firewall address-list name=x`
|
|
735
|
+
// promotes `firewall`, one level higher still. Both premises are available
|
|
736
|
+
// offline and both say no, so the reading is a fabrication rather than a
|
|
737
|
+
// guess with a basis.
|
|
738
|
+
//
|
|
739
|
+
// Only the fail-closed direction: PRESENCE in the tables decides, absence
|
|
740
|
+
// decides nothing, so an unlisted menu spelled `…/address-list` keeps
|
|
741
|
+
// today's reading. The vocabulary hit is excluded by the `VERBS` test itself
|
|
742
|
+
// (a vocabulary split promotes a `VERBS` member by construction), the
|
|
743
|
+
// published-command axis by `catalogAt` — first-order evidence about that
|
|
744
|
+
// exact path outranks a container listing of its prefix — and directives by
|
|
745
|
+
// R10, which is measured.
|
|
746
|
+
//
|
|
747
|
+
// This abstains, it does not reject: offline knows it cannot name a verb
|
|
748
|
+
// here, which needs no device evidence. Whether RouterOS refuses the shape
|
|
749
|
+
// outright is a device claim and is deliberately not made (contrast #311's
|
|
750
|
+
// `error`, which is CHR-grounded).
|
|
751
|
+
//
|
|
752
|
+
// One shape is excluded by the tail rather than by a table: a `{` after the
|
|
753
|
+
// run is a menu CONTAINER body (`/system {identity print}`, H7), where the
|
|
754
|
+
// run legitimately names a menu and no verb is missing at all.
|
|
755
|
+
// `invalidCommandBraceDiagnostics` reads that statement's `argsAt` to find
|
|
756
|
+
// the brace, so abstaining here would drop a real `error` on the statement
|
|
757
|
+
// after it. A tail this cannot see (`runEnds` null) is not classified, and
|
|
758
|
+
// the guard stands down there too.
|
|
759
|
+
if (
|
|
760
|
+
!directive &&
|
|
761
|
+
catalogAt === null &&
|
|
762
|
+
!split.ambiguous &&
|
|
763
|
+
split.verbAt !== null &&
|
|
764
|
+
!VERBS.has((run[split.verbAt] as RunToken).name) &&
|
|
765
|
+
operandTail(text, runEnds[run.length - 1] ?? null) === "argument"
|
|
766
|
+
) {
|
|
767
|
+
const full = candidates[candidates.length - 1] as string;
|
|
768
|
+
if (isKnownMenuPath(full.split("/").filter(Boolean)))
|
|
769
|
+
return {
|
|
770
|
+
resolution: "ambiguous",
|
|
771
|
+
kind: null,
|
|
772
|
+
path: null,
|
|
773
|
+
verb: null,
|
|
774
|
+
verbAt: null,
|
|
775
|
+
argsAt: null,
|
|
776
|
+
candidates,
|
|
777
|
+
why: `\`${full}\` is a known RouterOS menu and \`${(run[split.verbAt] as RunToken).name}\` is not a console verb: this run names a menu and the verb is missing, so offline will not promote a path segment to one`,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
660
780
|
if (split.ambiguous) {
|
|
661
781
|
// V4's bare path, the one case `proposed` refuses — and the one the baked
|
|
662
782
|
// container table can answer (#210). The lookup reads the LAST candidate,
|
|
@@ -791,19 +911,41 @@ export interface VerbAnalysis {
|
|
|
791
911
|
* pair this array with `segmentStatements` by index.
|
|
792
912
|
*/
|
|
793
913
|
export function resolveVerbs(text: string): VerbAnalysis {
|
|
794
|
-
|
|
914
|
+
const analysis = resolveStatements(text);
|
|
915
|
+
return resolveVerbsFromStatements(
|
|
916
|
+
analysis,
|
|
917
|
+
analysis.defects.some((d) => d.code === "bom" || d.code === "non-ascii")
|
|
918
|
+
? undefined
|
|
919
|
+
: documentRange(text, true),
|
|
920
|
+
);
|
|
795
921
|
}
|
|
796
922
|
|
|
797
|
-
/**
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
923
|
+
/**
|
|
924
|
+
* Reuse an existing Q4 walk when a composed analysis already performed it.
|
|
925
|
+
* `range`, if supplied, belongs to the same ASCII document as the analysis;
|
|
926
|
+
* statement spans are absolute document offsets, even within a partial range.
|
|
927
|
+
*/
|
|
928
|
+
export function resolveVerbsFromStatements(
|
|
929
|
+
{ statements, defects }: StatementAnalysis,
|
|
930
|
+
range?: MaskedRange,
|
|
931
|
+
): VerbAnalysis {
|
|
802
932
|
return {
|
|
803
933
|
splits: statements.map((s) => ({
|
|
804
934
|
...(s.unresolved
|
|
805
935
|
? unknownSplit(s.unresolved)
|
|
806
|
-
: resolveVerb(
|
|
936
|
+
: resolveVerb(
|
|
937
|
+
s.text,
|
|
938
|
+
s.context,
|
|
939
|
+
// The caller supplies a range only for ASCII input; widened
|
|
940
|
+
// fallback spans must still match the statement's own text.
|
|
941
|
+
range !== undefined &&
|
|
942
|
+
s.span.start >= range.start &&
|
|
943
|
+
s.span.end <= range.end &&
|
|
944
|
+
s.text.length === s.span.end - s.span.start &&
|
|
945
|
+
range.text.slice(s.span.start, s.span.end) === s.text
|
|
946
|
+
? rangeAt(range, s.span.start, s.span.end)
|
|
947
|
+
: undefined,
|
|
948
|
+
)),
|
|
807
949
|
span: s.span,
|
|
808
950
|
text: s.text,
|
|
809
951
|
contextCertain: s.contextCertain,
|
package/src/explain/write.ts
CHANGED
|
@@ -93,11 +93,13 @@ import { type Defect, hasStructuralDefect, mergeDefects } from "./defects.ts";
|
|
|
93
93
|
import { isMenuPath } from "./menus.ts";
|
|
94
94
|
import {
|
|
95
95
|
type DocumentAnalysis,
|
|
96
|
+
rangeForResolution,
|
|
96
97
|
resolveDocument,
|
|
97
98
|
resolveStatements,
|
|
98
99
|
type StatementAnalysis,
|
|
99
100
|
type StatementResolution,
|
|
100
101
|
} from "./pathresolve.ts";
|
|
102
|
+
import { documentRange, type MaskedRange, rangeAt } from "./segment.ts";
|
|
101
103
|
import {
|
|
102
104
|
catalogVerbAt,
|
|
103
105
|
describeStatement,
|
|
@@ -603,12 +605,29 @@ function catalogVerbOf(
|
|
|
603
605
|
* consumes this only as a rollup, so it cannot repeat that failure.
|
|
604
606
|
*/
|
|
605
607
|
export function occurrences(text: string): Occurrence[] {
|
|
606
|
-
|
|
608
|
+
const statements = resolveStatements(text);
|
|
609
|
+
return collect(
|
|
610
|
+
statements,
|
|
611
|
+
resolveDocument(text),
|
|
612
|
+
sourceRange(text, statements),
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function sourceRange(
|
|
617
|
+
text: string,
|
|
618
|
+
analysis: StatementAnalysis,
|
|
619
|
+
): MaskedRange | undefined {
|
|
620
|
+
return analysis.defects.some(
|
|
621
|
+
(d) => d.code === "bom" || d.code === "non-ascii",
|
|
622
|
+
)
|
|
623
|
+
? undefined
|
|
624
|
+
: documentRange(text, true);
|
|
607
625
|
}
|
|
608
626
|
|
|
609
627
|
function collect(
|
|
610
628
|
statements: StatementAnalysis,
|
|
611
629
|
brackets: DocumentAnalysis,
|
|
630
|
+
range?: MaskedRange,
|
|
612
631
|
): Occurrence[] {
|
|
613
632
|
const out: Occurrence[] = [];
|
|
614
633
|
const list = statements.statements;
|
|
@@ -627,7 +646,21 @@ function collect(
|
|
|
627
646
|
if (t.length === 0) continue;
|
|
628
647
|
// One parse per statement, threaded through every rule below: the Q6
|
|
629
648
|
// boundary reads the same run three or four times otherwise.
|
|
630
|
-
const
|
|
649
|
+
const trimStart = statement.text.length - statement.text.trimStart().length;
|
|
650
|
+
const described = describeStatement(
|
|
651
|
+
t,
|
|
652
|
+
range !== undefined &&
|
|
653
|
+
statement.span.start >= range.start &&
|
|
654
|
+
statement.span.end <= range.end &&
|
|
655
|
+
range.text.slice(statement.span.start, statement.span.end) ===
|
|
656
|
+
statement.text
|
|
657
|
+
? rangeAt(
|
|
658
|
+
range,
|
|
659
|
+
statement.span.start + trimStart,
|
|
660
|
+
statement.span.start + trimStart + t.length,
|
|
661
|
+
)
|
|
662
|
+
: undefined,
|
|
663
|
+
);
|
|
631
664
|
const catalogAt = catalogVerbOf(t, described, statement.context);
|
|
632
665
|
if (isDynamicForm(t, described)) {
|
|
633
666
|
out.push({
|
|
@@ -676,7 +709,8 @@ function collect(
|
|
|
676
709
|
for (const bracket of brackets.resolutions) {
|
|
677
710
|
const inner = bracket.inner.trim();
|
|
678
711
|
if (inner.length === 0) continue;
|
|
679
|
-
const
|
|
712
|
+
const bracketRange = rangeForResolution(bracket) ?? documentRange(inner);
|
|
713
|
+
const described = describeStatement(inner, bracketRange);
|
|
680
714
|
if (isDynamicForm(inner, described)) {
|
|
681
715
|
out.push({
|
|
682
716
|
kind: "bracket",
|
|
@@ -774,18 +808,25 @@ export function containsWrite(text: string): WriteAnalysis {
|
|
|
774
808
|
// One resolution pass each for the statement walk (Q4) and the bracket walk
|
|
775
809
|
// (Q3); both are re-entrant over the same segmentation and neither is cheap
|
|
776
810
|
// on adversarial input, so they are not re-run for the defects.
|
|
811
|
+
const statements = resolveStatements(text);
|
|
777
812
|
return containsWriteFromAnalyses(
|
|
778
|
-
|
|
813
|
+
statements,
|
|
779
814
|
resolveDocument(text),
|
|
815
|
+
sourceRange(text, statements),
|
|
780
816
|
);
|
|
781
817
|
}
|
|
782
818
|
|
|
783
|
-
/**
|
|
819
|
+
/**
|
|
820
|
+
* Reuse the Q3/Q4 walks when a composed analysis already performed them.
|
|
821
|
+
* `range`, if supplied, belongs to the same ASCII document as the analyses;
|
|
822
|
+
* statements outside that range keep their standalone reading.
|
|
823
|
+
*/
|
|
784
824
|
export function containsWriteFromAnalyses(
|
|
785
825
|
statements: StatementAnalysis,
|
|
786
826
|
brackets: DocumentAnalysis,
|
|
827
|
+
range?: MaskedRange,
|
|
787
828
|
): WriteAnalysis {
|
|
788
|
-
const found = collect(statements, brackets);
|
|
829
|
+
const found = collect(statements, brackets, range);
|
|
789
830
|
const defects = mergeDefects(statements.defects, brackets.defects);
|
|
790
831
|
const writes = found.filter((o) => o.klass === "write").length;
|
|
791
832
|
const blockers = found.filter((o) => BLOCKING.has(o.klass));
|