snice 7.5.0 → 7.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (265) hide show
  1. package/adapters/react/SniceProvider.js +1 -1
  2. package/adapters/react/SniceRouter.js +1 -1
  3. package/adapters/react/avatar.d.ts +1 -0
  4. package/adapters/react/avatar.d.ts.map +1 -1
  5. package/adapters/react/avatar.js +1 -1
  6. package/adapters/react/avatar.js.map +1 -1
  7. package/adapters/react/avatar.tsx +2 -1
  8. package/adapters/react/calendar.d.ts +4 -0
  9. package/adapters/react/calendar.d.ts.map +1 -1
  10. package/adapters/react/calendar.js +1 -1
  11. package/adapters/react/calendar.js.map +1 -1
  12. package/adapters/react/calendar.tsx +5 -1
  13. package/adapters/react/matchRoute.js +68 -2
  14. package/adapters/react/matchRoute.js.map +1 -1
  15. package/adapters/react/route-match.d.ts +7 -0
  16. package/adapters/react/useRequestHandler.js +1 -1
  17. package/bin/analyzer-contracts.js +60 -2
  18. package/bin/project-analyzer.js +1262 -118
  19. package/bin/snice.js +95 -5
  20. package/custom-elements.json +112 -0
  21. package/dist/cdn/accordion/snice-accordion.js +1 -1
  22. package/dist/cdn/action-bar/snice-action-bar.js +1 -1
  23. package/dist/cdn/activity-feed/snice-activity-feed.js +1 -1
  24. package/dist/cdn/alert/snice-alert.js +1 -1
  25. package/dist/cdn/app-tiles/snice-app-tiles.js +1 -1
  26. package/dist/cdn/approval-flow/snice-approval-flow.js +1 -1
  27. package/dist/cdn/audio-recorder/snice-audio-recorder.js +1 -1
  28. package/dist/cdn/availability/snice-availability.js +1 -1
  29. package/dist/cdn/avatar/README.md +1 -1
  30. package/dist/cdn/avatar/snice-avatar.js +9 -3
  31. package/dist/cdn/avatar/snice-avatar.js.map +1 -1
  32. package/dist/cdn/avatar/snice-avatar.min.js +2 -2
  33. package/dist/cdn/avatar/snice-avatar.min.js.map +1 -1
  34. package/dist/cdn/avatar-group/snice-avatar-group.js +1 -1
  35. package/dist/cdn/badge/snice-badge.js +1 -1
  36. package/dist/cdn/banner/snice-banner.js +1 -1
  37. package/dist/cdn/binpack/snice-binpack.js +1 -1
  38. package/dist/cdn/book/snice-book.js +1 -1
  39. package/dist/cdn/booking/snice-booking.js +1 -1
  40. package/dist/cdn/breadcrumbs/snice-breadcrumbs.js +1 -1
  41. package/dist/cdn/button/snice-button.js +2 -2
  42. package/dist/cdn/button/snice-button.js.map +1 -1
  43. package/dist/cdn/button/snice-button.min.js +1 -1
  44. package/dist/cdn/button/snice-button.min.js.map +1 -1
  45. package/dist/cdn/calendar/README.md +2 -2
  46. package/dist/cdn/calendar/snice-calendar.js +562 -40
  47. package/dist/cdn/calendar/snice-calendar.js.map +1 -1
  48. package/dist/cdn/calendar/snice-calendar.min.js +28 -1
  49. package/dist/cdn/calendar/snice-calendar.min.js.map +1 -1
  50. package/dist/cdn/camera/snice-camera.js +1 -1
  51. package/dist/cdn/camera-annotate/snice-camera-annotate.js +1 -1
  52. package/dist/cdn/candlestick/snice-candlestick.js +1 -1
  53. package/dist/cdn/card/snice-card.js +1 -1
  54. package/dist/cdn/carousel/snice-carousel.js +1 -1
  55. package/dist/cdn/cart/snice-cart.js +1 -1
  56. package/dist/cdn/chart/snice-chart.js +1 -1
  57. package/dist/cdn/chat/snice-chat.js +1 -1
  58. package/dist/cdn/checkbox/snice-checkbox.js +1 -1
  59. package/dist/cdn/chip/snice-chip.js +1 -1
  60. package/dist/cdn/code-block/snice-code-block.js +1 -1
  61. package/dist/cdn/color-display/snice-color-display.js +1 -1
  62. package/dist/cdn/color-picker/snice-color-picker.js +1 -1
  63. package/dist/cdn/command-palette/snice-command-palette.js +1 -1
  64. package/dist/cdn/comments/snice-comments.js +1 -1
  65. package/dist/cdn/countdown/snice-countdown.js +1 -1
  66. package/dist/cdn/cropper/snice-cropper.js +1 -1
  67. package/dist/cdn/data-card/snice-data-card.js +1 -1
  68. package/dist/cdn/date-picker/snice-date-picker.js +1 -1
  69. package/dist/cdn/date-range-picker/snice-date-range-picker.js +1 -1
  70. package/dist/cdn/date-time-picker/snice-date-time-picker.js +1 -1
  71. package/dist/cdn/diff/snice-diff.js +1 -1
  72. package/dist/cdn/divider/snice-divider.js +1 -1
  73. package/dist/cdn/doc/snice-doc.js +1 -1
  74. package/dist/cdn/draw/snice-draw.js +1 -1
  75. package/dist/cdn/drawer/snice-drawer.js +1 -1
  76. package/dist/cdn/empty-state/snice-empty-state.js +1 -1
  77. package/dist/cdn/estimate/snice-estimate.js +1 -1
  78. package/dist/cdn/file-gallery/snice-file-gallery.js +1 -1
  79. package/dist/cdn/file-upload/snice-file-upload.js +1 -1
  80. package/dist/cdn/flip-card/snice-flip-card.js +1 -1
  81. package/dist/cdn/flow/snice-flow.js +1 -1
  82. package/dist/cdn/form-layout/snice-form-layout.js +1 -1
  83. package/dist/cdn/funnel/snice-funnel.js +1 -1
  84. package/dist/cdn/gantt/snice-gantt.js +1 -1
  85. package/dist/cdn/gauge/snice-gauge.js +1 -1
  86. package/dist/cdn/grid/snice-grid.js +1 -1
  87. package/dist/cdn/heatmap/snice-heatmap.js +1 -1
  88. package/dist/cdn/image/snice-image.js +1 -1
  89. package/dist/cdn/input/snice-input.js +1 -1
  90. package/dist/cdn/invoice/snice-invoice.js +1 -1
  91. package/dist/cdn/kanban/snice-kanban.js +1 -1
  92. package/dist/cdn/key-value/snice-key-value.js +1 -1
  93. package/dist/cdn/kpi/snice-kpi.js +1 -1
  94. package/dist/cdn/layout/snice-layout.js +1 -1
  95. package/dist/cdn/leaderboard/snice-leaderboard.js +1 -1
  96. package/dist/cdn/link/snice-link.js +1 -1
  97. package/dist/cdn/link-preview/snice-link-preview.js +1 -1
  98. package/dist/cdn/list/snice-list.js +1 -1
  99. package/dist/cdn/location/snice-location.js +1 -1
  100. package/dist/cdn/login/README.md +1 -1
  101. package/dist/cdn/login/snice-login.js +2 -2
  102. package/dist/cdn/login/snice-login.js.map +1 -1
  103. package/dist/cdn/login/snice-login.min.js +1 -1
  104. package/dist/cdn/login/snice-login.min.js.map +1 -1
  105. package/dist/cdn/map/snice-map.js +1 -1
  106. package/dist/cdn/markdown/snice-markdown.js +1 -1
  107. package/dist/cdn/masonry/snice-masonry.js +1 -1
  108. package/dist/cdn/menu/snice-menu.js +1 -1
  109. package/dist/cdn/message-strip/snice-message-strip.js +1 -1
  110. package/dist/cdn/modal/snice-modal.js +1 -1
  111. package/dist/cdn/music-player/snice-music-player.js +1 -1
  112. package/dist/cdn/nav/snice-nav.js +1 -1
  113. package/dist/cdn/network-graph/snice-network-graph.js +1 -1
  114. package/dist/cdn/notification-center/snice-notification-center.js +1 -1
  115. package/dist/cdn/order-tracker/snice-order-tracker.js +1 -1
  116. package/dist/cdn/org-chart/snice-org-chart.js +1 -1
  117. package/dist/cdn/pagination/snice-pagination.js +1 -1
  118. package/dist/cdn/paint/snice-paint.js +1 -1
  119. package/dist/cdn/pdf-viewer/snice-pdf-viewer.js +1 -1
  120. package/dist/cdn/permission-matrix/snice-permission-matrix.js +1 -1
  121. package/dist/cdn/podcast-player/snice-podcast-player.js +1 -1
  122. package/dist/cdn/popover/snice-popover.js +1 -1
  123. package/dist/cdn/pricing-table/snice-pricing-table.js +1 -1
  124. package/dist/cdn/product-card/snice-product-card.js +1 -1
  125. package/dist/cdn/progress/snice-progress.js +1 -1
  126. package/dist/cdn/progress-ring/snice-progress-ring.js +1 -1
  127. package/dist/cdn/qr-code/snice-qr-code.js +1 -1
  128. package/dist/cdn/qr-reader/snice-qr-reader.js +1 -1
  129. package/dist/cdn/radio/snice-radio.js +1 -1
  130. package/dist/cdn/range-slider/snice-range-slider.js +1 -1
  131. package/dist/cdn/rating/snice-rating.js +1 -1
  132. package/dist/cdn/receipt/snice-receipt.js +1 -1
  133. package/dist/cdn/recipe/snice-recipe.js +1 -1
  134. package/dist/cdn/runtime/README.md +2 -2
  135. package/dist/cdn/runtime/snice-runtime.esm.js +706 -267
  136. package/dist/cdn/runtime/snice-runtime.esm.js.map +1 -1
  137. package/dist/cdn/runtime/snice-runtime.esm.min.js +1 -1
  138. package/dist/cdn/runtime/snice-runtime.esm.min.js.map +1 -1
  139. package/dist/cdn/runtime/snice-runtime.js +706 -267
  140. package/dist/cdn/runtime/snice-runtime.js.map +1 -1
  141. package/dist/cdn/runtime/snice-runtime.min.js +1 -1
  142. package/dist/cdn/runtime/snice-runtime.min.js.map +1 -1
  143. package/dist/cdn/sankey/snice-sankey.js +1 -1
  144. package/dist/cdn/segmented-control/snice-segmented-control.js +1 -1
  145. package/dist/cdn/select/snice-select.js +3 -1
  146. package/dist/cdn/select/snice-select.js.map +1 -1
  147. package/dist/cdn/select/snice-select.min.js +2 -2
  148. package/dist/cdn/select/snice-select.min.js.map +1 -1
  149. package/dist/cdn/skeleton/snice-skeleton.js +1 -1
  150. package/dist/cdn/slider/snice-slider.js +1 -1
  151. package/dist/cdn/sortable/snice-sortable.js +1 -1
  152. package/dist/cdn/sparkline/snice-sparkline.js +1 -1
  153. package/dist/cdn/spinner/snice-spinner.js +1 -1
  154. package/dist/cdn/split-button/snice-split-button.js +1 -1
  155. package/dist/cdn/split-pane/snice-split-pane.js +1 -1
  156. package/dist/cdn/spotlight/snice-spotlight.js +1 -1
  157. package/dist/cdn/stat-group/snice-stat-group.js +1 -1
  158. package/dist/cdn/step-input/snice-step-input.js +1 -1
  159. package/dist/cdn/stepper/snice-stepper.js +1 -1
  160. package/dist/cdn/switch/README.md +1 -1
  161. package/dist/cdn/switch/snice-switch.js +8 -4
  162. package/dist/cdn/switch/snice-switch.js.map +1 -1
  163. package/dist/cdn/switch/snice-switch.min.js +8 -4
  164. package/dist/cdn/switch/snice-switch.min.js.map +1 -1
  165. package/dist/cdn/table/README.md +2 -2
  166. package/dist/cdn/table/snice-table.esm.js +77 -5
  167. package/dist/cdn/table/snice-table.esm.js.map +1 -1
  168. package/dist/cdn/table/snice-table.esm.min.js +68 -47
  169. package/dist/cdn/table/snice-table.esm.min.js.map +1 -1
  170. package/dist/cdn/table/snice-table.js +76 -4
  171. package/dist/cdn/table/snice-table.js.map +1 -1
  172. package/dist/cdn/table/snice-table.min.js +53 -32
  173. package/dist/cdn/table/snice-table.min.js.map +1 -1
  174. package/dist/cdn/tabs/snice-tabs.js +1 -1
  175. package/dist/cdn/tag/snice-tag.js +1 -1
  176. package/dist/cdn/tag-input/snice-tag-input.js +1 -1
  177. package/dist/cdn/terminal/snice-terminal.js +1 -1
  178. package/dist/cdn/testimonial/snice-testimonial.js +1 -1
  179. package/dist/cdn/textarea/snice-textarea.js +1 -1
  180. package/dist/cdn/time-picker/snice-time-picker.js +1 -1
  181. package/dist/cdn/time-range-picker/snice-time-range-picker.js +1 -1
  182. package/dist/cdn/timeline/snice-timeline.js +1 -1
  183. package/dist/cdn/timer/snice-timer.js +1 -1
  184. package/dist/cdn/toast/snice-toast.js +1 -1
  185. package/dist/cdn/tooltip/snice-tooltip.js +1 -1
  186. package/dist/cdn/tree/snice-tree.js +1 -1
  187. package/dist/cdn/treemap/snice-treemap.js +1 -1
  188. package/dist/cdn/user-card/snice-user-card.js +1 -1
  189. package/dist/cdn/video-player/snice-video-player.js +1 -1
  190. package/dist/cdn/virtual-scroller/snice-virtual-scroller.js +1 -1
  191. package/dist/cdn/waterfall/snice-waterfall.js +1 -1
  192. package/dist/cdn/weather/snice-weather.js +1 -1
  193. package/dist/cdn/work-order/snice-work-order.js +1 -1
  194. package/dist/components/avatar/snice-avatar.d.ts +1 -0
  195. package/dist/components/avatar/snice-avatar.js +8 -2
  196. package/dist/components/avatar/snice-avatar.js.map +1 -1
  197. package/dist/components/avatar/snice-avatar.types.d.ts +2 -0
  198. package/dist/components/button/snice-button.js +1 -1
  199. package/dist/components/button/snice-button.js.map +1 -1
  200. package/dist/components/calendar/snice-calendar.d.ts +38 -1
  201. package/dist/components/calendar/snice-calendar.js +390 -40
  202. package/dist/components/calendar/snice-calendar.js.map +1 -1
  203. package/dist/components/calendar/snice-calendar.types.d.ts +63 -0
  204. package/dist/components/select/snice-select.js +2 -0
  205. package/dist/components/select/snice-select.js.map +1 -1
  206. package/dist/components/switch/snice-switch.js +7 -3
  207. package/dist/components/switch/snice-switch.js.map +1 -1
  208. package/dist/components/table/snice-table.d.ts +9 -0
  209. package/dist/components/table/snice-table.js +72 -2
  210. package/dist/components/table/snice-table.js.map +1 -1
  211. package/dist/components/table/table-keyboard.js +1 -1
  212. package/dist/components/table/table-keyboard.js.map +1 -1
  213. package/dist/index.cjs +703 -264
  214. package/dist/index.cjs.map +1 -1
  215. package/dist/index.d.ts +3 -1
  216. package/dist/index.esm.js +703 -264
  217. package/dist/index.esm.js.map +1 -1
  218. package/dist/index.iife.js +703 -264
  219. package/dist/index.iife.js.map +1 -1
  220. package/dist/parts.d.ts +14 -4
  221. package/dist/react/SniceProvider.js +1 -1
  222. package/dist/react/SniceRouter.js +1 -1
  223. package/dist/react/index.js +1 -1
  224. package/dist/react/matchRoute.js +68 -2
  225. package/dist/react/matchRoute.js.map +1 -1
  226. package/dist/react/route-match.d.ts +7 -0
  227. package/dist/react/useRequestHandler.js +1 -1
  228. package/dist/render-errors.d.ts +9 -0
  229. package/dist/route-match.d.ts +7 -0
  230. package/dist/symbols.cjs +1 -1
  231. package/dist/symbols.esm.js +1 -1
  232. package/dist/template-marker.d.ts +5 -0
  233. package/dist/testing-dom.cjs +1 -1
  234. package/dist/testing-dom.esm.js +1 -1
  235. package/dist/testing.d.ts +3 -0
  236. package/dist/transitions.cjs +1 -1
  237. package/dist/transitions.esm.js +1 -1
  238. package/dist/types/dispatch-options.d.ts +5 -4
  239. package/dist/types/event-timing.d.ts +7 -0
  240. package/dist/types/index.d.ts +1 -0
  241. package/dist/types/on-options.d.ts +21 -6
  242. package/dist/utils.d.ts +3 -0
  243. package/docs/ai/DEVELOPMENT.md +1 -1
  244. package/docs/ai/api.md +7 -2
  245. package/docs/ai/cli.md +14 -0
  246. package/docs/ai/components/avatar.md +1 -0
  247. package/docs/ai/components/calendar.md +24 -1
  248. package/docs/ai/components/switch.md +3 -0
  249. package/docs/ai/components/table.md +1 -1
  250. package/docs/ai/decorators.md +3 -2
  251. package/docs/ai/events.md +45 -8
  252. package/docs/ai/rendering.md +7 -0
  253. package/docs/ai/routing.md +6 -1
  254. package/docs/ai/testing.md +17 -0
  255. package/docs/cli.md +19 -0
  256. package/docs/components/avatar.md +1 -0
  257. package/docs/components/calendar.md +112 -2
  258. package/docs/components/switch.md +7 -1
  259. package/docs/components/table.md +2 -1
  260. package/docs/events.md +90 -10
  261. package/docs/rendering.md +10 -0
  262. package/docs/routing.md +22 -1
  263. package/docs/testing.md +19 -0
  264. package/package.json +2 -2
  265. package/vscode.html-custom-data.json +28 -0
@@ -530,6 +530,38 @@ const RULE_DEFINITIONS = [
530
530
  }
531
531
  }
532
532
  },
533
+ {
534
+ id: 'snice/unguarded-context-load',
535
+ severity: 'warning',
536
+ category: 'lifecycle',
537
+ description: 'Warn when a controller starts repeatable load work from an unguarded @context handler.',
538
+ check(context) {
539
+ if (isFrameworkImplementation(context.filename) || isTestFilename(context.filename)) return;
540
+ for (const declaration of findDecoratedClasses(context.source, 'controller')) {
541
+ for (const method of findDecoratedClassMethods(declaration.body, 'context', declaration.bodyStart)) {
542
+ if (/\bonce\s*:\s*true\b/.test(method.decoratorArguments)) continue;
543
+
544
+ const body = blankStringContents(maskComments(method.body));
545
+ const load = /\b(?:void\s+|await\s+)?this\.((?:re)?load|refresh|fetch)(?:[A-Z_$][\w$]*)?\s*\(/.exec(body);
546
+ if (!load) continue;
547
+
548
+ const beforeLoad = body.slice(0, load.index);
549
+ const statementPrefix = beforeLoad.slice(Math.max(
550
+ beforeLoad.lastIndexOf(';'),
551
+ beforeLoad.lastIndexOf('\n')
552
+ ) + 1);
553
+ const hasGuard = /\b(?:if|switch|for|while)\s*\(|\breturn\b/.test(beforeLoad)
554
+ || /(?:&&|\|\||\?)/.test(statementPrefix);
555
+ if (hasGuard) continue;
556
+
557
+ context.report(method.bodyStart + load.index, {
558
+ message: `${declaration.name}.${method.name} starts ${load[1]} work from @context(), which fires on every context update while attached.`,
559
+ fix: 'Add an explicit first-delivery guard reset in detach(), pass @context({ once: true }) for a true one-shot, or diff the context update before loading. Review docs/ai/controllers.md.'
560
+ });
561
+ }
562
+ }
563
+ }
564
+ },
533
565
  {
534
566
  id: 'snice/imperative-controller-attach',
535
567
  severity: 'suggestion',
@@ -555,16 +587,8 @@ const RULE_DEFINITIONS = [
555
587
  description: 'Warn when a Snice element redeclares an inherited HTMLElement IDL member.',
556
588
  check(context) {
557
589
  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
- }
590
+ for (const finding of findNativeIdlShadowFindings(context.source, context.provenance)) {
591
+ context.report(finding.index, finding);
568
592
  }
569
593
  }
570
594
  },
@@ -1512,6 +1536,14 @@ const RULE_DEFINITIONS = [
1512
1536
  projectOnly: true,
1513
1537
  check() {}
1514
1538
  },
1539
+ {
1540
+ id: 'snice/route-param-has-no-binding-target',
1541
+ severity: 'warning',
1542
+ category: 'router',
1543
+ description: 'Warn when a page route parameter has no statically reachable binding target through the Router attribute channel.',
1544
+ projectOnly: true,
1545
+ check() {}
1546
+ },
1515
1547
  {
1516
1548
  id: 'snice/shadowed-query-route',
1517
1549
  severity: 'warning',
@@ -1662,6 +1694,7 @@ export function analyzeProject(files) {
1662
1694
  const routerConstructions = [];
1663
1695
  const architectureDeclarations = [];
1664
1696
  const pageClasses = [];
1697
+ const localClassDeclarations = [];
1665
1698
  const reactiveRenderContracts = new Map();
1666
1699
  const localElementContracts = new Map();
1667
1700
  const staleGuards = [];
@@ -1686,8 +1719,37 @@ export function analyzeProject(files) {
1686
1719
  staleGuards.push({ file, index: guard.index, field: guard[2], owner: owner?.kind });
1687
1720
  }
1688
1721
  const provenance = buildSourceProvenance(analyzable);
1722
+ enrichSniceProvenance(provenance, file, normalized);
1723
+ if (!isFrameworkImplementation(file.filename)) {
1724
+ for (const finding of findNativeIdlShadowFindings(analyzable, provenance)) {
1725
+ const location = sourceLocation(file.source, finding.index);
1726
+ const duplicate = diagnostics.some(diagnostic =>
1727
+ diagnostic.ruleId === 'snice/element-member-shadows-native-idl' &&
1728
+ diagnostic.file === file.filename &&
1729
+ diagnostic.line === location.line &&
1730
+ diagnostic.column === location.column
1731
+ );
1732
+ if (!duplicate) {
1733
+ diagnostics.push({
1734
+ severity: 'warning',
1735
+ ruleId: 'snice/element-member-shadows-native-idl',
1736
+ message: finding.message,
1737
+ fix: finding.fix,
1738
+ file: file.filename,
1739
+ line: location.line,
1740
+ column: location.column
1741
+ });
1742
+ }
1743
+ }
1744
+ }
1745
+ localClassDeclarations.push(...findClassBodies(analyzable).map(declaration => ({
1746
+ ...declaration,
1747
+ file,
1748
+ provenance,
1749
+ routeProperties: inspectRouteProperties(declaration.body, declaration.bodyStart, provenance)
1750
+ })));
1689
1751
  routerConstructions.push(
1690
- ...findRouterConstructions(analyzable, localsFor(provenance.rootBindings, 'Router'))
1752
+ ...findRouterConstructions(analyzable, provenRootBindingNames(provenance, 'Router'))
1691
1753
  .map(construction => ({ ...construction, file }))
1692
1754
  );
1693
1755
  for (const convention of [
@@ -1696,10 +1758,23 @@ export function analyzeProject(files) {
1696
1758
  { decorator: 'controller', folder: 'controllers', kind: 'controller' },
1697
1759
  { decorator: 'daemon', folder: 'daemons', kind: 'daemon' }
1698
1760
  ]) {
1699
- for (const declaration of findDecoratedClasses(analyzable, convention.decorator)) {
1761
+ const bindingPageNames = convention.decorator === 'page'
1762
+ ? routeDecoratorNames(provenance, file, normalized)
1763
+ : [];
1764
+ const decoratorNames = convention.decorator === 'page'
1765
+ ? [...new Set(['page', ...bindingPageNames])]
1766
+ : [convention.decorator];
1767
+ const declarations = decoratorNames.flatMap(name =>
1768
+ findDecoratedClasses(analyzable, name).map(declaration => ({ ...declaration, routeDecorator: name }))
1769
+ );
1770
+ for (const declaration of declarations.filter((item, index, all) =>
1771
+ all.findIndex(candidate => candidate.bodyStart === item.bodyStart) === index
1772
+ )) {
1700
1773
  const architectural = { ...declaration, ...convention, file };
1701
1774
  architectureDeclarations.push(architectural);
1702
- if (convention.decorator === 'page') pageClasses.push(architectural);
1775
+ if (convention.decorator === 'page' && bindingPageNames.includes(declaration.routeDecorator)) {
1776
+ pageClasses.push(architectural);
1777
+ }
1703
1778
  if (convention.decorator === 'page' || convention.decorator === 'element') {
1704
1779
  reactiveRenderContracts.set(`${file.filename}:${declaration.bodyStart}`, {
1705
1780
  ...architectural,
@@ -1968,6 +2043,86 @@ export function analyzeProject(files) {
1968
2043
  });
1969
2044
  }
1970
2045
 
2046
+ const bindingEnvironment = resolveRouteBindingEnvironment(page, localClassDeclarations, normalized);
2047
+ const reportedRouteAttributes = new Set();
2048
+ for (const route of findPageRoutes(page.file.source, page.index)) {
2049
+ for (const parameter of findRouteParameters(route)) {
2050
+ const routeAttribute = parameter.name.toLowerCase();
2051
+ if (reportedRouteAttributes.has(routeAttribute)) continue;
2052
+
2053
+ const reachable = bindingEnvironment.properties.find(property =>
2054
+ property.attributeKind !== 'false' &&
2055
+ property.attributeKind !== 'unknown' &&
2056
+ routePropertyAttribute(property, bindingEnvironment.naming) === routeAttribute
2057
+ );
2058
+ if (reachable) continue;
2059
+
2060
+ const sameName = bindingEnvironment.properties.find(property => property.name === parameter.name);
2061
+ if (sameName?.attributeKind === 'unknown') continue;
2062
+ if (sameName?.attributeKind === 'default' && bindingEnvironment.naming === 'unknown') continue;
2063
+ if (bindingEnvironment.customAttributes.has(routeAttribute)) continue;
2064
+
2065
+ reportedRouteAttributes.add(routeAttribute);
2066
+ const location = sourceLocation(page.file.source, parameter.index);
2067
+ const routeLabel = `${parameter.marker}${parameter.name}`;
2068
+ if (sameName?.attributeKind === 'false') {
2069
+ const disabledBy = sameName.kind === 'state'
2070
+ ? '@state() is internal state and disables the attribute channel used by Router'
2071
+ : '@property({ attribute: false }) disables the attribute channel used by Router';
2072
+ const enableFix = sameName.kind === 'state'
2073
+ ? `Replace @state() with a plain @property() on ${sameName.name}`
2074
+ : `Remove attribute: false so ${sameName.name} is a plain bindable @property()`;
2075
+ diagnostics.push({
2076
+ severity: 'warning',
2077
+ ruleId: 'snice/route-param-has-no-binding-target',
2078
+ message: `Route parameter "${routeLabel}" cannot bind ${page.name}.${sameName.name} because ${disabledBy}.`,
2079
+ fix: `${enableFix}, or remove/rename "${routeLabel}" if the URL value is not a page input. Review docs/ai/routing.md.`,
2080
+ file: page.file.filename,
2081
+ line: location.line,
2082
+ column: location.column
2083
+ });
2084
+ continue;
2085
+ }
2086
+
2087
+ if (sameName) {
2088
+ const observedAttribute = routePropertyAttribute(sameName, bindingEnvironment.naming);
2089
+ const defaultAttribute = defaultRoutePropertyAttribute(sameName.name, bindingEnvironment.naming);
2090
+ const removeAliasFix = sameName.attributeKind === 'alias' && defaultAttribute === routeAttribute
2091
+ ? `remove the explicit attribute alias so @property() ${sameName.name} observes "${routeAttribute}"`
2092
+ : `declare @property({ attribute: '${parameter.name}' }) ${sameName.name} so it observes "${routeAttribute}"`;
2093
+ diagnostics.push({
2094
+ severity: 'warning',
2095
+ ruleId: 'snice/route-param-has-no-binding-target',
2096
+ message: `Route parameter "${routeLabel}" sets the "${routeAttribute}" attribute, but ${page.name}.${sameName.name} observes "${observedAttribute}", so Router cannot reach it.`,
2097
+ fix: `Change the route parameter spelling to "${parameter.marker}${observedAttribute}", or ${removeAliasFix}. Review docs/ai/routing.md.`,
2098
+ file: page.file.filename,
2099
+ line: location.line,
2100
+ column: location.column
2101
+ });
2102
+ continue;
2103
+ }
2104
+
2105
+ if (bindingEnvironment.nativeAttributes.has(routeAttribute)) continue;
2106
+ if (!bindingEnvironment.complete) continue;
2107
+
2108
+ const suggestedName = kebabToCamel(parameter.name);
2109
+ const propertySuggestion = /^[A-Za-z_$][\w$]*$/.test(suggestedName)
2110
+ ? suggestedName === parameter.name
2111
+ ? `Add @property() ${suggestedName} = ''; to ${page.name}`
2112
+ : `Add @property({ attribute: '${parameter.name}' }) ${suggestedName} = ''; to ${page.name}`
2113
+ : `Rename "${routeLabel}" to a valid property name and add a matching plain @property()`;
2114
+ diagnostics.push({
2115
+ severity: 'warning',
2116
+ ruleId: 'snice/route-param-has-no-binding-target',
2117
+ message: `Route parameter "${routeLabel}" has no bindable @property on ${page.name}; Router sets the "${routeAttribute}" attribute, which otherwise does not populate page state.`,
2118
+ fix: `${propertySuggestion}, or remove the parameter if the page does not consume it. Review docs/ai/routing.md.`,
2119
+ file: page.file.filename,
2120
+ line: location.line,
2121
+ column: location.column
2122
+ });
2123
+ }
2124
+ }
2125
+
1971
2126
  const precedingBareRoutes = new Set();
1972
2127
  for (const route of findPageStringRoutes(page.file.source, page.index)) {
1973
2128
  const queryIndex = route.spec.indexOf('?');
@@ -2238,7 +2393,8 @@ function usesComponent(source, recommendation) {
2238
2393
 
2239
2394
  export function findImports(source) {
2240
2395
  const imports = [];
2241
- for (const start of source.matchAll(/^\s*import\b/gm)) {
2396
+ const code = maskNonExecutableCode(source);
2397
+ for (const start of code.matchAll(/^\s*import\b/gm)) {
2242
2398
  const statement = source.slice(start.index, start.index + 4000);
2243
2399
  const sideEffect = statement.match(/^\s*import\s*(['"])([^'"]+)\1\s*;?/);
2244
2400
  if (sideEffect) {
@@ -2282,13 +2438,15 @@ function findObjectCalls(source, functionName) {
2282
2438
 
2283
2439
  function findClassBodies(source) {
2284
2440
  const classes = [];
2285
- const pattern = /\bclass\s+[A-Za-z_$][\w$]*\s+extends\s+([A-Za-z_$][\w$]*)[^{]*\{/g;
2441
+ const pattern = /\bclass\s+([A-Za-z_$][\w$]*)\s+extends\s+([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)[^{]*\{/g;
2286
2442
  for (const match of source.matchAll(pattern)) {
2287
2443
  const open = source.indexOf('{', match.index);
2288
2444
  const close = findMatchingDelimiter(source, open, '{', '}');
2289
2445
  if (close < 0) continue;
2290
2446
  classes.push({
2291
- base: match[1],
2447
+ index: match.index,
2448
+ name: match[1],
2449
+ base: match[2],
2292
2450
  bodyStart: open + 1,
2293
2451
  body: source.slice(open + 1, close)
2294
2452
  });
@@ -2373,7 +2531,7 @@ function findDecoratedClasses(source, decoratorName) {
2373
2531
  // intervening decorators so each Snice role resolves to that class.
2374
2532
  while (true) {
2375
2533
  while (/\s/.test(source[declarationStart] ?? '')) declarationStart++;
2376
- const nextDecorator = /^@[A-Za-z_$][\w$]*/.exec(source.slice(declarationStart));
2534
+ const nextDecorator = /^@[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*/.exec(source.slice(declarationStart));
2377
2535
  if (!nextDecorator) break;
2378
2536
  declarationStart += nextDecorator[0].length;
2379
2537
  while (/\s/.test(source[declarationStart] ?? '')) declarationStart++;
@@ -2392,6 +2550,7 @@ function findDecoratedClasses(source, decoratorName) {
2392
2550
  declarations.push({
2393
2551
  index: match.index,
2394
2552
  name: declaration[1],
2553
+ base: /\bextends\s+([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)/.exec(declaration[0])?.[1] ?? null,
2395
2554
  bodyStart: open + 1,
2396
2555
  body: source.slice(open + 1, close)
2397
2556
  });
@@ -2412,6 +2571,91 @@ function findSniceElementClasses(source) {
2412
2571
  return declarations;
2413
2572
  }
2414
2573
 
2574
+ function parameterDeclaresBinding(parameters, name) {
2575
+ const escaped = escapeRegExp(name);
2576
+ return splitTopLevelArguments(parameters).some(parameter => {
2577
+ const value = parameter.trim().replace(/^\.\.\.\s*/, '');
2578
+ if (new RegExp(`^${escaped}(?=\\s*[?:=,]|\\s*$)`).test(value)) return true;
2579
+ if (!value.startsWith('{') && !value.startsWith('[')) return false;
2580
+ return new RegExp(
2581
+ `(?:^|[,\\[{:])\\s*${escaped}(?=\\s*[,}\\]=?:]|\\s*$)`
2582
+ ).test(value);
2583
+ });
2584
+ }
2585
+
2586
+ function isImportedDecoratorBindingShadowed(source, bindingName, useIndex) {
2587
+ const controls = new Set(['if', 'for', 'while', 'switch', 'with']);
2588
+ for (let open = source.indexOf('('); open >= 0 && open < useIndex; open = source.indexOf('(', open + 1)) {
2589
+ const close = findMatchingDelimiter(source, open, '(', ')');
2590
+ if (close < 0 || close >= useIndex) continue;
2591
+ let cursor = close + 1;
2592
+ while (/\s/.test(source[cursor] ?? '')) cursor++;
2593
+ if (source[cursor] === ':') {
2594
+ cursor++;
2595
+ while (cursor < source.length && source[cursor] !== '{' && !source.startsWith('=>', cursor)) cursor++;
2596
+ }
2597
+ while (/\s/.test(source[cursor] ?? '')) cursor++;
2598
+ let bodyOpen = -1;
2599
+ if (source.startsWith('=>', cursor)) {
2600
+ cursor += 2;
2601
+ while (/\s/.test(source[cursor] ?? '')) cursor++;
2602
+ if (source[cursor] === '{') bodyOpen = cursor;
2603
+ } else if (source[cursor] === '{') {
2604
+ const prefix = source.slice(Math.max(0, open - 160), open);
2605
+ const preceding = /([A-Za-z_$][\w$]*)\s*$/.exec(prefix)?.[1];
2606
+ if (preceding && !controls.has(preceding)) bodyOpen = cursor;
2607
+ }
2608
+ if (bodyOpen < 0) continue;
2609
+ const bodyClose = findMatchingDelimiter(source, bodyOpen, '{', '}');
2610
+ if (bodyClose < useIndex) continue;
2611
+ if (parameterDeclaresBinding(source.slice(open + 1, close), bindingName)) return true;
2612
+ }
2613
+
2614
+ const escaped = escapeRegExp(bindingName);
2615
+ for (const arrow of source.matchAll(new RegExp(`\\b${escaped}\\s*=>\\s*\\{`, 'g'))) {
2616
+ const bodyOpen = source.indexOf('{', arrow.index);
2617
+ const bodyClose = findMatchingDelimiter(source, bodyOpen, '{', '}');
2618
+ if (bodyOpen < useIndex && useIndex < bodyClose) return true;
2619
+ }
2620
+ return false;
2621
+ }
2622
+
2623
+ function findProvenSniceElementClasses(source, provenance) {
2624
+ const code = maskNonExecutableCode(source, { preserveTemplateExpressions: true });
2625
+ const decoratorNames = new Set(provenRootBindingNames(provenance, 'element'));
2626
+ const declarations = [];
2627
+ const seen = new Set();
2628
+ for (const decoratorName of decoratorNames) {
2629
+ const bindingName = decoratorName.split('.')[0];
2630
+ for (const declaration of findDecoratedClasses(code, decoratorName)) {
2631
+ if (
2632
+ seen.has(declaration.bodyStart) ||
2633
+ isImportedDecoratorBindingShadowed(code, bindingName, declaration.index)
2634
+ ) continue;
2635
+ seen.add(declaration.bodyStart);
2636
+ declarations.push(declaration);
2637
+ }
2638
+ }
2639
+ return declarations;
2640
+ }
2641
+
2642
+ function findNativeIdlShadowFindings(source, provenance) {
2643
+ const findings = [];
2644
+ for (const declaration of findProvenSniceElementClasses(source, provenance)) {
2645
+ const members = inspectElementMembers(declaration.body);
2646
+ for (const name of new Set([...members.fields, ...members.accessors, ...members.reactive])) {
2647
+ if (!NATIVE_ELEMENT_IDL_MEMBERS.has(name)) continue;
2648
+ const relativeIndex = members.memberIndices.get(name) ?? 0;
2649
+ findings.push({
2650
+ index: declaration.bodyStart + relativeIndex,
2651
+ message: `${declaration.name}.${name} shadows HTMLElement.${name}, so native reflection/accessibility behavior and Snice property binding can conflict.`,
2652
+ fix: `Rename the application-owned member. If native ${name} semantics are intended, use the inherited HTMLElement property/attribute without redeclaring it.`
2653
+ });
2654
+ }
2655
+ }
2656
+ return findings;
2657
+ }
2658
+
2415
2659
  /**
2416
2660
  * Find standalone, static CustomEvent dispatches that @dispatch can express
2417
2661
  * without changing control flow. Calls whose return value is consumed, whose
@@ -2472,29 +2716,777 @@ function findElementDecoratorTag(source, index) {
2472
2716
  }
2473
2717
 
2474
2718
  function findPageStringRoutes(source, decoratorIndex) {
2719
+ return findPageRoutes(source, decoratorIndex).filter(route => route.kind === 'string');
2720
+ }
2721
+
2722
+ function findPageRoutes(source, decoratorIndex) {
2475
2723
  const open = source.indexOf('(', decoratorIndex);
2476
2724
  if (open < 0) return [];
2477
2725
  const close = findMatchingDelimiter(source, open, '(', ')');
2478
2726
  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('[');
2727
+ const options = unwrapStaticExpression(source, open + 1, close);
2728
+ if (!options || source[options.start] !== '{') return [];
2729
+ const routes = findStaticObjectProperty(source, options.start, options.end, 'routes');
2730
+ if (!routes) return [];
2731
+ const array = unwrapStaticExpression(source, routes.start, routes.end);
2732
+ if (!array || source[array.start] !== '[') return [];
2733
+ const arrayOpen = array.start;
2483
2734
  const arrayClose = findMatchingDelimiter(source, arrayOpen, '[', ']');
2484
- if (arrayClose < 0 || arrayClose > close) return [];
2735
+ if (arrayClose < 0 || arrayClose > array.end) return [];
2485
2736
 
2486
2737
  const body = source.slice(arrayOpen + 1, arrayClose);
2487
- const routes = [];
2738
+ const found = [];
2488
2739
  let searchFrom = 0;
2489
2740
  for (const item of splitTopLevelArguments(body)) {
2490
2741
  const relative = body.indexOf(item, searchFrom);
2491
2742
  if (relative < 0) continue;
2492
2743
  searchFrom = relative + item.length;
2493
- const literal = /^(['"])([^'"]+)\1$/.exec(item);
2494
- if (!literal) continue;
2495
- routes.push({ spec: literal[2], index: arrayOpen + 1 + relative });
2744
+ const itemBounds = unwrapStaticExpression(source, arrayOpen + 1 + relative, arrayOpen + 1 + relative + item.length);
2745
+ if (!itemBounds) continue;
2746
+ const literal = parseStaticString(source, itemBounds.start, itemBounds.end);
2747
+ if (literal) {
2748
+ found.push({
2749
+ kind: 'string',
2750
+ spec: literal.value,
2751
+ index: arrayOpen + 1 + relative,
2752
+ specStart: literal.start,
2753
+ sourceIndices: literal.sourceIndices
2754
+ });
2755
+ continue;
2756
+ }
2757
+ if (source[itemBounds.start] !== '{') continue;
2758
+ const path = findStaticObjectProperty(source, itemBounds.start, itemBounds.end, 'path');
2759
+ if (!path) continue;
2760
+ const pathBounds = unwrapStaticExpression(source, path.start, path.end);
2761
+ if (!pathBounds) continue;
2762
+ const objectPath = parseStaticString(source, pathBounds.start, pathBounds.end);
2763
+ if (!objectPath) continue;
2764
+ found.push({
2765
+ kind: 'object',
2766
+ spec: objectPath.value,
2767
+ index: pathBounds.start,
2768
+ specStart: objectPath.start,
2769
+ sourceIndices: objectPath.sourceIndices
2770
+ });
2771
+ }
2772
+ return found;
2773
+ }
2774
+
2775
+ function findRouteParameters(route) {
2776
+ const parameters = [];
2777
+ for (const match of route.spec.matchAll(/(?:[:*])([\w-]+)/g)) {
2778
+ parameters.push({ name: match[1], marker: match[0][0], index: route.sourceIndices?.[match.index] ?? route.specStart + match.index });
2779
+ }
2780
+ return parameters;
2781
+ }
2782
+
2783
+ function unwrapStaticExpression(source, start, end) {
2784
+ while (start < end && /\s/.test(source[start])) start++;
2785
+ while (end > start && /\s/.test(source[end - 1])) end--;
2786
+ while (source[start] === '(') {
2787
+ const close = findMatchingDelimiter(source, start, '(', ')');
2788
+ if (close < 0 || !/^\s*(?:as\s+const\s*)?$/.test(source.slice(close + 1, end))) break;
2789
+ start++;
2790
+ end = close;
2791
+ while (start < end && /\s/.test(source[start])) start++;
2792
+ while (end > start && /\s/.test(source[end - 1])) end--;
2793
+ }
2794
+ const suffix = /\s+as\s+const\s*$/.exec(source.slice(start, end));
2795
+ if (suffix) end = start + suffix.index;
2796
+ while (end > start && /\s/.test(source[end - 1])) end--;
2797
+ return start < end ? { start, end } : null;
2798
+ }
2799
+
2800
+ function findStaticObjectProperty(source, open, end, wanted) {
2801
+ const close = findMatchingDelimiter(source, open, '{', '}');
2802
+ if (close < 0 || close >= end) return null;
2803
+ const body = source.slice(open + 1, close);
2804
+ let offset = 0;
2805
+ let result = null;
2806
+ for (const item of splitTopLevelArguments(body)) {
2807
+ const relative = body.indexOf(item, offset);
2808
+ offset = relative + item.length;
2809
+ const absolute = open + 1 + relative;
2810
+ const trimmed = item.trimStart();
2811
+ // A spread or computed/shorthand key can replace any earlier property.
2812
+ // A later explicit key is certain again because JavaScript uses last-key
2813
+ // semantics for object literals.
2814
+ if (trimmed.startsWith('...') || trimmed.startsWith('[')) {
2815
+ result = null;
2816
+ continue;
2817
+ }
2818
+ // splitTopLevelArguments already isolates the property; find the first
2819
+ // colon outside a quoted key.
2820
+ const keyMatch = /^\s*(?:([A-Za-z_$][\w$]*)|(['"])(.*?)\2)\s*:/.exec(item);
2821
+ if (!keyMatch) {
2822
+ const staticMethodOrShorthand = new RegExp(
2823
+ `^\\s*(?:(?:get|set|async)\\s+)?\\*?\\s*(?:${escapeRegExp(wanted)}|(['"])${escapeRegExp(wanted)}\\1)\\s*(?:\\(|,|$)`
2824
+ );
2825
+ if (staticMethodOrShorthand.test(item)) result = null;
2826
+ continue;
2827
+ }
2828
+ const key = keyMatch[1] ?? keyMatch[3];
2829
+ if (key !== wanted) continue;
2830
+ result = { start: absolute + keyMatch[0].length, end: absolute + item.length };
2831
+ }
2832
+ return result;
2833
+ }
2834
+
2835
+ function parseStaticString(source, start, end) {
2836
+ const quote = source[start];
2837
+ if (!['\'', '"', '`'].includes(quote)) return null;
2838
+ let value = '';
2839
+ const sourceIndices = [];
2840
+ for (let index = start + 1; index < end; index++) {
2841
+ const char = source[index];
2842
+ if (char === quote) return /^\s*$/.test(source.slice(index + 1, end))
2843
+ ? { value, start: start + 1, sourceIndices }
2844
+ : null;
2845
+ if (quote === '`' && char === '$' && source[index + 1] === '{') return null;
2846
+ if (char !== '\\') { value += char; sourceIndices.push(index); continue; }
2847
+ const escapeStart = index;
2848
+ const next = source[++index];
2849
+ if (next === '\n') continue;
2850
+ if (next === '\r') {
2851
+ if (source[index + 1] === '\n') index++;
2852
+ continue;
2853
+ }
2854
+ let emitted;
2855
+ if (next === 'x' && /^[0-9a-fA-F]{2}/.test(source.slice(index + 1, index + 3))) {
2856
+ emitted = String.fromCharCode(parseInt(source.slice(index + 1, index + 3), 16)); index += 2;
2857
+ } else if (next === 'u' && source[index + 1] === '{') {
2858
+ const braceClose = source.indexOf('}', index + 2);
2859
+ const digits = braceClose >= 0 ? source.slice(index + 2, braceClose) : '';
2860
+ const codePoint = /^[0-9a-fA-F]{1,6}$/.test(digits) ? parseInt(digits, 16) : -1;
2861
+ if (codePoint < 0 || codePoint > 0x10ffff) return null;
2862
+ emitted = String.fromCodePoint(codePoint); index = braceClose;
2863
+ } else if (next === 'u' && /^[0-9a-fA-F]{4}/.test(source.slice(index + 1, index + 5))) {
2864
+ emitted = String.fromCharCode(parseInt(source.slice(index + 1, index + 5), 16)); index += 4;
2865
+ } else emitted = ({ n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', 0: '\0' }[next] ?? next);
2866
+ value += emitted;
2867
+ for (let unit = 0; unit < emitted.length; unit++) sourceIndices.push(escapeStart);
2868
+ }
2869
+ return null;
2870
+ }
2871
+
2872
+ function inspectRouteProperties(body, bodyStart = 0, provenance = null) {
2873
+ const masked = maskComments(body);
2874
+ const code = blankStringContents(masked);
2875
+ const depths = new Uint16Array(code.length + 1);
2876
+ let depth = 0;
2877
+ for (let index = 0; index < code.length; index++) {
2878
+ depths[index] = depth;
2879
+ if (code[index] === '{') depth++;
2880
+ else if (code[index] === '}') depth = Math.max(0, depth - 1);
2881
+ }
2882
+
2883
+ const properties = [];
2884
+ const propertyNames = decoratorBindingNames(provenance, 'property');
2885
+ if (!propertyNames.length) return properties;
2886
+ const pattern = new RegExp(`@(?:${propertyNames.map(escapeRegExp).join('|')})\\b`, 'g');
2887
+ for (const match of code.matchAll(pattern)) {
2888
+ if (depths[match.index] !== 0) continue;
2889
+ let cursor = match.index + match[0].length;
2890
+ while (/\s/.test(code[cursor] ?? '')) cursor++;
2891
+ let optionsSource = '';
2892
+ let optionsOpen = -1;
2893
+ let optionsClose = -1;
2894
+ let hasArguments = false;
2895
+ if (code[cursor] === '(') {
2896
+ const close = findMatchingDelimiter(masked, cursor, '(', ')');
2897
+ if (close < 0) continue;
2898
+ hasArguments = true;
2899
+ optionsSource = masked.slice(cursor + 1, close).trim();
2900
+ optionsOpen = cursor + 1;
2901
+ optionsClose = close;
2902
+ cursor = close + 1;
2903
+ }
2904
+ const declaration = /^\s*((?:(?:public|private|protected|static|readonly|override|declare|abstract)\s+)*)(?:accessor\s+)?([A-Za-z_$][\w$]*)/
2905
+ .exec(code.slice(cursor));
2906
+ if (!declaration || /\bstatic\b/.test(declaration[1])) continue;
2907
+
2908
+ let attributeKind = 'default';
2909
+ let attribute = null;
2910
+ if (hasArguments && optionsSource) {
2911
+ if (!optionsSource.startsWith('{')) {
2912
+ attributeKind = 'unknown';
2913
+ } else {
2914
+ const objectOpen = masked.indexOf('{', optionsOpen);
2915
+ const attributeProperty = findStaticObjectProperty(masked, objectOpen, optionsClose, 'attribute');
2916
+ const attributeBounds = attributeProperty
2917
+ ? unwrapStaticExpression(masked, attributeProperty.start, attributeProperty.end)
2918
+ : null;
2919
+ const attributeOption = attributeBounds
2920
+ ? masked.slice(attributeBounds.start, attributeBounds.end).trim()
2921
+ : null;
2922
+ if (!attributeProperty && /\.\.\./.test(optionsSource)) attributeKind = 'unknown';
2923
+ else if (attributeOption === 'false') attributeKind = 'false';
2924
+ else if (attributeOption === 'true') attributeKind = 'kebab';
2925
+ else if (attributeOption) {
2926
+ const alias = parseStaticString(masked, attributeBounds.start, attributeBounds.end);
2927
+ if (alias) {
2928
+ attributeKind = 'alias';
2929
+ attribute = alias.value.toLowerCase();
2930
+ } else {
2931
+ attributeKind = 'unknown';
2932
+ }
2933
+ }
2934
+ }
2935
+ }
2936
+ const nameOffset = cursor + declaration.index + declaration[0].lastIndexOf(declaration[2]);
2937
+ properties.push({
2938
+ name: declaration[2],
2939
+ index: bodyStart + nameOffset,
2940
+ attributeKind,
2941
+ attribute
2942
+ });
2943
+ }
2944
+ return properties;
2945
+ }
2946
+
2947
+ function decoratorBindingNames(provenance, imported) {
2948
+ const known = provenRootBindingNames(provenance, imported);
2949
+ if (known.length) return known;
2950
+ const importedLocals = new Set((provenance?.imports ?? []).flatMap(entry =>
2951
+ parseNamedImportEntries(entry.clause).map(binding => binding.local)
2952
+ ));
2953
+ const locallyDeclared = new RegExp(`\\b(?:const|let|var|function|class)\\s+${escapeRegExp(imported)}\\b`)
2954
+ .test(provenance?.source ?? '');
2955
+ return importedLocals.has(imported) || locallyDeclared ? [] : [imported];
2956
+ }
2957
+
2958
+ function provenRootBindingNames(provenance, imported) {
2959
+ return [
2960
+ ...localsFor(provenance?.rootBindings ?? new Map(), imported),
2961
+ ...[...(provenance?.namespaceBindings ?? new Map())]
2962
+ .filter(([, path]) => path === 'snice')
2963
+ .map(([local]) => `${local}.${imported}`),
2964
+ ...[...(provenance?.projectRootNamespaces ?? new Map())]
2965
+ .filter(([, exports]) => exports.has(imported))
2966
+ .map(([local]) => `${local}.${imported}`)
2967
+ ];
2968
+ }
2969
+
2970
+ function enrichSniceProvenance(provenance, file, projectFiles) {
2971
+ const projectRootNamespaces = new Map();
2972
+ for (const entry of provenance.imports) {
2973
+ if (entry.typeOnly) continue;
2974
+ if (!entry.path.startsWith('.')) continue;
2975
+ const target = resolveProjectSource(file.filename, entry.path, projectFiles);
2976
+ if (!target) continue;
2977
+ const namespace = /^\*\s+as\s+([A-Za-z_$][\w$]*)$/.exec(entry.clause ?? '');
2978
+ if (namespace) {
2979
+ const exports = new Set(['element', 'property', 'state', 'SniceElement', 'Router'].filter(name =>
2980
+ resolvesSniceRootExport(target, name, projectFiles)
2981
+ ));
2982
+ if (exports.size) projectRootNamespaces.set(namespace[1], exports);
2983
+ for (const exported of sniceRootExportNames(target, 'element', projectFiles)) {
2984
+ const list = provenance.rootBindings.get('element') ?? [];
2985
+ list.push({
2986
+ imported: 'element',
2987
+ local: `${namespace[1]}.${exported}`,
2988
+ index: entry.index,
2989
+ path: entry.path
2990
+ });
2991
+ provenance.rootBindings.set('element', list);
2992
+ }
2993
+ continue;
2994
+ }
2995
+ for (const binding of parseNamedImportEntries(entry.clause)) {
2996
+ if (binding.typeOnly) continue;
2997
+ for (const imported of ['element', 'property', 'state', 'SniceElement', 'Router']) {
2998
+ if (!resolvesSniceRootExport(target, binding.imported, projectFiles, imported)) continue;
2999
+ const list = provenance.rootBindings.get(imported) ?? [];
3000
+ list.push({ imported, local: binding.local, index: entry.index, path: entry.path });
3001
+ provenance.rootBindings.set(imported, list);
3002
+ }
3003
+ }
3004
+ }
3005
+ provenance.projectRootNamespaces = projectRootNamespaces;
3006
+ }
3007
+
3008
+ function readStaticQuotedString(source, quoteIndex) {
3009
+ const quote = source[quoteIndex];
3010
+ if (quote !== "'" && quote !== '"') return null;
3011
+ let escaped = false;
3012
+ for (let index = quoteIndex + 1; index < source.length; index++) {
3013
+ const character = source[index];
3014
+ if (character === '\n' || character === '\r') return null;
3015
+ if (escaped) escaped = false;
3016
+ else if (character === '\\') escaped = true;
3017
+ else if (character === quote) return parseStaticString(source, quoteIndex, index + 1);
3018
+ }
3019
+ return null;
3020
+ }
3021
+
3022
+ function findNamedExportStatements(source) {
3023
+ const code = maskNonExecutableCode(source);
3024
+ const statements = [];
3025
+ for (const match of code.matchAll(/\bexport\s*\{([^}]*)\}/g)) {
3026
+ const after = match.index + match[0].length;
3027
+ const from = /^\s*from\s*(['"])/.exec(code.slice(after));
3028
+ let path = null;
3029
+ if (from) {
3030
+ const quoteIndex = after + from[0].lastIndexOf(from[1]);
3031
+ const literal = readStaticQuotedString(source, quoteIndex);
3032
+ if (!literal) continue;
3033
+ path = literal.value;
3034
+ }
3035
+ statements.push({ bindings: match[1], path, index: match.index });
3036
+ }
3037
+ return statements;
3038
+ }
3039
+
3040
+ function findExportStarStatements(source) {
3041
+ const code = maskNonExecutableCode(source);
3042
+ const statements = [];
3043
+ for (const match of code.matchAll(/\bexport\s*\*\s*from\s*(['"])/g)) {
3044
+ const quoteIndex = match.index + match[0].lastIndexOf(match[1]);
3045
+ const literal = readStaticQuotedString(source, quoteIndex);
3046
+ if (literal) statements.push({ path: literal.value, index: match.index });
3047
+ }
3048
+ return statements;
3049
+ }
3050
+
3051
+ function resolvesSniceRootExport(file, exportedName, projectFiles, wanted = exportedName, visited = new Set()) {
3052
+ const key = `${normalizeModuleFilename(file.filename)}:${exportedName}:${wanted}`;
3053
+ if (visited.has(key)) return false;
3054
+ visited.add(key);
3055
+ if (findExportStarStatements(file.source).some(statement => statement.path === 'snice') && exportedName === wanted) return true;
3056
+ const provenance = buildSourceProvenance(file.source);
3057
+ const localNames = new Set(localsFor(provenance.rootBindings, wanted));
3058
+ for (const statement of findNamedExportStatements(file.source)) {
3059
+ for (const binding of parseNamedImportEntries(`{${statement.bindings}}`)) {
3060
+ if (binding.typeOnly) continue;
3061
+ if (binding.local !== exportedName) continue;
3062
+ if (statement.path === 'snice') return binding.imported === wanted;
3063
+ if (!statement.path && localNames.has(binding.imported)) return true;
3064
+ if (statement.path?.startsWith('.')) {
3065
+ const target = resolveProjectSource(file.filename, statement.path, projectFiles);
3066
+ if (target && resolvesSniceRootExport(target, binding.imported, projectFiles, wanted, visited)) return true;
3067
+ }
3068
+ }
3069
+ }
3070
+ return false;
3071
+ }
3072
+
3073
+ function sniceRootExportNames(file, wanted, projectFiles, visited = new Set()) {
3074
+ const filename = normalizeModuleFilename(file.filename);
3075
+ if (visited.has(filename)) return new Set();
3076
+ visited.add(filename);
3077
+ const exported = new Set();
3078
+ const provenance = buildSourceProvenance(file.source);
3079
+ const directLocals = new Set(localsFor(provenance.rootBindings, wanted));
3080
+ if (findExportStarStatements(file.source).some(statement => statement.path === 'snice')) {
3081
+ exported.add(wanted);
3082
+ }
3083
+ for (const statement of findNamedExportStatements(file.source)) {
3084
+ for (const binding of parseNamedImportEntries(`{${statement.bindings}}`)) {
3085
+ if (binding.typeOnly) continue;
3086
+ if (statement.path === 'snice' && binding.imported === wanted) {
3087
+ exported.add(binding.local);
3088
+ continue;
3089
+ }
3090
+ if (!statement.path) {
3091
+ if (directLocals.has(binding.imported)) {
3092
+ exported.add(binding.local);
3093
+ continue;
3094
+ }
3095
+ for (const entry of provenance.imports) {
3096
+ if (entry.typeOnly || !entry.path.startsWith('.')) continue;
3097
+ const imported = parseNamedImportEntries(entry.clause)
3098
+ .find(candidate => !candidate.typeOnly && candidate.local === binding.imported);
3099
+ if (!imported) continue;
3100
+ const target = resolveProjectSource(file.filename, entry.path, projectFiles);
3101
+ if (!target) continue;
3102
+ const targetExports = sniceRootExportNames(target, wanted, projectFiles, new Set(visited));
3103
+ if (targetExports.has(imported.imported)) exported.add(binding.local);
3104
+ }
3105
+ continue;
3106
+ }
3107
+ if (statement.path?.startsWith('.')) {
3108
+ const target = resolveProjectSource(file.filename, statement.path, projectFiles);
3109
+ if (!target) continue;
3110
+ const targetExports = sniceRootExportNames(target, wanted, projectFiles, new Set(visited));
3111
+ if (targetExports.has(binding.imported)) exported.add(binding.local);
3112
+ }
3113
+ }
3114
+ }
3115
+ for (const statement of findExportStarStatements(file.source)) {
3116
+ if (!statement.path.startsWith('.')) continue;
3117
+ const target = resolveProjectSource(file.filename, statement.path, projectFiles);
3118
+ if (!target) continue;
3119
+ for (const name of sniceRootExportNames(target, wanted, projectFiles, new Set(visited))) {
3120
+ exported.add(name);
3121
+ }
3122
+ }
3123
+ return exported;
3124
+ }
3125
+
3126
+ function routeDecoratorNames(provenance, file, projectFiles) {
3127
+ const names = [];
3128
+ let barePageImported = false;
3129
+ for (const entry of provenance.imports) {
3130
+ for (const binding of parseNamedImportEntries(entry.clause)) {
3131
+ if (binding.local === 'page') barePageImported = true;
3132
+ if (!entry.path.startsWith('.')) continue;
3133
+ const target = resolveProjectSource(file.filename, entry.path, projectFiles);
3134
+ if (target && routerPageExportNames(target, projectFiles).has(binding.imported)) names.push(binding.local);
3135
+ }
3136
+ const namespace = /^\*\s+as\s+([A-Za-z_$][\w$]*)$/.exec(entry.clause ?? '');
3137
+ if (namespace && entry.path.startsWith('.')) {
3138
+ const target = resolveProjectSource(file.filename, entry.path, projectFiles);
3139
+ if (target) {
3140
+ for (const exported of routerPageExportNames(target, projectFiles)) names.push(`${namespace[1]}.${exported}`);
3141
+ }
3142
+ }
3143
+ }
3144
+ for (const routerName of provenRootBindingNames(provenance, 'Router')) {
3145
+ const escaped = escapeRegExp(routerName);
3146
+ for (const match of provenance.source.matchAll(new RegExp(`(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*${escaped}\\s*\\(`, 'g'))) {
3147
+ names.push(`${match[1]}.page`);
3148
+ }
3149
+ for (const match of provenance.source.matchAll(new RegExp(`(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*${escaped}\\s*\\(`, 'g'))) {
3150
+ const page = /(?:^|,)\s*page(?:\s*:\s*([A-Za-z_$][\w$]*))?(?=\s*,|\s*$)/.exec(match[1]);
3151
+ if (page) names.push(page[1] ?? 'page');
3152
+ }
3153
+ }
3154
+ const localPageDeclaration = /\b(?:const|let|var|function|class)\s+page\b/.test(provenance.source ?? '');
3155
+ if (!barePageImported && !localPageDeclaration) names.push('page');
3156
+ return [...new Set(names)];
3157
+ }
3158
+
3159
+ function resolveProjectSource(from, specifier, files) {
3160
+ const base = normalizeModuleFilename(`${from.replaceAll('\\', '/').replace(/\/[^/]*$/, '')}/${specifier}`);
3161
+ const candidates = new Set([base, ...['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'].map(ext => base + ext),
3162
+ ...['.ts', '.tsx', '.js', '.jsx'].map(ext => `${base}/index${ext}`)]);
3163
+ return files.find(candidate => candidates.has(normalizeModuleFilename(candidate.filename))) ?? null;
3164
+ }
3165
+
3166
+ function routerPageExportNames(file, projectFiles, visited = new Set()) {
3167
+ const filename = normalizeModuleFilename(file.filename);
3168
+ if (visited.has(filename)) return new Set();
3169
+ visited.add(filename);
3170
+ const source = maskNonExecutableCode(file.source);
3171
+ const provenance = buildSourceProvenance(file.source);
3172
+ enrichSniceProvenance(provenance, file, projectFiles);
3173
+ const locals = new Set();
3174
+ for (const routerName of provenRootBindingNames(provenance, 'Router')) {
3175
+ const direct = new RegExp(`(?:export\\s+)?(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*${escapeRegExp(routerName)}\\s*\\(`, 'g');
3176
+ for (const match of source.matchAll(direct)) {
3177
+ const page = /(?:^|,)\s*page(?:\s*:\s*([A-Za-z_$][\w$]*))?(?=\s*,|\s*$)/.exec(match[1]);
3178
+ if (page) locals.add(page[1] ?? 'page');
3179
+ }
3180
+ const instance = new RegExp(`(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*${escapeRegExp(routerName)}\\s*\\(`, 'g');
3181
+ for (const match of source.matchAll(instance)) {
3182
+ const destructure = new RegExp(`(?:export\\s+)?(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*${escapeRegExp(match[1])}\\b`, 'g');
3183
+ for (const binding of source.matchAll(destructure)) {
3184
+ const page = /(?:^|,)\s*page(?:\s*:\s*([A-Za-z_$][\w$]*))?(?=\s*,|\s*$)/.exec(binding[1]);
3185
+ if (page) locals.add(page[1] ?? 'page');
3186
+ }
3187
+ }
3188
+ }
3189
+ const exported = new Set();
3190
+ for (const local of locals) {
3191
+ if (new RegExp(`export\\s+(?:const|let|var)\\s*\\{[^}]*\\b${escapeRegExp(local)}\\b`).test(source)) exported.add(local);
3192
+ for (const statement of source.matchAll(/export\s*\{([^}]*)\}/g)) {
3193
+ const after = source.slice(statement.index + statement[0].length);
3194
+ if (/^\s*from\b/.test(after)) continue;
3195
+ for (const binding of parseNamedImportEntries(`{${statement[1]}}`)) {
3196
+ if (binding.imported === local) exported.add(binding.local);
3197
+ }
3198
+ }
3199
+ }
3200
+ for (const statement of findNamedExportStatements(file.source)) {
3201
+ if (!statement.path?.startsWith('.')) continue;
3202
+ const target = resolveProjectSource(file.filename, statement.path, projectFiles);
3203
+ if (!target) continue;
3204
+ const targetExports = routerPageExportNames(target, projectFiles, visited);
3205
+ for (const binding of parseNamedImportEntries(`{${statement.bindings}}`)) {
3206
+ if (targetExports.has(binding.imported)) exported.add(binding.local);
3207
+ }
3208
+ }
3209
+ return exported;
3210
+ }
3211
+
3212
+ function inspectRouteStateNames(body, provenance) {
3213
+ const code = blankStringContents(maskComments(body));
3214
+ const names = new Set();
3215
+ let depth = 0;
3216
+ const depths = new Uint16Array(code.length + 1);
3217
+ for (let index = 0; index < code.length; index++) {
3218
+ depths[index] = depth;
3219
+ if (code[index] === '{') depth++;
3220
+ else if (code[index] === '}') depth = Math.max(0, depth - 1);
3221
+ }
3222
+ const stateNames = decoratorBindingNames(provenance, 'state');
3223
+ if (!stateNames.length) return names;
3224
+ const pattern = new RegExp(`@(?:${stateNames.map(escapeRegExp).join('|')})\\b`, 'g');
3225
+ for (const match of code.matchAll(pattern)) {
3226
+ if (depths[match.index] !== 0) continue;
3227
+ let cursor = match.index + match[0].length;
3228
+ while (/\s/.test(code[cursor] ?? '')) cursor++;
3229
+ if (code[cursor] === '(') {
3230
+ const close = findMatchingDelimiter(code, cursor, '(', ')');
3231
+ if (close < 0) continue;
3232
+ cursor = close + 1;
3233
+ }
3234
+ const declaration = /^\s*(?:(?:public|private|protected|readonly|override|declare|abstract)\s+)*(?:accessor\s+)?([A-Za-z_$][\w$]*)/.exec(code.slice(cursor));
3235
+ if (declaration) names.add(declaration[1]);
3236
+ }
3237
+ return names;
3238
+ }
3239
+
3240
+ function resolveRouteBindingEnvironment(page, localClasses, projectFiles) {
3241
+ const propertiesByName = new Map();
3242
+ const shadowedNames = new Set();
3243
+ const visited = new Set();
3244
+ let current = localClasses.find(candidate =>
3245
+ candidate.file.filename === page.file.filename && candidate.bodyStart === page.bodyStart
3246
+ ) ?? {
3247
+ name: page.name,
3248
+ base: page.base,
3249
+ body: page.body,
3250
+ bodyStart: page.bodyStart,
3251
+ file: page.file,
3252
+ provenance: buildSourceProvenance(maskComments(page.file.source)),
3253
+ routeProperties: inspectRouteProperties(page.body, page.bodyStart, buildSourceProvenance(maskComments(page.file.source)))
3254
+ };
3255
+ let naming = 'unknown';
3256
+ let complete = true;
3257
+ let customObserved = null;
3258
+ let observedAttributesDeclared = false;
3259
+ let hasAttributeCallback = false;
3260
+ let observedAttributesUnknown = false;
3261
+
3262
+ while (current && !visited.has(`${current.file.filename}:${current.bodyStart}`)) {
3263
+ visited.add(`${current.file.filename}:${current.bodyStart}`);
3264
+ if (hasUnresolvedRelativeRouteDecorator(current.body, current.provenance)) complete = false;
3265
+ const stateNames = inspectRouteStateNames(current.body, current.provenance);
3266
+ for (const name of stateNames) {
3267
+ if (!shadowedNames.has(name)) {
3268
+ propertiesByName.set(name, { name, attributeKind: 'false', attribute: null, kind: 'state' });
3269
+ }
3270
+ shadowedNames.add(name);
3271
+ }
3272
+ for (const property of current.routeProperties) {
3273
+ if (!shadowedNames.has(property.name)) propertiesByName.set(property.name, property);
3274
+ shadowedNames.add(property.name);
3275
+ }
3276
+ if (!observedAttributesDeclared) {
3277
+ const observed = inspectObservedAttributes(current.body);
3278
+ if (observed !== undefined) {
3279
+ observedAttributesDeclared = true;
3280
+ if (observed === null) observedAttributesUnknown = true;
3281
+ else customObserved = observed;
3282
+ }
3283
+ }
3284
+ if (hasOwnAttributeChangedCallback(current.body)) hasAttributeCallback = true;
3285
+
3286
+ const sniceElementNames = new Set(provenRootBindingNames(current.provenance, 'SniceElement'));
3287
+ if (current.base === 'HTMLElement') {
3288
+ naming = 'lowercase';
3289
+ break;
3290
+ }
3291
+ if (sniceElementNames.has(current.base)) {
3292
+ naming = 'kebab';
3293
+ break;
3294
+ }
3295
+ if (!current.base) {
3296
+ complete = false;
3297
+ break;
3298
+ }
3299
+
3300
+ const sameFile = localClasses.filter(candidate =>
3301
+ candidate.file.filename === current.file.filename && candidate.name === current.base
3302
+ );
3303
+ let candidates = sameFile;
3304
+ if (!candidates.length) {
3305
+ const imported = current.provenance.imports.flatMap(entry =>
3306
+ parseNamedImportEntries(entry.clause)
3307
+ .filter(binding => binding.local === current.base && entry.path.startsWith('.'))
3308
+ .map(binding => ({ entry, binding }))
3309
+ );
3310
+ candidates = imported.flatMap(({ entry, binding }) =>
3311
+ resolveImportedRouteClass(current.file.filename, entry.path, binding.imported, localClasses, projectFiles)
3312
+ );
3313
+ const namespaceBase = /^([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/.exec(current.base);
3314
+ if (namespaceBase) {
3315
+ const path = current.provenance.namespaceBindings.get(namespaceBase[1]);
3316
+ if (path?.startsWith('.')) {
3317
+ candidates.push(...resolveImportedRouteClass(current.file.filename, path, namespaceBase[2], localClasses, projectFiles));
3318
+ }
3319
+ }
3320
+ }
3321
+ if (candidates.length !== 1) {
3322
+ complete = false;
3323
+ break;
3324
+ }
3325
+ current = candidates[0];
3326
+ }
3327
+
3328
+ const nativeAttributes = new Set([...NATIVE_ELEMENT_IDL_MEMBERS].map(name => name.toLowerCase()));
3329
+ const customAttributes = hasAttributeCallback && Array.isArray(customObserved)
3330
+ ? new Set(customObserved.map(name => name.toLowerCase()))
3331
+ : new Set();
3332
+ return {
3333
+ properties: [...propertiesByName.values()], naming,
3334
+ complete: complete && !(hasAttributeCallback && observedAttributesUnknown),
3335
+ nativeAttributes,
3336
+ customAttributes
3337
+ };
3338
+ }
3339
+
3340
+ function hasUnresolvedRelativeRouteDecorator(body, provenance) {
3341
+ const proven = new Set([
3342
+ ...provenRootBindingNames(provenance, 'property'),
3343
+ ...provenRootBindingNames(provenance, 'state')
3344
+ ]);
3345
+ const code = blankStringContents(maskComments(body));
3346
+ for (const entry of provenance.imports) {
3347
+ if (!entry.path.startsWith('.')) continue;
3348
+ const namespace = /^\*\s+as\s+([A-Za-z_$][\w$]*)$/.exec(entry.clause ?? '');
3349
+ if (namespace) {
3350
+ for (const member of ['property', 'state']) {
3351
+ const name = `${namespace[1]}.${member}`;
3352
+ if (!proven.has(name) && new RegExp(`@${escapeRegExp(name)}\\b`).test(code)) return true;
3353
+ }
3354
+ continue;
3355
+ }
3356
+ for (const binding of parseNamedImportEntries(entry.clause)) {
3357
+ if (!proven.has(binding.local) && new RegExp(`@${escapeRegExp(binding.local)}\\b`).test(code)) return true;
3358
+ }
3359
+ }
3360
+ return false;
3361
+ }
3362
+
3363
+ function normalizeModuleFilename(filename) {
3364
+ const parts = filename.replaceAll('\\', '/').split('/');
3365
+ const normalized = [];
3366
+ for (const part of parts) {
3367
+ if (part === '..') normalized.pop();
3368
+ else if (part !== '.') normalized.push(part);
2496
3369
  }
2497
- return routes;
3370
+ return normalized.join('/');
3371
+ }
3372
+
3373
+ function resolveLocalModule(from, specifier, localClasses) {
3374
+ const base = normalizeModuleFilename(`${from.replaceAll('\\', '/').replace(/\/[^/]*$/, '')}/${specifier}`);
3375
+ const candidates = new Set([base, ...['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'].map(ext => base + ext),
3376
+ ...['.ts', '.tsx', '.js', '.jsx'].map(ext => `${base}/index${ext}`)]);
3377
+ return new Set([...candidates].filter(candidate => localClasses.some(item => normalizeModuleFilename(item.file.filename) === candidate)));
3378
+ }
3379
+
3380
+ function resolveImportedRouteClass(from, specifier, exportedName, localClasses, projectFiles, visited = new Set()) {
3381
+ const target = resolveProjectSource(from, specifier, projectFiles);
3382
+ const key = `${from}:${specifier}:${exportedName}`;
3383
+ if (visited.has(key) || !target) return [];
3384
+ visited.add(key);
3385
+ const results = [];
3386
+ for (const filename of [normalizeModuleFilename(target.filename)]) {
3387
+ const direct = localClasses.filter(candidate =>
3388
+ normalizeModuleFilename(candidate.file.filename) === filename && candidate.name === exportedName
3389
+ );
3390
+ results.push(...direct);
3391
+ const file = target;
3392
+ for (const statement of findNamedExportStatements(file.source)) {
3393
+ for (const binding of parseNamedImportEntries(`{${statement.bindings}}`)) {
3394
+ if (binding.local !== exportedName) continue;
3395
+ if (statement.path?.startsWith('.')) {
3396
+ results.push(...resolveImportedRouteClass(file.filename, statement.path, binding.imported, localClasses, projectFiles, visited));
3397
+ } else {
3398
+ results.push(...localClasses.filter(candidate =>
3399
+ normalizeModuleFilename(candidate.file.filename) === filename && candidate.name === binding.imported
3400
+ ));
3401
+ }
3402
+ }
3403
+ }
3404
+ }
3405
+ return results.filter((candidate, index, all) =>
3406
+ all.findIndex(item => item.file.filename === candidate.file.filename && item.bodyStart === candidate.bodyStart) === index
3407
+ );
3408
+ }
3409
+
3410
+ function inspectObservedAttributes(body) {
3411
+ const source = maskComments(body);
3412
+ const code = blankStringContents(source);
3413
+ const depths = lexicalBraceDepths(code);
3414
+ const getterPattern = /(?:^|[;\n])\s*(?:(?:public|protected|private)\s+)?static\s+get\s+observedAttributes\s*\(\s*\)\s*\{/g;
3415
+ const fieldPattern = /(?:^|[;\n])\s*(?:(?:public|protected|private)\s+)?static\s+(?:readonly\s+)?observedAttributes\s*=/g;
3416
+ const getters = [...code.matchAll(getterPattern)].filter(match => depths[match.index] === 0);
3417
+ const fields = [...code.matchAll(fieldPattern)].filter(match => depths[match.index] === 0);
3418
+ if (getters.length + fields.length > 1) return null;
3419
+ for (const declaration of getters) {
3420
+ const open = code.indexOf('{', declaration.index);
3421
+ const close = findMatchingDelimiter(source, open, '{', '}');
3422
+ if (close < 0) return null;
3423
+ const getterBody = source.slice(open + 1, close);
3424
+ const returnStatement = /^\s*return\s+([\s\S]*?);?\s*$/.exec(blankStringContents(getterBody));
3425
+ if (!returnStatement) return null;
3426
+ const returnIndex = getterBody.search(/\breturn\b/) + 'return'.length;
3427
+ let expressionEnd = close;
3428
+ while (/\s/.test(source[expressionEnd - 1] ?? '')) expressionEnd--;
3429
+ if (source[expressionEnd - 1] === ';') expressionEnd--;
3430
+ const expression = unwrapStaticExpression(source, open + 1 + returnIndex, expressionEnd);
3431
+ return expression ? parseStaticStringArray(source, expression.start, expression.end) : null;
3432
+ }
3433
+ for (const declaration of fields) {
3434
+ const equals = code.indexOf('=', declaration.index);
3435
+ let end = code.indexOf(';', equals + 1);
3436
+ if (end < 0) end = code.indexOf('\n', equals + 1);
3437
+ if (end < 0) end = code.length;
3438
+ const expression = unwrapStaticExpression(source, equals + 1, end);
3439
+ return expression ? parseStaticStringArray(source, expression.start, expression.end) : null;
3440
+ }
3441
+ return undefined;
3442
+ }
3443
+
3444
+ function parseStaticStringArray(source, start, end) {
3445
+ if (source[start] !== '[') return null;
3446
+ const arrayClose = findMatchingDelimiter(source, start, '[', ']');
3447
+ if (arrayClose < 0 || !/^\s*$/.test(source.slice(arrayClose + 1, end))) return null;
3448
+ const values = [];
3449
+ const body = source.slice(start + 1, arrayClose);
3450
+ let offset = 0;
3451
+ for (const item of splitTopLevelArguments(body)) {
3452
+ const relative = body.indexOf(item, offset);
3453
+ offset = relative + item.length;
3454
+ const itemStart = start + 1 + relative;
3455
+ const literal = parseStaticString(source, itemStart, itemStart + item.length);
3456
+ if (!literal) return null;
3457
+ values.push(literal.value);
3458
+ }
3459
+ return values;
3460
+ }
3461
+
3462
+ function lexicalBraceDepths(code) {
3463
+ const depths = new Uint16Array(code.length + 1);
3464
+ let depth = 0;
3465
+ for (let index = 0; index < code.length; index++) {
3466
+ depths[index] = depth;
3467
+ if (code[index] === '{') depth++;
3468
+ else if (code[index] === '}') depth = Math.max(0, depth - 1);
3469
+ }
3470
+ return depths;
3471
+ }
3472
+
3473
+ function hasOwnAttributeChangedCallback(body) {
3474
+ const code = blankStringContents(maskComments(body));
3475
+ const depths = lexicalBraceDepths(code);
3476
+ for (const match of code.matchAll(/(?:^|[;\n])\s*(?:(?:public|protected|private|override)\s+)*(?:async\s+)?attributeChangedCallback\s*\(/g)) {
3477
+ if (depths[match.index] === 0) return true;
3478
+ }
3479
+ return false;
3480
+ }
3481
+
3482
+ function routePropertyAttribute(property, naming) {
3483
+ if (property.attributeKind === 'alias') return property.attribute;
3484
+ if (property.attributeKind === 'kebab') return camelToKebab(property.name).toLowerCase();
3485
+ return defaultRoutePropertyAttribute(property.name, naming);
3486
+ }
3487
+
3488
+ function defaultRoutePropertyAttribute(name, naming) {
3489
+ return naming === 'kebab' ? camelToKebab(name).toLowerCase() : name.toLowerCase();
2498
3490
  }
2499
3491
 
2500
3492
  function inspectElementMembers(body) {
@@ -2911,6 +3903,223 @@ function blankStringContents(source) {
2911
3903
  return output;
2912
3904
  }
2913
3905
 
3906
+ /**
3907
+ * Preserve executable tokens and source positions while blanking comments and
3908
+ * literal contents. Provenance scans use this shared view so code examples in
3909
+ * documentation strings cannot manufacture imports, exports, or Router calls.
3910
+ */
3911
+ function maskNonExecutableCode(source, { preserveTemplateExpressions = false } = {}) {
3912
+ const output = source.split('');
3913
+ const blank = (start, end) => {
3914
+ for (let index = start; index < end; index++) {
3915
+ if (source[index] !== '\n' && source[index] !== '\r') output[index] = ' ';
3916
+ }
3917
+ };
3918
+ const quotedEnd = (start, quote) => {
3919
+ for (let index = start + 1; index < source.length; index++) {
3920
+ if (source[index] === '\\') { index++; continue; }
3921
+ if (source[index] === quote) return index;
3922
+ if (quote !== '`' && (source[index] === '\n' || source[index] === '\r')) return source.length;
3923
+ }
3924
+ return source.length;
3925
+ };
3926
+ const controlBodies = new Set(['if', 'while', 'for', 'with']);
3927
+ const expressionKeywords = new Set([
3928
+ 'await', 'case', 'delete', 'do', 'else', 'in', 'instanceof', 'new',
3929
+ 'of', 'return', 'throw', 'typeof', 'void', 'yield'
3930
+ ]);
3931
+ const controlParens = new Set(['if', 'while', 'for', 'with', 'switch', 'catch']);
3932
+
3933
+ const scanTemplate = start => {
3934
+ let rawStart = start + 1;
3935
+ for (let index = start + 1; index < source.length; index++) {
3936
+ if (source[index] === '\\') { index++; continue; }
3937
+ if (source[index] === '`') {
3938
+ if (preserveTemplateExpressions) blank(rawStart, index);
3939
+ return index;
3940
+ }
3941
+ if (source[index] !== '$' || source[index + 1] !== '{') continue;
3942
+ if (preserveTemplateExpressions) blank(rawStart, index);
3943
+ index = scanCode(index + 2, true) - 1;
3944
+ rawStart = index + 1;
3945
+ }
3946
+ if (preserveTemplateExpressions) blank(rawStart, source.length);
3947
+ return source.length;
3948
+ };
3949
+
3950
+ const scanCode = (start, templateExpression = false) => {
3951
+ let expectExpression = true;
3952
+ let expectStatement = !templateExpression;
3953
+ let pendingControl = null;
3954
+ let templateBraceDepth = templateExpression ? 1 : 0;
3955
+ const parens = [];
3956
+ const braces = [];
3957
+
3958
+ for (let index = start; index < source.length;) {
3959
+ const character = source[index];
3960
+ if (/\s/.test(character)) { index++; continue; }
3961
+
3962
+ if (character === '/' && source[index + 1] === '/') {
3963
+ const newline = source.indexOf('\n', index + 2);
3964
+ const end = newline < 0 ? source.length : newline;
3965
+ blank(index, end);
3966
+ index = end;
3967
+ continue;
3968
+ }
3969
+ if (character === '/' && source[index + 1] === '*') {
3970
+ const close = source.indexOf('*/', index + 2);
3971
+ const end = close < 0 ? source.length : close + 2;
3972
+ blank(index, end);
3973
+ index = end;
3974
+ continue;
3975
+ }
3976
+ if (character === "'" || character === '"') {
3977
+ const end = quotedEnd(index, character);
3978
+ blank(index + 1, end);
3979
+ index = end < source.length ? end + 1 : end;
3980
+ expectExpression = false;
3981
+ expectStatement = false;
3982
+ pendingControl = null;
3983
+ continue;
3984
+ }
3985
+ if (character === '`') {
3986
+ const end = scanTemplate(index);
3987
+ if (!preserveTemplateExpressions) blank(index + 1, end);
3988
+ index = end < source.length ? end + 1 : end;
3989
+ expectExpression = false;
3990
+ expectStatement = false;
3991
+ pendingControl = null;
3992
+ continue;
3993
+ }
3994
+ if (character === '/') {
3995
+ if (source[index + 1] === '=') {
3996
+ index += 2;
3997
+ expectExpression = true;
3998
+ expectStatement = false;
3999
+ pendingControl = null;
4000
+ continue;
4001
+ }
4002
+ if (expectExpression) {
4003
+ const end = regexLiteralEnd(source, index);
4004
+ if (end > index) {
4005
+ blank(index, end);
4006
+ index = end;
4007
+ expectExpression = false;
4008
+ expectStatement = false;
4009
+ pendingControl = null;
4010
+ continue;
4011
+ }
4012
+ }
4013
+ index++;
4014
+ expectExpression = true;
4015
+ expectStatement = false;
4016
+ pendingControl = null;
4017
+ continue;
4018
+ }
4019
+ if (/[A-Za-z_$]/.test(character)) {
4020
+ let end = index + 1;
4021
+ while (/[\w$]/.test(source[end] ?? '')) end++;
4022
+ const word = source.slice(index, end);
4023
+ if (word === 'await' && pendingControl === 'for') {
4024
+ expectExpression = true;
4025
+ expectStatement = false;
4026
+ } else if (controlParens.has(word)) {
4027
+ pendingControl = word;
4028
+ expectExpression = true;
4029
+ expectStatement = false;
4030
+ } else {
4031
+ pendingControl = null;
4032
+ expectExpression = expressionKeywords.has(word);
4033
+ expectStatement = word === 'do' || word === 'else';
4034
+ }
4035
+ index = end;
4036
+ continue;
4037
+ }
4038
+ if (/\d/.test(character)) {
4039
+ index++;
4040
+ while (/[\w.]/.test(source[index] ?? '')) index++;
4041
+ expectExpression = false;
4042
+ expectStatement = false;
4043
+ pendingControl = null;
4044
+ continue;
4045
+ }
4046
+ if (character === '(') {
4047
+ parens.push(pendingControl);
4048
+ pendingControl = null;
4049
+ expectExpression = true;
4050
+ expectStatement = false;
4051
+ index++;
4052
+ continue;
4053
+ }
4054
+ if (character === ')') {
4055
+ const control = parens.pop();
4056
+ expectExpression = controlBodies.has(control);
4057
+ expectStatement = controlBodies.has(control);
4058
+ pendingControl = null;
4059
+ index++;
4060
+ continue;
4061
+ }
4062
+ if (character === '{') {
4063
+ braces.push(expectStatement);
4064
+ if (templateExpression) templateBraceDepth++;
4065
+ expectExpression = true;
4066
+ expectStatement = true;
4067
+ pendingControl = null;
4068
+ index++;
4069
+ continue;
4070
+ }
4071
+ if (character === '}') {
4072
+ if (templateExpression && --templateBraceDepth === 0) return index + 1;
4073
+ const block = braces.pop() ?? false;
4074
+ expectExpression = block;
4075
+ expectStatement = block;
4076
+ pendingControl = null;
4077
+ index++;
4078
+ continue;
4079
+ }
4080
+ if (character === '[') {
4081
+ expectExpression = true;
4082
+ expectStatement = false;
4083
+ pendingControl = null;
4084
+ index++;
4085
+ continue;
4086
+ }
4087
+ if (character === ']') {
4088
+ expectExpression = false;
4089
+ expectStatement = false;
4090
+ pendingControl = null;
4091
+ index++;
4092
+ continue;
4093
+ }
4094
+ if ((character === '+' || character === '-') && source[index + 1] === character) {
4095
+ index += 2;
4096
+ pendingControl = null;
4097
+ continue;
4098
+ }
4099
+ if ('=,:;!?&|+-*%^~<>'.includes(character)) {
4100
+ expectExpression = true;
4101
+ expectStatement = character === ';' && parens.length === 0;
4102
+ pendingControl = null;
4103
+ index++;
4104
+ continue;
4105
+ }
4106
+ if (character === '.') {
4107
+ expectExpression = false;
4108
+ expectStatement = false;
4109
+ pendingControl = null;
4110
+ index++;
4111
+ continue;
4112
+ }
4113
+ pendingControl = null;
4114
+ index++;
4115
+ }
4116
+ return source.length;
4117
+ };
4118
+
4119
+ scanCode(0);
4120
+ return output.join('');
4121
+ }
4122
+
2914
4123
  function isClearlyMismatchedComponent(directory, moduleName) {
2915
4124
  const target = moduleName.slice('snice-'.length);
2916
4125
  const topLevel = new Set([
@@ -2950,10 +4159,11 @@ function parseNamedImportEntries(clause) {
2950
4159
  return braces[1]
2951
4160
  .split(',')
2952
4161
  .map(value => {
4162
+ const typeOnly = /^type\s+/.test(value.trim());
2953
4163
  const cleaned = value.trim().replace(/^type\s+/, '');
2954
4164
  if (!cleaned) return null;
2955
4165
  const [imported, local = imported] = cleaned.split(/\s+as\s+/);
2956
- return { imported: imported.trim(), local: local.trim() };
4166
+ return { imported: imported.trim(), local: local.trim(), typeOnly };
2957
4167
  })
2958
4168
  .filter(Boolean);
2959
4169
  }
@@ -2984,101 +4194,30 @@ function importHasProjectUse(source, entry) {
2984
4194
  }
2985
4195
 
2986
4196
  function hasCodeIdentifier(source, target) {
2987
- const stack = [{ kind: 'code', templateExpression: false, braces: 0 }];
2988
- let index = 0;
2989
- while (index < source.length) {
2990
- const frame = stack.at(-1);
2991
- const character = source[index];
2992
-
2993
- if (frame.kind === 'quoted') {
2994
- if (character === '\\') index += 2;
2995
- else {
2996
- index++;
2997
- if (character === frame.quote) stack.pop();
2998
- }
2999
- continue;
3000
- }
3001
- if (frame.kind === 'regex') {
3002
- if (character === '\\') {
3003
- index += 2;
3004
- } else if (character === '[') {
3005
- frame.characterClass = true;
3006
- index++;
3007
- } else if (character === ']' && frame.characterClass) {
3008
- frame.characterClass = false;
3009
- index++;
3010
- } else if (character === '/' && !frame.characterClass) {
3011
- index++;
3012
- while (/[A-Za-z]/.test(source[index] ?? '')) index++;
3013
- stack.pop();
3014
- } else {
3015
- index++;
3016
- }
3017
- continue;
3018
- }
3019
- if (frame.kind === 'template') {
3020
- if (character === '\\') {
3021
- index += 2;
3022
- } else if (character === '`') {
3023
- stack.pop();
3024
- index++;
3025
- } else if (character === '$' && source[index + 1] === '{') {
3026
- stack.push({ kind: 'code', templateExpression: true, braces: 1 });
3027
- index += 2;
3028
- } else {
3029
- index++;
3030
- }
3031
- continue;
3032
- }
4197
+ const code = maskNonExecutableCode(source, { preserveTemplateExpressions: true });
4198
+ return new RegExp(`(?:^|[^\\w$])${escapeRegExp(target)}(?![\\w$])`).test(code);
4199
+ }
3033
4200
 
3034
- if (character === "'" || character === '"') {
3035
- stack.push({ kind: 'quoted', quote: character });
3036
- index++;
3037
- continue;
3038
- }
3039
- if (character === '`') {
3040
- stack.push({ kind: 'template' });
3041
- index++;
3042
- continue;
3043
- }
3044
- if (character === '/' && startsRegexLiteral(source, index)) {
3045
- stack.push({ kind: 'regex', characterClass: false });
3046
- index++;
3047
- continue;
3048
- }
3049
- if (frame.templateExpression && character === '{') {
3050
- frame.braces++;
3051
- index++;
3052
- continue;
3053
- }
3054
- if (frame.templateExpression && character === '}') {
3055
- frame.braces--;
3056
- index++;
3057
- if (frame.braces === 0) stack.pop();
4201
+ function regexLiteralEnd(source, slashIndex) {
4202
+ let characterClass = false;
4203
+ for (let index = slashIndex + 1; index < source.length; index++) {
4204
+ const character = source[index];
4205
+ if (character === '\n' || character === '\r') return -1;
4206
+ if (character === '\\') { index++; continue; }
4207
+ if (character === '[' && !characterClass) {
4208
+ characterClass = true;
3058
4209
  continue;
3059
4210
  }
3060
- if (/[A-Za-z_$]/.test(character)) {
3061
- let end = index + 1;
3062
- while (/[\w$]/.test(source[end] ?? '')) end++;
3063
- if (source.slice(index, end) === target) return true;
3064
- index = end;
4211
+ if (character === ']' && characterClass) {
4212
+ characterClass = false;
3065
4213
  continue;
3066
4214
  }
4215
+ if (character !== '/' || characterClass) continue;
3067
4216
  index++;
4217
+ while (/[A-Za-z]/.test(source[index] ?? '')) index++;
4218
+ return index;
3068
4219
  }
3069
- return false;
3070
- }
3071
-
3072
- function startsRegexLiteral(source, slashIndex) {
3073
- let previous = slashIndex - 1;
3074
- while (previous >= 0 && /\s/.test(source[previous])) previous--;
3075
- if (previous < 0 || /[([{=:;,!?&|+*%^~<>-]/.test(source[previous])) return true;
3076
- if (!/[\w$]/.test(source[previous])) return false;
3077
- let start = previous;
3078
- while (start > 0 && /[\w$]/.test(source[start - 1])) start--;
3079
- return /^(?:return|case|throw|else|do|typeof|instanceof|in|of|yield|await|void|delete)$/.test(
3080
- source.slice(start, previous + 1)
3081
- );
4220
+ return -1;
3082
4221
  }
3083
4222
 
3084
4223
  function reactExportReplacement(name) {
@@ -3093,13 +4232,18 @@ function reactExportReplacement(name) {
3093
4232
  }
3094
4233
 
3095
4234
  function buildSourceProvenance(source) {
4235
+ const code = maskNonExecutableCode(source);
3096
4236
  const imports = findImports(source);
3097
4237
  const rootBindings = new Map();
4238
+ const namespaceBindings = new Map();
3098
4239
  const reactBindings = [];
3099
4240
  const componentVariables = new Map();
3100
4241
  for (const entry of imports) {
3101
- if (entry.path === 'snice') {
4242
+ const namespace = /^\*\s+as\s+([A-Za-z_$][\w$]*)$/.exec(entry.clause ?? '');
4243
+ if (namespace && !entry.typeOnly) namespaceBindings.set(namespace[1], entry.path);
4244
+ if (entry.path === 'snice' && !entry.typeOnly) {
3102
4245
  for (const binding of parseNamedImportEntries(entry.clause)) {
4246
+ if (binding.typeOnly) continue;
3103
4247
  const list = rootBindings.get(binding.imported) ?? [];
3104
4248
  list.push({ ...binding, index: entry.index, path: entry.path });
3105
4249
  rootBindings.set(binding.imported, list);
@@ -3121,7 +4265,7 @@ function buildSourceProvenance(source) {
3121
4265
  for (const match of source.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document\.)?querySelector(?:<[^>]+>)?\(\s*['"](snice-[a-z0-9-]+)['"]\s*\)/g)) {
3122
4266
  if (COMPONENT_CONTRACTS[match[2]]) componentVariables.set(match[1], match[2]);
3123
4267
  }
3124
- return { imports, rootBindings, reactBindings, componentVariables };
4268
+ return { source: code, imports, rootBindings, namespaceBindings, reactBindings, componentVariables };
3125
4269
  }
3126
4270
 
3127
4271
  function normalizeReactExport(name) {