partforge 0.26.0 → 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.
package/bin/cli.js CHANGED
@@ -15,9 +15,10 @@ import { verify } from "../src/testing/verify.js";
15
15
  import { renderViews } from "../src/testing/render.js";
16
16
  import { createPickServer, requestPicks, formatPickResult } from "../src/framework/pick-request/server.js";
17
17
  import { matchPattern } from "../src/testing/error-patterns.js";
18
+ import { lintPart } from "../src/lint.js";
18
19
 
19
20
  const die = (msg) => { console.error(msg); process.exit(1); };
20
- const USAGE = "usage: partforge <measure|render|pick-serve|pick> …";
21
+ const USAGE = "usage: partforge <lint|measure|render|pick-serve|pick> …";
21
22
 
22
23
  // Crash contract (issue #27): with --json, a thrown error becomes structured
23
24
  // stdout JSON; either way the message is matched against ERROR-PATTERNS.md and
@@ -59,16 +60,57 @@ async function loadPart(partPath, usage) {
59
60
  const bootKernel = (part) => (detectBackend(part) === "occt" ? bootOcctKernel() : bootManifoldKernel());
60
61
 
61
62
  const commands = {
63
+ async lint(args) {
64
+ const usage = "usage: partforge lint <part-module> [--params <json>] [--json] [--out <file>] [--strict]";
65
+ const { values: flags, positionals: [partPath] } = parse(args, {
66
+ params: { type: "string" },
67
+ json: { type: "boolean" },
68
+ out: { type: "string" },
69
+ strict: { type: "boolean" },
70
+ }, usage);
71
+ try {
72
+ const part = await loadPart(partPath, usage);
73
+ const params = flags.params ? JSON.parse(flags.params) : undefined;
74
+ const report = lintPart(part, { params });
75
+ if (!flags.json) printLint(report);
76
+ if (flags.out) {
77
+ mkdirSync(dirname(resolve(flags.out)), { recursive: true });
78
+ writeFileSync(flags.out, JSON.stringify(report, null, 2));
79
+ console.log(`\nwrote ${flags.out}`);
80
+ }
81
+ if (flags.json) console.log(JSON.stringify(report, null, 2));
82
+ process.exit(report.ok && (!flags.strict || report.warnings.length === 0) ? 0 : 1);
83
+ } catch (e) {
84
+ crash("lint", e, !!flags.json);
85
+ }
86
+ },
87
+
62
88
  async measure(args) {
63
- const usage = "usage: partforge measure <part-module> [view] [--process <profile>] [--no-verify] [--json] [--out <file>]";
89
+ const usage = "usage: partforge measure <part-module> [view] [--process <profile>] [--no-verify] [--no-lint] [--json] [--out <file>]";
64
90
  const { values: flags, positionals: [partPath, view] } = parse(args, {
65
91
  process: { type: "string" },
66
92
  "no-verify": { type: "boolean" },
93
+ "no-lint": { type: "boolean" },
67
94
  json: { type: "boolean" },
68
95
  out: { type: "string" },
69
96
  }, usage);
70
97
  try {
71
98
  const part = await loadPart(partPath, usage);
99
+ // Error-tier lint before the kernel boots: a statically broken part fails in
100
+ // milliseconds with a precise message rather than after a WASM boot and a
101
+ // downstream error that doesn't name the cause. Warnings never gate measure.
102
+ if (!flags["no-lint"]) {
103
+ const lint = lintPart(part);
104
+ if (!lint.ok) {
105
+ if (flags.json) console.log(JSON.stringify({ ok: false, lint }, null, 2));
106
+ else printLint(lint);
107
+ if (flags.out) {
108
+ mkdirSync(dirname(resolve(flags.out)), { recursive: true });
109
+ writeFileSync(flags.out, JSON.stringify({ ok: false, lint }, null, 2));
110
+ }
111
+ process.exit(1);
112
+ }
113
+ }
72
114
  const kernel = await bootKernel(part);
73
115
  const report = measure(kernel, part, view);
74
116
  printMeasure(report);
@@ -172,6 +214,19 @@ function printVerify(v) {
172
214
  console.log(` result: ${f ? `${f} gate failure(s)` : "all gates passed"}${w ? `, ${w} warning(s)` : ""}`);
173
215
  }
174
216
 
217
+ function printLint(r) {
218
+ const all = [...r.errors, ...r.warnings];
219
+ if (all.length === 0) { console.log("lint: clean"); return; }
220
+ console.log("lint:");
221
+ for (const f of all) {
222
+ console.log(` ${f.severity === "error" ? "✗" : "⚠"} ${f.rule}${f.path ? ` ${f.path}` : ""}`);
223
+ console.log(` ${f.message}`);
224
+ console.log(` hint: ${f.hint}${f.pattern ? ` (ERROR-PATTERNS.md#${f.pattern})` : ""}`);
225
+ }
226
+ const e = r.errors.length, w = r.warnings.length;
227
+ console.log(` result: ${e ? `${e} error(s)` : "no errors"}${w ? `, ${w} warning(s)` : ""}`);
228
+ }
229
+
175
230
  const [, , cmd, ...args] = process.argv;
176
231
  if (!commands[cmd]) die(USAGE);
177
232
  await commands[cmd](args);
@@ -390,6 +390,28 @@ choosing a preset updates both numeric and text fields.
390
390
  Every `key` used must exist in `defaults`. `src/parts/demo.js` is the worked example for
391
391
  everything below.
392
392
 
393
+ **Standalone toggles** (a plain on/off checkbox, no accompanying sliders): add a
394
+ `toggles` array to a preset section — shown below the preset picker, outside the
395
+ Advanced fold, so it stays visible:
396
+
397
+ ```js
398
+ {
399
+ id: "shape",
400
+ title: "Shape ops",
401
+ toggles: [
402
+ { key: "clip", label: "Clip arms to a disc (intersect)", on: 1,
403
+ description: "**Intersect** the cross with a circle so the four arm tips are rounded off to a common radius." },
404
+ ],
405
+ }
406
+ ```
407
+
408
+ Each entry is `{ key, label, on?, hidden?, description? }`: checked sets `key` to `on`
409
+ (default `1`); unchecked sets it to `0`. This is the correct home for a bare boolean —
410
+ a `features` entry *requires* a `sliders` array (the panel reads `feat.sliders.filter(...)`
411
+ unguarded and throws if it's missing), so a feature with nothing to reveal belongs in
412
+ `toggles` instead. `src/parts/bracket.js`'s `clip` toggle (shown above) is the worked
413
+ example.
414
+
393
415
  **Control metadata (optional — on any control def, feature, or section):**
394
416
 
395
417
  - `description` — a CommonMark string shown in a click-open **ⓘ** popover beside the
@@ -785,6 +807,83 @@ The `measure` function is also exported for vitest (boot a Manifold kernel as in
785
807
  expect(r.subparts[0].holes).toBe(1); // e.g. expects one bore
786
808
  });
787
809
 
810
+ ## Linting
811
+
812
+ `partforge lint` statically validates a PartDefinition without booting a geometry
813
+ kernel. It runs in milliseconds and catches the authoring mistakes that otherwise
814
+ surface only at runtime — or, worse, not at all.
815
+
816
+ ```bash
817
+ npx partforge lint src/parts/<part>.js [--params '{"h":40}'] [--json] [--out f] [--strict]
818
+ ```
819
+
820
+ Exit 0 when clean, 1 when any **error** finding is present; `--strict` also fails on
821
+ warnings. `partforge measure` runs the error tier automatically before booting a
822
+ kernel — pass `--no-lint` to skip it.
823
+
824
+ The same check is available programmatically and in the browser:
825
+
826
+ ```js
827
+ import { lintPart } from "partforge/lint";
828
+ const { ok, errors, warnings } = lintPart(part, { params });
829
+ ```
830
+
831
+ `partforge/lint` has **zero runtime dependencies** and never imports a geometry
832
+ kernel or the DOM viewer, so it runs unchanged in Node, a Web Worker, a sandboxed
833
+ iframe, and Deno. A worker also answers `{ type: "lint", params }` with
834
+ `{ type: "lint-report", report }` without booting its kernel.
835
+
836
+ **Findings** carry the same guarantees as verify's checks — a self-contained `hint`
837
+ on every one, and a stable `pattern` id where an ERROR-PATTERNS.md entry applies:
838
+
839
+ ```js
840
+ { rule: "features-requires-sliders", severity: "error",
841
+ message: "section \"flange\" feature 0 has no `sliders` array",
842
+ hint: "A `features` entry must carry a `sliders` array …",
843
+ path: "parameters[1].features[0]", pattern: "features-missing-sliders" }
844
+ ```
845
+
846
+ `path` is a JS accessor path rooted at the PartDefinition — `parameters[1].features[0]`,
847
+ `defaults.bore`, `parts.spacer.views[0]`, `parameters[0].presets["M3"].od`. Findings
848
+ about the definition as a whole use `""`.
849
+
850
+ **Severity.** A finding is an `error` when the part is *provably broken* — it cannot
851
+ behave as authored — whether or not that shows up as a thrown exception. Some error
852
+ findings do correspond to a runtime throw (`build-throws`, `verify-expect-throws`),
853
+ but others catch **silent** wrongness: `missing-meta-title`, `part-view-unknown`,
854
+ `control-key-not-in-defaults`, `preset-key-not-in-defaults`, and
855
+ `verify-unknown-subpart` all fire on parts that build, measure, and verify cleanly —
856
+ a dead control that's silently unreachable, a view that renders nothing, or a
857
+ `verify` expectation that's silently dropped so its gate never runs. That's still an
858
+ error: the part doesn't do what its author wrote, the failure is just quiet instead
859
+ of loud. Everything speculative or stylistic — lossy but not broken — is a `warning`
860
+ and never blocks anything. Because `measure` runs the error tier as a gate (see
861
+ below), a part with one of these silent defects now exits non-zero where it
862
+ previously didn't; that's the fix working as intended, not a regression.
863
+
864
+ ### Rule catalog
865
+
866
+ **Definition shape** — `missing-meta-title`, `missing-defaults`, `no-buildable-parts`,
867
+ `missing-views`, `part-view-unknown` (all errors); `view-unused` (warning).
868
+
869
+ **Parameter schema** — `features-requires-sliders`, `control-key-not-in-defaults`,
870
+ `preset-key-not-in-defaults` (errors); `slider-range-excludes-default`,
871
+ `unknown-control-field`, `duplicate-control-key`, `default-not-exposed` (warnings).
872
+
873
+ **Kernel API**, found by executing `build()` against a geometry-free probe —
874
+ `unknown-kernel-op`, `unknown-solid-op`, `invalid-op-options`, `build-throws`,
875
+ `derive-throws`, `manifold-backend-uses-occt-op`, `build-runaway` (errors);
876
+ `nondeterministic-build` (warning, from diffing two probe runs).
877
+
878
+ **Verify block** — `verify-unknown-metric`, `verify-unknown-subpart`,
879
+ `verify-bad-expr`, `verify-bad-pair-check`, `verify-unknown-process`,
880
+ `verify-expect-throws` (all errors). Note `_view` also accepts the pair-wise
881
+ `contacts` / `clearance` keys, which are not scalar view metrics; they are
882
+ validated by `verify-bad-pair-check`, matching `verify.js`'s own handling.
883
+
884
+ A rule that itself throws yields an `internal-rule-error` **warning** and the run
885
+ continues: `lintPart` never throws and never blocks a part because of a linter bug.
886
+
788
887
  ### The diagnostics contract (for agents)
789
888
 
790
889
  `partforge measure <part> --json` / `--out <file>` emits the machine-readable
@@ -817,13 +916,20 @@ JSON to stdout and exits 1:
817
916
  ```
818
917
 
819
918
  `pattern`/`hint` appear when the message matches an ERROR-PATTERNS.md symptom
820
- string. Exit codes: 0 pass, 1 gate failure or crash — unchanged. Caveat: a throw
821
- *after* measure output has printed (e.g. an unknown metric in `verify.expect`, or
822
- a per-case build crash) appends this JSON after the human lines, so stdout is no
823
- longer pure JSON; prefer `--out` (or parse the trailing JSON object the crash
824
- JSON is pretty-printed across multiple lines) for robust machine parsing. With
825
- `--out` the measure report is written to the file as soon as `measure` succeeds,
826
- so even if a later `verify` throw crashes the run the file is there — it just
919
+ string. Exit codes: 0 pass, 1 gate failure or crash — unchanged. `measure`'s
920
+ automatic lint pass (see "Linting" above) now catches most of the defects that
921
+ used to surface this way statically, before the kernel boots, so they fail with
922
+ pure JSON up front instead. The caveat narrows but doesn't disappear: lint
923
+ resolves `verify.expect` once against the part's *defaults*, while `verify()`
924
+ itself expands every `verify.cases` entry and re-resolves `expect(p, d)` per
925
+ case so an expectation that only names a bad metric/subpart for a non-default
926
+ case (see `test/fixtures/unknown-metric-in-case-part.js`) still passes lint
927
+ clean and then throws at runtime, after measure output has printed. That throw
928
+ appends crash JSON after the human lines, so stdout is no longer pure JSON;
929
+ prefer `--out` (or parse the trailing JSON object — the crash JSON is
930
+ pretty-printed across multiple lines) for robust machine parsing. With `--out`
931
+ the measure report is written to the file as soon as `measure` succeeds, so
932
+ even if a later `verify` throw crashes the run the file is there — it just
827
933
  lacks the `verify` key.
828
934
 
829
935
  **Fresh-evidence rule.** A passing report is evidence only for the source, parameters,
@@ -115,6 +115,12 @@ The framework itself rebuilds each sub-part fresh per job and applies `place` on
115
115
  - **Cause:** A `key` used in the `parameters` schema (slider, feature, or preset override) doesn't exist in `defaults` — every key must, including `hidden` ones.
116
116
  - **Fix:** Add the key to `defaults` with a sensible starting value. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Parameters: the control-panel schema".
117
117
 
118
+ ## features-missing-sliders
119
+
120
+ - **Symptom:** `Cannot read properties of undefined (reading 'filter')` thrown from the control panel while the app boots, with no geometry ever rendering.
121
+ - **Cause:** A `features` entry in the parameter schema has no `sliders` array — `controls.js` reads `feat.sliders.filter(...)` unguarded. A bare on/off control was put in `features` instead of `toggles`.
122
+ - **Fix:** Move a bare boolean to the section's `toggles` array (`{ key, label, on }`), or give the `features` entry the `sliders` array it requires. `npx partforge lint <part>` catches this statically as `features-requires-sliders`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Parameters: the control-panel schema".
123
+
118
124
  ## dimmed-control-vestigial-param
119
125
 
120
126
  - **Symptom:** A control renders dimmed (but still editable) and changing it does nothing on screen.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@
25
25
  ".": "./src/index.js",
26
26
  "./worker": "./src/framework/worker.js",
27
27
  "./geometry": "./src/framework/geometry/polygon.js",
28
+ "./lint": "./src/lint.js",
28
29
  "./derive": "./src/framework/derive.js",
29
30
  "./testing": "./src/testing.js",
30
31
  "./tokens.css": "./src/framework/tokens.css"
@@ -60,4 +60,10 @@ Picks come back **in request order**, each echoing its prompt, so you can map th
60
60
  ## Related: debugging failures
61
61
 
62
62
  If anything fails while you're editing a part, grep `docs/ERROR-PATTERNS.md` for the
63
- symptom first — its preamble states the full grep-first rule.
63
+ symptom first — its preamble states the full grep-first rule. Before assuming a user's
64
+ click is needed at all, run the static linter — it's instant, needs no live app, and
65
+ catches schema/build mistakes no pick session would explain:
66
+
67
+ ```bash
68
+ partforge lint src/parts/<part>.js
69
+ ```
@@ -12,7 +12,16 @@
12
12
  // capability (Manifold can't do toSTEP; both backends now define shape2d, so
13
13
  // that stub is dead in practice — kept as a safety net for a future backend).
14
14
  // The per-Solid twin of this layer is addSugar() in solid-sugar.js.
15
- import * as opentype from "opentype.js";
15
+ // opentype.js 2.x ships no `exports` map: bundlers take its `module` field (real
16
+ // ESM, named `parse`), while Node ESM takes `main` (a UMD/CJS bundle, whose named
17
+ // exports Node cannot statically detect — the namespace holds only `default`).
18
+ // So `import * as opentype` gives a working `.parse` in the browser and `undefined`
19
+ // under Node, which broke every headless text2d build (`opentype.parse is not a
20
+ // function`) while the browser stayed green. Normalize both interop shapes here.
21
+ import * as opentypeNamespace from "opentype.js";
22
+ const opentype = typeof opentypeNamespace.parse === "function"
23
+ ? opentypeNamespace
24
+ : (opentypeNamespace.default ?? opentypeNamespace);
16
25
  import { KernelCapabilityError } from "./errors.js";
17
26
  import { isPlainOptions, KERNEL_OP_SPECS } from "./op-options.js";
18
27
  import { textGlyphs } from "./text2d.js";
@@ -28,7 +28,9 @@ function editDistance(a, b) {
28
28
  // Prefix match first so long-form names hit their short key (radius→r,
29
29
  // height→h, diameter→d), then edit distance ≤ 2 for plain typos. A digit
30
30
  // suffix is peeled and re-attached so radius1 hints r1, not r.
31
- function suggest(key, valid) {
31
+ // Exported so partforge/lint's `unknown-control-field` rule reuses this exact
32
+ // suggester rather than carrying a second copy of the edit-distance logic.
33
+ export function suggest(key, valid) {
32
34
  const lk = key.toLowerCase();
33
35
  const m = /^([a-z]+)(\d+)$/.exec(lk);
34
36
  if (m) for (const v of valid) if (m[1].startsWith(v.toLowerCase()) && valid.includes(v + m[2])) return v + m[2];
@@ -1,22 +1,44 @@
1
- // Geometry-free backend detection. A probe kernel records every op a part's
2
- // build() invokes (returning chainable no-op proxies, dummy values for queries);
3
- // if an OCCT-only op was used, the part needs the OCCT backend. The op list lives
4
- // in kernel.js the same list generates the Manifold backend's throwing stubs.
5
- import { OCCT_ONLY_OPS } from "./kernel.js";
1
+ // Geometry-free build execution. Two consumers share one Proxy implementation:
2
+ //
3
+ // createProbeKernel() records op NAMES so detectBackend() can route a part to
4
+ // OCCT when it uses fillet/chamfer/shell.
5
+ // • createValidatingProbe() additionally checks op names against the kernel
6
+ // contract's op lists and routes options-form calls through the same op-options
7
+ // normalizers the real backends use, so partforge/lint can catch a bad call in
8
+ // microseconds instead of after a WASM boot.
9
+ //
10
+ // Catch-all proxies (rather than a hand-listed allowlist) mean new kernel/solid
11
+ // methods never have to be mirrored here — the probe can't drift out of sync with
12
+ // the real backends. (That drift previously broke the panel's relevance dimming
13
+ // when the build-step vocabulary was added but not taught to the probe.) The
14
+ // validating probe DOES need an allowlist, so it takes one from kernel.js's op
15
+ // lists, which test/kernel-contract.test.js pins to both backend implementations.
16
+ import {
17
+ OCCT_ONLY_OPS, KERNEL_OPS, KERNEL_OPTIONAL_OPS,
18
+ SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS,
19
+ } from "./kernel.js";
20
+ import { KERNEL_OP_SPECS, SOLID_OP_SPECS, isPlainOptions } from "./op-options.js";
6
21
  import { resolveDerived } from "../derive.js";
7
22
 
8
23
  const OCCT_ONLY = new Set(OCCT_ONLY_OPS);
9
24
 
10
- export function createProbeKernel() {
11
- const used = new Set();
12
- const note = (name) => used.add(name);
25
+ // The probe returns ONE chainable handle for every non-query op, so it cannot tell a
26
+ // Solid from a Shape2D — k.box() and k.shape2d() yield the same object. The solid-scope
27
+ // allowlist is therefore the union of all three surfaces: deliberately permissive, so
28
+ // it never false-positives on an error-severity rule.
29
+ const KERNEL_ALLOWED = new Set([...KERNEL_OPS, ...KERNEL_OPTIONAL_OPS]);
30
+ const SOLID_ALLOWED = new Set([...SOLID_OPS, ...SOLID_OPTIONAL_OPS, ...SHAPE2D_OPS]);
31
+
32
+ export const MAX_PROBE_OPS = 100000;
33
+
34
+ // Thrown to unwind a runaway build. Never escapes runValidatingProbe.
35
+ export class ProbeRunawayError extends Error {
36
+ constructor(message) { super(message); this.name = "ProbeRunawayError"; }
37
+ }
13
38
 
14
- // Catch-all proxies: any method records its name and returns the chainable solid
15
- // proxy, EXCEPT the queries below, which return realistic dummy values the build may
16
- // read. Using a Proxy (rather than a hand-listed allowlist) means new kernel/solid
17
- // methods never have to be mirrored here — the probe can't drift out of sync with the
18
- // real backends. (That drift previously broke the panel's relevance dimming/hiding when
19
- // the build-step vocabulary was added but not taught to the probe.)
39
+ // Shared proxy construction. `onCall(scope, op, args)` observes every op; queries
40
+ // return realistic dummy values the build may read.
41
+ function makeProbe(onCall) {
20
42
  const solidQueries = {
21
43
  boundingBox: () => ({ min: [0, 0, 0], max: [1, 1, 1], center: [0.5, 0.5, 0.5], size: [1, 1, 1] }),
22
44
  volume: () => 1,
@@ -29,24 +51,101 @@ export function createProbeKernel() {
29
51
  cleanup: () => {},
30
52
  };
31
53
 
32
- // `ignore` keeps the proxy from masquerading as a thenable/internal handle: symbols,
33
- // `then` (so it's never await-unwrapped), and `_`-prefixed internals resolve to
34
- // undefined rather than a chainable op.
35
- const ignore = (key) => typeof key !== "string" || key === "then" || key[0] === "_";
54
+ // `ignore` keeps the proxy from masquerading as a thenable/internal/serializable
55
+ // handle: symbols, `then` (so it's never await-unwrapped), `_`-prefixed internals,
56
+ // and `toJSON` all resolve to undefined rather than a chainable op. `toJSON` matters
57
+ // because a handle nested inside an options object (the normal calling convention,
58
+ // e.g. `k.extrude({ profile: someShape, h: 5 })`) isn't caught by the `describe()`
59
+ // identity check below — that only sees the top-level options object, not the
60
+ // nested handle — so `JSON.stringify` walks into it and probes for `toJSON` per the
61
+ // spec. Without this, that probe would be recorded as a real op and then flagged as
62
+ // an unknown one.
63
+ const ignore = (key) => typeof key !== "string" || key === "then" || key === "toJSON" || key[0] === "_";
36
64
 
37
- const opProxy = (queries) => new Proxy({}, {
65
+ // A query (boundingBox, volume, toMesh, …) must count against `onCall`'s ceiling
66
+ // exactly like any other op — returning `queries[key]` directly here used to let
67
+ // every query bypass the counter entirely, so a query-only loop (`for(;;)
68
+ // s.volume()`) never tripped MAX_PROBE_OPS and hung forever. Wrap it the same
69
+ // way as the chaining branch below: observe the call, then run the real query.
70
+ const opProxy = (queries, scope) => new Proxy({}, {
38
71
  get(_t, key) {
39
72
  if (ignore(key)) return undefined;
40
- if (key in queries) return queries[key];
41
- return (..._args) => { note(key); return proxy; };
73
+ if (key in queries) return (...args) => { onCall(scope, key, args); return queries[key](...args); };
74
+ return (...args) => { onCall(scope, key, args); return proxy; };
42
75
  },
43
76
  });
44
77
 
45
- const proxy = opProxy(solidQueries); // a solid handle: every op chains back to itself
46
- const kernel = opProxy(kernelQueries); // factory ops (cylinder/box/prism/…) return a solid
78
+ const proxy = opProxy(solidQueries, "solid"); // a solid handle: every op chains back to itself
79
+ const kernel = opProxy(kernelQueries, "kernel"); // factory ops (cylinder/box/prism/…) return a solid
80
+ return { kernel, proxy };
81
+ }
82
+
83
+ export function createProbeKernel() {
84
+ const used = new Set();
85
+ const { kernel } = makeProbe((_scope, key) => used.add(key));
47
86
  return { kernel, used };
48
87
  }
49
88
 
89
+ export function createValidatingProbe({ maxOps = MAX_PROBE_OPS } = {}) {
90
+ const calls = [];
91
+ const issues = [];
92
+ const used = new Set();
93
+ let count = 0;
94
+ let solidProxy = null;
95
+
96
+ // Args are recorded as strings so two probe runs can be compared for determinism.
97
+ // The chainable handle is a single shared object, so identity is enough to spot it —
98
+ // and checking identity FIRST matters, because JSON.stringify would trip its traps.
99
+ const describe = (a) => {
100
+ if (a === solidProxy) return "<solid>";
101
+ if (typeof a === "function") return "<fn>";
102
+ try { return JSON.stringify(a) ?? String(a); } catch { return "<unserializable>"; }
103
+ };
104
+
105
+ const onCall = (scope, op, args) => {
106
+ if (++count > maxOps) throw new ProbeRunawayError(`build exceeded ${maxOps} kernel operations`);
107
+ used.add(op);
108
+ const allowed = scope === "kernel" ? KERNEL_ALLOWED : SOLID_ALLOWED;
109
+ if (!allowed.has(op)) issues.push({ kind: "unknown-op", scope, op });
110
+ // Validate ONLY the options form — the normative rule (KERNEL-CONTRACT.md
111
+ // "Calling convention") is that a call is options form when it receives exactly
112
+ // one plain-object argument. Legacy positional calls have no options contract to
113
+ // check against. We run `toArgs` (key + required validation) but never the spec's
114
+ // separate `check` hook: `check` inspects real geometry (revolve's calls
115
+ // boundingBox() on its profile), which is meaningless against a proxy.
116
+ const specs = scope === "kernel" ? KERNEL_OP_SPECS : SOLID_OP_SPECS;
117
+ if (specs[op] && args.length === 1 && isPlainOptions(args[0])) {
118
+ try { specs[op].toArgs(args[0]); }
119
+ catch (e) { issues.push({ kind: "invalid-options", scope, op, message: e?.message || String(e) }); }
120
+ }
121
+ calls.push({ scope, op, args: args.map(describe) });
122
+ };
123
+
124
+ const { kernel, proxy } = makeProbe(onCall);
125
+ solidProxy = proxy;
126
+ return { kernel, calls, issues, used };
127
+ }
128
+
129
+ /**
130
+ * Execute every sub-part's build() against a validating probe.
131
+ * Never throws: a build error becomes an entry in `throws`, a runaway sets `runaway`.
132
+ */
133
+ export function runValidatingProbe(part, p, d, { maxOps = MAX_PROBE_OPS } = {}) {
134
+ const probe = createValidatingProbe({ maxOps });
135
+ const throws = [];
136
+ let runaway = false;
137
+ for (const [name, sp] of Object.entries(part?.parts ?? {})) {
138
+ if (typeof sp?.build !== "function") continue; // no-buildable-parts already reports this
139
+ try {
140
+ sp.build(probe.kernel, p, d);
141
+ } catch (e) {
142
+ if (e instanceof ProbeRunawayError) { runaway = true; break; }
143
+ throws.push({ subpart: name, message: e?.message || String(e) });
144
+ }
145
+ }
146
+ return { calls: probe.calls, issues: probe.issues, used: probe.used, throws, runaway };
147
+ }
148
+
50
149
  export function detectBackend(part, params = {}) {
51
150
  if (part.meta?.backend) return part.meta.backend;
52
151
  const p = { ...part.defaults, ...params };
@@ -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
+ }