@colixsystems/widget-sdk 0.276.0 → 0.277.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
@@ -1,17 +1,19 @@
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.
8
- //
9
- // The SDK build script copies both into `dist/`.
10
-
1
+ // GENERATED from linter.js by scripts/generate-cjs.js — DO NOT EDIT.
2
+ // Change linter.js, then run `npm run generate:cjs` in packages/widget-sdk.
11
3
  "use strict";
12
4
 
13
5
  const { CONTRACT, normaliseActionTriggerTypes } = require("./contract.cjs");
14
6
  const { LUCIDE_ICON_NAMES, LUCIDE_VERSION } = require("./lucideIconNames.cjs");
7
+ const { stripNonCode: _stripNonCode } = require("./source-mask.cjs");
8
+
9
+ // Static-analysis linter per docs/architecture/widget-marketplace.md §3.1.
10
+ // Scans widget source for banned patterns. Pure text scanning — no AST parsing
11
+ // dep — which is sufficient to gate v0.1 submissions and gives clear pointers
12
+ // to humans during review.
13
+ //
14
+ // The banned-identifier list is derived from `CONTRACT.bannedApis` so the
15
+ // system prompt, the linter, and the runtime allowlist agree.
16
+
15
17
 
16
18
  // Per-identifier match rule, run over the whole masked source. Most banned
17
19
  // identifiers compile to a whole-word match.
@@ -164,156 +166,18 @@ const CONTRACT_RULES = CONTRACT.bannedApis.map((b) =>
164
166
  _ruleForIdentifier(b.identifier, b.reason),
165
167
  );
166
168
 
167
- // Replace the *content* of comments and string / template literals with
168
- // spaces so the banned-identifier scan only ever sees executable code. A
169
- // banned host-escape identifier (`window`, `document`, `eval`, `process`, …)
170
- // is only dangerous as a real identifier reference — never as prose in a
171
- // `//` comment or as character data inside a string — so matching the bare
172
- // word there is a false positive that blocks an otherwise-clean widget (a
173
- // comment that reads "the hour window the grid renders" must not trip
174
- // `no-window`).
175
- //
176
- // Newlines are preserved verbatim so reported line numbers still line up
177
- // with the original source. Template-literal `${ … }` expression holes are
178
- // left intact: real code lives there and must still be scanned (`${window}`
179
- // is a genuine escape). Backslash escapes inside strings/templates are
180
- // consumed so an escaped quote (`"\""`) doesn't end the literal early.
181
- function _stripNonCode(source, { keepStrings = false } = {}) {
182
- let out = "";
183
- const n = source.length;
184
- let mode = "code"; // code | line | block | sq | dq | tmpl
185
- // Brace depth, plus a stack of the depths at which an enclosing template
186
- // literal resumes — lets a `${ … }` hole (which may itself contain `{}`,
187
- // strings, or nested templates) be told apart from the literal text.
188
- let braceDepth = 0;
189
- const tmplStack = [];
190
- const keep = (ch) => {
191
- out += ch;
192
- };
193
- const blank = (ch) => {
194
- out += ch === "\n" || ch === "\r" ? ch : " ";
195
- };
196
- // String / template CONTENT: blanked for the banned-identifier scan
197
- // (prose must not trip `no-window`), kept for the host-API-URL scan,
198
- // whose whole job is to find a URL literal.
199
- const str = keepStrings ? keep : blank;
200
- let i = 0;
201
- while (i < n) {
202
- const ch = source[i];
203
- const nx = source[i + 1];
204
- if (mode === "code") {
205
- if (ch === "/" && nx === "/") {
206
- mode = "line";
207
- blank(ch);
208
- blank(nx);
209
- i += 2;
210
- } else if (ch === "/" && nx === "*") {
211
- mode = "block";
212
- blank(ch);
213
- blank(nx);
214
- i += 2;
215
- } else if (ch === "'") {
216
- mode = "sq";
217
- str(ch);
218
- i += 1;
219
- } else if (ch === '"') {
220
- mode = "dq";
221
- str(ch);
222
- i += 1;
223
- } else if (ch === "`") {
224
- mode = "tmpl";
225
- str(ch);
226
- i += 1;
227
- } else if (ch === "{") {
228
- braceDepth += 1;
229
- keep(ch);
230
- i += 1;
231
- } else if (ch === "}") {
232
- braceDepth -= 1;
233
- if (
234
- tmplStack.length > 0 &&
235
- tmplStack[tmplStack.length - 1] === braceDepth
236
- ) {
237
- tmplStack.pop();
238
- mode = "tmpl";
239
- str(ch);
240
- } else {
241
- keep(ch);
242
- }
243
- i += 1;
244
- } else {
245
- keep(ch);
246
- i += 1;
247
- }
248
- } else if (mode === "line") {
249
- if (ch === "\n") {
250
- mode = "code";
251
- keep(ch);
252
- } else {
253
- blank(ch);
254
- }
255
- i += 1;
256
- } else if (mode === "block") {
257
- if (ch === "*" && nx === "/") {
258
- mode = "code";
259
- blank(ch);
260
- blank(nx);
261
- i += 2;
262
- } else {
263
- blank(ch);
264
- i += 1;
265
- }
266
- } else if (mode === "sq" || mode === "dq") {
267
- const quote = mode === "sq" ? "'" : '"';
268
- if (ch === "\\") {
269
- str(ch);
270
- if (i + 1 < n) str(nx);
271
- i += 2;
272
- } else if (ch === quote) {
273
- mode = "code";
274
- str(ch);
275
- i += 1;
276
- } else if (ch === "\n") {
277
- // A bare newline terminates an unterminated string in JS; bail back
278
- // to code so malformed input can't blank the rest of the file.
279
- mode = "code";
280
- keep(ch);
281
- i += 1;
282
- } else {
283
- str(ch);
284
- i += 1;
285
- }
286
- } else {
287
- // mode === "tmpl"
288
- if (ch === "\\") {
289
- str(ch);
290
- if (i + 1 < n) str(nx);
291
- i += 2;
292
- } else if (ch === "`") {
293
- mode = "code";
294
- str(ch);
295
- i += 1;
296
- } else if (ch === "$" && nx === "{") {
297
- // Enter an expression hole. Remember the brace depth the template
298
- // resumes at, then count the `{` so its matching `}` is recognised.
299
- tmplStack.push(braceDepth);
300
- braceDepth += 1;
301
- mode = "code";
302
- keep(ch);
303
- keep(nx);
304
- i += 2;
305
- } else {
306
- str(ch);
307
- i += 1;
308
- }
309
- }
310
- }
311
- return out;
312
- }
169
+ // sc-6086: moved to source-mask.js so the packer's scanner masks source the
170
+ // same way this linter does. Alias kept — the rules below read _stripNonCode.
313
171
 
172
+ // Extra rules that don't map 1:1 to a banned identifier in the contract:
173
+ // host-internal imports that widgets must never touch.
174
+ //
314
175
  // REQ-WSDK-PLATFORM: `no-axios-import` is GONE. axios is on the vetted
315
- // import list now (`CONTRACT.vettedImports`). See linter.js for the
316
- // source-of-truth comment.
176
+ // import list now (`CONTRACT.vettedImports`) — widgets may call third-party
177
+ // APIs directly. Calls to the host's own /api/* surface are not gated at
178
+ // runtime — they simply 401 (widget code never gets the JWT), so the soft
179
+ // `no-host-api-url` rule below is the only thing that catches them, and it
180
+ // flags relative `/api/` literals as well as the real `/api/v1` prefix.
317
181
  const EXTRA_RULES = [
318
182
  {
319
183
  id: "no-auth-store-import",
@@ -331,12 +195,32 @@ const EXTRA_RULES = [
331
195
 
332
196
  const RULES = [...CONTRACT_RULES, ...EXTRA_RULES];
333
197
 
198
+ /**
199
+ * Returns the contract-derived list of banned identifiers. Exposed so the
200
+ * SDK contract test (and any host build step that wants to surface the
201
+ * list to humans) can read the canonical set without re-parsing the
202
+ * source.
203
+ */
334
204
  function bannedIdentifiers() {
335
205
  return CONTRACT.bannedApis.map((b) => b.identifier);
336
206
  }
337
207
 
338
- // REQ-WSDK-PLATFORM §3.4, §8: import-specifier validation. See linter.js
339
- // for the source-of-truth comment.
208
+ // REQ-WSDK-PLATFORM §3.4, §8: scan the source for every bare import
209
+ // specifier and validate it against `CONTRACT.vettedImports`.
210
+ //
211
+ // Recognised forms (string-level, no AST):
212
+ // import X from "spec"
213
+ // import { … } from "spec" (also wrapped over several lines)
214
+ // import * as X from "spec"
215
+ // import "spec"
216
+ // export { … } from "spec" / export * from "spec"
217
+ // const X = require("spec") // CJS — for completeness
218
+ //
219
+ // Relative imports within the bundle (`./foo.js`, `./shared/util.js`) are
220
+ // allowed — REQ-WSDK-PLATFORM §4.2 lets a split-impl widget share helpers
221
+ // between `widget.web.jsx` and `widget.native.jsx`. Parent-directory
222
+ // (`../`) and absolute (`/`, `http(s):`) paths are rejected to keep the
223
+ // bundle scope sealed.
340
224
  // The import clause follows the grammar (default, `* as ns`, `{ … }`), so
341
225
  // prose that merely says "import … from" rarely matches. A brace list stops
342
226
  // at the next import/export keyword and no two quantifiers share whitespace,
@@ -379,8 +263,8 @@ function _classifySpecifier(spec) {
379
263
 
380
264
  // The package root of a bare specifier: `leaflet/dist/leaflet.css` → "leaflet",
381
265
  // `@scope/pkg/sub` → "@scope/pkg". A subpath/asset import of a vetted package
382
- // belongs to that package, so the root carries the vetting + platforms.
383
- // Mirror of linter.js.
266
+ // (its CSS, a marker PNG, react-dom/client) belongs to that package, so the
267
+ // root carries the vetting + platform metadata.
384
268
  function _packageRoot(spec) {
385
269
  const parts = spec.split("/");
386
270
  // A traversal / empty segment is a non-canonical specifier — never resolve it
@@ -411,9 +295,7 @@ function subpathImportRootsFor(manifest) {
411
295
 
412
296
  function _importRules(source, manifest) {
413
297
  const findings = [];
414
- const allowed = new Map(
415
- CONTRACT.vettedImports.map((v) => [v.specifier, v]),
416
- );
298
+ const allowed = new Map(CONTRACT.vettedImports.map((v) => [v.specifier, v]));
417
299
  // Core-infra specifiers (react-dom, react-dom/client) are host-shimmed on
418
300
  // both runtimes but kept off the author-facing vetted list — accept them so
419
301
  // a bundle's transitive react-dom import isn't flagged import-not-vetted.
@@ -422,6 +304,9 @@ function _importRules(source, manifest) {
422
304
  allowed.set(spec, { specifier: spec, platforms: ["web", "native"] });
423
305
  }
424
306
  }
307
+ // Track declared `supportedPlatforms` so a widget that claims "web only"
308
+ // doesn't import a native-only package (and vice versa) without the
309
+ // marketplace listing being honest about which platforms ship.
425
310
  const declaredPlatforms =
426
311
  manifest &&
427
312
  Array.isArray(manifest.supportedPlatforms) &&
@@ -615,6 +500,13 @@ function _leafletRules(source) {
615
500
  return findings;
616
501
  }
617
502
 
503
+ // REQ-WSDK-PLATFORM §3.5: soft warning when source contains host-API URL
504
+ // substrings. `severity: "warning"` so a HUMAN-authored submission still
505
+ // publishes and the marketplace review queue flags it for a human pass.
506
+ // sc-3589 — an AI-agent widget has no such review queue, so its publish loop
507
+ // treats every finding (this one included) as a blocking check that drives a
508
+ // repair turn; a lone unrepaired one still publishes with a warning.
509
+ // False positives are possible: the needles are plain substring matches.
618
510
  // Explicit opt-out. A widget legitimately calling a THIRD-PARTY API with a
619
511
  // relative path against an `axios` baseURL cannot avoid the substring match,
620
512
  // and post-sc-3589 an unavoidable finding burns AI repair turns on correct
@@ -664,6 +556,8 @@ function _hostApiUrlRules(source) {
664
556
  line: i + 1,
665
557
  snippet: line.trim().slice(0, 200),
666
558
  });
559
+ // Only fire once per line — repeated needle hits on the same line
560
+ // are noise.
667
561
  break;
668
562
  }
669
563
  }
@@ -671,11 +565,26 @@ function _hostApiUrlRules(source) {
671
565
  return findings;
672
566
  }
673
567
 
674
- // sc-6970 — see linter.js for the rationale comment. The two files must stay
675
- // in lockstep (the contract test asserts behaviour-equivalence).
568
+ // sc-6970 — no-html-in-content.
569
+ //
570
+ // Widgets draw with React Native primitives, so there is no
571
+ // `dangerouslySetInnerHTML` on either host: an HTML tag inside a STRING stays
572
+ // literal text the reader sees as tag soup. This is the defect a generated
573
+ // "manage content" widget shipped — a hand-rolled B / I / H2 toolbar whose only
574
+ // possible action was splicing `<strong>` and `<br>` into a TextInput, leaving
575
+ // the author reading markup instead of a preview. `severity: "warning"` so a
576
+ // human submission still publishes and the review queue flags it, while the AI
577
+ // publish loop treats it as a blocking check that drives a repair turn.
578
+ //
579
+ // Only STRING content is scanned. JSX is excluded by construction (the masked
580
+ // and unmasked sources agree outside string literals), so a widget's own
581
+ // `<View>` / `<Text>` elements can never trigger it.
676
582
  const HTML_IN_CONTENT_RE =
677
583
  /<\/\s*[a-z][a-z0-9]*\s*>|<(?:br|hr|img|p|div|span|strong|em|b|i|u|s|h[1-6]|ul|ol|li|a|blockquote|code|pre|table|thead|tbody|tr|td|th)(?:\s[^<>]*)?\/?>/i;
678
584
 
585
+ // Everything the masked source hides but the string-keeping source shows is,
586
+ // by definition, string/template text. Column positions are preserved by both
587
+ // maskings, so the result still lines up 1:1 with the original lines.
679
588
  function _stringLiteralLines(source) {
680
589
  const withStrings = _stripNonCode(source, { keepStrings: true }).split(
681
590
  /\r?\n/,
@@ -712,12 +621,28 @@ function _htmlInContentRules(source) {
712
621
  return findings;
713
622
  }
714
623
 
715
- // sc-5619 — see linter.js for the rationale comment. The two files must stay
716
- // in lockstep (the contract test asserts behaviour-equivalence).
624
+ // sc-5619 — no-hand-built-page-url.
625
+ //
626
+ // A published app answers on two URL shapes: `/play/<tenantId>/page/<slug>` on
627
+ // the platform host, and `/page/<slug>` at the workspace's own domain. The host
628
+ // owns that choice (`playerPath.js` on web, react-navigation natively), so a
629
+ // widget that builds the path itself — almost always from the `workspace.id` the
630
+ // SDK hands it — hardcodes ONE shape and breaks on the other: on a custom domain
631
+ // it rewrites the customer's clean domain back to the platform form and
632
+ // republishes their workspace id in the address bar.
633
+ //
634
+ // An `error` with no opt-out directive, like `no-external-translation-api`: the
635
+ // SDK covers every legitimate case, so there is no correct code to rescue.
636
+ // Navigate with `useNavigation().goTo(pageId)`, and follow a link whose shape
637
+ // the widget does NOT control (a datastore value, a notification link) with
638
+ // `useNavigation().openLink(link)` — that routes through the shared resolver,
639
+ // which already accepts either shape and rebuilds the one this host uses.
717
640
  const PAGE_URL_RE = /["'`][^"'`]*\/play\/|\/play\/\$\{/;
718
641
 
719
642
  function _handBuiltPageUrlRules(source) {
720
643
  const findings = [];
644
+ // Strings kept, comments blanked: the literal lives IN a string, but a comment
645
+ // or doc line quoting `/play/<id>` must not block a publish.
721
646
  const lines = _stripNonCode(source, { keepStrings: true }).split(/\r?\n/);
722
647
  for (let i = 0; i < lines.length; i += 1) {
723
648
  if (!PAGE_URL_RE.test(lines[i])) continue;
@@ -739,8 +664,20 @@ function _handBuiltPageUrlRules(source) {
739
664
  return findings;
740
665
  }
741
666
 
742
- // sc-4085 — see linter.js for the rationale comment. The two files must stay
743
- // in lockstep (the contract test asserts behaviour-equivalence).
667
+ // sc-4085 — no-external-translation-api.
668
+ //
669
+ // Translation is a platform capability, not a third-party API: `useTranslate()`
670
+ // (sc-3783) routes content through the platform's provider key, a durable
671
+ // tenant-scoped cache and a metered budget. A widget that fetches a vendor
672
+ // endpoint itself gets none of that, and ships an unvetted third-party
673
+ // dependency into every app the widget is installed in — the same reason a
674
+ // widget may never wire its own payment processor.
675
+ //
676
+ // This is an `error`, not a `warning` like `no-host-api-url`, and it carries no
677
+ // opt-out directive: a hostname match is unambiguous (unlike a relative `/api/`
678
+ // path, which a legitimate third-party call cannot always avoid), so there is
679
+ // no correct code for the directive to rescue. A workspace that needs a
680
+ // different provider changes the platform's translation key, not the widget.
744
681
  function _translationApiRules(source) {
745
682
  const findings = [];
746
683
  const hosts = CONTRACT.translationApiHosts || [];
@@ -802,9 +739,23 @@ function _scopeMintRules(source) {
802
739
  return findings;
803
740
  }
804
741
 
805
- // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-required-for-user-mutation. See
806
- // linter.js for the rationale comment. The two files must stay in
807
- // lockstep (the contract test asserts behaviour-equivalence).
742
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-required-for-user-mutation.
743
+ //
744
+ // A widget that calls `useUsers().invite()` / `.deactivate()` /
745
+ // `.reactivate()` / `.sendPasswordReset()` / `.update()` / `.remove()` MUST declare
746
+ // `users.write:*` in its
747
+ // manifest's `requestedScopes`; similarly `useGroups()` mutation methods
748
+ // require `groups.write:*`. The rule is enforced statically so a manifest
749
+ // that drifts from the source (e.g. an author forgot to add the scope
750
+ // after wiring up the mutation) is rejected at submission time.
751
+ //
752
+ // The check is pattern-based: scan the source for `.invite(`, `.deactivate(`
753
+ // etc. and only fire when the source ALSO contains `useUsers(` / `useGroups(`
754
+ // nearby (i.e. the destructured callee likely originated from the hook).
755
+ // False positives are accepted in exchange for keeping the linter
756
+ // AST-free; an author whose code happened to spell `.invite(` for an
757
+ // unrelated reason can opt out with a `// @appstudio-skip-scope-check`
758
+ // trailing comment on the offending line.
808
759
  const USER_MUTATION_METHODS = [
809
760
  "invite",
810
761
  "deactivate",
@@ -819,7 +770,12 @@ const USER_MUTATION_METHODS = [
819
770
  // useUsers().remove() must declare `users.delete:*` so the static contract
820
771
  // matches the backend gate on DELETE /api/v1/app/users/:userId.
821
772
  const USER_DELETE_METHODS = ["remove"];
822
- const GROUP_MUTATION_METHODS = ["create", "remove", "addMember", "removeMember"];
773
+ const GROUP_MUTATION_METHODS = [
774
+ "create",
775
+ "remove",
776
+ "addMember",
777
+ "removeMember",
778
+ ];
823
779
  const SKIP_COMMENT = /\/\/[^\n]*@appstudio-skip-scope-check/;
824
780
 
825
781
  function _scopeRules(source, manifest) {
@@ -839,8 +795,7 @@ function _scopeRules(source, manifest) {
839
795
  if (!reads) {
840
796
  findings.push({
841
797
  rule: "scope-required-for-useUsers",
842
- label:
843
- "useUsers() requires `users.read:*` in manifest.requestedScopes",
798
+ label: "useUsers() requires `users.read:*` in manifest.requestedScopes",
844
799
  line: 0,
845
800
  snippet: "",
846
801
  });
@@ -917,10 +872,7 @@ function _scopeRules(source, manifest) {
917
872
  for (const m of USER_MUTATION_METHODS) {
918
873
  const re = new RegExp(`\\.${m}\\s*\\(`);
919
874
  if (re.test(line)) {
920
- if (
921
- !declared.has("users.write:*") &&
922
- !declared.has("users.write")
923
- ) {
875
+ if (!declared.has("users.write:*") && !declared.has("users.write")) {
924
876
  findings.push({
925
877
  rule: "scope-required-for-user-mutation",
926
878
  label: `useUsers().${m}() requires \`users.write:*\` in manifest.requestedScopes`,
@@ -963,6 +915,10 @@ function _scopeRules(source, manifest) {
963
915
  const needsArgs = m === "remove" ? "\\s*[^)\\s]" : "";
964
916
  const re = new RegExp(`${lookbehind}\\.${m}\\s*\\(${needsArgs}`);
965
917
  if (re.test(line)) {
918
+ // `.remove(` is also a useUsers method; only blame groups
919
+ // when the source also uses the groups hook AND the source
920
+ // doesn't ALSO use the users hook (an ambiguous .remove(
921
+ // is best left to the user-mutation rule above).
966
922
  if (m === "remove" && usesUsersHook) continue;
967
923
  if (
968
924
  !declared.has("groups.write:*") &&
@@ -984,10 +940,24 @@ function _scopeRules(source, manifest) {
984
940
  return findings;
985
941
  }
986
942
 
943
+ /**
944
+ * Lint a JavaScript source string.
945
+ *
946
+ * REQ-WSDK-PLATFORM update: findings can now carry a `severity` field
947
+ * (`"error"` | `"warning"`). The `ok` flag is true iff NO `severity:
948
+ * "error"` finding exists — warnings (currently just `no-host-api-url`)
949
+ * surface to the reviewer but do not block publish. Findings without an
950
+ * explicit `severity` are treated as errors for back-compat with existing
951
+ * rules.
952
+ *
953
+ * @param {string} source
954
+ * @param {{ manifest?: { requestedScopes?: string[], supportedPlatforms?: string[] } }} [options]
955
+ * @returns {{ ok: boolean, findings: Array<{ rule: string, severity?: "error" | "warning", label: string, line: number, snippet: string }> }}
956
+ */
987
957
  // REQ-WIDGET-ACTION — validate manifest-declared server actions. These run in
988
958
  // the isolated-vm action runner, NOT in the rendered component, so they are
989
959
  // validated structurally here (shape + caps) rather than scanned as component
990
- // source. Mirrors the validator in manifest.cjs; emits `error` findings so a
960
+ // source. Mirrors the validator in manifest.js; emits `error` findings so a
991
961
  // malformed / oversized / mis-triggered action blocks publish.
992
962
  function _manifestActionRules(manifest) {
993
963
  const findings = [];
@@ -995,7 +965,13 @@ function _manifestActionRules(manifest) {
995
965
  const validTriggers = new Set(CONTRACT.actionTriggerTypes);
996
966
  const maxBytes = CONTRACT.actionScriptMaxBytes;
997
967
  const push = (label) =>
998
- findings.push({ rule: "manifest-action", severity: "error", label, line: 0, snippet: "" });
968
+ findings.push({
969
+ rule: "manifest-action",
970
+ severity: "error",
971
+ label,
972
+ line: 0,
973
+ snippet: "",
974
+ });
999
975
  if (!Array.isArray(manifest.actions)) {
1000
976
  push("manifest.actions must be an array (omit it or use [] for none)");
1001
977
  return findings;
@@ -1018,9 +994,16 @@ function _manifestActionRules(manifest) {
1018
994
  }
1019
995
  const triggerTypes = normaliseActionTriggerTypes(a);
1020
996
  if (triggerTypes === null) {
1021
- push(`manifest.actions[].triggerTypes must be a non-empty array of unique values from ${[...validTriggers].join(", ")}`);
1022
- } else if (triggerTypes.includes("schedule") && (typeof a.scheduleCron !== "string" || !a.scheduleCron)) {
1023
- push("manifest.actions[].scheduleCron is required when triggerTypes contains 'schedule'");
997
+ push(
998
+ `manifest.actions[].triggerTypes must be a non-empty array of unique values from ${[...validTriggers].join(", ")}`,
999
+ );
1000
+ } else if (
1001
+ triggerTypes.includes("schedule") &&
1002
+ (typeof a.scheduleCron !== "string" || !a.scheduleCron)
1003
+ ) {
1004
+ push(
1005
+ "manifest.actions[].scheduleCron is required when triggerTypes contains 'schedule'",
1006
+ );
1024
1007
  }
1025
1008
  if (typeof a.scriptSource !== "string" || a.scriptSource.length === 0) {
1026
1009
  push("manifest.actions[].scriptSource must be a non-empty string");
@@ -1034,7 +1017,9 @@ function _manifestActionRules(manifest) {
1034
1017
  }
1035
1018
  }
1036
1019
  if (a.triggerTableId !== undefined || a.apiKeyId !== undefined) {
1037
- push("manifest.actions[] must not include triggerTableId or apiKeyId — those are tenant-local and bound after install");
1020
+ push(
1021
+ "manifest.actions[] must not include triggerTableId or apiKeyId — those are tenant-local and bound after install",
1022
+ );
1038
1023
  }
1039
1024
  }
1040
1025
  return findings;
@@ -1047,7 +1032,7 @@ function _manifestActionRules(manifest) {
1047
1032
  // cleanly and fails only at runtime load ("does not provide an export named
1048
1033
  // 'House'"), leaving the widget blank. This rule rejects such imports at publish
1049
1034
  // so the agent's repair loop fixes them. The valid set is committed data
1050
- // (lucideIconNames.cjs) because the linter runs where lucide is not installed.
1035
+ // (lucideIconNames.js) because the linter runs where lucide is not installed.
1051
1036
  const LUCIDE_NAME_SET = new Set(LUCIDE_ICON_NAMES);
1052
1037
  // `[^}]*` (not `[\s\S]*?`) so the brace capture can never cross a `}` into a
1053
1038
  // neighbouring import — otherwise a preceding `import { View, Pressable } from
@@ -1270,14 +1255,28 @@ function _selfContainedReferenceRules(source) {
1270
1255
  return findings;
1271
1256
  }
1272
1257
 
1273
- // sc-3466 / sc-3493 — percentage height on an <Image> collapses to 0 against a
1274
- // content-sized parent, so the image loads but renders invisible on both hosts.
1275
- // `severity: "warning"`: the same value is correct under a definite-height
1276
- // parent, which this AST-free scan cannot see. Mirror of linter.js.
1258
+ // sc-3466 / sc-3493 — percentage height on an <Image> collapses to 0.
1259
+ // React Native / Yoga resolves a percentage `height` against the PARENT's
1260
+ // height; a content-sized parent has none, so the value resolves to 0 and the
1261
+ // image fetches its uri but renders invisible on BOTH the web Player and the
1262
+ // native Expo export. sc-3466 taught the rule to the AI widget agent's
1263
+ // DEFAULT_SYSTEM_PROMPT, which remains the primary guard — this is the
1264
+ // mechanical belt-and-braces catch for a model (or a human author) that
1265
+ // ignores it.
1266
+ //
1267
+ // `severity: "warning"` deliberately: `height: "100%"` IS correct inside a
1268
+ // parent with a definite height (a fixed-height hero), which the AST-free scan
1269
+ // cannot see, so a blocking rule would reject valid widgets.
1270
+ //
1271
+ // Scope is the literal inline form only. A height threaded through a variable
1272
+ // or a StyleSheet object stays out of reach of a text scan.
1277
1273
  const _IMAGE_TAG_RE = /<(Image|ImageBackground)\b/g;
1278
1274
  const _PERCENT_HEIGHT_RE =
1279
1275
  /(^|[{,;\s])height\s*:\s*(["'])\s*\d+(?:\.\d+)?\s*%\s*\2/g;
1280
1276
 
1277
+ // Index of the `>` closing the JSX opening tag that starts at `from`. Braces
1278
+ // and string literals are skipped so a `>` inside `onPress={() => …}` or an
1279
+ // attribute string can't end the tag early.
1281
1280
  function _jsxOpenTagEnd(source, from) {
1282
1281
  let depth = 0;
1283
1282
  let quote = "";
@@ -1324,8 +1323,22 @@ function _bundledAssetRules(source, assetFiles) {
1324
1323
  return findings;
1325
1324
  }
1326
1325
 
1327
- // sc-4649 / sc-4686 — see linter.js for the rationale comments. The two
1328
- // files must stay in lockstep (a test asserts behaviour-equivalence).
1326
+ // sc-4649 / sc-4686 — payment-currency.
1327
+ //
1328
+ // A widget must not name a currency AT ALL. Each workspace charges its app users
1329
+ // in one currency of its own choosing, the owner can change it long after the
1330
+ // widget was authored, and the server refuses a charge in anything else — so
1331
+ // omitting `currency` is the ONLY always-correct call: the platform applies the
1332
+ // workspace's own, and an existing widget follows a switch with no rebuild.
1333
+ //
1334
+ // That makes this rule workspace-INDEPENDENT (it needs no expected value passed
1335
+ // in, unlike its first sc-4649 form) and therefore it also fires in a bare
1336
+ // `appstudio-widget lint`, before a third-party developer ever uploads.
1337
+ //
1338
+ // `error`, not a warning like `payment-error-not-branched`: a literal here either
1339
+ // disagrees with the workspace and kills every checkout, or agrees today and
1340
+ // silently starts lying the moment the owner switches. Neither is code worth an
1341
+ // opt-out directive.
1329
1342
  const REQUEST_PAYMENT_CALL = "requestPayment(";
1330
1343
  // A generous window: the options object is usually inline, occasionally spread
1331
1344
  // over a few lines. Bounded so an unbalanced source can't scan to EOF.
@@ -1690,6 +1703,8 @@ function _mutationHookCalls(code) {
1690
1703
 
1691
1704
  function _imagePercentHeightRules(source) {
1692
1705
  const findings = [];
1706
+ // Comments are blanked (string contents kept) so a commented-out example —
1707
+ // including the one in this rule's own docs — is never flagged.
1693
1708
  const code = _stripNonCode(source, { keepStrings: true });
1694
1709
  const sourceLines = source.split(/\r?\n/);
1695
1710
  const lineAt = _lineCounter(code);
@@ -1722,16 +1737,38 @@ function _imagePercentHeightRules(source) {
1722
1737
  return findings;
1723
1738
  }
1724
1739
 
1725
- // sc-7274 — a percentage `flexBasis` sizes the MAIN axis, which in a column is
1726
- // the HEIGHT: under a definite-height parent (a Grid cell stretches its child
1727
- // on the web Player) the child fills the container and, with `flex-shrink: 0`,
1728
- // overflows over the content below. `width: "100%"` is the direction-agnostic
1729
- // replacement. Mirror of linter.js (see there for the full rationale).
1740
+ // sc-7274 — a percentage `flexBasis` sizes the MAIN axis, and in a column that
1741
+ // is the HEIGHT.
1742
+ //
1743
+ // `flexBasis: "100%"` written to mean "full width" is only that in a ROW. In a
1744
+ // column parent it asks for the parent's whole HEIGHT, and the moment an
1745
+ // ancestor makes that height definite — a Grid cell stretches its child on the
1746
+ // web Player — every such sibling becomes as tall as the container. `View`
1747
+ // defaults to `flex-shrink: 0`, so they cannot shrink back: they overflow
1748
+ // hundreds of pixels down over whatever follows. That shipped a customer form
1749
+ // with a blank band mid-layout and three fields painted across the submit row,
1750
+ // visible only once published (the builder canvas leaves the height auto,
1751
+ // where the percentage degrades to `content` and the form looks right).
1752
+ //
1753
+ // `width: "100%"` is the direction-agnostic replacement — it fills the row in a
1754
+ // column parent AND takes its own line in a wrap row — so a percentage basis is
1755
+ // never the only way to say it.
1756
+ //
1757
+ // `severity: "warning"`, like `image-percent-height`: in a row parent the value
1758
+ // IS correct, and an AST-free scan cannot see the parent's `flexDirection`
1759
+ // (the style usually lives in a shared field/cell helper). A warning still
1760
+ // drives an AI-agent repair turn, which is the point of the rule.
1761
+ //
1762
+ // Scope is the literal inline form, including the ternary a responsive cell
1763
+ // writes (`flexBasis: stacked ? "100%" : CARD`). A basis threaded through a
1764
+ // variable stays beyond the scan; the designer skill remains the first guard.
1730
1765
  const _FLEX_BASIS_PERCENT_RE =
1731
1766
  /\bflexBasis\s*:[^,}\n]*?(["'])\s*(\d+(?:\.\d+)?)\s*%\s*\1/g;
1732
1767
 
1733
1768
  function _flexBasisPercentRules(source) {
1734
1769
  const findings = [];
1770
+ // Comments blanked, strings kept: the value under test IS a string literal,
1771
+ // so this rule's own documentation must not flag itself.
1735
1772
  const code = _stripNonCode(source, { keepStrings: true });
1736
1773
  const sourceLines = source.split(/\r?\n/);
1737
1774
  const lineAt = _lineCounter(code);
@@ -1742,6 +1779,8 @@ function _flexBasisPercentRules(source) {
1742
1779
  findings.push({
1743
1780
  rule: "flex-basis-percent",
1744
1781
  severity: "warning",
1782
+ // Kept under FINDING_LABEL_BUDGET: the repair turn truncates the whole
1783
+ // message at 300 chars, which would otherwise eat the fix and the line.
1745
1784
  label:
1746
1785
  `flexBasis "${hit[2]}%" sizes the MAIN axis — in a column that is the ` +
1747
1786
  `HEIGHT, so under a definite-height parent it fills the container and ` +
@@ -2081,14 +2120,28 @@ function _fieldInBorderedRowRules(source) {
2081
2120
  return findings;
2082
2121
  }
2083
2122
 
2084
- // sc-4913 — a measured frame INCLUDES the element's own padding, so cells sized
2085
- // from it overflow the content box and the last one wraps into an empty column.
2086
- // Mirror of linter.js (see there for the full rationale).
2123
+ // sc-4913 — a measured frame INCLUDES the element's own padding.
2124
+ //
2125
+ // `onLayout` reports the frame width, and padding sits inside that frame. A
2126
+ // widget that measures its padded root and then sizes fixed-pixel grid columns
2127
+ // to fill the measured number overflows its own content box by left+right
2128
+ // padding, so the last column wraps: a whole empty column of whitespace beside
2129
+ // the cards, on both hosts. Nothing else catches it — the widget renders and
2130
+ // every other gate passes.
2131
+ //
2132
+ // Fires only when the measurement feeds sizing arithmetic. A padded box
2133
+ // measured just to branch narrow/wide is off by one padding pair and correct
2134
+ // enough; `isNarrowWidth` is the sanctioned reader for that. A widget that DID
2135
+ // subtract its own padding matches too — no text scan can verify the
2136
+ // subtraction — and that is deliberate: the remediation (measure an unpadded
2137
+ // inner View) is correct for it as well, and retires the arithmetic entirely.
2087
2138
  const _JSX_TAG_RE = /<([A-Z][\w.]*)\b/g;
2088
2139
  const _ON_LAYOUT_ATTR_RE = /\bonLayout\s*=/;
2089
2140
  const _OWN_PADDING_RE = /\bpadding(?:Horizontal|Left|Right|Start|End)?\s*:/;
2090
2141
  const _WIDTH_HOLDER_RE =
2091
2142
  /const\s*\[\s*([\w$]+)\s*,\s*([\w$]+)\s*\]\s*=\s*(useState|useContainerWidth)\b/g;
2143
+ // A setter fed a layout width within this many chars of the call is measuring:
2144
+ // covers `setW(e.nativeEvent.layout.width)` and `setW(Math.round(…))` alike.
2092
2145
  const _LAYOUT_FEED_WINDOW = 80;
2093
2146
 
2094
2147
  function _measuredWidthNames(code) {
@@ -2131,6 +2184,8 @@ function _sizesFromMeasuredWidth(code, names) {
2131
2184
  }
2132
2185
 
2133
2186
  function _measuredPaddingRules(source) {
2187
+ // Comments are blanked so this rule's own documented anti-pattern, and a
2188
+ // commented-out draft, never fire.
2134
2189
  const code = _stripNonCode(source, { keepStrings: true });
2135
2190
  const measured = _measuredWidthNames(code);
2136
2191
  if (!_sizesFromMeasuredWidth(code, measured)) return [];
@@ -2146,6 +2201,8 @@ function _measuredPaddingRules(source) {
2146
2201
  if (!_ON_LAYOUT_ATTR_RE.test(attrs)) continue;
2147
2202
  const padded = _OWN_PADDING_RE.exec(attrs);
2148
2203
  if (!padded) continue;
2204
+ // Report the padding, not the tag: on a multi-line open tag the style line
2205
+ // is the actionable one.
2149
2206
  const line = lineAt(tag.index + padded.index);
2150
2207
  findings.push({
2151
2208
  rule: "measured-width-ignores-padding",
@@ -2161,9 +2218,13 @@ function _measuredPaddingRules(source) {
2161
2218
  return findings;
2162
2219
  }
2163
2220
 
2164
- // Narrow a split-impl widget's manifest to the platform a single bundle file
2165
- // ships to, so `import-platform-mismatch` lints each file against what it
2166
- // actually targets. Mirror of linter.js.
2221
+ /**
2222
+ * Narrow a split-impl widget's manifest to the platform a single bundle file
2223
+ * ships to, so `import-platform-mismatch` lints each file against what it
2224
+ * actually targets. `widget.web.jsx` → ["web"], `widget.native.jsx` →
2225
+ * ["native"], cross-platform `widget.jsx` keeps the manifest's union claim.
2226
+ * Returns a shallow clone; the original is untouched.
2227
+ */
2167
2228
  function narrowManifestForFile(manifest, filename) {
2168
2229
  if (!manifest || typeof manifest !== "object") return manifest;
2169
2230
  if (filename === "widget.web.jsx") {
@@ -2761,6 +2822,7 @@ function lintSource(source, options) {
2761
2822
  // Comments and string text are masked, so a banned identifier fires only on
2762
2823
  // code, never on the same word in prose or string data.
2763
2824
  findings.push(..._bannedApiFindings(source, lines));
2825
+ // REQ-WSDK-PLATFORM §3.4: import-specifier validation.
2764
2826
  findings.push(
2765
2827
  ..._importRules(source, options && options.manifest).map((f) => ({
2766
2828
  ...f,
@@ -2768,6 +2830,7 @@ function lintSource(source, options) {
2768
2830
  })),
2769
2831
  );
2770
2832
  findings.push(..._leafletRules(source));
2833
+ // REQ-WSDK-PLATFORM §3.5: soft host-API URL warning (does not block).
2771
2834
  findings.push(..._hostApiUrlRules(source));
2772
2835
  // sc-6970 — soft warning: HTML markup spliced into a content string.
2773
2836
  findings.push(..._htmlInContentRules(source));
@@ -2776,7 +2839,10 @@ function lintSource(source, options) {
2776
2839
  findings.push(..._handBuiltPageUrlRules(source));
2777
2840
  findings.push(..._lucideIconRules(source));
2778
2841
  findings.push(..._bundledAssetRules(source, options && options.assetFiles));
2842
+ // sc-2353 / sc-5897 — widget source must be self-contained: a referenced
2843
+ // `React` global or SDK export with no import throws at render.
2779
2844
  findings.push(..._selfContainedReferenceRules(source));
2845
+ // sc-3493 — soft warning: percentage height on an <Image> collapses to 0.
2780
2846
  findings.push(..._imagePercentHeightRules(source));
2781
2847
  // sc-7274 — soft warning: a percentage flexBasis sizes the main axis, so in a
2782
2848
  // column it claims the parent's whole height and overflows.
@@ -2800,24 +2866,27 @@ function lintSource(source, options) {
2800
2866
  findings.push(..._rawUseMemoIntoDatastoreQueryRules(source));
2801
2867
  findings.push(..._handRolledWriteGateRules(source));
2802
2868
  findings.push(..._rowActionGatingRules(source));
2869
+ // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
2870
+ // line-by-line scan so banned-identifier findings stay first in the
2871
+ // output.
2803
2872
  findings.push(
2804
2873
  ..._scopeRules(source, options && options.manifest).map((f) => ({
2805
2874
  ...f,
2806
2875
  severity: f.severity || "error",
2807
2876
  })),
2808
2877
  );
2878
+ // REQ-WIDGET-ACTION — structural validation of manifest-declared actions.
2809
2879
  findings.push(..._manifestActionRules(options && options.manifest));
2810
2880
  findings.push(..._badgeCounterRules(source, options && options.manifest));
2811
2881
  const hasErrors = findings.some((f) => f.severity !== "warning");
2812
2882
  return { ok: !hasErrors, findings };
2813
2883
  }
2814
-
2815
2884
  module.exports = {
2816
- lintSource,
2885
+ bannedIdentifiers,
2886
+ subpathImportRootsFor,
2887
+ narrowManifestForFile,
2817
2888
  lintStyleWiring,
2818
2889
  callsBasicStyle,
2819
2890
  lintEditability,
2820
- bannedIdentifiers,
2821
- narrowManifestForFile,
2822
- subpathImportRootsFor,
2891
+ lintSource,
2823
2892
  };