@penpair/core 0.2.0 → 0.3.1

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.
Files changed (125) hide show
  1. package/dist/adapters.js +58 -11
  2. package/dist/approvals.js +143 -29
  3. package/dist/atomic-output.js +9 -4
  4. package/dist/audit-rules.js +345 -79
  5. package/dist/backup-note.js +29 -8
  6. package/dist/baseline-coverage.js +169 -35
  7. package/dist/baselines-config.js +119 -29
  8. package/dist/baselines.js +280 -57
  9. package/dist/cli-arguments.js +18 -6
  10. package/dist/cli-main.js +1645 -392
  11. package/dist/client.d.ts +5 -2
  12. package/dist/client.js +127 -28
  13. package/dist/command-result.js +341 -52
  14. package/dist/coordination.js +91 -29
  15. package/dist/credentials.js +57 -13
  16. package/dist/deep-check.js +56 -12
  17. package/dist/design-spec.js +189 -49
  18. package/dist/endpoint.js +102 -34
  19. package/dist/export.js +86 -18
  20. package/dist/file-archive.js +33 -7
  21. package/dist/file-guard.js +29 -11
  22. package/dist/formats.js +215 -14
  23. package/dist/index.d.ts +8 -7
  24. package/dist/index.js +8 -7
  25. package/dist/inspection.d.ts +1 -1
  26. package/dist/inspection.js +378 -65
  27. package/dist/instance/config-schema.js +400 -41
  28. package/dist/instance/config.js +101 -30
  29. package/dist/instance/connect.js +151 -44
  30. package/dist/instance/connection.d.ts +26 -5
  31. package/dist/instance/connection.js +194 -21
  32. package/dist/instance/gitignore.js +5 -2
  33. package/dist/instance/init.js +32 -7
  34. package/dist/instance/runner.js +32 -10
  35. package/dist/instance/setup.js +46 -13
  36. package/dist/instance/tty.js +26 -8
  37. package/dist/journal.d.ts +10 -0
  38. package/dist/journal.js +212 -49
  39. package/dist/kit-smoke.js +82 -20
  40. package/dist/kit-source.js +10 -40
  41. package/dist/kit.js +102 -34
  42. package/dist/layout-policy.d.ts +2 -0
  43. package/dist/layout-policy.js +86 -0
  44. package/dist/layout.d.ts +7 -1
  45. package/dist/layout.js +27 -70
  46. package/dist/live-smoke.js +13 -4
  47. package/dist/local-profile.js +2 -1
  48. package/dist/mcp-server.js +360 -47
  49. package/dist/native-api.js +47 -11
  50. package/dist/onboard.js +33 -7
  51. package/dist/operation.d.ts +2 -2
  52. package/dist/operation.js +279 -69
  53. package/dist/page-session.d.ts +23 -0
  54. package/dist/page-session.js +142 -0
  55. package/dist/png.js +18 -3
  56. package/dist/portable-tokens.js +254 -57
  57. package/dist/probe.d.ts +1 -1
  58. package/dist/probe.js +48 -11
  59. package/dist/project-input.js +24 -10
  60. package/dist/project-snippets.js +18 -6
  61. package/dist/project-status.js +133 -24
  62. package/dist/registry.js +217 -60
  63. package/dist/run-file.js +129 -30
  64. package/dist/shots.js +108 -26
  65. package/dist/skill-check-entry.d.ts +17 -0
  66. package/dist/skill-check-entry.js +89 -0
  67. package/dist/skill-check-instructions.d.ts +7 -0
  68. package/dist/skill-check-instructions.js +39 -0
  69. package/dist/skill-check.d.ts +15 -0
  70. package/dist/skill-check.js +36 -0
  71. package/dist/skill-file.d.ts +2 -0
  72. package/dist/skill-file.js +31 -0
  73. package/dist/skill-instructions.d.ts +4 -0
  74. package/dist/skill-instructions.js +97 -22
  75. package/dist/skill-metadata.d.ts +8 -0
  76. package/dist/skill-metadata.js +52 -0
  77. package/dist/skill-routing.d.ts +2 -0
  78. package/dist/skill-routing.js +36 -0
  79. package/dist/skills.js +131 -41
  80. package/dist/snippet.js +27 -7
  81. package/dist/snippets/read.js +8 -2
  82. package/dist/structure-snapshot.d.ts +12 -0
  83. package/dist/structure-snapshot.js +439 -85
  84. package/dist/token-edit.js +219 -64
  85. package/dist/token-projection.d.ts +56 -0
  86. package/dist/token-projection.js +286 -0
  87. package/dist/tokens-diff.js +125 -32
  88. package/dist/version.d.ts +1 -1
  89. package/dist/version.js +1 -1
  90. package/package.json +3 -2
  91. package/skills/design-judgment/SKILL.md +1 -1
  92. package/skills/design-judgment/agents/openai.yaml +6 -0
  93. package/skills/penpair-design/SKILL.md +14 -1
  94. package/skills/penpair-design/agents/openai.yaml +6 -0
  95. package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
  96. package/skills/penpair-design/references/project-facts.md +2 -0
  97. package/skills/penpair-handoff/SKILL.md +14 -1
  98. package/skills/penpair-handoff/agents/openai.yaml +6 -0
  99. package/skills/penpair-handoff/references/comparison-companion.md +16 -0
  100. package/skills/penpair-handoff/references/handoff-flow.md +2 -0
  101. package/skills/penpair-recipes/SKILL.md +13 -1
  102. package/skills/penpair-recipes/agents/openai.yaml +6 -0
  103. package/skills/penpair-recipes/assets/visual-checkpoint.md +48 -0
  104. package/skills/penpair-recipes/references/visual-checkpoint.md +37 -0
  105. package/skills/penpair-setup/SKILL.md +9 -3
  106. package/skills/penpair-setup/agents/openai.yaml +6 -0
  107. package/skills/penpair-verify/SKILL.md +49 -0
  108. package/skills/penpair-verify/agents/openai.yaml +6 -0
  109. package/skills/penpair-verify/references/browser-evidence.md +33 -0
  110. package/dist/cli.d.ts +0 -2
  111. package/dist/cli.js +0 -3
  112. package/dist/docker/lifecycle.d.ts +0 -96
  113. package/dist/docker/lifecycle.js +0 -273
  114. package/dist/docker/secrets.d.ts +0 -11
  115. package/dist/docker/secrets.js +0 -38
  116. package/dist/docker/template.d.ts +0 -8
  117. package/dist/docker/template.js +0 -158
  118. package/dist/docker-profile.d.ts +0 -114
  119. package/dist/docker-profile.js +0 -18
  120. package/dist/instance/lifecycle.d.ts +0 -129
  121. package/dist/instance/lifecycle.js +0 -336
  122. package/dist/instance/secrets.d.ts +0 -11
  123. package/dist/instance/secrets.js +0 -38
  124. package/dist/instance/template.d.ts +0 -8
  125. package/dist/instance/template.js +0 -158
@@ -3,61 +3,158 @@ import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { resolveProjectOutput } from "./project-input.js";
5
5
  import { pngSize } from "./png.js";
6
- import { validateBaselines } from "./baselines-config.js";
6
+ import { validateBaselines, } from "./baselines-config.js";
7
7
  import { observationHash } from "./journal.js";
8
- export const designCaseKey = (route, value) => observationHash({ route, viewport: { width: value.viewport.width, height: value.viewport.height }, theme: value.theme, state: value.state, fixture: value.fixture });
8
+ export const designCaseKey = (route, value) => observationHash({
9
+ route,
10
+ viewport: {
11
+ width: value.viewport.width,
12
+ height: value.viewport.height,
13
+ },
14
+ theme: value.theme,
15
+ state: value.state,
16
+ fixture: value.fixture,
17
+ });
9
18
  export function baselineCoverage(config, results = []) {
10
19
  const rows = [];
11
20
  for (const requirement of config.coverage?.required ?? []) {
12
21
  const key = designCaseKey(requirement.route, requirement);
13
- const unsupported = config.coverage?.unsupported?.find(value => designCaseKey(value.route, value) === key);
14
- const frames = config.frames.filter(frame => frame.route && frame.case && designCaseKey(frame.route, frame.case) === key);
15
- const frameIds = frames.map(frame => frame.id);
22
+ const unsupported = config.coverage?.unsupported?.find((value) => designCaseKey(value.route, value) === key);
23
+ const frames = config.frames.filter((frame) => frame.route && frame.case && designCaseKey(frame.route, frame.case) === key);
24
+ const frameIds = frames.map((frame) => frame.id);
16
25
  if (unsupported) {
17
- rows.push({ requirement, status: "unsupported", frameIds, reason: unsupported.reason });
26
+ rows.push({
27
+ requirement,
28
+ status: "unsupported",
29
+ frameIds,
30
+ reason: unsupported.reason,
31
+ });
18
32
  continue;
19
33
  }
20
34
  if (!frames.length) {
21
- rows.push({ requirement, status: "missing-case", frameIds });
35
+ rows.push({
36
+ requirement,
37
+ status: "missing-case",
38
+ frameIds,
39
+ });
22
40
  continue;
23
41
  }
24
- const captures = frames.flatMap(frame => { const found = results.find(result => result.id === frame.id); return found ? [{ frame, result: found }] : []; });
42
+ const captures = frames.flatMap((frame) => {
43
+ const found = results.find((result) => result.id === frame.id);
44
+ return found
45
+ ? [
46
+ {
47
+ frame,
48
+ result: found,
49
+ },
50
+ ]
51
+ : [];
52
+ });
25
53
  if (!captures.length || captures.every(({ result }) => !result.approved)) {
26
- rows.push({ requirement, status: "missing-capture", frameIds });
54
+ rows.push({
55
+ requirement,
56
+ status: "missing-capture",
57
+ frameIds,
58
+ });
27
59
  continue;
28
60
  }
29
61
  const approved = captures.some(({ frame, result }) => {
30
62
  const evidence = result.approved && "case" in result.approved ? result.approved : undefined;
31
- return evidence?.approval?.kind !== "forced-reference" && !["missing", "unstable", "case-mismatch"].includes(result.status) && evidence?.case && observationHash(evidence.case) === observationHash(frame.case) && result.sha256 === evidence.sha256 && evidence.frameId === result.frameId && evidence.file === result.file && (!result.captured || evidence.fileId === result.captured.fileId && evidence.pageId === result.captured.pageId) && evidence.designSize?.width === frame.case.viewport.width && evidence.designSize?.height === frame.case.viewport.height && result.imageWidth === Math.round(frame.case.viewport.width * evidence.scale) && result.imageHeight === Math.round(frame.case.viewport.height * evidence.scale) && (!frame.case.activeThemeIds || evidence.activeThemeIds && observationHash([...frame.case.activeThemeIds].sort()) === observationHash([...evidence.activeThemeIds].sort()));
63
+ return (evidence?.approval?.kind !== "forced-reference" &&
64
+ ![
65
+ "missing",
66
+ "unstable",
67
+ "case-mismatch",
68
+ ].includes(result.status) &&
69
+ evidence?.case &&
70
+ observationHash(evidence.case) === observationHash(frame.case) &&
71
+ result.sha256 === evidence.sha256 &&
72
+ evidence.frameId === result.frameId &&
73
+ evidence.file === result.file &&
74
+ (!result.captured ||
75
+ (evidence.fileId === result.captured.fileId &&
76
+ evidence.pageId === result.captured.pageId)) &&
77
+ evidence.designSize?.width === frame.case.viewport.width &&
78
+ evidence.designSize?.height === frame.case.viewport.height &&
79
+ result.imageWidth === Math.round(frame.case.viewport.width * evidence.scale) &&
80
+ result.imageHeight === Math.round(frame.case.viewport.height * evidence.scale) &&
81
+ (!frame.case.activeThemeIds ||
82
+ (evidence.activeThemeIds &&
83
+ observationHash([
84
+ ...frame.case.activeThemeIds,
85
+ ].sort()) ===
86
+ observationHash([
87
+ ...evidence.activeThemeIds,
88
+ ].sort()))));
89
+ });
90
+ const changed = approved &&
91
+ captures.some(({ result }) => result.status === "changed" &&
92
+ result.captured &&
93
+ result.approved &&
94
+ result.captured.sha256 !== result.approved.sha256);
95
+ rows.push({
96
+ requirement,
97
+ status: changed
98
+ ? "changed-unapproved"
99
+ : approved
100
+ ? "approved-design-intent"
101
+ : "unapproved-metadata",
102
+ frameIds,
103
+ ...(!approved
104
+ ? {
105
+ reason: captures.flatMap(({ result }) => result.problems ?? []).join("; ") ||
106
+ "Approved case/viewport/theme evidence is absent or mismatched",
107
+ }
108
+ : {}),
32
109
  });
33
- const changed = approved && captures.some(({ result }) => result.status === "changed" && result.captured && result.approved && result.captured.sha256 !== result.approved.sha256);
34
- rows.push({ requirement, status: changed ? "changed-unapproved" : approved ? "approved-design-intent" : "unapproved-metadata", frameIds, ...(!approved ? { reason: captures.flatMap(({ result }) => result.problems ?? []).join("; ") || "Approved case/viewport/theme evidence is absent or mismatched" } : {}) });
35
110
  }
36
- const unknown = config.frames.flatMap(frame => {
111
+ const unknown = config.frames.flatMap((frame) => {
37
112
  const fields = [];
38
- if (!frame.case)
113
+ if (!frame.case) {
39
114
  fields.push("viewport", "theme", "state", "fixture", "font environment");
115
+ }
40
116
  else {
41
- if (frame.case.fonts === undefined)
117
+ if (frame.case.fonts === undefined) {
42
118
  fields.push("declared fonts absent");
43
- else if (frame.case.fonts.length)
119
+ }
120
+ else if (frame.case.fonts.length) {
44
121
  fields.push("font loading/version not observed");
45
- if (frame.case.activeThemeIds === undefined)
122
+ }
123
+ if (frame.case.activeThemeIds === undefined) {
46
124
  fields.push("theme activation not asserted; label is design intent");
125
+ }
47
126
  }
48
- const result = results.find(result => result.id === frame.id);
49
- if (frame.case && !result?.captured?.renderEnvironment)
127
+ const result = results.find((result) => result.id === frame.id);
128
+ if (frame.case && !result?.captured?.renderEnvironment) {
50
129
  fields.push("renderer environment not observed");
51
- return fields.length ? [{ id: frame.id, fields }] : [];
130
+ }
131
+ return fields.length
132
+ ? [
133
+ {
134
+ id: frame.id,
135
+ fields,
136
+ },
137
+ ]
138
+ : [];
52
139
  });
53
- return { schemaVersion: 1, kind: "baseline-coverage", configured: Boolean(config.coverage), designCasesComplete: rows.length > 0 && rows.every(row => row.status === "approved-design-intent"), rows, unknown, browserVerification: "not-run", accessibility: "not-assessed" };
140
+ return {
141
+ schemaVersion: 1,
142
+ kind: "baseline-coverage",
143
+ configured: Boolean(config.coverage),
144
+ designCasesComplete: rows.length > 0 && rows.every((row) => row.status === "approved-design-intent"),
145
+ rows,
146
+ unknown,
147
+ browserVerification: "not-run",
148
+ accessibility: "not-assessed",
149
+ };
54
150
  }
55
151
  async function boundedRead(file, maximum) {
56
152
  const handle = await open(file, "r");
57
153
  try {
58
154
  const info = await handle.stat();
59
- if (!info.isFile() || info.size > maximum)
155
+ if (!info.isFile() || info.size > maximum) {
60
156
  throw new Error("Artifact exceeds the coverage read budget or is not a regular file");
157
+ }
61
158
  return await handle.readFile();
62
159
  }
63
160
  finally {
@@ -67,47 +164,84 @@ async function boundedRead(file, maximum) {
67
164
  /** Offline coverage validates the approved image bytes, never only a manifest's claims. */
68
165
  export async function readBaselineCoverage(root, config, expectedFileId) {
69
166
  config = structuredClone(config);
70
- validateBaselines(config, message => { throw new Error(message); });
167
+ validateBaselines(config, (message) => {
168
+ throw new Error(message);
169
+ });
71
170
  const file = await resolveProjectOutput(root, path.join(config.root, "penpair-baselines.json"));
72
171
  let manifest;
73
172
  try {
74
173
  manifest = JSON.parse((await boundedRead(file, 16 * 1024 * 1024)).toString("utf8"));
75
174
  }
76
175
  catch (error) {
77
- if (error.code !== "ENOENT")
176
+ if (error.code !== "ENOENT") {
78
177
  throw error;
178
+ }
79
179
  }
80
- if (!manifest)
180
+ if (!manifest) {
81
181
  return baselineCoverage(config);
82
- if (manifest.schemaVersion !== 2 || !Array.isArray(manifest.frames) || manifest.frames.length > 1000)
182
+ }
183
+ if (manifest.schemaVersion !== 2 ||
184
+ !Array.isArray(manifest.frames) ||
185
+ manifest.frames.length > 1000) {
83
186
  throw new Error("Coverage needs a bounded schema-2 baseline manifest; regenerate a reviewed capture");
84
- if (expectedFileId && manifest.fileId !== expectedFileId)
187
+ }
188
+ if (expectedFileId && manifest.fileId !== expectedFileId) {
85
189
  throw new Error("Coverage manifest belongs to a different pinned file");
190
+ }
86
191
  const ids = new Set();
87
192
  for (const value of manifest.frames) {
88
- if (!value || typeof value.id !== "string" || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(value.id) || ids.has(value.id) || !["new", "same", "changed", "missing", "unstable", "case-mismatch"].includes(value.status) || typeof value.file !== "string" || !value.file)
193
+ if (!value ||
194
+ typeof value.id !== "string" ||
195
+ !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(value.id) ||
196
+ ids.has(value.id) ||
197
+ ![
198
+ "new",
199
+ "same",
200
+ "changed",
201
+ "missing",
202
+ "unstable",
203
+ "case-mismatch",
204
+ ].includes(value.status) ||
205
+ typeof value.file !== "string" ||
206
+ !value.file) {
89
207
  throw new Error("Malformed baseline frame records");
208
+ }
90
209
  ids.add(value.id);
91
210
  }
92
- const results = manifest.frames.map(value => ({ ...value }));
211
+ const results = manifest.frames.map((value) => ({
212
+ ...value,
213
+ }));
93
214
  for (const result of results) {
94
- if (!result.approved)
215
+ if (!result.approved) {
95
216
  continue;
217
+ }
96
218
  try {
97
- if (typeof result.file !== "string" || path.isAbsolute(result.file) || result.file.split(/[\\/]/).includes(".."))
219
+ if (typeof result.file !== "string" ||
220
+ path.isAbsolute(result.file) ||
221
+ result.file.split(/[\\/]/).includes("..")) {
98
222
  throw new Error("Approved image path leaves its baseline folder");
99
- if ("fileId" in result.approved && result.approved.fileId !== manifest.fileId)
223
+ }
224
+ if ("fileId" in result.approved && result.approved.fileId !== manifest.fileId) {
100
225
  throw new Error("Approved image provenance belongs to a different file");
226
+ }
101
227
  const target = await resolveProjectOutput(root, path.join(config.root, result.file));
102
- const bytes = await boundedRead(target, 64 * 1024 * 1024), size = pngSize(bytes);
103
- if (!size || size.width < 1 || size.height < 1 || createHash("sha256").update(bytes).digest("hex") !== result.approved.sha256 || result.sha256 !== result.approved.sha256)
228
+ const bytes = await boundedRead(target, 64 * 1024 * 1024);
229
+ const size = pngSize(bytes);
230
+ if (!size ||
231
+ size.width < 1 ||
232
+ size.height < 1 ||
233
+ createHash("sha256").update(bytes).digest("hex") !== result.approved.sha256 ||
234
+ result.sha256 !== result.approved.sha256) {
104
235
  throw new Error("Approved PNG bytes do not match their manifest");
236
+ }
105
237
  result.imageWidth = size.width;
106
238
  result.imageHeight = size.height;
107
239
  }
108
240
  catch (error) {
109
241
  result.status = "case-mismatch";
110
- result.problems = [error instanceof Error ? error.message : String(error)];
242
+ result.problems = [
243
+ error instanceof Error ? error.message : String(error),
244
+ ];
111
245
  }
112
246
  }
113
247
  return baselineCoverage(config, results);
@@ -1,80 +1,170 @@
1
1
  import path from "node:path";
2
2
  const idPattern = /^[a-z0-9][a-z0-9_-]{0,63}$/;
3
3
  export function validateBaselines(value, fail) {
4
- if (typeof value !== "object" || value === null || Array.isArray(value))
4
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
5
5
  return fail("baselines must be an object");
6
+ }
6
7
  const v = value;
7
- if (typeof v.page !== "string" || v.page.length === 0)
8
+ if (typeof v.page !== "string" || v.page.length === 0) {
8
9
  fail("baselines.page must be the name of a page");
9
- if (typeof v.root !== "string" || v.root.length === 0 || path.isAbsolute(v.root) || v.root.split(/[\\/]/).includes(".."))
10
+ }
11
+ if (typeof v.root !== "string" ||
12
+ v.root.length === 0 ||
13
+ path.isAbsolute(v.root) ||
14
+ v.root.split(/[\\/]/).includes("..")) {
10
15
  fail("baselines.root must be a relative folder that stays inside the project");
11
- if (!Array.isArray(v.frames) || v.frames.length === 0 || v.frames.length > 1000)
16
+ }
17
+ if (!Array.isArray(v.frames) || v.frames.length === 0 || v.frames.length > 1000) {
12
18
  fail("baselines.frames must list at least one frame, at most 1000");
19
+ }
13
20
  const seen = new Set();
14
21
  for (const entry of v.frames) {
15
22
  const f = entry;
16
- if (typeof f !== "object" || f === null)
23
+ if (typeof f !== "object" || f === null) {
17
24
  fail("every entry in baselines.frames must be an object");
18
- if (typeof f.frame !== "string" || f.frame.length === 0)
25
+ }
26
+ if (typeof f.frame !== "string" || f.frame.length === 0) {
19
27
  fail("every baselines.frames entry needs a frame name");
20
- if (typeof f.id !== "string" || !idPattern.test(f.id))
28
+ }
29
+ if (typeof f.id !== "string" || !idPattern.test(f.id)) {
21
30
  fail(`baselines.frames id ${JSON.stringify(f.id)} must be lowercase letters, digits, hyphens or underscores`);
22
- if (seen.has(f.id))
31
+ }
32
+ if (seen.has(f.id)) {
23
33
  fail(`baselines.frames id ${JSON.stringify(f.id)} is used twice`);
34
+ }
24
35
  seen.add(f.id);
25
36
  if (f.case !== undefined) {
26
37
  validateBaselineCase(f.case, fail);
27
- if (typeof f.route !== "string" || !f.route)
38
+ if (typeof f.route !== "string" || !f.route) {
28
39
  fail("baseline case metadata requires an implementation route");
40
+ }
29
41
  }
30
- if (f.route !== undefined && (typeof f.route !== "string" || f.route.length === 0))
42
+ if (f.route !== undefined && (typeof f.route !== "string" || f.route.length === 0)) {
31
43
  fail("baselines.frames route must be a non-empty string");
44
+ }
32
45
  }
33
46
  if (v.coverage !== undefined) {
34
47
  const coverage = v.coverage;
35
- if (!coverage || typeof coverage !== "object" || Array.isArray(coverage) || !Array.isArray(coverage.required) || !coverage.required.length || coverage.required.length > 1000 || coverage.unsupported !== undefined && (!Array.isArray(coverage.unsupported) || coverage.unsupported.length > 1000))
48
+ if (!coverage ||
49
+ typeof coverage !== "object" ||
50
+ Array.isArray(coverage) ||
51
+ !Array.isArray(coverage.required) ||
52
+ !coverage.required.length ||
53
+ coverage.required.length > 1000 ||
54
+ (coverage.unsupported !== undefined &&
55
+ (!Array.isArray(coverage.unsupported) || coverage.unsupported.length > 1000))) {
36
56
  fail("baselines.coverage needs 1–1000 required cases and bounded unsupported cases");
57
+ }
37
58
  const required = new Set();
38
- const key = (value) => JSON.stringify([value.route, value.viewport.width, value.viewport.height, value.theme, value.state, value.fixture]);
59
+ const key = (value) => JSON.stringify([
60
+ value.route,
61
+ value.viewport.width,
62
+ value.viewport.height,
63
+ value.theme,
64
+ value.state,
65
+ value.fixture,
66
+ ]);
39
67
  for (const value of coverage.required) {
40
- validateBaselineCase(value, fail, ["route"]);
41
- if (typeof value.route !== "string" || !value.route)
68
+ validateBaselineCase(value, fail, [
69
+ "route",
70
+ ]);
71
+ if (typeof value.route !== "string" || !value.route) {
42
72
  fail("coverage cases need a route");
43
- if (required.has(key(value)))
73
+ }
74
+ if (required.has(key(value))) {
44
75
  fail("Duplicate required coverage case");
76
+ }
45
77
  required.add(key(value));
46
78
  }
47
79
  const unsupported = new Set();
48
80
  for (const value of coverage.unsupported ?? []) {
49
- validateBaselineCase(value, fail, ["route", "reason"]);
50
- if (typeof value.route !== "string" || !value.route || typeof value.reason !== "string" || !value.reason.trim())
81
+ validateBaselineCase(value, fail, [
82
+ "route",
83
+ "reason",
84
+ ]);
85
+ if (typeof value.route !== "string" ||
86
+ !value.route ||
87
+ typeof value.reason !== "string" ||
88
+ !value.reason.trim()) {
51
89
  fail("unsupported cases need route and reason");
52
- if (!required.has(key(value)) || unsupported.has(key(value)))
90
+ }
91
+ if (!required.has(key(value)) || unsupported.has(key(value))) {
53
92
  fail("unsupported cases must uniquely reference required cases");
93
+ }
54
94
  unsupported.add(key(value));
55
95
  }
56
96
  }
57
- if (v.threshold !== undefined && (typeof v.threshold !== "number" || !Number.isFinite(v.threshold) || v.threshold < 0 || v.threshold > 1))
97
+ if (v.threshold !== undefined &&
98
+ (typeof v.threshold !== "number" ||
99
+ !Number.isFinite(v.threshold) ||
100
+ v.threshold < 0 ||
101
+ v.threshold > 1)) {
58
102
  fail("baselines.threshold must be a number from 0 to 1");
103
+ }
59
104
  return v;
60
105
  }
61
106
  export function validateBaselineCase(value, fail, extraKeys = []) {
62
- if (!value || typeof value !== "object" || Array.isArray(value))
107
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
63
108
  return fail("baseline case must be an object");
109
+ }
64
110
  const entry = value;
65
- const allowed = new Set(["viewport", "theme", "state", "fixture", ...(extraKeys.length ? [] : ["fonts", "activeThemeIds"]), ...extraKeys]);
66
- if (Object.keys(entry).some(key => !allowed.has(key)))
111
+ const allowed = new Set([
112
+ "viewport",
113
+ "theme",
114
+ "state",
115
+ "fixture",
116
+ ...(extraKeys.length
117
+ ? []
118
+ : [
119
+ "fonts",
120
+ "activeThemeIds",
121
+ ]),
122
+ ...extraKeys,
123
+ ]);
124
+ if (Object.keys(entry).some((key) => !allowed.has(key))) {
67
125
  fail("Unknown baseline case field; capture constraints belong on frame cases");
68
- if (entry.viewport && Object.keys(entry.viewport).some(key => !["width", "height"].includes(key)))
126
+ }
127
+ if (entry.viewport &&
128
+ Object.keys(entry.viewport).some((key) => ![
129
+ "width",
130
+ "height",
131
+ ].includes(key))) {
69
132
  fail("Viewport accepts width and height only");
70
- for (const key of ["theme", "state", "fixture"])
71
- if (typeof entry[key] !== "string" || !entry[key].trim() || entry[key].length > 200 || /[\x00-\x1f]/.test(entry[key]))
133
+ }
134
+ for (const key of [
135
+ "theme",
136
+ "state",
137
+ "fixture",
138
+ ]) {
139
+ if (typeof entry[key] !== "string" ||
140
+ !entry[key].trim() ||
141
+ entry[key].length > 200 ||
142
+ /[\x00-\x1f]/.test(entry[key])) {
72
143
  fail(`baseline case ${key} needs a short explicit label`);
73
- for (const key of ["width", "height"])
74
- if (!entry.viewport || !Number.isInteger(entry.viewport[key]) || entry.viewport[key] < 1 || entry.viewport[key] > 16384)
144
+ }
145
+ }
146
+ for (const key of [
147
+ "width",
148
+ "height",
149
+ ]) {
150
+ if (!entry.viewport ||
151
+ !Number.isInteger(entry.viewport[key]) ||
152
+ entry.viewport[key] < 1 ||
153
+ entry.viewport[key] > 16384) {
75
154
  fail("baseline case viewport dimensions must be integers from 1 to 16384");
76
- for (const key of ["fonts", "activeThemeIds"])
77
- if (entry[key] !== undefined && (!Array.isArray(entry[key]) || entry[key].length > 100 || entry[key].some(value => typeof value !== "string" || !value.trim()) || new Set(entry[key]).size !== entry[key].length))
155
+ }
156
+ }
157
+ for (const key of [
158
+ "fonts",
159
+ "activeThemeIds",
160
+ ]) {
161
+ if (entry[key] !== undefined &&
162
+ (!Array.isArray(entry[key]) ||
163
+ entry[key].length > 100 ||
164
+ entry[key].some((value) => typeof value !== "string" || !value.trim()) ||
165
+ new Set(entry[key]).size !== entry[key].length)) {
78
166
  fail(`baseline case ${key} must contain at most 100 unique nonempty strings`);
167
+ }
168
+ }
79
169
  return entry;
80
170
  }