@colixsystems/widget-sdk 0.43.0 → 0.45.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/README.md CHANGED
@@ -49,7 +49,25 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
49
49
 
50
50
  ## Status
51
51
 
52
- `v0.43.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
52
+ `v0.45.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
53
+
54
+ ### What's new in 0.45.0
55
+
56
+ **New linter rule `lucide-unknown-icon` (sc-1366).** `lucide-react-native` is a vetted import the bundler **externalises**, so esbuild never checks the *named* imports — a widget that imports an icon name absent from the pinned `lucide-react-native@0.368.0` (e.g. `import { House } from "lucide-react-native"`, when that version only ships `Home` — `House` was added to lucide later) bundles and publishes cleanly, then fails only at **runtime load** ("does not provide an export named 'House'") and renders blank. The linter now rejects such imports at publish so the failure becomes a repair-loop finding instead of a broken shipped widget. The valid set is committed data (`src/lucideIconNames.{cjs,js}` — the 1451 base export names + the generated-from `LUCIDE_VERSION`) because the linter runs in zero-dependency contexts where `lucide-react-native` (and its `react-native` peer) is not installed; alias forms (`<Name>Icon`, `Lucide<Name>`) are normalised. Regenerate after a lucide bump with `node scripts/generate-lucide-icon-names.cjs`; a test asserts `LUCIDE_VERSION` matches the frontend pin so the set can't go stale. The `CONTRACT` object is unchanged (no new field), so `CONTRACT.version` stays at `1.32.0`. The author/agent-facing mirror of this rule is the "Icons" note in the AI agent's `DEFAULT_SYSTEM_PROMPT` (shipped in the companion sc-1273/sc-1362 prompt PR).
57
+
58
+ ### What's new in 0.44.0
59
+
60
+ **AI-generated widgets are bundled at publish (sc-1265).** Until this release the AI agent's publish path served the validated LLM source **verbatim** (transpiled, not bundled), which meant an AI widget could only import the host-shimmed specifiers — a draft that imported, say, `react-native-gesture-handler` published cleanly but threw "unresolvable bare imports" at load. Marketplace widgets, in contrast, are esbuild-**bundled** by the publish packer and get the full vetted-import surface. This release closes that gap: every AI publish now ships through the **`@appstudio/widget-bundler`** sidecar (`services/widget-bundler` — internal HTTP service that wraps the SAME `bundleWebEntry` the marketplace packer uses), so the bundled output is byte-shape identical regardless of who published.
61
+
62
+ - **`react-native` is now host-resolved on web.** A bundled widget — AI or marketplace — that imports `react-native` (directly, or via a vetted RN package like `react-native-gesture-handler` that imports it internally) leaves the specifier as a bare import; the Studio loader shims it to the host's **single** `react-native-web` instance. That keeps the bundle small (gesture-handler probes dropped from 762 KB to ~248 KB) and avoids two RN-web copies in the same page (`StyleSheet` + context conflicts).
63
+ - **`hostExternalSpecifiers()` grew `react-native`.** The canonical "host-resolved at runtime" set the bundler externalises now lists react family + `react-dom` + `react-dom/client` + `@colixsystems/widget-sdk` + the vetted shimmed packages (lucide / svg / date-fns) + `react-native`.
64
+ - **`bundleWebEntry` resolves RN web builds.** `resolveExtensions` is `.web.js`-first, `mainFields` is `[browser, module, main]`, and the `browser` export condition is active — so esbuild picks the web build of an RN-shape package automatically. No alias step; `react-native` stays external.
65
+ - **Contract description fix.** The vetted-imports entry for `react-native` previously claimed "the host bundler aliases this to react-native-web" — that was never true and the misclaim leaked into the AI system prompt. The description now reads: "`react-native` is host-resolved to the host's single react-native-web instance (external, shimmed) — NOT aliased at bundle time".
66
+ - **AI publish storage shape.** Every AI publish now writes the `bundleFiles` JSONB column with the bundled web entry under `widget.web.jsx` and the transpiled RN source under `widget.native.jsx`. `bundleSource` (the legacy single-file column) stays NULL on AI rows; `resolveWebBundleSource` / `resolveNativeBundleSource` pick the right file at load time.
67
+ - **Bundle failure → repair loop.** A bundler error is now a `bundle.web` finding the existing publish repair loop folds into the next attempt's prompt, so the model can self-correct a draft whose imports the bundler refused.
68
+ - **`CONTRACT.version` → `1.32.0`** (additive: the `react-native` host-resolution behaviour + a corrected vetted-imports description; the SET of vetted imports is unchanged).
69
+
70
+ **Vetted `@shopify/react-native-skia` for canvas-style graphics & games (sc-1270).** Widgets that need true 2D/GPU canvas drawing (games, custom visualisations) can now `import` Skia. It is **native-only** (`platforms: ["native"]`, like `react-native-maps` / `lottie-react-native`): author it in `widget.native.jsx` and pair it with a web variant in `widget.web.jsx` — a browser `<canvas>` or `react-native-svg`. This closes the gap where a raw `<canvas>` rendered in the web Player but crashed the Expo export ("View config getter callback for component `canvas` … received undefined") because React Native has no `<canvas>`. The compiler pins `@shopify/react-native-skia` in the exported app's `package.json`; no host shim is added (native-only packages are never shimmed, and there is no Skia web build wired into the Player). Unified Skia-on-web (CanvasKit/WASM) is a documented follow-up. Additive — `CONTRACT.version` bumped to 1.31.0.
53
71
 
54
72
  ### What's new in 0.43.0
55
73
 
@@ -484,6 +502,46 @@ The manifest declares the matching scope:
484
502
 
485
503
  The server-side gate is `canGrant` on the target record — Studio owners pass automatically; an APP_USER holds `canGrant` as the record's creator or via a delegated grant. A caller without `canGrant` receives `PermissionError { code: "FORBIDDEN" }`. The hook collapses to a stable no-op when `tableId` or `recordId` is null/empty — so a widget can render its picker first, then bind to the picked record without conditional hook tricks.
486
504
 
505
+ ## Cross-platform widgets (single-file vs split)
506
+
507
+ Every widget runs in **both** the web Player and the exported native (Expo) app.
508
+ There are two ways to author one, and the **file set you ship decides
509
+ `supportedPlatforms`** — you don't hand-declare it, the platform derives it.
510
+
511
+ **1. Single file — `widget.jsx` (the default).** Works on web AND native. Pick
512
+ this when the widget needs only the SDK primitives + hooks, or only packages
513
+ that support both platforms (every `["web", "native"]` entry in
514
+ `CONTRACT.vettedImports` — `react-native`, `react-native-svg`, `date-fns`,
515
+ `react-native-paper`, `@shopify/flash-list`, `react-native-reanimated`,
516
+ `react-native-gesture-handler`, `expo-linear-gradient`, …). Most widgets land
517
+ here. Derives `supportedPlatforms: ["web", "native"]`.
518
+
519
+ **2. Split implementation — `widget.web.jsx` + `widget.native.jsx`.** Pick this
520
+ when the widget needs a package that only runs on ONE platform. Each file
521
+ targets its platform and imports the package that works there; shared logic
522
+ lives in a sibling `./helper.js` both files import. This is the **canonical
523
+ pattern** for graphics, media, and any platform-divergent library:
524
+
525
+ | Capability | `widget.native.jsx` | `widget.web.jsx` |
526
+ | ---------- | ------------------- | ---------------- |
527
+ | Maps | `react-native-maps` | `react-leaflet` / `leaflet` |
528
+ | Canvas / 2D-GPU drawing | `@shopify/react-native-skia` | `<canvas>` or `react-native-svg` |
529
+ | Video | `expo-video` | the browser `<video>` element |
530
+ | Audio | `expo-audio` | the browser `<audio>` element |
531
+ | Lottie animation | `lottie-react-native` | `lottie-react` |
532
+ | Embedded web content | `react-native-webview` | an `<iframe>` |
533
+
534
+ A few native-only packages (`@react-native-community/datetimepicker`,
535
+ `expo-clipboard`) are already wrapped by an SDK primitive/hook
536
+ (`<DateTimePicker>`, `useClipboard()`) that resolves the right implementation on
537
+ each platform — reach for those first and you stay single-file.
538
+
539
+ **The linter enforces honesty at publish.** A single-file widget that imports a
540
+ native-only package while claiming web (or vice-versa) fails the
541
+ `import-platform-mismatch` rule — move the import into the file that targets its
542
+ platform, or ship the split pair. So you can't accidentally publish a widget
543
+ that renders on one platform and blanks on the other.
544
+
487
545
  ## Linter
488
546
 
489
547
  ```sh
package/dist/contract.cjs CHANGED
@@ -1140,7 +1140,7 @@ const VETTED_IMPORTS = [
1140
1140
  platforms: ["web", "native"],
1141
1141
  category: "primitive",
1142
1142
  description:
1143
- "Direct RN imports for APIs the SDK hasn't re-exported yet. On web the host bundler aliases this to react-native-web; on native Metro resolves the real library.",
1143
+ "Direct RN imports for APIs the SDK hasn't re-exported yet. On web `react-native` is host-resolved to the host's single react-native-web instance (external, shimmed) — NOT aliased at bundle time — so a bundled widget shares the host's RN-web (one StyleSheet/context, no double-instance conflicts). On native Metro resolves the real library.",
1144
1144
  },
1145
1145
  {
1146
1146
  specifier: "axios",
@@ -1162,6 +1162,13 @@ const VETTED_IMPORTS = [
1162
1162
  description:
1163
1163
  "Cross-platform SVG drawing primitives. Used by the built-in Chart widget; works on both platforms.",
1164
1164
  },
1165
+ {
1166
+ specifier: "@shopify/react-native-skia",
1167
+ platforms: ["native"],
1168
+ category: "drawing",
1169
+ description:
1170
+ "Canvas-style 2D/GPU drawing & animation (games, custom graphics) on native. Native-only — author it in widget.native.jsx and pair it with a web variant in widget.web.jsx (a <canvas> or react-native-svg). There is no Skia web build wired into the Player.",
1171
+ },
1165
1172
  {
1166
1173
  specifier: "lucide-react-native",
1167
1174
  platforms: ["web", "native"],
@@ -1658,7 +1665,23 @@ const CONTRACT = deepFreeze({
1658
1665
  // hosts) and unwraps `{ data, meta }`. New required field on the
1659
1666
  // `assets` context slice: `list: "function"`. No existing hook,
1660
1667
  // primitive, manifest field, or token changed shape — minor bump.
1661
- version: "1.30.0",
1668
+ //
1669
+ // 1.31.0: additive (sc-1270) — vetted `@shopify/react-native-skia` for
1670
+ // canvas-style 2D/GPU drawing & games. Native-only (platforms:
1671
+ // ["native"]); authors pair it with a <canvas>/react-native-svg web
1672
+ // variant in widget.web.jsx. Pinned in the compiler's export
1673
+ // package.json; no host shim (native-only is never shimmed). No hook,
1674
+ // primitive, manifest field, or token changed shape — minor bump.
1675
+ //
1676
+ // 1.32.0: behaviour (sc-1265) — `react-native` is now host-resolved on web
1677
+ // as an EXTERNAL, loader-shimmed to the host's single react-native-web
1678
+ // instance, instead of being aliased at bundle time. AI publishes now
1679
+ // bundle through the widget-bundler sidecar (byte-shape identical to the
1680
+ // marketplace packer), so a bundled widget shares the host's one RN-web
1681
+ // instance — one StyleSheet/context, no double-instance conflicts. The
1682
+ // vetted-import SET is unchanged (react-native was already vetted); only
1683
+ // its web resolution + description changed — minor bump.
1684
+ version: "1.32.0",
1662
1685
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
1663
1686
  hooks: HOOKS,
1664
1687
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1140,7 +1140,7 @@ const VETTED_IMPORTS = [
1140
1140
  platforms: ["web", "native"],
1141
1141
  category: "primitive",
1142
1142
  description:
1143
- "Direct RN imports for APIs the SDK hasn't re-exported yet. On web the host bundler aliases this to react-native-web; on native Metro resolves the real library.",
1143
+ "Direct RN imports for APIs the SDK hasn't re-exported yet. On web `react-native` is host-resolved to the host's single react-native-web instance (external, shimmed) — NOT aliased at bundle time — so a bundled widget shares the host's RN-web (one StyleSheet/context, no double-instance conflicts). On native Metro resolves the real library.",
1144
1144
  },
1145
1145
  {
1146
1146
  specifier: "axios",
@@ -1162,6 +1162,13 @@ const VETTED_IMPORTS = [
1162
1162
  description:
1163
1163
  "Cross-platform SVG drawing primitives. Used by the built-in Chart widget; works on both platforms.",
1164
1164
  },
1165
+ {
1166
+ specifier: "@shopify/react-native-skia",
1167
+ platforms: ["native"],
1168
+ category: "drawing",
1169
+ description:
1170
+ "Canvas-style 2D/GPU drawing & animation (games, custom graphics) on native. Native-only — author it in widget.native.jsx and pair it with a web variant in widget.web.jsx (a <canvas> or react-native-svg). There is no Skia web build wired into the Player.",
1171
+ },
1165
1172
  {
1166
1173
  specifier: "lucide-react-native",
1167
1174
  platforms: ["web", "native"],
@@ -1658,7 +1665,23 @@ const CONTRACT = deepFreeze({
1658
1665
  // hosts) and unwraps `{ data, meta }`. New required field on the
1659
1666
  // `assets` context slice: `list: "function"`. No existing hook,
1660
1667
  // primitive, manifest field, or token changed shape — minor bump.
1661
- version: "1.30.0",
1668
+ //
1669
+ // 1.31.0: additive (sc-1270) — vetted `@shopify/react-native-skia` for
1670
+ // canvas-style 2D/GPU drawing & games. Native-only (platforms:
1671
+ // ["native"]); authors pair it with a <canvas>/react-native-svg web
1672
+ // variant in widget.web.jsx. Pinned in the compiler's export
1673
+ // package.json; no host shim (native-only is never shimmed). No hook,
1674
+ // primitive, manifest field, or token changed shape — minor bump.
1675
+ //
1676
+ // 1.32.0: behaviour (sc-1265) — `react-native` is now host-resolved on web
1677
+ // as an EXTERNAL, loader-shimmed to the host's single react-native-web
1678
+ // instance, instead of being aliased at bundle time. AI publishes now
1679
+ // bundle through the widget-bundler sidecar (byte-shape identical to the
1680
+ // marketplace packer), so a bundled widget shares the host's one RN-web
1681
+ // instance — one StyleSheet/context, no double-instance conflicts. The
1682
+ // vetted-import SET is unchanged (react-native was already vetted); only
1683
+ // its web resolution + description changed — minor bump.
1684
+ version: "1.32.0",
1662
1685
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
1663
1686
  hooks: HOOKS,
1664
1687
  primitives: PRIMITIVES,
package/dist/dev-shims.js CHANGED
@@ -107,6 +107,12 @@ const HOST_EXTERNAL_SPECIFIERS = [
107
107
  "lucide-react-native",
108
108
  "react-native-svg",
109
109
  "date-fns",
110
+ // sc-1265: a bundled widget (and any vetted RN package it inlines, e.g.
111
+ // react-native-gesture-handler) imports `react-native`; the host resolves it
112
+ // to its OWN single react-native-web instance through a blob shim, so the
113
+ // bundle shares one RN-web (StyleSheet/context) instead of inlining a second
114
+ // copy. On native, Metro resolves the real react-native in the export.
115
+ "react-native",
110
116
  ];
111
117
 
112
118
  /**
package/dist/linter.cjs CHANGED
@@ -11,6 +11,7 @@
11
11
  "use strict";
12
12
 
13
13
  const { CONTRACT } = require("./contract.cjs");
14
+ const { LUCIDE_ICON_NAMES, LUCIDE_VERSION } = require("./lucideIconNames.cjs");
14
15
 
15
16
  function _ruleForIdentifier(identifier, reason) {
16
17
  const id = identifier;
@@ -513,6 +514,63 @@ function _manifestActionRules(manifest) {
513
514
  return findings;
514
515
  }
515
516
 
517
+ // sc-1366 — invalid lucide icon import gate. lucide-react-native is a vetted
518
+ // import that the bundler EXTERNALISES, so esbuild never checks the named
519
+ // imports; a name that does not exist in the pinned version (`House` was added
520
+ // to lucide AFTER the pinned 0.368.0, which only has `Home`) bundles + publishes
521
+ // cleanly and fails only at runtime load ("does not provide an export named
522
+ // 'House'"), leaving the widget blank. This rule rejects such imports at publish
523
+ // so the agent's repair loop fixes them. The valid set is committed data
524
+ // (lucideIconNames.cjs) because the linter runs where lucide is not installed.
525
+ const LUCIDE_NAME_SET = new Set(LUCIDE_ICON_NAMES);
526
+ const LUCIDE_IMPORT_RE =
527
+ /import\s+(?:[A-Za-z0-9_$]+\s*,\s*)?\{([\s\S]*?)\}\s*from\s*["']lucide-react-native["']/g;
528
+
529
+ // lucide re-exports each base icon as `<Name>`, `<Name>Icon`, and `Lucide<Name>`;
530
+ // the committed set holds only the base names, so normalise the alias forms
531
+ // before the membership check. No base name ends in "Icon" or starts with
532
+ // "Lucide" (asserted by lucide-icon-names.test.js), so stripping is unambiguous.
533
+ function _isValidLucideName(name) {
534
+ if (LUCIDE_NAME_SET.has(name)) return true;
535
+ if (name.endsWith("Icon") && LUCIDE_NAME_SET.has(name.slice(0, -4))) return true;
536
+ if (name.startsWith("Lucide") && LUCIDE_NAME_SET.has(name.slice(6))) return true;
537
+ return false;
538
+ }
539
+
540
+ function _lucideIconRules(source) {
541
+ const findings = [];
542
+ const sourceLines = source.split(/\r?\n/);
543
+ LUCIDE_IMPORT_RE.lastIndex = 0;
544
+ let m;
545
+ while ((m = LUCIDE_IMPORT_RE.exec(source))) {
546
+ const line = source.slice(0, m.index).split(/\r?\n/).length;
547
+ for (const raw of (m[1] || "").split(",")) {
548
+ // Strip any inline comment from the specifier token so a `{ Home, // x`
549
+ // comment can't glue onto and hide the next specifier (e.g. `House`).
550
+ const cleaned = raw
551
+ .replace(/\/\*[\s\S]*?\*\//g, "")
552
+ .replace(/\/\/[^\n]*/g, "");
553
+ const imported = cleaned.trim().split(/\s+as\s+/)[0].trim();
554
+ if (!imported || imported === "type") continue;
555
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(imported)) continue;
556
+ if (_isValidLucideName(imported)) continue;
557
+ findings.push({
558
+ rule: "lucide-unknown-icon",
559
+ severity: "error",
560
+ label:
561
+ `lucide-react-native has no export "${imported}" in the pinned ` +
562
+ `lucide-react-native@${LUCIDE_VERSION}. The widget bundles cleanly but FAILS at ` +
563
+ `runtime load ("does not provide an export named '${imported}'") and never ` +
564
+ `renders — replace it with an icon name that exists in that version (lucide ` +
565
+ `adds and renames icons across releases, so a newer name may not exist here).`,
566
+ line,
567
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
568
+ });
569
+ }
570
+ }
571
+ return findings;
572
+ }
573
+
516
574
  function lintSource(source, options) {
517
575
  if (typeof source !== "string") {
518
576
  return {
@@ -557,6 +615,7 @@ function lintSource(source, options) {
557
615
  })),
558
616
  );
559
617
  findings.push(..._hostApiUrlRules(source));
618
+ findings.push(..._lucideIconRules(source));
560
619
  findings.push(
561
620
  ..._scopeRules(source, options && options.manifest).map((f) => ({
562
621
  ...f,
package/dist/linter.js CHANGED
@@ -7,6 +7,7 @@
7
7
  // system prompt, the linter, and the runtime allowlist agree.
8
8
 
9
9
  import { CONTRACT } from "./contract.js";
10
+ import { LUCIDE_ICON_NAMES, LUCIDE_VERSION } from "./lucideIconNames.js";
10
11
 
11
12
  // Per-identifier match rule. Most banned identifiers compile to a
12
13
  // whole-word match; a few have special syntax (`Function(`, `new Function`,
@@ -601,6 +602,63 @@ function _manifestActionRules(manifest) {
601
602
  return findings;
602
603
  }
603
604
 
605
+ // sc-1366 — invalid lucide icon import gate. lucide-react-native is a vetted
606
+ // import that the bundler EXTERNALISES, so esbuild never checks the named
607
+ // imports; a name that does not exist in the pinned version (`House` was added
608
+ // to lucide AFTER the pinned 0.368.0, which only has `Home`) bundles + publishes
609
+ // cleanly and fails only at runtime load ("does not provide an export named
610
+ // 'House'"), leaving the widget blank. This rule rejects such imports at publish
611
+ // so the agent's repair loop fixes them. The valid set is committed data
612
+ // (lucideIconNames.js) because the linter runs where lucide is not installed.
613
+ const LUCIDE_NAME_SET = new Set(LUCIDE_ICON_NAMES);
614
+ const LUCIDE_IMPORT_RE =
615
+ /import\s+(?:[A-Za-z0-9_$]+\s*,\s*)?\{([\s\S]*?)\}\s*from\s*["']lucide-react-native["']/g;
616
+
617
+ // lucide re-exports each base icon as `<Name>`, `<Name>Icon`, and `Lucide<Name>`;
618
+ // the committed set holds only the base names, so normalise the alias forms
619
+ // before the membership check. No base name ends in "Icon" or starts with
620
+ // "Lucide" (asserted by lucide-icon-names.test.js), so stripping is unambiguous.
621
+ function _isValidLucideName(name) {
622
+ if (LUCIDE_NAME_SET.has(name)) return true;
623
+ if (name.endsWith("Icon") && LUCIDE_NAME_SET.has(name.slice(0, -4))) return true;
624
+ if (name.startsWith("Lucide") && LUCIDE_NAME_SET.has(name.slice(6))) return true;
625
+ return false;
626
+ }
627
+
628
+ function _lucideIconRules(source) {
629
+ const findings = [];
630
+ const sourceLines = source.split(/\r?\n/);
631
+ LUCIDE_IMPORT_RE.lastIndex = 0;
632
+ let m;
633
+ while ((m = LUCIDE_IMPORT_RE.exec(source))) {
634
+ const line = source.slice(0, m.index).split(/\r?\n/).length;
635
+ for (const raw of (m[1] || "").split(",")) {
636
+ // Strip any inline comment from the specifier token so a `{ Home, // x`
637
+ // comment can't glue onto and hide the next specifier (e.g. `House`).
638
+ const cleaned = raw
639
+ .replace(/\/\*[\s\S]*?\*\//g, "")
640
+ .replace(/\/\/[^\n]*/g, "");
641
+ const imported = cleaned.trim().split(/\s+as\s+/)[0].trim();
642
+ if (!imported || imported === "type") continue;
643
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(imported)) continue;
644
+ if (_isValidLucideName(imported)) continue;
645
+ findings.push({
646
+ rule: "lucide-unknown-icon",
647
+ severity: "error",
648
+ label:
649
+ `lucide-react-native has no export "${imported}" in the pinned ` +
650
+ `lucide-react-native@${LUCIDE_VERSION}. The widget bundles cleanly but FAILS at ` +
651
+ `runtime load ("does not provide an export named '${imported}'") and never ` +
652
+ `renders — replace it with an icon name that exists in that version (lucide ` +
653
+ `adds and renames icons across releases, so a newer name may not exist here).`,
654
+ line,
655
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
656
+ });
657
+ }
658
+ }
659
+ return findings;
660
+ }
661
+
604
662
  export function lintSource(source, options) {
605
663
  if (typeof source !== "string") {
606
664
  return {
@@ -647,6 +705,7 @@ export function lintSource(source, options) {
647
705
  );
648
706
  // REQ-WSDK-PLATFORM §3.5: soft host-API URL warning (does not block).
649
707
  findings.push(..._hostApiUrlRules(source));
708
+ findings.push(..._lucideIconRules(source));
650
709
  // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
651
710
  // line-by-line scan so banned-identifier findings stay first in the
652
711
  // output.