@lokascript/framework 4.5.0 → 4.7.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 (123) hide show
  1. package/CHANGELOG.md +220 -8
  2. package/dist/api/create-dsl.d.ts +59 -1
  3. package/dist/api/create-dsl.d.ts.map +1 -1
  4. package/dist/api/domain-registry.d.ts.map +1 -1
  5. package/dist/api/index.d.ts +1 -0
  6. package/dist/api/index.d.ts.map +1 -1
  7. package/dist/api/index.js +1938 -724
  8. package/dist/api/index.js.map +1 -1
  9. package/dist/api/reading.d.ts +8 -0
  10. package/dist/api/reading.d.ts.map +1 -0
  11. package/dist/api/translation-loss.d.ts +84 -0
  12. package/dist/api/translation-loss.d.ts.map +1 -0
  13. package/dist/core/index.d.ts +1 -0
  14. package/dist/core/index.d.ts.map +1 -1
  15. package/dist/core/index.js +1047 -684
  16. package/dist/core/index.js.map +1 -1
  17. package/dist/core/matching/confidence-model.d.ts +54 -0
  18. package/dist/core/matching/confidence-model.d.ts.map +1 -0
  19. package/dist/core/matching/index.d.ts +12 -0
  20. package/dist/core/matching/index.d.ts.map +1 -0
  21. package/dist/core/matching/match-skeleton.d.ts +114 -0
  22. package/dist/core/matching/match-skeleton.d.ts.map +1 -0
  23. package/dist/core/matching/stock-confidence.d.ts +15 -0
  24. package/dist/core/matching/stock-confidence.d.ts.map +1 -0
  25. package/dist/core/matching/stock-reader.d.ts +56 -0
  26. package/dist/core/matching/stock-reader.d.ts.map +1 -0
  27. package/dist/core/matching/stock-reading.d.ts +44 -0
  28. package/dist/core/matching/stock-reading.d.ts.map +1 -0
  29. package/dist/core/matching/value-reader.d.ts +62 -0
  30. package/dist/core/matching/value-reader.d.ts.map +1 -0
  31. package/dist/core/pattern-matching/index.js +561 -660
  32. package/dist/core/pattern-matching/index.js.map +1 -1
  33. package/dist/core/pattern-matching/pattern-matcher.d.ts +0 -117
  34. package/dist/core/pattern-matching/pattern-matcher.d.ts.map +1 -1
  35. package/dist/core/types.d.ts +48 -0
  36. package/dist/core/types.d.ts.map +1 -1
  37. package/dist/core/types.js.map +1 -1
  38. package/dist/generation/index.d.ts +2 -0
  39. package/dist/generation/index.d.ts.map +1 -1
  40. package/dist/generation/index.js +583 -1
  41. package/dist/generation/index.js.map +1 -1
  42. package/dist/generation/pattern-render.d.ts +72 -0
  43. package/dist/generation/pattern-render.d.ts.map +1 -0
  44. package/dist/generation/schema-patterns.d.ts +152 -0
  45. package/dist/generation/schema-patterns.d.ts.map +1 -0
  46. package/dist/index.cjs +2132 -890
  47. package/dist/index.cjs.map +1 -1
  48. package/dist/index.js +2110 -887
  49. package/dist/index.js.map +1 -1
  50. package/dist/ir/index.js.map +1 -1
  51. package/dist/parsing/index.js +561 -660
  52. package/dist/parsing/index.js.map +1 -1
  53. package/dist/testing/gate/index.d.ts +8 -0
  54. package/dist/testing/gate/index.d.ts.map +1 -0
  55. package/dist/testing/gate/index.js +632 -0
  56. package/dist/testing/gate/index.js.map +1 -0
  57. package/dist/testing/gate/lint/format.d.ts +7 -0
  58. package/dist/testing/gate/lint/format.d.ts.map +1 -0
  59. package/dist/testing/gate/lint/index.d.ts +30 -0
  60. package/dist/testing/gate/lint/index.d.ts.map +1 -0
  61. package/dist/testing/gate/lint/rules/extractor-coverage.d.ts +18 -0
  62. package/dist/testing/gate/lint/rules/extractor-coverage.d.ts.map +1 -0
  63. package/dist/testing/gate/lint/rules/index.d.ts +14 -0
  64. package/dist/testing/gate/lint/rules/index.d.ts.map +1 -0
  65. package/dist/testing/gate/lint/rules/keyword-classification.d.ts +19 -0
  66. package/dist/testing/gate/lint/rules/keyword-classification.d.ts.map +1 -0
  67. package/dist/testing/gate/lint/rules/keyword-coverage.d.ts +13 -0
  68. package/dist/testing/gate/lint/rules/keyword-coverage.d.ts.map +1 -0
  69. package/dist/testing/gate/lint/rules/marker-tokenization.d.ts +20 -0
  70. package/dist/testing/gate/lint/rules/marker-tokenization.d.ts.map +1 -0
  71. package/dist/testing/gate/lint/rules/position-ordering.d.ts +25 -0
  72. package/dist/testing/gate/lint/rules/position-ordering.d.ts.map +1 -0
  73. package/dist/testing/gate/lint/rules/renderer-coherence.d.ts +24 -0
  74. package/dist/testing/gate/lint/rules/renderer-coherence.d.ts.map +1 -0
  75. package/dist/testing/gate/lint/rules/schema-rules.d.ts +10 -0
  76. package/dist/testing/gate/lint/rules/schema-rules.d.ts.map +1 -0
  77. package/dist/testing/gate/lint/types.d.ts +74 -0
  78. package/dist/testing/gate/lint/types.d.ts.map +1 -0
  79. package/dist/testing/gate/round-trip.d.ts +163 -0
  80. package/dist/testing/gate/round-trip.d.ts.map +1 -0
  81. package/package.json +7 -3
  82. package/src/__test__/fixtures/sql-dsl.ts +11 -2
  83. package/src/__test__/sql-integration.test.ts +2 -2
  84. package/src/api/create-dsl-engine.test.ts +86 -0
  85. package/src/api/create-dsl.test.ts +120 -0
  86. package/src/api/create-dsl.ts +314 -39
  87. package/src/api/domain-registry.test.ts +70 -5
  88. package/src/api/domain-registry.ts +82 -1
  89. package/src/api/index.ts +1 -0
  90. package/src/api/reading.ts +29 -0
  91. package/src/api/translation-loss.test.ts +89 -0
  92. package/src/api/translation-loss.ts +128 -0
  93. package/src/core/index.ts +1 -0
  94. package/src/core/matching/confidence-model.ts +58 -0
  95. package/src/core/matching/index.ts +12 -0
  96. package/src/core/matching/match-skeleton.test.ts +179 -0
  97. package/src/core/matching/match-skeleton.ts +621 -0
  98. package/src/core/matching/stock-confidence.ts +238 -0
  99. package/src/core/matching/stock-reader.test.ts +181 -0
  100. package/src/core/matching/stock-reader.ts +186 -0
  101. package/src/core/matching/stock-reading.ts +681 -0
  102. package/src/core/matching/value-reader.ts +85 -0
  103. package/src/core/pattern-matching/pattern-matcher.ts +11 -876
  104. package/src/core/types.ts +48 -0
  105. package/src/generation/index.ts +2 -0
  106. package/src/generation/pattern-render.ts +378 -0
  107. package/src/generation/schema-patterns.test.ts +99 -0
  108. package/src/generation/schema-patterns.ts +962 -0
  109. package/src/testing/gate/index.ts +8 -0
  110. package/src/testing/gate/lint/format.ts +23 -0
  111. package/src/testing/gate/lint/index.ts +73 -0
  112. package/src/testing/gate/lint/lint.test.ts +131 -0
  113. package/src/testing/gate/lint/rules/extractor-coverage.ts +63 -0
  114. package/src/testing/gate/lint/rules/index.ts +37 -0
  115. package/src/testing/gate/lint/rules/keyword-classification.ts +52 -0
  116. package/src/testing/gate/lint/rules/keyword-coverage.ts +38 -0
  117. package/src/testing/gate/lint/rules/marker-tokenization.ts +129 -0
  118. package/src/testing/gate/lint/rules/position-ordering.ts +87 -0
  119. package/src/testing/gate/lint/rules/renderer-coherence.ts +100 -0
  120. package/src/testing/gate/lint/rules/schema-rules.ts +35 -0
  121. package/src/testing/gate/lint/types.ts +83 -0
  122. package/src/testing/gate/round-trip.test.ts +118 -0
  123. package/src/testing/gate/round-trip.ts +438 -0
@@ -0,0 +1,962 @@
1
+ /**
2
+ * Patterns from command schemas: the generator semantic's hyperscript parser
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.
6
+ *
7
+ * Per command and language it builds the main pattern and its variants: the
8
+ * simple variant (required roles only), the omit-role, widened-type and
9
+ * required-role variants, an SOV language's verb-first fallback, and the
10
+ * value-prefix-literal variants (`RoleSpec` / `CommandSchema` document each).
11
+ * What differs between grammars is a hook (`SchemaPatternGrammar`); what a
12
+ * grammar adds on top (hyperscript's fused event-handler patterns) stays in the
13
+ * grammar's package.
14
+ */
15
+
16
+ import type { ExtractionRule, LanguagePattern, PatternToken } from '../core/types';
17
+ import type { CommandSchema, RoleSpec } from '../schema';
18
+
19
+ /** What differs between grammars in how a schema becomes patterns. */
20
+ export interface SchemaPatternGrammar {
21
+ /**
22
+ * How `svoPosition` / `sovPosition` sort: `'ascending'` puts the lowest first
23
+ * (hyperscript's schemas, 1-based), `'descending'` the highest (a domain's
24
+ * schemas, as framework's `generatePattern` reads them).
25
+ */
26
+ readonly positionOrder: 'ascending' | 'descending';
27
+ }
28
+
29
+ /** A role marker in a language profile. */
30
+ export interface SchemaPatternMarker {
31
+ readonly primary: string;
32
+ readonly alternatives?: string[];
33
+ readonly position: 'before' | 'after';
34
+ }
35
+
36
+ /** What the generator reads of a language profile. */
37
+ export interface SchemaPatternProfile {
38
+ readonly code: string;
39
+ readonly wordOrder: string;
40
+ readonly keywords: Readonly<
41
+ Record<string, { readonly primary: string; readonly alternatives?: string[] } | undefined>
42
+ >;
43
+ readonly roleMarkers: Readonly<Partial<Record<string, SchemaPatternMarker>>>;
44
+ /**
45
+ * The language may drop its role markers colloquially (Turkish case
46
+ * suffixes): each marker becomes an optional group that still renders.
47
+ */
48
+ readonly markersOptional?: boolean;
49
+ }
50
+
51
+ /** The generator, bound to a grammar (`createSchemaPatternGenerator`). */
52
+ export interface SchemaPatternGenerator {
53
+ /** The main pattern for a command in a language. */
54
+ generatePattern(
55
+ schema: CommandSchema,
56
+ profile: SchemaPatternProfile,
57
+ config?: SchemaPatternConfig
58
+ ): LanguagePattern;
59
+ /** The command without its optional roles; null when it has none. */
60
+ generateSimplePattern(
61
+ schema: CommandSchema,
62
+ profile: SchemaPatternProfile,
63
+ config?: SchemaPatternConfig
64
+ ): LanguagePattern | null;
65
+ /** An SOV language's verb-first fallback; null for other word orders. */
66
+ generateVerbFirstPattern(
67
+ schema: CommandSchema,
68
+ profile: SchemaPatternProfile,
69
+ config?: SchemaPatternConfig
70
+ ): LanguagePattern | null;
71
+ /** The main pattern and every variant the schema and config ask for. */
72
+ generatePatternVariants(
73
+ schema: CommandSchema,
74
+ profile: SchemaPatternProfile,
75
+ config?: SchemaPatternConfig
76
+ ): LanguagePattern[];
77
+ /** The token(s) of one role: its value slot and marker, in the language's order. */
78
+ buildRoleToken(roleSpec: RoleSpec, profile: SchemaPatternProfile): PatternToken[];
79
+ }
80
+
81
+ /** A generator for the grammar's schemas. */
82
+ export function createSchemaPatternGenerator(
83
+ grammar: SchemaPatternGrammar
84
+ ): SchemaPatternGenerator {
85
+ return {
86
+ generatePattern: (schema, profile, config) => generatePattern(grammar, schema, profile, config),
87
+ generateSimplePattern: (schema, profile, config) =>
88
+ generateSimplePattern(grammar, schema, profile, config),
89
+ generateVerbFirstPattern: (schema, profile, config) =>
90
+ generateVerbFirstPattern(schema, profile, config),
91
+ generatePatternVariants: (schema, profile, config) =>
92
+ generatePatternVariants(grammar, schema, profile, config),
93
+ buildRoleToken,
94
+ };
95
+ }
96
+
97
+ /** The schema without its variant-only roles: what every pattern but a required-role variant is built from. */
98
+ export function withoutVariantOnlyRoles<S extends CommandSchema>(schema: S): S {
99
+ return schema.roles.some(r => r.variantOnly)
100
+ ? { ...schema, roles: schema.roles.filter(r => !r.variantOnly) }
101
+ : schema;
102
+ }
103
+
104
+ /**
105
+ * Roles in the grammar's position order for the word order (a stable sort).
106
+ * Each order keeps the rule its generator always had for a role with no
107
+ * position: 99 ascending (semantic's `sortRolesByWordOrder`), 0 descending
108
+ * (framework's `sortRolesByWordOrder`).
109
+ */
110
+ function sortRolesByPosition<T extends Pick<RoleSpec, 'svoPosition' | 'sovPosition'>>(
111
+ roles: readonly T[],
112
+ wordOrder: string,
113
+ order: SchemaPatternGrammar['positionOrder']
114
+ ): T[] {
115
+ const sortKey = wordOrder === 'SOV' ? 'sovPosition' : 'svoPosition';
116
+ return [...roles].sort((a, b) =>
117
+ order === 'ascending'
118
+ ? (a[sortKey] ?? 99) - (b[sortKey] ?? 99)
119
+ : (b[sortKey] ?? 0) - (a[sortKey] ?? 0)
120
+ );
121
+ }
122
+
123
+ /**
124
+ * Minimum interface for role specs that can have marker resolution.
125
+ * Compatible with the RoleSpec type from command-schemas.
126
+ */
127
+ export interface RoleSpecWithMarker {
128
+ role: string;
129
+ markerOverride?: Record<string, string | undefined>;
130
+ markerLegacy?: Record<string, readonly string[]>;
131
+ markerVariants?: Record<string, readonly string[]>;
132
+ methodCarrier?: string;
133
+ }
134
+
135
+ /**
136
+ * Resolved marker information for a role.
137
+ */
138
+ export interface ResolvedMarker {
139
+ /** Primary marker string (may be empty string for no marker) */
140
+ primary: string;
141
+ /** Alternative markers */
142
+ alternatives?: string[];
143
+ /** Position relative to role value */
144
+ position: 'before' | 'after';
145
+ /** Whether this is an override marker */
146
+ isOverride: boolean;
147
+ }
148
+
149
+ /**
150
+ * Resolve the marker for a role in a specific language.
151
+ *
152
+ * Checks for command-specific marker overrides first,
153
+ * then falls back to the default marker from the language profile.
154
+ *
155
+ * @param roleSpec Role specification with optional override
156
+ * @param profile Language profile with role markers
157
+ * @returns Resolved marker info, or null if no marker
158
+ */
159
+ export function resolveMarkerForRole(
160
+ roleSpec: RoleSpecWithMarker,
161
+ profile: SchemaPatternProfile
162
+ ): ResolvedMarker | null {
163
+ // Check for command-specific marker override first
164
+ const overrideMarker = roleSpec.markerOverride?.[profile.code];
165
+ const defaultMarker = profile.roleMarkers[roleSpec.role];
166
+
167
+ if (overrideMarker !== undefined) {
168
+ // Use override marker (can be empty string to suppress default marker).
169
+ // markerLegacy entries stay accepted, so correcting an override does not
170
+ // stop the marker a previous release rendered from parsing.
171
+ const alternatives = legacyMarkerAlternatives(roleSpec, profile.code, overrideMarker);
172
+ return {
173
+ primary: overrideMarker,
174
+ ...(alternatives && { alternatives }),
175
+ position: defaultMarker?.position ?? 'before',
176
+ isOverride: true,
177
+ };
178
+ }
179
+
180
+ if (defaultMarker && defaultMarker.primary) {
181
+ const result: ResolvedMarker = {
182
+ primary: defaultMarker.primary,
183
+ position: defaultMarker.position,
184
+ isOverride: false,
185
+ };
186
+ if (defaultMarker.alternatives) {
187
+ result.alternatives = defaultMarker.alternatives;
188
+ }
189
+ return result;
190
+ }
191
+
192
+ return null;
193
+ }
194
+
195
+ /**
196
+ * Alternatives an OVERRIDE marker accepts: the role's {@link RoleSpecWithMarker.markerLegacy}
197
+ * entries for this language, minus the override itself.
198
+ *
199
+ * Every generator that resolves a marker has its own override branch, and each
200
+ * one used to drop alternatives entirely — so correcting a marker silently
201
+ * stopped the previous one parsing. This is the single definition of what an
202
+ * override still accepts; call it from each of those branches.
203
+ *
204
+ * Prefer {@link schemaMarkerAlternatives}, which also merges `markerVariants`.
205
+ * This narrower entry point remains for callers that must exclude variants.
206
+ */
207
+ export function legacyMarkerAlternatives(
208
+ roleSpec: RoleSpecWithMarker,
209
+ languageCode: string,
210
+ overrideMarker: string
211
+ ): string[] | undefined {
212
+ const legacy = roleSpec.markerLegacy?.[languageCode];
213
+ if (!legacy?.length) return undefined;
214
+ const alternatives = [...new Set(legacy)].filter(a => a && a !== overrideMarker);
215
+ return alternatives.length ? alternatives : undefined;
216
+ }
217
+
218
+ /**
219
+ * Every schema-declared alternative a role's marker accepts in this language:
220
+ * `markerLegacy` ∪ `markerVariants`, minus the marker itself.
221
+ *
222
+ * **The single definition — call it from every branch of every generator.** The
223
+ * two fields used to sit on DIFFERENT branches: the override branch read only
224
+ * `markerLegacy`, the profile-default branch only `markerVariants`. So giving a
225
+ * language a `markerOverride` silently deleted its `markerVariants`, and giving
226
+ * it `markerVariants` did nothing at all wherever an override existed. Both
227
+ * halves were live: tr `set`'s dative allomorphs (`e|a|ye|ya`) were declared as
228
+ * variants but dropped by the tr override, so `… doğru ya ayarla` parsed only
229
+ * inside an event handler — the one generator that happened to merge both.
230
+ *
231
+ * `markerVariants` are held out for a role with a {@link RoleSpecWithMarker.methodCarrier}:
232
+ * there they are not synonyms but distinct command shapes recorded into another
233
+ * role (`put`'s `into|before|after` → `method`), and merging them as synonyms is
234
+ * what broke `put-before`/`put-after` in 23 languages at once. That flag is the
235
+ * discriminator because it is set on exactly the role whose variants carry
236
+ * meaning — pinned by `schema-consistency.test.ts`, which fails if a new
237
+ * `markerVariants` appears without that decision being made.
238
+ */
239
+ export function schemaMarkerAlternatives(
240
+ roleSpec: RoleSpecWithMarker,
241
+ languageCode: string,
242
+ marker: string
243
+ ): string[] | undefined {
244
+ const legacy = roleSpec.markerLegacy?.[languageCode] ?? [];
245
+ const variants = roleSpec.methodCarrier ? [] : (roleSpec.markerVariants?.[languageCode] ?? []);
246
+ const alternatives = [...new Set([...legacy, ...variants])].filter(a => a && a !== marker);
247
+ return alternatives.length ? alternatives : undefined;
248
+ }
249
+
250
+ /**
251
+ * Configuration for pattern generation.
252
+ */
253
+ export interface SchemaPatternConfig {
254
+ /** Base priority for generated patterns (higher = checked first) */
255
+ basePriority?: number;
256
+ /** Whether to generate simple patterns (without optional roles) */
257
+ generateSimpleVariants?: boolean;
258
+ /** Whether to generate alternative keyword patterns */
259
+ generateAlternatives?: boolean;
260
+ /**
261
+ * Whether to generate verb-first fallback patterns for SOV languages
262
+ * (`トグル .active` alongside the canonical `.active を トグル`). Default on.
263
+ * These are strictly lower priority than the canonical patterns, so native
264
+ * SOV order always wins; this only accepts bilingual/code-switched input.
265
+ */
266
+ generateVerbFirstVariants?: boolean;
267
+ }
268
+
269
+ const defaultConfig: SchemaPatternConfig = {
270
+ basePriority: 100,
271
+ generateSimpleVariants: true,
272
+ generateAlternatives: true,
273
+ };
274
+
275
+ /**
276
+ * Generate a pattern for a command in a specific language.
277
+ */
278
+ function generatePattern(
279
+ grammar: SchemaPatternGrammar,
280
+ schema: CommandSchema,
281
+ profile: SchemaPatternProfile,
282
+ config: SchemaPatternConfig = defaultConfig
283
+ ): LanguagePattern {
284
+ const id = `${schema.action}-${profile.code}-generated`;
285
+ const priority = config.basePriority ?? 100;
286
+
287
+ // Get keyword translation
288
+ const keyword = profile.keywords[schema.action];
289
+ if (!keyword) {
290
+ throw new Error(`No keyword translation for '${schema.action}' in ${profile.code}`);
291
+ }
292
+
293
+ // Build tokens based on word order
294
+ const tokens = buildTokens(grammar, schema, profile, keyword);
295
+
296
+ // Build extraction rules with defaults for optional roles
297
+ // This ensures defaults are applied even when optional groups don't match
298
+ const extraction = buildExtractionRulesWithDefaults(schema, profile);
299
+
300
+ // Build template format string (for documentation)
301
+ const format = buildFormatString(grammar, schema, profile, keyword);
302
+
303
+ return {
304
+ id,
305
+ language: profile.code,
306
+ command: schema.action,
307
+ priority,
308
+ template: {
309
+ format,
310
+ tokens,
311
+ },
312
+ extraction,
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Generate a simple variant pattern (without optional roles).
318
+ */
319
+ function generateSimplePattern(
320
+ grammar: SchemaPatternGrammar,
321
+ schema: CommandSchema,
322
+ profile: SchemaPatternProfile,
323
+ config: SchemaPatternConfig = defaultConfig
324
+ ): LanguagePattern | null {
325
+ // Only generate simple variant if there are optional roles
326
+ const optionalRoles = schema.roles.filter(r => !r.required);
327
+ if (optionalRoles.length === 0) {
328
+ return null;
329
+ }
330
+
331
+ const requiredRoles = schema.roles.filter(r => r.required);
332
+ const simpleSchema: CommandSchema = {
333
+ ...schema,
334
+ roles: requiredRoles,
335
+ };
336
+
337
+ const pattern = generatePattern(grammar, simpleSchema, profile, config);
338
+
339
+ // Adjust for simple variant
340
+ return {
341
+ ...pattern,
342
+ id: `${schema.action}-${profile.code}-generated-simple`,
343
+ priority: (config.basePriority ?? 100) - 5, // Lower priority than full pattern (was -10)
344
+ extraction: buildExtractionRulesWithDefaults(schema, profile),
345
+ };
346
+ }
347
+
348
+ /**
349
+ * Generate a verb-first fallback pattern for an SOV language.
350
+ *
351
+ * Canonical SOV order is patient-then-verb (`.active を トグル`). Bilingual and
352
+ * code-switching authors often write the English/loanword verb first
353
+ * (`トグル .active`). This emits `[verb] [required role(s), UNMARKED]` at a lower
354
+ * priority so the canonical pattern always wins for native input — the
355
+ * verb-first form only matches when the input genuinely starts with the verb.
356
+ *
357
+ * Kept deliberately conservative: only commands with a single required role
358
+ * (patient-focused: toggle/add/remove/show/hide/…) get this fallback, which
359
+ * keeps the unmarked match unambiguous. Returns null otherwise.
360
+ */
361
+ function generateVerbFirstPattern(
362
+ schema: CommandSchema,
363
+ profile: SchemaPatternProfile,
364
+ config: SchemaPatternConfig = defaultConfig
365
+ ): LanguagePattern | null {
366
+ if (profile.wordOrder !== 'SOV') return null;
367
+
368
+ const requiredRoles = schema.roles.filter(r => r.required);
369
+ if (requiredRoles.length !== 1) return null;
370
+
371
+ const keyword = profile.keywords[schema.action];
372
+ if (!keyword) return null;
373
+
374
+ const verbToken: PatternToken = keyword.alternatives
375
+ ? { type: 'literal', value: keyword.primary, alternatives: keyword.alternatives }
376
+ : { type: 'literal', value: keyword.primary };
377
+
378
+ // Verb first, then the required role(s) with NO marker (the marker is what
379
+ // disambiguates roles in canonical SOV; verb-first input typically omits it).
380
+ // A valuePrefixLiteral (rolePrefixLiteralVariants clones only) still precedes
381
+ // its role's value — the SOV transformer renders `移動 url "/page" に`
382
+ // verb-first, which is exactly this shape (trailing particle tolerated).
383
+ const roleTokens: PatternToken[] = requiredRoles.flatMap(r => {
384
+ const prefix = r.valuePrefixLiteral?.[profile.code];
385
+ const roleToken: PatternToken = {
386
+ type: 'role',
387
+ role: r.role,
388
+ optional: false,
389
+ expectedTypes: r.expectedTypes,
390
+ };
391
+ return prefix ? [{ type: 'literal', value: prefix } as PatternToken, roleToken] : [roleToken];
392
+ });
393
+
394
+ return {
395
+ id: `${schema.action}-${profile.code}-generated-verb-first`,
396
+ language: profile.code,
397
+ command: schema.action,
398
+ // Strictly below the canonical (basePriority) and simple (basePriority - 5)
399
+ // patterns so native order is always preferred.
400
+ priority: (config.basePriority ?? 100) - 20,
401
+ template: {
402
+ format: `${keyword.primary} <${requiredRoles[0].role}>`,
403
+ tokens: [verbToken, ...roleTokens],
404
+ },
405
+ extraction: buildExtractionRulesWithDefaults(schema, profile),
406
+ };
407
+ }
408
+
409
+ /**
410
+ * Generate all pattern variants for a command in a language.
411
+ */
412
+ function generatePatternVariants(
413
+ grammar: SchemaPatternGrammar,
414
+ schema: CommandSchema,
415
+ profile: SchemaPatternProfile,
416
+ config: SchemaPatternConfig = defaultConfig
417
+ ): LanguagePattern[] {
418
+ const patterns: LanguagePattern[] = [];
419
+ // A variant-only role is in the required-role variants alone.
420
+ const full = schema;
421
+ schema = withoutVariantOnlyRoles(schema);
422
+
423
+ // Main pattern
424
+ patterns.push(generatePattern(grammar, schema, profile, config));
425
+
426
+ // Simple variant (without optional roles)
427
+ if (config.generateSimpleVariants) {
428
+ const simple = generateSimplePattern(grammar, schema, profile, config);
429
+ if (simple) {
430
+ patterns.push(simple);
431
+ }
432
+ }
433
+
434
+ // Omit-role variants: an EXTRA pattern per schema-listed role with that
435
+ // role (and its marker phrase) removed entirely. Covers a REQUIRED role
436
+ // whose phrase is legitimately absent in valid hyperscript (transition's
437
+ // goal: `transition *max-height over 300ms`, slide-toggle) — making the
438
+ // role optional inside the main pattern is the verified skippable-group
439
+ // re-binding regression (see the transition goal NOTE); a separate variant
440
+ // has no skippable group at all. Priority sits below the simple variant so
441
+ // any text carrying the role phrase always prefers the fuller patterns.
442
+ for (const omitted of schema.omitRoleVariants ?? []) {
443
+ const variantSchema: CommandSchema = {
444
+ ...schema,
445
+ roles: schema.roles.filter(r => r.role !== omitted),
446
+ };
447
+ const variant = generatePattern(grammar, variantSchema, profile, config);
448
+ patterns.push({
449
+ ...variant,
450
+ id: `${schema.action}-${profile.code}-generated-no-${omitted}`,
451
+ priority: (config.basePriority ?? 100) - 8,
452
+ });
453
+ }
454
+
455
+ // Widened-type variants: an EXTRA pattern per entry whose named role accepts a
456
+ // wider set of value types. Deliberately BELOW every command pattern, so it is
457
+ // inert on any span a real command can claim — see the schema field's doc for
458
+ // the ko `transition opacity to 0` collision that rules out widening the main
459
+ // pattern in place.
460
+ for (const widen of schema.widenTypeVariants ?? []) {
461
+ if (widen.excludeLanguages?.includes(profile.code)) continue;
462
+ const cloneSchema: CommandSchema = {
463
+ ...schema,
464
+ roles: schema.roles.map(r =>
465
+ r.role === widen.role ? { ...r, expectedTypes: [...widen.types] } : r
466
+ ),
467
+ };
468
+ const main = generatePattern(grammar, cloneSchema, profile, config);
469
+ patterns.push({
470
+ ...main,
471
+ id: `${schema.action}-${profile.code}-generated-${widen.idSuffix}`,
472
+ priority: (config.basePriority ?? 100) - (widen.priorityDelta ?? 15),
473
+ });
474
+ }
475
+
476
+ // Required-role variants: inert unless the role's marker is present, so they
477
+ // may outrank the command's other patterns (see the schema field).
478
+ for (const v of full.requiredRoleVariants ?? []) {
479
+ const variantSchema: CommandSchema = {
480
+ ...full,
481
+ roles: full.roles
482
+ .filter(r => !v.omit?.includes(r.role))
483
+ .map(r => {
484
+ const required = r.role === v.role ? { ...r, required: true } : r;
485
+ return r.role === v.widen?.role
486
+ ? { ...required, expectedTypes: [...v.widen.types] }
487
+ : required;
488
+ }),
489
+ };
490
+ const variant = generatePattern(grammar, variantSchema, profile, config);
491
+ patterns.push({
492
+ ...variant,
493
+ id: `${schema.action}-${profile.code}-generated-${v.idSuffix}`,
494
+ priority: (config.basePriority ?? 100) + (v.priorityDelta ?? 5),
495
+ });
496
+ }
497
+
498
+ // Verb-first fallback for SOV languages (lower priority than the above).
499
+ if (config.generateVerbFirstVariants !== false) {
500
+ const verbFirst = generateVerbFirstPattern(schema, profile, config);
501
+ if (verbFirst) {
502
+ patterns.push(verbFirst);
503
+ }
504
+ }
505
+
506
+ // Value-prefix-literal variants: an EXTRA pattern per entry with a REQUIRED
507
+ // literal keyword immediately before the named role's value (`go [to] url
508
+ // {destination}`). Higher priority than the counterpart pattern, but inert
509
+ // unless the keyword is present — base patterns above stay byte-identical,
510
+ // so forms riding on them (`go back`) cannot regress. The matched keyword is
511
+ // recorded as a fixed-value literal in the variant's methodCarrier role.
512
+ for (const v of schema.rolePrefixLiteralVariants ?? []) {
513
+ const literal = v.literal[profile.code];
514
+ if (!literal) continue;
515
+ const { rolePrefixLiteralVariants: _omitted, ...baseSchema } = schema;
516
+ const cloneSchema: CommandSchema = {
517
+ ...baseSchema,
518
+ roles: schema.roles.map(r =>
519
+ r.role === v.role ? { ...r, valuePrefixLiteral: { [profile.code]: literal } } : r
520
+ ),
521
+ };
522
+ const delta = v.priorityDelta ?? 5;
523
+ const carrier: Record<string, ExtractionRule> = v.methodCarrier
524
+ ? { [v.methodCarrier]: { value: literal } }
525
+ : {};
526
+ const main = generatePattern(grammar, cloneSchema, profile, config);
527
+ patterns.push({
528
+ ...main,
529
+ id: `${schema.action}-${profile.code}-generated-${v.idSuffix}`,
530
+ priority: (config.basePriority ?? 100) + delta,
531
+ extraction: { ...main.extraction, ...carrier },
532
+ });
533
+ if (config.generateVerbFirstVariants !== false) {
534
+ const verbFirstUrl = generateVerbFirstPattern(cloneSchema, profile, config);
535
+ if (verbFirstUrl) {
536
+ patterns.push({
537
+ ...verbFirstUrl,
538
+ id: `${schema.action}-${profile.code}-generated-verb-first-${v.idSuffix}`,
539
+ priority: (config.basePriority ?? 100) - 20 + delta,
540
+ extraction: { ...verbFirstUrl.extraction, ...carrier },
541
+ });
542
+ }
543
+ }
544
+ }
545
+
546
+ return patterns;
547
+ }
548
+
549
+ /**
550
+ * Build pattern tokens based on word order.
551
+ */
552
+ function buildTokens(
553
+ grammar: SchemaPatternGrammar,
554
+ schema: CommandSchema,
555
+ profile: SchemaPatternProfile,
556
+ keyword: { primary: string; alternatives?: string[] }
557
+ ): PatternToken[] {
558
+ const tokens: PatternToken[] = [];
559
+
560
+ // Get verb token
561
+ const verbToken: PatternToken = keyword.alternatives
562
+ ? { type: 'literal', value: keyword.primary, alternatives: keyword.alternatives }
563
+ : { type: 'literal', value: keyword.primary };
564
+
565
+ // Get role tokens sorted by position. In SOV a role with `sovSlot: 'postVerb'`
566
+ // follows the verb (a domain's summarize format: `content 要約 として format`).
567
+ const postVerbRoles =
568
+ profile.wordOrder === 'SOV' ? schema.roles.filter(r => r.sovSlot === 'postVerb') : [];
569
+ const roleTokens = buildRoleTokens(
570
+ grammar,
571
+ postVerbRoles.length
572
+ ? { ...schema, roles: schema.roles.filter(r => r.sovSlot !== 'postVerb') }
573
+ : schema,
574
+ profile
575
+ );
576
+ const postVerbTokens = postVerbRoles.length
577
+ ? buildRoleTokens(grammar, { ...schema, roles: postVerbRoles }, profile)
578
+ : [];
579
+
580
+ // Arrange based on word order
581
+ switch (profile.wordOrder) {
582
+ case 'SVO':
583
+ // Verb first, then roles in order
584
+ tokens.push(verbToken);
585
+ tokens.push(...roleTokens);
586
+ break;
587
+
588
+ case 'SOV':
589
+ // Roles first (reversed for SOV), then verb
590
+ tokens.push(...roleTokens);
591
+ tokens.push(verbToken);
592
+ tokens.push(...postVerbTokens);
593
+ break;
594
+
595
+ case 'VSO':
596
+ // Verb first, then subject, then object
597
+ tokens.push(verbToken);
598
+ tokens.push(...roleTokens);
599
+ break;
600
+
601
+ default:
602
+ // Default to SVO
603
+ tokens.push(verbToken);
604
+ tokens.push(...roleTokens);
605
+ }
606
+
607
+ return collapseAdjacentLiterals(tokens);
608
+ }
609
+
610
+ /**
611
+ * Collapse adjacent identical literal tokens into one.
612
+ *
613
+ * For the event-handler trigger (`on`) schema, a handful of languages use the
614
+ * same word for the trigger keyword (the `on` verb) and the event role's marker —
615
+ * bn `তে`, hi `पर`, th `เมื่อ`. `buildTokens` then emits it twice (`<event> তে তে`),
616
+ * which the renderer prints verbatim; on re-parse the duplicate pairs with the
617
+ * following token as a phantom command (e.g. `তে আমি` → a spurious `on me`). No
618
+ * trigger pattern needs to match the same literal twice in a row, so fold the
619
+ * duplicate, preserving any `alternatives` from either side. Verified to affect
620
+ * only the `on` patterns of bn/hi/th — no other command/language has adjacent
621
+ * duplicate literals. See MULTILINGUAL_BEHAVIORS_PLAN.md Phase 2.
622
+ */
623
+ function collapseAdjacentLiterals(tokens: PatternToken[]): PatternToken[] {
624
+ const out: PatternToken[] = [];
625
+ for (const tok of tokens) {
626
+ const prev = out[out.length - 1];
627
+ if (prev && prev.type === 'literal' && tok.type === 'literal' && prev.value === tok.value) {
628
+ const alts = [...(prev.alternatives ?? []), ...(tok.alternatives ?? [])];
629
+ if (alts.length) out[out.length - 1] = { ...prev, alternatives: [...new Set(alts)] };
630
+ continue;
631
+ }
632
+ out.push(tok);
633
+ }
634
+ return out;
635
+ }
636
+
637
+ /**
638
+ * Build tokens for roles.
639
+ */
640
+ function buildRoleTokens(
641
+ grammar: SchemaPatternGrammar,
642
+ schema: CommandSchema,
643
+ profile: SchemaPatternProfile
644
+ ): PatternToken[] {
645
+ const tokens: PatternToken[] = [];
646
+
647
+ // Sort roles by position using shared utility
648
+ const sortedRoles = sortRolesByPosition(schema.roles, profile.wordOrder, grammar.positionOrder);
649
+
650
+ for (const roleSpec of sortedRoles) {
651
+ const roleToken = buildRoleToken(roleSpec, profile);
652
+
653
+ if (!roleSpec.required) {
654
+ // Wrap optional roles in a group
655
+ tokens.push({
656
+ type: 'group',
657
+ optional: true,
658
+ tokens: roleToken,
659
+ });
660
+ } else {
661
+ tokens.push(...roleToken);
662
+ }
663
+ }
664
+
665
+ return tokens;
666
+ }
667
+
668
+ /**
669
+ * Build token(s) for a single role.
670
+ *
671
+ * Exported so the fused event-handler generators can build a wrapped command's
672
+ * optional-role slots from the very same code the standalone command patterns
673
+ * use — a hand-rolled copy there is what let the two drift apart in the first
674
+ * place (see `appendRemainingOptionalRoles` in event-handlers-sov.ts).
675
+ */
676
+ function buildRoleToken(roleSpec: RoleSpec, profile: SchemaPatternProfile): PatternToken[] {
677
+ const tokens: PatternToken[] = [];
678
+
679
+ // Check for command-specific marker override first
680
+ const overrideMarker = roleSpec.markerOverride?.[profile.code];
681
+ const defaultMarker = profile.roleMarkers[roleSpec.role];
682
+
683
+ // A role whose renderOverride is the empty string parses WITH its marker but
684
+ // renders WITHOUT it (e.g. fetch's `from`: `fetch from /api` must parse, yet
685
+ // canonical _hyperscript rejects `fetch from "/api"` while accepting
686
+ // `fetch "/api"`). Tag the emitted marker literal so the renderer skips it;
687
+ // the pattern matcher ignores the flag, so parsing is unchanged.
688
+ const suppressMarker = roleSpec.renderOverride?.[profile.code] === '';
689
+
690
+ // Role value token
691
+ const roleValueToken: PatternToken = {
692
+ type: 'role',
693
+ role: roleSpec.role,
694
+ optional: !roleSpec.required,
695
+ expectedTypes: roleSpec.expectedTypes,
696
+ // The matcher reads this too — a `'keyword'`-shaped slot holds a fixed
697
+ // keyword phrase (`using view transition`) and must not be skipped by the
698
+ // trailing-slot verb guard.
699
+ ...(roleSpec.valueShape !== undefined ? { valueShape: roleSpec.valueShape } : {}),
700
+ // A greedy role reads up to the next marker the pattern owes (a domain's
701
+ // SQL condition); its value reader does the reading.
702
+ ...(roleSpec.greedy ? { greedy: true } : {}),
703
+ };
704
+
705
+ // Required literal immediately before the role VALUE (rolePrefixLiteralVariants
706
+ // clones only): `[to] url {destination}`, `url {destination} に`. Deliberately
707
+ // NOT an optional group — the variant must only match when the keyword is
708
+ // present, so base patterns keep winning everywhere else.
709
+ const prefixLiteral = roleSpec.valuePrefixLiteral?.[profile.code];
710
+ const pushPrefixed = (): void => {
711
+ if (prefixLiteral) tokens.push({ type: 'literal', value: prefixLiteral });
712
+ tokens.push(roleValueToken);
713
+ };
714
+
715
+ // Use override marker if available, otherwise use default
716
+ if (overrideMarker !== undefined) {
717
+ // Command-specific marker override. Multi-word markers (e.g. "partials in",
718
+ // "with title") split into one literal token per word so the matcher
719
+ // consumes them sequentially. A per-command markerOptional wraps each word
720
+ // in an optional group (`go to /page` and `go back` both parse — the
721
+ // render side drops the preposition, so the parse side can't require it).
722
+ const markerWords = overrideMarker ? overrideMarker.split(/\s+/).filter(Boolean) : [];
723
+ // Where the profile carries no RoleMarker for this role there is nothing to
724
+ // take a side from, and 'before' is wrong in a verb-final language: ja
725
+ // rendered `opacity を に 0` and bn `opacity কে তে 0`, the override marker
726
+ // stranded AHEAD of its own value. Default to 'after' for SOV.
727
+ //
728
+ // EXEMPT a PASSTHROUGH override — one whose marker is the same string en
729
+ // uses (`scope`, `manner`). Those are English words carried verbatim into
730
+ // every language, so they keep English's prepositional side; without the
731
+ // exemption ja/ko/qu lose `set`'s scope.
732
+ const isPassthroughOverride =
733
+ overrideMarker !== undefined && overrideMarker === roleSpec.markerOverride?.en;
734
+ const position =
735
+ roleSpec.markerPositionOverride?.[profile.code] ??
736
+ roleSpec.markerPosition ??
737
+ defaultMarker?.position ??
738
+ (profile.wordOrder === 'SOV' && !isPassthroughOverride ? 'after' : 'before');
739
+ const optionalMarker = roleSpec.markerOptional?.[profile.code] === true;
740
+ // Single-word overrides also accept every schema-declared alternative
741
+ // (markerLegacy ∪ markerVariants), so correcting an override's marker does
742
+ // not stop the previous one parsing, and declaring an override does not
743
+ // silently delete that language's variants. schemaMarkerAlternatives holds
744
+ // variants out for a methodCarrier role (`put`'s before/after) — see its doc.
745
+ const pushWord = (word: string): void => {
746
+ const alternatives =
747
+ markerWords.length === 1
748
+ ? (schemaMarkerAlternatives(roleSpec, profile.code, word) ?? [])
749
+ : [];
750
+ const literal: PatternToken = {
751
+ type: 'literal',
752
+ value: word,
753
+ ...(alternatives.length ? { alternatives } : {}),
754
+ ...(suppressMarker ? { renderSuppress: true } : {}),
755
+ };
756
+ tokens.push(optionalMarker ? { type: 'group', optional: true, tokens: [literal] } : literal);
757
+ };
758
+ if (position === 'before') {
759
+ for (const word of markerWords) pushWord(word);
760
+ pushPrefixed();
761
+ } else {
762
+ pushPrefixed();
763
+ for (const word of markerWords) pushWord(word);
764
+ }
765
+ } else if (defaultMarker) {
766
+ // When the profile allows colloquial marker-dropping, the marker becomes an
767
+ // optional group so `.active değiştir` parses as well as `.active i değiştir`.
768
+ // The marked form still matches the marker literal directly (higher
769
+ // confidence); the unmarked form falls back through the optional group.
770
+ // The schema's markerVariants/markerLegacy merge in as extra alternatives:
771
+ // the transformer can render a role with a particle the profile default
772
+ // doesn't cover (zh 把 before go's destination in `go back`).
773
+ const asMarker = (): PatternToken => {
774
+ const alternatives = [
775
+ ...new Set([
776
+ ...(defaultMarker.alternatives ?? []),
777
+ ...(schemaMarkerAlternatives(roleSpec, profile.code, defaultMarker.primary) ?? []),
778
+ ]),
779
+ ].filter(a => a !== defaultMarker.primary);
780
+ return {
781
+ type: 'literal',
782
+ value: defaultMarker.primary,
783
+ ...(alternatives.length ? { alternatives } : {}),
784
+ ...(suppressMarker ? { renderSuppress: true } : {}),
785
+ };
786
+ };
787
+ const pushMarker = (marker: PatternToken): void => {
788
+ if (!profile.markersOptional && !roleSpec.markerOptional?.[profile.code]) {
789
+ tokens.push(marker);
790
+ return;
791
+ }
792
+ // A profile-wide `markersOptional` says the marker may be DROPPED
793
+ // colloquially, not that it is absent from canonical output — so the group
794
+ // still renders. A per-role `markerOptional` is the opposite: the render
795
+ // side genuinely omits that marker (`go to /page` vs `go back`), so it
796
+ // stays render-optional too.
797
+ tokens.push({
798
+ type: 'group',
799
+ optional: true,
800
+ ...(profile.markersOptional && !roleSpec.markerOptional?.[profile.code]
801
+ ? { renderRequired: true }
802
+ : {}),
803
+ tokens: [marker],
804
+ });
805
+ };
806
+ // A schema's own side for the role (a domain's `markerPosition`) outranks the profile's.
807
+ if ((roleSpec.markerPosition ?? defaultMarker.position) === 'before') {
808
+ // Preposition: "on #button"
809
+ if (defaultMarker.primary) {
810
+ pushMarker(asMarker());
811
+ }
812
+ pushPrefixed();
813
+ } else {
814
+ // Postposition/particle: "#button に"
815
+ pushPrefixed();
816
+ pushMarker(asMarker());
817
+ }
818
+ } else {
819
+ // No marker, just the role value
820
+ pushPrefixed();
821
+ }
822
+
823
+ return tokens;
824
+ }
825
+
826
+ // =============================================================================
827
+ // Extraction Rules Building
828
+ // =============================================================================
829
+
830
+ /**
831
+ * Build extraction rules for a pattern.
832
+ */
833
+ function buildExtractionRules(
834
+ schema: CommandSchema,
835
+ profile: SchemaPatternProfile
836
+ ): Record<string, ExtractionRule> {
837
+ const rules: Record<string, ExtractionRule> = {};
838
+
839
+ for (const roleSpec of schema.roles) {
840
+ // Check for command-specific marker override first
841
+ const overrideMarker = roleSpec.markerOverride?.[profile.code];
842
+ const defaultMarker = profile.roleMarkers[roleSpec.role];
843
+
844
+ if (roleSpec.valuePrefixLiteral?.[profile.code]) {
845
+ // The prefix literal sits immediately before the value on every surface,
846
+ // so it is the extraction anchor (push/replace's proven `url` shape) —
847
+ // it wins over the role's own marker (`to`), which may be optional/absent.
848
+ rules[roleSpec.role] = { marker: roleSpec.valuePrefixLiteral[profile.code] };
849
+ } else if (overrideMarker !== undefined) {
850
+ // Use the override marker, accepting the schema's declared alternatives
851
+ // so a corrected marker does not stop the previous one parsing and an
852
+ // override does not delete that language's variants (mirrors
853
+ // buildRoleToken; single-word overrides only, as a multi-word marker has
854
+ // no single token to swap).
855
+ if (!overrideMarker) {
856
+ rules[roleSpec.role] = {};
857
+ } else {
858
+ const isSingleWord = !/\s/.test(overrideMarker.trim());
859
+ const markerAlternatives = isSingleWord
860
+ ? (schemaMarkerAlternatives(roleSpec, profile.code, overrideMarker) ?? [])
861
+ : [];
862
+ rules[roleSpec.role] = markerAlternatives.length
863
+ ? { marker: overrideMarker, markerAlternatives }
864
+ : { marker: overrideMarker };
865
+ }
866
+ } else if (defaultMarker && defaultMarker.primary) {
867
+ // Merge the schema's declared alternatives (mirrors buildRoleToken)
868
+ const markerAlternatives = [
869
+ ...new Set([
870
+ ...(defaultMarker.alternatives ?? []),
871
+ ...(schemaMarkerAlternatives(roleSpec, profile.code, defaultMarker.primary) ?? []),
872
+ ]),
873
+ ].filter(a => a !== defaultMarker.primary);
874
+ rules[roleSpec.role] = markerAlternatives.length
875
+ ? { marker: defaultMarker.primary, markerAlternatives }
876
+ : { marker: defaultMarker.primary };
877
+ } else {
878
+ rules[roleSpec.role] = {};
879
+ }
880
+ }
881
+
882
+ return rules;
883
+ }
884
+
885
+ /**
886
+ * Build extraction rules with defaults for optional roles.
887
+ */
888
+ function buildExtractionRulesWithDefaults(
889
+ schema: CommandSchema,
890
+ profile: SchemaPatternProfile
891
+ ): Record<string, ExtractionRule> {
892
+ const baseRules = buildExtractionRules(schema, profile);
893
+ const rules: Record<string, ExtractionRule> = {};
894
+
895
+ // Copy base rules and add defaults for optional roles
896
+ for (const roleSpec of schema.roles) {
897
+ const baseRule = baseRules[roleSpec.role] || {};
898
+
899
+ if (!roleSpec.required && roleSpec.default) {
900
+ rules[roleSpec.role] = { ...baseRule, default: roleSpec.default };
901
+ } else {
902
+ rules[roleSpec.role] = baseRule;
903
+ }
904
+ }
905
+
906
+ return rules;
907
+ }
908
+
909
+ // =============================================================================
910
+ // Format String Building
911
+ // =============================================================================
912
+
913
+ /**
914
+ * Build a human-readable format string for documentation.
915
+ */
916
+ function buildFormatString(
917
+ grammar: SchemaPatternGrammar,
918
+ schema: CommandSchema,
919
+ profile: SchemaPatternProfile,
920
+ keyword: { primary: string }
921
+ ): string {
922
+ const parts: string[] = [];
923
+
924
+ // Sort roles by position using shared utility
925
+ const sortedRoles = sortRolesByPosition(schema.roles, profile.wordOrder, grammar.positionOrder);
926
+
927
+ // Build role parts
928
+ const roleParts = sortedRoles.map(roleSpec => {
929
+ // Use shared marker resolution utility
930
+ const resolved = resolveMarkerForRole(roleSpec, profile);
931
+ let part = '';
932
+
933
+ if (resolved && resolved.primary) {
934
+ // Has a marker
935
+ if (resolved.position === 'before') {
936
+ part = `${resolved.primary} {${roleSpec.role}}`;
937
+ } else {
938
+ part = `{${roleSpec.role}} ${resolved.primary}`;
939
+ }
940
+ } else {
941
+ // No marker
942
+ part = `{${roleSpec.role}}`;
943
+ }
944
+
945
+ return roleSpec.required ? part : `[${part}]`;
946
+ });
947
+
948
+ // Arrange based on word order
949
+ switch (profile.wordOrder) {
950
+ case 'SVO':
951
+ case 'VSO':
952
+ parts.push(keyword.primary, ...roleParts);
953
+ break;
954
+ case 'SOV':
955
+ parts.push(...roleParts, keyword.primary);
956
+ break;
957
+ default:
958
+ parts.push(keyword.primary, ...roleParts);
959
+ }
960
+
961
+ return parts.join(' ');
962
+ }