@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
@@ -1,29 +1,24 @@
1
1
  /**
2
- * `engine: 'core'` (domains rethink Phase 4.7): the same config and DSL as the
3
- * classic engine, read by the core. framework's SQL fixture reads, compiles and
4
- * translates as it does on the classic engine, refuses what it does not read,
5
- * and renders through its own patterns where no renderer is configured.
2
+ * The core engine (domains rethink Phase 4.7), the only engine since 5.0. framework's SQL fixture
3
+ * reads, compiles and translates as the classic engine did (its readings and translations below
4
+ * were taken from the classic engine on 4.8), refuses what it does not read, and renders through
5
+ * its own patterns where no renderer is configured. A role with a word list reads only its words.
6
6
  */
7
7
  import { describe, expect, it, vi } from 'vitest';
8
8
  import { createMultilingualDSL, UnreadInputError, type DSLConfig } from './create-dsl';
9
+ import { defineCommand, defineRole } from '../schema';
10
+ import { BaseTokenizer, getDefaultExtractors } from '../index';
9
11
  import { sqlDSLConfig, testQueries, expectedSQL } from '../__test__/fixtures/sql-dsl';
10
12
  import type { SemanticNode } from '../core/types';
11
- import { generatePattern } from '../generation/pattern-generator';
12
13
  import { createSchemaPatternGenerator } from '../generation/schema-patterns';
13
14
 
14
- // Pass-through spies. On the SQL fixture the two engines read, compile and render alike, so
15
- // these are what show which engine a DSL was built on.
16
- vi.mock('../generation/pattern-generator', async importOriginal => {
17
- const actual = await importOriginal<typeof import('../generation/pattern-generator')>();
18
- return { ...actual, generatePattern: vi.fn(actual.generatePattern) };
19
- });
15
+ // A pass-through spy: what shows a DSL was built on the core.
20
16
  vi.mock('../generation/schema-patterns', async importOriginal => {
21
17
  const actual = await importOriginal<typeof import('../generation/schema-patterns')>();
22
18
  return { ...actual, createSchemaPatternGenerator: vi.fn(actual.createSchemaPatternGenerator) };
23
19
  });
24
20
 
25
- const core = createMultilingualDSL({ ...sqlDSLConfig(), engine: 'core' });
26
- const classic = createMultilingualDSL(sqlDSLConfig());
21
+ const core = createMultilingualDSL(sqlDSLConfig());
27
22
 
28
23
  const reading = (node: SemanticNode) =>
29
24
  [
@@ -34,23 +29,54 @@ const reading = (node: SemanticNode) =>
34
29
  ),
35
30
  ].join(' ');
36
31
 
37
- describe("createMultilingualDSL with engine: 'core'", () => {
32
+ describe('createMultilingualDSL on the core engine', () => {
33
+ // [language, input, SQL, reading, translation] — the reading and translation the classic engine gave.
38
34
  const queries = [
39
- ['en', testQueries.english.simple, expectedSQL.simple],
40
- ['en', testQueries.english.withWhere, expectedSQL.withWhere],
41
- ['es', testQueries.spanish.simple, 'SELECT nombre FROM usuarios'],
42
- ['es', testQueries.spanish.withWhere, 'SELECT nombre FROM usuarios WHERE edad > 18'],
35
+ [
36
+ 'en',
37
+ testQueries.english.simple,
38
+ expectedSQL.simple,
39
+ 'select columns=name source=users',
40
+ 'seleccionar name de users',
41
+ ],
42
+ [
43
+ 'en',
44
+ testQueries.english.withWhere,
45
+ expectedSQL.withWhere,
46
+ 'select columns=name source=users condition=age > 18',
47
+ 'seleccionar name de users donde age > 18',
48
+ ],
49
+ [
50
+ 'es',
51
+ testQueries.spanish.simple,
52
+ 'SELECT nombre FROM usuarios',
53
+ 'select columns=nombre source=usuarios',
54
+ 'select nombre from usuarios',
55
+ ],
56
+ [
57
+ 'es',
58
+ testQueries.spanish.withWhere,
59
+ 'SELECT nombre FROM usuarios WHERE edad > 18',
60
+ 'select columns=nombre source=usuarios condition=edad > 18',
61
+ 'select nombre from usuarios where edad > 18',
62
+ ],
43
63
  ] as const;
44
64
 
45
- it.each(queries)('reads and compiles %s `%s` as the classic engine does', (lang, input, sql) => {
46
- expect(reading(core.parse(input, lang))).toBe(reading(classic.parse(input, lang)));
47
- expect(core.compile(input, lang).code).toBe(sql);
48
- });
65
+ it.each(queries)(
66
+ 'reads and compiles %s `%s` as the classic engine did',
67
+ (lang, input, sql, read) => {
68
+ expect(reading(core.parse(input, lang))).toBe(read);
69
+ expect(core.compile(input, lang).code).toBe(sql);
70
+ }
71
+ );
49
72
 
50
- it.each(queries)('translates %s `%s` as the classic engine does', (lang, input) => {
51
- const to = lang === 'en' ? 'es' : 'en';
52
- expect(core.translate(input, lang, to)).toBe(classic.translate(input, lang, to));
53
- });
73
+ it.each(queries)(
74
+ 'translates %s `%s` as the classic engine did',
75
+ (lang, input, _sql, _read, translated) => {
76
+ const to = lang === 'en' ? 'es' : 'en';
77
+ expect(core.translate(input, lang, to)).toBe(translated);
78
+ }
79
+ );
54
80
 
55
81
  it('refuses input it does not read', () => {
56
82
  expect(() => core.parse('select name from users banana', 'en')).toThrow(UnreadInputError);
@@ -68,19 +94,82 @@ describe("createMultilingualDSL with engine: 'core'", () => {
68
94
  });
69
95
 
70
96
  it.each([
71
- ['core', { engine: 'core' }],
72
- ['classic', { engine: 'classic' }],
73
- ['classic', {}],
74
- ] as const)('builds on the %s engine with %o', (expected, option) => {
75
- vi.mocked(generatePattern).mockClear();
97
+ ['no engine option', {}],
98
+ ["engine: 'core'", { engine: 'core' }],
99
+ ] as const)('builds on the core with %s', (_label, option) => {
76
100
  vi.mocked(createSchemaPatternGenerator).mockClear();
77
101
  createMultilingualDSL({ ...sqlDSLConfig(), ...option }).parse(testQueries.english.simple, 'en');
78
- expect(vi.mocked(createSchemaPatternGenerator).mock.calls.length > 0).toBe(expected === 'core');
79
- expect(vi.mocked(generatePattern).mock.calls.length > 0).toBe(expected === 'classic');
102
+ expect(vi.mocked(createSchemaPatternGenerator).mock.calls.length).toBeGreaterThan(0);
80
103
  });
81
104
 
82
- it('throws on an engine it does not know, rather than reading with the classic one', () => {
105
+ it("refuses engine: 'classic', removed in 5.0, and says what replaced it", () => {
106
+ const classic = { ...sqlDSLConfig(), engine: 'classic' } as unknown as DSLConfig;
107
+ expect(() => createMultilingualDSL(classic)).toThrow(/removed in @lokascript\/framework 5\.0/);
108
+ });
109
+
110
+ it('throws on an engine it does not know, rather than reading with the default', () => {
83
111
  const misspelled = { ...sqlDSLConfig(), engine: 'cor' } as unknown as DSLConfig;
84
112
  expect(() => createMultilingualDSL(misspelled)).toThrow('Unknown DSL engine "cor"');
85
113
  });
86
114
  });
115
+
116
+ describe('a role with a word list, on the core', () => {
117
+ class WordTokenizer extends BaseTokenizer {
118
+ readonly language = 'en';
119
+ readonly direction = 'ltr' as const;
120
+ constructor() {
121
+ super();
122
+ this.registerExtractors(getDefaultExtractors());
123
+ }
124
+ classifyToken(token: string): 'keyword' | 'identifier' {
125
+ return token.toLowerCase() === 'when' ? 'keyword' : 'identifier';
126
+ }
127
+ }
128
+ // behaviorspec's `when [actor] action`: both are bare words.
129
+ const when = defineCommand({
130
+ action: 'when',
131
+ primaryRole: 'action',
132
+ roles: [
133
+ defineRole({ role: 'actor', required: false, expectedTypes: ['expression'], svoPosition: 2 }),
134
+ defineRole({
135
+ role: 'action',
136
+ required: true,
137
+ expectedTypes: ['expression'],
138
+ svoPosition: 1,
139
+ words: { '*': ['clicks', 'types'] },
140
+ }),
141
+ ],
142
+ });
143
+ const dsl = createMultilingualDSL({
144
+ name: 'when',
145
+ schemas: [when],
146
+ languages: [
147
+ {
148
+ code: 'en',
149
+ name: 'English',
150
+ nativeName: 'English',
151
+ tokenizer: new WordTokenizer(),
152
+ patternProfile: {
153
+ code: 'en',
154
+ wordOrder: 'SVO',
155
+ keywords: { when: { primary: 'when' } },
156
+ roleMarkers: {},
157
+ },
158
+ },
159
+ ],
160
+ engine: 'core',
161
+ });
162
+
163
+ it('reads a listed word, with or without the optional role before it', () => {
164
+ expect(reading(dsl.parse('when clicks', 'en'))).toBe('when action=clicks');
165
+ expect(reading(dsl.parse('when user clicks', 'en'))).toBe('when actor=user action=clicks');
166
+ });
167
+
168
+ it('refuses a word it does not list, rather than reading the listed word as the role before it', () => {
169
+ // Without the list: actor `clicks`, action `zzqx`.
170
+ expect(() => dsl.parse('when clicks zzqx', 'en')).toThrow(UnreadInputError);
171
+ // A listed word is not part of a longer value, so the refusal does not say to quote it.
172
+ expect(() => dsl.parse('when clicks zzqx', 'en')).toThrow(/leaving "zzqx" unread$/);
173
+ expect(() => dsl.parse('when user frobs', 'en')).toThrow();
174
+ });
175
+ });
@@ -449,6 +449,13 @@ describe('MultilingualDSL: Explicit Syntax Support', () => {
449
449
  expect(refusal.message).toMatch(/leaving "banana" unread/);
450
450
  });
451
451
 
452
+ it('says when the bare word read last and the next may be one value, to be quoted', () => {
453
+ const dsl = createTestDSL();
454
+ expect(() => dsl.parse('select name from users banana', 'en')).toThrow(
455
+ /unread; if "users banana" is one value, quote it$/
456
+ );
457
+ });
458
+
452
459
  it('compile and validate report the refusal instead of compiling the shorter reading', () => {
453
460
  const dsl = createTestDSL();
454
461
  const compiled = dsl.compile('select name from users banana banana', 'en');
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type {
6
6
  SemanticNode,
7
+ LanguageToken,
7
8
  LanguageTokenizer,
8
9
  LanguagePattern,
9
10
  PatternMatchResult,
@@ -12,10 +13,9 @@ import type {
12
13
  } from '../core/types';
13
14
  import { extractRoleValue } from '../core/types';
14
15
  import type { CommandSchema } from '../schema';
15
- import { PatternMatcher } from '../core/pattern-matching';
16
16
  import { MatchSkeleton, createStockReader, stockConfidenceModel } from '../core/matching';
17
- import { generatePattern, type PatternGenLanguageProfile } from '../generation/pattern-generator';
18
- import { createDomainRenderer, type DomainRenderFn } from '../generation/renderer';
17
+ import type { PatternGenLanguageProfile } from '../generation/profile';
18
+ import type { DomainRenderFn } from '../generation/renderer';
19
19
  import {
20
20
  createSchemaPatternGenerator,
21
21
  type SchemaPatternProfile,
@@ -185,18 +185,17 @@ export interface DSLConfig {
185
185
  readonly customPatterns?: LanguagePattern[];
186
186
 
187
187
  /**
188
- * What reads and writes the DSL (domains rethink Phase 4.7). `'classic'`, the
189
- * default: framework's first pattern generator, matcher and schema renderer.
190
- * `'core'`: the core semantic's hyperscript parser runs — patterns from
188
+ * What reads and writes the DSL. `'core'`, the only engine (and the
189
+ * default): the core semantic's hyperscript parser runs — patterns from
191
190
  * `createSchemaPatternGenerator` (positions descending, as a domain's schemas
192
191
  * read), matched by `MatchSkeleton` with the stock reader and confidence
193
192
  * model, and rendered through those patterns where the domain's own renderer
194
- * declines. Parsing's whole-input rule, the refusals, translation's
195
- * read-back, compile, explicit syntax and extensions are the same on both.
196
- * The domains move to `'core'` one at a time against their gate (Phase 5);
197
- * `'classic'` is removed in a later major, once no domain uses it.
193
+ * declines. The option stays so a config written for framework 4.7–4.8
194
+ * (`engine: 'core'`) builds unchanged; `'classic'`, framework's first
195
+ * generator and matcher, was removed in 5.0 (domains rethink Phase 6) and
196
+ * throws.
198
197
  */
199
- readonly engine?: 'classic' | 'core';
198
+ readonly engine?: 'core';
200
199
  }
201
200
 
202
201
  /**
@@ -229,7 +228,9 @@ export interface CompileResult {
229
228
  /**
230
229
  * Thrown by `parse` (and so `compile`, `validate`, `translate`) when the input reads as a command
231
230
  * 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.
231
+ * `reading` is what the longest such match read, `unread` the text after it. `hint`, when there is
232
+ * one, says how the input might read: where a bare word read as a value is followed by another, the
233
+ * two may be one value written without quotes (todo `add buy milk to groceries`).
233
234
  */
234
235
  export class UnreadInputError extends Error {
235
236
  readonly code = 'unread-input';
@@ -238,9 +239,13 @@ export class UnreadInputError extends Error {
238
239
  readonly input: string,
239
240
  readonly language: string,
240
241
  readonly reading: SemanticNode,
241
- readonly unread: string
242
+ readonly unread: string,
243
+ readonly hint?: string
242
244
  ) {
243
- super(`"${input}" (${language}) reads as ${reading.action} only by leaving "${unread}" unread`);
245
+ super(
246
+ `"${input}" (${language}) reads as ${reading.action} only by leaving "${unread}" unread` +
247
+ (hint ? `; ${hint}` : '')
248
+ );
244
249
  this.name = 'UnreadInputError';
245
250
  }
246
251
  }
@@ -491,16 +496,37 @@ class MultilingualDSLImpl implements MultilingualDSL {
491
496
  .slice(longestPartial.consumedTokens)
492
497
  .map(t => t.value)
493
498
  .join(' ');
499
+ const hint = this.quoteHint(longestPartial, tokens.tokens);
494
500
  throw new UnreadInputError(
495
501
  input,
496
502
  language,
497
503
  this.nodeOf(longestPartial, input, language),
498
- unread
504
+ unread,
505
+ ...(hint ? [hint] : [])
499
506
  );
500
507
  }
501
508
  throw new Error(`No pattern matched for input: ${input}`);
502
509
  }
503
510
 
511
+ /**
512
+ * `if "buy milk" is one value, quote it`, where the last token a match read is a bare word it took
513
+ * as a role's value (a role with no word list) and the first it left is a bare word too.
514
+ */
515
+ private quoteHint(
516
+ match: PatternMatchResult,
517
+ tokens: readonly LanguageToken[]
518
+ ): string | undefined {
519
+ const last = tokens[match.consumedTokens - 1];
520
+ const next = tokens[match.consumedTokens];
521
+ if (last?.kind !== 'identifier' || next?.kind !== 'identifier') return undefined;
522
+ const schema = this.schemaLookup.getSchema(match.pattern.command);
523
+ const role = [...match.captured].find(
524
+ ([, v]) => (v as { raw?: unknown }).raw === last.value
525
+ )?.[0];
526
+ if (!role || schema?.roles.find(r => r.role === role)?.words) return undefined;
527
+ return `if "${last.value} ${next.value}" is one value, quote it`;
528
+ }
529
+
504
530
  private nodeOf(match: PatternMatchResult, input: string, language: string): SemanticNode {
505
531
  return {
506
532
  kind: 'command',
@@ -860,10 +886,10 @@ export function createMultilingualDSL(config: DSLConfig): MultilingualDSL {
860
886
  // =============================================================================
861
887
 
862
888
  /**
863
- * What differs between the engines {@link DSLConfig.engine} names: how each
864
- * command's pattern is generated, how input is matched against it, and the
865
- * renderer `render()` falls back to. Parsing's whole-input rule, refusals,
866
- * translation's read-back, compile, explicit syntax and extensions are shared.
889
+ * What reads and writes a DSL: how each command's pattern is generated, how
890
+ * input is matched against it, and the renderer `render()` falls back to.
891
+ * Parsing's whole-input rule, refusals, translation's read-back, compile,
892
+ * explicit syntax and extensions sit above it.
867
893
  */
868
894
  interface DSLEngine {
869
895
  generate(schema: CommandSchema, profile: PatternGenLanguageProfile): LanguagePattern;
@@ -872,29 +898,20 @@ interface DSLEngine {
872
898
  }
873
899
 
874
900
  /**
875
- * The engine `config.engine` names. An unknown name throws rather than reading
876
- * with the default: a domain that misspells `'core'` would otherwise measure
877
- * the classic engine against its gate and take the result for the core's.
901
+ * The engine `config.engine` names: the core, the only one since 5.0. An
902
+ * unknown name throws rather than reading with the default, and `'classic'`
903
+ * says what replaced it.
878
904
  */
879
905
  function engineFor(config: DSLConfig): DSLEngine {
880
- const name = config.engine ?? 'classic';
881
- if (name === 'classic') return classicEngine();
906
+ const name: string = config.engine ?? 'core';
882
907
  if (name === 'core') return coreEngine();
883
- throw new Error(`Unknown DSL engine "${String(name)}" (expected "classic" or "core")`);
884
- }
885
-
886
- /** framework's first generator and matcher, and its schema renderer (`engine: 'classic'`). */
887
- function classicEngine(): DSLEngine {
888
- const matcher = new PatternMatcher();
889
- return {
890
- generate: (schema, profile) => generatePattern(schema, profile),
891
- match: (tokens, pattern, language) => matcher.matchPattern(tokens, pattern, { code: language }),
892
- fallbackRenderer: config =>
893
- createDomainRenderer({
894
- schemas: config.schemas,
895
- profiles: config.languages.map(l => l.patternProfile),
896
- }),
897
- };
908
+ if (name === 'classic') {
909
+ throw new Error(
910
+ 'The "classic" DSL engine was removed in @lokascript/framework 5.0. ' +
911
+ 'Omit `engine` (or pass "core"): every domain reads on the core.'
912
+ );
913
+ }
914
+ throw new Error(`Unknown DSL engine "${name}" (expected "core")`);
898
915
  }
899
916
 
900
917
  /**
package/src/core/index.ts CHANGED
@@ -4,5 +4,4 @@
4
4
 
5
5
  export * from './types';
6
6
  export * from './tokenization';
7
- export * from './pattern-matching';
8
7
  export * from './matching';
@@ -10,3 +10,5 @@ export type { ConfidenceBreakdown, ConfidenceContext, ConfidenceModel } from './
10
10
  export { StockValueReader, createStockReader, greedyStopMarkers, readGreedy } from './stock-reader';
11
11
  export { readRoleValue, type ReadingProfile } from './stock-reading';
12
12
  export { stockConfidenceModel } from './stock-confidence';
13
+ export * from './type-validation';
14
+ export * from './possessive-keywords';
@@ -1,8 +1,7 @@
1
1
  /**
2
- * The stock confidence model: how framework's old matcher scored a domain
3
- * pattern's match (`core/pattern-matching/`), moved here as a `ConfidenceModel`
4
- * for the matcher's skeleton (domains rethink Phase 4.7); the old matcher calls
5
- * it too until Phase 6.
2
+ * The stock confidence model: how framework's first matcher scored a domain
3
+ * pattern's match, moved here as a `ConfidenceModel` for the matcher's
4
+ * skeleton (domains rethink Phase 4.7).
6
5
  *
7
6
  * Role coverage (a required slot 1, an optional one 0.8, an absent optional one
8
7
  * with a default 60% of that), a stem-match penalty (up to 15%, floor 0.5), and
@@ -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) {
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Reading a domain value at a role slot: the value reading framework's old
3
- * matcher (`core/pattern-matching/`) did, moved here so the stock reader on the
4
- * matcher's skeleton (`stock-reader.ts`, domains rethink Phase 4.7) and the old
5
- * matcher run the same code until Phase 6 deletes the old one.
2
+ * Reading a domain value at a role slot: the value reading framework's first
3
+ * matcher did, moved here for the stock reader on the matcher's skeleton
4
+ * (`stock-reader.ts`, domains rethink Phase 4.7). The first matcher was
5
+ * deleted in 5.0 (Phase 6).
6
6
  *
7
7
  * In order: an English noise word before a selector (`the`), then the
8
8
  * multi-token forms (a possessive, `my value`; a method call,
@@ -22,8 +22,8 @@ import type {
22
22
  LanguageToken,
23
23
  } from '../types';
24
24
  import { createSelector, createLiteral, createReference, createPropertyPath } from '../types';
25
- import { isTypeCompatible } from '../pattern-matching/utils/type-validation';
26
- import { getPossessiveReference } from '../pattern-matching/utils/possessive-keywords';
25
+ import { isTypeCompatible } from './type-validation';
26
+ import { getPossessiveReference } from './possessive-keywords';
27
27
 
28
28
  /** What value reading needs of a language profile: its code, and its possessive words. */
29
29
  export interface ReadingProfile {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Type Validation Tests
3
3
  *
4
- * Tests the type compatibility and validation utilities used by PatternMatcher.
4
+ * Tests the type compatibility and validation utilities the stock reader uses.
5
5
  */
6
6
 
7
7
  import { describe, it, expect } from 'vitest';
@@ -17,7 +17,7 @@ import {
17
17
  isVariableRef,
18
18
  isBuiltInReference,
19
19
  } from './type-validation';
20
- import type { SemanticValue } from '../../types';
20
+ import type { SemanticValue } from '../types';
21
21
 
22
22
  describe('Type Validation', () => {
23
23
  describe('isTypeCompatible', () => {
@@ -5,7 +5,7 @@
5
5
  * Used by pattern-matcher to validate captured role values.
6
6
  */
7
7
 
8
- import type { SemanticValue } from '../../types';
8
+ import type { SemanticValue } from '../types';
9
9
 
10
10
  /**
11
11
  * Semantic value type strings.
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
@@ -12,7 +12,7 @@ import { createDomainRenderer, createSchemaRenderer } from './renderer';
12
12
  import type { SemanticNode } from '../core/types';
13
13
  import type { CommandSchema } from '../schema';
14
14
  import { defineCommand, defineRole } from '../schema';
15
- import type { PatternGenLanguageProfile } from './pattern-generator';
15
+ import type { PatternGenLanguageProfile } from './profile';
16
16
 
17
17
  function makeNode(action: string, roles: Record<string, string>): SemanticNode {
18
18
  const rolesMap = new Map<string, { type: 'expression'; raw: string }>();