micro-contracts 0.13.0 → 0.14.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.
@@ -0,0 +1,731 @@
1
+ # micro-contracts 仕様変更案: inline `x-event` 対応
2
+
3
+ **対象パッケージ**: `micro-contracts` (現行 v0.13.0)
4
+ **提案バージョン**: v0.14.0
5
+ **作成日**: 2026-04-29
6
+ **ステータス**: Draft
7
+
8
+ ---
9
+
10
+ ## 1. 背景
11
+
12
+ 現行の `micro-contracts` は画面仕様 OpenAPI の `x-events`(GET 操作直下のフラットリスト)を解析し、`ScreenContext.events` として Handlebars テンプレートに渡している。この設計には以下の問題がある。
13
+
14
+ - **トリガーとの紐づけがない**: イベントがどの UI 要素(link 遷移・アクション送信・画面表示)で発火するか YAML 上で表現できない
15
+ - **手動呼び出し依存**: 生成される `useXxxEvents()` hook は関数を返すだけで、発火タイミングは実装者任せ
16
+ - **method フィールドの冗長性**: `method` から関数名を生成するが、トリガーが自明な場合は不要
17
+
18
+ 複数の利用プロジェクトで YAML スキーマを **inline `x-event`** 形式に移行済み。`micro-contracts` がこの新スキーマを解析・テンプレートに渡せるようにする。
19
+
20
+ さらに、`x-interactions`(画面内インタラクションの宣言)は現在利用プロジェクト側が独自に使用しているカスタム拡張であり、`micro-contracts` v0.13.0 には **型定義・パーサー・linter ルールのいずれも存在しない**。inline `x-event` が `x-interactions` の子要素に配置されるため、本提案では `x-interactions` 自体の正式サポートも併せて提案する。
21
+
22
+ ---
23
+
24
+ ## 2. 変更概要
25
+
26
+ | 項目 | 旧 (v0.13) | 新 (v0.14) |
27
+ |------|------------|------------|
28
+ | YAML フィールド | `get.x-events` (配列) | `get.x-event`, `links.*.x-event`, `post/put/patch/delete.x-event`, `x-interactions.*.x-event` (単値) |
29
+ | テンプレートデータ | `ScreenContext.events: ScreenEventDefinition[]` | `ScreenContext.screenEvent?`, `ScreenLink.event?`, `ScreenContext.actions[].event?`, `ScreenContext.interactions[].event?` |
30
+ | `method` フィールド | 必須 | **廃止** |
31
+ | `$ref` 解決 | なし | `components.x-event-defs` を解決(ローカル参照のみ) |
32
+ | params 自動導出 | なし | get→path params, links→遷移先 path params のみ。それ以外は明示指定 |
33
+ | linter ルール | `x-events` の配列構造チェック | `x-event` の3形式バリデーション |
34
+ | `x-interactions` | **未認識** (プロジェクト独自拡張) | `ScreenContext.interactions[]` として正式サポート |
35
+
36
+ ---
37
+
38
+ ## 3. 新 YAML スキーマ
39
+
40
+ ### 3.1 配置場所と `type` の自動推論
41
+
42
+ ```yaml
43
+ /home:
44
+ get:
45
+ x-event: home_view # → type: "screen_view" (自動推論)
46
+ responses:
47
+ '200':
48
+ links:
49
+ goToDetail:
50
+ operationId: renderDetailPage
51
+ x-event: # → type: "user_action" (自動推論)
52
+ $ref: '#/components/x-event-defs/itemTap'
53
+ post:
54
+ operationId: actionHomePage
55
+ x-event: mode_switch # → type: "user_action" (自動推論)
56
+ delete:
57
+ operationId: deleteHomeItem
58
+ x-event: item_delete # post 以外の変更系メソッドも対象
59
+ ```
60
+
61
+ | 配置場所 | 推論される `type` |
62
+ |----------|------------------|
63
+ | `get` 直下 | `screen_view` |
64
+ | `links.*` 直下 | `user_action` |
65
+ | `post` / `put` / `patch` / `delete` 直下 | `user_action` |
66
+ | `x-interactions.*` 直下 | `user_action` |
67
+
68
+ `type` はオブジェクト形式で明示オーバーライド可能(例: `type: system`)。
69
+
70
+ #### `type` の値
71
+
72
+ | 値 | 意味 |
73
+ |----|------|
74
+ | `screen_view` | 画面表示時に自動発火 |
75
+ | `user_action` | ユーザー操作に起因するイベント |
76
+ | `system` | システム起因のイベント(OAuth コールバック等) |
77
+
78
+ 上記は推奨値。自由文字列も許容するが、ツールやテンプレートが認識する値は上記3種とする。
79
+
80
+ ### 3.2 `x-event` の3形式
81
+
82
+ ```yaml
83
+ # 文字列形式(大半のケース)
84
+ x-event: home_view
85
+
86
+ # オブジェクト形式(type オーバーライド / 追加 params)
87
+ x-event:
88
+ name: oauth_callback_result
89
+ type: system
90
+ params:
91
+ success: boolean
92
+
93
+ # $ref 形式(複数箇所で再利用)
94
+ x-event:
95
+ $ref: '#/components/x-event-defs/itemTap'
96
+ ```
97
+
98
+ `$ref` は `#/components/x-event-defs/*` のローカル参照のみサポートする。外部ファイル参照は対象外。
99
+
100
+ ### 3.3 再利用可能な定義
101
+
102
+ ```yaml
103
+ components:
104
+ x-event-defs:
105
+ itemTap:
106
+ name: item_tap
107
+ params:
108
+ itemId: string
109
+ ```
110
+
111
+ ### 3.4 params の導出ルール
112
+
113
+ | トリガー | params 導出 | 例 |
114
+ |----------|-------------|-----|
115
+ | `get` 直下 | 画面の path params から**自動導出** | `/calendar/{yearMonth}` → `{ yearMonth: string }` |
116
+ | `links.*` | 遷移先の path params から**自動導出** | `renderDetailPage` → `/item/{itemId}` → `{ itemId: string }` |
117
+ | `post` / `put` / `patch` / `delete` | 自動導出**なし**。必要なら `params` で明示指定 | `x-event: { name: mode_switch, params: { mode: string } }` |
118
+ | `x-interactions.*` | 自動導出**なし**。必要なら `params` で明示指定 | 同上 |
119
+
120
+ 自動導出される params は、明示 `params` が指定されている場合は上書きされない(明示指定が優先)。
121
+
122
+ ---
123
+
124
+ ## 3.A `x-interactions` の正式サポート(新規拡張)
125
+
126
+ > **注意**: `x-interactions` は現在 `micro-contracts` に型定義・パーサー・linter ルールが **一切存在しない**。
127
+ > 利用プロジェクト側がカスタム拡張として YAML に記載し、プロジェクト固有のツールが独自に解析している状態である。
128
+
129
+ ### 現状の問題
130
+
131
+ - `micro-contracts` の `extractScreens()` は `x-interactions` を完全に無視するため、テンプレートに渡される `ScreenContext` にインタラクション情報が含まれない
132
+ - 結果としてテンプレートでインタラクション起因のイベント生成ができない
133
+ - プロジェクト側が独自パーサーを重複実装している
134
+
135
+ ### YAML 上の形式
136
+
137
+ ```yaml
138
+ /home:
139
+ get:
140
+ x-interactions:
141
+ - name: daySwipe
142
+ description: Horizontal day-by-day swipe navigation
143
+ x-event: # ← inline event(任意)
144
+ name: day_swipe
145
+ params:
146
+ direction: string
147
+
148
+ - name: itemSelect
149
+ description: Select an item from choices
150
+ x-event:
151
+ name: item_select
152
+ params:
153
+ itemId: string
154
+ ```
155
+
156
+ 必須フィールドは `name` のみ。`description` は推奨。その他のプロジェクト固有のプロパティ(UI ライブラリバインディング等)は `x-interactions` エントリのカスタムフィールドとして自由に追加でき、テンプレートへパススルーされる。
157
+
158
+ ### 提案する型定義
159
+
160
+ ```typescript
161
+ /**
162
+ * Raw x-interactions entry as it appears in OpenAPI YAML.
163
+ * Placed on `paths.<path>.get['x-interactions']`.
164
+ *
165
+ * `name` is required. All other fields are optional.
166
+ * Projects may add custom fields (e.g., module, factory) which
167
+ * are passed through to templates as-is.
168
+ */
169
+ export interface InteractionDefinitionRaw {
170
+ name: string;
171
+ description?: string;
172
+ 'x-event'?: string | InlineEventRaw;
173
+ [key: string]: unknown; // プロジェクト固有フィールドのパススルー
174
+ }
175
+ ```
176
+
177
+ `OperationObject` への追加:
178
+
179
+ ```typescript
180
+ export interface OperationObject {
181
+ // ... 既存フィールド ...
182
+ 'x-interactions'?: InteractionDefinitionRaw[]; // ★ 新規
183
+ }
184
+ ```
185
+
186
+ ### パーサーでの処理
187
+
188
+ `extractScreens()` 内で `operation['x-interactions']` を読み取り、各エントリを `ScreenInteraction` に変換する。子要素の `x-event` は `resolveInlineEvent()` で解決する(セクション 5.1 参照)。`name`, `description`, `x-event` 以外のフィールドは `extras` にまとめてパススルーする。
189
+
190
+ ### linter ルール
191
+
192
+ ```typescript
193
+ if (operation['x-interactions']) {
194
+ const interactions = operation['x-interactions'];
195
+ if (!Array.isArray(interactions)) {
196
+ errors.push({
197
+ type: 'error',
198
+ code: 'SCREEN_INVALID_INTERACTIONS',
199
+ message: 'x-interactions must be an array',
200
+ path, location,
201
+ });
202
+ } else {
203
+ for (let i = 0; i < interactions.length; i++) {
204
+ if (!interactions[i].name) {
205
+ errors.push({
206
+ type: 'error',
207
+ code: 'SCREEN_INVALID_INTERACTION',
208
+ message: `x-interactions[${i}] must have a name`,
209
+ path, location,
210
+ });
211
+ }
212
+ // 子 x-event があれば x-event バリデーションを再利用
213
+ }
214
+ }
215
+ }
216
+ ```
217
+
218
+ ---
219
+
220
+ ## 4. TypeScript 型定義の変更
221
+
222
+ ### 4.1 `types.ts` — 旧型の廃止と新型の追加
223
+
224
+ ```typescript
225
+ // ── 廃止 ──
226
+ // 以下を @deprecated にし、次メジャーで削除
227
+ export interface ScreenEventDefinition {
228
+ name: string;
229
+ type: string;
230
+ method: string; // ← 廃止
231
+ params?: Record<string, string>;
232
+ }
233
+
234
+ // ── 追加 ──
235
+ /**
236
+ * Inline event declaration (placed on get, links, actions, interactions).
237
+ * `type` is auto-inferred from placement if omitted.
238
+ */
239
+ export interface InlineEventDefinition {
240
+ name: string;
241
+ type: string; // resolved (inferred or explicit)
242
+ params?: Record<string, string>; // auto-derived (get/links) or explicit
243
+ }
244
+
245
+ // OperationObject に追加
246
+ export interface OperationObject {
247
+ // ... 既存フィールド ...
248
+ 'x-events'?: ScreenEventDefinition[]; // @deprecated — 後方互換用
249
+ 'x-event'?: string | InlineEventRaw; // ★ 新規
250
+ 'x-interactions'?: InteractionDefinitionRaw[]; // ★ 新規 (セクション 3.A 参照)
251
+ }
252
+
253
+ // YAML 上の生値($ref 解決前)
254
+ type InlineEventRaw =
255
+ | string // name のみ
256
+ | { name: string; type?: string; params?: Record<string, string> }
257
+ | { $ref: string };
258
+ ```
259
+
260
+ ### 4.2 `templateProcessor.d.ts` — ScreenContext の拡張
261
+
262
+ ```typescript
263
+ export interface ScreenContext {
264
+ route: string;
265
+ screenConst: string;
266
+ screenId: string;
267
+ screenName: string;
268
+ operationId: string;
269
+ supportsBack: boolean;
270
+ viewModelSchema: string;
271
+ links: ScreenLink[];
272
+ requiresAuth: boolean;
273
+
274
+ // ── 廃止(後方互換用に残す) ──
275
+ /** @deprecated Use screenEvent / links[].event / actions[].event instead */
276
+ events: ScreenEventDefinition[];
277
+
278
+ // ── 追加 ──
279
+ /** Inline event on the GET operation (screen_view) */
280
+ screenEvent?: InlineEventDefinition;
281
+ /** Mutation operations (post/put/patch/delete) with optional inline events */
282
+ actions: ScreenAction[];
283
+ /** Interaction bindings with optional inline events */
284
+ interactions: ScreenInteraction[];
285
+ /** Path parameters of this screen's route */
286
+ pathParams: string[];
287
+ }
288
+
289
+ export interface ScreenLink {
290
+ name: string;
291
+ targetRoute: string;
292
+ targetOperationId: string;
293
+ // ── 追加 ──
294
+ /** Inline event fired on navigation via this link */
295
+ event?: InlineEventDefinition;
296
+ }
297
+
298
+ /** NEW — mutation operation (post/put/patch/delete) on this screen */
299
+ export interface ScreenAction {
300
+ /** HTTP method (post, put, patch, delete) */
301
+ method: string;
302
+ operationId: string;
303
+ summary: string;
304
+ schemaRef: string;
305
+ event?: InlineEventDefinition;
306
+ }
307
+
308
+ /** NEW — in-page interaction binding */
309
+ export interface ScreenInteraction {
310
+ name: string;
311
+ description: string;
312
+ event?: InlineEventDefinition;
313
+ /** Project-specific fields passed through from YAML */
314
+ extras: Record<string, unknown>;
315
+ }
316
+ ```
317
+
318
+ ---
319
+
320
+ ## 5. パーサーの変更 (`templateProcessor.js` — `extractScreens`)
321
+
322
+ ### 5.1 `x-event` 解決関数の追加
323
+
324
+ ```typescript
325
+ function resolveInlineEvent(
326
+ raw: string | Record<string, unknown> | undefined,
327
+ eventDefs: Record<string, unknown>,
328
+ defaultType: string,
329
+ ): InlineEventDefinition | undefined {
330
+ if (raw == null) return undefined;
331
+
332
+ // 文字列形式
333
+ if (typeof raw === 'string') {
334
+ return { name: raw, type: defaultType };
335
+ }
336
+
337
+ // $ref 形式(ローカル参照のみ: #/components/x-event-defs/*)
338
+ if (raw.$ref) {
339
+ const ref = raw.$ref as string;
340
+ if (!ref.startsWith('#/components/x-event-defs/')) {
341
+ return { name: ref, type: defaultType }; // 解決不能 — name にフォールバック
342
+ }
343
+ const defName = ref.split('/').pop()!;
344
+ const resolved = eventDefs[defName] as Record<string, unknown> | undefined;
345
+ if (!resolved) return { name: defName, type: defaultType };
346
+ return {
347
+ name: (resolved.name as string) ?? defName,
348
+ type: (resolved.type as string) ?? defaultType,
349
+ params: resolved.params as Record<string, string> | undefined,
350
+ };
351
+ }
352
+
353
+ // オブジェクト形式
354
+ return {
355
+ name: (raw.name as string) ?? '',
356
+ type: (raw.type as string) ?? defaultType,
357
+ params: raw.params as Record<string, string> | undefined,
358
+ };
359
+ }
360
+ ```
361
+
362
+ ### 5.2 params 自動導出
363
+
364
+ params の自動導出は **path parameters のみ** を対象とする。requestBody や query parameters からの自動導出は行わない。アクションやインタラクションで params が必要な場合は `x-event` オブジェクト内で明示指定する。
365
+
366
+ ```typescript
367
+ function deriveEventParams(
368
+ event: InlineEventDefinition,
369
+ placement: 'get' | 'link',
370
+ context: { routePath: string; targetRoute?: string },
371
+ ): InlineEventDefinition {
372
+ // 明示 params がある場合はそのまま返す
373
+ if (event.params) return event;
374
+
375
+ switch (placement) {
376
+ case 'get': {
377
+ const params = extractPathParams(context.routePath);
378
+ if (params.length > 0) {
379
+ event.params = Object.fromEntries(params.map(p => [p, 'string']));
380
+ }
381
+ break;
382
+ }
383
+ case 'link': {
384
+ if (context.targetRoute) {
385
+ const params = extractPathParams(context.targetRoute);
386
+ if (params.length > 0) {
387
+ event.params = Object.fromEntries(params.map(p => [p, 'string']));
388
+ }
389
+ }
390
+ break;
391
+ }
392
+ }
393
+ return event;
394
+ }
395
+
396
+ function extractPathParams(route: string): string[] {
397
+ const matches = route.matchAll(/\{(\w+)\}/g);
398
+ return [...matches].map(m => m[1]);
399
+ }
400
+ ```
401
+
402
+ ### 5.3 `extractScreens` 関数の変更
403
+
404
+ ```diff
405
+ function extractScreens(spec) {
406
+ const screens = [];
407
+ const operationRouteMap = buildOperationRouteMap(spec);
408
+ + const components = spec.components ?? {};
409
+ + const eventDefs = components['x-event-defs'] ?? {};
410
+
411
+ for (const [apiPath, pathItem] of Object.entries(spec.paths)) {
412
+ const operation = pathItem.get;
413
+ if (!operation) continue;
414
+ const screenId = operation['x-screen-id'];
415
+ if (!screenId) continue;
416
+
417
+ // ... (既存の screenConst, screenName 等の抽出) ...
418
+
419
+ - const events = operation['x-events'] || [];
420
+ + // ── 新: inline x-event (get 直下) ──
421
+ + const screenEvent = resolveInlineEvent(
422
+ + operation['x-event'], eventDefs, 'screen_view'
423
+ + );
424
+ + if (screenEvent) {
425
+ + deriveEventParams(screenEvent, 'get', { routePath: apiPath });
426
+ + }
427
+ +
428
+ + // ── 後方互換: 旧 x-events が残っていれば読む ──
429
+ + const legacyEvents = operation['x-events'] || [];
430
+
431
+ - // Extract navigation links from 200 response
432
+ + // Extract navigation links from 200 response only
433
+ const links = [];
434
+ - // ... (既存の link 抽出ループ) ...
435
+ - links.push({ name: linkName, targetRoute, targetOperationId: linkObj.operationId });
436
+ + const response200 = operation.responses?.['200'];
437
+ + if (response200 && !isReference(response200) && response200.links) {
438
+ + for (const [linkName, linkObj] of Object.entries(response200.links)) {
439
+ + if (linkObj.operationId) {
440
+ + const targetRoute = operationRouteMap.get(linkObj.operationId) || '';
441
+ + const linkEvent = resolveInlineEvent(
442
+ + linkObj['x-event'], eventDefs, 'user_action'
443
+ + );
444
+ + if (linkEvent) {
445
+ + deriveEventParams(linkEvent, 'link',
446
+ + { routePath: apiPath, targetRoute });
447
+ + }
448
+ + links.push({
449
+ + name: linkName, targetRoute,
450
+ + targetOperationId: linkObj.operationId,
451
+ + event: linkEvent,
452
+ + });
453
+ + }
454
+ + }
455
+ + }
456
+
457
+ + // ── 新: 変更系メソッド (post/put/patch/delete) の解析 ──
458
+ + const actions = [];
459
+ + const mutationMethods = ['post', 'put', 'patch', 'delete'];
460
+ + for (const method of mutationMethods) {
461
+ + const mutationOp = pathItem[method];
462
+ + if (!mutationOp) continue;
463
+ + const actionEvent = resolveInlineEvent(
464
+ + mutationOp['x-event'], eventDefs, 'user_action'
465
+ + );
466
+ + // schemaRef 抽出 ...
467
+ + actions.push({
468
+ + method,
469
+ + operationId: mutationOp.operationId ?? '',
470
+ + summary: mutationOp.summary ?? '',
471
+ + schemaRef,
472
+ + event: actionEvent,
473
+ + });
474
+ + }
475
+ +
476
+ + // ── 新: x-interactions の解析 ──
477
+ + const rawInteractions = operation['x-interactions'] ?? [];
478
+ + const interactions = rawInteractions.map(i => {
479
+ + const { name, description, 'x-event': rawEvent, ...extras } = i;
480
+ + return {
481
+ + name: name ?? '',
482
+ + description: description ?? '',
483
+ + event: resolveInlineEvent(rawEvent, eventDefs, 'user_action'),
484
+ + extras,
485
+ + };
486
+ + });
487
+
488
+ screens.push({
489
+ route: apiPath, screenConst, screenId, screenName,
490
+ operationId, supportsBack, viewModelSchema,
491
+ - links, events, requiresAuth,
492
+ + links,
493
+ + events: legacyEvents, // 後方互換
494
+ + screenEvent,
495
+ + actions,
496
+ + interactions,
497
+ + pathParams: extractPathParams(apiPath),
498
+ + requiresAuth,
499
+ });
500
+ }
501
+ }
502
+ ```
503
+
504
+ ---
505
+
506
+ ## 6. linter の変更 (`linter.js`)
507
+
508
+ ### 6.1 旧ルールの deprecation
509
+
510
+ ```diff
511
+ -// Validate x-events structure
512
+ -if (operation['x-events']) {
513
+ +// Deprecated: x-events (flat list) — warn but don't error
514
+ +if (operation['x-events']) {
515
+ + warnings.push({
516
+ + type: 'warning',
517
+ + code: 'SCREEN_DEPRECATED_X_EVENTS',
518
+ + message: 'x-events (flat list) is deprecated. Use inline x-event instead.',
519
+ + path, location,
520
+ + });
521
+ ```
522
+
523
+ ### 6.2 新ルールの追加
524
+
525
+ ```typescript
526
+ // Validate inline x-event on GET
527
+ if (operation['x-event'] != null) {
528
+ validateInlineEvent(operation['x-event'], path, location, errors);
529
+ }
530
+
531
+ // Validate x-event on links (200 response only)
532
+ if (response200?.links) {
533
+ for (const [linkName, linkObj] of Object.entries(response200.links)) {
534
+ if (linkObj['x-event'] != null) {
535
+ validateInlineEvent(linkObj['x-event'],
536
+ `${path}.responses.200.links.${linkName}`, location, errors);
537
+ }
538
+ }
539
+ }
540
+
541
+ // Validate x-event on mutation methods (post/put/patch/delete)
542
+ for (const method of ['post', 'put', 'patch', 'delete']) {
543
+ const mutationOp = pathItem[method];
544
+ if (mutationOp?.['x-event'] != null) {
545
+ validateInlineEvent(mutationOp['x-event'],
546
+ `${path}.${method}`, location, errors);
547
+ }
548
+ }
549
+
550
+ // Validate $ref targets exist in components.x-event-defs
551
+ // ($ref は #/components/x-event-defs/* のローカル参照のみサポート)
552
+
553
+ // Validate x-events と x-event の同時使用は禁止
554
+ if (operation['x-events'] && operation['x-event'] != null) {
555
+ errors.push({
556
+ type: 'error',
557
+ code: 'SCREEN_CONFLICTING_EVENT_DEFS',
558
+ message: 'x-events and x-event cannot coexist on the same operation',
559
+ path, location,
560
+ });
561
+ }
562
+
563
+ // Validate x-interactions structure (★ 新規 — セクション 3.A 参照)
564
+ if (operation['x-interactions']) {
565
+ const interactions = operation['x-interactions'];
566
+ if (!Array.isArray(interactions)) {
567
+ errors.push({
568
+ type: 'error',
569
+ code: 'SCREEN_INVALID_INTERACTIONS',
570
+ message: 'x-interactions must be an array',
571
+ path, location,
572
+ });
573
+ } else {
574
+ for (let i = 0; i < interactions.length; i++) {
575
+ if (!interactions[i].name) {
576
+ errors.push({
577
+ type: 'error',
578
+ code: 'SCREEN_INVALID_INTERACTION',
579
+ message: `x-interactions[${i}] must have a name`,
580
+ path, location,
581
+ });
582
+ }
583
+ // 子 x-event のバリデーション
584
+ if (interactions[i]['x-event'] != null) {
585
+ validateInlineEvent(interactions[i]['x-event'],
586
+ `${path}.x-interactions[${i}]`, location, errors);
587
+ }
588
+ }
589
+ }
590
+ }
591
+
592
+ // ── 共通バリデーション関数 ──
593
+ function validateInlineEvent(raw, path, location, errors) {
594
+ if (typeof raw !== 'string' && typeof raw !== 'object') {
595
+ errors.push({
596
+ type: 'error',
597
+ code: 'SCREEN_INVALID_X_EVENT',
598
+ message: 'x-event must be a string, object with {name}, or {$ref}',
599
+ path, location,
600
+ });
601
+ }
602
+ if (typeof raw === 'object' && !raw.$ref && !raw.name) {
603
+ errors.push({
604
+ type: 'error',
605
+ code: 'SCREEN_INVALID_X_EVENT',
606
+ message: 'x-event object must have either $ref or name',
607
+ path, location,
608
+ });
609
+ }
610
+ }
611
+ ```
612
+
613
+ ---
614
+
615
+ ## 7. Handlebars ヘルパーの追加(任意)
616
+
617
+ テンプレートで使える追加ヘルパー(必要に応じて):
618
+
619
+ ```typescript
620
+ // event の有無チェック
621
+ Handlebars.registerHelper('hasEvent', (ctx) =>
622
+ !!(ctx?.screenEvent || ctx?.links?.some(l => l.event) ||
623
+ ctx?.actions?.some(a => a.event) ||
624
+ ctx?.interactions?.some(i => i.event))
625
+ );
626
+
627
+ // params を TypeScript 引数リストに展開
628
+ Handlebars.registerHelper('eventParamsSignature', (params) => {
629
+ if (!params) return '';
630
+ return Object.entries(params)
631
+ .map(([k, v]) => `${k}: ${v === 'integer' ? 'number' : v}`)
632
+ .join(', ');
633
+ });
634
+ ```
635
+
636
+ ---
637
+
638
+ ## 8. 後方互換性
639
+
640
+ | 項目 | 方針 |
641
+ |------|------|
642
+ | `x-events` (旧形式) | v0.14 では **warn + 引き続き読み込み**。`ScreenContext.events` に格納される。v0.15 で削除予定 |
643
+ | `ScreenEventDefinition.method` | v0.14 では型定義に残す(optional に変更)。テンプレートからの参照は `events[].method` で引き続き動作 |
644
+ | 旧テンプレートの `{{#each events}}` | `events` (legacy) が空でも `screenEvent` / `links[].event` から全イベントを収集する `allEvents` ヘルパーは提供しない。テンプレート側で新構造を使う |
645
+ | `x-events` と `x-event` の共存 | 同一 GET 操作に両方ある場合はエラー |
646
+ | `x-interactions` (既存 YAML) | v0.13 では無視されていたため、v0.14 で読み込み開始しても既存の生成結果に影響なし。破壊的変更にはあたらない |
647
+
648
+ ---
649
+
650
+ ## 9. テスト要件
651
+
652
+ | テスト | 内容 |
653
+ |--------|------|
654
+ | パーサーユニット | 文字列形式、オブジェクト形式、`$ref` 形式の3パターンを解決できること |
655
+ | パーサーユニット | `components.x-event-defs` が未定義の場合に安全にフォールバックすること |
656
+ | パーサーユニット | `get.x-event` → `screenEvent`、`links.*.x-event` → `ScreenLink.event`、`post.x-event` → `ScreenAction.event` に正しくマッピングされること |
657
+ | パーサーユニット | `put` / `patch` / `delete` の `x-event` も `ScreenAction` として収集されること |
658
+ | params 自動導出 | `get` 直下: path params が params として導出されること |
659
+ | params 自動導出 | `links.*`: 遷移先 route の path params が導出されること |
660
+ | params 自動導出 | `actions` の `x-event`: params が自動導出**されない**こと(明示指定のみ) |
661
+ | params 自動導出 | `x-interactions` の `x-event`: params が自動導出**されない**こと(明示指定のみ) |
662
+ | params 自動導出 | 明示 params がある場合、自動導出が上書きされないこと |
663
+ | linter | `x-events` 使用時に deprecation warning が出ること |
664
+ | linter | `x-event` の不正値(数値、空オブジェクト等)でエラーになること |
665
+ | linter | `$ref` 先が `components.x-event-defs` に存在しない場合に warning が出ること |
666
+ | linter | `$ref` が `#/components/x-event-defs/` 以外を指す場合のハンドリング |
667
+ | linter | `x-events` と `x-event` の同時使用でエラーになること |
668
+ | x-interactions パーサー | `x-interactions` 配列が `ScreenContext.interactions[]` に正しくマッピングされること |
669
+ | x-interactions パーサー | `x-interactions[].x-event` が `ScreenInteraction.event` として解決されること |
670
+ | x-interactions パーサー | `x-interactions` が省略された画面で `interactions` が空配列になること |
671
+ | x-interactions パーサー | `name` が欠落したエントリで linter エラーが出ること |
672
+ | x-interactions パーサー | `name`, `description`, `x-event` 以外のフィールドが `extras` にパススルーされること |
673
+ | x-interactions linter | `x-interactions` が配列以外の値の場合にエラーになること |
674
+ | E2E | 変換済みの画面仕様 YAML を食わせてテンプレート出力が期待通りになること |
675
+ | 後方互換 | 旧 `x-events` 形式の YAML を食わせて `ScreenContext.events` が従来通り埋まること |
676
+
677
+ ---
678
+
679
+ ## 10. テンプレートへの影響
680
+
681
+ `micro-contracts` 自体にはデフォルトの `screen-events.hbs` テンプレートは含まれていない(プロジェクト側で配置)。しかし、テンプレートに渡されるデータ構造が変わるため、プロジェクト側のテンプレート更新は必須。
682
+
683
+ 以下は **React プロジェクトでの利用例**。テンプレートはプロジェクト側が自由に定義できるため、フレームワークに依存しない形式で記述することも可能。
684
+
685
+ ```handlebars
686
+ {{#each screens}}
687
+ {{#if screenEvent}}
688
+ export function use{{screenName}}ScreenView() {
689
+ const tracked = useRef(false);
690
+ useEffect(() => {
691
+ if (tracked.current) return;
692
+ tracked.current = true;
693
+ trackEvent('{{screenEvent.name}}', '{{screenEvent.type}}', {});
694
+ }, []);
695
+ }
696
+ {{/if}}
697
+
698
+ {{#each links}}
699
+ {{#if this.event}}
700
+ export function navigateVia{{capitalize this.name}}(
701
+ {{eventParamsSignature this.event.params}}
702
+ ) {
703
+ trackEvent('{{this.event.name}}', '{{this.event.type}}',
704
+ { {{#each this.event.params}}{{@key}}{{#unless @last}}, {{/unless}}{{/each}} });
705
+ return '{{this.targetRoute}}';
706
+ }
707
+ {{/if}}
708
+ {{/each}}
709
+
710
+ {{#each actions}}
711
+ {{#if this.event}}
712
+ export function track{{capitalize this.operationId}}Event(
713
+ {{eventParamsSignature this.event.params}}
714
+ ) {
715
+ trackEvent('{{this.event.name}}', '{{this.event.type}}',
716
+ { {{#each this.event.params}}{{@key}}{{#unless @last}}, {{/unless}}{{/each}} });
717
+ }
718
+ {{/if}}
719
+ {{/each}}
720
+ {{/each}}
721
+ ```
722
+
723
+ ---
724
+
725
+ ## 11. リリース手順
726
+
727
+ 1. `micro-contracts` v0.14.0-beta.0 をリリース
728
+ 2. 利用プロジェクト A で `npm install micro-contracts@0.14.0-beta.0` → `npx micro-contracts generate` で動作確認
729
+ 3. 利用プロジェクト B で同様に確認
730
+ 4. 問題なければ v0.14.0 を正式リリース
731
+ 5. 各プロジェクトの `events.generated.ts` を再生成(手書き版を自動生成に置換)