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 +57 -2
- package/docs/AUTHORING-PARTS.md +113 -7
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +2 -1
- package/skills/partforge/SKILL.md +7 -1
- package/src/framework/geometry/kernel-front.js +10 -1
- package/src/framework/geometry/op-options.js +3 -1
- package/src/framework/geometry/probe.js +122 -23
- package/src/framework/lint/finding.js +20 -0
- package/src/framework/lint/index.js +114 -0
- package/src/framework/lint/rules-build.js +84 -0
- package/src/framework/lint/rules-schema.js +209 -0
- package/src/framework/lint/rules-shape.js +86 -0
- package/src/framework/lint/rules-verify.js +288 -0
- package/src/framework/verify-metrics.js +51 -0
- package/src/framework/worker.js +9 -0
- package/src/lint.js +3 -0
- package/src/testing/verify.js +4 -45
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);
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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.
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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,
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
33
|
-
// `then` (so it's never await-unwrapped),
|
|
34
|
-
// undefined rather than a chainable op.
|
|
35
|
-
|
|
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
|
-
|
|
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 (...
|
|
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
|
+
}
|