partforge 0.76.0 → 0.78.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 +150 -6
- package/docs/AUTHORING-PARTS.md +196 -0
- package/docs/ERROR-PATTERNS.md +42 -0
- package/package.json +1 -1
- package/src/framework/jobs.js +31 -0
- package/src/framework/lint/index.js +37 -3
- package/src/framework/lint/rules-schema.js +39 -11
- package/src/framework/lint/rules-source.js +108 -0
- package/src/framework/lint/source-scan.js +333 -0
- package/src/framework/oracle/describe/accept.js +188 -0
- package/src/framework/oracle/describe/features/dressups.js +173 -0
- package/src/framework/oracle/describe/features/holes.js +129 -0
- package/src/framework/oracle/describe/features/prismatic.js +454 -0
- package/src/framework/oracle/describe/features/sweeps.js +233 -0
- package/src/framework/oracle/describe/fit.js +535 -0
- package/src/framework/oracle/describe/hints.js +91 -0
- package/src/framework/oracle/describe/limits.js +19 -0
- package/src/framework/oracle/describe/patterns.js +494 -0
- package/src/framework/oracle/describe/ransac.js +391 -0
- package/src/framework/oracle/describe/report.js +217 -0
- package/src/framework/oracle/describe/segment.js +498 -0
- package/src/framework/oracle/describe/snap.js +83 -0
- package/src/framework/oracle/describe/surface-graph.js +396 -0
- package/src/framework/oracle/describe/topology.js +121 -0
- package/src/framework/oracle/describe.js +538 -0
- package/src/lint.js +5 -0
- package/src/testing.js +5 -0
- package/types/lint.d.ts +44 -2
- package/types/testing.d.ts +178 -0
package/bin/cli.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
8
|
import { pathToFileURL } from "node:url";
|
|
9
|
-
import { resolve, dirname } from "node:path";
|
|
10
|
-
import { writeFileSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { resolve, dirname, basename } from "node:path";
|
|
10
|
+
import { writeFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
11
11
|
import { detectBackend } from "../src/framework/backend-select.js";
|
|
12
12
|
import { fontsFor } from "../src/framework/fonts.js";
|
|
13
13
|
import { viewAnimations, evaluate, cueAt } from "../src/framework/animation.js";
|
|
@@ -15,6 +15,8 @@ import { bootOcctKernel } from "../src/testing/occt.js";
|
|
|
15
15
|
import { bootManifoldKernel } from "../src/testing/manifold.js";
|
|
16
16
|
import { measure } from "../src/framework/oracle/measure.js";
|
|
17
17
|
import { verify } from "../src/framework/oracle/verify.js";
|
|
18
|
+
import { describe as describeMesh } from "../src/framework/oracle/describe.js";
|
|
19
|
+
import { compactDescribe } from "../src/framework/oracle/describe/report.js";
|
|
18
20
|
import { renderViews } from "../src/testing/render.js";
|
|
19
21
|
import {
|
|
20
22
|
createPickServer, requestPicks, formatPickResult,
|
|
@@ -25,7 +27,7 @@ import { matchPattern } from "../src/testing/error-patterns.js";
|
|
|
25
27
|
import { lintPart } from "../src/lint.js";
|
|
26
28
|
|
|
27
29
|
const die = (msg) => { console.error(msg); process.exit(1); };
|
|
28
|
-
const USAGE = "usage: partforge <lint|measure|render|pick-serve|pick> …";
|
|
30
|
+
const USAGE = "usage: partforge <lint|measure|render|describe|pick-serve|pick> …";
|
|
29
31
|
|
|
30
32
|
// Crash contract (issue #27): with --json, a thrown error becomes structured
|
|
31
33
|
// stdout JSON; either way the message is matched against ERROR-PATTERNS.md and
|
|
@@ -77,6 +79,21 @@ async function loadPart(partPath, usage) {
|
|
|
77
79
|
return part;
|
|
78
80
|
}
|
|
79
81
|
|
|
82
|
+
// The source rules (group 9) read the part's TEXT, which `loadPart` never sees —
|
|
83
|
+
// it imports the module, and evaluation is exactly what erases the defects those
|
|
84
|
+
// rules exist for (`13 / 3` is just a number by then). The entry module's own file
|
|
85
|
+
// is all we hand over: following relative imports is a deliberate non-goal, and a
|
|
86
|
+
// missing/unreadable file simply leaves `sources` off, which turns the group into
|
|
87
|
+
// a no-op rather than failing the command.
|
|
88
|
+
const readSources = (partPath) => {
|
|
89
|
+
try {
|
|
90
|
+
const path = basename(partPath);
|
|
91
|
+
return { files: { [path]: readFileSync(resolve(process.cwd(), partPath), "utf8") }, entrypoint: path };
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
80
97
|
// Pass the part's declared fonts through, mirroring the worker path (jobs.js) —
|
|
81
98
|
// otherwise a part using a named font builds in the browser but dies headlessly
|
|
82
99
|
// with `text2d: unknown font …`. A function-form `fonts` is resolved against
|
|
@@ -102,7 +119,8 @@ const commands = {
|
|
|
102
119
|
try {
|
|
103
120
|
const part = await loadPart(partPath, usage);
|
|
104
121
|
const params = flags.params ? JSON.parse(flags.params) : undefined;
|
|
105
|
-
const
|
|
122
|
+
const sources = readSources(partPath);
|
|
123
|
+
const report = lintPart(part, { params, sources });
|
|
106
124
|
if (!flags.json) printLint(report);
|
|
107
125
|
if (flags.out) {
|
|
108
126
|
mkdirSync(dirname(resolve(flags.out)), { recursive: true });
|
|
@@ -131,7 +149,7 @@ const commands = {
|
|
|
131
149
|
// milliseconds with a precise message rather than after a WASM boot and a
|
|
132
150
|
// downstream error that doesn't name the cause. Warnings never gate measure.
|
|
133
151
|
if (!flags["no-lint"]) {
|
|
134
|
-
const lint = lintPart(part);
|
|
152
|
+
const lint = lintPart(part, { sources: readSources(partPath) });
|
|
135
153
|
if (!lint.ok) {
|
|
136
154
|
if (flags.json) console.log(JSON.stringify({ ok: false, lint }, null, 2));
|
|
137
155
|
else printLint(lint);
|
|
@@ -170,6 +188,51 @@ const commands = {
|
|
|
170
188
|
}
|
|
171
189
|
},
|
|
172
190
|
|
|
191
|
+
async describe(args) {
|
|
192
|
+
const usage = "usage: partforge describe <part-module#importName> [--surfaces] [--json] [--budget N] [--out <file>]";
|
|
193
|
+
const { values: flags, positionals: [target] } = parse(args, {
|
|
194
|
+
surfaces: { type: "boolean" },
|
|
195
|
+
json: { type: "boolean" },
|
|
196
|
+
budget: { type: "string" },
|
|
197
|
+
out: { type: "string" },
|
|
198
|
+
}, usage);
|
|
199
|
+
if (!target) die(usage);
|
|
200
|
+
// `part.js#importName` — describe reads a FILE, and a file only reaches the kernel
|
|
201
|
+
// through a part's `imports` declaration, so the part is how we find it. A bare mesh
|
|
202
|
+
// path is deliberately not accepted in v1: it would need its own resolver, its own
|
|
203
|
+
// format sniffing, and its own unit assumptions, all of which the import pipeline
|
|
204
|
+
// already owns.
|
|
205
|
+
const [partPath, importName] = target.split("#");
|
|
206
|
+
if (!importName) die(`describe needs an import name: <part-module>#<importName>\n${usage}`);
|
|
207
|
+
try {
|
|
208
|
+
const part = await loadPart(partPath, usage);
|
|
209
|
+
if (!part.imports?.[importName]) {
|
|
210
|
+
die(`describe: "${importName}" is not a declared import of ${partPath} ` +
|
|
211
|
+
`(have: ${Object.keys(part.imports ?? {}).join(", ") || "none"})`);
|
|
212
|
+
}
|
|
213
|
+
const kernel = await bootKernel(part);
|
|
214
|
+
const solid = kernel.import(importName);
|
|
215
|
+
const report = describeMesh(kernel, solid, {
|
|
216
|
+
name: importName,
|
|
217
|
+
digest: kernel._importDigest?.(importName) ?? null,
|
|
218
|
+
budget: flags.budget ? Number(flags.budget) : undefined,
|
|
219
|
+
});
|
|
220
|
+
if (flags.out) {
|
|
221
|
+
mkdirSync(dirname(resolve(flags.out)), { recursive: true });
|
|
222
|
+
writeFileSync(flags.out, JSON.stringify(report, null, 2));
|
|
223
|
+
}
|
|
224
|
+
if (flags.json) console.log(JSON.stringify(report, null, 2));
|
|
225
|
+
else printDescribe(report, { surfaces: !!flags.surfaces });
|
|
226
|
+
if (flags.out) console.log(`\nwrote ${flags.out}`);
|
|
227
|
+
// A closed-set error exits non-zero; LOW COVERAGE does not. Coverage is a finding
|
|
228
|
+
// the caller must be able to read, and an exit code that conflated the two would
|
|
229
|
+
// train an agent to discard exactly the reports it most needs to look at.
|
|
230
|
+
process.exit(report.error ? 1 : 0);
|
|
231
|
+
} catch (e) {
|
|
232
|
+
crash("describe", e, !!flags.json);
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
|
|
173
236
|
async render(args) {
|
|
174
237
|
const usage = "usage: partforge render <part-module> [view] [--views iso,front] [--out <dir>] " +
|
|
175
238
|
"[--params <json>] [--animation <name>] [--at <t[,t…]>] [--step <index|label>]";
|
|
@@ -379,13 +442,94 @@ function printVerify(v) {
|
|
|
379
442
|
console.log(` result: ${f ? `${f} gate failure(s)` : "all gates passed"}${w ? `, ${w} warning(s)` : ""}`);
|
|
380
443
|
}
|
|
381
444
|
|
|
445
|
+
// Human/agent-readable summary. Features, patterns, symmetry, score, residual — the
|
|
446
|
+
// compact shape (spec §3.4), because a 24k-triangle part yields hundreds of surfaces and
|
|
447
|
+
// dumping them buries the reader in the noise the oracle exists to remove. `--surfaces`
|
|
448
|
+
// opts back in; `--json` always has everything. Reads the SAME compactDescribe() output
|
|
449
|
+
// a model reads (not a hand-rolled subset), so what a human sees here and what an agent
|
|
450
|
+
// sees over `--json` cannot drift apart.
|
|
451
|
+
function printDescribe(report, { surfaces }) {
|
|
452
|
+
if (report.error) {
|
|
453
|
+
console.error(`describe: ${report.error}${report.detail ? ` — ${report.detail}` : ""}`);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const c = compactDescribe(report);
|
|
457
|
+
if (c.warning) console.log(`\n!! ${c.warning}\n`);
|
|
458
|
+
console.log(`${c.source.name ?? "mesh"} — ${c.source.triangles} triangles, ` +
|
|
459
|
+
`${c.bounds.size.map((v) => v.toFixed(2)).join(" x ")} mm, ${c.frame.up} up`);
|
|
460
|
+
// The `share` hint lives HERE, at the point of use, not several lines below beside
|
|
461
|
+
// Score (fix round 2, IMPORTANT 1 — a prior version printed the full `score.note`
|
|
462
|
+
// paragraph under Score and it dominated the report: measured at 36-43% of a typical
|
|
463
|
+
// run's line count, the single largest visual element, bigger than the feature list,
|
|
464
|
+
// banners, and score line combined — burying findings instead of clarifying them). One
|
|
465
|
+
// line, next to the column it explains; the full note is still in `--json` unabridged
|
|
466
|
+
// (`buildScore` in report.js attaches it unconditionally — nothing lost there).
|
|
467
|
+
console.log(`\nFeatures (${c.features.length}):` +
|
|
468
|
+
(c.features.length ? ` share = fraction of part volume this feature ` +
|
|
469
|
+
`accounts for — a size measure, not certainty` : ""));
|
|
470
|
+
// `volumeShareReason` (fix round 2, IMPORTANT 2): `volumeShare: null` alone doesn't
|
|
471
|
+
// say whether this feature type is never proposed at all, was proposed but never
|
|
472
|
+
// reached before the search ran out of budget, or was reached and built but simply
|
|
473
|
+
// didn't win — three different signals to a rebuilder deciding what to do next. See
|
|
474
|
+
// describe.js's own comment on the field for the exact three-way split.
|
|
475
|
+
const REASON_LABEL = { "not-proposed": "not proposed", budget: "budget", rejected: "rejected" };
|
|
476
|
+
for (const f of c.features) {
|
|
477
|
+
const dim = f.diameter ?? f.radius ?? f.width ?? f.thickness ?? f.depth;
|
|
478
|
+
const snap = f.snapped?.diameter?.note ? ` [${f.snapped.diameter.note}]` : "";
|
|
479
|
+
const share = f.volumeShare != null
|
|
480
|
+
? `${(100 * f.volumeShare).toFixed(1)}%`
|
|
481
|
+
: `n/a (${REASON_LABEL[f.volumeShareReason] ?? f.volumeShareReason ?? "unknown"})`;
|
|
482
|
+
console.log(` ${f.id.padEnd(5)} ${f.type.padEnd(14)} ` +
|
|
483
|
+
`${dim != null ? dim.toFixed(3) : ""}`.padEnd(10) +
|
|
484
|
+
`share ${share}${snap}`);
|
|
485
|
+
}
|
|
486
|
+
if (c.patterns.length) {
|
|
487
|
+
console.log(`\nPatterns (${c.patterns.length}):`);
|
|
488
|
+
for (const p of c.patterns) {
|
|
489
|
+
console.log(` ${p.id.padEnd(5)} ${p.type.padEnd(9)} x${p.counts.join("x")} ` +
|
|
490
|
+
`pitch ${p.pitch.map((v) => v.toFixed(2)).join(", ")} [${p.members.length} members]`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (c.symmetry.length) {
|
|
494
|
+
console.log(`\nSymmetry:`);
|
|
495
|
+
for (const s of c.symmetry) console.log(` ${s.type} coverage ${s.coverage}`);
|
|
496
|
+
}
|
|
497
|
+
if (surfaces) {
|
|
498
|
+
console.log(`\nSurfaces (${report.surfaces.length}):`);
|
|
499
|
+
for (const s of report.surfaces) {
|
|
500
|
+
console.log(` ${s.id.padEnd(5)} ${s.type.padEnd(9)} area ${s.area.toFixed(2)}`.padEnd(40) +
|
|
501
|
+
`rms ${s.rms.toExponential(2)}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// TWO DIFFERENT NUMBERS, printed as two: explainedArea is how much of the mesh's
|
|
505
|
+
// SURFACE segmentation fitted to some primitive; explainedVolumeFraction is how much
|
|
506
|
+
// of the part's actual SHAPE the accepted features reconstruct. They can diverge
|
|
507
|
+
// totally — a dome segments to ~100% area and 0% volume, since a sphere is not a
|
|
508
|
+
// candidate-eligible feature type — so printing only one (or a blended "xor%") would
|
|
509
|
+
// hide exactly the gap the LOW COVERAGE banner above exists to catch. The wording here
|
|
510
|
+
// ("surface area explained" vs. "volume reconstructed") already carries that
|
|
511
|
+
// distinction; `score.note`'s full prose (this point, plus the volumeShare aside now
|
|
512
|
+
// covered at the Features header instead) stays JSON-only — fix round 2, IMPORTANT 1.
|
|
513
|
+
console.log(`\nScore: ${(100 * c.score.explainedArea).toFixed(1)}% surface area explained, ` +
|
|
514
|
+
`${(100 * c.score.explainedVolumeFraction).toFixed(1)}% volume reconstructed ` +
|
|
515
|
+
`(residual xor ${(100 * c.score.xorFraction).toFixed(2)}% of volume)`);
|
|
516
|
+
console.log(`Residual: ${(100 * c.residual.areaFraction).toFixed(2)}% of area in ` +
|
|
517
|
+
`${c.residual.regions.length} region(s)`);
|
|
518
|
+
const truncated = Object.entries(report.truncated ?? {}).filter(([, v]) => v).map(([k]) => k);
|
|
519
|
+
if (truncated.length) console.log(`Truncated (caps hit): ${truncated.join(", ")}`);
|
|
520
|
+
}
|
|
521
|
+
|
|
382
522
|
function printLint(r) {
|
|
383
523
|
const all = [...r.errors, ...r.warnings, ...(r.notes ?? [])];
|
|
384
524
|
if (all.length === 0) { console.log("lint: clean"); return; }
|
|
385
525
|
console.log("lint:");
|
|
386
526
|
for (const f of all) {
|
|
387
527
|
const icon = f.severity === "error" ? "✗" : f.severity === "warning" ? "⚠" : "·";
|
|
388
|
-
|
|
528
|
+
// A source-rule finding carries file+line — the only location a reader can
|
|
529
|
+
// open. Print it alongside the accessor path (which is `""` for a finding
|
|
530
|
+
// about a token anywhere in the file) rather than instead of it.
|
|
531
|
+
const at = f.file ? ` ${f.file}:${f.line ?? "?"}` : "";
|
|
532
|
+
console.log(` ${icon} ${f.rule}${f.path ? ` ${f.path}` : ""}${at}`);
|
|
389
533
|
console.log(` ${f.message}`);
|
|
390
534
|
console.log(` hint: ${f.hint}${f.pattern ? ` (ERROR-PATTERNS.md#${f.pattern})` : ""}`);
|
|
391
535
|
}
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -1494,6 +1494,166 @@ build: (k, p, d) => {
|
|
|
1494
1494
|
|
|
1495
1495
|
**CLI:** `partforge measure|render|lint` work on an importing part exactly as on any other — the `imports` field resolves in the CLI's Node boot the same way `fonts` does, no extra flags.
|
|
1496
1496
|
|
|
1497
|
+
## Describing an imported mesh
|
|
1498
|
+
|
|
1499
|
+
Before you write the parametric rebuild, ask the mesh itself what's in it: `partforge
|
|
1500
|
+
describe <part-module>#<importName>` reads an already-declared import (`k.import`'s own
|
|
1501
|
+
name, not a bare file path — the `imports` field is how the file gets to a kernel at
|
|
1502
|
+
all, so it's how `describe` finds it too) and emits a semantic feature report — holes,
|
|
1503
|
+
bosses, pockets, extrusions, patterns, symmetry — rather than a triangle soup. On
|
|
1504
|
+
`import-demo.js`, whose `scan` import is a plain 20×14×8mm block:
|
|
1505
|
+
|
|
1506
|
+
```
|
|
1507
|
+
$ npx partforge describe src/parts/import-demo.js#scan
|
|
1508
|
+
|
|
1509
|
+
scan — 12 triangles, 20.00 x 14.00 x 8.00 mm, +Z up
|
|
1510
|
+
|
|
1511
|
+
Features (1): share = fraction of part volume this feature accounts for — a size measure, not certainty
|
|
1512
|
+
f0 extrusion 8.000 share 100.0%
|
|
1513
|
+
|
|
1514
|
+
Score: 100.0% surface area explained, 100.0% volume reconstructed (residual xor 0.00% of volume)
|
|
1515
|
+
Residual: 0.00% of area in 0 region(s)
|
|
1516
|
+
```
|
|
1517
|
+
|
|
1518
|
+
**Two report shapes, and which one is authoritative.** `describe()` (`src/framework/
|
|
1519
|
+
oracle/describe.js`) returns the FULL report — every surface, every fitted edge, every
|
|
1520
|
+
feature at full precision — and that is the archive: `--json` prints it whole, and
|
|
1521
|
+
`--out <file>` writes it whole. `compactDescribe()` (`src/framework/oracle/describe/
|
|
1522
|
+
report.js`) derives a second, smaller shape from it — features, patterns, symmetry,
|
|
1523
|
+
score, residual, and the suggested rebuild, with `surfaces`/`edges` reduced to counts —
|
|
1524
|
+
and that is what a model reads, and what the terminal printer above reads too: **the
|
|
1525
|
+
same `compactDescribe()` output**, not a hand-rolled subset, so what a human sees in the
|
|
1526
|
+
default summary and what an agent gets from a cloud turn cannot drift apart. The full
|
|
1527
|
+
report is authoritative for the facts (a fitted surface's rms, an edge's exact radius);
|
|
1528
|
+
the compact report is authoritative for what's worth paying attention to.
|
|
1529
|
+
|
|
1530
|
+
**Computed once, reused for the session.** `describe` depends on nothing but the mesh's
|
|
1531
|
+
own bytes — no part params, no derived state — so unlike `measure`/`verify` it never
|
|
1532
|
+
needs to re-run on every parameter edit. The worker keys its memo on the import's
|
|
1533
|
+
content digest (`kernel._importDigest(name)`), so re-describing the same file, or asking
|
|
1534
|
+
again mid-session while you iterate on the parametric rebuild, is free after the first
|
|
1535
|
+
call. The CLI doesn't carry a memo across process invocations — each `partforge
|
|
1536
|
+
describe` run is its own process — but within one browser session or one long-running
|
|
1537
|
+
tool loop, the mesh is segmented once.
|
|
1538
|
+
|
|
1539
|
+
**`--surfaces` and `--json`, and why the default elides.** A hand-modelled block
|
|
1540
|
+
segments to six surfaces; a real 24k-triangle CAD export segments to hundreds. Printing
|
|
1541
|
+
every one of them by default would bury the two or three that actually matter under
|
|
1542
|
+
exactly the noise this oracle exists to remove — so the plain-text summary shows
|
|
1543
|
+
features, patterns, symmetry, score, and residual only. Pass `--surfaces` to add the
|
|
1544
|
+
surface table back (`s0 plane area … rms …`, one line per fitted patch); pass `--json`
|
|
1545
|
+
to get everything, always, structured — the mode an agent should default to over
|
|
1546
|
+
scraping the text summary. `--out <file>` writes the full report to disk independently
|
|
1547
|
+
of either flag, so you can capture it once and read it multiple ways.
|
|
1548
|
+
|
|
1549
|
+
**Low coverage exits zero — it's a finding, not a failure.** A poor segmentation (a
|
|
1550
|
+
dome, a free-form scan, anything the four feature detectors can't reconstruct) is
|
|
1551
|
+
exactly the situation an agent most needs to see, not one that should make itself
|
|
1552
|
+
harder to see. `describe` treats "the shape isn't well explained" and "the command
|
|
1553
|
+
failed" as different questions: a `LOW COVERAGE` banner at the top of the summary (and
|
|
1554
|
+
`compactDescribe()`'s own `warning` field in `--json` output) is how a poor result
|
|
1555
|
+
announces itself, and the process still exits 0. Only a genuine closed-set error — the
|
|
1556
|
+
mesh couldn't be read or segmented at all — exits 1. Coverage is gated on the WORSE of
|
|
1557
|
+
two numbers, both always printed rather than blended into one: `explainedArea` (how much
|
|
1558
|
+
of the mesh's *surface* a fitted primitive accounts for) and `explainedVolumeFraction`
|
|
1559
|
+
(how much of the part's actual *shape* the accepted features reconstruct). They can
|
|
1560
|
+
diverge totally — a dome segments to ~100% surface area (it fits a sphere cleanly) and
|
|
1561
|
+
0% volume (a sphere isn't a candidate-eligible feature type for any detector) — so
|
|
1562
|
+
printing only one of them, or an average, would hide precisely the gap the banner exists
|
|
1563
|
+
to catch. Each feature's own score is named `volumeShare`, never "confidence": it is the
|
|
1564
|
+
fraction of the part's volume that feature accounts for — a measure of *size* — so a
|
|
1565
|
+
small-but-certain feature (a precisely-fitted 3mm hole in a large plate) legitimately
|
|
1566
|
+
reports a small share. Read a feature's fitted surface `rms`/`maxDev` (under `--surfaces`
|
|
1567
|
+
or `--json`) for an actual certainty signal. The one-line hint at the `Features (N):`
|
|
1568
|
+
header states the size-not-certainty point at its point of use; the longer explanation
|
|
1569
|
+
(`score.note` — both this and the `explainedArea`/`explainedVolumeFraction` distinction
|
|
1570
|
+
above, spelled out for a reader who never gets this far) stays `--json`-only, since
|
|
1571
|
+
printing it in full under `Score:` used to be the single largest visual element in every
|
|
1572
|
+
plain-text run — measured at 36-43% of a typical report's line count, bigger than the
|
|
1573
|
+
feature list, the banners, and the score line combined.
|
|
1574
|
+
|
|
1575
|
+
**Why a null `volumeShare` isn't always the same story.** A feature can carry
|
|
1576
|
+
`volumeShare: null` for three different reasons, distinguished by its sibling field
|
|
1577
|
+
`volumeShareReason` (`--surfaces`/`--json`; the text view renders it as `share n/a
|
|
1578
|
+
(<reason>)`):
|
|
1579
|
+
|
|
1580
|
+
- `"not-proposed"` — this feature TYPE is never turned into an acceptance candidate at
|
|
1581
|
+
all. Fillets, chamfers, revolves and shells fall here today; they're still reported as
|
|
1582
|
+
features, just not yet reconstructable through this pipeline.
|
|
1583
|
+
- `"budget"` — a candidate WAS proposed, but the search ran out of `--budget` before
|
|
1584
|
+
reaching it. Raise `--budget` and re-describe; the feature may resolve to a real share
|
|
1585
|
+
once given the chance.
|
|
1586
|
+
- `"rejected"` — a candidate was proposed, built, and evaluated, but never won a round
|
|
1587
|
+
(`accept.js`'s `MIN_GAIN_FRACTION` gate, or simply never the best candidate available).
|
|
1588
|
+
Raising `--budget` will not change this outcome — the feature genuinely doesn't fit
|
|
1589
|
+
well enough to explain a meaningful share of the part.
|
|
1590
|
+
|
|
1591
|
+
`"budget"` and `"rejected"` are the two that matter most to get right, and the
|
|
1592
|
+
distinction is real, not cosmetic: "try a bigger budget" and "this doesn't fit" are
|
|
1593
|
+
different next actions for whoever is rebuilding the part.
|
|
1594
|
+
|
|
1595
|
+
**What `describe` reconstructs today, and what it doesn't.** The FACTS layer — surfaces,
|
|
1596
|
+
arcs, holes, dress-ups, sweeps, patterns, symmetry — covers the tool's full detection
|
|
1597
|
+
vocabulary; a feature can be *reported* there regardless of shape. The RECONSTRUCTION
|
|
1598
|
+
score (`explainedVolumeFraction`, and every accepted feature's own `volumeShare`) is
|
|
1599
|
+
narrower: `toCandidate` only proposes box and cylinder footprints as acceptance
|
|
1600
|
+
candidates today, so it currently reconstructs prismatic parts built from roughly fewer
|
|
1601
|
+
than eight box/cylinder features well, and does not yet reconstruct round bosses,
|
|
1602
|
+
revolves, fillets, chamfers, or shells — those are detected and reported (with
|
|
1603
|
+
`volumeShareReason: "not-proposed"`) but never turned into a candidate that could win
|
|
1604
|
+
volume back. Measured directly on this repo's own reference parts: `demo.js`
|
|
1605
|
+
reconstructs 65.7% of its volume, `filleted-box.js` 36.2%, `bracket.js` 23.6%, a plain
|
|
1606
|
+
tube 0.0%, and a hollow box 1.9%. The low-coverage banner fires on every one of these, so
|
|
1607
|
+
nothing here is misreported — but a low `explainedVolumeFraction` on a turned or
|
|
1608
|
+
feature-dense part means **"not yet reconstructable by this tool"**, not "not
|
|
1609
|
+
understood" or "broken." Read the FACTS (features, surfaces, patterns) as the ground
|
|
1610
|
+
truth regardless of the volume score; read the volume score as a measure of how much of
|
|
1611
|
+
that ground truth also comes with a working, boolean-verified rebuild recipe.
|
|
1612
|
+
|
|
1613
|
+
**Closed error set.** Anything short of a programming mistake comes back as
|
|
1614
|
+
`{error: "<code>", detail, diagnostic}` rather than a thrown exception — a CLI or an
|
|
1615
|
+
agent can act on a code far better than on a stack trace — and every code has an
|
|
1616
|
+
ERROR-PATTERNS.md entry:
|
|
1617
|
+
|
|
1618
|
+
- `not-manifold` — the mesh still has open edges after repair; see
|
|
1619
|
+
[ERROR-PATTERNS.md#describe-not-manifold](ERROR-PATTERNS.md#describe-not-manifold).
|
|
1620
|
+
- `too-large` — over the 400,000-triangle segmentation ceiling; see
|
|
1621
|
+
[ERROR-PATTERNS.md#describe-too-large](ERROR-PATTERNS.md#describe-too-large).
|
|
1622
|
+
- `empty` — the mesh has zero triangles; see
|
|
1623
|
+
[ERROR-PATTERNS.md#describe-empty](ERROR-PATTERNS.md#describe-empty).
|
|
1624
|
+
- `unreadable` — `solid.toMesh()` itself threw; see
|
|
1625
|
+
[ERROR-PATTERNS.md#describe-unreadable](ERROR-PATTERNS.md#describe-unreadable).
|
|
1626
|
+
|
|
1627
|
+
`not-manifold` and `empty` describe real states `describe()` itself checks for and can
|
|
1628
|
+
return through any caller that hands it a `Solid` directly, but through the CLI they are
|
|
1629
|
+
largely theoretical: `kernel.import()`'s own registration validation rejects an empty or
|
|
1630
|
+
a wide-open (non-manifold) mesh *before* a `Solid` ever exists to describe, so
|
|
1631
|
+
`partforge describe` on such a file fails earlier, as a generic import error (the
|
|
1632
|
+
`crash()` path every other verb shares — `describe: import "x": mesh is not a solid
|
|
1633
|
+
after repair…`) rather than as a structured `{error: "not-manifold"}` / `{error:
|
|
1634
|
+
"empty"}` report. See
|
|
1635
|
+
[ERROR-PATTERNS.md#import-mesh-not-solid](ERROR-PATTERNS.md#import-mesh-not-solid) for
|
|
1636
|
+
that failure. `describe-unreadable`'s own ERROR-PATTERNS entry already draws this same
|
|
1637
|
+
line for its own code (a `k.import` parse failure is a *different*, separately-thrown
|
|
1638
|
+
error, not `describe-unreadable`); this is the general version of that point.
|
|
1639
|
+
|
|
1640
|
+
A `budget-exceeded` report (the acceptance loop ran out of boolean attempts before the
|
|
1641
|
+
residual converged) is not one of these — it's a `warning` on an otherwise-valid report,
|
|
1642
|
+
same tier as low coverage, not a reason to exit non-zero; raise `--budget` and re-run.
|
|
1643
|
+
`compactDescribe()` surfaces it exactly where the low-coverage banner lives, and shows
|
|
1644
|
+
both together if a report happens to earn both — an exhausted budget is exactly the kind
|
|
1645
|
+
of run that also leaves coverage low, and neither warning is allowed to mask the other.
|
|
1646
|
+
See [ERROR-PATTERNS.md#describe-budget-exceeded](ERROR-PATTERNS.md#describe-budget-exceeded).
|
|
1647
|
+
|
|
1648
|
+
**The loop this completes.** `describe` exists to feed a specific workflow, the one
|
|
1649
|
+
"Importing geometry" above sets up and this closes: `describe` the import to get its
|
|
1650
|
+
feature list and a proposed reconstruction (`suggestion` in the full report); write a
|
|
1651
|
+
parametric part from that proposal; bind the rebuild's sub-part to the import with
|
|
1652
|
+
`reference: "scan"`; and let `verify`'s `ref*` gates (`refXorVolume`, `refVolumeDeltaPct`,
|
|
1653
|
+
`refBboxDelta` — "The `reference` field and the deviation gate", above) hold the rebuild
|
|
1654
|
+
to the scan on every future `measure`/`verify` run, long after the one-time `describe`
|
|
1655
|
+
call that suggested it. `describe` proposes; `verify` keeps you honest.
|
|
1656
|
+
|
|
1497
1657
|
---
|
|
1498
1658
|
|
|
1499
1659
|
## Wiring a part into a runnable app
|
|
@@ -1878,6 +2038,16 @@ import { lintPart } from "partforge/lint";
|
|
|
1878
2038
|
const { ok, errors, warnings } = lintPart(part, { params });
|
|
1879
2039
|
```
|
|
1880
2040
|
|
|
2041
|
+
`lintPart(part, { sources })` optionally takes the part's own source files
|
|
2042
|
+
(`{ files: { path: text }, entrypoint }` — `entrypoint` names the file holding the
|
|
2043
|
+
`PartDefinition`, defaulting to the first key) and unlocks a ninth rule group that
|
|
2044
|
+
reads the source itself, catching the defects evaluation erases. The CLI passes the
|
|
2045
|
+
module's own file automatically, so `partforge lint`/`measure` always run it; a
|
|
2046
|
+
programmatic caller that omits `sources` (or hands over a malformed one) just gets
|
|
2047
|
+
no findings from that group. Source findings carry `file` and `line` on top of the
|
|
2048
|
+
standard shape, and `SOURCE_RULE_IDS` names them — a host that gates rendering on
|
|
2049
|
+
lint errors uses it to keep them reported but non-blocking.
|
|
2050
|
+
|
|
1881
2051
|
`partforge/lint` has **zero runtime dependencies** and never imports a geometry
|
|
1882
2052
|
kernel or the DOM viewer, so it runs unchanged in Node, a Web Worker, a sandboxed
|
|
1883
2053
|
iframe, and Deno. A worker also answers `{ type: "lint", params }` with
|
|
@@ -2056,6 +2226,32 @@ control that the control's own `allow` list would refuse — at build time it's
|
|
|
2056
2226
|
swapped for `defaults[key]`, i.e. itself, so the part boots with no usable
|
|
2057
2227
|
font; use a source `allow` accepts, or widen `allow`) (warning).
|
|
2058
2228
|
|
|
2229
|
+
**Source rules** — the ninth group, which runs only when the caller hands over
|
|
2230
|
+
`sources` (above) — `control-default-not-literal` (a control's `defaults` entry is
|
|
2231
|
+
written as something other than a plain literal: an expression like `13 / 3`, an
|
|
2232
|
+
array or object, a template literal, a `0x10`/`1_000` spelling. Hosts persist a
|
|
2233
|
+
panel edit by rewriting that value's span in the source, so a spelling the
|
|
2234
|
+
rewriter cannot read means the user's edit is silently lost on reload — write a
|
|
2235
|
+
plain decimal/string/boolean literal, or move the computation into `derive()`)
|
|
2236
|
+
(error); `impure-source-token` (the source contains `Math.random`, `Date.now`,
|
|
2237
|
+
`performance.now`, or an argless `new Date()` — replace it with a parameter or a
|
|
2238
|
+
`derive()` output) (warning). Only a default a **visible** control is actually
|
|
2239
|
+
**bound** to is checked: an unbound non-primitive default (a lookup table, an
|
|
2240
|
+
array of hole positions) is never rewritten by a panel save and stays legal and
|
|
2241
|
+
unflagged, and so is the default of a statically hidden control (`hidden: true`
|
|
2242
|
+
on the control, or on an enclosing group or section) — it renders no widget, so
|
|
2243
|
+
there is no panel edit to lose, and `hidden: true` is the documented idiom for an
|
|
2244
|
+
internal constant. A `when`-conditioned control is *not* hidden — it can appear,
|
|
2245
|
+
so its default is checked.
|
|
2246
|
+
`impure-source-token` is warning-tier because the behavioral
|
|
2247
|
+
`nondeterministic-build` probe stays the error authority on impurity — the source
|
|
2248
|
+
scan is the wider net that also catches an impure value stable within one probe
|
|
2249
|
+
pass. It scans `.js`/`.mjs` files only (prose in a `README.md` is not a build),
|
|
2250
|
+
and code only within them (comments and string/template *interiors* are blanked
|
|
2251
|
+
first), so an impurity token inside a `${…}` interpolation is not seen. It emits
|
|
2252
|
+
one finding per (file, token) pair, carrying the occurrence count and the first
|
|
2253
|
+
occurrence's line, rather than one per occurrence.
|
|
2254
|
+
|
|
2059
2255
|
A rule that itself throws yields an `internal-rule-error` **warning** and the run
|
|
2060
2256
|
continues: `lintPart` never throws and never blocks a part because of a linter bug.
|
|
2061
2257
|
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -601,6 +601,48 @@ between the Manifold preview and the OCCT STEP export.
|
|
|
601
601
|
|
|
602
602
|
The same channel carries every other degrade in a build: an `extrude` rim bevel reduced or skipped (`extrude bevel <b> …`), a `roundedBox` rim radius clamped to `round.side`, and the `Shape2D` corner-op clamps in the two entries above. A build result's `warnings` is the complete list of what the part asked for and did not get.
|
|
603
603
|
|
|
604
|
+
## describe-not-manifold
|
|
605
|
+
|
|
606
|
+
- **Symptom:** `describe` returns `{"error": "not-manifold"}`.
|
|
607
|
+
- **Cause:** The mesh still has open edges after vertex-merge and winding repair, so it does not bound a solid and acceptance cannot diff against it.
|
|
608
|
+
- **Fix:** Repair the mesh before describing it — Meshmixer, `meshlabserver`, or the slicer's own repair. `describe` will not repair geometry it was asked to report on; silently sealing a hole would make the report a description of a mesh the user does not have.
|
|
609
|
+
|
|
610
|
+
## describe-too-large
|
|
611
|
+
|
|
612
|
+
- **Symptom:** `{"error": "too-large"}` naming a triangle count.
|
|
613
|
+
- **Cause:** Above 400,000 triangles the segmentation pass stops being usable in an interactive loop.
|
|
614
|
+
- **Fix:** Decimate first. A CAD-exported STL re-exported at a coarser chord tolerance loses nothing the describer uses; the feature vocabulary reads surfaces, not facets.
|
|
615
|
+
|
|
616
|
+
## describe-empty
|
|
617
|
+
|
|
618
|
+
- **Symptom:** `{"error": "empty"}`.
|
|
619
|
+
- **Cause:** The mesh has zero triangles — usually an import that resolved to an empty file, or a `k.import` name that registered as an error entry.
|
|
620
|
+
- **Fix:** Check the `imports` source actually resolves. See [import-unknown-name](#import-unknown-name).
|
|
621
|
+
|
|
622
|
+
## describe-budget-exceeded
|
|
623
|
+
|
|
624
|
+
- **Symptom:** The report carries `warning: "budget-exceeded"` and `score.xorFraction` is higher than expected.
|
|
625
|
+
- **Cause:** The acceptance loop hit its boolean budget before the residual converged. The report is partial but honestly scored — not wrong, just incomplete.
|
|
626
|
+
- **Fix:** Raise `--budget`, or accept the partial description. A part needing far more than the default is usually one where a residual region is being attacked by many near-identical candidates; check `residual.regions` first.
|
|
627
|
+
|
|
628
|
+
## describe-unreadable
|
|
629
|
+
|
|
630
|
+
- **Symptom:** `{"error": "unreadable"}`.
|
|
631
|
+
- **Cause:** `solid.toMesh()` threw rather than returning geometry — a corrupt or otherwise unrepresentable solid.
|
|
632
|
+
- **Fix:** Confirm the source file parses cleanly upstream (`k.import` itself throws separately for an unparseable STL/3MF/STEP — see [import-unknown-name](#import-unknown-name)); re-export the file if the kernel cannot read back what it just built. STL is assumed to be in millimetres (the format carries no unit metadata) — see the import section of AUTHORING-PARTS.md.
|
|
633
|
+
|
|
634
|
+
## control-default-not-literal
|
|
635
|
+
|
|
636
|
+
- **Symptom:** A control works live — the slider moves, the geometry updates — but the user's panel edits are gone when the part is reopened. Nothing throws anywhere.
|
|
637
|
+
- **Cause:** The control's `defaults` entry is written as something other than a plain literal — an expression (`13 / 3`), an array or object, a template literal, a hex/`1_000` spelling. Hosts persist a panel edit by rewriting that value's span in the source, so a value the rewriter cannot read is skipped and the edit is silently lost. The evaluated-object lint cannot see this (`13 / 3` evaluates to an ordinary number); only the source says.
|
|
638
|
+
- **Fix:** Write the computed value as a plain decimal/string/boolean literal, or move the computation into `derive()`. `lintPart(part, { sources })` and the CLI report this as the error `control-default-not-literal` with file and line. Only a **visible** control's default is checked — a statically hidden one (`hidden: true` on the control, group or section) renders no widget, so there is no panel edit to lose, and an expression there is legitimate. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Linting" (Rule catalog → Source rules).
|
|
639
|
+
|
|
640
|
+
## impure-source-token
|
|
641
|
+
|
|
642
|
+
- **Symptom:** The preview shows stale geometry after a parameter edit, or a part behaves differently across rebuilds with identical params — often intermittent.
|
|
643
|
+
- **Cause:** The source contains `Math.random`, `Date.now`, `performance.now`, or an argless `new Date()`. A build must be a pure function of `(k, p, d)`; the memoizing kernel hashes inputs, so an impure value silently serves stale geometry (see impure-build-stale-preview, above). The behavioral lint probe catches impurity only when it changes the recorded call sequence between two probe runs; a value stable within one pass escapes it, which is why the source scan warns on the token itself.
|
|
644
|
+
- **Fix:** Replace the impure value with a parameter or a `derive()` output. `new Date(0)` and other argument-carrying forms are deterministic and not flagged; only `.js`/`.mjs` files are scanned, so the same words in a `README.md` are prose. One finding is emitted per (file, token) pair, carrying the occurrence count and the first occurrence's line. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Caching & determinism".
|
|
645
|
+
|
|
604
646
|
# Hardware library
|
|
605
647
|
|
|
606
648
|
Reserved for `hardware-*` patterns (issue #30). No entries yet.
|
package/package.json
CHANGED
package/src/framework/jobs.js
CHANGED
|
@@ -15,6 +15,15 @@ import { verify } from "./oracle/verify.js";
|
|
|
15
15
|
import { buildView } from "./oracle/build.js";
|
|
16
16
|
import { MATCH_VIEWS, rasterizeMeshMask, rasterizeRingsMask } from "./oracle/silhouette.js";
|
|
17
17
|
import { matchViews } from "./oracle/match.js";
|
|
18
|
+
import { describe as describeMesh, describeMemo } from "./oracle/describe.js";
|
|
19
|
+
import { compactDescribe } from "./oracle/describe/report.js";
|
|
20
|
+
|
|
21
|
+
// One describe memo for the life of this worker. Deliberately NOT swept on setPart the
|
|
22
|
+
// way solid-cache is: describe is pure in the mesh bytes (spec §4.1), so an edit can
|
|
23
|
+
// never invalidate it, and dropping it on rebind would throw away the single most
|
|
24
|
+
// expensive thing this worker computes for no reason at all. Keyed by content digest, so
|
|
25
|
+
// a genuinely changed file misses correctly.
|
|
26
|
+
const DESCRIBE_MEMO = describeMemo();
|
|
18
27
|
|
|
19
28
|
// Handle one geometry job, posting results/progress via `post(msg, transfer?)`.
|
|
20
29
|
// Backend-agnostic and part-agnostic: every part specific comes through `part`.
|
|
@@ -375,6 +384,28 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
375
384
|
const match = scoreMatchTargets(built, msg.matchTargets, onProgress);
|
|
376
385
|
if (match) report.match = match;
|
|
377
386
|
post({ type: "report", ...report }, match?.map((m) => m.delta.data.buffer) ?? []);
|
|
387
|
+
} else if (msg.type === "describe") {
|
|
388
|
+
// Semantic description of an IMPORTED mesh — not of the built part. The two are
|
|
389
|
+
// different questions: `inspect` asks "what did this source build?", `describe`
|
|
390
|
+
// asks "what is this file?". describe never touches the part's own geometry, which
|
|
391
|
+
// is why it takes an import name rather than a view.
|
|
392
|
+
//
|
|
393
|
+
// Manifold only, and not by choice on this path: mesh imports on OCCT are never
|
|
394
|
+
// attempted, so a describe job posted to an OCCT worker is a routing bug, not a
|
|
395
|
+
// fallback opportunity. It surfaces as an ordinary error rather than a reroute.
|
|
396
|
+
const solid = kernel.import(msg.importName); // throws on an unknown name
|
|
397
|
+
// `_importDigest` is the backend's existing underscore side-channel (KERNEL-CONTRACT
|
|
398
|
+
// "Conformance classes") — the same digest already folded into every import cache key.
|
|
399
|
+
const digest = kernel._importDigest?.(msg.importName) ?? null;
|
|
400
|
+
const full = describeMesh(kernel, solid, {
|
|
401
|
+
name: msg.importName,
|
|
402
|
+
digest,
|
|
403
|
+
budget: msg.budget,
|
|
404
|
+
memo: DESCRIBE_MEMO,
|
|
405
|
+
});
|
|
406
|
+
// The compact shape is derived, never memoised separately: one memo entry per
|
|
407
|
+
// mesh, two views of it, no way for the two to drift.
|
|
408
|
+
post({ type: "describe-report", report: msg.compact ? compactDescribe(full) : full });
|
|
378
409
|
}
|
|
379
410
|
} catch (err) {
|
|
380
411
|
// `subparts` (generate jobs only) tells the reroute policy which sub-parts the
|
|
@@ -17,8 +17,32 @@ import { ANIMATION_RULES } from "./rules-animations.js";
|
|
|
17
17
|
import { PLACE_RULES } from "./rules-place.js";
|
|
18
18
|
import { IMPORT_RULES } from "./rules-imports.js";
|
|
19
19
|
import { FONT_RULES } from "./rules-fonts.js";
|
|
20
|
+
import { SOURCE_RULES } from "./rules-source.js";
|
|
20
21
|
|
|
21
|
-
export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES];
|
|
22
|
+
export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES, ...SOURCE_RULES];
|
|
23
|
+
|
|
24
|
+
// A usable sources input, or null. Deliberately forgiving: lintPart's callers
|
|
25
|
+
// include hosted paths handing over user/LLM-authored trees, so a malformed
|
|
26
|
+
// shape means "no source rules", never a throw. Non-string file values are
|
|
27
|
+
// dropped per entry rather than voiding the whole map.
|
|
28
|
+
function normalizeSources(sources) {
|
|
29
|
+
if (!sources || typeof sources !== "object") return null;
|
|
30
|
+
const rawFiles = sources.files;
|
|
31
|
+
if (!rawFiles || typeof rawFiles !== "object") return null;
|
|
32
|
+
// Null-prototype so a file literally keyed `__proto__` is KEPT as an own
|
|
33
|
+
// property: `{}["__proto__"] = text` would set the prototype instead, quietly
|
|
34
|
+
// dropping that file from the scan while still counting toward `any`.
|
|
35
|
+
const files = Object.create(null);
|
|
36
|
+
let any = false;
|
|
37
|
+
for (const [path, text] of Object.entries(rawFiles)) {
|
|
38
|
+
if (typeof text !== "string") continue;
|
|
39
|
+
files[path] = text;
|
|
40
|
+
any = true;
|
|
41
|
+
}
|
|
42
|
+
if (!any) return null;
|
|
43
|
+
const entrypoint = typeof sources.entrypoint === "string" ? sources.entrypoint : Object.keys(files)[0];
|
|
44
|
+
return { files, entrypoint };
|
|
45
|
+
}
|
|
22
46
|
|
|
23
47
|
// Every rule runs inside a guard. lintPart is called on a user-facing hosted path
|
|
24
48
|
// (partforge-cloud's sandbox), and a linter that takes down the preview it exists to
|
|
@@ -74,7 +98,9 @@ export function lintContext(part, params) {
|
|
|
74
98
|
/**
|
|
75
99
|
* Lint a PartDefinition. Never throws.
|
|
76
100
|
* @param {object} part the default-exported PartDefinition
|
|
77
|
-
* @param {{params?: object
|
|
101
|
+
* @param {{params?: object, sources?: {files?: Record<string, string>, entrypoint?: string}}} [opts]
|
|
102
|
+
* `params` are layered over part.defaults for the probe pass; `sources` is the part's own
|
|
103
|
+
* source text, which unlocks the source rules (Group 9) — omit it and lint behaves as before.
|
|
78
104
|
* @returns {{ok: boolean, errors: object[], warnings: object[], notes: object[]}}
|
|
79
105
|
*/
|
|
80
106
|
export function lintPart(part, opts) {
|
|
@@ -82,7 +108,7 @@ export function lintPart(part, opts) {
|
|
|
82
108
|
// parameter only fires on `undefined` — a caller passing `lintPart(part, null)`
|
|
83
109
|
// (a plausible downstream-harness call) would otherwise throw destructuring
|
|
84
110
|
// `{ params }` out of `null` before this function's body ever runs.
|
|
85
|
-
const { params } = opts ?? {};
|
|
111
|
+
const { params, sources } = opts ?? {};
|
|
86
112
|
// lintContext already guards its own internals (see its comment above), but it
|
|
87
113
|
// is user-authored data all the way down — wrap the call itself too, so a
|
|
88
114
|
// failure mode neither of us has thought of still degrades to a report instead
|
|
@@ -102,6 +128,14 @@ export function lintPart(part, opts) {
|
|
|
102
128
|
notes: [],
|
|
103
129
|
};
|
|
104
130
|
}
|
|
131
|
+
// Deliberately its OWN guard, outside the lintContext try above: a malformed
|
|
132
|
+
// `sources` input means "no source rules", never a broken part. `sources` is
|
|
133
|
+
// caller-supplied data (a throwing `files`/`entrypoint` getter, a Proxy whose
|
|
134
|
+
// `ownKeys` trap throws), and folding it into the block above would turn that
|
|
135
|
+
// into a `lint-context-error` — an id that is NOT in SOURCE_RULE_IDS, so a
|
|
136
|
+
// host filtering source findings out to keep them non-blocking would instead
|
|
137
|
+
// refuse to render a part that builds fine.
|
|
138
|
+
try { ctx.sources = normalizeSources(sources); } catch { ctx.sources = null; }
|
|
105
139
|
const findings = runRules(RULES, ctx);
|
|
106
140
|
// `p` (params merged from `defaults`) failed to build — every rule still ran
|
|
107
141
|
// against the `{}` fallback (each guarded individually by runRules), but the
|