@xrmforge/devkit 0.5.7 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,450 +1,534 @@
1
- # XrmForge - AI Agent Instructions
2
-
3
- This file helps AI coding assistants write optimal Dynamics 365 form scripts.
4
-
5
- ## Packages
6
-
7
- - `@xrmforge/typegen` - Generates typed declarations from Dataverse metadata
8
- - `@xrmforge/helpers` - Browser-safe runtime: select(), parseLookup(), typedForm(), Xrm constants, Action executors
9
- - `@xrmforge/testing` - Type-safe form mocks: createFormMock(), fireOnChange()
10
- - `@xrmforge/devkit` - esbuild IIFE bundles via xrmforge build
11
- - `@xrmforge/eslint-plugin` - D365-specific ESLint rules
12
-
13
- ## Generated Types (generated/ directory)
14
-
15
- Run `xrmforge generate` to create:
16
- - `generated/forms/{entity}.ts` - Form interface + Fields/Tabs/Sections/Subgrids enums
17
- - `generated/optionsets/{entity}.ts` - OptionSet const enums
18
- - `generated/entities/{entity}.ts` - Entity interface
19
- - `generated/fields/{entity}.ts` - Entity Fields enum for type-safe $select queries
20
- - `generated/entity-names.ts` - EntityNames const enum
21
- - `generated/form-mapping.json` - Entity to form interface mapping (read after generate!)
22
- - `generated/index.ts` - Barrel file with `export * from` re-exports
23
-
24
- **After generate:** Read `generated/form-mapping.json` for the mapping of entity logical
25
- names to form interface names. Do NOT guess interface names from entity names.
26
- Fields enum member names are based on the **primary language label** (often German),
27
- not the logical field name. Always read the generated files to get correct names.
28
-
29
- **Large projects (50+ entities):** `form-mapping.json` can be large. Do NOT grep through
30
- all generated files to find interface names. Read `form-mapping.json` once, then use
31
- the mapping for all imports. This saves time and avoids wrong guesses.
32
-
33
- ## Rules: MANDATORY (every violation is a bug)
34
-
35
- 1. **Fields Enum** for ALL getAttribute/getControl calls. Never raw strings.
36
- ```typescript
37
- import { AccountMainFormFieldsEnum as Fields } from '../generated/forms/account.js';
38
- form.getAttribute(Fields.Name) // CORRECT
39
- form.getAttribute("name") // BUG - raw string
40
- ```
41
-
42
- 2. **OptionSet Enum** for ALL value comparisons. Never magic numbers.
43
- ```typescript
44
- import { StatusCode } from '../generated/optionsets/invoice.js';
45
- if (status === StatusCode.Active) // CORRECT
46
- if (status === 0) // BUG - magic number
47
- ```
48
-
49
- 3. **FormContext Cast** to generated form interface in every onLoad:
50
- ```typescript
51
- import type { AccountMainForm } from '../generated/forms/account.js';
52
- const form = ctx.getFormContext() as AccountMainForm;
53
- ```
54
-
55
- 4. **EntityNames Enum** in ALL Xrm.WebApi calls — no exceptions, even for system entities:
56
- ```typescript
57
- import { EntityNames } from '../generated/entity-names.js';
58
- Xrm.WebApi.retrieveRecord(EntityNames.Account, id)
59
- ```
60
- If an entity is not in EntityNames, extend the generation (`--entities` flag) to include it.
61
-
62
- 5. **Lookup helpers** from @xrmforge/helpers for ALL lookup value access:
63
- ```typescript
64
- import { formLookup, formLookupId, parseLookup } from '@xrmforge/helpers';
65
- // Form lookups (getAttribute on FormContext):
66
- const customer = formLookup(form.getAttribute(Fields.CustomerId));
67
- const customerId = formLookupId(form.getAttribute(Fields.CustomerId));
68
- // Web API response lookups (_fieldname_value + OData annotations):
69
- const parent = parseLookup(apiResponse, 'parentaccountid');
70
- ```
71
- **NEVER** access lookup values directly:
72
- ```typescript
73
- // BUG - never do this:
74
- form.getAttribute(Fields.CustomerId).getValue()[0].id.replace('{','')
75
- // CORRECT:
76
- formLookupId(form.getAttribute(Fields.CustomerId))
77
- ```
78
-
79
- 6. **select()** from @xrmforge/helpers for ALL $select queries:
80
- ```typescript
81
- import { select } from '@xrmforge/helpers';
82
- // Simple query:
83
- Xrm.WebApi.retrieveRecord(EntityNames.Account, id, select(Fields.Name, Fields.Revenue))
84
- // Combined with $filter:
85
- Xrm.WebApi.retrieveMultipleRecords(EntityNames.Account,
86
- `${select(Fields.Name, Fields.Revenue)}&$filter=statecode eq 0`)
87
- ```
88
- **Signature:** `select(...fields: string[])` takes REST parameters, NOT an array.
89
- ```typescript
90
- select(Fields.Name, Fields.Revenue) // CORRECT
91
- select([Fields.Name, Fields.Revenue]) // WRONG - array argument
92
- select('account', Fields.Name) // WRONG - first arg is not entity name
93
- ```
94
-
95
- 7. **wrapHandler()** around EVERY exported async event handler:
96
- ```typescript
97
- import { createLogger } from '../shared/logger';
98
- import { wrapHandler } from '../shared/error-handler';
99
- const logger = createLogger('Namespace.Entity');
100
- export const onLoad = wrapHandler('Namespace.Entity.onLoad', logger, async (ctx) => {
101
- // handler code
102
- });
103
- ```
104
-
105
- 8. **createFormMock()** from @xrmforge/testing for ALL form tests:
106
- ```typescript
107
- import { createFormMock, fireOnChange, setupXrmMock } from '@xrmforge/testing';
108
- ```
109
-
110
- 9. **Module exports** (not window/global assignments). esbuild globalName handles namespacing.
111
-
112
- 10. **Structured Logger** instead of console.* (except in logger.ts itself):
113
- ```typescript
114
- import { createLogger } from '../shared/logger';
115
- const logger = createLogger('Namespace.Entity');
116
- logger.info('Form loaded', { recordId });
117
- ```
118
-
119
- ## Rules: NEVER (every occurrence is a bug)
120
-
121
- - Never `getAttribute("raw_string")` when Fields enum exists
122
- - Never magic numbers for OptionSet values (use OptionSet enums)
123
- - Never `Xrm.Page` (deprecated since D365 v9.0)
124
- - Never synchronous XMLHttpRequest
125
- - Never `eval()`
126
- - Never `window.X = ...` (use module exports)
127
- - Never `console.log/warn/error` in form scripts (use shared logger)
128
- - Never export async handlers without wrapHandler()
129
- - Never `Xrm.WebApi.retrieveRecord("account", ...)` with raw entity name (use EntityNames, no exceptions even for system entities)
130
- - Never `"?$select=name,revenue"` as raw string (use select() from @xrmforge/helpers)
131
- - Never `.getValue()[0].id` or `.getValue()[0].id.replace(...)` for lookups (use formLookup/formLookupId from @xrmforge/helpers)
132
- - Never build your own lookup helper (getLookupValueId, firstLookupValue, etc.) when formLookup/formLookupId exists
133
- - Never `import ... from '@xrmforge/typegen'` in browser code. @xrmforge/typegen is a Node.js CLI tool. Use `@xrmforge/helpers` for browser-safe runtime functions (select, parseLookup, formLookup, createUnboundAction, etc.)
134
-
135
- ## Subagent Handoff (when delegating to sub-agents)
136
-
137
- Copy these MANDATORY rules into every sub-agent prompt:
138
-
139
- ```
140
- 1. Fields Enum for ALL getAttribute/getControl (never raw strings)
141
- 2. OptionSet Enum for ALL value comparisons (never magic numbers)
142
- 3. EntityNames for ALL Xrm.WebApi calls (never raw entity names, no exceptions)
143
- 4. formLookup/formLookupId for ALL lookup access (never .getValue()[0].id)
144
- 5. select() for ALL $select queries (never raw "$select=" strings)
145
- 6. wrapHandler() around EVERY exported async handler
146
- 7. createLogger() instead of console.* (except logger.ts)
147
- ```
148
-
149
- ## Mandatory Shared Utilities
150
-
151
- Every XrmForge project MUST have these in `src/shared/`:
152
-
153
- ### logger.ts
154
- ```typescript
155
- export interface Logger { debug(msg: string, data?: unknown): void; info(...); warn(...); error(...); }
156
- export function createLogger(namespace: string): Logger;
157
- // Only file allowed to use console.*
158
- ```
159
-
160
- ### error-handler.ts
161
- ```typescript
162
- export function wrapHandler<T>(name: string, logger: Logger, handler: T): T;
163
- // Catches sync+async errors, shows form notification, never rethrows
164
- ```
165
-
166
- ### constants.ts
167
- ```typescript
168
- export const NOTIFICATION_IDS = { ... } as const;
169
- export const MESSAGES = { ... } as const;
170
- ```
171
-
172
- ## Before/After Examples
173
-
174
- ### Field Access
175
- ```typescript
176
- // BEFORE: formContext.getAttribute("name").getValue()
177
- // AFTER:
178
- import { AccountMainFormFieldsEnum as Fields } from '../generated/forms/account.js';
179
- import type { AccountMainForm } from '../generated/forms/account.js';
180
- const form = ctx.getFormContext() as AccountMainForm;
181
- form.getAttribute(Fields.AccountName).getValue(); // StringAttribute, typed
182
- ```
183
-
184
- ### OptionSet Comparison
185
- ```typescript
186
- // BEFORE: if (status.getValue() === 595300002) { ... }
187
- // AFTER:
188
- import { StatusCode } from '../generated/optionsets/invoice.js';
189
- if (status.getValue() === StatusCode.Gebucht) { ... }
190
- ```
191
-
192
- ### Testing (onLoad + onChange)
193
- ```typescript
194
- import { createFormMock, setupXrmMock, teardownXrmMock } from '@xrmforge/testing';
195
-
196
- beforeEach(() => setupXrmMock());
197
- afterEach(() => teardownXrmMock());
198
-
199
- // onLoad test:
200
- const mock = createFormMock<AccountMainForm>({ name: 'Test', statuscode: 0 });
201
- onLoad(mock.asEventContext());
202
- expect(mock.getControl(Fields.Revenue).getVisible()).toBe(true);
203
-
204
- // onChange test (MANDATORY for every onChange handler):
205
- mock.setValue(Fields.Revenue, 500000);
206
- mock.fireOnChange(Fields.Revenue);
207
- expect(mock.getControl(Fields.CreditLimit).getVisible()).toBe(true);
208
- expect(mock.getValue(Fields.CreditOnHold)).toBe(true);
209
- ```
210
-
211
- **Test quality rule:** At least 30% of tests MUST use `fireOnChange` or WebApi mock
212
- assertions. Pure smoke tests (`onLoad` + `not.toThrow`) do NOT count as behavior tests.
213
- Tests that only check `getOnChangeHandlers().length > 0` are registration tests, not
214
- behavior tests. Every onChange handler MUST have a `fireOnChange` test.
215
-
216
- **attr.controls:** Since @xrmforge/testing 0.2.3, `createFormMock()` automatically links
217
- each attribute to its control. `mock.getControl(Fields.Name)` works out of the box.
218
- No need to mock controls separately.
219
-
220
- ## File Structure
221
-
222
- ```
223
- src/forms/{entity}-form.ts - Form scripts (one per entity)
224
- src/shared/{name}.ts - Shared utilities
225
- generated/ - Generated types (do not edit manually)
226
- tests/forms/{entity}.test.ts - Tests
227
- xrmforge.config.json - Build config
228
- ```
229
-
230
- ## Pattern Recognition: Legacy to XrmForge
231
-
232
- When you see these patterns in legacy code, apply the XrmForge replacement:
233
-
234
- | Legacy Pattern | XrmForge Replacement |
235
- |---|---|
236
- | `getAttribute("name")` | `getAttribute(Fields.Name)` |
237
- | `getControl("name")` | `getControl(Fields.Name)` |
238
- | `getValue() === 595300000` | `getValue() === OptionSets.StatusCode.Active` |
239
- | `Xrm.WebApi.retrieveRecord("account", id)` | `Xrm.WebApi.retrieveRecord(EntityNames.Account, id)` |
240
- | `"?$select=name,revenue"` | `select(Fields.Name, Fields.Revenue)` (from @xrmforge/helpers) |
241
- | `value[0].id.replace("{","")...` | `formLookupId(form.getAttribute(Fields.X))` for forms, `parseLookup(response, 'field')` for Web API |
242
- | `Xrm.Page.getAttribute(...)` | `formContext.getAttribute(...)` |
243
- | `var formContext` (global) | `const form = ctx.getFormContext()` (parameter) |
244
- | `function form_OnLoad(ctx)` | `export function onLoad(ctx: Xrm.Events.EventContext)` |
245
- | `.then(success, error)` | `async/await with try/catch` |
246
-
247
- ### Creating OptionSet Enums from Legacy Magic Numbers
248
-
249
- When you find magic numbers like `getValue() === 105710002` in legacy code:
250
- 1. Search the file for ALL numeric comparisons with getValue()
251
- 2. Create a const enum in generated/optionsets/ with descriptive names
252
- 3. Import and use the enum instead of the number
253
-
254
- Example:
255
- ```typescript
256
- // generated/optionsets/invoice.ts
257
- export const enum InvoiceStatusCode {
258
- Neu = 1,
259
- Versendet = 105710000,
260
- Abgeschlossen = 105710001,
261
- Gebucht = 105710002,
262
- }
263
-
264
- // In the form script:
265
- import { InvoiceStatusCode } from '../../generated/optionsets/invoice.js';
266
- if (status.getValue() === InvoiceStatusCode.Gebucht) { ... }
267
- ```
268
-
269
- ## Testing with Global Xrm Mock
270
-
271
- Use `setupXrmMock()` from @xrmforge/testing to mock the global Xrm namespace:
272
- ```typescript
273
- import { createFormMock, setupXrmMock, teardownXrmMock } from '@xrmforge/testing';
274
-
275
- beforeEach(() => setupXrmMock());
276
- afterEach(() => teardownXrmMock());
277
-
278
- // Override specific WebApi methods:
279
- setupXrmMock({
280
- webApiOverrides: {
281
- retrieveMultipleRecords: async () => ({ entities: [{ name: 'Test' }] }),
282
- },
283
- });
284
- ```
285
-
286
- ## Build
287
-
288
- ```bash
289
- npx xrmforge build # IIFE bundles for D365
290
- npx xrmforge build --watch # Watch mode (~10ms rebuilds)
291
- ```
292
-
293
- ## @types/xrm Pitfalls (known issues)
294
-
295
- When creating manual typings without `xrmforge generate`:
296
-
297
- 1. **Form Interface:** Do NOT use `interface extends Xrm.FormContext` (getAttribute overload conflicts).
298
- Use `Omit` pattern instead:
299
- ```typescript
300
- interface AccountMainForm extends Omit<Xrm.FormContext, 'getAttribute' | 'getControl'> {
301
- getAttribute(name: Fields.AccountName): Xrm.Attributes.StringAttribute;
302
- getAttribute(name: string): Xrm.Attributes.Attribute;
303
- // ...
304
- }
305
- ```
306
-
307
- 2. **AlertDialogResponse** does NOT exist in @types/xrm. Use `Xrm.Async.PromiseLike<void>`.
308
-
309
- 3. **ConfirmDialogResponse** does NOT exist. Use `Xrm.Navigation.ConfirmResult`.
310
-
311
- 4. **setNotification()** requires 2 arguments: (message, uniqueId).
312
-
313
- 5. **openFile()** requires `fileSize` property in FileDetails.
314
-
315
- 6. **const enum in .d.ts files** cannot be imported at runtime by test frameworks.
316
- Since v0.8.0, XrmForge generates `.ts` files, so this is no longer an issue.
317
- For manual typings, use regular `enum` in `.ts` files (not `.d.ts`).
318
-
319
- ## Self-Check (MANDATORY before Tests)
320
-
321
- After converting ALL scripts, run these checks. Fix every violation before proceeding to tests.
322
- Document results in SESSION-GEDAECHTNIS.md (violation count per category).
323
-
324
- **Platform note:** The checks below use bash/grep syntax. On Windows PowerShell, use
325
- `Select-String` instead of `grep`. Example:
326
- ```powershell
327
- # PowerShell equivalent of: grep -rn "getAttribute('" src/forms/ --include="*.ts"
328
- Get-ChildItem -Recurse src/forms -Filter *.ts | Select-String "getAttribute\('" | Where-Object { $_.Line -notmatch 'Fields\.' }
329
- ```
330
-
331
- ### Pattern Compliance (all must be 0, or documented exception)
332
-
333
- ```bash
334
- # 1. Raw field strings in getAttribute/getControl (must use Fields Enum)
335
- grep -rn "getAttribute('" src/forms/ --include="*.ts" | grep -v "Fields\."
336
- grep -rn "getControl('" src/forms/ --include="*.ts" | grep -v "Fields\."
337
-
338
- # 2. Magic numbers in OptionSet comparisons (must use OptionSet Enum)
339
- grep -rn "getValue() ===" src/ --include="*.ts" | grep -E "[0-9]{3,}"
340
-
341
- # 3. Direct _value access instead of parseLookup (in Web API responses)
342
- grep -rn "_value\b" src/ --include="*.ts" | grep -v "generated/" | grep -v "parseLookup" | grep -v "getValue"
343
-
344
- # 4. Raw entity names in WebApi calls (must use EntityNames, no exceptions)
345
- grep -rn "retrieveRecord\|retrieveMultipleRecords\|deleteRecord\|createRecord\|updateRecord" src/ --include="*.ts" | grep "'[a-z]" | grep -v "EntityNames"
346
- # Every match is a violation. If entity is missing from EntityNames, extend generation.
347
-
348
- # 5. Missing select() - no raw "$select=" strings anywhere in src/
349
- grep -rn '\$select' src/ --include="*.ts" | grep -v "select(" | grep -v "generated/"
350
-
351
- # 6. Missing FormContext Cast in onLoad (must have "as <Generated>Form")
352
- grep -rn "getFormContext()" src/forms/ --include="*.ts" | grep -v " as "
353
-
354
- # 7. Exported handlers without wrapHandler
355
- grep -rn "^export const\|^export async function\|^export function" src/forms/ --include="*.ts" | grep -v "wrapHandler"
356
-
357
- # 8. Entity-level FieldsEnums not used (generated/fields/ should be imported)
358
- echo "Fields imports from generated/fields/:"
359
- grep -rn "from.*generated/fields/" src/ --include="*.ts" | wc -l
360
- ```
361
-
362
- ### Code Quality (all must be 0)
363
-
364
- ```bash
365
- # console.* outside logger.ts (exclude JSDoc comments)
366
- grep -rn "console\." src/ --include="*.ts" | grep -v "logger.ts" | grep -v "^\s*\*" | grep -v "^\s*//"
367
-
368
- # Xrm.Page (deprecated since D365 v9.0, exclude JSDoc comments)
369
- grep -rn "Xrm\.Page" src/ --include="*.ts" | grep -v "^\s*\*" | grep -v "^\s*//"
370
-
371
- # var declarations
372
- grep -rnE "^\s*var " src/ --include="*.ts"
373
-
374
- # eval()
375
- grep -rn "\beval(" src/ --include="*.ts"
376
-
377
- # XMLHttpRequest
378
- grep -rn "XMLHttpRequest" src/ --include="*.ts"
379
-
380
- # as any without eslint-disable comment explaining why
381
- grep -rn "as any" src/ --include="*.ts" | grep -v "eslint-disable"
382
- ```
383
-
384
- ### Documentation (all must pass)
385
-
386
- ```bash
387
- # Files without JSDoc header (first line must be /**)
388
- for f in src/forms/*.ts src/shared/*.ts; do
389
- head -1 "$f" | grep -q "^/\*\*" || echo "No header: $f"
390
- done
391
-
392
- # Exported functions without JSDoc
393
- grep -rn -B1 "^export " src/ --include="*.ts" | grep -E "^[^*]*export" | grep -v "/\*\*"
394
- ```
395
-
396
- ### Test Completeness
397
-
398
- ```bash
399
- # Every form script needs a test file
400
- for f in src/forms/*.ts; do
401
- base=$(basename "$f" .ts)
402
- test -f "tests/forms/${base}.test.ts" || echo "No test: $f"
403
- done
404
-
405
- # Every test file must use setupXrmMock
406
- for f in tests/**/*.test.ts; do
407
- grep -q "setupXrmMock" "$f" || echo "No setupXrmMock: $f"
408
- done
409
-
410
- # Every test file needs at least 2 test cases
411
- for f in tests/**/*.test.ts; do
412
- count=$(grep -c "it(" "$f" 2>/dev/null || echo 0)
413
- [ "$count" -lt 2 ] && echo "Only $count tests: $f"
414
- done
415
- ```
416
-
417
- ### Test Gap Analysis (after writing tests)
418
-
419
- Before declaring tests complete, verify coverage gaps:
420
-
421
- ```bash
422
- # 1. onChange handlers without fireOnChange test
423
- for f in tests/**/*.test.ts; do
424
- has_onchange=$(grep -c "onChange\|on_change\|OnChange" "$(echo $f | sed 's|tests/|src/|;s|.test.ts|.ts|')" 2>/dev/null || echo 0)
425
- has_fire=$(grep -c "fireOnChange" "$f" 2>/dev/null || echo 0)
426
- [ "$has_onchange" -gt 0 ] && [ "$has_fire" -eq 0 ] && echo "Missing fireOnChange: $f"
427
- done
428
-
429
- # 2. WebApi calls without mock assertions
430
- grep -rln "Xrm.WebApi\|retrieveRecord\|retrieveMultiple" src/forms/ --include="*.ts" | while read f; do
431
- test_f="tests/forms/$(basename "$f" .ts).test.ts"
432
- [ -f "$test_f" ] && ! grep -q "retrieveRecord\|retrieveMultiple\|webApiOverrides" "$test_f" && echo "No WebApi mock: $test_f"
433
- done
434
-
435
- # 3. Behavior test ratio (target: >= 30%)
436
- total=$(grep -rc "it(" tests/ --include="*.test.ts" 2>/dev/null | awk -F: '{s+=$2}END{print s}')
437
- behavior=$(grep -rc "fireOnChange\|retrieveRecord\|retrieveMultiple\|expect.*getValue\|expect.*getVisible" tests/ --include="*.test.ts" 2>/dev/null | awk -F: '{s+=$2}END{print s}')
438
- echo "Behavior tests: $behavior / $total (target: >= 30%)"
439
- ```
440
-
441
- ### Exceptions
442
-
443
- Some checks have legitimate exceptions:
444
- - **Raw field strings in helpers**: Generic helper functions that accept `fieldName: string` parameters cannot use Fields Enums. Document these.
445
- - **System entities not in EntityNames**: Entities not in the Solution (e.g. `annotation`, `transactioncurrency`, `systemuser`) may use string literals. Document which ones.
446
- - **as any for Grid.refresh()**: `@types/xrm` does not type `Grid.refresh()`. Requires eslint-disable with explanation.
447
-
448
- ## Full Migration Guide
449
-
450
- See: https://www.npmjs.com/package/@xrmforge/typegen (MIGRATION.md)
1
+ # XrmForge - AI Agent Instructions
2
+
3
+ This file helps AI coding assistants write optimal Dynamics 365 form scripts.
4
+
5
+ ## Packages
6
+
7
+ - `@xrmforge/typegen` - Generates typed declarations from Dataverse metadata (Node.js CLI only, NEVER import in browser code)
8
+ - `@xrmforge/helpers` - Browser-safe runtime: typedForm(), select(), parseLookup(), formLookup(), Xrm constants, Action executors
9
+ - `@xrmforge/testing` - Type-safe form mocks: createFormMock(), fireOnChange(), setupXrmMock()
10
+ - `@xrmforge/devkit` - esbuild IIFE bundles via xrmforge build
11
+ - `@xrmforge/eslint-plugin` - D365-specific ESLint rules
12
+
13
+ ## Generated Types (generated/ directory)
14
+
15
+ Run `xrmforge generate` to create:
16
+ - `generated/forms/{entity}.ts` - Form interface + Fields/Tabs/Sections/Subgrids enums
17
+ - `generated/optionsets/{entity}.ts` - OptionSet const enums
18
+ - `generated/entities/{entity}.ts` - Entity interface (for Web API response typing)
19
+ - `generated/fields/{entity}.ts` - Entity Fields enum for type-safe $select queries
20
+ - `generated/entity-names.ts` - EntityNames const enum
21
+ - `generated/actions/global.ts` - Custom API Action executors (typed params + results)
22
+ - `generated/functions/global.ts` - Custom API Function executors
23
+ - `generated/form-mapping.json` - Entity to form interface mapping (read after generate!)
24
+ - `generated/index.ts` - Barrel file with `export * from` re-exports
25
+
26
+ **After generate:** Read `generated/form-mapping.json` for the mapping of entity logical
27
+ names to form interface names. Do NOT guess interface names from entity names.
28
+ Fields enum member names are based on the **primary language label** (often German),
29
+ not the logical field name. Always read the generated files to get correct names.
30
+
31
+ **System entities:** If a form script needs an entity NOT in the generated EntityNames
32
+ (e.g. transactioncurrency, pricelevel, uom, systemuser), re-run generate with
33
+ `--entities transactioncurrency,pricelevel,...` to include them. NEVER create a local
34
+ `SystemEntities` object with raw strings as a workaround.
35
+
36
+ ## Rules: MANDATORY (every violation is a bug)
37
+
38
+ ### 1. typedForm() for ALL field access (primary pattern)
39
+
40
+ Use `typedForm<FormInterface>(formContext)` from `@xrmforge/helpers` to create a
41
+ typed proxy. Access fields as direct properties instead of getAttribute chains.
42
+
43
+ ```typescript
44
+ import { typedForm } from '@xrmforge/helpers';
45
+ import type { AccountLMFirmaForm } from '../../generated/forms/account.js';
46
+ import { AccountLMFirmaFormFieldsEnum as Fields } from '../../generated/forms/account.js';
47
+
48
+ export const onLoad = wrapHandler('LM.Account.onLoad', logger, (ctx) => {
49
+ const form = typedForm<AccountLMFirmaForm>(ctx.getFormContext());
50
+
51
+ // Direct field access - fully typed, IDE autocomplete works
52
+ const name = form.name.getValue(); // string | null
53
+ form.revenue.setValue(150000); // NumberAttribute
54
+ const parent = form.parentaccountid.getValue(); // LookupValue[] | null
55
+
56
+ // Control access
57
+ form.$control('name').setDisabled(true);
58
+
59
+ // Full FormContext for ui, data, tabs, addOnChange
60
+ form.$context.ui.setFormNotification('OK', FormNotificationLevel.Info, 'id');
61
+ form.$context.getAttribute(Fields.Name).addOnChange(() => { ... });
62
+ });
63
+ ```
64
+
65
+ **When to use `form.$context.getAttribute(Fields.X)` instead of `form.fieldname`:**
66
+ - `addOnChange()`, `removeOnChange()` (event registration on the attribute)
67
+ - `setRequiredLevel()`, `setSubmitMode()` (attribute-level settings)
68
+ - `getControl()` with typed control access (use `form.$control(Fields.X)`)
69
+
70
+ **When to use `form.fieldname` (the typedForm proxy):**
71
+ - `getValue()`, `setValue()` (reading and writing values)
72
+ - Any read-only access to field values
73
+
74
+ ### 2. Fields Enum for ALL getAttribute/getControl AND select() calls
75
+
76
+ Two types of Fields enums exist:
77
+ - **Form-level**: `AccountLMFirmaFormFieldsEnum` (from `generated/forms/`) - for getAttribute/getControl on the form
78
+ - **Entity-level**: `AccountFields` (from `generated/fields/`) - for Web API $select queries
79
+
80
+ ```typescript
81
+ // Form field access (form-level Fields):
82
+ import { AccountLMFirmaFormFieldsEnum as Fields } from '../../generated/forms/account.js';
83
+ form.$context.getAttribute(Fields.Name).addOnChange(() => { ... });
84
+
85
+ // Web API queries (entity-level Fields):
86
+ import { AccountFields } from '../../generated/fields/account.js';
87
+ Xrm.WebApi.retrieveRecord(EntityNames.Account, id,
88
+ select(AccountFields.Name, AccountFields.WebsiteUrl));
89
+ ```
90
+
91
+ **NEVER use raw strings in select():**
92
+ ```typescript
93
+ select('name', 'websiteurl') // BUG - raw strings
94
+ select(AccountFields.Name, AccountFields.WebsiteUrl) // CORRECT
95
+ ```
96
+
97
+ ### 3. OptionSet Enum for ALL value comparisons AND FetchXML
98
+
99
+ Never use magic numbers. Not in comparisons, not in FetchXML, not anywhere.
100
+
101
+ ```typescript
102
+ import { InvoiceStatusCode } from '../../generated/optionsets/invoice.js';
103
+
104
+ // Comparisons:
105
+ if (status === InvoiceStatusCode.Gebucht) { ... } // CORRECT
106
+ if (status === 105710002) { ... } // BUG
107
+
108
+ // FetchXML:
109
+ `<condition attribute='${InvoiceFields.Statuscode}' operator='in'>
110
+ <value>${InvoiceStatusCode.Aktiv}</value>
111
+ <value>${InvoiceStatusCode.Gebucht}</value>
112
+ </condition>` // CORRECT
113
+
114
+ `<condition attribute='statuscode' operator='in'>
115
+ <value>1</value><value>105710002</value>
116
+ </condition>` // BUG - raw strings AND magic numbers
117
+ ```
118
+
119
+ ### 4. EntityNames Enum in ALL Xrm.WebApi calls
120
+
121
+ No exceptions, even for system entities. If missing, extend generation.
122
+
123
+ ```typescript
124
+ import { EntityNames } from '../../generated/entity-names.js';
125
+ Xrm.WebApi.retrieveRecord(EntityNames.Account, id, query);
126
+ ```
127
+
128
+ ### 5. Lookup helpers from @xrmforge/helpers
129
+
130
+ ```typescript
131
+ import { formLookup, formLookupId, parseLookup } from '@xrmforge/helpers';
132
+ // Form (via typedForm proxy):
133
+ const customer = formLookup(form.parentaccountid);
134
+ const customerId = formLookupId(form.parentaccountid);
135
+ // Web API response (use NavigationProperties enum, NOT raw strings):
136
+ import { AccountNavigationProperties as AccountNav } from '../../generated/entities/account.js';
137
+ const parent = parseLookup(apiResponse, AccountNav.ParentAccountId);
138
+ ```
139
+
140
+ ### 6. select(), $filter, $expand, $orderby with Fields Enums
141
+
142
+ ALL OData query parts must use entity-level Fields Enums. No raw field name strings anywhere.
143
+
144
+ ```typescript
145
+ import { select, selectExpand } from '@xrmforge/helpers';
146
+ import { AccountFields } from '../../generated/fields/account.js';
147
+
148
+ // $select:
149
+ select(AccountFields.Name, AccountFields.Revenue)
150
+
151
+ // $filter (field names via template literal):
152
+ `${select(AccountFields.Name)}&$filter=${AccountFields.Statecode} eq 0`
153
+
154
+ // $expand (navigation properties):
155
+ selectExpand(
156
+ [AccountFields.Name, AccountFields.Revenue],
157
+ `primarycontactid($select=${ContactFields.Fullname})`,
158
+ )
159
+
160
+ // $orderby:
161
+ `${select(AccountFields.Name)}&$orderby=${AccountFields.Name} asc`
162
+ ```
163
+
164
+ ### 6b. Web API response typing with generated Entity interfaces
165
+
166
+ Always type Web API responses with generated Entity interfaces. Never access properties with `as string` casts.
167
+
168
+ ```typescript
169
+ import type { Account } from '../../generated/entities/account.js';
170
+
171
+ const result = await Xrm.WebApi.retrieveRecord(
172
+ EntityNames.Account, id, select(AccountFields.Name)
173
+ ) as Account;
174
+ result.name // typed as string | null, no cast needed
175
+ ```
176
+
177
+ ### 7. wrapHandler() around EVERY exported handler
178
+
179
+ ```typescript
180
+ export const onLoad = wrapHandler('Namespace.Entity.onLoad', logger, async (ctx) => {
181
+ const form = typedForm<MyForm>(ctx.getFormContext());
182
+ // ...
183
+ });
184
+ ```
185
+
186
+ ### 8. Custom API Executors from generated/actions/
187
+
188
+ Never build your own ExecuteFunctionCall wrapper. Use the generated executors:
189
+
190
+ ```typescript
191
+ import { CreateEMailFromInvoice } from '../../generated/actions/global.js';
192
+ import { withProgress } from '@xrmforge/helpers';
193
+
194
+ const result = await withProgress(
195
+ CreateEMailFromInvoice.execute({ InvoiceId: recordId }),
196
+ { title: 'E-Mail wird erstellt...' }
197
+ );
198
+ // result.EmailId is typed as string
199
+ ```
200
+
201
+ ### 9. Named constants for ALL non-obvious values
202
+
203
+ ```typescript
204
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
205
+ form.lm_zahlungsziel.setValue(new Date(date.getTime() + days * MS_PER_DAY));
206
+ // NEVER: new Date(date.getTime() + days * 86400000)
207
+ ```
208
+
209
+ ### 10. Localized UI strings via pickLang()
210
+
211
+ All user-visible strings MUST go through `pickLang()` in constants.ts:
212
+
213
+ ```typescript
214
+ // constants.ts:
215
+ export const MESSAGES = {
216
+ de: { titlePlaceholder: '[Kurzbeschreibung der Anfrage]' },
217
+ en: { titlePlaceholder: '[Brief description]' },
218
+ } as const;
219
+ export function pickLang<K extends string>(languageId: number, table: { de: Record<K, string>; en: Record<K, string> }): Record<K, string>;
220
+
221
+ // form script:
222
+ import { MESSAGES, pickLang } from '../shared/constants.js';
223
+ const lang = pickLang(Xrm.Utility.getGlobalContext().userSettings.languageId, MESSAGES);
224
+ form.title.setValue(lang.titlePlaceholder);
225
+ ```
226
+
227
+ ### 11. Tabs, Sections, Subgrids via generated enums
228
+
229
+ Never use raw strings for tab, section, or subgrid names:
230
+
231
+ ```typescript
232
+ import { AccountLMFirmaFormTabs as Tabs } from '../../generated/forms/account.js';
233
+ import { AccountLMFirmaFormSUMMARYTABSections as SummarySections } from '../../generated/forms/account.js';
234
+ import { AccountLMFirmaFormSubgrids as Subgrids } from '../../generated/forms/account.js';
235
+
236
+ form.$context.ui.tabs.get(Tabs.SUMMARYTAB).setVisible(true);
237
+ form.$context.ui.tabs.get(Tabs.SUMMARYTAB).sections.get(SummarySections.General).setVisible(false);
238
+ (form.$context.getControl(Subgrids.Orders) as Xrm.Controls.GridControl).refresh();
239
+ ```
240
+
241
+ ### 12. Notification IDs from NOTIFICATION_IDS
242
+
243
+ All notification unique IDs must be in `constants.ts`, never inline raw strings:
244
+
245
+ ```typescript
246
+ // constants.ts:
247
+ export const NOTIFICATION_IDS = {
248
+ genericError: 'lmapp.notification.generic-error',
249
+ saveWarning: 'lmapp.notification.save-warning',
250
+ addressMissing: 'lmapp.notification.address-missing',
251
+ } as const;
252
+
253
+ // form script:
254
+ form.$context.ui.setFormNotification(msg, FormNotificationLevel.Error, NOTIFICATION_IDS.genericError);
255
+ ```
256
+
257
+ ### 13. Xrm constants from @xrmforge/helpers for ALL Xrm enum values
258
+
259
+ Never use raw strings or magic numbers for Xrm API constants:
260
+
261
+ ```typescript
262
+ import { SaveMode, FormNotificationLevel, RequiredLevel, SubmitMode, DisplayState } from '@xrmforge/helpers';
263
+
264
+ // Save mode:
265
+ if (ctx.getEventArgs().getSaveMode() === SaveMode.AutoSave) { ... } // not === 70
266
+
267
+ // Form type (const enum from @types/xrm, works at runtime):
268
+ if (form.$context.ui.getFormType() === XrmEnum.FormType.Create) { ... } // not === 1
269
+
270
+ // Display state:
271
+ if (tab.getDisplayState() === DisplayState.Expanded) { ... } // not === 'expanded'
272
+
273
+ // Required level:
274
+ form.$context.getAttribute(Fields.Name).setRequiredLevel(RequiredLevel.Required); // not 'required'
275
+
276
+ // Submit mode:
277
+ form.$context.getAttribute(Fields.Name).setSubmitMode(SubmitMode.Always); // not 'always'
278
+
279
+ // Notification level:
280
+ form.$context.ui.setFormNotification(msg, FormNotificationLevel.Error, id); // not 'ERROR'
281
+ ```
282
+
283
+ ### 14. EntityNames in openForm and ALL entity references
284
+
285
+ ```typescript
286
+ // openForm:
287
+ Xrm.Navigation.openForm({ entityName: EntityNames.Account, entityId: id }); // not "account"
288
+
289
+ // openWebResource, openUrl, etc.: use EntityNames wherever an entity name appears
290
+ ```
291
+
292
+ ### 15. Module exports, Structured Logger, createFormMock
293
+
294
+ - Module exports (not window/global assignments). esbuild globalName handles namespacing.
295
+ - `createLogger()` instead of console.* (except in logger.ts itself)
296
+ - `createFormMock()` from @xrmforge/testing for ALL form tests
297
+
298
+ ## Rules: NEVER (every occurrence is a bug)
299
+
300
+ **Field/Entity/Resource names:**
301
+ - Never raw strings in `getAttribute()`, `getControl()`, `select()`, `$filter`, `$expand`, `$orderby`, `parseLookup()`, FetchXML `attribute=`, or any function that takes a field name
302
+ - Never raw entity name strings in `Xrm.WebApi`, `Xrm.Navigation.openForm`, or anywhere an entity name appears (use `EntityNames`)
303
+ - Never raw tab/section/subgrid names (use generated Tabs/Sections/Subgrids enums)
304
+ - Never raw notification IDs (use `NOTIFICATION_IDS` from constants.ts)
305
+ - Never create `SystemEntities` objects with raw strings (extend generation with `--entities`)
306
+
307
+ **Magic values:**
308
+ - Never magic numbers for OptionSet values, status codes, or FetchXML `<value>` (use OptionSet Enums)
309
+ - Never magic numbers for time calculations (use named constants like `MS_PER_DAY`)
310
+ - Never `getSaveMode() === 70` (use `SaveMode.AutoSave` from @xrmforge/helpers)
311
+ - Never `getFormType() === 1` (use `XrmEnum.FormType.Create`)
312
+ - Never `'expanded'`/`'collapsed'` (use `DisplayState` from @xrmforge/helpers)
313
+ - Never `'ERROR'`/`'INFO'`/`'WARNING'` (use `FormNotificationLevel`)
314
+ - Never `'none'`/`'required'`/`'recommended'` (use `RequiredLevel`)
315
+ - Never `'always'`/`'dirty'` (use `SubmitMode`)
316
+
317
+ **Web API responses:**
318
+ - Never access WebApi response properties with `as string` casts (use generated Entity interfaces)
319
+ - Never `.getValue()[0].id` for lookups (use `formLookup`/`formLookupId`)
320
+ - Never raw strings in `parseLookup()` (use NavigationProperties enum)
321
+
322
+ **Code quality:**
323
+ - Never `Xrm.Page` (deprecated since D365 v9.0)
324
+ - Never `eval()`, never synchronous XMLHttpRequest
325
+ - Never `window.X = ...` (use module exports)
326
+ - Never `console.log/warn/error` in form scripts (use shared logger)
327
+ - Never export handlers without `wrapHandler()`
328
+ - Never unlokalized UI strings (use `pickLang()` from constants.ts)
329
+ - Never build your own getValue/setFieldValue/setDisabled/addOnChange helpers (use `typedForm` + native Xrm API)
330
+ - Never `import ... from '@xrmforge/typegen'` in browser code (use `@xrmforge/helpers`)
331
+ - Never `as any` without eslint-disable comment explaining why
332
+ - Never untyped `catch (error)` (always `catch (error: unknown)`)
333
+
334
+ ## Subagent Handoff (when delegating to sub-agents)
335
+
336
+ Copy these MANDATORY rules into every sub-agent prompt:
337
+
338
+ ```
339
+ 1. typedForm<FormType>(ctx.getFormContext()) for ALL field access
340
+ 2. Entity-level Fields Enums in ALL select(), $filter, $expand, $orderby, FetchXML attribute=
341
+ 3. OptionSet Enum for ALL value comparisons AND FetchXML <value> (never magic numbers)
342
+ 4. EntityNames for ALL Xrm.WebApi calls AND openForm (never raw entity names)
343
+ 5. formLookup/formLookupId for ALL lookup access (never .getValue()[0].id)
344
+ 6. parseLookup with NavigationProperties enum (never raw nav property strings)
345
+ 7. Generated Entity interfaces for ALL WebApi response typing (never as string casts)
346
+ 8. Tabs/Sections/Subgrids enums for ALL UI structure access (never raw strings)
347
+ 9. SaveMode/FormType/DisplayState/RequiredLevel/SubmitMode/FormNotificationLevel constants
348
+ 10. wrapHandler() around EVERY exported handler
349
+ 11. createLogger() instead of console.* (except logger.ts)
350
+ 12. Custom API Executors from generated/actions/ (never build your own)
351
+ 13. NOTIFICATION_IDS from constants.ts for all notification unique IDs
352
+ 14. Named constants for non-obvious values (never magic numbers like 86400000)
353
+ 15. pickLang() for all user-visible strings (never hardcoded German/English)
354
+ ```
355
+
356
+ ## Mandatory Shared Utilities
357
+
358
+ Every XrmForge project MUST have these in `src/shared/`:
359
+
360
+ ### logger.ts
361
+ ```typescript
362
+ export interface Logger { debug(msg: string, data?: unknown): void; info(...); warn(...); error(...); }
363
+ export function createLogger(namespace: string): Logger;
364
+ // Only file allowed to use console.*
365
+ ```
366
+
367
+ ### error-handler.ts
368
+ ```typescript
369
+ export function wrapHandler(name: string, logger: Logger, handler: EventHandler): EventHandler;
370
+ export function wrapCommand(name: string, logger: Logger, handler: CommandHandler): CommandHandler;
371
+ // Catches sync+async errors, shows form notification via FormNotificationLevel.Error
372
+ ```
373
+
374
+ ### constants.ts
375
+ ```typescript
376
+ export const NOTIFICATION_IDS = { ... } as const;
377
+ export const MESSAGES = { de: { ... }, en: { ... } } as const;
378
+ export function pickLang<K extends string>(languageId: number, table: ...): Record<K, string>;
379
+ ```
380
+
381
+ ## Before/After Examples
382
+
383
+ ### Field Access (primary pattern: typedForm)
384
+ ```typescript
385
+ // BEFORE (legacy):
386
+ formContext.getAttribute("name").getValue()
387
+ // BEFORE (getAttribute + Fields):
388
+ form.getAttribute(Fields.Name).getValue()
389
+ // AFTER (typedForm - preferred):
390
+ const form = typedForm<AccountForm>(ctx.getFormContext());
391
+ form.name.getValue()
392
+ ```
393
+
394
+ ### Web API Query
395
+ ```typescript
396
+ // BEFORE:
397
+ Xrm.WebApi.retrieveRecord("account", id, "?$select=name,revenue")
398
+ // AFTER:
399
+ import { AccountFields } from '../../generated/fields/account.js';
400
+ Xrm.WebApi.retrieveRecord(EntityNames.Account, id,
401
+ select(AccountFields.Name, AccountFields.Revenue))
402
+ ```
403
+
404
+ ### OptionSet Comparison
405
+ ```typescript
406
+ // BEFORE: if (status.getValue() === 595300002) { ... }
407
+ // AFTER:
408
+ import { StatusCode } from '../../generated/optionsets/invoice.js';
409
+ if (form.statuscode.getValue() === StatusCode.Gebucht) { ... }
410
+ ```
411
+
412
+ ### FetchXML
413
+ ```typescript
414
+ // BEFORE:
415
+ `<condition attribute='statuscode' operator='in'><value>1</value><value>105710000</value></condition>`
416
+ // AFTER:
417
+ import { LmBestellungFields } from '../../generated/fields/lm_bestellung.js';
418
+ import { LmBestellungStatusCode } from '../../generated/optionsets/lm_bestellung.js';
419
+ `<condition attribute='${LmBestellungFields.Statuscode}' operator='in'>
420
+ <value>${LmBestellungStatusCode.Aktiv}</value>
421
+ <value>${LmBestellungStatusCode.InArbeit}</value>
422
+ </condition>`
423
+ ```
424
+
425
+ ### Lookup Access
426
+ ```typescript
427
+ // BEFORE: form.getAttribute("customerid").getValue()[0].id.replace("{","").replace("}","")
428
+ // AFTER:
429
+ const customerId = formLookupId(form.customerid);
430
+ ```
431
+
432
+ ### Custom API Call
433
+ ```typescript
434
+ // BEFORE: ExecuteFunctionCall("CancelInvoice", { InvoiceId: id })
435
+ // AFTER:
436
+ import { CancelInvoice } from '../../generated/actions/global.js';
437
+ const result = await withProgress(
438
+ CancelInvoice.execute({ InvoiceId: id }),
439
+ { title: 'Rechnung wird storniert...' }
440
+ );
441
+ ```
442
+
443
+ ### Date Calculation
444
+ ```typescript
445
+ // BEFORE: new Date(date.getTime() + nettotage * 86400000)
446
+ // AFTER:
447
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
448
+ new Date(date.getTime() + nettotage * MS_PER_DAY)
449
+ ```
450
+
451
+ ### UI Strings
452
+ ```typescript
453
+ // BEFORE: form.title.setValue('[Kurzbeschreibung der Anfrage]')
454
+ // AFTER:
455
+ const lang = pickLang(Xrm.Utility.getGlobalContext().userSettings.languageId, MESSAGES);
456
+ form.title.setValue(lang.titlePlaceholder);
457
+ ```
458
+
459
+ ## Testing (onLoad + onChange)
460
+
461
+ ```typescript
462
+ import { createFormMock, setupXrmMock, teardownXrmMock } from '@xrmforge/testing';
463
+
464
+ beforeEach(() => setupXrmMock());
465
+ afterEach(() => teardownXrmMock());
466
+
467
+ // onLoad test:
468
+ const mock = createFormMock<AccountMainForm>({ name: 'Test', statuscode: 0 });
469
+ onLoad(mock.asEventContext());
470
+ expect(mock.getControl(Fields.Revenue).getVisible()).toBe(true);
471
+
472
+ // onChange test (MANDATORY for every onChange handler):
473
+ mock.setValue(Fields.Revenue, 500000);
474
+ mock.fireOnChange(Fields.Revenue);
475
+ expect(mock.getControl(Fields.CreditLimit).getVisible()).toBe(true);
476
+ ```
477
+
478
+ **Test quality rule:** At least 30% of tests MUST use `fireOnChange` or WebApi mock
479
+ assertions. Pure smoke tests (`onLoad` + `not.toThrow`) do NOT count.
480
+ Every onChange handler MUST have a `fireOnChange` test.
481
+
482
+ **attr.controls:** Since @xrmforge/testing 0.2.3, `createFormMock()` automatically links
483
+ each attribute to its control. `mock.getControl(Fields.Name)` works out of the box.
484
+
485
+ ## Pattern Recognition: Legacy to XrmForge
486
+
487
+ | Legacy Pattern | XrmForge Replacement |
488
+ |---|---|
489
+ | `getAttribute("name")` | `form.name` (via typedForm) or `getAttribute(Fields.Name)` |
490
+ | `getControl("name")` | `form.$control(Fields.Name)` |
491
+ | `getValue() === 595300000` | `form.statuscode.getValue() === StatusCode.Active` |
492
+ | `Xrm.WebApi.retrieveRecord("account", id)` | `Xrm.WebApi.retrieveRecord(EntityNames.Account, id)` |
493
+ | `"?$select=name,revenue"` | `select(AccountFields.Name, AccountFields.Revenue)` |
494
+ | `value[0].id.replace("{","")` | `formLookupId(form.customerid)` |
495
+ | `Xrm.Page.getAttribute(...)` | `form.fieldname` (via typedForm) |
496
+ | `var formContext` (global) | `const form = typedForm<MyForm>(ctx.getFormContext())` |
497
+ | `function form_OnLoad(ctx)` | `export const onLoad = wrapHandler(...)` |
498
+ | `ExecuteFunctionCall("name", ...)` | `import { Name } from '../../generated/actions/global.js'` |
499
+ | `setFormNotification(msg, 'ERROR', id)` | `setFormNotification(msg, FormNotificationLevel.Error, id)` |
500
+ | `86400000` | `const MS_PER_DAY = 24 * 60 * 60 * 1000` |
501
+ | `'[Kurzbeschreibung]'` | `pickLang(languageId, MESSAGES).placeholder` |
502
+
503
+ ## @types/xrm Pitfalls (known issues)
504
+
505
+ 1. **Form Interface:** Do NOT use `interface extends Xrm.FormContext`. Use `Omit` pattern.
506
+ 2. **AlertDialogResponse** does NOT exist. Use `Xrm.Async.PromiseLike<void>`.
507
+ 3. **ConfirmDialogResponse** does NOT exist. Use `Xrm.Navigation.ConfirmResult`.
508
+ 4. **setNotification()** requires 2 arguments: (message, uniqueId).
509
+ 5. **openFile()** requires `fileSize` property in FileDetails.
510
+ 6. **Grid.refresh()** requires `(grid as any).refresh()` with eslint-disable comment.
511
+
512
+ ## Build
513
+
514
+ ```bash
515
+ npx xrmforge build # IIFE bundles for D365
516
+ npx xrmforge build --watch # Watch mode (~10ms rebuilds)
517
+ ```
518
+
519
+ ## File Structure
520
+
521
+ ```
522
+ src/forms/{entity}-form.ts - Form scripts (one per entity)
523
+ src/shared/logger.ts - Structured logger (only file with console.*)
524
+ src/shared/error-handler.ts - wrapHandler + wrapCommand
525
+ src/shared/constants.ts - NOTIFICATION_IDS, MESSAGES, pickLang
526
+ generated/ - Generated types (do not edit manually)
527
+ tests/forms/{entity}.test.ts - Tests
528
+ xrmforge.config.json - Build config
529
+ scripts/validate-form.mjs - Quality gate (run after each batch)
530
+ ```
531
+
532
+ ## Self-Check (MANDATORY before Tests)
533
+
534
+ Run `node scripts/validate-form.mjs` after every batch. Must report 0 violations.