@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.
- package/LICENSE +21 -21
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/templates/AGENT.md +707 -707
- package/dist/templates/azure-pipelines.yml +5 -9
- package/dist/templates/constants.ts +32 -32
- package/dist/templates/error-handler.ts +96 -96
- package/dist/templates/eslint.config.js +21 -21
- package/dist/templates/example-form.test.ts +33 -33
- package/dist/templates/example-form.ts +77 -77
- package/dist/templates/github-actions-ci.yml +5 -9
- package/dist/templates/logger.ts +67 -67
- package/dist/templates/validate-form.mjs +393 -393
- package/package.json +1 -1
|
@@ -1,393 +1,393 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* validate-form.mjs - Quality Gate for XrmForge Form Scripts
|
|
3
|
-
*
|
|
4
|
-
* Runs three checks in one command:
|
|
5
|
-
* 1. TypeScript Compiler (tsc --noEmit)
|
|
6
|
-
* 2. ESLint (src/ --max-warnings=0)
|
|
7
|
-
* 3. Pattern Compliance (critical rule violations via text search)
|
|
8
|
-
*
|
|
9
|
-
* Exit code: 0 = all green, 1 = errors found
|
|
10
|
-
*
|
|
11
|
-
* Usage:
|
|
12
|
-
* node scripts/validate-form.mjs
|
|
13
|
-
* npm run validate
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { execSync } from 'node:child_process';
|
|
17
|
-
import { readdirSync, readFileSync } from 'node:fs';
|
|
18
|
-
import { join, relative } from 'node:path';
|
|
19
|
-
|
|
20
|
-
const RED = '\x1b[31m';
|
|
21
|
-
const GREEN = '\x1b[32m';
|
|
22
|
-
const YELLOW = '\x1b[33m';
|
|
23
|
-
const NC = '\x1b[0m';
|
|
24
|
-
|
|
25
|
-
let totalErrors = 0;
|
|
26
|
-
|
|
27
|
-
// ============================================================
|
|
28
|
-
// 1. TypeScript Compiler
|
|
29
|
-
// ============================================================
|
|
30
|
-
|
|
31
|
-
console.log('=== XrmForge Quality Gate ===\n');
|
|
32
|
-
console.log('--- TypeScript ---');
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
execSync('npx tsc --noEmit', { stdio: 'pipe', encoding: 'utf-8' });
|
|
36
|
-
console.log(`${GREEN}OK${NC} tsc --noEmit`);
|
|
37
|
-
} catch (err) {
|
|
38
|
-
const output = (err.stdout || '') + (err.stderr || '');
|
|
39
|
-
const errorCount = (output.match(/error TS/g) || []).length;
|
|
40
|
-
console.log(`${RED}FAIL${NC} tsc --noEmit (${errorCount} errors)`);
|
|
41
|
-
console.log(output.split('\n').slice(0, 20).join('\n'));
|
|
42
|
-
totalErrors += errorCount || 1;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ============================================================
|
|
46
|
-
// 2. ESLint
|
|
47
|
-
// ============================================================
|
|
48
|
-
|
|
49
|
-
console.log('\n--- ESLint ---');
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
execSync('npx eslint src/ --max-warnings=0', { stdio: 'pipe', encoding: 'utf-8' });
|
|
53
|
-
console.log(`${GREEN}OK${NC} eslint src/ --max-warnings=0`);
|
|
54
|
-
} catch (err) {
|
|
55
|
-
const output = (err.stdout || '') + (err.stderr || '');
|
|
56
|
-
const problemMatch = output.match(/(\d+) problems?/);
|
|
57
|
-
const count = problemMatch ? problemMatch[1] : '?';
|
|
58
|
-
console.log(`${RED}FAIL${NC} eslint (${count} problems)`);
|
|
59
|
-
console.log(output.split('\n').slice(0, 20).join('\n'));
|
|
60
|
-
totalErrors += parseInt(count) || 1;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ============================================================
|
|
64
|
-
// 3. Pattern Compliance (critical rules)
|
|
65
|
-
// ============================================================
|
|
66
|
-
|
|
67
|
-
console.log('\n--- Pattern Compliance ---');
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Collect all .ts files recursively from a directory.
|
|
71
|
-
*/
|
|
72
|
-
function collectTsFiles(dir) {
|
|
73
|
-
const results = [];
|
|
74
|
-
try {
|
|
75
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
76
|
-
const fullPath = join(dir, entry.name);
|
|
77
|
-
if (entry.isDirectory() && entry.name !== 'node_modules' && entry.name !== 'dist') {
|
|
78
|
-
results.push(...collectTsFiles(fullPath));
|
|
79
|
-
} else if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
|
|
80
|
-
results.push(fullPath);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
} catch {
|
|
84
|
-
// Directory does not exist
|
|
85
|
-
}
|
|
86
|
-
return results;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Check a pattern rule against all files.
|
|
91
|
-
* @param {string} label - Description of the check
|
|
92
|
-
* @param {string[]} files - Files to check
|
|
93
|
-
* @param {RegExp} regex - Pattern to match (violation)
|
|
94
|
-
* @param {string[]} excludeFiles - File paths to exclude
|
|
95
|
-
* @param {RegExp[]} excludePatterns - Line patterns to exclude (not violations even if regex matches)
|
|
96
|
-
* @returns Number of violations
|
|
97
|
-
*/
|
|
98
|
-
function checkPattern(label, files, regex, excludeFiles = [], excludePatterns = []) {
|
|
99
|
-
const violations = [];
|
|
100
|
-
for (const file of files) {
|
|
101
|
-
const relPath = relative(process.cwd(), file);
|
|
102
|
-
if (excludeFiles.some((ex) => relPath.includes(ex))) continue;
|
|
103
|
-
|
|
104
|
-
const lines = readFileSync(file, 'utf-8').split('\n');
|
|
105
|
-
for (let i = 0; i < lines.length; i++) {
|
|
106
|
-
const line = lines[i];
|
|
107
|
-
const trimmed = line.trim();
|
|
108
|
-
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
|
|
109
|
-
if (excludePatterns.some((ep) => ep.test(trimmed))) continue;
|
|
110
|
-
if (regex.test(line)) {
|
|
111
|
-
violations.push(` ${relPath}:${i + 1}: ${trimmed}`);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (violations.length === 0) {
|
|
117
|
-
console.log(`${GREEN}OK${NC} [0] ${label}`);
|
|
118
|
-
} else {
|
|
119
|
-
console.log(`${RED}FAIL${NC} [${violations.length}] ${label}`);
|
|
120
|
-
violations.slice(0, 10).forEach((v) => console.log(v));
|
|
121
|
-
if (violations.length > 10) {
|
|
122
|
-
console.log(` ... and ${violations.length - 10} more`);
|
|
123
|
-
}
|
|
124
|
-
totalErrors += violations.length;
|
|
125
|
-
}
|
|
126
|
-
return violations.length;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const formFiles = collectTsFiles('src/forms');
|
|
130
|
-
const allSrcFiles = collectTsFiles('src');
|
|
131
|
-
|
|
132
|
-
// ── Field Access ─────────────────────────────────────────────────────────────
|
|
133
|
-
|
|
134
|
-
// 3a. Raw strings in getAttribute/getControl (must use Fields Enum)
|
|
135
|
-
checkPattern(
|
|
136
|
-
'Raw field strings in getAttribute/getControl',
|
|
137
|
-
formFiles,
|
|
138
|
-
/(?:getAttribute|getControl)\s*\(\s*['"][a-z]/,
|
|
139
|
-
);
|
|
140
|
-
|
|
141
|
-
// 3b. Raw strings in helper wrappers (getValue, setFieldValue, setDisabled, addOnChange, setVisible, setRequiredLevel)
|
|
142
|
-
checkPattern(
|
|
143
|
-
'Raw field strings in helper functions (use typedForm or Fields Enum)',
|
|
144
|
-
allSrcFiles,
|
|
145
|
-
/(?:getValue|setFieldValue|setDisabled|addOnChange|setVisible|setRequiredLevel|addPreSearch)\s*\(\s*\w+\s*,\s*['"][a-z]/,
|
|
146
|
-
['generated/', 'logger.ts'],
|
|
147
|
-
);
|
|
148
|
-
|
|
149
|
-
// 3c. Raw strings in select() (must use entity-level Fields Enum)
|
|
150
|
-
checkPattern(
|
|
151
|
-
'Raw field strings in select() (use entity-level Fields Enum)',
|
|
152
|
-
allSrcFiles,
|
|
153
|
-
/\bselect\s*\(\s*['"][a-z]/,
|
|
154
|
-
['generated/'],
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
// ── Entity Names ─────────────────────────────────────────────────────────────
|
|
158
|
-
|
|
159
|
-
// 3d. Raw entity name strings in WebApi calls (must use EntityNames Enum)
|
|
160
|
-
checkPattern(
|
|
161
|
-
'Raw entity names in WebApi (must use EntityNames Enum)',
|
|
162
|
-
allSrcFiles,
|
|
163
|
-
/(?:retrieveRecord|retrieveMultipleRecords|createRecord|updateRecord|deleteRecord)\s*\(\s*['"]/,
|
|
164
|
-
['generated/'],
|
|
165
|
-
);
|
|
166
|
-
|
|
167
|
-
// 3e. SystemEntities workaround (must extend generation instead)
|
|
168
|
-
checkPattern(
|
|
169
|
-
'SystemEntities workaround (extend generation with --entities instead)',
|
|
170
|
-
allSrcFiles,
|
|
171
|
-
/SystemEntities\./,
|
|
172
|
-
);
|
|
173
|
-
|
|
174
|
-
// ── Magic Values ─────────────────────────────────────────────────────────────
|
|
175
|
-
|
|
176
|
-
// 3f. Magic numbers in OptionSet comparisons (must use OptionSet Enum)
|
|
177
|
-
checkPattern(
|
|
178
|
-
'Magic numbers in value comparisons (use OptionSet Enum)',
|
|
179
|
-
allSrcFiles,
|
|
180
|
-
/getValue\(\)\s*===?\s*\d{3,}/,
|
|
181
|
-
['generated/'],
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
// 3g. Magic number 86400000 (must use named constant MS_PER_DAY)
|
|
185
|
-
checkPattern(
|
|
186
|
-
'Magic number 86400000 (use named constant MS_PER_DAY)',
|
|
187
|
-
allSrcFiles,
|
|
188
|
-
/86400000/,
|
|
189
|
-
);
|
|
190
|
-
|
|
191
|
-
// 3h. Raw notification level strings (must use FormNotificationLevel)
|
|
192
|
-
checkPattern(
|
|
193
|
-
'Raw notification level strings (use FormNotificationLevel from @xrmforge/helpers)',
|
|
194
|
-
allSrcFiles,
|
|
195
|
-
/setFormNotification\s*\([^)]*['"](?:ERROR|WARNING|INFO)['"]/,
|
|
196
|
-
);
|
|
197
|
-
|
|
198
|
-
// ── Deprecated / Unsafe ──────────────────────────────────────────────────────
|
|
199
|
-
|
|
200
|
-
// 3i. Xrm.Page (deprecated since D365 v9.0)
|
|
201
|
-
checkPattern(
|
|
202
|
-
'Xrm.Page (deprecated since D365 v9.0)',
|
|
203
|
-
allSrcFiles,
|
|
204
|
-
/\bXrm\.Page\b/,
|
|
205
|
-
);
|
|
206
|
-
|
|
207
|
-
// 3j. eval() usage
|
|
208
|
-
checkPattern(
|
|
209
|
-
'eval() usage (use Number() or JSON.parse())',
|
|
210
|
-
allSrcFiles,
|
|
211
|
-
/\beval\s*\(/,
|
|
212
|
-
);
|
|
213
|
-
|
|
214
|
-
// 3k. console.log/warn/error outside logger.ts (must use Logger)
|
|
215
|
-
checkPattern(
|
|
216
|
-
'console.* outside logger.ts (must use Logger)',
|
|
217
|
-
allSrcFiles,
|
|
218
|
-
/\bconsole\.(log|warn|error|info|debug)\b/,
|
|
219
|
-
['logger.ts'],
|
|
220
|
-
);
|
|
221
|
-
|
|
222
|
-
// ── Type Safety Bypass ───────────────────────────────────────────────────────
|
|
223
|
-
|
|
224
|
-
// 3l2. Cast to Xrm.FormContext (bypasses typed form interface)
|
|
225
|
-
checkPattern(
|
|
226
|
-
'Cast to Xrm.FormContext (use typedForm $unsafe() for off-form fields)',
|
|
227
|
-
formFiles,
|
|
228
|
-
/as\s+(?:unknown\s+as\s+)?Xrm\.FormContext/,
|
|
229
|
-
);
|
|
230
|
-
|
|
231
|
-
// 3l3. Raw strings in $filter (field names must use Fields Enum interpolation)
|
|
232
|
-
checkPattern(
|
|
233
|
-
'Raw field names in $filter (use Fields Enum interpolation)',
|
|
234
|
-
allSrcFiles,
|
|
235
|
-
/\$filter=[^$]*\b(?:eq|ne|gt|lt|ge|le|contains|startswith)\b/,
|
|
236
|
-
['generated/', 'validate-form'],
|
|
237
|
-
[
|
|
238
|
-
// Allow if the line contains template literal interpolation (${...})
|
|
239
|
-
/\$\{/,
|
|
240
|
-
],
|
|
241
|
-
);
|
|
242
|
-
|
|
243
|
-
// 3l4. Raw strings in $unsafe() (must use Entity-level Fields Enum)
|
|
244
|
-
checkPattern(
|
|
245
|
-
'Raw field strings in $unsafe() (use Entity-level Fields Enum)',
|
|
246
|
-
formFiles,
|
|
247
|
-
/\$unsafe\s*\(\s*['"][a-z]/,
|
|
248
|
-
);
|
|
249
|
-
|
|
250
|
-
// ── Handler Pattern ──────────────────────────────────────────────────────────
|
|
251
|
-
|
|
252
|
-
// 3l. Exported handlers without wrapHandler or wrapCommand
|
|
253
|
-
checkPattern(
|
|
254
|
-
'Exported handlers without wrapHandler/wrapCommand',
|
|
255
|
-
formFiles,
|
|
256
|
-
/^export\s+(const|async\s+function|function)\s+\w+(?!.*(?:wrapHandler|wrapCommand))/,
|
|
257
|
-
[],
|
|
258
|
-
[
|
|
259
|
-
// Re-exports: `export const form_OnLoad = onLoad;` (alias for a wrapped handler)
|
|
260
|
-
/^export\s+const\s+\w+\s*=\s*[a-zA-Z][a-zA-Z0-9]*\s*;/,
|
|
261
|
-
],
|
|
262
|
-
);
|
|
263
|
-
|
|
264
|
-
// ── Raw $select ──────────────────────────────────────────────────────────────
|
|
265
|
-
|
|
266
|
-
// 3m. Raw $select strings (must use select() from @xrmforge/helpers)
|
|
267
|
-
checkPattern(
|
|
268
|
-
'Raw $select strings (must use select() from @xrmforge/helpers)',
|
|
269
|
-
allSrcFiles,
|
|
270
|
-
/['"]\?\$select=/,
|
|
271
|
-
['generated/'],
|
|
272
|
-
);
|
|
273
|
-
|
|
274
|
-
// ── FetchXML ─────────────────────────────────────────────────────────────────
|
|
275
|
-
|
|
276
|
-
// 3n. Raw field names in FetchXML attribute= (should use Fields Enum interpolation)
|
|
277
|
-
checkPattern(
|
|
278
|
-
'Raw field names in FetchXML (use Fields Enum interpolation)',
|
|
279
|
-
allSrcFiles,
|
|
280
|
-
/attribute\s*=\s*'[a-z][a-z0-9_]+'/,
|
|
281
|
-
['generated/'],
|
|
282
|
-
);
|
|
283
|
-
|
|
284
|
-
// 3o. Magic numbers in FetchXML <value> (should use OptionSet Enum)
|
|
285
|
-
checkPattern(
|
|
286
|
-
'Magic numbers in FetchXML values (use OptionSet Enum)',
|
|
287
|
-
allSrcFiles,
|
|
288
|
-
/<value>\d{3,}<\/value>/,
|
|
289
|
-
['generated/'],
|
|
290
|
-
);
|
|
291
|
-
|
|
292
|
-
// ── WebApi Response Typing ────────────────────────────────────────────────────
|
|
293
|
-
|
|
294
|
-
// 3p. Untyped WebApi responses (must use generated Entity interfaces)
|
|
295
|
-
checkPattern(
|
|
296
|
-
'Untyped WebApi response cast (use generated Entity interface instead of Record<string, unknown>)',
|
|
297
|
-
allSrcFiles,
|
|
298
|
-
/as\s+Record\s*<\s*string\s*,\s*unknown\s*>/,
|
|
299
|
-
['generated/'],
|
|
300
|
-
);
|
|
301
|
-
|
|
302
|
-
// 3q. Manual OData annotation access (use parseLookup instead)
|
|
303
|
-
checkPattern(
|
|
304
|
-
'Manual OData annotation access (use parseLookup() from @xrmforge/helpers)',
|
|
305
|
-
allSrcFiles,
|
|
306
|
-
/@OData\.Community\.Display|@Microsoft\.Dynamics\.CRM\.lookuplogicalname|_value(?:@|\s*as\s)/,
|
|
307
|
-
['generated/', 'node_modules'],
|
|
308
|
-
);
|
|
309
|
-
|
|
310
|
-
// ── Legacy Helper Wrappers ───────────────────────────────────────────────────
|
|
311
|
-
|
|
312
|
-
// 3r. Forbidden legacy helper functions (must use typedForm + @xrmforge/helpers)
|
|
313
|
-
checkPattern(
|
|
314
|
-
'Forbidden helper: getLookupId (use formLookupId from @xrmforge/helpers)',
|
|
315
|
-
allSrcFiles,
|
|
316
|
-
/\bgetLookupId\s*\(/,
|
|
317
|
-
['generated/'],
|
|
318
|
-
);
|
|
319
|
-
|
|
320
|
-
checkPattern(
|
|
321
|
-
'Forbidden helper: setLookupValue (use form.field.setValue([{...}]))',
|
|
322
|
-
allSrcFiles,
|
|
323
|
-
/\bsetLookupValue\s*\(/,
|
|
324
|
-
['generated/'],
|
|
325
|
-
);
|
|
326
|
-
|
|
327
|
-
// ── UI Localization ──────────────────────────────────────────────────────────
|
|
328
|
-
|
|
329
|
-
// 3s. Hardcoded UI strings in dialogs/progress (must use pickLang from constants.ts)
|
|
330
|
-
checkPattern(
|
|
331
|
-
'Hardcoded UI string in dialog/progress (use pickLang(MESSAGES) from constants.ts)',
|
|
332
|
-
allSrcFiles,
|
|
333
|
-
/(?:openAlertDialog|openConfirmDialog|openErrorDialog|showProgressIndicator)\s*\(\s*(?:\{\s*text\s*:\s*)?['"]/,
|
|
334
|
-
['generated/', 'constants.ts'],
|
|
335
|
-
);
|
|
336
|
-
|
|
337
|
-
// ── Duplicate Framework Functions ────────────────────────────────────────────
|
|
338
|
-
|
|
339
|
-
// 3t. Own normalizeGuid/compareGuid (use normalizeGuid from @xrmforge/helpers)
|
|
340
|
-
checkPattern(
|
|
341
|
-
'Own normalizeGuid/compareGuid definition (use normalizeGuid from @xrmforge/helpers)',
|
|
342
|
-
allSrcFiles,
|
|
343
|
-
/(?:export\s+)?function\s+(?:normalizeGuid|compareGuid)\s*\(/,
|
|
344
|
-
);
|
|
345
|
-
|
|
346
|
-
// ── Legacy Code Smells ───────────────────────────────────────────────────────
|
|
347
|
-
|
|
348
|
-
// 3u. var declarations (use const/let)
|
|
349
|
-
checkPattern(
|
|
350
|
-
'var declarations (use const or let)',
|
|
351
|
-
allSrcFiles,
|
|
352
|
-
/^\s*var\s/,
|
|
353
|
-
);
|
|
354
|
-
|
|
355
|
-
// 3v. Synchronous XMLHttpRequest (use fetch or Xrm.WebApi)
|
|
356
|
-
checkPattern(
|
|
357
|
-
'XMLHttpRequest (use fetch or Xrm.WebApi)',
|
|
358
|
-
allSrcFiles,
|
|
359
|
-
/\bXMLHttpRequest\b/,
|
|
360
|
-
);
|
|
361
|
-
|
|
362
|
-
// ============================================================
|
|
363
|
-
// Test Completeness (warning only, does not fail the gate)
|
|
364
|
-
// ============================================================
|
|
365
|
-
|
|
366
|
-
console.log('\n--- Test Completeness ---');
|
|
367
|
-
|
|
368
|
-
let missingTests = 0;
|
|
369
|
-
for (const formFile of formFiles) {
|
|
370
|
-
const base = formFile.replace(/.*[\\/]/, '').replace(/\.ts$/, '');
|
|
371
|
-
try {
|
|
372
|
-
readFileSync(join('tests', 'forms', `${base}.test.ts`));
|
|
373
|
-
} catch {
|
|
374
|
-
console.log(`${YELLOW}WARN${NC} No test file for ${relative(process.cwd(), formFile)}`);
|
|
375
|
-
missingTests++;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
if (missingTests === 0) {
|
|
379
|
-
console.log(`${GREEN}OK${NC} [0] All form scripts have test files`);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// ============================================================
|
|
383
|
-
// Result
|
|
384
|
-
// ============================================================
|
|
385
|
-
|
|
386
|
-
console.log('\n=== Result ===');
|
|
387
|
-
if (totalErrors === 0) {
|
|
388
|
-
console.log(`${GREEN}All checks passed. 0 violations.${NC}`);
|
|
389
|
-
process.exit(0);
|
|
390
|
-
} else {
|
|
391
|
-
console.log(`${RED}${totalErrors} violations found.${NC}`);
|
|
392
|
-
process.exit(1);
|
|
393
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* validate-form.mjs - Quality Gate for XrmForge Form Scripts
|
|
3
|
+
*
|
|
4
|
+
* Runs three checks in one command:
|
|
5
|
+
* 1. TypeScript Compiler (tsc --noEmit)
|
|
6
|
+
* 2. ESLint (src/ --max-warnings=0)
|
|
7
|
+
* 3. Pattern Compliance (critical rule violations via text search)
|
|
8
|
+
*
|
|
9
|
+
* Exit code: 0 = all green, 1 = errors found
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node scripts/validate-form.mjs
|
|
13
|
+
* npm run validate
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execSync } from 'node:child_process';
|
|
17
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
18
|
+
import { join, relative } from 'node:path';
|
|
19
|
+
|
|
20
|
+
const RED = '\x1b[31m';
|
|
21
|
+
const GREEN = '\x1b[32m';
|
|
22
|
+
const YELLOW = '\x1b[33m';
|
|
23
|
+
const NC = '\x1b[0m';
|
|
24
|
+
|
|
25
|
+
let totalErrors = 0;
|
|
26
|
+
|
|
27
|
+
// ============================================================
|
|
28
|
+
// 1. TypeScript Compiler
|
|
29
|
+
// ============================================================
|
|
30
|
+
|
|
31
|
+
console.log('=== XrmForge Quality Gate ===\n');
|
|
32
|
+
console.log('--- TypeScript ---');
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
execSync('npx tsc --noEmit', { stdio: 'pipe', encoding: 'utf-8' });
|
|
36
|
+
console.log(`${GREEN}OK${NC} tsc --noEmit`);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
const output = (err.stdout || '') + (err.stderr || '');
|
|
39
|
+
const errorCount = (output.match(/error TS/g) || []).length;
|
|
40
|
+
console.log(`${RED}FAIL${NC} tsc --noEmit (${errorCount} errors)`);
|
|
41
|
+
console.log(output.split('\n').slice(0, 20).join('\n'));
|
|
42
|
+
totalErrors += errorCount || 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============================================================
|
|
46
|
+
// 2. ESLint
|
|
47
|
+
// ============================================================
|
|
48
|
+
|
|
49
|
+
console.log('\n--- ESLint ---');
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
execSync('npx eslint src/ --max-warnings=0', { stdio: 'pipe', encoding: 'utf-8' });
|
|
53
|
+
console.log(`${GREEN}OK${NC} eslint src/ --max-warnings=0`);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
const output = (err.stdout || '') + (err.stderr || '');
|
|
56
|
+
const problemMatch = output.match(/(\d+) problems?/);
|
|
57
|
+
const count = problemMatch ? problemMatch[1] : '?';
|
|
58
|
+
console.log(`${RED}FAIL${NC} eslint (${count} problems)`);
|
|
59
|
+
console.log(output.split('\n').slice(0, 20).join('\n'));
|
|
60
|
+
totalErrors += parseInt(count) || 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ============================================================
|
|
64
|
+
// 3. Pattern Compliance (critical rules)
|
|
65
|
+
// ============================================================
|
|
66
|
+
|
|
67
|
+
console.log('\n--- Pattern Compliance ---');
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Collect all .ts files recursively from a directory.
|
|
71
|
+
*/
|
|
72
|
+
function collectTsFiles(dir) {
|
|
73
|
+
const results = [];
|
|
74
|
+
try {
|
|
75
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
76
|
+
const fullPath = join(dir, entry.name);
|
|
77
|
+
if (entry.isDirectory() && entry.name !== 'node_modules' && entry.name !== 'dist') {
|
|
78
|
+
results.push(...collectTsFiles(fullPath));
|
|
79
|
+
} else if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
|
|
80
|
+
results.push(fullPath);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// Directory does not exist
|
|
85
|
+
}
|
|
86
|
+
return results;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check a pattern rule against all files.
|
|
91
|
+
* @param {string} label - Description of the check
|
|
92
|
+
* @param {string[]} files - Files to check
|
|
93
|
+
* @param {RegExp} regex - Pattern to match (violation)
|
|
94
|
+
* @param {string[]} excludeFiles - File paths to exclude
|
|
95
|
+
* @param {RegExp[]} excludePatterns - Line patterns to exclude (not violations even if regex matches)
|
|
96
|
+
* @returns Number of violations
|
|
97
|
+
*/
|
|
98
|
+
function checkPattern(label, files, regex, excludeFiles = [], excludePatterns = []) {
|
|
99
|
+
const violations = [];
|
|
100
|
+
for (const file of files) {
|
|
101
|
+
const relPath = relative(process.cwd(), file);
|
|
102
|
+
if (excludeFiles.some((ex) => relPath.includes(ex))) continue;
|
|
103
|
+
|
|
104
|
+
const lines = readFileSync(file, 'utf-8').split('\n');
|
|
105
|
+
for (let i = 0; i < lines.length; i++) {
|
|
106
|
+
const line = lines[i];
|
|
107
|
+
const trimmed = line.trim();
|
|
108
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
|
|
109
|
+
if (excludePatterns.some((ep) => ep.test(trimmed))) continue;
|
|
110
|
+
if (regex.test(line)) {
|
|
111
|
+
violations.push(` ${relPath}:${i + 1}: ${trimmed}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (violations.length === 0) {
|
|
117
|
+
console.log(`${GREEN}OK${NC} [0] ${label}`);
|
|
118
|
+
} else {
|
|
119
|
+
console.log(`${RED}FAIL${NC} [${violations.length}] ${label}`);
|
|
120
|
+
violations.slice(0, 10).forEach((v) => console.log(v));
|
|
121
|
+
if (violations.length > 10) {
|
|
122
|
+
console.log(` ... and ${violations.length - 10} more`);
|
|
123
|
+
}
|
|
124
|
+
totalErrors += violations.length;
|
|
125
|
+
}
|
|
126
|
+
return violations.length;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const formFiles = collectTsFiles('src/forms');
|
|
130
|
+
const allSrcFiles = collectTsFiles('src');
|
|
131
|
+
|
|
132
|
+
// ── Field Access ─────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
// 3a. Raw strings in getAttribute/getControl (must use Fields Enum)
|
|
135
|
+
checkPattern(
|
|
136
|
+
'Raw field strings in getAttribute/getControl',
|
|
137
|
+
formFiles,
|
|
138
|
+
/(?:getAttribute|getControl)\s*\(\s*['"][a-z]/,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// 3b. Raw strings in helper wrappers (getValue, setFieldValue, setDisabled, addOnChange, setVisible, setRequiredLevel)
|
|
142
|
+
checkPattern(
|
|
143
|
+
'Raw field strings in helper functions (use typedForm or Fields Enum)',
|
|
144
|
+
allSrcFiles,
|
|
145
|
+
/(?:getValue|setFieldValue|setDisabled|addOnChange|setVisible|setRequiredLevel|addPreSearch)\s*\(\s*\w+\s*,\s*['"][a-z]/,
|
|
146
|
+
['generated/', 'logger.ts'],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// 3c. Raw strings in select() (must use entity-level Fields Enum)
|
|
150
|
+
checkPattern(
|
|
151
|
+
'Raw field strings in select() (use entity-level Fields Enum)',
|
|
152
|
+
allSrcFiles,
|
|
153
|
+
/\bselect\s*\(\s*['"][a-z]/,
|
|
154
|
+
['generated/'],
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// ── Entity Names ─────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
// 3d. Raw entity name strings in WebApi calls (must use EntityNames Enum)
|
|
160
|
+
checkPattern(
|
|
161
|
+
'Raw entity names in WebApi (must use EntityNames Enum)',
|
|
162
|
+
allSrcFiles,
|
|
163
|
+
/(?:retrieveRecord|retrieveMultipleRecords|createRecord|updateRecord|deleteRecord)\s*\(\s*['"]/,
|
|
164
|
+
['generated/'],
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// 3e. SystemEntities workaround (must extend generation instead)
|
|
168
|
+
checkPattern(
|
|
169
|
+
'SystemEntities workaround (extend generation with --entities instead)',
|
|
170
|
+
allSrcFiles,
|
|
171
|
+
/SystemEntities\./,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// ── Magic Values ─────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
// 3f. Magic numbers in OptionSet comparisons (must use OptionSet Enum)
|
|
177
|
+
checkPattern(
|
|
178
|
+
'Magic numbers in value comparisons (use OptionSet Enum)',
|
|
179
|
+
allSrcFiles,
|
|
180
|
+
/getValue\(\)\s*===?\s*\d{3,}/,
|
|
181
|
+
['generated/'],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// 3g. Magic number 86400000 (must use named constant MS_PER_DAY)
|
|
185
|
+
checkPattern(
|
|
186
|
+
'Magic number 86400000 (use named constant MS_PER_DAY)',
|
|
187
|
+
allSrcFiles,
|
|
188
|
+
/86400000/,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// 3h. Raw notification level strings (must use FormNotificationLevel)
|
|
192
|
+
checkPattern(
|
|
193
|
+
'Raw notification level strings (use FormNotificationLevel from @xrmforge/helpers)',
|
|
194
|
+
allSrcFiles,
|
|
195
|
+
/setFormNotification\s*\([^)]*['"](?:ERROR|WARNING|INFO)['"]/,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// ── Deprecated / Unsafe ──────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
// 3i. Xrm.Page (deprecated since D365 v9.0)
|
|
201
|
+
checkPattern(
|
|
202
|
+
'Xrm.Page (deprecated since D365 v9.0)',
|
|
203
|
+
allSrcFiles,
|
|
204
|
+
/\bXrm\.Page\b/,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// 3j. eval() usage
|
|
208
|
+
checkPattern(
|
|
209
|
+
'eval() usage (use Number() or JSON.parse())',
|
|
210
|
+
allSrcFiles,
|
|
211
|
+
/\beval\s*\(/,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// 3k. console.log/warn/error outside logger.ts (must use Logger)
|
|
215
|
+
checkPattern(
|
|
216
|
+
'console.* outside logger.ts (must use Logger)',
|
|
217
|
+
allSrcFiles,
|
|
218
|
+
/\bconsole\.(log|warn|error|info|debug)\b/,
|
|
219
|
+
['logger.ts'],
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// ── Type Safety Bypass ───────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
// 3l2. Cast to Xrm.FormContext (bypasses typed form interface)
|
|
225
|
+
checkPattern(
|
|
226
|
+
'Cast to Xrm.FormContext (use typedForm $unsafe() for off-form fields)',
|
|
227
|
+
formFiles,
|
|
228
|
+
/as\s+(?:unknown\s+as\s+)?Xrm\.FormContext/,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// 3l3. Raw strings in $filter (field names must use Fields Enum interpolation)
|
|
232
|
+
checkPattern(
|
|
233
|
+
'Raw field names in $filter (use Fields Enum interpolation)',
|
|
234
|
+
allSrcFiles,
|
|
235
|
+
/\$filter=[^$]*\b(?:eq|ne|gt|lt|ge|le|contains|startswith)\b/,
|
|
236
|
+
['generated/', 'validate-form'],
|
|
237
|
+
[
|
|
238
|
+
// Allow if the line contains template literal interpolation (${...})
|
|
239
|
+
/\$\{/,
|
|
240
|
+
],
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// 3l4. Raw strings in $unsafe() (must use Entity-level Fields Enum)
|
|
244
|
+
checkPattern(
|
|
245
|
+
'Raw field strings in $unsafe() (use Entity-level Fields Enum)',
|
|
246
|
+
formFiles,
|
|
247
|
+
/\$unsafe\s*\(\s*['"][a-z]/,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// ── Handler Pattern ──────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
// 3l. Exported handlers without wrapHandler or wrapCommand
|
|
253
|
+
checkPattern(
|
|
254
|
+
'Exported handlers without wrapHandler/wrapCommand',
|
|
255
|
+
formFiles,
|
|
256
|
+
/^export\s+(const|async\s+function|function)\s+\w+(?!.*(?:wrapHandler|wrapCommand))/,
|
|
257
|
+
[],
|
|
258
|
+
[
|
|
259
|
+
// Re-exports: `export const form_OnLoad = onLoad;` (alias for a wrapped handler)
|
|
260
|
+
/^export\s+const\s+\w+\s*=\s*[a-zA-Z][a-zA-Z0-9]*\s*;/,
|
|
261
|
+
],
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// ── Raw $select ──────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
// 3m. Raw $select strings (must use select() from @xrmforge/helpers)
|
|
267
|
+
checkPattern(
|
|
268
|
+
'Raw $select strings (must use select() from @xrmforge/helpers)',
|
|
269
|
+
allSrcFiles,
|
|
270
|
+
/['"]\?\$select=/,
|
|
271
|
+
['generated/'],
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
// ── FetchXML ─────────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
// 3n. Raw field names in FetchXML attribute= (should use Fields Enum interpolation)
|
|
277
|
+
checkPattern(
|
|
278
|
+
'Raw field names in FetchXML (use Fields Enum interpolation)',
|
|
279
|
+
allSrcFiles,
|
|
280
|
+
/attribute\s*=\s*'[a-z][a-z0-9_]+'/,
|
|
281
|
+
['generated/'],
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
// 3o. Magic numbers in FetchXML <value> (should use OptionSet Enum)
|
|
285
|
+
checkPattern(
|
|
286
|
+
'Magic numbers in FetchXML values (use OptionSet Enum)',
|
|
287
|
+
allSrcFiles,
|
|
288
|
+
/<value>\d{3,}<\/value>/,
|
|
289
|
+
['generated/'],
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
// ── WebApi Response Typing ────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
// 3p. Untyped WebApi responses (must use generated Entity interfaces)
|
|
295
|
+
checkPattern(
|
|
296
|
+
'Untyped WebApi response cast (use generated Entity interface instead of Record<string, unknown>)',
|
|
297
|
+
allSrcFiles,
|
|
298
|
+
/as\s+Record\s*<\s*string\s*,\s*unknown\s*>/,
|
|
299
|
+
['generated/'],
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
// 3q. Manual OData annotation access (use parseLookup instead)
|
|
303
|
+
checkPattern(
|
|
304
|
+
'Manual OData annotation access (use parseLookup() from @xrmforge/helpers)',
|
|
305
|
+
allSrcFiles,
|
|
306
|
+
/@OData\.Community\.Display|@Microsoft\.Dynamics\.CRM\.lookuplogicalname|_value(?:@|\s*as\s)/,
|
|
307
|
+
['generated/', 'node_modules'],
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// ── Legacy Helper Wrappers ───────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
// 3r. Forbidden legacy helper functions (must use typedForm + @xrmforge/helpers)
|
|
313
|
+
checkPattern(
|
|
314
|
+
'Forbidden helper: getLookupId (use formLookupId from @xrmforge/helpers)',
|
|
315
|
+
allSrcFiles,
|
|
316
|
+
/\bgetLookupId\s*\(/,
|
|
317
|
+
['generated/'],
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
checkPattern(
|
|
321
|
+
'Forbidden helper: setLookupValue (use form.field.setValue([{...}]))',
|
|
322
|
+
allSrcFiles,
|
|
323
|
+
/\bsetLookupValue\s*\(/,
|
|
324
|
+
['generated/'],
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
// ── UI Localization ──────────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
// 3s. Hardcoded UI strings in dialogs/progress (must use pickLang from constants.ts)
|
|
330
|
+
checkPattern(
|
|
331
|
+
'Hardcoded UI string in dialog/progress (use pickLang(MESSAGES) from constants.ts)',
|
|
332
|
+
allSrcFiles,
|
|
333
|
+
/(?:openAlertDialog|openConfirmDialog|openErrorDialog|showProgressIndicator)\s*\(\s*(?:\{\s*text\s*:\s*)?['"]/,
|
|
334
|
+
['generated/', 'constants.ts'],
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
// ── Duplicate Framework Functions ────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
// 3t. Own normalizeGuid/compareGuid (use normalizeGuid from @xrmforge/helpers)
|
|
340
|
+
checkPattern(
|
|
341
|
+
'Own normalizeGuid/compareGuid definition (use normalizeGuid from @xrmforge/helpers)',
|
|
342
|
+
allSrcFiles,
|
|
343
|
+
/(?:export\s+)?function\s+(?:normalizeGuid|compareGuid)\s*\(/,
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
// ── Legacy Code Smells ───────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
// 3u. var declarations (use const/let)
|
|
349
|
+
checkPattern(
|
|
350
|
+
'var declarations (use const or let)',
|
|
351
|
+
allSrcFiles,
|
|
352
|
+
/^\s*var\s/,
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
// 3v. Synchronous XMLHttpRequest (use fetch or Xrm.WebApi)
|
|
356
|
+
checkPattern(
|
|
357
|
+
'XMLHttpRequest (use fetch or Xrm.WebApi)',
|
|
358
|
+
allSrcFiles,
|
|
359
|
+
/\bXMLHttpRequest\b/,
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
// ============================================================
|
|
363
|
+
// Test Completeness (warning only, does not fail the gate)
|
|
364
|
+
// ============================================================
|
|
365
|
+
|
|
366
|
+
console.log('\n--- Test Completeness ---');
|
|
367
|
+
|
|
368
|
+
let missingTests = 0;
|
|
369
|
+
for (const formFile of formFiles) {
|
|
370
|
+
const base = formFile.replace(/.*[\\/]/, '').replace(/\.ts$/, '');
|
|
371
|
+
try {
|
|
372
|
+
readFileSync(join('tests', 'forms', `${base}.test.ts`));
|
|
373
|
+
} catch {
|
|
374
|
+
console.log(`${YELLOW}WARN${NC} No test file for ${relative(process.cwd(), formFile)}`);
|
|
375
|
+
missingTests++;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (missingTests === 0) {
|
|
379
|
+
console.log(`${GREEN}OK${NC} [0] All form scripts have test files`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ============================================================
|
|
383
|
+
// Result
|
|
384
|
+
// ============================================================
|
|
385
|
+
|
|
386
|
+
console.log('\n=== Result ===');
|
|
387
|
+
if (totalErrors === 0) {
|
|
388
|
+
console.log(`${GREEN}All checks passed. 0 violations.${NC}`);
|
|
389
|
+
process.exit(0);
|
|
390
|
+
} else {
|
|
391
|
+
console.log(`${RED}${totalErrors} violations found.${NC}`);
|
|
392
|
+
process.exit(1);
|
|
393
|
+
}
|