@hyperfixi/testing-framework 4.1.0 → 4.3.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.
- package/CHANGELOG.md +246 -1
- package/package.json +7 -7
- package/src/multilingual/command-shapes-gate.ts +76 -0
- package/src/multilingual/command-shapes.1.test.ts +9 -0
- package/src/multilingual/command-shapes.2.test.ts +9 -0
- package/src/multilingual/command-shapes.3.test.ts +9 -0
- package/src/multilingual/command-shapes.cases.json +6816 -0
- package/src/multilingual/command-shapes.cases.test.ts +122 -0
- package/src/multilingual/command-shapes.oracle.test.ts +204 -0
- package/src/multilingual/command-shapes.ts +919 -0
- package/src/multilingual/direct-path-shapes.ts +9 -2
- package/src/multilingual/value-matrix.branch-phrases.test.ts +13 -0
- package/src/multilingual/value-matrix.branch.test.ts +13 -0
- package/src/multilingual/value-matrix.ts +56 -10
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command-shape gate — what a translation silently drops
|
|
3
|
+
* -----------------------------------------------------------
|
|
4
|
+
* Every other multilingual gate reads the corpus or a generated matrix of
|
|
5
|
+
* value shapes. Neither holds the shapes a page author writes around the
|
|
6
|
+
* values: a second class ref, a `when` filter, `else if`, `break`, `catch`, a
|
|
7
|
+
* second feature after the first `end`. Semantic's parser matches a prefix of
|
|
8
|
+
* such a script and leaves the rest unconsumed; `translate()` returned the
|
|
9
|
+
* prefix as though it were the whole.
|
|
10
|
+
*
|
|
11
|
+
* A CASE is one script a page author could write: every `_` attribute and
|
|
12
|
+
* hyperscript script body in the vendored upstream suite, core's reference
|
|
13
|
+
* examples and its hover examples, that the engine parses
|
|
14
|
+
* (`command-shapes.cases.json`, harvested by tools/harvest-command-shapes.ts).
|
|
15
|
+
* A page's script is read as the host reads it, as a program of features; a
|
|
16
|
+
* documented example as free-standing statements (see Reader).
|
|
17
|
+
*
|
|
18
|
+
* ## Lanes
|
|
19
|
+
* - `en` `translate(src, 'en', 'en')`;
|
|
20
|
+
* - `<L>` each of 23 languages: `translate(translate(src, 'en', L), L, 'en')`.
|
|
21
|
+
*
|
|
22
|
+
* ## Oracle
|
|
23
|
+
* The engine's parse of the lane's English equals its parse of the source,
|
|
24
|
+
* positions stripped, after the named EQUIVALENCES (each rewrites both parses,
|
|
25
|
+
* and each is pinned by a run on the engine and on upstream in
|
|
26
|
+
* command-shapes.equivalences.test.ts). Node only, no browser: the same engine
|
|
27
|
+
* reads both strings, so equal parses run the same.
|
|
28
|
+
*
|
|
29
|
+
* A (case, lane) pair is one of
|
|
30
|
+
* - `pass` the parses are equal;
|
|
31
|
+
* - `refused` a `translate` call threw: the caller was told;
|
|
32
|
+
* - `silent` it returned English that parses differently, or that the
|
|
33
|
+
* engine rejects, and nothing said so.
|
|
34
|
+
*
|
|
35
|
+
* ## The baseline
|
|
36
|
+
* `baselines/command-shapes.json` lists, per case, the lanes that are refused
|
|
37
|
+
* and the lanes that are silent. The gate fails on a pair that does not pass
|
|
38
|
+
* and is not listed, on a listed pair that passes, on a pair listed refused
|
|
39
|
+
* that went silent, and on a pair listed silent that is refused now (prune
|
|
40
|
+
* it): so both lists only shrink. Cases in a LOUD family (shapes semantic does
|
|
41
|
+
* not carry yet) carry their reason; the gate fails when one starts to pass.
|
|
42
|
+
* Regenerate with tools/regen-command-shapes-baseline.ts.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { readFileSync } from 'node:fs';
|
|
46
|
+
import path from 'node:path';
|
|
47
|
+
import { fileURLToPath } from 'node:url';
|
|
48
|
+
import { FOREIGN_LANGUAGES } from './value-matrix';
|
|
49
|
+
|
|
50
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
51
|
+
|
|
52
|
+
export const CASES_PATH = path.join(here, 'command-shapes.cases.json');
|
|
53
|
+
export const BASELINE_PATH = path.resolve(here, '../../baselines/command-shapes.json');
|
|
54
|
+
const RUNNER_PATH = path.resolve(here, '../../../engine/upstream-suite/run.mjs');
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Cases
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
export interface CommandShapeCase {
|
|
61
|
+
/** A hash of the source with its whitespace collapsed: stable across re-harvests. */
|
|
62
|
+
id: string;
|
|
63
|
+
source: string;
|
|
64
|
+
/** `upstream:<file>`, `reference:<command>`, `reference-pattern:<name>` or `hover:<keyword>`. */
|
|
65
|
+
origin: string;
|
|
66
|
+
/** What the source's origin tests: the upstream file's stem, or the documented keyword. */
|
|
67
|
+
family: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CommandShapeCases {
|
|
71
|
+
description: string;
|
|
72
|
+
/** The vendored upstream version the cases were harvested from. */
|
|
73
|
+
upstream: string;
|
|
74
|
+
cases: CommandShapeCase[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The key two sources share when they differ only in whitespace. */
|
|
78
|
+
export const collapse = (source: string): string => source.replace(/\s+/g, ' ').trim();
|
|
79
|
+
|
|
80
|
+
/** The upstream version `upstream-suite/run.mjs` runs (its VENDORED constant). */
|
|
81
|
+
export function vendoredUpstreamVersion(): string {
|
|
82
|
+
const match = /const VENDORED = '([^']+)'/.exec(readFileSync(RUNNER_PATH, 'utf8'));
|
|
83
|
+
if (!match?.[1]) throw new Error(`no VENDORED constant in ${RUNNER_PATH}`);
|
|
84
|
+
return match[1];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function loadCommandShapeCases(): CommandShapeCases {
|
|
88
|
+
return JSON.parse(readFileSync(CASES_PATH, 'utf8')) as CommandShapeCases;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Lanes
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/** Every lane, in report order: English, then the 23 languages. */
|
|
96
|
+
export const LANES: readonly string[] = ['en', ...FOREIGN_LANGUAGES];
|
|
97
|
+
|
|
98
|
+
export type Outcome = 'pass' | 'refused' | 'silent';
|
|
99
|
+
|
|
100
|
+
/** How a silent lane differs: what the report and the triage group by. */
|
|
101
|
+
export type SilentKind =
|
|
102
|
+
/** The engine rejects the lane's English. */
|
|
103
|
+
| 'rejected'
|
|
104
|
+
/** A command or feature the source has is missing, or one it lacks is there. */
|
|
105
|
+
| 'commands'
|
|
106
|
+
/** The same commands and features, parsed differently. */
|
|
107
|
+
| 'differs';
|
|
108
|
+
|
|
109
|
+
export interface LaneResult {
|
|
110
|
+
outcome: Outcome;
|
|
111
|
+
/** The lane's English (a silent lane), or the error's first line (a refused one). */
|
|
112
|
+
detail?: string;
|
|
113
|
+
kind?: SilentKind;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface CaseResult {
|
|
117
|
+
id: string;
|
|
118
|
+
/** The LOUD family the case belongs to (see LOUD), if any. */
|
|
119
|
+
loud?: string;
|
|
120
|
+
/** The command and feature node types of the source's engine parse, for the report. */
|
|
121
|
+
statements: string[];
|
|
122
|
+
lanes: Record<string, LaneResult>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// The oracle
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* What the engine's parse carries that is not meaning: where a node sits, and
|
|
131
|
+
* a label derived from what it holds (`displayName`). Functions (what a node
|
|
132
|
+
* runs) are dropped too. Not `source`: a `js` body keeps its code there.
|
|
133
|
+
*/
|
|
134
|
+
const POSITION_KEYS: ReadonlySet<string> = new Set([
|
|
135
|
+
'start',
|
|
136
|
+
'end',
|
|
137
|
+
'line',
|
|
138
|
+
'column',
|
|
139
|
+
'displayName',
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
/** The engine's parse surface this uses. */
|
|
143
|
+
export interface EngineParser {
|
|
144
|
+
parse(source: string): unknown;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* How the engine reads a case, and so its lanes' English: a page's script (an
|
|
149
|
+
* `_` attribute, a script tag's body) as a program of features, as the host
|
|
150
|
+
* does; a documented example as free-standing statements (`toggle .active`).
|
|
151
|
+
* A leading `set` is a feature in a program and a command in statements.
|
|
152
|
+
*/
|
|
153
|
+
export type Reader = 'program' | 'statements';
|
|
154
|
+
|
|
155
|
+
export const readerOf = (shape: Pick<CommandShapeCase, 'origin'>): Reader =>
|
|
156
|
+
shape.origin.startsWith('upstream:') ? 'program' : 'statements';
|
|
157
|
+
|
|
158
|
+
/** The engine's two readers. Statements exclude a lone expression: it is not a script. */
|
|
159
|
+
export async function loadEngineReaders(): Promise<Record<Reader, EngineParser>> {
|
|
160
|
+
const engine = await import('@hyperfixi/engine');
|
|
161
|
+
engine.register(...engine.everything);
|
|
162
|
+
return {
|
|
163
|
+
program: { parse: engine.parseProgram },
|
|
164
|
+
statements: {
|
|
165
|
+
parse(source) {
|
|
166
|
+
const parsed = engine.parse(source);
|
|
167
|
+
if (parsed.kind === 'expression') throw new Error('an expression, not a script');
|
|
168
|
+
return parsed;
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A parse as plain data: positions and functions stripped, cycles cut. */
|
|
175
|
+
export function plainParse(parsed: unknown): unknown {
|
|
176
|
+
const seen = new WeakSet<object>();
|
|
177
|
+
const walk = (value: unknown): unknown => {
|
|
178
|
+
if (typeof value === 'function') return undefined;
|
|
179
|
+
if (!value || typeof value !== 'object') return value;
|
|
180
|
+
if (seen.has(value)) return '<cycle>';
|
|
181
|
+
seen.add(value);
|
|
182
|
+
if (Array.isArray(value)) return value.map(walk);
|
|
183
|
+
const out: Record<string, unknown> = {};
|
|
184
|
+
for (const [key, v] of Object.entries(value)) {
|
|
185
|
+
if (POSITION_KEYS.has(key)) continue;
|
|
186
|
+
const w = walk(v);
|
|
187
|
+
if (w !== undefined) out[key] = w;
|
|
188
|
+
}
|
|
189
|
+
seen.delete(value);
|
|
190
|
+
return out;
|
|
191
|
+
};
|
|
192
|
+
return walk(parsed);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The node types in a plain parse, with their counts, for the commands and features. */
|
|
196
|
+
export function statementCounts(plain: unknown): Map<string, number> {
|
|
197
|
+
const counts = new Map<string, number>();
|
|
198
|
+
const walk = (value: unknown): void => {
|
|
199
|
+
if (!value || typeof value !== 'object') return;
|
|
200
|
+
if (Array.isArray(value)) {
|
|
201
|
+
for (const v of value) walk(v);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const type = (value as { type?: unknown }).type;
|
|
205
|
+
if (typeof type === 'string' && /(Command|Feature)$/.test(type)) {
|
|
206
|
+
counts.set(type, (counts.get(type) ?? 0) + 1);
|
|
207
|
+
}
|
|
208
|
+
for (const v of Object.values(value)) walk(v);
|
|
209
|
+
};
|
|
210
|
+
walk(plain);
|
|
211
|
+
return counts;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Every node type in a plain parse. */
|
|
215
|
+
export function nodeTypes(plain: unknown): Set<string> {
|
|
216
|
+
const types = new Set<string>();
|
|
217
|
+
const walk = (value: unknown): void => {
|
|
218
|
+
if (!value || typeof value !== 'object') return;
|
|
219
|
+
if (Array.isArray(value)) {
|
|
220
|
+
for (const v of value) walk(v);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const type = (value as { type?: unknown }).type;
|
|
224
|
+
if (typeof type === 'string') types.add(type);
|
|
225
|
+
for (const v of Object.values(value)) walk(v);
|
|
226
|
+
};
|
|
227
|
+
walk(plain);
|
|
228
|
+
return types;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Judge one lane's English against the source's parse (already plain and
|
|
233
|
+
* normalized): pass, or how it is silent.
|
|
234
|
+
*/
|
|
235
|
+
export function judge(
|
|
236
|
+
engine: EngineParser,
|
|
237
|
+
want: string,
|
|
238
|
+
wantPlain: unknown,
|
|
239
|
+
english: string
|
|
240
|
+
): LaneResult {
|
|
241
|
+
let got: unknown;
|
|
242
|
+
try {
|
|
243
|
+
got = normalize(plainParse(engine.parse(english)));
|
|
244
|
+
} catch {
|
|
245
|
+
return { outcome: 'silent', kind: 'rejected', detail: english };
|
|
246
|
+
}
|
|
247
|
+
if (JSON.stringify(got) === want) return { outcome: 'pass' };
|
|
248
|
+
const a = statementCounts(wantPlain);
|
|
249
|
+
const b = statementCounts(got);
|
|
250
|
+
const same = a.size === b.size && [...a].every(([type, n]) => b.get(type) === n);
|
|
251
|
+
return { outcome: 'silent', kind: same ? 'differs' : 'commands', detail: english };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Named equivalences
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Two spellings the engine parses into different nodes that run the same.
|
|
260
|
+
* Each rewrites a plain node (its children already rewritten) from one
|
|
261
|
+
* spelling into the other, and each is pinned by runs on the engine and on
|
|
262
|
+
* upstream (command-shapes.equivalences.test.ts). `the` is not one: `halt
|
|
263
|
+
* event` does not parse where `halt the event` does.
|
|
264
|
+
*/
|
|
265
|
+
export interface Equivalence {
|
|
266
|
+
name: string;
|
|
267
|
+
description: string;
|
|
268
|
+
/**
|
|
269
|
+
* Handlers that must leave the same observation on both engines, written so
|
|
270
|
+
* the observation is not empty: each pair is a spelling and its rewrite.
|
|
271
|
+
*/
|
|
272
|
+
pins: ReadonlyArray<readonly [string, string]>;
|
|
273
|
+
rewrite(node: PlainNode): PlainNode;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export type PlainNode = Record<string, unknown>;
|
|
277
|
+
|
|
278
|
+
const isNode = (value: unknown, type: string): value is PlainNode =>
|
|
279
|
+
!!value && typeof value === 'object' && (value as PlainNode).type === type;
|
|
280
|
+
|
|
281
|
+
/** The pronoun each possessive or subject form reads as. */
|
|
282
|
+
const PRONOUNS: Readonly<Record<string, string>> = { my: 'me', I: 'me', its: 'it', your: 'you' };
|
|
283
|
+
|
|
284
|
+
const isPronoun = (value: unknown): boolean =>
|
|
285
|
+
isNode(value, 'symbol') &&
|
|
286
|
+
value.scope === 'local' &&
|
|
287
|
+
['me', 'it', 'you'].includes(String(value.name));
|
|
288
|
+
|
|
289
|
+
/** A node without one key. */
|
|
290
|
+
const without = (node: PlainNode, key: string): PlainNode => {
|
|
291
|
+
const { [key]: _dropped, ...rest } = node;
|
|
292
|
+
return rest;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
export const EQUIVALENCES: readonly Equivalence[] = [
|
|
296
|
+
{
|
|
297
|
+
name: 'pronoun-spelling',
|
|
298
|
+
description: '`my`, `I`, `its` and `your` read as `me`, `me`, `it` and `you`',
|
|
299
|
+
pins: [
|
|
300
|
+
['on click put my.id into #o', 'on click put me.id into #o'],
|
|
301
|
+
[
|
|
302
|
+
"on click get {name:'Q'} then put its.name into #o",
|
|
303
|
+
"on click get {name:'Q'} then put it.name into #o",
|
|
304
|
+
],
|
|
305
|
+
],
|
|
306
|
+
rewrite: node =>
|
|
307
|
+
isNode(node, 'symbol') && node.scope === 'local' && String(node.name) in PRONOUNS
|
|
308
|
+
? { ...node, name: PRONOUNS[String(node.name)] }
|
|
309
|
+
: node,
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
name: 'of-possessive',
|
|
313
|
+
description: "`the X of Y` is `Y's X`, for a property, a style and an attribute",
|
|
314
|
+
pins: [
|
|
315
|
+
['on click put the id of me into #o', "on click put me's id into #o"],
|
|
316
|
+
['on click set the *color of #t to "red"', 'on click set #t\'s *color to "red"'],
|
|
317
|
+
['on click put the @id of me into #o', "on click put me's @id into #o"],
|
|
318
|
+
],
|
|
319
|
+
rewrite: node => {
|
|
320
|
+
if (!isNode(node, 'ofExpression')) return node;
|
|
321
|
+
const name = String(node.name);
|
|
322
|
+
if (node.kind === 'property') return { type: 'possessive', root: node.root, prop: name };
|
|
323
|
+
if (node.kind === 'style') {
|
|
324
|
+
return { type: 'possessive', root: node.root, attribute: { type: 'styleRef', name } };
|
|
325
|
+
}
|
|
326
|
+
if (node.kind === 'attribute') {
|
|
327
|
+
return {
|
|
328
|
+
type: 'possessive',
|
|
329
|
+
root: node.root,
|
|
330
|
+
attribute: { type: 'attributeRef', name, css: `[${name}]` },
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
return node;
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
name: 'dotted-possessive',
|
|
338
|
+
description: "`my x` is `my.x`, and `its x` is `it.x`: a pronoun's property, either spelling",
|
|
339
|
+
pins: [['on click put my id into #o', 'on click put my.id into #o']],
|
|
340
|
+
rewrite: node =>
|
|
341
|
+
isNode(node, 'possessive') && isPronoun(node.root) && typeof node.prop === 'string'
|
|
342
|
+
? { type: 'propertyAccess', root: node.root, prop: node.prop }
|
|
343
|
+
: node,
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
name: 'implicit-me',
|
|
347
|
+
description: 'a target left out is `me` (`hide` is `hide me`)',
|
|
348
|
+
pins: [
|
|
349
|
+
[
|
|
350
|
+
'on click add .a then put my className into #o',
|
|
351
|
+
'on click add .a to me then put my className into #o',
|
|
352
|
+
],
|
|
353
|
+
],
|
|
354
|
+
rewrite: node =>
|
|
355
|
+
isNode(node, 'implicitMeTarget') ? { type: 'symbol', name: 'me', scope: 'local' } : node,
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
name: 'go-to-string',
|
|
359
|
+
description: '`go to "<address>"` is `go url "<address>"`',
|
|
360
|
+
pins: [['on click go to "#here"', 'on click go url "#here"']],
|
|
361
|
+
rewrite: node =>
|
|
362
|
+
isNode(node, 'goCommand') && isNode(node.target, 'string')
|
|
363
|
+
? { ...without(node, 'target'), url: node.target.value }
|
|
364
|
+
: node,
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
name: 'call-pseudo-command',
|
|
368
|
+
description: 'a call written as a command (`foo()`) is `call foo()`',
|
|
369
|
+
pins: [
|
|
370
|
+
['on click getText() then put it into #o', 'on click call getText() then put it into #o'],
|
|
371
|
+
],
|
|
372
|
+
rewrite: node => (isNode(node, 'pseudoCommand') ? { ...node, type: 'getCommand' } : node),
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
name: 'pseudo-command-target',
|
|
376
|
+
description:
|
|
377
|
+
'a call written as a command on a target (`f(x) from the document`) is `call document.f(x)`',
|
|
378
|
+
pins: [
|
|
379
|
+
[
|
|
380
|
+
'on click setAttribute("title", "T") on #o then put #o.title into #o',
|
|
381
|
+
'on click call #o.setAttribute("title", "T") then put #o.title into #o',
|
|
382
|
+
],
|
|
383
|
+
],
|
|
384
|
+
rewrite: node => {
|
|
385
|
+
if (!isNode(node, 'getCommand') || !('target' in node)) return node;
|
|
386
|
+
const call = node.value;
|
|
387
|
+
if (!isNode(call, 'functionCall') || !isNode(call.root, 'symbol')) return node;
|
|
388
|
+
const root = { type: 'propertyAccess', root: node.target, prop: call.root.name };
|
|
389
|
+
return { ...without(node, 'target'), value: { ...call, root } };
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
name: 'empty-else',
|
|
394
|
+
description: 'an `else` with nothing in it is no `else`',
|
|
395
|
+
pins: [['on click if true put "a" into #o else end', 'on click if true put "a" into #o end']],
|
|
396
|
+
rewrite: node =>
|
|
397
|
+
isNode(node, 'ifCommand') && Array.isArray(node.falseBranch) && !node.falseBranch.length
|
|
398
|
+
? without(node, 'falseBranch')
|
|
399
|
+
: node,
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
name: 'milliseconds',
|
|
403
|
+
description: 'a time in milliseconds (`10 ms`, `10ms`) is the number (`10`)',
|
|
404
|
+
pins: [['on click put 10 ms into #o', 'on click put 10 into #o']],
|
|
405
|
+
rewrite: node =>
|
|
406
|
+
isNode(node, 'timeExpression') && node.suffix === 1 ? (node.root as PlainNode) : node,
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
name: 'a-tick',
|
|
410
|
+
description: '`wait a tick` is `wait 0ms`: both engines wait one setTimeout of 0',
|
|
411
|
+
pins: [['on click wait a tick then put "x" into #o', 'on click wait 0ms then put "x" into #o']],
|
|
412
|
+
rewrite: node =>
|
|
413
|
+
isNode(node, 'waitCommand') && !('time' in node) && !('events' in node)
|
|
414
|
+
? { ...node, time: { type: 'number', value: 0 } }
|
|
415
|
+
: node,
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
name: 'parenthesized-name',
|
|
419
|
+
description:
|
|
420
|
+
'a name in parentheses is the name: `(e).message` is `e.message` (the verified render ' +
|
|
421
|
+
'parenthesizes a variable spelled like a word of the target language)',
|
|
422
|
+
pins: [
|
|
423
|
+
[
|
|
424
|
+
"on click set e to {a: 'Q'} then put (e).a into #o",
|
|
425
|
+
"on click set e to {a: 'Q'} then put e.a into #o",
|
|
426
|
+
],
|
|
427
|
+
],
|
|
428
|
+
rewrite: node =>
|
|
429
|
+
isNode(node, 'parenthesized') && isNode(node.expr, 'symbol') ? node.expr : node,
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
name: 'by-one',
|
|
433
|
+
description: '`increment x by 1` is `increment x`, and so for `decrement`',
|
|
434
|
+
pins: [
|
|
435
|
+
[
|
|
436
|
+
'on click set x to 5 then increment x by 1 then put x into #o',
|
|
437
|
+
'on click set x to 5 then increment x then put x into #o',
|
|
438
|
+
],
|
|
439
|
+
[
|
|
440
|
+
'on click set x to 5 then decrement x by 1 then put x into #o',
|
|
441
|
+
'on click set x to 5 then decrement x then put x into #o',
|
|
442
|
+
],
|
|
443
|
+
],
|
|
444
|
+
rewrite: node =>
|
|
445
|
+
(isNode(node, 'incrementCommand') || isNode(node, 'decrementCommand')) &&
|
|
446
|
+
isNode(node.amount, 'number') &&
|
|
447
|
+
node.amount.value === 1
|
|
448
|
+
? without(node, 'amount')
|
|
449
|
+
: node,
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
name: 'named-arguments-object',
|
|
453
|
+
description: 'naked named arguments (`with method:"POST"`) are the braced object literal',
|
|
454
|
+
pins: [['on click fetch /x with method:"POST"', 'on click fetch /x with {method:"POST"}']],
|
|
455
|
+
rewrite: node =>
|
|
456
|
+
isNode(node, 'namedArgumentList')
|
|
457
|
+
? { type: 'objectLiteral', keys: node.names, values: node.values }
|
|
458
|
+
: node,
|
|
459
|
+
},
|
|
460
|
+
];
|
|
461
|
+
|
|
462
|
+
/** A plain parse with every equivalence (or the given ones) applied, bottom-up. */
|
|
463
|
+
export function normalize(
|
|
464
|
+
plain: unknown,
|
|
465
|
+
equivalences: readonly Equivalence[] = EQUIVALENCES
|
|
466
|
+
): unknown {
|
|
467
|
+
const walk = (value: unknown): unknown => {
|
|
468
|
+
if (!value || typeof value !== 'object') return value;
|
|
469
|
+
if (Array.isArray(value)) return value.map(walk);
|
|
470
|
+
let node: PlainNode = {};
|
|
471
|
+
for (const [key, v] of Object.entries(value)) node[key] = walk(v);
|
|
472
|
+
for (const equivalence of equivalences) node = equivalence.rewrite(node);
|
|
473
|
+
return node;
|
|
474
|
+
};
|
|
475
|
+
return walk(plain);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
// Running
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
type Translate = (input: string, from: string, to: string) => string;
|
|
483
|
+
|
|
484
|
+
/** The first line of what a refused call threw. */
|
|
485
|
+
const firstLine = (e: unknown): string => ((e as Error)?.message ?? String(e)).split('\n')[0] ?? '';
|
|
486
|
+
|
|
487
|
+
export interface CommandShapeRunner {
|
|
488
|
+
/** Every lane of one case, or only `lanes`. */
|
|
489
|
+
runCase(shape: CommandShapeCase, lanes?: readonly string[]): CaseResult;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Load the engine (ESM-only, so imported here rather than at the top: tsx
|
|
494
|
+
* runs the tools as CommonJS) and semantic, and return a case runner.
|
|
495
|
+
*/
|
|
496
|
+
export async function initCommandShapes(): Promise<CommandShapeRunner> {
|
|
497
|
+
const readers = await loadEngineReaders();
|
|
498
|
+
const { translate } = (await import('@lokascript/semantic')) as { translate: Translate };
|
|
499
|
+
|
|
500
|
+
const lane = (source: string, language: string): string =>
|
|
501
|
+
language === 'en'
|
|
502
|
+
? translate(source, 'en', 'en')
|
|
503
|
+
: translate(translate(source, 'en', language), language, 'en');
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
runCase(shape, lanes = LANES) {
|
|
507
|
+
const engine = readers[readerOf(shape)];
|
|
508
|
+
const plain = plainParse(engine.parse(shape.source));
|
|
509
|
+
const wantPlain = normalize(plain);
|
|
510
|
+
const want = JSON.stringify(wantPlain);
|
|
511
|
+
const loud = loudFamilyOf(shape.source, plain);
|
|
512
|
+
const result: CaseResult = {
|
|
513
|
+
id: shape.id,
|
|
514
|
+
...(loud ? { loud: loud.name } : {}),
|
|
515
|
+
statements: [...statementCounts(plain).keys()].sort(),
|
|
516
|
+
lanes: {},
|
|
517
|
+
};
|
|
518
|
+
for (const language of lanes) {
|
|
519
|
+
let english: string;
|
|
520
|
+
try {
|
|
521
|
+
english = lane(shape.source, language);
|
|
522
|
+
} catch (e) {
|
|
523
|
+
result.lanes[language] = { outcome: 'refused', detail: firstLine(e) };
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
result.lanes[language] = judge(engine, want, wantPlain, english);
|
|
527
|
+
}
|
|
528
|
+
return result;
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Run the given cases, in order. */
|
|
534
|
+
export async function runCommandShapes(
|
|
535
|
+
cases: readonly CommandShapeCase[],
|
|
536
|
+
lanes: readonly string[] = LANES
|
|
537
|
+
): Promise<CaseResult[]> {
|
|
538
|
+
const runner = await initCommandShapes();
|
|
539
|
+
return cases.map(shape => runner.runCase(shape, lanes));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
// Scope: what semantic is not asked to carry yet
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* A family of shapes semantic does not carry yet (the M1 plan's LOUD verdict).
|
|
548
|
+
* A translation of one must be REFUSED, never silently lossy: its baseline
|
|
549
|
+
* entries carry the reason, and the gate fails when one starts to pass (it
|
|
550
|
+
* belongs with the rest then). Every other case must round-trip, or be fixed.
|
|
551
|
+
*/
|
|
552
|
+
export interface LoudFamily {
|
|
553
|
+
name: string;
|
|
554
|
+
reason: string;
|
|
555
|
+
/** Does the case belong to the family? `types` are the node types of its engine parse. */
|
|
556
|
+
matches(source: string, types: ReadonlySet<string>, plain: unknown): boolean;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Some node in a plain parse satisfies `test`. */
|
|
560
|
+
function someNode(plain: unknown, test: (node: PlainNode) => boolean): boolean {
|
|
561
|
+
if (!plain || typeof plain !== 'object') return false;
|
|
562
|
+
if (Array.isArray(plain)) return plain.some(v => someNode(v, test));
|
|
563
|
+
return test(plain as PlainNode) || Object.values(plain).some(v => someNode(v, test));
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export const LOUD: readonly LoudFamily[] = [
|
|
567
|
+
{
|
|
568
|
+
name: 'ask-answer',
|
|
569
|
+
reason:
|
|
570
|
+
'semantic has no schema and no dictionary word for `ask` / `answer`; adding them is an ' +
|
|
571
|
+
'owner vocabulary decision (the M2 sheet)',
|
|
572
|
+
matches: (_source, types) => types.has('askCommand') || types.has('answerCommand'),
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
name: 'templated-selector',
|
|
576
|
+
reason:
|
|
577
|
+
'a templated selector (`#{…}`, `.{"…"}`, `<${…}/>`) is rare, and its fix is in the tokenizer',
|
|
578
|
+
matches: source => /[#.]\{|<[^<>]*\$\{/.test(source),
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
name: 'caret-on-target',
|
|
582
|
+
reason: '`^name on <element>` is new in upstream 0.9.93 and rare; plain `^name` is carried',
|
|
583
|
+
matches: (_source, _types, plain) =>
|
|
584
|
+
someNode(plain, node => node.type === 'symbol' && 'on' in node),
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
name: 'dangling-possessive',
|
|
588
|
+
reason:
|
|
589
|
+
"a possessive with nothing after it (`halt the event's then …`), which the engine " +
|
|
590
|
+
'reads as the bare reference; one upstream test source',
|
|
591
|
+
matches: source => /'s(?=\s+(then|end)\b|\s*$)/.test(source),
|
|
592
|
+
}, // M1 step 3 (2026-10-07): the English refusals left after the value and
|
|
593
|
+
// command-form fixes, each family with why it is not carried.
|
|
594
|
+
{
|
|
595
|
+
name: 'scroll-by',
|
|
596
|
+
reason:
|
|
597
|
+
"scroll's relative form (`scroll #box down by 200px`) names a direction, which no " +
|
|
598
|
+
"language's dictionary has: adding them is an owner vocabulary decision (the M2 sheet)",
|
|
599
|
+
matches: (_source, _types, plain) =>
|
|
600
|
+
someNode(plain, node => node.type === 'scrollCommand' && 'by' in node),
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
name: 'beep',
|
|
604
|
+
reason:
|
|
605
|
+
"`beep!`, upstream's debugging command, has no schema: its form in each language is an " +
|
|
606
|
+
'owner vocabulary decision (the M2 sheet)',
|
|
607
|
+
matches: (_source, types) => types.has('beepCommand'),
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
name: 'halt-modes',
|
|
611
|
+
reason:
|
|
612
|
+
"halt's `default` (also a command word) and `the event's bubbling|default`: the slot " +
|
|
613
|
+
'that keeps `call` out of `halt call f()` cannot tell `default` from a command without ' +
|
|
614
|
+
'a halt-only reading (OPEN_ITEMS P56)',
|
|
615
|
+
matches: source => /\bhalt\s+(default\b|the event's\s+(bubbling|default)\b)/.test(source),
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
name: 'collection-expressions',
|
|
619
|
+
reason:
|
|
620
|
+
"upstream 0.9.93's collection expressions (`… where it matches .a`, `mapped to`) are an " +
|
|
621
|
+
"expression form semantic's reader does not have: a parser-track item (OPEN_ITEMS P57)",
|
|
622
|
+
// Not `if no <q/> in … where …`, which English reads (its foreign lanes are
|
|
623
|
+
// the value-words family).
|
|
624
|
+
matches: (source, types) => types.has('collectionExpression') && !/\bif\s+no\s+</.test(source),
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
name: 'pseudo-class-query',
|
|
628
|
+
reason:
|
|
629
|
+
'a query of a pseudo-class (`<:checked/>`): the tokenizers read `:checked` as a local ' +
|
|
630
|
+
'variable; a tokenizer item, rare in pages',
|
|
631
|
+
matches: source => /<:[\w-]+\/>/.test(source),
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
name: 'closest-forms',
|
|
635
|
+
reason:
|
|
636
|
+
"`closest @attr` (an ancestor's attribute) and `closest parent <q/>`: the forms of " +
|
|
637
|
+
'`closest` besides a query, rare in pages',
|
|
638
|
+
matches: (_source, _types, plain) =>
|
|
639
|
+
someNode(
|
|
640
|
+
plain,
|
|
641
|
+
node =>
|
|
642
|
+
node.type === 'closestExpr' &&
|
|
643
|
+
// Not `closest @foo to #d1`, which English reads (value-words).
|
|
644
|
+
(node.parentSearch === true ||
|
|
645
|
+
(String(node.css ?? '').startsWith('[') &&
|
|
646
|
+
(node.to as { type?: unknown } | undefined)?.type === 'implicitMeTarget'))
|
|
647
|
+
),
|
|
648
|
+
},
|
|
649
|
+
{
|
|
650
|
+
name: 'possessive-after-query',
|
|
651
|
+
reason:
|
|
652
|
+
"a possessive after a positional query (`the next <div/>'s textContent`), rare in pages; " +
|
|
653
|
+
'the `of` form (`the textContent of the next <div/>`) is carried',
|
|
654
|
+
matches: (source, types) => types.has('relativePositionalExpression') && /\/>'s\b/.test(source),
|
|
655
|
+
},
|
|
656
|
+
{
|
|
657
|
+
name: 'colon-event-arguments',
|
|
658
|
+
reason: 'a colon-qualified event name with arguments (`send foo:bar(x:42)`), rare in pages',
|
|
659
|
+
matches: (source, types) => types.has('namedArgumentList') && /\w:\w+\(/.test(source),
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
name: 'call-chain-command',
|
|
663
|
+
reason: 'a call chain as a command (`bar().foo()`), rare in pages',
|
|
664
|
+
matches: (source, types) => types.has('pseudoCommand') && /\)\.\w+\(/.test(source),
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
name: 'rare-heads',
|
|
668
|
+
reason:
|
|
669
|
+
'handler heads rare in pages: a quoted event name (`on "a-b"`), a filter after ' +
|
|
670
|
+
'parameters (`on foo(bar)[bar]`), `and on`, a source on each `or` leg, a ' +
|
|
671
|
+
'parenthesized feature (`(on click …)`)',
|
|
672
|
+
matches: source =>
|
|
673
|
+
/^\s*on\s+"|^\s*on\s+[\w:.-]+\([^)]*\)\[|\bon\s+\w+\s+\d+\s+and\s+on\b|\bfrom\s+\S+\s+or\s+\w+\s+from\b|^\s*\(on\s/.test(
|
|
674
|
+
source
|
|
675
|
+
),
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
name: 'rare-values',
|
|
679
|
+
reason:
|
|
680
|
+
'value spellings rare in pages: an escaped class name (`.group-\\[…\\]`), a comma ' +
|
|
681
|
+
'list in a query (`<#d1, #d2/>`), an attribute after a bare or bracketed reference ' +
|
|
682
|
+
'(`it @a`, `my [@a]`), a dotted path before `of`, a unit after parentheses ' +
|
|
683
|
+
'(`(w)px`), `set {…} on <obj>`',
|
|
684
|
+
matches: source =>
|
|
685
|
+
/\\\[|<[^<>]*,[^<>]*\/>|\b(my|its|your)\s+\[@|\bset\s+(it|me|you)\s+@|\b[a-z]\w*\.[a-z]\w*\s+of\b|\)(px|em|rem|%)|\bset\s+\{[^}]*\}\s+on\b/i.test(
|
|
686
|
+
source
|
|
687
|
+
),
|
|
688
|
+
},
|
|
689
|
+
{
|
|
690
|
+
name: 'rare-commands',
|
|
691
|
+
reason:
|
|
692
|
+
'commands rare in pages: `measure` of a property list, `pick characters 0 to 3 from …`, ' +
|
|
693
|
+
'an `if` whose body is empty (`if false end`), a behavior holding a bare command, two ' +
|
|
694
|
+
'behaviors with no `end` between',
|
|
695
|
+
matches: (source, types, plain) =>
|
|
696
|
+
(types.has('measureCommand') && /\bmeasure\s+\w+\s*,/.test(source)) ||
|
|
697
|
+
someNode(plain, node => node.type === 'pickCommand' && node.variant === 'range') ||
|
|
698
|
+
/\bif\s+\S+\s+end\b/.test(source) ||
|
|
699
|
+
(types.has('behaviorFeature') && types.has('setFeature')) ||
|
|
700
|
+
(source.match(/\bbehavior\b/g)?.length ?? 0) > 1,
|
|
701
|
+
},
|
|
702
|
+
];
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* A family of foreign lanes a translation still loses SILENTLY, with why (the
|
|
706
|
+
* M1 plan's Phase 2 exit: the English lane has no silent pair, and every
|
|
707
|
+
* foreign silent entry carries a reason). Each is queued for the foreign-lane
|
|
708
|
+
* group (M1 phase 3, group 6). Listed by case id: a case that stops being
|
|
709
|
+
* silent must leave its family (command-shapes.cases.test.ts holds both ways).
|
|
710
|
+
*/
|
|
711
|
+
export interface SilentFamily {
|
|
712
|
+
name: string;
|
|
713
|
+
reason: string;
|
|
714
|
+
ids: readonly string[];
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export const SILENT_FAMILIES: readonly SilentFamily[] = [
|
|
718
|
+
{
|
|
719
|
+
name: 'value-words',
|
|
720
|
+
reason:
|
|
721
|
+
"a language's word inside a value reads back as another English word: `is not` → " +
|
|
722
|
+
'`it not` (ar/hi/ms/pl/th), pl `to` → `it`, qu `where` → `target`, ms/pl `undefined`, ' +
|
|
723
|
+
'ms `it` → `its`',
|
|
724
|
+
ids: [
|
|
725
|
+
'a30e7d6174',
|
|
726
|
+
'69537d01ae',
|
|
727
|
+
'd74a86f3a0',
|
|
728
|
+
'da24c9d768',
|
|
729
|
+
'b948ed872b',
|
|
730
|
+
'a4c56acf1e',
|
|
731
|
+
'87250fbc5e',
|
|
732
|
+
'54a65f4602',
|
|
733
|
+
],
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
name: 'it-locative',
|
|
737
|
+
reason: 'it `in me` (a query scope) reads back as `on me`',
|
|
738
|
+
ids: ['2159462e64', '0ff2f70080', 'd7b35fefe6'],
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
name: 'possessive-split',
|
|
742
|
+
reason:
|
|
743
|
+
"a possessive reads back split or reworded: qu `$data 's inner' s val`, id `my` → " +
|
|
744
|
+
"`me punya`, qu `#box's *opacity` → `#box … on *opacity`",
|
|
745
|
+
ids: ['76130fbf7d', '36e2294330', 'b839394853', '7bc08adb98'],
|
|
746
|
+
},
|
|
747
|
+
];
|
|
748
|
+
|
|
749
|
+
/** The silent family a case belongs to, if any. */
|
|
750
|
+
export function silentFamilyOf(id: string): SilentFamily | undefined {
|
|
751
|
+
return SILENT_FAMILIES.find(family => family.ids.includes(id));
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** The LOUD family a case belongs to, if any. */
|
|
755
|
+
export function loudFamilyOf(source: string, plain: unknown): LoudFamily | undefined {
|
|
756
|
+
const types = nodeTypes(plain);
|
|
757
|
+
return LOUD.find(family => family.matches(source, types, plain));
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// ---------------------------------------------------------------------------
|
|
761
|
+
// The baseline
|
|
762
|
+
// ---------------------------------------------------------------------------
|
|
763
|
+
|
|
764
|
+
export interface BaselineEntry {
|
|
765
|
+
/** The source, whitespace collapsed and cut at 120 characters: for reading, not asserted. */
|
|
766
|
+
src: string;
|
|
767
|
+
/** The case's family (see CommandShapeCase): for reading the burn-down, not asserted. */
|
|
768
|
+
family: string;
|
|
769
|
+
/** Lanes whose translation is refused, in LANES order; `*` stands for all 23 languages. */
|
|
770
|
+
refused?: string;
|
|
771
|
+
/** Lanes whose translation is silently lossy, in the same shorthand. */
|
|
772
|
+
silent?: string;
|
|
773
|
+
/** For a case in a LOUD family: why it is not carried yet. */
|
|
774
|
+
loud?: string;
|
|
775
|
+
/** For a case with silent lanes: its silent family and why (SILENT_FAMILIES). */
|
|
776
|
+
why?: string;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
export interface CommandShapesBaseline {
|
|
780
|
+
description: string;
|
|
781
|
+
/** The vendored upstream version the cases came from. */
|
|
782
|
+
upstream: string;
|
|
783
|
+
cases: number;
|
|
784
|
+
pairs: number;
|
|
785
|
+
pass: number;
|
|
786
|
+
refused: number;
|
|
787
|
+
silent: number;
|
|
788
|
+
entries: Record<string, BaselineEntry>;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const FOREIGN_LANES: readonly string[] = LANES.filter(lane => lane !== 'en');
|
|
792
|
+
|
|
793
|
+
/** Lanes (in LANES order) as a baseline string, `*` for all 23 languages. */
|
|
794
|
+
export function compressLanes(lanes: readonly string[]): string {
|
|
795
|
+
const set = new Set(lanes);
|
|
796
|
+
const all = FOREIGN_LANES.every(lane => set.has(lane));
|
|
797
|
+
const out = LANES.filter(lane => set.has(lane) && !(all && lane !== 'en'));
|
|
798
|
+
if (all) out.push('*');
|
|
799
|
+
return out.join(' ');
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** The lanes a baseline string names. */
|
|
803
|
+
export function expandLanes(text: string | undefined): string[] {
|
|
804
|
+
const out: string[] = [];
|
|
805
|
+
for (const token of (text ?? '').split(' ').filter(Boolean)) {
|
|
806
|
+
if (token === '*') out.push(...FOREIGN_LANES);
|
|
807
|
+
else out.push(token);
|
|
808
|
+
}
|
|
809
|
+
return out;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const lanesWith = (result: CaseResult, outcome: Outcome): string[] =>
|
|
813
|
+
LANES.filter(lane => result.lanes[lane]?.outcome === outcome);
|
|
814
|
+
|
|
815
|
+
/** The baseline a run implies. */
|
|
816
|
+
export function baselineFrom(
|
|
817
|
+
results: readonly CaseResult[],
|
|
818
|
+
cases: readonly CommandShapeCase[],
|
|
819
|
+
description: string,
|
|
820
|
+
upstream: string
|
|
821
|
+
): CommandShapesBaseline {
|
|
822
|
+
const byId = new Map(cases.map(c => [c.id, c]));
|
|
823
|
+
const reasons = new Map(LOUD.map(family => [family.name, family.reason]));
|
|
824
|
+
const doc: CommandShapesBaseline = {
|
|
825
|
+
description,
|
|
826
|
+
upstream,
|
|
827
|
+
cases: results.length,
|
|
828
|
+
pairs: 0,
|
|
829
|
+
pass: 0,
|
|
830
|
+
refused: 0,
|
|
831
|
+
silent: 0,
|
|
832
|
+
entries: {},
|
|
833
|
+
};
|
|
834
|
+
for (const r of results) {
|
|
835
|
+
const refused = lanesWith(r, 'refused');
|
|
836
|
+
const silent = lanesWith(r, 'silent');
|
|
837
|
+
doc.pairs += Object.keys(r.lanes).length;
|
|
838
|
+
doc.refused += refused.length;
|
|
839
|
+
doc.silent += silent.length;
|
|
840
|
+
doc.pass += lanesWith(r, 'pass').length;
|
|
841
|
+
if (!refused.length && !silent.length) continue;
|
|
842
|
+
const shape = byId.get(r.id);
|
|
843
|
+
const src = collapse(shape?.source ?? '');
|
|
844
|
+
doc.entries[r.id] = {
|
|
845
|
+
src: src.length > 120 ? `${src.slice(0, 119)}…` : src,
|
|
846
|
+
family: shape?.family ?? '',
|
|
847
|
+
...(refused.length ? { refused: compressLanes(refused) } : {}),
|
|
848
|
+
...(silent.length ? { silent: compressLanes(silent) } : {}),
|
|
849
|
+
...(r.loud ? { loud: `${r.loud}: ${reasons.get(r.loud) ?? ''}` } : {}),
|
|
850
|
+
...(silent.length && silentFamilyOf(r.id)
|
|
851
|
+
? { why: `${silentFamilyOf(r.id)!.name}: ${silentFamilyOf(r.id)!.reason}` }
|
|
852
|
+
: {}),
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
return doc;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** The outcome the baseline lists for a pair: `pass` unless it is listed. */
|
|
859
|
+
export function listedOutcome(entry: BaselineEntry | undefined, lane: string): Outcome {
|
|
860
|
+
if (expandLanes(entry?.silent).includes(lane)) return 'silent';
|
|
861
|
+
if (expandLanes(entry?.refused).includes(lane)) return 'refused';
|
|
862
|
+
return 'pass';
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
export interface PairChange {
|
|
866
|
+
id: string;
|
|
867
|
+
lane: string;
|
|
868
|
+
listed: Outcome;
|
|
869
|
+
got: Outcome;
|
|
870
|
+
detail?: string;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/** How bad an outcome is: a change toward a worse one is a regression. */
|
|
874
|
+
const RANK: Readonly<Record<Outcome, number>> = { pass: 0, refused: 1, silent: 2 };
|
|
875
|
+
|
|
876
|
+
export interface BaselineDiff {
|
|
877
|
+
/** Pairs worse than listed: pass → refused or silent, refused → silent. */
|
|
878
|
+
worse: PairChange[];
|
|
879
|
+
/** Pairs better than listed: prune them, so the lists only shrink. */
|
|
880
|
+
better: PairChange[];
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Compare results with the baseline, over the cases that ran (a shard
|
|
885
|
+
* compares only its own; an entry for a case outside `results` is not judged).
|
|
886
|
+
*/
|
|
887
|
+
export function diffBaseline(
|
|
888
|
+
results: readonly CaseResult[],
|
|
889
|
+
baseline: Pick<CommandShapesBaseline, 'entries'>
|
|
890
|
+
): BaselineDiff {
|
|
891
|
+
const diff: BaselineDiff = { worse: [], better: [] };
|
|
892
|
+
for (const r of results) {
|
|
893
|
+
const entry = baseline.entries[r.id];
|
|
894
|
+
for (const lane of LANES) {
|
|
895
|
+
const got = r.lanes[lane];
|
|
896
|
+
if (!got) continue;
|
|
897
|
+
const listed = listedOutcome(entry, lane);
|
|
898
|
+
if (listed === got.outcome) continue;
|
|
899
|
+
const change: PairChange = {
|
|
900
|
+
id: r.id,
|
|
901
|
+
lane,
|
|
902
|
+
listed,
|
|
903
|
+
got: got.outcome,
|
|
904
|
+
...(got.detail !== undefined ? { detail: got.detail } : {}),
|
|
905
|
+
};
|
|
906
|
+
(RANK[got.outcome] > RANK[listed] ? diff.worse : diff.better).push(change);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return diff;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** Entries for cases the cases file no longer holds. */
|
|
913
|
+
export function orphanedEntries(
|
|
914
|
+
baseline: Pick<CommandShapesBaseline, 'entries'>,
|
|
915
|
+
cases: readonly CommandShapeCase[]
|
|
916
|
+
): string[] {
|
|
917
|
+
const ids = new Set(cases.map(c => c.id));
|
|
918
|
+
return Object.keys(baseline.entries).filter(id => !ids.has(id));
|
|
919
|
+
}
|