@tikoci/centrs 0.1.9-next.22 → 0.1.9-next.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/GLOSSARY.txt +38 -0
  3. package/README.md +2 -2
  4. package/commands/api/AGENTS.md +18 -1
  5. package/commands/api/README.md +10 -6
  6. package/commands/api/examples.md +1 -1
  7. package/commands/execute/README.md +26 -6
  8. package/commands/execute/examples.md +43 -5
  9. package/commands/explain/README.md +873 -182
  10. package/commands/explain/examples.md +447 -38
  11. package/commands/retrieve/README.md +13 -2
  12. package/commands/retrieve/examples.md +30 -1
  13. package/commands/transfer/README.md +8 -4
  14. package/docs/CLI.md +19 -5
  15. package/docs/CONSTITUTION.md +18 -9
  16. package/docs/MATRIX.md +51 -26
  17. package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
  18. package/docs/errors/routeros/permission-denied.md +24 -0
  19. package/docs/errors/transport/edge-auth-failed.md +22 -0
  20. package/docs/errors/transport/write-outcome-unknown.md +3 -0
  21. package/docs/errors/usage/invalid-cursor.md +20 -0
  22. package/docs/errors/validation/unknown-path.md +9 -0
  23. package/package.json +5 -2
  24. package/src/api.ts +34 -39
  25. package/src/cli/explain.ts +206 -47
  26. package/src/core/envelope.ts +10 -3
  27. package/src/core/error-catalog.ts +15 -0
  28. package/src/core/execute-command.ts +53 -0
  29. package/src/core/fanout.ts +12 -2
  30. package/src/core/inspect.ts +4 -5
  31. package/src/core/routeros-errors.ts +25 -0
  32. package/src/execute-cmr.ts +2 -8
  33. package/src/execute.ts +48 -20
  34. package/src/explain/args.ts +181 -15
  35. package/src/explain/blocks.ts +3 -4
  36. package/src/explain/brace-slots.ts +5 -0
  37. package/src/explain/catalog.ts +13 -6
  38. package/src/explain/defects.ts +1 -0
  39. package/src/explain/group-faults.ts +1060 -0
  40. package/src/explain/highlight-projection.ts +10 -8
  41. package/src/explain/operators.ts +22 -20
  42. package/src/explain/pathresolve.ts +102 -20
  43. package/src/explain/quoted-string.ts +41 -6
  44. package/src/explain/run-tokens.ts +223 -0
  45. package/src/explain/scope-brace.ts +319 -7
  46. package/src/explain/segment.ts +177 -4
  47. package/src/explain/spaced-args.ts +116 -0
  48. package/src/explain/symbols.ts +163 -7
  49. package/src/explain/transport.ts +13 -0
  50. package/src/explain/values.ts +55 -4
  51. package/src/explain/verbsplit.ts +14 -188
  52. package/src/explain/write.ts +8 -1
  53. package/src/explain-live-facets.ts +739 -0
  54. package/src/explain-live-types.ts +490 -0
  55. package/src/explain-live.ts +593 -0
  56. package/src/explain.ts +772 -60
  57. package/src/index.ts +6 -0
  58. package/src/offline-gate.ts +30 -9
  59. package/src/protocols/adapter.ts +88 -10
  60. package/src/protocols/btest-session.ts +30 -10
  61. package/src/protocols/mac-telnet-console.ts +16 -0
  62. package/src/protocols/mac-telnet.ts +38 -10
  63. package/src/protocols/native-api.ts +13 -12
  64. package/src/retrieve-follow.ts +2 -1
  65. package/src/retrieve-sample.ts +2 -1
  66. package/src/retrieve-wait.ts +10 -6
  67. package/src/retrieve.ts +77 -27
  68. package/src/transfer.ts +38 -13
@@ -0,0 +1,593 @@
1
+ /** Read-only device evidence for explain (#491). Kept out of the browser entry. */
2
+ import { createHash } from "node:crypto";
3
+ import {
4
+ type ApiRequest,
5
+ type ResolvedApiRequest,
6
+ resolveApiRequest,
7
+ } from "./api.ts";
8
+ import { buildTip } from "./core/envelope.ts";
9
+ import {
10
+ type InspectChildItem,
11
+ isArgumentNode,
12
+ pathTokens,
13
+ } from "./core/inspect.ts";
14
+ import { routerOsVersionAtLeast } from "./core/preflight.ts";
15
+ import { routerOsStringLiteral } from "./core/routeros-string.ts";
16
+ import { CentrsError } from "./errors.ts";
17
+ import { analyzeCoordinates } from "./explain/coordinates.ts";
18
+ import {
19
+ buildExplainErrorEnvelope,
20
+ compareExplainDiagnostics,
21
+ type ExplainData,
22
+ type ExplainEnvelope,
23
+ type ExplainEnvelopeOptions,
24
+ type ExplainErrorEnvelope,
25
+ type ExplainEvidence,
26
+ type ExplainSpanClass,
27
+ type ExplainSpanRange,
28
+ explainEnvelope,
29
+ explainValidationMeta,
30
+ verdictOf,
31
+ } from "./explain.ts";
32
+ import {
33
+ collectExplainFacets,
34
+ type ExplainLiveFacets,
35
+ resolveExplainCursor,
36
+ } from "./explain-live-facets.ts";
37
+ import { collectValueTypes } from "./explain-live-types.ts";
38
+ import {
39
+ createProtocolAdapter,
40
+ type ProtocolAdapter,
41
+ } from "./protocols/index.ts";
42
+ import { classifyParseResult } from "./protocols/mac-telnet-console.ts";
43
+ import { redactTargetInput, toCoreSource } from "./resolver/index.ts";
44
+
45
+ export interface ExplainLiveRequest
46
+ extends Pick<
47
+ ApiRequest,
48
+ | "targetInput"
49
+ | "quickchr"
50
+ | "via"
51
+ | "host"
52
+ | "port"
53
+ | "username"
54
+ | "password"
55
+ | "insecure"
56
+ | "timeout"
57
+ | "cdbFile"
58
+ | "cdbPassword"
59
+ | "resolve"
60
+ >,
61
+ ExplainLiveFacets {
62
+ input: string;
63
+ signal?: AbortSignal;
64
+ }
65
+
66
+ /** What the facet module needs from one live collection. */
67
+ export interface LiveProbeContext {
68
+ data: ExplainData;
69
+ input: string;
70
+ /** The SUB-per-non-ASCII-byte input; its offsets are device byte offsets. */
71
+ analyzed: string;
72
+ version?: string;
73
+ highlightBytes: number;
74
+ sha256(text: string): string;
75
+ backend: Pick<ProtocolAdapter, "inspect" | "execute">;
76
+ evidence(probe: string, fields?: Partial<ExplainEvidence>): ExplainEvidence;
77
+ run<T>(ev: ExplainEvidence, action: () => Promise<T>): Promise<T | undefined>;
78
+ }
79
+
80
+ /** Hard work bounds. Oversize probes are skipped, never silently truncated. */
81
+ export const EXPLAIN_LIVE_LIMITS = {
82
+ highlightBytes: 32767,
83
+ parseBytes: 65536,
84
+ commandPaths: 32,
85
+ } as const;
86
+
87
+ /** Refuse competing target sources before reading input or resolving a device. */
88
+ export function assertExplainTarget(
89
+ request: Pick<ExplainLiveRequest, "targetInput" | "quickchr">,
90
+ ): void {
91
+ if (request.quickchr !== undefined && request.targetInput !== undefined)
92
+ throw new CentrsError({
93
+ code: "usage/conflicting-flags",
94
+ summary:
95
+ "--quickchr cannot be combined with a positional or library target.",
96
+ remediation: "Choose either one --quickchr machine or one target.",
97
+ });
98
+ }
99
+
100
+ /** The shared resolver is used for connection settings only; no api request runs. */
101
+ export async function explainLiveEnvelope(
102
+ request: ExplainLiveRequest,
103
+ options: ExplainEnvelopeOptions = {},
104
+ env: Record<string, string | undefined> = Bun.env,
105
+ ): Promise<ExplainEnvelope | ExplainErrorEnvelope> {
106
+ let resolved: ResolvedApiRequest | undefined;
107
+ try {
108
+ assertExplainTarget(request);
109
+ resolveExplainCursor(request.input, request.cursor);
110
+ resolved = await resolveApiRequest(
111
+ {
112
+ ...request,
113
+ endpoint: "/system/resource",
114
+ format: options.format?.value,
115
+ validate: true,
116
+ },
117
+ env,
118
+ );
119
+ const backend = createProtocolAdapter({
120
+ protocol: resolved.via.value,
121
+ ...resolved.target,
122
+ username: resolved.auth.username,
123
+ password: resolved.auth.password,
124
+ timeoutMs: resolved.timeoutMs.value,
125
+ insecure: resolved.insecure.value,
126
+ signal: request.signal,
127
+ });
128
+ try {
129
+ const envelope = explainEnvelope(request.input, options);
130
+ envelope.tips = envelope.tips.filter(
131
+ (tip) => tip.code !== "tip/explain-offline-only",
132
+ );
133
+ await collectExplainEvidence(
134
+ envelope.data,
135
+ request.input,
136
+ backend,
137
+ request.signal,
138
+ request,
139
+ );
140
+ const schema = envelope.data.schema;
141
+ if (schema?.truncated)
142
+ envelope.tips = [
143
+ ...envelope.tips,
144
+ buildTip(
145
+ "tip/explain-schema-truncated",
146
+ `The schema of ${schema.path} was cut to the ${request.full ? "--full safety ceiling" : "default size"}; counts are complete.`,
147
+ schema.next.length > 0
148
+ ? `Describe a narrower path such as ${schema.next.join(", ")}${request.full ? "." : ", or pass --full."}`
149
+ : request.full
150
+ ? "Describe a narrower path."
151
+ : "Pass --full to lift the size limits.",
152
+ ),
153
+ ];
154
+ const probes = envelope.data.evidence.filter(
155
+ (e) => e.source === "live-inspect",
156
+ );
157
+ const failure = probes.find((e) =>
158
+ ["auth-error", "transport-error", "timeout", "interrupted"].includes(
159
+ e.outcome,
160
+ ),
161
+ );
162
+ if (
163
+ failure &&
164
+ !probes.some(
165
+ (e) =>
166
+ e.probe !== "/system/resource" &&
167
+ (e.outcome === "ok" || e.outcome === "empty"),
168
+ )
169
+ ) {
170
+ throw new CentrsError({
171
+ code: failure.errorCode ?? "transport/connection-closed",
172
+ summary:
173
+ failure.reason ??
174
+ "Live explain was interrupted before collecting device evidence.",
175
+ remediation:
176
+ "Check the target, credentials and timeout, then retry; omit the target for offline analysis.",
177
+ });
178
+ }
179
+ envelope.warnings = [...envelope.warnings, ...resolved.warnings];
180
+ envelope.meta = liveMeta(resolved, envelope.data, options.format);
181
+ return envelope;
182
+ } finally {
183
+ await backend.close();
184
+ }
185
+ } catch (error) {
186
+ const envelope = buildExplainErrorEnvelope(error, [], options.format);
187
+ if (resolved)
188
+ envelope.meta = {
189
+ ...liveMeta(resolved, undefined, options.format),
190
+ operation: undefined,
191
+ };
192
+ else
193
+ envelope.meta.target = { input: redactTargetInput(request.targetInput) };
194
+ return envelope;
195
+ }
196
+ }
197
+
198
+ function liveMeta(
199
+ resolved: ResolvedApiRequest,
200
+ data?: ExplainData,
201
+ format?: ExplainEnvelopeOptions["format"],
202
+ ): ExplainEnvelope["meta"] {
203
+ const target = resolved.target;
204
+ return {
205
+ target: {
206
+ input: target.input,
207
+ host: target.host,
208
+ port: target.port,
209
+ baseUrl: target.baseUrl,
210
+ mac: target.mac,
211
+ identity: target.identity,
212
+ recordIndex: target.recordIndex,
213
+ source: toCoreSource(target.source),
214
+ sources: Object.fromEntries(
215
+ Object.entries(target.sources).map(([key, value]) => [
216
+ key,
217
+ toCoreSource(value),
218
+ ]),
219
+ ),
220
+ },
221
+ via: resolved.via.value,
222
+ validation: explainValidationMeta(),
223
+ settings: {
224
+ via: toCoreSource(resolved.via.source),
225
+ host: toCoreSource(target.hostSource),
226
+ port: toCoreSource(target.portSource),
227
+ timeoutMs: toCoreSource(resolved.timeoutMs.source),
228
+ format: toCoreSource(format?.source ?? resolved.format.source),
229
+ insecure: toCoreSource(resolved.insecure.source),
230
+ username: resolved.auth.usernameSource
231
+ ? toCoreSource(resolved.auth.usernameSource)
232
+ : undefined,
233
+ password: resolved.auth.passwordSource
234
+ ? toCoreSource(resolved.auth.passwordSource)
235
+ : undefined,
236
+ },
237
+ ...(data
238
+ ? {
239
+ operation: {
240
+ command: "explain" as const,
241
+ mode: "live" as const,
242
+ statementCount: data.structure.statementCount,
243
+ verdict: data.verdict,
244
+ },
245
+ }
246
+ : {}),
247
+ };
248
+ }
249
+
250
+ /** Transport seam for deterministic failure controls; callers normally use explainLiveEnvelope. */
251
+ export async function collectExplainEvidence(
252
+ data: ExplainData,
253
+ input: string,
254
+ backend: Pick<
255
+ ProtocolAdapter,
256
+ "getSingleton" | "inspect" | "execute" | "protocol"
257
+ >,
258
+ signal?: AbortSignal,
259
+ facets: ExplainLiveFacets = {},
260
+ ): Promise<void> {
261
+ const live = { arguments: [] } as NonNullable<ExplainData["live"]>;
262
+ data.live = live;
263
+ let version: string | undefined;
264
+ let stopped = false;
265
+ let evidenceIndex = 0;
266
+ const full = { start: 0, end: data.input.bytes };
267
+ const sha = (text: string) => createHash("sha256").update(text).digest("hex");
268
+ const evidence = (
269
+ probe: string,
270
+ fields: Partial<ExplainEvidence> = {},
271
+ ): ExplainEvidence => {
272
+ const entry: ExplainEvidence = {
273
+ id: `live${evidenceIndex++}`,
274
+ source: "live-inspect",
275
+ probe,
276
+ basis: "direct",
277
+ outcome: "ok",
278
+ observedAt: new Date().toISOString(),
279
+ ...(version ? { version } : {}),
280
+ ...fields,
281
+ };
282
+ data.evidence.push(entry);
283
+ return entry;
284
+ };
285
+ const run = async <T>(
286
+ ev: ExplainEvidence,
287
+ action: () => Promise<T>,
288
+ ): Promise<T | undefined> => {
289
+ if (signal?.aborted || stopped) {
290
+ ev.outcome = signal?.aborted ? "interrupted" : "skipped";
291
+ ev.reason = signal?.aborted
292
+ ? "caller interrupted the analysis"
293
+ : "a prior transport or authentication probe failed";
294
+ delete ev.coverage;
295
+ return undefined;
296
+ }
297
+ try {
298
+ return await action();
299
+ } catch (error) {
300
+ const code =
301
+ error instanceof CentrsError ? error.code : "internal/unhandled";
302
+ ev.errorCode = code;
303
+ ev.outcome = signal?.aborted
304
+ ? "interrupted"
305
+ : code.startsWith("auth/") ||
306
+ code === "transport/auth-failed" ||
307
+ code === "transport/edge-auth-failed" ||
308
+ code === "routeros/permission-denied"
309
+ ? "auth-error"
310
+ : code.includes("timeout")
311
+ ? "timeout"
312
+ : code.startsWith("transport/")
313
+ ? "transport-error"
314
+ : code === "routeros/unknown-path" ||
315
+ code === "routeros/unsupported-capability"
316
+ ? "unsupported"
317
+ : "invalid-response";
318
+ ev.reason =
319
+ error instanceof CentrsError
320
+ ? error.summary
321
+ : "Probe did not return a usable response.";
322
+ stopped = [
323
+ "auth-error",
324
+ "timeout",
325
+ "transport-error",
326
+ "interrupted",
327
+ ].includes(ev.outcome);
328
+ delete ev.coverage;
329
+ return undefined;
330
+ }
331
+ };
332
+ const environment = evidence("/system/resource");
333
+ const resource = await run(environment, () =>
334
+ backend.getSingleton("/system/resource"),
335
+ );
336
+ if (
337
+ resource &&
338
+ typeof resource === "object" &&
339
+ "version" in resource &&
340
+ typeof resource.version === "string"
341
+ ) {
342
+ version = resource.version;
343
+ environment.version = version;
344
+ const fields = resource as Record<string, unknown>;
345
+ live.environment = {
346
+ ev: environment.id,
347
+ version,
348
+ architecture:
349
+ typeof fields["architecture-name"] === "string"
350
+ ? fields["architecture-name"]
351
+ : undefined,
352
+ board:
353
+ typeof fields["board-name"] === "string"
354
+ ? fields["board-name"]
355
+ : undefined,
356
+ };
357
+ } else if (environment.outcome === "ok") {
358
+ environment.outcome = "invalid-response";
359
+ environment.reason =
360
+ "Device version is unavailable; unsafe probes will be skipped.";
361
+ }
362
+
363
+ // Both probes operate on data: the only executed scripts are escaped :parse wrappers (this one and the #225 type probes).
364
+ const parse = evidence(":parse", {
365
+ coverage: full,
366
+ inputSha256: sha(input),
367
+ normalization: "original-utf8",
368
+ });
369
+ if (data.input.bytes > EXPLAIN_LIVE_LIMITS.parseBytes) {
370
+ parse.outcome = "skipped";
371
+ parse.reason = `Input exceeds the ${EXPLAIN_LIVE_LIMITS.parseBytes}-byte parse budget.`;
372
+ delete parse.coverage;
373
+ } else {
374
+ const result = await run(parse, () =>
375
+ backend.execute({
376
+ path: "",
377
+ command: "",
378
+ script: `:put [:parse ${routerOsStringLiteral(input)}]`,
379
+ }),
380
+ );
381
+ if (result) {
382
+ if (typeof result.ret !== "string" || result.ret.trim() === "") {
383
+ parse.outcome = "empty";
384
+ delete parse.coverage;
385
+ } else {
386
+ live.parse = { ev: parse.id, output: result.ret, rejected: false };
387
+ try {
388
+ classifyParseResult(result.ret, input, backend.protocol);
389
+ } catch (error) {
390
+ if (!(error instanceof CentrsError)) throw error;
391
+ live.parse.rejected = true;
392
+ live.parse.position = error.position;
393
+ const span = error.position
394
+ ? positionSpan(
395
+ input,
396
+ error.position.line,
397
+ error.position.column,
398
+ data.input.bytes,
399
+ )
400
+ : full;
401
+ data.diagnostics.push({
402
+ code: "explain/live-inspect/parse-rejected",
403
+ severity: "error",
404
+ message: error.summary,
405
+ span,
406
+ ev: parse.id,
407
+ });
408
+ }
409
+ }
410
+ }
411
+ }
412
+ const normalized = new TextDecoder().decode(
413
+ analyzeCoordinates(input).analyzed,
414
+ );
415
+ const highlight = evidence("highlight", {
416
+ inputSha256: sha(normalized),
417
+ normalization: "sub-per-non-ascii-byte",
418
+ });
419
+ if (data.input.bytes > EXPLAIN_LIVE_LIMITS.highlightBytes) {
420
+ highlight.outcome = "skipped";
421
+ highlight.reason =
422
+ "Input exceeds the 32,767-byte highlight limit; no prefix was submitted.";
423
+ } else {
424
+ const rows = await run(highlight, () =>
425
+ backend.inspect("highlight", "", normalized),
426
+ );
427
+ if (rows !== undefined) {
428
+ const row: unknown = Array.isArray(rows) ? rows[0] : undefined;
429
+ if (Array.isArray(rows) && rows.length === 0) highlight.outcome = "empty";
430
+ else if (
431
+ !row ||
432
+ typeof row !== "object" ||
433
+ !("highlight" in row) ||
434
+ typeof row.highlight !== "string"
435
+ ) {
436
+ highlight.outcome = "invalid-response";
437
+ highlight.reason = "Highlight response has no class stream.";
438
+ } else {
439
+ const classes = row.highlight.split(",");
440
+ if (classes.length !== data.input.bytes) {
441
+ highlight.outcome = "invalid-response";
442
+ highlight.reason =
443
+ "Highlight length does not match submitted bytes; spans were not published.";
444
+ } else {
445
+ highlight.coverage = full;
446
+ live.highlight = { ev: highlight.id, classes };
447
+ for (let start = 0; start < classes.length; ) {
448
+ const raw = classes[start] ?? "none";
449
+ const classification = highlightClass(raw);
450
+ let end = start + 1;
451
+ while (end < classes.length && classes[end] === raw) end++;
452
+ data.spans.push({
453
+ start,
454
+ end,
455
+ class: classification,
456
+ ev: highlight.id,
457
+ });
458
+ if (raw === "error" || raw === "syntax-obsolete")
459
+ data.diagnostics.push({
460
+ code: `explain/live-inspect/${raw === "error" ? "highlight-error" : "obsolete-syntax"}`,
461
+ severity: raw === "error" ? "error" : "warning",
462
+ message:
463
+ raw === "error"
464
+ ? "The device highlighter reports an error in the submitted input."
465
+ : "The device marks this spelling as obsolete syntax.",
466
+ span: { start, end },
467
+ ev: highlight.id,
468
+ });
469
+ start = end;
470
+ }
471
+ }
472
+ }
473
+ }
474
+ }
475
+ const paths = new Map<string, string[]>();
476
+ for (const statement of data.structure.statements) {
477
+ if (statement.resolution !== "resolved" || statement.kind !== "command")
478
+ continue;
479
+ const path = `${statement.command.path.replace(/\/$/, "")}/${statement.command.verb}`;
480
+ const ids = paths.get(path) ?? [];
481
+ ids.push(statement.id);
482
+ paths.set(path, ids);
483
+ }
484
+ let queried = 0;
485
+ for (const [path, statementIds] of paths) {
486
+ const ev = evidence("child", { path, statementIds });
487
+ if (++queried > EXPLAIN_LIVE_LIMITS.commandPaths) {
488
+ ev.outcome = "skipped";
489
+ ev.reason = "Command-path discovery budget exhausted.";
490
+ continue;
491
+ }
492
+ // No scripting-keyword path discovery on old/unknown devices. No completion calls at all.
493
+ if (
494
+ pathTokens(path).length === 1 &&
495
+ (!version || !routerOsVersionAtLeast(version, "7.21.4"))
496
+ ) {
497
+ ev.outcome = "skipped";
498
+ ev.reason =
499
+ "Scripting-path discovery is disabled before 7.21.4 or when version is unknown.";
500
+ continue;
501
+ }
502
+ const rows = await run(ev, () =>
503
+ backend.inspect("child", pathTokens(path).join(",")),
504
+ );
505
+ if (rows === undefined) continue;
506
+ if (
507
+ !Array.isArray(rows) ||
508
+ rows.some((row) => row === null || typeof row !== "object")
509
+ ) {
510
+ ev.outcome = "invalid-response";
511
+ ev.reason = "Child response is not an array of nodes.";
512
+ continue;
513
+ }
514
+ if (rows.length === 0) ev.outcome = "empty";
515
+ const names = [
516
+ ...new Set(
517
+ (rows as InspectChildItem[])
518
+ .filter(isArgumentNode)
519
+ .map((row) => row.name)
520
+ .filter(
521
+ (name): name is string =>
522
+ typeof name === "string" && name.length > 0,
523
+ ),
524
+ ),
525
+ ].sort();
526
+ live.arguments.push({ ev: ev.id, path, statementIds, names });
527
+ }
528
+ const ctx: LiveProbeContext = {
529
+ data,
530
+ input,
531
+ analyzed: normalized,
532
+ version,
533
+ highlightBytes: EXPLAIN_LIVE_LIMITS.highlightBytes,
534
+ sha256: sha,
535
+ backend,
536
+ evidence,
537
+ run,
538
+ };
539
+ if (facets.schema || facets.complete) await collectExplainFacets(ctx, facets);
540
+ // Last, so the facets' evidence ids do not depend on how many values there are.
541
+ await collectValueTypes(ctx);
542
+ for (const ev of data.evidence)
543
+ if (
544
+ ev.source === "live-inspect" &&
545
+ ev.outcome !== "ok" &&
546
+ !(ev.facet && ev.outcome === "empty")
547
+ ) {
548
+ data.diagnostics.push({
549
+ code: `explain/live-inspect/${ev.outcome}`,
550
+ severity: "warning",
551
+ message: `${ev.probe}: ${ev.reason ?? ev.outcome}`,
552
+ span: ev.coverage ?? full,
553
+ ev: ev.id,
554
+ });
555
+ }
556
+ data.diagnostics.sort(compareExplainDiagnostics);
557
+ data.spans.sort((a, b) => a.start - b.start || a.end - b.end);
558
+ data.verdict = verdictOf(data.diagnostics);
559
+ }
560
+
561
+ function positionSpan(
562
+ input: string,
563
+ line: number,
564
+ column: number,
565
+ length: number,
566
+ ): ExplainSpanRange {
567
+ const lines = input.split("\n");
568
+ let offset = 0;
569
+ for (let i = 0; i < Math.min(line - 1, lines.length); i++)
570
+ offset += new TextEncoder().encode(lines[i] ?? "").length + 1;
571
+ const start = Math.min(length, offset + Math.max(0, column - 1));
572
+ return { start, end: Math.min(length, start + 1) };
573
+ }
574
+
575
+ function highlightClass(value: string): ExplainSpanClass {
576
+ switch (value) {
577
+ case "comment":
578
+ case "dir":
579
+ case "cmd":
580
+ case "escaped":
581
+ case "variable-local":
582
+ case "variable-global":
583
+ case "variable-auto":
584
+ case "variable-parameter":
585
+ return value;
586
+ case "arg":
587
+ case "arg-dot":
588
+ case "arg-scope":
589
+ return "arg";
590
+ default:
591
+ return "unclassified";
592
+ }
593
+ }