partforge 0.26.1 → 0.27.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,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
+ ];
@@ -0,0 +1,288 @@
1
+ // Group 4 — the verify block's own well-formedness. Each condition here currently
2
+ // throws from verify() mid-run, AFTER measure has printed and the kernel has booted,
3
+ // which is also the documented reason CLI stdout isn't pure JSON in that case.
4
+ // Catching them statically removes both the wasted boot and the stdout caveat.
5
+ import { err } from "./finding.js";
6
+ import { SUBPART_METRICS, VIEW_METRICS } from "../verify-metrics.js";
7
+ import { PROFILES } from "../../testing/dfm-profiles.js";
8
+ import { parseAssertion } from "../../testing/assert-dsl.js";
9
+ import { suggest } from "../geometry/op-options.js";
10
+
11
+ // Resolve `expect` to a plain object. The function form (p, d) => ({…}) is invoked
12
+ // once with the probe's params so per-preset topology can be linted like any other.
13
+ // Returns { expect, threw }.
14
+ //
15
+ // Exported so `lintContext` (index.js) can memoize a single call per lint pass and
16
+ // share it across every Group 4 rule below — `expect` is user-supplied code, and
17
+ // without memoization a function-form `expect` that throws only on its first call
18
+ // would fire both `verify-expect-throws` (first call) and whatever rule calls it
19
+ // next (second call succeeds), a cascading double-report.
20
+ export function resolveExpect(verify, p, d) {
21
+ if (typeof verify?.expect !== "function") return { expect: verify?.expect, threw: null };
22
+ try { return { expect: verify.expect(p, d), threw: null }; }
23
+ catch (e) { return { expect: null, threw: e?.message || String(e) }; }
24
+ }
25
+
26
+ const isExpectation = (v) => v !== null && typeof v === "object" && !Array.isArray(v) && "expr" in v;
27
+ const exprOf = (v) => (isExpectation(v) ? v.expr : v);
28
+
29
+ // `contacts` (must-touch pairs) and `clearance` (free-fit gaps) live under
30
+ // `verify.expect._view` but are pair-wise checks, not scalar VIEW_METRICS — mirror
31
+ // verify.js's own peel (verify.js:143) so they never hit the scalar-metric /
32
+ // assertion-expression checks below. Validated for real by `verify-bad-pair-check`.
33
+ const peelPairKeys = (metrics) => {
34
+ const { contacts, clearance, ...rest } = metrics;
35
+ return rest;
36
+ };
37
+
38
+ // `JSON.stringify` can itself throw (BigInt, circular refs) — lintPart must never
39
+ // throw, so fall back to `String` for anything that won't serialize.
40
+ const describe = (v) => { try { return JSON.stringify(v); } catch { return String(v); } };
41
+
42
+ // `suggest` calls `.toLowerCase()` on the key; guard against non-string pair names
43
+ // (a runtime `contacts`/`clearance` entry can contain anything).
44
+ const safeSuggest = (key, valid) => (typeof key === "string" ? suggest(key, valid) : null);
45
+
46
+ // `requirePair` (verify.js:41) throws before it even looks at `part.parts` when a
47
+ // pair names the same sub-part twice — a pair describes a relationship between two
48
+ // distinct sub-parts, so self-pairs are never legal.
49
+ function checkSameName(a, b, path) {
50
+ return a === b
51
+ ? [err("verify-bad-pair-check",
52
+ `\`${path}\` names the same sub-part twice ("${a}")`,
53
+ "A pair must name two different sub-parts. Change one side to the other sub-part it should be checked against, or remove the entry if it doesn't describe a real relationship.",
54
+ path)]
55
+ : [];
56
+ }
57
+
58
+ // Both `contacts` pairs and `clearance` keys ultimately name two sub-parts; a name
59
+ // absent from `part.parts` is what the runtime `requirePair` throws for.
60
+ function checkPairNames(a, b, names, path) {
61
+ const out = [];
62
+ for (const n of [a, b]) {
63
+ if (names.includes(n)) continue;
64
+ const hint = safeSuggest(n, names);
65
+ out.push(err("verify-unknown-subpart",
66
+ `\`${path}\` references "${n}", which is not a sub-part`,
67
+ `Use one of the sub-part names (${names.join(", ")})${hint ? ` — did you mean "${hint}"?` : "."}`,
68
+ path));
69
+ }
70
+ return out;
71
+ }
72
+
73
+ // Static analogue of verify.js's `pairGapChecks` contacts handling (verify.js:61-66).
74
+ function checkContacts(contacts, names, path) {
75
+ if (contacts === undefined || contacts === null) return [];
76
+ if (!Array.isArray(contacts)) {
77
+ return [err("verify-bad-pair-check",
78
+ `\`${path}\` must be an array of ["a", "b"] pairs, got ${describe(contacts)}`,
79
+ `Set \`contacts\` to an array of two-element sub-part name pairs, e.g. \`contacts: [["lid", "body"]]\`.`,
80
+ path)];
81
+ }
82
+ const out = [];
83
+ contacts.forEach((pair, i) => {
84
+ const pairPath = `${path}[${i}]`;
85
+ if (!Array.isArray(pair) || pair.length !== 2) {
86
+ out.push(err("verify-bad-pair-check",
87
+ `\`${pairPath}\` must be an ["a", "b"] pair, got ${describe(pair)}`,
88
+ `Each \`contacts\` entry must be a two-element array naming the two sub-parts that should touch, e.g. \`["lid", "body"]\`.`,
89
+ pairPath));
90
+ return;
91
+ }
92
+ out.push(...checkSameName(pair[0], pair[1], pairPath));
93
+ // `requirePair` (verify.js:41) checks the same-name case first and throws
94
+ // before ever looking at `part.parts` — so a same-name pair never reaches the
95
+ // unknown-name check at runtime either. Skip it here too, or an unknown
96
+ // self-paired name would double-report (once per identical position).
97
+ if (pair[0] !== pair[1]) out.push(...checkPairNames(pair[0], pair[1], names, pairPath));
98
+ });
99
+ return out;
100
+ }
101
+
102
+ // Static analogue of verify.js's `pairGapChecks` clearance handling (verify.js:85-87).
103
+ // Note the separator is the multiplication sign "×" (U+00D7), not the letter x.
104
+ function checkClearance(clearance, names, path) {
105
+ if (clearance === undefined || clearance === null || typeof clearance !== "object") return [];
106
+ const out = [];
107
+ for (const key of Object.keys(clearance)) {
108
+ const pairPath = `${path}[${JSON.stringify(key)}]`;
109
+ const pair = key.split("×").map((s) => s.trim());
110
+ if (pair.length !== 2 || !pair[0] || !pair[1]) {
111
+ out.push(err("verify-bad-pair-check",
112
+ `\`${pairPath}\` key must be "a×b" (sub-part names joined by the multiplication sign ×), got ${JSON.stringify(key)}`,
113
+ `Rename the key to \`"a×b"\` using the two sub-part names that should have a declared clearance, e.g. \`"lid×body"\`. The separator is U+00D7 (×), not the letter x.`,
114
+ pairPath));
115
+ continue;
116
+ }
117
+ out.push(...checkSameName(pair[0], pair[1], pairPath));
118
+ // Same short-circuit as `checkContacts` above: a same-name pair never reaches
119
+ // the unknown-name check at runtime (or here), so it can't double-report.
120
+ if (pair[0] !== pair[1]) out.push(...checkPairNames(pair[0], pair[1], names, pairPath));
121
+ }
122
+ return out;
123
+ }
124
+
125
+ // `clearance`'s values genuinely are assertion expressions run through the DSL
126
+ // (verify.js:96), unlike `contacts` (pair arrays with nothing to parse). Mirror
127
+ // verify.js's own normalization — a bare value or an `{ expr, hint }` wrapper,
128
+ // via `normalizeExpectation` (verify.js:91) — using the same `isExpectation` /
129
+ // `exprOf` helpers the scalar-metric loop below uses. Reported under
130
+ // `verify-bad-expr`, not `verify-bad-pair-check`: a malformed key is a pair-naming
131
+ // problem, but a malformed value is an assertion-DSL problem, same as any other
132
+ // metric's expectation.
133
+ function checkClearanceExprs(clearance, path) {
134
+ if (clearance === undefined || clearance === null || typeof clearance !== "object") return [];
135
+ const out = [];
136
+ for (const [key, spec] of Object.entries(clearance)) {
137
+ try { parseAssertion(exprOf(spec)); }
138
+ catch (e) {
139
+ out.push(err("verify-bad-expr",
140
+ `the expectation for _view.clearance["${key}"] is not a valid assertion: ${e?.message || String(e)}`,
141
+ "Use the assertion DSL: a bare value for equality, a comparison like `>=3`, a range like `2..5`, or a componentwise vector like `<=[60,60,60]` (with `*` to skip an axis).",
142
+ `${path}[${JSON.stringify(key)}]`));
143
+ }
144
+ }
145
+ return out;
146
+ }
147
+
148
+ // Static walk of `resolveProfile`'s (dfm-profiles.js) `base` chain, mirroring its
149
+ // exact throw conditions at every level: a string not in `PROFILES` ("unknown
150
+ // process profile"), or — recursively — a bad `base` nested inside a `base`
151
+ // object. A falsy `base` resolves to `{}` at runtime (`spec.base ? … : {}`) and
152
+ // is never reached, so it's left alone here too. `seen` records every `base`
153
+ // value already visited so a self-referential or cyclic chain (`base.base ===
154
+ // base`) cannot recurse forever — `lintPart` must always terminate. `depth` is a
155
+ // second, cheap belt-and-suspenders cap for the same reason.
156
+ function checkProcessSpec(spec, path, valid, seen, depth = 0) {
157
+ if (depth > 50) return []; // pathological chain — bail rather than hang
158
+ if (typeof spec === "string") {
159
+ if (valid.includes(spec)) return [];
160
+ const hint = suggest(spec, valid);
161
+ return [err("verify-unknown-process",
162
+ `\`${path}\` names "${spec}", which is not a known DFM profile`,
163
+ `Use one of: ${valid.join(", ")}${hint ? ` — did you mean "${hint}"?` : ""}, or pass an inline profile object such as \`{ bed: [220, 220, 250], minWall: 1.2 }\`.`,
164
+ path)];
165
+ }
166
+ if (spec && typeof spec === "object") {
167
+ if (!spec.base) return []; // no base (or a falsy one) — nothing further to resolve
168
+ if (seen.has(spec.base)) return []; // already visited — self-referential/cyclic, stop
169
+ seen.add(spec.base);
170
+ return checkProcessSpec(spec.base, `${path}.base`, valid, seen, depth + 1);
171
+ }
172
+ // Anything else truthy (number, boolean, …) is what resolveProfile's final
173
+ // branch throws "invalid process profile" for.
174
+ return [err("verify-unknown-process",
175
+ `\`${path}\` is not a valid DFM profile: ${describe(spec)}`,
176
+ `Use one of: ${valid.join(", ")}, or an inline profile object such as \`{ bed: [220, 220, 250], minWall: 1.2 }\`.`,
177
+ path)];
178
+ }
179
+
180
+ export const VERIFY_RULES = [
181
+ {
182
+ id: "verify-expect-throws",
183
+ run: ({ resolveExpectOnce }) => {
184
+ const { threw } = resolveExpectOnce();
185
+ return threw ? [err("verify-expect-throws",
186
+ `\`verify.expect(p, d)\` threw: ${threw}`,
187
+ "The function form of `expect` must return an expectation object for any parameter set. Guard whatever it reads, or switch to the static object form.",
188
+ "verify.expect")] : [];
189
+ },
190
+ },
191
+ {
192
+ id: "verify-unknown-subpart",
193
+ run: ({ part, resolveExpectOnce }) => {
194
+ const { expect } = resolveExpectOnce();
195
+ if (!expect || typeof expect !== "object") return [];
196
+ const names = Object.keys(part?.parts ?? {});
197
+ return Object.keys(expect)
198
+ .filter((key) => key !== "_view" && !names.includes(key))
199
+ .map((key) => {
200
+ const hint = suggest(key, names);
201
+ return err("verify-unknown-subpart",
202
+ `\`verify.expect\` targets "${key}", which is not a sub-part`,
203
+ `Use one of the sub-part names (${names.join(", ")}) or the literal \`_view\` for whole-assembly metrics${hint ? ` — did you mean "${hint}"?` : "."}`,
204
+ `verify.expect.${key}`);
205
+ });
206
+ },
207
+ },
208
+ {
209
+ id: "verify-unknown-metric",
210
+ run: ({ part, resolveExpectOnce }) => {
211
+ const { expect } = resolveExpectOnce();
212
+ if (!expect || typeof expect !== "object") return [];
213
+ const names = Object.keys(part?.parts ?? {});
214
+ const out = [];
215
+ for (const [target, metricsRaw] of Object.entries(expect)) {
216
+ if (target !== "_view" && !names.includes(target)) continue; // reported by verify-unknown-subpart
217
+ if (!metricsRaw || typeof metricsRaw !== "object") continue;
218
+ // contacts/clearance are pair checks, not scalar view metrics — validated
219
+ // separately by verify-bad-pair-check, and excluded here.
220
+ const metrics = target === "_view" ? peelPairKeys(metricsRaw) : metricsRaw;
221
+ const registry = target === "_view" ? VIEW_METRICS : SUBPART_METRICS;
222
+ const valid = Object.keys(registry);
223
+ for (const metric of Object.keys(metrics)) {
224
+ if (valid.includes(metric)) continue;
225
+ const hint = suggest(metric, valid);
226
+ out.push(err("verify-unknown-metric",
227
+ `"${metric}" is not a ${target === "_view" ? "view" : "sub-part"} metric`,
228
+ `Valid ${target === "_view" ? "view" : "sub-part"} metrics are: ${valid.join(", ")}${hint ? ` — did you mean "${hint}"?` : "."}`,
229
+ `verify.expect.${target}.${metric}`));
230
+ }
231
+ }
232
+ return out;
233
+ },
234
+ },
235
+ {
236
+ id: "verify-bad-expr",
237
+ run: ({ resolveExpectOnce }) => {
238
+ const { expect } = resolveExpectOnce();
239
+ if (!expect || typeof expect !== "object") return [];
240
+ const out = [];
241
+ for (const [target, metricsRaw] of Object.entries(expect)) {
242
+ if (!metricsRaw || typeof metricsRaw !== "object") continue;
243
+ // `contacts` is pair arrays with no expression to parse, and `clearance`'s
244
+ // values are keyed by pair name rather than metric name — both are peeled
245
+ // from this scalar-metric loop. `clearance`'s values are still assertions
246
+ // though, so they get their own pass (with a pair-shaped path) below.
247
+ const metrics = target === "_view" ? peelPairKeys(metricsRaw) : metricsRaw;
248
+ for (const [metric, spec] of Object.entries(metrics)) {
249
+ try { parseAssertion(exprOf(spec)); }
250
+ catch (e) {
251
+ out.push(err("verify-bad-expr",
252
+ `the expectation for ${target}.${metric} is not a valid assertion: ${e?.message || String(e)}`,
253
+ "Use the assertion DSL: a bare value for equality, a comparison like `>=3`, a range like `2..5`, or a componentwise vector like `<=[60,60,60]` (with `*` to skip an axis).",
254
+ `verify.expect.${target}.${metric}`));
255
+ }
256
+ }
257
+ }
258
+ out.push(...checkClearanceExprs(expect?._view?.clearance, "verify.expect._view.clearance"));
259
+ return out;
260
+ },
261
+ },
262
+ {
263
+ id: "verify-bad-pair-check",
264
+ run: ({ part, resolveExpectOnce }) => {
265
+ const { expect } = resolveExpectOnce();
266
+ const view = expect?._view;
267
+ if (!view || typeof view !== "object") return [];
268
+ const names = Object.keys(part?.parts ?? {});
269
+ return [
270
+ ...checkContacts(view.contacts, names, "verify.expect._view.contacts"),
271
+ ...checkClearance(view.clearance, names, "verify.expect._view.clearance"),
272
+ ];
273
+ },
274
+ },
275
+ {
276
+ id: "verify-unknown-process",
277
+ run: ({ part }) => {
278
+ const process = part?.verify?.process;
279
+ // verify.js:163-164 — `profileSpec ?? part.verify?.process` then
280
+ // `profileSpec ? resolveProfile(profileSpec) : null` — that second check is
281
+ // truthiness, not a null/undefined check, so ANY falsy `process` (undefined,
282
+ // null, "", 0, false) never reaches `resolveProfile` and can't throw.
283
+ if (!process) return [];
284
+ const valid = Object.keys(PROFILES);
285
+ return checkProcessSpec(process, "verify.process", valid, new Set());
286
+ },
287
+ },
288
+ ];