partforge 0.76.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 +25 -5
- package/docs/AUTHORING-PARTS.md +36 -0
- package/docs/ERROR-PATTERNS.md +12 -0
- package/package.json +1 -1
- 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/lint.js +5 -0
- package/types/lint.d.ts +44 -2
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
|
|
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
|
-
|
|
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
|
}
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -1878,6 +1878,16 @@ import { lintPart } from "partforge/lint";
|
|
|
1878
1878
|
const { ok, errors, warnings } = lintPart(part, { params });
|
|
1879
1879
|
```
|
|
1880
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
|
+
|
|
1881
1891
|
`partforge/lint` has **zero runtime dependencies** and never imports a geometry
|
|
1882
1892
|
kernel or the DOM viewer, so it runs unchanged in Node, a Web Worker, a sandboxed
|
|
1883
1893
|
iframe, and Deno. A worker also answers `{ type: "lint", params }` with
|
|
@@ -2056,6 +2066,32 @@ control that the control's own `allow` list would refuse — at build time it's
|
|
|
2056
2066
|
swapped for `defaults[key]`, i.e. itself, so the part boots with no usable
|
|
2057
2067
|
font; use a source `allow` accepts, or widen `allow`) (warning).
|
|
2058
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
|
+
|
|
2059
2095
|
A rule that itself throws yields an `internal-rule-error` **warning** and the run
|
|
2060
2096
|
continues: `lintPart` never throws and never blocks a part because of a linter bug.
|
|
2061
2097
|
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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
|
@@ -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
|
|
@@ -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,28 @@ 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
|
+
|
|
107
135
|
// What a control can actually edit: one finite scalar. Non-finite numbers are out
|
|
108
136
|
// with the rest — a slider cannot show NaN, and neither NaN nor Infinity survives
|
|
109
137
|
// a round trip through JSON or a source rewrite.
|
|
@@ -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(
|
|
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 };
|