@tikoci/centrs 0.1.3 → 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 +39 -0
- package/GLOSSARY.txt +213 -0
- package/LICENSE +21 -0
- package/README.md +36 -8
- package/commands/AGENTS.md +7 -1
- package/commands/api/README.md +19 -18
- package/commands/api/examples.md +15 -0
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +6 -1
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +41 -29
- package/commands/execute/examples.md +41 -3
- package/commands/explain/README.md +1716 -0
- package/commands/explain/examples.md +606 -0
- package/commands/retrieve/README.md +25 -27
- package/commands/retrieve/examples.md +16 -0
- package/commands/transfer/README.md +28 -20
- package/commands/transfer/examples.md +29 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +66 -15
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -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/transport/provider-trust.md +29 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +45 -10
- package/src/api-fanout.ts +29 -14
- package/src/api.ts +97 -54
- package/src/cli/api.ts +25 -6
- package/src/cli/btest.ts +18 -2
- package/src/cli/execute.ts +25 -6
- package/src/cli/explain.ts +503 -0
- package/src/cli/retrieve.ts +33 -1
- package/src/cli/selection.ts +71 -11
- package/src/cli/settings.ts +2 -2
- package/src/cli/transfer.ts +23 -6
- package/src/cli.ts +15 -2
- package/src/core/envelope.ts +5 -2
- package/src/core/error-catalog.ts +40 -0
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +29 -14
- package/src/execute.ts +114 -63
- 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 +206 -4
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/facts.ts +1 -1
- package/src/resolver/index.ts +23 -4
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +99 -12
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/target.ts +1 -1
- package/src/retrieve-fanout.ts +26 -12
- package/src/retrieve.ts +81 -18
- package/src/transfer-fanout.ts +42 -25
- package/src/transfer.ts +87 -44
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline write-shape inference for `explain` — `structure.containsWrite`.
|
|
3
|
+
*
|
|
4
|
+
* Ratified by the phase-0 lab, question Q16 (#185) and promoted from the
|
|
5
|
+
* throwaway probe `.scratch/explain-lab-q16-write.ts`. Q16 asked what shape the
|
|
6
|
+
* field should have and answered that the spec's sketched **boolean is unsafe**:
|
|
7
|
+
* a fail-closed `true | false | unknown` reaches 100% precision on what it
|
|
8
|
+
* decides (226/226 holdout, 469/469 dev) with zero false negatives, abstaining
|
|
9
|
+
* on ~35%, while a two-valued field would have to call `/disk format-drive` a
|
|
10
|
+
* non-write. The tristate is the ratified amendment; this module implements it.
|
|
11
|
+
*
|
|
12
|
+
* **This is not the execute gate.** `canonicalizeExecuteCommand`'s `mode` /
|
|
13
|
+
* `writeShaped` verdict is reproduced verbatim elsewhere and is unchanged by
|
|
14
|
+
* anything here (constitution: canonicalizer ownership). `containsWrite` is a
|
|
15
|
+
* distinct explain-only inference with basis `heuristic`, so an agent can never
|
|
16
|
+
* mistake it for a guard `execute` actually applies.
|
|
17
|
+
*
|
|
18
|
+
* **Write-ness is not device-published.** No schema artifact carries mutation
|
|
19
|
+
* semantics: `inspect.json` tags nodes `path|dir|cmd|arg` and nothing more, and
|
|
20
|
+
* restraml's `openapi.json` method assignment is rule-derived (2,844 of 3,649
|
|
21
|
+
* endpoints are a `post` catch-all), so reading it back would be circular. The
|
|
22
|
+
* table below is therefore CURATED, which makes its edges the whole question —
|
|
23
|
+
* hence `unknown` as a first-class outcome rather than a synonym for "no". The
|
|
24
|
+
* gap is standing, not staleness: a 17-name table covers 93.1% of cmd nodes on
|
|
25
|
+
* 7.24beta1 and 93.8% on 7.9.2 (flat to 0.7pp across all of 7.x, ~0.07% churn
|
|
26
|
+
* per minor version), yet 56 of the 225 uncovered verb names on 7.24beta1 are
|
|
27
|
+
* write-shaped (`reboot`, `format`, `delete`, `reset-configuration`, `sign`,
|
|
28
|
+
* `flush`, …).
|
|
29
|
+
*
|
|
30
|
+
* **Two vocabularies, measured not guessed.** Menu verbs are the CRUD verbs —
|
|
31
|
+
* every entry appears under 45+ menus in all 52 trees 7.9→7.24beta1, with none
|
|
32
|
+
* added or removed. Root cmds are RouterOS scripting directives, a CLOSED list
|
|
33
|
+
* enumerated from the tree (41 names on 7.9.2, 55 on 7.24beta1, +15/−1 over the
|
|
34
|
+
* whole 7.x series). The root list is load-bearing: `set`, `find`, `export`,
|
|
35
|
+
* `import`, `password`, `undo` and `redo` are ALL root cmds as well as menu
|
|
36
|
+
* verbs and do not mean the same thing in both places — `:set x 5` assigns a
|
|
37
|
+
* script variable, `/ip/route set …` writes the device. Write-ness genuinely
|
|
38
|
+
* depends on the menu, but only across that one boundary, which is exactly the
|
|
39
|
+
* boundary Q6 measured at 99.9% precision. Q16 composes with Q6 rather than
|
|
40
|
+
* needing a schema of its own (decision 3 stays closed).
|
|
41
|
+
*
|
|
42
|
+
* **What #207 changed, and what it did NOT.** This module now reads one baked
|
|
43
|
+
* artifact, `menus.ts` — the `dir`/`path` node set, i.e. exactly the
|
|
44
|
+
* `path|dir|cmd|arg` tagging named above as carrying no mutation semantics. It
|
|
45
|
+
* still carries none, and nothing here infers write-ness from it. It answers a
|
|
46
|
+
* strictly narrower question that Q6 leaves open: whether a bare path is a
|
|
47
|
+
* MENU or a no-argument command. Decision 3 stays closed — the two vocabularies
|
|
48
|
+
* above are unchanged, still curated, still frozen against tuning — and the
|
|
49
|
+
* module stays offline, because a baked constant is not a lookup.
|
|
50
|
+
*
|
|
51
|
+
* **#228 added the second baked artifact, and the same caveat applies twice.**
|
|
52
|
+
* `catalog.ts`'s command axis says which token of a run is the VERB; it says
|
|
53
|
+
* nothing about whether that verb mutates, and the publication carries no
|
|
54
|
+
* mutation semantics either. So the tables above are still the only thing that
|
|
55
|
+
* decides `write` vs `read`, and a published command whose verb they do not list
|
|
56
|
+
* abstains exactly as before (`/system/reboot` is `unknown`, not `false`).
|
|
57
|
+
*
|
|
58
|
+
* What it does change is which token gets looked up. The punctuation rule reads
|
|
59
|
+
* the first SPACE token as the verb, so `/interface ethernet reset-counters
|
|
60
|
+
* ether1` looked up `ethernet` — a menu segment — and abstained on a curated
|
|
61
|
+
* WRITE verb; `/system/gps/monitor once` looked up `once`. Over the frozen
|
|
62
|
+
* corpus plus the export stratum, 171 readings move and every one names a better
|
|
63
|
+
* token: abstention 44.8% -> 44.6% dev, 46.0% holdout and 0.0% export unchanged,
|
|
64
|
+
* and exactly one document changes verdict.
|
|
65
|
+
*
|
|
66
|
+
* **Fail-closed is enforced where input is DISCARDED, not where it is
|
|
67
|
+
* classified** (Q16 finding 2, the part worth carrying past this module). The
|
|
68
|
+
* lab priced three navigation arms; only the ratified `failclosed` one is
|
|
69
|
+
* promoted. The two naive arms are not actually fail-closed whatever the rollup
|
|
70
|
+
* rule says: `/interface ethernet reset-counters` is bare-word-shaped, so Q4's
|
|
71
|
+
* nav rule swallows the statement, AND carries a verb outside Q6's frozen
|
|
72
|
+
* table, so the verb rule cannot rescue it — and a swallowed statement produces
|
|
73
|
+
* no occurrence, so nothing is left to abstain about and the document
|
|
74
|
+
* confidently reports "no write". The two abstention mechanisms cancelled
|
|
75
|
+
* instead of composing. A classifier can only be conservative about what it is
|
|
76
|
+
* given, so every statement that cannot be CONFIRMED as navigation emits an
|
|
77
|
+
* occurrence here. Cost on holdout: +0.8pp abstention, and the overconfident
|
|
78
|
+
* bin halves.
|
|
79
|
+
*
|
|
80
|
+
* That rule binds the BRACKET walk exactly as it binds the statement walk, and
|
|
81
|
+
* the two must be kept in step. Both fail-closed guards below — the Q14 defect
|
|
82
|
+
* floor and the path-shaped-but-unread abstention — were originally applied to
|
|
83
|
+
* statements only, so `/ip/$menu/remove 0` abstained while `:put [/ip/$menu/remove
|
|
84
|
+
* 0]` cleared the document to a confident `false`. Discarding input inside a
|
|
85
|
+
* bracket discards it just as thoroughly. Cost of closing it over the frozen
|
|
86
|
+
* 911-script corpus: +0.8pp abstention on both splits (7 documents, all
|
|
87
|
+
* `false` -> `unknown`, none off `true`), and two of the seven are pasted CLI
|
|
88
|
+
* transcripts that hide a real write behind the `[user@device]` prompt — i.e.
|
|
89
|
+
* genuine false negatives the tristate exists to prevent.
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
import { type Defect, hasStructuralDefect, mergeDefects } from "./defects.ts";
|
|
93
|
+
import { isMenuPath } from "./menus.ts";
|
|
94
|
+
import {
|
|
95
|
+
type DocumentAnalysis,
|
|
96
|
+
resolveDocument,
|
|
97
|
+
resolveStatements,
|
|
98
|
+
type StatementAnalysis,
|
|
99
|
+
type StatementResolution,
|
|
100
|
+
} from "./pathresolve.ts";
|
|
101
|
+
import {
|
|
102
|
+
catalogVerbAt,
|
|
103
|
+
describeStatement,
|
|
104
|
+
type RunToken,
|
|
105
|
+
splitRun,
|
|
106
|
+
} from "./verbsplit.ts";
|
|
107
|
+
|
|
108
|
+
/** One statement's run, directive flag and V4 flag — parsed once, read by every rule. */
|
|
109
|
+
type Described = ReturnType<typeof describeStatement>;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Menu verbs that mutate persistent device state. Frequency-justified against
|
|
113
|
+
* the command trees, and frozen against tuning per the Q6 vocabulary discipline
|
|
114
|
+
* — this is a curated list, not a schema, and its honesty depends on it not
|
|
115
|
+
* being fitted to scoring output.
|
|
116
|
+
*/
|
|
117
|
+
export const WRITE_VERBS: ReadonlySet<string> = new Set([
|
|
118
|
+
"add",
|
|
119
|
+
"remove",
|
|
120
|
+
"set",
|
|
121
|
+
"unset",
|
|
122
|
+
"enable",
|
|
123
|
+
"disable",
|
|
124
|
+
"comment",
|
|
125
|
+
"move",
|
|
126
|
+
"edit",
|
|
127
|
+
"reset",
|
|
128
|
+
// Counter resets mutate device state (statistics), not configuration. They
|
|
129
|
+
// are included because this field is a SAFETY signal, and the same frequency
|
|
130
|
+
// justification applies: `reset-counters` under 29 menus and
|
|
131
|
+
// `reset-counters-all` under 18, in every tree 7.9→7.24beta1.
|
|
132
|
+
"reset-counters",
|
|
133
|
+
"reset-counters-all",
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
/** Menu verbs that read only. `export` emits config, it does not apply it. */
|
|
137
|
+
export const READ_VERBS: ReadonlySet<string> = new Set([
|
|
138
|
+
"print",
|
|
139
|
+
"get",
|
|
140
|
+
"find",
|
|
141
|
+
"export",
|
|
142
|
+
"monitor",
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Root cmds (scripting directives) that DO mutate device state. Four names, and
|
|
147
|
+
* the argument for each is that its documented effect is a configuration
|
|
148
|
+
* change, not that its name merely looked mutating:
|
|
149
|
+
* `password` changes the logged-in user's password
|
|
150
|
+
* `undo` reverts the previous configuration change
|
|
151
|
+
* `redo` re-applies it
|
|
152
|
+
* `import` applies an .rsc file to the configuration
|
|
153
|
+
* Every other root cmd is control flow, a pure function, or output.
|
|
154
|
+
*/
|
|
155
|
+
export const ROOT_WRITE: ReadonlySet<string> = new Set([
|
|
156
|
+
"password",
|
|
157
|
+
"undo",
|
|
158
|
+
"redo",
|
|
159
|
+
"import",
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Root cmds that run a command built at runtime. Offline cannot see what they
|
|
164
|
+
* will do, so they force `unknown` — never `false`.
|
|
165
|
+
*/
|
|
166
|
+
export const ROOT_DYNAMIC: ReadonlySet<string> = new Set(["execute", "parse"]);
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The closed root-cmd vocabulary, union across the 7.9.2 / 7.20.8 / 7.23rc1 /
|
|
170
|
+
* 7.24beta1 trees. Membership is what makes a root-level command classifiable
|
|
171
|
+
* at all: a root cmd NOT in this set is a directive this table has not seen, so
|
|
172
|
+
* it abstains rather than being read as harmless control flow.
|
|
173
|
+
*/
|
|
174
|
+
export const ROOT_CMDS: ReadonlySet<string> = new Set([
|
|
175
|
+
"beep",
|
|
176
|
+
"break",
|
|
177
|
+
"continue",
|
|
178
|
+
"convert",
|
|
179
|
+
"delay",
|
|
180
|
+
"deserialize",
|
|
181
|
+
"do",
|
|
182
|
+
"error",
|
|
183
|
+
"execute",
|
|
184
|
+
"exit",
|
|
185
|
+
"export",
|
|
186
|
+
"find",
|
|
187
|
+
"for",
|
|
188
|
+
"foreach",
|
|
189
|
+
"global",
|
|
190
|
+
"grep",
|
|
191
|
+
"if",
|
|
192
|
+
"import",
|
|
193
|
+
"jobname",
|
|
194
|
+
"len",
|
|
195
|
+
"local",
|
|
196
|
+
"lock",
|
|
197
|
+
"nothing",
|
|
198
|
+
"onerror",
|
|
199
|
+
"parse",
|
|
200
|
+
"password",
|
|
201
|
+
"pick",
|
|
202
|
+
"ping",
|
|
203
|
+
"put",
|
|
204
|
+
"quit",
|
|
205
|
+
"range",
|
|
206
|
+
"recursive-print",
|
|
207
|
+
"redo",
|
|
208
|
+
"resolve",
|
|
209
|
+
"retry",
|
|
210
|
+
"return",
|
|
211
|
+
"rndnum",
|
|
212
|
+
"rndstr",
|
|
213
|
+
"serialize",
|
|
214
|
+
"set",
|
|
215
|
+
"time",
|
|
216
|
+
"timestamp",
|
|
217
|
+
"toarray",
|
|
218
|
+
"tobool",
|
|
219
|
+
"tocrlf",
|
|
220
|
+
"toid",
|
|
221
|
+
"toip",
|
|
222
|
+
"toip6",
|
|
223
|
+
"tolf",
|
|
224
|
+
"tonsec",
|
|
225
|
+
"tonum",
|
|
226
|
+
"tostr",
|
|
227
|
+
"totime",
|
|
228
|
+
"typeof",
|
|
229
|
+
"undo",
|
|
230
|
+
"while",
|
|
231
|
+
]);
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The `pathresolve` refusal reasons that name no command at this level, so the
|
|
235
|
+
* statement may be classified normally instead of blocking. Deliberately an
|
|
236
|
+
* ALLOW list: an unrecognized reason is treated as a defect, so this composition
|
|
237
|
+
* fails closed when the resolver grows a new one.
|
|
238
|
+
*
|
|
239
|
+
* `no leading path token` is clearable only because `classifyStatement`'s
|
|
240
|
+
* `isPathShapedButUnread` guard catches the dangerous half of it — the run of a
|
|
241
|
+
* `/`-led statement being TRUNCATED by a variable segment (`/ip/$menu/remove`)
|
|
242
|
+
* rather than genuinely absent (`"a string"`, `=`).
|
|
243
|
+
*/
|
|
244
|
+
const CLEARABLE_UNRESOLVED: ReadonlySet<string> = new Set([
|
|
245
|
+
"dynamic or substitution-headed statement",
|
|
246
|
+
"no leading path token",
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Whether the command itself is not statically knowable, so the verdict must
|
|
251
|
+
* degrade to `unknown` rather than `false`. Checked BEFORE the navigation gate:
|
|
252
|
+
* `/system/script run myscript` is entirely bare words, so the nav rule would
|
|
253
|
+
* otherwise drop the one form that is unknowable by definition.
|
|
254
|
+
*
|
|
255
|
+
* Read the leading run rather than searching the raw statement. A raw substring
|
|
256
|
+
* probe masks known writes whose argument merely contains `/system script run`,
|
|
257
|
+
* and separate slash/space regexes miss valid mixed spellings such as
|
|
258
|
+
* `/system/script run myscript`.
|
|
259
|
+
*/
|
|
260
|
+
function isDynamicForm(text: string, described: Described): boolean {
|
|
261
|
+
if (text.startsWith("$") || /^\[\s*\$/.test(text)) return true;
|
|
262
|
+
if (!text.startsWith("/") || described.run.length < 3) return false;
|
|
263
|
+
const [system, script, run] = described.run;
|
|
264
|
+
return (
|
|
265
|
+
system?.name.toLowerCase() === "system" &&
|
|
266
|
+
script?.name.toLowerCase() === "script" &&
|
|
267
|
+
run?.name.toLowerCase() === "run"
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The tristate. Rendered to the `structure.containsWrite` JSON field as
|
|
273
|
+
* `true | false | "unknown"`; kept as a string union here so `unknown` can
|
|
274
|
+
* never be reached by boolean coercion.
|
|
275
|
+
*/
|
|
276
|
+
export type WriteVerdict = "true" | "false" | "unknown";
|
|
277
|
+
|
|
278
|
+
/** How one classified command occurrence bears on the rollup. */
|
|
279
|
+
export type OccurrenceClass =
|
|
280
|
+
/** A curated write verb, in the vocabulary that matches its position. */
|
|
281
|
+
| "write"
|
|
282
|
+
/** A curated read verb. */
|
|
283
|
+
| "read"
|
|
284
|
+
/** A decided verb outside both curated tables — abstains. */
|
|
285
|
+
| "unknown-verb"
|
|
286
|
+
/**
|
|
287
|
+
* A path-shaped statement whose verb/menu boundary Q6 refused — abstains.
|
|
288
|
+
* `/system/reboot` and `/ip/address` are the same text, so a bare path may be
|
|
289
|
+
* a no-argument WRITE. See the note on `classifyStatement`.
|
|
290
|
+
*/
|
|
291
|
+
| "ambiguous"
|
|
292
|
+
/** The command is built at runtime; offline cannot read it — abstains. */
|
|
293
|
+
| "dynamic"
|
|
294
|
+
/** A statement the path resolver refused (Q14 structural defect) — abstains. */
|
|
295
|
+
| "defect"
|
|
296
|
+
/**
|
|
297
|
+
* The statement has no leading path run at all (it opens with `[`, `(` or a
|
|
298
|
+
* string), so it names no command. Carries no write signal either way — an
|
|
299
|
+
* inner `[…]` command is reached by the bracket walk instead.
|
|
300
|
+
*/
|
|
301
|
+
| "no-verb";
|
|
302
|
+
|
|
303
|
+
/** One command offline can see, at any block or bracket depth. */
|
|
304
|
+
export interface Occurrence {
|
|
305
|
+
kind: "statement" | "bracket";
|
|
306
|
+
/** Trimmed source text of the statement or bracket inner. */
|
|
307
|
+
text: string;
|
|
308
|
+
/** Menu context in force, `/` at document root. */
|
|
309
|
+
context: string;
|
|
310
|
+
/** The verb the Q6 boundary decided, or null. */
|
|
311
|
+
verb: string | null;
|
|
312
|
+
/** Root-level (`:foo` or a root cmd) rather than a menu command. */
|
|
313
|
+
directive: boolean;
|
|
314
|
+
klass: OccurrenceClass;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Classify one decided verb in its position. The (menu, verb) dependency. */
|
|
318
|
+
export function classifyVerb(
|
|
319
|
+
verb: string | null,
|
|
320
|
+
directive: boolean,
|
|
321
|
+
): OccurrenceClass {
|
|
322
|
+
if (verb === null) return "no-verb";
|
|
323
|
+
const v = verb.toLowerCase();
|
|
324
|
+
if (directive) {
|
|
325
|
+
if (ROOT_DYNAMIC.has(v)) return "dynamic";
|
|
326
|
+
if (ROOT_WRITE.has(v)) return "write";
|
|
327
|
+
if (ROOT_CMDS.has(v)) return "read";
|
|
328
|
+
return "unknown-verb";
|
|
329
|
+
}
|
|
330
|
+
if (WRITE_VERBS.has(v)) return "write";
|
|
331
|
+
if (READ_VERBS.has(v)) return "read";
|
|
332
|
+
return "unknown-verb";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The verb Q6's ratified boundary decides for a run, or null if it refuses. */
|
|
336
|
+
function verbOfRun(
|
|
337
|
+
run: RunToken[],
|
|
338
|
+
directive: boolean,
|
|
339
|
+
whole: boolean,
|
|
340
|
+
): string | null {
|
|
341
|
+
const split = splitRun(run, { directive, whole });
|
|
342
|
+
if (split.ambiguous || split.verbAt === null) return null;
|
|
343
|
+
return (run[split.verbAt] as RunToken | undefined)?.name ?? null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Can this statement be CONFIRMED as pure menu navigation? Only when the path it
|
|
348
|
+
* names is a known menu — which is now decidable offline, against the baked
|
|
349
|
+
* container table in `menus.ts`.
|
|
350
|
+
*
|
|
351
|
+
* **What this replaced, and why (#207).** The original rule read token SHAPE: a
|
|
352
|
+
* hyphenated word anywhere after the first token is verb-shaped
|
|
353
|
+
* (`reset-counters`, `format-drive`, `firmware-upgrade`), and testing only the
|
|
354
|
+
* final token would miss `/disk format-drive disk1`, where the verb carries a
|
|
355
|
+
* positional operand. The reasoning was sound; the premise was not. Hyphenated
|
|
356
|
+
* MENU segments are ordinary on a real device, so the rule fired on genuine
|
|
357
|
+
* navigation constantly — measured on the #203 export stratum (120 captures,
|
|
358
|
+
* pinned 7.23.2 and 7.24rc2), **every Q16 abstention in the entire stratum was
|
|
359
|
+
* this one rule**, across 25 distinct menus, at a 100% false-positive rate.
|
|
360
|
+
*
|
|
361
|
+
* The corpus could not have shown it: the rule fires on 2.97% of documents and
|
|
362
|
+
* changes the verdict of three, because the corpus is 96.8% two forum authors
|
|
363
|
+
* writing scripts, and bare-path navigation into a hyphenated menu is an
|
|
364
|
+
* *export* idiom. Corpus-green was not evidence the rule was right.
|
|
365
|
+
*
|
|
366
|
+
* **Why shape cannot be fixed.** `/interface wireless reset-configuration wlan1`
|
|
367
|
+
* is a command, `/ip dhcp-server network` and `/tool mac-server mac-winbox` are
|
|
368
|
+
* menus, and no token-shape rule separates them — the distinction is semantic.
|
|
369
|
+
* `menus.ts` supplies it as a baked constant rather than a live lookup, so this
|
|
370
|
+
* module stays offline. See that file for the source pin.
|
|
371
|
+
*
|
|
372
|
+
* **Still fail-closed, and in both directions.** A path the table does not carry
|
|
373
|
+
* is not confirmed navigation, so it emits an occurrence and abstains — the
|
|
374
|
+
* pre-#207 behaviour for an unrecognized menu, and the reason a pinned,
|
|
375
|
+
* deliberately incomplete table is safe. Going the other way, a bare-path
|
|
376
|
+
* COMMAND is no longer confirmed as navigation at all: `/system reboot` used to
|
|
377
|
+
* pass this rule (no hyphen) and be dropped mid-document, clearing the document
|
|
378
|
+
* to `false` on a statically obvious write. That was the exact false negative
|
|
379
|
+
* Q16's hard threshold forbids, and only `isDanglingBarePath` below caught the
|
|
380
|
+
* end-of-document case. It is closed here.
|
|
381
|
+
*
|
|
382
|
+
* Measured over the frozen 911-script corpus: abstention **unchanged** at 44.8%
|
|
383
|
+
* dev / 46.0% holdout, and exactly two documents move, in opposite directions.
|
|
384
|
+
* One gains an abstention (a pasted `/system/gps/monitor once` transcript, where
|
|
385
|
+
* the abstention is correct). One loses one — a rextended snippet whose elided
|
|
386
|
+
* `... script=… ...` line names no command, so its `unknown` was reached only
|
|
387
|
+
* *via* this bug misreading `/ip dhcp-client`; the residual question of how
|
|
388
|
+
* elided pastes should classify belongs to #192, not here. On the export stratum
|
|
389
|
+
* abstention drops from 3.8% compact / 11.2–11.6% verbose to **0.0% on every
|
|
390
|
+
* stratum**, with no document changing verdict.
|
|
391
|
+
*/
|
|
392
|
+
function isConfirmedNav(described: Described): boolean {
|
|
393
|
+
// Only reached for a statement the Q4 resolver already read as navigation, so
|
|
394
|
+
// an empty run means the bare `/` or `..` forms — the two Q4's CHR round
|
|
395
|
+
// confirmed as valid RouterOS navigation. They name no command at all.
|
|
396
|
+
if (described.run.length === 0) return true;
|
|
397
|
+
// Argument-looking text after the run means this was never navigation.
|
|
398
|
+
if (!described.whole) return false;
|
|
399
|
+
return isMenuPath(described.run.map((t) => t.name));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* A bare path with NOTHING after it in the whole document. Q6 ratified that such
|
|
404
|
+
* a statement is `ambiguous` — `/ip/route` is a directory and `/system/reboot` is
|
|
405
|
+
* a no-argument command, and the text is identical — but `isConfirmedNav` above
|
|
406
|
+
* decides at STATEMENT scale and would drop both as navigation, so a lone or
|
|
407
|
+
* trailing `/system/reboot` cleared the document to `false`. That is a false
|
|
408
|
+
* negative on a statically obvious write, the one thing Q16's hard threshold
|
|
409
|
+
* forbids, and it reproduces inside the tristate exactly the failure the tristate
|
|
410
|
+
* was ratified to prevent.
|
|
411
|
+
*
|
|
412
|
+
* Position was the one schema-free confirmation available at document scale:
|
|
413
|
+
* navigating and then ending the script is a no-op, while a no-argument command
|
|
414
|
+
* is meaningful. This is deliberately the WEAKEST rule that closes the reported
|
|
415
|
+
* case — measured over the frozen 911-script corpus it moves **zero** documents,
|
|
416
|
+
* so the ratified navigation arm's abstention (44.0% dev / 45.2% holdout) is
|
|
417
|
+
* unchanged. Every corpus bare path sits mid-document, followed by further
|
|
418
|
+
* statements; working scripts do not end on a menu path.
|
|
419
|
+
*
|
|
420
|
+
* **#207 narrowed what this rule is for.** `isConfirmedNav` now knows whether the
|
|
421
|
+
* path is a menu, so `/system/reboot` is refused there — at any position, and
|
|
422
|
+
* without the +1.4pp / +1.9pp abstention that closing it positionally would have
|
|
423
|
+
* cost. What is left here is a document-scale hedge over paths the table DOES
|
|
424
|
+
* carry: a known menu that ends the document is still suspicious, since a
|
|
425
|
+
* trailing no-op is not what working scripts do, and the table is version-less
|
|
426
|
+
* so a name can be a menu on one RouterOS and a command on another the pinned
|
|
427
|
+
* trees never saw. Retained on that basis; it fires on no corpus document and no
|
|
428
|
+
* export capture.
|
|
429
|
+
*
|
|
430
|
+
* The bare `/` and `..` forms are exempt: they carry no run, name no command, and
|
|
431
|
+
* Q4's CHR round confirmed them as navigation outright.
|
|
432
|
+
*
|
|
433
|
+
* `lastNonEmpty` is the index of the document's last non-blank statement, walked
|
|
434
|
+
* once by `collect`; a statement at or past it is followed only by blanks.
|
|
435
|
+
*/
|
|
436
|
+
function isDanglingBarePath(
|
|
437
|
+
described: Described,
|
|
438
|
+
lastNonEmpty: number,
|
|
439
|
+
index: number,
|
|
440
|
+
): boolean {
|
|
441
|
+
if (described.run.length === 0) return false;
|
|
442
|
+
return index >= lastNonEmpty;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Does the run carry a token Q6's boundary — or the published command catalog —
|
|
447
|
+
* would call the verb?
|
|
448
|
+
*
|
|
449
|
+
* The catalog arm (#228 step 2) is what routes a bare `/system/gps/monitor`
|
|
450
|
+
* away from the unconfirmed-nav fallback, where the last token was only ever a
|
|
451
|
+
* GUESS and even a recognized READ verb had to be downgraded, and into
|
|
452
|
+
* {@link classifyStatement}, where it is a decided verb read against the
|
|
453
|
+
* vocabulary that matches its position.
|
|
454
|
+
*/
|
|
455
|
+
function isCommandShaped(
|
|
456
|
+
described: Described,
|
|
457
|
+
catalogAt: number | null,
|
|
458
|
+
): boolean {
|
|
459
|
+
if (described.run.length === 0) return false;
|
|
460
|
+
if (catalogAt !== null) return true;
|
|
461
|
+
const split = splitRun(described.run, {
|
|
462
|
+
directive: described.directive,
|
|
463
|
+
whole: described.whole,
|
|
464
|
+
});
|
|
465
|
+
return !split.ambiguous && split.verbAt !== null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Classify one statement the Q4 walker read as navigation but Q6 could not
|
|
470
|
+
* confirm as a command. Reached only when every other rule has already
|
|
471
|
+
* abstained, so it falls back to Q6's `last`-token reading purely to obtain a
|
|
472
|
+
* verb CANDIDATE — and is never allowed to produce `false`: an unrecognized
|
|
473
|
+
* candidate stays `unknown-verb`, and even a recognized READ verb is downgraded
|
|
474
|
+
* to `unknown-verb`, because the reading that produced it is not trusted enough
|
|
475
|
+
* to clear the document.
|
|
476
|
+
*/
|
|
477
|
+
function classifyUnconfirmedNav(
|
|
478
|
+
statement: StatementResolution,
|
|
479
|
+
text: string,
|
|
480
|
+
described: Described,
|
|
481
|
+
): Occurrence {
|
|
482
|
+
const run = described.run;
|
|
483
|
+
const candidate = (run[run.length - 1] as RunToken | undefined)?.name ?? null;
|
|
484
|
+
const klass =
|
|
485
|
+
candidate === null ? "unknown-verb" : classifyVerb(candidate, false);
|
|
486
|
+
return {
|
|
487
|
+
kind: "statement",
|
|
488
|
+
text,
|
|
489
|
+
context: statement.context,
|
|
490
|
+
verb: candidate,
|
|
491
|
+
directive: false,
|
|
492
|
+
klass: klass === "read" ? "unknown-verb" : klass,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Classify a statement that reached neither the dynamic, defect, nor
|
|
498
|
+
* navigation branch: read Q6's boundary and look the verb up in the vocabulary
|
|
499
|
+
* that matches its position.
|
|
500
|
+
*
|
|
501
|
+
* The `ambiguous` arm is where this composition departs from the lab SUT, and
|
|
502
|
+
* it is a fail-closed correction rather than a port. The lab's path resolver
|
|
503
|
+
* called any all-bare-word statement navigation, so a bare-word head reached
|
|
504
|
+
* the unconfirmed-nav fallback and was classified from its last token. The
|
|
505
|
+
* production resolver does not: `menuNavPath` requires a leading `/`, because
|
|
506
|
+
* Q4's CHR round confirmed bare-word navigation is not valid RouterOS. That
|
|
507
|
+
* correction means a statement like `reset-counters` (relative to an
|
|
508
|
+
* `/interface` context) now arrives here with V4 ambiguity and no decided verb
|
|
509
|
+
* — and clearing it would be a false negative on a curated write verb, the one
|
|
510
|
+
* thing Q16's hard threshold forbids. So an undecided boundary over a non-empty
|
|
511
|
+
* run abstains, which is Q14's rule (b) — a bare-word-headed statement degrades
|
|
512
|
+
* to `unknown` — enforced at the rollup.
|
|
513
|
+
*/
|
|
514
|
+
function classifyStatement(
|
|
515
|
+
statement: StatementResolution,
|
|
516
|
+
text: string,
|
|
517
|
+
described: Described,
|
|
518
|
+
catalogAt: number | null,
|
|
519
|
+
): Occurrence {
|
|
520
|
+
const verb =
|
|
521
|
+
catalogAt === null
|
|
522
|
+
? verbOfRun(described.run, described.directive, described.whole)
|
|
523
|
+
: ((described.run[catalogAt] as RunToken).name ?? null);
|
|
524
|
+
// A single-token command at document root is a root cmd written without its
|
|
525
|
+
// `:` sigil (`/import file-name=…`), so it reads against the root vocabulary
|
|
526
|
+
// rather than the menu one.
|
|
527
|
+
const directive =
|
|
528
|
+
described.directive ||
|
|
529
|
+
(verb !== null && statement.context === "/" && described.run.length === 1);
|
|
530
|
+
const klass =
|
|
531
|
+
verb === null && isPathShapedButUnread(text, described)
|
|
532
|
+
? "ambiguous"
|
|
533
|
+
: classifyVerb(verb, directive);
|
|
534
|
+
return {
|
|
535
|
+
kind: "statement",
|
|
536
|
+
text,
|
|
537
|
+
context: statement.context,
|
|
538
|
+
verb,
|
|
539
|
+
directive,
|
|
540
|
+
klass,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Does this text name a command the run could not read? Used to decide whether
|
|
546
|
+
* a `null` verb may clear the document or must abstain.
|
|
547
|
+
*
|
|
548
|
+
* A NON-EMPTY run with no decided verb is V4's bare path — Q6's ratified
|
|
549
|
+
* `ambiguous`. An EMPTY run usually means the text names no command at this
|
|
550
|
+
* level (`[find]`, `(1)`, `"a string"`, `=`) and is safely `no-verb`: an inner
|
|
551
|
+
* `[…]` command is reached by the bracket walk instead.
|
|
552
|
+
*
|
|
553
|
+
* The exception is a `/`-led statement, where an empty run means the run was
|
|
554
|
+
* TRUNCATED rather than absent — `runTokens` stops at the first segment that is
|
|
555
|
+
* not a bare word, so `/ip/$menu/remove 0` and `/ip/$m/add address=…` yield an
|
|
556
|
+
* empty run while plainly naming a write. Clearing those is a false negative on
|
|
557
|
+
* a curated write verb, so a path-shaped statement whose path could not be read
|
|
558
|
+
* abstains.
|
|
559
|
+
*/
|
|
560
|
+
function isPathShapedButUnread(text: string, described: Described): boolean {
|
|
561
|
+
return described.run.length > 0 || text.startsWith("/");
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* The published catalog's verb index for one statement or bracket inner, or
|
|
566
|
+
* null (#228 step 2).
|
|
567
|
+
*
|
|
568
|
+
* Applies `resolveVerb`'s base rule locally rather than calling it: this module
|
|
569
|
+
* reads `splitRun` directly, and threading the whole resolver in to get one
|
|
570
|
+
* index would put the R9 `bare-word head is not a known verb` refusal in front
|
|
571
|
+
* of the classification, where a refusal means "unknown" instead of "abstain".
|
|
572
|
+
* The two must agree on the BASE, though — a lookup rooted differently would ask
|
|
573
|
+
* about a different path — so the rule is stated the same way in both places: a
|
|
574
|
+
* directive or a `/`-led statement resolves at the root, everything else
|
|
575
|
+
* inherits its context.
|
|
576
|
+
*
|
|
577
|
+
* Including the bare-word rule `resolveVerb` states beside its own refusal: a
|
|
578
|
+
* relative statement is only looked up under an INHERITED menu context, never at
|
|
579
|
+
* the root, because the seven root-level catalog commands are all ordinary
|
|
580
|
+
* English words and the corpus's pasted `import serial` would otherwise read as
|
|
581
|
+
* RouterOS `/import`.
|
|
582
|
+
*/
|
|
583
|
+
function catalogVerbOf(
|
|
584
|
+
text: string,
|
|
585
|
+
described: Described,
|
|
586
|
+
context: string,
|
|
587
|
+
): number | null {
|
|
588
|
+
if (described.directive) return null;
|
|
589
|
+
if (text.startsWith("/")) return catalogVerbAt(described.run, "/");
|
|
590
|
+
if (context === "/") return null;
|
|
591
|
+
return catalogVerbAt(described.run, context);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Every command occurrence offline can see: statements at every block depth
|
|
596
|
+
* (Q4's stateful walker) plus bracket-inner commands at every nesting depth
|
|
597
|
+
* (Q3's resolver).
|
|
598
|
+
*
|
|
599
|
+
* The ORDER carries no meaning, even though the list is emitted in source order
|
|
600
|
+
* and repeats are significant (`writes` counts multiplicity — do not dedupe).
|
|
601
|
+
* Both lab harness bugs corrected during phase 0 were alignment bugs, where a
|
|
602
|
+
* positional correspondence silently lined the wrong pair up; `containsWrite`
|
|
603
|
+
* consumes this only as a rollup, so it cannot repeat that failure.
|
|
604
|
+
*/
|
|
605
|
+
export function occurrences(text: string): Occurrence[] {
|
|
606
|
+
return collect(resolveStatements(text), resolveDocument(text));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function collect(
|
|
610
|
+
statements: StatementAnalysis,
|
|
611
|
+
brackets: DocumentAnalysis,
|
|
612
|
+
): Occurrence[] {
|
|
613
|
+
const out: Occurrence[] = [];
|
|
614
|
+
const list = statements.statements;
|
|
615
|
+
// Walked once here rather than per statement, so the dangling-bare-path rule
|
|
616
|
+
// stays O(n) on nav-heavy documents.
|
|
617
|
+
let lastNonEmpty = -1;
|
|
618
|
+
for (let i = list.length - 1; i >= 0; i--)
|
|
619
|
+
if ((list[i] as StatementResolution).text.trim().length > 0) {
|
|
620
|
+
lastNonEmpty = i;
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
for (let index = 0; index < list.length; index++) {
|
|
625
|
+
const statement = list[index] as StatementResolution;
|
|
626
|
+
const t = statement.text.trim();
|
|
627
|
+
if (t.length === 0) continue;
|
|
628
|
+
// One parse per statement, threaded through every rule below: the Q6
|
|
629
|
+
// boundary reads the same run three or four times otherwise.
|
|
630
|
+
const described = describeStatement(t);
|
|
631
|
+
const catalogAt = catalogVerbOf(t, described, statement.context);
|
|
632
|
+
if (isDynamicForm(t, described)) {
|
|
633
|
+
out.push({
|
|
634
|
+
kind: "statement",
|
|
635
|
+
text: t,
|
|
636
|
+
context: statement.context,
|
|
637
|
+
verb: null,
|
|
638
|
+
directive: false,
|
|
639
|
+
klass: "dynamic",
|
|
640
|
+
});
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
// Q14 floor — the resolver refused this statement and did not descend, so
|
|
644
|
+
// a write may be hiding in a body that was never walked. Only the reasons
|
|
645
|
+
// on the safe list clear: those name no command at this level, and their
|
|
646
|
+
// inner brackets are classified by the bracket walk below. Any OTHER
|
|
647
|
+
// reason blocks, including one this list has not seen — so rewording a
|
|
648
|
+
// reason in `pathresolve` degrades to over-abstention rather than
|
|
649
|
+
// silently clearing a hidden write.
|
|
650
|
+
if (
|
|
651
|
+
statement.unresolved !== undefined &&
|
|
652
|
+
!CLEARABLE_UNRESOLVED.has(statement.unresolved)
|
|
653
|
+
) {
|
|
654
|
+
out.push({
|
|
655
|
+
kind: "statement",
|
|
656
|
+
text: t,
|
|
657
|
+
context: statement.context,
|
|
658
|
+
verb: null,
|
|
659
|
+
directive: false,
|
|
660
|
+
klass: "defect",
|
|
661
|
+
});
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
if (statement.isNav && !isCommandShaped(described, catalogAt)) {
|
|
665
|
+
if (
|
|
666
|
+
isConfirmedNav(described) &&
|
|
667
|
+
!isDanglingBarePath(described, lastNonEmpty, index)
|
|
668
|
+
)
|
|
669
|
+
continue;
|
|
670
|
+
out.push(classifyUnconfirmedNav(statement, t, described));
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
out.push(classifyStatement(statement, t, described, catalogAt));
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
for (const bracket of brackets.resolutions) {
|
|
677
|
+
const inner = bracket.inner.trim();
|
|
678
|
+
if (inner.length === 0) continue;
|
|
679
|
+
const described = describeStatement(inner);
|
|
680
|
+
if (isDynamicForm(inner, described)) {
|
|
681
|
+
out.push({
|
|
682
|
+
kind: "bracket",
|
|
683
|
+
text: inner,
|
|
684
|
+
context: bracket.context,
|
|
685
|
+
verb: null,
|
|
686
|
+
directive: false,
|
|
687
|
+
klass: "dynamic",
|
|
688
|
+
});
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
// The same Q14 defect floor the statement walk applies above. A bracket
|
|
692
|
+
// carries its own refusal reasons and its own vocabulary — `variable path
|
|
693
|
+
// segment` is reachable here but not at statement scale — so the ALLOW
|
|
694
|
+
// list does the same work: an unrecognized reason blocks.
|
|
695
|
+
if (
|
|
696
|
+
bracket.unresolved !== undefined &&
|
|
697
|
+
!CLEARABLE_UNRESOLVED.has(bracket.unresolved)
|
|
698
|
+
) {
|
|
699
|
+
out.push({
|
|
700
|
+
kind: "bracket",
|
|
701
|
+
text: inner,
|
|
702
|
+
context: bracket.context,
|
|
703
|
+
verb: null,
|
|
704
|
+
directive: false,
|
|
705
|
+
klass: "defect",
|
|
706
|
+
});
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
const catalogAt = catalogVerbOf(inner, described, bracket.context);
|
|
710
|
+
const verb =
|
|
711
|
+
catalogAt === null
|
|
712
|
+
? verbOfRun(described.run, described.directive, described.whole)
|
|
713
|
+
: ((described.run[catalogAt] as RunToken).name ?? null);
|
|
714
|
+
const klass =
|
|
715
|
+
verb === null && isPathShapedButUnread(inner, described)
|
|
716
|
+
? "ambiguous"
|
|
717
|
+
: classifyVerb(verb, described.directive);
|
|
718
|
+
out.push({
|
|
719
|
+
kind: "bracket",
|
|
720
|
+
text: inner,
|
|
721
|
+
context: bracket.context,
|
|
722
|
+
verb,
|
|
723
|
+
directive: described.directive,
|
|
724
|
+
klass,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
return out;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** Q16 result: the tristate plus everything the caller needs to see why. */
|
|
732
|
+
export interface WriteAnalysis {
|
|
733
|
+
verdict: WriteVerdict;
|
|
734
|
+
/** How many occurrences were proven writes. Agreed with IL 100% on both splits. */
|
|
735
|
+
writes: number;
|
|
736
|
+
/**
|
|
737
|
+
* Unclassifiable occurrences. These force `unknown` only when no proven write
|
|
738
|
+
* exists; rule 1 may therefore return `true` with blockers still reported.
|
|
739
|
+
*/
|
|
740
|
+
blockers: Occurrence[];
|
|
741
|
+
occurrences: Occurrence[];
|
|
742
|
+
/**
|
|
743
|
+
* Located structural surprises from the segmenter / bounded traversal, plus
|
|
744
|
+
* the `bom`/`non-ascii` positional facts. Any NON-POSITIONAL defect abstains
|
|
745
|
+
* (see {@link hasStructuralDefect}); the positional two never do.
|
|
746
|
+
*/
|
|
747
|
+
defects: Defect[];
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const BLOCKING: ReadonlySet<OccurrenceClass> = new Set([
|
|
751
|
+
"unknown-verb",
|
|
752
|
+
"ambiguous",
|
|
753
|
+
"dynamic",
|
|
754
|
+
"defect",
|
|
755
|
+
]);
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Fail-closed rollup, per the four rules ratified with the tristate:
|
|
759
|
+
*
|
|
760
|
+
* 1. A proven write wins outright — an `unknown` elsewhere cannot make a
|
|
761
|
+
* document that definitely writes stop writing.
|
|
762
|
+
* 2. With no proven write, ANY unclassifiable occurrence yields `unknown`,
|
|
763
|
+
* never `false`. That is the spec's zero-false-negative requirement
|
|
764
|
+
* expressed as a rule rather than a hope.
|
|
765
|
+
* 3. Statements the parser cannot confirm as navigation emit an occurrence
|
|
766
|
+
* rather than being dropped (enforced in `occurrences`).
|
|
767
|
+
* 4. `unknown` is reported with its blockers, so the caller can see why.
|
|
768
|
+
*
|
|
769
|
+
* A document-level structural defect (unterminated string, over-depth nesting)
|
|
770
|
+
* means part of the input was never walked, so it abstains under rule 2 for the
|
|
771
|
+
* same reason a `defect` occurrence does.
|
|
772
|
+
*/
|
|
773
|
+
export function containsWrite(text: string): WriteAnalysis {
|
|
774
|
+
// One resolution pass each for the statement walk (Q4) and the bracket walk
|
|
775
|
+
// (Q3); both are re-entrant over the same segmentation and neither is cheap
|
|
776
|
+
// on adversarial input, so they are not re-run for the defects.
|
|
777
|
+
return containsWriteFromAnalyses(
|
|
778
|
+
resolveStatements(text),
|
|
779
|
+
resolveDocument(text),
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** Reuse the Q3/Q4 walks when a composed analysis already performed them. */
|
|
784
|
+
export function containsWriteFromAnalyses(
|
|
785
|
+
statements: StatementAnalysis,
|
|
786
|
+
brackets: DocumentAnalysis,
|
|
787
|
+
): WriteAnalysis {
|
|
788
|
+
const found = collect(statements, brackets);
|
|
789
|
+
const defects = mergeDefects(statements.defects, brackets.defects);
|
|
790
|
+
const writes = found.filter((o) => o.klass === "write").length;
|
|
791
|
+
const blockers = found.filter((o) => BLOCKING.has(o.klass));
|
|
792
|
+
if (writes > 0)
|
|
793
|
+
return {
|
|
794
|
+
verdict: "true",
|
|
795
|
+
writes,
|
|
796
|
+
blockers,
|
|
797
|
+
occurrences: found,
|
|
798
|
+
defects,
|
|
799
|
+
};
|
|
800
|
+
if (blockers.length > 0 || hasStructuralDefect(defects))
|
|
801
|
+
return {
|
|
802
|
+
verdict: "unknown",
|
|
803
|
+
writes: 0,
|
|
804
|
+
blockers,
|
|
805
|
+
occurrences: found,
|
|
806
|
+
defects,
|
|
807
|
+
};
|
|
808
|
+
return {
|
|
809
|
+
verdict: "false",
|
|
810
|
+
writes: 0,
|
|
811
|
+
blockers: [],
|
|
812
|
+
occurrences: found,
|
|
813
|
+
defects,
|
|
814
|
+
};
|
|
815
|
+
}
|