@xrmforge/devkit 0.7.13 → 0.7.14

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.
@@ -1,707 +1,707 @@
1
- # XrmForge - AI Agent Instructions
2
-
3
- ## Quality Philosophy
4
-
5
- The goal is not "code that compiles" or "code that passes a linter". The goal is
6
- code that reads like a description of the business logic. A developer opening a
7
- file should immediately understand what happens, without Xrm API docs, without
8
- OData knowledge, without deciphering GUIDs or magic numbers.
9
-
10
- Every string that references a Dataverse resource (field name, entity name,
11
- OptionSet value, tab name, section name, notification ID, navigation property)
12
- MUST come from a generated constant or a named constant from constants.ts.
13
- No exceptions. No workarounds. No helper wrappers that accept raw strings.
14
-
15
- Abstraction layers that merely wrap single API calls with string parameters
16
- (getValue, setValue, setDisabled, addOnChange) destroy type safety and must not
17
- exist. The correct abstraction is `typedForm()` (language-level proxy), not
18
- string wrappers (API-level indirection). Business logic belongs in named
19
- functions with domain-specific names, not in anonymous chains of API calls.
20
-
21
- ## Packages
22
-
23
- - `@xrmforge/typegen` - Generates typed declarations from Dataverse metadata (Node.js CLI only, NEVER import in browser code)
24
- - `@xrmforge/helpers` - Browser-safe runtime: typedForm(), select(), parseLookup(), formLookup(), Xrm constants, Action executors
25
- - `@xrmforge/testing` - Type-safe form mocks: createFormMock(), fireOnChange(), setupXrmMock()
26
- - `@xrmforge/devkit` - esbuild IIFE bundles via xrmforge build
27
- - `@xrmforge/eslint-plugin` - D365-specific ESLint rules
28
-
29
- ## Generated Types (generated/ directory)
30
-
31
- Run `xrmforge generate` to create:
32
- - `generated/forms/{entity}.ts` - Form interface + Fields/Tabs/Sections/Subgrids enums
33
- - `generated/optionsets/{entity}.ts` - OptionSet const enums
34
- - `generated/entities/{entity}.ts` - Entity interface (for Web API response typing)
35
- - `generated/fields/{entity}.ts` - Entity Fields enum for type-safe $select queries
36
- - `generated/entity-names.ts` - EntityNames const enum
37
- - `generated/actions/global.ts` - Custom API Action executors (typed params + results)
38
- - `generated/functions/global.ts` - Custom API Function executors
39
- - `generated/form-mapping.json` - Entity to form interface mapping (read after generate!)
40
- - `generated/index.ts` - Barrel file with `export * from` re-exports
41
-
42
- **After generate:** Read `generated/form-mapping.json` for the mapping of entity logical
43
- names to form interface names. Do NOT guess interface names from entity names.
44
- Fields enum member names are based on the **primary language label** (often German),
45
- not the logical field name. Always read the generated files to get correct names.
46
-
47
- **System entities:** If a form script needs an entity NOT in the generated EntityNames
48
- (e.g. transactioncurrency, pricelevel, uom, systemuser), re-run generate with
49
- `--entities transactioncurrency,pricelevel,...` to include them. NEVER create a local
50
- `SystemEntities` object with raw strings as a workaround.
51
-
52
- ## Rules: MANDATORY (every violation is a bug)
53
-
54
- ### 1. typedForm() for ALL field access (primary pattern)
55
-
56
- Use `typedForm<FormTypeInfo>(formContext)` from `@xrmforge/helpers` to create a
57
- typed proxy. **Pass the generated `<Form>TypeInfo` type, not the bare form interface:** the
58
- TypeInfo bundles the field/attribute/control maps so type extraction stays reliable across
59
- package boundaries (the bare interface resolves to `never` in consumer projects). Access
60
- fields as direct properties instead of getAttribute chains.
61
-
62
- ```typescript
63
- import { typedForm } from '@xrmforge/helpers';
64
- import type { AccountLMFirmaFormTypeInfo } from '../../generated/forms/account.js';
65
- import { AccountLMFirmaFormFieldsEnum as Fields } from '../../generated/forms/account.js';
66
-
67
- export const onLoad = wrapHandler('LM.Account.onLoad', logger, (ctx) => {
68
- const form = typedForm<AccountLMFirmaFormTypeInfo>(ctx.getFormContext());
69
-
70
- // Direct field access - fully typed, IDE autocomplete works
71
- const name = form.name.getValue(); // string | null
72
- form.revenue.setValue(150000); // NumberAttribute
73
- const parent = form.parentaccountid.getValue(); // LookupValue[] | null
74
-
75
- // addOnChange directly on the proxy (NOT via $context.getAttribute)
76
- form.name.addOnChange(() => { logger.debug('Name changed'); });
77
- form.revenue.addOnChange(() => { recalculate(form); });
78
-
79
- // Control access via controls proxy (typed from ControlMap, no cast needed)
80
- form.controls.name.setDisabled(true);
81
- form.controls.customerid.setEntityTypes([EntityNames.Account]); // LookupControl
82
- form.controls.revenue.setVisible(false); // NumberControl
83
-
84
- // Full FormContext for ui, data, tabs
85
- form.$context.ui.setFormNotification('OK', FormNotificationLevel.Info, 'id');
86
- });
87
- ```
88
-
89
- **Use `form.fieldname` (the typedForm proxy) for EVERYTHING on the attribute:**
90
- - `getValue()`, `setValue()` (reading and writing values)
91
- - `addOnChange()`, `removeOnChange()` (event registration)
92
- - `setRequiredLevel()`, `setSubmitMode()` (attribute-level settings)
93
- - Any attribute method: the proxy returns the full typed Attribute object
94
-
95
- **Use `form.controls.fieldname` for control-level operations:**
96
- - `setDisabled()`, `setVisible()`, `setLabel()`
97
- - `addPreSearch()` (on LookupControl)
98
- - `setNotification()`, `clearNotification()`
99
-
100
- **Use `form.$context` ONLY for FormContext-level operations:**
101
- - `form.$context.ui` (setFormNotification, tabs, close)
102
- - `form.$context.data` (save, entity, process)
103
- - `form.$context.ui.getFormType()`
104
- - NOT for getAttribute (use the proxy instead)
105
-
106
- **When to use `form.$unsafe(EntityFields.X)` (off-form fields):**
107
-
108
- D365 loads all entity attributes into the FormContext, not just those on the form.
109
- If a field is NOT in the generated form interface (compile error on `form.fieldname`),
110
- use `$unsafe()` with the **Entity-level Fields Enum** (never a raw string):
111
-
112
- ```typescript
113
- import { OpportunityFields } from '../../generated/fields/opportunity.js';
114
-
115
- // Off-form field: not in the form interface, but loaded by D365
116
- form.$unsafe(OpportunityFields.VslBeauftragung)?.setValue(closeDate);
117
-
118
- // WRONG: raw string in $unsafe
119
- form.$unsafe('estimatedclosedate')?.setValue(closeDate);
120
- ```
121
-
122
- `$unsafe()` returns `Attribute | null` (nullable, because the field may not exist).
123
- Always use optional chaining (`?.`). The Entity-level Fields Enum ensures the field
124
- name is valid even though it's not on the form.
125
-
126
- ### 2. Fields Enum for ALL getAttribute/getControl AND select() calls
127
-
128
- Two types of Fields enums exist:
129
- - **Form-level**: `AccountLMFirmaFormFieldsEnum` (from `generated/forms/`) - for getAttribute/getControl on the form
130
- - **Entity-level**: `AccountFields` (from `generated/fields/`) - for Web API $select queries
131
-
132
- ```typescript
133
- // Form field access (form-level Fields):
134
- import { AccountLMFirmaFormFieldsEnum as Fields } from '../../generated/forms/account.js';
135
- form.$context.getAttribute(Fields.Name).addOnChange(() => { ... });
136
-
137
- // Web API queries (entity-level Fields):
138
- import { AccountFields } from '../../generated/fields/account.js';
139
- Xrm.WebApi.retrieveRecord(EntityNames.Account, id,
140
- select(AccountFields.Name, AccountFields.WebsiteUrl));
141
- ```
142
-
143
- **NEVER use raw strings in select():**
144
- ```typescript
145
- select('name', 'websiteurl') // BUG - raw strings
146
- select(AccountFields.Name, AccountFields.WebsiteUrl) // CORRECT
147
- ```
148
-
149
- ### 3. OptionSet Enum for ALL value comparisons AND FetchXML
150
-
151
- Never use magic numbers. Not in comparisons, not in FetchXML, not anywhere.
152
-
153
- ```typescript
154
- import { InvoiceStatusCode } from '../../generated/optionsets/invoice.js';
155
-
156
- // Comparisons:
157
- if (status === InvoiceStatusCode.Gebucht) { ... } // CORRECT
158
- if (status === 105710002) { ... } // BUG
159
-
160
- // FetchXML:
161
- `<condition attribute='${InvoiceFields.Statuscode}' operator='in'>
162
- <value>${InvoiceStatusCode.Aktiv}</value>
163
- <value>${InvoiceStatusCode.Gebucht}</value>
164
- </condition>` // CORRECT
165
-
166
- `<condition attribute='statuscode' operator='in'>
167
- <value>1</value><value>105710002</value>
168
- </condition>` // BUG - raw strings AND magic numbers
169
- ```
170
-
171
- ### 4. EntityNames Enum in ALL Xrm.WebApi calls
172
-
173
- No exceptions, even for system entities. If missing, extend generation.
174
-
175
- ```typescript
176
- import { EntityNames } from '../../generated/entity-names.js';
177
- Xrm.WebApi.retrieveRecord(EntityNames.Account, id, query);
178
- ```
179
-
180
- ### 5. Lookup helpers from @xrmforge/helpers
181
-
182
- ```typescript
183
- import { formLookup, formLookupId, parseLookup } from '@xrmforge/helpers';
184
- // Form (via typedForm proxy):
185
- const customer = formLookup(form.parentaccountid);
186
- const customerId = formLookupId(form.parentaccountid);
187
- // Web API response (use NavigationProperties enum, NOT raw strings):
188
- import { AccountNavigationProperties as AccountNav } from '../../generated/entities/account.js';
189
- const parent = parseLookup(apiResponse, AccountNav.ParentAccountId);
190
- ```
191
-
192
- ### 6. select(), $filter, $expand, $orderby with Fields Enums
193
-
194
- ALL OData query parts must use entity-level Fields Enums. No raw field name strings anywhere.
195
-
196
- ```typescript
197
- import { select, selectExpand } from '@xrmforge/helpers';
198
- import { AccountFields } from '../../generated/fields/account.js';
199
-
200
- // $select:
201
- select(AccountFields.Name, AccountFields.Revenue)
202
-
203
- // $filter (field names via template literal):
204
- `${select(AccountFields.Name)}&$filter=${AccountFields.Statecode} eq 0`
205
-
206
- // $expand (navigation properties):
207
- selectExpand(
208
- [AccountFields.Name, AccountFields.Revenue],
209
- `primarycontactid($select=${ContactFields.Fullname})`,
210
- )
211
-
212
- // $orderby:
213
- `${select(AccountFields.Name)}&$orderby=${AccountFields.Name} asc`
214
- ```
215
-
216
- ### 6b. Web API response typing with generated Entity interfaces
217
-
218
- Always type Web API responses with generated Entity interfaces. Never access properties with `as string` casts.
219
-
220
- ```typescript
221
- import type { Account } from '../../generated/entities/account.js';
222
-
223
- const result = await Xrm.WebApi.retrieveRecord(
224
- EntityNames.Account, id, select(AccountFields.Name)
225
- ) as Account;
226
- result.name // typed as string | null, no cast needed
227
- ```
228
-
229
- ### 7. wrapHandler() around EVERY exported handler
230
-
231
- ```typescript
232
- export const onLoad = wrapHandler('Namespace.Entity.onLoad', logger, async (ctx) => {
233
- const form = typedForm<MyFormTypeInfo>(ctx.getFormContext());
234
- // ...
235
- });
236
- ```
237
-
238
- ### 8. Custom API Executors from generated/actions/
239
-
240
- Never build your own ExecuteFunctionCall wrapper. Use the generated executors:
241
-
242
- ```typescript
243
- import { CreateEMailFromInvoice } from '../../generated/actions/global.js';
244
- import { withProgress } from '@xrmforge/helpers';
245
-
246
- // withProgress(message, operation): the operation is a thunk (() => Promise),
247
- // NOT an already-started promise, and the first argument is the progress message.
248
- const result = await withProgress(
249
- lang.creatingEmail,
250
- () => CreateEMailFromInvoice.execute({ InvoiceId: recordId }),
251
- );
252
- // result.EmailId is typed as string
253
- ```
254
-
255
- ### 9. Named constants for ALL non-obvious values
256
-
257
- ```typescript
258
- const MS_PER_DAY = 24 * 60 * 60 * 1000;
259
- form.lm_zahlungsziel.setValue(new Date(date.getTime() + days * MS_PER_DAY));
260
- // NEVER: new Date(date.getTime() + days * 86400000)
261
- ```
262
-
263
- ### 10. Localized UI strings via pickLang()
264
-
265
- All user-visible strings MUST go through `pickLang()` in constants.ts:
266
-
267
- ```typescript
268
- // constants.ts:
269
- export const MESSAGES = {
270
- de: { titlePlaceholder: '[Kurzbeschreibung der Anfrage]' },
271
- en: { titlePlaceholder: '[Brief description]' },
272
- } as const;
273
- export function pickLang<K extends string>(languageId: number, table: { de: Record<K, string>; en: Record<K, string> }): Record<K, string>;
274
-
275
- // form script:
276
- import { MESSAGES, pickLang } from '../shared/constants.js';
277
- const lang = pickLang(Xrm.Utility.getGlobalContext().userSettings.languageId, MESSAGES);
278
- form.title.setValue(lang.titlePlaceholder);
279
- ```
280
-
281
- ### 11. Tabs, Sections, Subgrids via generated enums
282
-
283
- Never use raw strings for tab, section, or subgrid names:
284
-
285
- ```typescript
286
- import { AccountLMFirmaFormTabs as Tabs } from '../../generated/forms/account.js';
287
- import { AccountLMFirmaFormSUMMARYTABSections as SummarySections } from '../../generated/forms/account.js';
288
- import { AccountLMFirmaFormSubgrids as Subgrids } from '../../generated/forms/account.js';
289
-
290
- form.$context.ui.tabs.get(Tabs.SUMMARYTAB).setVisible(true);
291
- form.$context.ui.tabs.get(Tabs.SUMMARYTAB).sections.get(SummarySections.General).setVisible(false);
292
- (form.$context.getControl(Subgrids.Orders) as Xrm.Controls.GridControl).refresh();
293
- ```
294
-
295
- ### 12. Notification IDs from NOTIFICATION_IDS
296
-
297
- All notification unique IDs must be in `constants.ts`, never inline raw strings:
298
-
299
- ```typescript
300
- // constants.ts:
301
- export const NOTIFICATION_IDS = {
302
- genericError: 'lmapp.notification.generic-error',
303
- saveWarning: 'lmapp.notification.save-warning',
304
- addressMissing: 'lmapp.notification.address-missing',
305
- } as const;
306
-
307
- // form script:
308
- form.$context.ui.setFormNotification(msg, FormNotificationLevel.Error, NOTIFICATION_IDS.genericError);
309
- ```
310
-
311
- ### 13. Xrm constants from @xrmforge/helpers for ALL Xrm enum values
312
-
313
- Never use raw strings or magic numbers for Xrm API constants:
314
-
315
- ```typescript
316
- import { SaveMode, FormNotificationLevel, RequiredLevel, SubmitMode, DisplayState } from '@xrmforge/helpers';
317
-
318
- // Save mode:
319
- if (ctx.getEventArgs().getSaveMode() === SaveMode.AutoSave) { ... } // not === 70
320
-
321
- // Form type (const enum from @types/xrm, works at runtime):
322
- if (form.$context.ui.getFormType() === FormType.Create) { ... } // not === 1
323
-
324
- // Display state:
325
- if (tab.getDisplayState() === DisplayState.Expanded) { ... } // not === 'expanded'
326
-
327
- // Required level:
328
- form.$context.getAttribute(Fields.Name).setRequiredLevel(RequiredLevel.Required); // not 'required'
329
-
330
- // Submit mode:
331
- form.$context.getAttribute(Fields.Name).setSubmitMode(SubmitMode.Always); // not 'always'
332
-
333
- // Notification level:
334
- form.$context.ui.setFormNotification(msg, FormNotificationLevel.Error, id); // not 'ERROR'
335
- ```
336
-
337
- ### 14. EntityNames in openForm and ALL entity references
338
-
339
- ```typescript
340
- // openForm:
341
- Xrm.Navigation.openForm({ entityName: EntityNames.Account, entityId: id }); // not "account"
342
-
343
- // openWebResource, openUrl, etc.: use EntityNames wherever an entity name appears
344
- ```
345
-
346
- ### 15. Module exports, Structured Logger, createFormMock
347
-
348
- - Module exports (not window/global assignments). esbuild globalName handles namespacing.
349
- - `createLogger()` instead of console.* (except in logger.ts itself)
350
- - `createFormMock()` from @xrmforge/testing for ALL form tests
351
-
352
- ## Rules: NEVER (every occurrence is a bug)
353
-
354
- **Field/Entity/Resource names:**
355
- - Never raw strings in `getAttribute()`, `getControl()`, `select()`, `$filter`, `$expand`, `$orderby`, `parseLookup()`, FetchXML `attribute=`, or any function that takes a field name
356
- - Never raw entity name strings in `Xrm.WebApi`, `Xrm.Navigation.openForm`, or anywhere an entity name appears (use `EntityNames`)
357
- - Never raw tab/section/subgrid names (use generated Tabs/Sections/Subgrids enums)
358
- - Never raw notification IDs (use `NOTIFICATION_IDS` from constants.ts)
359
- - Never create `SystemEntities` objects with raw strings (extend generation with `--entities`)
360
-
361
- **Magic values:**
362
- - Never magic numbers for OptionSet values, status codes, or FetchXML `<value>` (use OptionSet Enums)
363
- - Never magic numbers for time calculations (use named constants like `MS_PER_DAY`)
364
- - Never `getSaveMode() === 70` (use `SaveMode.AutoSave` from @xrmforge/helpers)
365
- - Never `getFormType() === 1` (use `FormType.Create` from `@xrmforge/helpers`)
366
- - Never `XrmEnum.FormType` (does NOT exist at runtime, esbuild does not resolve const enums from .d.ts. Use `FormType` from `@xrmforge/helpers`)
367
- - Never `'expanded'`/`'collapsed'` (use `DisplayState` from @xrmforge/helpers)
368
- - Never `'ERROR'`/`'INFO'`/`'WARNING'` (use `FormNotificationLevel`)
369
- - Never `'none'`/`'required'`/`'recommended'` (use `RequiredLevel`)
370
- - Never `'always'`/`'dirty'` (use `SubmitMode`)
371
-
372
- **Web API responses:**
373
- - Never access WebApi response properties with `as string` casts (use generated Entity interfaces)
374
- - Never `.getValue()[0].id` for lookups (use `formLookup`/`formLookupId`)
375
- - Never raw strings in `parseLookup()` (use NavigationProperties enum)
376
- - Never raw strings in `$unsafe()` (use Entity-level Fields Enum: `form.$unsafe(AccountFields.X)`)
377
- - Never manual OData annotation access (`_value`, `@OData.Community.Display.V1.FormattedValue`, `@Microsoft.Dynamics.CRM.lookuplogicalname`). Use `parseLookup()` which extracts all three.
378
-
379
- **Code quality:**
380
- - Never `Xrm.Page` (deprecated since D365 v9.0)
381
- - Never `eval()`, never synchronous XMLHttpRequest
382
- - Never `window.X = ...` (use module exports)
383
- - Never `console.log/warn/error` in form scripts (use shared logger)
384
- - Never export handlers without `wrapHandler()`
385
- - Never unlokalized UI strings (use `pickLang()` from constants.ts)
386
- - Never build your own getValue/setFieldValue/setDisabled/addOnChange helpers (use `typedForm` + native Xrm API)
387
- - Never `import ... from '@xrmforge/typegen'` in browser code (use `@xrmforge/helpers`)
388
- - Never `as Xrm.Controls.LookupControl` or similar control casts (`form.controls.fieldname` returns the typed control from ControlMap)
389
- - Never `as any` without eslint-disable comment explaining why
390
- - Never untyped `catch (error)` (always `catch (error: unknown)`)
391
-
392
- ## Subagent Handoff (when delegating to sub-agents)
393
-
394
- Copy these MANDATORY rules into every sub-agent prompt:
395
-
396
- ```
397
- 1. typedForm<MyFormTypeInfo>(ctx.getFormContext()) for ALL field access (generated TypeInfo, NOT the bare form interface)
398
- 2. Entity-level Fields Enums in ALL select(), $filter, $expand, $orderby, FetchXML attribute=
399
- 3. OptionSet Enum for ALL value comparisons AND FetchXML <value> (never magic numbers)
400
- 4. EntityNames for ALL Xrm.WebApi calls AND openForm (never raw entity names)
401
- 5. formLookup/formLookupId for ALL lookup access (never .getValue()[0].id)
402
- 6. parseLookup with NavigationProperties enum (never raw nav property strings)
403
- 7. Generated Entity interfaces for ALL WebApi response typing (never as string casts)
404
- 8. Tabs/Sections/Subgrids enums for ALL UI structure access (never raw strings)
405
- 9. SaveMode/FormType/DisplayState/RequiredLevel/SubmitMode/FormNotificationLevel constants
406
- 10. wrapHandler() around EVERY exported handler
407
- 11. createLogger() instead of console.* (except logger.ts)
408
- 12. Custom API Executors from generated/actions/ (never build your own)
409
- 13. NOTIFICATION_IDS from constants.ts for all notification unique IDs
410
- 14. Named constants for non-obvious values (never magic numbers like 86400000)
411
- 15. pickLang() for all user-visible strings (never hardcoded German/English)
412
- ```
413
-
414
- ## Mandatory Shared Utilities
415
-
416
- Every XrmForge project MUST have these in `src/shared/`:
417
-
418
- ### logger.ts
419
- ```typescript
420
- export interface Logger { debug(msg: string, data?: unknown): void; info(...); warn(...); error(...); }
421
- export function createLogger(namespace: string): Logger;
422
- // Only file allowed to use console.*
423
- ```
424
-
425
- ### error-handler.ts
426
- ```typescript
427
- export function wrapHandler(name: string, logger: Logger, handler: EventHandler): EventHandler;
428
- export function wrapCommand(name: string, logger: Logger, handler: CommandHandler): CommandHandler;
429
- // Catches sync+async errors, shows form notification via FormNotificationLevel.Error
430
- ```
431
-
432
- ### constants.ts
433
- ```typescript
434
- export const NOTIFICATION_IDS = { ... } as const;
435
- export const MESSAGES = { de: { ... }, en: { ... } } as const;
436
- export function pickLang<K extends string>(languageId: number, table: ...): Record<K, string>;
437
- ```
438
-
439
- ## Before/After Examples
440
-
441
- ### Field Access (primary pattern: typedForm)
442
- ```typescript
443
- // BEFORE (legacy):
444
- formContext.getAttribute("name").getValue()
445
- // BEFORE (getAttribute + Fields):
446
- form.getAttribute(Fields.Name).getValue()
447
- // AFTER (typedForm - preferred):
448
- const form = typedForm<AccountFormTypeInfo>(ctx.getFormContext());
449
- form.name.getValue()
450
- ```
451
-
452
- ### Web API Query
453
- ```typescript
454
- // BEFORE:
455
- Xrm.WebApi.retrieveRecord("account", id, "?$select=name,revenue")
456
- // AFTER:
457
- import { AccountFields } from '../../generated/fields/account.js';
458
- Xrm.WebApi.retrieveRecord(EntityNames.Account, id,
459
- select(AccountFields.Name, AccountFields.Revenue))
460
- ```
461
-
462
- ### OptionSet Comparison
463
- ```typescript
464
- // BEFORE: if (status.getValue() === 595300002) { ... }
465
- // AFTER:
466
- import { StatusCode } from '../../generated/optionsets/invoice.js';
467
- if (form.statuscode.getValue() === StatusCode.Gebucht) { ... }
468
- ```
469
-
470
- ### FetchXML
471
- ```typescript
472
- // BEFORE:
473
- `<condition attribute='statuscode' operator='in'><value>1</value><value>105710000</value></condition>`
474
- // AFTER:
475
- import { LmBestellungFields } from '../../generated/fields/lm_bestellung.js';
476
- import { LmBestellungStatusCode } from '../../generated/optionsets/lm_bestellung.js';
477
- `<condition attribute='${LmBestellungFields.Statuscode}' operator='in'>
478
- <value>${LmBestellungStatusCode.Aktiv}</value>
479
- <value>${LmBestellungStatusCode.InArbeit}</value>
480
- </condition>`
481
- ```
482
-
483
- ### Lookup Access (Form)
484
- ```typescript
485
- // BEFORE: form.getAttribute("customerid").getValue()[0].id.replace("{","").replace("}","")
486
- // AFTER:
487
- const customerId = formLookupId(form.customerid);
488
- ```
489
-
490
- ### Lookup from WebApi Response to Form Field
491
- ```typescript
492
- // BEFORE (verbose, error-prone OData annotations):
493
- form.customerid.setValue([{
494
- id: raw._parentcustomerid_value as string,
495
- entityType: EntityNames.Account,
496
- name: (raw['_parentcustomerid_value@OData.Community.Display.V1.FormattedValue'] as string) ?? '',
497
- }]);
498
-
499
- // AFTER (parseLookup extracts id, name, entityType automatically):
500
- import { parseLookup } from '@xrmforge/helpers';
501
- import { ContactNavigationProperties as ContactNav } from '../../generated/entities/contact.js';
502
-
503
- const customer = parseLookup(raw, ContactNav.ParentCustomerId);
504
- if (customer) {
505
- form.customerid.setValue([customer]);
506
- }
507
- ```
508
-
509
- ### Custom API Call
510
- ```typescript
511
- // BEFORE: ExecuteFunctionCall("CancelInvoice", { InvoiceId: id })
512
- // AFTER:
513
- import { CancelInvoice } from '../../generated/actions/global.js';
514
- const result = await withProgress(
515
- lang.cancellingInvoice,
516
- () => CancelInvoice.execute({ InvoiceId: id }),
517
- );
518
- ```
519
-
520
- ### Date Calculation
521
- ```typescript
522
- // BEFORE: new Date(date.getTime() + nettotage * 86400000)
523
- // AFTER:
524
- const MS_PER_DAY = 24 * 60 * 60 * 1000;
525
- new Date(date.getTime() + nettotage * MS_PER_DAY)
526
- ```
527
-
528
- ### UI Strings
529
- ```typescript
530
- // BEFORE: form.title.setValue('[Kurzbeschreibung der Anfrage]')
531
- // AFTER:
532
- const lang = pickLang(Xrm.Utility.getGlobalContext().userSettings.languageId, MESSAGES);
533
- form.title.setValue(lang.titlePlaceholder);
534
- ```
535
-
536
- ## Testing (onLoad + onChange)
537
-
538
- ```typescript
539
- import { createFormMock, setupXrmMock, teardownXrmMock } from '@xrmforge/testing';
540
-
541
- beforeEach(() => setupXrmMock());
542
- afterEach(() => teardownXrmMock());
543
-
544
- // onLoad test:
545
- const mock = createFormMock<AccountMainForm>({ name: 'Test', statuscode: 0 });
546
- onLoad(mock.asEventContext());
547
- expect(mock.getControl(Fields.Revenue).getVisible()).toBe(true);
548
-
549
- // onChange test (MANDATORY for every onChange handler):
550
- mock.setValue(Fields.Revenue, 500000);
551
- mock.fireOnChange(Fields.Revenue);
552
- expect(mock.getControl(Fields.CreditLimit).getVisible()).toBe(true);
553
- ```
554
-
555
- **Test quality rule:** At least 30% of tests MUST use `fireOnChange` or WebApi mock
556
- assertions. Pure smoke tests (`onLoad` + `not.toThrow`) do NOT count.
557
- Every onChange handler MUST have a `fireOnChange` test.
558
-
559
- **attr.controls:** Since @xrmforge/testing 0.2.3, `createFormMock()` automatically links
560
- each attribute to its control. `mock.getControl(Fields.Name)` works out of the box.
561
-
562
- ## Pattern Recognition: Legacy to XrmForge
563
-
564
- ### Xrm API Patterns
565
- | Legacy Pattern | XrmForge Replacement |
566
- |---|---|
567
- | `getAttribute("name")` | `form.name` (via typedForm) |
568
- | `getControl("name")` | `form.controls.name` |
569
- | `Xrm.Page.getAttribute(...)` | `form.fieldname` (via typedForm) |
570
- | `var formContext` (global) | `const form = typedForm<MyFormTypeInfo>(ctx.getFormContext())` |
571
- | `function form_OnLoad(ctx)` | `export const onLoad = wrapHandler(...)` |
572
- | `Xrm.WebApi.retrieveRecord("account", id)` | `Xrm.WebApi.retrieveRecord(EntityNames.Account, id)` |
573
- | `"?$select=name,revenue"` | `select(AccountFields.Name, AccountFields.Revenue)` |
574
- | `value[0].id.replace("{","")` | `formLookupId(form.customerid)` |
575
- | `ExecuteFunctionCall("name", ...)` | `import { Name } from '../../generated/actions/global.js'` |
576
- | `setFormNotification(msg, 'ERROR', id)` | `setFormNotification(msg, FormNotificationLevel.Error, id)` |
577
- | `getValue() === 595300000` | `form.statuscode.getValue() === StatusCode.Active` |
578
- | `86400000` | `const MS_PER_DAY = 24 * 60 * 60 * 1000` |
579
- | `'[Kurzbeschreibung]'` | `pickLang(languageId, MESSAGES).placeholder` |
580
-
581
- ### Legacy Helper Functions (DO NOT recreate, use typedForm instead)
582
-
583
- These helper wrappers are common in legacy code. They destroy type safety.
584
- Never recreate them. Use the typed API directly.
585
-
586
- | Legacy Helper | XrmForge Replacement |
587
- |---|---|
588
- | `GetValue(fieldName)` | `form.fieldname.getValue()` (typed via typedForm) |
589
- | `SetValue(fieldName, value)` | `form.fieldname.setValue(value)` (typed via typedForm) |
590
- | `SetDisabled(attributeName, disabled)` | `form.controls.fieldname.setDisabled(disabled)` |
591
- | `SetVisible(attributeName, visible)` | `form.controls.fieldname.setVisible(visible)` |
592
- | `SetRequiredLevel(attributeName, level)` | `form.$context.getAttribute(Fields.X).setRequiredLevel(RequiredLevel.Required)` |
593
- | `AddOnChange(attributeName, callback)` | `form.$context.getAttribute(Fields.X).addOnChange(cb)` |
594
- | `AddPreSearch(controlName, callback)` | `form.controls.fieldname.addPreSearch(cb)` (typed as LookupControl from ControlMap) |
595
- | `GetLookupValueId(fieldName)` | `formLookupId(form.fieldname)` |
596
- | `SetLookupValue(field, id, type, name)` | `form.fieldname.setValue([{ id, entityType, name }])` |
597
- | `GetId()` | `form.$context.data.entity.getId()` |
598
- | `GetEntityName()` | `form.$context.data.entity.getEntityName()` |
599
- | `GetFormType()` | `form.$context.ui.getFormType()` |
600
- | `GetIsDirty()` | `form.$context.data.entity.getIsDirty()` |
601
- | `IsNullOrEmpty(value)` | `value == null \|\| value === ''` (inline) |
602
- | `IsAttributeNullOrEmpty(field)` | `form.fieldname.getValue() == null` |
603
- | `GetUserId()` | `Xrm.Utility.getGlobalContext().userSettings.userId` |
604
- | `GetUserLanguageId()` | `Xrm.Utility.getGlobalContext().userSettings.languageId` |
605
- | `OpenForm(entityName, id)` | `Xrm.Navigation.openForm({ entityName: EntityNames.X, entityId: id })` |
606
- | `OpenAlertDialog(text)` | `Xrm.Navigation.openAlertDialog({ text })` |
607
- | `OpenConfirmDialog(text, ...)` | `Xrm.Navigation.openConfirmDialog({ text, title, ... })` |
608
- | `ShowProgressIndicator(msg)` | `Xrm.Utility.showProgressIndicator(msg)` |
609
- | `CloseProgressIndicator()` | `Xrm.Utility.closeProgressIndicator()` |
610
- | `SetNotification(attr, msg)` | `form.$context.getControl(Fields.X).setNotification(msg, NOTIFICATION_IDS.x)` |
611
- | `SetSectionDisabled(tab, sec, off)` | `form.$context.ui.tabs.get(Tabs.X).sections.get(Sections.Y).setVisible(!off)` |
612
-
613
- ### GUID Handling (common CRM anti-pattern)
614
-
615
- D365 returns GUIDs in various formats: `{A1B2C3D4-...}`, `a1b2c3d4-...`, `A1B2C3D4-...`.
616
- Legacy code commonly has helpers like `CompareGuid()`, `GetCompatibleGuid()`,
617
- `NormalizeGuid()`, `StripBraces()`. **Do NOT recreate these.**
618
-
619
- `formLookupId()` from @xrmforge/helpers already normalizes GUIDs (removes braces).
620
- GUID comparison is then a simple `===`:
621
-
622
- ```typescript
623
- // WRONG: legacy GUID helpers
624
- function CompareGuid(a, b) { return a.replace(/[{}]/g,'').toLowerCase() === b.replace(/[{}]/g,'').toLowerCase(); }
625
- const id = GetCompatibleGuid(form.getAttribute("customerid").getValue()[0].id);
626
-
627
- // CORRECT: formLookupId normalizes automatically
628
- const customerId = formLookupId(form.customerid); // already clean: "a1b2c3d4-..."
629
- if (customerId === otherNormalizedId) { ... } // simple ===
630
- ```
631
-
632
- ### Typed repetition beats untyped loops
633
-
634
- When multiple fields need the same operation (e.g. 8 address fields), write
635
- 8 typed lines instead of 1 loop with raw strings:
636
-
637
- ```typescript
638
- // WRONG: DRY reflex, but raw strings bypass type safety
639
- for (const f of ['address1_name', 'address1_line1', 'address1_city']) {
640
- form.$unsafe(f)?.addOnChange(handler);
641
- }
642
-
643
- // CORRECT: more lines, but every field is compile-time validated
644
- form.address1_name.addOnChange(handler);
645
- form.address1_line1.addOnChange(handler);
646
- form.address1_city.addOnChange(handler);
647
- form.address1_postalcode.addOnChange(handler);
648
- form.address1_country.addOnChange(handler);
649
- ```
650
-
651
- 8 typed lines are better than 1 loop with raw strings. The type system
652
- catches renamed/removed fields at compile time. A loop with raw strings
653
- only fails at runtime. DRY is a recommendation, type safety is mandatory.
654
-
655
- **Rule of thumb:** If a helper function just wraps a single Xrm API call with a
656
- string parameter, it MUST NOT exist. The typed API is shorter, safer, and provides
657
- IDE autocomplete. Only keep shared helpers that contain actual domain logic
658
- (calculations, WebApi queries, multi-step workflows).
659
-
660
- ## @types/xrm Pitfalls (known issues)
661
-
662
- 1. **Form Interface:** Do NOT use `interface extends Xrm.FormContext`. Use `Omit` pattern.
663
- 2. **AlertDialogResponse** does NOT exist. Use `Xrm.Async.PromiseLike<void>`.
664
- 3. **ConfirmDialogResponse** does NOT exist. Use `Xrm.Navigation.ConfirmResult`.
665
- 4. **setNotification()** requires 2 arguments: (message, uniqueId).
666
- 5. **openFile()** requires `fileSize` property in FileDetails.
667
- 6. **Grid.refresh()** requires `(grid as any).refresh()` with eslint-disable comment.
668
-
669
- ## Build
670
-
671
- ```bash
672
- npx xrmforge build # IIFE bundles for D365
673
- npx xrmforge build --watch # Watch mode (~10ms rebuilds)
674
- ```
675
-
676
- ## Drift Check (generated/ vs. live environment)
677
-
678
- `generated/` is a snapshot of the Dataverse environment. When the environment
679
- changes after generation, the snapshot silently drifts (compiles fine, fails at
680
- runtime with cryptic OData errors). Use the drift check as a CI step:
681
-
682
- ```bash
683
- npx xrmforge generate ... --check # read-only, writes nothing
684
- # Exit 0 = up to date, 1 = error, 2 = drift detected (regenerate and commit)
685
- ```
686
-
687
- NEVER edit files under `generated/` by hand and NEVER run formatters (Prettier)
688
- or lint autofixes on them: the drift check compares byte-by-byte and would stay
689
- permanently red. After a typegen/cli upgrade a drift report is expected:
690
- regenerate and commit.
691
-
692
- ## File Structure
693
-
694
- ```
695
- src/forms/{entity}-form.ts - Form scripts (one per entity)
696
- src/shared/logger.ts - Structured logger (only file with console.*)
697
- src/shared/error-handler.ts - wrapHandler + wrapCommand
698
- src/shared/constants.ts - NOTIFICATION_IDS, MESSAGES, pickLang
699
- generated/ - Generated types (do not edit manually)
700
- tests/forms/{entity}.test.ts - Tests
701
- xrmforge.config.json - Build config
702
- scripts/validate-form.mjs - Quality gate (run after each batch)
703
- ```
704
-
705
- ## Self-Check (MANDATORY before Tests)
706
-
707
- Run `node scripts/validate-form.mjs` after every batch. Must report 0 violations.
1
+ # XrmForge - AI Agent Instructions
2
+
3
+ ## Quality Philosophy
4
+
5
+ The goal is not "code that compiles" or "code that passes a linter". The goal is
6
+ code that reads like a description of the business logic. A developer opening a
7
+ file should immediately understand what happens, without Xrm API docs, without
8
+ OData knowledge, without deciphering GUIDs or magic numbers.
9
+
10
+ Every string that references a Dataverse resource (field name, entity name,
11
+ OptionSet value, tab name, section name, notification ID, navigation property)
12
+ MUST come from a generated constant or a named constant from constants.ts.
13
+ No exceptions. No workarounds. No helper wrappers that accept raw strings.
14
+
15
+ Abstraction layers that merely wrap single API calls with string parameters
16
+ (getValue, setValue, setDisabled, addOnChange) destroy type safety and must not
17
+ exist. The correct abstraction is `typedForm()` (language-level proxy), not
18
+ string wrappers (API-level indirection). Business logic belongs in named
19
+ functions with domain-specific names, not in anonymous chains of API calls.
20
+
21
+ ## Packages
22
+
23
+ - `@xrmforge/typegen` - Generates typed declarations from Dataverse metadata (Node.js CLI only, NEVER import in browser code)
24
+ - `@xrmforge/helpers` - Browser-safe runtime: typedForm(), select(), parseLookup(), formLookup(), Xrm constants, Action executors
25
+ - `@xrmforge/testing` - Type-safe form mocks: createFormMock(), fireOnChange(), setupXrmMock()
26
+ - `@xrmforge/devkit` - esbuild IIFE bundles via xrmforge build
27
+ - `@xrmforge/eslint-plugin` - D365-specific ESLint rules
28
+
29
+ ## Generated Types (generated/ directory)
30
+
31
+ Run `xrmforge generate` to create:
32
+ - `generated/forms/{entity}.ts` - Form interface + Fields/Tabs/Sections/Subgrids enums
33
+ - `generated/optionsets/{entity}.ts` - OptionSet const enums
34
+ - `generated/entities/{entity}.ts` - Entity interface (for Web API response typing)
35
+ - `generated/fields/{entity}.ts` - Entity Fields enum for type-safe $select queries
36
+ - `generated/entity-names.ts` - EntityNames const enum
37
+ - `generated/actions/global.ts` - Custom API Action executors (typed params + results)
38
+ - `generated/functions/global.ts` - Custom API Function executors
39
+ - `generated/form-mapping.json` - Entity to form interface mapping (read after generate!)
40
+ - `generated/index.ts` - Barrel file with `export * from` re-exports
41
+
42
+ **After generate:** Read `generated/form-mapping.json` for the mapping of entity logical
43
+ names to form interface names. Do NOT guess interface names from entity names.
44
+ Fields enum member names are based on the **primary language label** (often German),
45
+ not the logical field name. Always read the generated files to get correct names.
46
+
47
+ **System entities:** If a form script needs an entity NOT in the generated EntityNames
48
+ (e.g. transactioncurrency, pricelevel, uom, systemuser), re-run generate with
49
+ `--entities transactioncurrency,pricelevel,...` to include them. NEVER create a local
50
+ `SystemEntities` object with raw strings as a workaround.
51
+
52
+ ## Rules: MANDATORY (every violation is a bug)
53
+
54
+ ### 1. typedForm() for ALL field access (primary pattern)
55
+
56
+ Use `typedForm<FormTypeInfo>(formContext)` from `@xrmforge/helpers` to create a
57
+ typed proxy. **Pass the generated `<Form>TypeInfo` type, not the bare form interface:** the
58
+ TypeInfo bundles the field/attribute/control maps so type extraction stays reliable across
59
+ package boundaries (the bare interface resolves to `never` in consumer projects). Access
60
+ fields as direct properties instead of getAttribute chains.
61
+
62
+ ```typescript
63
+ import { typedForm } from '@xrmforge/helpers';
64
+ import type { AccountLMFirmaFormTypeInfo } from '../../generated/forms/account.js';
65
+ import { AccountLMFirmaFormFieldsEnum as Fields } from '../../generated/forms/account.js';
66
+
67
+ export const onLoad = wrapHandler('LM.Account.onLoad', logger, (ctx) => {
68
+ const form = typedForm<AccountLMFirmaFormTypeInfo>(ctx.getFormContext());
69
+
70
+ // Direct field access - fully typed, IDE autocomplete works
71
+ const name = form.name.getValue(); // string | null
72
+ form.revenue.setValue(150000); // NumberAttribute
73
+ const parent = form.parentaccountid.getValue(); // LookupValue[] | null
74
+
75
+ // addOnChange directly on the proxy (NOT via $context.getAttribute)
76
+ form.name.addOnChange(() => { logger.debug('Name changed'); });
77
+ form.revenue.addOnChange(() => { recalculate(form); });
78
+
79
+ // Control access via controls proxy (typed from ControlMap, no cast needed)
80
+ form.controls.name.setDisabled(true);
81
+ form.controls.customerid.setEntityTypes([EntityNames.Account]); // LookupControl
82
+ form.controls.revenue.setVisible(false); // NumberControl
83
+
84
+ // Full FormContext for ui, data, tabs
85
+ form.$context.ui.setFormNotification('OK', FormNotificationLevel.Info, 'id');
86
+ });
87
+ ```
88
+
89
+ **Use `form.fieldname` (the typedForm proxy) for EVERYTHING on the attribute:**
90
+ - `getValue()`, `setValue()` (reading and writing values)
91
+ - `addOnChange()`, `removeOnChange()` (event registration)
92
+ - `setRequiredLevel()`, `setSubmitMode()` (attribute-level settings)
93
+ - Any attribute method: the proxy returns the full typed Attribute object
94
+
95
+ **Use `form.controls.fieldname` for control-level operations:**
96
+ - `setDisabled()`, `setVisible()`, `setLabel()`
97
+ - `addPreSearch()` (on LookupControl)
98
+ - `setNotification()`, `clearNotification()`
99
+
100
+ **Use `form.$context` ONLY for FormContext-level operations:**
101
+ - `form.$context.ui` (setFormNotification, tabs, close)
102
+ - `form.$context.data` (save, entity, process)
103
+ - `form.$context.ui.getFormType()`
104
+ - NOT for getAttribute (use the proxy instead)
105
+
106
+ **When to use `form.$unsafe(EntityFields.X)` (off-form fields):**
107
+
108
+ D365 loads all entity attributes into the FormContext, not just those on the form.
109
+ If a field is NOT in the generated form interface (compile error on `form.fieldname`),
110
+ use `$unsafe()` with the **Entity-level Fields Enum** (never a raw string):
111
+
112
+ ```typescript
113
+ import { OpportunityFields } from '../../generated/fields/opportunity.js';
114
+
115
+ // Off-form field: not in the form interface, but loaded by D365
116
+ form.$unsafe(OpportunityFields.VslBeauftragung)?.setValue(closeDate);
117
+
118
+ // WRONG: raw string in $unsafe
119
+ form.$unsafe('estimatedclosedate')?.setValue(closeDate);
120
+ ```
121
+
122
+ `$unsafe()` returns `Attribute | null` (nullable, because the field may not exist).
123
+ Always use optional chaining (`?.`). The Entity-level Fields Enum ensures the field
124
+ name is valid even though it's not on the form.
125
+
126
+ ### 2. Fields Enum for ALL getAttribute/getControl AND select() calls
127
+
128
+ Two types of Fields enums exist:
129
+ - **Form-level**: `AccountLMFirmaFormFieldsEnum` (from `generated/forms/`) - for getAttribute/getControl on the form
130
+ - **Entity-level**: `AccountFields` (from `generated/fields/`) - for Web API $select queries
131
+
132
+ ```typescript
133
+ // Form field access (form-level Fields):
134
+ import { AccountLMFirmaFormFieldsEnum as Fields } from '../../generated/forms/account.js';
135
+ form.$context.getAttribute(Fields.Name).addOnChange(() => { ... });
136
+
137
+ // Web API queries (entity-level Fields):
138
+ import { AccountFields } from '../../generated/fields/account.js';
139
+ Xrm.WebApi.retrieveRecord(EntityNames.Account, id,
140
+ select(AccountFields.Name, AccountFields.WebsiteUrl));
141
+ ```
142
+
143
+ **NEVER use raw strings in select():**
144
+ ```typescript
145
+ select('name', 'websiteurl') // BUG - raw strings
146
+ select(AccountFields.Name, AccountFields.WebsiteUrl) // CORRECT
147
+ ```
148
+
149
+ ### 3. OptionSet Enum for ALL value comparisons AND FetchXML
150
+
151
+ Never use magic numbers. Not in comparisons, not in FetchXML, not anywhere.
152
+
153
+ ```typescript
154
+ import { InvoiceStatusCode } from '../../generated/optionsets/invoice.js';
155
+
156
+ // Comparisons:
157
+ if (status === InvoiceStatusCode.Gebucht) { ... } // CORRECT
158
+ if (status === 105710002) { ... } // BUG
159
+
160
+ // FetchXML:
161
+ `<condition attribute='${InvoiceFields.Statuscode}' operator='in'>
162
+ <value>${InvoiceStatusCode.Aktiv}</value>
163
+ <value>${InvoiceStatusCode.Gebucht}</value>
164
+ </condition>` // CORRECT
165
+
166
+ `<condition attribute='statuscode' operator='in'>
167
+ <value>1</value><value>105710002</value>
168
+ </condition>` // BUG - raw strings AND magic numbers
169
+ ```
170
+
171
+ ### 4. EntityNames Enum in ALL Xrm.WebApi calls
172
+
173
+ No exceptions, even for system entities. If missing, extend generation.
174
+
175
+ ```typescript
176
+ import { EntityNames } from '../../generated/entity-names.js';
177
+ Xrm.WebApi.retrieveRecord(EntityNames.Account, id, query);
178
+ ```
179
+
180
+ ### 5. Lookup helpers from @xrmforge/helpers
181
+
182
+ ```typescript
183
+ import { formLookup, formLookupId, parseLookup } from '@xrmforge/helpers';
184
+ // Form (via typedForm proxy):
185
+ const customer = formLookup(form.parentaccountid);
186
+ const customerId = formLookupId(form.parentaccountid);
187
+ // Web API response (use NavigationProperties enum, NOT raw strings):
188
+ import { AccountNavigationProperties as AccountNav } from '../../generated/entities/account.js';
189
+ const parent = parseLookup(apiResponse, AccountNav.ParentAccountId);
190
+ ```
191
+
192
+ ### 6. select(), $filter, $expand, $orderby with Fields Enums
193
+
194
+ ALL OData query parts must use entity-level Fields Enums. No raw field name strings anywhere.
195
+
196
+ ```typescript
197
+ import { select, selectExpand } from '@xrmforge/helpers';
198
+ import { AccountFields } from '../../generated/fields/account.js';
199
+
200
+ // $select:
201
+ select(AccountFields.Name, AccountFields.Revenue)
202
+
203
+ // $filter (field names via template literal):
204
+ `${select(AccountFields.Name)}&$filter=${AccountFields.Statecode} eq 0`
205
+
206
+ // $expand (navigation properties):
207
+ selectExpand(
208
+ [AccountFields.Name, AccountFields.Revenue],
209
+ `primarycontactid($select=${ContactFields.Fullname})`,
210
+ )
211
+
212
+ // $orderby:
213
+ `${select(AccountFields.Name)}&$orderby=${AccountFields.Name} asc`
214
+ ```
215
+
216
+ ### 6b. Web API response typing with generated Entity interfaces
217
+
218
+ Always type Web API responses with generated Entity interfaces. Never access properties with `as string` casts.
219
+
220
+ ```typescript
221
+ import type { Account } from '../../generated/entities/account.js';
222
+
223
+ const result = await Xrm.WebApi.retrieveRecord(
224
+ EntityNames.Account, id, select(AccountFields.Name)
225
+ ) as Account;
226
+ result.name // typed as string | null, no cast needed
227
+ ```
228
+
229
+ ### 7. wrapHandler() around EVERY exported handler
230
+
231
+ ```typescript
232
+ export const onLoad = wrapHandler('Namespace.Entity.onLoad', logger, async (ctx) => {
233
+ const form = typedForm<MyFormTypeInfo>(ctx.getFormContext());
234
+ // ...
235
+ });
236
+ ```
237
+
238
+ ### 8. Custom API Executors from generated/actions/
239
+
240
+ Never build your own ExecuteFunctionCall wrapper. Use the generated executors:
241
+
242
+ ```typescript
243
+ import { CreateEMailFromInvoice } from '../../generated/actions/global.js';
244
+ import { withProgress } from '@xrmforge/helpers';
245
+
246
+ // withProgress(message, operation): the operation is a thunk (() => Promise),
247
+ // NOT an already-started promise, and the first argument is the progress message.
248
+ const result = await withProgress(
249
+ lang.creatingEmail,
250
+ () => CreateEMailFromInvoice.execute({ InvoiceId: recordId }),
251
+ );
252
+ // result.EmailId is typed as string
253
+ ```
254
+
255
+ ### 9. Named constants for ALL non-obvious values
256
+
257
+ ```typescript
258
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
259
+ form.lm_zahlungsziel.setValue(new Date(date.getTime() + days * MS_PER_DAY));
260
+ // NEVER: new Date(date.getTime() + days * 86400000)
261
+ ```
262
+
263
+ ### 10. Localized UI strings via pickLang()
264
+
265
+ All user-visible strings MUST go through `pickLang()` in constants.ts:
266
+
267
+ ```typescript
268
+ // constants.ts:
269
+ export const MESSAGES = {
270
+ de: { titlePlaceholder: '[Kurzbeschreibung der Anfrage]' },
271
+ en: { titlePlaceholder: '[Brief description]' },
272
+ } as const;
273
+ export function pickLang<K extends string>(languageId: number, table: { de: Record<K, string>; en: Record<K, string> }): Record<K, string>;
274
+
275
+ // form script:
276
+ import { MESSAGES, pickLang } from '../shared/constants.js';
277
+ const lang = pickLang(Xrm.Utility.getGlobalContext().userSettings.languageId, MESSAGES);
278
+ form.title.setValue(lang.titlePlaceholder);
279
+ ```
280
+
281
+ ### 11. Tabs, Sections, Subgrids via generated enums
282
+
283
+ Never use raw strings for tab, section, or subgrid names:
284
+
285
+ ```typescript
286
+ import { AccountLMFirmaFormTabs as Tabs } from '../../generated/forms/account.js';
287
+ import { AccountLMFirmaFormSUMMARYTABSections as SummarySections } from '../../generated/forms/account.js';
288
+ import { AccountLMFirmaFormSubgrids as Subgrids } from '../../generated/forms/account.js';
289
+
290
+ form.$context.ui.tabs.get(Tabs.SUMMARYTAB).setVisible(true);
291
+ form.$context.ui.tabs.get(Tabs.SUMMARYTAB).sections.get(SummarySections.General).setVisible(false);
292
+ (form.$context.getControl(Subgrids.Orders) as Xrm.Controls.GridControl).refresh();
293
+ ```
294
+
295
+ ### 12. Notification IDs from NOTIFICATION_IDS
296
+
297
+ All notification unique IDs must be in `constants.ts`, never inline raw strings:
298
+
299
+ ```typescript
300
+ // constants.ts:
301
+ export const NOTIFICATION_IDS = {
302
+ genericError: 'lmapp.notification.generic-error',
303
+ saveWarning: 'lmapp.notification.save-warning',
304
+ addressMissing: 'lmapp.notification.address-missing',
305
+ } as const;
306
+
307
+ // form script:
308
+ form.$context.ui.setFormNotification(msg, FormNotificationLevel.Error, NOTIFICATION_IDS.genericError);
309
+ ```
310
+
311
+ ### 13. Xrm constants from @xrmforge/helpers for ALL Xrm enum values
312
+
313
+ Never use raw strings or magic numbers for Xrm API constants:
314
+
315
+ ```typescript
316
+ import { SaveMode, FormNotificationLevel, RequiredLevel, SubmitMode, DisplayState } from '@xrmforge/helpers';
317
+
318
+ // Save mode:
319
+ if (ctx.getEventArgs().getSaveMode() === SaveMode.AutoSave) { ... } // not === 70
320
+
321
+ // Form type (const enum from @types/xrm, works at runtime):
322
+ if (form.$context.ui.getFormType() === FormType.Create) { ... } // not === 1
323
+
324
+ // Display state:
325
+ if (tab.getDisplayState() === DisplayState.Expanded) { ... } // not === 'expanded'
326
+
327
+ // Required level:
328
+ form.$context.getAttribute(Fields.Name).setRequiredLevel(RequiredLevel.Required); // not 'required'
329
+
330
+ // Submit mode:
331
+ form.$context.getAttribute(Fields.Name).setSubmitMode(SubmitMode.Always); // not 'always'
332
+
333
+ // Notification level:
334
+ form.$context.ui.setFormNotification(msg, FormNotificationLevel.Error, id); // not 'ERROR'
335
+ ```
336
+
337
+ ### 14. EntityNames in openForm and ALL entity references
338
+
339
+ ```typescript
340
+ // openForm:
341
+ Xrm.Navigation.openForm({ entityName: EntityNames.Account, entityId: id }); // not "account"
342
+
343
+ // openWebResource, openUrl, etc.: use EntityNames wherever an entity name appears
344
+ ```
345
+
346
+ ### 15. Module exports, Structured Logger, createFormMock
347
+
348
+ - Module exports (not window/global assignments). esbuild globalName handles namespacing.
349
+ - `createLogger()` instead of console.* (except in logger.ts itself)
350
+ - `createFormMock()` from @xrmforge/testing for ALL form tests
351
+
352
+ ## Rules: NEVER (every occurrence is a bug)
353
+
354
+ **Field/Entity/Resource names:**
355
+ - Never raw strings in `getAttribute()`, `getControl()`, `select()`, `$filter`, `$expand`, `$orderby`, `parseLookup()`, FetchXML `attribute=`, or any function that takes a field name
356
+ - Never raw entity name strings in `Xrm.WebApi`, `Xrm.Navigation.openForm`, or anywhere an entity name appears (use `EntityNames`)
357
+ - Never raw tab/section/subgrid names (use generated Tabs/Sections/Subgrids enums)
358
+ - Never raw notification IDs (use `NOTIFICATION_IDS` from constants.ts)
359
+ - Never create `SystemEntities` objects with raw strings (extend generation with `--entities`)
360
+
361
+ **Magic values:**
362
+ - Never magic numbers for OptionSet values, status codes, or FetchXML `<value>` (use OptionSet Enums)
363
+ - Never magic numbers for time calculations (use named constants like `MS_PER_DAY`)
364
+ - Never `getSaveMode() === 70` (use `SaveMode.AutoSave` from @xrmforge/helpers)
365
+ - Never `getFormType() === 1` (use `FormType.Create` from `@xrmforge/helpers`)
366
+ - Never `XrmEnum.FormType` (does NOT exist at runtime, esbuild does not resolve const enums from .d.ts. Use `FormType` from `@xrmforge/helpers`)
367
+ - Never `'expanded'`/`'collapsed'` (use `DisplayState` from @xrmforge/helpers)
368
+ - Never `'ERROR'`/`'INFO'`/`'WARNING'` (use `FormNotificationLevel`)
369
+ - Never `'none'`/`'required'`/`'recommended'` (use `RequiredLevel`)
370
+ - Never `'always'`/`'dirty'` (use `SubmitMode`)
371
+
372
+ **Web API responses:**
373
+ - Never access WebApi response properties with `as string` casts (use generated Entity interfaces)
374
+ - Never `.getValue()[0].id` for lookups (use `formLookup`/`formLookupId`)
375
+ - Never raw strings in `parseLookup()` (use NavigationProperties enum)
376
+ - Never raw strings in `$unsafe()` (use Entity-level Fields Enum: `form.$unsafe(AccountFields.X)`)
377
+ - Never manual OData annotation access (`_value`, `@OData.Community.Display.V1.FormattedValue`, `@Microsoft.Dynamics.CRM.lookuplogicalname`). Use `parseLookup()` which extracts all three.
378
+
379
+ **Code quality:**
380
+ - Never `Xrm.Page` (deprecated since D365 v9.0)
381
+ - Never `eval()`, never synchronous XMLHttpRequest
382
+ - Never `window.X = ...` (use module exports)
383
+ - Never `console.log/warn/error` in form scripts (use shared logger)
384
+ - Never export handlers without `wrapHandler()`
385
+ - Never unlokalized UI strings (use `pickLang()` from constants.ts)
386
+ - Never build your own getValue/setFieldValue/setDisabled/addOnChange helpers (use `typedForm` + native Xrm API)
387
+ - Never `import ... from '@xrmforge/typegen'` in browser code (use `@xrmforge/helpers`)
388
+ - Never `as Xrm.Controls.LookupControl` or similar control casts (`form.controls.fieldname` returns the typed control from ControlMap)
389
+ - Never `as any` without eslint-disable comment explaining why
390
+ - Never untyped `catch (error)` (always `catch (error: unknown)`)
391
+
392
+ ## Subagent Handoff (when delegating to sub-agents)
393
+
394
+ Copy these MANDATORY rules into every sub-agent prompt:
395
+
396
+ ```
397
+ 1. typedForm<MyFormTypeInfo>(ctx.getFormContext()) for ALL field access (generated TypeInfo, NOT the bare form interface)
398
+ 2. Entity-level Fields Enums in ALL select(), $filter, $expand, $orderby, FetchXML attribute=
399
+ 3. OptionSet Enum for ALL value comparisons AND FetchXML <value> (never magic numbers)
400
+ 4. EntityNames for ALL Xrm.WebApi calls AND openForm (never raw entity names)
401
+ 5. formLookup/formLookupId for ALL lookup access (never .getValue()[0].id)
402
+ 6. parseLookup with NavigationProperties enum (never raw nav property strings)
403
+ 7. Generated Entity interfaces for ALL WebApi response typing (never as string casts)
404
+ 8. Tabs/Sections/Subgrids enums for ALL UI structure access (never raw strings)
405
+ 9. SaveMode/FormType/DisplayState/RequiredLevel/SubmitMode/FormNotificationLevel constants
406
+ 10. wrapHandler() around EVERY exported handler
407
+ 11. createLogger() instead of console.* (except logger.ts)
408
+ 12. Custom API Executors from generated/actions/ (never build your own)
409
+ 13. NOTIFICATION_IDS from constants.ts for all notification unique IDs
410
+ 14. Named constants for non-obvious values (never magic numbers like 86400000)
411
+ 15. pickLang() for all user-visible strings (never hardcoded German/English)
412
+ ```
413
+
414
+ ## Mandatory Shared Utilities
415
+
416
+ Every XrmForge project MUST have these in `src/shared/`:
417
+
418
+ ### logger.ts
419
+ ```typescript
420
+ export interface Logger { debug(msg: string, data?: unknown): void; info(...); warn(...); error(...); }
421
+ export function createLogger(namespace: string): Logger;
422
+ // Only file allowed to use console.*
423
+ ```
424
+
425
+ ### error-handler.ts
426
+ ```typescript
427
+ export function wrapHandler(name: string, logger: Logger, handler: EventHandler): EventHandler;
428
+ export function wrapCommand(name: string, logger: Logger, handler: CommandHandler): CommandHandler;
429
+ // Catches sync+async errors, shows form notification via FormNotificationLevel.Error
430
+ ```
431
+
432
+ ### constants.ts
433
+ ```typescript
434
+ export const NOTIFICATION_IDS = { ... } as const;
435
+ export const MESSAGES = { de: { ... }, en: { ... } } as const;
436
+ export function pickLang<K extends string>(languageId: number, table: ...): Record<K, string>;
437
+ ```
438
+
439
+ ## Before/After Examples
440
+
441
+ ### Field Access (primary pattern: typedForm)
442
+ ```typescript
443
+ // BEFORE (legacy):
444
+ formContext.getAttribute("name").getValue()
445
+ // BEFORE (getAttribute + Fields):
446
+ form.getAttribute(Fields.Name).getValue()
447
+ // AFTER (typedForm - preferred):
448
+ const form = typedForm<AccountFormTypeInfo>(ctx.getFormContext());
449
+ form.name.getValue()
450
+ ```
451
+
452
+ ### Web API Query
453
+ ```typescript
454
+ // BEFORE:
455
+ Xrm.WebApi.retrieveRecord("account", id, "?$select=name,revenue")
456
+ // AFTER:
457
+ import { AccountFields } from '../../generated/fields/account.js';
458
+ Xrm.WebApi.retrieveRecord(EntityNames.Account, id,
459
+ select(AccountFields.Name, AccountFields.Revenue))
460
+ ```
461
+
462
+ ### OptionSet Comparison
463
+ ```typescript
464
+ // BEFORE: if (status.getValue() === 595300002) { ... }
465
+ // AFTER:
466
+ import { StatusCode } from '../../generated/optionsets/invoice.js';
467
+ if (form.statuscode.getValue() === StatusCode.Gebucht) { ... }
468
+ ```
469
+
470
+ ### FetchXML
471
+ ```typescript
472
+ // BEFORE:
473
+ `<condition attribute='statuscode' operator='in'><value>1</value><value>105710000</value></condition>`
474
+ // AFTER:
475
+ import { LmBestellungFields } from '../../generated/fields/lm_bestellung.js';
476
+ import { LmBestellungStatusCode } from '../../generated/optionsets/lm_bestellung.js';
477
+ `<condition attribute='${LmBestellungFields.Statuscode}' operator='in'>
478
+ <value>${LmBestellungStatusCode.Aktiv}</value>
479
+ <value>${LmBestellungStatusCode.InArbeit}</value>
480
+ </condition>`
481
+ ```
482
+
483
+ ### Lookup Access (Form)
484
+ ```typescript
485
+ // BEFORE: form.getAttribute("customerid").getValue()[0].id.replace("{","").replace("}","")
486
+ // AFTER:
487
+ const customerId = formLookupId(form.customerid);
488
+ ```
489
+
490
+ ### Lookup from WebApi Response to Form Field
491
+ ```typescript
492
+ // BEFORE (verbose, error-prone OData annotations):
493
+ form.customerid.setValue([{
494
+ id: raw._parentcustomerid_value as string,
495
+ entityType: EntityNames.Account,
496
+ name: (raw['_parentcustomerid_value@OData.Community.Display.V1.FormattedValue'] as string) ?? '',
497
+ }]);
498
+
499
+ // AFTER (parseLookup extracts id, name, entityType automatically):
500
+ import { parseLookup } from '@xrmforge/helpers';
501
+ import { ContactNavigationProperties as ContactNav } from '../../generated/entities/contact.js';
502
+
503
+ const customer = parseLookup(raw, ContactNav.ParentCustomerId);
504
+ if (customer) {
505
+ form.customerid.setValue([customer]);
506
+ }
507
+ ```
508
+
509
+ ### Custom API Call
510
+ ```typescript
511
+ // BEFORE: ExecuteFunctionCall("CancelInvoice", { InvoiceId: id })
512
+ // AFTER:
513
+ import { CancelInvoice } from '../../generated/actions/global.js';
514
+ const result = await withProgress(
515
+ lang.cancellingInvoice,
516
+ () => CancelInvoice.execute({ InvoiceId: id }),
517
+ );
518
+ ```
519
+
520
+ ### Date Calculation
521
+ ```typescript
522
+ // BEFORE: new Date(date.getTime() + nettotage * 86400000)
523
+ // AFTER:
524
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
525
+ new Date(date.getTime() + nettotage * MS_PER_DAY)
526
+ ```
527
+
528
+ ### UI Strings
529
+ ```typescript
530
+ // BEFORE: form.title.setValue('[Kurzbeschreibung der Anfrage]')
531
+ // AFTER:
532
+ const lang = pickLang(Xrm.Utility.getGlobalContext().userSettings.languageId, MESSAGES);
533
+ form.title.setValue(lang.titlePlaceholder);
534
+ ```
535
+
536
+ ## Testing (onLoad + onChange)
537
+
538
+ ```typescript
539
+ import { createFormMock, setupXrmMock, teardownXrmMock } from '@xrmforge/testing';
540
+
541
+ beforeEach(() => setupXrmMock());
542
+ afterEach(() => teardownXrmMock());
543
+
544
+ // onLoad test:
545
+ const mock = createFormMock<AccountMainForm>({ name: 'Test', statuscode: 0 });
546
+ onLoad(mock.asEventContext());
547
+ expect(mock.getControl(Fields.Revenue).getVisible()).toBe(true);
548
+
549
+ // onChange test (MANDATORY for every onChange handler):
550
+ mock.setValue(Fields.Revenue, 500000);
551
+ mock.fireOnChange(Fields.Revenue);
552
+ expect(mock.getControl(Fields.CreditLimit).getVisible()).toBe(true);
553
+ ```
554
+
555
+ **Test quality rule:** At least 30% of tests MUST use `fireOnChange` or WebApi mock
556
+ assertions. Pure smoke tests (`onLoad` + `not.toThrow`) do NOT count.
557
+ Every onChange handler MUST have a `fireOnChange` test.
558
+
559
+ **attr.controls:** Since @xrmforge/testing 0.2.3, `createFormMock()` automatically links
560
+ each attribute to its control. `mock.getControl(Fields.Name)` works out of the box.
561
+
562
+ ## Pattern Recognition: Legacy to XrmForge
563
+
564
+ ### Xrm API Patterns
565
+ | Legacy Pattern | XrmForge Replacement |
566
+ |---|---|
567
+ | `getAttribute("name")` | `form.name` (via typedForm) |
568
+ | `getControl("name")` | `form.controls.name` |
569
+ | `Xrm.Page.getAttribute(...)` | `form.fieldname` (via typedForm) |
570
+ | `var formContext` (global) | `const form = typedForm<MyFormTypeInfo>(ctx.getFormContext())` |
571
+ | `function form_OnLoad(ctx)` | `export const onLoad = wrapHandler(...)` |
572
+ | `Xrm.WebApi.retrieveRecord("account", id)` | `Xrm.WebApi.retrieveRecord(EntityNames.Account, id)` |
573
+ | `"?$select=name,revenue"` | `select(AccountFields.Name, AccountFields.Revenue)` |
574
+ | `value[0].id.replace("{","")` | `formLookupId(form.customerid)` |
575
+ | `ExecuteFunctionCall("name", ...)` | `import { Name } from '../../generated/actions/global.js'` |
576
+ | `setFormNotification(msg, 'ERROR', id)` | `setFormNotification(msg, FormNotificationLevel.Error, id)` |
577
+ | `getValue() === 595300000` | `form.statuscode.getValue() === StatusCode.Active` |
578
+ | `86400000` | `const MS_PER_DAY = 24 * 60 * 60 * 1000` |
579
+ | `'[Kurzbeschreibung]'` | `pickLang(languageId, MESSAGES).placeholder` |
580
+
581
+ ### Legacy Helper Functions (DO NOT recreate, use typedForm instead)
582
+
583
+ These helper wrappers are common in legacy code. They destroy type safety.
584
+ Never recreate them. Use the typed API directly.
585
+
586
+ | Legacy Helper | XrmForge Replacement |
587
+ |---|---|
588
+ | `GetValue(fieldName)` | `form.fieldname.getValue()` (typed via typedForm) |
589
+ | `SetValue(fieldName, value)` | `form.fieldname.setValue(value)` (typed via typedForm) |
590
+ | `SetDisabled(attributeName, disabled)` | `form.controls.fieldname.setDisabled(disabled)` |
591
+ | `SetVisible(attributeName, visible)` | `form.controls.fieldname.setVisible(visible)` |
592
+ | `SetRequiredLevel(attributeName, level)` | `form.$context.getAttribute(Fields.X).setRequiredLevel(RequiredLevel.Required)` |
593
+ | `AddOnChange(attributeName, callback)` | `form.$context.getAttribute(Fields.X).addOnChange(cb)` |
594
+ | `AddPreSearch(controlName, callback)` | `form.controls.fieldname.addPreSearch(cb)` (typed as LookupControl from ControlMap) |
595
+ | `GetLookupValueId(fieldName)` | `formLookupId(form.fieldname)` |
596
+ | `SetLookupValue(field, id, type, name)` | `form.fieldname.setValue([{ id, entityType, name }])` |
597
+ | `GetId()` | `form.$context.data.entity.getId()` |
598
+ | `GetEntityName()` | `form.$context.data.entity.getEntityName()` |
599
+ | `GetFormType()` | `form.$context.ui.getFormType()` |
600
+ | `GetIsDirty()` | `form.$context.data.entity.getIsDirty()` |
601
+ | `IsNullOrEmpty(value)` | `value == null \|\| value === ''` (inline) |
602
+ | `IsAttributeNullOrEmpty(field)` | `form.fieldname.getValue() == null` |
603
+ | `GetUserId()` | `Xrm.Utility.getGlobalContext().userSettings.userId` |
604
+ | `GetUserLanguageId()` | `Xrm.Utility.getGlobalContext().userSettings.languageId` |
605
+ | `OpenForm(entityName, id)` | `Xrm.Navigation.openForm({ entityName: EntityNames.X, entityId: id })` |
606
+ | `OpenAlertDialog(text)` | `Xrm.Navigation.openAlertDialog({ text })` |
607
+ | `OpenConfirmDialog(text, ...)` | `Xrm.Navigation.openConfirmDialog({ text, title, ... })` |
608
+ | `ShowProgressIndicator(msg)` | `Xrm.Utility.showProgressIndicator(msg)` |
609
+ | `CloseProgressIndicator()` | `Xrm.Utility.closeProgressIndicator()` |
610
+ | `SetNotification(attr, msg)` | `form.$context.getControl(Fields.X).setNotification(msg, NOTIFICATION_IDS.x)` |
611
+ | `SetSectionDisabled(tab, sec, off)` | `form.$context.ui.tabs.get(Tabs.X).sections.get(Sections.Y).setVisible(!off)` |
612
+
613
+ ### GUID Handling (common CRM anti-pattern)
614
+
615
+ D365 returns GUIDs in various formats: `{A1B2C3D4-...}`, `a1b2c3d4-...`, `A1B2C3D4-...`.
616
+ Legacy code commonly has helpers like `CompareGuid()`, `GetCompatibleGuid()`,
617
+ `NormalizeGuid()`, `StripBraces()`. **Do NOT recreate these.**
618
+
619
+ `formLookupId()` from @xrmforge/helpers already normalizes GUIDs (removes braces).
620
+ GUID comparison is then a simple `===`:
621
+
622
+ ```typescript
623
+ // WRONG: legacy GUID helpers
624
+ function CompareGuid(a, b) { return a.replace(/[{}]/g,'').toLowerCase() === b.replace(/[{}]/g,'').toLowerCase(); }
625
+ const id = GetCompatibleGuid(form.getAttribute("customerid").getValue()[0].id);
626
+
627
+ // CORRECT: formLookupId normalizes automatically
628
+ const customerId = formLookupId(form.customerid); // already clean: "a1b2c3d4-..."
629
+ if (customerId === otherNormalizedId) { ... } // simple ===
630
+ ```
631
+
632
+ ### Typed repetition beats untyped loops
633
+
634
+ When multiple fields need the same operation (e.g. 8 address fields), write
635
+ 8 typed lines instead of 1 loop with raw strings:
636
+
637
+ ```typescript
638
+ // WRONG: DRY reflex, but raw strings bypass type safety
639
+ for (const f of ['address1_name', 'address1_line1', 'address1_city']) {
640
+ form.$unsafe(f)?.addOnChange(handler);
641
+ }
642
+
643
+ // CORRECT: more lines, but every field is compile-time validated
644
+ form.address1_name.addOnChange(handler);
645
+ form.address1_line1.addOnChange(handler);
646
+ form.address1_city.addOnChange(handler);
647
+ form.address1_postalcode.addOnChange(handler);
648
+ form.address1_country.addOnChange(handler);
649
+ ```
650
+
651
+ 8 typed lines are better than 1 loop with raw strings. The type system
652
+ catches renamed/removed fields at compile time. A loop with raw strings
653
+ only fails at runtime. DRY is a recommendation, type safety is mandatory.
654
+
655
+ **Rule of thumb:** If a helper function just wraps a single Xrm API call with a
656
+ string parameter, it MUST NOT exist. The typed API is shorter, safer, and provides
657
+ IDE autocomplete. Only keep shared helpers that contain actual domain logic
658
+ (calculations, WebApi queries, multi-step workflows).
659
+
660
+ ## @types/xrm Pitfalls (known issues)
661
+
662
+ 1. **Form Interface:** Do NOT use `interface extends Xrm.FormContext`. Use `Omit` pattern.
663
+ 2. **AlertDialogResponse** does NOT exist. Use `Xrm.Async.PromiseLike<void>`.
664
+ 3. **ConfirmDialogResponse** does NOT exist. Use `Xrm.Navigation.ConfirmResult`.
665
+ 4. **setNotification()** requires 2 arguments: (message, uniqueId).
666
+ 5. **openFile()** requires `fileSize` property in FileDetails.
667
+ 6. **Grid.refresh()** requires `(grid as any).refresh()` with eslint-disable comment.
668
+
669
+ ## Build
670
+
671
+ ```bash
672
+ npx xrmforge build # IIFE bundles for D365
673
+ npx xrmforge build --watch # Watch mode (~10ms rebuilds)
674
+ ```
675
+
676
+ ## Drift Check (generated/ vs. live environment)
677
+
678
+ `generated/` is a snapshot of the Dataverse environment. When the environment
679
+ changes after generation, the snapshot silently drifts (compiles fine, fails at
680
+ runtime with cryptic OData errors). Use the drift check as a CI step:
681
+
682
+ ```bash
683
+ npx xrmforge generate ... --check # read-only, writes nothing
684
+ # Exit 0 = up to date, 1 = error, 2 = drift detected (regenerate and commit)
685
+ ```
686
+
687
+ NEVER edit files under `generated/` by hand and NEVER run formatters (Prettier)
688
+ or lint autofixes on them: the drift check compares byte-by-byte and would stay
689
+ permanently red. After a typegen/cli upgrade a drift report is expected:
690
+ regenerate and commit.
691
+
692
+ ## File Structure
693
+
694
+ ```
695
+ src/forms/{entity}-form.ts - Form scripts (one per entity)
696
+ src/shared/logger.ts - Structured logger (only file with console.*)
697
+ src/shared/error-handler.ts - wrapHandler + wrapCommand
698
+ src/shared/constants.ts - NOTIFICATION_IDS, MESSAGES, pickLang
699
+ generated/ - Generated types (do not edit manually)
700
+ tests/forms/{entity}.test.ts - Tests
701
+ xrmforge.config.json - Build config
702
+ scripts/validate-form.mjs - Quality gate (run after each batch)
703
+ ```
704
+
705
+ ## Self-Check (MANDATORY before Tests)
706
+
707
+ Run `node scripts/validate-form.mjs` after every batch. Must report 0 violations.