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

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
@@ -99,7 +99,9 @@ import {
99
99
  staleInstanceDiagnostics,
100
100
  } from "@smallpen/core";
101
101
  import {
102
+ BATCH_HISTORY_LIMITS,
102
103
  applyOperationBatch,
104
+ batchHistoryPath,
103
105
  defaultLibraryCacheRoot,
104
106
  fontVariantName,
105
107
  importDraft,
@@ -169,6 +171,8 @@ import {
169
171
  viewSummary,
170
172
  } from "./read-output.mjs";
171
173
 
174
+ import { runValidationJobs } from "./validation-runner.mjs";
175
+
172
176
  let writeState = "not-applied";
173
177
  async function trackedWrite(action) {
174
178
  writeState = "unknown";
@@ -438,6 +442,8 @@ function printJson(value) {
438
442
  "libraryRevisions",
439
443
  "batchId",
440
444
  "alreadyApplied",
445
+ "history",
446
+ "historyWindow",
441
447
  "changed",
442
448
  "dryRun",
443
449
  "noChange",
@@ -1181,7 +1187,7 @@ async function writeReply(result, args) {
1181
1187
  ],
1182
1188
  }
1183
1189
  : {
1184
- ledgerPath: `${resolve(args[1])}.batches.json`,
1190
+ ledgerPath: await batchHistoryPath(resolve(args[1])),
1185
1191
  batchId: result.batchId,
1186
1192
  }),
1187
1193
  };
@@ -1973,8 +1979,11 @@ async function designCommand(packagePath, args, override) {
1973
1979
  };
1974
1980
  }
1975
1981
 
1976
- async function validationDesigns(workspace, args) {
1977
- if (!flag(args, "--all")) return [await designForCommand(workspace, args)];
1982
+ async function validationJobs(workspace, args) {
1983
+ if (!flag(args, "--all")) {
1984
+ const design = await designForCommand(workspace, args);
1985
+ return [{ design, selection: design.selection }];
1986
+ }
1978
1987
  const componentId = option(args, "--component-id"),
1979
1988
  screenId = option(args, "--screen-id");
1980
1989
  if (!componentId && !screenId)
@@ -1992,11 +2001,9 @@ async function validationDesigns(workspace, args) {
1992
2001
  (snapshot) => snapshot?.manifest.packageId === first.selection.packageId,
1993
2002
  );
1994
2003
  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
- }),
2004
+ ({ selector: designSelector(args),
2005
+ target: { componentId, packageId: first.selection.packageId, variantId: variant.id },
2006
+ selection: { componentId, packageId: first.selection.packageId, variantId: variant.id } }),
2000
2007
  );
2001
2008
  }
2002
2009
  const screen = workspace.product.manifest.entries.screens
@@ -2005,11 +2012,8 @@ async function validationDesigns(workspace, args) {
2005
2012
  if (!screen)
2006
2013
  throw new SmallPenError("missing_screen", `No Screen ${screenId}`);
2007
2014
  return screen.presentations.map((presentation) =>
2008
- selectedDesign(workspace, {
2009
- ...designSelector(args),
2010
- screenId,
2011
- presentationId: presentation.id,
2012
- }),
2015
+ ({ selector: { ...designSelector(args), screenId, presentationId: presentation.id },
2016
+ selection: { screenId, presentationId: presentation.id } }),
2013
2017
  );
2014
2018
  }
2015
2019
 
@@ -3235,6 +3239,13 @@ async function changesCommand(packagePath, args) {
3235
3239
  const revisions = {
3236
3240
  revision: product.revision,
3237
3241
  ...(foundation ? { foundationRevision: foundation.revision } : {}),
3242
+ historyWindow: packages.map((snapshot, index) => ({
3243
+ packageId: snapshot.manifest.packageId,
3244
+ ...BATCH_HISTORY_LIMITS,
3245
+ retainedBatches: histories[index].length,
3246
+ oldestRevision: histories[index][0]?.baseRevision,
3247
+ newestRevision: histories[index].at(-1)?.revision,
3248
+ })),
3238
3249
  };
3239
3250
  if (since === undefined) {
3240
3251
  const limit = integerOption(args, "--limit") || 10;
@@ -3265,6 +3276,7 @@ async function changesCommand(packagePath, args) {
3265
3276
  "unknown_revision",
3266
3277
  "This revision is not in the recorded history; read the current design with view instead",
3267
3278
  {
3279
+ historyWindow: revisions.historyWindow,
3268
3280
  nextOperations: [
3269
3281
  { argv: ["changes", packagePath, "--json"], operation: "smallpen.changes" },
3270
3282
  { argv: ["view", packagePath, "--as", "text", "--json"], operation: "smallpen.view" },
@@ -3570,30 +3582,27 @@ async function validateWholePackage(packagePath, args, workspace, resolution, on
3570
3582
  };
3571
3583
  const issues = [], checked = [], skipped = [], hints = [];
3572
3584
  let unreadable = 0;
3585
+ const jobs = [];
3573
3586
  for (const target of targets) {
3574
3587
  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);
3588
+ jobs.push(...await validationJobs(workspace, ["validate", packagePath, ...target.ids, ...pass]));
3586
3589
  } catch (error) {
3587
3590
  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
- });
3591
+ issues.push({ target: target.where, code: error.code ?? "unreadable_target", severity: "error", check: "read", message: error.message });
3595
3592
  }
3596
3593
  }
3594
+ const outcome = await runChecks(workspace, jobs, args);
3595
+ for (const result of outcome.results) {
3596
+ const report = validationResult(result);
3597
+ for (const issue of report.issues ?? []) {
3598
+ const { target: _target, ...rest } = issue;
3599
+ issues.push({ target: validationLabel(workspace, report.selection), ...rest });
3600
+ }
3601
+ if (result.report) checked.push(report.selection);
3602
+ skipped.push(...(report.coverage?.skipped ?? []).map(skip => ({ ...skip, target: report.selection, label: validationLabel(workspace, report.selection) })));
3603
+ for (const hint of report.hints ?? [])
3604
+ if (!hints.some(other => JSON.stringify(other) === JSON.stringify(hint))) hints.push(hint);
3605
+ }
3597
3606
  // Layout limits belong to the tool, with full diagnostics kept separately
3598
3607
  // from design issues and every skipped layout check counted in coverage.
3599
3608
  const limits = issues.filter((issue) => issue.code === "layout_projection_partial");
@@ -3604,9 +3613,9 @@ async function validateWholePackage(packagePath, args, workspace, resolution, on
3604
3613
  return {
3605
3614
  packageId: product.manifest.packageId,
3606
3615
  ...workspaceRevisions(product, workspace.foundation, workspace.libraries),
3607
- status: unreadable ? "invalid" : "valid",
3616
+ status: outcome.interrupted ? "interrupted" : outcome.results.some(result => result.error) ? "partial" : unreadable ? "invalid" : "valid",
3608
3617
  dataStatus: unreadable ? "invalid" : "valid",
3609
- visualStatus: design.length ? "issues" : "no-issues-found",
3618
+ visualStatus: design.length ? "issues" : outcome.interrupted ? "partial" : "no-issues-found",
3610
3619
  issueCount: design.length,
3611
3620
  issues: full ? design : page.items,
3612
3621
  ...(full ? {} : { page: page.page }),
@@ -3618,7 +3627,8 @@ async function validateWholePackage(packagePath, args, workspace, resolution, on
3618
3627
  selection: { themes: themeSettings(workspace).selection.themes },
3619
3628
  coverage: {
3620
3629
  // Every target drawn and every check run on it.
3621
- complete: unreadable === 0 && notChecked.length === 0,
3630
+ complete: unreadable === 0 && notChecked.length === 0 && !outcome.interrupted,
3631
+ ...executionCoverage(outcome),
3622
3632
  targetCount: checked.length,
3623
3633
  // By name, short: "Home (desktop)", "Button (Style=primary)".
3624
3634
  // Pages by version; components as one line each unless --full.
@@ -3763,48 +3773,63 @@ function projectSummary(snapshot) {
3763
3773
  };
3764
3774
  }
3765
3775
 
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 };
3776
+ function validationLabel(workspace, selection) {
3777
+ const named = selectionNames(workspace, selection);
3778
+ const parts = [named.page ?? named.component, named.platform ?? named.variant].filter(Boolean);
3779
+ return parts.length > 1 ? `${parts[0]} (${parts[1]})` : parts[0] ?? "design";
3780
+ }
3781
+
3782
+ async function runChecks(workspace, jobs, args) {
3783
+ const controller = new AbortController();
3784
+ const interrupt = () => controller.abort();
3785
+ process.once("SIGINT", interrupt);
3786
+ process.once("SIGTERM", interrupt);
3787
+ try {
3788
+ const outcome = await runValidationJobs(workspace, jobs.map(job => ({
3789
+ ...job, label: validationLabel(workspace, job.selection),
3790
+ })), {
3791
+ timeoutMs: Number(option(args, "--target-timeout") ?? 30_000),
3792
+ signal: controller.signal,
3793
+ progress: event => process.stderr.write(JSON.stringify(event) + "\n"),
3783
3794
  });
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);
3795
+ if (outcome.interrupted) process.exitCode = 130;
3796
+ return outcome;
3797
+ } finally {
3798
+ process.removeListener("SIGINT", interrupt);
3799
+ process.removeListener("SIGTERM", interrupt);
3804
3800
  }
3801
+ }
3802
+
3803
+ function executionCoverage(outcome) {
3804
+ return {
3805
+ totalTargetCount: outcome.total,
3806
+ completedTargetCount: outcome.results.length,
3807
+ failedTargetCount: outcome.results.filter(result => result.error).length,
3808
+ remainingCount: outcome.remaining.length,
3809
+ remaining: outcome.remaining,
3810
+ interrupted: outcome.interrupted,
3811
+ };
3812
+ }
3813
+
3814
+ function validationResult({ job, report, error }) {
3815
+ return report ?? {
3816
+ selection: job.selection,
3817
+ issues: [{ ...error, severity: "error", check: "execution" }],
3818
+ coverage: { complete: false, nodeCount: 0, visibleNodeCount: 0, checks: [],
3819
+ skipped: [{ check: "native-layout", reason: error.message, code: error.code, target: job.selection }] },
3820
+ };
3821
+ }
3822
+
3823
+ // Every check of the selected targets, untruncated.
3824
+ async function validateTargets(workspace, args) {
3825
+ const outcome = await runChecks(workspace, await validationJobs(workspace, args), args);
3826
+ const reports = outcome.results.map(validationResult);
3827
+ const empty = { issues: [], coverage: { complete: false, checks: [], skipped: [], nodeCount: 0, visibleNodeCount: 0 } };
3805
3828
  const all = flag(args, "--all");
3806
3829
  const visual = {
3807
- ...reports[0],
3830
+ ...(reports[0] ?? empty),
3831
+ ...(outcome.interrupted ? { status: "interrupted", visualStatus: "partial" }
3832
+ : outcome.results.some(result => result.error) ? { status: "partial", visualStatus: "partial" } : {}),
3808
3833
  ...(reports.some((report) => report.hints?.length) ? { hints: reports.flatMap((report) => report.hints ?? []) } : {}),
3809
3834
  issues: reports.flatMap((report) =>
3810
3835
  report.issues.map((issue) =>
@@ -3812,7 +3837,9 @@ async function validateTargets(workspace, args) {
3812
3837
  ),
3813
3838
  ),
3814
3839
  coverage: {
3815
- ...reports[0].coverage,
3840
+ ...(reports[0]?.coverage ?? empty.coverage),
3841
+ ...executionCoverage(outcome),
3842
+ complete: !outcome.interrupted && reports.every(report => report.coverage.complete),
3816
3843
  targets: reports.map((report) => ({ ...selectionNames(workspace, report.selection), ...report.selection })),
3817
3844
  targetCount: reports.length,
3818
3845
  nodeCount: reports.reduce(
@@ -3859,7 +3886,7 @@ async function validateTargets(workspace, args) {
3859
3886
  ...(option(args, "--component-id")
3860
3887
  ? {
3861
3888
  componentId: option(args, "--component-id"),
3862
- packageId: reports[0].selection.packageId,
3889
+ packageId: reports[0]?.selection.packageId,
3863
3890
  }
3864
3891
  : { screenId: option(args, "--screen-id") }),
3865
3892
  };
@@ -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.15",
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.15",
19
+ "@smallpen/local-package": "0.1.0-alpha.15"
20
20
  },
21
21
  "repository": {
22
22
  "type": "git",