snice 7.2.0 → 7.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/snice/SKILL.md +36 -13
- package/README.md +11 -6
- package/adapters/react/SniceProvider.js +1 -1
- package/adapters/react/SniceRouter.d.ts +2 -0
- package/adapters/react/SniceRouter.js +3 -2
- package/adapters/react/SniceRouter.js.map +1 -1
- package/adapters/react/matchRoute.d.ts +4 -1
- package/adapters/react/matchRoute.js +13 -3
- package/adapters/react/matchRoute.js.map +1 -1
- package/adapters/react/useRequestHandler.js +1 -1
- package/bin/analyzer-contracts.js +4 -1
- package/bin/project-analyzer.js +521 -20
- package/bin/snice.js +77 -13
- package/bin/templates/AI_GUIDANCE.md +6 -1
- package/bin/templates/default/README.md +1 -1
- package/bin/templates/default/src/components/app-header.ts +11 -16
- package/bin/templates/default/src/components/notification-badge.ts +5 -14
- package/bin/templates/default/src/components/search-bar.ts +21 -65
- package/bin/templates/default/src/pages/dashboard.ts +15 -11
- package/bin/templates/default/src/pages/data.ts +10 -13
- package/bin/templates/default/src/pages/notifications.ts +4 -9
- package/bin/templates/default/src/pages/profile.ts +2 -8
- package/bin/templates/default/src/pages/settings.ts +16 -16
- package/dist/app-context.d.ts +10 -10
- package/dist/cdn/accordion/snice-accordion.js +1 -1
- package/dist/cdn/action-bar/snice-action-bar.js +1 -1
- package/dist/cdn/activity-feed/snice-activity-feed.js +1 -1
- package/dist/cdn/alert/snice-alert.js +1 -1
- package/dist/cdn/app-tiles/snice-app-tiles.js +1 -1
- package/dist/cdn/approval-flow/snice-approval-flow.js +1 -1
- package/dist/cdn/audio-recorder/snice-audio-recorder.js +1 -1
- package/dist/cdn/availability/snice-availability.js +1 -1
- package/dist/cdn/avatar/snice-avatar.js +1 -1
- package/dist/cdn/avatar-group/snice-avatar-group.js +1 -1
- package/dist/cdn/badge/snice-badge.js +1 -1
- package/dist/cdn/banner/snice-banner.js +1 -1
- package/dist/cdn/binpack/snice-binpack.js +1 -1
- package/dist/cdn/book/snice-book.js +1 -1
- package/dist/cdn/booking/snice-booking.js +1 -1
- package/dist/cdn/breadcrumbs/snice-breadcrumbs.js +1 -1
- package/dist/cdn/button/snice-button.js +1 -1
- package/dist/cdn/calendar/snice-calendar.js +1 -1
- package/dist/cdn/camera/snice-camera.js +1 -1
- package/dist/cdn/camera-annotate/snice-camera-annotate.js +1 -1
- package/dist/cdn/candlestick/snice-candlestick.js +1 -1
- package/dist/cdn/card/snice-card.js +1 -1
- package/dist/cdn/carousel/snice-carousel.js +1 -1
- package/dist/cdn/cart/snice-cart.js +1 -1
- package/dist/cdn/chart/snice-chart.js +1 -1
- package/dist/cdn/chat/snice-chat.js +1 -1
- package/dist/cdn/checkbox/snice-checkbox.js +1 -1
- package/dist/cdn/chip/snice-chip.js +1 -1
- package/dist/cdn/code-block/snice-code-block.js +1 -1
- package/dist/cdn/color-display/snice-color-display.js +1 -1
- package/dist/cdn/color-picker/snice-color-picker.js +1 -1
- package/dist/cdn/command-palette/snice-command-palette.js +1 -1
- package/dist/cdn/comments/snice-comments.js +1 -1
- package/dist/cdn/countdown/snice-countdown.js +1 -1
- package/dist/cdn/cropper/snice-cropper.js +1 -1
- package/dist/cdn/data-card/snice-data-card.js +1 -1
- package/dist/cdn/date-picker/snice-date-picker.js +1 -1
- package/dist/cdn/date-range-picker/snice-date-range-picker.js +1 -1
- package/dist/cdn/date-time-picker/snice-date-time-picker.js +1 -1
- package/dist/cdn/diff/snice-diff.js +1 -1
- package/dist/cdn/divider/snice-divider.js +1 -1
- package/dist/cdn/doc/snice-doc.js +1 -1
- package/dist/cdn/draw/snice-draw.js +1 -1
- package/dist/cdn/drawer/snice-drawer.js +1 -1
- package/dist/cdn/empty-state/snice-empty-state.js +1 -1
- package/dist/cdn/estimate/snice-estimate.js +1 -1
- package/dist/cdn/file-gallery/snice-file-gallery.js +1 -1
- package/dist/cdn/file-upload/snice-file-upload.js +1 -1
- package/dist/cdn/flip-card/snice-flip-card.js +1 -1
- package/dist/cdn/flow/snice-flow.js +1 -1
- package/dist/cdn/form-layout/snice-form-layout.js +1 -1
- package/dist/cdn/funnel/snice-funnel.js +1 -1
- package/dist/cdn/gantt/snice-gantt.js +1 -1
- package/dist/cdn/gauge/snice-gauge.js +1 -1
- package/dist/cdn/grid/snice-grid.js +1 -1
- package/dist/cdn/heatmap/snice-heatmap.js +1 -1
- package/dist/cdn/image/snice-image.js +1 -1
- package/dist/cdn/input/snice-input.js +1 -1
- package/dist/cdn/invoice/snice-invoice.js +1 -1
- package/dist/cdn/kanban/snice-kanban.js +1 -1
- package/dist/cdn/key-value/snice-key-value.js +1 -1
- package/dist/cdn/kpi/snice-kpi.js +1 -1
- package/dist/cdn/layout/snice-layout.js +1 -1
- package/dist/cdn/leaderboard/snice-leaderboard.js +1 -1
- package/dist/cdn/link/snice-link.js +1 -1
- package/dist/cdn/link-preview/snice-link-preview.js +1 -1
- package/dist/cdn/list/snice-list.js +1 -1
- package/dist/cdn/location/snice-location.js +1 -1
- package/dist/cdn/login/snice-login.js +1 -1
- package/dist/cdn/map/snice-map.js +1 -1
- package/dist/cdn/markdown/snice-markdown.js +1 -1
- package/dist/cdn/masonry/snice-masonry.js +1 -1
- package/dist/cdn/menu/snice-menu.js +1 -1
- package/dist/cdn/message-strip/snice-message-strip.js +1 -1
- package/dist/cdn/modal/snice-modal.js +1 -1
- package/dist/cdn/music-player/snice-music-player.js +1 -1
- package/dist/cdn/nav/snice-nav.js +1 -1
- package/dist/cdn/network-graph/snice-network-graph.js +1 -1
- package/dist/cdn/notification-center/snice-notification-center.js +1 -1
- package/dist/cdn/order-tracker/snice-order-tracker.js +1 -1
- package/dist/cdn/org-chart/snice-org-chart.js +1 -1
- package/dist/cdn/pagination/snice-pagination.js +1 -1
- package/dist/cdn/paint/snice-paint.js +1 -1
- package/dist/cdn/pdf-viewer/snice-pdf-viewer.js +1 -1
- package/dist/cdn/permission-matrix/snice-permission-matrix.js +1 -1
- package/dist/cdn/podcast-player/snice-podcast-player.js +1 -1
- package/dist/cdn/popover/snice-popover.js +1 -1
- package/dist/cdn/pricing-table/snice-pricing-table.js +1 -1
- package/dist/cdn/product-card/snice-product-card.js +1 -1
- package/dist/cdn/progress/snice-progress.js +1 -1
- package/dist/cdn/progress-ring/snice-progress-ring.js +1 -1
- package/dist/cdn/qr-code/snice-qr-code.js +1 -1
- package/dist/cdn/qr-reader/snice-qr-reader.js +1 -1
- package/dist/cdn/radio/snice-radio.js +1 -1
- package/dist/cdn/range-slider/snice-range-slider.js +1 -1
- package/dist/cdn/rating/snice-rating.js +1 -1
- package/dist/cdn/receipt/snice-receipt.js +1 -1
- package/dist/cdn/recipe/snice-recipe.js +1 -1
- package/dist/cdn/runtime/README.md +2 -2
- package/dist/cdn/runtime/snice-runtime.esm.js +333 -192
- package/dist/cdn/runtime/snice-runtime.esm.js.map +1 -1
- package/dist/cdn/runtime/snice-runtime.esm.min.js +1 -1
- package/dist/cdn/runtime/snice-runtime.esm.min.js.map +1 -1
- package/dist/cdn/runtime/snice-runtime.js +333 -191
- package/dist/cdn/runtime/snice-runtime.js.map +1 -1
- package/dist/cdn/runtime/snice-runtime.min.js +1 -1
- package/dist/cdn/runtime/snice-runtime.min.js.map +1 -1
- package/dist/cdn/sankey/snice-sankey.js +1 -1
- package/dist/cdn/segmented-control/snice-segmented-control.js +1 -1
- package/dist/cdn/select/snice-select.js +1 -1
- package/dist/cdn/skeleton/snice-skeleton.js +1 -1
- package/dist/cdn/slider/snice-slider.js +1 -1
- package/dist/cdn/sortable/snice-sortable.js +1 -1
- package/dist/cdn/sparkline/snice-sparkline.js +1 -1
- package/dist/cdn/spinner/snice-spinner.js +1 -1
- package/dist/cdn/split-button/snice-split-button.js +1 -1
- package/dist/cdn/split-pane/snice-split-pane.js +1 -1
- package/dist/cdn/spotlight/snice-spotlight.js +1 -1
- package/dist/cdn/stat-group/snice-stat-group.js +1 -1
- package/dist/cdn/step-input/snice-step-input.js +1 -1
- package/dist/cdn/stepper/snice-stepper.js +1 -1
- package/dist/cdn/switch/snice-switch.js +1 -1
- package/dist/cdn/table/snice-table.esm.js +1 -1
- package/dist/cdn/table/snice-table.js +1 -1
- package/dist/cdn/tabs/snice-tabs.js +1 -1
- package/dist/cdn/tag/snice-tag.js +1 -1
- package/dist/cdn/tag-input/snice-tag-input.js +1 -1
- package/dist/cdn/terminal/snice-terminal.js +1 -1
- package/dist/cdn/testimonial/snice-testimonial.js +1 -1
- package/dist/cdn/textarea/snice-textarea.js +1 -1
- package/dist/cdn/time-picker/snice-time-picker.js +1 -1
- package/dist/cdn/time-range-picker/snice-time-range-picker.js +1 -1
- package/dist/cdn/timeline/snice-timeline.js +1 -1
- package/dist/cdn/timer/snice-timer.js +1 -1
- package/dist/cdn/toast/snice-toast.js +1 -1
- package/dist/cdn/tooltip/snice-tooltip.js +1 -1
- package/dist/cdn/tree/snice-tree.js +1 -1
- package/dist/cdn/treemap/snice-treemap.js +1 -1
- package/dist/cdn/user-card/snice-user-card.js +1 -1
- package/dist/cdn/video-player/snice-video-player.js +1 -1
- package/dist/cdn/virtual-scroller/snice-virtual-scroller.js +1 -1
- package/dist/cdn/waterfall/snice-waterfall.js +1 -1
- package/dist/cdn/weather/snice-weather.js +1 -1
- package/dist/cdn/work-order/snice-work-order.js +1 -1
- package/dist/components/pagination/snice-pagination.types.d.ts +5 -0
- package/dist/context-provider.d.ts +11 -1
- package/dist/context.d.ts +5 -6
- package/dist/index.cjs +330 -188
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.esm.js +330 -189
- package/dist/index.esm.js.map +1 -1
- package/dist/index.iife.js +330 -188
- package/dist/index.iife.js.map +1 -1
- package/dist/react/SniceProvider.js +1 -1
- package/dist/react/SniceRouter.d.ts +2 -0
- package/dist/react/SniceRouter.js +3 -2
- package/dist/react/SniceRouter.js.map +1 -1
- package/dist/react/index.js +1 -1
- package/dist/react/matchRoute.d.ts +4 -1
- package/dist/react/matchRoute.js +13 -3
- package/dist/react/matchRoute.js.map +1 -1
- package/dist/react/useRequestHandler.js +1 -1
- package/dist/symbols.cjs +1 -1
- package/dist/symbols.esm.js +1 -1
- package/dist/testing-dom.cjs +48 -0
- package/dist/testing-dom.cjs.map +1 -0
- package/dist/testing-dom.d.ts +23 -0
- package/dist/testing-dom.esm.js +45 -0
- package/dist/testing-dom.esm.js.map +1 -0
- package/dist/transitions.cjs +1 -1
- package/dist/transitions.esm.js +1 -1
- package/dist/types/context.d.ts +8 -8
- package/dist/types/page-options.d.ts +15 -1
- package/dist/types/router-instance.d.ts +2 -1
- package/dist/utils.d.ts +3 -1
- package/docs/ai/README.md +6 -4
- package/docs/ai/api.md +13 -2
- package/docs/ai/architecture.md +28 -11
- package/docs/ai/bindings.md +1 -1
- package/docs/ai/cli.md +26 -1
- package/docs/ai/components/badge.md +4 -0
- package/docs/ai/components/pagination.md +4 -2
- package/docs/ai/components/segmented-control.md +5 -1
- package/docs/ai/controllers.md +41 -12
- package/docs/ai/decorators.md +3 -3
- package/docs/ai/events.md +8 -2
- package/docs/ai/fetcher.md +32 -1
- package/docs/ai/lifecycle.md +4 -1
- package/docs/ai/patterns.md +9 -4
- package/docs/ai/react-integration.md +1 -0
- package/docs/ai/request-response.md +20 -6
- package/docs/ai/routing.md +33 -4
- package/docs/ai/testing.md +71 -0
- package/docs/bindings.md +1 -1
- package/docs/cli.md +27 -2
- package/docs/components/badge.md +4 -0
- package/docs/components/pagination.md +8 -5
- package/docs/components/segmented-control.md +5 -1
- package/docs/controllers.md +46 -15
- package/docs/events.md +11 -2
- package/docs/fetcher.md +32 -1
- package/docs/lifecycle.md +5 -1
- package/docs/react-integration.md +1 -0
- package/docs/request-response.md +25 -7
- package/docs/routing.md +39 -3
- package/docs/testing.md +71 -0
- package/package.json +6 -1
package/bin/project-analyzer.js
CHANGED
|
@@ -47,6 +47,11 @@ const LEGACY_LIT_LIFECYCLE = new Set([
|
|
|
47
47
|
'performUpdate'
|
|
48
48
|
]);
|
|
49
49
|
|
|
50
|
+
const NATIVE_ELEMENT_IDL_MEMBERS = new Set([
|
|
51
|
+
'autofocus', 'dir', 'hidden', 'id', 'inert', 'lang', 'role', 'slot',
|
|
52
|
+
'title', 'translate'
|
|
53
|
+
]);
|
|
54
|
+
|
|
50
55
|
const SNICE_REACT_EVENT_PROPS = new Set([
|
|
51
56
|
'onInputChange', 'onSelectChange', 'onTextareaChange', 'onCheckboxChange',
|
|
52
57
|
'onRadioChange', 'onSliderChange', 'onDateChange', 'onTimeChange',
|
|
@@ -426,6 +431,200 @@ const RULE_DEFINITIONS = [
|
|
|
426
431
|
}
|
|
427
432
|
}
|
|
428
433
|
},
|
|
434
|
+
{
|
|
435
|
+
id: 'snice/light-render-root-with-styles',
|
|
436
|
+
severity: 'suggestion',
|
|
437
|
+
category: 'architecture',
|
|
438
|
+
description: 'Ask for an explicit integration reason before a styled element gives up shadow-root encapsulation.',
|
|
439
|
+
check(context) {
|
|
440
|
+
if (isFrameworkImplementation(context.filename) || isTestFilename(context.filename)) return;
|
|
441
|
+
for (const declaration of findDecoratedClasses(context.source, 'element')) {
|
|
442
|
+
const header = context.source.slice(declaration.index, declaration.bodyStart);
|
|
443
|
+
const option = /\b(?:renderRoot\s*:\s*['"]light['"]|shadow\s*:\s*false)/.exec(header);
|
|
444
|
+
if (!option || !/(?:@styles\b|\bstatic\s+styles\b)/.test(declaration.body)) continue;
|
|
445
|
+
context.report(declaration.index + option.index, {
|
|
446
|
+
message: `${declaration.name} opts into light DOM despite owning component styles, so selectors and internals are no longer encapsulated.`,
|
|
447
|
+
fix: "Use the default shadow render root. Keep renderRoot: 'light' only for a documented integration requirement such as native parser parentage or intentional global-CSS participation."
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
id: 'snice/translator-controller',
|
|
454
|
+
severity: 'suggestion',
|
|
455
|
+
category: 'architecture',
|
|
456
|
+
description: 'Recommend keeping internal-event translation in the element contract instead of a controller.',
|
|
457
|
+
check(context) {
|
|
458
|
+
if (isFrameworkImplementation(context.filename)) return;
|
|
459
|
+
for (const declaration of findDecoratedClasses(context.source, 'controller')) {
|
|
460
|
+
const onMethods = findDecoratedClassMethods(declaration.body, 'on');
|
|
461
|
+
const dispatchMethods = findDecoratedClassMethods(declaration.body, 'dispatch');
|
|
462
|
+
if (!onMethods.length || !dispatchMethods.length) continue;
|
|
463
|
+
const dispatchNames = new Set(dispatchMethods.map(method => method.name));
|
|
464
|
+
const translationMethods = new Set([
|
|
465
|
+
...onMethods.map(method => method.name),
|
|
466
|
+
...dispatchNames,
|
|
467
|
+
'attach',
|
|
468
|
+
'detach'
|
|
469
|
+
]);
|
|
470
|
+
const translatesEveryHandler = onMethods.every(method =>
|
|
471
|
+
[...dispatchNames].some(name => new RegExp(`\\bthis\\.${escapeRegExp(name)}\\s*\\(`).test(method.body)) ||
|
|
472
|
+
/\bthis\.element(?:\?\.|\.)dispatchEvent\s*\(/.test(method.body)
|
|
473
|
+
);
|
|
474
|
+
const ownsState = [...inspectElementMembers(declaration.body).fields]
|
|
475
|
+
.some(name => name !== 'element');
|
|
476
|
+
const ownsOtherMethods = findClassMethods(declaration.body)
|
|
477
|
+
.some(method => !translationMethods.has(method.name));
|
|
478
|
+
const hasExternalBehavior = /\b(?:fetch|setTimeout|setInterval)\s*\(|\bnew\s+(?:WebSocket|EventSource|Worker)\s*\(|\b(?:localStorage|sessionStorage|indexedDB|caches)\b|@(request|respond|state|property)\b/.test(declaration.body);
|
|
479
|
+
if (!translatesEveryHandler || ownsState || ownsOtherMethods || hasExternalBehavior) continue;
|
|
480
|
+
context.report(declaration.index, {
|
|
481
|
+
message: `${declaration.name} only translates element events into other element events and owns no external behavior or state.`,
|
|
482
|
+
fix: 'Put visual behavior, internal-part handling, and semantic event dispatch in the element itself. Use a controller only for application behavior specific to a set of elements.'
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
id: 'snice/controller-event-origin',
|
|
489
|
+
severity: 'warning',
|
|
490
|
+
category: 'events',
|
|
491
|
+
description: 'Require shared controllers to distinguish their host event from the same event bubbling out of nested hosts.',
|
|
492
|
+
check(context) {
|
|
493
|
+
if (isFrameworkImplementation(context.filename)) return;
|
|
494
|
+
for (const declaration of findDecoratedClasses(context.source, 'controller')) {
|
|
495
|
+
const dispatched = new Set(
|
|
496
|
+
findDecoratedClassMethods(declaration.body, 'dispatch')
|
|
497
|
+
.flatMap(method => firstDecoratorStringArguments(method.decoratorArguments))
|
|
498
|
+
);
|
|
499
|
+
if (!dispatched.size) continue;
|
|
500
|
+
for (const handler of findDecoratedClassMethods(declaration.body, 'on')) {
|
|
501
|
+
const listened = firstDecoratorStringArguments(handler.decoratorArguments);
|
|
502
|
+
const onArguments = splitTopLevelArguments(handler.decoratorArguments);
|
|
503
|
+
const hasSelector = /^\s*(['"])[\s\S]*\1\s*$/.test(onArguments[1] ?? '');
|
|
504
|
+
if (hasSelector || !listened.some(event => dispatched.has(event))) continue;
|
|
505
|
+
const parameter = /^\s*([A-Za-z_$][\w$]*)/.exec(handler.parameters)?.[1];
|
|
506
|
+
if (!parameter || hasHostOriginCheck(handler.body, parameter)) continue;
|
|
507
|
+
const event = listened.find(name => dispatched.has(name));
|
|
508
|
+
context.report(declaration.bodyStart + handler.decoratorIndex, {
|
|
509
|
+
message: `${declaration.name} both listens for and dispatches "${event}" without checking that the event originated on its own host.`,
|
|
510
|
+
fix: `Guard the handler with if (${parameter}.target !== this.element) return; so the same event from a nested controller host is not handled twice.`
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
id: 'snice/controller-owns-routing',
|
|
518
|
+
severity: 'warning',
|
|
519
|
+
category: 'architecture',
|
|
520
|
+
description: 'Keep route parsing and navigation in routed pages instead of attached controllers.',
|
|
521
|
+
check(context) {
|
|
522
|
+
if (isFrameworkImplementation(context.filename)) return;
|
|
523
|
+
for (const declaration of findDecoratedClasses(context.source, 'controller')) {
|
|
524
|
+
const routing = /\bnew\s+URLSearchParams\s*\(|\b(?:window\.)?location\.(?:search|hash)\b|\b(?:window\.)?history\.(?:pushState|replaceState)\s*\(/.exec(declaration.body);
|
|
525
|
+
if (!routing) continue;
|
|
526
|
+
context.report(declaration.bodyStart + routing.index, {
|
|
527
|
+
message: `${declaration.name} owns URL parsing or navigation even though routing is page orchestration.`,
|
|
528
|
+
fix: 'Declare route/query parameters in @page({ routes }), receive them as page properties, and navigate through the Router. Keep the controller element-scoped.'
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
id: 'snice/imperative-controller-attach',
|
|
535
|
+
severity: 'suggestion',
|
|
536
|
+
category: 'architecture',
|
|
537
|
+
description: 'Prefer declarative controller bindings in application templates.',
|
|
538
|
+
check(context) {
|
|
539
|
+
if (isFrameworkImplementation(context.filename) || isTestFilename(context.filename)) return;
|
|
540
|
+
for (const localName of localsFor(context.provenance.rootBindings, 'attachController')) {
|
|
541
|
+
const pattern = new RegExp(`\\b${escapeRegExp(localName)}\\s*\\(`, 'g');
|
|
542
|
+
for (const match of context.source.matchAll(pattern)) {
|
|
543
|
+
context.report(match.index, {
|
|
544
|
+
message: `${localName}() imperatively attaches a controller in application code even though controller bindings own this lifecycle declaratively.`,
|
|
545
|
+
fix: 'Bind the decorated class in the owning template with controller=${ControllerClass}. Keep imperative attachment for focused framework tests only. Review docs/ai/controllers.md.'
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
id: 'snice/element-member-shadows-native-idl',
|
|
553
|
+
severity: 'warning',
|
|
554
|
+
category: 'properties',
|
|
555
|
+
description: 'Warn when a Snice element redeclares an inherited HTMLElement IDL member.',
|
|
556
|
+
check(context) {
|
|
557
|
+
if (isFrameworkImplementation(context.filename)) return;
|
|
558
|
+
for (const declaration of findSniceElementClasses(context.source)) {
|
|
559
|
+
const members = inspectElementMembers(declaration.body);
|
|
560
|
+
for (const name of new Set([...members.fields, ...members.accessors, ...members.reactive])) {
|
|
561
|
+
if (!NATIVE_ELEMENT_IDL_MEMBERS.has(name)) continue;
|
|
562
|
+
const relativeIndex = members.memberIndices.get(name) ?? 0;
|
|
563
|
+
context.report(declaration.bodyStart + relativeIndex, {
|
|
564
|
+
message: `${declaration.name}.${name} shadows HTMLElement.${name}, so native reflection/accessibility behavior and Snice property binding can conflict.`,
|
|
565
|
+
fix: `Rename the application-owned member. If native ${name} semantics are intended, use the inherited HTMLElement property/attribute without redeclaring it.`
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
id: 'snice/self-ready-await',
|
|
573
|
+
severity: 'error',
|
|
574
|
+
category: 'lifecycle',
|
|
575
|
+
description: 'Reject awaiting an element\'s own ready promise from one of its @ready handlers.',
|
|
576
|
+
check(context) {
|
|
577
|
+
for (const declaration of findSniceElementClasses(context.source)) {
|
|
578
|
+
for (const method of findDecoratedClassMethods(declaration.body, 'ready', declaration.bodyStart)) {
|
|
579
|
+
const wait = /\bawait\s+(?:Promise\.resolve\s*\(\s*)?this\.ready\b/.exec(method.body);
|
|
580
|
+
if (!wait) continue;
|
|
581
|
+
context.report(method.bodyStart + wait.index, {
|
|
582
|
+
message: `${declaration.name}.${method.name} awaits this.ready from inside @ready, so each side waits for the other and initialization cannot complete.`,
|
|
583
|
+
fix: 'Remove the self-wait. @ready already runs after the first render; await a child element\'s ready/rendered promise when child readiness is what the code needs.'
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
id: 'snice/stray-probe-test',
|
|
591
|
+
severity: 'warning',
|
|
592
|
+
category: 'testing',
|
|
593
|
+
description: 'Reject diagnostic test files whose assertions cannot fail.',
|
|
594
|
+
check(context) {
|
|
595
|
+
if (!isTestFilename(context.filename)) return;
|
|
596
|
+
const code = blankStringContents(context.source);
|
|
597
|
+
for (const match of code.matchAll(/\bexpect\s*\(\s*true\s*\)\s*\.\s*toBe\s*\(\s*true\s*\)/g)) {
|
|
598
|
+
context.report(match.index, {
|
|
599
|
+
message: 'This test assertion is always true and looks like an abandoned diagnostic probe.',
|
|
600
|
+
fix: 'Delete the probe file or replace the assertion with an observable behavior or contract that can fail.'
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
id: 'snice/test-helper-value-without-event',
|
|
607
|
+
severity: 'suggestion',
|
|
608
|
+
category: 'testing',
|
|
609
|
+
description: 'Warn when a test helper that appears to simulate user input only writes a DOM property.',
|
|
610
|
+
check(context) {
|
|
611
|
+
if (!isTestFilename(context.filename)) return;
|
|
612
|
+
for (const helper of findNamedFunctionBodies(context.source)) {
|
|
613
|
+
if (!/^(?:(?:type|enter|fill|change|check|uncheck|toggle|select|edit|input)|set(?:Input|Field|Control))/i.test(helper.name)) continue;
|
|
614
|
+
const write = /\b([A-Za-z_$][\w$]*)\s*\.\s*(value|checked)\s*=(?!=|>)/.exec(helper.body);
|
|
615
|
+
if (!write) continue;
|
|
616
|
+
const target = escapeRegExp(write[1]);
|
|
617
|
+
const userSignal = new RegExp(
|
|
618
|
+
`\\b${target}\\s*\\.\\s*(?:dispatchEvent|click)\\s*\\(|\\b(?:fireEvent|userEvent)\\b|\\b(?:fill|check|uncheck|selectOption)\\s*\\(`
|
|
619
|
+
);
|
|
620
|
+
if (userSignal.test(helper.body)) continue;
|
|
621
|
+
context.report(helper.bodyStart + write.index, {
|
|
622
|
+
message: `${helper.name}() writes .${write[2]} but emits no event, so it changes mechanism state without simulating a user action.`,
|
|
623
|
+
fix: 'Dispatch the control\'s documented input/change event after the write, or use the test runner\'s user-interaction helper. Keep direct writes only in tests of the property API itself.'
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
},
|
|
429
628
|
{
|
|
430
629
|
id: 'snice/observe-target',
|
|
431
630
|
severity: 'error',
|
|
@@ -650,6 +849,51 @@ const RULE_DEFINITIONS = [
|
|
|
650
849
|
}
|
|
651
850
|
}
|
|
652
851
|
},
|
|
852
|
+
{
|
|
853
|
+
id: 'snice/live-control-value-binding',
|
|
854
|
+
severity: 'suggestion',
|
|
855
|
+
category: 'rendering',
|
|
856
|
+
description: 'Recommend live property bindings for controlled values on verified self-mutating Snice controls.',
|
|
857
|
+
check(context) {
|
|
858
|
+
const components = {
|
|
859
|
+
'snice-input': 'input',
|
|
860
|
+
'snice-textarea': 'textarea',
|
|
861
|
+
'snice-segmented-control': 'segmented-control'
|
|
862
|
+
};
|
|
863
|
+
for (const opening of findOpeningTags(context.source, Object.keys(components))) {
|
|
864
|
+
const binding = /(?:^|\s)value\s*=\s*(?:['"])?\$\{/.exec(opening.text);
|
|
865
|
+
if (!binding) continue;
|
|
866
|
+
const valueIndex = opening.index + binding.index + binding[0].indexOf('value');
|
|
867
|
+
context.report(valueIndex, {
|
|
868
|
+
message: `${opening.name} receives a dynamic value attribute, but its live value can diverge after user interaction.`,
|
|
869
|
+
fix: `For a controlled value, import live from 'snice' and use .value=\${live(value)} so an owner render compares with the control's current property. Keep value= only for an authored/reset default. Review docs/ai/components/${components[opening.name]}.md and docs/ai/bindings.md.`
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
id: 'snice/prefer-dispatch-decorator',
|
|
876
|
+
severity: 'suggestion',
|
|
877
|
+
category: 'events',
|
|
878
|
+
description: 'Prefer @dispatch for static CustomEvents emitted from a Snice host.',
|
|
879
|
+
check(context) {
|
|
880
|
+
if (isFrameworkImplementation(context.filename)) return;
|
|
881
|
+
|
|
882
|
+
const declarations = [
|
|
883
|
+
...findSniceElementClasses(context.source).map(declaration => ({ declaration, role: 'element' })),
|
|
884
|
+
...findDecoratedClasses(context.source, 'controller').map(declaration => ({ declaration, role: 'controller' }))
|
|
885
|
+
];
|
|
886
|
+
|
|
887
|
+
for (const { declaration, role } of declarations) {
|
|
888
|
+
for (const dispatch of findManualCustomEventDispatches(declaration.body, role)) {
|
|
889
|
+
context.report(declaration.bodyStart + dispatch.index, {
|
|
890
|
+
message: `Manual host dispatch of "${dispatch.eventName}" duplicates the standard @dispatch CustomEvent path.`,
|
|
891
|
+
fix: `Prefer an emitter method decorated with @dispatch('${dispatch.eventName}') and return the detail payload directly. Keep dispatchEvent() when code needs the Event object, a dynamic event name, or the cancellation boolean. Review docs/ai/events.md.`
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
},
|
|
653
897
|
{
|
|
654
898
|
id: 'snice/custom-event-contract',
|
|
655
899
|
severity: 'error',
|
|
@@ -1245,10 +1489,34 @@ const RULE_DEFINITIONS = [
|
|
|
1245
1489
|
check() {}
|
|
1246
1490
|
},
|
|
1247
1491
|
{
|
|
1248
|
-
id: 'snice/recommend-page-
|
|
1492
|
+
id: 'snice/recommend-page-decomposition',
|
|
1249
1493
|
severity: 'suggestion',
|
|
1250
1494
|
category: 'architecture',
|
|
1251
|
-
description: 'Recommend
|
|
1495
|
+
description: 'Recommend decomposing an oversized page without attaching a controller to the page host.',
|
|
1496
|
+
projectOnly: true,
|
|
1497
|
+
check() {}
|
|
1498
|
+
},
|
|
1499
|
+
{
|
|
1500
|
+
id: 'snice/controller-on-page-host',
|
|
1501
|
+
severity: 'warning',
|
|
1502
|
+
category: 'architecture',
|
|
1503
|
+
description: 'Warn when a routed page imperatively attaches a controller to itself.',
|
|
1504
|
+
projectOnly: true,
|
|
1505
|
+
check() {}
|
|
1506
|
+
},
|
|
1507
|
+
{
|
|
1508
|
+
id: 'snice/recommend-route-params',
|
|
1509
|
+
severity: 'suggestion',
|
|
1510
|
+
category: 'router',
|
|
1511
|
+
description: 'Recommend declarative page route/query parameters instead of manual URL parsing and history writes.',
|
|
1512
|
+
projectOnly: true,
|
|
1513
|
+
check() {}
|
|
1514
|
+
},
|
|
1515
|
+
{
|
|
1516
|
+
id: 'snice/shadowed-query-route',
|
|
1517
|
+
severity: 'warning',
|
|
1518
|
+
category: 'router',
|
|
1519
|
+
description: 'Warn when same-page string route order makes a query-parameter variant unreachable.',
|
|
1252
1520
|
projectOnly: true,
|
|
1253
1521
|
check() {}
|
|
1254
1522
|
},
|
|
@@ -1293,7 +1561,7 @@ const RULE_DEFINITIONS = [
|
|
|
1293
1561
|
*/
|
|
1294
1562
|
export const PROJECT_ANALYZER_RULES = Object.freeze(
|
|
1295
1563
|
RULE_DEFINITIONS.map(({ id, severity, category, description }) =>
|
|
1296
|
-
Object.freeze({ id, severity, category, description })
|
|
1564
|
+
Object.freeze({ id, code: id, severity, category, description })
|
|
1297
1565
|
)
|
|
1298
1566
|
);
|
|
1299
1567
|
|
|
@@ -1304,6 +1572,7 @@ export const PROJECT_ANALYZER_RULES = Object.freeze(
|
|
|
1304
1572
|
* @param {string} [filename=''] Optional filename included in every diagnostic.
|
|
1305
1573
|
* @returns {Array<{
|
|
1306
1574
|
* severity: 'error'|'warning'|'suggestion',
|
|
1575
|
+
* code: string,
|
|
1307
1576
|
* ruleId: string,
|
|
1308
1577
|
* message: string,
|
|
1309
1578
|
* fix: string,
|
|
@@ -1327,6 +1596,7 @@ export function analyzeSource(source, filename = '') {
|
|
|
1327
1596
|
const location = sourceLocation(source, Math.max(0, nonSource.index));
|
|
1328
1597
|
return [{
|
|
1329
1598
|
severity: 'error',
|
|
1599
|
+
code: 'snice/non-source-content',
|
|
1330
1600
|
ruleId: 'snice/non-source-content',
|
|
1331
1601
|
message: nonSource.message,
|
|
1332
1602
|
fix: nonSource.fix,
|
|
@@ -1354,6 +1624,7 @@ export function analyzeSource(source, filename = '') {
|
|
|
1354
1624
|
seen.add(key);
|
|
1355
1625
|
const diagnostic = {
|
|
1356
1626
|
severity: rule.severity,
|
|
1627
|
+
code: rule.id,
|
|
1357
1628
|
ruleId: rule.id,
|
|
1358
1629
|
message: detail.message,
|
|
1359
1630
|
fix: detail.fix,
|
|
@@ -1403,8 +1674,16 @@ export function analyzeProject(files) {
|
|
|
1403
1674
|
continue;
|
|
1404
1675
|
}
|
|
1405
1676
|
const analyzable = maskComments(file.source);
|
|
1677
|
+
const staleGuardOwners = ['page', 'element', 'controller'].flatMap(kind =>
|
|
1678
|
+
findDecoratedClasses(analyzable, kind).map(declaration => ({
|
|
1679
|
+
kind,
|
|
1680
|
+
start: declaration.bodyStart,
|
|
1681
|
+
end: declaration.bodyStart + declaration.body.length
|
|
1682
|
+
}))
|
|
1683
|
+
);
|
|
1406
1684
|
for (const guard of analyzable.matchAll(/\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*\+\+this\.([A-Za-z_$][\w$]*(?:Id|Version|Token|Generation))\b([\s\S]{0,2000}?)\b(?:\1\s*!==?\s*this\.\2|this\.\2\s*!==?\s*\1)/g)) {
|
|
1407
|
-
|
|
1685
|
+
const owner = staleGuardOwners.find(candidate => guard.index >= candidate.start && guard.index < candidate.end);
|
|
1686
|
+
staleGuards.push({ file, index: guard.index, field: guard[2], owner: owner?.kind });
|
|
1408
1687
|
}
|
|
1409
1688
|
const provenance = buildSourceProvenance(analyzable);
|
|
1410
1689
|
routerConstructions.push(
|
|
@@ -1657,6 +1936,59 @@ export function analyzeProject(files) {
|
|
|
1657
1936
|
if (!declarationsByClass.has(key)) declarationsByClass.set(key, []);
|
|
1658
1937
|
declarationsByClass.get(key).push(declaration);
|
|
1659
1938
|
}
|
|
1939
|
+
|
|
1940
|
+
for (const page of pageClasses) {
|
|
1941
|
+
const selfAttachment = /\battachController\s*\(\s*this\s*,|\bthis\.controller\s*=/.exec(page.body);
|
|
1942
|
+
if (selfAttachment) {
|
|
1943
|
+
const index = page.bodyStart + selfAttachment.index;
|
|
1944
|
+
const location = sourceLocation(page.file.source, index);
|
|
1945
|
+
diagnostics.push({
|
|
1946
|
+
severity: 'warning',
|
|
1947
|
+
ruleId: 'snice/controller-on-page-host',
|
|
1948
|
+
message: `${page.name} attaches a controller to its own page host, coupling two lifecycle owners for one route.`,
|
|
1949
|
+
fix: 'Keep element orchestration in the page. Put application behavior specific to a set of elements in a controller and bind it where the page composes those elements. A host-free reusable function may stay a plain module.',
|
|
1950
|
+
file: page.file.filename,
|
|
1951
|
+
line: location.line,
|
|
1952
|
+
column: location.column
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
const manualRoute = /\bnew\s+URLSearchParams\s*\(|\b(?:window\.)?location\.search\b|\b(?:window\.)?history\.(?:pushState|replaceState)\s*\(/.exec(page.body);
|
|
1957
|
+
if (manualRoute) {
|
|
1958
|
+
const index = page.bodyStart + manualRoute.index;
|
|
1959
|
+
const location = sourceLocation(page.file.source, index);
|
|
1960
|
+
diagnostics.push({
|
|
1961
|
+
severity: 'suggestion',
|
|
1962
|
+
ruleId: 'snice/recommend-route-params',
|
|
1963
|
+
message: `${page.name} parses or writes URL state manually even though route/query parameters can be declared by the page.`,
|
|
1964
|
+
fix: "Declare parameters in @page({ routes: ['/path?q=:query'] }), receive them as page properties before @ready, and call the Router's navigate(path) to write route state. Review docs/ai/routing.md.",
|
|
1965
|
+
file: page.file.filename,
|
|
1966
|
+
line: location.line,
|
|
1967
|
+
column: location.column
|
|
1968
|
+
});
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
const precedingBareRoutes = new Set();
|
|
1972
|
+
for (const route of findPageStringRoutes(page.file.source, page.index)) {
|
|
1973
|
+
const queryIndex = route.spec.indexOf('?');
|
|
1974
|
+
const pathname = queryIndex >= 0 ? route.spec.slice(0, queryIndex) : route.spec;
|
|
1975
|
+
if (queryIndex < 0) {
|
|
1976
|
+
precedingBareRoutes.add(pathname);
|
|
1977
|
+
continue;
|
|
1978
|
+
}
|
|
1979
|
+
if (!precedingBareRoutes.has(pathname)) continue;
|
|
1980
|
+
const location = sourceLocation(page.file.source, route.index);
|
|
1981
|
+
diagnostics.push({
|
|
1982
|
+
severity: 'warning',
|
|
1983
|
+
ruleId: 'snice/shadowed-query-route',
|
|
1984
|
+
message: `${page.name}'s query route "${route.spec}" is shadowed by an earlier "${pathname}" entry with the same specificity, so its query properties never bind.`,
|
|
1985
|
+
fix: `Move "${route.spec}" before "${pathname}" in the routes array, or use route objects with explicit order values when a more complex tie-break is intentional. Review docs/ai/routing.md.`,
|
|
1986
|
+
file: page.file.filename,
|
|
1987
|
+
line: location.line,
|
|
1988
|
+
column: location.column
|
|
1989
|
+
});
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1660
1992
|
for (const [key, declarations] of declarationsByClass) {
|
|
1661
1993
|
const page = declarations.find(declaration => declaration.decorator === 'page');
|
|
1662
1994
|
const element = declarations.find(declaration => declaration.decorator === 'element');
|
|
@@ -1751,14 +2083,15 @@ export function analyzeProject(files) {
|
|
|
1751
2083
|
}
|
|
1752
2084
|
}
|
|
1753
2085
|
|
|
1754
|
-
|
|
1755
|
-
|
|
2086
|
+
const repeatedStaleGuards = new Set(staleGuards.map(guard => guard.file.filename)).size >= 2;
|
|
2087
|
+
if (repeatedStaleGuards) {
|
|
2088
|
+
for (const guard of staleGuards.filter(candidate => candidate.owner)) {
|
|
1756
2089
|
const location = sourceLocation(guard.file.source, guard.index);
|
|
1757
2090
|
diagnostics.push({
|
|
1758
2091
|
severity: 'suggestion',
|
|
1759
2092
|
ruleId: 'snice/duplicated-stale-guard',
|
|
1760
2093
|
message: `A ${guard.field} stale-response guard is repeated across project files.`,
|
|
1761
|
-
fix: 'Extract
|
|
2094
|
+
fix: 'Extract host-free request sequencing into a plain module in the project\'s chosen location. Keep element-specific application behavior in controllers and element orchestration in pages. Review docs/ai/architecture.md.',
|
|
1762
2095
|
file: guard.file.filename,
|
|
1763
2096
|
line: location.line,
|
|
1764
2097
|
column: location.column
|
|
@@ -1766,21 +2099,21 @@ export function analyzeProject(files) {
|
|
|
1766
2099
|
}
|
|
1767
2100
|
}
|
|
1768
2101
|
|
|
1769
|
-
const
|
|
2102
|
+
const pageDecompositionReported = new Set();
|
|
1770
2103
|
for (const page of pageClasses) {
|
|
1771
2104
|
const profile = pageLogicProfile(page.body);
|
|
1772
2105
|
if (!profile.recommend) continue;
|
|
1773
2106
|
const location = sourceLocation(page.file.source, page.index);
|
|
1774
2107
|
diagnostics.push({
|
|
1775
2108
|
severity: 'suggestion',
|
|
1776
|
-
ruleId: 'snice/recommend-page-
|
|
2109
|
+
ruleId: 'snice/recommend-page-decomposition',
|
|
1777
2110
|
message: `${page.name} contains substantial non-visual logic (${profile.effectfulMethods} effectful methods, ${profile.effects} external effects, ${profile.decisions} decision points).`,
|
|
1778
|
-
fix:
|
|
2111
|
+
fix: 'Keep visual behavior in elements, application behavior specific to a set of elements in controllers, and element orchestration in the page. A host-free reusable function may stay a plain module. Review docs/ai/architecture.md.',
|
|
1779
2112
|
file: page.file.filename,
|
|
1780
2113
|
line: location.line,
|
|
1781
2114
|
column: location.column
|
|
1782
2115
|
});
|
|
1783
|
-
|
|
2116
|
+
pageDecompositionReported.add(`${page.file.filename}:${page.index}`);
|
|
1784
2117
|
}
|
|
1785
2118
|
|
|
1786
2119
|
const repeatedPageMethods = new Map();
|
|
@@ -1797,18 +2130,18 @@ export function analyzeProject(files) {
|
|
|
1797
2130
|
if (files.length < 2) continue;
|
|
1798
2131
|
for (const { page, method } of occurrences) {
|
|
1799
2132
|
const reportKey = `${page.file.filename}:${page.index}`;
|
|
1800
|
-
if (
|
|
2133
|
+
if (pageDecompositionReported.has(reportKey)) continue;
|
|
1801
2134
|
const location = sourceLocation(page.file.source, method.index);
|
|
1802
2135
|
diagnostics.push({
|
|
1803
2136
|
severity: 'suggestion',
|
|
1804
|
-
ruleId: 'snice/recommend-page-
|
|
2137
|
+
ruleId: 'snice/recommend-page-decomposition',
|
|
1805
2138
|
message: `${method.name}() repeats substantial non-visual logic across ${files.length} page files.`,
|
|
1806
|
-
fix:
|
|
2139
|
+
fix: 'Keep visual behavior in elements, application behavior specific to a set of elements in controllers, and element orchestration in pages. Extract this host-free reusable operation into a plain module in the project\'s chosen location. Review docs/ai/architecture.md.',
|
|
1807
2140
|
file: page.file.filename,
|
|
1808
2141
|
line: location.line,
|
|
1809
2142
|
column: location.column
|
|
1810
2143
|
});
|
|
1811
|
-
|
|
2144
|
+
pageDecompositionReported.add(reportKey);
|
|
1812
2145
|
}
|
|
1813
2146
|
}
|
|
1814
2147
|
|
|
@@ -1827,7 +2160,9 @@ export function analyzeProject(files) {
|
|
|
1827
2160
|
}
|
|
1828
2161
|
}
|
|
1829
2162
|
|
|
1830
|
-
return diagnostics
|
|
2163
|
+
return diagnostics
|
|
2164
|
+
.map(diagnostic => diagnostic.code ? diagnostic : { ...diagnostic, code: diagnostic.ruleId })
|
|
2165
|
+
.sort(compareProjectDiagnostics);
|
|
1831
2166
|
}
|
|
1832
2167
|
|
|
1833
2168
|
/**
|
|
@@ -2077,10 +2412,91 @@ function findSniceElementClasses(source) {
|
|
|
2077
2412
|
return declarations;
|
|
2078
2413
|
}
|
|
2079
2414
|
|
|
2415
|
+
/**
|
|
2416
|
+
* Find standalone, static CustomEvent dispatches that @dispatch can express
|
|
2417
|
+
* without changing control flow. Calls whose return value is consumed, whose
|
|
2418
|
+
* event name is dynamic, or whose target is not the Snice host are deliberately
|
|
2419
|
+
* left alone as valid low-level event code.
|
|
2420
|
+
*/
|
|
2421
|
+
function findManualCustomEventDispatches(body, role) {
|
|
2422
|
+
const dispatches = [];
|
|
2423
|
+
const pattern = role === 'controller'
|
|
2424
|
+
? /\bthis\s*\.\s*element\s*(?:\?\.\s*|\.\s*)dispatchEvent\s*\(/g
|
|
2425
|
+
: /\bthis\s*\.\s*dispatchEvent\s*\(/g;
|
|
2426
|
+
|
|
2427
|
+
for (const match of body.matchAll(pattern)) {
|
|
2428
|
+
const lineStart = body.lastIndexOf('\n', match.index) + 1;
|
|
2429
|
+
if (body.slice(lineStart, match.index).trim()) continue;
|
|
2430
|
+
|
|
2431
|
+
const dispatchOpen = body.indexOf('(', match.index);
|
|
2432
|
+
const dispatchClose = findMatchingDelimiter(body, dispatchOpen, '(', ')');
|
|
2433
|
+
if (dispatchOpen < 0 || dispatchClose < 0) continue;
|
|
2434
|
+
|
|
2435
|
+
let afterDispatch = dispatchClose + 1;
|
|
2436
|
+
while (body[afterDispatch] === ' ' || body[afterDispatch] === '\t') afterDispatch++;
|
|
2437
|
+
if (body[afterDispatch] !== ';') continue;
|
|
2438
|
+
const lineEnd = body.indexOf('\n', afterDispatch);
|
|
2439
|
+
const remainder = body.slice(afterDispatch + 1, lineEnd < 0 ? body.length : lineEnd);
|
|
2440
|
+
if (remainder.trim()) continue;
|
|
2441
|
+
|
|
2442
|
+
let cursor = dispatchOpen + 1;
|
|
2443
|
+
while (/\s/.test(body[cursor] ?? '')) cursor++;
|
|
2444
|
+
if (!body.startsWith('new', cursor) || /[\w$]/.test(body[cursor + 3] ?? '')) continue;
|
|
2445
|
+
cursor += 3;
|
|
2446
|
+
while (/\s/.test(body[cursor] ?? '')) cursor++;
|
|
2447
|
+
if (!body.startsWith('CustomEvent', cursor) || /[\w$]/.test(body[cursor + 11] ?? '')) continue;
|
|
2448
|
+
cursor += 11;
|
|
2449
|
+
while (/\s/.test(body[cursor] ?? '')) cursor++;
|
|
2450
|
+
|
|
2451
|
+
if (body[cursor] === '<') {
|
|
2452
|
+
const genericClose = findMatchingDelimiter(body, cursor, '<', '>');
|
|
2453
|
+
if (genericClose < 0) continue;
|
|
2454
|
+
cursor = genericClose + 1;
|
|
2455
|
+
while (/\s/.test(body[cursor] ?? '')) cursor++;
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
if (body[cursor] !== '(') continue;
|
|
2459
|
+
cursor++;
|
|
2460
|
+
while (/\s/.test(body[cursor] ?? '')) cursor++;
|
|
2461
|
+
const eventName = /^(['"])([^'"]+)\1/.exec(body.slice(cursor));
|
|
2462
|
+
if (!eventName) continue;
|
|
2463
|
+
|
|
2464
|
+
dispatches.push({ index: match.index, eventName: eventName[2] });
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
return dispatches;
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2080
2470
|
function findElementDecoratorTag(source, index) {
|
|
2081
2471
|
return /^@element\s*\(\s*['"]([^'"]+)['"]/.exec(source.slice(index, index + 500))?.[1] ?? null;
|
|
2082
2472
|
}
|
|
2083
2473
|
|
|
2474
|
+
function findPageStringRoutes(source, decoratorIndex) {
|
|
2475
|
+
const open = source.indexOf('(', decoratorIndex);
|
|
2476
|
+
if (open < 0) return [];
|
|
2477
|
+
const close = findMatchingDelimiter(source, open, '(', ')');
|
|
2478
|
+
if (close < 0) return [];
|
|
2479
|
+
const argumentsSource = source.slice(open + 1, close);
|
|
2480
|
+
const routesProperty = /\broutes\s*:\s*\[/.exec(argumentsSource);
|
|
2481
|
+
if (!routesProperty) return [];
|
|
2482
|
+
const arrayOpen = open + 1 + routesProperty.index + routesProperty[0].lastIndexOf('[');
|
|
2483
|
+
const arrayClose = findMatchingDelimiter(source, arrayOpen, '[', ']');
|
|
2484
|
+
if (arrayClose < 0 || arrayClose > close) return [];
|
|
2485
|
+
|
|
2486
|
+
const body = source.slice(arrayOpen + 1, arrayClose);
|
|
2487
|
+
const routes = [];
|
|
2488
|
+
let searchFrom = 0;
|
|
2489
|
+
for (const item of splitTopLevelArguments(body)) {
|
|
2490
|
+
const relative = body.indexOf(item, searchFrom);
|
|
2491
|
+
if (relative < 0) continue;
|
|
2492
|
+
searchFrom = relative + item.length;
|
|
2493
|
+
const literal = /^(['"])([^'"]+)\1$/.exec(item);
|
|
2494
|
+
if (!literal) continue;
|
|
2495
|
+
routes.push({ spec: literal[2], index: arrayOpen + 1 + relative });
|
|
2496
|
+
}
|
|
2497
|
+
return routes;
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2084
2500
|
function inspectElementMembers(body) {
|
|
2085
2501
|
const code = blankStringContents(maskComments(body));
|
|
2086
2502
|
const depths = new Uint16Array(code.length + 1);
|
|
@@ -2096,11 +2512,13 @@ function inspectElementMembers(body) {
|
|
|
2096
2512
|
const readonly = new Set();
|
|
2097
2513
|
const functionFields = new Set();
|
|
2098
2514
|
const reactive = new Set();
|
|
2099
|
-
const
|
|
2515
|
+
const memberIndices = new Map();
|
|
2516
|
+
const fieldPattern = /(?:^|\n)\s*((?:(?:public|private|protected|static|readonly|override|declare|abstract)\s+)*)([A-Za-z_$][\w$]*)\s*[!?]?\s*(?::[^=\n;]+)?\s*(?==|;)/g;
|
|
2100
2517
|
for (const match of code.matchAll(fieldPattern)) {
|
|
2101
2518
|
const nameIndex = match.index + match[0].lastIndexOf(match[2]);
|
|
2102
2519
|
if (depths[nameIndex] !== 0) continue;
|
|
2103
2520
|
fields.add(match[2]);
|
|
2521
|
+
if (!memberIndices.has(match[2])) memberIndices.set(match[2], nameIndex);
|
|
2104
2522
|
if (/\breadonly\b/.test(match[1])) readonly.add(match[2]);
|
|
2105
2523
|
}
|
|
2106
2524
|
const functionFieldPattern = /(?:^|\n)\s*(?:(?:public|private|protected|static|readonly|override|declare|abstract)\s+)*([A-Za-z_$][\w$]*)\s*[!?]?\s*(?::[^=\n;{]+)?\s*=\s*(?:async\s*)?(?:\([^\n)]*\)|[A-Za-z_$][\w$]*)\s*=>/g;
|
|
@@ -2111,7 +2529,10 @@ function inspectElementMembers(body) {
|
|
|
2111
2529
|
const accessorPattern = /(?:^|\n)\s*(?:(?:public|private|protected|static|readonly|override|declare|abstract)\s+)*(?:get|set|accessor)\s+([A-Za-z_$][\w$]*)\b/g;
|
|
2112
2530
|
for (const match of code.matchAll(accessorPattern)) {
|
|
2113
2531
|
const nameIndex = match.index + match[0].lastIndexOf(match[1]);
|
|
2114
|
-
if (depths[nameIndex] === 0)
|
|
2532
|
+
if (depths[nameIndex] === 0) {
|
|
2533
|
+
accessors.add(match[1]);
|
|
2534
|
+
if (!memberIndices.has(match[1])) memberIndices.set(match[1], nameIndex);
|
|
2535
|
+
}
|
|
2115
2536
|
}
|
|
2116
2537
|
const decoratedPattern = /@(property|state)\b/g;
|
|
2117
2538
|
for (const match of code.matchAll(decoratedPattern)) {
|
|
@@ -2124,7 +2545,12 @@ function inspectElementMembers(body) {
|
|
|
2124
2545
|
cursor = close + 1;
|
|
2125
2546
|
}
|
|
2126
2547
|
const declaration = /^\s*(?:(?:public|private|protected|static|readonly|override|declare|abstract)\s+)*(?:accessor\s+)?([A-Za-z_$][\w$]*)/.exec(code.slice(cursor));
|
|
2127
|
-
if (declaration)
|
|
2548
|
+
if (declaration) {
|
|
2549
|
+
reactive.add(declaration[1]);
|
|
2550
|
+
if (!memberIndices.has(declaration[1])) {
|
|
2551
|
+
memberIndices.set(declaration[1], cursor + declaration.index + declaration[0].lastIndexOf(declaration[1]));
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2128
2554
|
}
|
|
2129
2555
|
|
|
2130
2556
|
// An undecorated native-style accessor can still be reactive when its
|
|
@@ -2153,7 +2579,7 @@ function inspectElementMembers(body) {
|
|
|
2153
2579
|
if (close >= 0 && invalidates(body.slice(open + 1, close))) invalidatingAccessors.add(match[1]);
|
|
2154
2580
|
}
|
|
2155
2581
|
|
|
2156
|
-
return { fields, accessors, readonly, functionFields, reactive, invalidatingAccessors };
|
|
2582
|
+
return { fields, accessors, readonly, functionFields, reactive, invalidatingAccessors, memberIndices };
|
|
2157
2583
|
}
|
|
2158
2584
|
|
|
2159
2585
|
function findDecoratedClassMethods(body, decoratorName, bodyStart = 0) {
|
|
@@ -2162,10 +2588,12 @@ function findDecoratedClassMethods(body, decoratorName, bodyStart = 0) {
|
|
|
2162
2588
|
const pattern = new RegExp(`@${escapeRegExp(decoratorName)}\\b`, 'g');
|
|
2163
2589
|
for (const match of code.matchAll(pattern)) {
|
|
2164
2590
|
let cursor = match.index + match[0].length;
|
|
2591
|
+
let decoratorArguments = '';
|
|
2165
2592
|
while (/\s/.test(code[cursor] ?? '')) cursor++;
|
|
2166
2593
|
if (code[cursor] === '(') {
|
|
2167
2594
|
const close = findMatchingDelimiter(code, cursor, '(', ')');
|
|
2168
2595
|
if (close < 0) continue;
|
|
2596
|
+
decoratorArguments = body.slice(cursor + 1, close);
|
|
2169
2597
|
cursor = close + 1;
|
|
2170
2598
|
}
|
|
2171
2599
|
const signature = /^\s*(?:(?:public|private|protected|static|override)\s+)*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\(/.exec(code.slice(cursor));
|
|
@@ -2179,6 +2607,9 @@ function findDecoratedClassMethods(body, decoratorName, bodyStart = 0) {
|
|
|
2179
2607
|
if (methodClose < 0) continue;
|
|
2180
2608
|
methods.push({
|
|
2181
2609
|
name: signature[1],
|
|
2610
|
+
decoratorIndex: match.index,
|
|
2611
|
+
decoratorArguments,
|
|
2612
|
+
parameters: body.slice(parametersOpen + 1, parametersClose),
|
|
2182
2613
|
bodyStart: bodyStart + methodOpen + 1,
|
|
2183
2614
|
body: body.slice(methodOpen + 1, methodClose)
|
|
2184
2615
|
});
|
|
@@ -2186,6 +2617,55 @@ function findDecoratedClassMethods(body, decoratorName, bodyStart = 0) {
|
|
|
2186
2617
|
return methods;
|
|
2187
2618
|
}
|
|
2188
2619
|
|
|
2620
|
+
function firstDecoratorStringArguments(argumentsSource) {
|
|
2621
|
+
const first = /^\s*(['"])([^'"]+)\1/.exec(argumentsSource);
|
|
2622
|
+
if (first) return [first[2]];
|
|
2623
|
+
const array = /^\s*\[([^\]]*)\]/.exec(argumentsSource);
|
|
2624
|
+
if (!array) return [];
|
|
2625
|
+
return [...array[1].matchAll(/(['"])([^'"]+)\1/g)].map(match => match[2]);
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
function splitTopLevelArguments(source) {
|
|
2629
|
+
const argumentsList = [];
|
|
2630
|
+
let start = 0;
|
|
2631
|
+
let quote = '';
|
|
2632
|
+
let escaped = false;
|
|
2633
|
+
let round = 0;
|
|
2634
|
+
let square = 0;
|
|
2635
|
+
let curly = 0;
|
|
2636
|
+
for (let index = 0; index < source.length; index++) {
|
|
2637
|
+
const character = source[index];
|
|
2638
|
+
if (quote) {
|
|
2639
|
+
if (escaped) escaped = false;
|
|
2640
|
+
else if (character === '\\') escaped = true;
|
|
2641
|
+
else if (character === quote) quote = '';
|
|
2642
|
+
continue;
|
|
2643
|
+
}
|
|
2644
|
+
if (character === '"' || character === "'" || character === '`') {
|
|
2645
|
+
quote = character;
|
|
2646
|
+
continue;
|
|
2647
|
+
}
|
|
2648
|
+
if (character === '(') round++;
|
|
2649
|
+
else if (character === ')') round = Math.max(0, round - 1);
|
|
2650
|
+
else if (character === '[') square++;
|
|
2651
|
+
else if (character === ']') square = Math.max(0, square - 1);
|
|
2652
|
+
else if (character === '{') curly++;
|
|
2653
|
+
else if (character === '}') curly = Math.max(0, curly - 1);
|
|
2654
|
+
else if (character === ',' && round === 0 && square === 0 && curly === 0) {
|
|
2655
|
+
argumentsList.push(source.slice(start, index).trim());
|
|
2656
|
+
start = index + 1;
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
argumentsList.push(source.slice(start).trim());
|
|
2660
|
+
return argumentsList;
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
function hasHostOriginCheck(body, parameter) {
|
|
2664
|
+
const target = `${escapeRegExp(parameter)}\\s*\\.\\s*target`;
|
|
2665
|
+
const host = 'this\\s*\\.\\s*element';
|
|
2666
|
+
return new RegExp(`(?:${target})\\s*[!=]==?\\s*(?:${host})|(?:${host})\\s*[!=]==?\\s*(?:${target})`).test(body);
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2189
2669
|
function isDirectMemberWrite(body, index, length) {
|
|
2190
2670
|
const before = body.slice(Math.max(0, index - 3), index);
|
|
2191
2671
|
const after = body.slice(index + length).trimStart();
|
|
@@ -2218,6 +2698,23 @@ function findClassMethods(body, bodyStart = 0) {
|
|
|
2218
2698
|
return methods;
|
|
2219
2699
|
}
|
|
2220
2700
|
|
|
2701
|
+
function findNamedFunctionBodies(source) {
|
|
2702
|
+
const code = blankStringContents(maskComments(source));
|
|
2703
|
+
const functions = [];
|
|
2704
|
+
const pattern = /\bfunction\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)[^{]*\{|\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*(?::[^=\n]+)?=>\s*\{/g;
|
|
2705
|
+
for (const match of code.matchAll(pattern)) {
|
|
2706
|
+
const open = match.index + match[0].lastIndexOf('{');
|
|
2707
|
+
const close = findMatchingDelimiter(code, open, '{', '}');
|
|
2708
|
+
if (close < 0) continue;
|
|
2709
|
+
functions.push({
|
|
2710
|
+
name: match[1] ?? match[2],
|
|
2711
|
+
bodyStart: open + 1,
|
|
2712
|
+
body: source.slice(open + 1, close)
|
|
2713
|
+
});
|
|
2714
|
+
}
|
|
2715
|
+
return functions;
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2221
2718
|
function pageLogicProfile(body) {
|
|
2222
2719
|
const code = blankStringContents(body);
|
|
2223
2720
|
const methods = findClassMethods(body);
|
|
@@ -3101,6 +3598,10 @@ function isFrameworkImplementation(filename) {
|
|
|
3101
3598
|
return /(?:^|[/\\])packages[/\\]components[/\\]src(?:[/\\]|$)/.test(filename);
|
|
3102
3599
|
}
|
|
3103
3600
|
|
|
3601
|
+
function isTestFilename(filename) {
|
|
3602
|
+
return /(?:^|[/\\])[^/\\]+\.(?:test|spec)\.[cm]?[jt]sx?$/.test(filename);
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3104
3605
|
function sourceLocation(source, index) {
|
|
3105
3606
|
const prefix = source.slice(0, index);
|
|
3106
3607
|
const line = prefix.split('\n').length;
|