@colixsystems/widget-sdk 0.139.0 → 0.140.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
@@ -70,7 +70,19 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
70
70
 
71
71
  ## Status
72
72
 
73
- `v0.137.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**.
73
+ `v0.140.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**.
74
+
75
+ ### What's new in 0.140.0 (contract 1.108.0)
76
+
77
+ **Three rules make a generated widget's EDITABILITY checkable, and `lintEditability` joins the linter export (sc-7799).** The rules that already had a deterministic gate — the three basic style fields, `no-hardcoded-design`, `style-field-unread` — held every time. The ones left to prose drifted: `ui.group`, per-element coverage and author-editable copy were guidance only, so some widgets shipped a grouped Style section covering every element while others shipped the three basics and nothing else, and wording was routinely pinned in the source where no author could reach it.
78
+
79
+ - **`style-group-missing`** — a `styleSchema` field with no `ui.group`, or one filed under a catch-all name (`Advanced`, `Other`, `Misc`, `Style`, `Styles`, `General`) that rebuilds the flat list grouping exists to split. The three basics belong under `"Basics"`; each per-element field under the Title Case singular name of the element it styles (`"Card"`, `"Title"`, `"Row"`).
80
+ - **`style-elements-uncovered`** — a widget that renders several visual elements while its `styleSchema` carries nothing beyond `background`/`textColor`/`align`. A genuinely single-element widget is exempt by construction.
81
+ - **`text-literal-unexposed`** — a string literal sitting directly in a `<Text>`: copy that reaches neither the author nor the translation pipeline.
82
+
83
+ All three are `warning`, so `appstudio-widget lint` reports them and still exits 0 — the human author decides when to act. The AI widget agent publishes with no human in the loop and `_lintFindingsToPublishChecks` turns every finding into a publish check regardless of severity, so the same three **block** an agent publish and drive a repair turn, with no new severity plumbing.
84
+
85
+ `lintEditability(manifest, files)` is bundle-level like `lintStyleWiring`, and needs `--manifest` for the same reason. It counts rendered elements PER FILE, though: joining `widget.web.jsx` and `widget.native.jsx` would double every tag and call a single-`<Text>` split-impl widget multi-element.
74
86
 
75
87
  ### What's new in 0.139.0 (contract 1.108.0)
76
88
 
@@ -1762,15 +1774,22 @@ npx appstudio-widget lint widget.web.jsx widget.native.jsx --manifest manifest.j
1762
1774
  Scans for banned patterns (`eval`, `new Function`, dynamic `import()`, direct imports of host stores, raw axios) and for the styling rules below. **Only error-severity findings change the exit code**; warnings print and exit 0.
1763
1775
 
1764
1776
  ```js
1765
- import { lintSource, lintStyleWiring } from "@colixsystems/widget-sdk/linter";
1777
+ import {
1778
+ lintSource,
1779
+ lintStyleWiring,
1780
+ lintEditability,
1781
+ } from "@colixsystems/widget-sdk/linter";
1766
1782
  const report = lintSource(source, { manifest });
1767
1783
  // Bundle-level — pass every file the widget ships:
1768
1784
  const wiring = lintStyleWiring(manifest, { "widget.jsx": source });
1785
+ const editability = lintEditability(manifest, { "widget.jsx": source });
1769
1786
  ```
1770
1787
 
1771
1788
  Two rules keep a widget's look reachable from the Studio (sc-6455). `no-hardcoded-design` flags a colour literal, a `fontFamily` string, or a numeric `fontSize` — values that outrank the theme permanently and that no control can move. `style-field-unread` flags a `styleSchema` field the source never reads, which renders a control that does nothing. Both are warnings for a human author and blocking for the AI widget agent. License a genuinely un-tokenizable value with a preceding `// appstudio-design-ok: <reason>` (the reason is required); see *What's new in 0.110.0*.
1772
1789
 
1773
- Pass `--manifest` to enable `style-field-unread` — it needs the manifest, and it needs every source at once so a split-impl widget's per-host field reads are seen together.
1790
+ Three more rules keep a widget EDITABLE once it ships (sc-7799), via the bundle-level `lintEditability(manifest, files)`. `style-group-missing` flags a `styleSchema` field with no `ui.group` — or one under a catch-all name — so a long Style section reads as labelled per-element fieldsets. `style-elements-uncovered` flags a multi-element widget whose Style section still only restyles the widget as a whole. `text-literal-unexposed` flags copy pinned in a `<Text>` — wording no control can change and no locale can translate. All three are warnings here and blocking for the AI widget agent; see *What's new in 0.140.0*.
1791
+
1792
+ Pass `--manifest` to enable `style-field-unread` and the three editability rules — they need the manifest, and every source at once so a split-impl widget's per-host reads are seen together.
1774
1793
 
1775
1794
  `flex-basis-percent` flags a literal percentage `flexBasis` (sc-7274) — `"100%"`, `"50%"`, or the ternary a responsive cell writes. `flex-basis` sizes the MAIN axis, so in a column parent it asks for the parent's HEIGHT: under an ancestor that makes that height definite (a Grid cell stretches its child on the web Player) the child fills the container and, since a `View` never shrinks, overflows over everything below it. Say full-width with `width: "100%"` — correct in a column AND in a wrap row — and keep a NUMBER for a wrap threshold. A warning, because the percentage is right in a row and a text scan cannot see the parent.
1776
1795
 
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import { readFileSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
9
  import { pathToFileURL } from "node:url";
10
10
  import { argv, exit, stderr, stdout } from "node:process";
11
- import { lintSource, lintStyleWiring } from "./linter.js";
11
+ import { lintSource, lintStyleWiring, lintEditability } from "./linter.js";
12
12
  import { startDevServer } from "./devserver.js";
13
13
 
14
14
  function usage() {
@@ -106,9 +106,14 @@ async function runLint(rest) {
106
106
  const wiring = manifest
107
107
  ? lintStyleWiring(manifest, files)
108
108
  : { ok: true, findings: [] };
109
+ // sc-7799 — same manifest+bundle shape as the dead-control gate above.
110
+ const editability = manifest
111
+ ? lintEditability(manifest, files)
112
+ : { ok: true, findings: [] };
109
113
  const all = perFile
110
114
  .flatMap(([, r]) => r.findings)
111
- .concat(wiring.findings);
115
+ .concat(wiring.findings)
116
+ .concat(editability.findings);
112
117
  if (all.length === 0) {
113
118
  stdout.write(`${label}: clean\n`);
114
119
  exit(0);
@@ -116,7 +121,7 @@ async function runLint(rest) {
116
121
  // sc-3493 — a warning-severity finding used to be swallowed: `ok` stays true
117
122
  // for warnings, so the CLI printed "clean" and dropped them. A warning nobody
118
123
  // sees is pointless. Report every finding; only errors change the exit code.
119
- const ok = perFile.every(([, r]) => r.ok) && wiring.ok;
124
+ const ok = perFile.every(([, r]) => r.ok) && wiring.ok && editability.ok;
120
125
  const errors = all.filter((f) => f.severity !== "warning").length;
121
126
  const stream = ok ? stdout : stderr;
122
127
  stream.write(
@@ -131,6 +136,10 @@ async function runLint(rest) {
131
136
  stream.write(`manifest.styleSchema:\n`);
132
137
  writeFindings(wiring.findings, stream);
133
138
  }
139
+ if (editability.findings.length > 0) {
140
+ stream.write(`manifest.editability:\n`);
141
+ writeFindings(editability.findings, stream);
142
+ }
134
143
  exit(ok ? 0 : 1);
135
144
  }
136
145
 
package/dist/linter.cjs CHANGED
@@ -1438,11 +1438,7 @@ function lintStyleWiring(manifest, files) {
1438
1438
  if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
1439
1439
  return empty;
1440
1440
  }
1441
- let sources = [];
1442
- if (typeof files === "string") sources = [files];
1443
- else if (Array.isArray(files)) sources = files;
1444
- else if (files && typeof files === "object") sources = Object.values(files);
1445
- sources = sources.filter((s) => typeof s === "string" && s.length > 0);
1441
+ const sources = _bundleSources(files);
1446
1442
  if (sources.length === 0) return empty;
1447
1443
  const code = sources
1448
1444
  .map((s) => _stripNonCode(s, { keepStrings: true }))
@@ -1471,6 +1467,266 @@ function lintStyleWiring(manifest, files) {
1471
1467
  return { ok: !hasErrors, findings };
1472
1468
  }
1473
1469
 
1470
+ // sc-7799 — a group name that groups everything defeats the rule: one
1471
+ // catch-all fieldset reads exactly like the flat list it replaced.
1472
+ const _GENERIC_STYLE_GROUPS = new Set([
1473
+ "advanced",
1474
+ "other",
1475
+ "misc",
1476
+ "style",
1477
+ "styles",
1478
+ "general",
1479
+ ]);
1480
+
1481
+ // The three the basic-style gate mandates; they restyle the widget as a whole.
1482
+ const _WHOLE_WIDGET_STYLE_FIELDS = new Set([
1483
+ "background",
1484
+ "textColor",
1485
+ "align",
1486
+ ]);
1487
+
1488
+ // A second element an author would style apart from the text. Pressable and
1489
+ // Button are deliberately absent: a touch target wrapping the only label is
1490
+ // one element, not two.
1491
+ const _SECOND_ELEMENT_TAGS = ["Image", "TextInput", "FlatList", "SectionList"];
1492
+
1493
+ const _SEPARATOR_GLYPHS = new Set(["-", "–", "—", "•", "·", "…", "|", "/"]);
1494
+
1495
+ // Names ride the finding; the remainder is counted, so one finding stays
1496
+ // inside the agent's per-message budget however many fields are wrong.
1497
+ const _EDITABILITY_LIST_CAP = 3;
1498
+
1499
+ // Tighter for copy: each entry carries the text AND its line, so six of them
1500
+ // push the message past the report's 300-char truncation.
1501
+ const _LITERAL_LIST_CAP = 2;
1502
+
1503
+ // Shared by both bundle-level gates — one normaliser, not two.
1504
+ function _bundleSources(files) {
1505
+ let sources = [];
1506
+ if (typeof files === "string") sources = [files];
1507
+ else if (Array.isArray(files)) sources = files;
1508
+ else if (files && typeof files === "object") sources = Object.values(files);
1509
+ return sources.filter((s) => typeof s === "string" && s.length > 0);
1510
+ }
1511
+
1512
+ function _plainObject(value) {
1513
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1514
+ }
1515
+
1516
+ // Both the count and each entry are bounded: the backend wraps the label in
1517
+ // `[manifest.editability] … (line N: snippet)` and truncates at 300, so an
1518
+ // unbounded name list loses the tail of the advice that follows it.
1519
+ function _clip(text, max) {
1520
+ return text.length > max ? `${text.slice(0, max)}…` : text;
1521
+ }
1522
+
1523
+ function _quoteList(names) {
1524
+ const shown = names.slice(0, _EDITABILITY_LIST_CAP).map((n) => _clip(n, 16));
1525
+ const more = names.length - shown.length;
1526
+ return shown.join(", ") + (more > 0 ? `, …(+${more} more)` : "");
1527
+ }
1528
+
1529
+ // Copy worth a control: a separator glyph or a bare number is chrome.
1530
+ function _isAuthoredCopy(text) {
1531
+ if (text.length < 2) return false;
1532
+ if (!/[A-Za-z]/.test(text)) return false;
1533
+ return !_SEPARATOR_GLYPHS.has(text);
1534
+ }
1535
+
1536
+ // The `>` that CLOSES a JSX tag, skipping quoted attribute values and any `>`
1537
+ // inside a `{…}` expression — `onPress={() => x}` carries one, and treating it
1538
+ // as the tag end reports the handler body as copy.
1539
+ function _tagEnd(code, from) {
1540
+ let depth = 0;
1541
+ let quote = null;
1542
+ for (let i = from; i < code.length; i++) {
1543
+ const ch = code[i];
1544
+ if (quote) {
1545
+ if (ch === "\\") i++;
1546
+ else if (ch === quote) quote = null;
1547
+ continue;
1548
+ }
1549
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
1550
+ else if (ch === "{") depth++;
1551
+ else if (ch === "}") depth--;
1552
+ else if (ch === ">" && depth <= 0) return i;
1553
+ }
1554
+ return -1;
1555
+ }
1556
+
1557
+ // The balanced `{…}` starting at `from`, or null.
1558
+ function _bracedSpan(code, from) {
1559
+ if (code[from] !== "{") return null;
1560
+ let depth = 0;
1561
+ let quote = null;
1562
+ for (let i = from; i < code.length; i++) {
1563
+ const ch = code[i];
1564
+ if (quote) {
1565
+ if (ch === "\\") i++;
1566
+ else if (ch === quote) quote = null;
1567
+ continue;
1568
+ }
1569
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
1570
+ else if (ch === "{") depth++;
1571
+ else if (ch === "}") {
1572
+ depth--;
1573
+ if (depth === 0) return code.slice(from + 1, i);
1574
+ }
1575
+ }
1576
+ return null;
1577
+ }
1578
+
1579
+ // Copy pinned in a `<Text>`: a bare literal child, or a braced child that is
1580
+ // NOTHING BUT a string literal (`{"Hello"}`) — the one-pair-of-braces evasion.
1581
+ // A braced expression carrying an identifier is left alone: it may already be
1582
+ // a prop, and under-firing is right for a gate that blocks a publish.
1583
+ function _textLiteralOccurrences(codes, masks) {
1584
+ const byText = new Map();
1585
+ for (let f = 0; f < codes.length; f++) {
1586
+ const code = codes[f];
1587
+ // Locate tags on the string-blanked mask, so markup HELD IN a string is
1588
+ // not read as markup. Both masks preserve offsets, so the child is read
1589
+ // back from the kept-strings source at the very same index.
1590
+ const mask = masks[f];
1591
+ const re = /<Text\b/g;
1592
+ let m;
1593
+ while ((m = re.exec(mask)) !== null) {
1594
+ const end = _tagEnd(mask, m.index);
1595
+ if (end === -1) continue;
1596
+ let i = end + 1;
1597
+ while (i < code.length && /\s/.test(code[i])) i++;
1598
+ let text = null;
1599
+ if (code[i] === "{") {
1600
+ const inner = _bracedSpan(code, i);
1601
+ const lit =
1602
+ inner && inner.trim().match(/^(["'`])((?:\\.|(?!\1).)*)\1$/);
1603
+ // A template carrying `${…}` interpolates a value, so it is not a literal.
1604
+ if (lit && !(lit[1] === "`" && lit[2].includes("${"))) text = lit[2];
1605
+ } else {
1606
+ const child = code.slice(i).match(/^([^<>{}\n]+)/);
1607
+ if (child) text = child[1];
1608
+ }
1609
+ if (text === null) continue;
1610
+ text = text.trim();
1611
+ if (!_isAuthoredCopy(text) || byText.has(text)) continue;
1612
+ // Split-impl carries the same copy twice; report the string, not the file.
1613
+ byText.set(text, code.slice(0, i).split("\n").length);
1614
+ }
1615
+ }
1616
+ return [...byText.entries()].map(([text, line]) => ({ text, line }));
1617
+ }
1618
+
1619
+ // Per FILE: joining widget.web.jsx and widget.native.jsx doubles every tag.
1620
+ // A second element must be a DISTINCT visual kind beside the text — two
1621
+ // `<Text>` tags are as often a loading branch and its content as a title and
1622
+ // a body, and a blocking gate may not guess.
1623
+ function _rendersSeveralElements(code) {
1624
+ if (!/<Text[\s>/]/.test(code)) return false;
1625
+ return _SECOND_ELEMENT_TAGS.some((tag) =>
1626
+ new RegExp(`<${tag}[\\s>/]`).test(code),
1627
+ );
1628
+ }
1629
+
1630
+ function _styleGroupFindings(manifest) {
1631
+ const schema = _plainObject(manifest) ? manifest.styleSchema : null;
1632
+ if (!_plainObject(schema)) return [];
1633
+ const bad = Object.keys(schema).filter((field) => {
1634
+ const def = schema[field];
1635
+ const group = _plainObject(def) && _plainObject(def.ui) ? def.ui.group : "";
1636
+ if (typeof group !== "string" || group.trim().length === 0) return true;
1637
+ return _GENERIC_STYLE_GROUPS.has(group.trim().toLowerCase());
1638
+ });
1639
+ if (bad.length === 0) return [];
1640
+ return [
1641
+ {
1642
+ rule: "style-group-missing",
1643
+ severity: "warning",
1644
+ label:
1645
+ `styleSchema fields with no usable ui.group: ${_quoteList(
1646
+ bad.map((f) => `"${f}"`),
1647
+ )}. Group EVERY field — basics under "Basics", each per-element ` +
1648
+ `field under the element it styles. A catch-all name is not a group.`,
1649
+ line: 0,
1650
+ snippet: _clip(bad[0], 12),
1651
+ },
1652
+ ];
1653
+ }
1654
+
1655
+ function _styleCoverageFindings(manifest, codes) {
1656
+ const schema = _plainObject(manifest) ? manifest.styleSchema : null;
1657
+ if (!_plainObject(schema)) return [];
1658
+ const fields = Object.keys(schema);
1659
+ if (fields.length === 0) return [];
1660
+ if (fields.some((f) => !_WHOLE_WIDGET_STYLE_FIELDS.has(f))) return [];
1661
+ if (!codes.some(_rendersSeveralElements)) return [];
1662
+ return [
1663
+ {
1664
+ rule: "style-elements-uncovered",
1665
+ severity: "warning",
1666
+ label:
1667
+ `The widget renders more than one visual element but its styleSchema ` +
1668
+ `stops at background/textColor/align. Add 2-4 grouped per-element ` +
1669
+ `fields (cardBackground, titleColor, rowBackground, imageRadius), ` +
1670
+ `each applied ONLY when set.`,
1671
+ line: 0,
1672
+ snippet: "",
1673
+ },
1674
+ ];
1675
+ }
1676
+
1677
+ function _textLiteralFindings(occurrences) {
1678
+ if (occurrences.length === 0) return [];
1679
+ // Each entry is bounded so a long line of copy cannot push the whole
1680
+ // message past the report's truncation and take the line numbers with it.
1681
+ const listed = occurrences
1682
+ .slice(0, _LITERAL_LIST_CAP)
1683
+ .map((o) => `"${_clip(o.text, 16)}" (line ${o.line})`);
1684
+ const more = occurrences.length - listed.length;
1685
+ return [
1686
+ {
1687
+ rule: "text-literal-unexposed",
1688
+ severity: "warning",
1689
+ label:
1690
+ `Copy pinned in a <Text> reaches neither author nor translation: ` +
1691
+ `${listed.join(", ")}${more > 0 ? `, …(+${more} more)` : ""}. ` +
1692
+ `Declare each as a propertySchema string defaulting to that text ` +
1693
+ `and render the prop; fixed chrome uses t().`,
1694
+ line: occurrences[0].line,
1695
+ snippet: _clip(occurrences[0].text, 12),
1696
+ },
1697
+ ];
1698
+ }
1699
+
1700
+ /**
1701
+ * sc-7799 — the editability gates: what an author can still change once the
1702
+ * widget ships. Bundle-level like `lintStyleWiring`, and for the same reason
1703
+ * — one manifest serves both halves of a split-impl widget.
1704
+ *
1705
+ * Each rule emits ONE consolidated finding, because the agent's draft-findings
1706
+ * report keeps only the first dozen checks and truncates each message: a
1707
+ * per-field finding on a 14-field manifest would evict the render-time crash
1708
+ * sitting behind it.
1709
+ *
1710
+ * @param {object} manifest widget manifest (`styleSchema` only)
1711
+ * @param {object|string[]|string} files bundle sources — a `{ name: source }`
1712
+ * map, an array of sources, or one source string
1713
+ * @returns {{ ok: boolean, findings: Array<{ rule: string, severity: string, label: string, line: number, snippet: string }> }}
1714
+ */
1715
+ function lintEditability(manifest, files) {
1716
+ const sources = _bundleSources(files);
1717
+ if (sources.length === 0) return { ok: true, findings: [] };
1718
+ const codes = sources.map((s) => _stripNonCode(s, { keepStrings: true }));
1719
+ const masks = sources.map((s) => _stripNonCode(s, { keepStrings: false }));
1720
+ const findings = [
1721
+ ..._styleGroupFindings(manifest),
1722
+ ..._styleCoverageFindings(manifest, codes),
1723
+ ..._textLiteralFindings(_textLiteralOccurrences(codes, masks)),
1724
+ ];
1725
+ // Every rule here is a warning by construction: the CLI must stay exit-0 for
1726
+ // a human author, while the agent blocks on any finding regardless.
1727
+ return { ok: true, findings };
1728
+ }
1729
+
1474
1730
  function lintSource(source, options) {
1475
1731
  if (typeof source !== "string") {
1476
1732
  return {
@@ -1553,6 +1809,7 @@ function lintSource(source, options) {
1553
1809
  module.exports = {
1554
1810
  lintSource,
1555
1811
  lintStyleWiring,
1812
+ lintEditability,
1556
1813
  bannedIdentifiers,
1557
1814
  narrowManifestForFile,
1558
1815
  };
package/dist/linter.js CHANGED
@@ -1509,11 +1509,7 @@ export function lintStyleWiring(manifest, files) {
1509
1509
  if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
1510
1510
  return empty;
1511
1511
  }
1512
- let sources = [];
1513
- if (typeof files === "string") sources = [files];
1514
- else if (Array.isArray(files)) sources = files;
1515
- else if (files && typeof files === "object") sources = Object.values(files);
1516
- sources = sources.filter((s) => typeof s === "string" && s.length > 0);
1512
+ const sources = _bundleSources(files);
1517
1513
  if (sources.length === 0) return empty;
1518
1514
  const code = sources
1519
1515
  .map((s) => _stripNonCode(s, { keepStrings: true }))
@@ -1542,6 +1538,266 @@ export function lintStyleWiring(manifest, files) {
1542
1538
  return { ok: !hasErrors, findings };
1543
1539
  }
1544
1540
 
1541
+ // sc-7799 — a group name that groups everything defeats the rule: one
1542
+ // catch-all fieldset reads exactly like the flat list it replaced.
1543
+ const _GENERIC_STYLE_GROUPS = new Set([
1544
+ "advanced",
1545
+ "other",
1546
+ "misc",
1547
+ "style",
1548
+ "styles",
1549
+ "general",
1550
+ ]);
1551
+
1552
+ // The three the basic-style gate mandates; they restyle the widget as a whole.
1553
+ const _WHOLE_WIDGET_STYLE_FIELDS = new Set([
1554
+ "background",
1555
+ "textColor",
1556
+ "align",
1557
+ ]);
1558
+
1559
+ // A second element an author would style apart from the text. Pressable and
1560
+ // Button are deliberately absent: a touch target wrapping the only label is
1561
+ // one element, not two.
1562
+ const _SECOND_ELEMENT_TAGS = ["Image", "TextInput", "FlatList", "SectionList"];
1563
+
1564
+ const _SEPARATOR_GLYPHS = new Set(["-", "–", "—", "•", "·", "…", "|", "/"]);
1565
+
1566
+ // Names ride the finding; the remainder is counted, so one finding stays
1567
+ // inside the agent's per-message budget however many fields are wrong.
1568
+ const _EDITABILITY_LIST_CAP = 3;
1569
+
1570
+ // Tighter for copy: each entry carries the text AND its line, so six of them
1571
+ // push the message past the report's 300-char truncation.
1572
+ const _LITERAL_LIST_CAP = 2;
1573
+
1574
+ // Shared by both bundle-level gates — one normaliser, not two.
1575
+ function _bundleSources(files) {
1576
+ let sources = [];
1577
+ if (typeof files === "string") sources = [files];
1578
+ else if (Array.isArray(files)) sources = files;
1579
+ else if (files && typeof files === "object") sources = Object.values(files);
1580
+ return sources.filter((s) => typeof s === "string" && s.length > 0);
1581
+ }
1582
+
1583
+ function _plainObject(value) {
1584
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1585
+ }
1586
+
1587
+ // Both the count and each entry are bounded: the backend wraps the label in
1588
+ // `[manifest.editability] … (line N: snippet)` and truncates at 300, so an
1589
+ // unbounded name list loses the tail of the advice that follows it.
1590
+ function _clip(text, max) {
1591
+ return text.length > max ? `${text.slice(0, max)}…` : text;
1592
+ }
1593
+
1594
+ function _quoteList(names) {
1595
+ const shown = names.slice(0, _EDITABILITY_LIST_CAP).map((n) => _clip(n, 16));
1596
+ const more = names.length - shown.length;
1597
+ return shown.join(", ") + (more > 0 ? `, …(+${more} more)` : "");
1598
+ }
1599
+
1600
+ // Copy worth a control: a separator glyph or a bare number is chrome.
1601
+ function _isAuthoredCopy(text) {
1602
+ if (text.length < 2) return false;
1603
+ if (!/[A-Za-z]/.test(text)) return false;
1604
+ return !_SEPARATOR_GLYPHS.has(text);
1605
+ }
1606
+
1607
+ // The `>` that CLOSES a JSX tag, skipping quoted attribute values and any `>`
1608
+ // inside a `{…}` expression — `onPress={() => x}` carries one, and treating it
1609
+ // as the tag end reports the handler body as copy.
1610
+ function _tagEnd(code, from) {
1611
+ let depth = 0;
1612
+ let quote = null;
1613
+ for (let i = from; i < code.length; i++) {
1614
+ const ch = code[i];
1615
+ if (quote) {
1616
+ if (ch === "\\") i++;
1617
+ else if (ch === quote) quote = null;
1618
+ continue;
1619
+ }
1620
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
1621
+ else if (ch === "{") depth++;
1622
+ else if (ch === "}") depth--;
1623
+ else if (ch === ">" && depth <= 0) return i;
1624
+ }
1625
+ return -1;
1626
+ }
1627
+
1628
+ // The balanced `{…}` starting at `from`, or null.
1629
+ function _bracedSpan(code, from) {
1630
+ if (code[from] !== "{") return null;
1631
+ let depth = 0;
1632
+ let quote = null;
1633
+ for (let i = from; i < code.length; i++) {
1634
+ const ch = code[i];
1635
+ if (quote) {
1636
+ if (ch === "\\") i++;
1637
+ else if (ch === quote) quote = null;
1638
+ continue;
1639
+ }
1640
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
1641
+ else if (ch === "{") depth++;
1642
+ else if (ch === "}") {
1643
+ depth--;
1644
+ if (depth === 0) return code.slice(from + 1, i);
1645
+ }
1646
+ }
1647
+ return null;
1648
+ }
1649
+
1650
+ // Copy pinned in a `<Text>`: a bare literal child, or a braced child that is
1651
+ // NOTHING BUT a string literal (`{"Hello"}`) — the one-pair-of-braces evasion.
1652
+ // A braced expression carrying an identifier is left alone: it may already be
1653
+ // a prop, and under-firing is right for a gate that blocks a publish.
1654
+ function _textLiteralOccurrences(codes, masks) {
1655
+ const byText = new Map();
1656
+ for (let f = 0; f < codes.length; f++) {
1657
+ const code = codes[f];
1658
+ // Locate tags on the string-blanked mask, so markup HELD IN a string is
1659
+ // not read as markup. Both masks preserve offsets, so the child is read
1660
+ // back from the kept-strings source at the very same index.
1661
+ const mask = masks[f];
1662
+ const re = /<Text\b/g;
1663
+ let m;
1664
+ while ((m = re.exec(mask)) !== null) {
1665
+ const end = _tagEnd(mask, m.index);
1666
+ if (end === -1) continue;
1667
+ let i = end + 1;
1668
+ while (i < code.length && /\s/.test(code[i])) i++;
1669
+ let text = null;
1670
+ if (code[i] === "{") {
1671
+ const inner = _bracedSpan(code, i);
1672
+ const lit =
1673
+ inner && inner.trim().match(/^(["'`])((?:\\.|(?!\1).)*)\1$/);
1674
+ // A template carrying `${…}` interpolates a value, so it is not a literal.
1675
+ if (lit && !(lit[1] === "`" && lit[2].includes("${"))) text = lit[2];
1676
+ } else {
1677
+ const child = code.slice(i).match(/^([^<>{}\n]+)/);
1678
+ if (child) text = child[1];
1679
+ }
1680
+ if (text === null) continue;
1681
+ text = text.trim();
1682
+ if (!_isAuthoredCopy(text) || byText.has(text)) continue;
1683
+ // Split-impl carries the same copy twice; report the string, not the file.
1684
+ byText.set(text, code.slice(0, i).split("\n").length);
1685
+ }
1686
+ }
1687
+ return [...byText.entries()].map(([text, line]) => ({ text, line }));
1688
+ }
1689
+
1690
+ // Per FILE: joining widget.web.jsx and widget.native.jsx doubles every tag.
1691
+ // A second element must be a DISTINCT visual kind beside the text — two
1692
+ // `<Text>` tags are as often a loading branch and its content as a title and
1693
+ // a body, and a blocking gate may not guess.
1694
+ function _rendersSeveralElements(code) {
1695
+ if (!/<Text[\s>/]/.test(code)) return false;
1696
+ return _SECOND_ELEMENT_TAGS.some((tag) =>
1697
+ new RegExp(`<${tag}[\\s>/]`).test(code),
1698
+ );
1699
+ }
1700
+
1701
+ function _styleGroupFindings(manifest) {
1702
+ const schema = _plainObject(manifest) ? manifest.styleSchema : null;
1703
+ if (!_plainObject(schema)) return [];
1704
+ const bad = Object.keys(schema).filter((field) => {
1705
+ const def = schema[field];
1706
+ const group = _plainObject(def) && _plainObject(def.ui) ? def.ui.group : "";
1707
+ if (typeof group !== "string" || group.trim().length === 0) return true;
1708
+ return _GENERIC_STYLE_GROUPS.has(group.trim().toLowerCase());
1709
+ });
1710
+ if (bad.length === 0) return [];
1711
+ return [
1712
+ {
1713
+ rule: "style-group-missing",
1714
+ severity: "warning",
1715
+ label:
1716
+ `styleSchema fields with no usable ui.group: ${_quoteList(
1717
+ bad.map((f) => `"${f}"`),
1718
+ )}. Group EVERY field — basics under "Basics", each per-element ` +
1719
+ `field under the element it styles. A catch-all name is not a group.`,
1720
+ line: 0,
1721
+ snippet: _clip(bad[0], 12),
1722
+ },
1723
+ ];
1724
+ }
1725
+
1726
+ function _styleCoverageFindings(manifest, codes) {
1727
+ const schema = _plainObject(manifest) ? manifest.styleSchema : null;
1728
+ if (!_plainObject(schema)) return [];
1729
+ const fields = Object.keys(schema);
1730
+ if (fields.length === 0) return [];
1731
+ if (fields.some((f) => !_WHOLE_WIDGET_STYLE_FIELDS.has(f))) return [];
1732
+ if (!codes.some(_rendersSeveralElements)) return [];
1733
+ return [
1734
+ {
1735
+ rule: "style-elements-uncovered",
1736
+ severity: "warning",
1737
+ label:
1738
+ `The widget renders more than one visual element but its styleSchema ` +
1739
+ `stops at background/textColor/align. Add 2-4 grouped per-element ` +
1740
+ `fields (cardBackground, titleColor, rowBackground, imageRadius), ` +
1741
+ `each applied ONLY when set.`,
1742
+ line: 0,
1743
+ snippet: "",
1744
+ },
1745
+ ];
1746
+ }
1747
+
1748
+ function _textLiteralFindings(occurrences) {
1749
+ if (occurrences.length === 0) return [];
1750
+ // Each entry is bounded so a long line of copy cannot push the whole
1751
+ // message past the report's truncation and take the line numbers with it.
1752
+ const listed = occurrences
1753
+ .slice(0, _LITERAL_LIST_CAP)
1754
+ .map((o) => `"${_clip(o.text, 16)}" (line ${o.line})`);
1755
+ const more = occurrences.length - listed.length;
1756
+ return [
1757
+ {
1758
+ rule: "text-literal-unexposed",
1759
+ severity: "warning",
1760
+ label:
1761
+ `Copy pinned in a <Text> reaches neither author nor translation: ` +
1762
+ `${listed.join(", ")}${more > 0 ? `, …(+${more} more)` : ""}. ` +
1763
+ `Declare each as a propertySchema string defaulting to that text ` +
1764
+ `and render the prop; fixed chrome uses t().`,
1765
+ line: occurrences[0].line,
1766
+ snippet: _clip(occurrences[0].text, 12),
1767
+ },
1768
+ ];
1769
+ }
1770
+
1771
+ /**
1772
+ * sc-7799 — the editability gates: what an author can still change once the
1773
+ * widget ships. Bundle-level like `lintStyleWiring`, and for the same reason
1774
+ * — one manifest serves both halves of a split-impl widget.
1775
+ *
1776
+ * Each rule emits ONE consolidated finding, because the agent's draft-findings
1777
+ * report keeps only the first dozen checks and truncates each message: a
1778
+ * per-field finding on a 14-field manifest would evict the render-time crash
1779
+ * sitting behind it.
1780
+ *
1781
+ * @param {object} manifest widget manifest (`styleSchema` only)
1782
+ * @param {object|string[]|string} files bundle sources — a `{ name: source }`
1783
+ * map, an array of sources, or one source string
1784
+ * @returns {{ ok: boolean, findings: Array<{ rule: string, severity: string, label: string, line: number, snippet: string }> }}
1785
+ */
1786
+ export function lintEditability(manifest, files) {
1787
+ const sources = _bundleSources(files);
1788
+ if (sources.length === 0) return { ok: true, findings: [] };
1789
+ const codes = sources.map((s) => _stripNonCode(s, { keepStrings: true }));
1790
+ const masks = sources.map((s) => _stripNonCode(s, { keepStrings: false }));
1791
+ const findings = [
1792
+ ..._styleGroupFindings(manifest),
1793
+ ..._styleCoverageFindings(manifest, codes),
1794
+ ..._textLiteralFindings(_textLiteralOccurrences(codes, masks)),
1795
+ ];
1796
+ // Every rule here is a warning by construction: the CLI must stay exit-0 for
1797
+ // a human author, while the agent blocks on any finding regardless.
1798
+ return { ok: true, findings };
1799
+ }
1800
+
1545
1801
  export function lintSource(source, options) {
1546
1802
  if (typeof source !== "string") {
1547
1803
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.139.0",
3
+ "version": "0.140.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
@@ -49,7 +49,7 @@
49
49
  ],
50
50
  "scripts": {
51
51
  "build": "node scripts/build.js",
52
- "test": "node --test src/__tests__/contract.test.js src/__tests__/theme-token-refs.test.js src/__tests__/vetted-imports-audit.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-invites.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-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-page-url.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-flex-basis-percent.test.js src/__tests__/linter-hardcoded-design.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.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__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/flatten-entry.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/corner-radius.test.js src/__tests__/theme-components-parity.test.js src/__tests__/navigation-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/interaction-lift.test.js src/__tests__/toast-host.test.js src/__tests__/overlay-tokens.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js src/__tests__/hooks-camera.test.js src/__tests__/hooks-barcode.test.js src/__tests__/hooks-image-editor.test.js src/__tests__/hooks-bound-columns.test.js src/__tests__/hooks-stable-query.test.js src/__tests__/hooks-can-write.test.js src/__tests__/widget-route.test.js src/__tests__/linter-html-in-content.test.js src/__tests__/markdown.test.js src/__tests__/markdown-edit.test.js src/__tests__/richtext-tokens.test.js src/__tests__/style-group.test.js"
52
+ "test": "node --test src/__tests__/contract.test.js src/__tests__/theme-token-refs.test.js src/__tests__/vetted-imports-audit.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-invites.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-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-page-url.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-flex-basis-percent.test.js src/__tests__/linter-hardcoded-design.test.js src/__tests__/linter-editability.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.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__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/flatten-entry.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/corner-radius.test.js src/__tests__/theme-components-parity.test.js src/__tests__/navigation-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/interaction-lift.test.js src/__tests__/toast-host.test.js src/__tests__/overlay-tokens.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js src/__tests__/hooks-camera.test.js src/__tests__/hooks-barcode.test.js src/__tests__/hooks-image-editor.test.js src/__tests__/hooks-bound-columns.test.js src/__tests__/hooks-stable-query.test.js src/__tests__/hooks-can-write.test.js src/__tests__/widget-route.test.js src/__tests__/linter-html-in-content.test.js src/__tests__/markdown.test.js src/__tests__/markdown-edit.test.js src/__tests__/richtext-tokens.test.js src/__tests__/style-group.test.js"
53
53
  },
54
54
  "engines": {
55
55
  "node": ">=18"