@colixsystems/widget-sdk 0.276.0 → 0.277.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/dist/bundled-assets.cjs +6 -2
- package/dist/bundled-assets.js +0 -1
- package/dist/contract.cjs +5 -4
- package/dist/contract.js +2 -2
- package/dist/define-widget.js +8 -1
- package/dist/index.d.ts +2 -3
- package/dist/linter.cjs +290 -212
- package/dist/linter.js +11 -2
- package/dist/lucideIconName.cjs +14 -5
- package/dist/manifest.cjs +7 -15
- package/dist/manifest.js +10 -69
- package/dist/navigation.cjs +31 -11
- package/dist/source-mask.cjs +169 -0
- package/dist/theme-appearance.cjs +7 -5
- package/dist/theme-appearance.js +1 -1
- package/dist/theme-background.cjs +3 -8
- package/dist/theme-components.cjs +13 -10
- package/package.json +2 -2
- package/src/contract.js +2 -2
package/dist/linter.cjs
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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
|
-
//
|
|
168
|
-
//
|
|
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`)
|
|
316
|
-
//
|
|
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:
|
|
339
|
-
//
|
|
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
|
|
383
|
-
//
|
|
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 —
|
|
675
|
-
//
|
|
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 —
|
|
716
|
-
//
|
|
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 —
|
|
743
|
-
//
|
|
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.
|
|
806
|
-
//
|
|
807
|
-
//
|
|
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 = [
|
|
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.
|
|
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({
|
|
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(
|
|
1022
|
-
|
|
1023
|
-
|
|
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(
|
|
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.
|
|
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
|
|
1274
|
-
//
|
|
1275
|
-
//
|
|
1276
|
-
//
|
|
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 —
|
|
1328
|
-
//
|
|
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.
|
|
@@ -1576,13 +1589,22 @@ function _rawUseMemoIntoDatastoreQueryRules(source) {
|
|
|
1576
1589
|
// `.roles` re-implements the table ACL client-side and drifts from it the
|
|
1577
1590
|
// moment the ACL changes; `useCanWrite(tableId)` reads the same rule the
|
|
1578
1591
|
// write endpoint enforces. Flags only when the file has NOT already reached
|
|
1579
|
-
// for it.
|
|
1592
|
+
// for it, and only on useUser()'s own value: `theme.typography.roles` is a
|
|
1593
|
+
// style read (sc-15204).
|
|
1594
|
+
function _readsUserRoles(code) {
|
|
1595
|
+
const named = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*useUser\s*\(\s*\)/.exec(code);
|
|
1596
|
+
if (named && new RegExp(`\\b${named[1].replace(/\$/g, "\\$")}\\??\\.(groupIds|roles)\\b`).test(code)) return true;
|
|
1597
|
+
const destructured = /\b(?:const|let|var)\s*\{([^}]*)\}\s*=\s*useUser\s*\(\s*\)/.exec(code);
|
|
1598
|
+
if (destructured && /\b(groupIds|roles)\b/.test(destructured[1])) return true;
|
|
1599
|
+
return /\buseUser\s*\(\s*\)\s*\??\.(groupIds|roles)\b/.test(code);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1580
1602
|
function _handRolledWriteGateRules(source) {
|
|
1581
1603
|
const code = _stripNonCode(source);
|
|
1582
1604
|
if (/\buseCanWrite\s*\(/.test(code)) return [];
|
|
1583
1605
|
const userCall = /\buseUser\s*\(\s*\)/.exec(code);
|
|
1584
1606
|
if (!userCall) return [];
|
|
1585
|
-
if (
|
|
1607
|
+
if (!_readsUserRoles(code)) return [];
|
|
1586
1608
|
const hasWrite =
|
|
1587
1609
|
/\.(create|update)\s*\(/.test(code) ||
|
|
1588
1610
|
/\buseDatastoreMutation\s*\(/.test(code);
|
|
@@ -1690,6 +1712,8 @@ function _mutationHookCalls(code) {
|
|
|
1690
1712
|
|
|
1691
1713
|
function _imagePercentHeightRules(source) {
|
|
1692
1714
|
const findings = [];
|
|
1715
|
+
// Comments are blanked (string contents kept) so a commented-out example —
|
|
1716
|
+
// including the one in this rule's own docs — is never flagged.
|
|
1693
1717
|
const code = _stripNonCode(source, { keepStrings: true });
|
|
1694
1718
|
const sourceLines = source.split(/\r?\n/);
|
|
1695
1719
|
const lineAt = _lineCounter(code);
|
|
@@ -1722,16 +1746,38 @@ function _imagePercentHeightRules(source) {
|
|
|
1722
1746
|
return findings;
|
|
1723
1747
|
}
|
|
1724
1748
|
|
|
1725
|
-
// sc-7274 — a percentage `flexBasis` sizes the MAIN axis,
|
|
1726
|
-
// the HEIGHT
|
|
1727
|
-
//
|
|
1728
|
-
//
|
|
1729
|
-
//
|
|
1749
|
+
// sc-7274 — a percentage `flexBasis` sizes the MAIN axis, and in a column that
|
|
1750
|
+
// is the HEIGHT.
|
|
1751
|
+
//
|
|
1752
|
+
// `flexBasis: "100%"` written to mean "full width" is only that in a ROW. In a
|
|
1753
|
+
// column parent it asks for the parent's whole HEIGHT, and the moment an
|
|
1754
|
+
// ancestor makes that height definite — a Grid cell stretches its child on the
|
|
1755
|
+
// web Player — every such sibling becomes as tall as the container. `View`
|
|
1756
|
+
// defaults to `flex-shrink: 0`, so they cannot shrink back: they overflow
|
|
1757
|
+
// hundreds of pixels down over whatever follows. That shipped a customer form
|
|
1758
|
+
// with a blank band mid-layout and three fields painted across the submit row,
|
|
1759
|
+
// visible only once published (the builder canvas leaves the height auto,
|
|
1760
|
+
// where the percentage degrades to `content` and the form looks right).
|
|
1761
|
+
//
|
|
1762
|
+
// `width: "100%"` is the direction-agnostic replacement — it fills the row in a
|
|
1763
|
+
// column parent AND takes its own line in a wrap row — so a percentage basis is
|
|
1764
|
+
// never the only way to say it.
|
|
1765
|
+
//
|
|
1766
|
+
// `severity: "warning"`, like `image-percent-height`: in a row parent the value
|
|
1767
|
+
// IS correct, and an AST-free scan cannot see the parent's `flexDirection`
|
|
1768
|
+
// (the style usually lives in a shared field/cell helper). A warning still
|
|
1769
|
+
// drives an AI-agent repair turn, which is the point of the rule.
|
|
1770
|
+
//
|
|
1771
|
+
// Scope is the literal inline form, including the ternary a responsive cell
|
|
1772
|
+
// writes (`flexBasis: stacked ? "100%" : CARD`). A basis threaded through a
|
|
1773
|
+
// variable stays beyond the scan; the designer skill remains the first guard.
|
|
1730
1774
|
const _FLEX_BASIS_PERCENT_RE =
|
|
1731
1775
|
/\bflexBasis\s*:[^,}\n]*?(["'])\s*(\d+(?:\.\d+)?)\s*%\s*\1/g;
|
|
1732
1776
|
|
|
1733
1777
|
function _flexBasisPercentRules(source) {
|
|
1734
1778
|
const findings = [];
|
|
1779
|
+
// Comments blanked, strings kept: the value under test IS a string literal,
|
|
1780
|
+
// so this rule's own documentation must not flag itself.
|
|
1735
1781
|
const code = _stripNonCode(source, { keepStrings: true });
|
|
1736
1782
|
const sourceLines = source.split(/\r?\n/);
|
|
1737
1783
|
const lineAt = _lineCounter(code);
|
|
@@ -1742,6 +1788,8 @@ function _flexBasisPercentRules(source) {
|
|
|
1742
1788
|
findings.push({
|
|
1743
1789
|
rule: "flex-basis-percent",
|
|
1744
1790
|
severity: "warning",
|
|
1791
|
+
// Kept under FINDING_LABEL_BUDGET: the repair turn truncates the whole
|
|
1792
|
+
// message at 300 chars, which would otherwise eat the fix and the line.
|
|
1745
1793
|
label:
|
|
1746
1794
|
`flexBasis "${hit[2]}%" sizes the MAIN axis — in a column that is the ` +
|
|
1747
1795
|
`HEIGHT, so under a definite-height parent it fills the container and ` +
|
|
@@ -2081,14 +2129,28 @@ function _fieldInBorderedRowRules(source) {
|
|
|
2081
2129
|
return findings;
|
|
2082
2130
|
}
|
|
2083
2131
|
|
|
2084
|
-
// sc-4913 — a measured frame INCLUDES the element's own padding
|
|
2085
|
-
//
|
|
2086
|
-
//
|
|
2132
|
+
// sc-4913 — a measured frame INCLUDES the element's own padding.
|
|
2133
|
+
//
|
|
2134
|
+
// `onLayout` reports the frame width, and padding sits inside that frame. A
|
|
2135
|
+
// widget that measures its padded root and then sizes fixed-pixel grid columns
|
|
2136
|
+
// to fill the measured number overflows its own content box by left+right
|
|
2137
|
+
// padding, so the last column wraps: a whole empty column of whitespace beside
|
|
2138
|
+
// the cards, on both hosts. Nothing else catches it — the widget renders and
|
|
2139
|
+
// every other gate passes.
|
|
2140
|
+
//
|
|
2141
|
+
// Fires only when the measurement feeds sizing arithmetic. A padded box
|
|
2142
|
+
// measured just to branch narrow/wide is off by one padding pair and correct
|
|
2143
|
+
// enough; `isNarrowWidth` is the sanctioned reader for that. A widget that DID
|
|
2144
|
+
// subtract its own padding matches too — no text scan can verify the
|
|
2145
|
+
// subtraction — and that is deliberate: the remediation (measure an unpadded
|
|
2146
|
+
// inner View) is correct for it as well, and retires the arithmetic entirely.
|
|
2087
2147
|
const _JSX_TAG_RE = /<([A-Z][\w.]*)\b/g;
|
|
2088
2148
|
const _ON_LAYOUT_ATTR_RE = /\bonLayout\s*=/;
|
|
2089
2149
|
const _OWN_PADDING_RE = /\bpadding(?:Horizontal|Left|Right|Start|End)?\s*:/;
|
|
2090
2150
|
const _WIDTH_HOLDER_RE =
|
|
2091
2151
|
/const\s*\[\s*([\w$]+)\s*,\s*([\w$]+)\s*\]\s*=\s*(useState|useContainerWidth)\b/g;
|
|
2152
|
+
// A setter fed a layout width within this many chars of the call is measuring:
|
|
2153
|
+
// covers `setW(e.nativeEvent.layout.width)` and `setW(Math.round(…))` alike.
|
|
2092
2154
|
const _LAYOUT_FEED_WINDOW = 80;
|
|
2093
2155
|
|
|
2094
2156
|
function _measuredWidthNames(code) {
|
|
@@ -2131,6 +2193,8 @@ function _sizesFromMeasuredWidth(code, names) {
|
|
|
2131
2193
|
}
|
|
2132
2194
|
|
|
2133
2195
|
function _measuredPaddingRules(source) {
|
|
2196
|
+
// Comments are blanked so this rule's own documented anti-pattern, and a
|
|
2197
|
+
// commented-out draft, never fire.
|
|
2134
2198
|
const code = _stripNonCode(source, { keepStrings: true });
|
|
2135
2199
|
const measured = _measuredWidthNames(code);
|
|
2136
2200
|
if (!_sizesFromMeasuredWidth(code, measured)) return [];
|
|
@@ -2146,6 +2210,8 @@ function _measuredPaddingRules(source) {
|
|
|
2146
2210
|
if (!_ON_LAYOUT_ATTR_RE.test(attrs)) continue;
|
|
2147
2211
|
const padded = _OWN_PADDING_RE.exec(attrs);
|
|
2148
2212
|
if (!padded) continue;
|
|
2213
|
+
// Report the padding, not the tag: on a multi-line open tag the style line
|
|
2214
|
+
// is the actionable one.
|
|
2149
2215
|
const line = lineAt(tag.index + padded.index);
|
|
2150
2216
|
findings.push({
|
|
2151
2217
|
rule: "measured-width-ignores-padding",
|
|
@@ -2161,9 +2227,13 @@ function _measuredPaddingRules(source) {
|
|
|
2161
2227
|
return findings;
|
|
2162
2228
|
}
|
|
2163
2229
|
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2230
|
+
/**
|
|
2231
|
+
* Narrow a split-impl widget's manifest to the platform a single bundle file
|
|
2232
|
+
* ships to, so `import-platform-mismatch` lints each file against what it
|
|
2233
|
+
* actually targets. `widget.web.jsx` → ["web"], `widget.native.jsx` →
|
|
2234
|
+
* ["native"], cross-platform `widget.jsx` keeps the manifest's union claim.
|
|
2235
|
+
* Returns a shallow clone; the original is untouched.
|
|
2236
|
+
*/
|
|
2167
2237
|
function narrowManifestForFile(manifest, filename) {
|
|
2168
2238
|
if (!manifest || typeof manifest !== "object") return manifest;
|
|
2169
2239
|
if (filename === "widget.web.jsx") {
|
|
@@ -2761,6 +2831,7 @@ function lintSource(source, options) {
|
|
|
2761
2831
|
// Comments and string text are masked, so a banned identifier fires only on
|
|
2762
2832
|
// code, never on the same word in prose or string data.
|
|
2763
2833
|
findings.push(..._bannedApiFindings(source, lines));
|
|
2834
|
+
// REQ-WSDK-PLATFORM §3.4: import-specifier validation.
|
|
2764
2835
|
findings.push(
|
|
2765
2836
|
..._importRules(source, options && options.manifest).map((f) => ({
|
|
2766
2837
|
...f,
|
|
@@ -2768,6 +2839,7 @@ function lintSource(source, options) {
|
|
|
2768
2839
|
})),
|
|
2769
2840
|
);
|
|
2770
2841
|
findings.push(..._leafletRules(source));
|
|
2842
|
+
// REQ-WSDK-PLATFORM §3.5: soft host-API URL warning (does not block).
|
|
2771
2843
|
findings.push(..._hostApiUrlRules(source));
|
|
2772
2844
|
// sc-6970 — soft warning: HTML markup spliced into a content string.
|
|
2773
2845
|
findings.push(..._htmlInContentRules(source));
|
|
@@ -2776,7 +2848,10 @@ function lintSource(source, options) {
|
|
|
2776
2848
|
findings.push(..._handBuiltPageUrlRules(source));
|
|
2777
2849
|
findings.push(..._lucideIconRules(source));
|
|
2778
2850
|
findings.push(..._bundledAssetRules(source, options && options.assetFiles));
|
|
2851
|
+
// sc-2353 / sc-5897 — widget source must be self-contained: a referenced
|
|
2852
|
+
// `React` global or SDK export with no import throws at render.
|
|
2779
2853
|
findings.push(..._selfContainedReferenceRules(source));
|
|
2854
|
+
// sc-3493 — soft warning: percentage height on an <Image> collapses to 0.
|
|
2780
2855
|
findings.push(..._imagePercentHeightRules(source));
|
|
2781
2856
|
// sc-7274 — soft warning: a percentage flexBasis sizes the main axis, so in a
|
|
2782
2857
|
// column it claims the parent's whole height and overflows.
|
|
@@ -2800,24 +2875,27 @@ function lintSource(source, options) {
|
|
|
2800
2875
|
findings.push(..._rawUseMemoIntoDatastoreQueryRules(source));
|
|
2801
2876
|
findings.push(..._handRolledWriteGateRules(source));
|
|
2802
2877
|
findings.push(..._rowActionGatingRules(source));
|
|
2878
|
+
// REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
|
|
2879
|
+
// line-by-line scan so banned-identifier findings stay first in the
|
|
2880
|
+
// output.
|
|
2803
2881
|
findings.push(
|
|
2804
2882
|
..._scopeRules(source, options && options.manifest).map((f) => ({
|
|
2805
2883
|
...f,
|
|
2806
2884
|
severity: f.severity || "error",
|
|
2807
2885
|
})),
|
|
2808
2886
|
);
|
|
2887
|
+
// REQ-WIDGET-ACTION — structural validation of manifest-declared actions.
|
|
2809
2888
|
findings.push(..._manifestActionRules(options && options.manifest));
|
|
2810
2889
|
findings.push(..._badgeCounterRules(source, options && options.manifest));
|
|
2811
2890
|
const hasErrors = findings.some((f) => f.severity !== "warning");
|
|
2812
2891
|
return { ok: !hasErrors, findings };
|
|
2813
2892
|
}
|
|
2814
|
-
|
|
2815
2893
|
module.exports = {
|
|
2816
|
-
|
|
2894
|
+
bannedIdentifiers,
|
|
2895
|
+
subpathImportRootsFor,
|
|
2896
|
+
narrowManifestForFile,
|
|
2817
2897
|
lintStyleWiring,
|
|
2818
2898
|
callsBasicStyle,
|
|
2819
2899
|
lintEditability,
|
|
2820
|
-
|
|
2821
|
-
narrowManifestForFile,
|
|
2822
|
-
subpathImportRootsFor,
|
|
2900
|
+
lintSource,
|
|
2823
2901
|
};
|