-
Notifications
You must be signed in to change notification settings - Fork 153
Expand file tree
/
Copy pathspec-lint.js
More file actions
424 lines (404 loc) · 30.4 KB
/
Copy pathspec-lint.js
File metadata and controls
424 lines (404 loc) · 30.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
// plugins/model-apps/scripts/lib/spec-lint.js
// Pure App Spec guardrail. Returns { ok, errors, warnings }. errors block the plan
// gate; warnings teach. Bakes in the modeling lessons hit live — notably the
// relationship schema-name vs lookup-name collision Dataverse rejects.
const { relationshipSchemaName, relationshipFor, invalidChoiceSampleTokens, isPlatformIconRef } = require('./app-spec.js');
const { normalizeSpecShape } = require('./spec-shape.js');
const { resolveSurfaces, unresolvedSurfaceMessage } = require('./surface-resolver.js');
const CHOICE_OPTION_WARN = 12;
const SEQNUM_RE = /\{SEQNUM(:\d+)?\}/i;
// FetchXML operators that take no <value> (so a filter may omit value/values).
const NO_VALUE_OPS = new Set(['null', 'not-null', 'eq-userid', 'ne-userid', 'eq-useroruserteams', 'eq-userteams',
'today', 'yesterday', 'tomorrow', 'this-week', 'last-week', 'next-week', 'this-month', 'last-month', 'next-month',
'this-year', 'last-year', 'next-year', 'this-fiscal-year', 'last-seven-days', 'next-seven-days']);
function lintAppSpec(spec) {
const errors = [];
const warnings = [];
const E = (m) => errors.push(m);
const W = (m) => warnings.push(m);
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
return { ok: false, errors: ['spec is not an object'], warnings };
}
const lc = (s) => String(s || '').toLowerCase();
// Lint runs on WORK-IN-PROGRESS specs (it is the gate the author hits before plan mode), so a
// half-typed collection must produce findings, not a crash that kills the authoring flow.
// Shared with validateAppSpec so the two gates can never disagree about what a malformed spec is.
const arrOf = (v) => (Array.isArray(v) ? v : []);
const shape = normalizeSpecShape(spec);
for (const m of shape.errors) E(m);
spec = shape.spec;
const prefix = spec.solution && spec.solution.publisherPrefix;
const entityNames = new Set();
const globalChoiceNames = new Set((spec.globalChoices || []).map((g) => lc(g.name)));
const webResourceNames = new Set((spec.webResources || []).map((w) => lc(w.name)));
const WEB_RESOURCE_KINDS = new Set(['js', 'html', 'css', 'xml', 'png', 'jpg', 'gif', 'xsl', 'ico', 'svg', 'resx']);
const FORM_EVENTS = new Set(['onload', 'onsave', 'onchange']);
// columns per entity (logical) — used to validate onchange attributes.
const columnsByEntity = {};
for (const e of spec.entities || []) {
const set = new Set((e.columns || []).map((c) => lc(c.schemaName)));
if (e.primaryAttribute && e.primaryAttribute.schemaName) set.add(lc(e.primaryAttribute.schemaName));
columnsByEntity[lc(e.schemaName)] = set;
}
for (const wr of spec.webResources || []) {
if (!wr.name) E('A webResource is missing a name');
if (!WEB_RESOURCE_KINDS.has(lc(wr.type || 'js'))) E(`webResource '${wr.name}' has unknown type '${wr.type}'`);
if (wr.content === undefined && wr.contentBase64 === undefined && !wr.contentPath) E(`webResource '${wr.name}' needs content, contentBase64, or contentPath`);
if (lc(wr.type || 'js') === 'js' && wr.name && !lc(wr.name).endsWith('.js')) W(`web resource '${wr.name}' is a script but its name doesn't end in .js — Dataverse convention expects the extension`);
if (prefix && wr.name && !lc(wr.name).startsWith(lc(prefix) + '_')) W(`web resource '${wr.name}' does not use the solution prefix '${prefix}_'`);
}
for (const g of spec.globalChoices || []) {
if (!g.name) E('A globalChoice is missing a name');
if (!(Array.isArray(g.options) && g.options.length)) E(`globalChoice '${g.name}' needs options[]`);
}
for (const e of spec.entities || []) {
const key = lc(e.schemaName);
if (entityNames.has(key)) E(`Duplicate entity schemaName: ${e.schemaName}`);
entityNames.add(key);
if (prefix && e.schemaName && !lc(e.schemaName).startsWith(lc(prefix) + '_')) {
W(`Entity ${e.schemaName} does not use the solution prefix '${prefix}_'`);
}
if (!e.primaryAttribute || !e.primaryAttribute.schemaName || !e.primaryAttribute.displayName) {
E(`Entity ${e.schemaName} is missing a primaryAttribute (schemaName + displayName)`);
}
if (e.primaryAttribute && e.primaryAttribute.autoNumberFormat && !SEQNUM_RE.test(e.primaryAttribute.autoNumberFormat)) {
W(`Entity ${e.schemaName} primary AutoNumber format '${e.primaryAttribute.autoNumberFormat}' has no {SEQNUM} token — every record would get the same value`);
}
const cols = new Set();
for (const c of e.columns || []) {
const ck = lc(c.schemaName);
if (cols.has(ck)) E(`Entity ${e.schemaName} has a duplicate column ${c.schemaName}`);
cols.add(ck);
if (c.type === 'Choice' || c.type === 'MultiChoice') {
if (c.globalChoice && !globalChoiceNames.has(lc(c.globalChoice))) E(`Column ${e.schemaName}.${c.schemaName} references unknown globalChoice '${c.globalChoice}'`);
else if (!c.globalChoice && !(c.options && c.options.length)) E(`${c.type} column ${e.schemaName}.${c.schemaName} needs options[] or a globalChoice`);
else if (c.options && c.options.length > CHOICE_OPTION_WARN) W(`Column ${e.schemaName}.${c.schemaName} has ${c.options.length} options — consider a lookup table`);
}
if ((c.source === 'Calculated' || c.source === 'Rollup') && !c.formula) W(`${c.source} column ${e.schemaName}.${c.schemaName} has no formula — it will be created empty`);
if (c.type === 'AutoNumber' && c.autoNumberFormat && !SEQNUM_RE.test(c.autoNumberFormat)) W(`AutoNumber column ${e.schemaName}.${c.schemaName} format '${c.autoNumberFormat}' has no {SEQNUM} token`);
}
const keyable = new Set([...cols, lc(e.primaryAttribute && e.primaryAttribute.schemaName)]);
for (const k of e.alternateKeys || []) {
if (!k.schemaName) E(`Entity ${e.schemaName} has an alternate key without a schemaName`);
for (const kc of k.columns || []) if (!keyable.has(lc(kc))) E(`Alternate key ${e.schemaName}.${k.schemaName} references unknown column '${kc}'`);
if (!(k.columns && k.columns.length)) E(`Alternate key ${e.schemaName}.${k.schemaName} needs columns[]`);
}
for (const sr of e.statusReasons || []) {
if (!sr.label) E(`Entity ${e.schemaName} has a statusReason without a label`);
if (sr.state && sr.state !== 'Active' && sr.state !== 'Inactive') E(`statusReason '${sr.label}' state must be 'Active' or 'Inactive'`);
}
}
for (const r of spec.relationships || []) {
if (r.type === 'ManyToMany') {
if (!entityNames.has(lc(r.entity1))) E(`N:N relationship references unknown entity '${r.entity1}'`);
if (!entityNames.has(lc(r.entity2))) E(`N:N relationship references unknown entity '${r.entity2}'`);
if (prefix && r.schemaName && !lc(r.schemaName).startsWith(lc(prefix) + '_')) {
E(`N:N relationship schema name '${r.schemaName}' must start with the publisher prefix '${prefix}_' (Dataverse rejects an unprefixed relationship name); omit schemaName to auto-generate a valid one`);
}
continue;
}
if (r.type !== 'OneToMany') continue;
// The `referenced` (parent) side may be a standard/system table (systemuser, account, …) that
// isn't declared in entities[] — a supported pattern (the build auto-prefixes the relationship
// name). So WARN, don't error, when it's absent (matches validateAppSpec + the build). The
// `referencing` (child) side gets the lookup column, so it MUST be a declared entity.
if (!entityNames.has(lc(r.referenced))) W(`Relationship references entity '${r.referenced}' that isn't declared in entities[] — OK if it's a standard/system table (e.g. systemuser, account); otherwise a likely typo`);
if (!entityNames.has(lc(r.referencing))) E(`Relationship references unknown entity '${r.referencing}'`);
if (!r.lookup || !r.lookup.schemaName) {
E(`OneToMany ${r.referenced}->${r.referencing} is missing lookup.schemaName`);
continue;
}
if (lc(relationshipSchemaName(r, prefix)) === lc(r.lookup.schemaName)) {
E(`Relationship schema name '${relationshipSchemaName(r, prefix)}' collides with its lookup attribute name '${r.lookup.schemaName}' — Dataverse rejects this; use a distinct relationship name`);
}
// Dataverse requires a relationship schema name to start with the publisher prefix. The default
// name is auto-prefixed (incl. relationships to standard tables like systemuser/account), but an
// EXPLICIT rel.schemaName is honored verbatim — so catch an explicit name that would 400 at build.
if (prefix && r.schemaName && !lc(r.schemaName).startsWith(lc(prefix) + '_')) {
E(`Relationship schema name '${r.schemaName}' must start with the publisher prefix '${prefix}_' (Dataverse rejects an unprefixed relationship name); omit schemaName to auto-generate a valid one`);
}
}
// QuickView forms referenced by a host form's quickViews[] (so we only warn about unplaced ones).
const placedQuickViewForms = new Set();
for (const f of spec.forms || []) for (const qv of f.quickViews || []) if (qv && qv.form) placedQuickViewForms.add(qv.form);
for (const f of spec.forms || []) {
const formType = f.formType || 'Main';
if (!['Main', 'QuickCreate', 'QuickView'].includes(formType)) E(`Form ${f.entity} has invalid formType '${f.formType}' (use Main/QuickCreate/QuickView)`);
if (formType !== 'Main' && (f.subgrids || []).length) E(`Form ${f.entity} is a ${formType} form but declares sub-grids — sub-grids are Main-form only`);
if (formType === 'QuickView' && !placedQuickViewForms.has(f.name)) W(`Form ${f.entity} is a QuickView form but isn't placed on any host form — add a quickViews[] entry (lookup + form) on the parent form to surface it`);
// An EXPLICIT layout must supply real structure. An empty `tabs: []` compiles to a form with no
// authored tab (only the adapter's seed tab survives), and a tab with no sections has nowhere to
// place fields/sub-grids/quick-views — the build would silently drop them (firstSectionRowsPointer
// returns ''). Catch both at the plan gate instead.
const isExplicit = Array.isArray(f.tabs) || f.layout === 'explicit';
if (isExplicit) {
if (!Array.isArray(f.tabs) || f.tabs.length === 0) E(`Form ${f.entity} uses an explicit layout but declares no tabs — add at least one tab with a section, or use layout:'auto'`);
else for (const t of f.tabs) {
if (!Array.isArray(t.sections) || t.sections.length === 0) E(`Form ${f.entity} explicit tab '${t.label || t.name || ''}' has no sections — add at least one section with fields`);
}
}
for (const sg of f.subgrids || []) {
const has1N = (spec.relationships || []).some(
(r) => r.type === 'OneToMany' && lc(r.referenced) === lc(f.entity) && lc(r.referencing) === lc(sg.childEntity)
);
const hasNN = (spec.relationships || []).some(
(r) => r.type === 'ManyToMany' && ((lc(r.entity1) === lc(f.entity) && lc(r.entity2) === lc(sg.childEntity)) || (lc(r.entity1) === lc(sg.childEntity) && lc(r.entity2) === lc(f.entity)))
);
if (!has1N && !hasNN) E(`Form ${f.entity} sub-grid for ${sg.childEntity} has no matching OneToMany or ManyToMany relationship`);
}
for (const ev of f.events || []) {
if (!FORM_EVENTS.has(lc(ev.event))) { E(`Form ${f.entity} has an event with unknown type '${ev.event}' (use onload/onsave/onchange)`); continue; }
if (!ev.library) E(`Form ${f.entity} ${ev.event} handler is missing a library (web-resource name)`);
else if (!webResourceNames.has(lc(ev.library))) E(`Form ${f.entity} ${ev.event} handler references undeclared web resource '${ev.library}' — add it to webResources[]`);
if (!ev.function) E(`Form ${f.entity} ${ev.event} handler is missing a function name`);
if (lc(ev.event) === 'onchange') {
if (!ev.attribute) E(`Form ${f.entity} onchange handler requires an attribute (column logical name)`);
else if ((columnsByEntity[lc(f.entity)] || new Set()).size && !columnsByEntity[lc(f.entity)].has(lc(ev.attribute))) W(`Form ${f.entity} onchange handler binds '${ev.attribute}', which isn't a column on ${f.entity}`);
}
}
// Quick-view placement: each entry embeds a QuickView form (by name) via a lookup column.
for (const qv of f.quickViews || []) {
if (!qv || !qv.lookup) { E(`Form ${f.entity} has a quickView missing lookup (the lookup column logical name)`); continue; }
if (!qv.targetEntity || !entityNames.has(lc(qv.targetEntity))) E(`Form ${f.entity} quickView references unknown targetEntity '${qv.targetEntity}'`);
// Resolve by (name, targetEntity) preferring the QuickView so a same-named Main on the target entity
// doesn't shadow it (order-dependent otherwise; Sol review).
const qvCandidates = qv.form ? (spec.forms || []).filter((x) => x.name === qv.form && lc(x.entity) === lc(qv.targetEntity)) : [];
const qf = qvCandidates.find((x) => (x.formType || 'Main') === 'QuickView') || qvCandidates[0];
if (!qf) E(`Form ${f.entity} quickView references form '${qv.form}' (a QuickView on '${qv.targetEntity}') not found in forms[]`);
else if ((qf.formType || 'Main') !== 'QuickView') E(`Form ${f.entity} quickView form '${qv.form}' must be a QuickView form`);
}
}
// Commands (modern command-bar buttons) — a functional leaf button needs a JS library + function;
// a flyout/split container (type FlyoutAnchor/SplitButton) instead holds child buttons.
const COMMAND_LOCATIONS = new Set(['maintab', 'hometab', 'contextualtab']);
const COMMAND_TYPES = new Set(['button', 'flyoutanchor', 'splitbutton']);
const lintCmdAction = (label, ent, library, fn) => {
if (!library) E(`Command '${label}' on ${ent} needs a library (web-resource name)`);
else if (!webResourceNames.has(lc(library))) E(`Command '${label}' references undeclared web resource '${library}' — add it to webResources[]`);
if (!fn) E(`Command '${label}' on ${ent} needs a function name`);
};
for (const c of spec.commands || []) {
if (!c.entity || !entityNames.has(lc(c.entity))) { E(`Command references unknown entity '${c.entity}'`); continue; }
if (!c.label) E(`A command on ${c.entity} is missing a label`);
if (c.location && !COMMAND_LOCATIONS.has(lc(c.location))) E(`Command '${c.label}' has invalid location '${c.location}' (MainTab/HomeTab/ContextualTab)`);
const type = c.type || 'Button';
if (!COMMAND_TYPES.has(lc(type))) E(`Command '${c.label}' has invalid type '${c.type}' (Button/FlyoutAnchor/SplitButton)`);
if (lc(type) === 'flyoutanchor' || lc(type) === 'splitbutton') {
if (!(Array.isArray(c.children) && c.children.length)) E(`Command '${c.label}' on ${c.entity} is a ${type} but has no children[] (menu buttons)`);
for (const ch of c.children || []) {
if (!ch || !ch.label) { E(`Command '${c.label}' on ${c.entity} has a child button without a label`); continue; }
lintCmdAction(`${c.label} ▸ ${ch.label}`, c.entity, ch.library, ch.function);
}
} else {
lintCmdAction(c.label, c.entity, c.library, c.function);
}
}
// Dashboards — chart/list tiles must reference a declared chart/view; webresource a web resource.
const DASH_TILE_TYPES = new Set(['chart', 'list', 'iframe', 'webresource']);
const viewNames = new Set((spec.views || []).map((v) => lc(v.name)));
const chartNames = new Set((spec.charts || []).map((c) => lc(c.name)));
for (const d of spec.dashboards || []) {
if (!d.name) { E('A dashboard is missing a name'); continue; }
if (!(d.tiles && d.tiles.length)) W(`Dashboard '${d.name}' has no tiles`);
for (const t of d.tiles || []) {
if (!DASH_TILE_TYPES.has(t.type)) { E(`Dashboard '${d.name}' has a tile with invalid type '${t.type}' (chart/list/iframe/webresource)`); continue; }
if (t.type === 'chart' && (!t.chart || !chartNames.has(lc(t.chart)))) E(`Dashboard '${d.name}' chart tile references unknown chart '${t.chart}'`);
if ((t.type === 'chart' || t.type === 'list') && (!t.view || !viewNames.has(lc(t.view)))) E(`Dashboard '${d.name}' ${t.type} tile references unknown view '${t.view}'`);
if (t.type === 'iframe' && !t.url) E(`Dashboard '${d.name}' iframe tile needs a url`);
if (t.type === 'webresource' && (!t.webResource || !webResourceNames.has(lc(t.webResource)))) E(`Dashboard '${d.name}' webresource tile references undeclared web resource '${t.webResource}'`);
}
}
// Sitemap subareas — each names exactly one target (entity/dashboard/url/page). A DashBoard subarea
// surfaces a built dashboard in the app nav (and auto-pins it as an app component); a page subarea
// surfaces a generative page (declared in pages[]) as a GenPage subarea.
const dashNames = new Set((spec.dashboards || []).map((d) => d && d.name).filter(Boolean));
// A page subarea may reference a page by its stable KEY (schemaVersion 2 — migrateAppSpec rewrites
// name-based appShell page refs to keys) OR by name (legacy). Accept EITHER so a valid v2 key ref where
// key !== name (e.g. key 'order-detail', name 'Order Detail') is not falsely flagged. validateAppSpec is
// the strict enforcer (it selects keys-for-v2 / names-for-legacy per schemaVersion, app-spec.js:586); the
// guardrail lint just must not error on a correct ref.
const pageRefs = new Set([
...arrOf(spec.pages).map((p) => p && p.key).filter(Boolean),
...arrOf(spec.pages).map((p) => p && p.name).filter(Boolean),
]);
// Genpage data sources that aren't declared entities are likely standard tables (fine) or a typo.
const entityLowerSet = new Set((spec.entities || []).map((e) => lc(e.schemaName)));
for (const p of spec.pages || []) {
for (const ds of p.dataSources || []) {
if (!entityLowerSet.has(lc(ds))) W(`Page '${p.name}' data source '${ds}' isn't a declared entity — ok if it's a standard table, otherwise a likely typo`);
}
}
// The sitemap `VectorIcon` attribute must be an SVG path (e.g. /_imgs/TableIconsFluentV9/x.svg) or
// a $webresource:<name>.svg reference — NOT a bare Fluent token, which breaks the modern
// app-designer property pane. For ENTITY subareas the nav icon comes from the TABLE icon
// (entities[].vectorIcon → IconVectorName), so a subarea vectorIcon is DROPPED at build time; warn
// the author to set the table icon instead.
// The sitemap `VectorIcon` attribute must be a platform icon reference — an SVG/image path
// (e.g. /WebResources/<pub>/icons/x.svg) or a `$webresource:<name>.svg` reference — NOT a bare
// Fluent token, which breaks the modern app-designer property pane. An entity-subarea vectorIcon that
// IS a valid platform ref now round-trips (the build emits it); only a BARE token on an entity subarea
// is dropped. (isPlatformIconRef also accepts an extension-less WebResources path like an OOB
// /_imgs/.../CDSEntity, which is a valid live reference.)
for (const a of (spec.appShell && spec.appShell.areas) || []) {
if (a.vectorIcon && !isPlatformIconRef(a.vectorIcon)) W(`Sitemap area "${a.label || ''}": vectorIcon '${a.vectorIcon}' is a bare token — the sitemap VectorIcon needs an SVG path (…/x.svg) or a $webresource:<name>.svg reference, not a Fluent token`);
for (const g of a.groups || []) {
for (const sa of g.subAreas || []) {
const targets = ['entity', 'dashboard', 'url', 'page'].filter((k) => sa[k]);
if (targets.length === 0) E(`Sitemap subarea "${sa.title || ''}" needs an entity, dashboard, url, or page`);
else if (targets.length > 1) E(`Sitemap subarea "${sa.title || ''}" sets multiple targets (${targets.join(', ')}) — pick one`);
if (sa.entity && !entityNames.has(lc(sa.entity))) E(`Sitemap subarea references unknown entity '${sa.entity}'`);
if (sa.dashboard && !dashNames.has(sa.dashboard)) E(`Sitemap subarea references unknown dashboard '${sa.dashboard}' — declare it in dashboards[]`);
if (sa.page && !pageRefs.has(sa.page)) E(`Sitemap subarea references unknown page '${sa.page}' — declare it in pages[]`);
if (sa.vectorIcon && !isPlatformIconRef(sa.vectorIcon)) {
if (sa.entity) W(`Sitemap subarea "${sa.title || ''}": vectorIcon '${sa.vectorIcon}' is a bare token — on an entity subarea a bare Fluent token breaks the app designer and is DROPPED. Use an SVG path (/WebResources/<pub>/icons/x.svg) or $webresource:<name>.svg, or set entities[].vectorIcon (the table icon).`);
else W(`Sitemap subarea "${sa.title || ''}": vectorIcon '${sa.vectorIcon}' is a bare token — the sitemap VectorIcon needs an SVG path (…/x.svg) or a $webresource:<name>.svg reference, not a Fluent token`);
}
}
}
}
for (const ch of spec.charts || []) {
const ent = (spec.entities || []).find((e) => lc(e.schemaName) === lc(ch.entity));
if (!ent) { E(`Chart '${ch.name}' references unknown entity '${ch.entity}'`); continue; }
const col = (ent.columns || []).find((c) => lc(c.schemaName) === lc(ch.groupBy));
if (!col) W(`Chart '${ch.name}' groups by '${ch.groupBy}', which isn't a column on ${ch.entity}`);
else if (col.type !== 'Choice') W(`Chart '${ch.name}' groups by a non-Choice column '${ch.groupBy}' — Choice columns chart best`);
}
// View filters: each condition needs a value unless the operator is a no-value kind; in/not-in
// need a values[]. Choice labels in values resolve to ints at build time.
for (const v of spec.views || []) {
// View-name collision: an authored view named exactly like the Dataverse stock default view
// ("Active/Inactive <DisplayCollectionName>") is matched by name+entity and RECONCILED onto that
// stock default rather than created anew — so only its COLUMNS merge in (unioned with the stock
// set, incl. the stock "Created On"); its authored filters and sort are NOT applied. Warn so the
// author picks a distinct name (or knowingly customizes the stock default).
const ve = (spec.entities || []).find((x) => lc(x.schemaName) === lc(v.entity));
if (ve && v.name) {
const plural = ve.pluralName || `${ve.displayName || ve.schemaName}s`;
if (lc(v.name) === `active ${lc(plural)}` || lc(v.name) === `inactive ${lc(plural)}`) {
W(`View '${v.name}' has the same name as ${ve.schemaName}'s stock default view — it will MERGE onto that default (columns are unioned and the stock "Created On" is kept, but its filters/sort are ignored). Use a distinct name to author a separate view.`);
}
}
for (const f of v.filters || []) {
if (!f.attr) { E(`View '${v.name}' has a filter without an attr`); continue; }
const op = f.op || 'eq';
if (op === 'in' || op === 'not-in') {
if (!(Array.isArray(f.values) && f.values.length)) E(`View '${v.name}' filter on '${f.attr}' uses ${op} but has no values[]`);
} else if (!NO_VALUE_OPS.has(op) && f.value === undefined) {
E(`View '${v.name}' filter on '${f.attr}' (${op}) needs a value`);
}
}
}
// Sample data: a custom statusReason must be declared on the entity; every $parent/$parents
// bind must have a OneToMany from the named parent to this entity (so the lookup exists);
// every Choice/MultiChoice value must be a declared option label or an option int (a raw
// label only auto-resolves for inline-option columns — global choices used to slip through
// and get rejected by Dataverse, so catch unresolvable labels here regardless of binding).
for (const [ent, recs] of Object.entries(spec.sampleData || {})) {
const e = (spec.entities || []).find((x) => lc(x.schemaName) === lc(ent));
if (!e) continue; // unknown-entity is already an error in validateAppSpec
// #5 (edit-loop safety): a table whose PRIMARY is auto-numbered has no author-supplied key value in
// its sample rows (the number is server-assigned), so unless it declares a SINGLE-COLUMN alternate
// key, sample seeding has no idempotency key — every re-run (or a retried insert) DUPLICATES the
// rows, and you can't refresh data with --sample-data on an edit. Warn so the author adds a natural
// key. Mirrors entity-provision.js chooseMatchOn (single-column alt key, else the primary name).
if (Array.isArray(recs) && recs.length && e.primaryAttribute && e.primaryAttribute.autoNumberFormat) {
const hasSingleColKey = (e.alternateKeys || []).some((k) => Array.isArray(k.columns) && k.columns.length === 1);
if (!hasSingleColKey) {
W(`sampleData['${ent}']: ${ent}'s primary column is auto-numbered, so its ${recs.length} sample row(s) have no idempotency key — a re-run (or a retried insert) will DUPLICATE them, and you can't refresh data with --sample-data on an edit. Add a single-column alternateKeys[] entry (a natural key) to make seeding idempotent.`);
}
}
const declaredReasons = new Set((e.statusReasons || []).map((s) => lc(s.label)));
for (const rec of Array.isArray(recs) ? recs : []) {
if (rec && rec.statusReason && !declaredReasons.has(lc(rec.statusReason))) E(`sampleData['${ent}'] sets statusReason '${rec.statusReason}', which isn't a declared status reason on ${ent}`);
const parents = [].concat(rec && rec.$parent ? [rec.$parent] : [], (rec && rec.$parents) || []);
for (const p of parents) {
if (p && p.entity && !relationshipFor(spec, p.entity, ent)) E(`sampleData['${ent}']: no OneToMany from parent '${p.entity}' to '${ent}' (needed to bind the lookup)`);
}
// Shared with validateAppSpec (#4) so the guardrail and the hard gate flag the same tokens.
for (const { field, token } of invalidChoiceSampleTokens(spec, e, rec)) {
E(`sampleData['${ent}'] sets ${field}='${token}', which isn't a valid option for that Choice column — use a declared option label or its integer value`);
}
}
}
// ai block — row-summary guardrails.
const D365_OWNED_SUMMARY_TABLES = new Set(['incident', 'lead', 'opportunity']);
const DESCRIPTIVE_TYPES = new Set(['Text', 'Memo', 'Choice', 'MultiChoice', 'DateTime', 'Money', 'Integer', 'Decimal', 'Double', 'Boolean']);
if (spec.ai && spec.ai.summaries && spec.ai.summaries.tables && typeof spec.ai.summaries.tables === 'object') {
for (const [k, v] of Object.entries(spec.ai.summaries.tables)) {
if (D365_OWNED_SUMMARY_TABLES.has(lc(k))) {
W(`Summary table '${k}' is a Dynamics 365 app table (Case/Lead/Opportunity) — it provides its own summaries; the row-summary feature isn't available for it`);
}
const ent = (spec.entities || []).find((e) => lc(e.schemaName) === lc(k));
if (ent) {
const descriptiveCols = (ent.columns || []).filter((c) => DESCRIPTIVE_TYPES.has(c.type));
if (descriptiveCols.length === 0) {
W(`Summary table '${k}' has no descriptive columns — a row summary may not be useful`);
}
if (v && Array.isArray(v.columns)) {
const entCols = new Set([
...(ent.columns || []).map((c) => lc(c.schemaName)),
...(ent.primaryAttribute && ent.primaryAttribute.schemaName ? [lc(ent.primaryAttribute.schemaName)] : []),
]);
for (const c of v.columns) {
if (typeof c === 'string' && !entCols.has(lc(c))) {
E(`ai.summaries.tables['${k}'].columns: unknown column '${c}'`);
}
}
}
}
}
}
dupWarn((spec.views || []).map((v) => v.name), 'view', W);
dupWarn((spec.charts || []).map((c) => c.name), 'chart', W);
dupWarn((spec.forms || []).map((f) => f.name).filter(Boolean), 'form', W);
// Design-completeness warnings. These are WARNINGS, never errors: a spec without personas or pages
// is still buildable, and the author may have good reason. They exist because all three were steps
// the authoring flow only asked for in prose, and prose steps get silently skipped — testers
// reported exactly that (no jobs enumerated, no pages proposed). Surfacing them at the lint gate
// makes the omission visible while it is still cheap to fix.
//
// Every traversal is shape-guarded. Lint runs on WORK-IN-PROGRESS specs (it is the gate the author
// hits before plan mode), so a half-typed `personas: {}` or a null entry must produce a finding —
// validateAppSpec already reports the shape error — and never a crash that kills the flow.
const personas = Array.isArray(spec.personas) ? spec.personas.filter((p) => p && typeof p === 'object') : [];
const jobs = personas.flatMap((p) => (Array.isArray(p.jobs) ? p.jobs : [])
.filter((j) => j && typeof j === 'object')
.map((j) => ({ persona: p.persona, job: j })));
if (!personas.length) {
W('no personas[] — no jobs-to-be-done are recorded and no security role is authored, so the app opens only for system administrators. Capture who uses this app and what each of them needs to get done.');
} else if (!jobs.length) {
W('personas[] declares no jobs — a persona with no jobs-to-be-done neither documents the app nor sizes its security role.');
}
for (const { persona, job } of jobs) {
if (!(Array.isArray(job.surfaces) && job.surfaces.length)) {
W(`persona "${persona}" job "${job.name}" is not mapped to a surface (jobs[].surfaces[]) — nothing in this app demonstrably lets that persona do the job.`);
}
}
// A surface that names nothing this spec builds is the NEXT failure after "no surfaces at all":
// the job claims coverage that does not exist. Only a warning, because a surface may legitimately
// name an out-of-the-box artifact this spec never authors (the same reason app-spec.js validates
// surfaces as shape-only) — see lib/surface-resolver.js.
for (const u of resolveSurfaces(spec).unresolved) W(unresolvedSurfaceMessage(u));
if (!(Array.isArray(spec.pages) && spec.pages.length)) {
W('no pages[] — per the genpage-first policy, non-record surfaces (overview/landing, dashboard, analytics, guided or wizard flows) should be generative pages. If this app is genuinely record-CRUD only, ignore this.');
}
// An icon the user cannot picture is an icon they cannot approve. At plan time the SVG may not be
// drawn yet, and a web-resource name ("new_project_icon") describes nothing — so a custom table
// carrying an icon without `iconDescription` is a review gap, not a build problem.
for (const e of arrOf(spec.entities)) {
if (!e || typeof e !== 'object' || e.existing) continue;
if ((e.vectorIcon || e.icon) && !(typeof e.iconDescription === 'string' && e.iconDescription.trim())) {
W(`entity ${e.schemaName || '?'}: has an icon but no iconDescription — describe what the glyph DEPICTS (e.g. "a briefcase") so the user can approve it before the SVG is drawn.`);
}
}
return { ok: errors.length === 0, errors, warnings };
}
function dupWarn(names, kind, W) {
const seen = new Set();
for (const n of names) {
const k = String(n || '').toLowerCase();
if (k && seen.has(k)) W(`Duplicate ${kind} name: ${n}`);
seen.add(k);
}
}
module.exports = { lintAppSpec };