@smallpen/cli 0.1.0-alpha.13 → 0.1.0-alpha.14

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.
@@ -28,6 +28,11 @@ const integer = (option, description, extra = {}) =>
28
28
  });
29
29
 
30
30
  const PARAMETERS = {
31
+ targetTimeout: integer(
32
+ "--target-timeout",
33
+ "Maximum milliseconds per validation target, including native layout; progress uses JSON lines on stderr and interruption returns partial coverage",
34
+ { minimum: 1, maximum: 600_000, default: 30_000 },
35
+ ),
31
36
  stdout: flag(
32
37
  "--stdout",
33
38
  "Print large results to stdout instead of saving a temporary result file",
@@ -488,7 +493,7 @@ export const COMMAND_CONTRACTS = {
488
493
  ),
489
494
  validate: command(
490
495
  "Validate canonical data and report text/layout/contrast issues and coverage; a page checks every version and a component every variant unless one is named",
491
- [...TARGET, ...VIEW, "designSystem", "limit", "offset"],
496
+ [...TARGET, ...VIEW, "designSystem", "limit", "offset", "targetTimeout"],
492
497
  { conflicts: { designSystem: ["page", "component"] } },
493
498
  ),
494
499
  inspect: command("Read package identity, revision and counts"),
@@ -261,7 +261,7 @@ export function componentLookup(workspace) {
261
261
  }
262
262
  return (reference) =>
263
263
  reference
264
- ? (sets.get(`${reference.packageId}\0${reference.assetId}`) ?? sets.get(reference.assetId))
264
+ ? (reference.packageId ? sets.get(`${reference.packageId}\0${reference.assetId}`) : sets.get(reference.assetId))
265
265
  : undefined;
266
266
  }
267
267
 
@@ -468,7 +468,8 @@ function variantPairs(selection, set) {
468
468
  // Node IDs show only with full: agents read and edit by name.
469
469
  export function outlineTree(tree, projection, { full = false, components, ids = full } = {}) {
470
470
  const lines = [];
471
- const size = (node) => `${Math.round(node.bounds.width)}×${Math.round(node.bounds.height)}`;
471
+ const dimension = value => Number.isFinite(value) ? Math.round(value) : "?";
472
+ const size = (node) => `${dimension(node.bounds?.width)}×${dimension(node.bounds?.height)}`;
472
473
  const describe = (node) => {
473
474
  const raw = projection.nodes[node.id];
474
475
  const reference = instanceReference(node, projection);
package/bin/schema.mjs CHANGED
@@ -765,8 +765,8 @@ function setTopic(command) {
765
765
  command,
766
766
  fields: {
767
767
  set: "FIELD=VALUE, repeatable; VALUE is JSON (320, [8,16], {\"Style\":\"ghost\"}, true) or plain text; {token.path} binds a Token",
768
- element: "fields: width, height (number, fill, hug), fill, stroke, radius, opacity, shadow, visible, rotation, margin, min/max sizes; texts: text, color, font, fontFamily, fontSize, fontWeight, lineHeight, letterSpacing, textTransform, textDecoration, textAlign; containers: layout (row|column|none), gap, padding, align, justify",
769
- copy: "a copy of a component: props, text {\"Element\": \"text\"}, set {\"Element.field\": value}, width, height (numbers), visible, opacity",
768
+ element: "fields: width, height (number, px, Token, fill, hug), fill, stroke, radius, opacity, shadow, visible, rotation, margin, min/max sizes; texts: text, color, font, fontFamily, fontSize, fontWeight, lineHeight, letterSpacing, textTransform, textDecoration, textAlign; containers: layout (row|column|none), gap, padding, align, justify",
769
+ copy: "a copy of a component: props, text {\"Element\": \"text\"}, set {\"Element.field\": value}, width/height (numbers, px, Tokens, hug, or fill in a row/column parent), visible, opacity. Sizes inside set (Element.width/height) take numbers or Tokens",
770
770
  itself: "without --element: the page version (or the variant) itself, for example its width and height",
771
771
  },
772
772
  example: [
package/bin/smallpen.mjs CHANGED
@@ -169,6 +169,8 @@ import {
169
169
  viewSummary,
170
170
  } from "./read-output.mjs";
171
171
 
172
+ import { runValidationJobs } from "./validation-runner.mjs";
173
+
172
174
  let writeState = "not-applied";
173
175
  async function trackedWrite(action) {
174
176
  writeState = "unknown";
@@ -1973,8 +1975,11 @@ async function designCommand(packagePath, args, override) {
1973
1975
  };
1974
1976
  }
1975
1977
 
1976
- async function validationDesigns(workspace, args) {
1977
- if (!flag(args, "--all")) return [await designForCommand(workspace, args)];
1978
+ async function validationJobs(workspace, args) {
1979
+ if (!flag(args, "--all")) {
1980
+ const design = await designForCommand(workspace, args);
1981
+ return [{ design, selection: design.selection }];
1982
+ }
1978
1983
  const componentId = option(args, "--component-id"),
1979
1984
  screenId = option(args, "--screen-id");
1980
1985
  if (!componentId && !screenId)
@@ -1992,11 +1997,9 @@ async function validationDesigns(workspace, args) {
1992
1997
  (snapshot) => snapshot?.manifest.packageId === first.selection.packageId,
1993
1998
  );
1994
1999
  return owner.domain.componentSets.get(componentId).variants.map((variant) =>
1995
- selectedDesign(workspace, designSelector(args), {
1996
- componentId,
1997
- packageId: first.selection.packageId,
1998
- variantId: variant.id,
1999
- }),
2000
+ ({ selector: designSelector(args),
2001
+ target: { componentId, packageId: first.selection.packageId, variantId: variant.id },
2002
+ selection: { componentId, packageId: first.selection.packageId, variantId: variant.id } }),
2000
2003
  );
2001
2004
  }
2002
2005
  const screen = workspace.product.manifest.entries.screens
@@ -2005,11 +2008,8 @@ async function validationDesigns(workspace, args) {
2005
2008
  if (!screen)
2006
2009
  throw new SmallPenError("missing_screen", `No Screen ${screenId}`);
2007
2010
  return screen.presentations.map((presentation) =>
2008
- selectedDesign(workspace, {
2009
- ...designSelector(args),
2010
- screenId,
2011
- presentationId: presentation.id,
2012
- }),
2011
+ ({ selector: { ...designSelector(args), screenId, presentationId: presentation.id },
2012
+ selection: { screenId, presentationId: presentation.id } }),
2013
2013
  );
2014
2014
  }
2015
2015
 
@@ -3570,29 +3570,26 @@ async function validateWholePackage(packagePath, args, workspace, resolution, on
3570
3570
  };
3571
3571
  const issues = [], checked = [], skipped = [], hints = [];
3572
3572
  let unreadable = 0;
3573
+ const jobs = [];
3573
3574
  for (const target of targets) {
3574
3575
  try {
3575
- const report = await validateTargets(workspace, ["validate", packagePath, ...target.ids, ...pass]);
3576
- for (const issue of report.issues ?? []) {
3577
- const named = selectionNames(workspace, issue.target ?? {});
3578
- const at = [named.page ?? named.component ?? target.where, named.platform ?? named.variant].filter(Boolean);
3579
- const { target: _target, ...rest } = issue;
3580
- issues.push({ target: at.length > 1 ? `${at[0]} (${at[1]})` : at[0], ...rest });
3581
- }
3582
- checked.push(...(report.coverage?.targets ?? []));
3583
- skipped.push(...(report.coverage?.skipped ?? []).map((skip) => ({ ...skip, label: targetLabel(skip.target) })));
3584
- for (const hint of report.hints ?? [])
3585
- if (!hints.some((other) => JSON.stringify(other) === JSON.stringify(hint))) hints.push(hint);
3576
+ jobs.push(...await validationJobs(workspace, ["validate", packagePath, ...target.ids, ...pass]));
3586
3577
  } catch (error) {
3587
3578
  unreadable += 1;
3588
- issues.push({
3589
- target: target.where,
3590
- code: error?.code ?? "unreadable_target",
3591
- severity: "error",
3592
- check: "read",
3593
- message: `${target.where} cannot be drawn: ${error?.message ?? error}`,
3594
- });
3579
+ issues.push({ target: target.where, code: error.code ?? "unreadable_target", severity: "error", check: "read", message: error.message });
3580
+ }
3581
+ }
3582
+ const outcome = await runChecks(workspace, jobs, args);
3583
+ for (const result of outcome.results) {
3584
+ const report = validationResult(result);
3585
+ for (const issue of report.issues ?? []) {
3586
+ const { target: _target, ...rest } = issue;
3587
+ issues.push({ target: validationLabel(workspace, report.selection), ...rest });
3595
3588
  }
3589
+ if (result.report) checked.push(report.selection);
3590
+ skipped.push(...(report.coverage?.skipped ?? []).map(skip => ({ ...skip, target: report.selection, label: validationLabel(workspace, report.selection) })));
3591
+ for (const hint of report.hints ?? [])
3592
+ if (!hints.some(other => JSON.stringify(other) === JSON.stringify(hint))) hints.push(hint);
3596
3593
  }
3597
3594
  // Layout limits belong to the tool, with full diagnostics kept separately
3598
3595
  // from design issues and every skipped layout check counted in coverage.
@@ -3604,9 +3601,9 @@ async function validateWholePackage(packagePath, args, workspace, resolution, on
3604
3601
  return {
3605
3602
  packageId: product.manifest.packageId,
3606
3603
  ...workspaceRevisions(product, workspace.foundation, workspace.libraries),
3607
- status: unreadable ? "invalid" : "valid",
3604
+ status: outcome.interrupted ? "interrupted" : outcome.results.some(result => result.error) ? "partial" : unreadable ? "invalid" : "valid",
3608
3605
  dataStatus: unreadable ? "invalid" : "valid",
3609
- visualStatus: design.length ? "issues" : "no-issues-found",
3606
+ visualStatus: design.length ? "issues" : outcome.interrupted ? "partial" : "no-issues-found",
3610
3607
  issueCount: design.length,
3611
3608
  issues: full ? design : page.items,
3612
3609
  ...(full ? {} : { page: page.page }),
@@ -3618,7 +3615,8 @@ async function validateWholePackage(packagePath, args, workspace, resolution, on
3618
3615
  selection: { themes: themeSettings(workspace).selection.themes },
3619
3616
  coverage: {
3620
3617
  // Every target drawn and every check run on it.
3621
- complete: unreadable === 0 && notChecked.length === 0,
3618
+ complete: unreadable === 0 && notChecked.length === 0 && !outcome.interrupted,
3619
+ ...executionCoverage(outcome),
3622
3620
  targetCount: checked.length,
3623
3621
  // By name, short: "Home (desktop)", "Button (Style=primary)".
3624
3622
  // Pages by version; components as one line each unless --full.
@@ -3763,48 +3761,63 @@ function projectSummary(snapshot) {
3763
3761
  };
3764
3762
  }
3765
3763
 
3766
- // Every check of the selected targets, untruncated.
3767
- async function validateTargets(workspace, args) {
3768
- const designs = await validationDesigns(workspace, args);
3769
- const reports = [];
3770
- for (const design of designs) {
3771
- const report = {
3772
- ...(await validateDesign(workspace, design)),
3773
- selection: design.selection,
3774
- };
3775
- // A skipped check names its element; its path is a projected node id.
3776
- const nodes = design.projection?.nodes ?? {};
3777
- report.coverage.skipped = (report.coverage.skipped ?? []).map((skip) => {
3778
- const at = typeof skip.path === "string" && Object.hasOwn(nodes, skip.path) ? skip.path
3779
- : typeof skip.nodeId === "string" && Object.hasOwn(nodes, skip.nodeId) ? skip.nodeId : undefined;
3780
- if (at === undefined) return skip;
3781
- const { path: _path, ...rest } = skip;
3782
- return { ...rest, element: elementPath(nodes, design.projection.rootId, at) || nodes[at].name };
3764
+ function validationLabel(workspace, selection) {
3765
+ const named = selectionNames(workspace, selection);
3766
+ const parts = [named.page ?? named.component, named.platform ?? named.variant].filter(Boolean);
3767
+ return parts.length > 1 ? `${parts[0]} (${parts[1]})` : parts[0] ?? "design";
3768
+ }
3769
+
3770
+ async function runChecks(workspace, jobs, args) {
3771
+ const controller = new AbortController();
3772
+ const interrupt = () => controller.abort();
3773
+ process.once("SIGINT", interrupt);
3774
+ process.once("SIGTERM", interrupt);
3775
+ try {
3776
+ const outcome = await runValidationJobs(workspace, jobs.map(job => ({
3777
+ ...job, label: validationLabel(workspace, job.selection),
3778
+ })), {
3779
+ timeoutMs: Number(option(args, "--target-timeout") ?? 30_000),
3780
+ signal: controller.signal,
3781
+ progress: event => process.stderr.write(JSON.stringify(event) + "\n"),
3783
3782
  });
3784
- if (design.selection.screenId) {
3785
- const flow = prototypeConnections(
3786
- workspace.product,
3787
- design.selection,
3788
- {
3789
- ...workspace,
3790
- context: design.projection.context,
3791
- initialProjection: design.projection,
3792
- },
3793
- );
3794
- report.issues.push(
3795
- ...flow.issues.map((issue) => ({
3796
- ...issue,
3797
- check: "prototype-connections",
3798
- })),
3799
- );
3800
- report.coverage.checks.push("prototype-connections");
3801
- report.coverage.connectionCount = flow.connections.length;
3802
- }
3803
- reports.push(report);
3783
+ if (outcome.interrupted) process.exitCode = 130;
3784
+ return outcome;
3785
+ } finally {
3786
+ process.removeListener("SIGINT", interrupt);
3787
+ process.removeListener("SIGTERM", interrupt);
3804
3788
  }
3789
+ }
3790
+
3791
+ function executionCoverage(outcome) {
3792
+ return {
3793
+ totalTargetCount: outcome.total,
3794
+ completedTargetCount: outcome.results.length,
3795
+ failedTargetCount: outcome.results.filter(result => result.error).length,
3796
+ remainingCount: outcome.remaining.length,
3797
+ remaining: outcome.remaining,
3798
+ interrupted: outcome.interrupted,
3799
+ };
3800
+ }
3801
+
3802
+ function validationResult({ job, report, error }) {
3803
+ return report ?? {
3804
+ selection: job.selection,
3805
+ issues: [{ ...error, severity: "error", check: "execution" }],
3806
+ coverage: { complete: false, nodeCount: 0, visibleNodeCount: 0, checks: [],
3807
+ skipped: [{ check: "native-layout", reason: error.message, code: error.code, target: job.selection }] },
3808
+ };
3809
+ }
3810
+
3811
+ // Every check of the selected targets, untruncated.
3812
+ async function validateTargets(workspace, args) {
3813
+ const outcome = await runChecks(workspace, await validationJobs(workspace, args), args);
3814
+ const reports = outcome.results.map(validationResult);
3815
+ const empty = { issues: [], coverage: { complete: false, checks: [], skipped: [], nodeCount: 0, visibleNodeCount: 0 } };
3805
3816
  const all = flag(args, "--all");
3806
3817
  const visual = {
3807
- ...reports[0],
3818
+ ...(reports[0] ?? empty),
3819
+ ...(outcome.interrupted ? { status: "interrupted", visualStatus: "partial" }
3820
+ : outcome.results.some(result => result.error) ? { status: "partial", visualStatus: "partial" } : {}),
3808
3821
  ...(reports.some((report) => report.hints?.length) ? { hints: reports.flatMap((report) => report.hints ?? []) } : {}),
3809
3822
  issues: reports.flatMap((report) =>
3810
3823
  report.issues.map((issue) =>
@@ -3812,7 +3825,9 @@ async function validateTargets(workspace, args) {
3812
3825
  ),
3813
3826
  ),
3814
3827
  coverage: {
3815
- ...reports[0].coverage,
3828
+ ...(reports[0]?.coverage ?? empty.coverage),
3829
+ ...executionCoverage(outcome),
3830
+ complete: !outcome.interrupted && reports.every(report => report.coverage.complete),
3816
3831
  targets: reports.map((report) => ({ ...selectionNames(workspace, report.selection), ...report.selection })),
3817
3832
  targetCount: reports.length,
3818
3833
  nodeCount: reports.reduce(
@@ -3859,7 +3874,7 @@ async function validateTargets(workspace, args) {
3859
3874
  ...(option(args, "--component-id")
3860
3875
  ? {
3861
3876
  componentId: option(args, "--component-id"),
3862
- packageId: reports[0].selection.packageId,
3877
+ packageId: reports[0]?.selection.packageId,
3863
3878
  }
3864
3879
  : { screenId: option(args, "--screen-id") }),
3865
3880
  };
@@ -0,0 +1,62 @@
1
+ import { Worker } from "node:worker_threads";
2
+
3
+ // A persistent worker reuses the shared App runtime. Terminating the worker
4
+ // also stops synchronous WASM; a Promise timeout in this process cannot.
5
+ export async function runValidationJobs(workspace, jobs, {
6
+ timeoutMs = 30_000, signal, progress = () => {},
7
+ workerUrl = new URL("./validation-worker.mjs", import.meta.url),
8
+ } = {}) {
9
+ const results = [];
10
+ let worker;
11
+ const emit = (event, extra = {}) => progress({
12
+ type: "validation-progress", event, total: jobs.length, completed: results.length, ...extra,
13
+ });
14
+ emit("start");
15
+ try {
16
+ for (const job of jobs) {
17
+ if (signal?.aborted) break;
18
+ emit("target", { target: job.label });
19
+ worker ??= new Worker(workerUrl, { workerData: workspace });
20
+ const result = await new Promise(resolve => {
21
+ let finished = false;
22
+ const finish = value => {
23
+ if (finished) return;
24
+ finished = true;
25
+ clearTimeout(timer);
26
+ worker.off("message", onMessage);
27
+ worker.off("error", onError);
28
+ worker.off("exit", onExit);
29
+ signal?.removeEventListener("abort", onAbort);
30
+ resolve(value);
31
+ };
32
+ const onMessage = value => finish(value);
33
+ const onError = error => finish({ error: { code: "validation_worker_failed", message: error.message }, restart: true });
34
+ const onExit = code => onError(new Error(`Validation worker exited (${code})`));
35
+ const onAbort = () => finish({ interrupted: true, restart: true });
36
+ const timer = setTimeout(() => finish({ error: {
37
+ code: "validation_target_timeout", message: `Target exceeded ${timeoutMs}ms`,
38
+ details: { timeoutMs, target: job.label, selection: job.selection },
39
+ }, restart: true }), timeoutMs);
40
+ worker.once("message", onMessage);
41
+ worker.once("error", onError);
42
+ worker.once("exit", onExit);
43
+ signal?.addEventListener("abort", onAbort, { once: true });
44
+ if (signal?.aborted) onAbort();
45
+ else worker.postMessage(job);
46
+ });
47
+ if (result.restart) {
48
+ await worker.terminate();
49
+ worker = undefined;
50
+ }
51
+ if (result.interrupted) break;
52
+ results.push({ job, ...result });
53
+ emit("completed", { target: job.label, status: result.error ? result.error.code : "checked" });
54
+ }
55
+ } finally {
56
+ await worker?.terminate();
57
+ }
58
+ const remaining = jobs.slice(results.length).map(job => ({ target: job.label, selection: job.selection }));
59
+ const interrupted = Boolean(signal?.aborted);
60
+ emit(interrupted ? "interrupted" : "finished", { remaining: remaining.length });
61
+ return { results, interrupted, remaining, total: jobs.length };
62
+ }
@@ -0,0 +1,29 @@
1
+ import { parentPort, workerData } from "node:worker_threads";
2
+ import { elementPath, prototypeConnections } from "@smallpen/core";
3
+ import { selectedDesign, validateDesign } from "./design-output.mjs";
4
+
5
+ const workspace = workerData;
6
+ parentPort.on("message", async (job) => {
7
+ try {
8
+ const design = job.design ?? selectedDesign(workspace, job.selector, job.target);
9
+ const report = { ...(await validateDesign(workspace, design)), selection: design.selection };
10
+ const nodes = design.projection.nodes;
11
+ report.coverage.skipped = (report.coverage.skipped ?? []).map(skip => {
12
+ const at = [skip.path, skip.nodeId].find(id => typeof id === "string" && Object.hasOwn(nodes, id));
13
+ if (!at) return skip;
14
+ const { path: _path, ...rest } = skip;
15
+ return { ...rest, element: elementPath(nodes, design.projection.rootId, at) || nodes[at].name };
16
+ });
17
+ if (design.selection.screenId) {
18
+ const flow = prototypeConnections(workspace.product, design.selection, {
19
+ ...workspace, context: design.projection.context, initialProjection: design.projection,
20
+ });
21
+ report.issues.push(...flow.issues.map(issue => ({ ...issue, check: "prototype-connections" })));
22
+ report.coverage.checks.push("prototype-connections");
23
+ report.coverage.connectionCount = flow.connections.length;
24
+ }
25
+ parentPort.postMessage({ report });
26
+ } catch (error) {
27
+ parentPort.postMessage({ error: { code: error.code ?? "unreadable_target", message: error.message, details: error.details } });
28
+ }
29
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smallpen/cli",
3
- "version": "0.1.0-alpha.13",
3
+ "version": "0.1.0-alpha.14",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -15,8 +15,8 @@
15
15
  "bin"
16
16
  ],
17
17
  "dependencies": {
18
- "@smallpen/core": "0.1.0-alpha.13",
19
- "@smallpen/local-package": "0.1.0-alpha.13"
18
+ "@smallpen/core": "0.1.0-alpha.14",
19
+ "@smallpen/local-package": "0.1.0-alpha.14"
20
20
  },
21
21
  "repository": {
22
22
  "type": "git",