@colixsystems/widget-sdk 0.3.0 → 0.6.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/hooks.js CHANGED
@@ -1,15 +1,58 @@
1
- // Widget hooks per docs/architecture/widget-marketplace.md §3.1.
2
- // Each hook reads from the host-provided WidgetContext via React context, then
3
- // delegates to the host implementation. Bodies are intentionally slim the
4
- // hosts (Studio, Player, exported app) own the runtime semantics.
1
+ // Widget hooks per docs/architecture/widget-marketplace.md §3.1 and the
2
+ // CONTRACT in ./contract.js (the single source of truth when a hook's
3
+ // signature or return shape changes, update both this file and the
4
+ // CONTRACT entry in lockstep).
5
+ //
6
+ // Each hook reads from the host-provided WidgetContext via React context,
7
+ // then delegates to the host implementation. The hosts (Studio, Player,
8
+ // exported app) own the runtime semantics — this file is the SDK surface
9
+ // widgets call.
5
10
  //
6
11
  // This file avoids JSX so it can ship as a plain .js without a transform.
7
12
 
8
- import React, { createContext, useContext, useCallback } from "react";
13
+ import React, {
14
+ createContext,
15
+ useContext,
16
+ useCallback,
17
+ useEffect,
18
+ useRef,
19
+ useState,
20
+ } from "react";
9
21
 
10
22
  /** @internal — host-injected context value of shape WidgetContext (see index.d.ts). */
11
23
  const HostWidgetContext = createContext(null);
12
24
 
25
+ /**
26
+ * Structured error thrown by `useDatastoreMutation` callbacks (and surfaced
27
+ * by `useDatastoreQuery` in its `error` slot). Carries a stable `code` so
28
+ * widgets can branch on the error class without parsing axios message
29
+ * strings.
30
+ *
31
+ * `code` is one of:
32
+ * - "VALIDATION" — 400 / 422 from the datastore
33
+ * - "CONSTRAINT_VIOLATION" — 409 from the datastore
34
+ * - "FORBIDDEN" — 403 from the datastore
35
+ * - "NOT_FOUND" — 404 from the datastore
36
+ * - "INTERNAL" — anything else (network, 5xx, axios timeout)
37
+ *
38
+ * `fieldErrors` (when present) is a flat map of field name to per-field
39
+ * error message, populated when the datastore returned a structured
40
+ * 400/422 payload with `errors: [{ field, code }, ...]`.
41
+ */
42
+ export class DatastoreError extends Error {
43
+ constructor(code, message, opts) {
44
+ super(message);
45
+ this.name = "DatastoreError";
46
+ this.code = code;
47
+ if (opts && opts.fieldErrors && typeof opts.fieldErrors === "object") {
48
+ this.fieldErrors = opts.fieldErrors;
49
+ }
50
+ if (opts && opts.cause) {
51
+ this.cause = opts.cause;
52
+ }
53
+ }
54
+ }
55
+
13
56
  /**
14
57
  * Wraps children with the host-provided WidgetContext.
15
58
  * The host (Studio/Player/native shell) builds the value and renders this provider.
@@ -23,54 +66,247 @@ function useWidgetContextOrThrow(hookName) {
23
66
  if (ctx == null) {
24
67
  throw new Error(
25
68
  `${hookName} must be used inside a WidgetContextProvider. ` +
26
- `The host (Studio, Player, or exported app) is responsible for mounting it.`
69
+ `The host (Studio, Player, or exported app) is responsible for mounting it.`,
27
70
  );
28
71
  }
29
72
  return ctx;
30
73
  }
31
74
 
32
- // v0.1 scope: returns the host's datastore-backed query result.
33
- // The host owns suspense/caching; this hook is a passthrough to ctx.datastore.
75
+ // --------------------------------------------------------------- helpers
76
+
77
+ /**
78
+ * Coerce an arbitrary thrown value (axios error, plain Error, string) into
79
+ * a DatastoreError with a stable `.code`. Reads `error.response.status` if
80
+ * present (axios shape) and falls back to inspecting the message string.
81
+ */
82
+ function toDatastoreError(err) {
83
+ if (err instanceof DatastoreError) return err;
84
+ const status =
85
+ err && err.response && typeof err.response.status === "number"
86
+ ? err.response.status
87
+ : null;
88
+ const bodyMessage =
89
+ err &&
90
+ err.response &&
91
+ err.response.data &&
92
+ typeof err.response.data.error === "string"
93
+ ? err.response.data.error
94
+ : null;
95
+ const fallbackMessage =
96
+ bodyMessage ||
97
+ (err && typeof err.message === "string"
98
+ ? err.message
99
+ : "Datastore call failed");
100
+ let code = "INTERNAL";
101
+ if (status === 400 || status === 422) code = "VALIDATION";
102
+ else if (status === 409) code = "CONSTRAINT_VIOLATION";
103
+ else if (status === 403) code = "FORBIDDEN";
104
+ else if (status === 404) code = "NOT_FOUND";
105
+ // Surface a structured fieldErrors map when the server emitted one
106
+ // (record.controller.js shapes 422 bodies as `{ errors: [{ field, code }] }`).
107
+ let fieldErrors;
108
+ if (
109
+ err &&
110
+ err.response &&
111
+ err.response.data &&
112
+ Array.isArray(err.response.data.errors)
113
+ ) {
114
+ const map = {};
115
+ for (const entry of err.response.data.errors) {
116
+ if (entry && typeof entry.field === "string") {
117
+ map[entry.field] = entry.message || entry.code || "Invalid value";
118
+ }
119
+ }
120
+ if (Object.keys(map).length > 0) fieldErrors = map;
121
+ }
122
+ return new DatastoreError(code, fallbackMessage, {
123
+ cause: err,
124
+ fieldErrors,
125
+ });
126
+ }
127
+
128
+ // --------------------------------------------------------------- hooks
129
+
130
+ /**
131
+ * Stateful datastore query hook. Returns { data, loading, error, refetch }.
132
+ *
133
+ * The host's datastore client exposes `records(table).list(query)` which
134
+ * returns a Promise<Record[]>. We hold the result in component state and
135
+ * re-fetch when [table, JSON.stringify(query)] changes. `refetch` re-runs
136
+ * the same call on demand.
137
+ *
138
+ * When `table` is falsy (e.g. the user hasn't bound a `tableRef` property
139
+ * yet), the hook resolves to { data: [], loading: false, error: null,
140
+ * refetch } so the widget can render its empty state without throwing.
141
+ */
34
142
  export function useDatastoreQuery(table, query) {
35
143
  const ctx = useWidgetContextOrThrow("useDatastoreQuery");
36
144
  if (!ctx.datastore || typeof ctx.datastore.records !== "function") {
37
- throw new Error("useDatastoreQuery: host did not inject a datastore client");
145
+ throw new Error(
146
+ "useDatastoreQuery: host did not inject a datastore client",
147
+ );
38
148
  }
39
- // The host-injected datastore is expected to expose a `useRecords(table, query)` hook;
40
- // v0.1 stub returns the raw promise interface and lets the host wire React Query.
41
- return ctx.datastore.records(table).list(query);
149
+ const [data, setData] = useState([]);
150
+ const [loading, setLoading] = useState(Boolean(table));
151
+ const [error, setError] = useState(null);
152
+
153
+ // Capture the latest table + query in refs so refetch() — a stable
154
+ // callback identity — always reads the current arguments without having
155
+ // to be re-bound on every render.
156
+ //
157
+ // We hold ctx.datastore.records in a ref for the same reason: the
158
+ // Studio / Player / PageRenderer rebuild the WidgetContext value
159
+ // inside their render closure on every render, so `ctx` is a fresh
160
+ // object identity every render. If `doFetch` listed `[ctx]` in its
161
+ // dep array, the callback identity (and therefore the `refetch`
162
+ // surface) would change every render and any widget that put
163
+ // `refetch` in a useEffect dep array would loop forever. The effect
164
+ // that drives the actual initial fetch keeps `[table, queryKey]` so
165
+ // it only re-runs when the bound table or the query payload changes.
166
+ const tableRef = useRef(table);
167
+ const queryRef = useRef(query);
168
+ const recordsRef = useRef(ctx.datastore.records);
169
+ tableRef.current = table;
170
+ queryRef.current = query;
171
+ recordsRef.current = ctx.datastore.records;
172
+
173
+ const runRef = useRef(0);
174
+
175
+ const doFetch = useCallback(async () => {
176
+ const myRun = ++runRef.current;
177
+ const t = tableRef.current;
178
+ if (!t) {
179
+ // No table bound yet — collapse to a stable empty result without
180
+ // a network round-trip.
181
+ setLoading(false);
182
+ setError(null);
183
+ setData([]);
184
+ return;
185
+ }
186
+ setLoading(true);
187
+ setError(null);
188
+ try {
189
+ const ns = recordsRef.current(t);
190
+ const rows = await ns.list(queryRef.current);
191
+ // Discard the result if a newer fetch has started since we kicked off.
192
+ if (runRef.current !== myRun) return;
193
+ setData(Array.isArray(rows) ? rows : []);
194
+ setLoading(false);
195
+ } catch (err) {
196
+ if (runRef.current !== myRun) return;
197
+ setError(toDatastoreError(err));
198
+ setLoading(false);
199
+ }
200
+ }, []);
201
+
202
+ // Re-run on mount and whenever [table, JSON.stringify(query)] changes.
203
+ const queryKey = (() => {
204
+ try {
205
+ return JSON.stringify(query);
206
+ } catch (_e) {
207
+ return null;
208
+ }
209
+ })();
210
+ useEffect(() => {
211
+ doFetch();
212
+ // eslint-disable-next-line react-hooks/exhaustive-deps
213
+ }, [table, queryKey]);
214
+
215
+ const refetch = useCallback(async () => {
216
+ await doFetch();
217
+ }, [doFetch]);
218
+
219
+ return { data, loading, error, refetch };
42
220
  }
43
221
 
44
- // v0.1 scope: returns { create, update, delete } from the injected datastore client.
222
+ /**
223
+ * Datastore mutation hook. Returns { create, update, delete }, each method
224
+ * returning a Promise. Rejected promises throw a DatastoreError carrying a
225
+ * stable `.code` (`VALIDATION`, `CONSTRAINT_VIOLATION`, `FORBIDDEN`,
226
+ * `NOT_FOUND`, `INTERNAL`) plus an optional `fieldErrors` map.
227
+ */
45
228
  export function useDatastoreMutation(table) {
46
229
  const ctx = useWidgetContextOrThrow("useDatastoreMutation");
47
230
  if (!ctx.datastore || typeof ctx.datastore.records !== "function") {
48
- throw new Error("useDatastoreMutation: host did not inject a datastore client");
231
+ throw new Error(
232
+ "useDatastoreMutation: host did not inject a datastore client",
233
+ );
49
234
  }
50
235
  const ns = ctx.datastore.records(table);
51
236
  return {
52
- create: (values) => ns.create(values),
53
- update: (id, values) => ns.update(id, values),
54
- delete: (id) => ns.delete(id),
237
+ create: async (values) => {
238
+ try {
239
+ return await ns.create(values);
240
+ } catch (err) {
241
+ throw toDatastoreError(err);
242
+ }
243
+ },
244
+ update: async (id, values) => {
245
+ try {
246
+ return await ns.update(id, values);
247
+ } catch (err) {
248
+ throw toDatastoreError(err);
249
+ }
250
+ },
251
+ delete: async (id) => {
252
+ try {
253
+ return await ns.delete(id);
254
+ } catch (err) {
255
+ throw toDatastoreError(err);
256
+ }
257
+ },
55
258
  };
56
259
  }
57
260
 
58
- // v0.1 scope: emits a named event through ctx.events.emit. Page-level event
59
- // bindings (subscribed by the host) decide what happens next.
261
+ /**
262
+ * Emit a named widget event through ctx.events.emit. Page-level event
263
+ * bindings (subscribed by the host) decide what happens next.
264
+ */
60
265
  export function useWidgetEvent(name) {
61
266
  const ctx = useWidgetContextOrThrow("useWidgetEvent");
62
267
  return useCallback((payload) => ctx.events.emit(name, payload), [ctx, name]);
63
268
  }
64
269
 
65
- // v0.1 scope: returns ThemeTokens. Host owns theme resolution per workspace.
270
+ /**
271
+ * Returns the host-provided theme tokens. The host guarantees every field
272
+ * documented in CONTRACT.themeTokens is present (defaults merged with
273
+ * tenant overrides), so widgets read `theme.colors.primary` without
274
+ * optional chaining.
275
+ */
66
276
  export function useTheme() {
67
277
  const ctx = useWidgetContextOrThrow("useTheme");
68
278
  return ctx.workspace.theme;
69
279
  }
70
280
 
71
- // v0.1 scope: returns { locale, t }. Host owns translation tables;
72
- // the SDK does not bundle a translation engine.
281
+ /**
282
+ * Returns { t, locale }. `t(key, fallback)` resolves `{{t:key}}` against
283
+ * the host's translation table and falls back to `fallback ?? key` when
284
+ * the key is missing. The host's i18n.t may or may not honour the two-arg
285
+ * form; we degrade gracefully either way.
286
+ */
73
287
  export function useI18n() {
74
288
  const ctx = useWidgetContextOrThrow("useI18n");
75
- return ctx.i18n;
289
+ const i18n = ctx.i18n || {};
290
+ const locale = typeof i18n.locale === "string" ? i18n.locale : "en";
291
+ const hostT = typeof i18n.t === "function" ? i18n.t : null;
292
+ const t = useCallback(
293
+ (key, fallback) => {
294
+ if (typeof key !== "string" || !key) {
295
+ return typeof fallback === "string" ? fallback : "";
296
+ }
297
+ if (hostT) {
298
+ // Try the two-arg form first; if the host's `t` ignores `fallback`
299
+ // and returns the bare key on a miss, swap in the fallback.
300
+ const out = hostT(key, fallback);
301
+ if (typeof out === "string" && out.length > 0 && out !== key) {
302
+ return out;
303
+ }
304
+ // Host returned the key (unresolved) or nothing usable.
305
+ return typeof fallback === "string" ? fallback : key;
306
+ }
307
+ return typeof fallback === "string" ? fallback : key;
308
+ },
309
+ [hostT],
310
+ );
311
+ return { t, locale };
76
312
  }
package/dist/index.d.ts CHANGED
@@ -42,7 +42,11 @@ export interface WidgetPropertyDef {
42
42
  enum?: Array<{ value: unknown; label: string }>;
43
43
  items?: WidgetPropertyDef;
44
44
  properties?: Record<string, WidgetPropertyDef>;
45
- ui?: { widget?: "textarea" | "slider" | "code"; group?: string; order?: number };
45
+ ui?: {
46
+ widget?: "textarea" | "slider" | "code";
47
+ group?: string;
48
+ order?: number;
49
+ };
46
50
  validation?: { min?: number; max?: number; pattern?: string };
47
51
  }
48
52
 
@@ -181,7 +185,10 @@ export interface WidgetContext<TProps = unknown> {
181
185
  };
182
186
  datastore: unknown; // typed by @colixsystems/datastore-client
183
187
  events: { emit(eventName: string, payload?: unknown): void };
184
- i18n: { locale: string; t(key: string, vars?: Record<string, unknown>): string };
188
+ i18n: {
189
+ locale: string;
190
+ t(key: string, vars?: Record<string, unknown>): string;
191
+ };
185
192
  platform: "web" | "native";
186
193
  logger: {
187
194
  debug: (...args: unknown[]) => void;
@@ -226,16 +233,16 @@ export function defineWidget<TProps = Record<string, unknown>>(opts: {
226
233
  }): WidgetModule<TProps>;
227
234
 
228
235
  export function validateManifest(
229
- manifest: unknown
236
+ manifest: unknown,
230
237
  ): { ok: true } | { ok: false; errors: string[] };
231
238
 
232
239
  export function validatePropertySchema(
233
- schema: unknown
240
+ schema: unknown,
234
241
  ): { ok: true } | { ok: false; errors: string[] };
235
242
 
236
243
  export function validateProps<T = Record<string, unknown>>(
237
244
  schema: WidgetPropertySchema,
238
- props: unknown
245
+ props: unknown,
239
246
  ): { ok: true; value: T } | { ok: false; errors: string[] };
240
247
 
241
248
  export interface Query {
@@ -260,35 +267,65 @@ export interface MutationApi<T> {
260
267
 
261
268
  export function useDatastoreQuery<T = unknown>(
262
269
  table: string,
263
- query?: Query
270
+ query?: Query,
264
271
  ): QueryResult<T>;
265
272
 
266
273
  export function useDatastoreMutation<T = unknown>(
267
- table: string
274
+ table: string,
268
275
  ): MutationApi<T>;
269
276
 
270
- export function useWidgetEvent(
271
- name: string
272
- ): (payload?: unknown) => void;
277
+ export function useWidgetEvent(name: string): (payload?: unknown) => void;
273
278
 
274
279
  export function useTheme(): ThemeTokens;
275
280
 
276
281
  export function useI18n(): {
277
282
  locale: string;
278
- t(key: string, vars?: Record<string, unknown>): string;
283
+ t(key: string, fallback?: string): string;
279
284
  };
280
285
 
286
+ /**
287
+ * Error class thrown by useDatastoreMutation callbacks (and surfaced by
288
+ * useDatastoreQuery in its `error` slot). The `code` is a stable
289
+ * categorisation widgets can branch on.
290
+ */
291
+ export class DatastoreError extends Error {
292
+ code:
293
+ | "VALIDATION"
294
+ | "CONSTRAINT_VIOLATION"
295
+ | "FORBIDDEN"
296
+ | "NOT_FOUND"
297
+ | "INTERNAL";
298
+ fieldErrors?: Record<string, string>;
299
+ constructor(
300
+ code: DatastoreError["code"],
301
+ message: string,
302
+ opts?: {
303
+ fieldErrors?: Record<string, string>;
304
+ cause?: unknown;
305
+ },
306
+ );
307
+ }
308
+
281
309
  export function WidgetContextProvider(props: {
282
310
  value: WidgetContext;
283
311
  children?: ReactNode;
284
312
  }): JSX.Element;
285
313
 
286
- // Primitives — platform-aware. Type as opaque components.
314
+ // Primitives — re-exported from `react-native` (web build aliases to
315
+ // `react-native-web`). Typed as opaque components here; widget authors
316
+ // targeting TypeScript should install `@types/react-native` for full
317
+ // prop typing.
287
318
  export const Text: any;
288
319
  export const View: any;
289
320
  export const Pressable: any;
290
321
  export const Image: any;
291
322
  export const ScrollView: any;
323
+ export const TextInput: any;
324
+ export const FlatList: any;
325
+ export const SectionList: any;
326
+ export const ActivityIndicator: any;
327
+ export const Switch: any;
328
+ export const StyleSheet: any;
292
329
 
293
330
  // Linter
294
331
  export interface LintFinding {
@@ -301,3 +338,76 @@ export function lintSource(source: string): {
301
338
  ok: boolean;
302
339
  findings: LintFinding[];
303
340
  };
341
+
342
+ // --------------------------------------------------------------- CONTRACT
343
+ //
344
+ // Single-source-of-truth contract artefact. See docs/design/ai-widget-contract.md.
345
+ // The runtime value is `Object.freeze`d; the type below describes the
346
+ // public shape consumers can read.
347
+
348
+ export interface ContractHookEntry {
349
+ name: string;
350
+ signature: string;
351
+ returnShape: Record<string, string>;
352
+ requiredContextSlice: string[];
353
+ scopes: string[] | null;
354
+ }
355
+
356
+ export interface ContractPrimitiveEntry {
357
+ name: string;
358
+ description: string;
359
+ props: Record<
360
+ string,
361
+ { type: string; required?: boolean; description?: string }
362
+ >;
363
+ }
364
+
365
+ export interface ContractManifestField {
366
+ type: string;
367
+ required: boolean;
368
+ description?: string;
369
+ default?: unknown;
370
+ values?: readonly string[];
371
+ example?: unknown;
372
+ }
373
+
374
+ export interface ContractBundleShape {
375
+ name: string;
376
+ description: string;
377
+ predicate: string;
378
+ manifestSource: string;
379
+ audience: string;
380
+ }
381
+
382
+ export interface ContractBannedApi {
383
+ identifier: string;
384
+ reason: string;
385
+ }
386
+
387
+ export interface AiWidgetContract {
388
+ readonly version: string;
389
+ readonly hooks: ReadonlyArray<ContractHookEntry>;
390
+ readonly primitives: ReadonlyArray<ContractPrimitiveEntry>;
391
+ readonly manifestSchema: Readonly<Record<string, ContractManifestField>>;
392
+ readonly manifestCategories: ReadonlyArray<string>;
393
+ readonly manifestPlatforms: ReadonlyArray<string>;
394
+ readonly themeTokens: ThemeTokens;
395
+ readonly widgetContextShape: Readonly<
396
+ Record<
397
+ string,
398
+ {
399
+ description: string;
400
+ required: boolean;
401
+ fields: Record<string, unknown>;
402
+ }
403
+ >
404
+ >;
405
+ readonly bundleExportContract: ReadonlyArray<ContractBundleShape>;
406
+ readonly bannedApis: ReadonlyArray<ContractBannedApi>;
407
+ readonly allowedBareImports: ReadonlyArray<string>;
408
+ }
409
+
410
+ export const CONTRACT: AiWidgetContract;
411
+
412
+ export function isHookAllowed(name: string): boolean;
413
+ export function requiredContextKeys(): string[];
package/dist/index.js CHANGED
@@ -7,11 +7,25 @@ export { validateManifest, canonicalCategory } from "./manifest.js";
7
7
  export { validatePropertySchema, validateProps } from "./property-schema.js";
8
8
  export {
9
9
  WidgetContextProvider,
10
+ DatastoreError,
10
11
  useDatastoreQuery,
11
12
  useDatastoreMutation,
12
13
  useWidgetEvent,
13
14
  useTheme,
14
15
  useI18n,
15
16
  } from "./hooks.js";
16
- export { Text, View, Pressable, Image, ScrollView } from "./primitives.js";
17
- export { lintSource } from "./linter.js";
17
+ export {
18
+ Text,
19
+ View,
20
+ Pressable,
21
+ Image,
22
+ ScrollView,
23
+ TextInput,
24
+ FlatList,
25
+ SectionList,
26
+ ActivityIndicator,
27
+ Switch,
28
+ StyleSheet,
29
+ } from "./primitives.js";
30
+ export { lintSource, bannedIdentifiers } from "./linter.js";
31
+ export { CONTRACT, isHookAllowed, requiredContextKeys } from "./contract.js";
@@ -7,11 +7,25 @@ export { validateManifest, canonicalCategory } from "./manifest.js";
7
7
  export { validatePropertySchema, validateProps } from "./property-schema.js";
8
8
  export {
9
9
  WidgetContextProvider,
10
+ DatastoreError,
10
11
  useDatastoreQuery,
11
12
  useDatastoreMutation,
12
13
  useWidgetEvent,
13
14
  useTheme,
14
15
  useI18n,
15
16
  } from "./hooks.js";
16
- export { Text, View, Pressable, Image, ScrollView } from "./primitives.native.js";
17
- export { lintSource } from "./linter.js";
17
+ export {
18
+ Text,
19
+ View,
20
+ Pressable,
21
+ Image,
22
+ ScrollView,
23
+ TextInput,
24
+ FlatList,
25
+ SectionList,
26
+ ActivityIndicator,
27
+ Switch,
28
+ StyleSheet,
29
+ } from "./primitives.native.js";
30
+ export { lintSource, bannedIdentifiers } from "./linter.js";
31
+ export { CONTRACT, isHookAllowed, requiredContextKeys } from "./contract.js";
@@ -0,0 +1,104 @@
1
+ // CommonJS-flavoured copy of the linter so backend services (CJS runtime)
2
+ // can `require()` it directly without dynamic-import gymnastics.
3
+ //
4
+ // The ESM `linter.js` is the source-of-truth. This file MUST mirror its
5
+ // rule set byte-for-byte — the contract test in packages/widget-sdk
6
+ // asserts the two are behaviour-equivalent. When you edit one, edit the
7
+ // other in the same PR. (Pattern matches `contract.cjs` / `contract.js`.)
8
+ //
9
+ // The SDK build script copies both into `dist/`.
10
+
11
+ "use strict";
12
+
13
+ const { CONTRACT } = require("./contract.cjs");
14
+
15
+ function _ruleForIdentifier(identifier, reason) {
16
+ const id = identifier;
17
+ if (id === "Function") {
18
+ return {
19
+ id: "no-function-constructor",
20
+ label: `${reason} (banned: Function() constructor)`,
21
+ pattern: /(^|[^A-Za-z0-9_$.])Function\s*\(/,
22
+ };
23
+ }
24
+ if (id === "new Function") {
25
+ return {
26
+ id: "no-new-function",
27
+ label: `${reason} (banned: new Function())`,
28
+ pattern: /\bnew\s+Function\s*\(/,
29
+ };
30
+ }
31
+ if (id === "import(") {
32
+ return {
33
+ id: "no-dynamic-import",
34
+ label: `${reason} (banned: dynamic import())`,
35
+ pattern: /(^|[^A-Za-z0-9_$.])import\s*\(/,
36
+ };
37
+ }
38
+ if (id === "eval") {
39
+ return {
40
+ id: "no-eval",
41
+ label: `${reason} (banned: eval)`,
42
+ pattern: /\beval\s*\(/,
43
+ };
44
+ }
45
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46
+ return {
47
+ id: `no-${id.toLowerCase()}`,
48
+ label: `${reason} (banned: ${id})`,
49
+ pattern: new RegExp(`\\b${escaped}\\b`),
50
+ };
51
+ }
52
+
53
+ const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
54
+ _ruleForIdentifier(b.identifier, b.reason),
55
+ );
56
+
57
+ const EXTRA_RULES = [
58
+ {
59
+ id: "no-auth-store-import",
60
+ label: "widgets must not import the host's auth store",
61
+ pattern: /useAuthStore/,
62
+ },
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
+ ];
70
+
71
+ const RULES = [...CONTRACT_RULES, ...EXTRA_RULES];
72
+
73
+ function bannedIdentifiers() {
74
+ return CONTRACT.bannedApis.map((b) => b.identifier);
75
+ }
76
+
77
+ function lintSource(source) {
78
+ if (typeof source !== "string") {
79
+ return {
80
+ ok: false,
81
+ findings: [
82
+ { rule: "input", label: "source must be a string", line: 0, snippet: "" },
83
+ ],
84
+ };
85
+ }
86
+ const findings = [];
87
+ const lines = source.split(/\r?\n/);
88
+ for (let i = 0; i < lines.length; i++) {
89
+ const line = lines[i];
90
+ for (const rule of RULES) {
91
+ if (rule.pattern.test(line)) {
92
+ findings.push({
93
+ rule: rule.id,
94
+ label: rule.label,
95
+ line: i + 1,
96
+ snippet: line.trim().slice(0, 200),
97
+ });
98
+ }
99
+ }
100
+ }
101
+ return { ok: findings.length === 0, findings };
102
+ }
103
+
104
+ module.exports = { lintSource, bannedIdentifiers };