@lokascript/framework 4.7.0 → 5.0.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 (96) hide show
  1. package/CHANGELOG.md +76 -1
  2. package/dist/api/create-dsl.d.ts +14 -12
  3. package/dist/api/create-dsl.d.ts.map +1 -1
  4. package/dist/api/index.js +888 -1683
  5. package/dist/api/index.js.map +1 -1
  6. package/dist/core/index.d.ts +0 -1
  7. package/dist/core/index.d.ts.map +1 -1
  8. package/dist/core/index.js +560 -1133
  9. package/dist/core/index.js.map +1 -1
  10. package/dist/core/matching/index.d.ts +2 -0
  11. package/dist/core/matching/index.d.ts.map +1 -1
  12. package/dist/core/matching/possessive-keywords.d.ts.map +1 -0
  13. package/dist/core/matching/stock-confidence.d.ts +3 -4
  14. package/dist/core/matching/stock-confidence.d.ts.map +1 -1
  15. package/dist/core/matching/stock-reader.d.ts +8 -1
  16. package/dist/core/matching/stock-reader.d.ts.map +1 -1
  17. package/dist/core/matching/stock-reading.d.ts +4 -4
  18. package/dist/core/{pattern-matching/utils → matching}/type-validation.d.ts +1 -1
  19. package/dist/core/matching/type-validation.d.ts.map +1 -0
  20. package/dist/core/types.d.ts +5 -0
  21. package/dist/core/types.d.ts.map +1 -1
  22. package/dist/core/types.js.map +1 -1
  23. package/dist/generation/index.d.ts +1 -1
  24. package/dist/generation/index.d.ts.map +1 -1
  25. package/dist/generation/index.js +32 -171
  26. package/dist/generation/index.js.map +1 -1
  27. package/dist/generation/profile.d.ts +30 -0
  28. package/dist/generation/profile.d.ts.map +1 -0
  29. package/dist/generation/renderer.d.ts +1 -1
  30. package/dist/generation/renderer.d.ts.map +1 -1
  31. package/dist/generation/schema-patterns.d.ts +3 -3
  32. package/dist/index.cjs +969 -1674
  33. package/dist/index.cjs.map +1 -1
  34. package/dist/index.js +969 -1668
  35. package/dist/index.js.map +1 -1
  36. package/dist/ir/index.js.map +1 -1
  37. package/dist/multilingual/builders.d.ts +3 -3
  38. package/dist/multilingual/builders.d.ts.map +1 -1
  39. package/dist/multilingual/index.js.map +1 -1
  40. package/dist/parsing/index.d.ts +0 -1
  41. package/dist/parsing/index.d.ts.map +1 -1
  42. package/dist/parsing/index.js +0 -1145
  43. package/dist/parsing/index.js.map +1 -1
  44. package/dist/testing/gate/index.js +44 -12
  45. package/dist/testing/gate/index.js.map +1 -1
  46. package/dist/testing/gate/lint/types.d.ts +1 -1
  47. package/dist/testing/gate/lint/types.d.ts.map +1 -1
  48. package/dist/testing/gate/round-trip.d.ts +24 -3
  49. package/dist/testing/gate/round-trip.d.ts.map +1 -1
  50. package/package.json +2 -7
  51. package/src/__test__/test-utils.ts +1 -1
  52. package/src/api/create-dsl-engine.test.ts +123 -34
  53. package/src/api/create-dsl.test.ts +7 -0
  54. package/src/api/create-dsl.ts +56 -39
  55. package/src/core/index.ts +0 -1
  56. package/src/core/matching/index.ts +2 -0
  57. package/src/core/matching/stock-confidence.ts +3 -4
  58. package/src/core/matching/stock-reader.test.ts +87 -3
  59. package/src/core/matching/stock-reader.ts +20 -1
  60. package/src/core/matching/stock-reading.ts +6 -6
  61. package/src/core/{pattern-matching/utils → matching}/type-validation.test.ts +2 -2
  62. package/src/core/{pattern-matching/utils → matching}/type-validation.ts +1 -1
  63. package/src/core/types.ts +5 -0
  64. package/src/generation/domain-renderer.test.ts +1 -1
  65. package/src/generation/index.ts +1 -1
  66. package/src/generation/profile.ts +41 -0
  67. package/src/generation/renderer.test.ts +1 -1
  68. package/src/generation/renderer.ts +1 -1
  69. package/src/generation/schema-patterns.test.ts +41 -1
  70. package/src/generation/schema-patterns.ts +15 -3
  71. package/src/multilingual/bridge.test.ts +31 -23
  72. package/src/multilingual/builders.ts +3 -3
  73. package/src/parsing/index.ts +0 -1
  74. package/src/testing/gate/lint/types.ts +1 -1
  75. package/src/testing/gate/round-trip.test.ts +99 -9
  76. package/src/testing/gate/round-trip.ts +76 -11
  77. package/dist/core/pattern-matching/index.d.ts +0 -6
  78. package/dist/core/pattern-matching/index.d.ts.map +0 -1
  79. package/dist/core/pattern-matching/index.js +0 -1211
  80. package/dist/core/pattern-matching/index.js.map +0 -1
  81. package/dist/core/pattern-matching/pattern-matcher.d.ts +0 -122
  82. package/dist/core/pattern-matching/pattern-matcher.d.ts.map +0 -1
  83. package/dist/core/pattern-matching/utils/index.d.ts +0 -6
  84. package/dist/core/pattern-matching/utils/index.d.ts.map +0 -1
  85. package/dist/core/pattern-matching/utils/possessive-keywords.d.ts.map +0 -1
  86. package/dist/core/pattern-matching/utils/type-validation.d.ts.map +0 -1
  87. package/dist/generation/pattern-generator.d.ts +0 -55
  88. package/dist/generation/pattern-generator.d.ts.map +0 -1
  89. package/src/core/pattern-matching/index.ts +0 -6
  90. package/src/core/pattern-matching/pattern-matcher.test.ts +0 -1031
  91. package/src/core/pattern-matching/pattern-matcher.ts +0 -765
  92. package/src/core/pattern-matching/utils/index.ts +0 -6
  93. package/src/generation/pattern-generator.test.ts +0 -532
  94. package/src/generation/pattern-generator.ts +0 -327
  95. /package/dist/core/{pattern-matching/utils → matching}/possessive-keywords.d.ts +0 -0
  96. /package/src/core/{pattern-matching/utils → matching}/possessive-keywords.ts +0 -0
@@ -2,7 +2,7 @@
2
2
  * Pattern generation, rendering, and diagnostics
3
3
  */
4
4
 
5
- export * from './pattern-generator';
5
+ export * from './profile';
6
6
  export * from './renderer';
7
7
  export * from './diagnostics';
8
8
  export * from './schema-patterns';
@@ -0,0 +1,41 @@
1
+ /**
2
+ * A domain's language profile, and the role order its sentences use.
3
+ */
4
+
5
+ import type { RoleSpec } from '../schema';
6
+
7
+ /**
8
+ * A domain's language profile as framework reads it: word order, command
9
+ * keywords, and role markers (with the side of the value each attaches to).
10
+ */
11
+ export interface PatternGenLanguageProfile {
12
+ readonly code: string;
13
+ readonly wordOrder: 'SVO' | 'SOV' | 'VSO' | 'VOS' | 'OSV' | 'OVS' | 'free';
14
+ readonly keywords: Record<string, { primary: string; alternatives?: string[] }>;
15
+ readonly roleMarkers?: Record<
16
+ string,
17
+ { primary: string; alternatives?: string[]; position?: 'before' | 'after' }
18
+ >;
19
+ }
20
+
21
+ /**
22
+ * Sort roles by word order: descending `svoPosition` (or `sovPosition` in an
23
+ * SOV language) — higher values come first. The schema renderer
24
+ * (`createSchemaRenderer`) orders roles with it, as the core's pattern
25
+ * generator does (`createSchemaPatternGenerator` with `positionOrder:
26
+ * 'descending'`), so a rendered surface re-parses.
27
+ */
28
+ export function sortRolesByWordOrder(roles: RoleSpec[], wordOrder: string): RoleSpec[] {
29
+ const sorted = [...roles];
30
+
31
+ if (wordOrder === 'SVO') {
32
+ // Sort by svoPosition (higher = earlier)
33
+ sorted.sort((a, b) => (b.svoPosition ?? 0) - (a.svoPosition ?? 0));
34
+ } else if (wordOrder === 'SOV') {
35
+ // Sort by sovPosition (higher = earlier)
36
+ sorted.sort((a, b) => (b.sovPosition ?? 0) - (a.sovPosition ?? 0));
37
+ }
38
+ // VSO and others use SVO-style ordering
39
+
40
+ return sorted;
41
+ }
@@ -11,7 +11,7 @@ import type { KeywordTable, MarkerTable } from './renderer';
11
11
  import type { SemanticNode } from '../core/types';
12
12
  import type { CommandSchema } from '../schema';
13
13
  import { defineCommand, defineRole } from '../schema';
14
- import type { PatternGenLanguageProfile } from './pattern-generator';
14
+ import type { PatternGenLanguageProfile } from './profile';
15
15
 
16
16
  // =============================================================================
17
17
  // Test Data
@@ -11,7 +11,7 @@
11
11
  import type { SemanticNode } from '../core/types';
12
12
  import { extractRoleValue, extractValue } from '../core/types';
13
13
  import type { CommandSchema } from '../schema';
14
- import { sortRolesByWordOrder, type PatternGenLanguageProfile } from './pattern-generator';
14
+ import { sortRolesByWordOrder, type PatternGenLanguageProfile } from './profile';
15
15
 
16
16
  // =============================================================================
17
17
  // Renderer Interface
@@ -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',
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Patterns from command schemas: the generator semantic's hyperscript parser
3
3
  * runs (domains rethink Phase 4.4, `docs-internal/DOMAINS_RETHINK.md`), moved
4
- * here from `@lokascript/semantic` beside the domains' own `generatePattern`
5
- * (`pattern-generator.ts`), which `createMultilingualDSL` still uses.
4
+ * here from `@lokascript/semantic`; `createMultilingualDSL` generates every
5
+ * domain's patterns with it (the classic generator went in 5.0, Phase 6).
6
6
  *
7
7
  * Per command and language it builds the main pattern and its variants: the
8
8
  * simple variant (required roles only), the omit-role, widened-type and
@@ -21,7 +21,7 @@ export interface SchemaPatternGrammar {
21
21
  /**
22
22
  * How `svoPosition` / `sovPosition` sort: `'ascending'` puts the lowest first
23
23
  * (hyperscript's schemas, 1-based), `'descending'` the highest (a domain's
24
- * schemas, as framework's `generatePattern` reads them).
24
+ * schemas, as `sortRolesByWordOrder` and the schema renderer read them).
25
25
  */
26
26
  readonly positionOrder: 'ascending' | 'descending';
27
27
  }
@@ -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
@@ -14,9 +14,9 @@ import {
14
14
  deriveRoleMarkers,
15
15
  } from './builders';
16
16
  import type { GrammarProfileSlice, DomainVocabulary } from './types';
17
- import { generatePattern } from '../generation/pattern-generator';
18
17
  import { defineCommand, defineRole } from '../schema';
19
18
  import { createMultilingualDSL } from '../api/create-dsl';
19
+ import { extractValue } from '../core/types';
20
20
 
21
21
  // =============================================================================
22
22
  // Fixture slices (mirror the shape of semantic KNOWN_PROFILES entries)
@@ -189,7 +189,7 @@ describe('buildPatternProfile', () => {
189
189
  expect(profile.keywords.select.alternatives).toEqual([...alternatives]);
190
190
  });
191
191
 
192
- describe('generatePattern integration', () => {
192
+ describe('a DSL built from the profile (the core engine)', () => {
193
193
  const moveSchema = defineCommand({
194
194
  action: 'move',
195
195
  description: 'Move a thing',
@@ -212,37 +212,45 @@ describe('buildPatternProfile', () => {
212
212
  }),
213
213
  ],
214
214
  });
215
+ const dsl = createMultilingualDSL({
216
+ name: 'Move',
217
+ schemas: [moveSchema],
218
+ languages: [
219
+ buildLanguageConfig(EN_SLICE, { keywords: { move: { primary: 'move' } } }),
220
+ buildLanguageConfig(JA_SLICE, { keywords: { move: { primary: '移動' } } }),
221
+ buildLanguageConfig(AR_SLICE, { keywords: { move: { primary: 'انقل' } } }),
222
+ ],
223
+ });
224
+ const roles = (input: string, language: string) => {
225
+ const node = dsl.parse(input, language);
226
+ return [
227
+ node.action,
228
+ ...[...node.roles].map(([role, value]) => `${role}=${extractValue(value)}`),
229
+ ];
230
+ };
215
231
 
216
232
  it('SVO (en-like): keyword first, prepositional marker before its role', () => {
217
- const vocab: DomainVocabulary = { keywords: { move: { primary: 'move' } } };
218
- const pattern = generatePattern(moveSchema, buildPatternProfile(EN_SLICE, vocab));
219
-
220
- expect(pattern.template.tokens).toEqual([
221
- { type: 'literal', value: 'move' },
222
- { type: 'role', role: 'patient', optional: false, expectedTypes: ['expression'] },
223
- { type: 'literal', value: 'into' },
224
- { type: 'role', role: 'destination', optional: false, expectedTypes: ['expression'] },
233
+ expect(roles('move box into shelf', 'en')).toEqual([
234
+ 'move',
235
+ 'patient=box',
236
+ 'destination=shelf',
225
237
  ]);
226
238
  });
227
239
 
228
240
  it('SOV (ja-like): particles after their roles, verb last', () => {
229
- const vocab: DomainVocabulary = { keywords: { move: { primary: '移動' } } };
230
- const pattern = generatePattern(moveSchema, buildPatternProfile(JA_SLICE, vocab));
231
-
232
- expect(pattern.template.tokens).toEqual([
233
- { type: 'role', role: 'patient', optional: false, expectedTypes: ['expression'] },
234
- { type: 'literal', value: 'を' },
235
- { type: 'role', role: 'destination', optional: false, expectedTypes: ['expression'] },
236
- { type: 'literal', value: 'に' },
237
- { type: 'literal', value: '移動' },
241
+ expect(roles('box を shelf に 移動', 'ja')).toEqual([
242
+ 'move',
243
+ 'patient=box',
244
+ 'destination=shelf',
238
245
  ]);
239
246
  });
240
247
 
241
248
  it('VSO (ar-like): verb first', () => {
242
- const vocab: DomainVocabulary = { keywords: { move: { primary: 'انقل' } } };
243
- const pattern = generatePattern(moveSchema, buildPatternProfile(AR_SLICE, vocab));
244
-
245
- expect(pattern.template.tokens[0]).toEqual({ type: 'literal', value: 'انقل' });
249
+ expect(roles('انقل box في shelf', 'ar')).toEqual([
250
+ 'move',
251
+ 'patient=box',
252
+ 'destination=shelf',
253
+ ]);
246
254
  });
247
255
  });
248
256
  });
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { LanguageTokenizer } from '../core/types';
13
- import type { PatternGenLanguageProfile } from '../generation/pattern-generator';
13
+ import type { PatternGenLanguageProfile } from '../generation/profile';
14
14
  import type { LanguageConfig } from '../api/create-dsl';
15
15
  import type { LanguageProfile as GrammarProfile } from '../grammar';
16
16
  import { createSimpleTokenizer, type TokenizerProfile } from '../core/tokenization/base-tokenizer';
@@ -51,8 +51,8 @@ function mergeRoleMarkers(
51
51
 
52
52
  /**
53
53
  * Build a pattern-generation profile: domain keywords + slice grammar
54
- * (word order, role markers). The result feeds `generatePattern` /
55
- * `generatePatternVariants` and `LanguageConfig.patternProfile`.
54
+ * (word order, role markers). The result feeds `LanguageConfig.patternProfile`
55
+ * and the schema renderer.
56
56
  */
57
57
  export function buildPatternProfile(
58
58
  slice: GrammarProfileSlice,
@@ -4,7 +4,6 @@
4
4
 
5
5
  // Re-export core parsing types
6
6
  export type { SemanticNode, TokenStream, LanguageTokenizer } from '../core/types';
7
- export { PatternMatcher } from '../core/pattern-matching';
8
7
 
9
8
  // Multi-statement parser
10
9
  export { createMultiStatementParser, accumulateBlocks } from './multi-statement';
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { CommandSchema } from '../../../schema';
6
6
  import type { LanguageTokenizer } from '../../../core/types';
7
- import type { PatternGenLanguageProfile } from '../../../generation/pattern-generator';
7
+ import type { PatternGenLanguageProfile } from '../../../generation/profile';
8
8
 
9
9
  export type Severity = 'error' | 'warning' | 'note';
10
10
 
@@ -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;
@@ -1,6 +0,0 @@
1
- /**
2
- * Pattern matching infrastructure for semantic parsing
3
- */
4
- export * from './pattern-matcher';
5
- export * from './utils';
6
- //# sourceMappingURL=index.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/core/pattern-matching/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,cAAc,mBAAmB,CAAC;AAClC,cAAc,SAAS,CAAC"}