@svelte-vitals/core 0.50.0 → 0.51.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/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal.d.ts +44 -189
- package/dist/internal.js +2293 -33
- package/dist/{json-CcKHN-tx.d.ts → json-D26FMrh-.d.ts} +92 -150
- package/dist/{markdown-CEYpv4SU.js → markdown-CD7sqanK.js} +583 -2714
- package/package.json +2 -2
package/dist/internal.js
CHANGED
|
@@ -1,5 +1,2236 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as SECTIONING_TAGS, $n as settingSeverity, $t as valueFromNodes, A as a11yAbbrTitle, An as seoDescriptionLength, At as collectDerivedGuardBindings, B as a11yUseList, Bn as seoOgUrl, Bt as unwrapTs, C as a11yDisallowedElement, Cn as seoHeadingLevelSkip, Ct as parseKitModuleFacts, D as a11yDeprecatedElement, Dn as seoHreflang, Dt as addBoundNames, E as a11yDeprecatedAttr, En as seoSingleH1, Et as WALK_IGNORED_KEYS, F as isInteractiveContainer, Fn as seoJsonLdRelativeUrl, Ft as matchLifecycleCall, G as a11yInvalidAriaValue, Gn as resolveRuleOptions, Gt as attrText, H as a11yAccessibleName, Hn as seoTwitterCard, Ht as walkEvalScope, I as isInteractiveElement, In as seoJsonLdDeprecatedType, It as parseModuleProgram, J as a11yInvalidRole, Jn as applyOverrides, Jt as attrValueOf, K as a11yRequiredAriaProps, Kn as shouldSkipRangeCheck, Kt as attrTextOf, L as literalTabindexValue, Ln as seoJsonLdValidity, Lt as rootObjectName, M as a11yNoAutofocus, Mn as seoJsonLdRequiredProps, Mt as collectProgramBindings, N as a11yNoAccesskey, Nn as seoJsonLdPlaceholder, Nt as collectSuppressions, O as HTML_SPEC, On as seoImageAlt, Ot as collectBrowserGlobalRefs, P as a11yPositiveTabindex, Pn as seoJsonLdDateFormat, Pt as collectSvelteLifecycleImports, Q as NAMING_ATTRS, Qn as selectRules, Qt as textFromNodes, R as a11yRequireDatetime, Rn as seoSitemapInRobots, Rt as scopeIntroducedNames, S as a11yPermittedContents, Sn as seoSsrDisabled, Sr as isPenalized, St as parseInMemoryExports, T as a11yDisallowedAriaProps, Tn as seoDuplicateTitle, Tr as defaultProject, Tt as isRootRelativePath, U as a11yAriaHiddenFocus, Un as seoIndexability, Ut as walkScoped, V as a11yLabelHasControl, Vn as seoOgDescription, Vt as walkEstree, W as a11yInteractiveNesting, Wn as performancePreconnect, Wt as CHILD_NODE_KEYS, X as ASIDE_DEMOTING_TAGS, Xn as compileOverrides, Xt as lineOf, Y as ANCESTRY_DEPENDENT_TAGS, Yn as applyRuleSeverities, Yt as findAttr, Z as IDREF_ATTRS, Zn as formatFailedRuleWarning, Zt as parseSvelte, _ as a11yIdDuplication, _n as correctnessUnmutatedState, _r as classify, _t as architectureReservedDirectoryNames, a as formatJsonReport, an as securityJavascriptUrl, ar as performanceResponsiveImage, at as stripTextDirective, b as a11yDoctype, bn as correctnessEachIndexKey, br as summarize, bt as architecturePrivateScopeImport, c as formatGithubReport, cn as correctnessServerBrowserGlobal, cr as seoHtmlLang, ct as performanceStateRaw, d as ruleMetaById, dn as correctnessOrphanEffect, dr as seoJsonLd, dt as performanceMinifyDisabled, en as architecturePropCount, er as withFailedRulesOff, et as decodeFragmentId, f as severityToSarifLevel, fn as correctnessAutoplayMuted, fr as seoOgTitle, ft as performanceNamespaceImport, g as a11yNoMissingIdRef, gn as correctnessPropMutation, gr as seoTitlePresence, gt as architectureReservedNamePlacement, h as a11yUnverifiedIdRef, hn as correctnessStalePropDerivation, hr as seoDescriptionPresence, ht as architectureRouteComponentImport, i as buildJsonReport, in as securityHandlerStateWrite, ir as performancePreloadMissingAs, it as splitTokens, j as a11yNoDuplicateDt, jn as seoTitleLength, jt as collectNamedImportAliases, k as a11yPatternTitle, kn as seoCharset, kt as collectBrowserGuardImports, l as SEVERITY_RANK, ln as correctnessBasePathNavigation, lr as seoSitemapXml, lt as performanceSequentialAwaits, m as explainRule, mn as correctnessNonreactiveBuiltinState, mr as seoCanonicalUrl, mt as architectureDocLinkTarget, n as mdEscape, nn as securitySharedStateImport, nr as performanceLcpImage, nt as isTopFragment, o as computeHealth, on as securityRawHtml, or as performanceImageLoadingHint, ot as resolveRole, p as allRules, pn as correctnessCheckableBindValue, pr as seoOgImage, pt as performanceHeavyImport, q as a11yUnknownAriaAttribute, qn as validateRuleSetting, qt as attrValue, r as terminalSafe, rn as securityServerModuleState, rr as performanceFontPreloadCrossorigin, rt as resolveLandmark, s as computeScore, sn as correctnessInstanceBrowserGlobal, sr as performanceImageDimensions, st as performanceIframeLoading, t as formatMarkdownReport, tn as architectureComponentSize, tr as performanceRenderBlockingScript, tt as foldOccurrences, u as messageText, un as correctnessOrphanLifecycle, ur as seoRobotsTxt, ut as performanceLoadWaterfall, v as a11yTopLevelLandmark, vn as correctnessEffectAsOnMount, vr as effectiveSeverity, vt as architectureDirectoryNaming, w as a11yDeprecatedAria, wn as seoDuplicateDescription, wr as defaultConfig, wt as resolveRepoLocalPath, x as a11yRequiredElement, xn as correctnessEachKey, xr as docsUrlFor, xt as collectTopLevelBindings, y as a11yDuplicateLandmark, yn as correctnessEffectAsDerived, yr as hasFailureAtOrAbove, yt as architectureUnitEntryFile, z as a11yPlaceholderLabelOption, zn as seoViewport, zt as unwrapExport } from "./markdown-CD7sqanK.js";
|
|
2
|
+
//#region src/head.ts
|
|
3
|
+
const JS_MIME_TYPES = /* @__PURE__ */ new Set([
|
|
4
|
+
"application/ecmascript",
|
|
5
|
+
"application/javascript",
|
|
6
|
+
"application/x-ecmascript",
|
|
7
|
+
"application/x-javascript",
|
|
8
|
+
"text/ecmascript",
|
|
9
|
+
"text/javascript",
|
|
10
|
+
"text/javascript1.0",
|
|
11
|
+
"text/javascript1.1",
|
|
12
|
+
"text/javascript1.2",
|
|
13
|
+
"text/javascript1.3",
|
|
14
|
+
"text/javascript1.4",
|
|
15
|
+
"text/javascript1.5",
|
|
16
|
+
"text/jscript",
|
|
17
|
+
"text/livescript",
|
|
18
|
+
"text/x-ecmascript",
|
|
19
|
+
"text/x-javascript"
|
|
20
|
+
]);
|
|
21
|
+
/** HTML strips only ASCII whitespace from a `type` before matching — `trim()` would also strip
|
|
22
|
+
* U+00A0 etc. and misread a data block as a classic script. */
|
|
23
|
+
const ASCII_WHITESPACE_EDGES = /^[\t\n\f\r ]+|[\t\n\f\r ]+$/g;
|
|
24
|
+
/**
|
|
25
|
+
* Whether a `type` attribute (undefined = absent) makes a `<script>` a classic,
|
|
26
|
+
* render-blocking-capable script. Per the HTML spec, absent and the literal empty string are
|
|
27
|
+
* classic; any other value is ASCII-whitespace-stripped and must match a JavaScript MIME type —
|
|
28
|
+
* so a whitespace-only `type` is a data block, not a classic script.
|
|
29
|
+
*/
|
|
30
|
+
function isClassicScriptType(type) {
|
|
31
|
+
if (type === void 0 || type === "") return true;
|
|
32
|
+
return JS_MIME_TYPES.has(type.replace(ASCII_WHITESPACE_EDGES, "").toLowerCase());
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/component-parse.ts
|
|
36
|
+
const KNOWN_TAGS = new Set(Object.keys(HTML_SPEC.elements).map((k) => k.replace(/^svg:/, "").toLowerCase()));
|
|
37
|
+
/** Whether `expr` is a length-only list constructor: `Array(n)` / `new Array(n)` (single argument = length semantics) or `Array.from({ length: n }, …)`. */
|
|
38
|
+
function isLengthOnlyArrayCall(expr) {
|
|
39
|
+
const e = unwrapTs(expr);
|
|
40
|
+
if (!e) return false;
|
|
41
|
+
if ((e.type === "CallExpression" || e.type === "NewExpression") && e.callee?.type === "Identifier" && e.callee.name === "Array") return (e.arguments?.length ?? 0) === 1;
|
|
42
|
+
if (e.type === "CallExpression" && e.callee?.type === "MemberExpression" && !e.callee.computed && e.callee.object?.type === "Identifier" && e.callee.object.name === "Array" && e.callee.property.type === "Identifier" && e.callee.property.name === "from" && e.arguments?.[0]?.type === "ObjectExpression") return (e.arguments[0].properties ?? []).some((p) => p?.type === "Property" && !p.computed && (p.key?.name === "length" || p.key?.value === "length"));
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Whether the each expression yields no item identity to key on: a constant
|
|
47
|
+
* inline array literal (fixed length, never reorders), a length-only list
|
|
48
|
+
* (`Array(n)`, `new Array(n)`, `[...Array(n)]`, `Array.from({ length: n })` —
|
|
49
|
+
* placeholder/skeleton lists), or a spread array whose every element spreads a
|
|
50
|
+
* length-only list. Such blocks are skipped entirely — neither each-key nor
|
|
51
|
+
* each-index-key can give useful advice on them.
|
|
52
|
+
*/
|
|
53
|
+
function isIdentityFreeEach(node) {
|
|
54
|
+
const expr = unwrapTs(node.expression);
|
|
55
|
+
if (expr.type === "ArrayExpression" && Array.isArray(expr.elements)) return expr.elements.every((el) => el?.type !== "SpreadElement" || isLengthOnlyArrayCall(el.argument));
|
|
56
|
+
return isLengthOnlyArrayCall(expr);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Whether `expr` is the index binding itself or a trivial coercion of it —
|
|
60
|
+
* `i`, `String(i)`, `Number(i)`, `` `${i}` ``, `i.toString()`, `i + ''` — all
|
|
61
|
+
* position-based identity. A template or concatenation with any literal text
|
|
62
|
+
* (`` `row-${i}` ``, `i + '-row'`) or extra expressions is treated as composite
|
|
63
|
+
* and NOT matched: composite keys may be a deliberate uniqueness workaround for
|
|
64
|
+
* duplicate items.
|
|
65
|
+
*/
|
|
66
|
+
function isIndexExpression(expr, index) {
|
|
67
|
+
const e = unwrapTs(expr);
|
|
68
|
+
if (e.type === "Identifier") return e.name === index;
|
|
69
|
+
if (e.type === "CallExpression") {
|
|
70
|
+
const callee = e.callee;
|
|
71
|
+
if (callee.type === "Identifier" && (callee.name === "String" || callee.name === "Number") && e.arguments.length === 1) return isIndexExpression(e.arguments[0], index);
|
|
72
|
+
if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && callee.property.name === "toString" && e.arguments.length === 0) return isIndexExpression(callee.object, index);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
if (e.type === "TemplateLiteral") {
|
|
76
|
+
const exprs = e.expressions;
|
|
77
|
+
if (exprs.length !== 1) return false;
|
|
78
|
+
if (e.quasis.some((q) => (q.value.cooked ?? q.value.raw) !== "")) return false;
|
|
79
|
+
return isIndexExpression(exprs[0], index);
|
|
80
|
+
}
|
|
81
|
+
if (e.type === "BinaryExpression" && e.operator === "+") {
|
|
82
|
+
const emptyString = (n) => n.type === "Literal" && n.value === "";
|
|
83
|
+
if (emptyString(e.left)) return isIndexExpression(e.right, index);
|
|
84
|
+
if (emptyString(e.right)) return isIndexExpression(e.left, index);
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Whether the block's key expression is its index binding or a trivial
|
|
90
|
+
* stringification of it (`{#each items as item, i (i)}`, `(String(i))`,
|
|
91
|
+
* `` (`${i}`) ``, `(i.toString())`) — position-based identity, the anti-pattern
|
|
92
|
+
* Svelte's docs call out ("do not use the index as a key"). Composite keys that
|
|
93
|
+
* merely CONTAIN the index add uniqueness and are never matched
|
|
94
|
+
* (correctness/each-index-key).
|
|
95
|
+
*/
|
|
96
|
+
function isIndexKey(each) {
|
|
97
|
+
if (typeof each.index !== "string" || each.key == null) return false;
|
|
98
|
+
return isIndexExpression(each.key, each.index);
|
|
99
|
+
}
|
|
100
|
+
/** Recursively collect every `{#each}` block in the template (correctness/each-key). */
|
|
101
|
+
function collectEachBlocks(node, source, acc) {
|
|
102
|
+
if (Array.isArray(node)) {
|
|
103
|
+
for (const child of node) collectEachBlocks(child, source, acc);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!node || typeof node !== "object") return;
|
|
107
|
+
if (node.type === "EachBlock" && node.context != null && !isIdentityFreeEach(node)) acc.push({
|
|
108
|
+
hasKey: node.key != null,
|
|
109
|
+
line: lineOf(source, node.start),
|
|
110
|
+
...isIndexKey(node) ? { indexKey: true } : {}
|
|
111
|
+
});
|
|
112
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) collectEachBlocks(node[key], source, acc);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Whether a CallExpression *creates an effect*: `$effect(...)` or `$effect.pre(...)`.
|
|
116
|
+
* Excludes the non-effect `$effect.*` readers (`$effect.tracking()`, `$effect.root()`),
|
|
117
|
+
* which would otherwise be recorded as effects and seed spurious correctness/effect-as-derived pass units.
|
|
118
|
+
*/
|
|
119
|
+
function isEffectCall(node) {
|
|
120
|
+
const c = node?.callee;
|
|
121
|
+
if (c?.type === "Identifier") return c.name === "$effect";
|
|
122
|
+
if (c?.type === "MemberExpression" && c.object?.type === "Identifier" && c.object.name === "$effect") return c.property?.type === "Identifier" && c.property.name === "pre";
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
/** Whether a CallExpression is `$effect.root(...)` — a legal standalone reactive scope (correctness/orphan-effect). */
|
|
126
|
+
function isEffectRootCall(node) {
|
|
127
|
+
const c = node?.callee;
|
|
128
|
+
return c?.type === "MemberExpression" && c.object?.type === "Identifier" && c.object.name === "$effect" && c.property?.type === "Identifier" && c.property.name === "root";
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Whether a CallExpression is a `$state` *declaration* form: `$state(...)`,
|
|
132
|
+
* `$state.raw(...)`, or `$state.frozen(...)` — but NOT readers like
|
|
133
|
+
* `$state.snapshot(...)`, which would otherwise pollute the state-name set (correctness/effect-as-derived).
|
|
134
|
+
*/
|
|
135
|
+
function isStateDeclaration(node) {
|
|
136
|
+
const c = node?.callee;
|
|
137
|
+
if (c?.type === "Identifier") return c.name === "$state";
|
|
138
|
+
if (c?.type === "MemberExpression" && c.object?.type === "Identifier" && c.object.name === "$state") return c.property?.type === "Identifier" && (c.property.name === "raw" || c.property.name === "frozen");
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
/** True when a function's body does nothing but assign to `$state` identifiers (correctness/effect-as-derived). */
|
|
142
|
+
function bodyOnlyAssignsState(fn, stateNames) {
|
|
143
|
+
const isStateAssign = (expr) => expr?.type === "AssignmentExpression" && expr.operator === "=" && expr.left?.type === "Identifier" && stateNames.has(expr.left.name);
|
|
144
|
+
const body = fn?.body;
|
|
145
|
+
if (!body) return false;
|
|
146
|
+
if (body.type !== "BlockStatement") return isStateAssign(body);
|
|
147
|
+
if (body.body.length === 0) return false;
|
|
148
|
+
return body.body.every((s) => s?.type === "ExpressionStatement" && isStateAssign(s.expression));
|
|
149
|
+
}
|
|
150
|
+
/** `$derived(...)` or `$derived.by(...)` declaration form. */
|
|
151
|
+
function isDerivedDeclaration(node) {
|
|
152
|
+
const c = node?.callee;
|
|
153
|
+
if (c?.type === "Identifier") return c.name === "$derived";
|
|
154
|
+
if (c?.type === "MemberExpression" && c.object?.type === "Identifier" && c.object.name === "$derived") return c.property?.type === "Identifier" && c.property.name === "by";
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
function collectStateWrites(root, stateNames, acc, kinds) {
|
|
158
|
+
const record = (name, kind) => {
|
|
159
|
+
acc.add(name);
|
|
160
|
+
if (kinds) {
|
|
161
|
+
let set = kinds.get(name);
|
|
162
|
+
if (!set) kinds.set(name, set = /* @__PURE__ */ new Set());
|
|
163
|
+
set.add(kind);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
walkScoped(root, (n, scope) => {
|
|
167
|
+
const shadowed = (name) => name === void 0 || scope.has(name);
|
|
168
|
+
if (n?.type === "AssignmentExpression") {
|
|
169
|
+
if (n.left?.type === "Identifier" && stateNames.has(n.left.name) && !shadowed(n.left.name)) record(n.left.name, "reassign");
|
|
170
|
+
else if (n.left?.type === "MemberExpression") {
|
|
171
|
+
const r = rootObjectName(n.left);
|
|
172
|
+
if (r && stateNames.has(r) && !shadowed(r)) record(r, "mutate");
|
|
173
|
+
} else if (n.left?.type === "ObjectPattern" || n.left?.type === "ArrayPattern") {
|
|
174
|
+
const bound = /* @__PURE__ */ new Set();
|
|
175
|
+
addBoundNames(n.left, bound);
|
|
176
|
+
for (const name of bound) if (stateNames.has(name) && !shadowed(name)) record(name, "reassign");
|
|
177
|
+
}
|
|
178
|
+
} else if (n?.type === "UpdateExpression") if (n.argument?.type === "Identifier") {
|
|
179
|
+
if (stateNames.has(n.argument.name) && !shadowed(n.argument.name)) record(n.argument.name, "reassign");
|
|
180
|
+
} else {
|
|
181
|
+
const r = rootObjectName(n.argument);
|
|
182
|
+
if (r && stateNames.has(r) && !shadowed(r)) record(r, "mutate");
|
|
183
|
+
}
|
|
184
|
+
else if (n?.type === "UnaryExpression" && n.operator === "delete") {
|
|
185
|
+
const r = rootObjectName(n.argument);
|
|
186
|
+
if (r && stateNames.has(r) && !shadowed(r)) record(r, "mutate");
|
|
187
|
+
} else if (n?.type === "CallExpression") {
|
|
188
|
+
if (n.callee?.type === "MemberExpression") {
|
|
189
|
+
const r = rootObjectName(n.callee);
|
|
190
|
+
if (r && stateNames.has(r) && !shadowed(r)) record(r, "mutate");
|
|
191
|
+
}
|
|
192
|
+
for (const a of n.arguments ?? []) {
|
|
193
|
+
const arg = a?.type === "SpreadElement" ? a.argument : a;
|
|
194
|
+
const r = rootObjectName(arg);
|
|
195
|
+
if (r && stateNames.has(r) && !shadowed(r)) record(r, "escape");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/** Function-shaped nodes whose bodies defer evaluation — prop reads inside them stay reactive (compiled to call-time reads). */
|
|
201
|
+
function isDeferredBody(n) {
|
|
202
|
+
return n?.type === "FunctionDeclaration" || n?.type === "FunctionExpression" || n?.type === "ArrowFunctionExpression";
|
|
203
|
+
}
|
|
204
|
+
/** A plain `$state(...)` call — bare-Identifier callee, never `$state.raw`/`$state.frozen` (performance/state-raw candidates only; `isStateDeclaration` stays raw-inclusive for `stateNames`). */
|
|
205
|
+
function isPlainStateCall(node) {
|
|
206
|
+
return node?.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "$state";
|
|
207
|
+
}
|
|
208
|
+
/** Built-in classes with reactive drop-ins in svelte/reactivity — plain instances in $state are NOT deep-proxied, so their mutations are untracked (correctness/nonreactive-builtin-state). */
|
|
209
|
+
const BUILTIN_STATE_TYPES = /* @__PURE__ */ new Set([
|
|
210
|
+
"Map",
|
|
211
|
+
"Set",
|
|
212
|
+
"Date",
|
|
213
|
+
"URL",
|
|
214
|
+
"URLSearchParams"
|
|
215
|
+
]);
|
|
216
|
+
/** Type-specific mutating methods. URL mutates via property writes and deep searchParams calls only. */
|
|
217
|
+
const BUILTIN_MUTATIONS = {
|
|
218
|
+
Map: /* @__PURE__ */ new Set([
|
|
219
|
+
"set",
|
|
220
|
+
"delete",
|
|
221
|
+
"clear"
|
|
222
|
+
]),
|
|
223
|
+
Set: /* @__PURE__ */ new Set([
|
|
224
|
+
"add",
|
|
225
|
+
"delete",
|
|
226
|
+
"clear"
|
|
227
|
+
]),
|
|
228
|
+
Date: /* @__PURE__ */ new Set([
|
|
229
|
+
"setTime",
|
|
230
|
+
"setFullYear",
|
|
231
|
+
"setMonth",
|
|
232
|
+
"setDate",
|
|
233
|
+
"setHours",
|
|
234
|
+
"setMinutes",
|
|
235
|
+
"setSeconds",
|
|
236
|
+
"setMilliseconds",
|
|
237
|
+
"setYear",
|
|
238
|
+
"setUTCFullYear",
|
|
239
|
+
"setUTCMonth",
|
|
240
|
+
"setUTCDate",
|
|
241
|
+
"setUTCHours",
|
|
242
|
+
"setUTCMinutes",
|
|
243
|
+
"setUTCSeconds",
|
|
244
|
+
"setUTCMilliseconds"
|
|
245
|
+
]),
|
|
246
|
+
URL: /* @__PURE__ */ new Set(),
|
|
247
|
+
URLSearchParams: /* @__PURE__ */ new Set([
|
|
248
|
+
"append",
|
|
249
|
+
"set",
|
|
250
|
+
"delete",
|
|
251
|
+
"sort"
|
|
252
|
+
])
|
|
253
|
+
};
|
|
254
|
+
/**
|
|
255
|
+
* Signals for correctness/nonreactive-builtin-state, per candidate binding
|
|
256
|
+
* (name → constructor type): `mutated` collects type-specific mutations that
|
|
257
|
+
* happen INSIDE a function body (a top-level init mutation runs before first
|
|
258
|
+
* render and can never leave the UI stale); `reassigned` collects whole-binding
|
|
259
|
+
* reassignments ANYWHERE (a fresh reassignment after mutation makes the code
|
|
260
|
+
* work) — except the bare self-assignment `b = b`, a no-op under $state's
|
|
261
|
+
* referential equality in Svelte 5, which must not exempt. Member writes
|
|
262
|
+
* (assignment / update / `delete`) and deep member calls count as mutation
|
|
263
|
+
* only for URL bindings (URL mutates via properties: `u.href = …`,
|
|
264
|
+
* `u.searchParams.set(...)` — final method in the URLSearchParams set); the
|
|
265
|
+
* other types count only direct calls from their own method table, so an
|
|
266
|
+
* expando-property write on a Map never flags (SvelteMap would not track it
|
|
267
|
+
* either). Deep reads (`get`, `has`, …) never count. Shadow-aware; class
|
|
268
|
+
* bodies count as function depth.
|
|
269
|
+
*/
|
|
270
|
+
function collectBuiltinStateSignals(node, candidates, mutated, reassigned, shadowed = /* @__PURE__ */ new Set(), inFunction = false) {
|
|
271
|
+
if (Array.isArray(node)) {
|
|
272
|
+
for (const child of node) collectBuiltinStateSignals(child, candidates, mutated, reassigned, shadowed, inFunction);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string") return;
|
|
276
|
+
const introduced = scopeIntroducedNames(node);
|
|
277
|
+
const scope = introduced.size > 0 ? /* @__PURE__ */ new Set([...shadowed, ...introduced]) : shadowed;
|
|
278
|
+
const boundary = isDeferredBody(node) || node.type === "ClassDeclaration" || node.type === "ClassExpression";
|
|
279
|
+
const nextInFunction = inFunction || boundary;
|
|
280
|
+
const hit = (name) => typeof name === "string" && candidates.has(name) && !scope.has(name) ? name : void 0;
|
|
281
|
+
if (node.type === "AssignmentExpression") {
|
|
282
|
+
if (node.left?.type === "Identifier") {
|
|
283
|
+
const n = hit(node.left.name);
|
|
284
|
+
const isBareSelfAssign = node.right?.type === "Identifier" && node.right.name === n;
|
|
285
|
+
if (n && !isBareSelfAssign) reassigned.add(n);
|
|
286
|
+
} else if (node.left?.type === "ObjectPattern" || node.left?.type === "ArrayPattern") {
|
|
287
|
+
const bound = /* @__PURE__ */ new Set();
|
|
288
|
+
addBoundNames(node.left, bound);
|
|
289
|
+
for (const name of bound) {
|
|
290
|
+
const n = hit(name);
|
|
291
|
+
if (n) reassigned.add(n);
|
|
292
|
+
}
|
|
293
|
+
} else if (node.left?.type === "MemberExpression" && inFunction) {
|
|
294
|
+
const n = hit(rootObjectName(node.left));
|
|
295
|
+
if (n && candidates.get(n) === "URL") mutated.add(n);
|
|
296
|
+
}
|
|
297
|
+
} else if (node.type === "UpdateExpression" && node.argument?.type === "MemberExpression" && inFunction) {
|
|
298
|
+
const n = hit(rootObjectName(node.argument));
|
|
299
|
+
if (n && candidates.get(n) === "URL") mutated.add(n);
|
|
300
|
+
} else if (node.type === "UnaryExpression" && node.operator === "delete" && inFunction) {
|
|
301
|
+
const n = hit(rootObjectName(node.argument));
|
|
302
|
+
if (n && candidates.get(n) === "URL") mutated.add(n);
|
|
303
|
+
} else if (node.type === "CallExpression" && node.callee?.type === "MemberExpression" && !node.callee.computed && inFunction) {
|
|
304
|
+
const method = node.callee.property?.name;
|
|
305
|
+
if (typeof method === "string") {
|
|
306
|
+
if (node.callee.object?.type === "Identifier") {
|
|
307
|
+
const n = hit(node.callee.object.name);
|
|
308
|
+
if (n && BUILTIN_MUTATIONS[candidates.get(n)]?.has(method)) mutated.add(n);
|
|
309
|
+
} else if (node.callee.object?.type === "MemberExpression") {
|
|
310
|
+
const n = hit(rootObjectName(node.callee));
|
|
311
|
+
if (n && candidates.get(n) === "URL" && BUILTIN_MUTATIONS.URLSearchParams.has(method)) mutated.add(n);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
for (const key of Object.keys(node)) {
|
|
316
|
+
if (WALK_IGNORED_KEYS.has(key)) continue;
|
|
317
|
+
collectBuiltinStateSignals(node[key], candidates, mutated, reassigned, scope, nextInFunction);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Scan a binding pattern (a `VariableDeclarator.id`, or nested inside one) for
|
|
322
|
+
* real references hiding inside it, while skipping the bound identifiers
|
|
323
|
+
* themselves — a binding site is never a read. `const { a } = x` binds `a`,
|
|
324
|
+
* no reference to anything; `const { a = obj } = x` reads `obj` (an
|
|
325
|
+
* `AssignmentPattern`'s default-value RHS is a real expression position);
|
|
326
|
+
* `const { [obj]: a } = x` reads `obj` as a computed key. Both cases hand off
|
|
327
|
+
* to `collectAliasRefs` (normal expression-walk semantics) for the referenced
|
|
328
|
+
* subtree; only the pattern shape itself (`ObjectPattern`/`ArrayPattern`/
|
|
329
|
+
* `RestElement`/`AssignmentPattern` bound side) is walked specially here.
|
|
330
|
+
*/
|
|
331
|
+
function collectPatternAliasRefs(node, names, acc, scope, ownRhs) {
|
|
332
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string") return;
|
|
333
|
+
if (node.type === "Identifier") return;
|
|
334
|
+
if (node.type === "ObjectPattern") {
|
|
335
|
+
for (const prop of node.properties ?? []) if (prop?.type === "RestElement") collectPatternAliasRefs(prop.argument, names, acc, scope, ownRhs);
|
|
336
|
+
else if (prop?.type === "Property") {
|
|
337
|
+
if (prop.computed) collectAliasRefs(prop.key, names, acc, scope, ownRhs);
|
|
338
|
+
collectPatternAliasRefs(prop.value, names, acc, scope, ownRhs);
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (node.type === "ArrayPattern") {
|
|
343
|
+
for (const el of node.elements ?? []) collectPatternAliasRefs(el, names, acc, scope, ownRhs);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (node.type === "AssignmentPattern") {
|
|
347
|
+
collectPatternAliasRefs(node.left, names, acc, scope, ownRhs);
|
|
348
|
+
collectAliasRefs(node.right, names, acc, scope, ownRhs);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (node.type === "RestElement") collectPatternAliasRefs(node.argument, names, acc, scope, ownRhs);
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Bare references to candidate names outside their own reassignments — aliasing
|
|
355
|
+
* escapes (performance/state-raw condition 4). Nearest-enclosing-assignment
|
|
356
|
+
* semantics: a reference is exempt only while the CLOSEST surrounding
|
|
357
|
+
* AssignmentExpression assigns to that same candidate (`list = [...list, x]`
|
|
358
|
+
* qualifies; `obj = (cache = obj)` does not). Skips the assignment LHS itself
|
|
359
|
+
* and non-computed member properties/keys; shadow-aware. A `VariableDeclarator`
|
|
360
|
+
* routes its `id` through `collectPatternAliasRefs` — bound names in the
|
|
361
|
+
* pattern are never references, but destructuring defaults and computed keys
|
|
362
|
+
* are — so the candidate's own `$state(literal)` declarator contains no
|
|
363
|
+
* self-reference; `let b = $state([...list])` referencing ANOTHER candidate
|
|
364
|
+
* still correctly counts as an alias escape of `list` via `init`. Walks
|
|
365
|
+
* whatever subtree it is given — callers choose the roots.
|
|
366
|
+
*/
|
|
367
|
+
function collectAliasRefs(node, names, acc, shadowed = /* @__PURE__ */ new Set(), ownRhs = null) {
|
|
368
|
+
if (Array.isArray(node)) {
|
|
369
|
+
for (const child of node) collectAliasRefs(child, names, acc, shadowed, ownRhs);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string") return;
|
|
373
|
+
const introduced = scopeIntroducedNames(node);
|
|
374
|
+
const scope = introduced.size > 0 ? /* @__PURE__ */ new Set([...shadowed, ...introduced]) : shadowed;
|
|
375
|
+
if (node.type === "AssignmentExpression") {
|
|
376
|
+
const lhsIsCandidate = node.left?.type === "Identifier" && names.has(node.left.name) && !scope.has(node.left.name);
|
|
377
|
+
if (!lhsIsCandidate) collectAliasRefs(node.left, names, acc, scope, null);
|
|
378
|
+
collectAliasRefs(node.right, names, acc, scope, lhsIsCandidate ? node.left.name : null);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (node.type === "VariableDeclarator") {
|
|
382
|
+
collectPatternAliasRefs(node.id, names, acc, scope, ownRhs);
|
|
383
|
+
if (node.init) collectAliasRefs(node.init, names, acc, scope, ownRhs);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (node.type === "Identifier" && names.has(node.name) && !scope.has(node.name) && node.name !== ownRhs) {
|
|
387
|
+
acc.add(node.name);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
for (const key of Object.keys(node)) {
|
|
391
|
+
if (WALK_IGNORED_KEYS.has(key)) continue;
|
|
392
|
+
if (node.type === "MemberExpression" && key === "property" && !node.computed) continue;
|
|
393
|
+
if (node.type === "Property" && key === "key" && !node.computed) continue;
|
|
394
|
+
collectAliasRefs(node[key], names, acc, scope, ownRhs);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Alias scan over the fragment: template READ positions are exempt, but function
|
|
399
|
+
* bodies inside the template (inline handlers) are not — `onclick={() => (cache = obj)}`
|
|
400
|
+
* escapes. Threads template shadowing (each contexts etc.) into the handler scan.
|
|
401
|
+
*/
|
|
402
|
+
function collectFragmentAliasRefs(node, names, acc, shadowed = /* @__PURE__ */ new Set()) {
|
|
403
|
+
if (Array.isArray(node)) {
|
|
404
|
+
for (const child of node) collectFragmentAliasRefs(child, names, acc, shadowed);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string") return;
|
|
408
|
+
if (isDeferredBody(node)) {
|
|
409
|
+
const introduced = scopeIntroducedNames(node);
|
|
410
|
+
const scope = introduced.size > 0 ? /* @__PURE__ */ new Set([...shadowed, ...introduced]) : shadowed;
|
|
411
|
+
collectAliasRefs(node.body, names, acc, scope, null);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const introduced = scopeIntroducedNames(node);
|
|
415
|
+
const scope = introduced.size > 0 ? /* @__PURE__ */ new Set([...shadowed, ...introduced]) : shadowed;
|
|
416
|
+
if (Array.isArray(node.attributes)) collectFragmentAliasRefs(node.attributes, names, acc, scope);
|
|
417
|
+
for (const key of Object.keys(node)) {
|
|
418
|
+
if (WALK_IGNORED_KEYS.has(key) || key === "attributes") continue;
|
|
419
|
+
collectFragmentAliasRefs(node[key], names, acc, scope);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Each-context taint (performance/state-raw condition 5): for `{#each candidate as item}`
|
|
424
|
+
* or `{#each candidate.path as item}`, any mutate/escape of the context binding (or index)
|
|
425
|
+
* inside the block — member writes, method calls, call arguments, `bind:`, component props —
|
|
426
|
+
* disqualifies the candidate over the candidate or a member path of it
|
|
427
|
+
* (`{#each obj.items as item}`): item-level edits stop being reactive under $state.raw.
|
|
428
|
+
* Pure reassignments of the context name are ignored (they don't touch the list's contents).
|
|
429
|
+
*/
|
|
430
|
+
function collectEachContextTaint(node, names, acc, shadowed = /* @__PURE__ */ new Set()) {
|
|
431
|
+
if (Array.isArray(node)) {
|
|
432
|
+
for (const child of node) collectEachContextTaint(child, names, acc, shadowed);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string") return;
|
|
436
|
+
const introduced = scopeIntroducedNames(node);
|
|
437
|
+
const scope = introduced.size > 0 ? /* @__PURE__ */ new Set([...shadowed, ...introduced]) : shadowed;
|
|
438
|
+
if (node.type === "EachBlock") {
|
|
439
|
+
const expr = unwrapTs(node.expression);
|
|
440
|
+
const target = expr?.type === "Identifier" ? expr.name : expr?.type === "MemberExpression" ? rootObjectName(expr) : void 0;
|
|
441
|
+
if (target !== void 0 && names.has(target) && !shadowed.has(target)) {
|
|
442
|
+
const ctxNames = /* @__PURE__ */ new Set();
|
|
443
|
+
addBoundNames(node.context, ctxNames);
|
|
444
|
+
if (typeof node.index === "string") ctxNames.add(node.index);
|
|
445
|
+
if (ctxNames.size > 0) {
|
|
446
|
+
const union = /* @__PURE__ */ new Set();
|
|
447
|
+
const kinds = /* @__PURE__ */ new Map();
|
|
448
|
+
collectStateWrites(node.body, ctxNames, union, kinds);
|
|
449
|
+
collectTemplateEscapes(node.body, ctxNames, union, kinds);
|
|
450
|
+
if ([...union].some((n) => {
|
|
451
|
+
const k = kinds.get(n);
|
|
452
|
+
return !k || [...k].some((kind) => kind !== "reassign");
|
|
453
|
+
})) acc.add(target);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
for (const key of Object.keys(node)) {
|
|
458
|
+
if (WALK_IGNORED_KEYS.has(key)) continue;
|
|
459
|
+
collectEachContextTaint(node[key], names, acc, scope);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Whether `node` references any of `names` in an EAGER position: nested
|
|
464
|
+
* function/arrow bodies (incl. object getters/methods, whose values are
|
|
465
|
+
* FunctionExpressions) are skipped — the compiler defers those reads to call
|
|
466
|
+
* time, so they stay reactive. Non-computed member properties and object keys
|
|
467
|
+
* are not references. Shadow-aware via `scopeIntroducedNames`.
|
|
468
|
+
*/
|
|
469
|
+
function refsNamesEagerly(node, names, shadowed = /* @__PURE__ */ new Set()) {
|
|
470
|
+
if (Array.isArray(node)) return node.some((c) => refsNamesEagerly(c, names, shadowed));
|
|
471
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string") return false;
|
|
472
|
+
if (isDeferredBody(node)) return false;
|
|
473
|
+
const introduced = scopeIntroducedNames(node);
|
|
474
|
+
const scope = introduced.size > 0 ? /* @__PURE__ */ new Set([...shadowed, ...introduced]) : shadowed;
|
|
475
|
+
if (node.type === "Identifier" && names.has(node.name) && !scope.has(node.name)) return true;
|
|
476
|
+
for (const key of Object.keys(node)) {
|
|
477
|
+
if (WALK_IGNORED_KEYS.has(key)) continue;
|
|
478
|
+
if (node.type === "MemberExpression" && key === "property" && !node.computed) continue;
|
|
479
|
+
if (node.type === "Property" && key === "key" && !node.computed) continue;
|
|
480
|
+
if (refsNamesEagerly(node[key], names, scope)) return true;
|
|
481
|
+
}
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
/** Whether the subtree contains any call, construction, or await — used to keep stale-prop candidates to plain expressions (rune wrappers and helper/service calls are all excluded structurally). */
|
|
485
|
+
function containsCallLike(node) {
|
|
486
|
+
let found = false;
|
|
487
|
+
walkEstree(node, (n) => {
|
|
488
|
+
if (n?.type === "CallExpression" || n?.type === "NewExpression" || n?.type === "AwaitExpression") found = true;
|
|
489
|
+
});
|
|
490
|
+
return found;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Names from `names` referenced in the template fragment in an eager position:
|
|
494
|
+
* expression tags, attribute/directive expressions, and block expressions count;
|
|
495
|
+
* inline-handler function bodies do NOT (deferred reads never render), while
|
|
496
|
+
* `{#snippet}` bodies DO (render content). Shadow-aware for template scopes
|
|
497
|
+
* (each contexts + index, snippet parameters, await value/error).
|
|
498
|
+
*/
|
|
499
|
+
function collectFragmentRefs(node, names, acc, shadowed = /* @__PURE__ */ new Set()) {
|
|
500
|
+
if (Array.isArray(node)) {
|
|
501
|
+
for (const c of node) collectFragmentRefs(c, names, acc, shadowed);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string") return;
|
|
505
|
+
if (isDeferredBody(node)) return;
|
|
506
|
+
const introduced = scopeIntroducedNames(node);
|
|
507
|
+
const scope = introduced.size > 0 ? /* @__PURE__ */ new Set([...shadowed, ...introduced]) : shadowed;
|
|
508
|
+
if (node.type === "Identifier" && names.has(node.name) && !scope.has(node.name)) acc.add(node.name);
|
|
509
|
+
if (node.type === "EachBlock" || node.type === "AwaitBlock") {
|
|
510
|
+
collectFragmentRefs(node.expression, names, acc, shadowed);
|
|
511
|
+
for (const key of Object.keys(node)) {
|
|
512
|
+
if (WALK_IGNORED_KEYS.has(key) || key === "expression") continue;
|
|
513
|
+
collectFragmentRefs(node[key], names, acc, scope);
|
|
514
|
+
}
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (Array.isArray(node.attributes)) collectFragmentRefs(node.attributes, names, acc, scope);
|
|
518
|
+
for (const key of Object.keys(node)) {
|
|
519
|
+
if (WALK_IGNORED_KEYS.has(key) || key === "attributes") continue;
|
|
520
|
+
if (node.type === "MemberExpression" && key === "property" && !node.computed) continue;
|
|
521
|
+
if (node.type === "Property" && key === "key" && !node.computed) continue;
|
|
522
|
+
collectFragmentRefs(node[key], names, acc, scope);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Stale prop derivations (correctness/stale-prop-derivation): top-level
|
|
527
|
+
* const/let/var Identifier declarators whose CALL-FREE initializer references a
|
|
528
|
+
* prop eagerly. Reassignment/escape and template-reference filtering happen at
|
|
529
|
+
* the call site, where the fragment is available.
|
|
530
|
+
*/
|
|
531
|
+
function collectStalePropCandidates(program, propNames, source) {
|
|
532
|
+
const out = [];
|
|
533
|
+
for (const stmt of program.body ?? []) {
|
|
534
|
+
if (stmt?.type !== "VariableDeclaration") continue;
|
|
535
|
+
for (const d of stmt.declarations ?? []) {
|
|
536
|
+
if (d?.id?.type !== "Identifier" || !d.init) continue;
|
|
537
|
+
if (containsCallLike(d.init)) continue;
|
|
538
|
+
if (!refsNamesEagerly(d.init, propNames)) continue;
|
|
539
|
+
out.push({
|
|
540
|
+
name: d.id.name,
|
|
541
|
+
line: lineOf(source, d.start)
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return out;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Component-like nodes whose attributes are props passed INTO another component
|
|
549
|
+
* (an escape), as opposed to `SvelteElement` (`<svelte:element this={...}>`), whose
|
|
550
|
+
* attributes are DOM-attribute reads on a dynamically-named element — not an escape.
|
|
551
|
+
*/
|
|
552
|
+
const COMPONENT_LIKE_TYPES = /* @__PURE__ */ new Set([
|
|
553
|
+
"Component",
|
|
554
|
+
"SvelteComponent",
|
|
555
|
+
"SvelteSelf"
|
|
556
|
+
]);
|
|
557
|
+
/**
|
|
558
|
+
* Add state names ESCAPED via the template (correctness/unmutated-state rules 5–6): a `bind:` on any
|
|
559
|
+
* element, or passed as a prop to a component (static `<Foo>`, or dynamic
|
|
560
|
+
* `<svelte:component>`/`<svelte:self>`). Slot children / DOM-attribute reads do
|
|
561
|
+
* not escape. `CHILD_NODE_KEYS` omits `attributes`, so inspect them explicitly.
|
|
562
|
+
* The optional `kinds` map records every hit as 'escape' for consumers that need
|
|
563
|
+
* the classification (performance/state-raw); the `acc` union contract is unchanged.
|
|
564
|
+
*/
|
|
565
|
+
function collectTemplateEscapes(node, stateNames, acc, kinds) {
|
|
566
|
+
const record = (name) => {
|
|
567
|
+
acc.add(name);
|
|
568
|
+
if (kinds) {
|
|
569
|
+
let set = kinds.get(name);
|
|
570
|
+
if (!set) kinds.set(name, set = /* @__PURE__ */ new Set());
|
|
571
|
+
set.add("escape");
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
if (Array.isArray(node)) {
|
|
575
|
+
for (const c of node) collectTemplateEscapes(c, stateNames, acc, kinds);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string") return;
|
|
579
|
+
if (Array.isArray(node.attributes)) {
|
|
580
|
+
for (const attr of node.attributes) if (attr?.type === "BindDirective") {
|
|
581
|
+
const r = rootObjectName(attr.expression);
|
|
582
|
+
if (r && stateNames.has(r)) record(r);
|
|
583
|
+
} else if (COMPONENT_LIKE_TYPES.has(node.type)) walkEstree(attr, (m) => {
|
|
584
|
+
if (m?.type === "Identifier" && stateNames.has(m.name)) record(m.name);
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) collectTemplateEscapes(node[key], stateNames, acc, kinds);
|
|
588
|
+
}
|
|
589
|
+
/** Directive types verified against svelte 5 modern-AST output: `use:x={obj}` → `UseDirective`, `transition:x={obj}` → `TransitionDirective`, `animate:x={obj}` → `AnimateDirective`. */
|
|
590
|
+
const DIRECTIVE_ESCAPE_TYPES = /* @__PURE__ */ new Set([
|
|
591
|
+
"UseDirective",
|
|
592
|
+
"TransitionDirective",
|
|
593
|
+
"AnimateDirective"
|
|
594
|
+
]);
|
|
595
|
+
/**
|
|
596
|
+
* Directive expressions that hand a value to arbitrary code — `use:action={obj}`,
|
|
597
|
+
* `transition:fn={obj}`, `animate:fn={obj}` — are reference handoffs, the same class
|
|
598
|
+
* as a call argument. Serves `performance/state-raw` and `correctness/unmutated-state`
|
|
599
|
+
* (the receiving code may mutate the proxy invisibly, so such state is neither
|
|
600
|
+
* raw-able nor "unused"). The shared template-escape collector deliberately still
|
|
601
|
+
* excludes directives so `correctness/stale-prop-derivation`'s disqualification set
|
|
602
|
+
* is unchanged — a stale prop-derived value handed to an action is still worth flagging.
|
|
603
|
+
*/
|
|
604
|
+
function collectDirectiveEscapes(node, names, acc) {
|
|
605
|
+
if (Array.isArray(node)) {
|
|
606
|
+
for (const c of node) collectDirectiveEscapes(c, names, acc);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string") return;
|
|
610
|
+
if (Array.isArray(node.attributes)) {
|
|
611
|
+
for (const attr of node.attributes) if (DIRECTIVE_ESCAPE_TYPES.has(attr?.type) && attr.expression) walkEstree(attr.expression, (m) => {
|
|
612
|
+
if (m?.type === "Identifier" && names.has(m.name)) acc.add(m.name);
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) collectDirectiveEscapes(node[key], names, acc);
|
|
616
|
+
}
|
|
617
|
+
const RUNE_NAMES = /* @__PURE__ */ new Set([
|
|
618
|
+
"$state",
|
|
619
|
+
"$derived",
|
|
620
|
+
"$effect",
|
|
621
|
+
"$props",
|
|
622
|
+
"$bindable",
|
|
623
|
+
"$inspect",
|
|
624
|
+
"$host"
|
|
625
|
+
]);
|
|
626
|
+
/**
|
|
627
|
+
* Local names bound by any non-type-only import specifier (default, named, or namespace),
|
|
628
|
+
* anywhere in a program (correctness/effect-as-onmount). An imported binding is opaque to this
|
|
629
|
+
* analysis — it may be a runes-module state object, a `svelte/reactivity`/`svelte/reactivity/window`
|
|
630
|
+
* constructor or live binding, or anything else carrying hidden reactivity — so member reads on
|
|
631
|
+
* it are folded into `reactiveNames` rather than risk telling someone to replace a live effect
|
|
632
|
+
* with `onMount`.
|
|
633
|
+
*/
|
|
634
|
+
function collectImportedLocalNames(program, acc) {
|
|
635
|
+
for (const stmt of program?.body ?? []) {
|
|
636
|
+
if (stmt?.type !== "ImportDeclaration" || stmt.importKind === "type") continue;
|
|
637
|
+
for (const s of stmt.specifiers ?? []) {
|
|
638
|
+
if (s?.importKind === "type" || s?.local?.type !== "Identifier") continue;
|
|
639
|
+
acc.add(s.local.name);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Local names declared with a `new …()` initializer (`const x = new Foo()`), anywhere in a
|
|
645
|
+
* program (correctness/effect-as-onmount). A class instance is opaque the same way an import is —
|
|
646
|
+
* it may carry `$state` fields — so it's folded into `reactiveNames` alongside imports.
|
|
647
|
+
* Declarator-init only: `let x; x = new Foo();` is not seen (`VariableDeclarator.init` is unset,
|
|
648
|
+
* and the later `AssignmentExpression` isn't a declarator at all) — a rarer style, left
|
|
649
|
+
* undetected rather than adding an assignment-tracking pass for it.
|
|
650
|
+
*/
|
|
651
|
+
function collectNewExprLocalNames(program, acc) {
|
|
652
|
+
walkEstree(program, (n) => {
|
|
653
|
+
if (n.type !== "VariableDeclarator" || n.id?.type !== "Identifier" || !n.init) return;
|
|
654
|
+
if (unwrapTs(n.init).type === "NewExpression") acc.add(n.id.name);
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Whether an $effect callback body reads a reactive value (correctness/effect-as-onmount, conservative):
|
|
659
|
+
* a reactive name (rune declarator, imported binding, or a local declared with a `new …()`
|
|
660
|
+
* initializer — see `collectImportedLocalNames`/`collectNewExprLocalNames`), a `$`-prefixed
|
|
661
|
+
* store subscription, or any bare-identifier call. Still blind to a reactive value reached only
|
|
662
|
+
* through a plain function call's return value (`const c = createCounter()`) or a
|
|
663
|
+
* prop-drilled/destructured member of one of the above — narrower than "any" reactive read,
|
|
664
|
+
* deliberately: those shapes have no syntactic marker to key off, so treating them as reactive by
|
|
665
|
+
* default would give up detection entirely rather than narrow it.
|
|
666
|
+
*/
|
|
667
|
+
function bodyReadsReactive(fn, reactiveNames) {
|
|
668
|
+
let reads = false;
|
|
669
|
+
const visit = (n) => {
|
|
670
|
+
if (reads || !n) return;
|
|
671
|
+
if (Array.isArray(n)) {
|
|
672
|
+
for (const c of n) visit(c);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (typeof n !== "object" || typeof n.type !== "string") return;
|
|
676
|
+
if (n.type === "Identifier") {
|
|
677
|
+
if (reactiveNames.has(n.name) || n.name.startsWith("$") && !RUNE_NAMES.has(n.name)) reads = true;
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (n.type === "CallExpression" && n.callee?.type === "Identifier") {
|
|
681
|
+
reads = true;
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if (n.type === "MemberExpression") {
|
|
685
|
+
visit(n.object);
|
|
686
|
+
if (n.computed) visit(n.property);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (n.type === "Property") {
|
|
690
|
+
if (n.computed) visit(n.key);
|
|
691
|
+
visit(n.value);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
for (const key of Object.keys(n)) if (!WALK_IGNORED_KEYS.has(key)) visit(n[key]);
|
|
695
|
+
};
|
|
696
|
+
visit(fn.body);
|
|
697
|
+
return reads;
|
|
698
|
+
}
|
|
699
|
+
/** Empty effect callback body (`() => {}` or no body). */
|
|
700
|
+
function bodyIsEmpty(fn) {
|
|
701
|
+
const body = fn?.body;
|
|
702
|
+
if (!body) return true;
|
|
703
|
+
if (body.type === "BlockStatement") return (body.body ?? []).length === 0;
|
|
704
|
+
return false;
|
|
705
|
+
}
|
|
706
|
+
/** Attributes whose value navigates/executes — a literal `javascript:` here is an XSS vector (security/javascript-url). */
|
|
707
|
+
const URL_ATTRS = [
|
|
708
|
+
"href",
|
|
709
|
+
"src",
|
|
710
|
+
"action",
|
|
711
|
+
"formaction"
|
|
712
|
+
];
|
|
713
|
+
/** Native checkable input types where bind:value binds the wrong DOM property (correctness/checkable-bind-value). */
|
|
714
|
+
const CHECKABLE_INPUT_TYPES = /* @__PURE__ */ new Set(["checkbox", "radio"]);
|
|
715
|
+
/** Recursively collect Security facts: `{@html}` tags and literal `javascript:` URLs (security/raw-html, security/javascript-url). */
|
|
716
|
+
function collectSecurityFacts(node, source, htmlTags, jsUrls) {
|
|
717
|
+
if (Array.isArray(node)) {
|
|
718
|
+
for (const child of node) collectSecurityFacts(child, source, htmlTags, jsUrls);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (!node || typeof node !== "object") return;
|
|
722
|
+
if (node.type === "HtmlTag") htmlTags.push({ line: lineOf(source, node.start) });
|
|
723
|
+
if ((node.type === "RegularElement" || node.type === "SvelteElement") && Array.isArray(node.attributes)) for (const name of URL_ATTRS) {
|
|
724
|
+
const attr = findAttr(node.attributes, name);
|
|
725
|
+
if (!attr) continue;
|
|
726
|
+
const value = attrTextOf(attr);
|
|
727
|
+
if (value !== void 0 && /^\s*javascript:/i.test(value)) jsUrls.push({ line: lineOf(source, attr.start ?? node.start) });
|
|
728
|
+
}
|
|
729
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) collectSecurityFacts(node[key], source, htmlTags, jsUrls);
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* `<input type="checkbox">` / `<input type="radio">` elements carrying a `bind:value`
|
|
733
|
+
* directive (correctness/checkable-bind-value) — bind:value binds the DOM value property,
|
|
734
|
+
* which checkbox/radio interaction never changes, so the bound state silently never updates.
|
|
735
|
+
* Only `RegularElement` (a static `<input>`) is checked, so a dynamic tag via
|
|
736
|
+
* `<svelte:element this="input" …>` is naturally skipped (it is a different node type). A
|
|
737
|
+
* dynamic `type={expr}` makes `attrTextOf` return `undefined`, which is also skipped.
|
|
738
|
+
*/
|
|
739
|
+
function collectCheckableBindValues(node, source, acc) {
|
|
740
|
+
if (Array.isArray(node)) {
|
|
741
|
+
for (const child of node) collectCheckableBindValues(child, source, acc);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (!node || typeof node !== "object") return;
|
|
745
|
+
if (node.type === "RegularElement" && node.name === "input" && Array.isArray(node.attributes)) {
|
|
746
|
+
const typeAttr = findAttr(node.attributes, "type");
|
|
747
|
+
const typeValue = typeAttr ? attrTextOf(typeAttr) : void 0;
|
|
748
|
+
if (typeValue && CHECKABLE_INPUT_TYPES.has(typeValue)) {
|
|
749
|
+
const bindValue = node.attributes.find((a) => a?.type === "BindDirective" && a.name === "value");
|
|
750
|
+
if (bindValue) acc.push({
|
|
751
|
+
kind: typeValue,
|
|
752
|
+
line: lineOf(source, bindValue.start ?? node.start)
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) collectCheckableBindValues(node[key], source, acc);
|
|
757
|
+
}
|
|
758
|
+
/** Classify a single Attribute's raw `value`: bare (`true`) is a literal `''`, a single Text
|
|
759
|
+
* node is its literal text, anything else (an ExpressionTag or a multi-part template) is dynamic. */
|
|
760
|
+
function classifyAttrValue(value) {
|
|
761
|
+
if (value === true) return { literal: "" };
|
|
762
|
+
if (Array.isArray(value) && value.length === 1 && value[0]?.type === "Text") return { literal: String(value[0].data ?? "") };
|
|
763
|
+
return { expression: true };
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Every element, namespace-tracked: `inSvg` is set on `<svg>` and everything under it, and cleared
|
|
767
|
+
* again under `<foreignObject>`; a component declaring `<svelte:options namespace="svg" />` starts
|
|
768
|
+
* inside SVG, since a `<g>`-root partial has no `<svg>` of its own. `<svelte:element this="…">` is
|
|
769
|
+
* skipped — the tag is dynamic, so neither its name nor its namespace is known — and its subtree
|
|
770
|
+
* keeps the surrounding namespace. Compared lowercased throughout — the AST keeps source spelling,
|
|
771
|
+
* HTML does not.
|
|
772
|
+
*/
|
|
773
|
+
const CHAIN_BREAKS = /* @__PURE__ */ new Set([
|
|
774
|
+
"Component",
|
|
775
|
+
"SvelteComponent",
|
|
776
|
+
"SvelteSelf",
|
|
777
|
+
"SvelteElement",
|
|
778
|
+
"SlotElement",
|
|
779
|
+
"RenderTag",
|
|
780
|
+
"HtmlTag",
|
|
781
|
+
"SnippetBlock",
|
|
782
|
+
"SvelteHead"
|
|
783
|
+
]);
|
|
784
|
+
const SILENT_BREAKS = /* @__PURE__ */ new Set(["SnippetBlock", "SvelteHead"]);
|
|
785
|
+
function collectElements(node, source, acc, inSvg, parent) {
|
|
786
|
+
if (Array.isArray(node)) {
|
|
787
|
+
for (const child of node) collectElements(child, source, acc, inSvg, parent);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (!node || typeof node !== "object") return;
|
|
791
|
+
let next = inSvg;
|
|
792
|
+
let nextParent = parent;
|
|
793
|
+
if (typeof node.type === "string" && CHAIN_BREAKS.has(node.type)) {
|
|
794
|
+
if (parent !== void 0 && !SILENT_BREAKS.has(node.type)) acc[parent].unknownContent = true;
|
|
795
|
+
nextParent = void 0;
|
|
796
|
+
} else if (node.type === "RegularElement" && typeof node.name === "string" && Array.isArray(node.attributes)) {
|
|
797
|
+
const tag = node.name.toLowerCase();
|
|
798
|
+
const self = inSvg || tag === "svg";
|
|
799
|
+
const hasSpread = node.attributes.some((a) => a?.type === "SpreadAttribute");
|
|
800
|
+
acc.push({
|
|
801
|
+
tag,
|
|
802
|
+
line: lineOf(source, node.start),
|
|
803
|
+
attrs: node.attributes.filter((a) => a?.type === "Attribute" && typeof a.name === "string").map((a) => {
|
|
804
|
+
const cls = classifyAttrValue(a.value);
|
|
805
|
+
const value = "expression" in cls ? void 0 : cls.literal;
|
|
806
|
+
return {
|
|
807
|
+
name: String(a.name).toLowerCase(),
|
|
808
|
+
line: lineOf(source, a.start ?? node.start),
|
|
809
|
+
...value !== void 0 ? { value } : {}
|
|
810
|
+
};
|
|
811
|
+
}),
|
|
812
|
+
...self ? { inSvg: true } : {},
|
|
813
|
+
...parent !== void 0 ? { parent } : {},
|
|
814
|
+
...hasSpread ? { hasSpread: true } : {}
|
|
815
|
+
});
|
|
816
|
+
if (tag.includes("-") || !KNOWN_TAGS.has(tag)) {
|
|
817
|
+
if (parent !== void 0) acc[parent].unknownContent = true;
|
|
818
|
+
nextParent = void 0;
|
|
819
|
+
} else nextParent = acc.length - 1;
|
|
820
|
+
if (tag === "svg") next = true;
|
|
821
|
+
else if (tag === "foreignobject") next = false;
|
|
822
|
+
}
|
|
823
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) collectElements(node[key], source, acc, next, nextParent);
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Elements carrying a `role` and/or `aria-*` attribute(s) (a11y ARIA rules). Only
|
|
827
|
+
* `RegularElement` is checked, so `<svelte:element this="div" role="…">` is out of static reach —
|
|
828
|
+
* same convention as `collectCheckableBindValues`/`collectHrefLinks`.
|
|
829
|
+
*/
|
|
830
|
+
/** HTML-AAM: a `<select>` is a combobox unless `multiple` or a display `size` above 1 makes it a listbox. */
|
|
831
|
+
function selectNativeRole(attributes) {
|
|
832
|
+
if (findAttr(attributes, "multiple") !== void 0) return "listbox";
|
|
833
|
+
const size = findAttr(attributes, "size");
|
|
834
|
+
if (size === void 0) return "combobox";
|
|
835
|
+
if (attrValueOf(size) !== "static") return void 0;
|
|
836
|
+
return Number(attrTextOf(size)) > 1 ? "listbox" : "combobox";
|
|
837
|
+
}
|
|
838
|
+
function collectAriaElements(node, source, acc) {
|
|
839
|
+
if (Array.isArray(node)) {
|
|
840
|
+
for (const child of node) collectAriaElements(child, source, acc);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
if (!node || typeof node !== "object") return;
|
|
844
|
+
if (node.type === "RegularElement" && Array.isArray(node.attributes)) {
|
|
845
|
+
const roleAttr = findAttr(node.attributes, "role");
|
|
846
|
+
const ariaAttrs = node.attributes.filter((a) => a?.type === "Attribute" && typeof a.name === "string" && a.name.toLowerCase().startsWith("aria-"));
|
|
847
|
+
if (roleAttr || ariaAttrs.length > 0) {
|
|
848
|
+
const tag = node.name.toLowerCase();
|
|
849
|
+
const inputType = tag === "input" ? attrText(node.attributes, "type") : void 0;
|
|
850
|
+
const hasList = tag === "input" && findAttr(node.attributes, "list") !== void 0;
|
|
851
|
+
const selectKind = tag === "select" ? selectNativeRole(node.attributes) : void 0;
|
|
852
|
+
const hasSpread = node.attributes.some((a) => a?.type === "SpreadAttribute");
|
|
853
|
+
acc.push({
|
|
854
|
+
tag,
|
|
855
|
+
line: lineOf(source, node.start),
|
|
856
|
+
...roleAttr ? { role: classifyAttrValue(roleAttr.value) } : {},
|
|
857
|
+
...inputType !== void 0 ? { inputType: inputType.toLowerCase() } : {},
|
|
858
|
+
...hasList ? { hasList: true } : {},
|
|
859
|
+
...selectKind ? { selectKind } : {},
|
|
860
|
+
...hasSpread ? { hasSpread: true } : {},
|
|
861
|
+
aria: ariaAttrs.map((a) => ({
|
|
862
|
+
name: String(a.name).toLowerCase(),
|
|
863
|
+
line: lineOf(source, a.start ?? node.start),
|
|
864
|
+
...classifyAttrValue(a.value)
|
|
865
|
+
}))
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) collectAriaElements(node[key], source, acc);
|
|
870
|
+
}
|
|
871
|
+
/** This element's `Attribute` nodes (directives/spreads excluded) as `isInteractiveElement`'s
|
|
872
|
+
* input shape. */
|
|
873
|
+
function elementAttrs(attributes) {
|
|
874
|
+
return attributes.filter((a) => a?.type === "Attribute" && typeof a.name === "string").map((a) => ({
|
|
875
|
+
name: String(a.name).toLowerCase(),
|
|
876
|
+
...classifyAttrValue(a.value)
|
|
877
|
+
}));
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Interactive elements nested inside another interactive container (a11y/interactive-nesting).
|
|
881
|
+
* Only `RegularElement` is checked, so `<svelte:element this="button">` is out of static reach
|
|
882
|
+
* — same convention as `collectAriaElements`. `stack` tracks the enclosing containers
|
|
883
|
+
* (`isInteractiveContainer`); any interactive element entering while it is non-empty records
|
|
884
|
+
* one fact at the descendant's line, keyed to the nearest enclosing container.
|
|
885
|
+
*/
|
|
886
|
+
/** The literal role that made an element a container, for the finding's message. */
|
|
887
|
+
function containerRoleOf(attrs) {
|
|
888
|
+
const role = attrs.find((a) => a.name === "role")?.literal;
|
|
889
|
+
return role ? resolveRole(splitTokens(role)) : void 0;
|
|
890
|
+
}
|
|
891
|
+
function collectInteractiveNestings(node, source, acc, stack) {
|
|
892
|
+
if (Array.isArray(node)) {
|
|
893
|
+
for (const child of node) collectInteractiveNestings(child, source, acc, stack);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (!node || typeof node !== "object") return;
|
|
897
|
+
if (node.type === "SnippetBlock") {
|
|
898
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) collectInteractiveNestings(node[key], source, acc, []);
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
let opened = false;
|
|
902
|
+
if (node.type === "RegularElement" && Array.isArray(node.attributes)) {
|
|
903
|
+
const attrs = elementAttrs(node.attributes);
|
|
904
|
+
if (stack.length > 0 && isInteractiveElement(node.name, attrs)) {
|
|
905
|
+
const container = stack[stack.length - 1];
|
|
906
|
+
acc.push({
|
|
907
|
+
containerTag: container.tag,
|
|
908
|
+
...container.role ? { containerRole: container.role } : {},
|
|
909
|
+
descendantTag: node.name,
|
|
910
|
+
line: lineOf(source, node.start)
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
if (isInteractiveContainer(node.name, attrs)) {
|
|
914
|
+
const role = node.name === "button" || node.name === "a" ? void 0 : containerRoleOf(attrs);
|
|
915
|
+
stack.push({
|
|
916
|
+
tag: node.name,
|
|
917
|
+
...role ? { role } : {}
|
|
918
|
+
});
|
|
919
|
+
opened = true;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) collectInteractiveNestings(node[key], source, acc, stack);
|
|
923
|
+
if (opened) stack.pop();
|
|
924
|
+
}
|
|
925
|
+
/** Tags on which a `disabled` attribute actually removes focusability (it does nothing on `<a>`). */
|
|
926
|
+
const DISABLEABLE_TAGS = /* @__PURE__ */ new Set([
|
|
927
|
+
"button",
|
|
928
|
+
"input",
|
|
929
|
+
"select",
|
|
930
|
+
"textarea"
|
|
931
|
+
]);
|
|
932
|
+
/**
|
|
933
|
+
* Keyboard-focusable elements hidden by a literal `aria-hidden="true"` (a11y/aria-hidden-focus).
|
|
934
|
+
* Same conventions as `collectInteractiveNestings`: only `RegularElement`, snippet bodies walk
|
|
935
|
+
* with a fresh stack (they render at their `{@render}` site), and an expression `aria-hidden`
|
|
936
|
+
* is unknowable — a toggled `aria-hidden={...}` never opens a container. Exempt as not
|
|
937
|
+
* focusable: a `tabindex` that is negative (the documented remediation) or an expression (it
|
|
938
|
+
* may resolve to -1), a `disabled` form control, and anything at or under an `inert` or
|
|
939
|
+
* `hidden` element — an inert subtree leaves the tab order and a hidden one does not render,
|
|
940
|
+
* so both carry down the walk as `unfocusable`.
|
|
941
|
+
*/
|
|
942
|
+
function collectAriaHiddenFocusables(node, source, acc, stack, unfocusable = false) {
|
|
943
|
+
if (Array.isArray(node)) {
|
|
944
|
+
for (const child of node) collectAriaHiddenFocusables(child, source, acc, stack, unfocusable);
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
if (!node || typeof node !== "object") return;
|
|
948
|
+
if (node.type === "SnippetBlock") {
|
|
949
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) collectAriaHiddenFocusables(node[key], source, acc, []);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
let opened = false;
|
|
953
|
+
if (node.type === "RegularElement" && Array.isArray(node.attributes)) {
|
|
954
|
+
const attrs = elementAttrs(node.attributes);
|
|
955
|
+
if (attrs.some((a) => a.name === "inert" || a.name === "hidden")) unfocusable = true;
|
|
956
|
+
const hiddenSelf = attrs.find((a) => a.name === "aria-hidden")?.literal?.trim().toLowerCase() === "true";
|
|
957
|
+
const tabindexAttr = attrs.find((a) => a.name === "tabindex");
|
|
958
|
+
const tabindex = literalTabindexValue(tabindexAttr?.literal);
|
|
959
|
+
const tabindexExempts = tabindexAttr !== void 0 && tabindexAttr.literal === void 0;
|
|
960
|
+
if (!unfocusable && !tabindexExempts && !(tabindex !== void 0 && tabindex < 0) && !(DISABLEABLE_TAGS.has(node.name) && attrs.some((a) => a.name === "disabled")) && isInteractiveElement(node.name, attrs) && (hiddenSelf || stack.length > 0)) acc.push({
|
|
961
|
+
tag: node.name,
|
|
962
|
+
...hiddenSelf ? {} : { containerTag: stack[stack.length - 1] },
|
|
963
|
+
line: lineOf(source, node.start)
|
|
964
|
+
});
|
|
965
|
+
if (hiddenSelf) {
|
|
966
|
+
stack.push(node.name);
|
|
967
|
+
opened = true;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) collectAriaHiddenFocusables(node[key], source, acc, stack, unfocusable);
|
|
971
|
+
if (opened) stack.pop();
|
|
972
|
+
}
|
|
973
|
+
/** `aria-label`/`aria-labelledby`/`title` present on this element: a literal non-empty value,
|
|
974
|
+
* or any expression (its rendered value is unknowable, but its *presence* is enough to name
|
|
975
|
+
* the element — a11y/accessible-name treats an expression name source as present). */
|
|
976
|
+
/** An attribute present with a non-blank literal, or with an expression whose value is unknowable
|
|
977
|
+
* and so must be assumed to carry one. */
|
|
978
|
+
function hasNamingValue(attributes, name) {
|
|
979
|
+
const attr = findAttr(attributes, name);
|
|
980
|
+
if (!attr) return false;
|
|
981
|
+
const v = classifyAttrValue(attr.value);
|
|
982
|
+
return "expression" in v || v.literal !== void 0 && v.literal.trim().length > 0;
|
|
983
|
+
}
|
|
984
|
+
function hasNamingAttr(attributes) {
|
|
985
|
+
return [
|
|
986
|
+
"aria-label",
|
|
987
|
+
"aria-labelledby",
|
|
988
|
+
"title"
|
|
989
|
+
].some((name) => hasNamingValue(attributes, name));
|
|
990
|
+
}
|
|
991
|
+
/** A tag name with a hyphen is a custom element: it may be form-associated, and its shadow root may
|
|
992
|
+
* supply content, neither of which is visible here. Treated as unknowable by both a11y scanners. */
|
|
993
|
+
function isCustomElement(node) {
|
|
994
|
+
return node.type === "RegularElement" && typeof node.name === "string" && node.name.includes("-");
|
|
995
|
+
}
|
|
996
|
+
function scanAccessibleNameSubtree(node, skip) {
|
|
997
|
+
if (Array.isArray(node)) {
|
|
998
|
+
const acc = {
|
|
999
|
+
named: false,
|
|
1000
|
+
unknowable: false
|
|
1001
|
+
};
|
|
1002
|
+
for (const child of node) {
|
|
1003
|
+
const r = scanAccessibleNameSubtree(child, skip);
|
|
1004
|
+
acc.named ||= r.named;
|
|
1005
|
+
acc.unknowable ||= r.unknowable;
|
|
1006
|
+
}
|
|
1007
|
+
return acc;
|
|
1008
|
+
}
|
|
1009
|
+
if (!node || typeof node !== "object") return {
|
|
1010
|
+
named: false,
|
|
1011
|
+
unknowable: false
|
|
1012
|
+
};
|
|
1013
|
+
if (skip !== void 0 && node === skip) return {
|
|
1014
|
+
named: false,
|
|
1015
|
+
unknowable: false
|
|
1016
|
+
};
|
|
1017
|
+
if (node.type === "SnippetBlock") return {
|
|
1018
|
+
named: false,
|
|
1019
|
+
unknowable: false
|
|
1020
|
+
};
|
|
1021
|
+
if (node.type === "Text") return {
|
|
1022
|
+
named: String(node.data ?? "").trim().length > 0,
|
|
1023
|
+
unknowable: false
|
|
1024
|
+
};
|
|
1025
|
+
if (node.type === "ExpressionTag" || node.type === "RenderTag" || node.type === "HtmlTag" || node.type === "SlotElement" || node.type === "SvelteFragment" || COMPONENT_LIKE_TYPES.has(node.type) || isCustomElement(node)) return {
|
|
1026
|
+
named: false,
|
|
1027
|
+
unknowable: true
|
|
1028
|
+
};
|
|
1029
|
+
if (node.type === "RegularElement" && node.name === "img" && Array.isArray(node.attributes)) {
|
|
1030
|
+
if (hasNamingValue(node.attributes, "alt")) return {
|
|
1031
|
+
named: true,
|
|
1032
|
+
unknowable: false
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
const acc = {
|
|
1036
|
+
named: false,
|
|
1037
|
+
unknowable: false
|
|
1038
|
+
};
|
|
1039
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) {
|
|
1040
|
+
const r = scanAccessibleNameSubtree(node[key], skip);
|
|
1041
|
+
acc.named ||= r.named;
|
|
1042
|
+
acc.unknowable ||= r.unknowable;
|
|
1043
|
+
}
|
|
1044
|
+
return acc;
|
|
1045
|
+
}
|
|
1046
|
+
/** Which accessible-name target `node` is, or undefined if it isn't one (a11y/accessible-name).
|
|
1047
|
+
* `a` needs a literal `href` (an expression href is unknowable whether the anchor even renders
|
|
1048
|
+
* as a link, matching the `isInteractiveContainer` convention above); `input` needs a literal
|
|
1049
|
+
* `type="image"`. */
|
|
1050
|
+
function accessibleNameTarget(node) {
|
|
1051
|
+
if (node.name === "button") return "button";
|
|
1052
|
+
if (node.name === "a") return attrText(node.attributes, "href") !== void 0 ? "a" : void 0;
|
|
1053
|
+
if (node.name === "input") return attrText(node.attributes, "type")?.toLowerCase() === "image" ? "input" : void 0;
|
|
1054
|
+
}
|
|
1055
|
+
/** The first labelable descendant of a `<label>` — the only control an implicit association
|
|
1056
|
+
* reaches, so a second control inside the same label is not named by it. */
|
|
1057
|
+
function firstLabelableDescendant(node) {
|
|
1058
|
+
if (Array.isArray(node)) {
|
|
1059
|
+
for (const child of node) {
|
|
1060
|
+
const hit = firstLabelableDescendant(child);
|
|
1061
|
+
if (hit) return hit;
|
|
1062
|
+
}
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
if (!node || typeof node !== "object") return void 0;
|
|
1066
|
+
if (node.type === "SnippetBlock") return void 0;
|
|
1067
|
+
if (node.type === "RegularElement" && isLabelableDescendant(node)) return node;
|
|
1068
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) {
|
|
1069
|
+
const hit = firstLabelableDescendant(node[key]);
|
|
1070
|
+
if (hit) return hit;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* What each `<label>` in the file names: the literal `for` it points at, and the one control it
|
|
1075
|
+
* encapsulates. HTML-AAM gives `button` and `input type="image"` this step ahead of their own
|
|
1076
|
+
* subtree, so a label that contributes a name — or content this analysis cannot read — takes the
|
|
1077
|
+
* element out of `a11y/accessible-name`'s reach. A label that is itself provably empty does not:
|
|
1078
|
+
* the control stays unnamed and is still reported.
|
|
1079
|
+
*/
|
|
1080
|
+
function collectLabelTargets(node, acc) {
|
|
1081
|
+
if (Array.isArray(node)) {
|
|
1082
|
+
for (const child of node) collectLabelTargets(child, acc);
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
if (!node || typeof node !== "object") return;
|
|
1086
|
+
if (node.type === "RegularElement" && node.name === "label" && Array.isArray(node.attributes)) {
|
|
1087
|
+
const wrapped = firstLabelableDescendant(node);
|
|
1088
|
+
const scan = scanAccessibleNameSubtree(node, wrapped);
|
|
1089
|
+
if (scan.named || scan.unknowable) {
|
|
1090
|
+
const forId = attrText(node.attributes, "for");
|
|
1091
|
+
if (forId !== void 0 && forId.trim().length > 0) acc.ids.add(forId.trim());
|
|
1092
|
+
if (wrapped) acc.nodes.add(wrapped);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) collectLabelTargets(node[key], acc);
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* `button`/`a href`/`input type="image"` elements with no computable accessible name
|
|
1099
|
+
* (a11y/accessible-name). A spread attribute on the element itself makes its rendered
|
|
1100
|
+
* attributes unknowable, so the element is skipped entirely rather than risk a false positive.
|
|
1101
|
+
*
|
|
1102
|
+
* `labels` carries the two `<label>` name routes, which `<a>` does not have.
|
|
1103
|
+
*/
|
|
1104
|
+
function collectUnnamedInteractive(node, source, acc, labels) {
|
|
1105
|
+
if (Array.isArray(node)) {
|
|
1106
|
+
for (const child of node) collectUnnamedInteractive(child, source, acc, labels);
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
if (!node || typeof node !== "object") return;
|
|
1110
|
+
if (node.type === "RegularElement" && Array.isArray(node.attributes)) {
|
|
1111
|
+
const target = accessibleNameTarget(node);
|
|
1112
|
+
const hasSpread = node.attributes.some((a) => a?.type === "SpreadAttribute");
|
|
1113
|
+
const id = attrText(node.attributes, "id");
|
|
1114
|
+
const namedByLabel = target !== "a" && (labels.nodes.has(node) || id !== void 0 && labels.ids.has(id.trim()));
|
|
1115
|
+
if (target && !hasSpread && !namedByLabel) if (target === "input") {
|
|
1116
|
+
if (!hasNamingAttr(node.attributes) && !hasNamingValue(node.attributes, "alt")) acc.push({
|
|
1117
|
+
tag: node.name,
|
|
1118
|
+
line: lineOf(source, node.start)
|
|
1119
|
+
});
|
|
1120
|
+
} else {
|
|
1121
|
+
const scan = scanAccessibleNameSubtree(node);
|
|
1122
|
+
if (!hasNamingAttr(node.attributes) && !scan.named && !scan.unknowable) acc.push({
|
|
1123
|
+
tag: node.name,
|
|
1124
|
+
line: lineOf(source, node.start)
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) collectUnnamedInteractive(node[key], source, acc, labels);
|
|
1129
|
+
}
|
|
1130
|
+
/** Whether `node` is a labelable element that associates a `<label>` by wrapping it
|
|
1131
|
+
* (a11y/label-has-control): `input` (unless its literal `type` is `hidden`), `select`,
|
|
1132
|
+
* `textarea`, `button`, `meter`, `output`, `progress`. */
|
|
1133
|
+
const LABELABLE_TAGS = /* @__PURE__ */ new Set([
|
|
1134
|
+
"input",
|
|
1135
|
+
"select",
|
|
1136
|
+
"textarea",
|
|
1137
|
+
"button",
|
|
1138
|
+
"meter",
|
|
1139
|
+
"output",
|
|
1140
|
+
"progress"
|
|
1141
|
+
]);
|
|
1142
|
+
function isLabelableDescendant(node) {
|
|
1143
|
+
if (node.type !== "RegularElement" || !LABELABLE_TAGS.has(node.name)) return false;
|
|
1144
|
+
if (node.name !== "input") return true;
|
|
1145
|
+
return attrText(node.attributes ?? [], "type")?.toLowerCase() !== "hidden";
|
|
1146
|
+
}
|
|
1147
|
+
function scanLabelSubtree(node) {
|
|
1148
|
+
if (Array.isArray(node)) {
|
|
1149
|
+
const acc = {
|
|
1150
|
+
hasControl: false,
|
|
1151
|
+
unknowable: false
|
|
1152
|
+
};
|
|
1153
|
+
for (const child of node) {
|
|
1154
|
+
const r = scanLabelSubtree(child);
|
|
1155
|
+
acc.hasControl ||= r.hasControl;
|
|
1156
|
+
acc.unknowable ||= r.unknowable;
|
|
1157
|
+
}
|
|
1158
|
+
return acc;
|
|
1159
|
+
}
|
|
1160
|
+
if (!node || typeof node !== "object") return {
|
|
1161
|
+
hasControl: false,
|
|
1162
|
+
unknowable: false
|
|
1163
|
+
};
|
|
1164
|
+
if (node.type === "SnippetBlock") return {
|
|
1165
|
+
hasControl: false,
|
|
1166
|
+
unknowable: false
|
|
1167
|
+
};
|
|
1168
|
+
if (node.type === "ExpressionTag" || node.type === "RenderTag" || node.type === "HtmlTag" || node.type === "SlotElement" || node.type === "SvelteFragment" || COMPONENT_LIKE_TYPES.has(node.type) || isCustomElement(node)) return {
|
|
1169
|
+
hasControl: false,
|
|
1170
|
+
unknowable: true
|
|
1171
|
+
};
|
|
1172
|
+
if (isLabelableDescendant(node)) return {
|
|
1173
|
+
hasControl: true,
|
|
1174
|
+
unknowable: false
|
|
1175
|
+
};
|
|
1176
|
+
const acc = {
|
|
1177
|
+
hasControl: false,
|
|
1178
|
+
unknowable: false
|
|
1179
|
+
};
|
|
1180
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) {
|
|
1181
|
+
const r = scanLabelSubtree(node[key]);
|
|
1182
|
+
acc.hasControl ||= r.hasControl;
|
|
1183
|
+
acc.unknowable ||= r.unknowable;
|
|
1184
|
+
}
|
|
1185
|
+
return acc;
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* `<label>` elements with neither a `for` attribute (literal or expression — its presence is
|
|
1189
|
+
* enough) nor a wrapped labelable descendant (a11y/label-has-control). A spread attribute may
|
|
1190
|
+
* supply `for`, so it is treated the same as an explicit one.
|
|
1191
|
+
*/
|
|
1192
|
+
function collectUnassociatedLabels(node, source, acc) {
|
|
1193
|
+
if (Array.isArray(node)) {
|
|
1194
|
+
for (const child of node) collectUnassociatedLabels(child, source, acc);
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
if (!node || typeof node !== "object") return;
|
|
1198
|
+
if (node.type === "RegularElement" && node.name === "label" && Array.isArray(node.attributes)) {
|
|
1199
|
+
const hasFor = findAttr(node.attributes, "for") !== void 0;
|
|
1200
|
+
const hasSpread = node.attributes.some((a) => a?.type === "SpreadAttribute");
|
|
1201
|
+
if (!hasFor && !hasSpread) {
|
|
1202
|
+
const scan = scanLabelSubtree(node);
|
|
1203
|
+
if (!scan.hasControl && !scan.unknowable) acc.push({ line: lineOf(source, node.start) });
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) collectUnassociatedLabels(node[key], source, acc);
|
|
1207
|
+
}
|
|
1208
|
+
/** Requires trailing whitespace after the bullet char to avoid flagging `-webkit` prose or a
|
|
1209
|
+
* signed number like `-1` (a11y/use-list). */
|
|
1210
|
+
const BULLET_TEXT_RE = /^[•・·\-*]\s/;
|
|
1211
|
+
/** Ancestries whose text is code, keystrokes or user input rather than prose, so a leading dash
|
|
1212
|
+
* is a character in the content and not a bullet (a11y/use-list). */
|
|
1213
|
+
const VERBATIM_TEXT_TAGS = /* @__PURE__ */ new Set([
|
|
1214
|
+
"pre",
|
|
1215
|
+
"code",
|
|
1216
|
+
"kbd",
|
|
1217
|
+
"samp",
|
|
1218
|
+
"textarea"
|
|
1219
|
+
]);
|
|
1220
|
+
/**
|
|
1221
|
+
* Text nodes whose trimmed content opens with a bullet character followed by whitespace,
|
|
1222
|
+
* outside any `li` ancestor — a plain-text bullet where a `<ul>`/`<ol>` would give assistive
|
|
1223
|
+
* technology a real list structure (a11y/use-list). A list is two or more items: a lone bullet
|
|
1224
|
+
* line (`<p>- note</p>`) is a dash, not a list, and WCAG H48 is about sequences. So bullet texts
|
|
1225
|
+
* count only in twos or more under one parent — either as sibling text nodes (`- one<br>- two`)
|
|
1226
|
+
* or as the opening text of sibling elements (`<p>• one</p><p>• two</p>`).
|
|
1227
|
+
*/
|
|
1228
|
+
function collectBulletTexts(node, source, acc, inert, afterExpression = false, reported = /* @__PURE__ */ new WeakSet()) {
|
|
1229
|
+
if (Array.isArray(node)) {
|
|
1230
|
+
let prevWasExpression = afterExpression;
|
|
1231
|
+
let group = [];
|
|
1232
|
+
const flush = () => {
|
|
1233
|
+
if (group.length >= 2) for (const b of group) {
|
|
1234
|
+
if (reported.has(b.text)) continue;
|
|
1235
|
+
reported.add(b.text);
|
|
1236
|
+
acc.push({
|
|
1237
|
+
line: b.line,
|
|
1238
|
+
char: b.char
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
group = [];
|
|
1242
|
+
};
|
|
1243
|
+
const bulletOf = (text, after) => {
|
|
1244
|
+
if (!text || typeof text !== "object" || text.type !== "Text" || after) return void 0;
|
|
1245
|
+
const trimmed = String(text.data ?? "").trim();
|
|
1246
|
+
return BULLET_TEXT_RE.test(trimmed) ? {
|
|
1247
|
+
line: lineOf(source, text.start),
|
|
1248
|
+
char: trimmed[0],
|
|
1249
|
+
text
|
|
1250
|
+
} : void 0;
|
|
1251
|
+
};
|
|
1252
|
+
for (const child of node) {
|
|
1253
|
+
if (!child || typeof child !== "object") continue;
|
|
1254
|
+
if (child.type === "Text") {
|
|
1255
|
+
const b = inert ? void 0 : bulletOf(child, prevWasExpression);
|
|
1256
|
+
if (b) group.push(b);
|
|
1257
|
+
else if (String(child.data ?? "").trim()) flush();
|
|
1258
|
+
} else if (child.type === "Comment" || child.type === "RegularElement" && child.name === "br") {} else {
|
|
1259
|
+
let item;
|
|
1260
|
+
if (!inert && child.type === "RegularElement" && child.name !== "li" && !VERBATIM_TEXT_TAGS.has(child.name)) item = bulletOf((child.fragment?.nodes ?? []).find((n) => !(n?.type === "Text" && !String(n.data ?? "").trim()) && n?.type !== "Comment"), prevWasExpression);
|
|
1261
|
+
if (item) group.push(item);
|
|
1262
|
+
else flush();
|
|
1263
|
+
collectBulletTexts(child, source, acc, inert, prevWasExpression, reported);
|
|
1264
|
+
}
|
|
1265
|
+
if (child.type !== "Comment") prevWasExpression = child.type === "ExpressionTag";
|
|
1266
|
+
}
|
|
1267
|
+
flush();
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
if (!node || typeof node !== "object") return;
|
|
1271
|
+
if (node.type === "Text") return;
|
|
1272
|
+
if (node.type === "SnippetBlock") return;
|
|
1273
|
+
const nowInert = inert || node.type === "RegularElement" && (node.name === "li" || VERBATIM_TEXT_TAGS.has(node.name));
|
|
1274
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) collectBulletTexts(node[key], source, acc, nowInert, false, reported);
|
|
1275
|
+
}
|
|
1276
|
+
/** Whether `<select attrs>` is subject to the placeholder-label-option requirement: `required`
|
|
1277
|
+
* present (literal — a dynamic `required` makes presence unknowable), no `multiple` (literal
|
|
1278
|
+
* or dynamic — either way this select is out of scope), and `size` absent or a literal ≤ 1
|
|
1279
|
+
* (a dynamic `size` is unknowable, so treated the same as out of scope). */
|
|
1280
|
+
function selectNeedsPlaceholder(attributes) {
|
|
1281
|
+
const requiredAttr = findAttr(attributes, "required");
|
|
1282
|
+
if (!requiredAttr || attrValueOf(requiredAttr) === "dynamic") return false;
|
|
1283
|
+
if (findAttr(attributes, "multiple")) return false;
|
|
1284
|
+
const sizeAttr = findAttr(attributes, "size");
|
|
1285
|
+
if (sizeAttr) {
|
|
1286
|
+
if (attrValueOf(sizeAttr) === "dynamic") return false;
|
|
1287
|
+
const size = Number(attrText(attributes, "size"));
|
|
1288
|
+
if (!(Number.isFinite(size) && size <= 1)) return false;
|
|
1289
|
+
}
|
|
1290
|
+
return true;
|
|
1291
|
+
}
|
|
1292
|
+
/** First child of a `<select>`'s fragment that isn't purely a whitespace `Text` node or a
|
|
1293
|
+
* `Comment` — what the browser treats as the element's first real content. */
|
|
1294
|
+
function firstSignificantChild(nodes) {
|
|
1295
|
+
for (const child of nodes ?? []) {
|
|
1296
|
+
if (!child) continue;
|
|
1297
|
+
if (child.type === "Comment") continue;
|
|
1298
|
+
if (child.type === "Text" && !String(child.data ?? "").trim()) continue;
|
|
1299
|
+
return child;
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
/** Whether an `<option>` is a valid placeholder label option per the HTML spec: an empty
|
|
1303
|
+
* `value` attribute, or no `value` attribute and no text content. A dynamic `value` or a
|
|
1304
|
+
* dynamic text child makes the option's effective value unknowable, so it is treated the
|
|
1305
|
+
* same as a valid placeholder — proving a violation is what this rule requires, not guessing one. */
|
|
1306
|
+
function isPlaceholderOption(option) {
|
|
1307
|
+
const attributes = option.attributes ?? [];
|
|
1308
|
+
if (findAttr(attributes, "value")) {
|
|
1309
|
+
const literal = attrText(attributes, "value");
|
|
1310
|
+
return literal === void 0 || literal === "";
|
|
1311
|
+
}
|
|
1312
|
+
return textFromNodes(option.fragment?.nodes ?? []) === void 0;
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* `<select required>` (no `multiple`, display size absent or ≤ 1) whose first `option` element
|
|
1316
|
+
* child is not a placeholder label option (a11y/placeholder-label-option). The first significant
|
|
1317
|
+
* child not being a literal `option` — an `{#each}` block, a Component, an `{#if}`, etc. — makes
|
|
1318
|
+
* the select's first option unknowable, so it is skipped rather than risk a false positive; a
|
|
1319
|
+
* `<select>` with no content at all has no placeholder to find, so it is flagged. A spread
|
|
1320
|
+
* attribute on the `<select>` itself (may supply `multiple`/`size`) or on the first `option`
|
|
1321
|
+
* (may supply `value`) makes the relevant check unknowable, so it is skipped too.
|
|
1322
|
+
*/
|
|
1323
|
+
function collectSelectsMissingPlaceholder(node, source, acc) {
|
|
1324
|
+
if (Array.isArray(node)) {
|
|
1325
|
+
for (const child of node) collectSelectsMissingPlaceholder(child, source, acc);
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
if (!node || typeof node !== "object") return;
|
|
1329
|
+
if (node.type === "RegularElement" && node.name === "select" && Array.isArray(node.attributes)) {
|
|
1330
|
+
if (!node.attributes.some((a) => a?.type === "SpreadAttribute") && selectNeedsPlaceholder(node.attributes)) {
|
|
1331
|
+
const first = firstSignificantChild(node.fragment?.nodes);
|
|
1332
|
+
if (!first) acc.push({ line: lineOf(source, node.start) });
|
|
1333
|
+
else if (first.type === "RegularElement" && first.name === "option") {
|
|
1334
|
+
if (!(first.attributes ?? []).some((a) => a?.type === "SpreadAttribute") && !isPlaceholderOption(first)) acc.push({ line: lineOf(source, node.start) });
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) collectSelectsMissingPlaceholder(node[key], source, acc);
|
|
1339
|
+
}
|
|
1340
|
+
/** Machine-readable literal formats a `<time>` may use without a `datetime` attribute — a
|
|
1341
|
+
* permissive subset of the HTML spec's valid time-string grammar (year/month/date, time,
|
|
1342
|
+
* date-time, yearless date, duration). Anything else needs an explicit `datetime`. */
|
|
1343
|
+
/** The `<time>` content syntaxes the HTML spec permits without a `datetime` attribute. A year is
|
|
1344
|
+
* four **or more** digits, and a duration has two spellings: the `PnYnMnD` form and the
|
|
1345
|
+
* alternative `4h 18m 3s` form, which is what recipes and media lengths are written in. */
|
|
1346
|
+
const MACHINE_READABLE_TIME = [
|
|
1347
|
+
/^\d{4,}(-\d{2}){0,2}$/,
|
|
1348
|
+
/^\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/,
|
|
1349
|
+
/^\d{4,}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/,
|
|
1350
|
+
/^\d{2}-\d{2}$/,
|
|
1351
|
+
/^\d{4,}-W\d{2}$/,
|
|
1352
|
+
/^(Z|[+-]\d{2}:?\d{2})$/,
|
|
1353
|
+
/^P(?=\d|T)[\d.,TYWDHMS]*$/i,
|
|
1354
|
+
/^\d+(\.\d+)?\s*[wdhms](\s+\d+(\.\d+)?\s*[wdhms])*$/i
|
|
1355
|
+
];
|
|
1356
|
+
/**
|
|
1357
|
+
* `<time>` elements with no `datetime` attribute whose content is plain literal text that isn't
|
|
1358
|
+
* itself machine-readable (a11y/require-datetime). Any non-`Text` child — an `ExpressionTag`, a
|
|
1359
|
+
* Component, a block — makes the rendered text unknowable, so the element is skipped rather than
|
|
1360
|
+
* risk a false positive. A spread attribute may itself supply `datetime`, so it is skipped too.
|
|
1361
|
+
*/
|
|
1362
|
+
function collectTimesMissingDatetime(node, source, acc) {
|
|
1363
|
+
if (Array.isArray(node)) {
|
|
1364
|
+
for (const child of node) collectTimesMissingDatetime(child, source, acc);
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
if (!node || typeof node !== "object") return;
|
|
1368
|
+
if (node.type === "RegularElement" && node.name === "time" && findAttr(node.attributes ?? [], "datetime") === void 0 && !(node.attributes ?? []).some((a) => a?.type === "SpreadAttribute")) {
|
|
1369
|
+
const nodes = node.fragment?.nodes ?? [];
|
|
1370
|
+
if (nodes.length > 0 && nodes.every((n) => n?.type === "Text")) {
|
|
1371
|
+
const trimmed = nodes.map((n) => String(n.data ?? "")).join("").trim();
|
|
1372
|
+
if (trimmed.length > 0 && !MACHINE_READABLE_TIME.some((re) => re.test(trimmed))) acc.push({
|
|
1373
|
+
line: lineOf(source, node.start),
|
|
1374
|
+
text: trimmed
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) collectTimesMissingDatetime(node[key], source, acc);
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Fully-static text content of a node list: concatenated `Text` data, looked through static
|
|
1382
|
+
* regular elements (`<dt><code>HTTP</code></dt>` names "HTTP"), with comments contributing
|
|
1383
|
+
* nothing. `undefined` when anything dynamic appears anywhere below — an expression, a
|
|
1384
|
+
* component, a block, `{@html}`/`{@render}`, `<svelte:element>`, a `<slot>`, or a custom
|
|
1385
|
+
* element (its shadow root may supply content) — because the rendered text is then unknowable.
|
|
1386
|
+
*/
|
|
1387
|
+
function staticTextOf(nodes) {
|
|
1388
|
+
let out = "";
|
|
1389
|
+
for (const n of nodes) {
|
|
1390
|
+
if (!n || typeof n !== "object") continue;
|
|
1391
|
+
if (n.type === "Comment") continue;
|
|
1392
|
+
if (n.type === "Text") {
|
|
1393
|
+
out += String(n.data ?? "");
|
|
1394
|
+
continue;
|
|
1395
|
+
}
|
|
1396
|
+
if (n.type === "RegularElement" && typeof n.name === "string" && !n.name.includes("-")) {
|
|
1397
|
+
const inner = staticTextOf(n.fragment?.nodes ?? []);
|
|
1398
|
+
if (inner === void 0) return void 0;
|
|
1399
|
+
out += inner;
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
return out;
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Duplicate `<dt>` names within one `<dl>` (a11y/no-duplicate-dt). Candidates are `<dt>`
|
|
1408
|
+
* elements that are direct children of the `<dl>`, or direct children of a direct `<div>`
|
|
1409
|
+
* child (the spec's div-wrapped name-value groups); a dt under a logic block or component is
|
|
1410
|
+
* not a candidate — its multiplicity or rendered content is unknowable, and unknowable names
|
|
1411
|
+
* can only ever add duplicates, never fabricate one between two static non-duplicates. A
|
|
1412
|
+
* candidate's name is its fully-static text content (`staticTextOf` above — static phrasing
|
|
1413
|
+
* markup like `<dt><code>HTTP</code></dt>` is looked through, comments contribute nothing),
|
|
1414
|
+
* trimmed and whitespace-collapsed; an empty name is not a candidate (two empty dts are a
|
|
1415
|
+
* missing-content defect, not a duplicate name).
|
|
1416
|
+
* Comparison is case-sensitive — the spec leaves the equality undefined, so only certain
|
|
1417
|
+
* duplicates are reported. Namespace is tracked like `collectElements`: a `<dl>` inside
|
|
1418
|
+
* `<svg>` never renders as an HTML definition list, so it is skipped; `<foreignObject>`
|
|
1419
|
+
* children return to HTML. Nested `<dl>`s get their own scope for free — the walk recurses
|
|
1420
|
+
* and a nested dl's dts are never the outer dl's direct children.
|
|
1421
|
+
*/
|
|
1422
|
+
function collectDuplicateDts(node, source, acc, inSvg) {
|
|
1423
|
+
if (Array.isArray(node)) {
|
|
1424
|
+
for (const child of node) collectDuplicateDts(child, source, acc, inSvg);
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
if (!node || typeof node !== "object") return;
|
|
1428
|
+
let next = inSvg;
|
|
1429
|
+
if (node.type === "RegularElement" && typeof node.name === "string") {
|
|
1430
|
+
const tag = node.name.toLowerCase();
|
|
1431
|
+
if (tag === "svg") next = true;
|
|
1432
|
+
else if (tag === "foreignobject") next = false;
|
|
1433
|
+
if (!inSvg && tag === "dl") {
|
|
1434
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1435
|
+
const judge = (dt) => {
|
|
1436
|
+
const raw = staticTextOf(dt.fragment?.nodes ?? []);
|
|
1437
|
+
if (raw === void 0) return;
|
|
1438
|
+
const name = raw.trim().replace(/\s+/g, " ");
|
|
1439
|
+
if (!name) return;
|
|
1440
|
+
if (seen.has(name)) acc.push({
|
|
1441
|
+
line: lineOf(source, dt.start),
|
|
1442
|
+
text: name
|
|
1443
|
+
});
|
|
1444
|
+
else seen.add(name);
|
|
1445
|
+
};
|
|
1446
|
+
for (const child of node.fragment?.nodes ?? []) {
|
|
1447
|
+
if (child?.type !== "RegularElement" || typeof child.name !== "string") continue;
|
|
1448
|
+
const childTag = child.name.toLowerCase();
|
|
1449
|
+
if (childTag === "dt") judge(child);
|
|
1450
|
+
else if (childTag === "div") {
|
|
1451
|
+
for (const inner of child.fragment?.nodes ?? []) if (inner?.type === "RegularElement" && typeof inner.name === "string" && inner.name.toLowerCase() === "dt") judge(inner);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) collectDuplicateDts(node[key], source, acc, next);
|
|
1457
|
+
}
|
|
1458
|
+
/**
|
|
1459
|
+
* `<video>` with a literal `autoplay` attribute and no `muted` in any form
|
|
1460
|
+
* (correctness/autoplay-muted). Presence is what autoplays, so any literal value counts
|
|
1461
|
+
* (`autoplay="false"` still autoplays); an expression value is unknowable and is skipped.
|
|
1462
|
+
* `muted` passes in every form — bare, `bind:muted`, a spread that could supply it, or
|
|
1463
|
+
* `muted={expr}`, whose value is deliberately not evaluated (issue #580 scopes the rule to
|
|
1464
|
+
* attribute presence). Only `RegularElement` is checked — `<svelte:element this="video">`
|
|
1465
|
+
* follows the same RegularElement-only convention as `collectCheckableBindValues`. Namespace
|
|
1466
|
+
* is tracked like `collectElements`: a `<video>` inside `<svg>` is an SVG-namespace element,
|
|
1467
|
+
* not an HTML video, so it is skipped; `<foreignObject>` children return to HTML.
|
|
1468
|
+
*/
|
|
1469
|
+
function collectVideosAutoplayNoMuted(node, source, acc, inSvg) {
|
|
1470
|
+
if (Array.isArray(node)) {
|
|
1471
|
+
for (const child of node) collectVideosAutoplayNoMuted(child, source, acc, inSvg);
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
if (!node || typeof node !== "object") return;
|
|
1475
|
+
let next = inSvg;
|
|
1476
|
+
if (node.type === "RegularElement" && typeof node.name === "string") {
|
|
1477
|
+
const tag = node.name.toLowerCase();
|
|
1478
|
+
if (tag === "svg") next = true;
|
|
1479
|
+
else if (tag === "foreignobject") next = false;
|
|
1480
|
+
if (!inSvg && tag === "video" && Array.isArray(node.attributes)) {
|
|
1481
|
+
const literalAutoplay = attrText(node.attributes, "autoplay") !== void 0;
|
|
1482
|
+
const hasMuted = findAttr(node.attributes, "muted") !== void 0 || node.attributes.some((a) => a?.type === "BindDirective" && a.name === "muted" || a?.type === "SpreadAttribute");
|
|
1483
|
+
if (literalAutoplay && !hasMuted) acc.push({ line: lineOf(source, node.start) });
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) collectVideosAutoplayNoMuted(node[key], source, acc, next);
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Root-relative `<a href="/…">` literals (correctness/base-path-navigation). Only
|
|
1490
|
+
* `RegularElement` anchors with a fully static href are considered, which is what makes the
|
|
1491
|
+
* correct forms self-excluding: `href="{base}/x"` and `href={resolve('/x')}` contain an
|
|
1492
|
+
* `ExpressionTag`, so `attrTextOf` returns undefined. `<svelte:element this="a">` is a
|
|
1493
|
+
* different node type and is out of static reach.
|
|
1494
|
+
*/
|
|
1495
|
+
function collectHrefLinks(node, source, acc) {
|
|
1496
|
+
if (Array.isArray(node)) {
|
|
1497
|
+
for (const child of node) collectHrefLinks(child, source, acc);
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
if (!node || typeof node !== "object") return;
|
|
1501
|
+
if (node.type === "RegularElement" && node.name === "a" && Array.isArray(node.attributes)) {
|
|
1502
|
+
const attr = findAttr(node.attributes, "href");
|
|
1503
|
+
const value = attr ? attrTextOf(attr) : void 0;
|
|
1504
|
+
if (value !== void 0 && isRootRelativePath(value)) acc.push({
|
|
1505
|
+
kind: "href",
|
|
1506
|
+
path: value,
|
|
1507
|
+
line: lineOf(source, attr?.start ?? node.start)
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
for (const key of CHILD_NODE_KEYS) if (key in node) collectHrefLinks(node[key], source, acc);
|
|
1511
|
+
}
|
|
1512
|
+
const GOTO_NAMES = /* @__PURE__ */ new Set(["goto"]);
|
|
1513
|
+
/**
|
|
1514
|
+
* Root-relative `goto('/…')` calls (correctness/base-path-navigation). Only a plain string
|
|
1515
|
+
* literal argument counts, which self-excludes the correct forms — `goto(resolve('/x'))` is a
|
|
1516
|
+
* CallExpression and ``goto(`${base}/x`)`` is a TemplateLiteral. `roots` are the nodes to walk
|
|
1517
|
+
* (the instance program plus the template fragment, so inline handlers are covered); `locals`
|
|
1518
|
+
* comes from the same program's imports.
|
|
1519
|
+
*/
|
|
1520
|
+
function collectGotoLinks(locals, roots, source, acc) {
|
|
1521
|
+
if (locals.size === 0) return;
|
|
1522
|
+
for (const root of roots) {
|
|
1523
|
+
if (!root) continue;
|
|
1524
|
+
walkEstree(root, (n) => {
|
|
1525
|
+
if (n.type !== "CallExpression" || n.callee?.type !== "Identifier" || !locals.has(n.callee.name)) return;
|
|
1526
|
+
const arg = n.arguments?.[0];
|
|
1527
|
+
if (arg?.type !== "Literal" || typeof arg.value !== "string" || !isRootRelativePath(arg.value)) return;
|
|
1528
|
+
acc.push({
|
|
1529
|
+
kind: "goto",
|
|
1530
|
+
path: arg.value,
|
|
1531
|
+
line: lineOf(source, n.start)
|
|
1532
|
+
});
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
/** Whether a CallExpression is a bare `$props()` call. */
|
|
1537
|
+
function isPropsCall(node) {
|
|
1538
|
+
return node?.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "$props";
|
|
1539
|
+
}
|
|
1540
|
+
/** Whether a CallExpression is a `$bindable(...)` call (a destructured prop's default value). */
|
|
1541
|
+
function isBindableCall(node) {
|
|
1542
|
+
return node?.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "$bindable";
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* Local identifier names bound to a prop from `$props()`: plain and renamed destructured
|
|
1546
|
+
* names, and the `...rest` binding. `includeBindable` controls whether a prop initialized
|
|
1547
|
+
* with `$bindable(...)` is included — `false` excludes it (correctness/prop-mutation: mutating
|
|
1548
|
+
* a `$bindable` prop is the intended contract), `true` includes it (correctness/stale-prop-derivation:
|
|
1549
|
+
* a `$bindable` prop can still be derived-from-at-init just like any other prop). `let props
|
|
1550
|
+
* = $props()` (no destructuring) tracks `props` itself, since none of its fields can be
|
|
1551
|
+
* `$bindable` either. Returns an empty set when `$props()` appears more than once, or a
|
|
1552
|
+
* destructuring shape is ambiguous (nested pattern) — conservative, to avoid false positives
|
|
1553
|
+
* rather than chase every shape.
|
|
1554
|
+
*/
|
|
1555
|
+
function collectPropNames(program, includeBindable) {
|
|
1556
|
+
const names = /* @__PURE__ */ new Set();
|
|
1557
|
+
let seen = 0;
|
|
1558
|
+
let ambiguous = false;
|
|
1559
|
+
walkEstree(program, (n) => {
|
|
1560
|
+
if (n.type !== "VariableDeclarator" || !n.init || !isPropsCall(unwrapTs(n.init))) return;
|
|
1561
|
+
seen++;
|
|
1562
|
+
if (n.id?.type === "Identifier") {
|
|
1563
|
+
names.add(n.id.name);
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
if (n.id?.type !== "ObjectPattern" || !Array.isArray(n.id.properties)) {
|
|
1567
|
+
ambiguous = true;
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
for (const p of n.id.properties) if (p?.type === "RestElement") addBoundNames(p.argument, names);
|
|
1571
|
+
else if (p?.type === "Property") {
|
|
1572
|
+
if (p.value?.type === "AssignmentPattern") {
|
|
1573
|
+
if ((includeBindable || !isBindableCall(p.value.right)) && p.value.left?.type === "Identifier") names.add(p.value.left.name);
|
|
1574
|
+
} else if (p.value?.type === "Identifier") names.add(p.value.name);
|
|
1575
|
+
}
|
|
1576
|
+
});
|
|
1577
|
+
return ambiguous || seen > 1 ? /* @__PURE__ */ new Set() : names;
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* Local identifier names bound to a prop via legacy `export let foo` / `export let foo = default`
|
|
1581
|
+
* (correctness/stale-prop-derivation, correctness/prop-mutation: the same two bugs exist under
|
|
1582
|
+
* legacy reactivity, just with a different fix). Top-level only, plain identifiers only — a
|
|
1583
|
+
* component can't mix legacy `export let` props with runes-mode `$props()` in the same file
|
|
1584
|
+
* (a Svelte compile error), so this and `collectPropNames`'s result are never both non-empty
|
|
1585
|
+
* for the same component.
|
|
1586
|
+
*/
|
|
1587
|
+
function collectLegacyPropNames(program) {
|
|
1588
|
+
const names = /* @__PURE__ */ new Set();
|
|
1589
|
+
for (const stmt of program.body ?? []) {
|
|
1590
|
+
if (stmt?.type !== "ExportNamedDeclaration" || stmt.declaration?.type !== "VariableDeclaration") continue;
|
|
1591
|
+
for (const d of stmt.declaration.declarations ?? []) if (d?.id?.type === "Identifier") names.add(d.id.name);
|
|
1592
|
+
}
|
|
1593
|
+
return names;
|
|
1594
|
+
}
|
|
1595
|
+
/** Mutating array/Set/Map methods — a call to one of these on a non-bindable prop mutates it (correctness/prop-mutation). */
|
|
1596
|
+
const MUTATING_METHODS = /* @__PURE__ */ new Set([
|
|
1597
|
+
"push",
|
|
1598
|
+
"pop",
|
|
1599
|
+
"shift",
|
|
1600
|
+
"unshift",
|
|
1601
|
+
"splice",
|
|
1602
|
+
"sort",
|
|
1603
|
+
"reverse",
|
|
1604
|
+
"copyWithin",
|
|
1605
|
+
"fill",
|
|
1606
|
+
"set",
|
|
1607
|
+
"add",
|
|
1608
|
+
"delete",
|
|
1609
|
+
"clear"
|
|
1610
|
+
]);
|
|
1611
|
+
/**
|
|
1612
|
+
* Flag mutations of a non-`$bindable` prop (correctness/prop-mutation): a member-expression write
|
|
1613
|
+
* (`prop.x = …`, `prop.x += …`, `prop.x++`), `delete prop.x`, or a call to a mutating
|
|
1614
|
+
* method on the prop (`prop.push(...)`). Plain reassignment of the prop identifier
|
|
1615
|
+
* itself (`prop = 5`) is NOT flagged — Svelte's docs explicitly sanction temporary
|
|
1616
|
+
* reassignment for ephemeral state; only mutation is prohibited. Run over the instance
|
|
1617
|
+
* program AND the template fragment (inline handlers can mutate props in the template).
|
|
1618
|
+
* Scope-aware (issue #140): a local that shadows the prop's name is not flagged.
|
|
1619
|
+
*/
|
|
1620
|
+
function collectPropMutations(root, propNames, source, acc) {
|
|
1621
|
+
if (propNames.size === 0) return;
|
|
1622
|
+
walkScoped(root, (n, scope) => {
|
|
1623
|
+
const flag = (r) => {
|
|
1624
|
+
if (r && propNames.has(r) && !scope.has(r)) acc.push({
|
|
1625
|
+
name: r,
|
|
1626
|
+
line: lineOf(source, n.start)
|
|
1627
|
+
});
|
|
1628
|
+
};
|
|
1629
|
+
if (n.type === "AssignmentExpression" && n.left?.type === "MemberExpression") flag(rootObjectName(n.left));
|
|
1630
|
+
else if (n.type === "UpdateExpression" && n.argument?.type === "MemberExpression") flag(rootObjectName(n.argument));
|
|
1631
|
+
else if (n.type === "UnaryExpression" && n.operator === "delete") flag(rootObjectName(n.argument));
|
|
1632
|
+
else if (n.type === "CallExpression" && n.callee?.type === "MemberExpression") {
|
|
1633
|
+
const method = n.callee.property?.type === "Identifier" ? n.callee.property.name : void 0;
|
|
1634
|
+
if (method && MUTATING_METHODS.has(method)) flag(rootObjectName(n.callee.object));
|
|
1635
|
+
}
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
/** Named props destructured from `$props()`, or 0 when unknowable (architecture/prop-count). */
|
|
1639
|
+
function countProps(program) {
|
|
1640
|
+
let count = 0;
|
|
1641
|
+
let seen = 0;
|
|
1642
|
+
let uncountable = false;
|
|
1643
|
+
walkEstree(program, (n) => {
|
|
1644
|
+
if (n.type !== "VariableDeclarator" || !n.init || !isPropsCall(unwrapTs(n.init))) return;
|
|
1645
|
+
seen++;
|
|
1646
|
+
const props = n.id?.type === "ObjectPattern" ? n.id.properties : void 0;
|
|
1647
|
+
if (!Array.isArray(props)) {
|
|
1648
|
+
uncountable = true;
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
count = props.filter((p) => p?.type === "Property").length;
|
|
1652
|
+
});
|
|
1653
|
+
return uncountable || seen > 1 ? 0 : count;
|
|
1654
|
+
}
|
|
1655
|
+
/** Source line count, not over-counting a single trailing newline (architecture/component-size). */
|
|
1656
|
+
function countLines(source) {
|
|
1657
|
+
if (source.length === 0) return 0;
|
|
1658
|
+
return source.split("\n").length - (source.endsWith("\n") ? 1 : 0);
|
|
1659
|
+
}
|
|
1660
|
+
/** Whether an import declaration contributes no runtime value binding — see `ComponentFacts.importSpans`. */
|
|
1661
|
+
function isTypeOnlyImport(n) {
|
|
1662
|
+
if (n.importKind === "type") return true;
|
|
1663
|
+
const specs = n.specifiers;
|
|
1664
|
+
return Array.isArray(specs) && specs.length > 0 && specs.every((s) => s?.importKind === "type");
|
|
1665
|
+
}
|
|
1666
|
+
/** Module specifiers of every `import`, each with its source line (see `ComponentFacts.importSpans`). */
|
|
1667
|
+
function collectImportSources(program, source, acc) {
|
|
1668
|
+
walkEstree(program, (n) => {
|
|
1669
|
+
if (n.type === "ImportDeclaration" && typeof n.source?.value === "string") acc.push({
|
|
1670
|
+
source: n.source.value,
|
|
1671
|
+
line: lineOf(source, n.start),
|
|
1672
|
+
...isTypeOnlyImport(n) ? { type: true } : {}
|
|
1673
|
+
});
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
/** A specifier is "bare" (a node_modules package) when it is not relative/absolute/alias-local. */
|
|
1677
|
+
function isBareSpecifier(s) {
|
|
1678
|
+
return !/^[./$#]/.test(s);
|
|
1679
|
+
}
|
|
1680
|
+
/** Value `import * as X from '<bare pkg>'` namespace imports (type-only excluded) — performance/namespace-import. */
|
|
1681
|
+
function collectNamespaceImports(program, source, acc) {
|
|
1682
|
+
walkEstree(program, (n) => {
|
|
1683
|
+
if (n.type !== "ImportDeclaration" || n.importKind === "type") return;
|
|
1684
|
+
const spec = n.source?.value;
|
|
1685
|
+
if (typeof spec !== "string" || !isBareSpecifier(spec)) return;
|
|
1686
|
+
if (Array.isArray(n.specifiers) && n.specifiers.some((s) => s?.type === "ImportNamespaceSpecifier")) acc.push({
|
|
1687
|
+
source: spec,
|
|
1688
|
+
line: lineOf(source, n.start)
|
|
1689
|
+
});
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
const MD_LINK = /\[[^\]]*\]\(([^)\s]+)\)/g;
|
|
1693
|
+
const SCRIPT_OPEN = /<script(?:\s[^>]*)?>/;
|
|
1694
|
+
const SCRIPT_CLOSE = /<\/script\s*>/;
|
|
1695
|
+
const STYLE_OPEN = /<style(?:\s[^>]*)?>/;
|
|
1696
|
+
const STYLE_CLOSE = /<\/style\s*>/;
|
|
1697
|
+
/**
|
|
1698
|
+
* Markdown links inside comments. A plain text scan, like `collectSuppressions` — a link in a comment is
|
|
1699
|
+
* not an AST node. `//` counts as a comment opener only at the start of a line, which is what keeps the
|
|
1700
|
+
* scan off the `//` in `https://`; a trailing `// [x](y)` is therefore missed, and measurement found no
|
|
1701
|
+
* such link, so the cost is a case that does not occur rather than one this rule needs.
|
|
1702
|
+
*
|
|
1703
|
+
* `<script>`/`<style>` blocks are tracked separately (open on the tag, close on the closing tag) because
|
|
1704
|
+
* neither comment form applies uniformly inside them: `<!--`/`-->` are markup syntax, not script/style
|
|
1705
|
+
* syntax, so a `<!--` inside a script string literal (e.g. a URL comparison) must not open a markup
|
|
1706
|
+
* comment; and a line-leading `//` is only a comment in a script, never in markup or style, where it is
|
|
1707
|
+
* either content or a syntax error. The tags themselves are read from the markup *outside* any comment, so
|
|
1708
|
+
* a commented-out block does not open one. `wholeFileIsScript` covers `.svelte.ts`/`.svelte.js` runes
|
|
1709
|
+
* modules, which have no `<script>` tag at all — the entire file is script.
|
|
1710
|
+
*/
|
|
1711
|
+
function collectCommentLinks(source, { wholeFileIsScript = false } = {}) {
|
|
1712
|
+
const out = [];
|
|
1713
|
+
let htmlOpen = false;
|
|
1714
|
+
let block = wholeFileIsScript ? "script" : void 0;
|
|
1715
|
+
source.split("\n").forEach((line, i) => {
|
|
1716
|
+
let text = "";
|
|
1717
|
+
if (block !== void 0) {
|
|
1718
|
+
if (block === "script" && /^\s*\/\//.test(line)) text = line.replace(/^\s*\/\//, "");
|
|
1719
|
+
if (!wholeFileIsScript && (block === "script" ? SCRIPT_CLOSE : STYLE_CLOSE).test(line)) block = void 0;
|
|
1720
|
+
} else {
|
|
1721
|
+
let plain = "";
|
|
1722
|
+
let rest = line;
|
|
1723
|
+
while (rest.length > 0) {
|
|
1724
|
+
if (htmlOpen) {
|
|
1725
|
+
const end = rest.indexOf("-->");
|
|
1726
|
+
if (end === -1) {
|
|
1727
|
+
text += rest;
|
|
1728
|
+
break;
|
|
1729
|
+
}
|
|
1730
|
+
text += rest.slice(0, end);
|
|
1731
|
+
htmlOpen = false;
|
|
1732
|
+
rest = rest.slice(end + 3);
|
|
1733
|
+
continue;
|
|
1734
|
+
}
|
|
1735
|
+
const start = rest.indexOf("<!--");
|
|
1736
|
+
if (start === -1) {
|
|
1737
|
+
plain += rest;
|
|
1738
|
+
break;
|
|
1739
|
+
}
|
|
1740
|
+
plain += rest.slice(0, start);
|
|
1741
|
+
htmlOpen = true;
|
|
1742
|
+
rest = rest.slice(start + 4);
|
|
1743
|
+
}
|
|
1744
|
+
const opened = SCRIPT_OPEN.test(plain) ? "script" : STYLE_OPEN.test(plain) ? "style" : void 0;
|
|
1745
|
+
if (opened !== void 0 && !(opened === "script" ? SCRIPT_CLOSE : STYLE_CLOSE).test(plain)) block = opened;
|
|
1746
|
+
}
|
|
1747
|
+
for (const m of text.matchAll(MD_LINK)) if (m[1] !== void 0) out.push({
|
|
1748
|
+
url: m[1],
|
|
1749
|
+
line: i + 1
|
|
1750
|
+
});
|
|
1751
|
+
});
|
|
1752
|
+
return out;
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* Calls matching `matcher` that run when `root` itself is evaluated (correctness/orphan-effect, correctness/orphan-lifecycle).
|
|
1756
|
+
* `skipSubtree` exempts a call's children — correctness/orphan-effect uses it for `$effect.root(...)`
|
|
1757
|
+
* callbacks, which are a legal standalone reactive scope. `initialShadowed` seeds the
|
|
1758
|
+
* shadow set threaded through `walkEvalScope` (correctness/orphan-lifecycle uses it to seed a
|
|
1759
|
+
* constructor's own parameters before scanning its body).
|
|
1760
|
+
*/
|
|
1761
|
+
function collectEvalScopeCalls(root, source, matcher, skipSubtree, initialShadowed) {
|
|
1762
|
+
const out = [];
|
|
1763
|
+
walkEvalScope(root, (n, shadowed) => {
|
|
1764
|
+
if (n.type !== "CallExpression") return void 0;
|
|
1765
|
+
if (skipSubtree?.(n)) return true;
|
|
1766
|
+
const name = matcher(n, shadowed);
|
|
1767
|
+
if (name) out.push({
|
|
1768
|
+
name,
|
|
1769
|
+
line: lineOf(source, n.start)
|
|
1770
|
+
});
|
|
1771
|
+
}, initialShadowed);
|
|
1772
|
+
return out;
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Matcher-parameterised orphan-call collector (correctness/orphan-effect, correctness/orphan-lifecycle): (1) matching calls that
|
|
1776
|
+
* run at module evaluation time, (2) a module-scope `new` (direct top-level statements
|
|
1777
|
+
* only, export-unwrapped) of a same-file top-level class whose constructor directly
|
|
1778
|
+
* makes a matching call. See `collectOrphanEffects`'s doc comment for why pattern 2 is
|
|
1779
|
+
* restricted to top-level `ClassDeclaration`s and top-level `new` statements.
|
|
1780
|
+
*/
|
|
1781
|
+
function collectOrphanCalls(program, source, matcher, skipSubtree) {
|
|
1782
|
+
const out = collectEvalScopeCalls(program, source, matcher, skipSubtree).map((c) => ({
|
|
1783
|
+
...c,
|
|
1784
|
+
kind: "top-level"
|
|
1785
|
+
}));
|
|
1786
|
+
const body = program.body ?? [];
|
|
1787
|
+
const matchingClasses = /* @__PURE__ */ new Map();
|
|
1788
|
+
for (const stmt of body) {
|
|
1789
|
+
const decl = unwrapExport(stmt);
|
|
1790
|
+
if (decl?.type !== "ClassDeclaration" || decl.id?.type !== "Identifier") continue;
|
|
1791
|
+
const ctor = (decl.body?.body ?? []).find((m) => m?.type === "MethodDefinition" && m.kind === "constructor" && m.value?.body);
|
|
1792
|
+
if (!ctor) continue;
|
|
1793
|
+
const ctorShadow = /* @__PURE__ */ new Set();
|
|
1794
|
+
for (const p of ctor.value.params ?? []) addBoundNames(p, ctorShadow);
|
|
1795
|
+
const calls = collectEvalScopeCalls(ctor.value.body, source, matcher, skipSubtree, ctorShadow);
|
|
1796
|
+
if (calls.length > 0) matchingClasses.set(decl.id.name, calls[0].name);
|
|
1797
|
+
}
|
|
1798
|
+
if (matchingClasses.size > 0) for (const stmt of body) {
|
|
1799
|
+
const decl = unwrapExport(stmt);
|
|
1800
|
+
if (!(decl?.type === "VariableDeclaration" || decl?.type === "ExpressionStatement" || stmt.type === "ExportDefaultDeclaration" && decl?.type !== "FunctionDeclaration" && decl?.type !== "ClassDeclaration")) continue;
|
|
1801
|
+
walkEvalScope(decl, (n) => {
|
|
1802
|
+
if (n.type === "NewExpression" && n.callee?.type === "Identifier" && matchingClasses.has(n.callee.name)) out.push({
|
|
1803
|
+
name: matchingClasses.get(n.callee.name),
|
|
1804
|
+
line: lineOf(source, n.start),
|
|
1805
|
+
kind: "constructor-instantiated",
|
|
1806
|
+
className: n.callee.name
|
|
1807
|
+
});
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
return out.sort((a, b) => a.line - b.line);
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Orphan `$effect` facts for a module-context program (correctness/orphan-effect): (1) effects that run
|
|
1814
|
+
* at module evaluation time, (2) a module-scope `new` of a same-file class whose
|
|
1815
|
+
* constructor creates a bare effect. Conservative by construction — never crosses a
|
|
1816
|
+
* function boundary, so factory functions, IIFEs, and cross-file classes are not flagged.
|
|
1817
|
+
*
|
|
1818
|
+
* Pattern 2 (the class/`new` half) operates on DIRECT top-level statements only — it
|
|
1819
|
+
* never descends into blocks/if/for/try. A top-level `ClassDeclaration` name is a real
|
|
1820
|
+
* module-scope binding that an import can't legally share, so restricting collection to
|
|
1821
|
+
* `program.body` (unwrapping only `export`/`export default`) rules out two false-positive
|
|
1822
|
+
* vectors: a block-scoped class shadowing an imported name of the same name, and a class
|
|
1823
|
+
* expression's own name (`const A = class Store {…}`), which is only visible inside the
|
|
1824
|
+
* expression, never as a module-scope binding. This matches the design spec's own wording
|
|
1825
|
+
* for pattern 2: "flag top-level `new ClassName(...)` statements". Pattern 1 (top-level
|
|
1826
|
+
* `$effect`, including inside top-level blocks/if) is unaffected — see
|
|
1827
|
+
* `collectEvalScopeCalls` above. Generalised as `collectOrphanCalls` — correctness/orphan-lifecycle reuses
|
|
1828
|
+
* the same walk with a lifecycle-import matcher.
|
|
1829
|
+
*/
|
|
1830
|
+
function collectOrphanEffects(program, source) {
|
|
1831
|
+
return collectOrphanCalls(program, source, (n) => isEffectCall(n) ? "$effect" : void 0, isEffectRootCall).map(({ line, kind, className }) => ({
|
|
1832
|
+
line,
|
|
1833
|
+
kind,
|
|
1834
|
+
...className !== void 0 ? { className } : {}
|
|
1835
|
+
}));
|
|
1836
|
+
}
|
|
1837
|
+
/** Orphan lifecycle-call facts for a module-context program (correctness/orphan-lifecycle). */
|
|
1838
|
+
function collectOrphanLifecycleCalls(program, source) {
|
|
1839
|
+
const imports = collectSvelteLifecycleImports(program);
|
|
1840
|
+
if (imports.locals.size === 0 && imports.namespaces.size === 0) return [];
|
|
1841
|
+
return collectOrphanCalls(program, source, (n, shadowed) => {
|
|
1842
|
+
const m = matchLifecycleCall(n, imports);
|
|
1843
|
+
return m && !shadowed.has(m.local) ? m.canonical : void 0;
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
/** A Svelte runes module file — the whole file is one module-scope program (correctness/orphan-effect). */
|
|
1847
|
+
const MODULE_FILE_RE = /\.svelte\.(ts|js)$/;
|
|
1848
|
+
/**
|
|
1849
|
+
* Module-scope reactive-state declarations in a runes module (security/shared-state-import): a top-level
|
|
1850
|
+
* `let|const x = $state(...)` / `$state.raw(...)` declaration, and a module-scope
|
|
1851
|
+
* `new` (in a top-level variable declaration) of a same-file top-level class with a
|
|
1852
|
+
* `$state` field initializer — recorded under the instance binding's name at the
|
|
1853
|
+
* declaration line. Direct top-level statements only (export-unwrapped), mirroring
|
|
1854
|
+
* correctness/orphan-effect's pattern-2 conservatism.
|
|
1855
|
+
*/
|
|
1856
|
+
function collectModuleStateDecls(program, source) {
|
|
1857
|
+
const out = [];
|
|
1858
|
+
const body = program.body ?? [];
|
|
1859
|
+
const statefulClasses = /* @__PURE__ */ new Set();
|
|
1860
|
+
for (const stmt of body) {
|
|
1861
|
+
const decl = unwrapExport(stmt);
|
|
1862
|
+
if (decl?.type === "VariableDeclaration") {
|
|
1863
|
+
for (const d of decl.declarations ?? []) if (d?.id?.type === "Identifier" && d.init && isStateDeclaration(unwrapTs(d.init))) out.push({
|
|
1864
|
+
name: d.id.name,
|
|
1865
|
+
line: lineOf(source, d.start)
|
|
1866
|
+
});
|
|
1867
|
+
} else if (decl?.type === "ClassDeclaration" && decl.id?.type === "Identifier") {
|
|
1868
|
+
if ((decl.body?.body ?? []).some((m) => m?.type === "PropertyDefinition" && m.value && isStateDeclaration(unwrapTs(m.value)))) statefulClasses.add(decl.id.name);
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
if (statefulClasses.size > 0) for (const stmt of body) {
|
|
1872
|
+
const decl = unwrapExport(stmt);
|
|
1873
|
+
if (decl?.type !== "VariableDeclaration") continue;
|
|
1874
|
+
for (const d of decl.declarations ?? []) if (d?.init?.type === "NewExpression" && d.init.callee?.type === "Identifier" && statefulClasses.has(d.init.callee.name)) out.push({
|
|
1875
|
+
name: d.id?.type === "Identifier" ? d.id.name : d.init.callee.name,
|
|
1876
|
+
line: lineOf(source, d.start)
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
return out.sort((a, b) => a.line - b.line);
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Facts for a `.svelte.ts`/`.svelte.js` runes module (correctness/orphan-effect, correctness/orphan-lifecycle, correctness/server-browser-global,
|
|
1883
|
+
* performance/heavy-import, performance/namespace-import, architecture/private-scope-import, architecture/route-component-import).
|
|
1884
|
+
* The whole file runs at import time, so only `orphanEffects`, `orphanLifecycleCalls`, `browserGlobalRefs`,
|
|
1885
|
+
* `moduleStateDecls`, `imports`/`importSpans`/`namespaceImports`, and `suppressions` are populated —
|
|
1886
|
+
* `loc` stays 0 so architecture/component-size skips module files (there is no component to size), and
|
|
1887
|
+
* the rest of the component-only facts stay empty. Uses `parseModuleProgram` to get the ESTree program
|
|
1888
|
+
* from the wrapped source; the 1-line wrap prefix is subtracted from every reported line.
|
|
1889
|
+
*/
|
|
1890
|
+
function parseModuleFacts(source, filename) {
|
|
1891
|
+
const { program, wrapped } = parseModuleProgram(source, filename);
|
|
1892
|
+
const shift = (line) => Math.max(0, line - 1);
|
|
1893
|
+
const orphanEffects = program ? collectOrphanEffects(program, wrapped).map((f) => ({
|
|
1894
|
+
...f,
|
|
1895
|
+
line: shift(f.line)
|
|
1896
|
+
})) : [];
|
|
1897
|
+
const orphanLifecycleCalls = program ? collectOrphanLifecycleCalls(program, wrapped).map((f) => ({
|
|
1898
|
+
...f,
|
|
1899
|
+
line: shift(f.line)
|
|
1900
|
+
})) : [];
|
|
1901
|
+
const browserGlobalRefs = program ? collectBrowserGlobalRefs(program, wrapped).map((r) => ({
|
|
1902
|
+
...r,
|
|
1903
|
+
line: shift(r.line),
|
|
1904
|
+
context: "module"
|
|
1905
|
+
})) : [];
|
|
1906
|
+
const moduleStateDecls = program ? collectModuleStateDecls(program, wrapped).map((d) => ({
|
|
1907
|
+
...d,
|
|
1908
|
+
line: shift(d.line)
|
|
1909
|
+
})) : [];
|
|
1910
|
+
const basePathLinks = [];
|
|
1911
|
+
if (program) {
|
|
1912
|
+
const locals = collectNamedImportAliases(program, "$app/navigation", GOTO_NAMES);
|
|
1913
|
+
const raw = [];
|
|
1914
|
+
collectGotoLinks(locals, [program], wrapped, raw);
|
|
1915
|
+
for (const l of raw) basePathLinks.push({
|
|
1916
|
+
...l,
|
|
1917
|
+
line: shift(l.line)
|
|
1918
|
+
});
|
|
1919
|
+
basePathLinks.sort((a, b) => a.line - b.line);
|
|
1920
|
+
}
|
|
1921
|
+
const importSpans = [];
|
|
1922
|
+
const namespaceImports = [];
|
|
1923
|
+
if (program) {
|
|
1924
|
+
const rawImportSpans = [];
|
|
1925
|
+
collectImportSources(program, wrapped, rawImportSpans);
|
|
1926
|
+
for (const s of rawImportSpans) importSpans.push({
|
|
1927
|
+
...s,
|
|
1928
|
+
line: shift(s.line)
|
|
1929
|
+
});
|
|
1930
|
+
const rawNamespaceImports = [];
|
|
1931
|
+
collectNamespaceImports(program, wrapped, rawNamespaceImports);
|
|
1932
|
+
for (const n of rawNamespaceImports) namespaceImports.push({
|
|
1933
|
+
...n,
|
|
1934
|
+
line: shift(n.line)
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
return {
|
|
1938
|
+
eachBlocks: [],
|
|
1939
|
+
effects: [],
|
|
1940
|
+
htmlTags: [],
|
|
1941
|
+
javascriptUrls: [],
|
|
1942
|
+
loc: 0,
|
|
1943
|
+
propCount: 0,
|
|
1944
|
+
imports: importSpans.map((s) => s.source),
|
|
1945
|
+
importSpans,
|
|
1946
|
+
namespaceImports,
|
|
1947
|
+
constableStates: [],
|
|
1948
|
+
mutatedProps: [],
|
|
1949
|
+
stalePropDerivations: [],
|
|
1950
|
+
rawableStates: [],
|
|
1951
|
+
nonreactiveBuiltinStates: [],
|
|
1952
|
+
checkableBindValues: [],
|
|
1953
|
+
basePathLinks,
|
|
1954
|
+
suppressions: collectSuppressions(source),
|
|
1955
|
+
commentLinks: collectCommentLinks(source, { wholeFileIsScript: true }),
|
|
1956
|
+
orphanEffects,
|
|
1957
|
+
orphanLifecycleCalls,
|
|
1958
|
+
browserGlobalRefs,
|
|
1959
|
+
moduleStateDecls
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
/**
|
|
1963
|
+
* Parse one source file's facts (CLI/static + vite build mode): a `.svelte` component's
|
|
1964
|
+
* reactivity/correctness + security + architecture facts, or a `.svelte.ts`/`.svelte.js`
|
|
1965
|
+
* runes module's orphan-$effect facts (correctness/orphan-effect).
|
|
1966
|
+
*/
|
|
1967
|
+
function parseComponentFacts(source, filename) {
|
|
1968
|
+
if (MODULE_FILE_RE.test(filename)) return parseModuleFacts(source, filename);
|
|
1969
|
+
const ast = parseSvelte(source, filename);
|
|
1970
|
+
const eachBlocks = [];
|
|
1971
|
+
collectEachBlocks(ast.fragment ?? ast, source, eachBlocks);
|
|
1972
|
+
const htmlTags = [];
|
|
1973
|
+
const javascriptUrls = [];
|
|
1974
|
+
collectSecurityFacts(ast.fragment ?? ast, source, htmlTags, javascriptUrls);
|
|
1975
|
+
const checkableBindValues = [];
|
|
1976
|
+
collectCheckableBindValues(ast.fragment ?? ast, source, checkableBindValues);
|
|
1977
|
+
const ariaElements = [];
|
|
1978
|
+
collectAriaElements(ast.fragment ?? ast, source, ariaElements);
|
|
1979
|
+
const elements = [];
|
|
1980
|
+
collectElements(ast.fragment ?? ast, source, elements, ast.options?.namespace === "svg");
|
|
1981
|
+
const interactiveNestings = [];
|
|
1982
|
+
collectInteractiveNestings(ast.fragment ?? ast, source, interactiveNestings, []);
|
|
1983
|
+
const ariaHiddenFocusables = [];
|
|
1984
|
+
collectAriaHiddenFocusables(ast.fragment ?? ast, source, ariaHiddenFocusables, []);
|
|
1985
|
+
const unnamedInteractive = [];
|
|
1986
|
+
const labelTargets = {
|
|
1987
|
+
ids: /* @__PURE__ */ new Set(),
|
|
1988
|
+
nodes: /* @__PURE__ */ new Set()
|
|
1989
|
+
};
|
|
1990
|
+
collectLabelTargets(ast.fragment ?? ast, labelTargets);
|
|
1991
|
+
collectUnnamedInteractive(ast.fragment ?? ast, source, unnamedInteractive, labelTargets);
|
|
1992
|
+
const unassociatedLabels = [];
|
|
1993
|
+
collectUnassociatedLabels(ast.fragment ?? ast, source, unassociatedLabels);
|
|
1994
|
+
const bulletTexts = [];
|
|
1995
|
+
collectBulletTexts(ast.fragment ?? ast, source, bulletTexts, false);
|
|
1996
|
+
const selectsMissingPlaceholder = [];
|
|
1997
|
+
collectSelectsMissingPlaceholder(ast.fragment ?? ast, source, selectsMissingPlaceholder);
|
|
1998
|
+
const timesMissingDatetime = [];
|
|
1999
|
+
collectTimesMissingDatetime(ast.fragment ?? ast, source, timesMissingDatetime);
|
|
2000
|
+
const videosAutoplayNoMuted = [];
|
|
2001
|
+
collectVideosAutoplayNoMuted(ast.fragment ?? ast, source, videosAutoplayNoMuted, ast.options?.namespace === "svg");
|
|
2002
|
+
const duplicateDts = [];
|
|
2003
|
+
collectDuplicateDts(ast.fragment ?? ast, source, duplicateDts, ast.options?.namespace === "svg");
|
|
2004
|
+
const basePathLinks = [];
|
|
2005
|
+
collectHrefLinks(ast.fragment ?? ast, source, basePathLinks);
|
|
2006
|
+
const gotoPrograms = [ast.module?.content, ast.instance?.content].filter(Boolean);
|
|
2007
|
+
const gotoLocals = /* @__PURE__ */ new Set();
|
|
2008
|
+
for (const p of gotoPrograms) for (const n of collectNamedImportAliases(p, "$app/navigation", GOTO_NAMES)) gotoLocals.add(n);
|
|
2009
|
+
collectGotoLinks(gotoLocals, [...gotoPrograms, ast.fragment], source, basePathLinks);
|
|
2010
|
+
basePathLinks.sort((a, b) => a.line - b.line);
|
|
2011
|
+
const loc = countLines(source);
|
|
2012
|
+
const suppressions = collectSuppressions(source);
|
|
2013
|
+
const moduleProgram = ast.module?.content;
|
|
2014
|
+
const importSpans = [];
|
|
2015
|
+
const namespaceImports = [];
|
|
2016
|
+
if (moduleProgram) {
|
|
2017
|
+
collectImportSources(moduleProgram, source, importSpans);
|
|
2018
|
+
collectNamespaceImports(moduleProgram, source, namespaceImports);
|
|
2019
|
+
}
|
|
2020
|
+
const orphanEffects = moduleProgram ? collectOrphanEffects(moduleProgram, source) : [];
|
|
2021
|
+
const orphanLifecycleCalls = moduleProgram ? collectOrphanLifecycleCalls(moduleProgram, source) : [];
|
|
2022
|
+
const browserGlobalRefs = [];
|
|
2023
|
+
if (moduleProgram) for (const r of collectBrowserGlobalRefs(moduleProgram, source)) browserGlobalRefs.push({
|
|
2024
|
+
...r,
|
|
2025
|
+
context: "module"
|
|
2026
|
+
});
|
|
2027
|
+
const effects = [];
|
|
2028
|
+
const constableStates = [];
|
|
2029
|
+
const mutatedProps = [];
|
|
2030
|
+
const stalePropDerivations = [];
|
|
2031
|
+
const rawableStates = [];
|
|
2032
|
+
const nonreactiveBuiltinStates = [];
|
|
2033
|
+
let propCount = 0;
|
|
2034
|
+
const program = ast.instance?.content;
|
|
2035
|
+
if (program) {
|
|
2036
|
+
collectImportSources(program, source, importSpans);
|
|
2037
|
+
collectNamespaceImports(program, source, namespaceImports);
|
|
2038
|
+
propCount = countProps(program);
|
|
2039
|
+
const legacyPropNames = collectLegacyPropNames(program);
|
|
2040
|
+
const nonBindableProps = /* @__PURE__ */ new Set([...collectPropNames(program, false), ...legacyPropNames]);
|
|
2041
|
+
const rawMutations = [];
|
|
2042
|
+
collectPropMutations(program, nonBindableProps, source, rawMutations);
|
|
2043
|
+
if (ast.fragment) collectPropMutations(ast.fragment, nonBindableProps, source, rawMutations);
|
|
2044
|
+
for (const m of rawMutations) mutatedProps.push(legacyPropNames.has(m.name) ? {
|
|
2045
|
+
...m,
|
|
2046
|
+
legacy: true
|
|
2047
|
+
} : m);
|
|
2048
|
+
const allPropNames = /* @__PURE__ */ new Set([...collectPropNames(program, true), ...legacyPropNames]);
|
|
2049
|
+
if (allPropNames.size > 0) {
|
|
2050
|
+
const candidates = collectStalePropCandidates(program, allPropNames, source);
|
|
2051
|
+
if (candidates.length > 0) {
|
|
2052
|
+
const candidateNames = new Set(candidates.map((c) => c.name));
|
|
2053
|
+
const disqualified = /* @__PURE__ */ new Set();
|
|
2054
|
+
collectStateWrites(program, candidateNames, disqualified);
|
|
2055
|
+
if (ast.fragment) {
|
|
2056
|
+
collectStateWrites(ast.fragment, candidateNames, disqualified);
|
|
2057
|
+
collectTemplateEscapes(ast.fragment, candidateNames, disqualified);
|
|
2058
|
+
}
|
|
2059
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
2060
|
+
if (ast.fragment) collectFragmentRefs(ast.fragment, candidateNames, referenced);
|
|
2061
|
+
const isLegacy = legacyPropNames.size > 0;
|
|
2062
|
+
for (const c of candidates) if (!disqualified.has(c.name) && referenced.has(c.name)) stalePropDerivations.push(isLegacy ? {
|
|
2063
|
+
...c,
|
|
2064
|
+
legacy: true
|
|
2065
|
+
} : c);
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
const stateNames = /* @__PURE__ */ new Set();
|
|
2069
|
+
const reactiveNames = /* @__PURE__ */ new Set();
|
|
2070
|
+
collectImportedLocalNames(program, reactiveNames);
|
|
2071
|
+
collectNewExprLocalNames(program, reactiveNames);
|
|
2072
|
+
if (moduleProgram) {
|
|
2073
|
+
collectImportedLocalNames(moduleProgram, reactiveNames);
|
|
2074
|
+
collectNewExprLocalNames(moduleProgram, reactiveNames);
|
|
2075
|
+
}
|
|
2076
|
+
const stateDecls = [];
|
|
2077
|
+
walkEstree(program, (n) => {
|
|
2078
|
+
if (n.type !== "VariableDeclarator" || !n.init) return;
|
|
2079
|
+
const init = unwrapTs(n.init);
|
|
2080
|
+
if (isStateDeclaration(init) && n.id?.type === "Identifier") {
|
|
2081
|
+
stateNames.add(n.id.name);
|
|
2082
|
+
stateDecls.push({
|
|
2083
|
+
name: n.id.name,
|
|
2084
|
+
line: lineOf(source, n.start)
|
|
2085
|
+
});
|
|
2086
|
+
}
|
|
2087
|
+
if (isStateDeclaration(init) || isDerivedDeclaration(init) || isPropsCall(init)) addBoundNames(n.id, reactiveNames);
|
|
2088
|
+
});
|
|
2089
|
+
walkEstree(program, (n) => {
|
|
2090
|
+
if (n.type !== "CallExpression" || !isEffectCall(n)) return;
|
|
2091
|
+
const fn = n.arguments?.[0];
|
|
2092
|
+
const isFn = fn?.type === "ArrowFunctionExpression" || fn?.type === "FunctionExpression";
|
|
2093
|
+
effects.push({
|
|
2094
|
+
line: lineOf(source, n.start),
|
|
2095
|
+
assignsOnlyState: isFn ? bodyOnlyAssignsState(fn, stateNames) : false,
|
|
2096
|
+
mountOnly: isFn ? !bodyIsEmpty(fn) && !bodyReadsReactive(fn, reactiveNames) : false
|
|
2097
|
+
});
|
|
2098
|
+
});
|
|
2099
|
+
const writtenOrEscaped = /* @__PURE__ */ new Set();
|
|
2100
|
+
collectStateWrites(program, stateNames, writtenOrEscaped);
|
|
2101
|
+
if (ast.fragment) {
|
|
2102
|
+
collectStateWrites(ast.fragment, stateNames, writtenOrEscaped);
|
|
2103
|
+
collectTemplateEscapes(ast.fragment, stateNames, writtenOrEscaped);
|
|
2104
|
+
collectDirectiveEscapes(ast.fragment, stateNames, writtenOrEscaped);
|
|
2105
|
+
}
|
|
2106
|
+
for (const d of stateDecls) if (!writtenOrEscaped.has(d.name)) constableStates.push(d);
|
|
2107
|
+
const rawableCandidates = [];
|
|
2108
|
+
for (const stmt of program.body ?? []) {
|
|
2109
|
+
if (stmt?.type !== "VariableDeclaration") continue;
|
|
2110
|
+
for (const d of stmt.declarations ?? []) {
|
|
2111
|
+
if (d?.id?.type !== "Identifier" || !d.init) continue;
|
|
2112
|
+
const init = unwrapTs(d.init);
|
|
2113
|
+
if (!isPlainStateCall(init)) continue;
|
|
2114
|
+
const arg = unwrapTs(init.arguments?.[0]);
|
|
2115
|
+
if (arg?.type === "ObjectExpression" || arg?.type === "ArrayExpression") rawableCandidates.push({
|
|
2116
|
+
name: d.id.name,
|
|
2117
|
+
line: lineOf(source, d.start)
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
if (rawableCandidates.length > 0) {
|
|
2122
|
+
const candNames = new Set(rawableCandidates.map((c) => c.name));
|
|
2123
|
+
const union = /* @__PURE__ */ new Set();
|
|
2124
|
+
const kinds = /* @__PURE__ */ new Map();
|
|
2125
|
+
collectStateWrites(program, candNames, union, kinds);
|
|
2126
|
+
if (ast.fragment) {
|
|
2127
|
+
collectStateWrites(ast.fragment, candNames, union, kinds);
|
|
2128
|
+
collectTemplateEscapes(ast.fragment, candNames, union, kinds);
|
|
2129
|
+
}
|
|
2130
|
+
const aliasEscapes = /* @__PURE__ */ new Set();
|
|
2131
|
+
collectAliasRefs(program, candNames, aliasEscapes);
|
|
2132
|
+
const eachTaint = /* @__PURE__ */ new Set();
|
|
2133
|
+
if (ast.fragment) {
|
|
2134
|
+
collectFragmentAliasRefs(ast.fragment, candNames, aliasEscapes);
|
|
2135
|
+
collectDirectiveEscapes(ast.fragment, candNames, aliasEscapes);
|
|
2136
|
+
collectEachContextTaint(ast.fragment, candNames, eachTaint);
|
|
2137
|
+
}
|
|
2138
|
+
for (const c of rawableCandidates) {
|
|
2139
|
+
const k = kinds.get(c.name);
|
|
2140
|
+
const reassigned = k?.has("reassign") ?? false;
|
|
2141
|
+
const dirty = k !== void 0 && [...k].some((kind) => kind !== "reassign") || aliasEscapes.has(c.name) || eachTaint.has(c.name);
|
|
2142
|
+
if (reassigned && !dirty) rawableStates.push(c);
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
const builtinCandidates = /* @__PURE__ */ new Map();
|
|
2146
|
+
for (const stmt of program.body ?? []) {
|
|
2147
|
+
if (stmt?.type !== "VariableDeclaration") continue;
|
|
2148
|
+
for (const d of stmt.declarations ?? []) {
|
|
2149
|
+
if (d?.id?.type !== "Identifier" || !d.init) continue;
|
|
2150
|
+
const init = unwrapTs(d.init);
|
|
2151
|
+
if (!isPlainStateCall(init)) continue;
|
|
2152
|
+
const arg = unwrapTs(init.arguments?.[0]);
|
|
2153
|
+
if (arg?.type === "NewExpression" && arg.callee?.type === "Identifier" && BUILTIN_STATE_TYPES.has(arg.callee.name)) builtinCandidates.set(d.id.name, {
|
|
2154
|
+
type: arg.callee.name,
|
|
2155
|
+
line: lineOf(source, d.start)
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
if (builtinCandidates.size > 0) {
|
|
2160
|
+
const types = new Map([...builtinCandidates].map(([n, meta]) => [n, meta.type]));
|
|
2161
|
+
const mutatedBuiltins = /* @__PURE__ */ new Set();
|
|
2162
|
+
const reassignedBuiltins = /* @__PURE__ */ new Set();
|
|
2163
|
+
collectBuiltinStateSignals(program, types, mutatedBuiltins, reassignedBuiltins);
|
|
2164
|
+
if (ast.fragment) collectBuiltinStateSignals(ast.fragment, types, mutatedBuiltins, reassignedBuiltins);
|
|
2165
|
+
for (const [name, meta] of builtinCandidates) if (mutatedBuiltins.has(name) && !reassignedBuiltins.has(name)) nonreactiveBuiltinStates.push({
|
|
2166
|
+
name,
|
|
2167
|
+
type: meta.type,
|
|
2168
|
+
line: meta.line
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
let moduleExtra;
|
|
2172
|
+
if (moduleProgram) {
|
|
2173
|
+
const moduleBrowserImports = collectBrowserGuardImports(moduleProgram);
|
|
2174
|
+
moduleExtra = {
|
|
2175
|
+
guards: /* @__PURE__ */ new Set([...moduleBrowserImports, ...collectDerivedGuardBindings(moduleProgram, moduleBrowserImports)]),
|
|
2176
|
+
bound: collectProgramBindings(moduleProgram)
|
|
2177
|
+
};
|
|
2178
|
+
}
|
|
2179
|
+
for (const r of collectBrowserGlobalRefs(program, source, moduleExtra)) browserGlobalRefs.push({
|
|
2180
|
+
...r,
|
|
2181
|
+
context: "instance"
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
const imports = importSpans.map((s) => s.source);
|
|
2185
|
+
return {
|
|
2186
|
+
eachBlocks,
|
|
2187
|
+
effects,
|
|
2188
|
+
htmlTags,
|
|
2189
|
+
javascriptUrls,
|
|
2190
|
+
loc,
|
|
2191
|
+
propCount,
|
|
2192
|
+
imports,
|
|
2193
|
+
importSpans,
|
|
2194
|
+
namespaceImports,
|
|
2195
|
+
constableStates,
|
|
2196
|
+
mutatedProps,
|
|
2197
|
+
stalePropDerivations,
|
|
2198
|
+
rawableStates,
|
|
2199
|
+
nonreactiveBuiltinStates,
|
|
2200
|
+
checkableBindValues,
|
|
2201
|
+
basePathLinks,
|
|
2202
|
+
orphanEffects,
|
|
2203
|
+
orphanLifecycleCalls,
|
|
2204
|
+
browserGlobalRefs,
|
|
2205
|
+
moduleStateDecls: [],
|
|
2206
|
+
suppressions,
|
|
2207
|
+
commentLinks: collectCommentLinks(source),
|
|
2208
|
+
ariaElements,
|
|
2209
|
+
elements,
|
|
2210
|
+
interactiveNestings,
|
|
2211
|
+
ariaHiddenFocusables,
|
|
2212
|
+
unnamedInteractive,
|
|
2213
|
+
unassociatedLabels,
|
|
2214
|
+
bulletTexts,
|
|
2215
|
+
selectsMissingPlaceholder,
|
|
2216
|
+
timesMissingDatetime,
|
|
2217
|
+
videosAutoplayNoMuted,
|
|
2218
|
+
duplicateDts
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
//#endregion
|
|
2
2222
|
//#region src/inline-directives.ts
|
|
2223
|
+
/**
|
|
2224
|
+
* Enter every fact-carrying file into a directive index — components, Kit modules, and the Vite
|
|
2225
|
+
* config when its minify flag was read. The one home for the union both pipelines apply, so a new
|
|
2226
|
+
* fact family that carries suppressions is wired into inline directives in a single place.
|
|
2227
|
+
*/
|
|
2228
|
+
function addFactsDirectives(index, facts) {
|
|
2229
|
+
for (const c of facts.components ?? []) index.set(c.file, c.suppressions ?? []);
|
|
2230
|
+
for (const m of facts.kitModules ?? []) index.set(m.file, m.suppressions ?? []);
|
|
2231
|
+
const vc = facts.viteMinifyDisabled;
|
|
2232
|
+
if (vc?.file) index.set(vc.file, vc.suppressions ?? []);
|
|
2233
|
+
}
|
|
3
2234
|
/** The directive silencing `r`, if one sits on its line and names its rule (or names nothing). */
|
|
4
2235
|
function directiveFor(index, r) {
|
|
5
2236
|
const line = r.line;
|
|
@@ -21,6 +2252,7 @@ function directiveFor(index, r) {
|
|
|
21
2252
|
* rule's `passLabel` when it declares one, else its `title`.
|
|
22
2253
|
*/
|
|
23
2254
|
function applyInlineDirectives(results, index, rules, config) {
|
|
2255
|
+
if (index.size === 0) return [...results];
|
|
24
2256
|
const labels = new Map(rules.map((r) => [r.id, r.passLabel ?? r.title]));
|
|
25
2257
|
const kept = [];
|
|
26
2258
|
/** rule+route → the first suppressed result, for the PASS this pair may need. */
|
|
@@ -73,6 +2305,11 @@ function unknownDirectiveIds(index, rules) {
|
|
|
73
2305
|
//#endregion
|
|
74
2306
|
//#region src/component.ts
|
|
75
2307
|
/**
|
|
2308
|
+
* Component-body facts for the Correctness category — the source-analysis boundary
|
|
2309
|
+
* (mirrors images.ts / headings.ts). Collected by the static (CLI) provider only;
|
|
2310
|
+
* the rendered provider can't see reactivity, so correctness rules no-op there.
|
|
2311
|
+
*/
|
|
2312
|
+
/**
|
|
76
2313
|
* Warnings for files a collector could not read or parse: the file contributes empty facts, so any
|
|
77
2314
|
* findings it would have produced are simply missing rather than reported as fixed. The two causes
|
|
78
2315
|
* are reported separately — an unreadable file is an environment problem (permissions, a descriptor
|
|
@@ -93,8 +2330,6 @@ function skippedFileWarnings(facts) {
|
|
|
93
2330
|
if (unparsed.length > 0) out.push(`skipped ${unparsed.length} file(s) that could not be parsed: ${list(unparsed)}`, "findings for these files are unavailable until they parse.");
|
|
94
2331
|
return out;
|
|
95
2332
|
}
|
|
96
|
-
//#endregion
|
|
97
|
-
//#region src/component-collect.ts
|
|
98
2333
|
/**
|
|
99
2334
|
* Fallback facts for a file that fails to read or parse (dev tooling must never
|
|
100
2335
|
* throw). This is the single source of truth for the empty-facts shape — add new
|
|
@@ -659,19 +2894,6 @@ const VITE_CONFIG_FILES = [
|
|
|
659
2894
|
];
|
|
660
2895
|
/** SvelteKit's config resolution order (`@sveltejs/kit` checks js before ts). */
|
|
661
2896
|
const SVELTE_CONFIG_FILES = ["svelte.config.js", "svelte.config.ts"];
|
|
662
|
-
//#endregion
|
|
663
|
-
//#region src/runtime.ts
|
|
664
|
-
/**
|
|
665
|
-
* How many file reads may be in flight at once. Analysis reads every `.svelte` file in a project
|
|
666
|
-
* in parallel, which on a large project opens more descriptors than the process is allowed: at
|
|
667
|
-
* `ulimit -n 1024` — a common container default — a 1 681-route project raised `EMFILE`, and
|
|
668
|
-
* because a failed read lands in the same `catch` as a malformed component, 682 files were dropped
|
|
669
|
-
* and the run still reported a normal score. The cap is what keeps the analysis whole.
|
|
670
|
-
*
|
|
671
|
-
* 64 is chosen to sit well under the stock 256 on macOS while leaving descriptors for everything
|
|
672
|
-
* else the process holds open. It is not a throughput knob: reads are a few percent of the work.
|
|
673
|
-
*/
|
|
674
|
-
const READ_CONCURRENCY = 64;
|
|
675
2897
|
/**
|
|
676
2898
|
* `readFile` with at most `limit` reads in flight. A plain counter plus a queue of waiters —
|
|
677
2899
|
* deliberately not a dependency, and pure enough to live in core.
|
|
@@ -727,6 +2949,26 @@ async function runRules(rules, ctx) {
|
|
|
727
2949
|
failedRules
|
|
728
2950
|
};
|
|
729
2951
|
}
|
|
2952
|
+
/**
|
|
2953
|
+
* Run rules and apply the correction sequence every pipeline owes its results: configured
|
|
2954
|
+
* severities, overrides, inline directives, then the failed-rule weight correction — a failed
|
|
2955
|
+
* rule examined nothing, so its weight must not stay in the Health denominator, and every
|
|
2956
|
+
* downstream consumer must score against the returned `scoringConfig` so they agree.
|
|
2957
|
+
* A caller with no directive sources passes an empty index, which makes the directive pass
|
|
2958
|
+
* identity; the severity and override passes are config-governed and run regardless.
|
|
2959
|
+
*/
|
|
2960
|
+
async function runAnalysis(rules, ctx, directives) {
|
|
2961
|
+
const { results: raw, examined, failedRules } = await runRules(rules, ctx);
|
|
2962
|
+
const results = applyInlineDirectives(applyOverrides(applyRuleSeverities(raw, ctx.config), ctx.config), directives, rules, ctx.config);
|
|
2963
|
+
const failedRuleIds = failedRules.map((f) => f.id);
|
|
2964
|
+
return {
|
|
2965
|
+
results,
|
|
2966
|
+
examined,
|
|
2967
|
+
failedRules,
|
|
2968
|
+
failedRuleIds,
|
|
2969
|
+
scoringConfig: withFailedRulesOff(ctx.config, failedRuleIds)
|
|
2970
|
+
};
|
|
2971
|
+
}
|
|
730
2972
|
//#endregion
|
|
731
2973
|
//#region src/reporter/palette.ts
|
|
732
2974
|
/** Default: no decoration (identity) — output is byte-identical to plain text. */
|
|
@@ -967,14 +3209,6 @@ function formatSarifReport(results, config, meta) {
|
|
|
967
3209
|
}
|
|
968
3210
|
//#endregion
|
|
969
3211
|
//#region src/reporter/app-shell.ts
|
|
970
|
-
const BAND_COLOR = {
|
|
971
|
-
good: "#2FA968",
|
|
972
|
-
warn: "#E8A317",
|
|
973
|
-
poor: "#E5484D"
|
|
974
|
-
};
|
|
975
|
-
function scoreBand(score) {
|
|
976
|
-
return score >= 90 ? "good" : score >= 50 ? "warn" : "poor";
|
|
977
|
-
}
|
|
978
3212
|
function escapeHtml(s) {
|
|
979
3213
|
return s.replace(/[&<>"']/g, (c) => c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : c === "\"" ? """ : "'");
|
|
980
3214
|
}
|
|
@@ -1247,30 +3481,56 @@ const APP_SCRIPT = `
|
|
|
1247
3481
|
return pre;
|
|
1248
3482
|
}
|
|
1249
3483
|
|
|
3484
|
+
// Analyzed-repo strings (title, location, recommendation, fix description, docs URL) flow
|
|
3485
|
+
// into a prompt the user pastes into a coding agent — same threat model as
|
|
3486
|
+
// reporter/sanitize.ts's mdEscape, re-implemented here in ES5 because APP_SCRIPT runs in
|
|
3487
|
+
// the browser and cannot import build-time modules. Keep the two in sync.
|
|
3488
|
+
function mdSafe(text) {
|
|
3489
|
+
return String(text)
|
|
3490
|
+
.replace(/\\r\\n|\\r|\\n/g, ' ')
|
|
3491
|
+
.replace(/<[^>]+>/g, function (tag) {
|
|
3492
|
+
var longest = 0;
|
|
3493
|
+
var runs = tag.match(/\`+/g);
|
|
3494
|
+
if (runs) for (var i = 0; i < runs.length; i++) if (runs[i].length > longest) longest = runs[i].length;
|
|
3495
|
+
var fence = Array(longest + 2).join('\`');
|
|
3496
|
+
var pad = tag.charAt(0) === '\`' || tag.charAt(tag.length - 1) === '\`' ? ' ' : '';
|
|
3497
|
+
return fence + pad + tag + pad + fence;
|
|
3498
|
+
})
|
|
3499
|
+
.replace(/\\[([^\\]]*)\\]\\(([^)]*)\\)/g, '[$1]\\\\($2\\\\)');
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
function fenceFor(snippet) {
|
|
3503
|
+
var longest = 0;
|
|
3504
|
+
var runs = String(snippet).match(/\`+/g);
|
|
3505
|
+
if (runs) for (var i = 0; i < runs.length; i++) if (runs[i].length > longest) longest = runs[i].length;
|
|
3506
|
+
return Array(Math.max(3, longest + 1) + 1).join('\`');
|
|
3507
|
+
}
|
|
3508
|
+
|
|
1250
3509
|
// Plain-text, copy-pasteable prompt for a single finding — same ingredients as the
|
|
1251
3510
|
// agent reporter's per-finding block (rule id, location, recommendation, fix, docs),
|
|
1252
3511
|
// reshaped for a standalone request rather than a whole-project remediation doc.
|
|
1253
3512
|
function buildAiPrompt(issue, route) {
|
|
1254
3513
|
var lines = ['Fix this svelte-vitals finding:', ''];
|
|
1255
|
-
lines.push('- Rule: ' + issue.id + ' — ' + issue.title + ' (' + issue.severity + ')');
|
|
1256
|
-
if (route) lines.push('- Route: ' + route);
|
|
3514
|
+
lines.push('- Rule: ' + issue.id + ' — ' + mdSafe(issue.title) + ' (' + issue.severity + ')');
|
|
3515
|
+
if (route) lines.push('- Route: ' + mdSafe(route));
|
|
1257
3516
|
if (issue.location) {
|
|
1258
|
-
lines.push('- Location: ' + issue.location + (issue.line !== undefined ? ':' + issue.line : ''));
|
|
3517
|
+
lines.push('- Location: ' + mdSafe(issue.location) + (issue.line !== undefined ? ':' + issue.line : ''));
|
|
1259
3518
|
}
|
|
1260
|
-
if (issue.recommendation) lines.push('- Recommendation: ' + issue.recommendation);
|
|
3519
|
+
if (issue.recommendation) lines.push('- Recommendation: ' + mdSafe(issue.recommendation));
|
|
1261
3520
|
if (issue.fix) {
|
|
1262
|
-
lines.push('- Fix: ' + issue.fix.description);
|
|
3521
|
+
lines.push('- Fix: ' + mdSafe(issue.fix.description));
|
|
1263
3522
|
if (issue.fix.snippet) {
|
|
1264
|
-
|
|
3523
|
+
var fence = fenceFor(issue.fix.snippet);
|
|
3524
|
+
lines.push('', fence + (issue.fix.lang || 'svelte'), issue.fix.snippet, fence);
|
|
1265
3525
|
}
|
|
1266
3526
|
}
|
|
1267
|
-
if (issue.docsUrl) lines.push('- Docs: ' + issue.docsUrl);
|
|
3527
|
+
if (issue.docsUrl) lines.push('- Docs: ' + mdSafe(issue.docsUrl));
|
|
1268
3528
|
lines.push(
|
|
1269
3529
|
'',
|
|
1270
3530
|
'After fixing, re-run \`svelte-vitals --diff\` (or revisit this route) to confirm ' +
|
|
1271
3531
|
issue.id +
|
|
1272
3532
|
' passes' +
|
|
1273
|
-
(route ? ' for ' + route : '') +
|
|
3533
|
+
(route ? ' for ' + mdSafe(route) : '') +
|
|
1274
3534
|
'.'
|
|
1275
3535
|
);
|
|
1276
3536
|
return lines.join('\\n');
|
|
@@ -1707,4 +3967,4 @@ function formatHtmlReport(results, config, meta) {
|
|
|
1707
3967
|
return buildHtmlDocument(buildJsonReport(results, config, meta), meta);
|
|
1708
3968
|
}
|
|
1709
3969
|
//#endregion
|
|
1710
|
-
export {
|
|
3970
|
+
export { ANCESTRY_DEPENDENT_TAGS, APP_SCRIPT, ASIDE_DEMOTING_TAGS, CHILD_NODE_KEYS, IDREF_ATTRS, NAMING_ATTRS, ROBOTS_SOURCE_PATHS, SECTIONING_TAGS, SITEMAP_SOURCE_PATHS, SVELTE_CONFIG_FILES, VITE_CONFIG_FILES, a11yAbbrTitle, a11yAccessibleName, a11yAriaHiddenFocus, a11yDeprecatedAria, a11yDeprecatedAttr, a11yDeprecatedElement, a11yDisallowedAriaProps, a11yDisallowedElement, a11yDoctype, a11yDuplicateLandmark, a11yIdDuplication, a11yInteractiveNesting, a11yInvalidAriaValue, a11yInvalidRole, a11yLabelHasControl, a11yNoAccesskey, a11yNoAutofocus, a11yNoDuplicateDt, a11yNoMissingIdRef, a11yPatternTitle, a11yPermittedContents, a11yPlaceholderLabelOption, a11yPositiveTabindex, a11yRequireDatetime, a11yRequiredAriaProps, a11yRequiredElement, a11yTopLevelLandmark, a11yUnknownAriaAttribute, a11yUnverifiedIdRef, a11yUseList, addFactsDirectives, allRules, architectureComponentSize, architectureDirectoryNaming, architectureDocLinkTarget, architecturePrivateScopeImport, architecturePropCount, architectureReservedDirectoryNames, architectureReservedNamePlacement, architectureRouteComponentImport, architectureUnitEntryFile, attrText, attrTextOf, attrValue, attrValueOf, buildHtmlDocument, buildJsonReport, classify, collectComponentFacts, collectKitModuleFacts, collectSourceFiles, collectSuppressions, compileOverrides, computeHealth, computeScore, correctnessAutoplayMuted, correctnessBasePathNavigation, correctnessCheckableBindValue, correctnessEachIndexKey, correctnessEachKey, correctnessEffectAsDerived, correctnessEffectAsOnMount, correctnessInstanceBrowserGlobal, correctnessNonreactiveBuiltinState, correctnessOrphanEffect, correctnessOrphanLifecycle, correctnessPropMutation, correctnessServerBrowserGlobal, correctnessStalePropDerivation, correctnessUnmutatedState, decodeFragmentId, defaultConfig, defaultProject, docsUrlFor, effectiveSeverity, escapeHtml, explainRule, findAttr, findMinifyDisabled, foldOccurrences, formatAgentReport, formatConsoleReport, formatFailedRuleWarning, formatGithubReport, formatHtmlReport, formatJsonReport, formatMarkdownReport, formatSarifReport, hasFailureAtOrAbove, isClassicScriptType, isPenalized, isTopFragment, lineOf, noColorPalette, parseComponentFacts, parseSvelte, performanceFontPreloadCrossorigin, performanceHeavyImport, performanceIframeLoading, performanceImageDimensions, performanceImageLoadingHint, performanceLcpImage, performanceLoadWaterfall, performanceMinifyDisabled, performanceNamespaceImport, performancePreconnect, performancePreloadMissingAs, performanceRenderBlockingScript, performanceResponsiveImage, performanceSequentialAwaits, performanceStateRaw, renderAppShell, resolveKitAliases, resolveKitPathsBase, resolveLandmark, resolveRepoLocalPath, resolveRuleOptions, runAnalysis, runRules, safeHref, scoreColor, securityHandlerStateWrite, securityJavascriptUrl, securityRawHtml, securityServerModuleState, securitySharedStateImport, selectRules, seoCanonicalUrl, seoCharset, seoDescriptionLength, seoDescriptionPresence, seoDuplicateDescription, seoDuplicateTitle, seoHeadingLevelSkip, seoHreflang, seoHtmlLang, seoImageAlt, seoIndexability, seoJsonLd, seoJsonLdDateFormat, seoJsonLdDeprecatedType, seoJsonLdPlaceholder, seoJsonLdRelativeUrl, seoJsonLdRequiredProps, seoJsonLdValidity, seoOgDescription, seoOgImage, seoOgTitle, seoOgUrl, seoRobotsTxt, seoSingleH1, seoSitemapInRobots, seoSitemapXml, seoSsrDisabled, seoTitleLength, seoTitlePresence, seoTwitterCard, seoViewport, settingSeverity, shouldSkipRangeCheck, skippedFileWarnings, splitTokens, stripTextDirective, summarize, terminalSafe, textFromNodes, unknownDirectiveIds, validateRuleSetting, valueFromNodes, withFailedRulesOff, withReadLimit };
|