@colixsystems/widget-sdk 0.14.1 → 0.15.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/dist/contract.js CHANGED
@@ -241,6 +241,42 @@ const HOOKS = [
241
241
  ],
242
242
  scopes: ["groups.read:*"],
243
243
  },
244
+ // REQ-WSDK-PLATFORM §6 — Tier A SDK hooks.
245
+ {
246
+ name: "useClipboard",
247
+ signature: "useClipboard()",
248
+ description:
249
+ "Cross-platform clipboard access. Returns { copy, paste, hasContent }. " +
250
+ "All methods return Promises; rejections surface a structured ClipboardError " +
251
+ "with .code in PERMISSION_DENIED | INTERNAL. On web the browser may " +
252
+ "require a user gesture for read access — surface the error to the " +
253
+ "user as a 'try again after clicking' prompt when code === PERMISSION_DENIED.",
254
+ returnShape: {
255
+ copy:
256
+ "(text: string) => Promise<void> // rejects with ClipboardError",
257
+ paste:
258
+ "() => Promise<string> // rejects with ClipboardError",
259
+ hasContent: "() => Promise<boolean> // best-effort, never throws",
260
+ },
261
+ requiredContextSlice: [],
262
+ scopes: null,
263
+ },
264
+ {
265
+ name: "useToast",
266
+ signature: "useToast()",
267
+ description:
268
+ "Surfaces a short auto-dismissing notification. Returns { showToast }. " +
269
+ "showToast({ kind: 'success' | 'error' | 'info' | 'warning', message }) " +
270
+ "asks the host to render a workspace-themed toast. If the host hasn't " +
271
+ "wired a renderer, the web variant dispatches an 'appstudio:widget-toast' " +
272
+ "CustomEvent on window; native logs to the console. The widget never " +
273
+ "owns the toast UI — that's the host's responsibility.",
274
+ returnShape: {
275
+ showToast: "({ kind, message }) => void",
276
+ },
277
+ requiredContextSlice: ["toast.showToast"],
278
+ scopes: null,
279
+ },
244
280
  ];
245
281
 
246
282
  // REQ-WSDK-RN-WEB: see contract.cjs for the source-of-truth comment.
@@ -329,6 +365,22 @@ const PRIMITIVES = [
329
365
  rnComponent: "Linking",
330
366
  docsUrl: "https://reactnative.dev/docs/linking",
331
367
  },
368
+ // REQ-WSDK-PLATFORM §6 — Tier A SDK primitive.
369
+ {
370
+ name: "Icon",
371
+ description:
372
+ 'Lucide icon. `<Icon name="check" size={16} color="..." />`. Unknown names render the Square fallback so the canvas always shows something visible. Names are the lucide icon ids (`https://lucide.dev/icons`). Works on both web and native.',
373
+ rnComponent: "lucide-react-native",
374
+ docsUrl: "https://lucide.dev/icons",
375
+ },
376
+ // REQ-WSDK-PLATFORM §6 — Tier A SDK primitive.
377
+ {
378
+ name: "DateTimePicker",
379
+ description:
380
+ 'Cross-platform date / time / datetime picker. `<DateTimePicker value={iso} onChange={iso => …} mode="date" | "time" | "datetime" />`. The value prop and the onChange callback both speak ISO 8601 strings (the datastore wire format) — authors never round-trip through `new Date()`. Web renders the browser\'s native input via react-native-web; native uses @react-native-community/datetimepicker.',
381
+ rnComponent: "@react-native-community/datetimepicker",
382
+ docsUrl: "https://github.com/react-native-datetimepicker/datetimepicker",
383
+ },
332
384
  ];
333
385
 
334
386
  const CATEGORIES = [
@@ -545,6 +597,20 @@ const WIDGET_CONTEXT_SHAPE = {
545
597
  error: "function",
546
598
  },
547
599
  },
600
+ // REQ-WSDK-PLATFORM §6 — backs useToast(). The host installs its own
601
+ // workspace-themed toast renderer here; if omitted, the SDK's useToast
602
+ // hook falls back to a CustomEvent on web / console.log on native so
603
+ // widget code still runs without a host integration.
604
+ toast: {
605
+ description:
606
+ "Optional host toast slot. { showToast({ kind, message }): void }. " +
607
+ "The host populates this to render workspace-themed notifications " +
608
+ "from any widget that calls useToast(). When omitted the SDK falls " +
609
+ "back to dispatching an 'appstudio:widget-toast' CustomEvent on web " +
610
+ "and console.log on native.",
611
+ required: false,
612
+ fields: { showToast: "function" },
613
+ },
548
614
  };
549
615
 
550
616
  const BUNDLE_EXPORT_CONTRACT = [
@@ -567,6 +633,9 @@ const BUNDLE_EXPORT_CONTRACT = [
567
633
  },
568
634
  ];
569
635
 
636
+ // REQ-WSDK-PLATFORM (docs/design/req-widget-sdk-cross-platform-primitives.md
637
+ // §3.5, §8): `fetch` and `XMLHttpRequest` are NOT banned. See contract.cjs
638
+ // for the source-of-truth comment.
570
639
  const BANNED_APIS = [
571
640
  { identifier: "eval", reason: "Arbitrary code evaluation." },
572
641
  {
@@ -595,21 +664,119 @@ const BANNED_APIS = [
595
664
  reason: "Same reason as localStorage.",
596
665
  },
597
666
  {
598
- identifier: "fetch",
599
- reason: "Direct network calls bypass the datastore client + tenant auth.",
667
+ identifier: "import(",
668
+ reason: "Dynamic import bypasses the loader's allowlist.",
669
+ },
670
+ { identifier: "globalThis", reason: "Host environment escape." },
671
+ ];
672
+
673
+ // REQ-WSDK-PLATFORM §3.4, §5: vetted package allowlist. See contract.cjs
674
+ // for the source-of-truth comment.
675
+ const VETTED_IMPORTS = [
676
+ {
677
+ specifier: "react",
678
+ platforms: ["web", "native"],
679
+ category: "core",
680
+ description: "React. Hooks, JSX, lifecycle. Unchanged.",
600
681
  },
601
682
  {
602
- identifier: "XMLHttpRequest",
603
- reason: "Direct network calls bypass the datastore client + tenant auth.",
683
+ specifier: "@colixsystems/widget-sdk",
684
+ platforms: ["web", "native"],
685
+ category: "core",
686
+ description:
687
+ "The AppStudio widget SDK — primitives, hooks, manifest helpers. Unchanged.",
604
688
  },
605
689
  {
606
- identifier: "import(",
607
- reason: "Dynamic import bypasses the loader's allowlist.",
690
+ specifier: "react-native",
691
+ platforms: ["web", "native"],
692
+ category: "primitive",
693
+ description:
694
+ "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.",
695
+ },
696
+ {
697
+ specifier: "axios",
698
+ platforms: ["web", "native"],
699
+ category: "network",
700
+ description:
701
+ "HTTP client for third-party APIs. Calls to the host's /api/* surface are blocked at runtime — widgets get no JWT token, so use SDK hooks for workspace data.",
702
+ },
703
+ {
704
+ specifier: "date-fns",
705
+ platforms: ["web", "native"],
706
+ category: "utility",
707
+ description: "Pure-JS date math. Works on both platforms unchanged.",
708
+ },
709
+ {
710
+ specifier: "react-native-svg",
711
+ platforms: ["web", "native"],
712
+ category: "drawing",
713
+ description:
714
+ "Cross-platform SVG drawing primitives. Used by the built-in Chart widget; works on both platforms.",
715
+ },
716
+ {
717
+ specifier: "lucide-react-native",
718
+ platforms: ["web", "native"],
719
+ category: "iconography",
720
+ description:
721
+ "Lucide icon set as React components. Used by the built-in Icon widget; works on both platforms.",
722
+ },
723
+ {
724
+ specifier: "react-native-maps",
725
+ platforms: ["native"],
726
+ category: "geo",
727
+ description:
728
+ "Native map view + markers. Native-only; pair with leaflet/react-leaflet in widget.web.jsx for a web variant.",
729
+ },
730
+ {
731
+ specifier: "leaflet",
732
+ platforms: ["web"],
733
+ category: "geo",
734
+ description:
735
+ "Web-only mapping library. Use alongside react-leaflet in widget.web.jsx as the web counterpart to react-native-maps.",
736
+ },
737
+ {
738
+ specifier: "react-leaflet",
739
+ platforms: ["web"],
740
+ category: "geo",
741
+ description: "React bindings for leaflet. Web-only.",
742
+ },
743
+ {
744
+ specifier: "expo-av",
745
+ platforms: ["native"],
746
+ category: "media",
747
+ description:
748
+ "Native audio + video playback. Native-only; pair with browser <audio>/<video> in widget.web.jsx.",
749
+ },
750
+ {
751
+ specifier: "@react-native-community/datetimepicker",
752
+ platforms: ["native"],
753
+ category: "input",
754
+ description:
755
+ "Native date/time picker. The SDK's <DateTimePicker> primitive already wraps this; reach for it directly only if you need RN-specific options.",
756
+ },
757
+ {
758
+ specifier: "expo-clipboard",
759
+ platforms: ["native"],
760
+ category: "system",
761
+ description:
762
+ "Native clipboard. The SDK's useClipboard() hook already wraps this; reach for it directly only if you need RN-specific options.",
763
+ },
764
+ {
765
+ specifier: "expo-haptics",
766
+ platforms: ["native"],
767
+ category: "system",
768
+ description:
769
+ "Native haptic feedback. Pair with navigator.vibrate in widget.web.jsx.",
608
770
  },
609
- { identifier: "globalThis", reason: "Host environment escape." },
610
771
  ];
611
772
 
612
- const ALLOWED_BARE_IMPORTS = ["react", "@colixsystems/widget-sdk"];
773
+ const ALLOWED_BARE_IMPORTS = VETTED_IMPORTS.map((v) => v.specifier);
774
+
775
+ const HOST_API_URL_PATTERNS = [
776
+ "/api/v1",
777
+ "/uploads/",
778
+ "Authorization: Bearer",
779
+ ];
613
780
 
614
781
  function deepFreeze(value) {
615
782
  if (value === null || typeof value !== "object") return value;
@@ -619,7 +786,7 @@ function deepFreeze(value) {
619
786
  }
620
787
 
621
788
  const CONTRACT = deepFreeze({
622
- version: "1.4.0",
789
+ version: "1.5.0",
623
790
  hooks: HOOKS,
624
791
  primitives: PRIMITIVES,
625
792
  manifestSchema: MANIFEST_SCHEMA,
@@ -629,7 +796,9 @@ const CONTRACT = deepFreeze({
629
796
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
630
797
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
631
798
  bannedApis: BANNED_APIS,
799
+ vettedImports: VETTED_IMPORTS,
632
800
  allowedBareImports: ALLOWED_BARE_IMPORTS,
801
+ hostApiUrlPatterns: HOST_API_URL_PATTERNS,
633
802
  });
634
803
 
635
804
  function isHookAllowed(name) {
@@ -0,0 +1,102 @@
1
+ // REQ-WSDK-PLATFORM §6 — `<DateTimePicker>` SDK primitive.
2
+ //
3
+ // Cross-platform date / time / datetime picker. Wraps
4
+ // `@react-native-community/datetimepicker` (works on both web and native:
5
+ // on web it renders the browser's native `<input type="date|time">`
6
+ // surface through react-native-web's mapping). The wire format is ISO
7
+ // 8601 strings — the same format the datastore speaks, so widget authors
8
+ // never round-trip through `new Date()`.
9
+ //
10
+ // Props:
11
+ // value: string | null — ISO 8601 (`2026-05-28` for date mode,
12
+ // `2026-05-28T14:30:00.000Z` for datetime).
13
+ // `null` defaults to "now".
14
+ // onChange: (iso: string) => void
15
+ // mode: "date" | "time" | "datetime" — default "date"
16
+ // minimumDate / maximumDate: string | null — ISO bounds
17
+ // disabled: boolean
18
+ //
19
+ // The author writes:
20
+ // const [day, setDay] = useState(null);
21
+ // <DateTimePicker value={day} onChange={setDay} mode="date" />
22
+ //
23
+ // …and `day` ends up as an ISO string suitable for storing directly into
24
+ // a DATE column. The previous pattern of importing the RN library
25
+ // directly and managing `Date` objects in widget state is gone — the
26
+ // primitive normalizes both ends.
27
+
28
+ import React, { useMemo } from "react";
29
+ // eslint-disable-next-line no-restricted-syntax
30
+ import RNDateTimePicker from "@react-native-community/datetimepicker";
31
+
32
+ function _parseToDate(value) {
33
+ if (value == null || value === "") return new Date();
34
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? new Date() : value;
35
+ if (typeof value === "string") {
36
+ const d = new Date(value);
37
+ return Number.isNaN(d.getTime()) ? new Date() : d;
38
+ }
39
+ return new Date();
40
+ }
41
+
42
+ function _formatToIso(date, mode) {
43
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) return null;
44
+ if (mode === "date") {
45
+ // Local-date ISO (yyyy-mm-dd) — calendar dates should be timezone-free
46
+ // so a "May 28" picked in Stockholm doesn't read as "May 27" in NYC.
47
+ const y = date.getFullYear();
48
+ const m = String(date.getMonth() + 1).padStart(2, "0");
49
+ const d = String(date.getDate()).padStart(2, "0");
50
+ return `${y}-${m}-${d}`;
51
+ }
52
+ if (mode === "time") {
53
+ // Local-time ISO (hh:mm) — time-of-day is timezone-free for the same
54
+ // reason. Authors who want a full datetime get mode="datetime".
55
+ const h = String(date.getHours()).padStart(2, "0");
56
+ const mm = String(date.getMinutes()).padStart(2, "0");
57
+ return `${h}:${mm}`;
58
+ }
59
+ // datetime — full UTC ISO so the wire format round-trips through the
60
+ // datastore's DATE column unchanged.
61
+ return date.toISOString();
62
+ }
63
+
64
+ export function DateTimePicker({
65
+ value,
66
+ onChange,
67
+ mode,
68
+ minimumDate,
69
+ maximumDate,
70
+ disabled,
71
+ }) {
72
+ const effectiveMode = mode === "time" || mode === "datetime" ? mode : "date";
73
+ const dateValue = useMemo(() => _parseToDate(value), [value]);
74
+ const min = useMemo(
75
+ () => (minimumDate ? _parseToDate(minimumDate) : undefined),
76
+ [minimumDate],
77
+ );
78
+ const max = useMemo(
79
+ () => (maximumDate ? _parseToDate(maximumDate) : undefined),
80
+ [maximumDate],
81
+ );
82
+
83
+ const handleChange = (_event, picked) => {
84
+ if (typeof onChange !== "function") return;
85
+ if (!(picked instanceof Date)) return;
86
+ const iso = _formatToIso(picked, effectiveMode);
87
+ if (iso != null) onChange(iso);
88
+ };
89
+
90
+ // The RN library's `mode` accepts "date" / "time"; for "datetime" we
91
+ // ask for "datetime" on iOS / Android and let the picker's
92
+ // implementation handle it. react-native-web's mapping interprets
93
+ // "datetime" as `<input type="datetime-local">`.
94
+ return React.createElement(RNDateTimePicker, {
95
+ value: dateValue,
96
+ mode: effectiveMode,
97
+ minimumDate: min,
98
+ maximumDate: max,
99
+ disabled: !!disabled,
100
+ onChange: handleChange,
101
+ });
102
+ }
package/dist/hooks.js CHANGED
@@ -61,7 +61,9 @@ export function WidgetContextProvider({ value, children }) {
61
61
  return React.createElement(HostWidgetContext.Provider, { value }, children);
62
62
  }
63
63
 
64
- function useWidgetContextOrThrow(hookName) {
64
+ // Exported for ./toast.js + future hook modules outside hooks.js. Internal
65
+ // utility — widget code should not import this directly.
66
+ export function useWidgetContextOrThrow(hookName) {
65
67
  const ctx = useContext(HostWidgetContext);
66
68
  if (ctx == null) {
67
69
  throw new Error(
package/dist/icon.js ADDED
@@ -0,0 +1,29 @@
1
+ // REQ-WSDK-PLATFORM §6 — `<Icon>` SDK primitive.
2
+ //
3
+ // Wraps lucide-react-native's name-keyed component map behind a small
4
+ // stable API: `<Icon name="check" size={16} color="..." />`. Same source
5
+ // runs on both platforms — lucide-react-native ships a working web build
6
+ // (via the host's Vite shim) and a native build (Metro picks it
7
+ // straight). Unknown names fall back to the `Square` glyph so the
8
+ // canvas always shows something visible.
9
+ //
10
+ // The built-in `Icon` widget at frontend/src/components/widgets/Icon/
11
+ // already implements this exact pattern. Marketplace widgets reach for
12
+ // the SDK primitive instead of importing lucide-react-native directly,
13
+ // keeping the import-surface of a typical widget small (and avoiding the
14
+ // `import * as LucideIcons` pattern which trips eslint's
15
+ // no-restricted-syntax in some configs).
16
+
17
+ import React from "react";
18
+ import * as LucideIcons from "lucide-react-native";
19
+
20
+ export function Icon({ name, size, color }) {
21
+ const candidate =
22
+ typeof name === "string" && name.length > 0 ? LucideIcons[name] : null;
23
+ const Component = candidate || LucideIcons.Square;
24
+ const pixelSize = Number.isFinite(size) && size > 0 ? size : 24;
25
+ return React.createElement(Component, {
26
+ size: pixelSize,
27
+ color: color || "#0f172a",
28
+ });
29
+ }
package/dist/index.d.ts CHANGED
@@ -607,9 +607,29 @@ export const ActivityIndicator: any;
607
607
  export const Switch: any;
608
608
  export const StyleSheet: any;
609
609
 
610
+ /**
611
+ * REQ-WSDK-PLATFORM §6 — Lucide icon primitive. Unknown names fall back
612
+ * to the `Square` glyph so the canvas always shows something visible.
613
+ *
614
+ * @example
615
+ * import { Icon } from '@colixsystems/widget-sdk';
616
+ * <Icon name="check" size={16} color={theme.colors.primary} />
617
+ */
618
+ export const Icon: (props: {
619
+ name?: string;
620
+ size?: number;
621
+ color?: string;
622
+ }) => any;
623
+
610
624
  // Linter
611
625
  export interface LintFinding {
612
626
  rule: string;
627
+ /**
628
+ * REQ-WSDK-PLATFORM update: findings can now carry a `severity`. The
629
+ * lint's `ok` flag is true iff no `severity: "error"` finding exists.
630
+ * `severity: "warning"` findings surface to reviewers but do not block.
631
+ */
632
+ severity?: "error" | "warning";
613
633
  label: string;
614
634
  line: number;
615
635
  snippet: string;
package/dist/index.js CHANGED
@@ -26,6 +26,11 @@ export {
26
26
  useChildRenderer,
27
27
  WidgetTree,
28
28
  } from "./hooks.js";
29
+ // REQ-WSDK-PLATFORM §6 — Tier A hooks. Each ships in a per-platform file
30
+ // (./clipboard.js / .native.js, ./toast.js / .native.js); index.js picks
31
+ // the web variant and index.native.js picks the native variant.
32
+ export { useClipboard, ClipboardError } from "./clipboard.js";
33
+ export { useToast } from "./toast.js";
29
34
  export {
30
35
  Text,
31
36
  View,
@@ -39,6 +44,8 @@ export {
39
44
  Switch,
40
45
  StyleSheet,
41
46
  Linking,
47
+ Icon,
48
+ DateTimePicker,
42
49
  } from "./primitives.js";
43
50
  export { lintSource, bannedIdentifiers } from "./linter.js";
44
51
  export { CONTRACT, isHookAllowed, requiredContextKeys } from "./contract.js";
@@ -26,6 +26,9 @@ export {
26
26
  useChildRenderer,
27
27
  WidgetTree,
28
28
  } from "./hooks.js";
29
+ // REQ-WSDK-PLATFORM §6 — Tier A hooks (native variants).
30
+ export { useClipboard, ClipboardError } from "./clipboard.native.js";
31
+ export { useToast } from "./toast.native.js";
29
32
  export {
30
33
  Text,
31
34
  View,
@@ -39,6 +42,8 @@ export {
39
42
  Switch,
40
43
  StyleSheet,
41
44
  Linking,
45
+ Icon,
46
+ DateTimePicker,
42
47
  } from "./primitives.native.js";
43
48
  export { lintSource, bannedIdentifiers } from "./linter.js";
44
49
  export { CONTRACT, isHookAllowed, requiredContextKeys } from "./contract.js";
package/dist/linter.cjs CHANGED
@@ -54,18 +54,15 @@ const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
54
54
  _ruleForIdentifier(b.identifier, b.reason),
55
55
  );
56
56
 
57
+ // REQ-WSDK-PLATFORM: `no-axios-import` is GONE. axios is on the vetted
58
+ // import list now (`CONTRACT.vettedImports`). See linter.js for the
59
+ // source-of-truth comment.
57
60
  const EXTRA_RULES = [
58
61
  {
59
62
  id: "no-auth-store-import",
60
63
  label: "widgets must not import the host's auth store",
61
64
  pattern: /useAuthStore/,
62
65
  },
63
- {
64
- id: "no-axios-import",
65
- label:
66
- "widgets must not import axios directly; use the injected datastore client",
67
- pattern: /from\s+['"]axios['"]|require\s*\(\s*['"]axios['"]\s*\)/,
68
- },
69
66
  ];
70
67
 
71
68
  const RULES = [...CONTRACT_RULES, ...EXTRA_RULES];
@@ -74,6 +71,128 @@ function bannedIdentifiers() {
74
71
  return CONTRACT.bannedApis.map((b) => b.identifier);
75
72
  }
76
73
 
74
+ // REQ-WSDK-PLATFORM §3.4, §8: import-specifier validation. See linter.js
75
+ // for the source-of-truth comment.
76
+ const IMPORT_SPECIFIER_RE =
77
+ /(?:^|[\s;])(?:import(?:\s+[^"';]+from)?|require)\s*\(?\s*['"]([^'"]+)['"]/g;
78
+
79
+ function _classifySpecifier(spec) {
80
+ if (
81
+ spec.startsWith("./") ||
82
+ (spec.startsWith(".") && !spec.startsWith(".."))
83
+ ) {
84
+ return "relative-ok";
85
+ }
86
+ if (spec.startsWith("../")) return "relative-escape";
87
+ if (
88
+ spec.startsWith("/") ||
89
+ spec.startsWith("http:") ||
90
+ spec.startsWith("https:") ||
91
+ spec.startsWith("blob:") ||
92
+ spec.startsWith("data:")
93
+ ) {
94
+ return "absolute";
95
+ }
96
+ return "bare";
97
+ }
98
+
99
+ function _importRules(source, manifest) {
100
+ const findings = [];
101
+ const allowed = new Map(
102
+ CONTRACT.vettedImports.map((v) => [v.specifier, v]),
103
+ );
104
+ const declaredPlatforms =
105
+ manifest &&
106
+ Array.isArray(manifest.supportedPlatforms) &&
107
+ manifest.supportedPlatforms.length > 0
108
+ ? new Set(manifest.supportedPlatforms)
109
+ : null;
110
+
111
+ const lines = source.split(/\r?\n/);
112
+ for (let i = 0; i < lines.length; i += 1) {
113
+ const line = lines[i];
114
+ IMPORT_SPECIFIER_RE.lastIndex = 0;
115
+ let m;
116
+ while ((m = IMPORT_SPECIFIER_RE.exec(line))) {
117
+ const spec = m[1];
118
+ const kind = _classifySpecifier(spec);
119
+ if (kind === "relative-ok") continue;
120
+ if (kind === "relative-escape") {
121
+ findings.push({
122
+ rule: "no-parent-relative-import",
123
+ label: `relative import "${spec}" escapes the bundle (\`../\`) — only sibling-file imports (\`./foo.js\`) are allowed`,
124
+ line: i + 1,
125
+ snippet: line.trim().slice(0, 200),
126
+ });
127
+ continue;
128
+ }
129
+ if (kind === "absolute") {
130
+ findings.push({
131
+ rule: "no-absolute-import",
132
+ label: `absolute import "${spec}" is not allowed — use a vetted bare specifier or a sibling-file (\`./foo.js\`) import`,
133
+ line: i + 1,
134
+ snippet: line.trim().slice(0, 200),
135
+ });
136
+ continue;
137
+ }
138
+ const entry = allowed.get(spec);
139
+ if (!entry) {
140
+ findings.push({
141
+ rule: "import-not-vetted",
142
+ label:
143
+ `import "${spec}" is not on the vetted package list — see ` +
144
+ `CONTRACT.vettedImports for the current set or open a request to add it`,
145
+ line: i + 1,
146
+ snippet: line.trim().slice(0, 200),
147
+ });
148
+ continue;
149
+ }
150
+ if (declaredPlatforms) {
151
+ for (const wanted of declaredPlatforms) {
152
+ if (!entry.platforms.includes(wanted)) {
153
+ findings.push({
154
+ rule: "import-platform-mismatch",
155
+ label:
156
+ `"${spec}" supports [${entry.platforms.join(", ")}] but the ` +
157
+ `manifest claims supportedPlatforms includes "${wanted}". ` +
158
+ `Move this import into widget.${wanted === "web" ? "native" : "web"}.jsx, ` +
159
+ `or drop "${wanted}" from manifest.supportedPlatforms.`,
160
+ line: i + 1,
161
+ snippet: line.trim().slice(0, 200),
162
+ });
163
+ }
164
+ }
165
+ }
166
+ }
167
+ }
168
+ return findings;
169
+ }
170
+
171
+ function _hostApiUrlRules(source) {
172
+ const findings = [];
173
+ const patterns = CONTRACT.hostApiUrlPatterns || [];
174
+ const lines = source.split(/\r?\n/);
175
+ for (let i = 0; i < lines.length; i += 1) {
176
+ const line = lines[i];
177
+ for (const needle of patterns) {
178
+ if (line.includes(needle)) {
179
+ findings.push({
180
+ rule: "no-host-api-url",
181
+ severity: "warning",
182
+ label:
183
+ `source contains "${needle}" — calls to the AppStudio host API ` +
184
+ `are blocked at runtime (widgets get no JWT token). Use SDK ` +
185
+ `hooks for workspace data; \`axios\`/\`fetch\` for third-party APIs.`,
186
+ line: i + 1,
187
+ snippet: line.trim().slice(0, 200),
188
+ });
189
+ break;
190
+ }
191
+ }
192
+ }
193
+ return findings;
194
+ }
195
+
77
196
  // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-required-for-user-mutation. See
78
197
  // linter.js for the rationale comment. The two files must stay in
79
198
  // lockstep (the contract test asserts behaviour-equivalence).
@@ -173,7 +292,13 @@ function lintSource(source, options) {
173
292
  return {
174
293
  ok: false,
175
294
  findings: [
176
- { rule: "input", label: "source must be a string", line: 0, snippet: "" },
295
+ {
296
+ rule: "input",
297
+ severity: "error",
298
+ label: "source must be a string",
299
+ line: 0,
300
+ snippet: "",
301
+ },
177
302
  ],
178
303
  };
179
304
  }
@@ -185,6 +310,7 @@ function lintSource(source, options) {
185
310
  if (rule.pattern.test(line)) {
186
311
  findings.push({
187
312
  rule: rule.id,
313
+ severity: "error",
188
314
  label: rule.label,
189
315
  line: i + 1,
190
316
  snippet: line.trim().slice(0, 200),
@@ -192,8 +318,21 @@ function lintSource(source, options) {
192
318
  }
193
319
  }
194
320
  }
195
- findings.push(..._scopeRules(source, options && options.manifest));
196
- return { ok: findings.length === 0, findings };
321
+ findings.push(
322
+ ..._importRules(source, options && options.manifest).map((f) => ({
323
+ ...f,
324
+ severity: f.severity || "error",
325
+ })),
326
+ );
327
+ findings.push(..._hostApiUrlRules(source));
328
+ findings.push(
329
+ ..._scopeRules(source, options && options.manifest).map((f) => ({
330
+ ...f,
331
+ severity: f.severity || "error",
332
+ })),
333
+ );
334
+ const hasErrors = findings.some((f) => f.severity !== "warning");
335
+ return { ok: !hasErrors, findings };
197
336
  }
198
337
 
199
338
  module.exports = { lintSource, bannedIdentifiers };