partforge 0.26.1 → 0.28.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.
@@ -0,0 +1,20 @@
1
+ // Finding constructors. The shape mirrors the diagnostics contract that verify's
2
+ // checks already satisfy (docs/AUTHORING-PARTS.md "The diagnostics contract"):
3
+ // a self-contained `hint` on every finding, plus an optional stable ERROR-PATTERNS.md
4
+ // `pattern` id. Verify's [x,y,z] `location` is replaced by `path`, an accessor path
5
+ // into the PartDefinition — nothing parses it, it is for navigation only.
6
+ const make = (severity) => (rule, message, hint, path = "", pattern) => ({
7
+ rule, severity, message, hint, path, ...(pattern ? { pattern } : {}),
8
+ });
9
+
10
+ // error → the part is PROVABLY broken: it cannot behave as authored, whether or
11
+ // not that surfaces as a thrown exception. A dead control (control-key-not-in-
12
+ // defaults), a view that renders nothing (part-view-unknown), or a verify
13
+ // expectation that's silently dropped so its gate never runs (verify-unknown-
14
+ // subpart) build/measure/verify cleanly today and still earn error — the defect
15
+ // is real even though nothing throws. Because `measure` gates on this tier, a
16
+ // part with one of these silent defects now exits non-zero where it previously
17
+ // didn't; that's the point, not a regression.
18
+ export const err = make("error");
19
+ // warning → suspicious or lossy, but the part behaves as authored.
20
+ export const warn = make("warning");
@@ -0,0 +1,114 @@
1
+ // partforge/lint — static PartDefinition validation. Pure: no I/O, no async, and
2
+ // (load-bearing) an import closure that never reaches three / manifold-3d / replicad,
3
+ // so this runs unchanged in Node, a browser sandbox iframe, and Deno. The purity
4
+ // guarantee is enforced by test/lint-purity.test.js — read it before adding an import.
5
+ //
6
+ // A rule is { id, run(ctx) → Finding[] }, one rule object per finding id, so the
7
+ // registry doubles as the documented rule catalog. Rules are cheap and parts are
8
+ // tiny; clarity beats sharing a walk between rules.
9
+ import { resolveDerived } from "../derive.js";
10
+ import { err, warn } from "./finding.js";
11
+ import { SHAPE_RULES } from "./rules-shape.js";
12
+ import { SCHEMA_RULES } from "./rules-schema.js";
13
+ import { runValidatingProbe } from "../geometry/probe.js";
14
+ import { BUILD_RULES } from "./rules-build.js";
15
+ import { VERIFY_RULES, resolveExpect } from "./rules-verify.js";
16
+
17
+ export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES];
18
+
19
+ // Every rule runs inside a guard. lintPart is called on a user-facing hosted path
20
+ // (partforge-cloud's sandbox), and a linter that takes down the preview it exists to
21
+ // protect is worse than no linter — so a throwing rule becomes a WARNING, never an
22
+ // error, and never blocks a part that would otherwise have built.
23
+ export function runRules(rules, ctx) {
24
+ const out = [];
25
+ for (const rule of rules) {
26
+ try {
27
+ const found = rule.run(ctx);
28
+ if (Array.isArray(found)) out.push(...found);
29
+ } catch (e) {
30
+ out.push(warn("internal-rule-error",
31
+ `lint rule "${rule.id}" threw: ${e?.message || String(e)}`,
32
+ "This is a partforge bug rather than a problem with your part; every other rule still ran. Please report it with the part that triggered it."));
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+
38
+ // Build the shared context. A throwing derive() must not abort the lint — the
39
+ // throw is captured as `deriveError` (for Group 3's `derive-throws` rule) and `d`
40
+ // falls back to {}, so Groups 1/2/4 remain useful without derived values.
41
+ //
42
+ // Building `p` can itself throw — `part.defaults` may be a getter that throws, or
43
+ // `part.defaults` / `params` may be a Proxy whose `ownKeys` trap throws (the spread
44
+ // below walks own keys) — so that construction gets the same never-escapes
45
+ // treatment as derive(): fall back to `{}` rather than let a hostile/broken input
46
+ // take down lintContext (and, by extension, lintPart — see its own guard below).
47
+ // The failure is also captured as `pError`, mirroring `deriveError`, so lintPart
48
+ // can still surface it as a real finding instead of silently linting against an
49
+ // empty `{}` params object as if nothing were wrong.
50
+ export function lintContext(part, params) {
51
+ let p;
52
+ let pError = null;
53
+ try { p = { ...(part?.defaults ?? {}), ...(params ?? {}) }; }
54
+ catch (e) { p = {}; pError = e?.message || String(e); }
55
+ let d = {};
56
+ let deriveError = null;
57
+ try { d = resolveDerived(part ?? {}, p) ?? {}; } catch (e) { d = {}; deriveError = e?.message || String(e); }
58
+ let cached = null;
59
+ const probe = () => (cached ??= runValidatingProbe(part, p, d));
60
+ const probeAgain = () => runValidatingProbe(part, p, d);
61
+ // Group 4's several rules all need `verify.expect` resolved; `expect` may be a
62
+ // user-supplied function, so — same reasoning as `probe` above — resolve it once
63
+ // per lint pass and share it, rather than letting each rule invoke it again and
64
+ // risk a cascading double-report from a function that only throws sometimes.
65
+ let cachedExpect = null;
66
+ const resolveExpectOnce = () => (cachedExpect ??= resolveExpect(part?.verify, p, d));
67
+ return { part, p, d, pError, deriveError, probe, probeAgain, resolveExpectOnce };
68
+ }
69
+
70
+ /**
71
+ * Lint a PartDefinition. Never throws.
72
+ * @param {object} part the default-exported PartDefinition
73
+ * @param {{params?: object}} [opts] params layered over part.defaults for the probe pass
74
+ * @returns {{ok: boolean, errors: object[], warnings: object[]}}
75
+ */
76
+ export function lintPart(part, opts) {
77
+ // `opts` is defaulted here, not via `= {}` on the parameter, because a default
78
+ // parameter only fires on `undefined` — a caller passing `lintPart(part, null)`
79
+ // (a plausible downstream-harness call) would otherwise throw destructuring
80
+ // `{ params }` out of `null` before this function's body ever runs.
81
+ const { params } = opts ?? {};
82
+ // lintContext already guards its own internals (see its comment above), but it
83
+ // is user-authored data all the way down — wrap the call itself too, so a
84
+ // failure mode neither of us has thought of still degrades to a report instead
85
+ // of an escaping throw. `lintPart` must never throw; that guarantee is a bigger
86
+ // deal than any one finding.
87
+ let ctx;
88
+ try {
89
+ ctx = lintContext(part, params);
90
+ } catch (e) {
91
+ return {
92
+ ok: false,
93
+ errors: [err("lint-context-error",
94
+ `partforge/lint could not build a lint context: ${e?.message || String(e)}`,
95
+ "This part is too malformed for lint to analyze safely — make sure `defaults`, `params`, and `verify`/`derive` are plain, side-effect-free data rather than throwing getters or hostile Proxies.",
96
+ "")],
97
+ warnings: [],
98
+ };
99
+ }
100
+ const findings = runRules(RULES, ctx);
101
+ // `p` (params merged from `defaults`) failed to build — every rule still ran
102
+ // against the `{}` fallback (each guarded individually by runRules), but the
103
+ // part is provably broken independent of whatever those rules happened to
104
+ // notice, so report it directly rather than relying on incidental fallout.
105
+ if (ctx.pError) {
106
+ findings.push(err("lint-context-error",
107
+ `partforge/lint could not read \`defaults\`/\`params\`: ${ctx.pError}`,
108
+ "Make sure `defaults` and any `params` passed to lintPart are plain, side-effect-free data rather than a throwing getter or a hostile Proxy.",
109
+ ""));
110
+ }
111
+ const errors = findings.filter((f) => f.severity === "error");
112
+ const warnings = findings.filter((f) => f.severity === "warning");
113
+ return { ok: errors.length === 0, errors, warnings };
114
+ }
@@ -0,0 +1,84 @@
1
+ // Group 3 — how the build uses the kernel, discovered by executing build() against
2
+ // the validating probe with no geometry kernel. Every error in this group already
3
+ // throws at runtime; the value is reaching it in microseconds, before a WASM boot.
4
+ import { err, warn } from "./finding.js";
5
+ import { OCCT_ONLY_OPS } from "../geometry/kernel.js";
6
+ import { MAX_PROBE_OPS } from "../geometry/probe.js";
7
+
8
+ const OCCT_ONLY = new Set(OCCT_ONLY_OPS);
9
+ const unique = (xs) => [...new Set(xs)];
10
+
11
+ export const BUILD_RULES = [
12
+ {
13
+ id: "derive-throws",
14
+ run: ({ deriveError }) => (deriveError ? [
15
+ err("derive-throws", `derive(p) threw: ${deriveError}`,
16
+ "derive(p) must return derived values for any parameter set the defaults allow — fix the error so it does not throw. As written, the app is left spinning with no geometry: resolveParams's catch reports the failure but no build ever runs.",
17
+ "derive"),
18
+ ] : []),
19
+ },
20
+ {
21
+ id: "unknown-kernel-op",
22
+ run: ({ probe }) => unique(probe().issues
23
+ .filter((i) => i.kind === "unknown-op" && i.scope === "kernel").map((i) => i.op))
24
+ .map((op) => err("unknown-kernel-op", `\`k.${op}(…)\` is not a kernel operation`,
25
+ `Remove the call or correct the name — see the kernel op table in docs/AUTHORING-PARTS.md for the full list.`,
26
+ "parts")),
27
+ },
28
+ {
29
+ id: "unknown-solid-op",
30
+ run: ({ probe }) => unique(probe().issues
31
+ .filter((i) => i.kind === "unknown-op" && i.scope === "solid").map((i) => i.op))
32
+ .map((op) => err("unknown-solid-op", `\`.${op}(…)\` is not a Solid or Shape2D method`,
33
+ `Remove the call or correct the name — see the Solid and Shape2D method tables in docs/AUTHORING-PARTS.md.`,
34
+ "parts")),
35
+ },
36
+ {
37
+ id: "invalid-op-options",
38
+ run: ({ probe }) => unique(probe().issues
39
+ .filter((i) => i.kind === "invalid-options").map((i) => i.message))
40
+ .map((message) => err("invalid-op-options", message,
41
+ "Correct the options object to match the op's documented keys — the message above names the offending key or the missing required one.",
42
+ "parts")),
43
+ },
44
+ {
45
+ id: "build-throws",
46
+ run: ({ probe }) => probe().throws.map(({ subpart, message }) =>
47
+ err("build-throws", `sub-part "${subpart}" threw during a geometry-free build: ${message}`,
48
+ "Fix the error in build(). This was raised with no kernel attached, so it is a fault in the build's own logic (bad arithmetic, a missing param, a null dereference) rather than a geometry failure.",
49
+ `parts.${subpart}.build`)),
50
+ },
51
+ {
52
+ id: "manifold-backend-uses-occt-op",
53
+ run: ({ part, probe }) => {
54
+ if (part?.meta?.backend !== "manifold") return [];
55
+ return [...probe().used].filter((op) => OCCT_ONLY.has(op))
56
+ .map((op) => err("manifold-backend-uses-occt-op",
57
+ `\`meta.backend\` pins Manifold, but the build calls \`${op}\`, which only OCCT implements`,
58
+ `Remove \`meta.backend: "manifold"\` and let the probe route this part to OCCT, or replace \`${op}\` with a mesh-friendly construction — as written the build throws KernelCapabilityError.`,
59
+ "meta.backend"));
60
+ },
61
+ },
62
+ {
63
+ id: "build-runaway",
64
+ run: ({ probe }) => (probe().runaway ? [
65
+ err("build-runaway", `the build exceeded ${MAX_PROBE_OPS} kernel operations`,
66
+ "A loop in build() is not terminating, or a count parameter is far larger than intended. Bound the loop, or reduce the governing parameter.",
67
+ "parts"),
68
+ ] : []),
69
+ },
70
+ {
71
+ id: "nondeterministic-build",
72
+ run: ({ probe, probeAgain }) => {
73
+ const a = probe();
74
+ if (a.runaway || a.throws.length > 0) return []; // an aborted build can't be compared
75
+ const b = probeAgain();
76
+ if (b.runaway || b.throws.length > 0) return [];
77
+ if (JSON.stringify(a.calls) === JSON.stringify(b.calls)) return [];
78
+ return [warn("nondeterministic-build",
79
+ "two builds with identical parameters produced different kernel calls",
80
+ "build() must be a pure function of (k, p, d). Remove Math.random(), Date/clock reads, and module-level mutable state — the preview kernel memoizes geometry by content hash, so an impure build silently returns stale geometry.",
81
+ "parts", "impure-build-stale-preview")];
82
+ },
83
+ },
84
+ ];
@@ -0,0 +1,209 @@
1
+ // Group 2 — the parameter schema that src/framework/controls.js turns into the panel.
2
+ // Two failure modes drive this group: a shape controls.js reads without guarding
3
+ // (features[].sliders at controls.js:313, an unguarded .filter), and keys that don't
4
+ // resolve against `defaults`, which produce a control that silently does nothing.
5
+ import { err, warn } from "./finding.js";
6
+ import { suggest } from "../geometry/op-options.js";
7
+
8
+ // Fields controls.js reads on a slider/number descriptor.
9
+ const CONTROL_FIELDS = ["key", "label", "unit", "min", "max", "step", "control", "hidden", "description"];
10
+ const FEATURE_FIELDS = ["key", "label", "on", "sliders", "hidden", "description"];
11
+ const TOGGLE_FIELDS = ["key", "label", "on", "hidden", "description"];
12
+
13
+ const sections = (part) => (Array.isArray(part?.parameters) ? part.parameters : []);
14
+ const arr = (x) => (Array.isArray(x) ? x : []);
15
+ const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
16
+
17
+ // Every (descriptor, path, allowed-fields) triple that owns a parameter key, across
18
+ // all four section kinds. A feature's own `sliders` are collected too, since each
19
+ // slider is a full control descriptor in its own right.
20
+ function collectDescriptors(part) {
21
+ const out = [];
22
+ sections(part).forEach((sec, si) => {
23
+ arr(sec?.advanced).forEach((d, i) => {
24
+ if (d) out.push({ d, path: `parameters[${si}].advanced[${i}]`, fields: CONTROL_FIELDS });
25
+ });
26
+ arr(sec?.features).forEach((f, i) => {
27
+ if (!f) return;
28
+ out.push({ d: f, path: `parameters[${si}].features[${i}]`, fields: FEATURE_FIELDS });
29
+ arr(f.sliders).forEach((s, j) => {
30
+ // Tag with the owning feature's key so slider-range-excludes-default can
31
+ // recognise the demo.js flange_d pattern below: a slider sharing its key
32
+ // with the feature is not an independent parameter, it's the feature's own
33
+ // magnitude, and `defaults[key] === 0` there means "off", not "out of range".
34
+ if (s) out.push({ d: s, path: `parameters[${si}].features[${i}].sliders[${j}]`, fields: CONTROL_FIELDS, featureKey: f.key });
35
+ });
36
+ });
37
+ arr(sec?.toggles).forEach((t, i) => {
38
+ if (t) out.push({ d: t, path: `parameters[${si}].toggles[${i}]`, fields: TOGGLE_FIELDS });
39
+ });
40
+ });
41
+ return out;
42
+ }
43
+
44
+ const defaultKeys = (part) => new Set(Object.keys(part?.defaults ?? {}));
45
+
46
+ // Mirrors src/framework/controls.js's own visibility predicates (visibleFeatures /
47
+ // sectionRenders, controls.js:32,36-41) — NOT imported, because controls.js pulls in
48
+ // `marked`/`dompurify` (via markdown.js) for its description popovers, which would
49
+ // break partforge/lint's zero-bare-dependency purity guarantee (test/lint-purity.test.js).
50
+ // `features-requires-sliders` must not flag a `feat.sliders.filter(...)` the panel
51
+ // will never reach: controls.js only iterates `visibleFeatures(sec)` (skipping any
52
+ // feature marked `hidden: true`), and only builds a section at all when
53
+ // `sectionRenders(sec)` is true.
54
+ const visibleFeatures = (sec) => arr(sec?.features).filter((f) => f && !f.hidden);
55
+ function sectionRenders(sec) {
56
+ if (sec?.hidden) return false;
57
+ if (sec?.features) return visibleFeatures(sec).length > 0;
58
+ const hasPresets = sec?.presets && Object.keys(sec.presets).length > 0;
59
+ return !!hasPresets || arr(sec?.advanced).some((d) => d && !d.hidden) || arr(sec?.toggles).some((t) => t && !t.hidden);
60
+ }
61
+
62
+ export const SCHEMA_RULES = [
63
+ {
64
+ id: "features-requires-sliders",
65
+ run: ({ part }) => {
66
+ const out = [];
67
+ sections(part).forEach((sec, si) => {
68
+ // A feature the panel will never reach can't crash it: skip the whole
69
+ // section when it never renders at all (sectionRenders), and skip any
70
+ // individual feature the panel skips via `hidden: true` (visibleFeatures'
71
+ // own filter condition, restated inline rather than built into a Set).
72
+ if (!sectionRenders(sec)) return;
73
+ arr(sec?.features).forEach((f, i) => {
74
+ if (f && !f.hidden && !Array.isArray(f.sliders)) {
75
+ out.push(err("features-requires-sliders",
76
+ `section "${sec.id ?? si}" feature ${i} has no \`sliders\` array`,
77
+ "A `features` entry must carry a `sliders` array — the control panel reads it unguarded, so a missing one throws \"Cannot read properties of undefined (reading 'filter')\". A bare on/off control belongs in `toggles` instead.",
78
+ `parameters[${si}].features[${i}]`,
79
+ "features-missing-sliders"));
80
+ }
81
+ });
82
+ });
83
+ return out;
84
+ },
85
+ },
86
+ {
87
+ id: "control-key-not-in-defaults",
88
+ run: ({ part }) => {
89
+ // Only skip when `defaults` isn't a plain object at all (missing-defaults
90
+ // already reports that) — an explicit `defaults: {}` must still be checked,
91
+ // otherwise every control key in the part is silently unreachable and dead.
92
+ if (!isPlainObject(part?.defaults)) return [];
93
+ const known = defaultKeys(part);
94
+ return collectDescriptors(part)
95
+ .filter(({ d }) => typeof d.key === "string" && !known.has(d.key))
96
+ .map(({ d, path }) => err("control-key-not-in-defaults",
97
+ `control key "${d.key}" is not in \`defaults\``,
98
+ `Add "${d.key}" to \`defaults\`${suggest(d.key, [...known]) ? `, or correct it to "${suggest(d.key, [...known])}"` : ""} — a control whose key is absent from defaults is silently dead and never reaches the build.`,
99
+ `${path}.key`));
100
+ },
101
+ },
102
+ {
103
+ id: "preset-key-not-in-defaults",
104
+ run: ({ part }) => {
105
+ // Same guard as control-key-not-in-defaults: only bail when `defaults` is
106
+ // entirely absent (or not an object), not merely empty.
107
+ if (!isPlainObject(part?.defaults)) return [];
108
+ const known = defaultKeys(part);
109
+ const out = [];
110
+ sections(part).forEach((sec, si) => {
111
+ const presets = sec?.presets;
112
+ if (!presets || typeof presets !== "object") return;
113
+ for (const [name, bundle] of Object.entries(presets)) {
114
+ if (!bundle || typeof bundle !== "object") continue;
115
+ for (const key of Object.keys(bundle)) {
116
+ if (known.has(key)) continue;
117
+ const hint = suggest(key, [...known]);
118
+ out.push(err("preset-key-not-in-defaults",
119
+ `preset "${name}" sets "${key}", which is not in \`defaults\``,
120
+ `Add "${key}" to \`defaults\`${hint ? `, or correct it to "${hint}"` : ""} — a preset field absent from defaults is dropped, so selecting the preset silently does nothing for it.`,
121
+ `parameters[${si}].presets[${JSON.stringify(name)}].${key}`));
122
+ }
123
+ }
124
+ });
125
+ return out;
126
+ },
127
+ },
128
+ {
129
+ id: "slider-range-excludes-default",
130
+ run: ({ part }) => {
131
+ const defaults = part?.defaults ?? {};
132
+ return collectDescriptors(part)
133
+ // A slider that shares its key with the feature that owns it (demo.js's
134
+ // flange_d) is exempt ONLY when the default is actually the feature's
135
+ // off-sentinel: controls.js sets `params[feat.key] = 0` on uncheck (and
136
+ // reads `params[feat.key] > 0` to decide checked state), so `0` there means
137
+ // "off", not "out of range". Matching keys alone isn't enough — a mistyped
138
+ // non-zero "on" default (e.g. 999 against an 8..50 slider) is exactly the
139
+ // authoring mistake this rule exists to catch, and must still warn.
140
+ .filter(({ d, featureKey }) => !(featureKey !== undefined && featureKey === d.key && defaults[d.key] === 0))
141
+ .filter(({ d }) => typeof d.key === "string"
142
+ && typeof defaults[d.key] === "number"
143
+ && (typeof d.min === "number" || typeof d.max === "number")
144
+ && ((typeof d.min === "number" && defaults[d.key] < d.min)
145
+ || (typeof d.max === "number" && defaults[d.key] > d.max)))
146
+ .map(({ d, path }) => warn("slider-range-excludes-default",
147
+ `\`defaults.${d.key}\` is ${defaults[d.key]}, outside this control's range ${d.min ?? "-∞"}..${d.max ?? "∞"}`,
148
+ `Widen the control's min/max or move \`defaults.${d.key}\` inside the range — as it stands the panel clamps the value on first render, so the geometry the user sees is not the geometry the defaults describe.`,
149
+ path));
150
+ },
151
+ },
152
+ {
153
+ id: "unknown-control-field",
154
+ run: ({ part }) => {
155
+ const out = [];
156
+ for (const { d, path, fields } of collectDescriptors(part)) {
157
+ for (const key of Object.keys(d)) {
158
+ if (fields.includes(key)) continue;
159
+ const hint = suggest(key, fields);
160
+ out.push(warn("unknown-control-field",
161
+ `unrecognised control field "${key}"`,
162
+ `The control panel ignores "${key}"${hint ? ` — did you mean "${hint}"?` : ` (recognised: ${fields.join(", ")}).`}`,
163
+ `${path}.${key}`));
164
+ }
165
+ }
166
+ return out;
167
+ },
168
+ },
169
+ {
170
+ id: "duplicate-control-key",
171
+ run: ({ part }) => {
172
+ const seen = new Map();
173
+ const out = [];
174
+ for (const { d, path } of collectDescriptors(part)) {
175
+ if (typeof d.key !== "string") continue;
176
+ // A feature and its own slider legitimately share a key (see demo.js's
177
+ // flange_d), so only flag a repeat that crosses to a different owner path.
178
+ const root = path.replace(/\.sliders\[\d+\]$/, "");
179
+ if (seen.has(d.key) && seen.get(d.key) !== root) {
180
+ out.push(warn("duplicate-control-key",
181
+ `parameter key "${d.key}" is owned by more than one control`,
182
+ `Two controls writing "${d.key}" fight over the same value — rename one, or remove the duplicate.`,
183
+ path));
184
+ } else if (!seen.has(d.key)) {
185
+ seen.set(d.key, root);
186
+ }
187
+ }
188
+ return out;
189
+ },
190
+ },
191
+ {
192
+ id: "default-not-exposed",
193
+ run: ({ part }) => {
194
+ if (sections(part).length === 0) return []; // no panel declared at all — nothing to expose
195
+ const exposed = new Set(collectDescriptors(part).map(({ d }) => d.key).filter(Boolean));
196
+ for (const sec of sections(part)) {
197
+ for (const bundle of Object.values(sec?.presets ?? {})) {
198
+ for (const key of Object.keys(bundle ?? {})) exposed.add(key);
199
+ }
200
+ }
201
+ return Object.keys(part?.defaults ?? {})
202
+ .filter((key) => !exposed.has(key))
203
+ .map((key) => warn("default-not-exposed",
204
+ `\`defaults.${key}\` is not referenced by any control`,
205
+ `Either add a control for "${key}" or leave it as an intentional internal constant — a hidden control (\`hidden: true\`) counts as exposing it, and is the documented way to keep a build-only value out of the panel.`,
206
+ `defaults.${key}`));
207
+ },
208
+ },
209
+ ];
@@ -0,0 +1,86 @@
1
+ // Group 1 — definition shape and view wiring. This group is the shared replacement
2
+ // for partforge-cloud's hand-rolled sandbox validate(), which checks meta.title,
3
+ // defaults and build but not `views`, and had already drifted from the eval runner's
4
+ // separate views check. One source of truth ends that split.
5
+ import { err, warn } from "./finding.js";
6
+
7
+ const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
8
+ const partEntries = (part) => (isPlainObject(part?.parts) ? Object.entries(part.parts) : []);
9
+
10
+ export const SHAPE_RULES = [
11
+ {
12
+ id: "missing-meta-title",
13
+ run: ({ part }) => (typeof part?.meta?.title === "string" && part.meta.title.length > 0 ? [] : [
14
+ err("missing-meta-title", "the part has no `meta.title`",
15
+ "Add a `meta` object with a `title` string — it names the part in the viewer and in export filenames.",
16
+ "meta.title"),
17
+ ]),
18
+ },
19
+ {
20
+ id: "missing-defaults",
21
+ run: ({ part }) => (isPlainObject(part?.defaults) ? [] : [
22
+ err("missing-defaults", "the part has no `defaults` object",
23
+ "Add a `defaults` object giving every parameter its starting value; the control panel and every build read from it.",
24
+ "defaults"),
25
+ ]),
26
+ },
27
+ {
28
+ id: "no-buildable-parts",
29
+ run: ({ part }) => {
30
+ const entries = partEntries(part);
31
+ if (entries.length === 0) {
32
+ return [err("no-buildable-parts", "the part declares no sub-parts in `parts`",
33
+ "Add at least one entry to `parts`, each with a `build(k, p, d)` function returning a solid.",
34
+ "parts")];
35
+ }
36
+ return entries
37
+ .filter(([, sp]) => typeof sp?.build !== "function")
38
+ .map(([name]) => err("no-buildable-parts", `sub-part "${name}" has no \`build\` function`,
39
+ "Every entry in `parts` needs a `build(k, p, d)` function that returns a solid.",
40
+ `parts.${name}.build`));
41
+ },
42
+ },
43
+ {
44
+ id: "missing-views",
45
+ run: ({ part }) => (isPlainObject(part?.views) && Object.keys(part.views).length > 0 ? [] : [
46
+ err("missing-views", "the part has no `views` map",
47
+ "Add a top-level `views` object — e.g. `views: { main: { label: \"Main\" } }` — and list each view name in the owning sub-part's `views` array.",
48
+ "views"),
49
+ ]),
50
+ },
51
+ {
52
+ id: "part-view-unknown",
53
+ run: ({ part }) => {
54
+ const known = isPlainObject(part?.views) ? new Set(Object.keys(part.views)) : new Set();
55
+ if (known.size === 0) return []; // missing-views already reported it; don't pile on
56
+ const out = [];
57
+ for (const [name, sp] of partEntries(part)) {
58
+ if (!Array.isArray(sp?.views)) continue;
59
+ sp.views.forEach((v, i) => {
60
+ if (!known.has(v)) {
61
+ out.push(err("part-view-unknown",
62
+ `sub-part "${name}" lists view "${v}", which is not in the \`views\` map`,
63
+ `Add "${v}" to the top-level \`views\` map, or correct the name to one of: ${[...known].join(", ")}.`,
64
+ `parts.${name}.views[${i}]`));
65
+ }
66
+ });
67
+ }
68
+ return out;
69
+ },
70
+ },
71
+ {
72
+ id: "view-unused",
73
+ run: ({ part }) => {
74
+ if (!isPlainObject(part?.views)) return [];
75
+ const used = new Set();
76
+ for (const [, sp] of partEntries(part)) {
77
+ if (Array.isArray(sp?.views)) for (const v of sp.views) used.add(v);
78
+ }
79
+ return Object.keys(part.views)
80
+ .filter((v) => !used.has(v))
81
+ .map((v) => warn("view-unused", `view "${v}" is not listed by any sub-part`,
82
+ `Either add "${v}" to a sub-part's \`views\` array or remove it from the \`views\` map — as it stands the view renders empty.`,
83
+ `views.${v}`));
84
+ },
85
+ },
86
+ ];