@colixsystems/widget-sdk 0.116.0 → 0.118.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,54 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
70
70
 
71
71
  ## Status
72
72
 
73
- `v0.112.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.118.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.118.0 (contract 1.90.0)
76
+
77
+ **A `styleSchema` field's `default` now actually applies.** Declaring `default` on a style field wrote it into the manifest and nothing ever read it back, so a widget's own styling baseline — and any design saved from the Widget Builder preview — was silently dropped on the next render. The host now resolves it onto `props.style`.
78
+
79
+ It is the **weakest** layer, deliberately: it applies only when nothing above it sets that field.
80
+
81
+ ```
82
+ styleSchema `default` -> palette / components.<scope> -> widgetStyles[manifestId] -> per-instance props.style
83
+ ```
84
+
85
+ So a workspace theme still outranks a widget's own baseline, and a field you leave undefaulted keeps following the theme exactly as before. Nothing changes for a widget that declares no style defaults.
86
+
87
+ The host still does **not** apply style to elements — your widget owns placement and keeps reading `props.style.<field>` (or `useWidgetStyle()`) and applying each value where it chooses:
88
+
89
+ ```jsx
90
+ const style = useWidgetStyle();
91
+ <View style={[styles.card, style.cardBackground && { backgroundColor: style.cardBackground }]}>
92
+ ```
93
+
94
+ Keep `themeDefault` for a fallback that IS a theme token: it stays display-only (a greyed placeholder in the Style panel) so the field tracks the workspace theme. Use `default` for a literal constant your code genuinely falls back to — that value now reaches `props.style`, so it must match what your code applies.
95
+
96
+ ### What's new in 0.117.0 (contract 1.89.0)
97
+
98
+ **`expo-sensors` is a vetted import — widgets can read device motion.** The allowlist held no sensor package, so a step counter, a shake control, a tilt/level, or a compass had nothing to read the hardware with. `expo-sensors` (Accelerometer, Gyroscope, Magnetometer, DeviceMotion, Barometer, Pedometer, LightSensor) is now on the list as **native-only** (`platforms: ["native"]`, category `sensors`) and pinned by the Expo export.
99
+
100
+ Native-only is deliberate rather than a gap: the package's own web build derives "acceleration" from `deviceorientation` **angles**, not real motion, so a shake threshold tuned in the Player would behave differently in the exported app. Split the widget and read the browser API directly on web:
101
+
102
+ ```jsx
103
+ // widget.native.jsx
104
+ import { Accelerometer } from "expo-sensors";
105
+
106
+ Accelerometer.setUpdateInterval(100);
107
+ const sub = Accelerometer.addListener(({ x, y, z }) => setReading({ x, y, z }));
108
+ return () => sub.remove(); // ALWAYS remove it — a live sensor drains the battery
109
+ ```
110
+
111
+ ```jsx
112
+ // widget.web.jsx — window.DeviceMotionEvent exposes the same hardware
113
+ const onMotion = (e) => setReading(e.accelerationIncludingGravity);
114
+ window.addEventListener("devicemotion", onMotion);
115
+ return () => window.removeEventListener("devicemotion", onMotion);
116
+ ```
117
+
118
+ Start the reading from a **user gesture** on both hosts — iOS Safari also needs an explicit `DeviceMotionEvent.requestPermission()` grant, and neither host delivers readings to a listener attached on mount.
119
+
120
+ **Fixed: a `expo-haptics` widget failed the native build.** `expo-haptics` has been vetted since the package expansion but was never pinned in the exported app's `package.json`, so a widget importing it rendered in the web Player and broke the Expo bundle with "Unable to resolve module expo-haptics". It is pinned now, and the pairing is no longer hand-maintained: every native-capable entry on the vetted list is checked against the export's dependency set by a contract-derived test.
74
121
 
75
122
  ### What's new in 0.116.0 (contract 1.88.0)
76
123
 
package/dist/contract.cjs CHANGED
@@ -1967,8 +1967,11 @@ const MANIFEST_SCHEMA = {
1967
1967
  "exposes; the Studio Properties Panel renders a \"Style\" section from " +
1968
1968
  "it. The author's resolved values are delivered to the widget under " +
1969
1969
  "props.style (one object keyed by style-field name); the widget reads " +
1970
- "props.style.<field> and applies each wherever it chooses. The host " +
1971
- "never auto-applies style the widget owns placement.",
1970
+ "props.style.<field> and applies each wherever it chooses. A field's " +
1971
+ "own `default` is resolved onto props.style as the WEAKEST layer " +
1972
+ "(sc-6750): it applies only when no theme layer and no per-instance " +
1973
+ "value sets that field. The host never applies style to elements " +
1974
+ "— the widget owns placement.",
1972
1975
  default: {},
1973
1976
  },
1974
1977
  rendersOwnChrome: {
@@ -2571,6 +2574,13 @@ const VETTED_IMPORTS = [
2571
2574
  description:
2572
2575
  "Renders a QR code, drawn as SVG through the vetted react-native-svg — so one `<QRCode value={…} size={…} />` covers both platforms with no split file. Pure JS with no native module of its own: the web host resolves it and the Expo export pins it, exactly like date-fns. Its `text-encoding` polyfill is opt-in for React Native < 0.75 only; the export ships RN 0.85.3, which has a global TextEncoder, so the Metro transformer that package documents is deliberately not wired up.",
2573
2576
  },
2577
+ {
2578
+ specifier: "expo-sensors",
2579
+ platforms: ["native"],
2580
+ category: "sensors",
2581
+ description:
2582
+ "Device motion hardware on the Expo export: Accelerometer, Gyroscope, Magnetometer, DeviceMotion, Barometer, Pedometer and LightSensor, each read as an addListener subscription with setUpdateInterval — always remove the subscription on unmount, a sensor left running drains the battery. Expo SDK 56 ships 56.0.x. Native-only on purpose: the package's own web build derives acceleration from deviceorientation ANGLES rather than real motion, so a shake or tilt threshold tuned on one host would read differently on the other. Author it in widget.native.jsx and pair it with a widget.web.jsx reading window.DeviceMotionEvent (accelerationIncludingGravity / rotationRate), the browser API the same hardware exposes. Both hosts need a user gesture before readings start, and iOS Safari additionally needs an explicit DeviceMotionEvent.requestPermission() grant — so gate the reading behind a Pressable, never start it on mount.",
2583
+ },
2574
2584
  ];
2575
2585
 
2576
2586
  // sc-1064: CORE React infrastructure specifiers the host RESOLVES at runtime
@@ -3500,7 +3510,24 @@ const CONTRACT = deepFreeze({
3500
3510
  // widget can open at SCREEN level. Everything before it was clipped by the
3501
3511
  // layout container the widget sits in, so a preview or dialog could not leave
3502
3512
  // the widget's own tile on either host.
3503
- version: "1.88.0",
3513
+ // 1.89.0: additive — `expo-sensors` joins VETTED_IMPORTS as a native-only
3514
+ // `sensors` package (Accelerometer / Gyroscope / DeviceMotion / Barometer /
3515
+ // Pedometer / LightSensor). Widgets had no path to device motion at all: the
3516
+ // allowlist held no sensor package, so a step counter, a tilt/shake control,
3517
+ // a level, or a compass was unbuildable. Native-only because the package's
3518
+ // web build reads deviceorientation angles rather than real acceleration —
3519
+ // the web half is a widget.web.jsx over window.DeviceMotionEvent. Pinned in
3520
+ // the compiler's export dependencies, like every other native-module member.
3521
+ // 1.90.0: additive (sc-6750) — a `styleSchema` field's declared `default`
3522
+ // now RESOLVES onto `props.style`, as the weakest style layer. It was
3523
+ // written into the manifest and then read by nothing, so an author's saved
3524
+ // colour was silently dropped on the next render. Precedence is unchanged
3525
+ // above it: styleSchema `default` -> palette/`components.<scope>` ->
3526
+ // `widgetStyles[manifestId]` -> per-instance `props.style`, so a workspace
3527
+ // theme still outranks a widget's own baseline and a field the author never
3528
+ // defaulted follows the theme exactly as before. The host still never
3529
+ // applies style to elements — the widget owns placement.
3530
+ version: "1.90.0",
3504
3531
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3505
3532
  hooks: HOOKS,
3506
3533
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1967,8 +1967,11 @@ const MANIFEST_SCHEMA = {
1967
1967
  "exposes; the Studio Properties Panel renders a \"Style\" section from " +
1968
1968
  "it. The author's resolved values are delivered to the widget under " +
1969
1969
  "props.style (one object keyed by style-field name); the widget reads " +
1970
- "props.style.<field> and applies each wherever it chooses. The host " +
1971
- "never auto-applies style the widget owns placement.",
1970
+ "props.style.<field> and applies each wherever it chooses. A field's " +
1971
+ "own `default` is resolved onto props.style as the WEAKEST layer " +
1972
+ "(sc-6750): it applies only when no theme layer and no per-instance " +
1973
+ "value sets that field. The host never applies style to elements " +
1974
+ "— the widget owns placement.",
1972
1975
  default: {},
1973
1976
  },
1974
1977
  rendersOwnChrome: {
@@ -2571,6 +2574,13 @@ const VETTED_IMPORTS = [
2571
2574
  description:
2572
2575
  "Renders a QR code, drawn as SVG through the vetted react-native-svg — so one `<QRCode value={…} size={…} />` covers both platforms with no split file. Pure JS with no native module of its own: the web host resolves it and the Expo export pins it, exactly like date-fns. Its `text-encoding` polyfill is opt-in for React Native < 0.75 only; the export ships RN 0.85.3, which has a global TextEncoder, so the Metro transformer that package documents is deliberately not wired up.",
2573
2576
  },
2577
+ {
2578
+ specifier: "expo-sensors",
2579
+ platforms: ["native"],
2580
+ category: "sensors",
2581
+ description:
2582
+ "Device motion hardware on the Expo export: Accelerometer, Gyroscope, Magnetometer, DeviceMotion, Barometer, Pedometer and LightSensor, each read as an addListener subscription with setUpdateInterval — always remove the subscription on unmount, a sensor left running drains the battery. Expo SDK 56 ships 56.0.x. Native-only on purpose: the package's own web build derives acceleration from deviceorientation ANGLES rather than real motion, so a shake or tilt threshold tuned on one host would read differently on the other. Author it in widget.native.jsx and pair it with a widget.web.jsx reading window.DeviceMotionEvent (accelerationIncludingGravity / rotationRate), the browser API the same hardware exposes. Both hosts need a user gesture before readings start, and iOS Safari additionally needs an explicit DeviceMotionEvent.requestPermission() grant — so gate the reading behind a Pressable, never start it on mount.",
2583
+ },
2574
2584
  ];
2575
2585
 
2576
2586
  // sc-1064: CORE React infrastructure specifiers the host RESOLVES at runtime
@@ -3500,7 +3510,24 @@ const CONTRACT = deepFreeze({
3500
3510
  // widget can open at SCREEN level. Everything before it was clipped by the
3501
3511
  // layout container the widget sits in, so a preview or dialog could not leave
3502
3512
  // the widget's own tile on either host.
3503
- version: "1.88.0",
3513
+ // 1.89.0: additive — `expo-sensors` joins VETTED_IMPORTS as a native-only
3514
+ // `sensors` package (Accelerometer / Gyroscope / DeviceMotion / Barometer /
3515
+ // Pedometer / LightSensor). Widgets had no path to device motion at all: the
3516
+ // allowlist held no sensor package, so a step counter, a tilt/shake control,
3517
+ // a level, or a compass was unbuildable. Native-only because the package's
3518
+ // web build reads deviceorientation angles rather than real acceleration —
3519
+ // the web half is a widget.web.jsx over window.DeviceMotionEvent. Pinned in
3520
+ // the compiler's export dependencies, like every other native-module member.
3521
+ // 1.90.0: additive (sc-6750) — a `styleSchema` field's declared `default`
3522
+ // now RESOLVES onto `props.style`, as the weakest style layer. It was
3523
+ // written into the manifest and then read by nothing, so an author's saved
3524
+ // colour was silently dropped on the next render. Precedence is unchanged
3525
+ // above it: styleSchema `default` -> palette/`components.<scope>` ->
3526
+ // `widgetStyles[manifestId]` -> per-instance `props.style`, so a workspace
3527
+ // theme still outranks a widget's own baseline and a field the author never
3528
+ // defaulted follows the theme exactly as before. The host still never
3529
+ // applies style to elements — the widget owns placement.
3530
+ version: "1.90.0",
3504
3531
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3505
3532
  hooks: HOOKS,
3506
3533
  primitives: PRIMITIVES,
package/dist/devserver.js CHANGED
@@ -67,32 +67,38 @@ import {
67
67
  shimSpecifierFromSlug,
68
68
  } from "./dev-shims.js";
69
69
  import { bundleWebEntry } from "./webbundle.js";
70
+ import {
71
+ findImportStatements,
72
+ findRelativeImportStatements,
73
+ } from "./flatten-entry.js";
70
74
 
71
75
  // Bare-import / relative-import detection. The loader rewrites bare specifiers
72
76
  // in the ENTRY to client-side blob shims, so they resolve fine. Relative
73
77
  // imports in a SINGLE-FILE entry have nowhere to resolve — single-file mode
74
78
  // refuses them with guidance. In directory mode, relative imports in the entry
75
79
  // AND in siblings are rewritten to absolute dev-server URLs.
76
- const RELATIVE_IMPORT_RE =
77
- /(?:^|\n)\s*(?:import|export)[^;\n]*?from\s*['"](\.\.?\/[^'"]+)['"]/g;
78
-
79
- // Static-import / static-export-from anchored at a statement boundary
80
- // (start-of-line, `;`, or newline before `import`/`export`) so the rewriter
81
- // doesn't touch occurrences inside string literals or comment bodies. The
82
- // dynamic-import form (`import("...")`) is matched separately by
83
- // `DYNAMIC_IMPORT_RE` below — it's safe to anchor in the same way because
84
- // it's also valid only as an expression.
85
80
  //
86
- // Capture groups for both regexes:
87
- // 1 = leading keyword + whitespace (and the leading boundary char, which we
88
- // preserve verbatim during replace so the surrounding statement isn't
89
- // glued onto the previous line)
81
+ // STATIC imports are found by the packer's scanner (`findImportStatements`),
82
+ // never by a local regex. sc-6086 removed RELATIVE_IMPORT_RE and sc-6670 the
83
+ // rewriter's STATIC_IMPORT_RE for the same defect: a `[^'";\n]` clause body
84
+ // cannot span newlines, so a multi-line `import {\n a,\n} from "react"` — the
85
+ // norm in this codebase — read as no import at all and was served verbatim.
86
+ // The scanner walks the clause character by character, so line breaks are
87
+ // nothing special, and it returns the specifier's quote offsets so the
88
+ // rewriter splices exactly the specifier and can never bleed into an adjacent
89
+ // statement. One scanner for the guard, the packer, and the rewriter (§3).
90
+
91
+ // Dynamic import (`import("…")`) is an EXPRESSION, valid at any brace depth,
92
+ // so the statement scanner does not see it — it keeps its own regex. That
93
+ // regex separates the specifier from `import` with `\s*`, which does match
94
+ // newlines, so it has no multi-line gap (sc-6670 verified).
95
+ //
96
+ // Capture groups:
97
+ // 1 = `import` + whitespace + `(` (and the leading boundary char, preserved
98
+ // verbatim during replace so the call isn't glued onto what precedes it)
90
99
  // 2 = opening quote
91
100
  // 3 = specifier
92
- const STATIC_IMPORT_RE =
93
- /((?:^|[\n;])\s*(?:import|export)\s+(?:[^'";\n]*?\s+from\s*)?)(['"])([^'"]+)\2/gm;
94
- const DYNAMIC_IMPORT_RE =
95
- /((?:^|[\s;\(,!?:=])import\s*\(\s*)(['"])([^'"]+)\2/g;
101
+ const DYNAMIC_IMPORT_RE = /((?:^|[\s;\(,!?:=])import\s*\(\s*)(['"])([^'"]+)\2/g;
96
102
 
97
103
  /**
98
104
  * Lazily resolve `sucrase`'s `transform`. Kept out of the module's static
@@ -137,15 +143,21 @@ export function transpile(transform, source) {
137
143
  * single-file mode to reject split-impl bundles with a clear message instead
138
144
  * of serving a module the browser can't resolve.
139
145
  *
146
+ * sc-6086: this delegates to the packer's statement scanner rather than
147
+ * `RELATIVE_IMPORT_RE`, whose `[^;\n]` body cannot span newlines — so a
148
+ * MULTI-LINE `import {\n a,\n b,\n} from "./lib/x.js"` was invisible to it.
149
+ * That blind spot is why `worktime-employer` passed every local check and
150
+ * still shipped an unresolvable import to the marketplace. One scanner, so
151
+ * the dev guard and the packer agree on what a relative import is
152
+ * (CLAUDE.md §3).
153
+ *
140
154
  * @param {string} source
141
155
  * @returns {string[]} unique relative specifiers
142
156
  */
143
157
  export function findRelativeImports(source) {
144
- const out = new Set();
145
- let m;
146
- RELATIVE_IMPORT_RE.lastIndex = 0;
147
- while ((m = RELATIVE_IMPORT_RE.exec(source))) out.add(m[1]);
148
- return Array.from(out);
158
+ return Array.from(
159
+ new Set(findRelativeImportStatements(source).map((s) => s.specifier)),
160
+ );
149
161
  }
150
162
 
151
163
  /**
@@ -201,15 +213,16 @@ export function resolveRelativeImport(fromRel, specifier) {
201
213
  export function rewriteImportsForDirectoryMode(source, ctx) {
202
214
  const { fromRel, baseUrl, shimmable } = ctx;
203
215
  const unresolved = new Set();
204
- const handler = (match, prefix, quote, spec) => {
216
+ // The URL a specifier should become, or null to leave it exactly as written.
217
+ const resolveSpecifier = (spec) => {
205
218
  // Relative — resolve under the widget dir.
206
219
  if (spec.startsWith("./") || spec.startsWith("../")) {
207
220
  const rel = resolveRelativeImport(fromRel, spec);
208
221
  if (!rel) {
209
222
  unresolved.add(spec);
210
- return match;
223
+ return null;
211
224
  }
212
- return `${prefix}${quote}${baseUrl}/file/${rel}${quote}`;
225
+ return `${baseUrl}/file/${rel}`;
213
226
  }
214
227
  // Absolute or already-resolved — leave alone.
215
228
  if (
@@ -219,21 +232,36 @@ export function rewriteImportsForDirectoryMode(source, ctx) {
219
232
  spec.startsWith("blob:") ||
220
233
  spec.startsWith("data:")
221
234
  ) {
222
- return match;
235
+ return null;
223
236
  }
224
237
  // Bare — rewrite if shimmable, else surface as unresolved.
225
238
  if (shimmable.includes(spec)) {
226
- return `${prefix}${quote}${baseUrl}/shim/${shimSlugFor(spec)}${quote}`;
239
+ return `${baseUrl}/shim/${shimSlugFor(spec)}`;
227
240
  }
228
241
  unresolved.add(spec);
229
- return match;
242
+ return null;
230
243
  };
231
- // Two passes: anchored static-import + anchored dynamic-import. The split
232
- // (vs. one mega-regex) keeps each pattern simple AND lets us anchor each at
233
- // its real statement boundary so substrings inside string literals like
234
- // `const s = "import x from 'react'"` never match.
235
- let out = source.replace(STATIC_IMPORT_RE, handler);
236
- out = out.replace(DYNAMIC_IMPORT_RE, handler);
244
+
245
+ // Pass 1 static imports, spliced by the scanner's quote offsets. Splicing
246
+ // only what sits BETWEEN the quotes is what makes span bleed impossible:
247
+ // the clause, the trailing `;`, and any adjacent statement are never part of
248
+ // the replaced range. Walk back-to-front so earlier offsets stay valid.
249
+ const statements = findImportStatements(source);
250
+ let out = source;
251
+ for (let i = statements.length - 1; i >= 0; i -= 1) {
252
+ const { specifier, quoteStart, quoteEnd } = statements[i];
253
+ const url = resolveSpecifier(specifier);
254
+ if (url === null) continue;
255
+ out = out.slice(0, quoteStart + 1) + url + out.slice(quoteEnd);
256
+ }
257
+
258
+ // Pass 2 — dynamic `import("…")`, an expression the statement scanner does
259
+ // not report. Its own anchor keeps it off substrings inside string literals
260
+ // like `const s = "import('react')"`.
261
+ out = out.replace(DYNAMIC_IMPORT_RE, (match, prefix, quote, spec) => {
262
+ const url = resolveSpecifier(spec);
263
+ return url === null ? match : `${prefix}${quote}${url}${quote}`;
264
+ });
237
265
  return { code: out, unresolved: Array.from(unresolved) };
238
266
  }
239
267
 
@@ -315,7 +343,10 @@ export function loadWidgetJson(widgetDir) {
315
343
  const candidates = [
316
344
  resolve(widgetDir, p),
317
345
  p.startsWith(`first-party-widgets/${widgetSlug}/`)
318
- ? resolve(widgetDir, p.slice(`first-party-widgets/${widgetSlug}/`.length))
346
+ ? resolve(
347
+ widgetDir,
348
+ p.slice(`first-party-widgets/${widgetSlug}/`.length),
349
+ )
319
350
  : null,
320
351
  resolve(widgetDir, "..", "..", p),
321
352
  ].filter(Boolean);
@@ -326,7 +357,10 @@ export function loadWidgetJson(widgetDir) {
326
357
  `${configPath}: ${label} must point at a file under ${widgetDir} (got "${p}")`,
327
358
  );
328
359
  }
329
- const manifestAbs = _resolveUnderWidget("manifestSource", config.manifestSource);
360
+ const manifestAbs = _resolveUnderWidget(
361
+ "manifestSource",
362
+ config.manifestSource,
363
+ );
330
364
 
331
365
  let entryAbs;
332
366
  if (config.componentSources && typeof config.componentSources === "object") {
@@ -353,7 +387,12 @@ export function loadWidgetJson(widgetDir) {
353
387
  );
354
388
  }
355
389
  const entryRel = relative(widgetDir, entryAbs).split(sep).join("/");
356
- return { widgetDir, manifestPath: manifestAbs, entryPath: entryAbs, entryRel };
390
+ return {
391
+ widgetDir,
392
+ manifestPath: manifestAbs,
393
+ entryPath: entryAbs,
394
+ entryRel,
395
+ };
357
396
  }
358
397
 
359
398
  function _isUnder(parentAbs, childAbs) {
@@ -444,7 +483,13 @@ export function createDevServer({
444
483
  // entry's own ancestor node_modules by default, so a widget that vendored
445
484
  // its deps locally still resolves.
446
485
  const bundleNodePaths = [];
447
- const _frontendNm = resolve(watchRoot, "..", "..", "frontend", "node_modules");
486
+ const _frontendNm = resolve(
487
+ watchRoot,
488
+ "..",
489
+ "..",
490
+ "frontend",
491
+ "node_modules",
492
+ );
448
493
  if (existsSync(_frontendNm)) bundleNodePaths.push(_frontendNm);
449
494
 
450
495
  const sseClients = new Set();
@@ -700,7 +745,13 @@ export function createDevServer({
700
745
  if (url === "/__dev/events") return serveEvents(req, res);
701
746
  if (url === "/__dev/health") {
702
747
  res.writeHead(200, { "Content-Type": "application/json" });
703
- res.end(JSON.stringify({ ok: true, manifestId, mode: directoryMode ? "directory" : "single-file" }));
748
+ res.end(
749
+ JSON.stringify({
750
+ ok: true,
751
+ manifestId,
752
+ mode: directoryMode ? "directory" : "single-file",
753
+ }),
754
+ );
704
755
  return;
705
756
  }
706
757
  if (directoryMode && url.startsWith("/file/")) {
@@ -736,7 +787,8 @@ export function createDevServer({
736
787
  if (ok) onLint("lint: clean");
737
788
  else {
738
789
  onLint(`lint: ${findings.length} finding(s)`);
739
- for (const f of findings) onLint(` [${f.rule}] line ${f.line}: ${f.label}`);
790
+ for (const f of findings)
791
+ onLint(` [${f.rule}] line ${f.line}: ${f.label}`);
740
792
  }
741
793
  }
742
794
  }