partforge 0.76.0 → 0.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +150 -6
- package/docs/AUTHORING-PARTS.md +196 -0
- package/docs/ERROR-PATTERNS.md +42 -0
- package/package.json +1 -1
- package/src/framework/jobs.js +31 -0
- package/src/framework/lint/index.js +37 -3
- package/src/framework/lint/rules-schema.js +39 -11
- package/src/framework/lint/rules-source.js +108 -0
- package/src/framework/lint/source-scan.js +333 -0
- package/src/framework/oracle/describe/accept.js +188 -0
- package/src/framework/oracle/describe/features/dressups.js +173 -0
- package/src/framework/oracle/describe/features/holes.js +129 -0
- package/src/framework/oracle/describe/features/prismatic.js +454 -0
- package/src/framework/oracle/describe/features/sweeps.js +233 -0
- package/src/framework/oracle/describe/fit.js +535 -0
- package/src/framework/oracle/describe/hints.js +91 -0
- package/src/framework/oracle/describe/limits.js +19 -0
- package/src/framework/oracle/describe/patterns.js +494 -0
- package/src/framework/oracle/describe/ransac.js +391 -0
- package/src/framework/oracle/describe/report.js +217 -0
- package/src/framework/oracle/describe/segment.js +498 -0
- package/src/framework/oracle/describe/snap.js +83 -0
- package/src/framework/oracle/describe/surface-graph.js +396 -0
- package/src/framework/oracle/describe/topology.js +121 -0
- package/src/framework/oracle/describe.js +538 -0
- package/src/lint.js +5 -0
- package/src/testing.js +5 -0
- package/types/lint.d.ts +44 -2
- package/types/testing.d.ts +178 -0
|
@@ -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
|
+
}
|