@colixsystems/widget-sdk 0.13.0 → 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/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,12 +71,234 @@ function bannedIdentifiers() {
74
71
  return CONTRACT.bannedApis.map((b) => b.identifier);
75
72
  }
76
73
 
77
- function lintSource(source) {
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
+
196
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-required-for-user-mutation. See
197
+ // linter.js for the rationale comment. The two files must stay in
198
+ // lockstep (the contract test asserts behaviour-equivalence).
199
+ const USER_MUTATION_METHODS = ["invite", "deactivate", "reactivate", "remove"];
200
+ const GROUP_MUTATION_METHODS = ["create", "remove", "addMember", "removeMember"];
201
+ const SKIP_COMMENT = /\/\/[^\n]*@appstudio-skip-scope-check/;
202
+
203
+ function _scopeRules(source, manifest) {
204
+ const findings = [];
205
+ if (!manifest || typeof manifest !== "object") return findings;
206
+ const declared = new Set(
207
+ Array.isArray(manifest.requestedScopes)
208
+ ? manifest.requestedScopes.filter((s) => typeof s === "string")
209
+ : [],
210
+ );
211
+ const usesUsersHook = /\buseUsers\s*\(/.test(source);
212
+ const usesGroupsHook = /\buseGroups\s*\(/.test(source);
213
+
214
+ if (usesUsersHook) {
215
+ const reads = declared.has("users.read:*") || declared.has("users.read");
216
+ if (!reads) {
217
+ findings.push({
218
+ rule: "scope-required-for-useUsers",
219
+ label:
220
+ "useUsers() requires `users.read:*` in manifest.requestedScopes",
221
+ line: 0,
222
+ snippet: "",
223
+ });
224
+ }
225
+ }
226
+ if (usesGroupsHook) {
227
+ const reads = declared.has("groups.read:*") || declared.has("groups.read");
228
+ if (!reads) {
229
+ findings.push({
230
+ rule: "scope-required-for-useGroups",
231
+ label:
232
+ "useGroups() requires `groups.read:*` in manifest.requestedScopes",
233
+ line: 0,
234
+ snippet: "",
235
+ });
236
+ }
237
+ }
238
+
239
+ const lines = source.split(/\r?\n/);
240
+ let firedUsersWrite = false;
241
+ let firedGroupsWrite = false;
242
+ for (let i = 0; i < lines.length; i++) {
243
+ const line = lines[i];
244
+ if (SKIP_COMMENT.test(line)) continue;
245
+ if (usesUsersHook && !firedUsersWrite) {
246
+ for (const m of USER_MUTATION_METHODS) {
247
+ const re = new RegExp(`\\.${m}\\s*\\(`);
248
+ if (re.test(line)) {
249
+ if (
250
+ !declared.has("users.write:*") &&
251
+ !declared.has("users.write")
252
+ ) {
253
+ findings.push({
254
+ rule: "scope-required-for-user-mutation",
255
+ label: `useUsers().${m}() requires \`users.write:*\` in manifest.requestedScopes`,
256
+ line: i + 1,
257
+ snippet: line.trim().slice(0, 200),
258
+ });
259
+ }
260
+ firedUsersWrite = true;
261
+ break;
262
+ }
263
+ }
264
+ }
265
+ if (usesGroupsHook && !firedGroupsWrite) {
266
+ for (const m of GROUP_MUTATION_METHODS) {
267
+ const re = new RegExp(`\\.${m}\\s*\\(`);
268
+ if (re.test(line)) {
269
+ if (m === "remove" && usesUsersHook) continue;
270
+ if (
271
+ !declared.has("groups.write:*") &&
272
+ !declared.has("groups.write")
273
+ ) {
274
+ findings.push({
275
+ rule: "scope-required-for-group-mutation",
276
+ label: `useGroups().${m}() requires \`groups.write:*\` in manifest.requestedScopes`,
277
+ line: i + 1,
278
+ snippet: line.trim().slice(0, 200),
279
+ });
280
+ }
281
+ firedGroupsWrite = true;
282
+ break;
283
+ }
284
+ }
285
+ }
286
+ }
287
+ return findings;
288
+ }
289
+
290
+ function lintSource(source, options) {
78
291
  if (typeof source !== "string") {
79
292
  return {
80
293
  ok: false,
81
294
  findings: [
82
- { 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
+ },
83
302
  ],
84
303
  };
85
304
  }
@@ -91,6 +310,7 @@ function lintSource(source) {
91
310
  if (rule.pattern.test(line)) {
92
311
  findings.push({
93
312
  rule: rule.id,
313
+ severity: "error",
94
314
  label: rule.label,
95
315
  line: i + 1,
96
316
  snippet: line.trim().slice(0, 200),
@@ -98,7 +318,21 @@ function lintSource(source) {
98
318
  }
99
319
  }
100
320
  }
101
- 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 };
102
336
  }
103
337
 
104
338
  module.exports = { lintSource, bannedIdentifiers };
package/dist/linter.js CHANGED
@@ -57,18 +57,19 @@ const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
57
57
 
58
58
  // Extra rules that don't map 1:1 to a banned identifier in the contract:
59
59
  // host-internal imports that widgets must never touch.
60
+ //
61
+ // REQ-WSDK-PLATFORM: `no-axios-import` is GONE. axios is on the vetted
62
+ // import list now (`CONTRACT.vettedImports`) — widgets may call third-party
63
+ // APIs directly. Calls to the host's own /api/* surface are blocked at
64
+ // runtime by the WidgetContextProvider's network gate; the soft
65
+ // `no-host-api-url` rule below flags obvious host-URL substrings so
66
+ // authors learn the rule statically.
60
67
  const EXTRA_RULES = [
61
68
  {
62
69
  id: "no-auth-store-import",
63
70
  label: "widgets must not import the host's auth store",
64
71
  pattern: /useAuthStore/,
65
72
  },
66
- {
67
- id: "no-axios-import",
68
- label:
69
- "widgets must not import axios directly; use the injected datastore client",
70
- pattern: /from\s+['"]axios['"]|require\s*\(\s*['"]axios['"]\s*\)/,
71
- },
72
73
  ];
73
74
 
74
75
  const RULES = [...CONTRACT_RULES, ...EXTRA_RULES];
@@ -83,17 +84,295 @@ export function bannedIdentifiers() {
83
84
  return CONTRACT.bannedApis.map((b) => b.identifier);
84
85
  }
85
86
 
87
+ // REQ-WSDK-PLATFORM §3.4, §8: scan the source for every bare import
88
+ // specifier and validate it against `CONTRACT.vettedImports`.
89
+ //
90
+ // Recognised forms (string-level, no AST):
91
+ // import X from "spec"
92
+ // import { … } from "spec"
93
+ // import * as X from "spec"
94
+ // import "spec"
95
+ // const X = require("spec") // CJS — for completeness
96
+ //
97
+ // Relative imports within the bundle (`./foo.js`, `./shared/util.js`) are
98
+ // allowed — REQ-WSDK-PLATFORM §4.2 lets a split-impl widget share helpers
99
+ // between `widget.web.jsx` and `widget.native.jsx`. Parent-directory
100
+ // (`../`) and absolute (`/`, `http(s):`) paths are rejected to keep the
101
+ // bundle scope sealed.
102
+ const IMPORT_SPECIFIER_RE =
103
+ /(?:^|[\s;])(?:import(?:\s+[^"';]+from)?|require)\s*\(?\s*['"]([^'"]+)['"]/g;
104
+
105
+ function _classifySpecifier(spec) {
106
+ if (
107
+ spec.startsWith("./") ||
108
+ (spec.startsWith(".") && !spec.startsWith(".."))
109
+ ) {
110
+ return "relative-ok";
111
+ }
112
+ if (spec.startsWith("../")) return "relative-escape";
113
+ if (
114
+ spec.startsWith("/") ||
115
+ spec.startsWith("http:") ||
116
+ spec.startsWith("https:") ||
117
+ spec.startsWith("blob:") ||
118
+ spec.startsWith("data:")
119
+ ) {
120
+ return "absolute";
121
+ }
122
+ return "bare";
123
+ }
124
+
125
+ function _importRules(source, manifest) {
126
+ const findings = [];
127
+ const allowed = new Map(
128
+ CONTRACT.vettedImports.map((v) => [v.specifier, v]),
129
+ );
130
+ // Track declared `supportedPlatforms` so a widget that claims "web only"
131
+ // doesn't import a native-only package (and vice versa) without the
132
+ // marketplace listing being honest about which platforms ship.
133
+ const declaredPlatforms =
134
+ manifest &&
135
+ Array.isArray(manifest.supportedPlatforms) &&
136
+ manifest.supportedPlatforms.length > 0
137
+ ? new Set(manifest.supportedPlatforms)
138
+ : null;
139
+
140
+ const lines = source.split(/\r?\n/);
141
+ for (let i = 0; i < lines.length; i += 1) {
142
+ const line = lines[i];
143
+ // Reset lastIndex — the regex is /g and shared across lines.
144
+ IMPORT_SPECIFIER_RE.lastIndex = 0;
145
+ let m;
146
+ while ((m = IMPORT_SPECIFIER_RE.exec(line))) {
147
+ const spec = m[1];
148
+ const kind = _classifySpecifier(spec);
149
+ if (kind === "relative-ok") continue;
150
+ if (kind === "relative-escape") {
151
+ findings.push({
152
+ rule: "no-parent-relative-import",
153
+ label: `relative import "${spec}" escapes the bundle (\`../\`) — only sibling-file imports (\`./foo.js\`) are allowed`,
154
+ line: i + 1,
155
+ snippet: line.trim().slice(0, 200),
156
+ });
157
+ continue;
158
+ }
159
+ if (kind === "absolute") {
160
+ findings.push({
161
+ rule: "no-absolute-import",
162
+ label: `absolute import "${spec}" is not allowed — use a vetted bare specifier or a sibling-file (\`./foo.js\`) import`,
163
+ line: i + 1,
164
+ snippet: line.trim().slice(0, 200),
165
+ });
166
+ continue;
167
+ }
168
+ // Bare specifier — validate against the vetted list.
169
+ const entry = allowed.get(spec);
170
+ if (!entry) {
171
+ findings.push({
172
+ rule: "import-not-vetted",
173
+ label:
174
+ `import "${spec}" is not on the vetted package list — see ` +
175
+ `CONTRACT.vettedImports for the current set or open a request to add it`,
176
+ line: i + 1,
177
+ snippet: line.trim().slice(0, 200),
178
+ });
179
+ continue;
180
+ }
181
+ // If the manifest declares supportedPlatforms, every imported
182
+ // specifier must support every declared platform. A widget that
183
+ // imports react-native-maps (native-only) cannot honestly claim
184
+ // supportedPlatforms: ["web", "native"] from a single source file —
185
+ // it must either drop "web" from the manifest, OR move that import
186
+ // into widget.native.jsx so the web bundle doesn't see it.
187
+ if (declaredPlatforms) {
188
+ for (const wanted of declaredPlatforms) {
189
+ if (!entry.platforms.includes(wanted)) {
190
+ findings.push({
191
+ rule: "import-platform-mismatch",
192
+ label:
193
+ `"${spec}" supports [${entry.platforms.join(", ")}] but the ` +
194
+ `manifest claims supportedPlatforms includes "${wanted}". ` +
195
+ `Move this import into widget.${wanted === "web" ? "native" : "web"}.jsx, ` +
196
+ `or drop "${wanted}" from manifest.supportedPlatforms.`,
197
+ line: i + 1,
198
+ snippet: line.trim().slice(0, 200),
199
+ });
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+ return findings;
206
+ }
207
+
208
+ // REQ-WSDK-PLATFORM §3.5: soft warning when source contains host-API URL
209
+ // substrings. NOT a hard block — false positives are possible (a widget
210
+ // that happens to call a third-party API also located at `/api`). The
211
+ // marketplace review queue surfaces the warning for a human pass.
212
+ function _hostApiUrlRules(source) {
213
+ const findings = [];
214
+ const patterns = CONTRACT.hostApiUrlPatterns || [];
215
+ const lines = source.split(/\r?\n/);
216
+ for (let i = 0; i < lines.length; i += 1) {
217
+ const line = lines[i];
218
+ for (const needle of patterns) {
219
+ if (line.includes(needle)) {
220
+ findings.push({
221
+ rule: "no-host-api-url",
222
+ severity: "warning",
223
+ label:
224
+ `source contains "${needle}" — calls to the AppStudio host API ` +
225
+ `are blocked at runtime (widgets get no JWT token). Use SDK ` +
226
+ `hooks for workspace data; \`axios\`/\`fetch\` for third-party APIs.`,
227
+ line: i + 1,
228
+ snippet: line.trim().slice(0, 200),
229
+ });
230
+ // Only fire once per line — repeated needle hits on the same line
231
+ // are noise.
232
+ break;
233
+ }
234
+ }
235
+ }
236
+ return findings;
237
+ }
238
+
239
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-required-for-user-mutation.
240
+ //
241
+ // A widget that calls `useUsers().invite()` / `.deactivate()` /
242
+ // `.reactivate()` / `.remove()` MUST declare `users.write:*` in its
243
+ // manifest's `requestedScopes`; similarly `useGroups()` mutation methods
244
+ // require `groups.write:*`. The rule is enforced statically so a manifest
245
+ // that drifts from the source (e.g. an author forgot to add the scope
246
+ // after wiring up the mutation) is rejected at submission time.
247
+ //
248
+ // The check is pattern-based: scan the source for `.invite(`, `.deactivate(`
249
+ // etc. and only fire when the source ALSO contains `useUsers(` / `useGroups(`
250
+ // nearby (i.e. the destructured callee likely originated from the hook).
251
+ // False positives are accepted in exchange for keeping the linter
252
+ // AST-free; an author whose code happened to spell `.invite(` for an
253
+ // unrelated reason can opt out with a `// @appstudio-skip-scope-check`
254
+ // trailing comment on the offending line.
255
+ const USER_MUTATION_METHODS = ["invite", "deactivate", "reactivate", "remove"];
256
+ const GROUP_MUTATION_METHODS = ["create", "remove", "addMember", "removeMember"];
257
+ const SKIP_COMMENT = /\/\/[^\n]*@appstudio-skip-scope-check/;
258
+
259
+ function _scopeRules(source, manifest) {
260
+ const findings = [];
261
+ if (!manifest || typeof manifest !== "object") return findings;
262
+ const declared = new Set(
263
+ Array.isArray(manifest.requestedScopes)
264
+ ? manifest.requestedScopes.filter((s) => typeof s === "string")
265
+ : [],
266
+ );
267
+ const usesUsersHook = /\buseUsers\s*\(/.test(source);
268
+ const usesGroupsHook = /\buseGroups\s*\(/.test(source);
269
+
270
+ if (usesUsersHook) {
271
+ const reads = declared.has("users.read:*") || declared.has("users.read");
272
+ if (!reads) {
273
+ findings.push({
274
+ rule: "scope-required-for-useUsers",
275
+ label:
276
+ "useUsers() requires `users.read:*` in manifest.requestedScopes",
277
+ line: 0,
278
+ snippet: "",
279
+ });
280
+ }
281
+ }
282
+ if (usesGroupsHook) {
283
+ const reads = declared.has("groups.read:*") || declared.has("groups.read");
284
+ if (!reads) {
285
+ findings.push({
286
+ rule: "scope-required-for-useGroups",
287
+ label:
288
+ "useGroups() requires `groups.read:*` in manifest.requestedScopes",
289
+ line: 0,
290
+ snippet: "",
291
+ });
292
+ }
293
+ }
294
+
295
+ const lines = source.split(/\r?\n/);
296
+ let firedUsersWrite = false;
297
+ let firedGroupsWrite = false;
298
+ for (let i = 0; i < lines.length; i++) {
299
+ const line = lines[i];
300
+ if (SKIP_COMMENT.test(line)) continue;
301
+ if (usesUsersHook && !firedUsersWrite) {
302
+ for (const m of USER_MUTATION_METHODS) {
303
+ const re = new RegExp(`\\.${m}\\s*\\(`);
304
+ if (re.test(line)) {
305
+ if (
306
+ !declared.has("users.write:*") &&
307
+ !declared.has("users.write")
308
+ ) {
309
+ findings.push({
310
+ rule: "scope-required-for-user-mutation",
311
+ label: `useUsers().${m}() requires \`users.write:*\` in manifest.requestedScopes`,
312
+ line: i + 1,
313
+ snippet: line.trim().slice(0, 200),
314
+ });
315
+ }
316
+ firedUsersWrite = true;
317
+ break;
318
+ }
319
+ }
320
+ }
321
+ if (usesGroupsHook && !firedGroupsWrite) {
322
+ for (const m of GROUP_MUTATION_METHODS) {
323
+ const re = new RegExp(`\\.${m}\\s*\\(`);
324
+ if (re.test(line)) {
325
+ // `.remove(` is also a useUsers method; only blame groups
326
+ // when the source also uses the groups hook AND the source
327
+ // doesn't ALSO use the users hook (an ambiguous .remove(
328
+ // is best left to the user-mutation rule above).
329
+ if (m === "remove" && usesUsersHook) continue;
330
+ if (
331
+ !declared.has("groups.write:*") &&
332
+ !declared.has("groups.write")
333
+ ) {
334
+ findings.push({
335
+ rule: "scope-required-for-group-mutation",
336
+ label: `useGroups().${m}() requires \`groups.write:*\` in manifest.requestedScopes`,
337
+ line: i + 1,
338
+ snippet: line.trim().slice(0, 200),
339
+ });
340
+ }
341
+ firedGroupsWrite = true;
342
+ break;
343
+ }
344
+ }
345
+ }
346
+ }
347
+ return findings;
348
+ }
349
+
86
350
  /**
87
351
  * Lint a JavaScript source string.
352
+ *
353
+ * REQ-WSDK-PLATFORM update: findings can now carry a `severity` field
354
+ * (`"error"` | `"warning"`). The `ok` flag is true iff NO `severity:
355
+ * "error"` finding exists — warnings (currently just `no-host-api-url`)
356
+ * surface to the reviewer but do not block publish. Findings without an
357
+ * explicit `severity` are treated as errors for back-compat with existing
358
+ * rules.
359
+ *
88
360
  * @param {string} source
89
- * @returns {{ ok: boolean, findings: Array<{ rule: string, label: string, line: number, snippet: string }> }}
361
+ * @param {{ manifest?: { requestedScopes?: string[], supportedPlatforms?: string[] } }} [options]
362
+ * @returns {{ ok: boolean, findings: Array<{ rule: string, severity?: "error" | "warning", label: string, line: number, snippet: string }> }}
90
363
  */
91
- export function lintSource(source) {
364
+ export function lintSource(source, options) {
92
365
  if (typeof source !== "string") {
93
366
  return {
94
367
  ok: false,
95
368
  findings: [
96
- { rule: "input", label: "source must be a string", line: 0, snippet: "" },
369
+ {
370
+ rule: "input",
371
+ severity: "error",
372
+ label: "source must be a string",
373
+ line: 0,
374
+ snippet: "",
375
+ },
97
376
  ],
98
377
  };
99
378
  }
@@ -105,6 +384,7 @@ export function lintSource(source) {
105
384
  if (rule.pattern.test(line)) {
106
385
  findings.push({
107
386
  rule: rule.id,
387
+ severity: "error",
108
388
  label: rule.label,
109
389
  line: i + 1,
110
390
  snippet: line.trim().slice(0, 200),
@@ -112,5 +392,24 @@ export function lintSource(source) {
112
392
  }
113
393
  }
114
394
  }
115
- return { ok: findings.length === 0, findings };
395
+ // REQ-WSDK-PLATFORM §3.4: import-specifier validation.
396
+ findings.push(
397
+ ..._importRules(source, options && options.manifest).map((f) => ({
398
+ ...f,
399
+ severity: f.severity || "error",
400
+ })),
401
+ );
402
+ // REQ-WSDK-PLATFORM §3.5: soft host-API URL warning (does not block).
403
+ findings.push(..._hostApiUrlRules(source));
404
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
405
+ // line-by-line scan so banned-identifier findings stay first in the
406
+ // output.
407
+ findings.push(
408
+ ..._scopeRules(source, options && options.manifest).map((f) => ({
409
+ ...f,
410
+ severity: f.severity || "error",
411
+ })),
412
+ );
413
+ const hasErrors = findings.some((f) => f.severity !== "warning");
414
+ return { ok: !hasErrors, findings };
116
415
  }
@@ -60,3 +60,11 @@ export const StyleSheet = ReactNative.StyleSheet;
60
60
  // the OS hands it off to the system handler. `canOpenURL` reports whether
61
61
  // the URL scheme is registered.
62
62
  export const Linking = ReactNative.Linking;
63
+ // REQ-WSDK-PLATFORM §6 — `<Icon>` wraps lucide-react-native. Same source
64
+ // runs on both platforms; see ./icon.js.
65
+ export { Icon } from "./icon.js";
66
+ // REQ-WSDK-PLATFORM §6 — `<DateTimePicker>` wraps
67
+ // @react-native-community/datetimepicker and normalizes its value to
68
+ // ISO 8601 strings. Same source runs on both platforms; see
69
+ // ./datetimepicker.js.
70
+ export { DateTimePicker } from "./datetimepicker.js";
@@ -20,3 +20,12 @@ export {
20
20
  StyleSheet,
21
21
  Linking,
22
22
  } from "react-native";
23
+
24
+ // REQ-WSDK-PLATFORM §6 — `<Icon>` is implemented in one file that runs on
25
+ // both platforms (lucide-react-native ships a working build for both).
26
+ export { Icon } from "./icon.js";
27
+ // REQ-WSDK-PLATFORM §6 — `<DateTimePicker>` ditto. The underlying
28
+ // @react-native-community/datetimepicker handles its own
29
+ // per-platform rendering; the SDK wrapper just normalizes the value
30
+ // to ISO strings.
31
+ export { DateTimePicker } from "./datetimepicker.js";
@@ -6,6 +6,12 @@ const VALID_TYPES = new Set([
6
6
  "color", "icon", "image",
7
7
  "select", "multiselect",
8
8
  "tableRef", "columnRef", "recordBinding",
9
+ // REQ-USERMGMT M4 / §4.8: `groupRef` is a Group picker that emits a bare
10
+ // AppUserGroup UUID into the page JSON. Renders via `GroupSelector` in
11
+ // the Studio Properties Panel. REQ-GEN-07 compliance: no typed UUIDs —
12
+ // value is the raw uuid string so the tenant-copy walker can remap it
13
+ // through `_remapJson` without per-prop hooks.
14
+ "groupRef",
9
15
  "expression", "eventBinding",
10
16
  "object", "array",
11
17
  ]);
@@ -87,6 +93,7 @@ function coerceLeaf(def, value, path, errors) {
87
93
  case "tableRef":
88
94
  case "columnRef":
89
95
  case "recordBinding":
96
+ case "groupRef":
90
97
  case "expression":
91
98
  case "eventBinding":
92
99
  if (typeof value !== "string") errors.push(`${path}: expected string`);