@lokascript/framework 4.7.0 → 4.8.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 (36) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/dist/api/create-dsl.d.ts +5 -2
  3. package/dist/api/create-dsl.d.ts.map +1 -1
  4. package/dist/api/index.js +45 -5
  5. package/dist/api/index.js.map +1 -1
  6. package/dist/core/index.js +10 -0
  7. package/dist/core/index.js.map +1 -1
  8. package/dist/core/matching/stock-reader.d.ts +8 -1
  9. package/dist/core/matching/stock-reader.d.ts.map +1 -1
  10. package/dist/core/pattern-matching/index.js.map +1 -1
  11. package/dist/core/types.d.ts +5 -0
  12. package/dist/core/types.d.ts.map +1 -1
  13. package/dist/core/types.js.map +1 -1
  14. package/dist/generation/index.js +12 -2
  15. package/dist/generation/index.js.map +1 -1
  16. package/dist/index.cjs +45 -5
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.js +45 -5
  19. package/dist/index.js.map +1 -1
  20. package/dist/ir/index.js.map +1 -1
  21. package/dist/parsing/index.js.map +1 -1
  22. package/dist/testing/gate/index.js +44 -12
  23. package/dist/testing/gate/index.js.map +1 -1
  24. package/dist/testing/gate/round-trip.d.ts +24 -3
  25. package/dist/testing/gate/round-trip.d.ts.map +1 -1
  26. package/package.json +2 -2
  27. package/src/api/create-dsl-engine.test.ts +65 -1
  28. package/src/api/create-dsl.test.ts +7 -0
  29. package/src/api/create-dsl.ts +32 -4
  30. package/src/core/matching/stock-reader.test.ts +87 -3
  31. package/src/core/matching/stock-reader.ts +20 -1
  32. package/src/core/types.ts +5 -0
  33. package/src/generation/schema-patterns.test.ts +41 -1
  34. package/src/generation/schema-patterns.ts +12 -0
  35. package/src/testing/gate/round-trip.test.ts +99 -9
  36. package/src/testing/gate/round-trip.ts +76 -11
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type {
6
6
  SemanticNode,
7
+ LanguageToken,
7
8
  LanguageTokenizer,
8
9
  LanguagePattern,
9
10
  PatternMatchResult,
@@ -229,7 +230,9 @@ export interface CompileResult {
229
230
  /**
230
231
  * Thrown by `parse` (and so `compile`, `validate`, `translate`) when the input reads as a command
231
232
  * only by leaving some of it unread: `select name from users banana` is not `select name from users`.
232
- * `reading` is what the longest such match read, `unread` the text after it.
233
+ * `reading` is what the longest such match read, `unread` the text after it. `hint`, when there is
234
+ * one, says how the input might read: where a bare word read as a value is followed by another, the
235
+ * two may be one value written without quotes (todo `add buy milk to groceries`).
233
236
  */
234
237
  export class UnreadInputError extends Error {
235
238
  readonly code = 'unread-input';
@@ -238,9 +241,13 @@ export class UnreadInputError extends Error {
238
241
  readonly input: string,
239
242
  readonly language: string,
240
243
  readonly reading: SemanticNode,
241
- readonly unread: string
244
+ readonly unread: string,
245
+ readonly hint?: string
242
246
  ) {
243
- super(`"${input}" (${language}) reads as ${reading.action} only by leaving "${unread}" unread`);
247
+ super(
248
+ `"${input}" (${language}) reads as ${reading.action} only by leaving "${unread}" unread` +
249
+ (hint ? `; ${hint}` : '')
250
+ );
244
251
  this.name = 'UnreadInputError';
245
252
  }
246
253
  }
@@ -491,16 +498,37 @@ class MultilingualDSLImpl implements MultilingualDSL {
491
498
  .slice(longestPartial.consumedTokens)
492
499
  .map(t => t.value)
493
500
  .join(' ');
501
+ const hint = this.quoteHint(longestPartial, tokens.tokens);
494
502
  throw new UnreadInputError(
495
503
  input,
496
504
  language,
497
505
  this.nodeOf(longestPartial, input, language),
498
- unread
506
+ unread,
507
+ ...(hint ? [hint] : [])
499
508
  );
500
509
  }
501
510
  throw new Error(`No pattern matched for input: ${input}`);
502
511
  }
503
512
 
513
+ /**
514
+ * `if "buy milk" is one value, quote it`, where the last token a match read is a bare word it took
515
+ * as a role's value (a role with no word list) and the first it left is a bare word too.
516
+ */
517
+ private quoteHint(
518
+ match: PatternMatchResult,
519
+ tokens: readonly LanguageToken[]
520
+ ): string | undefined {
521
+ const last = tokens[match.consumedTokens - 1];
522
+ const next = tokens[match.consumedTokens];
523
+ if (last?.kind !== 'identifier' || next?.kind !== 'identifier') return undefined;
524
+ const schema = this.schemaLookup.getSchema(match.pattern.command);
525
+ const role = [...match.captured].find(
526
+ ([, v]) => (v as { raw?: unknown }).raw === last.value
527
+ )?.[0];
528
+ if (!role || schema?.roles.find(r => r.role === role)?.words) return undefined;
529
+ return `if "${last.value} ${next.value}" is one value, quote it`;
530
+ }
531
+
504
532
  private nodeOf(match: PatternMatchResult, input: string, language: string): SemanticNode {
505
533
  return {
506
534
  kind: 'command',
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The stock value reader on the matcher's skeleton (domains rethink Phase 4.7):
3
- * each kind of value it reads, a greedy slot's stop markers, the guard against a
4
- * marker read as a value, and the skip of an optional slot.
3
+ * each kind of value it reads, a greedy slot's stop markers, a slot's word list,
4
+ * the guard against a marker read as a value, and the skip of an optional slot.
5
5
  */
6
6
  import { describe, expect, it } from 'vitest';
7
7
  import { MatchSkeleton } from './match-skeleton';
@@ -29,7 +29,7 @@ const lit = (value: string, ...alternatives: string[]): PatternToken =>
29
29
  alternatives.length ? { type: 'literal', value, alternatives } : { type: 'literal', value };
30
30
  const slot = (
31
31
  role: string,
32
- extra: { optional?: boolean; greedy?: boolean; expectedTypes?: string[] } = {}
32
+ extra: { optional?: boolean; greedy?: boolean; expectedTypes?: string[]; words?: string[] } = {}
33
33
  ): PatternToken => ({ type: 'role', role, ...extra }) as PatternToken;
34
34
  const group = (...tokens: PatternToken[]): PatternToken => ({
35
35
  type: 'group',
@@ -129,6 +129,53 @@ describe('a greedy slot', () => {
129
129
  });
130
130
  });
131
131
 
132
+ describe('a slot with a word list', () => {
133
+ const click = pattern([lit('when'), slot('action', { words: ['clicks', 'クリック'] })]);
134
+
135
+ it('reads one of its words, in any case, by value or normalized form', () => {
136
+ expect(read(click, ['when', 'keyword'], ['Clicks', 'identifier'])).toEqual({
137
+ action: { type: 'expression', raw: 'Clicks' },
138
+ });
139
+ expect(read(click, ['when', 'keyword'], ['クリックする', 'identifier', 'クリック'])).toEqual({
140
+ action: { type: 'expression', raw: 'クリックする' },
141
+ });
142
+ });
143
+
144
+ it('leaves any other word unread', () => {
145
+ expect(read(click, ['when', 'keyword'], ['zzqx', 'identifier'])).toBeNull();
146
+ });
147
+
148
+ it('gives the word to the required slot when the optional slot before it took it (behaviorspec `when clicks zzqx`)', () => {
149
+ const when = pattern([
150
+ lit('when'),
151
+ group(slot('actor', { optional: true })),
152
+ slot('action', { words: ['clicks'] }),
153
+ ]);
154
+ const result = matcher().matchPattern(
155
+ stream(['when', 'keyword'], ['clicks', 'identifier'], ['zzqx', 'identifier']),
156
+ when
157
+ );
158
+ expect(result && Object.fromEntries(result.captured)).toEqual({
159
+ action: { type: 'expression', raw: 'clicks' },
160
+ });
161
+ // `zzqx` is left for the caller to refuse as unread.
162
+ expect(result?.consumedTokens).toBe(2);
163
+ });
164
+
165
+ it('an optional one is skipped for a word it does not list (voice `close zzqx`)', () => {
166
+ const close = pattern([lit('close'), slot('patient', { optional: true, words: ['tab'] })]);
167
+ expect(read(close, ['close', 'keyword'], ['tab', 'identifier'])).toEqual({
168
+ patient: { type: 'expression', raw: 'tab' },
169
+ });
170
+ const result = matcher().matchPattern(
171
+ stream(['close', 'keyword'], ['zzqx', 'identifier']),
172
+ close
173
+ );
174
+ expect(result && Object.fromEntries(result.captured)).toEqual({});
175
+ expect(result?.consumedTokens).toBe(1);
176
+ });
177
+ });
178
+
132
179
  describe('the marker guard and the optional skip', () => {
133
180
  it('a required slot does not read the marker the pattern wants next', () => {
134
181
  // SOV `{patient} を 実行` given `を 実行`: the patient is missing, not `を`.
@@ -136,6 +183,43 @@ describe('the marker guard and the optional skip', () => {
136
183
  expect(read(sov, ['を', 'particle'], ['実行', 'keyword'])).toBeNull();
137
184
  });
138
185
 
186
+ it('an optional slot does not read a marker the rest of the pattern owes, through its optional groups', () => {
187
+ // behaviorspec `when user types into #search`: the typed text is optional and has no marker.
188
+ const when = pattern([
189
+ lit('when'),
190
+ slot('action'),
191
+ group(slot('value', { optional: true })),
192
+ group(lit('on'), slot('target')),
193
+ group(lit('into'), slot('destination')),
194
+ ]);
195
+ expect(
196
+ read(
197
+ when,
198
+ ['when', 'keyword'],
199
+ ['types', 'identifier'],
200
+ ['into', 'keyword'],
201
+ ['#q', 'selector']
202
+ )
203
+ ).toEqual({
204
+ action: { type: 'expression', raw: 'types' },
205
+ destination: { type: 'selector', value: '#q' },
206
+ });
207
+ expect(
208
+ read(
209
+ when,
210
+ ['when', 'keyword'],
211
+ ['types', 'identifier'],
212
+ ["'hi'", 'literal'],
213
+ ['into', 'keyword'],
214
+ ['#q', 'selector']
215
+ )
216
+ ).toEqual({
217
+ action: { type: 'expression', raw: 'types' },
218
+ value: { type: 'literal', value: 'hi', dataType: 'string' },
219
+ destination: { type: 'selector', value: '#q' },
220
+ });
221
+ });
222
+
139
223
  it('an optional slot is skipped for the literal the pattern wants next, when the rest then reads to the end', () => {
140
224
  const p = pattern([lit('go'), group(slot('x', { optional: true })), lit('now')]);
141
225
  expect(read(p, ['go', 'keyword'], ['now', 'keyword'])).toEqual({});
@@ -7,13 +7,18 @@
7
7
  * - A **greedy** slot (`RoleSpec.greedy`: a SQL condition, a column list) reads
8
8
  * every token up to the next marker the pattern still owes, or the end, as
9
9
  * one expression (`readGreedy`).
10
+ * - A slot with a **word list** (`RoleSpec.words`: behaviorspec's interaction,
11
+ * voice's thing to close) reads only one of its words, compared lower-cased
12
+ * by value or normalized form; any other word leaves the slot unread.
10
13
  * - Any other slot reads one value (`readRoleValue`, the old matcher's reading):
11
14
  * a selector, a quoted string, a number, a boolean, a duration, a URL, an
12
15
  * identifier, or one of the multi-token forms it knows (`user.name`,
13
16
  * `#dialog.showModal()`), checked against the slot's expected types.
14
17
  * - A **required** slot reads nothing where the stream is at the marker the
15
18
  * pattern wants next (the old matcher's guard against an SOV marker taken as
16
- * a value). Where a required slot finds nothing to read, and the optional,
19
+ * a value). An **optional** one reads nothing at any marker the rest of the
20
+ * pattern owes, through its optional groups (behaviorspec `when user types
21
+ * into #search`, whose typed text is optional and has no marker). Where a required slot finds nothing to read, and the optional,
17
22
  * marker-less slot just before it read a value with nothing read since, that
18
23
  * value is given back and the required slot reads again (the old matcher's
19
24
  * backtrack: behaviorspec `when clicks`, where the optional actor took
@@ -89,6 +94,13 @@ export function readGreedy(
89
94
  }
90
95
 
91
96
  /** The stock reader, for a matcher whose profiles it may consult (possessive words). */
97
+ /** Whether `token` is one of a slot's `words`, by value or normalized form, lower-cased. */
98
+ export function isListedWord(token: LanguageToken | null, words: readonly string[]): boolean {
99
+ if (!token) return false;
100
+ const forms = [token.value, token.normalized].filter((f): f is string => typeof f === 'string');
101
+ return forms.some(form => words.some(word => word.toLowerCase() === form.toLowerCase()));
102
+ }
103
+
92
104
  export class StockValueReader implements ValueReader {
93
105
  private profile: ReadingProfile | undefined;
94
106
  /** The last optional, marker-less slot that read a value: what a failing required slot may take back. */
@@ -147,6 +159,13 @@ export class StockValueReader implements ValueReader {
147
159
  const token = tokens.peek();
148
160
  if (token && this.view.wouldMatch(next, token)) return false;
149
161
  }
162
+ if (slot.words && !isListedWord(tokens.peek(), slot.words)) return slot.optional ?? false;
163
+ // Nor does an optional slot read a marker the rest of the pattern owes, through its optional
164
+ // groups (`when user types into #search`: `into` is the destination's, not the typed text).
165
+ if (slot.optional && !slot.greedy) {
166
+ const token = tokens.peek();
167
+ if (token && isStopMarker(token, greedyStopMarkers(this.view.patternRest()))) return true;
168
+ }
150
169
  if (slot.greedy) {
151
170
  const value = readGreedy(tokens, greedyStopMarkers(this.view.patternRest()));
152
171
  if (value) {
package/src/core/types.ts CHANGED
@@ -174,6 +174,11 @@ export interface RolePatternToken {
174
174
  readonly optional?: boolean;
175
175
  readonly expectedTypes?: Array<ExpectedType>;
176
176
  readonly greedy?: boolean;
177
+ /**
178
+ * The words the slot reads, from the schema's `RoleSpec.words` for the pattern's language (its
179
+ * `'*'` list and the language's own): the stock reader reads the slot only from one of them.
180
+ */
181
+ readonly words?: readonly string[];
177
182
  /**
178
183
  * Mirrors the schema's `RoleSpec.valueShape`: the slot's surface form is
179
184
  * unambiguous on its own. Carried onto the pattern token because the value
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The schema-pattern generator semantic's hyperscript parser runs (domains
3
3
  * rethink Phase 4.4), on a schema of its own: the grammar's position order,
4
- * the variants, and a profile's optional markers.
4
+ * the variants, a profile's optional markers, and a role's word list.
5
5
  */
6
6
  import { describe, expect, it } from 'vitest';
7
7
  import { defineCommand, defineRole } from '../schema';
@@ -80,6 +80,46 @@ describe('createSchemaPatternGenerator', () => {
80
80
  ]);
81
81
  });
82
82
 
83
+ it("puts a role's word list for the pattern's language on its token, in every variant", () => {
84
+ const close = defineCommand({
85
+ action: 'send',
86
+ roles: [
87
+ defineRole({
88
+ role: 'patient',
89
+ required: true,
90
+ expectedTypes: ['expression'],
91
+ words: { '*': ['tab'], tr: ['sekme'] },
92
+ }),
93
+ defineRole({ role: 'destination', required: false, expectedTypes: ['selector'] }),
94
+ ],
95
+ });
96
+ const generator = createSchemaPatternGenerator({ positionOrder: 'ascending' });
97
+ const wordsOf = (tokens: readonly PatternToken[]): unknown[] =>
98
+ tokens.flatMap(t =>
99
+ t.type === 'role' ? [[t.role, t.words]] : t.type === 'group' ? wordsOf(t.tokens) : []
100
+ );
101
+ const at = (profile: SchemaPatternProfile) => {
102
+ const variants = generator.generatePatternVariants(close, profile, {
103
+ basePriority: 100,
104
+ generateSimpleVariants: true,
105
+ });
106
+ const roles = variants.flatMap(p => wordsOf(p.template.tokens)) as [string, unknown][];
107
+ return {
108
+ variants: variants.length,
109
+ patient: [
110
+ ...new Set(roles.filter(([r]) => r === 'patient').map(([, w]) => JSON.stringify(w))),
111
+ ],
112
+ destination: [
113
+ ...new Set(roles.filter(([r]) => r === 'destination').map(([, w]) => JSON.stringify(w))),
114
+ ],
115
+ };
116
+ };
117
+ expect(at(en)).toEqual({ variants: 2, patient: ['["tab"]'], destination: [undefined] });
118
+ expect(at(tr)).toEqual({ variants: 3, patient: ['["tab","sekme"]'], destination: [undefined] });
119
+ // No list, no field: the patterns of a grammar that lists no words are unchanged.
120
+ expect(JSON.stringify(generator.generatePattern(send, en))).not.toContain('words');
121
+ });
122
+
83
123
  it('drops variant-only roles', () => {
84
124
  const scroll = defineCommand({
85
125
  action: 'scroll',
@@ -382,11 +382,13 @@ function generateVerbFirstPattern(
382
382
  // verb-first, which is exactly this shape (trailing particle tolerated).
383
383
  const roleTokens: PatternToken[] = requiredRoles.flatMap(r => {
384
384
  const prefix = r.valuePrefixLiteral?.[profile.code];
385
+ const words = wordsFor(r, profile.code);
385
386
  const roleToken: PatternToken = {
386
387
  type: 'role',
387
388
  role: r.role,
388
389
  optional: false,
389
390
  expectedTypes: r.expectedTypes,
391
+ ...(words ? { words } : {}),
390
392
  };
391
393
  return prefix ? [{ type: 'literal', value: prefix } as PatternToken, roleToken] : [roleToken];
392
394
  });
@@ -673,6 +675,13 @@ function buildRoleTokens(
673
675
  * use — a hand-rolled copy there is what let the two drift apart in the first
674
676
  * place (see `appendRemainingOptionalRoles` in event-handlers-sov.ts).
675
677
  */
678
+ /** The words a role reads in `language` (`RoleSpec.words`: its `'*'` list and the language's own). */
679
+ function wordsFor(roleSpec: RoleSpec, language: string): readonly string[] | undefined {
680
+ if (!roleSpec.words) return undefined;
681
+ const words = [...(roleSpec.words['*'] ?? []), ...(roleSpec.words[language] ?? [])];
682
+ return words.length > 0 ? words : undefined;
683
+ }
684
+
676
685
  function buildRoleToken(roleSpec: RoleSpec, profile: SchemaPatternProfile): PatternToken[] {
677
686
  const tokens: PatternToken[] = [];
678
687
 
@@ -686,6 +695,7 @@ function buildRoleToken(roleSpec: RoleSpec, profile: SchemaPatternProfile): Patt
686
695
  // `fetch "/api"`). Tag the emitted marker literal so the renderer skips it;
687
696
  // the pattern matcher ignores the flag, so parsing is unchanged.
688
697
  const suppressMarker = roleSpec.renderOverride?.[profile.code] === '';
698
+ const words = wordsFor(roleSpec, profile.code);
689
699
 
690
700
  // Role value token
691
701
  const roleValueToken: PatternToken = {
@@ -700,6 +710,8 @@ function buildRoleToken(roleSpec: RoleSpec, profile: SchemaPatternProfile): Patt
700
710
  // A greedy role reads up to the next marker the pattern owes (a domain's
701
711
  // SQL condition); its value reader does the reading.
702
712
  ...(roleSpec.greedy ? { greedy: true } : {}),
713
+ // A role with a word list reads only one of its words.
714
+ ...(words ? { words } : {}),
703
715
  };
704
716
 
705
717
  // Required literal immediately before the role VALUE (rolePrefixLiteralVariants
@@ -1,14 +1,17 @@
1
1
  /**
2
2
  * The round-trip gate kit on framework's SQL fixture DSL: the commands it writes,
3
- * the outcomes it reads, and the baseline and snapshot it compares with.
3
+ * the outcomes it reads (an unknown word read into a role among them), and the
4
+ * baseline and snapshot it compares with.
4
5
  */
5
6
  import { describe, expect, it } from 'vitest';
6
- import { createSQLDSL, selectCommand } from '../../__test__/fixtures/sql-dsl';
7
+ import { createSQLDSL, selectCommand, sqlDSLConfig } from '../../__test__/fixtures/sql-dsl';
8
+ import { createMultilingualDSL } from '../../api/create-dsl';
7
9
  import {
8
10
  commandsFor,
9
11
  compare,
10
12
  countsOf,
11
13
  listingOf,
14
+ readsWord,
12
15
  runRoundTrip,
13
16
  snapshotChanges,
14
17
  snapshotOf,
@@ -42,11 +45,19 @@ describe('runRoundTrip', () => {
42
45
  ]);
43
46
  });
44
47
 
48
+ it("writes a fixture's own commands after the generated ones, once each", () => {
49
+ const own = '[select columns:email source:users condition:age]';
50
+ expect(
51
+ commandsFor([selectCommand], { ...fixture, commands: [own, '[select columns:id source:t]'] })
52
+ ).toEqual([...commandsFor([selectCommand], fixture), '[select columns:id source:t]']);
53
+ expect(commandsFor([selectCommand], fixture)).toContain(own);
54
+ });
55
+
45
56
  it('reads every render back, in every language, and tries each with an unknown word after it', () => {
46
57
  expect(countsOf(results)).toEqual({
47
58
  roundTrip: { cases: 8, pass: 8, refused: 0, silent: 0 },
48
- // The `where` condition is greedy: it takes the unknown word as text (an open-text role).
49
- leftover: { cases: 8, pass: 0, refused: 4, silent: 4 },
59
+ // The `where` condition is greedy: it takes the unknown word as text, into the SQL (`pass`).
60
+ leftover: { cases: 8, pass: 4, refused: 4, silent: 0 },
50
61
  });
51
62
  expect(results.find(r => r.kind === 'roundTrip' && r.lang === 'es')).toMatchObject({
52
63
  text: 'seleccionar name de users',
@@ -65,12 +76,14 @@ describe('compare', () => {
65
76
  });
66
77
 
67
78
  it('reports a case worse than listed, one better, and a listed case that is gone', () => {
79
+ // Two leftover cases accepted that the baseline lists as refused.
80
+ const accepted = '[select columns:name source:users]';
81
+ const now = results.map(r =>
82
+ r.kind === 'leftover' && r.source === accepted ? { ...r, outcome: 'silent' as const } : r
83
+ );
68
84
  const listing = listingOf(results);
69
- const accepted = Object.keys(listing.leftover)[0]!;
70
- const better = { ...listing.leftover };
71
- delete better[accepted];
72
- const drift = compare('sql', results, {
73
- ...baselineOf({ roundTrip: listing.roundTrip, leftover: better }),
85
+ const drift = compare('sql', now, {
86
+ ...baselineOf(listing),
74
87
  roundTrip: {
75
88
  sql: {
76
89
  '[select columns:name source:users]': {
@@ -97,6 +110,83 @@ describe('compare', () => {
97
110
  });
98
111
  });
99
112
 
113
+ describe('an unknown word read into a role', () => {
114
+ const leftover = (rs: typeof results) =>
115
+ rs
116
+ .filter(r => r.kind === 'leftover' && r.source.includes('condition'))
117
+ .map(r => [r.lang, r.outcome, r.why]);
118
+
119
+ it('is read (`pass`) where it extends an open-text role and reaches the code', () => {
120
+ expect(leftover(results)).toEqual([
121
+ ['en', 'pass', undefined],
122
+ ['es', 'pass', undefined],
123
+ ['en', 'pass', undefined],
124
+ ['es', 'pass', undefined],
125
+ ]);
126
+ expect(results.find(r => r.kind === 'leftover' && r.source.includes('condition'))?.read).toBe(
127
+ 'select columns:expression=name condition:expression="age zzqx" source:expression=users'
128
+ );
129
+ });
130
+
131
+ it('is accepted (`silent`) where it does not reach the code', () => {
132
+ const dropsCondition = createMultilingualDSL({
133
+ ...sqlDSLConfig(),
134
+ codeGenerator: { generate: () => 'SELECT name FROM users' },
135
+ });
136
+ const rs = runRoundTrip('sql', dropsCondition, [selectCommand], fixture);
137
+ expect(leftover(rs)[0]).toEqual([
138
+ 'en',
139
+ 'silent',
140
+ 'accepted as select; the word does not reach the code',
141
+ ]);
142
+ });
143
+
144
+ it('is refused where the input stops compiling, unless the command never compiles on its own', () => {
145
+ const refusesTheWord = createMultilingualDSL({
146
+ ...sqlDSLConfig(),
147
+ codeGenerator: {
148
+ generate: n => {
149
+ const c = n.roles.get('condition') as { raw?: string } | undefined;
150
+ if (c?.raw?.includes('zzqx')) throw new Error('not a condition');
151
+ return 'SELECT';
152
+ },
153
+ },
154
+ });
155
+ const rs = runRoundTrip('sql', refusesTheWord, [selectCommand], fixture);
156
+ expect(leftover(rs)[0]).toEqual(['en', 'refused', 'does not compile']);
157
+ const alone = runRoundTrip('sql', refusesTheWord, [selectCommand], {
158
+ ...fixture,
159
+ englishDoesNotCompile: ['select'],
160
+ });
161
+ expect(leftover(alone)[0]).toEqual(['en', 'pass', undefined]);
162
+ });
163
+
164
+ const node = (action: string, roles: Record<string, string>) => ({
165
+ action,
166
+ roles: new Map(Object.entries(roles).map(([k, v]) => [k, { type: 'expression', raw: v }])),
167
+ });
168
+
169
+ it.each([
170
+ ['extends one role', { condition: 'age' }, { condition: 'age zzqx' }, true],
171
+ ['fills a role the text left empty', {}, { list: 'zzqx' }, true],
172
+ [
173
+ 'moves a value to make room for it (`when clicks zzqx`)',
174
+ { action: 'clicks' },
175
+ { actor: 'clicks', action: 'zzqx' },
176
+ false,
177
+ ],
178
+ ['replaces a value', { quantity: '2' }, { quantity: 'zzqx' }, false],
179
+ ['is in two roles', {}, { a: 'zzqx', b: 'zzqx' }, false],
180
+ ['is not read at all', { a: 'x' }, { a: 'x' }, false],
181
+ ] as const)('a parse that %s is the text read with the word: %s', (_, was, now, want) => {
182
+ expect(readsWord(node('when', was), node('when', now), 'zzqx')).toBe(want);
183
+ });
184
+
185
+ it('a parse as another command is not', () => {
186
+ expect(readsWord(node('when', {}), node('given', { a: 'zzqx' }), 'zzqx')).toBe(false);
187
+ });
188
+ });
189
+
100
190
  describe('the parse snapshot', () => {
101
191
  it('shows a parse that moved', () => {
102
192
  const parses = snapshotOf(results);
@@ -13,8 +13,13 @@
13
13
  * generator normalizes: bdd ja `#box0 が 表示 前提` is `visible`). English is a lane like the others.
14
14
  * `refused` when the render is null or the parse throws: the caller is told. `silent` otherwise: it
15
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`).
16
+ * - **leftover**: the same render plus one unknown word. `refused` is the expected outcome: the parse
17
+ * throws, or the input does not compile. `pass` where the word is READ: the parse is the text's own
18
+ * with the word in one role (an open-text role extended by it, `where active zzqx`, or a role the
19
+ * text left empty, `list zzqx`), and the word is in the code (for a command that never compiles on
20
+ * its own, `englishDoesNotCompile`, the parse alone). `silent` otherwise: the input is accepted and
21
+ * read as something else (`when clicks zzqx`: actor `clicks`, action `zzqx`), or the word goes
22
+ * nowhere (voice `back zzqx` compiles as `back`).
18
23
  *
19
24
  * The outcomes are the command-shape gate's (hyperfixi `testing-framework`). A baseline lists every
20
25
  * case that is not at its best outcome; `compare` reports a case worse than listed and one better than
@@ -93,7 +98,16 @@ export interface GateFixture {
93
98
  oracle: 'compile' | 'node';
94
99
  /** Keys are `<action>.<role>` or `<role>` (every action). The first value is the one the other cases use. */
95
100
  values: Record<string, string[]>;
96
- /** Actions that do not compile on their own (a gate may check every other one does). */
101
+ /**
102
+ * Commands (bracket syntax) written besides the generated ones, where one role's value depends on
103
+ * another's and no single list fits both: behaviorspec's `[given subject:#box value:visible]` beside
104
+ * `[given subject:viewport value:1024x768]`.
105
+ */
106
+ commands?: string[];
107
+ /**
108
+ * Actions that do not compile on their own (a gate may check every other one does). Leftover input
109
+ * in such a command is judged by its parse alone.
110
+ */
97
111
  englishDoesNotCompile?: string[];
98
112
  }
99
113
 
@@ -132,6 +146,7 @@ export function commandsFor(schemas: readonly GateSchema[], fixture: GateFixture
132
146
  });
133
147
  }
134
148
  }
149
+ for (const command of fixture.commands ?? []) out.add(command);
135
150
  return [...out];
136
151
  }
137
152
 
@@ -181,6 +196,35 @@ function describeRead(want: Record<string, string>, ref: GateNode, back: GateNod
181
196
 
182
197
  const message = (e: unknown) => String((e as Error)?.message ?? e).slice(0, 160);
183
198
 
199
+ /** What `text` compiles to, or `undefined` when it does not compile. */
200
+ function codeOf(dsl: GateDSL, text: string, lang: string): string | undefined {
201
+ try {
202
+ const c = dsl.compile(text, lang);
203
+ return c.ok && typeof c.code === 'string' ? c.code : undefined;
204
+ } catch {
205
+ return undefined;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Whether `read`, the parse of a text plus `word`, is `back`, the text's own parse, with the word read
211
+ * into it: the same action and roles, except one role, whose value is its value in `back` followed by
212
+ * the word (an open-text role at the end: sql `where active zzqx`) or, where `back` has no such role,
213
+ * the word itself (todo `list zzqx`).
214
+ */
215
+ export function readsWord(back: GateNode, read: GateNode, word: string): boolean {
216
+ if (read.action !== back.action) return false;
217
+ const was = roleValues(back);
218
+ const now = roleValues(read);
219
+ let changed = 0;
220
+ for (const role of new Set([...Object.keys(was), ...Object.keys(now)])) {
221
+ if (was[role] === now[role]) continue;
222
+ if (now[role] !== (role in was ? `${was[role]} ${word}` : word)) return false;
223
+ changed++;
224
+ }
225
+ return changed === 1;
226
+ }
227
+
184
228
  /** What a command (bracket syntax) compiles to; null when it does not compile on its own. */
185
229
  export function referenceCode(dsl: GateDSL, source: string): string | null {
186
230
  try {
@@ -253,18 +297,39 @@ export function runRoundTrip(
253
297
 
254
298
  // Leftover input: only where the text itself reads, or a refusal would say nothing.
255
299
  const extended = `${text} ${UNKNOWN_WORD}`;
300
+ let read: GateNode;
256
301
  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
- });
302
+ read = dsl.parse(extended, lang);
265
303
  } catch {
266
304
  results.push({ ...at, kind: 'leftover', outcome: 'refused', text: extended });
305
+ continue;
267
306
  }
307
+ // Read with the word, the word must reach the code. Where the input does not compile, compile
308
+ // tells the caller (`refused`), except for a command that never compiles on its own (bdd's
309
+ // `and`), where only the parse can say.
310
+ const kept = readsWord(back, read, UNKNOWN_WORD);
311
+ const code = kept ? codeOf(dsl, extended, lang) : undefined;
312
+ const alone = fixture.englishDoesNotCompile?.includes(read.action) ?? false;
313
+ const [left, leftWhy]: [Outcome, string | undefined] = !kept
314
+ ? [
315
+ 'silent',
316
+ `accepted as ${read.action}; reads ${describeRead(roleValues(back), back, read)}`,
317
+ ]
318
+ : code !== undefined
319
+ ? code.includes(UNKNOWN_WORD)
320
+ ? ['pass', undefined]
321
+ : ['silent', `accepted as ${read.action}; the word does not reach the code`]
322
+ : alone
323
+ ? ['pass', undefined]
324
+ : ['refused', 'does not compile'];
325
+ results.push({
326
+ ...at,
327
+ kind: 'leftover',
328
+ outcome: left,
329
+ text: extended,
330
+ ...(leftWhy ? { why: leftWhy } : {}),
331
+ read: summarize(read),
332
+ });
268
333
  }
269
334
  }
270
335
  return results;