@colixsystems/widget-sdk 0.65.0 → 0.66.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/README.md CHANGED
@@ -53,7 +53,16 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
53
53
 
54
54
  ## Status
55
55
 
56
- `v0.65.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
56
+ `v0.66.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
57
+
58
+ ### What's new in 0.66.0
59
+
60
+ **New linter rule `image-percent-height`, and `appstudio-widget lint` finally prints warnings (sc-3493).**
61
+
62
+ - **`image-percent-height` (severity `warning`, non-blocking).** An `<Image>` / `<ImageBackground>` sized with a literal percentage `height` — `style={{ width: "100%", height: "47%" }}` — is flagged. React Native / Yoga resolves a percentage height against the **parent's** height, so under a content-sized parent it collapses to 0: the `uri` still fetches, but the image is invisible on both the web Player and the native Expo export, with nothing in the console to trace. Author fix: size it with `aspectRatio` (`{ width: "100%", aspectRatio: 1 }`) or a numeric pixel height. It is a **warning**, not an error, precisely because `height: "100%"` *is* correct inside a parent with a definite height (a fixed-height hero) and a text scan cannot tell the two apart — so the rule informs without rejecting a valid widget. Scope is the literal inline form only; a height threaded through a variable or a `StyleSheet` object is beyond an AST-free scan, and the guidance in the `useFilestoreFile` note below remains the primary guard. Comments are not scanned, so documenting the anti-pattern is safe.
63
+ - **The CLI no longer swallows warnings.** `runLint` reported `clean` and dropped every `severity: "warning"` finding whenever there were no errors, which made the existing `no-host-api-url` warning (and this new one) invisible to anyone using `appstudio-widget lint`. It now prints an `N error(s), M warning(s)` header and one line per finding tagged `error` / `warning`. **Exit codes are unchanged:** `0` when there are no error-severity findings (warnings included), `1` otherwise — so a warning still never blocks a build. `clean` is printed only when there are genuinely zero findings.
64
+
65
+ `CONTRACT` is unchanged (no new field), and no export changed signature.
57
66
 
58
67
  ### What's new in 0.65.0
59
68
 
package/dist/cli.js CHANGED
@@ -60,15 +60,25 @@ function runLint(rest) {
60
60
  exit(1);
61
61
  }
62
62
  const { ok, findings } = lintSource(source);
63
- if (ok) {
63
+ if (findings.length === 0) {
64
64
  stdout.write(`${filePath}: clean\n`);
65
65
  exit(0);
66
66
  }
67
- stderr.write(`${filePath}: ${findings.length} finding(s)\n`);
67
+ // sc-3493 — a warning-severity finding used to be swallowed: `ok` stays true
68
+ // for warnings, so the CLI printed "clean" and dropped them. A warning nobody
69
+ // sees is pointless. Report every finding; only errors change the exit code.
70
+ const errors = findings.filter((f) => f.severity !== "warning").length;
71
+ const stream = ok ? stdout : stderr;
72
+ stream.write(
73
+ `${filePath}: ${errors} error(s), ${findings.length - errors} warning(s)\n`,
74
+ );
68
75
  for (const f of findings) {
69
- stderr.write(` [${f.rule}] line ${f.line}: ${f.label}\n ${f.snippet}\n`);
76
+ const severity = f.severity === "warning" ? "warning" : "error";
77
+ stream.write(
78
+ ` ${severity} [${f.rule}] line ${f.line}: ${f.label}\n ${f.snippet}\n`,
79
+ );
70
80
  }
71
- exit(1);
81
+ exit(ok ? 0 : 1);
72
82
  }
73
83
 
74
84
  async function runDev(rest) {
package/dist/linter.cjs CHANGED
@@ -669,6 +669,64 @@ function _reactInScopeRules(source) {
669
669
  return findings;
670
670
  }
671
671
 
672
+ // sc-3466 / sc-3493 — percentage height on an <Image> collapses to 0 against a
673
+ // content-sized parent, so the image loads but renders invisible on both hosts.
674
+ // `severity: "warning"`: the same value is correct under a definite-height
675
+ // parent, which this AST-free scan cannot see. Mirror of linter.js.
676
+ const _IMAGE_TAG_RE = /<(Image|ImageBackground)\b/g;
677
+ const _PERCENT_HEIGHT_RE =
678
+ /(^|[{,;\s])height\s*:\s*(["'])\s*\d+(?:\.\d+)?\s*%\s*\2/g;
679
+
680
+ function _jsxOpenTagEnd(source, from) {
681
+ let depth = 0;
682
+ let quote = "";
683
+ for (let i = from; i < source.length; i += 1) {
684
+ const ch = source[i];
685
+ if (quote) {
686
+ if (ch === "\\") i += 1;
687
+ else if (ch === quote) quote = "";
688
+ continue;
689
+ }
690
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
691
+ else if (ch === "{") depth += 1;
692
+ else if (ch === "}") depth -= 1;
693
+ else if (ch === ">" && depth <= 0) return i;
694
+ }
695
+ return source.length;
696
+ }
697
+
698
+ function _imagePercentHeightRules(source) {
699
+ const findings = [];
700
+ const code = _stripNonCode(source, { keepStrings: true });
701
+ const sourceLines = source.split(/\r?\n/);
702
+ _IMAGE_TAG_RE.lastIndex = 0;
703
+ let tag;
704
+ while ((tag = _IMAGE_TAG_RE.exec(code))) {
705
+ const end = _jsxOpenTagEnd(code, tag.index + tag[0].length);
706
+ const attrs = code.slice(tag.index, end);
707
+ _PERCENT_HEIGHT_RE.lastIndex = 0;
708
+ const hit = _PERCENT_HEIGHT_RE.exec(attrs);
709
+ if (!hit) continue;
710
+ const line = code.slice(0, tag.index + hit.index).split(/\r?\n/).length;
711
+ findings.push({
712
+ rule: "image-percent-height",
713
+ severity: "warning",
714
+ label:
715
+ `<${tag[1]}> sizes its height with a percentage — React Native ` +
716
+ `resolves that against the PARENT's height, and a content-sized ` +
717
+ `parent has none, so it collapses to 0: the image loads but is ` +
718
+ `invisible on BOTH the web Player and the native Expo export. Size ` +
719
+ `it with aspectRatio (e.g. { width: "100%", aspectRatio: 1 }) or a ` +
720
+ `numeric pixel height. Warning only — a percentage height is correct ` +
721
+ `when the parent has a definite height (e.g. a fixed-height hero).`,
722
+ line,
723
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
724
+ });
725
+ _IMAGE_TAG_RE.lastIndex = end;
726
+ }
727
+ return findings;
728
+ }
729
+
672
730
  // Narrow a split-impl widget's manifest to the platform a single bundle file
673
731
  // ships to, so `import-platform-mismatch` lints each file against what it
674
732
  // actually targets. Mirror of linter.js.
@@ -729,6 +787,7 @@ function lintSource(source, options) {
729
787
  findings.push(..._hostApiUrlRules(source));
730
788
  findings.push(..._lucideIconRules(source));
731
789
  findings.push(..._reactInScopeRules(source));
790
+ findings.push(..._imagePercentHeightRules(source));
732
791
  findings.push(
733
792
  ..._scopeRules(source, options && options.manifest).map((f) => ({
734
793
  ...f,
package/dist/linter.js CHANGED
@@ -761,6 +761,82 @@ function _reactInScopeRules(source) {
761
761
  return findings;
762
762
  }
763
763
 
764
+ // sc-3466 / sc-3493 — percentage height on an <Image> collapses to 0.
765
+ // React Native / Yoga resolves a percentage `height` against the PARENT's
766
+ // height; a content-sized parent has none, so the value resolves to 0 and the
767
+ // image fetches its uri but renders invisible on BOTH the web Player and the
768
+ // native Expo export. sc-3466 taught the rule to the AI widget agent's
769
+ // DEFAULT_SYSTEM_PROMPT, which remains the primary guard — this is the
770
+ // mechanical belt-and-braces catch for a model (or a human author) that
771
+ // ignores it.
772
+ //
773
+ // `severity: "warning"` deliberately: `height: "100%"` IS correct inside a
774
+ // parent with a definite height (a fixed-height hero), which the AST-free scan
775
+ // cannot see, so a blocking rule would reject valid widgets.
776
+ //
777
+ // Scope is the literal inline form only. A height threaded through a variable
778
+ // or a StyleSheet object stays out of reach of a text scan.
779
+ const _IMAGE_TAG_RE = /<(Image|ImageBackground)\b/g;
780
+ const _PERCENT_HEIGHT_RE =
781
+ /(^|[{,;\s])height\s*:\s*(["'])\s*\d+(?:\.\d+)?\s*%\s*\2/g;
782
+
783
+ // Index of the `>` closing the JSX opening tag that starts at `from`. Braces
784
+ // and string literals are skipped so a `>` inside `onPress={() => …}` or an
785
+ // attribute string can't end the tag early.
786
+ function _jsxOpenTagEnd(source, from) {
787
+ let depth = 0;
788
+ let quote = "";
789
+ for (let i = from; i < source.length; i += 1) {
790
+ const ch = source[i];
791
+ if (quote) {
792
+ if (ch === "\\") i += 1;
793
+ else if (ch === quote) quote = "";
794
+ continue;
795
+ }
796
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
797
+ else if (ch === "{") depth += 1;
798
+ else if (ch === "}") depth -= 1;
799
+ else if (ch === ">" && depth <= 0) return i;
800
+ }
801
+ return source.length;
802
+ }
803
+
804
+ function _imagePercentHeightRules(source) {
805
+ const findings = [];
806
+ // Comments are blanked (string contents kept) so a commented-out example —
807
+ // including the one in this rule's own docs — is never flagged.
808
+ const code = _stripNonCode(source, { keepStrings: true });
809
+ const sourceLines = source.split(/\r?\n/);
810
+ _IMAGE_TAG_RE.lastIndex = 0;
811
+ let tag;
812
+ while ((tag = _IMAGE_TAG_RE.exec(code))) {
813
+ const end = _jsxOpenTagEnd(code, tag.index + tag[0].length);
814
+ const attrs = code.slice(tag.index, end);
815
+ _PERCENT_HEIGHT_RE.lastIndex = 0;
816
+ const hit = _PERCENT_HEIGHT_RE.exec(attrs);
817
+ if (!hit) continue;
818
+ const line = code.slice(0, tag.index + hit.index).split(/\r?\n/).length;
819
+ findings.push({
820
+ rule: "image-percent-height",
821
+ severity: "warning",
822
+ label:
823
+ `<${tag[1]}> sizes its height with a percentage — React Native ` +
824
+ `resolves that against the PARENT's height, and a content-sized ` +
825
+ `parent has none, so it collapses to 0: the image loads but is ` +
826
+ `invisible on BOTH the web Player and the native Expo export. Size ` +
827
+ `it with aspectRatio (e.g. { width: "100%", aspectRatio: 1 }) or a ` +
828
+ `numeric pixel height. Warning only — a percentage height is correct ` +
829
+ `when the parent has a definite height (e.g. a fixed-height hero).`,
830
+ line,
831
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
832
+ });
833
+ // One finding per <Image>; a second percentage height on the same tag is
834
+ // the same defect.
835
+ _IMAGE_TAG_RE.lastIndex = end;
836
+ }
837
+ return findings;
838
+ }
839
+
764
840
  /**
765
841
  * Narrow a split-impl widget's manifest to the platform a single bundle file
766
842
  * ships to, so `import-platform-mismatch` lints each file against what it
@@ -828,6 +904,8 @@ export function lintSource(source, options) {
828
904
  findings.push(..._lucideIconRules(source));
829
905
  // sc-2353 — widget source must be self-contained (reference React ⇒ import it).
830
906
  findings.push(..._reactInScopeRules(source));
907
+ // sc-3493 — soft warning: percentage height on an <Image> collapses to 0.
908
+ findings.push(..._imagePercentHeightRules(source));
831
909
  // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
832
910
  // line-by-line scan so banned-identifier findings stay first in the
833
911
  // output.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.65.0",
3
+ "version": "0.66.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -48,7 +48,7 @@
48
48
  ],
49
49
  "scripts": {
50
50
  "build": "node scripts/build.js",
51
- "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js"
51
+ "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=18"