@svgrid/mcp 2.6.4 → 2.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1018 @@
1
+ /**
2
+ * check_svgrid_code - the verification half of the MCP server.
3
+ *
4
+ * Retrieval tools (docs, demos, API listing) only ever give a model something
5
+ * to read. This one closes the loop: the model writes SvGrid code, this checks
6
+ * it against the REAL exported surface of the installed version and hands back
7
+ * diagnostics it can act on. A wrong prop name, a symbol imported from the
8
+ * wrong package, or Svelte 4 syntax in a Svelte 5 component all come back with
9
+ * the exact replacement rather than "hmm, that didn't work" three edits later.
10
+ *
11
+ * Two layers:
12
+ * 1. Static analysis (this file) - pure, no dependencies, no filesystem, so
13
+ * it runs identically in the Node stdio server and in a Worker.
14
+ * 2. The Svelte compiler - injected by the caller when it is available
15
+ * (`compile` option). Node has it; the Worker does not, and skips.
16
+ *
17
+ * The API surface is generated from the workspace sources at build time by
18
+ * scripts/api-surface.mjs, so it cannot drift from what the package exports.
19
+ */
20
+ // ---------------------------------------------------------------------------
21
+ // Text scanning helpers
22
+ // ---------------------------------------------------------------------------
23
+ /**
24
+ * Blank out comments and the inside of strings, keeping every offset and line
25
+ * break intact. All structural scanning runs on this copy so a prop name in a
26
+ * doc comment or a `<SvGrid>` inside a template string never trips a rule.
27
+ */
28
+ export function blankOut(src) {
29
+ const out = src.split('');
30
+ const n = src.length;
31
+ let i = 0;
32
+ const blank = (from, to) => {
33
+ for (let k = from; k < to && k < n; k++)
34
+ if (out[k] !== '\n')
35
+ out[k] = ' ';
36
+ };
37
+ while (i < n) {
38
+ const c = src[i];
39
+ const d = src[i + 1];
40
+ if (c === '/' && d === '/') {
41
+ let j = i;
42
+ while (j < n && src[j] !== '\n')
43
+ j++;
44
+ blank(i, j);
45
+ i = j;
46
+ continue;
47
+ }
48
+ if (c === '/' && d === '*') {
49
+ const close = src.indexOf('*/', i + 2);
50
+ const j = close === -1 ? n : close + 2;
51
+ blank(i, j);
52
+ i = j;
53
+ continue;
54
+ }
55
+ if (c === '<' && src.startsWith('<!--', i)) {
56
+ const close = src.indexOf('-->', i + 4);
57
+ const j = close === -1 ? n : close + 3;
58
+ blank(i, j);
59
+ i = j;
60
+ continue;
61
+ }
62
+ if (c === '"' || c === "'" || c === '`') {
63
+ let j = i + 1;
64
+ while (j < n) {
65
+ if (src[j] === '\\') {
66
+ j += 2;
67
+ continue;
68
+ }
69
+ if (src[j] === c)
70
+ break;
71
+ j++;
72
+ }
73
+ blank(i + 1, Math.min(j, n));
74
+ i = Math.min(j + 1, n);
75
+ continue;
76
+ }
77
+ i++;
78
+ }
79
+ return out.join('');
80
+ }
81
+ /** 1-based line number for a character offset. */
82
+ function lineAt(src, offset) {
83
+ let line = 1;
84
+ for (let i = 0; i < offset && i < src.length; i++)
85
+ if (src[i] === '\n')
86
+ line++;
87
+ return line;
88
+ }
89
+ function levenshtein(a, b) {
90
+ const m = a.length;
91
+ const n = b.length;
92
+ if (!m)
93
+ return n;
94
+ if (!n)
95
+ return m;
96
+ let prev = Array.from({ length: n + 1 }, (_, i) => i);
97
+ const curr = new Array(n + 1);
98
+ for (let i = 1; i <= m; i++) {
99
+ curr[0] = i;
100
+ for (let j = 1; j <= n; j++) {
101
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
102
+ }
103
+ prev = curr.slice();
104
+ }
105
+ return prev[n];
106
+ }
107
+ /**
108
+ * Closest known name to `word`, or null when nothing is near enough. A
109
+ * case-only difference always wins; otherwise the edit distance has to be
110
+ * small relative to the word so "foo" does not "resolve" to "bar".
111
+ */
112
+ export function nearest(word, candidates) {
113
+ const lower = word.toLowerCase();
114
+ const exactCase = candidates.find((c) => c.toLowerCase() === lower);
115
+ if (exactCase && exactCase !== word)
116
+ return exactCase;
117
+ // `getSortedRowModel` and `createSortedRowModel` are 5 edits apart but the
118
+ // same idea, so the comparison also runs with the verb prefix removed.
119
+ const core = (s) => s.replace(/^(?:get|set|create|make|build|use|enable|with)/i, '');
120
+ const lowerCore = core(lower);
121
+ const budget = word.length <= 4 ? 1 : word.length <= 8 ? 2 : 3;
122
+ let best = null;
123
+ let bestScore = Infinity;
124
+ for (const c of candidates) {
125
+ const cl = c.toLowerCase();
126
+ const d = Math.min(levenshtein(lower, cl), levenshtein(lowerCore, core(cl)));
127
+ if (d < bestScore && d <= budget) {
128
+ bestScore = d;
129
+ best = c;
130
+ }
131
+ }
132
+ return best;
133
+ }
134
+ /** Index of the bracket matching the one at `open`, or -1. */
135
+ function matchBracket(src, open) {
136
+ const pairs = { '{': '}', '[': ']', '(': ')' };
137
+ const close = pairs[src[open]];
138
+ if (!close)
139
+ return -1;
140
+ let depth = 0;
141
+ for (let i = open; i < src.length; i++) {
142
+ const c = src[i];
143
+ if (c === '{' || c === '[' || c === '(')
144
+ depth++;
145
+ else if (c === '}' || c === ']' || c === ')') {
146
+ depth--;
147
+ if (depth === 0)
148
+ return src[i] === close ? i : -1;
149
+ }
150
+ }
151
+ return -1;
152
+ }
153
+ // ---------------------------------------------------------------------------
154
+ // Rename tables
155
+ //
156
+ // Names developers (and models trained on other table libraries) reach for,
157
+ // mapped to the SvGrid equivalent. These are the highest-value diagnostics in
158
+ // the file: a plain "unknown prop" makes a model guess again, a rename makes it
159
+ // write the right line.
160
+ // ---------------------------------------------------------------------------
161
+ const PROP_RENAMES = {
162
+ rowData: 'data',
163
+ rows: 'data',
164
+ columnDefs: 'columns',
165
+ colDefs: 'columns',
166
+ columnDefinitions: 'columns',
167
+ defaultColDef: '',
168
+ rowModelType: '',
169
+ enableSorting: 'sortable',
170
+ enableFilter: 'filterable',
171
+ enableFiltering: 'filterable',
172
+ enableEditing: 'editable',
173
+ enableSelection: 'selectable',
174
+ enableGrouping: 'groupable',
175
+ enablePagination: 'pageable',
176
+ pagination: 'pageable',
177
+ paginationPageSize: 'pageSize',
178
+ rowSelection: 'selectionMode',
179
+ onGridReady: 'onApiReady',
180
+ onSelectionChanged: 'onRowSelectionChange',
181
+ onCellValueChanged: 'onCellValueChange',
182
+ onRowClicked: 'onRowClick',
183
+ onCellClicked: 'onCellClick',
184
+ getRowClass: 'rowClass',
185
+ getRowNodeId: 'getRowId',
186
+ height: 'containerHeight',
187
+ domLayout: 'autoRowHeight',
188
+ theme: '',
189
+ striped: 'zebraRows',
190
+ stripe: 'zebraRows',
191
+ loadingMessage: 'loadingOverlay',
192
+ noDataMessage: 'emptyMessage',
193
+ placeholder: 'emptyMessage',
194
+ };
195
+ const PROP_RENAME_NOTES = {
196
+ defaultColDef: 'SvGrid has no shared column default; set the key on each column, or map over your columns to add it.',
197
+ rowModelType: 'SvGrid always renders from `data`. For server-side paging/sorting, build the rows with `createServerDataSource` and feed its result into `data`.',
198
+ theme: 'Themes are stylesheets, not a prop: import one, e.g. `import "@svgrid/grid/themes/shadcn.css"`.',
199
+ };
200
+ const COLUMN_RENAMES = {
201
+ accessorKey: 'field',
202
+ accessor: 'field',
203
+ key: 'field',
204
+ dataIndex: 'field',
205
+ name: 'field',
206
+ accessorFn: 'fieldFn',
207
+ valueGetter: 'fieldFn',
208
+ headerName: 'header',
209
+ title: 'header',
210
+ label: 'header',
211
+ cellRenderer: 'cell',
212
+ render: 'cell',
213
+ renderCell: 'cell',
214
+ component: 'cell',
215
+ valueFormatter: 'formatter',
216
+ enableSorting: 'sortable',
217
+ sorting: 'sortable',
218
+ sortingFn: 'sortable',
219
+ enableColumnFilter: 'filterable',
220
+ filterFn: 'filterable',
221
+ filter: 'filterable',
222
+ hide: 'visible',
223
+ hidden: 'visible',
224
+ type: 'cellDataType',
225
+ size: 'width',
226
+ flex: 'width',
227
+ minWidth: 'width',
228
+ maxWidth: 'width',
229
+ pinned: '',
230
+ frozen: '',
231
+ rowGroup: '',
232
+ resizable: '',
233
+ meta: '',
234
+ };
235
+ const COLUMN_RENAME_NOTES = {
236
+ hide: 'Inverted in SvGrid: `hide: true` becomes `visible: false`.',
237
+ hidden: 'Inverted in SvGrid: `hidden: true` becomes `visible: false`.',
238
+ pinned: 'Column pinning is set on the grid, not the column: `initialColumnPinning={{ left: ["id"] }}`.',
239
+ frozen: 'Column pinning is set on the grid, not the column: `initialColumnPinning={{ left: ["id"] }}`.',
240
+ rowGroup: 'Grouping is set on the grid: `groupable` plus `groupBy={["field"]}`.',
241
+ resizable: 'Columns resize by default; there is no per-column switch.',
242
+ meta: 'SvGrid has no per-column `meta` bag. Put extra data on your row type, or close over it in a `cell` snippet.',
243
+ };
244
+ /**
245
+ * Wrong names common enough to be worth an exact replacement. Everything else
246
+ * is checked against the generated method list, so this stays short on purpose
247
+ * - a guessed entry here would reject a method that really exists.
248
+ */
249
+ const API_METHOD_HINTS = {
250
+ exportExcel: 'exportData({ format: "xlsx" })',
251
+ exportXlsx: 'exportData({ format: "xlsx" })',
252
+ exportPdf: 'exportData({ format: "pdf" })',
253
+ };
254
+ function push(ctx, d) {
255
+ ctx.out.push(d);
256
+ }
257
+ /** Imports: unknown symbols, unknown subpaths, wrong package, missing themes. */
258
+ function checkImports(ctx) {
259
+ const { scan, raw, surface } = ctx;
260
+ // Both `import { a } from 'x'` and the bare `import 'x'` a stylesheet uses.
261
+ const re = /import\s+(?:(type\s+)?([\s\S]*?)\s+from\s*)?(['"])([^'"]*)\3/g;
262
+ for (let m; (m = re.exec(scan));) {
263
+ const clause = m[2] ?? '';
264
+ // The specifier text was blanked out by `blankOut`; read it back from the
265
+ // raw source. The blanked copy is the same length, so the opening quote sits
266
+ // exactly one specifier-length + one quote before the end of the match.
267
+ const specStart = m.index + m[0].length - m[4].length - 1;
268
+ const spec = raw.slice(specStart, specStart + m[4].length);
269
+ const line = lineAt(raw, m.index);
270
+ if (!spec.startsWith('@svgrid/'))
271
+ continue;
272
+ ctx.importedFrom.add(spec);
273
+ const isGrid = spec === '@svgrid/grid' || spec.startsWith('@svgrid/grid/');
274
+ const isEnt = spec === '@svgrid/enterprise' || spec.startsWith('@svgrid/enterprise/');
275
+ // Theme stylesheets are files, not module exports.
276
+ if (spec.startsWith('@svgrid/grid/themes/')) {
277
+ const file = spec.slice('@svgrid/grid/themes/'.length);
278
+ if (surface.themes.length && !surface.themes.includes(file)) {
279
+ const guess = nearest(file, surface.themes);
280
+ push(ctx, {
281
+ rule: 'svgrid/unknown-theme',
282
+ severity: 'error',
283
+ line,
284
+ message: `There is no theme "${file}" in @svgrid/grid.`,
285
+ fix: guess
286
+ ? `Use "@svgrid/grid/themes/${guess}".`
287
+ : `Shipped themes: ${surface.themes.join(', ')}.`,
288
+ see: 'help/theming',
289
+ });
290
+ }
291
+ continue;
292
+ }
293
+ if (isGrid || isEnt) {
294
+ const known = isGrid ? surface.grid : surface.enterprise;
295
+ const other = isGrid ? surface.enterprise : surface.grid;
296
+ const otherName = isGrid ? '@svgrid/enterprise' : '@svgrid/grid';
297
+ const pkgName = isGrid ? '@svgrid/grid' : '@svgrid/enterprise';
298
+ if (!known.subpaths.includes(spec) && !spec.endsWith('.css')) {
299
+ push(ctx, {
300
+ rule: 'svgrid/unknown-subpath',
301
+ severity: 'error',
302
+ line,
303
+ message: `"${spec}" is not an export path of ${pkgName}.`,
304
+ fix: `Importable paths: ${known.subpaths.join(', ')}.`,
305
+ });
306
+ continue;
307
+ }
308
+ // Only the package root re-exports everything; the subpaths are narrow
309
+ // and would produce noise, so names are only checked against the root.
310
+ if (spec !== pkgName)
311
+ continue;
312
+ const defaultImport = /^\s*([A-Za-z_$][\w$]*)\s*(?:,|$)/.exec(clause.replace(/\{[\s\S]*$/, ''));
313
+ if (defaultImport && !clause.trimStart().startsWith('{') && !clause.includes('* as')) {
314
+ push(ctx, {
315
+ rule: 'svgrid/default-import',
316
+ severity: 'error',
317
+ line,
318
+ message: `${pkgName} has no default export.`,
319
+ fix: `Use a named import: import { ${defaultImport[1]} } from '${pkgName}'`,
320
+ });
321
+ }
322
+ const braces = /\{([\s\S]*)\}/.exec(clause);
323
+ if (!braces)
324
+ continue;
325
+ for (const rawName of braces[1].split(',')) {
326
+ const part = rawName.trim().replace(/^type\s+/, '');
327
+ if (!part)
328
+ continue;
329
+ const name = /^([A-Za-z_$][\w$]*)/.exec(part)?.[1];
330
+ if (!name)
331
+ continue;
332
+ ctx.importedNames.add(name);
333
+ if (known.values.includes(name) || known.types.includes(name))
334
+ continue;
335
+ if (other.values.includes(name) || other.types.includes(name)) {
336
+ push(ctx, {
337
+ rule: 'svgrid/wrong-package',
338
+ severity: 'error',
339
+ line,
340
+ message: `\`${name}\` is exported by ${otherName}, not ${pkgName}.`,
341
+ fix: `import { ${name} } from '${otherName}'`,
342
+ });
343
+ continue;
344
+ }
345
+ const guess = nearest(name, [...known.values, ...known.types]);
346
+ push(ctx, {
347
+ rule: 'svgrid/unknown-import',
348
+ severity: 'error',
349
+ line,
350
+ message: `${pkgName}@${isGrid ? surface.gridVersion : surface.enterpriseVersion} does not export \`${name}\`.`,
351
+ fix: guess ? `Did you mean \`${guess}\`?` : 'Call get_api_reference for the exported surface.',
352
+ });
353
+ }
354
+ }
355
+ }
356
+ }
357
+ /** Attributes on every `<Tag ...>` occurrence, expressions and shorthands included. */
358
+ function readTagAttrs(ctx, tag) {
359
+ const { scan, raw } = ctx;
360
+ const out = [];
361
+ const re = new RegExp(`<${tag}(?=[\\s/>])`, 'g');
362
+ for (let m; (m = re.exec(scan));) {
363
+ let i = m.index + tag.length + 1;
364
+ const attrs = [];
365
+ let spread = false;
366
+ let guard = 0;
367
+ while (i < scan.length && guard++ < 20000) {
368
+ const c = scan[i];
369
+ if (c === '>')
370
+ break;
371
+ if (c === '/' && scan[i + 1] === '>')
372
+ break;
373
+ if (/\s/.test(c)) {
374
+ i++;
375
+ continue;
376
+ }
377
+ if (c === '{') {
378
+ const end = matchBracket(scan, i);
379
+ if (end === -1)
380
+ break;
381
+ const inner = scan.slice(i + 1, end).trim();
382
+ if (inner.startsWith('...'))
383
+ spread = true;
384
+ else {
385
+ const short = /^([A-Za-z_$][\w$]*)$/.exec(inner);
386
+ if (short) {
387
+ attrs.push({ name: short[1], line: lineAt(raw, i), valueRaw: short[1], shorthand: true });
388
+ }
389
+ }
390
+ i = end + 1;
391
+ continue;
392
+ }
393
+ // A literal `...` inside a tag is prose shorthand for "and the rest";
394
+ // treat it like a spread so nothing is reported as missing.
395
+ if (c === '.' && scan.startsWith('...', i)) {
396
+ spread = true;
397
+ i += 3;
398
+ continue;
399
+ }
400
+ const nameMatch = /^([A-Za-z_$@#][\w$:.-]*)/.exec(scan.slice(i));
401
+ if (!nameMatch) {
402
+ i++;
403
+ continue;
404
+ }
405
+ const name = nameMatch[1];
406
+ const line = lineAt(raw, i);
407
+ let j = i + name.length;
408
+ while (j < scan.length && /\s/.test(scan[j]))
409
+ j++;
410
+ let valueRaw = '';
411
+ if (scan[j] === '=') {
412
+ j++;
413
+ while (j < scan.length && /\s/.test(scan[j]))
414
+ j++;
415
+ if (scan[j] === '{') {
416
+ const end = matchBracket(scan, j);
417
+ if (end === -1)
418
+ break;
419
+ valueRaw = raw.slice(j, end + 1);
420
+ j = end + 1;
421
+ }
422
+ else if (scan[j] === '"' || scan[j] === "'") {
423
+ const quote = scan[j];
424
+ let k = j + 1;
425
+ while (k < scan.length && scan[k] !== quote)
426
+ k++;
427
+ valueRaw = raw.slice(j, k + 1);
428
+ j = k + 1;
429
+ }
430
+ else {
431
+ const bare = /^[^\s/>]*/.exec(scan.slice(j))[0];
432
+ valueRaw = bare;
433
+ j += bare.length;
434
+ }
435
+ }
436
+ attrs.push({ name, line, valueRaw, shorthand: false });
437
+ i = j;
438
+ }
439
+ out.push({ attrs, spread, count: out.length });
440
+ }
441
+ return out;
442
+ }
443
+ /** `<SvGrid>` props: unknown names, renamed names, required ones, string booleans. */
444
+ function checkGridProps(ctx) {
445
+ const { surface } = ctx;
446
+ const known = surface.props.map((p) => p.name);
447
+ const booleanProps = new Set(surface.props.filter((p) => p.type.trim() === 'boolean').map((p) => p.name));
448
+ const usages = readTagAttrs(ctx, 'SvGrid');
449
+ for (const usage of usages) {
450
+ const seen = new Set();
451
+ for (const attr of usage.attrs) {
452
+ let name = attr.name;
453
+ if (name.startsWith('--'))
454
+ continue;
455
+ if (name.startsWith('bind:'))
456
+ name = name.slice(5);
457
+ if (name.startsWith('use:') || name.startsWith('transition:') || name.startsWith('animate:'))
458
+ continue;
459
+ if (name.startsWith('in:') || name.startsWith('out:'))
460
+ continue;
461
+ seen.add(name);
462
+ if (name.startsWith('on:')) {
463
+ const evt = name.slice(3);
464
+ const callback = `on${evt.charAt(0).toUpperCase()}${evt.slice(1)}`;
465
+ const real = known.find((k) => k.toLowerCase() === callback.toLowerCase());
466
+ push(ctx, {
467
+ rule: 'svelte/legacy-event-directive',
468
+ severity: 'error',
469
+ line: attr.line,
470
+ message: `\`on:${evt}\` never fires: SvGrid dispatches no component events, it takes callback props.`,
471
+ fix: real
472
+ ? `Use \`${real}={...}\`.`
473
+ : `Look for the matching \`on...\` prop - call get_api_reference or read reference/SvGrid.`,
474
+ see: 'reference/SvGrid',
475
+ });
476
+ continue;
477
+ }
478
+ if (known.includes(name)) {
479
+ // `sortable="true"` is a string, which is truthy even when it says "false".
480
+ if (booleanProps.has(name) && /^["']/.test(attr.valueRaw)) {
481
+ const literal = attr.valueRaw.slice(1, -1);
482
+ push(ctx, {
483
+ rule: 'svgrid/boolean-prop-string',
484
+ severity: 'error',
485
+ line: attr.line,
486
+ message: `\`${name}\` is a boolean, and "${literal}" is a string (always truthy).`,
487
+ fix: literal === 'false' ? `Write \`${name}={false}\`.` : `Write \`${name}\` on its own, or \`${name}={true}\`.`,
488
+ });
489
+ }
490
+ continue;
491
+ }
492
+ const renamed = PROP_RENAMES[name];
493
+ if (renamed !== undefined) {
494
+ push(ctx, {
495
+ rule: 'svgrid/renamed-prop',
496
+ severity: 'error',
497
+ line: attr.line,
498
+ message: `\`${name}\` is not a SvGrid prop.`,
499
+ fix: renamed ? `Use \`${renamed}\`.` : PROP_RENAME_NOTES[name],
500
+ see: 'reference/SvGrid',
501
+ });
502
+ continue;
503
+ }
504
+ if (name === 'class' || name === 'style') {
505
+ push(ctx, {
506
+ rule: 'svgrid/unstyled-prop',
507
+ severity: 'warning',
508
+ line: attr.line,
509
+ message: `\`${name}\` is ignored: SvGrid does not forward unknown attributes to its root element.`,
510
+ fix: 'Wrap the grid in an element and style that, or set `--sg-*` custom properties on the component.',
511
+ see: 'help/theming',
512
+ });
513
+ continue;
514
+ }
515
+ const guess = nearest(name, known);
516
+ push(ctx, {
517
+ rule: 'svgrid/unknown-prop',
518
+ severity: 'error',
519
+ line: attr.line,
520
+ message: `\`${name}\` is not a prop of <SvGrid> in @svgrid/grid@${surface.gridVersion}.`,
521
+ fix: guess ? `Did you mean \`${guess}\`?` : 'Call get_api_reference, or read the reference/SvGrid doc for the prop list.',
522
+ see: 'reference/SvGrid',
523
+ });
524
+ }
525
+ // Only a file that imports SvGrid itself is complete enough to be missing
526
+ // a required prop; anything else is an excerpt.
527
+ if (usage.spread || ctx.isFragment || !ctx.importedNames.has('SvGrid'))
528
+ continue;
529
+ for (const required of surface.props.filter((p) => !p.optional)) {
530
+ if (!seen.has(required.name)) {
531
+ push(ctx, {
532
+ rule: 'svgrid/missing-required-prop',
533
+ severity: 'error',
534
+ line: usage.attrs[0]?.line ?? 1,
535
+ message: `<SvGrid> requires \`${required.name}\`.`,
536
+ fix: `Add \`${required.name}={...}\` (${required.type}).`,
537
+ see: 'getting-started',
538
+ });
539
+ }
540
+ }
541
+ }
542
+ }
543
+ /** Depth-1 keys of every object literal directly inside an array. */
544
+ function objectKeysInArray(scan, arrOpen) {
545
+ const arrClose = matchBracket(scan, arrOpen);
546
+ if (arrClose === -1)
547
+ return [];
548
+ const objects = [];
549
+ let i = arrOpen + 1;
550
+ let depth = 0;
551
+ while (i < arrClose) {
552
+ const c = scan[i];
553
+ if (depth === 0 && c === '{') {
554
+ const objClose = matchBracket(scan, i);
555
+ if (objClose === -1)
556
+ break;
557
+ const keys = [];
558
+ let d = 0;
559
+ for (let j = i; j < objClose; j++) {
560
+ const ch = scan[j];
561
+ if (ch === '{' || ch === '[' || ch === '(')
562
+ d++;
563
+ else if (ch === '}' || ch === ']' || ch === ')')
564
+ d--;
565
+ else if (d === 1) {
566
+ const rest = scan.slice(j);
567
+ const km = /^([A-Za-z_$][\w$]*)\s*:/.exec(rest);
568
+ if (km && !/[\w$.]/.test(scan[j - 1] ?? '')) {
569
+ keys.push({ key: km[1], offset: j });
570
+ // A column group nests real column definitions under `columns`,
571
+ // so those get collected too.
572
+ if (km[1] === 'columns') {
573
+ let v = j + km[0].length;
574
+ while (v < objClose && /\s/.test(scan[v]))
575
+ v++;
576
+ if (scan[v] === '[')
577
+ objects.push(...objectKeysInArray(scan, v));
578
+ }
579
+ // Skip past the value so nested keys are not collected here.
580
+ let k = j + km[0].length;
581
+ let vd = 0;
582
+ for (; k < objClose; k++) {
583
+ const t = scan[k];
584
+ if (t === '{' || t === '[' || t === '(')
585
+ vd++;
586
+ else if (t === '}' || t === ']' || t === ')') {
587
+ if (vd === 0)
588
+ break;
589
+ vd--;
590
+ }
591
+ else if (vd === 0 && (t === ',' || t === '\n'))
592
+ break;
593
+ }
594
+ j = k - 1;
595
+ }
596
+ }
597
+ }
598
+ objects.push(keys);
599
+ i = objClose + 1;
600
+ continue;
601
+ }
602
+ if (c === '{' || c === '[' || c === '(')
603
+ depth++;
604
+ else if (c === '}' || c === ']' || c === ')')
605
+ depth--;
606
+ i++;
607
+ }
608
+ return objects;
609
+ }
610
+ /** Column definition keys, checked only inside things actually named `columns`. */
611
+ function checkColumns(ctx) {
612
+ const { scan, raw, surface } = ctx;
613
+ const known = surface.columnDef.map((c) => c.name);
614
+ const anchors = new Set(['field', 'fieldFn', 'header', 'id', 'cell', 'columns']);
615
+ // Two shapes, kept separate on purpose. An assignment may carry a type
616
+ // annotation containing commas (`ColumnDef<TFeatures, TData>[]`), so it is
617
+ // anchored on the `=`; a plain property must be followed immediately by the
618
+ // array, or `columns: 2, fields: [...]` in an unrelated config object would
619
+ // hand us the wrong array.
620
+ const arrayOpens = new Set();
621
+ // `const columns: ExprColumn[] = [...]` is somebody else's `columns`. When a
622
+ // declaration names its type, believe it.
623
+ const assigned = /(?:^|[\s({,])columns\s*(?::([^=\n]*))?=\s*\{?\s*\[/g;
624
+ for (let m; (m = assigned.exec(scan));) {
625
+ const annotation = m[1];
626
+ if (annotation && !/ColumnDef|SvColumn|any\b|unknown\b/.test(annotation))
627
+ continue;
628
+ arrayOpens.add(m.index + m[0].lastIndexOf('['));
629
+ }
630
+ // A bare `columns: [...]` PROPERTY is deliberately not a starting point:
631
+ // export options and other config objects have one too, holding a different
632
+ // shape. Column groups are reached by recursing from a real column instead.
633
+ for (const arrOpen of arrayOpens) {
634
+ for (const obj of objectKeysInArray(scan, arrOpen)) {
635
+ const names = obj.map((k) => k.key);
636
+ // Only judge objects that look like column definitions. A real one has a
637
+ // SvGrid key, or one of the keys other table libraries use for the same
638
+ // job - which is exactly the case worth reporting. Anything else inside
639
+ // an array that happens to be called `columns` is left alone.
640
+ const looksLikeColumn = names.some((n) => anchors.has(n) || known.includes(n) || n in COLUMN_RENAMES);
641
+ if (!looksLikeColumn)
642
+ continue;
643
+ for (const { key, offset } of obj) {
644
+ if (known.includes(key))
645
+ continue;
646
+ const line = lineAt(raw, offset);
647
+ const renamed = COLUMN_RENAMES[key];
648
+ if (renamed !== undefined) {
649
+ push(ctx, {
650
+ rule: 'svgrid/renamed-column-key',
651
+ severity: 'error',
652
+ line,
653
+ message: `\`${key}\` is not a SvGrid column key.`,
654
+ fix: renamed ? `Use \`${renamed}\`.` : COLUMN_RENAME_NOTES[key],
655
+ see: 'help/columns/column-definitions',
656
+ });
657
+ continue;
658
+ }
659
+ const guess = nearest(key, known);
660
+ push(ctx, {
661
+ rule: 'svgrid/unknown-column-key',
662
+ severity: 'error',
663
+ line,
664
+ message: `\`${key}\` is not a key of ColumnDef in @svgrid/grid@${surface.gridVersion}.`,
665
+ fix: guess ? `Did you mean \`${guess}\`?` : `Valid keys: ${known.join(', ')}.`,
666
+ see: 'help/columns/column-definitions',
667
+ });
668
+ }
669
+ }
670
+ }
671
+ }
672
+ /**
673
+ * Svelte 4 syntax in a Svelte 5 file. Severities here match what the compiler
674
+ * actually does: `export let` and `$:` are hard errors once the file uses any
675
+ * rune, while `on:`, `<slot>` and `<svelte:component>` still work and only
676
+ * warn. Nothing is reported as fatal that the compiler accepts.
677
+ */
678
+ function checkSvelteVersion(ctx) {
679
+ const { scan, raw, isSvelte } = ctx;
680
+ const usesRunes = /\$state\b|\$props\b|\$derived\b|\$effect\b|\$bindable\b/.test(scan);
681
+ const rules = [
682
+ {
683
+ re: /(^|\n)\s*export\s+let\s+([A-Za-z_$][\w$]*)/g,
684
+ rule: 'svelte/legacy-export-let',
685
+ severity: usesRunes ? 'error' : 'warning',
686
+ message: usesRunes
687
+ ? '`export let` is not allowed in runes mode, and this file uses runes.'
688
+ : '`export let` is the Svelte 4 way to declare a prop.',
689
+ fix: 'let { name } = $props()',
690
+ },
691
+ {
692
+ re: /(^|\n)\s*\$:\s/g,
693
+ rule: 'svelte/legacy-reactive-statement',
694
+ severity: usesRunes ? 'error' : 'warning',
695
+ message: usesRunes
696
+ ? '`$:` is not allowed in runes mode, and this file uses runes.'
697
+ : '`$:` is the Svelte 4 reactive statement.',
698
+ fix: 'Use `const x = $derived(...)` for values, `$effect(() => {...})` for side effects.',
699
+ },
700
+ {
701
+ re: /createEventDispatcher\s*\(/g,
702
+ rule: 'svelte/legacy-dispatcher',
703
+ severity: 'warning',
704
+ message: 'createEventDispatcher is the Svelte 4 event model and is deprecated in Svelte 5.',
705
+ fix: 'Take a callback prop instead: `let { onchange } = $props()`.',
706
+ },
707
+ {
708
+ re: /<slot\b/g,
709
+ rule: 'svelte/legacy-slot',
710
+ severity: 'warning',
711
+ message: '`<slot>` is deprecated in Svelte 5, which uses snippets.',
712
+ fix: 'Take a `children` prop and render it with `{@render children()}`.',
713
+ },
714
+ {
715
+ re: /<svelte:component\b/g,
716
+ rule: 'svelte/legacy-component-tag',
717
+ severity: 'warning',
718
+ message: '`<svelte:component>` is deprecated in Svelte 5 - components are dynamic by default.',
719
+ fix: 'Render the variable directly: `<Thing />` where `Thing` holds the component.',
720
+ },
721
+ {
722
+ re: /\son:[a-zA-Z]+[={\s]/g,
723
+ rule: 'svelte/legacy-event-directive',
724
+ severity: 'warning',
725
+ message: 'The `on:` event directive is deprecated in Svelte 5.',
726
+ fix: 'Use the plain attribute form: `onclick={...}`.',
727
+ },
728
+ ];
729
+ for (const r of rules) {
730
+ if (!isSvelte && (r.rule === 'svelte/legacy-slot' || r.rule === 'svelte/legacy-component-tag'))
731
+ continue;
732
+ for (let m; (m = r.re.exec(scan));) {
733
+ push(ctx, {
734
+ rule: r.rule,
735
+ severity: r.severity,
736
+ line: lineAt(raw, m.index),
737
+ message: r.message,
738
+ fix: r.fix,
739
+ });
740
+ }
741
+ }
742
+ // A plain `let` array that is later mutated is not reactive under runes: the
743
+ // grid keeps rendering the first snapshot and nothing errors. Only counts
744
+ // for a value the markup actually reads - mutating a local accumulator
745
+ // inside a function is ordinary code.
746
+ if (!isSvelte || !usesRunes)
747
+ return;
748
+ const markupStart = scan.lastIndexOf('</script>');
749
+ const markup = markupStart === -1 ? '' : scan.slice(markupStart);
750
+ const decls = /(^|\n)\s*let\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]*)?=\s*(\[|\{)/g;
751
+ for (let m; (m = decls.exec(scan));) {
752
+ const name = m[2];
753
+ const tail = scan.slice(m.index + m[0].length);
754
+ if (/\$state|\$derived|\$props/.test(scan.slice(m.index, m.index + m[0].length + 40)))
755
+ continue;
756
+ if (!new RegExp(`[^\\w$]${name}[^\\w$]`).test(markup))
757
+ continue;
758
+ const mutated = new RegExp(`\\b${name}\\s*(?:\\.(?:push|pop|splice|shift|unshift|sort|reverse)\\s*\\(|\\[[^\\]]*\\]\\s*=[^=])`);
759
+ if (mutated.test(tail)) {
760
+ push(ctx, {
761
+ rule: 'svelte/non-reactive-mutation',
762
+ severity: 'error',
763
+ line: lineAt(raw, m.index),
764
+ message: `\`${name}\` is a plain \`let\` but is mutated later, so the UI will not update.`,
765
+ fix: `Declare it as \`let ${name} = $state(...)\`, or replace the value instead of mutating it.`,
766
+ see: 'help/reactivity',
767
+ });
768
+ }
769
+ }
770
+ }
771
+ /**
772
+ * Traps specific to this codebase's Svelte version, each one a bug that has
773
+ * shipped here before and compiled without complaint.
774
+ */
775
+ function checkKnownTraps(ctx) {
776
+ const { scan, raw, filename, isSvelte } = ctx;
777
+ // A `$derived` read before its own declaration in a .svelte.ts module
778
+ // compiles to a getter that returns undefined - silently.
779
+ if (filename.endsWith('.svelte.ts')) {
780
+ const derived = /(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*\$derived/g;
781
+ for (let m; (m = derived.exec(scan));) {
782
+ const name = m[1];
783
+ const before = scan.slice(0, m.index);
784
+ const used = new RegExp(`[^\\w$.]${name}[^\\w$:]`).test(before);
785
+ if (used) {
786
+ push(ctx, {
787
+ rule: 'svelte/derived-before-use',
788
+ severity: 'error',
789
+ line: lineAt(raw, m.index),
790
+ message: `\`${name}\` is a $derived that is referenced above its own declaration; that compiles to an empty getter with no error.`,
791
+ fix: `Move the \`${name}\` declaration above its first use.`,
792
+ });
793
+ }
794
+ }
795
+ }
796
+ // Excerpts routinely elide their import block, and a doc snippet that shows
797
+ // only the interesting lines is not broken. So "you forgot to import X" is
798
+ // only claimed for a file that demonstrably lists its svgrid imports.
799
+ if (!isSvelte || ctx.isFragment || ctx.importedNames.size === 0)
800
+ return;
801
+ // A component reference that is never imported: the template renders nothing
802
+ // and, in a runes file, the compiler does not complain either.
803
+ const used = new Set();
804
+ for (const m of scan.matchAll(/<(Sv[A-Z][\w$]*)\b/g))
805
+ used.add(m[1]);
806
+ if (used.size) {
807
+ const imported = new Set();
808
+ for (const m of scan.matchAll(/import\s+(?:type\s+)?([\s\S]*?)\s+from\s*['"]/g)) {
809
+ for (const part of m[1].replace(/[{}]/g, ' ').split(',')) {
810
+ const name = /([A-Za-z_$][\w$]*)\s*$/.exec(part.trim())?.[1];
811
+ if (name)
812
+ imported.add(name);
813
+ }
814
+ }
815
+ for (const name of used) {
816
+ if (imported.has(name))
817
+ continue;
818
+ // A locally declared component (a snippet-bound const, a lazy import
819
+ // assigned to a variable) is not an import but is perfectly valid.
820
+ if (new RegExp(`(?:const|let|var|function)\\s+${name}\\b`).test(scan))
821
+ continue;
822
+ const at = scan.indexOf(`<${name}`);
823
+ push(ctx, {
824
+ rule: 'svgrid/component-not-imported',
825
+ // A warning, not an error: an excerpt that shows only the interesting
826
+ // imports is legitimate, and this must not fail an otherwise good file.
827
+ severity: 'warning',
828
+ line: lineAt(raw, at < 0 ? 0 : at),
829
+ message: `<${name}> is used but never imported.`,
830
+ fix: `import { ${name} } from '@svgrid/grid'`,
831
+ });
832
+ }
833
+ }
834
+ }
835
+ /** Features that exist only in the paid package, used without importing it. */
836
+ function checkEnterpriseUsage(ctx) {
837
+ const { scan, raw, importedFrom, importedNames } = ctx;
838
+ const hasEnterprise = [...importedFrom].some((s) => s.startsWith('@svgrid/enterprise'));
839
+ // Only calls on a receiver whose name ends in "api" - how the docs, the
840
+ // demos and `onApiReady` all name it. Widening this to `grid` immediately
841
+ // starts flagging `grid.cloneNode()` on a DOM ref, and one bad finding makes
842
+ // every other one suspect.
843
+ const receiver = '(?:^|[^\\w$.])(?:[\\w$]*[Aa]pi)';
844
+ // `api` is a popular variable name, and the UI kit's other components hand
845
+ // out their own (a dock manager's `api.float()` is not a grid method). Only
846
+ // check it where the file shows where the handle came from.
847
+ const holdsGridApi = /onApiReady/.test(scan) ||
848
+ /\b(?:SvGridApi|EnterpriseGridApi)\b/.test(scan) ||
849
+ /createSvGrid\s*\(|createGridState\s*\(/.test(scan) ||
850
+ /<SvGrid[\s/>]/.test(scan);
851
+ // Same excerpt rule as the import checks: a file that never shows an svgrid
852
+ // import is an excerpt, and its `api` may not even be ours.
853
+ if (ctx.surface.apiMethods.length && importedFrom.size > 0 && holdsGridApi) {
854
+ const free = new Set(ctx.surface.apiMethods);
855
+ const paid = new Set(ctx.surface.enterpriseApiMethods);
856
+ const all = [...free, ...paid];
857
+ const calls = new RegExp(`${receiver}\\.([A-Za-z_$][\\w$]*)\\s*\\(`, 'g');
858
+ for (let m; (m = calls.exec(scan));) {
859
+ const method = m[1];
860
+ const line = lineAt(raw, m.index);
861
+ if (free.has(method))
862
+ continue;
863
+ if (paid.has(method)) {
864
+ if (hasEnterprise)
865
+ continue;
866
+ push(ctx, {
867
+ rule: 'svgrid/enterprise-not-installed',
868
+ severity: 'error',
869
+ line,
870
+ message: `\`${method}()\` is added by @svgrid/enterprise, and this file never imports it.`,
871
+ fix: "import { installEnterprise } from '@svgrid/enterprise' and call installEnterprise(api) once the grid is ready.",
872
+ see: 'help/export',
873
+ });
874
+ continue;
875
+ }
876
+ const hint = API_METHOD_HINTS[method];
877
+ const guess = hint ?? nearest(method, all);
878
+ push(ctx, {
879
+ rule: 'svgrid/unknown-api-method',
880
+ severity: 'error',
881
+ line,
882
+ message: `The grid API has no \`${method}()\` in @svgrid/grid@${ctx.surface.gridVersion}.`,
883
+ fix: guess ? `Use \`${guess}\`.` : 'Call get_api_reference for the api surface.',
884
+ see: 'reference/SvGrid',
885
+ });
886
+ }
887
+ }
888
+ // The pivot ENGINE lives in the paid package; the prop alone renders an
889
+ // upsell note instead of a pivot.
890
+ if (/<SvGrid[^>]*\spivot=/.test(scan) && !importedNames.has('enablePivot') && !importedNames.has('installEnterprise')) {
891
+ const at = scan.search(/<SvGrid[^>]*\spivot=/);
892
+ push(ctx, {
893
+ rule: 'svgrid/pivot-needs-engine',
894
+ severity: 'warning',
895
+ line: lineAt(raw, at < 0 ? 0 : at),
896
+ message: 'The `pivot` prop needs the pivot engine registered, which ships in @svgrid/enterprise.',
897
+ fix: "import { enablePivot } from '@svgrid/enterprise' and call enablePivot() before the grid renders.",
898
+ see: 'help/pivot',
899
+ });
900
+ }
901
+ }
902
+ /** Feature constants referenced in `tableFeatures({...})` but never imported. */
903
+ function checkFeatures(ctx) {
904
+ const { scan, raw, surface, importedNames } = ctx;
905
+ const re = /tableFeatures\s*\(\s*\{/g;
906
+ for (let m; (m = re.exec(scan));) {
907
+ const open = scan.indexOf('{', m.index);
908
+ const close = matchBracket(scan, open);
909
+ if (close === -1)
910
+ continue;
911
+ const body = scan.slice(open + 1, close);
912
+ for (const km of body.matchAll(/([A-Za-z_$][\w$]*)\s*[,:}]/g)) {
913
+ const name = km[1];
914
+ if (surface.features.includes(name)) {
915
+ if (!importedNames.has(name) && importedNames.size > 0) {
916
+ push(ctx, {
917
+ rule: 'svgrid/feature-not-imported',
918
+ severity: 'error',
919
+ line: lineAt(raw, open + 1 + km.index),
920
+ message: `\`${name}\` is used in tableFeatures() but never imported.`,
921
+ fix: `import { ${name} } from '@svgrid/grid'`,
922
+ });
923
+ }
924
+ continue;
925
+ }
926
+ if (name.endsWith('Feature')) {
927
+ const guess = nearest(name, surface.features);
928
+ push(ctx, {
929
+ rule: 'svgrid/unknown-feature',
930
+ severity: 'error',
931
+ line: lineAt(raw, open + 1 + km.index),
932
+ message: `\`${name}\` is not a SvGrid feature.`,
933
+ fix: guess ? `Did you mean \`${guess}\`?` : `Available: ${surface.features.join(', ')}.`,
934
+ see: 'help/features',
935
+ });
936
+ }
937
+ }
938
+ }
939
+ }
940
+ // ---------------------------------------------------------------------------
941
+ // Entry point
942
+ // ---------------------------------------------------------------------------
943
+ /** Run every static rule. Exported for tests and for hosts that skip compiling. */
944
+ export function checkStatic(source, surface, filename = 'Component.svelte') {
945
+ const ctx = {
946
+ raw: source,
947
+ scan: blankOut(source),
948
+ surface,
949
+ filename,
950
+ isSvelte: filename.endsWith('.svelte') || /<script[\s>]/.test(source),
951
+ isFragment: !/<script[\s>]/.test(source) && !/^\s*import\s/m.test(source),
952
+ importedFrom: new Set(),
953
+ importedNames: new Set(),
954
+ out: [],
955
+ };
956
+ checkImports(ctx);
957
+ checkGridProps(ctx);
958
+ checkColumns(ctx);
959
+ checkSvelteVersion(ctx);
960
+ checkKnownTraps(ctx);
961
+ checkEnterpriseUsage(ctx);
962
+ checkFeatures(ctx);
963
+ const rank = { error: 0, warning: 1, info: 2 };
964
+ const unique = new Map();
965
+ for (const d of ctx.out)
966
+ unique.set(`${d.rule}|${d.line}|${d.message}`, d);
967
+ // Where a specific rule and the generic one it supersedes both fired (the
968
+ // `on:` directive is both "deprecated syntax" and "this component has no
969
+ // events"), keep only the error.
970
+ const hasError = new Set([...unique.values()].filter((d) => d.severity === 'error').map((d) => `${d.rule}|${d.line}`));
971
+ return [...unique.values()]
972
+ .filter((d) => d.severity === 'error' || !hasError.has(`${d.rule}|${d.line}`))
973
+ .sort((a, b) => rank[a.severity] - rank[b.severity] || a.line - b.line);
974
+ }
975
+ /**
976
+ * Check a snippet and report what a model should do next. `compile` is the
977
+ * optional second gate: when the host can reach a Svelte compiler, real parse
978
+ * errors are merged in with the static findings.
979
+ */
980
+ export async function checkSvGridCode(source, surface, opts = {}) {
981
+ const filename = opts.filename ?? 'Component.svelte';
982
+ const diagnostics = checkStatic(source, surface, filename);
983
+ let compiler = 'not-svelte';
984
+ if (filename.endsWith('.svelte')) {
985
+ compiler = 'unavailable';
986
+ if (opts.compile) {
987
+ const res = await opts.compile(source, filename);
988
+ if (res.available) {
989
+ compiler = 'svelte';
990
+ diagnostics.unshift(...res.diagnostics);
991
+ }
992
+ }
993
+ }
994
+ const counts = {
995
+ errors: diagnostics.filter((d) => d.severity === 'error').length,
996
+ warnings: diagnostics.filter((d) => d.severity === 'warning').length,
997
+ info: diagnostics.filter((d) => d.severity === 'info').length,
998
+ };
999
+ const ok = counts.errors === 0;
1000
+ const parts = [];
1001
+ if (ok && counts.warnings === 0)
1002
+ parts.push(`Clean against @svgrid/grid@${surface.gridVersion}.`);
1003
+ else if (ok)
1004
+ parts.push(`No errors against @svgrid/grid@${surface.gridVersion}, ${counts.warnings} warning(s).`);
1005
+ else
1006
+ parts.push(`${counts.errors} error(s) against @svgrid/grid@${surface.gridVersion}. Fix them and check again.`);
1007
+ if (compiler === 'unavailable' && filename.endsWith('.svelte')) {
1008
+ parts.push('The Svelte compiler was not reachable here, so this is API validation only - run svelte-check in the project too.');
1009
+ }
1010
+ return {
1011
+ ok,
1012
+ checkedAgainst: `@svgrid/grid@${surface.gridVersion}`,
1013
+ compiler,
1014
+ counts,
1015
+ diagnostics,
1016
+ summary: parts.join(' '),
1017
+ };
1018
+ }