@lokascript/framework 4.6.0 → 4.7.0

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 (115) hide show
  1. package/CHANGELOG.md +87 -1
  2. package/dist/api/create-dsl.d.ts +13 -0
  3. package/dist/api/create-dsl.d.ts.map +1 -1
  4. package/dist/api/index.d.ts +1 -0
  5. package/dist/api/index.d.ts.map +1 -1
  6. package/dist/api/index.js +1756 -710
  7. package/dist/api/index.js.map +1 -1
  8. package/dist/api/translation-loss.d.ts +84 -0
  9. package/dist/api/translation-loss.d.ts.map +1 -0
  10. package/dist/core/index.d.ts +1 -0
  11. package/dist/core/index.d.ts.map +1 -1
  12. package/dist/core/index.js +1047 -684
  13. package/dist/core/index.js.map +1 -1
  14. package/dist/core/matching/confidence-model.d.ts +54 -0
  15. package/dist/core/matching/confidence-model.d.ts.map +1 -0
  16. package/dist/core/matching/index.d.ts +12 -0
  17. package/dist/core/matching/index.d.ts.map +1 -0
  18. package/dist/core/matching/match-skeleton.d.ts +114 -0
  19. package/dist/core/matching/match-skeleton.d.ts.map +1 -0
  20. package/dist/core/matching/stock-confidence.d.ts +15 -0
  21. package/dist/core/matching/stock-confidence.d.ts.map +1 -0
  22. package/dist/core/matching/stock-reader.d.ts +56 -0
  23. package/dist/core/matching/stock-reader.d.ts.map +1 -0
  24. package/dist/core/matching/stock-reading.d.ts +44 -0
  25. package/dist/core/matching/stock-reading.d.ts.map +1 -0
  26. package/dist/core/matching/value-reader.d.ts +62 -0
  27. package/dist/core/matching/value-reader.d.ts.map +1 -0
  28. package/dist/core/pattern-matching/index.js +561 -660
  29. package/dist/core/pattern-matching/index.js.map +1 -1
  30. package/dist/core/pattern-matching/pattern-matcher.d.ts +0 -117
  31. package/dist/core/pattern-matching/pattern-matcher.d.ts.map +1 -1
  32. package/dist/core/types.d.ts +48 -0
  33. package/dist/core/types.d.ts.map +1 -1
  34. package/dist/core/types.js.map +1 -1
  35. package/dist/generation/index.d.ts +2 -0
  36. package/dist/generation/index.d.ts.map +1 -1
  37. package/dist/generation/index.js +583 -1
  38. package/dist/generation/index.js.map +1 -1
  39. package/dist/generation/pattern-render.d.ts +72 -0
  40. package/dist/generation/pattern-render.d.ts.map +1 -0
  41. package/dist/generation/schema-patterns.d.ts +152 -0
  42. package/dist/generation/schema-patterns.d.ts.map +1 -0
  43. package/dist/index.cjs +1979 -907
  44. package/dist/index.cjs.map +1 -1
  45. package/dist/index.js +1961 -906
  46. package/dist/index.js.map +1 -1
  47. package/dist/ir/index.js.map +1 -1
  48. package/dist/parsing/index.js +561 -660
  49. package/dist/parsing/index.js.map +1 -1
  50. package/dist/testing/gate/index.d.ts +8 -0
  51. package/dist/testing/gate/index.d.ts.map +1 -0
  52. package/dist/testing/gate/index.js +632 -0
  53. package/dist/testing/gate/index.js.map +1 -0
  54. package/dist/testing/gate/lint/format.d.ts +7 -0
  55. package/dist/testing/gate/lint/format.d.ts.map +1 -0
  56. package/dist/testing/gate/lint/index.d.ts +30 -0
  57. package/dist/testing/gate/lint/index.d.ts.map +1 -0
  58. package/dist/testing/gate/lint/rules/extractor-coverage.d.ts +18 -0
  59. package/dist/testing/gate/lint/rules/extractor-coverage.d.ts.map +1 -0
  60. package/dist/testing/gate/lint/rules/index.d.ts +14 -0
  61. package/dist/testing/gate/lint/rules/index.d.ts.map +1 -0
  62. package/dist/testing/gate/lint/rules/keyword-classification.d.ts +19 -0
  63. package/dist/testing/gate/lint/rules/keyword-classification.d.ts.map +1 -0
  64. package/dist/testing/gate/lint/rules/keyword-coverage.d.ts +13 -0
  65. package/dist/testing/gate/lint/rules/keyword-coverage.d.ts.map +1 -0
  66. package/dist/testing/gate/lint/rules/marker-tokenization.d.ts +20 -0
  67. package/dist/testing/gate/lint/rules/marker-tokenization.d.ts.map +1 -0
  68. package/dist/testing/gate/lint/rules/position-ordering.d.ts +25 -0
  69. package/dist/testing/gate/lint/rules/position-ordering.d.ts.map +1 -0
  70. package/dist/testing/gate/lint/rules/renderer-coherence.d.ts +24 -0
  71. package/dist/testing/gate/lint/rules/renderer-coherence.d.ts.map +1 -0
  72. package/dist/testing/gate/lint/rules/schema-rules.d.ts +10 -0
  73. package/dist/testing/gate/lint/rules/schema-rules.d.ts.map +1 -0
  74. package/dist/testing/gate/lint/types.d.ts +74 -0
  75. package/dist/testing/gate/lint/types.d.ts.map +1 -0
  76. package/dist/testing/gate/round-trip.d.ts +163 -0
  77. package/dist/testing/gate/round-trip.d.ts.map +1 -0
  78. package/package.json +6 -2
  79. package/src/__test__/fixtures/sql-dsl.ts +8 -2
  80. package/src/api/create-dsl-engine.test.ts +86 -0
  81. package/src/api/create-dsl.ts +141 -18
  82. package/src/api/index.ts +1 -0
  83. package/src/api/translation-loss.test.ts +89 -0
  84. package/src/api/translation-loss.ts +128 -0
  85. package/src/core/index.ts +1 -0
  86. package/src/core/matching/confidence-model.ts +58 -0
  87. package/src/core/matching/index.ts +12 -0
  88. package/src/core/matching/match-skeleton.test.ts +179 -0
  89. package/src/core/matching/match-skeleton.ts +621 -0
  90. package/src/core/matching/stock-confidence.ts +238 -0
  91. package/src/core/matching/stock-reader.test.ts +181 -0
  92. package/src/core/matching/stock-reader.ts +186 -0
  93. package/src/core/matching/stock-reading.ts +681 -0
  94. package/src/core/matching/value-reader.ts +85 -0
  95. package/src/core/pattern-matching/pattern-matcher.ts +11 -876
  96. package/src/core/types.ts +48 -0
  97. package/src/generation/index.ts +2 -0
  98. package/src/generation/pattern-render.ts +378 -0
  99. package/src/generation/schema-patterns.test.ts +99 -0
  100. package/src/generation/schema-patterns.ts +962 -0
  101. package/src/testing/gate/index.ts +8 -0
  102. package/src/testing/gate/lint/format.ts +23 -0
  103. package/src/testing/gate/lint/index.ts +73 -0
  104. package/src/testing/gate/lint/lint.test.ts +131 -0
  105. package/src/testing/gate/lint/rules/extractor-coverage.ts +63 -0
  106. package/src/testing/gate/lint/rules/index.ts +37 -0
  107. package/src/testing/gate/lint/rules/keyword-classification.ts +52 -0
  108. package/src/testing/gate/lint/rules/keyword-coverage.ts +38 -0
  109. package/src/testing/gate/lint/rules/marker-tokenization.ts +129 -0
  110. package/src/testing/gate/lint/rules/position-ordering.ts +87 -0
  111. package/src/testing/gate/lint/rules/renderer-coherence.ts +100 -0
  112. package/src/testing/gate/lint/rules/schema-rules.ts +35 -0
  113. package/src/testing/gate/lint/types.ts +83 -0
  114. package/src/testing/gate/round-trip.test.ts +118 -0
  115. package/src/testing/gate/round-trip.ts +438 -0
@@ -0,0 +1,438 @@
1
+ /**
2
+ * The round-trip gate kit (domains rethink Phase 4.6, `docs-internal/DOMAINS_RETHINK.md`): every
3
+ * command of a grammar, written in each of its languages by its own renderer and read back by its own
4
+ * parser. Moved from lokascript-domains' `packages/domains/gate/round-trip.ts` (the domain gate, Phase
5
+ * 2), so any grammar on the framework runs the same gate; the domains repo keeps its fixtures
6
+ * (`gate/values.ts`), baselines and regen tool.
7
+ *
8
+ * A case is a command (bracket syntax, parsed in English) and a language:
9
+ *
10
+ * - **round trip**: `render(ref, lang)`, then `parse`. `pass` when the parse is the reference node, or,
11
+ * for a grammar whose compiled output does not depend on the language, when the text compiles to what
12
+ * the command itself (its bracket form) compiles to (a parser may keep a translated word that the
13
+ * generator normalizes: bdd ja `#box0 が 表示 前提` is `visible`). English is a lane like the others.
14
+ * `refused` when the render is null or the parse throws: the caller is told. `silent` otherwise: it
15
+ * reads, or compiles to, something else.
16
+ * - **leftover**: the same render plus one unknown word. `refused` is the expected outcome; a parse
17
+ * that returns accepted input it did not read (`silent`).
18
+ *
19
+ * The outcomes are the command-shape gate's (hyperfixi `testing-framework`). A baseline lists every
20
+ * case that is not at its best outcome; `compare` reports a case worse than listed and one better than
21
+ * listed, so a gate built on it only shrinks.
22
+ */
23
+
24
+ export type Outcome = 'pass' | 'refused' | 'silent';
25
+ export type CaseKind = 'roundTrip' | 'leftover';
26
+
27
+ /** Best first. A round trip's best is `pass`; leftover input's is `refused`. */
28
+ export const RANK: Record<CaseKind, Record<Outcome, number>> = {
29
+ roundTrip: { pass: 2, refused: 1, silent: 0 },
30
+ leftover: { refused: 2, pass: 2, silent: 0 },
31
+ };
32
+ export const BEST: Record<CaseKind, Outcome> = { roundTrip: 'pass', leftover: 'refused' };
33
+
34
+ /** Appended for the leftover cases: a word no grammar's vocabulary has. */
35
+ export const UNKNOWN_WORD = 'zzqx';
36
+
37
+ export interface CaseResult {
38
+ /** The grammar (a domain's name). */
39
+ grammar: string;
40
+ kind: CaseKind;
41
+ /** The command, in bracket syntax (read in English). */
42
+ source: string;
43
+ lang: string;
44
+ outcome: Outcome;
45
+ /** What the renderer wrote (plus the unknown word, for leftover). */
46
+ text?: string;
47
+ /** Why it is not `pass`: the error, or what the parse read instead. */
48
+ why?: string;
49
+ /** The round trip's parse, summarized (`summarize`); absent when it was refused. */
50
+ read?: string;
51
+ }
52
+
53
+ /** What the gate reads of a role in a schema. */
54
+ export interface GateRoleSpec {
55
+ readonly role: string;
56
+ readonly required?: boolean;
57
+ readonly expectedTypes?: readonly string[];
58
+ }
59
+
60
+ /** What the gate reads of a command schema. */
61
+ export interface GateSchema {
62
+ readonly action: string;
63
+ readonly roles: readonly GateRoleSpec[];
64
+ }
65
+
66
+ /** What the gate reads of a parse. */
67
+ export interface GateNode {
68
+ readonly action: string;
69
+ readonly roles: ReadonlyMap<string, unknown> | Readonly<Record<string, unknown>>;
70
+ }
71
+
72
+ /** The grammar's DSL, as the gate drives it. */
73
+ export interface GateDSL {
74
+ parse(input: string, language: string): GateNode;
75
+ compile(input: string, language: string): { ok: boolean; code?: string; errors?: string[] };
76
+ render?(node: GateNode, language: string): string | null;
77
+ getSupportedLanguages(): string[];
78
+ }
79
+
80
+ /**
81
+ * The values the gate writes into each role. A role not listed gets a plain name no vocabulary uses
82
+ * (`v0x`, or `#box0` where only a selector fits): its value must come back verbatim. A role that takes
83
+ * FIXED words lists every word the grammar's renderer and generator know, in English: the gate writes
84
+ * the command once per word, in every language, so a word one of the two tables lacks fails in the
85
+ * languages where it is missing.
86
+ */
87
+ export interface GateFixture {
88
+ /**
89
+ * `compile`: a round trip also passes when the text compiles to the command's own code (the grammar
90
+ * compiles to something that does not depend on the language). `node`: only the parse counts (a
91
+ * grammar that compiles to a sentence in each language).
92
+ */
93
+ oracle: 'compile' | 'node';
94
+ /** Keys are `<action>.<role>` or `<role>` (every action). The first value is the one the other cases use. */
95
+ values: Record<string, string[]>;
96
+ /** Actions that do not compile on their own (a gate may check every other one does). */
97
+ englishDoesNotCompile?: string[];
98
+ }
99
+
100
+ /** A value no vocabulary uses: a selector where only a selector fits, a plain name otherwise. */
101
+ function defaultValue(role: GateRoleSpec, i: number): string {
102
+ const types = role.expectedTypes ?? [];
103
+ if (types.includes('selector') && !types.includes('literal') && !types.includes('expression'))
104
+ return `#box${i}`;
105
+ return `v${i}x`;
106
+ }
107
+
108
+ function valuesFor(fixture: GateFixture, action: string, role: GateRoleSpec, i: number): string[] {
109
+ return (
110
+ fixture.values[`${action}.${role.role}`] ?? fixture.values[role.role] ?? [defaultValue(role, i)]
111
+ );
112
+ }
113
+
114
+ /**
115
+ * The commands a grammar is checked on: for each schema, its required roles alone and all its roles
116
+ * (each with its first value), then each further value of a role that takes fixed words, alone.
117
+ */
118
+ export function commandsFor(schemas: readonly GateSchema[], fixture: GateFixture): string[] {
119
+ const out = new Set<string>();
120
+ for (const schema of schemas) {
121
+ const required = schema.roles.filter(r => r.required);
122
+ const variants =
123
+ required.length === schema.roles.length ? [required] : [required, [...schema.roles]];
124
+ for (const roles of variants) {
125
+ const values = roles.map((r, i) => valuesFor(fixture, schema.action, r, i));
126
+ const write = (pick: string[]) =>
127
+ `[${schema.action}${roles.map((r, i) => ` ${r.role}:${pick[i]}`).join('')}]`;
128
+ const first = values.map(v => v[0]!);
129
+ out.add(write(first));
130
+ values.forEach((vs, i) => {
131
+ for (const v of vs.slice(1)) out.add(write(first.map((f, j) => (j === i ? v : f))));
132
+ });
133
+ }
134
+ }
135
+ return [...out];
136
+ }
137
+
138
+ function roleEntries(node: GateNode): [string, unknown][] {
139
+ return node.roles instanceof Map
140
+ ? [...(node.roles as ReadonlyMap<string, unknown>)]
141
+ : Object.entries(node.roles ?? {});
142
+ }
143
+
144
+ function roleValues(node: GateNode): Record<string, string> {
145
+ return Object.fromEntries(
146
+ roleEntries(node)
147
+ .map(([k, v]): [string, string] => [
148
+ k,
149
+ String((v as { value?: unknown })?.value ?? (v as { raw?: unknown })?.raw ?? v),
150
+ ])
151
+ .sort(([a], [b]) => (a < b ? -1 : 1))
152
+ );
153
+ }
154
+
155
+ /** A parse as one line: `action role:type=value …`, roles sorted. What the parse snapshot records. */
156
+ export function summarize(node: GateNode): string {
157
+ const roles = roleEntries(node)
158
+ .map(([k, v]) => {
159
+ const t = (v as { type?: unknown })?.type;
160
+ const raw = (v as { value?: unknown })?.value ?? (v as { raw?: unknown })?.raw ?? v;
161
+ const text = String(raw);
162
+ return `${k}${t ? `:${String(t)}` : ''}=${/[\s=]/.test(text) ? JSON.stringify(text) : text}`;
163
+ })
164
+ .sort();
165
+ return [node.action, ...roles].join(' ');
166
+ }
167
+
168
+ function describeRead(want: Record<string, string>, ref: GateNode, back: GateNode): string {
169
+ if (back.action !== ref.action) return `another command: ${back.action}`;
170
+ const got = roleValues(back);
171
+ const lost = Object.keys(want).filter(k => !(k in got));
172
+ const added = Object.keys(got).filter(k => !(k in want));
173
+ const changed = Object.keys(want).filter(k => k in got && got[k] !== want[k]);
174
+ const parts = [
175
+ ...lost.map(k => `${k} lost`),
176
+ ...added.map(k => `${k} added (${got[k]})`),
177
+ ...changed.map(k => `${k}: ${got[k]}`),
178
+ ];
179
+ return parts.join(', ');
180
+ }
181
+
182
+ const message = (e: unknown) => String((e as Error)?.message ?? e).slice(0, 160);
183
+
184
+ /** What a command (bracket syntax) compiles to; null when it does not compile on its own. */
185
+ export function referenceCode(dsl: GateDSL, source: string): string | null {
186
+ try {
187
+ const c = dsl.compile(source, 'en');
188
+ return c.ok && typeof c.code === 'string' ? c.code : null;
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+
194
+ /** Run every case of one grammar: each command of `commandsFor` in each of the DSL's languages. */
195
+ export function runRoundTrip(
196
+ grammar: string,
197
+ dsl: GateDSL,
198
+ schemas: readonly GateSchema[],
199
+ fixture: GateFixture
200
+ ): CaseResult[] {
201
+ const languages = dsl.getSupportedLanguages();
202
+ const results: CaseResult[] = [];
203
+
204
+ for (const source of commandsFor(schemas, fixture)) {
205
+ const ref = dsl.parse(source, 'en');
206
+ const want = roleValues(ref);
207
+ const sameNode = (back: GateNode) =>
208
+ back.action === ref.action && JSON.stringify(roleValues(back)) === JSON.stringify(want);
209
+
210
+ // The code the command itself compiles to: its bracket form, not its English render, or the English
211
+ // lane would be measured against itself (bdd's `When double-click` reads `double`, and compiled to a
212
+ // comment that every correct language then "differed" from). Null when the command does not compile
213
+ // alone; then only the node can pass.
214
+ const refCode = fixture.oracle === 'compile' ? referenceCode(dsl, source) : null;
215
+
216
+ for (const lang of languages) {
217
+ const at = { grammar, source, lang };
218
+ const text = dsl.render?.(ref, lang) ?? null;
219
+ if (!text) {
220
+ results.push({ ...at, kind: 'roundTrip', outcome: 'refused', why: 'no render' });
221
+ continue;
222
+ }
223
+ let back: GateNode;
224
+ try {
225
+ back = dsl.parse(text, lang);
226
+ } catch (e) {
227
+ results.push({ ...at, kind: 'roundTrip', outcome: 'refused', text, why: message(e) });
228
+ continue;
229
+ }
230
+
231
+ let outcome: Outcome = 'silent';
232
+ let why: string | undefined;
233
+ if (sameNode(back)) outcome = 'pass';
234
+ else if (refCode !== null) {
235
+ let code: string | undefined;
236
+ try {
237
+ const c = dsl.compile(text, lang);
238
+ code = c.ok ? c.code : undefined;
239
+ } catch {
240
+ code = undefined;
241
+ }
242
+ if (code === refCode) outcome = 'pass';
243
+ else why = `compiles differently; reads ${describeRead(want, ref, back)}`;
244
+ } else why = `reads ${describeRead(want, ref, back)}`;
245
+ results.push({
246
+ ...at,
247
+ kind: 'roundTrip',
248
+ outcome,
249
+ text,
250
+ ...(why ? { why } : {}),
251
+ read: summarize(back),
252
+ });
253
+
254
+ // Leftover input: only where the text itself reads, or a refusal would say nothing.
255
+ const extended = `${text} ${UNKNOWN_WORD}`;
256
+ try {
257
+ const read = dsl.parse(extended, lang);
258
+ results.push({
259
+ ...at,
260
+ kind: 'leftover',
261
+ outcome: 'silent',
262
+ text: extended,
263
+ why: `accepted as ${read.action}`,
264
+ });
265
+ } catch {
266
+ results.push({ ...at, kind: 'leftover', outcome: 'refused', text: extended });
267
+ }
268
+ }
269
+ }
270
+ return results;
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------------------------------
274
+ // The baseline
275
+ // ---------------------------------------------------------------------------------------------------
276
+
277
+ export interface ListedCase {
278
+ outcome: Exclude<Outcome, 'pass'>;
279
+ text?: string;
280
+ why?: string;
281
+ }
282
+
283
+ export interface GateBaseline {
284
+ /** The upstream packages this was measured on (the grammar's lockfile). */
285
+ upstream: Record<string, string>;
286
+ /** Per grammar and kind: cases and outcomes. Written by the regen tool for the reader; not checked. */
287
+ counts: Record<string, Record<CaseKind, Record<Outcome | 'cases', number>>>;
288
+ /** Per grammar, command and language: every round trip that is not `pass`. */
289
+ roundTrip: Record<string, Record<string, Record<string, ListedCase>>>;
290
+ /** Per grammar and command: the languages whose leftover case was accepted (`silent`). */
291
+ leftover: Record<string, Record<string, string[]>>;
292
+ }
293
+
294
+ export interface DriftCase {
295
+ kind: CaseKind;
296
+ source: string;
297
+ lang: string;
298
+ listed: Outcome;
299
+ now: Outcome;
300
+ text?: string;
301
+ why?: string;
302
+ }
303
+
304
+ export interface Drift {
305
+ worse: DriftCase[];
306
+ better: DriftCase[];
307
+ /** Listed cases that are no longer cases at all. */
308
+ stale: { kind: CaseKind; source: string; lang: string }[];
309
+ }
310
+
311
+ function listedOutcome(
312
+ baseline: GateBaseline,
313
+ r: Pick<CaseResult, 'grammar' | 'kind' | 'source' | 'lang'>
314
+ ): Outcome {
315
+ if (r.kind === 'roundTrip')
316
+ return baseline.roundTrip[r.grammar]?.[r.source]?.[r.lang]?.outcome ?? 'pass';
317
+ return baseline.leftover[r.grammar]?.[r.source]?.includes(r.lang) ? 'silent' : 'refused';
318
+ }
319
+
320
+ /** How one grammar's results moved against the baseline. */
321
+ export function compare(grammar: string, results: CaseResult[], baseline: GateBaseline): Drift {
322
+ const drift: Drift = { worse: [], better: [], stale: [] };
323
+ const seen = new Set<string>();
324
+ for (const r of results) {
325
+ seen.add(`${r.kind}\u0000${r.source}\u0000${r.lang}`);
326
+ const listed = listedOutcome(baseline, r);
327
+ const rank = RANK[r.kind];
328
+ const entry = { kind: r.kind, source: r.source, lang: r.lang, listed, now: r.outcome };
329
+ if (rank[r.outcome] < rank[listed])
330
+ drift.worse.push({
331
+ ...entry,
332
+ ...(r.text !== undefined ? { text: r.text } : {}),
333
+ ...(r.why !== undefined ? { why: r.why } : {}),
334
+ });
335
+ else if (rank[r.outcome] > rank[listed]) drift.better.push(entry);
336
+ }
337
+ const listedCases: { kind: CaseKind; source: string; lang: string }[] = [
338
+ ...Object.entries(baseline.roundTrip[grammar] ?? {}).flatMap(([source, langs]) =>
339
+ Object.keys(langs).map(lang => ({ kind: 'roundTrip' as const, source, lang }))
340
+ ),
341
+ ...Object.entries(baseline.leftover[grammar] ?? {}).flatMap(([source, langs]) =>
342
+ langs.map(lang => ({ kind: 'leftover' as const, source, lang }))
343
+ ),
344
+ ];
345
+ for (const c of listedCases)
346
+ if (!seen.has(`${c.kind}\u0000${c.source}\u0000${c.lang}`)) drift.stale.push(c);
347
+ return drift;
348
+ }
349
+
350
+ /** The baseline entries for one grammar's results. */
351
+ export function listingOf(results: CaseResult[]): {
352
+ roundTrip: Record<string, Record<string, ListedCase>>;
353
+ leftover: Record<string, string[]>;
354
+ } {
355
+ const roundTrip: Record<string, Record<string, ListedCase>> = {};
356
+ const leftover: Record<string, string[]> = {};
357
+ for (const r of results) {
358
+ if (r.kind === 'roundTrip' && r.outcome !== 'pass') {
359
+ (roundTrip[r.source] ??= {})[r.lang] = {
360
+ outcome: r.outcome,
361
+ ...(r.text ? { text: r.text } : {}),
362
+ ...(r.why ? { why: r.why } : {}),
363
+ };
364
+ } else if (r.kind === 'leftover' && r.outcome === 'silent')
365
+ (leftover[r.source] ??= []).push(r.lang);
366
+ }
367
+ return { roundTrip, leftover };
368
+ }
369
+
370
+ export function countsOf(
371
+ results: CaseResult[]
372
+ ): Record<CaseKind, Record<Outcome | 'cases', number>> {
373
+ const counts = {
374
+ roundTrip: { cases: 0, pass: 0, refused: 0, silent: 0 },
375
+ leftover: { cases: 0, pass: 0, refused: 0, silent: 0 },
376
+ };
377
+ for (const r of results) {
378
+ counts[r.kind].cases++;
379
+ counts[r.kind][r.outcome]++;
380
+ }
381
+ return counts;
382
+ }
383
+
384
+ // ---------------------------------------------------------------------------------------------------
385
+ // The parse snapshot
386
+ // ---------------------------------------------------------------------------------------------------
387
+
388
+ /**
389
+ * Every round trip's parse, so a change that moves a parse shows it, whether or not an outcome moved
390
+ * (a value's type, a role a passing case gained, what a silent case reads now). `refused` where the
391
+ * parse threw. Leftover cases are not in it: their outcome is the baseline's.
392
+ */
393
+ export interface ParseSnapshot {
394
+ upstream: Record<string, string>;
395
+ /** Per grammar, command and language. */
396
+ parses: Record<string, Record<string, Record<string, string>>>;
397
+ }
398
+
399
+ export function snapshotOf(results: CaseResult[]): Record<string, Record<string, string>> {
400
+ const out: Record<string, Record<string, string>> = {};
401
+ for (const r of results)
402
+ if (r.kind === 'roundTrip') (out[r.source] ??= {})[r.lang] = r.read ?? 'refused';
403
+ return out;
404
+ }
405
+
406
+ export interface SnapshotChange {
407
+ source: string;
408
+ lang: string;
409
+ was?: string;
410
+ now?: string;
411
+ }
412
+
413
+ /** Every round trip whose parse differs from the snapshot's, for one grammar. */
414
+ export function snapshotChanges(
415
+ grammar: string,
416
+ results: CaseResult[],
417
+ snapshot: ParseSnapshot
418
+ ): SnapshotChange[] {
419
+ const now = snapshotOf(results);
420
+ const was = snapshot.parses[grammar] ?? {};
421
+ const changes: SnapshotChange[] = [];
422
+ for (const source of new Set([...Object.keys(was), ...Object.keys(now)]))
423
+ for (const lang of new Set([
424
+ ...Object.keys(was[source] ?? {}),
425
+ ...Object.keys(now[source] ?? {}),
426
+ ])) {
427
+ const a = was[source]?.[lang];
428
+ const b = now[source]?.[lang];
429
+ if (a !== b)
430
+ changes.push({
431
+ source,
432
+ lang,
433
+ ...(a !== undefined ? { was: a } : {}),
434
+ ...(b !== undefined ? { now: b } : {}),
435
+ });
436
+ }
437
+ return changes;
438
+ }