partforge 0.75.0 → 0.77.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
@@ -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";
@@ -77,6 +77,21 @@ async function loadPart(partPath, usage) {
77
77
  return part;
78
78
  }
79
79
 
80
+ // The source rules (group 9) read the part's TEXT, which `loadPart` never sees —
81
+ // it imports the module, and evaluation is exactly what erases the defects those
82
+ // rules exist for (`13 / 3` is just a number by then). The entry module's own file
83
+ // is all we hand over: following relative imports is a deliberate non-goal, and a
84
+ // missing/unreadable file simply leaves `sources` off, which turns the group into
85
+ // a no-op rather than failing the command.
86
+ const readSources = (partPath) => {
87
+ try {
88
+ const path = basename(partPath);
89
+ return { files: { [path]: readFileSync(resolve(process.cwd(), partPath), "utf8") }, entrypoint: path };
90
+ } catch {
91
+ return undefined;
92
+ }
93
+ };
94
+
80
95
  // Pass the part's declared fonts through, mirroring the worker path (jobs.js) —
81
96
  // otherwise a part using a named font builds in the browser but dies headlessly
82
97
  // with `text2d: unknown font …`. A function-form `fonts` is resolved against
@@ -102,7 +117,8 @@ const commands = {
102
117
  try {
103
118
  const part = await loadPart(partPath, usage);
104
119
  const params = flags.params ? JSON.parse(flags.params) : undefined;
105
- const report = lintPart(part, { params });
120
+ const sources = readSources(partPath);
121
+ const report = lintPart(part, { params, sources });
106
122
  if (!flags.json) printLint(report);
107
123
  if (flags.out) {
108
124
  mkdirSync(dirname(resolve(flags.out)), { recursive: true });
@@ -131,7 +147,7 @@ const commands = {
131
147
  // milliseconds with a precise message rather than after a WASM boot and a
132
148
  // downstream error that doesn't name the cause. Warnings never gate measure.
133
149
  if (!flags["no-lint"]) {
134
- const lint = lintPart(part);
150
+ const lint = lintPart(part, { sources: readSources(partPath) });
135
151
  if (!lint.ok) {
136
152
  if (flags.json) console.log(JSON.stringify({ ok: false, lint }, null, 2));
137
153
  else printLint(lint);
@@ -385,7 +401,11 @@ function printLint(r) {
385
401
  console.log("lint:");
386
402
  for (const f of all) {
387
403
  const icon = f.severity === "error" ? "✗" : f.severity === "warning" ? "⚠" : "·";
388
- console.log(` ${icon} ${f.rule}${f.path ? ` ${f.path}` : ""}`);
404
+ // A source-rule finding carries file+line — the only location a reader can
405
+ // open. Print it alongside the accessor path (which is `""` for a finding
406
+ // about a token anywhere in the file) rather than instead of it.
407
+ const at = f.file ? ` ${f.file}:${f.line ?? "?"}` : "";
408
+ console.log(` ${icon} ${f.rule}${f.path ? ` ${f.path}` : ""}${at}`);
389
409
  console.log(` ${f.message}`);
390
410
  console.log(` hint: ${f.hint}${f.pattern ? ` (ERROR-PATTERNS.md#${f.pattern})` : ""}`);
391
411
  }
@@ -609,7 +609,12 @@ parameters: [
609
609
  ```
610
610
 
611
611
  Every control `key` must exist in `defaults`, or the control is silently dead —
612
- `control-key-not-in-defaults` is an error for exactly that reason.
612
+ `control-key-not-in-defaults` is an error for exactly that reason. Its value there must
613
+ be a **number, string or boolean** — a control writes one scalar, so a control bound to
614
+ an array, an object, `null` or `NaN` is dead in the same silent way
615
+ (`control-default-not-primitive`), and a host that saves panel settings back into
616
+ `defaults` cannot write that value either. A non-primitive `defaults` entry that no
617
+ control is bound to is fine: `defaults` also seeds `p` for `build()`.
613
618
 
614
619
  **Ids.** A section, a group and a preset may carry an `id`; the renderer keys its
615
620
  element, state and disclosure maps on ids, so they must be unique across the whole
@@ -1873,6 +1878,16 @@ import { lintPart } from "partforge/lint";
1873
1878
  const { ok, errors, warnings } = lintPart(part, { params });
1874
1879
  ```
1875
1880
 
1881
+ `lintPart(part, { sources })` optionally takes the part's own source files
1882
+ (`{ files: { path: text }, entrypoint }` — `entrypoint` names the file holding the
1883
+ `PartDefinition`, defaulting to the first key) and unlocks a ninth rule group that
1884
+ reads the source itself, catching the defects evaluation erases. The CLI passes the
1885
+ module's own file automatically, so `partforge lint`/`measure` always run it; a
1886
+ programmatic caller that omits `sources` (or hands over a malformed one) just gets
1887
+ no findings from that group. Source findings carry `file` and `line` on top of the
1888
+ standard shape, and `SOURCE_RULE_IDS` names them — a host that gates rendering on
1889
+ lint errors uses it to keep them reported but non-blocking.
1890
+
1876
1891
  `partforge/lint` has **zero runtime dependencies** and never imports a geometry
1877
1892
  kernel or the DOM viewer, so it runs unchanged in Node, a Web Worker, a sandboxed
1878
1893
  iframe, and Deno. A worker also answers `{ type: "lint", params }` with
@@ -1896,7 +1911,8 @@ about the definition as a whole use `""`.
1896
1911
  behave as authored — whether or not that shows up as a thrown exception. Some error
1897
1912
  findings do correspond to a runtime throw (`build-throws`, `verify-expect-throws`),
1898
1913
  but others catch **silent** wrongness: `missing-meta-title`, `part-view-unknown`,
1899
- `control-key-not-in-defaults`, `preset-key-not-in-defaults`, and
1914
+ `control-key-not-in-defaults`, `control-default-not-primitive`,
1915
+ `preset-key-not-in-defaults`, and
1900
1916
  `verify-unknown-subpart` all fire on parts that build, measure, and verify cleanly —
1901
1917
  a dead control that's silently unreachable, a view that renders nothing, or a
1902
1918
  `verify` expectation that's silently dropped so its gate never runs. That's still an
@@ -1913,7 +1929,8 @@ previously didn't; that's the fix working as intended, not a regression.
1913
1929
  `default-view-ambiguous` (warnings).
1914
1930
 
1915
1931
  **Parameter schema** — `features-requires-sliders`, `features-requires-on`,
1916
- `control-key-not-in-defaults`, `preset-key-not-in-defaults`, `mixed-section-shape`,
1932
+ `control-key-not-in-defaults`, `control-default-not-primitive`,
1933
+ `preset-key-not-in-defaults`, `mixed-section-shape`,
1917
1934
  `duplicate-preset-name`, `duplicate-node-id`, `select-options-missing`,
1918
1935
  `select-default-not-in-options`, `log-scale-needs-positive-min`,
1919
1936
  `when-key-not-in-defaults`, `when-unknown-operator`, `unknown-control-type` (errors);
@@ -2049,6 +2066,32 @@ control that the control's own `allow` list would refuse — at build time it's
2049
2066
  swapped for `defaults[key]`, i.e. itself, so the part boots with no usable
2050
2067
  font; use a source `allow` accepts, or widen `allow`) (warning).
2051
2068
 
2069
+ **Source rules** — the ninth group, which runs only when the caller hands over
2070
+ `sources` (above) — `control-default-not-literal` (a control's `defaults` entry is
2071
+ written as something other than a plain literal: an expression like `13 / 3`, an
2072
+ array or object, a template literal, a `0x10`/`1_000` spelling. Hosts persist a
2073
+ panel edit by rewriting that value's span in the source, so a spelling the
2074
+ rewriter cannot read means the user's edit is silently lost on reload — write a
2075
+ plain decimal/string/boolean literal, or move the computation into `derive()`)
2076
+ (error); `impure-source-token` (the source contains `Math.random`, `Date.now`,
2077
+ `performance.now`, or an argless `new Date()` — replace it with a parameter or a
2078
+ `derive()` output) (warning). Only a default a **visible** control is actually
2079
+ **bound** to is checked: an unbound non-primitive default (a lookup table, an
2080
+ array of hole positions) is never rewritten by a panel save and stays legal and
2081
+ unflagged, and so is the default of a statically hidden control (`hidden: true`
2082
+ on the control, or on an enclosing group or section) — it renders no widget, so
2083
+ there is no panel edit to lose, and `hidden: true` is the documented idiom for an
2084
+ internal constant. A `when`-conditioned control is *not* hidden — it can appear,
2085
+ so its default is checked.
2086
+ `impure-source-token` is warning-tier because the behavioral
2087
+ `nondeterministic-build` probe stays the error authority on impurity — the source
2088
+ scan is the wider net that also catches an impure value stable within one probe
2089
+ pass. It scans `.js`/`.mjs` files only (prose in a `README.md` is not a build),
2090
+ and code only within them (comments and string/template *interiors* are blanked
2091
+ first), so an impurity token inside a `${…}` interpolation is not seen. It emits
2092
+ one finding per (file, token) pair, carrying the occurrence count and the first
2093
+ occurrence's line, rather than one per occurrence.
2094
+
2052
2095
  A rule that itself throws yields an `internal-rule-error` **warning** and the run
2053
2096
  continues: `lintPart` never throws and never blocks a part because of a linter bug.
2054
2097
 
@@ -601,6 +601,18 @@ 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
+ ## control-default-not-literal
605
+
606
+ - **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.
607
+ - **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.
608
+ - **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).
609
+
610
+ ## impure-source-token
611
+
612
+ - **Symptom:** The preview shows stale geometry after a parameter edit, or a part behaves differently across rebuilds with identical params — often intermittent.
613
+ - **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.
614
+ - **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".
615
+
604
616
  # Hardware library
605
617
 
606
618
  Reserved for `hardware-*` patterns (issue #30). No entries yet.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.75.0",
3
+ "version": "0.77.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",
@@ -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}} [opts] params layered over part.defaults for the probe pass
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
@@ -21,28 +21,34 @@ const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArr
21
21
  // Every (descriptor, path, allowed-fields) triple that owns a parameter key, across
22
22
  // all four section kinds. A feature's own `sliders` are collected too, since each
23
23
  // slider is a full control descriptor in its own right.
24
+ // Each record also carries `inHidden`: whether a STATICALLY hidden container
25
+ // (a section, group or feature written `hidden: true`) encloses the descriptor.
26
+ // It is read only by controlBoundKeys({ excludeHidden }); every other rule
27
+ // ignores it, so their populations are unchanged. `when` is deliberately not
28
+ // hiddenness — a `when`-conditioned node can still appear.
24
29
  function collectDescriptors(part) {
25
30
  const out = [];
26
31
  sections(part).forEach((sec, si) => {
32
+ const secHidden = sec?.hidden === true;
27
33
  // The authored shape: children in `controls`, recursively. Field lists are
28
34
  // the authored ones (authorFieldsFor) — the legacy lists stay untouched so
29
35
  // `when` on a legacy descriptor still warns. A section routes to one shape
30
36
  // or the other (desugar's winner-takes-all), so `return` before the legacy
31
37
  // loops below rather than falling through to them.
32
- function walkAuthored(list, base) {
38
+ function walkAuthored(list, base, inHidden) {
33
39
  arr(list).forEach((entry, i) => {
34
40
  if (!entry) return;
35
41
  const path = `${base}[${i}]`;
36
42
  if (entry.type === "group") {
37
- out.push({ d: entry, path, fields: GROUP_FIELDS, container: true, authored: true });
38
- walkAuthored(entry.controls, `${path}.controls`);
43
+ out.push({ d: entry, path, fields: GROUP_FIELDS, container: true, authored: true, inHidden });
44
+ walkAuthored(entry.controls, `${path}.controls`, inHidden || entry.hidden === true);
39
45
  } else if (entry.type === "preset") {
40
- out.push({ d: entry, path, fields: PRESET_FIELDS, container: true, authored: true });
46
+ out.push({ d: entry, path, fields: PRESET_FIELDS, container: true, authored: true, inHidden });
41
47
  } else {
42
48
  // A typo'd type (e.g. "grup") fails both branches above and lands
43
49
  // here — authorFieldsFor falls back to AUTHOR_COMMON, and
44
50
  // unknown-control-type (below) is what actually diagnoses it.
45
- out.push({ d: entry, path, fields: authorFieldsFor(entry.type ?? "slider"), authored: true });
51
+ out.push({ d: entry, path, fields: authorFieldsFor(entry.type ?? "slider"), authored: true, inHidden });
46
52
  }
47
53
  });
48
54
  }
@@ -52,27 +58,27 @@ function collectDescriptors(part) {
52
58
  // that case (a deliberate scope choice), and pushing every section
53
59
  // unconditionally would produce findings with nothing to say. `when`-only
54
60
  // rules just need it present when relevant.
55
- if (sec?.when !== undefined) out.push({ d: sec, path: `parameters[${si}]`, fields: SECTION_FIELDS, container: true });
56
- walkAuthored(sec.controls, `parameters[${si}].controls`);
61
+ if (sec?.when !== undefined) out.push({ d: sec, path: `parameters[${si}]`, fields: SECTION_FIELDS, container: true, inHidden: secHidden });
62
+ walkAuthored(sec.controls, `parameters[${si}].controls`, secHidden);
57
63
  return;
58
64
  }
59
65
 
60
66
  arr(sec?.advanced).forEach((d, i) => {
61
- if (d) out.push({ d, path: `parameters[${si}].advanced[${i}]`, fields: fieldsFor("slider") });
67
+ if (d) out.push({ d, path: `parameters[${si}].advanced[${i}]`, fields: fieldsFor("slider"), inHidden: secHidden });
62
68
  });
63
69
  arr(sec?.features).forEach((f, i) => {
64
70
  if (!f) return;
65
- out.push({ d: f, path: `parameters[${si}].features[${i}]`, fields: FEATURE_FIELDS });
71
+ out.push({ d: f, path: `parameters[${si}].features[${i}]`, fields: FEATURE_FIELDS, inHidden: secHidden });
66
72
  arr(f.sliders).forEach((s, j) => {
67
73
  // Tag with the owning feature's key so slider-range-excludes-default can
68
74
  // recognise the demo.js flange_d pattern below: a slider sharing its key
69
75
  // with the feature is not an independent parameter, it's the feature's own
70
76
  // magnitude, and `defaults[key] === 0` there means "off", not "out of range".
71
- if (s) out.push({ d: s, path: `parameters[${si}].features[${i}].sliders[${j}]`, fields: fieldsFor("slider"), featureKey: f.key });
77
+ if (s) out.push({ d: s, path: `parameters[${si}].features[${i}].sliders[${j}]`, fields: fieldsFor("slider"), featureKey: f.key, inHidden: secHidden || f.hidden === true });
72
78
  });
73
79
  });
74
80
  arr(sec?.toggles).forEach((t, i) => {
75
- if (t) out.push({ d: t, path: `parameters[${si}].toggles[${i}]`, fields: fieldsFor("checkbox") });
81
+ if (t) out.push({ d: t, path: `parameters[${si}].toggles[${i}]`, fields: fieldsFor("checkbox"), inHidden: secHidden });
76
82
  });
77
83
  });
78
84
  return out;
@@ -104,6 +110,44 @@ function collectPresetBundles(part) {
104
110
 
105
111
  const defaultKeys = (part) => new Set(Object.keys(part?.defaults ?? {}));
106
112
 
113
+ // The keys a real control is bound to — the population whose defaults must be
114
+ // panel-writable. Shared with rules-source.js's control-default-not-literal,
115
+ // which needs the SAME key set control-default-not-primitive uses (unbound
116
+ // non-primitive defaults are legal: they seed `p` for build()).
117
+ //
118
+ // `excludeHidden` drops descriptors a STATICALLY hidden container encloses, and
119
+ // those written `hidden: true` themselves. Only the source rule wants it: a
120
+ // hidden control renders no widget, so no panel edit of it can ever be lost,
121
+ // and `hidden: true` is the documented idiom for an internal constant — exactly
122
+ // where an author legitimately writes an expression. `control-default-not-primitive`
123
+ // leaves it off, because a hidden control's key still seeds `p` and so still
124
+ // has to be a scalar.
125
+ export function controlBoundKeys(part, { excludeHidden = false } = {}) {
126
+ return new Set(
127
+ collectDescriptors(part)
128
+ .filter(({ container }) => !container)
129
+ .filter(({ d, inHidden }) => !excludeHidden || !(inHidden === true || d.hidden === true))
130
+ .map(({ d }) => d.key)
131
+ .filter((k) => typeof k === "string"),
132
+ );
133
+ }
134
+
135
+ // What a control can actually edit: one finite scalar. Non-finite numbers are out
136
+ // with the rest — a slider cannot show NaN, and neither NaN nor Infinity survives
137
+ // a round trip through JSON or a source rewrite.
138
+ const isEditableValue = (v) =>
139
+ typeof v === "string" || typeof v === "boolean" || (typeof v === "number" && Number.isFinite(v));
140
+
141
+ // Name a value for a finding's message: the reader needs to know WHICH default is
142
+ // wrong and what it is, and `typeof []` ("object") tells them neither.
143
+ const describeValue = (v) => {
144
+ if (Array.isArray(v)) return "an array";
145
+ if (v === null) return "a null";
146
+ if (typeof v === "number") return `a ${Number.isNaN(v) ? "NaN" : String(v)}`;
147
+ if (v === undefined) return "an undefined";
148
+ return `a ${typeof v}`;
149
+ };
150
+
107
151
  // Walk one WhenCondition, calling onKey(key) for every param key it reads and
108
152
  // onOp(op) for every operator name it uses. allOf/anyOf/not recurse; a bare
109
153
  // `{ key: value }` entry counts as reading `key` with no operator to check.
@@ -191,6 +235,32 @@ export const SCHEMA_RULES = [
191
235
  `${path}.key`));
192
236
  },
193
237
  },
238
+ {
239
+ // A control edits ONE value, and the widgets read and write it as a scalar:
240
+ // a slider over an array renders NaN, a checkbox over an object writes
241
+ // `true` over it. It is dead in the same silent way control-key-not-in-defaults
242
+ // is — the part builds, the panel renders, the control does nothing usable —
243
+ // which is why this is an error rather than a warning.
244
+ //
245
+ // Only a key a CONTROL is bound to. `defaults` also seeds `p` for build(), so
246
+ // parking a table or a point list there for the build to read is legitimate
247
+ // authoring and none of lint's business.
248
+ id: "control-default-not-primitive",
249
+ run: ({ part }) => {
250
+ // Same guard as control-key-not-in-defaults: bail only when `defaults`
251
+ // isn't a plain object at all (missing-defaults already reports that).
252
+ if (!isPlainObject(part?.defaults)) return [];
253
+ const defaults = part.defaults;
254
+ return collectDescriptors(part)
255
+ .filter(({ container }) => !container)
256
+ .filter(({ d }) => typeof d.key === "string" && Object.hasOwn(defaults, d.key))
257
+ .filter(({ d }) => !isEditableValue(defaults[d.key]))
258
+ .map(({ d, path }) => err("control-default-not-primitive",
259
+ `control "${d.key}" is bound to ${describeValue(defaults[d.key])} default`,
260
+ `Give "${d.key}" a number, string or boolean default — a control writes one scalar, so it cannot edit this, and a host that saves panel settings back into \`defaults\` cannot write it either.`,
261
+ `${path}.key`));
262
+ },
263
+ },
194
264
  {
195
265
  id: "preset-key-not-in-defaults",
196
266
  run: ({ part }) => {
@@ -0,0 +1,108 @@
1
+ // Group 9 — source-text rules. These run only when the caller hands lintPart
2
+ // the part's SOURCE alongside the evaluated definition, because they exist
3
+ // precisely for the defects evaluation erases: `13 / 3` evaluates to a plain
4
+ // number, so the evaluated-object rules cannot see it, yet a host that
5
+ // persists panel settings by rewriting the defaults literal cannot write that
6
+ // value back — the control moves, the build is green, and the user's edit is
7
+ // silently gone on reload. Findings here carry `file` and `line` on top of
8
+ // the standard shape.
9
+ import { err, warn } from "./finding.js";
10
+ import { controlBoundKeys } from "./rules-schema.js";
11
+ import { pickDefaultsFile, stripNonCode, lineOf } from "./source-scan.js";
12
+
13
+ const MAX_SOURCE_CHARS = 48;
14
+ // The quoted source is LLM- or user-authored text on its way into a JSON
15
+ // diagnostics channel, so control characters come out along with the whitespace
16
+ // collapse — a raw C0 byte in a finding message is an escaping hazard for every
17
+ // consumer downstream, and it renders as nothing useful anyway.
18
+ const describeSource = (raw) => {
19
+ const flat = String(raw ?? "").replace(/\s+/g, " ").replace(/[\u0000-\u001f\u007f]/g, "").trim();
20
+ return flat.length > MAX_SOURCE_CHARS ? `${flat.slice(0, MAX_SOURCE_CHARS - 1)}…` : flat;
21
+ };
22
+
23
+ // Impurity tokens a source scan can see that the behavioral probe (which runs
24
+ // build() twice and diffs the recorded calls) can miss when the impure value
25
+ // is stable within one probe pass. `new Date()` matches only the ARGLESS
26
+ // form: `new Date(0)` is deterministic and legal.
27
+ //
28
+ // Scope limit (documented in AUTHORING-PARTS.md → Rule catalog → Source rules):
29
+ // stripNonCode blanks whole template interiors, so a token inside a `${…}`
30
+ // interpolation is not seen by this rule.
31
+ const IMPURE_TOKENS = [
32
+ { re: /\bMath\s*\.\s*random\b/g, token: "Math.random" },
33
+ { re: /\bDate\s*\.\s*now\b/g, token: "Date.now" },
34
+ { re: /\bperformance\s*\.\s*now\b/g, token: "performance.now" },
35
+ { re: /\bnew\s+Date\s*\(\s*\)/g, token: "new Date()" },
36
+ ];
37
+
38
+ export const SOURCE_RULES = [
39
+ {
40
+ id: "control-default-not-literal",
41
+ run: ({ part, sources }) => {
42
+ if (!sources) return [];
43
+ const picked = pickDefaultsFile(sources.files, sources.entrypoint);
44
+ if (!picked) return [];
45
+ // `excludeHidden` because a STATICALLY hidden control renders no widget,
46
+ // so a panel save can never lose an edit to it — and `hidden: true` is
47
+ // the documented idiom for an internal constant, exactly the place an
48
+ // author legitimately writes an expression. A `when`-conditioned control
49
+ // stays in: it can appear, so its default must be writable.
50
+ const bound = controlBoundKeys(part, { excludeHidden: true });
51
+ return picked.entries
52
+ .filter((e) => e.key !== null && bound.has(e.key) && !e.readable)
53
+ .map((e) => ({
54
+ ...err("control-default-not-literal",
55
+ `control "${e.key}" has a default written as \`${describeSource(e.raw)}\`, which a panel-settings save cannot write back`,
56
+ `Write the computed value as a plain decimal number, quoted string or boolean literal (e.g. \`4.333\` instead of \`13 / 3\`), or move the computation into \`derive()\`. Hosts persist panel edits by rewriting this value in the source, so a spelling the rewriter cannot read silently loses the user's changes when the part is reopened.`,
57
+ `defaults.${e.key}`,
58
+ "control-default-not-literal"),
59
+ file: picked.path,
60
+ line: lineOf(picked.source, e.index),
61
+ }));
62
+ },
63
+ },
64
+ {
65
+ id: "impure-source-token",
66
+ run: ({ sources }) => {
67
+ if (!sources?.files) return [];
68
+ const out = [];
69
+ for (const [path, source] of Object.entries(sources.files)) {
70
+ if (typeof source !== "string") continue;
71
+ // Code files only. A part tree can carry prose and data (a README, a
72
+ // profile JSON), and `Date.now()` written in a sentence is not an
73
+ // impure build — the scanner's comment/string blanking has no purchase
74
+ // on a file that is not JS at all.
75
+ if (!/\.m?js$/.test(path)) continue;
76
+ const code = stripNonCode(source);
77
+ for (const { re, token } of IMPURE_TOKENS) {
78
+ // ONE finding per (file, token), carrying the occurrence count and
79
+ // the first occurrence's line. Per-occurrence findings are unbounded:
80
+ // a part looping `Math.random()` a few thousand times produced ~1.4 MB
81
+ // of identical findings on a channel an LLM reads. The count is the
82
+ // information; the rest was repetition.
83
+ re.lastIndex = 0;
84
+ let count = 0;
85
+ let firstIndex = 0;
86
+ for (let m; (m = re.exec(code)); ) {
87
+ if (count === 0) firstIndex = m.index;
88
+ count++;
89
+ }
90
+ if (count === 0) continue;
91
+ out.push({
92
+ ...warn("impure-source-token",
93
+ `\`${token}\`${count > 1 ? ` ×${count}` : ""} in ${path} — an impure build silently returns stale geometry`,
94
+ "A build must be a pure function of (k, p, d): the preview kernel memoizes geometry by content hash, so a value that changes between calls silently serves stale geometry instead of rebuilding. Replace the impure value with a parameter or a derive() output.",
95
+ "",
96
+ "impure-source-token"),
97
+ file: path,
98
+ line: lineOf(code, firstIndex),
99
+ });
100
+ }
101
+ }
102
+ // Deterministic order for stable reports: by file, then line.
103
+ return out.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1));
104
+ },
105
+ },
106
+ ];
107
+
108
+ export const SOURCE_RULE_IDS = new Set(SOURCE_RULES.map((r) => r.id));
@@ -0,0 +1,333 @@
1
+ // Source-text scanning for partforge/lint's SOURCE_RULES. The scanner is a
2
+ // verbatim port of the READ half of partforge-cloud's
3
+ // src/parts/partDefaults.js — the module that persists panel settings by
4
+ // rewriting the defaults literal. THE READABILITY PREDICATE MUST MATCH THAT
5
+ // REWRITER EXACTLY: a value this scanner passes but the rewriter refuses is a
6
+ // silent regression of the incident the control-default-not-literal rule
7
+ // exists to prevent (a `13 / 3` default that builds green and loses the
8
+ // user's panel edits). partforge-cloud carries a parity test against a shared
9
+ // fixture corpus; change the predicate only in both places together.
10
+ //
11
+ // Zero dependencies, no AST: this file must stay inside lint's pure import
12
+ // closure (test/lint-purity.test.js) so lintPart keeps running in Node, the
13
+ // browser sandbox iframe, and Deno.
14
+ //
15
+ // KNOWN BLIND SPOT — regex literals are not tokenized: a `/[/*]/` or `/x\/y/`
16
+ // reads as a comment opener and can blank the rest of the file, and a regex
17
+ // containing a quote can likewise derail string skipping.
18
+ // It is parity-correct (the cloud rewriter has the same gap) and fails toward
19
+ // FALSE NEGATIVES — a derailed scan finds no defaults literal and no tokens,
20
+ // so a rule says nothing rather than something wrong.
21
+
22
+ // Span of the object literal after the first `defaults:` key (indices into
23
+ // `source`, end exclusive, covering `{...}`). String- and comment-aware so a
24
+ // "defaults: {" inside a string or comment can't fool the scan.
25
+ export function findDefaultsLiteral(source) {
26
+ if (typeof source !== "string") return null;
27
+ let i = 0;
28
+ while (i < source.length) {
29
+ const c = source[i];
30
+ if (c === '"' || c === "'" || c === "`") {
31
+ const quote = c;
32
+ i++;
33
+ while (i < source.length && source[i] !== quote) i += source[i] === "\\" ? 2 : 1;
34
+ i++;
35
+ continue;
36
+ }
37
+ if (c === "/" && source[i + 1] === "/") { while (i < source.length && source[i] !== "\n") i++; continue; }
38
+ if (c === "/" && source[i + 1] === "*") { const j = source.indexOf("*/", i + 2); if (j === -1) return null; i = j + 2; continue; }
39
+ const m = /^defaults\s*:\s*\{/.exec(source.slice(i));
40
+ if (m && (i === 0 || /[\s{,]/.test(source[i - 1]))) {
41
+ const start = i + m[0].length - 1; // the "{"
42
+ let depth = 0;
43
+ let k = start;
44
+ while (k < source.length) {
45
+ const ch = source[k];
46
+ if (ch === '"' || ch === "'" || ch === "`") {
47
+ const quote = ch;
48
+ k++;
49
+ while (k < source.length && source[k] !== quote) k += source[k] === "\\" ? 2 : 1;
50
+ k++;
51
+ continue;
52
+ }
53
+ if (ch === "/" && source[k + 1] === "/") { while (k < source.length && source[k] !== "\n") k++; continue; }
54
+ if (ch === "/" && source[k + 1] === "*") { const j = source.indexOf("*/", k + 2); if (j === -1) return null; k = j + 2; continue; }
55
+ if (ch === "{") depth++;
56
+ else if (ch === "}") { depth--; if (depth === 0) return { start, end: k + 1 }; }
57
+ k++;
58
+ }
59
+ return null;
60
+ }
61
+ i++;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ // --- entry scanning -------------------------------------------------------
67
+ //
68
+ // Every helper here takes an explicit `end` bound and reports an index back,
69
+ // so a run it cannot terminate (an unclosed string, a runaway comment) stops
70
+ // at the bound instead of walking off the literal.
71
+
72
+ // Whitespace and comments from `i`.
73
+ function skipTrivia(text, i, end) {
74
+ for (;;) {
75
+ while (i < end && /\s/.test(text[i])) i++;
76
+ if (text[i] === "/" && text[i + 1] === "/") { while (i < end && text[i] !== "\n") i++; continue; }
77
+ if (text[i] === "/" && text[i + 1] === "*") {
78
+ const j = text.indexOf("*/", i + 2);
79
+ if (j === -1 || j >= end) return end;
80
+ i = j + 2;
81
+ continue;
82
+ }
83
+ return i;
84
+ }
85
+ }
86
+
87
+ // Index just past a '…' / "…" run starting at its own quote.
88
+ function skipQuoted(text, i, end) {
89
+ const quote = text[i];
90
+ i++;
91
+ while (i < end && text[i] !== quote) i += text[i] === "\\" ? 2 : 1;
92
+ return Math.min(i + 1, end);
93
+ }
94
+
95
+ // Index just past a `…` run, stepping over ${…} interpolations so a comma or
96
+ // brace inside one cannot end the value early.
97
+ function skipTemplate(text, i, end) {
98
+ i++;
99
+ while (i < end) {
100
+ const c = text[i];
101
+ if (c === "\\") { i += 2; continue; }
102
+ if (c === "`") return i + 1;
103
+ if (c === "$" && text[i + 1] === "{") { i = skipInterpolation(text, i + 2, end); continue; }
104
+ i++;
105
+ }
106
+ return end;
107
+ }
108
+
109
+ function skipInterpolation(text, i, end) {
110
+ let depth = 1;
111
+ while (i < end) {
112
+ const c = text[i];
113
+ if (c === '"' || c === "'") { i = skipQuoted(text, i, end); continue; }
114
+ if (c === "`") { i = skipTemplate(text, i, end); continue; }
115
+ if (c === "{") depth++;
116
+ else if (c === "}" && --depth === 0) return i + 1;
117
+ i++;
118
+ }
119
+ return end;
120
+ }
121
+
122
+ // The end of one value: the next top-level `,` or the literal's own `}`.
123
+ // Bracket-, string- and comment-aware, so an expression, an array or a nested
124
+ // object is spanned WHOLE even though it will not be read.
125
+ function scanValueEnd(text, i, end) {
126
+ let depth = 0;
127
+ while (i < end) {
128
+ const c = text[i];
129
+ if (c === '"' || c === "'") { i = skipQuoted(text, i, end); continue; }
130
+ if (c === "`") { i = skipTemplate(text, i, end); continue; }
131
+ if (c === "/" && (text[i + 1] === "/" || text[i + 1] === "*")) { i = skipTrivia(text, i, end); continue; }
132
+ if (c === "(" || c === "[" || c === "{") { depth++; i++; continue; }
133
+ if (c === ")" || c === "]" || c === "}") { if (depth === 0) return i; depth--; i++; continue; }
134
+ if (c === "," && depth === 0) return i;
135
+ i++;
136
+ }
137
+ return end;
138
+ }
139
+
140
+ // JS's own single-character escapes. An escape outside this set is the
141
+ // character itself (`\a` is "a"), which is JS's rule rather than a guess;
142
+ // the sequences that are NOT decodable that way — legacy octal, a malformed
143
+ // \x/\u — return null and leave the key un-editable.
144
+ const SIMPLE_ESCAPES = { n: "\n", t: "\t", r: "\r", b: "\b", f: "\f", v: "\v", 0: "\0" };
145
+
146
+ // A quoted literal (quotes included) → its string value, or null when it is
147
+ // not exactly one complete string.
148
+ function decodeStringLiteral(raw) {
149
+ const quote = raw[0];
150
+ if ((quote !== '"' && quote !== "'") || raw.length < 2 || raw[raw.length - 1] !== quote) return null;
151
+ let out = "";
152
+ for (let i = 1; i < raw.length - 1; i++) {
153
+ const c = raw[i];
154
+ if (c === quote) return null; // closed early: this is not one string
155
+ if (c === "\n" || c === "\r") return null; // an unescaped newline is a syntax error
156
+ if (c !== "\\") { out += c; continue; }
157
+ const e = raw[++i];
158
+ if (e === undefined) return null;
159
+ if (e === "\n" || e === "\u2028" || e === "\u2029") continue; // line continuation
160
+ if (e === "\r") { if (raw[i + 1] === "\n") i++; continue; }
161
+ if (e === "x") {
162
+ const h = raw.slice(i + 1, i + 3);
163
+ if (!/^[0-9a-fA-F]{2}$/.test(h)) return null;
164
+ out += String.fromCharCode(Number.parseInt(h, 16));
165
+ i += 2;
166
+ continue;
167
+ }
168
+ if (e === "u") {
169
+ if (raw[i + 1] === "{") {
170
+ const close = raw.indexOf("}", i + 2);
171
+ const h = close === -1 ? "" : raw.slice(i + 2, close);
172
+ if (!/^[0-9a-fA-F]{1,6}$/.test(h) || Number.parseInt(h, 16) > 0x10ffff) return null;
173
+ out += String.fromCodePoint(Number.parseInt(h, 16));
174
+ i = close;
175
+ continue;
176
+ }
177
+ const h = raw.slice(i + 1, i + 5);
178
+ if (!/^[0-9a-fA-F]{4}$/.test(h)) return null;
179
+ out += String.fromCharCode(Number.parseInt(h, 16));
180
+ i += 4;
181
+ continue;
182
+ }
183
+ if (/[1-9]/.test(e) || (e === "0" && /\d/.test(raw[i + 1] ?? ""))) return null; // legacy octal
184
+ out += Object.hasOwn(SIMPLE_ESCAPES, e) ? SIMPLE_ESCAPES[e] : e;
185
+ }
186
+ return out;
187
+ }
188
+
189
+ // Numeric literals JS accepts and this module can round-trip. Deliberately not
190
+ // hex, separators or bigint: those stay un-editable rather than being rewritten
191
+ // into a different spelling of themselves.
192
+ const NUMBER_RE = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
193
+
194
+ // One value's source text → { value }, or null when it is not a primitive
195
+ // literal this module can read AND write back.
196
+ function readValue(raw) {
197
+ if (raw === "true") return { value: true };
198
+ if (raw === "false") return { value: false };
199
+ if (NUMBER_RE.test(raw)) {
200
+ const n = Number(raw);
201
+ return Number.isFinite(n) ? { value: n } : null;
202
+ }
203
+ const q = raw[0];
204
+ if (q === '"' || q === "'") {
205
+ const s = decodeStringLiteral(raw);
206
+ return s === null ? null : { value: s };
207
+ }
208
+ return null;
209
+ }
210
+
211
+ // A key at `i`: identifier or quoted string. → { key, next } or null.
212
+ function readKey(text, i, end) {
213
+ const q = text[i];
214
+ if (q === '"' || q === "'") {
215
+ const next = skipQuoted(text, i, end);
216
+ const key = decodeStringLiteral(text.slice(i, next));
217
+ return key === null ? null : { key, next };
218
+ }
219
+ const m = /^[A-Za-z_$][\w$]*/.exec(text.slice(i, end));
220
+ return m ? { key: m[0], next: i + m[0].length } : null;
221
+ }
222
+
223
+ // Split `{ … }` into entries in source order. `readable` says whether the
224
+ // value was interpreted; `raw` is its exact source text either way, and
225
+ // valueStart/valueEnd are its span (indices into `text`), which is what a
226
+ // rewrite splices. An entry that is not a `key: value` pair at all — a spread,
227
+ // a computed key, a method — is spanned and kept as an unreadable, unnamed
228
+ // entry so the rest of the literal still reads.
229
+ //
230
+ // Null ONLY when `text` is not a braced object; an object whose every entry is
231
+ // unreadable is a valid scan of zero readable keys.
232
+ function scanEntries(text) {
233
+ const open = skipTrivia(text, 0, text.length);
234
+ if (text[open] !== "{") return null;
235
+ let close = text.length;
236
+ while (close > open && /\s/.test(text[close - 1])) close--;
237
+ if (text[close - 1] !== "}") return null;
238
+ const end = close - 1; // the closing brace
239
+ const entries = [];
240
+ let i = open + 1;
241
+ for (;;) {
242
+ i = skipTrivia(text, i, end);
243
+ if (i >= end) return entries;
244
+ const start = i;
245
+ const k = readKey(text, i, end);
246
+ let entry = null;
247
+ if (k) {
248
+ const afterKey = skipTrivia(text, k.next, end);
249
+ if (text[afterKey] === ":") {
250
+ const valueStart = skipTrivia(text, afterKey + 1, end);
251
+ let valueEnd = scanValueEnd(text, valueStart, end);
252
+ while (valueEnd > valueStart && /\s/.test(text[valueEnd - 1])) valueEnd--;
253
+ const raw = text.slice(valueStart, valueEnd);
254
+ const read = readValue(raw);
255
+ entry = { key: k.key, valueStart, valueEnd, raw, readable: !!read, value: read?.value };
256
+ i = valueEnd;
257
+ }
258
+ }
259
+ if (!entry) {
260
+ const stop = scanValueEnd(text, start, end);
261
+ entry = { key: null, valueStart: start, valueEnd: stop, raw: text.slice(start, stop), readable: false };
262
+ i = stop;
263
+ }
264
+ entries.push(entry);
265
+ i = skipTrivia(text, i, end);
266
+ if (text[i] !== ",") return entries; // no separator: nothing further can be read
267
+ i++;
268
+ }
269
+ }
270
+
271
+ // The literal's entries with ABSOLUTE value offsets into `source`, so a rule
272
+ // can report file + line. Null when there is no defaults literal (or it is
273
+ // unscannable) — a rule treats that as "nothing to say", never an error.
274
+ export function defaultsEntriesIn(source) {
275
+ if (typeof source !== "string") return null;
276
+ const span = findDefaultsLiteral(source);
277
+ if (!span) return null;
278
+ const entries = scanEntries(source.slice(span.start, span.end));
279
+ if (!entries) return null;
280
+ return entries.map((e) => ({
281
+ key: e.key, raw: e.raw, readable: e.readable, index: span.start + e.valueStart,
282
+ }));
283
+ }
284
+
285
+ // Which file the settings session would write — same preference order as the
286
+ // cloud rewriter's findDefaultsFile/readDefaultsEntries: entrypoint first, then
287
+ // any file whose literal has a readable entry. The third tier — falling back
288
+ // to the first literal found anywhere, even wholly unreadable — is a
289
+ // DELIBERATE divergence from cloud's findDefaultsFile, which returns null
290
+ // there: a lint rule still needs to name the right file for a part whose only
291
+ // defaults literal has no readable entry at all.
292
+ export function pickDefaultsFile(files, entrypoint) {
293
+ if (!files || typeof files !== "object") return null;
294
+ const paths = [entrypoint, ...Object.keys(files).filter((p) => p !== entrypoint)]
295
+ .filter((p) => typeof p === "string" && typeof files[p] === "string");
296
+ let firstFound = null;
297
+ for (const path of paths) {
298
+ const entries = defaultsEntriesIn(files[path]);
299
+ if (!entries) continue;
300
+ const found = { path, source: files[path], entries };
301
+ if (entries.some((e) => e.readable)) return found;
302
+ firstFound ??= found;
303
+ }
304
+ return firstFound;
305
+ }
306
+
307
+ // `source` with every string, template and comment interior blanked to
308
+ // spaces — same length, newlines preserved — so a token scan over the result
309
+ // can never match inside a string or comment and lineOf stays accurate.
310
+ export function stripNonCode(source) {
311
+ if (typeof source !== "string") return "";
312
+ const out = source.split("");
313
+ const blank = (from, to) => {
314
+ for (let i = from; i < to && i < out.length; i++) if (out[i] !== "\n") out[i] = " ";
315
+ };
316
+ let i = 0;
317
+ while (i < source.length) {
318
+ const c = source[i];
319
+ if (c === '"' || c === "'") { const end = skipQuoted(source, i, source.length); blank(i + 1, end - 1); i = end; continue; }
320
+ if (c === "`") { const end = skipTemplate(source, i, source.length); blank(i + 1, end - 1); i = end; continue; }
321
+ if (c === "/" && source[i + 1] === "/") { let j = i + 2; while (j < source.length && source[j] !== "\n") j++; blank(i, j); i = j; continue; }
322
+ if (c === "/" && source[i + 1] === "*") { const j = source.indexOf("*/", i + 2); const end = j === -1 ? source.length : j + 2; blank(i, end); i = end; continue; }
323
+ i++;
324
+ }
325
+ return out.join("");
326
+ }
327
+
328
+ export function lineOf(text, index) {
329
+ if (typeof text !== "string") return 1;
330
+ let n = 1;
331
+ for (let i = 0; i < index && i < text.length; i++) if (text[i] === "\n") n++;
332
+ return n;
333
+ }
package/src/lint.js CHANGED
@@ -1,3 +1,8 @@
1
1
  // Public entry for `partforge/lint`. Deliberately separate from `partforge/testing`,
2
2
  // whose entry pulls in the WASM kernels and cannot load in a browser sandbox.
3
3
  export { lintPart, RULES } from "./framework/lint/index.js";
4
+ // The ids of the rules that read SOURCE rather than the evaluated part. Hosts
5
+ // that gate rendering on lint errors (partforge-cloud's sandbox loader) use
6
+ // this to keep source findings REPORTED but non-blocking: a persistence
7
+ // defect must not stop a legacy part from rendering.
8
+ export { SOURCE_RULE_IDS } from "./framework/lint/rules-source.js";
package/types/lint.d.ts CHANGED
@@ -29,6 +29,16 @@ export interface Finding {
29
29
  * `""` for findings about the definition as a whole. For navigation only.
30
30
  */
31
31
  path: string;
32
+ /**
33
+ * Present on source-rule findings: the tree path of the file the finding was
34
+ * read from, as keyed in `sources.files`.
35
+ */
36
+ file?: string;
37
+ /**
38
+ * Present on source-rule findings: the 1-indexed line of the offending source
39
+ * within `file`.
40
+ */
41
+ line?: number;
32
42
  /** A stable ERROR-PATTERNS.md entry id, when one applies. */
33
43
  pattern?: string;
34
44
  }
@@ -42,15 +52,32 @@ export interface LintReport {
42
52
  notes: Finding[];
43
53
  }
44
54
 
55
+ /**
56
+ * The part's own source text, keyed by tree path — `entrypoint` names the file
57
+ * holding the `PartDefinition` (the first key when omitted). Handing this over
58
+ * unlocks the source rules, which read the text the evaluated definition has
59
+ * already erased.
60
+ */
61
+ export interface LintSources {
62
+ files: Record<string, string>;
63
+ entrypoint?: string;
64
+ }
65
+
45
66
  /**
46
67
  * Lint a PartDefinition. NEVER throws — a rule that throws yields an
47
68
  * `internal-rule-error` warning and the run continues.
48
69
  *
49
70
  * @param part - the default-exported PartDefinition (deliberately `unknown`:
50
71
  * lint's whole job is to be handed something that may not be one).
51
- * @param opts - `params` are layered over `part.defaults` for the probe pass.
72
+ * @param opts - `params` are layered over `part.defaults` for the probe pass;
73
+ * `sources` is the part's own source text. Omitting `sources` (or handing
74
+ * over a malformed one) makes the source rules a silent no-op — they are
75
+ * never a reason for lint to fail.
52
76
  */
53
- export function lintPart(part: unknown, opts?: { params?: ResolvedParams } | null): LintReport;
77
+ export function lintPart(
78
+ part: unknown,
79
+ opts?: { params?: ResolvedParams; sources?: LintSources } | null,
80
+ ): LintReport;
54
81
 
55
82
  /** The shared context a rule reads. */
56
83
  export interface LintContext {
@@ -69,6 +96,13 @@ export interface LintContext {
69
96
  probeAgain(): unknown;
70
97
  /** `verify.expect` resolved once per lint pass. */
71
98
  resolveExpectOnce(): unknown;
99
+ /**
100
+ * The normalized `opts.sources`, or `null` when none was handed over (or none
101
+ * survived normalization). The source rules return no findings when it is
102
+ * `null`. Optional: `lintContext` builds the context without it, and the
103
+ * field is assigned separately by `lintPart`.
104
+ */
105
+ sources?: LintSources | null;
72
106
  }
73
107
 
74
108
  export interface LintRule {
@@ -82,4 +116,12 @@ export interface LintRule {
82
116
  */
83
117
  export const RULES: LintRule[];
84
118
 
119
+ /**
120
+ * The ids of the rules that read SOURCE rather than the evaluated part. A host
121
+ * that gates rendering on lint errors uses this to keep source findings
122
+ * REPORTED but non-blocking: a persistence defect is not a reason to refuse to
123
+ * render a part that builds.
124
+ */
125
+ export const SOURCE_RULE_IDS: ReadonlySet<string>;
126
+
85
127
  export type { PartDefinition };