@lokascript/framework 4.6.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 (116) hide show
  1. package/CHANGELOG.md +121 -1
  2. package/dist/api/create-dsl.d.ts +18 -2
  3. package/dist/api/create-dsl.d.ts.map +1 -1
  4. package/dist/api/index.d.ts +1 -0
  5. package/dist/api/index.d.ts.map +1 -1
  6. package/dist/api/index.js +1799 -713
  7. package/dist/api/index.js.map +1 -1
  8. package/dist/api/translation-loss.d.ts +84 -0
  9. package/dist/api/translation-loss.d.ts.map +1 -0
  10. package/dist/core/index.d.ts +1 -0
  11. package/dist/core/index.d.ts.map +1 -1
  12. package/dist/core/index.js +1057 -684
  13. package/dist/core/index.js.map +1 -1
  14. package/dist/core/matching/confidence-model.d.ts +54 -0
  15. package/dist/core/matching/confidence-model.d.ts.map +1 -0
  16. package/dist/core/matching/index.d.ts +12 -0
  17. package/dist/core/matching/index.d.ts.map +1 -0
  18. package/dist/core/matching/match-skeleton.d.ts +114 -0
  19. package/dist/core/matching/match-skeleton.d.ts.map +1 -0
  20. package/dist/core/matching/stock-confidence.d.ts +15 -0
  21. package/dist/core/matching/stock-confidence.d.ts.map +1 -0
  22. package/dist/core/matching/stock-reader.d.ts +63 -0
  23. package/dist/core/matching/stock-reader.d.ts.map +1 -0
  24. package/dist/core/matching/stock-reading.d.ts +44 -0
  25. package/dist/core/matching/stock-reading.d.ts.map +1 -0
  26. package/dist/core/matching/value-reader.d.ts +62 -0
  27. package/dist/core/matching/value-reader.d.ts.map +1 -0
  28. package/dist/core/pattern-matching/index.js +561 -660
  29. package/dist/core/pattern-matching/index.js.map +1 -1
  30. package/dist/core/pattern-matching/pattern-matcher.d.ts +0 -117
  31. package/dist/core/pattern-matching/pattern-matcher.d.ts.map +1 -1
  32. package/dist/core/types.d.ts +53 -0
  33. package/dist/core/types.d.ts.map +1 -1
  34. package/dist/core/types.js.map +1 -1
  35. package/dist/generation/index.d.ts +2 -0
  36. package/dist/generation/index.d.ts.map +1 -1
  37. package/dist/generation/index.js +593 -1
  38. package/dist/generation/index.js.map +1 -1
  39. package/dist/generation/pattern-render.d.ts +72 -0
  40. package/dist/generation/pattern-render.d.ts.map +1 -0
  41. package/dist/generation/schema-patterns.d.ts +152 -0
  42. package/dist/generation/schema-patterns.d.ts.map +1 -0
  43. package/dist/index.cjs +2016 -904
  44. package/dist/index.cjs.map +1 -1
  45. package/dist/index.js +1998 -903
  46. package/dist/index.js.map +1 -1
  47. package/dist/ir/index.js.map +1 -1
  48. package/dist/parsing/index.js +561 -660
  49. package/dist/parsing/index.js.map +1 -1
  50. package/dist/testing/gate/index.d.ts +8 -0
  51. package/dist/testing/gate/index.d.ts.map +1 -0
  52. package/dist/testing/gate/index.js +664 -0
  53. package/dist/testing/gate/index.js.map +1 -0
  54. package/dist/testing/gate/lint/format.d.ts +7 -0
  55. package/dist/testing/gate/lint/format.d.ts.map +1 -0
  56. package/dist/testing/gate/lint/index.d.ts +30 -0
  57. package/dist/testing/gate/lint/index.d.ts.map +1 -0
  58. package/dist/testing/gate/lint/rules/extractor-coverage.d.ts +18 -0
  59. package/dist/testing/gate/lint/rules/extractor-coverage.d.ts.map +1 -0
  60. package/dist/testing/gate/lint/rules/index.d.ts +14 -0
  61. package/dist/testing/gate/lint/rules/index.d.ts.map +1 -0
  62. package/dist/testing/gate/lint/rules/keyword-classification.d.ts +19 -0
  63. package/dist/testing/gate/lint/rules/keyword-classification.d.ts.map +1 -0
  64. package/dist/testing/gate/lint/rules/keyword-coverage.d.ts +13 -0
  65. package/dist/testing/gate/lint/rules/keyword-coverage.d.ts.map +1 -0
  66. package/dist/testing/gate/lint/rules/marker-tokenization.d.ts +20 -0
  67. package/dist/testing/gate/lint/rules/marker-tokenization.d.ts.map +1 -0
  68. package/dist/testing/gate/lint/rules/position-ordering.d.ts +25 -0
  69. package/dist/testing/gate/lint/rules/position-ordering.d.ts.map +1 -0
  70. package/dist/testing/gate/lint/rules/renderer-coherence.d.ts +24 -0
  71. package/dist/testing/gate/lint/rules/renderer-coherence.d.ts.map +1 -0
  72. package/dist/testing/gate/lint/rules/schema-rules.d.ts +10 -0
  73. package/dist/testing/gate/lint/rules/schema-rules.d.ts.map +1 -0
  74. package/dist/testing/gate/lint/types.d.ts +74 -0
  75. package/dist/testing/gate/lint/types.d.ts.map +1 -0
  76. package/dist/testing/gate/round-trip.d.ts +184 -0
  77. package/dist/testing/gate/round-trip.d.ts.map +1 -0
  78. package/package.json +6 -2
  79. package/src/__test__/fixtures/sql-dsl.ts +8 -2
  80. package/src/api/create-dsl-engine.test.ts +150 -0
  81. package/src/api/create-dsl.test.ts +7 -0
  82. package/src/api/create-dsl.ts +173 -22
  83. package/src/api/index.ts +1 -0
  84. package/src/api/translation-loss.test.ts +89 -0
  85. package/src/api/translation-loss.ts +128 -0
  86. package/src/core/index.ts +1 -0
  87. package/src/core/matching/confidence-model.ts +58 -0
  88. package/src/core/matching/index.ts +12 -0
  89. package/src/core/matching/match-skeleton.test.ts +179 -0
  90. package/src/core/matching/match-skeleton.ts +621 -0
  91. package/src/core/matching/stock-confidence.ts +238 -0
  92. package/src/core/matching/stock-reader.test.ts +265 -0
  93. package/src/core/matching/stock-reader.ts +205 -0
  94. package/src/core/matching/stock-reading.ts +681 -0
  95. package/src/core/matching/value-reader.ts +85 -0
  96. package/src/core/pattern-matching/pattern-matcher.ts +11 -876
  97. package/src/core/types.ts +53 -0
  98. package/src/generation/index.ts +2 -0
  99. package/src/generation/pattern-render.ts +378 -0
  100. package/src/generation/schema-patterns.test.ts +139 -0
  101. package/src/generation/schema-patterns.ts +974 -0
  102. package/src/testing/gate/index.ts +8 -0
  103. package/src/testing/gate/lint/format.ts +23 -0
  104. package/src/testing/gate/lint/index.ts +73 -0
  105. package/src/testing/gate/lint/lint.test.ts +131 -0
  106. package/src/testing/gate/lint/rules/extractor-coverage.ts +63 -0
  107. package/src/testing/gate/lint/rules/index.ts +37 -0
  108. package/src/testing/gate/lint/rules/keyword-classification.ts +52 -0
  109. package/src/testing/gate/lint/rules/keyword-coverage.ts +38 -0
  110. package/src/testing/gate/lint/rules/marker-tokenization.ts +129 -0
  111. package/src/testing/gate/lint/rules/position-ordering.ts +87 -0
  112. package/src/testing/gate/lint/rules/renderer-coherence.ts +100 -0
  113. package/src/testing/gate/lint/rules/schema-rules.ts +35 -0
  114. package/src/testing/gate/lint/types.ts +83 -0
  115. package/src/testing/gate/round-trip.test.ts +208 -0
  116. package/src/testing/gate/round-trip.ts +503 -0
package/src/core/types.ts CHANGED
@@ -140,6 +140,12 @@ export interface LanguagePattern {
140
140
  readonly template: PatternTemplate;
141
141
  readonly extraction: ExtractionRules;
142
142
  readonly constraints?: PatternConstraints;
143
+ /**
144
+ * The pattern reads a form the renderer does not write by choosing it
145
+ * (hyperscript's wait `esperar evento {event}`: the renderer writes the event
146
+ * word itself, before a name no table knows). The renderer skips it.
147
+ */
148
+ readonly parseOnly?: boolean;
143
149
  }
144
150
 
145
151
  export interface PatternTemplate {
@@ -153,6 +159,13 @@ export interface LiteralPatternToken {
153
159
  readonly type: 'literal';
154
160
  readonly value: string;
155
161
  readonly alternatives?: string[];
162
+ /**
163
+ * Parse-only marker: the matcher still consumes this literal, but the
164
+ * renderer skips it. Set when a role declares `renderOverride: { <lang>: '' }`
165
+ * (hyperscript's fetch `from`: required to PARSE `fetch from /api`, absent from
166
+ * the render, since `fetch from "/api"` is not canonical _hyperscript).
167
+ */
168
+ readonly renderSuppress?: boolean;
156
169
  }
157
170
 
158
171
  export interface RolePatternToken {
@@ -161,12 +174,33 @@ export interface RolePatternToken {
161
174
  readonly optional?: boolean;
162
175
  readonly expectedTypes?: Array<ExpectedType>;
163
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[];
182
+ /**
183
+ * Mirrors the schema's `RoleSpec.valueShape`: the slot's surface form is
184
+ * unambiguous on its own. Carried onto the pattern token because the value
185
+ * reader needs it too, not just the confidence model (hyperscript's
186
+ * `'keyword'` slots hold a fixed keyword phrase and are exempt from the
187
+ * trailing-slot verb guard).
188
+ */
189
+ readonly valueShape?: 'time' | 'reference' | 'keyword' | 'object' | 'clause' | 'list';
164
190
  }
165
191
 
166
192
  export interface GroupPatternToken {
167
193
  readonly type: 'group';
168
194
  readonly tokens: PatternToken[];
169
195
  readonly optional?: boolean;
196
+ /**
197
+ * The group is optional for PARSING but belongs in canonical output: the
198
+ * marker-only groups a profile's optional case markers produce (Turkish case
199
+ * suffixes may be dropped colloquially, so the parse accepts both forms).
200
+ * The matcher's skeleton also requires such a group's marker after a role it
201
+ * captures (`MatchSkeleton`).
202
+ */
203
+ readonly renderRequired?: boolean;
170
204
  }
171
205
 
172
206
  export interface ExtractionRules {
@@ -180,6 +214,11 @@ export interface ExtractionRule {
180
214
  readonly transform?: (raw: string) => SemanticValue;
181
215
  readonly default?: SemanticValue;
182
216
  readonly value?: string;
217
+ /**
218
+ * The static `value` is an expression, as the English parse types the word
219
+ * (hyperscript's `go back` destination), not a literal.
220
+ */
221
+ readonly valueIsExpression?: true;
183
222
  readonly fromRole?: string;
184
223
  }
185
224
 
@@ -195,6 +234,20 @@ export interface PatternMatchResult {
195
234
  readonly captured: ReadonlyMap<SemanticRole, SemanticValue>;
196
235
  readonly consumedTokens: number;
197
236
  readonly confidence: number;
237
+ /**
238
+ * Stream index at which each captured role's value STARTED, for callers that
239
+ * need to re-read a slot's own span rather than the tail after it. Populated
240
+ * on the same success path as `captured` (`MatchSkeleton`), so a role left
241
+ * behind by a rolled-back group survives in both maps or in neither.
242
+ */
243
+ readonly roleStarts?: ReadonlyMap<SemanticRole, number>;
244
+ /**
245
+ * A source phrase hyperscript's reader found in an event HEAD (`from #btn`
246
+ * right after the event) rather than in a pattern slot: kept out of
247
+ * `captured`, so a fused pattern's own source group can neither overwrite it
248
+ * nor be overwritten (`ValueReader.resultFields`).
249
+ */
250
+ readonly eventSource?: SemanticValue;
198
251
  }
199
252
 
200
253
  export interface PatternMatchError {
@@ -5,3 +5,5 @@
5
5
  export * from './pattern-generator';
6
6
  export * from './renderer';
7
7
  export * from './diagnostics';
8
+ export * from './schema-patterns';
9
+ export * from './pattern-render';
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Rendering a command through a language's own patterns: the renderer core
3
+ * semantic's hyperscript renderer runs (domains rethink Phase 4.5,
4
+ * `docs-internal/DOMAINS_RETHINK.md`), moved here from `@lokascript/semantic`.
5
+ *
6
+ * A node renders with the pattern its parser would read it back through:
7
+ * `selectRenderPattern` scores a language's patterns for the node (the roles it
8
+ * carries, the slots each pattern has, the values a pattern pins), and
9
+ * `renderPattern` walks the chosen pattern's tokens. What a value looks like in
10
+ * the language, and the grammar's preferences, are `PatternRenderHooks`.
11
+ * (The domains' `renderer.ts` renders from the schema instead, for
12
+ * `createMultilingualDSL`.)
13
+ */
14
+
15
+ import type {
16
+ ExtractionRule,
17
+ GroupPatternToken,
18
+ LanguagePattern,
19
+ PatternToken,
20
+ RolePatternToken,
21
+ SemanticNode,
22
+ } from '../core/types';
23
+
24
+ /**
25
+ * What the core reads of a node: its roles. A grammar's own node type (semantic's
26
+ * `SemanticNode`) qualifies as it is; the hooks receive it unchanged.
27
+ */
28
+ export interface RenderableNode {
29
+ readonly roles: ReadonlyMap<string, { readonly type: string }>;
30
+ }
31
+
32
+ /** A grammar's part of rendering through patterns, for its node type `N`. */
33
+ export interface PatternRenderHooks<N extends RenderableNode = SemanticNode> {
34
+ /**
35
+ * The patterns a node may render with, from those that are not parse-only
36
+ * (hyperscript: an event handler renders only with a trigger pattern, never a
37
+ * fused `<command>-event-*` one).
38
+ */
39
+ candidates?(node: N, patterns: readonly LanguagePattern[]): readonly LanguagePattern[];
40
+ /** Whether the node carries `role` as written, not as a default its parser filled in. */
41
+ hasRealRole(node: N, role: string): boolean;
42
+ /** The grammar's own preference between patterns, added to the score. */
43
+ adjustScore?(pattern: LanguagePattern, node: N): number;
44
+ /**
45
+ * Text for token `i` and the tokens after it it covers, when the grammar
46
+ * writes them as one (hyperscript: a reference in its case, es `a mí`);
47
+ * undefined renders the token as usual.
48
+ */
49
+ span?(
50
+ tokens: readonly PatternToken[],
51
+ i: number,
52
+ node: N,
53
+ language: string,
54
+ topLevel: boolean
55
+ ): { text: string; tokens: number } | undefined;
56
+ /** A role slot's text; null writes nothing. */
57
+ writeRole(token: RolePatternToken, node: N, language: string): string | null;
58
+ /**
59
+ * Whether an optional group whose required roles the node carries is written
60
+ * (hyperscript: not for the implicit `me` its parser filled in). Default yes.
61
+ */
62
+ writeGroup?(token: GroupPatternToken, node: N, language: string): boolean;
63
+ /**
64
+ * Add a token's text to `parts` (hyperscript: a marker written onto its
65
+ * word, ko `#out에`). Default: a part of its own.
66
+ */
67
+ pushPart?(
68
+ parts: string[],
69
+ text: string,
70
+ token: PatternToken,
71
+ previous: PatternToken | undefined,
72
+ node: N,
73
+ language: string
74
+ ): void;
75
+ /** Before top-level token `i` (hyperscript: a scroll's direction word before its amount). */
76
+ beforeToken?(
77
+ tokens: readonly PatternToken[],
78
+ i: number,
79
+ parts: string[],
80
+ node: N,
81
+ language: string
82
+ ): void;
83
+ /** After a top-level token wrote `parts[part]` (hyperscript: where the event went). */
84
+ afterToken?(token: PatternToken, part: number, node: N): void;
85
+ }
86
+
87
+ /**
88
+ * Score a role slot nested inside an OPTIONAL group. Lower than the top-level
89
+ * bonus on purpose: it must break a tie between two patterns that are otherwise
90
+ * equal (the handcrafted `toggle-{L}-full` versus the generated pattern that
91
+ * also carries `[{duration}]`) without ever outweighing a top-level difference.
92
+ */
93
+ const NESTED_ROLE_BONUS = 5;
94
+
95
+ /**
96
+ * Score against a pattern that has no slot for a role the node carries. Larger
97
+ * than any priority gap between a command's patterns (80–100), so rendering
98
+ * the role always beats a more idiomatic form that drops it.
99
+ */
100
+ const UNCOVERED_ROLE_PENALTY = 30;
101
+
102
+ /**
103
+ * The literal a pattern pins a role to, normalized for comparison: an
104
+ * extraction default, or a fixed value the pattern records when it matches.
105
+ * The second is how `go`'s url variant says what it means (`go url /page`
106
+ * records `method: 'url'`); reading only defaults let that variant render every
107
+ * `go`, so `go back` came out as `go url back`: a navigation to a page named
108
+ * "back" in every language.
109
+ */
110
+ function pinnedLiteral(rule: ExtractionRule | undefined): string | undefined {
111
+ if (rule?.default?.type === 'literal') return String(rule.default.value).trim().toLowerCase();
112
+ if (rule?.value !== undefined) return String(rule.value).trim().toLowerCase();
113
+ return undefined;
114
+ }
115
+
116
+ /**
117
+ * The pattern to render `node` with, or null when there is none.
118
+ */
119
+ export function selectRenderPattern<N extends RenderableNode>(
120
+ node: N,
121
+ patterns: readonly LanguagePattern[],
122
+ hooks: PatternRenderHooks<N>
123
+ ): LanguagePattern | null {
124
+ let candidates: readonly LanguagePattern[] = patterns.filter(pattern => !pattern.parseOnly);
125
+ if (candidates.length === 0) candidates = patterns;
126
+ if (hooks.candidates) candidates = hooks.candidates(node, candidates);
127
+
128
+ // Which literal values does the candidate set pin, per role? A value that is
129
+ // pinned by SOME candidate is a real alternative the pattern set distinguishes
130
+ // (put's manner: before / after / at end of), so choosing a pattern pinned to a
131
+ // different one would silently change meaning. A value nothing pins is not an
132
+ // alternative — it is just the value that came out of the parse — and must not
133
+ // disqualify anything. That distinction is what keeps `repeat until event X`
134
+ // renderable: a zh parse yields loopType `until` while the English pattern that
135
+ // carries the event pins `until-event`, and nothing pins bare `until`.
136
+ const pinnedValues = new Map<string, Set<string>>();
137
+ for (const pattern of candidates) {
138
+ for (const [role, rule] of Object.entries(pattern.extraction ?? {})) {
139
+ const key = pinnedLiteral(rule);
140
+ if (key === undefined) continue;
141
+ const set = pinnedValues.get(role) ?? new Set<string>();
142
+ set.add(key);
143
+ pinnedValues.set(role, set);
144
+ }
145
+ }
146
+
147
+ // Score patterns by how well they match our roles
148
+ const scored = candidates.map(pattern => {
149
+ let score = pattern.priority;
150
+
151
+ // Check each role token in the pattern, INCLUDING the ones nested inside
152
+ // optional groups. Scoring only the top level made a slot the pattern
153
+ // really has invisible: every `[for {duration}]` / `[with {style}]` group
154
+ // lives one level down, so a handcrafted pattern without the slot tied
155
+ // with the generated one that has it and won on order — `toggle .loading
156
+ // for 2s` rendered as `alternar .loading` in es/it/pl/ru/uk/vi/zh, the
157
+ // duration dropped in silence.
158
+ //
159
+ // A role inside a group is scored as PRESENT-only: its absence is what
160
+ // "optional" means, and the group's own role tokens are not consistently
161
+ // flagged `optional` (a handcrafted `[en {destination}]` writes the role
162
+ // bare), so reading the flag there would apply the missing-role penalty to
163
+ // a slot that is optional by construction.
164
+ const hasRealRole = (role: string): boolean => hooks.hasRealRole(node, role);
165
+ const scoreTokens = (tokens: readonly PatternToken[], inGroup: boolean): void => {
166
+ for (const token of tokens) {
167
+ if (token.type === 'group') {
168
+ scoreTokens(token.tokens, true);
169
+ } else if (token.type === 'role') {
170
+ if (hasRealRole(token.role)) {
171
+ // Bonus for patterns that use roles we have. A nested slot scores
172
+ // LESS than a top-level one, so carrying an extra optional slot
173
+ // breaks a tie without ever outweighing a top-level difference —
174
+ // measured: at equal weight it also re-ordered the pl behavior
175
+ // handler and three qu positional rows.
176
+ score += inGroup ? NESTED_ROLE_BONUS : 10;
177
+ } else if (!inGroup && !token.optional) {
178
+ // Heavy penalty for patterns that require roles we DON'T have
179
+ // This prevents selecting "source" patterns when there's no source
180
+ score -= 50;
181
+ }
182
+ }
183
+ }
184
+ };
185
+ scoreTokens(pattern.template.tokens, false);
186
+
187
+ // A pattern with no slot for a role the node really carries drops it in
188
+ // silence, so it loses to one that renders it. The bonuses above only
189
+ // reward coverage, which let a slot-less pattern win on a top-level-vs-
190
+ // nested difference: once show's patient became optional, zh `显示
191
+ // {patient}` beat the generated `显示 [把 {patient}] [用 {style}]` and
192
+ // `show #modal with *opacity` rendered without its strategy (bn/hi/zh).
193
+ const slots = new Set<string>();
194
+ const collectSlots = (tokens: readonly PatternToken[]): void => {
195
+ for (const token of tokens) {
196
+ if (token.type === 'group') collectSlots(token.tokens);
197
+ else if (token.type === 'role') slots.add(token.role);
198
+ }
199
+ };
200
+ collectSlots(pattern.template.tokens);
201
+ for (const role of node.roles.keys()) {
202
+ if (hasRealRole(role) && !slots.has(role)) score -= UNCOVERED_ROLE_PENALTY;
203
+ }
204
+
205
+ // Value-pinned variants. A positional pattern (`put X before Y`, `at end
206
+ // of`, `after`) carries its position as a baked-in literal plus an
207
+ // extraction default that records which value it means:
208
+ // put-es-before -> extraction.manner.default = 'before'
209
+ // put-es-at-end -> extraction.manner.default = 'at end of'
210
+ // while the neutral `put-es-full` pins nothing. So the pattern set already
211
+ // says which surface each value selects; scoring just has to read it.
212
+ //
213
+ // The rule: a pattern that pins a role to a literal may be chosen ONLY when
214
+ // the node carries that exact value. Matching is a strong preference (the
215
+ // pinned form is the whole reason the value exists); mismatching — or the
216
+ // node not carrying the role at all — disqualifies it, so a plain
217
+ // `put X into Y` can never render as `poner X en fin de Y`.
218
+ //
219
+ // This replaces an id-regex that penalized every `-at-end|-before|-after`
220
+ // pattern unconditionally. That kept plain puts safe but made the positional
221
+ // forms unreachable: `put "<p>" before me` rendered with the into-pattern in
222
+ // all 23 languages, losing the distinction the source drew.
223
+ for (const [role, rule] of Object.entries(pattern.extraction ?? {})) {
224
+ const pinnedKey = pinnedLiteral(rule);
225
+ if (pinnedKey === undefined) continue;
226
+ const actual = node.roles.get(role);
227
+ // The value as the pattern would produce it: a literal, or an
228
+ // expression where the rule says so (`go back`).
229
+ const actualKey =
230
+ actual?.type === (rule.valueIsExpression ? 'expression' : 'literal')
231
+ ? String(
232
+ actual.type === 'expression'
233
+ ? (actual as { raw?: unknown }).raw
234
+ : (actual as { value?: unknown }).value
235
+ )
236
+ .trim()
237
+ .toLowerCase()
238
+ : undefined;
239
+
240
+ if (actualKey === pinnedKey) {
241
+ // The pinned form is the whole reason this value exists — prefer it
242
+ // decisively over the neutral pattern, whatever the parse priorities say.
243
+ score += 60;
244
+ } else if (actualKey === undefined || pinnedValues.get(role)?.has(actualKey)) {
245
+ // Either the node has no such value (so this pattern would invent one:
246
+ // a plain `put X into Y` must never render as `put X at end of Y`), or it
247
+ // has a DIFFERENT value that the pattern set treats as an alternative.
248
+ // Both are wrong surfaces; disqualify.
249
+ score -= 200;
250
+ }
251
+ // Otherwise the node's value is not one of the pinned alternatives, so this
252
+ // pattern is still the best available carrier for it — leave the score alone
253
+ // and let priority and role coverage decide.
254
+ }
255
+
256
+ score += hooks.adjustScore?.(pattern, node) ?? 0;
257
+
258
+ return { pattern, score };
259
+ });
260
+
261
+ scored.sort((a, b) => b.score - a.score);
262
+
263
+ return scored.length > 0 ? scored[0]!.pattern : null;
264
+ }
265
+
266
+ /**
267
+ * The parts `node` renders to through `pattern`'s tokens, in order: each
268
+ * literal (unless parse-only), each role's text, each group that is written.
269
+ * The grammar joins them (and adds what the pattern does not model).
270
+ */
271
+ export function renderPattern<N extends RenderableNode>(
272
+ node: N,
273
+ pattern: LanguagePattern,
274
+ hooks: PatternRenderHooks<N>
275
+ ): string[] {
276
+ const parts: string[] = [];
277
+ const language = pattern.language;
278
+ const tokens = pattern.template.tokens;
279
+ for (let i = 0; i < tokens.length; i++) {
280
+ hooks.beforeToken?.(tokens, i, parts, node, language);
281
+ const span = hooks.span?.(tokens, i, node, language, true);
282
+ if (span !== undefined) {
283
+ parts.push(span.text);
284
+ i += span.tokens - 1;
285
+ continue;
286
+ }
287
+ const token = tokens[i]!;
288
+ const rendered = renderPatternToken(token, node, language, hooks);
289
+ if (rendered !== null) {
290
+ pushPart(hooks, parts, rendered, token, tokens[i - 1], node, language);
291
+ hooks.afterToken?.(token, parts.length - 1, node);
292
+ }
293
+ }
294
+ return parts;
295
+ }
296
+
297
+ function pushPart<N extends RenderableNode>(
298
+ hooks: PatternRenderHooks<N>,
299
+ parts: string[],
300
+ text: string,
301
+ token: PatternToken,
302
+ previous: PatternToken | undefined,
303
+ node: N,
304
+ language: string
305
+ ): void {
306
+ if (hooks.pushPart) hooks.pushPart(parts, text, token, previous, node, language);
307
+ else parts.push(text);
308
+ }
309
+
310
+ /**
311
+ * Render a single pattern token.
312
+ */
313
+ function renderPatternToken<N extends RenderableNode>(
314
+ token: PatternToken,
315
+ node: N,
316
+ language: string,
317
+ hooks: PatternRenderHooks<N>
318
+ ): string | null {
319
+ switch (token.type) {
320
+ case 'literal':
321
+ // Parse-only markers (renderSuppress) consume input but never render —
322
+ // e.g. fetch's `from`, which parses `fetch from /api` yet must be absent
323
+ // from output (`fetch from "/api"` is invalid canonical _hyperscript).
324
+ return token.renderSuppress ? null : token.value;
325
+
326
+ case 'role':
327
+ return hooks.writeRole(token, node, language);
328
+
329
+ case 'group': {
330
+ // Check if we have all required roles in the group
331
+ const hasRequired = token.tokens
332
+ .filter(t => t.type === 'role' && !t.optional)
333
+ .every(t => node.roles.has((t as RolePatternToken).role));
334
+
335
+ if (!hasRequired && token.optional) {
336
+ return null;
337
+ }
338
+
339
+ if (hooks.writeGroup && !hooks.writeGroup(token, node, language)) return null;
340
+
341
+ const groupParts: string[] = [];
342
+ let hasRoleValue = false;
343
+ for (let i = 0; i < token.tokens.length; i++) {
344
+ const span = hooks.span?.(token.tokens, i, node, language, false);
345
+ if (span !== undefined) {
346
+ groupParts.push(span.text);
347
+ hasRoleValue = true;
348
+ i += span.tokens - 1;
349
+ continue;
350
+ }
351
+ const subToken = token.tokens[i]!;
352
+ const rendered = renderPatternToken(subToken, node, language, hooks);
353
+ if (rendered !== null) {
354
+ pushPart(hooks, groupParts, rendered, subToken, token.tokens[i - 1], node, language);
355
+ if (subToken.type === 'role') hasRoleValue = true;
356
+ }
357
+ }
358
+
359
+ // Don't emit an optional group that DECLARES a role slot and did not
360
+ // fill it — e.g. don't emit a dangling "with" when the style role is
361
+ // absent from "hide #output".
362
+ //
363
+ // …unless the group is `renderRequired`: a MARKER wrapped on its own by
364
+ // `profile.markersOptional` (tr, where the case suffix may be dropped
365
+ // colloquially, so the parse side has to accept both forms). Dropping
366
+ // those cost tr every role marker in every rendered command —
367
+ // `add .selected to #item` came out `#item .selected ekle` where the
368
+ // corpus has `#item e .selected i ekle`, and the marker-less surface
369
+ // re-parses as a one-role `add` with the destination defaulted to `me`.
370
+ if (token.optional && !hasRoleValue && !token.renderRequired) return null;
371
+
372
+ return groupParts.length > 0 ? groupParts.join(' ') : null;
373
+ }
374
+
375
+ default:
376
+ return null;
377
+ }
378
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The schema-pattern generator semantic's hyperscript parser runs (domains
3
+ * rethink Phase 4.4), on a schema of its own: the grammar's position order,
4
+ * the variants, a profile's optional markers, and a role's word list.
5
+ */
6
+ import { describe, expect, it } from 'vitest';
7
+ import { defineCommand, defineRole } from '../schema';
8
+ import type { PatternToken } from '../core/types';
9
+ import {
10
+ createSchemaPatternGenerator,
11
+ withoutVariantOnlyRoles,
12
+ type SchemaPatternProfile,
13
+ } from './schema-patterns';
14
+
15
+ const send = defineCommand({
16
+ action: 'send',
17
+ primaryRole: 'patient',
18
+ roles: [
19
+ defineRole({ role: 'patient', required: true, expectedTypes: ['expression'], svoPosition: 1 }),
20
+ defineRole({
21
+ role: 'destination',
22
+ required: false,
23
+ expectedTypes: ['selector'],
24
+ svoPosition: 2,
25
+ sovPosition: 1,
26
+ }),
27
+ ],
28
+ });
29
+
30
+ const en: SchemaPatternProfile = {
31
+ code: 'en',
32
+ wordOrder: 'SVO',
33
+ keywords: { send: { primary: 'send' } },
34
+ roleMarkers: { destination: { primary: 'to', position: 'before' } },
35
+ };
36
+
37
+ const tr: SchemaPatternProfile = {
38
+ code: 'tr',
39
+ wordOrder: 'SOV',
40
+ keywords: { send: { primary: 'gönder' } },
41
+ roleMarkers: { destination: { primary: 'e', position: 'after' } },
42
+ markersOptional: true,
43
+ };
44
+
45
+ /** A pattern's tokens as text: literals as themselves, roles as {role}, groups in brackets. */
46
+ function shape(tokens: readonly PatternToken[]): string {
47
+ return tokens
48
+ .map(t =>
49
+ t.type === 'literal'
50
+ ? t.value
51
+ : t.type === 'role'
52
+ ? `{${t.role}}`
53
+ : `[${shape(t.tokens)}${'renderRequired' in t && t.renderRequired ? '!' : ''}]`
54
+ )
55
+ .join(' ');
56
+ }
57
+
58
+ describe('createSchemaPatternGenerator', () => {
59
+ it("sorts roles in the grammar's position order", () => {
60
+ const ascending = createSchemaPatternGenerator({ positionOrder: 'ascending' });
61
+ const descending = createSchemaPatternGenerator({ positionOrder: 'descending' });
62
+ expect(shape(ascending.generatePattern(send, en).template.tokens)).toBe(
63
+ 'send {patient} [to {destination}]'
64
+ );
65
+ expect(shape(descending.generatePattern(send, en).template.tokens)).toBe(
66
+ 'send [to {destination}] {patient}'
67
+ );
68
+ });
69
+
70
+ it('builds the simple and SOV verb-first variants', () => {
71
+ const generator = createSchemaPatternGenerator({ positionOrder: 'ascending' });
72
+ const variants = generator.generatePatternVariants(send, tr, {
73
+ basePriority: 100,
74
+ generateSimpleVariants: true,
75
+ });
76
+ expect(variants.map(p => [p.id, p.priority, shape(p.template.tokens)])).toEqual([
77
+ ['send-tr-generated', 100, '[{destination} [e!]] {patient} gönder'],
78
+ ['send-tr-generated-simple', 95, '{patient} gönder'],
79
+ ['send-tr-generated-verb-first', 80, 'gönder {patient}'],
80
+ ]);
81
+ });
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
+
123
+ it('drops variant-only roles', () => {
124
+ const scroll = defineCommand({
125
+ action: 'scroll',
126
+ roles: [
127
+ defineRole({ role: 'patient', required: true, expectedTypes: ['selector'] }),
128
+ defineRole({
129
+ role: 'quantity',
130
+ required: false,
131
+ expectedTypes: ['literal'],
132
+ variantOnly: true,
133
+ }),
134
+ ],
135
+ });
136
+ expect(withoutVariantOnlyRoles(scroll).roles.map(r => r.role)).toEqual(['patient']);
137
+ expect(withoutVariantOnlyRoles(send)).toBe(send);
138
+ });
139
+ });