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.
@@ -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
+ }